# push-gateway 工作排期

> 负责人：ai02
> 关联：[workline.md](../workline.md)、[coord.md](../coord.md)、[contracts/push-gateway_contract.md](../contracts/push-gateway_contract.md)、[objections/push-gateway_issue.md](../objections/push-gateway_issue.md)
> 模式：全并行（各 AI 一口气完成 P2-P6 全部代码，最后统一集成测试）
> 依据：[ai-allocation.md](../../ai-allocation.md) §7.2、[02-architecture-design.md](../../../services/push-gateway/docs/02-architecture-design.md) §12 实施优先级、[president-final-rulings.md](../../president-final-rulings.md) §3.4 回写义务

---

## §1 总览

push-gateway 是 Go 实现的实时推送基础设施服务（L3 网关层），管理 WebSocket 长连接，接收 msg 服务的推送请求投递到在线客户端。无业务状态，仅持有连接池 + Redis 跨实例广播。

**全阶段目标**：
- **批次 0（等待期）**：复审 02 文档 + 回写 ISSUE-053/055/056/058 裁决 + 提请 ISSUE-001~007 仲裁
- **批次 4（P5，13 天）**：完整实现 /internal/push + WebSocket 生命周期 + Redis Pub/Sub + Kafka 消费 + /readyz + /metrics + OTel + 多阶段 Dockerfile + slog + shared-go 接入 + 集成测试
- **批次 5（P6 硬化，5 天）**：Reconnect 协议 + Redis Stream 持久化 + 测试覆盖率 ≥ 80% + ADR/非功能性需求/失败模式章节补全

**关键路径依赖**：
- 批次 0.14：shared-go 包骨架（coord）—— tracer/logger/jwks/env 4 模块
- 批次 1：iam P2.1（JWT RS256 + JWKS 端点）—— WebSocket 鉴权前置
- 批次 4：msg gRPC 50056 + edu.notification.requested topic —— 推送事件来源

---

## §2 全阶段甘特图（批次 0 + 批次 4 + 批次 5）

```mermaid
gantt
    title ai02 push-gateway 全阶段排期（批次 0 + 批次 4 P5 + 批次 5 P6）
    dateFormat YYYY-MM-DD
    axisFormat %m-%d

    section 批次0 等待期 2天
    0.1 复审01/02文档+核查已有仲裁 :crit, a0a, 2026-07-10, 1d
    0.2 回写ISSUE-053/055/056/058到02文档 :crit, a0b, after a0a, 1d
    0.3 提请ISSUE-001~007待coord仲裁 :a0c, after a0a, 1d

    section 批次4 P5-P0 安全加固 3天
    4.1 Origin校验+CheckOrigin白名单 :crit, a4a, after b3a, 1d
    4.2 /internal/*鉴权对齐X-Internal-Token :crit, a4b, after a4a, 1d
    4.3 心跳改WebSocket控制帧+SetReadDeadline :crit, a4c, after a4a, 1d
    4.4 单用户连接数限制MaxConn=5 :crit, a4d, after a4c, 1d
    4.5 Send通道满时指标+日志 :a4e, after a4d, 1d

    section 批次4 P5-P0 基础设施 2天
    4.6 Dockerfile重构多阶段+非root+healthcheck+ldflags :crit, a4f, after b3a, 1d
    4.7 引入log/slog替换标准log :a4g, after a4f, 1d
    4.8 接入shared-go tracer/logger/jwks/env :a4h, after a4g, 1d

    section 批次4 P5-P1 横向扩展 3天
    4.9 Redis Pub/Sub跨实例广播 :crit, a4i, after a4h, 2d
    4.10 Redis SET在线状态+启动重建(ISSUE-058) :crit, a4j, after a4i, 1d
    4.11 /metrics自定义指标+/readyz软失败(ISSUE-055) :a4k, after a4j, 1d
    4.12 优雅关闭所有WebSocket连接 :a4l, after a4k, 1d

    section 批次4 P5-P2 高级功能 3天
    4.13 Kafka消费edu.notification.requested(ISSUE-053) :a4m, after a4j, 2d
    4.14 JWT RS256升级+JWKS fetcher :a4n, after a4m, 1d
    4.15 设计决策记录章节回写(ISSUE-056) :a4o, after a4n, 1d

    section 批次4 P5-P2 集成 2天
    4.16 与msg联调/internal/push双通道 :crit, a4p, after a4o, 1d
    4.17 集成测试+端到端验证 :crit, a4q, after a4p, 1d

    section 批次5 P6 硬化 5天
    5.1 Reconnect协议session_id+last_seq :a5a, after a4q, 2d
    5.2 Redis Stream替代Pub/Sub持久化 :a5b, after a5a, 2d
    5.3 测试覆盖率≥80% :crit, a5c, after a4q, 3d
    5.4 ADR+非功能性需求+失败模式章节(ISSUE-007) :a5d, after a4q, 2d
```

