背景
一个 SaaS 产品需要在定价页增加优惠码功能:提供 5%、10%、20% 三档折扣,每档按批次生成,每个批次可独立启用/停用,支持导出 CSV 给运营团队分发。
Stripe 本身提供了 Promotion Codes API,支持创建折扣码并关联到 Coupon。但直接用它有几个问题:
- 需要 Webhook 消费逻辑来追踪使用状态,因为 Stripe 的 promotion code 没有”被谁在什么时候用掉”的简单查询
- 没有批次概念,批量管理(一次性停用某个批次的所有码)需要挨个调用 API
- 运营团队需要 CSV 导出和 Web 管理后台,不习惯登录 Stripe Dashboard
结论:Stripe 负责折扣生效(Checkout 时验证并应用),自身系统负责码的管理和追踪。
编码规则
优惠码的设计要兼顾”一眼可辨识”和”不可被猜测”。选择了带前缀的格式:
批次号 + 档位前缀 + 随机串
示例:001F8K7N → 批次 001, 5% 档(F), 随机 4 位
- 批次号:3 位数字(001-999),支持 999 个批次
- 档位前缀:
F(5%)、T(10%)、W(20%),用不直观的首字母降低猜测概率 - 随机串:4 位,字符集排除易混淆字符(
0/O/I/1等),仅用ABCDEFGHJKLMNPQRSTUVWXYZ23456789
const TIER_PREFIX = [
'5%' => 'F',
'10%' => 'T',
'20%' => 'W',
];
function generateCode(int $batchId, string $tier, int $randomLen = 4): string
{
$tierChar = self::TIER_PREFIX[$tier] ?? 'X';
$batchNum = sprintf('%03d', $batchId);
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$random = '';
for ($i = 0; $i < $randomLen; $i++) {
$random .= $chars[random_int(0, strlen($chars) - 1)];
}
return $batchNum . $tierChar . $random;
}
8 位长度,够短、够唯一、档位和批次可读。
数据库设计
三个表:
promo_batches
├── id, name, active (0/1), created_at
promo_codes
├── id, batch_id, tier (5%/10%/20%), code, status (0=available, 2=used), used_at
promo_code_usage
├── id, code_id, stripe_session_id, used_at
表之间的设计决策:
promo_codes.status只设了两个值(可用/已用),没有”暂停”状态。码级别的暂停无业务价值——要么整个批次停用,要么单个码被用掉。promo_code_usage独立于promo_codes,记录了 Stripe session 关联,方便对账时反查。- 批次表没有”过期时间”字段。运营层面由人工控制启用/停用,避免引入自动过期逻辑的复杂度。
与 Stripe 的对接
每个档位在 Stripe Dashboard 创建对应的 Coupon(如 5% off 的永不过期 coupon),拿到 coupon ID 后写入配置文件:
PROMO_COUPON_5=abc123
PROMO_COUPON_10=def456
PROMO_COUPON_20=ghi789
Checkout Session 创建时开启 promotion codes:
$sessionData = [
'mode' => 'subscription',
'allow_promotion_codes' => true, // 关键:让 Stripe 验证和管理折扣
'line_items' => [[...]],
// ...
];
这样用户输入的码由 Stripe 验证有效性并计算折扣金额,自身系统无需参与折扣计算——避免了金额不一致的 bug。
Stripe 的 checkout.session.completed webhook 负责回调,在系统中更新 promo_codes.status 和记录 promo_code_usage。
管理后台
一个单页面的管理界面,三个操作:
1. 创建批次
输入批次名称、各档位码数量,一键生成。前端直接用 fetch 调 API:
function createBatch() {
api('create_batch', { name, counts: { '5%': 10, '10%': 10, '20%': 10 } })
.then(data => {
showMsg(msgEl, 'Batch created — ' + data.codes_created + ' codes', 'success');
loadBatches();
});
}
后端 create_batch 在一个事务里完成:建批次记录 → 循环生成码 → 批量 insert → 返回生成数量。事务保证不会出现”批次创建了但码没生成完”的中间状态。
2. 启用/停用批次
不是删码,而是翻转 promo_batches.active。停用后该批次的所有码仍然在数据库中保留,只是不在有效列表中返回给 Checkout 流程。一个按钮操作,Toggle 逻辑。
3. 导出 CSV
将某个批次的所有码导出为 CSV:
Code,Tier,Status,Used At
001F8K7N,5%,Available,—
001T9M2P,10%,Used,2026-05-23
格式直接给运营团队用,无需二次加工。
设计中的取舍
为什么不在 Stripe 里直接管理批次? 可以,但需要每次调 API 去更新每个 promotion code 的 active 状态。假设一个批次 30 个码,启停一次就是 30 次 API 调用。自建 promo_batches.active 字段后用一条 SQL UPDATE 解决,性能差异一个数量级。
为什么要追踪使用记录? Stripe 的 promotion code 本身不记录”谁用了这个码”(除非去查对应的 Checkout Session)。自建 promo_code_usage 表让运营可以直接在管理后台看到每个码的使用状态,不需要登录 Stripe Dashboard。
为什么码的状态只有 available/used 而不设 expired? 折扣永远有效(由 Stripe Coupon 管理过期),码本身的”失效”通过批次停用来实现。这样状态机只有两个状态,避免了 available/used/expired 三态带来的组合复杂度。
小结
这个系统的设计核心是边界划分:Stripe 处理它擅长的——折扣验证和金额计算;自身系统处理自己擅长的——批次管理、状态追踪和运营友好的导出。不在 Stripe 里做它不擅长的管理操作,也不在自己系统里重复 Stripe 已经做好的支付安全逻辑。
一个值得注意的点:编码规则中档位前缀(F/T/W)的选择没有用 5/10/20 的直接映射。不是因为安全——这样的码不会暴露在公开页面上——而是让运营人员在 Excel 里扫一眼就能区分批次和档位,不需要读第三列。