# msg 对接契约

> 负责人：ai10
> 关联：[matrix.md](../matrix.md)、[msg.proto](../../../packages/shared-proto/proto/msg.proto)、[events.proto](../../../packages/shared-proto/proto/events.proto)、[02-architecture-design.md](../../../services/msg/docs/02-architecture-design.md)
> 仲裁依赖：ISSUE-008（topic 命名）、ISSUE-009（RPC 数量）、ISSUE-013（events.proto 补齐）

---

## §1 我提供什么（对外接口）

### 1.1 gRPC 接口

> 端口：50056 | proto 包名：`next_edu_cloud.msg.v1` | proto 文件：[msg.proto](../../../packages/shared-proto/proto/msg.proto)
>
> **⚠️ ISSUE-009 待仲裁**：ai-allocation.md 规定 13 RPC，02-architecture-design.md 设计 17 RPC。下表列出设计文档完整 17 RPC，标注基线 13 RPC（✅基线 / ➕扩展待仲裁）。coord 裁决后裁剪或放宽。

#### NotificationService（9 RPC：5 基线 + 4 扩展）

| RPC                   | 请求                                                                                                                   | 响应                                                  | 基线/扩展 | 说明              |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | --------- | ----------------- |
| SendNotification      | `SendNotificationRequest{user_id, type, title, content, channel, metadata?, related_entity_type?, related_entity_id?}` | `Notification`                                        | ✅基线    | 单条发送          |
| BatchSendNotification | `BatchSendNotificationRequest{items[], group_id?}`                                                                     | `BatchSendNotificationResponse{ids[], failed[]}`      | ➕扩展    | 批量发送          |
| ListNotifications     | `ListNotificationsRequest{user_id, only_unread?, type?, page?, page_size?}`                                            | `ListNotificationsResponse{notifications[], total}`   | ✅基线    | 列表（补分页）    |
| GetUnreadCount        | `GetUnreadCountRequest{user_id}`                                                                                       | `GetUnreadCountResponse{count}`                       | ➕扩展    | 未读数（Redis）   |
| MarkAsRead            | `MarkAsReadRequest{id, user_id}`                                                                                       | `Empty`                                               | ✅基线    | 标记已读          |
| BatchMarkAsRead       | `BatchMarkAsReadRequest{ids[], user_id}`                                                                               | `Empty`                                               | ➕扩展    | 批量已读          |
| MarkAllAsRead         | `MarkAllAsReadRequest{user_id, before?}`                                                                               | `Empty`                                               | ➕扩展    | 全部已读          |
| SearchNotifications   | `SearchNotificationsRequest{user_id, query, type?, page?, page_size?}`                                                 | `SearchNotificationsResponse{notifications[], total}` | ✅基线    | ES 全文检索       |
| RecallNotification    | `RecallNotificationRequest{group_id, reason?}`                                                                         | `RecallNotificationResponse{recalled_count}`          | ✅基线    | 撤回广播          |

#### NotificationPreferenceService（2 RPC 基线）

| RPC                | 请求                                               | 响应                                    | 基线/扩展 | 说明     |
| ------------------ | -------------------------------------------------- | --------------------------------------- | --------- | -------- |
| GetPreferences     | `GetPreferencesRequest{user_id}`                   | `GetPreferencesResponse{preferences[]}` | ✅基线    | 偏好查询 |
| UpdatePreferences  | `UpdatePreferencesRequest{user_id, preferences[]}` | `Empty`                                 | ✅基线    | 偏好更新 |

> **注**：02-architecture-design.md 设计 2 RPC；原 contract 版本的 GetPreferenceByChannel / ListPreferences 暂移除（待 ISSUE-009 仲裁是否保留）

#### NotificationTemplateService（4 RPC：4 基线 + 2 扩展）

