# 模块架构设计文档 — 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<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 表清单（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.<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.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 {
        <<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 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<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 已提供 `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）
