382 lines
20 KiB
Markdown
382 lines
20 KiB
Markdown
# 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)
|