做任务系统的自动化测试做了几年,最常被问到的一类问题是:某个成长任务面板上那十几个任务,能不能不点界面,直接用接口一次性跑完。这个问题背后其实藏着一条完整的工程链路——行为事件上报链怎么还原、协议怎么固化、状态怎么推进,最后才谈得上纯接口自动领奖。这次我把这套东西从头到尾拆一遍,讲清楚17个成长任务是怎么被一条脚本串起来的,每一环为什么这么设计,中间哪些地方最容易翻车。
这篇内容适合三类人看:做过客户端埋点或服务端任务系统的后端同学、想学接口自动化但只会用现成工具的测试同学、以及对"从抓包到协议还原"这条路径好奇的工程爱好者。读完之后你应该能自己搭一套结构类似的任务编排脚本,跑在你自己的测试环境里。先说明一件事:下文所有分析对象都是我自己搭建的模拟成长任务系统和它对应的测试客户端,所有协议都是我这边环境里的字段,讲的是方法论,不是某个线上产品的操作说明书。涉及真实业务系统时,动手之前先确认你有授权,这一点没有例外。
1. 先把问题拆开:这个"一键完成"到底在做什么
1.1 成长任务的四段式生命周期
很多人一上来就想直接调领奖接口,结果必然失败。成长任务这件事,从系统设计的角度看,是四个阶段串起来的:事件采集 → 事件上报 → 服务端校验与进度累加 → 达标发奖。这四个阶段是严格串行的,前一个没走完,后一个连门都进不去。
拿最常见的"完成3次收藏"这个任务举例。客户端在用户点击收藏时,本地SDK先记一条行为事件,塞进队列;到了上报时机,这条事件被打包发到服务端;服务端拿到事件后,先做合法性和防重放校验,再判断它是否匹配某个任务的条件;匹配上了,任务进度从1变成2,再变成3;进度达到目标值后,任务状态从"进行中"翻转为"可领取";最后一步才是用户点领取,服务端发放奖励并把状态置为"已领取"。
这四段里,普通用户只感知到第三段的进度条和第四段的小红点,但自动化要复现的是全链路。你得让服务端"相信"用户真的收藏了三次,而这件事的唯一凭证,就是那条事件上报请求。所以整件事的命门不在领奖接口,而在上报链的结构还原上。
1.2 为什么绕开界面直接调接口反而更稳
有同学会问:既然有界面,用UI自动化点不就完了?我试过,17个任务用UI脚本跑,平均要在30分钟左右,失败率接近三成。原因很朴素:界面层耦合了太多跟业务无关的东西——页面加载动画、元素定位、滚动位置、弹窗拦截、版本改版导致的选择器失效。任何一样出问题,整条脚本就断了。
而接口层的调用只有三件事:请求能不能构造对、签名能不能算对、服务端状态机能不能推进对。前两件是确定性的,第三件可以通过轮询拿到明确的结果。确定性的东西就好办,能重试、能复现、能加日志。
还有个更实际的原因:很多成长任务的触发条件在界面上根本不好模拟。比如"首次在XX页面停留超过60秒",你总不能在UI脚本里真的干等一分钟;但在事件层,这条事件就是一次带时长字段的上报,构造起来干净利落。所以纯接口方案不只是快,它解决的是"事件条件难以在UI层复现"这个根本问题。
注意:这里的"稳"是建立在协议已经被完整还原、且你拥有合法测试权限的前提下的。协议还原不完整时,接口方案的失败率会比UI方案更高,因为UI至少能靠肉眼看出卡在哪一步。
1.3 合规边界:能做什么,碰不得什么
这条得单独拎出来说,因为这件事很容易滑到不该去的地方。我给自己定的边界是三条:只用在自己拥有或已获书面授权的系统上;只用于自动化测试、压测、回归验证这类正当工程目的;不用于规避任何平台的规则去获取不当利益。
具体到操作层面,如果你分析的是你自己的系统,那随便拆,这是你的资产;如果是合作方提供的测试环境,先拿到明确的测试授权和接口说明;如果是别人的线上系统,那就到此为止,看看设计思路就够了,别往下走。很多同学栽跟头不是因为技术问题,是因为把"我想学协议设计"和"我要拿它做点什么"混为一谈了。前者值得鼓励,后者风险自负。
2. 行为事件上报链的结构还原
2.1 事件模型:一条上报到底带了什么
我这边环境里,一条行为事件的请求体大概长这样。字段名做了脱敏,但结构是完整的:
{ "event_id": "e_20240518143012_a7f3", "event_type": "collect_item", "occur_ts": 1716013812, "client_seq": 128, "session_id": "s_9c1e4b...", "device_id": "d_5f2a...", "app_version": "3.7.2", "extra": {"item_id": "1024", "page": "detail"}, "nonce": "8f3c1a9d", "sign": "b41d9e..." }这十来个字段各有各的用处,缺一个都可能被打回。event_type是业务语义,服务端靠它匹配任务条件;occur_ts是事件真实发生的时间,用来做时间窗口校验,一般允许的正负偏差在300秒左右;client_seq是客户端自增序号,服务端用它做顺序校验,防止乱序;nonce是随机串,用于去重;sign是签名,防篡改。
我一开始以为client_seq只是个摆设,结果有一次本地调试时偷懒固定成1,连续上报十几条,服务端全部接收了但进度只增加了1——原因是服务端用(device_id, client_seq)做了幂等键。这个细节如果不抓包对比多次请求,根本发现不了。所以还原一条链路,最重要的动作是对同一种行为抓三次以上的包,逐字段做diff,把变的和不变的区分开。变的通常是时间戳、随机数、序号、签名;不变的是设备和版本信息。分清楚这两类,模型就立起来了。
2.2 签名与防重放:那串 sign 是怎么算出来的
签名是整条链路上最硬的一关,也是最容易让人卡住的地方。常见的做法是把参与签名的字段按字典序排序,拼成key=value&key=value的形式,末尾追加密钥,再做一次哈希。我这边环境的实现是 HMAC-SHA256:
import hmac, hashlib, time, uuid def build_sign(params: dict, secret: str) -> str: items = sorted((k, str(v)) for k, v in params.items() if k != "sign") raw = "&".join(f"{k}={v}" for k, v in items) return hmac.new(secret.encode(), raw.encode(), hashlib.sha256).hexdigest()看着简单,但实际还原时有三个坑:
第一,哪些字段参与签名。有的实现是全字段参与,有的是白名单,有的会把sign之外的全部算进去。判断方法是做对照实验:构造两个请求,只改动某个字段,看服务端返回的是"签名错误"还是"参数错误"。返回签名错误说明该字段参与了签名,反之则没有。这个技巧我用得最多,靠逐字段试探就能把签名范围圈出来。
第二,拼接格式的细节。&还是|,=还是:,值要不要URL编码,空值参不参与——这些都是要靠报错反推的。我的做法是先实现一版,跑一遍,错就调整格式,一般五六轮之内能对上。
第三,密钥从哪来。这一步在自有环境里很简单,读取本地配置就行。但要注意密钥可能有版本,跟着app_version走。我把密钥做成可配置项放在独立文件里,切换版本时只改配置不改代码。
防重放这边,服务端一般做两件事:occur_ts跟服务器时间比对,超窗口直接拒;nonce在缓存里查,存在就拒,不存在则写入并设置TTL(我这边是600秒)。这意味着自动化脚本每次上报都必须生成新的 nonce,而且时间戳要用当前时间而不是随便填。我用固定时间戳调试过一次,前几条成功后面全被拒,排查了半小时才反应过来是时间窗口的问题。
2.3 服务端校验的三道闸门
搞清楚了请求怎么构造,还得搞清楚服务端会拦什么。我实测下来,这类任务系统的校验大致分三道:
| 闸门 | 校验内容 | 失败表现 | 应对思路 |
|---|---|---|---|
| 第一道:身份与签名 | 令牌有效性、签名正确性、时间窗口 | 401 / 签名错误 | 保证会话新鲜、签名逻辑逐字段对齐 |
| 第二道:事件合法性 | 事件类型是否注册、字段是否完整、序号是否递增 | 参数错误 / 静默丢弃 | 字段严格按抓包结果构造,序号单调递增 |
| 第三道:行为合理性 | 事件间隔、单日频次、序列是否符合业务逻辑 | 200但进度不动 | 加随机间隔、控制速率、按真实操作顺序编排 |
第三道闸门最值得说。它不报错,就默默不给你加进度,你从返回体上完全看不出来。我第一次遇到的时候以为是签名问题,反复排查了两个小时,最后把请求间隔从50毫秒改成1.2秒才发现通了。后来我总结:凡是返回成功但状态没变化的,先怀疑频率和序列,再怀疑其他。
序列这块也有讲究。比如"浏览商品→加入收藏→下单"这类任务链,服务端可能校验前置事件是否存在,你直接发下单事件,它不会报错但也不会算数。所以任务编排不能只看单个任务的条件,要把任务之间的依赖关系也排进去。
2.4 版本差异与字段漂移
协议不是一成不变的。我这边的测试客户端在两个版本之间做了三次调整:新增了一个channel字段、extra从字符串改成了对象、client_seq的起始值从0变成了1。每一次改动都让上一版脚本报废。
应对办法是把协议描述从代码里抽出来,做成独立配置。代码只负责"读配置→构造请求→发出去→解析回包",具体字段长什么样由配置决定。这样版本一变,改的是配置文件不是逻辑代码,改动量和出错面都小一个数量级。这个思路后面第3节会展开讲。
3. 从抓包到可复用协议:把链路固化成配置
3.1 抓包与字段清洗
抓包本身没什么技术含量,难的是清洗。原始抓包里混着大量跟自动化无关的东西:Cookie里一堆统计标识、请求头里的浏览器指纹、一堆用不上的埋点事件。直接照抄的结果就是脚本又长又脆,改一处崩一片。
我的清洗原则是三条:只保留服务端真正校验的字段、把可变字段参数化、把固定字段提到公共配置里。判断"真正校验"的方法是删字段测试——从上往下一个个删,删掉之后报参数错误的就留下,删掉没影响的就去掉。这个过程有点笨,但一轮下来能砍掉三成以上的冗余字段。
清洗完之后,一条请求应该只剩下三类东西:公共头(设备、版本、令牌)、事件主体(类型、时间、序号、扩展)、安全字段(nonce、签名)。分好类之后,配置文件的结构自然就出来了。
3.2 协议描述文件设计
我用 YAML 来描述协议,一个任务对应一个条目,大致长这样:
tasks: - key: collect_3 name: "完成3次收藏" event_type: "collect_item" repeat: 3 extra_template: item_id: "${rand_item}" page: "detail" interval_ms: [900, 1600] verify: progress_api: "/task/progress" task_key: "collect_3" target: 3这份配置干了几件事:告诉执行器要发什么事件、发几次、每次的扩展字段怎么填、间隔多久、发完之后用哪个接口去验证进度。${rand_item}这种占位符在执行时会被替换成随机值,避免每次上报的item_id都一样触发重复判定。
有了这层抽象,17个任务的编排就从"写17段代码"变成了"写17条配置"。加任务、改参数、调间隔,全在配置里完成。我后来把这个文件扩展到了四十多条,代码一行没动。
3.3 状态机驱动:17个任务怎么排
任务之间不是完全独立的。有些任务有前置依赖,有些共享同一个事件类型,有些领奖之后会刷新出新任务。所以执行器不能简单地 for 循环遍历,得有个状态机。
我把每个任务的状态定义为四个:未开始、进行中、可领取、已领取。执行器的工作就是把这个状态往前推。
STATE_TODO, STATE_DOING, STATE_CLAIMABLE, STATE_DONE = range(4) def run_task(ctx, task): # 阶段一:先查当前状态,已领过的直接跳过 prog = ctx.query_progress(task["key"]) if prog["status"] == "claimed": return STATE_DONE # 阶段二:补事件,直到进度达标 need = prog["target"] - prog["current"] for _ in range(need): ctx.send_event(task["event_type"], ctx.render(task.get("extra_template"))) ctx.sleep_random(*task.get("interval_ms", [800, 1500])) # 阶段三:轮询确认进度 if not ctx.wait_until(lambda: ctx.query_progress(task["key"])["status"] == "claimable", timeout=10): return STATE_DOING # 阶段四:领奖 ctx.claim(task["key"]) return STATE_DONE这段逻辑最关键的地方是先查状态再补事件,而不是无脑发满次数。原因很实际:脚本可能中途断掉重跑,如果每次都发满3次,第二次跑就会超额上报,触发频率限制。先查差多少补多少,天然就是幂等的,断了重跑也不会出问题。
另外,need可能为0(进度已经够了但没领奖),这时候直接跳到轮询和领奖阶段。这种情况在重跑时非常常见,处理不好就会白跑一轮。
4. 纯接口自动领奖的实现细节
4.1 会话与凭证管理
所有请求都依赖一个有效的会话。凭证一般是登录后拿到的令牌,有效期从几十分钟到几天不等。这里有两个实践点:
一是令牌要带过期时间做本地缓存。我一开始每次跑脚本都重新登录,结果触发了登录频次限制。后来改成把令牌和时间戳存到本地文件,剩余有效期超过10分钟就直接复用,低于阈值再重新登录。这个改动让脚本的整体耗时降了不少。
二是令牌失效要有统一的处理入口。服务端返回401的时候,不能简单抛异常,应该触发一次重新登录再重放当前请求,最多重试一次。重试还失败就停下来报错,别死循环。我在封装层统一做了这件事,业务代码里完全不用关心令牌。
4.2 事件上报的构造与发送
上报请求的构造分三步:填模板、补动态字段、算签名。顺序不能乱,因为签名依赖前面的所有字段。
def send_event(ctx, event_type, extra): now = int(time.time()) ctx.seq += 1 body = { "event_id": f"e_{now}_{uuid.uuid4().hex[:6]}", "event_type": event_type, "occur_ts": now, "client_seq": ctx.seq, "session_id": ctx.session_id, "device_id": ctx.device_id, "app_version": ctx.app_version, "extra": extra, "nonce": uuid.uuid4().hex[:8], } body["sign"] = build_sign(body, ctx.secret) resp = ctx.post("/event/report", json=body, timeout=8) return resp.json()这里的client_seq是进程内自增的,注意不要每次都从1开始——如果服务端用(device_id, client_seq)做幂等,重启脚本后序号回退就会造成新事件被当成重复丢弃。我的做法是把 seq 也持久化到本地状态文件里,启动时读出来接着往下走。
4.3 任务进度轮询与领奖判定
上报成功后不是立刻就变成可领取,服务端可能有异步处理。所以中间要加一轮轮询。轮询策略是:间隔1秒,最多10次,也就是10秒超时。这个参数是根据我这边的处理延迟定的,实测平均1.5秒内会更新,10秒是留了足够余量。
轮询超时之后怎么处理?我的选择是标记为"进行中"、跳过、继续跑下一个任务,全部跑完之后再回头重试一轮。这样能避免因为某个任务卡住拖垮整个流程。第二轮还失败的,单独输出到失败清单里人工看。
领奖请求本身很简单,带上任务key就行。但要注意两点:一是领奖也要做幂等保护,同一任务重复领奖应该返回明确的状态码而不是报错;二是领奖成功后最好再查一次状态,确认真的变成已领取,有些实现是在异步发放的。
4.4 限速、重试与幂等
限速这块我踩了最多次坑。参数是这么定的:
| 参数 | 取值 | 依据 |
|---|---|---|
| 单事件间隔 | 800~1600ms 随机 | 低于500ms时连续发会出现进度不增 |
| 全局QPS | 不超过2 | 超过后偶发429 |
| 单任务重试 | 最多2次 | 再多会放大风控触发概率 |
| 重试退避 | 1s / 3s | 指数退避,避免集中重放 |
| 轮询间隔 | 1000ms | 服务端处理延迟均值1.5s以内 |
随机间隔比固定间隔有效得多。固定间隔的特征太明显,服务端如果要统计间隔方差,一眼就能看出来。我在间隔上加了±30%的抖动之后,成功率明显提升。当然这不是教你去对抗检测,而是在真实的自动化测试里,人类操作本来就有随机性,脚本模拟得更接近真实反而更少产生脏数据。
重试要区分错误类型。网络超时、5xx 这类可以重试;4xx 里的参数错误、签名错误不要重试,重试一百次结果都一样,只会刷日志。我在封装层按状态码分流:5xx 和超时进重试队列,4xx 直接抛出并记录请求体,方便排查。
5. 常见问题与排查速查
5.1 签名类问题
签名错误是最常见的问题,表现形式是明确的报错,好排查。但有一种隐蔽情况:报错信息统一是"请求非法",不告诉你是哪个字段。这时候就要用二分法:把参数字段分成两半,先只对一半签,看结果;逐步缩小范围直到定位。
我把踩过的坑整理成了速查表:
| 现象 | 最可能原因 | 快速验证方法 |
|---|---|---|
| 全部请求签名错误 | 密钥不对或拼接分隔符不对 | 用抓包里的原始数据反算,看能否复现出同样的sign |
| 部分字段改了才报错 | 签名白名单不完整 | 逐字段改动做对照测试 |
| 时间戳用本地时间报错 | 服务器时区或时钟偏移 | 先调一次时间接口取服务端时间再算 |
| 偶发签名错误 | 排序不稳定或浮点数格式化差异 | 强制把所有值转字符串再排序 |
| 中文参数报错 | 编码方式不一致 | 统一UTF-8并检查是否做了URL编码 |
第一行的方法最好用:拿一条抓包记录里的完整参数,在本地反复算,直到算出的 sign 跟抓包完全一致。这一步通过了,说明算法、密钥、拼接格式全对,后面就只是参数构造的问题了。这一步过不了,后面全是白费。
5.2 进度不刷新与领奖失败
这类问题最磨人,因为没报错。我遇到过四种情况:
第一种是频率太高,前面提过,加间隔就好。第二种是事件序列不对,服务端要求某个前置事件先存在,得调整任务执行顺序或者补发前置事件。第三种是单日次数上限,我在测试环境里跑太多轮触发了,第二天才恢复。第四种最隐蔽——extra里的字段用了固定值,服务端做了去重,同一条内容重复上报不算新的行为。
领奖失败一般是三种原因:任务没真正达标(进度查询和领奖接口的判断条件不一致)、已经领过、奖励库存不足(测试环境常见)。排查顺序是先查进度再查领取记录,两个都对还失败就是服务端问题,直接看日志。
提示:调试阶段建议打开完整的请求/响应日志,把每次上报的 body 和返回都落到文件里。出问题时能直接对比"成功那次"和"失败那次"的差异,比凭记忆猜快十倍。
5.3 稳定性与并发
单任务串行跑17个任务,正常情况下一分半到两分钟能跑完。想提速的话可以小并发,但并发度一旦超过3,失败率就往上窜,而且失败原因很难归因——你不知道是并发导致的风控,还是某个任务本身的逻辑问题。
我的建议是:默认串行,把并发当成一个需要谨慎开启的选项。真要做并发,按任务之间是否有依赖关系分组,无依赖的组内并发,有依赖的串行。同时给并发加一个全局令牌桶,保证整体QPS不超过阈值。
还有一个稳定性细节:脚本要能处理中途的意外中断。我的做法是每完成一个任务就把状态写进本地 JSON 文件,重跑时先读状态跳过已完成的。这个改动看起来很小,但让调试体验好了非常多——不用每次都从头跑一遍。
6. 面板化与工程收尾
6.1 面板该有什么
脚本能跑之后,下一步自然是做成面板。我做的面板很简单,一个表格列出17个任务,每行显示状态、进度、最后一次操作时间和错误信息,顶部两个按钮:全量执行、只跑失败项。右半边是一块实时日志区。
面板的价值不在于好看,在于把不确定性可视化。之前排查一个偶发失败,翻日志翻得头大;有了面板之后,一眼就能看到某个任务连续三次都是"上报成功但进度不增",直接锁定是频率问题。
技术上用什么不重要,我用的就是最简单的本地Web页面加一个执行器进程,通过文件或内存队列通信。轻量、改起来快、不用维护额外服务。
6.2 可观测性与回归测试
自动化脚本最怕的是"今天能跑,下周不能跑,而且不知道为什么"。解决办法是留痕。
我做了三层记录:请求层记完整 body 和响应(脱敏后),任务层记状态流转,会话层记登录和令牌续期。三层结合起来,任何一次异常都能回溯到具体是哪一层的问题。
另外,每次协议有变更,我都会用固定的一组"黄金样本"跑一次回归:把抓包保存下来的请求凝固成测试用例,验证签名函数、字段构造、解析逻辑是否还对得上。这样版本升级的时候,能在脚本真正跑之前就发现不兼容的地方。
6.3 我踩过的几个坑
最后分享三条我觉得最值钱的经验。
第一条:不要过早优化。我一开始就想把17个任务做成完全数据驱动的通用框架,结果配置写了一堆抽象层,实际只用到了三成。后来砍掉多余抽象,老老实实一条配置对应一个任务,反而维护成本最低。抽象是在重复出现第三次之后才值得做的事。
第二条:日志要在动手前就设计好。前两版脚本我都是先写逻辑后加日志,结果每次出问题都要重新加打点、重跑、再等失败复现。第三版我一开始就把请求、响应、状态、耗时全都落下来,后面排查问题的速度提升了不止一倍。日志不是负担,是调试时唯一可信的东西。
第三条:把"不确定"当成一等公民。接口返回成功不代表业务成功,进度加了不代表任务完成,任务完成不代表奖励到账。每一步都要有独立的确认动作。我现在的流程里,领奖之后一定会再查一次最终状态,就是为了堵住这个缺口。多做一次查询,比事后补数据省事得多。
这套东西跑下来我最深的体会是:所谓"一键完成",本质上不是某个神奇的工具或脚本,而是对整个链路理解的完整度。链路还原得越细,脚本就越短、越稳。反过来,只要还有一环是靠猜的,那这个脚本迟早会在某个你想不到的地方断掉。什么时候你能把一条上报请求的每个字段为什么存在都说清楚,什么时候这件事就真的做完了。