# 模块架构设计文档 — 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)