# 模块架构设计文档 — push-gateway

> 阶段：阶段 2 交付物（v1.0 仲裁回写于 2026-07-10）
> 日期：2026-07-09（v0.1）/ 2026-07-10（v1.0）
> 关联：[01 理解确认书](./01-understanding.md)、[004 架构影响地图](../../../docs/architecture/004_architecture_impact_map.md)、[president-final-rulings](../../../docs/architecture/president-final-rulings.md)
>
> 本文档覆盖：WebSocket 连接生命周期、与 msg 的 HTTP 推送通道协议、用户 session 映射、水平扩展方案（Redis Pub/Sub 跨实例广播）。仲裁结果见 §14 ADR。

---

## 1. 模块内部分层图

```mermaid
graph TB
    subgraph Client["客户端"]
        B[浏览器/移动端]
    end

    subgraph External["外部入口层"]
        WS[WebSocket Upgrader<br/>/ws?token=JWT]
    end

    subgraph MW["中间件链"]
        M1[Recovery]
        M2[OTelgin]
        M3[RequestID]
        M4[OriginGuard<br/>CheckOrigin 白名单]
        M5[InternalAuth<br/>/internal/* 校验]
    end

    subgraph Hub["Hub 连接池（核心）"]
        H[Hub<br/>map userID -> map connID -> Connection]
        Conn[Connection<br/>userID + ws + send chan + heartbeat]
    end

    subgraph Writer["写协程池"]
        W[每 Connection 一个写协程<br/>消费 send chan 写 ws]
    end

    subgraph Reader["读协程"]
        R[每 Connection 一个读协程<br/>处理 Ping 控制帧 + 消息]
    end

    subgraph PushAPI["内部推送 API"]
        P1[POST /internal/push]
        P2[POST /internal/broadcast]
    end

    subgraph Redis["Redis Pub/Sub（横向扩展）"]
        Sub[Subscriber<br/>订阅 edu.push.user.* + edu.push.broadcast]
        Pub[Publisher<br/>本实例无此用户时 PUBLISH]
    end

    subgraph Kafka["Kafka 消费者（可选）"]
        K[Consumer Group<br/>topic: edu.notification.requested]
    end

    subgraph Msg["msg 服务"]
        MSG[msg :3007]
    end

    B -->|WebSocket| WS
    WS --> M1 --> M2 --> M3 --> M4 --> H
    H --> Conn
    Conn --> W
    Conn --> R
    R -->|Ping| Conn
    MSG -->|HTTP /internal/push| P1
    MSG -->|HTTP /internal/broadcast| P2
    P1 --> H
    P2 --> H
    P1 -.本地无此用户.-> Pub --> Sub
    Sub -.广播到本实例.-> H
    MSG -.NotificationRequested 事件.-> K
    K --> H

    style Redis fill:#fff4e6
    style Kafka fill:#fff4e6
```

**关键设计决策**：

- **Hub 是单例**，进程内唯一，持有所有连接
- **每 Connection 两个 goroutine**：读协程（消费 ws）+ 写协程（消费 send chan）
- **Redis Pub/Sub 解决跨实例广播**：本实例无目标用户时 PUBLISH 到 `edu.push.user.<userID>` channel，所有实例订阅此 channel，持有该用户的实例收到后投递
- **Kafka 消费可选**：广播类通知走 Kafka 解耦；定向推送走 HTTP `/internal/push`（同步返结果）

## 2. 领域模型

无业务聚合。仅连接管理值对象：

```go
// Connection 包装单个 WebSocket 连接
type Connection struct {
    UserID    string
    ConnID    string                 // 连接唯一 ID（uuid）
    conn      *websocket.Conn
    send      chan []byte             // 异步发送通道（cap 64）
    closeOnce sync.Once
    closed    bool
    mu        sync.Mutex
    lastPing  time.Time              // 最后心跳时间
}

// Hub 管理所有在线连接
type Hub struct {
    mu       sync.RWMutex
    clients  map[string]map[string]*Connection  // userID -> connID -> Connection
    counters map[string]int                       // userID -> 连接计数（快速判断超限）
}
```

**不变式**：

- `Hub.clients[userID][connID]` 一一对应 Connection
- `Connection.send` channel 关闭后不可再写
- `Connection.closed` 为 true 后所有操作 no-op

## 3. 数据模型

**无数据库**。所有状态在内存 + Redis。

### 3.1 Redis 数据结构

| Key 模式                             | 类型                       | TTL             | 用途                                         |
| ------------------------------------ | -------------------------- | --------------- | -------------------------------------------- |
| `edu:push:online:<userID>`           | SET（成员是 `instanceID`） | 60s（心跳续期） | 在线用户所在实例集合（用于判断用户是否在线） |
| `edu:push:session:<userID>:<connID>` | HASH                       | 60s             | 单连接元数据（instanceID, device, lastPing） |
| `edu:push:channel:user:<userID>`     | Pub/Sub channel            | —               | 跨实例定向推送 channel                       |
| `edu:push:channel:broadcast`         | Pub/Sub channel            | —               | 跨实例广播 channel                           |

### 3.2 在线状态判定

```
用户在线 := Redis SET edu:push:online:<userID> 非空
用户离线 := SET 为空或不存在
```