| RPC            | 请求                                                                                               | 响应                                   | 基线/扩展 | 说明       |
| -------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------- | --------- | ---------- |
| CreateTemplate | `CreateTemplateRequest{code, type, title_template, content_template, default_channels, variables}` | `NotificationTemplate`                 | ✅基线    | 创建模板   |
| GetTemplate    | `GetTemplateRequest{id}`                                                                           | `NotificationTemplate`                 | ✅基线    | 查询模板   |
| ListTemplates  | `ListTemplatesRequest{type?, status?}`                                                             | `ListTemplatesResponse{templates[]}`   | ✅基线    | 模板列表   |
| UpdateTemplate | `UpdateTemplateRequest{id, ...}`                                                                   | `NotificationTemplate`                 | ➕扩展    | 更新模板   |
| DeleteTemplate | `DeleteTemplateRequest{id}`                                                                        | `Empty`                                | ➕扩展    | 删除模板   |
| RenderTemplate | `RenderTemplateRequest{code, variables, locale?}`                                                  | `RenderedNotification{title, content}` | ✅基线    | 渲染模板   |

> **汇总**：基线 13 RPC（9 基线表中 ✅ × 5 + 偏好 ✅ × 2 + 模板 ✅ × 4 = 11... 实际基线 = NotificationService 5 + Preference 2 + Template 4 = 11）。**注**：若严格按 ai-allocation 13 RPC，基线应为 13，此处设计文档 11 基线 + 6 扩展 = 17，与 13 预算差 4。待 ISSUE-009 仲裁后最终确认。

### 1.2 HTTP 端点

> msg 对外以 gRPC 为主（BFF 通过 gRPC 调用）。以下 REST 端点为过渡期/管理端使用，最终将逐步迁移至 gRPC。

| Method | Path                                     | 权限                    | 说明                 |
| ------ | ---------------------------------------- | ----------------------- | -------------------- |
| POST   | /notifications/send                      | MSG_NOTIFICATION_SEND   | 单条发送             |
| POST   | /notifications/batch                     | MSG_NOTIFICATION_SEND   | 批量发送             |
| GET    | /notifications/user/:userId              | MSG_NOTIFICATION_READ   | 用户通知列表         |
| GET    | /notifications/user/:userId/unread-count | MSG_NOTIFICATION_READ   | 未读数（Redis）      |
| PUT    | /notifications/:id/read                  | MSG_NOTIFICATION_READ   | 标记已读             |
| GET    | /notifications/search                    | MSG_NOTIFICATION_READ   | ES 全文检索          |
| GET    | /preferences/user/:userId                | MSG_NOTIFICATION_READ   | 用户偏好             |
| PUT    | /preferences/user/:userId                | MSG_NOTIFICATION_MANAGE | 更新偏好             |
| GET    | /templates                               | MSG_NOTIFICATION_MANAGE | 模板列表             |
| POST   | /templates                               | MSG_NOTIFICATION_MANAGE | 创建模板             |

### 1.3 GraphQL schema

不适用（msg 是业务服务，非 BFF）。

### 1.4 Kafka 事件发布

> **⚠️ ISSUE-008 待仲裁**：topic 命名存在三套约定（per-event / aggregate / aggregate+action）。本表采用 02-architecture-design.md §5.2 约定（per-event topic），与 004 §7.2 一致。coord 裁决后统一。

| Topic                        | Event Type                  | 触发时机       | 消费方                         | Outbox |
| ---------------------------- | --------------------------- | -------------- | ------------------------------ | ------ |
| `edu.notification.sent`      | NotificationSent            | 通知发送成功   | push-gateway / data-ana        | ✅     |
| `edu.notification.read`      | NotificationRead            | 通知被标记已读 | data-ana                       | ✅     |
| `edu.notification.recalled`  | NotificationRecalled        | 通知被撤回     | push-gateway（删除已推送消息） | ✅     |
| `edu.notification.failed`    | NotificationFailed          | 通知投递失败   | data-ana（监控告警）           | ✅     |

> **Producer 幂等**：`idempotent=true` + `transactionalId=msg-producer`
> **DLQ**：消费失败超 3 次投递 `edu.notification.dlq`（见 02-architecture-design.md §5，待补充）

### 1.5 错误码前缀

