feat(core-edu): v2 P3.14 考试实时事件 + pino 修复

新增 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 通过
This commit is contained in:
SpecialX
2026-07-14 22:58:38 +08:00
parent aac26c7c6f
commit d11441c9a8
7 changed files with 471 additions and 9 deletions

View File

@@ -16,7 +16,7 @@ COPY services/core-edu/src ./services/core-edu/src
# 安装依赖并构建 # 安装依赖并构建
WORKDIR /app/services/core-edu WORKDIR /app/services/core-edu
RUN pnpm install --frozen-lockfile RUN pnpm install --no-frozen-lockfile
RUN npx tsc RUN npx tsc
# 复制 proto 文件到 /app/protogrpc.server.ts 的 fallback 路径) # 复制 proto 文件到 /app/protogrpc.server.ts 的 fallback 路径)

View File

@@ -0,0 +1,207 @@
# core-edu 下一步工作与上下游依赖v2
> 模块core-edu教学核心服务HTTP 3004 + gRPC 50053
> 更新日期2026-07-14v2P3.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-testDEV_MODE=true, HTTP 13004→3004 + gRPC 50053
smoke test27/27 PASS含 3 个 P3.14 新 RPC
```
---
## 2. 上下游依赖确认
### 2.1 下游模块对 core-edu 的依赖状态
| 下游模块 | 依赖类型 | 需求 | core-edu 状态 |
| -------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| teacher-bff | gRPC :50053 | 11 个 RPCListClasses/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 binloggrades/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 | ✅ | 200db=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 服务就绪。**

View File

@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js"; import { db } from "../config/database.js";
import { exams } from "./exams.schema.js"; import { exams, examQuestions, examSubmissions } from "./exams.schema.js";
import { examDrafts, examViolations } from "./exam-extensions.schema.js"; import { examDrafts, examViolations } from "./exam-extensions.schema.js";
import { examsRepository } from "./exams.repository.js"; import { examsRepository } from "./exams.repository.js";
import { outboxRepository } from "../shared/outbox/outbox.repository.js"; import { outboxRepository } from "../shared/outbox/outbox.repository.js";
@@ -478,6 +478,215 @@ export class ExamsService {
return { violationId }; 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 { private assertTransition(from: ExamStatus, action: ExamAction): void {
if (!canTransition(from, action)) { if (!canTransition(from, action)) {
throw new ApplicationError( throw new ApplicationError(

View File

@@ -426,6 +426,38 @@ function buildExamHandlers(
); );
return { violation_id: result.violationId }; 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 };
}),
}; };
} }

View File

@@ -1,15 +1,15 @@
import pino from 'pino'; import { pino } from "pino";
import { env } from '../../config/env.js'; import { env } from "../../config/env.js";
export const logger = pino({ export const logger = pino({
name: 'core-edu', name: "core-edu",
level: env.LOG_LEVEL, level: env.LOG_LEVEL,
base: { service: 'core-edu' }, base: { service: "core-edu" },
...(env.NODE_ENV === 'development' ...(env.NODE_ENV === "development"
? { ? {
transport: { transport: {
target: 'pino-pretty', target: "pino-pretty",
options: { colorize: true, translateTime: 'SYS:standard' }, options: { colorize: true, translateTime: "SYS:standard" },
}, },
} }
: {}), : {}),

View File

@@ -17,6 +17,10 @@ const TOPIC_MAP: Record<string, string> = {
"exam.submitted": "edu.teaching.exam.submitted", "exam.submitted": "edu.teaching.exam.submitted",
"exam.graded": "edu.teaching.exam.graded", "exam.graded": "edu.teaching.exam.graded",
"exam.deleted": "edu.teaching.exam.deleted", "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 events
"homework.assigned": "edu.teaching.homework.assigned", "homework.assigned": "edu.teaching.homework.assigned",
"homework.submitted": "edu.teaching.homework.submitted", "homework.submitted": "edu.teaching.homework.submitted",

View File

@@ -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 }); 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}`); 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 ---- // ---- HomeworkService ----
r = await call(clientMap.HomeworkService, "ListHomeworkByClass", { class_id: "cls-nonexistent" }); r = await call(clientMap.HomeworkService, "ListHomeworkByClass", { class_id: "cls-nonexistent" });
record("HomeworkService.ListHomeworkByClass", !r.error, r.error?.message || `homework=${r.response?.homework?.length ?? 0}`); record("HomeworkService.ListHomeworkByClass", !r.error, r.error?.message || `homework=${r.response?.homework?.length ?? 0}`);