家里有三张《畅游杭州一票通》的权益卡,对应三个人。
平台的规则是一张卡一次只能约一份,于是一个周末去一个地方,整套流程要完整地做三遍:打开小程序、选卡、选场馆、选日期、选票种、确认,再回到第一步。
三遍还算小事。真正麻烦的是每遍都要重新判断:这家免不免福点、要不要提前一天、几点截止、这张卡今天是不是已经约满、这家是不是去年去过了、这单能不能退。人每次都会漏一个。
之前写过一遍脚本形态的实现,那篇讲的是”为什么”和”怎么想到的”。这一篇讲工程:最后落地的是一套 Go 写的 CLI + 一个常驻的飞书机器人,跑在家里那台没有 Go 工具链的 Linux 机器上,周六早上自动发一张卡片,点一下,三张卡同时下单。
一、先把目标收敛成一句话
每周六早上,机器给出不超过三个候选;我点其中一个,三张卡各约一份,结果回执在同一张卡片上更新。
围绕这句话,边界也定死了:
- 只约可退的场馆,不可退的只列出来提示,绝不自动下单;
- 只约免福点的(不额外掏钱),要补福点的只展示;
- 同一张卡、同一家场馆、同一天已经有单,跳过;
- 不做验证码识别、不绕过库存、不高频轮询;
- 所有写操作默认 dry-run,必须显式加
--yes才真发。
二、整体架构
最后是三个进程、两种触发方式:
|
几个刻意的选择:
| 决定 | 原因 |
|---|---|
| 业务逻辑和机器人拆成两个二进制 | iwen 可以单独在终端里跑、可以 dry-run、可以被测试;机器人只负责”什么时候跑、结果怎么展示” |
| CLI 用 Go 重写,只用标准库 | 目标机器没有 Go、也不想装 Python 依赖;CGO_ENABLED=0 出一个静态二进制,scp 过去就能跑 |
| 机器人也用 Go | 飞书官方 SDK 的长连接实现最完整,且不需要公网回调地址 |
| 编译在开发机,运行在家庭服务器 | 家庭服务器只当一个”有 systemd 的插座” |
仓库结构:
|
iwen.py 是最早的实现,留着当兜底;Go 版要求命令、参数、文本输出和 JSON 输出与它逐字节一致,cli/format_test.go 里就是拿两边做 diff 的回归。这样机器人从”调 Python”换成”调二进制”时,解析逻辑一行都不用改。
三、爱文亿侧:登录、库存与下单
3.1 先把”点”这条路排掉
最自然的想法是 UI 自动化,但这个方向很快被证伪:
- 微信小程序里
uiautomator dump出来的无障碍树是空的,只有一个 407 字节的光节点,拿不到控件; - PC 端
__APP__.wxapkg在微信 4.2 换了密钥派生,老的V1MMWX解密失效,反编译也走不通。
剩下的只有后端接口。做法是在一台 Windows 机器上把 travel.iwen1.com 指到本地,起一个只听 127.0.0.1 的转发器,配一张自签 CA,完整抓一次”登录 + 下单”,拿全接口和参数,然后当场复原:hosts 还原、根证书删除、转发器杀掉、DNS 缓存刷新。
抓包留档前必须洗字段:卡列表接口的响应里有姓名、身份证号、卡号、卡密,这些不进仓库、不进文章。
3.2 鉴权:一个账号级的长期 token
关键发现是登录态非常”朴素”:所有接口都靠一个请求头,x-auth-token,加上 x-from-mini: 1,body 是 application/x-www-form-urlencoded。
两次 POST /MiniApp/Login 传的微信 code 不同,返回的 Token 完全一样——说明它是账号级、长期有效的,不绑定某次登录、不绑定设备、不需要微信在线。这一条把整个方案的成本降下来了:不需要手机、不需要浏览器、不需要模拟器。
|
业务码只有两个要紧:ResultCode=100 是成功,300 是登录态失效。这两者必须分开处理——早期版本把 300 当成”没有数据”,结果订单列表返回空,脚本以为”所有场馆都没去过”,从此不再推荐去过的场馆,静默失效了很久。现在 300 直接报错退出:
|
响应头里还能看到这套系统的年纪:server: Microsoft-IIS/7.5、x-aspnetmvc-version: 4.0,日期是 /Date(1777305600000)/ 这种 .NET 老格式。也正因为如此,TLS 偶尔抖一下,网络层必须带重试。
3.3 接口地图
| 用途 | 路径 | 关键参数 |
|---|---|---|
| 卡列表 | POST /CardGroup/GetAvailableCard |
attrationId=-1 |
| 权益包 | POST /AttractionGroup/GetCardThemeAttractionList |
CardThemeId、userCardId |
| 包余量 | POST /AttractionGroup/GetZuHePackageSummary |
parentAttractionId、userCardId |
| 包内场馆 | POST /AttractionGroup/GetCardThemeAttractionZuHeList |
cardThemeId、parentAttractionId、userCardId |
| 场馆详情 | POST /AttractionGroup/GetAttractionGroupDetailJson |
attractionId 等 |
| 档期与库存 | POST /AttractionGroup/GetAttractionExchangeJson |
attrId、ucardKey、ParentattractionId |
| 下单 | POST /OrderGroup/ExchangeOrderZuHe |
见下 |
| 取消 | POST /OrderGroup/CancelOrderGroup |
id |
| 订单记录 | POST /OrderGroup/GetOrderGroupList |
page、pageSize |
| 福点价格 | POST /PayOrder/GetPriceDetails |
{} |
| 短信验证码 | POST /MiniApp/SendSms |
mobile |
| 验证码换 token | POST /MiniApp/BindMobile |
openid、mobile、code |
所有接口都返回同样的信封:ResultCode / ResultMessage / Response 或 List / Entity。
3.4 库存与 specKey
GetAttractionExchangeJson 是整套东西的核心。它返回三层结构,正好对应”日期 → 场次 → 票种”:
|
也就是说,”能不能约”不是看场馆列表里的 Status,而是看目标日期下有没有 Stock > 0 的票种。场馆列表只告诉你”这家在权益包里”,档期接口才告诉你”这天还有没有位”。
CLI 把整棵树叶摊平成一个 Live 结构,按需取某天的全部票种:
|
这个接口按 tries=4, wait=2s 重试——它是全流程里最容易被 TLS 抖动打到的一个,而且查询量大(每家场馆、每张卡、每个包各一次),重试比失败重来便宜。
3.5 下单只要五个字段
|
没有任何签名、nonce、时间戳。难度全在”选对这四个 Id”上,而不在请求本身。
ParentattractionId 是最容易错的一个。最初的实现按”选 N 最大”挑包,觉得大包更保险,结果同一个场馆挂在两个包里时,服务端按付费档处理,回 剩余次数不足。后来改成按包的 N 从小到大逐个试,第一个成功就停:
|
注意 tries=1:下单不重试。这是幂等考虑——请求超时时服务端可能已经落单,重试会变成两张单。宁可报错让人看一眼。
成功之后 sleep 600ms 再处理下一张卡,三张卡之间留一点间隔,不去贴着平台打。
3.6 免福点的判定
平台把”权益次数”和”福点”混在一起展示,很容易看错。真正的规则是:
该场馆最低的福点档 ≤ 所在权益包的”选 N”,就能用 1 次权益直接抵掉;超了就得整份付福点(1 福点 = 10 元)。
|
两个真机实证,一个免、一个不免:
| 场馆 | 最低福点档 | 所在包 | 结果 |
|---|---|---|---|
| 临安大明山 | 1.9 | C 选 3 | 1.9 ≤ 3,免福点,下单回执 ExchangeNumber=0.0 |
| 安吉奇幻嘉乐比 | 7 | X 选 5 | 7 > 5,要 7 福点 = 70 元 |
按这个规则,当前权益包(CardThemeId=576)下的 96 家场馆里,67 家免福点、29 家要补。
3.7 平台硬约束
这些规则只能靠实测一条条试出来,全部写进了代码:
| 规则 | 代码里的体现 |
|---|---|
| 每张卡每天最多约 6 个项目 | dayLimit = 6,下单前数当天已约条数 |
StepDay=1 必须提前一天 |
周六发卡片,就是为了约周日 |
NoLaterThanTime 是当天提交截止 |
有 12:00 这种早截单的场次 |
SupportCancel=0 约错退不掉 |
默认过滤,只有 --allow-noncancel 才破例 |
| 同一张卡对同一家一年只能约一次 | 查 Status=3 的历史单 |
| 同馆同日三张卡各一份是允许的 | 这是整套方案成立的前提 |
| 取消会退回权益次数 | 实测 RemainCount 2 → 3,所以整个流程可逆 |
| 权益包有有效期 | 当前这批发到 2026-12-31;A 包(选 1)三张卡都已用完,代码里只剩 0 次的包会直接跳过 |
3.8 限流与缓存
没有”疯狂重试”的策略,反而处处在收着:
- 场馆/权益包/卡列表缓存 6 小时(
~/.cache/awen-iwen/),只有下单/退单后主动清掉相关前缀; - 每周只查一次库存:
/plan最多查 12 家、/week最多查 25 家,卡片只展示前 3 家; - 下单三张卡之间 600ms;
- 网络错误退避
2s × 尝试次数,业务错误不退避、不重试。
四、飞书侧:长连接、卡片和指令
4.1 为什么是长连接
一开始想过群机器人 webhook:配置简单,一行 curl 就能发消息。但它只能”发”,不能”收”——卡片上的按钮点下去需要有人接。
要收事件有两条路:公网回调地址,或者长连接。家里没有公网 IP、也不想为这个开端口,所以选长连接:机器人进程主动连飞书,事件从这条 WebSocket 推下来。
代价是一个很容易踩的坑:一个飞书应用只能有一个长连接消费者。这台机器上原来还有一个博客相关的机器人,用的是同一个应用,结果新机器人只能收到表情事件,消息全被旧进程收走了。解决办法是给这套东西建一个独立的自建应用,两边互不干扰。
4.2 需要开的权限和订阅
- 机器人能力;
- 事件订阅:
im.message.receive_v1(私聊消息)、card.action.trigger(卡片回传交互)、application.bot.menu_v6(机器人菜单,可选); - 权限:
im:message(发消息)、application:app_slash_command:read/write(同步斜杠命令); - 发布版本。
长连接模式下不需要配请求地址,也就没有”回调 URL 校验”这一步。SDK 建连时用 App ID / Secret 换 tenant token,事件信封由 SDK 校验。
4.3 卡片:把”选择”做成按钮
候选卡片最多三家,每家一个「约这家」,底部一个「这周不约」。结构就是飞书卡片的 JSON:
|
value 是自定义载荷,点按钮时整包回到 card.action.trigger 的 event.action.value 里。于是机器人不需要维护任何”上一次发了什么”的服务端状态,日期和场馆 ID 跟着卡片走,重启、换进程都不影响。
点击之后的处理是”先应答、再干活、最后原地改卡片”:
|
三个细节值得记下来:
- 回调必须快速返回。飞书对卡片回调有超时,三张卡下单要几秒到几十秒,所以立刻回一个 toast,真正的活扔进 goroutine,完成后用
im.message.patch改那张卡。 - 防双击用 message id 做锁。同一条卡片正在下单时再点,只回一个”正在约”的 toast,不会重复下单。
- 卡片本身就是结果页。成功、部分失败、失败原因都渲染在同一张卡上,聊天记录里只留一条。

