Files
Edu/services/msg/docs/02-architecture.md
SpecialX faaaf29f67 docs: ai 协作文档体系重构与多 ai 仲裁结果落地
1.AI 协作文档体系重构(objections/worklines/contracts+matrix.md)

2.coord 仲裁文档(final-decisions/cross-review/final-rulings/orchestration)

3.各服务 01/02 文档补全

4.共享包初始化(shared-ts/shared-go/hooks/ui-components/ui-tokens)

5.Proto 契约补全

6.004 架构影响地图更新

7.端口分配表

8.设计规格文档
2026-07-10 12:58:22 +08:00

817 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块架构设计文档 — msg
> AI 标识ai10
> 负责模块msgP5 沟通通知)
> 阶段:架构设计外包 · 阶段 2模块架构设计
> 日期2026-07-09
> 关联文档:[01-understanding.md](./01-understanding.md)、[ai-allocation.md](../../../docs/architecture/ai-allocation.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[pending-features.md](../../../docs/architecture/roadmap/pending-features.md)、[project_rules.md](../../../.trae/rules/project_rules.md)
> 参考实现:[classes 黄金模板](../../classes/src/)、[core-edu Outbox](../../core-edu/src/shared/outbox/)、[iam RBAC](../../iam/src/)
---
## 0. 设计原则与长远目标
本设计遵循以下原则,确保既能实现 P5 退出标准,又为 P6 硬化与未来扩展铺路:
1. **通知即基础设施**msg 是全校所有业务事件的下收件人,设计上按"通知中台"标准而非"业务 CRUD"标准,预留多租户、多渠道、多策略扩展点。
2. **契约先行**proto → 实现。NotificationService gRPC + events.proto NotificationEvent 必须先定。
3. **最终一致 + 幂等**Kafka 消费 at-least-once业务侧 event_id 去重Outbox 保证通知请求事件不丢。
4. **降级优先**每个外部依赖DB/ES/Redis/Push/SMS/邮件)都有降级路径,单点故障不阻断核心链路。
5. **策略模式贯穿渠道层**:新增渠道(微信/钉钉/企业微信)只需实现 ChannelStrategy 接口,不改核心。
6. **读路径分级**:列表/计数走 Redis 缓存 → DB → ES 三级降级;全文检索走 ES → DB like 降级。
7. **可观测可追踪**:每条通知从"事件触发 → 模板渲染 → 渠道投递 → 已读"全链路 traceId 串联,投递结果落 deliveries 表。
8. **向前兼容**:所有 schema 变更只增不删proto 字段只加编号不复用,事件版本用 v1/v2 后缀。
---
## 1. 模块内部分层图
```mermaid
flowchart TD
subgraph Gateway["API Gateway (L4)"]
GW[路由转发 + JWT 校验]
end
subgraph BFF["BFF (L3)"]
BFF1[teacher-bff / student-bff / parent-bff]
end
subgraph Msg["msg 服务 (L5 业务服务)"]
direction TB
subgraph Entry["入口层"]
CTRL[NotificationsController<br/>REST]
GRPC[NotificationGrpcController<br/>gRPC · P5 启用]
KCONS[KafkaConsumer<br/>事件消费]
end
subgraph Guard["横切层"]
PERM[PermissionGuard<br/>@RequirePermission]
VALID[Zod 校验]
FILTER[GlobalErrorFilter]
end
subgraph App["应用服务层 (ApplicationService)"]
NS[NotificationService<br/>编排:幂等 → 模板渲染 → fan-out]
TS[TemplateService<br/>模板 CRUD + 变量替换 + i18n]
DS[DeliveryService<br/>投放编排 + 重试 + DLQ]
PREF[PreferenceService<br/>偏好查询 + 静默时段]
READ[ReadStateService<br/>已读/未读 + Redis 位图]
IDEM[IdempotencyService<br/>event_id 去重 Redis SETNX]
end
subgraph Strategy["渠道策略层 (ChannelStrategy)"]
INAPP[InAppChannel<br/>DB+ES 写入]
EMAIL[EmailChannel<br/>SMTP async]
SMS[SmsChannel<br/>短信网关 + 限流 + 配额]
PUSH[PushChannel<br/>gRPC → push-gateway]
WECHAT[WechatChannel<br/>未来·预留接口]
end
subgraph Domain["领域服务 (DomainService)"]
REPO[NotificationRepository<br/>Drizzle 数据访问]
OUTBOX[OutboxPublisher<br/>NotificationRequested 事件]
end
subgraph Data["数据层"]
DB[(MySQL<br/>写模型)]
ES[(Elasticsearch<br/>全文检索 + 降级读)]
REDIS[(Redis<br/>幂等/位图/计数/限流)]
KAFKA[(Kafka<br/>消费+发布)]
end
end
subgraph External["外部依赖"]
PUSHGW[push-gateway<br/>WebSocket 推送]
SMTP[SMTP 邮件服务]
SMSGW[短信服务商]
end
GW --> CTRL
BFF1 --> GRPC
KAFKA --> KCONS
KCONS --> IDEM --> NS
CTRL --> PERM --> VALID --> NS
GRPC --> NS
NS --> TS
NS --> PREF
NS --> DS
NS --> READ
DS --> INAPP & EMAIL & SMS & PUSH
INAPP --> REPO --> DB
INAPP --> ES
PUSH --> PUSHGW
EMAIL --> SMTP
SMS --> SMSGW
NS --> OUTBOX --> KAFKA
READ --> REDIS
READ --> REPO
IDEM --> REDIS
```
**关键拦截点**
- `PermissionGuard`APP_GUARD每个 Controller 方法 `@RequirePermission` 校验
- `GlobalErrorFilter`ZodError → 400、ApplicationError → 对应状态码、兜底 500
- `IdempotencyService`Kafka 消费与 HTTP send 均先过幂等检查
- `OutboxPublisher`:事务内写 outbox 表,独立轮询发布到 Kafka
---
## 2. 领域模型
### 2.1 聚合根与实体
```mermaid
classDiagram
class Notification {
+id: UUID
+userId: string
+type: NotificationType
+category: NotificationCategory
+priority: Priority
+title: string
+content: string
+channel: Channel
+metadata: JSON
+scheduledAt?: DateTime
+expiresAt?: DateTime
+isRead: boolean
+readAt?: DateTime
+idempotencyKey?: string
+sourceEventId?: string
+createdAt: DateTime
+markAsRead()
+isExpired()
}
class NotificationTemplate {
+id: UUID
+code: string
+name: string
+category: NotificationCategory
+channelTemplates: Map~Channel, ChannelTemplate~
+variables: TemplateVariable[]
+isActive: boolean
+version: int
+render(ctx): RenderedContent
}
class ChannelTemplate {
+channel: Channel
+subjectTpl: string
+bodyTpl: string
+i18n: Map~locale, LocalizedTpl~
}
class NotificationPreference {
+userId: string
+channelEnabled: Map~Channel, boolean~
+categoryEnabled: Map~Category, boolean~
+quietHours: QuietHours
+digestConfig: DigestConfig
+isChannelAllowed(channel, category): boolean
}
class NotificationDelivery {
+id: UUID
+notificationId: UUID
+channel: Channel
+status: DeliveryStatus
+externalId?: string
+attemptCount: int
+lastError?: string
+deliveredAt?: DateTime
+markSent()
+markFailed(err)
+canRetry(): boolean
}
class QuietHours {
+start: string
+end: string
+timezone: string
+isInQuietHours(now): boolean
}
Notification "1" --> "0..*" NotificationDelivery : has
NotificationTemplate "1" --> "0..*" ChannelTemplate : contains
NotificationPreference "1" --> "1" QuietHours : has
enum NotificationType { SYSTEM ANNOUNCEMENT EXAM HOMEWORK GRADE ATTENDANCE MASTERY }
enum NotificationCategory { ACADEMIC BEHAVIOR SYSTEM MARKETING }
enum Priority { URGENT HIGH NORMAL LOW }
enum Channel { IN_APP EMAIL SMS PUSH WECHAT }
enum DeliveryStatus { PENDING SENT DELIVERED FAILED RETRYING }
```
### 2.2 聚合边界与通信
- **Notification 聚合**(根):包含 1:N Delivery。聚合内直接调用聚合间通过事件。
- **NotificationTemplate 聚合**(根):独立管理,被 NotificationService 引用渲染。
- **NotificationPreference 聚合**(根):按 userId 隔离,被 PreferenceService 查询。
- **跨聚合通信**TemplateService / PreferenceService / ReadStateService 同服务内直接方法调用(应用层编排),不发事件。
- **跨服务通信**:仅通过 Kafka 事件(消费 core-edu/iam/data-ana 事件;发布 NotificationRequested 给 push-gateway
---
## 3. 数据模型
### 3.1 表清单MySQLmsg 独占库)
| 表名 | 用途 | 现状 |
| ------------------------------ | -------------------------- | --------------- |
| `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 |
| ------------ | ---------------------- | -------------------- | ---------------- |
| 用户通知列表 | DBidx_user_created | ESuser_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 APIHTTP经 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 APINotificationServiceproto 包 `next_edu_cloud.msg.v1`
| 方法 | 请求 | 响应 | 说明 |
| ------------------- | -------------------------- | --------------------------- | ---------------- |
| SendNotification | SendNotificationRequest | Notification | 发送通知 |
| ListNotifications | ListNotificationsRequest | ListNotificationsResponse | 列表查询 |
| MarkAsRead | MarkAsReadRequest | Empty | 标记已读 |
| MarkAllAsRead | MarkAllAsReadRequest | Empty | 全部已读(新增) |
| GetUnreadCount | GetUnreadCountRequest | GetUnreadCountResponse | 未读计数(新增) |
| SearchNotifications | SearchNotificationsRequest | SearchNotificationsResponse | 全文检索 |
| GetPreference | GetPreferenceRequest | NotificationPreference | 偏好查询(新增) |
| UpdatePreference | UpdatePreferenceRequest | NotificationPreference | 偏好更新(新增) |
> proto 字段扩展(在现有 msg.proto 基础上新增 category/priority/scheduledAt/expiresAt/idempotencyKey 等),按 §5 契约规范走 coord 变更流程。
### 4.3 请求/响应示例(发送通知)
```json
POST /notifications
{
"userId": "u-123",
"type": "EXAM",
"category": "ACADEMIC",
"priority": "HIGH",
"title": "数学期中考试已发布",
"content": "请于 2026-07-15 前完成",
"channel": "in_app",
"metadata": { "examId": "e-456" },
"idempotencyKey": "client-uuid-789",
"scheduledAt": null
}
```
```json
{
"success": true,
"data": {
"id": "notif-uuid",
"skipped": false,
"deliveries": [
{ "channel": "in_app", "status": "SENT" },
{ "channel": "push", "status": "SENT", "externalId": "push-msg-id" }
]
}
}
```
---
## 5. 事件设计
### 5.1 消费的事件Kafka Consumer
消费组:`msg-service`partition 按 aggregate_id 保证同聚合有序。
| Topic | 生产者 | 事件类型 | msg 动作 |
| ----------------------------------- | -------- | ----------------- | ----------------------------------- |
| `edu.identity.user.created` | iam | UserRegistered | 发欢迎通知in_app + email |
| `edu.identity.user.updated` | iam | UserUpdated | 通知关键信息变更 |
| `edu.identity.user.role_changed` | iam | UserRoleChanged | 通知角色变更 |
| `edu.teaching.exam.published` | core-edu | ExamPublished | 给班级全体学生发考试通知fan-out |
| `edu.teaching.assignment.submitted` | core-edu | HomeworkSubmitted | 通知教师有学生提交作业 |
| `edu.teaching.grade.recorded` | core-edu | GradeRecorded | 通知学生成绩已录入 |
| `edu.insight.mastery.updated` | data-ana | MasteryUpdated | 掌握度低于阈值触发预警通知 |
**消费幂等**:每个消息的 `event_id`proto 字段)→ Redis SETNX `msg:idem:{event_id}` TTL 7 天。SETNX 成功才处理失败说明已处理跳过。Redis 不可用时降级到 `msg_idempotency` 表唯一键。
### 5.2 发布的事件Outbox → Kafka
| Topic | 事件类型 | 触发时机 | 消费者 |
| ------------------------- | ---------------------- | -------------------- | ------------ |
| `edu.notification.events` | notification.requested | 通知创建后,请求推送 | push-gateway |
| `edu.notification.events` | notification.delivered | 渠道投递成功(未来) | data-ana |
| `edu.notification.events` | notification.read | 用户标记已读(未来) | data-ana |
> NotificationRequested 事件需 coord 在 events.proto 追加 NotificationEvent message见 §7 交互点)。
### 5.3 事件版本演化
- 字段只增不减proto 编号不复用
- 破坏性变更新建 `v2` 后缀 topic`edu.notification.events.v2`),消费者双消费过渡期后下线 v1
---
## 6. 横切关注点对齐清单
### 6.1 权限装饰器(权限点清单)
| 权限常量 | 说明 | 端点 |
| ----------------------- | --------------------- | ---------------------------------------------------------------- |
| MSG_NOTIFICATION_SEND | 发送通知 | POST /notifications, POST /notifications/batch, gRPC Send |
| MSG_NOTIFICATION_READ | 读取通知 | GET /notifications/user/*, search, unread-count, GET preferences |
| MSG_NOTIFICATION_MANAGE | 管理通知(已读/删除) | PUT /read, read-all, DELETE, PUT preferences |
| MSG_TEMPLATE_MANAGE | 模板管理(新增) | POST/GET/PUT/DELETE /notifications/templates/* |
> 权限模型从硬编码 ROLE_PERMISSIONS 迁移到对齐 iam 的 RBAC角色 → 权限点映射由 iam 通过 `getEffectivePermissions` 下发PermissionGuard 读取 `x-user-permissions` header由 Gateway 注入)。过渡期保留 ROLE_PERMISSIONS 兜底。
### 6.2 错误码清单(前缀 `MSG_`
| 错误码 | 触发条件 | HTTP |
| -------------------------- | ----------------------------- | ---- |
| MSG_VALIDATION_ERROR | Zod 校验失败 | 400 |
| MSG_NOT_FOUND | 通知/模板/偏好不存在 | 404 |
| MSG_PERMISSION_DENIED | 权限不足 | 403 |
| MSG_CONFLICT | 幂等键冲突/重复发送 | 409 |
| MSG_BUSINESS_ERROR | 偏好禁用该渠道/静默时段跳过 | 422 |
| MSG_TEMPLATE_RENDER_ERROR | 模板变量缺失/渲染失败(新增) | 422 |
| MSG_RATE_LIMITED | 短信/邮件限流(新增) | 429 |
| MSG_QUOTA_EXCEEDED | 短信配额耗尽(新增) | 429 |
| MSG_DATABASE_ERROR | DB 操作失败 | 500 |
| MSG_EXTERNAL_GATEWAY_ERROR | SMTP/SMS 网关错误(新增) | 502 |
| MSG_INTERNAL_ERROR | 兜底 | 500 |
### 6.3 可观测性
**Logger**pino结构化 JSON注入 traceId`x-request-id` header。初始化位置 [logger.ts](../src/shared/observability/logger.ts)。
**Metrics 指标清单**prom-client前缀 `msg_`
| 指标名 | 类型 | 标签 | 说明 |
| ------------------------------------- | --------- | ------------------------------ | --------------- |
| msg_notification_sent_total | Counter | channel,type,category,priority | 通知发送总数 |
| msg_notification_delivered_total | Counter | channel,status | 投递结果总数 |
| msg_notification_read_total | Counter | type | 已读总数 |
| msg_kafka_consumed_total | Counter | topic,event_type | Kafka 消费总数 |
| msg_kafka_consumed_errors_total | Counter | topic | Kafka 消费失败 |
| msg_outbox_pending | Gauge | — | Outbox 待发布数 |
| msg_outbox_publish_duration_seconds | Histogram | — | Outbox 发布耗时 |
| msg_template_render_duration_seconds | Histogram | code,channel | 模板渲染耗时 |
| msg_idempotency_dedup_total | Counter | source(kafka/http) | 幂等去重命中数 |
| msg_channel_delivery_duration_seconds | Histogram | channel | 渠道投递耗时 |
**Tracer**OpenTelemetry SDK + OTLP exporter初始化位置 [tracer.ts](../src/shared/observability/tracer.ts)。span 命名:`msg.<service>.<method>`(如 `msg.notification.send``msg.kafka.consume`)。
### 6.4 健康检查
| 端点 | 检查逻辑 | 失败状态 |
| ------------ | ------------------------------------------------------------ | -------- |
| GET /healthz | 进程存活,不检查依赖 | 200 |
| GET /readyz | DB `SELECT 1` + Redis `PING` + ES `ping`(任一失败返回 503 | 503 |
> 当前 /readyz 仅查 DB**阶段 2 实现需补 Redis + ES 检查**ai10 修订)。
### 6.5 优雅关闭顺序
统一到 LifecycleService移除 main.ts 重复逻辑),顺序:
1. HTTP server 停止接收新请求(`app.close()`
2. Kafka consumer 提交 offset 并停止(`consumer.disconnect()`
3. OutboxPublisher 停止轮询(`stop()`
4. Elasticsearch 关闭(`closeEs()`
5. Redis 关闭(`redisClient.quit()`
6. MySQL 连接池关闭(`closeDb()`
7. Tracer flush 并关闭(`shutdownTracer()`
---
## 7. 与其他模块的交互点(契约清单)
| 方向 | 对方服务 | 协议 | 接口/事件 | 用途 |
| ------ | ------------ | ----- | ----------------------------------------------- | ----------------------------------------- |
| 被调用 | api-gateway | HTTP | /notifications/* | REST 转发 |
| 被调用 | teacher-bff | gRPC | NotificationService.* | 教师端通知聚合 |
| 被调用 | student-bff | gRPC | NotificationService.* | 学生端通知查询 |
| 被调用 | parent-bff | gRPC | NotificationService.* | 家长端通知查询 |
| 调用 | push-gateway | gRPC | PushService.Pushai02 定义) | 实时推送(替代当前 fetch /internal/push |
| 消费 | iam | Kafka | edu.identity.user.* | 用户创建/更新/角色变更触发通知 |
| 消费 | core-edu | Kafka | edu.teaching.exam.published 等 | 考试/作业/成绩事件触发通知 |
| 消费 | data-ana | Kafka | edu.insight.mastery.updated | 掌握度预警 |
| 发布 | — | Kafka | edu.notification.events | NotificationRequested → push-gateway |
| 读 | iam | gRPC | UserService.GetUser可选查用户信息渲染模板 | 模板变量填充(如学生姓名) |
### 跨模块契约对齐提请 coord
1. **events.proto 补充 NotificationEvent**msg 发布 NotificationRequested 需 proto 定义coord 维护)
2. **push-gateway gRPC PushService**ai02 需定义 gRPC Push 方法msg 从 fetch 迁移到 gRPC当前 fetch 作为降级保留)
3. **iam 用户信息查询**模板渲染需用户姓名msg 是否调 iam.GetUser还是事件 payload 携带足够信息?(倾向后者,避免同步依赖)
4. **msg.proto 扩展**:新增 MarkAllAsRead / GetUnreadCount / GetPreference / UpdatePreference 方法coord 统一更新 proto
---
## 8. 渠道策略模式设计ai-allocation §5 核心)
```mermaid
classDiagram
class ChannelStrategy {
<<interface>>
+channel: Channel
+send(notification, rendered): Promise~DeliveryResult~
+isAvailable(): boolean
+checkRateLimit(userId): boolean
}
class InAppChannel {
+send(): DB insert + ES index
+isAvailable(): DB up
}
class EmailChannel {
+send(): SMTP send (async queue)
+isAvailable(): SMTP reachable
+checkRateLimit(): Redis 滑动窗口
}
class SmsChannel {
+send(): 短信网关 API
+isAvailable(): 网关可达 + 配额未满
+checkRateLimit(): Redis 严格限流
+checkQuota(): Redis 计数
}
class PushChannel {
+send(): gRPC → push-gateway
+isAvailable(): push-gateway up
+fallback(): InAppChannel
}
class WechatChannel {
+send(): 未来实现
+isAvailable(): false (P5 不实现)
}
ChannelStrategy <|.. InAppChannel
ChannelStrategy <|.. EmailChannel
ChannelStrategy <|.. SmsChannel
ChannelStrategy <|.. PushChannel
ChannelStrategy <|.. WechatChannel
```
**渠道编排逻辑**DeliveryService
1. 按 NotificationPreference 过滤被禁用渠道
2. 按 QuietHours 过滤非紧急通知URGENT 绕过静默)
3. 对每个启用渠道并行调用 `ChannelStrategy.send()`
4. 失败渠道记录到 deliveries 表,按 max_retry 重试(指数退避)
5. PushChannel 失败降级到 InAppChannel保证不丢
**未来扩展**(不改核心):新增微信/钉钉/企业微信渠道,只需实现 ChannelStrategy 并注册到 ChannelRegistry。
---
## 9. 已读/未读状态管理Redis 位图)
### 设计
- 每个用户一个 Redis Bitmapkey `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 truthRedis 是缓存标记已读时双写DB update + Redis SETBITRedis 失败仅 log 不阻断
### 未来扩展
- 位图支持按 category 分桶(`msg:unread:{userId}:ACADEMIC`)实现分类未读数
---
## 10. ES 降级查询策略ai-allocation §5
### 双向降级
| 触发场景 | 主路径 | 降级路径 |
| ------------ | ------ | -------------------------- |
| 正常列表查询 | DB | ESDB 不可用时) |
| 正常全文检索 | ES | DB LIKEES 不可用时) |
| DB 不可用 | — | ES 读模型(可能延迟 1s |
| ES 不可用 | — | DB LIKE 或返回空(检索类) |
### 实现要点
- `NotificationRepository` 内置 `listByUser()` 优先 DBcatch DB error 后 fallback 到 `esSearch()`
- `search()` 优先 EScatch ES error 后 fallback 到 `dbLikeSearch()`
- 降级时 metrics 记录 `msg_db_fallback_total` / `msg_es_fallback_total` 便于监控降级频率
- ES 读模型通过 `safeIndex` 异步同步,可能存在 1s 延迟,降级时在响应头标注 `X-Read-Model: es-degraded`
---
## 11. Kafka 消费幂等设计ai-allocation §5
```mermaid
flowchart TD
K[Kafka 消息] --> CHECK{Redis SETNX<br/>msg:idem:event_id}
CHECK -- 设置成功 --> PROC[处理事件<br/>渲染模板→fan-out]
CHECK -- 已存在 --> SKIP[跳过,返回原结果]
PROC --> WRITE[写 notifications + deliveries + outbox]
WRITE --> CACHE[缓存结果到 Redis]
PROC -.失败.-> DLQ[Dead Letter Topic<br/>edu.notification.dlq]
DLQ --> ALERT[告警 + 人工处理]
```
**双保险**
- L1Redis 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 消费积压 | 通知延迟 | 监控 lagHPA 扩容 consumer 实例 |
| 模板变量缺失导致渲染失败 | 通知发不出 | 渲染失败记 MSG_TEMPLATE_RENDER_ERROR降级用默认文案 |
| ES 与 DB 数据不一致 | 检索结果缺失 | 定期 reconcile job未来 P6降级读标注 |
### 12.2 假设
- 假设 iam 在 P2 已提供 `getEffectivePermissions` APImsg 的 PermissionGuard 可读取权限(过渡期用 ROLE_PERMISSIONS 兜底)
- 假设 push-gatewayai02P5 提供 gRPC PushService当前 fetch /internal/push 作为降级)
- 假设 coord 在 events.proto 补充 NotificationEvent message
- 假设 Redis 纳入 P5 基础设施infra/docker-compose.yml 已有 edu-redismsg 共用)
- 假设 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/SmsM14 | push-gateway gRPC |
| P0 | 通知模板模块M13 | — |
| P0 | Redis 位图已读/未读M15 | Redis |
| P0 | ES mapping + ensureIndexM3 | — |
| P0 | DB→ES 降级读路径M16 | — |
| P1 | 投递记录表 + 重试M17 | — |
| P1 | 幂等键HTTP sendM18 | — |
| P1 | 优先级/分类M19 | — |
| P1 | /readyz 补 Redis+ES 检查 | — |
| P1 | 优雅关闭统一到 LifecycleServiceM11 | — |
| P1 | NotificationsModule exportsM7 | — |
| P1 | 权限模型对齐 iam RBACM21 | iam getEffectivePermissions |
| P1 | gRPC controllerM9待 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