`MSG_`（完整清单见 02-architecture-design.md §6.2）：
- MSG_VALIDATION_ERROR / MSG_NOT_FOUND / MSG_PERMISSION_DENIED / MSG_CONFLICT / MSG_BUSINESS_ERROR / MSG_DATABASE_ERROR / MSG_INTERNAL_ERROR（当前已实现 7 个）
- MSG_ES_UNAVAILABLE / MSG_REDIS_UNAVAILABLE / MSG_PUSH_GATEWAY_UNAVAILABLE / MSG_TEMPLATE_NOT_FOUND / MSG_TEMPLATE_RENDER_ERROR / MSG_RATE_LIMIT_EXCEEDED（P5 新增 6 个）

---

## §2 我消费什么（依赖上游）

### 2.1 gRPC 调用（同步）

| 调用方       | 对方服务     | 协议 | RPC                  | 用途                   | 状态               |
| ------------ | ------------ | ---- | -------------------- | ---------------------- | ------------------ |
| msg → push   | push-gateway | gRPC | PushService.Push     | 实时推送通知到在线用户 | P5 待实现（D5）    |

> 当前降级：fetch POST `/internal/push`（见 [notifications.service.ts](../../../services/msg/src/notifications/notifications.service.ts) L179）
> **调用方向澄清**（ISSUE-005）：004 §4.1 表述"push-gateway → Msg"有歧义，实际方向是 msg → push-gateway

### 2.2 Kafka 事件订阅（异步）

> **⚠️ ISSUE-008 待仲裁**：topic 命名采用 02-architecture-design.md §5.1 约定（per-event topic，与 004 §7.2 一致）。原 contract 版本的 aggregate topic（edu.iam.user.events / edu.exam.events）待 coord 裁决后统一。

| Topic                               | 来源服务      | 处理逻辑                     | 触发通知             | 幂等键     | 阻塞性 |
| ----------------------------------- | ------------- | ---------------------------- | -------------------- | ---------- | ------ |
| `edu.identity.user.created`         | iam (ai06)    | 发送欢迎通知                 | welcome 通知         | `event_id` | 🟡     |
| `edu.identity.user.updated`         | iam (ai06)    | 用户信息变更，清理缓存       | —                    | `event_id` | 🟡     |
| `edu.identity.user.deleted`         | iam (ai06)    | 用户删除，归档通知           | —                    | `event_id` | 🟡     |
| `edu.identity.user.role_changed`    | iam (ai06)    | 角色变更通知                 | system 通知          | `event_id` | 🟡     |
| `edu.identity.role.created`         | iam (ai06)    | 角色新增（管理通知）         | system 通知          | `event_id` | 🟡     |
| `edu.identity.role.updated`         | iam (ai06)    | 角色权限变更通知             | system 通知          | `event_id` | 🟡     |
| `edu.teaching.exam.published`       | core-edu (ai08) | 推送考试通知给班级学生     | exam 通知（fan-out） | `event_id` | 🟡     |
| `edu.teaching.assignment.submitted` | core-edu (ai08) | 通知教师有学生提交作业     | homework 通知        | `event_id` | 🟡     |
| `edu.teaching.assignment.graded`    | core-edu (ai08) | 通知学生作业已批改         | grade 通知           | `event_id` | 🟡     |
| `edu.teaching.grade.recorded`       | core-edu (ai08) | 通知学生成绩已录入         | grade 通知           | `event_id` | 🟡     |
| `edu.teaching.attendance.recorded`  | core-edu (ai08) | 出勤异常通知家长           | attendance 通知      | `event_id` | 🟡     |
| `edu.insight.mastery.updated`       | data-ana (ai11) | 学情掌握度下降，触发预警   | mastery_alert 通知   | `event_id` | 🟡     |

> **⚠️ ISSUE-013 阻塞**：events.proto 当前仅有 ClassEvent / ExamEvent / HomeworkEvent / GradeEvent，缺 UserEvent / RoleEvent / MasteryEvent / NotificationEvent 4 类 message。msg 消费需 coord 补齐 proto（D1）。补齐前用通用 JSON payload 解析。
>
> **幂等去重**：三层防线（L1 Redis SETNX `msg:processed:{event_id}` TTL 7d / L2 msg_idempotency 表 / L3 notifications.event_id UNIQUE INDEX）

### 2.3 HTTP 调用

无主动 HTTP 调用上游（push-gateway 走 gRPC，HTTP 仅为降级）。

