土法炼钢兴趣小组的算法知识备份

【金融科技工程】08:支付宝、微信支付接入:服务商模式、预授权、分账、退款、对账

文章导航

分类入口
architecturefintech
标签入口
#alipay#wechatpay#isv#profit-sharing#pre-auth#refund#reconciliation#apiv3#rsa2

目录

本文是【金融科技工程】系列第 08 篇。上一篇《卡组织收单链路》讲了银联、Visa、Mastercard 与 ISO 8583/20022 的报文世界;在中国,真正跑在用户手机里的 C 端钱包是支付宝与微信支付。本文聚焦”接入”本身:从商户进件、签名体系、支付场景,到预授权、分账、退款、对账、异步通知、沙箱与灰度,最后落到服务商模式下最容易踩的二清红线。

一、读者画像与本文边界

本文预设读者画像是中小型平台或 SaaS 的后端工程师 / 架构师:公司已经(或即将)提供 C 端收款能力,需要把支付宝和微信支付接入到自己的交易系统里。如果你是:

那么这篇文章为你写的。

本文不覆盖的内容:

本文用到的术语与缩写在首次出现时给出中英对照,例如:开放平台(Open Platform)、服务商(Independent Software Vendor,ISV)、特约商户(Sub-Merchant)、商户号(Merchant ID,mch_id)、应用标识(AppID)、预授权(Pre-Authorization)、分账(Profit Sharing)、对账单(Reconciliation Bill)。


二、两条接入路径:直连商户 vs 服务商模式

“接入支付宝 / 微信支付”听起来只有一种做法,其实有两条完全不同的路径,后续的签约主体、资金流向、合规边界都不同。

2.1 直连商户(Direct Merchant)

直连商户路径:

这条路径适合自营业务:一家公司,一套营业执照,流水归自己。工程上最省事:一套密钥、一套 AppID、一套回调域名。

2.2 服务商模式(ISV / Partner Mode)

服务商模式路径:

服务商模式适合:

2.3 两条路的合规关键差异:二清

二清(Second Clearing)是中国人民银行禁止的:没有支付牌照的机构,不得从事资金归集后再向商户结算的活动。简单翻译:钱不能先进你(平台)的账户,再由你转给商户——除非你持牌。

这就决定了:

模式 钱走哪里 是否二清 代表玩法
直连 钱包 → 商户对公 自营电商
合规服务商 钱包 → 特约商户对公(平台收分润) 美团、滴滴、收钱吧
违规”大商户” 钱包 → 平台账户 → 再打给商户 是,违规 被监管点名的聚合支付
平台持牌 钱包 → 平台备付金 → 商户 否(因为有牌照) 支付宝、微信自身、拉卡拉

所以工程上,服务商模式最核心的设计是:分账接收方、清算账户一定要是特约商户自己的对公账户,平台不碰资金本金,只分润。第 11 篇讲清结算时会再展开。


三、支付宝开放平台:应用、签名、能力

3.1 应用(Application)与 AppID

登录 Alipay Open Platform(open.alipay.com),创建一个”网页/移动应用”,获得 AppID(16 位数字)。所有后续调用都要带 AppID——它标识你这套业务,而非商户。

一个 AppID 可以签约多种能力(Capability):

每个能力必须单独签约,在”产品中心”申请开通,一般需要营业执照、行业资质。

3.2 RSA2 签名

支付宝 Open API 全部使用非对称签名:商户生成 RSA 2048 密钥对,把公钥上传到开放平台,平台下发”支付宝公钥”(平台自己的公钥)。

签名算法是 SHA256WithRSA(也叫 RSA2,老的 RSA1 用 SHA1,已经不允许新商户使用)。

请求签名流程:

  1. 收集所有业务参数 + 公共参数(app_id、method、charset、sign_type=RSA2、timestamp、version、biz_content 等);
  2. 按参数名字典序k1=v1&k2=v2
  3. 用商户私钥 SHA256WithRSA 签名,Base64 编码,作为 sign 参数;
  4. 作为 form-urlencoded POST 到 https://openapi.alipay.com/gateway.do

响应验签

3.3 应用证书模式

2020 年后支付宝推应用证书 + 支付宝根证书模式(代替公钥模式),为了兼容”一个商户绑多个应用”,证书文件包含证书序列号(SN),签名时带上 app_cert_snalipay_root_cert_sn 两个参数。新接入必须走证书模式。

3.4 商户进件(服务商模式)

服务商模式下,你用间连商户进件 APIalipay.open.agent.createalipay.open.agent.facetoface.sign 等)提交材料:

提交后进入人工审核(1–3 工作日),通过后拿到 PID(Partner ID),后续交易带上 sys_service_provider_id(你的 ISV PID)+ 商户 PID,资金结算到商户。

3.5 常用 API 一览

