Files
Edu/services/msg/README.md
SpecialX 7b7abbb309 feat(msg): 完整实现 msg 消息服务
包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
2026-07-10 19:09:52 +08:00

382 lines
20 KiB
Markdown
Raw Permalink 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 沟通通知中台
> 版本1.0P5 完整实现)
> 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 |
| 框架 | NestJSHTTP + gRPC 双协议 microservice | 10 |
| 语言 | TypeScriptESM 模式) | 5 |
| ORM | Drizzle ORM函数式 `getDb()` | latest |
| 写模型 | MySQL | 8.0 |
| 读模型 | ElasticsearchsafeIndex/safeSearch 降级) | 8 |
| 缓存/位图 | Redis幂等去重 + 已读位图) | 7 |
| 消息 | KafkaKafkaJSproducer + consumer | 3.x |
| 可观测性 | pino + prom-client + OpenTelemetry SDK | — |
| ID | cuid2varchar(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 REST10 端点)
│ ├─ notifications.service.ts # Application Service
│ ├─ notifications.repository.ts # Drizzle 数据访问
│ ├─ notifications.schema.ts # Drizzle 表定义4 张表)
│ └─ notifications.dto.ts # Zod 验证 + DTO
├─ preferences/ # 用户偏好限界上下文
│ ├─ preferences.controller.ts # HTTP REST2 端点)
│ ├─ preferences.service.ts
│ ├─ preferences.repository.ts
│ └─ preferences.dto.ts
├─ templates/ # 通知模板限界上下文
│ ├─ templates.controller.ts # HTTP REST6 端点)
│ ├─ 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 + /readyz5 项依赖)
│ ├─ 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/pushM4 仲裁)
│ └─ redis/
│ ├─ redis.client.ts # getRedis/checkRedisConnection/closeRedis
│ └─ idempotency.guard.ts # checkAndMarkRedis SETNX + DB 降级)
├─ app.module.ts # 根 Module
└─ main.ts # HTTP + gRPC 双协议启动
```
## API 清单
### HTTP REST18 端点)
#### Notifications10 端点)
| 方法 | 路径 | 权限点 | 说明 |
| ------ | ---------------------------------------- | ----------------------- | ----------------- |
| 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 | 删除 |
#### Preferences2 端点)
| 方法 | 路径 | 权限点 | 说明 |
| ---- | ------------------------- | ----------------------- | -------- |
| GET | /preferences/user/:userId | MSG_NOTIFICATION_READ | 查询偏好 |
| PUT | /preferences/user/:userId | MSG_NOTIFICATION_MANAGE | 更新偏好 |
#### Templates6 端点)
| 方法 | 路径 | 权限点 | 说明 |
| ------ | ----------------- | ----------------------- | -------- |
| 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 | 渲染模板 |
### gRPC3 Service / 17 RPC
契约源:[`packages/shared-proto/proto/msg.proto`](../../packages/shared-proto/proto/msg.proto)
包名:`next_edu_cloud.msg.v1`
#### NotificationService9 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 | 撤回 |
#### NotificationPreferenceService2 RPC
| RPC | 权限点 | 说明 |
| ----------------- | ----------------------- | -------- |
| GetPreferences | MSG_NOTIFICATION_READ | 查询偏好 |
| UpdatePreferences | MSG_NOTIFICATION_MANAGE | 更新偏好 |
#### NotificationTemplateService6 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 IDvarchar(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)