Files
Edu/services/core-edu/docs/02-architecture-design.md
SpecialX faaaf29f67 docs: ai 协作文档体系重构与多 ai 仲裁结果落地
1.AI 协作文档体系重构(objections/worklines/contracts+matrix.md)

2.coord 仲裁文档(final-decisions/cross-review/final-rulings/orchestration)

3.各服务 01/02 文档补全

4.共享包初始化(shared-ts/shared-go/hooks/ui-components/ui-tokens)

5.Proto 契约补全

6.004 架构影响地图更新

7.端口分配表

8.设计规格文档
2026-07-10 12:58:22 +08:00

80 KiB
Raw Permalink Blame History

模块架构设计文档 — core-edu

AI 标识ai08ai-allocation.md §3.2 负责) 模块core-edu教学核心服务 阶段:架构设计外包 · 阶段 2模块架构设计· 待 coord 交叉审查 日期2026-07-09 关联文档:


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 替代 OutboxCDC 直投)
读模型 MySQL 单库 MySQL + ClickHouse 双轨读 CQRS 完全分离core-edu 仅写
多租户 school_id 字段预留 行级隔离 数据库级隔离
数据归档 软删除 archived_at 冷数据迁 ClickHouse 冷数据迁 S3 + 元数据 MySQL
排课冲突 教师时间冲突检测 教室资源冲突 全校资源调度算法
微前端协作 teacher-portal + student-portal + parent-portal 查看成绩 + admin-portal 全局分析

1. 模块内部分层图

1.1 分层架构

graph TB
    subgraph Entry["入口层"]
        HTTP[HTTP Controller<br/>REST /exams /homework /grades /attendance]
        GRPC[gRPC Controller<br/>ExamService/HomeworkService<br/>GradeService/AttendanceService]
        KAFKA_C[Kafka Consumer<br/>IAM 事件订阅]
    end

    subgraph Guard["Guard / Filter 层"]
        AUTH[AuthMiddleware<br/>x-user-id/x-user-roles 解析]
        PERM[PermissionGuard<br/>@RequirePermission APP_GUARD]
        VAL[Zod ValidationPipe<br/>输入校验]
        ERR[GlobalErrorFilter<br/>ActionState 信封]
    end

    subgraph App["Application Service 层"]
        EXAM_SVC[ExamsService<br/>考试状态机编排]
        HW_SVC[HomeworkService<br/>作业状态机 + 并发锁]
        GRADE_SVC[GradesService<br/>成绩计算 + 幂等]
        ATT_SVC[AttendanceService<br/>考勤录入]
        COURSE_SVC[CourseService<br/>排课 + 冲突检测]
        IAM_CSM[IamUserConsumer<br/>消费 user 事件]
    end

    subgraph Domain["Domain 层(纯函数)"]
        EXAM_SM[ExamStateMachine<br/>draft→published→...]
        HW_SM[HomeworkStateMachine<br/>assigned→submitted→graded]
        GRADE_CALC[GradeCalculator<br/>加权/平均/自定义公式]
        SCHED_CHECK[ScheduleConflictChecker<br/>教师/教室/班级冲突]
        AGG[聚合根<br/>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<br/>事务内写]
        DATASCOPE[DataScopeInjector<br/>WHERE 条件注入]
    end

    subgraph Infra["基础设施层"]
        DB[(MySQL core_edu_*<br/>独占库)]
        REDIS[(Redis<br/>分布式锁 + 短缓存)]
        KAFKA_P[Kafka Producer<br/>idempotent+transactionalId]
        OUTBOX_PUB[OutboxPublisher<br/>setInterval 5s 轮询]
        TEMPORAL[Temporal Client<br/>考试发布工作流]
        OTEL[OTel SDK<br/>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 聚合根与边界

graph LR
    subgraph D2["D2 教学组织classes 子域)"]
        CLASS[Class 班级聚合]
        SUBJ[Subject 学科聚合]
        ENROLL[Enrollment 选课聚合]
    end

    subgraph D3a["D3 教学核心 - 考试子域"]
        EXAM[Exam 考试聚合]
        EXAM_Q[ExamQuestion 考试题项<br/>实体,归 Exam]
        EXAM_SUB[ExamSubmission 考试提交聚合]
    end

    subgraph D3b["D3 教学核心 - 作业子域"]
        HW[Homework 作业聚合]
        HW_SUB[HomeworkSubmission 作业提交聚合]
        HW_ANS[HomeworkAnswer 作业答案<br/>实体,归 Submission]
    end

    subgraph D3c["D3 教学核心 - 成绩子域"]
        GRADE[Grade 成绩聚合]
    end

    subgraph D3d["D3 教学核心 - 排课考勤子域"]
        COURSE[Course 课程聚合]
        LESSON[Lesson 课时实体<br/>归 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 > 0examDate > 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<string, number>, 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_outbox4 表) 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_associations10 表) P3 总计 14 表