API 用途
alipay.trade.create 统一收单下单(扫码、JSAPI 场景)
alipay.trade.precreate 预下单生成二维码(C 扫 B)
alipay.trade.pay 条码支付(B 扫 C)
alipay.trade.app.pay App 支付(原生 SDK 调起)
alipay.trade.wap.pay H5 支付
alipay.trade.page.pay PC 网站支付
alipay.trade.query 查询订单状态
alipay.trade.refund 退款
alipay.trade.close 关闭订单
alipay.fund.auth.order.freeze 预授权冻结
alipay.fund.auth.order.unfreeze 预授权解冻
alipay.trade.order.settle 分账
alipay.data.dataservice.bill.downloadurl.query 对账单下载

四、微信支付:V2、V3、mch_id、AppID 的交叉

微信支付这边是另一种复杂度:历史上存在 V2(pay.weixin.qq.com 的老 API)和 V3(api.mch.weixin.qq.com 的新 API)两套 API 并存,且支付场景与 AppID 类型绑死。

4.1 商户号(mch_id)与 AppID 的组合

微信支付的账户关系是一个让新接入者头痛的知识点:

一个 mch_id 可以关联多个 AppID(在商户平台”产品中心 - AppID 账号管理”里绑定),但每种支付场景要求 AppID 类型固定:

场景 AppID 类型 发起方
JSAPI 支付(公众号 H5) 公众号 AppID 微信浏览器内 H5
JSAPI 支付(小程序) 小程序 AppID 小程序 wx.requestPayment
App 支付 开放平台移动应用 AppID 原生 App SDK
H5 支付(外部浏览器) 公众号 AppID 微信外的 H5
Native 支付 公众号或开放平台 AppID PC 扫码
付款码支付(B 扫 C) 任意(以 mch_id 为主) 收银机扫用户付款码

openid 是用户在某个 AppID 下的唯一标识;JSAPI、小程序下单必须传 openid,所以下单前必须先完成授权登录拿到 openid。

4.2 V2 vs V3

V3 是官方主推,V2 仍可用但已进入维护期,新功能只上 V3(例如商家转账、合单支付、分账都基本 V3 优先)。实际工程中很多老系统仍是 V2,迁移 V3 是一个独立项目

4.3 V3 签名构造

V3 请求签名算法:

signature = RSA-SHA256(
  HTTP 方法 + "\n" +
  URL 路径(含 query) + "\n" +
  时间戳 + "\n" +
  随机串 + "\n" +
  请求体 + "\n",
  商户私钥
)

HTTP 头:

Authorization: WECHATPAY2-SHA256-RSA2048 \
  mchid="1900000001", \
  nonce_str="593BEC0C930BF1AFEB40B4A08C8FB242", \
  signature="...base64...", \
  timestamp="1554208460", \
  serial_no="商户证书序列号"

响应验签微信支付平台证书(注意:不是微信证书中心的 HTTPS 证书,是从 /v3/certificates 接口下载、用 APIv3 密钥 AES-GCM 解密后拿到的证书)。平台证书会轮换,SDK 要能自动拉新(每天一次)并在多张证书间按 Wechatpay-Serial 头选。

4.4 APIv3 密钥与平台证书

四把密钥一把不能少:漏了 APIv3 密钥,回调通知里的用户信息就解不开;漏了平台证书,回调签名就没法验。

4.5 常用 V3 API

API 路径
JSAPI 下单 POST /v3/pay/transactions/jsapi
Native 下单 POST /v3/pay/transactions/native
App 下单 POST /v3/pay/transactions/app
H5 下单 POST /v3/pay/transactions/h5
查询订单 GET /v3/pay/transactions/id/{transaction_id}
关闭订单 POST /v3/pay/transactions/out-trade-no/{out_trade_no}/close
退款申请 POST /v3/refund/domestic/refunds
退款查询 GET /v3/refund/domestic/refunds/{out_refund_no}
申请交易账单 GET /v3/bill/tradebill
申请资金账单 GET /v3/bill/fundflowbill
分账请求 POST /v3/profitsharing/orders
分账回退 POST /v3/profitsharing/return-orders
解冻剩余资金 POST /v3/profitsharing/orders/unfreeze

五、支付场景:扫码、JSAPI、App、H5、小程序、刷脸

5.1 C 扫 B vs B 扫 C

中国线下支付的术语容易绕:

两者资金都是用户付给商户,差别是发起设备。便利店、餐厅多用 B 扫 C(快,不需要用户操作),停车场、自助售货多用 C 扫 B(设备不用装扫码头)。

5.2 JSAPI(公众号 / 小程序内支付)

在微信内部浏览器或小程序里调起:

  1. 后端调 POST /v3/pay/transactions/jsapiopenid + 金额 + out_trade_no,拿到 prepay_id
  2. 后端再对 prepay_id 第二次签名,返回给前端 {appId, timeStamp, nonceStr, package: "prepay_id=xxx", signType: "RSA", paySign}
  3. 前端 wx.requestPayment(params)(小程序)或 WeixinJSBridge.invoke('getBrandWCPayRequest', params)(公众号)。

