云闪付(UnionPay)是银联旗下移动支付 App,覆盖中国大陆绝大多数银行卡。本项目对接银联全渠道(open.unionpay.com)下的两种场景:
| 场景 | 接口 | 适用端 | 说明 |
|---|---|---|---|
| App 支付(手机控件支付) | appTransReq.do |
iOS / Android App | 后端返回 tn(交易流水号),前端拉起云闪付 App 完成支付 |
| 手机网站支付 | appTransReq.do + 收银台 |
移动端浏览器 / Web | 后端返回 tn,客户端拼接银联收银台地址跳转 |
本项目(Flutter)移动端使用 App 支付(
uppay://scheme 拉起云闪付),Web/桌面使用手机网站支付(银联收银台跳转)。本文档涉及的交易类型均为消费(CONSUME),银联交易类型对照:01消费、02消费撤销、04退款、00查询(来自 Pay-Java-Parent UnionPay 枚举)。
| 项目 | 说明 |
|---|---|
商户号 merId |
银联商户服务平台(open.unionpay.com)申请,需企业资质 |
| 商户私钥 | unionpay_private_key.pem,下单签名用(App 支付场景) |
| 银联公钥 | unionpay_public_key.pem,回调验签用 |
签名方式 signMethod |
11(RSA2 / SHA-256,推荐)、01(RSA / SHA-1) |
回调地址 notifyUrl |
公网 HTTPS,接收银联异步支付结果通知 |
银联提供官方沙箱环境:网关
https://gateway.test.95516.com,可用测试商户号(777290058110048等)联调,无需真实扣款。
# 云闪付
UNIONPAY_MER_ID= # 商户号,如 777290058110048
UNIONPAY_PRIVATE_KEY_PATH=./certs/unionpay_private_key.pem
UNIONPAY_PUBLIC_KEY_PATH=./certs/unionpay_public_key.pem
# 签名方式:11=RSA2(SHA256) 推荐;01=RSA(SHA1)
UNIONPAY_SIGN_METHOD=11
# 沙箱网关 true;生产置 false
UNIONPAY_SANDBOX=true
# 回调地址(须公网可达,测试可用内网穿透)
UNIONPAY_NOTIFY_URL=http://localhost:3000/v1/payment/notify/unionpay
# 商户私钥(2048 位 PKCS#1)
openssl genrsa -out unionpay_private_key.pem 2048
# 对应公钥(用于换取银联登记的商户公钥)
openssl rsa -in unionpay_private_key.pem -pubout -out unionpay_public_key.pem
商户公钥需在银联商户平台登记;回调验签用的是银联平台证书导出的公钥,生产环境应从银联商户平台下载。
sequenceDiagram
participant App as Flutter App
participant Backend as 业务后端
participant UnionPay as 银联网关
App->>Backend: POST /v1/payment/unionpay/ {product_id, source}
Backend->>Backend: 创建订单、计算金额(单位:分)
Backend->>UnionPay: appTransReq.do(商户私钥 RSA2 签名)
UnionPay-->>Backend: tn(交易流水号)
Backend-->>App: { payment_id, tn, sandbox }
App->>App: uppay://sdkpay?tn=xxx 拉起云闪付 App
App->>UnionPay: 用户在云闪付内完成扣款
UnionPay->>Backend: 异步通知(表单POST, 银联私钥签名)
Backend->>Backend: 验签 + respCode==00 + 幂等更新订单 + 发货
App->>Backend: GET /v1/payment/{payment_id} 轮询最终状态
统一原则(与全项目一致):客户端回调结果仅做 UI 提示,最终支付状态一律以 GET /v1/payment/{payment_id} 后端查询为准。
server/src/
services/unionpay.service.js # 银联签名/验签、网关表单 POST、创建支付
services/pay_gateway.service.js # 渠道分发(CHANNELS 含 unionpay)
services/notify.service.js # handleUnionPayNotify 验签 + markPaidByOutTradeNo
controllers/notify.controller.js # POST /v1/payment/notify/unionpay(express.urlencoded)
mock/mock.factory.js # MOCK_MODE 下返回 mock_tn_* 模拟参数
config/index.js # unionpay 配置段 + 网关 getter
| 字段 | 值 | 说明 |
|---|---|---|
version |
5.1.0 |
版本号 |
encoding |
UTF-8 |
编码 |
signMethod |
11 / 01 |
RSA2 / RSA |
txnType |
01 |
交易类型:消费 |
txnSubType |
01 |
交易子类:消费 |
bizType |
000201 |
手机支付 |
channelType |
08 |
渠道类型:手机 |
accessType |
0 |
接入类型:商户直连接入 |
merId |
商户号 | 银联分配 |
orderId |
订单号 | 商户订单号(out_trade_no) |
txnTime |
YYYYMMDDHHmmss |
交易时间(订单创建时间格式化) |
txnAmt |
金额(分) | 交易金额,单位分 |
currencyCode |
156 |
货币代码:人民币 |
notifyUrl |
回调地址 | 银联异步通知地址 |
// 1. 取除 sign/signValue 外的全部参数,按 key 升序
// 2. 拼接 k=v&k2=v2&...
// 3. 用商户私钥签名(RSA-SHA256 或 RSA-SHA1),Base64 编码
function sign(params, key) {
const sorted = Object.keys(params)
.filter((k) => !['sign', 'signValue'].includes(k))
.sort()
.map((k) => `${k}=${params[k]}`)
.join('&');
return crypto.sign(signAlg(), Buffer.from(sorted, 'utf8'), key).toString('base64');
}
回调验签(verifySign):同样拼装原文,用银联公钥验证 signValue 是否匹配。
const resp = await postForm(config.unionpay.gateway, base); // 表单 POST
if (resp.respCode !== '00') throw ...; // 下单失败
const tn = resp.tn; // 交易流水号
// App 支付:返回 { payment_id, tn, params: tn } → 前端拉起 uppay://
// Web 支付:返回 { payment_id, tn, redirect_url: gateway/transReceipt.do?tn=tn }
tn 是调起云闪付 App 的唯一凭证。tn 拼银联收银台地址跳转。// 1. 验签(verifySign,MOCK 模式下跳过)
// 2. respCode === '00' 表示交易成功
// 3. 取 orderId 匹配本地订单 → markPaidByOutTradeNo 幂等更新
回调幂等:同一 orderId 多次回调只更新一次,状态机 PENDING → PAID。
MOCK_MODE=true 时(无真实商户号也能端到端跑通):
{ tn: 'mock_tn_...', payment_id, sandbox: true }(web 额外返回 redirect_url)。tn 以 mock_ 开头即不拉起 App,调用 POST /v1/payment/simulate/success 模拟支付成功。markPaid 幂等流程。lib/models/payment.dart # UnionPayCreatedResponse { paymentId, tn, redirectUrl, params }
lib/service/api_service.dart # createUnionPayPayment({productId, source}) → POST /v1/payment/unionpay/
lib/service/pay_service.dart # payWithUnionPay:App/Web 分支编排 + 结果确认
lib/utils/pay_result.dart # showPayResultDialog:统一支付结果弹窗
final created = await _api.createUnionPayPayment(productId: productId, source: 'app');
final tn = created.tn;
// Mock 模式(tn 以 mock_ 开头):直接模拟支付成功
if (tn.startsWith('mock_')) {
await _api.simulatePaymentSuccess(created.paymentId);
await _queryFinalStatus(context, created.paymentId, channelName: '云闪付');
return;
}
// 真实模式:uppay:// scheme 拉起云闪付 App
await launchUrl(Uri.parse('uppay://sdkpay?tn=$tn'),
mode: LaunchMode.externalApplication);
// 用户支付完成后回到 App,轮询后端确认最终状态
await _queryFinalStatus(context, created.paymentId, channelName: '云闪付');
uppay://scheme 拉起是演示用简化方案;正式接入推荐集成银联官方原生 SDK(UnionPayAndroid SDK /UPPayPlugin),由 SDK 完成唤起、支付结果回调与二次验签,可靠性更高。
final created = await _api.createUnionPayPayment(productId: productId, source: 'web');
await _handleWebRedirect(
context: context,
url: created.redirectUrl, // 银联收银台
paymentId: created.paymentId,
channelName: '云闪付',
);
_handleWebRedirect 弹窗提供「打开支付页 / 模拟支付成功(Mock)/ 取消」,真实模式打开支付页后轮询后端。
_queryFinalStatus 每 1s 轮询 GET /v1/payment/{payment_id}(最多约 12s 覆盖回调延迟),成功后弹出 showPayResultDialog(绿色对勾 + 渠道 + 订单号),点击「完成」回到商品页。
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /v1/payment/unionpay/ |
创建云闪付支付。{product_id, source};App 返回 {payment_id, tn, sandbox},web 额外返回 redirect_url |
| GET | /v1/payment/:payment_id |
查询支付状态 {success, note} |
| POST | /v1/payment/notify/unionpay |
银联异步通知(表单 POST,验签后更新订单) |
| POST | /v1/payment/simulate/success |
仅 MOCK_MODE:{payment_id} 模拟支付成功 |
product_id 计算(单位分),不信任客户端。markPaid 幂等,同一订单重复通知只处理一次。UNIONPAY_NOTIFY_URL 映射到外网。gateway.test.95516.com,生产 gateway.95516.com;上线前务必确认 UNIONPAY_SANDBOX=false。.gitignore)。00 查询 接口对账,处理回调丢失场景。