import "server-only" import { cache } from "react" import { and, count, desc, eq, inArray } from "drizzle-orm" import { createId } from "@paralleldrive/cuid2" import { db } from "@/shared/db" import { practiceAnswers, practiceSessions, questions, } from "@/shared/db/schema" import { selectQuestionsForPractice } from "./data-access-strategy" import { autoGradeAnswer } from "./lib/grading" import { asPracticeAnswerStatus, asPracticeSourceMeta, asPracticeStatus, asPracticeType, } from "./lib/type-guards" import { practiceErrors } from "./lib/errors" import type { PracticeAnswerRecord, PracticeSessionDetail, PracticeSessionSummary, PracticeSourceMeta, PracticeStats, PracticeStatus, PracticeType, } from "./types" // --------------------------------------------------------------------------- // 行映射 // --------------------------------------------------------------------------- function mapSessionRow(row: typeof practiceSessions.$inferSelect): PracticeSessionSummary { const answeredQuestions = row.answeredQuestions const correctCount = row.correctCount return { id: row.id, studentId: row.studentId, subjectId: row.subjectId, practiceType: asPracticeType(row.practiceType), status: asPracticeStatus(row.status), totalQuestions: row.totalQuestions, answeredQuestions, correctCount, accuracy: answeredQuestions > 0 ? correctCount / answeredQuestions : 0, startedAt: row.startedAt, completedAt: row.completedAt, createdAt: row.createdAt, } } function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & { question?: typeof questions.$inferSelect | null }): PracticeAnswerRecord { return { id: row.id, sessionId: row.sessionId, questionId: row.questionId, variantContent: row.variantContent, isVariant: row.isVariant, orderIndex: row.orderIndex, status: asPracticeAnswerStatus(row.status), studentAnswer: row.studentAnswer, isCorrect: row.isCorrect, score: row.score, maxScore: row.maxScore, answeredAt: row.answeredAt, question: row.question ? { id: row.question.id, content: row.question.content, type: row.question.type, difficulty: row.question.difficulty, } : null, } } // --------------------------------------------------------------------------- // 查询:练习会话列表 // --------------------------------------------------------------------------- export const getPracticeSessions = cache(async ( studentId: string, options?: { status?: PracticeStatus practiceType?: PracticeType page?: number pageSize?: number }, ): Promise<{ data: PracticeSessionSummary[]; total: number }> => { const page = options?.page ?? 1 const pageSize = options?.pageSize ?? 20 const offset = (page - 1) * pageSize const conditions = [eq(practiceSessions.studentId, studentId)] if (options?.status) { conditions.push(eq(practiceSessions.status, options.status)) } if (options?.practiceType) { conditions.push(eq(practiceSessions.practiceType, options.practiceType)) } const whereClause = and(...conditions) const [totalResult] = await db .select({ value: count() }) .from(practiceSessions) .where(whereClause) const total = Number(totalResult?.value ?? 0) const rows = await db .select() .from(practiceSessions) .where(whereClause) .orderBy(desc(practiceSessions.createdAt)) .limit(pageSize) .offset(offset) return { data: rows.map(mapSessionRow), total, } }) // --------------------------------------------------------------------------- // 查询:练习会话详情(含答题记录) // --------------------------------------------------------------------------- export const getPracticeSessionById = cache(async ( sessionId: string, studentId: string, ): Promise => { const session = await db.query.practiceSessions.findFirst({ where: and( eq(practiceSessions.id, sessionId), eq(practiceSessions.studentId, studentId), ), }) if (!session) return null const answers = await db .select() .from(practiceAnswers) .where(eq(practiceAnswers.sessionId, sessionId)) .orderBy(practiceAnswers.orderIndex) // 批量查询题目内容 const questionIds = answers.map((a) => a.questionId) const questionMap = new Map() if (questionIds.length > 0) { const questionRows = await db .select() .from(questions) .where(inArray(questions.id, questionIds)) for (const q of questionRows) { questionMap.set(q.id, q) } } const mappedAnswers: PracticeAnswerRecord[] = answers.map((a) => { const question = questionMap.get(a.questionId) ?? null return mapAnswerRow({ ...a, question }) }) const summary = mapSessionRow(session) return { ...summary, sourceMeta: asPracticeSourceMeta(session.sourceMeta), answers: mappedAnswers, } }) // --------------------------------------------------------------------------- // 查询:练习统计 // --------------------------------------------------------------------------- export const getPracticeStats = cache(async (studentId: string): Promise => { const rows = await db .select({ practiceType: practiceSessions.practiceType, status: practiceSessions.status, totalQuestions: practiceSessions.totalQuestions, answeredQuestions: practiceSessions.answeredQuestions, correctCount: practiceSessions.correctCount, }) .from(practiceSessions) .where(eq(practiceSessions.studentId, studentId)) const totalSessions = rows.length let completedSessions = 0 let totalQuestionsAnswered = 0 let totalCorrect = 0 const byTypeMap = new Map() for (const row of rows) { if (row.status === "completed") { completedSessions++ } totalQuestionsAnswered += row.answeredQuestions totalCorrect += row.correctCount const stat = byTypeMap.get(row.practiceType) ?? { sessionCount: 0, totalQuestions: 0, correctCount: 0 } stat.sessionCount++ stat.totalQuestions += row.totalQuestions stat.correctCount += row.correctCount byTypeMap.set(row.practiceType, stat) } const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({ practiceType: asPracticeType(type), sessionCount: stat.sessionCount, totalQuestions: stat.totalQuestions, correctCount: stat.correctCount, accuracy: stat.totalQuestions > 0 ? stat.correctCount / stat.totalQuestions : 0, })) return { totalSessions, completedSessions, totalQuestionsAnswered, totalCorrect, overallAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0, byType, } }) // --------------------------------------------------------------------------- // 写入:创建练习会话 // --------------------------------------------------------------------------- /** * 创建练习会话。 * * 1. 根据练习类型调用出题策略选择题目 * 2. 创建会话记录 * 3. 创建答题记录(初始状态为 pending) * * @throws {PracticeError} 未找到题目时抛 no_questions_found * * @returns 会话 ID 和选中的题目数量 */ export async function createPracticeSession( studentId: string, input: { practiceType: PracticeType subjectId?: string sourceMeta: PracticeSourceMeta questionCount?: number }, ): Promise<{ sessionId: string; selectedCount: number }> { const { practiceType, sourceMeta, questionCount = 10 } = input // 调用出题策略选择题目 const selection = await selectQuestionsForPractice( studentId, practiceType, sourceMeta, questionCount, ) if (selection.questionIds.length === 0) { throw practiceErrors.noQuestionsFound() } const sessionId = createId() const now = new Date() // 事务:创建会话 + 答题记录 await db.transaction(async (tx) => { await tx.insert(practiceSessions).values({ id: sessionId, studentId, subjectId: input.subjectId ?? null, practiceType, // sourceMeta 已在上层通过 parseSourceMeta 校验,此处直接写入 sourceMeta: sourceMeta as unknown, status: "in_progress", totalQuestions: selection.questionIds.length, answeredQuestions: 0, correctCount: 0, startedAt: now, }) // 批量插入答题记录 const answerRows = selection.questionIds.map((questionId, index) => ({ id: createId(), sessionId, studentId, questionId, variantContent: selection.variants.get(questionId) ?? null, isVariant: selection.variants.has(questionId), orderIndex: index, status: "pending" as const, maxScore: 1, })) await tx.insert(practiceAnswers).values(answerRows) }) return { sessionId, selectedCount: selection.questionIds.length } } // --------------------------------------------------------------------------- // 写入:提交单题答案 // --------------------------------------------------------------------------- /** * 提交单题答案并自动判分。 * * 自动判分逻辑: * - 选择题/判断题:通过 extractCorrectAnswer 比对答案 * - 填空题:暂不自动判分(isCorrect = null) * * 并发安全:整个校验+判分+统计更新流程包裹在事务中, * 对答题记录加行锁(SELECT ... FOR UPDATE), * 防止同一答案被并发重复判分导致统计累加错误。 * * @throws {PracticeError} 会话/答题记录不存在、已结束、已作答时抛对应错误码 * * @returns 是否判分成功 */ export async function submitPracticeAnswer( sessionId: string, studentId: string, answerId: string, answer: unknown, skip: boolean = false, ): Promise<{ isCorrect: boolean | null; score: number | null }> { // 事务:行锁 + 校验 + 判分 + 统计更新(防止并发重复判分) return await db.transaction(async (tx) => { // 1. 校验会话归属(带行锁) const [session] = await tx .select() .from(practiceSessions) .where(and( eq(practiceSessions.id, sessionId), eq(practiceSessions.studentId, studentId), )) .for("update") if (!session) { throw practiceErrors.sessionNotFound() } if (session.status !== "in_progress") { throw practiceErrors.sessionEnded() } // 2. 查询答题记录(带行锁,防止并发重复提交) const [answerRecord] = await tx .select() .from(practiceAnswers) .where(and( eq(practiceAnswers.id, answerId), eq(practiceAnswers.sessionId, sessionId), )) .for("update") if (!answerRecord) { throw practiceErrors.answerNotFound() } if (answerRecord.status === "answered") { throw practiceErrors.answerAlreadySubmitted() } const now = new Date() if (skip) { // 跳过此题:状态置为 skipped,不累加已答题数与正确数 await tx .update(practiceAnswers) .set({ status: "skipped", answeredAt: now, }) .where(eq(practiceAnswers.id, answerId)) // 累加已答题数(不累加正确数) await tx .update(practiceSessions) .set({ answeredQuestions: session.answeredQuestions + 1, }) .where(eq(practiceSessions.id, sessionId)) return { isCorrect: null, score: null } } // 3. 自动判分:查询题目内容并提取正确答案 const [question] = await tx .select() .from(questions) .where(eq(questions.id, answerRecord.questionId)) .limit(1) if (!question) { throw practiceErrors.questionNotFound() } // 如果是变式题,使用变式题内容 const contentToUse = answerRecord.variantContent ?? question.content const isCorrect = autoGradeAnswer(question.type, contentToUse, answer) const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null) // 4. 更新答题记录 await tx .update(practiceAnswers) .set({ status: "answered", studentAnswer: answer, isCorrect, score, answeredAt: now, }) .where(eq(practiceAnswers.id, answerId)) // 5. 累加会话统计(基于步骤 1 已加锁的 session 行) await tx .update(practiceSessions) .set({ answeredQuestions: session.answeredQuestions + 1, correctCount: session.correctCount + (isCorrect === true ? 1 : 0), }) .where(eq(practiceSessions.id, sessionId)) return { isCorrect, score } }) } // --------------------------------------------------------------------------- // 写入:完成/放弃练习会话 // --------------------------------------------------------------------------- /** * 完成练习会话。 * * 完整性校验:必须答完所有题目(answeredQuestions === totalQuestions)才能完成, * 防止学生提前完成导致统计失真。 * * 注意:跳过的题目也算"已作答"(status=skipped),与 answeredQuestions 累加逻辑一致。 * * @throws {PracticeError} 会话不存在 → session_not_found;未答完 → session_not_complete */ export async function completePracticeSession( sessionId: string, studentId: string, ): Promise { const session = await db.query.practiceSessions.findFirst({ where: and( eq(practiceSessions.id, sessionId), eq(practiceSessions.studentId, studentId), ), }) if (!session) { throw practiceErrors.sessionNotFound() } if (session.status !== "in_progress") { // 已完成或已放弃,幂等返回 return } // 完整性校验:必须答完所有题目 if (session.answeredQuestions !== session.totalQuestions) { throw practiceErrors.sessionNotComplete() } await db .update(practiceSessions) .set({ status: "completed", completedAt: new Date(), }) .where(eq(practiceSessions.id, sessionId)) } /** * 放弃练习会话。 * * 幂等:已完成或已放弃的会话再次调用不会报错。 * * @throws {PracticeError} 会话不存在 → session_not_found */ export async function abandonPracticeSession( sessionId: string, studentId: string, ): Promise { const session = await db.query.practiceSessions.findFirst({ where: and( eq(practiceSessions.id, sessionId), eq(practiceSessions.studentId, studentId), ), }) if (!session) { throw practiceErrors.sessionNotFound() } if (session.status !== "in_progress") { // 已完成或已放弃,幂等返回 return } await db .update(practiceSessions) .set({ status: "abandoned", completedAt: new Date(), }) .where(eq(practiceSessions.id, sessionId)) } // --------------------------------------------------------------------------- // 内部辅助函数 // --------------------------------------------------------------------------- // 自动判分、答案归一化等纯函数已抽取至 lib/grading.ts,便于单测与复用。 // 会话统计累加逻辑已内联到 submitPracticeSession 事务中,确保原子性。