> **依赖锚点**：`b3a` = 批次 3 完成信号（content + data-ana 就绪，见 [workline.md](../workline.md) §1 批次 3）
> **总工期**：批次 0（2 天）+ 批次 4（13 天）+ 批次 5（5 天）= **20 天**

---

## §3 详细任务

### 3.1 批次 0：等待期（2 天）

#### 任务 0.1：复审 01/02 文档 + 核查已有仲裁

- **负责人**：ai02
- **依赖**：无
- **交付物**：
  - [objections/push-gateway_issue.md](../objections/push-gateway_issue.md) §0 核查矩阵
  - 01/02 文档审查结论（已汇报给用户）
- **验收标准**：
  - 5 项已有仲裁（ISSUE-053/055/056/058 + ARB /internal/push）核查完成
  - 01 文档 7 项偏差登记
  - 02 文档 4 项未回写 + 3 项规范缺失登记

#### 任务 0.2：回写 ISSUE-053/055/056/058 到 02 文档

- **负责人**：ai02
- **依赖**：任务 0.1
- **交付物**：02-architecture-design.md 修订
  - §5.1 topic 改为 `edu.notification.requested`（ISSUE-053）
  - §6.7 增 Kafka 软失败逻辑 + Redis 软失败（ISSUE-055/058，待 ISSUE-006 仲裁最终策略）
  - 新增 §5.4"设计决策记录：gRPC vs HTTP 协议选型（coord 已采纳 P1）"（ISSUE-056）
  - §3.1/§8.4 补 Hub 启动 Redis SET 重建 + 60s 不一致窗口文档化 + `push_gateway_redis_set_rebuild_total` 指标（ISSUE-058）
- **验收标准**：4 项裁决全部回写，[objections/push-gateway_issue.md](../objections/push-gateway_issue.md) §0 核查矩阵状态更新为 ✅

#### 任务 0.3：提请 ISSUE-001~007 待 coord 仲裁

- **负责人**：ai02
- **依赖**：任务 0.1
- **交付物**：[objections/push-gateway_issue.md](../objections/push-gateway_issue.md) §1 七项 issue
- **验收标准**：coord 在 [coord.md](../coord.md) 追加 ARB-003+ 仲裁章节

### 3.2 批次 4（P5）：完整实现（13 天）

#### 任务 4.1：Origin 校验 + CheckOrigin 白名单（P0，1 天）

- **负责人**：ai02
- **依赖**：批次 3 完成信号 `b3a`
- **交付物**：[internal/ws/handler.go](../../../services/push-gateway/internal/ws/handler.go) 修订
  - `upgrader.CheckOrigin` 从 `return true` 改为读 `WS_ALLOWED_ORIGINS` 环境变量白名单
  - 无 Origin 头拒绝
- **验收标准**：
  - 非白名单 Origin 返 403
  - 白名单来源（teacher/student/parent portal 域名）通过

#### 任务 4.2：/internal/* 鉴权对齐 X-Internal-Token（P0，1 天）

- **负责人**：ai02
- **依赖**：任务 4.1 + ISSUE-002 仲裁结果
- **交付物**：
  - [internal/ws/handler.go](../../../services/push-gateway/internal/ws/handler.go) `internalAPIKeyHeader` 改为 `X-Internal-Token`（待仲裁确认）
  - [internal/config/config.go](../../../services/push-gateway/internal/config/config.go) `InternalAPIKey` → `InternalAPIToken`，环境变量 `INTERNAL_API_TOKEN`
  - 错误码对齐 `PUSH_UNAUTHORIZED`
