feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:56:10 +08:00
parent d260df864c
commit 7dd5c44406
23 changed files with 3344 additions and 2 deletions

View File

@@ -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;

View File

@@ -0,0 +1,376 @@
# core-edu 下一步工作与上下游依赖
> 模块core-edu教学核心服务HTTP 3004 + gRPC 50053
> 负责人ai08
> 更新日期2026-07-13v3P3.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 Agraded 统一、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 成绩计算配置化 | ✅ | GradeCalculatorweighted_average / sum / customscope 优先级 class > subject > school |
| P3.7 Redis 分布式锁 | ✅ | homework submit 接入 acquireLock/releaseLock |
| P3.8 DataScope 下推 | ✅ | datascope-injector.tsauth.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-alpineEXPOSE 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-testDEV_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 | ✅ | 200checks: 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 | ✅ | 返回 UUIDd5eeefba-... |
| 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.*` topic12 个 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-bffai03 负责)— 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-bffai04 负责)— 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-bffai05 负责)— 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-gatewayai01 负责)— 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-bffai03缺失 RPC7 个)
| # | 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-bffai04缺失 RPC7 个)
| # | 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-bffai05缺失 RPC5 个)
| # | 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-gatewayai01依赖
| 依赖项 | 状态 |
| ------------------------------------ | --------- |
| 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 个 portalai13/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 + /readyzDB 探针) | teacher-bffREST 调用) | ✅ |
| 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 RPC9 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 readinessDB + Redis 探针)
curl -H "x-user-id: dev-teacher" -H "x-user-role: teacher" http://localhost:13004/readyz
# gRPC smoke test24 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 server9 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 聚合 stubP3.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 test24 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 服务就绪。**

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { AdminService } from "./admin.service.js";
@Module({
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -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<SchoolInfo[]> {
// P3.13 stub: 待 IAM gRPC 客户端集成
return [];
}
async listGradeLevels(_schoolId: string): Promise<GradeLevelInfo[]> {
// P3.13 stub: 待 classes gRPC 客户端集成
return [];
}
async listDepartments(_schoolId: string): Promise<DepartmentInfo[]> {
// P3.13 stub: 待 IAM gRPC 客户端集成
return [];
}
async listAcademicYears(_schoolId?: string): Promise<AcademicYearInfo[]> {
// P3.13 stub: 待 classes gRPC 客户端集成
return [];
}
}

View File

@@ -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,
],

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { DashboardService } from "./dashboard.service.js";
@Module({
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -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<DashboardData> {
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<ClassPerformance> {
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;
}
}

View File

@@ -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;

View File

@@ -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");
});
});
});

View File

@@ -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(

View File

@@ -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>,
): 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权重 0totalWeight = 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/,
);
});
});
});

View File

@@ -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<ReportCard> {
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";
}
}

View File

@@ -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. 相对于源文件位置devsrc/grpcproddist/grpc目录层级一致
* 2. 相对于 cwd 的 packages/shared-proto/protocwd = 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<string, unknown>;
const segment = root["next_edu_cloud"];
const coreEdu = (segment as Record<string, unknown> | undefined)?.[
"core_edu"
];
const v1 = (coreEdu as Record<string, unknown> | 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<grpc.UntypedServiceImplementation> {
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<grpc.UntypedServiceImplementation>;
};
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<resp> 转为 grpc handleUnaryCall
// ----------------------------------------------------------------------------
type ProtoRequest = Record<string, unknown>;
type ProtoResponse = Record<string, unknown>;
function wrapHandler(
handler: (req: ProtoRequest) => Promise<ProtoResponse>,
): grpc.handleUnaryCall<unknown, unknown> {
return (call, callback) => {
// call.request 类型为 unknownaddService 的实现签名),此处从 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 stringnull -> ""
// ----------------------------------------------------------------------------
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 handlers8 RPC
// ----------------------------------------------------------------------------
function buildExamHandlers(
service: ExamsService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
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 handlers5 RPC
// ----------------------------------------------------------------------------
function buildHomeworkHandlers(
service: HomeworkService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
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 handlers6 RPC
// ----------------------------------------------------------------------------
function buildGradeHandlers(
service: GradesService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
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 handlers4 RPC
// ----------------------------------------------------------------------------
function buildClassHandlers(
service: ClassesService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
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 handlers4 RPC
// ----------------------------------------------------------------------------
function buildAttendanceHandlers(
service: AttendanceService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
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 handlersP3.13 新增1 RPC
// ----------------------------------------------------------------------------
function buildScheduleHandlers(
service: SchedulingService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
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 handlersP3.13 新增3 RPC
// ----------------------------------------------------------------------------
function buildLeaveRequestHandlers(
service: LeaveRequestsService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
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 handlersP3.13 新增2 RPC
// ----------------------------------------------------------------------------
function buildDashboardHandlers(
service: DashboardService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
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 handlersP3.13 新增4 RPC - aggregation stubs
// ----------------------------------------------------------------------------
function buildAdminHandlers(
service: AdminService,
): Record<string, grpc.handleUnaryCall<unknown, unknown>> {
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<void> {
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<void>((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<void> {
const server = grpcServer;
if (!server) {
return;
}
await new Promise<void>((resolve, reject) => {
server.tryShutdown((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
grpcServer = null;
logger.info({ service: "core-edu" }, "gRPC server stopped");
}

View File

@@ -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");
});
});
});

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { LeaveRequestsService } from "./leave-requests.service.js";
@Module({
providers: [LeaveRequestsService],
exports: [LeaveRequestsService],
})
export class LeaveRequestsModule {}

View File

@@ -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<void> {
await db.insert(leaveRequests).values(record);
},
async findById(id: string): Promise<LeaveRequest | undefined> {
const rows = await db
.select()
.from(leaveRequests)
.where(eq(leaveRequests.id, id))
.limit(1);
return rows[0];
},
async findByStudentId(
studentId: string,
status?: string,
): Promise<LeaveRequest[]> {
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<void> {
const updates: Partial<LeaveRequest> = { status };
if (reviewedBy !== undefined) updates.reviewedBy = reviewedBy;
if (reviewComment !== undefined) updates.reviewComment = reviewComment;
await db.update(leaveRequests).set(updates).where(eq(leaveRequests.id, id));
},
};

View File

@@ -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;

View File

@@ -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<LeaveRequest[]> {
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<void> {
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 -> cancelledapproved/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);
}
}

View File

@@ -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<void> {
@@ -38,13 +39,18 @@ async function bootstrap(): Promise<void> {
await outboxPublisher.start();
await app.listen(env.PORT);
// 启动 gRPC server9 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<void> => {
logger.info({ signal }, "Shutting down gracefully...");
await stopGrpcServer();
await outboxPublisher.stop();
await disconnectRedis();
await disconnectKafka();

View File

@@ -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<ScheduleSlot> & { 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);
});
});
});

View File

@@ -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<Schedule[]> {
return schedulingRepository.findSchedulesByClassId(classId);
}
// --------------------------------------------------------------------------
// P3.13 新增:按学生查询课表(通过考勤记录反查 class_id再查该班所有排课
// --------------------------------------------------------------------------
async getScheduleByStudent(
studentId: string,
weekStart?: string,
): Promise<ScheduleSlotInfo[]> {
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 ?? "",
};
});
}
}

View File

@@ -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);
}

View File

@@ -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);
});