跳至主要内容

优惠券系统设计:责任链、价格漏斗、折后分摊与一分钱兜底

项目 内容
文档类型 技术设计文档
适用范围 购物车 / 结算页试算、订单创建、支付前复核、退款与结算
示例语言 Go 1.22(示例代码已通过单元测试与模糊测试)

1. 背景与目标

价格计算是电商交易链路中最容易产生资损的环节。一笔订单可能同时包含活动价、店铺券、平台券,用户只关心最终支付金额,但系统必须回答另外三个问题:

  • 每一分优惠由谁出资:商家、平台还是活动预算,这决定了结算。
  • 每个订单行实际支付多少:这决定了部分退款、开票和售后。
  • 同样的输入是否永远得到同样的结果:这决定了试算、下单、支付复核、对账能否互相印证。

本文给出一套以 价格漏斗 定义金额流向、以 责任链 组织计算节点、以 折后分摊 落实行级明细、以 一分钱兜底 守住金额下限的设计方案。

设计目标

编号 目标 说明
G1 可解释 任意一笔订单都能说清每层优惠减了多少、为什么
G2 可复算 给定输入与规则版本,结果唯一且确定
G3 可对账 明细求和等于总额,出资方可归集
G4 可扩展 新增优惠类型不修改既有节点
G5 防资损 任何组合都不会产生 0 元单或负数金额

非目标:本文不涉及券的发放、营销预算投放与风控模型,只讨论下单环节的计价、分摊与核销一致性。

2. 术语与约定

术语 含义
订单行(Line) 订单中 SKU 维度的一条记录,分摊、退款的最小单位
商品级折扣 活动价、秒杀价、会员价等直接作用于商品单价的优惠
店铺券 商家出资,仅适用于该店铺商品
平台券 平台出资,可跨店铺适用
分摊基数 某张券作用时,各适用行的当前金额,用于按比例拆分券额
行级兜底 每个订单行实付不低于 MinPayPerLine(默认 1 个最小货币单位)

全局约定

  1. 金额一律使用 int64,单位为最小货币单位(如“分”)。禁止 float64;不同币种的最小单位由币种配置决定,不得在代码中硬编码 100。
  2. 漏斗顺序固定:商品级折扣 → 店铺券 → 平台券 → 兜底校验。顺序属于业务规则,需写入产品文档与规则版本,不得依赖调用顺序隐含。
  3. 门槛按作用时的金额判断:店铺券判断商品折后金额,平台券判断店铺券后金额。此口径必须与产品确认并固化。
  4. 计算一次,落账固化,后续只读:结算、退款、对账读取明细表,不重新计价。

3. 价格漏斗

订单金额自上而下逐层流动,每一层以上一层的结果为输入:

原价
 │  ① 商品级折扣(活动价 / 秒杀 / 会员价)       出资方:活动预算或商家
 ▼
商品折后金额
 │  ② 店铺券                                    出资方:商家
 ▼
店铺券后金额
 │  ③ 平台券                                    出资方:平台
 ▼
平台券后金额
 │  ④ 兜底校验(每行 ≥ MinPayPerLine)
 ▼
行级实付  →  汇总为订单实付

漏斗的性质

  • 每层只减不增,Current 单调递减。
  • 每层产出的优惠明细必须带出资方,便于结算。
  • 兜底约束贯穿每一层,而不是只在末尾检查一次(见第 6 节)。

4. 责任链设计

4.1 为什么使用责任链

漏斗各层的业务细节不同(适用范围、门槛、出资方),但处理骨架相同:校验、计算、分摊、落明细。责任链将每一层封装为独立节点,带来:

收益 说明
开闭原则 新增运费券、积分抵扣等只需增加节点并调整链的顺序
独立测试 每个节点输入上下文、断言输出,无需启动完整下单流程
多场景复用 购物车、结算页、下单、支付复核共用同一条链,杜绝页面价与订单价不一致
顺序可配置 调整“平台券先于店铺券”之类的规则,不改节点内部逻辑

