从 JWT 心跳包看「停留时长」类任务的判定设计

举报
yd_271372592 发表于 2026/11/12 15:59:22 2026/11/12
【摘要】 把「停留满 30 秒得积分」这类任务的心跳机制拆开来看:解出 JWT payload 里的 accumulated_ms 累计值、max_gap_ms 间隔上限、nonce 防重放,解释为什么后台标签页会失败,以及任务状态与积分余额为何不同步。

研究缘起

很多技术平台都有「浏览满 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 秒的间隔上限,服务端判定会话中断。

对应的处理是两层:

  1. 启动浏览器时加上 --disable-background-timer-throttling、--disable-backgrounding-occluded-windows、--disable-renderer-backgrounding,让后台标签页也能正常跑定时器;
  2. 用 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,三个简单的字段组合起来,就把大部分伪造手段堵住了。

至于结算不同步的问题,属于工程上很常见的取舍——用异步换吞吐,代价是最终一致。理解这一点,才能在对接这类接口时不踩坑。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。