3.1.1 考试域

// 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_questionsP3 新增)
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_submissionsP3 新增)
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 作业域

// 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_submissionsP3 新增)
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_answersP3 新增)
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 成绩域

// 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_formulasP3 新增,成绩计算配置化)
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 新增)

// core_edu_coursesP3 新增)
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_lessonsP3 新增,课时实体归 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_schedulesP3 新增,排课聚合)
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_attendanceP3 新增)
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已存在需补字段

// 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_associationsP3 新增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 最终一致(< 5s
校管理员查看全校分析 ClickHouse 宽表data-ana 服务) 最终一致
教导主任查看年级成绩分布 ClickHouse 宽表data-ana 服务) 最终一致

双轨读策略known-issues §2.6):刚提交的成绩查 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 APIP3 启用,端口 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 权限点清单

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_idUUID+ occurred_at(业务时间戳)+ metadata: { traceId, userId } Topic 命名遵循 coord 仲裁 edu.teaching.<aggregate>.<action>

事件 Topic 触发时机 消费者 payload schemav1
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.updatedP4 强制) data-ana 接收学生掌握度,用于推荐个性化练习 基于 mastery_score_id 去重

5.3 Outbox Relay Worker 轮询逻辑

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 热更新预留):

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+

  • P3NestJS 进程内 setInterval 轮询(当前模式)
  • P4+:独立 Go 服务 services/outbox-relay/pending-features 远期目标)
  • P6+Debezium 监听 outbox 表 binlogCDC 直投 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 初始化

  • 实例:pinoname=core-edulevel 由 LOG_LEVEL 环境变量控制
  • 位置:shared/observability/logger.ts
  • 必含字段:servicetraceIdspanIduserId(从 x-user-id 头注入)
  • P3 修复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
  • serviceNamecore-edu
  • exporterOTLP HTTP → ${OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces
  • P3 新增 span
    • exam.state_transitionattributes: exam_id, from, to
    • homework.submit_lockattributes: homework_id, student_id, acquired
    • grade.calculateattributes: student_id, formula_type
    • outbox.publishattributes: event_id, event_type, topic
    • kafka.consumeattributes: topic, event_type, event_id

6.6 /healthz 检查逻辑

@Get('healthz')
liveness() {
  return { status: 'ok', service: 'core-edu', timestamp: new Date().toISOString() };
}

仅返回进程状态,不检查依赖(已具备)。

6.7 /readyz 检查逻辑

@Get('readyz')
async readiness() {
  const checks = await Promise.allSettled([
    db.execute(sql`SELECT 1`),                           // MySQL
    redis.ping(),                                         // RedisP3 新增)
    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 优雅关闭顺序

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 SDKflush trace
  await shutdownTracer();
  process.exit(0);
});

7. 与其他模块的交互点(契约清单)

方向 对方服务 协议 接口/事件 用途
被调用 teacher-bff HTTPP2 已用)/ gRPCP3+ 切换) 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.updatedP4 强制) 接收掌握度用于推荐个性化练习
被动同步 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.startexamPublishWorkflow P3 试点考试发布编排

8. 关键设计:考试生命周期状态机

8.1 状态机定义

