在线支付系列(五):统一支付网关架构设计

在线支付系列(五):统一支付网关架构设计

这是在线支付系列的最后一篇。前四篇里,我们分别搞定了支付宝、微信支付、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),只需要:

  1. 写一个新的 Adapter 实现四个方法
  2. 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 协议深度解析


参考来源


欢迎关注公众号 coft,获取更多深度技术文章。在线支付系列到此完结,如果这个系列对你有帮助,欢迎转发分享。

posted @ 2026-04-04 20:49  warm3snow  阅读(10)  评论(0)    收藏  举报