深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
中
ESC

爱文亿门票自动预约:从接口逆向到飞书长连接机器人

中文

家里有三张《畅游杭州一票通》的权益卡,对应三个人。

平台的规则是一张卡一次只能约一份,于是一个周末去一个地方,整套流程要完整地做三遍:打开小程序、选卡、选场馆、选日期、选票种、确认,再回到第一步。

三遍还算小事。真正麻烦的是每遍都要重新判断:这家免不免福点、要不要提前一天、几点截止、这张卡今天是不是已经约满、这家是不是去年去过了、这单能不能退。人每次都会漏一个。

之前写过一遍脚本形态的实现,那篇讲的是”为什么”和”怎么想到的”。这一篇讲工程:最后落地的是一套 Go 写的 CLI + 一个常驻的飞书机器人,跑在家里那台没有 Go 工具链的 Linux 机器上,周六早上自动发一张卡片,点一下,三张卡同时下单。

一、先把目标收敛成一句话

每周六早上,机器给出不超过三个候选;我点其中一个,三张卡各约一份,结果回执在同一张卡片上更新。

围绕这句话,边界也定死了:

  • 只约可退的场馆,不可退的只列出来提示,绝不自动下单;
  • 只约免福点的(不额外掏钱),要补福点的只展示;
  • 同一张卡、同一家场馆、同一天已经有单,跳过;
  • 不做验证码识别、不绕过库存、不高频轮询;
  • 所有写操作默认 dry-run,必须显式加 --yes 才真发。

二、整体架构

最后是三个进程、两种触发方式:

┌──────────── 开发/编译机(有 Go 工具链)────────────┐
│ cli/ ──build──► iwen │
│ bot/ ──build──► awen-iwen-bot │
│ │ │
│ ./bot/deploy.sh(scp 静态二进制) │
└────────────────────┬────────────────────────────┘
▼
┌──── 家庭服务器(systemd --user)────┐
│ awen-iwen-bot serve 常驻长连接 │
│ awen-iwen-sunday.timer 周六 09:00 │
│ awen-iwen-doctor.timer 每天 09:30 │
└──────┬────────────────────┬─────────┘
│ exec │ WebSocket(主动外连)
▼ ▼
iwen CLI(只读写 API) 飞书开放平台
│
▼
travel.iwen1.com HTTP API

几个刻意的选择:

决定 原因
业务逻辑和机器人拆成两个二进制 iwen 可以单独在终端里跑、可以 dry-run、可以被测试;机器人只负责”什么时候跑、结果怎么展示”
CLI 用 Go 重写,只用标准库 目标机器没有 Go、也不想装 Python 依赖;CGO_ENABLED=0 出一个静态二进制,scp 过去就能跑
机器人也用 Go 飞书官方 SDK 的长连接实现最完整,且不需要公网回调地址
编译在开发机,运行在家庭服务器 家庭服务器只当一个”有 systemd 的插座”

仓库结构:

awen-iwen/
├── cli/ # iwen:接口 + 规则 + 下单(Go,零第三方依赖)
│ ├── main.go # HTTP、token、登录、短信续期、配置
│ ├── data.go # 卡 / 权益包 / 场馆 / 库存
│ ├── book.go # 候选筛选、幂等检查、下单
│ ├── sunday.go # 天气、周日方案、JSON 输出
│ └── cmds.go # 各子命令
├── bot/ # awen-iwen-bot:飞书长连接、卡片、指令
│ ├── main.go # serve / tick / check / cmd
│ ├── slash.go # 斜杠命令注册同步
│ ├── systemd/ # 三个 service + 两个 timer
│ └── deploy.sh # 编译 + scp + 装 unit
├── iwen.py # Python 版兜底(机器人已不再调用)
├── aesgcm.py # 纯标准库 AES-256-GCM,解短信用
├── capture/chain.json # 抓包留档(已洗掉姓名/身份证/卡号)
└── tests/

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 完全一样——说明它是账号级、长期有效的,不绑定某次登录、不绑定设备、不需要微信在线。这一条把整个方案的成本降下来了:不需要手机、不需要浏览器、不需要模拟器。