**续期机制**：每 30s 客户端心跳时，本实例 SADD 自己 + EXPIRE 60s。

## 4. API 设计

### 4.1 WebSocket 端点

| 方法 | 路径  | 鉴权                                              | 说明           |
| ---- | ----- | ------------------------------------------------- | -------------- |
| GET  | `/ws` | JWT（query `?token=` 或 `Authorization: Bearer`） | WebSocket 升级 |

**WebSocket 子协议（应用层消息格式）**：

```json
// 服务端 → 客户端：推送消息
{
  "type": "message",
  "event": "notification.created",
  "data": { ... },
  "seq": 12345,                  // 全局递增序列号（用于 reconnect）
  "timestamp": "2026-07-09T..."
}

// 服务端 → 客户端：确认
{ "type": "ack", "seq": 12345 }

// 客户端 → 服务端：心跳（WebSocket Ping 控制帧，非文本消息）
// 客户端 → 服务端：消息确认
{ "type": "ack", "seq": 12345 }
```

### 4.2 内部推送 API

| 方法 | 路径                        | 鉴权                  | 请求体                         | 响应                            |
| ---- | --------------------------- | --------------------- | ------------------------------ | ------------------------------- |
| POST | `/internal/push`            | `X-Internal-Token` 头 | `{user_id, event, data, ttl?}` | `{success, delivered, online}`  |
| POST | `/internal/broadcast`       | `X-Internal-Token` 头 | `{event, data, filter?}`       | `{success, reached}`            |
| GET  | `/internal/online/<userID>` | `X-Internal-Token` 头 | —                              | `{online: bool, instances: []}` |

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

- 启动时从 `INTERNAL_API_TOKEN` 环境变量加载
- 与 msg 服务共享同一密钥（K8s Secret 注入）
- 缺失/不匹配 → 401

**响应语义**：

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

### 4.3 健康检查

| 端点       | 检查逻辑                                                                                                       |
| ---------- | -------------------------------------------------------------------------------------------------------------- |
| `/healthz` | 进程存活，返回 200                                                                                             |
| `/readyz`  | Redis PING + Kafka 元数据探活；**软失败**（ISSUE-058/006）：失败返 200 + `degraded: true`，仅 Hub 关闭时返 503 |

### 4.4 指标端点

| 端点       | 暴露指标 |
| ---------- | -------- |
| `/metrics` | 见 §6.4  |

## 5. 事件设计

### 5.1 我消费的 Kafka 事件

| Topic                        | Message                 | 触发场景                   | 消费动作                                                              |
| ---------------------------- | ----------------------- | -------------------------- | --------------------------------------------------------------------- |
| `edu.notification.requested` | `NotificationRequested` | msg 服务收到通知请求后发布 | 消费 → 调用 Hub.SendToUser 投递 → 若离线则 ACK 不重投（msg 服务落库） |

> **Topic 命名变更（ISSUE-053 / president §1.5）**：原名 `edu.notification.events` 不符合 G16 规则（`edu.<domain>.<aggregate>.<action>`），已改为 `edu.notification.requested`。旧名作废。

**消费语义**：

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

### 5.2 我发布的 Kafka 事件

**无**。推送结果通过 HTTP `/internal/push` 同步响应返 msg 服务。

### 5.3 与 msg 服务的双通道协议

```mermaid
sequenceDiagram
    participant Msg as msg 服务
    participant GW as push-gateway
    participant Hub as Hub
    participant Redis as Redis Pub/Sub
    participant Client as 客户端

    rect rgb(240, 248, 255)
        Note over Msg,Client: 通道 1：HTTP 同步推送（定向）
        Msg->>GW: POST /internal/push {user_id, event, data}
        GW->>Hub: SendToUser(user_id, msg)
        alt 本实例有此用户
            Hub->>Client: WebSocket 推送
            Client-->>Hub: ack
            Hub-->>GW: delivered=true
        else 本实例无此用户
            GW->>Redis: PUBLISH edu.push.channel.user.<userID> msg
            Note over Redis: 持有该用户的实例订阅后投递
            Redis-->>GW: 投递结果（异步，通过另一个 ack channel）
            GW-->>Msg: delivered=true, online=true
        end
    end

    rect rgb(240, 255, 240)
        Note over Msg,Client: 通道 2：Kafka 异步消费（广播）
        Msg->>Msg: 发布 NotificationRequested 事件到 Kafka
        Note over GW: Consumer Group 异步消费
        GW->>Hub: Broadcast(msg)
        Hub->>Client: WebSocket 推送（所有在线连接）
    end
```

**双通道选择规则**（msg 服务决策）：

- 定向推送（单用户/少数用户）→ HTTP `/internal/push`（需立即知道在线/离线）
- 广播（全校/全年级）→ Kafka 事件（解耦 + 削峰）
- 紧急通知（考试开始）→ HTTP `/internal/push`（实时性优先）

## 6. 横切关注点对齐清单

### 6.1 权限校验

| 端点                            | 鉴权方式                                              |
| ------------------------------- | ----------------------------------------------------- |
| `/ws`                           | JWT RS256（P2 起，与 api-gateway 共用 JWKS 拉取逻辑） |
| `/internal/*`                   | `X-Internal-Token` 头校验                             |
| `/healthz` `/readyz` `/metrics` | 无                                                    |