实现说明:经典责任链由节点自行决定是否传递给下一个节点。计价场景中每一层都必须执行,因此本文采用其 pipeline 变体:Chain 顺序调用各节点,节点通过返回 error 中断。券不满足门槛是正常业务分支,记录 Skipped 后返回 nil;只有数据非法或不变量被破坏才返回 error。

4.2 数据模型与上下文

Context 是各节点共享的计价上下文,Detail 是最终落库的优惠明细。

package pricing

// Money 以最小货币单位(如“分”)表示的金额,全程使用整数,禁止浮点数。
type Money = int64

// Funding 优惠的出资方。
type Funding string

const (
    FundingActivity Funding = "ACTIVITY" // 活动补贴
    FundingMerchant Funding = "MERCHANT" // 商家
    FundingPlatform Funding = "PLATFORM" // 平台
)

// Stage 漏斗阶段。
type Stage string

const (
    StageItemPromo      Stage = "ITEM_PROMO"
    StageShopCoupon     Stage = "SHOP_COUPON"
    StagePlatformCoupon Stage = "PLATFORM_COUPON"
)

// Line 订单行(SKU 维度)。Current 随漏斗逐层递减,最终即行级实付。
type Line struct {
    LineID         int64
    SkuID          int64
    ShopID         int64
    Qty            int64
    UnitPrice      Money // 原单价
    PromoUnitPrice Money // 商品级折后单价;0 表示无活动价
    Origin         Money // 原价小计 = UnitPrice * Qty
    Current        Money // 当前金额(上一层处理后的结果)
}

// Coupon 满减券(固定面额)。折扣券可在节点内先换算成面额再复用同一流程。
type Coupon struct {
    ID        int64
    ShopID    int64              // 店铺券必填,平台券为 0
    Threshold Money              // 使用门槛,按“作用时”的金额判断
    Face      Money              // 面额
    ScopeSkus map[int64]struct{} // 适用商品;为空表示不限
}

func (c *Coupon) applicable(l *Line, shopScoped bool) bool {
    if shopScoped && l.ShopID != c.ShopID {
        return false
    }
    if len(c.ScopeSkus) == 0 {
        return true
    }
    _, ok := c.ScopeSkus[l.SkuID]
    return ok
}

// Detail 优惠明细,是结算、退款、对账的唯一依据。
type Detail struct {
    OrderID     string
    LineID      int64
    Stage       Stage
    CouponID    int64
    Funding     Funding
    Amount      Money
    RuleVersion string
}

// SkipReason 券未生效的原因,便于前端提示与问题排查。
type SkipReason struct {
    CouponID int64
    Reason   string
}

// Context 责任链在各节点之间传递的计价上下文。
type Context struct {
    OrderID        string
    RuleVersion    string
    Lines          []*Line
    ShopCoupons    []*Coupon
    PlatformCoupon *Coupon
    MinPayPerLine  Money // 行级兜底,通常为 1

    Details []Detail
    Skipped []SkipReason
}

func (pc *Context) skip(c *Coupon, reason string) {
    pc.Skipped = append(pc.Skipped, SkipReason{CouponID: c.ID, Reason: reason})
}

// Payable 订单实付。
func (pc *Context) Payable() Money {
    var s Money
    for _, l := range pc.Lines {
        s += l.Current
    }
    return s
}

4.3 节点抽象与链

package pricing

import (
    "context"
    "fmt"
)

// Handler 是责任链上的一个节点,只负责漏斗中的一层。
// 节点内部“券不可用”属于正常分支,应记录 Skipped 后返回 nil;
// 只有数据非法或不变量被破坏时才返回 error,从而中断整条链。
type Handler interface {
    Name() string
    Handle(ctx context.Context, pc *Context) error
}

// Chain 按固定顺序执行节点。
type Chain struct {
    handlers []Handler
}

