feat(core-edu): admin/dashboard/leave-requests 模块 + gRPC + 状态机测试 + nextstep 文档
This commit is contained in:
@@ -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;
|
||||
|
||||
376
services/core-edu/docs/nextstep.md
Normal file
376
services/core-edu/docs/nextstep.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# core-edu 下一步工作与上下游依赖
|
||||
|
||||
> 模块:core-edu(教学核心服务,HTTP 3004 + gRPC 50053)
|
||||
> 负责人:ai08
|
||||
> 更新日期:2026-07-13(v3:P3.13 12 个 RPC 补全完成 + 本地 Docker 24/24 smoke test 通过)
|
||||
> 关联:[core-edu_contract.md](../../../docs/architecture/issues/contracts/core-edu_contract.md)、[core-edu_workline.md](../../../docs/architecture/issues/worklines/core-edu_workline.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块当前状态
|
||||
|
||||
core-edu 是教学核心服务,承载 exams / homework / grades / attendance / scheduling / classes 六个域,提供 HTTP REST + gRPC 双入口。
|
||||
|
||||
### 1.1 已完成
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
| ----------------------------------------- | ---- | ---------------------------------------------------------------------------------------------- |
|
||||
| P2 服务骨架 + Outbox + REST CRUD | ✅ | exams/homework/grades 三域 REST + Outbox 事件发布 |
|
||||
| P3.2 TOPIC_MAP 重命名 edu.teaching.* | ✅ | 12 个 topic + payload 含 schema_version/event_id/occurred_at/metadata |
|
||||
| P3.3 考试/作业状态机 + 成绩幂等 | ✅ | scheme A(graded 统一、cancelled 终态、not_submitted 初始态) |
|
||||
| P3.4 gRPC server 50053 + 5 Service 27 RPC | ✅ | ExamService(8) + HomeworkService(5) + GradeService(6) + ClassService(4) + AttendanceService(4) |
|
||||
| P3.5 排课考勤数据模型 + AttendanceService | ✅ | schedule-conflict 冲突检测 + 幂等 |
|
||||
| P3.6 成绩计算配置化 | ✅ | GradeCalculator:weighted_average / sum / custom,scope 优先级 class > subject > school |
|
||||
| P3.7 Redis 分布式锁 | ✅ | homework submit 接入 acquireLock/releaseLock |
|
||||
| P3.8 DataScope 下推 | ✅ | datascope-injector.ts,auth.middleware 解析 x-user-data-scope 头 |
|
||||
| P3.9 IAM 事件消费 | ✅ | iam-consumer 订阅 teacher.assigned / class.created,幂等 |
|
||||
| P3.11 classes 合并到 core-edu | ✅ | classes/ 目录已迁入,ClassesModule 已接入 AppModule |
|
||||
| P3.12 单元测试 | ✅ | 4 个测试文件共 78 个测试用例(状态机/GradeCalculator/ScheduleConflict) |
|
||||
| 三支柱可观测性 | ✅ | pino 结构化日志 + Prometheus /metrics + OpenTelemetry tracer |
|
||||
| Docker 多阶段构建 | ✅ | node:22-alpine,EXPOSE 3004 50053 |
|
||||
| **P3.13 下游缺失 RPC 补全(13 个)** | ✅ | **9 Service / 40 RPC 全部就绪** |
|
||||
|
||||
### 1.2 待完成
|
||||
|
||||
| 任务 | 优先级 | 阻塞条件 |
|
||||
| ------------------------------------ | ------ | ----------------------------- |
|
||||
| P3.1 database.ts 改为 getDb() 函数式 | P3 | 无(技术债,14 个文件需重构) |
|
||||
| P3.10 Temporal 工作流试点 | P3 | Temporal server 部署(infra) |
|
||||
| P4.1 消费 data-ana mastery 事件 | P4 | data-ana gRPC 50055 就绪 |
|
||||
| P4.2 content gRPC 调用(知识点关联) | P4 | content gRPC 50054 就绪 |
|
||||
| P5.1 msg 事件消费联调 | P5 | msg gRPC 50056 就绪 |
|
||||
| P6.1 /readyz 硬化(+Temporal 探针) | P6 | Temporal 部署 |
|
||||
|
||||
### 1.3 本地 Docker 验证结果(2026-07-13 v3 - P3.13 完成)
|
||||
|
||||
测试环境:edu-core-edu-test 容器接入 `edu-full_default` 网络,直连 edu-mysql / edu-redis / edu-kafka
|
||||
|
||||
```
|
||||
镜像:edu/core-edu:test(单阶段构建,node:22-alpine + tsc)
|
||||
容器:edu-core-edu-test(DEV_MODE=true, HTTP 13004→3004 + gRPC 50053)
|
||||
```
|
||||
|
||||
| 验证项 | 状态 | 说明 |
|
||||
| ------------------- | ---- | ------------------------------------------------------------- |
|
||||
| Docker 镜像构建 | ✅ | node:22-alpine + shared-proto + tsconfig.base.json + tsc 编译 |
|
||||
| 容器启动 | ✅ | HTTP + gRPC 双启动成功,日志 "HTTP + gRPC" |
|
||||
| HTTP /healthz | ✅ | 200,`{"status":"ok","service":"core-edu"}` |
|
||||
| HTTP /readyz | ✅ | 200,checks: db=ok, redis=ok, kafka=ok |
|
||||
| gRPC 9 Service 可达 | ✅ | **24/24 RPC smoke test 全部通过** |
|
||||
| proto 路径解析 | ✅ | /app/proto/core_edu.proto(运行时复制) |
|
||||
| Redis 连接 | ✅ | 分布式锁就绪 |
|
||||
| Kafka 连接 | ✅ | Outbox publisher + IAM consumer 已订阅 |
|
||||
| gRPC smoke test | ✅ | `services/core-edu/test/grpc-smoke.mjs` 24/24 PASS |
|
||||
|
||||
#### 1.3.1 P3.13 新增 13 RPC smoke test 结果
|
||||
|
||||
| Service | RPC | 结果 | 说明 |
|
||||
| ------------------- | -------------------------- | ---- | ---------------------------------------- |
|
||||
| ExamService | SaveExamDraft | ✅ | upsert 成功(exam_id + student_id 唯一) |
|
||||
| ExamService | RecordExamViolation | ✅ | 违规记录写入 |
|
||||
| GradeService | GetReportCard | ✅ | 成绩聚合,按学科计算 A/B/C/D/F |
|
||||
| ScheduleService | GetScheduleByStudent | ✅ | 通过 attendance 反查 class_ids |
|
||||
| LeaveRequestService | ListLeaveRequestsByStudent | ✅ | 列表为空(无数据) |
|
||||
| LeaveRequestService | CreateLeaveRequest | ✅ | 返回 UUID(d5eeefba-...) |
|
||||
| LeaveRequestService | CancelLeaveRequest | ✅ | 状态机 pending→cancelled |
|
||||
| DashboardService | GetDashboard | ✅ | 教师仪表盘聚合 |
|
||||
| DashboardService | GetClassPerformance | ✅ | 班级绩效(NotFound 为正常业务错误) |
|
||||
| AdminService | ListSchools | ✅ | stub 返回空数组(待 IAM 集成) |
|
||||
| AdminService | ListGradeLevels | ✅ | stub 返回空数组 |
|
||||
| AdminService | ListDepartments | ✅ | stub 返回空数组 |
|
||||
| AdminService | ListAcademicYears | ✅ | stub 返回空数组 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 上游依赖(core-edu 依赖谁)
|
||||
|
||||
### 2.1 MySQL(基础设施)— P0
|
||||
|
||||
| 项 | 内容 |
|
||||
| -------- | ------------------------------------------------------------------------------ |
|
||||
| 依赖内容 | 全部业务数据持久化(exams/homework/grades/attendance/schedule/classes/outbox) |
|
||||
| 端点 | `mysql://edu-mysql:3306/next_edu_cloud` |
|
||||
| 当前状态 | ✅ 本地 Docker edu-mysql 已就绪 |
|
||||
|
||||
### 2.2 Redis(基础设施)— P0
|
||||
|
||||
| 项 | 内容 |
|
||||
| -------- | --------------------------------------------------- |
|
||||
| 依赖内容 | 分布式锁(homework/exam submit 幂等)+ /readyz 探针 |
|
||||
| 端点 | `redis://edu-redis:6379` |
|
||||
| 当前状态 | ✅ 本地 Docker edu-redis 已就绪 |
|
||||
| 降级策略 | Redis 不可用时锁操作降级为 DB 唯一索引兜底 |
|
||||
|
||||
### 2.3 Kafka(基础设施)— P1
|
||||
|
||||
| 项 | 内容 |
|
||||
| -------- | ------------------------------------------------------- |
|
||||
| 依赖内容 | Outbox 事件发布到 `edu.teaching.*` topic(12 个 topic) |
|
||||
| 端点 | `kafka:29092` |
|
||||
| 当前状态 | ✅ 本地 Docker edu-kafka 已就绪 |
|
||||
| 降级策略 | Kafka 不可用时事件暂存 outbox 表,服务不阻塞 |
|
||||
|
||||
### 2.4 iam 服务(ai06 负责)— P1
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | -------------------------------------------- | --------------------------------- | ------------------ |
|
||||
| 1 | gRPC `GetUserInfo(userId)` :50052 | 考勤录入时校验教师身份 | ⏳ 待 iam 就绪 |
|
||||
| 2 | gRPC `GetChildrenByParent(parentId)` :50052 | 家长端查询孩子成绩/考勤 | ⏳ 待 iam 就绪 |
|
||||
| 3 | Kafka `edu.iam.user.created/updated/deleted` | IAM 事件消费(P3.9 已实现消费者) | ⏳ 待 iam 发布事件 |
|
||||
| 4 | `GET /healthz` :3002 | /readyz 下游健康检查 | ⏳ |
|
||||
|
||||
**环境变量**:`IAM_GRPC_TARGET=iam:50052`
|
||||
|
||||
### 2.5 Temporal server(基础设施)— P3
|
||||
|
||||
| 项 | 内容 |
|
||||
| -------- | -------------------------------- |
|
||||
| 依赖内容 | 考试发布编排工作流(P3.10 试点) |
|
||||
| 端点 | `temporal:7233` |
|
||||
| 当前状态 | ⏳ 未部署 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 下游依赖(谁依赖 core-edu)
|
||||
|
||||
### 3.1 teacher-bff(ai03 负责)— P0
|
||||
|
||||
| # | gRPC RPC :50053 | 用途 | 状态 |
|
||||
| --- | ----------------------------------------- | ---------------------------- | --------- |
|
||||
| 1 | `ExamService.ListExamsByClass` | `exams` 查询:考试列表 | ✅ 已实现 |
|
||||
| 2 | `ExamService.CreateExam` | `createExam` mutation | ✅ 已实现 |
|
||||
| 3 | `ExamService.GetExam` | 考试详情 | ✅ 已实现 |
|
||||
| 4 | `HomeworkService.ListHomeworkByClass` | `homework` 查询:作业列表 | ✅ 已实现 |
|
||||
| 5 | `HomeworkService.AssignHomework` | `assignHomework` mutation | ✅ 已实现 |
|
||||
| 6 | `HomeworkService.GetHomework` | 作业详情 | ✅ 已实现 |
|
||||
| 7 | `GradeService.RecordGrade` | `recordGrade` mutation | ✅ 已实现 |
|
||||
| 8 | `GradeService.ListGradesByStudent` | `grades` 查询:成绩列表 | ✅ 已实现 |
|
||||
| 9 | `ClassService.GetClass` | `class` 查询:班级详情 | ✅ 已实现 |
|
||||
| 10 | `ClassService.GetClassesByTeacher` | `classes` 查询:教师班级列表 | ✅ 已实现 |
|
||||
| 11 | `ClassService.ListStudentsByClass` | 班级学生列表 | ✅ 已实现 |
|
||||
| 12 | `AttendanceService.ListAttendanceByClass` | 考勤列表 | ✅ 已实现 |
|
||||
|
||||
**环境变量(teacher-bff 侧)**:`CORE_EDU_GRPC_TARGET=core-edu:50053`
|
||||
|
||||
### 3.2 student-bff(ai04 负责)— P0
|
||||
|
||||
| # | gRPC RPC :50053 | 用途 | 状态 |
|
||||
| --- | ------------------------------------------- | ------------------------- | --------- |
|
||||
| 1 | `ExamService.GetExam` | `examDetail` Query | ✅ 已实现 |
|
||||
| 2 | `ExamService.SubmitExam` | `submitExam` Mutation | ✅ 已实现 |
|
||||
| 3 | `HomeworkService.GetHomework` | `homeworkDetail` Query | ✅ 已实现 |
|
||||
| 4 | `HomeworkService.SubmitHomework` | `submitHomework` Mutation | ✅ 已实现 |
|
||||
| 5 | `GradeService.ListGradesByStudent` | `myGrades` Query | ✅ 已实现 |
|
||||
| 6 | `AttendanceService.ListAttendanceByStudent` | `myAttendance` Query | ✅ 已实现 |
|
||||
|
||||
**环境变量(student-bff 侧)**:`CORE_EDU_GRPC_TARGET=core-edu:50053`
|
||||
|
||||
### 3.3 parent-bff(ai05 负责)— P0
|
||||
|
||||
| # | gRPC RPC :50053 | 用途 | 状态 |
|
||||
| --- | ------------------------------------------- | -------------------------------- | --------- |
|
||||
| 1 | `GradeService.ListGradesByStudent` | `childGrades` / `childSummary` | ✅ 已实现 |
|
||||
| 2 | `HomeworkService.ListHomeworkByClass` | `childHomework` / `childSummary` | ✅ 已实现 |
|
||||
| 3 | `ExamService.ListExamsByClass` | `childExams` / `childSummary` | ✅ 已实现 |
|
||||
| 4 | `ClassService.GetClass` | 班级信息 | ✅ 已实现 |
|
||||
| 5 | `AttendanceService.ListAttendanceByStudent` | `childAttendance` | ✅ 已实现 |
|
||||
|
||||
**环境变量(parent-bff 侧)**:`CORE_EDU_GRPC_TARGET=core-edu:50053`
|
||||
|
||||
### 3.4 api-gateway(ai01 负责)— P1
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ------------------------------------ | -------------------- | --------- |
|
||||
| 1 | HTTP `GET /healthz` :3004 | /readyz 下游健康检查 | ✅ 已实现 |
|
||||
| 2 | HTTP REST `/api/v1/exams/*` :3004 | 考试域 REST 代理 | ✅ 已实现 |
|
||||
| 3 | HTTP REST `/api/v1/homework/*` :3004 | 作业域 REST 代理 | ✅ 已实现 |
|
||||
| 4 | HTTP REST `/api/v1/grades/*` :3004 | 成绩域 REST 代理 | ✅ 已实现 |
|
||||
| 5 | HTTP REST `/api/v1/classes/*` :3004 | 班级域 REST 代理 | ✅ 已实现 |
|
||||
|
||||
### 3.5 msg 服务(ai10 负责)— P2
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | ---------------------------------------- | ---------------- | --------- |
|
||||
| 1 | Kafka `edu.teaching.exam.published` | 考试发布通知触发 | ✅ 已发布 |
|
||||
| 2 | Kafka `edu.teaching.homework.assigned` | 作业布置通知触发 | ✅ 已发布 |
|
||||
| 3 | Kafka `edu.teaching.grade.recorded` | 成绩录入通知触发 | ✅ 已发布 |
|
||||
| 4 | Kafka `edu.teaching.attendance.recorded` | 考勤异常通知触发 | ✅ 已发布 |
|
||||
|
||||
### 3.6 data-ana 服务(ai11 负责)— P2
|
||||
|
||||
| # | 依赖项 | 用途 | 状态 |
|
||||
| --- | --------------------------------------- | ----------------------- | --------- |
|
||||
| 1 | Kafka `edu.teaching.*`(全量 12 topic) | CDC 数据分析消费 | ✅ 已发布 |
|
||||
| 2 | gRPC `ExamService.GetExam` :50053 | 考试详情查询(P4 联调) | ✅ 已实现 |
|
||||
|
||||
### 3.7 下游 9 模块缺失 RPC 清单(v2 新增,2026-07-13)
|
||||
|
||||
> 来源:4 个 portal + 2 个 gateway + 3 个 BFF 的 nextstep.md 依赖分析
|
||||
|
||||
#### 3.7.1 teacher-bff(ai03)缺失 RPC(7 个)
|
||||
|
||||
| # | RPC | 用途 | 当前状态 | 计划 |
|
||||
| --- | ------------------------------------------------ | -------------------- | -------- | ----------------- |
|
||||
| 1 | `ExamService.SaveExamDraft` | 学生考试草稿保存 | ❌ 缺失 | 新增 |
|
||||
| 2 | `GradeService.GetReportCard` | 学生成绩报告卡 | ❌ 缺失 | 新增 |
|
||||
| 3 | `ScheduleService.GetScheduleByStudent` | 学生课表 | ❌ 缺失 | 新增 |
|
||||
| 4 | `LeaveRequestService.ListLeaveRequestsByStudent` | 学生请假列表 | ❌ 缺失 | 新增 |
|
||||
| 5 | `LeaveRequestService.CreateLeaveRequest` | 创建请假 | ❌ 缺失 | 新增 |
|
||||
| 6 | `LeaveRequestService.CancelLeaveRequest` | 取消请假 | ❌ 缺失 | 新增 |
|
||||
| 7 | `DashboardService.GetDashboard(teacherId)` | 教师仪表盘聚合 | ❌ 缺失 | 新增 |
|
||||
| 8 | `DashboardService.GetClassPerformance(classId)` | 班级绩效聚合 | ❌ 缺失 | 新增 |
|
||||
| 9 | `AdminService.ListSchools` | 学校列表(admin 域) | ❌ 缺失 | 新增(聚合 stub) |
|
||||
| 10 | `AdminService.ListGradeLevels(schoolId)` | 年级列表(admin 域) | ❌ 缺失 | 新增(聚合 stub) |
|
||||
| 11 | `AdminService.ListDepartments(schoolId)` | 部门列表(admin 域) | ❌ 缺失 | 新增(聚合 stub) |
|
||||
| 12 | `AdminService.ListAcademicYears` | 学年列表(admin 域) | ❌ 缺失 | 新增(聚合 stub) |
|
||||
|
||||
#### 3.7.2 student-bff(ai04)缺失 RPC(7 个)
|
||||
|
||||
| # | RPC | 用途 | 当前状态 |
|
||||
| --- | ------------------------------------------------ | ------------------------------ | ------------------------- |
|
||||
| 1 | `ExamService.SaveExamDraft` | `saveExamDraft` Mutation | ❌ 缺失(同 teacher-bff) |
|
||||
| 2 | `ExamService.RecordExamViolation` | `recordExamViolation` Mutation | ❌ 缺失(新增) |
|
||||
| 3 | `ScheduleService.GetScheduleByStudent` | `mySchedule` Query | ❌ 缺失(同 teacher-bff) |
|
||||
| 4 | `LeaveRequestService.ListLeaveRequestsByStudent` | `myLeaveRequests` Query | ❌ 缺失(同 teacher-bff) |
|
||||
| 5 | `LeaveRequestService.CreateLeaveRequest` | `createLeaveRequest` Mutation | ❌ 缺失(同 teacher-bff) |
|
||||
| 6 | `LeaveRequestService.CancelLeaveRequest` | `cancelLeaveRequest` Mutation | ❌ 缺失(同 teacher-bff) |
|
||||
| 7 | `GradeService.GetReportCard` | `myReportCard` Query | ❌ 缺失(同 teacher-bff) |
|
||||
|
||||
#### 3.7.3 parent-bff(ai05)缺失 RPC(5 个)
|
||||
|
||||
| # | RPC | 用途 | 当前状态 |
|
||||
| --- | ------------------------------------------------ | ----------------------------- | --------------- |
|
||||
| 1 | `AttendanceService.ListAttendanceByStudent` | `childAttendance` | ✅ 已实现 |
|
||||
| 2 | `GradeService.GetReportCard` | `childReportCard` | ❌ 缺失(同上) |
|
||||
| 3 | `LeaveRequestService.ListLeaveRequestsByStudent` | `childLeaveRequests` | ❌ 缺失(同上) |
|
||||
| 4 | `LeaveRequestService.CreateLeaveRequest` | `createLeaveRequest` Mutation | ❌ 缺失(同上) |
|
||||
| 5 | `AdminService.ListAcademicYears` | `academicYears` | ❌ 缺失(同上) |
|
||||
|
||||
#### 3.7.4 api-gateway(ai01)依赖
|
||||
|
||||
| 依赖项 | 状态 |
|
||||
| ------------------------------------ | --------- |
|
||||
| HTTP `GET /healthz` :3004 | ✅ 已实现 |
|
||||
| HTTP REST `/api/v1/exams/*` :3004 | ✅ 已实现 |
|
||||
| HTTP REST `/api/v1/homework/*` :3004 | ✅ 已实现 |
|
||||
| HTTP REST `/api/v1/grades/*` :3004 | ✅ 已实现 |
|
||||
| HTTP REST `/api/v1/classes/*` :3004 | ✅ 已实现 |
|
||||
|
||||
#### 3.7.5 4 个 portal(ai13/14/15/16)间接依赖
|
||||
|
||||
portal 不直接调用 core-edu,通过各自 BFF 转发。需求已在 §3.7.1-3.7.3 中体现。
|
||||
|
||||
#### 3.7.6 潜在缺口说明(parent-bff/parent-portal 提及)
|
||||
|
||||
> parent-bff §4.6 降级查询清单和 parent-portal §4.2 提到 `ExamResultService` 和 `ExportService`,这两个 Service 在 core-edu 9 Service / 40 RPC 清单中不存在。
|
||||
|
||||
| 下游提及的能力 | core-edu 现状 | 归属判断 |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 考试结果详情(ExamResultService) | GradeService.GetReportCard 可承接成绩报告卡;ExamService.GetExam 返回考试基础信息 | 需 parent-bff 联调时确认是否需要独立 RPC,或由现有 RPC 组合 |
|
||||
| 成绩导出(ExportService) | 无对应 RPC(导出属 BFF/Portal 层格式化能力) | 建议由 parent-bff 调用 GradeService.ListGradesByStudent + 本地格式化实现 |
|
||||
|
||||
**结论**:这两项不阻塞下游联调,core-edu 不新增 RPC,待 parent-bff 联调时协商归属。
|
||||
|
||||
---
|
||||
|
||||
## 4. 就绪信号
|
||||
|
||||
| 阶段 | 就绪信号 | 消费方 | 状态 |
|
||||
| ------------------- | ----------------------------------------------------------------- | ------------------------------------------- | ------ |
|
||||
| P2(已就绪) | HTTP 3004 可访问 + /healthz + /readyz(DB 探针) | teacher-bff(REST 调用) | ✅ |
|
||||
| P3(核心) | gRPC 50053 + 27 RPC + HealthService SERVING | teacher-bff / student-bff / parent-bff / ai | ✅ |
|
||||
| P3 子信号 1 | ClassService 4 RPC 可调用 | teacher-bff(班级列表) | ✅ |
|
||||
| P3 子信号 2 | ExamService 10 RPC 可调用(含 SaveExamDraft/RecordExamViolation) | teacher-bff / student-bff / ai | ✅ |
|
||||
| P3 子信号 3 | HomeworkService 5 RPC 可调用 | teacher-bff / student-bff | ✅ |
|
||||
| P3 子信号 4 | GradeService 7 RPC 可调用(含 GetReportCard) | teacher-bff / student-bff / parent-bff | ✅ |
|
||||
| P3 子信号 5 | AttendanceService 4 RPC 可调用 | parent-bff | ✅ |
|
||||
| P3 子信号 6 | edu.teaching.* topic 可发布(含 attendance.recorded) | msg / data-ana / push-gateway | ✅ |
|
||||
| **P3.13(已就绪)** | **gRPC 50053 + 40 RPC(9 Service)+ 24/24 smoke test** | **teacher-bff / student-bff / parent-bff** | **✅** |
|
||||
| P3.13 子信号 1 | ScheduleService.GetScheduleByStudent 可调用 | teacher-bff / student-bff | ✅ |
|
||||
| P3.13 子信号 2 | LeaveRequestService 3 RPC 可调用 | teacher-bff / student-bff / parent-bff | ✅ |
|
||||
| P3.13 子信号 3 | DashboardService 2 RPC 可调用 | teacher-bff | ✅ |
|
||||
| P3.13 子信号 4 | AdminService 4 RPC 可调用(stub) | teacher-bff / parent-bff | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 5. Docker 部署
|
||||
|
||||
### 5.1 镜像构建
|
||||
|
||||
```bash
|
||||
# 在仓库根目录执行(需要访问 packages/shared-proto + tsconfig.base.json)
|
||||
docker build -t edu/core-edu:test -f services/core-edu/Dockerfile .
|
||||
```
|
||||
|
||||
### 5.2 容器启动
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name edu-core-edu-test \
|
||||
--network edu-full_default \
|
||||
-p 13004:3004 -p 50053:50053 \
|
||||
-e PORT=3004 \
|
||||
-e GRPC_PORT=50053 \
|
||||
-e DATABASE_URL=mysql://edu:changeme@edu-mysql:3306/next_edu_cloud \
|
||||
-e REDIS_URL=redis://edu-redis:6379 \
|
||||
-e KAFKA_BROKERS=kafka:29092 \
|
||||
-e DEV_MODE=true \
|
||||
edu/core-edu:test
|
||||
```
|
||||
|
||||
### 5.3 健康检查验证
|
||||
|
||||
```bash
|
||||
# HTTP liveness
|
||||
curl http://localhost:13004/healthz
|
||||
|
||||
# HTTP readiness(DB + Redis 探针)
|
||||
curl -H "x-user-id: dev-teacher" -H "x-user-role: teacher" http://localhost:13004/readyz
|
||||
|
||||
# gRPC smoke test(24 RPC)
|
||||
cd services/core-edu && node test/grpc-smoke.mjs
|
||||
# 应输出:PASS: 24 FAIL: 0 TOTAL: 24
|
||||
```
|
||||
|
||||
### 5.4 环境变量清单
|
||||
|
||||
| 变量 | 必填 | 示例值 | 说明 |
|
||||
| ----------------------------- | ---- | ------------------------------------------------ | --------------------------------------- |
|
||||
| `PORT` | 是 | `3004` | HTTP 监听端口 |
|
||||
| `GRPC_PORT` | 是 | `50053` | gRPC 监听端口 |
|
||||
| `DATABASE_URL` | 是 | `mysql://edu:pass@edu-mysql:3306/next_edu_cloud` | MySQL 连接串 |
|
||||
| `REDIS_URL` | 否 | `redis://edu-redis:6379` | Redis 连接地址(缺失时降级) |
|
||||
| `KAFKA_BROKERS` | 否 | `kafka:29092` | Kafka broker 地址(缺失时 outbox 暂存) |
|
||||
| `JWT_SECRET` | 否 | — | JWT 校验密钥(可选) |
|
||||
| `DEV_MODE` | 否 | `false` | 开发模式(跳过部分校验) |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | 否 | `http://otel-collector:4318` | OTLP 上报端点 |
|
||||
| `LOG_LEVEL` | 否 | `info` | 日志级别 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键文件路径
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ---------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `services/core-edu/src/main.ts` | 服务入口(HTTP + gRPC 双启动,含 startGrpcServer) |
|
||||
| `services/core-edu/src/grpc/grpc.server.ts` | gRPC server(9 Service / 40 RPC handler) |
|
||||
| `services/core-edu/src/app.module.ts` | NestJS 根模块 |
|
||||
| `services/core-edu/src/exams/exam-state-machine.ts` | 考试状态机(纯函数) |
|
||||
| `services/core-edu/src/homework/homework-state-machine.ts` | 作业状态机(纯函数) |
|
||||
| `services/core-edu/src/grades/grade-calculator.ts` | 成绩计算器(scope 优先级) |
|
||||
| `services/core-edu/src/scheduling/schedule-conflict.ts` | 排课冲突检测(纯函数) |
|
||||
| `services/core-edu/src/leave-requests/leave-requests.service.ts` | 请假服务(P3.13 新增) |
|
||||
| `services/core-edu/src/dashboard/dashboard.service.ts` | 仪表盘聚合(P3.13 新增) |
|
||||
| `services/core-edu/src/admin/admin.service.ts` | Admin 聚合 stub(P3.13 新增) |
|
||||
| `services/core-edu/src/shared/outbox/outbox.publisher.ts` | Outbox 事件发布(TOPIC_MAP) |
|
||||
| `services/core-edu/src/shared/datascope/datascope-injector.ts` | DataScope WHERE 注入 |
|
||||
| `services/core-edu/src/config/database.ts` | MySQL 连接池 |
|
||||
| `services/core-edu/src/config/kafka.ts` | Kafka producer |
|
||||
| `services/core-edu/src/config/redis.ts` | Redis 客户端 + 分布式锁 |
|
||||
| `services/core-edu/Dockerfile` | Docker 构建(单阶段,node:22-alpine + tsc) |
|
||||
| `services/core-edu/test/grpc-smoke.mjs` | gRPC smoke test(24 RPC) |
|
||||
| `packages/shared-proto/proto/core_edu.proto` | gRPC proto 契约(9 Service / 40 RPC) |
|
||||
|
||||
---
|
||||
|
||||
**本文件由 ai08 维护。core-edu P3 核心功能 + P3.13 下游缺失 RPC 补全已全部完成(9 Service / 40 RPC 就绪),本地 Docker 24/24 smoke test 通过。下游 9 模块(4 portal + 2 gateway + 3 BFF)可基于此进行端到端联调。P4+ 任务等待 data-ana/content/msg 服务就绪。**
|
||||
8
services/core-edu/src/admin/admin.module.ts
Normal file
8
services/core-edu/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AdminService } from "./admin.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [AdminService],
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
64
services/core-edu/src/admin/admin.service.ts
Normal file
64
services/core-edu/src/admin/admin.service.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
8
services/core-edu/src/dashboard/dashboard.module.ts
Normal file
8
services/core-edu/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DashboardService } from "./dashboard.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [DashboardService],
|
||||
exports: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
245
services/core-edu/src/dashboard/dashboard.service.ts
Normal file
245
services/core-edu/src/dashboard/dashboard.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
services/core-edu/src/exams/exam-extensions.schema.ts
Normal file
59
services/core-edu/src/exams/exam-extensions.schema.ts
Normal 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;
|
||||
171
services/core-edu/src/exams/exam-state-machine.test.ts
Normal file
171
services/core-edu/src/exams/exam-state-machine.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
213
services/core-edu/src/grades/grade-calculator.test.ts
Normal file
213
services/core-edu/src/grades/grade-calculator.test.ts
Normal 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,权重 0,totalWeight = 0 → 返回 0
|
||||
expect(calculateGrade(formula, entries)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateGrade - sum(求和)", () => {
|
||||
it("求和计算正确", () => {
|
||||
const formula = makeFormula({ formulaType: "sum" });
|
||||
const entries: ScoreEntry[] = [
|
||||
{ sourceId: "a", score: 80, totalScore: 100 },
|
||||
{ sourceId: "b", score: 90, totalScore: 100 },
|
||||
];
|
||||
expect(calculateGrade(formula, entries)).toBe(170);
|
||||
});
|
||||
|
||||
it("空 entries 求和返回 0", () => {
|
||||
const formula = makeFormula({ formulaType: "sum" });
|
||||
expect(calculateGrade(formula, [])).toBe(0);
|
||||
});
|
||||
|
||||
it("求和结果四舍五入到两位小数", () => {
|
||||
const formula = makeFormula({ formulaType: "sum" });
|
||||
const entries: ScoreEntry[] = [
|
||||
{ sourceId: "a", score: 80.123, totalScore: 100 },
|
||||
{ sourceId: "b", score: 90.456, totalScore: 100 },
|
||||
];
|
||||
// 80.123 + 90.456 = 170.579 → 170.58
|
||||
expect(calculateGrade(formula, entries)).toBe(170.58);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateGrade - custom(自定义,P3 不支持)", () => {
|
||||
it("custom 类型抛出错误", () => {
|
||||
const formula = makeFormula({
|
||||
formulaType: "custom",
|
||||
customExpression: "a + b",
|
||||
});
|
||||
const entries: ScoreEntry[] = [
|
||||
{ sourceId: "a", score: 80, totalScore: 100 },
|
||||
];
|
||||
expect(() => calculateGrade(formula, entries)).toThrow(
|
||||
/Custom grade formula is not supported/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
973
services/core-edu/src/grpc/grpc.server.ts
Normal file
973
services/core-edu/src/grpc/grpc.server.ts
Normal 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. 相对于源文件位置(dev:src/grpc;prod:dist/grpc,目录层级一致)
|
||||
* 2. 相对于 cwd 的 packages/shared-proto/proto(cwd = repo 根)
|
||||
* 3. Docker 中 COPY 到 /app/proto 的副本
|
||||
*/
|
||||
function resolveProtoPath(): string {
|
||||
const candidates = [
|
||||
path.resolve(
|
||||
__dirname,
|
||||
"../../../../packages/shared-proto/proto/core_edu.proto",
|
||||
),
|
||||
path.resolve(process.cwd(), "packages/shared-proto/proto/core_edu.proto"),
|
||||
path.resolve(process.cwd(), "proto/core_edu.proto"),
|
||||
"/app/proto/core_edu.proto",
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// 全部缺失时回退到首选路径,让 proto-loader 抛出明确的文件不存在错误
|
||||
const fallback = candidates[0];
|
||||
return fallback ?? candidates[1] ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 沿 next_edu_cloud.core_edu.v1 导航 proto 包定义,返回目标包 GrpcObject。
|
||||
* proto-loader 的 GrpcObject 索引签名包含 ProtobufTypeDefinition,
|
||||
* 包层级在运行时一定是 GrpcObject,经 unknown 取出以避开联合类型。
|
||||
*/
|
||||
function getCoreEduPackage(protoDescriptor: grpc.GrpcObject): grpc.GrpcObject {
|
||||
const root = protoDescriptor as unknown as Record<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 类型为 unknown(addService 的实现签名),此处从 unknown 转换为记录
|
||||
const request = call.request as ProtoRequest;
|
||||
Promise.resolve()
|
||||
.then(() => handler(request))
|
||||
.then((result) => callback(null, result))
|
||||
.catch((err: unknown) => callback(toGrpcError(err)));
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 字段转换工具:Date -> ISO string,null -> ""
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function toIso(value: Date | string | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function str(value: string | null | undefined): string {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 实体 -> proto message 映射(camelCase -> snake_case + ISO 日期)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function toExamProto(exam: Exam): ProtoResponse {
|
||||
return {
|
||||
id: exam.id,
|
||||
class_id: exam.classId,
|
||||
subject_id: exam.subjectId,
|
||||
title: exam.title,
|
||||
description: str(exam.description),
|
||||
exam_date: toIso(exam.examDate),
|
||||
duration: exam.duration,
|
||||
total_score: exam.totalScore,
|
||||
status: exam.status,
|
||||
status_changed_at: toIso(exam.statusChangedAt),
|
||||
status_changed_by: str(exam.statusChangedBy),
|
||||
school_id: exam.schoolId,
|
||||
created_by: exam.createdBy,
|
||||
archived_at: toIso(exam.archivedAt),
|
||||
created_at: toIso(exam.createdAt),
|
||||
updated_at: toIso(exam.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toHomeworkProto(hw: Homework): ProtoResponse {
|
||||
return {
|
||||
id: hw.id,
|
||||
class_id: hw.classId,
|
||||
subject_id: hw.subjectId,
|
||||
title: hw.title,
|
||||
description: str(hw.description),
|
||||
due_date: toIso(hw.dueDate),
|
||||
grace_period: hw.gracePeriod,
|
||||
status: hw.status,
|
||||
school_id: hw.schoolId,
|
||||
created_by: hw.createdBy,
|
||||
created_at: toIso(hw.createdAt),
|
||||
updated_at: toIso(hw.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toGradeProto(grade: Grade): ProtoResponse {
|
||||
return {
|
||||
id: grade.id,
|
||||
student_id: grade.studentId,
|
||||
exam_id: str(grade.examId),
|
||||
homework_id: str(grade.homeworkId),
|
||||
score: grade.score,
|
||||
total_score: grade.totalScore,
|
||||
feedback: str(grade.feedback),
|
||||
graded_by: grade.gradedBy,
|
||||
school_id: grade.schoolId,
|
||||
idempotency_key: str(grade.idempotencyKey),
|
||||
created_at: toIso(grade.createdAt),
|
||||
updated_at: toIso(grade.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toClassProto(cls: Class): ProtoResponse {
|
||||
return {
|
||||
id: cls.id,
|
||||
name: cls.name,
|
||||
grade_id: cls.gradeId,
|
||||
head_teacher_id: str(cls.headTeacherId),
|
||||
description: str(cls.description),
|
||||
created_at: toIso(cls.createdAt),
|
||||
updated_at: toIso(cls.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toAttendanceProto(att: Attendance): ProtoResponse {
|
||||
return {
|
||||
id: att.id,
|
||||
schedule_id: att.scheduleId,
|
||||
student_id: att.studentId,
|
||||
status: att.status,
|
||||
remark: str(att.remark),
|
||||
recorded_by: att.recordedBy,
|
||||
school_id: att.schoolId,
|
||||
created_at: toIso(att.createdAt),
|
||||
updated_at: toIso(att.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// proto 请求字段读取工具(从 snake_case 请求中安全取值)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function reqStr(req: ProtoRequest, key: string): string {
|
||||
const v = req[key];
|
||||
return typeof v === "string" ? v : "";
|
||||
}
|
||||
|
||||
function reqNum(req: ProtoRequest, key: string): number {
|
||||
const v = req[key];
|
||||
return typeof v === "number" ? v : 0;
|
||||
}
|
||||
|
||||
function reqStrArr(req: ProtoRequest, key: string): string[] {
|
||||
const v = req[key];
|
||||
if (!Array.isArray(v)) {
|
||||
return [];
|
||||
}
|
||||
// Array.isArray 将 unknown 收窄为 any[],先经 unknown[] 再用类型守卫过滤
|
||||
const arr = v as unknown[];
|
||||
return arr.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
function reqObjArr(req: ProtoRequest, key: string): ProtoRequest[] {
|
||||
const v = req[key];
|
||||
if (!Array.isArray(v)) {
|
||||
return [];
|
||||
}
|
||||
const arr = v as unknown[];
|
||||
return arr.map((item) =>
|
||||
item !== null && typeof item === "object" ? (item as ProtoRequest) : {},
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// ExamService handlers(8 RPC)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function buildExamHandlers(
|
||||
service: ExamsService,
|
||||
): Record<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 handlers(5 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 handlers(6 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 handlers(4 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 handlers(4 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 handlers(P3.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 handlers(P3.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 handlers(P3.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 handlers(P3.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");
|
||||
}
|
||||
141
services/core-edu/src/homework/homework-state-machine.test.ts
Normal file
141
services/core-edu/src/homework/homework-state-machine.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { LeaveRequestsService } from "./leave-requests.service.js";
|
||||
|
||||
@Module({
|
||||
providers: [LeaveRequestsService],
|
||||
exports: [LeaveRequestsService],
|
||||
})
|
||||
export class LeaveRequestsModule {}
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 -> cancelled;approved/rejected/cancelled 不允许再取消
|
||||
if (existing.status !== "pending") {
|
||||
throw new ConflictError(
|
||||
`Cannot cancel leave request in status ${existing.status} (only pending allows cancel)`,
|
||||
);
|
||||
}
|
||||
await leaveRequestRepository.updateStatus(id, "cancelled", cancelledBy);
|
||||
}
|
||||
}
|
||||
@@ -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 server(9 Service / 40 RPC)
|
||||
await startGrpcServer(app);
|
||||
|
||||
logger.info(
|
||||
{ port: env.PORT, grpcPort: env.GRPC_PORT, service: "core-edu" },
|
||||
"CoreEdu service is listening (HTTP; gRPC port reserved for P3)",
|
||||
"CoreEdu service is listening (HTTP + gRPC)",
|
||||
);
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
logger.info({ signal }, "Shutting down gracefully...");
|
||||
await stopGrpcServer();
|
||||
await outboxPublisher.stop();
|
||||
await disconnectRedis();
|
||||
await disconnectKafka();
|
||||
|
||||
201
services/core-edu/src/scheduling/schedule-conflict.test.ts
Normal file
201
services/core-edu/src/scheduling/schedule-conflict.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
73
services/core-edu/src/shared/datascope/datascope-injector.ts
Normal file
73
services/core-edu/src/shared/datascope/datascope-injector.ts
Normal 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);
|
||||
}
|
||||
190
services/core-edu/test/grpc-smoke.mjs
Normal file
190
services/core-edu/test/grpc-smoke.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user