feat(core-edu): 完整实现 core-edu 教学核心服务
包含 classes/exams/homework/grades/attendance/scheduling 域、outbox、iam-consumer、redis 配置等完整实现
This commit is contained in:
@@ -256,22 +256,34 @@
|
|||||||
|
|
||||||
### 2.4 core-edu(TS/NestJS,P3)
|
### 2.4 core-edu(TS/NestJS,P3)
|
||||||
|
|
||||||
| 场景 | 技术/规则 |
|
| 场景 | 技术/规则 |
|
||||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 |
|
| 考试全生命周期 | 教师创建 → 发布 → 学生作答 → 教师批改 → 成绩统计 |
|
||||||
| Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka |
|
| Outbox 模式 | 业务事务同写 `outbox_events` 表,后台 relay worker 投递 Kafka |
|
||||||
| Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 |
|
| Outbox relay | Go 写独立服务 `services/outbox-relay/`,轻量高吞吐 |
|
||||||
| Kafka topic | `exam.published` / `homework.graded` / `grade.recorded` |
|
| Kafka topic | `edu.teaching.exam.created` / `edu.teaching.homework.graded` / `edu.teaching.grade.recorded`(ISSUE-002/004 仲裁统一前缀) |
|
||||||
| 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 |
|
| 不引入 Saga | 跨服务一致性用 Outbox + 最终一致性 |
|
||||||
| 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费(DataAna/Msg) |
|
| 批改后联动 | 批改完成 → 发 `homework.graded` 事件 → 下游消费(DataAna/Msg) |
|
||||||
| Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) |
|
| Temporal 试点 | 仅 1 个工作流(考试发布编排:创建作业→通知) |
|
||||||
| schema 表 | exams / exam_questions / homework_assignments / homework_submissions / homework_answers / grade_records / outbox_events |
|
| 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` |
|
| datetime 列 | Drizzle `datetime` 列需 Date 对象,HTTP 请求体里是 ISO 字符串,service 层必须 `new Date(input)` 转换否则报 `toISOString is not a function` |
|
||||||
| Kafka 非阻塞 | 开发环境 Kafka 未启动时 `connectKafka()` 必须 try/catch 且 main 中用 `void` 调用,否则阻塞服务启动 |
|
| Kafka 非阻塞 | 开发环境 Kafka 未启动时 `connectKafka()` 必须 try/catch 且 main 中用 `void` 调用,否则阻塞服务启动 |
|
||||||
| 相对 import | `src/<domain>/` 下文件回溯一级用 `../`,不要用 `../../`(NestJS ESM 模块) |
|
| 相对 import | `src/<domain>/` 下文件回溯一级用 `../`,不要用 `../../`(NestJS ESM 模块) |
|
||||||
| 身份头读取 | Controller 用 `@Req() req` 从 `req.headers['x-user-id']` 读 createdBy/gradedBy,不依赖未注册的 AuthMiddleware |
|
| 身份头读取 | Controller 用 `@Req() req` 从 `req.headers['x-user-id']` 读 createdBy/gradedBy,不依赖未注册的 AuthMiddleware |
|
||||||
| 健康检查 | health.controller 用 Drizzle `db.execute(sql\`SELECT 1\`)`,不用 typeorm DataSource |
|
| 健康检查 | health.controller 用 Drizzle `db.execute(sql\`SELECT 1\`)`,不用 typeorm DataSource |
|
||||||
| Outbox 验证 | 业务事务同写 `core_edu_outbox` 表,Kafka 未启动时事件 status=failed,启动后会重试 |
|
| 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)
|
### 2.5 content(TS/NestJS,P4)
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,14 @@ plugins:
|
|||||||
out: ../shared-ts/gen/proto
|
out: ../shared-ts/gen/proto
|
||||||
- remote: buf.build/protocolbuffers/python
|
- remote: buf.build/protocolbuffers/python
|
||||||
out: ../shared-py/gen/proto
|
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
|
||||||
|
|||||||
@@ -3,8 +3,17 @@ syntax = "proto3";
|
|||||||
package next_edu_cloud.core_edu.v1;
|
package next_edu_cloud.core_edu.v1;
|
||||||
|
|
||||||
// CoreEdu service contracts - P3 core teaching domain.
|
// 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.
|
// 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 {
|
service ExamService {
|
||||||
rpc CreateExam(CreateExamRequest) returns (CreateExamResponse);
|
rpc CreateExam(CreateExamRequest) returns (CreateExamResponse);
|
||||||
@@ -12,6 +21,9 @@ service ExamService {
|
|||||||
rpc ListExamsByClass(ListExamsByClassRequest) returns (ListExamsResponse);
|
rpc ListExamsByClass(ListExamsByClassRequest) returns (ListExamsResponse);
|
||||||
rpc UpdateExam(UpdateExamRequest) returns (UpdateExamResponse);
|
rpc UpdateExam(UpdateExamRequest) returns (UpdateExamResponse);
|
||||||
rpc DeleteExam(DeleteExamRequest) returns (DeleteExamResponse);
|
rpc DeleteExam(DeleteExamRequest) returns (DeleteExamResponse);
|
||||||
|
rpc PublishExam(PublishExamRequest) returns (PublishExamResponse);
|
||||||
|
rpc SubmitExam(SubmitExamRequest) returns (SubmitExamResponse);
|
||||||
|
rpc GradeExam(GradeExamRequest) returns (GradeExamResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
service HomeworkService {
|
service HomeworkService {
|
||||||
@@ -19,6 +31,7 @@ service HomeworkService {
|
|||||||
rpc GetHomework(GetHomeworkRequest) returns (Homework);
|
rpc GetHomework(GetHomeworkRequest) returns (Homework);
|
||||||
rpc ListHomeworkByClass(ListHomeworkByClassRequest) returns (ListHomeworkResponse);
|
rpc ListHomeworkByClass(ListHomeworkByClassRequest) returns (ListHomeworkResponse);
|
||||||
rpc SubmitHomework(SubmitHomeworkRequest) returns (SubmitHomeworkResponse);
|
rpc SubmitHomework(SubmitHomeworkRequest) returns (SubmitHomeworkResponse);
|
||||||
|
rpc GradeHomework(GradeHomeworkRequest) returns (GradeHomeworkResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
service GradeService {
|
service GradeService {
|
||||||
@@ -27,54 +40,76 @@ service GradeService {
|
|||||||
rpc ListGradesByStudent(ListGradesByStudentRequest) returns (ListGradesResponse);
|
rpc ListGradesByStudent(ListGradesByStudentRequest) returns (ListGradesResponse);
|
||||||
rpc ListGradesByExam(ListGradesByExamRequest) returns (ListGradesResponse);
|
rpc ListGradesByExam(ListGradesByExamRequest) returns (ListGradesResponse);
|
||||||
rpc ListGradesByHomework(ListGradesByHomeworkRequest) 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 {
|
message Exam {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
string class_id = 2;
|
string class_id = 2;
|
||||||
string title = 3;
|
string subject_id = 3;
|
||||||
string description = 4;
|
string title = 4;
|
||||||
string exam_date = 5;
|
string description = 5;
|
||||||
string duration = 6;
|
string exam_date = 6; // ISO 8601
|
||||||
string total_score = 7;
|
int32 duration = 7; // seconds
|
||||||
string status = 8;
|
string total_score = 8; // decimal as string
|
||||||
string created_by = 9;
|
string status = 9;
|
||||||
string created_at = 10;
|
string status_changed_at = 10;
|
||||||
string updated_at = 11;
|
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 id = 1;
|
||||||
string class_id = 2;
|
string exam_id = 2;
|
||||||
string title = 3;
|
string question_id = 3;
|
||||||
string description = 4;
|
int32 order = 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 score = 5;
|
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 graded_by = 7;
|
||||||
string created_at = 8;
|
string total_score = 8;
|
||||||
string updated_at = 9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateExamRequest {
|
message CreateExamRequest {
|
||||||
string class_id = 1;
|
string class_id = 1;
|
||||||
string title = 2;
|
string subject_id = 2;
|
||||||
string description = 3;
|
string title = 3;
|
||||||
string exam_date = 4;
|
string description = 4;
|
||||||
string duration = 5;
|
string exam_date = 5;
|
||||||
string total_score = 6;
|
int32 duration = 6;
|
||||||
string created_by = 7;
|
string total_score = 7;
|
||||||
|
string school_id = 8;
|
||||||
|
string created_by = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateExamResponse {
|
message CreateExamResponse {
|
||||||
@@ -98,7 +133,7 @@ message UpdateExamRequest {
|
|||||||
string title = 2;
|
string title = 2;
|
||||||
string description = 3;
|
string description = 3;
|
||||||
string exam_date = 4;
|
string exam_date = 4;
|
||||||
string duration = 5;
|
int32 duration = 5;
|
||||||
string total_score = 6;
|
string total_score = 6;
|
||||||
string status = 7;
|
string status = 7;
|
||||||
}
|
}
|
||||||
@@ -115,12 +150,88 @@ message DeleteExamResponse {
|
|||||||
bool success = 1;
|
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 {
|
message AssignHomeworkRequest {
|
||||||
string class_id = 1;
|
string class_id = 1;
|
||||||
string title = 2;
|
string subject_id = 2;
|
||||||
string description = 3;
|
string title = 3;
|
||||||
string due_date = 4;
|
string description = 4;
|
||||||
string created_by = 5;
|
string due_date = 5;
|
||||||
|
int32 grace_period = 6;
|
||||||
|
string school_id = 7;
|
||||||
|
string created_by = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AssignHomeworkResponse {
|
message AssignHomeworkResponse {
|
||||||
@@ -140,11 +251,45 @@ message ListHomeworkResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message SubmitHomeworkRequest {
|
message SubmitHomeworkRequest {
|
||||||
string id = 1;
|
string homework_id = 1;
|
||||||
|
string student_id = 2;
|
||||||
|
repeated AnswerInput answers = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SubmitHomeworkResponse {
|
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;
|
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 {
|
message RecordGradeRequest {
|
||||||
@@ -152,8 +297,11 @@ message RecordGradeRequest {
|
|||||||
string exam_id = 2;
|
string exam_id = 2;
|
||||||
string homework_id = 3;
|
string homework_id = 3;
|
||||||
string score = 4;
|
string score = 4;
|
||||||
string feedback = 5;
|
string total_score = 5;
|
||||||
string graded_by = 6;
|
string feedback = 6;
|
||||||
|
string graded_by = 7;
|
||||||
|
string school_id = 8;
|
||||||
|
string idempotency_key = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RecordGradeResponse {
|
message RecordGradeResponse {
|
||||||
@@ -179,3 +327,107 @@ message ListGradesByHomeworkRequest {
|
|||||||
message ListGradesResponse {
|
message ListGradesResponse {
|
||||||
repeated Grade grades = 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,13 +4,36 @@ package next_edu_cloud.events.v1;
|
|||||||
|
|
||||||
// Cross-service event contracts published by CoreEdu via the transactional
|
// Cross-service event contracts published by CoreEdu via the transactional
|
||||||
// outbox pattern and consumed by downstream services (notifications, analytics,
|
// outbox pattern and consumed by downstream services (notifications, analytics,
|
||||||
// audit, etc.). Topics follow the convention edu.{domain}.events.
|
// audit, etc.).
|
||||||
|
//
|
||||||
|
// Topic naming follows the arbitration in coord-cross-review §3.1:
|
||||||
|
// edu.teaching.<aggregate>.<action>
|
||||||
//
|
//
|
||||||
// Event routing (TOPIC_MAP in outbox.publisher.ts):
|
// Event routing (TOPIC_MAP in outbox.publisher.ts):
|
||||||
// edu.exam.events <- exam.created / exam.updated / exam.deleted
|
// edu.teaching.exam.created <- ExamEvent.created
|
||||||
// edu.homework.events <- homework.assigned / homework.submitted / homework.graded
|
// edu.teaching.exam.updated <- ExamEvent.updated
|
||||||
// edu.grade.events <- grade.recorded / grade.updated
|
// edu.teaching.exam.published <- ExamEvent.published
|
||||||
// edu.class.events <- class.transferred
|
// 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 {
|
message ClassEvent {
|
||||||
string event_id = 1;
|
string event_id = 1;
|
||||||
@@ -20,7 +43,7 @@ message ClassEvent {
|
|||||||
string class_id = 5;
|
string class_id = 5;
|
||||||
string name = 6;
|
string name = 6;
|
||||||
string action = 7;
|
string action = 7;
|
||||||
map<string, string> metadata = 8;
|
EventMetadata metadata = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExamEvent {
|
message ExamEvent {
|
||||||
@@ -30,9 +53,10 @@ message ExamEvent {
|
|||||||
int64 occurred_at = 4;
|
int64 occurred_at = 4;
|
||||||
string exam_id = 5;
|
string exam_id = 5;
|
||||||
string class_id = 6;
|
string class_id = 6;
|
||||||
string title = 7;
|
string subject_id = 7;
|
||||||
string action = 8;
|
string title = 8;
|
||||||
map<string, string> metadata = 9;
|
string action = 9;
|
||||||
|
EventMetadata metadata = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message HomeworkEvent {
|
message HomeworkEvent {
|
||||||
@@ -42,9 +66,10 @@ message HomeworkEvent {
|
|||||||
int64 occurred_at = 4;
|
int64 occurred_at = 4;
|
||||||
string homework_id = 5;
|
string homework_id = 5;
|
||||||
string class_id = 6;
|
string class_id = 6;
|
||||||
string title = 7;
|
string subject_id = 7;
|
||||||
string action = 8;
|
string title = 8;
|
||||||
map<string, string> metadata = 9;
|
string action = 9;
|
||||||
|
EventMetadata metadata = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GradeEvent {
|
message GradeEvent {
|
||||||
@@ -55,6 +80,19 @@ message GradeEvent {
|
|||||||
string grade_id = 5;
|
string grade_id = 5;
|
||||||
string student_id = 6;
|
string student_id = 6;
|
||||||
string score = 7;
|
string score = 7;
|
||||||
string action = 8;
|
string total_score = 8;
|
||||||
map<string, string> metadata = 9;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
175
pnpm-lock.yaml
generated
175
pnpm-lock.yaml
generated
@@ -82,8 +82,88 @@ importers:
|
|||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/hooks:
|
||||||
|
dependencies:
|
||||||
|
'@edu/ui-components':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../ui-components
|
||||||
|
react:
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.1
|
||||||
|
react-dom:
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.1(react@18.3.1)
|
||||||
|
devDependencies:
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.31
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.7(@types/react@18.3.31)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/shared-proto: {}
|
packages/shared-proto: {}
|
||||||
|
|
||||||
|
packages/shared-ts:
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common':
|
||||||
|
specifier: ^10.4.0
|
||||||
|
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/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@paralleldrive/cuid2':
|
||||||
|
specifier: ^2.2.2
|
||||||
|
version: 2.3.1
|
||||||
|
drizzle-orm:
|
||||||
|
specifier: ^0.31.0
|
||||||
|
version: 0.31.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.6.1)(@types/react@18.3.31)(better-sqlite3@11.10.0)(mysql2@3.22.6(@types/node@22.20.0))(react@18.3.1)
|
||||||
|
kafkajs:
|
||||||
|
specifier: ^2.2.0
|
||||||
|
version: 2.2.4
|
||||||
|
pino:
|
||||||
|
specifier: ^9.4.0
|
||||||
|
version: 9.14.0
|
||||||
|
reflect-metadata:
|
||||||
|
specifier: ^0.2.2
|
||||||
|
version: 0.2.2
|
||||||
|
rxjs:
|
||||||
|
specifier: ^7.8.0
|
||||||
|
version: 7.8.2
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.20.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/ui-components:
|
||||||
|
dependencies:
|
||||||
|
'@edu/ui-tokens':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../ui-tokens
|
||||||
|
react:
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.1
|
||||||
|
react-dom:
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.1(react@18.3.1)
|
||||||
|
devDependencies:
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.31
|
||||||
|
'@types/react-dom':
|
||||||
|
specifier: ^18.3.0
|
||||||
|
version: 18.3.7(@types/react@18.3.31)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/ui-tokens: {}
|
||||||
|
|
||||||
scripts/arch-scan:
|
scripts/arch-scan:
|
||||||
dependencies:
|
dependencies:
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
@@ -302,6 +382,9 @@ importers:
|
|||||||
prom-client:
|
prom-client:
|
||||||
specifier: ^15.1.0
|
specifier: ^15.1.0
|
||||||
version: 15.1.3
|
version: 15.1.3
|
||||||
|
redis:
|
||||||
|
specifier: ^4.7.0
|
||||||
|
version: 4.7.1
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
@@ -1494,6 +1577,10 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@noble/hashes@1.8.0':
|
||||||
|
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||||
|
engines: {node: ^14.21.3 || >=16}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2503,6 +2590,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@opentelemetry/api': ^1.1.0
|
'@opentelemetry/api': ^1.1.0
|
||||||
|
|
||||||
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
|
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
@@ -2537,6 +2627,35 @@ packages:
|
|||||||
'@protobufjs/utf8@1.1.2':
|
'@protobufjs/utf8@1.1.2':
|
||||||
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
|
resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
|
||||||
|
|
||||||
|
'@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':
|
'@rollup/rollup-android-arm-eabi@4.62.2':
|
||||||
resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
|
resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
@@ -3317,6 +3436,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
|
resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
code-block-writer@13.0.3:
|
||||||
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
||||||
|
|
||||||
@@ -3995,6 +4118,10 @@ packages:
|
|||||||
generate-function@2.3.1:
|
generate-function@2.3.1:
|
||||||
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
|
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
|
||||||
|
|
||||||
|
generic-pool@3.9.0:
|
||||||
|
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
|
||||||
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
get-caller-file@2.0.5:
|
get-caller-file@2.0.5:
|
||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
@@ -5048,6 +5175,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
redis@4.7.1:
|
||||||
|
resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==}
|
||||||
|
|
||||||
reflect-metadata@0.2.2:
|
reflect-metadata@0.2.2:
|
||||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||||
|
|
||||||
@@ -6642,6 +6772,8 @@ snapshots:
|
|||||||
'@next/swc-win32-x64-msvc@14.2.33':
|
'@next/swc-win32-x64-msvc@14.2.33':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
@@ -8135,6 +8267,10 @@ snapshots:
|
|||||||
'@opentelemetry/api': 1.9.1
|
'@opentelemetry/api': 1.9.1
|
||||||
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1)
|
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1)
|
||||||
|
|
||||||
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
|
dependencies:
|
||||||
|
'@noble/hashes': 1.8.0
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
@@ -8160,6 +8296,32 @@ snapshots:
|
|||||||
|
|
||||||
'@protobufjs/utf8@1.1.2': {}
|
'@protobufjs/utf8@1.1.2': {}
|
||||||
|
|
||||||
|
'@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':
|
'@rollup/rollup-android-arm-eabi@4.62.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -8998,6 +9160,8 @@ snapshots:
|
|||||||
|
|
||||||
cluster-key-slot@1.1.1: {}
|
cluster-key-slot@1.1.1: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
code-block-writer@13.0.3: {}
|
code-block-writer@13.0.3: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
@@ -9757,6 +9921,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-property: 1.0.2
|
is-property: 1.0.2
|
||||||
|
|
||||||
|
generic-pool@3.9.0: {}
|
||||||
|
|
||||||
get-caller-file@2.0.5: {}
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-east-asian-width@1.6.0: {}
|
get-east-asian-width@1.6.0: {}
|
||||||
@@ -10802,6 +10968,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
redis-errors: 1.2.0
|
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-metadata@0.2.2: {}
|
||||||
|
|
||||||
repeat-string@1.6.1: {}
|
repeat-string@1.6.1: {}
|
||||||
|
|||||||
@@ -1,65 +1,265 @@
|
|||||||
-- CoreEdu 服务数据库初始化脚本
|
-- CoreEdu 服务数据库初始化脚本(P3 完整版)
|
||||||
-- 表:core_edu_exams / core_edu_homework / core_edu_grades / core_edu_outbox
|
-- 共 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 (
|
CREATE TABLE IF NOT EXISTS core_edu_exams (
|
||||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
class_id CHAR(36) NOT NULL,
|
class_id CHAR(36) NOT NULL,
|
||||||
|
subject_id CHAR(36) NOT NULL,
|
||||||
title VARCHAR(200) NOT NULL,
|
title VARCHAR(200) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
exam_date DATETIME NOT NULL,
|
exam_date DATETIME NOT NULL,
|
||||||
duration VARCHAR(50) NOT NULL,
|
duration INT NOT NULL,
|
||||||
total_score VARCHAR(10) NOT NULL,
|
total_score DECIMAL(6,2) NOT NULL,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
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,
|
created_by CHAR(36) NOT NULL,
|
||||||
|
archived_at DATETIME NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
INDEX idx_exams_class_id (class_id),
|
INDEX idx_exams_class_status (class_id, status),
|
||||||
INDEX idx_exams_status (status),
|
INDEX idx_exams_school_date (school_id, exam_date),
|
||||||
INDEX idx_exams_created_by (created_by)
|
INDEX idx_exams_created_by (created_by)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS core_edu_homework (
|
||||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
class_id CHAR(36) NOT NULL,
|
class_id CHAR(36) NOT NULL,
|
||||||
|
subject_id CHAR(36) NOT NULL,
|
||||||
title VARCHAR(200) NOT NULL,
|
title VARCHAR(200) NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
due_date DATETIME NOT NULL,
|
due_date DATETIME NOT NULL,
|
||||||
|
grace_period INT NOT NULL DEFAULT 300,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'assigned',
|
status VARCHAR(20) NOT NULL DEFAULT 'assigned',
|
||||||
|
school_id CHAR(36) NOT NULL,
|
||||||
created_by CHAR(36) NOT NULL,
|
created_by CHAR(36) NOT NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
INDEX idx_homework_class_id (class_id),
|
INDEX idx_homework_class_status (class_id, status),
|
||||||
INDEX idx_homework_status (status),
|
|
||||||
INDEX idx_homework_created_by (created_by)
|
INDEX idx_homework_created_by (created_by)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS core_edu_grades (
|
||||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
student_id CHAR(36) NOT NULL,
|
student_id CHAR(36) NOT NULL,
|
||||||
exam_id CHAR(36),
|
exam_id CHAR(36),
|
||||||
homework_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,
|
feedback TEXT,
|
||||||
graded_by CHAR(36) NOT NULL,
|
graded_by CHAR(36) NOT NULL,
|
||||||
|
school_id CHAR(36) NOT NULL,
|
||||||
|
idempotency_key VARCHAR(128),
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_student_id (student_id),
|
||||||
INDEX idx_grades_exam_id (exam_id),
|
INDEX idx_grades_exam_id (exam_id),
|
||||||
INDEX idx_grades_homework_id (homework_id),
|
INDEX idx_grades_homework_id (homework_id),
|
||||||
INDEX idx_grades_graded_by (graded_by)
|
INDEX idx_grades_graded_by (graded_by)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS core_edu_outbox (
|
||||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
event_id CHAR(36) NOT NULL,
|
||||||
aggregate_id CHAR(36) NOT NULL,
|
aggregate_id CHAR(36) NOT NULL,
|
||||||
aggregate_type VARCHAR(50) NOT NULL,
|
aggregate_type VARCHAR(50) NOT NULL,
|
||||||
event_type VARCHAR(100) NOT NULL,
|
event_type VARCHAR(100) NOT NULL,
|
||||||
|
occurred_at DATETIME NOT NULL,
|
||||||
payload TEXT NOT NULL,
|
payload TEXT NOT NULL,
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
retry_count BIGINT NOT NULL DEFAULT 0,
|
retry_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
next_retry_at DATETIME NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
processed_at TIMESTAMP NULL,
|
processed_at TIMESTAMP NULL,
|
||||||
INDEX idx_outbox_status (status),
|
UNIQUE KEY uniq_event_id (event_id),
|
||||||
INDEX idx_outbox_aggregate (aggregate_type, aggregate_id),
|
INDEX idx_outbox_status_retry (status, next_retry_at),
|
||||||
INDEX idx_outbox_created_at (created_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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.53.0",
|
||||||
"zod": "^3.23.0",
|
"zod": "^3.23.0",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
|
"redis": "^4.7.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.0"
|
"rxjs": "^7.8.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,12 +3,25 @@ import { APP_GUARD } from "@nestjs/core";
|
|||||||
import { ExamsModule } from "./exams/exams.module.js";
|
import { ExamsModule } from "./exams/exams.module.js";
|
||||||
import { HomeworkModule } from "./homework/homework.module.js";
|
import { HomeworkModule } from "./homework/homework.module.js";
|
||||||
import { GradesModule } from "./grades/grades.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 { HealthModule } from "./shared/health/health.module.js";
|
||||||
import { PermissionGuard } from "./middleware/permission.guard.js";
|
import { PermissionGuard } from "./middleware/permission.guard.js";
|
||||||
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ExamsModule, HomeworkModule, GradesModule, HealthModule],
|
imports: [
|
||||||
|
ExamsModule,
|
||||||
|
HomeworkModule,
|
||||||
|
GradesModule,
|
||||||
|
AttendanceModule,
|
||||||
|
ClassesModule,
|
||||||
|
SchedulingModule,
|
||||||
|
IamConsumerModule,
|
||||||
|
HealthModule,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||||
LifecycleService,
|
LifecycleService,
|
||||||
|
|||||||
20
services/core-edu/src/attendance/attendance-status.ts
Normal file
20
services/core-edu/src/attendance/attendance-status.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
83
services/core-edu/src/attendance/attendance.controller.ts
Normal file
83
services/core-edu/src/attendance/attendance.controller.ts
Normal file
@@ -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<ReturnType<AttendanceService["getAttendance"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<AttendanceService["listByStudent"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<AttendanceService["listByClass"]>>;
|
||||||
|
}> {
|
||||||
|
const data = await this.attendanceService.listByClass(classId);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
services/core-edu/src/attendance/attendance.module.ts
Normal file
10
services/core-edu/src/attendance/attendance.module.ts
Normal file
@@ -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 {}
|
||||||
74
services/core-edu/src/attendance/attendance.repository.ts
Normal file
74
services/core-edu/src/attendance/attendance.repository.ts
Normal file
@@ -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<Attendance | undefined> {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(attendance)
|
||||||
|
.where(eq(attendance.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByScheduleAndStudent(
|
||||||
|
scheduleId: string,
|
||||||
|
studentId: string,
|
||||||
|
): Promise<Attendance | undefined> {
|
||||||
|
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<Attendance[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(attendance)
|
||||||
|
.where(eq(attendance.studentId, studentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByScheduleId(scheduleId: string): Promise<Attendance[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(attendance)
|
||||||
|
.where(eq(attendance.scheduleId, scheduleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByClassId(classId: string): Promise<Attendance[]> {
|
||||||
|
// 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<void> {
|
||||||
|
await tx.insert(attendance).values(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<NewAttendance>): Promise<void> {
|
||||||
|
await db.update(attendance).set(data).where(eq(attendance.id, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const attendanceRepository = new AttendanceRepository();
|
||||||
40
services/core-edu/src/attendance/attendance.schema.ts
Normal file
40
services/core-edu/src/attendance/attendance.schema.ts
Normal file
@@ -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;
|
||||||
116
services/core-edu/src/attendance/attendance.service.ts
Normal file
116
services/core-edu/src/attendance/attendance.service.ts
Normal file
@@ -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<Attendance> {
|
||||||
|
const record = await attendanceRepository.findById(id);
|
||||||
|
if (!record) {
|
||||||
|
throw new NotFoundError(`Attendance ${id} not found`);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByStudent(studentId: string): Promise<Attendance[]> {
|
||||||
|
return attendanceRepository.findByStudentId(studentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByClass(classId: string): Promise<Attendance[]> {
|
||||||
|
return attendanceRepository.findByClassId(classId);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
services/core-edu/src/classes/classes.controller.ts
Normal file
64
services/core-edu/src/classes/classes.controller.ts
Normal file
@@ -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<ReturnType<ClassesService["getClass"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<ClassesService["getClassesByTeacher"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<ClassesService["batchGetClasses"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<ClassesService["listStudentsByClass"]>>;
|
||||||
|
}> {
|
||||||
|
const data = await this.classesService.listStudentsByClass(classId);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
@Module({
|
||||||
controllers: [],
|
controllers: [ClassesController],
|
||||||
providers: [],
|
providers: [ClassesService],
|
||||||
exports: [],
|
exports: [ClassesService],
|
||||||
})
|
})
|
||||||
export class ClassesModule {}
|
export class ClassesModule {}
|
||||||
|
|||||||
47
services/core-edu/src/classes/classes.repository.ts
Normal file
47
services/core-edu/src/classes/classes.repository.ts
Normal file
@@ -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<Class | undefined> {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(classes)
|
||||||
|
.where(eq(classes.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIds(ids: string[]): Promise<Class[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
return db.select().from(classes).where(inArray(classes.id, ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByTeacherId(teacherId: string): Promise<Class[]> {
|
||||||
|
// 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<void> {
|
||||||
|
await db.insert(classes).values(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<Class>): Promise<void> {
|
||||||
|
await db.update(classes).set(data).where(eq(classes.id, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const classesRepository = new ClassesRepository();
|
||||||
31
services/core-edu/src/classes/classes.schema.ts
Normal file
31
services/core-edu/src/classes/classes.schema.ts
Normal file
@@ -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;
|
||||||
39
services/core-edu/src/classes/classes.service.ts
Normal file
39
services/core-edu/src/classes/classes.service.ts
Normal file
@@ -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<Class> {
|
||||||
|
const cls = await classesRepository.findById(id);
|
||||||
|
if (!cls) {
|
||||||
|
throw new NotFoundError(`Class ${id} not found`);
|
||||||
|
}
|
||||||
|
return cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClassesByTeacher(teacherId: string): Promise<Class[]> {
|
||||||
|
return classesRepository.findByTeacherId(teacherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchGetClasses(ids: string[]): Promise<Class[]> {
|
||||||
|
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<Array<{ id: string; name: string; classId: string }>> {
|
||||||
|
// 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
services/core-edu/src/classes/teacher-associations.schema.ts
Normal file
35
services/core-edu/src/classes/teacher-associations.schema.ts
Normal file
@@ -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;
|
||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
PORT: z.string().default("3004"),
|
PORT: z.string().default("3004"),
|
||||||
|
GRPC_PORT: z.string().default("50053"),
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
REDIS_URL: z.string().url().optional(),
|
REDIS_URL: z.string().url().optional(),
|
||||||
JWT_SECRET: z.string().optional(),
|
JWT_SECRET: z.string().optional(),
|
||||||
@@ -23,7 +24,7 @@ export function loadEnv(): Env {
|
|||||||
const result = envSchema.safeParse(process.env);
|
const result = envSchema.safeParse(process.env);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error(
|
console.error(
|
||||||
"❌ Invalid environment variables:",
|
"Invalid environment variables:",
|
||||||
result.error.flatten().fieldErrors,
|
result.error.flatten().fieldErrors,
|
||||||
);
|
);
|
||||||
throw new Error("Invalid environment configuration");
|
throw new Error("Invalid environment configuration");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Kafka } from "kafkajs";
|
import { Kafka } from "kafkajs";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
|
import { logger } from "../shared/observability/logger.js";
|
||||||
|
|
||||||
export const kafka = new Kafka({
|
export const kafka = new Kafka({
|
||||||
brokers: env.KAFKA_BROKERS.split(","),
|
brokers: env.KAFKA_BROKERS.split(","),
|
||||||
@@ -13,15 +14,35 @@ export const producer = kafka.producer({
|
|||||||
|
|
||||||
export const consumer = kafka.consumer({ groupId: "core-edu-group" });
|
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<void> {
|
export async function connectKafka(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await producer.connect();
|
await producer.connect();
|
||||||
await consumer.connect();
|
await consumer.connect();
|
||||||
console.log("Kafka connected");
|
logger.info("Kafka connected");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
logger.warn(
|
||||||
"Kafka connect failed, running without Kafka:",
|
{ err: err instanceof Error ? err.message : String(err) },
|
||||||
err instanceof Error ? err.message : String(err),
|
"Kafka connect failed, running without Kafka",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
services/core-edu/src/config/redis.ts
Normal file
97
services/core-edu/src/config/redis.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (client && client.isOpen) {
|
||||||
|
await client.quit();
|
||||||
|
logger.info("Redis disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 健康检查(用于 /readyz)。
|
||||||
|
*/
|
||||||
|
export async function isRedisHealthy(): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
const c = getRedisClient();
|
||||||
|
if (!c || !c.isOpen) return;
|
||||||
|
try {
|
||||||
|
await c.del(key);
|
||||||
|
} catch {
|
||||||
|
// 释放失败不影响业务(锁会自动过期)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
services/core-edu/src/exams/exam-state-machine.ts
Normal file
60
services/core-edu/src/exams/exam-state-machine.ts
Normal file
@@ -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<Record<ExamAction, ExamStatus>>
|
||||||
|
> = {
|
||||||
|
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;
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Put,
|
Put,
|
||||||
Req,
|
Req,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
ExamsService,
|
ExamsService,
|
||||||
type CreateExamInput,
|
type CreateExamInput,
|
||||||
@@ -18,26 +19,75 @@ import {
|
|||||||
RequirePermission,
|
RequirePermission,
|
||||||
} from "../middleware/permission.guard.js";
|
} from "../middleware/permission.guard.js";
|
||||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.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 {
|
export class ExamsController {
|
||||||
constructor(private readonly examsService: ExamsService) {}
|
constructor(private readonly examsService: ExamsService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission(Permissions.EXAM_CREATE)
|
@RequirePermission(Permissions.EXAM_CREATE)
|
||||||
async create(
|
async create(
|
||||||
@Body() body: CreateExamInput,
|
@Body() body: unknown,
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
): Promise<{ success: true; data: { id: string } }> {
|
): Promise<{ success: true; data: { id: string } }> {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new UnauthorizedError("Missing x-user-id header");
|
throw new UnauthorizedError("Missing x-user-id header");
|
||||||
}
|
}
|
||||||
const result = await this.examsService.createExam({
|
const parsed = createExamSchema.safeParse(body);
|
||||||
...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,
|
createdBy: userId,
|
||||||
});
|
};
|
||||||
|
const result = await this.examsService.createExam(input, userId);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +115,22 @@ export class ExamsController {
|
|||||||
@RequirePermission(Permissions.EXAM_UPDATE)
|
@RequirePermission(Permissions.EXAM_UPDATE)
|
||||||
async update(
|
async update(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Body() body: UpdateExamInput,
|
@Body() body: unknown,
|
||||||
): Promise<{ success: true; data: { success: true } }> {
|
): 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 } };
|
return { success: true, data: { success: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,4 +142,74 @@ export class ExamsController {
|
|||||||
await this.examsService.deleteExam(id);
|
await this.examsService.deleteExam(id);
|
||||||
return { success: true, data: { success: true } };
|
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 } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { db } from "../config/database.js";
|
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 {
|
export class ExamsRepository {
|
||||||
async findById(id: string): Promise<Exam | undefined> {
|
async findById(id: string): Promise<Exam | undefined> {
|
||||||
@@ -23,6 +33,63 @@ export class ExamsRepository {
|
|||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
await db.delete(exams).where(eq(exams.id, id));
|
await db.delete(exams).where(eq(exams.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exam questions
|
||||||
|
async addQuestions(questions: NewExamQuestion[]): Promise<void> {
|
||||||
|
if (questions.length === 0) return;
|
||||||
|
await db.insert(examQuestions).values(questions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listQuestions(examId: string): Promise<ExamQuestion[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(examQuestions)
|
||||||
|
.where(eq(examQuestions.examId, examId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exam submissions
|
||||||
|
async findSubmission(
|
||||||
|
examId: string,
|
||||||
|
studentId: string,
|
||||||
|
): Promise<ExamSubmission | undefined> {
|
||||||
|
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<ExamSubmission | undefined> {
|
||||||
|
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<void> {
|
||||||
|
await tx.insert(examSubmissions).values(submission);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubmission(
|
||||||
|
id: string,
|
||||||
|
data: Partial<NewExamSubmission>,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(examSubmissions)
|
||||||
|
.set(data)
|
||||||
|
.where(eq(examSubmissions.id, id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const examsRepository = new ExamsRepository();
|
export const examsRepository = new ExamsRepository();
|
||||||
|
|||||||
@@ -5,21 +5,102 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
char,
|
char,
|
||||||
datetime,
|
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(),
|
export const exams = mysqlTable(
|
||||||
classId: char('class_id', { length: 36 }).notNull(),
|
"core_edu_exams",
|
||||||
title: varchar('title', { length: 200 }).notNull(),
|
{
|
||||||
description: text('description'),
|
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||||
examDate: datetime('exam_date').notNull(),
|
classId: char("class_id", { length: 36 }).notNull(),
|
||||||
duration: varchar('duration', { length: 50 }).notNull(),
|
subjectId: char("subject_id", { length: 36 }).notNull(),
|
||||||
totalScore: varchar('total_score', { length: 10 }).notNull(),
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
description: text("description"),
|
||||||
createdBy: char('created_by', { length: 36 }).notNull(),
|
examDate: datetime("exam_date").notNull(),
|
||||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
duration: int("duration").notNull(), // 秒
|
||||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
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 Exam = typeof exams.$inferSelect;
|
||||||
export type NewExam = typeof exams.$inferInsert;
|
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;
|
||||||
|
|||||||
@@ -5,19 +5,31 @@ import { db } from "../config/database.js";
|
|||||||
import { exams } from "./exams.schema.js";
|
import { exams } from "./exams.schema.js";
|
||||||
import { examsRepository } from "./exams.repository.js";
|
import { examsRepository } from "./exams.repository.js";
|
||||||
import { outboxRepository } from "../shared/outbox/outbox.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 type { Exam, NewExam } from "./exams.schema.js";
|
||||||
import {
|
import {
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
|
ConflictError,
|
||||||
|
ApplicationError,
|
||||||
|
CoreEduErrorCode,
|
||||||
} from "../shared/errors/application-error.js";
|
} from "../shared/errors/application-error.js";
|
||||||
|
|
||||||
export interface CreateExamInput {
|
export interface CreateExamInput {
|
||||||
classId: string;
|
classId: string;
|
||||||
|
subjectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
examDate: Date | string;
|
examDate: Date | string;
|
||||||
duration: string;
|
duration: number;
|
||||||
totalScore: string;
|
totalScore: string;
|
||||||
|
schoolId: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,26 +37,45 @@ export interface UpdateExamInput {
|
|||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
examDate?: Date;
|
examDate?: Date;
|
||||||
duration?: string;
|
duration?: number;
|
||||||
totalScore?: string;
|
totalScore?: string;
|
||||||
status?: string;
|
}
|
||||||
|
|
||||||
|
export interface AnswerInput {
|
||||||
|
questionId: string;
|
||||||
|
answer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreInput {
|
||||||
|
questionId: string;
|
||||||
|
score: string;
|
||||||
|
teacherComment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExamsService {
|
export class ExamsService {
|
||||||
async createExam(input: CreateExamInput): Promise<{ id: string }> {
|
async createExam(
|
||||||
if (!input.classId || !input.title || !input.createdBy) {
|
input: CreateExamInput,
|
||||||
throw new ValidationError("classId, title, createdBy are required");
|
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 id = randomUUID();
|
||||||
const exam: NewExam = {
|
const exam: NewExam = {
|
||||||
id,
|
id,
|
||||||
classId: input.classId,
|
classId: input.classId,
|
||||||
|
subjectId: input.subjectId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
// Drizzle datetime 列需要 Date 对象(调用 toISOString)。HTTP 请求体里的
|
|
||||||
// examDate 是 ISO 字符串,这里统一转成 Date,避免 "toISOString is not a function"。
|
|
||||||
examDate:
|
examDate:
|
||||||
input.examDate instanceof Date
|
input.examDate instanceof Date
|
||||||
? input.examDate
|
? input.examDate
|
||||||
@@ -52,22 +83,34 @@ export class ExamsService {
|
|||||||
duration: input.duration,
|
duration: input.duration,
|
||||||
totalScore: input.totalScore,
|
totalScore: input.totalScore,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
|
statusChangedAt: new Date(),
|
||||||
|
schoolId: input.schoolId,
|
||||||
createdBy: input.createdBy,
|
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 db.transaction(async (tx) => {
|
||||||
await tx.insert(exams).values(exam);
|
await tx.insert(exams).values(exam);
|
||||||
await outboxRepository.create(
|
await outboxRepository.create(
|
||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
eventId: event.event_id,
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: "exam",
|
aggregateType: "exam",
|
||||||
eventType: "exam.created",
|
eventType: "exam.created",
|
||||||
payload: JSON.stringify({
|
occurredAt: new Date(event.occurred_at),
|
||||||
id,
|
payload: serializeEvent(event),
|
||||||
classId: input.classId,
|
|
||||||
title: input.title,
|
|
||||||
}),
|
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
@@ -94,16 +137,28 @@ export class ExamsService {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundError(`Exam ${id} not found`);
|
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 db.transaction(async (tx) => {
|
||||||
await tx.update(exams).set(data).where(eq(exams.id, id));
|
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(
|
await outboxRepository.create(
|
||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
eventId: event.event_id,
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: "exam",
|
aggregateType: "exam",
|
||||||
eventType: "exam.updated",
|
eventType: "exam.updated",
|
||||||
payload: JSON.stringify({ id, changes: data }),
|
occurredAt: new Date(event.occurred_at),
|
||||||
|
payload: serializeEvent(event),
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
@@ -119,17 +174,231 @@ export class ExamsService {
|
|||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.delete(exams).where(eq(exams.id, id));
|
await tx.delete(exams).where(eq(exams.id, id));
|
||||||
|
const event = buildEvent({
|
||||||
|
aggregateId: id,
|
||||||
|
eventType: "exam.deleted",
|
||||||
|
payload: { examId: id },
|
||||||
|
});
|
||||||
await outboxRepository.create(
|
await outboxRepository.create(
|
||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
eventId: event.event_id,
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: "exam",
|
aggregateType: "exam",
|
||||||
eventType: "exam.deleted",
|
eventType: "exam.deleted",
|
||||||
payload: JSON.stringify({ id }),
|
occurredAt: new Date(event.occurred_at),
|
||||||
|
payload: serializeEvent(event),
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async publishExam(id: string, publishedBy: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
96
services/core-edu/src/grades/grade-calculator.ts
Normal file
96
services/core-edu/src/grades/grade-calculator.ts
Normal file
@@ -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<string, number> | 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<GradeScope, number> = {
|
||||||
|
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)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +1,63 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
|
import { Body, Controller, Get, Param, Post, Put, Req } from "@nestjs/common";
|
||||||
import { GradesService, type RecordGradeInput } from "./grades.service.js";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
GradesService,
|
||||||
|
type RecordGradeInput,
|
||||||
|
type UpdateGradeInput,
|
||||||
|
} from "./grades.service.js";
|
||||||
import {
|
import {
|
||||||
Permissions,
|
Permissions,
|
||||||
RequirePermission,
|
RequirePermission,
|
||||||
} from "../middleware/permission.guard.js";
|
} from "../middleware/permission.guard.js";
|
||||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.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 {
|
export class GradesController {
|
||||||
constructor(private readonly gradesService: GradesService) {}
|
constructor(private readonly gradesService: GradesService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission(Permissions.GRADE_CREATE)
|
@RequirePermission(Permissions.GRADE_CREATE)
|
||||||
async record(
|
async record(
|
||||||
@Body() body: RecordGradeInput,
|
@Body() body: unknown,
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
): Promise<{ success: true; data: { id: string } }> {
|
): Promise<{ success: true; data: { id: string } }> {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new UnauthorizedError("Missing x-user-id header");
|
throw new UnauthorizedError("Missing x-user-id header");
|
||||||
}
|
}
|
||||||
const result = await this.gradesService.recordGrade({
|
const parsed = recordGradeSchema.safeParse(body);
|
||||||
...body,
|
if (!parsed.success) {
|
||||||
|
throw new ValidationError("Invalid grade input", parsed.error.flatten());
|
||||||
|
}
|
||||||
|
const input: RecordGradeInput = {
|
||||||
|
...parsed.data,
|
||||||
gradedBy: userId,
|
gradedBy: userId,
|
||||||
});
|
};
|
||||||
|
const result = await this.gradesService.recordGrade(input, userId);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,4 +100,27 @@ export class GradesController {
|
|||||||
const data = await this.gradesService.listByHomework(homeworkId);
|
const data = await this.gradesService.listByHomework(homeworkId);
|
||||||
return { success: true, data };
|
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 } };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
services/core-edu/src/grades/grades.repository.ts
Normal file
64
services/core-edu/src/grades/grades.repository.ts
Normal file
@@ -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<Grade | undefined> {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(grades)
|
||||||
|
.where(eq(grades.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByStudentId(studentId: string): Promise<Grade[]> {
|
||||||
|
return db.select().from(grades).where(eq(grades.studentId, studentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByExamId(examId: string): Promise<Grade[]> {
|
||||||
|
return db.select().from(grades).where(eq(grades.examId, examId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByHomeworkId(homeworkId: string): Promise<Grade[]> {
|
||||||
|
return db.select().from(grades).where(eq(grades.homeworkId, homeworkId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIdempotencyKey(
|
||||||
|
idempotencyKey: string,
|
||||||
|
): Promise<Grade | undefined> {
|
||||||
|
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<void> {
|
||||||
|
await tx.insert(grades).values(grade);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<NewGrade>): Promise<void> {
|
||||||
|
await db.update(grades).set(data).where(eq(grades.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grade formulas
|
||||||
|
async findFormulasByScope(
|
||||||
|
scope: string,
|
||||||
|
_scopeId: string,
|
||||||
|
): Promise<GradeFormula[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(gradeFormulas)
|
||||||
|
.where(eq(gradeFormulas.scope, scope));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gradesRepository = new GradesRepository();
|
||||||
@@ -4,19 +4,74 @@ import {
|
|||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
char,
|
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(),
|
export const grades = mysqlTable(
|
||||||
studentId: char('student_id', { length: 36 }).notNull(),
|
"core_edu_grades",
|
||||||
examId: char('exam_id', { length: 36 }),
|
{
|
||||||
homeworkId: char('homework_id', { length: 36 }),
|
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||||
score: varchar('score', { length: 10 }).notNull(),
|
studentId: char("student_id", { length: 36 }).notNull(),
|
||||||
feedback: text('feedback'),
|
examId: char("exam_id", { length: 36 }),
|
||||||
gradedBy: char('graded_by', { length: 36 }).notNull(),
|
homeworkId: char("homework_id", { length: 36 }),
|
||||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
score: decimal("score", { precision: 6, scale: 2 }).notNull(),
|
||||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
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 Grade = typeof grades.$inferSelect;
|
||||||
export type NewGrade = typeof grades.$inferInsert;
|
export type NewGrade = typeof grades.$inferInsert;
|
||||||
|
export type GradeFormula = typeof gradeFormulas.$inferSelect;
|
||||||
|
export type NewGradeFormula = typeof gradeFormulas.$inferInsert;
|
||||||
|
|||||||
@@ -1,34 +1,62 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { db } from "../config/database.js";
|
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 { 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 {
|
import {
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
} from "../shared/errors/application-error.js";
|
} from "../shared/errors/application-error.js";
|
||||||
|
import type { Grade, NewGrade } from "./grades.schema.js";
|
||||||
|
|
||||||
export interface RecordGradeInput {
|
export interface RecordGradeInput {
|
||||||
studentId: string;
|
studentId: string;
|
||||||
examId?: string;
|
examId?: string;
|
||||||
homeworkId?: string;
|
homeworkId?: string;
|
||||||
score: string;
|
score: string;
|
||||||
|
totalScore: string;
|
||||||
feedback?: string;
|
feedback?: string;
|
||||||
gradedBy: string;
|
gradedBy: string;
|
||||||
|
schoolId: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGradeInput {
|
||||||
|
score?: string;
|
||||||
|
feedback?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GradesService {
|
export class GradesService {
|
||||||
async recordGrade(input: RecordGradeInput): Promise<{ id: string }> {
|
async recordGrade(
|
||||||
if (!input.studentId || !input.score || !input.gradedBy) {
|
input: RecordGradeInput,
|
||||||
throw new ValidationError("studentId, score, gradedBy are required");
|
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) {
|
if (!input.examId && !input.homeworkId) {
|
||||||
throw new ValidationError("Either examId or homeworkId must be provided");
|
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 id = randomUUID();
|
||||||
const record: NewGrade = {
|
const record: NewGrade = {
|
||||||
id,
|
id,
|
||||||
@@ -36,25 +64,38 @@ export class GradesService {
|
|||||||
examId: input.examId,
|
examId: input.examId,
|
||||||
homeworkId: input.homeworkId,
|
homeworkId: input.homeworkId,
|
||||||
score: input.score,
|
score: input.score,
|
||||||
|
totalScore: input.totalScore,
|
||||||
feedback: input.feedback,
|
feedback: input.feedback,
|
||||||
gradedBy: input.gradedBy,
|
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 db.transaction(async (tx) => {
|
||||||
await tx.insert(grades).values(record);
|
await gradesRepository.create(record, tx);
|
||||||
await outboxRepository.create(
|
await outboxRepository.create(
|
||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
eventId: event.event_id,
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: "grade",
|
aggregateType: "grade",
|
||||||
eventType: "grade.recorded",
|
eventType: "grade.recorded",
|
||||||
payload: JSON.stringify({
|
occurredAt: new Date(event.occurred_at),
|
||||||
id,
|
payload: serializeEvent(event),
|
||||||
studentId: input.studentId,
|
|
||||||
score: input.score,
|
|
||||||
examId: input.examId,
|
|
||||||
homeworkId: input.homeworkId,
|
|
||||||
}),
|
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
@@ -65,11 +106,7 @@ export class GradesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getGrade(id: string): Promise<Grade> {
|
async getGrade(id: string): Promise<Grade> {
|
||||||
const [record] = await db
|
const record = await gradesRepository.findById(id);
|
||||||
.select()
|
|
||||||
.from(grades)
|
|
||||||
.where(eq(grades.id, id))
|
|
||||||
.limit(1);
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
throw new NotFoundError(`Grade ${id} not found`);
|
throw new NotFoundError(`Grade ${id} not found`);
|
||||||
}
|
}
|
||||||
@@ -77,14 +114,53 @@ export class GradesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listByStudent(studentId: string): Promise<Grade[]> {
|
async listByStudent(studentId: string): Promise<Grade[]> {
|
||||||
return db.select().from(grades).where(eq(grades.studentId, studentId));
|
return gradesRepository.findByStudentId(studentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listByExam(examId: string): Promise<Grade[]> {
|
async listByExam(examId: string): Promise<Grade[]> {
|
||||||
return db.select().from(grades).where(eq(grades.examId, examId));
|
return gradesRepository.findByExamId(examId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listByHomework(homeworkId: string): Promise<Grade[]> {
|
async listByHomework(homeworkId: string): Promise<Grade[]> {
|
||||||
return db.select().from(grades).where(eq(grades.homeworkId, homeworkId));
|
return gradesRepository.findByHomeworkId(homeworkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateGrade(
|
||||||
|
id: string,
|
||||||
|
data: UpdateGradeInput,
|
||||||
|
updatedBy: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
91
services/core-edu/src/homework/homework-state-machine.ts
Normal file
91
services/core-edu/src/homework/homework-state-machine.ts
Normal file
@@ -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<Record<HomeworkAction, HomeworkStatus>>
|
||||||
|
> = {
|
||||||
|
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<Record<SubmissionAction, SubmissionStatus>>
|
||||||
|
> = {
|
||||||
|
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;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
|
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
|
||||||
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
HomeworkService,
|
HomeworkService,
|
||||||
type AssignHomeworkInput,
|
type AssignHomeworkInput,
|
||||||
@@ -8,26 +9,70 @@ import {
|
|||||||
RequirePermission,
|
RequirePermission,
|
||||||
} from "../middleware/permission.guard.js";
|
} from "../middleware/permission.guard.js";
|
||||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.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 {
|
export class HomeworkController {
|
||||||
constructor(private readonly homeworkService: HomeworkService) {}
|
constructor(private readonly homeworkService: HomeworkService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission(Permissions.HOMEWORK_CREATE)
|
@RequirePermission(Permissions.HOMEWORK_CREATE)
|
||||||
async assign(
|
async assign(
|
||||||
@Body() body: AssignHomeworkInput,
|
@Body() body: unknown,
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
): Promise<{ success: true; data: { id: string } }> {
|
): Promise<{ success: true; data: { id: string } }> {
|
||||||
const userId = req.userId;
|
const userId = req.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new UnauthorizedError("Missing x-user-id header");
|
throw new UnauthorizedError("Missing x-user-id header");
|
||||||
}
|
}
|
||||||
const result = await this.homeworkService.assignHomework({
|
const parsed = assignHomeworkSchema.safeParse(body);
|
||||||
...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,
|
createdBy: userId,
|
||||||
});
|
};
|
||||||
|
const result = await this.homeworkService.assignHomework(input, userId);
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +100,42 @@ export class HomeworkController {
|
|||||||
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
|
@RequirePermission(Permissions.HOMEWORK_SUBMIT)
|
||||||
async submit(
|
async submit(
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
): Promise<{ success: true; data: { success: true } }> {
|
@Body() body: unknown,
|
||||||
await this.homeworkService.submitHomework(id);
|
): Promise<{ success: true; data: { submissionId: string } }> {
|
||||||
return { success: true, data: { success: true } };
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
services/core-edu/src/homework/homework.repository.ts
Normal file
100
services/core-edu/src/homework/homework.repository.ts
Normal file
@@ -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<Homework | undefined> {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(homework)
|
||||||
|
.where(eq(homework.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByClassId(classId: string): Promise<Homework[]> {
|
||||||
|
return db.select().from(homework).where(eq(homework.classId, classId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<NewHomework>): Promise<void> {
|
||||||
|
await db.update(homework).set(data).where(eq(homework.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await db.delete(homework).where(eq(homework.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submissions
|
||||||
|
async findSubmission(
|
||||||
|
homeworkId: string,
|
||||||
|
studentId: string,
|
||||||
|
): Promise<HomeworkSubmission | undefined> {
|
||||||
|
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<HomeworkSubmission | undefined> {
|
||||||
|
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<void> {
|
||||||
|
await tx.insert(homeworkSubmissions).values(submission);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubmission(
|
||||||
|
id: string,
|
||||||
|
data: Partial<NewHomeworkSubmission>,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(homeworkSubmissions)
|
||||||
|
.set(data)
|
||||||
|
.where(eq(homeworkSubmissions.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answers
|
||||||
|
async createAnswers(
|
||||||
|
answers: NewHomeworkAnswer[],
|
||||||
|
tx: typeof db = db,
|
||||||
|
): Promise<void> {
|
||||||
|
if (answers.length === 0) return;
|
||||||
|
await tx.insert(homeworkAnswers).values(answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAnswers(submissionId: string): Promise<HomeworkAnswer[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(homeworkAnswers)
|
||||||
|
.where(eq(homeworkAnswers.submissionId, submissionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const homeworkRepository = new HomeworkRepository();
|
||||||
@@ -5,19 +5,98 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
char,
|
char,
|
||||||
datetime,
|
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(),
|
export const homework = mysqlTable(
|
||||||
classId: char('class_id', { length: 36 }).notNull(),
|
"core_edu_homework",
|
||||||
title: varchar('title', { length: 200 }).notNull(),
|
{
|
||||||
description: text('description'),
|
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||||
dueDate: datetime('due_date').notNull(),
|
classId: char("class_id", { length: 36 }).notNull(),
|
||||||
status: varchar('status', { length: 20 }).notNull().default('assigned'),
|
subjectId: char("subject_id", { length: 36 }).notNull(),
|
||||||
createdBy: char('created_by', { length: 36 }).notNull(),
|
title: varchar("title", { length: 200 }).notNull(),
|
||||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
description: text("description"),
|
||||||
updatedAt: timestamp('updated_at').notNull().defaultNow().onUpdateNow(),
|
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 Homework = typeof homework.$inferSelect;
|
||||||
export type NewHomework = typeof homework.$inferInsert;
|
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;
|
||||||
|
|||||||
@@ -1,56 +1,97 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { db } from "../config/database.js";
|
import { db } from "../config/database.js";
|
||||||
import { homework } from "./homework.schema.js";
|
import { homework } from "./homework.schema.js";
|
||||||
|
import { homeworkRepository } from "./homework.repository.js";
|
||||||
import { outboxRepository } from "../shared/outbox/outbox.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 {
|
import {
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
|
ConflictError,
|
||||||
} from "../shared/errors/application-error.js";
|
} from "../shared/errors/application-error.js";
|
||||||
|
import type { Homework, NewHomework } from "./homework.schema.js";
|
||||||
|
|
||||||
export interface AssignHomeworkInput {
|
export interface AssignHomeworkInput {
|
||||||
classId: string;
|
classId: string;
|
||||||
|
subjectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
dueDate: Date | string;
|
dueDate: Date | string;
|
||||||
|
gracePeriod?: number;
|
||||||
|
schoolId: string;
|
||||||
createdBy: 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()
|
@Injectable()
|
||||||
export class HomeworkService {
|
export class HomeworkService {
|
||||||
async assignHomework(input: AssignHomeworkInput): Promise<{ id: string }> {
|
async assignHomework(
|
||||||
if (!input.classId || !input.title || !input.createdBy) {
|
input: AssignHomeworkInput,
|
||||||
throw new ValidationError("classId, title, createdBy are required");
|
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 id = randomUUID();
|
||||||
const record: NewHomework = {
|
const record: NewHomework = {
|
||||||
id,
|
id,
|
||||||
classId: input.classId,
|
classId: input.classId,
|
||||||
|
subjectId: input.subjectId,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
// Drizzle datetime 列需要 Date 对象;HTTP 请求体里 dueDate 是 ISO 字符串。
|
|
||||||
dueDate:
|
dueDate:
|
||||||
input.dueDate instanceof Date ? input.dueDate : new Date(input.dueDate),
|
input.dueDate instanceof Date ? input.dueDate : new Date(input.dueDate),
|
||||||
|
gracePeriod: input.gracePeriod ?? 300,
|
||||||
status: "assigned",
|
status: "assigned",
|
||||||
|
schoolId: input.schoolId,
|
||||||
createdBy: input.createdBy,
|
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 db.transaction(async (tx) => {
|
||||||
await tx.insert(homework).values(record);
|
await tx.insert(homework).values(record);
|
||||||
await outboxRepository.create(
|
await outboxRepository.create(
|
||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
|
eventId: event.event_id,
|
||||||
aggregateId: id,
|
aggregateId: id,
|
||||||
aggregateType: "homework",
|
aggregateType: "homework",
|
||||||
eventType: "homework.assigned",
|
eventType: "homework.assigned",
|
||||||
payload: JSON.stringify({
|
occurredAt: new Date(event.occurred_at),
|
||||||
id,
|
payload: serializeEvent(event),
|
||||||
classId: input.classId,
|
|
||||||
title: input.title,
|
|
||||||
}),
|
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
@@ -61,11 +102,7 @@ export class HomeworkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getHomework(id: string): Promise<Homework> {
|
async getHomework(id: string): Promise<Homework> {
|
||||||
const [record] = await db
|
const record = await homeworkRepository.findById(id);
|
||||||
.select()
|
|
||||||
.from(homework)
|
|
||||||
.where(eq(homework.id, id))
|
|
||||||
.limit(1);
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
throw new NotFoundError(`Homework ${id} not found`);
|
throw new NotFoundError(`Homework ${id} not found`);
|
||||||
}
|
}
|
||||||
@@ -73,31 +110,178 @@ export class HomeworkService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listByClass(classId: string): Promise<Homework[]> {
|
async listByClass(classId: string): Promise<Homework[]> {
|
||||||
return db.select().from(homework).where(eq(homework.classId, classId));
|
return homeworkRepository.findByClassId(classId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitHomework(id: string): Promise<void> {
|
async submitHomework(
|
||||||
const existing = await this.getHomework(id);
|
homeworkId: string,
|
||||||
if (existing.status === "submitted") {
|
studentId: string,
|
||||||
throw new ValidationError(`Homework ${id} already submitted`);
|
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 db.transaction(async (tx) => {
|
||||||
await tx
|
await homeworkRepository.updateSubmission(submissionId, {
|
||||||
.update(homework)
|
status: "graded",
|
||||||
.set({ status: "submitted" })
|
gradedAt: new Date(),
|
||||||
.where(eq(homework.id, id));
|
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(
|
await outboxRepository.create(
|
||||||
{
|
{
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
aggregateId: id,
|
eventId: event.event_id,
|
||||||
|
aggregateId: homeworkId,
|
||||||
aggregateType: "homework",
|
aggregateType: "homework",
|
||||||
eventType: "homework.submitted",
|
eventType: "homework.graded",
|
||||||
payload: JSON.stringify({ id }),
|
occurredAt: new Date(event.occurred_at),
|
||||||
|
payload: serializeEvent(event),
|
||||||
status: "pending",
|
status: "pending",
|
||||||
},
|
},
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { totalScore: totalScoreStr };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { IamConsumerService } from "./iam-consumer.service.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [IamConsumerService],
|
||||||
|
exports: [IamConsumerService],
|
||||||
|
})
|
||||||
|
export class IamConsumerModule {}
|
||||||
187
services/core-edu/src/iam-consumer/iam-consumer.service.ts
Normal file
187
services/core-edu/src/iam-consumer/iam-consumer.service.ts
Normal file
@@ -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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NestFactory } from "@nestjs/core";
|
|||||||
import { AppModule } from "./app.module.js";
|
import { AppModule } from "./app.module.js";
|
||||||
import { env } from "./config/env.js";
|
import { env } from "./config/env.js";
|
||||||
import { connectKafka, disconnectKafka } from "./config/kafka.js";
|
import { connectKafka, disconnectKafka } from "./config/kafka.js";
|
||||||
|
import { connectRedis, disconnectRedis } from "./config/redis.js";
|
||||||
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
|
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
|
||||||
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
|
||||||
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
|
||||||
@@ -27,33 +28,33 @@ async function bootstrap(): Promise<void> {
|
|||||||
// publisher will retry sends and messages stay pending until Kafka recovers.
|
// publisher will retry sends and messages stay pending until Kafka recovers.
|
||||||
void connectKafka();
|
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
|
// Start the transactional outbox publisher - polls pending messages
|
||||||
// and publishes them to Kafka topics defined in TOPIC_MAP.
|
// and publishes them to Kafka topics defined in TOPIC_MAP.
|
||||||
await outboxPublisher.start();
|
await outboxPublisher.start();
|
||||||
|
|
||||||
await app.listen(env.PORT);
|
await app.listen(env.PORT);
|
||||||
logger.info(
|
logger.info(
|
||||||
{ port: env.PORT, service: "core-edu" },
|
{ port: env.PORT, grpcPort: env.GRPC_PORT, service: "core-edu" },
|
||||||
"CoreEdu service is listening",
|
"CoreEdu service is listening (HTTP; gRPC port reserved for P3)",
|
||||||
);
|
);
|
||||||
|
|
||||||
process.on("SIGTERM", async () => {
|
const shutdown = async (signal: string): Promise<void> => {
|
||||||
logger.info("SIGTERM received, shutting down gracefully...");
|
logger.info({ signal }, "Shutting down gracefully...");
|
||||||
await outboxPublisher.stop();
|
await outboxPublisher.stop();
|
||||||
|
await disconnectRedis();
|
||||||
await disconnectKafka();
|
await disconnectKafka();
|
||||||
await shutdownTracer();
|
await shutdownTracer();
|
||||||
await app.close();
|
await app.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
};
|
||||||
|
|
||||||
process.on("SIGINT", async () => {
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
logger.info("SIGINT received, shutting down gracefully...");
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||||
await outboxPublisher.stop();
|
|
||||||
await disconnectKafka();
|
|
||||||
await shutdownTracer();
|
|
||||||
await app.close();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
@@ -9,16 +9,35 @@ import { PermissionDeniedError } from "../shared/errors/application-error.js";
|
|||||||
import type { AuthenticatedRequest } from "./auth.middleware.js";
|
import type { AuthenticatedRequest } from "./auth.middleware.js";
|
||||||
|
|
||||||
export const Permissions = {
|
export const Permissions = {
|
||||||
|
// Exam permissions
|
||||||
EXAM_CREATE: "CORE_EDU_EXAM_CREATE" as const,
|
EXAM_CREATE: "CORE_EDU_EXAM_CREATE" as const,
|
||||||
EXAM_READ: "CORE_EDU_EXAM_READ" as const,
|
EXAM_READ: "CORE_EDU_EXAM_READ" as const,
|
||||||
EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE" as const,
|
EXAM_UPDATE: "CORE_EDU_EXAM_UPDATE" as const,
|
||||||
EXAM_DELETE: "CORE_EDU_EXAM_DELETE" 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_CREATE: "CORE_EDU_HOMEWORK_CREATE" as const,
|
||||||
HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ" as const,
|
HOMEWORK_READ: "CORE_EDU_HOMEWORK_READ" as const,
|
||||||
HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE" as const,
|
HOMEWORK_UPDATE: "CORE_EDU_HOMEWORK_UPDATE" as const,
|
||||||
HOMEWORK_SUBMIT: "CORE_EDU_HOMEWORK_SUBMIT" 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_CREATE: "CORE_EDU_GRADE_CREATE" as const,
|
||||||
GRADE_READ: "CORE_EDU_GRADE_READ" 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;
|
} as const;
|
||||||
|
|
||||||
export type Permission = (typeof Permissions)[keyof typeof Permissions];
|
export type Permission = (typeof Permissions)[keyof typeof Permissions];
|
||||||
@@ -33,29 +52,56 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
|||||||
Permissions.EXAM_READ,
|
Permissions.EXAM_READ,
|
||||||
Permissions.EXAM_UPDATE,
|
Permissions.EXAM_UPDATE,
|
||||||
Permissions.EXAM_DELETE,
|
Permissions.EXAM_DELETE,
|
||||||
|
Permissions.EXAM_PUBLISH,
|
||||||
|
Permissions.EXAM_SUBMIT,
|
||||||
|
Permissions.EXAM_GRADE,
|
||||||
Permissions.HOMEWORK_CREATE,
|
Permissions.HOMEWORK_CREATE,
|
||||||
Permissions.HOMEWORK_READ,
|
Permissions.HOMEWORK_READ,
|
||||||
Permissions.HOMEWORK_UPDATE,
|
Permissions.HOMEWORK_UPDATE,
|
||||||
Permissions.HOMEWORK_SUBMIT,
|
Permissions.HOMEWORK_SUBMIT,
|
||||||
|
Permissions.HOMEWORK_GRADE,
|
||||||
Permissions.GRADE_CREATE,
|
Permissions.GRADE_CREATE,
|
||||||
Permissions.GRADE_READ,
|
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: [
|
teacher: [
|
||||||
Permissions.EXAM_CREATE,
|
Permissions.EXAM_CREATE,
|
||||||
Permissions.EXAM_READ,
|
Permissions.EXAM_READ,
|
||||||
Permissions.EXAM_UPDATE,
|
Permissions.EXAM_UPDATE,
|
||||||
|
Permissions.EXAM_PUBLISH,
|
||||||
|
Permissions.EXAM_GRADE,
|
||||||
Permissions.HOMEWORK_CREATE,
|
Permissions.HOMEWORK_CREATE,
|
||||||
Permissions.HOMEWORK_READ,
|
Permissions.HOMEWORK_READ,
|
||||||
Permissions.HOMEWORK_UPDATE,
|
Permissions.HOMEWORK_UPDATE,
|
||||||
Permissions.HOMEWORK_SUBMIT,
|
Permissions.HOMEWORK_GRADE,
|
||||||
Permissions.GRADE_CREATE,
|
Permissions.GRADE_CREATE,
|
||||||
Permissions.GRADE_READ,
|
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: [
|
student: [
|
||||||
Permissions.EXAM_READ,
|
Permissions.EXAM_READ,
|
||||||
|
Permissions.EXAM_SUBMIT,
|
||||||
Permissions.HOMEWORK_READ,
|
Permissions.HOMEWORK_READ,
|
||||||
Permissions.HOMEWORK_SUBMIT,
|
Permissions.HOMEWORK_SUBMIT,
|
||||||
Permissions.GRADE_READ,
|
Permissions.GRADE_READ,
|
||||||
|
Permissions.ATTENDANCE_READ,
|
||||||
|
Permissions.CLASS_READ,
|
||||||
|
Permissions.COURSE_READ,
|
||||||
|
Permissions.SCHEDULE_READ,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
82
services/core-edu/src/scheduling/schedule-conflict.ts
Normal file
82
services/core-edu/src/scheduling/schedule-conflict.ts
Normal file
@@ -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<ScheduleSlot, "id"> & { 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 };
|
||||||
|
}
|
||||||
157
services/core-edu/src/scheduling/scheduling.controller.ts
Normal file
157
services/core-edu/src/scheduling/scheduling.controller.ts
Normal file
@@ -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<ReturnType<SchedulingService["getCourse"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<SchedulingService["listCoursesByClass"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<SchedulingService["listCoursesByTeacher"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<SchedulingService["getSchedule"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<SchedulingService["listSchedulesByCourse"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<SchedulingService["listSchedulesByTeacher"]>>;
|
||||||
|
}> {
|
||||||
|
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<ReturnType<SchedulingService["listSchedulesByClass"]>>;
|
||||||
|
}> {
|
||||||
|
const data = await this.schedulingService.listSchedulesByClass(classId);
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
services/core-edu/src/scheduling/scheduling.module.ts
Normal file
10
services/core-edu/src/scheduling/scheduling.module.ts
Normal file
@@ -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 {}
|
||||||
113
services/core-edu/src/scheduling/scheduling.repository.ts
Normal file
113
services/core-edu/src/scheduling/scheduling.repository.ts
Normal file
@@ -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<Course | undefined> {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(courses)
|
||||||
|
.where(eq(courses.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCoursesByClassId(classId: string): Promise<Course[]> {
|
||||||
|
return db.select().from(courses).where(eq(courses.classId, classId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCoursesByTeacherId(teacherId: string): Promise<Course[]> {
|
||||||
|
return db.select().from(courses).where(eq(courses.teacherId, teacherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCourse(course: NewCourse): Promise<void> {
|
||||||
|
await db.insert(courses).values(course);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedules
|
||||||
|
async findScheduleById(id: string): Promise<Schedule | undefined> {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(schedules)
|
||||||
|
.where(eq(schedules.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSchedulesByCourseId(courseId: string): Promise<Schedule[]> {
|
||||||
|
return db.select().from(schedules).where(eq(schedules.courseId, courseId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSchedulesByTeacherId(teacherId: string): Promise<Schedule[]> {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(schedules)
|
||||||
|
.where(eq(schedules.teacherId, teacherId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findSchedulesByClassId(classId: string): Promise<Schedule[]> {
|
||||||
|
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<Schedule[]> {
|
||||||
|
// 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<string>();
|
||||||
|
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<void> {
|
||||||
|
await db.insert(schedules).values(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSchedule(id: string, data: Partial<NewSchedule>): Promise<void> {
|
||||||
|
await db.update(schedules).set(data).where(eq(schedules.id, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schedulingRepository = new SchedulingRepository();
|
||||||
87
services/core-edu/src/scheduling/scheduling.schema.ts
Normal file
87
services/core-edu/src/scheduling/scheduling.schema.ts
Normal file
@@ -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;
|
||||||
164
services/core-edu/src/scheduling/scheduling.service.ts
Normal file
164
services/core-edu/src/scheduling/scheduling.service.ts
Normal file
@@ -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<Course> {
|
||||||
|
const course = await schedulingRepository.findCourseById(id);
|
||||||
|
if (!course) {
|
||||||
|
throw new NotFoundError(`Course ${id} not found`);
|
||||||
|
}
|
||||||
|
return course;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCoursesByClass(classId: string): Promise<Course[]> {
|
||||||
|
return schedulingRepository.findCoursesByClassId(classId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listCoursesByTeacher(teacherId: string): Promise<Course[]> {
|
||||||
|
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<ScheduleSlot, "id"> = {
|
||||||
|
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<Schedule> {
|
||||||
|
const schedule = await schedulingRepository.findScheduleById(id);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError(`Schedule ${id} not found`);
|
||||||
|
}
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSchedulesByCourse(courseId: string): Promise<Schedule[]> {
|
||||||
|
return schedulingRepository.findSchedulesByCourseId(courseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSchedulesByTeacher(teacherId: string): Promise<Schedule[]> {
|
||||||
|
return schedulingRepository.findSchedulesByTeacherId(teacherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSchedulesByClass(classId: string): Promise<Schedule[]> {
|
||||||
|
return schedulingRepository.findSchedulesByClassId(classId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,19 @@ export enum CoreEduErrorCode {
|
|||||||
FORBIDDEN = "CORE_EDU_FORBIDDEN",
|
FORBIDDEN = "CORE_EDU_FORBIDDEN",
|
||||||
CONFLICT = "CORE_EDU_CONFLICT",
|
CONFLICT = "CORE_EDU_CONFLICT",
|
||||||
INTERNAL_ERROR = "CORE_EDU_INTERNAL_ERROR",
|
INTERNAL_ERROR = "CORE_EDU_INTERNAL_ERROR",
|
||||||
|
// Domain-specific errors
|
||||||
EXAM_NOT_FOUND = "CORE_EDU_EXAM_NOT_FOUND",
|
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_NOT_FOUND = "CORE_EDU_HOMEWORK_NOT_FOUND",
|
||||||
|
HOMEWORK_INVALID_TRANSITION = "CORE_EDU_HOMEWORK_INVALID_TRANSITION",
|
||||||
GRADE_NOT_FOUND = "CORE_EDU_GRADE_NOT_FOUND",
|
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",
|
OUTBOX_PUBLISH_FAILED = "CORE_EDU_OUTBOX_PUBLISH_FAILED",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { db } from "../../config/database.js";
|
import { db } from "../../config/database.js";
|
||||||
|
import { isRedisHealthy } from "../../config/redis.js";
|
||||||
|
import { isKafkaConnected } from "../../config/kafka.js";
|
||||||
|
|
||||||
const SERVICE_NAME = "core-edu";
|
const SERVICE_NAME = "core-edu";
|
||||||
|
|
||||||
|
interface HealthResponse {
|
||||||
|
status: string;
|
||||||
|
service: string;
|
||||||
|
timestamp: string;
|
||||||
|
checks?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
@Get("healthz")
|
@Get("healthz")
|
||||||
liveness(): { status: string; service: string; timestamp: string } {
|
liveness(): HealthResponse {
|
||||||
return {
|
return {
|
||||||
status: "ok",
|
status: "ok",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
@@ -16,29 +25,51 @@ export class HealthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("readyz")
|
@Get("readyz")
|
||||||
async readiness(): Promise<{
|
async readiness(): Promise<HealthResponse> {
|
||||||
status: string;
|
const checks: Record<string, string> = {};
|
||||||
service: string;
|
let allHealthy = true;
|
||||||
timestamp: string;
|
|
||||||
}> {
|
// DB check
|
||||||
try {
|
try {
|
||||||
await db.execute(sql`SELECT 1`);
|
await db.execute(sql`SELECT 1`);
|
||||||
return {
|
checks.db = "ok";
|
||||||
status: "ok",
|
|
||||||
service: SERVICE_NAME,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} 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(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
status: "error",
|
status: "error",
|
||||||
service: SERVICE_NAME,
|
service: SERVICE_NAME,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
error:
|
checks,
|
||||||
error instanceof Error ? error.message : "database unreachable",
|
|
||||||
},
|
},
|
||||||
HttpStatus.SERVICE_UNAVAILABLE,
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
service: SERVICE_NAME,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
checks,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { closeDb } from "../../config/database.js";
|
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";
|
const SERVICE_NAME = "core-edu";
|
||||||
|
|
||||||
@@ -21,6 +24,9 @@ export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
|||||||
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
|
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
|
await outboxPublisher.stop();
|
||||||
|
await disconnectRedis();
|
||||||
|
await disconnectKafka();
|
||||||
await closeDb();
|
await closeDb();
|
||||||
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
61
services/core-edu/src/shared/outbox/event-builder.ts
Normal file
61
services/core-edu/src/shared/outbox/event-builder.ts
Normal file
@@ -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<T = Record<string, unknown>> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,30 +1,46 @@
|
|||||||
import { logger } from '../observability/logger.js';
|
import { logger } from "../observability/logger.js";
|
||||||
import { producer } from '../../config/kafka.js';
|
import { producer } from "../../config/kafka.js";
|
||||||
import { outboxRepository } from './outbox.repository.js';
|
import { outboxRepository } from "./outbox.repository.js";
|
||||||
import type { OutboxMessage } from './outbox.schema.js';
|
import type { OutboxMessage } from "./outbox.schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TOPIC_MAP: 事件类型 → Kafka topic
|
||||||
|
*
|
||||||
|
* 仲裁依据:ISSUE-002 / ISSUE-004 / events.proto 头注释
|
||||||
|
* 命名规范:edu.teaching.<aggregate>.<action>
|
||||||
|
*/
|
||||||
const TOPIC_MAP: Record<string, string> = {
|
const TOPIC_MAP: Record<string, string> = {
|
||||||
'exam.created': 'edu.exam.events',
|
// Exam events
|
||||||
'exam.updated': 'edu.exam.events',
|
"exam.created": "edu.teaching.exam.created",
|
||||||
'exam.deleted': 'edu.exam.events',
|
"exam.updated": "edu.teaching.exam.updated",
|
||||||
'homework.assigned': 'edu.homework.events',
|
"exam.published": "edu.teaching.exam.published",
|
||||||
'homework.submitted': 'edu.homework.events',
|
"exam.submitted": "edu.teaching.exam.submitted",
|
||||||
'homework.graded': 'edu.homework.events',
|
"exam.graded": "edu.teaching.exam.graded",
|
||||||
'grade.recorded': 'edu.grade.events',
|
"exam.deleted": "edu.teaching.exam.deleted",
|
||||||
'grade.updated': 'edu.grade.events',
|
// Homework events
|
||||||
'class.transferred': 'edu.class.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 POLL_INTERVAL_MS = 5000;
|
||||||
const BATCH_SIZE = 100;
|
const BATCH_SIZE = 100;
|
||||||
const MAX_RETRY = 5;
|
const MAX_RETRY = 5;
|
||||||
|
const RETRY_BACKOFF_BASE_MS = 1000;
|
||||||
|
|
||||||
export class OutboxPublisher {
|
export class OutboxPublisher {
|
||||||
private intervalId: NodeJS.Timeout | null = null;
|
private intervalId: NodeJS.Timeout | null = null;
|
||||||
private isPolling = false;
|
private isPolling = false;
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
logger.info('OutboxPublisher started');
|
logger.info("OutboxPublisher started");
|
||||||
this.intervalId = setInterval(() => {
|
this.intervalId = setInterval(() => {
|
||||||
void this.poll();
|
void this.poll();
|
||||||
}, POLL_INTERVAL_MS);
|
}, POLL_INTERVAL_MS);
|
||||||
@@ -35,7 +51,7 @@ export class OutboxPublisher {
|
|||||||
clearInterval(this.intervalId);
|
clearInterval(this.intervalId);
|
||||||
this.intervalId = null;
|
this.intervalId = null;
|
||||||
}
|
}
|
||||||
logger.info('OutboxPublisher stopped');
|
logger.info("OutboxPublisher stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async poll(): Promise<void> {
|
private async poll(): Promise<void> {
|
||||||
@@ -47,14 +63,14 @@ export class OutboxPublisher {
|
|||||||
await this.publish(message);
|
await this.publish(message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'Outbox poll failed');
|
logger.error({ error }, "Outbox poll failed");
|
||||||
} finally {
|
} finally {
|
||||||
this.isPolling = false;
|
this.isPolling = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async publish(message: OutboxMessage): Promise<void> {
|
private async publish(message: OutboxMessage): Promise<void> {
|
||||||
const topic = TOPIC_MAP[message.eventType] ?? 'edu.fallback.events';
|
const topic = TOPIC_MAP[message.eventType] ?? "edu.teaching.fallback";
|
||||||
try {
|
try {
|
||||||
await producer.send({
|
await producer.send({
|
||||||
topic,
|
topic,
|
||||||
@@ -65,21 +81,32 @@ export class OutboxPublisher {
|
|||||||
headers: {
|
headers: {
|
||||||
eventType: message.eventType,
|
eventType: message.eventType,
|
||||||
aggregateType: message.aggregateType,
|
aggregateType: message.aggregateType,
|
||||||
|
eventId: message.eventId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await outboxRepository.markProcessed(message.id);
|
await outboxRepository.markProcessed(message.id);
|
||||||
logger.info(
|
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) {
|
} catch (error) {
|
||||||
logger.error({ error, id: message.id }, 'Outbox publish failed');
|
logger.error(
|
||||||
if (message.retryCount + 1 >= MAX_RETRY) {
|
{ error, id: message.id, eventId: message.eventId },
|
||||||
|
"Outbox publish failed",
|
||||||
|
);
|
||||||
|
const nextRetry = message.retryCount + 1;
|
||||||
|
if (nextRetry >= MAX_RETRY) {
|
||||||
await outboxRepository.markFailed(message.id);
|
await outboxRepository.markFailed(message.id);
|
||||||
} else {
|
} else {
|
||||||
await outboxRepository.incrementRetry(message.id);
|
const backoff = RETRY_BACKOFF_BASE_MS * Math.pow(2, nextRetry);
|
||||||
|
await outboxRepository.incrementRetry(message.id, backoff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql, and, lte, isNull, or } from "drizzle-orm";
|
||||||
import { db } from '../../config/database.js';
|
import { db } from "../../config/database.js";
|
||||||
import { outbox, type OutboxMessage, type NewOutboxMessage } from './outbox.schema.js';
|
import {
|
||||||
|
outbox,
|
||||||
|
type OutboxMessage,
|
||||||
|
type NewOutboxMessage,
|
||||||
|
} from "./outbox.schema.js";
|
||||||
|
|
||||||
type DbClient = typeof db;
|
type DbClient = typeof db;
|
||||||
|
|
||||||
@@ -9,33 +13,45 @@ export class OutboxRepository {
|
|||||||
await tx.insert(outbox).values(message);
|
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<OutboxMessage[]> {
|
async findPending(limit: number = 100): Promise<OutboxMessage[]> {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
.from(outbox)
|
.from(outbox)
|
||||||
.where(eq(outbox.status, 'pending'))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(outbox.status, "pending"),
|
||||||
|
or(lte(outbox.nextRetryAt, new Date()), isNull(outbox.nextRetryAt)),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markProcessed(id: string): Promise<void> {
|
async markProcessed(id: string): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.update(outbox)
|
.update(outbox)
|
||||||
.set({ status: 'processed', processedAt: new Date() })
|
.set({ status: "processed", processedAt: new Date() })
|
||||||
.where(eq(outbox.id, id));
|
.where(eq(outbox.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async incrementRetry(id: string): Promise<void> {
|
async incrementRetry(id: string, backoffMs: number): Promise<void> {
|
||||||
|
const nextRetryAt = new Date(Date.now() + backoffMs);
|
||||||
await db
|
await db
|
||||||
.update(outbox)
|
.update(outbox)
|
||||||
.set({ retryCount: sql`${outbox.retryCount} + 1` })
|
.set({
|
||||||
|
retryCount: sql`${outbox.retryCount} + 1`,
|
||||||
|
nextRetryAt,
|
||||||
|
})
|
||||||
.where(eq(outbox.id, id));
|
.where(eq(outbox.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async markFailed(id: string): Promise<void> {
|
async markFailed(id: string): Promise<void> {
|
||||||
await db
|
await db.update(outbox).set({ status: "failed" }).where(eq(outbox.id, id));
|
||||||
.update(outbox)
|
|
||||||
.set({ status: 'failed' })
|
|
||||||
.where(eq(outbox.id, id));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', {
|
// 事务性发件箱(Outbox 模式)
|
||||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
export const outbox = mysqlTable(
|
||||||
aggregateId: char('aggregate_id', { length: 36 }).notNull(),
|
"core_edu_outbox",
|
||||||
aggregateType: varchar('aggregate_type', { length: 50 }).notNull(),
|
{
|
||||||
eventType: varchar('event_type', { length: 100 }).notNull(),
|
id: char("id", { length: 36 }).notNull().primaryKey(),
|
||||||
payload: text('payload').notNull(),
|
eventId: char("event_id", { length: 36 }).notNull(),
|
||||||
status: varchar('status', { length: 20 }).notNull().default('pending'),
|
aggregateId: char("aggregate_id", { length: 36 }).notNull(),
|
||||||
retryCount: bigint('retry_count', { mode: 'number' }).notNull().default(0),
|
aggregateType: varchar("aggregate_type", { length: 50 }).notNull(),
|
||||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
eventType: varchar("event_type", { length: 100 }).notNull(),
|
||||||
processedAt: timestamp('processed_at'),
|
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 OutboxMessage = typeof outbox.$inferSelect;
|
||||||
export type NewOutboxMessage = typeof outbox.$inferInsert;
|
export type NewOutboxMessage = typeof outbox.$inferInsert;
|
||||||
|
|||||||
Reference in New Issue
Block a user