func NewChain(hs ...Handler) *Chain { return &Chain{handlers: hs} }

// Run 依次执行各节点。
func (c *Chain) Run(ctx context.Context, pc *Context) error {
    for _, h := range c.handlers {
        if err := ctx.Err(); err != nil {
            return err
        }
        if err := h.Handle(ctx, pc); err != nil {
            return fmt.Errorf("pricing: handler %s: %w", h.Name(), err)
        }
    }
    return nil
}

// DefaultChain 顺序即业务规则:商品折扣 → 店铺券 → 平台券 → 兜底校验。
func DefaultChain() *Chain {
    return NewChain(
        ItemPromoHandler{},
        ShopCouponHandler{},
        PlatformCouponHandler{},
        FloorGuardHandler{},
    )
}

4.4 节点实现

商品折扣、店铺券、平台券三类节点的差别只在适用范围和出资方,因此券节点共用 applyCoupon,流程为“过滤适用行 → 判断门槛 → 计算可抵扣空间并截断 → 分摊 → 落明细”。

package pricing

import (
    "context"
    "errors"
    "fmt"
)

// ---------- ① 商品级折扣 ----------

type ItemPromoHandler struct{}

func (ItemPromoHandler) Name() string { return "item_promo" }

func (ItemPromoHandler) Handle(_ context.Context, pc *Context) error {
    for _, l := range pc.Lines {
        l.Origin = l.UnitPrice * l.Qty
        l.Current = l.Origin
        if l.PromoUnitPrice > 0 && l.PromoUnitPrice < l.UnitPrice {
            l.Current = l.PromoUnitPrice * l.Qty
        }
        if l.Current < pc.MinPayPerLine {
            l.Current = pc.MinPayPerLine // 活动价本身也不能击穿底线
        }
        if d := l.Origin - l.Current; d > 0 {
            pc.Details = append(pc.Details, Detail{
                OrderID: pc.OrderID, LineID: l.LineID, Stage: StageItemPromo,
                Funding: FundingActivity, Amount: d, RuleVersion: pc.RuleVersion,
            })
        }
    }
    return nil
}

// ---------- ② 店铺券(商家出资) ----------

type ShopCouponHandler struct{}

func (ShopCouponHandler) Name() string { return "shop_coupon" }

func (ShopCouponHandler) Handle(_ context.Context, pc *Context) error {
    for _, cp := range pc.ShopCoupons {
        if err := applyCoupon(pc, cp, true, StageShopCoupon, FundingMerchant); err != nil {
            return err
        }
    }
    return nil
}

// ---------- ③ 平台券(平台出资) ----------

type PlatformCouponHandler struct{}

func (PlatformCouponHandler) Name() string { return "platform_coupon" }

func (PlatformCouponHandler) Handle(_ context.Context, pc *Context) error {
    if pc.PlatformCoupon == nil {
        return nil
    }
    return applyCoupon(pc, pc.PlatformCoupon, false, StagePlatformCoupon, FundingPlatform)
}

// applyCoupon 是两类券共用的“校验 → 截断 → 分摊 → 落明细”流程。
func applyCoupon(pc *Context, cp *Coupon, shopScoped bool, stage Stage, funding Funding) error {
    var eligible []*Line
    var total Money
    for _, l := range pc.Lines {
        if cp.applicable(l, shopScoped) {
            eligible = append(eligible, l)
            total += l.Current // 门槛按“上一层折后金额”判断
        }
    }
    if len(eligible) == 0 {
        pc.skip(cp, "no_applicable_line")
        return nil
    }
    if total < cp.Threshold {
        pc.skip(cp, "below_threshold")
        return nil
    }

    bases := make([]Money, len(eligible))
    caps := make([]Money, len(eligible))
    var capacity Money
    for i, l := range eligible {
        bases[i] = l.Current // 分摊基数:作用时的行金额,而非原价
        if room := l.Current - pc.MinPayPerLine; room > 0 {
            caps[i] = room // 一分钱兜底:每行至少保留 MinPayPerLine
            capacity += room
        }
    }

    actual := min(cp.Face, capacity) // 面额大于可抵扣空间时截断,以实际抵扣额为准
    if actual <= 0 {
        pc.skip(cp, "no_discount_room")
        return nil
    }

    shares, err := Allocate(actual, bases, caps)
    if err != nil {
        return fmt.Errorf("coupon %d: %w", cp.ID, err)
    }
    for i, l := range eligible {
        if shares[i] == 0 {
            continue
        }
        l.Current -= shares[i]
        pc.Details = append(pc.Details, Detail{
            OrderID: pc.OrderID, LineID: l.LineID, Stage: stage, CouponID: cp.ID,
            Funding: funding, Amount: shares[i], RuleVersion: pc.RuleVersion,
        })
    }
    return nil
}

