《互联网产品的服务端架构·交易、增长与平台化》3.1 支付渠道接入

从「每接一个渠道就复制一遍代码」的支付接入写起:支付宝、微信与 Apple Pay 在币种、限额和异步通知上的差异,决定了必须有一张统一支付单。文章给出 Request 校验、币种矩阵与失败场景,并说明为什么只存渠道单号会让换渠道后的对账无法关联。

从「每接一个渠道就复制一遍代码」说起

「摊位」是一个面向海外华人小店的下单产品,客单价不高,支付方式却很杂:国内用户用支付宝和微信,海外用户用 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 是简化假设,真实接入以签约币种为准。
  • 忽略异步通知的时延。渠道返回「处理中」不等于成功,状态推进必须以回调或查单结果为准。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查