支付宝对应的是生活号 / 小程序 JSAPI,流程相似但用 alipay.trade.create + 前端 my.tradePay

5.3 App 支付

原生 App 集成微信/支付宝 SDK。后端下单拿到支付参数,透传给 App SDK,SDK 调起钱包 App,完成后回到商户 App,并不以回调结果为准——必须以异步通知 / 主动查单为准。

5.4 H5 支付

非微信、非支付宝的外部浏览器里支付,后端拿到一个跳转 URL,浏览器 302 到钱包唤起支付。H5 支付有Referer 防刷要求:商户域名必须在商户平台配置白名单,不然报 “mweb_url invalid”。

5.5 刷脸支付:蜻蜓、青蛙

支付宝”蜻蜓”、微信”青蛙”是刷脸支付硬件。工程上是 SDK(Android / 自研 Linux)跑在设备里,调用人脸识别云端,识别成功后用 face_id 替代付款码走正常支付。对接方主要是收银系统 ISV,本文不展开。


六、预授权:冻结、解冻、扣款

预授权是酒店、租车、共享充电宝等行业的刚需:用户下单时冻结一笔押金(不真实扣款),使用完毕后根据实际金额扣款解冻

6.1 支付宝预授权

两段式 API:

alipay.fund.auth.order.freeze      // 冻结
alipay.fund.auth.order.unfreeze    // 解冻(全部 or 部分)
alipay.trade.pay                   // 以冻结单 auth_no 转支付(从预授权转完成)

业务流程:

  1. 用户下单 → 调 alipay.fund.auth.order.freeze 冻结 500 元,拿到 auth_no
  2. 用户使用充电宝 6 小时 → 计费 18 元;
  3. 商户调 alipay.trade.payauth_no + amount=18,从冻结额度里扣 18 元,剩余 482 元自动解冻;
  4. 或者整单退单,调 alipay.fund.auth.order.unfreeze 解冻 500 元。

支付宝的冻结最长180 天

6.2 微信”担保交易”与预授权

微信支付没有通用的”预授权”对外开放(历史上做过,后来在部分行业定向开放),更多场景是走”担保交易”或订单确认收货模式。共享租赁行业一般通过”免密支付 / 分期免押”替代——用户授权一个免密协议,归还后再扣款。

如果你做酒店/租车,支付宝有现成预授权,微信要找行业方案(微信”租借服务”平台 + 芝麻信用免押的组合变种)。

6.3 状态机

stateDiagram-v2
    [*] --> 冻结中: freeze 成功
    冻结中 --> 部分扣款: pay(auth_no, 部分)
    冻结中 --> 全额解冻: unfreeze(全部)
    冻结中 --> 全额扣款: pay(auth_no, 全部)
    部分扣款 --> 剩余解冻: 自动
    部分扣款 --> 追加扣款: pay(auth_no, 再扣)
    全额解冻 --> [*]
    全额扣款 --> [*]
    剩余解冻 --> [*]

工程提醒:冻结额度可扣款次数在不同版本 API 下规则不同(早期只允许扣一次,现在支持多次扣款直到额度用完)。


七、分账:合单、接收方、T+1 与解冻

平台业务最常见需求:用户付 100 元,平台分 20 元,商户得 80 元,快递员得 5 元,怎么做?答案:分账(Profit Sharing),而不是自己”先收后付”(二清)。

7.1 分账接收方绑定

不管支付宝还是微信支付,分账前都要:

  1. 开通分账产品:服务商或商户申请分账能力;
  2. 添加分账接收方:接收方必须是同一家支付机构下的个人钱包或商户号;微信 V3 用 POST /v3/profitsharing/receivers/add,支付宝用 alipay.trade.royalty.relation.bind
  3. 接收方可以是个人(需要用户授权,支付宝点同意、微信关注某公众号)或商户

7.2 分账时机

7.3 微信分账 V3 示例请求体

{
  "transaction_id": "4208450740201411110007820472",
  "out_order_no": "P20260422001",
  "receivers": [
    {
      "type": "MERCHANT_ID",
      "account": "190001735",
      "amount": 2000,
      "description": "平台服务费"
    },
    {
      "type": "PERSONAL_OPENID",
      "account": "86693952",
      "amount": 500,
      "description": "配送员分润"
    }
  ],
  "unfreeze_unsplit": true
}

unfreeze_unsplit=true 表示分完剩下的直接解冻到原商户。否则需要再调一次 orders/unfreeze

7.4 分账回退

分账完成后的回退(Return)只能来自已分账的接收方,且只能退回到原支付商户。回退限额 = 已分金额;超过就报错。退款和回退是两码事:

