Skip to content
// 0x
Go back
0x17 // 后端实践

优惠码批量生成系统的设计 — Stripe Promotion Codes + 自建管理层

背景

一个 SaaS 产品需要在定价页增加优惠码功能:提供 5%、10%、20% 三档折扣,每档按批次生成,每个批次可独立启用/停用,支持导出 CSV 给运营团队分发。

Stripe 本身提供了 Promotion Codes API,支持创建折扣码并关联到 Coupon。但直接用它有几个问题:

  1. 需要 Webhook 消费逻辑来追踪使用状态,因为 Stripe 的 promotion code 没有”被谁在什么时候用掉”的简单查询
  2. 没有批次概念,批量管理(一次性停用某个批次的所有码)需要挨个调用 API
  3. 运营团队需要 CSV 导出和 Web 管理后台,不习惯登录 Stripe Dashboard

结论:Stripe 负责折扣生效(Checkout 时验证并应用),自身系统负责码的管理和追踪

编码规则

优惠码的设计要兼顾”一眼可辨识”和”不可被猜测”。选择了带前缀的格式:

批次号 + 档位前缀 + 随机串

示例:001F8K7N  →  批次 001, 5% 档(F), 随机 4 位
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

表之间的设计决策:

与 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 里扫一眼就能区分批次和档位,不需要读第三列。


Share this post on:

Previous Post
mysqladmin ping 骗了我——Unix socket 和 TCP 之间的调试陷阱
Next Post
库存可用量计算重构 — 把散落的硬编码公式收敛为一句话