# Msg 沟通通知中台

> 版本：1.0（P5 完整实现）
> HTTP 端口：3007 / gRPC 端口：50056
> 设计文档：[02-architecture-design.md](./docs/02-architecture-design.md)
> 契约文档：[msg_contract.md](../../docs/architecture/issues/contracts/msg_contract.md)

## 职责

沟通通知限界上下文（P5），承担：

1. **多渠道通知分发**：站内信 / 邮件 / 短信 / 推送 / 微信（策略模式 `NotificationChannel` 抽象）
2. **事件驱动 fan-out**：消费 iam / core-edu / data-ana 共 12 类 Kafka 事件触发通知
3. **CQRS 读写分离**：MySQL 写模型 + Elasticsearch 全文检索 + Redis 位图（已读状态降级）
4. **Push Gateway 解耦**：通过 HTTP POST `/internal/push` 委托 push-gateway 推送实时通知（coord M4 仲裁豁免 gRPC）
5. **Outbox 事务性事件**：通知发送 / 已读 / 撤回 / 失败 4 类事件经 Outbox 模式投递到 Kafka
6. **幂等去重三层防线**：Redis SETNX + processed_events 表 + event_id UNIQUE INDEX

## 技术栈

| 层        | 技术                                                        | 版本   |
| --------- | ----------------------------------------------------------- | ------ |
| 运行时    | Node.js                                                     | 22     |
| 框架      | NestJS（HTTP + gRPC 双协议 microservice）                   | 10     |
| 语言      | TypeScript（ESM 模式）                                      | 5      |
| ORM       | Drizzle ORM（函数式 `getDb()`）                             | latest |
| 写模型    | MySQL                                                       | 8.0    |
| 读模型    | Elasticsearch（safeIndex/safeSearch 降级）                  | 8      |
| 缓存/位图 | Redis（幂等去重 + 已读位图）                                | 7      |
| 消息      | Kafka（KafkaJS，producer + consumer）                       | 3.x    |
| 可观测性  | pino + prom-client + OpenTelemetry SDK                      | —      |
| ID        | cuid2（varchar(32)）                                        | —      |
| 契约      | protobuf（`packages/shared-proto/proto/msg.proto`，17 RPC） | buf v2 |

## 架构分层

```mermaid
flowchart TB
  subgraph Gateway
    AG[api-gateway]
  end
  subgraph BFF
    TBFF[teacher-bff]
  end
  subgraph MsgService[msg service · HTTP:3007 + gRPC:50056]
    HTTP[HTTP Controller]
    GRPC[gRPC Controller · 3 Service 17 RPC]
    SVC[Application Service · notifications/preferences/templates]
    DISP[ChannelDispatcher · 策略模式]
    CH[InApp/Email/SMS/Push/WeChat Channels]
    KAFKA_C[KafkaConsumer · 12 类事件]
    OUTBOX[OutboxPublisher · 4 类事件]
    REPO[Repository · Drizzle]
    ES[Elasticsearch 读模型]
    REDIS[Redis 幂等/位图]
    PUSH[Push Gateway Client · HTTP]
  end
  AG --> HTTP
  TBFF --> GRPC
  KAFKA_C --> SVC
  SVC --> DISP
  DISP --> CH
  CH --> PUSH
  SVC --> REPO
  SVC --> ES
  SVC --> REDIS
  SVC --> OUTBOX
  OUTBOX -->|publish| KT[(Kafka · edu.notification.*)]
  KAFKA_K[(Kafka · edu.identity/teaching/insight.*)] --> KAFKA_C
```

## 模块结构

