parent: - Add parent-student-attendance-detail component auth: - Add actions, data-access, schema, services, types onboarding: - Add parent-children-form and hooks directory files: - Add actions, schema, hooks directory notifications: - Add schema and schema test adaptive-practice: - Add answer-input, answer-result, practice-result-view, practice-starter-with-nav - Add question-card, question-content, lib and services directories ai: - Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
535 lines
15 KiB
TypeScript
535 lines
15 KiB
TypeScript
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<PracticeSessionDetail | null> => {
|
||
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<string, typeof questions.$inferSelect>()
|
||
|
||
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<PracticeStats> => {
|
||
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<string, { sessionCount: number; totalQuestions: number; correctCount: number }>()
|
||
|
||
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<void> {
|
||
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<void> {
|
||
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 事务中,确保原子性。
|