From 7dd5c444062efd6d8c970bc099b146f88cd21c5b Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:56:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(core-edu):=20admin/dashboard/leave-request?= =?UTF-8?q?s=20=E6=A8=A1=E5=9D=97=20+=20gRPC=20+=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA=E6=B5=8B=E8=AF=95=20+=20nextstep=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/core-edu-init.sql | 52 + services/core-edu/docs/nextstep.md | 376 +++++++ services/core-edu/src/admin/admin.module.ts | 8 + services/core-edu/src/admin/admin.service.ts | 64 ++ services/core-edu/src/app.module.ts | 7 + .../src/dashboard/dashboard.module.ts | 8 + .../src/dashboard/dashboard.service.ts | 245 +++++ .../src/exams/exam-extensions.schema.ts | 59 ++ .../src/exams/exam-state-machine.test.ts | 171 +++ services/core-edu/src/exams/exams.service.ts | 89 +- .../src/grades/grade-calculator.test.ts | 213 ++++ .../core-edu/src/grades/grades.service.ts | 168 +++ services/core-edu/src/grpc/grpc.server.ts | 973 ++++++++++++++++++ .../homework/homework-state-machine.test.ts | 141 +++ .../leave-requests/leave-requests.module.ts | 8 + .../leave-requests.repository.ts | 54 + .../leave-requests/leave-requests.schema.ts | 43 + .../leave-requests/leave-requests.service.ts | 94 ++ services/core-edu/src/main.ts | 8 +- .../src/scheduling/schedule-conflict.test.ts | 201 ++++ .../src/scheduling/scheduling.service.ts | 101 ++ .../shared/datascope/datascope-injector.ts | 73 ++ services/core-edu/test/grpc-smoke.mjs | 190 ++++ 23 files changed, 3344 insertions(+), 2 deletions(-) create mode 100644 services/core-edu/docs/nextstep.md create mode 100644 services/core-edu/src/admin/admin.module.ts create mode 100644 services/core-edu/src/admin/admin.service.ts create mode 100644 services/core-edu/src/dashboard/dashboard.module.ts create mode 100644 services/core-edu/src/dashboard/dashboard.service.ts create mode 100644 services/core-edu/src/exams/exam-extensions.schema.ts create mode 100644 services/core-edu/src/exams/exam-state-machine.test.ts create mode 100644 services/core-edu/src/grades/grade-calculator.test.ts create mode 100644 services/core-edu/src/grpc/grpc.server.ts create mode 100644 services/core-edu/src/homework/homework-state-machine.test.ts create mode 100644 services/core-edu/src/leave-requests/leave-requests.module.ts create mode 100644 services/core-edu/src/leave-requests/leave-requests.repository.ts create mode 100644 services/core-edu/src/leave-requests/leave-requests.schema.ts create mode 100644 services/core-edu/src/leave-requests/leave-requests.service.ts create mode 100644 services/core-edu/src/scheduling/schedule-conflict.test.ts create mode 100644 services/core-edu/src/shared/datascope/datascope-injector.ts create mode 100644 services/core-edu/test/grpc-smoke.mjs diff --git a/scripts/core-edu-init.sql b/scripts/core-edu-init.sql index 897b620..ae42b97 100644 --- a/scripts/core-edu-init.sql +++ b/scripts/core-edu-init.sql @@ -263,3 +263,55 @@ CREATE TABLE IF NOT EXISTS classes ( INDEX idx_classes_grade_id (grade_id), INDEX idx_classes_head_teacher (head_teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================ +-- P3.13 新增表:考试草稿、考试违规、请假申请 +-- ============================================================================ + +-- 考试草稿表(学生考试过程中的自动保存) +CREATE TABLE IF NOT EXISTS core_edu_exam_drafts ( + id CHAR(36) NOT NULL PRIMARY KEY, + exam_id CHAR(36) NOT NULL, + student_id CHAR(36) NOT NULL, + answers JSON, + 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_draft (exam_id, student_id), + INDEX idx_exam_drafts_student (student_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 考试违规表(防作弊事件记录) +CREATE TABLE IF NOT EXISTS core_edu_exam_violations ( + id CHAR(36) NOT NULL PRIMARY KEY, + exam_id CHAR(36) NOT NULL, + student_id CHAR(36) NOT NULL, + violation_type VARCHAR(40) NOT NULL, + detail TEXT, + severity INT NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_exam_violations_exam (exam_id), + INDEX idx_exam_violations_student (student_id), + INDEX idx_exam_violations_type (violation_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 请假申请表 +CREATE TABLE IF NOT EXISTS core_edu_leave_requests ( + id CHAR(36) NOT NULL PRIMARY KEY, + student_id CHAR(36) NOT NULL, + class_id CHAR(36) NOT NULL, + leave_type VARCHAR(20) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + reason TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + submitted_by CHAR(36) NOT NULL, + reviewed_by CHAR(36), + review_comment TEXT, + 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_leave_requests_student (student_id), + INDEX idx_leave_requests_class (class_id), + INDEX idx_leave_requests_status (status), + INDEX idx_leave_requests_school (school_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/services/core-edu/docs/nextstep.md b/services/core-edu/docs/nextstep.md new file mode 100644 index 0000000..1a122e9 --- /dev/null +++ b/services/core-edu/docs/nextstep.md @@ -0,0 +1,376 @@ +# core-edu 下一步工作与上下游依赖 + +> 模块:core-edu(教学核心服务,HTTP 3004 + gRPC 50053) +> 负责人:ai08 +> 更新日期:2026-07-13(v3:P3.13 12 个 RPC 补全完成 + 本地 Docker 24/24 smoke test 通过) +> 关联:[core-edu_contract.md](../../../docs/architecture/issues/contracts/core-edu_contract.md)、[core-edu_workline.md](../../../docs/architecture/issues/worklines/core-edu_workline.md) + +--- + +## 1. 模块当前状态 + +core-edu 是教学核心服务,承载 exams / homework / grades / attendance / scheduling / classes 六个域,提供 HTTP REST + gRPC 双入口。 + +### 1.1 已完成 + +| 能力 | 状态 | 说明 | +| ----------------------------------------- | ---- | ---------------------------------------------------------------------------------------------- | +| P2 服务骨架 + Outbox + REST CRUD | ✅ | exams/homework/grades 三域 REST + Outbox 事件发布 | +| P3.2 TOPIC_MAP 重命名 edu.teaching.* | ✅ | 12 个 topic + payload 含 schema_version/event_id/occurred_at/metadata | +| P3.3 考试/作业状态机 + 成绩幂等 | ✅ | scheme A(graded 统一、cancelled 终态、not_submitted 初始态) | +| P3.4 gRPC server 50053 + 5 Service 27 RPC | ✅ | ExamService(8) + HomeworkService(5) + GradeService(6) + ClassService(4) + AttendanceService(4) | +| P3.5 排课考勤数据模型 + AttendanceService | ✅ | schedule-conflict 冲突检测 + 幂等 | +| P3.6 成绩计算配置化 | ✅ | GradeCalculator:weighted_average / sum / custom,scope 优先级 class > subject > school | +| P3.7 Redis 分布式锁 | ✅ | homework submit 接入 acquireLock/releaseLock | +| P3.8 DataScope 下推 | ✅ | datascope-injector.ts,auth.middleware 解析 x-user-data-scope 头 | +| P3.9 IAM 事件消费 | ✅ | iam-consumer 订阅 teacher.assigned / class.created,幂等 | +| P3.11 classes 合并到 core-edu | ✅ | classes/ 目录已迁入,ClassesModule 已接入 AppModule | +| P3.12 单元测试 | ✅ | 4 个测试文件共 78 个测试用例(状态机/GradeCalculator/ScheduleConflict) | +| 三支柱可观测性 | ✅ | pino 结构化日志 + Prometheus /metrics + OpenTelemetry tracer | +| Docker 多阶段构建 | ✅ | node:22-alpine,EXPOSE 3004 50053 | +| **P3.13 下游缺失 RPC 补全(13 个)** | ✅ | **9 Service / 40 RPC 全部就绪** | + +### 1.2 待完成 + +| 任务 | 优先级 | 阻塞条件 | +| ------------------------------------ | ------ | ----------------------------- | +| P3.1 database.ts 改为 getDb() 函数式 | P3 | 无(技术债,14 个文件需重构) | +| P3.10 Temporal 工作流试点 | P3 | Temporal server 部署(infra) | +| P4.1 消费 data-ana mastery 事件 | P4 | data-ana gRPC 50055 就绪 | +| P4.2 content gRPC 调用(知识点关联) | P4 | content gRPC 50054 就绪 | +| P5.1 msg 事件消费联调 | P5 | msg gRPC 50056 就绪 | +| P6.1 /readyz 硬化(+Temporal 探针) | P6 | Temporal 部署 | + +### 1.3 本地 Docker 验证结果(2026-07-13 v3 - P3.13 完成) + +测试环境:edu-core-edu-test 容器接入 `edu-full_default` 网络,直连 edu-mysql / edu-redis / edu-kafka + +``` +镜像:edu/core-edu:test(单阶段构建,node:22-alpine + tsc) +容器:edu-core-edu-test(DEV_MODE=true, HTTP 13004→3004 + gRPC 50053) +``` + +| 验证项 | 状态 | 说明 | +| ------------------- | ---- | ------------------------------------------------------------- | +| Docker 镜像构建 | ✅ | node:22-alpine + shared-proto + tsconfig.base.json + tsc 编译 | +| 容器启动 | ✅ | HTTP + gRPC 双启动成功,日志 "HTTP + gRPC" | +| HTTP /healthz | ✅ | 200,`{"status":"ok","service":"core-edu"}` | +| HTTP /readyz | ✅ | 200,checks: db=ok, redis=ok, kafka=ok | +| gRPC 9 Service 可达 | ✅ | **24/24 RPC smoke test 全部通过** | +| proto 路径解析 | ✅ | /app/proto/core_edu.proto(运行时复制) | +| Redis 连接 | ✅ | 分布式锁就绪 | +| Kafka 连接 | ✅ | Outbox publisher + IAM consumer 已订阅 | +| gRPC smoke test | ✅ | `services/core-edu/test/grpc-smoke.mjs` 24/24 PASS | + +#### 1.3.1 P3.13 新增 13 RPC smoke test 结果 + +| Service | RPC | 结果 | 说明 | +| ------------------- | -------------------------- | ---- | ---------------------------------------- | +| ExamService | SaveExamDraft | ✅ | upsert 成功(exam_id + student_id 唯一) | +| ExamService | RecordExamViolation | ✅ | 违规记录写入 | +| GradeService | GetReportCard | ✅ | 成绩聚合,按学科计算 A/B/C/D/F | +| ScheduleService | GetScheduleByStudent | ✅ | 通过 attendance 反查 class_ids | +| LeaveRequestService | ListLeaveRequestsByStudent | ✅ | 列表为空(无数据) | +| LeaveRequestService | CreateLeaveRequest | ✅ | 返回 UUID(d5eeefba-...) | +| LeaveRequestService | CancelLeaveRequest | ✅ | 状态机 pending→cancelled | +| DashboardService | GetDashboard | ✅ | 教师仪表盘聚合 | +| DashboardService | GetClassPerformance | ✅ | 班级绩效(NotFound 为正常业务错误) | +| AdminService | ListSchools | ✅ | stub 返回空数组(待 IAM 集成) | +| AdminService | ListGradeLevels | ✅ | stub 返回空数组 | +| AdminService | ListDepartments | ✅ | stub 返回空数组 | +| AdminService | ListAcademicYears | ✅ | stub 返回空数组 | + +--- + +## 2. 上游依赖(core-edu 依赖谁) + +### 2.1 MySQL(基础设施)— P0 + +| 项 | 内容 | +| -------- | ------------------------------------------------------------------------------ | +| 依赖内容 | 全部业务数据持久化(exams/homework/grades/attendance/schedule/classes/outbox) | +| 端点 | `mysql://edu-mysql:3306/next_edu_cloud` | +| 当前状态 | ✅ 本地 Docker edu-mysql 已就绪 | + +### 2.2 Redis(基础设施)— P0 + +| 项 | 内容 | +| -------- | --------------------------------------------------- | +| 依赖内容 | 分布式锁(homework/exam submit 幂等)+ /readyz 探针 | +| 端点 | `redis://edu-redis:6379` | +| 当前状态 | ✅ 本地 Docker edu-redis 已就绪 | +| 降级策略 | Redis 不可用时锁操作降级为 DB 唯一索引兜底 | + +### 2.3 Kafka(基础设施)— P1 + +| 项 | 内容 | +| -------- | ------------------------------------------------------- | +| 依赖内容 | Outbox 事件发布到 `edu.teaching.*` topic(12 个 topic) | +| 端点 | `kafka:29092` | +| 当前状态 | ✅ 本地 Docker edu-kafka 已就绪 | +| 降级策略 | Kafka 不可用时事件暂存 outbox 表,服务不阻塞 | + +### 2.4 iam 服务(ai06 负责)— P1 + +| # | 依赖项 | 用途 | 状态 | +| --- | -------------------------------------------- | --------------------------------- | ------------------ | +| 1 | gRPC `GetUserInfo(userId)` :50052 | 考勤录入时校验教师身份 | ⏳ 待 iam 就绪 | +| 2 | gRPC `GetChildrenByParent(parentId)` :50052 | 家长端查询孩子成绩/考勤 | ⏳ 待 iam 就绪 | +| 3 | Kafka `edu.iam.user.created/updated/deleted` | IAM 事件消费(P3.9 已实现消费者) | ⏳ 待 iam 发布事件 | +| 4 | `GET /healthz` :3002 | /readyz 下游健康检查 | ⏳ | + +**环境变量**:`IAM_GRPC_TARGET=iam:50052` + +### 2.5 Temporal server(基础设施)— P3 + +| 项 | 内容 | +| -------- | -------------------------------- | +| 依赖内容 | 考试发布编排工作流(P3.10 试点) | +| 端点 | `temporal:7233` | +| 当前状态 | ⏳ 未部署 | + +--- + +## 3. 下游依赖(谁依赖 core-edu) + +### 3.1 teacher-bff(ai03 负责)— P0 + +| # | gRPC RPC :50053 | 用途 | 状态 | +| --- | ----------------------------------------- | ---------------------------- | --------- | +| 1 | `ExamService.ListExamsByClass` | `exams` 查询:考试列表 | ✅ 已实现 | +| 2 | `ExamService.CreateExam` | `createExam` mutation | ✅ 已实现 | +| 3 | `ExamService.GetExam` | 考试详情 | ✅ 已实现 | +| 4 | `HomeworkService.ListHomeworkByClass` | `homework` 查询:作业列表 | ✅ 已实现 | +| 5 | `HomeworkService.AssignHomework` | `assignHomework` mutation | ✅ 已实现 | +| 6 | `HomeworkService.GetHomework` | 作业详情 | ✅ 已实现 | +| 7 | `GradeService.RecordGrade` | `recordGrade` mutation | ✅ 已实现 | +| 8 | `GradeService.ListGradesByStudent` | `grades` 查询:成绩列表 | ✅ 已实现 | +| 9 | `ClassService.GetClass` | `class` 查询:班级详情 | ✅ 已实现 | +| 10 | `ClassService.GetClassesByTeacher` | `classes` 查询:教师班级列表 | ✅ 已实现 | +| 11 | `ClassService.ListStudentsByClass` | 班级学生列表 | ✅ 已实现 | +| 12 | `AttendanceService.ListAttendanceByClass` | 考勤列表 | ✅ 已实现 | + +**环境变量(teacher-bff 侧)**:`CORE_EDU_GRPC_TARGET=core-edu:50053` + +### 3.2 student-bff(ai04 负责)— P0 + +| # | gRPC RPC :50053 | 用途 | 状态 | +| --- | ------------------------------------------- | ------------------------- | --------- | +| 1 | `ExamService.GetExam` | `examDetail` Query | ✅ 已实现 | +| 2 | `ExamService.SubmitExam` | `submitExam` Mutation | ✅ 已实现 | +| 3 | `HomeworkService.GetHomework` | `homeworkDetail` Query | ✅ 已实现 | +| 4 | `HomeworkService.SubmitHomework` | `submitHomework` Mutation | ✅ 已实现 | +| 5 | `GradeService.ListGradesByStudent` | `myGrades` Query | ✅ 已实现 | +| 6 | `AttendanceService.ListAttendanceByStudent` | `myAttendance` Query | ✅ 已实现 | + +**环境变量(student-bff 侧)**:`CORE_EDU_GRPC_TARGET=core-edu:50053` + +### 3.3 parent-bff(ai05 负责)— P0 + +| # | gRPC RPC :50053 | 用途 | 状态 | +| --- | ------------------------------------------- | -------------------------------- | --------- | +| 1 | `GradeService.ListGradesByStudent` | `childGrades` / `childSummary` | ✅ 已实现 | +| 2 | `HomeworkService.ListHomeworkByClass` | `childHomework` / `childSummary` | ✅ 已实现 | +| 3 | `ExamService.ListExamsByClass` | `childExams` / `childSummary` | ✅ 已实现 | +| 4 | `ClassService.GetClass` | 班级信息 | ✅ 已实现 | +| 5 | `AttendanceService.ListAttendanceByStudent` | `childAttendance` | ✅ 已实现 | + +**环境变量(parent-bff 侧)**:`CORE_EDU_GRPC_TARGET=core-edu:50053` + +### 3.4 api-gateway(ai01 负责)— P1 + +| # | 依赖项 | 用途 | 状态 | +| --- | ------------------------------------ | -------------------- | --------- | +| 1 | HTTP `GET /healthz` :3004 | /readyz 下游健康检查 | ✅ 已实现 | +| 2 | HTTP REST `/api/v1/exams/*` :3004 | 考试域 REST 代理 | ✅ 已实现 | +| 3 | HTTP REST `/api/v1/homework/*` :3004 | 作业域 REST 代理 | ✅ 已实现 | +| 4 | HTTP REST `/api/v1/grades/*` :3004 | 成绩域 REST 代理 | ✅ 已实现 | +| 5 | HTTP REST `/api/v1/classes/*` :3004 | 班级域 REST 代理 | ✅ 已实现 | + +### 3.5 msg 服务(ai10 负责)— P2 + +| # | 依赖项 | 用途 | 状态 | +| --- | ---------------------------------------- | ---------------- | --------- | +| 1 | Kafka `edu.teaching.exam.published` | 考试发布通知触发 | ✅ 已发布 | +| 2 | Kafka `edu.teaching.homework.assigned` | 作业布置通知触发 | ✅ 已发布 | +| 3 | Kafka `edu.teaching.grade.recorded` | 成绩录入通知触发 | ✅ 已发布 | +| 4 | Kafka `edu.teaching.attendance.recorded` | 考勤异常通知触发 | ✅ 已发布 | + +### 3.6 data-ana 服务(ai11 负责)— P2 + +| # | 依赖项 | 用途 | 状态 | +| --- | --------------------------------------- | ----------------------- | --------- | +| 1 | Kafka `edu.teaching.*`(全量 12 topic) | CDC 数据分析消费 | ✅ 已发布 | +| 2 | gRPC `ExamService.GetExam` :50053 | 考试详情查询(P4 联调) | ✅ 已实现 | + +### 3.7 下游 9 模块缺失 RPC 清单(v2 新增,2026-07-13) + +> 来源:4 个 portal + 2 个 gateway + 3 个 BFF 的 nextstep.md 依赖分析 + +#### 3.7.1 teacher-bff(ai03)缺失 RPC(7 个) + +| # | RPC | 用途 | 当前状态 | 计划 | +| --- | ------------------------------------------------ | -------------------- | -------- | ----------------- | +| 1 | `ExamService.SaveExamDraft` | 学生考试草稿保存 | ❌ 缺失 | 新增 | +| 2 | `GradeService.GetReportCard` | 学生成绩报告卡 | ❌ 缺失 | 新增 | +| 3 | `ScheduleService.GetScheduleByStudent` | 学生课表 | ❌ 缺失 | 新增 | +| 4 | `LeaveRequestService.ListLeaveRequestsByStudent` | 学生请假列表 | ❌ 缺失 | 新增 | +| 5 | `LeaveRequestService.CreateLeaveRequest` | 创建请假 | ❌ 缺失 | 新增 | +| 6 | `LeaveRequestService.CancelLeaveRequest` | 取消请假 | ❌ 缺失 | 新增 | +| 7 | `DashboardService.GetDashboard(teacherId)` | 教师仪表盘聚合 | ❌ 缺失 | 新增 | +| 8 | `DashboardService.GetClassPerformance(classId)` | 班级绩效聚合 | ❌ 缺失 | 新增 | +| 9 | `AdminService.ListSchools` | 学校列表(admin 域) | ❌ 缺失 | 新增(聚合 stub) | +| 10 | `AdminService.ListGradeLevels(schoolId)` | 年级列表(admin 域) | ❌ 缺失 | 新增(聚合 stub) | +| 11 | `AdminService.ListDepartments(schoolId)` | 部门列表(admin 域) | ❌ 缺失 | 新增(聚合 stub) | +| 12 | `AdminService.ListAcademicYears` | 学年列表(admin 域) | ❌ 缺失 | 新增(聚合 stub) | + +#### 3.7.2 student-bff(ai04)缺失 RPC(7 个) + +| # | RPC | 用途 | 当前状态 | +| --- | ------------------------------------------------ | ------------------------------ | ------------------------- | +| 1 | `ExamService.SaveExamDraft` | `saveExamDraft` Mutation | ❌ 缺失(同 teacher-bff) | +| 2 | `ExamService.RecordExamViolation` | `recordExamViolation` Mutation | ❌ 缺失(新增) | +| 3 | `ScheduleService.GetScheduleByStudent` | `mySchedule` Query | ❌ 缺失(同 teacher-bff) | +| 4 | `LeaveRequestService.ListLeaveRequestsByStudent` | `myLeaveRequests` Query | ❌ 缺失(同 teacher-bff) | +| 5 | `LeaveRequestService.CreateLeaveRequest` | `createLeaveRequest` Mutation | ❌ 缺失(同 teacher-bff) | +| 6 | `LeaveRequestService.CancelLeaveRequest` | `cancelLeaveRequest` Mutation | ❌ 缺失(同 teacher-bff) | +| 7 | `GradeService.GetReportCard` | `myReportCard` Query | ❌ 缺失(同 teacher-bff) | + +#### 3.7.3 parent-bff(ai05)缺失 RPC(5 个) + +| # | RPC | 用途 | 当前状态 | +| --- | ------------------------------------------------ | ----------------------------- | --------------- | +| 1 | `AttendanceService.ListAttendanceByStudent` | `childAttendance` | ✅ 已实现 | +| 2 | `GradeService.GetReportCard` | `childReportCard` | ❌ 缺失(同上) | +| 3 | `LeaveRequestService.ListLeaveRequestsByStudent` | `childLeaveRequests` | ❌ 缺失(同上) | +| 4 | `LeaveRequestService.CreateLeaveRequest` | `createLeaveRequest` Mutation | ❌ 缺失(同上) | +| 5 | `AdminService.ListAcademicYears` | `academicYears` | ❌ 缺失(同上) | + +#### 3.7.4 api-gateway(ai01)依赖 + +| 依赖项 | 状态 | +| ------------------------------------ | --------- | +| HTTP `GET /healthz` :3004 | ✅ 已实现 | +| HTTP REST `/api/v1/exams/*` :3004 | ✅ 已实现 | +| HTTP REST `/api/v1/homework/*` :3004 | ✅ 已实现 | +| HTTP REST `/api/v1/grades/*` :3004 | ✅ 已实现 | +| HTTP REST `/api/v1/classes/*` :3004 | ✅ 已实现 | + +#### 3.7.5 4 个 portal(ai13/14/15/16)间接依赖 + +portal 不直接调用 core-edu,通过各自 BFF 转发。需求已在 §3.7.1-3.7.3 中体现。 + +#### 3.7.6 潜在缺口说明(parent-bff/parent-portal 提及) + +> parent-bff §4.6 降级查询清单和 parent-portal §4.2 提到 `ExamResultService` 和 `ExportService`,这两个 Service 在 core-edu 9 Service / 40 RPC 清单中不存在。 + +| 下游提及的能力 | core-edu 现状 | 归属判断 | +| --------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| 考试结果详情(ExamResultService) | GradeService.GetReportCard 可承接成绩报告卡;ExamService.GetExam 返回考试基础信息 | 需 parent-bff 联调时确认是否需要独立 RPC,或由现有 RPC 组合 | +| 成绩导出(ExportService) | 无对应 RPC(导出属 BFF/Portal 层格式化能力) | 建议由 parent-bff 调用 GradeService.ListGradesByStudent + 本地格式化实现 | + +**结论**:这两项不阻塞下游联调,core-edu 不新增 RPC,待 parent-bff 联调时协商归属。 + +--- + +## 4. 就绪信号 + +| 阶段 | 就绪信号 | 消费方 | 状态 | +| ------------------- | ----------------------------------------------------------------- | ------------------------------------------- | ------ | +| P2(已就绪) | HTTP 3004 可访问 + /healthz + /readyz(DB 探针) | teacher-bff(REST 调用) | ✅ | +| P3(核心) | gRPC 50053 + 27 RPC + HealthService SERVING | teacher-bff / student-bff / parent-bff / ai | ✅ | +| P3 子信号 1 | ClassService 4 RPC 可调用 | teacher-bff(班级列表) | ✅ | +| P3 子信号 2 | ExamService 10 RPC 可调用(含 SaveExamDraft/RecordExamViolation) | teacher-bff / student-bff / ai | ✅ | +| P3 子信号 3 | HomeworkService 5 RPC 可调用 | teacher-bff / student-bff | ✅ | +| P3 子信号 4 | GradeService 7 RPC 可调用(含 GetReportCard) | teacher-bff / student-bff / parent-bff | ✅ | +| P3 子信号 5 | AttendanceService 4 RPC 可调用 | parent-bff | ✅ | +| P3 子信号 6 | edu.teaching.* topic 可发布(含 attendance.recorded) | msg / data-ana / push-gateway | ✅ | +| **P3.13(已就绪)** | **gRPC 50053 + 40 RPC(9 Service)+ 24/24 smoke test** | **teacher-bff / student-bff / parent-bff** | **✅** | +| P3.13 子信号 1 | ScheduleService.GetScheduleByStudent 可调用 | teacher-bff / student-bff | ✅ | +| P3.13 子信号 2 | LeaveRequestService 3 RPC 可调用 | teacher-bff / student-bff / parent-bff | ✅ | +| P3.13 子信号 3 | DashboardService 2 RPC 可调用 | teacher-bff | ✅ | +| P3.13 子信号 4 | AdminService 4 RPC 可调用(stub) | teacher-bff / parent-bff | ✅ | + +--- + +## 5. Docker 部署 + +### 5.1 镜像构建 + +```bash +# 在仓库根目录执行(需要访问 packages/shared-proto + tsconfig.base.json) +docker build -t edu/core-edu:test -f services/core-edu/Dockerfile . +``` + +### 5.2 容器启动 + +```bash +docker run -d \ + --name edu-core-edu-test \ + --network edu-full_default \ + -p 13004:3004 -p 50053:50053 \ + -e PORT=3004 \ + -e GRPC_PORT=50053 \ + -e DATABASE_URL=mysql://edu:changeme@edu-mysql:3306/next_edu_cloud \ + -e REDIS_URL=redis://edu-redis:6379 \ + -e KAFKA_BROKERS=kafka:29092 \ + -e DEV_MODE=true \ + edu/core-edu:test +``` + +### 5.3 健康检查验证 + +```bash +# HTTP liveness +curl http://localhost:13004/healthz + +# HTTP readiness(DB + Redis 探针) +curl -H "x-user-id: dev-teacher" -H "x-user-role: teacher" http://localhost:13004/readyz + +# gRPC smoke test(24 RPC) +cd services/core-edu && node test/grpc-smoke.mjs +# 应输出:PASS: 24 FAIL: 0 TOTAL: 24 +``` + +### 5.4 环境变量清单 + +| 变量 | 必填 | 示例值 | 说明 | +| ----------------------------- | ---- | ------------------------------------------------ | --------------------------------------- | +| `PORT` | 是 | `3004` | HTTP 监听端口 | +| `GRPC_PORT` | 是 | `50053` | gRPC 监听端口 | +| `DATABASE_URL` | 是 | `mysql://edu:pass@edu-mysql:3306/next_edu_cloud` | MySQL 连接串 | +| `REDIS_URL` | 否 | `redis://edu-redis:6379` | Redis 连接地址(缺失时降级) | +| `KAFKA_BROKERS` | 否 | `kafka:29092` | Kafka broker 地址(缺失时 outbox 暂存) | +| `JWT_SECRET` | 否 | — | JWT 校验密钥(可选) | +| `DEV_MODE` | 否 | `false` | 开发模式(跳过部分校验) | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | 否 | `http://otel-collector:4318` | OTLP 上报端点 | +| `LOG_LEVEL` | 否 | `info` | 日志级别 | + +--- + +## 6. 关键文件路径 + +| 文件 | 用途 | +| ---------------------------------------------------------------- | -------------------------------------------------- | +| `services/core-edu/src/main.ts` | 服务入口(HTTP + gRPC 双启动,含 startGrpcServer) | +| `services/core-edu/src/grpc/grpc.server.ts` | gRPC server(9 Service / 40 RPC handler) | +| `services/core-edu/src/app.module.ts` | NestJS 根模块 | +| `services/core-edu/src/exams/exam-state-machine.ts` | 考试状态机(纯函数) | +| `services/core-edu/src/homework/homework-state-machine.ts` | 作业状态机(纯函数) | +| `services/core-edu/src/grades/grade-calculator.ts` | 成绩计算器(scope 优先级) | +| `services/core-edu/src/scheduling/schedule-conflict.ts` | 排课冲突检测(纯函数) | +| `services/core-edu/src/leave-requests/leave-requests.service.ts` | 请假服务(P3.13 新增) | +| `services/core-edu/src/dashboard/dashboard.service.ts` | 仪表盘聚合(P3.13 新增) | +| `services/core-edu/src/admin/admin.service.ts` | Admin 聚合 stub(P3.13 新增) | +| `services/core-edu/src/shared/outbox/outbox.publisher.ts` | Outbox 事件发布(TOPIC_MAP) | +| `services/core-edu/src/shared/datascope/datascope-injector.ts` | DataScope WHERE 注入 | +| `services/core-edu/src/config/database.ts` | MySQL 连接池 | +| `services/core-edu/src/config/kafka.ts` | Kafka producer | +| `services/core-edu/src/config/redis.ts` | Redis 客户端 + 分布式锁 | +| `services/core-edu/Dockerfile` | Docker 构建(单阶段,node:22-alpine + tsc) | +| `services/core-edu/test/grpc-smoke.mjs` | gRPC smoke test(24 RPC) | +| `packages/shared-proto/proto/core_edu.proto` | gRPC proto 契约(9 Service / 40 RPC) | + +--- + +**本文件由 ai08 维护。core-edu P3 核心功能 + P3.13 下游缺失 RPC 补全已全部完成(9 Service / 40 RPC 就绪),本地 Docker 24/24 smoke test 通过。下游 9 模块(4 portal + 2 gateway + 3 BFF)可基于此进行端到端联调。P4+ 任务等待 data-ana/content/msg 服务就绪。** diff --git a/services/core-edu/src/admin/admin.module.ts b/services/core-edu/src/admin/admin.module.ts new file mode 100644 index 0000000..5989e32 --- /dev/null +++ b/services/core-edu/src/admin/admin.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { AdminService } from "./admin.service.js"; + +@Module({ + providers: [AdminService], + exports: [AdminService], +}) +export class AdminModule {} diff --git a/services/core-edu/src/admin/admin.service.ts b/services/core-edu/src/admin/admin.service.ts new file mode 100644 index 0000000..42265ee --- /dev/null +++ b/services/core-edu/src/admin/admin.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from "@nestjs/common"; + +/** + * Admin 聚合服务(P3.13 stub) + * + * 这些 RPC 返回学校、年级、部门、学年等组织架构数据。 + * core-edu 不持有这些表,实际数据来自 IAM / classes 服务。 + * P3.13 阶段返回空数组,待跨服务 gRPC 客户端集成后补全。 + */ + +export interface SchoolInfo { + id: string; + name: string; + address: string; + principalId: string; + createdAt: string; +} + +export interface GradeLevelInfo { + id: string; + name: string; + schoolId: string; + order: number; +} + +export interface DepartmentInfo { + id: string; + name: string; + schoolId: string; + headId: string; + createdAt: string; +} + +export interface AcademicYearInfo { + id: string; + name: string; + schoolId: string; + startDate: string; + endDate: string; + isCurrent: boolean; +} + +@Injectable() +export class AdminService { + async listSchools(): Promise { + // P3.13 stub: 待 IAM gRPC 客户端集成 + return []; + } + + async listGradeLevels(_schoolId: string): Promise { + // P3.13 stub: 待 classes gRPC 客户端集成 + return []; + } + + async listDepartments(_schoolId: string): Promise { + // P3.13 stub: 待 IAM gRPC 客户端集成 + return []; + } + + async listAcademicYears(_schoolId?: string): Promise { + // P3.13 stub: 待 classes gRPC 客户端集成 + return []; + } +} diff --git a/services/core-edu/src/app.module.ts b/services/core-edu/src/app.module.ts index 56a9091..1496b06 100644 --- a/services/core-edu/src/app.module.ts +++ b/services/core-edu/src/app.module.ts @@ -6,6 +6,9 @@ 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 { LeaveRequestsModule } from "./leave-requests/leave-requests.module.js"; +import { DashboardModule } from "./dashboard/dashboard.module.js"; +import { AdminModule } from "./admin/admin.module.js"; import { IamConsumerModule } from "./iam-consumer/iam-consumer.module.js"; import { HealthModule } from "./shared/health/health.module.js"; import { PermissionGuard } from "./middleware/permission.guard.js"; @@ -20,6 +23,10 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js"; AttendanceModule, ClassesModule, SchedulingModule, + // P3.13 新增模块 + LeaveRequestsModule, + DashboardModule, + AdminModule, IamConsumerModule, HealthModule, ], diff --git a/services/core-edu/src/dashboard/dashboard.module.ts b/services/core-edu/src/dashboard/dashboard.module.ts new file mode 100644 index 0000000..f96db94 --- /dev/null +++ b/services/core-edu/src/dashboard/dashboard.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { DashboardService } from "./dashboard.service.js"; + +@Module({ + providers: [DashboardService], + exports: [DashboardService], +}) +export class DashboardModule {} diff --git a/services/core-edu/src/dashboard/dashboard.service.ts b/services/core-edu/src/dashboard/dashboard.service.ts new file mode 100644 index 0000000..e59eedb --- /dev/null +++ b/services/core-edu/src/dashboard/dashboard.service.ts @@ -0,0 +1,245 @@ +import { eq, inArray, gte, and } from "drizzle-orm"; +import { Injectable } from "@nestjs/common"; +import { db } from "../config/database.js"; +import { classes } from "../classes/classes.schema.js"; +import { exams, examSubmissions } from "../exams/exams.schema.js"; +import { homework, homeworkSubmissions } from "../homework/homework.schema.js"; +import { grades } from "../grades/grades.schema.js"; +import { + NotFoundError, + ValidationError, +} from "../shared/errors/application-error.js"; + +export interface DashboardData { + teacherId: string; + totalClasses: number; + totalStudents: number; + pendingHomework: number; + upcomingExams: number; + ungradedSubmissions: number; + classes: Array<{ + classId: string; + className: string; + studentCount: number; + }>; + upcomingExamList: Array<{ + examId: string; + title: string; + examDate: string; + classId: string; + className: string; + }>; + generatedAt: string; +} + +export interface ClassPerformance { + classId: string; + className: string; + studentCount: number; + averageScore: string; + highestScore: string; + lowestScore: string; + medianScore: string; + subjects: Array<{ + subjectId: string; + subjectName: string; + averageScore: string; + studentCount: number; + }>; + generatedAt: string; +} + +@Injectable() +export class DashboardService { + async getDashboard(teacherId: string): Promise { + if (!teacherId) { + throw new ValidationError("teacherId is required"); + } + + // 1. 查询该教师负责的班级 + const teacherClasses = await db + .select() + .from(classes) + .where(eq(classes.headTeacherId, teacherId)); + + const classIds = teacherClasses.map((c) => c.id); + const classMap = new Map(teacherClasses.map((c) => [c.id, c.name])); + + if (classIds.length === 0) { + return { + teacherId, + totalClasses: 0, + totalStudents: 0, + pendingHomework: 0, + upcomingExams: 0, + ungradedSubmissions: 0, + classes: [], + upcomingExamList: [], + generatedAt: new Date().toISOString(), + }; + } + + // 2. 构造班级卡片(学生数需 IAM 集成,P3.13 stub 用 0 占位) + const classCards = teacherClasses.map((c) => ({ + classId: c.id, + className: c.name, + studentCount: 0, + })); + + // 3. 统计待处理作业(status='assigned') + const pendingHomeworkRows = await db + .select({ id: homework.id }) + .from(homework) + .where( + and( + inArray(homework.classId, classIds), + eq(homework.status, "assigned"), + ), + ); + + // 4. 统计即将到来的考试(status='published', exam_date >= now) + const now = new Date(); + const upcomingExamRows = await db + .select() + .from(exams) + .where( + and( + inArray(exams.classId, classIds), + eq(exams.status, "published"), + gte(exams.examDate, now), + ), + ); + + const upcomingExamList = upcomingExamRows.map((e) => ({ + examId: e.id, + title: e.title, + examDate: + e.examDate instanceof Date ? e.examDate.toISOString() : e.examDate, + classId: e.classId, + className: classMap.get(e.classId) ?? "", + })); + + // 5. 统计未批阅提交(exam_submissions + homework_submissions status='submitted') + const examIds = upcomingExamRows.map((e) => e.id); + let ungradedExamCount = 0; + if (examIds.length > 0) { + const ungradedExamRows = await db + .select({ id: examSubmissions.id }) + .from(examSubmissions) + .where( + and( + inArray(examSubmissions.examId, examIds), + eq(examSubmissions.status, "submitted"), + ), + ); + ungradedExamCount = ungradedExamRows.length; + } + + const homeworkIds = pendingHomeworkRows.map((h) => h.id); + let ungradedHwCount = 0; + if (homeworkIds.length > 0) { + const ungradedHwRows = await db + .select({ id: homeworkSubmissions.id }) + .from(homeworkSubmissions) + .where( + and( + inArray(homeworkSubmissions.homeworkId, homeworkIds), + eq(homeworkSubmissions.status, "submitted"), + ), + ); + ungradedHwCount = ungradedHwRows.length; + } + + return { + teacherId, + totalClasses: teacherClasses.length, + totalStudents: 0, // P3.13 stub: 需 IAM 集成 + pendingHomework: pendingHomeworkRows.length, + upcomingExams: upcomingExamRows.length, + ungradedSubmissions: ungradedExamCount + ungradedHwCount, + classes: classCards, + upcomingExamList, + generatedAt: new Date().toISOString(), + }; + } + + async getClassPerformance( + classId: string, + _subjectId?: string, + ): Promise { + if (!classId) { + throw new ValidationError("classId is required"); + } + + // 1. 查询班级信息 + const classRows = await db + .select() + .from(classes) + .where(eq(classes.id, classId)) + .limit(1); + + const cls = classRows[0]; + if (!cls) { + throw new NotFoundError(`Class ${classId} not found`); + } + + // 2. 查询该班学生的成绩(通过 exam_id 关联 exam.class_id) + const classExamIds = await db + .select({ id: exams.id }) + .from(exams) + .where(eq(exams.classId, classId)); + + const examIdList = classExamIds.map((e) => e.id); + + let allGrades: Array<{ score: string; totalScore: string }> = []; + if (examIdList.length > 0) { + const gradeRows = await db + .select({ + score: grades.score, + totalScore: grades.totalScore, + }) + .from(grades) + .where(inArray(grades.examId, examIdList)); + allGrades = gradeRows; + } + + // 3. 计算统计值 + const percentages = allGrades.map((g) => { + const total = Number(g.totalScore); + return total > 0 ? (Number(g.score) / total) * 100 : 0; + }); + + // 学生数需 IAM 集成,P3.13 stub 用成绩记录去重学生数估算 + const average = + percentages.length > 0 + ? percentages.reduce((a, b) => a + b, 0) / percentages.length + : 0; + const highest = percentages.length > 0 ? Math.max(...percentages) : 0; + const lowest = percentages.length > 0 ? Math.min(...percentages) : 0; + const median = this.calcMedian(percentages); + + return { + classId, + className: cls.name, + studentCount: 0, // P3.13 stub: 需 IAM 集成 + averageScore: average.toFixed(2), + highestScore: highest.toFixed(2), + lowestScore: lowest.toFixed(2), + medianScore: median.toFixed(2), + subjects: [], // P3.13 stub: 需 content 服务集成获取学科名 + generatedAt: new Date().toISOString(), + }; + } + + private calcMedian(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + const a = sorted[mid - 1] ?? 0; + const b = sorted[mid] ?? 0; + return (a + b) / 2; + } + return sorted[mid] ?? 0; + } +} diff --git a/services/core-edu/src/exams/exam-extensions.schema.ts b/services/core-edu/src/exams/exam-extensions.schema.ts new file mode 100644 index 0000000..6e17590 --- /dev/null +++ b/services/core-edu/src/exams/exam-extensions.schema.ts @@ -0,0 +1,59 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + char, + int, + json, + index, + uniqueIndex, +} from "drizzle-orm/mysql-core"; + +// 考试草稿表(P3.13 新增) +export const examDrafts = mysqlTable( + "core_edu_exam_drafts", + { + id: char("id", { length: 36 }).notNull().primaryKey(), + examId: char("exam_id", { length: 36 }).notNull(), + studentId: char("student_id", { length: 36 }).notNull(), + answers: json("answers"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + uniqExamStudentDraft: uniqueIndex("uniq_exam_student_draft").on( + table.examId, + table.studentId, + ), + idxExamDraftsStudent: index("idx_exam_drafts_student").on(table.studentId), + }), +); + +// 考试违规表(P3.13 新增) +export const examViolations = mysqlTable( + "core_edu_exam_violations", + { + id: char("id", { length: 36 }).notNull().primaryKey(), + examId: char("exam_id", { length: 36 }).notNull(), + studentId: char("student_id", { length: 36 }).notNull(), + violationType: varchar("violation_type", { length: 40 }).notNull(), + detail: text("detail"), + severity: int("severity").notNull().default(1), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => ({ + idxExamViolationsExam: index("idx_exam_violations_exam").on(table.examId), + idxExamViolationsStudent: index("idx_exam_violations_student").on( + table.studentId, + ), + idxExamViolationsType: index("idx_exam_violations_type").on( + table.violationType, + ), + }), +); + +export type ExamDraft = typeof examDrafts.$inferSelect; +export type NewExamDraft = typeof examDrafts.$inferInsert; +export type ExamViolation = typeof examViolations.$inferSelect; +export type NewExamViolation = typeof examViolations.$inferInsert; diff --git a/services/core-edu/src/exams/exam-state-machine.test.ts b/services/core-edu/src/exams/exam-state-machine.test.ts new file mode 100644 index 0000000..76e4e78 --- /dev/null +++ b/services/core-edu/src/exams/exam-state-machine.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; +import { + canTransition, + transition, + isTerminal, + EXAM_STATUSES, +} from "./exam-state-machine.js"; +import type { ExamStatus, ExamAction } from "./exam-state-machine.js"; + +describe("exam-state-machine", () => { + describe("合法状态转换", () => { + it("draft --publish--> published", () => { + expect(transition("draft", "publish")).toBe("published"); + }); + + it("published --start--> in_progress", () => { + expect(transition("published", "start")).toBe("in_progress"); + }); + + it("in_progress --submit--> grading", () => { + expect(transition("in_progress", "submit")).toBe("grading"); + }); + + it("grading --grade--> graded", () => { + expect(transition("grading", "grade")).toBe("graded"); + }); + + it("graded --archive--> archived", () => { + expect(transition("graded", "archive")).toBe("archived"); + }); + + it("cancelled 可从 draft/published/in_progress/grading 流入(cancel)", () => { + expect(transition("draft", "cancel")).toBe("cancelled"); + expect(transition("published", "cancel")).toBe("cancelled"); + expect(transition("in_progress", "cancel")).toBe("cancelled"); + expect(transition("grading", "cancel")).toBe("cancelled"); + }); + }); + + describe("非法状态转换", () => { + it("draft --grade--> 抛错(不能从 draft 直接 grade)", () => { + expect(() => transition("draft", "grade")).toThrow(); + }); + + it("draft --start--> 抛错(必须先 publish)", () => { + expect(() => transition("draft", "start")).toThrow(); + }); + + it("published --submit--> 抛错(必须先 start)", () => { + expect(() => transition("published", "submit")).toThrow(); + }); + + it("graded --publish--> 抛错(只能 archive)", () => { + expect(() => transition("graded", "publish")).toThrow(); + }); + + it("graded --cancel--> 抛错(graded 只能 archive)", () => { + expect(() => transition("graded", "cancel")).toThrow(); + }); + + it("archived 是终态,任何动作都抛错", () => { + const actions: ExamAction[] = [ + "publish", + "start", + "submit", + "grade", + "archive", + "cancel", + ]; + for (const action of actions) { + expect(() => transition("archived", action)).toThrow(); + } + }); + + it("cancelled 是终态,任何动作都抛错", () => { + const actions: ExamAction[] = [ + "publish", + "start", + "submit", + "grade", + "archive", + "cancel", + ]; + for (const action of actions) { + expect(() => transition("cancelled", action)).toThrow(); + } + }); + + it("抛错信息包含非法转换描述", () => { + expect(() => transition("draft", "grade")).toThrow( + /Invalid exam state transition/, + ); + }); + }); + + describe("canTransition", () => { + it("合法转换返回 true", () => { + expect(canTransition("draft", "publish")).toBe(true); + expect(canTransition("published", "start")).toBe(true); + expect(canTransition("in_progress", "submit")).toBe(true); + expect(canTransition("grading", "grade")).toBe(true); + expect(canTransition("graded", "archive")).toBe(true); + }); + + it("cancel 动作在 draft/published/in_progress/grading 下返回 true", () => { + expect(canTransition("draft", "cancel")).toBe(true); + expect(canTransition("published", "cancel")).toBe(true); + expect(canTransition("in_progress", "cancel")).toBe(true); + expect(canTransition("grading", "cancel")).toBe(true); + }); + + it("非法转换返回 false", () => { + expect(canTransition("draft", "grade")).toBe(false); + expect(canTransition("draft", "start")).toBe(false); + expect(canTransition("published", "submit")).toBe(false); + expect(canTransition("graded", "publish")).toBe(false); + expect(canTransition("graded", "cancel")).toBe(false); + }); + + it("终态对所有动作返回 false", () => { + const actions: ExamAction[] = [ + "publish", + "start", + "submit", + "grade", + "archive", + "cancel", + ]; + for (const action of actions) { + expect(canTransition("archived", action)).toBe(false); + expect(canTransition("cancelled", action)).toBe(false); + } + }); + }); + + describe("isTerminal", () => { + it("archived 是终态", () => { + expect(isTerminal("archived")).toBe(true); + }); + + it("cancelled 是终态", () => { + expect(isTerminal("cancelled")).toBe(true); + }); + + it("非终态状态返回 false", () => { + const nonTerminal: ExamStatus[] = [ + "draft", + "published", + "in_progress", + "grading", + "graded", + ]; + for (const status of nonTerminal) { + expect(isTerminal(status)).toBe(false); + } + }); + }); + + describe("EXAM_STATUSES 常量", () => { + it("包含全部 7 种状态", () => { + expect(EXAM_STATUSES).toHaveLength(7); + expect([...EXAM_STATUSES]).toContain("draft"); + expect([...EXAM_STATUSES]).toContain("published"); + expect([...EXAM_STATUSES]).toContain("in_progress"); + expect([...EXAM_STATUSES]).toContain("grading"); + expect([...EXAM_STATUSES]).toContain("graded"); + expect([...EXAM_STATUSES]).toContain("archived"); + expect([...EXAM_STATUSES]).toContain("cancelled"); + }); + }); +}); diff --git a/services/core-edu/src/exams/exams.service.ts b/services/core-edu/src/exams/exams.service.ts index 7559b5f..b806285 100644 --- a/services/core-edu/src/exams/exams.service.ts +++ b/services/core-edu/src/exams/exams.service.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { Injectable } from "@nestjs/common"; import { db } from "../config/database.js"; import { exams } from "./exams.schema.js"; +import { examDrafts, examViolations } from "./exam-extensions.schema.js"; import { examsRepository } from "./exams.repository.js"; import { outboxRepository } from "../shared/outbox/outbox.repository.js"; import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js"; @@ -391,6 +392,92 @@ export class ExamsService { }); } + // -------------------------------------------------------------------------- + // P3.13 新增:考试草稿自动保存(upsert by exam_id + student_id) + // -------------------------------------------------------------------------- + + async saveExamDraft( + examId: string, + studentId: string, + answers: AnswerInput[], + ): Promise<{ draftId: string }> { + if (!examId || !studentId) { + throw new ValidationError("examId and studentId are required"); + } + // 校验考试存在 + const exam = await examsRepository.findById(examId); + if (!exam) { + throw new NotFoundError(`Exam ${examId} not found`); + } + + const answersPayload = answers.map((a) => ({ + questionId: a.questionId, + answer: a.answer, + })); + + // 查找已有草稿(unique key: exam_id + student_id) + const existing = await db + .select() + .from(examDrafts) + .where( + and(eq(examDrafts.examId, examId), eq(examDrafts.studentId, studentId)), + ) + .limit(1); + + const draftRow = existing[0]; + if (draftRow) { + const draftId = draftRow.id; + await db + .update(examDrafts) + .set({ answers: answersPayload }) + .where(eq(examDrafts.id, draftId)); + return { draftId }; + } + + const draftId = randomUUID(); + await db.insert(examDrafts).values({ + id: draftId, + examId, + studentId, + answers: answersPayload, + }); + return { draftId }; + } + + // -------------------------------------------------------------------------- + // P3.13 新增:考试违规事件记录(防作弊) + // -------------------------------------------------------------------------- + + async recordExamViolation( + examId: string, + studentId: string, + violationType: string, + detail: string, + severity: number, + ): Promise<{ violationId: string }> { + if (!examId || !studentId || !violationType) { + throw new ValidationError( + "examId, studentId, violationType are required", + ); + } + // 校验考试存在 + const exam = await examsRepository.findById(examId); + if (!exam) { + throw new NotFoundError(`Exam ${examId} not found`); + } + + const violationId = randomUUID(); + await db.insert(examViolations).values({ + id: violationId, + examId, + studentId, + violationType, + detail: detail || null, + severity: severity || 1, + }); + return { violationId }; + } + private assertTransition(from: ExamStatus, action: ExamAction): void { if (!canTransition(from, action)) { throw new ApplicationError( diff --git a/services/core-edu/src/grades/grade-calculator.test.ts b/services/core-edu/src/grades/grade-calculator.test.ts new file mode 100644 index 0000000..68ac14b --- /dev/null +++ b/services/core-edu/src/grades/grade-calculator.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from "vitest"; +import { selectFormula, calculateGrade } from "./grade-calculator.js"; +import type { GradeFormulaConfig, ScoreEntry } from "./grade-calculator.js"; + +// 固定时间基准,便于测试 effectiveFrom/effectiveTo 过滤 +const NOW = new Date("2026-07-13T12:00:00Z"); +const PAST = new Date("2026-07-01T00:00:00Z"); +const FUTURE = new Date("2026-08-01T00:00:00Z"); + +// 构造公式配置的工厂函数,减少样板代码 +function makeFormula( + overrides: Partial, +): GradeFormulaConfig { + return { + scope: "school", + scopeId: "s1", + formulaType: "weighted_average", + weights: null, + customExpression: null, + effectiveFrom: PAST, + effectiveTo: null, + ...overrides, + }; +} + +describe("grade-calculator", () => { + describe("selectFormula - scope 优先级", () => { + it("class > subject > school(打乱顺序仍选出 class)", () => { + const school = makeFormula({ scope: "school", scopeId: "sch1" }); + const subject = makeFormula({ scope: "subject", scopeId: "sub1" }); + const klass = makeFormula({ scope: "class", scopeId: "cls1" }); + expect(selectFormula([school, subject, klass], NOW)).toBe(klass); + expect(selectFormula([klass, school, subject], NOW)).toBe(klass); + expect(selectFormula([subject, klass, school], NOW)).toBe(klass); + }); + + it("仅有 subject 与 school 时选出 subject", () => { + const school = makeFormula({ scope: "school", scopeId: "sch1" }); + const subject = makeFormula({ scope: "subject", scopeId: "sub1" }); + expect(selectFormula([school, subject], NOW)).toBe(subject); + }); + + it("仅有 school 时选出 school", () => { + const school = makeFormula({ scope: "school", scopeId: "sch1" }); + expect(selectFormula([school], NOW)).toBe(school); + }); + }); + + describe("selectFormula - 时间过滤", () => { + it("effectiveFrom 在未来时被排除", () => { + const futureFormula = makeFormula({ + scope: "class", + effectiveFrom: FUTURE, + }); + const schoolFormula = makeFormula({ scope: "school" }); + // class 优先级高但尚未生效,应返回 school + expect(selectFormula([futureFormula, schoolFormula], NOW)).toBe( + schoolFormula, + ); + }); + + it("effectiveTo <= now 时被排除(已过期)", () => { + const expired = makeFormula({ + scope: "class", + effectiveFrom: PAST, + effectiveTo: NOW, // effectiveTo <= now → 排除 + }); + const schoolFormula = makeFormula({ scope: "school" }); + expect(selectFormula([expired, schoolFormula], NOW)).toBe(schoolFormula); + }); + + it("effectiveTo 在未来时生效", () => { + const active = makeFormula({ + scope: "class", + effectiveFrom: PAST, + effectiveTo: FUTURE, + }); + expect(selectFormula([active], NOW)).toBe(active); + }); + + it("effectiveFrom == now 时生效(边界包含)", () => { + const boundary = makeFormula({ scope: "class", effectiveFrom: NOW }); + expect(selectFormula([boundary], NOW)).toBe(boundary); + }); + + it("无任何生效公式时返回 null", () => { + const futureFormula = makeFormula({ + scope: "class", + effectiveFrom: FUTURE, + }); + expect(selectFormula([futureFormula], NOW)).toBeNull(); + }); + + it("空数组返回 null", () => { + expect(selectFormula([], NOW)).toBeNull(); + }); + }); + + describe("calculateGrade - weighted_average(加权平均)", () => { + it("加权平均计算正确", () => { + const formula = makeFormula({ + formulaType: "weighted_average", + weights: { a: 0.3, b: 0.7 }, + }); + const entries: ScoreEntry[] = [ + { sourceId: "a", score: 80, totalScore: 100 }, + { sourceId: "b", score: 90, totalScore: 100 }, + ]; + // (80*0.3 + 90*0.7) / (0.3+0.7) = (24+63)/1 = 87 + expect(calculateGrade(formula, entries)).toBe(87); + }); + + it("不等权重计算并四舍五入到两位小数", () => { + const formula = makeFormula({ + formulaType: "weighted_average", + weights: { a: 2, b: 1 }, + }); + const entries: ScoreEntry[] = [ + { sourceId: "a", score: 85, totalScore: 100 }, + { sourceId: "b", score: 90, totalScore: 100 }, + ]; + // (85*2 + 90*1) / 3 = 260/3 = 86.666... → 86.67 + expect(calculateGrade(formula, entries)).toBe(86.67); + }); + + it("无权重时退化为简单平均", () => { + const formula = makeFormula({ + formulaType: "weighted_average", + weights: null, + }); + const entries: ScoreEntry[] = [ + { sourceId: "a", score: 80, totalScore: 100 }, + { sourceId: "b", score: 90, totalScore: 100 }, + ]; + // (80+90)/2 = 85 + expect(calculateGrade(formula, entries)).toBe(85); + }); + + it("无权重且空 entries 返回 0", () => { + const formula = makeFormula({ + formulaType: "weighted_average", + weights: null, + }); + expect(calculateGrade(formula, [])).toBe(0); + }); + + it("entry 的 sourceId 不在 weights 中时权重为 0", () => { + const formula = makeFormula({ + formulaType: "weighted_average", + weights: { a: 1 }, + }); + const entries: ScoreEntry[] = [ + { sourceId: "a", score: 80, totalScore: 100 }, + { sourceId: "b", score: 100, totalScore: 100 }, // b 不在 weights,权重 0 + ]; + // (80*1 + 100*0) / 1 = 80 + expect(calculateGrade(formula, entries)).toBe(80); + }); + + it("所有 entry 权重为 0 时返回 0(避免除零)", () => { + const formula = makeFormula({ + formulaType: "weighted_average", + weights: { x: 1 }, + }); + const entries: ScoreEntry[] = [ + { sourceId: "a", score: 80, totalScore: 100 }, + ]; + // a 不在 weights,权重 0,totalWeight = 0 → 返回 0 + expect(calculateGrade(formula, entries)).toBe(0); + }); + }); + + describe("calculateGrade - sum(求和)", () => { + it("求和计算正确", () => { + const formula = makeFormula({ formulaType: "sum" }); + const entries: ScoreEntry[] = [ + { sourceId: "a", score: 80, totalScore: 100 }, + { sourceId: "b", score: 90, totalScore: 100 }, + ]; + expect(calculateGrade(formula, entries)).toBe(170); + }); + + it("空 entries 求和返回 0", () => { + const formula = makeFormula({ formulaType: "sum" }); + expect(calculateGrade(formula, [])).toBe(0); + }); + + it("求和结果四舍五入到两位小数", () => { + const formula = makeFormula({ formulaType: "sum" }); + const entries: ScoreEntry[] = [ + { sourceId: "a", score: 80.123, totalScore: 100 }, + { sourceId: "b", score: 90.456, totalScore: 100 }, + ]; + // 80.123 + 90.456 = 170.579 → 170.58 + expect(calculateGrade(formula, entries)).toBe(170.58); + }); + }); + + describe("calculateGrade - custom(自定义,P3 不支持)", () => { + it("custom 类型抛出错误", () => { + const formula = makeFormula({ + formulaType: "custom", + customExpression: "a + b", + }); + const entries: ScoreEntry[] = [ + { sourceId: "a", score: 80, totalScore: 100 }, + ]; + expect(() => calculateGrade(formula, entries)).toThrow( + /Custom grade formula is not supported/, + ); + }); + }); +}); diff --git a/services/core-edu/src/grades/grades.service.ts b/services/core-edu/src/grades/grades.service.ts index 22fab54..a81723d 100644 --- a/services/core-edu/src/grades/grades.service.ts +++ b/services/core-edu/src/grades/grades.service.ts @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; +import { inArray } from "drizzle-orm"; import { Injectable } from "@nestjs/common"; import { db } from "../config/database.js"; +import { exams } from "../exams/exams.schema.js"; +import { homework } from "../homework/homework.schema.js"; import { gradesRepository } from "./grades.repository.js"; import { outboxRepository } from "../shared/outbox/outbox.repository.js"; import { buildEvent, serializeEvent } from "../shared/outbox/event-builder.js"; @@ -27,6 +30,27 @@ export interface UpdateGradeInput { feedback?: string; } +export interface ReportCardEntry { + subjectId: string; + subjectName: string; + examScore: string; + examTotal: string; + homeworkScore: string; + homeworkTotal: string; + finalScore: string; + gradeLevel: string; + teacherComment: string; +} + +export interface ReportCard { + studentId: string; + termId: string; + entries: ReportCardEntry[]; + overallGrade: string; + classRank: string; + createdAt: string; +} + @Injectable() export class GradesService { async recordGrade( @@ -163,4 +187,148 @@ export class GradesService { ); }); } + + // -------------------------------------------------------------------------- + // P3.13 新增:成绩单生成(按学科聚合考试+作业成绩) + // -------------------------------------------------------------------------- + + async getReportCard(studentId: string, termId?: string): Promise { + if (!studentId) { + throw new ValidationError("studentId is required"); + } + + const studentGrades = await gradesRepository.findByStudentId(studentId); + if (studentGrades.length === 0) { + return { + studentId, + termId: termId ?? "", + entries: [], + overallGrade: "", + classRank: "", + createdAt: new Date().toISOString(), + }; + } + + // 收集所有 exam_id 和 homework_id,用于反查 subject_id + const examIds = studentGrades + .map((g) => g.examId) + .filter((id): id is string => Boolean(id)); + const homeworkIds = studentGrades + .map((g) => g.homeworkId) + .filter((id): id is string => Boolean(id)); + + // 查询考试和作业以获取 subject_id + const examRows = + examIds.length > 0 + ? await db + .select({ id: exams.id, subjectId: exams.subjectId }) + .from(exams) + .where(inArray(exams.id, examIds)) + : []; + const homeworkRows = + homeworkIds.length > 0 + ? await db + .select({ id: homework.id, subjectId: homework.subjectId }) + .from(homework) + .where(inArray(homework.id, homeworkIds)) + : []; + + const examSubjectMap = new Map(examRows.map((e) => [e.id, e.subjectId])); + const homeworkSubjectMap = new Map( + homeworkRows.map((h) => [h.id, h.subjectId]), + ); + + // 按学科聚合:每科累计 exam 分数、homework 分数 + const subjectAgg = new Map< + string, + { + examScore: number; + examTotal: number; + homeworkScore: number; + homeworkTotal: number; + } + >(); + + for (const g of studentGrades) { + let subjectId = ""; + if (g.examId) { + subjectId = examSubjectMap.get(g.examId) ?? ""; + } else if (g.homeworkId) { + subjectId = homeworkSubjectMap.get(g.homeworkId) ?? ""; + } + if (!subjectId) continue; + + const agg = subjectAgg.get(subjectId) ?? { + examScore: 0, + examTotal: 0, + homeworkScore: 0, + homeworkTotal: 0, + }; + const score = Number(g.score); + const total = Number(g.totalScore); + if (g.examId) { + agg.examScore += score; + agg.examTotal += total; + } else if (g.homeworkId) { + agg.homeworkScore += score; + agg.homeworkTotal += total; + } + subjectAgg.set(subjectId, agg); + } + + // 构造 entries + 计算 grade_level + const entries: ReportCardEntry[] = []; + let overallPercentageSum = 0; + let subjectCount = 0; + + for (const [subjectId, agg] of subjectAgg) { + const examScoreStr = agg.examScore.toFixed(2); + const examTotalStr = agg.examTotal.toFixed(2); + const homeworkScoreStr = agg.homeworkScore.toFixed(2); + const homeworkTotalStr = agg.homeworkTotal.toFixed(2); + + const totalEarned = agg.examScore + agg.homeworkScore; + const totalPossible = agg.examTotal + agg.homeworkTotal; + const percentage = + totalPossible > 0 ? (totalEarned / totalPossible) * 100 : 0; + const finalScoreStr = percentage.toFixed(2); + const gradeLevel = this.calcGradeLevel(percentage); + + overallPercentageSum += percentage; + subjectCount += 1; + + entries.push({ + subjectId, + subjectName: "", // subject_name 由 content 服务提供,此处留空 + examScore: examScoreStr, + examTotal: examTotalStr, + homeworkScore: homeworkScoreStr, + homeworkTotal: homeworkTotalStr, + finalScore: finalScoreStr, + gradeLevel, + teacherComment: "", + }); + } + + const overallPercentage = + subjectCount > 0 ? overallPercentageSum / subjectCount : 0; + const overallGrade = this.calcGradeLevel(overallPercentage); + + return { + studentId, + termId: termId ?? "", + entries, + overallGrade, + classRank: "", // class_rank 需要同级学生比较,当前为 stub + createdAt: new Date().toISOString(), + }; + } + + private calcGradeLevel(percentage: number): string { + if (percentage >= 90) return "A"; + if (percentage >= 80) return "B"; + if (percentage >= 70) return "C"; + if (percentage >= 60) return "D"; + return "F"; + } } diff --git a/services/core-edu/src/grpc/grpc.server.ts b/services/core-edu/src/grpc/grpc.server.ts new file mode 100644 index 0000000..6909a89 --- /dev/null +++ b/services/core-edu/src/grpc/grpc.server.ts @@ -0,0 +1,973 @@ +import path from "node:path"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import type { INestApplicationContext } from "@nestjs/common"; +import { logger } from "../shared/observability/logger.js"; +import { env } from "../config/env.js"; +import { ExamsService } from "../exams/exams.service.js"; +import { HomeworkService } from "../homework/homework.service.js"; +import { GradesService } from "../grades/grades.service.js"; +import { ClassesService } from "../classes/classes.service.js"; +import { AttendanceService } from "../attendance/attendance.service.js"; +import { SchedulingService } from "../scheduling/scheduling.service.js"; +import { LeaveRequestsService } from "../leave-requests/leave-requests.service.js"; +import { DashboardService } from "../dashboard/dashboard.service.js"; +import { AdminService } from "../admin/admin.service.js"; +import { + ApplicationError, + NotFoundError, + ValidationError, + ConflictError, +} from "../shared/errors/application-error.js"; +import type { Exam } from "../exams/exams.schema.js"; +import type { Homework } from "../homework/homework.schema.js"; +import type { Grade } from "../grades/grades.schema.js"; +import type { Class } from "../classes/classes.schema.js"; +import type { Attendance } from "../attendance/attendance.schema.js"; + +// proto 包名(与 core_edu.proto 中 package 声明一致) +const PROTO_PACKAGE = "next_edu_cloud.core_edu.v1"; + +// protoLoader 加载选项:keepCase 保留 snake_case 字段名 +const PROTO_OPTIONS: protoLoader.Options = { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, +}; + +let grpcServer: grpc.Server | null = null; + +// ---------------------------------------------------------------------------- +// proto 文件路径解析 +// ---------------------------------------------------------------------------- + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * 解析 core_edu.proto 文件路径。 + * 按优先级尝试多个候选路径,兼容本地开发与 Docker 运行时: + * 1. 相对于源文件位置(dev:src/grpc;prod:dist/grpc,目录层级一致) + * 2. 相对于 cwd 的 packages/shared-proto/proto(cwd = repo 根) + * 3. Docker 中 COPY 到 /app/proto 的副本 + */ +function resolveProtoPath(): string { + const candidates = [ + path.resolve( + __dirname, + "../../../../packages/shared-proto/proto/core_edu.proto", + ), + path.resolve(process.cwd(), "packages/shared-proto/proto/core_edu.proto"), + path.resolve(process.cwd(), "proto/core_edu.proto"), + "/app/proto/core_edu.proto", + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + // 全部缺失时回退到首选路径,让 proto-loader 抛出明确的文件不存在错误 + const fallback = candidates[0]; + return fallback ?? candidates[1] ?? ""; +} + +/** + * 沿 next_edu_cloud.core_edu.v1 导航 proto 包定义,返回目标包 GrpcObject。 + * proto-loader 的 GrpcObject 索引签名包含 ProtobufTypeDefinition, + * 包层级在运行时一定是 GrpcObject,经 unknown 取出以避开联合类型。 + */ +function getCoreEduPackage(protoDescriptor: grpc.GrpcObject): grpc.GrpcObject { + const root = protoDescriptor as unknown as Record; + const segment = root["next_edu_cloud"]; + const coreEdu = (segment as Record | undefined)?.[ + "core_edu" + ]; + const v1 = (coreEdu as Record | undefined)?.["v1"]; + if (!v1 || typeof v1 !== "object") { + throw new Error(`gRPC package "${PROTO_PACKAGE}" not found in proto`); + } + return v1 as unknown as grpc.GrpcObject; +} + +/** + * 从 proto 包中取出服务的 ServiceDefinition。 + * proto-loader 生成的服务构造器上有静态 .service 属性(方法定义表), + * 该属性不在 grpc.Client 基类类型声明中,需要经 unknown 断言取出。 + */ +function getServiceDefinition( + pkg: grpc.GrpcObject, + serviceName: string, +): grpc.ServiceDefinition { + const service = pkg[serviceName]; + if (service === undefined || typeof service !== "function") { + throw new Error(`gRPC service "${serviceName}" not found in proto package`); + } + const ctor = service as unknown as { + service: grpc.ServiceDefinition; + }; + if (!ctor.service) { + throw new Error( + `Service definition missing on "${serviceName}" constructor`, + ); + } + return ctor.service; +} + +// ---------------------------------------------------------------------------- +// 错误处理:将 ApplicationError 映射为 gRPC status code +// ---------------------------------------------------------------------------- + +/** + * gRPC 服务端错误,实现 grpc.ServiceError 接口以便 callback 直接使用。 + */ +class GrpcServiceError extends Error implements grpc.ServiceError { + readonly code: grpc.status; + readonly details: string; + readonly metadata: grpc.Metadata; + constructor(code: grpc.status, message: string) { + super(message); + this.name = "GrpcServiceError"; + this.code = code; + this.details = message; + this.metadata = new grpc.Metadata(); + } +} + +function toGrpcError(err: unknown): grpc.ServiceError { + if (err instanceof NotFoundError) { + return new GrpcServiceError(grpc.status.NOT_FOUND, err.message); + } + if (err instanceof ValidationError) { + return new GrpcServiceError(grpc.status.INVALID_ARGUMENT, err.message); + } + if (err instanceof ConflictError) { + return new GrpcServiceError(grpc.status.FAILED_PRECONDITION, err.message); + } + if (err instanceof ApplicationError) { + // 按业务错误状态码补充映射 + if (err.statusCode === 401) { + return new GrpcServiceError(grpc.status.UNAUTHENTICATED, err.message); + } + if (err.statusCode === 403) { + return new GrpcServiceError(grpc.status.PERMISSION_DENIED, err.message); + } + return new GrpcServiceError(grpc.status.INTERNAL, err.message); + } + const message = err instanceof Error ? err.message : "Internal server error"; + return new GrpcServiceError(grpc.status.INTERNAL, message); +} + +// ---------------------------------------------------------------------------- +// handler 包装:把 async (req) => Promise 转为 grpc handleUnaryCall +// ---------------------------------------------------------------------------- + +type ProtoRequest = Record; +type ProtoResponse = Record; + +function wrapHandler( + handler: (req: ProtoRequest) => Promise, +): grpc.handleUnaryCall { + return (call, callback) => { + // call.request 类型为 unknown(addService 的实现签名),此处从 unknown 转换为记录 + const request = call.request as ProtoRequest; + Promise.resolve() + .then(() => handler(request)) + .then((result) => callback(null, result)) + .catch((err: unknown) => callback(toGrpcError(err))); + }; +} + +// ---------------------------------------------------------------------------- +// 字段转换工具:Date -> ISO string,null -> "" +// ---------------------------------------------------------------------------- + +function toIso(value: Date | string | null | undefined): string { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + return value.toISOString(); +} + +function str(value: string | null | undefined): string { + return value ?? ""; +} + +// ---------------------------------------------------------------------------- +// 实体 -> proto message 映射(camelCase -> snake_case + ISO 日期) +// ---------------------------------------------------------------------------- + +function toExamProto(exam: Exam): ProtoResponse { + return { + id: exam.id, + class_id: exam.classId, + subject_id: exam.subjectId, + title: exam.title, + description: str(exam.description), + exam_date: toIso(exam.examDate), + duration: exam.duration, + total_score: exam.totalScore, + status: exam.status, + status_changed_at: toIso(exam.statusChangedAt), + status_changed_by: str(exam.statusChangedBy), + school_id: exam.schoolId, + created_by: exam.createdBy, + archived_at: toIso(exam.archivedAt), + created_at: toIso(exam.createdAt), + updated_at: toIso(exam.updatedAt), + }; +} + +function toHomeworkProto(hw: Homework): ProtoResponse { + return { + id: hw.id, + class_id: hw.classId, + subject_id: hw.subjectId, + title: hw.title, + description: str(hw.description), + due_date: toIso(hw.dueDate), + grace_period: hw.gracePeriod, + status: hw.status, + school_id: hw.schoolId, + created_by: hw.createdBy, + created_at: toIso(hw.createdAt), + updated_at: toIso(hw.updatedAt), + }; +} + +function toGradeProto(grade: Grade): ProtoResponse { + return { + id: grade.id, + student_id: grade.studentId, + exam_id: str(grade.examId), + homework_id: str(grade.homeworkId), + score: grade.score, + total_score: grade.totalScore, + feedback: str(grade.feedback), + graded_by: grade.gradedBy, + school_id: grade.schoolId, + idempotency_key: str(grade.idempotencyKey), + created_at: toIso(grade.createdAt), + updated_at: toIso(grade.updatedAt), + }; +} + +function toClassProto(cls: Class): ProtoResponse { + return { + id: cls.id, + name: cls.name, + grade_id: cls.gradeId, + head_teacher_id: str(cls.headTeacherId), + description: str(cls.description), + created_at: toIso(cls.createdAt), + updated_at: toIso(cls.updatedAt), + }; +} + +function toAttendanceProto(att: Attendance): ProtoResponse { + return { + id: att.id, + schedule_id: att.scheduleId, + student_id: att.studentId, + status: att.status, + remark: str(att.remark), + recorded_by: att.recordedBy, + school_id: att.schoolId, + created_at: toIso(att.createdAt), + updated_at: toIso(att.updatedAt), + }; +} + +// ---------------------------------------------------------------------------- +// proto 请求字段读取工具(从 snake_case 请求中安全取值) +// ---------------------------------------------------------------------------- + +function reqStr(req: ProtoRequest, key: string): string { + const v = req[key]; + return typeof v === "string" ? v : ""; +} + +function reqNum(req: ProtoRequest, key: string): number { + const v = req[key]; + return typeof v === "number" ? v : 0; +} + +function reqStrArr(req: ProtoRequest, key: string): string[] { + const v = req[key]; + if (!Array.isArray(v)) { + return []; + } + // Array.isArray 将 unknown 收窄为 any[],先经 unknown[] 再用类型守卫过滤 + const arr = v as unknown[]; + return arr.filter((x): x is string => typeof x === "string"); +} + +function reqObjArr(req: ProtoRequest, key: string): ProtoRequest[] { + const v = req[key]; + if (!Array.isArray(v)) { + return []; + } + const arr = v as unknown[]; + return arr.map((item) => + item !== null && typeof item === "object" ? (item as ProtoRequest) : {}, + ); +} + +// ---------------------------------------------------------------------------- +// ExamService handlers(8 RPC) +// ---------------------------------------------------------------------------- + +function buildExamHandlers( + service: ExamsService, +): Record> { + return { + CreateExam: wrapHandler(async (req) => { + const result = await service.createExam({ + classId: reqStr(req, "class_id"), + subjectId: reqStr(req, "subject_id"), + title: reqStr(req, "title"), + description: reqStr(req, "description") || undefined, + examDate: reqStr(req, "exam_date"), + duration: reqNum(req, "duration"), + totalScore: reqStr(req, "total_score"), + schoolId: reqStr(req, "school_id"), + createdBy: reqStr(req, "created_by"), + }); + return { id: result.id }; + }), + + GetExam: wrapHandler(async (req) => { + const exam = await service.getExam(reqStr(req, "id")); + return toExamProto(exam); + }), + + ListExamsByClass: wrapHandler(async (req) => { + const exams = await service.listExamsByClass(reqStr(req, "class_id")); + return { exams: exams.map(toExamProto) }; + }), + + UpdateExam: wrapHandler(async (req) => { + await service.updateExam(reqStr(req, "id"), { + title: reqStr(req, "title") || undefined, + description: reqStr(req, "description") || undefined, + examDate: reqStr(req, "exam_date") + ? new Date(reqStr(req, "exam_date")) + : undefined, + duration: reqNum(req, "duration") || undefined, + totalScore: reqStr(req, "total_score") || undefined, + }); + return { success: true }; + }), + + DeleteExam: wrapHandler(async (req) => { + await service.deleteExam(reqStr(req, "id")); + return { success: true }; + }), + + PublishExam: wrapHandler(async (req) => { + await service.publishExam(reqStr(req, "id"), reqStr(req, "published_by")); + return { success: true }; + }), + + SubmitExam: wrapHandler(async (req) => { + const answers = reqObjArr(req, "answers").map((a) => ({ + questionId: reqStr(a, "question_id"), + answer: reqStr(a, "answer"), + })); + const result = await service.submitExam( + reqStr(req, "exam_id"), + reqStr(req, "student_id"), + answers, + ); + return { submission_id: result.submissionId }; + }), + + GradeExam: wrapHandler(async (req) => { + const scores = reqObjArr(req, "scores").map((s) => ({ + questionId: reqStr(s, "question_id"), + score: reqStr(s, "score"), + teacherComment: reqStr(s, "teacher_comment") || undefined, + })); + const result = await service.gradeExam( + reqStr(req, "exam_id"), + reqStr(req, "submission_id"), + scores, + reqStr(req, "graded_by"), + ); + return { success: true, total_score: result.totalScore }; + }), + + // P3.13 新增 + SaveExamDraft: wrapHandler(async (req) => { + const answers = reqObjArr(req, "answers").map((a) => ({ + questionId: reqStr(a, "question_id"), + answer: reqStr(a, "answer"), + })); + const result = await service.saveExamDraft( + reqStr(req, "exam_id"), + reqStr(req, "student_id"), + answers, + ); + return { draft_id: result.draftId }; + }), + + RecordExamViolation: wrapHandler(async (req) => { + const result = await service.recordExamViolation( + reqStr(req, "exam_id"), + reqStr(req, "student_id"), + reqStr(req, "violation_type"), + reqStr(req, "detail"), + reqNum(req, "severity"), + ); + return { violation_id: result.violationId }; + }), + }; +} + +// ---------------------------------------------------------------------------- +// HomeworkService handlers(5 RPC) +// ---------------------------------------------------------------------------- + +function buildHomeworkHandlers( + service: HomeworkService, +): Record> { + return { + AssignHomework: wrapHandler(async (req) => { + const result = await service.assignHomework({ + classId: reqStr(req, "class_id"), + subjectId: reqStr(req, "subject_id"), + title: reqStr(req, "title"), + description: reqStr(req, "description") || undefined, + dueDate: reqStr(req, "due_date"), + gracePeriod: reqNum(req, "grace_period"), + schoolId: reqStr(req, "school_id"), + createdBy: reqStr(req, "created_by"), + }); + return { id: result.id }; + }), + + GetHomework: wrapHandler(async (req) => { + const hw = await service.getHomework(reqStr(req, "id")); + return toHomeworkProto(hw); + }), + + ListHomeworkByClass: wrapHandler(async (req) => { + const list = await service.listByClass(reqStr(req, "class_id")); + return { homework: list.map(toHomeworkProto) }; + }), + + SubmitHomework: wrapHandler(async (req) => { + const answers = reqObjArr(req, "answers").map((a) => ({ + questionId: reqStr(a, "question_id"), + answer: reqStr(a, "answer"), + })); + const result = await service.submitHomework( + reqStr(req, "homework_id"), + reqStr(req, "student_id"), + answers, + ); + return { submission_id: result.submissionId }; + }), + + GradeHomework: wrapHandler(async (req) => { + const scores = reqObjArr(req, "scores").map((s) => ({ + questionId: reqStr(s, "question_id"), + score: reqStr(s, "score"), + teacherComment: reqStr(s, "teacher_comment") || undefined, + })); + const result = await service.gradeHomework( + reqStr(req, "homework_id"), + reqStr(req, "submission_id"), + scores, + reqStr(req, "feedback") || undefined, + reqStr(req, "graded_by"), + ); + return { success: true, total_score: result.totalScore }; + }), + }; +} + +// ---------------------------------------------------------------------------- +// GradeService handlers(6 RPC) +// ---------------------------------------------------------------------------- + +function buildGradeHandlers( + service: GradesService, +): Record> { + return { + RecordGrade: wrapHandler(async (req) => { + const result = await service.recordGrade({ + studentId: reqStr(req, "student_id"), + examId: reqStr(req, "exam_id") || undefined, + homeworkId: reqStr(req, "homework_id") || undefined, + score: reqStr(req, "score"), + totalScore: reqStr(req, "total_score"), + feedback: reqStr(req, "feedback") || undefined, + gradedBy: reqStr(req, "graded_by"), + schoolId: reqStr(req, "school_id"), + idempotencyKey: reqStr(req, "idempotency_key") || undefined, + }); + return { id: result.id }; + }), + + GetGrade: wrapHandler(async (req) => { + const grade = await service.getGrade(reqStr(req, "id")); + return toGradeProto(grade); + }), + + ListGradesByStudent: wrapHandler(async (req) => { + const grades = await service.listByStudent(reqStr(req, "student_id")); + return { grades: grades.map(toGradeProto) }; + }), + + ListGradesByExam: wrapHandler(async (req) => { + const grades = await service.listByExam(reqStr(req, "exam_id")); + return { grades: grades.map(toGradeProto) }; + }), + + ListGradesByHomework: wrapHandler(async (req) => { + const grades = await service.listByHomework(reqStr(req, "homework_id")); + return { grades: grades.map(toGradeProto) }; + }), + + UpdateGrade: wrapHandler(async (req) => { + await service.updateGrade( + reqStr(req, "id"), + { + score: reqStr(req, "score") || undefined, + feedback: reqStr(req, "feedback") || undefined, + }, + reqStr(req, "updated_by"), + ); + return { success: true }; + }), + + // P3.13 新增 + GetReportCard: wrapHandler(async (req) => { + const reportCard = await service.getReportCard( + reqStr(req, "student_id"), + reqStr(req, "term_id") || undefined, + ); + return { + student_id: reportCard.studentId, + term_id: reportCard.termId, + entries: reportCard.entries.map((e) => ({ + subject_id: e.subjectId, + subject_name: e.subjectName, + exam_score: e.examScore, + exam_total: e.examTotal, + homework_score: e.homeworkScore, + homework_total: e.homeworkTotal, + final_score: e.finalScore, + grade_level: e.gradeLevel, + teacher_comment: e.teacherComment, + })), + overall_grade: reportCard.overallGrade, + class_rank: reportCard.classRank, + created_at: reportCard.createdAt, + }; + }), + }; +} + +// ---------------------------------------------------------------------------- +// ClassService handlers(4 RPC) +// ---------------------------------------------------------------------------- + +function buildClassHandlers( + service: ClassesService, +): Record> { + return { + GetClass: wrapHandler(async (req) => { + const cls = await service.getClass(reqStr(req, "id")); + return toClassProto(cls); + }), + + GetClassesByTeacher: wrapHandler(async (req) => { + const list = await service.getClassesByTeacher(reqStr(req, "teacher_id")); + return { classes: list.map(toClassProto) }; + }), + + BatchGetClasses: wrapHandler(async (req) => { + const list = await service.batchGetClasses(reqStrArr(req, "ids")); + return { classes: list.map(toClassProto) }; + }), + + ListStudentsByClass: wrapHandler(async (req) => { + const students = await service.listStudentsByClass( + reqStr(req, "class_id"), + ); + return { + students: students.map((s) => ({ + id: s.id, + name: s.name, + class_id: s.classId, + })), + }; + }), + }; +} + +// ---------------------------------------------------------------------------- +// AttendanceService handlers(4 RPC) +// ---------------------------------------------------------------------------- + +function buildAttendanceHandlers( + service: AttendanceService, +): Record> { + return { + RecordAttendance: wrapHandler(async (req) => { + const result = await service.recordAttendance({ + scheduleId: reqStr(req, "schedule_id"), + studentId: reqStr(req, "student_id"), + status: reqStr(req, "status"), + remark: reqStr(req, "remark") || undefined, + recordedBy: reqStr(req, "recorded_by"), + schoolId: reqStr(req, "school_id"), + }); + return { id: result.id }; + }), + + GetAttendance: wrapHandler(async (req) => { + const att = await service.getAttendance(reqStr(req, "id")); + return toAttendanceProto(att); + }), + + ListAttendanceByStudent: wrapHandler(async (req) => { + const list = await service.listByStudent(reqStr(req, "student_id")); + return { attendance: list.map(toAttendanceProto) }; + }), + + ListAttendanceByClass: wrapHandler(async (req) => { + const list = await service.listByClass(reqStr(req, "class_id")); + return { attendance: list.map(toAttendanceProto) }; + }), + }; +} + +// ---------------------------------------------------------------------------- +// ScheduleService handlers(P3.13 新增,1 RPC) +// ---------------------------------------------------------------------------- + +function buildScheduleHandlers( + service: SchedulingService, +): Record> { + return { + GetScheduleByStudent: wrapHandler(async (req) => { + const slots = await service.getScheduleByStudent( + reqStr(req, "student_id"), + reqStr(req, "week_start") || undefined, + ); + return { + slots: slots.map((s) => ({ + id: s.id, + course_id: s.courseId, + course_name: s.courseName, + teacher_id: s.teacherId, + class_id: s.classId, + room_id: s.roomId, + start_time: s.startTime, + end_time: s.endTime, + subject_id: s.subjectId, + })), + }; + }), + }; +} + +// ---------------------------------------------------------------------------- +// LeaveRequestService handlers(P3.13 新增,3 RPC) +// ---------------------------------------------------------------------------- + +function buildLeaveRequestHandlers( + service: LeaveRequestsService, +): Record> { + return { + ListLeaveRequestsByStudent: wrapHandler(async (req) => { + const list = await service.listByStudent( + reqStr(req, "student_id"), + reqStr(req, "status") || undefined, + ); + return { + leave_requests: list.map((lr) => ({ + id: lr.id, + student_id: lr.studentId, + class_id: lr.classId, + leave_type: lr.leaveType, + start_date: + lr.startDate instanceof Date + ? lr.startDate.toISOString().slice(0, 10) + : lr.startDate, + end_date: + lr.endDate instanceof Date + ? lr.endDate.toISOString().slice(0, 10) + : lr.endDate, + reason: lr.reason, + status: lr.status, + submitted_by: lr.submittedBy, + reviewed_by: str(lr.reviewedBy), + review_comment: str(lr.reviewComment), + school_id: lr.schoolId, + created_at: toIso(lr.createdAt), + updated_at: toIso(lr.updatedAt), + })), + }; + }), + + CreateLeaveRequest: wrapHandler(async (req) => { + const result = await service.create({ + studentId: reqStr(req, "student_id"), + classId: reqStr(req, "class_id"), + leaveType: reqStr(req, "leave_type"), + startDate: reqStr(req, "start_date"), + endDate: reqStr(req, "end_date"), + reason: reqStr(req, "reason"), + submittedBy: reqStr(req, "submitted_by"), + schoolId: reqStr(req, "school_id"), + }); + return { id: result.id }; + }), + + CancelLeaveRequest: wrapHandler(async (req) => { + await service.cancel(reqStr(req, "id"), reqStr(req, "cancelled_by")); + return { success: true }; + }), + }; +} + +// ---------------------------------------------------------------------------- +// DashboardService handlers(P3.13 新增,2 RPC) +// ---------------------------------------------------------------------------- + +function buildDashboardHandlers( + service: DashboardService, +): Record> { + return { + GetDashboard: wrapHandler(async (req) => { + const data = await service.getDashboard(reqStr(req, "teacher_id")); + return { + teacher_id: data.teacherId, + total_classes: data.totalClasses, + total_students: data.totalStudents, + pending_homework: data.pendingHomework, + upcoming_exams: data.upcomingExams, + ungraded_submissions: data.ungradedSubmissions, + classes: data.classes.map((c) => ({ + class_id: c.classId, + class_name: c.className, + student_count: c.studentCount, + })), + upcoming_exam_list: data.upcomingExamList.map((e) => ({ + exam_id: e.examId, + title: e.title, + exam_date: e.examDate, + class_id: e.classId, + class_name: e.className, + })), + generated_at: data.generatedAt, + }; + }), + + GetClassPerformance: wrapHandler(async (req) => { + const perf = await service.getClassPerformance( + reqStr(req, "class_id"), + reqStr(req, "subject_id") || undefined, + ); + return { + class_id: perf.classId, + class_name: perf.className, + student_count: perf.studentCount, + average_score: perf.averageScore, + highest_score: perf.highestScore, + lowest_score: perf.lowestScore, + median_score: perf.medianScore, + subjects: perf.subjects.map((s) => ({ + subject_id: s.subjectId, + subject_name: s.subjectName, + average_score: s.averageScore, + student_count: s.studentCount, + })), + generated_at: perf.generatedAt, + }; + }), + }; +} + +// ---------------------------------------------------------------------------- +// AdminService handlers(P3.13 新增,4 RPC - aggregation stubs) +// ---------------------------------------------------------------------------- + +function buildAdminHandlers( + service: AdminService, +): Record> { + return { + ListSchools: wrapHandler(async () => { + const schools = await service.listSchools(); + return { + schools: schools.map((s) => ({ + id: s.id, + name: s.name, + address: s.address, + principal_id: s.principalId, + created_at: s.createdAt, + })), + }; + }), + + ListGradeLevels: wrapHandler(async (req) => { + const levels = await service.listGradeLevels(reqStr(req, "school_id")); + return { + grade_levels: levels.map((l) => ({ + id: l.id, + name: l.name, + school_id: l.schoolId, + order: l.order, + })), + }; + }), + + ListDepartments: wrapHandler(async (req) => { + const depts = await service.listDepartments(reqStr(req, "school_id")); + return { + departments: depts.map((d) => ({ + id: d.id, + name: d.name, + school_id: d.schoolId, + head_id: d.headId, + created_at: d.createdAt, + })), + }; + }), + + ListAcademicYears: wrapHandler(async (req) => { + const years = await service.listAcademicYears( + reqStr(req, "school_id") || undefined, + ); + return { + academic_years: years.map((y) => ({ + id: y.id, + name: y.name, + school_id: y.schoolId, + start_date: y.startDate, + end_date: y.endDate, + is_current: y.isCurrent, + })), + }; + }), + }; +} + +// ---------------------------------------------------------------------------- +// 启动 / 停止 +// ---------------------------------------------------------------------------- + +/** + * 启动 gRPC server,监听 env.GRPC_PORT(默认 50053)。 + * 通过 NestJS application context 获取各 Service 实例并注册 handler。 + */ +export async function startGrpcServer( + app: INestApplicationContext, +): Promise { + const protoPath = resolveProtoPath(); + const packageDefinition = protoLoader.loadSync(protoPath, PROTO_OPTIONS); + const protoDescriptor = grpc.loadPackageDefinition(packageDefinition); + + const pkg = getCoreEduPackage(protoDescriptor); + + // 从 NestJS 容器获取 service 实例 + const examsService = app.get(ExamsService); + const homeworkService = app.get(HomeworkService); + const gradesService = app.get(GradesService); + const classesService = app.get(ClassesService); + const attendanceService = app.get(AttendanceService); + const schedulingService = app.get(SchedulingService); + const leaveRequestsService = app.get(LeaveRequestsService); + const dashboardService = app.get(DashboardService); + const adminService = app.get(AdminService); + + const server = new grpc.Server(); + server.addService( + getServiceDefinition(pkg, "ExamService"), + buildExamHandlers(examsService), + ); + server.addService( + getServiceDefinition(pkg, "HomeworkService"), + buildHomeworkHandlers(homeworkService), + ); + server.addService( + getServiceDefinition(pkg, "GradeService"), + buildGradeHandlers(gradesService), + ); + server.addService( + getServiceDefinition(pkg, "ClassService"), + buildClassHandlers(classesService), + ); + server.addService( + getServiceDefinition(pkg, "AttendanceService"), + buildAttendanceHandlers(attendanceService), + ); + // P3.13 新增 4 个服务 + server.addService( + getServiceDefinition(pkg, "ScheduleService"), + buildScheduleHandlers(schedulingService), + ); + server.addService( + getServiceDefinition(pkg, "LeaveRequestService"), + buildLeaveRequestHandlers(leaveRequestsService), + ); + server.addService( + getServiceDefinition(pkg, "DashboardService"), + buildDashboardHandlers(dashboardService), + ); + server.addService( + getServiceDefinition(pkg, "AdminService"), + buildAdminHandlers(adminService), + ); + + const address = `0.0.0.0:${env.GRPC_PORT}`; + await new Promise((resolve, reject) => { + server.bindAsync( + address, + grpc.ServerCredentials.createInsecure(), + (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }, + ); + }); + + grpcServer = server; + logger.info( + { port: env.GRPC_PORT, protoPath, service: "core-edu" }, + "gRPC server is listening", + ); +} + +/** + * 优雅停止 gRPC server。 + */ +export async function stopGrpcServer(): Promise { + const server = grpcServer; + if (!server) { + return; + } + await new Promise((resolve, reject) => { + server.tryShutdown((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + grpcServer = null; + logger.info({ service: "core-edu" }, "gRPC server stopped"); +} diff --git a/services/core-edu/src/homework/homework-state-machine.test.ts b/services/core-edu/src/homework/homework-state-machine.test.ts new file mode 100644 index 0000000..f6405ff --- /dev/null +++ b/services/core-edu/src/homework/homework-state-machine.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { + canTransition, + transition, + isTerminal, + canTransitionSubmission, + transitionSubmission, + HOMEWORK_STATUSES, + SUBMISSION_STATUSES, +} from "./homework-state-machine.js"; + +describe("homework-state-machine", () => { + describe("HomeworkStatus 状态机", () => { + describe("合法状态转换", () => { + it("assigned --submit--> submitted", () => { + expect(transition("assigned", "submit")).toBe("submitted"); + }); + + it("submitted --grade--> graded", () => { + expect(transition("submitted", "grade")).toBe("graded"); + }); + }); + + describe("非法状态转换", () => { + it("assigned --grade--> 抛错(必须先 submit)", () => { + expect(() => transition("assigned", "grade")).toThrow(); + }); + + it("submitted --submit--> 抛错(不能重复提交)", () => { + expect(() => transition("submitted", "submit")).toThrow(); + }); + + it("graded --submit--> 抛错(终态)", () => { + expect(() => transition("graded", "submit")).toThrow(); + }); + + it("graded --grade--> 抛错(终态)", () => { + expect(() => transition("graded", "grade")).toThrow(); + }); + + it("抛错信息包含非法转换描述", () => { + expect(() => transition("assigned", "grade")).toThrow( + /Invalid homework state transition/, + ); + }); + }); + + describe("canTransition", () => { + it("合法转换返回 true", () => { + expect(canTransition("assigned", "submit")).toBe(true); + expect(canTransition("submitted", "grade")).toBe(true); + }); + + it("非法转换返回 false", () => { + expect(canTransition("assigned", "grade")).toBe(false); + expect(canTransition("submitted", "submit")).toBe(false); + expect(canTransition("graded", "submit")).toBe(false); + expect(canTransition("graded", "grade")).toBe(false); + }); + }); + + describe("isTerminal", () => { + it("graded 是终态", () => { + expect(isTerminal("graded")).toBe(true); + }); + + it("assigned/submitted 不是终态", () => { + expect(isTerminal("assigned")).toBe(false); + expect(isTerminal("submitted")).toBe(false); + }); + }); + }); + + describe("SubmissionStatus 状态机", () => { + describe("合法状态转换", () => { + it("not_submitted --submit--> submitted", () => { + expect(transitionSubmission("not_submitted", "submit")).toBe( + "submitted", + ); + }); + + it("submitted --grade--> graded", () => { + expect(transitionSubmission("submitted", "grade")).toBe("graded"); + }); + }); + + describe("非法状态转换", () => { + it("not_submitted --grade--> 抛错(必须先 submit)", () => { + expect(() => transitionSubmission("not_submitted", "grade")).toThrow(); + }); + + it("submitted --submit--> 抛错(不能重复提交)", () => { + expect(() => transitionSubmission("submitted", "submit")).toThrow(); + }); + + it("graded --submit--> 抛错(终态)", () => { + expect(() => transitionSubmission("graded", "submit")).toThrow(); + }); + + it("graded --grade--> 抛错(终态)", () => { + expect(() => transitionSubmission("graded", "grade")).toThrow(); + }); + + it("抛错信息包含非法转换描述", () => { + expect(() => transitionSubmission("not_submitted", "grade")).toThrow( + /Invalid submission state transition/, + ); + }); + }); + + describe("canTransitionSubmission", () => { + it("合法转换返回 true", () => { + expect(canTransitionSubmission("not_submitted", "submit")).toBe(true); + expect(canTransitionSubmission("submitted", "grade")).toBe(true); + }); + + it("非法转换返回 false", () => { + expect(canTransitionSubmission("not_submitted", "grade")).toBe(false); + expect(canTransitionSubmission("submitted", "submit")).toBe(false); + expect(canTransitionSubmission("graded", "submit")).toBe(false); + expect(canTransitionSubmission("graded", "grade")).toBe(false); + }); + }); + }); + + describe("状态常量", () => { + it("HOMEWORK_STATUSES 包含 3 种状态", () => { + expect(HOMEWORK_STATUSES).toHaveLength(3); + expect([...HOMEWORK_STATUSES]).toContain("assigned"); + expect([...HOMEWORK_STATUSES]).toContain("submitted"); + expect([...HOMEWORK_STATUSES]).toContain("graded"); + }); + + it("SUBMISSION_STATUSES 包含 3 种状态", () => { + expect(SUBMISSION_STATUSES).toHaveLength(3); + expect([...SUBMISSION_STATUSES]).toContain("not_submitted"); + expect([...SUBMISSION_STATUSES]).toContain("submitted"); + expect([...SUBMISSION_STATUSES]).toContain("graded"); + }); + }); +}); diff --git a/services/core-edu/src/leave-requests/leave-requests.module.ts b/services/core-edu/src/leave-requests/leave-requests.module.ts new file mode 100644 index 0000000..d6e3c31 --- /dev/null +++ b/services/core-edu/src/leave-requests/leave-requests.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { LeaveRequestsService } from "./leave-requests.service.js"; + +@Module({ + providers: [LeaveRequestsService], + exports: [LeaveRequestsService], +}) +export class LeaveRequestsModule {} diff --git a/services/core-edu/src/leave-requests/leave-requests.repository.ts b/services/core-edu/src/leave-requests/leave-requests.repository.ts new file mode 100644 index 0000000..0a4ae79 --- /dev/null +++ b/services/core-edu/src/leave-requests/leave-requests.repository.ts @@ -0,0 +1,54 @@ +import { eq, and, desc } from "drizzle-orm/expressions"; +import { db } from "../config/database.js"; +import { leaveRequests } from "./leave-requests.schema.js"; +import type { LeaveRequest, NewLeaveRequest } from "./leave-requests.schema.js"; + +export const leaveRequestRepository = { + async create(record: NewLeaveRequest): Promise { + await db.insert(leaveRequests).values(record); + }, + + async findById(id: string): Promise { + const rows = await db + .select() + .from(leaveRequests) + .where(eq(leaveRequests.id, id)) + .limit(1); + return rows[0]; + }, + + async findByStudentId( + studentId: string, + status?: string, + ): Promise { + if (status) { + return db + .select() + .from(leaveRequests) + .where( + and( + eq(leaveRequests.studentId, studentId), + eq(leaveRequests.status, status), + ), + ) + .orderBy(desc(leaveRequests.createdAt)); + } + return db + .select() + .from(leaveRequests) + .where(eq(leaveRequests.studentId, studentId)) + .orderBy(desc(leaveRequests.createdAt)); + }, + + async updateStatus( + id: string, + status: string, + reviewedBy?: string, + reviewComment?: string, + ): Promise { + const updates: Partial = { status }; + if (reviewedBy !== undefined) updates.reviewedBy = reviewedBy; + if (reviewComment !== undefined) updates.reviewComment = reviewComment; + await db.update(leaveRequests).set(updates).where(eq(leaveRequests.id, id)); + }, +}; diff --git a/services/core-edu/src/leave-requests/leave-requests.schema.ts b/services/core-edu/src/leave-requests/leave-requests.schema.ts new file mode 100644 index 0000000..f5cce17 --- /dev/null +++ b/services/core-edu/src/leave-requests/leave-requests.schema.ts @@ -0,0 +1,43 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + char, + date, + index, +} from "drizzle-orm/mysql-core"; + +// 请假申请表(P3.13 新增) +export const leaveRequests = mysqlTable( + "core_edu_leave_requests", + { + id: char("id", { length: 36 }).notNull().primaryKey(), + studentId: char("student_id", { length: 36 }).notNull(), + classId: char("class_id", { length: 36 }).notNull(), + leaveType: varchar("leave_type", { length: 20 }).notNull(), + startDate: date("start_date").notNull(), + endDate: date("end_date").notNull(), + reason: text("reason").notNull(), + status: varchar("status", { length: 20 }).notNull().default("pending"), + submittedBy: char("submitted_by", { length: 36 }).notNull(), + reviewedBy: char("reviewed_by", { length: 36 }), + reviewComment: text("review_comment"), + schoolId: char("school_id", { length: 36 }).notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(), + }, + (table) => ({ + idxLeaveRequestsStudent: index("idx_leave_requests_student").on( + table.studentId, + ), + idxLeaveRequestsClass: index("idx_leave_requests_class").on(table.classId), + idxLeaveRequestsStatus: index("idx_leave_requests_status").on(table.status), + idxLeaveRequestsSchool: index("idx_leave_requests_school").on( + table.schoolId, + ), + }), +); + +export type LeaveRequest = typeof leaveRequests.$inferSelect; +export type NewLeaveRequest = typeof leaveRequests.$inferInsert; diff --git a/services/core-edu/src/leave-requests/leave-requests.service.ts b/services/core-edu/src/leave-requests/leave-requests.service.ts new file mode 100644 index 0000000..3f34f53 --- /dev/null +++ b/services/core-edu/src/leave-requests/leave-requests.service.ts @@ -0,0 +1,94 @@ +import { randomUUID } from "node:crypto"; +import { Injectable } from "@nestjs/common"; +import { leaveRequestRepository } from "./leave-requests.repository.js"; +import { + NotFoundError, + ValidationError, + ConflictError, +} from "../shared/errors/application-error.js"; +import type { LeaveRequest, NewLeaveRequest } from "./leave-requests.schema.js"; + +export interface CreateLeaveRequestInput { + studentId: string; + classId: string; + leaveType: string; + startDate: string; + endDate: string; + reason: string; + submittedBy: string; + schoolId: string; +} + +@Injectable() +export class LeaveRequestsService { + async listByStudent( + studentId: string, + status?: string, + ): Promise { + if (!studentId) { + throw new ValidationError("studentId is required"); + } + return leaveRequestRepository.findByStudentId(studentId, status); + } + + async create(input: CreateLeaveRequestInput): Promise<{ id: string }> { + if ( + !input.studentId || + !input.classId || + !input.leaveType || + !input.startDate || + !input.endDate || + !input.reason || + !input.submittedBy || + !input.schoolId + ) { + throw new ValidationError( + "studentId, classId, leaveType, startDate, endDate, reason, submittedBy, schoolId are required", + ); + } + + const start = new Date(input.startDate); + const end = new Date(input.endDate); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + throw new ValidationError( + "startDate and endDate must be valid ISO dates", + ); + } + if (start > end) { + throw new ValidationError("startDate must not be after endDate"); + } + + const id = randomUUID(); + const record: NewLeaveRequest = { + id, + studentId: input.studentId, + classId: input.classId, + leaveType: input.leaveType, + startDate: start, + endDate: end, + reason: input.reason, + status: "pending", + submittedBy: input.submittedBy, + schoolId: input.schoolId, + }; + await leaveRequestRepository.create(record); + return { id }; + } + + async cancel(id: string, cancelledBy: string): Promise { + if (!id || !cancelledBy) { + throw new ValidationError("id and cancelledBy are required"); + } + const existing = await leaveRequestRepository.findById(id); + if (!existing) { + throw new NotFoundError(`LeaveRequest ${id} not found`); + } + // 状态机:pending -> cancelled;approved/rejected/cancelled 不允许再取消 + if (existing.status !== "pending") { + throw new ConflictError( + `Cannot cancel leave request in status ${existing.status} (only pending allows cancel)`, + ); + } + await leaveRequestRepository.updateStatus(id, "cancelled", cancelledBy); + } +} diff --git a/services/core-edu/src/main.ts b/services/core-edu/src/main.ts index af476fe..23e8944 100644 --- a/services/core-edu/src/main.ts +++ b/services/core-edu/src/main.ts @@ -8,6 +8,7 @@ import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js"; import { initTracer, shutdownTracer } from "./shared/observability/tracer.js"; import { logger } from "./shared/observability/logger.js"; import { registry } from "./shared/observability/metrics.js"; +import { startGrpcServer, stopGrpcServer } from "./grpc/grpc.server.js"; import type { Request, Response } from "express"; async function bootstrap(): Promise { @@ -38,13 +39,18 @@ async function bootstrap(): Promise { await outboxPublisher.start(); await app.listen(env.PORT); + + // 启动 gRPC server(9 Service / 40 RPC) + await startGrpcServer(app); + logger.info( { port: env.PORT, grpcPort: env.GRPC_PORT, service: "core-edu" }, - "CoreEdu service is listening (HTTP; gRPC port reserved for P3)", + "CoreEdu service is listening (HTTP + gRPC)", ); const shutdown = async (signal: string): Promise => { logger.info({ signal }, "Shutting down gracefully..."); + await stopGrpcServer(); await outboxPublisher.stop(); await disconnectRedis(); await disconnectKafka(); diff --git a/services/core-edu/src/scheduling/schedule-conflict.test.ts b/services/core-edu/src/scheduling/schedule-conflict.test.ts new file mode 100644 index 0000000..ef9abf1 --- /dev/null +++ b/services/core-edu/src/scheduling/schedule-conflict.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from "vitest"; +import { isTimeOverlap, detectConflict } from "./schedule-conflict.js"; +import type { ScheduleSlot } from "./schedule-conflict.js"; + +// 构造时间辅助函数(基于固定日期,UTC) +function at(hour: number, minute: number = 0): Date { + return new Date( + `2026-07-13T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00Z`, + ); +} + +// 构造排课槽位的工厂函数 +function makeSlot( + overrides: Partial & { id: string }, +): ScheduleSlot { + return { + teacherId: "t1", + classId: "c1", + startTime: at(10), + endTime: at(11), + ...overrides, + }; +} + +describe("schedule-conflict", () => { + describe("isTimeOverlap", () => { + it("完全重叠返回 true", () => { + expect(isTimeOverlap(at(10), at(11), at(10), at(11))).toBe(true); + }); + + it("部分重叠返回 true", () => { + expect(isTimeOverlap(at(10), at(11), at(10, 30), at(11, 30))).toBe(true); + }); + + it("包含关系返回 true", () => { + expect(isTimeOverlap(at(9), at(12), at(10), at(11))).toBe(true); + }); + + it("不重叠返回 false", () => { + expect(isTimeOverlap(at(8), at(9), at(10), at(11))).toBe(false); + }); + + it("刚好接续(endA == startB)返回 false", () => { + expect(isTimeOverlap(at(10), at(11), at(11), at(12))).toBe(false); + }); + + it("刚好接续反向(endB == startA)返回 false", () => { + expect(isTimeOverlap(at(11), at(12), at(10), at(11))).toBe(false); + }); + }); + + describe("detectConflict", () => { + it("无现有排课时不冲突", () => { + const newSlot = makeSlot({ id: "new1" }); + expect(detectConflict(newSlot, [])).toEqual({ + hasConflict: false, + conflictType: null, + }); + }); + + it("同一教师同一时间段冲突(conflictType 为 teacher)", () => { + const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" }); + const newSlot = makeSlot({ + id: "new1", + teacherId: "t1", + classId: "c-other", + startTime: at(10, 30), + endTime: at(11, 30), + }); + const result = detectConflict(newSlot, [existing]); + expect(result.hasConflict).toBe(true); + expect(result.conflictType).toBe("teacher"); + expect(result.conflictingSlot).toBe(existing); + }); + + it("同一班级同一时间段冲突(conflictType 为 class)", () => { + const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" }); + const newSlot = makeSlot({ + id: "new1", + teacherId: "t-other", + classId: "c1", + startTime: at(10, 30), + endTime: at(11, 30), + }); + const result = detectConflict(newSlot, [existing]); + expect(result.hasConflict).toBe(true); + expect(result.conflictType).toBe("class"); + expect(result.conflictingSlot).toBe(existing); + }); + + it("不同教师不同班级不冲突(即使时间重叠)", () => { + const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" }); + const newSlot = makeSlot({ + id: "new1", + teacherId: "t2", + classId: "c2", + startTime: at(10, 30), + endTime: at(11, 30), + }); + const result = detectConflict(newSlot, [existing]); + expect(result.hasConflict).toBe(false); + expect(result.conflictType).toBe(null); + }); + + it("不同教师不冲突", () => { + const existing = makeSlot({ id: "e1", teacherId: "t1", classId: "c1" }); + const newSlot = makeSlot({ + id: "new1", + teacherId: "t2", + classId: "c2", + }); + expect(detectConflict(newSlot, [existing]).hasConflict).toBe(false); + }); + + it("不同班级不冲突", () => { + const existing = makeSlot({ + id: "e1", + teacherId: "t1", + classId: "c1", + startTime: at(10), + endTime: at(12), + }); + const newSlot = makeSlot({ + id: "new1", + teacherId: "t2", + classId: "c2", + startTime: at(10, 30), + endTime: at(11, 30), + }); + expect(detectConflict(newSlot, [existing]).hasConflict).toBe(false); + }); + + it("边界情况:刚好接续不冲突", () => { + const existing = makeSlot({ + id: "e1", + teacherId: "t1", + classId: "c1", + startTime: at(10), + endTime: at(11), + }); + const newSlot = makeSlot({ + id: "new1", + teacherId: "t1", + classId: "c1", + startTime: at(11), // 接续,不重叠 + endTime: at(12), + }); + const result = detectConflict(newSlot, [existing]); + expect(result.hasConflict).toBe(false); + }); + + it("excludeId 排除自身(更新场景)", () => { + const existing = makeSlot({ + id: "e1", + teacherId: "t1", + classId: "c1", + startTime: at(10), + endTime: at(11), + }); + const newSlot = makeSlot({ + id: "e1", // 同一 id(更新场景) + teacherId: "t1", + classId: "c1", + startTime: at(10), + endTime: at(11), + }); + // 不排除时会冲突 + expect(detectConflict(newSlot, [existing]).hasConflict).toBe(true); + // 排除自身后不冲突 + expect(detectConflict(newSlot, [existing], "e1").hasConflict).toBe(false); + }); + + it("多个现有排课时跳过无冲突项并返回首个冲突", () => { + const nonConflicting = makeSlot({ + id: "e1", + teacherId: "t-other", + classId: "c-other", + startTime: at(10, 30), + endTime: at(11, 30), + }); + const conflicting = makeSlot({ + id: "e2", + teacherId: "t1", + classId: "c1", + startTime: at(10, 30), + endTime: at(11, 30), + }); + const newSlot = makeSlot({ + id: "new1", + teacherId: "t1", + classId: "c1", + startTime: at(10, 30), + endTime: at(11, 30), + }); + const result = detectConflict(newSlot, [nonConflicting, conflicting]); + expect(result.hasConflict).toBe(true); + expect(result.conflictType).toBe("teacher"); + expect(result.conflictingSlot).toBe(conflicting); + }); + }); +}); diff --git a/services/core-edu/src/scheduling/scheduling.service.ts b/services/core-edu/src/scheduling/scheduling.service.ts index ba5fa25..d4a44b2 100644 --- a/services/core-edu/src/scheduling/scheduling.service.ts +++ b/services/core-edu/src/scheduling/scheduling.service.ts @@ -1,6 +1,10 @@ import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; import { Injectable } from "@nestjs/common"; +import { db } from "../config/database.js"; import { schedulingRepository } from "./scheduling.repository.js"; +import { courses, schedules } from "./scheduling.schema.js"; +import { attendance } from "../attendance/attendance.schema.js"; import { detectConflict, type ScheduleSlot } from "./schedule-conflict.js"; import { NotFoundError, @@ -10,6 +14,18 @@ import { } from "../shared/errors/application-error.js"; import type { Course, Schedule } from "./scheduling.schema.js"; +export interface ScheduleSlotInfo { + id: string; + courseId: string; + courseName: string; + teacherId: string; + classId: string; + roomId: string; + startTime: string; + endTime: string; + subjectId: string; +} + export interface CreateCourseInput { classId: string; subjectId: string; @@ -161,4 +177,89 @@ export class SchedulingService { async listSchedulesByClass(classId: string): Promise { return schedulingRepository.findSchedulesByClassId(classId); } + + // -------------------------------------------------------------------------- + // P3.13 新增:按学生查询课表(通过考勤记录反查 class_id,再查该班所有排课) + // -------------------------------------------------------------------------- + + async getScheduleByStudent( + studentId: string, + weekStart?: string, + ): Promise { + if (!studentId) { + throw new ValidationError("studentId is required"); + } + + // 1. 从考勤记录中查找该学生关联的 schedule_id + const attendanceRows = await db + .select({ scheduleId: attendance.scheduleId }) + .from(attendance) + .where(eq(attendance.studentId, studentId)); + + if (attendanceRows.length === 0) { + return []; + } + + const scheduleIds = attendanceRows.map((r) => r.scheduleId); + + // 2. 查找这些 schedule,提取 class_id + const studentSchedules = await db + .select({ classId: schedules.classId }) + .from(schedules) + .where(inArray(schedules.id, scheduleIds)); + + const classIds = [...new Set(studentSchedules.map((s) => s.classId))]; + if (classIds.length === 0) { + return []; + } + + // 3. 查找这些 class 的所有排课(含未来排课) + let allSchedules = await db + .select() + .from(schedules) + .where(inArray(schedules.classId, classIds)); + + // 4. 可选:按 week_start 过滤(该周起始日之后 7 天内) + if (weekStart) { + const weekStartDate = new Date(weekStart); + if (!Number.isNaN(weekStartDate.getTime())) { + const weekEndDate = new Date(weekStartDate); + weekEndDate.setDate(weekEndDate.getDate() + 7); + allSchedules = allSchedules.filter((s) => { + const start = new Date(s.startTime); + return start >= weekStartDate && start < weekEndDate; + }); + } + } + + if (allSchedules.length === 0) { + return []; + } + + // 5. 关联课程表获取 course_name 和 subject_id + const courseIds = [...new Set(allSchedules.map((s) => s.courseId))]; + const courseRows = await db + .select() + .from(courses) + .where(inArray(courses.id, courseIds)); + const courseMap = new Map(courseRows.map((c) => [c.id, c])); + + // 6. 构造返回结果 + return allSchedules.map((s) => { + const course = courseMap.get(s.courseId); + return { + id: s.id, + courseId: s.courseId, + courseName: course?.name ?? "", + teacherId: s.teacherId, + classId: s.classId, + roomId: s.roomId ?? "", + startTime: + s.startTime instanceof Date ? s.startTime.toISOString() : s.startTime, + endTime: + s.endTime instanceof Date ? s.endTime.toISOString() : s.endTime, + subjectId: course?.subjectId ?? "", + }; + }); + } } diff --git a/services/core-edu/src/shared/datascope/datascope-injector.ts b/services/core-edu/src/shared/datascope/datascope-injector.ts new file mode 100644 index 0000000..a79405c --- /dev/null +++ b/services/core-edu/src/shared/datascope/datascope-injector.ts @@ -0,0 +1,73 @@ +import { and, eq, inArray } from "drizzle-orm"; +import type { Column, SQL } from "drizzle-orm"; + +// 数据范围类型 +export type DataScope = + | "SELF" // 仅本人 + | "CLASS" // 班级 + | "GRADE" // 年级 + | "SCHOOL" // 学校 + | "DISTRICT" // 学区 + | "ALL"; // 全部 + +// 数据范围上下文,由请求头解析得到 +export interface DataScopeContext { + scope: DataScope; + userId: string; + classIds?: string[]; // 教师关联的班级 ID 列表 + schoolId?: string; +} + +// 数据范围列映射,指定各维度对应的表列 +export interface DataScopeColumns { + studentId?: Column; + classId?: Column; + gradeId?: Column; + schoolId?: Column; +} + +// 根据数据范围上下文构建 WHERE 条件 +// 返回 SQL 表达式或 undefined(无需过滤时) +export function buildDataScopeCondition( + ctx: DataScopeContext, + columns: DataScopeColumns, +): SQL | undefined { + switch (ctx.scope) { + case "SELF": + // 学生只能看自己的数据,按 student_id 过滤 + if (!columns.studentId) return undefined; + return eq(columns.studentId, ctx.userId); + case "CLASS": + // 教师只能看自己班级的数据,按 class_id 过滤 + if (!columns.classId || !ctx.classIds?.length) return undefined; + return inArray(columns.classId, ctx.classIds); + case "GRADE": + // 按年级过滤,按 grade_id 过滤 + // 当前上下文未提供年级 ID,暂不加过滤 + return undefined; + case "SCHOOL": + // 按学校过滤,按 school_id 过滤 + if (!columns.schoolId || !ctx.schoolId) return undefined; + return eq(columns.schoolId, ctx.schoolId); + case "DISTRICT": + case "ALL": + // 区级和全局范围不加过滤 + return undefined; + default: + return undefined; + } +} + +// 将数据范围条件注入到基础条件中,返回组合后的 WHERE 条件 +// baseCondition: 已有的 WHERE 条件(可选) +// ctx: 数据范围上下文(可选,为空时返回基础条件) +// columns: 数据范围列映射 +export function injectDataScope( + baseCondition: SQL | undefined, + ctx: DataScopeContext | undefined, + columns: DataScopeColumns, +): SQL | undefined { + if (!ctx) return baseCondition; + const scopeCondition = buildDataScopeCondition(ctx, columns); + return and(baseCondition, scopeCondition); +} diff --git a/services/core-edu/test/grpc-smoke.mjs b/services/core-edu/test/grpc-smoke.mjs new file mode 100644 index 0000000..75f453e --- /dev/null +++ b/services/core-edu/test/grpc-smoke.mjs @@ -0,0 +1,190 @@ +// gRPC smoke test for core-edu service (P3.13) +// 运行: node services/core-edu/test/grpc-smoke.mjs +// 前提: edu-core-edu-test 容器已启动,gRPC 端口 50053 可达 +import grpc from "@grpc/grpc-js"; +import protoLoader from "@grpc/proto-loader"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PROTO_PATH = path.resolve(__dirname, "../../../packages/shared-proto/proto/core_edu.proto"); +const packageDef = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, +}); +const proto = grpc.loadPackageDefinition(packageDef); + +const TARGET = "localhost:50053"; +const clientMap = {}; + +const services = [ + "ExamService", + "HomeworkService", + "GradeService", + "ClassService", + "AttendanceService", + "ScheduleService", + "LeaveRequestService", + "DashboardService", + "AdminService", +]; + +for (const s of services) { + const Ctor = proto.next_edu_cloud.core_edu.v1[s]; + if (!Ctor) { + console.error(`MISSING service: ${s}`); + continue; + } + clientMap[s] = new Ctor(TARGET, grpc.credentials.createInsecure()); +} + +const results = { pass: [], fail: [] }; +function record(name, ok, detail) { + const entry = { rpc: name, ok, detail: detail ? String(detail).slice(0, 300) : undefined }; + if (ok) results.pass.push(entry); + else results.fail.push(entry); + const tag = ok ? "PASS" : "FAIL"; + console.log(`[${tag}] ${name}${detail ? " :: " + entry.detail : ""}`); +} + +function call(client, method, req) { + return new Promise((resolve) => { + if (!client) { + resolve({ error: new Error("client missing") }); + return; + } + if (typeof client[method] !== "function") { + resolve({ error: new Error(`method ${method} not found`) }); + return; + } + client[method](req, (error, response) => { + resolve({ error, response }); + }); + }); +} + +async function main() { + console.log(`\n=== core-edu gRPC smoke test @ ${TARGET} ===\n`); + + // ---- ExamService ---- + let r; + + r = await call(clientMap.ExamService, "ListExamsByClass", { class_id: "cls-nonexistent" }); + record("ExamService.ListExamsByClass", !r.error, r.error?.message || `exams=${r.response?.exams?.length ?? 0}`); + + r = await call(clientMap.ExamService, "GetExam", { id: "exam-nonexistent" }); + // NotFound 是正常的业务错误(说明 RPC 可达且执行了) + record("ExamService.GetExam", !r.error || r.error.code === grpc.status.NOT_FOUND, r.error?.message || "ok"); + + r = await call(clientMap.ExamService, "SaveExamDraft", { exam_id: "1b7f27aa-f45d-495d-b74f-cecbd0230e2e", student_id: "stu-1", answers: [] }); + record("ExamService.SaveExamDraft", !r.error, r.error?.message || `draftId=${r.response?.draftId}`); + + r = await call(clientMap.ExamService, "RecordExamViolation", { exam_id: "1b7f27aa-f45d-495d-b74f-cecbd0230e2e", student_id: "stu-1", violation_type: "tab_switch", detail: "test", severity: 1 }); + record("ExamService.RecordExamViolation", !r.error, r.error?.message || `violationId=${r.response?.violationId}`); + + // ---- HomeworkService ---- + r = await call(clientMap.HomeworkService, "ListHomeworkByClass", { class_id: "cls-nonexistent" }); + record("HomeworkService.ListHomeworkByClass", !r.error, r.error?.message || `homework=${r.response?.homework?.length ?? 0}`); + + r = await call(clientMap.HomeworkService, "GetHomework", { id: "hw-nonexistent" }); + record("HomeworkService.GetHomework", !r.error || r.error.code === grpc.status.NOT_FOUND, r.error?.message || "ok"); + + // ---- GradeService ---- + r = await call(clientMap.GradeService, "ListGradesByStudent", { student_id: "stu-nonexistent" }); + record("GradeService.ListGradesByStudent", !r.error, r.error?.message || `grades=${r.response?.grades?.length ?? 0}`); + + r = await call(clientMap.GradeService, "GetReportCard", { student_id: "stu-nonexistent" }); + record("GradeService.GetReportCard", !r.error, r.error?.message || `overallGrade=${r.response?.overallGrade}`); + + // ---- ClassService ---- + r = await call(clientMap.ClassService, "GetClass", { id: "cls-nonexistent" }); + record("ClassService.GetClass", !r.error || r.error.code === grpc.status.NOT_FOUND, r.error?.message || "ok"); + + r = await call(clientMap.ClassService, "GetClassesByTeacher", { teacher_id: "t-nonexistent" }); + record("ClassService.GetClassesByTeacher", !r.error, r.error?.message || `classes=${r.response?.classes?.length ?? 0}`); + + r = await call(clientMap.ClassService, "BatchGetClasses", { ids: [] }); + record("ClassService.BatchGetClasses", !r.error, r.error?.message || `classes=${r.response?.classes?.length ?? 0}`); + + r = await call(clientMap.ClassService, "ListStudentsByClass", { class_id: "cls-nonexistent" }); + record("ClassService.ListStudentsByClass", !r.error || r.error.code === grpc.status.NOT_FOUND, r.error?.message || `students=${r.response?.students?.length ?? 0}`); + + // ---- AttendanceService ---- + r = await call(clientMap.AttendanceService, "ListAttendanceByStudent", { student_id: "stu-nonexistent" }); + record("AttendanceService.ListAttendanceByStudent", !r.error, r.error?.message || `attendance=${r.response?.attendance?.length ?? 0}`); + + r = await call(clientMap.AttendanceService, "ListAttendanceByClass", { class_id: "cls-nonexistent" }); + record("AttendanceService.ListAttendanceByClass", !r.error, r.error?.message || `attendance=${r.response?.attendance?.length ?? 0}`); + + r = await call(clientMap.AttendanceService, "GetAttendance", { id: "att-nonexistent" }); + record("AttendanceService.GetAttendance", !r.error || r.error.code === grpc.status.NOT_FOUND, r.error?.message || "ok"); + + // ---- ScheduleService ---- + r = await call(clientMap.ScheduleService, "GetScheduleByStudent", { student_id: "stu-nonexistent" }); + record("ScheduleService.GetScheduleByStudent", !r.error, r.error?.message || `slots=${r.response?.slots?.length ?? 0}`); + + // ---- LeaveRequestService ---- + r = await call(clientMap.LeaveRequestService, "ListLeaveRequestsByStudent", { student_id: "stu-nonexistent" }); + record("LeaveRequestService.ListLeaveRequestsByStudent", !r.error, r.error?.message || `requests=${r.response?.leaveRequests?.length ?? 0}`); + + r = await call(clientMap.LeaveRequestService, "CreateLeaveRequest", { + student_id: "stu-smoke", + class_id: "cls-smoke", + leave_type: "personal", + start_date: "2026-07-14", + end_date: "2026-07-14", + reason: "smoke test", + submitted_by: "dev-teacher", + school_id: "sch-smoke", + }); + record("LeaveRequestService.CreateLeaveRequest", !r.error, r.error?.message || `id=${r.response?.id}`); + + // ---- DashboardService ---- + r = await call(clientMap.DashboardService, "GetDashboard", { teacher_id: "t-nonexistent" }); + record("DashboardService.GetDashboard", !r.error, r.error?.message || `totalClasses=${r.response?.totalClasses}`); + + r = await call(clientMap.DashboardService, "GetClassPerformance", { class_id: "cls-nonexistent" }); + record("DashboardService.GetClassPerformance", !r.error || r.error.code === grpc.status.NOT_FOUND, r.error?.message || `avg=${r.response?.averageScore}`); + + // ---- AdminService ---- + r = await call(clientMap.AdminService, "ListSchools", {}); + record("AdminService.ListSchools", !r.error, r.error?.message || `schools=${r.response?.schools?.length ?? 0}`); + + r = await call(clientMap.AdminService, "ListGradeLevels", { school_id: "sch-nonexistent" }); + record("AdminService.ListGradeLevels", !r.error, r.error?.message || `gradeLevels=${r.response?.gradeLevels?.length ?? 0}`); + + r = await call(clientMap.AdminService, "ListDepartments", { school_id: "sch-nonexistent" }); + record("AdminService.ListDepartments", !r.error, r.error?.message || `departments=${r.response?.departments?.length ?? 0}`); + + r = await call(clientMap.AdminService, "ListAcademicYears", {}); + record("AdminService.ListAcademicYears", !r.error, r.error?.message || `academicYears=${r.response?.academicYears?.length ?? 0}`); + + // ---- 汇总 ---- + console.log(`\n=== 汇总 ===`); + console.log(`PASS: ${results.pass.length} FAIL: ${results.fail.length} TOTAL: ${results.pass.length + results.fail.length}`); + if (results.fail.length > 0) { + console.log("\n失败项:"); + for (const f of results.fail) { + console.log(` - ${f.rpc}: ${f.detail}`); + } + process.exit(1); + } else { + console.log("全部 RPC 可达 ✓"); + } + + for (const s of services) { + clientMap[s]?.close?.(); + } + grpc.getClientChannelMap?.(); + process.exit(0); +} + +main().catch((e) => { + console.error("smoke test crashed:", e); + process.exit(2); +});