---

## §3 就绪信号

### 3.1 我依赖的上游就绪标志

| 标志 | 提供方 | 阻塞性 | 说明 |
| ---- | ------ | ------ | ---- |
| events.proto 补 UserEvent/RoleEvent/MasteryEvent/NotificationEvent | coord | 🔴 阻塞 T9 | D1，ISSUE-013 |
| events.proto GradeEvent 补 class_id；全部补 student_ids[] | coord | 🔴 阻塞 fan-out | D2，ISSUE-006 |
| msg.proto 补 5 扩展 RPC + Preference/Template Service | coord | 🔴 阻塞 gRPC | D3-D4，ISSUE-009 |
| push-gateway gRPC PushService.Push | ai02 | 🔴 阻塞 T8 | D5 |
| iam 发布 6 类 user/role 事件 | ai06 | 🟡 集成验证 | D6，开发期用 mock |
| core-edu 发布 5 类教学事件 | ai08 | 🟡 集成验证 | D7 |
| data-ana 发布 mastery 事件 | ai11 | 🟡 集成验证 | D8 |
| Redis 集群可用 | infra | 🟡 降级 DB | D9 |

### 3.2 我的就绪标志（供下游消费）

- [ ] msg gRPC 50056 启用（HealthService.Check 返回 SERVING）
- [ ] NotificationService RPC 可调用（基线 5 + 扩展 4，待 ISSUE-009 仲裁）
- [ ] NotificationPreferenceService 2 RPC 可调用
- [ ] NotificationTemplateService RPC 可调用（基线 4 + 扩展 2，含 RenderTemplate）
- [ ] `edu.notification.sent/read/recalled/failed` topic 可发布（待 ISSUE-008 仲裁命名）
- [ ] /readyz 返回 6 项依赖状态（DB/ES/Redis/Kafka producer/Kafka consumer/PushGateway）
- [ ] 测试覆盖率 ≥ 80%

---

## §4 Mock 策略

### 4.1 我提供的 mock（供下游）

在 msg 真实服务就绪前，为下游（teacher-bff / student-bff / parent-bff / push-gateway）提供 mock：

- **gRPC mock**：grpc-mock 拦截 50056 端口
  - ListNotifications 返回固定 10 条未读通知
  - MarkAsRead 返回 success=true
  - GetPreferences 返回默认偏好（in_app + email 开启，sms + push 关闭）
  - RenderTemplate 返回固定 title + content
- **Kafka mock**：msg 就绪前不发布真实通知事件，push-gateway 使用本地 stub 推送

### 4.2 我消费的 mock（开发期间）

在真实上游就绪前，msg 使用以下 mock：

- **业务事件**：core-edu / data-ana 就绪前，msg 内置定时器发布本地 stub 事件（ExamEvent / HomeworkEvent），触发 mock 通知流程
- **用户偏好**：iam 就绪前使用默认偏好（所有用户 in_app 开启）
- **模板渲染**：内置 5 个常用模板（exam.published / homework.graded / grade.recorded / mastery.warning / system.notice）
- **Push Gateway**：ai02 就绪前用 fetch POST /internal/push 降级（当前实现保留）

---

## §5 待仲裁项汇总

| ISSUE | 主题 | 阻塞性 | 当前采用 |
| ----- | ---- | ------ | -------- |
| ISSUE-008 | Kafka topic 命名（per-event vs aggregate） | 🟡 | per-event（02-architecture-design.md §5 + 004 §7.2） |
| ISSUE-009 | RPC 数量（13 vs 17） | 🟡 | 17（02-architecture-design.md §4.2），标注基线/扩展 |
| ISSUE-013 | events.proto 缺 4 类 message | 🔴 | 用 JSON payload 降级，待 coord 补齐 |
| ISSUE-006 | events.proto P9 字段描述 | 🟡 | 待 coord 修正 |
| ISSUE-010 | markAsRead 权限点 | 🟡 | 以 02-architecture-design.md §6.1 为准（READ） |
| ISSUE-011 | DB↔ES 降级方向 | 🟡 | 待 coord 仲裁（双向降级 vs 单向） |