```
services/msg/src/
├─ notifications/                    # 通知限界上下文
│  ├─ notifications.controller.ts    # HTTP REST（10 端点）
│  ├─ notifications.service.ts       # Application Service
│  ├─ notifications.repository.ts    # Drizzle 数据访问
│  ├─ notifications.schema.ts        # Drizzle 表定义（4 张表）
│  └─ notifications.dto.ts           # Zod 验证 + DTO
├─ preferences/                      # 用户偏好限界上下文
│  ├─ preferences.controller.ts      # HTTP REST（2 端点）
│  ├─ preferences.service.ts
│  ├─ preferences.repository.ts
│  └─ preferences.dto.ts
├─ templates/                        # 通知模板限界上下文
│  ├─ templates.controller.ts        # HTTP REST（6 端点）
│  ├─ templates.service.ts
│  ├─ templates.repository.ts
│  └─ templates.dto.ts
├─ channels/                         # 多渠道分发（策略模式）
│  ├─ channel.types.ts               # NotificationChannelStrategy 接口
│  ├─ channel-dispatcher.service.ts  # ChannelDispatcher 编排
│  ├─ in-app.channel.ts
│  ├─ email.channel.ts
│  ├─ sms.channel.ts
│  ├─ push.channel.ts                # 走 PushGatewayClient
│  └─ wechat.channel.ts
├─ grpc/                             # gRPC 入口（3 Service 17 RPC）
│  ├─ grpc.module.ts
│  ├─ notifications.grpc.controller.ts  # 9 RPC
│  ├─ preferences.grpc.controller.ts    # 2 RPC
│  └─ templates.grpc.controller.ts      # 6 RPC
├─ config/                           # 配置
│  ├─ env.ts                         # @t3-oss/env-nextjs + Zod
│  ├─ database.ts                    # getDb() 函数式
│  ├─ elasticsearch.ts               # safeIndex/safeSearch 降级
│  └─ grpc.ts                        # gRPC microservice options
├─ middleware/
│  ├─ auth.middleware.ts             # JWT 校验 + userId/roles 注入
│  └─ permission.guard.ts            # @RequirePermission 3 个权限点
├─ shared/
│  ├─ errors/
│  │  ├─ application-error.ts        # 13 个错误码
│  │  └─ global-error.filter.ts      # GlobalErrorFilter
│  ├─ health/
│  │  └─ health.controller.ts        # /healthz + /readyz（5 项依赖）
│  ├─ kafka/
│  │  ├─ kafka.client.ts             # producer + consumer + isKafkaHealthy
│  │  ├─ kafka.consumer.ts           # 12 类事件路由
│  │  └─ topic-map.ts                # PRODUCER_TOPIC_MAP + CONSUMER_TOPICS
│  ├─ lifecycle/
│  │  └─ lifecycle.service.ts        # DB/ES/Redis/Kafka/Outbox 生命周期
│  ├─ observability/
│  │  ├─ logger.ts                   # pino
│  │  ├─ metrics.ts                  # 15 个业务指标
│  │  └─ tracer.ts                   # OpenTelemetry SDK
│  ├─ outbox/
│  │  ├─ outbox.publisher.ts         # 轮询 pending 投递 Kafka
│  │  ├─ outbox.repository.ts        # findPending/markPublished/incrementRetry
│  │  ├─ outbox.service.ts           # 事务内写入 outbox
│  │  └─ outbox.schema.ts            # msg_outbox_events + processed_events
│  ├─ push/
│  │  └─ push-gateway.client.ts      # HTTP POST /internal/push（M4 仲裁）
│  └─ redis/
│     ├─ redis.client.ts             # getRedis/checkRedisConnection/closeRedis
│     └─ idempotency.guard.ts        # checkAndMark（Redis SETNX + DB 降级）
├─ app.module.ts                     # 根 Module
└─ main.ts                           # HTTP + gRPC 双协议启动
```

## API 清单

### HTTP REST（18 端点）

#### Notifications（10 端点）

| 方法   | 路径                                     | 权限点                  | 说明              |
| ------ | ---------------------------------------- | ----------------------- | ----------------- |
| 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   | 未读数            |
| PUT    | /notifications/:id/read                  | MSG_NOTIFICATION_READ   | 标记已读          |
| PUT    | /notifications/batch/read                | MSG_NOTIFICATION_READ   | 批量已读          |
| PUT    | /notifications/read-all                  | MSG_NOTIFICATION_READ   | 全部已读          |
| GET    | /notifications/search                    | MSG_NOTIFICATION_READ   | ES 全文检索       |
| POST   | /notifications/recall                    | MSG_NOTIFICATION_MANAGE | 撤回广播          |
| DELETE | /notifications/:id                       | MSG_NOTIFICATION_MANAGE | 删除              |

