如何设计统一支付网关架构以应对在线支付系列挑战?

摘要:在线支付系列(五):统一支付网关架构设计 这是在线支付系列的最后一篇。前四篇里,我们分别搞定了支付宝、微信支付、Stripe 和 PayPal。如果你真的一篇一篇跟着做了下来,你的代码库里大概率已经出现了这样的场景—— 一、噩梦的开始 小陈
在线支付系列(五):统一支付网关架构设计 这是在线支付系列的最后一篇。前四篇里,我们分别搞定了支付宝、微信支付、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,获取更多深度技术文章。在线支付系列到此完结,如果这个系列对你有帮助,欢迎转发分享。