# push-gateway 对接契约

> 负责人：ai02
> 关联：[matrix.md](./matrix.md)、[msg.proto](../../../packages/shared-proto/proto/msg.proto)、[events.proto](../../../packages/shared-proto/proto/events.proto)、[02-architecture-design.md](../../../services/push-gateway/docs/02-architecture-design.md)
> 版本：v2（2026-07-10，对齐总裁裁决 ISSUE-053/055/056/058 + 02 文档 + 现码）
> 变更摘要：① topic 改 `edu.notification.requested`（ISSUE-053）；② /internal/send → /internal/push（对齐代码 + 总裁 §4.2）；③ 鉴权 mTLS → X-Internal-Token（对齐总裁 §7.2）；④ 移除 /sse（待 ISSUE-001 仲裁）；⑤ 新增 /internal/online 端点

---

## §1 我提供什么（对外接口）

### 1.1 gRPC 接口（如有）

**无对外 gRPC**。push-gateway 是 WebSocket 推送入口，仅通过 HTTP /internal/* 接收 msg 服务调用。

> 协议选型决策（coord 已采纳 P1，见 [02 文档 §5.4](../../../services/push-gateway/docs/02-architecture-design.md)）：HTTP /internal/* + Kafka 双通道，不走 gRPC。理由：推送结果需同步返回（delivered/online），HTTP 同步响应更直接；广播走 Kafka 解耦。

### 1.2 HTTP 端点

| Method | Path                        | 用途                               | 认证                                            | 请求体                         | 响应                                                        |
| ------ | --------------------------- | ---------------------------------- | ----------------------------------------------- | ------------------------------ | ----------------------------------------------------------- |
| GET    | /ws                         | WebSocket 升级端点（实时推送通知） | JWT RS256（query `?token=` 或 `Authorization`） | —                              | 升级为 WebSocket 长连接                                     |
| POST   | /internal/push              | 内部单推接口（msg 服务触发）       | `X-Internal-Token` 头                           | `{user_id, event, data, ttl?}` | `{success, delivered, online}`                              |
| POST   | /internal/broadcast         | 内部广播接口（msg 服务触发）       | `X-Internal-Token` 头                           | `{event, data, filter?}`       | `{success, reached}`                                        |
| GET    | /internal/online/\<userID\> | 查询用户在线状态                   | `X-Internal-Token` 头                           | —                              | `{online: bool, instances: []}`                             |
| GET    | /healthz                    | 健康检查（liveness）               | 公开                                            | —                              | `{status:"ok", service, version, connections}`              |
| GET    | /readyz                     | 就绪检查（readiness）              | 公开                                            | —                              | `{status, degraded?}`（Redis/Kafka 软失败时 degraded:true） |
| GET    | /metrics                    | Prometheus 指标端点                | 公开（内网）                                    | —                              | Prometheus 文本格式                                         |

**SSE 端点裁决**（ARB-015 §17.3，ISSUE-001 已裁决）：

- ❌ 不支持 `GET /sse`（仅 WebSocket /ws；删除 contract v1 的 /sse 行，02 文档 §10 正确）
- 理由：简化实现 + 统一推送通道 + WebSocket 双向通信能力更强

**鉴权头裁决**（ARB-015 §17.3，ISSUE-002 已裁决）：

- 统一采用 `X-Internal-Token` 头（对齐总裁裁决 §7.2）
- 现码 `X-Internal-Key` 需 ai02 统一为 `X-Internal-Token`

**`X-Internal-Token` 校验机制**：

- 启动时从 `INTERNAL_API_TOKEN` 环境变量加载
- 与 msg 服务共享同一密钥（K8s Secret 注入）
- DevMode（`DEV_MODE=true`）下跳过校验，便于本地联调
- 缺失/不匹配 → 401 + `PUSH_UNAUTHORIZED`

**响应语义**（/internal/push）：

- `delivered: true`：本实例或跨实例成功投递到至少一个连接
- `online: false`：用户离线，msg 应走离线推送（SMS/邮件）
- `delivered: false, online: true`：投递失败（连接满/异常），msg 应重试或落库

### 1.3 GraphQL schema（如 BFF）

不适用。push-gateway 非 BFF。

### 1.4 Kafka 事件发布（如有）

**无**。push-gateway 不发布任何 Kafka 事件，仅消费事件触发推送。推送结果通过 HTTP /internal/push 同步响应返 msg 服务。

### 1.5 错误码前缀

`PUSH_`（见 [matrix.md](./matrix.md) §6 错误码前缀矩阵）

**错误码清单**（对齐 [02 文档 §6.2](../../../services/push-gateway/docs/02-architecture-design.md)）：

| 错误码                      | HTTP | 触发条件               |
| --------------------------- | ---- | ---------------------- |
| `PUSH_UNAUTHORIZED`         | 401  | 缺失/无效 token        |
| `PUSH_INVALID_REQUEST`      | 400  | JSON 解析失败          |
| `PUSH_INVALID_PAYLOAD`      | 400  | event/data 字段缺失    |
| `PUSH_TOO_MANY_CONNECTIONS` | 429  | 单用户连接数超限（>5） |
| `PUSH_INTERNAL_ERROR`       | 500  | panic / Redis 不可达   |

### 1.6 WebSocket 应用层消息协议

**服务端 → 客户端**：

```json
{
  "type": "message",
  "event": "notification.created",
  "data": { ... },
  "seq": 12345,
  "timestamp": "2026-07-09T..."
}
```

**客户端 → 服务端**：

- WebSocket Ping 控制帧（心跳，30s 间隔，非文本消息）
- 重连时：`GET /ws?token=&session_id=<id>&last_seq=<n>`（P6 实现）

**心跳规则**（RFC 6455 控制帧）：

- 客户端每 30s 发送 Ping
- 服务端自动回 Pong（gorilla/websocket 默认）
- 服务端 `SetReadDeadline(60s)`，60s 无消息则关闭连接

---

## §2 我消费什么（依赖上游）

### 2.1 gRPC 调用（同步）

**无主动 gRPC 调用上游**。

> JWT 公钥通过 HTTP `GET iam/.well-known/jwks.json` 拉取（非 gRPC），由 `shared-go/auth/jwks` 实现，5 分钟缓存刷新。

### 2.2 Kafka 事件订阅（异步）

| Topic                        | Event                 | 发布方     | mock 策略                                                                   |
| ---------------------------- | --------------------- | ---------- | --------------------------------------------------------------------------- |
| `edu.notification.requested` | NotificationRequested | msg (ai10) | msg 就绪前不订阅，使用本地定时器每 30 秒推送 1 条 mock 通知到所有在线客户端 |

> **topic 命名对齐 ISSUE-053 裁决**（[president-final-rulings.md](../president-final-rulings.md) §1.5）：禁止抽象名 `edu.*.events`，统一 `edu.<domain>.<aggregate>.<action>` 格式。
> 原 contract v1 写的 `edu.msg.notification.events` 已废弃。

**消费语义**：

- Consumer Group：`push-gateway`
- 至少一次（at-least-once），消费失败重试 3 次后入死信队列
- 幂等性：基于 `event_id` Redis SETNX 去重（TTL 24h）

### 2.3 HTTP 调用（如有）

| 调用方       | Method | Path                        | 用途                              | 时机       |
| ------------ | ------ | --------------------------- | --------------------------------- | ---------- |
| push-gateway | GET    | `iam/.well-known/jwks.json` | 拉取 RS256 公钥校验 WebSocket JWT | iam 就绪后 |

### 2.4 Redis 协议

| 用途                   | 数据结构        | Key 模式                             | TTL             |
| ---------------------- | --------------- | ------------------------------------ | --------------- |
| 在线用户所在实例集合   | SET             | `edu:push:online:<userID>`           | 60s（心跳续期） |
| 单连接元数据           | HASH            | `edu:push:session:<userID>:<connID>` | 60s             |
| 跨实例定向推送 channel | Pub/Sub channel | `edu:push:channel:user:<userID>`     | —               |
| 跨实例广播 channel     | Pub/Sub channel | `edu:push:channel:broadcast`         | —               |
| 幂等去重               | SETNX           | `edu:push:idempotent:<event_id>`     | 24h             |

> **Hub 启动重建机制**（对齐 ISSUE-058）：实例启动时遍历内存连接 SADD + EXPIRE 60s；先清空 Redis 中本 instanceID 旧成员避免幽灵成员；实例崩溃 SET 自然过期（60s）。

### 2.5 内部接口（msg 调用 push-gateway）

| 被调用方     | Method.Path                   | 用途               | 说明                                       |
| ------------ | ----------------------------- | ------------------ | ------------------------------------------ |
| push-gateway | POST /internal/push           | msg 服务单用户推送 | msg 渲染模板后定向推送给目标用户           |
| push-gateway | POST /internal/broadcast      | msg 服务批量推送   | msg 收到业务事件后渲染模板，调此接口广播   |
| push-gateway | GET /internal/online/<userID> | msg 查在线状态     | msg 决定走在线推送还是离线推送（SMS/邮件） |

---

## §3 就绪信号

### 3.1 我依赖的上游就绪标志

- [ ] **shared-go 包骨架**（coord 批次 0.14）：`packages/shared-go` 含 tracer/logger/jwks/env 4 模块
- [ ] **iam JWT RS256 + JWKS 端点**（ai06 批次 1）：iam gRPC 50052 + `/.well-known/jwks.json` 可访问
- [ ] **msg gRPC + Kafka topic**（ai10 批次 4）：msg gRPC 50056 + `edu.notification.requested` topic 有事件发布
- [x] **Redis 基础设施**（coord P1）：Redis 7.x 可访问 ✅ 已就绪
- [x] **Kafka 基础设施**（coord P1）：Kafka 可访问 ✅ 已就绪
- [ ] **ISSUE-001~007 仲裁**（coord）：[coord.md](../coord.md) 追加 ARB-003+ 仲裁章节

### 3.2 我的就绪标志（供下游消费）

- [ ] push-gateway HTTP :8081 启用（`GET /healthz` 返 200）
- [ ] /readyz 返 200（含 Redis/Kafka 软失败检查，`degraded` 字段）
- [ ] WebSocket /ws 端点可升级连接（JWT RS256 鉴权后建立长连接）
- [ ] /internal/push + /internal/broadcast 接收 msg 推送并下发到在线客户端
- [ ] /internal/online/<userID> 查在线状态可调用
- [ ] Kafka consumer `edu.notification.requested` 订阅成功（Consumer Group `push-gateway` lag=0）
- [ ] /metrics 暴露 8+ 自定义指标（`push_gateway_*` 系列）

---

## §4 Mock 策略

### 4.1 我提供的 mock

在 push-gateway 真实就绪前，为下游（各前端 portal）提供以下 mock：

- **WebSocket mock**：前端开发期使用 mock-socket 库模拟 WS 连接
  - 连接成功后每 30 秒推送 1 条 mock 通知（`type="system"`, `event="notification.created"`, `data={title:"测试通知"}`）
- **HTTP mock**：/internal/* 接口返回 `{success:true, delivered:true, online:true}`

### 4.2 我消费的 mock

在真实上游就绪前，push-gateway 使用以下 mock：

- **NotificationEvent mock**：msg 就绪前，push-gateway 内置定时器每 30 秒生成 1 条 mock NotificationRequested 事件，推送到所有在线客户端
- **JWT 验签 mock**：iam 就绪前使用本地固定 mock RS256 公钥验签 WebSocket 连接的 token（或 DevMode `dev-token` 跳过）
- **Kafka 订阅 mock**：msg 就绪前不启动 Kafka consumer，使用本地定时器替代
- **JWKS fetcher mock**：iam 就绪前 shared-go/jwks 返回硬编码公钥

---

## §5 与 02 文档、现码、总裁裁决的对齐说明

| 维度                   | 现 code            | 02 文档                   | 总裁裁决                                      | 本 contract 采用              | 对齐任务                                   |
| ---------------------- | ------------------ | ------------------------- | --------------------------------------------- | ----------------------------- | ------------------------------------------ |
| 内部端点路径           | `/internal/push`   | `/internal/push`          | §4.2 as-is 采纳 02 §4.2                       | `/internal/push`              | ✅ 已对齐（v1 写 `/internal/send` 已修正） |
| 鉴权头名               | `X-Internal-Key`   | `X-Internal-Token`        | §7.2 "X-Internal-Token 重命名"                | `X-Internal-Token`            | 待 ISSUE-002 仲裁后改代码                  |
| 鉴权环境变量           | `INTERNAL_API_KEY` | `INTERNAL_API_TOKEN`      | §7.2                                          | `INTERNAL_API_TOKEN`          | 待 ISSUE-002 仲裁后改代码                  |
| 鉴权机制               | 共享密钥           | 共享密钥                  | —                                             | 共享密钥（v1 写 mTLS 已废弃） | ✅ 已对齐                                  |
| Kafka topic            | —（未实现）        | `edu.notification.events` | §1.5 ISSUE-053 → `edu.notification.requested` | `edu.notification.requested`  | ✅ 已对齐 ISSUE-053                        |
| /readyz Redis 失败策略 | —（仅返连接数）    | 返 503 硬失败             | §4.3 ISSUE-058 "仅告警不阻塞"                 | 软失败 200 + `degraded:true`  | 待 ISSUE-006 仲裁最终策略                  |
| /readyz Kafka 失败策略 | —                  | 未描述                    | §3.3 ISSUE-055 软失败                         | 软失败 200 + `degraded:true`  | ✅ 已对齐 ISSUE-055                        |
| /sse 端点              | 无                 | §10 建议不支持            | —                                             | 不提供（待 ISSUE-001 仲裁）   | 待 ISSUE-001 仲裁                          |
| 容量目标               | —                  | 10w+                      | —                                             | 10w+                          | 待 ISSUE-003 仲裁更新 modules/README       |
| 错误码前缀             | 无前缀             | `PUSH_*`                  | —                                             | `PUSH_*`                      | 待 P5 实现时统一                           |
| 心跳协议               | 文本 ping/pong     | RFC 6455 控制帧           | —                                             | RFC 6455 控制帧               | 待 P5 任务 4.3 重构                        |

---

## §6 变更历史

| 版本 | 日期       | 变更内容                                                                                                                                                         | 变更依据                               |
| ---- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| v1   | 2026-07-09 | 初始版本（coord 生成）                                                                                                                                           | —                                      |
| v2   | 2026-07-10 | topic 改 `edu.notification.requested`；/internal/send → /internal/push；鉴权 mTLS → X-Internal-Token；移除 /sse（待仲裁）；新增 /internal/online；新增 §5 对齐表 | ISSUE-053/055/056/058 + 总裁 §4.2/§7.2 |
