惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

C
Cisco Blogs
Cyberwarzone
Cyberwarzone
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
SecWiki News
SecWiki News
Martin Fowler
Martin Fowler
T
Tor Project blog
N
Netflix TechBlog - Medium
C
Cybersecurity and Infrastructure Security Agency CISA
V
Vulnerabilities – Threatpost
V
Visual Studio Blog
GbyAI
GbyAI
PCI Perspectives
PCI Perspectives
D
DataBreaches.Net
Jina AI
Jina AI
H
Heimdal Security Blog
云风的 BLOG
云风的 BLOG
P
Privacy International News Feed
A
About on SuperTechFans
J
Java Code Geeks
美团技术团队
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
News | PayPal Newsroom
有赞技术团队
有赞技术团队
MyScale Blog
MyScale Blog
博客园 - 司徒正美
C
Check Point Blog
T
Threat Research - Cisco Blogs
Attack and Defense Labs
Attack and Defense Labs
宝玉的分享
宝玉的分享
AI
AI
Simon Willison's Weblog
Simon Willison's Weblog
C
Cyber Attacks, Cyber Crime and Cyber Security
I
Intezer
P
Proofpoint News Feed
Blog — PlanetScale
Blog — PlanetScale
Apple Machine Learning Research
Apple Machine Learning Research
Hugging Face - Blog
Hugging Face - Blog
The Last Watchdog
The Last Watchdog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Vercel News
Vercel News
I
InfoQ
阮一峰的网络日志
阮一峰的网络日志
Cisco Talos Blog
Cisco Talos Blog
W
WeLiveSecurity
Hacker News: Ask HN
Hacker News: Ask HN
Recent Commits to openclaw:main
Recent Commits to openclaw:main
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
D
Docker
博客园 - Franky
Security Archives - TechRepublic
Security Archives - TechRepublic

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

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】契约测试与 Schema 演进:服务间的信任协议 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 排序基准测试:用数据说话
【金融科技工程】08:支付宝、微信支付接入:服务商模式、预授权、分账、退款、对账
2026-04-22 · via 土法炼钢兴趣小组的算法知识备份

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

一、读者画像与本文边界

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

  • 一家电商、外卖、票务、SaaS 工具的后端,要做”微信小程序下单 + 支付宝 App 支付”的收银台;
  • 一家物业、停车、连锁零售的聚合收银,要在多门店、多主体下分账、对账;
  • 一家共享充电宝、共享汽车、酒店集团,要玩预授权冻结押金
  • 一家 ISV / 支付服务商,要替一堆小微商户开户、代理进件、代收代付;

那么这篇文章为你写的。

本文不覆盖的内容:

  • 支付宝 / 微信支付的 C 端风控、反洗钱、支付密码体系——见第 20、21 篇;
  • 跨境支付(Alipay+、WeChat Pay HK、境外收单)——见第 13 篇;
  • 聚合支付网关的路由、限流、补单——见第 09 篇;本文只讲单通道接入的细节。

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


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

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

2.1 直连商户(Direct Merchant)

直连商户路径:

  • 商户(营业执照持有人)直接在支付宝开放平台 / 微信支付商户平台注册;
  • 签约主体是商户自己,结算银行卡也是商户对公账户;
  • 每一笔交易的资金流是:用户钱包 → 支付宝/微信 → 商户对公账户(T+1 结算);
  • 手续费从商户自己的签约费率里扣(典型 0.38%–0.6%,行业不同)。

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

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

服务商模式路径:

  • 你(平台方)先成为服务商(支付宝叫 ISV,微信叫服务商 / Partner),拿到服务商 AppID 和服务商商户号(sp_appid、sp_mchid);
  • 服务商帮下面的特约商户(Sub-Merchant)代开户 / 进件:提交营业执照、法人身份证、门店照片、对公账户;
  • 每个特约商户拿到自己的 sub_mchid(微信)或 pid(支付宝);
  • 交易发起时带上服务商身份 + 特约商户身份,资金直接从钱包清算到特约商户对公账户(这是关键!),服务商只拿分润。

服务商模式适合:

  • SaaS 收银:比如一个连锁餐饮 SaaS,背后几千家小商户;
  • 平台类业务:美团、饿了么、滴滴这种;
  • 聚合工具:收钱吧、哆啦宝、二维火等。

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

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

这就决定了:

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

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


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

3.1 应用(Application)与 AppID

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

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

  • 当面付(面对面扫码)
  • App 支付
  • 电脑网站支付
  • 手机网站支付
  • 预授权
  • 资金分账
  • 花呗分期

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

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

