1. 从 tn 到 paydata:移动支付链接转换的核心逻辑
移动端 H5 页面里拉起云闪付完成支付,这件事看起来只是“点一下按钮跳过去”,但真正做过支付接入的人都知道,中间最麻烦的往往不是支付本身,而是参数怎么传、链接怎么转、不同环境怎么兼容。标题里提到的tn、scheme、paydata,其实就是这条链路里最关键的三个角色。
先说tn。在银联体系里,tn是交易流水号(Transaction Number),它由银联侧生成,用来唯一标识一笔交易。你在接入云闪付支付时,后端调用下单接口,银联返回的核心字段之一就是tn。这个值本身不是链接,也不是可以直接丢给浏览器跳转的 URL,它更像是一张“取票凭证”。用户拿着这张凭证,通过特定的入口进入云闪付,云闪付再根据tn去查询这笔交易的详情,最终完成付款。
那scheme是什么?简单理解,scheme就是 App 之间互相唤起的“暗号”。比如alipays://是支付宝的 scheme,upwrp://或uppay://是云闪付相关的 scheme。H5 页面本身运行在浏览器里,浏览器没有权限直接打开另一个 App,但可以通过访问一个符合规范的 scheme 链接,让操作系统识别并尝试唤起对应 App。这就是所谓的“唤起”。
paydata则是把tn和其他必要参数打包后形成的一个数据体。不同渠道对paydata的格式要求不一样,有的要求 Base64 编码,有的要求 JSON 字符串再做 URL Encode,还有的要求直接拼接成 query string。标题里说“tn 转链接 paydata”,本质上就是:拿到后端返回的 tn,按照云闪付要求的格式组装成 paydata,再拼成 scheme 链接,最终在 H5 里触发跳转。
这条链路解决的核心问题是:H5 页面无法直接调用云闪付 SDK,但业务又需要在网页里完成支付。适合谁来参考?主要是三类人:一是做 H5 收银台的前端,二是做聚合支付的后端,三是需要在自己 App 内嵌 H5 里接入云闪付的移动端开发。只要你的场景里出现“网页里拉起云闪付”,这套逻辑就绕不开。
我见过不少团队一开始以为直接把tn拼到某个 URL 后面就行,结果测试时发现安卓能跳、iOS 没反应,或者云闪付打开了却提示“交易不存在”。问题基本都出在paydata的组装格式和 scheme 的兼容处理上。下面我按实际项目里的做法,把整条链路拆开讲清楚。
2. 核心细节解析:tn、scheme、paydata 到底怎么配合
2.1 tn 的获取时机与后端职责边界
tn不是前端生成的,也不是随便编一个就能用。它的来源只有一个:后端调用银联的下单接口,银联返回。这里有一个很容易踩的坑——下单和支付是两步。很多新手会以为调了下单接口用户就付钱了,其实下单只是“占了一个交易号”,用户还没付款。真正的扣款发生在云闪付 App 内用户确认之后。
后端在下单时通常需要传这些信息:商户号、订单号、金额、交易时间、回调地址、商品描述等。银联返回的报文里,tn一般在tn字段或者respCode为成功后的业务字段里。后端拿到tn之后,不应该直接把它丢给前端就完事,因为前端还需要知道“用哪个 scheme 跳”“paydata 怎么拼”。比较稳妥的做法是:后端把tn和组装好的paydata一起返回,或者后端直接返回一个完整的跳转链接,前端只负责触发。
我个人的经验是,能后端做的就不要让前端做。原因很简单:paydata的组装规则可能会因为渠道版本变化而调整,如果散落在前端代码里,改起来要发版;放在后端,改完直接生效。而且paydata里可能包含签名或敏感字段,放前端有泄露风险。
2.2 scheme 的格式与常见变体
云闪付的 scheme 在不同场景下写法有差异。常见的有这几种形态:
uppay://uppayment/pay?...这类是云闪付 App 的标准支付入口upwrp://开头的是银联钱包相关的唤起协议- 有些渠道会要求用
https://开头的中间页,再由中间页 302 到 scheme
为什么会有这么多变体?因为云闪付 App 本身在安卓和 iOS 上的 URL Scheme 注册不完全一致,而且不同版本的 App 对参数解析的严格程度也不同。安卓上相对宽松,iOS 上如果 scheme 没注册或者参数格式不对,系统会直接静默失败,用户点了没反应,控制台也不一定报错。
这里有个实操细节:iOS 上 scheme 唤起必须在用户手势的同步调用栈里触发。什么意思?就是你不能在setTimeout或者异步请求的回调里直接location.href = scheme,否则 iOS 会认为这不是用户主动行为,从而拦截。正确的做法是用户点击按钮后,先同步触发跳转,或者用一个隐藏的iframe来承载 scheme。不过现在很多浏览器对 iframe 方式也有限制,所以更稳的方案是:点击按钮时直接window.location.href = schemeUrl,如果需要先请求后端拿参数,那就提前把参数准备好,点击时直接用。
2.3 paydata 的组装规则与编码陷阱
paydata是整条链路里最容易出错的地方。它的本质是一个字符串,里面包含了tn以及其他渠道要求的字段。常见的组装方式有两种:
第一种是直接拼接:
tn=1234567890&mid=商户号&...第二种是 JSON 后 Base64:
const paydata = btoa(JSON.stringify({ tn: '1234567890', mid: 'xxx' }));然后把这个paydata作为参数拼到 scheme 后面:
uppay://uppayment/pay?paydata=xxxxx坑在哪里?URL Encode 的次数。有些渠道要求paydata先 Base64,再 URL Encode 一次;有些要求 Base64 后直接拼,不能再 Encode;还有些要求 Encode 两次。如果你 Encode 次数不对,云闪付打开后会提示“参数错误”或者“交易不存在”。我遇到过最离谱的一次是,后端 Encode 了一次,前端拿到后又 Encode 了一次,结果云闪付解析出来是乱码。
另一个坑是字符集。Base64 之前一定要确认是 UTF-8,如果后端用的是 GBK,前端用btoa会直接报错,因为btoa只支持 Latin-1 字符。这时候需要先用encodeURIComponent处理再 Base64,或者用TextEncoder转成 Uint8Array 再 Base64。
2.4 H5 环境下的兼容性差异
H5 拉起云闪付,在不同容器里表现完全不同:
| 环境 | 表现 | 注意事项 |
|---|---|---|
| 微信内置浏览器 | 通常无法直接唤起,需要引导外部打开 | 微信会拦截 scheme,建议提示用户用浏览器打开 |
| 支付宝内置浏览器 | 可能被拦截,视版本而定 | 不建议在支付宝内拉起云闪付 |
| 手机自带浏览器 | 一般可以正常唤起 | iOS Safari 需要用户手势触发 |
| App 内嵌 WebView | 取决于 WebView 配置 | 需要原生侧允许 scheme 跳转 |
| 云闪付 App 内 H5 | 可以直接跳转 | 但场景较少 |
微信里是最麻烦的。微信对 scheme 的拦截比较严格,普通 H5 里直接跳基本没戏。常见的做法是做一个中间页,提示用户“点击右上角在浏览器中打开”,然后在外部浏览器里再触发 scheme。这个体验不算好,但合规且稳定。
3. 实操过程:从 tn 到成功唤起云闪付的完整链路
3.1 后端下单并返回 paydata
假设后端已经调通了银联下单接口,拿到了tn。接下来后端需要组装paydata。以下是一个常见的组装示例(以某渠道要求 Base64 + URL Encode 为例):
// Node.js 示例 const payload = { tn: '202401011234567890', mid: '898110158110001', // 其他渠道要求的字段 }; const jsonStr = JSON.stringify(payload); const base64Str = Buffer.from(jsonStr, 'utf-8').toString('base64'); const paydata = encodeURIComponent(base64Str); // 最终返回给前端 res.json({ code: 0, data: { paydata: paydata, scheme: `uppay://uppayment/pay?paydata=${paydata}` } });这里为什么要用Buffer.from而不是btoa?因为 Node.js 环境里btoa对中文支持不好,Buffer更稳。前端如果要做同样的处理,可以用:
function toBase64(str) { const bytes = new TextEncoder().encode(str); let binary = ''; bytes.forEach(b => binary += String.fromCharCode(b)); return btoa(binary); }3.2 前端触发 scheme 跳转
前端拿到scheme后,不要急着直接跳。先判断当前环境:
function isWechat() { return /MicroMessenger/i.test(navigator.userAgent); } function isIOS() { return /iPhone|iPad|iPod/i.test(navigator.userAgent); } function openUnionPay(schemeUrl) { if (isWechat()) { // 微信内提示外部打开 showGuideMask(); return; } if (isIOS()) { // iOS 必须在用户手势同步栈里触发 window.location.href = schemeUrl; } else { // 安卓可以用 iframe 兜底 const iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.src = schemeUrl; document.body.appendChild(iframe); setTimeout(() => { document.body.removeChild(iframe); }, 2000); } }安卓用 iframe 的原因是:部分安卓浏览器直接改location.href会弹出一个“是否打开云闪付”的确认框,如果用户点了取消,页面可能白屏。用 iframe 可以避免页面跳走,同时也能触发唤起。但注意,现在很多现代浏览器对 iframe 唤起也有限制,所以更稳的做法还是直接location.href,然后设置一个定时器检测页面是否隐藏,如果没隐藏说明唤起失败,再引导用户下载或换方式。
3.3 唤起失败的兜底与检测
唤起成功和失败的判断,在 H5 里没有标准事件。常用的检测手段是visibilitychange:
let hasHidden = false; document.addEventListener('visibilitychange', () => { if (document.hidden) { hasHidden = true; } }); // 触发跳转后 setTimeout(() => { if (!hasHidden) { // 说明没有跳走,唤起失败 showDownloadGuide(); } }, 2500);这个 2500ms 是经验值。太短了可能云闪付还没启动完,太长了用户等得着急。我实测下来,安卓中低端机可能需要 3 秒,iOS 一般 1.5 秒内就有反应。所以可以做成 2 秒开始检测,3 秒还没反应就提示。
3.4 回调与订单状态确认
用户跳去云闪付付款,付完之后云闪付会回调后端配置的notifyUrl。前端这边不能只依赖用户返回页面来判断支付成功,因为用户可能付完直接杀进程。正确的做法是:
- 后端收到回调后更新订单状态
- 前端在用户返回页面时,轮询后端订单状态接口
- 轮询间隔建议 1.5 秒一次,最多轮询 10 次
- 如果轮询到成功,跳转成功页;如果超时,提示“支付结果确认中,请稍后查看订单”
这里有个细节:轮询接口要做防重放和签名校验,不能只传订单号就返回状态,否则容易被刷。
4. 常见问题与排查技巧实录
4.1 云闪付打开了但提示“交易不存在”
这是最高频的问题。原因通常有三个:
tn过期了。银联的tn一般有有效期,常见是 30 分钟,超过后云闪付查不到交易paydata组装格式不对,云闪付解析不出tn- 下单和唤起用的不是同一个商户环境,比如下单是测试环境,唤起是生产环境
排查顺序:先看后端下单返回的tn是否还在有效期,再抓包看paydata解码后内容是否正确,最后确认环境一致性。
4.2 iOS 点击没反应
iOS 上最常见的原因是异步回调里触发 scheme。比如:
// 错误做法 btn.onclick = async () => { const res = await fetch('/api/getPayData'); const data = await res.json(); window.location.href = data.scheme; // iOS 会拦截 };正确做法是提前把scheme拿到,点击时直接跳:
let cachedScheme = ''; // 页面加载时就请求好 fetch('/api/getPayData').then(res => res.json()).then(data => { cachedScheme = data.scheme; }); btn.onclick = () => { if (cachedScheme) { window.location.href = cachedScheme; } };如果必须点击后再请求,那就用一个同步的a标签,href先设为javascript:void(0),点击后请求回来再改href并触发click(),但这种方式在 iOS 上也不一定稳。最稳的还是提前缓存。
4.3 微信内无法唤起
微信内直接唤起云闪付基本不可行。可行的方案是:
- 做一个遮罩层,引导用户点击右上角“在浏览器打开”
- 或者用微信的
wx.openUrl相关能力(需要公众号配置) - 或者提示用户复制链接到浏览器
不要试图用各种 hack 绕过微信拦截,一是容易被封,二是体验很差。
4.4 paydata 编码后长度超限
有些渠道对paydata的长度有限制,比如不能超过 1024 字符。如果字段太多,Base64 后会超。这时候需要精简字段,只传必要的tn和商户标识。另外,URL Encode 后长度会增加约 30%,如果接近上限,可以考虑用短链接中转。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方向 |
|---|---|---|
| 点击无反应 | iOS 异步触发 / scheme 错误 | 改为同步触发,检查 scheme |
| 提示交易不存在 | tn 过期 / paydata 格式错 | 检查有效期和编码 |
| 微信内打不开 | 微信拦截 scheme | 引导外部浏览器打开 |
| 安卓弹框后白屏 | location.href 直接跳转 | 改用 iframe 或加兜底 |
| 支付成功但订单未更新 | 回调未处理 / 轮询未做 | 检查 notifyUrl 和轮询逻辑 |
| paydata 解析乱码 | 字符集不是 UTF-8 | 统一用 UTF-8 编码 |
4.6 实操心得:几个让我少加班的小技巧
第一个技巧:把 scheme 和 paydata 的组装做成配置化。不同渠道的格式要求不一样,如果硬编码在代码里,每接一个渠道就要改一次。我一般会定义一个渠道配置表,把 scheme 前缀、编码方式、参数字段名都放在配置里,新增渠道只加配置不改逻辑。
第二个技巧:在测试环境准备一个“模拟唤起页”。因为云闪付的测试环境不一定随时可用,我通常会做一个页面,把scheme打印出来,同时提供“复制 scheme”“手动跳转”按钮,方便排查是参数问题还是唤起问题。
第三个技巧:日志要打全。前端在触发 scheme 前,把scheme、paydata、userAgent、时间戳都上报到日志服务。这样用户反馈“付不了”的时候,你能快速定位是哪个环节出了问题,而不是靠猜。
第四个技巧:金额单位要确认。银联下单金额一般是分,不是元。我见过有团队传了“1”以为是 1 元,结果用户付了 1 分,测试时没发现,上线后才发现对账不平。这种低级错误一旦发生,排查起来非常痛苦。
5. 不同场景下的方案选型与扩展思路
5.1 纯 H5 收银台 vs App 内嵌 H5
纯 H5 收银台的特点是运行在浏览器里,没有原生能力,只能靠 scheme。这种场景下,兼容性处理是重点,尤其是微信和 iOS。App 内嵌 H5 则不同,原生侧可以拦截 URL 请求,识别到特定的 scheme 后直接调用原生模块唤起云闪付,甚至可以直接集成云闪付 SDK。如果你们有自己的 App,强烈建议走原生拦截方案,稳定性和体验都会好很多。
原生拦截的做法是:WebView 设置shouldOverrideUrlLoading,当 URL 以uppay://开头时,不加载这个 URL,而是取出参数,调用原生云闪付 SDK。这样就不依赖系统 scheme 唤起了,成功率接近 100%。
5.2 多域名下的 H5 分发问题
热搜词里提到了“uniapp 封装 h5 如何指向 2 个域名”,这其实和支付链路也有关。有些团队会把收银台部署在多个域名下,比如主站域名和备用域名。这时候要注意:下单时配置的回调域名必须和实际访问域名一致,否则银联回调可能被跨域拦截。另外,如果用了 CDN,要确保 scheme 跳转不被 CDN 的中间页拦截。
5.3 与小程序跳转的对比
小程序里拉起云闪付又是另一套逻辑。小程序不能直接用 scheme,需要通过wx.navigateToMiniProgram或者云闪付提供的小程序跳转能力。如果你们同时有 H5 和小程序,建议把支付参数组装逻辑抽成公共模块,两端共用,只是跳转方式不同。
5.4 安全与合规注意事项
支付链路涉及资金,安全不能马虎。几个基本要求:
paydata里的敏感字段要加密或签名,不能明文传- 回调接口要验签,防止伪造回调
- 订单状态查询接口要做权限校验,不能凭订单号就能查
- 前端不要存储
tn或paydata到 localStorage,用完即弃
另外,云闪付的接入需要商户资质,个人开发者一般拿不到。如果你是在做聚合支付,要确保上游渠道是合规的,不要接来路不明的通道。
5.5 性能与体验优化
最后说几个体验上的优化点。第一,预下单。用户进入收银台时就可以先调下单接口,把tn和scheme准备好,用户点击时直接跳,减少等待。第二,骨架屏。唤起云闪付需要时间,页面上给个 loading 或者“正在打开云闪付”的提示,避免用户以为卡死。第三,失败引导。唤起失败时,不要只提示“失败”,要给具体指引,比如“请确认已安装云闪付”“请用浏览器打开”等。
我在实际项目里踩过最深的坑,就是 iOS 上把 scheme 放在fetch回调里跳,测试时用安卓一直没问题,上线后 iOS 用户大面积反馈点不动。后来改成页面加载时预请求、点击时同步跳转,问题才解决。所以如果你现在正在做这块,记住一句话:iOS 的 scheme 唤起,必须发生在用户手势的同步调用栈里,这一条能帮你省掉很多排查时间。