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