#### Preferences（2 端点）

| 方法 | 路径                      | 权限点                  | 说明     |
| ---- | ------------------------- | ----------------------- | -------- |
| GET  | /preferences/user/:userId | MSG_NOTIFICATION_READ   | 查询偏好 |
| PUT  | /preferences/user/:userId | MSG_NOTIFICATION_MANAGE | 更新偏好 |

#### Templates（6 端点）

| 方法   | 路径              | 权限点                  | 说明     |
| ------ | ----------------- | ----------------------- | -------- |
| GET    | /templates        | MSG_NOTIFICATION_MANAGE | 列表     |
| POST   | /templates        | MSG_NOTIFICATION_MANAGE | 创建     |
| GET    | /templates/:id    | MSG_NOTIFICATION_MANAGE | 查询     |
| PUT    | /templates/:id    | MSG_NOTIFICATION_MANAGE | 更新     |
| DELETE | /templates/:id    | MSG_NOTIFICATION_MANAGE | 删除     |
| POST   | /templates/render | MSG_NOTIFICATION_SEND   | 渲染模板 |

### gRPC（3 Service / 17 RPC）

契约源：[`packages/shared-proto/proto/msg.proto`](../../packages/shared-proto/proto/msg.proto)
包名：`next_edu_cloud.msg.v1`

#### NotificationService（9 RPC）

| RPC                   | 权限点                  | 说明     |
| --------------------- | ----------------------- | -------- |
| SendNotification      | MSG_NOTIFICATION_SEND   | 单条发送 |
| BatchSendNotification | MSG_NOTIFICATION_SEND   | 批量发送 |
| ListNotifications     | MSG_NOTIFICATION_READ   | 列表     |
| GetUnreadCount        | MSG_NOTIFICATION_READ   | 未读数   |
| MarkAsRead            | MSG_NOTIFICATION_READ   | 标记已读 |
| BatchMarkAsRead       | MSG_NOTIFICATION_READ   | 批量已读 |
| MarkAllAsRead         | MSG_NOTIFICATION_READ   | 全部已读 |
| SearchNotifications   | MSG_NOTIFICATION_READ   | ES 检索  |
| RecallNotification    | MSG_NOTIFICATION_MANAGE | 撤回     |

#### NotificationPreferenceService（2 RPC）

| RPC               | 权限点                  | 说明     |
| ----------------- | ----------------------- | -------- |
| GetPreferences    | MSG_NOTIFICATION_READ   | 查询偏好 |
| UpdatePreferences | MSG_NOTIFICATION_MANAGE | 更新偏好 |

#### NotificationTemplateService（6 RPC）

| RPC            | 权限点                  | 说明 |
| -------------- | ----------------------- | ---- |
| CreateTemplate | MSG_NOTIFICATION_MANAGE | 创建 |
| GetTemplate    | MSG_NOTIFICATION_MANAGE | 查询 |
| ListTemplates  | MSG_NOTIFICATION_MANAGE | 列表 |
| UpdateTemplate | MSG_NOTIFICATION_MANAGE | 更新 |
| DeleteTemplate | MSG_NOTIFICATION_MANAGE | 删除 |
| RenderTemplate | MSG_NOTIFICATION_SEND   | 渲染 |

## Kafka 事件流

### 消费（12 类事件）

| Topic                             | 来源     | 触发通知                                |
| --------------------------------- | -------- | --------------------------------------- |
| edu.identity.user.created         | iam      | 欢迎通知                                |
| edu.identity.user.updated         | iam      | （幂等标记，无通知）                    |
| edu.identity.user.deleted         | iam      | （幂等标记，无通知）                    |
| edu.identity.user.role_changed    | iam      | 角色变更通知                            |
| edu.identity.role.created         | iam      | （幂等标记，无通知）                    |
| edu.identity.role.updated         | iam      | 权限变更通知（fan-out affectedUserIds） |
| edu.teaching.exam.published       | core-edu | 考试通知（fan-out studentIds）          |
| edu.teaching.assignment.submitted | core-edu | 作业提交通知（教师）                    |
| edu.teaching.assignment.graded    | core-edu | 作业批改通知（学生）                    |
| edu.teaching.grade.recorded       | core-edu | 成绩录入通知                            |
| edu.teaching.attendance.recorded  | core-edu | 出勤异常通知（家长）                    |
| edu.insight.mastery.updated       | data-ana | 学情预警通知                            |

