Trae 每天自动签到:Serverless 定时任务完整复盘

61 阅读36分钟

写在前面

这是一个我给自己做的小工具: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 不是白拿的,它同时给了我三条硬约束,而这三条约束几乎决定了后面所有的设计:

  1. 代码包要尽量单文件。函数计算的标准 Python runtime 已经把 urllib、json、base64 都带上了,我只要不引第三方库,就不需要"打包依赖"这一步 —— 少一层抽象就少一层线上事故。
  2. 执行时长是硬预算。我给它配了 300 秒超时。这不是个人偏好,而是一个要分配给重试的预算:如果重试策略是"退避 10 次、每次间隔约 22 秒",那就差不多吃掉 220 秒 —— 刚好卡在预算内。做重试之前先算清楚预算,否则重试会把超时吃光,反而变成另一种失败。
  3. 没有交互式的调试环境。跑在云上的东西,你能依靠的只有日志和返回值。这把"可观测性"从一个加分项变成了必需品(详见第六节)。

顺手记一个 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 bundlemacOS 在 /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} 次重试")

这段代码有三个设计点值得单独说:

  1. urlopen 成功后要再查一次业务码。 这是整个函数里最反直觉、也最容易漏的一行。
  2. 401/403 直接抛错,不进重试。 凭据失效重试 100 次也不会好,只会白等 —— 而这里白等的代价是吃掉 FC 的执行预算。
  3. 退避要加随机抖动(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}

传这个事件时,函数会无视业务状态,先发一条"通道测试"消息,然后照常执行主流程(幂等,不影响结果):

结果结论下一步
收到"通道测试"函数能跑 + 通道正常问题在触发器(去检查定时触发器配了没)
没收到通道有问题去看日志里推送那一行的原始错误

把排查顺序固化成表

最后,我把这次排查的层次固化下来。遇到"没收到通知",不要先从通道下手,按这个顺序往下走:

层怎么验本次实况
① 函数跑了吗看业务数据里的领取时刻是否落在定时器时刻✅ 都落在预期时刻
② 业务成功了吗关键指标(余额)有没有按预期变化✅ 正常
③ 通道送达了吗日志里"推送 成功/失败"那一行✅ 手动测试能收到
④ 额度被抢了吗是否有别的任务共用同一份凭据、且同一时刻触发❌ ← 真凶在第四层

真凶在第四层,而我一开始直接跳到了第三层去"加通道"。 绕了三轮才回到根因。这就是为什么我要把这张表写下来 —— 防止下次又凭直觉下手。


七、通道选型:先问三件事,再比额度

"我要一个通知"这句话背后,其实藏着三个独立的问题。任何一个答错,方案都要返工。

三问

  1. 落在哪? —— 有些服务的推送是落在它自己的 App 里,微信里永远收不到。如果需求是"微信通知",这类通道第一轮就该出局,不能拿它当微信的冗余。
  2. 要不要实名? —— 有的平台已经要求实名才能调用发送接口(未实名会返回明确错误码)。
  3. 实名要不要花钱? —— 更关键的一问:有些平台的实名认证本身是付费的。

我自己就在第 2、3 问上栽过:选中一个看起来额度很足(200 条/天)的通道,接入最后一步才发现未实名不能发消息,而实名是要付费的。

对比

Turbo 类(简单 form 接口)群机器人(Webhook 类)pushplusApp 类推送
落在哪微信服务号微信(需额外配置)微信服务号它自己的 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)
企业微信 / pushplusHTTP 恒 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.0HTTP 200 {"code":0,...} ✅
浏览器 UAHTTP 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. 加通道的成本不在代码里,在别人的工作里 —— 每多一个通道,就意味着要多注册一个平台、多走一遍配置流程(有的还要建群、配插件、做实名)。而收益只是"多一层冗余"。
  2. 额度算一下就知道根本不够用不上 —— 每天 1 条消息,单条通道免费额度是 5 条/天。余量充足。
  3. 多通道会掩盖问题 —— 当"部分通道失败"变成一种常态日志,你就不会再认真看它了。而它可能在掩盖一个真实的配置错误。

所以我最终的做法是:只启用一条通道,另外两条的能力完整保留在代码里(配了就发、没配就跳过),但不填值。

默认只配一条,等确认额度真的不够时再加。 这个判断标准很清晰:如果连"每天 1 条"都收不到,那问题一定不在通道数量上。


九、真凶:共享凭据的额度竞争

现在回到那个把整件事串起来的问题:通知到底为什么丢?

我的 FC 里有两个类似的定时任务:workbuddy_checkin(给 WorkBuddy 签到)和这次这个 trae_checkin。它们共用同一个 Server酱 SendKey,而这个 SendKey 的免费额度是 5 条/天。

两个任务原本都配在 09:00 触发。

于是:同一分钟,两个函数各发一条。看起来 2 < 5,够用 —— 但只要其中任何一个重试过一次、或我手动点了几次"测试函数",额度就会被吃掉,而被挤掉的那一条,双方看到的错误码完全一样(都是 40014:额度已用完)。

而且在额度耗尽的情况下,任务会认为"我发了,但被拒了",日志里只有一行额度错误 —— 从任何一个任务的视角看,都像是"通道坏了"。

解法不是加通道,是错开时间。

任务触发时间Cron
workbuddy_checkin09:000 0 9 * * *
trae_checkin(本任务)09:050 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 用;到期作废
refreshToken180 天客户端拿它静默换新 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;而且设备密钥对就存在本地,不用自己生成)。

但我最终没有做,原因有两个:

  1. 纯标准库做不了 ECDSA。 要么手写约 120 行 P-256 曲线实现,要么给函数加一层依赖装密码学库。这就把"单文件零依赖"这个核心优势丢掉了。
  2. 它有真实的副作用。 如果服务端在刷新时会轮换 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_truncatesmarkdown 语法差异 + 字节上限截断
test_93000_invalid_webhook_fails_fast配置类错误不重试
test_push_always_sends_user_agentWAF 回归防线: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
9074device_id 服务端不认 / 偶发拥堵先确认用的是真实设备号;确认无误后才走退避重试
1001认证失败⚠️ 先检查 Authorization 头的写法(scheme 必须在值里),再怀疑 token 过期
401 / 403token 失效重新登录拿新 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 个用例

最后三句话

如果这篇复盘只能留下三个结论,我希望是这三个:

  1. 错误文案是给人看的,不一定是真的。 「当前参与用户太多」的真因可能只是我的设备号传错了。看到反常的错误码,先怀疑自己。
  2. 无人值守系统的核心不是"能跑",而是"能证明它在跑"。 只报喜的通知系统,等于没有通知系统。
  3. 先量后改。 这次绕的三轮弯,全都源于看到现象就直接下手 —— 换通道、加冗余,而真凶是两个任务在抢同一份额度。

本文涉及的所有接口调用,均针对作者本人拥有合法授权的账号,凭据仅存于本地环境变量,未使用任何批量、代理或绕过手段。请遵守你所使用平台的服务条款;技术可行不等于规则允许。

文中的接口路径、错误码、字段名均为实测真实值,方便你对照排查;token / 设备号等凭据一律为占位示例。客户端版本升级时契约可能变化 —— 以你本地客户端 bundle 里的真实调用为准。