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
This commit is contained in:
@@ -11,10 +11,17 @@ import {
|
||||
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,
|
||||
PracticeAnswerStatus,
|
||||
PracticeSessionDetail,
|
||||
PracticeSessionSummary,
|
||||
PracticeSourceMeta,
|
||||
@@ -34,8 +41,8 @@ function mapSessionRow(row: typeof practiceSessions.$inferSelect): PracticeSessi
|
||||
id: row.id,
|
||||
studentId: row.studentId,
|
||||
subjectId: row.subjectId,
|
||||
practiceType: row.practiceType as PracticeType,
|
||||
status: row.status as PracticeStatus,
|
||||
practiceType: asPracticeType(row.practiceType),
|
||||
status: asPracticeStatus(row.status),
|
||||
totalQuestions: row.totalQuestions,
|
||||
answeredQuestions,
|
||||
correctCount,
|
||||
@@ -56,7 +63,7 @@ function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & {
|
||||
variantContent: row.variantContent,
|
||||
isVariant: row.isVariant,
|
||||
orderIndex: row.orderIndex,
|
||||
status: row.status as PracticeAnswerStatus,
|
||||
status: asPracticeAnswerStatus(row.status),
|
||||
studentAnswer: row.studentAnswer,
|
||||
isCorrect: row.isCorrect,
|
||||
score: row.score,
|
||||
@@ -170,7 +177,7 @@ export const getPracticeSessionById = cache(async (
|
||||
|
||||
return {
|
||||
...summary,
|
||||
sourceMeta: session.sourceMeta as PracticeSourceMeta | null,
|
||||
sourceMeta: asPracticeSourceMeta(session.sourceMeta),
|
||||
answers: mappedAnswers,
|
||||
}
|
||||
})
|
||||
@@ -213,7 +220,7 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
|
||||
}
|
||||
|
||||
const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({
|
||||
practiceType: type as PracticeType,
|
||||
practiceType: asPracticeType(type),
|
||||
sessionCount: stat.sessionCount,
|
||||
totalQuestions: stat.totalQuestions,
|
||||
correctCount: stat.correctCount,
|
||||
@@ -241,6 +248,8 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
|
||||
* 2. 创建会话记录
|
||||
* 3. 创建答题记录(初始状态为 pending)
|
||||
*
|
||||
* @throws {PracticeError} 未找到题目时抛 no_questions_found
|
||||
*
|
||||
* @returns 会话 ID 和选中的题目数量
|
||||
*/
|
||||
export async function createPracticeSession(
|
||||
@@ -263,7 +272,7 @@ export async function createPracticeSession(
|
||||
)
|
||||
|
||||
if (selection.questionIds.length === 0) {
|
||||
return { sessionId: "", selectedCount: 0 }
|
||||
throw practiceErrors.noQuestionsFound()
|
||||
}
|
||||
|
||||
const sessionId = createId()
|
||||
@@ -276,6 +285,7 @@ export async function createPracticeSession(
|
||||
studentId,
|
||||
subjectId: input.subjectId ?? null,
|
||||
practiceType,
|
||||
// sourceMeta 已在上层通过 parseSourceMeta 校验,此处直接写入
|
||||
sourceMeta: sourceMeta as unknown,
|
||||
status: "in_progress",
|
||||
totalQuestions: selection.questionIds.length,
|
||||
@@ -314,6 +324,12 @@ export async function createPracticeSession(
|
||||
* - 选择题/判断题:通过 extractCorrectAnswer 比对答案
|
||||
* - 填空题:暂不自动判分(isCorrect = null)
|
||||
*
|
||||
* 并发安全:整个校验+判分+统计更新流程包裹在事务中,
|
||||
* 对答题记录加行锁(SELECT ... FOR UPDATE),
|
||||
* 防止同一答案被并发重复判分导致统计累加错误。
|
||||
*
|
||||
* @throws {PracticeError} 会话/答题记录不存在、已结束、已作答时抛对应错误码
|
||||
*
|
||||
* @returns 是否判分成功
|
||||
*/
|
||||
export async function submitPracticeAnswer(
|
||||
@@ -323,95 +339,123 @@ export async function submitPracticeAnswer(
|
||||
answer: unknown,
|
||||
skip: boolean = false,
|
||||
): Promise<{ isCorrect: boolean | null; score: number | null }> {
|
||||
// 校验会话归属
|
||||
const session = await db.query.practiceSessions.findFirst({
|
||||
where: and(
|
||||
eq(practiceSessions.id, sessionId),
|
||||
eq(practiceSessions.studentId, studentId),
|
||||
),
|
||||
})
|
||||
// 事务:行锁 + 校验 + 判分 + 统计更新(防止并发重复判分)
|
||||
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 new Error("练习会话不存在或无权访问")
|
||||
}
|
||||
if (!session) {
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
throw new Error("练习会话已结束")
|
||||
}
|
||||
if (session.status !== "in_progress") {
|
||||
throw practiceErrors.sessionEnded()
|
||||
}
|
||||
|
||||
// 查询答题记录
|
||||
const answerRecord = await db.query.practiceAnswers.findFirst({
|
||||
where: and(
|
||||
eq(practiceAnswers.id, answerId),
|
||||
eq(practiceAnswers.sessionId, sessionId),
|
||||
),
|
||||
})
|
||||
// 2. 查询答题记录(带行锁,防止并发重复提交)
|
||||
const [answerRecord] = await tx
|
||||
.select()
|
||||
.from(practiceAnswers)
|
||||
.where(and(
|
||||
eq(practiceAnswers.id, answerId),
|
||||
eq(practiceAnswers.sessionId, sessionId),
|
||||
))
|
||||
.for("update")
|
||||
|
||||
if (!answerRecord) {
|
||||
throw new Error("答题记录不存在")
|
||||
}
|
||||
if (!answerRecord) {
|
||||
throw practiceErrors.answerNotFound()
|
||||
}
|
||||
|
||||
if (answerRecord.status === "answered") {
|
||||
throw new Error("此题已作答")
|
||||
}
|
||||
if (answerRecord.status === "answered") {
|
||||
throw practiceErrors.answerAlreadySubmitted()
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const now = new Date()
|
||||
|
||||
if (skip) {
|
||||
// 跳过此题
|
||||
await db
|
||||
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: "skipped",
|
||||
status: "answered",
|
||||
studentAnswer: answer,
|
||||
isCorrect,
|
||||
score,
|
||||
answeredAt: now,
|
||||
})
|
||||
.where(eq(practiceAnswers.id, answerId))
|
||||
|
||||
// 更新会话统计
|
||||
await updateSessionStats(sessionId, 0, false)
|
||||
return { isCorrect: null, score: null }
|
||||
}
|
||||
// 5. 累加会话统计(基于步骤 1 已加锁的 session 行)
|
||||
await tx
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
answeredQuestions: session.answeredQuestions + 1,
|
||||
correctCount: session.correctCount + (isCorrect === true ? 1 : 0),
|
||||
})
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
|
||||
// 自动判分:查询题目内容并提取正确答案
|
||||
const question = await db.query.questions.findFirst({
|
||||
where: eq(questions.id, answerRecord.questionId),
|
||||
return { isCorrect, score }
|
||||
})
|
||||
|
||||
if (!question) {
|
||||
throw new Error("题目不存在")
|
||||
}
|
||||
|
||||
// 如果是变式题,使用变式题内容
|
||||
const contentToUse = answerRecord.variantContent ?? question.content
|
||||
const isCorrect = autoGradeAnswer(question.type, contentToUse, answer)
|
||||
|
||||
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
|
||||
|
||||
await db
|
||||
.update(practiceAnswers)
|
||||
.set({
|
||||
status: "answered",
|
||||
studentAnswer: answer,
|
||||
isCorrect,
|
||||
score,
|
||||
answeredAt: now,
|
||||
})
|
||||
.where(eq(practiceAnswers.id, answerId))
|
||||
|
||||
// 更新会话统计
|
||||
await updateSessionStats(
|
||||
sessionId,
|
||||
1,
|
||||
isCorrect === true,
|
||||
)
|
||||
|
||||
return { isCorrect, score }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 写入:完成/放弃练习会话
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 完成练习会话。
|
||||
*
|
||||
* 完整性校验:必须答完所有题目(answeredQuestions === totalQuestions)才能完成,
|
||||
* 防止学生提前完成导致统计失真。
|
||||
*
|
||||
* 注意:跳过的题目也算"已作答"(status=skipped),与 answeredQuestions 累加逻辑一致。
|
||||
*
|
||||
* @throws {PracticeError} 会话不存在 → session_not_found;未答完 → session_not_complete
|
||||
*/
|
||||
export async function completePracticeSession(
|
||||
sessionId: string,
|
||||
studentId: string,
|
||||
@@ -424,13 +468,19 @@ export async function completePracticeSession(
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
throw new Error("练习会话不存在或无权访问")
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
// 已完成或已放弃,幂等返回
|
||||
return
|
||||
}
|
||||
|
||||
// 完整性校验:必须答完所有题目
|
||||
if (session.answeredQuestions !== session.totalQuestions) {
|
||||
throw practiceErrors.sessionNotComplete()
|
||||
}
|
||||
|
||||
await db
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
@@ -440,6 +490,13 @@ export async function completePracticeSession(
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 放弃练习会话。
|
||||
*
|
||||
* 幂等:已完成或已放弃的会话再次调用不会报错。
|
||||
*
|
||||
* @throws {PracticeError} 会话不存在 → session_not_found
|
||||
*/
|
||||
export async function abandonPracticeSession(
|
||||
sessionId: string,
|
||||
studentId: string,
|
||||
@@ -452,10 +509,11 @@ export async function abandonPracticeSession(
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
throw new Error("练习会话不存在或无权访问")
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
// 已完成或已放弃,幂等返回
|
||||
return
|
||||
}
|
||||
|
||||
@@ -472,148 +530,5 @@ export async function abandonPracticeSession(
|
||||
// 内部辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 更新会话统计(已答题数、正确数)。
|
||||
*/
|
||||
async function updateSessionStats(
|
||||
sessionId: string,
|
||||
newlyAnswered: number,
|
||||
newlyCorrect: boolean,
|
||||
): Promise<void> {
|
||||
const session = await db.query.practiceSessions.findFirst({
|
||||
where: eq(practiceSessions.id, sessionId),
|
||||
columns: {
|
||||
answeredQuestions: true,
|
||||
correctCount: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!session) return
|
||||
|
||||
await db
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
answeredQuestions: session.answeredQuestions + newlyAnswered,
|
||||
correctCount: session.correctCount + (newlyCorrect ? 1 : 0),
|
||||
})
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动判分:比对学生答案与正确答案。
|
||||
*
|
||||
* 支持题型:
|
||||
* - single_choice: 比对选中选项 ID
|
||||
* - multiple_choice: 比对选中选项 ID 集合(顺序无关)
|
||||
* - judgment: 比对布尔值
|
||||
* - text: 不自动判分(返回 null)
|
||||
*
|
||||
* @param questionType 题目类型
|
||||
* @param content 题目内容(或变式题内容)
|
||||
* @param studentAnswer 学生答案
|
||||
* @returns 是否正确(null 表示无法自动判分)
|
||||
*/
|
||||
function autoGradeAnswer(
|
||||
questionType: string,
|
||||
content: unknown,
|
||||
studentAnswer: unknown,
|
||||
): boolean | null {
|
||||
if (questionType === "single_choice" || questionType === "multiple_choice") {
|
||||
const correctIds = extractChoiceCorrectIds(content)
|
||||
if (correctIds.length === 0) return null
|
||||
|
||||
const studentIds = normalizeAnswerToIds(studentAnswer)
|
||||
if (studentIds.length === 0) return false
|
||||
|
||||
if (questionType === "single_choice") {
|
||||
return studentIds.length === 1 && studentIds[0] === correctIds[0]
|
||||
}
|
||||
|
||||
// multiple_choice: 集合比对
|
||||
if (studentIds.length !== correctIds.length) return false
|
||||
const correctSet = new Set(correctIds)
|
||||
return studentIds.every((id) => correctSet.has(id))
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const correctAnswer = extractJudgmentCorrectAnswer(content)
|
||||
if (correctAnswer === null) return null
|
||||
|
||||
const studentBool = normalizeAnswerToBool(studentAnswer)
|
||||
if (studentBool === null) return null
|
||||
|
||||
return studentBool === correctAnswer
|
||||
}
|
||||
|
||||
// text 题型不自动判分
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取选择题正确选项 ID 列表。
|
||||
*/
|
||||
function extractChoiceCorrectIds(content: unknown): string[] {
|
||||
if (!isRecord(content)) return []
|
||||
|
||||
const options = content.options
|
||||
if (!Array.isArray(options)) return []
|
||||
|
||||
return options
|
||||
.filter((opt: unknown) => isRecord(opt) && opt.isCorrect === true)
|
||||
.map((opt: unknown) => {
|
||||
const record = opt as Record<string, unknown>
|
||||
return typeof record.id === "string" ? record.id : ""
|
||||
})
|
||||
.filter((id: string) => id.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取判断题正确答案。
|
||||
*/
|
||||
function extractJudgmentCorrectAnswer(content: unknown): boolean | null {
|
||||
if (!isRecord(content)) return null
|
||||
|
||||
const answer = content.answer
|
||||
if (typeof answer === "boolean") return answer
|
||||
if (typeof answer === "string") {
|
||||
const lower = answer.toLowerCase()
|
||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 将学生答案归一化为选项 ID 列表。
|
||||
*/
|
||||
function normalizeAnswerToIds(answer: unknown): string[] {
|
||||
if (typeof answer === "string") return [answer]
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.filter((v): v is string => typeof v === "string")
|
||||
}
|
||||
if (isRecord(answer)) {
|
||||
const ids = answer.selectedIds
|
||||
if (Array.isArray(ids)) {
|
||||
return ids.filter((v): v is string => typeof v === "string")
|
||||
}
|
||||
if (typeof answer.id === "string") return [answer.id]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 将学生答案归一化为布尔值。
|
||||
*/
|
||||
function normalizeAnswerToBool(answer: unknown): boolean | null {
|
||||
if (typeof answer === "boolean") return answer
|
||||
if (typeof answer === "string") {
|
||||
const lower = answer.toLowerCase()
|
||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
// 自动判分、答案归一化等纯函数已抽取至 lib/grading.ts,便于单测与复用。
|
||||
// 会话统计累加逻辑已内联到 submitPracticeSession 事务中,确保原子性。
|
||||
|
||||
Reference in New Issue
Block a user