feat: H0-12 Quiz 模型与 API
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 11s
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 11s
Prisma 新增: - Quiz(测验) - QuizQuestion(题目,支持 choice/fill/judge 三种题型) - QuizAttempt(答题记录) - QuizAnswer(作答详情) API: - POST /quizzes(生成测验,自动从KB知识点抽题) - GET /quizzes(列表) - GET /quizzes/:id(含题目) - POST /quizzes/:id/start(开始答题) - POST /quizzes/:id/submit(提交答案+评分) - GET /quizzes/:id/results?attemptId=(结果详情) - GET /quizzes/history/list(历史记录) 题目生成策略: - 选择题:题干=知识点标题,选项=内容片段+其他知识点干扰项 - 填空题:随机关键词挖空 - 判断题:随机生成对/错陈述 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6ab54be309
commit
6033fbc997
@ -1569,3 +1569,77 @@ model VendorBill {
|
||||
@@unique([provider, billMonth])
|
||||
@@index([provider])
|
||||
}
|
||||
|
||||
// ── Quiz ──
|
||||
|
||||
model Quiz {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
knowledgeBaseId String
|
||||
title String @db.VarChar(255)
|
||||
description String? @db.Text
|
||||
questionCount Int @default(0)
|
||||
sourceType String @default("kb") @db.VarChar(16)
|
||||
sourceId String? @db.VarChar(100)
|
||||
status String @default("ready") @db.VarChar(16)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id])
|
||||
questions QuizQuestion[]
|
||||
attempts QuizAttempt[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([knowledgeBaseId])
|
||||
}
|
||||
|
||||
model QuizQuestion {
|
||||
id String @id @default(cuid())
|
||||
quizId String
|
||||
type String @db.VarChar(16)
|
||||
stem String @db.Text
|
||||
options Json?
|
||||
answer String @db.VarChar(500)
|
||||
explanation String? @db.Text
|
||||
orderIndex Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
quiz Quiz @relation(fields: [quizId], references: [id])
|
||||
answers QuizAnswer[]
|
||||
|
||||
@@index([quizId])
|
||||
}
|
||||
|
||||
model QuizAttempt {
|
||||
id String @id @default(cuid())
|
||||
quizId String
|
||||
userId String
|
||||
totalQuestions Int @default(0)
|
||||
correctCount Int @default(0)
|
||||
score Int @default(0)
|
||||
startedAt DateTime @default(now())
|
||||
finishedAt DateTime?
|
||||
|
||||
quiz Quiz @relation(fields: [quizId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
answers QuizAnswer[]
|
||||
|
||||
@@index([quizId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model QuizAnswer {
|
||||
id String @id @default(cuid())
|
||||
attemptId String
|
||||
questionId String
|
||||
userAnswer String @db.VarChar(500)
|
||||
isCorrect Boolean @default(false)
|
||||
answeredAt DateTime @default(now())
|
||||
|
||||
attempt QuizAttempt @relation(fields: [attemptId], references: [id])
|
||||
question QuizQuestion @relation(fields: [questionId], references: [id])
|
||||
|
||||
@@index([attemptId])
|
||||
@@index([questionId])
|
||||
}
|
||||
|
||||
@ -59,6 +59,7 @@ import { HermesAgentModule } from './modules/hermes-agent/hermes-agent.module';
|
||||
import { ReleaseModule } from './modules/release/release.module';
|
||||
import { ComplianceModule } from './modules/compliance/compliance.module';
|
||||
import { AdminNotificationsModule } from './modules/admin-notifications/admin-notifications.module';
|
||||
import { QuizModule } from './modules/quiz/quiz.module';
|
||||
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './common/guards/roles.guard';
|
||||
@ -163,6 +164,7 @@ import appleConfig from './config/apple.config';
|
||||
ReleaseModule,
|
||||
ComplianceModule,
|
||||
AdminNotificationsModule,
|
||||
QuizModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: RateLimitGuard },
|
||||
|
||||
53
src/modules/quiz/quiz.controller.ts
Normal file
53
src/modules/quiz/quiz.controller.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { QuizService } from './quiz.service';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import type { UserPayload } from '../../common/types';
|
||||
|
||||
@ApiTags('quiz')
|
||||
@Controller('quizzes')
|
||||
export class QuizController {
|
||||
constructor(private readonly service: QuizService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '生成测验' })
|
||||
async create(@CurrentUser() user: UserPayload, @Body() dto: any) {
|
||||
return this.service.create(String(user?.id || 'anonymous'), dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '测验列表' })
|
||||
async findAll(@CurrentUser() user: UserPayload, @Query('knowledgeBaseId') kbId?: string) {
|
||||
return this.service.findAll(String(user?.id || 'anonymous'), kbId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '测验详情(含题目)' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post(':id/start')
|
||||
@ApiOperation({ summary: '开始答题' })
|
||||
async start(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
||||
return this.service.start(String(user?.id || 'anonymous'), id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@ApiOperation({ summary: '提交答案' })
|
||||
async submit(@CurrentUser() user: UserPayload, @Param('id') id: string, @Body() dto: { attemptId: string; answers: { questionId: string; answer: string }[] }) {
|
||||
return this.service.submit(String(user?.id || 'anonymous'), dto.attemptId, dto.answers);
|
||||
}
|
||||
|
||||
@Get(':id/results')
|
||||
@ApiOperation({ summary: '测验结果' })
|
||||
async getResults(@Param('id') id: string, @Query('attemptId') attemptId: string) {
|
||||
return this.service.getResults(attemptId);
|
||||
}
|
||||
|
||||
@Get('history/list')
|
||||
@ApiOperation({ summary: '测验历史' })
|
||||
async getHistory(@CurrentUser() user: UserPayload) {
|
||||
return this.service.getHistory(String(user?.id || 'anonymous'));
|
||||
}
|
||||
}
|
||||
10
src/modules/quiz/quiz.module.ts
Normal file
10
src/modules/quiz/quiz.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { QuizController } from './quiz.controller';
|
||||
import { QuizService } from './quiz.service';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
@Module({
|
||||
controllers: [QuizController],
|
||||
providers: [QuizService, PrismaService],
|
||||
})
|
||||
export class QuizModule {}
|
||||
141
src/modules/quiz/quiz.service.ts
Normal file
141
src/modules/quiz/quiz.service.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class QuizService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(userId: string, dto: { knowledgeBaseId: string; title?: string; sourceType?: string; sourceId?: string; questionCount?: number }) {
|
||||
const count = dto.questionCount ?? 5;
|
||||
const kb = await this.prisma.knowledgeBase.findUnique({ where: { id: dto.knowledgeBaseId } });
|
||||
if (!kb || kb.deletedAt) throw new NotFoundException('知识库不存在');
|
||||
|
||||
const quiz = await this.prisma.quiz.create({
|
||||
data: {
|
||||
userId, knowledgeBaseId: dto.knowledgeBaseId,
|
||||
title: dto.title || `${kb.title} - 自测`,
|
||||
sourceType: dto.sourceType ?? 'kb', sourceId: dto.sourceId ?? null,
|
||||
questionCount: count,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate questions from KB items
|
||||
const items = await this.prisma.knowledgeItem.findMany({
|
||||
where: { knowledgeBaseId: dto.knowledgeBaseId, deletedAt: null },
|
||||
orderBy: { updatedAt: 'desc' }, take: count * 2,
|
||||
});
|
||||
|
||||
if (items.length === 0) throw new BadRequestException('知识库中没有知识点,无法生成测验');
|
||||
|
||||
const shuffled = items.sort(() => Math.random() - 0.5).slice(0, count);
|
||||
const questions: any[] = [];
|
||||
|
||||
for (let i = 0; i < shuffled.length; i++) {
|
||||
const item = shuffled[i];
|
||||
const otherItems = items.filter(x => x.id !== item.id);
|
||||
|
||||
// Alternate question types
|
||||
const types = ['choice', 'fill', 'judge'];
|
||||
const qType = types[i % 3];
|
||||
|
||||
let stem = '', options: string[] = [], answer = '';
|
||||
|
||||
if (qType === 'choice') {
|
||||
stem = `${item.title} 是什么?`;
|
||||
const correct = item.content?.slice(0, 80) ?? '正确答案';
|
||||
options = [correct];
|
||||
for (const o of otherItems.slice(0, 3)) {
|
||||
options.push(o.content?.slice(0, 80) ?? '其他选项');
|
||||
}
|
||||
options = options.sort(() => Math.random() - 0.5);
|
||||
answer = String(options.indexOf(correct));
|
||||
} else if (qType === 'fill') {
|
||||
const words = (item.content ?? '').split(/[,。;\s]+/).filter(w => w.length >= 3);
|
||||
const blank = words.length > 0 ? words[Math.floor(Math.random() * words.length)] : '关键概念';
|
||||
stem = `${item.title}:请填写缺失的关键词。${(item.content ?? '').replace(blank, '____')}`;
|
||||
answer = blank;
|
||||
} else {
|
||||
const isCorrect = Math.random() > 0.5;
|
||||
stem = `关于「${item.title}」,以下说法是否正确?${isCorrect ? item.content?.slice(0, 100) ?? '正确描述' : '错误描述'}`;
|
||||
answer = String(isCorrect);
|
||||
}
|
||||
|
||||
questions.push({
|
||||
quizId: quiz.id, type: qType, stem,
|
||||
options: options.length > 0 ? options : undefined,
|
||||
answer, explanation: item.content?.slice(0, 200) ?? '',
|
||||
orderIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.quizQuestion.createMany({ data: questions });
|
||||
|
||||
return this.prisma.quiz.findUnique({ where: { id: quiz.id }, include: { questions: { orderBy: { orderIndex: 'asc' } } } });
|
||||
}
|
||||
|
||||
async findAll(userId: string, kbId?: string) {
|
||||
return this.prisma.quiz.findMany({
|
||||
where: { userId, ...(kbId ? { knowledgeBaseId: kbId } : {}) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const quiz = await this.prisma.quiz.findUnique({
|
||||
where: { id }, include: { questions: { orderBy: { orderIndex: 'asc' } } },
|
||||
});
|
||||
if (!quiz) throw new NotFoundException('测验不存在');
|
||||
return quiz;
|
||||
}
|
||||
|
||||
async start(userId: string, quizId: string) {
|
||||
const quiz = await this.prisma.quiz.findUnique({ where: { id: quizId } });
|
||||
if (!quiz) throw new NotFoundException('测验不存在');
|
||||
|
||||
return this.prisma.quizAttempt.create({
|
||||
data: { quizId, userId, totalQuestions: quiz.questionCount },
|
||||
});
|
||||
}
|
||||
|
||||
async submit(userId: string, attemptId: string, answers: { questionId: string; answer: string }[]) {
|
||||
const attempt = await this.prisma.quizAttempt.findUnique({ where: { id: attemptId } });
|
||||
if (!attempt || attempt.userId !== userId) throw new NotFoundException('答题记录不存在');
|
||||
if (attempt.finishedAt) throw new BadRequestException('已提交过答案');
|
||||
|
||||
let correctCount = 0;
|
||||
|
||||
for (const a of answers) {
|
||||
const q = await this.prisma.quizQuestion.findUnique({ where: { id: a.questionId } });
|
||||
const isCorrect = q?.answer === a.answer;
|
||||
if (isCorrect) correctCount++;
|
||||
await this.prisma.quizAnswer.create({
|
||||
data: { attemptId, questionId: a.questionId, userAnswer: a.answer, isCorrect },
|
||||
});
|
||||
}
|
||||
|
||||
const score = answers.length > 0 ? Math.round((correctCount / answers.length) * 100) : 0;
|
||||
|
||||
await this.prisma.quizAttempt.update({
|
||||
where: { id: attemptId },
|
||||
data: { correctCount, score, finishedAt: new Date() },
|
||||
});
|
||||
|
||||
return { score, correctCount, totalQuestions: answers.length, finishedAt: new Date() };
|
||||
}
|
||||
|
||||
async getResults(attemptId: string) {
|
||||
const attempt = await this.prisma.quizAttempt.findUnique({
|
||||
where: { id: attemptId },
|
||||
include: { answers: { include: { question: true } } },
|
||||
});
|
||||
if (!attempt) throw new NotFoundException('答题记录不存在');
|
||||
return attempt;
|
||||
}
|
||||
|
||||
async getHistory(userId: string) {
|
||||
return this.prisma.quizAttempt.findMany({
|
||||
where: { userId }, orderBy: { startedAt: 'desc' }, take: 50,
|
||||
include: { quiz: { select: { title: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user