优惠券控制台
提示
优惠券控制台是 v4.0.0 新增的,入口在
/dashboard/admin/coupons。它只对provider: 'stripe'的套餐生效。
设计前提:Stripe 是唯一存储
本地不建表、不做迁移、不存任何优惠券数据。 页面每次交互都直接读写 Stripe。
这么做的理由很简单:优惠券的兑换次数、是否过期、是否被停用,事实来源永远在 Stripe 那边。本地存一份只会带来同步问题,而收益是零 —— 优惠券的读写频率低到不需要缓存。
代价是列表功能受 Stripe API 能力限制,见下文「列表的扫描上限」。
数据模型:一个优惠券是两个对象
Stripe 把优惠券拆成两层,模板沿用这个语义:
Coupon(折扣本身)
├── 折扣:percent_off 或 amount_off(二选一)
├── 适用范围:applies_to.products(空 = 全部产品)
└── 计费时长:duration = once | repeating | forever
repeating 时还要给 duration_in_months
Promotion Code(对客的码)
├── code:顾客在收银台输入的字符串,如 LAUNCH20
├── max_redemptions:总兑换次数上限
├── expires_at:过期时间
└── restrictions:first_time_transaction(仅新客)/ minimum_amount(最低订单额)创建时两个对象一次性建好。如果第二步(建 promotion code)失败 —— 最常见的原因是码重复 —— 代码会把刚建好的 coupon 删掉,不留孤儿对象。
最重要的约束:码创建后不可修改
Stripe 的 promotion code 一旦创建,除了启用/停用开关,什么都改不了。历史发票会引用它,所以 Stripe 也永远不会真正删除它。
这条约束塑造了整个 UI:
| 想做的事 | 实际能做的 |
|---|---|
| 改折扣力度 | ❌ 只能停用旧码,复制一份改完再建新码 |
| 改适用套餐 | ❌ 同上 |
| 改过期时间 | ❌ 同上 |
| 改兑换次数上限 | ❌ 同上 |
| 停止发放 | ✅ 关掉启用开关 |
| 删除 | ❌ Stripe 不支持,停用就是「删除」 |
所以列表行的操作只有两个:启用开关和复制一份。「复制」会把当前码的所有参数预填进创建表单,改完提交就是一个新码。

状态
操作者视角的四种状态:
| 状态 | 含义 | 能否重新启用 |
|---|---|---|
active | 正常可用 | — |
disabled | 被手工停用 | ✅ 可以重新打开 |
expired | 已过 expires_at | ❌ 终态 |
exhausted | 兑换次数已用尽 | ❌ 终态 |
expired 和 exhausted 之所以单列而不是合并成一个「inactive」,正是因为 Stripe 拒绝重新启用它们 —— 行内的开关直接读状态来决定自己是否禁用。
创建一个优惠券

