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

1291 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块架构设计文档 — 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 替代 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 分层架构
```mermaid
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 聚合根与边界
```mermaid
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_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_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 作业域
```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_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 成绩域
```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_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 新增)
```typescript
// 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已存在需补字段
```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_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](../../../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 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 权限点清单
```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.<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.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+
- 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 初始化
- 实例:`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`
- exporterOTLP 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(), // 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 优雅关闭顺序
```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 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.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.startexamPublishWorkflow | P3 试点考试发布编排 |
---
## 8. 关键设计:考试生命周期状态机
### 8.1 状态机定义
```mermaid
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 实现方式
```typescript
// 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 分布式锁 + 排队机制
```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<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 计算流程
```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 长远演进
- P3JSON 配置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 长远演进
- P3DB 查询检测(简单区间重叠)
- P4+Redis 排课索引sorted set by start_timeO(log N) 查询
- P6+:全校资源调度算法(约束满足问题 CSP如 OR-Tools
---
## 12. 关键设计Temporal 工作流试点
### 12.1 P3 试点:考试发布编排工作流
```mermaid
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 交叉审查规则](../../../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.<aggregate>.<action>`(符合 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