7.5 合单支付

“合单”指一次支付金额分属多个子单,每个子单可以走不同商户。微信提供 combine 合单支付(POST /v3/combine-transactions/jsapi 等),典型场景是商城一单买 A、B 两家店的商品。合单后分账各自独立。


八、退款:全额、部分、跨年、挂起

8.1 退款基本规则

8.2 跨年退款

支付宝、微信支付对”跨年退款”有特殊规则:超过一年的订单退款成功率会下降,部分场景(银行侧已销账)需要走线下退款(客服介入)。工程上对老订单退款要加重试 + 工单兜底

8.3 退款异常与挂起

退款可能进入异常状态:

状态 原因 处理
PROCESSING 正在处理 10 分钟后查单
SUCCESS 成功 入账完成
CLOSED 关闭 重新发起
ABNORMAL 异常(银行退回失败等) 商户平台人工处理,一般退到”不可用余额

异常退款通常能在商户平台操作”退到余额”或”线下打款”,代码里一定要有兜底:每天扫描异常退款,推人工工单。

8.4 退款与分账的交互

前文提过:退款时会按比例追回分账。流程是:

  1. 商户发起退款;
  2. 微信/支付宝检查分账接收方余额;
  3. 已分账金额 / 订单金额 的比例从接收方余额扣回;
  4. 任何接收方余额不足 → 整笔退款失败,进入 ABNORMAL;
  5. 余额充足 → 退款成功,接收方账单里出现”分账回退”记录。

这就是为什么平台分到的钱不能立刻提现——必须保留至少 180 天余额以应对退款。


九、异步通知工程:签名、防重放、幂等、补单

支付结果以异步通知为准(不是前端的”支付成功”页面,也不是 SDK 回调)。工程上处理异步通知的四个核心点:签名校验、防重放、幂等、补单。

9.1 签名校验

验签失败一律丢弃,千万不要返回 200——不然攻击者会尝试伪造。

9.2 防重放

通知可能被中间人捕获后重放:

9.3 幂等

异步通知至少会收到一次,可能多次(超时重推)。处理逻辑:

BEGIN;
  SELECT * FROM payment WHERE out_trade_no = ? FOR UPDATE;
  IF status IN ('PAID', 'REFUNDED') THEN
    COMMIT; RETURN 200;  // 幂等忽略
  END IF;
  IF status = 'PENDING' THEN
    UPDATE payment SET status='PAID', paid_at=now(), 
                       channel_trade_no=? WHERE out_trade_no=? AND status='PENDING';
    // 写账务分录(见第 3 篇复式记账)
  END IF;
COMMIT;
RETURN 200 "SUCCESS";

锁的是 out_trade_no,不是业务订单号,因为同一业务订单可能因关单后重下导致多个 out_trade_no。

9.4 通知重试策略

两家钱包的重试间隔都是指数退避:

收到通知后必须返回约定的成功响应(支付宝返回文本 success,微信 V3 返回 {"code":"SUCCESS"} 并 200 状态码),任何非约定返回都会被当作失败并重推。

9.5 补单:主动查单

通知可能永久丢失(商户回调服务器宕机超过 25 小时)。不能依赖通知,必须有主动查单兜底:


十、对账单:业务账单、资金账单、三方对账

10.1 两类账单

两家钱包都提供两类账单:

账单类型 内容 用途
交易/业务账单 当天所有订单(含成功、已退款、已关闭),每笔一行 对商户自己的订单表
资金账单 当天所有资金变动(收款、退款、提现、手续费),每笔一行 对商户对公账户流水

交易账单的金额 ≠ 资金账单的金额——因为有手续费退款跨天分账等差异。正确对账方式是:

这叫三方对账(Three-way Reconciliation):订单表 ↔︎ 通道账单 ↔︎ 银行流水,第 23 篇会深入。

10.2 下载接口

10.3 入库与比对

典型 ETL 流水线:

01:00  下载 T-1 交易账单(支付宝、微信各一份)
01:10  解析 CSV → 插入 bill_raw 表
01:20  转换 → bill_normalized 表(统一 schema)
01:30  与 platform.orders 表 LEFT JOIN 跑 SQL 差异
01:40  差异入 diff_pending 表 + 邮件/告警
08:30  人工清差

常见差异类型:

10.4 账单下载时机

不要在 09:00 前重试——只会拿到”账单未生成”。


十一、沙箱与灰度

11.1 沙箱

沙箱的限制决定了你必须做真实小额灰度:

11.2 灰度

新通道、新版本上线用按用户的灰度:hash(user_id) % 100 < k,k 从 1 → 5 → 20 → 50 → 100。同时监控:

任何指标下降立即回滚。


十二、工程坑点清单

项目上线后最常见的踩坑,大致分这些类:

12.1 证书类

12.2 标识类