响应验签

  • 响应体是 JSON,形如 {"alipay_trade_create_response": {...}, "sign": "..."}
  • 验签对象是 alipay_trade_create_response 对应的 JSON 字符串原文(注意不是重新序列化,必须原始字节),用支付宝公钥验;
  • 这里有个经典坑:Java 官方 SDK 老版本会重新序列化,导致字段顺序变化,验签失败。一定要截取原文

3.3 应用证书模式

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

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

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

  • 营业执照、法人身份证正反面、门店照片、收银台照片;
  • 结算银行账户(对公 or 小微商户可以对私);
  • 经营类目(MCC)、日峰值交易额、客单价。

提交后进入人工审核(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:在 pay.weixin.qq.com 申请,10 位数字,绑定对公银行账户。
  • AppID:在 mp.weixin.qq.com(公众号)、mp.weixin.qq.com(小程序)、open.weixin.qq.com(开放平台 / 移动应用 / 网站应用)申请,18 位字符串。

一个 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

  • V2:XML body,MD5 或 HMAC-SHA256 签名(基于 APIv2 密钥),证书挂在客户端做双向 TLS(退款、下载对账单要用)。
  • V3:JSON body,基于商户 API 私钥 + 微信支付平台证书的 RSA-SHA256 签名,敏感字段(银行卡号、身份证号)要用平台证书的公钥加密传输。

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 密钥:32 字节字符串,商户平台后台自己设置。用途:解密回调通知和 /v3/certificates 响应里的敏感字段(AES-256-GCM)。
  • 商户 API 私钥:商户自己生成 RSA 2048 私钥,CSR 上传商户平台拿到”商户 API 证书”,里面的序列号即 serial_no
  • 微信支付平台证书:微信下发,用于验证响应签名加密上行敏感字段

四把密钥一把不能少:漏了 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

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

  • C 扫 B(User-presented → Merchant-scanned? 反了,重看):用户(Consumer)用钱包扫商户贴的码;叫”主扫”。对应 Native / Precreate,用户主动发起。
  • B 扫 C:商户(Business)用扫码枪扫用户钱包里的付款码(18 位动态条码);叫”被扫”或”付款码”。对应 micropay / pay

两者资金都是用户付给商户,差别是发起设备。便利店、餐厅多用 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 分账时机

  • 订单支付成功后 → 订单默认处于”分账冻结”状态,最长可冻结到180 天(微信)/ 180 天(支付宝);
  • 下单时在订单中声明”需分账”(profit_sharing=true);
  • 支付成功后,调分账 API 把钱按比例分给接收方;
  • 剩余未分的钱调解冻接口,退回商户余额。

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 签名校验

  • 支付宝:把 POST 来的 form-urlencoded 参数(剔除 signsign_type)按字典序拼起来,用支付宝公钥验 sign
  • 微信 V3:通知是 JSON body,HTTP 头带 Wechatpay-SignatureWechatpay-SerialWechatpay-TimestampWechatpay-Nonce,按 timestamp\nnonce\nbody\n 组合,用对应序列号的平台证书公钥验签。

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

9.2 防重放

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

  • 校验 timestamp 与当前时间差 ≤ 5 分钟(微信要求);
  • 校验 nonce_str / out_trade_no 是否已处理过(用 Redis SET NX,TTL 10 分钟);
  • 对回调数据 transaction_id + trade_state 做幂等键。

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 通知重试策略

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

  • 支付宝: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 状态码),任何非约定返回都会被当作失败并重推。

9.5 补单:主动查单

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

  • 创建订单 30 秒后起,定时调 trade.query / /v3/pay/transactions/out-trade-no/{no}
  • 指数退避:30s、60s、2m、5m、10m、30m……最长查 24 小时;
  • 查到成功 → 走和通知相同的处理逻辑(同一幂等键,自然去重);
  • 24 小时仍未成功 → 关单(trade.close)并退给用户。

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

10.1 两类账单

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

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

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

  • 先用交易账单商户自己的订单表(确认哪些交易成功);
  • 再用资金账单银行对公账户流水(确认钱真的到了);
  • 两张账单交叉(资金账单上每笔收款的 trade_no 应能在交易账单找到对应订单)。

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

10.2 下载接口

  • 支付宝:alipay.data.dataservice.bill.downloadurl.query,返回一个30 分钟有效期的临时 URL,下载的是CSV 压缩包(.csv.zip)
  • 微信 V3:GET /v3/bill/tradebill?bill_date=2026-04-21,返回 download_url + hash;再 GET 下载,得到 gzip 的 CSV;敏感字段(卡号 4 后缀)平台已脱敏。

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  人工清差

常见差异类型:

  • 长款:账单有,订单表没有 → 可能被刷单 / 接收异步通知失败;
  • 短款:订单表成功,账单没有 → 可能实际未到账,或账单延迟;
  • 金额不一致:多为退款跨日导致的汇总差;
  • 手续费差:费率计算差 1 分,来自四舍五入规则不同。

