站内信接口
本文件包含两条互相独立的链路:
- 站内信(
/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 条软上限)。切网后马上回来并重建,最坏情况是自己最旧的那条被踢掉,属预期行为。
- 这不影响前端行为:前端本来就要走「重新签发 ticket → 新建
事件格式
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只用于「资格刚生成、需回源拉详情」的场景。
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 可丢失,不能当唯一同步机制:原有的轮询 / 页面回前台刷新兼定时刷新必须保留,本事件只是把到账后的刷新提前。
- 下注、派彩、活动领奖(含每日幸运补偿发奖)、后台赠送、提现等变动当前不推此事件;免费账户的普通下注 / 派彩也不推,仅幸运救援补发推。
客户端接入规则
以下为强制契约,逐条实现:
- 登录后才建连,未登录不建连;退出登录时主动
close()。 - 收到 toast 直接渲染,不发任何回源请求。
type=msg的data.content已是服务端翻好的成品文案,剥标签 / 转义后直接展示(不要查词典、不要插值)。例外只有两类:type=promotion_trigger(见活动触发清单)与type=balance_changed(见余额变动清单),它们不带业务数据,必须回源拉一次对应接口。 - 遇到未知
type、未知icon或未知活动标识一律静默处理:未知type/ 活动标识直接丢弃,未知icon按默认样式展示;不得展示原始标识、不得弹错误提示、不得打断用户操作。后端新活动上线会直接新增标识,也可能新增type/icon取值,不会等前端发版。 onerror不做降级轮询——连接不上就算了,没有 toast 不影响任何业务。需要恢复连接时走「重新签发 ticket → 新建EventSource」(ticket 一次性,浏览器原生重连必然401)。- 重连必须带退避与次数上限(建议指数退避,起步不低于服务端下发的
retry:值)。签票接口有 1 分钟 / 30 次的硬限流,无退避的裸重连会被429挡住并放大故障,详见签票频次限制。 - 要容忍「刚连上就断」:服务端停机或连接数触顶时,会在接受连接后立即关闭(无
event: closed)。这是正常现象,按第 4/5 条退避重连即可,不要向玩家报错。 visibilitychange必须实现:页面转hidden超过 5 分钟主动close(),转visible时按第 4 条重建。重建后不补拉断连期间的 toast(已丢失,符合本通道定位)。不实现会让后台标签页长期占用服务端连接。- 展示时长与叠加策略由前端自定(自动消失时间、堆叠还是排队);
data.show_time当前恒为0(= 前端自定),服务端下发非 0 值时才按其建议秒数展示。 - 收到
event: closed必须close()且不再重连,用于与网络故障断开相区分。 - 单会员单实例最多 3 条连接,超出会踢掉最旧的一条。多标签页无需任何协调(最差是同一条 toast 在多个标签页各弹一次,无正确性问题),但不要在同一页面里建多条连接。
- 签票请求必须带
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 条名额。