12.3 时序类

12.4 合规类

12.5 体验类


十三、时序图:用户扫码 → 小程序 → 微信 API → 商户后台 → 回调

下面这张图是小程序下单 + JSAPI 支付的完整时序,服务商模式下最常见的组合。

sequenceDiagram
    autonumber
    participant U as 用户
    participant MP as 小程序
    participant BE as 商户后端
    participant WX as 微信支付 V3
    participant SUB as 特约商户账户
    participant PF as 平台账户

    U->>MP: 点击"立即支付"
    MP->>BE: POST /order/create {goods, amount}
    BE->>BE: 生成 out_trade_no, 写订单表 (PENDING)
    BE->>WX: POST /v3/pay/transactions/jsapi<br/>sp_mchid, sub_mchid, openid, amount, notify_url
    WX-->>BE: {prepay_id}
    BE->>BE: 二次签名生成 paySign
    BE-->>MP: {appId, timeStamp, nonceStr, package, paySign}
    MP->>U: wx.requestPayment 调起收银台
    U->>WX: 输入密码/指纹确认
    WX->>SUB: 清算 (T+1),扣手续费
    WX->>PF: 平台分润暂冻结
    WX-->>MP: 支付成功
    MP->>BE: 轮询/通知前端跳转
    par 异步通知
        WX->>BE: POST notify_url (V3 签名)
        BE->>BE: 验签 → 幂等 → 更新订单 PAID → 写账务分录
        BE-->>WX: {"code":"SUCCESS"}
    and 主动查单兜底
        BE->>WX: GET /v3/pay/transactions/out-trade-no/{no}
        WX-->>BE: {trade_state: SUCCESS}
    end
    BE->>WX: POST /v3/profitsharing/orders<br/>分账给 PF (服务费) + 其他接收方
    WX->>PF: 划拨分账金额
    WX-->>BE: {order_id, state: PROCESSING}
    WX->>BE: 分账回调 (SUCCESS)
    BE->>BE: 写分账账务

十四、代码示例:Go 实现 JSAPI 下单 + V3 签名验签 + 分账

下面给一个最小可跑的 Go 片段(去掉错误处理细节),展示三个关键动作:V3 签名、JSAPI 下单、分账请求。生产代码建议直接用 github.com/wechatpay-apiv3/wechatpay-go(官方库)。

package wechatpay

import (
    "bytes"
    "crypto"
    "crypto/rand"
    "crypto/rsa"
    "crypto/sha256"
    "crypto/x509"
    "encoding/base64"
    "encoding/json"
    "encoding/pem"
    "fmt"
    "io"
    "net/http"
    "strconv"
    "time"
)

type Client struct {
    MchID       string          // 服务商 mch_id
    SerialNo    string          // 商户证书序列号
    PrivateKey  *rsa.PrivateKey // 商户 API 私钥
    APIv3Key    []byte          // APIv3 密钥(32 bytes)
    PlatformCerts map[string]*rsa.PublicKey // serial_no -> platform cert pubkey
}

// buildAuthorization 构造 V3 请求签名头
func (c *Client) buildAuthorization(method, urlPath string, body []byte) (string, error) {
    ts := strconv.FormatInt(time.Now().Unix(), 10)
    nonce := randomString(32)

    signStr := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n",
        method, urlPath, ts, nonce, string(body))

    h := sha256.New()
    h.Write([]byte(signStr))
    sig, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, crypto.SHA256, h.Sum(nil))
    if err != nil {
        return "", err
    }

    return fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",`+
        `timestamp="%s",serial_no="%s",signature="%s"`,
        c.MchID, nonce, ts, c.SerialNo, base64.StdEncoding.EncodeToString(sig)), nil
}

type JSAPIRequest struct {
    SPAppID     string `json:"sp_appid"`
    SPMchID     string `json:"sp_mchid"`
    SubMchID    string `json:"sub_mchid"`
    Description string `json:"description"`
    OutTradeNo  string `json:"out_trade_no"`
    NotifyURL   string `json:"notify_url"`
    SettleInfo  struct {
        ProfitSharing bool `json:"profit_sharing"`
    } `json:"settle_info"`
    Amount struct {
        Total    int    `json:"total"`
        Currency string `json:"currency"`
    } `json:"amount"`
    Payer struct {
        SPOpenID string `json:"sp_openid,omitempty"`
        SubOpenID string `json:"sub_openid,omitempty"`
    } `json:"payer"`
}

type JSAPIResponse struct {
    PrepayID string `json:"prepay_id"`
}

