鸿蒙实践中心「每日浏览一个案例」任务背后的 30 秒驻留机制

举报
yd_254390150 发表于 2026/11/03 10:57:52 2026/11/03
【摘要】 鸿蒙开发实践中心的每日浏览任务不是打开页面就算数,而是靠心跳累计驻留时长。记录从 CDP 预注入钩子找上报接口、逆向 evaluator=dwell 评分模型,到发现 session_id 是带状态令牌必须链式传递的完整过程。

鸿蒙实践中心「每日浏览一个案例」任务背后的 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 过就算完成。这类任务不开心跳,靠的是外部状态比对,逻辑反而更简单。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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