### 发布（4 类事件 · Outbox 模式）

| 事件类型              | Topic                     | 触发条件     |
| --------------------- | ------------------------- | ------------ |
| notification.sent     | edu.notification.sent     | 通知发送成功 |
| notification.read     | edu.notification.read     | 通知标记已读 |
| notification.recalled | edu.notification.recalled | 通知撤回     |
| notification.failed   | edu.notification.failed   | 通知发送失败 |

### 幂等去重

- **Redis SETNX**（首选）：`SETNX msg:event:{event_id} 1 EX 86400`
- **DB 降级**：`processed_events` 表 `event_id` UNIQUE INDEX
- **三层防线**：Redis → DB → 业务层 event_id 检查

## 数据库表

建表脚本：[`scripts/msg-init.sql`](../../scripts/msg-init.sql)

| 表                           | 用途        | 关键索引                                     |
| ---------------------------- | ----------- | -------------------------------------------- |
| msg_notifications            | 通知主表    | event_id UNIQUE / user_id+is_read / group_id |
| msg_notification_preferences | 用户偏好    | user_id+type UNIQUE                          |
| msg_notification_templates   | 通知模板    | code UNIQUE                                  |
| msg_notification_deliveries  | 投递记录    | notification_id / status / next_retry_at     |
| msg_outbox_events            | Outbox 事件 | status+next_retry_at / topic                 |
| processed_events             | 消费幂等    | event_id PK                                  |

## 健康检查

| 端点         | 用途      | 检查项                                                               |
| ------------ | --------- | -------------------------------------------------------------------- |
| GET /healthz | liveness  | 仅进程状态                                                           |
| GET /readyz  | readiness | DB（关键）/ ES（可选）/ Redis（可选）/ Kafka / PushGateway（软失败） |

`/readyz` 返回结构：

```json
{
  "status": "ok" | "degraded",
  "service": "msg",
  "timestamp": "2026-07-10T...",
  "checks": {
    "database": { "status": "ok" },
    "elasticsearch": { "status": "ok" },
    "redis": { "status": "ok" },
    "kafka": { "status": "ok" },
    "pushGateway": { "status": "skipped" }
  }
}
```

## 可观测性

### Metrics（/metrics 端点 · 15 个指标）

| 指标                                   | 类型      | 标签                     |
| -------------------------------------- | --------- | ------------------------ |
| msg_http_requests_total                | Counter   | method/route/status_code |
| msg_http_request_duration_seconds      | Histogram | method/route             |
| msg_grpc_requests_total                | Counter   | rpc_method/status        |
| msg_notification_sent_total            | Counter   | type/channel/status      |
| msg_notification_send_duration_seconds | Histogram | type/channel             |
| msg_notification_failed_total          | Counter   | type/channel/error_code  |
| msg_notification_read_total            | Counter   | type                     |
| msg_channel_dispatch_duration_seconds  | Histogram | channel                  |
| msg_kafka_consumer_lag                 | Gauge     | topic                    |
| msg_kafka_consumer_processed_total     | Counter   | topic/status             |
| msg_idempotent_duplicate_total         | Counter   | topic                    |
| msg_outbox_pending_count               | Gauge     | —                        |
| msg_push_gateway_call_total            | Counter   | status                   |
| msg_push_gateway_call_duration_seconds | Histogram | —                        |
| msg_unread_count                       | Gauge     | user_id                  |

### Tracer

- OpenTelemetry SDK + auto-instrumentations
- 服务名：`msg`
- OTLP exporter：`{OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces`

## 错误码