### 6.2 错误码清单

| 错误码                      | 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 不可达   |

### 6.3 Logger

```go
// internal/observability/logger.go（待新增）
var Logger *slog.Logger

func InitLogger(level string) {
    Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(level)}))
    slog.SetDefault(Logger)
}
```

**日志字段**：`timestamp` `level` `service=push-gateway` `request_id` `trace_id` `user_id` `conn_id` `event`

### 6.4 Metrics 指标清单

| 指标名                                      | 类型      | 标签             | 描述                                              |
| ------------------------------------------- | --------- | ---------------- | ------------------------------------------------- |
| `push_gateway_active_connections`           | Gauge     | —                | 当前在线连接数                                    |
| `push_gateway_connections_per_user_max`     | Gauge     | —                | 单用户最大连接数（运行期峰值）                    |
| `push_gateway_messages_pushed_total`        | Counter   | event, result    | 推送消息总数（result=delivered/dropped/offline）  |
| `push_gateway_messages_dropped_total`       | Counter   | reason           | 丢弃消息数（reason=channel_full/buffer_overflow） |
| `push_gateway_heartbeat_total`              | Counter   | —                | 心跳接收总数                                      |
| `push_gateway_disconnect_total`             | Counter   | reason           | 断开连接数（reason=idle/error/closed/too_many）   |
| `push_gateway_redis_pubsub_latency_seconds` | Histogram | direction        | Pub/Sub 延迟（publish/subscribe）                 |
| `push_gateway_kafka_consumed_total`         | Counter   | topic, partition | Kafka 消费数                                      |
| `push_gateway_redis_set_rebuild_total`      | Counter   | —                | Redis online SET 重建次数（ISSUE-058 启动重建）   |
| `go_*`                                      | —         | —                | prom-client 默认 Go runtime 指标                  |

### 6.5 Tracer

已实现 OTel tracer。**待补**：

- WebSocket 升级 span（`ws.upgrade`）
- 消息推送 span（`push.message`，标签 user_id/event）
- Redis Pub/Sub span（`redis.publish` / `redis.subscribe`）

### 6.6 /healthz 检查逻辑

```go
func Healthz(c *gin.Context) {
    c.JSON(200, gin.H{
        "status":    "ok",
        "service":   "push-gateway",
        "version":   Version,
        "timestamp": time.Now().UTC().Format(time.RFC3339),
        "connections": hub.ActiveConnections(),
    })
}
```

### 6.7 /readyz 检查逻辑（软失败 — ISSUE-058/006）

```go
// internal/health/readyz.go
// Redis/Kafka 不可达时不返 503，而是 200 + degraded:true，
// 避免 K8s 在依赖短暂抖动时驱逐 Pod（ARB-015 §17.4）。
// 仅 Hub.CloseAll 触发的 closing 状态返 503（优雅关闭期间）。
func (rz *Readyzer) Handler(c *gin.Context) {
    if rz.hub.IsClosing() {
        c.JSON(503, readyzResponse{Status: "shutting_down", Degraded: true, ...})
        return
    }
    deps := map[string]*dependencyStatus{}
    degraded := false
    // Redis 软探活
    if err := rz.redis.Ping(ctx); err != nil {
        deps["redis"] = &dependencyStatus{Ok: false, Error: err.Error()}
        degraded = true
    }
    // Kafka 软探活
    if err := rz.kafka.HealthCheck(ctx); err != nil {
        deps["kafka"] = &dependencyStatus{Ok: false, Error: err.Error()}
        degraded = true
    }
    c.JSON(200, readyzResponse{Status: statusText(degraded), Degraded: degraded, ...})
}
```

### 6.8 优雅关闭顺序

```go
// 1. 收到 SIGTERM
// 2. 标记 Hub 为 closing（拒绝新连接）
// 3. 向所有在线连接发送 close 帧（code=1001 going away）
// 4. 等待所有连接关闭（10s 超时）
// 5. srv.Shutdown(ctx) 停止 HTTP 服务
// 6. 关闭 Kafka consumer（提交 offset）
// 7. 关闭 Redis subscriber
// 8. tracerShutdown() flush span
// 9. os.Exit(0)
```

## 7. WebSocket 连接生命周期（核心设计）

```mermaid
stateDiagram-v2
    [*] --> Connecting: 客户端 GET /ws?token=
    Connecting --> Authenticating: Upgrade 成功
    Authenticating --> Rejected: JWT 无效
    Rejected --> [*]: 返回 401
    Authenticating --> Connected: JWT 有效
    Connected --> Heartbeating: 注册到 Hub
    Heartbeating --> Heartbeating: 收到 Ping → 回 Pong
    Heartbeating --> Reconnecting: 客户端断线
    Heartbeating --> Closing: 服务端 SIGTERM
    Heartbeating --> IdleTimeout: 60s 无心跳
    IdleTimeout --> Closing: 服务端主动关闭
    Reconnecting --> Connected: 客户端重连（带 session_id + last_seq）
    Closing --> [*]: 发送 close 帧 + 注销 Hub
```

### 7.1 心跳协议（RFC 6455 控制帧）

**规则**：

