三张券,结算页只减了 30 元
「刚好」的结算页支持选券。一个用户买了 100 元的套餐,券包里有三张券:一张“满 100 减 30”的平台券,不可叠加;两张“满 50 减 20”的商家券,可叠加。系统默认推荐了那张 30 元券,页面显示实付 70 元。
用户没有直接下单。他把订单拆成两笔 50 元,分别用了两张 20 元券,实付 60 元。拆单多了一次配送,但省了 10 元。他把截图发到客服,问:“为什么系统不直接帮我选最便宜的?”
客服转给研发,研发的第一反应是“把券按优惠金额排序,取最大的”。这个改法在大多数情况下看起来对,但它会把这位用户推回 70 元:30 大于 20,排序后仍然先选 30 元券,而 30 元券不可叠加,选完就结束了。
问题不在排序,而在“最优”的定义。用户要的是总优惠最大,不是单张券面额最大。
叠加规则必须先于算法
在写任何选券代码之前,产品和技术要先把规则说清楚。规则含糊,算法再优也会得到不可解释的结果。
| 规则维度 | 常见取值 | 对算法的影响 | 含糊时会怎样 |
|---|---|---|---|
| 是否可叠加 | 可叠加、不可叠加 | 不可叠加券选中后独占整单 | 两张“不可叠加”券被一起用掉 |
| 叠加范围 | 平台券、商家券、品类券 | 决定哪些券能同时出现 | 跨店券被用在单店订单 |
| 计算基准 | 原价、折后价、排除品类后金额 | 决定每张券的优惠额 | 折扣券和立减券互相放大 |
| 金额上限 | 不超过订单金额、单券封顶 | 总优惠要封顶 | 多张券把实付算成负数 |
| 取整方式 | 向下、四舍五入 | 百分比券按分取整 | 同一单在不同端差一分 |
| 同额排序 | ID 字典序、领取时间 | 决定最终推荐哪一组 | 同样优惠每次推荐不同券 |
本章的 Best 采用一组明确但简化的规则:不可叠加券最多选一张,且选中后不能再使用其他券;可叠加券可以任意组合,每张券的优惠都按原始订单金额独立计算;总优惠不超过订单金额;百分比按分向下取整;优惠相同时按排序后的 ID 列表字典序取较小者。
这里要特别说明“按原始订单金额独立计算”。真实系统里,多张折扣券通常不能同时使用,或者第二张只能按折后价计算。本章为了让规则可验证,允许可叠加券各自按原价计算,再对总额封顶。生产环境如果允许两张 50% 折扣券叠加,必须明确是减 100% 还是减 75%,否则前端展示和后端结算会各算一套。
最优解不是贪心
选券是一个带约束的组合问题。约束来自“不可叠加券只能选一张”和“券之间有互斥关系”,目标函数是总优惠最大。
贪心有两种常见写法,都会出错。第一种是按面额从大到小排序,先选最大的,再在剩余券里继续选。它错在只看单张面额,不看组合:30 元的不可叠加券单独用,比 20 加 20 的组合少 10 元。第二种是按“单位门槛收益”排序,先选折扣率高的。它错在门槛是硬约束,折扣率高的券可能因为差一元不满足门槛,选完反而把名额占掉。
正确的做法是把候选方案列全,再比较。候选方案其实只有两类:一类是某一张不可叠加券单独使用,另一类是全部可叠加券的组合。对于本章的规则,可叠加券的优惠各自独立,所以最优组合就是把所有能用的可叠加券都选上,不需要枚举子集。两类方案比较总优惠,优惠相同再比 ID 字典序,结果就唯一了。
这也是为什么规则要先定:如果可叠加券之间存在互斥,或者折扣券按折后价计算,候选空间会变大,算法就要改成枚举或动态规划。规则越复杂,越需要把“哪些券能一起用”变成显式数据,而不是散落在 if 里。
失败场景:先选大额券,反而少减 10 元
回到开头。券包是:平台券 A,满 100 减 30,不可叠加;商家券 B、C,各满 50 减 20,可叠加。订单 100 元。
贪心实现先把三张券按优惠额排序:A 30、B 20、C 20。选中 A 后,因为 A 不可叠加,循环结束,总优惠 30。它没有比较“放弃 A、选 B 加 C”的方案,因为贪心一旦做出选择就不再回退。
正确的比较是:方案一,A 单独用,优惠 30;方案二,B 加 C,优惠 40。40 大于 30,选 B 和 C。用户实付 60 元,和拆单结果一致。
第二个变种更隐蔽:如果 B、C 的门槛是满 60 减 20,订单 100 元,两张券都能用,总优惠 40。但如果先选了 A,再想加 B,代码发现 A 不可叠加,把 B 丢掉;用户仍然只减 30。第三种变种是封顶写错:B、C 各减 60,订单 100 元,总优惠本应封顶 100,代码却返回 120,实付变成 -20,支付网关直接拒绝。第四种是百分比取整:订单 101 元、50% 折扣,浮点计算得到 50.5,四舍五入成 51,而财务规则是向下取整 50,两边对账差一分。
这些错误的共同点是“用局部最优代替全局比较”。券的数量通常不多,把候选方案列全的成本很低,没有必要为了省几次比较牺牲正确性。
用 Best 比较所有候选
Best 的输入是一组券和订单金额,输出选中的券 ID 和总优惠。它不负责查询券、不负责锁券,只回答“在这些券里,哪种组合最划算”。
// file: internal/coupon/stack.go
// tier: L1
// verify: go test ./internal/coupon/
package coupon
import (
"errors"
"fmt"
"sort"
)
var (
// ErrNegativeOrder 表示订单金额为负数。
ErrNegativeOrder = errors.New("coupon: negative order amount")
// ErrDuplicateCoupon 表示同一张券被传入两次。
ErrDuplicateCoupon = errors.New("coupon: duplicate coupon id")
// ErrEmptyCouponID 表示券实例缺少 ID。
ErrEmptyCouponID = errors.New("coupon: empty coupon id")
)
// Coupon 是一张已经发到用户手里的券。Stackable 表示它能否与其他券叠加;
// 不可叠加券在一次下单中最多使用一张,并且不能和任何其他券同时使用。
type Coupon struct {
ID string
Kind Kind
Value int64
MinAmountCents int64
Stackable bool
}
// Plan 是一组选中的券和它们带来的总优惠。IDs 按字典序排列,保证同一个
// 输入永远得到同一个结果,便于缓存、审计和测试。
type Plan struct {
IDs []string
DiscountCents int64
}
// Best 在给定订单金额下选出优惠最大的一组券。规则如下:
//
// - 不可叠加券最多选一张,且选中后不能再使用其他券;
// - 可叠加券可以任意组合,每张券的优惠都按原始订单金额独立计算;
// - 总优惠不能超过订单金额,金额券也不能把订单减成负数;
// - 百分比折扣按分计算并向下取整;
// - 优惠金额相同时,按排序后的 ID 列表字典序取较小者。
//
// 空券列表返回零优惠的 Plan 和 nil,表示“这个订单没有券可用”,不是错误。
func Best(coupons []Coupon, amountCents int64) (Plan, error) {
if amountCents < 0 {
return Plan{}, fmt.Errorf("%w: %d", ErrNegativeOrder, amountCents)
}
seen := make(map[string]struct{}, len(coupons))
stackableIDs := make([]string, 0, len(coupons))
stackableDiscounts := make([]int64, 0, len(coupons))
var best Plan
for _, c := range coupons {
if c.ID == "" {
return Plan{}, ErrEmptyCouponID
}
if _, ok := seen[c.ID]; ok {
return Plan{}, fmt.Errorf("%w: %s", ErrDuplicateCoupon, c.ID)
}
seen[c.ID] = struct{}{}
d, err := discountOf(c, amountCents)
if err != nil {
return Plan{}, err
}
if c.Stackable {
if d > 0 {
stackableIDs = append(stackableIDs, c.ID)
stackableDiscounts = append(stackableDiscounts, d)
}
continue
}
cand := Plan{IDs: []string{c.ID}, DiscountCents: d}
if better(cand, best) {
best = cand
}
}
total := int64(0)
for _, d := range stackableDiscounts {
if total >= amountCents-d {
total = amountCents
break
}
total += d
}
if total > 0 {
sort.Strings(stackableIDs)
cand := Plan{IDs: stackableIDs, DiscountCents: total}
if better(cand, best) {
best = cand
}
}
return best, nil
}
// discountOf 计算单张券在原始订单金额上的优惠。门槛未达到时返回 0,
// 但券本身仍然必须是合法的;非法券不能因为“反正用不上”而被静默忽略。
func discountOf(c Coupon, amountCents int64) (int64, error) {
if c.MinAmountCents < 0 {
return 0, fmt.Errorf("%w: min %d", ErrInvalidTemplate, c.MinAmountCents)
}
switch c.Kind {
case KindAmount:
if c.Value <= 0 {
return 0, fmt.Errorf("%w: amount value %d", ErrInvalidValue, c.Value)
}
case KindPercent:
if c.Value < 1 || c.Value > 100 {
return 0, fmt.Errorf("%w: percent value %d", ErrInvalidValue, c.Value)
}
default:
return 0, fmt.Errorf("%w: %q", ErrInvalidKind, c.Kind)
}
if amountCents < c.MinAmountCents {
return 0, nil
}
if c.Kind == KindAmount {
if c.Value >= amountCents {
return amountCents, nil
}
return c.Value, nil
}
// 先除后乘,避免 amountCents*Value 在大额订单上溢出 int64。
return amountCents/100*c.Value + amountCents%100*c.Value/100, nil
}
// better 判断 a 是否优于 b:先比优惠金额,再比排序后的 ID 列表字典序。
func better(a, b Plan) bool {
if a.DiscountCents != b.DiscountCents {
return a.DiscountCents > b.DiscountCents
}
for i := 0; i < len(a.IDs) && i < len(b.IDs); i++ {
if a.IDs[i] != b.IDs[i] {
return a.IDs[i] < b.IDs[i]
}
}
return len(a.IDs) < len(b.IDs)
}
实现里有几个关键点:
- 先校验订单金额非负、券 ID 非空且不重复、券类型和面值合法。非法券不能因为“反正用不上”被静默忽略,否则脏数据会一直留在券包里。
- 每张券的优惠由
discountOf独立计算。未达到门槛返回 0,但券本身仍然要合法;金额券的优惠不会超过订单金额,百分比券按amount/100*value + amount%100*value/100计算,先除后乘避免int64溢出。 - 可叠加券的优惠先累加,累加过程中按订单金额封顶。封顶放在循环里而不是最后做一次减法,是为了避免多张券相加时溢出。
- 不可叠加券各自形成“单独使用”的候选方案,可叠加券形成另一个候选方案。
better先比优惠金额,再按排序后的 ID 列表比较字典序。 - 空券列表返回零优惠的
Plan和nil。没有券不是错误,结算页应该正常显示原价。 - 返回的
IDs排序后再输出,保证同一组输入永远得到同一个结果,便于缓存和审计。
Best 是纯函数,也不处理并发。线上选券时,券可能在同一时刻被其他订单占用,所以“算出最优组合”之后还要有一次原子锁券;锁券失败就重新选,不能假设计算结果一直有效。
测试:组合、封顶、同额与非法输入
选券的错误大多发生在比较逻辑和边界上:组合是否真的比单张大、封顶是否生效、同额时是否稳定、非法输入是否被挡住。
// file: internal/coupon/stack_test.go
// tier: L1
// verify: go test ./internal/coupon/
package coupon
import (
"errors"
"reflect"
"testing"
)
func TestBestEmptyCoupons(t *testing.T) {
got, err := Best(nil, 10000)
if err != nil {
t.Fatalf("Best = %v, want nil", err)
}
if got.DiscountCents != 0 || len(got.IDs) != 0 {
t.Fatalf("Best = %+v, want zero plan", got)
}
}
func TestBestPrefersStackableCombination(t *testing.T) {
coupons := []Coupon{
{ID: "a-nonstack", Kind: KindAmount, Value: 3000},
{ID: "b-stack", Kind: KindAmount, Value: 2000, Stackable: true},
{ID: "c-stack", Kind: KindAmount, Value: 2000, Stackable: true},
}
got, err := Best(coupons, 10000)
if err != nil {
t.Fatalf("Best = %v, want nil", err)
}
if got.DiscountCents != 4000 {
t.Fatalf("DiscountCents = %d, want 4000", got.DiscountCents)
}
if !reflect.DeepEqual(got.IDs, []string{"b-stack", "c-stack"}) {
t.Fatalf("IDs = %v, want [b-stack c-stack]", got.IDs)
}
}
func TestBestPrefersLargerSingleNonStackable(t *testing.T) {
coupons := []Coupon{
{ID: "a-nonstack", Kind: KindAmount, Value: 5000},
{ID: "b-stack", Kind: KindAmount, Value: 2000, Stackable: true},
{ID: "c-stack", Kind: KindAmount, Value: 2000, Stackable: true},
}
got, err := Best(coupons, 10000)
if err != nil {
t.Fatalf("Best = %v, want nil", err)
}
if got.DiscountCents != 5000 || !reflect.DeepEqual(got.IDs, []string{"a-nonstack"}) {
t.Fatalf("Best = %+v, want 5000 via a-nonstack", got)
}
}
func TestBestTieBreaksBySortedIDs(t *testing.T) {
coupons := []Coupon{
{ID: "b", Kind: KindAmount, Value: 1000},
{ID: "a", Kind: KindAmount, Value: 1000},
}
got, err := Best(coupons, 10000)
if err != nil {
t.Fatalf("Best = %v, want nil", err)
}
if !reflect.DeepEqual(got.IDs, []string{"a"}) {
t.Fatalf("IDs = %v, want [a]", got.IDs)
}
}
func TestBestCapsDiscountAtOrderAmount(t *testing.T) {
coupons := []Coupon{
{ID: "a-stack", Kind: KindAmount, Value: 6000, Stackable: true},
{ID: "b-stack", Kind: KindAmount, Value: 6000, Stackable: true},
}
got, err := Best(coupons, 10000)
if err != nil {
t.Fatalf("Best = %v, want nil", err)
}
if got.DiscountCents != 10000 {
t.Fatalf("DiscountCents = %d, want 10000", got.DiscountCents)
}
}
func TestBestRespectsMinAmountAndFloorsPercent(t *testing.T) {
coupons := []Coupon{{ID: "p", Kind: KindPercent, Value: 33, MinAmountCents: 1000}}
if got, err := Best(coupons, 999); err != nil || got.DiscountCents != 0 {
t.Fatalf("Best below min = %+v, %v; want zero plan", got, err)
}
got, err := Best(coupons, 1000)
if err != nil {
t.Fatalf("Best = %v, want nil", err)
}
if got.DiscountCents != 330 {
t.Fatalf("DiscountCents = %d, want 330", got.DiscountCents)
}
got, err = Best([]Coupon{{ID: "p", Kind: KindPercent, Value: 50}}, 101)
if err != nil || got.DiscountCents != 50 {
t.Fatalf("floor = %+v, %v; want 50", got, err)
}
}
func TestBestRejectsBadInput(t *testing.T) {
if _, err := Best(nil, -1); !errors.Is(err, ErrNegativeOrder) {
t.Fatalf("negative amount = %v, want ErrNegativeOrder", err)
}
if _, err := Best([]Coupon{{ID: "x", Kind: Kind("gift"), Value: 1}}, 100); !errors.Is(err, ErrInvalidKind) {
t.Fatalf("bad kind = %v, want ErrInvalidKind", err)
}
if _, err := Best([]Coupon{{ID: "x", Kind: KindPercent, Value: 0}}, 100); !errors.Is(err, ErrInvalidValue) {
t.Fatalf("bad value = %v, want ErrInvalidValue", err)
}
dup := []Coupon{
{ID: "same", Kind: KindAmount, Value: 100},
{ID: "same", Kind: KindAmount, Value: 200},
}
if _, err := Best(dup, 1000); !errors.Is(err, ErrDuplicateCoupon) {
t.Fatalf("duplicate = %v, want ErrDuplicateCoupon", err)
}
}
TestBestEmptyCoupons 锁定空输入返回零方案。TestBestPrefersStackableCombination 复现开头的事故:30 元不可叠加券对 20 加 20 的可叠加组合,断言结果是 40 元、ID 为 [b-stack c-stack]。TestBestPrefersLargerSingleNonStackable 把不可叠加券换成 50 元,断言这时单张更大。
TestBestTieBreaksBySortedIDs 用两张同额券验证字典序,b 和 a 最终选 a。TestBestCapsDiscountAtOrderAmount 用两张 60 元券打 100 元订单,断言总优惠封顶 100。TestBestRespectsMinAmountAndFloorsPercent 同时覆盖门槛不满足、33% 向下取整和 101 元的 50% 折扣。TestBestRejectsBadInput 覆盖负订单金额、未知类型、非法面值和重复 ID。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/coupon/ -run 'TestBest' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestBestEmptyCoupons
--- PASS: TestBestEmptyCoupons (0.00s)
=== RUN TestBestPrefersStackableCombination
--- PASS: TestBestPrefersStackableCombination (0.00s)
=== RUN TestBestPrefersLargerSingleNonStackable
--- PASS: TestBestPrefersLargerSingleNonStackable (0.00s)
=== RUN TestBestTieBreaksBySortedIDs
--- PASS: TestBestTieBreaksBySortedIDs (0.00s)
=== RUN TestBestCapsDiscountAtOrderAmount
--- PASS: TestBestCapsDiscountAtOrderAmount (0.00s)
=== RUN TestBestRespectsMinAmountAndFloorsPercent
--- PASS: TestBestRespectsMinAmountAndFloorsPercent (0.00s)
=== RUN TestBestRejectsBadInput
--- PASS: TestBestRejectsBadInput (0.00s)
PASS
ok webapp/internal/coupon 0.241s
PASS 表示 7 个选券用例全部通过。它们验证的是本章简化规则下的最优解;如果生产规则允许折扣券按折后价叠加,或者不同券之间存在额外互斥,Best 的候选空间和测试都要同步扩展。
小结
叠加规则要先于算法确定:哪些券能一起用、按什么金额计算、总额怎么封顶、同额怎么排序。规则清楚之后,最优解就是列全候选方案再比较,而不是按面额排序的贪心。
Best 用“不可叠加券单独候选、可叠加券合并候选”的方式覆盖了本章的规则,金额封顶、百分比取整和 ID 字典序都落在可测试的边界上。下一节把视角从单笔订单拉到活动预算:券发得出去,钱不一定烧得起。
阅读导航:上一节:4.1 券模板、发放与核销 · 下一节:4.3 营销预算与防刷 。
常见错误与边界
- 按单张面额排序后贪心。30 元单券可能输给 20 加 20 的组合,必须比较候选方案的总优惠。
- 把“不可叠加”理解成“不能和同类券叠加”。它通常意味着不能和任何券同时使用,规则要写清楚。
- 折扣券按折后价还是原价计算没有定义。两种算法结果不同,前端展示和后端结算必须用同一套。
- 多张券相加不做封顶。优惠超过订单金额会算出负数实付,支付网关会直接拒绝。
- 百分比用浮点计算再四舍五入。财务通常按分向下取整,浮点和银行家舍入都会造成对账差。
- 同额时不定义排序。每次请求可能推荐不同的券,用户刷新结算页看到的价格会变。
- 把非法券静默忽略。脏数据会一直留在券包里,用户看到券却永远用不了。
- 算出最优组合就认为券已经锁定。并发下单时券可能已被占用,必须再做一次原子锁券。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。