// ---------- ④ 兜底校验 ----------

// FloorGuardHandler 只做断言,不做修正:走到这里仍然违反约束,说明前序节点有缺陷,
// 静默修正会掩盖资损,必须中断并告警。
type FloorGuardHandler struct{}

func (FloorGuardHandler) Name() string { return "floor_guard" }

var ErrInvariant = errors.New("pricing: invariant violated")

func (FloorGuardHandler) Handle(_ context.Context, pc *Context) error {
    var origin, pay, discount Money
    for _, l := range pc.Lines {
        if l.Current < pc.MinPayPerLine {
            return fmt.Errorf("%w: line %d pay %d below floor %d", ErrInvariant, l.LineID, l.Current, pc.MinPayPerLine)
        }
        origin += l.Origin
        pay += l.Current
    }
    for _, d := range pc.Details {
        if d.Amount <= 0 {
            return fmt.Errorf("%w: non-positive detail on line %d", ErrInvariant, d.LineID)
        }
        discount += d.Amount
    }
    if origin-pay != discount {
        return fmt.Errorf("%w: origin(%d) - pay(%d) != discount(%d)", ErrInvariant, origin, pay, discount)
    }
    return nil
}

5. 折后分摊

5.1 为什么必须分摊到行

一张店铺券减 10 元,订单里有三件商品,这 10 元必须拆到每一行:

  • 部分退款:只退一件时,应退金额取决于该行实付。
  • 商家结算:店铺券由商家出资,平台券由平台出资,对账按行、按出资方归集。
  • 发票与税务:开票金额以行级实付为准。

5.2 分摊规则

项目 规则
分摊范围 仅限该券适用的行(店铺券限该店铺,且受商品范围约束)
分摊基数 作用时的行金额(商品折后价或店铺券后金额),不使用原价
取整 最大余数法,保证 Σ分摊额 = 券实际抵扣额
行上限 每行分摊额不超过 当前金额 − MinPayPerLine
落账 每行每张券一条 Detail,含出资方

为什么不用原价做基数:已经享受活动价的商品,如果按原价分摊,会被分到过多的券额,造成比例失真,也会使行金额逼近底线。

5.3 尾差处理

常见的“最后一行倒挤”有两个缺陷:结果依赖行顺序,且最后一行可能被挤到负数。本文使用最大余数法,并把行上限纳入计算:

  1. 按 discount × base_i / Σbase 向下取整得到初始份额;
  2. 触及行上限的行按上限封顶,未分出的金额在其余行中重新分摊,直至收敛;
  3. 取整丢掉的零头按余数从大到小逐个最小单位发放,余数相同按下标小者优先,结果确定。

乘法使用 128 位中间值(bits.Mul64 / bits.Div64),避免 discount × base 溢出 int64。调用方应按 LineID 固定行顺序,以保证并列余数时结果稳定。

package pricing

import (
    "errors"
    "math/bits"
    "sort"
)

var ErrExceedCapacity = errors.New("pricing: discount exceeds total capacity")

