鸿蒙实践中心「每日浏览一个案例」任务背后的 30 秒驻留机制
鸿蒙实践中心「每日浏览一个案例」任务背后的 30 秒驻留机制
华为云开发者成长中心最近上了两个鸿蒙相关的新任务:「每日浏览一个案例」+10 分和「每日 Star 一个鸿蒙案例代码仓」+30 分。落地页是鸿蒙开发实践中心的 #practice-tasks 区块,页面上的原话很短:
打开任意案例详情页并保持浏览 30 秒
一开始按字面做:打开案例详情页,把页面挂着不动,等 40 秒。结果任务状态一直停在「未完成」,控制台里也没有任何业务请求。这说明「保持浏览 30 秒」不是靠打开页面本身来判定的。
一、先把上报接口找出来
直接给 XMLHttpRequest 和 fetch 打钩子,在页面加载之后再注入,抓到的是空的。原因是这套前端是模块化加载的,业务脚本的初始化早于注入时机。
改用 CDP 的 Page.addScriptToEvaluateOnNewDocument,把钩子脚本注册成「每个新文档执行前先跑」,再导航到案例详情页,结果立刻不一样:
fetch GET 200 /api/growth/tasks/case-view-daily
fetch GET 200 /api/cases/<case_id>/document
fetch POST 200 /api/growth/tasks/case-view-daily/sessions
三个接口就把机制讲清楚了:先查任务状态,再建一个「浏览会话」。但建完会话之后就没有下文了,停留 42 秒也没有再发任何请求。
二、任务的判定模型
直接调 GET /api/growth/tasks/case-view-daily,服务端把评分模型整个吐了出来:
{
"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—— 累计满 30 秒才算完成;max_gap_ms: 15000—— 两次心跳之间的间隔不能超过 15 秒,超了这段时长就不计入。
所以前端应该在做一件事:建会话之后,定期发心跳把时长累加上去。问题是我没抓到心跳请求——因为第一次注入钩子时只覆盖了 XHR 和 fetch,没覆盖 navigator.sendBeacon。
三、把心跳接口挖出来
与其继续猜,不如直接从加载的 JS 里找。用 performance.getEntriesByType('resource') 拿到案例页实际加载的全部 25 个 JS 资源,逐个下载下来搜 /api/ 路径,在 LayoutComponent.vue_vue_type_script_setup_true_lang-BAWj-PVb.js 里找到了关键的两段:
function Rt(t) {
return k(`/api/growth/sessions/${encodeURIComponent(t)}/heartbeat`, {
method: "POST",
credentials: "include",
body: JSON.stringify({ session_id: t })
})
}
于是完整的协议就是:
1. POST /api/growth/tasks/case-view-daily/sessions,body 里带 case_id,拿到一个 session_id;
2. 每隔不超过 15 秒,POST /api/growth/sessions/{session_id}/heartbeat,body 里带 session_id;
3. 累计到 30 秒,任务自动置为完成。
四、踩到的最大的一个坑:session_id 是带状态的
第一次实现的时候,我建了会话,然后拿同一个 session_id 连发 5 次心跳,间隔 10 秒,心想累计 50 秒总够了吧。结果:
心跳 #1 accumulated_ms=10070
心跳 #2 accumulated_ms=0
心跳 #3 accumulated_ms=0
心跳 #4 accumulated_ms=0
心跳 #5 accumulated_ms=0
只有第一次累加了,后面全归零。把 session_id 当 JWT 解开才明白:它不是随机 ID,而是一个签名过的状态令牌,里面直接编码了累计进度:
{
"accumulated_ms": 0,
"case_id": "c5f78fbd...",
"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;下一次心跳必须用这个新令牌。用旧令牌,服务端看到的是「上一拍时间已经过期」,直接把增量为零处理。
改成链式传递之后就顺了:
心跳 #1 accumulated_ms=9068
心跳 #2 accumulated_ms=18128
心跳 #3 accumulated_ms=27187
心跳 #4 accumulated_ms=36246
★ 已达 30 秒
[最终] today_status=done completed_at=1793674538182
五、这个设计为什么合理
把它当作正常功能看,这套机制其实挺讲究:
- 防挂机:光打开页面不算数,必须有持续的交互证据(心跳),而且心跳间隔超过 15 秒就丢弃那一段,挂在后台标签页上不会白拿分;
- 无状态服务端:时长不存数据库,而是编码进签名令牌由客户端回传。服务端每次只做「解签 → 校验时间差 → 重新签一个」,省掉了一轮存储和清理定时任务;
- 不可伪造:客户端改不了
accumulated_ms,因为令牌有签名;想直接跳到 30 秒,得先有密钥。
从做题角度说,结论也很清楚:这类 dwell 任务不能靠「等」,得把心跳节奏按上来——间隔压到 10 秒左右,连续 4 拍就能稳稳跨过 30 秒门槛。
六、小结
| 环节 | 关键点 |
|---|---|
| 找上报接口 | 页面加载后再打钩子会漏;用 Page.addScriptToEvaluateOnNewDocument 先注入 |
| 理解判定 | GET /api/growth/tasks/{task_key} 会返回 evaluator/need_seconds/max_gap_ms |
| 心跳接口位置 | 在业务 JS chunk 里搜 /api/;performance.getEntriesByType('resource') 能列全 |
| 最容易错的地方 | session_id 是状态令牌,每次响应要给下一次用,不能复用 |
| 节奏 | 间隔 10 秒、连发 4 次,稳定过 30 秒 |
顺便说一句,同页面的第二个任务「Star 鸿蒙开发实践中心案例仓」走的是完全不同的判定:evaluator: "external_check",服务端配置了 orgs: ["harmony-practice-center"]、match: "any",也就是绑好 GitCode 之后,只要该组织下任意一个仓库被你 Star 过就算完成。这类任务不开心跳,靠的是外部状态比对,逻辑反而更简单。
- 点赞
- 收藏
- 关注作者
评论(0)