// JSAPIOrder 下 JSAPI 单(服务商模式)
func (c *Client) JSAPIOrder(req *JSAPIRequest) (*JSAPIResponse, error) {
    const urlPath = "/v3/pay/partner/transactions/jsapi"
    body, _ := json.Marshal(req)

    auth, err := c.buildAuthorization("POST", urlPath, body)
    if err != nil {
        return nil, err
    }

    httpReq, _ := http.NewRequest("POST",
        "https://api.mch.weixin.qq.com"+urlPath, bytes.NewReader(body))
    httpReq.Header.Set("Authorization", auth)
    httpReq.Header.Set("Accept", "application/json")
    httpReq.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(httpReq)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    respBody, _ := io.ReadAll(resp.Body)

    if resp.StatusCode != 200 {
        return nil, fmt.Errorf("jsapi order failed: %s %s", resp.Status, respBody)
    }

    // 响应验签(略,见 VerifyResponse)
    if err := c.VerifyResponse(resp.Header, respBody); err != nil {
        return nil, fmt.Errorf("response verify: %w", err)
    }

    var out JSAPIResponse
    _ = json.Unmarshal(respBody, &out)
    return &out, nil
}

// VerifyResponse 验证 V3 响应签名
func (c *Client) VerifyResponse(h http.Header, body []byte) error {
    ts := h.Get("Wechatpay-Timestamp")
    nonce := h.Get("Wechatpay-Nonce")
    serial := h.Get("Wechatpay-Serial")
    sigB64 := h.Get("Wechatpay-Signature")

    pub, ok := c.PlatformCerts[serial]
    if !ok {
        return fmt.Errorf("unknown platform cert serial: %s", serial)
    }

    signStr := fmt.Sprintf("%s\n%s\n%s\n", ts, nonce, string(body))
    hash := sha256.Sum256([]byte(signStr))
    sig, err := base64.StdEncoding.DecodeString(sigB64)
    if err != nil {
        return err
    }
    return rsa.VerifyPKCS1v15(pub, crypto.SHA256, hash[:], sig)
}

// BuildPaySign 生成前端调起的 paySign
func (c *Client) BuildPaySign(appID, prepayID string) (map[string]string, error) {
    ts := strconv.FormatInt(time.Now().Unix(), 10)
    nonce := randomString(32)
    pkg := "prepay_id=" + prepayID

    signStr := fmt.Sprintf("%s\n%s\n%s\n%s\n", appID, ts, nonce, pkg)
    h := sha256.Sum256([]byte(signStr))
    sig, err := rsa.SignPKCS1v15(rand.Reader, c.PrivateKey, crypto.SHA256, h[:])
    if err != nil {
        return nil, err
    }

    return map[string]string{
        "appId":     appID,
        "timeStamp": ts,
        "nonceStr":  nonce,
        "package":   pkg,
        "signType":  "RSA",
        "paySign":   base64.StdEncoding.EncodeToString(sig),
    }, nil
}

// ---------------- 分账 ----------------

type Receiver struct {
    Type        string `json:"type"`        // MERCHANT_ID | PERSONAL_OPENID | PERSONAL_SUB_OPENID
    Account     string `json:"account"`
    Amount      int    `json:"amount"`      // 分
    Description string `json:"description"`
}

type ProfitSharingRequest struct {
    AppID         string     `json:"appid"`
    SubMchID      string     `json:"sub_mchid"`
    TransactionID string     `json:"transaction_id"`
    OutOrderNo    string     `json:"out_order_no"`
    Receivers     []Receiver `json:"receivers"`
    UnfreezeUnsplit bool     `json:"unfreeze_unsplit"`
}

func (c *Client) ProfitSharing(req *ProfitSharingRequest) ([]byte, error) {
    const urlPath = "/v3/profitsharing/orders"
    body, _ := json.Marshal(req)
    auth, err := c.buildAuthorization("POST", urlPath, body)
    if err != nil {
        return nil, err
    }

    httpReq, _ := http.NewRequest("POST",
        "https://api.mch.weixin.qq.com"+urlPath, bytes.NewReader(body))
    httpReq.Header.Set("Authorization", auth)
    httpReq.Header.Set("Content-Type", "application/json")
    httpReq.Header.Set("Accept", "application/json")

    resp, err := http.DefaultClient.Do(httpReq)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    respBody, _ := io.ReadAll(resp.Body)

    if resp.StatusCode >= 400 {
        return nil, fmt.Errorf("profit sharing failed: %s %s", resp.Status, respBody)
    }
    if err := c.VerifyResponse(resp.Header, respBody); err != nil {
        return nil, err
    }
    return respBody, nil
}

// ---------------- 工具 ----------------

func LoadPrivateKeyPEM(pemBytes []byte) (*rsa.PrivateKey, error) {
    block, _ := pem.Decode(pemBytes)
    if block == nil {
        return nil, fmt.Errorf("invalid PEM")
    }
    key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
    if err != nil {
        return nil, err
    }
    return key.(*rsa.PrivateKey), nil
}

func randomString(n int) string {
    b := make([]byte, n/2)
    rand.Read(b)
    return fmt.Sprintf("%x", b)
}

