一笔“下单成功、付款却按旧价”的订单
「拾集」是一个为本书构造的假想社区电商产品。用户在小程序里浏览商品、下单、支付,商家在后台履约发货。后文出现的订单量、比例、耗时与成本,除运行输出外都是案例假设值,用来演示判断过程,不是任何真实系统的实测。
一位用户买了三件商品:两盒咖啡豆,单价 69.90 元;一只马克杯,49.00 元;还有一件赠品,0 元。下单页显示应付 188.80 元,用户确认后订单创建成功,状态是 created。十分钟后他回到支付页,页面仍显示 188.80 元;支付成功后,商家后台看到的应收却变成了 208.80 元。
原因不复杂:下单时拿的是活动价,支付页拿的是商品当前价。中间运营结束了一场满减活动,价格回涨,而订单里只存了 SKU 和数量,没有存下单那一刻的成交价。支付链路重新读了一次商品价,于是算出了另一个数。
客诉的焦点不是二十块钱,而是“我到底该付多少”。用户认为自己付的是确认页的价格,商家认为订单金额应该以支付时为准。两边都能自圆其说,因为系统没有定义一个唯一的金额来源。
下单、支付、履约经常被画成一条直线:用户点按钮,系统创建订单,跳转支付,支付成功就发货。真实链路至少有三个断点:下单要校验一堆外部条件,支付要等渠道异步回调,履约要等仓库和物流。每个断点都可能失败、重试或超时,把它们当成一个事务,任何一段抖动都会让整条链路卡住。
下单链路的五道校验
下单不是把购物车原样写进数据库。它要在有限的时间里确认五件事,并且尽量把便宜、确定的检查放在前面:
| 顺序 | 检查什么 | 依赖 | 失败时用户看到 | 为什么要放在这个位置 |
|---|---|---|---|---|
| 1 | 登录态、账号状态、是否被冻结 | 用户中心 | 请重新登录 / 账号不可用 | 最便宜,且决定后续能否归属用户 |
| 2 | SKU 是否存在、是否上架、是否限购 | 商品中心 | 商品已下架 / 超出限购 | 避免为不存在的商品占库存 |
| 3 | 可售库存是否足够、是否占用 | 库存服务 | 库存不足 | 有外部副作用,放在结构校验之后 |
| 4 | 读取价格快照并随订单落库 | 价格 / 营销服务 | 价格已变化,请确认 | 金额必须固定,不能留给支付时再算 |
| 5 | 设备、频次、地址、黑名单 | 风控服务 | 下单失败或转人工 | 最慢、最不确定,尽量后置 |
这张表是案例假设值,具体顺序要按各系统的延迟和失败率调整,但有一条原则不变:先把订单的金额固定下来,再让用户去支付。
价格快照至少包含四样东西:每条明细的成交单价、优惠分摊、整单应付金额、价格版本或活动 ID。只存一个总额不够,退款时要按明细退,对账时要解释每一分钱从哪里来。
支付和履约是两条独立链路。支付处理的是“钱有没有到”,输入是渠道回调,核心是幂等和金额校验;履约处理的是“货有没有出”,输入是仓库作业和物流单,核心是状态推进和异常补偿。支付成功不等于履约成功,履约失败也不等于自动退款。把两者塞进一个“订单完成”的概念里,会让失败时的责任边界变得模糊。
失败场景:支付前价格变了
「拾集」上线首月做了一次限时满减。活动在 20:00 结束,订单在支付时重新计算金额。下面是一组案例假设值:
- 下单到支付的平均间隔 4 分钟,P99 为 38 分钟。
- 活动结束后的 5 分钟内,有 1842 笔订单处于待支付状态。
- 其中 617 笔的支付页仍展示活动价,实际扣款却按回涨后的价格计算。
- 43 位用户发起客诉,8 笔直接退款,剩余差额靠人工补券平息。
- 当天对账出现 1.2 万元差额,财务无法判断是渠道手续费还是订单改价。
更麻烦的是库存。支付页改价后,部分用户放弃支付,但订单仍然占着库存;超时关闭任务要等 30 分钟才释放,导致活动商品在高峰期显示“售罄”,而实际库存被一批不会付款的订单锁住。
修复分四步,顺序不能反:
- 下单时把成交单价、优惠分摊和应付总额写入订单明细,支付链路只读订单金额,不再读商品价。
- 支付页与扣款使用同一个金额来源。价格变化必须作废原订单并让用户重建,不能静默改价。
- 支付回调校验实付金额与订单应付金额是否一致,不一致就拒绝入账并告警。
- 价格快照保留活动 ID 和版本号,客诉与对账时能还原“当时为什么是这个价”。
这套做法的代价是订单表变宽、下单写入变多,但它把“金额”从一个随时会变的外部状态变成了订单自身的属性。交易系统里,可解释比省几列字段重要得多。
Go 实现:Validate 与 Total
下面的代码来自本书配套的 webapp-lab。Validate 只做结构校验:ID、用户、明细、数量、单价是否合法。它不查库存,也不查商品是否上架,因为那些是下单编排层的职责,放进这里会让纯函数依赖外部系统。Order 里的 State 类型定义在同包的 state.go 中,下一节会展开。
金额一律用 int64 表示分。Total 在乘法和累加两处都检查溢出:单行金额可能越界,整单累加也可能越界。溢出时返回错误,而不是让结果回绕成一个负数或看似合理的小数。
// file: internal/order/order.go
// tier: L1
// verify: go test ./internal/order/
package order
import (
"fmt"
"math"
"strings"
)
// Item 是订单中的一条商品明细。PriceCents 使用 int64 表示的最小货币单位(分),
// 避免 float64 在累加时产生误差。
type Item struct {
SKU string
PriceCents int64
Qty int
}
// Order 是一张订单。State 定义在同包的 state.go 中。
type Order struct {
ID string
UserID string
Items []Item
State State
}
// Validate 检查一张订单是否具备进入支付链路的基本条件。
//
// 校验只做结构判断,不查库存也不查真实价格:库存属于下单链路的前置检查,
// 价格属于价格快照。把这两件事塞进 Validate 会让它依赖外部系统,
// 既不好测试,也无法说明失败原因。
func Validate(o Order) error {
if strings.TrimSpace(o.ID) == "" {
return fmt.Errorf("order: id must not be empty")
}
if strings.TrimSpace(o.UserID) == "" {
return fmt.Errorf("order: user id must not be empty")
}
if len(o.Items) == 0 {
return fmt.Errorf("order: order %q has no items", o.ID)
}
for i, it := range o.Items {
if strings.TrimSpace(it.SKU) == "" {
return fmt.Errorf("order: item %d has empty sku", i)
}
if it.Qty <= 0 {
return fmt.Errorf("order: item %d qty %d must be positive", i, it.Qty)
}
if it.PriceCents < 0 {
return fmt.Errorf("order: item %d price %d must not be negative", i, it.PriceCents)
}
}
return nil
}
// Total 返回订单应付金额,单位为分。
//
// 每一步都做 int64 溢出检查:单行金额乘法和整单累加都有可能越界。
// 溢出时返回错误而不是回绕成负数或一个看似合理的小数,因为回绕后的金额
// 可能让用户少付甚至免费,也会让对账永远对不上。
func Total(o Order) (int64, error) {
if len(o.Items) == 0 {
return 0, fmt.Errorf("order: order %q has no items", o.ID)
}
var sum int64
for i, it := range o.Items {
if it.Qty <= 0 {
return 0, fmt.Errorf("order: item %d qty %d must be positive", i, it.Qty)
}
if it.PriceCents < 0 {
return 0, fmt.Errorf("order: item %d price %d must not be negative", i, it.PriceCents)
}
if it.PriceCents > math.MaxInt64/int64(it.Qty) {
return 0, fmt.Errorf("order: item %d total overflows int64", i)
}
line := it.PriceCents * int64(it.Qty)
if sum > math.MaxInt64-line {
return 0, fmt.Errorf("order: order %q total overflows int64", o.ID)
}
sum += line
}
return sum, nil
}
代码里有三个刻意的选择。第一,空字符串和只有空白的 ID 都被拒绝,因为空白 ID 在日志和告警里看起来像“有值”,排查时最容易误导人。第二,单价允许为 0,赠品和试用装是真实存在的业务,不能把 0 元当成缺价。第三,Validate 和 Total 都会重复检查数量和单价,看起来啰嗦,但 Total 可能被独立调用,不能假设调用方一定先跑过 Validate。
测试:空明细、零价与溢出
测试把结构错误、合法边界和两类溢出都固定下来。尤其是累加溢出:单行金额不溢出,不代表整单不溢出,这条分支很容易在代码评审时被忽略。
// file: internal/order/order_test.go
// tier: L1
// verify: go test ./internal/order/
package order
import (
"math"
"strings"
"testing"
)
func TestValidate(t *testing.T) {
valid := Order{
ID: "o-1",
UserID: "u-1",
Items: []Item{{SKU: "sku-a", PriceCents: 1999, Qty: 2}},
}
if err := Validate(valid); err != nil {
t.Fatalf("valid order rejected: %v", err)
}
cases := []struct {
name string
o Order
want string
}{
{"empty-id", Order{UserID: "u-1", Items: []Item{{SKU: "a", Qty: 1}}}, "id must not be empty"},
{"blank-id", Order{ID: " ", UserID: "u-1", Items: []Item{{SKU: "a", Qty: 1}}}, "id must not be empty"},
{"empty-user", Order{ID: "o-1", Items: []Item{{SKU: "a", Qty: 1}}}, "user id must not be empty"},
{"no-items", Order{ID: "o-1", UserID: "u-1"}, "has no items"},
{"empty-sku", Order{ID: "o-1", UserID: "u-1", Items: []Item{{Qty: 1}}}, "empty sku"},
{"zero-qty", Order{ID: "o-1", UserID: "u-1", Items: []Item{{SKU: "a", PriceCents: 1}}}, "must be positive"},
{"negative-qty", Order{ID: "o-1", UserID: "u-1", Items: []Item{{SKU: "a", PriceCents: 1, Qty: -1}}}, "must be positive"},
{"negative-price", Order{ID: "o-1", UserID: "u-1", Items: []Item{{SKU: "a", PriceCents: -1, Qty: 1}}}, "must not be negative"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := Validate(c.o)
if err == nil {
t.Fatal("want error, got nil")
}
if !strings.Contains(err.Error(), c.want) {
t.Fatalf("error %q does not contain %q", err, c.want)
}
})
}
}
func TestValidateAllowsFreeItem(t *testing.T) {
o := Order{ID: "o-1", UserID: "u-1", Items: []Item{{SKU: "gift", PriceCents: 0, Qty: 1}}}
if err := Validate(o); err != nil {
t.Fatalf("zero-price item rejected: %v", err)
}
}
func TestTotal(t *testing.T) {
o := Order{
ID: "o-1",
UserID: "u-1",
Items: []Item{
{SKU: "a", PriceCents: 1999, Qty: 2},
{SKU: "b", PriceCents: 500, Qty: 1},
{SKU: "c", PriceCents: 0, Qty: 3},
},
}
got, err := Total(o)
if err != nil {
t.Fatal(err)
}
if want := int64(4498); got != want {
t.Fatalf("total = %d, want %d", got, want)
}
}
func TestTotalRejectsEmptyItems(t *testing.T) {
if _, err := Total(Order{ID: "o-1"}); err == nil {
t.Fatal("want error for empty items")
}
}
func TestTotalRejectsItemOverflow(t *testing.T) {
o := Order{ID: "o-1", UserID: "u-1", Items: []Item{{SKU: "a", PriceCents: math.MaxInt64, Qty: 2}}}
_, err := Total(o)
if err == nil {
t.Fatal("want overflow error")
}
if !strings.Contains(err.Error(), "overflows int64") {
t.Fatalf("error %q does not mention overflow", err)
}
}
func TestTotalRejectsSumOverflow(t *testing.T) {
half := int64(math.MaxInt64/2) + 1
o := Order{ID: "o-1", UserID: "u-1", Items: []Item{
{SKU: "a", PriceCents: half, Qty: 1},
{SKU: "b", PriceCents: half, Qty: 1},
}}
if _, err := Total(o); err == nil {
t.Fatal("want sum overflow error")
}
}
TestValidateAllowsFreeItem 明确了一个业务边界:0 元商品合法,负数价格不合法。TestTotalRejectsItemOverflow 和 TestTotalRejectsSumOverflow 分别覆盖乘法溢出与加法溢出。测试不关心错误文案的完整句子,只匹配关键片段,这样调整措辞时不会误伤,但删掉检查一定会失败。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/order/ -run 'TestValidate|TestTotal' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidate
=== RUN TestValidate/empty-id
=== RUN TestValidate/blank-id
=== RUN TestValidate/empty-user
=== RUN TestValidate/no-items
=== RUN TestValidate/empty-sku
=== RUN TestValidate/zero-qty
=== RUN TestValidate/negative-qty
=== RUN TestValidate/negative-price
--- PASS: TestValidate (0.00s)
--- PASS: TestValidate/empty-id (0.00s)
--- PASS: TestValidate/blank-id (0.00s)
--- PASS: TestValidate/empty-user (0.00s)
--- PASS: TestValidate/no-items (0.00s)
--- PASS: TestValidate/empty-sku (0.00s)
--- PASS: TestValidate/zero-qty (0.00s)
--- PASS: TestValidate/negative-qty (0.00s)
--- PASS: TestValidate/negative-price (0.00s)
=== RUN TestValidateAllowsFreeItem
--- PASS: TestValidateAllowsFreeItem (0.00s)
=== RUN TestTotal
--- PASS: TestTotal (0.00s)
=== RUN TestTotalRejectsEmptyItems
--- PASS: TestTotalRejectsEmptyItems (0.00s)
=== RUN TestTotalRejectsItemOverflow
--- PASS: TestTotalRejectsItemOverflow (0.00s)
=== RUN TestTotalRejectsSumOverflow
--- PASS: TestTotalRejectsSumOverflow (0.00s)
PASS
ok webapp/internal/order 0.261s
输出里所有用例通过,只说明当前实现与测试一致,不说明价格策略正确。金额是否该在下单时锁定、锁多久、活动结束怎么处理,仍然要靠业务规则和对账数据决定。
小结
下单、支付、履约是三条链路,不是一个事务。下单负责把用户、商品、库存、价格和风控收敛成一张金额确定的订单;支付负责确认钱到了,并且只认订单金额;履约负责把货发出去,失败时走补偿而不是回头改订单。
Validate 和 Total 做的事很小:拒绝结构错误的订单,用 int64 分算出不会溢出的总额。真正决定系统是否可信的,是订单里有没有一份不可变的价格快照,以及支付回调敢不敢拿实付金额和它比对。
阅读导航:上一节:目录与学习路径 · 下一节:2.2 订单状态机与超时关闭 。
常见错误与边界
- 支付时重新计算金额。商品价、活动价、优惠券任意一项变化,用户实付就会和确认页不一致。
- 只存订单总额,不存明细单价和优惠分摊。退款只能拍脑袋按比例拆,对账永远差几分钱。
- 用浮点数表示金额。
0.1 + 0.2不等于0.3,累加几千行后差额会被财务发现。 - 把库存校验放进纯结构校验函数。函数一旦依赖库存服务,单测要起 mock,失败原因也混在一起。
- 认为支付成功就等于订单完成。支付回调只证明钱到了,发货、签收、退款都还没发生。
- 让超时关闭任务无条件关单。它必须带状态条件,否则会把刚支付的订单一起关掉。
- 价格快照不记版本。客诉时说不清“当时是哪个活动”,也无法判断是配置错还是代码错。
- 把赠品单价 0 元当成缺价拒绝。合法的 0 元和漏填的价格必须靠字段是否存在区分,而不是靠数值。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。