- 客户端每 30s 发送 WebSocket Ping 控制帧
- 服务端自动回 Pong（gorilla/websocket 默认行为）
- 服务端 `conn.SetReadDeadline(60s)`：60s 未收到任何消息（含 Ping）则关闭连接
- 服务端每 30s 检查 `lastPing`，超过 60s 主动关闭

**不使用文本消息 `ping/pong`**（当前骨架的实现，需重构）。

### 7.2 单用户连接数限制

```go
const MaxConnectionsPerUser = 5

func (h *Hub) Register(userID string, conn *websocket.Conn) (*Connection, error) {
    h.mu.Lock()
    defer h.mu.Unlock()
    if h.counters[userID] >= MaxConnectionsPerUser {
        return nil, ErrTooManyConnections
    }
    // ... 注册逻辑
}
```

**超限策略**：拒绝新连接，返回 close 帧（code=1008 policy violation），客户端应处理为"已达到最大连接数"。

### 7.3 Reconnect 协议（P6 硬化项，本期仅预留）

**协议**：

- 客户端首次连接时，服务端返回 `{type:"hello", session_id:"<uuid>", seq:0}`
- 后续每条推送消息带 `seq` 递增
- 客户端断线重连时，`/ws?token=&session_id=<id>&last_seq=<n>`
- 服务端从 msg 服务拉取 `last_seq+1` 到当前的所有未送达消息补推

**本期不实现**，仅协议预留。

### 7.4 Origin 校验

```go
var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        origin := r.Header.Get("Origin")
        if origin == "" {
            return false // 无 Origin 拒绝
        }
        for _, allowed := range allowedOrigins {
            if origin == allowed {
                return true
            }
        }
        return false
    },
}
```

**白名单来源**：环境变量 `WS_ALLOWED_ORIGINS`（逗号分隔）。

## 8. 横向扩展方案（Redis Pub/Sub）

### 8.1 多实例部署架构

```mermaid
graph TB
    subgraph LB["负载均衡器"]
        L[Sticky Session 或 IP Hash<br/>保证同一用户路由到同一实例]
    end

    subgraph Instances["push-gateway 实例集群"]
        I1[Instance 1<br/>持有用户 A,B,C]
        I2[Instance 2<br/>持有用户 D,E,F]
        I3[Instance 3<br/>持有用户 G,H,I]
    end

    subgraph Redis["Redis Pub/Sub"]
        R[(Redis<br/>edu.push.channel.user.*<br/>edu.push.channel.broadcast)]
    end

    subgraph Msg["msg 服务"]
        M[msg]
    end

    M -->|HTTP /internal/push user=A| I1
    M -->|HTTP /internal/push user=D| I2
    M -->|HTTP /internal/push user=Z 不知在哪| R
    R --> I1
    R --> I2
    R --> I3
    M -->|Kafka broadcast| K[Kafka]
    K --> I1
    K --> I2
    K --> I3
```

### 8.2 跨实例推送流程

1. msg 服务调本实例 `/internal/push {user_id: A}`
2. 本实例 Hub 查 `clients[A]`：
   - 命中 → 直接投递
   - 未命中 → 查 Redis `edu:push:online:A`：
     - 不存在 → 用户离线，返 `delivered:false, online:false`
     - 存在 → PUBLISH 到 `edu.push.channel.user.A`，所有实例订阅此 channel
3. 持有用户 A 的实例收到 Pub/Sub 消息 → 投递到本地连接 → 通过另一 ack channel 返结果
4. 本实例收到 ack → 返 msg 服务 `delivered:true`

### 8.3 广播优化

- **不遍历所有用户**：直接 PUBLISH 到 `edu.push.channel.broadcast`
- 所有实例订阅此 channel，收到后遍历本地连接投递
- 单实例广播成本 O(local_connections)，集群总成本 O(sum_connections)

### 8.4 在线状态一致性

- Redis SET `edu:push:online:<userID>` 存储用户所在实例集合
- 实例启动时生成唯一 `instanceID`（uuid）
- 连接建立时 SADD 自己到 SET，EXPIRE 60s
- 心跳续期时 EXPIRE 60s
- 连接断开时 SREM 自己；若 SET 空，DEL key
- 实例崩溃时 SET 自然过期（60s）

## 9. 与其他模块的交互点（契约清单）

| 方向   | 对方服务   | 协议      | 接口/事件                                              | 用途                             |
| ------ | ---------- | --------- | ------------------------------------------------------ | -------------------------------- |
| 被调用 | msg        | HTTP      | `POST /internal/push`                                  | 定向推送                         |
| 被调用 | msg        | HTTP      | `POST /internal/broadcast`                             | 广播                             |
| 被调用 | msg        | HTTP      | `GET /internal/online/<userID>`                        | 查在线状态                       |
| 消费   | msg        | Kafka     | `edu.notification.requested` / `NotificationRequested` | 异步广播通知（ISSUE-053 重命名） |
| 调用   | iam        | HTTP      | `GET /.well-known/jwks.json`                           | RS256 公钥（P2）                 |
| 调用   | Redis      | Redis     | Pub/Sub + SET                                          | 跨实例广播 + 在线状态            |
| 被调用 | 微前端     | WebSocket | `/ws`                                                  | 长连接                           |
| 被调用 | K8s/Docker | HTTP      | `/healthz` `/readyz`                                   | 探针                             |
| 被调用 | Prometheus | HTTP      | `GET /metrics`                                         | 指标采集                         |