| 错误码                       | HTTP | 触发条件                  |
| ---------------------------- | ---- | ------------------------- |
| MSG_VALIDATION_ERROR         | 400  | Zod 校验失败              |
| MSG_NOT_FOUND                | 404  | 通知/偏好/模板不存在      |
| MSG_PERMISSION_DENIED        | 403  | 权限不足 / 跨用户访问     |
| MSG_CONFLICT                 | 409  | 重复发送 / 状态机非法转换 |
| MSG_BUSINESS_ERROR           | 422  | 业务规则违反              |
| MSG_DATABASE_ERROR           | 500  | Drizzle 操作异常          |
| MSG_INTERNAL_ERROR           | 500  | 未知异常                  |
| MSG_ES_UNAVAILABLE           | 503  | ES 不可用且无降级路径     |
| MSG_REDIS_UNAVAILABLE        | 503  | Redis 不可用且无降级路径  |
| MSG_PUSH_GATEWAY_UNAVAILABLE | 503  | Push Gateway 不可用       |
| MSG_TEMPLATE_NOT_FOUND       | 404  | 模板不存在                |
| MSG_TEMPLATE_RENDER_ERROR    | 422  | 模板渲染失败（变量缺失）  |
| MSG_RATE_LIMIT_EXCEEDED      | 429  | 通知频率超限              |

## 开发

```bash
# 安装依赖（monorepo 根目录）
pnpm install

# 启动开发服务（HTTP:3007 + gRPC:50056）
pnpm --filter msg dev

# 构建产物
pnpm --filter msg build

# 启动生产服务
pnpm --filter msg start
```

## 环境变量

| 变量                        | 默认值            | 必填 | 说明                                         |
| --------------------------- | ----------------- | ---- | -------------------------------------------- |
| PORT                        | 3007              | 否   | HTTP 端口                                    |
| GRPC_PORT                   | 50056             | 否   | gRPC 端口                                    |
| DATABASE_URL                | —                 | 是   | MySQL 连接串                                 |
| REDIS_URL                   | —                 | 否   | Redis 连接串（未配置降级到 DB 去重）         |
| JWT_SECRET                  | —                 | 否   | JWT 密钥（Gateway 已校验，可选）             |
| JWT_ISSUER                  | next-edu-cloud    | 否   | JWT 签发者                                   |
| KAFKA_BROKERS               | localhost:9092    | 否   | Kafka broker 列表                            |
| KAFKA_CLIENT_ID             | msg-service       | 否   | Kafka client ID                              |
| KAFKA_CONSUMER_GROUP_ID     | msg-service-group | 否   | Kafka consumer group                         |
| ES_URL                      | —                 | 否   | Elasticsearch 地址（未配置则降级到 DB 查询） |
| PUSH_GATEWAY_URL            | —                 | 否   | Push Gateway 地址（M4 仲裁 HTTP）            |
| PUSH_INTERNAL_TOKEN         | —                 | 否   | Push Gateway 内部鉴权 token                  |
| OTEL_EXPORTER_OTLP_ENDPOINT | —                 | 否   | OpenTelemetry OTLP endpoint                  |
| LOG_LEVEL                   | info              | 否   | pino 日志级别                                |
| NODE_ENV                    | development       | 否   | 运行环境                                     |
| DEV_MODE                    | false             | 否   | 开发模式（跳过权限校验）                     |

## 仲裁依据

- **M4**：Push Gateway 走 HTTP POST `/internal/push`，豁免 gRPC，鉴权头 `X-Internal-Key`
- **M5**：消费 topic 用 `edu.teaching.*` / `edu.identity.*` / `edu.insight.*`
- **G2**：/readyz 多依赖检查（DB/ES/Redis/Kafka/PushGateway）
- **G10**：getDb() 函数式（非类）
- **G11**：cuid2 ID（varchar(32)）
- **G12**：ESM 模式，相对 import 带 `.js` 后缀
- **G13**：仅类型导入用 `import type`

详见 [coord-final-decisions.md](../../docs/architecture/coord-final-decisions.md) + [president-final-rulings.md](../../docs/architecture/president-final-rulings.md)
