# 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)