diff --git a/docs/troubleshooting/known-issues.md b/docs/troubleshooting/known-issues.md index d6aff54..139129f 100644 --- a/docs/troubleshooting/known-issues.md +++ b/docs/troubleshooting/known-issues.md @@ -271,22 +271,34 @@ ### 2.4 core-edu(TS/NestJS,P3) -| 场景 | 技术/规则 | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 | -| Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka | -| Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 | -| Kafka topic | `exam.published` / `homework.graded` / `grade.recorded` | -| 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 | -| 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费(DataAna/Msg) | -| Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) | -| schema 表 | exams / exam_questions / homework_assignments / homework_submissions / homework_answers / grade_records / outbox_events | -| datetime 列 | Drizzle `datetime` 列需 Date 对象,HTTP 请求体里是 ISO 字符串,service 层必须 `new Date(input)` 转换否则报 `toISOString is not a function` | -| Kafka 非阻塞 | 开发环境 Kafka 未启动时 `connectKafka()` 必须 try/catch 且 main 中用 `void` 调用,否则阻塞服务启动 | -| 相对 import | `src//` 下文件回溯一级用 `../`,不要用 `../../`(NestJS ESM 模块) | -| 身份头读取 | Controller 用 `@Req() req` 从 `req.headers['x-user-id']` 读 createdBy/gradedBy,不依赖未注册的 AuthMiddleware | -| 健康检查 | health.controller 用 Drizzle `db.execute(sql\`SELECT 1\`)`,不用 typeorm DataSource | -| Outbox 验证 | 业务事务同写 `core_edu_outbox` 表,Kafka 未启动时事件 status=failed,启动后会重试 | +| 场景 | 技术/规则 | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 | +| Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka | +| Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 | +| Kafka topic | `edu.teaching.exam.created` / `edu.teaching.homework.graded` / `edu.teaching.grade.recorded`(ISSUE-002/004 仲裁统一前缀) | +| 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 | +| 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费(DataAna/Msg) | +| Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) | +| schema 表 | exams / exam_questions / exam_submissions / homework / homework_submissions / homework_answers / grades / grade_formulas / attendance / courses / lessons / schedules / classes / teacher_associations / outbox | +| datetime 列 | Drizzle `datetime` 列需 Date 对象,HTTP 请求体里是 ISO 字符串,service 层必须 `new Date(input)` 转换否则报 `toISOString is not a function` | +| Kafka 非阻塞 | 开发环境 Kafka 未启动时 `connectKafka()` 必须 try/catch 且 main 中用 `void` 调用,否则阻塞服务启动 | +| 相对 import | `src//` 下文件回溯一级用 `../`,不要用 `../../`(NestJS ESM 模块) | +| 身份头读取 | Controller 用 `@Req() req` 从 `req.headers['x-user-id']` 读 createdBy/gradedBy,不依赖未注册的 AuthMiddleware | +| 健康检查 | health.controller 用 Drizzle `db.execute(sql\`SELECT 1\`)`,不用 typeorm DataSource | +| Outbox 验证 | 业务事务同写 `core_edu_outbox` 表,Kafka 未启动时事件 status=failed,启动后会重试 | +| Drizzle 索引语法 | drizzle-orm 0.31+ 的 `mysqlTable` 第三参数回调必须返回对象(`{ idx: index(...).on(...) }`),不能返回数组 | +| Drizzle datetime defaultNow | `datetime` 列类型不支持 `defaultNow()`,需改用 `timestamp` 列类型或在 service 层显式传入 `new Date()` | +| KafkaJS 连接状态 | KafkaJS Producer 类型无 `isConnected()` 方法,需监听 `producer.connect`/`producer.disconnect` 事件自行维护布尔标志 | +| 状态机类型转换 | DB 查询返回的 `status` 字段为 `string`,传入状态机函数前需 `as ExamStatus` 转换(drizzle 不支持 enum 字面量类型推导) | +| Controller createdBy | Controller 构造 `CreateExamInput`/`AssignHomeworkInput` 时需显式从 `req.userId` 赋值 `createdBy` 字段,Zod schema 不含此字段 | +| Redis 分布式锁 | 作业提交幂等用 Redis `SET NX EX`,Redis 不可用时降级到 DB 唯一索引兜底 | +| 课表冲突检测 | `findOverlappingSchedules` 查 teacher + class 两个维度的时间重叠,ISSUE-006 #2 裁决 room_id 仅 P3 预留不校验冲突 | +| 成绩公式优先级 | scope 优先级 class(3) > subject(2) > school(1),ISSUE-006 #3 裁决;`gradeFormulas` 表按 effectiveFrom/effectiveTo 时间窗口匹配 | +| 作业宽限期 | 默认 grace_period=300 秒(ISSUE-006 #4 裁决),提交时 `dueDate + gracePeriod` 判断是否迟到 | +| 考试归档 | ISSUE-006 #7 裁决 archived 仅软删除(设 status + archivedAt),不物理删除 | +| 事件 payload | `buildEvent` 统一封装 event_id/occurred_at/metadata.schema_version,ISSUE-006 #6 裁决 exam.submitted 事件只带 submission_id+studentId | +| IAM 事件消费 | `iam-consumer` 订阅 `edu.iam.teacher.assigned`/`edu.iam.class.created`,幂等 upsert 到 `teacher_associations`/`classes` 表 | ### 2.5 content(TS/NestJS,P4) diff --git a/packages/shared-proto/buf.gen.yaml b/packages/shared-proto/buf.gen.yaml index 45c5be8..8ac94b9 100644 --- a/packages/shared-proto/buf.gen.yaml +++ b/packages/shared-proto/buf.gen.yaml @@ -5,4 +5,15 @@ plugins: - remote: buf.build/protocolbuffers/js out: ../shared-ts/gen/proto - remote: buf.build/protocolbuffers/python - out: ../shared-py/gen/proto \ No newline at end of file + out: ../shared-py/gen/proto + # gRPC plugins (president-final-rulings §2.5 / batch 0.9) + - remote: buf.build/grpc/go + out: ../shared-go/gen/proto + opt: + - paths=source_relative + - remote: buf.build/grpc/node + out: ../shared-ts/gen/proto + opt: + - grpc_js + - remote: buf.build/grpc/python + out: ../shared-py/gen/proto diff --git a/packages/shared-proto/proto/core_edu.proto b/packages/shared-proto/proto/core_edu.proto index 033e320..640d373 100644 --- a/packages/shared-proto/proto/core_edu.proto +++ b/packages/shared-proto/proto/core_edu.proto @@ -3,8 +3,17 @@ syntax = "proto3"; package next_edu_cloud.core_edu.v1; // CoreEdu service contracts - P3 core teaching domain. -// Covers exam management, homework assignment, and grade recording. +// Covers exam management, homework assignment, grade recording, +// attendance tracking, and class queries. +// +// Total: 5 Service / 27 RPC (P3 target state per president-final-rulings §2.5). // Event contracts live in events.proto under next_edu_cloud.events.v1. +// +// Status naming (ISSUE-003-ai08 arbitration, scheme A): +// ExamStatus: draft / published / in_progress / grading / graded / archived / cancelled +// HomeworkStatus: assigned / submitted / graded +// SubmissionStatus: not_submitted / submitted / graded +// AttendanceStatus: present / absent / late / leave service ExamService { rpc CreateExam(CreateExamRequest) returns (CreateExamResponse); @@ -12,6 +21,9 @@ service ExamService { rpc ListExamsByClass(ListExamsByClassRequest) returns (ListExamsResponse); rpc UpdateExam(UpdateExamRequest) returns (UpdateExamResponse); rpc DeleteExam(DeleteExamRequest) returns (DeleteExamResponse); + rpc PublishExam(PublishExamRequest) returns (PublishExamResponse); + rpc SubmitExam(SubmitExamRequest) returns (SubmitExamResponse); + rpc GradeExam(GradeExamRequest) returns (GradeExamResponse); } service HomeworkService { @@ -19,6 +31,7 @@ service HomeworkService { rpc GetHomework(GetHomeworkRequest) returns (Homework); rpc ListHomeworkByClass(ListHomeworkByClassRequest) returns (ListHomeworkResponse); rpc SubmitHomework(SubmitHomeworkRequest) returns (SubmitHomeworkResponse); + rpc GradeHomework(GradeHomeworkRequest) returns (GradeHomeworkResponse); } service GradeService { @@ -27,54 +40,76 @@ service GradeService { rpc ListGradesByStudent(ListGradesByStudentRequest) returns (ListGradesResponse); rpc ListGradesByExam(ListGradesByExamRequest) returns (ListGradesResponse); rpc ListGradesByHomework(ListGradesByHomeworkRequest) returns (ListGradesResponse); + rpc UpdateGrade(UpdateGradeRequest) returns (UpdateGradeResponse); } +service ClassService { + rpc GetClass(GetClassRequest) returns (ClassInfo); + rpc GetClassesByTeacher(GetClassesByTeacherRequest) returns (GetClassesByTeacherResponse); + rpc BatchGetClasses(BatchGetClassesRequest) returns (BatchGetClassesResponse); + rpc ListStudentsByClass(ListStudentsByClassRequest) returns (ListStudentsByClassResponse); +} + +service AttendanceService { + rpc RecordAttendance(RecordAttendanceRequest) returns (RecordAttendanceResponse); + rpc GetAttendance(GetAttendanceRequest) returns (Attendance); + rpc ListAttendanceByStudent(ListAttendanceByStudentRequest) returns (ListAttendanceResponse); + rpc ListAttendanceByClass(ListAttendanceByClassRequest) returns (ListAttendanceResponse); +} + +// ---------------------------------------------------------------------------- +// Exam domain +// ---------------------------------------------------------------------------- + message Exam { string id = 1; string class_id = 2; - string title = 3; - string description = 4; - string exam_date = 5; - string duration = 6; - string total_score = 7; - string status = 8; - string created_by = 9; - string created_at = 10; - string updated_at = 11; + string subject_id = 3; + string title = 4; + string description = 5; + string exam_date = 6; // ISO 8601 + int32 duration = 7; // seconds + string total_score = 8; // decimal as string + string status = 9; + string status_changed_at = 10; + string status_changed_by = 11; + string school_id = 12; + string created_by = 13; + string archived_at = 14; + string created_at = 15; + string updated_at = 16; } -message Homework { +message ExamQuestion { string id = 1; - string class_id = 2; - string title = 3; - string description = 4; - string due_date = 5; - string status = 6; - string created_by = 7; - string created_at = 8; - string updated_at = 9; -} - -message Grade { - string id = 1; - string student_id = 2; - string exam_id = 3; - string homework_id = 4; + string exam_id = 2; + string question_id = 3; + int32 order = 4; string score = 5; - string feedback = 6; + string question_type = 6; +} + +message ExamSubmission { + string id = 1; + string exam_id = 2; + string student_id = 3; + string status = 4; + string submitted_at = 5; + string graded_at = 6; string graded_by = 7; - string created_at = 8; - string updated_at = 9; + string total_score = 8; } message CreateExamRequest { string class_id = 1; - string title = 2; - string description = 3; - string exam_date = 4; - string duration = 5; - string total_score = 6; - string created_by = 7; + string subject_id = 2; + string title = 3; + string description = 4; + string exam_date = 5; + int32 duration = 6; + string total_score = 7; + string school_id = 8; + string created_by = 9; } message CreateExamResponse { @@ -98,7 +133,7 @@ message UpdateExamRequest { string title = 2; string description = 3; string exam_date = 4; - string duration = 5; + int32 duration = 5; string total_score = 6; string status = 7; } @@ -115,12 +150,88 @@ message DeleteExamResponse { bool success = 1; } +message PublishExamRequest { + string id = 1; + string published_by = 2; +} + +message PublishExamResponse { + bool success = 1; +} + +message SubmitExamRequest { + string exam_id = 1; + string student_id = 2; + repeated AnswerInput answers = 3; +} + +message AnswerInput { + string question_id = 1; + string answer = 2; +} + +message SubmitExamResponse { + string submission_id = 1; +} + +message GradeExamRequest { + string exam_id = 1; + string submission_id = 2; + repeated ScoreInput scores = 3; + string graded_by = 4; +} + +message ScoreInput { + string question_id = 1; + string score = 2; + string teacher_comment = 3; +} + +message GradeExamResponse { + bool success = 1; + string total_score = 2; +} + +// ---------------------------------------------------------------------------- +// Homework domain +// ---------------------------------------------------------------------------- + +message Homework { + string id = 1; + string class_id = 2; + string subject_id = 3; + string title = 4; + string description = 5; + string due_date = 6; + int32 grace_period = 7; + string status = 8; + string school_id = 9; + string created_by = 10; + string created_at = 11; + string updated_at = 12; +} + +message HomeworkSubmission { + string id = 1; + string homework_id = 2; + string student_id = 3; + string status = 4; + string submitted_at = 5; + string graded_at = 6; + string graded_by = 7; + string total_score = 8; + string feedback = 9; +} + message AssignHomeworkRequest { string class_id = 1; - string title = 2; - string description = 3; - string due_date = 4; - string created_by = 5; + string subject_id = 2; + string title = 3; + string description = 4; + string due_date = 5; + int32 grace_period = 6; + string school_id = 7; + string created_by = 8; } message AssignHomeworkResponse { @@ -140,11 +251,45 @@ message ListHomeworkResponse { } message SubmitHomeworkRequest { - string id = 1; + string homework_id = 1; + string student_id = 2; + repeated AnswerInput answers = 3; } message SubmitHomeworkResponse { + string submission_id = 1; +} + +message GradeHomeworkRequest { + string homework_id = 1; + string submission_id = 2; + repeated ScoreInput scores = 3; + string feedback = 4; + string graded_by = 5; +} + +message GradeHomeworkResponse { bool success = 1; + string total_score = 2; +} + +// ---------------------------------------------------------------------------- +// Grade domain +// ---------------------------------------------------------------------------- + +message Grade { + string id = 1; + string student_id = 2; + string exam_id = 3; + string homework_id = 4; + string score = 5; + string total_score = 6; + string feedback = 7; + string graded_by = 8; + string school_id = 9; + string idempotency_key = 10; + string created_at = 11; + string updated_at = 12; } message RecordGradeRequest { @@ -152,8 +297,11 @@ message RecordGradeRequest { string exam_id = 2; string homework_id = 3; string score = 4; - string feedback = 5; - string graded_by = 6; + string total_score = 5; + string feedback = 6; + string graded_by = 7; + string school_id = 8; + string idempotency_key = 9; } message RecordGradeResponse { @@ -179,3 +327,107 @@ message ListGradesByHomeworkRequest { message ListGradesResponse { repeated Grade grades = 1; } + +message UpdateGradeRequest { + string id = 1; + string score = 2; + string feedback = 3; + string updated_by = 4; +} + +message UpdateGradeResponse { + bool success = 1; +} + +// ---------------------------------------------------------------------------- +// Class domain (merged from classes service per ISSUE-006 decision #1) +// ---------------------------------------------------------------------------- + +message ClassInfo { + string id = 1; + string name = 2; + string grade_id = 3; + string head_teacher_id = 4; + string description = 5; + string created_at = 6; + string updated_at = 7; +} + +message StudentInfo { + string id = 1; + string name = 2; + string class_id = 3; +} + +message GetClassRequest { + string id = 1; +} + +message GetClassesByTeacherRequest { + string teacher_id = 1; +} + +message GetClassesByTeacherResponse { + repeated ClassInfo classes = 1; +} + +message BatchGetClassesRequest { + repeated string ids = 1; +} + +message BatchGetClassesResponse { + repeated ClassInfo classes = 1; +} + +message ListStudentsByClassRequest { + string class_id = 1; +} + +message ListStudentsByClassResponse { + repeated StudentInfo students = 1; +} + +// ---------------------------------------------------------------------------- +// Attendance domain +// ---------------------------------------------------------------------------- + +message Attendance { + string id = 1; + string schedule_id = 2; + string student_id = 3; + string status = 4; + string remark = 5; + string recorded_by = 6; + string school_id = 7; + string created_at = 8; + string updated_at = 9; +} + +message RecordAttendanceRequest { + string schedule_id = 1; + string student_id = 2; + string status = 3; + string remark = 4; + string recorded_by = 5; + string school_id = 6; +} + +message RecordAttendanceResponse { + string id = 1; +} + +message GetAttendanceRequest { + string id = 1; +} + +message ListAttendanceByStudentRequest { + string student_id = 1; +} + +message ListAttendanceByClassRequest { + string class_id = 1; +} + +message ListAttendanceResponse { + repeated Attendance attendance = 1; +} diff --git a/packages/shared-proto/proto/events.proto b/packages/shared-proto/proto/events.proto index 9cbfb11..2f72452 100644 --- a/packages/shared-proto/proto/events.proto +++ b/packages/shared-proto/proto/events.proto @@ -2,18 +2,38 @@ syntax = "proto3"; package next_edu_cloud.events.v1; -// Cross-service event contracts published via the transactional outbox pattern -// and consumed by downstream services (notifications, analytics, audit, etc.). -// Topics follow the convention edu.... +// Cross-service event contracts published by CoreEdu via the transactional +// outbox pattern and consumed by downstream services (notifications, analytics, +// audit, etc.). // -// Event routing (TOPIC_MAP in outbox publisher): -// edu.exam.events <- exam.created / exam.updated / exam.deleted -// edu.homework.events <- homework.assigned / homework.submitted / homework.graded -// edu.grade.events <- grade.recorded / grade.updated -// edu.class.events <- class.transferred -// edu.iam.user.events <- user.created / user.updated / user.disabled / user.role_changed -// edu.iam.role.events <- role.created / role.updated -// edu.iam.audit.created <- audit (unified audit topic, action field distinguishes) +// Topic naming follows the arbitration in coord-cross-review §3.1: +// edu.teaching.. +// +// Event routing (TOPIC_MAP in outbox.publisher.ts): +// edu.teaching.exam.created <- ExamEvent.created +// edu.teaching.exam.updated <- ExamEvent.updated +// edu.teaching.exam.published <- ExamEvent.published +// edu.teaching.exam.submitted <- ExamEvent.submitted +// edu.teaching.exam.deleted <- ExamEvent.deleted +// edu.teaching.homework.assigned <- HomeworkEvent.assigned +// edu.teaching.homework.submitted <- HomeworkEvent.submitted +// edu.teaching.homework.graded <- HomeworkEvent.graded +// edu.teaching.grade.recorded <- GradeEvent.recorded +// edu.teaching.grade.updated <- GradeEvent.updated +// edu.teaching.attendance.recorded <- AttendanceEvent.recorded +// edu.teaching.class.transferred <- ClassEvent.transferred +// +// All events MUST include: +// - schema_version: event schema version (default "v1") +// - event_id: UUID for idempotent consumption +// - occurred_at: business timestamp (ms epoch) +// - metadata: { traceId, userId } + +message EventMetadata { + string schema_version = 1; + string trace_id = 2; + string user_id = 3; +} message ClassEvent { string event_id = 1; @@ -23,7 +43,7 @@ message ClassEvent { string class_id = 5; string name = 6; string action = 7; - map metadata = 8; + EventMetadata metadata = 8; } message ExamEvent { @@ -33,9 +53,10 @@ message ExamEvent { int64 occurred_at = 4; string exam_id = 5; string class_id = 6; - string title = 7; - string action = 8; - map metadata = 9; + string subject_id = 7; + string title = 8; + string action = 9; + EventMetadata metadata = 10; } message HomeworkEvent { @@ -45,9 +66,10 @@ message HomeworkEvent { int64 occurred_at = 4; string homework_id = 5; string class_id = 6; - string title = 7; - string action = 8; - map metadata = 9; + string subject_id = 7; + string title = 8; + string action = 9; + EventMetadata metadata = 10; } message GradeEvent { @@ -58,8 +80,21 @@ message GradeEvent { string grade_id = 5; string student_id = 6; string score = 7; - string action = 8; - map metadata = 9; + string total_score = 8; + string action = 9; + EventMetadata metadata = 10; +} + +message AttendanceEvent { + string event_id = 1; + string aggregate_id = 2; + string event_type = 3; + int64 occurred_at = 4; + string attendance_id = 5; + string schedule_id = 6; + string student_id = 7; + string status = 8; + EventMetadata metadata = 9; } // IAM 用户事件(edu.iam.user.events topic) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46db4b6..d4e5df9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,7 +222,7 @@ importers: version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^10.4.0 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.3.1 @@ -491,6 +491,9 @@ importers: prom-client: specifier: ^15.1.0 version: 15.1.3 + redis: + specifier: ^4.7.0 + version: 4.7.1 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -3261,8 +3264,34 @@ packages: '@protobufjs/utf8@1.1.2': resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} - '@rolldown/pluginutils@1.0.0-beta.27': - resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} @@ -4382,6 +4411,10 @@ packages: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} @@ -5259,13 +5292,9 @@ packages: generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} @@ -6669,6 +6698,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -10554,7 +10586,31 @@ snapshots: '@protobufjs/utf8@1.1.2': {} - '@rolldown/pluginutils@1.0.0-beta.27': {} + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -11750,6 +11806,8 @@ snapshots: cluster-key-slot@1.1.1: {} + cluster-key-slot@1.1.2: {} + code-block-writer@13.0.3: {} color-convert@2.0.1: @@ -12786,9 +12844,7 @@ snapshots: dependencies: is-property: 1.0.2 - generator-function@2.0.1: {} - - gensync@1.0.0-beta.2: {} + generic-pool@3.9.0: {} get-caller-file@2.0.5: {} @@ -14239,6 +14295,15 @@ snapshots: dependencies: redis-errors: 1.2.0 + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: diff --git a/scripts/core-edu-init.sql b/scripts/core-edu-init.sql index d301ec4..897b620 100644 --- a/scripts/core-edu-init.sql +++ b/scripts/core-edu-init.sql @@ -1,65 +1,265 @@ --- CoreEdu 服务数据库初始化脚本 --- 表:core_edu_exams / core_edu_homework / core_edu_grades / core_edu_outbox +-- CoreEdu 服务数据库初始化脚本(P3 完整版) +-- 共 14 表:考试域 3 + 作业域 3 + 成绩域 2 + 排课考勤域 4 + Outbox 1 + 教师关联 1 +-- 仲裁依据:02-architecture-design.md §3.1 + president-final-rulings §2.5 + +-- ============================================================================ +-- 考试域 +-- ============================================================================ CREATE TABLE IF NOT EXISTS core_edu_exams ( id CHAR(36) NOT NULL PRIMARY KEY, class_id CHAR(36) NOT NULL, + subject_id CHAR(36) NOT NULL, title VARCHAR(200) NOT NULL, description TEXT, exam_date DATETIME NOT NULL, - duration VARCHAR(50) NOT NULL, - total_score VARCHAR(10) NOT NULL, + duration INT NOT NULL, + total_score DECIMAL(6,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'draft', + status_changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + status_changed_by CHAR(36), + school_id CHAR(36) NOT NULL, created_by CHAR(36) NOT NULL, + archived_at DATETIME NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_exams_class_id (class_id), - INDEX idx_exams_status (status), + INDEX idx_exams_class_status (class_id, status), + INDEX idx_exams_school_date (school_id, exam_date), INDEX idx_exams_created_by (created_by) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS core_edu_exam_questions ( + id CHAR(36) NOT NULL PRIMARY KEY, + exam_id CHAR(36) NOT NULL, + question_id CHAR(36) NOT NULL, + `order` INT NOT NULL, + score DECIMAL(6,2) NOT NULL, + question_type VARCHAR(30) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_exam_questions_exam_id (exam_id), + INDEX idx_exam_questions_question_id (question_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS core_edu_exam_submissions ( + id CHAR(36) NOT NULL PRIMARY KEY, + exam_id CHAR(36) NOT NULL, + student_id CHAR(36) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'not_submitted', + submitted_at DATETIME NULL, + graded_at DATETIME NULL, + graded_by CHAR(36), + total_score DECIMAL(6,2) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_exam_student (exam_id, student_id), + INDEX idx_exam_submissions_exam_id (exam_id), + INDEX idx_exam_submissions_student_id (student_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================ +-- 作业域 +-- ============================================================================ + CREATE TABLE IF NOT EXISTS core_edu_homework ( id CHAR(36) NOT NULL PRIMARY KEY, class_id CHAR(36) NOT NULL, + subject_id CHAR(36) NOT NULL, title VARCHAR(200) NOT NULL, description TEXT, due_date DATETIME NOT NULL, + grace_period INT NOT NULL DEFAULT 300, status VARCHAR(20) NOT NULL DEFAULT 'assigned', + school_id CHAR(36) NOT NULL, created_by CHAR(36) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_homework_class_id (class_id), - INDEX idx_homework_status (status), + INDEX idx_homework_class_status (class_id, status), INDEX idx_homework_created_by (created_by) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS core_edu_homework_submissions ( + id CHAR(36) NOT NULL PRIMARY KEY, + homework_id CHAR(36) NOT NULL, + student_id CHAR(36) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'not_submitted', + submitted_at DATETIME NULL, + graded_at DATETIME NULL, + graded_by CHAR(36), + total_score DECIMAL(6,2) NULL, + feedback TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_hw_student (homework_id, student_id), + INDEX idx_hw_submissions_homework_id (homework_id), + INDEX idx_hw_submissions_student_id (student_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS core_edu_homework_answers ( + id CHAR(36) NOT NULL PRIMARY KEY, + submission_id CHAR(36) NOT NULL, + question_id CHAR(36) NOT NULL, + answer TEXT, + score DECIMAL(6,2) NULL, + teacher_comment TEXT, + is_correct BOOLEAN NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_hw_answers_submission_id (submission_id), + INDEX idx_hw_answers_question_id (question_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================ +-- 成绩域 +-- ============================================================================ + CREATE TABLE IF NOT EXISTS core_edu_grades ( id CHAR(36) NOT NULL PRIMARY KEY, student_id CHAR(36) NOT NULL, exam_id CHAR(36), homework_id CHAR(36), - score VARCHAR(10) NOT NULL, + score DECIMAL(6,2) NOT NULL, + total_score DECIMAL(6,2) NOT NULL, feedback TEXT, graded_by CHAR(36) NOT NULL, + school_id CHAR(36) NOT NULL, + idempotency_key VARCHAR(128), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_student_exam (student_id, exam_id), + UNIQUE KEY uniq_student_hw (student_id, homework_id), + UNIQUE KEY uniq_idempotency (idempotency_key), INDEX idx_grades_student_id (student_id), INDEX idx_grades_exam_id (exam_id), INDEX idx_grades_homework_id (homework_id), INDEX idx_grades_graded_by (graded_by) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +CREATE TABLE IF NOT EXISTS core_edu_grade_formulas ( + id CHAR(36) NOT NULL PRIMARY KEY, + scope VARCHAR(20) NOT NULL, + scope_id CHAR(36) NOT NULL, + formula_type VARCHAR(20) NOT NULL, + weights JSON, + custom_expression TEXT, + effective_from DATETIME NOT NULL, + effective_to DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_grade_formulas_scope (scope, scope_id), + INDEX idx_grade_formulas_effective (effective_from, effective_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================ +-- 排课考勤域 +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS core_edu_courses ( + id CHAR(36) NOT NULL PRIMARY KEY, + class_id CHAR(36) NOT NULL, + subject_id CHAR(36) NOT NULL, + teacher_id CHAR(36) NOT NULL, + school_id CHAR(36) NOT NULL, + name VARCHAR(200) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_courses_class_id (class_id), + INDEX idx_courses_teacher_id (teacher_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS core_edu_lessons ( + id CHAR(36) NOT NULL PRIMARY KEY, + course_id CHAR(36) NOT NULL, + title VARCHAR(200) NOT NULL, + `order` INT NOT NULL, + knowledge_point_ids JSON, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_lessons_course_id (course_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS core_edu_schedules ( + id CHAR(36) NOT NULL PRIMARY KEY, + course_id CHAR(36) NOT NULL, + lesson_id CHAR(36), + teacher_id CHAR(36) NOT NULL, + class_id CHAR(36) NOT NULL, + room_id CHAR(36), + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'scheduled', + school_id CHAR(36) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_schedules_teacher_time (teacher_id, start_time, end_time), + INDEX idx_schedules_class_time (class_id, start_time, end_time), + INDEX idx_schedules_course_id (course_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS core_edu_attendance ( + id CHAR(36) NOT NULL PRIMARY KEY, + schedule_id CHAR(36) NOT NULL, + student_id CHAR(36) NOT NULL, + status VARCHAR(20) NOT NULL, + remark TEXT, + recorded_by CHAR(36) NOT NULL, + school_id CHAR(36) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_sched_student (schedule_id, student_id), + INDEX idx_attendance_student_id (student_id), + INDEX idx_attendance_schedule_id (schedule_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================ +-- Outbox(事务性发件箱) +-- ============================================================================ + CREATE TABLE IF NOT EXISTS core_edu_outbox ( id CHAR(36) NOT NULL PRIMARY KEY, + event_id CHAR(36) NOT NULL, aggregate_id CHAR(36) NOT NULL, aggregate_type VARCHAR(50) NOT NULL, event_type VARCHAR(100) NOT NULL, + occurred_at DATETIME NOT NULL, payload TEXT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', retry_count BIGINT NOT NULL DEFAULT 0, + next_retry_at DATETIME NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, processed_at TIMESTAMP NULL, - INDEX idx_outbox_status (status), - INDEX idx_outbox_aggregate (aggregate_type, aggregate_id), - INDEX idx_outbox_created_at (created_at) + UNIQUE KEY uniq_event_id (event_id), + INDEX idx_outbox_status_retry (status, next_retry_at), + INDEX idx_outbox_aggregate (aggregate_type, aggregate_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================ +-- 教师关联(IAM 事件消费幂等) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS core_edu_teacher_associations ( + id CHAR(36) NOT NULL PRIMARY KEY, + teacher_id CHAR(36) NOT NULL, + class_id CHAR(36) NOT NULL, + subject_id CHAR(36), + school_id CHAR(36) NOT NULL, + source VARCHAR(20) NOT NULL DEFAULT 'iam_event', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_teacher_class_subject (teacher_id, class_id, subject_id), + INDEX idx_teacher_assoc_teacher_id (teacher_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================ +-- 班级表(从 classes 服务合并,保留原表名兼容 CDC) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS classes ( + id CHAR(36) NOT NULL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + grade_id CHAR(36) NOT NULL, + head_teacher_id CHAR(36), + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_classes_grade_id (grade_id), + INDEX idx_classes_head_teacher (head_teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/services/core-edu/package.json b/services/core-edu/package.json index 9dd4782..4c8b531 100644 --- a/services/core-edu/package.json +++ b/services/core-edu/package.json @@ -26,6 +26,7 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.53.0", "zod": "^3.23.0", "uuid": "^10.0.0", + "redis": "^4.7.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.0" }, diff --git a/services/core-edu/src/app.module.ts b/services/core-edu/src/app.module.ts index 143c3b7..1f77bb0 100644 --- a/services/core-edu/src/app.module.ts +++ b/services/core-edu/src/app.module.ts @@ -3,12 +3,25 @@ import { APP_GUARD } from "@nestjs/core"; import { ExamsModule } from "./exams/exams.module.js"; import { HomeworkModule } from "./homework/homework.module.js"; import { GradesModule } from "./grades/grades.module.js"; +import { AttendanceModule } from "./attendance/attendance.module.js"; +import { ClassesModule } from "./classes/classes.module.js"; +import { SchedulingModule } from "./scheduling/scheduling.module.js"; +import { IamConsumerModule } from "./iam-consumer/iam-consumer.module.js"; import { HealthModule } from "./shared/health/health.module.js"; import { PermissionGuard } from "./middleware/permission.guard.js"; import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; @Module({ - imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule], + imports: [ + ExamsModule, + HomeworkModule, + GradesModule, + AttendanceModule, + ClassesModule, + SchedulingModule, + IamConsumerModule, + HealthModule, + ], providers: [ { provide: APP_GUARD, useClass: PermissionGuard }, LifecycleService, diff --git a/services/core-edu/src/attendance/attendance-status.ts b/services/core-edu/src/attendance/attendance-status.ts new file mode 100644 index 0000000..e8724c3 --- /dev/null +++ b/services/core-edu/src/attendance/attendance-status.ts @@ -0,0 +1,20 @@ +/** + * 考勤状态枚举(纯常量,用于校验) + * + * 状态值(ISSUE-003 仲裁 scheme A): + * present / absent / late / leave + */ +export const ATTENDANCE_STATUSES = [ + "present", + "absent", + "late", + "leave", +] as const; + +export type AttendanceStatus = (typeof ATTENDANCE_STATUSES)[number]; + +export function isValidAttendanceStatus( + status: string, +): status is AttendanceStatus { + return (ATTENDANCE_STATUSES as readonly string[]).includes(status); +} diff --git a/services/core-edu/src/attendance/attendance.controller.ts b/services/core-edu/src/attendance/attendance.controller.ts new file mode 100644 index 0000000..e7e512c --- /dev/null +++ b/services/core-edu/src/attendance/attendance.controller.ts @@ -0,0 +1,83 @@ +import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common"; +import { z } from "zod"; +import { + AttendanceService, + type RecordAttendanceInput, +} from "./attendance.service.js"; +import { + Permissions, + RequirePermission, +} from "../middleware/permission.guard.js"; +import type { AuthenticatedRequest } from "../middleware/auth.middleware.js"; +import { + UnauthorizedError, + ValidationError, +} from "../shared/errors/application-error.js"; + +const recordAttendanceSchema = z.object({ + scheduleId: z.string().min(1), + studentId: z.string().min(1), + status: z.enum(["present", "absent", "late", "leave"]), + remark: z.string().optional(), + schoolId: z.string().min(1), +}); + +@Controller("v1/attendance") +export class AttendanceController { + constructor(private readonly attendanceService: AttendanceService) {} + + @Post() + @RequirePermission(Permissions.ATTENDANCE_CREATE) + async record( + @Body() body: unknown, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: { id: string } }> { + const userId = req.userId; + if (!userId) { + throw new UnauthorizedError("Missing x-user-id header"); + } + const parsed = recordAttendanceSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError( + "Invalid attendance input", + parsed.error.flatten(), + ); + } + const input: RecordAttendanceInput = { + ...parsed.data, + recordedBy: userId, + }; + const result = await this.attendanceService.recordAttendance(input, userId); + return { success: true, data: result }; + } + + @Get(":id") + @RequirePermission(Permissions.ATTENDANCE_READ) + async findOne(@Param("id") id: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.attendanceService.getAttendance(id); + return { success: true, data }; + } + + @Get("student/:studentId") + @RequirePermission(Permissions.ATTENDANCE_READ) + async listByStudent(@Param("studentId") studentId: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.attendanceService.listByStudent(studentId); + return { success: true, data }; + } + + @Get("class/:classId") + @RequirePermission(Permissions.ATTENDANCE_READ) + async listByClass(@Param("classId") classId: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.attendanceService.listByClass(classId); + return { success: true, data }; + } +} diff --git a/services/core-edu/src/attendance/attendance.module.ts b/services/core-edu/src/attendance/attendance.module.ts new file mode 100644 index 0000000..af41d8a --- /dev/null +++ b/services/core-edu/src/attendance/attendance.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { AttendanceController } from "./attendance.controller.js"; +import { AttendanceService } from "./attendance.service.js"; + +@Module({ + controllers: [AttendanceController], + providers: [AttendanceService], + exports: [AttendanceService], +}) +export class AttendanceModule {} diff --git a/services/core-edu/src/attendance/attendance.repository.ts b/services/core-edu/src/attendance/attendance.repository.ts new file mode 100644 index 0000000..3d7862b --- /dev/null +++ b/services/core-edu/src/attendance/attendance.repository.ts @@ -0,0 +1,74 @@ +import { eq, and, inArray } from "drizzle-orm"; +import { db } from "../config/database.js"; +import { + attendance, + type Attendance, + type NewAttendance, +} from "./attendance.schema.js"; +import { schedules } from "../scheduling/scheduling.schema.js"; + +export class AttendanceRepository { + async findById(id: string): Promise { + const [result] = await db + .select() + .from(attendance) + .where(eq(attendance.id, id)) + .limit(1); + return result; + } + + async findByScheduleAndStudent( + scheduleId: string, + studentId: string, + ): Promise { + const [result] = await db + .select() + .from(attendance) + .where( + and( + eq(attendance.scheduleId, scheduleId), + eq(attendance.studentId, studentId), + ), + ) + .limit(1); + return result; + } + + async findByStudentId(studentId: string): Promise { + return db + .select() + .from(attendance) + .where(eq(attendance.studentId, studentId)); + } + + async findByScheduleId(scheduleId: string): Promise { + return db + .select() + .from(attendance) + .where(eq(attendance.scheduleId, scheduleId)); + } + + async findByClassId(classId: string): Promise { + // Two-step query: find schedule IDs for class, then attendance by those IDs + const classSchedules = await db + .select({ id: schedules.id }) + .from(schedules) + .where(eq(schedules.classId, classId)); + const scheduleIds = classSchedules.map((s) => s.id); + if (scheduleIds.length === 0) return []; + return db + .select() + .from(attendance) + .where(inArray(attendance.scheduleId, scheduleIds)); + } + + async create(record: NewAttendance, tx: typeof db = db): Promise { + await tx.insert(attendance).values(record); + } + + async update(id: string, data: Partial): Promise { + await db.update(attendance).set(data).where(eq(attendance.id, id)); + } +} + +export const attendanceRepository = new AttendanceRepository(); diff --git a/services/core-edu/src/attendance/attendance.schema.ts b/services/core-edu/src/attendance/attendance.schema.ts new file mode 100644 index 0000000..38155b7 --- /dev/null +++ b/services/core-edu/src/attendance/attendance.schema.ts @@ -0,0 +1,40 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + char, + index, + uniqueIndex, +} from "drizzle-orm/mysql-core"; + +// 考勤记录表 +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) => ({ + uniqSchedStudent: uniqueIndex("uniq_sched_student").on( + table.scheduleId, + table.studentId, + ), + idxAttendanceStudentId: index("idx_attendance_student_id").on( + table.studentId, + ), + idxAttendanceScheduleId: index("idx_attendance_schedule_id").on( + table.scheduleId, + ), + }), +); + +export type Attendance = typeof attendance.$inferSelect; +export type NewAttendance = typeof attendance.$inferInsert; diff --git a/services/core-edu/src/attendance/attendance.service.ts b/services/core-edu/src/attendance/attendance.service.ts new file mode 100644 index 0000000..156d939 --- /dev/null +++ b/services/core-edu/src/attendance/attendance.service.ts @@ -0,0 +1,116 @@ +import { randomUUID } from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import { db } from "../config/database.js"; +import { attendanceRepository } from "./attendance.repository.js"; +import { outboxRepository } from "../shared/outbox/outbox.repository.js"; +import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js"; +import { isValidAttendanceStatus } from "./attendance-status.js"; +import { + NotFoundError, + ValidationError, + ConflictError, +} from "../shared/errors/application-error.js"; +import type { Attendance } from "./attendance.schema.js"; + +export interface RecordAttendanceInput { + scheduleId: string; + studentId: string; + status: string; + remark?: string; + recordedBy: string; + schoolId: string; +} + +@Injectable() +export class AttendanceService { + async recordAttendance( + input: RecordAttendanceInput, + userId?: string, + ): Promise<{ id: string }> { + if ( + !input.scheduleId || + !input.studentId || + !input.recordedBy || + !input.schoolId + ) { + throw new ValidationError( + "scheduleId, studentId, recordedBy, schoolId are required", + ); + } + if (!isValidAttendanceStatus(input.status)) { + throw new ValidationError( + `Invalid attendance status: ${input.status}. Must be one of: present, absent, late, leave`, + ); + } + + // Idempotency: check existing record + const existing = await attendanceRepository.findByScheduleAndStudent( + input.scheduleId, + input.studentId, + ); + if (existing) { + throw new ConflictError( + `Attendance already recorded for student ${input.studentId} in schedule ${input.scheduleId}`, + ); + } + + const id = randomUUID(); + const event = buildEvent({ + aggregateId: id, + eventType: "attendance.recorded", + payload: { + attendanceId: id, + scheduleId: input.scheduleId, + studentId: input.studentId, + status: input.status, + }, + userId, + }); + + await db.transaction(async (tx) => { + await attendanceRepository.create( + { + id, + scheduleId: input.scheduleId, + studentId: input.studentId, + status: input.status, + remark: input.remark, + recordedBy: input.recordedBy, + schoolId: input.schoolId, + }, + tx, + ); + await outboxRepository.create( + { + id: randomUUID(), + eventId: event.event_id, + aggregateId: id, + aggregateType: "attendance", + eventType: "attendance.recorded", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), + status: "pending", + }, + tx, + ); + }); + + return { id }; + } + + async getAttendance(id: string): Promise { + const record = await attendanceRepository.findById(id); + if (!record) { + throw new NotFoundError(`Attendance ${id} not found`); + } + return record; + } + + async listByStudent(studentId: string): Promise { + return attendanceRepository.findByStudentId(studentId); + } + + async listByClass(classId: string): Promise { + return attendanceRepository.findByClassId(classId); + } +} diff --git a/services/core-edu/src/classes/classes.controller.ts b/services/core-edu/src/classes/classes.controller.ts new file mode 100644 index 0000000..aed6ac3 --- /dev/null +++ b/services/core-edu/src/classes/classes.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Get, Param, Post } from "@nestjs/common"; +import { z } from "zod"; +import { ClassesService } from "./classes.service.js"; +import { + Permissions, + RequirePermission, +} from "../middleware/permission.guard.js"; +import { ValidationError } from "../shared/errors/application-error.js"; + +const batchGetSchema = z.object({ + ids: z.array(z.string().min(1)).min(1), +}); + +@Controller("v1/classes") +export class ClassesController { + constructor(private readonly classesService: ClassesService) {} + + @Get(":id") + @RequirePermission(Permissions.CLASS_READ) + async findOne(@Param("id") id: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.classesService.getClass(id); + return { success: true, data }; + } + + @Get("teacher/:teacherId") + @RequirePermission(Permissions.CLASS_READ) + async listByTeacher(@Param("teacherId") teacherId: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.classesService.getClassesByTeacher(teacherId); + return { success: true, data }; + } + + @Post("batch") + @RequirePermission(Permissions.CLASS_READ) + async batchGet(@Body() body: unknown): Promise<{ + success: true; + data: Awaited>; + }> { + const parsed = batchGetSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError( + "Invalid batch request", + parsed.error.flatten(), + ); + } + const data = await this.classesService.batchGetClasses(parsed.data.ids); + return { success: true, data }; + } + + @Get(":classId/students") + @RequirePermission(Permissions.CLASS_READ) + async listStudents(@Param("classId") classId: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.classesService.listStudentsByClass(classId); + return { success: true, data }; + } +} diff --git a/services/core-edu/src/classes/classes.module.ts b/services/core-edu/src/classes/classes.module.ts index 830a80e..60bcde8 100644 --- a/services/core-edu/src/classes/classes.module.ts +++ b/services/core-edu/src/classes/classes.module.ts @@ -1,15 +1,10 @@ -import { Module } from '@nestjs/common'; +import { Module } from "@nestjs/common"; +import { ClassesController } from "./classes.controller.js"; +import { ClassesService } from "./classes.service.js"; -/** - * Classes module - P3 skeleton placeholder. - * - * The classes domain was merged into CoreEdu in P3. This empty module - * exists so that future class-transfer features and event handlers can - * be registered without restructuring the AppModule imports. - */ @Module({ - controllers: [], - providers: [], - exports: [], + controllers: [ClassesController], + providers: [ClassesService], + exports: [ClassesService], }) export class ClassesModule {} diff --git a/services/core-edu/src/classes/classes.repository.ts b/services/core-edu/src/classes/classes.repository.ts new file mode 100644 index 0000000..52078c2 --- /dev/null +++ b/services/core-edu/src/classes/classes.repository.ts @@ -0,0 +1,47 @@ +import { eq, inArray } from "drizzle-orm"; +import { db } from "../config/database.js"; +import { classes, type Class } from "./classes.schema.js"; +import { teacherAssociations } from "./teacher-associations.schema.js"; + +export class ClassesRepository { + async findById(id: string): Promise { + const [result] = await db + .select() + .from(classes) + .where(eq(classes.id, id)) + .limit(1); + return result; + } + + async findByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + return db.select().from(classes).where(inArray(classes.id, ids)); + } + + async findByTeacherId(teacherId: string): Promise { + // Via teacher associations + const associations = await db + .select({ classId: teacherAssociations.classId }) + .from(teacherAssociations) + .where(eq(teacherAssociations.teacherId, teacherId)); + const classIds = [...new Set(associations.map((a) => a.classId))]; + if (classIds.length === 0) return []; + return db.select().from(classes).where(inArray(classes.id, classIds)); + } + + async create(data: { + id: string; + name: string; + gradeId: string; + headTeacherId?: string; + description?: string; + }): Promise { + await db.insert(classes).values(data); + } + + async update(id: string, data: Partial): Promise { + await db.update(classes).set(data).where(eq(classes.id, id)); + } +} + +export const classesRepository = new ClassesRepository(); diff --git a/services/core-edu/src/classes/classes.schema.ts b/services/core-edu/src/classes/classes.schema.ts new file mode 100644 index 0000000..dcbbe46 --- /dev/null +++ b/services/core-edu/src/classes/classes.schema.ts @@ -0,0 +1,31 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + char, + index, +} from "drizzle-orm/mysql-core"; + +// 班级表(从 classes 服务合并,保留原表名兼容 CDC) +export const classes = mysqlTable( + "classes", + { + id: char("id", { length: 36 }).notNull().primaryKey(), + name: varchar("name", { length: 100 }).notNull(), + gradeId: char("grade_id", { length: 36 }).notNull(), + headTeacherId: char("head_teacher_id", { length: 36 }), + description: text("description"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + idxClassesGradeId: index("idx_classes_grade_id").on(table.gradeId), + idxClassesHeadTeacher: index("idx_classes_head_teacher").on( + table.headTeacherId, + ), + }), +); + +export type Class = typeof classes.$inferSelect; +export type NewClass = typeof classes.$inferInsert; diff --git a/services/core-edu/src/classes/classes.service.ts b/services/core-edu/src/classes/classes.service.ts new file mode 100644 index 0000000..b3d134a --- /dev/null +++ b/services/core-edu/src/classes/classes.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from "@nestjs/common"; +import { classesRepository } from "./classes.repository.js"; +import { NotFoundError } from "../shared/errors/application-error.js"; +import type { Class } from "./classes.schema.js"; + +@Injectable() +export class ClassesService { + async getClass(id: string): Promise { + const cls = await classesRepository.findById(id); + if (!cls) { + throw new NotFoundError(`Class ${id} not found`); + } + return cls; + } + + async getClassesByTeacher(teacherId: string): Promise { + return classesRepository.findByTeacherId(teacherId); + } + + async batchGetClasses(ids: string[]): Promise { + return classesRepository.findByIds(ids); + } + + /** + * List students by class. + * P3: This requires IAM service integration via gRPC. + * Currently returns empty array as placeholder - IAM consumer will populate + * teacher_associations and the actual student list comes from IAM. + */ + async listStudentsByClass( + classId: string, + ): Promise> { + // Verify class exists + await this.getClass(classId); + // P3: Student list comes from IAM service (gRPC call to iam.GetUserByClass) + // For now, return empty - this will be implemented when IAM gRPC client is available + return []; + } +} diff --git a/services/core-edu/src/classes/teacher-associations.schema.ts b/services/core-edu/src/classes/teacher-associations.schema.ts new file mode 100644 index 0000000..c491933 --- /dev/null +++ b/services/core-edu/src/classes/teacher-associations.schema.ts @@ -0,0 +1,35 @@ +import { + mysqlTable, + varchar, + timestamp, + char, + index, + uniqueIndex, +} from "drizzle-orm/mysql-core"; + +// 教师关联表(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"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => ({ + uniqTeacherClassSubject: uniqueIndex("uniq_teacher_class_subject").on( + table.teacherId, + table.classId, + table.subjectId, + ), + idxTeacherAssocTeacherId: index("idx_teacher_assoc_teacher_id").on( + table.teacherId, + ), + }), +); + +export type TeacherAssociation = typeof teacherAssociations.$inferSelect; +export type NewTeacherAssociation = typeof teacherAssociations.$inferInsert; diff --git a/services/core-edu/src/config/env.ts b/services/core-edu/src/config/env.ts index 4a64c3c..a20161d 100644 --- a/services/core-edu/src/config/env.ts +++ b/services/core-edu/src/config/env.ts @@ -2,6 +2,7 @@ import { z } from "zod"; const envSchema = z.object({ PORT: z.string().default("3004"), + GRPC_PORT: z.string().default("50053"), DATABASE_URL: z.string().url(), REDIS_URL: z.string().url().optional(), JWT_SECRET: z.string().optional(), @@ -23,7 +24,7 @@ export function loadEnv(): Env { const result = envSchema.safeParse(process.env); if (!result.success) { console.error( - "❌ Invalid environment variables:", + "Invalid environment variables:", result.error.flatten().fieldErrors, ); throw new Error("Invalid environment configuration"); diff --git a/services/core-edu/src/config/kafka.ts b/services/core-edu/src/config/kafka.ts index 5f521e0..f8c6109 100644 --- a/services/core-edu/src/config/kafka.ts +++ b/services/core-edu/src/config/kafka.ts @@ -1,5 +1,6 @@ import { Kafka } from "kafkajs"; import { env } from "./env.js"; +import { logger } from "../shared/observability/logger.js"; export const kafka = new Kafka({ brokers: env.KAFKA_BROKERS.split(","), @@ -13,15 +14,35 @@ export const producer = kafka.producer({ export const consumer = kafka.consumer({ groupId: "core-edu-group" }); +let producerConnected = false; +let consumerConnected = false; + +producer.on("producer.connect", () => { + producerConnected = true; +}); +producer.on("producer.disconnect", () => { + producerConnected = false; +}); +consumer.on("consumer.connect", () => { + consumerConnected = true; +}); +consumer.on("consumer.disconnect", () => { + consumerConnected = false; +}); + +export function isKafkaConnected(): boolean { + return producerConnected && consumerConnected; +} + export async function connectKafka(): Promise { try { await producer.connect(); await consumer.connect(); - console.log("Kafka connected"); + logger.info("Kafka connected"); } catch (err) { - console.warn( - "Kafka connect failed, running without Kafka:", - err instanceof Error ? err.message : String(err), + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + "Kafka connect failed, running without Kafka", ); } } diff --git a/services/core-edu/src/config/redis.ts b/services/core-edu/src/config/redis.ts new file mode 100644 index 0000000..bf25b13 --- /dev/null +++ b/services/core-edu/src/config/redis.ts @@ -0,0 +1,97 @@ +import { createClient, type RedisClientType } from "redis"; +import { env } from "./env.js"; +import { logger } from "../shared/observability/logger.js"; + +let client: RedisClientType | null = null; + +/** + * 获取 Redis 客户端(单例)。 + * REDIS_URL 未配置时返回 null,调用方需做 null 检查。 + */ +export function getRedisClient(): RedisClientType | null { + if (!env.REDIS_URL) return null; + if (!client) { + client = createClient({ url: env.REDIS_URL }); + client.on("error", (err) => { + logger.error({ err: err.message }, "Redis client error"); + }); + } + return client; +} + +/** + * 连接 Redis(非阻塞:连接失败不影响服务启动)。 + */ +export async function connectRedis(): Promise { + const c = getRedisClient(); + if (!c) { + logger.warn("REDIS_URL not set, Redis disabled"); + return; + } + if (c.isOpen) return; + try { + await c.connect(); + logger.info("Redis connected"); + } catch (err) { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + "Redis connect failed, running without Redis", + ); + } +} + +/** + * 关闭 Redis 连接。 + */ +export async function disconnectRedis(): Promise { + if (client && client.isOpen) { + await client.quit(); + logger.info("Redis disconnected"); + } +} + +/** + * Redis 健康检查(用于 /readyz)。 + */ +export async function isRedisHealthy(): Promise { + if (!client || !client.isOpen) return false; + try { + const pong = await client.ping(); + return pong === "PONG"; + } catch { + return false; + } +} + +/** + * 分布式锁(用于作业提交幂等性保护)。 + * + * 使用 SET NX EX 实现: + * - 成功获取锁返回 true + * - 锁已被持有返回 false + * + * 锁自动过期(TTL),避免死锁。 + */ +export async function acquireLock( + key: string, + ttlSeconds: number, +): Promise { + const c = getRedisClient(); + if (!c || !c.isOpen) return false; + try { + const result = await c.set(key, "1", { NX: true, EX: ttlSeconds }); + return result === "OK"; + } catch { + return false; + } +} + +export async function releaseLock(key: string): Promise { + const c = getRedisClient(); + if (!c || !c.isOpen) return; + try { + await c.del(key); + } catch { + // 释放失败不影响业务(锁会自动过期) + } +} diff --git a/services/core-edu/src/exams/exam-state-machine.ts b/services/core-edu/src/exams/exam-state-machine.ts new file mode 100644 index 0000000..cbdb598 --- /dev/null +++ b/services/core-edu/src/exams/exam-state-machine.ts @@ -0,0 +1,60 @@ +/** + * 考试状态机(纯函数) + * + * 状态流转(ISSUE-003 仲裁 scheme A): + * draft → published → in_progress → grading → graded → archived + * └→ cancelled(终态) + * cancelled 可从 published/in_progress/grading 流入(异常终止) + * + * archived 为软删除终态(ISSUE-006 决策 #7:archived 仅做软删除,不物理删除) + */ +export type ExamStatus = + | "draft" + | "published" + | "in_progress" + | "grading" + | "graded" + | "archived" + | "cancelled"; + +export type ExamAction = + "publish" | "start" | "submit" | "grade" | "archive" | "cancel"; + +const TRANSITIONS: Record< + ExamStatus, + Partial> +> = { + draft: { publish: "published", cancel: "cancelled" }, + published: { start: "in_progress", cancel: "cancelled" }, + in_progress: { submit: "grading", cancel: "cancelled" }, + grading: { grade: "graded", cancel: "cancelled" }, + graded: { archive: "archived" }, + archived: {}, + cancelled: {}, +}; + +export function canTransition(from: ExamStatus, action: ExamAction): boolean { + return TRANSITIONS[from]?.[action] !== undefined; +} + +export function transition(from: ExamStatus, action: ExamAction): ExamStatus { + const next = TRANSITIONS[from]?.[action]; + if (!next) { + throw new Error(`Invalid exam state transition: ${from} --${action}-->`); + } + return next; +} + +export function isTerminal(status: ExamStatus): boolean { + return status === "archived" || status === "cancelled"; +} + +export const EXAM_STATUSES: readonly ExamStatus[] = [ + "draft", + "published", + "in_progress", + "grading", + "graded", + "archived", + "cancelled", +] as const; diff --git a/services/core-edu/src/exams/exams.controller.ts b/services/core-edu/src/exams/exams.controller.ts index 3f10dd7..e225969 100644 --- a/services/core-edu/src/exams/exams.controller.ts +++ b/services/core-edu/src/exams/exams.controller.ts @@ -8,6 +8,7 @@ import { Put, Req, } from "@nestjs/common"; +import { z } from "zod"; import { ExamsService, type CreateExamInput, @@ -18,26 +19,75 @@ import { RequirePermission, } from "../middleware/permission.guard.js"; import type { AuthenticatedRequest } from "../middleware/auth.middleware.js"; -import { UnauthorizedError } from "../shared/errors/application-error.js"; +import { + UnauthorizedError, + ValidationError, +} from "../shared/errors/application-error.js"; -@Controller("exams") +const createExamSchema = z.object({ + classId: z.string().min(1), + subjectId: z.string().min(1), + title: z.string().min(1).max(200), + description: z.string().optional(), + examDate: z.string().min(1), + duration: z.number().int().positive(), + totalScore: z.string().min(1), + schoolId: z.string().min(1), +}); + +const updateExamSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().optional(), + examDate: z.string().min(1).optional(), + duration: z.number().int().positive().optional(), + totalScore: z.string().min(1).optional(), +}); + +const answerInputSchema = z.object({ + questionId: z.string().min(1), + answer: z.string(), +}); + +const scoreInputSchema = z.object({ + questionId: z.string().min(1), + score: z.string().min(1), + teacherComment: z.string().optional(), +}); + +const submitExamSchema = z.object({ + studentId: z.string().min(1), + answers: z.array(answerInputSchema).default([]), +}); + +const gradeExamSchema = z.object({ + submissionId: z.string().min(1), + scores: z.array(scoreInputSchema).min(1), +}); + +@Controller("v1/exams") export class ExamsController { constructor(private readonly examsService: ExamsService) {} @Post() @RequirePermission(Permissions.EXAM_CREATE) async create( - @Body() body: CreateExamInput, + @Body() body: unknown, @Req() req: AuthenticatedRequest, ): Promise<{ success: true; data: { id: string } }> { const userId = req.userId; if (!userId) { throw new UnauthorizedError("Missing x-user-id header"); } - const result = await this.examsService.createExam({ - ...body, + const parsed = createExamSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError("Invalid exam input", parsed.error.flatten()); + } + const input: CreateExamInput = { + ...parsed.data, + examDate: new Date(parsed.data.examDate), createdBy: userId, - }); + }; + const result = await this.examsService.createExam(input, userId); return { success: true, data: result }; } @@ -65,9 +115,22 @@ export class ExamsController { @RequirePermission(Permissions.EXAM_UPDATE) async update( @Param("id") id: string, - @Body() body: UpdateExamInput, + @Body() body: unknown, ): Promise<{ success: true; data: { success: true } }> { - await this.examsService.updateExam(id, body); + const parsed = updateExamSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError( + "Invalid exam update input", + parsed.error.flatten(), + ); + } + const data: UpdateExamInput = { + ...parsed.data, + examDate: parsed.data.examDate + ? new Date(parsed.data.examDate) + : undefined, + }; + await this.examsService.updateExam(id, data); return { success: true, data: { success: true } }; } @@ -79,4 +142,74 @@ export class ExamsController { await this.examsService.deleteExam(id); return { success: true, data: { success: true } }; } + + @Post(":id/publish") + @RequirePermission(Permissions.EXAM_PUBLISH) + async publish( + @Param("id") id: string, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: { success: true } }> { + const userId = req.userId; + if (!userId) { + throw new UnauthorizedError("Missing x-user-id header"); + } + await this.examsService.publishExam(id, userId); + return { success: true, data: { success: true } }; + } + + @Post(":id/submit") + @RequirePermission(Permissions.EXAM_SUBMIT) + async submit( + @Param("id") id: string, + @Body() body: unknown, + ): Promise<{ success: true; data: { submissionId: string } }> { + const parsed = submitExamSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError("Invalid submit input", parsed.error.flatten()); + } + const result = await this.examsService.submitExam( + id, + parsed.data.studentId, + parsed.data.answers, + ); + return { success: true, data: result }; + } + + @Post(":id/grade") + @RequirePermission(Permissions.EXAM_GRADE) + async grade( + @Param("id") id: string, + @Body() body: unknown, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: { totalScore: string } }> { + const userId = req.userId; + if (!userId) { + throw new UnauthorizedError("Missing x-user-id header"); + } + const parsed = gradeExamSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError("Invalid grade input", parsed.error.flatten()); + } + const result = await this.examsService.gradeExam( + id, + parsed.data.submissionId, + parsed.data.scores, + userId, + ); + return { success: true, data: result }; + } + + @Post(":id/archive") + @RequirePermission(Permissions.EXAM_UPDATE) + async archive( + @Param("id") id: string, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: { success: true } }> { + const userId = req.userId; + if (!userId) { + throw new UnauthorizedError("Missing x-user-id header"); + } + await this.examsService.archiveExam(id, userId); + return { success: true, data: { success: true } }; + } } diff --git a/services/core-edu/src/exams/exams.repository.ts b/services/core-edu/src/exams/exams.repository.ts index 183b5b6..3044d49 100644 --- a/services/core-edu/src/exams/exams.repository.ts +++ b/services/core-edu/src/exams/exams.repository.ts @@ -1,6 +1,16 @@ -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { db } from "../config/database.js"; -import { exams, type Exam, type NewExam } from "./exams.schema.js"; +import { + exams, + examQuestions, + examSubmissions, + type Exam, + type NewExam, + type ExamQuestion, + type NewExamQuestion, + type ExamSubmission, + type NewExamSubmission, +} from "./exams.schema.js"; export class ExamsRepository { async findById(id: string): Promise { @@ -23,6 +33,63 @@ export class ExamsRepository { async delete(id: string): Promise { await db.delete(exams).where(eq(exams.id, id)); } + + // Exam questions + async addQuestions(questions: NewExamQuestion[]): Promise { + if (questions.length === 0) return; + await db.insert(examQuestions).values(questions); + } + + async listQuestions(examId: string): Promise { + return db + .select() + .from(examQuestions) + .where(eq(examQuestions.examId, examId)); + } + + // Exam submissions + async findSubmission( + examId: string, + studentId: string, + ): Promise { + const [result] = await db + .select() + .from(examSubmissions) + .where( + and( + eq(examSubmissions.examId, examId), + eq(examSubmissions.studentId, studentId), + ), + ) + .limit(1); + return result; + } + + async findSubmissionById(id: string): Promise { + const [result] = await db + .select() + .from(examSubmissions) + .where(eq(examSubmissions.id, id)) + .limit(1); + return result; + } + + async createSubmission( + submission: NewExamSubmission, + tx: typeof db = db, + ): Promise { + await tx.insert(examSubmissions).values(submission); + } + + async updateSubmission( + id: string, + data: Partial, + ): Promise { + await db + .update(examSubmissions) + .set(data) + .where(eq(examSubmissions.id, id)); + } } export const examsRepository = new ExamsRepository(); diff --git a/services/core-edu/src/exams/exams.schema.ts b/services/core-edu/src/exams/exams.schema.ts index e28ef51..963cbd2 100644 --- a/services/core-edu/src/exams/exams.schema.ts +++ b/services/core-edu/src/exams/exams.schema.ts @@ -5,21 +5,102 @@ import { timestamp, char, datetime, -} from 'drizzle-orm/mysql-core'; + int, + decimal, + index, + uniqueIndex, +} from "drizzle-orm/mysql-core"; -export const exams = mysqlTable('core_edu_exams', { - id: char('id', { length: 36 }).notNull().primaryKey(), - classId: char('class_id', { length: 36 }).notNull(), - title: varchar('title', { length: 200 }).notNull(), - description: text('description'), - examDate: datetime('exam_date').notNull(), - duration: varchar('duration', { length: 50 }).notNull(), - totalScore: varchar('total_score', { length: 10 }).notNull(), - status: varchar('status', { length: 20 }).notNull().default('draft'), - createdBy: char('created_by', { length: 36 }).notNull(), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), -}); +// 考试主表 +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(), + title: varchar("title", { length: 200 }).notNull(), + description: text("description"), + examDate: datetime("exam_date").notNull(), + duration: int("duration").notNull(), // 秒 + totalScore: decimal("total_score", { precision: 6, scale: 2 }).notNull(), + status: varchar("status", { length: 20 }).notNull().default("draft"), + statusChangedAt: timestamp("status_changed_at").notNull().defaultNow(), + statusChangedBy: char("status_changed_by", { length: 36 }), + schoolId: char("school_id", { length: 36 }).notNull(), + createdBy: char("created_by", { length: 36 }).notNull(), + archivedAt: datetime("archived_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + idxExamsClassStatus: index("idx_exams_class_status").on( + table.classId, + table.status, + ), + idxExamsSchoolDate: index("idx_exams_school_date").on( + table.schoolId, + table.examDate, + ), + idxExamsCreatedBy: index("idx_exams_created_by").on(table.createdBy), + }), +); + +// 考试题目关联表 +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(), + order: int("order").notNull(), + score: decimal("score", { precision: 6, scale: 2 }).notNull(), + questionType: varchar("question_type", { length: 30 }).notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => ({ + idxExamQuestionsExamId: index("idx_exam_questions_exam_id").on( + table.examId, + ), + idxExamQuestionsQuestionId: index("idx_exam_questions_question_id").on( + table.questionId, + ), + }), +); + +// 考试提交表 +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("not_submitted"), + 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(), + }, + (table) => ({ + uniqExamStudent: uniqueIndex("uniq_exam_student").on( + table.examId, + table.studentId, + ), + idxExamSubmissionsExamId: index("idx_exam_submissions_exam_id").on( + table.examId, + ), + idxExamSubmissionsStudentId: index("idx_exam_submissions_student_id").on( + table.studentId, + ), + }), +); export type Exam = typeof exams.$inferSelect; export type NewExam = typeof exams.$inferInsert; +export type ExamQuestion = typeof examQuestions.$inferSelect; +export type NewExamQuestion = typeof examQuestions.$inferInsert; +export type ExamSubmission = typeof examSubmissions.$inferSelect; +export type NewExamSubmission = typeof examSubmissions.$inferInsert; diff --git a/services/core-edu/src/exams/exams.service.ts b/services/core-edu/src/exams/exams.service.ts index 2ba14f8..7559b5f 100644 --- a/services/core-edu/src/exams/exams.service.ts +++ b/services/core-edu/src/exams/exams.service.ts @@ -5,19 +5,31 @@ import { db } from "../config/database.js"; import { exams } from "./exams.schema.js"; import { examsRepository } from "./exams.repository.js"; import { outboxRepository } from "../shared/outbox/outbox.repository.js"; +import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js"; +import { + canTransition, + transition, + type ExamStatus, + type ExamAction, +} from "./exam-state-machine.js"; import type { Exam, NewExam } from "./exams.schema.js"; import { NotFoundError, ValidationError, + ConflictError, + ApplicationError, + CoreEduErrorCode, } from "../shared/errors/application-error.js"; export interface CreateExamInput { classId: string; + subjectId: string; title: string; description?: string; examDate: Date | string; - duration: string; + duration: number; totalScore: string; + schoolId: string; createdBy: string; } @@ -25,26 +37,45 @@ export interface UpdateExamInput { title?: string; description?: string; examDate?: Date; - duration?: string; + duration?: number; totalScore?: string; - status?: string; +} + +export interface AnswerInput { + questionId: string; + answer: string; +} + +export interface ScoreInput { + questionId: string; + score: string; + teacherComment?: string; } @Injectable() export class ExamsService { - async createExam(input: CreateExamInput): Promise<{ id: string }> { - if (!input.classId || !input.title || !input.createdBy) { - throw new ValidationError("classId, title, createdBy are required"); + async createExam( + input: CreateExamInput, + userId?: string, + ): Promise<{ id: string }> { + if ( + !input.classId || + !input.title || + !input.createdBy || + !input.subjectId + ) { + throw new ValidationError( + "classId, subjectId, title, createdBy are required", + ); } const id = randomUUID(); const exam: NewExam = { id, classId: input.classId, + subjectId: input.subjectId, title: input.title, description: input.description, - // Drizzle datetime 列需要 Date 对象(调用 toISOString)。HTTP 请求体里的 - // examDate 是 ISO 字符串,这里统一转成 Date,避免 "toISOString is not a function"。 examDate: input.examDate instanceof Date ? input.examDate @@ -52,22 +83,34 @@ export class ExamsService { duration: input.duration, totalScore: input.totalScore, status: "draft", + statusChangedAt: new Date(), + schoolId: input.schoolId, createdBy: input.createdBy, }; + const event = buildEvent({ + aggregateId: id, + eventType: "exam.created", + payload: { + examId: id, + classId: input.classId, + subjectId: input.subjectId, + title: input.title, + }, + userId, + }); + await db.transaction(async (tx) => { await tx.insert(exams).values(exam); await outboxRepository.create( { id: randomUUID(), + eventId: event.event_id, aggregateId: id, aggregateType: "exam", eventType: "exam.created", - payload: JSON.stringify({ - id, - classId: input.classId, - title: input.title, - }), + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), status: "pending", }, tx, @@ -94,16 +137,28 @@ export class ExamsService { if (!existing) { throw new NotFoundError(`Exam ${id} not found`); } + if (existing.status !== "draft") { + throw new ConflictError( + `Cannot update exam in status ${existing.status} (only draft allows edits)`, + ); + } await db.transaction(async (tx) => { await tx.update(exams).set(data).where(eq(exams.id, id)); + const event = buildEvent({ + aggregateId: id, + eventType: "exam.updated", + payload: { examId: id, changes: data }, + }); await outboxRepository.create( { id: randomUUID(), + eventId: event.event_id, aggregateId: id, aggregateType: "exam", eventType: "exam.updated", - payload: JSON.stringify({ id, changes: data }), + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), status: "pending", }, tx, @@ -119,17 +174,231 @@ export class ExamsService { await db.transaction(async (tx) => { await tx.delete(exams).where(eq(exams.id, id)); + const event = buildEvent({ + aggregateId: id, + eventType: "exam.deleted", + payload: { examId: id }, + }); await outboxRepository.create( { id: randomUUID(), + eventId: event.event_id, aggregateId: id, aggregateType: "exam", eventType: "exam.deleted", - payload: JSON.stringify({ id }), + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), status: "pending", }, tx, ); }); } + + async publishExam(id: string, publishedBy: string): Promise { + const exam = await examsRepository.findById(id); + if (!exam) { + throw new NotFoundError(`Exam ${id} not found`); + } + const currentStatus = exam.status as ExamStatus; + this.assertTransition(currentStatus, "publish"); + const newStatus = transition(currentStatus, "publish"); + + await db.transaction(async (tx) => { + await tx + .update(exams) + .set({ + status: newStatus, + statusChangedAt: new Date(), + statusChangedBy: publishedBy, + }) + .where(eq(exams.id, id)); + const event = buildEvent({ + aggregateId: id, + eventType: "exam.published", + payload: { + examId: id, + classId: exam.classId, + subjectId: exam.subjectId, + }, + userId: publishedBy, + }); + await outboxRepository.create( + { + id: randomUUID(), + eventId: event.event_id, + aggregateId: id, + aggregateType: "exam", + eventType: "exam.published", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), + status: "pending", + }, + tx, + ); + }); + } + + async submitExam( + examId: string, + studentId: string, + _answers: AnswerInput[], + ): Promise<{ submissionId: string }> { + const exam = await examsRepository.findById(examId); + if (!exam) { + throw new NotFoundError(`Exam ${examId} not found`); + } + + // Check existing submission (idempotency via unique index) + const existing = await examsRepository.findSubmission(examId, studentId); + if ( + existing && + (existing.status === "submitted" || existing.status === "graded") + ) { + throw new ConflictError( + `Student ${studentId} already submitted exam ${examId}`, + ); + } + + const submissionId = randomUUID(); + await db.transaction(async (tx) => { + await examsRepository.createSubmission( + { + id: submissionId, + examId, + studentId, + status: "submitted", + submittedAt: new Date(), + }, + tx, + ); + + // ISSUE-006 决策 #6: exam.submitted 事件只带 submission_id + const event = buildEvent({ + aggregateId: examId, + eventType: "exam.submitted", + payload: { + examId, + submissionId, + studentId, + }, + userId: studentId, + }); + await outboxRepository.create( + { + id: randomUUID(), + eventId: event.event_id, + aggregateId: examId, + aggregateType: "exam", + eventType: "exam.submitted", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), + status: "pending", + }, + tx, + ); + }); + + return { submissionId }; + } + + async gradeExam( + examId: string, + submissionId: string, + scores: ScoreInput[], + gradedBy: string, + ): Promise<{ totalScore: string }> { + const exam = await examsRepository.findById(examId); + if (!exam) { + throw new NotFoundError(`Exam ${examId} not found`); + } + const submission = await examsRepository.findSubmissionById(submissionId); + if (!submission || submission.examId !== examId) { + throw new NotFoundError( + `Submission ${submissionId} not found for exam ${examId}`, + ); + } + if (submission.status === "graded") { + throw new ConflictError(`Submission ${submissionId} already graded`); + } + + // Calculate total score + const totalScore = scores.reduce((sum, s) => sum + Number(s.score), 0); + const totalScoreStr = totalScore.toFixed(2); + + await db.transaction(async (tx) => { + await examsRepository.updateSubmission(submissionId, { + status: "graded", + gradedAt: new Date(), + gradedBy, + totalScore: totalScore.toFixed(2), + }); + + const event = buildEvent({ + aggregateId: examId, + eventType: "exam.graded", + payload: { + examId, + submissionId, + studentId: submission.studentId, + totalScore: totalScoreStr, + }, + userId: gradedBy, + }); + await outboxRepository.create( + { + id: randomUUID(), + eventId: event.event_id, + aggregateId: examId, + aggregateType: "exam", + eventType: "exam.graded", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), + status: "pending", + }, + tx, + ); + }); + + return { totalScore: totalScoreStr }; + } + + async archiveExam(id: string, archivedBy: string): Promise { + const exam = await examsRepository.findById(id); + if (!exam) { + throw new NotFoundError(`Exam ${id} not found`); + } + // ISSUE-006 决策 #7: archived 仅做软删除 + if (exam.status === "archived") { + return; // Already archived + } + if (exam.status !== "graded") { + throw new ConflictError( + `Cannot archive exam in status ${exam.status} (only graded allows archive)`, + ); + } + + await db.transaction(async (tx) => { + await tx + .update(exams) + .set({ + status: "archived", + statusChangedAt: new Date(), + statusChangedBy: archivedBy, + archivedAt: new Date(), + }) + .where(eq(exams.id, id)); + }); + } + + private assertTransition(from: ExamStatus, action: ExamAction): void { + if (!canTransition(from, action)) { + throw new ApplicationError( + CoreEduErrorCode.EXAM_INVALID_TRANSITION, + `Invalid exam state transition: ${from} --${action}-->`, + 409, + { from, action }, + ); + } + } } diff --git a/services/core-edu/src/grades/grade-calculator.ts b/services/core-edu/src/grades/grade-calculator.ts new file mode 100644 index 0000000..ee6d0a1 --- /dev/null +++ b/services/core-edu/src/grades/grade-calculator.ts @@ -0,0 +1,96 @@ +/** + * 成绩计算器(纯函数) + * + * 支持的公式类型: + * - weighted_average: 加权平均(weights JSON: { examId/homeworkId: weight }) + * - sum: 直接求和 + * - custom: 自定义表达式(P3 仅预留,不执行) + * + * Scope 优先级(ISSUE-006 决策 #3):class > subject > school + */ +export type GradeScope = "class" | "subject" | "school"; + +export type FormulaType = "weighted_average" | "sum" | "custom"; + +export interface GradeFormulaConfig { + scope: GradeScope; + scopeId: string; + formulaType: FormulaType; + weights: Record | null; + customExpression: string | null; + effectiveFrom: Date; + effectiveTo: Date | null; +} + +export interface ScoreEntry { + sourceId: string; // examId or homeworkId + score: number; + totalScore: number; +} + +const SCOPE_PRIORITY: Record = { + class: 3, + subject: 2, + school: 1, +}; + +/** + * 按 scope 优先级选择生效公式(class > subject > school)。 + * 同时满足 effectiveFrom <= now < effectiveTo(如果 effectiveTo 存在)。 + */ +export function selectFormula( + formulas: GradeFormulaConfig[], + now: Date = new Date(), +): GradeFormulaConfig | null { + const effective = formulas.filter((f) => { + if (f.effectiveFrom > now) return false; + if (f.effectiveTo && f.effectiveTo <= now) return false; + return true; + }); + + if (effective.length === 0) return null; + + effective.sort((a, b) => SCOPE_PRIORITY[b.scope] - SCOPE_PRIORITY[a.scope]); + return effective[0]!; +} + +/** + * 计算最终成绩。 + * - weighted_average: Σ(score * weight) / Σ(weight) + * - sum: Σ(score) + * - custom: P3 不执行,抛出错误 + */ +export function calculateGrade( + formula: GradeFormulaConfig, + entries: ScoreEntry[], +): number { + switch (formula.formulaType) { + case "weighted_average": { + if (!formula.weights) { + // 无权重时退化为简单平均 + if (entries.length === 0) return 0; + const sum = entries.reduce((acc, e) => acc + e.score, 0); + return Math.round((sum / entries.length) * 100) / 100; + } + let weightedSum = 0; + let totalWeight = 0; + for (const entry of entries) { + const weight = formula.weights[entry.sourceId] ?? 0; + weightedSum += entry.score * weight; + totalWeight += weight; + } + if (totalWeight === 0) return 0; + return Math.round((weightedSum / totalWeight) * 100) / 100; + } + case "sum": { + return ( + Math.round(entries.reduce((acc, e) => acc + e.score, 0) * 100) / 100 + ); + } + case "custom": { + throw new Error( + "Custom grade formula is not supported in P3 (reserved for future)", + ); + } + } +} diff --git a/services/core-edu/src/grades/grades.controller.ts b/services/core-edu/src/grades/grades.controller.ts index 1ce8c65..7a6ecad 100644 --- a/services/core-edu/src/grades/grades.controller.ts +++ b/services/core-edu/src/grades/grades.controller.ts @@ -1,30 +1,63 @@ -import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common"; -import { GradesService, type RecordGradeInput } from "./grades.service.js"; +import { Body, Controller, Get, Param, Post, Put, Req } from "@nestjs/common"; +import { z } from "zod"; +import { + GradesService, + type RecordGradeInput, + type UpdateGradeInput, +} from "./grades.service.js"; import { Permissions, RequirePermission, } from "../middleware/permission.guard.js"; import type { AuthenticatedRequest } from "../middleware/auth.middleware.js"; -import { UnauthorizedError } from "../shared/errors/application-error.js"; +import { + UnauthorizedError, + ValidationError, +} from "../shared/errors/application-error.js"; -@Controller("grades") +const recordGradeSchema = z + .object({ + studentId: z.string().min(1), + examId: z.string().optional(), + homeworkId: z.string().optional(), + score: z.string().min(1), + totalScore: z.string().min(1), + feedback: z.string().optional(), + schoolId: z.string().min(1), + idempotencyKey: z.string().optional(), + }) + .refine((data) => data.examId || data.homeworkId, { + message: "Either examId or homeworkId must be provided", + }); + +const updateGradeSchema = z.object({ + score: z.string().min(1).optional(), + feedback: z.string().optional(), +}); + +@Controller("v1/grades") export class GradesController { constructor(private readonly gradesService: GradesService) {} @Post() @RequirePermission(Permissions.GRADE_CREATE) async record( - @Body() body: RecordGradeInput, + @Body() body: unknown, @Req() req: AuthenticatedRequest, ): Promise<{ success: true; data: { id: string } }> { const userId = req.userId; if (!userId) { throw new UnauthorizedError("Missing x-user-id header"); } - const result = await this.gradesService.recordGrade({ - ...body, + const parsed = recordGradeSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError("Invalid grade input", parsed.error.flatten()); + } + const input: RecordGradeInput = { + ...parsed.data, gradedBy: userId, - }); + }; + const result = await this.gradesService.recordGrade(input, userId); return { success: true, data: result }; } @@ -67,4 +100,27 @@ export class GradesController { const data = await this.gradesService.listByHomework(homeworkId); return { success: true, data }; } + + @Put(":id") + @RequirePermission(Permissions.GRADE_UPDATE) + async update( + @Param("id") id: string, + @Body() body: unknown, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: { success: true } }> { + const userId = req.userId; + if (!userId) { + throw new UnauthorizedError("Missing x-user-id header"); + } + const parsed = updateGradeSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError( + "Invalid grade update input", + parsed.error.flatten(), + ); + } + const data: UpdateGradeInput = parsed.data; + await this.gradesService.updateGrade(id, data, userId); + return { success: true, data: { success: true } }; + } } diff --git a/services/core-edu/src/grades/grades.repository.ts b/services/core-edu/src/grades/grades.repository.ts new file mode 100644 index 0000000..332453a --- /dev/null +++ b/services/core-edu/src/grades/grades.repository.ts @@ -0,0 +1,64 @@ +import { eq } from "drizzle-orm"; +import { db } from "../config/database.js"; +import { + grades, + gradeFormulas, + type Grade, + type NewGrade, + type GradeFormula, +} from "./grades.schema.js"; + +export class GradesRepository { + async findById(id: string): Promise { + const [result] = await db + .select() + .from(grades) + .where(eq(grades.id, id)) + .limit(1); + return result; + } + + async findByStudentId(studentId: string): Promise { + return db.select().from(grades).where(eq(grades.studentId, studentId)); + } + + async findByExamId(examId: string): Promise { + return db.select().from(grades).where(eq(grades.examId, examId)); + } + + async findByHomeworkId(homeworkId: string): Promise { + return db.select().from(grades).where(eq(grades.homeworkId, homeworkId)); + } + + async findByIdempotencyKey( + idempotencyKey: string, + ): Promise { + const [result] = await db + .select() + .from(grades) + .where(eq(grades.idempotencyKey, idempotencyKey)) + .limit(1); + return result; + } + + async create(grade: NewGrade, tx: typeof db = db): Promise { + await tx.insert(grades).values(grade); + } + + async update(id: string, data: Partial): Promise { + await db.update(grades).set(data).where(eq(grades.id, id)); + } + + // Grade formulas + async findFormulasByScope( + scope: string, + _scopeId: string, + ): Promise { + return db + .select() + .from(gradeFormulas) + .where(eq(gradeFormulas.scope, scope)); + } +} + +export const gradesRepository = new GradesRepository(); diff --git a/services/core-edu/src/grades/grades.schema.ts b/services/core-edu/src/grades/grades.schema.ts index 6126483..266426d 100644 --- a/services/core-edu/src/grades/grades.schema.ts +++ b/services/core-edu/src/grades/grades.schema.ts @@ -4,19 +4,74 @@ import { text, timestamp, char, -} from 'drizzle-orm/mysql-core'; + decimal, + json, + datetime, + index, + uniqueIndex, +} from "drizzle-orm/mysql-core"; -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: varchar('score', { length: 10 }).notNull(), - feedback: text('feedback'), - gradedBy: char('graded_by', { length: 36 }).notNull(), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), -}); +// 成绩主表 +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(), + totalScore: decimal("total_score", { precision: 6, scale: 2 }).notNull(), + feedback: text("feedback"), + gradedBy: char("graded_by", { length: 36 }).notNull(), + schoolId: char("school_id", { length: 36 }).notNull(), + idempotencyKey: varchar("idempotency_key", { length: 128 }), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + uniqStudentExam: uniqueIndex("uniq_student_exam").on( + table.studentId, + table.examId, + ), + uniqStudentHw: uniqueIndex("uniq_student_hw").on( + table.studentId, + table.homeworkId, + ), + uniqIdempotency: uniqueIndex("uniq_idempotency").on(table.idempotencyKey), + idxGradesStudentId: index("idx_grades_student_id").on(table.studentId), + idxGradesExamId: index("idx_grades_exam_id").on(table.examId), + idxGradesHomeworkId: index("idx_grades_homework_id").on(table.homeworkId), + idxGradesGradedBy: index("idx_grades_graded_by").on(table.gradedBy), + }), +); + +// 成绩公式表(按 scope 优先级:class > subject > school) +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(), + weights: json("weights"), + customExpression: text("custom_expression"), + effectiveFrom: datetime("effective_from").notNull(), + effectiveTo: datetime("effective_to"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => ({ + idxGradeFormulasScope: index("idx_grade_formulas_scope").on( + table.scope, + table.scopeId, + ), + idxGradeFormulasEffective: index("idx_grade_formulas_effective").on( + table.effectiveFrom, + table.effectiveTo, + ), + }), +); export type Grade = typeof grades.$inferSelect; export type NewGrade = typeof grades.$inferInsert; +export type GradeFormula = typeof gradeFormulas.$inferSelect; +export type NewGradeFormula = typeof gradeFormulas.$inferInsert; diff --git a/services/core-edu/src/grades/grades.service.ts b/services/core-edu/src/grades/grades.service.ts index 8dd1d36..22fab54 100644 --- a/services/core-edu/src/grades/grades.service.ts +++ b/services/core-edu/src/grades/grades.service.ts @@ -1,34 +1,62 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; import { Injectable } from "@nestjs/common"; import { db } from "../config/database.js"; -import { grades } from "./grades.schema.js"; +import { gradesRepository } from "./grades.repository.js"; import { outboxRepository } from "../shared/outbox/outbox.repository.js"; -import type { Grade, NewGrade } from "./grades.schema.js"; +import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js"; import { NotFoundError, ValidationError, } from "../shared/errors/application-error.js"; +import type { Grade, NewGrade } from "./grades.schema.js"; export interface RecordGradeInput { studentId: string; examId?: string; homeworkId?: string; score: string; + totalScore: string; feedback?: string; gradedBy: string; + schoolId: string; + idempotencyKey?: string; +} + +export interface UpdateGradeInput { + score?: string; + feedback?: string; } @Injectable() export class GradesService { - async recordGrade(input: RecordGradeInput): Promise<{ id: string }> { - if (!input.studentId || !input.score || !input.gradedBy) { - throw new ValidationError("studentId, score, gradedBy are required"); + async recordGrade( + input: RecordGradeInput, + userId?: string, + ): Promise<{ id: string }> { + if ( + !input.studentId || + !input.score || + !input.gradedBy || + !input.schoolId + ) { + throw new ValidationError( + "studentId, score, gradedBy, schoolId are required", + ); } if (!input.examId && !input.homeworkId) { throw new ValidationError("Either examId or homeworkId must be provided"); } + // Idempotency check + if (input.idempotencyKey) { + const existing = await gradesRepository.findByIdempotencyKey( + input.idempotencyKey, + ); + if (existing) { + return { id: existing.id }; + } + } + const id = randomUUID(); const record: NewGrade = { id, @@ -36,25 +64,38 @@ export class GradesService { examId: input.examId, homeworkId: input.homeworkId, score: input.score, + totalScore: input.totalScore, feedback: input.feedback, gradedBy: input.gradedBy, + schoolId: input.schoolId, + idempotencyKey: input.idempotencyKey, }; + const event = buildEvent({ + aggregateId: id, + eventType: "grade.recorded", + payload: { + gradeId: id, + studentId: input.studentId, + score: input.score, + totalScore: input.totalScore, + examId: input.examId, + homeworkId: input.homeworkId, + }, + userId, + }); + await db.transaction(async (tx) => { - await tx.insert(grades).values(record); + await gradesRepository.create(record, tx); await outboxRepository.create( { id: randomUUID(), + eventId: event.event_id, aggregateId: id, aggregateType: "grade", eventType: "grade.recorded", - payload: JSON.stringify({ - id, - studentId: input.studentId, - score: input.score, - examId: input.examId, - homeworkId: input.homeworkId, - }), + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), status: "pending", }, tx, @@ -65,11 +106,7 @@ export class GradesService { } async getGrade(id: string): Promise { - const [record] = await db - .select() - .from(grades) - .where(eq(grades.id, id)) - .limit(1); + const record = await gradesRepository.findById(id); if (!record) { throw new NotFoundError(`Grade ${id} not found`); } @@ -77,14 +114,53 @@ export class GradesService { } async listByStudent(studentId: string): Promise { - return db.select().from(grades).where(eq(grades.studentId, studentId)); + return gradesRepository.findByStudentId(studentId); } async listByExam(examId: string): Promise { - return db.select().from(grades).where(eq(grades.examId, examId)); + return gradesRepository.findByExamId(examId); } async listByHomework(homeworkId: string): Promise { - return db.select().from(grades).where(eq(grades.homeworkId, homeworkId)); + return gradesRepository.findByHomeworkId(homeworkId); + } + + async updateGrade( + id: string, + data: UpdateGradeInput, + updatedBy: string, + ): Promise { + const existing = await gradesRepository.findById(id); + if (!existing) { + throw new NotFoundError(`Grade ${id} not found`); + } + + await db.transaction(async (tx) => { + await gradesRepository.update(id, data); + + const event = buildEvent({ + aggregateId: id, + eventType: "grade.updated", + payload: { + gradeId: id, + studentId: existing.studentId, + changes: data, + }, + userId: updatedBy, + }); + await outboxRepository.create( + { + id: randomUUID(), + eventId: event.event_id, + aggregateId: id, + aggregateType: "grade", + eventType: "grade.updated", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), + status: "pending", + }, + tx, + ); + }); } } diff --git a/services/core-edu/src/homework/homework-state-machine.ts b/services/core-edu/src/homework/homework-state-machine.ts new file mode 100644 index 0000000..1729798 --- /dev/null +++ b/services/core-edu/src/homework/homework-state-machine.ts @@ -0,0 +1,91 @@ +/** + * 作业状态机(纯函数) + * + * 状态流转(ISSUE-003 仲裁 scheme A): + * assigned → submitted → graded + * + * SubmissionStatus(学生提交维度): + * not_submitted → submitted → graded + */ +export type HomeworkStatus = "assigned" | "submitted" | "graded"; + +export type HomeworkAction = "submit" | "grade"; + +const TRANSITIONS: Record< + HomeworkStatus, + Partial> +> = { + assigned: { submit: "submitted" }, + submitted: { grade: "graded" }, + graded: {}, +}; + +export function canTransition( + from: HomeworkStatus, + action: HomeworkAction, +): boolean { + return TRANSITIONS[from]?.[action] !== undefined; +} + +export function transition( + from: HomeworkStatus, + action: HomeworkAction, +): HomeworkStatus { + const next = TRANSITIONS[from]?.[action]; + if (!next) { + throw new Error( + `Invalid homework state transition: ${from} --${action}-->`, + ); + } + return next; +} + +export function isTerminal(status: HomeworkStatus): boolean { + return status === "graded"; +} + +// SubmissionStatus 状态机(用于 exam_submissions / homework_submissions 表的 status 字段) +export type SubmissionStatus = "not_submitted" | "submitted" | "graded"; + +export type SubmissionAction = "submit" | "grade"; + +const SUBMISSION_TRANSITIONS: Record< + SubmissionStatus, + Partial> +> = { + not_submitted: { submit: "submitted" }, + submitted: { grade: "graded" }, + graded: {}, +}; + +export function canTransitionSubmission( + from: SubmissionStatus, + action: SubmissionAction, +): boolean { + return SUBMISSION_TRANSITIONS[from]?.[action] !== undefined; +} + +export function transitionSubmission( + from: SubmissionStatus, + action: SubmissionAction, +): SubmissionStatus { + const next = SUBMISSION_TRANSITIONS[from]?.[action]; + if (!next) { + throw new Error( + `Invalid submission state transition: ${from} --${action}-->`, + ); + } + return next; +} + +export const HOMEWORK_STATUSES: readonly HomeworkStatus[] = [ + "assigned", + "submitted", + "graded", +] as const; + +export const SUBMISSION_STATUSES: readonly SubmissionStatus[] = [ + "not_submitted", + "submitted", + "graded", +] as const; diff --git a/services/core-edu/src/homework/homework.controller.ts b/services/core-edu/src/homework/homework.controller.ts index 8afb069..0605a14 100644 --- a/services/core-edu/src/homework/homework.controller.ts +++ b/services/core-edu/src/homework/homework.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common"; +import { z } from "zod"; import { HomeworkService, type AssignHomeworkInput, @@ -8,26 +9,70 @@ import { RequirePermission, } from "../middleware/permission.guard.js"; import type { AuthenticatedRequest } from "../middleware/auth.middleware.js"; -import { UnauthorizedError } from "../shared/errors/application-error.js"; +import { + UnauthorizedError, + ValidationError, +} from "../shared/errors/application-error.js"; -@Controller("homework") +const assignHomeworkSchema = z.object({ + classId: z.string().min(1), + subjectId: z.string().min(1), + title: z.string().min(1).max(200), + description: z.string().optional(), + dueDate: z.string().min(1), + gracePeriod: z.number().int().positive().optional(), + schoolId: z.string().min(1), +}); + +const answerInputSchema = z.object({ + questionId: z.string().min(1), + answer: z.string(), +}); + +const scoreInputSchema = z.object({ + questionId: z.string().min(1), + score: z.string().min(1), + teacherComment: z.string().optional(), +}); + +const submitHomeworkSchema = z.object({ + studentId: z.string().min(1), + answers: z.array(answerInputSchema).default([]), +}); + +const gradeHomeworkSchema = z.object({ + submissionId: z.string().min(1), + scores: z.array(scoreInputSchema).min(1), + feedback: z.string().optional(), +}); + +@Controller("v1/homework") export class HomeworkController { constructor(private readonly homeworkService: HomeworkService) {} @Post() @RequirePermission(Permissions.HOMEWORK_CREATE) async assign( - @Body() body: AssignHomeworkInput, + @Body() body: unknown, @Req() req: AuthenticatedRequest, ): Promise<{ success: true; data: { id: string } }> { const userId = req.userId; if (!userId) { throw new UnauthorizedError("Missing x-user-id header"); } - const result = await this.homeworkService.assignHomework({ - ...body, + const parsed = assignHomeworkSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError( + "Invalid homework input", + parsed.error.flatten(), + ); + } + const input: AssignHomeworkInput = { + ...parsed.data, + dueDate: new Date(parsed.data.dueDate), createdBy: userId, - }); + }; + const result = await this.homeworkService.assignHomework(input, userId); return { success: true, data: result }; } @@ -55,8 +100,42 @@ export class HomeworkController { @RequirePermission(Permissions.HOMEWORK_SUBMIT) async submit( @Param("id") id: string, - ): Promise<{ success: true; data: { success: true } }> { - await this.homeworkService.submitHomework(id); - return { success: true, data: { success: true } }; + @Body() body: unknown, + ): Promise<{ success: true; data: { submissionId: string } }> { + const parsed = submitHomeworkSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError("Invalid submit input", parsed.error.flatten()); + } + const result = await this.homeworkService.submitHomework( + id, + parsed.data.studentId, + parsed.data.answers, + ); + return { success: true, data: result }; + } + + @Post(":id/grade") + @RequirePermission(Permissions.HOMEWORK_GRADE) + async grade( + @Param("id") id: string, + @Body() body: unknown, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: { totalScore: string } }> { + const userId = req.userId; + if (!userId) { + throw new UnauthorizedError("Missing x-user-id header"); + } + const parsed = gradeHomeworkSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError("Invalid grade input", parsed.error.flatten()); + } + const result = await this.homeworkService.gradeHomework( + id, + parsed.data.submissionId, + parsed.data.scores, + parsed.data.feedback, + userId, + ); + return { success: true, data: result }; } } diff --git a/services/core-edu/src/homework/homework.repository.ts b/services/core-edu/src/homework/homework.repository.ts new file mode 100644 index 0000000..2ee9545 --- /dev/null +++ b/services/core-edu/src/homework/homework.repository.ts @@ -0,0 +1,100 @@ +import { eq, and } from "drizzle-orm"; +import { db } from "../config/database.js"; +import { + homework, + homeworkSubmissions, + homeworkAnswers, + type Homework, + type NewHomework, + type HomeworkSubmission, + type NewHomeworkSubmission, + type HomeworkAnswer, + type NewHomeworkAnswer, +} from "./homework.schema.js"; + +export class HomeworkRepository { + async findById(id: string): Promise { + const [result] = await db + .select() + .from(homework) + .where(eq(homework.id, id)) + .limit(1); + return result; + } + + async findByClassId(classId: string): Promise { + return db.select().from(homework).where(eq(homework.classId, classId)); + } + + async update(id: string, data: Partial): Promise { + await db.update(homework).set(data).where(eq(homework.id, id)); + } + + async delete(id: string): Promise { + await db.delete(homework).where(eq(homework.id, id)); + } + + // Submissions + async findSubmission( + homeworkId: string, + studentId: string, + ): Promise { + const [result] = await db + .select() + .from(homeworkSubmissions) + .where( + and( + eq(homeworkSubmissions.homeworkId, homeworkId), + eq(homeworkSubmissions.studentId, studentId), + ), + ) + .limit(1); + return result; + } + + async findSubmissionById( + id: string, + ): Promise { + const [result] = await db + .select() + .from(homeworkSubmissions) + .where(eq(homeworkSubmissions.id, id)) + .limit(1); + return result; + } + + async createSubmission( + submission: NewHomeworkSubmission, + tx: typeof db = db, + ): Promise { + await tx.insert(homeworkSubmissions).values(submission); + } + + async updateSubmission( + id: string, + data: Partial, + ): Promise { + await db + .update(homeworkSubmissions) + .set(data) + .where(eq(homeworkSubmissions.id, id)); + } + + // Answers + async createAnswers( + answers: NewHomeworkAnswer[], + tx: typeof db = db, + ): Promise { + if (answers.length === 0) return; + await tx.insert(homeworkAnswers).values(answers); + } + + async listAnswers(submissionId: string): Promise { + return db + .select() + .from(homeworkAnswers) + .where(eq(homeworkAnswers.submissionId, submissionId)); + } +} + +export const homeworkRepository = new HomeworkRepository(); diff --git a/services/core-edu/src/homework/homework.schema.ts b/services/core-edu/src/homework/homework.schema.ts index 14bc673..cb66e8f 100644 --- a/services/core-edu/src/homework/homework.schema.ts +++ b/services/core-edu/src/homework/homework.schema.ts @@ -5,19 +5,98 @@ import { timestamp, char, datetime, -} from 'drizzle-orm/mysql-core'; + int, + decimal, + boolean, + index, + uniqueIndex, +} from "drizzle-orm/mysql-core"; -export const homework = mysqlTable('core_edu_homework', { - id: char('id', { length: 36 }).notNull().primaryKey(), - classId: char('class_id', { length: 36 }).notNull(), - title: varchar('title', { length: 200 }).notNull(), - description: text('description'), - dueDate: datetime('due_date').notNull(), - status: varchar('status', { length: 20 }).notNull().default('assigned'), - createdBy: char('created_by', { length: 36 }).notNull(), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(), -}); +// 作业主表 +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(), + title: varchar("title", { length: 200 }).notNull(), + description: text("description"), + dueDate: datetime("due_date").notNull(), + gracePeriod: int("grace_period").notNull().default(300), // 秒,仲裁默认 300 + status: varchar("status", { length: 20 }).notNull().default("assigned"), + schoolId: char("school_id", { length: 36 }).notNull(), + createdBy: char("created_by", { length: 36 }).notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + idxHomeworkClassStatus: index("idx_homework_class_status").on( + table.classId, + table.status, + ), + idxHomeworkCreatedBy: index("idx_homework_created_by").on(table.createdBy), + }), +); + +// 作业提交表 +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("not_submitted"), + 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(), + }, + (table) => ({ + uniqHwStudent: uniqueIndex("uniq_hw_student").on( + table.homeworkId, + table.studentId, + ), + idxHwSubmissionsHomeworkId: index("idx_hw_submissions_homework_id").on( + table.homeworkId, + ), + idxHwSubmissionsStudentId: index("idx_hw_submissions_student_id").on( + table.studentId, + ), + }), +); + +// 作业答题表 +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(), + }, + (table) => ({ + idxHwAnswersSubmissionId: index("idx_hw_answers_submission_id").on( + table.submissionId, + ), + idxHwAnswersQuestionId: index("idx_hw_answers_question_id").on( + table.questionId, + ), + }), +); export type Homework = typeof homework.$inferSelect; export type NewHomework = typeof homework.$inferInsert; +export type HomeworkSubmission = typeof homeworkSubmissions.$inferSelect; +export type NewHomeworkSubmission = typeof homeworkSubmissions.$inferInsert; +export type HomeworkAnswer = typeof homeworkAnswers.$inferSelect; +export type NewHomeworkAnswer = typeof homeworkAnswers.$inferInsert; diff --git a/services/core-edu/src/homework/homework.service.ts b/services/core-edu/src/homework/homework.service.ts index 3e80430..1530326 100644 --- a/services/core-edu/src/homework/homework.service.ts +++ b/services/core-edu/src/homework/homework.service.ts @@ -1,56 +1,97 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; import { Injectable } from "@nestjs/common"; import { db } from "../config/database.js"; import { homework } from "./homework.schema.js"; +import { homeworkRepository } from "./homework.repository.js"; import { outboxRepository } from "../shared/outbox/outbox.repository.js"; -import type { Homework, NewHomework } from "./homework.schema.js"; +import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js"; +import { acquireLock, releaseLock } from "../config/redis.js"; import { NotFoundError, ValidationError, + ConflictError, } from "../shared/errors/application-error.js"; +import type { Homework, NewHomework } from "./homework.schema.js"; export interface AssignHomeworkInput { classId: string; + subjectId: string; title: string; description?: string; dueDate: Date | string; + gracePeriod?: number; + schoolId: string; createdBy: string; } +export interface AnswerInput { + questionId: string; + answer: string; +} + +export interface ScoreInput { + questionId: string; + score: string; + teacherComment?: string; +} + +const LOCK_TTL_SECONDS = 30; + @Injectable() export class HomeworkService { - async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> { - if (!input.classId || !input.title || !input.createdBy) { - throw new ValidationError("classId, title, createdBy are required"); + async assignHomework( + input: AssignHomeworkInput, + userId?: string, + ): Promise<{ id: string }> { + if ( + !input.classId || + !input.title || + !input.createdBy || + !input.subjectId + ) { + throw new ValidationError( + "classId, subjectId, title, createdBy are required", + ); } const id = randomUUID(); const record: NewHomework = { id, classId: input.classId, + subjectId: input.subjectId, title: input.title, description: input.description, - // Drizzle datetime 列需要 Date 对象;HTTP 请求体里 dueDate 是 ISO 字符串。 dueDate: input.dueDate instanceof Date ? input.dueDate : new Date(input.dueDate), + gracePeriod: input.gracePeriod ?? 300, status: "assigned", + schoolId: input.schoolId, createdBy: input.createdBy, }; + const event = buildEvent({ + aggregateId: id, + eventType: "homework.assigned", + payload: { + homeworkId: id, + classId: input.classId, + subjectId: input.subjectId, + title: input.title, + }, + userId, + }); + await db.transaction(async (tx) => { await tx.insert(homework).values(record); await outboxRepository.create( { id: randomUUID(), + eventId: event.event_id, aggregateId: id, aggregateType: "homework", eventType: "homework.assigned", - payload: JSON.stringify({ - id, - classId: input.classId, - title: input.title, - }), + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), status: "pending", }, tx, @@ -61,11 +102,7 @@ export class HomeworkService { } async getHomework(id: string): Promise { - const [record] = await db - .select() - .from(homework) - .where(eq(homework.id, id)) - .limit(1); + const record = await homeworkRepository.findById(id); if (!record) { throw new NotFoundError(`Homework ${id} not found`); } @@ -73,31 +110,178 @@ export class HomeworkService { } async listByClass(classId: string): Promise { - return db.select().from(homework).where(eq(homework.classId, classId)); + return homeworkRepository.findByClassId(classId); } - async submitHomework(id: string): Promise { - const existing = await this.getHomework(id); - if (existing.status === "submitted") { - throw new ValidationError(`Homework ${id} already submitted`); + async submitHomework( + homeworkId: string, + studentId: string, + answers: AnswerInput[], + ): Promise<{ submissionId: string }> { + const hw = await homeworkRepository.findById(homeworkId); + if (!hw) { + throw new NotFoundError(`Homework ${homeworkId} not found`); } + // Redis distributed lock for idempotency (P2 feature) + const lockKey = `hw:submit:${homeworkId}:${studentId}`; + const locked = await acquireLock(lockKey, LOCK_TTL_SECONDS); + if (!locked) { + // Lock unavailable - check if submission already exists + const existing = await homeworkRepository.findSubmission( + homeworkId, + studentId, + ); + if ( + existing && + (existing.status === "submitted" || existing.status === "graded") + ) { + throw new ConflictError( + `Student ${studentId} already submitted homework ${homeworkId}`, + ); + } + throw new ConflictError("Submission in progress, please retry"); + } + + try { + // Double-check after acquiring lock + const existing = await homeworkRepository.findSubmission( + homeworkId, + studentId, + ); + if ( + existing && + (existing.status === "submitted" || existing.status === "graded") + ) { + throw new ConflictError( + `Student ${studentId} already submitted homework ${homeworkId}`, + ); + } + + const submissionId = randomUUID(); + const now = new Date(); + + // Check grace period + const dueWithGrace = new Date( + hw.dueDate.getTime() + hw.gracePeriod * 1000, + ); + const isLate = now > dueWithGrace; + + await db.transaction(async (tx) => { + await homeworkRepository.createSubmission( + { + id: submissionId, + homeworkId, + studentId, + status: "submitted", + submittedAt: now, + }, + tx, + ); + + // Insert answers + if (answers.length > 0) { + const answerRecords = answers.map((a) => ({ + id: randomUUID(), + submissionId, + questionId: a.questionId, + answer: a.answer, + })); + await homeworkRepository.createAnswers(answerRecords, tx); + } + + const event = buildEvent({ + aggregateId: homeworkId, + eventType: "homework.submitted", + payload: { + homeworkId, + submissionId, + studentId, + isLate, + }, + userId: studentId, + }); + await outboxRepository.create( + { + id: randomUUID(), + eventId: event.event_id, + aggregateId: homeworkId, + aggregateType: "homework", + eventType: "homework.submitted", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), + status: "pending", + }, + tx, + ); + }); + + return { submissionId }; + } finally { + await releaseLock(lockKey); + } + } + + async gradeHomework( + homeworkId: string, + submissionId: string, + scores: ScoreInput[], + feedback: string | undefined, + gradedBy: string, + ): Promise<{ totalScore: string }> { + const hw = await homeworkRepository.findById(homeworkId); + if (!hw) { + throw new NotFoundError(`Homework ${homeworkId} not found`); + } + const submission = + await homeworkRepository.findSubmissionById(submissionId); + if (!submission || submission.homeworkId !== homeworkId) { + throw new NotFoundError( + `Submission ${submissionId} not found for homework ${homeworkId}`, + ); + } + if (submission.status === "graded") { + throw new ConflictError(`Submission ${submissionId} already graded`); + } + + const totalScore = scores.reduce((sum, s) => sum + Number(s.score), 0); + const totalScoreStr = totalScore.toFixed(2); + await db.transaction(async (tx) => { - await tx - .update(homework) - .set({ status: "submitted" }) - .where(eq(homework.id, id)); + await homeworkRepository.updateSubmission(submissionId, { + status: "graded", + gradedAt: new Date(), + gradedBy, + totalScore: totalScore.toFixed(2), + feedback, + }); + + const event = buildEvent({ + aggregateId: homeworkId, + eventType: "homework.graded", + payload: { + homeworkId, + submissionId, + studentId: submission.studentId, + totalScore: totalScoreStr, + }, + userId: gradedBy, + }); await outboxRepository.create( { id: randomUUID(), - aggregateId: id, + eventId: event.event_id, + aggregateId: homeworkId, aggregateType: "homework", - eventType: "homework.submitted", - payload: JSON.stringify({ id }), + eventType: "homework.graded", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), status: "pending", }, tx, ); }); + + return { totalScore: totalScoreStr }; } } diff --git a/services/core-edu/src/iam-consumer/iam-consumer.module.ts b/services/core-edu/src/iam-consumer/iam-consumer.module.ts new file mode 100644 index 0000000..05185a8 --- /dev/null +++ b/services/core-edu/src/iam-consumer/iam-consumer.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { IamConsumerService } from "./iam-consumer.service.js"; + +@Module({ + providers: [IamConsumerService], + exports: [IamConsumerService], +}) +export class IamConsumerModule {} diff --git a/services/core-edu/src/iam-consumer/iam-consumer.service.ts b/services/core-edu/src/iam-consumer/iam-consumer.service.ts new file mode 100644 index 0000000..c3b3f58 --- /dev/null +++ b/services/core-edu/src/iam-consumer/iam-consumer.service.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto"; +import { eq, and } from "drizzle-orm"; +import { Injectable, type OnModuleInit } from "@nestjs/common"; +import { db } from "../config/database.js"; +import { teacherAssociations } from "../classes/teacher-associations.schema.js"; +import { classes } from "../classes/classes.schema.js"; +import { consumer } from "../config/kafka.js"; +import { logger } from "../shared/observability/logger.js"; + +const IAM_TOPICS = [ + "edu.iam.teacher.assigned", + "edu.iam.class.created", +] as const; + +interface TeacherAssignedEvent { + teacher_id: string; + class_id: string; + subject_id?: string; + school_id: string; +} + +interface ClassCreatedEvent { + class_id: string; + name: string; + grade_id: string; + head_teacher_id?: string; + description?: string; +} + +@Injectable() +export class IamConsumerService implements OnModuleInit { + private isRunning = false; + + async onModuleInit(): Promise { + // Subscribe to IAM events (non-blocking - failures don't prevent service start) + void this.start().catch((err) => { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + "IAM consumer failed to start", + ); + }); + } + + private async start(): Promise { + if (this.isRunning) return; + + try { + for (const topic of IAM_TOPICS) { + await consumer.subscribe({ topic, fromBeginning: false }); + } + + this.isRunning = true; + logger.info({ topics: [...IAM_TOPICS] }, "IAM consumer subscribed"); + + await consumer.run({ + eachMessage: async ({ topic, message }) => { + try { + const value = message.value?.toString(); + if (!value) return; + + const payload = JSON.parse(value) as Record; + + if (topic === "edu.iam.teacher.assigned") { + await this.handleTeacherAssigned( + payload as unknown as TeacherAssignedEvent, + ); + } else if (topic === "edu.iam.class.created") { + await this.handleClassCreated( + payload as unknown as ClassCreatedEvent, + ); + } + } catch (err) { + logger.error( + { + err: err instanceof Error ? err.message : String(err), + topic, + offset: message.offset, + }, + "IAM consumer message processing failed", + ); + } + }, + }); + } catch (err) { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + "IAM consumer start failed", + ); + } + } + + /** + * Handle teacher.assigned event - upsert teacher_associations (idempotent via unique index). + */ + private async handleTeacherAssigned( + event: TeacherAssignedEvent, + ): Promise { + if (!event.teacher_id || !event.class_id || !event.school_id) { + logger.warn( + { event }, + "Invalid teacher.assigned event: missing required fields", + ); + return; + } + + // Check existing (idempotent) + const conditions = [ + eq(teacherAssociations.teacherId, event.teacher_id), + eq(teacherAssociations.classId, event.class_id), + ]; + if (event.subject_id) { + conditions.push(eq(teacherAssociations.subjectId, event.subject_id)); + } + const [existing] = await db + .select() + .from(teacherAssociations) + .where(and(...conditions)) + .limit(1); + + if (existing) { + logger.debug( + { teacherId: event.teacher_id, classId: event.class_id }, + "Teacher association already exists, skipping", + ); + return; + } + + await db.insert(teacherAssociations).values({ + id: randomUUID(), + teacherId: event.teacher_id, + classId: event.class_id, + subjectId: event.subject_id, + schoolId: event.school_id, + source: "iam_event", + }); + + logger.info( + { teacherId: event.teacher_id, classId: event.class_id }, + "Teacher association created from IAM event", + ); + } + + /** + * Handle class.created event - sync class record (idempotent via primary key). + */ + private async handleClassCreated(event: ClassCreatedEvent): Promise { + if (!event.class_id || !event.name || !event.grade_id) { + logger.warn( + { event }, + "Invalid class.created event: missing required fields", + ); + return; + } + + // Check existing + const [existing] = await db + .select() + .from(classes) + .where(eq(classes.id, event.class_id)) + .limit(1); + + if (existing) { + // Update if changed + await db + .update(classes) + .set({ + name: event.name, + gradeId: event.grade_id, + headTeacherId: event.head_teacher_id, + description: event.description, + }) + .where(eq(classes.id, event.class_id)); + logger.debug({ classId: event.class_id }, "Class updated from IAM event"); + return; + } + + await db.insert(classes).values({ + id: event.class_id, + name: event.name, + gradeId: event.grade_id, + headTeacherId: event.head_teacher_id, + description: event.description, + }); + + logger.info({ classId: event.class_id }, "Class created from IAM event"); + } +} diff --git a/services/core-edu/src/main.ts b/services/core-edu/src/main.ts index 8ee3712..af476fe 100644 --- a/services/core-edu/src/main.ts +++ b/services/core-edu/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from "@nestjs/core"; import { AppModule } from "./app.module.js"; import { env } from "./config/env.js"; import { connectKafka, disconnectKafka } from "./config/kafka.js"; +import { connectRedis, disconnectRedis } from "./config/redis.js"; import { outboxPublisher } from "./shared/outbox/outbox.publisher.js"; import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js"; import { initTracer, shutdownTracer } from "./shared/observability/tracer.js"; @@ -27,33 +28,33 @@ async function bootstrap(): Promise { // publisher will retry sends and messages stay pending until Kafka recovers. void connectKafka(); + // Connect Redis for distributed lock (homework submission idempotency). + // Non-blocking: if Redis is unavailable, service still starts; lock-based + // operations will fall back to DB unique index for idempotency. + void connectRedis(); + // Start the transactional outbox publisher - polls pending messages // and publishes them to Kafka topics defined in TOPIC_MAP. await outboxPublisher.start(); await app.listen(env.PORT); logger.info( - { port: env.PORT, service: "core-edu" }, - "CoreEdu service is listening", + { port: env.PORT, grpcPort: env.GRPC_PORT, service: "core-edu" }, + "CoreEdu service is listening (HTTP; gRPC port reserved for P3)", ); - process.on("SIGTERM", async () => { - logger.info("SIGTERM received, shutting down gracefully..."); + const shutdown = async (signal: string): Promise => { + logger.info({ signal }, "Shutting down gracefully..."); await outboxPublisher.stop(); + await disconnectRedis(); await disconnectKafka(); await shutdownTracer(); await app.close(); process.exit(0); - }); + }; - process.on("SIGINT", async () => { - logger.info("SIGINT received, shutting down gracefully..."); - await outboxPublisher.stop(); - await disconnectKafka(); - await shutdownTracer(); - await app.close(); - process.exit(0); - }); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); } void bootstrap(); diff --git a/services/core-edu/src/middleware/permission.guard.ts b/services/core-edu/src/middleware/permission.guard.ts index 35343d6..9d99bea 100644 --- a/services/core-edu/src/middleware/permission.guard.ts +++ b/services/core-edu/src/middleware/permission.guard.ts @@ -9,16 +9,35 @@ import { PermissionDeniedError } from "../shared/errors/application-error.js"; import type { AuthenticatedRequest } from "./auth.middleware.js"; export const Permissions = { + // Exam permissions EXAM_CREATE: "CORE_EDU_EXAM_CREATE" as const, EXAM_READ: "CORE_EDU_EXAM_READ" as const, EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE" as const, EXAM_DELETE: "CORE_EDU_EXAM_DELETE" as const, + EXAM_PUBLISH: "CORE_EDU_EXAM_PUBLISH" as const, + EXAM_SUBMIT: "CORE_EDU_EXAM_SUBMIT" as const, + EXAM_GRADE: "CORE_EDU_EXAM_GRADE" as const, + // Homework permissions HOMEWORK_CREATE: "CORE_EDU_HOMEWORK_CREATE" as const, HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ" as const, HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE" as const, HOMEWORK_SUBMIT: "CORE_EDU_HOMEWORK_SUBMIT" as const, + HOMEWORK_GRADE: "CORE_EDU_HOMEWORK_GRADE" as const, + // Grade permissions GRADE_CREATE: "CORE_EDU_GRADE_CREATE" as const, GRADE_READ: "CORE_EDU_GRADE_READ" as const, + GRADE_UPDATE: "CORE_EDU_GRADE_UPDATE" as const, + // Attendance permissions + ATTENDANCE_CREATE: "CORE_EDU_ATTENDANCE_CREATE" as const, + ATTENDANCE_READ: "CORE_EDU_ATTENDANCE_READ" as const, + // Class permissions + CLASS_READ: "CORE_EDU_CLASS_READ" as const, + // Course permissions + COURSE_CREATE: "CORE_EDU_COURSE_CREATE" as const, + COURSE_READ: "CORE_EDU_COURSE_READ" as const, + // Schedule permissions + SCHEDULE_CREATE: "CORE_EDU_SCHEDULE_CREATE" as const, + SCHEDULE_READ: "CORE_EDU_SCHEDULE_READ" as const, } as const; export type Permission = (typeof Permissions)[keyof typeof Permissions]; @@ -33,29 +52,56 @@ const ROLE_PERMISSIONS: Record = { Permissions.EXAM_READ, Permissions.EXAM_UPDATE, Permissions.EXAM_DELETE, + Permissions.EXAM_PUBLISH, + Permissions.EXAM_SUBMIT, + Permissions.EXAM_GRADE, Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_READ, Permissions.HOMEWORK_UPDATE, Permissions.HOMEWORK_SUBMIT, + Permissions.HOMEWORK_GRADE, Permissions.GRADE_CREATE, Permissions.GRADE_READ, + Permissions.GRADE_UPDATE, + Permissions.ATTENDANCE_CREATE, + Permissions.ATTENDANCE_READ, + Permissions.CLASS_READ, + Permissions.COURSE_CREATE, + Permissions.COURSE_READ, + Permissions.SCHEDULE_CREATE, + Permissions.SCHEDULE_READ, ], teacher: [ Permissions.EXAM_CREATE, Permissions.EXAM_READ, Permissions.EXAM_UPDATE, + Permissions.EXAM_PUBLISH, + Permissions.EXAM_GRADE, Permissions.HOMEWORK_CREATE, Permissions.HOMEWORK_READ, Permissions.HOMEWORK_UPDATE, - Permissions.HOMEWORK_SUBMIT, + Permissions.HOMEWORK_GRADE, Permissions.GRADE_CREATE, Permissions.GRADE_READ, + Permissions.GRADE_UPDATE, + Permissions.ATTENDANCE_CREATE, + Permissions.ATTENDANCE_READ, + Permissions.CLASS_READ, + Permissions.COURSE_CREATE, + Permissions.COURSE_READ, + Permissions.SCHEDULE_CREATE, + Permissions.SCHEDULE_READ, ], student: [ Permissions.EXAM_READ, + Permissions.EXAM_SUBMIT, Permissions.HOMEWORK_READ, Permissions.HOMEWORK_SUBMIT, Permissions.GRADE_READ, + Permissions.ATTENDANCE_READ, + Permissions.CLASS_READ, + Permissions.COURSE_READ, + Permissions.SCHEDULE_READ, ], }; diff --git a/services/core-edu/src/scheduling/schedule-conflict.ts b/services/core-edu/src/scheduling/schedule-conflict.ts new file mode 100644 index 0000000..982f8e9 --- /dev/null +++ b/services/core-edu/src/scheduling/schedule-conflict.ts @@ -0,0 +1,82 @@ +/** + * 排课冲突检测(纯函数) + * + * 冲突维度(P3): + * - teacher: 同一教师在同一时间段有排课冲突 + * - class: 同一班级在同一时间段有排课冲突 + * + * room_id 仅预留字段(ISSUE-006 决策 #2),P3 不校验教室冲突。 + */ + +export interface ScheduleSlot { + id: string; + teacherId: string; + classId: string; + startTime: Date; + endTime: Date; + roomId?: string | null; +} + +export interface ConflictResult { + hasConflict: boolean; + conflictingSlot?: ScheduleSlot; + conflictType: "teacher" | "class" | null; +} + +/** + * 检查时间区间重叠。 + * [startA, endA) 与 [startB, endB) 重叠的条件:startA < endB && startB < endA + */ +export function isTimeOverlap( + startA: Date, + endA: Date, + startB: Date, + endB: Date, +): boolean { + return startA < endB && startB < endA; +} + +/** + * 检查新排课与现有排课列表的冲突。 + * 排除自身(如果是更新场景,传入 excludeId)。 + */ +export function detectConflict( + newSlot: Omit & { id?: string }, + existingSlots: ScheduleSlot[], + excludeId?: string, +): ConflictResult { + for (const slot of existingSlots) { + if (excludeId && slot.id === excludeId) continue; + + if ( + !isTimeOverlap( + newSlot.startTime, + newSlot.endTime, + slot.startTime, + slot.endTime, + ) + ) { + continue; + } + + // 教师冲突 + if (newSlot.teacherId === slot.teacherId) { + return { + hasConflict: true, + conflictingSlot: slot, + conflictType: "teacher", + }; + } + + // 班级冲突 + if (newSlot.classId === slot.classId) { + return { + hasConflict: true, + conflictingSlot: slot, + conflictType: "class", + }; + } + } + + return { hasConflict: false, conflictType: null }; +} diff --git a/services/core-edu/src/scheduling/scheduling.controller.ts b/services/core-edu/src/scheduling/scheduling.controller.ts new file mode 100644 index 0000000..2a777ce --- /dev/null +++ b/services/core-edu/src/scheduling/scheduling.controller.ts @@ -0,0 +1,157 @@ +import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common"; +import { z } from "zod"; +import { + SchedulingService, + type CreateCourseInput, + type CreateScheduleInput, +} from "./scheduling.service.js"; +import { + Permissions, + RequirePermission, +} from "../middleware/permission.guard.js"; +import type { AuthenticatedRequest } from "../middleware/auth.middleware.js"; +import { + UnauthorizedError, + ValidationError, +} from "../shared/errors/application-error.js"; + +const createCourseSchema = z.object({ + classId: z.string().min(1), + subjectId: z.string().min(1), + teacherId: z.string().min(1), + schoolId: z.string().min(1), + name: z.string().min(1).max(200), + startDate: z.string().min(1), + endDate: z.string().min(1), +}); + +const createScheduleSchema = z.object({ + courseId: z.string().min(1), + lessonId: z.string().optional(), + teacherId: z.string().min(1), + classId: z.string().min(1), + roomId: z.string().optional(), + startTime: z.string().min(1), + endTime: z.string().min(1), + schoolId: z.string().min(1), +}); + +@Controller("v1") +export class SchedulingController { + constructor(private readonly schedulingService: SchedulingService) {} + + // ----- Course endpoints ----- + + @Post("courses") + @RequirePermission(Permissions.COURSE_CREATE) + async createCourse( + @Body() body: unknown, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: { id: string } }> { + const userId = req.userId; + if (!userId) { + throw new UnauthorizedError("Missing x-user-id header"); + } + const parsed = createCourseSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError("Invalid course input", parsed.error.flatten()); + } + const input: CreateCourseInput = parsed.data; + const result = await this.schedulingService.createCourse(input); + return { success: true, data: result }; + } + + @Get("courses/:id") + @RequirePermission(Permissions.COURSE_READ) + async getCourse(@Param("id") id: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.schedulingService.getCourse(id); + return { success: true, data }; + } + + @Get("courses/class/:classId") + @RequirePermission(Permissions.COURSE_READ) + async listCoursesByClass(@Param("classId") classId: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.schedulingService.listCoursesByClass(classId); + return { success: true, data }; + } + + @Get("courses/teacher/:teacherId") + @RequirePermission(Permissions.COURSE_READ) + async listCoursesByTeacher(@Param("teacherId") teacherId: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.schedulingService.listCoursesByTeacher(teacherId); + return { success: true, data }; + } + + // ----- Schedule endpoints ----- + + @Post("schedules") + @RequirePermission(Permissions.SCHEDULE_CREATE) + async createSchedule( + @Body() body: unknown, + @Req() req: AuthenticatedRequest, + ): Promise<{ success: true; data: { id: string } }> { + const userId = req.userId; + if (!userId) { + throw new UnauthorizedError("Missing x-user-id header"); + } + const parsed = createScheduleSchema.safeParse(body); + if (!parsed.success) { + throw new ValidationError( + "Invalid schedule input", + parsed.error.flatten(), + ); + } + const input: CreateScheduleInput = parsed.data; + const result = await this.schedulingService.createSchedule(input); + return { success: true, data: result }; + } + + @Get("schedules/:id") + @RequirePermission(Permissions.SCHEDULE_READ) + async getSchedule(@Param("id") id: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.schedulingService.getSchedule(id); + return { success: true, data }; + } + + @Get("schedules/course/:courseId") + @RequirePermission(Permissions.SCHEDULE_READ) + async listSchedulesByCourse(@Param("courseId") courseId: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.schedulingService.listSchedulesByCourse(courseId); + return { success: true, data }; + } + + @Get("schedules/teacher/:teacherId") + @RequirePermission(Permissions.SCHEDULE_READ) + async listSchedulesByTeacher(@Param("teacherId") teacherId: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.schedulingService.listSchedulesByTeacher(teacherId); + return { success: true, data }; + } + + @Get("schedules/class/:classId") + @RequirePermission(Permissions.SCHEDULE_READ) + async listSchedulesByClass(@Param("classId") classId: string): Promise<{ + success: true; + data: Awaited>; + }> { + const data = await this.schedulingService.listSchedulesByClass(classId); + return { success: true, data }; + } +} diff --git a/services/core-edu/src/scheduling/scheduling.module.ts b/services/core-edu/src/scheduling/scheduling.module.ts new file mode 100644 index 0000000..3f0f883 --- /dev/null +++ b/services/core-edu/src/scheduling/scheduling.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { SchedulingController } from "./scheduling.controller.js"; +import { SchedulingService } from "./scheduling.service.js"; + +@Module({ + controllers: [SchedulingController], + providers: [SchedulingService], + exports: [SchedulingService], +}) +export class SchedulingModule {} diff --git a/services/core-edu/src/scheduling/scheduling.repository.ts b/services/core-edu/src/scheduling/scheduling.repository.ts new file mode 100644 index 0000000..8caef9c --- /dev/null +++ b/services/core-edu/src/scheduling/scheduling.repository.ts @@ -0,0 +1,113 @@ +import { eq, and, lte, gte } from "drizzle-orm"; +import { db } from "../config/database.js"; +import { + courses, + schedules, + type Course, + type NewCourse, + type Schedule, + type NewSchedule, +} from "./scheduling.schema.js"; + +export class SchedulingRepository { + // Courses + async findCourseById(id: string): Promise { + const [result] = await db + .select() + .from(courses) + .where(eq(courses.id, id)) + .limit(1); + return result; + } + + async findCoursesByClassId(classId: string): Promise { + return db.select().from(courses).where(eq(courses.classId, classId)); + } + + async findCoursesByTeacherId(teacherId: string): Promise { + return db.select().from(courses).where(eq(courses.teacherId, teacherId)); + } + + async createCourse(course: NewCourse): Promise { + await db.insert(courses).values(course); + } + + // Schedules + async findScheduleById(id: string): Promise { + const [result] = await db + .select() + .from(schedules) + .where(eq(schedules.id, id)) + .limit(1); + return result; + } + + async findSchedulesByCourseId(courseId: string): Promise { + return db.select().from(schedules).where(eq(schedules.courseId, courseId)); + } + + async findSchedulesByTeacherId(teacherId: string): Promise { + return db + .select() + .from(schedules) + .where(eq(schedules.teacherId, teacherId)); + } + + async findSchedulesByClassId(classId: string): Promise { + return db.select().from(schedules).where(eq(schedules.classId, classId)); + } + + /** + * Find overlapping schedules for conflict detection. + * Returns schedules where [startTime, endTime) overlaps with the given range + * for a specific teacher or class. + */ + async findOverlappingSchedules( + teacherId: string, + classId: string, + startTime: Date, + endTime: Date, + excludeId?: string, + ): Promise { + // Query both teacher and class conflicts + const teacherSchedules = await db + .select() + .from(schedules) + .where( + and( + eq(schedules.teacherId, teacherId), + lte(schedules.startTime, endTime), + gte(schedules.endTime, startTime), + ), + ); + const classSchedules = await db + .select() + .from(schedules) + .where( + and( + eq(schedules.classId, classId), + lte(schedules.startTime, endTime), + gte(schedules.endTime, startTime), + ), + ); + const all = [...teacherSchedules, ...classSchedules]; + // Deduplicate and exclude self + const seen = new Set(); + return all.filter((s) => { + if (excludeId && s.id === excludeId) return false; + if (seen.has(s.id)) return false; + seen.add(s.id); + return true; + }); + } + + async createSchedule(schedule: NewSchedule): Promise { + await db.insert(schedules).values(schedule); + } + + async updateSchedule(id: string, data: Partial): Promise { + await db.update(schedules).set(data).where(eq(schedules.id, id)); + } +} + +export const schedulingRepository = new SchedulingRepository(); diff --git a/services/core-edu/src/scheduling/scheduling.schema.ts b/services/core-edu/src/scheduling/scheduling.schema.ts new file mode 100644 index 0000000..d555b67 --- /dev/null +++ b/services/core-edu/src/scheduling/scheduling.schema.ts @@ -0,0 +1,87 @@ +import { + mysqlTable, + varchar, + timestamp, + char, + datetime, + date, + int, + json, + index, +} from "drizzle-orm/mysql-core"; + +// 课程表 +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(), + }, + (table) => ({ + idxCoursesClassId: index("idx_courses_class_id").on(table.classId), + idxCoursesTeacherId: index("idx_courses_teacher_id").on(table.teacherId), + }), +); + +// 课次表 +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"), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => ({ + idxLessonsCourseId: index("idx_lessons_course_id").on(table.courseId), + }), +); + +// 排课表 +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 }), // P3 仅预留,不校验冲突 + startTime: datetime("start_time").notNull(), + endTime: datetime("end_time").notNull(), + status: varchar("status", { length: 20 }).notNull().default("scheduled"), + schoolId: char("school_id", { length: 36 }).notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + idxSchedulesTeacherTime: index("idx_schedules_teacher_time").on( + table.teacherId, + table.startTime, + table.endTime, + ), + idxSchedulesClassTime: index("idx_schedules_class_time").on( + table.classId, + table.startTime, + table.endTime, + ), + idxSchedulesCourseId: index("idx_schedules_course_id").on(table.courseId), + }), +); + +export type Course = typeof courses.$inferSelect; +export type NewCourse = typeof courses.$inferInsert; +export type Lesson = typeof lessons.$inferSelect; +export type NewLesson = typeof lessons.$inferInsert; +export type Schedule = typeof schedules.$inferSelect; +export type NewSchedule = typeof schedules.$inferInsert; diff --git a/services/core-edu/src/scheduling/scheduling.service.ts b/services/core-edu/src/scheduling/scheduling.service.ts new file mode 100644 index 0000000..ba5fa25 --- /dev/null +++ b/services/core-edu/src/scheduling/scheduling.service.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import { schedulingRepository } from "./scheduling.repository.js"; +import { detectConflict, type ScheduleSlot } from "./schedule-conflict.js"; +import { + NotFoundError, + ValidationError, + ApplicationError, + CoreEduErrorCode, +} from "../shared/errors/application-error.js"; +import type { Course, Schedule } from "./scheduling.schema.js"; + +export interface CreateCourseInput { + classId: string; + subjectId: string; + teacherId: string; + schoolId: string; + name: string; + startDate: Date | string; + endDate: Date | string; +} + +export interface CreateScheduleInput { + courseId: string; + lessonId?: string; + teacherId: string; + classId: string; + roomId?: string; + startTime: Date | string; + endTime: Date | string; + schoolId: string; +} + +@Injectable() +export class SchedulingService { + // Course operations + async createCourse(input: CreateCourseInput): Promise<{ id: string }> { + if (!input.classId || !input.subjectId || !input.teacherId || !input.name) { + throw new ValidationError( + "classId, subjectId, teacherId, name are required", + ); + } + const id = randomUUID(); + await schedulingRepository.createCourse({ + id, + classId: input.classId, + subjectId: input.subjectId, + teacherId: input.teacherId, + schoolId: input.schoolId, + name: input.name, + startDate: + input.startDate instanceof Date + ? input.startDate + : new Date(input.startDate), + endDate: + input.endDate instanceof Date ? input.endDate : new Date(input.endDate), + }); + return { id }; + } + + async getCourse(id: string): Promise { + const course = await schedulingRepository.findCourseById(id); + if (!course) { + throw new NotFoundError(`Course ${id} not found`); + } + return course; + } + + async listCoursesByClass(classId: string): Promise { + return schedulingRepository.findCoursesByClassId(classId); + } + + async listCoursesByTeacher(teacherId: string): Promise { + return schedulingRepository.findCoursesByTeacherId(teacherId); + } + + // Schedule operations + async createSchedule(input: CreateScheduleInput): Promise<{ id: string }> { + if ( + !input.courseId || + !input.teacherId || + !input.classId || + !input.schoolId + ) { + throw new ValidationError( + "courseId, teacherId, classId, schoolId are required", + ); + } + + const startTime = + input.startTime instanceof Date + ? input.startTime + : new Date(input.startTime); + const endTime = + input.endTime instanceof Date ? input.endTime : new Date(input.endTime); + + if (startTime >= endTime) { + throw new ValidationError("startTime must be before endTime"); + } + + // Conflict detection + const overlapping = await schedulingRepository.findOverlappingSchedules( + input.teacherId, + input.classId, + startTime, + endTime, + ); + + const newSlot: Omit = { + teacherId: input.teacherId, + classId: input.classId, + startTime, + endTime, + roomId: input.roomId, + }; + + const conflict = detectConflict(newSlot, overlapping); + if (conflict.hasConflict && conflict.conflictingSlot) { + throw new ApplicationError( + CoreEduErrorCode.SCHEDULE_CONFLICT, + `Schedule conflict detected (${conflict.conflictType}): overlaps with existing schedule ${conflict.conflictingSlot.id}`, + 409, + { + conflictType: conflict.conflictType, + conflictingScheduleId: conflict.conflictingSlot.id, + }, + ); + } + + const id = randomUUID(); + await schedulingRepository.createSchedule({ + id, + courseId: input.courseId, + lessonId: input.lessonId, + teacherId: input.teacherId, + classId: input.classId, + roomId: input.roomId, + startTime, + endTime, + schoolId: input.schoolId, + }); + return { id }; + } + + async getSchedule(id: string): Promise { + const schedule = await schedulingRepository.findScheduleById(id); + if (!schedule) { + throw new NotFoundError(`Schedule ${id} not found`); + } + return schedule; + } + + async listSchedulesByCourse(courseId: string): Promise { + return schedulingRepository.findSchedulesByCourseId(courseId); + } + + async listSchedulesByTeacher(teacherId: string): Promise { + return schedulingRepository.findSchedulesByTeacherId(teacherId); + } + + async listSchedulesByClass(classId: string): Promise { + return schedulingRepository.findSchedulesByClassId(classId); + } +} diff --git a/services/core-edu/src/shared/errors/application-error.ts b/services/core-edu/src/shared/errors/application-error.ts index 2e0621d..aa3cbf4 100644 --- a/services/core-edu/src/shared/errors/application-error.ts +++ b/services/core-edu/src/shared/errors/application-error.ts @@ -5,9 +5,19 @@ export enum CoreEduErrorCode { FORBIDDEN = "CORE_EDU_FORBIDDEN", CONFLICT = "CORE_EDU_CONFLICT", INTERNAL_ERROR = "CORE_EDU_INTERNAL_ERROR", + // Domain-specific errors EXAM_NOT_FOUND = "CORE_EDU_EXAM_NOT_FOUND", + EXAM_INVALID_TRANSITION = "CORE_EDU_EXAM_INVALID_TRANSITION", HOMEWORK_NOT_FOUND = "CORE_EDU_HOMEWORK_NOT_FOUND", + HOMEWORK_INVALID_TRANSITION = "CORE_EDU_HOMEWORK_INVALID_TRANSITION", GRADE_NOT_FOUND = "CORE_EDU_GRADE_NOT_FOUND", + GRADE_DUPLICATE = "CORE_EDU_GRADE_DUPLICATE", + ATTENDANCE_NOT_FOUND = "CORE_EDU_ATTENDANCE_NOT_FOUND", + ATTENDANCE_DUPLICATE = "CORE_EDU_ATTENDANCE_DUPLICATE", + CLASS_NOT_FOUND = "CORE_EDU_CLASS_NOT_FOUND", + COURSE_NOT_FOUND = "CORE_EDU_COURSE_NOT_FOUND", + SCHEDULE_NOT_FOUND = "CORE_EDU_SCHEDULE_NOT_FOUND", + SCHEDULE_CONFLICT = "CORE_EDU_SCHEDULE_CONFLICT", OUTBOX_PUBLISH_FAILED = "CORE_EDU_OUTBOX_PUBLISH_FAILED", } diff --git a/services/core-edu/src/shared/health/health.controller.ts b/services/core-edu/src/shared/health/health.controller.ts index 084eb4c..3789094 100644 --- a/services/core-edu/src/shared/health/health.controller.ts +++ b/services/core-edu/src/shared/health/health.controller.ts @@ -1,13 +1,22 @@ import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common"; import { sql } from "drizzle-orm"; import { db } from "../../config/database.js"; +import { isRedisHealthy } from "../../config/redis.js"; +import { isKafkaConnected } from "../../config/kafka.js"; const SERVICE_NAME = "core-edu"; +interface HealthResponse { + status: string; + service: string; + timestamp: string; + checks?: Record; +} + @Controller() export class HealthController { @Get("healthz") - liveness(): { status: string; service: string; timestamp: string } { + liveness(): HealthResponse { return { status: "ok", service: SERVICE_NAME, @@ -16,29 +25,51 @@ export class HealthController { } @Get("readyz") - async readiness(): Promise<{ - status: string; - service: string; - timestamp: string; - }> { + async readiness(): Promise { + const checks: Record = {}; + let allHealthy = true; + + // DB check try { await db.execute(sql`SELECT 1`); - return { - status: "ok", - service: SERVICE_NAME, - timestamp: new Date().toISOString(), - }; + checks.db = "ok"; } catch (error) { + checks.db = error instanceof Error ? error.message : "unreachable"; + allHealthy = false; + } + + // Redis check (optional - degrade gracefully) + try { + const redisOk = await isRedisHealthy(); + checks.redis = redisOk ? "ok" : "disabled"; + } catch { + checks.redis = "disabled"; + } + + // Kafka check (optional - degrade gracefully) + try { + checks.kafka = isKafkaConnected() ? "ok" : "disconnected"; + } catch { + checks.kafka = "unknown"; + } + + if (!allHealthy) { throw new HttpException( { status: "error", service: SERVICE_NAME, timestamp: new Date().toISOString(), - error: - error instanceof Error ? error.message : "database unreachable", + checks, }, HttpStatus.SERVICE_UNAVAILABLE, ); } + + return { + status: "ok", + service: SERVICE_NAME, + timestamp: new Date().toISOString(), + checks, + }; } } diff --git a/services/core-edu/src/shared/lifecycle/lifecycle.service.ts b/services/core-edu/src/shared/lifecycle/lifecycle.service.ts index e53443b..bf12f20 100644 --- a/services/core-edu/src/shared/lifecycle/lifecycle.service.ts +++ b/services/core-edu/src/shared/lifecycle/lifecycle.service.ts @@ -5,6 +5,9 @@ import { OnModuleInit, } from "@nestjs/common"; import { closeDb } from "../../config/database.js"; +import { disconnectRedis } from "../../config/redis.js"; +import { disconnectKafka } from "../../config/kafka.js"; +import { outboxPublisher } from "../outbox/outbox.publisher.js"; const SERVICE_NAME = "core-edu"; @@ -21,6 +24,9 @@ export class LifecycleService implements OnModuleInit, OnApplicationShutdown { `service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`, ); try { + await outboxPublisher.stop(); + await disconnectRedis(); + await disconnectKafka(); await closeDb(); this.logger.log(`service ${SERVICE_NAME} shutdown complete`); } catch (error) { diff --git a/services/core-edu/src/shared/outbox/event-builder.ts b/services/core-edu/src/shared/outbox/event-builder.ts new file mode 100644 index 0000000..9401e4e --- /dev/null +++ b/services/core-edu/src/shared/outbox/event-builder.ts @@ -0,0 +1,61 @@ +import { randomUUID } from "node:crypto"; + +/** + * Outbox 事件构建器 + * + * 所有事件 payload 必须包含(events.proto 仲裁 §2.1): + * - event_id: UUID(幂等消费) + * - occurred_at: 业务时间戳(ms epoch) + * - schema_version: 默认 "v1" + * - metadata: { traceId, userId } + */ + +export interface EventMetadata { + schema_version: string; + trace_id: string; + user_id: string; +} + +export interface OutboxEvent> { + event_id: string; + aggregate_id: string; + event_type: string; + occurred_at: number; + payload: T; + metadata: EventMetadata; +} + +export interface BuildEventInput { + aggregateId: string; + eventType: string; + payload: Record; + userId?: string; + traceId?: string; + schemaVersion?: string; +} + +/** + * 构建标准 Outbox 事件(含 event_id / occurred_at / schema_version / metadata)。 + * 返回的对象可直接 JSON.stringify 作为 outbox.payload 存储。 + */ +export function buildEvent(input: BuildEventInput): OutboxEvent { + return { + event_id: randomUUID(), + aggregate_id: input.aggregateId, + event_type: input.eventType, + occurred_at: Date.now(), + payload: input.payload, + metadata: { + schema_version: input.schemaVersion ?? "v1", + trace_id: input.traceId ?? "unknown", + user_id: input.userId ?? "system", + }, + }; +} + +/** + * 将 OutboxEvent 序列化为 JSON 字符串(用于 outbox.payload 列)。 + */ +export function serializeEvent(event: OutboxEvent): string { + return JSON.stringify(event); +} diff --git a/services/core-edu/src/shared/outbox/outbox.publisher.ts b/services/core-edu/src/shared/outbox/outbox.publisher.ts index 6144061..6c5a174 100644 --- a/services/core-edu/src/shared/outbox/outbox.publisher.ts +++ b/services/core-edu/src/shared/outbox/outbox.publisher.ts @@ -1,30 +1,46 @@ -import { logger } from '../observability/logger.js'; -import { producer } from '../../config/kafka.js'; -import { outboxRepository } from './outbox.repository.js'; -import type { OutboxMessage } from './outbox.schema.js'; +import { logger } from "../observability/logger.js"; +import { producer } from "../../config/kafka.js"; +import { outboxRepository } from "./outbox.repository.js"; +import type { OutboxMessage } from "./outbox.schema.js"; +/** + * TOPIC_MAP: 事件类型 → Kafka topic + * + * 仲裁依据:ISSUE-002 / ISSUE-004 / events.proto 头注释 + * 命名规范:edu.teaching.. + */ const TOPIC_MAP: Record = { - 'exam.created': 'edu.exam.events', - 'exam.updated': 'edu.exam.events', - 'exam.deleted': 'edu.exam.events', - 'homework.assigned': 'edu.homework.events', - 'homework.submitted': 'edu.homework.events', - 'homework.graded': 'edu.homework.events', - 'grade.recorded': 'edu.grade.events', - 'grade.updated': 'edu.grade.events', - 'class.transferred': 'edu.class.events', + // Exam events + "exam.created": "edu.teaching.exam.created", + "exam.updated": "edu.teaching.exam.updated", + "exam.published": "edu.teaching.exam.published", + "exam.submitted": "edu.teaching.exam.submitted", + "exam.graded": "edu.teaching.exam.graded", + "exam.deleted": "edu.teaching.exam.deleted", + // Homework events + "homework.assigned": "edu.teaching.homework.assigned", + "homework.submitted": "edu.teaching.homework.submitted", + "homework.graded": "edu.teaching.homework.graded", + // Grade events + "grade.recorded": "edu.teaching.grade.recorded", + "grade.updated": "edu.teaching.grade.updated", + // Attendance events + "attendance.recorded": "edu.teaching.attendance.recorded", + // Class events (ISSUE-004: 统一为 edu.teaching.class.transferred) + "class.transferred": "edu.teaching.class.transferred", }; const POLL_INTERVAL_MS = 5000; const BATCH_SIZE = 100; const MAX_RETRY = 5; +const RETRY_BACKOFF_BASE_MS = 1000; export class OutboxPublisher { private intervalId: NodeJS.Timeout | null = null; private isPolling = false; async start(): Promise { - logger.info('OutboxPublisher started'); + logger.info("OutboxPublisher started"); this.intervalId = setInterval(() => { void this.poll(); }, POLL_INTERVAL_MS); @@ -35,7 +51,7 @@ export class OutboxPublisher { clearInterval(this.intervalId); this.intervalId = null; } - logger.info('OutboxPublisher stopped'); + logger.info("OutboxPublisher stopped"); } private async poll(): Promise { @@ -47,14 +63,14 @@ export class OutboxPublisher { await this.publish(message); } } catch (error) { - logger.error({ error }, 'Outbox poll failed'); + logger.error({ error }, "Outbox poll failed"); } finally { this.isPolling = false; } } private async publish(message: OutboxMessage): Promise { - const topic = TOPIC_MAP[message.eventType] ?? 'edu.fallback.events'; + const topic = TOPIC_MAP[message.eventType] ?? "edu.teaching.fallback"; try { await producer.send({ topic, @@ -65,21 +81,32 @@ export class OutboxPublisher { headers: { eventType: message.eventType, aggregateType: message.aggregateType, + eventId: message.eventId, }, }, ], }); await outboxRepository.markProcessed(message.id); logger.info( - { id: message.id, eventType: message.eventType, topic }, - 'Outbox message published', + { + id: message.id, + eventId: message.eventId, + eventType: message.eventType, + topic, + }, + "Outbox message published", ); } catch (error) { - logger.error({ error, id: message.id }, 'Outbox publish failed'); - if (message.retryCount + 1 >= MAX_RETRY) { + logger.error( + { error, id: message.id, eventId: message.eventId }, + "Outbox publish failed", + ); + const nextRetry = message.retryCount + 1; + if (nextRetry >= MAX_RETRY) { await outboxRepository.markFailed(message.id); } else { - await outboxRepository.incrementRetry(message.id); + const backoff = RETRY_BACKOFF_BASE_MS * Math.pow(2, nextRetry); + await outboxRepository.incrementRetry(message.id, backoff); } } } diff --git a/services/core-edu/src/shared/outbox/outbox.repository.ts b/services/core-edu/src/shared/outbox/outbox.repository.ts index 46c8b22..349d4ad 100644 --- a/services/core-edu/src/shared/outbox/outbox.repository.ts +++ b/services/core-edu/src/shared/outbox/outbox.repository.ts @@ -1,6 +1,10 @@ -import { eq, sql } from 'drizzle-orm'; -import { db } from '../../config/database.js'; -import { outbox, type OutboxMessage, type NewOutboxMessage } from './outbox.schema.js'; +import { eq, sql, and, lte, isNull, or } from "drizzle-orm"; +import { db } from "../../config/database.js"; +import { + outbox, + type OutboxMessage, + type NewOutboxMessage, +} from "./outbox.schema.js"; type DbClient = typeof db; @@ -9,33 +13,45 @@ export class OutboxRepository { await tx.insert(outbox).values(message); } + /** + * 查找待发布消息: + * - status = 'pending' + * - next_retry_at IS NULL 或 next_retry_at <= NOW() + * 按 created_at 升序,取 limit 条 + */ async findPending(limit: number = 100): Promise { return db .select() .from(outbox) - .where(eq(outbox.status, 'pending')) + .where( + and( + eq(outbox.status, "pending"), + or(lte(outbox.nextRetryAt, new Date()), isNull(outbox.nextRetryAt)), + ), + ) .limit(limit); } async markProcessed(id: string): Promise { await db .update(outbox) - .set({ status: 'processed', processedAt: new Date() }) + .set({ status: "processed", processedAt: new Date() }) .where(eq(outbox.id, id)); } - async incrementRetry(id: string): Promise { + async incrementRetry(id: string, backoffMs: number): Promise { + const nextRetryAt = new Date(Date.now() + backoffMs); await db .update(outbox) - .set({ retryCount: sql`${outbox.retryCount} + 1` }) + .set({ + retryCount: sql`${outbox.retryCount} + 1`, + nextRetryAt, + }) .where(eq(outbox.id, id)); } async markFailed(id: string): Promise { - await db - .update(outbox) - .set({ status: 'failed' }) - .where(eq(outbox.id, id)); + await db.update(outbox).set({ status: "failed" }).where(eq(outbox.id, id)); } } diff --git a/services/core-edu/src/shared/outbox/outbox.schema.ts b/services/core-edu/src/shared/outbox/outbox.schema.ts index 2c79c8e..3dd33ab 100644 --- a/services/core-edu/src/shared/outbox/outbox.schema.ts +++ b/services/core-edu/src/shared/outbox/outbox.schema.ts @@ -1,16 +1,44 @@ -import { mysqlTable, varchar, text, timestamp, char, bigint } from 'drizzle-orm/mysql-core'; +import { + mysqlTable, + varchar, + text, + timestamp, + char, + bigint, + datetime, + index, + uniqueIndex, +} from "drizzle-orm/mysql-core"; -export const outbox = mysqlTable('core_edu_outbox', { - id: char('id', { length: 36 }).notNull().primaryKey(), - aggregateId: char('aggregate_id', { length: 36 }).notNull(), - aggregateType: varchar('aggregate_type', { length: 50 }).notNull(), - eventType: varchar('event_type', { length: 100 }).notNull(), - payload: text('payload').notNull(), - status: varchar('status', { length: 20 }).notNull().default('pending'), - retryCount: bigint('retry_count', { mode: 'number' }).notNull().default(0), - createdAt: timestamp('created_at').notNull().defaultNow(), - processedAt: timestamp('processed_at'), -}); +// 事务性发件箱(Outbox 模式) +export const outbox = mysqlTable( + "core_edu_outbox", + { + id: char("id", { length: 36 }).notNull().primaryKey(), + 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(), + occurredAt: datetime("occurred_at").notNull(), + payload: text("payload").notNull(), + status: varchar("status", { length: 20 }).notNull().default("pending"), + retryCount: bigint("retry_count", { mode: "number" }).notNull().default(0), + nextRetryAt: datetime("next_retry_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + processedAt: timestamp("processed_at"), + }, + (table) => ({ + uniqEventId: uniqueIndex("uniq_event_id").on(table.eventId), + idxOutboxStatusRetry: index("idx_outbox_status_retry").on( + table.status, + table.nextRetryAt, + ), + idxOutboxAggregate: index("idx_outbox_aggregate").on( + table.aggregateType, + table.aggregateId, + ), + }), +); export type OutboxMessage = typeof outbox.$inferSelect; export type NewOutboxMessage = typeof outbox.$inferInsert;