// Allocate 将 discount 按 bases 的比例分摊到各行,满足:
//  1. sum(result) == discount;
//  2. 0 <= result[i] <= caps[i](caps 为行可优惠上限,用于一分钱兜底);
//  3. 结果只依赖输入数值,取整尾差按“最大余数法”发放,余数相同按下标小者优先。
//
// 所有参数须为非负数;调用方应保证 discount <= sum(caps),否则返回 ErrExceedCapacity。
func Allocate(discount Money, bases, caps []Money) ([]Money, error) {
    n := len(bases)
    if n != len(caps) || discount < 0 {
        return nil, errors.New("pricing: invalid allocate input")
    }
    share := make([]Money, n)
    left := discount

    for left > 0 {
        // 仍有余量且有分摊权重的行参与本轮分摊。
        var sumBase uint64
        active := make([]int, 0, n)
        for i := 0; i < n; i++ {
            if share[i] < caps[i] && bases[i] > 0 {
                active = append(active, i)
                sumBase += uint64(bases[i])
            }
        }
        if len(active) == 0 {
            return nil, ErrExceedCapacity
        }

        type rem struct {
            idx int
            r   uint64
        }
        rems := make([]rem, 0, len(active))
        var given Money
        for _, i := range active {
            // left*base 可能超过 int64,使用 128 位乘除避免溢出。
            hi, lo := bits.Mul64(uint64(left), uint64(bases[i]))
            q, r := bits.Div64(hi, lo, sumBase)
            if room := caps[i] - share[i]; Money(q) > room {
                q = uint64(room) // 触顶,多出的部分留给下一轮重新分摊
            }
            share[i] += Money(q)
            given += Money(q)
            rems = append(rems, rem{i, r})
        }
        left -= given

        // 最大余数法:把取整丢掉的零头按余数从大到小逐分发放。
        sort.SliceStable(rems, func(a, b int) bool { return rems[a].r > rems[b].r })
        for _, e := range rems {
            if left == 0 {
                break
            }
            if share[e.idx] < caps[e.idx] {
                share[e.idx]++
                left--
            }
        }
    }
    return share, nil
}

6. 一分钱兜底

6.1 目的

  • 支付渠道普遍不支持 0 元支付,0 元订单需要额外流程。
  • 行级实付为 0 会使部分退款的比例计算出现除零。
  • 活动价、多张券叠加可能被组合出 0 元单,属于典型的羊毛场景。

6.2 规则

层级 规则
行级 每行实付 ≥ MinPayPerLine(默认 1)
整单 订单实付 ≥ 行数 × MinPayPerLine
截断 券面额超过可抵扣空间时,抵扣额截断为空间上限,不报错
口径 对用户展示、对商家与平台结算均使用实际抵扣额,而不是券面额

6.3 落在责任链中的位置

兜底内嵌在每个券节点的计算里,而不是只在末端检查。原因是先作用的券会消耗可抵扣空间:若店铺券已把某行压到只剩 1 分,平台券在该行就没有空间,应自然被截断或跳过。

实现上体现在 applyCoupon:

  • caps[i] = Current − MinPayPerLine 作为行上限;
  • actual = min(面额, Σcaps) 作为实际抵扣额;
  • 末端的 FloorGuardHandler 只做断言,不做修正。走到这里仍违反约束说明前序节点存在缺陷,静默修正会掩盖资损,应中断下单并告警。

6.4 被截断时的资金归属

场景 处理
店铺券先作用 先占用空间,商家按实际抵扣额出资
平台券后作用 空间不足时被截断,平台按实际抵扣额出资
希望优先保证平台券 调整链中节点顺序即可,但这会改变商家承担的金额,需经业务确认并升级规则版本

7. 不变量与对账

订单落库前必须自检,任一条不成立则拒绝落库并告警:

I1  Σ行实付 = 订单实付
I2  原价合计 − 实付 = Σ所有 Detail.Amount
I3  每行实付 ≥ MinPayPerLine
I4  每条 Detail.Amount > 0
I5  每张券的 Detail 之和 ≤ 券面额,且等于该券实际抵扣额

