把状态塞进令牌:一次「累计驻留 30 秒」的心跳协议拆解
需求:怎么证明一个用户真的「看」了 30 秒
做在线学习、案例浏览这类场景,几乎一定会遇到同一个需求:判断用户是否真的在页面上停留了足够长的时间。任务描述写得很轻松——「打开任意案例详情页并保持浏览 30 秒」——但落到实现上,这句话根本没法直接执行。
「打开页面」是客户端能自证的事,而「保持浏览 30 秒」是服务端需要相信的事。用户完全可以在打开页面的瞬间就切走,或者干脆不开浏览器,直接用脚本发一个「我看完了」的请求。所以真正的问题不是「怎么计时」,而是怎么让服务端在不信任客户端的前提下,仍然愿意相信这段时长的真实性。
把任务接口拉出来,服务端把整个评分模型都写在响应里了:
{
"task_key": "case-view-daily",
"evaluator": "dwell",
"period": "DAILY",
"points_hint": 10,
"need_seconds": 30,
"max_gap_ms": 15000,
"today_status": "todo",
"elapsed_seconds": 0
}
三个字段定义了整套规则:evaluator: dwell 说明按累计驻留时长判定;need_seconds: 30 是门槛;max_gap_ms: 15000 是心跳间隔上限。不是「打开页面就算」,而是「要有持续的心跳证据,且相邻两拍间隔不能超过 15 秒」。
协议本身很朴素
整个交互只有两个接口:
# 1) 建立浏览会话
POST /api/growth/tasks/case-view-daily/sessions
body: {"case_id": "c5f78fbd5a5e48d98857520b3c8f6565"}
→ {"session_id": "<JWT>", "need_seconds": 30, "max_gap_ms": 15000, "server_time": ...}
# 2) 每隔 < 15 秒发一次心跳
POST /api/growth/sessions/{session_id}/heartbeat
body: {"session_id": "<当前 JWT>"}
→ {"session_id": "<新 JWT,累计时长已更新>", ...}
看起来平平无奇。但如果按最常见的直觉去实现——建一次会话,然后拿同一个 session_id 连续发心跳——会得到一个非常诡异的结果:
心跳 #1 accumulated_ms = 10070
心跳 #2 accumulated_ms = 0
心跳 #3 accumulated_ms = 0
心跳 #4 accumulated_ms = 0
心跳 #5 accumulated_ms = 0
只有第一拍累加,之后全部归零。发五拍、每拍间隔 10 秒,按说累计该有 50 秒,实际永远停在 10 秒出头,任务状态纹丝不动。
把 session_id 解开就明白了
第一反应通常会是「服务端在按 IP 或按会话做去重」,于是去查请求头、加延时、换 case_id——全是错的方向。真正的线索在那个看起来像随机 ID 的字符串里:它其实是个 JWT。
{
"accumulated_ms": 0,
"case_id": "c5f78fbd5a5e48d98857520b3c8f6565",
"domain_id": "01a13f7c...",
"iam_id": "01a13f7c...",
"last_beat_at": 1793674430626,
"nonce": "9SmJKAKkIE0",
"task_key": "case-view-daily",
"exp": 1793676230631
}
session_id 不是会话标识,而是一个签名过的状态载体。进度(accumulated_ms)和上一拍时间(last_beat_at)直接编码在令牌里,服务端不需要为每个用户存任何东西。
于是「复用旧令牌」的后果就清楚了:服务端解签拿到的 last_beat_at 是第一拍的时间。当第五拍到达时,它算出「距上一拍已经过去 40 秒」,超过 max_gap_ms 的 15 秒上限,于是判定这一段驻留作废,增量为零。令牌里的 accumulated_ms 也仍然是旧的 0,所以怎么发都推进不了。
正确做法是链式传递——每次心跳的响应都返回一个新令牌,下一次必须用这个新的:
bean = create_session(case_id)
sid = bean["session_id"]
while elapsed < 30:
r = post_heartbeat(sid) # body 里的 session_id 就是当前这个
sid = r["session_id"] # ★ 关键:立刻换成新的
elapsed = r["accumulated_ms"] / 1000
改成链式之后立刻正常:
心跳 #1 accumulated_ms = 9068
心跳 #2 accumulated_ms = 18128
心跳 #3 accumulated_ms = 27187
心跳 #4 accumulated_ms = 36246
★ 已达 30 秒
[最终] today_status = done completed_at = 1793674538182
间隔压到 9 秒左右,四拍就能稳稳跨过 30 秒门槛。
这个设计其实很讲究
一开始我只觉得它别扭——为什么要把状态塞进令牌里来回传?想清楚之后会发现这是一个相当克制的取舍。
一、防挂机。只记录「何时打开过页面」是不够的,用户开完就切走照样能拿分。要求持续心跳、且间隔超时即作废,等价于要求「页面在被观测期间一直活着」。后台标签页会被浏览器节流,心跳自然稀疏,正好被这道规则挡掉——这大概是设计者顺手得到的好处。
二、无状态服务端。时长不落库,而是编码进签名令牌由客户端回传。服务端每拍只做三件事:解签、校验时间差、重新签一个。省掉了「会话表 + 过期清理定时任务」这一整套基础设施,也不存在「会话记录残留几百万条」的运维问题。对于每天几十万人打卡的功能,这个成本差异很实在。
三、不可伪造。客户端改不了 accumulated_ms,因为令牌有签名。想凭空跳到 30 秒,得有服务端的密钥。这比「前端上报一个 duration 字段」强得多。
四、令牌自带过期。exp 让「开一个会话放着,隔天再来发心跳」不成立,会话窗口天然收窄。
本质上这是一种用带宽换存储的做法:每拍多传几百字节,换取服务端零状态。当状态很小(一个整数加一个时间戳)、而请求频率可控时,这笔交易是划算的。
但代价也真实存在
不理解这套语义而按直觉写代码,就会掉进前面那个坑,而且症状极具误导性:请求返回 200、响应体结构完全正常、accumulated_ms 字段也存在,只是值不动。
更麻烦的是它的失败方式——不是报错,而是静默地什么都不发生。这类 bug 的排查成本远高于抛异常的那种,因为你手上没有任何错误信息可以追,只有一个「该变大却没变大」的数字。
所以用这类接口时,有三个习惯值得养成:
- 先读判定参数,再写实现。
evaluator/need_seconds/max_gap_ms这三个字段当时就摆在接口响应里,我却在「等 40 秒」上耗了一轮才发现任务压根不是按打开页面判定的。 - 看响应里有没有「需要回传的东西」。如果接口返回了一个看起来像 ID 但长度可疑的字符串,解开看看——很可能它不是资源标识,而是状态。
- 把「动作成功」和「状态推进」分开验证。200 只证明请求被受理,不证明业务前进了。要有独立的读接口(这里是
GET /api/growth/tasks/<key>)做对账。
小结
「累计驻留 30 秒」这种看似简单的需求,背后其实是在回答一个更本质的问题:如何在一个不可信的客户端上,建立一个服务端愿意采信的时间证明。用签名状态令牌 + 心跳间隔上限,是其中一种相当优雅的答案——它把「证明」的成本从服务端存储转移到了往返传输上。
而作为调用方,最该记住的一条是:当接口返回的东西不是数据而是一个带状态的信物时,它就必须被当作接力棒传下去。拿着发令枪跑完全程,是跑不完的。
- 点赞
- 收藏
- 关注作者
评论(0)