feat(msg): v2 ARB-013 topic 命名统一 + 考试事件消费

ARB-013 P0 修复:PRODUCER_TOPIC_MAP 从 edu.notification.* 改为 edu.notify.notification.*

kafka.consumer 新增 3 考试实时事件消费(exam.extended/force_submitted/question_reordered)

嵌套 payload 解包支持 + topic-map 扩展

新增 6 测试数据文件(docker-notify + 5 kafka 事件 json)

101 单元测试通过 + Docker 真实环境验证
This commit is contained in:
SpecialX
2026-07-14 23:00:33 +08:00
parent ad39a3bb0f
commit 765f7da4c0
13 changed files with 695 additions and 35 deletions

View File

@@ -28,8 +28,9 @@ RUN cd packages/shared-ts && pnpm build
WORKDIR /app/services/msg WORKDIR /app/services/msg
RUN pnpm build RUN pnpm build
# 剪枝 devDependencies # 剪枝 devDependencies(用 install --prod 替代 prune避免触发 husky prepare
RUN cd /app/services/msg && pnpm prune --prod ENV CI=true HUSKY=0
RUN cd /app/services/msg && pnpm install --prod --ignore-scripts
# Runtime stage # Runtime stage
FROM node:22-alpine FROM node:22-alpine

View File