4.4 指令:斜杠、反斜杠、中文都认
自动推卡片是主路径,但临时想查一下、想退一单,总不能等到周六。所以私聊里还有一组指令:
| 指令 | 别名 | 作用 |
|---|---|---|
/week [日期] |
\周末、\weekend、\去哪 |
天气 + 还有库存的免福点场馆 |
/plan [日期] |
\周日、\sunday |
出选择卡片 |
/orders |
\订单、\预约 |
待使用预约,每单一个取消按钮 |
/cancel <Id> / /cancel all |
\取消 |
取消一单 / 全部 |
/cards |
\卡、\余量 |
三张卡各包余量 |
/status |
\状态、\体检 |
登录态体检(只读) |
/help |
\帮助 |
说明 |
解析这一层看起来琐碎,但决定了”用起来顺不顺手”:
|
还有两处现实里的细节:
- 群里 @ 机器人时,正文里会带
@_user_1这种占位符,要先剥掉; - 指令只在私聊生效。群里 @ 也收得到事件,但
/cancel all是真能退单的,不能在群里误触发。
「谁可以说」这件事也没有做账号体系,而是用最土的办法:第一次收到消息时,把发送者的 open_id 记到 ~/.local/state/awen-iwen/admin_open_id;之后所有非主人的私聊一律忽略。配置项 FEISHU_ADMIN_OPEN_ID 可以预置。

