Files
hoelee-blog/src/content/posts/zh/i-let-an-agent-manage-my-shopee-vouchers.md
T
hoelee 68771101ea edit(content): review batch-1 editorial pass across 24 posts (EN + ZH)
- voice: first-person throughout (we/our -> I/my), matching the post guideline
- privacy: internal LAN IPs replaced with placeholder hostnames
  (n8n.internal, gpu-node.internal); one compose volume path generalised
- ZH twins: Traditional -> Simplified characters (site serves zh-Hans),
  whole-file renormalisation where the twin was still Traditional
- dynamicCta: true on 12 posts so the contextual dual-track CTA renders
- long configs wrapped in <details class="tech-deep-dive"> with a summary
  naming the artefact and line count
- self-hosted-tts: token refresh now writes .tmp + atomic mv, with a note on
  the 401 race the old truncating redirect created
2026-10-27 03:29:03 +08:00

9.4 KiB
Raw Blame History

title, description, pubDate, updatedDate, category, tags, ogImage, banner, draft, dynamicCta
title description pubDate updatedDate category tags ogImage banner draft dynamicCta
我让 AI 助手管我的 Shopee 优惠券 —— 它建了一张我没批准的券 优惠券没有草稿态:点 Confirm 就是上线并开始扣 escrow。本文记录我用「只读审计 → 提议 → 自动」三层设计把 AI 挡在那个按钮之外,以及那次因为列表延迟十分钟而多建一张券的教训。 2025-07-08 2026-09-29 ai
ai-agent
shopee
automation
human-in-the-loop
ecommerce
cron
/og/zh-i-let-an-agent-manage-my-shopee-vouchers.jpg /banners/i-let-an-agent-manage-my-shopee-vouchers.jpg false true

我在 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 —— 一句关于一次鼠标事件的真话,和一句关于世界的废话。点击日志不是结果。

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

我会怎么做不同

  • 一开始就用 propose 模式。 我最初上线的是「自动建 + 敞口上限」。上限限制的是错误的大小,propose 模式阻止的是错误本身。基于上限的设计建起来更安心,实际用起来明显更糟。
  • 按「slot 是否为空」判断,而不是「店里有没有券」。 因为第 2 条规则,「有没有券」是错的问题;「这一类券在不在」才是对的,也正是它让自动补位这件事成立。
  • 让每一次写入自己断言自己的结果。 现在创建路径靠另一次审计来验证;它应该自己验证,并在回读不一致时大声失败。
  • 动手前先把意图写进日志。 「我准备创建 RM9.60/18 × 20,敞口 RM192」和动作出现在同一份输出里,我那次重复点击当场就会暴露,而不是等下一次审计才发现。

结果

现在店里有四张券在跑,合起来正好构成一个按客单价分档的阶梯 —— 这才是原本的商业目标(注:上文的 JSON 片段为早期试跑模板,下方表格记录的则是最终线上稳定运行的四档规则):

篮子金额 折扣 买家实付
RM9(单件) RM6 off RM3
RM18(两件) RM9.20 off RM8.80
RM29 RM14 off RM15
新买家 RM4.50 off RM4.50

如果每一张的每一份都被领完,最坏敞口是 RM474。真实支出只是其中一小部分,因为只有订单完成核销时才会实际扣款。而那张误建出来的 RM140 敞口券(RM29 减 RM14 × 10 张),我最终决定保留,因为它刚好补上了我原本就想建的 RM29 满减档位。

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

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

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

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