写在前面
这是一个我给自己做的小工具:Trae CN 的每日签到。原来每天要打开客户端手动点一次,现在改成阿里云函数计算(FC)上的定时任务 —— 每天 09:05 自动跑,跑完通过微信告诉我结果,通知标题里直接带上当前的可用积分。
真正值得写下来的不是这个操作本身,而是这条路上踩到的坑 —— 它们几乎都不是"这个平台特有的问题",而是做任何定时任务都会遇到的一类问题:
- 客户端接口的真实契约,和社区博客里抄来的那份不一样;
- 服务端返回的错误文案是骗人的,照字面理解会浪费一整天;
- 任务其实一直在正常跑,但你以为它挂了 —— 因为你没有任何可靠的观测手段;
- 两个任务都很健康,却因为共享一份凭据的每日额度而互相把对方的通知挤掉;
- 你以为该加冗余的地方(多一个通道),其实根本不是瓶颈所在。
这些都是"自动化"这件事的原生难题,和具体是哪个平台无关。所以我把它整理成一篇复盘,平台相关的细节我都保留了真实值(域名、接口路径、错误码、字段名),方便你直接对照;但重点仍然放在方法论和工程取舍上。
合规与边界说明(先讲清楚)
这类文章最容易在边界上含糊,所以先说清楚:
做的事情:我自己名下的 Trae CN 账号,原本每天要打开客户端手动点一次签到。现在改成"我自己的 Serverless 函数,在固定时间替我做这一次操作"。技术上等价于换了个手指去点那个按钮 —— 用的还是客户端自己在用的那套接口和凭据,没有任何额外手段。
明确不做的事情:
- ❌ 不做批量账号、不用代理池、不做自动化注册;
- ❌ 不绕过、不伪造风控参数 —— 所有请求头(
Authorization、x-device-id)都用客户端自身产生的真实值; - ❌ 不修改服务端数据,不构造非正常调用链路,不尝试越权访问他人数据;
- ❌ 不对外提供任何形式的代刷服务。
凭据处理:文中出现的 token / 设备号全部是占位示例;真实登录态只存在我自己的 FC 环境变量里,不提交到任何仓库。
给读者的提醒:请遵守你所使用平台的服务条款。技术可行不等于规则允许 —— 如果平台明确禁止自动化访问,那就应该尊重它的规则。不要用这套方法去做任何超出自己账号范围的、或违反平台规则的事情。
下面正式进入技术部分。
一、为什么选 Serverless:三条硬约束倒逼出的设计
需求本身很朴素:每天固定时间,跑一次,一次不到一分钟,跑完告诉我结果。
把这个需求翻译成对运行环境的约束:
| 约束 | 含义 | 排除掉的方案 |
|---|---|---|
| 低频 | 每天一次、每次 < 1 分钟 → 一台常驻机器的利用率约 0.07% | 自己买 VPS 长期跑 |
| 必须能定时 + 能告警 | 到点自动跑;跑挂了我要知道 | 本地 cron(机器不一定开机)、纯 GitHub Actions(不能保证秒级时间窗口) |
| 不能让我维护机器 | 我不想为了一个每天跑 60 秒的东西去管系统更新、证书、端口 | 自建服务器 |
Serverless 函数计算(后面简称 FC)刚好对上:不跑不花钱、自带定时触发器、失败可接云监控告警。
但 Serverless 不是白拿的,它同时给了我三条硬约束,而这三条约束几乎决定了后面所有的设计:
- 代码包要尽量单文件。函数计算的标准 Python runtime 已经把
urllib、json、base64都带上了,我只要不引第三方库,就不需要"打包依赖"这一步 —— 少一层抽象就少一层线上事故。 - 执行时长是硬预算。我给它配了 300 秒超时。这不是个人偏好,而是一个要分配给重试的预算:如果重试策略是"退避 10 次、每次间隔约 22 秒",那就差不多吃掉 220 秒 —— 刚好卡在预算内。做重试之前先算清楚预算,否则重试会把超时吃光,反而变成另一种失败。
- 没有交互式的调试环境。跑在云上的东西,你能依靠的只有日志和返回值。这把"可观测性"从一个加分项变成了必需品(详见第六节)。
顺手记一个 FC 的具体坑:代码包本身不包含触发器。 你只上传代码的话,函数永远不会自己跑 —— 只有手动点"测试函数"才会动。这是新手最容易卡住的一步:代码没问题、环境变量没问题,就是不动,因为压根没配定时器。
二、接口契约:别抄博客,去读客户端自己
第一步是搞清楚"正确的请求长什么样"。社区里有不少现成的脚本,但直接抄的失败率很高 —— 它们经常是基于某个旧版本客户端写的,payload 或必需请求头已经变了。
我的做法是按固定顺序走一遍,全程用真实凭据打真实接口做验证:
| 步 | 动作 | 判断依据 |
|---|---|---|
| 1 | 找客户端本地存储 | Electron 系应用:macOS 在 ~/Library/Application Support/Trae CN/User/globalStorage/storage.json,Windows 在 %APPDATA%\Trae CN\User\globalStorage\ |
| 2 | 解密登录态 | 键名形如 iCubeAuthInfo://trae-cn,base64 解开后是 header[6] + key[32] + hmac[64] + ciphertext 的结构;解出来能看到 token / refreshToken / expiredAt / userId |
| 3 | 翻客户端的 JS bundle | macOS 在 /Applications/Trae CN.app/Contents/Resources/app/out/*.js。搜接口路径就能看到真实的请求方法、payload 构造方式、必需请求头、业务码含义 |
| 4 | 用真 token 打真实接口,做对照实验 | 同一接口"带某个头 / 不带"各打一次,用错误码差异反推这个头的作用(见第三节) |
| 5 | 确认响应结构再写解析 | 是 {code, data} 包裹还是扁平结构;判定字段到底叫什么名字(Trae 同时给了 checked_in 和 did_checked_in,选错就会误判状态) |
| 6 | 需要"主流程之外的数据"时,从 bundle 里提取接口路径全集 | 不要猜路径。一条正则把所有接口拉成清单 |
第 6 步很实用,一条命令就能把 bundle 里所有接口拉出来,比逐个猜路径快得多:
cd "/Applications/Trae CN.app/Contents/Resources/app/out"
grep -ohE '/trae/api/v[0-9]+/[A-Za-z0-9_/{}:.-]+' *.js | sort -u
这一步一次列出了 12 个端点,包括"查询可用积分余额"那个接口。如果想要的数据不在主流程上,别猜,去枚举。
本次实测到的接口契约(真实值)
既然方法讲完了,把这次摸出来的真实契约放出来,你可以直接拿去对照自己的环境:
| 项 | 实测值 |
|---|---|
| 域名 | https://api.trae.cn |
| 认证 | Authorization: Cloud-IDE-JWT <token> ⚠️ 注意是值前缀,不是头名 |
| 必填头 | x-device-id —— claim 必需、status 不需要;不带 claim 必回 9004 |
| 用户头 | x-user-id + X-User-Region(实测可不带,建议带上) |
| 签到状态 | POST /trae/api/v2/ug/checkin_credits/status,body {"req_source":1} |
| 签到领取 | POST /trae/api/v2/ug/checkin_credits/claim,同 payload |
| 可用积分 | POST /trae/api/v2/pay/ide_user_ent_usage,body {"require_usage":true,"req_source":1} |
| 响应结构 | 扁平,不是 {code, data} 包裹 |
| 已签到判定 | checked_in(不是 did_checked_in) |
| token 寿命 | 实测 14 天;refreshToken 约 180 天 |
| 单日奖励 | 150(免费)/ 200(会员)积分 |
真实响应样例:
// status —— 未签到
{"checked_in":false,"did_checked_in":false,"code":0,"credits":150,"extra_credits":50,"enable":true,"message":"success"}
// claim —— 缺 x-device-id
{"code":9004,"message":"The submitted order parameters are incorrect. Please try placing the order again"}
// claim —— 带真实 device_id,签到成功
{"code":0,"message":"success"}
这张表是我踩完坑之后回填的。如果你在别的时间点看到不一致,以你本地客户端 bundle 里的调用为准 —— 客户端版本升级时,payload 和必需头都可能变。
一个真实收获:credits 不是"总余额"
顺着上面那条路找到余额接口后,我确认了一件容易搞错的事:
客户端界面上显示的"可用积分",来源是 usage_summary.total_amount - usage_summary.consumed_amount(实测 1100 - 800 = 300,和界面完全一致)。
而签到接口自己返回的那个 credits: 150,只是"每日签到"这一个活动项的奖励额度,不是账户总余额。
这类"字段名相似、语义不同"的情况在逆向里非常常见。判断依据永远是"和客户端界面上的数字对齐",而不是字段名看起来像什么。
三、"必需头"是存在就行,还是必须真实?
这是我在这个项目里踩到的最大的一个坑,也是方法论上最有价值的一次实验。
背景:签到接口(claim)需要一个 x-device-id 请求头,不带会返回 code: 9004(参数错误)。
那么问题来了:服务端是只检查这个头"存在且格式合法",还是必须是一个真实有效的设备号?
如果只是"存在即可",那我可以自己生成一个 UUID,用户零配置;如果是"必须真实",就必须去客户端存储里把真设备号挖出来。
社区里流传的说法是"随便一个合法 UUID 都能过"。我没有接受这个说法,而是做了一次双对照实验:
| 实验组 | 请求头 | 返回 |
|---|---|---|
| A | 完全不带头 | code: 9004(参数错误) |
| B | 带一个格式合法的假值 | code: 9074("当前参与用户太多") |
| C | 带客户端真实设备号 | code: 0,首次调用即成功 ✅ |
A 和 B 的错误码不一样 —— 这就说明服务端的校验强度比我以为的高:它不只是看头在不在,还会校验值本身。社区那个"任意值都能过"的结论,在我的实测环境下是不成立的。
可复用的结论:判断一个必需头的校验强度,必须做两次对照 ——"(a) 不带"和"(b) 带格式合法的假值"。
- 两者错误码相同 → 只看存在性 → 可以本地派生,用户零配置;
- 两者错误码不同 → 值也必须真实 → 老老实实去客户端里挖。
只做 (a) 就下"任意值都行"的结论,是错的。 我这个项目就是被这个结论坑了一轮。
那"真设备号"到底藏在哪?
既然值必须真实,就得把它找出来。这里还有个坑:它不在 storage.json 里。
storage.json 里确实有 telemetry.machineId / telemetry.devDeviceId 这类键,但那是遥测哈希,服务端根本不认。拿它当 x-device-id,得到的就是 9074。
⚠️ 顺带一个容易写错的地方:这两个是带点的扁平键名,顶层 key 字面就是
"telemetry.machineId"。 写成storage["telemetry"]["machineId"]会永远取到空值 —— 这个坑我真实踩过。
真正的设备号在同级目录的另一个文件里:
~/Library/Application Support/Trae CN/aha/TinyStorage
→ {"tiny_storage_data":{"aha.device.device_id":"<加密串>"}}
这个值是同一套 iCube 加密串,用同一个解密函数解出来是:
{"device_id_str":"<一段纯数字字符串>","install_id_str":"...","uuid":"..."}
取 device_id_str 即可。
找法:不要猜键名。直接在客户端目录下扫一遍比翻文档快得多:
grep -rl "device_id" ~/Library/Application\ Support/Trae\ CN/
四、错误文案 ≠ 错误原因
上一节里那个 9074,是我这次收获最大的一课。
它的文案是「当前参与用户太多,请稍后再试」。
看到这句话,人的第一反应必然是:"这是服务端拥堵,跟我没关系,等着重试就好了。"
于是我很自然地走上了"退避重试"这条路 —— 结果连续 20 多次调用,全部返回 9074。真拥堵不可能连续堵这么久。这才开始怀疑:这句话可能在骗我。
排查过程:
| 观察 | 结果 | 结论 |
|---|---|---|
| 连续调用 20+ 次 | 全部 9074 | 不符合"偶发拥堵"的特征 |
换 req_source 参数(1 / 2 都试) | 依旧 9074 | 不是这个参数的问题 |
| 查活动时间窗口 | 在有效期内 | 不是"活动没开始/已结束" |
换真实的设备号(用来当 x-device-id 的值) | {"code":0,"message":"success"} ✅ | 真因在这里 |
根因:我之前用的是一个"看起来像设备号"的值(客户端遥测遥测模块产生的哈希),服务端根本不认,于是它给了一个语义完全错位的错误码 —— 文案说"人太多",实际含义是"你这个设备号无效"。
这一课的通用版本:
错误文案是给人看的,错误码是给程序看的,而它们都可能与真实原因错位。
排查顺序应该是:先排除"是不是我自己的参数问题",再谈"服务端是不是真的不稳定"。
具体做法:做单变量对照实验,一次只改一个变量。如果"改掉我的错误参数"能让问题立刻消失,那它从来就不是环境问题。
不过要补一句:退避重试我最终还是保留了。 因为真实的偶发拥堵也是可能存在的,而且代价很低。
"找到真因"和"保留容错"不矛盾 —— 关键是别用容错去掩盖自己的参数错误。当时如果一开始就无脑加重试,只会拿到 20 次 9074 和一堆无意义的日志。
五、单文件零依赖,怎么落地
确定用 FC + 纯标准库之后,代码上就需要守住几条纪律。
为什么拒绝 requests
函数计算的标准 Python runtime 自带 urllib。用 urllib 意味着零依赖、单文件、不需要任何打包流程。
对只做几个 POST 请求的场景来说,requests 带来的便利远远抵不上"多引入一层依赖管理"的成本。所以这个项目里一个第三方库都没有。
但这里有个副作用要注意:从
requests迁回urllib时,必须逐个核对请求头。requests会顺手帮你带一些你没显式设置的头(比如 User-Agent),而urllib用的是它自己的默认值 —— 这个差异真的会引发线上事故,见第七节的 WAF 坑。
关键实现:限流码藏在 HTTP 200 的 body 里
这是整个项目里最容易写出 bug、而且测试还测不出来的一段。
服务端的限流返回的是 HTTP 200 + body 里的业务码,不是 HTTP 429。所以如果你只在 except HTTPError 分支里判断限流,限流会被当成成功返回,完全不重试:
RATE_LIMIT_CODES = (9074, 429)
def post_with_retry(url, headers, body, op, max_retries=None):
"""带限流重试;401/403 不重试。
⚠️ 关键:限流是 HTTP 200 + body 业务码,不是 HTTP 429。
所以 urlopen 成功返回后必须再查一次 body 里的业务码。
"""
limit_retries = MAX_RETRIES if max_retries is None else max_retries
data = json.dumps(body).encode("utf-8")
for attempt in range(1, limit_retries + 1):
try:
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
parsed = json.loads(resp.read().decode("utf-8"))
# ★ HTTP 200 但业务码是限流 → 继续重试(这一行是重点)
if isinstance(parsed, dict) and parsed.get("code") in RATE_LIMIT_CODES:
log(f"[{op}] 限流 body code={parsed.get('code')} ({attempt}/{limit_retries})")
else:
return parsed, attempt
except urllib.error.HTTPError as e:
if e.code in (401, 403):
raise PermissionError(f"HTTP {e.code}, token 失效") # 配置错,不重试
# HTTP 错误也可能带 JSON body,一并解析业务码
try:
parsed = json.loads(e.read().decode("utf-8"))
code = parsed.get("code")
if code in RATE_LIMIT_CODES:
pass # 继续重试
elif code:
return parsed, attempt # 其他业务码,重试无意义
except (ValueError, OSError):
pass
except (urllib.error.URLError, TimeoutError):
pass # 网络抖动,重试
if attempt < limit_retries:
time.sleep(RETRY_BASE_SLEEP + random.uniform(0, RETRY_JITTER)) # 退避 + 抖动
raise RuntimeError(f"[{op}] 超过 {limit_retries} 次重试")
这段代码有三个设计点值得单独说:
urlopen成功后要再查一次业务码。 这是整个函数里最反直觉、也最容易漏的一行。- 401/403 直接抛错,不进重试。 凭据失效重试 100 次也不会好,只会白等 —— 而这里白等的代价是吃掉 FC 的执行预算。
- 退避要加随机抖动(
RETRY_BASE_SLEEP + random.uniform(0, RETRY_JITTER))。固定间隔重试会在服务端形成整齐的脉冲,反而更容易撞上限流。
辅助接口要单独给一个小的重试预算
主流程之外我还会查一次"可用余额",用来让通知更有信息量。但辅助接口绝不能继承主流程的重试预算:
# 余额查询只给 2 次机会;它失败不能拖累签到,更不能吃掉 300s 超时
resp, _ = post_with_retry(USAGE_URL, headers, body, op, max_retries=min(2, MAX_RETRIES))
而且它失败时的处理是**"降级但不编造"**:拿不到余额就只报签到结果,绝不填一个看起来合理的数字。宁可信息少,也不要假信息。
六、可观测性:本文我最想传递的部分
前面的都是"怎么让它跑起来",这一节是"怎么知道它还活着"。
事情的起因很典型:某天我没收到通知。
第一反应当然是"推送通道坏了"。于是我换通道、加通道、试了三家 —— 都不是原因。真正的根因在后面第九节,但这次排查过程暴露了更本质的问题:
在一个无人值守的自动化系统里,"收不到消息"这个现象,可能对应至少四种完全不同的故障,而你没有任何手段区分它们。
这才是真正需要解决的设计问题。于是我把三件事写进了设计原则。
原则一:失败也要通知
最开始的版本是"签到成功才推送"。这看起来很合理 —— 报喜不报忧嘛。
这是错的。
因为一旦只推成功,收件人看到的世界就是:
| 实际情况 | 你收到的消息 |
|---|---|
| 签到成功 | 一条成功通知 |
| 签到失败 | 什么都没有 |
| 函数根本没跑(触发器没配) | 什么都没有 |
后两行在收件人眼里完全一样。你只会觉得"今天怎么没消息",然后无从下手。
所以现在:成功和失败都推,失败的标题单独区分(签到异常|N 项失败),正文带上原始错误原因。
这条原则其实是通用的:任何"只在成功时报警"的监控,都是无效监控。
原则二:推送失败必须反映到返回值里
推送是整条链路的最后一环。如果它失败了,而函数依然返回 200,那就意味着没有任何外部信号 —— 云监控看不到异常,人也没收到消息。
所以:推送重试 N 次 → 全败时函数返回 500,并在返回体里带上 "pushed": false。
这样 FC 的云监控告警("函数执行失败 → 通知")就能兜住最后一层。在最坏的情况下,至少还有一条不依赖推送通道本身的告警路径。
原则三:通知里必须有"关键数字"
一条"签到成功"的通知,信息量接近于零。所以我让标题直接带上账户余额:
标题:Trae 签到成功|可用 300
微信服务号的卡片默认只显示标题 —— 把关键数字放进标题,用户不点开就能看到最关心的那个信息。
配套纪律是:余额查不到时,宁可省略,绝不编数字。 宁可标题短一截,也不要出现一个假数字。
加一个"诊断入口":test_push
系统里最难的判断是"函数没跑"和"函数跑了但通道坏了"。这两者的现象一模一样。
加一个诊断事件就能一键区分:
{"test_push": true}
传这个事件时,函数会无视业务状态,先发一条"通道测试"消息,然后照常执行主流程(幂等,不影响结果):
| 结果 | 结论 | 下一步 |
|---|---|---|
| 收到"通道测试" | 函数能跑 + 通道正常 | 问题在触发器(去检查定时触发器配了没) |
| 没收到 | 通道有问题 | 去看日志里推送那一行的原始错误 |
把排查顺序固化成表
最后,我把这次排查的层次固化下来。遇到"没收到通知",不要先从通道下手,按这个顺序往下走:
| 层 | 怎么验 | 本次实况 |
|---|---|---|
| ① 函数跑了吗 | 看业务数据里的领取时刻是否落在定时器时刻 | ✅ 都落在预期时刻 |
| ② 业务成功了吗 | 关键指标(余额)有没有按预期变化 | ✅ 正常 |
| ③ 通道送达了吗 | 日志里"推送 成功/失败"那一行 | ✅ 手动测试能收到 |
| ④ 额度被抢了吗 | 是否有别的任务共用同一份凭据、且同一时刻触发 | ❌ ← 真凶在第四层 |
真凶在第四层,而我一开始直接跳到了第三层去"加通道"。 绕了三轮才回到根因。这就是为什么我要把这张表写下来 —— 防止下次又凭直觉下手。
七、通道选型:先问三件事,再比额度
"我要一个通知"这句话背后,其实藏着三个独立的问题。任何一个答错,方案都要返工。
三问
- 落在哪? —— 有些服务的推送是落在它自己的 App 里,微信里永远收不到。如果需求是"微信通知",这类通道第一轮就该出局,不能拿它当微信的冗余。
- 要不要实名? —— 有的平台已经要求实名才能调用发送接口(未实名会返回明确错误码)。
- 实名要不要花钱? —— 更关键的一问:有些平台的实名认证本身是付费的。
我自己就在第 2、3 问上栽过:选中一个看起来额度很足(200 条/天)的通道,接入最后一步才发现未实名不能发消息,而实名是要付费的。
对比
| Turbo 类(简单 form 接口) | 群机器人(Webhook 类) | pushplus | App 类推送 | |
|---|---|---|---|---|
| 落在哪 | 微信服务号 | 微信(需额外配置) | 微信服务号 | 它自己的 App ❌ |
| 要实名吗 | 不要 | 不要 | 要,且付费 | 不要 |
| 免费额度 | 5 条/天 | 不限 | 200 条/天 | 测试期不限 |
| 正文可见 | 免费版仅标题 | ✅ 可见全文 | ✅ | ✅(在 App 内) |
结论很清楚:"落在微信 + 不花钱"的组合,其实只有两种。其余的要么落点不对,要么要付费。
★ 坑一:HTTP 状态码骗了你两次
这是接入过程中最容易静默出错的地方 —— 有两家服务,成功和失败都返回 HTTP 200:
// pushplus:坏 token(实测 HTTP 依然是 200!)
{"code":903,"data":"无效的用户token","msg":"用户令牌不正确"}
// 企业微信 Webhook:无效地址(HTTP 也是 200!)
{"errcode":93000,"errmsg":"invalid webhook url, hint: [...]"}
如果你用 if resp.status == 200: return True 来判断是否发送成功,上面这两种情况都会被判定为"发送成功",然后消息就静默丢了。
规律总结:
| 服务类型 | 报错方式 |
|---|---|
| Server酱 系列 | HTTP 状态码(坏 key → HTTP 400 / 403 + JSON) |
| 企业微信 / pushplus | HTTP 恒 200,错误码在 body 里(errcode / code) |
所以推送函数必须同时写两个分支:
except HTTPError分支 + "业务码非成功"分支。只看其中一边,必翻车。
★ 坑二:WAF 会按 User-Agent 拦你,而 403 长得像"key 无效"
从 requests 换到 urllib 之后,我遇到了一个极具误导性的错误。
某个推送域名在 CDN/WAF 后面,会按 User-Agent 拦截请求。同一个 key、同一个 URL,只换 UA:
| User-Agent | 响应 |
|---|---|
Python-urllib/3.10(urllib 默认) | HTTP 403 error code: 1010 ← WAF 拦的 |
curl/8.4.0 | HTTP 200 {"code":0,...} ✅ |
| 浏览器 UA | HTTP 200 {"code":0,...} ✅ |
这个 403 和"凭证无效"的响应长得一模一样。 我的第一反应就是去反复核对 key,浪费了一整轮 —— 而真正的问题在请求头。
可复用的结论:排查任何 403 时,第一件事是换个 UA 复测,不要先怀疑凭据。 另外,从 SDK 迁移到裸
urllib时要格外小心 —— SDK 顺手帮你带的默认请求头,往往正是关键所在。 迁移时必须逐个核对。
最终设计:多通道,任一送达即成功
def notify(title, desp):
"""发到所有已配置的通道,任一送达即算成功。"""
jobs = []
if sct := _env_channel("SERVERCHAN_SENDKEY"):
jobs.append(("通道A", lambda: push_a(sct, title, desp)))
if wh := _env_channel("WECOM_WEBHOOK"):
jobs.append(("通道B", lambda: push_b(wh, title, desp)))
# ... 其余通道同理
if not jobs:
log("未配置任何推送通道,跳过通知")
return False, []
detail, any_ok = [], False
for label, run in jobs:
ok = run() # 每条通道独立执行、独立记日志
detail.append({"channel": label, "ok": ok})
any_ok = any_ok or ok
if not any_ok:
log("⚠️ 所有通道推送失败 —— 结果只落在日志里了")
elif len(detail) > 1 and not all(d["ok"] for d in detail):
# 部分失败是正常状态,降级为提醒,不影响返回值
log(f"⚠️ 部分通道失败(消息已由其他通道送达)")
return any_ok, detail
运行时的日志形态:
[10:52:01] 通道A 推送 失败(1/3): code=40014 message=额度已用完
[10:52:01] 通道B 推送 成功(1/3)
[10:52:01] ⚠️ 部分通道失败(消息已由其他通道送达) ← 返回 200
只有全部通道都失败才返回 500。 这样"某条通道额度用尽"不会导致整体静默。
一个小而值钱的设计:占位符护栏
配置模板文件里没填的值长这样:
SERVERCHAN_SENDKEY=<把这里替换成你的 SendKey>
如果这份模板被整份复制进控制台而没逐行替换,这个占位符会被当成真实凭据 —— 然后每天用它发一次、每天失败;在只配了它的情况下,还会把整个函数的返回值拖成 500。
所以加一道拦截:
def _env_channel(name: str) -> str:
"""读通道环境变量,把「占位符」视为未配置。"""
raw = os.environ.get(name, "").strip()
if not raw:
return ""
if raw.startswith("<") and raw.endswith(">"):
log(f"⚠️ {name} 还是占位符 —— 按「未配置」处理,记得替换成真实值")
return ""
return raw
八行代码。但它的价值在于:把"配置错误"和"运行故障"在日志层面区分开了。 否则你会看到一个每天都在失败、但看起来像网络问题的系统。
八、多通道 ≠ 多多益善
写到这里必须说明一个我最后才想明白的取舍。
我一度认为"通道越多越可靠",然后给这个每天只发 1 条消息的任务配了三条通道。
这是过度设计。 原因有三:
- 加通道的成本不在代码里,在别人的工作里 —— 每多一个通道,就意味着要多注册一个平台、多走一遍配置流程(有的还要建群、配插件、做实名)。而收益只是"多一层冗余"。
- 额度算一下就知道根本不够用不上 —— 每天 1 条消息,单条通道免费额度是 5 条/天。余量充足。
- 多通道会掩盖问题 —— 当"部分通道失败"变成一种常态日志,你就不会再认真看它了。而它可能在掩盖一个真实的配置错误。
所以我最终的做法是:只启用一条通道,另外两条的能力完整保留在代码里(配了就发、没配就跳过),但不填值。
默认只配一条,等确认额度真的不够时再加。 这个判断标准很清晰:如果连"每天 1 条"都收不到,那问题一定不在通道数量上。
九、真凶:共享凭据的额度竞争
现在回到那个把整件事串起来的问题:通知到底为什么丢?
我的 FC 里有两个类似的定时任务:workbuddy_checkin(给 WorkBuddy 签到)和这次这个 trae_checkin。它们共用同一个 Server酱 SendKey,而这个 SendKey 的免费额度是 5 条/天。
两个任务原本都配在 09:00 触发。
于是:同一分钟,两个函数各发一条。看起来 2 < 5,够用 —— 但只要其中任何一个重试过一次、或我手动点了几次"测试函数",额度就会被吃掉,而被挤掉的那一条,双方看到的错误码完全一样(都是 40014:额度已用完)。
而且在额度耗尽的情况下,任务会认为"我发了,但被拒了",日志里只有一行额度错误 —— 从任何一个任务的视角看,都像是"通道坏了"。
解法不是加通道,是错开时间。
| 任务 | 触发时间 | Cron |
|---|---|---|
workbuddy_checkin | 09:00 | 0 0 9 * * * |
trae_checkin(本任务) | 09:05 | 0 5 9 * * * |
顺便还躲开了整点的服务端高峰(签到类接口在整点的 9074 明显更常见)。
用数据验证,而不是用感觉
改完定时器之后,"通知收到了"这件事不能作为验证依据 —— 它可能只是那天运气好。
我需要一个不依赖通知、不依赖日志的证据,来证明"定时器真的在按新时间跑"。
好在这个业务的数据里天然带着答案:每笔积分发放记录里都有一个 start_time(发放时刻的 Unix 秒),以及形如 checkin_20260924_<userId> 的 entitlement_id。
把这些记录的时刻列出来:
| 领取记录 | 实际时刻(北京时间) | 谁触发的 |
|---|---|---|
checkin_20260924_... | 09:05:01 | 定时触发器(改时间后第一次)★ |
checkin_20260923_... | 09:00:02 | 定时触发器(旧时间) |
checkin_20260922_... | 09:00:01 | 定时触发器(旧时间) |
checkin_20260921_... | 10:27:05 | 人工点的 |
checkin_20260920_... | 18:33:47 | 人工点的 |
落在 09:05:0x 就是"机器在跑"的铁证 —— 人不可能掐得这么准。 反过来,零散的午后时刻一眼就能看出是手动操作。
这个技巧可以推广:给任何自动化任务找一个"机器指纹"。 人操作的痕迹是散乱的,机器的痕迹是对齐到秒的。 有了这个判据,你就能在不看日志的情况下,回答"这个任务历史上到底有没有在正常工作"。
这次的结论是: 一个看起来像"通道故障"的问题,真因是两个任务竞争同一份有额度的共享资源。而这类问题,靠"增加冗余"是永远修不好的 —— 增加冗余只会让竞争更激烈。
先量后改,别看现象就下手。
十、凭据生命周期:为什么我最终没有做自动续期
这个项目还有一个必须面对的问题:登录态是会过期的。
我一开始想的是"能不能让它自动续期,彻底不用管"。但把链路拆开看之后,我做了一个反直觉的决定。
先分清"短的那个"和"长的那个"
凭据体系通常是双层的:
| 凭据 | 寿命 | 作用 |
|---|---|---|
token(JWT) | 14 天 | 每次调用 API 用;到期作废 |
refreshToken | 180 天 | 客户端拿它静默换新 JWT |
关键认知:用户平时感觉不到 token 过期,是因为客户端在自动续。 会过期的,只是"我复制出来存到云函数环境变量里的那一份静态 JWT"。
所以"能不能延长"这个问题的正解不是"找更长效的 token",而是**"由谁负责续期"**。
三条路线
| 路线 | 做法 | 代价 | 风险 |
|---|---|---|---|
| A. 手动续期 | 到期前跑一次导出脚本,把新值贴回控制台 | 约 30 秒 / 14 天 | 无 |
| B. 函数内自动续期 | 每次运行先用 refreshToken 换新 JWT | 需要实现请求签名 | 可能把桌面客户端挤下线(见下) |
| C. 本机保活回推 | 读客户端已刷新的 token,用云 API 写回函数环境变量 | 需托管云 AccessKey | 无账号风险,但多一份云凭据要管 |
路线 B 的完整链路我已经逆向清楚了(POST /trae/api/v3/oauth/ExchangeToken,签名是 ECDSA-SHA256 / P-256,签的内容是 方法\n路径\nClientID\nRefreshToken\n时间戳\nNonce;而且设备密钥对就存在本地,不用自己生成)。
但我最终没有做,原因有两个:
- 纯标准库做不了 ECDSA。 要么手写约 120 行 P-256 曲线实现,要么给函数加一层依赖装密码学库。这就把"单文件零依赖"这个核心优势丢掉了。
- 它有真实的副作用。 如果服务端在刷新时会轮换 refreshToken,那我手里这份换了新的,客户端缓存的旧的就失效了,它下次自刷新会报错并弹出登出。虽然重新登录就能恢复,但会打断正常使用。
这是一个典型的"能力可行,但不该做"的工程判断。
风险是不对称的:路线 A 的成本是 每 14 天花 30 秒;路线 B 的收益是省下这 30 秒,代价是有可能把用户的桌面客户端踢下线。
这笔账算下来,A 明显更优。有时候"不做",才是更好的工程决策。
所以我的方案是:手动续期 + 提前告警
既然选择了手动,那就要确保不会忘。做法是写一个导出脚本,把整个续期流程压缩成一条命令:
# 只看剩余天数,不打印敏感值
./renew.sh --check
# 导出 + 自动复制到剪贴板 + 打印后续步骤
./renew.sh
然后配一条定期任务,每 7 天跑一次 --check,剩余 ≤ 3 天时推微信提醒。
这样"过期了才发现"这个风险就被彻底消除了,而且是零风险的。 对比之下,用"自动续期"这种有副作用的机制去解决一个每天只花 30 秒的问题,性价比并不高。
十一、把踩过的坑钉成测试
项目最后有一套 66 个用例的测试,全部用 unittest.mock 的思路 monkeypatch 掉网络层,不依赖真实请求、不消耗任何额度。
比起数量,更重要的是测试里钉住的是什么。这个项目的测试里有几类特别值得说:
| 测试 | 钉住的是 |
|---|---|
test_401_does_not_retry | 凭据失效不能进重试(否则白等,还吃超时预算) |
test_9074_retries_until_success | 限流必须重试,且要能识别 HTTP 200 + body 业务码这种形态 |
test_all_failed_still_pushes_alert_and_returns_500 | 失败也要推,且推送失败必须反映到返回码 |
test_turbo_quota_exceeded_backup_still_delivers | 事故回归:额度被抢空时,备用通道要能兜住 |
test_wecom_converts_bullets_and_truncates | markdown 语法差异 + 字节上限截断 |
test_93000_invalid_webhook_fails_fast | 配置类错误不重试 |
test_push_always_sends_user_agent | WAF 回归防线:UA 必须带(见坑二) |
test_placeholder_env_is_treated_as_unconfigured | 占位符护栏(防止配置错误伪装成运行故障) |
一个关于测试的教训
写测试这件事上,我踩了一个值得单独记下来的坑:
我一度有过一套"全绿但没用"的测试。
因为当时我对接口的理解是错的(以为限流走 HTTP 429),所以我写的 mock 也照着这个错误假设来构造 —— 测试全绿,因为 mock 和实现犯的是同一个错。
发现这个问题之后,我改了一条纪律:
关键假设必须先用真实接口验证,再写 mock。
测试能证明的只是"实现和我的假设一致",它无法证明"我的假设是对的"。
收尾:一份可复用的清单
把上面所有内容压缩成一份清单,做同类事情时可以直接对照:
逆向阶段
- 先读客户端自己的存储和 JS bundle,不要抄社区脚本
- 用正则枚举接口路径全集,别猜
- 必需头必须做双对照(不带 / 带假值),错误码不同 → 值也必须真实
- 响应结构、判定字段名,都要用真实响应确认
- 错误文案 ≠ 错误原因:先排除自己的参数问题,再谈服务端不稳定
实现阶段
- 先算清楚超时预算,再决定重试次数(重试会吃掉执行时间)
- 限流可能在 HTTP 200 的 body 里 ——
urlopen成功后要再查一次业务码 - 401/403 / 配置类错误码不重试;退避要加随机抖动
- 辅助接口给独立的小重试预算,失败时降级但绝不编造数据
- 从 SDK 迁到裸 HTTP 库时,逐个核对请求头
可观测性(最重要的一组)
- 失败也要通知 —— 只推成功 = 静默失败
- 推送失败要反映到返回码 → 交给云监控兜底
- 通知标题里带上关键数字;拿不到就省略
- 留一个
test_push类的诊断入口,区分"没跑"和"跑了但通道坏了" - 给自动化任务找一个**"机器指纹"**(如带时间戳的业务记录),用数据证明它在跑
运维阶段
- 默认只配一条通道;确认额度不够再加
- 共用凭据时,先算额度,再错开触发时间
- 凭据过期 → 优先"手动续期 + 提前告警",而不是带副作用的自动续期
- 把每次事故都钉成一条回归测试
附录:本次项目的真实参数速查
前面讲的都是方法论,这里把真实参数集中放一份,方便你对照排查。
业务错误码
| code | 含义 | 处理 |
|---|---|---|
0 | 成功 | — |
9004 | 参数错误 | 完全没带 x-device-id |
9074 | device_id 服务端不认 / 偶发拥堵 | 先确认用的是真实设备号;确认无误后才走退避重试 |
1001 | 认证失败 | ⚠️ 先检查 Authorization 头的写法(scheme 必须在值里),再怀疑 token 过期 |
401 / 403 | token 失效 | 重新登录拿新 JWT(约 14 天一轮) |
注意
9004(完全没带)和9074(带了个服务端不认的值)是两个不同的错, 但后者的文案是「当前参与用户太多」—— 这就是第四节那个坑的来源。
FC 部署参数
| 项 | 值 |
|---|---|
| 区域 | 华东1(杭州) |
| 函数名 | trae_checkin |
| 类型 | 事件函数 |
| 运行时 | Python 3.10(标准 runtime,自带 urllib) |
| 规格 | 0.35 vCPU / 0.5 GB,最小实例数 0,并发 1 |
| 超时 | 300 秒(重试预算的来源) |
| 定时触发器 | 0 5 9 * * *,时区 Asia/Shanghai → 每天 09:05 |
环境变量
| 变量 | 必需 | 说明 |
|---|---|---|
TRAE_TOKEN_1 | ✅ | Cloud-IDE-JWT 登录态,约 14 天过期 |
TRAE_DEVICE_ID_1 | ✅ | 客户端真实设备号。留空会退化成派生值,大概率撞 9074 |
TRAE_USER_ID_1 | 可选 | |
TRAE_REGION_1 | 默认 CN | |
SERVERCHAN_SENDKEY | ✅ | 推送通道,与另一个签到函数共用 |
TRAE_MAX_RETRIES | 默认 10。FC 超时改 600s 后可提到 25 | |
TRAE_RETRY_BASE_SLEEP / TRAE_RETRY_JITTER | 默认 15 / 15 秒,退避+抖动 | |
TRAE_TOKEN_2..9 | 配置结构上支持顺序读取更多账号(遇空停止)。我自己只用了 1 个 |
推送通道的失败信号(四家各不相同,别混着看)
| 通道 | 成功标志 | 典型失败 |
|---|---|---|
| Server酱Turbo | {"code":0} | HTTP 400 + {"code":40001}(Key 错);40014 = 额度用完 |
| 企业微信机器人 | {"errcode":0} | 93000 = webhook 无效(HTTP 仍 200,不重试);45009 = 超 20 条/分钟(可重试) |
| pushplus | {"code":200} | 903 = token 错;905 = 未实名(且实名要付费);成功失败都是 HTTP 200 |
| Server酱³ | {"code":0} | HTTP 403 —— 坏 key 与 WAF 拦 UA 都是 403,看 body 里有没有 error code: 1010 |
规律:Server酱 那两家用 HTTP 状态码报错;腾讯系(企业微信)和 pushplus 是 「HTTP 200 但 body 里带错误码」。所以推送函数必须同时写
except HTTPError和"业务码非成功"两个分支,只看一边必翻车。
项目结构
.
├── fc/index.py # FC handler,单文件零依赖,约 970 行
├── trae_checkin/ # 本地 CLI 复用层(crypto / auth / client)
├── scripts/
│ ├── export_env.py # 从客户端解密导出环境变量(token 续期用)
│ ├── renew.sh # 一键续期:导出 + 复制到剪贴板
│ └── make-zip.sh # 打包 FC zip(含 py_compile 语法检查)
└── tests/ # 66 个用例
最后三句话
如果这篇复盘只能留下三个结论,我希望是这三个:
- 错误文案是给人看的,不一定是真的。 「当前参与用户太多」的真因可能只是我的设备号传错了。看到反常的错误码,先怀疑自己。
- 无人值守系统的核心不是"能跑",而是"能证明它在跑"。 只报喜的通知系统,等于没有通知系统。
- 先量后改。 这次绕的三轮弯,全都源于看到现象就直接下手 —— 换通道、加冗余,而真凶是两个任务在抢同一份额度。
本文涉及的所有接口调用,均针对作者本人拥有合法授权的账号,凭据仅存于本地环境变量,未使用任何批量、代理或绕过手段。请遵守你所使用平台的服务条款;技术可行不等于规则允许。
文中的接口路径、错误码、字段名均为实测真实值,方便你对照排查;token / 设备号等凭据一律为占位示例。客户端版本升级时契约可能变化 —— 以你本地客户端 bundle 里的真实调用为准。