func callWithToken(path string, body url.Values, tries int, wait time.Duration, tok string) (map[string]any, error) {
var last error
for i := 0; i < tries; i++ {
req, _ := http.NewRequest("POST", baseURL+path, strings.NewReader(body.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("x-auth-token", tok)
req.Header.Set("x-from-mini", "1")

resp, err := httpClient.Do(req)
if err != nil { // 只对网络层错误退避重试
last = err
if i < tries-1 { time.Sleep(wait * time.Duration(i+1)) }
continue
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode >= 400 {
return map[string]any{"ResultCode": -resp.StatusCode}, nil
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil { last = err; continue }
return out, nil
}
return nil, fmt.Errorf("请求 %s 失败:%w", path, last)
}

业务码只有两个要紧:ResultCode=100 是成功,300 是登录态失效。这两者必须分开处理——早期版本把 300 当成”没有数据”,结果订单列表返回空,脚本以为”所有场馆都没去过”,从此不再推荐去过的场馆,静默失效了很久。现在 300 直接报错退出:

func needOK(r map[string]any, what string) map[string]any {
if code := asInt(r["ResultCode"]); code == 100 {
return r
} else if code == 300 {
fatalf("%s:登录态失效(300 %s)", what, asString(r["ResultMessage"]))
}
fatalf("%s 失败:ResultCode=%d %s", what, code, asString(r["ResultMessage"]))
return nil
}

响应头里还能看到这套系统的年纪: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 是整套东西的核心。它返回三层结构,正好对应”日期 → 场次 → 票种”:

Response.DefaultStartDate
Response.DateGroups[].Date
└─ Times[].Time
└─ Types[].Id ← 下单时的 specKey
Types[].Stock ← 真实余票
Types[].ExchangeNumber ← 这一档要扣几个福点
Types[].TicketPrice

也就是说,”能不能约”不是看场馆列表里的 Status,而是看目标日期下有没有 Stock > 0 的票种。场馆列表只告诉你”这家在权益包里”,档期接口才告诉你”这天还有没有位”。

CLI 把整棵树叶摊平成一个 Live 结构,按需取某天的全部票种:

func (l *Live) slotsOn(date string) []Slot {
var out []Slot
for _, g := range l.DateGroups {
if g.Date != date { continue }
for _, t := range g.Times { out = append(out, t.Types...) }
}
return out
}

这个接口按 tries=4, wait=2s 重试——它是全流程里最容易被 TLS 抖动打到的一个,而且查询量大(每家场馆、每张卡、每个包各一次),重试比失败重来便宜。

3.5 下单只要五个字段

POST /OrderGroup/ExchangeOrderZuHe
userCardKey=<卡 Id>
specKey=<票种 Id>
attractionId=<场馆 Id>
Remarkscontent=
ParentattractionId=<权益包 Id>

没有任何签名、nonce、时间戳。难度全在”选对这四个 Id”上,而不在请求本身。

ParentattractionId 是最容易错的一个。最初的实现按”选 N 最大”挑包,觉得大包更保险,结果同一个场馆挂在两个包里时,服务端按付费档处理,回 剩余次数不足。后来改成按包的 N 从小到大逐个试,第一个成功就停:

for _, sp := range p.Specs[cid] {
body := url.Values{
"userCardKey": {strconv.Itoa(cid)},
"specKey": {strconv.Itoa(sp.Slot.ID)},
"attractionId": {strconv.Itoa(p.Venue.ID)},
"Remarkscontent": {""},
"ParentattractionId": {strconv.Itoa(sp.Opt.Pkg)},
}
r, err := call("/OrderGroup/ExchangeOrderZuHe", body, 1, 0)
if err != nil { /* 记失败,换下一个候选 */ break }
if asInt(r["ResultCode"]) == 100 { /* 成功 */ break }
}

注意 tries=1:下单不重试。这是幂等考虑——请求超时时服务端可能已经落单,重试会变成两张单。宁可报错让人看一眼。

成功之后 sleep 600ms 再处理下一张卡,三张卡之间留一点间隔,不去贴着平台打。

3.6 免福点的判定

平台把”权益次数”和”福点”混在一起展示,很容易看错。真正的规则是:

该场馆最低的福点档 ≤ 所在权益包的”选 N”,就能用 1 次权益直接抵掉;超了就得整份付福点(1 福点 = 10 元)。

// classify 免福点 = 存在一个还有余量的权益包,它的「选N」>= 该场馆最低福点档
func classify(v *Venue) (bool, *Opt) {
var ok []Opt
for _, o := range v.Opts {
if o.Remain > 0 && o.Fu <= float64(o.N) {
ok = append(ok, o)
}
}
if len(ok) > 0 {
sort.SliceStable(ok, func(i, j int) bool { return ok[i].N > ok[j].N })
best := ok[0]
return true, &best
}
/* 返回最低福点档,供提示"要补多少" */
return false, cheapest(v.Opts)
}

两个真机实证,一个免、一个不免:

场馆 最低福点档 所在包 结果
临安大明山 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:

func button(label, typ string, value map[string]string) map[string]any {
return map[string]any{
"tag": "button",
"text": map[string]any{"tag": "plain_text", "content": label},
"type": typ,
"value": value, // 点击后原样回到事件里
}
}

actions(button("约这家", "primary", map[string]string{
"action": "iwen_pick",
"date": p.Date,
"venue": fmt.Sprint(c.ID),
}))

value 是自定义载荷,点按钮时整包回到 card.action.trigger 的 event.action.value 里。于是机器人不需要维护任何”上一次发了什么”的服务端状态,日期和场馆 ID 跟着卡片走,重启、换进程都不影响。

点击之后的处理是”先应答、再干活、最后原地改卡片”:

func (b *bot) onCard(e *callback.CardActionTriggerEvent) (*callback.CardActionTriggerResponse, error) {
v := e.Event.Action.Value
msgID := e.Event.Context.OpenMessageID // 用来 patch 同一张卡

switch str(v["action"]) {
case "iwen_pick":
if !b.busy.take(msgID) { // 防双击
return &callback.CardActionTriggerResponse{
Toast: &callback.Toast{Type: "info", Content: "正在约,别急…"},
}, nil
}
go func() {
defer b.busy.done(msgID)
card := errorCard("预约失败", "预订请求异常")
if bk, err := book(str(v["date"]), str(v["venue"])); err == nil {
card = resultCard(bk)
}
b.patchCard(msgID, card) // 原地更新,不刷屏
}()
return &callback.CardActionTriggerResponse{
Toast: &callback.Toast{Type: "info", Content: "开始预约,约完更新这张卡…"},
}, nil
}
/* iwen_cancel / iwen_skip 同理 */
}

三个细节值得记下来:

  1. 回调必须快速返回。飞书对卡片回调有超时,三张卡下单要几秒到几十秒,所以立刻回一个 toast,真正的活扔进 goroutine,完成后用 im.message.patch 改那张卡。
  2. 防双击用 message id 做锁。同一条卡片正在下单时再点,只回一个”正在约”的 toast,不会重复下单。
  3. 卡片本身就是结果页。成功、部分失败、失败原因都渲染在同一张卡上,聊天记录里只留一条。

点「约这家」后,同一张卡片被原地更新成逐人结果

4.4 指令:斜杠、反斜杠、中文都认

自动推卡片是主路径,但临时想查一下、想退一单,总不能等到周六。所以私聊里还有一组指令:

指令 别名 作用
/week [日期] \周末、\weekend、\去哪 天气 + 还有库存的免福点场馆
/plan [日期] \周日、\sunday 出选择卡片
/orders \订单、\预约 待使用预约,每单一个取消按钮
/cancel <Id> / /cancel all \取消 取消一单 / 全部
/cards \卡、\余量 三张卡各包余量
/status \状态、\体检 登录态体检(只读)
/help \帮助 说明

解析这一层看起来琐碎,但决定了”用起来顺不顺手”:

// 中文输入法常常打出全角 \ / /,一并认
prefix, size := utf8.DecodeRuneInString(text)
switch prefix {
case '/', '\\', '/', '\':
default:
return "", "", false
}
text = strings.TrimSpace(text[size:])
name, arg := cutSpace(text)
if c, ok := lookupExact(name); ok { return c, arg, true }
// 只对中文别名做前缀匹配:\周末去哪儿玩 也能识别;
// 英文必须完整,否则 /cancelall 会被当成 /cancel
if c, rest, ok := lookupCNPrefix(name); ok { ... }

还有两处现实里的细节:

  • 群里 @ 机器人时,正文里会带 @_user_1 这种占位符,要先剥掉;
  • 指令只在私聊生效。群里 @ 也收得到事件,但 /cancel all 是真能退单的,不能在群里误触发。

「谁可以说」这件事也没有做账号体系,而是用最土的办法:第一次收到消息时,把发送者的 open_id 记到 ~/.local/state/awen-iwen/admin_open_id;之后所有非主人的私聊一律忽略。配置项 FEISHU_ADMIN_OPEN_ID 可以预置。

输入框里打 / 就能看到已注册的指令

指令本身还要注册到开放平台,否则输入框的 / 建议列表里看不到。这组接口没有官方 SDK 封装,直接发 HTTP:

GET    /open-apis/application/v7/app_slash_commands        列出
POST /open-apis/application/v7/app_slash_commands 新建
PATCH /open-apis/application/v7/app_slash_commands/:id 改
DELETE /open-apis/application/v7/app_slash_commands/:id 删

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 字段里:

ts := strconv.FormatInt(time.Now().Unix(), 10)
body := map[string]any{"timestamp": ts, "msg_type": "text",
"content": map[string]string{"text": text}}
if sec := cfg["FEISHU_SECRET"]; sec != "" {
mac := hmac.New(sha256.New, []byte(ts+"\n"+sec))
body["sign"] = base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

顺带记一笔:飞书 webhook 发送失败时 HTTP 仍然是 200,错误只在响应体的 code 里。只看状态码的代码会以为发成功了。

五、调度与可靠性

5.1 systemd user 单元

家庭服务器上没有 Docker、没有 supervisor,就用 systemd 的 user 实例:不需要 root,日志直接追加到文件。

# awen-iwen-sunday.timer
[Timer]
OnCalendar=Sat *-*-* 09:00:00
Persistent=true
Unit=awen-iwen-sunday.service
# awen-iwen-sunday.service
[Service]
Type=oneshot
Environment=IWEN_BIN=%h/bin/iwen
ExecStart=%h/bin/awen-iwen-bot tick
StandardOutput=append:%h/logs/awen-iwen.log
StandardError=append:%h/logs/awen-iwen.log
  • 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 是”能不能约”的唯一入口,它对着三份数据做检查:

for _, cid := range cardIDs {
if visited(used, cid, v.ID) { // 这张卡以前去过这家(Status=3)
seen = append(seen, cid)
} else if !hasOrder(rows, cid, date, v.ID) { // 这天还没有同馆同馆的单
missing = append(missing, cid)
} else {
already = append(already, cid) // 已有单,跳过
}
}
if len(missing) == 0 { /* 全部已约或去过,明确告诉用户原因 */ }

for _, cid := range missing {
/* 数这张卡当天已约几项,>= dayLimit(6) 直接拒绝 */
}

三个问题分别挡三种重复:同一张卡同一天同一馆重复下单、同一张卡一年内重复去同一家、一天约超过 6 项。机器人那边还有一层 busy 锁挡”手抖点两下”。

5.3 进程退出要快

长连接库对 context 取消不敏感,systemd 发 SIGTERM 之后如果进程不退出,stop 会一直等到 TimeoutStopSec 才 KILL。部署脚本每次都要停一次机器人,90 秒的等待很难受。所以加了一个兜底:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
time.Sleep(2 * time.Second) // 给正常收尾一点时间
os.Exit(0) // 库不返回就直接退
}()

TimeoutStopSec=15,加上这 2 秒,重启从 90 秒变成 2 秒。部署脚本里还留了一手:stop 两秒后仍不退出就 SIGKILL,避免旧进程占着长连接导致新进程收不到事件。

5.4 登录态自愈

token 是账号级的,理论上长期有效,但平台会踢、会过期。整套自愈链路是:

每天 09:30  doctor  ──►  ok / expired / unknown
│ │ │
│ │ └─ 只报错,不发短信
│ └─► renew:SendSms → 读短信 → BindMobile
└─ 静默

有三个设计点,都是踩过坑之后加的。

第一,只有服务端明确回 300 才续期。 网络抖动、TLS 失败、5xx、空卡列表都算 unknown:

func tokenState() (state, detail string) {
r, err := call("/CardGroup/GetAvailableCard", url.Values{"attrationId": {"-1"}}, 2, time.Second)
if err != nil { return "unknown", "网络/请求失败:" + err.Error() }
switch code := asInt(r["ResultCode"]); code {
case 100:
if n := len(asList(r["List"])); n > 0 { return "ok", ... }
return "unknown", "接口返回空卡列表"
case 300:
return "expired", "登录态失效"
default:
return "unknown", "ResultCode=" + ...
}
}

判断失误的代价是一条真实的短信发到手机上,很烦人。

第二,30 分钟内最多发一条。 用 ~/.config/awen-iwen/sms.stamp 的 mtime 做闸;续期成功登录态就恢复了,下一次体检不会再触发,这个间隔只在”续期一直失败、反复重试”时生效。

第三,”新短信”用服务端自增 ID 判断,不用时间戳。 短信的 date 是手机侧 System.currentTimeMillis() 生成的,手机时钟比服务器慢一两秒,刚到的短信看起来就比发码时刻还”旧”。2026-11-14 真的因此丢过一次验证码。现在先记下发码前列表里的最大 id,之后只认 id 更大的新消息:

func smsIsNew(m smsMsg, skip map[string]bool, sinceID int64) bool {
if skip[asString(m.ID)] { return false }
if sinceID > 0 && smsIDNum(m.ID) <= sinceID { return false }
return true
}

验证码本身来自自建的短信转发服务,接口是 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 会在执行前检查子命令和参数:

func guardWrite(args []string) error {
if !dryRun || len(args) == 0 { return nil }
// 判定和 iwen 一致:不带 --yes 的下单/退单本来就不落单,不用拦
write := args[0] == "renew" ||
(args[0] == "cancel" && has(args, "--yes")) ||
(args[0] == "book" && has(args, "--yes")) ||
(args[0] == "sunday" && has(args, "--book") && !has(args, "--dry-run"))
if !write { return nil }
return fmt.Errorf("dry-run 模式拒绝执行写操作:iwen %s", strings.Join(args, " "))
}

教训很直白:“演练模式”要拦的是副作用,不是输出。

六、部署与配置

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 前缀和引号。样例文件里只有键名:

# ~/.config/awen-iwen/feishu.conf
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_ADMIN_OPEN_ID=

6.2 一次部署

cd cli && CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ~/bin/iwen .
./bot/deploy.sh

deploy.sh 做的事:

  1. 交叉出两个静态二进制(CGO_ENABLED=0,目标机器不需要 Go);
  2. 远端建目录、chmod 700 ~/.config/awen-iwen;
  3. 先停机器人,两秒后仍在就 SIGKILL(换二进制时旧进程会占着长连接);
  4. scp 两个二进制、iwen.py / aesgcm.py(兜底)、systemd 单元;
  5. 首次部署时把本地 token / account.json / sms.conf / sms.stamp 带过去(已存在则不覆盖);
  6. daemon-reload,enable --now 体检 timer,按需 start 机器人。

二进制是静态的,所以回滚也简单:把旧文件 scp 回去再 systemctl --user restart。

6.3 自检命令

iwen doctor            # 只打一个最轻的接口,不动缓存
iwen cards # 三张卡 + 每个包剩几次
iwen week --limit 25 # 下一个周日,按票面价降序真查库存
iwen check 大明山 # 单家的档期/场次/票种/库存/specKey
iwen book 大明山 --date 2026-11-15 --mirror # 三卡镜像,默认 dry-run
iwen renew # 登录态正常时什么都不做

七、已知限制与风控

这套东西是”把自己的账号操作自动化”,不是”绕过平台”。边界和风险都写在明面上:

规则层面

  • 权益卡到 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、最多三个候选。

八、可以复用的几条

  1. 先证伪最直觉的方案。 UI 自动化看起来最”正统”,但无障碍树是空的、包也解不开,早点花两小时证明它不可行,比花两周把它调稳划算。
  2. 抓包要留复原清单。 hosts 备份、证书指纹、进程、临时目录,抓完当场还原,别让调试环境留在机器上。
  3. 把平台规则写成断言,而不是写在注释里。 “每天 6 项””提前 N 天””一年一次”都必须是代码里的判断,人才会漏。
  4. 写操作默认 dry-run,演练模式要拦副作用。 打印一份”将要发的请求”,比任何文档都直观。
  5. 业务码和网络错误分开处理。 300 就是 300,超时就是超时,前者要人介入,后者可以退避重试;混在一起就会出现”静默失效”。
  6. 事件回调先应答再干活。 所有 IM 平台都有回调超时,重活扔后台,用消息 ID 做幂等锁,回来再 patch 原消息。
  7. 判断”新消息”用服务端序号,不用客户端时间。 时钟偏差一两秒就足够让一条验证码永远等不到。
  8. 一个应用一个长连接消费者。 多进程抢同一个 app 的事件,表现是”偶尔能收到”,非常难查;该拆应用就拆。

最后

自动化真正省下的不是钱,是周六早上那十分钟:不用打开小程序、不用把三张卡的库存对三遍、不用在”这家能不能退”上犹豫。

机器负责把重复的判断折叠起来,人只负责点一下”约这家”。

至于”去哪里”,还是自己决定比较好。

文章作者:阿文
文章链接: https://www.awen.me/post/d556e7c4.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客!

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️✋ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐⛎ ♈♉♊ ♋♌♍ ♎♏♐ ♑♒♓ 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘❌⭕ 🛑⛔📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭❗ ❕❓❔ ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️✅ 🈯💹❇️ ✳️❎🌐 💠Ⓜ️🌀 💤🏧🚾 ♿🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️✋ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐⛎ ♈♉♊ ♋♌♍ ♎♏♐ ♑♒♓ 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘❌⭕ 🛑⛔📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭❗ ❕❓❔ ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️✅ 🈯💹❇️ ✳️❎🌐 💠Ⓜ️🌀 💤🏧🚾 ♿🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️