# 模块架构设计文档 — core-edu > AI 标识:ai08(按 [ai-allocation.md §3.2](../../../docs/architecture/ai-allocation.md) 负责) > 模块:core-edu(教学核心服务) > 阶段:架构设计外包 · 阶段 2(模块架构设计)· 待 coord 交叉审查 > 日期:2026-07-09 > 关联文档: > > - [阶段 1 理解确认书](./01-understanding.md) > - [004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md) > - [coord 交叉审查报告](../../../docs/architecture/coord-cross-review.md) > - [pending-features P3](../../../docs/architecture/roadmap/pending-features.md#p3-核心教学阶段m7-m10) > - [项目规则](../../../.trae/rules/project_rules.md) > - [核心教学服务 README](../README.md) --- ## 0. 设计原则与长远定位 ### 0.1 设计原则 1. **DDD 限界上下文清晰**:core-edu 承载 D2 教学组织 + D3 教学核心两个上下文,**聚合根间通过事件通信**,禁止跨聚合直接事务 2. **CQRS 读写分离**:写模型 MySQL 独占,读模型由 data-ana 通过 CDC 投递 ClickHouse 宽表;core-edu 只写不读 ClickHouse 3. **EDA 事件驱动**:所有跨聚合/跨服务副作用通过 Outbox + Kafka 事件触发,**禁止同步调用下游**(msg/data-ana/content) 4. **状态机显式化**:考试/作业/成绩生命周期用状态机建模,转换需校验前置状态,状态变更必须发事件 5. **幂等性优先**:所有外部入口(HTTP / gRPC / Kafka 消费)假设可能重试,业务操作必须基于业务唯一键去重 6. **配置化优于硬编码**:成绩计算公式、状态转换规则、并发限流阈值均配置化,为 P6 配置中心(Consul)热更新预留 7. **可观测性内置**:每个状态转换、每个 Outbox 投递、每个 gRPC 调用必须创建 span 与 metric ### 0.2 长远定位(P3 → P6+ 演进路径) | 维度 | P3 现状目标 | P4-P5 演进 | P6+ 远期 | | ---------- | --------------------------------- | ------------------------------------- | -------------------------------- | | 入口协议 | REST + gRPC 双入口 | gRPC 为主,REST 仅 BFF 兼容 | Service Mesh sidecar 接管协议 | | 状态机 | 显式状态字段 + 转换函数 | XState 风格状态机抽象 | Temporal 工作流托管长流程 | | 成绩计算 | JSON 配置公式(加权/平均/自定义) | 规则引擎(durable-rules) | DSL + 可视化编辑器 | | 作业批改 | 教师手动批改 | AI 辅助批改(选择题自动) | AI 全题型批改 + 教师审核 | | Outbox | NestJS 进程内 relay worker | 独立 Go 服务 `services/outbox-relay/` | Debezium 替代 Outbox(CDC 直投) | | 读模型 | MySQL 单库 | MySQL + ClickHouse 双轨读 | CQRS 完全分离,core-edu 仅写 | | 多租户 | school_id 字段预留 | 行级隔离 | 数据库级隔离 | | 数据归档 | 软删除 archived_at | 冷数据迁 ClickHouse | 冷数据迁 S3 + 元数据 MySQL | | 排课冲突 | 教师时间冲突检测 | 教室资源冲突 | 全校资源调度算法 | | 微前端协作 | teacher-portal + student-portal | + parent-portal 查看成绩 | + admin-portal 全局分析 | --- ## 1. 模块内部分层图 ### 1.1 分层架构 ```mermaid graph TB subgraph Entry["入口层"] HTTP[HTTP Controller
REST /exams /homework /grades /attendance] GRPC[gRPC Controller
ExamService/HomeworkService
GradeService/AttendanceService] KAFKA_C[Kafka Consumer
IAM 事件订阅] end subgraph Guard["Guard / Filter 层"] AUTH[AuthMiddleware
x-user-id/x-user-roles 解析] PERM[PermissionGuard
@RequirePermission APP_GUARD] VAL[Zod ValidationPipe
输入校验] ERR[GlobalErrorFilter
ActionState 信封] end subgraph App["Application Service 层"] EXAM_SVC[ExamsService
考试状态机编排] HW_SVC[HomeworkService
作业状态机 + 并发锁] GRADE_SVC[GradesService
成绩计算 + 幂等] ATT_SVC[AttendanceService
考勤录入] COURSE_SVC[CourseService
排课 + 冲突检测] IAM_CSM[IamUserConsumer
消费 user 事件] end subgraph Domain["Domain 层(纯函数)"] EXAM_SM[ExamStateMachine
draft→published→...] HW_SM[HomeworkStateMachine
assigned→submitted→graded] GRADE_CALC[GradeCalculator
加权/平均/自定义公式] SCHED_CHECK[ScheduleConflictChecker
教师/教室/班级冲突] AGG[聚合根
Exam/Homework/Grade/Class/Course] end subgraph Repo["Repository 层(Drizzle)"] EXAM_REPO[ExamsRepository] HW_REPO[HomeworkRepository] GRADE_REPO[GradesRepository] ATT_REPO[AttendanceRepository] COURSE_REPO[CourseRepository] OUTBOX_REPO[OutboxRepository
事务内写] DATASCOPE[DataScopeInjector
WHERE 条件注入] end subgraph Infra["基础设施层"] DB[(MySQL core_edu_*
独占库)] REDIS[(Redis
分布式锁 + 短缓存)] KAFKA_P[Kafka Producer
idempotent+transactionalId] OUTBOX_PUB[OutboxPublisher
setInterval 5s 轮询] TEMPORAL[Temporal Client
考试发布工作流] OTEL[OTel SDK
tracer/metrics/logger] end HTTP --> AUTH GRPC --> AUTH AUTH --> PERM PERM --> VAL VAL --> ERR ERR --> EXAM_SVC ERR --> HW_SVC ERR --> GRADE_SVC ERR --> ATT_SVC ERR --> COURSE_SVC KAFKA_C --> IAM_CSM EXAM_SVC --> EXAM_SM HW_SVC --> HW_SM GRADE_SVC --> GRADE_CALC COURSE_SVC --> SCHED_CHECK EXAM_SM --> AGG HW_SM --> AGG EXAM_SVC --> EXAM_REPO HW_SVC --> HW_REPO HW_SVC --> REDIS GRADE_SVC --> GRADE_REPO ATT_SVC --> ATT_REPO COURSE_SVC --> COURSE_REPO IAM_CSM --> COURSE_REPO EXAM_REPO --> DATASCOPE GRADE_REPO --> DATASCOPE DATASCOPE --> DB EXAM_SVC --> OUTBOX_REPO HW_SVC --> OUTBOX_REPO GRADE_SVC --> OUTBOX_REPO ATT_SVC --> OUTBOX_REPO OUTBOX_REPO --> DB OUTBOX_PUB --> KAFKA_P OUTBOX_PUB --> DB EXAM_SVC --> TEMPORAL EXAM_SVC --> OTEL HW_SVC --> OTEL GRADE_SVC --> OTEL ``` ### 1.2 调用链标注 | 调用链 | 拦截点 | 说明 | | ------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | HTTP POST /exams | Auth → PermissionGuard → ZodPipe → GlobalErrorFilter → ExamsService | 创建考试,事务内写 exams + outbox | | gRPC ExamService.CreateExam | 同上(gRPC 上下文适配) | P3 启用 50053 端口 | | HTTP POST /homework/:id/submit | Auth → PermissionGuard → HomeworkService → Redis 分布式锁 → HomeworkRepository → OutboxRepository | 高并发提交,锁 key = `homework:submit:{homeworkId}:{studentId}` | | Kafka edu.identity.user.created | IamUserConsumer → CourseService.UpdateTeacherAssociation | 初始化教师默认班级关联,幂等(基于 user_id + class_id 唯一索引) | | OutboxPublisher.poll() | 每 5s 轮询 pending → KafkaProducer.send → markProcessed | 失败重试 5 次,超阈值 markFailed | --- ## 2. 领域模型 ### 2.1 聚合根与边界 ```mermaid graph LR subgraph D2["D2 教学组织(classes 子域)"] CLASS[Class 班级聚合] SUBJ[Subject 学科聚合] ENROLL[Enrollment 选课聚合] end subgraph D3a["D3 教学核心 - 考试子域"] EXAM[Exam 考试聚合] EXAM_Q[ExamQuestion 考试题项
实体,归 Exam] EXAM_SUB[ExamSubmission 考试提交聚合] end subgraph D3b["D3 教学核心 - 作业子域"] HW[Homework 作业聚合] HW_SUB[HomeworkSubmission 作业提交聚合] HW_ANS[HomeworkAnswer 作业答案
实体,归 Submission] end subgraph D3c["D3 教学核心 - 成绩子域"] GRADE[Grade 成绩聚合] end subgraph D3d["D3 教学核心 - 排课考勤子域"] COURSE[Course 课程聚合] LESSON[Lesson 课时实体
归 Course] SCHED[Schedule 排课聚合] ATT[Attendance 考勤聚合] end CLASS -.1..*> EXAM CLASS -.1..*> HW CLASS -.1..*> COURSE EXAM -.1..1.-> EXAM_SUB HW -.1..1.-> HW_SUB EXAM -.1..*> GRADE HW -.1..*> GRADE COURSE -.1..*> SCHED SCHED -.1..*> ATT ``` ### 2.2 聚合根职责 | 聚合根 | 职责 | 不变式(Invariants) | 跨聚合通信 | | ---------------------- | ------------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | **Exam** | 考试生命周期管理 | status 状态机转换合法;totalScore > 0;examDate > now() 当 status=draft | 发 `exam.published` 事件触发 msg 通知 + data-ana 创建分析骨架 | | **ExamSubmission** | 学生考试作答 | 同一学生同一考试仅 1 条提交;提交时间在 [examDate, examDate+duration] 窗口内 | 发 `exam.submitted` 事件 | | **Homework** | 作业布置与生命周期 | status ∈ {assigned, submitted, graded};dueDate > assignedAt | 发 `homework.assigned` 事件 | | **HomeworkSubmission** | 学生作业提交与批改 | 同一学生同一作业仅 1 条提交;提交时间 ≤ dueDate + grace_period | 发 `homework.submitted` / `homework.graded` 事件 | | **Grade** | 成绩记录 | score ∈ [0, totalScore];examId/homeworkId 二选一;同一 (studentId, examId/homeworkId) 唯一 | 发 `grade.recorded` / `grade.updated` 事件 | | **Class** | 班级组织 | name 唯一;gradeId 存在 | 发 `class.transferred` 事件(合并 classes 后) | | **Course** | 课程与课时编排 | 教师时间不冲突;教室不冲突;班级不冲突 | 发 `schedule.conflict_detected` 事件(可选) | | **Attendance** | 考勤记录 | 同一学生同一课时仅 1 条;status ∈ {present, absent, late, leave} | 发 `attendance.recorded` 事件 | ### 2.3 值对象 | 值对象 | 字段 | 用途 | | --------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------- | | `Score` | value: number, total: number, weight?: number | 成绩值对象,封装分数与满分 | | `TimeWindow` | start: Date, end: Date, grace?: number | 时间窗口(考试作答/作业提交) | | `GradeFormula` | type: 'weighted'\|'average'\|'custom', weights?: Record, expression?: string | 成绩计算公式 | | `ConflictInfo` | type: 'teacher'\|'room'\|'class', conflictingScheduleId: string | 排课冲突信息 | | `EventMetadata` | schemaVersion: string, traceId: string, userId: string | 事件元数据,所有 outbox payload 强制携带 | --- ## 3. 数据模型 ### 3.1 表 schema 清单 > 当前已实施:`core_edu_exams` / `core_edu_homework` / `core_edu_grades` / `core_edu_outbox`(4 表) > P3 新增:`core_edu_exam_questions` / `core_edu_exam_submissions` / `core_edu_homework_submissions` / `core_edu_homework_answers` / `core_edu_courses` / `core_edu_lessons` / `core_edu_schedules` / `core_edu_attendance` / `core_edu_grade_formulas` / `core_edu_teacher_associations`(10 表) > P3 总计 14 表 #### 3.1.1 考试域 ```typescript // core_edu_exams(已存在,需补字段) export const exams = mysqlTable("core_edu_exams", { id: char("id", { length: 36 }).notNull().primaryKey(), classId: char("class_id", { length: 36 }).notNull(), subjectId: char("subject_id", { length: 36 }).notNull(), // P3 新增 title: varchar("title", { length: 200 }).notNull(), description: text("description"), examDate: datetime("exam_date").notNull(), duration: int("duration").notNull(), // 改为 int(秒),原 varchar totalScore: decimal("total_score", { precision: 6, scale: 2 }).notNull(), // 改为 decimal status: varchar("status", { length: 20 }).notNull().default("draft"), // P3 新增:状态机审计 statusChangedAt: datetime("status_changed_at").notNull().defaultNow(), statusChangedBy: char("status_changed_by", { length: 36 }), // P3 新增:多租户预留 schoolId: char("school_id", { length: 36 }).notNull(), createdBy: char("created_by", { length: 36 }).notNull(), // P3 新增:软删除 archivedAt: datetime("archived_at"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }); // core_edu_exam_questions(P3 新增) export const examQuestions = mysqlTable("core_edu_exam_questions", { id: char("id", { length: 36 }).notNull().primaryKey(), examId: char("exam_id", { length: 36 }).notNull(), questionId: char("question_id", { length: 36 }).notNull(), // 引用 content 服务题库 order: int("order").notNull(), score: decimal("score", { precision: 6, scale: 2 }).notNull(), questionType: varchar("question_type", { length: 30 }).notNull(), // choice/fill/short_answer/essay createdAt: timestamp("created_at").notNull().defaultNow(), }); // core_edu_exam_submissions(P3 新增) export const examSubmissions = mysqlTable("core_edu_exam_submissions", { id: char("id", { length: 36 }).notNull().primaryKey(), examId: char("exam_id", { length: 36 }).notNull(), studentId: char("student_id", { length: 36 }).notNull(), status: varchar("status", { length: 20 }).notNull().default("in_progress"), // in_progress / submitted / graded submittedAt: datetime("submitted_at"), gradedAt: datetime("graded_at"), gradedBy: char("graded_by", { length: 36 }), totalScore: decimal("total_score", { precision: 6, scale: 2 }), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }); ``` #### 3.1.2 作业域 ```typescript // core_edu_homework(已存在,需补字段) export const homework = mysqlTable("core_edu_homework", { id: char("id", { length: 36 }).notNull().primaryKey(), classId: char("class_id", { length: 36 }).notNull(), subjectId: char("subject_id", { length: 36 }).notNull(), // P3 新增 title: varchar("title", { length: 200 }).notNull(), description: text("description"), dueDate: datetime("due_date").notNull(), gracePeriod: int("grace_period").notNull().default(0), // P3 新增:宽限期(秒) status: varchar("status", { length: 20 }).notNull().default("assigned"), schoolId: char("school_id", { length: 36 }).notNull(), // P3 新增 createdBy: char("created_by", { length: 36 }).notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }); // core_edu_homework_submissions(P3 新增) export const homeworkSubmissions = mysqlTable("core_edu_homework_submissions", { id: char("id", { length: 36 }).notNull().primaryKey(), homeworkId: char("homework_id", { length: 36 }).notNull(), studentId: char("student_id", { length: 36 }).notNull(), status: varchar("status", { length: 20 }).notNull().default("draft"), // draft / submitted / graded submittedAt: datetime("submitted_at"), gradedAt: datetime("graded_at"), gradedBy: char("graded_by", { length: 36 }), totalScore: decimal("total_score", { precision: 6, scale: 2 }), feedback: text("feedback"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }); // core_edu_homework_answers(P3 新增) export const homeworkAnswers = mysqlTable("core_edu_homework_answers", { id: char("id", { length: 36 }).notNull().primaryKey(), submissionId: char("submission_id", { length: 36 }).notNull(), questionId: char("question_id", { length: 36 }).notNull(), answer: text("answer"), score: decimal("score", { precision: 6, scale: 2 }), teacherComment: text("teacher_comment"), isCorrect: boolean("is_correct"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }); ``` #### 3.1.3 成绩域 ```typescript // core_edu_grades(已存在,需补字段 + 唯一索引) export const grades = mysqlTable( "core_edu_grades", { id: char("id", { length: 36 }).notNull().primaryKey(), studentId: char("student_id", { length: 36 }).notNull(), examId: char("exam_id", { length: 36 }), homeworkId: char("homework_id", { length: 36 }), score: decimal("score", { precision: 6, scale: 2 }).notNull(), // 改为 decimal totalScore: decimal("total_score", { precision: 6, scale: 2 }).notNull(), // P3 新增 feedback: text("feedback"), gradedBy: char("graded_by", { length: 36 }).notNull(), schoolId: char("school_id", { length: 36 }).notNull(), // P3 新增 // P3 新增:幂等性 idempotencyKey: varchar("idempotency_key", { length: 128 }), // studentId + examId/homeworkId createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }, (table) => ({ // 同一学生同一考试/作业仅 1 条成绩 uniqueStudentExam: uniqueIndex("uniq_student_exam").on( table.studentId, table.examId, ), uniqueStudentHomework: uniqueIndex("uniq_student_hw").on( table.studentId, table.homeworkId, ), idempotencyIdx: uniqueIndex("uniq_idempotency").on(table.idempotencyKey), }), ); // core_edu_grade_formulas(P3 新增,成绩计算配置化) export const gradeFormulas = mysqlTable("core_edu_grade_formulas", { id: char("id", { length: 36 }).notNull().primaryKey(), scope: varchar("scope", { length: 20 }).notNull(), // class / subject / school scopeId: char("scope_id", { length: 36 }).notNull(), formulaType: varchar("formula_type", { length: 20 }).notNull(), // weighted / average / custom weights: json("weights"), // { "exam": 0.4, "homework": 0.3, "attendance": 0.3 } customExpression: text("custom_expression"), // 自定义公式表达式 effectiveFrom: datetime("effective_from").notNull(), effectiveTo: datetime("effective_to"), createdAt: timestamp("created_at").notNull().defaultNow(), }); ``` #### 3.1.4 排课考勤域(P3 新增) ```typescript // core_edu_courses(P3 新增) export const courses = mysqlTable("core_edu_courses", { id: char("id", { length: 36 }).notNull().primaryKey(), classId: char("class_id", { length: 36 }).notNull(), subjectId: char("subject_id", { length: 36 }).notNull(), teacherId: char("teacher_id", { length: 36 }).notNull(), schoolId: char("school_id", { length: 36 }).notNull(), name: varchar("name", { length: 200 }).notNull(), startDate: date("start_date").notNull(), endDate: date("end_date").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }); // core_edu_lessons(P3 新增,课时实体归 Course) export const lessons = mysqlTable("core_edu_lessons", { id: char("id", { length: 36 }).notNull().primaryKey(), courseId: char("course_id", { length: 36 }).notNull(), title: varchar("title", { length: 200 }).notNull(), order: int("order").notNull(), knowledgePointIds: json("knowledge_point_ids"), // 引用 content 服务知识点 createdAt: timestamp("created_at").notNull().defaultNow(), }); // core_edu_schedules(P3 新增,排课聚合) export const schedules = mysqlTable("core_edu_schedules", { id: char("id", { length: 36 }).notNull().primaryKey(), courseId: char("course_id", { length: 36 }).notNull(), lessonId: char("lesson_id", { length: 36 }), teacherId: char("teacher_id", { length: 36 }).notNull(), classId: char("class_id", { length: 36 }).notNull(), roomId: char("room_id", { length: 36 }), // 教室 ID(可选,pending-features 未要求) startTime: datetime("start_time").notNull(), endTime: datetime("end_time").notNull(), status: varchar("status", { length: 20 }).notNull().default("scheduled"), // scheduled / completed / cancelled schoolId: char("school_id", { length: 36 }).notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }); // core_edu_attendance(P3 新增) export const attendance = mysqlTable( "core_edu_attendance", { id: char("id", { length: 36 }).notNull().primaryKey(), scheduleId: char("schedule_id", { length: 36 }).notNull(), studentId: char("student_id", { length: 36 }).notNull(), status: varchar("status", { length: 20 }).notNull(), // present / absent / late / leave remark: text("remark"), recordedBy: char("recorded_by", { length: 36 }).notNull(), schoolId: char("school_id", { length: 36 }).notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), }, (table) => ({ uniqueScheduleStudent: uniqueIndex("uniq_sched_student").on( table.scheduleId, table.studentId, ), }), ); ``` #### 3.1.5 Outbox(已存在,需补字段) ```typescript // core_edu_outbox(已存在,P3 补 event_id + schema_version + occurred_at) export const outbox = mysqlTable( "core_edu_outbox", { id: char("id", { length: 36 }).notNull().primaryKey(), // P3 新增:业务事件 ID(独立于 outbox id,支持重投递幂等) eventId: char("event_id", { length: 36 }).notNull(), aggregateId: char("aggregate_id", { length: 36 }).notNull(), aggregateType: varchar("aggregate_type", { length: 50 }).notNull(), eventType: varchar("event_type", { length: 100 }).notNull(), // P3 新增:事件发生时间(业务时间,区别于 createdAt 系统时间) occurredAt: datetime("occurred_at").notNull(), payload: text("payload").notNull(), // JSON: { schema_version, ...业务字段, metadata: { traceId, userId } } status: varchar("status", { length: 20 }).notNull().default("pending"), retryCount: bigint("retry_count", { mode: "number" }).notNull().default(0), nextRetryAt: datetime("next_retry_at"), // P3 新增:指数退避 createdAt: timestamp("created_at").notNull().defaultNow(), processedAt: timestamp("processed_at"), }, (table) => ({ eventIdIdx: uniqueIndex("uniq_event_id").on(table.eventId), // 幂等去重 statusIdx: index("idx_status_next_retry").on( table.status, table.nextRetryAt, ), }), ); // core_edu_teacher_associations(P3 新增,IAM 事件消费幂等) export const teacherAssociations = mysqlTable( "core_edu_teacher_associations", { id: char("id", { length: 36 }).notNull().primaryKey(), teacherId: char("teacher_id", { length: 36 }).notNull(), classId: char("class_id", { length: 36 }).notNull(), subjectId: char("subject_id", { length: 36 }), schoolId: char("school_id", { length: 36 }).notNull(), source: varchar("source", { length: 20 }).notNull().default("iam_event"), // iam_event / manual / import createdAt: timestamp("created_at").notNull().defaultNow(), }, (table) => ({ uniqueTeacherClassSubject: uniqueIndex("uniq_teacher_class_subject").on( table.teacherId, table.classId, table.subjectId, ), }), ); ``` ### 3.2 索引策略 | 表 | 索引类型 | 字段 | 用途 | | ----------------------------- | -------- | ---------------------------------- | ---------------- | | core_edu_exams | 主键 | id | 单条查询 | | core_edu_exams | 普通 | (class_id, status) | 按班级列出考试 | | core_edu_exams | 普通 | (school_id, exam_date) | 全校考试日历 | | core_edu_exam_submissions | 唯一 | (exam_id, student_id) | 幂等提交 | | core_edu_homework_submissions | 唯一 | (homework_id, student_id) | 幂等提交 | | core_edu_grades | 唯一 | (student_id, exam_id) | 幂等录入 | | core_edu_grades | 唯一 | (student_id, homework_id) | 幂等录入 | | core_edu_grades | 普通 | (student_id) | 学生成绩查询 | | core_edu_schedules | 普通 | (teacher_id, start_time, end_time) | 教师时间冲突检测 | | core_edu_schedules | 普通 | (class_id, start_time, end_time) | 班级时间冲突检测 | | core_edu_attendance | 唯一 | (schedule_id, student_id) | 幂等考勤 | | core_edu_outbox | 唯一 | event_id | 事件幂等 | | core_edu_outbox | 普通 | (status, next_retry_at) | relay 轮询 | | core_edu_teacher_associations | 唯一 | (teacher_id, class_id, subject_id) | IAM 事件幂等 | ### 3.3 读写分离策略 | 场景 | 读源 | 写目标 | 一致性 | | ------------------------ | ----------------------------------------------------------------------------------------------- | -------------- | ---------------- | | 教师查看班级考试列表 | MySQL(实时) | MySQL | 强一致 | | 教师批改单条作业 | MySQL(实时) | MySQL + Outbox | 强一致 | | 学生查看自己成绩 | MySQL(实时) | MySQL + Outbox | 强一致 | | 家长查看子女成绩趋势 | ClickHouse 宽表(CDC 同步,[known-issues §2.6](../../../docs/troubleshooting/known-issues.md)) | — | 最终一致(< 5s) | | 校管理员查看全校分析 | ClickHouse 宽表(data-ana 服务) | — | 最终一致 | | 教导主任查看年级成绩分布 | ClickHouse 宽表(data-ana 服务) | — | 最终一致 | > **双轨读策略**([known-issues §2.6](../../../docs/troubleshooting/known-issues.md)):刚提交的成绩查 MySQL 主库(强一致),聚合统计查 ClickHouse 宽表(延迟 1-5s 可接受)。core-edu 仅负责 MySQL 写,ClickHouse 由 data-ana 通过 CDC 自动同步。 --- ## 4. API 设计 ### 4.1 REST API | method | path | 权限 | 请求/响应结构 | 说明 | | ------ | -------------------------------- | -------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | POST | /exams | CORE_EDU_EXAM_CREATE | { classId, subjectId, title, examDate, duration, totalScore } → { id } | 创建考试(status=draft) | | GET | /exams/:id | CORE_EDU_EXAM_READ | → Exam | 查询考试详情 | | GET | /exams/class/:classId | CORE_EDU_EXAM_READ | → Exam[] | 按班级列出考试(DataScope 注入) | | PUT | /exams/:id | CORE_EDU_EXAM_UPDATE | { title?, examDate?, ... } → { success } | 更新考试(仅 draft 状态) | | DELETE | /exams/:id | CORE_EDU_EXAM_DELETE | → { success } | 删除考试(仅 draft 状态,软删除) | | POST | /exams/:id/publish | CORE_EDU_EXAM_PUBLISH | → { success } | **P3 新增** 发布考试(draft → published),触发 Temporal 工作流 | | POST | /exams/:id/start | CORE_EDU_EXAM_SUBMIT | → { submissionId } | **P3 新增** 学生开始作答(published → in_progress) | | POST | /exams/:id/submit | CORE_EDU_EXAM_SUBMIT | { answers: [{ questionId, answer }] } → { submissionId } | **P3 新增** 提交答卷(in_progress → submitted),高并发锁 | | POST | /exams/:id/grade | CORE_EDU_EXAM_GRADE | { submissionId, scores: [{ questionId, score }] } → { success } | **P3 新增** 教师批改(submitted → graded) | | POST | /exams/:id/archive | CORE_EDU_EXAM_UPDATE | → { success } | **P3 新增** 归档考试(graded → archived) | | POST | /homework | CORE_EDU_HOMEWORK_CREATE | { classId, subjectId, title, dueDate, gracePeriod? } → { id } | 布置作业(status=assigned) | | GET | /homework/:id | CORE_EDU_HOMEWORK_READ | → Homework | 查询作业 | | GET | /homework/class/:classId | CORE_EDU_HOMEWORK_READ | → Homework[] | 按班级列出作业 | | POST | /homework/:id/submit | CORE_EDU_HOMEWORK_SUBMIT | { answers: [{ questionId, answer }] } → { submissionId } | **P3 新增** 提交作业(assigned → submitted),高并发锁 + 完整 answers | | POST | /homework/:id/grade | CORE_EDU_HOMEWORK_GRADE | { submissionId, scores, feedback? } → { success } | **P3 新增** 批改作业(submitted → graded),发 homework.graded 事件 | | POST | /grades | CORE_EDU_GRADE_CREATE | { studentId, examId?, homeworkId?, score, totalScore, idempotencyKey? } → { id } | 录入成绩(幂等) | | GET | /grades/:id | CORE_EDU_GRADE_READ | → Grade | 查询成绩 | | GET | /grades/student/:studentId | CORE_EDU_GRADE_READ | → Grade[] | 按学生查成绩(DataScope 注入) | | GET | /grades/exam/:examId | CORE_EDU_GRADE_READ | → Grade[] | 按考试查成绩(教师权限) | | GET | /grades/homework/:homeworkId | CORE_EDU_GRADE_READ | → Grade[] | 按作业查成绩(教师权限) | | PUT | /grades/:id | CORE_EDU_GRADE_UPDATE | { score, feedback? } → { success } | **P3 新增** 修正成绩(发 grade.updated 事件) | | POST | /attendance | CORE_EDU_ATTENDANCE_CREATE | { scheduleId, studentId, status, remark? } → { id } | **P3 新增** 录入考勤 | | GET | /attendance/schedule/:scheduleId | CORE_EDU_ATTENDANCE_READ | → Attendance[] | **P3 新增** 按课时查考勤 | | GET | /attendance/student/:studentId | CORE_EDU_ATTENDANCE_READ | → Attendance[] | **P3 新增** 按学生查考勤(DataScope 注入) | | POST | /courses | CORE_EDU_COURSE_CREATE | { classId, subjectId, teacherId, name, startDate, endDate } → { id } | **P3 新增** 创建课程 | | POST | /schedules | CORE_EDU_SCHEDULE_CREATE | { courseId, lessonId?, teacherId, classId, startTime, endTime } → { id } | **P3 新增** 排课(含冲突检测) | | GET | /schedules/teacher/:teacherId | CORE_EDU_SCHEDULE_READ | → Schedule[] | **P3 新增** 教师课表 | | GET | /schedules/class/:classId | CORE_EDU_SCHEDULE_READ | → Schedule[] | **P3 新增** 班级课表 | ### 4.2 gRPC API(P3 启用,端口 50053) | Service | Method | 请求 | 响应 | 说明 | | --------------------- | ---------------------------- | ----------------------------------- | ------------------------ | ---------------------------------------- | | ExamService | CreateExam | CreateExamRequest | CreateExamResponse | 同 REST POST /exams | | ExamService | GetExam | GetExamRequest | Exam | 同 REST GET /exams/:id | | ExamService | ListExamsByClass | ListExamsByClassRequest | ListExamsResponse | 同 REST GET /exams/class/:classId | | ExamService | UpdateExam | UpdateExamRequest | UpdateExamResponse | 同 REST PUT /exams/:id | | ExamService | DeleteExam | DeleteExamRequest | DeleteExamResponse | 同 REST DELETE /exams/:id | | ExamService | **PublishExam** | PublishExamRequest | PublishExamResponse | **P3 新增** | | ExamService | **SubmitExam** | SubmitExamRequest | SubmitExamResponse | **P3 新增** 含 answers | | ExamService | **GradeExam** | GradeExamRequest | GradeExamResponse | **P3 新增** | | HomeworkService | AssignHomework | AssignHomeworkRequest | AssignHomeworkResponse | 同 REST POST /homework | | HomeworkService | GetHomework | GetHomeworkRequest | Homework | 同 REST GET /homework/:id | | HomeworkService | ListHomeworkByClass | ListHomeworkByClassRequest | ListHomeworkResponse | 同 REST GET /homework/class/:classId | | HomeworkService | **SubmitHomework** | SubmitHomeworkRequest(含 answers) | SubmitHomeworkResponse | **P3 增强** 含完整 answers | | HomeworkService | **GradeHomework** | GradeHomeworkRequest | GradeHomeworkResponse | **P3 新增** | | GradeService | RecordGrade | RecordGradeRequest | RecordGradeResponse | 同 REST POST /grades | | GradeService | GetGrade | GetGradeRequest | Grade | 同 REST GET /grades/:id | | GradeService | ListGradesByStudent | ListGradesByStudentRequest | ListGradesResponse | 同 REST GET /grades/student/:studentId | | GradeService | ListGradesByExam | ListGradesByExamRequest | ListGradesResponse | 同 REST GET /grades/exam/:examId | | GradeService | ListGradesByHomework | ListGradesByHomeworkRequest | ListGradesResponse | 同 REST GET /grades/homework/:homeworkId | | GradeService | **UpdateGrade** | UpdateGradeRequest | UpdateGradeResponse | **P3 新增** | | **AttendanceService** | **RecordAttendance** | RecordAttendanceRequest | RecordAttendanceResponse | **P3 新增**(coord 整改 #14) | | **AttendanceService** | **ListAttendanceBySchedule** | ListAttendanceByScheduleRequest | ListAttendanceResponse | **P3 新增** | | **AttendanceService** | **ListAttendanceByStudent** | ListAttendanceByStudentRequest | ListAttendanceResponse | **P3 新增** | ### 4.3 权限点清单 ```typescript export const Permissions = { // 考试域 EXAM_CREATE: "CORE_EDU_EXAM_CREATE", EXAM_READ: "CORE_EDU_EXAM_READ", EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE", EXAM_DELETE: "CORE_EDU_EXAM_DELETE", EXAM_PUBLISH: "CORE_EDU_EXAM_PUBLISH", // P3 新增 EXAM_SUBMIT: "CORE_EDU_EXAM_SUBMIT", // P3 新增(学生) EXAM_GRADE: "CORE_EDU_EXAM_GRADE", // P3 新增(教师) // 作业域 HOMEWORK_CREATE: "CORE_EDU_HOMEWORK_CREATE", HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ", HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE", HOMEWORK_SUBMIT: "CORE_EDU_HOMEWORK_SUBMIT", HOMEWORK_GRADE: "CORE_EDU_HOMEWORK_GRADE", // P3 新增 // 成绩域 GRADE_CREATE: "CORE_EDU_GRADE_CREATE", GRADE_READ: "CORE_EDU_GRADE_READ", GRADE_UPDATE: "CORE_EDU_GRADE_UPDATE", // P3 新增 // 考勤域(P3 新增) ATTENDANCE_CREATE: "CORE_EDU_ATTENDANCE_CREATE", ATTENDANCE_READ: "CORE_EDU_ATTENDANCE_READ", // 排课域(P3 新增) COURSE_CREATE: "CORE_EDU_COURSE_CREATE", COURSE_READ: "CORE_EDU_COURSE_READ", SCHEDULE_CREATE: "CORE_EDU_SCHEDULE_CREATE", SCHEDULE_READ: "CORE_EDU_SCHEDULE_READ", } as const; ``` --- ## 5. 事件设计 ### 5.1 发布的领域事件 > 所有事件 payload 必须含 `schema_version`(默认 `"v1"`)+ `event_id`(UUID)+ `occurred_at`(业务时间戳)+ `metadata: { traceId, userId }` > Topic 命名遵循 [coord 仲裁](../../../docs/architecture/coord-cross-review.md#31-core-edu-教学事件-topic-命名双轨p0-最严重) `edu.teaching..` | 事件 | Topic | 触发时机 | 消费者 | payload schema(v1) | | ------------------- | ---------------------------------- | ------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | exam.created | `edu.teaching.exam.created` | CreateExam 事务内 | msg、data-ana | `{ schema_version, event_id, occurred_at, exam_id, class_id, subject_id, title, exam_date, duration, total_score, status, created_by, metadata }` | | exam.updated | `edu.teaching.exam.updated` | UpdateExam | msg | `{ schema_version, event_id, occurred_at, exam_id, changes: {...}, metadata }` | | exam.published | `edu.teaching.exam.published` | PublishExam(状态机转换) | msg、data-ana | `{ schema_version, event_id, occurred_at, exam_id, class_id, exam_date, metadata }` | | exam.deleted | `edu.teaching.exam.deleted` | DeleteExam(软删除) | data-ana | `{ schema_version, event_id, occurred_at, exam_id, metadata }` | | exam.submitted | `edu.teaching.exam.submitted` | 学生提交答卷 | data-ana、msg | `{ schema_version, event_id, occurred_at, exam_id, submission_id, student_id, submitted_at, metadata }` | | homework.assigned | `edu.teaching.homework.assigned` | AssignHomework | msg、data-ana | `{ schema_version, event_id, occurred_at, homework_id, class_id, subject_id, title, due_date, metadata }` | | homework.submitted | `edu.teaching.homework.submitted` | 学生提交作业 | data-ana、msg | `{ schema_version, event_id, occurred_at, homework_id, submission_id, student_id, submitted_at, metadata }` | | homework.graded | `edu.teaching.homework.graded` | 教师批改完成 | msg、data-ana | `{ schema_version, event_id, occurred_at, homework_id, submission_id, student_id, total_score, graded_by, metadata }` | | grade.recorded | `edu.teaching.grade.recorded` | RecordGrade | data-ana、msg | `{ schema_version, event_id, occurred_at, grade_id, student_id, exam_id?, homework_id?, score, total_score, metadata }` | | grade.updated | `edu.teaching.grade.updated` | UpdateGrade | data-ana | `{ schema_version, event_id, occurred_at, grade_id, student_id, old_score, new_score, metadata }` | | attendance.recorded | `edu.teaching.attendance.recorded` | RecordAttendance | data-ana、msg | `{ schema_version, event_id, occurred_at, attendance_id, schedule_id, student_id, status, metadata }` | | class.transferred | `edu.org.class.created` | classes 合并后 | data-ana | `{ schema_version, event_id, occurred_at, class_id, name, metadata }` | ### 5.2 消费的事件 | Topic | 来源 | 消费动作 | 幂等策略 | | ---------------------------------------- | -------- | ---------------------------------------------------------- | ------------------------------------------- | | `edu.identity.user.created` | IAM | 初始化教师默认班级关联(写 core_edu_teacher_associations) | 唯一索引 (teacher_id, class_id, subject_id) | | `edu.identity.user.updated` | IAM | 更新教师关联(角色变更时) | 基于用户事件序列号去重 | | `edu.identity.user.deleted` | IAM | 软删除教师关联(不物理删除,保留历史成绩归属) | 基于用户 id 去重 | | `edu.insight.mastery.updated`(P4 强制) | data-ana | 接收学生掌握度,用于推荐个性化练习 | 基于 mastery_score_id 去重 | ### 5.3 Outbox Relay Worker 轮询逻辑 ```mermaid sequenceDiagram participant Service as Application Service participant DB as MySQL participant Publisher as OutboxPublisher participant Kafka as Kafka participant Consumer as 下游消费者 Service->>DB: BEGIN TRANSACTION Service->>DB: INSERT 业务表 Service->>DB: INSERT outbox (status=pending, event_id=uuid, next_retry_at=now) Service->>DB: COMMIT loop 每 5 秒 Publisher->>DB: SELECT * FROM outbox WHERE status='pending' AND next_retry_at <= now() LIMIT 100 DB-->>Publisher: messages[] loop 每条 message Publisher->>Kafka: send(topic, key=aggregateId, value=payload, headers={event_id, schema_version, traceId}) alt 发送成功 Kafka-->>Publisher: ack Publisher->>DB: UPDATE outbox SET status='processed', processed_at=now WHERE id=? alt 发送失败 Kafka-->>Publisher: error alt retryCount + 1 < MAX_RETRY(5) Publisher->>DB: UPDATE outbox SET retry_count=retry_count+1, next_retry_at=now()+exponential_backoff WHERE id=? else 超过重试上限 Publisher->>DB: UPDATE outbox SET status='failed' WHERE id=? Publisher->>Publisher: 发告警 metric + log end end end end Note over Consumer: 消费端基于 event_id 幂等去重(Redis SETNX 或 DB 唯一索引) ``` **轮询参数配置化**(为 P6 Consul 热更新预留): ```typescript const OUTBOX_CONFIG = { POLL_INTERVAL_MS: 5000, // 轮询间隔 BATCH_SIZE: 100, // 每批拉取数 MAX_RETRY: 5, // 最大重试次数 BACKOFF_BASE_MS: 1000, // 指数退避基数 BACKOFF_MAX_MS: 60000, // 最大退避时间 STUCK_TIMEOUT_MS: 300000, // 5 分钟无进展视为卡死,重置 status=pending }; ``` **远期演进**(P6+): - P3:NestJS 进程内 `setInterval` 轮询(当前模式) - P4+:独立 Go 服务 `services/outbox-relay/`(pending-features 远期目标) - P6+:Debezium 监听 outbox 表 binlog,CDC 直投 Kafka(彻底移除轮询) --- ## 6. 横切关注点对齐清单 ### 6.1 权限装饰器覆盖 | Controller 方法 | 权限常量 | | ----------------------------------------------- | -------------------------- | | POST /exams | CORE_EDU_EXAM_CREATE | | GET /exams/:id, GET /exams/class/:classId | CORE_EDU_EXAM_READ | | PUT /exams/:id, POST /exams/:id/archive | CORE_EDU_EXAM_UPDATE | | DELETE /exams/:id | CORE_EDU_EXAM_DELETE | | POST /exams/:id/publish | CORE_EDU_EXAM_PUBLISH | | POST /exams/:id/start, POST /exams/:id/submit | CORE_EDU_EXAM_SUBMIT | | POST /exams/:id/grade | CORE_EDU_EXAM_GRADE | | POST /homework | CORE_EDU_HOMEWORK_CREATE | | GET /homework/:id, GET /homework/class/:classId | CORE_EDU_HOMEWORK_READ | | POST /homework/:id/submit | CORE_EDU_HOMEWORK_SUBMIT | | POST /homework/:id/grade | CORE_EDU_HOMEWORK_GRADE | | POST /grades | CORE_EDU_GRADE_CREATE | | GET /grades/* | CORE_EDU_GRADE_READ | | PUT /grades/:id | CORE_EDU_GRADE_UPDATE | | POST /attendance | CORE_EDU_ATTENDANCE_CREATE | | GET /attendance/* | CORE_EDU_ATTENDANCE_READ | | POST /courses | CORE_EDU_COURSE_CREATE | | POST /schedules | CORE_EDU_SCHEDULE_CREATE | | GET /schedules/* | CORE_EDU_SCHEDULE_READ | ### 6.2 错误码清单 | 错误码 | 触发条件 | HTTP 状态码 | | ------------------------------------------- | ----------------------------------------- | ----------- | | CORE_EDU_VALIDATION_ERROR | Zod 校验失败 / 业务校验失败 | 400 | | CORE_EDU_UNAUTHORIZED | 缺少 x-user-id 头 | 401 | | CORE_EDU_FORBIDDEN | 权限不足 | 403 | | CORE_EDU_NOT_FOUND | 资源不存在 | 404 | | CORE_EDU_CONFLICT | 状态机非法转换 / 重复提交 / 排课冲突 | 409 | | CORE_EDU_EXAM_NOT_FOUND | 考试不存在 | 404 | | CORE_EDU_HOMEWORK_NOT_FOUND | 作业不存在 | 404 | | CORE_EDU_GRADE_NOT_FOUND | 成绩不存在 | 404 | | CORE_EDU_ATTENDANCE_NOT_FOUND | 考勤不存在 | 404 | | CORE_EDU_SCHEDULE_NOT_FOUND | 排课不存在 | 404 | | CORE_EDU_EXAM_INVALID_STATUS_TRANSITION | 考试状态机非法转换(如 draft → graded) | 409 | | CORE_EDU_HOMEWORK_INVALID_STATUS_TRANSITION | 作业状态机非法转换 | 409 | | CORE_EDU_GRADE_OUT_OF_RANGE | score > totalScore | 400 | | CORE_EDU_GRADE_DUPLICATE | 同一学生同一考试/作业重复录入 | 409 | | CORE_EDU_SCHEDULE_CONFLICT | 排课冲突(教师/教室/班级) | 409 | | CORE_EDU_HOMEWORK_SUBMIT_LOCK_TIMEOUT | 作业提交分布式锁获取超时 | 429 | | CORE_EDU_OUTBOX_PUBLISH_FAILED | Outbox 投递失败(仅 log,不影响业务响应) | — | | CORE_EDU_TEMPORAL_WORKFLOW_FAILED | Temporal 工作流启动失败 | 500 | | CORE_EDU_INTERNAL_ERROR | 未捕获异常 | 500 | ### 6.3 Logger 初始化 - 实例:`pino`,name=`core-edu`,level 由 `LOG_LEVEL` 环境变量控制 - 位置:[shared/observability/logger.ts](../src/shared/observability/logger.ts) - 必含字段:`service`、`traceId`、`spanId`、`userId`(从 x-user-id 头注入) - **P3 修复**:[config/kafka.ts](../src/config/kafka.ts) L20/L22 的 `console.log`/`console.warn` 改用 `logger.info`/`logger.warn` ### 6.4 Metrics 指标清单 | 指标名 | 类型 | 标签 | 描述 | | -------------------------------------------- | --------- | --------------------- | --------------------------------- | | core_edu_requests_total | Counter | method, route, status | HTTP 请求总数(已具备) | | core_edu_request_duration_seconds | Histogram | method, route, status | HTTP 请求延迟(已具备) | | core_edu_outbox_pending | Gauge | — | 待投递 outbox 消息数(已具备) | | core_edu_outbox_published_total | Counter | eventType, topic | 已投递 outbox 消息数(已具备) | | core_edu_outbox_failed_total | Counter | eventType | 投递失败 outbox 消息数(P3 新增) | | core_edu_outbox_retry_total | Counter | eventType | 重试次数(P3 新增) | | core_edu_exam_state_transitions_total | Counter | from, to | 考试状态机转换次数(P3 新增) | | core_edu_homework_submit_lock_acquired_total | Counter | — | 作业提交锁获取成功次数(P3 新增) | | core_edu_homework_submit_lock_timeout_total | Counter | — | 作业提交锁超时次数(P3 新增) | | core_edu_grade_calculation_duration_seconds | Histogram | formula_type | 成绩计算耗时(P3 新增) | | core_edu_grpc_requests_total | Counter | method, status | gRPC 请求总数(P3 新增) | | core_edu_grpc_request_duration_seconds | Histogram | method, status | gRPC 请求延迟(P3 新增) | | core_edu_kafka_consumed_total | Counter | topic, event_type | Kafka 消费消息数(P3 新增) | | core_edu_temporal_workflow_started_total | Counter | workflow_type | Temporal 工作流启动数(P3 新增) | ### 6.5 Tracer 初始化 - SDK:`@opentelemetry/sdk-node` + `getNodeAutoInstrumentations()`(已具备) - 位置:[shared/observability/tracer.ts](../src/shared/observability/tracer.ts) - serviceName:`core-edu` - exporter:OTLP HTTP → `${OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces` - **P3 新增 span**: - `exam.state_transition`(attributes: exam_id, from, to) - `homework.submit_lock`(attributes: homework_id, student_id, acquired) - `grade.calculate`(attributes: student_id, formula_type) - `outbox.publish`(attributes: event_id, event_type, topic) - `kafka.consume`(attributes: topic, event_type, event_id) ### 6.6 /healthz 检查逻辑 ```typescript @Get('healthz') liveness() { return { status: 'ok', service: 'core-edu', timestamp: new Date().toISOString() }; } ``` 仅返回进程状态,不检查依赖(已具备)。 ### 6.7 /readyz 检查逻辑 ```typescript @Get('readyz') async readiness() { const checks = await Promise.allSettled([ db.execute(sql`SELECT 1`), // MySQL redis.ping(), // Redis(P3 新增) kafkaProducer isConnected ? Promise.resolve() : Promise.reject(), // Kafka producer ]); const failed = checks.filter(r => r.status === 'rejected'); if (failed.length > 0) { throw new HttpException({ status: 'error', ... }, 503); } return { status: 'ok', service: 'core-edu', timestamp: ... }; } ``` ### 6.8 优雅关闭顺序 ```typescript process.on("SIGTERM", async () => { logger.info("SIGTERM received, shutting down gracefully..."); // 1. 停止接收新请求(NestJS app.close 自动处理) // 2. 等待进行中的 HTTP/gRPC 请求完成 await app.close(); // 3. 停止 OutboxPublisher 轮询 await outboxPublisher.stop(); // 4. 停止 Kafka consumer(停止消费 IAM 事件) await kafkaConsumer.disconnect(); // 5. 刷新 Kafka producer 缓冲区(确保最后一批事件投递) await kafkaProducer.disconnect(); // 6. 关闭 Redis 连接 await redis.quit(); // 7. 关闭 MySQL 连接池 await closeDb(); // 8. 关闭 Temporal client await temporalClient.close(); // 9. 关闭 OTel SDK(flush trace) await shutdownTracer(); process.exit(0); }); ``` --- ## 7. 与其他模块的交互点(契约清单) | 方向 | 对方服务 | 协议 | 接口/事件 | 用途 | | -------------- | ----------- | --------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | 被调用 | teacher-bff | HTTP(P2 已用)/ gRPC(P3+ 切换) | ExamService.* / HomeworkService.* / GradeService.* | 教师场景域聚合 | | 被调用 | student-bff | gRPC | ExamService.SubmitExam / HomeworkService.SubmitHomework / GradeService.ListGradesByStudent | 学生作答与查成绩 | | 被调用 | parent-bff | gRPC | GradeService.ListGradesByStudent / AttendanceService.ListAttendanceByStudent | 家长查看子女数据 | | 被调用 | api-gateway | HTTP | 直连 REST 端点(绕过 BFF 内部场景) | — | | 发布 | — | Kafka | `edu.teaching.exam.created` / `.updated` / `.published` / `.submitted` / `.deleted` | msg 通知 + data-ana 分析 | | 发布 | — | Kafka | `edu.teaching.homework.assigned` / `.submitted` / `.graded` | msg 通知 + data-ana 掌握度 | | 发布 | — | Kafka | `edu.teaching.grade.recorded` / `.updated` | data-ana 宽表 + msg 通知 | | 发布 | — | Kafka | `edu.teaching.attendance.recorded` | data-ana 出勤统计 + msg 通知 | | 发布 | — | Kafka | `edu.org.class.created` | data-ana 班级维度(classes 合并后) | | 消费 | iam | Kafka | `edu.identity.user.created` / `.updated` / `.deleted` | 同步教师默认班级关联 | | 消费 | data-ana | Kafka | `edu.insight.mastery.updated`(P4 强制) | 接收掌握度用于推荐个性化练习 | | 被动同步 | data-ana | CDC | Debezium 监听 `core_edu_grades` / `core_edu_exams` / `core_edu_homework` / `core_edu_attendance` binlog | data-ana 自动同步 ClickHouse 宽表(无需 core-edu 主动配合) | | 调用(远期) | content | gRPC | ContentService.GetKnowledgePoints | 排课关联知识点(P4 content 就绪后) | | 被调用(远期) | ai | gRPC | ExamService.GetExam / GradeService.ListGradesByExam | AI 辅助批改(P5 ai 就绪后) | | 调用 | temporal | gRPC | Workflow.start(examPublishWorkflow) | P3 试点考试发布编排 | --- ## 8. 关键设计:考试生命周期状态机 ### 8.1 状态机定义 ```mermaid stateDiagram-v2 [*] --> draft: CreateExam draft --> published: PublishExam
(触发 Temporal 工作流) published --> in_progress: StartExam
(学生开始作答) published --> cancelled: CancelExam in_progress --> grading: SubmitExam
(所有学生提交或超时) in_progress --> cancelled: CancelExam grading --> graded: GradeExam
(教师完成批改) graded --> archived: ArchiveExam
(归档) graded --> grading: ReopenGrading
(重新批改,发 grade.updated) cancelled --> [*] archived --> [*] ``` ### 8.2 状态转换规则 | 当前状态 | 允许的转换 | 触发条件 | 副作用事件 | | ----------- | ----------- | -------------------------------------------------- | --------------------------------------------- | | draft | published | 教师调用 PublishExam,校验 examDate > now() | exam.published | | draft | cancelled | 教师取消 | exam.deleted(软删除) | | published | in_progress | 学生调用 StartExam(首个学生触发) | — | | published | cancelled | 教师取消 | exam.deleted | | in_progress | grading | 所有学生提交 OR 超时(examDate + duration 后自动) | — | | in_progress | cancelled | 教师取消 | exam.deleted | | grading | graded | 教师完成所有学生批改 | homework.graded(如果是作业)/ grade.recorded | | graded | archived | 教师归档(30 天后提醒) | — | | graded | grading | 教师重新开启批改(成绩复核) | grade.updated | ### 8.3 实现方式 ```typescript // domain/exam-state-machine.ts(纯函数,可测试) const TRANSITIONS: Record = { draft: ["published", "cancelled"], published: ["in_progress", "cancelled"], in_progress: ["grading", "cancelled"], grading: ["graded"], graded: ["archived", "grading"], archived: [], cancelled: [], }; export function canTransition(from: string, to: string): boolean { return TRANSITIONS[from]?.includes(to) ?? false; } export function transition( from: string, to: string, context: TransitionContext, ): TransitionResult { if (!canTransition(from, to)) { throw new ConflictError(`Invalid exam status transition: ${from} → ${to}`, { code: "CORE_EDU_EXAM_INVALID_STATUS_TRANSITION", from, to, }); } // 状态特定校验 if (from === "draft" && to === "published") { if (context.exam.examDate <= new Date()) { throw new ValidationError( "Exam date must be in the future when publishing", ); } } return { newStatus: to, events: getEventsForTransition(from, to, context) }; } ``` ### 8.4 长远演进 - P3:纯函数状态机 + DB status 字段 - P4+:XState 风格状态机库(如果业务规则变复杂) - P6+:Temporal 工作流托管长流程状态(考试从发布到归档可能跨数月) --- ## 9. 关键设计:作业提交高并发优化 ### 9.1 问题场景 期末考试/作业截止前 5 分钟,全班 50+ 学生同时提交,造成: 1. 重复提交(网络重试导致同一学生提交多次) 2. 数据库行锁竞争(多个事务同时写 homework_submissions 表) 3. Outbox 表写入阻塞 ### 9.2 解决方案:Redis 分布式锁 + 排队机制 ```mermaid sequenceDiagram participant Student as 学生 participant GW as API Gateway participant BFF as student-bff participant Core as core-edu participant Redis as Redis participant DB as MySQL Student->>GW: POST /homework/:id/submit GW->>BFF: 路由 BFF->>Core: gRPC SubmitHomework Core->>Redis: SET lock:hw:submit:{hwId}:{studentId} NX EX 30 alt 获取锁成功 Redis-->>Core: OK Core->>DB: BEGIN Core->>DB: SELECT * FROM homework_submissions WHERE homework_id=? AND student_id=? FOR UPDATE alt 已存在提交 DB-->>Core: existing Core-->>BFF: 幂等返回已存在 submissionId else 不存在 Core->>DB: INSERT homework_submissions Core->>DB: INSERT homework_answers (batch) Core->>DB: INSERT outbox (homework.submitted) Core->>DB: COMMIT Core-->>BFF: success, submissionId end Core->>Redis: DEL lock else 锁竞争(重复提交) Redis-->>Core: nil Core->>Core: 等待 100ms 重试(最多 5 次) alt 重试中获取到锁 Redis-->>Core: OK Core->>DB: 同上流程(会发现已存在提交,幂等返回) else 重试 5 次仍失败 Core-->>BFF: 429 CORE_EDU_HOMEWORK_SUBMIT_LOCK_TIMEOUT end end ``` ### 9.3 锁参数 | 参数 | 值 | 说明 | | -------- | ------------------------------------------- | --------------------------- | | 锁 key | `lock:hw:submit:{homeworkId}:{studentId}` | 细粒度:同一学生同一作业 | | 锁过期 | 30s | 防止持锁进程崩溃导致死锁 | | 重试次数 | 5 | 排队等待重试 | | 重试间隔 | 100ms(固定) | P3 简化,P6+ 可改为指数退避 | | 超时响应 | 429 + CORE_EDU_HOMEWORK_SUBMIT_LOCK_TIMEOUT | 客户端可重试 | ### 9.4 幂等性保障 1. **锁层**:Redis SETNX 防止并发重复提交 2. **DB 层**:唯一索引 `(homework_id, student_id)` 兜底 3. **业务层**:先 SELECT 检查已存在则直接返回(幂等) 4. **Outbox 层**:`event_id` UUID 保证事件不重复 --- ## 10. 关键设计:成绩计算配置化 ### 10.1 公式类型 ```typescript type GradeFormulaType = "weighted" | "average" | "custom"; interface GradeFormula { type: GradeFormulaType; weights?: Record; // weighted: { exam: 0.4, homework: 0.3, attendance: 0.3 } customExpression?: string; // custom: 安全表达式(如 "exam*0.5 + homework*0.3 + attendance*0.2") } ``` ### 10.2 计算流程 ```mermaid flowchart TD A[请求计算学生总评成绩] --> B{查询 grade_formulas} B --> C{scope 优先级} C -->|class 级| D[用 class 公式] C -->|subject 级| E[用 subject 公式] C -->|school 级| F[用 school 公式] C -->|无公式| G[默认平均] D --> H{formula_type} E --> H F --> H G --> H H -->|weighted| I[按 weights 加权求和] H -->|average| J[简单平均] H -->|custom| K[解析 customExpression 安全求值] I --> L[返回总评成绩] J --> L K --> L ``` ### 10.3 安全求值(custom 公式) P3 采用白名单 + 受限表达式求值: - 仅允许:变量名(exam/homework/attendance)、数字、四则运算符、括号 - 禁止:函数调用、对象访问、原型链、eval - 实现:先用正则白名单校验,再用 `Function` 构造器求值(沙箱) P6+ 演进:引入 `expr-eval` 或 `mathjs` 等成熟表达式库。 ### 10.4 长远演进 - P3:JSON 配置(weights / customExpression) - P4+:规则引擎(durable-rules / json-rules-engine),支持条件分支(如"出勤率 < 80% 则总评降一档") - P6+:可视化公式编辑器(前端 DSL + 后端解释器) --- ## 11. 关键设计:排课冲突检测 ### 11.1 冲突类型 | 冲突类型 | 检测条件 | | ---------------- | ----------------------------------------------------- | | 教师时间冲突 | 同一教师同一时间段([start, end) 区间重叠)已有排课 | | 班级时间冲突 | 同一班级同一时间段已有排课 | | 教室冲突(可选) | 同一教室同一时间段已有排课(P3 预留 room_id,不强制) | ### 11.2 检测算法 ```sql -- 教师冲突检测 SELECT id FROM core_edu_schedules WHERE teacher_id = ? AND status = 'scheduled' AND start_time < ? -- 新排课的 end_time AND end_time > ? -- 新排课的 start_time LIMIT 1; -- 班级冲突检测 SELECT id FROM core_edu_schedules WHERE class_id = ? AND status = 'scheduled' AND start_time < ? AND end_time > ? LIMIT 1; ``` ### 11.3 长远演进 - P3:DB 查询检测(简单区间重叠) - P4+:Redis 排课索引(sorted set by start_time),O(log N) 查询 - P6+:全校资源调度算法(约束满足问题 CSP,如 OR-Tools) --- ## 12. 关键设计:Temporal 工作流试点 ### 12.1 P3 试点:考试发布编排工作流 ```mermaid flowchart TD Start[ExamPublished 事件] --> A1[Activity: 创建 ExamSubmission 骨架
为每个学生创建 in_progress 提交] A1 --> A2[Activity: 通知 msg 服务
发送考试通知给学生] A2 --> A3[Activity: 等待作答窗口
Timer: examDate + duration] A3 --> A4[Activity: 自动提交未答学生
status=submitted, score=0] A4 --> A5[Activity: 通知教师
考试已结束可批改] A5 --> End[工作流完成] ``` ### 12.2 选择 Temporal 的理由 1. **长流程编排**:考试从发布到批改完成可能跨数小时到数周,传统消息队列难以维护状态 2. **可观测**:Temporal UI 可视化工作流进度 3. **可回滚**:工作流失败可重试或回滚 4. **P3 试点**:仅 1 个工作流,验证技术栈,P4+ 可推广到作业批改、成绩结算等场景 ### 12.3 集成方式 - Temporal server:独立部署(infra/docker-compose.yml 已规划) - core-edu 作为 Temporal client:`@temporal/sdk` 包 - 工作流定义:`src/workflows/exam-publish.workflow.ts` - Activity 实现:`src/workflows/exam-publish.activities.ts` - 触发点:`ExamsService.publishExam()` 调用 `workflowClient.start(examPublishWorkflow, ...)` --- ## 13. 风险与假设 ### 13.1 技术风险 | 风险 | 影响 | 缓解措施 | | ------------------------------------ | ---------------- | --------------------------------------------- | | Redis 分布式锁在主从切换时失效 | 重复提交 | DB 唯一索引兜底 + 业务层 SELECT 检查 | | Outbox relay 进程内模式,单点故障 | 事件丢失 | P3 接受(业务事务已落库),P4+ 迁独立 Go 服务 | | Temporal server 引入运维复杂度 | P3 试点延期 | 若 P3 时间紧,可降级为纯事件驱动(无工作流) | | gRPC server 启用增加 NestJS 启动时间 | 服务启动慢 | lazy init gRPC,HTTP 先就绪 | | ClickHouse CDC 延迟 > 5s | 家长查看成绩延迟 | 双轨读策略(实时查 MySQL) | | 多题型 answer 结构差异大 | schema 复杂 | answer 字段用 JSON,按 questionType 分桶解析 | | 成绩计算 custom 公式安全风险 | 代码注入 | 白名单正则 + 沙箱求值,P6+ 迁成熟库 | ### 13.2 假设 | 假设 | 依赖方 | fallback | | --------------------------------------------- | ---------------- | ------------------------------------------------------------------------------ | | 假设 iam 在 P3 启用 gRPC server | iam(ai06) | 不就绪则 core-edu 仍走 HTTP 调 iam(仅查权限),不影响 core-edu 自身 gRPC 启用 | | 假设 data-ana CDC 链路稳定 | data-ana(ai06) | CDC 失败不影响 core-edu 写业务,仅影响读模型延迟 | | 假设 msg 服务在 P5 就绪前不消费 core-edu 事件 | msg(ai05) | 事件留在 Kafka(按 retention 配置),msg 上线后回溯消费 | | 假设 content 服务在 P4 就绪 | content(ai09) | P3 不调 content,题库 question_id 仅作外键引用,不校验存在性 | | 假设 Redis 在 P3 部署 | infra | 不就绪则降级:作业提交锁改用 DB SELECT FOR UPDATE(性能下降但功能可用) | | 假设 Temporal 在 P3 部署 | infra | 不就绪则考试发布降级为同步事件驱动(无工作流) | ### 13.3 未决的设计决策(提请 coord 仲裁) | # | 决策点 | 选项 | ai08 倾向 | 影响 | | --- | ------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------- | ---------------------------------------- | | 1 | classes 服务合并到 core-edu 的时机 | (a) P3 初期合并 / (b) P3 末期合并 | (a) 初期合并,避免后期大改 | 影响 ClassesModule 迁移路径 | | 2 | 排课 room_id 是否 P3 实现 | (a) P3 实现 / (b) P3 仅预留字段 / (c) P6+ | (b) 仅预留字段,pending-features 未明确要求 | 影响 ScheduleConflictChecker 复杂度 | | 3 | 成绩计算公式 scope 优先级 | (a) class > subject > school / (b) school > subject > class | (a) 越细粒度优先 | 影响 GradeCalculator 实现 | | 4 | 作业 grace_period 默认值 | (a) 0 秒(严格)/ (b) 300 秒(5 分钟宽限) | (b) 5 分钟宽限 | 影响 HomeworkService.submitHomework 校验 | | 5 | 是否在 P3 启用 events.proto schema 强制校验 | (a) JSON Schema 校验 / (b) 仅文档约束 | (b) 仅文档约束,P6+ 引入 schema registry | 影响 Outbox payload 序列化 | | 6 | exam.submitted 事件是否包含完整 answers | (a) 包含(事件大)/ (b) 仅含 submission_id,下游按需查询 | (b) 仅含 submission_id | 影响 Kafka 消息大小与下游查询模式 | | 7 | archived 考试数据是否物理迁移到归档表 | (a) P3 物理迁移 / (b) P3 仅软删除 / (c) P6+ 迁移 | (b) 仅软删除,P6+ 迁 ClickHouse 冷存储 | 影响 schema 设计 | --- ## 14. 实施优先级(P3 阶段) | 优先级 | 任务 | 依赖 | | ------ | ------------------------------------------------------------------------------ | ---------------------- | | P0 | TOPIC_MAP 重命名为 `edu.teaching.*` + payload 补 `schema_version` + `event_id` | 无 | | P0 | Drizzle `getDb()` 统一 + kafka.ts console.log → logger | 无 | | P0 | Zod 输入验证(全部 Controller) | 无 | | P0 | /readyz 补 Kafka + Redis 探针 | Redis 引入 | | P1 | 考试状态机实现(draft → published → ... → archived) | Zod | | P1 | 作业状态机补全(+ graded)+ homework.graded 事件 | 状态机 | | P1 | 成绩录入幂等(唯一索引 + idempotencyKey) | schema 改造 | | P1 | DataScope 下推(Repository 层 WHERE 注入) | — | | P1 | 消费 IAM 事件(user.created/updated/deleted) | Kafka consumer | | P2 | 作业提交 Redis 分布式锁 | Redis | | P2 | gRPC server 启用(端口 50053) | buf.gen.yaml gRPC 插件 | | P2 | core_edu.proto 补 AttendanceService + AttendanceService 实现 | proto coord 审查 | | P2 | 排课/考勤数据模型(4 表 + CRUD) | schema | | P2 | 成绩计算配置化(grade_formulas 表 + GradeCalculator) | schema | | P3 | Temporal 工作流试点(考试发布编排) | Temporal server | | P3 | classes 服务合并到 core-edu | 决策 #1 | | P3 | Dockerfile 多阶段构建核对 | — | | P3 | 测试覆盖率 ≥ 80% | 全部功能就绪 | --- ## 15. 自检清单(对照 coord 交叉审查规则) > [ai-allocation §8 交叉审查规则](../../../docs/architecture/ai-allocation.md#8-交叉审查规则) ### 15.1 接口一致性 - [x] proto 包名 `next_edu_cloud.core_edu.v1`(符合 coord 仲裁) - [x] gRPC 端口 50053 声明(符合 coord 全局端口矩阵) - [x] HTTP 端口 3004 声明 - [ ] core_edu.proto 补 AttendanceService(待 coord 修改 proto 后实现) - [ ] buf generate 后 gRPC 代码生成(待 coord 补 buf.gen.yaml gRPC 插件) ### 15.2 Kafka topic 不重复 - [x] topic 命名统一为 `edu.teaching..`(符合 coord 仲裁) - [x] 无与其他服务 topic 重复 - [x] CDC topic `edu-cdc.next_edu_cloud.core_edu_*` 已实施 ### 15.3 端口不冲突 - [x] HTTP 3004(不冲突) - [x] gRPC 50053(不冲突) - [x] /metrics 随 HTTP 3004 ### 15.4 错误码前缀不重叠 - [x] `CORE_EDU_*` 唯一(与 iam `IAM_*` / content `CONTENT_*` / msg `MSG_*` 等不重叠) - [x] 子域统一 `CORE_EDU_*`(不再细分 `EXAMS_`/`HOMEWORK_`/`GRADES_`,符合 coord §5.5 仲裁) ### 15.5 黄金模板对齐 - [x] @RequirePermission 覆盖全部 Controller 方法(P3 新增端点也补) - [x] 错误码前缀 `CORE_EDU_*` - [x] /healthz + /readyz 存在(P3 增强 /readyz 探针) - [ ] Zod 输入验证(P3 实施) - [x] GlobalErrorFilter 注册到 AppModule - [ ] Dockerfile 多阶段(P3 核对) - [x] ActionState 信封对齐 --- **AI Agent**: ai08 (core-edu) **Coordinator**: coord-ai **Branch**: 单仓库并行模式(直接 push main)