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.设计规格文档
42 KiB
模块架构设计文档 — msg
AI 标识:ai10 负责模块:msg(P5 沟通通知) 阶段:架构设计外包 · 阶段 2(模块架构设计) 日期:2026-07-09 关联文档:01-understanding.md、ai-allocation.md、004 架构影响地图、pending-features.md、project_rules.md 参考实现:classes 黄金模板、core-edu Outbox、iam RBAC
0. 设计原则与长远目标
本设计遵循以下原则,确保既能实现 P5 退出标准,又为 P6 硬化与未来扩展铺路:
- 通知即基础设施:msg 是全校所有业务事件的下收件人,设计上按"通知中台"标准而非"业务 CRUD"标准,预留多租户、多渠道、多策略扩展点。
- 契约先行:proto → 实现。NotificationService gRPC + events.proto NotificationEvent 必须先定。
- 最终一致 + 幂等:Kafka 消费 at-least-once,业务侧 event_id 去重;Outbox 保证通知请求事件不丢。
- 降级优先:每个外部依赖(DB/ES/Redis/Push/SMS/邮件)都有降级路径,单点故障不阻断核心链路。
- 策略模式贯穿渠道层:新增渠道(微信/钉钉/企业微信)只需实现 ChannelStrategy 接口,不改核心。
- 读路径分级:列表/计数走 Redis 缓存 → DB → ES 三级降级;全文检索走 ES → DB like 降级。
- 可观测可追踪:每条通知从"事件触发 → 模板渲染 → 渠道投递 → 已读"全链路 traceId 串联,投递结果落 deliveries 表。
- 向前兼容:所有 schema 变更只增不删,proto 字段只加编号不复用,事件版本用 v1/v2 后缀。
1. 模块内部分层图
flowchart TD
subgraph Gateway["API Gateway (L4)"]
GW[路由转发 + JWT 校验]
end
subgraph BFF["BFF (L3)"]
BFF1[teacher-bff / student-bff / parent-bff]
end
subgraph Msg["msg 服务 (L5 业务服务)"]
direction TB
subgraph Entry["入口层"]
CTRL[NotificationsController<br/>REST]
GRPC[NotificationGrpcController<br/>gRPC · P5 启用]
KCONS[KafkaConsumer<br/>事件消费]
end
subgraph Guard["横切层"]
PERM[PermissionGuard<br/>@RequirePermission]
VALID[Zod 校验]
FILTER[GlobalErrorFilter]
end
subgraph App["应用服务层 (ApplicationService)"]
NS[NotificationService<br/>编排:幂等 → 模板渲染 → fan-out]
TS[TemplateService<br/>模板 CRUD + 变量替换 + i18n]
DS[DeliveryService<br/>投放编排 + 重试 + DLQ]
PREF[PreferenceService<br/>偏好查询 + 静默时段]
READ[ReadStateService<br/>已读/未读 + Redis 位图]
IDEM[IdempotencyService<br/>event_id 去重 Redis SETNX]
end
subgraph Strategy["渠道策略层 (ChannelStrategy)"]
INAPP[InAppChannel<br/>DB+ES 写入]
EMAIL[EmailChannel<br/>SMTP async]
SMS[SmsChannel<br/>短信网关 + 限流 + 配额]
PUSH[PushChannel<br/>gRPC → push-gateway]
WECHAT[WechatChannel<br/>未来·预留接口]
end
subgraph Domain["领域服务 (DomainService)"]
REPO[NotificationRepository<br/>Drizzle 数据访问]
OUTBOX[OutboxPublisher<br/>NotificationRequested 事件]
end
subgraph Data["数据层"]
DB[(MySQL<br/>写模型)]
ES[(Elasticsearch<br/>全文检索 + 降级读)]
REDIS[(Redis<br/>幂等/位图/计数/限流)]
KAFKA[(Kafka<br/>消费+发布)]
end
end
subgraph External["外部依赖"]
PUSHGW[push-gateway<br/>WebSocket 推送]
SMTP[SMTP 邮件服务]
SMSGW[短信服务商]
end
GW --> CTRL
BFF1 --> GRPC
KAFKA --> KCONS
KCONS --> IDEM --> NS
CTRL --> PERM --> VALID --> NS
GRPC --> NS
NS --> TS
NS --> PREF
NS --> DS
NS --> READ
DS --> INAPP & EMAIL & SMS & PUSH
INAPP --> REPO --> DB
INAPP --> ES
PUSH --> PUSHGW
EMAIL --> SMTP
SMS --> SMSGW
NS --> OUTBOX --> KAFKA
READ --> REDIS
READ --> REPO
IDEM --> REDIS
关键拦截点:
PermissionGuard(APP_GUARD):每个 Controller 方法@RequirePermission校验GlobalErrorFilter:ZodError → 400、ApplicationError → 对应状态码、兜底 500IdempotencyService:Kafka 消费与 HTTP send 均先过幂等检查OutboxPublisher:事务内写 outbox 表,独立轮询发布到 Kafka
2. 领域模型
2.1 聚合根与实体
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)
{
"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 请求/响应示例(发送通知)
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
}
{
"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-permissionsheader(由 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。
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。span 命名:msg.<service>.<method>(如 msg.notification.send、msg.kafka.consume)。
6.4 健康检查
| 端点 | 检查逻辑 | 失败状态 |
|---|---|---|
| GET /healthz | 进程存活,不检查依赖 | 200 |
| GET /readyz | DB SELECT 1 + Redis PING + ES ping(任一失败返回 503) |
503 |
当前 /readyz 仅查 DB,阶段 2 实现需补 Redis + ES 检查(ai10 修订)。
6.5 优雅关闭顺序
统一到 LifecycleService(移除 main.ts 重复逻辑),顺序:
- HTTP server 停止接收新请求(
app.close()) - Kafka consumer 提交 offset 并停止(
consumer.disconnect()) - OutboxPublisher 停止轮询(
stop()) - Elasticsearch 关闭(
closeEs()) - Redis 关闭(
redisClient.quit()) - MySQL 连接池关闭(
closeDb()) - 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
- events.proto 补充 NotificationEvent:msg 发布 NotificationRequested 需 proto 定义(coord 维护)
- push-gateway gRPC PushService:ai02 需定义 gRPC Push 方法,msg 从 fetch 迁移到 gRPC(当前 fetch 作为降级保留)
- iam 用户信息查询:模板渲染需用户姓名,msg 是否调 iam.GetUser?还是事件 payload 携带足够信息?(倾向后者,避免同步依赖)
- msg.proto 扩展:新增 MarkAllAsRead / GetUnreadCount / GetPreference / UpdatePreference 方法,coord 统一更新 proto
8. 渠道策略模式设计(ai-allocation §5 核心)
classDiagram
class ChannelStrategy {
<<interface>>
+channel: Channel
+send(notification, rendered): Promise~DeliveryResult~
+isAvailable(): boolean
+checkRateLimit(userId): boolean
}
class InAppChannel {
+send(): DB insert + ES index
+isAvailable(): DB up
}
class EmailChannel {
+send(): SMTP send (async queue)
+isAvailable(): SMTP reachable
+checkRateLimit(): Redis 滑动窗口
}
class SmsChannel {
+send(): 短信网关 API
+isAvailable(): 网关可达 + 配额未满
+checkRateLimit(): Redis 严格限流
+checkQuota(): Redis 计数
}
class PushChannel {
+send(): gRPC → push-gateway
+isAvailable(): push-gateway up
+fallback(): InAppChannel
}
class WechatChannel {
+send(): 未来实现
+isAvailable(): false (P5 不实现)
}
ChannelStrategy <|.. InAppChannel
ChannelStrategy <|.. EmailChannel
ChannelStrategy <|.. SmsChannel
ChannelStrategy <|.. PushChannel
ChannelStrategy <|.. WechatChannel
渠道编排逻辑(DeliveryService):
- 按 NotificationPreference 过滤被禁用渠道
- 按 QuietHours 过滤非紧急通知(URGENT 绕过静默)
- 对每个启用渠道并行调用
ChannelStrategy.send() - 失败渠道记录到 deliveries 表,按 max_retry 重试(指数退避)
- 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)
flowchart TD
K[Kafka 消息] --> CHECK{Redis SETNX<br/>msg:idem:event_id}
CHECK -- 设置成功 --> PROC[处理事件<br/>渲染模板→fan-out]
CHECK -- 已存在 --> SKIP[跳过,返回原结果]
PROC --> WRITE[写 notifications + deliveries + outbox]
WRITE --> CACHE[缓存结果到 Redis]
PROC -.失败.-> DLQ[Dead Letter Topic<br/>edu.notification.dlq]
DLQ --> ALERT[告警 + 人工处理]
双保险:
- L1:Redis SETNX(快速去重,TTL 7 天)
- L2:
msg_idempotency表唯一键(Redis 不可用时兜底) - L3:
notifications.source_event_id唯一索引(最终防线)
死信队列:消费失败超过 3 次的消息投递到 edu.notification.dlq,触发告警,人工介入。
12. 风险与假设
12.1 技术风险
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| 广播场景 fan-out 性能(1 通知→N 学生) | DB 写入瓶颈 | 批量 INSERT + 异步 ES 索引 + Push 走 push-gateway 批量 |
| 短信成本失控 | 资金损失 | 严格限流 + 配额管理 + 降级到 in_app |
| Redis 单点故障 | 未读计数/幂等失效 | 降级到 DB,双写保证最终一致 |
| Kafka 消费积压 | 通知延迟 | 监控 lag,HPA 扩容 consumer 实例 |
| 模板变量缺失导致渲染失败 | 通知发不出 | 渲染失败记 MSG_TEMPLATE_RENDER_ERROR,降级用默认文案 |
| ES 与 DB 数据不一致 | 检索结果缺失 | 定期 reconcile job(未来 P6),降级读标注 |
12.2 假设
- 假设 iam 在 P2 已提供
getEffectivePermissionsAPI,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 仲裁)
- Redis 是否 P5 必选:msg 幂等/位图/限流强依赖 Redis,若 P5 不引入则需全部降级到 DB(性能折损)
- gRPC 启用时机:msg gRPC controller 是否 P5 实现(影响 BFF 调用方式)
- 短信/邮件服务商选型:影响 ChannelStrategy 实现(P5 是否实现真实发送,还是 mock + 接口预留)
- 每日摘要(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)