FloorGuardHandler 覆盖 I2–I4;I1 由汇总逻辑天然保证,I5 建议在落库事务中补充校验。

此外,建议每日离线对账:按订单重放计价链(使用订单记录的规则版本),与落库明细逐项比对,差异即告警。

8. 试算与下单的一致性

要求 做法
同一份代码 购物车、结算页、创建订单、支付前复核调用同一个 Chain
同一份规则 订单记录 RuleVersion,复核时使用同一版本,变更时提示用户重新确认价格
结果固化 下单时把 Detail 与行级实付落库,支付回调与售后不再重算
输入快照 保存试算时的券、活动、商品价格快照,用于纠纷复盘

9. 券的状态与并发

9.1 状态机

UNUSED ──锁定──▶ LOCKED ──支付成功──▶ USED
   ▲               │                     │
   └──超时/取消────┘                     └──退款且规则允许──▶ REFUNDED / UNUSED

9.2 并发与幂等

  • 锁券使用 CAS:UPDATE coupon_user SET status='LOCKED', order_id=? WHERE id=? AND status='UNUSED',影响行数为 0 即失败。
  • 幂等键:(coupon_id, order_id, action) 建唯一索引,锁券、核销、退券均可安全重试。
  • 事务边界:创建订单、落明细、锁券应在同一本地事务内完成;跨服务场景使用本地消息表或 TCC,并配套超时释放任务。
  • 释放补偿:订单取消或支付超时触发退券,退券同样按幂等键处理。

10. 退款的反向漏斗

退款以 Detail 为依据原路回退,不重新计价。

场景 处理
整单退 退行级实付之和;券退回或作废由产品规则决定
整行退 退该行实付;出资方回退额来自该行各 Detail
行内部分数量退 按“累计应退 − 已退”计算,保证多次退款之和恰好等于行实付
退后不再满足券门槛 是否追回券优惠,由产品规则决定,必须在设计期确定
package pricing

// RefundAmount 计算某订单行本次应退金额。
// linePay 为行级实付,lineQty 为购买数量,refundedQty/refundedAmt 为此前累计已退数量与金额,
// nowQty 为本次退货数量。
// 采用“累计应退 − 已退”的方式,保证多次部分退款之和恰好等于行实付,不丢分、不多退。
func RefundAmount(linePay Money, lineQty, refundedQty, refundedAmt, nowQty int64) Money {
    totalQty := refundedQty + nowQty
    if totalQty >= lineQty {
        return linePay - refundedAmt // 最后一次退完,余数全部退出
    }
    cumulative := linePay * totalQty / lineQty // 如担心溢出,可换 math/big 或 bits.Mul64
    return cumulative - refundedAmt
}

11. 测试策略

计价逻辑是资金逻辑,测试应覆盖三个层次。

层次 内容
单元测试 分摊的尾差、触顶、超限;各节点的门槛、范围、跳过原因
集成测试 完整链路的金额、明细、出资方归集;兜底场景下平台券被跳过
模糊 / 属性测试 随机生成订单与券,断言第 7 节的不变量恒成立
package pricing

import (
    "context"
    "testing"
)

func sum(xs []Money) (s Money) {
    for _, x := range xs {
        s += x
    }
    return
}

func TestAllocateLargestRemainder(t *testing.T) {
    got, err := Allocate(10, []Money{1, 1, 1}, []Money{100, 100, 100})
    if err != nil || sum(got) != 10 {
        t.Fatalf("got %v err %v", got, err)
    }
    if got[0] != 4 || got[1] != 3 || got[2] != 3 {
        t.Fatalf("unexpected %v", got)
    }
}

func TestAllocateRespectsCaps(t *testing.T) {
    got, err := Allocate(100, []Money{50, 50}, []Money{10, 200})
    if err != nil || got[0] != 10 || got[1] != 90 {
        t.Fatalf("got %v err %v", got, err)
    }
}