调用方:

client := &Client{
    MchID: "1900001109",
    SerialNo: "34345340239483293...",
    PrivateKey: priv,
    APIv3Key: []byte("your-32-byte-apiv3-key-xxxxxxxxx"),
    PlatformCerts: map[string]*rsa.PublicKey{...},
}

resp, err := client.JSAPIOrder(&JSAPIRequest{
    SPAppID: "wx8888...",
    SPMchID: "1900001109",
    SubMchID: "1900000109",   // 特约商户
    Description: "订单 #P20260422001",
    OutTradeNo: "P20260422001",
    NotifyURL: "https://pay.example.com/wxpay/notify",
    Amount: struct{Total int "json:\"total\""; Currency string "json:\"currency\""}{
        Total: 10000, Currency: "CNY"},
    Payer: struct{SPOpenID string "json:\"sp_openid,omitempty\""; SubOpenID string "json:\"sub_openid,omitempty\""}{
        SubOpenID: "oLw..."},
})
if err != nil { log.Fatal(err) }

paySign, _ := client.BuildPaySign("wxSubAppID...", resp.PrepayID)
// paySign 返回给小程序前端

支付宝的 SDK 使用类似,官方 github.com/smartwalle/alipay/v3 社区库最常用,签名 / 验签 / 证书模式都已封装。


十四 bis、订单与账务数据模型示例

接入两家钱包后,商户侧至少要维护四张核心表:支付单、退款单、分账单、异步通知原始日志。下面给一套 Postgres / MySQL 风格的最小建表,供参考(生产环境会再拆冷热表、按日分区)。