## 10. 风险与假设

- **假设**：Redis 在 P5 部署时可用（P1 已就绪）
- **假设**：msg 服务 P5 实现 NotificationRequested 事件发布
- **假设**：iam P2 暴露 JWKS 端点
- **风险**：单实例内存连接池上限受 Go runtime 内存限制（10w 连接约 1-2GB）
- **风险**：Redis Pub/Sub 是 fire-and-forget，实例崩溃时未投递的消息丢失（msg 服务落库兜底）
- **风险**：跨实例 ack 链路复杂，可能引入延迟（建议 P6 用 Redis Stream 替代 Pub/Sub 实现持久化）
- **未决**：reconnect 协议是否本期实现？建议本期仅协议预留，P6 实现
- **未决**：是否在 push-gateway 做 SSE 支持？建议不支持，WebSocket 已够用，避免协议膨胀

## 11. 容量与性能目标

| 指标               | 目标    | 实现方式                            |
| ------------------ | ------- | ----------------------------------- |
| 单实例最大连接数   | 10w+    | goroutine per connection + 内存优化 |
| 推送延迟（本实例） | < 50ms  | 异步 send chan                      |
| 推送延迟（跨实例） | < 200ms | Redis Pub/Sub                       |
| 心跳间隔           | 30s     | 客户端 Ping                         |
| 空闲超时           | 60s     | SetReadDeadline                     |
| 单用户最大连接数   | 5       | Hub.counters 限制                   |
| 消息体上限         | 64KB    | WebSocket 消息大小限制              |

## 12. 实施优先级

| 优先级 | 任务                                              | 阶段           |
| ------ | ------------------------------------------------- | -------------- |
| P0     | Origin 校验（CheckOrigin 白名单）                 | P5 立即        |
| P0     | `/internal/*` 鉴权（X-Internal-Token）            | P5 立即        |
| P0     | 心跳改用 WebSocket 控制帧 + SetReadDeadline       | P5 立即        |
| P0     | 单用户连接数限制                                  | P5 立即        |
| P0     | Send 通道满时记录指标+日志                        | P5 立即        |
| P0     | Dockerfile 重构（多阶段 + 非 root + healthcheck） | P5 立即        |
| P0     | 引入 `log/slog` 替换标准 log                      | P5 立即        |
| P1     | Redis Pub/Sub 跨实例广播                          | P5             |
| P1     | `/metrics` 端点 + prom-client                     | P5             |
| P1     | `/readyz` 真实健康检查（Redis PING）              | P5             |
| P1     | 优雅关闭所有 WebSocket 连接                       | P5             |
| P2     | Kafka 消费 `edu.notification.events`              | P5             |
| P2     | JWT RS256 升级（与 api-gateway 共用 JWKS）        | P5（依赖 iam） |
| P2     | 在线状态 Redis SET 维护                           | P5             |
| P3     | Reconnect 协议（session_id + last_seq）           | P6             |
| P3     | Redis Stream 替代 Pub/Sub（持久化）               | P6             |
| P3     | 测试覆盖率 ≥ 80%                                  | P6             |

## 13. 与 api-gateway 共用的横切代码

为避免重复实现，建议以下模块提取到 `packages/shared-go/`（由 coord 维护，ai01 提交需求）：

| 模块                      | 用途                | 当前位置                  |
| ------------------------- | ------------------- | ------------------------- |
| `observability/tracer.go` | OTel tracer 初始化  | 两服务各有一份，完全相同  |
| `observability/logger.go` | slog 结构化日志     | 待新增（两服务共用）      |
| `auth/jwks.go`            | JWKS 公钥拉取与缓存 | 待新增（两服务共用，P2）  |
| `config/env.go`           | 环境变量加载工具    | 两服务各有 getEnv，可统一 |

**提交方式**：通过 `# proto-change` 渠道向 coord 声明需求，coord 在 `shared-go` 中建立，ai01 在本服务中改为 import。

---

## 14. 架构决策记录（ADR）

> 本节沉淀所有 ISSUE 仲裁结果与设计决策，作为未来变更的决策源。每条 ADR 引用对应的 ISSUE / president ruling / ARB 编号。

### ADR-001 仅 WebSocket，不支持 SSE（ISSUE-001）

- **决策**：push-gateway 仅实现 WebSocket 长连接通道，不提供 SSE（Server-Sent Events）端点。
- **理由**：WebSocket 双向通信能力更强（服务端可主动推送 + 客户端可发 ack/heartbeat），单协议栈降低运维成本；SSE 单向推送且浏览器连接数受限（HTTP/1.1 下 6 个/域名）。微前端已统一使用 WebSocket 客户端。
- **影响**：不支持仅支持 SSE 的旧客户端；客户端必须实现 WebSocket Ping 心跳。
- **状态**：accepted（president-final-rulings §1.1）

### ADR-002 X-Internal-Token 命名（ISSUE-002 / ARB-015 §17.3）

