一、为什么需要一个”支付网关”
在早期小规模业务里,支付接入常常是”一路通吃”:业务系统直接调用支付宝
SDK、微信 SDK,把
out_trade_no、金额、回调地址拼进去,发出请求即可。这种做法在通道数量≤2、交易量不高、商户结构简单的时候完全够用。
但只要业务稍微长大一点,问题就开始冒出来:
- 新增一个通道(比如接入 Visa、Stripe、Airwallex、银联国际、或一个国内的聚合通道 Ping++/连连),所有下单、退款、查单、回调的代码都要重新写一遍。
- 某个通道突发故障(2024 年微信支付曾出现过分钟级抖动、Visa 在 2018 年欧洲区有过近 10 小时不可用),业务层没有能力做快速切换。
- 商户数量上涨后,出现”A 商户额度满了、B 通道 KYC 还没通过、C 商户要走某个特定费率包”等各种组合需求,if-else 已经无法维护。
- 风控团队想在某些金额档位上加白名单、黑名单、黑卡库拦截,但没地方挂。
- 对账团队抱怨:通道订单号、业务订单号、用户订单号各走各的,跨通道对账几乎做不了。
这些问题的共同特征是:它们都是”通道无关”的横切关注点,堆在业务系统里就会反复污染业务代码。把它们抽出来,就是支付网关(Payment Gateway)。
本文的读者画像:已经读过本系列第 06 篇《支付系统全景》与第 08 篇《支付宝、微信支付接入》,对主流通道 API 不陌生;正在或即将承担一个面向 2~10 个通道、日均数十万到数千万笔交易的网关工程。下文不追求穷举所有特性,而是围绕”要上线一个能活过 2~3 年业务增长”的网关,给出骨架、数据模型、状态机与代码片段。
二、网关在支付架构中的定位
2.1 分层视图
一张自上而下的分层图,大部分中等规模公司的支付栈都可以抽象成这样:
flowchart TB
subgraph Biz[业务域]
Order[订单/营销/会员]
Merchant[商户平台]
Subs[订阅/计费]
end
subgraph GW[支付网关]
API[统一接入 API]
Route[路由/通道选型]
Limit[限流/熔断]
Sign[签名/验签/HSM]
Notify[异步通知分发]
Recon[补单/查单 Worker]
end
subgraph Ch[通道适配层]
Ali[支付宝适配器]
WX[微信适配器]
UP[银联适配器]
V[Visa/MC 适配器]
Str[Stripe/Airwallex]
end
subgraph Infra[底层基础设施]
Ledger[账务/清结算]
Risk[风控引擎]
KMS[KMS/HSM]
Obs[监控/链路追踪]
end
Biz --> API
API --> Route
Route --> Limit
Limit --> Sign
Sign --> Ch
Ch --> Notify
Notify --> Biz
Recon -.-> Ch
Recon -.-> Ledger
Route -.-> Risk
Sign -.-> KMS
GW -.-> Obs
几个要点:
- 网关不是”代理”:它不是一个只做协议转换的薄层,而是持有业务单号、通道订单号、状态机、异步通知分发队列的一个有状态系统。
- 风控在网关前后各拦一次:进入通道前拦截明显异常(金额、频次、黑名单),通道返回后根据风险分再决定是否放行入账,这部分留到第 19 篇《实时风控引擎》。
- 网关不做账:它只负责”把钱成功地从用户侧移动到我方收单账户”,并把事件丢给账务域。真正的记账、清算在第 03 篇《复式记账》与第 11 篇《清算结算》里。
2.2 边界再明确一点
网关负责:
- 统一对外接入协议(HTTPS + JSON + 签名)。
- 内部业务订单(payments)与通道订单(channel_orders)的建立与状态机推进。
- 通道路由与灰度。
- 签名/验签、密钥管理、防重放。
- 异步通知的接收、验证、分发给业务方。
- 超时未决补单(主动查询)、异常重试、对账差错标记。
- 可观测性:通道成功率、耗时、单量、错误码分布。
网关不负责:
- 商户入驻、资质审核(商户平台)。
- 账务科目分录、余额计算(账务系统)。
- 反洗钱规则库、黑名单源(风控平台)。
- 发票、账单、订阅计费(见第 10 篇)。
三、核心能力矩阵
下面逐项展开。
3.1 路由与通道选型
一个生产级路由器至少要综合以下维度:
| 维度 | 示例 | 来源 |
|---|---|---|
| 币种 | CNY 只能走国内通道,USD/EUR 走国际通道 | 订单参数 |
| 地域 | 用户 IP / BIN / billing country | 订单 + BIN 查询 |
| 金额区间 | 小额走 A,大额走 B(费率阶梯) | 通道费率表 |
| 产品类型 | 线上扫码 vs APP 支付 vs 银行卡 | 订单 trade_type |
| 商户配置 | 白名单通道、禁用通道、费率包 | 商户平台 |
| 通道健康度 | 最近 5 分钟成功率、平均耗时 | 实时监控 |
| 成本 | 通道费率、分账后净成本 | 费率管理 |
| 限额 | 商户在通道的日/月限额 | 通道配额 |
路由器的输出是一个 通道链(Channel Chain):主通道 + 备选通道列表。主通道失败到某一等级(如 5xx、超时、风控拒绝)时,按策略切到下一个。这里”失败”的判定必须非常保守——明确的业务拒绝(余额不足、卡被冻结)不能降级,否则会把同一笔钱重复路由到多个通道,造成重复扣款。
3.2 限流与熔断
限流有三个层级:
- 全局限流:保护网关自身(例如 20k QPS)。
- 通道级限流:保护下游通道(例如微信支付的商户级
QPS、银联核心系统的通道级限额)。部分通道会在 API 网关层返回
FREQUENCY_LIMITED,不限流则会被通道封禁。 - 商户级限流:防止某个商户的刷单流量把整个网关占满。
熔断(Circuit Breaker)则更关注”快速失败”:
- 半开态恢复:熔断触发后每 N 秒放 1 个探测请求,成功率回到阈值以上才恢复。
- 按错误码熔断:只对”通道故障”类错误计数,对业务拒绝(余额不足、卡号错误)不计数,否则正常的业务拒绝会误触熔断。
- 按通道+商户+产品三元组熔断:粒度要细,否则一个商户的配置错误会拖垮整个通道。
3.3 补单与重试
补单(Re-query / Reconcile)的出现场景:
- 业务下单后,通道接口超时,不知道通道侧是否创建了订单。
- 通道创建成功,但异步通知丢失或未触达(公网抖动、回调 URL 一时不可用)。
- 通道已出款但我方数据库落地失败。
工程上一般有三条补偿路径:
- 同步 API
下单时超时:立即查询通道订单(最多 1~2
次短时重试),确认状态;仍未决则标记
UNKNOWN,交给后台 Worker 处理。 - 异步通知超时未到:下单后 T 秒、2T 秒、4T 秒…(指数退避)主动 Polling 通道订单接口。
- 每日对账兜底:T+1 拉取通道账单,对未落地订单补录或冲正,流程详见第 23 篇《对账系统工程》。
3.4 签名、验签与防重放
每个通道的签名算法各异:支付宝用 RSA2(SHA256withRSA)、微信支付 V3 用 RSA-SHA256 + 证书链、银联用 RSA + MD5、Visa/MC 在收单链路上用 MAC + PIN Block(见第 07 篇)。网关内部必须:
- 每个通道一个
Signer接口实现。 - 私钥/证书从 KMS 或 HSM 读取,进程内不落盘。
- 每次请求带
nonce + timestamp,服务端校验重放窗口(一般 5 分钟内)。 - 验签失败必须返回明确错误码,不能静默丢弃(否则排查成本极高)。
3.5 异步通知分发
网关收到通道异步通知后:
- 验签、校验金额与订单号。
- 写入
notify_events表(幂等)。 - 查询内部订单状态机,决定是否推进。
- 把通知转发给业务方(下游订单系统、账务、风控),按业务方订阅关系发多路。
转发的重试策略需要独立于通道的重试(通道自己会重发若干次,但不能依赖它):
- 重试间隔:30s、1m、5m、15m、1h、6h、24h(可配置)。
- 死信队列(DLQ):超过最大重试次数进入 DLQ,触发告警人工介入。
- 业务方返回”明确接受”才算成功,其他返回(包括 5xx、超时、200 但 body 不符合约定)都算失败。
3.6 统一业务单号与幂等
Idempotency-Key 是网关的基石:
- 业务方下单时必须带
out_trade_no(32 位以内、唯一)。 - 网关内部以
(merchant_id, out_trade_no)作为唯一键。 - 同一键在网关侧只会产生一个
payments主订单;可以对应多个channel_orders(一条主订单、多次路由尝试)。 - 重放同一键的下单请求:如果之前成功,返回原结果;如果进行中,返回
PROCESSING;如果失败(且通道订单已关闭),可按策略允许再次下单或直接拒绝。
四、数据模型
4.1 四张核心表
-- 主订单(幂等主键:merchant_id + out_trade_no)
CREATE TABLE payments (
id BIGINT PRIMARY KEY,
merchant_id BIGINT NOT NULL,
out_trade_no VARCHAR(64) NOT NULL,
biz_line VARCHAR(32) NOT NULL,
amount BIGINT NOT NULL, -- 最小货币单位(分/cent)
currency CHAR(3) NOT NULL, -- ISO 4217
subject VARCHAR(128),
payer_country CHAR(2),
state VARCHAR(24) NOT NULL, -- 见 5.1 状态机
risk_score SMALLINT,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
finished_at DATETIME(3),
UNIQUE KEY uk_merchant_trade (merchant_id, out_trade_no),
KEY idx_state_time (state, created_at)
);
-- 通道订单(一条主订单可能有多条通道尝试)
CREATE TABLE channel_orders (
id BIGINT PRIMARY KEY,
payment_id BIGINT NOT NULL,
channel_code VARCHAR(32) NOT NULL, -- alipay / wechatpay / unionpay / stripe ...
channel_trade_no VARCHAR(64), -- 通道返回订单号
attempt_seq INT NOT NULL, -- 第几次尝试
state VARCHAR(24) NOT NULL, -- 见 5.2
request_body MEDIUMTEXT, -- 脱敏后
response_body MEDIUMTEXT,
error_code VARCHAR(64),
error_msg VARCHAR(512),
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_payment_attempt (payment_id, attempt_seq),
KEY idx_channel_tradeno (channel_code, channel_trade_no)
);
-- 异步通知事件
CREATE TABLE notify_events (
id BIGINT PRIMARY KEY,
channel_code VARCHAR(32) NOT NULL,
channel_event_id VARCHAR(64) NOT NULL, -- 通道侧事件 ID,用于幂等
payment_id BIGINT,
raw_body MEDIUMTEXT,
received_at DATETIME(3) NOT NULL,
verified TINYINT NOT NULL,
dispatch_state VARCHAR(24) NOT NULL, -- PENDING / DISPATCHED / DLQ
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3),
UNIQUE KEY uk_channel_event (channel_code, channel_event_id),
KEY idx_dispatch (dispatch_state, next_retry_at)
);
-- 退款单
CREATE TABLE refund_orders (
id BIGINT PRIMARY KEY,
payment_id BIGINT NOT NULL,
out_refund_no VARCHAR(64) NOT NULL,
channel_refund_no VARCHAR(64),
amount BIGINT NOT NULL,
reason VARCHAR(256),
state VARCHAR(24) NOT NULL,
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_payment_refund (payment_id, out_refund_no)
);几点值得强调:
- 金额永远用最小货币单位存 BIGINT,理由见第 02 篇《钱的建模》。
channel_orders而不是在payments表堆列:同一笔主订单可能在 A 通道失败后切到 B 通道,需要多行记录。notify_events上的(channel_code, channel_event_id)是通道通知幂等的关键。支付宝的notify_id、微信支付 V3 的event_id、Stripe 的event.id都适合做这一列。- 请求/响应 body 脱敏:卡号、CVV、密码字段必须在入库前替换为掩码,否则是 PCI DSS 合规红线。
4.2 请求幂等键
网关对外 HTTP API 建议:
POST /v1/payments
Idempotency-Key: 3f9a7b2e-...
Content-Type: application/json
{
"merchant_id": 1001,
"out_trade_no": "ORD20260422001",
"amount": 9900,
"currency": "CNY",
"subject": "会员季度",
"return_url": "https://merchant.example.com/return",
"notify_url": "https://merchant.example.com/notify",
"channel_hint": "alipay" // 可选,否则由网关路由决策
}
Idempotency-Key 与 out_trade_no
双重保护:前者用于防止客户端重试产生多条请求(例如 HTTP
超时重试),后者用于业务级订单唯一。两者一般相同,但 Stripe
的 Idempotency-Key
是独立于业务单号的请求级唯一键,也有采用该模式的网关实现。
五、双状态机设计
5.1 主订单状态机(内部)
stateDiagram-v2
[*] --> INIT
INIT --> ROUTING: 风控通过
INIT --> REJECTED: 风控拒绝
ROUTING --> SUBMITTED: 选中通道、提交通道
ROUTING --> FAILED: 所有通道不可用
SUBMITTED --> PAID: 通道返回成功通知
SUBMITTED --> UNKNOWN: 超时/异常
UNKNOWN --> PAID: 补单确认成功
UNKNOWN --> FAILED: 补单确认失败
SUBMITTED --> FAILED: 明确失败
PAID --> REFUNDING: 发起退款
REFUNDING --> REFUNDED: 退款成功
REFUNDING --> PAID: 退款失败回滚
FAILED --> [*]
REFUNDED --> [*]
REJECTED --> [*]
关键点:
UNKNOWN是必须存在的状态:只要依赖网络,就一定会出现”不知道到底成功没有”的区间。把它显式建模而不是偷懒用PROCESSING搪塞,后续补单、对账、客服差错处理就有清晰的抓手。PAID是终态的上游:只有PAID才能进入退款;FAILED、REJECTED不可退款;UNKNOWN不能直接退款,必须先被补单推进到PAID或FAILED。- 所有状态转移必须记录操作日志,带
traceId、操作者、旧状态、新状态、原因。
5.2 通道订单状态机
stateDiagram-v2
[*] --> PREPARED
PREPARED --> SENT: HTTP 已发出
SENT --> ACK: 通道返回 2xx
SENT --> TIMEOUT: 网络超时
ACK --> CH_SUCCESS: 异步通知 SUCCESS
ACK --> CH_FAILED: 异步通知 FAIL
ACK --> CH_CLOSED: 主动关单
TIMEOUT --> CH_SUCCESS: 补单确认
TIMEOUT --> CH_FAILED: 补单确认
CH_SUCCESS --> [*]
CH_FAILED --> [*]
CH_CLOSED --> [*]
主订单 state 与通道订单 state
的映射关系建议做成一张独立的跃迁表,而不是散落在代码里的
switch:
CREATE TABLE state_transition_rules (
trigger VARCHAR(64), -- CH_SUCCESS / CH_FAILED / TIMEOUT / ...
from_state VARCHAR(24),
to_state VARCHAR(24),
action VARCHAR(64), -- NOTIFY_BIZ / LEDGER_POST / ...
PRIMARY KEY (trigger, from_state)
);这样一条日志 + 一张规则表 + 一个状态机执行器,故障回放和对账差异的排查效率会大幅提升。
六、路由策略
6.1 常见策略
- 加权轮询(Weighted Round Robin):按通道费率/成功率做静态权重。
- 成功率动态路由:实时计算近 N 分钟各通道成功率,按 EWMA(指数加权移动平均)调整权重,成功率低于阈值的通道降权或下线。
- 主备通道:对大额订单或关键商户配置主备,主通道在 3~5 秒内无响应即切备。
- 金额/BIN 分段路由:按 BIN(卡号前 6~8 位)识别卡组织与发卡行,定向到费率更优或成功率更高的通道。
- 灰度路由:新通道按比例(如 1%→5%→25%→50%→100%)逐步放量,详见 9.1。
6.2 一个最小可用的 Go 路由器
package router
import (
"context"
"errors"
"sort"
"sync"
"time"
)
type Channel struct {
Code string
Weight int
Enabled bool
Healthy func() bool
Fee func(amount int64, currency string) int64
Support func(req *Request) bool
}
type Request struct {
MerchantID int64
Amount int64
Currency string
BIN string
Country string
Hint string
}
type Router struct {
mu sync.RWMutex
channels map[string]*Channel
metrics MetricsProvider
}
func (r *Router) Pick(ctx context.Context, req *Request) ([]*Channel, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var candidates []*Channel
for _, ch := range r.channels {
if !ch.Enabled || !ch.Healthy() {
continue
}
if !ch.Support(req) {
continue
}
candidates = append(candidates, ch)
}
if len(candidates) == 0 {
return nil, errors.New("no available channel")
}
// 综合成功率、费率、权重
type scored struct {
ch *Channel
score float64
}
scoredList := make([]scored, 0, len(candidates))
for _, ch := range candidates {
sr := r.metrics.SuccessRate(ch.Code, 5*time.Minute)
fee := float64(ch.Fee(req.Amount, req.Currency))
score := sr*0.7 + (1.0/(1.0+fee/100.0))*0.2 + float64(ch.Weight)*0.1
if ch.Code == req.Hint {
score += 0.5
}
scoredList = append(scoredList, scored{ch, score})
}
sort.Slice(scoredList, func(i, j int) bool {
return scoredList[i].score > scoredList[j].score
})
chain := make([]*Channel, 0, len(scoredList))
for _, s := range scoredList {
chain = append(chain, s.ch)
}
return chain, nil
}这段代码有意写得朴素:打分函数在生产中会上升到一个独立的策略服务,支持热加载权重配置。但”候选集过滤 + 打分排序 + 返回链表”这个骨架不变。
6.3 决策流
flowchart LR
A[下单请求] --> B{参数校验}
B -->|失败| Z1[400]
B -->|通过| C{风控前置}
C -->|拒绝| Z2[REJECTED]
C -->|通过| D[候选通道过滤]
D --> E[打分排序]
E --> F[选主通道]
F --> G{限流/熔断}
G -->|触发| H[切备通道]
H --> F
G -->|通过| I[签名调用]
I --> J{通道响应}
J -->|成功| K[SUBMITTED]
J -->|业务失败| L[FAILED]
J -->|系统错误/超时| H
K --> M[等异步通知]
要点:“业务失败”不能降级。比如 Visa 返回
05 Do not honor、14 Invalid card number、51 Insufficient funds,这些都是发卡行明确拒绝,换通道重试要么无效要么涉嫌规避风控(尤其是拒付类)。只有系统错误(网络超时、5xx、通道自身限流)才能切备。
6.4 路由策略的常见反模式
- “谁便宜走谁”:单纯按费率排序,忽视成功率与用户体验。短期成本低,长期因转化率下降得不偿失。
- “一票否决”:某个通道上一笔失败就下线整条链路,忽略失败原因是业务拒绝还是系统错误。
- “路由与状态机耦合”:路由结果直接影响主订单状态,导致每次调整策略都要改状态机。正确做法是路由只影响
channel_orders,主订单只关心”最终是否 PAID”。 - “把路由器当风控用”:在路由器里写”用户黑名单”、“商户黑名单”、“金额黑名单”等风控规则。当规则超过 20 条时代码几乎无法维护,应将风控规则剥离到独立的风控引擎。
6.5 路由配置热加载
路由策略不可能一次写死,权重、阈值、灰度比例都会频繁调整。一个生产级路由器必须支持:
- 配置中心监听(Apollo、Nacos、etcd);任何配置变更在 30 秒内生效。
- 原子切换:新老配置切换时保证单笔请求用的是同一份配置快照,避免中途读到混合值。
- 变更审计:每次配置变更记录”谁在什么时候改了什么”,配合发布单。
- Dry-run 模式:新策略先在影子流量上跑,对比打分结果与老策略的差异,确认符合预期再放量。
七、熔断器实现示例
7.0 为什么熔断对支付网关尤其重要
支付通道是典型的”外部依赖 + 慢故障”:它不会突然 100% 宕机,而是先进入”大量请求 hang 30s 再 502”的状态。如果不熔断,几秒内网关的所有 worker 都会被这种慢请求占满,导致本来健康的通道也无法服务新的请求。这种级联故障在 2021~2023 年的多次公开支付事故复盘里都出现过。
熔断的目的不是”替通道失败”,而是”用 5% 的主动放弃换 95% 的整体可用”。下面给一个可直接使用的实现骨架。
7.1 一个可落地的实现
一个常用形态是”滑动窗口 + 半开态探测”:
package breaker
import (
"errors"
"sync"
"sync/atomic"
"time"
)
type State int32
const (
Closed State = iota
Open
HalfOpen
)
type Breaker struct {
name string
failureRate float64 // 触发熔断的失败率阈值
minSamples int64 // 统计最少样本数
window time.Duration
sleepWindow time.Duration // Open -> HalfOpen 的冷却时间
halfOpenMax int64 // HalfOpen 态允许的探测并发数
state atomic.Int32
openedAt atomic.Int64
halfOpenInflight atomic.Int64
mu sync.Mutex
bucketStart time.Time
totalCount int64
failureCount int64
}
var ErrOpen = errors.New("circuit breaker open")
func (b *Breaker) Allow() error {
switch State(b.state.Load()) {
case Closed:
return nil
case Open:
if time.Now().UnixNano()-b.openedAt.Load() > int64(b.sleepWindow) {
if b.state.CompareAndSwap(int32(Open), int32(HalfOpen)) {
return nil
}
}
return ErrOpen
case HalfOpen:
if b.halfOpenInflight.Add(1) > b.halfOpenMax {
b.halfOpenInflight.Add(-1)
return ErrOpen
}
return nil
}
return nil
}
func (b *Breaker) Report(success bool) {
b.mu.Lock()
defer b.mu.Unlock()
now := time.Now()
if now.Sub(b.bucketStart) > b.window {
b.bucketStart = now
b.totalCount = 0
b.failureCount = 0
}
b.totalCount++
if !success {
b.failureCount++
}
switch State(b.state.Load()) {
case HalfOpen:
b.halfOpenInflight.Add(-1)
if success {
b.state.Store(int32(Closed))
b.failureCount = 0
b.totalCount = 0
} else {
b.state.Store(int32(Open))
b.openedAt.Store(now.UnixNano())
}
case Closed:
if b.totalCount >= b.minSamples &&
float64(b.failureCount)/float64(b.totalCount) >= b.failureRate {
b.state.Store(int32(Open))
b.openedAt.Store(now.UnixNano())
}
}
}要配合每个”通道 ×
商户”单独一套实例,不要全局一把锁。Report
里只对”通道故障”错误计数——业务拒绝(如余额不足)应该
Report(true),否则高拒付率商户会反复触发熔断。
八、异步通知与补单 Worker
8.1 异步通知的坑
真实世界里,异步通知几乎无法假设”必达 + 有序”:
- 支付宝早期
notify_url没响应时重发 8 次(间隔:4m、10m、10m、1h、2h、6h、15h),后期再看文档又改过版本。 - 微信支付 V3
notify_url未收到 200 时,最多 15 次。 - Stripe 会在 webhook 失败后最多重试 3 天。
- 通道甚至会出现乱序:先到 PAID,后到 CLOSED;网关必须以状态机而不是”最后一次通知胜出”来处理。
8.2 通道通知接收
func (h *NotifyHandler) Handle(w http.ResponseWriter, r *http.Request) {
ch := mux.Vars(r)["channel"]
body, _ := io.ReadAll(r.Body)
adapter := h.registry.Get(ch)
if adapter == nil {
http.Error(w, "unknown channel", 404)
return
}
ev, err := adapter.ParseNotify(body, r.Header)
if err != nil {
h.metrics.IncVerifyFail(ch)
http.Error(w, "verify failed", 400)
return
}
// 幂等写入
if err := h.repo.SaveEvent(r.Context(), ev); err != nil {
if errors.Is(err, store.ErrDuplicate) {
adapter.WriteAck(w)
return
}
http.Error(w, "db error", 500)
return
}
// 异步推进状态机与下游分发
h.queue.Enqueue(ev.ID)
adapter.WriteAck(w) // 每个通道的确认体格式不同
}这里一个关键习惯:先写入
notify_events 再返回
ACK。如果先推进状态机再写事件表,进程挂掉后事件丢失,通道不会再重发。
8.3 补单(Polling)Worker
func (w *ReconcileWorker) Run(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
orders := w.repo.FindPendingOlderThan(ctx, []string{"SUBMITTED", "UNKNOWN"}, 30*time.Second, 500)
for _, o := range orders {
w.pool.Submit(func() { w.query(ctx, o) })
}
}
}
}
func (w *ReconcileWorker) query(ctx context.Context, o *ChannelOrder) {
adapter := w.registry.Get(o.ChannelCode)
resp, err := adapter.Query(ctx, o)
switch {
case err != nil && isTransient(err):
w.repo.BumpRetry(ctx, o, backoff(o.RetryCount))
case err != nil:
w.stateMachine.Apply(ctx, o, "CH_FAILED", err.Error())
case resp.State == "SUCCESS":
w.stateMachine.Apply(ctx, o, "CH_SUCCESS", "")
case resp.State == "FAILED", resp.State == "CLOSED":
w.stateMachine.Apply(ctx, o, "CH_FAILED", resp.ErrorCode)
default:
w.repo.BumpRetry(ctx, o, 30*time.Second)
}
}工程约定:
- Polling 间隔指数退避但有上限(比如最长 30 分钟一次),否则半天没推进的单子永远不被看见。
- 补单不直接改
payments.state,而是调用状态机,统一走日志、审计、下游通知。 - 关单(close)能力要明确:超过 T 小时未决的订单主动关单,避免长期占用。
8.4 通知分发重试
func (d *Dispatcher) dispatch(ev *NotifyEvent) {
subscribers := d.subs.For(ev.BizLine, ev.PaymentID)
for _, s := range subscribers {
err := d.postJSON(s.URL, ev, s.Secret)
if err == nil {
d.repo.MarkDispatched(ev.ID, s.ID)
continue
}
if ev.RetryCount >= 10 {
d.repo.MoveToDLQ(ev.ID, s.ID)
d.alert.Fire("notify_dlq", ev.ID)
continue
}
d.repo.ScheduleNext(ev.ID, s.ID, backoffSeq[ev.RetryCount])
}
}backoffSeq 一般配
[30s, 1m, 5m, 15m, 1h, 6h, 24h, 24h, 24h, 24h]。死信队列不是”扔掉”,而是”不再自动重试、进入人工处理通道”,通常对接工单系统。
8.5 通知有序性与幂等落地
业务方在接收网关分发的通知时,同样要做两件事:
- 幂等:以
event_id或(payment_id, event_type)作为唯一键,重复到达直接返回成功。 - 状态保序:业务方自己维护订单状态机,只接受”前向推进”。
PAID收到后如果再收到SUBMITTED,忽略即可。
这两点如果业务方不做,网关再努力也无法保证最终一致。因此网关的 SDK / 接入文档必须把”业务方必须做”的清单写清楚,最好附上样例代码与一个可下载的接入自测工具包。
九、风控点接入
网关与风控的交互有两个点:
- 下单前(Pre-risk):收到业务请求后,在路由前调用风控,拿到
allow/review/reject与risk_score。这个调用必须是低延迟、必达(超时直接 fail-close 或降级到本地规则),否则会被黑产打爆。 - 通道返回后(Post-risk):通道
SUCCESS后,在账务落账前再评估一次。对高风险订单可放入”待放款”队列,人工复核或延迟出款。
风控接口建议放入 risk_score 到
payments
表,便于后续采样分析和模型回归。详细的规则引擎、特征工程、Flink
实时流留给第 19 篇。
9.1 Pre-risk 的降级与兜底
Pre-risk 是”拦截在路由之前”的关键一步,但它同时引入了一个新的单点:风控本身的可用性。工程上常见的降级策略有三种:
- Fail-open(通过):风控超时/不可用时直接放行。优点是用户体验无感,缺点是遇到黑产集中攻击时会造成批量资损。
- Fail-close(拒绝):风控超时即拒绝交易。优点是安全,缺点是风控故障瞬间业务成功率会暴跌。
- Fail-local(本地降级规则):在网关侧内置一套保守的本地规则(黑名单 IP、异常金额、异常频次),风控不可用时走本地规则。这是大多数生产系统的选择。
在订单入账侧的 Post-risk 则通常 fail-close——反正钱已经收了,宁可让客服人工复核,也不要把高风险订单直接放款给下游。
9.2 三道口子的执行顺序
一笔订单在网关内一般会经过”三道口子”:
- 参数校验与签名验签(微秒~毫秒级,必过)。
- Pre-risk 风控(十毫秒级,允许降级)。
- 路由决策 + 限流熔断(毫秒级,本地逻辑)。
三道口子全部通过后才会真正调用通道。这个顺序不能随意打乱,尤其不能把”调用通道”放在”风控”之前,否则攻击者可以通过大量伪造请求让网关持续调通道、产生费用并污染统计数据。
十、密钥与证书管理
10.1 层次
- 对称密钥(AES-256-GCM):网关内部字段加密(如卡号 token、持卡人姓名),存储在列加密或应用层加密。
- 非对称密钥(RSA-2048 / EC-P256):与通道的签名/验签,如支付宝 RSA2、微信 V3。
- 证书:微信支付 V3 平台证书、TLS 双向证书(银联、卡组织收单需要)。
10.2 管理方式
按安全等级由低到高:
- 配置中心 + 加密列:仅适合 dev/staging。
- KMS 包裹(Envelope Encryption):主密钥在云 KMS,数据密钥用主密钥加密后存库。启动时用主密钥解开。
- HSM(Hardware Security Module):密钥永不落盘,签名操作在 HSM 内完成。PCI DSS 3.2 对卡数据密钥管理强制要求 FIPS 140-2 Level 3 以上的 HSM;中国人行在《银行卡收单业务管理办法》与 JR/T 0025 系列中对加密机也有强约束。
- TEE/云 HSM 托管:AWS CloudHSM、阿里云加密服务、华为云 DEW,成本与运维更友好。
10.3 证书轮换与泄露应急
- 轮换:至少每年一次;微信支付平台证书由微信主动轮换,商户必须下载更新,代码层面要做”同时接受新旧两张证书”的过渡窗口。
- 泄露:一旦怀疑私钥泄露,立即(1)吊销证书(2)通知通道侧更换密钥(3)全网关滚动发布新证书(4)回查 30 天内可疑交易。演练流程要像消防演习一样每年做一次。
10.4 接口抽象
type KeyProvider interface {
Sign(ctx context.Context, keyID string, payload []byte) ([]byte, error)
Verify(ctx context.Context, keyID string, payload, sig []byte) error
Encrypt(ctx context.Context, keyID string, plaintext []byte) ([]byte, error)
Decrypt(ctx context.Context, keyID string, ciphertext []byte) ([]byte, error)
}keyID 是一个稳定的逻辑 ID(如
alipay/merchant/1001/sign),Provider
负责解析到具体的 KMS/HSM 资源。这样当从 KMS 迁移到 HSM
时,业务代码无需改动。
10.5 防重放的细节
签名正确不代表请求合法。一个常见攻击是重放攻击(Replay Attack):攻击者截获一笔合法请求后,在短时间内反复重放,如果网关没有防重放机制,同一笔款会被反复扣多次。标准做法:
- 请求必带
nonce(随机串)+timestamp。 - 服务端维护一个短期(5~10 分钟)的
nonce已使用集合,用 Redis 存SET nonce_xxx 1 EX 600 NX。NX失败即为重放。 timestamp偏离服务端时钟超过阈值的直接拒绝。- 对外回调入口(通道打过来的异步通知)同样要做防重放,以通道侧事件 ID 幂等。
10.6 一个值得抄的规范:PCI DSS 的密钥管理要求
PCI DSS v4.0 的第 3 章与第 8 章对密钥管理有非常细的硬性要求,值得任何涉及卡数据的团队直接参考:
- 数据加密密钥(DEK)与密钥加密密钥(KEK)分层;DEK 可以定期轮换,KEK 保存在 HSM 内。
- 密钥的全生命周期审计:创建、分发、使用、备份、销毁各步骤都要留审计日志。
- 密钥的”双人控制”(split knowledge / dual control):没有任何一个人可以独自拿到完整的主密钥。
- 密钥至少每年或在”有人离职/怀疑泄露”时轮换。
不做卡数据的国内业务虽然不强制 PCI,但其方法论同样可用于支付宝/微信的商户私钥保护。
十一、可观测性
11.0 为什么支付网关的观测要”双轨”
一般后端服务的监控聚焦于”系统是否健康”:QPS、延迟、错误率、资源使用。支付网关除了这些 SRE 指标之外,还要承担资金安全的监控责任:
- 交易面:本通道应到的钱有没有到?有没有悬挂订单?异步通知是否按时分发?
- 系统面:接口是否抖动?熔断是否正确触发?证书是否即将过期?
两条轨道的受众也不一样——SRE 指标给值班工程师看,资金指标给财务、风控、客服看。看板要分开做,告警通道也分开(例如系统告警进 PagerDuty,资金告警进财务值班群 + 电话)。
11.1 链路追踪
每个入网请求生成一个 traceId,透传到:
- 风控调用(Header
X-Trace-Id)。 - 通道 HTTP 调用(放进自定义 Header,通道不关心即可)。
- 异步通知处理、补单 Worker(从
channel_orders上带出去)。 - 账务系统、下游业务通知。
链路贯通后,“一笔订单从下单到入账”的完整时序图可以从 Jaeger/Tempo 直接拉出来,大幅降低故障排查成本。
11.2 核心指标
| 指标 | 维度 | 告警示例 |
|---|---|---|
| 下单成功率 | 通道、商户、产品 | 5 分钟内 < 95% |
| 通道平均耗时(P95/P99) | 通道、产品 | P99 > 3s |
| 异步通知延迟(到达 - 成功时间) | 通道 | P99 > 60s |
| 通知分发失败率 | 业务方 | > 1% |
| 补单积压 | 状态 | UNKNOWN > 100 持续 10 分钟 |
| 熔断触发次数 | 通道 × 商户 | 任一触发即告警 |
| 验签失败数 | 通道 | 突增(同比 > 3σ) |
11.3 业务看板
除了 SRE 指标,给业务和财务团队看的日看板至少包含:
- 当日下单数、成功数、成功金额、退款金额。
- 各通道占比与费率成本。
- TOP 10 失败错误码分布。
- 昨日对账差异笔数(来自对账系统)。
十二、灰度与发布
12.1 通道灰度
新接入通道/新签名算法/新路由策略的发布策略:
- 影子流量(Shadow):真实流量 100% 走旧通道,同时复制到新通道,只比对响应不影响真实交易。适合验证签名、响应解析的正确性。
- 按商户白名单:指定若干低风险商户先切 1%→5%→25%。
- 按金额档:新通道先跑小额(≤100 元),观察 1~2 周后放开。
- 按时段:先在交易低峰(凌晨)开启,避免问题在高峰放大。
12.2 功能开关(Feature Flag)
网关的每个策略点都应该有开关:路由策略、熔断阈值、通知重试参数、补单频率。开关中心(如 Apollo、Nacos、自研)必须支持秒级生效与审计日志。金融场景下的一个铁律是:配置改动也要有变更单,因为一个错误的阈值比一行错误代码造成的资损可能更大。
12.3 回滚
- 通道级回滚:一键把某个通道流量降为 0%。
- 版本回滚:保留最近 3 个部署版本镜像,5 分钟内可回滚。
- 数据兼容:状态机新加的状态值必须向后兼容,旧版本看到未知状态时保守处理(不推进)。
12.4 发布窗口
金融场景的发布窗口与普通互联网公司差异明显:
- 禁止发布时段:大促(双十一、618、春节红包)、月末结账日、节假日前夜一般禁止网关侧发布。
- 灰度观察时间:每升一档流量需要至少 1~2 小时的观察窗口,覆盖一轮通道异步通知的 backoff 序列。
- 变更审批:配置/策略/版本三类变更要走审批单,并与监控看板链接——变更出现后 30 分钟内指标异常会自动回滚。
把这些规范写进 CI/CD 流水线(禁止时段自动阻断、灰度卡点自动等待),比写进文档有效十倍。
十三、真实案例
下面几个案例的事实以公开资料为准,具体数字仅用作工程参考。
13.1 拉卡拉(Lakala)
拉卡拉作为 A 股上市的第三方支付机构(股票代码 300773),在其招股说明书与年报中披露了其”全渠道支付”的技术架构:统一接入层对接银联、网联、卡组织国际通道(Visa/Mastercard/JCB 等),商户通过 API/POS/扫码多入口下单,网关内部做路由与合规校验。对于 A 股公众公司而言,其年报中披露的”交易处理能力”与”通道冗余”信息,是研究国内大型收单机构网关设计的公开可验证资料。
工程启示:通道冗余不是靠多写几套适配器,而是把所有通道抽象到同一套状态机下,否则运维成本会随通道数指数增长。
13.2 收钱吧
收钱吧作为聚合支付的代表玩家,对外是”一个二维码收全渠道”,对内实际上是一个多通道网关 + 商户服务体系。其官网与备案信息显示其同时持有支付业务许可证(由 2018 年收购上海钱方获得相关牌照体系)。聚合支付网关的一个典型工程挑战是:同一张收款码背后会在不同场景下落到不同通道(支付宝扫->支付宝、微信扫->微信、银联云闪付扫->银联),网关要做 User-Agent 与 scheme 识别后再路由。
13.3 Airwallex 多通道路由
Airwallex(空中云汇)是澳洲起家的跨境支付科技公司,主营多币种收单/付款/换汇。其官方工程博客中公开提到”smart routing”:根据发卡国、卡组织、历史成功率动态选择收单机构(acquirer),在欧洲区域甚至能够一笔交易在多家 acquirer 之间 fall-back。其路由器是上文 6.1 第 2 种”成功率动态路由”的生产实现。
工程启示:跨境场景下,同一张卡在不同 acquirer 上的批准率差异可达 10~20 个百分点。一个聪明的路由器直接等于利润。
13.4 Stripe Radar + Connect
Stripe 是国际支付 SaaS 的标杆:
- Stripe Connect:多商户/平台模式,网关层抽象了 Account、Destination Charge、Separate Charges and Transfers 等多种分账模型。
- Stripe Radar:风控引擎,对所有网关流量做实时评分,与路由器深度耦合(高风险交易可能被拒,也可能被路由到 3DS 2.0 强验证分支)。
- Idempotency-Key:Stripe 是把幂等键从请求级做到极致的开源教科书,其 API 文档 与技术博客《Implementing Stripe-like Idempotency Keys in Postgres》对工程实现写得非常清楚。
13.5 历史事故借鉴
- 2018 年 Visa 欧洲区故障(2018-06-01):近 10 小时不可用,影响全欧洲卡支付。公开复盘显示是数据中心硬件故障触发的级联问题。工程启示:不能把”卡组织绝对可用”写进假设里。
- 2024
年微信支付抖动(多次见诸媒体):商户侧普遍体验到”扫码后长时间未回调”,若网关没有补单机制,会把”已成功但未通知”的订单长期滞留在
UNKNOWN。
十三补、常见通道差异速查
下表是一张经常用到的通道差异速查表。它不追求穷举,而是把”写适配器时最容易踩坑”的点列出来,供团队新人快速对齐。
| 维度 | 支付宝 | 微信支付 V3 | 银联 | Visa / Mastercard | Stripe |
|---|---|---|---|---|---|
| 签名算法 | RSA2(SHA256withRSA) | RSA-SHA256 + 证书链 | RSA + SHA1/SHA256 | MAC + PIN Block(ISO 8583) | HMAC-SHA256(Webhook) |
| 密钥形式 | 应用私钥 + 支付宝公钥 | 商户私钥 + 平台证书(可轮换) | 双向 TLS 证书 + 报文签名 | HSM 内 MK/ZMK 体系 | API Key + Webhook Secret |
| 金额单位 | 元(字符串,两位小数) | 分(整数) | 分(整数) | 最小货币单位(整数) | 最小货币单位(整数) |
| 订单号 | out_trade_no ≤ 64 位 |
out_trade_no 6-32 位 |
orderId 商户自定义 |
无统一字段,走 STAN |
client_reference_id |
| 异步通知 | POST form,重试 8 次 | POST JSON,重试 15 次 | POST form,重试 5 次 | 不直接回调,走批处理 | POST JSON,最长 3 天 |
| 通知确认 | 返回文本 success |
返回 JSON {code:"SUCCESS"} |
返回 00 成功码 |
N/A | 200 状态码即可 |
| 查单接口 | alipay.trade.query |
/v3/pay/transactions/out-trade-no/{x} |
5.1.2 交易查询 |
走清算文件(Clearing) | /v1/payment_intents/{id} |
| 退款粒度 | 支持部分退款,多次 | 支持部分退款,多次 | 支持部分退款 | 支持,但要看 MCC 与发卡行 | 支持部分退款、多次 |
| 幂等 | 以 out_trade_no 幂等 |
以 out_trade_no 幂等 |
以 orderId 幂等 |
以 STAN + Date 幂等 |
以 Idempotency-Key 幂等 |
| 对账文件 | 次日 T+1 对账单 API | 次日 T+1 对账单 API | T+1 清算文件 | T+1 / T+2 清算文件(Base II / IPM) | API 拉取 balance transactions |
把上述差异沉淀成每个 Adapter 实现的
golden-test,能显著减少”改 A 通道不小心把 B
通道弄坏”的回归。
十四、工程坑点
- 双扣款:同一笔订单切换通道后,旧通道延迟成功,导致两边都扣款。解法:切换前必须先拿到旧通道”明确失败 + 已关单”确认,或做 T+0 自动冲正。
- 通知乱序:先到
PAID后到CLOSED。解法:以状态机的前向推进为准,不接受倒退;对冲突通知记录审计日志人工复核。 - 金额不匹配:通道通知回来的金额与下单金额不一致(罕见但出现过)。解法:验签通过后再比对金额,任何差异直接 reject 通知并告警。
- 时钟漂移:签名用
timestamp,容器时钟偏差导致验签失败。必须配 NTP,并允许 ±300s 容差。 - 通道文档与实际不一致:支付宝某些错误码在不同版本 SDK 上含义不同;微信 V2/V3 签名算法差异巨大。解法:每个通道适配器要维护自己的”错误码映射 + 版本矩阵”测试集。
- 回调 URL 被爬虫命中:网关
notify_url一旦暴露在 DNS,会被各种扫描器探测。解法:对未带签名的请求快速返回 400,记录来源 IP,必要时 WAF 拉黑。 - 幂等键被复用:商户 SDK bug 把同一
out_trade_no用在不同金额订单。解法:幂等键命中后严格比对”关键字段(金额、币种、subject)“是否一致,否则返回 409。 - 数据库热点:
payments表按时间写,主键自增导致尾页热点。解法:主键用雪花 ID 或 TiDB 的 AUTO_RANDOM(见第 04 篇《账务数据库设计》)。 - 日志脱敏失败:调试期不小心把卡号、CVV 写进日志,过了 PCI 审计就是灾难。解法:日志框架内置敏感字段过滤器 + 单元测试兜底。
- 补单无限循环:通道接口对不存在的订单返回
UNKNOWN,Worker 反复查。解法:给补单加最大次数 + 最大时长双重上限,过期关单。 - 退款超过原单金额:由于并发或人工错误,退款总额累加后超过支付金额。解法:
refund_orders插入前在事务中锁payments行并做SUM(已退金额) + 本次 <= 支付金额校验。 - 商户
notify_url被替换:运营后台被越权操作导致notify_url指向他人服务器,交易通知被劫持。解法:notify_url改动需二次确认,并对回调 URL 做域名白名单与 HTTPS 强制。 - 退款先于支付成功通知到达:极端情况下商户发起退款,异步流水导致退款通知早于原支付通知到达。解法:状态机里严格要求
PAID → REFUNDING,否则写入待处理队列延迟处理。 - 金额单位误用:支付宝 API
金额是”元.分分”字符串,微信是”分”整数,一线工程师切换时把
100(分)误当成1.00元传出去,差 100 倍。解法:Adapter 层统一入参是内部Money{amount, currency},出参转换集中一个 helper。 - 节假日 / 凌晨对账切换:通道对账文件在某些节假日延迟 1~2 天,或跨年时日期格式变化。解法:对账任务的时间窗口配置化,异常不要直接告警爆炸,而是带”文件未到”的独立告警等级。
十五、选型与落地清单
面对一个新项目,推荐按下列顺序推进:
- 明确通道清单与商户模型:第一版 1~2 个通道、单一商户结构就够了,别上来就通用化。
- 定义统一下单 API:字段设计参考 4.2,先让上游业务接进来。
- 落地数据模型:四张核心表先上,状态机用枚举 + 规则表表达。
- 写第一个通道适配器:用真实通道跑通”下单 + 异步通知 + 查单 + 退款”四个动作,作为”参考实现”。
- 抽离路由器与熔断器:当第二个通道出现时立刻抽离,不要等第三个。
- 补单 Worker:补单比路由重要,先上线。
- 通知分发服务:通知可靠性 > 通知性能。
- 密钥托管:至少做到 KMS 封装,PCI 相关业务再上 HSM。
- 可观测性:上线前必须有核心指标、链路追踪、对账差异看板。
- 演练:密钥泄露、通道宕机、DB 主从切换三个场景每半年演练一次。
十六、小结
支付网关在整个金融科技栈里,是”离钱最近、坑最多、但最容易被低估”的一层。它看起来只是协议转换和转发,实际是一个有状态、强幂等、高可用、强合规的分布式系统。做好它的关键不在某个花哨算法,而在:
- 严格的状态机——把每一个”不知道”都建模成显式状态。
- 守纪律的路由——对系统错误降级,对业务拒绝绝不降级。
- 双保险的补偿——同步补单 + T+1 对账兜底。
- 可观测与可回滚——任何一个参数变更都能 5 分钟内撤回。
从团队形态上讲,一个成熟支付网关背后通常是”三角结构”:
- 工程团队负责网关本体、适配器、路由与补单 Worker。
- 对账团队负责每日 T+1 文件导入、与账务核对、差错处理。
- 风控团队负责规则、模型与名单库。
三方的边界不清,网关就会被塞进各种”临时补丁”——一个特殊商户的绕风控开关、一个通道的特殊退款流程、一段写死的费率。这些”临时”最终都会变成事故的放大器。清晰的分工、契约化的接口、完善的审计日志,是网关工程能活得久的根本。
回顾全文,我们从”为什么需要网关”讲到”分层结构”,从”能力矩阵”讲到”状态机与数据模型”,再落到”路由器 / 熔断器 / 补单 Worker”的代码骨架,最后以”密钥 / 灰度 / 观测 / 案例”收尾。对于一个刚起步的团队,建议不要一次把全部能力做完,按第十五节的清单分阶段落地;对于已经有规模的团队,建议用本文的章节作为 review checklist,逐项对照自家系统是否有显式的、可观测的、可回滚的实现。
下一篇我们从”把钱收进来”走到”持续向用户收钱”:订阅与计费系统的用量计量、账单、出海税务。
参考资料
- 支付宝开放平台开发者文档:https://opendocs.alipay.com/
- 微信支付 API v3 文档:https://pay.weixin.qq.com/docs/merchant/apis/
- 中国银联 UnionPay API 文档:https://open.unionpay.com/
- Stripe API Reference(Idempotency、Webhooks、Connect):https://stripe.com/docs/api
- Stripe Engineering Blog:https://stripe.com/blog/engineering
- Airwallex Developer Portal:https://www.airwallex.com/docs
- Visa Europe 2018 Outage Post-mortem(公开信息):Visa 官方给英国 Treasury Committee 的回复信
- PCI DSS v4.0:https://www.pcisecuritystandards.org/document_library
- 人民银行《银行卡收单业务管理办法》、JR/T 0025 系列金融 IC 卡规范
- 拉卡拉 2023 年年度报告:http://www.cninfo.com.cn/
- CNCF OpenTelemetry(链路追踪标准):https://opentelemetry.io/
上一篇:《支付宝、微信支付接入:服务商模式、预授权、分账、退款、对账》
下一篇:《订阅与计费系统:用量计量、账单、发票、出海 VAT/GST》
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【金融科技工程】幂等、事务与一致性:SAGA、TCC、对账补偿
支付与账务系统里,"这笔操作能不能重放一遍"几乎是每一次故障复盘都会问到的问题。本文从网络重试的本质谈起,讲清楚幂等(idempotency)的三层设计、Idempotency-Key 的工程细节、订单状态机的落库方式,并横向对比 2PC、TCC、SAGA、可靠消息四种分布式事务方案,配合 Outbox Pattern、CDC、补偿策略与真实事故案例,给出一份可以直接落地的检查清单。
【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图
金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。
【金融科技工程】钱的建模:金额精度、币种、会计单位、多语言金额
在代码里正确地表示"一笔钱"远比看起来难。本文系统梳理金额的数值建模(浮点、定点、Decimal、最小单位)、币种标准(ISO 4217)、本地化显示、汇率换算与数据库存储,并给出 Go、Python、Java、Rust 的工程化示例。
【金融科技工程】复式记账工程化:科目、分录、余额、对账
把 500 年历史的复式记账翻译成工程师可以落地的数据模型、SQL 表结构与余额计算策略,覆盖充值、下单、退款、分润、红包、多币种与冲销的真实场景,并对比 TigerBeetle、beancount、Ledger CLI、Square LedgerDB、Stripe Ledger 等开源与工业实现。