-- 支付单(统一通道抽象)
CREATE TABLE payment (
    id              BIGSERIAL PRIMARY KEY,
    out_trade_no    VARCHAR(64)  NOT NULL UNIQUE,           -- 商户订单号,幂等键
    biz_order_id    VARCHAR(64)  NOT NULL,                  -- 业务订单号(可多对一)
    channel         VARCHAR(16)  NOT NULL,                  -- alipay | wechatpay
    scene           VARCHAR(16)  NOT NULL,                  -- jsapi | app | native | h5 | micropay
    sp_mchid        VARCHAR(32),                            -- 服务商商户号(可空)
    sub_mchid       VARCHAR(32)  NOT NULL,                  -- 特约商户号 / PID
    appid           VARCHAR(32)  NOT NULL,
    amount          BIGINT       NOT NULL,                  -- 分
    currency        CHAR(3)      NOT NULL DEFAULT 'CNY',
    status          VARCHAR(16)  NOT NULL,                  -- PENDING | PAID | CLOSED | REFUNDED
    channel_trade_no VARCHAR(64),                           -- 通道流水号(transaction_id / trade_no)
    openid          VARCHAR(64),
    paid_at         TIMESTAMPTZ,
    closed_at       TIMESTAMPTZ,
    profit_sharing  BOOLEAN      NOT NULL DEFAULT FALSE,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE INDEX idx_payment_biz ON payment (biz_order_id);
CREATE INDEX idx_payment_status_created ON payment (status, created_at);

-- 退款单
CREATE TABLE refund (
    id               BIGSERIAL PRIMARY KEY,
    out_refund_no    VARCHAR(64)  NOT NULL UNIQUE,
    out_trade_no     VARCHAR(64)  NOT NULL REFERENCES payment(out_trade_no),
    amount           BIGINT       NOT NULL,
    status           VARCHAR(16)  NOT NULL,                 -- PENDING | SUCCESS | ABNORMAL | CLOSED
    channel_refund_no VARCHAR(64),
    reason           VARCHAR(255),
    refunded_at      TIMESTAMPTZ,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE INDEX idx_refund_status_created ON refund (status, created_at);

-- 分账单(一次 profitsharing 对应多条 receivers)
CREATE TABLE profit_sharing (
    id               BIGSERIAL PRIMARY KEY,
    out_order_no     VARCHAR(64)  NOT NULL UNIQUE,
    out_trade_no     VARCHAR(64)  NOT NULL REFERENCES payment(out_trade_no),
    status           VARCHAR(16)  NOT NULL,                 -- PROCESSING | FINISHED | CLOSED
    unfreeze_unsplit BOOLEAN      NOT NULL,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE TABLE profit_sharing_receiver (
    id               BIGSERIAL PRIMARY KEY,
    ps_id            BIGINT       NOT NULL REFERENCES profit_sharing(id),
    receiver_type    VARCHAR(24)  NOT NULL,                 -- MERCHANT_ID | PERSONAL_OPENID
    receiver_account VARCHAR(64)  NOT NULL,
    amount           BIGINT       NOT NULL,
    status           VARCHAR(16)  NOT NULL,                 -- PENDING | SUCCESS | CLOSED | RETURNED
    description      VARCHAR(128),
    finished_at      TIMESTAMPTZ
);

-- 异步通知原始日志(验签后原文存档,便于纠纷时回溯)
CREATE TABLE channel_notify_log (
    id            BIGSERIAL PRIMARY KEY,
    channel       VARCHAR(16)  NOT NULL,
    event_type    VARCHAR(32)  NOT NULL,                    -- TRANSACTION.SUCCESS | REFUND.SUCCESS | PROFITSHARING.SUCCESS
    out_trade_no  VARCHAR(64),
    nonce         VARCHAR(64),
    received_at   TIMESTAMPTZ  NOT NULL DEFAULT now(),
    raw_headers   JSONB,
    raw_body      TEXT,
    verify_ok     BOOLEAN      NOT NULL,
    processed     BOOLEAN      NOT NULL DEFAULT FALSE,
    processed_at  TIMESTAMPTZ
);
CREATE INDEX idx_notify_trade ON channel_notify_log (out_trade_no);
CREATE UNIQUE INDEX uniq_notify_nonce ON channel_notify_log (channel, nonce);

几个设计要点值得单拎出来讲:

  1. out_trade_no 是支付单的物理主键(UNIQUE),因为它同时是我们发出去给通道的幂等键,也是通道回传给我们的关联键。允许同一 biz_order_id 对应多个 payment(关单重下)。
  2. 退款 out_refund_no 不是越小越好:微信/支付宝对退款单号有 6–64 位限制,建议长度拉满以支持组合编码(如 R{timestamp}{payment_id}),不要用自增。
  3. 分账的冻结属性在 payment 表冗余一份 profit_sharing 字段,方便跑批扫描”未分账到期订单”。
  4. 通知日志(channel, nonce) 唯一索引,天然防重放;raw_body 原文存档用于日后纠纷回溯——千万别删,监管检查和客诉调证都要用。
  5. 字段规模上,10 亿级订单量时 payment 表按 created_at分区,冷表转 OSS + Hive,详见第 04 篇。

14 bis.2 账务分录与支付的对应

结合第 03 篇复式记账,一笔微信 JSAPI 收款(服务商模式,交易额 100 元,手续费 0.38 元,平台分润 10 元)的记账应拆为四组分录:

T:用户支付 100 元
  Dr 特约商户-待结算    100.00
    Cr 用户应付-订单     100.00

T:通道扣手续费 0.38 元(支付宝/微信从待结算里扣,T+1 到账时就是 99.62)
  Dr 费用-通道手续费      0.38
    Cr 特约商户-待结算     0.38

T+N(分账时):平台分润 10 元从待结算转到平台应收
  Dr 特约商户-待结算     10.00
    Cr 平台应收-服务费    10.00

T+1(结算):剩余 89.62 结算到商户对公
  Dr 商户对公-银行存款    89.62
    Cr 特约商户-待结算     89.62

退款时所有分录逆向记账,分账接收方按比例追回。账务分录和支付单的关联是通过 payment.id + entry.business_id 完成的,第 03 篇有详解。


十五、真实案例与公开事件

国内:收钱吧、哆啦宝(2015–2018)的”大商户”整改。早期聚合支付平台普遍用”大商户模式”——平台自己作为一个大商户在银行收款,再按订单结算给下面真实商户。2017 年央行 281 号文《关于规范支付创新业务的通知》明确禁止无证机构”以聚合支付之名行无证经营收单业务之实”。随后一批聚合支付商被调整为合规服务商模式(钱直接到商户),或与持牌机构合作,历史存量清退历时数年。这是服务商模式二清红线最典型的公开事件。

国际:Stripe Connect 的 Platform / Connected Account 模式。Stripe 做 SaaS 平台 / Marketplace 收款有三种 Connect 账户:Standard、Express、Custom。Standard 和国内服务商模式几乎对应:Connected Account 是 Stripe 直接签约的商户,平台只是代建账户和发起支付;Custom 则把全部 UI 自定义给平台,资金仍是 Stripe → Connected Account。Stripe 通过 application_fee_amount 实现平台分润,等价于微信/支付宝的分账,但 Stripe 原生支持全球,国内两家钱包的分账目前主要限于境内商户。

2023 某酒店集团押金事故(公开新闻):预授权到期自动解冻,但商户结账时忘了先扣款就解冻,结果用户退房后账上余额不足,几千个订单转为线下催收。教训:预授权到期前必须要有主动结算任务,失败有告警。


十六、选型与落地清单

16.1 选型:直连 vs 服务商

16.2 选型:V2 vs V3

16.3 落地清单(Go live 前必过)


十七、参考资料


上一篇《卡组织收单链路:银联/Visa/Mastercard、ISO 8583、ISO 20022 迁移》

下一篇《支付网关设计:路由、限流、补单、异步通知、签名与防重放》

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。


By .