2026-04-22 · architecture / fintech
【金融科技工程】支付系统全景:收单、发卡、聚合、跨境、B2B、C2C
把模糊的"支付"一词拆成可工程化的系统分类——四方参与者、收单发卡双边、卡组织与钱包通道、中国与国际的费率结构、实时支付浪潮、以及一张完整的资金流图。























本文是【金融科技工程】系列第 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)。
“接入支付宝 / 微信支付”听起来只有一种做法,其实有两条完全不同的路径,后续的签约主体、资金流向、合规边界都不同。
直连商户路径:
这条路径适合自营业务:一家公司,一套营业执照,流水归自己。工程上最省事:一套密钥、一套 AppID、一套回调域名。
服务商模式路径:
服务商模式适合:
二清(Second Clearing)是中国人民银行禁止的:没有支付牌照的机构,不得从事资金归集后再向商户结算的活动。简单翻译:钱不能先进你(平台)的账户,再由你转给商户——除非你持牌。
这就决定了:
| 模式 | 钱走哪里 | 是否二清 | 代表玩法 |
|---|---|---|---|
| 直连 | 钱包 → 商户对公 | 否 | 自营电商 |
| 合规服务商 | 钱包 → 特约商户对公(平台收分润) | 否 | 美团、滴滴、收钱吧 |
| 违规”大商户” | 钱包 → 平台账户 → 再打给商户 | 是,违规 | 被监管点名的聚合支付 |
| 平台持牌 | 钱包 → 平台备付金 → 商户 | 否(因为有牌照) | 支付宝、微信自身、拉卡拉 |
所以工程上,服务商模式最核心的设计是:分账接收方、清算账户一定要是特约商户自己的对公账户,平台不碰资金本金,只分润。第 11 篇讲清结算时会再展开。
登录 Alipay Open Platform(open.alipay.com),创建一个”网页/移动应用”,获得 AppID(16 位数字)。所有后续调用都要带 AppID——它标识你这套业务,而非商户。
一个 AppID 可以签约多种能力(Capability):
每个能力必须单独签约,在”产品中心”申请开通,一般需要营业执照、行业资质。
支付宝 Open API 全部使用非对称签名:商户生成 RSA 2048 密钥对,把公钥上传到开放平台,平台下发”支付宝公钥”(平台自己的公钥)。
签名算法是 SHA256WithRSA(也叫 RSA2,老的
RSA1 用 SHA1,已经不允许新商户使用)。
请求签名流程:
k1=v1&k2=v2;SHA256WithRSA 签名,Base64
编码,作为 sign 参数;https://openapi.alipay.com/gateway.do。响应验签:
{"alipay_trade_create_response": {...}, "sign": "..."};alipay_trade_create_response
对应的 JSON
字符串原文(注意不是重新序列化,必须原始字节),用支付宝公钥验;2020 年后支付宝推应用证书 +
支付宝根证书模式(代替公钥模式),为了兼容”一个商户绑多个应用”,证书文件包含证书序列号(SN),签名时带上
app_cert_sn 和 alipay_root_cert_sn
两个参数。新接入必须走证书模式。
服务商模式下,你用间连商户进件
API(alipay.open.agent.create、alipay.open.agent.facetoface.sign
等)提交材料:
提交后进入人工审核(1–3 工作日),通过后拿到
PID(Partner ID),后续交易带上
sys_service_provider_id(你的 ISV PID)+ 商户
PID,资金结算到商户。
| 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(pay.weixin.qq.com 的老 API)和 V3(api.mch.weixin.qq.com 的新 API)两套 API 并存,且支付场景与 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。
V3 是官方主推,V2 仍可用但已进入维护期,新功能只上 V3(例如商家转账、合单支付、分账都基本 V3 优先)。实际工程中很多老系统仍是 V2,迁移 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 头选。
/v3/certificates
响应里的敏感字段(AES-256-GCM)。serial_no。四把密钥一把不能少:漏了 APIv3 密钥,回调通知里的用户信息就解不开;漏了平台证书,回调签名就没法验。
| 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 |
中国线下支付的术语容易绕:
micropay
/ pay。两者资金都是用户付给商户,差别是发起设备。便利店、餐厅多用 B 扫 C(快,不需要用户操作),停车场、自助售货多用 C 扫 B(设备不用装扫码头)。
在微信内部浏览器或小程序里调起:
POST /v3/pay/transactions/jsapi 传
openid + 金额 + out_trade_no,拿到
prepay_id;prepay_id
第二次签名,返回给前端
{appId, timeStamp, nonceStr, package: "prepay_id=xxx", signType: "RSA", paySign};wx.requestPayment(params)(小程序)或
WeixinJSBridge.invoke('getBrandWCPayRequest', params)(公众号)。支付宝对应的是生活号 / 小程序
JSAPI,流程相似但用
alipay.trade.create + 前端
my.tradePay。
原生 App 集成微信/支付宝 SDK。后端下单拿到支付参数,透传给 App SDK,SDK 调起钱包 App,完成后回到商户 App,并不以回调结果为准——必须以异步通知 / 主动查单为准。
在非微信、非支付宝的外部浏览器里支付,后端拿到一个跳转 URL,浏览器 302 到钱包唤起支付。H5 支付有Referer 防刷要求:商户域名必须在商户平台配置白名单,不然报 “mweb_url invalid”。
支付宝”蜻蜓”、微信”青蛙”是刷脸支付硬件。工程上是
SDK(Android / 自研
Linux)跑在设备里,调用人脸识别云端,识别成功后用
face_id
替代付款码走正常支付。对接方主要是收银系统
ISV,本文不展开。
预授权是酒店、租车、共享充电宝等行业的刚需:用户下单时冻结一笔押金(不真实扣款),使用完毕后根据实际金额扣款或解冻。
两段式 API:
alipay.fund.auth.order.freeze // 冻结
alipay.fund.auth.order.unfreeze // 解冻(全部 or 部分)
alipay.trade.pay // 以冻结单 auth_no 转支付(从预授权转完成)
业务流程:
alipay.fund.auth.order.freeze
冻结 500 元,拿到 auth_no;alipay.trade.pay 传
auth_no + amount=18,从冻结额度里扣 18
元,剩余 482 元自动解冻;alipay.fund.auth.order.unfreeze 解冻 500
元。支付宝的冻结最长180 天。
微信支付没有通用的”预授权”对外开放(历史上做过,后来在部分行业定向开放),更多场景是走”担保交易”或订单确认收货模式。共享租赁行业一般通过”免密支付 / 分期免押”替代——用户授权一个免密协议,归还后再扣款。
如果你做酒店/租车,支付宝有现成预授权,微信要找行业方案(微信”租借服务”平台 + 芝麻信用免押的组合变种)。
stateDiagram-v2
[*] --> 冻结中: freeze 成功
冻结中 --> 部分扣款: pay(auth_no, 部分)
冻结中 --> 全额解冻: unfreeze(全部)
冻结中 --> 全额扣款: pay(auth_no, 全部)
部分扣款 --> 剩余解冻: 自动
部分扣款 --> 追加扣款: pay(auth_no, 再扣)
全额解冻 --> [*]
全额扣款 --> [*]
剩余解冻 --> [*]
工程提醒:冻结额度和可扣款次数在不同版本 API 下规则不同(早期只允许扣一次,现在支持多次扣款直到额度用完)。
平台业务最常见需求:用户付 100 元,平台分 20 元,商户得 80 元,快递员得 5 元,怎么做?答案:分账(Profit Sharing),而不是自己”先收后付”(二清)。
不管支付宝还是微信支付,分账前都要:
POST /v3/profitsharing/receivers/add,支付宝用
alipay.trade.royalty.relation.bind。profit_sharing=true);{
"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。
分账完成后的回退(Return)只能来自已分账的接收方,且只能退回到原支付商户。回退限额 = 已分金额;超过就报错。退款和回退是两码事:
“合单”指一次支付金额分属多个子单,每个子单可以走不同商户。微信提供
combine
合单支付(POST /v3/combine-transactions/jsapi
等),典型场景是商城一单买 A、B
两家店的商品。合单后分账各自独立。
支付宝、微信支付对”跨年退款”有特殊规则:超过一年的订单退款成功率会下降,部分场景(银行侧已销账)需要走线下退款(客服介入)。工程上对老订单退款要加重试 + 工单兜底。
退款可能进入异常状态:
| 状态 | 原因 | 处理 |
|---|---|---|
| PROCESSING | 正在处理 | 10 分钟后查单 |
| SUCCESS | 成功 | 入账完成 |
| CLOSED | 关闭 | 重新发起 |
| ABNORMAL | 异常(银行退回失败等) | 商户平台人工处理,一般退到”不可用余额” |
异常退款通常能在商户平台操作”退到余额”或”线下打款”,代码里一定要有兜底:每天扫描异常退款,推人工工单。
前文提过:退款时会按比例追回分账。流程是:
这就是为什么平台分到的钱不能立刻提现——必须保留至少 180 天余额以应对退款。
支付结果以异步通知为准(不是前端的”支付成功”页面,也不是 SDK 回调)。工程上处理异步通知的四个核心点:签名校验、防重放、幂等、补单。
sign、sign_type)按字典序拼起来,用支付宝公钥验
sign。Wechatpay-Signature、Wechatpay-Serial、Wechatpay-Timestamp、Wechatpay-Nonce,按
timestamp\nnonce\nbody\n
组合,用对应序列号的平台证书公钥验签。验签失败一律丢弃,千万不要返回 200——不然攻击者会尝试伪造。
通知可能被中间人捕获后重放:
timestamp 与当前时间差 ≤ 5
分钟(微信要求);nonce_str / out_trade_no
是否已处理过(用 Redis SET NX,TTL 10 分钟);transaction_id +
trade_state 做幂等键。异步通知至少会收到一次,可能多次(超时重推)。处理逻辑:
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。
两家钱包的重试间隔都是指数退避:
4m, 10m, 10m, 1h, 2h, 6h, 15h,最多
8 次,总 ~25 小时;15s, 15s, 30s, 3m, 10m, 20m, 30m, 30m, 30m, 60m, 3h, 3h, 3h, 6h, 6h,最多
15 次,总 ~24 小时。收到通知后必须返回约定的成功响应(支付宝返回文本
success,微信 V3 返回
{"code":"SUCCESS"} 并 200
状态码),任何非约定返回都会被当作失败并重推。
通知可能永久丢失(商户回调服务器宕机超过 25 小时)。不能依赖通知,必须有主动查单兜底:
trade.query /
/v3/pay/transactions/out-trade-no/{no};trade.close)并退给用户。两家钱包都提供两类账单:
| 账单类型 | 内容 | 用途 |
|---|---|---|
| 交易/业务账单 | 当天所有订单(含成功、已退款、已关闭),每笔一行 | 对商户自己的订单表 |
| 资金账单 | 当天所有资金变动(收款、退款、提现、手续费),每笔一行 | 对商户对公账户流水 |
交易账单的金额 ≠ 资金账单的金额——因为有手续费、退款跨天、分账等差异。正确对账方式是:
trade_no
应能在交易账单找到对应订单)。这叫三方对账(Three-way Reconciliation):订单表 ↔︎ 通道账单 ↔︎ 银行流水,第 23 篇会深入。
alipay.data.dataservice.bill.downloadurl.query,返回一个30
分钟有效期的临时 URL,下载的是CSV
压缩包(.csv.zip);GET /v3/bill/tradebill?bill_date=2026-04-21,返回
download_url + hash;再 GET 下载,得到 gzip 的
CSV;敏感字段(卡号 4 后缀)平台已脱敏。典型 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 人工清差
常见差异类型:
不要在 09:00 前重试——只会拿到”账单未生成”。
openapi.alipaydev.com:有独立的
AppID、测试买家账号、测试金额;能力有限,不能测分账、预授权某些组合;api.mch.weixin.qq.com/sandboxnew/),V3
基本没有全功能沙箱,最好用真实商户小额金额(最低
0.01 元)灰度。沙箱的限制决定了你必须做真实小额灰度:
新通道、新版本上线用按用户的灰度:hash(user_id) % 100 < k,k
从 1 → 5 → 20 → 50 → 100。同时监控:
任何指标下降立即回滚。
项目上线后最常见的踩坑,大致分这些类:
openssl s_client -connect your.domain:443 -showcerts
验证证书链,缺中间证书的加上。Wechatpay-Serial 选。APPID_MCHID_NOT_MATCH;去商户平台绑定并公众号那边确认关联。下面这张图是小程序下单 + 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
片段(去掉错误处理细节),展示三个关键动作: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
社区库最常用,签名 / 验签 / 证书模式都已封装。
接入两家钱包后,商户侧至少要维护四张核心表:支付单、退款单、分账单、异步通知原始日志。下面给一套 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);几个设计要点值得单拎出来讲:
out_trade_no
是支付单的物理主键(UNIQUE),因为它同时是我们发出去给通道的幂等键,也是通道回传给我们的关联键。允许同一
biz_order_id 对应多个
payment(关单重下)。out_refund_no
不是越小越好:微信/支付宝对退款单号有 6–64
位限制,建议长度拉满以支持组合编码(如
R{timestamp}{payment_id}),不要用自增。profit_sharing
字段,方便跑批扫描”未分账到期订单”。(channel, nonce)
唯一索引,天然防重放;raw_body
原文存档用于日后纠纷回溯——千万别删,监管检查和客诉调证都要用。created_at 月分区,冷表转 OSS
+ Hive,详见第 04 篇。结合第 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 某酒店集团押金事故(公开新闻):预授权到期自动解冻,但商户结账时忘了先扣款就解冻,结果用户退房后账上余额不足,几千个订单转为线下催收。教训:预授权到期前必须要有主动结算任务,失败有告警。
https://opendocs.alipay.com/open/00fjfhhttps://opendocs.alipay.com/apishttps://pay.weixin.qq.com/wiki/doc/apiv3/index.shtmlhttps://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay4_0.shtmlhttps://github.com/wechatpay-apiv3/wechatpay-gohttps://stripe.com/docs/connect上一篇:《卡组织收单链路:银联/Visa/Mastercard、ISO 8583、ISO 20022 迁移》
下一篇:《支付网关设计:路由、限流、补单、异步通知、签名与防重放》
把当前热点继续串成多页阅读,而不是停在单篇消费。
2026-04-22 · architecture / fintech
把模糊的"支付"一词拆成可工程化的系统分类——四方参与者、收单发卡双边、卡组织与钱包通道、中国与国际的费率结构、实时支付浪潮、以及一张完整的资金流图。
2026-04-22 · architecture / fintech
从金融本质、分层架构到文件格式、匹配算法、差错处理与调账流程,系统讲解对账系统的工程落地,包含 Python/Go 示例、Mermaid 状态机与大型平台十亿级对账方案。
2026-04-22 · architecture / fintech
金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。
2026-04-22 · architecture / fintech
在代码里正确地表示"一笔钱"远比看起来难。本文系统梳理金额的数值建模(浮点、定点、Decimal、最小单位)、币种标准(ISO 4217)、本地化显示、汇率换算与数据库存储,并给出 Go、Python、Java、Rust 的工程化示例。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。