docs: ai 协作文档体系重构与多 ai 仲裁结果落地
1.AI 协作文档体系重构(objections/worklines/contracts+matrix.md) 2.coord 仲裁文档(final-decisions/cross-review/final-rulings/orchestration) 3.各服务 01/02 文档补全 4.共享包初始化(shared-ts/shared-go/hooks/ui-components/ui-tokens) 5.Proto 契约补全 6.004 架构影响地图更新 7.端口分配表 8.设计规格文档
This commit is contained in:
191
services/msg/docs/01-understanding.md
Normal file
191
services/msg/docs/01-understanding.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# 模块理解确认书 — msg
|
||||
|
||||
> AI 标识:ai05(初版)→ ai10(复核修订,按 ai-allocation.md §3.2 重新分配)
|
||||
> 负责模块:msg(P5)
|
||||
> 阶段:架构设计外包 · 阶段 1(全局理解)
|
||||
> 初版日期:2026-07-09
|
||||
> 复核修订日期:2026-07-09(ai10 接手后修正 3 处失实条目 M2/M8/M12,并补充 ai-allocation §5 缺失项)
|
||||
> 关联文档:[ai-allocation.md](../../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[pending-features.md](../../../docs/architecture/roadmap/pending-features.md)、[known-issues.md](../../../docs/troubleshooting/known-issues.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 我在架构中的位置
|
||||
|
||||
- **层级**:业务微服务层(L5),DDD 限界上下文
|
||||
- **业务领域**:D5 沟通通知领域(004 §1.1b)
|
||||
- **上游**(谁调用我):
|
||||
- teacher-bff / student-bff / parent-bff:聚合层调 msg 查询用户通知列表、发送通知
|
||||
- api-gateway:REST 转发通知请求
|
||||
- Kafka:消费 core-edu / iam 事件触发通知(004 §4.1 `CoreEdu -.事件.-> Msg`、`IAM -.事件.-> Msg`)
|
||||
- **下游**(我调用谁):
|
||||
- MySQL(写模型主库,独占)
|
||||
- Elasticsearch(全文检索,已实现 safeSearch)
|
||||
- push-gateway(gRPC 推送通道,004 §4.1 `PushGW → Msg`,当前用 fetch POST /internal/push 降级)
|
||||
- **通信方式**:
|
||||
- 当前:HTTP REST(Controller,无 gRPC controller)
|
||||
- 目标态(004 §4.1 / pending-features P5):gRPC 暴露 `NotificationService`
|
||||
- Kafka:消费 core-edu(ExamPublished/HomeworkGraded/GradeRecorded)、iam(UserRegistered)事件(004 §7.3)
|
||||
- **端口**:3007(见 [msg env.ts](../src/config/env.ts))
|
||||
|
||||
## 2. 我的限界上下文
|
||||
|
||||
- **聚合职责**:管理 Notification(通知)聚合 + NotificationPreference(用户通知偏好)
|
||||
- **我的数据属于**:D5 沟通通知领域
|
||||
- **我不负责**:
|
||||
- 不负责 WebSocket 长连接管理(由 push-gateway 承载)
|
||||
- 不负责业务数据变更(仅消费事件触发通知)
|
||||
- 不直接访问 core-edu / iam 的数据库
|
||||
- **现有骨架领域模块**(1 个,见 [msg/src](../src)):
|
||||
- `notifications/`:通知 CRUD + ES 全文检索 + Push Gateway 推送 + 用户偏好(6 端点)
|
||||
|
||||
## 3. 我与外部的契约
|
||||
|
||||
- **消费的 proto message**(从 shared-proto):
|
||||
- 消费 `events.proto` 的事件 message(ClassEvent/ExamEvent/HomeworkEvent/GradeEvent)
|
||||
- **events.proto 当前无 NotificationEvent**,若 msg 发事件需补充
|
||||
- **暴露的契约**(见 [msg.proto](../../../packages/shared-proto/proto/msg.proto),包名 `next_edu_cloud.msg.v1`):
|
||||
- `NotificationService`:SendNotification / ListNotifications / MarkAsRead / SearchNotifications
|
||||
- **当前实现均为 REST,gRPC controller 未实现**
|
||||
- **事件契约**:
|
||||
- 消费(004 §7.2 / §7.3):
|
||||
- `edu.identity.user.created` / `edu.identity.user.updated`(IAM 发,msg 发欢迎通知)
|
||||
- `edu.teaching.exam.published`(core-edu 发,msg 推送考试通知给学生)
|
||||
- `edu.teaching.assignment.submitted`(core-edu 发,msg 通知教师)
|
||||
- `edu.teaching.grade.recorded`(core-edu 发,msg 通知学生)
|
||||
- `edu.insight.mastery.updated`(data-ana 发,msg 触发预警)
|
||||
- 发布:无明确(pending-features 未要求 msg 发事件)
|
||||
- **错误码前缀**:`MSG_*`(MSG_VALIDATION_ERROR / NOT_FOUND / PERMISSION_DENIED / CONFLICT / BUSINESS_ERROR / DATABASE_ERROR / INTERNAL_ERROR,见 [application-error.ts](../src/shared/errors/application-error.ts))
|
||||
- **权限点**(3 个,见 [permission.guard.ts](../src/middleware/permission.guard.ts)):
|
||||
- `MSG_NOTIFICATION_SEND`、`MSG_NOTIFICATION_READ`、`MSG_NOTIFICATION_MANAGE`
|
||||
|
||||
## 4. 我的技术栈
|
||||
|
||||
- 语言:TypeScript 5.6(ESM 模式)
|
||||
- 框架:NestJS 10
|
||||
- ORM:Drizzle ORM 0.31 + mysql2 3.11
|
||||
- 全文检索:@elastic/elasticsearch 8.15(已实现 safeIndex/safeSearch,**无 mapping 定义**,依赖动态 mapping)
|
||||
- 消息总线:kafkajs 2.2(**已装依赖但无 consumer/producer 代码**,env.ts 有 KAFKA_BROKERS 默认值)
|
||||
- 推送:fetch POST 到 push-gateway `/internal/push`(降级模式,PUSH_GATEWAY_URL 未配置或失败时跳过)
|
||||
- 幂等去重:Redis(**env.ts 预留 REDIS_URL 但无 redis 客户端依赖**,pending-features P5 要求 event_id 去重)
|
||||
- 可观测:pino + prom-client + OpenTelemetry(三支柱已具备)
|
||||
|
||||
## 5. 我的阶段归属
|
||||
|
||||
- **P5 沟通与 AI 阶段**(pending-features §P5):
|
||||
- 会话/消息 CRUD + 调 Push Gateway 推送 + 通知偏好
|
||||
- 多渠道(站内/SMS/邮件/微信),沿用旧项目 dispatcher 模式
|
||||
- Elasticsearch 题库全文检索(从 MySQL 同步)
|
||||
- **退出标准**:教师发广播通知 → 全在线学生实时收到(Push Gateway)→ AI 辅助出题流式返回 → 题库全文检索 < 200ms
|
||||
- **依赖上游**:P1 黄金模板 classes、P3 core-edu(事件来源)、P5 push-gateway(推送通道,ai01 设计)、P5 ai(无直接依赖)
|
||||
|
||||
## 6. 我需要对齐的黄金模板项(对照 classes 服务)
|
||||
|
||||
- [x] 权限装饰器 `@RequirePermission`(3 个 MSG_* 权限点,全部 Controller 方法已覆盖)
|
||||
- [x] 错误码前缀统一(`MSG_*`)
|
||||
- [x] logger / metrics / tracer 三支柱(已具备)
|
||||
- [x] `/healthz` 健康检查(HealthModule 已注册)
|
||||
- [⚠️] `/readyz`(**仅检查 DB `SELECT 1`,未检查 ES 连通性**,ES 故障时仍返回 ok)
|
||||
- [x] 优雅关闭 SIGTERM(main.ts 已处理:app.close → closeEs → closeDb → shutdownTracer)
|
||||
- [ ] 测试覆盖率 ≥ 80%(**当前 0%**,无测试文件)
|
||||
- [x] Dockerfile 多阶段构建(已具备)
|
||||
- [x] Zod 输入验证(Controller 用 `schema.parse(body)`;**GlobalErrorFilter 已识别 ZodError 返回 400**,见 [global-error.filter.ts](../src/shared/errors/global-error.filter.ts) L32-42,ai10 复核修正)
|
||||
- [x] GlobalErrorFilter 统一兜底
|
||||
|
||||
## 7. 现有骨架差距与待决策(提请 coord 仲裁)
|
||||
|
||||
| # | 差距 | 影响 | 提请决策 |
|
||||
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| M1 | **Kafka 消费未实现**:装了 kafkajs 但无 consumer 代码,无 shared/kafka/ 目录 | 004 §7.3 列 msg 消费 5 类事件(ExamPublished/HomeworkGraded/GradeRecorded/UserRegistered/MasteryUpdated) | 阶段 2 设计需补 Kafka consumer + 幂等去重 |
|
||||
| M2 | **无 Outbox**:骨架无 shared/outbox/ | 004 §7.2 明确 msg 生产 `edu.notification.events`(NotificationRequested,消费者 push-gateway),**ai10 复核:msg 必须有 Outbox**(004 §7.2/§7.3 已定义,原"无明确"判断失实) | 阶段 2 设计补 Outbox(参照 core-edu outbox.publisher.ts) |
|
||||
| M3 | **ES 无 mapping 定义**:依赖动态 mapping,索引名 "notifications" 硬编码在 service | 检索质量不稳定,索引管理缺失 | 阶段 2 设计补 mapping + ensureIndex |
|
||||
| M4 | **无独立 repository**:notifications.service.ts 直接用 db,无 repository 抽象 | 与黄金模板分层不一致(iam 有 repository) | 阶段 2 设计补 repository 层 |
|
||||
| M5 | **Redis 未引入**:env.ts 预留 REDIS_URL 但无 redis 客户端依赖 | pending-features P5 要求 event_id 去重(Redis SETNX 或 DB 唯一索引) | 阶段 2 设计决策:Redis SETNX vs DB 唯一索引 |
|
||||
| M6 | **createBatch 无事务/无批量优化**:for 循环串行调 send,无批量 INSERT | 性能瓶颈(广播场景) | 阶段 2 设计改为批量 INSERT |
|
||||
| M7 | **NotificationsModule 缺 exports**:notifications.module.ts 无 `exports: [NotificationsService]` | 未来 BFF 注入受阻 | 阶段 2 设计补 exports |
|
||||
| M8 | **~~ZodError 未特殊处理~~(ai10 复核:已修复,失实条目)**:GlobalErrorFilter 已识别 ZodError 返回 400(见 [global-error.filter.ts](../src/shared/errors/global-error.filter.ts) L32-42) | 无(已修复) | ✅ 已修复,无需处理 |
|
||||
| M9 | **gRPC 未实现**:proto 定义了 NotificationService,但无 gRPC controller | pending-features P5 未强制 gRPC | 确认 P5 是否启用 gRPC(ai03 提请统一决策) |
|
||||
| M10 | **README 与实现脱节**:README API 表标 `POST /notifications/:id/read`,实际是 PUT;漏 batch/user/:userId/user/:userId/page 端点;声称"消费 Kafka 事件"但无代码 | 文档误导 | 阶段 2 设计同步修正 README |
|
||||
| M11 | **main.ts 与 LifecycleService 重复关闭资源**:两者都调 closeDb/closeEs | 重复关闭可能报错(虽有 try-catch) | 阶段 2 设计统一关闭逻辑到 LifecycleService |
|
||||
| M12 | **~~proto 包名不一致~~(ai10 复核:失实条目)**:实际 `next_edu_cloud.msg.v1` 完全符合 project_rules §5 `next_edu_cloud.<domain>.v1` 规范(coord 已裁决采用 `next_edu_cloud.*` 格式) | 无(命名正确) | ✅ 命名正确,无需处理 |
|
||||
|
||||
---
|
||||
|
||||
## 服务审计表 — ai10(msg 复核)
|
||||
|
||||
> 审计标准对照 [project_rules §3](../../../.trae/rules/project_rules.md) 与 [known-issues §2.2 classes 黄金模板](../../../docs/troubleshooting/known-issues.md)
|
||||
|
||||
| 服务 | 权限装饰器 | 错误码前缀 | logger | metrics | tracer | /healthz | /readyz | 优雅关闭 | 测试覆盖率 | Dockerfile |
|
||||
| ---- | --------------- | ---------- | ------- | -------------- | ------------------------------- | -------- | ---------------- | --------------------------------------- | ---------- | ---------- |
|
||||
| msg | ✅ 6 端点全覆盖 | ✅ `MSG_*` | ✅ pino | ✅ prom-client | ✅ OTel + auto-instrumentations | ✅ | ⚠️ 仅 DB 不查 ES | ⚠️ main.ts 与 LifecycleService 重复关闭 | 0% | ✅ |
|
||||
|
||||
## ai10 复核结论(ai-allocation §5 设计重点对照)
|
||||
|
||||
ai-allocation.md §5 对 ai10(msg)明确列出 6 项设计重点,对照现有骨架:
|
||||
|
||||
| ai-allocation §5 设计重点 | 现状 | 阶段 2 是否需设计 |
|
||||
| --------------------------------------------------- | ------------------------------ | ----------------- |
|
||||
| 通知渠道抽象(站内信/邮件/短信,策略模式) | ❌ 无抽象,硬编码 | ✅ 必须 |
|
||||
| ES 降级查询策略(DB 不可用时走 ES 索引) | ❌ 仅 ES 降级,无 DB 降级到 ES | ✅ 必须 |
|
||||
| Kafka 消费幂等设计(event_id 去重 via Redis SETNX) | ❌ 无 Kafka 代码 | ✅ 必须 |
|
||||
| 与 ai02(push-gateway)的推送通道协议(gRPC) | ⚠️ 用 fetch POST 降级,无 gRPC | ✅ 需 gRPC 化 |
|
||||
| 通知模板管理(模板 CRUD + 变量替换) | ❌ 无模板模块 | ✅ 必须 |
|
||||
| 已读/未读状态管理(Redis 位图) | ❌ 仅 DB boolean | ✅ 必须 |
|
||||
|
||||
**新增差距(ai10 补充,原 ai05 文档遗漏)**:
|
||||
|
||||
| # | 差距 | 影响 |
|
||||
| --- | ------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| M13 | **无通知模板模块**:缺 msg_notification_templates 表、模板 CRUD、变量替换、i18n | ai-allocation §5 强制要求 |
|
||||
| M14 | **无渠道策略抽象**:send 方法硬编码 channel 判断,无 ChannelStrategy 接口 | 多渠道扩展困难(未来加微信/钉钉需改核心) |
|
||||
| M15 | **无 Redis 位图已读/未读**:isRead 仅 DB boolean,无 unread 计数缓存 | 高频未读数查询打 DB,广播场景性能瓶颈 |
|
||||
| M16 | **无 DB→ES 降级读路径**:仅有 ES→空降级,无 DB 故障时走 ES 读模型 | ai-allocation §5 要求 DB 不可用走 ES 索引 |
|
||||
| M17 | **无投放记录表**:缺 msg_notification_deliveries(per-channel 投递状态) | 无法追踪邮件/短信投递成功失败 |
|
||||
| M18 | **无幂等键**:send 无 idempotencyKey 参数,重复请求会重复发通知 | 客户端重试导致重复通知 |
|
||||
| M19 | **无通知优先级/分类**:缺 priority/category 字段 | 无法区分紧急通知与普通通知,无法按类退订 |
|
||||
| M20 | **无调度/延迟发送**:缺 scheduledAt 字段与调度器 | 未来定时通知、每日摘要无法支持 |
|
||||
| M21 | **权限模型过简**:仅 3 个权限点,硬编码 ROLE_PERMISSIONS | 应对齐 iam 的 RBAC + DataScope(DATA_SCOPE=SELF/ALL) |
|
||||
|
||||
---
|
||||
|
||||
## 跨模块契约对齐提请(coord 交叉审查)
|
||||
|
||||
### 接口一致性检查(msg 相关)
|
||||
|
||||
| 本服务声明 | 对方服务声明 | 是否匹配 | 备注 |
|
||||
| -------------------------------------------- | ------------------------------- | --------- | ------------------------------------------------------------------------------------------------ |
|
||||
| ai05 msg: 消费 `edu.teaching.exam.published` | ai03 core-edu: 发布考试事件 | ⚠️ 待确认 | ai03 提请 topic 命名统一(004 `edu.teaching.exam.published` vs core-edu 代码 `edu.exam.events`) |
|
||||
| ai05 msg: 消费 `edu.identity.user.created` | ai02 iam: 发布用户创建事件 | ⚠️ 待确认 | ai02 设计需确认 topic |
|
||||
| ai05 msg: 调 push-gateway `/internal/push` | ai01 push-gateway: 暴露推送端点 | ✅ 已实现 | msg 当前用 fetch POST,push-gateway 已有 /internal/push 端点 |
|
||||
|
||||
### 全局冲突检查(msg 相关)
|
||||
|
||||
| 检查项 | 检查结果 | 备注 |
|
||||
| -------------------- | --------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| 端口不冲突 | ✅ msg 3007 | 与 iam(3002)/classes(3001)/teacher-bff(3003)/core-edu(3004)/content(3005)/data-ana(3006)/ai(3008) 不冲突 |
|
||||
| Topic 不重复 | ⚠️ 待汇总 | msg 仅消费不发布 |
|
||||
| 错误码前缀不重叠 | ✅ `MSG_*` 唯一 | 与 iam `IAM_*` / core-edu `CORE_EDU_*` / content `CONTENT_*` 不重叠 |
|
||||
| Proto message 不遗漏 | ⚠️ 待确认 | events.proto 无 NotificationEvent,若 msg 发事件需补充 |
|
||||
| proto 包名规范 | ⚠️ 不一致 | 实际 `next_edu_cloud.<domain>.v1`,规则要求 `edu.<domain>.v1`,**提请 coord 仲裁**(ai03 已提请) |
|
||||
|
||||
### 待 coord 仲裁的决策项(msg 相关)
|
||||
|
||||
1. ~~**proto 包名统一**:`next_edu_cloud.*` vs `edu.*`~~(ai10 复核:coord 已裁决 `next_edu_cloud.*`,本项目全部 proto 已采用,无需再仲裁)
|
||||
2. **gRPC 启用时机**:P4/P5 是否启用 gRPC controller,还是继续 REST(影响 content/msg/iam/core-edu 全部服务)
|
||||
3. ~~**msg Outbox**:是否需要~~(ai10 复核:004 §7.2 已明确 msg 生产 `edu.notification.events`,**必须有 Outbox**,无需仲裁)
|
||||
4. **msg Redis 引入**:env.ts 预留 REDIS_URL 但无依赖,幂等去重用 Redis SETNX 还是 DB 唯一索引(ai10 倾向 Redis SETNX + DB 唯一索引双保险,待 coord 确认 Redis 是否纳入 P5 基础设施)
|
||||
5. **events.proto 补充**:msg 发 NotificationRequested 事件需追加 NotificationEvent message(coord 维护 proto)
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
1. ~~等待 coord 审核本确认书~~(ai10 复核修订完成,进入阶段 2)
|
||||
2. 进入**阶段 2:模块架构设计文档**,按 ai-allocation.md §7 模板产出 [02-architecture.md](./02-architecture.md):
|
||||
- msg 模块架构设计文档(含通知渠道抽象策略模式、ES 降级查询、Kafka 消费幂等、push-gateway 推送协议、通知模板、已读/未读状态管理)
|
||||
3. 阶段 2 设计完成后同步更新 README(修正 §M10 文档脱节问题)
|
||||
|
||||
---
|
||||
|
||||
**AI Agent**: ai10 (msg)
|
||||
**Coordinator**: coord-ai
|
||||
**Branch**: 单仓库并行模式(直接 push main)
|
||||
1157
services/msg/docs/02-architecture-design.md
Normal file
1157
services/msg/docs/02-architecture-design.md
Normal file
File diff suppressed because it is too large
Load Diff
816
services/msg/docs/02-architecture.md
Normal file
816
services/msg/docs/02-architecture.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# 模块架构设计文档 — msg
|
||||
|
||||
> AI 标识:ai10
|
||||
> 负责模块:msg(P5 沟通通知)
|
||||
> 阶段:架构设计外包 · 阶段 2(模块架构设计)
|
||||
> 日期:2026-07-09
|
||||
> 关联文档:[01-understanding.md](./01-understanding.md)、[ai-allocation.md](../../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[pending-features.md](../../../docs/architecture/roadmap/pending-features.md)、[project_rules.md](../../../.trae/rules/project_rules.md)
|
||||
> 参考实现:[classes 黄金模板](../../classes/src/)、[core-edu Outbox](../../core-edu/src/shared/outbox/)、[iam RBAC](../../iam/src/)
|
||||
|
||||
---
|
||||
|
||||
## 0. 设计原则与长远目标
|
||||
|
||||
本设计遵循以下原则,确保既能实现 P5 退出标准,又为 P6 硬化与未来扩展铺路:
|
||||
|
||||
1. **通知即基础设施**:msg 是全校所有业务事件的下收件人,设计上按"通知中台"标准而非"业务 CRUD"标准,预留多租户、多渠道、多策略扩展点。
|
||||
2. **契约先行**:proto → 实现。NotificationService gRPC + events.proto NotificationEvent 必须先定。
|
||||
3. **最终一致 + 幂等**:Kafka 消费 at-least-once,业务侧 event_id 去重;Outbox 保证通知请求事件不丢。
|
||||
4. **降级优先**:每个外部依赖(DB/ES/Redis/Push/SMS/邮件)都有降级路径,单点故障不阻断核心链路。
|
||||
5. **策略模式贯穿渠道层**:新增渠道(微信/钉钉/企业微信)只需实现 ChannelStrategy 接口,不改核心。
|
||||
6. **读路径分级**:列表/计数走 Redis 缓存 → DB → ES 三级降级;全文检索走 ES → DB like 降级。
|
||||
7. **可观测可追踪**:每条通知从"事件触发 → 模板渲染 → 渠道投递 → 已读"全链路 traceId 串联,投递结果落 deliveries 表。
|
||||
8. **向前兼容**:所有 schema 变更只增不删,proto 字段只加编号不复用,事件版本用 v1/v2 后缀。
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块内部分层图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Gateway["API Gateway (L4)"]
|
||||
GW[路由转发 + JWT 校验]
|
||||
end
|
||||
|
||||
subgraph BFF["BFF (L3)"]
|
||||
BFF1[teacher-bff / student-bff / parent-bff]
|
||||
end
|
||||
|
||||
subgraph Msg["msg 服务 (L5 业务服务)"]
|
||||
direction TB
|
||||
|
||||
subgraph Entry["入口层"]
|
||||
CTRL[NotificationsController<br/>REST]
|
||||
GRPC[NotificationGrpcController<br/>gRPC · P5 启用]
|
||||
KCONS[KafkaConsumer<br/>事件消费]
|
||||
end
|
||||
|
||||
subgraph Guard["横切层"]
|
||||
PERM[PermissionGuard<br/>@RequirePermission]
|
||||
VALID[Zod 校验]
|
||||
FILTER[GlobalErrorFilter]
|
||||
end
|
||||
|
||||
subgraph App["应用服务层 (ApplicationService)"]
|
||||
NS[NotificationService<br/>编排:幂等 → 模板渲染 → fan-out]
|
||||
TS[TemplateService<br/>模板 CRUD + 变量替换 + i18n]
|
||||
DS[DeliveryService<br/>投放编排 + 重试 + DLQ]
|
||||
PREF[PreferenceService<br/>偏好查询 + 静默时段]
|
||||
READ[ReadStateService<br/>已读/未读 + Redis 位图]
|
||||
IDEM[IdempotencyService<br/>event_id 去重 Redis SETNX]
|
||||
end
|
||||
|
||||
subgraph Strategy["渠道策略层 (ChannelStrategy)"]
|
||||
INAPP[InAppChannel<br/>DB+ES 写入]
|
||||
EMAIL[EmailChannel<br/>SMTP async]
|
||||
SMS[SmsChannel<br/>短信网关 + 限流 + 配额]
|
||||
PUSH[PushChannel<br/>gRPC → push-gateway]
|
||||
WECHAT[WechatChannel<br/>未来·预留接口]
|
||||
end
|
||||
|
||||
subgraph Domain["领域服务 (DomainService)"]
|
||||
REPO[NotificationRepository<br/>Drizzle 数据访问]
|
||||
OUTBOX[OutboxPublisher<br/>NotificationRequested 事件]
|
||||
end
|
||||
|
||||
subgraph Data["数据层"]
|
||||
DB[(MySQL<br/>写模型)]
|
||||
ES[(Elasticsearch<br/>全文检索 + 降级读)]
|
||||
REDIS[(Redis<br/>幂等/位图/计数/限流)]
|
||||
KAFKA[(Kafka<br/>消费+发布)]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph External["外部依赖"]
|
||||
PUSHGW[push-gateway<br/>WebSocket 推送]
|
||||
SMTP[SMTP 邮件服务]
|
||||
SMSGW[短信服务商]
|
||||
end
|
||||
|
||||
GW --> CTRL
|
||||
BFF1 --> GRPC
|
||||
KAFKA --> KCONS
|
||||
KCONS --> IDEM --> NS
|
||||
CTRL --> PERM --> VALID --> NS
|
||||
GRPC --> NS
|
||||
NS --> TS
|
||||
NS --> PREF
|
||||
NS --> DS
|
||||
NS --> READ
|
||||
DS --> INAPP & EMAIL & SMS & PUSH
|
||||
INAPP --> REPO --> DB
|
||||
INAPP --> ES
|
||||
PUSH --> PUSHGW
|
||||
EMAIL --> SMTP
|
||||
SMS --> SMSGW
|
||||
NS --> OUTBOX --> KAFKA
|
||||
READ --> REDIS
|
||||
READ --> REPO
|
||||
IDEM --> REDIS
|
||||
```
|
||||
|
||||
**关键拦截点**:
|
||||
|
||||
- `PermissionGuard`(APP_GUARD):每个 Controller 方法 `@RequirePermission` 校验
|
||||
- `GlobalErrorFilter`:ZodError → 400、ApplicationError → 对应状态码、兜底 500
|
||||
- `IdempotencyService`:Kafka 消费与 HTTP send 均先过幂等检查
|
||||
- `OutboxPublisher`:事务内写 outbox 表,独立轮询发布到 Kafka
|
||||
|
||||
---
|
||||
|
||||
## 2. 领域模型
|
||||
|
||||
### 2.1 聚合根与实体
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Notification {
|
||||
+id: UUID
|
||||
+userId: string
|
||||
+type: NotificationType
|
||||
+category: NotificationCategory
|
||||
+priority: Priority
|
||||
+title: string
|
||||
+content: string
|
||||
+channel: Channel
|
||||
+metadata: JSON
|
||||
+scheduledAt?: DateTime
|
||||
+expiresAt?: DateTime
|
||||
+isRead: boolean
|
||||
+readAt?: DateTime
|
||||
+idempotencyKey?: string
|
||||
+sourceEventId?: string
|
||||
+createdAt: DateTime
|
||||
+markAsRead()
|
||||
+isExpired()
|
||||
}
|
||||
|
||||
class NotificationTemplate {
|
||||
+id: UUID
|
||||
+code: string
|
||||
+name: string
|
||||
+category: NotificationCategory
|
||||
+channelTemplates: Map~Channel, ChannelTemplate~
|
||||
+variables: TemplateVariable[]
|
||||
+isActive: boolean
|
||||
+version: int
|
||||
+render(ctx): RenderedContent
|
||||
}
|
||||
|
||||
class ChannelTemplate {
|
||||
+channel: Channel
|
||||
+subjectTpl: string
|
||||
+bodyTpl: string
|
||||
+i18n: Map~locale, LocalizedTpl~
|
||||
}
|
||||
|
||||
class NotificationPreference {
|
||||
+userId: string
|
||||
+channelEnabled: Map~Channel, boolean~
|
||||
+categoryEnabled: Map~Category, boolean~
|
||||
+quietHours: QuietHours
|
||||
+digestConfig: DigestConfig
|
||||
+isChannelAllowed(channel, category): boolean
|
||||
}
|
||||
|
||||
class NotificationDelivery {
|
||||
+id: UUID
|
||||
+notificationId: UUID
|
||||
+channel: Channel
|
||||
+status: DeliveryStatus
|
||||
+externalId?: string
|
||||
+attemptCount: int
|
||||
+lastError?: string
|
||||
+deliveredAt?: DateTime
|
||||
+markSent()
|
||||
+markFailed(err)
|
||||
+canRetry(): boolean
|
||||
}
|
||||
|
||||
class QuietHours {
|
||||
+start: string
|
||||
+end: string
|
||||
+timezone: string
|
||||
+isInQuietHours(now): boolean
|
||||
}
|
||||
|
||||
Notification "1" --> "0..*" NotificationDelivery : has
|
||||
NotificationTemplate "1" --> "0..*" ChannelTemplate : contains
|
||||
NotificationPreference "1" --> "1" QuietHours : has
|
||||
|
||||
enum NotificationType { SYSTEM ANNOUNCEMENT EXAM HOMEWORK GRADE ATTENDANCE MASTERY }
|
||||
enum NotificationCategory { ACADEMIC BEHAVIOR SYSTEM MARKETING }
|
||||
enum Priority { URGENT HIGH NORMAL LOW }
|
||||
enum Channel { IN_APP EMAIL SMS PUSH WECHAT }
|
||||
enum DeliveryStatus { PENDING SENT DELIVERED FAILED RETRYING }
|
||||
```
|
||||
|
||||
### 2.2 聚合边界与通信
|
||||
|
||||
- **Notification 聚合**(根):包含 1:N Delivery。聚合内直接调用,聚合间通过事件。
|
||||
- **NotificationTemplate 聚合**(根):独立管理,被 NotificationService 引用渲染。
|
||||
- **NotificationPreference 聚合**(根):按 userId 隔离,被 PreferenceService 查询。
|
||||
- **跨聚合通信**:TemplateService / PreferenceService / ReadStateService 同服务内直接方法调用(应用层编排),不发事件。
|
||||
- **跨服务通信**:仅通过 Kafka 事件(消费 core-edu/iam/data-ana 事件;发布 NotificationRequested 给 push-gateway)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 表清单(MySQL,msg 独占库)
|
||||
|
||||
| 表名 | 用途 | 现状 |
|
||||
| ------------------------------ | -------------------------- | --------------- |
|
||||
| `msg_notifications` | 通知主表 | ✅ 已有,需扩展 |
|
||||
| `msg_notification_preferences` | 用户偏好 | ✅ 已有,需扩展 |
|
||||
| `msg_notification_templates` | 通知模板 | ❌ 新增 |
|
||||
| `msg_notification_deliveries` | 投递记录(per-channel) | ❌ 新增 |
|
||||
| `msg_outbox` | Outbox 事件表 | ❌ 新增 |
|
||||
| `msg_idempotency` | 幂等键(Redis 不可用降级) | ❌ 新增 |
|
||||
|
||||
### 3.2 Schema 定义
|
||||
|
||||
#### msg_notifications(扩展现有)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| --------------- | ------------ | ---------------------------- | ---------------------------------- |
|
||||
| id | char(36) | PK | UUID |
|
||||
| user_id | char(36) | NOT NULL, idx | 接收人 |
|
||||
| type | varchar(50) | NOT NULL, idx | EXAM/HOMEWORK/GRADE/SYSTEM... |
|
||||
| category | varchar(30) | NOT NULL, default 'ACADEMIC' | ACADEMIC/BEHAVIOR/SYSTEM/MARKETING |
|
||||
| priority | varchar(10) | NOT NULL, default 'NORMAL' | URGENT/HIGH/NORMAL/LOW |
|
||||
| title | varchar(200) | NOT NULL | |
|
||||
| content | text | NOT NULL | |
|
||||
| channel | varchar(20) | NOT NULL, default 'in_app' | in_app/email/sms/push/wechat |
|
||||
| metadata | json | nullable | 透传业务上下文 |
|
||||
| scheduled_at | timestamp | nullable, idx | 调度发送时间(未来) |
|
||||
| expires_at | timestamp | nullable | 过期清理 |
|
||||
| is_read | boolean | NOT NULL, default false | |
|
||||
| read_at | timestamp | nullable | |
|
||||
| idempotency_key | varchar(128) | nullable, idx | 幂等键(HTTP send 用) |
|
||||
| source_event_id | varchar(128) | nullable, idx | 触发该通知的 Kafka event_id |
|
||||
| created_at | timestamp | NOT NULL, default now, idx | |
|
||||
|
||||
**索引**:
|
||||
|
||||
- `idx_user_created (user_id, created_at DESC)` — 用户列表查询主索引
|
||||
- `idx_user_unread (user_id, is_read, created_at DESC)` — 未读列表
|
||||
- `idx_source_event (source_event_id)` — 事件溯源
|
||||
- `idx_idempotency (idempotency_key)` — 唯一索引(防重)
|
||||
- `idx_scheduled (scheduled_at) WHERE scheduled_at IS NOT NULL` — 调度扫描
|
||||
- `idx_expires (expires_at) WHERE expires_at IS NOT NULL` — 过期清理
|
||||
|
||||
#### msg_notification_preferences(扩展现有)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| ----------------- | ----------- | ----------------------- | ------------------------- |
|
||||
| user_id | char(36) | PK | |
|
||||
| email_enabled | boolean | default true | |
|
||||
| sms_enabled | boolean | default false | |
|
||||
| push_enabled | boolean | default true | |
|
||||
| in_app_enabled | boolean | default true | |
|
||||
| wechat_enabled | boolean | default false | **新增,未来渠道** |
|
||||
| academic_enabled | boolean | default true | **新增,按分类退订** |
|
||||
| behavior_enabled | boolean | default true | **新增** |
|
||||
| system_enabled | boolean | default true | **新增** |
|
||||
| marketing_enabled | boolean | default false | **新增** |
|
||||
| quiet_start | varchar(5) | nullable | **新增** "22:00" 静默开始 |
|
||||
| quiet_end | varchar(5) | nullable | **新增** "07:00" 静默结束 |
|
||||
| quiet_timezone | varchar(40) | default 'Asia/Shanghai' | **新增** |
|
||||
| digest_email | boolean | default false | **新增** 每日摘要开关 |
|
||||
| updated_at | timestamp | default now | **新增** |
|
||||
|
||||
#### msg_notification_templates(新增)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| ----------- | ------------ | --------------- | ------------------------------- |
|
||||
| id | char(36) | PK | UUID |
|
||||
| code | varchar(64) | UNIQUE, idx | 模板编码(如 EXAM_PUBLISHED) |
|
||||
| name | varchar(100) | NOT NULL | 模板名称 |
|
||||
| category | varchar(30) | NOT NULL | 分类 |
|
||||
| channel | varchar(20) | NOT NULL | 渠道 |
|
||||
| subject_tpl | varchar(200) | nullable | 标题模板(含 {{var}}) |
|
||||
| body_tpl | text | NOT NULL | 正文模板 |
|
||||
| variables | json | nullable | 变量定义 [{name,required,desc}] |
|
||||
| locale | varchar(10) | default 'zh-CN' | i18n |
|
||||
| is_active | boolean | default true | |
|
||||
| version | int | default 1 | 版本号 |
|
||||
| created_at | timestamp | default now | |
|
||||
| updated_at | timestamp | default now | |
|
||||
|
||||
**唯一索引**:`uk_code_channel_locale (code, channel, locale)`
|
||||
|
||||
#### msg_notification_deliveries(新增)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| --------------- | ------------ | ------------- | -------------------------------------- |
|
||||
| id | char(36) | PK | UUID |
|
||||
| notification_id | char(36) | NOT NULL, idx | FK→notifications |
|
||||
| channel | varchar(20) | NOT NULL | 投递渠道 |
|
||||
| status | varchar(20) | NOT NULL | PENDING/SENT/DELIVERED/FAILED/RETRYING |
|
||||
| external_id | varchar(128) | nullable | 外部网关返回 ID |
|
||||
| attempt_count | int | default 0 | 重试次数 |
|
||||
| max_retry | int | default 3 | 最大重试 |
|
||||
| last_error | text | nullable | 失败原因 |
|
||||
| next_retry_at | timestamp | nullable, idx | 下次重试时间 |
|
||||
| delivered_at | timestamp | nullable | 投递成功时间 |
|
||||
| created_at | timestamp | default now | |
|
||||
| updated_at | timestamp | default now | |
|
||||
|
||||
**索引**:`idx_notification (notification_id)`、`idx_retry (status, next_retry_at)`
|
||||
|
||||
#### msg_outbox(新增,参照 core-edu)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| -------------- | ----------- | ------------------ | --------------------------- |
|
||||
| id | char(36) | PK | UUID |
|
||||
| aggregate_id | char(36) | NOT NULL, idx | notificationId |
|
||||
| aggregate_type | varchar(30) | NOT NULL | 'Notification' |
|
||||
| event_type | varchar(50) | NOT NULL | notification.requested/sent |
|
||||
| payload | text | NOT NULL | JSON 序列化事件 |
|
||||
| processed | boolean | default false, idx | |
|
||||
| retry_count | int | default 0 | |
|
||||
| created_at | timestamp | default now | |
|
||||
| processed_at | timestamp | nullable | |
|
||||
|
||||
#### msg_idempotency(新增,Redis 降级用)
|
||||
|
||||
| 字段 | 类型 | 约束 | 说明 |
|
||||
| ---------- | ------------ | ------------- | -------------------------- |
|
||||
| key | varchar(128) | PK | event_id 或 idempotencyKey |
|
||||
| result | json | NOT NULL | 首次执行结果(缓存复用) |
|
||||
| created_at | timestamp | default now | |
|
||||
| expires_at | timestamp | NOT NULL, idx | TTL(默认 7 天) |
|
||||
|
||||
### 3.3 读写分离策略
|
||||
|
||||
| 读场景 | 主路径 | 降级路径 1 | 降级路径 2 |
|
||||
| ------------ | ---------------------- | -------------------- | ---------------- |
|
||||
| 用户通知列表 | DB(idx_user_created) | ES(user_id filter) | — |
|
||||
| 未读计数 | Redis 位图 BITCOUNT | DB COUNT(*) | ES count |
|
||||
| 全文检索 | ES multi_match | DB LIKE(降级) | 返回空(不阻断) |
|
||||
| 用户偏好 | Redis 缓存(5min TTL) | DB | 默认偏好(内存) |
|
||||
| 模板渲染 | Redis 缓存(10min) | DB | — |
|
||||
|
||||
**写路径**:所有写走 MySQL 主库(单主,无读写分离),ES 通过同步索引异步写入(safeIndex 失败不阻断)。
|
||||
|
||||
### 3.4 Elasticsearch 索引设计
|
||||
|
||||
索引名:`msg_notifications_v1`(带版本号,便于 reindex)
|
||||
|
||||
```json
|
||||
{
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"userId": { "type": "keyword" },
|
||||
"type": { "type": "keyword" },
|
||||
"category": { "type": "keyword" },
|
||||
"priority": { "type": "keyword" },
|
||||
"channel": { "type": "keyword" },
|
||||
"title": {
|
||||
"type": "text",
|
||||
"analyzer": "ik_max_word",
|
||||
"search_analyzer": "ik_smart"
|
||||
},
|
||||
"content": {
|
||||
"type": "text",
|
||||
"analyzer": "ik_max_word",
|
||||
"search_analyzer": "ik_smart"
|
||||
},
|
||||
"isRead": { "type": "boolean" },
|
||||
"createdAt": { "type": "date" },
|
||||
"scheduledAt": { "type": "date" }
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"number_of_shards": 1,
|
||||
"number_of_replicas": 1,
|
||||
"refresh_interval": "1s"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**索引管理**:
|
||||
|
||||
- 启动时 `ensureIndex()`:若索引不存在则创建(带 mapping)
|
||||
- 别名 `msg_notifications` → `msg_notifications_v1`,reindex 时切换别名实现零停机
|
||||
- 中文分词需 ik 插件(已在 infra 配置)
|
||||
|
||||
---
|
||||
|
||||
## 4. API 设计
|
||||
|
||||
### 4.1 REST API(HTTP,经 api-gateway)
|
||||
|
||||
| method | path | 权限 | 说明 |
|
||||
| ------ | ---------------------------------------- | ----------------------- | ---------------------------- |
|
||||
| POST | /notifications | MSG_NOTIFICATION_SEND | 发送单条通知 |
|
||||
| POST | /notifications/batch | MSG_NOTIFICATION_SEND | 批量发送(广播) |
|
||||
| GET | /notifications/user/:userId | MSG_NOTIFICATION_READ | 查询用户通知(?unread=true) |
|
||||
| GET | /notifications/user/:userId/page | MSG_NOTIFICATION_READ | 分页查询 |
|
||||
| GET | /notifications/user/:userId/unread-count | MSG_NOTIFICATION_READ | 未读计数(Redis 位图优先) |
|
||||
| PUT | /notifications/:id/read | MSG_NOTIFICATION_MANAGE | 标记已读 |
|
||||
| PUT | /notifications/read-all | MSG_NOTIFICATION_MANAGE | 全部已读(按 userId) |
|
||||
| GET | /notifications/search | MSG_NOTIFICATION_READ | 全文检索 |
|
||||
| DELETE | /notifications/:id | MSG_NOTIFICATION_MANAGE | 删除通知(软删,未来) |
|
||||
| GET | /notifications/preferences/:userId | MSG_NOTIFICATION_READ | 查询偏好 |
|
||||
| PUT | /notifications/preferences/:userId | MSG_NOTIFICATION_MANAGE | 更新偏好 |
|
||||
| POST | /notifications/templates | MSG_TEMPLATE_MANAGE | 创建模板 |
|
||||
| GET | /notifications/templates | MSG_TEMPLATE_MANAGE | 模板列表 |
|
||||
| GET | /notifications/templates/:code | MSG_TEMPLATE_MANAGE | 查询单个模板 |
|
||||
| PUT | /notifications/templates/:id | MSG_TEMPLATE_MANAGE | 更新模板 |
|
||||
| DELETE | /notifications/templates/:id | MSG_TEMPLATE_MANAGE | 停用模板(软删) |
|
||||
|
||||
### 4.2 gRPC API(NotificationService,proto 包 `next_edu_cloud.msg.v1`)
|
||||
|
||||
| 方法 | 请求 | 响应 | 说明 |
|
||||
| ------------------- | -------------------------- | --------------------------- | ---------------- |
|
||||
| SendNotification | SendNotificationRequest | Notification | 发送通知 |
|
||||
| ListNotifications | ListNotificationsRequest | ListNotificationsResponse | 列表查询 |
|
||||
| MarkAsRead | MarkAsReadRequest | Empty | 标记已读 |
|
||||
| MarkAllAsRead | MarkAllAsReadRequest | Empty | 全部已读(新增) |
|
||||
| GetUnreadCount | GetUnreadCountRequest | GetUnreadCountResponse | 未读计数(新增) |
|
||||
| SearchNotifications | SearchNotificationsRequest | SearchNotificationsResponse | 全文检索 |
|
||||
| GetPreference | GetPreferenceRequest | NotificationPreference | 偏好查询(新增) |
|
||||
| UpdatePreference | UpdatePreferenceRequest | NotificationPreference | 偏好更新(新增) |
|
||||
|
||||
> proto 字段扩展(在现有 msg.proto 基础上新增 category/priority/scheduledAt/expiresAt/idempotencyKey 等),按 §5 契约规范走 coord 变更流程。
|
||||
|
||||
### 4.3 请求/响应示例(发送通知)
|
||||
|
||||
```json
|
||||
POST /notifications
|
||||
{
|
||||
"userId": "u-123",
|
||||
"type": "EXAM",
|
||||
"category": "ACADEMIC",
|
||||
"priority": "HIGH",
|
||||
"title": "数学期中考试已发布",
|
||||
"content": "请于 2026-07-15 前完成",
|
||||
"channel": "in_app",
|
||||
"metadata": { "examId": "e-456" },
|
||||
"idempotencyKey": "client-uuid-789",
|
||||
"scheduledAt": null
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "notif-uuid",
|
||||
"skipped": false,
|
||||
"deliveries": [
|
||||
{ "channel": "in_app", "status": "SENT" },
|
||||
{ "channel": "push", "status": "SENT", "externalId": "push-msg-id" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 事件设计
|
||||
|
||||
### 5.1 消费的事件(Kafka Consumer)
|
||||
|
||||
消费组:`msg-service`,partition 按 aggregate_id 保证同聚合有序。
|
||||
|
||||
| Topic | 生产者 | 事件类型 | msg 动作 |
|
||||
| ----------------------------------- | -------- | ----------------- | ----------------------------------- |
|
||||
| `edu.identity.user.created` | iam | UserRegistered | 发欢迎通知(in_app + email) |
|
||||
| `edu.identity.user.updated` | iam | UserUpdated | 通知关键信息变更 |
|
||||
| `edu.identity.user.role_changed` | iam | UserRoleChanged | 通知角色变更 |
|
||||
| `edu.teaching.exam.published` | core-edu | ExamPublished | 给班级全体学生发考试通知(fan-out) |
|
||||
| `edu.teaching.assignment.submitted` | core-edu | HomeworkSubmitted | 通知教师有学生提交作业 |
|
||||
| `edu.teaching.grade.recorded` | core-edu | GradeRecorded | 通知学生成绩已录入 |
|
||||
| `edu.insight.mastery.updated` | data-ana | MasteryUpdated | 掌握度低于阈值触发预警通知 |
|
||||
|
||||
**消费幂等**:每个消息的 `event_id`(proto 字段)→ Redis SETNX `msg:idem:{event_id}` TTL 7 天。SETNX 成功才处理;失败说明已处理,跳过。Redis 不可用时降级到 `msg_idempotency` 表唯一键。
|
||||
|
||||
### 5.2 发布的事件(Outbox → Kafka)
|
||||
|
||||
| Topic | 事件类型 | 触发时机 | 消费者 |
|
||||
| ------------------------- | ---------------------- | -------------------- | ------------ |
|
||||
| `edu.notification.events` | notification.requested | 通知创建后,请求推送 | push-gateway |
|
||||
| `edu.notification.events` | notification.delivered | 渠道投递成功(未来) | data-ana |
|
||||
| `edu.notification.events` | notification.read | 用户标记已读(未来) | data-ana |
|
||||
|
||||
> NotificationRequested 事件需 coord 在 events.proto 追加 NotificationEvent message(见 §7 交互点)。
|
||||
|
||||
### 5.3 事件版本演化
|
||||
|
||||
- 字段只增不减,proto 编号不复用
|
||||
- 破坏性变更新建 `v2` 后缀 topic(如 `edu.notification.events.v2`),消费者双消费过渡期后下线 v1
|
||||
|
||||
---
|
||||
|
||||
## 6. 横切关注点对齐清单
|
||||
|
||||
### 6.1 权限装饰器(权限点清单)
|
||||
|
||||
| 权限常量 | 说明 | 端点 |
|
||||
| ----------------------- | --------------------- | ---------------------------------------------------------------- |
|
||||
| MSG_NOTIFICATION_SEND | 发送通知 | POST /notifications, POST /notifications/batch, gRPC Send |
|
||||
| MSG_NOTIFICATION_READ | 读取通知 | GET /notifications/user/*, search, unread-count, GET preferences |
|
||||
| MSG_NOTIFICATION_MANAGE | 管理通知(已读/删除) | PUT /read, read-all, DELETE, PUT preferences |
|
||||
| MSG_TEMPLATE_MANAGE | 模板管理(新增) | POST/GET/PUT/DELETE /notifications/templates/* |
|
||||
|
||||
> 权限模型从硬编码 ROLE_PERMISSIONS 迁移到对齐 iam 的 RBAC:角色 → 权限点映射由 iam 通过 `getEffectivePermissions` 下发,PermissionGuard 读取 `x-user-permissions` header(由 Gateway 注入)。过渡期保留 ROLE_PERMISSIONS 兜底。
|
||||
|
||||
### 6.2 错误码清单(前缀 `MSG_`)
|
||||
|
||||
| 错误码 | 触发条件 | HTTP |
|
||||
| -------------------------- | ----------------------------- | ---- |
|
||||
| MSG_VALIDATION_ERROR | Zod 校验失败 | 400 |
|
||||
| MSG_NOT_FOUND | 通知/模板/偏好不存在 | 404 |
|
||||
| MSG_PERMISSION_DENIED | 权限不足 | 403 |
|
||||
| MSG_CONFLICT | 幂等键冲突/重复发送 | 409 |
|
||||
| MSG_BUSINESS_ERROR | 偏好禁用该渠道/静默时段跳过 | 422 |
|
||||
| MSG_TEMPLATE_RENDER_ERROR | 模板变量缺失/渲染失败(新增) | 422 |
|
||||
| MSG_RATE_LIMITED | 短信/邮件限流(新增) | 429 |
|
||||
| MSG_QUOTA_EXCEEDED | 短信配额耗尽(新增) | 429 |
|
||||
| MSG_DATABASE_ERROR | DB 操作失败 | 500 |
|
||||
| MSG_EXTERNAL_GATEWAY_ERROR | SMTP/SMS 网关错误(新增) | 502 |
|
||||
| MSG_INTERNAL_ERROR | 兜底 | 500 |
|
||||
|
||||
### 6.3 可观测性
|
||||
|
||||
**Logger**:pino,结构化 JSON,注入 traceId(从 `x-request-id` header)。初始化位置 [logger.ts](../src/shared/observability/logger.ts)。
|
||||
|
||||
**Metrics 指标清单**(prom-client,前缀 `msg_`):
|
||||
|
||||
| 指标名 | 类型 | 标签 | 说明 |
|
||||
| ------------------------------------- | --------- | ------------------------------ | --------------- |
|
||||
| msg_notification_sent_total | Counter | channel,type,category,priority | 通知发送总数 |
|
||||
| msg_notification_delivered_total | Counter | channel,status | 投递结果总数 |
|
||||
| msg_notification_read_total | Counter | type | 已读总数 |
|
||||
| msg_kafka_consumed_total | Counter | topic,event_type | Kafka 消费总数 |
|
||||
| msg_kafka_consumed_errors_total | Counter | topic | Kafka 消费失败 |
|
||||
| msg_outbox_pending | Gauge | — | Outbox 待发布数 |
|
||||
| msg_outbox_publish_duration_seconds | Histogram | — | Outbox 发布耗时 |
|
||||
| msg_template_render_duration_seconds | Histogram | code,channel | 模板渲染耗时 |
|
||||
| msg_idempotency_dedup_total | Counter | source(kafka/http) | 幂等去重命中数 |
|
||||
| msg_channel_delivery_duration_seconds | Histogram | channel | 渠道投递耗时 |
|
||||
|
||||
**Tracer**:OpenTelemetry SDK + OTLP exporter,初始化位置 [tracer.ts](../src/shared/observability/tracer.ts)。span 命名:`msg.<service>.<method>`(如 `msg.notification.send`、`msg.kafka.consume`)。
|
||||
|
||||
### 6.4 健康检查
|
||||
|
||||
| 端点 | 检查逻辑 | 失败状态 |
|
||||
| ------------ | ------------------------------------------------------------ | -------- |
|
||||
| GET /healthz | 进程存活,不检查依赖 | 200 |
|
||||
| GET /readyz | DB `SELECT 1` + Redis `PING` + ES `ping`(任一失败返回 503) | 503 |
|
||||
|
||||
> 当前 /readyz 仅查 DB,**阶段 2 实现需补 Redis + ES 检查**(ai10 修订)。
|
||||
|
||||
### 6.5 优雅关闭顺序
|
||||
|
||||
统一到 LifecycleService(移除 main.ts 重复逻辑),顺序:
|
||||
|
||||
1. HTTP server 停止接收新请求(`app.close()`)
|
||||
2. Kafka consumer 提交 offset 并停止(`consumer.disconnect()`)
|
||||
3. OutboxPublisher 停止轮询(`stop()`)
|
||||
4. Elasticsearch 关闭(`closeEs()`)
|
||||
5. Redis 关闭(`redisClient.quit()`)
|
||||
6. MySQL 连接池关闭(`closeDb()`)
|
||||
7. Tracer flush 并关闭(`shutdownTracer()`)
|
||||
|
||||
---
|
||||
|
||||
## 7. 与其他模块的交互点(契约清单)
|
||||
|
||||
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
|
||||
| ------ | ------------ | ----- | ----------------------------------------------- | ----------------------------------------- |
|
||||
| 被调用 | api-gateway | HTTP | /notifications/* | REST 转发 |
|
||||
| 被调用 | teacher-bff | gRPC | NotificationService.* | 教师端通知聚合 |
|
||||
| 被调用 | student-bff | gRPC | NotificationService.* | 学生端通知查询 |
|
||||
| 被调用 | parent-bff | gRPC | NotificationService.* | 家长端通知查询 |
|
||||
| 调用 | push-gateway | gRPC | PushService.Push(ai02 定义) | 实时推送(替代当前 fetch /internal/push) |
|
||||
| 消费 | iam | Kafka | edu.identity.user.* | 用户创建/更新/角色变更触发通知 |
|
||||
| 消费 | core-edu | Kafka | edu.teaching.exam.published 等 | 考试/作业/成绩事件触发通知 |
|
||||
| 消费 | data-ana | Kafka | edu.insight.mastery.updated | 掌握度预警 |
|
||||
| 发布 | — | Kafka | edu.notification.events | NotificationRequested → push-gateway |
|
||||
| 读 | iam | gRPC | UserService.GetUser(可选,查用户信息渲染模板) | 模板变量填充(如学生姓名) |
|
||||
|
||||
### 跨模块契约对齐提请 coord
|
||||
|
||||
1. **events.proto 补充 NotificationEvent**:msg 发布 NotificationRequested 需 proto 定义(coord 维护)
|
||||
2. **push-gateway gRPC PushService**:ai02 需定义 gRPC Push 方法,msg 从 fetch 迁移到 gRPC(当前 fetch 作为降级保留)
|
||||
3. **iam 用户信息查询**:模板渲染需用户姓名,msg 是否调 iam.GetUser?还是事件 payload 携带足够信息?(倾向后者,避免同步依赖)
|
||||
4. **msg.proto 扩展**:新增 MarkAllAsRead / GetUnreadCount / GetPreference / UpdatePreference 方法,coord 统一更新 proto
|
||||
|
||||
---
|
||||
|
||||
## 8. 渠道策略模式设计(ai-allocation §5 核心)
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class ChannelStrategy {
|
||||
<<interface>>
|
||||
+channel: Channel
|
||||
+send(notification, rendered): Promise~DeliveryResult~
|
||||
+isAvailable(): boolean
|
||||
+checkRateLimit(userId): boolean
|
||||
}
|
||||
|
||||
class InAppChannel {
|
||||
+send(): DB insert + ES index
|
||||
+isAvailable(): DB up
|
||||
}
|
||||
class EmailChannel {
|
||||
+send(): SMTP send (async queue)
|
||||
+isAvailable(): SMTP reachable
|
||||
+checkRateLimit(): Redis 滑动窗口
|
||||
}
|
||||
class SmsChannel {
|
||||
+send(): 短信网关 API
|
||||
+isAvailable(): 网关可达 + 配额未满
|
||||
+checkRateLimit(): Redis 严格限流
|
||||
+checkQuota(): Redis 计数
|
||||
}
|
||||
class PushChannel {
|
||||
+send(): gRPC → push-gateway
|
||||
+isAvailable(): push-gateway up
|
||||
+fallback(): InAppChannel
|
||||
}
|
||||
class WechatChannel {
|
||||
+send(): 未来实现
|
||||
+isAvailable(): false (P5 不实现)
|
||||
}
|
||||
|
||||
ChannelStrategy <|.. InAppChannel
|
||||
ChannelStrategy <|.. EmailChannel
|
||||
ChannelStrategy <|.. SmsChannel
|
||||
ChannelStrategy <|.. PushChannel
|
||||
ChannelStrategy <|.. WechatChannel
|
||||
```
|
||||
|
||||
**渠道编排逻辑**(DeliveryService):
|
||||
|
||||
1. 按 NotificationPreference 过滤被禁用渠道
|
||||
2. 按 QuietHours 过滤非紧急通知(URGENT 绕过静默)
|
||||
3. 对每个启用渠道并行调用 `ChannelStrategy.send()`
|
||||
4. 失败渠道记录到 deliveries 表,按 max_retry 重试(指数退避)
|
||||
5. PushChannel 失败降级到 InAppChannel(保证不丢)
|
||||
|
||||
**未来扩展**(不改核心):新增微信/钉钉/企业微信渠道,只需实现 ChannelStrategy 并注册到 ChannelRegistry。
|
||||
|
||||
---
|
||||
|
||||
## 9. 已读/未读状态管理(Redis 位图)
|
||||
|
||||
### 设计
|
||||
|
||||
- 每个用户一个 Redis Bitmap:key `msg:unread:{userId}`,bit offset = 通知序号(自增)
|
||||
- 新通知:`SETBIT msg:unread:{userId} {seq} 1`
|
||||
- 标记已读:`SETBIT msg:unread:{userId} {seq} 0`
|
||||
- 未读计数:`BITCOUNT msg:unread:{userId}`
|
||||
- 全部已读:`SET msg:unread:{userId} 0`(清空)
|
||||
|
||||
### 序号分配
|
||||
|
||||
- Redis INCR `msg:seq:{userId}` 获取递增序号,存入 notifications.seq 字段(新增)
|
||||
|
||||
### 降级策略
|
||||
|
||||
- Redis 不可用 → 降级到 DB `COUNT(*) WHERE user_id=? AND is_read=false`
|
||||
- DB 是 source of truth,Redis 是缓存;标记已读时双写(DB update + Redis SETBIT),Redis 失败仅 log 不阻断
|
||||
|
||||
### 未来扩展
|
||||
|
||||
- 位图支持按 category 分桶(`msg:unread:{userId}:ACADEMIC`)实现分类未读数
|
||||
|
||||
---
|
||||
|
||||
## 10. ES 降级查询策略(ai-allocation §5)
|
||||
|
||||
### 双向降级
|
||||
|
||||
| 触发场景 | 主路径 | 降级路径 |
|
||||
| ------------ | ------ | -------------------------- |
|
||||
| 正常列表查询 | DB | ES(DB 不可用时) |
|
||||
| 正常全文检索 | ES | DB LIKE(ES 不可用时) |
|
||||
| DB 不可用 | — | ES 读模型(可能延迟 1s) |
|
||||
| ES 不可用 | — | DB LIKE 或返回空(检索类) |
|
||||
|
||||
### 实现要点
|
||||
|
||||
- `NotificationRepository` 内置 `listByUser()` 优先 DB,catch DB error 后 fallback 到 `esSearch()`
|
||||
- `search()` 优先 ES,catch ES error 后 fallback 到 `dbLikeSearch()`
|
||||
- 降级时 metrics 记录 `msg_db_fallback_total` / `msg_es_fallback_total` 便于监控降级频率
|
||||
- ES 读模型通过 `safeIndex` 异步同步,可能存在 1s 延迟,降级时在响应头标注 `X-Read-Model: es-degraded`
|
||||
|
||||
---
|
||||
|
||||
## 11. Kafka 消费幂等设计(ai-allocation §5)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
K[Kafka 消息] --> CHECK{Redis SETNX<br/>msg:idem:event_id}
|
||||
CHECK -- 设置成功 --> PROC[处理事件<br/>渲染模板→fan-out]
|
||||
CHECK -- 已存在 --> SKIP[跳过,返回原结果]
|
||||
PROC --> WRITE[写 notifications + deliveries + outbox]
|
||||
WRITE --> CACHE[缓存结果到 Redis]
|
||||
PROC -.失败.-> DLQ[Dead Letter Topic<br/>edu.notification.dlq]
|
||||
DLQ --> ALERT[告警 + 人工处理]
|
||||
```
|
||||
|
||||
**双保险**:
|
||||
|
||||
- L1:Redis SETNX(快速去重,TTL 7 天)
|
||||
- L2:`msg_idempotency` 表唯一键(Redis 不可用时兜底)
|
||||
- L3:`notifications.source_event_id` 唯一索引(最终防线)
|
||||
|
||||
**死信队列**:消费失败超过 3 次的消息投递到 `edu.notification.dlq`,触发告警,人工介入。
|
||||
|
||||
---
|
||||
|
||||
## 12. 风险与假设
|
||||
|
||||
### 12.1 技术风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
| -------------------------------------- | ----------------- | ------------------------------------------------------ |
|
||||
| 广播场景 fan-out 性能(1 通知→N 学生) | DB 写入瓶颈 | 批量 INSERT + 异步 ES 索引 + Push 走 push-gateway 批量 |
|
||||
| 短信成本失控 | 资金损失 | 严格限流 + 配额管理 + 降级到 in_app |
|
||||
| Redis 单点故障 | 未读计数/幂等失效 | 降级到 DB,双写保证最终一致 |
|
||||
| Kafka 消费积压 | 通知延迟 | 监控 lag,HPA 扩容 consumer 实例 |
|
||||
| 模板变量缺失导致渲染失败 | 通知发不出 | 渲染失败记 MSG_TEMPLATE_RENDER_ERROR,降级用默认文案 |
|
||||
| ES 与 DB 数据不一致 | 检索结果缺失 | 定期 reconcile job(未来 P6),降级读标注 |
|
||||
|
||||
### 12.2 假设
|
||||
|
||||
- 假设 iam 在 P2 已提供 `getEffectivePermissions` API,msg 的 PermissionGuard 可读取权限(过渡期用 ROLE_PERMISSIONS 兜底)
|
||||
- 假设 push-gateway(ai02)P5 提供 gRPC PushService(当前 fetch /internal/push 作为降级)
|
||||
- 假设 coord 在 events.proto 补充 NotificationEvent message
|
||||
- 假设 Redis 纳入 P5 基础设施(infra/docker-compose.yml 已有 edu-redis,msg 共用)
|
||||
- 假设 core-edu 事件 payload 携带足够字段(classId/studentId/title)供模板渲染,msg 不需同步调 iam
|
||||
|
||||
### 12.3 未决决策(提请 coord 仲裁)
|
||||
|
||||
1. **Redis 是否 P5 必选**:msg 幂等/位图/限流强依赖 Redis,若 P5 不引入则需全部降级到 DB(性能折损)
|
||||
2. **gRPC 启用时机**:msg gRPC controller 是否 P5 实现(影响 BFF 调用方式)
|
||||
3. **短信/邮件服务商选型**:影响 ChannelStrategy 实现(P5 是否实现真实发送,还是 mock + 接口预留)
|
||||
4. **每日摘要(digest)是否 P5**:影响 scheduler 设计(倾向 P6 实现,P5 仅预留 schema)
|
||||
|
||||
---
|
||||
|
||||
## 13. 实施路线(P5 → P6 演进)
|
||||
|
||||
### P5(当前阶段,最小可用)
|
||||
|
||||
| 优先级 | 工作项 | 依赖 |
|
||||
| ------ | ------------------------------------------ | --------------------------- |
|
||||
| P0 | Repository 抽象层(M4) | — |
|
||||
| P0 | Kafka consumer + 幂等(M1/M5) | Redis |
|
||||
| P0 | Outbox + NotificationRequested 事件(M2) | events.proto 补充 |
|
||||
| P0 | 渠道策略模式 + InApp/Push/Email/Sms(M14) | push-gateway gRPC |
|
||||
| P0 | 通知模板模块(M13) | — |
|
||||
| P0 | Redis 位图已读/未读(M15) | Redis |
|
||||
| P0 | ES mapping + ensureIndex(M3) | — |
|
||||
| P0 | DB→ES 降级读路径(M16) | — |
|
||||
| P1 | 投递记录表 + 重试(M17) | — |
|
||||
| P1 | 幂等键(HTTP send)(M18) | — |
|
||||
| P1 | 优先级/分类(M19) | — |
|
||||
| P1 | /readyz 补 Redis+ES 检查 | — |
|
||||
| P1 | 优雅关闭统一到 LifecycleService(M11) | — |
|
||||
| P1 | NotificationsModule exports(M7) | — |
|
||||
| P1 | 权限模型对齐 iam RBAC(M21) | iam getEffectivePermissions |
|
||||
| P1 | gRPC controller(M9,待 coord 决策) | gRPC 启用决策 |
|
||||
| P2 | README 修正(M10) | — |
|
||||
| P2 | 测试覆盖率 ≥ 80% | — |
|
||||
|
||||
### P6+(未来硬化,schema 预留)
|
||||
|
||||
- 调度/延迟发送(scheduledAt 字段已预留)→ Scheduler worker
|
||||
- 每日摘要邮件(digest_email 字段已预留)→ Digest worker
|
||||
- 通知过期清理(expires_at 字段已预留)→ TTL job
|
||||
- 微信/钉钉渠道(WechatChannel 接口已预留)
|
||||
- 多租户隔离(tenant_id 字段预留)
|
||||
- Webhook 渠道(系统对接)
|
||||
|
||||
---
|
||||
|
||||
## 14. 与黄金模板(classes)对齐 checklist
|
||||
|
||||
| 检查项 | msg 现状 | 阶段 2 目标 |
|
||||
| ----------------------- | --------------- | ------------------ |
|
||||
| @RequirePermission 覆盖 | ✅ 6 端点 | ✅ 全部新端点 |
|
||||
| 错误码前缀 | ✅ MSG_ | ✅ 保持 |
|
||||
| logger/metrics/tracer | ✅ | ✅ 补 msg 专属指标 |
|
||||
| /healthz + /readyz | ⚠️ readyz 仅 DB | ✅ 补 Redis+ES |
|
||||
| 优雅关闭 | ⚠️ 重复关闭 | ✅ 统一 Lifecycle |
|
||||
| 测试覆盖率 | 0% | ≥ 80% |
|
||||
| Dockerfile 多阶段 | ✅ | ✅ |
|
||||
| Zod 输入验证 | ✅ | ✅ 新 DTO 同步 |
|
||||
| GlobalErrorFilter | ✅ 含 ZodError | ✅ |
|
||||
| Repository 抽象 | ❌ | ✅ 新增 |
|
||||
| Outbox | ❌ | ✅ 新增 |
|
||||
| Module exports | ❌ | ✅ 新增 |
|
||||
|
||||
---
|
||||
|
||||
**AI Agent**: ai10 (msg)
|
||||
**Coordinator**: coord-ai
|
||||
**Branch**: 单仓库并行模式(直接 push main)
|
||||
Reference in New Issue
Block a user