站内信接口

本文件包含两条互相独立的链路:

  • 站内信(/message/*):可回溯、有已读态、落库持久化,是消息的唯一权威来源。
  • 实时提示推送(/notify/*,SSE):纯瞬时 toast 通道,不落库、可丢失,只负责「当下弹一下」。

两者不共享数据通路,也没有任何顺序依赖。


未读数

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

URL: GET /message/unread

Query: 无

输出:

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

消息列表

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

URL: GET /message/list

Query:

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

输出:

字段 类型 说明
list list<object> 消息列表,元素结构见下表

元素字段:

字段 类型 说明
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 的批量消息以会员绑定状态为准,未绑定到当前会员的批量消息不会出现在列表中。


标记单条已读

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

URL: GET /message/read

Query:

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

输出: 无

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

错误说明:

message 场景
消息ID为空 未传 id
会员不存在 未登录(匿名会话)

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


全部已读

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

URL: GET /message/read-all

Query: 无

输出:

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

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


实时提示推送(SSE)

定位声明:这不是消息系统,务必先读

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

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

获取建连凭证

签发一次性 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 包装,与全站统一响应格式一致):

{
  "data": {
    "ticket": "9f3c1a...(48 hex)",
    "expire_in": 30
  }
}

约束:

  • TTL 30 秒:签发后 30 秒内必须用掉,过期即失效。请在真正要建连之前才调用,不要提前预取、不要缓存复用。
  • 一次性:建连时服务端以 GETDEL 原子消费,用过即废。每建立一条 SSE 连接(含任何形式的重连)都必须重新签发一张。
  • 有频次限制:按会员固定窗口计数,1 分钟内最多 30 次,超出返回 429。见下方签票频次限制。
  • 凭证只绑定「商户 + 会员」身份,不携带任何业务数据。
  • ⚠️ 本次请求的 Accept-Language 决定该条 SSE 连接后续所有 toast 的语言:EventSource 无法自定义请求头,服务端只能在签票这一刻取到语言并随 ticket 存下。因此签票请求必须按当前界面语言带上 Accept-Language;玩家切换语言后,需重新签票并重建连接才会生效(详见事件格式)。
HTTP message 场景
401 未登录 未带 JWT 或为匿名会话
429 操作过于频繁,请稍后再试 签票频次超限(1 分钟 / 30 次),见下方
>=400 凭证签发失败,请重试 随机源或缓存写入异常,可重试

签票频次限制

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

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

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

建立推送连接

SSE(text/event-stream)长连接,服务端单向下推 toast。不需要 Authorization 头,鉴权完全由 ticket 完成。

URL: GET /notify/stream

Query:

字段 类型 必填 说明
ticket string 是 获取建连凭证 返回的 ticket,一次性

响应头:

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

接入示例:

// 签票请求必须带上当前界面语言,它决定该条连接后续所有 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」的流程,见客户端接入规则。

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

鉴权通过后,服务端仍可能不接纳这条连接。此时 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 条软上限)。切网后马上回来并重建,最坏情况是自己最旧的那条被踢掉,属预期行为。

事件格式

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 为稳定英文活动标识(永不翻译),表示玩家刚满足该活动的触发条件(见 活动触发清单) 回源拉该活动详情后呈现入口 / 弹窗
balance_changed {reason},reason 为稳定英文变动来源标识(永不翻译),表示玩家钱包余额已变动(见 余额变动清单) 去抖后回源刷新余额(含游戏内钱包),不弹 toast

type=msg 的 data 字段:

字段 类型 说明
icon string 展示分类标识,供前端选图标/样式:recharge 充值 / withdraw 提现 / activity 活动 / bonus 赠送。可能为空串或新增取值,未知值按默认样式展示
content string 成品文案(含 HTML 标签,服务端已按建连语言翻译并插值完成,见 文案清单)
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 由服务端负责,口径与站内信列表接口消息列表完全一致(同一份后端词典)。语言取自签票请求的 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,因此该值实际只当作前端自实现退避的参考下限;退避不得短于此值,否则容易撞上签票频次限制。

: heartbeat —— 心跳注释行

: heartbeat

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


文案清单(type=msg)

该类 toast 与站内信同源:同一处业务同时落一条站内信 + 推一条 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 字段)。
  • 金额为字符串十进制、单位为货币面额(非分),货币符号请按商户币种渲染,参见【金额与币种】。
  • 这些与站内信是同一批文案,因此 toast 展示后仍会在消息列表里看到对应记录,属预期行为。
  • ⚠️ content 带 HTML 标签是既有约定形态(站内信侧沿用)。玩家端渲染 toast 时应剥标签或做转义,不要无脑 v-html。
  • 活动奖励到账也在此列(最后两条):它本就会落站内信,因此不单独立一个 type;活动名也由服务端翻好,前端无需知道具体是哪个活动。
  • 后端新增文案时该清单会同步更新,但前端无需跟随发版:content 已是成品文案,未在清单内的照常展示即可。

活动触发清单(type=promotion_trigger)

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

event: toast
data: {"type":"promotion_trigger","data":{"kind":"daily_bankrupt_pack"}}
标识 对应活动 收到后前端该做什么
daily_bankrupt_pack 每日幸运补偿 拉一次 GET /site_content/config,从 popups 取 kind=daily_bankrupt_pack 的礼包(含 offer_id / expire_at / server_now),按原有流程渲染弹窗与倒计时
lucky_compensation 幸运补偿 拉一次 GET /site_content/config(或 GET /promotion/lucky_compensation/detail)取 kind=lucky_compensation 的待开箱补偿(offer_id / deposit_amount / open_expire_at / server_now),在当前页面(含游戏内,直接覆盖游戏画面)上层弹宝箱第一步;按 offer_id 去重
free_register_bonus_rescue 免费注册奖金 · 幸运救援奖金 救援金额已入账免费账户,在当前游戏页上层弹「幸运救援奖金」弹窗;金额用 GET /promotion/free_register_bonus/detail 的 rescue_amount(领取时快照,可用已缓存值,不必每次回源);「Claim」/「✕」仅关闭,不调任何接口;游戏内余额以游戏回调为准
  • ⚠️ 这是客户端接入规则第 2 条「收到 toast 不回源」的唯一例外:触发类 toast 只告诉你「有了」,具体内容必须回源取——礼包字段多,且倒计时必须用服务端下发的 server_now 校准,不能靠 toast 到达时刻推算。
  • 同一份资格只推一次(生成时推)。玩家关掉弹窗后重新进首页时不会再收到 toast,但资格仍在 popups 里,照常展示到兑现或过期为止。
  • 必须按 offer_id 去重:极小概率下 toast 与 GET /site_content/config 的响应会几乎同时把同一个礼包送到前端(该次 config 请求本身就是触发源时),若不去重会弹两次。
  • 触发类 toast 不落站内信,也不代表任何资金变动,丢了只是少一次即时提醒,玩家下次刷新首页照样能看到礼包。

余额变动清单(type=balance_changed)

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

event: toast
data: {"type":"balance_changed","data":{"reason":"recharge_credit"}}
标识 含义 收到后前端该做什么
recharge_credit 充值已到账 回源刷一次余额(含游戏内钱包)
rescue 免费注册奖金 · 幸运救援已补发到免费账户 free_money 回源刷一次【账户余额】(游戏内钱包已由游戏回调更新,这里主要是站点顶栏 / 进度卡);弹窗另由同时下发的 promotion_trigger kind=free_register_bonus_rescue 负责,本信号不弹
void 免费注册奖金到期 / 输光作废,free_money 已清零(作废前余额已为 0 时不推) 回源刷一次【账户余额】;到期提示文案由同时下发的 type=msg toast 负责,本信号不弹
  • 一笔充值只推一条。充值赠送与本金在同一事务内入账,因此不单独推信号;收到 recharge_credit 后刷到的余额已含赠送。
  • reason 是开放集,遇到未知值也要照常刷新。它仅供埋点与可选文案,不要用它做白名单过滤(后端会直接新增取值,不等前端发版)。
  • 建议去抖 300~500ms:多笔充值几乎同时到账时会接连下发。
  • 不要弹 toast:同一笔到账已经有 type=msg(icon=recharge)负责提示,重复弹会造成双弹窗。
  • 不带金额与余额快照,也不要自己拿 msg 里的金额去加减:余额必须以业务接口返回为准。
  • SSE 可丢失,不能当唯一同步机制:原有的轮询 / 页面回前台刷新兼定时刷新必须保留,本事件只是把到账后的刷新提前。
  • 下注、派彩、活动领奖(含每日幸运补偿发奖)、后台赠送、提现等变动当前不推此事件;免费账户的普通下注 / 派彩也不推,仅幸运救援补发推。

客户端接入规则

以下为强制契约,逐条实现:

  1. 登录后才建连,未登录不建连;退出登录时主动 close()。
  2. 收到 toast 直接渲染,不发任何回源请求。type=msg 的 data.content 已是服务端翻好的成品文案,剥标签 / 转义后直接展示(不要查词典、不要插值)。例外只有两类:type=promotion_trigger(见活动触发清单)与 type=balance_changed(见余额变动清单),它们不带业务数据,必须回源拉一次对应接口。
  3. 遇到未知 type、未知 icon 或未知活动标识一律静默处理:未知 type / 活动标识直接丢弃,未知 icon 按默认样式展示;不得展示原始标识、不得弹错误提示、不得打断用户操作。后端新活动上线会直接新增标识,也可能新增 type / icon 取值,不会等前端发版。
  4. onerror 不做降级轮询——连接不上就算了,没有 toast 不影响任何业务。需要恢复连接时走「重新签发 ticket → 新建 EventSource」(ticket 一次性,浏览器原生重连必然 401)。
  5. 重连必须带退避与次数上限(建议指数退避,起步不低于服务端下发的 retry: 值)。签票接口有 1 分钟 / 30 次的硬限流,无退避的裸重连会被 429 挡住并放大故障,详见签票频次限制。
  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 文案由服务端翻译,语言在签票那一刻固化到连接上;不重建就会继续收到旧语言的文案。

常见坑

  • 本地开发多开标签页会把页面卡死: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 为准(见余额变动清单)。
  • 无退避重连会把自己锁在门外:签票接口 1 分钟只允许 30 次。写成 onerror 里立即重签重连的,只要服务端滚动发布或玩家网络抖一下,瞬间就会打满配额,后续合法重连全部 429。
  • 「刚连上就断」不是 bug:服务端停机中或连接数触顶时,HTTP 已经返回 200,但连接会被立即关闭且没有 event: closed。不要把它当成异常上报或提示玩家,按退避重连处理即可。
  • 切网后服务端不会立即发现:飞行模式 / 拔网线这类不发 FIN 的断开,服务端靠 TCP keepalive 约 60 秒才能回收旧连接。前端无需为此做任何特殊处理(照常重新签票建连),但要知道这 60 秒内自己会多占一个 3 条名额。