- **验收标准**：无 token/错 token 返 401 + `PUSH_UNAUTHORIZED`；DevMode 跳过

#### 任务 4.3：心跳改用 WebSocket 控制帧 + SetReadDeadline（P0，1 天）

- **负责人**：ai02
- **依赖**：任务 4.1
- **交付物**：[internal/ws/handler.go](../../../services/push-gateway/internal/ws/handler.go) 重构
  - 移除文本消息 `ping/pong` 逻辑（[handler.go#L82-L84](../../../services/push-gateway/internal/ws/handler.go#L82-L84)）
  - 改用 `conn.SetReadDeadline(60s)` + `conn.SetPongHandler` 
  - 客户端 Ping 控制帧 → gorilla 自动回 Pong
  - 60s 无任何消息则关闭连接
- **验收标准**：
  - 僵尸连接 60s 后自动清理
  - 心跳走 RFC 6455 控制帧，不再走文本消息

#### 任务 4.4：单用户连接数限制 MaxConn=5（P0，1 天）

- **负责人**：ai02
- **依赖**：任务 4.3
- **交付物**：[internal/hub/hub.go](../../../services/push-gateway/internal/hub/hub.go) 修订
  - Hub 增加 `counters map[string]int`（02 文档 §2）
  - `Register` 检查 `counters[userID] >= 5` 返 `ErrTooManyConnections`
  - 超限返 close 帧（code=1008 policy violation）
- **验收标准**：第 6 个连接被拒，错误码 `PUSH_TOO_MANY_CONNECTIONS` 429

#### 任务 4.5：Send 通道满时指标 + 日志（P0，1 天）

- **负责人**：ai02
- **依赖**：任务 4.4
- **交付物**：[internal/hub/hub.go](../../../services/push-gateway/internal/hub/hub.go) `Send` 方法修订
  - 通道满时 `slog.Warn` + `messages_dropped_total` Counter
- **验收标准**：`/metrics` 暴露 `push_gateway_messages_dropped_total{reason="channel_full"}`

#### 任务 4.6：Dockerfile 重构（P0，1 天）

- **负责人**：ai02
- **依赖**：批次 3 完成信号 `b3a`
- **交付物**：[Dockerfile](../../../services/push-gateway/Dockerfile) 重构
  - 多阶段（已有，保留）
  - 非 root 用户（`adduser -D appuser` + `USER appuser`）
  - healthcheck（`wget --spider http://localhost:8081/healthz`）
  - ldflags 优化（`-ldflags="-s -w -X main.Version=$(git rev-parse --short HEAD)"`）
  - go.mod 与 Dockerfile 版本对齐（1.25.0 vs golang:1.22-alpine）
- **验收标准**：`docker build` 通过，容器以非 root 运行，healthcheck 工作

#### 任务 4.7：引入 log/slog 替换标准 log（P0，1 天）

- **负责人**：ai02
- **依赖**：任务 4.6
- **交付物**：新增 `internal/observability/logger.go`
  - `slog.NewJSONHandler` + `slog.SetDefault`
  - 日志字段：`timestamp` `level` `service=push-gateway` `request_id` `trace_id` `user_id` `conn_id` `event`
  - main.go / handler.go / hub.go 替换所有 `log.Printf` 为 `slog`
- **验收标准**：日志输出 JSON 格式，包含 trace_id 字段

#### 任务 4.8：接入 shared-go（P0，1 天）

- **负责人**：ai02
- **依赖**：任务 4.7 + 批次 0.14（shared-go 骨架）
- **交付物**：
  - go.mod 引入 `github.com/edu-cloud/shared-go`
  - 替换本地 [observability/tracer.go](../../../services/push-gateway/internal/observability/tracer.go) 为 `shared-go/observability/tracer`
  - 引入 `shared-go/observability/logger`（替换任务 4.7 本地实现）
  - 引入 `shared-go/config/env`（替换 [config.go](../../../services/push-gateway/internal/config/config.go) `getEnv`）
  - 引入 `shared-go/auth/jwks`（任务 4.14 使用）
- **验收标准**：本地 tracer.go/logger.go 删除，统一从 shared-go import

#### 任务 4.9：Redis Pub/Sub 跨实例广播（P1，2 天）

- **负责人**：ai02
- **依赖**：任务 4.8
- **交付物**：新增 `internal/redis/pubsub.go` + Hub 改造
  - 订阅 `edu.push.channel.user.*` + `edu.push.channel.broadcast`
  - 本实例无目标用户时 PUBLISH 到对应 channel
  - 持有该用户的实例订阅后投递到本地连接
  - 引入 `github.com/redis/go-redis/v9` 依赖
- **验收标准**：
  - 双实例部署，msg 调实例 A `/internal/push user=B`，实例 B 持有 B → 收到推送
  - 广播 PUBLISH 一次，所有实例投递本地连接

#### 任务 4.10：Redis SET 在线状态 + 启动重建（P1，1 天）

- **负责人**：ai02
- **依赖**：任务 4.9
- **交付物**：Hub 改造（对齐 ISSUE-058）
  - 连接建立：`SADD edu:push:online:<userID> <instanceID>` + `EXPIRE 60s`
  - 心跳续期：`EXPIRE 60s`
  - 连接断开：`SREM` + 空 SET 则 `DEL`
  - **Hub 启动重建**：遍历内存连接 SADD + EXPIRE；先清空 Redis 中本 instanceID 旧成员（避免幽灵）
  - 实例崩溃 SET 自然过期（60s）
- **验收标准**：
  - 实例重启后 60s 内 Redis SET 重建完成
  - `push_gateway_redis_set_rebuild_total` 指标暴露

#### 任务 4.11：/metrics 自定义指标 + /readyz 软失败（P1，1 天）

- **负责人**：ai02
- **依赖**：任务 4.10
- **交付物**：新增 `internal/observability/metrics.go` + /readyz 重构
  - 指标清单（02 文档 §6.4）：`active_connections` `messages_pushed_total` `messages_dropped_total` `heartbeat_total` `disconnect_total` `redis_pubsub_latency_seconds` `kafka_consumed_total` `redis_set_rebuild_total`
  - /readyz 检查 Redis PING + Kafka consumer lag
  - Redis/Kafka 软失败（ISSUE-055/058，待 ISSUE-006 仲裁）：返 200 + `degraded: true`
- **验收标准**：
  - `/metrics` 暴露 8+ 自定义指标
  - Redis 故障时 /readyz 返 200 + `degraded: true`（不返 503）

#### 任务 4.12：优雅关闭所有 WebSocket 连接（P1，1 天）

- **负责人**：ai02
- **依赖**：任务 4.11
- **交付物**：[main.go](../../../services/push-gateway/main.go) + Hub 改造
  - Hub 新增 `CloseAll()` 方法，向所有连接发 close 帧（code=1001 going away）
  - SIGTERM → 标记 Hub closing（拒新连接）→ CloseAll → 等 10s → srv.Shutdown → 关 Kafka consumer → 关 Redis subscriber → tracerShutdown
- **验收标准**：SIGTERM 后所有连接收到 close 帧，无连接泄漏

#### 任务 4.13：Kafka 消费 edu.notification.requested（P2，2 天）

- **负责人**：ai02
- **依赖**：任务 4.10 + msg 就绪信号
- **交付物**：新增 `internal/kafka/consumer.go`
  - Consumer Group `push-gateway`
  - 订阅 `edu.notification.requested`（ISSUE-053 裁决的 topic 名）
  - 至少一次 + 重试 3 次入 DLQ
  - 幂等：`event_id` Redis SETNX TTL 24h
  - 消费 → 调 Hub.SendToUser / Broadcast
- **验收标准**：
  - msg 发布 `NotificationRequested` → push-gateway 消费 → 推送到在线客户端
  - 重复 event_id 不重投

#### 任务 4.14：JWT RS256 升级 + JWKS fetcher（P2，1 天）

- **负责人**：ai02
- **依赖**：任务 4.8（shared-go/jwks）+ iam 就绪信号
- **交付物**：[internal/ws/handler.go](../../../services/push-gateway/internal/ws/handler.go) `authenticate` 重构
  - 移除 HS256 共享密钥校验
  - 改用 RS256：通过 `shared-go/auth/jwks` 拉取 iam `/.well-known/jwks.json` 公钥
  - 缓存公钥 + 定期刷新（5 分钟）
- **验收标准**：
  - iam 签发的 RS256 JWT 通过校验
  - 公钥轮换后 5 分钟内生效

#### 任务 4.15：设计决策记录章节回写（P2，1 天）

- **负责人**：ai02
- **依赖**：任务 4.13
- **交付物**：02-architecture-design.md 新增 §5.4（ISSUE-056）
  - 标题："设计决策记录：gRPC vs HTTP 协议选型（coord 已采纳 P1）"
  - 正文标注"coord 已采纳，见 coord-final-decisions P1/P5/P6"
  - 记录决策背景、方案对比、采纳理由
- **验收标准**：章节存在且标注正确

#### 任务 4.16：与 msg 联调 /internal/push 双通道（P2，1 天）

- **负责人**：ai02
- **依赖**：任务 4.13 + 任务 4.14 + msg 就绪
- **交付物**：联调测试报告
  - 定向推送：msg → HTTP /internal/push → push-gateway → WebSocket
  - 广播：msg → Kafka NotificationRequested → push-gateway → 全在线客户端
  - 离线场景：`delivered: false, online: false` → msg 走 SMS/邮件
- **验收标准**：[matrix.md](../matrix.md) §9.5 推送链路检查清单全通过

#### 任务 4.17：集成测试 + 端到端验证（P2，1 天）

- **负责人**：ai02
- **依赖**：任务 4.16
- **交付物**：集成测试用例 + 端到端验证报告
  - 单实例 1000 连接压测
  - 双实例跨实例推送验证
  - Redis 故障降级验证
  - Kafka 消费积压验证
- **验收标准**：
  - [workline.md](../workline.md) §8 push-gateway 行更新为 ✅
  - [matrix.md](../matrix.md) §8 push-gateway 就绪信号 ✅

### 3.3 批次 5（P6）：硬化（5 天）

#### 任务 5.1：Reconnect 协议（2 天）

- **负责人**：ai02
- **依赖**：批次 4 完成
- **交付物**：02 文档 §7.3 落地
  - 首次连接返 `{type:"hello", session_id, seq:0}`
  - 每条推送带递增 `seq`
  - 重连 `/ws?token=&session_id=&last_seq=` → 从 msg 拉取 `last_seq+1` 到当前补推
- **验收标准**：客户端断线 30s 内重连，未送达消息补推成功

#### 任务 5.2：Redis Stream 替代 Pub/Sub（2 天）

- **负责人**：ai02
- **依赖**：任务 5.1
- **交付物**：`internal/redis/stream.go`
  - Pub/Sub → Redis Stream（持久化）
  - Consumer Group `push-gateway`
  - ACK 机制：投递成功后 XACK
  - 崩溃恢复：未 ACK 消息重新投递
- **验收标准**：实例崩溃时未投递消息不丢失（msg 落库兜底 + Stream 持久化双保险）

#### 任务 5.3：测试覆盖率 ≥ 80%（3 天，可与 5.1/5.2 并行）

- **负责人**：ai02
- **依赖**：批次 4 完成
- **交付物**：
  - `hub_test.go`：注册/注销/推送/广播/连接数限制
  - `ws_test.go`：鉴权/心跳/Origin 校验
  - `config_test.go`：环境变量加载
  - `redis_test.go`：Pub/Sub + SET 重建
  - `kafka_test.go`：消费 + 幂等
- **验收标准**：`go test -cover ./...` ≥ 80%

#### 任务 5.4：ADR + 非功能性需求 + 失败模式章节补全（2 天，可与 5.1/5.2 并行）

- **负责人**：ai02
- **依赖**：批次 4 完成 + ISSUE-007 仲裁
- **交付物**：02-architecture-design.md 新增章节
  - §14 ADR（Architecture Decision Records）
    - ADR-001：gorilla/websocket 选型（vs nhooyr/websocket）
    - ADR-002：Redis Pub/Sub vs Stream（P5 Pub/Sub → P6 Stream 演进）
    - ADR-003：心跳间隔 30s/60s 选型依据
    - ADR-004：10w 容量依据（goroutine-per-connection 内存估算）
    - ADR-005：HTTP + Kafka 双通道（vs 单通道）
  - §15 非功能性需求（可用性 SLO 99.9% / 安全合规 / 容量 SLA）
  - §16 失败模式（实例崩溃 / Redis 故障 / 网络分区 / Kafka 积压降级）
  - §17 容量估算（10w 连接内存/CPU/带宽）
- **验收标准**：4 章节齐全，符合 arc42 规范

---

## §4 依赖与就绪信号

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

| 依赖项 | 提供方 | 就绪信号 | 当前状态 | 影响任务 |
| ------ | ------ | -------- | -------- | -------- |
| shared-go 包骨架 | coord（批次 0.14） | `packages/shared-go` 含 tracer/logger/jwks/env 4 模块 | ⏳ | 任务 4.8 |
| iam JWT RS256 + JWKS 端点 | ai06（批次 1） | iam gRPC 50052 + `/.well-known/jwks.json` 可访问 | ⏳ | 任务 4.14 |
| msg gRPC + Kafka topic | ai10（批次 4） | msg gRPC 50056 + `edu.notification.requested` topic 有事件 | ⏳ | 任务 4.13/4.16 |
| Redis 基础设施 | coord（P1） | Redis 7.x 可访问 | ✅ P1 已就绪 | 任务 4.9/4.10 |
| Kafka 基础设施 | coord（P1） | Kafka 可访问 | ✅ P1 已就绪 | 任务 4.13 |
| ISSUE-001~007 仲裁 | coord | [coord.md](../coord.md) 追加 ARB-003+ | ⏳ | 任务 4.2/4.11/4.15/5.4 |

### 4.2 我的就绪信号（供下游消费）

| 就绪标志 | 验证方式 | 供消费方 |
| -------- | -------- | -------- |
| push-gateway HTTP :8081 启用 | `GET /healthz` 返 200 | k8s 探针 / 监控 |
| /readyz 返 200（含 Redis/Kafka 软失败检查） | `GET /readyz` 返 200 + `degraded` 字段 | k8s 探针 |
| WebSocket /ws 端点可升级（JWT RS256 鉴权） | 客户端 `ws://host:8081/ws?token=JWT` 建立连接 | teacher-portal / student-portal / parent-portal |
| /internal/push + /internal/broadcast 接收 msg 推送 | msg 调用返 `{success:true, delivered:true}` | msg (ai10) |
| /internal/online/<userID> 查在线状态 | 返 `{online:bool, instances:[]}` | msg (ai10) |
| Kafka consumer `edu.notification.requested` 订阅成功 | Consumer Group `push-gateway` lag=0 | msg (ai10) |
| /metrics 暴露 8+ 自定义指标 | `GET /metrics` 含 `push_gateway_*` 指标 | Prometheus |

### 4.3 Mock 策略（开发期间）

**我提供的 mock**（push-gateway 未就绪前，供前端 portal）：
- WebSocket mock：前端用 mock-socket 库模拟 WS 连接，每 30s 推 1 条 mock 通知
- HTTP mock：/internal/* 返 200 success

**我消费的 mock**（上游未就绪前）：
- NotificationEvent mock：msg 未就绪前，push-gateway 内置定时器每 30s 生成 mock 事件推所有在线客户端
- JWT 验签 mock：iam 未就绪前使用本地固定 RS256 公钥（或 DevMode dev-token）
- Kafka 订阅 mock：msg 未就绪前不启动 Kafka consumer，用本地定时器替代

---

## §5 风险与缓解

| 风险 | 概率 | 影响 | 缓解措施 |
| ---- | ---- | ---- | -------- |
| ISSUE-001~007 仲裁延迟 | 中 | 阻塞批次 4 启动 | ai02 先按建议方案推进，仲裁结果出来后调整 |
| msg 就绪延迟 | 中 | 阻塞任务 4.13/4.16 | 用 mock NotificationEvent 先完成 Kafka 消费逻辑 |
| 10w 连接压测不达标 | 低 | 容量目标降级 | P6 阶段压测，若不达标则 horizontal scaling 兜底 |
| Redis Pub/Sub 消息丢失 | 中 | 跨实例推送丢失 | msg 落库兜底 + P6 升级 Redis Stream |
| shared-go 接口变更 | 低 | 任务 4.8 返工 | 紧跟 coord 0.14 任务，接口冻结后立即对接 |
