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.设计规格文档
1158 lines
74 KiB
Markdown
1158 lines
74 KiB
Markdown
# 模块架构设计文档 — msg
|
||
|
||
> AI 标识:ai05
|
||
> 负责模块:msg(P5)
|
||
> 阶段:架构设计外包 · 阶段 2(模块架构设计)
|
||
> 日期:2026-07-09
|
||
> 版本:v1.0
|
||
> 关联文档:[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)、[known-issues.md](../../../docs/troubleshooting/known-issues.md)、[project_rules.md](../../../.trae/rules/project_rules.md)
|
||
|
||
---
|
||
|
||
## 0. 设计哲学与文档定位
|
||
|
||
### 0.1 设计哲学
|
||
|
||
msg 服务承载**沟通通知中台**职责,设计哲学遵循五条原则:
|
||
|
||
1. **多渠道抽象策略模式**:站内信 / 邮件 / 短信 / 微信 / 推送通道抽象为 `NotificationChannel` 接口,新增渠道只需新增策略实现,无需改动业务代码
|
||
2. **事件驱动 fan-out**:消费 core-edu / iam / data-ana 事件触发通知,所有事件处理幂等去重(at-least-once 语义保证)
|
||
3. **CQRS 读写分离**:写模型走 MySQL + Outbox;读模型走 ES 全文检索 + Redis 位图(已读状态)
|
||
4. **Push Gateway 解耦**:msg 不直接管理 WebSocket 长连接,通过 gRPC 委托 push-gateway 推送实时通知,msg 负责通知"业务编排"与"持久化"
|
||
5. **Outbox 强制**:业务事务事件(NotificationSent / NotificationRead)必须 Outbox 模式投递(004 §12.2 强制条款)
|
||
|
||
### 0.2 长远演进目标
|
||
|
||
msg 不止服务 P5 通知 CRUD 阶段,需为以下未来场景预留架构弹性:
|
||
|
||
| 时间线 | 演进方向 | 当前架构预留点 |
|
||
| ------- | ------------------------------------------------------- | ---------------------------------------------------------- |
|
||
| P5 完成 | 通知 CRUD + 多渠道分发 + Kafka 消费 + Push Gateway 推送 | NotificationChannel 抽象 + Kafka consumer + Redis 幂等去重 |
|
||
| P6+ | 富媒体消息(图片/视频/文件)、消息撤回与编辑 | metadata jsonb 字段预留 + status 状态机预留 recalled 字段 |
|
||
| 未来 | 群组消息(班级群/学科群/年级群) | group_id 字段预留 + fan-out 算法可扩展 |
|
||
| 未来 | 跨端消息已读状态同步(多端登录) | Redis 位图 key 设计支持多端维度 |
|
||
| 未来 | AI 触发的智能通知(学情预警自动推送) | 接收 data-ana 的 MasteryUpdated 事件触发预警通知 |
|
||
| 未来 | 通知疲劳控制(用户接收频率限制) | NotificationPreference.frequency_limit 字段预留 |
|
||
| 未来 | 消息合规审计(学校/教育主管部门审计要求) | notification_audit_log 表预留 |
|
||
| 未来 | 推送通道扩展(小程序/企业微信/钉钉/IM) | NotificationChannel 抽象 + 渠道配置表 |
|
||
| 未来 | 消息搜索与归档(按时间归档冷数据) | ES 索引 + alias 切换 + 冷热分离 |
|
||
|
||
### 0.3 文档结构说明
|
||
|
||
本设计文档遵循 ai-allocation.md §7 模板的 8 节结构,并补充第 9 节"演进路线"与第 10 节"风险与假设"。所有跨模块契约点均回标到 004 架构影响地图对应章节。
|
||
|
||
---
|
||
|
||
## 1. 模块内部分层图
|
||
|
||
### 1.1 整体分层架构
|
||
|
||
```mermaid
|
||
flowchart TB
|
||
subgraph Client["客户端"]
|
||
BFF["teacher-bff / student-bff / parent-bff"]
|
||
end
|
||
|
||
subgraph Gateway["API Gateway"]
|
||
GW["api-gateway (JWT 校验/限流/熔断)"]
|
||
end
|
||
|
||
subgraph Msg["msg 服务 (3007)"]
|
||
direction TB
|
||
CTL[Controller 层<br/>HTTP REST + gRPC]
|
||
GRD[Guard 层<br/>PermissionGuard + AuthMiddleware]
|
||
VAL[Validation 层<br/>Zod Schema Parse]
|
||
SVC[NotificationService<br/>通知编排]
|
||
DISP[ChannelDispatcher<br/>多渠道分发]
|
||
REPO[Repository 层<br/>Drizzle ORM]
|
||
OUT[Outbox Publisher<br/>独立 worker]
|
||
CONS[Kafka Consumer<br/>消费 5+ 类事件]
|
||
IDEM[Idempotency Guard<br/>Redis SETNX 去重]
|
||
PUSH[Push Gateway Client<br/>gRPC 推送通道]
|
||
FILTER[GlobalErrorFilter<br/>统一错误兜底]
|
||
OBS[Observability<br/>pino + prom-client + OTel]
|
||
end
|
||
|
||
subgraph Channels["通知渠道实现"]
|
||
INAPP[InAppChannel<br/>站内信]
|
||
EMAIL[EmailChannel<br/>邮件]
|
||
SMS[SMSChannel<br/>短信]
|
||
WECHAT[WeChatChannel<br/>微信]
|
||
end
|
||
|
||
subgraph Storage["数据存储"]
|
||
MySQL[MySQL 8<br/>写模型主库]
|
||
ES[Elasticsearch 8<br/>全文检索读模型]
|
||
Redis[Redis 7<br/>幂等去重 + 已读位图]
|
||
end
|
||
|
||
subgraph Bus["消息总线"]
|
||
Kafka[Kafka<br/>edu.notification.* + 消费 edu.teaching.* / edu.identity.* / edu.insight.*]
|
||
end
|
||
|
||
subgraph External["外部服务"]
|
||
PushGW[push-gateway<br/>WebSocket 推送]
|
||
SMTP[SMTP Server]
|
||
SMSProv[SMS Provider]
|
||
end
|
||
|
||
BFF -->|HTTP REST| GW
|
||
GW -->|HTTP REST| CTL
|
||
Kafka -.->|consume| CONS
|
||
CONS --> IDEM
|
||
IDEM --> SVC
|
||
CTL --> GRD
|
||
GRD --> VAL
|
||
VAL --> SVC
|
||
SVC --> REPO
|
||
SVC --> OUT
|
||
SVC --> DISP
|
||
DISP --> INAPP
|
||
DISP --> EMAIL
|
||
DISP --> SMS
|
||
DISP --> WECHAT
|
||
INAPP --> REPO
|
||
REPO --> MySQL
|
||
OUT -->|polling + publish| Kafka
|
||
SVC --> PUSH
|
||
PUSH -.->|gRPC| PushGW
|
||
EMAIL -.->|SMTP| SMTP
|
||
SMS -.->|HTTP API| SMSProv
|
||
WECHAT -.->|HTTP API| WECHAT
|
||
REPO --> ES
|
||
CTL -.-> FILTER
|
||
SVC -.-> OBS
|
||
IDEM -.-> Redis
|
||
```
|
||
|
||
### 1.2 请求处理链路
|
||
|
||
| 阶段 | 组件 | 职责 |
|
||
| -------- | ----------------------------------- | --------------------------------------------------- |
|
||
| 入口 | NestJS ExpressAdapter / gRPC server | HTTP 3007 / gRPC 50056 |
|
||
| 鉴权 | AuthMiddleware | 信任 Gateway 注入的 `x-user-id` / `x-user-roles` 头 |
|
||
| 授权 | PermissionGuard (APP_GUARD) | 校验 3 个 `MSG_*` 权限点(未来扩展为 6+ 个) |
|
||
| 校验 | Zod Schema Parse | Controller 层解析 body,失败抛 ZodError |
|
||
| 业务编排 | NotificationService | 通知发送 / 列表查询 / 标记已读 / 搜索 |
|
||
| 渠道分发 | ChannelDispatcher | 按用户偏好与通知类型选择渠道,并行投递 |
|
||
| 持久化 | Repository (Drizzle ORM) | 写 msg_notifications + msg_notification_preferences |
|
||
| 事件 | Outbox Publisher | 投递 NotificationSent / NotificationRead 事件 |
|
||
| 异步消费 | Kafka Consumer | 消费 core-edu / iam / data-ana 事件触发通知 |
|
||
| 幂等 | IdempotencyGuard | Redis SETNX 基于 event_id 去重 |
|
||
| 推送 | PushGatewayClient | gRPC 调用 push-gateway 实时投递 |
|
||
| 错误 | GlobalErrorFilter | 捕获 ApplicationError + ZodError,结构化响应 |
|
||
| 观测 | Logger / Metrics / Tracer | 全链路 traceparent 传递 |
|
||
|
||
### 1.3 同步链路 vs 异步链路
|
||
|
||
**同步链路**(用户主动操作):
|
||
|
||
- 用户调 POST /notifications/send → 同步写 DB + Outbox + 调度 channel dispatcher → 返回响应
|
||
- channel dispatcher 内部并行调多个渠道(in_app / push / email / sms),慢渠道走异步队列
|
||
|
||
**异步链路**(事件驱动触发):
|
||
|
||
- core-edu 发布 `edu.teaching.exam.published` → msg Kafka Consumer 消费 → 创建通知 → 调度 channel dispatcher → 发送给所有学生
|
||
- iam 发布 `edu.identity.user.created` → msg 消费 → 发欢迎通知
|
||
|
||
---
|
||
|
||
## 2. 领域模型
|
||
|
||
### 2.1 聚合根与实体
|
||
|
||
```mermaid
|
||
classDiagram
|
||
class Notification {
|
||
+id: string
|
||
+userId: string
|
||
+type: NotificationType
|
||
+title: string
|
||
+content: string
|
||
+channel: NotificationChannel
|
||
+status: NotificationStatus
|
||
+metadata: object
|
||
+relatedEntityType: string|null
|
||
+relatedEntityId: string|null
|
||
+groupId: string|null
|
||
+senderId: string|null
|
||
+createdAt: Date
|
||
+readAt: Date|null
|
||
+markAsRead(userId: string) void
|
||
+recall() void
|
||
}
|
||
class NotificationPreference {
|
||
+id: string
|
||
+userId: string
|
||
+type: NotificationType
|
||
+channels: NotificationChannel[]
|
||
+frequencyLimit: number
|
||
+quietHours: QuietHours|null
|
||
+enabled: boolean
|
||
+createdAt: Date
|
||
+updatedAt: Date
|
||
+allowsChannel(channel) boolean
|
||
}
|
||
class NotificationTemplate {
|
||
+id: string
|
||
+code: string
|
||
+type: NotificationType
|
||
+titleTemplate: string
|
||
+contentTemplate: string
|
||
+defaultChannels: NotificationChannel[]
|
||
+variables: string[]
|
||
+locale: string
|
||
+status: TemplateStatus
|
||
+createdAt: Date
|
||
+updatedAt: Date
|
||
+render(variables: object) RenderedNotification
|
||
}
|
||
class NotificationChannel {
|
||
<<interface>>
|
||
+send(notification: Notification) Promise~SendResult~
|
||
+name: string
|
||
}
|
||
|
||
class InAppChannel {
|
||
+send() Promise~SendResult~
|
||
}
|
||
class EmailChannel {
|
||
+send() Promise~SendResult~
|
||
}
|
||
class SMSChannel {
|
||
+send() Promise~SendResult~
|
||
}
|
||
class WeChatChannel {
|
||
+send() Promise~SendResult~
|
||
}
|
||
class PushChannel {
|
||
+send() Promise~SendResult~
|
||
}
|
||
|
||
Notification --> NotificationChannel : dispatched via
|
||
Notification "1" --> "0..1" NotificationTemplate : rendered from
|
||
Notification "many" --> "1" NotificationPreference : respects
|
||
NotificationChannel <|.. InAppChannel
|
||
NotificationChannel <|.. EmailChannel
|
||
NotificationChannel <|.. SMSChannel
|
||
NotificationChannel <|.. WeChatChannel
|
||
NotificationChannel <|.. PushChannel
|
||
```
|
||
|
||
### 2.2 值对象
|
||
|
||
| 值对象 | 字段 | 用途 |
|
||
| --------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------- |
|
||
| `NotificationType` | `system` / `exam` / `homework` / `grade` / `attendance` / `announcement` / `mastery_alert` / `welcome` | 通知类型枚举(对齐 004 §7.3 事件类型) |
|
||
| `NotificationChannel` | `in_app` / `email` / `sms` / `wechat` / `push` | 渠道枚举 |
|
||
| `NotificationStatus` | `pending` / `sent` / `delivered` / `read` / `failed` / `recalled` | 状态机 |
|
||
| `TemplateStatus` | `draft` / `active` / `archived` | 模板状态机 |
|
||
| `SendResult` | `{ success, channel, messageId?, error? }` | 发送结果值对象 |
|
||
| `QuietHours` | `{ start: "22:00", end: "07:00", timezone: "Asia/Shanghai" }` | 免打扰时段(未来扩展) |
|
||
|
||
### 2.3 状态机设计
|
||
|
||
#### Notification 状态机
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> pending: create
|
||
pending --> sent: channel dispatcher 投递成功
|
||
pending --> failed: 投递失败(重试耗尽)
|
||
sent --> delivered: 渠道回执确认送达
|
||
delivered --> read: 用户标记已读
|
||
sent --> read: 用户标记已读(跳过 delivered)
|
||
read --> recalled: 撤回 (P6+ 预留)
|
||
sent --> recalled: 撤回 (P6+ 预留)
|
||
failed --> pending: 重试
|
||
```
|
||
|
||
#### NotificationTemplate 状态机
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> draft: create
|
||
draft --> active: publish
|
||
active --> archived: archive
|
||
archived --> draft: edit (复活)
|
||
```
|
||
|
||
### 2.4 多渠道分发策略
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
NOTIF[Notification] --> DISP[ChannelDispatcher]
|
||
DISP --> PREF[用户 NotificationPreference]
|
||
PREF --> INAPP[in_app 总是发送]
|
||
PREF --> EMAIL{email 启用?}
|
||
PREF --> SMS{sms 启用?}
|
||
PREF --> WECHAT{wechat 启用?}
|
||
PREF --> PUSH{push 启用?}
|
||
INAPP --> DB[(MySQL)]
|
||
EMAIL --> SMTP[SMTP Server]
|
||
SMS --> SMSAPI[SMS Provider]
|
||
WECHAT --> WXAPI[WeChat API]
|
||
PUSH --> PUSHGW[Push Gateway gRPC]
|
||
```
|
||
|
||
**分发规则**:
|
||
|
||
1. `in_app` 渠道**总是发送**(保证站内信可见)
|
||
2. 其他渠道按 `NotificationPreference.channels` 配置启用/禁用
|
||
3. `push` 渠道仅对在线用户发送(push-gateway 维护在线状态)
|
||
4. `quiet_hours` 时段内除紧急通知外,其他渠道延迟到时段结束后发送
|
||
|
||
---
|
||
|
||
## 3. 数据模型
|
||
|
||
### 3.1 MySQL Schema
|
||
|
||
#### 3.1.1 `msg_notifications` 表(扩展字段)
|
||
|
||
| 字段 | 类型 | 约束 | 说明 |
|
||
| ------------------- | ------------ | -------------------------------------------- | -------------------------------------------- |
|
||
| id | varchar(32) | PK | cuid2 |
|
||
| user_id | varchar(32) | NOT NULL, INDEX | 接收者 |
|
||
| type | varchar(32) | NOT NULL, INDEX | NotificationType 枚举 |
|
||
| title | varchar(255) | NOT NULL | |
|
||
| content | text | NOT NULL | |
|
||
| channel | varchar(32) | NOT NULL | NotificationChannel 枚举 |
|
||
| status | varchar(32) | NOT NULL DEFAULT 'pending' | **新增** 状态机 |
|
||
| metadata | json | NULL | **新增** 扩展(attachments/links/actions) |
|
||
| related_entity_type | varchar(64) | NULL, INDEX | **新增** 关联实体类型(exam/homework/grade) |
|
||
| related_entity_id | varchar(32) | NULL | **新增** 关联实体 ID |
|
||
| group_id | varchar(32) | NULL, INDEX | **新增** 群组 ID(广播通知标识) |
|
||
| sender_id | varchar(32) | NULL | **新增** 发送者(系统通知为 null) |
|
||
| template_id | varchar(32) | NULL | **新增** 模板 ID |
|
||
| event_id | varchar(64) | NULL, UNIQUE | **新增** 触发事件 ID(幂等去重) |
|
||
| read_at | timestamp | NULL | 已读时间 |
|
||
| created_at | timestamp | NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||
| updated_at | timestamp | NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE | **新增** |
|
||
|
||
**索引**:
|
||
|
||
- PRIMARY KEY (`id`)
|
||
- INDEX `idx_notif_user_created` (`user_id`, `created_at` DESC)
|
||
- INDEX `idx_notif_user_type` (`user_id`, `type`)
|
||
- INDEX `idx_notif_user_unread` (`user_id`, `read_at` IS NULL)
|
||
- INDEX `idx_notif_group` (`group_id`)
|
||
- INDEX `idx_notif_related` (`related_entity_type`, `related_entity_id`)
|
||
- UNIQUE INDEX `idx_notif_event_id` (`event_id`)
|
||
|
||
#### 3.1.2 `msg_notification_preferences` 表(扩展字段)
|
||
|
||
| 字段 | 类型 | 约束 | 说明 |
|
||
| -------------------- | ----------- | -------------------------------------------- | ---------------------------------------- |
|
||
| id | varchar(32) | PK | cuid2 |
|
||
| user_id | varchar(32) | NOT NULL, INDEX | |
|
||
| type | varchar(32) | NOT NULL | NotificationType |
|
||
| channels | json | NOT NULL | 启用渠道数组 `["in_app","email","push"]` |
|
||
| frequency_limit | int | NULL | **新增** 频率限制(每小时最多 N 条) |
|
||
| quiet_hours_start | varchar(8) | NULL | **新增** 免打扰开始(如 "22:00") |
|
||
| quiet_hours_end | varchar(8) | NULL | **新增** 免打扰结束 |
|
||
| quiet_hours_timezone | varchar(64) | NULL DEFAULT 'Asia/Shanghai' | **新增** 时区 |
|
||
| enabled | boolean | NOT NULL DEFAULT true | |
|
||
| created_at | timestamp | NOT NULL DEFAULT CURRENT_TIMESTAMP | **补齐** |
|
||
| updated_at | timestamp | NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE | **补齐** |
|
||
|
||
**索引**:
|
||
|
||
- PRIMARY KEY (`id`)
|
||
- UNIQUE INDEX `idx_pref_user_type` (`user_id`, `type`)
|
||
|
||
#### 3.1.3 `msg_notification_templates` 表(**新增**)
|
||
|
||
| 字段 | 类型 | 约束 | 说明 |
|
||
| ---------------- | ------------ | -------------------------------------------- | ---------------------------------------- |
|
||
| id | varchar(32) | PK | cuid2 |
|
||
| code | varchar(64) | NOT NULL, UNIQUE | 模板代码(如 `exam.published.student`) |
|
||
| type | varchar(32) | NOT NULL | NotificationType |
|
||
| title_template | varchar(255) | NOT NULL | 含 `{{variable}}` 占位符 |
|
||
| content_template | text | NOT NULL | 含 `{{variable}}` 占位符 |
|
||
| default_channels | json | NOT NULL | 默认渠道数组 |
|
||
| variables | json | NOT NULL | 必填变量列表 `["examTitle","className"]` |
|
||
| locale | varchar(16) | NOT NULL DEFAULT 'zh-CN' | 国际化(多语言预留) |
|
||
| status | varchar(32) | NOT NULL DEFAULT 'draft' | TemplateStatus |
|
||
| created_at | timestamp | NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||
| updated_at | timestamp | NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE | |
|
||
|
||
**索引**:
|
||
|
||
- PRIMARY KEY (`id`)
|
||
- UNIQUE INDEX `idx_tpl_code_locale` (`code`, `locale`)
|
||
|
||
#### 3.1.4 `msg_outbox_events` 表(Outbox · 新增)
|
||
|
||
| 字段 | 类型 | 约束 | 说明 |
|
||
| -------------- | ------------ | ---------------------------------- | ----------------------------------------- |
|
||
| event_id | varchar(64) | PK | UUID v4 |
|
||
| aggregate_type | varchar(64) | NOT NULL | 'Notification' / 'NotificationPreference' |
|
||
| aggregate_id | varchar(32) | NOT NULL | |
|
||
| event_type | varchar(64) | NOT NULL | `edu.notification.sent` 等 |
|
||
| topic | varchar(128) | NOT NULL | |
|
||
| payload | json | NOT NULL | |
|
||
| status | varchar(16) | NOT NULL DEFAULT 'PENDING' | |
|
||
| retry_count | int | NOT NULL DEFAULT 0 | |
|
||
| created_at | timestamp | NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||
| published_at | timestamp | NULL | |
|
||
| next_retry_at | timestamp | NULL | |
|
||
|
||
**索引**:
|
||
|
||
- PRIMARY KEY (`event_id`)
|
||
- INDEX `idx_outbox_status_retry` (`status`, `next_retry_at`)
|
||
|
||
#### 3.1.5 `processed_events` 表(消费幂等 · 新增)
|
||
|
||
> 若不引入 Redis,使用 DB 唯一索引去重(推荐 Redis,性能更优)
|
||
|
||
| 字段 | 类型 | 约束 | 说明 |
|
||
| ------------ | ------------ | ---------------------------------- | ------------- |
|
||
| event_id | varchar(64) | PK | 消费的事件 ID |
|
||
| topic | varchar(128) | NOT NULL | |
|
||
| processed_at | timestamp | NOT NULL DEFAULT CURRENT_TIMESTAMP | |
|
||
|
||
**索引**:
|
||
|
||
- PRIMARY KEY (`event_id`)
|
||
- INDEX `idx_processed_topic` (`topic`, `processed_at`)
|
||
|
||
### 3.2 Elasticsearch 索引设计
|
||
|
||
#### 3.2.1 `notifications` 索引 mapping
|
||
|
||
```json
|
||
{
|
||
"mappings": {
|
||
"properties": {
|
||
"id": { "type": "keyword" },
|
||
"user_id": { "type": "keyword" },
|
||
"type": { "type": "keyword" },
|
||
"title": {
|
||
"type": "text",
|
||
"analyzer": "ik_max_word",
|
||
"search_analyzer": "ik_smart"
|
||
},
|
||
"content": {
|
||
"type": "text",
|
||
"analyzer": "ik_max_word",
|
||
"search_analyzer": "ik_smart"
|
||
},
|
||
"channel": { "type": "keyword" },
|
||
"status": { "type": "keyword" },
|
||
"group_id": { "type": "keyword" },
|
||
"related_entity_type": { "type": "keyword" },
|
||
"related_entity_id": { "type": "keyword" },
|
||
"sender_id": { "type": "keyword" },
|
||
"is_read": { "type": "boolean" },
|
||
"created_at": { "type": "date" },
|
||
"read_at": { "type": "date" }
|
||
}
|
||
},
|
||
"settings": {
|
||
"number_of_shards": 1,
|
||
"number_of_replicas": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3.2.2 索引管理
|
||
|
||
- **索引创建**:服务启动 `ensureIndex('notifications', mapping)` 幂等
|
||
- **数据同步**:业务写入 MySQL 时通过 Outbox 事件触发 ES 索引更新
|
||
- **查询降级**:ES 不可用时降级到 MySQL LIKE 查询
|
||
|
||
### 3.3 Redis 数据结构
|
||
|
||
#### 3.3.1 幂等去重
|
||
|
||
```
|
||
KEY: msg:processed:{event_id}
|
||
VALUE: 1
|
||
TTL: 7 天
|
||
```
|
||
|
||
使用 `SETNX` 命令原子性去重,返回 0 表示已处理。
|
||
|
||
#### 3.3.2 已读状态位图
|
||
|
||
```
|
||
KEY: msg:read:{user_id}:{yyyyMM}
|
||
VALUE: Bitmap
|
||
```
|
||
|
||
按月分桶存储已读状态,第 N 位表示该月第 N 条通知的已读状态。优势:
|
||
|
||
- 1 万条通知仅占 ~1.25KB 内存
|
||
- 支持按月快速统计已读数量
|
||
- 跨端同步(多端共享同一 bitmap)
|
||
|
||
#### 3.3.3 在线用户状态缓存
|
||
|
||
```
|
||
KEY: msg:online:{user_id}
|
||
VALUE: { device: "web"/"app", last_active: timestamp }
|
||
TTL: 5 分钟(push-gateway 心跳更新)
|
||
```
|
||
|
||
> 由 push-gateway 维护,msg 查询以决定是否走 push 渠道。
|
||
|
||
#### 3.3.4 通知频率限流
|
||
|
||
```
|
||
KEY: msg:ratelimit:{user_id}:{type}:{yyyyMMddHH}
|
||
VALUE: count
|
||
TTL: 1 小时
|
||
```
|
||
|
||
按 NotificationPreference.frequency_limit 限制。
|
||
|
||
### 3.4 读写分离策略(CQRS)
|
||
|
||
| 操作 | 路径 | 说明 |
|
||
| ------------------- | ------------------------------------- | ------------------- |
|
||
| 写(发送/标记已读) | Service → Repository → MySQL + Outbox | 单一写模型 |
|
||
| 读 - 列表查询 | Service → Repository → MySQL(默认) | 包含分页 |
|
||
| 读 - 全文检索 | Service → ES Client → ES | 检索专用(P5 启用) |
|
||
| 读 - 已读状态 | Service → Redis Bitmap | 跨端共享 |
|
||
| 读 - 未读计数 | Service → Redis Bitmap 或 MySQL COUNT | 高频查询走 Redis |
|
||
|
||
---
|
||
|
||
## 4. API 设计
|
||
|
||
### 4.1 REST API(当前 P5 实现 + 待补齐)
|
||
|
||
| Method | Path | 权限 | 请求体 | 响应 | 说明 |
|
||
| ------ | ---------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------- | ----------------------------- |
|
||
| POST | /notifications/send | MSG_NOTIFICATION_SEND | `{userId, type, title, content, channel, metadata?, relatedEntityType?, relatedEntityId?, groupId?}` | `{id, status}` | 单条发送 |
|
||
| POST | /notifications/batch | MSG_NOTIFICATION_SEND | `{items[]: SendItem, groupId?}` | `{ids[], failed[]}` | 批量发送(**改批量 INSERT**) |
|
||
| GET | /notifications/user/:userId | MSG_NOTIFICATION_READ | `?onlyUnread=&type=&page=1&pageSize=20` | `{items[], total}` | 用户通知列表 |
|
||
| GET | /notifications/user/:userId/page | MSG_NOTIFICATION_READ | `?cursor=&pageSize=20` | `{items[], nextCursor}` | 游标分页(**新增**) |
|
||
| GET | /notifications/user/:userId/unread-count | MSG_NOTIFICATION_READ | — | `{count}` | 未读数(Redis) |
|
||
| PUT | /notifications/:id/read | MSG_NOTIFICATION_READ | — | `{success: true}` | 标记已读 |
|
||
| PUT | /notifications/batch/read | MSG_NOTIFICATION_READ | `{ids[]}` | `{success: true}` | 批量标记已读(**新增**) |
|
||
| PUT | /notifications/read-all | MSG_NOTIFICATION_READ | `{userId, before?: timestamp}` | `{updated: N}` | 全部已读(**新增**) |
|
||
| GET | /notifications/search | MSG_NOTIFICATION_READ | `?q=&userId=&type=&page=&pageSize=` | `{items[], total}` | ES 全文检索 |
|
||
| DELETE | /notifications/:id | MSG_NOTIFICATION_MANAGE | — | `{success: true}` | 删除(管理员) |
|
||
| POST | /notifications/recall | MSG_NOTIFICATION_MANAGE | `{groupId, reason?}` | `{recalled: N}` | 撤回广播(**新增**) |
|
||
| GET | /preferences/user/:userId | MSG_NOTIFICATION_READ | — | `{preferences[]}` | 用户偏好 |
|
||
| PUT | /preferences/user/:userId | MSG_NOTIFICATION_MANAGE | `{preferences[]}` | `{success: true}` | 更新偏好 |
|
||
| GET | /templates | MSG_NOTIFICATION_MANAGE | `?type=&status=` | `{items[]}` | 模板列表 |
|
||
| POST | /templates | MSG_NOTIFICATION_MANAGE | `{code, type, titleTemplate, contentTemplate, defaultChannels, variables}` | `{id}` | 创建模板 |
|
||
| PUT | /templates/:id | MSG_NOTIFICATION_MANAGE | `{...}` | `{id}` | 更新模板 |
|
||
| DELETE | /templates/:id | MSG_NOTIFICATION_MANAGE | — | `{success: true}` | 删除模板 |
|
||
| POST | /internal/reindex | MSG_NOTIFICATION_MANAGE | `?from=&to=` | `{taskId}` | ES 重建索引(**预留**) |
|
||
|
||
### 4.2 gRPC API(待实现,对齐 004 §4.2 P5 启用)
|
||
|
||
#### 4.2.1 NotificationService
|
||
|
||
| RPC | 请求 | 响应 | 说明 |
|
||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ---------- |
|
||
| SendNotification | `SendNotificationRequest{user_id, type, title, content, channel, metadata?, related_entity_type?, related_entity_id?}` | `Notification` | |
|
||
| BatchSendNotification | `BatchSendNotificationRequest{items[], group_id?}` | `BatchSendNotificationResponse{ids[], failed[]}` | **新增** |
|
||
| ListNotifications | `ListNotificationsRequest{user_id, only_unread?, type?, page?, page_size?}` | `ListNotificationsResponse{notifications[], total}` | **补分页** |
|
||
| GetUnreadCount | `GetUnreadCountRequest{user_id}` | `GetUnreadCountResponse{count}` | **新增** |
|
||
| MarkAsRead | `MarkAsReadRequest{id, user_id}` | `Empty` | |
|
||
| BatchMarkAsRead | `BatchMarkAsReadRequest{ids[], user_id}` | `Empty` | **新增** |
|
||
| MarkAllAsRead | `MarkAllAsReadRequest{user_id, before?}` | `Empty` | **新增** |
|
||
| SearchNotifications | `SearchNotificationsRequest{user_id, query, type?, page?, page_size?}` | `SearchNotificationsResponse{notifications[], total}` | **补分页** |
|
||
| RecallNotification | `RecallNotificationRequest{group_id, reason?}` | `RecallNotificationResponse{recalled_count}` | **新增** |
|
||
|
||
#### 4.2.2 NotificationPreferenceService(**新增**)
|
||
|
||
| RPC | 请求 | 响应 | 说明 |
|
||
| ----------------- | -------------------------------------------------- | --------------------------------------- | ---- |
|
||
| GetPreferences | `GetPreferencesRequest{user_id}` | `GetPreferencesResponse{preferences[]}` | |
|
||
| UpdatePreferences | `UpdatePreferencesRequest{user_id, preferences[]}` | `Empty` | |
|
||
|
||
#### 4.2.3 NotificationTemplateService(**新增**)
|
||
|
||
| RPC | 请求 | 响应 | 说明 |
|
||
| -------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------ |
|
||
| CreateTemplate | `CreateTemplateRequest{code, type, title_template, content_template, default_channels, variables}` | `NotificationTemplate` | |
|
||
| GetTemplate | `GetTemplateRequest{id}` | `NotificationTemplate` | |
|
||
| ListTemplates | `ListTemplatesRequest{type?, status?}` | `ListTemplatesResponse{templates[]}` | |
|
||
| UpdateTemplate | `UpdateTemplateRequest{id, ...}` | `NotificationTemplate` | |
|
||
| DeleteTemplate | `DeleteTemplateRequest{id}` | `Empty` | |
|
||
| RenderTemplate | `RenderTemplateRequest{code, variables, locale?}` | `RenderedNotification{title, content}` | **新增**:渲染模板 |
|
||
|
||
### 4.3 错误响应结构
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"error": {
|
||
"code": "MSG_VALIDATION_ERROR",
|
||
"message": "User ID is required",
|
||
"details": { "field": "userId" }
|
||
},
|
||
"requestId": "req_xxx",
|
||
"timestamp": 1736000000000
|
||
}
|
||
```
|
||
|
||
### 4.4 分页统一规范
|
||
|
||
REST 使用 offset 模式(page/pageSize),gRPC 同时支持 offset 与 cursor 模式。
|
||
|
||
---
|
||
|
||
## 5. 事件设计
|
||
|
||
### 5.1 消费事件清单(**核心 · 5+ 类事件**)
|
||
|
||
| Topic | 来源服务 | 处理逻辑 | 触发通知 | 幂等键 |
|
||
| ----------------------------------- | -------- | ---------------------------- | -------------------- | ---------- |
|
||
| `edu.identity.user.created` | iam | 发送欢迎通知 | welcome 通知 | `event_id` |
|
||
| `edu.identity.user.updated` | iam | 用户信息变更,可选清理缓存 | — | `event_id` |
|
||
| `edu.identity.user.deleted` | iam | 用户删除,归档该用户通知 | — | `event_id` |
|
||
| `edu.identity.user.role_changed` | iam | 角色变更,发送权限变更通知 | system 通知 | `event_id` |
|
||
| `edu.identity.role.created` | iam | 角色新增(管理通知) | system 通知 | `event_id` |
|
||
| `edu.identity.role.updated` | iam | 角色权限变更,通知受影响用户 | system 通知 | `event_id` |
|
||
| `edu.teaching.exam.published` | core-edu | 推送考试通知给班级所有学生 | exam 通知(fan-out) | `event_id` |
|
||
| `edu.teaching.assignment.submitted` | core-edu | 通知教师有学生提交作业 | homework 通知 | `event_id` |
|
||
| `edu.teaching.assignment.graded` | core-edu | 通知学生作业已批改 | grade 通知 | `event_id` |
|
||
| `edu.teaching.grade.recorded` | core-edu | 通知学生成绩已录入 | grade 通知 | `event_id` |
|
||
| `edu.teaching.attendance.recorded` | core-edu | 出勤异常通知家长 | attendance 通知 | `event_id` |
|
||
| `edu.insight.mastery.updated` | data-ana | 学情掌握度下降,触发预警 | mastery_alert 通知 | `event_id` |
|
||
|
||
### 5.2 发布事件清单
|
||
|
||
| Event Type | Topic | 触发时机 | 消费者 |
|
||
| --------------------------- | --------------------------- | -------------- | ------------------------------ |
|
||
| `edu.notification.sent` | `edu.notification.sent` | 通知发送成功 | push-gateway、data-ana(统计) |
|
||
| `edu.notification.read` | `edu.notification.read` | 通知被标记已读 | data-ana(统计) |
|
||
| `edu.notification.recalled` | `edu.notification.recalled` | 通知被撤回 | push-gateway(删除已推送消息) |
|
||
| `edu.notification.failed` | `edu.notification.failed` | 通知投递失败 | data-ana(监控告警) |
|
||
|
||
### 5.3 TOPIC_MAP 路由表(msg 内部)
|
||
|
||
```typescript
|
||
// 发布事件路由
|
||
const PRODUCER_TOPIC_MAP: Record<string, string> = {
|
||
NotificationSent: "edu.notification.sent",
|
||
NotificationRead: "edu.notification.read",
|
||
NotificationRecalled: "edu.notification.recalled",
|
||
NotificationFailed: "edu.notification.failed",
|
||
};
|
||
|
||
// 消费事件订阅
|
||
const CONSUMER_TOPICS = [
|
||
"edu.identity.user.created",
|
||
"edu.identity.user.updated",
|
||
"edu.identity.user.deleted",
|
||
"edu.identity.user.role_changed",
|
||
"edu.identity.role.created",
|
||
"edu.identity.role.updated",
|
||
"edu.teaching.exam.published",
|
||
"edu.teaching.assignment.submitted",
|
||
"edu.teaching.assignment.graded",
|
||
"edu.teaching.grade.recorded",
|
||
"edu.teaching.attendance.recorded",
|
||
"edu.insight.mastery.updated",
|
||
];
|
||
```
|
||
|
||
### 5.4 Outbox Publisher 模式
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant TRIG as Kafka Trigger (core-edu/iam/data-ana)
|
||
participant CONS as Msg Kafka Consumer
|
||
participant IDEM as Redis SETNX (幂等)
|
||
participant SVC as NotificationService
|
||
participant DB as MySQL (notifications + outbox)
|
||
participant DISP as ChannelDispatcher
|
||
participant PUB as Outbox Publisher
|
||
participant OUT as Kafka (edu.notification.*)
|
||
participant PUSH as Push Gateway
|
||
|
||
TRIG-->>CONS: consume edu.teaching.exam.published
|
||
CONS->>IDEM: SETNX msg:processed:{event_id}
|
||
alt 已处理(返回 0)
|
||
IDEM-->>CONS: skip
|
||
else 未处理(返回 1)
|
||
CONS->>SVC: createNotificationFromEvent(event)
|
||
SVC->>DB: BEGIN TX
|
||
SVC->>DB: INSERT INTO msg_notifications
|
||
SVC->>DB: INSERT INTO msg_outbox_events (NotificationSent)
|
||
SVC->>DB: COMMIT
|
||
SVC->>DISP: dispatch(notification, userPreference)
|
||
par 多渠道并行
|
||
DISP->>DB: in_app 已持久化
|
||
DISP->>PUSH: gRPC push (在线用户)
|
||
DISP->>DISP: email/sms/wechat 异步队列
|
||
end
|
||
end
|
||
|
||
loop 每 1s 轮询
|
||
PUB->>DB: SELECT PENDING events
|
||
PUB->>OUT: produce(edu.notification.sent)
|
||
PUB->>DB: UPDATE status=SENT
|
||
end
|
||
```
|
||
|
||
### 5.5 幂等去重设计
|
||
|
||
#### 双层去重
|
||
|
||
1. **消费层幂等**(Redis SETNX):
|
||
- 基于 `event_id` 字段去重
|
||
- TTL 7 天(覆盖重试窗口)
|
||
- 防止 Kafka at-least-once 投递的重复消费
|
||
|
||
2. **业务层幂等**(DB UNIQUE INDEX):
|
||
- `msg_notifications.event_id` UNIQUE INDEX
|
||
- INSERT 冲突时跳过(INSERT IGNORE / ON DUPLICATE KEY UPDATE)
|
||
- 防止 Redis 故障时重复创建
|
||
|
||
#### Producer 幂等
|
||
|
||
- Kafka producer `idempotent=true` + `transactionalId=msg-producer`
|
||
- transactionalId 保证跨事务 Exactly-Once 语义
|
||
|
||
---
|
||
|
||
## 6. 横切关注点对齐清单
|
||
|
||
### 6.1 权限装饰器清单
|
||
|
||
| Controller 方法 | 权限点 | 说明 |
|
||
| --------------------------------------- | ----------------------- | -------------- |
|
||
| NotificationsController.send | MSG_NOTIFICATION_SEND | 单条发送 |
|
||
| NotificationsController.batch | MSG_NOTIFICATION_SEND | 批量发送 |
|
||
| NotificationsController.listByUser | MSG_NOTIFICATION_READ | 列表 |
|
||
| NotificationsController.listByUserPage | MSG_NOTIFICATION_READ | 游标分页 |
|
||
| NotificationsController.getUnreadCount | MSG_NOTIFICATION_READ | 未读数 |
|
||
| NotificationsController.markAsRead | MSG_NOTIFICATION_READ | 标记已读 |
|
||
| NotificationsController.batchMarkAsRead | MSG_NOTIFICATION_READ | 批量已读 |
|
||
| NotificationsController.markAllAsRead | MSG_NOTIFICATION_READ | 全部已读 |
|
||
| NotificationsController.search | MSG_NOTIFICATION_READ | 检索 |
|
||
| NotificationsController.recall | MSG_NOTIFICATION_MANAGE | 撤回(管理员) |
|
||
| NotificationsController.remove | MSG_NOTIFICATION_MANAGE | 删除 |
|
||
| PreferencesController.get | MSG_NOTIFICATION_READ | 偏好查询 |
|
||
| PreferencesController.update | MSG_NOTIFICATION_MANAGE | 偏好更新 |
|
||
| TemplatesController.* | MSG_NOTIFICATION_MANAGE | 模板 CRUD |
|
||
| NotificationsController.reindex | MSG_NOTIFICATION_MANAGE | 重建索引 |
|
||
| gRPC SendNotification | MSG_NOTIFICATION_SEND | BFF/内部调用 |
|
||
| gRPC BatchMarkAsRead | MSG_NOTIFICATION_READ | BFF 调用 |
|
||
|
||
> **权限点扩展**:从当前 3 个扩展为 3 个不变(SEND/READ/MANAGE),但覆盖更多端点。
|
||
|
||
### 6.2 错误码清单
|
||
|
||
| 错误码 | HTTP | 触发条件 |
|
||
| ---------------------------- | ---- | ----------------------------------------- |
|
||
| MSG_VALIDATION_ERROR | 400 | Zod 校验失败 / 通知类型非法 |
|
||
| MSG_NOT_FOUND | 404 | 通知/偏好/模板不存在 |
|
||
| MSG_PERMISSION_DENIED | 403 | 权限不足 / 跨用户访问 |
|
||
| MSG_CONFLICT | 409 | 重复发送(event_id 冲突)/ 状态机非法转换 |
|
||
| MSG_BUSINESS_ERROR | 422 | 业务规则违反(如撤回已撤回的通知) |
|
||
| MSG_DATABASE_ERROR | 500 | Drizzle 操作异常 |
|
||
| MSG_INTERNAL_ERROR | 500 | 未知异常 |
|
||
| MSG_ES_UNAVAILABLE | 503 | ES 不可用且无降级路径 |
|
||
| MSG_REDIS_UNAVAILABLE | 503 | Redis 不可用且无降级路径 |
|
||
| MSG_PUSH_GATEWAY_UNAVAILABLE | 503 | Push Gateway 不可用 |
|
||
| MSG_TEMPLATE_NOT_FOUND | 404 | 模板不存在 |
|
||
| MSG_TEMPLATE_RENDER_ERROR | 422 | 模板渲染失败(变量缺失) |
|
||
| MSG_RATE_LIMIT_EXCEEDED | 429 | 通知频率超限 |
|
||
|
||
### 6.3 Logger 配置
|
||
|
||
- **库**:pino
|
||
- **日志级别**:DEV_MODE `debug`,生产 `info`
|
||
- **结构化字段**:`requestId` / `userId` / `notificationId` / `eventType` / `channel` / `durationMs` / `eventId`
|
||
- **敏感字段脱敏**:通知 content 在生产环境日志中截断到 100 字符
|
||
|
||
### 6.4 Metrics 指标清单
|
||
|
||
| 指标名 | 类型 | 标签 | 说明 |
|
||
| ---------------------------------------- | --------- | ------------------------ | ------------------ |
|
||
| `msg_http_requests_total` | Counter | method/route/status_code | HTTP 请求总数 |
|
||
| `msg_http_request_duration_seconds` | Histogram | method/route | HTTP 请求延迟 |
|
||
| `msg_grpc_requests_total` | Counter | rpc_method/status | gRPC 调用 |
|
||
| `msg_notification_sent_total` | Counter | type/channel/status | 通知发送总数 |
|
||
| `msg_notification_send_duration_seconds` | Histogram | type/channel | 通知发送延迟 |
|
||
| `msg_notification_read_total` | Counter | type | 通知已读总数 |
|
||
| `msg_notification_failed_total` | Counter | type/channel/error_code | 通知失败总数 |
|
||
| `msg_channel_dispatch_duration_seconds` | Histogram | channel | 渠道分发延迟 |
|
||
| `msg_kafka_consumer_lag` | Gauge | topic | Kafka 消费滞后 |
|
||
| `msg_kafka_consumer_processed_total` | Counter | topic/status | Kafka 消费总数 |
|
||
| `msg_idempotent_duplicate_total` | Counter | topic | 幂等去重命中次数 |
|
||
| `msg_outbox_pending_count` | Gauge | — | Outbox 待投递数 |
|
||
| `msg_push_gateway_call_total` | Counter | status | Push Gateway 调用 |
|
||
| `msg_push_gateway_call_duration_seconds` | Histogram | — | Push Gateway 延迟 |
|
||
| `msg_unread_count` | Gauge | user_id | 用户未读数(采样) |
|
||
|
||
### 6.5 Tracer 配置
|
||
|
||
- **库**:@opentelemetry/sdk-node + auto-instrumentations
|
||
- **采样率**:DEV_MODE 100%,生产 10%
|
||
- **服务名**:`msg`
|
||
- **跨服务传播**:消费 Kafka 时从 header 提取 traceparent,发送 Push Gateway gRPC 时注入 traceparent
|
||
|
||
### 6.6 健康检查
|
||
|
||
#### /healthz(liveness)
|
||
|
||
```json
|
||
{ "status": "ok", "service": "msg", "timestamp": 1736000000000 }
|
||
```
|
||
|
||
#### /readyz(readiness · 多依赖检查)
|
||
|
||
```json
|
||
{
|
||
"status": "ok" | "degraded" | "down",
|
||
"checks": {
|
||
"database": { "status": "ok", "latency_ms": 5 },
|
||
"elasticsearch": { "status": "ok", "latency_ms": 8 },
|
||
"redis": { "status": "ok", "latency_ms": 2 },
|
||
"kafka_producer": { "status": "ok" },
|
||
"kafka_consumer": { "status": "ok", "lag": 0 },
|
||
"push_gateway": { "status": "ok", "latency_ms": 15 }
|
||
}
|
||
}
|
||
```
|
||
|
||
**判定规则**:
|
||
|
||
- DB 不可用 → `status: down`
|
||
- Redis 不可用 → `status: degraded`(降级到 DB 唯一索引去重)
|
||
- ES 不可用 → `status: degraded`(降级到 MySQL LIKE 查询)
|
||
- Kafka 不可用 → `status: degraded`(无法消费事件触发通知)
|
||
- Push Gateway 不可用 → `status: ok`(push 仅是渠道之一,不影响整体可用性)
|
||
|
||
### 6.7 优雅关闭顺序
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant SIG as SIGTERM
|
||
participant APP as NestJS App
|
||
participant HTTP as HTTP Server
|
||
participant CONS as Kafka Consumer
|
||
participant PUB as Outbox Publisher
|
||
participant DISP as ChannelDispatcher
|
||
participant REDIS as Redis Client
|
||
participant ES as ES Client
|
||
participant DB as Drizzle Pool
|
||
participant TR as Tracer
|
||
|
||
SIG->>APP: onApplicationShutdown
|
||
APP->>HTTP: server.close() (拒绝新请求)
|
||
APP->>CONS: consumer.stop() (停止消费)
|
||
APP->>PUB: publisher.stop() (完成当前批次)
|
||
APP->>DISP: dispatcher.stop() (完成在途渠道分发)
|
||
APP->>REDIS: redis.quit()
|
||
APP->>ES: esClient.close()
|
||
APP->>DB: pool.end()
|
||
APP->>TR: tracer.shutdown()
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 与其他模块的交互点
|
||
|
||
### 7.1 跨模块交互矩阵
|
||
|
||
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 | 状态 |
|
||
| ------ | -------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------- | ---------------------------- | ------------------ |
|
||
| 被调用 | teacher-bff / student-bff / parent-bff | HTTP REST | GET /notifications/user/:userId 等 | BFF 聚合查询通知 | ✅ 已实现 |
|
||
| 被调用 | api-gateway | HTTP REST | POST /notifications/send 等 | REST 转发 | ✅ 已实现 |
|
||
| 调用 | push-gateway | **gRPC**(**待实现,当前 fetch POST /internal/push 降级**) | PushService.Push | 实时推送通知 | P5 待实现 gRPC |
|
||
| 消费 | iam | Kafka | `edu.identity.user.{created,updated,deleted,role_changed}` | 触发用户通知 | P5 待实现 consumer |
|
||
| 消费 | iam | Kafka | `edu.identity.role.{created,updated}` | 触发角色变更通知 | P5 待实现 |
|
||
| 消费 | core-edu | Kafka | `edu.teaching.exam.published` | 考试通知 fan-out | P5 待实现 |
|
||
| 消费 | core-edu | Kafka | `edu.teaching.assignment.submitted` | 通知教师 | P5 待实现 |
|
||
| 消费 | core-edu | Kafka | `edu.teaching.assignment.graded` | 通知学生作业已批改 | P5 待实现 |
|
||
| 消费 | core-edu | Kafka | `edu.teaching.grade.recorded` | 通知学生成绩 | P5 待实现 |
|
||
| 消费 | core-edu | Kafka | `edu.teaching.attendance.recorded` | 通知家长出勤 | P5 待实现 |
|
||
| 消费 | data-ana | Kafka | `edu.insight.mastery.updated` | 学情预警通知 | P5 待实现 |
|
||
| 发布 | — | Kafka | `edu.notification.*`(4 类事件,见 §5.2) | 通知 push-gateway / data-ana | P5 待实现 |
|
||
|
||
### 7.2 跨模块契约一致性提请(coord 仲裁)
|
||
|
||
| # | 提请内容 | 阻塞性 | 备注 |
|
||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | --------------------------------- |
|
||
| P1 | msg.proto 需补 BatchSendNotification / GetUnreadCount / BatchMarkAsRead / MarkAllAsRead / RecallNotification RPC | 🔴 阻塞 gRPC 完整契约 | |
|
||
| P2 | msg.proto 需补 NotificationPreferenceService + NotificationTemplateService | 🔴 阻塞偏好与模板功能 | |
|
||
| P3 | msg.proto 需补 NotificationPreference / NotificationTemplate / RenderedNotification message | 🔴 阻塞 gRPC | |
|
||
| P4 | msg.proto Notification message 需补 metadata / related_entity_type / related_entity_id / group_id / sender_id / template_id / event_id 字段 | 🔴 阻塞 | |
|
||
| P5 | msg.proto ListNotificationsRequest / SearchNotificationsRequest 需补分页字段 | 🟡 | |
|
||
| P6 | events.proto 需追加 UserEvent / RoleEvent message(iam 发布事件契约) | 🔴 阻塞 msg 消费 iam 事件 | **coord 已在审查报告中识别** |
|
||
| P7 | events.proto 需追加 NotificationEvent message(msg 发布事件契约) | 🔴 阻塞 push-gateway 消费 msg 事件 | |
|
||
| P8 | events.proto 需追加 MasteryEvent message(data-ana 发布事件契约) | 🟡 阻塞 msg 消费 data-ana 事件 | |
|
||
| P9 | events.proto ExamEvent / HomeworkEvent / GradeEvent 需补 `class_id` / `student_ids[]` 字段,msg fan-out 通知需要 | 🔴 阻塞广播 | |
|
||
| P10 | Push Gateway 与 Msg 调用方向澄清:004 §4.1 写"PushGW→Msg",实际是 Msg→PushGW(msg 调 push-gateway 推送) | 🟡 文档表述歧义 | **建议 coord 修正 004 §4.1 表述** |
|
||
| P11 | core-edu 事件 topic 命名统一:004 §7.2 用 `edu.teaching.*` 新约定,events.proto 注释用 `edu.exam.events` 旧约定 | 🟡 文档不一致 | **coord 已仲裁采用新约定** |
|
||
| P12 | DB 连接模式统一:const db vs getDb(),建议统一为 getDb()(对齐 classes 黄金模板) | 🟡 | 与 content P6 一致 |
|
||
| P13 | ID 策略统一:cuid2 vs randomUUID,建议统一为 cuid2 | 🟡 | 与 content P7 一致 |
|
||
|
||
### 7.3 跨服务调用方向澄清(Push Gateway)
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph MsgSvc[msg 服务]
|
||
SVC[NotificationService]
|
||
CLIENT[PushGatewayClient]
|
||
end
|
||
subgraph PushGW[push-gateway 服务]
|
||
PUSH[PushService]
|
||
WS[WebSocket Connections]
|
||
end
|
||
subgraph User[用户端]
|
||
BROWSER[浏览器/App]
|
||
end
|
||
|
||
SVC -->|gRPC Push| CLIENT
|
||
CLIENT -.->|gRPC| PUSH
|
||
PUSH --> WS
|
||
WS -.->|WebSocket| BROWSER
|
||
```
|
||
|
||
**澄清**:
|
||
|
||
- msg 调用 push-gateway 的 gRPC `PushService.Push` 方法投递实时通知
|
||
- push-gateway 维护 WebSocket 连接池,将通知推送到对应用户的浏览器/App
|
||
- push-gateway 水平扩展时通过 Redis Pub/Sub 跨实例广播
|
||
- 004 §4.1 表述"PushGW→Msg 推送通道建立"应理解为"push-gateway 暴露 PushService RPC 供 msg 调用",建议 coord 修正表述
|
||
|
||
---
|
||
|
||
## 8. 演进路线
|
||
|
||
### 8.1 P5 阶段(当前)
|
||
|
||
**目标**:通知 CRUD + 多渠道分发 + Kafka 消费 + Push Gateway 推送 + ES 检索
|
||
|
||
**交付物**:
|
||
|
||
- msg_notifications / msg_notification_preferences 表字段扩展
|
||
- msg_notification_templates 表(新增)
|
||
- msg_outbox_events 表 + Outbox Publisher worker
|
||
- processed_events 表(或 Redis SETNX 幂等)
|
||
- Kafka consumer(消费 12 类事件)
|
||
- ChannelDispatcher 多渠道抽象(in_app/email/sms/wechat/push)
|
||
- PushGatewayClient gRPC 调用
|
||
- ES 索引 mapping + ensureIndex + 全文检索 API
|
||
- ZodError 在 GlobalErrorFilter 特殊处理
|
||
- 测试覆盖率 ≥ 80%
|
||
|
||
### 8.2 P6+ 长远演进(架构预留点)
|
||
|
||
| 演进方向 | 当前架构预留 | 触发条件 |
|
||
| -------------------- | ----------------------------------------------------------------- | -------------------- |
|
||
| **富媒体消息** | metadata jsonb 字段(attachments/links/actions) | 教师发送图文通知需求 |
|
||
| **消息撤回与编辑** | Notification.status=recalled 状态机 + RecallNotification RPC | 教师误发广播需求 |
|
||
| **群组消息** | group_id 字段 + fan-out 算法可扩展(班级群/学科群/年级群) | 群组协作需求 |
|
||
| **跨端已读同步** | Redis Bitmap 跨端共享 + last_read_at 时间戳 | 多端登录场景 |
|
||
| **AI 智能通知** | 接收 data-ana MasteryUpdated 事件触发预警通知 | AI 学情分析上线后 |
|
||
| **通知疲劳控制** | NotificationPreference.frequency_limit + Redis 限流计数 | 高频通知场景 |
|
||
| **消息合规审计** | notification_audit_log 表预留(操作者/时间/操作类型) | 教育合规要求 |
|
||
| **推送通道扩展** | NotificationChannel 抽象 + 渠道配置表(小程序/企业微信/钉钉) | 多端推送需求 |
|
||
| **消息搜索与归档** | ES alias 切换 + 冷热分离(按月归档) | 历史消息检索需求 |
|
||
| **多语言通知** | NotificationTemplate.locale 字段 + RenderTemplate RPC locale 参数 | 国际化场景 |
|
||
| **通知优先级** | metadata.priority 字段 + 优先级排序 | 紧急通知场景 |
|
||
| **通知模板版本管理** | NotificationTemplate.status 状态机 + version 字段 | 模板迭代需求 |
|
||
| **通知 A/B 测试** | metadata.variant 字段预留 + 统计打开率 | 通知效果优化 |
|
||
|
||
### 8.3 与黄金模板对齐
|
||
|
||
msg 在 P5 完成后,应将以下模式回写到 classes 黄金模板(known-issues §2.2 提及"长连接模式回写"):
|
||
|
||
- Kafka consumer + Redis SETNX 幂等去重模式
|
||
- ChannelDispatcher 多渠道分发模式
|
||
- Outbox 表 schema + Publisher worker 模式(与 content 共用模式)
|
||
- 多依赖 /readyz 检查模式(DB/ES/Redis/Kafka/PushGateway)
|
||
|
||
---
|
||
|
||
## 9. 风险与假设
|
||
|
||
### 9.1 技术风险
|
||
|
||
| # | 风险 | 影响 | 缓解措施 |
|
||
| --- | ----------------------------------------------- | --------------------- | ----------------------------------------------------------------------- |
|
||
| R1 | Kafka 消费滞后导致通知延迟 | 用户收不到及时通知 | 监控 consumer lag + 报警阈值 + 水平扩容 consumer |
|
||
| R2 | Redis 故障导致幂等去重失效 | 重复消费创建重复通知 | DB UNIQUE INDEX 双层保护(event_id 冲突时跳过) |
|
||
| R3 | Push Gateway 不可用导致实时推送失败 | 用户收不到实时通知 | 降级到 in_app 站内信 + 重试机制 |
|
||
| R4 | 高并发广播(全校 1 万学生)性能瓶颈 | DB 写入慢 + ES 索引慢 | BatchSendNotification 批量 INSERT + ES bulk indexing + fan-out 异步队列 |
|
||
| R5 | 多渠道发送部分失败 | 通知状态不一致 | ChannelDispatcher 返回 SendResult,部分失败标记为 partial_sent |
|
||
| R6 | Kafka 消息丢失(at-least-once 不可靠时) | 通知遗漏 | Outbox 保证业务事务 + Kafka acks=all + 监控 consumer lag |
|
||
| R7 | 通知频率过高打扰用户 | 用户疲劳 / 投诉 | NotificationPreference.frequency_limit + Redis 限流 + 默认 quiet_hours |
|
||
| R8 | 通知内容敏感信息泄露 | 合规风险 | Logger 脱敏 + content 字段不写入 ES 索引明文(可选加密) |
|
||
| R9 | ES 索引重建期间检索不可用 | 检索中断 | alias 切换蓝绿模式 |
|
||
| R10 | Kafka 事件 schema 演化(events.proto 增删字段) | 消费失败 | proto backward compatible 设计 + schema registry(未来) |
|
||
|
||
### 9.2 假设
|
||
|
||
| # | 假设 | 依赖 | Fallback |
|
||
| --- | ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------- |
|
||
| A1 | iam 会发布 `edu.identity.user.{created,updated,deleted,role_changed}` 事件 | ai06 设计确认 | msg 不消费此类事件,仅靠管理端手动创建通知 |
|
||
| A2 | iam 会发布 `edu.identity.role.{created,updated}` 事件 | ai06 设计确认 | msg 不消费此类事件 |
|
||
| A3 | core-edu 会发布 `edu.teaching.*` 5 类事件 | ai08 设计确认 | msg 不消费此类事件 |
|
||
| A4 | data-ana 会发布 `edu.insight.mastery.updated` 事件 | ai11 设计确认 | msg 不消费预警通知 |
|
||
| A5 | push-gateway 提供 gRPC `PushService.Push` 方法 | ai02 设计确认 | 降级到 fetch POST /internal/push(当前实现) |
|
||
| A6 | events.proto 会补充 UserEvent / RoleEvent / NotificationEvent / MasteryEvent message | coord 修改 proto | msg 消费时使用通用 JSON payload 解析 |
|
||
| A7 | Redis 集群可用 | infra 部署 | 降级到 DB 唯一索引去重(性能下降) |
|
||
| A8 | ES 8.x 可用 | infra 部署 | ES_URL 未配置时 esClient=null,降级到 MySQL LIKE 查询 |
|
||
| A9 | Kafka 集群可用 | infra 部署 | Outbox Publisher 失败时重试,最终一致 |
|
||
|
||
### 9.3 待 coord 仲裁项
|
||
|
||
1. **msg Outbox 启用时机**:P5(按 004 §12.2 强制条款,msg 发事件必须 Outbox,建议 P5 启用)
|
||
2. **msg Redis 引入时机**:P5(pending-features P5 要求 event_id 去重,建议引入 Redis)
|
||
3. **msg.proto proto 包名**:保持 `next_edu_cloud.msg.v1`(已仲裁)
|
||
4. **Push Gateway 调用方向表述**:004 §4.1 表述歧义,建议修正为"msg → push-gateway (gRPC)"
|
||
5. **events.proto 补充 message 优先级**:UserEvent/RoleEvent/NotificationEvent/MasteryEvent 应在 P5 之前补齐
|
||
6. **core-edu 事件 topic 命名**:已仲裁采用 `edu.teaching.*` 新约定,events.proto 注释需同步更新
|
||
7. **DB 连接模式统一**:const db vs getDb(),建议统一为 getDb()
|
||
8. **ID 策略统一**:cuid2 vs randomUUID,建议统一为 cuid2
|
||
9. **NotificationEvent 是否需要回执**:msg 发 NotificationSent 事件后,push-gateway 是否需要回执确认?
|
||
|
||
---
|
||
|
||
## 10. 实施计划(建议)
|
||
|
||
### 10.1 P5 阶段任务拆分
|
||
|
||
| # | 任务 | 优先级 | 预估文件改动 |
|
||
| --- | ------------------------------------------------------------------------------------------------------ | ------ | ----------------------------------- |
|
||
| T1 | msg_notifications 表新增 status/metadata/related_entity_*/group_id/sender_id/template_id/event_id 字段 | P0 | schema 迁移 |
|
||
| T2 | msg_notification_preferences 表补齐时间戳 + 新增 frequency_limit/quiet_hours 字段 | P0 | schema 迁移 |
|
||
| T3 | 新建 msg_notification_templates 表 | P0 | schema 迁移 |
|
||
| T4 | 新建 msg_outbox_events 表 + Outbox Publisher worker | P0 | 新建 shared/outbox/ |
|
||
| T5 | 新建 shared/kafka/ 目录(producer + consumer) | P0 | 新建 shared/kafka/ |
|
||
| T6 | 引入 redis 客户端(ioredis)+ 实现 IdempotencyGuard(SETNX) | P0 | 新建 shared/redis/ |
|
||
| T7 | 实现 ChannelDispatcher 多渠道抽象(in_app/email/sms/wechat/push) | P0 | 新建 channels/ 目录 |
|
||
| T8 | 实现 PushGatewayClient gRPC 调用(替代 fetch POST 降级) | P0 | 新建 shared/push/ |
|
||
| T9 | 实现 12 类 Kafka 事件 consumer(iam/core-edu/data-ana) | P0 | 新建 shared/kafka/consumers/ |
|
||
| T10 | 重构 notifications.service.ts:移除同步 fetch push-gateway,改为 gRPC + 异步分发 | P0 | 重构 service |
|
||
| T11 | 实现 batchMarkAsRead / markAllAsRead / recall / getUnreadCount 端点 | P0 | controller + service |
|
||
| T12 | 实现 NotificationPreference CRUD | P1 | 新建 preferences/ 目录 |
|
||
| T13 | 实现 NotificationTemplate CRUD + render | P1 | 新建 templates/ 目录 |
|
||
| T14 | ES 索引 mapping + ensureIndex + 数据同步 consumer | P1 | 修改 config/elasticsearch.ts |
|
||
| T15 | 重构 createBatch 为批量 INSERT(替代 for 循环串行) | P1 | service 重构 |
|
||
| T16 | NotificationsModule 补 exports: [NotificationsService] | P1 | module 修改 |
|
||
| T17 | /readyz 多依赖检查(DB/ES/Redis/Kafka/PushGateway) | P1 | 修改 health.controller.ts |
|
||
| T18 | ZodError 在 GlobalErrorFilter 特殊处理(返回 400) | P1 | 修改 global-error.filter.ts |
|
||
| T19 | 统一关闭逻辑到 LifecycleService(移除 main.ts 重复 close) | P1 | 修改 main.ts + lifecycle.service.ts |
|
||
| T20 | DB 连接模式改 getDb() 函数式 | P2 | 修改 database.ts |
|
||
| T21 | ID 策略改 cuid2 | P2 | 修改 service 层 |
|
||
| T22 | 补齐单元测试(Service/Repository/ChannelDispatcher) | P2 | 新建 *.spec.ts |
|
||
| T23 | 修正 README(与实现对齐) | P2 | 修改 README.md |
|
||
|
||
### 10.2 跨模块依赖(前置)
|
||
|
||
| # | 任务 | 阻塞性 |
|
||
| --- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||
| D1 | events.proto 补 UserEvent / RoleEvent / NotificationEvent / MasteryEvent message | 🔴 阻塞 msg 消费事件 |
|
||
| D2 | events.proto ExamEvent/HomeworkEvent/GradeEvent 补 class_id / student_ids[] 字段 | 🔴 阻塞广播 fan-out |
|
||
| D3 | msg.proto 补 BatchSendNotification / GetUnreadCount / BatchMarkAsRead / MarkAllAsRead / RecallNotification RPC | 🔴 阻塞 gRPC 完整契约 |
|
||
| D4 | msg.proto 补 NotificationPreferenceService + NotificationTemplateService | 🔴 阻塞偏好与模板 |
|
||
| D5 | push-gateway 提供 gRPC PushService.Push 方法(ai02 设计确认) | 🔴 阻塞实时推送 |
|
||
| D6 | iam 发布 6 类用户/角色事件(ai06 设计确认) | 🟡 不阻塞 P5 启动,但消费逻辑无法验证 |
|
||
| D7 | core-edu 发布 5 类教学事件(ai08 设计确认) | 🟡 同上 |
|
||
| D8 | data-ana 发布 mastery 事件(ai11 设计确认) | 🟡 同上 |
|
||
|
||
---
|
||
|
||
## 11. 与黄金模板对齐 checklist
|
||
|
||
| 项 | classes 黄金模板 | msg 当前 | msg 目标(P5 完成) |
|
||
| ----------------- | ---------------------------- | ------------------- | -------------------------------------------------- |
|
||
| 权限装饰器 | @RequirePermission 全覆盖 | ✅ 6 端点 | ✅ 扩展到 17+ 端点 |
|
||
| 错误码前缀 | CLASSES_* | ✅ MSG_* | ✅ 保持 + 新增 4 个错误码 |
|
||
| logger | pino | ✅ | ✅ 保持 + 敏感字段脱敏 |
|
||
| metrics | prom-client + /metrics | ✅ | ✅ 保持 + 15 个指标 |
|
||
| tracer | OTel + auto-instrumentations | ✅ | ✅ 保持 + 跨 Kafka trace 传递 |
|
||
| /healthz | ✅ | ✅ | ✅ 保持 |
|
||
| /readyz | DB SELECT 1 | ⚠️ 仅 DB | ✅ 多依赖(DB/ES/Redis/Kafka/PushGateway) |
|
||
| 优雅关闭 | LifecycleService | ⚠️ main.ts 重复关闭 | ✅ 统一到 LifecycleService |
|
||
| 测试覆盖率 | 60% | 0% | ≥ 80% |
|
||
| Dockerfile | 多阶段 | ✅ | ✅ 保持 |
|
||
| Zod 校验 | schema.parse | ⚠️ ZodError 未处理 | ✅ Controller 层全 parse + ZodError 400 |
|
||
| GlobalErrorFilter | ✅ | ✅ | ✅ + ZodError 分支 |
|
||
| DB 连接模式 | getDb() 函数式 | ⚠️ const db | ✅ 改 getDb() |
|
||
| ID 生成 | cuid2 | ⚠️ randomUUID | ✅ 改 cuid2 |
|
||
| Repository 抽象 | ✅ | ❌ 无 | ✅ 新建 repository 层 |
|
||
| Outbox | ❌(黄金模板自身无) | ❌ | ✅ **新增** Outbox 模式 |
|
||
| Kafka consumer | ❌ | ❌ | ✅ **新增** Kafka 消费模式(回写黄金模板) |
|
||
| 多渠道分发 | ❌ | ❌ | ✅ **新增** ChannelDispatcher 模式(回写黄金模板) |
|
||
| 状态机字段 | ❌ | ❌ | ✅ status 字段 |
|
||
|
||
---
|
||
|
||
## 12. 多渠道分发详细设计(附录)
|
||
|
||
### 12.1 NotificationChannel 接口
|
||
|
||
```typescript
|
||
interface NotificationChannel {
|
||
readonly name: NotificationChannelName;
|
||
send(
|
||
notification: Notification,
|
||
userPreference: NotificationPreference,
|
||
): Promise<SendResult>;
|
||
isAvailable(): boolean;
|
||
}
|
||
```
|
||
|
||
### 12.2 渠道实现清单
|
||
|
||
| 渠道 | 实现类 | 依赖 | 适用通知类型 | 备注 |
|
||
| ------ | ------------- | ----------------- | ------------------------------ | -------------------------------- |
|
||
| in_app | InAppChannel | MySQL | 全部 | 总是发送,写入 msg_notifications |
|
||
| email | EmailChannel | SMTP Server | system/exam/announcement | 异步队列 |
|
||
| sms | SMSChannel | SMS Provider API | grade/attendance/mastery_alert | 紧急通知优先 |
|
||
| wechat | WeChatChannel | WeChat API | 全部(用户绑定微信后) | 模板消息 |
|
||
| push | PushChannel | Push Gateway gRPC | 全部(在线用户) | 实时推送 |
|
||
|
||
### 12.3 ChannelDispatcher 调度逻辑
|
||
|
||
```typescript
|
||
class ChannelDispatcher {
|
||
async dispatch(
|
||
notification: Notification,
|
||
preference: NotificationPreference,
|
||
): Promise<SendResult[]> {
|
||
const channels = this.resolveChannels(notification, preference);
|
||
const results = await Promise.allSettled(
|
||
channels.map((ch) => ch.send(notification, preference)),
|
||
);
|
||
return results.map((r, i) =>
|
||
r.status === "fulfilled"
|
||
? r.value
|
||
: {
|
||
success: false,
|
||
channel: channels[i].name,
|
||
error: r.reason.message,
|
||
},
|
||
);
|
||
}
|
||
|
||
private resolveChannels(
|
||
notification: Notification,
|
||
preference: NotificationPreference,
|
||
): NotificationChannel[] {
|
||
if (!preference.enabled) return [this.inAppChannel]; // 仅站内信
|
||
const channels = preference.channels
|
||
.map((name) => this.channelRegistry.get(name))
|
||
.filter((ch) => ch && ch.isAvailable());
|
||
if (!channels.includes(this.inAppChannel)) {
|
||
channels.unshift(this.inAppChannel); // in_app 总是加入
|
||
}
|
||
return channels;
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
**AI Agent**: ai05 (content + msg)
|
||
**Coordinator**: coord-ai
|
||
**Branch**: 单仓库并行模式(直接 push main)
|