从「每接一个渠道就复制一遍代码」说起
「摊位」是一个面向海外华人小店的下单产品,客单价不高,支付方式却很杂:国内用户用支付宝和微信,海外用户用 Apple Pay。第一版接入支付宝时,团队在订单表里加了一个 alipay_trade_no 字段,回调来了就写进去。三个月后接微信,又加了 wechat_trade_no;再后来接 Apple Pay,字段名变成了 apple_trade_no。订单表越来越宽,支付相关代码却始终没有一张统一的单。
问题在第一次换渠道促销时暴露:运营要求同一笔订单在支付宝失败后可以改用微信重试。开发发现,旧代码把「支付单」和「渠道单」当成同一个东西,订单状态直接跟着某个渠道字段走。换渠道意味着换字段、换回调解析、换对账文件,任何一处漏改都会让订单「付了但没记上」。
统一支付单与渠道单
支付接入的第一层抽象不是「支持哪些渠道」,而是把资金对象拆成几张单。
| 对象 | 主键 | 关键字段 | 生命周期 | 谁来写 |
|---|---|---|---|---|
| 统一支付单 | 内部支付单号 | 订单号、应付金额、币种、状态 | 创建后跨渠道复用 | 业务服务 |
| 渠道单 | 渠道 + 渠道流水号 | 支付单号、渠道、实付金额、渠道状态 | 每次发起支付生成一条 | 渠道回调或查单 |
| 退款单 | 内部退款单号 | 原支付流水号、退款金额、状态 | 每笔退款生成一条 | 业务服务 |
| 对账记录 | 渠道 + 渠道流水号 | 金额、对账日期、差异类型 | 按日生成 | 对账任务 |
统一支付单回答「这笔订单要收多少钱」,渠道单回答「这笔钱实际从哪个渠道、以什么流水号收进来」。两者是一对多:一次支付失败后换渠道重试,会产生多条渠道单,但统一支付单只有一条,最终只有一条渠道单能把它推进到「已支付」。
渠道差异要收敛成配置,不要散落在 if 里
支付宝、微信和 Apple Pay 的差异主要在三个地方:币种、限额、异步通知。
币种方面,本案例假设支付宝与微信只收人民币,Apple Pay 收美元和人民币。限额方面,不同渠道、不同商户等级的单笔和单日上限都不同,而且会变。通知方面,支付宝和微信以服务端异步通知为准,Apple Pay 的确认更依赖客户端提交和服务端校验收据。这些差异如果写成散落的 if channel == ...,新增渠道时很难找全。
更稳的做法是先定义能力矩阵:渠道支持哪些币种、单笔上下限、是否需要异步通知、退款是否原路。代码里只保留一个 Supports 这样的纯函数,其余差异放在配置或渠道适配器里。校验层只回答「这个渠道能不能收这笔钱」,不承担「这笔钱有没有收到」。
金额只用整数分
支付金额不要用浮点数。0.1 + 0.2 在二进制浮点里不等于 0.3,对账时一分钱的差异足以让财务退回整批数据。本案例统一用 int64 分:9900 表示 99 元,1 表示 1 分。渠道要求元时,在适配器边界做一次除法并明确余数处理,业务层永远只看见分。
币种也要在入口校验。三位大写字母是最低要求,cny、CN、C1Y 都应该被拒绝,否则日志里会出现同一币种的多种写法,聚合和对账都会出错。
失败场景:只存渠道单号,换渠道后对不上账
某次大促,支付宝通道拥堵,大量订单支付失败后引导用户改用微信。开发在订单表里同时写了 alipay_trade_no 和 wechat_trade_no,但「已支付」判断仍然只看 alipay_trade_no 是否非空。结果微信支付成功的订单在业务侧仍是「待支付」,用户被重复提示付款;客服手动改状态后,财务对账又发现这些订单在微信账单里有流水,本地却没有对应的统一支付单。
更麻烦的是退款。退款接口只接受渠道流水号,而客服系统展示的是订单号。开发按订单号反查渠道流水号时,一条订单可能对应多条渠道单:支付宝失败的那条、微信成功的那条。没有统一支付单做锚点,反查只能靠金额和时间猜,猜错就会把退款打到一条失败的渠道单上,渠道直接拒绝,退款单却已经标记为「处理中」。
这个事故的根因不是字段不够多,而是缺少「一笔支付」的稳定身份。渠道流水号是渠道给的,换渠道就换号;订单号是业务给的,但一笔订单可能多次支付。只有内部支付单号能在两者之间做稳定的桥。
用 Validate 和 Supports 收敛渠道规则
channel.go 只做两件事:判断渠道是否支持某币种,以及校验发起支付的参数。它不碰订单状态,也不发起网络请求。
// file: internal/payment/channel.go
// tier: L1
// verify: go test ./internal/payment/
package payment
import (
"errors"
"fmt"
)
// Channel 是支付渠道的稳定标识。字符串值会写进订单备注、审计日志和
// 对账文件,不要用中文名或展示名做键。
type Channel string
const (
Alipay Channel = "alipay"
Wechat Channel = "wechat"
ApplePay Channel = "applepay"
)
var (
// ErrUnknownChannel 表示渠道不在已接入清单内。新增渠道必须先登记,
// 否则后面的币种、签名和账单适配都无从选择。
ErrUnknownChannel = errors.New("payment: unknown channel")
// ErrInvalidAmount 表示金额不是正整数。金额单位是分,0 元和负数都
// 不是合法支付请求。
ErrInvalidAmount = errors.New("payment: amount must be positive")
// ErrInvalidCurrency 表示币种不是三位大写字母。
ErrInvalidCurrency = errors.New("payment: currency must be three uppercase letters")
// ErrUnsupportedCurrency 表示渠道不接收该币种。
ErrUnsupportedCurrency = errors.New("payment: currency not supported by channel")
)
// Request 是发起支付时提交给渠道的最小请求。AmountCents 以分为单位,
// 用整数避免浮点误差;Currency 使用 ISO 4217 的三位大写代码。
type Request struct {
OrderID string
AmountCents int64
Currency string
}
// Supports 判断渠道是否支持某币种。本案例假设支付宝和微信只收人民币,
// Apple Pay 支持美元和人民币。真实接入应以渠道文档和商户签约币种为准,
// 不能把这里的假设直接带上线。
func Supports(c Channel, currency string) bool {
switch c {
case Alipay, Wechat:
return currency == "CNY"
case ApplePay:
return currency == "USD" || currency == "CNY"
default:
return false
}
}
// Validate 校验渠道与请求是否匹配。它只做本地参数校验,不发起任何渠道
// 调用,也不检查订单是否已经支付;那些属于回调和对账层。
func Validate(c Channel, r Request) error {
switch c {
case Alipay, Wechat, ApplePay:
default:
return fmt.Errorf("%w: %q", ErrUnknownChannel, c)
}
if r.AmountCents <= 0 {
return fmt.Errorf("%w: %d", ErrInvalidAmount, r.AmountCents)
}
if !isUpperCurrency(r.Currency) {
return fmt.Errorf("%w: %q", ErrInvalidCurrency, r.Currency)
}
if !Supports(c, r.Currency) {
return fmt.Errorf("%w: %s %s", ErrUnsupportedCurrency, c, r.Currency)
}
return nil
}
// isUpperCurrency 只接受三个 A-Z 字符。长度正确但含数字或小写的代码
// 同样拒绝,避免 "cny" 和 "CNY" 在日志里被当成两种币种。
func isUpperCurrency(s string) bool {
if len(s) != 3 {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < 'A' || s[i] > 'Z' {
return false
}
}
return true
}
几个设计点:
Channel用字符串常量而不是整数枚举。日志、数据库和对账文件里都能直接读懂,新增渠道也不需要维护数字映射。Supports是纯函数,返回布尔值,方便在配置加载、前端展示和服务端校验三处复用。本案例对 Apple Pay 的币种假设写在注释里,真实接入必须替换成渠道文档和商户签约结果。Validate先判渠道再判金额和币种。未知渠道优先报错,避免用一个不存在的渠道去解释后面的参数错误。- 币种校验不依赖
strings.ToUpper,而是逐字节检查 A-Z。这样c1y和CNY不会被当成同一个值。 - 金额只接受正整数。0 元支付在渠道侧通常也会被拒绝,本地提前拦截能减少无效请求和告警。
需要强调的是,Validate 通过不代表支付能成功。它只保证请求形状正确,渠道限额、风控、余额和网络状态都可能在后面失败。统一支付单要在调用渠道之前创建,并用「处理中」状态占位;渠道单要在拿到渠道响应后落库,两者用内部支付单号关联。
测试:币种矩阵与参数边界
渠道规则最容易写错的是「哪个渠道支持哪个币种」,以及币种字符串的大小写和长度。测试把这两类边界固定下来。
// file: internal/payment/channel_test.go
// tier: L1
// verify: go test ./internal/payment/
package payment
import (
"errors"
"testing"
)
func TestValidateAcceptsKnownChannelAndCurrency(t *testing.T) {
cases := []struct {
name string
c Channel
req Request
}{
{"alipay cny", Alipay, Request{OrderID: "o1", AmountCents: 1, Currency: "CNY"}},
{"wechat cny", Wechat, Request{OrderID: "o2", AmountCents: 9900, Currency: "CNY"}},
{"applepay usd", ApplePay, Request{OrderID: "o3", AmountCents: 499, Currency: "USD"}},
{"applepay cny", ApplePay, Request{OrderID: "o4", AmountCents: 1200, Currency: "CNY"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := Validate(tc.c, tc.req); err != nil {
t.Fatalf("Validate(%s, %+v) = %v; want nil", tc.c, tc.req, err)
}
})
}
}
func TestValidateRejectsUnknownChannel(t *testing.T) {
err := Validate(Channel("paypal"), Request{OrderID: "o1", AmountCents: 100, Currency: "USD"})
if !errors.Is(err, ErrUnknownChannel) {
t.Fatalf("err = %v; want ErrUnknownChannel", err)
}
}
func TestValidateRejectsNonPositiveAmount(t *testing.T) {
for _, amount := range []int64{0, -1, -9900} {
err := Validate(Alipay, Request{OrderID: "o1", AmountCents: amount, Currency: "CNY"})
if !errors.Is(err, ErrInvalidAmount) {
t.Fatalf("amount %d err = %v; want ErrInvalidAmount", amount, err)
}
}
}
func TestValidateRejectsBadCurrency(t *testing.T) {
for _, currency := range []string{"", "CN", "CNYY", "cny", "C1Y", "人民币"} {
err := Validate(Alipay, Request{OrderID: "o1", AmountCents: 100, Currency: currency})
if !errors.Is(err, ErrInvalidCurrency) {
t.Fatalf("currency %q err = %v; want ErrInvalidCurrency", currency, err)
}
}
}
func TestValidateRejectsUnsupportedCurrency(t *testing.T) {
cases := []struct {
c Channel
currency string
}{
{Alipay, "USD"},
{Wechat, "USD"},
{ApplePay, "EUR"},
}
for _, tc := range cases {
err := Validate(tc.c, Request{OrderID: "o1", AmountCents: 100, Currency: tc.currency})
if !errors.Is(err, ErrUnsupportedCurrency) {
t.Fatalf("Validate(%s, %s) err = %v; want ErrUnsupportedCurrency", tc.c, tc.currency, err)
}
}
}
func TestSupportsMatrix(t *testing.T) {
cases := []struct {
c Channel
currency string
want bool
}{
{Alipay, "CNY", true},
{Alipay, "USD", false},
{Wechat, "CNY", true},
{Wechat, "USD", false},
{ApplePay, "USD", true},
{ApplePay, "CNY", true},
{ApplePay, "EUR", false},
{Channel("paypal"), "USD", false},
}
for _, tc := range cases {
if got := Supports(tc.c, tc.currency); got != tc.want {
t.Fatalf("Supports(%s, %s) = %v; want %v", tc.c, tc.currency, got, tc.want)
}
}
}
TestValidateAcceptsKnownChannelAndCurrency 覆盖三个渠道的合法组合,包括 Apple Pay 的 USD 和 CNY。TestValidateRejectsUnknownChannel 防止新增渠道时忘记登记。TestValidateRejectsNonPositiveAmount 用 0 和负数验证金额边界。
TestValidateRejectsBadCurrency 覆盖空串、长度不足、长度超出、小写、含数字和中文,确保校验不是只检查长度。TestValidateRejectsUnsupportedCurrency 锁定支付宝和微信不收美元、Apple Pay 不收欧元。TestSupportsMatrix 把 Supports 单独拿出来测,避免它和 Validate 的错误顺序耦合。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/payment/ -run 'TestValidate|TestSupports' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidateAcceptsKnownChannelAndCurrency
=== RUN TestValidateAcceptsKnownChannelAndCurrency/alipay_cny
=== RUN TestValidateAcceptsKnownChannelAndCurrency/wechat_cny
=== RUN TestValidateAcceptsKnownChannelAndCurrency/applepay_usd
=== RUN TestValidateAcceptsKnownChannelAndCurrency/applepay_cny
--- PASS: TestValidateAcceptsKnownChannelAndCurrency (0.00s)
--- PASS: TestValidateAcceptsKnownChannelAndCurrency/alipay_cny (0.00s)
--- PASS: TestValidateAcceptsKnownChannelAndCurrency/wechat_cny (0.00s)
--- PASS: TestValidateAcceptsKnownChannelAndCurrency/applepay_usd (0.00s)
--- PASS: TestValidateAcceptsKnownChannelAndCurrency/applepay_cny (0.00s)
=== RUN TestValidateRejectsUnknownChannel
--- PASS: TestValidateRejectsUnknownChannel (0.00s)
=== RUN TestValidateRejectsNonPositiveAmount
--- PASS: TestValidateRejectsNonPositiveAmount (0.00s)
=== RUN TestValidateRejectsBadCurrency
--- PASS: TestValidateRejectsBadCurrency (0.00s)
=== RUN TestValidateRejectsUnsupportedCurrency
--- PASS: TestValidateRejectsUnsupportedCurrency (0.00s)
=== RUN TestSupportsMatrix
--- PASS: TestSupportsMatrix (0.00s)
PASS
ok webapp/internal/payment 0.324s
ok webapp/internal/payment 表示 3.1 相关的用例全部通过。这些用例只验证本地规则,不涉及真实渠道;渠道限额和签约币种仍要以渠道文档为准。
小结
支付接入先把对象拆对:统一支付单、渠道单、退款单、对账记录各司其职,渠道流水号只是渠道单的键,不能拿来当支付的身份。渠道差异收敛到能力矩阵和适配器,金额统一用 int64 分,币种在入口严格校验。
Validate 和 Supports 是这条链路最前面的一层,它们不保证支付成功,但能挡住大部分形状错误的请求。下一节处理支付真正落地的那一步:渠道回调来了,如何验、如何幂等、掉单了怎么办。
阅读导航:上一节:目录与学习路径 · 下一节:3.2 回调、幂等与掉单 。
常见错误与边界
- 把渠道流水号当支付单主键。换渠道后同一笔订单会有多个流水号,业务状态无法稳定关联。
- 订单表按渠道加字段。每接一个渠道加一列,查询和退款反查都会越来越难,最后没人说得清哪列才是真相。
- 金额用浮点或元。二分钱误差在对账时会被放大成整批差异,统一用
int64分。 - 币种只检查长度。
cny、C1Y都能通过长度检查,必须在入口统一成三位大写。 - 把渠道限额写死在代码里。限额会随商户等级和活动变化,应该配置化并保留渠道侧拒绝的兜底。
- 认为
Validate通过就能支付成功。风控、余额、网络和渠道维护都可能导致失败,统一支付单要先占位。 - Apple Pay 币种假设直接上线。本案例的 USD 和 CNY 是简化假设,真实接入以签约币种为准。
- 忽略异步通知的时延。渠道返回「处理中」不等于成功,状态推进必须以回调或查单结果为准。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。