diff --git a/drizzle/0010_grade_record_answers.sql b/drizzle/0010_grade_record_answers.sql new file mode 100644 index 0000000..f0c4e36 --- /dev/null +++ b/drizzle/0010_grade_record_answers.sql @@ -0,0 +1,14 @@ +CREATE TABLE `grade_record_answers` ( + `id` varchar(128) PRIMARY KEY NOT NULL, + `grade_record_id` varchar(128) NOT NULL, + `question_id` varchar(128) NOT NULL, + `score` decimal(6, 2) NOT NULL, + `full_score` decimal(6, 2) NOT NULL, + `feedback` text, + `created_at` timestamp DEFAULT (now()) NOT NULL, + `updated_at` timestamp DEFAULT (now()) NOT NULL, + CONSTRAINT `gra_gr_fk` FOREIGN KEY (`grade_record_id`) REFERENCES `grade_records`(`id`) ON DELETE cascade ON UPDATE no action, + CONSTRAINT `gra_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action +); +CREATE INDEX `grade_record_answers_record_idx` ON `grade_record_answers`(`grade_record_id`); +CREATE INDEX `grade_record_answers_question_idx` ON `grade_record_answers`(`question_id`); diff --git a/src/shared/db/schema.ts b/src/shared/db/schema.ts index c8b4fd6..db6f478 100644 --- a/src/shared/db/schema.ts +++ b/src/shared/db/schema.ts @@ -159,7 +159,7 @@ export const knowledgePointPrerequisites = mysqlTable("knowledge_point_prerequis prerequisiteKpId: varchar("prerequisite_kp_id", { length: 128 }).notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => ({ - kpPairPk: primaryKey({ columns: [table.knowledgePointId, table.prerequisiteKpId] }), + kpPairPk: primaryKey({ columns: [table.knowledgePointId, table.prerequisiteKpId], name: "kp_prereq_pk" }), kpIdx: index("kp_prereq_kp_idx").on(table.knowledgePointId), prereqIdx: index("kp_prereq_prereq_idx").on(table.prerequisiteKpId), kpFk: foreignKey({ @@ -701,6 +701,8 @@ export const homeworkAnswers = mysqlTable("homework_answers", { }), })); +export const aiProviderVisibilityEnum = mysqlEnum("visibility", ["public", "private"]); + export const aiProviders = mysqlTable("ai_providers", { id: id("id").primaryKey(), provider: mysqlEnum("provider", ["zhipu", "openai", "gemini", "custom"]).notNull(), @@ -709,6 +711,8 @@ export const aiProviders = mysqlTable("ai_providers", { apiKeyEncrypted: text("api_key_encrypted").notNull(), apiKeyLast4: varchar("api_key_last4", { length: 4 }), isDefault: boolean("is_default").default(false).notNull(), + // V3: 可见性 — public 由管理员发布,全员可用;private 仅创建者可见 + visibility: aiProviderVisibilityEnum.default("private").notNull(), createdBy: varchar("created_by", { length: 128 }), updatedBy: varchar("updated_by", { length: 128 }), createdAt: timestamp("created_at").defaultNow().notNull(), @@ -716,6 +720,8 @@ export const aiProviders = mysqlTable("ai_providers", { }, (table) => ({ providerIdx: index("ai_provider_idx").on(table.provider), defaultIdx: index("ai_provider_default_idx").on(table.isDefault), + visibilityIdx: index("ai_provider_visibility_idx").on(table.visibility), + createdByIdx: index("ai_provider_created_by_idx").on(table.createdBy), })); // --- 8. Announcements --- @@ -885,6 +891,32 @@ export const gradeRecords = mysqlTable("grade_records", { }).onDelete("cascade"), })); +// --- 11.1 Grade Record Answers (成绩每题得分) --- + +export const gradeRecordAnswers = mysqlTable("grade_record_answers", { + id: id("id").primaryKey(), + gradeRecordId: varchar("grade_record_id", { length: 128 }).notNull().references(() => gradeRecords.id, { onDelete: "cascade" }), + questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id, { onDelete: "cascade" }), + score: decimal("score", { precision: 6, scale: 2 }).notNull(), + fullScore: decimal("full_score", { precision: 6, scale: 2 }).notNull(), + feedback: text("feedback"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + gradeRecordIdx: index("grade_record_answers_record_idx").on(table.gradeRecordId), + questionIdx: index("grade_record_answers_question_idx").on(table.questionId), + gradeRecordFk: foreignKey({ + columns: [table.gradeRecordId], + foreignColumns: [gradeRecords.id], + name: "gra_gr_fk", + }).onDelete("cascade"), + questionFk: foreignKey({ + columns: [table.questionId], + foreignColumns: [questions.id], + name: "gra_q_fk", + }).onDelete("cascade"), +})); + // --- 12. File Attachments (文件附件) --- export const fileAttachments = mysqlTable("file_attachments", { @@ -1540,3 +1572,100 @@ export const gradeDrafts = mysqlTable("grade_drafts", { ), userUpdatedIdx: index("gd_user_updated_idx").on(table.userId, table.updatedAt), })); + +// --- 28. Adaptive Practice (专项练习闭环) --- + +/** + * 专项练习类型。 + * - error_variant: 错题变式练习(从错题本发起,生成变式题) + * - knowledge_point: 知识点专项练习(按知识点抽题) + * - weak_chapter: 薄弱章节练习(按掌握度自动推荐) + * - ai_recommended: AI 推荐练习(AI 根据学情综合推荐) + */ +export const practiceTypeEnum = mysqlEnum("practice_type", [ + "error_variant", + "knowledge_point", + "weak_chapter", + "ai_recommended", +]); + +export const practiceStatusEnum = mysqlEnum("practice_status", [ + "in_progress", + "completed", + "abandoned", +]); + +export const practiceAnswerStatusEnum = mysqlEnum("practice_answer_status", [ + "pending", + "answered", + "skipped", +]); + +/** + * 专项练习会话 - 学生发起的一次专项练习。 + * + * 记录练习类型、来源元数据(知识点/错题/章节)、状态和统计。 + * 练习题目存储在 practiceAnswers 表中。 + */ +export const practiceSessions = mysqlTable("practice_sessions", { + id: id("id").primaryKey(), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + subjectId: varchar("subject_id", { length: 128 }), + + practiceType: practiceTypeEnum.notNull(), + /** 来源元数据:{ knowledgePointIds?, errorBookItemIds?, chapterId?, difficulty?, sourceQuestionId? } */ + sourceMeta: json("source_meta"), + + status: practiceStatusEnum.default("in_progress").notNull(), + + totalQuestions: int("total_questions").default(0).notNull(), + answeredQuestions: int("answered_questions").default(0).notNull(), + correctCount: int("correct_count").default(0).notNull(), + + startedAt: timestamp("started_at", { mode: "date" }).defaultNow().notNull(), + completedAt: timestamp("completed_at", { mode: "date" }), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + studentIdx: index("ps_student_idx").on(table.studentId), + studentStatusIdx: index("ps_student_status_idx").on(table.studentId, table.status), + subjectIdx: index("ps_subject_idx").on(table.subjectId), +})); + +/** + * 专项练习答题记录 - 会话中每道题的作答情况。 + * + * 支持两种题目来源: + * 1. 题库中的题目(questionId 指向 questions 表) + * 2. AI 生成的变式题(variantContent 存储 JSON 内容,questionId 指向原题) + */ +export const practiceAnswers = mysqlTable("practice_answers", { + id: id("id").primaryKey(), + sessionId: varchar("session_id", { length: 128 }).notNull().references(() => practiceSessions.id, { onDelete: "cascade" }), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + + questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id), + /** 变式题内容(AI 生成的变式题,未入库时存储在此字段) */ + variantContent: json("variant_content"), + /** 是否为变式题 */ + isVariant: boolean("is_variant").default(false).notNull(), + + orderIndex: int("order_index").notNull(), + status: practiceAnswerStatusEnum.default("pending").notNull(), + + studentAnswer: json("student_answer"), + isCorrect: boolean("is_correct"), + score: int("score"), + maxScore: int("max_score").default(1).notNull(), + + answeredAt: timestamp("answered_at", { mode: "date" }), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + sessionIdx: index("pa_session_idx").on(table.sessionId), + studentIdx: index("pa_student_idx").on(table.studentId), + questionIdx: index("pa_question_idx").on(table.questionId), + sessionOrderIdx: index("pa_session_order_idx").on(table.sessionId, table.orderIndex), +}));