我让 AI 助手管我的 Shopee 优惠券 —— 它建了一张我没批准的券

· 2 min read · ai ai-agentshopeeautomationhuman-in-the-loopecommercecron

我在 Shopee 上开了一家小店,卖数字商品。店还年轻,挡在第一笔订单前面的不是库存,而是一个必须先在 30 天内完成一笔订单才能申请的商家计划。所以:先有订单。

优惠券是我手上最便宜的杠杆:数字商品的边际成本几乎为零,所以一张 RM6 的券,只有在真的有人买的时候才花我 RM6,平时什么也不花。而且我的 AI 助手本来就在用 Chrome DevTools Protocol 驱动 Shopee 卖家后台发商品,把优惠券自动化看起来只是顺手的事。

结果并不顺手。不是自动化难,而是平台有一个特性:

Shopee 的优惠券没有草稿态。 商品可以点 Save and Delist 存成草稿,以后再决定;优惠券不行 —— Confirm 既是发布按钮也是花钱按钮,点下去买家就能领,每领一张就从你的 escrow 余额里扣。中间没有「已保存、未上线」这个可以躲的位置。

这一句话决定了整套设计,也是我如果重做一次会最先做对的地方。

平台实际是怎么运作的(全都是试出来的)

四条规则,每一条我都是踩了才知道:

  1. 没有草稿态。 如上。修改已有的券 = 打开它的编辑页再点一次 Confirm(是重新保存,不会建出第二张),但依然没有「先摆好,再发布」这一步。
  2. 每个店只能有 1 张 New Buyer(“欢迎券”)。 想建第二张会弹提示:“Please create a new shop welcome voucher after the existing one is expired.” 点击是有效的、URL 不变、而任何一个字段旁边都没有红字 —— 这一点很重要,见后文。
  3. Shop 券不受这个限制。 我同时跑了 3 张 Shop 券,都正常。所以「每种券只能一张」这个概括是错的,欢迎券才是特例。
  4. 券一旦进入 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(十张全部被领用)。

三条教训,按代价排序:

  1. 「看不到变化」不等于「失败」,它等于「未知」。 通过 UI 发出的写操作有三种状态:成功了、被拒了、还没反映出来 —— 而后两种第一眼长得一模一样。
  2. 幂等保护要建在写路径之前,而不是之后。 先问列表有没有同款券,建一次,等,再读。从那以后,这个项目里每一次创建都先做一次「是否已存在」的读取。
  3. 永远不要重复点击一个你还没验证过的提交按钮。 我的脚本报告 Confirm: clicked @926,619 —— 一句关于一次鼠标事件的真话,和一句关于世界的废话。点击日志不是结果。

现在我把写入之后的读取当作唯一权威,其他一切当作噪声,并且在允许自己下结论之前强制等待。

我会怎么做不同

结果

现在店里有四张券在跑,合起来正好构成一个按客单价分档的阶梯 —— 这才是原本的商业目标:

篮子金额折扣买家实付
RM9(单件)RM6 offRM3
RM18(两件)RM9.20 offRM8.80
RM29RM14 offRM15
新买家RM4.50 offRM4.50

如果每一张的每一份都被领完,最坏敞口是 RM474。真实支出只是其中一小部分,因为只有订单完成才扣钱 —— 唯一的例外是那张我误建出来的 RM140 券,我留着它了,因为它正好就是我想补的 RM29 那一档。

自打 propose 闸门装上之后,助手自己创建的券数量是 0。它现在做的事,是每天打印一段参数,然后等我点头。

想给你的生意做一套类似的?

真正值得抄的不是 Shopee 这部分,而是那个模式:一个会监控、会计算、并且会在唯一那个花钱动作前自己停下来的助手。它适用于广告预算、退款审批、供应商下单,任何带「花钱」调用的流程。我在你已经在用的工具上构建这类自动化 —— Shopee 或 WooCommerce、你自己机器上的 Docker 和 Traefik、n8n,以及用来汇报的 Telegram。

在 WhatsApp 或 [email protected] 找我 —— 告诉我你想让助手在哪一步停下来问你,我会诚实地说这是周末就能搞定的事,还是个坏主意。更多见 hoelee.com。

Lee Teong Hoe

Full-stack developer & DevOps engineer. I build web apps, self-host infrastructure, and automate things — this blog is my living portfolio.