Files
NextEdu/src/modules/adaptive-practice/data-access.ts
SpecialX e9a5264fe7 feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates
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
2026-07-03 10:26:12 +08:00

535 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 事务中,确保原子性。