func TestAllocateExceed(t *testing.T) {
    if _, err := Allocate(100, []Money{1, 1}, []Money{10, 10}); err != ErrExceedCapacity {
        t.Fatalf("want ErrExceedCapacity, got %v", err)
    }
}

func FuzzAllocate(f *testing.F) {
    f.Add(int64(1000), int64(300), int64(700), int64(1<<40))
    f.Fuzz(func(t *testing.T, d, b1, b2, c int64) {
        if d < 0 || b1 <= 0 || b2 <= 0 || c < 0 || d > 1<<50 || b1 > 1<<50 || b2 > 1<<50 || c > 1<<50 {
            t.Skip()
        }
        caps := []Money{c, 1 << 51}
        got, err := Allocate(d, []Money{b1, b2}, caps)
        if err != nil {
            t.Fatal(err)
        }
        if sum(got) != d || got[0] > caps[0] || got[0] < 0 || got[1] < 0 {
            t.Fatalf("broken: %v", got)
        }
    })
}

func TestChainEndToEnd(t *testing.T) {
    pc := &Context{
        OrderID: "O1", RuleVersion: "v1", MinPayPerLine: 1,
        Lines: []*Line{
            {LineID: 1, SkuID: 1, ShopID: 10, Qty: 1, UnitPrice: 6000, PromoUnitPrice: 5000},
            {LineID: 2, SkuID: 2, ShopID: 10, Qty: 2, UnitPrice: 3000},
            {LineID: 3, SkuID: 3, ShopID: 20, Qty: 1, UnitPrice: 1000},
        },
        ShopCoupons:    []*Coupon{{ID: 100, ShopID: 10, Threshold: 10000, Face: 1000}},
        PlatformCoupon: &Coupon{ID: 200, Threshold: 10000, Face: 1500},
    }
    if err := DefaultChain().Run(context.Background(), pc); err != nil {
        t.Fatal(err)
    }
    // 原价 6000+6000+1000=13000;活动减 1000;店铺券 1000;平台券 1500 → 实付 9500
    if got := pc.Payable(); got != 9500 {
        t.Fatalf("payable = %d", got)
    }
}

func TestFloorBoundsCoupons(t *testing.T) {
    pc := &Context{
        OrderID: "O2", MinPayPerLine: 1,
        Lines:          []*Line{{LineID: 1, SkuID: 1, ShopID: 1, Qty: 1, UnitPrice: 500}},
        ShopCoupons:    []*Coupon{{ID: 1, ShopID: 1, Threshold: 100, Face: 10000}},
        PlatformCoupon: &Coupon{ID: 2, Threshold: 0, Face: 10000},
    }
    if err := DefaultChain().Run(context.Background(), pc); err != nil {
        t.Fatal(err)
    }
    if pc.Payable() != 1 {
        t.Fatalf("payable = %d, want 1", pc.Payable())
    }
    if len(pc.Skipped) != 1 || pc.Skipped[0].CouponID != 2 {
        t.Fatalf("platform coupon should be skipped: %+v", pc.Skipped)
    }
}

func TestRefundSumsToPay(t *testing.T) {
    var refunded Money
    var qty int64
    for i := 0; i < 3; i++ {
        r := RefundAmount(1000, 3, qty, refunded, 1)
        refunded += r
        qty++
    }
    if refunded != 1000 {
        t.Fatalf("refunded = %d", refunded)
    }
}

运行方式:

go test ./...
go test -fuzz=FuzzAllocate -fuzztime=30s

12. 补充事项与风险清单

以下内容在基础方案之外,落地时需要与产品、财务、支付团队逐项确认。

12.1 规则层面

