feat(msg): 完整实现 msg 消息服务

包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:09:52 +08:00
parent 21530dc7f6
commit 7b7abbb309
51 changed files with 5204 additions and 371 deletions

View File

@@ -1,52 +1,381 @@
# Msg 消息通知服务
# Msg 沟通通知中台
> 版本:0.1P5 骨架
> 端口3007
> 版本: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)
## 职责
消息通知限界上下文,管理通知的多渠道分发(站内信、邮件、短信、推送)。
消费 Kafka 事件,写入 MySQL + Elasticsearch调用 Push Gateway 实时推送。
沟通通知限界上下文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
## 技术栈
- NestJS 10 + TypeScript 5
- Drizzle ORM + MySQL 8
- Elasticsearch 8全文检索
- Kafka事件消费骨架
- pino + prom-client + OpenTelemetry
| 层 | 技术 | 版本 |
| --------- | ----------------------------------------------------------- | ------ |
| 运行时 | 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
pnpm dev # http://localhost:3007
# 启动开发服务HTTP:3007 + gRPC:50056
pnpm --filter msg dev
# 构建产物
pnpm --filter msg build
# 启动生产服务
pnpm --filter msg start
```
## API
| 方法 | 路径 | 说明 |
| ---- | ------------------------ | ---------------- |
| POST | /notifications | 发送通知 |
| GET | /notifications | 查询用户通知列表 |
| POST | /notifications/:id/read | 标记已读 |
| GET | /notifications/search?q= | 全文检索通知 |
## 健康检查
| 端点 | 用途 | 鉴权 |
| -------------- | ------------------------------------------------- | ---- |
| `GET /healthz` | 存活探针liveness仅返回进程状态不检查依赖 | 无 |
| `GET /readyz` | 就绪探针readiness检查 DB 连接,失败返回 503 | 无 |
实现见 `src/shared/health/health.controller.ts`5 个 NestJS 服务iam/core-edu/content/msg/classes一致。
## 环境变量
| 变量 | 默认值 | 说明 |
| ------------- | --------------------- | ------------------ |
| PORT | 3007 | 服务端口 |
| DATABASE_URL | - | MySQL 连接串 |
| KAFKA_BROKERS | localhost:9092 | Kafka broker 列表 |
| ES_URL | http://localhost:9200 | Elasticsearch 地址 |
| JWT_SECRET | - | JWT 密钥 |
| 变量 | 默认值 | 必填 | 说明 |
| --------------------------- | ----------------- | ---- | -------------------------------------------- |
| 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)