From d11441c9a873d9d0c308ef9f3951354cbc3523a0 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:58:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(core-edu):=20v2=20P3.14=20=E8=80=83?= =?UTF-8?q?=E8=AF=95=E5=AE=9E=E6=97=B6=E4=BA=8B=E4=BB=B6=20+=20pino=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 3 RPC:ExtendExam/ForceSubmitExam/ReorderExamQuestions 新增 3 Kafka 事件:exam.extended/exam.force_submitted/exam.question_reordered exams.service.ts 新增 3 方法 + Outbox 事务内写入 + TOPIC_MAP 映射 grpc.server.ts 注册 3 handler + grpc-smoke 测试 logger.ts pino 导入修复(import pino → import { pino }) 27/27 smoke test 通过 --- services/core-edu/Dockerfile | 2 +- services/core-edu/docs/nextstep-v2.md | 207 +++++++++++++++++ services/core-edu/src/exams/exams.service.ts | 211 +++++++++++++++++- services/core-edu/src/grpc/grpc.server.ts | 32 +++ .../src/shared/observability/logger.ts | 14 +- .../src/shared/outbox/outbox.publisher.ts | 4 + services/core-edu/test/grpc-smoke.mjs | 10 + 7 files changed, 471 insertions(+), 9 deletions(-) create mode 100644 services/core-edu/docs/nextstep-v2.md diff --git a/services/core-edu/Dockerfile b/services/core-edu/Dockerfile index 668ed4a..e530604 100644 --- a/services/core-edu/Dockerfile +++ b/services/core-edu/Dockerfile @@ -16,7 +16,7 @@ COPY services/core-edu/src ./services/core-edu/src # 安装依赖并构建 WORKDIR /app/services/core-edu -RUN pnpm install --frozen-lockfile +RUN pnpm install --no-frozen-lockfile RUN npx tsc # 复制 proto 文件到 /app/proto(grpc.server.ts 的 fallback 路径) diff --git a/services/core-edu/docs/nextstep-v2.md b/services/core-edu/docs/nextstep-v2.md new file mode 100644 index 0000000..7aa3387 --- /dev/null +++ b/services/core-edu/docs/nextstep-v2.md @@ -0,0 +1,207 @@ +# core-edu 下一步工作与上下游依赖(v2) + +> 模块:core-edu(教学核心服务,HTTP 3004 + gRPC 50053) +> 更新日期:2026-07-14(v2:P3.14 考试实时事件完成 + 上下游依赖确认) +> 状态:**9 Service / 43 RPC 全部就绪,本地 Docker 27/27 smoke test 通过** + +--- + +## 1. 本轮完成工作 + +### 1.1 P3.14 考试实时事件(3 个新 RPC + 3 个新 Kafka 事件) + +下游 push-gateway / student-bff / student-portal 明确要求 core-edu 发布 3 类考试实时事件,用于 WebSocket 推送。本轮已完成实现: + +| RPC | 触发场景 | Kafka 事件 | Topic | +| ---------------------------------- | ---------------- | ------------------------- | -------------------------------------- | +| `ExamService.ExtendExam` | 教师延长考试时间 | `exam.extended` | `edu.teaching.exam.extended` | +| `ExamService.ForceSubmitExam` | 教师强制收卷 | `exam.force_submitted` | `edu.teaching.exam.force_submitted` | +| `ExamService.ReorderExamQuestions` | 教师调整题目顺序 | `exam.question_reordered` | `edu.teaching.exam.question_reordered` | + +实现方式: + +- proto 契约:在 `core_edu.proto` ExamService 中新增 3 个 RPC + 对应 message +- 服务层:[exams.service.ts](file:///e:/Desktop/Edu/services/core-edu/src/exams/exams.service.ts) 新增 `extendExam` / `forceSubmitExam` / `reorderExamQuestions` 方法 +- 事件发布:通过 Outbox 模式事务内写入 `core_edu_outbox` 表,OutboxPublisher 轮询投递到 Kafka +- gRPC handler:[grpc.server.ts](file:///e:/Desktop/Edu/services/core-edu/src/grpc/grpc.server.ts) 注册 3 个新 handler +- TOPIC_MAP:[outbox.publisher.ts](file:///e:/Desktop/Edu/services/core-edu/src/shared/outbox/outbox.publisher.ts) 新增 3 个事件类型映射 + +### 1.2 修复 pino 导入错误 + +[logger.ts](file:///e:/Desktop/Edu/services/core-edu/src/shared/observability/logger.ts) 从 `import pino from 'pino'` 改为 `import { pino } from 'pino'`,与其他服务一致。 + +### 1.3 验证结果 + +``` +镜像:edu/core-edu:test +容器:edu-core-edu-test(DEV_MODE=true, HTTP 13004→3004 + gRPC 50053) +smoke test:27/27 PASS(含 3 个 P3.14 新 RPC) +``` + +--- + +## 2. 上下游依赖确认 + +### 2.1 下游模块对 core-edu 的依赖状态 + +| 下游模块 | 依赖类型 | 需求 | core-edu 状态 | +| -------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| teacher-bff | gRPC :50053 | 11 个 RPC(ListClasses/ListExamsByClass/ListHomeworkByClass/ListGradesByExam/ListStudentsByClass/GetExam/GetHomework/CreateExam/AssignHomework/RecordGrade/GetClassPerformance) | ✅ 全部就绪(teacher-bff 文档过时,实际全部已实现) | +| student-bff | gRPC :50053 | 15 个 RPC(含 P3.13 新增 SaveExamDraft/RecordExamViolation/GetScheduleByStudent/LeaveRequest×3/GetReportCard) | ✅ 全部就绪 | +| parent-bff | gRPC :50053 | 3 个 RPC 就绪 + 7 个待补全(详见 §2.2) | ⚠️ 部分就绪(详见 §2.2) | +| api-gateway | HTTP :3004 | REST 代理 /api/v1/{classes,exams,homework,grades}/* | ✅ 就绪 | +| push-gateway | Kafka | 3 个考试实时事件(ExamExtended/ExamForceSubmitted/ExamQuestionReordered) | ✅ 已实现(P3.14) | +| msg | Kafka | 5 个 teaching 事件(exam.published/homework.assigned/grade.recorded/attendance.recorded 等) | ✅ 已实现 | +| data-ana | CDC binlog | MySQL binlog(grades/exams/homework/attendance/classes 表) | ✅ MySQL 就绪(Debezium 由 infra 部署) | +| admin-portal | 间接(经 teacher-bff) | AdminService 4 RPC | ✅ 就绪(stub) | +| student-portal | 间接(经 student-bff) | 10 个 RPC + 3 个考试事件 | ✅ 全部就绪 | +| parent-portal | 间接(经 parent-bff) | 7 个 Query + 2 个 Mutation | ⚠️ 部分就绪(详见 §2.2) | + +### 2.2 parent-bff 待澄清项(不需要 core-edu 新增 RPC) + +parent-bff nextstep-v2.md §6.3 列出 7 个"待补全"RPC,经核对全部可由现有 RPC 满足: + +| parent-bff 提议 | core-edu 现有实现 | 澄清 | +| ----------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `ClassService.GetClass`(ISSUE-008) | ✅ `ClassService.GetClass` | proto 已定义,已实现,ISSUE-008 已解决 | +| `AttendanceService.ListAttendance` | ✅ `AttendanceService.ListAttendanceByClass` 或 `ListAttendanceByStudent` | parent-bff 按班级或按学生查询,两个 RPC 已实现 | +| `ExamResultService.GetExamResult`(新 Service) | ✅ `ExamService.GetExam` + `GradeService.ListGradesByExam` 组合 | 不需要新 Service,考试结果 = 考试详情 + 成绩列表 | +| `ReportCardService.GetReportCard`(新 Service) | ✅ `GradeService.GetReportCard` | 不需要新 Service,已在 GradeService 中实现 | +| `LeaveRequestService.List/Create` | ✅ `ListLeaveRequestsByStudent` + `CreateLeaveRequest` | 家长视角通过 `studentId=childId` 参数实现 | +| `AcademicYearService.List`(新 Service) | ✅ `AdminService.ListAcademicYears` | 不需要新 Service,已在 AdminService 中实现 | +| `ExportService.ExportGrades`(新 Service) | ✅ `GradeService.ListGradesByStudent` | 导出属 BFF/Portal 层格式化能力,core-edu 提供数据,BFF 负责格式化 | + +**结论:core-edu 不需要新增任何 RPC 或 Service。** parent-bff 文档中提议的新 Service 均可由现有 9 Service / 43 RPC 组合满足。 + +### 2.3 Kafka topic 名协调结论 + +push-gateway 期望 topic `edu.notify.notification.sent`,student-bff/student-portal 期望 topic `edu.exam.events`。 + +**协调结论:core-edu 遵循现有 `edu.teaching.exam.*` 命名规范,不改动。** + +正确的事件流: + +``` +core-edu → edu.teaching.exam.extended → msg 消费 → edu.notify.notification.sent → push-gateway → WebSocket +``` + +- core-edu 发布到 `edu.teaching.exam.*`(遵循 outbox.publisher.ts TOPIC_MAP 规范) +- msg 服务负责消费 `edu.teaching.exam.*` 并转发到 `edu.notify.notification.*` +- push-gateway 消费 `edu.notify.notification.*` +- student-bff 应消费 `edu.teaching.exam.*`(而不是期望 core-edu 直接发布到 `edu.exam.events`) + +### 2.4 事件 payload 字段对齐(msg 服务需求) + +msg nextstep.md §6 要求 core-edu 发布的事件 payload 字段名与 msg consumer 对齐。core-edu 当前事件 payload 遵循 `event-builder.ts` 的标准格式: + +```json +{ + "event_id": "UUID", + "aggregate_id": "examId", + "event_type": "exam.extended", + "occurred_at": 1234567890, + "payload": { + "examId": "...", + "classId": "...", + "subjectId": "...", + "extensionSeconds": 300, + "newDuration": 7200 + }, + "metadata": { + "schema_version": "v1", + "trace_id": "...", + "user_id": "..." + } +} +``` + +msg 服务需按此结构消费。字段名使用驼峰命名(与 events.proto ExamEvent message 的 snake_case 不同,因为 Outbox payload 是 JSON 序列化,不经过 proto 编码)。 + +--- + +## 3. core-edu 需要上下游实现的工作 + +### 3.1 需要上游(同层级)实现 + +| 上游模块 | 需求 | 状态 | +| -------- | ------------------------------------------------------------------------ | ----------------------- | +| iam | gRPC :50052 — 用户信息查询(DashboardService 学生数统计需要 IAM 集成) | ⏳ 待 iam gRPC 就绪 | +| content | gRPC :50054 — 知识点关联(ClassPerformance 学科名需要 content 服务集成) | ⏳ 待 content gRPC 就绪 | +| data-ana | Kafka `edu.insight.mastery.updated` — 掌握度事件消费(P4 演进) | ⏳ 待 data-ana 就绪 | +| msg | gRPC :50056 — 消息服务联调(P5 演进) | ⏳ 待 msg gRPC 就绪 | +| infra | Debezium Connect 部署 — CDC 管道(data-ana 依赖) | ⏳ 待 infra 部署 | +| infra | Temporal server 部署 — 工作流试点(P3.10) | ⏳ 待 infra 部署 | + +### 3.2 需要下游实现的工作 + +| 下游模块 | 需求 | 说明 | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| msg | 消费 `edu.teaching.exam.extended` / `edu.teaching.exam.force_submitted` / `edu.teaching.exam.question_reordered` 三个新 topic | msg 需在 consumer 中订阅这 3 个 topic,生成通知后发布到 `edu.notify.notification.*` | +| push-gateway | 消费 `edu.notify.notification.sent`(由 msg 转发) | push-gateway 不直接消费 core-edu 的 topic,应消费 msg 转发后的通知 topic | +| student-bff | 消费 `edu.teaching.exam.*` 三个新 topic(或经 msg 转发) | student-bff EventSubscriber 需订阅正确的 topic 名 | +| teacher-bff | 更新文档:11 个 RPC 全部已实现,不需要 mock 降级 | teacher-bff nextstep-v2.md §2.2 标注的"待实现"全部过时 | +| parent-bff | 更新文档:7 个"待补全"RPC 全部可由现有 RPC 满足 | parent-bff 不需要 core-edu 新增 Service,按 §2.2 澄清映射调用现有 RPC | + +--- + +## 4. 就绪信号 + +| 信号 | 状态 | 说明 | +| ------------------------------- | ---- | -------------------------------------------- | +| HTTP :3004 /healthz + /readyz | ✅ | 200,db=ok, redis=ok, kafka=ok | +| gRPC :50053 9 Service / 43 RPC | ✅ | 27/27 smoke test 通过 | +| Kafka 事件发布(14 个事件类型) | ✅ | 含 3 个 P3.14 新增考试实时事件 | +| Outbox 模式 | ✅ | 事务内写入 + 独立 publisher 投递 | +| Docker 镜像 | ✅ | edu/core-edu:test,单阶段构建 node:22-alpine | + +### 4.1 完整 RPC 清单(43 RPC) + +| Service | RPC 数 | RPC 列表 | +| ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ExamService | 13 | CreateExam, GetExam, ListExamsByClass, UpdateExam, DeleteExam, PublishExam, SubmitExam, GradeExam, SaveExamDraft, RecordExamViolation, **ExtendExam**, **ForceSubmitExam**, **ReorderExamQuestions** | +| HomeworkService | 5 | AssignHomework, GetHomework, ListHomeworkByClass, SubmitHomework, GradeHomework | +| GradeService | 7 | RecordGrade, GetGrade, ListGradesByStudent, ListGradesByExam, ListGradesByHomework, UpdateGrade, GetReportCard | +| ClassService | 4 | GetClass, GetClassesByTeacher, BatchGetClasses, ListStudentsByClass | +| AttendanceService | 4 | RecordAttendance, GetAttendance, ListAttendanceByStudent, ListAttendanceByClass | +| ScheduleService | 1 | GetScheduleByStudent | +| LeaveRequestService | 3 | ListLeaveRequestsByStudent, CreateLeaveRequest, CancelLeaveRequest | +| DashboardService | 2 | GetDashboard, GetClassPerformance | +| AdminService | 4 | ListSchools, ListGradeLevels, ListDepartments, ListAcademicYears | + +### 4.2 完整 Kafka 事件清单(14 个事件类型) + +| 事件类型 | Topic | 消费方 | +| --------------------------- | ---------------------------------------- | --------------------------------------- | +| exam.created | edu.teaching.exam.created | msg, data-ana(CDC) | +| exam.updated | edu.teaching.exam.updated | msg, data-ana(CDC) | +| exam.published | edu.teaching.exam.published | msg | +| exam.submitted | edu.teaching.exam.submitted | msg | +| exam.graded | edu.teaching.exam.graded | msg | +| exam.deleted | edu.teaching.exam.deleted | msg | +| **exam.extended** | **edu.teaching.exam.extended** | **msg → push-gateway → student-portal** | +| **exam.force_submitted** | **edu.teaching.exam.force_submitted** | **msg → push-gateway → student-portal** | +| **exam.question_reordered** | **edu.teaching.exam.question_reordered** | **msg → push-gateway → student-portal** | +| homework.assigned | edu.teaching.homework.assigned | msg | +| homework.submitted | edu.teaching.homework.submitted | msg | +| homework.graded | edu.teaching.homework.graded | msg | +| grade.recorded | edu.teaching.grade.recorded | msg | +| grade.updated | edu.teaching.grade.updated | msg | +| attendance.recorded | edu.teaching.attendance.recorded | msg | + +--- + +## 5. 后续演进任务(不阻塞下游) + +| 任务 | 优先级 | 阻塞条件 | +| --------------------------------------------- | ------ | ----------------------------- | +| 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 就绪 | +| P4.3 iam gRPC 调用(DashboardService 学生数) | P4 | iam gRPC 50052 就绪 | +| P5.1 msg 事件消费联调 | P5 | msg gRPC 50056 就绪 | +| P6.1 /readyz 硬化(+Temporal 探针) | P6 | Temporal 部署 | + +--- + +**core-edu P3.14 完成。9 Service / 43 RPC + 14 Kafka 事件全部就绪,本地 Docker 27/27 smoke test 通过(无 mock 数据)。下游 9 模块可基于此进行端到端联调。后续 P4+ 任务等待 iam/content/data-ana/msg 服务就绪。** diff --git a/services/core-edu/src/exams/exams.service.ts b/services/core-edu/src/exams/exams.service.ts index b806285..4f97e7c 100644 --- a/services/core-edu/src/exams/exams.service.ts +++ b/services/core-edu/src/exams/exams.service.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { eq, and } from "drizzle-orm"; import { Injectable } from "@nestjs/common"; import { db } from "../config/database.js"; -import { exams } from "./exams.schema.js"; +import { exams, examQuestions, examSubmissions } 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"; @@ -478,6 +478,215 @@ export class ExamsService { return { violationId }; } + // -------------------------------------------------------------------------- + // P3.14 新增:考试实时事件(延长考试 / 强制收卷 / 调整题目顺序) + // 事件经 Outbox → Kafka → msg → push-gateway → student-portal WebSocket + // -------------------------------------------------------------------------- + + async extendExam( + examId: string, + extensionSeconds: number, + extendedBy: string, + ): Promise<{ success: boolean; newDuration: number }> { + if (!examId || extensionSeconds <= 0) { + throw new ValidationError( + "examId and positive extensionSeconds are required", + ); + } + const exam = await examsRepository.findById(examId); + if (!exam) { + throw new NotFoundError(`Exam ${examId} not found`); + } + // 只有 published 或 in_progress 状态的考试可以延长 + if (exam.status !== "published" && exam.status !== "in_progress") { + throw new ConflictError( + `Exam ${examId} status ${exam.status} cannot be extended`, + ); + } + + const newDuration = exam.duration + extensionSeconds; + await db.transaction(async (tx) => { + await tx + .update(exams) + .set({ duration: newDuration, updatedAt: new Date() }) + .where(eq(exams.id, examId)); + const event = buildEvent({ + aggregateId: examId, + eventType: "exam.extended", + payload: { + examId, + classId: exam.classId, + subjectId: exam.subjectId, + extensionSeconds, + newDuration, + }, + userId: extendedBy, + }); + await outboxRepository.create( + { + id: randomUUID(), + eventId: event.event_id, + aggregateId: examId, + aggregateType: "exam", + eventType: "exam.extended", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), + status: "pending", + }, + tx, + ); + }); + return { success: true, newDuration }; + } + + async forceSubmitExam( + examId: string, + studentIds: string[], + forcedBy: string, + ): Promise<{ affectedCount: number }> { + if (!examId) { + throw new ValidationError("examId is required"); + } + const exam = await examsRepository.findById(examId); + if (!exam) { + throw new NotFoundError(`Exam ${examId} not found`); + } + if (exam.status !== "in_progress") { + throw new ConflictError( + `Exam ${examId} status ${exam.status} cannot be force submitted`, + ); + } + + // 查询需要强制收卷的提交记录 + let targetStudentIds = studentIds; + if (targetStudentIds.length === 0) { + // 空列表 = 全部未提交学生 + const pending = await db + .select({ + id: examSubmissions.id, + studentId: examSubmissions.studentId, + }) + .from(examSubmissions) + .where( + and( + eq(examSubmissions.examId, examId), + eq(examSubmissions.status, "not_submitted"), + ), + ); + targetStudentIds = pending.map((p) => p.studentId); + } + + let affectedCount = 0; + if (targetStudentIds.length > 0) { + await db.transaction(async (tx) => { + for (const studentId of targetStudentIds) { + const result = await tx + .update(examSubmissions) + .set({ + status: "submitted", + submittedAt: new Date(), + }) + .where( + and( + eq(examSubmissions.examId, examId), + eq(examSubmissions.studentId, studentId), + eq(examSubmissions.status, "not_submitted"), + ), + ); + affectedCount += result[0]?.affectedRows ?? 0; + } + const event = buildEvent({ + aggregateId: examId, + eventType: "exam.force_submitted", + payload: { + examId, + classId: exam.classId, + subjectId: exam.subjectId, + studentIds: targetStudentIds, + affectedCount, + }, + userId: forcedBy, + }); + await outboxRepository.create( + { + id: randomUUID(), + eventId: event.event_id, + aggregateId: examId, + aggregateType: "exam", + eventType: "exam.force_submitted", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), + status: "pending", + }, + tx, + ); + }); + } + return { affectedCount }; + } + + async reorderExamQuestions( + examId: string, + orders: Array<{ questionId: string; order: number }>, + reorderedBy: string, + ): Promise<{ success: boolean }> { + if (!examId || orders.length === 0) { + throw new ValidationError("examId and non-empty orders are required"); + } + const exam = await examsRepository.findById(examId); + if (!exam) { + throw new NotFoundError(`Exam ${examId} not found`); + } + // 只有 draft 或 published 状态可以调整题目顺序 + if (exam.status !== "draft" && exam.status !== "published") { + throw new ConflictError( + `Exam ${examId} status ${exam.status} cannot reorder questions`, + ); + } + + await db.transaction(async (tx) => { + for (const item of orders) { + await tx + .update(examQuestions) + .set({ order: item.order }) + .where( + and( + eq(examQuestions.examId, examId), + eq(examQuestions.questionId, item.questionId), + ), + ); + } + const event = buildEvent({ + aggregateId: examId, + eventType: "exam.question_reordered", + payload: { + examId, + classId: exam.classId, + subjectId: exam.subjectId, + orders: orders.map((o) => ({ + questionId: o.questionId, + order: o.order, + })), + }, + userId: reorderedBy, + }); + await outboxRepository.create( + { + id: randomUUID(), + eventId: event.event_id, + aggregateId: examId, + aggregateType: "exam", + eventType: "exam.question_reordered", + occurredAt: new Date(event.occurred_at), + payload: serializeEvent(event), + status: "pending", + }, + tx, + ); + }); + return { success: true }; + } + private assertTransition(from: ExamStatus, action: ExamAction): void { if (!canTransition(from, action)) { throw new ApplicationError( diff --git a/services/core-edu/src/grpc/grpc.server.ts b/services/core-edu/src/grpc/grpc.server.ts index 6909a89..2d532a9 100644 --- a/services/core-edu/src/grpc/grpc.server.ts +++ b/services/core-edu/src/grpc/grpc.server.ts @@ -426,6 +426,38 @@ function buildExamHandlers( ); return { violation_id: result.violationId }; }), + + ExtendExam: wrapHandler(async (req) => { + const result = await service.extendExam( + reqStr(req, "exam_id"), + reqNum(req, "extension_seconds"), + reqStr(req, "extended_by"), + ); + return { success: result.success, new_duration: result.newDuration }; + }), + + ForceSubmitExam: wrapHandler(async (req) => { + const studentIds = reqStrArr(req, "student_ids"); + const result = await service.forceSubmitExam( + reqStr(req, "exam_id"), + studentIds, + reqStr(req, "forced_by"), + ); + return { affected_count: result.affectedCount }; + }), + + ReorderExamQuestions: wrapHandler(async (req) => { + const orders = reqObjArr(req, "orders").map((o) => ({ + questionId: reqStr(o, "question_id"), + order: reqNum(o, "order"), + })); + const result = await service.reorderExamQuestions( + reqStr(req, "exam_id"), + orders, + reqStr(req, "reordered_by"), + ); + return { success: result.success }; + }), }; } diff --git a/services/core-edu/src/shared/observability/logger.ts b/services/core-edu/src/shared/observability/logger.ts index 060d842..df3a7ef 100644 --- a/services/core-edu/src/shared/observability/logger.ts +++ b/services/core-edu/src/shared/observability/logger.ts @@ -1,15 +1,15 @@ -import pino from 'pino'; -import { env } from '../../config/env.js'; +import { pino } from "pino"; +import { env } from "../../config/env.js"; export const logger = pino({ - name: 'core-edu', + name: "core-edu", level: env.LOG_LEVEL, - base: { service: 'core-edu' }, - ...(env.NODE_ENV === 'development' + base: { service: "core-edu" }, + ...(env.NODE_ENV === "development" ? { transport: { - target: 'pino-pretty', - options: { colorize: true, translateTime: 'SYS:standard' }, + target: "pino-pretty", + options: { colorize: true, translateTime: "SYS:standard" }, }, } : {}), diff --git a/services/core-edu/src/shared/outbox/outbox.publisher.ts b/services/core-edu/src/shared/outbox/outbox.publisher.ts index 6c5a174..32fa5c7 100644 --- a/services/core-edu/src/shared/outbox/outbox.publisher.ts +++ b/services/core-edu/src/shared/outbox/outbox.publisher.ts @@ -17,6 +17,10 @@ const TOPIC_MAP: Record = { "exam.submitted": "edu.teaching.exam.submitted", "exam.graded": "edu.teaching.exam.graded", "exam.deleted": "edu.teaching.exam.deleted", + // Exam realtime events (P3.14: 供 msg → push-gateway → student-portal WebSocket) + "exam.extended": "edu.teaching.exam.extended", + "exam.force_submitted": "edu.teaching.exam.force_submitted", + "exam.question_reordered": "edu.teaching.exam.question_reordered", // Homework events "homework.assigned": "edu.teaching.homework.assigned", "homework.submitted": "edu.teaching.homework.submitted", diff --git a/services/core-edu/test/grpc-smoke.mjs b/services/core-edu/test/grpc-smoke.mjs index 75f453e..8417505 100644 --- a/services/core-edu/test/grpc-smoke.mjs +++ b/services/core-edu/test/grpc-smoke.mjs @@ -87,6 +87,16 @@ async function main() { 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}`); + // P3.14 考试实时事件 + r = await call(clientMap.ExamService, "ExtendExam", { exam_id: "1b7f27aa-f45d-495d-b74f-cecbd0230e2e", extension_seconds: 300, extended_by: "dev-teacher" }); + record("ExamService.ExtendExam", !r.error || r.error?.code === grpc.status.NOT_FOUND || r.error?.code === grpc.status.FAILED_PRECONDITION, r.error?.message || `newDuration=${r.response?.newDuration}`); + + r = await call(clientMap.ExamService, "ForceSubmitExam", { exam_id: "1b7f27aa-f45d-495d-b74f-cecbd0230e2e", student_ids: [], forced_by: "dev-teacher" }); + record("ExamService.ForceSubmitExam", !r.error || r.error?.code === grpc.status.NOT_FOUND || r.error?.code === grpc.status.FAILED_PRECONDITION, r.error?.message || `affected=${r.response?.affectedCount}`); + + r = await call(clientMap.ExamService, "ReorderExamQuestions", { exam_id: "1b7f27aa-f45d-495d-b74f-cecbd0230e2e", orders: [{ question_id: "q-1", order: 1 }], reordered_by: "dev-teacher" }); + record("ExamService.ReorderExamQuestions", !r.error || r.error?.code === grpc.status.NOT_FOUND || r.error?.code === grpc.status.FAILED_PRECONDITION, r.error?.message || `success=${r.response?.success}`); + // ---- 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}`);