我让 AI 助手管我的 Shopee 优惠券 —— 它建了一张我没批准的券
我在 Shopee 上开了一家小店,卖数字商品。店还年轻,挡在第一笔订单前面的不是库存,而是一个必须先在 30 天内完成一笔订单才能申请的商家计划。所以:先有订单。
优惠券是我手上最便宜的杠杆:数字商品的边际成本几乎为零,所以一张 RM6 的券,只有在真的有人买的时候才花我 RM6,平时什么也不花。而且我的 AI 助手本来就在用 Chrome DevTools Protocol 驱动 Shopee 卖家后台发商品,把优惠券自动化看起来只是顺手的事。
结果并不顺手。不是自动化难,而是平台有一个特性:
Shopee 的优惠券没有草稿态。 商品可以点 Save and Delist 存成草稿,以后再决定;优惠券不行 —— Confirm 既是发布按钮也是花钱按钮,点下去买家就能领,每领一张就从你的 escrow 余额里扣。中间没有「已保存、未上线」这个可以躲的位置。
这一句话决定了整套设计,也是我如果重做一次会最先做对的地方。
平台实际是怎么运作的(全都是试出来的)
四条规则,每一条我都是踩了才知道:
- 没有草稿态。 如上。修改已有的券 = 打开它的编辑页再点一次
Confirm(是重新保存,不会建出第二张),但依然没有「先摆好,再发布」这一步。 - 每个店只能有 1 张 New Buyer(“欢迎券”)。 想建第二张会弹提示:“Please create a new shop welcome voucher after the existing one is expired.” 点击是有效的、URL 不变、而任何一个字段旁边都没有红字 —— 这一点很重要,见后文。
- Shop 券不受这个限制。 我同时跑了 3 张 Shop 券,都正常。所以「每种券只能一张」这个概括是错的,欢迎券才是特例。
- 券一旦进入
Ongoing,经济条件就锁死。 编辑页里折扣金额、最低消费、开始时间都是disabled,只剩下名称、代码后缀、结束日期、数量可以改。而且后台根本没有删除入口 —— 列表页没有,编辑页也没有。已上线的券只能改,或者等它过期。
第 4 条改变的是策略:你没法「修好」一个已经在跑的折扣,只能让它死掉再重建。也就是说,建券这个动作比看起来要不可逆得多。
三层设计
我不想给 AI 一个花钱按钮,也不想手动盯着券。最后落成的是三层结构,区别在于谁做决定:
| 层 | 做什么 | 谁决定 |
|---|---|---|
| A — 审计 | 只读:列出所有券,标记即将到期/已用完/长期无人领,输出一行摘要 | 不需要人,全自动 |
| B — 半自动 | 报告问题;人回一句话;助手执行修改 | 人 |
| C — 提议 | 算出它打算建的券(面额、最低消费、数量、周期、敞口),然后停下 | 人,针对价格 |
A 层是一个 cron 任务:每天早上 09:00,脚本驱动浏览器读券列表,打印一行 digest。浏览器/CDP 连不上或登录态失效时退出码是 2,页面解析失败是 3 —— 所以「没有输出」永远不等于「一切正常」。
C 层是有意思的部分。策略写在一个 JSON 文件里而不是代码里,每种券是一个 slot:
{
"enabled": true,
"mode": "propose",
"replenish": {
"templates": [
{
"slot": "new_buyer",
"type": "new_buyer",
"name": "New Buyer RM9.60 off min RM18",
"amount": 9.6, "min_spend": 18, "qty": 20,
"window_days": 14, "code_prefix": "NB",
"max_per_7_days": 1
}
]
},
"caps": { "monthly_exposure_rm": 300 }
}
助手只回答一个问题:有哪个 slot 是空的? 有的话,它算出参数 —— 在 propose 模式下 —— 打印出来然后停下:
PROPOSAL (nothing created yet): slot 'new_buyer' is empty → 9.6 off / Min RM18 / 20 qty
/ 14 days, code NBX4K, exposure RM192
why: no live voucher in slot 'new_buyer'
WAITING FOR PRICE CONFIRMATION before creating (policy mode=propose)
只有在策略里写着 "mode": "auto" 并且这次运行不是 dry run 时,创建路径才会执行:
mode = str(pol.get("mode") or "propose").lower()
if a.dry or mode != "auto":
print_proposal(actions) # 完全不碰平台
else:
apply_auto(actions) # 唯一一条能花钱的代码路径
整个技巧就是这样,一点也不高级:你和按钮之间隔一个布尔值,默认值是「先问」。上限、slot、digest 都是配角。
那个错误:一份迟到了十分钟的列表
接下来这段我不太想写,因为它花了钱,而原因纯粹是我不耐烦。
我填好优惠券表单,点了 Confirm,然后按读任何网页表单的方式读结果:URL 变了吗?没有。成功提示?没有。红色错误?没有。于是我去问脚本要券列表 —— 列表里没有这张券。
我由此得出的结论是:创建被静默拒绝了,大概又是某种「每类只能一张」的限制,只是这次平台懒得给错误信息。于是我又点了一次 Confirm。再点了一次。
券其实已经建好了,只是列表延迟了十分钟。它后来出现了,状态 Ongoing,参数是我填的 RM14 off / 最低消费 RM29 —— 一张我从没打算建的券,最坏情况下敞口 RM140(十张全部被领用)。
三条教训,按代价排序:
- 「看不到变化」不等于「失败」,它等于「未知」。 通过 UI 发出的写操作有三种状态:成功了、被拒了、还没反映出来 —— 而后两种第一眼长得一模一样。
- 幂等保护要建在写路径之前,而不是之后。 先问列表有没有同款券,建一次,等,再读。从那以后,这个项目里每一次创建都先做一次「是否已存在」的读取。
- 永远不要重复点击一个你还没验证过的提交按钮。 我的脚本报告
Confirm: clicked @926,619—— 一句关于一次鼠标事件的真话,和一句关于世界的废话。点击日志不是结果。
现在我把写入之后的读取当作唯一权威,其他一切当作噪声,并且在允许自己下结论之前强制等待。
我会怎么做不同
- 一开始就用 propose 模式。 我最初上线的是「自动建 + 敞口上限」。上限限制的是错误的大小,propose 模式阻止的是错误本身。基于上限的设计建起来更安心,实际用起来明显更糟。
- 按「slot 是否为空」判断,而不是「店里有没有券」。 因为第 2 条规则,「有没有券」是错的问题;「这一类券在不在」才是对的,也正是它让自动补位这件事成立。
- 让每一次写入自己断言自己的结果。 现在创建路径靠另一次审计来验证;它应该自己验证,并在回读不一致时大声失败。
- 动手前先把意图写进日志。 「我准备创建 RM9.60/18 × 20,敞口 RM192」和动作出现在同一份输出里,我那次重复点击当场就会暴露,而不是等下一次审计才发现。
结果
现在店里有四张券在跑,合起来正好构成一个按客单价分档的阶梯 —— 这才是原本的商业目标:
| 篮子金额 | 折扣 | 买家实付 |
|---|---|---|
| RM9(单件) | RM6 off | RM3 |
| RM18(两件) | RM9.20 off | RM8.80 |
| RM29 | RM14 off | RM15 |
| 新买家 | RM4.50 off | RM4.50 |
如果每一张的每一份都被领完,最坏敞口是 RM474。真实支出只是其中一小部分,因为只有订单完成才扣钱 —— 唯一的例外是那张我误建出来的 RM140 券,我留着它了,因为它正好就是我想补的 RM29 那一档。
自打 propose 闸门装上之后,助手自己创建的券数量是 0。它现在做的事,是每天打印一段参数,然后等我点头。
想给你的生意做一套类似的?
真正值得抄的不是 Shopee 这部分,而是那个模式:一个会监控、会计算、并且会在唯一那个花钱动作前自己停下来的助手。它适用于广告预算、退款审批、供应商下单,任何带「花钱」调用的流程。我在你已经在用的工具上构建这类自动化 —— Shopee 或 WooCommerce、你自己机器上的 Docker 和 Traefik、n8n,以及用来汇报的 Telegram。
在 WhatsApp 或 [email protected] 找我 —— 告诉我你想让助手在哪一步停下来问你,我会诚实地说这是周末就能搞定的事,还是个坏主意。更多见 hoelee.com。