表单字段:
| 字段 | 说明 |
|---|---|
| 码 | 留空则由 Stripe 生成。手填需匹配 ^[A-Z0-9_-]{3,30}$,会自动转大写 |
| 名称 | 优惠券展示名,出现在 Stripe 后台和发票上,最长 40 字符 |
| 折扣类型 | 百分比(0 < x ≤ 100)或固定金额(整数分)。二选一,互斥 |
| 适用套餐 | 多选,最多 20 个。留空 = 全部产品 |
| 计费时长 | once(仅首期)/ repeating(前 N 个月,1–36)/ forever(每期都打折) |
| 兑换次数上限 | 留空 = 不限 |
| 过期时间 | 提供「永不 / 7 天 / 30 天 / 90 天」快捷预设,也可自定义。必须是将来时间 |
| 仅限新客 | 限定从未付过款的客户 |
| 最低订单额 | 整数分,留空 = 无门槛 |
两个交互设计值得一提:
实时查重。 输入码时会实时调 checkPromotionCodeAvailability 问 Stripe 这个码占没占用,在提交前就告诉你,而不是把重复变成一次失败的创建。
效果摘要句。 对话框底部会用一句自然语言说清楚「顾客最终能得到什么」,比如「前 3 个月每期立减 $10,仅限 Pro 月付,仅新客可用」。折扣、范围、时长三者组合起来容易算错,这句话是防呆。
适用范围怎么落到 Stripe
表单里选的是套餐 slug,提交时会翻译成 Stripe 的 product id:
planId(config/pricing.ts)
↓ getPlanById + isStripePlan 校验
Stripe Price ID
↓ 反查
Stripe Product ID
↓ 写入
coupon.applies_to.products如果选中的套餐不是 Stripe 渠道的(provider 不是 stripe),会直接拒绝并告诉你是哪个套餐。
注意测试/正式模式
product id 在 test 和 live 两个模式下是不同的。在测试模式建的优惠券只对测试模式的产品生效,上线前需要在正式模式重建一份同名的码。
自动应用
在 config/pricing.ts 里配置了促销码,结账时会自动应用,顾客不需要手动输入:
// 全站活动
export const pricingCampaign: PricingCampaign = {
promotionCode: 'LAUNCH20',
}
// 或单个套餐(优先级更高)
{ id: 'pro-monthly', promotionCode: 'PRO30', ... }列表里被自动应用的码会带一个标记。这个标记很重要:这类码对每个访客都是生效的,停用它等于悄无声息地取消了全站折扣,顾客不会看到任何错误提示,只会发现价格变了。所以停用前先确认 config/pricing.ts 里没在引用它。
反过来,如果配置的码在当前模式下不存在或已停用,结账不会失败,而是回落到渠道自带的手工输入框。过期的活动配置不会阻断收款。
详见定价配置的促销码一节。
列表的扫描上限
Stripe 的 promotion code 列表接口只有游标分页,没有文本搜索。想给运营提供「按码搜索 + 按状态筛 + 按套餐筛」,只能一次把全量拉下来在内存里过滤。
所以有一个硬上限:
const MAX_SCANNED_CODES = 500一次最多扫 500 个码(每页 100,自动翻页)。返回结果里带两个字段:
| 字段 | 用途 |
|---|---|
scanned | 过滤前从 Stripe 拉到的数量,用作「0 / 42」里的分母 |
truncated | 是否撞到了 500 上限,撞到说明更早的码被丢掉了 |
truncated 为真时,页面会显示扫描上限警告。Stripe 返回的是最新优先的顺序,这也正是运营建完码之后期望看到的顺序,所以代码保留了这个顺序而没有重排。
如果你的码数量长期超过 500,说明该考虑换个策略了 —— 比如给码加统一前缀后在 Stripe 后台管理,或者定期清理历史码。
服务端接口
actions/coupons/admin.ts,四个 action,全部带管理员校验:
| Action | 作用 |
|---|---|
getAdminPromotionCodes({ pageIndex, pageSize, search?, status?, appliesTo? }) | 列表:一次扫描 + 内存过滤分页 |
checkPromotionCodeAvailability(code) | 提交前查重 |
createAdminPromotionCode(input) | 建 coupon + promotion code,promo 失败则回滚 coupon |
setPromotionCodeActive({ id, active }) | Stripe 唯一允许的修改操作 |
Stripe 未配置时所有 action 直接返回错误提示,页面也会据此展示不可用状态。
常见问题
Q: Creem 和 PayPal 的优惠券怎么办?
这个控制台只管 Stripe。Creem 走它自己的 discountCode(在 config/pricing.ts 配置促销码后,结账时会作为 discountCode 传给 Creem);PayPal 不支持自动应用促销码。
Stripe 的促销码只能限定到 Stripe product,这是它只覆盖 Stripe 的根本原因。
Q: 为什么不能改优惠券?
这是 Stripe 的约束,不是模板的选择。promotion code 被历史发票引用,允许修改会让历史账单的口径变得不可信。
Q: 停用的码还能被使用吗?
不能。但已经用过它的订阅,如果 duration 是 repeating 或 forever,折扣会继续按原条款生效到期 —— 停用只阻止新的兑换。
Q: 怎么看一个码被用了多少次?
列表里有兑换进度(已兑换 / 上限)。更详细的数据点击行内的 Stripe 后台深链,直达对应模式的 Stripe 页面。