- **决策**：`/internal/*` API 鉴权头从 `X-Internal-Key` 改名为 `X-Internal-Token`，环境变量从 `INTERNAL_API_KEY` 改为 `INTERNAL_API_TOKEN`。
- **理由**：与项目其他服务（api-gateway、msg）的内部鉴权头命名保持一致；"Token" 更准确表达 bearer 语义。
- **影响**：msg 服务调用方需同步修改请求头；DevMode 仍跳过校验。
- **状态**：accepted（president §7.2）

### ADR-003 单实例容量 10w+ 连接（ISSUE-003）

- **决策**：单实例目标连接数从 50k 提升到 10w+，通过 goroutine-per-connection + send chan(cap 64) + 内存优化实现。
- **理由**：10w 连接估算内存 ~1.5GB（每连接 ~15KB：goroutine 栈 8KB + chan 64×1.5KB + websocket buffer 4KB），Go runtime 可承载；实际峰值按 80% 水位（8w）规划副本数。
- **影响**：单 Pod resources.limits.memory 建议 2GB；超限时水平扩容而非单实例加内存。
- **状态**：accepted（president §3.1）

### ADR-004 无 DB，审计由 msg 服务负责（ISSUE-005）

- **决策**：push-gateway 不持久化任何业务数据（无 DB），消息审计、离线落库、重试队列均由 msg 服务负责。
- **理由**：push-gateway 职责单一（连接管理 + 消息转发），引入 DB 会增加状态一致性复杂度；msg 服务已有 Outbox + DB，天然承担审计职责。
- **影响**：push-gateway 崩溃时在途消息丢失（msg 通过 Outbox 重投兜底）；`/internal/push` 同步响应返 `delivered/online` 供 msg 决策是否离线推送。
- **状态**：accepted（president §5.1）

### ADR-005 Kafka topic 命名（ISSUE-053）

- **决策**：消费的 Kafka topic 从 `edu.notification.events` 改为 `edu.notification.requested`。
- **理由**：符合 G16 命名规则 `edu.<domain>.<aggregate>.<action>`，`requested` 表达"通知请求"语义，`events` 过于宽泛。
- **影响**：msg 服务 Outbox TOPIC_MAP 需同步修改；CI 检测 breaking change。
- **状态**：accepted（president-final-rulings §1.5）

### ADR-006 /readyz 软失败（ISSUE-058 / ISSUE-006 / ARB-015 §17.4）

- **决策**：`/readyz` 探针采用软失败语义：Redis 或 Kafka 不可达时返回 HTTP 200 + `degraded: true`，而非 503。
- **理由**：K8s readinessProbe 返 503 会立即从 Service endpoints 驱逐 Pod，导致 Redis 短暂抖动时所有 push-gateway 实例被驱逐、WebSocket 连接全断。软失败让 Pod 留在 endpoints 内，仅降级（无跨实例 fanout / 无 Kafka 消费），抖动恢复后自动恢复。
- **例外**：Hub 处于 `closing` 状态时返 503（优雅关闭期间确实不应接收新连接）。
- **影响**：msg 服务调用 `/internal/push` 时需容忍 `delivered:false, online:false`（Redis 不可达时的保守响应）；K8s readinessProbe 配置 `failureThreshold: 5` 给恢复留窗口。
- **状态**：accepted（president-final-rulings §1.6 / ARB-015 §17.4）

### ADR-007 Redis online SET 启动重建（ISSUE-058）

- **决策**：push-gateway 实例启动时主动扫描 `edu:push:online:*` 并 SREM 自己的 instanceID，然后基于本地 Hub 内存状态重新 SADD。
- **理由**：实例崩溃重启后，旧 instanceID 在 Redis SET 中可能尚未过期（60s TTL 窗口），直接 SADD 同名 instanceID 是 no-op，无法区分"旧连接已失效"与"新连接已建立"。先清后建确保 SET 与内存一致。
- **不一致窗口**：崩溃到 SET 过期之间（最长 60s），`/internal/online` 可能误报 online:true；msg 通过 `delivered:false` 重试或离线推送兜底。
- **指标**：`push_gateway_redis_set_rebuild_total` 记录重建次数。
- **状态**：accepted（president-final-rulings §1.6）

### ADR-008 Redis 作为必需依赖但软失败（ISSUE-055 vs ISSUE-058 仲裁）

- **决策**：Redis 是 push-gateway 的必需依赖（部署前置条件），但运行期 Redis 故障采用软失败（ADR-006），不阻断 WebSocket 本地投递。
- **理由**：ISSUE-055 主张 Redis 不可达时 push-gateway 应 503 停服；ISSUE-058 主张软失败。ARB-015 §17.4 裁定 ISSUE-058 优先：Redis 故障时本地 WebSocket 连接仍可服务，仅跨实例 fanout 与在线状态查询降级。
- **部署约束**：启动时 Redis 不可达记为 degraded 并继续启动（不 Fatal）；生产环境应通过 K8s deployment `initContainer` 等待 Redis 就绪。
- **状态**：accepted（ARB-015 §17.4）

### ADR-009 纯 HTTP + WebSocket，无 gRPC（ISSUE-007）

- **决策**：push-gateway 对外仅暴露 HTTP（`/internal/*` API）+ WebSocket（`/ws`），不实现 gRPC 服务。
- **理由**：msg 服务调用 push-gateway 用 HTTP 已足够（同步请求-响应语义）；引入 gRPC 会增加 proto 契约维护成本与 buf 生成链路。gRPC 的流式推送能力由 WebSocket 承担。
- **影响**：msg 服务无需生成 push-gateway 的 gRPC client；`packages/shared-proto/proto/` 无 push-gateway.proto。
- **状态**：accepted（ARB-015 §17.6）

