From 6033fbc99701d761711b4781833f9c5114a08b1f Mon Sep 17 00:00:00 2001 From: wangdl Date: Fri, 29 May 2026 20:03:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20H0-12=20Quiz=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=B8=8E=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- prisma/schema.prisma | 74 +++++++++++++++ src/app.module.ts | 2 + src/modules/quiz/quiz.controller.ts | 53 +++++++++++ src/modules/quiz/quiz.module.ts | 10 ++ src/modules/quiz/quiz.service.ts | 141 ++++++++++++++++++++++++++++ 5 files changed, 280 insertions(+) create mode 100644 src/modules/quiz/quiz.controller.ts create mode 100644 src/modules/quiz/quiz.module.ts create mode 100644 src/modules/quiz/quiz.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index db46024..793fc02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app.module.ts b/src/app.module.ts index c999267..8f26fe8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 }, diff --git a/src/modules/quiz/quiz.controller.ts b/src/modules/quiz/quiz.controller.ts new file mode 100644 index 0000000..4bdf4ed --- /dev/null +++ b/src/modules/quiz/quiz.controller.ts @@ -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')); + } +} diff --git a/src/modules/quiz/quiz.module.ts b/src/modules/quiz/quiz.module.ts new file mode 100644 index 0000000..7497007 --- /dev/null +++ b/src/modules/quiz/quiz.module.ts @@ -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 {} diff --git a/src/modules/quiz/quiz.service.ts b/src/modules/quiz/quiz.service.ts new file mode 100644 index 0000000..37558b0 --- /dev/null +++ b/src/modules/quiz/quiz.service.ts @@ -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 } } }, + }); + } +}