从 JWT 心跳包看「停留时长」类任务的判定设计
研究缘起
很多技术平台都有「浏览满 30 秒得积分」这类任务。看起来很简单:打开页面,等一会儿,积分到账。但真去拆解时会发现问题不少——浏览器会节流后台标签页,网络会抖,用户会切走再切回来。这些情况该怎么算?
本文记录一次实际的观察过程:把页面发出的心跳请求抓下来,解开里面的 JWT payload,看服务端到底是怎么设计的。所有结论都基于实际抓到的数据。
抓包第一步:找到心跳请求
打开案例详情页后,用 Chrome DevTools Protocol 的 Network.enable 监听请求,很快就定位到三个关键请求:
GET /api/growth/tasks/case-view-daily
POST /api/growth/tasks/case-view-daily/sessions
POST /api/growth/sessions/<jwt>/heartbeat
第一个查任务状态,第二个开启一次浏览会话,第三个是周期性的心跳上报。注意第二个请求的 body 里带着 {"case_id":"..."},说明会话是绑定到具体案例上的。
解开心跳里的 JWT
心跳请求的 path 里直接嵌了一个 JWT。把它按 . 切开,取第一段做 base64url 解码,得到这样的 payload:
{
"accumulated_ms": 434,
"case_id": "e0e8acc25fcb4590a70fe7823a00a165",
"domain_id": "3169a158dfea4534bb275f68d178a14a",
"domain_name": "sixgod185",
"exp": 1794276463659,
"iam_id": "9abfd39a5e2b417cb02bd63b89f19468",
"last_beat_at": 1794274663656,
"nonce": "_neicA4Wvz8",
"task_key": "case-view-daily"
}
这里面最有意思的是 accumulated_ms:它不是「本次会话开始到现在」的时间差,而是累计值。第一次心跳是 434ms,第二次跳到 4264ms,再后面 4879ms,一直在往上加。
这个设计意味着:服务端不需要保存会话状态,客户端每上报一次就把累计值加进去,服务端只需校验新的累计值是否大于旧的、以及两次心跳的间隔是否合理。这是一种无状态的做法,水平扩展时不用担心会话粘性。
两个关键参数:need_seconds 和 max_gap_ms
任务状态接口里返回了另外两个字段:
GET /api/growth/tasks/case-view-daily
{
"evaluator": "dwell",
"period": "DAILY",
"today_status": "todo",
"need_seconds": 30,
"max_gap_ms": 15000
}
need_seconds: 30 是达标门槛,好理解。max_gap_ms: 15000 才是真正的质量控制点:两次心跳之间不能超过 15 秒。
为什么需要这个?因为只有累计值是不够的——客户端可以先等 40 秒,然后一次性上报 accumulated_ms: 40000,服务端看到数字够大就放行。加上 15 秒的间隔上限,就要求客户端必须持续、规律地上报,不能「憋一发大的」。
换句话说,max_gap_ms 把「停留时长」从一个可以伪造的数字,变成了需要真实时间流逝才能满足的条件。
为什么后台标签页会失败
实际测试时遇到一个典型问题:把标签页切到后台,心跳一次都没发出去,任务永远不达标。
原因是 Chrome 对不可见标签页做定时器节流。页面里 setInterval 驱动的上报在高频场景下会被降到一分钟一次甚至更低,直接超过 15 秒的间隔上限,服务端判定会话中断。
对应的处理是两层:
- 启动浏览器时加上
--disable-background-timer-throttling、--disable-backgrounding-occluded-windows、--disable-renderer-backgrounding,让后台标签页也能正常跑定时器; - 用 CDP 的
Target.activateTarget把目标标签页真正激活到前台,而不是仅靠参数绕过。
这两层都要做。只加启动参数,页面在部分场景下仍会被判定为 occluded;只激活标签页,用户实际使用时又不可能一直盯着。
顺带发现的一个细节:结算和状态不同步
观察过程中还发现另一件事:任务状态接口返回 today_status: "todo" 时,积分余额其实已经涨了。
对比两个数据源就很清楚:
GET /growth/bonus-beans -> {"beans": 850}
GET /growth/bonus-flow -> {"count":N, "BonusFlowList":[
{"detail":"每日浏览一个鸿蒙案例","beans":10,
"commit_time":"2026-11-12 07:54:30","balance":850}
]}
这是典型的异步结算:行为发生 → 事件入队 → 结算任务跑批 → 写余额流水,最后才回写任务状态。中间几个阶段的时间戳并不一致。
所以做任何判断时,余额流水的 commit_time 是唯一可信源,任务状态字段只能当参考。这一点对写自动化脚本尤其重要:用状态字段做轮询条件,会在积分到账之后还傻等。
小结
把上面的观察整理成一张表:
字段 作用
-------------- ------------------------------
accumulated_ms 客户端累计停留时长,无状态设计
need_seconds 达标门槛
max_gap_ms 心跳最大间隔,防止一次性上报
exp JWT 过期时间
nonce 防重放
task_key 会话归属的任务
整体看下来,这套设计并不复杂,但每个字段都对应一个具体的作弊路径。真正值得借鉴的是思路:不要试图判断「用户是否认真看了」,而是设计一组让偷懒变得困难的约束。累计值 + 间隔上限 + nonce,三个简单的字段组合起来,就把大部分伪造手段堵住了。
至于结算不同步的问题,属于工程上很常见的取舍——用异步换吞吐,代价是最终一致。理解这一点,才能在对接这类接口时不踩坑。
- 点赞
- 收藏
- 关注作者
评论(0)