### ADR-010 shared-go 复用（ARB-015 §17.5）

- **决策**：tracer / logger / env / jwks 四个横切模块统一从 `packages/shared-go/` 导入，不在 push-gateway 内重复实现。
- **理由**：与 api-gateway 共用同一套 OTel SDK 配置、slog 日志格式、JWKS 缓存逻辑，避免代码漂移。
- **影响**：`go.mod` 依赖 `github.com/edu-cloud/shared-go`（replace 到 `../../packages/shared-go`）；`internal/observability/tracer.go` 仅包装 `shared-go/tracer.Init`。
- **状态**：accepted（ARB-015 §17.5）

---

## 15. 非功能性需求（NFR）

### 15.1 性能

| 指标               | 目标    | 测量方式                             | 验收标准                      |
| ------------------ | ------- | ------------------------------------ | ----------------------------- |
| 单实例最大连接数   | 10w+    | `push_gateway_active_connections`    | 持续 30min 稳定 ≥ 10w 不崩    |
| 本实例推送延迟 P99 | < 50ms  | `push.message` span                  | 压测 10w 连接下 P99 < 50ms    |
| 跨实例推送延迟 P99 | < 200ms | Redis Pub/Sub round-trip             | 3 实例 10w 连接下 P99 < 200ms |
| 广播推送延迟 P99   | < 100ms | `push_gateway_messages_pushed_total` | 10w 连接广播 P99 < 100ms      |
| 心跳间隔           | 30s     | 客户端配置                           | 客户端 Ping 间隔 30s ± 5s     |
| 空闲超时           | 60s     | `SetReadDeadline`                    | 60s 无帧断开                  |
| 消息体上限         | 64KB    | `SetReadLimit`                       | 超限返回 1009                 |

### 15.2 可用性

| 指标           | 目标   | 实现方式                                            |
| -------------- | ------ | --------------------------------------------------- |
| 服务可用性     | 99.9%  | 多副本部署 + K8s 自愈 + /readyz 软失败              |
| Redis 故障降级 | 不断连 | 软失败保留本地 WebSocket 服务（ADR-006/008）        |
| Kafka 故障降级 | 不断连 | 软失败保留 HTTP `/internal/push` 通道               |
| 滚动升级零中断 | 是     | Hub.CloseAll + srv.Shutdown + 10s drain             |
| 单实例崩溃恢复 | < 60s  | K8s 重启 + Redis SET TTL 过期 + 启动重建（ADR-007） |

### 15.3 安全

| 需求           | 实现                                                           |
| -------------- | -------------------------------------------------------------- |
| WebSocket 鉴权 | JWT RS256（iam JWKS，5min 缓存）；DevMode 接受 `dev-token`     |
| 内部 API 鉴权  | `X-Internal-Token` 头（K8s Secret 注入，与 msg 共享）          |
| Origin 白名单  | `WS_ALLOWED_ORIGINS` 环境变量，DevMode 空白名单允许所有        |
| 非 root 运行   | Dockerfile `USER app` (uid 1001)                               |
| 最小镜像       | alpine:3.20 + CGO_ENABLED=0 静态编译                           |
| 无密钥泄露     | INTERNAL_API_TOKEN / JWT_SECRET 通过 K8s Secret 注入，不入镜像 |

### 15.4 可观测性

| 支柱     | 实现                                                                    |
| -------- | ----------------------------------------------------------------------- |
| 日志     | slog JSON（prod）/ text（dev），字段 `service/user_id/conn_id/trace_id` |
| 指标     | 9 个 Prometheus 指标 + Go runtime 默认指标，`/metrics` 端点             |
| 追踪     | OTel SDK via shared-go/tracer，otelgin 中间件自动埋点 HTTP span         |
| 健康检查 | `/healthz`（liveness）+ `/readyz`（readiness，软失败）                  |

---

## 16. 失败模式与恢复

### 16.1 失败模式矩阵

| 失败场景               | 检测方式               | 系统行为                                                       | 恢复方式                         |
| ---------------------- | ---------------------- | -------------------------------------------------------------- | -------------------------------- |
| Redis 不可达           | `/readyz` 软探活       | 200 + `degraded:true`；跨实例 fanout 停止；本地 WebSocket 继续 | Redis 恢复后自动重连             |
| Kafka 不可达           | `/readyz` 软探活       | 200 + `degraded:true`；HTTP `/internal/push` 通道继续          | Kafka 恢复后 consumer 自动重连   |
| iam JWKS 不可达        | jwks.Fetcher 返错      | 新 WebSocket 连接被拒（401）；已建连接不受影响                 | JWKS 缓存 5min，恢复后新连接可用 |
| 单实例崩溃             | K8s livenessProbe 失败 | K8s 重启 Pod；该实例连接断开，客户端重连到其他实例             | Redis SET 60s 过期 + 启动重建    |
| 客户端网络抖动         | `SetReadDeadline(60s)` | 60s 无帧断开连接；客户端重连                                   | 客户端指数退避重连               |
| 单用户连接超限         | Hub.counters 检查      | 拒绝新连接，返 close 帧 1008                                   | 客户端关闭旧连接后重连           |
| send channel 满        | `Send()` 返 false      | 丢弃消息，`messages_dropped_total{reason=channel_full}` +1     | 消费端 drain 后恢复              |
| Kafka 消费失败         | 3 次重试后             | 发送 DLQ（`edu.notification.requested.dlq`），commit 跳过      | 人工排查 DLQ                     |
| Redis Pub/Sub 消息丢失 | fire-and-forget 特性   | msg 服务通过 `delivered:false` 响应触发重试或离线推送          | msg Outbox 重投                  |
| 内存 OOM               | K8s OOMKilled          | Pod 重启，连接断开，客户端重连                                 | 调整 resources.limits.memory     |

