# 银联云闪付(UnionPay)技术文档 ## 1. 概述 云闪付(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 枚举](https://github.com/egzosn/pay-java-parent))。 ## 2. 申请与配置 ### 2.1 需要申请的内容 | 项目 | 说明 | | --- | --- | | 商户号 `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` 等)联调,无需真实扣款。 ### 2.2 环境变量(server/.env) ```bash # 云闪付 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 ``` ### 2.3 密钥生成(RSA2,openssl 示例) ```bash # 商户私钥(2048 位 PKCS#1) openssl genrsa -out unionpay_private_key.pem 2048 # 对应公钥(用于换取银联登记的商户公钥) openssl rsa -in unionpay_private_key.pem -pubout -out unionpay_public_key.pem ``` > 商户公钥需在银联商户平台登记;回调验签用的是**银联平台证书**导出的公钥,生产环境应从银联商户平台下载。 ## 3. 支付流程 ```mermaid 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}` 后端查询为准。 ## 4. 后端集成(Node.js/Express) ### 4.1 目录与职责 ``` 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 ``` ### 4.2 下单参数(对齐银联全渠道 App 支付) | 字段 | 值 | 说明 | | --- | --- | --- | | `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` | 回调地址 | 银联异步通知地址 | ### 4.3 签名算法(sign / verifySign) ```js // 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` 是否匹配。 ### 4.4 创建支付(create) ```js 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 } ``` - **App 支付**:`tn` 是调起云闪付 App 的唯一凭证。 - **Web/手机网站支付**:客户端拿 `tn` 拼银联收银台地址跳转。 ### 4.5 回调处理(handleUnionPayNotify) ```js // 1. 验签(verifySign,MOCK 模式下跳过) // 2. respCode === '00' 表示交易成功 // 3. 取 orderId 匹配本地订单 → markPaidByOutTradeNo 幂等更新 ``` 回调幂等:同一 `orderId` 多次回调只更新一次,状态机 `PENDING → PAID`。 ### 4.6 Mock 模式 `MOCK_MODE=true` 时(无真实商户号也能端到端跑通): - 下单返回 `{ tn: 'mock_tn_...', payment_id, sandbox: true }`(web 额外返回 `redirect_url`)。 - 前端看到 `tn` 以 `mock_` 开头即不拉起 App,调用 `POST /v1/payment/simulate/success` 模拟支付成功。 - simulate 走与真实回调**相同**的 `markPaid` 幂等流程。 ## 5. 前端集成(Flutter) ### 5.1 模型与 API(lib/) ``` 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:统一支付结果弹窗 ``` ### 5.2 App 支付流程(payWithUnionPay) ```dart 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(`UnionPay` Android SDK / `UPPayPlugin`),由 SDK 完成唤起、支付结果回调与二次验签,可靠性更高。 ### 5.3 Web/手机网站支付流程 ```dart final created = await _api.createUnionPayPayment(productId: productId, source: 'web'); await _handleWebRedirect( context: context, url: created.redirectUrl, // 银联收银台 paymentId: created.paymentId, channelName: '云闪付', ); ``` `_handleWebRedirect` 弹窗提供「打开支付页 / 模拟支付成功(Mock)/ 取消」,真实模式打开支付页后轮询后端。 ### 5.4 最终状态确认 `_queryFinalStatus` 每 1s 轮询 `GET /v1/payment/{payment_id}`(最多约 12s 覆盖回调延迟),成功后弹出 `showPayResultDialog`(绿色对勾 + 渠道 + 订单号),点击「完成」回到商品页。 ## 6. 接口定义 | 方法 | 路径 | 说明 | | --- | --- | --- | | 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}` 模拟支付成功 | ## 7. 安全与注意事项 1. **签名与验签**:下单必须用商户私钥签名;回调必须用银联公钥验签,防止伪造通知。 2. **金额以服务端为准**:金额一律后端按 `product_id` 计算(单位分),不信任客户端。 3. **回调幂等**:`markPaid` 幂等,同一订单重复通知只处理一次。 4. **回调地址公网可达**:生产环境必须 HTTPS;本地联调可用内网穿透工具将 `UNIONPAY_NOTIFY_URL` 映射到外网。 5. **沙箱与生产网关**:沙箱 `gateway.test.95516.com`,生产 `gateway.95516.com`;上线前务必确认 `UNIONPAY_SANDBOX=false`。 6. **敏感信息**:商户号、私钥证书不得进入版本控制(已加入 `.gitignore`)。 7. **交易对账**:生产建议定时调用银联 `00 查询` 接口对账,处理回调丢失场景。