在线支付系列(五):统一支付网关架构设计
在线支付系列(五):统一支付网关架构设计
这是在线支付系列的最后一篇。前四篇里,我们分别搞定了支付宝、微信支付、Stripe 和 PayPal。如果你真的一篇一篇跟着做了下来,你的代码库里大概率已经出现了这样的场景——
一、噩梦的开始
小陈是一家跨境电商的后端工程师。过去三个月,他依次接入了支付宝、微信支付、Stripe 和 PayPal,每接一个都花了一两周。他觉得自己挺厉害的——四种支付全搞定了。
直到有一天早上,他打开了工作群:
产品经理:支付宝回调好像有问题,昨晚有几笔订单没到账
运营:微信退款怎么还没处理?
老板:PayPal 那边有个争议需要处理,你看看
财务:这个月的对账差了 3 笔,帮忙查查是哪个渠道的
小陈盯着屏幕发呆。四套支付渠道,四种签名机制,四种回调格式,四套退款逻辑,四份对账文件。每次改一个公共逻辑(比如加个日志字段),他得改四个地方。每次排查问题,他得在四套代码之间来回跳。
他突然意识到:接入四个渠道不是终点,而是噩梦的开始。
这正是「统一支付网关」要解决的问题。
二、思考:到底哪些东西可以统一?
在动手之前,小陈做了一张表,把四个渠道的差异摊开来看:
| 维度 | 支付宝 | 微信支付 | Stripe | PayPal |
|---|---|---|---|---|
| 金额单位 | 元(字符串 "99.99") |
分(整数 9999) |
分(整数 9999) |
元(字符串 "99.99") |
| 签名方式 | RSA2(SHA-256) | HMAC-SHA256 | HMAC-SHA256 | 调 API 验签 |
| 回调格式 | form 表单 | JSON + XML | JSON | JSON |
| 回调响应 | 返回 "success" |
返回 {"code":"SUCCESS"} |
返回 HTTP 200 | 返回 HTTP 200 |
| 退款接口 | 同步返回结果 | 异步回调通知 | 同步返回结果 | 同步返回结果 |
| 认证方式 | 应用私钥签名 | 商户证书 + API Key | Secret Key | OAuth 2.0 |
差异这么大,还能统一吗?
小陈画了一张图后发现:差异在细节,但流程是相通的。
每笔支付不管走哪个渠道,本质上都是这几步:
创建订单 → 调用渠道下单 → 等待用户支付 → 接收回调 → 更新状态 → 通知业务
↓
(可选)退款 → 对账
所以策略很清楚了:流程统一,差异下沉。
三、三层架构:让混乱变有序
小陈参考了几个开源方案(Jeepay、PayPal Braintree SDK、Stripe Connect 的设计),画出了这样的分层:
┌─────────────────────────────────────────────────────┐
│ 接入层(API Gateway) │
│ 统一 RESTful API / 参数校验 / 鉴权 / 限流 │
└────────────────────────┬────────────────────────────┘
│
┌────────────────────────▼────────────────────────────┐
│ 核心层(Payment Core) │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │订单 │ │路由 │ │幂等 │ │状态机 │ │通知 │ │
│ │管理 │ │引擎 │ │控制 │ │引擎 │ │中心 │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │对账 │ │风控 │ │
│ │引擎 │ │引擎 │ │
│ └──────┘ └──────┘ │
└────────────────────────┬────────────────────────────┘
│
┌────────────────────────▼────────────────────────────┐
│ 渠道层(Channel Adapters) │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │支付宝 │ │微信支付 │ │Stripe │ │PayPal │ │
│ │Adapter │ │Adapter │ │Adapter │ │Adapter │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
└─────────────────────────────────────────────────────┘
三层各自的职责:
- 接入层:对外暴露统一 API,业务系统只跟这一层打交道
- 核心层:所有渠道共享的公共逻辑——订单管理、状态流转、幂等控制、对账
- 渠道层:每个支付渠道的独有逻辑——签名方式、参数格式、回调解析
关键原则:业务系统永远不直接调渠道 API。哪天要换渠道,只需要改渠道层,上面两层不动。
四、统一 API:让业务系统只学一套接口
以前小陈的业务系统里散落着各种渠道特有的调用:
# 以前——到处都是渠道特有代码
if channel == "alipay":
result = alipay_client.trade_precreate(out_trade_no=order_id, ...)
elif channel == "wechat":
result = wechat_client.native_pay(out_trade_no=order_id, ...)
elif channel == "stripe":
result = stripe.PaymentIntent.create(amount=amount, ...)
elif channel == "paypal":
result = paypal_create_order(amount=amount, ...)
现在,业务系统只需要这样调:
4.1 统一下单
POST /api/v1/payments
{
"order_id": "ORD_20260403001", # 商户订单号(幂等键)
"amount": 9999, # 金额,统一用最小单位(分)
"currency": "CNY", # 币种
"channel": "wechat_native", # 支付渠道
"subject": "Premium Plan", # 商品描述
"notify_url": "https://...", # 回调地址(可选,有默认值)
"extra": { # 渠道特有参数(可选)
"openid": "oUpF8..." # 比如微信 JSAPI 需要 openid
}
}
响应也是统一的:
{
"payment_id": "PAY_xxx",
"channel_order_id": "wx_xxx",
"status": "PENDING",
"credential": {
"qr_code": "weixin://..."
}
}
credential 字段是前端拉起支付需要的凭证——微信是二维码链接,Stripe 是 client_secret,PayPal 是 approve_url。前端根据渠道类型解析即可。
4.2 统一查询、退款和关单
# 查询
GET /api/v1/payments/{payment_id}
# 退款
POST /api/v1/refunds
{
"payment_id": "PAY_xxx",
"amount": 5000, # 退 50 元(分)
"reason": "用户申请退款"
}
# 关单(超时未支付时主动关闭)
POST /api/v1/payments/{payment_id}/cancel
注意一个关键设计:金额统一用"分"。支付宝和 PayPal 用"元",微信和 Stripe 用"分",网关内部统一用分存储,在渠道适配器里做转换。这消除了最常见的金额计算 bug。
五、渠道适配器:策略模式让差异各归各位
这是整个网关最精妙的部分。小陈用了经典的「策略模式」——定义一个统一的适配器接口,每个渠道各自实现。
5.1 适配器基类
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
class PaymentStatus(Enum):
PENDING = "PENDING"
PAID = "PAID"
FAILED = "FAILED"
CLOSED = "CLOSED"
REFUNDING = "REFUNDING"
REFUNDED = "REFUNDED"
@dataclass
class PaymentRequest:
order_id: str
amount: int # 统一用分
currency: str
subject: str
notify_url: str
extra: dict = None
@dataclass
class PaymentResponse:
channel_order_id: str
status: PaymentStatus
credential: dict # 前端拉起支付的凭证
class PaymentAdapter(ABC):
"""支付渠道适配器基类——所有渠道必须实现这四个方法"""
@abstractmethod
async def create_payment(self, req: PaymentRequest) -> PaymentResponse:
"""创建支付"""
@abstractmethod
async def query_payment(self, channel_order_id: str) -> PaymentStatus:
"""查询支付状态"""
@abstractmethod
async def refund(self, channel_order_id: str,
amount: int, reason: str) -> dict:
"""退款"""
@abstractmethod
async def verify_notify(self, headers: dict,
body: bytes) -> dict:
"""验证并解析回调通知"""
四个方法,四个渠道,形成了一个 4×4 的矩阵。每个格子里是各渠道的独有逻辑,但格子外面的世界看到的都是同一个接口。
5.2 支付宝适配器
class AlipayAdapter(PaymentAdapter):
def __init__(self, client):
self.client = client # 支付宝 SDK 客户端
async def create_payment(self, req: PaymentRequest) -> PaymentResponse:
biz_content = {
"out_trade_no": req.order_id,
"total_amount": str(req.amount / 100), # 分 → 元(支付宝要元)
"subject": req.subject,
}
result = self.client.api_alipay_trade_precreate(
biz_content=biz_content,
notify_url=req.notify_url,
)
return PaymentResponse(
channel_order_id=result.get("trade_no", ""),
status=PaymentStatus.PENDING,
credential={"qr_code": result["qr_code"]},
)
async def verify_notify(self, headers, body) -> dict:
params = parse_form(body) # 支付宝回调是 form 格式
if not self.client.verify(params, params.pop("sign")):
raise ValueError("签名验证失败")
return {
"order_id": params["out_trade_no"],
"channel_order_id": params["trade_no"],
"amount": int(float(params["total_amount"]) * 100), # 元 → 分
"status": PaymentStatus.PAID,
}
def success_response(self):
return "success" # 支付宝要求返回纯文本 "success"
5.3 Stripe 适配器
class StripeAdapter(PaymentAdapter):
async def create_payment(self, req: PaymentRequest) -> PaymentResponse:
intent = stripe.PaymentIntent.create(
amount=req.amount, # Stripe 也用分,无需转换
currency=req.currency.lower(),
metadata={"order_id": req.order_id},
)
return PaymentResponse(
channel_order_id=intent.id,
status=PaymentStatus.PENDING,
credential={"client_secret": intent.client_secret},
)
async def verify_notify(self, headers, body) -> dict:
sig = headers.get("stripe-signature")
event = stripe.Webhook.construct_event(body, sig, WEBHOOK_SECRET)
intent = event["data"]["object"]
return {
"order_id": intent["metadata"]["order_id"],
"channel_order_id": intent["id"],
"amount": intent["amount"], # 已经是分
"status": PaymentStatus.PAID
if event["type"] == "payment_intent.succeeded"
else PaymentStatus.FAILED,
}
def success_response(self):
return {"status": "ok"} # Stripe 返回 200 即可
微信支付和 PayPal 的适配器同理,各自处理自己的签名和格式差异。关键是:核心层完全不需要知道这些差异。
5.4 路由注册
class PaymentRouter:
"""支付渠道路由器"""
def __init__(self):
self._adapters: dict[str, PaymentAdapter] = {}
def register(self, channel: str, adapter: PaymentAdapter):
self._adapters[channel] = adapter
def get_adapter(self, channel: str) -> PaymentAdapter:
adapter = self._adapters.get(channel)
if not adapter:
raise ValueError(f"不支持的支付渠道: {channel}")
return adapter
# 启动时注册所有渠道
router = PaymentRouter()
router.register("alipay_native", AlipayAdapter(alipay_client))
router.register("wechat_native", WechatAdapter(wechat_client))
router.register("stripe_card", StripeAdapter())
router.register("paypal", PayPalAdapter(paypal_config))
将来要加新渠道(比如 Apple Pay、Google Pay),只需要:
- 写一个新的
Adapter实现四个方法 router.register("apple_pay", ApplePayAdapter(...))
核心层和接入层的代码 一行都不用改。
六、订单状态机:让状态流转有章可循
支付订单的状态不是随意变化的。小陈见过最离谱的 bug 是:一笔已经退款成功的订单,因为一个延迟到达的支付回调,又被标记成了"已支付"。
为了杜绝这种情况,需要一个严格的状态机:
创建订单
│
▼
┌───────────┐
│ PENDING │ ─── 超时 ──→ CLOSED
└─────┬─────┘
│
支付成功/失败
┌─────┴─────┐
▼ ▼
┌─────────┐ ┌─────────┐
│ PAID │ │ FAILED │
└────┬────┘ └─────────┘
│
申请退款
▼
┌──────────┐
│ REFUNDING │
└────┬─────┘
│
退款成功/失败
┌────┴─────┐
▼ ▼
┌──────────┐ ┌────────┐
│ REFUNDED │ │ PAID │ (退款失败,回到 PAID)
└──────────┘ └────────┘
代码实现:
VALID_TRANSITIONS = {
PaymentStatus.PENDING: [PaymentStatus.PAID,
PaymentStatus.FAILED,
PaymentStatus.CLOSED],
PaymentStatus.PAID: [PaymentStatus.REFUNDING],
PaymentStatus.REFUNDING: [PaymentStatus.REFUNDED,
PaymentStatus.PAID], # 退款失败回到 PAID
PaymentStatus.FAILED: [], # 终态
PaymentStatus.CLOSED: [], # 终态
PaymentStatus.REFUNDED: [], # 终态
}
def transition(current: PaymentStatus, target: PaymentStatus):
if target not in VALID_TRANSITIONS.get(current, []):
raise ValueError(
f"非法状态流转: {current.value} → {target.value}"
)
return target
有了这个状态机,前面说的"延迟回调覆盖退款"的 bug 就不可能发生了——REFUNDED → PAID 不在合法转换列表里,直接抛异常。
七、幂等性:同一件事只做一次
支付系统里最危险的事情不是"支付失败",而是"支付了两次"。用户点了两次按钮、回调重复发了三次、网络超时重试——任何一种情况都可能导致重复扣款。
小陈的方案是 分布式锁 + 幂等键:
import redis
r = redis.Redis()
async def create_payment_idempotent(order_id: str, channel: str,
amount: int):
# 幂等键 = 订单号 + 渠道 + 金额
idem_key = f"pay:idem:{order_id}:{channel}:{amount}"
# 1. 尝试获取分布式锁(防并发重复请求)
lock = r.lock(f"lock:{idem_key}", timeout=10)
if not lock.acquire(blocking_timeout=3):
raise Exception("请求处理中,请稍候")
try:
# 2. 检查是否已有支付记录
existing = await get_payment_by_order(order_id)
if existing:
return existing # 直接返回已有结果,不重复创建
# 3. 首次请求,真正创建支付
result = await do_create_payment(order_id, channel, amount)
return result
finally:
lock.release()
同样的思路也应用在回调处理上:
@app.post("/api/v1/notify/{channel}")
async def unified_notify(channel: str, request: Request):
adapter = router.get_adapter(channel)
headers = dict(request.headers)
body = await request.body()
# 1. 让适配器验签 + 解析(每个渠道的签名逻辑不同,但输出格式统一)
result = await adapter.verify_notify(headers, body)
# 2. 幂等检查:已支付的订单不重复处理
payment = await get_payment(result["order_id"])
if payment.status == PaymentStatus.PAID:
return adapter.success_response() # 直接返回成功
# 3. 状态机校验 + 更新
transition(payment.status, result["status"])
await update_payment(payment.id, result)
# 4. 通知业务系统(异步,不阻塞回调响应)
await notify_merchant(payment)
return adapter.success_response()
这段代码把「验签」「幂等」「状态机」「通知」串成了一条清晰的管道。不管是哪个渠道的回调进来,走的都是这一条路。
八、补偿机制:为"不确定性"兜底
支付系统有一个残酷的现实:你永远不能假设网络是可靠的。回调可能丢失,API 可能超时,数据库可能短暂不可用。所以需要多层补偿:
8.1 定时轮询
async def poll_pending_orders():
"""每 30 秒检查一次 PENDING 超过 5 分钟的订单"""
pending_orders = await get_orders_by_status(
status=PaymentStatus.PENDING,
older_than_minutes=5
)
for order in pending_orders:
adapter = router.get_adapter(order.channel)
real_status = await adapter.query_payment(order.channel_order_id)
if real_status != order.status:
transition(order.status, real_status)
await update_payment(order.id, {"status": real_status})
await notify_merchant(order)
这是"双保险"策略:即使回调丢了,轮询也能补上。支付行业的潜规则是——不信任任何单一通知机制。
8.2 通知重试
当网关需要通知业务系统时,也可能失败。小陈用了指数退避重试:
async def notify_merchant_with_retry(payment, max_retries=8):
"""通知业务系统,失败时指数退避重试"""
for attempt in range(max_retries):
try:
resp = await http_post(payment.notify_url, payment.to_dict())
if resp.status_code == 200:
return # 通知成功
except Exception as e:
pass
# 指数退避:1s → 2s → 4s → 8s → 16s → 32s → 64s → 128s
await asyncio.sleep(2 ** attempt)
# 所有重试都失败,标记待人工处理
await mark_notify_failed(payment.id)
8.3 日终对账
每日凌晨 2:00 ──→ 下载各渠道账单文件
│
▼
逐笔与本地订单比对
┌──────┴──────┐
▼ ▼
金额/状态一致 发现差异
│ │
▼ ▼
标记对平 记录差异明细
├── 本地有渠道无 → 可能是测试单或关单
├── 渠道有本地无 → 严重!需补录
└── 金额不一致 → 严重!需人工核查
对账是支付系统的最后一道防线。 前面的幂等、状态机、回调处理做得再好,也不能保证 100% 正确。对账就是那个每天帮你"查缺补漏"的守门员。
九、完整的下单流程:走一遍
让我们以一笔微信支付为例,走完整个统一网关的流程:
用户在收银台选择"微信支付" → 点击"确认支付"
│
▼
① 业务系统调用统一 API
POST /api/v1/payments
{ "order_id": "ORD001", "amount": 9999, "channel": "wechat_native" }
│
▼
② 接入层:参数校验、鉴权、限流 ✓
│
▼
③ 核心层:
→ 幂等检查(这个订单号下过单吗?没有,继续)
→ 创建网关订单(状态:PENDING)
→ 路由引擎(channel = "wechat_native" → WechatAdapter)
│
▼
④ 渠道层(WechatAdapter):
→ 拼装微信 Native 下单参数
→ RSA-SHA256 签名
→ 调用微信 API,获取二维码链接
│
▼
⑤ 返回给业务系统:
{ "payment_id": "PAY_xxx", "credential": {"qr_code": "weixin://..."} }
│
▼
⑥ 前端展示二维码,用户扫码支付
│
▼
⑦ 微信服务器发送回调到统一回调入口:
POST /api/v1/notify/wechat_native
│
▼
⑧ 核心层:
→ WechatAdapter.verify_notify() 验签 + 解析
→ 幂等检查(已支付?没有,继续)
→ 状态机:PENDING → PAID ✓
→ 更新订单状态
→ 异步通知业务系统
→ 返回 {"code": "SUCCESS"} 给微信
│
▼
⑨ 业务系统收到通知 → 发货 / 开通服务
整个过程中,业务系统只和接入层打交道,完全不知道底层是微信支付还是 Stripe。如果哪天要把微信支付换成另一个渠道,业务系统的代码 一行都不用改。
十、什么时候该建统一网关?
小陈的经验总结:
你处于什么阶段?
│
├─── MVP / 初创期(1 个渠道)
│ └──→ 直接用渠道 SDK,别过度设计
│ 投入:1~2 天
│
├─── 成长期(2~3 个渠道)
│ └──→ 简单统一层 + 直连渠道
│ 开始抽象公共逻辑,但不用做得太重
│ 投入:1~2 周
│
└─── 规模化(4+ 渠道 / 多业务线)
└──→ 自建统一支付网关(本文方案)
三层架构 + 状态机 + 幂等 + 对账
投入:1~2 月
如果你的团队人手有限,也可以考虑现有的开源或商业方案:
| 方案 | 语言 | 特点 | 适合 |
|---|---|---|---|
| 自建(本文方案) | 任意 | 完全可控,按需定制 | 中大型企业,支付是核心业务 |
| Jeepay | Java | 开源聚合支付,支持支付宝/微信 | 国内中小型 |
| PayPal Braintree | 多语言 SDK | 国际化,聚合卡支付 + PayPal | 纯出海业务 |
| Ping++ | 多语言 SDK | 国内老牌聚合支付 SaaS | 想快速接入不想自建 |
| Stripe Connect | 多语言 SDK | 平台型支付(分账) | 多边市场、平台经济 |
十一、踩坑清单:小陈的血泪经验
经历了三个月的实战,小陈总结了这些教训,希望后来人少走弯路:
1. 签名验证失败
- 原因:参数排序错误 / 编码问题 / 密钥不匹配
- 教训:用官方 SDK,不要自己拼签名串。 小陈在微信支付上自己拼签名串,调了两天才发现是 URL encode 的规则不一样
2. 回调收不到
- 原因:回调地址不是公网 HTTPS / 处理超过 5 秒超时了
- 教训:回调逻辑要轻量化——收到就存库,重活放异步队列。用 ngrok 等内网穿透工具在开发阶段调试
3. 金额精度问题
- 原因:支付宝用元、微信用分,来回转换时浮点数精度丢失
- 教训:内部一律用整数分存储。
$99.99 → 9999,展示时再除以 100
4. 重复支付
- 原因:用户连点两次,或者重试逻辑没做幂等
- 教训:订单号 + 数据库唯一约束 + 分布式锁,三保险
5. 退款的坑比支付还多
- 原因:有的渠道同步返回结果,有的异步通知;退款金额不能超过原始金额;部分退款后再退款的余额计算
- 教训:退款状态要独立管理(
REFUNDING → REFUNDED),不要和支付状态混在一起
6. 证书/密钥过期
- 原因:微信支付 API 证书、支付宝应用公钥证书都有有效期
- 教训:做证书有效期监控 + 自动告警,不要等到线上报错才发现
十二、全系列回顾
如果你是第一次看到这篇文章,建议从第 1 篇开始读。整个系列的阅读路线:
| 篇目 | 标题 | 你会了解到 |
|---|---|---|
| 第 1 篇 | 一笔订单的支付之旅:在线支付全景概览 | 支付行业全貌、四方模型、各渠道特点、如何选型 |
| 第 2 篇 | 一杯咖啡的扫码之旅:支付宝 & 微信支付 | 扫码支付原理、签名机制、回调处理、完整对接代码 |
| 第 3 篇 | 一件跨境商品的卡支付之旅:Stripe & 信用卡 | Payment Intents、3DS 验证、PCI DSS 合规、前后端代码 |
| 第 4 篇 | 一位海外买家的安全支付之旅:PayPal | OAuth 2.0、Smart Buttons、争议保护、Webhook |
| 第 5 篇 | 当四条河流汇入一片海:统一支付网关(本文) | 三层架构、策略模式、状态机、幂等、对账补偿 |
前瞻:当 AI Agent 开始代替人类做消费决策时,支付体系将迎来又一次范式变革——从"人操作支付"到"Agent 自主支付"。关于 AI Agent 支付的深度分析,可以阅读本系列的姊妹篇:当 AI Agent 接管你的钱包 和 x402 协议深度解析。
参考来源
- Martin Fowler: Patterns of Enterprise Application Architecture
- Jeepay 开源聚合支付
- Stripe Connect 文档
- PayPal Braintree 文档
- 支付宝开放平台
- 微信支付 API v3 文档
欢迎关注公众号 coft,获取更多深度技术文章。在线支付系列到此完结,如果这个系列对你有帮助,欢迎转发分享。

浙公网安备 33010602011771号