### 16.2 降级矩阵

| 依赖故障      | 本地 WebSocket | HTTP /internal/push | Kafka 消费 | 跨实例 fanout | /readyz 状态   |
| ------------- | -------------- | ------------------- | ---------- | ------------- | -------------- |
| Redis 故障    | ✅ 正常        | ✅ 本地投递         | ✅ 正常    | ❌ 停止       | degraded       |
| Kafka 故障    | ✅ 正常        | ✅ 正常             | ❌ 停止    | ✅ 正常       | degraded       |
| iam JWKS 故障 | ⚠️ 新连接被拒  | ✅ 正常             | ✅ 正常    | ✅ 正常       | ok（缓存可用） |
| Hub 关闭中    | ❌ 拒绝新连接  | ❌ 503              | ⏸️ drain   | ❌ 停止       | shutting_down  |

---

## 17. 容量估算

### 17.1 单实例资源估算

| 资源         | 估算公式                              | 10w 连接目标值 | 备注                          |
| ------------ | ------------------------------------- | -------------- | ----------------------------- |
| 内存         | 每连接 ~15KB × 10w + Go runtime 200MB | ~1.7GB         | goroutine 栈 + chan + buffer  |
| goroutine 数 | 每连接 2（reader+writer）+ 10（后台） | ~20w           | Go runtime 调度开销可接受     |
| 文件描述符   | 每连接 1（WebSocket fd）+ 50（其他）  | ~10w           | ulimit -n 需调到 200000       |
| CPU          | 心跳处理 10w/30s = 3.3k QPS           | 1-2 core       | 心跳轻量，主要 CPU 在消息推送 |

### 17.2 K8s Pod 资源配置建议

```yaml
resources:
  requests:
    cpu: 500m
    memory: 2Gi
  limits:
    cpu: 2000m
    memory: 3Gi # 留 1.3GB 余量应对突发
```

### 17.3 副本数规划

| 场景           | 在线用户数 | 单实例容量 | 副本数 | 总内存 |
| -------------- | ---------- | ---------- | ------ | ------ |
| 开发环境       | 100        | 10w        | 1      | 3GB    |
| 测试环境       | 1k         | 10w        | 1      | 3GB    |
| 预发环境       | 1w         | 10w        | 2      | 6GB    |
| 生产（小规模） | 5w         | 10w        | 2      | 6GB    |
| 生产（大规模） | 20w        | 10w        | 3      | 9GB    |
| 生产（峰值）   | 50w        | 10w        | 6      | 18GB   |

> **副本数公式**：`ceil(在线用户数 × 0.8 / 10w) + 1`（80% 水位 + 1 冗余）

### 17.4 Redis 资源估算

| 数据结构                             | 单条大小 | 10w 在线用户总量 | 备注                      |
| ------------------------------------ | -------- | ---------------- | ------------------------- |
| `edu:push:online:<userID>`           | ~50B     | ~5MB             | SET + TTL                 |
| `edu:push:session:<userID>:<connID>` | ~100B    | ~10MB            | HASH，每连接一条          |
| Pub/Sub channel 消息                 | ~200B    | 瞬时             | 不持久化                  |
| `edu:push:idempotent:<event_id>`     | ~30B     | ~3MB/h           | 24h TTL，按 10w 事件/h 估 |

> Redis 总内存需求 < 50MB（10w 在线用户），现有 `edu-redis` 容器 128m 限制足够。

### 17.5 Kafka 容量估算

| 指标         | 估算              | 备注                         |
| ------------ | ----------------- | ---------------------------- |
| 消息速率     | 1k 通知/s（峰值） | 考试开始、成绩发布等批量场景 |
| 消息大小     | ~500B             | NotificationRequested JSON   |
| 吞吐量       | ~500KB/s          | 单 partition 足够            |
| Partition 数 | 3                 | 容错 + 并行消费              |
| 保留时间     | 24h               | 失败重试窗口                 |

---

## 18. 变更日志

| 日期       | 版本 | 变更                                                                                                      | 来源                              |
| ---------- | ---- | --------------------------------------------------------------------------------------------------------- | --------------------------------- |
| 2026-07-09 | 0.1  | 初始架构设计                                                                                              | 阶段 2 交付                       |
| 2026-07-10 | 1.0  | 仲裁回写：ISSUE-001/002/003/005/053/055/056/058/007；新增 §14 ADR / §15 NFR / §16 失败模式 / §17 容量估算 | ARB-015 + president-final-rulings |