10.4 账单下载时机

  • 支付宝账单 T+1 早上 09:00 后可下;
  • 微信账单 T+1 早上 09:00 后可下;
  • 月账单月初 3 日后可下。

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


十一、沙箱与灰度

11.1 沙箱

  • 支付宝沙箱 openapi.alipaydev.com:有独立的 AppID、测试买家账号、测试金额;能力有限,不能测分账、预授权某些组合;
  • 微信支付沙箱:历史上有专门的沙箱环境(api.mch.weixin.qq.com/sandboxnew/),V3 基本没有全功能沙箱,最好用真实商户小额金额(最低 0.01 元)灰度。

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

  • 上线前内部员工账号各刷一笔 0.01 元,完成一笔 0.01 元退款;
  • 分账场景:各接收方各分 0.01 元并回退;
  • 预授权:冻结 0.02 元扣 0.01 元解冻 0.01 元。

11.2 灰度

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

  • 支付成功率(分子:支付成功,分母:下单);
  • 异步通知到达率;
  • 退款成功率;
  • 账单差异率。

任何指标下降立即回滚。


十二、工程坑点清单

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

12.1 证书类

  • 证书过期:支付宝应用证书有效期 2–5 年,微信商户证书 5 年;到期没续签整个通道挂。加 30 天预警到 IM 群。
  • 回调 HTTPS 证书链不完整:浏览器能打开不代表微信支付服务端能验通(微信服务端用的 root store 更严格)。用 openssl s_client -connect your.domain:443 -showcerts 验证证书链,缺中间证书的加上。
  • 平台证书轮换:微信 V3 平台证书每 12 个月轮换,客户端要同时保留旧新两张,按 Wechatpay-Serial 选。

12.2 标识类

  • out_trade_no 冲突:同一 out_trade_no 重下会报”重复订单”或返回老订单。规则:一旦支付失败要重下,必须换新 out_trade_no,老的走关单。
  • AppID 与 mch_id 未绑定:报错 APPID_MCHID_NOT_MATCH;去商户平台绑定并公众号那边确认关联
  • openid 跨 AppID 不通:同一用户在公众号和小程序下 openid 不同,支付下单要用同一 AppID 对应的 openid

12.3 时序类

  • 异步通知乱序:通知服务器处理慢时,退款通知可能早于支付通知到达。用状态机 + FOR UPDATE 防错乱。
  • 查单时机过早:下单后立即查单可能返回 ORDERNOTEXIST。等至少 5 秒。

12.4 合规类

  • 服务商代收代付变二清:这是最严重的红线。若平台账户临时沉淀过商户资金(哪怕一小时),都可能被认定二清。正确做法:分账直接分到特约商户;平台自己的分润走独立分账接收方。
  • 备付金:支付机构客户备付金 100% 集中存管在人行,服务商不持牌就不存在备付金概念;有牌照的(拉卡拉、易宝)要按央行规则。

12.5 体验类

  • 关单太早:用户点支付后 SDK 唤起钱包还没付,商户端关单了;用户付钱后发现订单不存在,变成”异常充值”需要原路退。建议至少给 5–10 分钟关单窗口。
  • 支付中状态可见性:一定要给用户一个”支付中”的中间态,不要让用户点两次导致重复下单。

十三、时序图:用户扫码 → 小程序 → 微信 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 服务商

  • 直连:你只有一家公司、一个主体、一套流水 → 走直连。
  • 服务商:你做 SaaS、平台、多商户 → 必走服务商。不要用直连的一个商户号”帮”多家公司收钱,那就是二清。

16.2 选型:V2 vs V3

  • 新接入一律 V3,除非某个能力只有 V2(越来越少);
  • 老系统迁移 V3 按场景分批:先迁统一下单、通知;再迁退款、分账;最后迁对账。

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


十七、参考资料

  • 支付宝开放平台文档:https://opendocs.alipay.com/open/00fjfh
  • 支付宝接口文档目录:https://opendocs.alipay.com/apis
  • 微信支付 API v3 文档:https://pay.weixin.qq.com/wiki/doc/apiv3/index.shtml
  • 微信支付签名规范(V3):https://pay.weixin.qq.com/wiki/doc/apiv3/wechatpay/wechatpay4_0.shtml
  • 微信支付 Go 官方库:https://github.com/wechatpay-apiv3/wechatpay-go
  • 中国人民银行《关于规范支付创新业务的通知》(银办发〔2017〕281 号)
  • 中国人民银行《非银行支付机构客户备付金存管办法》(2021 年)
  • Stripe Connect 文档:https://stripe.com/docs/connect
  • 收钱吧、乐刷等聚合支付公开整改信息(公开新闻汇总)

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

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

同主题继续阅读

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

2026-04-22 · architecture / fintech

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

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