# 站内信接口

> 本文件包含**两条互相独立**的链路：
>
> - **站内信**（`/message/*`）：可回溯、有已读态、落库持久化，是消息的**唯一权威来源**。
> - **[实时提示推送](#realtime)**（`/notify/*`，SSE）：纯瞬时 toast 通道，**不落库、可丢失**，只负责「当下弹一下」。
>
> 两者**不共享数据通路，也没有任何顺序依赖**。

---

## 未读数 {#unread}

获取当前登录会员未读站内信总数（主表 + 批量绑定）。需登录。

**URL:** `GET` /message/unread

**Query:** 无

**输出:**

字段 | 类型 | 说明
---|---|---
count | int | 未读消息总数

---

## 消息列表 {#list}

按时间倒序拉取当前会员可见的站内信，支持按类型过滤与分页。结果服务端短时缓存（3s）。需登录（匿名会话返回业务错误「会员不存在」）。

**URL:** `GET` /message/list

**Query:**

字段 | 类型 | 必填 | 说明
---|---|---|---
message_type | int | 否 | 消息类型过滤，取值见下方枚举；不传或传 `0` 返回全部
offset | int | 否 | 偏移量，默认 `0`
limit | int | 否 | 每页条数，默认 `10`，上限 `50`

**输出:**

字段 | 类型 | 说明
---|---|---
list | list&lt;object&gt; | 消息列表，元素结构见下表

**元素字段:**

字段 | 类型 | 说明
---|---|---
id | string | 消息 ID（Snowflake）
message_type | int | 消息类型，见下方枚举
content | string | 消息正文（HTML / 纯文本，由后台维护）
is_popup | bool | 是否需要弹窗强提醒
status | int | 当前会员的阅读状态：`0`=未读 / `1`=已读。**全员广播消息的 `status` 同样是当前会员独立的已读态**，各会员互不影响
created_at | string | 发送时间，ISO8601

**`message_type` 枚举:**

值 | 含义
---|---
`1` | 系统通知
`2` | 活动消息
`3` | 奖励消息
`4` | 充值通知
`5` | 提现通知
`7` | 批量消息（针对指定会员群发）
`8` | 单一通知（针对单一会员）

> 仅返回当前会员注册之后产生的消息。`message_type=7` 的批量消息以会员绑定状态为准，未绑定到当前会员的批量消息不会出现在列表中。

---

## 标记单条已读 {#read}

把指定的一条消息标记为已读。重复调用幂等。需登录（未登录 / 匿名会话返回业务错误「会员不存在」）。

**URL:** `GET` /message/read

**Query:**

字段 | 类型 | 必填 | 说明
---|---|---|---
id | string | 是 | 消息 ID
message_type | int | 否 | **已废弃**：服务端忽略该参数，保留仅为向后兼容，新接入无需传

**输出:** 无

> 服务端按**消息 ID 自动判定**该消息走主表还是会员绑定表：定向消息更新主表已读态，批量消息（`message_type=7`）更新绑定行，全员广播为当前会员写入一条独立的已读记录。客户端无需再传消息类型。

**错误说明:**

message | 场景
---|---
消息ID为空 | 未传 `id`
会员不存在 | 未登录（匿名会话）

> 消息 ID 不存在或不属于当前会员可见范围时**不报错**，按无操作处理并返回成功（幂等），客户端对已被后台删除的消息重复调用是安全的。

---

## 全部已读 {#read-all}

把当前会员所有未读消息一键标记为已读，返回本次真实标记为已读的条数。**每次调用都实际执行，无结果缓存**；重复调用幂等（已无未读时返回 `0`）。需登录（未登录 / 匿名会话返回业务错误「会员不存在」）。

**URL:** `GET` /message/read-all

**Query:** 无

**输出:**

字段 | 类型 | 说明
---|---|---
count | int | 本次标记为已读的消息条数

> 单次调用最多为 **1000 条**全员广播消息补齐已读标记，超出部分由下次调用继续补齐（正常运营量级不会触发）。

---

## 实时提示推送（SSE） {#realtime}

### 定位声明：这不是消息系统，务必先读 {#realtime-positioning}

`/notify/*` 是一条**纯瞬时、可丢失、不落库**的 toast 通道，只用于「刚刚发生了什么，现在弹一下」。

- **不落库、不补偿、不重放、不回源**：服务端不存任何 toast 记录，没有「未送达队列」这个概念。玩家没连上、切网、后台挂起、Redis 抖动、服务滚动发布——**错过就是错过**，服务端不会补发。
- **不做建连补拉**：断线重连后**不会**收到断开期间产生的 toast，客户端也**不得**在重连后去拉取「漏掉的提示」。
- **不做降级轮询**：SSE 不可用时就是没有 toast，**业务零损失**——到账已入账、奖励已发放、站内信已落库。
- **需要回溯的内容一律走站内信**：`GET /message/list` / `/message/unread` 等接口是可回溯内容的唯一来源，与本通道**互不依赖**。
- **前端不得把 toast 当作业务状态的唯一来源**：余额、订单状态、奖励领取态等一律以各自的业务接口为准。`type=balance_changed` 虽然是一个可用的刷新**提示**（见[余额变动清单](#realtime-balance-changed)），但它同样可丢失，**不能作为刷新的唯一触发条件**，原有的定时 / 回前台刷新必须保留。

---

### 获取建连凭证 {#realtime-ticket}

签发一次性 SSE 建连凭证（ticket）。**需登录**（未登录返回 `401`）。

由于浏览器 `EventSource` **无法自定义请求头**，SSE 端点不走 `Authorization` 头鉴权；同时也**不采用 `?token=` 传 JWT**（JWT 会落 access log / CDN 日志 / Referer，有效期以小时计，泄露窗口过大），改为先换一张短时效的一次性 ticket。

**URL:** `POST` /notify/ticket

**输入:** 无

**输出:**

字段 | 类型 | 说明
---|---|---
ticket | string | 一次性建连凭证（48 位十六进制随机串）
expire_in | int | 有效期秒数，当前固定为 `30`

**响应示例**（注意外层 `data` 包装，与全站统一响应格式一致）：

```json
{
  "data": {
    "ticket": "9f3c1a...（48 hex）",
    "expire_in": 30
  }
}
```

**约束:**

- **TTL 30 秒**：签发后 30 秒内必须用掉，过期即失效。请在**真正要建连之前**才调用，不要提前预取、不要缓存复用。
- **一次性**：建连时服务端以 `GETDEL` 原子消费，**用过即废**。每建立一条 SSE 连接（含任何形式的重连）都必须**重新签发**一张。
- **有频次限制**：按会员固定窗口计数，**1 分钟内最多 30 次**，超出返回 `429`。见下方[签票频次限制](#realtime-ticket-rate)。
- 凭证只绑定「商户 + 会员」身份，不携带任何业务数据。
- ⚠️ **本次请求的 `Accept-Language` 决定该条 SSE 连接后续所有 toast 的语言**：`EventSource` 无法自定义请求头，服务端只能在签票这一刻取到语言并随 ticket 存下。因此**签票请求必须按当前界面语言带上 `Accept-Language`**；玩家切换语言后，需重新签票并重建连接才会生效（详见[事件格式](#realtime-events)）。

HTTP | message | 场景
---|---|---
`401` | 未登录 | 未带 JWT 或为匿名会话
`429` | 操作过于频繁，请稍后再试 | 签票频次超限（1 分钟 / 30 次），见下方
`>=400` | 凭证签发失败，请重试 | 随机源或缓存写入异常，可重试

#### 签票频次限制 {#realtime-ticket-rate}

服务端按「商户 + 会员」维度做**固定窗口**限流：**每自然分钟最多 30 次**签票，第 31 次起返回 `429`。窗口按分钟切换，不是滑动窗口。

阈值按「服务端最小重连间隔 3 秒 → 正常客户端极端情况下约 20 次/分钟」留富余设定，**正常接入不会触发**。会触发的只有一种情况：**前端重连没有退避**。

- **前端重连退避是硬性要求**，不是优化项。无退避的裸重连会在网络抖动时把签票打满，此后连正常重连也一起被 `429` 挡住 —— 故障被放大而不是被恢复。
- 收到 `429` **必须显著拉长退避间隔**（建议指数退避 + 上限），**不要**立即重试。
- 建议实现退避次数上限，超过后停止自动重连（如等待下一次 `visibilitychange` 转 `visible` 再试）。
- `429` 不影响任何业务，仅代表这一小段时间内没有 toast。

---

### 建立推送连接 {#realtime-stream}

SSE（`text/event-stream`）长连接，服务端单向下推 toast。**不需要** `Authorization` 头，鉴权完全由 ticket 完成。

**URL:** `GET` /notify/stream

**Query:**

字段 | 类型 | 必填 | 说明
---|---|---|---
ticket | string | 是 | [获取建连凭证](#realtime-ticket) 返回的 ticket，**一次性**

**响应头:**

```
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
```

**接入示例:**

```js
// 签票请求必须带上当前界面语言，它决定该条连接后续所有 toast 的文案语言
const { data } = await post('/api/game/notify/ticket');          // 每次建连都要重新签发
const es = new EventSource(`/api/game/notify/stream?ticket=${data.ticket}`);

es.addEventListener('toast', (e) => {
  const { type, data } = JSON.parse(e.data);
  // msg：data.content 已是成品文案，剥标签后直接展示（data.icon 选图标）
  // promotion_trigger：data.kind 是英文活动标识，需回源拉详情
  // balance_changed：余额已变动，需去抖后回源刷新余额（含游戏内钱包）
  // 未知 type / 未知活动标识一律静默丢弃
});

es.addEventListener('closed', () => es.close());                 // 必须关闭且不再重连
```

**鉴权失败:**

HTTP | message | 场景
---|---|---
`401` | 推送凭证无效或已过期 | ticket 缺失 / 拼写错误 / 已过 30s / **已被用过**（含浏览器自动重连复用同一 ticket）
`401` | 商户不匹配 | 建连域名解析出的商户与签发 ticket 时不一致（**必须用同一域名**）

> ⚠️ **浏览器原生重连一定会失败**：`EventSource` 自动重连时会**原样重发**当前 URL（含已被消费的 ticket），必然得到 `401`。因此 `onerror` 里**必须**走「重新签发 ticket → 新建 `EventSource`」的流程，见[客户端接入规则](#realtime-client-rules)。

**建连成功但立刻被断开的两种情况:**

鉴权通过后，服务端仍可能**不接纳**这条连接。此时 HTTP 状态码已经是 `200`、响应体已开始流式输出，**没有错误码可读**，表现统一为「刚连上就断」：服务端下发一次 `retry:` 帧后立即关闭，**不会**发送 `event: closed`。

场景 | 服务端行为 | 前端处理
---|---|---
**服务端连接已满** | 单实例 SSE 连接总数达到硬上限（**10000 条**）时不再接纳新连接 | 按退避重连即可。多实例部署下重连可能被负载均衡打到其它实例并成功
**服务端正在停机** | 优雅停机（滚动发布）时，服务端会主动关闭全部连接，并拒绝其间新到达的建连 | 同上，按退避重连，通常会连到新实例上

> 两种情况前端**处理方式完全相同**：`onerror` → 退避 → 重新签发 ticket → 新建 `EventSource`。无需区分，也无法区分。
>
> ⚠️ 正因为这类「秒断」会成片出现（滚动发布时全量客户端同时断开），**退避与 `429` 限流的遵守尤为关键**，否则会形成重连风暴。

**连接生命周期:**

- 服务端每 **20 秒**下发一次心跳注释行，保持链路活跃（`EventSource` 自动忽略注释行，前端无需处理）。
- 服务端会在建连时下发随机化的 `retry:` 帧（3000~15000ms），用于重连错峰。
- 单条连接有 **30~60 分钟（随机）的生命周期上限**，到点服务端会主动断开以做负载再平衡。这是正常现象，按重连流程重建即可。
- **单会员单实例最多 3 条连接**，超出时服务端踢掉**最旧**的一条，并给它下发 `event: closed`。注意这是**单实例**软上限，多实例部署下同一会员的连接会被负载均衡打散，全局数量可能更高。
- **单实例连接总数上限 10000 条**，触顶时新连接会被立即断开（见上表）。
- **死链回收约 60 秒**：关标签页 / 刷新 / `EventSource.close()` 会立即被服务端感知；但切网、飞行模式、拔线这类**不发 FIN** 的断开，服务端要靠 TCP keepalive（空闲 30s 后开始探测，每 10s 一次、共 3 次）才能发现，**约 60 秒**后才会释放旧连接。
    - 这**不影响前端行为**：前端本来就要走「重新签发 ticket → 新建 `EventSource`」重建，不必等旧连接被回收。
    - 唯一影响是这 60 秒内该会员在服务端可能多占一个连接名额（3 条软上限）。切网后马上回来并重建，最坏情况是自己最旧的那条被踢掉，属预期行为。

---

### 事件格式 {#realtime-events}

SSE 报文由若干帧组成，帧与帧之间以空行分隔。服务端只会下发以下四种内容：

#### `event: toast` —— 提示事件

```
event: toast
data: {"type":"msg","data":{"icon":"recharge","content":"<p>充值成功，到账金额：<strong>100.00</strong>。</p>","show_time":0}}
```

`data` 为 JSON 对象：

字段 | 类型 | 说明
---|---|---
type | string | 提示类型，决定 `data` 的结构，见下表
data | object | 类型专属载荷，结构随 `type` 变化

**`type` 枚举（3 种）与对应的 `data` 结构:**

值 | `data` 结构 | 前端处理
---|---|---
`msg` | `{icon, content, show_time}`，字段含义见下表 | 直接展示 `content`，仅需**剥标签 / 转义**；**不要查词典、不要插值**
`promotion_trigger` | `{kind}`，kind 为**稳定英文活动标识**（**永不翻译**），表示玩家刚满足该活动的触发条件（见 [活动触发清单](#realtime-promotion-trigger)） | 回源拉该活动详情后呈现入口 / 弹窗
`balance_changed` | `{reason}`，reason 为**稳定英文变动来源标识**（**永不翻译**），表示玩家钱包余额已变动（见 [余额变动清单](#realtime-balance-changed)） | 去抖后回源刷新余额（含游戏内钱包），**不弹 toast**

**`type=msg` 的 `data` 字段:**

字段 | 类型 | 说明
---|---|---
icon | string | 展示分类标识，供前端选图标/样式：`recharge` 充值 / `withdraw` 提现 / `activity` 活动 / `bonus` 赠送。**可能为空串或新增取值**，未知值按默认样式展示
content | string | **成品文案**（含 HTML 标签，服务端已按建连语言翻译并插值完成，见 [文案清单](#realtime-i18n-keys)）
show_time | int | 建议展示秒数；**当前恒为 `0`**，表示由前端自定，字段为将来预留

> ⚠️ **三种 `type` 的 `data` 结构完全不同，处理前必须先分流**：`msg` 的 `data.content` 是**可直接展示**的成品文案；`promotion_trigger` / `balance_changed` 只有一个英文标识（如 `daily_bankrupt_pack` / `recharge_credit`），直接展示会让玩家看到一串代码。
> ⚠️ **活动奖励到账也走 `msg`**（它本就会落一条站内信，`icon=activity`），不单独立类型；`promotion_trigger` 只用于「资格刚生成、需回源拉详情」的场景。

> - **`msg` 的 i18n 由服务端负责**，口径与站内信列表接口[消息列表](#list)完全一致（同一份后端词典）。语言取自[签票请求](#realtime-ticket)的 `Accept-Language`，在建连时固化到该条连接上；玩家切语言后需重新签票 + 重建连接才会生效。
> - **`promotion_trigger` 的 `kind`、`balance_changed` 的 `reason` 都不翻译**，是服务端登记的稳定英文标识，文案由前端自行组织。
> - **`show_time=0` 时展示时长、多条叠加还是排队，全部由前端自定**；服务端下发非 0 值时才按其建议展示。
> - `data` 内不会出现 token、手机号、订单号、余额快照或 URL。

#### `event: closed` —— 服务端主动踢连接

```
event: closed
data: {"reason":"conn_limit"}
retry: 86400000
```

字段 | 类型 | 说明
---|---|---
reason | string | **当前仅有 `conn_limit`** 一个取值（同一会员在该实例上的连接数超过上限，最旧的一条被踢）

同帧携带的 `retry: 86400000` 会把浏览器重连间隔拉到 24 小时，用于与「网络故障断开」相区分。**收到该事件必须调用 `close()` 且不再重连**，否则会与自己其它标签页的连接互相踢来踢去。

> ⚠️ 服务端的其它主动断开（停机、连接数触顶、生命周期到期）**都不发 `event: closed`**，而是只下发一次 `retry:` 帧后关闭 —— 这些情况都应该重连。换句话说：**只要没收到 `event: closed`，就该退避重连**。

#### `retry:` 帧 —— 重连间隔

```
retry: 7421
```

建连成功时立即下发，值在 `3000`~`15000` 毫秒间随机（jitter），用于避免滚动发布时全部客户端同时重连。连接因生命周期到期、服务端停机、连接数触顶而被服务端主动关闭时，也会先下发一次。前端**无需处理**，由 `EventSource` 自动采纳。

> 注意：`EventSource` 原生重连会复用已消费的 ticket 而必然 `401`，因此该值实际只当作**前端自实现退避的参考下限**；退避不得短于此值，否则容易撞上[签票频次限制](#realtime-ticket-rate)。

#### `: heartbeat` —— 心跳注释行

```
: heartbeat
```

每 20 秒一次。SSE 注释行，`EventSource` 自动忽略，**不会**触发任何事件回调，前端无需处理。

---

### 文案清单（`type=msg`） {#realtime-i18n-keys}

该类 toast 与[站内信](#list)**同源**：同一处业务同时落一条站内信 + 推一条 toast。两者的文案也同源——`data.content` 都是**服务端按语言翻译并插值好的成品**，前端**直接展示即可**，不需要词典、不需要插值。

下表仅供联调时对照（列的是后端词典 key 的中文原文，实际下发的是对应语言的译文，`%s` 已被服务端插值替换）：

词典 key（中文原文） | 插值参数 | `icon` | 场景
---|---|---|---
`<p>充值成功，到账金额：<strong>%s</strong>。</p>` | `[金额]` | `recharge` | 充值
`<p>充值赠送已到账，到账金额：<strong>%s</strong>。</p>` | `[金额]` | `recharge` | 充值赠送
`<p>提现申请已提交，扣款金额：<strong>%s</strong>，请耐心等待处理。</p>` | `[金额]` | `withdraw` | 提现
`<p>提现成功，到账金额：<strong>%s</strong>。</p>` | `[金额]` | `withdraw` | 提现
`<p>提现失败已退回，金额：<strong>%s</strong>。</p>` | `[金额]` | `withdraw` | 提现
`<p>您已收到赠送：<strong>%s</strong>。</p>` | `[金额]` | `bonus` | 后台赠送
`<p><strong>%s</strong> 奖励已到账：<strong>%s</strong>。</p>` | `[活动名, 金额]` | `activity` | 活动领奖（活动名同样已翻译）
`<p>活动奖励已到账：<strong>%s</strong>。</p>` | `[金额]` | `activity` | 活动领奖（活动名未登记时退化）

> - 插值参数列仅说明 `%s` 被什么替换，**不会单独下发**（无 `args` 字段）。
> - 金额为字符串十进制、单位为货币面额（非分），货币符号请按商户币种渲染，参见【[金额与币种](200_finance.md#amount_currency)】。
> - 这些与站内信是**同一批文案**，因此 toast 展示后**仍会**在消息列表里看到对应记录，属预期行为。
> - ⚠️ **`content` 带 HTML 标签是既有约定形态**（站内信侧沿用）。玩家端渲染 toast 时应**剥标签或做转义**，**不要无脑 `v-html`**。
> - **活动奖励到账也在此列**（最后两条）：它本就会落站内信，因此不单独立一个 `type`；活动名也由服务端翻好，前端无需知道具体是哪个活动。
> - 后端新增文案时该清单会同步更新，但**前端无需跟随发版**：`content` 已是成品文案，未在清单内的照常展示即可。

---

### 活动触发清单（`type=promotion_trigger`） {#realtime-promotion-trigger}

服务端刚为该玩家生成某活动的**资格**时下发。`data.kind` 是稳定英文活动标识（**不带 `_claim` 后缀**），`data` 内**不含任何业务数据**：

```
event: toast
data: {"type":"promotion_trigger","data":{"kind":"daily_bankrupt_pack"}}
```

标识 | 对应活动 | 收到后前端该做什么
---|---|---
`daily_bankrupt_pack` | [每日幸运补偿](417_promotion_daily_bankrupt_pack.md) | 拉一次 `GET /site_content/config`，从 `popups` 取 `kind=daily_bankrupt_pack` 的礼包（含 `offer_id` / `expire_at` / `server_now`），按[原有流程](417_promotion_daily_bankrupt_pack.md#redeem)渲染弹窗与倒计时
`lucky_compensation` | [幸运补偿](419_promotion_lucky_compensation.md) | 拉一次 `GET /site_content/config`（或 `GET /promotion/lucky_compensation/detail`）取 `kind=lucky_compensation` 的待开箱补偿（`offer_id` / `deposit_amount` / `open_expire_at` / `server_now`），在当前页面（**含游戏内，直接覆盖游戏画面**）上层弹[宝箱第一步](419_promotion_lucky_compensation.md#popup)；按 `offer_id` 去重
`free_register_bonus_rescue` | [免费注册奖金 · 幸运救援奖金](418_promotion_free_register_bonus.md#rescue) | 救援金额**已入账**免费账户，在当前游戏页上层弹「幸运救援奖金」弹窗；金额用 `GET /promotion/free_register_bonus/detail` 的 `rescue_amount`（领取时快照，可用已缓存值，不必每次回源）；「Claim」/「✕」仅关闭，不调任何接口；游戏内余额以游戏回调为准

> - ⚠️ 这是[客户端接入规则](#realtime-client-rules)第 2 条「收到 toast 不回源」的**唯一例外**：触发类 toast 只告诉你「有了」，具体内容必须回源取——礼包字段多，且倒计时必须用服务端下发的 `server_now` 校准，不能靠 toast 到达时刻推算。
> - **同一份资格只推一次**（生成时推）。玩家关掉弹窗后重新进首页时**不会**再收到 toast，但资格仍在 `popups` 里，照常展示到兑现或过期为止。
> - **必须按 `offer_id` 去重**：极小概率下 toast 与 `GET /site_content/config` 的响应会几乎同时把同一个礼包送到前端（该次 config 请求本身就是触发源时），若不去重会弹两次。
> - 触发类 toast **不落站内信**，也不代表任何资金变动，丢了只是少一次即时提醒，玩家下次刷新首页照样能看到礼包。

---

### 余额变动清单（`type=balance_changed`） {#realtime-balance-changed}

玩家钱包余额发生**非下注类**变动时下发。它是一条**给程序看的刷新信号**，不是给玩家看的文案：

```
event: toast
data: {"type":"balance_changed","data":{"reason":"recharge_credit"}}
```

标识 | 含义 | 收到后前端该做什么
---|---|---
`recharge_credit` | 充值已到账 | 回源刷一次余额（含游戏内钱包）
`rescue` | [免费注册奖金 · 幸运救援](418_promotion_free_register_bonus.md#rescue)已补发到免费账户 `free_money` | 回源刷一次【[账户余额](200_finance.md#account_balance)】（游戏内钱包已由游戏回调更新，这里主要是站点顶栏 / 进度卡）；弹窗另由同时下发的 `promotion_trigger` `kind=free_register_bonus_rescue` 负责，本信号不弹
`void` | [免费注册奖金](418_promotion_free_register_bonus.md#rules)到期 / 输光作废，`free_money` 已清零（作废前余额已为 0 时不推） | 回源刷一次【[账户余额](200_finance.md#account_balance)】；到期提示文案由同时下发的 `type=msg` toast 负责，本信号不弹

> - **一笔充值只推一条**。充值赠送与本金在**同一事务**内入账，因此不单独推信号；收到 `recharge_credit` 后刷到的余额**已含赠送**。
> - **`reason` 是开放集，遇到未知值也要照常刷新**。它仅供埋点与可选文案，不要用它做白名单过滤（后端会直接新增取值，不等前端发版）。
> - **建议去抖 300~500ms**：多笔充值几乎同时到账时会接连下发。
> - **不要弹 toast**：同一笔到账已经有 `type=msg`（`icon=recharge`）负责提示，重复弹会造成双弹窗。
> - **不带金额与余额快照**，也不要自己拿 `msg` 里的金额去加减：余额必须以业务接口返回为准。
> - **SSE 可丢失，不能当唯一同步机制**：原有的轮询 / 页面回前台刷新兼定时刷新必须保留，本事件只是把到账后的刷新提前。
> - 下注、派彩、活动领奖（含[每日幸运补偿](417_promotion_daily_bankrupt_pack.md)发奖）、后台赠送、提现等变动**当前不推**此事件；免费账户的普通下注 / 派彩也不推，仅幸运救援补发推。

---

### 客户端接入规则 {#realtime-client-rules}

以下为**强制**契约，逐条实现：

1. **登录后才建连**，未登录不建连；退出登录时主动 `close()`。
2. **收到 toast 直接渲染，不发任何回源请求**。`type=msg` 的 `data.content` 已是**服务端翻好的成品文案**，剥标签 / 转义后直接展示（**不要查词典、不要插值**）。**例外只有两类**：`type=promotion_trigger`（见[活动触发清单](#realtime-promotion-trigger)）与 `type=balance_changed`（见[余额变动清单](#realtime-balance-changed)），它们不带业务数据，必须回源拉一次对应接口。
3. **遇到未知 `type`、未知 `icon` 或未知活动标识一律静默处理**：未知 `type` / 活动标识直接丢弃，未知 `icon` 按默认样式展示；不得展示原始标识、不得弹错误提示、不得打断用户操作。后端新活动上线会**直接新增**标识，**也可能新增 `type` / `icon` 取值**，不会等前端发版。
4. **`onerror` 不做降级轮询**——连接不上就算了，没有 toast 不影响任何业务。需要恢复连接时走「**重新签发 ticket → 新建 `EventSource`**」（ticket 一次性，浏览器原生重连必然 `401`）。
5. **重连必须带退避与次数上限**（建议指数退避，起步不低于服务端下发的 `retry:` 值）。签票接口有 **1 分钟 / 30 次**的硬限流，无退避的裸重连会被 `429` 挡住并**放大故障**，详见[签票频次限制](#realtime-ticket-rate)。
6. **要容忍「刚连上就断」**：服务端停机或连接数触顶时，会在接受连接后立即关闭（无 `event: closed`）。这是正常现象，按第 4/5 条退避重连即可，**不要**向玩家报错。
7. **`visibilitychange` 必须实现**：页面转 `hidden` 超过 5 分钟主动 `close()`，转 `visible` 时按第 4 条重建。**重建后不补拉**断连期间的 toast（已丢失，符合本通道定位）。不实现会让后台标签页长期占用服务端连接。
8. **展示时长与叠加策略由前端自定**（自动消失时间、堆叠还是排队）；`data.show_time` 当前恒为 `0`（= 前端自定），服务端下发非 0 值时才按其建议秒数展示。
9. **收到 `event: closed` 必须 `close()` 且不再重连**，用于与网络故障断开相区分。
10. **单会员单实例最多 3 条连接**，超出会踢掉最旧的一条。多标签页**无需任何协调**（最差是同一条 toast 在多个标签页各弹一次，无正确性问题），但**不要在同一页面里建多条连接**。
11. **签票请求必须带 `Accept-Language`，切语言后必须重建连接**：toast 文案由服务端翻译，语言在[签票](#realtime-ticket)那一刻固化到连接上；不重建就会继续收到旧语言的文案。

---

### 常见坑 {#realtime-pitfalls}

- **本地开发多开标签页会把页面卡死**：Vite 代理走 HTTP/1.1，浏览器对同一域名有 **6 个并发连接**上限，SSE 是常驻连接，多开几个标签页就会把连接池占满，导致该域名下其它请求一直挂起（表现为接口「转圈不返回」）。调试时请减少标签页数量，或临时关闭 SSE 建连。生产环境经 Cloudflare 走 HTTP/2，多路复用，**无此问题**。
- **ticket 不能复用、不能进缓存**：一张 ticket 只能换一条连接，用过即废（服务端原子消费）。不要把带 ticket 的 stream URL 写进 Service Worker 缓存、书签、`localStorage` 或任何可被重放的地方；也不要在多个标签页间共享同一张 ticket。
- **不要给 SSE 请求套通用响应拦截器**：`/notify/stream` 返回的是 `text/event-stream` 流，不是统一的 JSON 响应体，用普通 HTTP 客户端（axios / fetch 后 `.json()`）请求会一直挂住。
- **切语言不重建连接会收到旧语言文案**：`EventSource` 无法自定义请求头，服务端只能在**签票那一刻**从 `Accept-Language` 取到语言并固化到这条连接。玩家切语言后请 `close()` 并重新签票建连。
- **不要把 `type=msg` 当刷新信号**：收到到账文案 toast 后**不要**去轮询余额或订单接口，也不要去解析 `content` 里的金额——刷余额请一律以 `type=balance_changed` 为准（见[余额变动清单](#realtime-balance-changed)）。
- **无退避重连会把自己锁在门外**：签票接口 1 分钟只允许 30 次。写成 `onerror` 里立即重签重连的，只要服务端滚动发布或玩家网络抖一下，瞬间就会打满配额，后续合法重连全部 `429`。
- **「刚连上就断」不是 bug**：服务端停机中或连接数触顶时，HTTP 已经返回 `200`，但连接会被立即关闭且**没有** `event: closed`。不要把它当成异常上报或提示玩家，按退避重连处理即可。
- **切网后服务端不会立即发现**：飞行模式 / 拔网线这类不发 FIN 的断开，服务端靠 TCP keepalive 约 **60 秒**才能回收旧连接。前端无需为此做任何特殊处理（照常重新签票建连），但要知道这 60 秒内自己会多占一个 3 条名额。