事项 建议
叠加与互斥 用规则表或独立的 ExclusionHandler 表达“同类券互斥、活动价不可叠券”等规则,不要散落在各券节点中
百分比折扣券 先在节点内换算为面额(向下取整并设上限)后复用 applyCoupon,取整方向须与财务对齐
其他抵扣项 运费券、积分、礼品卡、红包在漏斗中的位置需明确;建议以独立节点接入,并各自标注出资方
税费 含税价与不含税价的分摊口径要提前约定,尤其是跨币种、跨税率的订单
门槛口径 明确是否“含券后”计门槛;同一口径需在前端展示、后端计算、客服话术中一致
最优券推荐 店铺券与平台券存在先后依赖,组合较少时可枚举,较多时用剪枝搜索;推荐结果必须由同一条链验证后再展示

12.2 工程层面

事项 建议
可观测性 记录每个节点的入参摘要、耗时与 Skipped 原因,便于定位“为什么这张券不能用”
预算与库存 券的发放量、平台出资上限在核销时做原子扣减,防止超发
熔断开关 为每类优惠提供开关,出现资损可快速关闭节点而不发版
性能 分摊复杂度为 O(n log n),大订单(数百行)需关注循环内的内存分配
精度 分摊乘法使用 128 位中间值;退款示例中的 linePay * totalQty 在极端金额下也应改用同样方式
规则灰度 新规则先以影子模式运行,与线上结果比对无差异后再切换

12.3 业务风险

风险 说明
行级兜底吞噬优惠 被截断的券用户感知为“没减够”,前端必须展示实际抵扣额及原因
单价展示 行实付除以数量可能除不尽,展示单价仅供参考,结算以行级金额为准
兜底带来的账差 每行 1 分的底线金额由谁承担,需与财务明确(通常计入商家收入)
规则变更 券规则变更不得影响已下单订单,明细中的 RuleVersion 是纠纷处理的依据

13. 小结

关键词 解决的问题 核心做法
漏斗 多层优惠的顺序与门槛 固定顺序,逐层基于上一层结果,门槛按作用时金额判断
责任链 规则多变、需要扩展与复用 每层一个节点,各场景共用同一条链
折后分摊 退款、结算、对账需要行级明细 以作用时行金额为基数,最大余数法消除尾差,落明细
一分钱兜底 防止 0 元单与退款除零 券节点内按行预留底线,实际抵扣额截断,末端只做断言

价格系统的底线是可解释、可复算、可对账。漏斗定义金额如何流动,责任链让规则易于扩展,分摊把每一分钱落到行与出资方,兜底守住最后的边界。

此博客中的热门博文

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...

PyTorch学习路线图

  第一阶段:基础知识(2-3周) 第1周:Python与机器学习基础 Python数据结构与NumPy基础 张量概念与线性代数基础 机器学习基本概念 第2-3周:PyTorch核心 张量操作与计算图 自动微分(autograd)机制 数据加载与预处理(DataLoader, Dataset) 构建神经网络模块(nn.Module) 第二阶段:深度学习基础(4-6周) 第4周:线性模型与优化器 线性回归与逻辑回归实现 优化器(SGD, Adam等) 损失函数与评估指标 第5-6周:基础神经网络 多层感知机(MLP) 卷积神经网络(CNN) 循环神经网络(RNN, LSTM, GRU) 第7-8周:训练技巧 模型保存与加载 学习率调度 正则化技术 迁移学习 第三阶段:进阶应用(6-8周) 第9-10周:计算机视觉 图像分类 目标检测 图像分割 视觉Transformer 第11-12周:自然语言处理 词嵌入 序列到序列模型 Transformer架构 预训练语言模型应用 第13-14周:生成模型 自编码器 变分自编码器(VAE) 生成对抗网络(GAN) 扩散模型 第四阶段:工程实践(4-6周) 第15-16周:模型部署 模型量化与优化 TorchScript与ONNX导出 服务化部署 移动端部署 第17-18周:高级训练技术 分布式训练 混合精度训练 梯度累积与梯度裁剪 模型剪枝与蒸馏 第19-20周:项目实战 完整项目流程 模型性能优化 工业级代码实践

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...