在线支付系列五统一支付网关架构设计这是在线支付系列的最后一篇。前四篇里我们分别搞定了支付宝、微信支付、Stripe 和 PayPal。如果你真的一篇一篇跟着做了下来你的代码库里大概率已经出现了这样的场景——一、噩梦的开始小陈是一家跨境电商的后端工程师。过去三个月他依次接入了支付宝、微信支付、Stripe 和 PayPal每接一个都花了一两周。他觉得自己挺厉害的——四种支付全搞定了。直到有一天早上他打开了工作群产品经理支付宝回调好像有问题昨晚有几笔订单没到账 运营微信退款怎么还没处理 老板PayPal 那边有个争议需要处理你看看 财务这个月的对账差了 3 笔帮忙查查是哪个渠道的小陈盯着屏幕发呆。四套支付渠道四种签名机制四种回调格式四套退款逻辑四份对账文件。每次改一个公共逻辑比如加个日志字段他得改四个地方。每次排查问题他得在四套代码之间来回跳。他突然意识到接入四个渠道不是终点而是噩梦的开始。这正是「统一支付网关」要解决的问题。二、思考到底哪些东西可以统一在动手之前小陈做了一张表把四个渠道的差异摊开来看维度支付宝微信支付StripePayPal金额单位元字符串99.99分整数9999分整数9999元字符串99.99签名方式RSA2SHA-256HMAC-SHA256HMAC-SHA256调 API 验签回调格式form 表单JSON XMLJSONJSON回调响应返回success返回{code:SUCCESS}返回 HTTP 200返回 HTTP 200退款接口同步返回结果异步回调通知同步返回结果同步返回结果认证方式应用私钥签名商户证书 API KeySecret KeyOAuth 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让业务系统只学一套接口以前小陈的业务系统里散落着各种渠道特有的调用# 以前——到处都是渠道特有代码ifchannelalipay:resultalipay_client.trade_precreate(out_trade_noorder_id,...)elifchannelwechat:resultwechat_client.native_pay(out_trade_noorder_id,...)elifchannelstripe:resultstripe.PaymentIntent.create(amountamount,...)elifchannelpaypal:resultpaypal_create_order(amountamount,...)现在业务系统只需要这样调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_secretPayPal 是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 适配器基类fromabcimportABC,abstractmethodfromdataclassesimportdataclassfromenumimportEnumclassPaymentStatus(Enum):PENDINGPENDINGPAIDPAIDFAILEDFAILEDCLOSEDCLOSEDREFUNDINGREFUNDINGREFUNDEDREFUNDEDdataclassclassPaymentRequest:order_id:stramount:int# 统一用分currency:strsubject:strnotify_url:strextra:dictNonedataclassclassPaymentResponse:channel_order_id:strstatus:PaymentStatus credential:dict# 前端拉起支付的凭证classPaymentAdapter(ABC):支付渠道适配器基类——所有渠道必须实现这四个方法abstractmethodasyncdefcreate_payment(self,req:PaymentRequest)-PaymentResponse:创建支付abstractmethodasyncdefquery_payment(self,channel_order_id:str)-PaymentStatus:查询支付状态abstractmethodasyncdefrefund(self,channel_order_id:str,amount:int,reason:str)-dict:退款abstractmethodasyncdefverify_notify(self,headers:dict,body:bytes)-dict:验证并解析回调通知四个方法四个渠道形成了一个 4×4 的矩阵。每个格子里是各渠道的独有逻辑但格子外面的世界看到的都是同一个接口。5.2 支付宝适配器classAlipayAdapter(PaymentAdapter):def__init__(self,client):self.clientclient# 支付宝 SDK 客户端asyncdefcreate_payment(self,req:PaymentRequest)-PaymentResponse:biz_content{out_trade_no:req.order_id,total_amount:str(req.amount/100),# 分 → 元支付宝要元subject:req.subject,}resultself.client.api_alipay_trade_precreate(biz_contentbiz_content,notify_urlreq.notify_url,)returnPaymentResponse(channel_order_idresult.get(trade_no,),statusPaymentStatus.PENDING,credential{qr_code:result[qr_code]},)asyncdefverify_notify(self,headers,body)-dict:paramsparse_form(body)# 支付宝回调是 form 格式ifnotself.client.verify(params,params.pop(sign)):raiseValueError(签名验证失败)return{order_id:params[out_trade_no],channel_order_id:params[trade_no],amount:int(float(params[total_amount])*100),# 元 → 分status:PaymentStatus.PAID,}defsuccess_response(self):returnsuccess# 支付宝要求返回纯文本 success5.3 Stripe 适配器classStripeAdapter(PaymentAdapter):asyncdefcreate_payment(self,req:PaymentRequest)-PaymentResponse:intentstripe.PaymentIntent.create(amountreq.amount,# Stripe 也用分无需转换currencyreq.currency.lower(),metadata{order_id:req.order_id},)returnPaymentResponse(channel_order_idintent.id,statusPaymentStatus.PENDING,credential{client_secret:intent.client_secret},)asyncdefverify_notify(self,headers,body)-dict:sigheaders.get(stripe-signature)eventstripe.Webhook.construct_event(body,sig,WEBHOOK_SECRET)intentevent[data][object]return{order_id:intent[metadata][order_id],channel_order_id:intent[id],amount:intent[amount],# 已经是分status:PaymentStatus.PAIDifevent[type]payment_intent.succeededelsePaymentStatus.FAILED,}defsuccess_response(self):return{status:ok}# Stripe 返回 200 即可微信支付和 PayPal 的适配器同理各自处理自己的签名和格式差异。关键是核心层完全不需要知道这些差异。5.4 路由注册classPaymentRouter:支付渠道路由器def__init__(self):self._adapters:dict[str,PaymentAdapter]{}defregister(self,channel:str,adapter:PaymentAdapter):self._adapters[channel]adapterdefget_adapter(self,channel:str)-PaymentAdapter:adapterself._adapters.get(channel)ifnotadapter:raiseValueError(f不支持的支付渠道:{channel})returnadapter# 启动时注册所有渠道routerPaymentRouter()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],# 退款失败回到 PAIDPaymentStatus.FAILED:[],# 终态PaymentStatus.CLOSED:[],# 终态PaymentStatus.REFUNDED:[],# 终态}deftransition(current:PaymentStatus,target:PaymentStatus):iftargetnotinVALID_TRANSITIONS.get(current,[]):raiseValueError(f非法状态流转:{current.value}→{target.value})returntarget有了这个状态机前面说的延迟回调覆盖退款的 bug 就不可能发生了——REFUNDED → PAID不在合法转换列表里直接抛异常。七、幂等性同一件事只做一次支付系统里最危险的事情不是支付失败而是支付了两次。用户点了两次按钮、回调重复发了三次、网络超时重试——任何一种情况都可能导致重复扣款。小陈的方案是分布式锁 幂等键importredis rredis.Redis()asyncdefcreate_payment_idempotent(order_id:str,channel:str,amount:int):# 幂等键 订单号 渠道 金额idem_keyfpay:idem:{order_id}:{channel}:{amount}# 1. 尝试获取分布式锁防并发重复请求lockr.lock(flock:{idem_key},timeout10)ifnotlock.acquire(blocking_timeout3):raiseException(请求处理中请稍候)try:# 2. 检查是否已有支付记录existingawaitget_payment_by_order(order_id)ifexisting:returnexisting# 直接返回已有结果不重复创建# 3. 首次请求真正创建支付resultawaitdo_create_payment(order_id,channel,amount)returnresultfinally:lock.release()同样的思路也应用在回调处理上app.post(/api/v1/notify/{channel})asyncdefunified_notify(channel:str,request:Request):adapterrouter.get_adapter(channel)headersdict(request.headers)bodyawaitrequest.body()# 1. 让适配器验签 解析每个渠道的签名逻辑不同但输出格式统一resultawaitadapter.verify_notify(headers,body)# 2. 幂等检查已支付的订单不重复处理paymentawaitget_payment(result[order_id])ifpayment.statusPaymentStatus.PAID:returnadapter.success_response()# 直接返回成功# 3. 状态机校验 更新transition(payment.status,result[status])awaitupdate_payment(payment.id,result)# 4. 通知业务系统异步不阻塞回调响应awaitnotify_merchant(payment)returnadapter.success_response()这段代码把「验签」「幂等」「状态机」「通知」串成了一条清晰的管道。不管是哪个渠道的回调进来走的都是这一条路。八、补偿机制为不确定性兜底支付系统有一个残酷的现实你永远不能假设网络是可靠的。回调可能丢失API 可能超时数据库可能短暂不可用。所以需要多层补偿8.1 定时轮询asyncdefpoll_pending_orders():每 30 秒检查一次 PENDING 超过 5 分钟的订单pending_ordersawaitget_orders_by_status(statusPaymentStatus.PENDING,older_than_minutes5)fororderinpending_orders:adapterrouter.get_adapter(order.channel)real_statusawaitadapter.query_payment(order.channel_order_id)ifreal_status!order.status:transition(order.status,real_status)awaitupdate_payment(order.id,{status:real_status})awaitnotify_merchant(order)这是双保险策略即使回调丢了轮询也能补上。支付行业的潜规则是——不信任任何单一通知机制。8.2 通知重试当网关需要通知业务系统时也可能失败。小陈用了指数退避重试asyncdefnotify_merchant_with_retry(payment,max_retries8):通知业务系统失败时指数退避重试forattemptinrange(max_retries):try:respawaithttp_post(payment.notify_url,payment.to_dict())ifresp.status_code200:return# 通知成功exceptExceptionase:pass# 指数退避1s → 2s → 4s → 8s → 16s → 32s → 64s → 128sawaitasyncio.sleep(2**attempt)# 所有重试都失败标记待人工处理awaitmark_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 月如果你的团队人手有限也可以考虑现有的开源或商业方案方案语言特点适合自建本文方案任意完全可控按需定制中大型企业支付是核心业务JeepayJava开源聚合支付支持支付宝/微信国内中小型PayPal Braintree多语言 SDK国际化聚合卡支付 PayPal纯出海业务Ping多语言 SDK国内老牌聚合支付 SaaS想快速接入不想自建Stripe Connect多语言 SDK平台型支付分账多边市场、平台经济十一、踩坑清单小陈的血泪经验经历了三个月的实战小陈总结了这些教训希望后来人少走弯路1. 签名验证失败原因参数排序错误 / 编码问题 / 密钥不匹配教训用官方 SDK不要自己拼签名串。小陈在微信支付上自己拼签名串调了两天才发现是 URL encode 的规则不一样2. 回调收不到原因回调地址不是公网 HTTPS / 处理超过 5 秒超时了教训回调逻辑要轻量化——收到就存库重活放异步队列。用 ngrok 等内网穿透工具在开发阶段调试3. 金额精度问题原因支付宝用元、微信用分来回转换时浮点数精度丢失教训内部一律用整数分存储。$99.99 → 9999展示时再除以 1004. 重复支付原因用户连点两次或者重试逻辑没做幂等教训订单号 数据库唯一约束 分布式锁三保险5. 退款的坑比支付还多原因有的渠道同步返回结果有的异步通知退款金额不能超过原始金额部分退款后再退款的余额计算教训退款状态要独立管理REFUNDING → REFUNDED不要和支付状态混在一起6. 证书/密钥过期原因微信支付 API 证书、支付宝应用公钥证书都有有效期教训做证书有效期监控 自动告警不要等到线上报错才发现十二、全系列回顾如果你是第一次看到这篇文章建议从第 1 篇开始读。整个系列的阅读路线篇目标题你会了解到第 1 篇一笔订单的支付之旅在线支付全景概览支付行业全貌、四方模型、各渠道特点、如何选型第 2 篇一杯咖啡的扫码之旅支付宝 微信支付扫码支付原理、签名机制、回调处理、完整对接代码第 3 篇一件跨境商品的卡支付之旅Stripe 信用卡Payment Intents、3DS 验证、PCI DSS 合规、前后端代码第 4 篇一位海外买家的安全支付之旅PayPalOAuth 2.0、Smart Buttons、争议保护、Webhook第 5 篇当四条河流汇入一片海统一支付网关本文三层架构、策略模式、状态机、幂等、对账补偿前瞻当 AI Agent 开始代替人类做消费决策时支付体系将迎来又一次范式变革——从人操作支付到Agent 自主支付。关于 AI Agent 支付的深度分析可以阅读本系列的姊妹篇当 AI Agent 接管你的钱包 和 x402 协议深度解析。参考来源Martin Fowler: Patterns of Enterprise Application ArchitectureJeepay 开源聚合支付Stripe Connect 文档PayPal Braintree 文档支付宝开放平台微信支付 API v3 文档欢迎关注公众号coft获取更多深度技术文章。在线支付系列到此完结如果这个系列对你有帮助欢迎转发分享。