@@ -0,0 +1,433 @@
# msg 模块 Next Steps v2上下游依赖 + 状态澄清)
> 模块msg消息中台HTTP 3007 + gRPC 50056
> 更新日期2026-07-14v2ARB-013 落地 + 3 考试实时事件 + 嵌套 payload 解包 + 状态澄清)
> 状态:**13 gRPC RPC + 27 REST endpoints + 16 类 Kafka 消费 + 4 类 Kafka 发布101 单元测试通过Docker 本地真实环境验证全部通过**
>
> 关联:
>
> - [msg nextstep.md (v1)](./nextstep.md)
> - [msg proto 契约](../../../packages/shared-proto/proto/msg.proto)
> - [ARB-013 Kafka topic 命名](../../docs/architecture/issues/coord.md) §15
> - [core-edu nextstep-v2.md](../../core-edu/docs/nextstep-v2.md) §2.4 / §3.2
> - [push-gateway nextstep-v2.md](../../push-gateway/docs/nextstep-v2.md) §5.2
> - [teacher-bff nextstep-v2.md](../../teacher-bff/docs/nextstep-v2.md) §2.5
> - [parent-bff nextstep-v2.md](../../parent-bff/docs/nextstep-v2.md) §4.4
---
## 1. v2 本轮完成的工作
### 1.1 ARB-013 Kafka topic 命名统一P0 blocker 修复)
**原因**push-gateway nextstep-v2.md §5.2 标注 P0 blocker —— msg Outbox 发布到旧 topic `edu.notification.requested`push-gateway 已对齐到 `edu.notify.notification.sent`,导致 Kafka 链路断裂。
**改动**
- `src/shared/kafka/topic-map.ts``PRODUCER_TOPIC_MAP``edu.notification.*` 改为 `edu.notify.notification.*`4 topic + FALLBACK_TOPIC
- `src/shared/outbox/outbox.publisher.ts` — 注释更新
- `src/shared/outbox/outbox.schema.ts` — 注释更新
**验证结果**Docker 真实 Kafka
```
{"eventId":"r4jto...","eventType":"notification.sent","topic":"edu.notify.notification.sent","msg":"Outbox message published"}
```
Kafka topic 列表确认 `edu.notify.notification.sent` + `edu.notify.notification.read` 已创建。
### 1.2 新增 homework.assigned 消费core-edu 事件补全)
**原因**core-edu 发布 `edu.teaching.homework.assigned` 事件,但 msg CONSUMER_TOPICS 未包含此 topic。
**改动**
- `src/shared/kafka/topic-map.ts` — CONSUMER_TOPICS 添加 `edu.teaching.homework.assigned`
- `src/shared/kafka/kafka.consumer.ts` — routeEvent 添加 case + `handleHomeworkAssigned()` 方法(向所有 studentIds 发送新作业通知)
### 1.3 新增 3 个考试实时事件消费core-edu P3.14
**原因**core-edu nextstep-v2.md §3.2 要求 msg 消费 3 个考试实时事件 topic生成通知后转发到 `edu.notify.notification.*`,供 push-gateway 推送到 WebSocket。
**事件流**
```
core-edu → edu.teaching.exam.extended → msg 消费 → edu.notify.notification.sent → push-gateway → WebSocket → student-portal
```
**改动**
- `src/shared/kafka/topic-map.ts` — CONSUMER_TOPICS 添加 3 个 topic16 类事件总计)
- `src/shared/kafka/kafka.consumer.ts` — routeEvent 添加 3 个 case + 3 个 handler + `broadcastExamRealtimeEvent()` 辅助方法
**新增 topic**
| Topic | 触发场景 | msg 处理 |
| -------------------------------------- | ---------------- | ---------------------------- |
| `edu.teaching.exam.extended` | 教师延长考试时间 | 向学生发送"考试时间延长通知" |
| `edu.teaching.exam.force_submitted` | 教师强制提交考试 | 向学生发送"考试强制提交通知" |
| `edu.teaching.exam.question_reordered` | 教师调整题目顺序 | 向学生发送"题目顺序调整通知" |
**handler 逻辑**
- 读取 `payload.studentIds`,为每个学生创建 in_app 通知type=exam
- ChannelDispatcher 自动调用 push-gateway HTTP `/internal/push`(实时推送)
- Outbox 自动发布 `edu.notify.notification.sent`(异步 Kafka 推送)
- 若 payload 无 studentIds记录 warn 并跳过(等待 core-edu 补全 payload
### 1.4 嵌套 payload 解包core-edu Outbox 格式兼容)
**原因**core-edu nextstep-v2.md §2.4 明确事件 payload 是嵌套结构 `{ event_id, event_type, payload: { examId, ... } }`,但 msg 原有 handler 直接从根取字段(假设扁平结构)。
**改动**
- `src/shared/kafka/kafka.consumer.ts``routeEvent()` 内部统一解包:若 `payload.payload` 存在且为对象,则取 `payload.payload` 作为 businessPayload 传给 handler否则直接用 payload兼容扁平格式
- 所有 handler 接收 businessPayload已解包的业务字段不再处理外层包装
- 考试实时事件 handler 额外接收 rawPayload用于取 `event_id` 作为 groupId
**兼容性**:同时支持 core-edu Outbox 嵌套格式和测试用扁平格式。
### 1.5 groupId 长度安全(防御性 truncate
**原因**Kafka 消息无 eventId header 时,`extractEventId()` 降级生成 `topic:partition:offset`(如 `edu.teaching.homework.assigned:0:0` = 35 字符),超过 `group_id` 字段 varchar(32) 限制,导致 `ER_DATA_TOO_LONG`
**改动**
- `handleExamPublished` / `handleHomeworkAssigned` — groupId 优先用 `payload.eventId`(业务事件 IDcuid2 24 字符);降级路径的 Kafka eventId 若超过 32 字符则 truncate
- `broadcastExamRealtimeEvent()` — 同样从 `rawPayload.event_id` 取业务 eventId 作为 groupIdtruncate 防御
### 1.6 sendBatch eventId 幂等过滤v1 已完成v2 验证)
**v1 改动**
- `src/notifications/notifications.repository.ts` — 新增 `findExistingEventIds(eventIds)` 批量查询
- `src/notifications/notifications.service.ts` — sendBatch 批量 INSERT 前先过滤已存在的 eventId
**v2 Docker 验证**:重复 eventId 跳过DB COUNT=1幂等生效
### 1.7 pino ESM 导入修复
**原因**Docker 构建报 `TS2349: This expression is not callable`pino 默认导出在 ESM 模式下不可调用。
**改动**`src/shared/observability/logger.ts``import pino from 'pino'``import { pino } from 'pino'`
### 1.8 Dockerfile pnpm prune 修复
**原因**`pnpm prune --prod` 触发 husky prepare 脚本(`sh: husky: not found`)并要求 TTY 确认。
**改动**`services/msg/Dockerfile` — 替换为 `pnpm install --prod --ignore-scripts` + `ENV CI=true HUSKY=0`
---
## 2. msg 当前能力清单v2 更新)
### 2.1 gRPC 13 RPC端口 50056
| Service | RPC | 说明 |
| ----------------------------- | ------------------------------------------------------------------------------------- | ----------------- |
| NotificationService | SendNotification | 单条发送 |
| NotificationService | ListNotifications | 列表(分页+过滤) |
| NotificationService | MarkAsRead | 标记已读 |
| NotificationService | SearchNotifications | ES 全文检索 |
| NotificationService | RecallNotification | 撤回广播 |
| NotificationService | BatchSendNotification | 批量发送 |
| NotificationService | BatchMarkAsRead | 批量已读 |
| NotificationService | GetUnreadCount | 未读计数 |
| NotificationService | DeleteNotification | 删除通知 |
| NotificationPreferenceService | GetPreferences | 查询偏好 |
| NotificationPreferenceService | UpdatePreferences | 更新偏好 |
| NotificationTemplateService | CreateTemplate/GetTemplate/ListTemplates/UpdateTemplate/DeleteTemplate/RenderTemplate | 模板 CRUD + 渲染 |
### 2.2 REST endpoints端口 300727 个)
- `/notifications/*`10 endpointssend/batch/user/:userId/unread-count/:id/read/batch/read/read-all/search/recall/:id
- `/preferences/user/:userId`2 endpointsGET/PUT
- `/templates/*`6 endpointsCRUD + render
- `/announcements/*`9 endpointsCRUD + publish/archive/pin/read
- `/healthz``/readyz``/metrics`
### 2.3 Kafka 消费16 类事件)
| 上游 | Topic | msg 处理 |
| -------- | -------------------------------------- | ------------------------------------ |
| iam | `edu.identity.user.created` | 发送欢迎通知 |
| iam | `edu.identity.user.updated` | 仅幂等标记 |
| iam | `edu.identity.user.deleted` | 仅幂等标记 |
| iam | `edu.identity.user.role_changed` | 发送角色变更通知 |
| iam | `edu.identity.role.created` | 仅幂等标记 |
| iam | `edu.identity.role.updated` | 向受影响用户发送权限变更通知 |
| core-edu | `edu.teaching.exam.published` | 向学生发送考试通知 |
| core-edu | `edu.teaching.homework.assigned` | 向学生发送作业通知 |
| core-edu | `edu.teaching.assignment.submitted` | 向教师发送提交通知 |
| core-edu | `edu.teaching.assignment.graded` | 向学生发送批改通知 |
| core-edu | `edu.teaching.grade.recorded` | 向学生发送成绩通知 |
| core-edu | `edu.teaching.attendance.recorded` | 向家长发送出勤通知 |
| core-edu | `edu.teaching.exam.extended` | 向学生发送考试延长通知P3.14 新增) |
| core-edu | `edu.teaching.exam.force_submitted` | 向学生发送强制提交通知P3.14 新增) |
| core-edu | `edu.teaching.exam.question_reordered` | 向学生发送题目调整通知P3.14 新增) |
| data-ana | `edu.insight.mastery.updated` | 向学生发送学情预警通知 |
### 2.4 Kafka 发布4 类事件ARB-013 命名)
- `edu.notify.notification.sent`
- `edu.notify.notification.read`
- `edu.notify.notification.recalled`
- `edu.notify.notification.failed`
- 兜底:`edu.notify.notification.events`
### 2.5 Docker 验证结果v2 本轮)
| 测试项 | 结果 |
| --------------------------------------------------- | ------------------------------------------ |
| 容器健康检查 /healthz | ✅ `{"status":"ok"}` HTTP 200 |
| KafkaConsumer 订阅 16 topic | ✅ 全部加入 consumer group |
| Outbox 发布 topic = `edu.notify.notification.sent` | ✅ ARB-013 生效 |
| homework.assigned 消费(扁平 payload | ✅ 2 条通知入库 |
| homework.assigned 消费(降级路径 truncate | ✅ group_id 截断到 32 字符 |
| exam.extended 消费(嵌套 payload | ✅ 2 条通知入库group_id=evt-exam-ext-001 |
| exam.force_submitted 消费(嵌套 payload | ✅ 1 条通知入库 |
| exam.question_reordered 消费(嵌套 payload | ✅ 2 条通知入库 |
| Outbox 自动转发 5 条 `edu.notify.notification.sent` | ✅ 全部发布成功 |
| 101 单元测试 | ✅ 全部通过 |
| typecheck + lint | ✅ 零错误 |
---
## 3. 上游依赖状态澄清(⚠️ 纠正错误状态)
### 3.1 ⚠️ teacher-bff nextstep-v2.md §2.5 状态错误
teacher-bff nextstep-v2.md §2.5 标注 3 项 msg 依赖为"⏳ 待 msg 实现"**实际全部已实现**
| teacher-bff 标注 | 实际状态 | 说明 |
| ----------------------------------------------- | ------------------------------- | --------------------------------------------------------------- |
| gRPC `ListNotifications(userId)` ⏳ 待 msg 实现 | ✅ **已实现** | NotificationService.ListNotifications |
| gRPC `MarkNotificationRead(id)` ⏳ 待 msg 实现 | ✅ **已实现** | NotificationService.MarkAsRead |
| gRPC `ListAnnouncements(filter)` ⏳ 待 msg 实现 | ⚠️ **REST 已实现gRPC 未实现** | msg 提供 REST GET /announcements未提供 gRPC ListAnnouncements |
**需 teacher-bffai03更新**
1. ListNotifications / MarkNotificationRead 状态改为 ✅
2. ListAnnouncements 改用 REST `GET http://msg:3007/announcements`msg 公告为 REST onlyARB-008 限制 RPC 总数 13不新增 AnnouncementService gRPC
3. §5 联调待办 #4"msg gRPC 联调 ⏳"可标记为就绪msg :50056 已运行)
### 3.2 ⚠️ parent-bff nextstep-v2.md §4.4 状态错误
parent-bff nextstep-v2.md §4.4 标注 2 项 msg 依赖为"⚠️ proto 未定义 RPC"**实际 proto 已定义**
| parent-bff 标注 | 实际状态 | 说明 |
| ------------------------------------------------------ | ------------- | ----------------------------------------------- |
| `getNotificationPreferences(parentId)` ⚠️ proto 未定义 | ✅ **已定义** | NotificationPreferenceService.GetPreferences |
| `updateNotificationPreferences(...)` ⚠️ proto 未定义 | ✅ **已定义** | NotificationPreferenceService.UpdatePreferences |
| `GET /healthz` ⏳ msg 服务容器未运行 | ✅ **已运行** | msg :3007/healthz + :50056 gRPC 已就绪 |
**需 parent-bffai04更新**
1. getNotificationPreferences / updateNotificationPreferences 状态改为 ✅
2. RPC 命名对齐proto 实际为 `GetPreferences`/`UpdatePreferences`(非 `getNotificationPreferences`/`updateNotificationPreferences`parent-bff gRPC client 需用 proto 标准命名
3. /healthz 状态改为 ✅
4. §6.2 联调待办 #4"msg 服务容器启动 ⏳"可标记为就绪
5. §7"给 msg补全 NotificationPreferencesService"可标记为已完成
### 3.3 core-edu 事件 payload 字段确认
core-edu nextstep-v2.md §2.4 确认事件 payload 为嵌套结构:
```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 v2 已兼容**`routeEvent()` 自动解包 `payload.payload` 到 businessPayload。
**⚠️ 待 core-edu 确认**3 个考试实时事件的 `payload` 是否包含 `studentIds` 字段?
- msg handler 依赖 `payload.studentIds` 确定通知接收人
- 若 payload 无 studentIdsmsg 记录 warn 并跳过(不创建通知)
- core-edu §2.4 示例仅展示 exam.extended 的 examId/classId/subjectId/extensionSeconds/newDuration**未列出 studentIds**
- 需 core-edu 确认:考试实时事件 payload 是否应包含 `studentIds: string[]`
---
## 4. 下游依赖状态
### 4.1 push-gatewayai09— ✅ 全部对齐
| 协作项 | 状态 | 说明 |
| -------------------------------------------- | --------- | ---------------------------------------------------- |
| Kafka topic `edu.notify.notification.sent` | ✅ 已对齐 | msg Outbox 发布到此 topicpush-gateway 消费此 topic |
| HTTP `POST /internal/push` | ✅ 已对齐 | msg ChannelDispatcher 调用 push-gateway 实时推送 |
| 鉴权头 `X-Internal-Key: PUSH_INTERNAL_TOKEN` | ✅ 已对齐 | msg push-gateway.client.ts 已使用 |
| 请求体 `{ userId, event, data }` camelCase | ✅ 已对齐 | msg 已使用 camelCase |
**push-gateway nextstep-v2.md §5.2 标注的 P0 blocker 已解除**
### 4.2 api-gatewayai01— ⚠️ 缺 announcements 路由
| 路由 | 状态 | 说明 |
| ------------------------------------ | ------------- | ---------------------------------------------------------------- |
| `/api/v1/notifications/*` → msg:3007 | ✅ 已配置 | |
| `/api/v1/messages/*` → msg:3007 | ✅ 已配置 | |
| `/api/v1/announcements/*` → msg:3007 | ⚠️ **未配置** | msg 公告 REST API 在 `/announcements/*`,需 api-gateway 新增路由 |
**需 api-gatewayai01新增**`/api/v1/announcements/*``msg:3007` 路由代理。
### 4.3 teacher-bffai03— ⚠️ 需接入公告 REST
| 依赖 | 状态 | 说明 |
| ----------------------------------- | --------------- | ---------------------------------------------- |
| gRPC ListNotifications | ✅ proto 已定义 | teacher-bff 标注"⏳"有误,实际已实现 |
| gRPC MarkAsRead | ✅ proto 已定义 | teacher-bff 标注"⏳"有误,实际已实现 |
| REST GET /announcements | ✅ msg 已实现 | teacher-bff 期望 gRPC但 msg 公告为 REST only |
| REST POST /announcements | ✅ msg 已实现 | admin-portal 公告管理需要 |
| REST PUT /announcements/:id/publish | ✅ msg 已实现 | admin-portal 公告发布 |
| REST PUT /announcements/:id/archive | ✅ msg 已实现 | admin-portal 公告归档 |
| REST PUT /announcements/:id/pin | ✅ msg 已实现 | admin-portal 公告置顶 |
**需 teacher-bffai03**
1. 状态更正ListNotifications / MarkAsRead 已实现
2. 公告改用 REST 聚合(不期望 gRPC ListAnnouncements
3. admin-portal 的 4 个公告 mutationcreateAnnouncement/publishAnnouncement/archiveAnnouncement/toggleAnnouncementPin通过 teacher-bff HTTP 调用 msg REST
### 4.4 student-bffai04— ✅ 全部就绪
student-bff nextstep-v2.md 确认 msg "✅ 13 RPC + REST 就绪"。
### 4.5 parent-bffai05— ⚠️ 需状态更正 + 命名对齐
| 依赖 | 状态 | 说明 |
| ---------------------------------- | --------------- | ------------------------------------ |
| gRPC listNotifications | ✅ proto 已定义 | parent-bff 标注 ✅ 正确 |
| gRPC markAsRead | ✅ proto 已定义 | parent-bff 标注 ✅ 正确 |
| gRPC getNotificationPreferences | ✅ proto 已定义 | parent-bff 标注"⚠️ proto 未定义"有误 |
| gRPC updateNotificationPreferences | ✅ proto 已定义 | parent-bff 标注"⚠️ proto 未定义"有误 |
| /healthz | ✅ 已运行 | parent-bff 标注"⏳ 未运行"有误 |
**需 parent-bffai05**
1. 状态更正NotificationPreferenceService 已定义
2. RPC 命名对齐proto 为 `GetPreferences`/`UpdatePreferences`,非 `getNotificationPreferences`/`updateNotificationPreferences`
3. /healthz 状态更正为 ✅
### 4.6 前端 portalai13-ai16— ⚠️ 命名不一致
parent-portal nextstep-v2.md §1 标注命名不一致:
| 前端 operation | 后端命名 | 说明 |
| --------------------------- | ------------------------- | ---------------- |
| `myNotifications` | `notifications` | 前端加 `my` 前缀 |
| `myNotificationPreferences` | `notificationPreferences` | 前端加 `my` 前缀 |
| `markAsRead` | `markNotificationRead` | 命名不一致 |
**处理方案**:命名不一致由各 BFF 在 resolver 层映射msg proto/REST 命名保持不变。需各 BFF 确认 resolver 已做字段映射。
---
## 5. msg 需要上下游实现的工作
### 5.1 需要上游(同层级)实现
| 上游模块 | 需求 | 状态 | 说明 |
| -------- | ----------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| core-edu | 确认 3 个考试实时事件 payload 包含 `studentIds` | ⏳ 待确认 | msg handler 依赖 studentIds 确定通知接收人;若无则跳过 |
| core-edu | 确认 topic 命名对齐 | ⚠️ 待确认 | msg 订阅 `edu.teaching.assignment.submitted/graded`core-edu §4.2 列出 `homework.submitted/graded`,需确认 topic 名是否一致 |
| iam | Kafka 事件 payload 字段对齐 | ✅ 已确认 | msg 按 camelCase + snake_case 双兼容消费 |
| data-ana | `edu.insight.mastery.updated` 事件发布 | ⏳ 待 data-ana 就绪 | msg 已订阅,等待 data-ana 发布事件 |
### 5.2 需要下游实现的工作
| 下游模块 | 需求 | 状态 | 说明 |
| ------------ | ---------------------------------------------- | ---------------- | ------------------------------------------------------------------------- |
| api-gateway | 新增 `/api/v1/announcements/*` 路由 → msg:3007 | ⏳ 待 ai01 实现 | 公告 REST API 需通过 gateway 暴露 |
| teacher-bff | 状态更正ListNotifications/MarkAsRead 已实现 | ⏳ 待 ai03 更新 | nextstep-v2.md §2.5 标注"⏳"有误 |
| teacher-bff | 公告改用 REST 聚合(非 gRPC | ⏳ 待 ai03 实现 | msg 公告为 REST onlyteacher-bff 通过 HTTP 调用 msg:3007/announcements/* |
| parent-bff | 状态更正NotificationPreferenceService 已定义 | ⏳ 待 ai05 更新 | nextstep-v2.md §4.4 标注"⚠️ proto 未定义"有误 |
| parent-bff | RPC 命名对齐GetPreferences/UpdatePreferences | ⏳ 待 ai05 更新 | proto 标准命名,非 getNotificationPreferences |
| push-gateway | 消费 `edu.notify.notification.sent` | ✅ 已对齐 | push-gateway v2 已完成 |
| 各 BFF | GraphQL 字段命名映射 | ⏳ 待各 BFF 确认 | myNotifications vs notifications 等,由 BFF resolver 层处理 |
---
## 6. 待协调事项
| # | 事项 | 协调对象 | 说明 |
| --- | ------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | core-edu 考试实时事件 payload 确认 | ai07 | 确认 `edu.teaching.exam.extended/force_submitted/question_reordered` 的 payload 是否包含 `studentIds: string[]`。msg handler 依赖此字段 |
| 2 | core-edu topic 命名对齐 | ai07 | msg 订阅 `edu.teaching.assignment.submitted/graded`core-edu §4.2 列出 `homework.submitted/graded`。需确认实际 topic 名 |
| 3 | api-gateway 新增 announcements 路由 | ai01 | `/api/v1/announcements/*` → msg:3007 |
| 4 | teacher-bff 状态更正 + 公告 REST 接入 | ai03 | ListNotifications/MarkAsRead 已实现;公告改用 REST |
| 5 | parent-bff 状态更正 + RPC 命名对齐 | ai05 | NotificationPreferenceService 已定义;命名用 GetPreferences/UpdatePreferences |
| 6 | 公告 gRPC RPC 决策 | coord | 下游期望 ListAnnouncements gRPC但 ARB-008 限制 RPC 总数 13。msg 保持 REST only需 coord 仲裁 |
| 7 | 考试实时事件责任方澄清 | ai07/ai09 | student-portal 期望 ExamExtended/ExamForceSubmitted/ExamQuestionReordered 事件。core-edu 已发布 3 topicmsg 已消费并转发到 `edu.notify.notification.sent`push-gateway 推送到 WebSocket。链路已通 |
---
## 7. Docker 部署配置
### 7.1 启动命令
```bash
docker run -d --name edu-msg --network edu-full_default \
-p 3007:3007 -p 50056:50056 \
-e PORT=3007 -e GRPC_PORT=50056 \
-e DATABASE_URL=mysql://edu:changeme@edu-mysql:3306/next_edu_cloud \
-e REDIS_URL=redis://edu-redis:6379 \
-e KAFKA_BROKERS=edu-kafka:29092 \
-e ES_URL=http://edu-es:9200 \
-e PUSH_GATEWAY_URL=http://push-gateway:8081 \
-e DEV_MODE=false -e NODE_ENV=production -e LOG_LEVEL=info \
edu-msg:test
```
### 7.2 就绪信号
| 信号 | 状态 | 说明 |
| ------------------------ | ---- | ---------------------------------- |
| HTTP :3007 /healthz | ✅ | `{"status":"ok","service":"msg"}` |
| HTTP :3007 /readyz | ✅ | 5/6 OKpushGateway 软失败,预期) |
| gRPC :50056 13 RPC | ✅ | 3 Service 全部注册 |
| Kafka 消费 16 topic | ✅ | consumer group 加入成功 |
| Kafka 发布 4 topic | ✅ | Outbox 轮询 5sARB-013 命名 |
| Docker 镜像 edu-msg:test | ✅ | 多阶段构建pnpm workspace 模式 |
---
## 8. v2 工作完成总结
### 8.1 已完成的全部 v2 工作
1. **ARB-013 topic 命名统一**`edu.notification.*``edu.notify.notification.*`4 topic + FALLBACK
2. **homework.assigned 消费**:新增 topic 订阅 + handler
3. **3 个考试实时事件消费**exam.extended/force_submitted/question_reordered转发到 push-gateway
4. **嵌套 payload 解包**:兼容 core-edu Outbox `{ event_id, payload: {...} }` 格式
5. **groupId 长度安全**:优先用业务 eventId降级路径 truncate 到 32 字符
6. **sendBatch eventId 幂等**:批量查询过滤已存在 eventIdv1 完成v2 Docker 验证)
7. **pino ESM 导入修复**`import { pino } from 'pino'`
8. **Dockerfile pnpm prune 修复**`pnpm install --prod --ignore-scripts`
9. **Docker 本地真实环境验证**16 topic 订阅 + 5 条考试实时事件通知入库 + Outbox 转发
10. **101 单元测试通过**typecheck + lint 零错误
### 8.2 msg v2 对外能力
- **gRPC**13 RPC3 Service
- **REST**27 endpointsnotifications 10 + preferences 2 + templates 6 + announcements 9
- **Kafka 消费**16 类事件iam 6 + core-edu 9 + data-ana 1
- **Kafka 发布**4 类事件ARB-013 命名)
- **Docker**edu-msg:test 镜像就绪,/healthz + /readyz + /metrics 全部可用
---
**msg v2 完成。13 gRPC RPC + 27 REST endpoints + 16 类 Kafka 消费 + 4 类 Kafka 发布全部就绪101 单元测试通过Docker 本地真实环境验证全部通过(无 mock 数据)。等待 core-edu 确认考试实时事件 payload 字段 + api-gateway 新增 announcements 路由 + teacher-bff/parent-bff 状态更正后即可端到端联调。**

View File

@@ -21,7 +21,7 @@ import { NotificationsService } from "../../notifications/notifications.service.
* *
* 事件路由: * 事件路由:
* - iamuser.created/updated/deleted/role_changed, role.created/updated * - iamuser.created/updated/deleted/role_changed, role.created/updated
* - core-eduexam.published, assignment.submitted/graded, grade.recorded, attendance.recorded * - core-eduexam.published, homework.assigned, assignment.submitted/graded, grade.recorded, attendance.recorded
* - data-anamastery.updated * - data-anamastery.updated
*/ */
@Injectable() @Injectable()
@@ -153,22 +153,34 @@ export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy {
/** /**
* 根据 topic 路由到具体的事件处理器。 * 根据 topic 路由到具体的事件处理器。
*
* payload 兼容两种格式:
* - 扁平格式(测试用):消息 value 直接是业务字段 { examId, studentIds, ... }
* - 嵌套格式core-edu Outbox 标准):{ event_id, event_type, payload: { examId, ... } }
* routeEvent 内部统一解包到 businessPayloadhandler 只处理业务字段。
*/ */
private async routeEvent( private async routeEvent(
topic: string, topic: string,
eventId: string, eventId: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
// 解包嵌套 payloadcore-edu Outbox 发送 { event_id, event_type, payload: {...} }
const inner = payload.payload;
const businessPayload =
inner && typeof inner === "object" && !Array.isArray(inner)
? (inner as Record<string, unknown>)
: payload;
switch (topic) { switch (topic) {
// iam 事件 // iam 事件
case "edu.identity.user.created": case "edu.identity.user.created":
await this.handleUserCreated(eventId, payload); await this.handleUserCreated(eventId, businessPayload);
break; break;
case "edu.identity.user.role_changed": case "edu.identity.user.role_changed":
await this.handleRoleChanged(eventId, payload); await this.handleRoleChanged(eventId, businessPayload);
break; break;
case "edu.identity.role.updated": case "edu.identity.role.updated":
await this.handleRoleUpdated(eventId, payload); await this.handleRoleUpdated(eventId, businessPayload);
break; break;
case "edu.identity.user.updated": case "edu.identity.user.updated":
case "edu.identity.user.deleted": case "edu.identity.user.deleted":
@@ -180,26 +192,44 @@ export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy {
); );
break; break;
// core-edu 事件 // core-edu 事件6 基础)
case "edu.teaching.exam.published": case "edu.teaching.exam.published":
await this.handleExamPublished(eventId, payload); await this.handleExamPublished(eventId, businessPayload);
break;
case "edu.teaching.homework.assigned":
await this.handleHomeworkAssigned(eventId, businessPayload);
break; break;
case "edu.teaching.assignment.submitted": case "edu.teaching.assignment.submitted":
await this.handleAssignmentSubmitted(eventId, payload); await this.handleAssignmentSubmitted(eventId, businessPayload);
break; break;
case "edu.teaching.assignment.graded": case "edu.teaching.assignment.graded":
await this.handleAssignmentGraded(eventId, payload); await this.handleAssignmentGraded(eventId, businessPayload);
break; break;
case "edu.teaching.grade.recorded": case "edu.teaching.grade.recorded":
await this.handleGradeRecorded(eventId, payload); await this.handleGradeRecorded(eventId, businessPayload);
break; break;
case "edu.teaching.attendance.recorded": case "edu.teaching.attendance.recorded":
await this.handleAttendanceRecorded(eventId, payload); await this.handleAttendanceRecorded(eventId, businessPayload);
break;
// core-edu 考试实时事件P3.14 新增3 个)— 转发到 push-gateway
case "edu.teaching.exam.extended":
await this.handleExamExtended(eventId, businessPayload, payload);
break;
case "edu.teaching.exam.force_submitted":
await this.handleExamForceSubmitted(eventId, businessPayload, payload);
break;
case "edu.teaching.exam.question_reordered":
await this.handleExamQuestionReordered(
eventId,
businessPayload,
payload,
);
break; break;
// data-ana 事件 // data-ana 事件
case "edu.insight.mastery.updated": case "edu.insight.mastery.updated":
await this.handleMasteryUpdated(eventId, payload); await this.handleMasteryUpdated(eventId, businessPayload);
break; break;
default: default:
@@ -207,6 +237,145 @@ export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy {
} }
} }
// ============================================================
// 考试实时事件处理器P3.14:转发 core-edu → push-gateway → WebSocket
// ============================================================
/**
* 考试时间延长通知。
* payload: { examId, classId, subjectId, extensionSeconds, newDuration, studentIds? }
*/
private async handleExamExtended(
eventId: string,
payload: Record<string, unknown>,
rawPayload: Record<string, unknown>,
): Promise<void> {
const examId = String(payload.examId ?? payload.exam_id ?? "");
const className = String(payload.className ?? payload.class_name ?? "");
const extensionSeconds = Number(
payload.extensionSeconds ?? payload.extension_seconds ?? 0,
);
const newDuration = Number(
payload.newDuration ?? payload.new_duration ?? 0,
);
const studentIds = payload.studentIds ?? payload.student_ids;
const extensionMin = Math.round(extensionSeconds / 60);
const newDurationMin = Math.round(newDuration / 60);
const content = `你的考试时间已延长 ${extensionMin} 分钟,新时长:${newDurationMin} 分钟${className ? `${className}` : ""}`;
await this.broadcastExamRealtimeEvent(
eventId,
"exam",
"考试时间延长通知",
content,
"exam_extended",
examId,
studentIds,
rawPayload,
);
}
/**
* 考试强制提交通知。
* payload: { examId, classId, studentIds? }
*/
private async handleExamForceSubmitted(
eventId: string,
payload: Record<string, unknown>,
rawPayload: Record<string, unknown>,
): Promise<void> {
const examId = String(payload.examId ?? payload.exam_id ?? "");
const examTitle = String(payload.examTitle ?? payload.exam_title ?? "考试");
const studentIds = payload.studentIds ?? payload.student_ids;
const content = `你的考试「${examTitle}」已被教师强制提交`;
await this.broadcastExamRealtimeEvent(
eventId,
"exam",
"考试强制提交通知",
content,
"exam_force_submitted",
examId,
studentIds,
rawPayload,
);
}
/**
* 考试题目顺序调整通知。
* payload: { examId, classId, studentIds? }
*/
private async handleExamQuestionReordered(
eventId: string,
payload: Record<string, unknown>,
rawPayload: Record<string, unknown>,
): Promise<void> {
const examId = String(payload.examId ?? payload.exam_id ?? "");
const examTitle = String(payload.examTitle ?? payload.exam_title ?? "考试");
const studentIds = payload.studentIds ?? payload.student_ids;
const content = `你的考试「${examTitle}」题目顺序已调整,请刷新查看`;
await this.broadcastExamRealtimeEvent(
eventId,
"exam",
"题目顺序调整通知",
content,
"exam_question_reordered",
examId,
studentIds,
rawPayload,
);
}
/**
* 考试实时事件广播:为每个学生创建 in_app 通知(触发 ChannelDispatcher → push-gateway HTTP + Outbox → Kafka
* 若 payload 无 studentIds记录 warn 并跳过(等待 core-edu 补全 payload
*/
private async broadcastExamRealtimeEvent(
eventId: string,
type: string,
title: string,
content: string,
eventTag: string,
examId: string,
studentIds: unknown,
rawPayload: Record<string, unknown>,
): Promise<void> {
if (!Array.isArray(studentIds) || studentIds.length === 0) {
logger.warn(
{ eventId, eventTag, examId },
"Exam realtime event missing studentIds, skipping notification (core-edu should include studentIds in payload)",
);
return;
}
const businessEventId = String(
rawPayload.eventId ?? rawPayload.event_id ?? eventId,
);
const groupId =
businessEventId.length > 32
? businessEventId.slice(0, 32)
: businessEventId;
for (const userId of studentIds) {
await this.notificationsService.send({
userId: String(userId),
type,
title,
content,
channel: "in_app",
groupId,
eventId: `${businessEventId}:${userId}`,
relatedEntityType: "exam",
relatedEntityId: examId,
metadata: { source: "core-edu", event: eventTag },
});
}
}
// ============================================================ // ============================================================
// 事件处理器(每个创建对应通知) // 事件处理器(每个创建对应通知)
// ============================================================ // ============================================================
@@ -279,6 +448,14 @@ export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy {
if (!Array.isArray(studentIds)) return; if (!Array.isArray(studentIds)) return;
const examTitle = String(payload.examTitle ?? payload.exam_title ?? "考试"); const examTitle = String(payload.examTitle ?? payload.exam_title ?? "考试");
const className = String(payload.className ?? payload.class_name ?? ""); const className = String(payload.className ?? payload.class_name ?? "");
// groupId 优先用 payload 业务 eventId降级路径的 Kafka eventId 可能超长truncate 到 32
const businessEventId = String(
payload.eventId ?? payload.event_id ?? eventId,
);
const groupId =
businessEventId.length > 32
? businessEventId.slice(0, 32)
: businessEventId;
for (const userId of studentIds) { for (const userId of studentIds) {
await this.notificationsService.send({ await this.notificationsService.send({
@@ -287,8 +464,8 @@ export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy {
title: "新考试通知", title: "新考试通知",
content: `${className}」班级发布了新考试:${examTitle}`, content: `${className}」班级发布了新考试:${examTitle}`,
channel: "in_app", channel: "in_app",
groupId: eventId, groupId,
eventId: `${eventId}:${userId}`, eventId: `${businessEventId}:${userId}`,
relatedEntityType: "exam", relatedEntityType: "exam",
relatedEntityId: String(payload.examId ?? payload.exam_id ?? ""), relatedEntityId: String(payload.examId ?? payload.exam_id ?? ""),
metadata: { source: "core-edu", event: "exam.published" }, metadata: { source: "core-edu", event: "exam.published" },
@@ -296,6 +473,44 @@ export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy {
} }
} }
private async handleHomeworkAssigned(
eventId: string,
payload: Record<string, unknown>,
): Promise<void> {
const studentIds = payload.studentIds ?? payload.student_ids;
if (!Array.isArray(studentIds)) return;
const homeworkTitle = String(
payload.homeworkTitle ?? payload.homework_title ?? "作业",
);
const className = String(payload.className ?? payload.class_name ?? "");
const subject = String(payload.subject ?? "");
// groupId 优先用 payload 业务 eventId降级路径的 Kafka eventId 可能超长truncate 到 32
const businessEventId = String(
payload.eventId ?? payload.event_id ?? eventId,
);
const groupId =
businessEventId.length > 32
? businessEventId.slice(0, 32)
: businessEventId;
for (const userId of studentIds) {
await this.notificationsService.send({
userId: String(userId),
type: "homework",
title: "新作业通知",
content: `${className ? `${className}` : ""}${subject ? `${subject}` : ""}布置了新作业:${homeworkTitle}`,
channel: "in_app",
groupId,
eventId: `${businessEventId}:${userId}`,
relatedEntityType: "homework",
relatedEntityId: String(
payload.homeworkId ?? payload.homework_id ?? "",
),
metadata: { source: "core-edu", event: "homework.assigned" },
});
}
}
private async handleAssignmentSubmitted( private async handleAssignmentSubmitted(
eventId: string, eventId: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,

View File

@@ -3,24 +3,24 @@
* *
* 仲裁依据: * 仲裁依据:
* - M5消费 topic 用 `edu.teaching.*` / `edu.identity.*` / `edu.insight.*` * - M5消费 topic 用 `edu.teaching.*` / `edu.identity.*` / `edu.insight.*`
* - 发布 topic 用 `edu.notification.*`02-architecture-design.md §5.2 * - ARB-013发布 topic 用 `edu.notify.notification.*`统一命名
* *
* PRODUCER_TOPIC_MAPeventType → topicOutboxPublisher 按此路由发布。 * PRODUCER_TOPIC_MAPeventType → topicOutboxPublisher 按此路由发布。
* CONSUMER_TOPICSmsg 消费的所有 topic 列表KafkaConsumer 订阅。 * CONSUMER_TOPICSmsg 消费的所有 topic 列表KafkaConsumer 订阅。
*/ */
/** 生产者eventType → Kafka topic 路由 */ /** 生产者eventType → Kafka topic 路由ARB-013 命名) */
export const PRODUCER_TOPIC_MAP: Record<string, string> = { export const PRODUCER_TOPIC_MAP: Record<string, string> = {
"notification.sent": "edu.notification.sent", "notification.sent": "edu.notify.notification.sent",
"notification.read": "edu.notification.read", "notification.read": "edu.notify.notification.read",
"notification.recalled": "edu.notification.recalled", "notification.recalled": "edu.notify.notification.recalled",
"notification.failed": "edu.notification.failed", "notification.failed": "edu.notify.notification.failed",
}; };
/** 兜底 topic未在 TOPIC_MAP 命中的 eventType 走此 topic */ /** 兜底 topic未在 TOPIC_MAP 命中的 eventType 走此 topic */
export const FALLBACK_TOPIC = "edu.notification.events"; export const FALLBACK_TOPIC = "edu.notify.notification.events";
/** 消费者:订阅的 topic 列表iam 6 + core-edu 5 + data-ana 1 = 12 类事件) */ /** 消费者:订阅的 topic 列表iam 6 + core-edu 9 + data-ana 1 = 16 类事件) */
export const CONSUMER_TOPICS: readonly string[] = [ export const CONSUMER_TOPICS: readonly string[] = [
// iamidentity // iamidentity
"edu.identity.user.created", "edu.identity.user.created",
@@ -29,12 +29,16 @@ export const CONSUMER_TOPICS: readonly string[] = [
"edu.identity.user.role_changed", "edu.identity.user.role_changed",
"edu.identity.role.created", "edu.identity.role.created",
"edu.identity.role.updated", "edu.identity.role.updated",
// core-eduteaching // core-eduteaching— 6 基础 + 3 考试实时事件
"edu.teaching.exam.published", "edu.teaching.exam.published",
"edu.teaching.homework.assigned",
"edu.teaching.assignment.submitted", "edu.teaching.assignment.submitted",
"edu.teaching.assignment.graded", "edu.teaching.assignment.graded",
"edu.teaching.grade.recorded", "edu.teaching.grade.recorded",
"edu.teaching.attendance.recorded", "edu.teaching.attendance.recorded",
"edu.teaching.exam.extended",
"edu.teaching.exam.force_submitted",
"edu.teaching.exam.question_reordered",
// data-anainsight // data-anainsight
"edu.insight.mastery.updated", "edu.insight.mastery.updated",
] as const; ] as const;
@@ -46,6 +50,7 @@ export const CONSUMER_EVENT_TYPE_MAP: Record<string, string> = {
"edu.identity.role.created": "system", "edu.identity.role.created": "system",
"edu.identity.role.updated": "system", "edu.identity.role.updated": "system",
"edu.teaching.exam.published": "exam", "edu.teaching.exam.published": "exam",
"edu.teaching.homework.assigned": "homework",
"edu.teaching.assignment.submitted": "homework", "edu.teaching.assignment.submitted": "homework",
"edu.teaching.assignment.graded": "grade", "edu.teaching.assignment.graded": "grade",
"edu.teaching.grade.recorded": "grade", "edu.teaching.grade.recorded": "grade",

View File

@@ -1,17 +1,17 @@
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({
level: env.LOG_LEVEL, level: env.LOG_LEVEL,
// 修复pino 默认字段选项为 `base`,而非 `defaultFields` // 修复pino 默认字段选项为 `base`,而非 `defaultFields`
base: { base: {
service: 'msg', service: "msg",
version: '0.1.0', version: "0.1.0",
}, },
transport: transport:
env.NODE_ENV === 'development' env.NODE_ENV === "development"
? { ? {
target: 'pino-pretty', target: "pino-pretty",
options: { colorize: true }, options: { colorize: true },
} }
: undefined, : undefined,

View File

@@ -13,10 +13,10 @@ import type { OutboxEvent } from "./outbox.schema.js";
* OutboxPublisher —— 轮询 pending 记录并投递到 Kafka多 topic 路由)。 * OutboxPublisher —— 轮询 pending 记录并投递到 Kafka多 topic 路由)。
* *
* 参照 core-edu OutboxPublisher 模式,支持 TOPIC_MAP 多 topic 路由: * 参照 core-edu OutboxPublisher 模式,支持 TOPIC_MAP 多 topic 路由:
* - notification.sent → edu.notification.sent * - notification.sent → edu.notify.notification.sent
* - notification.read → edu.notification.read * - notification.read → edu.notify.notification.read
* - notification.recalled → edu.notification.recalled * - notification.recalled → edu.notify.notification.recalled
* - notification.failed → edu.notification.failed * - notification.failed → edu.notify.notification.failed
* *
* 仲裁依据at-least-once 投递 + 指数退避重试 + 幂等(消费端 event_id 去重)。 * 仲裁依据at-least-once 投递 + 指数退避重试 + 幂等(消费端 event_id 去重)。
*/ */

View File

@@ -25,7 +25,7 @@ export type OutboxStatus = "pending" | "published" | "failed";
* msg_outbox_events 表:事务性 Outbox。 * msg_outbox_events 表:事务性 Outbox。
* *
* 业务事务内写入OutboxPublisher 轮询 pending 记录投递到 Kafka。 * 业务事务内写入OutboxPublisher 轮询 pending 记录投递到 Kafka。
* eventType 经 TOPIC_MAP 路由到不同 topicedu.notification.sent/read/recalled/failed * eventType 经 TOPIC_MAP 路由到不同 topicedu.notify.notification.sent/read/recalled/failed
*/ */
export const outboxEvents = mysqlTable("msg_outbox_events", { export const outboxEvents = mysqlTable("msg_outbox_events", {
eventId: varchar("event_id", { length: 64 }).notNull().primaryKey(), eventId: varchar("event_id", { length: 64 }).notNull().primaryKey(),

View File

@@ -0,0 +1 @@
{"userId":"test-v2-user-001","type":"system","title":"ARB-013 verify","content":"topic 验证测试","channel":"in_app","eventId":"evt-v2-001"}

View File

@@ -0,0 +1 @@
{"event_id":"evt-exam-ext-001","aggregate_id":"exam-001","event_type":"exam.extended","occurred_at":1784021868,"payload":{"examId":"exam-001","classId":"cls-001","className":"高三1班","subjectId":"math","extensionSeconds":300,"newDuration":7200,"studentIds":["stu-exam-001","stu-exam-002"]},"metadata":{"schema_version":"v1"}}

View File

@@ -0,0 +1 @@
{"event_id":"evt-exam-force-001","aggregate_id":"exam-001","event_type":"exam.force_submitted","occurred_at":1784021868,"payload":{"examId":"exam-001","classId":"cls-001","examTitle":"期末数学考试","studentIds":["stu-exam-001"]},"metadata":{"schema_version":"v1"}}

View File

@@ -0,0 +1 @@
{"event_id":"evt-exam-reorder-001","aggregate_id":"exam-001","event_type":"exam.question_reordered","occurred_at":1784021868,"payload":{"examId":"exam-001","classId":"cls-001","examTitle":"期末数学考试","studentIds":["stu-exam-001","stu-exam-002"]},"metadata":{"schema_version":"v1"}}

View File

@@ -0,0 +1 @@
{"eventId":"evt-hw-test-001","homeworkId":"hw-test-001","homeworkTitle":"Kafka 消费测试作业","classId":"cls-test-001","className":"测试班级","subject":"数学","studentIds":["stu-test-001","stu-test-002"],"dueDate":"2026-07-20"}

View File

@@ -0,0 +1 @@
{"homeworkId":"hw-test-002","homeworkTitle":"降级路径测试","classId":"cls-test-002","className":"降级班级","subject":"物理","studentIds":["stu-test-003"],"dueDate":"2026-07-21"}