# 模块架构设计文档 — 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 层
HTTP REST + gRPC] GRD[Guard 层
PermissionGuard + AuthMiddleware] VAL[Validation 层
Zod Schema Parse] SVC[NotificationService
通知编排] DISP[ChannelDispatcher
多渠道分发] REPO[Repository 层
Drizzle ORM] OUT[Outbox Publisher
独立 worker] CONS[Kafka Consumer
消费 5+ 类事件] IDEM[Idempotency Guard
Redis SETNX 去重] PUSH[Push Gateway Client
gRPC 推送通道] FILTER[GlobalErrorFilter
统一错误兜底] OBS[Observability
pino + prom-client + OTel] end subgraph Channels["通知渠道实现"] INAPP[InAppChannel
站内信] EMAIL[EmailChannel
邮件] SMS[SMSChannel
短信] WECHAT[WeChatChannel
微信] end subgraph Storage["数据存储"] MySQL[MySQL 8
写模型主库] ES[Elasticsearch 8
全文检索读模型] Redis[Redis 7
幂等去重 + 已读位图] end subgraph Bus["消息总线"] Kafka[Kafka
edu.notification.* + 消费 edu.teaching.* / edu.identity.* / edu.insight.*] end subgraph External["外部服务"] PushGW[push-gateway
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 { <> +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 = { 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; 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 { 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)