| 项目 | 内容 |
|---|---|
| 文档类型 | 技术设计文档 |
| 适用范围 | 购物车 / 结算页试算、订单创建、支付前复核、退款与结算 |
| 示例语言 | Go 1.22(示例代码已通过单元测试与模糊测试) |
1. 背景与目标
价格计算是电商交易链路中最容易产生资损的环节。一笔订单可能同时包含活动价、店铺券、平台券,用户只关心最终支付金额,但系统必须回答另外三个问题:
- 每一分优惠由谁出资:商家、平台还是活动预算,这决定了结算。
- 每个订单行实际支付多少:这决定了部分退款、开票和售后。
- 同样的输入是否永远得到同样的结果:这决定了试算、下单、支付复核、对账能否互相印证。
本文给出一套以 价格漏斗 定义金额流向、以 责任链 组织计算节点、以 折后分摊 落实行级明细、以 一分钱兜底 守住金额下限的设计方案。
设计目标
| 编号 | 目标 | 说明 |
|---|---|---|
| G1 | 可解释 | 任意一笔订单都能说清每层优惠减了多少、为什么 |
| G2 | 可复算 | 给定输入与规则版本,结果唯一且确定 |
| G3 | 可对账 | 明细求和等于总额,出资方可归集 |
| G4 | 可扩展 | 新增优惠类型不修改既有节点 |
| G5 | 防资损 | 任何组合都不会产生 0 元单或负数金额 |
非目标:本文不涉及券的发放、营销预算投放与风控模型,只讨论下单环节的计价、分摊与核销一致性。
2. 术语与约定
| 术语 | 含义 |
|---|---|
| 订单行(Line) | 订单中 SKU 维度的一条记录,分摊、退款的最小单位 |
| 商品级折扣 | 活动价、秒杀价、会员价等直接作用于商品单价的优惠 |
| 店铺券 | 商家出资,仅适用于该店铺商品 |
| 平台券 | 平台出资,可跨店铺适用 |
| 分摊基数 | 某张券作用时,各适用行的当前金额,用于按比例拆分券额 |
| 行级兜底 | 每个订单行实付不低于 MinPayPerLine(默认 1 个最小货币单位) |
全局约定
- 金额一律使用
int64,单位为最小货币单位(如“分”)。禁止float64;不同币种的最小单位由币种配置决定,不得在代码中硬编码 100。 - 漏斗顺序固定:商品级折扣 → 店铺券 → 平台券 → 兜底校验。顺序属于业务规则,需写入产品文档与规则版本,不得依赖调用顺序隐含。
- 门槛按作用时的金额判断:店铺券判断商品折后金额,平台券判断店铺券后金额。此口径必须与产品确认并固化。
- 计算一次,落账固化,后续只读:结算、退款、对账读取明细表,不重新计价。
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 尾差处理
常见的“最后一行倒挤”有两个缺陷:结果依赖行顺序,且最后一行可能被挤到负数。本文使用最大余数法,并把行上限纳入计算:
- 按
discount × base_i / Σbase向下取整得到初始份额; - 触及行上限的行按上限封顶,未分出的金额在其余行中重新分摊,直至收敛;
- 取整丢掉的零头按余数从大到小逐个最小单位发放,余数相同按下标小者优先,结果确定。
乘法使用 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 元单与退款除零 | 券节点内按行预留底线,实际抵扣额截断,末端只做断言 |
价格系统的底线是可解释、可复算、可对账。漏斗定义金额如何流动,责任链让规则易于扩展,分摊把每一分钱落到行与出资方,兜底守住最后的边界。