stateDiagram-v2
    [*] --> draft: CreateExam
    draft --> published: PublishExam<br/>(触发 Temporal 工作流)
    published --> in_progress: StartExam<br/>(学生开始作答)
    published --> cancelled: CancelExam
    in_progress --> grading: SubmitExam<br/>(所有学生提交或超时)
    in_progress --> cancelled: CancelExam
    grading --> graded: GradeExam<br/>(教师完成批改)
    graded --> archived: ArchiveExam<br/>(归档)
    graded --> grading: ReopenGrading<br/>(重新批改,发 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 实现方式

// domain/exam-state-machine.ts纯函数可测试
const TRANSITIONS: Record<string, string[]> = {
  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 分布式锁 + 排队机制

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 公式类型

type GradeFormulaType = "weighted" | "average" | "custom";

interface GradeFormula {
  type: GradeFormulaType;
  weights?: Record<string, number>; // weighted: { exam: 0.4, homework: 0.3, attendance: 0.3 }
  customExpression?: string; // custom: 安全表达式(如 "exam*0.5 + homework*0.3 + attendance*0.2"
}

10.2 计算流程

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-evalmathjs 等成熟表达式库。

10.4 长远演进

  • P3JSON 配置weights / customExpression
  • P4+规则引擎durable-rules / json-rules-engine支持条件分支如"出勤率 < 80% 则总评降一档"
  • P6+:可视化公式编辑器(前端 DSL + 后端解释器)

11. 关键设计:排课冲突检测

11.1 冲突类型

冲突类型 检测条件
教师时间冲突 同一教师同一时间段([start, end) 区间重叠)已有排课
班级时间冲突 同一班级同一时间段已有排课
教室冲突(可选) 同一教室同一时间段已有排课P3 预留 room_id不强制

11.2 检测算法

-- 教师冲突检测
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 长远演进

  • P3DB 查询检测(简单区间重叠)
  • P4+Redis 排课索引sorted set by start_timeO(log N) 查询
  • P6+:全校资源调度算法(约束满足问题 CSP如 OR-Tools

12. 关键设计Temporal 工作流试点

12.1 P3 试点:考试发布编排工作流

flowchart TD
    Start[ExamPublished 事件] --> A1[Activity: 创建 ExamSubmission 骨架<br/>为每个学生创建 in_progress 提交]
    A1 --> A2[Activity: 通知 msg 服务<br/>发送考试通知给学生]
    A2 --> A3[Activity: 等待作答窗口<br/>Timer: examDate + duration]
    A3 --> A4[Activity: 自动提交未答学生<br/>status=submitted, score=0]
    A4 --> A5[Activity: 通知教师<br/>考试已结束可批改]
    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 gRPCHTTP 先就绪
ClickHouse CDC 延迟 > 5s 家长查看成绩延迟 双轨读策略(实时查 MySQL
多题型 answer 结构差异大 schema 复杂 answer 字段用 JSON按 questionType 分桶解析
成绩计算 custom 公式安全风险 代码注入 白名单正则 + 沙箱求值P6+ 迁成熟库

13.2 假设

假设 依赖方 fallback
假设 iam 在 P3 启用 gRPC server iamai06 不就绪则 core-edu 仍走 HTTP 调 iam仅查权限不影响 core-edu 自身 gRPC 启用
假设 data-ana CDC 链路稳定 data-anaai06 CDC 失败不影响 core-edu 写业务,仅影响读模型延迟
假设 msg 服务在 P5 就绪前不消费 core-edu 事件 msgai05 事件留在 Kafka按 retention 配置msg 上线后回溯消费
假设 content 服务在 P4 就绪 contentai09 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 交叉审查规则

15.1 接口一致性

  • proto 包名 next_edu_cloud.core_edu.v1(符合 coord 仲裁)
  • gRPC 端口 50053 声明(符合 coord 全局端口矩阵)
  • HTTP 端口 3004 声明
  • core_edu.proto 补 AttendanceService待 coord 修改 proto 后实现)
  • buf generate 后 gRPC 代码生成(待 coord 补 buf.gen.yaml gRPC 插件)

15.2 Kafka topic 不重复

  • topic 命名统一为 edu.teaching.<aggregate>.<action>(符合 coord 仲裁)
  • 无与其他服务 topic 重复
  • CDC topic edu-cdc.next_edu_cloud.core_edu_* 已实施

15.3 端口不冲突

  • HTTP 3004不冲突
  • gRPC 50053不冲突
  • /metrics 随 HTTP 3004

15.4 错误码前缀不重叠

  • CORE_EDU_* 唯一(与 iam IAM_* / content CONTENT_* / msg MSG_* 等不重叠)
  • 子域统一 CORE_EDU_*(不再细分 EXAMS_/HOMEWORK_/GRADES_,符合 coord §5.5 仲裁)

15.5 黄金模板对齐

  • @RequirePermission 覆盖全部 Controller 方法P3 新增端点也补)
  • 错误码前缀 CORE_EDU_*
  • /healthz + /readyz 存在P3 增强 /readyz 探针)
  • Zod 输入验证P3 实施)
  • GlobalErrorFilter 注册到 AppModule
  • Dockerfile 多阶段P3 核对)
  • ActionState 信封对齐

AI Agent: ai08 (core-edu) Coordinator: coord-ai Branch: 单仓库并行模式(直接 push main