指令本身还要注册到开放平台,否则输入框的 / 建议列表里看不到。这组接口没有官方 SDK 封装,直接发 HTTP:
|
awen-iwen-bot slash sync 按 command 名字做幂等 upsert:有则改描述,没有则建,重复跑不会建出重复项。注意两点:command 字段不带前导 /,且必须匹配 ^[a-z][a-z0-9_-]{0,31}$;另外斜杠命令的点击只负责把文字填进输入框,触发仍然走 im.message.receive_v1,所以 im:message 权限和发布版本不能少。
4.5 通知与告警
机器人发出的消息分三类:
- 选择卡片(周六定时):interactive 卡片;
- 结果回执(按钮回调):patch 原卡片;
- 告警(登录态失效):红色 error 卡片,带恢复步骤。
另外保留了一条旧通道:群机器人 webhook,纯文本。它的价值是”机器人进程本身挂了也能发”——iwen sunday --notify 由 cron 或人手跑,不依赖长连接。自定义机器人开了签名时,签名是 base64(HMAC-SHA256(secret, timestamp + "\n" + secret)),放在请求体的 sign 字段里:
|
顺带记一笔:飞书 webhook 发送失败时 HTTP 仍然是 200,错误只在响应体的 code 里。只看状态码的代码会以为发成功了。
五、调度与可靠性
5.1 systemd user 单元
家庭服务器上没有 Docker、没有 supervisor,就用 systemd 的 user 实例:不需要 root,日志直接追加到文件。
|
|
tick是 oneshot:查周日天气、选候选、发卡片,做完就退;serve是常驻:只负责收事件,Restart=always、RestartSec=5;check是体检:每天 09:30,RandomizedDelaySec=10m避免和别家的整点任务撞在一起;- 所有 timer 都是
Persistent=true:机器那会儿关着,开机后会补跑一次。
日志只有一个文件 ~/logs/awen-iwen.log,每个模块带自己的前缀:[tick]、[card]、[book]、[send]、[doctor]、[msg]、[cmd]。出问题的时候 grep 一个前缀就够了。
5.2 幂等:下单之前先问三个问题
buildPick 是”能不能约”的唯一入口,它对着三份数据做检查:
|
三个问题分别挡三种重复:同一张卡同一天同一馆重复下单、同一张卡一年内重复去同一家、一天约超过 6 项。机器人那边还有一层 busy 锁挡”手抖点两下”。
5.3 进程退出要快
长连接库对 context 取消不敏感,systemd 发 SIGTERM 之后如果进程不退出,stop 会一直等到 TimeoutStopSec 才 KILL。部署脚本每次都要停一次机器人,90 秒的等待很难受。所以加了一个兜底:
|
TimeoutStopSec=15,加上这 2 秒,重启从 90 秒变成 2 秒。部署脚本里还留了一手:stop 两秒后仍不退出就 SIGKILL,避免旧进程占着长连接导致新进程收不到事件。
5.4 登录态自愈
token 是账号级的,理论上长期有效,但平台会踢、会过期。整套自愈链路是:
|
有三个设计点,都是踩过坑之后加的。
第一,只有服务端明确回 300 才续期。 网络抖动、TLS 失败、5xx、空卡列表都算 unknown:
|
判断失误的代价是一条真实的短信发到手机上,很烦人。
第二,30 分钟内最多发一条。 用 ~/.config/awen-iwen/sms.stamp 的 mtime 做闸;续期成功登录态就恢复了,下一次体检不会再触发,这个间隔只在”续期一直失败、反复重试”时生效。
第三,”新短信”用服务端自增 ID 判断,不用时间戳。 短信的 date 是手机侧 System.currentTimeMillis() 生成的,手机时钟比服务器慢一两秒,刚到的短信看起来就比发码时刻还”旧”。2026-11-14 真的因此丢过一次验证码。现在先记下发码前列表里的最大 id,之后只认 id 更大的新消息:
|
验证码本身来自自建的短信转发服务,接口是 GET /api/sms?limit=10,带 Bearer token,正文用 AES-256-GCM 加密,格式 base64(iv(12) || ciphertext || tag(16))。为了不给目标机器装 cryptography,解密的实现是纯标准库的(Go 用 crypto/aes + crypto/cipher,Python 兜底版用纯 Python 写了一遍 AES)。
实测一次完整续期 51 秒,其中约 45 秒在等短信;登录态正常时体检快路径 0.25 秒。
5.5 日志里不出现秘密
几条硬规矩:
- token 落盘 0600,只打印长度和 sha256 前缀,永不回显原文;
open_id在日志里过mask():abcd…wxyz;- 手机号掩码成
138****00; - 消息事件只记”类型 + 前 120 字”,指令正文没有机密;
- 订单日志只记订单号、日期、场馆、持卡人代号,不记身份证。
5.6 dry-run 必须拦写操作
所有写操作默认 dry-run,要 --yes 才真发,这是 CLI 层的约束。机器人还有一个”演练模式”:awen-iwen-bot cmd '\周末' 不连飞书,只把会回的内容打出来。
这里出过一次事故:演练模式最初只拦了”发消息”,没拦 iwen 的写操作。结果本地演练 \取消全部,真的把三张待使用的单退掉了。现在 guardWrite 会在执行前检查子命令和参数:
|
教训很直白:“演练模式”要拦的是副作用,不是输出。
六、部署与配置
6.1 目录与权限
| 路径 | 内容 | 权限 |
|---|---|---|
~/.config/awen-iwen/token |
x-auth-token 单行 |
0600 |
~/.config/awen-iwen/account.json |
手机号 + openid(续期用) | 0600 |
~/.config/awen-iwen/sms.conf |
短信通道地址 / token / AES key | 0600 |
~/.config/awen-iwen/sms.stamp |
上次发短信时间(节流) | 0600 |
~/.config/awen-iwen/feishu.conf |
App ID / Secret / 管理员 open_id | 0600 |
~/.cache/awen-iwen/ |
卡、包、场馆缓存(6h) | 0700 |
~/.local/state/awen-iwen/admin_open_id |
自动记住的主人 | 0600 |
~/bin/iwen、~/bin/awen-iwen-bot |
静态二进制 | 0755 |
~/logs/awen-iwen.log |
全部日志 | 0640 |
配置都是 KEY=VALUE 的纯文本,解析器跳过 # 注释、容忍 export 前缀和引号。样例文件里只有键名:
|
6.2 一次部署
|
deploy.sh 做的事:
- 交叉出两个静态二进制(
CGO_ENABLED=0,目标机器不需要 Go); - 远端建目录、
chmod 700 ~/.config/awen-iwen; - 先停机器人,两秒后仍在就
SIGKILL(换二进制时旧进程会占着长连接); scp两个二进制、iwen.py/aesgcm.py(兜底)、systemd 单元;- 首次部署时把本地 token / account.json / sms.conf / sms.stamp 带过去(已存在则不覆盖);
daemon-reload,enable --now体检 timer,按需start机器人。
二进制是静态的,所以回滚也简单:把旧文件 scp 回去再 systemctl --user restart。
6.3 自检命令
|
七、已知限制与风控
这套东西是”把自己的账号操作自动化”,不是”绕过平台”。边界和风险都写在明面上:
规则层面
- 权益卡到 2026-12-31 到期,之后这套代码只剩参考价值;
- A 包(选 1)三张卡都空了,能约的次数是有限的;
- 96 家场馆里 67 家免福点、29 家要补,补福点的默认不自动约;
- 不可退的场馆(漂流、湿地一类)默认只提示不下单;
- 核销要带身份证原件,机器替代不了这一步。
工程层面
- 库存是竞态的:查完到下单之间可能被别人抢走,这时只能报”剩余不足”,不会重试到死;
- 单点:家庭服务器、家里的网、飞书的长连接,任意一个断了当周就不会自动推卡片。
Persistent=true只能补”机器关机”,补不了”服务挂了”; - 平台是 IIS 7.5 + ASP.NET MVC 4 的老栈,偶尔 TLS 抖动,网络层必须重试;
- 接口是非公开的,平台改一版就可能全废。所以代码里对
ResultCode的处理很保守:不认识的码一律当unknown,不猜。
安全层面
- 账号级 token 等于账号的全部权限:能查卡、能下单、能退单。它只存在于目标机器
~/.config,0600,不进日志、不进聊天记录、不进 git; - 短信通道的 token + AES key 能解密手机上收到的全部短信,包括银行验证码。这是整套系统里最敏感的一环,单独 0600,不和其他配置放一起,不外拷;
- 抓包留档(
capture/chain.json)在入库前必须洗掉姓名、身份证号、卡号、卡密; - 飞书侧只允许一个 admin 私聊,非主人消息直接丢弃——因为
/cancel all真的能退单。
合规层面
- 这是自用工具,服务对象是账号持有人自己和家人;
- 不批量注册、不代约、不对外提供接口;
- 请求频率刻意压低:缓存 6 小时、每周一次全量查询、下单间隔 600ms、最多三个候选。
八、可以复用的几条
- 先证伪最直觉的方案。 UI 自动化看起来最”正统”,但无障碍树是空的、包也解不开,早点花两小时证明它不可行,比花两周把它调稳划算。
- 抓包要留复原清单。 hosts 备份、证书指纹、进程、临时目录,抓完当场还原,别让调试环境留在机器上。
- 把平台规则写成断言,而不是写在注释里。 “每天 6 项””提前 N 天””一年一次”都必须是代码里的判断,人才会漏。
- 写操作默认 dry-run,演练模式要拦副作用。 打印一份”将要发的请求”,比任何文档都直观。
- 业务码和网络错误分开处理。 300 就是 300,超时就是超时,前者要人介入,后者可以退避重试;混在一起就会出现”静默失效”。
- 事件回调先应答再干活。 所有 IM 平台都有回调超时,重活扔后台,用消息 ID 做幂等锁,回来再 patch 原消息。
- 判断”新消息”用服务端序号,不用客户端时间。 时钟偏差一两秒就足够让一条验证码永远等不到。
- 一个应用一个长连接消费者。 多进程抢同一个 app 的事件,表现是”偶尔能收到”,非常难查;该拆应用就拆。
最后
自动化真正省下的不是钱,是周六早上那十分钟:不用打开小程序、不用把三张卡的库存对三遍、不用在”这家能不能退”上犹豫。
机器负责把重复的判断折叠起来,人只负责点一下”约这家”。
至于”去哪里”,还是自己决定比较好。
评论
0 条评论