云闪付TN转Android/iOS paydata:服务端加密与联调实践 1. 从一次联调说起为什么要把 TN 转成 paydata去年有段时间我几乎每周都在处理同一个问题商户后台下完单拿到云闪付返回的一串TN客户端拿着它去拉起支付Android 上一切正常到了 iOS 就卡在“支付参数错误”或者干脆没反应。更让新同事困惑的是明明接口文档里写的是“传 tn 即可”可到了某些版本的 SDK 上偏偏要传一段看起来像乱码的paydata。这个问题的本质是把支付链路里的“交易令牌”和“客户端可消费的支付参数”这两件事混成了一件事。TN 是服务端之间流转的短令牌它代表的是一笔已经完成预下单的交易本身不包含足够的校验信息而paydata 是客户端 SDK 真正能识别、能自校验的一段密文它把 tn 连同商户号、订单号、时间戳等内容打包后加密客户端 SDK 用内置公钥解出来再判断这笔交易是不是合法的。所以“云闪付 TN 转换为 AndroidIOS 的 paydata”这个事说白了就是在服务端把一次性的短令牌加工成两个平台都能直接吃进去的参数。它解决的是跨端支付参数不一致、TN 明文字段被篡改、TN 在有效期内被重复消费这三类问题。这套东西适合谁看做过移动支付接入的后端同学、正在被双端联调折腾的客户端同学以及想搞清楚“为什么服务端要多做这一步”的产品和测试同学。下面我按自己实际踩过的路把这套转换完整拆一遍。2. 整体方案设计与思路拆解2.1 需求到底从哪来TN 直接下发不行吗先说清楚 TN 是什么。商户服务端调用下单接口网关返回一个短字符串通常是十几到二十几位数字有效期普遍在几分钟到十几分钟之间而且一次性——用掉就作废过期也不能用。它的定位类似餐厅取餐号后厨网关认这个号能对应到一份订单但顾客客户端拿着号并不能证明什么。早期很多团队图省事直接把 TN 明文塞给客户端客户端再原样丢给 SDK。这个做法能跑通但有三处隐患。第一是可篡改。TN 是明文短串中间环节H5 中转、深链跳转、日志采集都有可能被替换成另一笔订单的号如果没有额外校验客户端拉起的就是别人的交易。第二是可重放。同一个 TN 在有效期内被反复调用虽然网关侧通常会拦但拦截是发生在支付阶段用户体验上是“点了没反应”或“报错”排查成本高。第三是平台差异无法统一。Android 和 iOS 的 SDK 对入参结构的解析实现不一样明文下发意味着两边都要各自拼参数任何一边改了规则另一方就得跟着改。所以业内比较稳妥的做法是服务端统一加工客户端只做透传。服务端拿到 TN 后按约定规则组装明文、加密、编码输出一段 paydataAndroid 和 iOS 拿到的就是“可以立刻使用的完整参数”客户端不参与任何拼接逻辑。2.2 为什么转换必须放在服务端这里有个很实际的取舍。有人会问加密公钥是公开的客户端自己加密不行吗技术上可以工程上不建议原因有三个。一是密钥管理。加密用的公钥虽然不保密但配套的参数规则排序方式、时间戳精度、随机串长度、编码方式属于协议细节放在客户端意味着每次协议微调都要发版而移动端发版周期是不可控的。放在服务端改一行配置就能灰度。二是参数来源。paydata 里除了 tn通常还要带商户号、订单号、时间戳、随机串。这些字段的正确来源是服务端的订单库客户端手里只有订单号拿不到可信的商户号。让客户端去拼等于让它自己编造可信数据。三是可观测性。服务端生成 paydata 时可以顺手打点生成耗时、失败原因、同一订单的生成次数。这些数据在排查“为什么用户点了没反应”时非常关键。客户端生成的话你只能靠用户反馈。提示服务端转换不等于把密钥也放服务端。公钥加密、客户端解密私钥始终不出网关这条边界要守住。2.3 完整链路全景从下单到回调把整条链路摊开看大概是这么几步。客户端提交订单商户服务端生成内部订单号。商户服务端调用下单接口拿到 TN 和过期时间。服务端按规则组装明文串用公钥加密产出 paydata。服务端把paydata、tn可选用于排查、expireAt一起返回给客户端。客户端根据平台类型调用对应 SDK 的支付方法传入 paydata。SDK 解密校验拉起支付面板用户完成付款。支付结果通过客户端回调 服务端异步通知双通道返回服务端以异步通知为准。这条链路里第 3 步是本文的重点第 4 步的字段设计也很讲究——我后面会专门讲为什么要保留 tn 明文返回。2.4 方案选型三种常见做法的对比实际落地时我见过三种做法各有适用场景列出来方便对照。做法实现方式优点缺点适用场景明文透传 TN服务端只下发 tn实现最简单联调快无校验、不可防篡改、双端规则不统一内部测试、极简 Demo客户端自行组装下发 tn 参数客户端拼装加密服务端压力小协议变更需发版参数可信度低单一平台、长期不发版的固化 App服务端统一转换服务端产出 paydata客户端透传规则统一、可灰度、可观测服务端需维护密钥与协议版本生产环境、多端多版本并存我们最终选的是第三种。理由很直接项目要同时支持 Android、iOS还有 H5 和小程序的中转场景客户端版本碎片化严重只有把规则收到服务端才能做到“老版本 App 不改也能继续用”。3. paydata 的构成与加密原理3.1 明文串的组装规则排序、拼接、编码paydata 加密前的明文一般是一段keyvalue用连接的字符串规则和常见的签名串很像但有三个细节必须掐死。第一参数名按 ASCII 升序排序。这一步是为了让同一组参数无论谁拼、拼多少次结果都完全一致。不排序的话服务端和客户端拼出来的顺序不同密文就不同排查时根本对不上。第二空值参数不参与拼接。有些字段是可选的值为空时如果还拼成key不同语言的序列化实现可能给出不同结果有的输出空串有的输出null直接导致解密失败。第三值必须做 URL 编码且统一 UTF-8。这是最容易出事的地方。URLEncoder.encode在 Java 里会把空格编码成而标准 URL 编码应该是%20。如果服务端用客户端按%20解字符串就变了。我一般会额外做一次替换.replace(, %20)把差异抹平。具体到一个典型实现明文大概长这样merIdM1000000001nonceStra1b2c3d4e5orderIdORD20240101001timestamp1704096000000tn88202301011234567890字段说明我列个表方便对照自己项目的实际情况。字段含义长度建议注意事项tn下单返回的交易令牌原始长度不做任何截断或格式化merId商户编号固定长度与网关分配的一致不能带空格orderId商户订单号≤32同一订单多次生成应保持一致timestamp毫秒级时间戳13 位统一用毫秒别混用秒nonceStr随机串16~32每次生成都不同用于防重放3.2 RSA 加密与密文长度计算组装完明文下一步是加密。行业内普遍用RSA 公钥加密 PKCS#1 v1.5 填充输出 Base64。这里有个必须算清楚的账明文不能超过密钥长度减去填充开销。以常见的两种密钥长度为例1024 位密钥密文固定 128 字节PKCS#1 填充占 11 字节明文上限 117 字节。2048 位密钥密文固定 256 字节明文上限 245 字节。按上面那个明文样例算一下merIdM1000000001nonceStra1b2c3d4e5orderIdORD20240101001timestamp1704096000000tn88202301011234567890大约 115 个字符。如果全部是 ASCII正好卡在 1024 位的 117 字节上限边缘但只要 tn 长一点、订单号长一点立刻就超。所以我一般直接上2048 位把余量留足省得后期加字段还要换密钥。Base64 之后的长度也要心里有数2048 位密钥对应 256 字节密文Base64 编码后是4 × ⌈256/3⌉ 344个字符。这个数字可以直接用来做校验——如果客户端收到的 paydata Base64 解码后不是 256 字节那基本可以断定密钥位数配错了或者密文被截断了。注意不要自作聪明地做“分段加密再拼接”。分段加密后每段都带自己的填充拼起来是一段非法密文标准解密接口解不出来。如果明文真的超长正确做法是精简字段或者改用“加密会话密钥 对称加密数据”的混合方案。3.3 Base64 与 URL 编码两个平台的隐形坑密文出来是二进制必须转成可传输的文本这就是 Base64 的活儿。但 Base64 输出的字符集里有、/、三个特殊字符它们在不同传输通道里的命运完全不同。Android 侧如果通过 Intent extra 往下传在某些 ROM 上会被当成空格处理可能被截断。所以服务端输出后一定要再做一次URL 编码或者直接输出 URL-safe Base64把换成-/换成_去掉尾部。我更倾向后者因为只做一次转换客户端不需要再解码一次。iOS 侧通过 URL Scheme 或自定义 scheme 回跳时未编码的和/会破坏 URL 结构。iOS 上最典型的症状是“paydata 传到 SDK 后提示参数非法”实际原因就是中间被 URL 解码了一次。这里有个判断技巧如果同一个 paydata 在 Android 上能过、iOS 上不能过九成是编码问题不是加密问题。加密错了两边都过不了只有编码才会产生平台性差异。3.4 双端差异化输出一份数据两种形态虽然我强调“统一在服务端转换”但 Android 和 iOS 的 SDK 对入参的接受形态确实存在差别需要做一层适配。Android 的 SDK 方法通常接受paydata字符串内部自行 Base64 解码和解密。有些历史版本还兼容直接传 tn 明文但为了统一我一律传 paydata。iOS 的 SDK 方法同样接受paydata但对字符串里的空白字符极其敏感前后带一个空格都会判定非法。还有一类差异在回调 scheme上。iOS 需要在工程里注册 URL Scheme支付完成后通过 scheme 回跳Android 一般走 Activity 回调。所以服务端返回的 JSON 我一般设计成这样{ code: 0000, orderId: ORD20240101001, tn: 88202301011234567890, paydata: AbCd...344 字符左右, expireAt: 1704096300000, protocolVersion: v2 }tn明文为什么还要返回不是给客户端用的是给排查和日志用的。出问题时客户端上报 tn服务端能立刻定位到是哪一笔下单、什么时候生成的、有没有重复生成。protocolVersion则是为了灰度新规则上线时客户端可以按版本号选择行为。4. 服务端实现从 TN 到 paydata 的完整落地4.1 参数收集与排序的具体写法先看参数收集。这一步的关键是数据来源要单一。订单号、商户号从订单表取时间戳在生成时取一次并落库随机串用安全随机数生成。千万不要在多个方法里各取一次时间戳前后差几毫秒排查时就会怀疑人生。排序这块Java 里用TreeMap最省事天然按 key 的字典序排列。但要注意TreeMap的排序规则是String.compareTo对纯 ASCII 参数名来说就是 ASCII 升序符合要求。如果参数名里混了大小写compareTo是按码点排的大写字母在小写字母前面这点要和网关文档核对清楚。private static String buildPlainText(MapString, String params) { TreeMapString, String sorted new TreeMap(params); StringBuilder sb new StringBuilder(); for (Map.EntryString, String e : sorted.entrySet()) { String v e.getValue(); if (v null || v.trim().isEmpty()) { continue; // 空值不参与拼接 } if (sb.length() 0) { sb.append(); } sb.append(e.getKey()).append().append(urlEncode(v)); } return sb.toString(); } private static String urlEncode(String v) { try { return URLEncoder.encode(v, UTF-8).replace(, %20); } catch (UnsupportedEncodingException e) { throw new IllegalStateException(UTF-8 not supported, e); } }这段代码里有两个点值得单独说。一是trim().isEmpty()的判断能同时拦住null、空串和纯空格二是replace(, %20)前面提过这是 Java 编码器的默认行为不处理就是给后面埋雷。4.2 RSA 公钥加密的实现与密钥格式坑加密方法本身不复杂坑全在密钥格式上。RSA 公钥有两种常见格式PKCS#1以-----BEGIN RSA PUBLIC KEY-----开头和 PKCS#8/X.509以-----BEGIN PUBLIC KEY-----开头。网关一般下发 PKCS#8但有些老项目给的是 PKCS#1两者不能混用直接KeyFactory.generatePublic会抛InvalidKeySpecException。区分方法很简单看 PEM 头。BEGIN PUBLIC KEY用X509EncodedKeySpecBEGIN RSA PUBLIC KEY需要先转成 PKCS#8或者用 BouncyCastle 的RSAPublicKeySpec单独处理。我的建议是统一要求网关提供 PKCS#8少一层转换就少一个故障点。public static String encrypt(String plain, String publicKeyBase64) throws Exception { byte[] keyBytes Base64.getDecoder().decode( publicKeyBase64.replaceAll(-----[A-Z ]-----, ).replaceAll(\\s, )); PublicKey publicKey KeyFactory.getInstance(RSA) .generatePublic(new X509EncodedKeySpec(keyBytes)); Cipher cipher Cipher.getInstance(RSA/ECB/PKCS1Padding); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encrypted cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); // 输出 URL-safe Base64去掉尾部填充符 return Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted); }注意Cipher.getInstance的参数必须写全RSA/ECB/PKCS1Padding。有些环境默认给的是RSA/ECB/PKCS1Padding有些是RSA/None/NoPadding不写清楚在不同 JDK 版本上行为可能不一致。另外getUrlEncoder().withoutPadding()直接把 Base64 做成 URL 安全形式省掉了客户端再解码一次的动作。如果客户端 SDK 强制要求标准 Base64带和/那就改回Base64.getEncoder()但一定要在返回前做一次 URL 编码否则 Android 侧传参会出问题。4.3 一次完整的转换流程与耗时实测把上面几步串起来一个完整的转换方法大致是这样查订单 → 组装参数 → 生成明文 → 校验长度 → 加密 → 编码 → 落日志 → 返回。长度校验这一步我强烈建议加上而且要抛明确的异常int plainBytes plain.getBytes(StandardCharsets.UTF_8).length; int maxBytes keySize / 8 - 11; // PKCS#1 填充开销 if (plainBytes maxBytes) { throw new IllegalArgumentException( plain too long: plainBytes maxBytes); }关于耗时我在一台 4 核 8G 的机器上做过简单压测单次 RSA-2048 加密的耗时在0.3~0.8 毫秒之间2048 位密钥生成才是真正慢的操作几百毫秒但密钥只需初始化一次并缓存。所以 paydata 转换本身不会成为性能瓶颈整个接口的耗时主要花在查订单和调下单接口上。实测下来一个包含“查订单 生成 paydata”的接口P99 在15ms 左右其中 paydata 转换部分占不到 1ms。这也意味着如果接口慢问题几乎肯定不在加密上别往这个方向查。4.4 缓存、幂等与 TN 复用策略TN 是一次性的但用户可能会在一分钟内连点三次“立即支付”。如果每次都重新下单拿新 TN会产生三笔待支付订单后续对账会很痛苦。我的做法是按订单号做短缓存。同一个订单号在 TN 有效期内重复请求直接返回缓存的 paydata超过有效期或订单状态变化才重新下单。缓存 key 用orderIdTTL 设成 TN 有效期减去 60 秒的安全余量。String cacheKey paydata: orderId; String cached redis.get(cacheKey); if (cached ! null) { return JSON.parseObject(cached, PayDataResp.class); } // 未命中则下单 转换 PayDataResp resp createAndConvert(orderId); long ttl Math.max(30, (resp.getExpireAt() - System.currentTimeMillis()) / 1000 - 60); redis.setex(cacheKey, ttl, JSON.toJSONString(resp));这里留 60 秒余量是有讲究的如果缓存刚好在过期瞬间失效客户端拿到的 paydata 可能只剩几秒有效期用户走到支付页面时就已经过期体验上就是“点支付报错再点一次又好了”。留出余量可以把这类边界问题压到最低。5. 客户端接入要点Android 与 iOS 的差异处理5.1 Android 侧参数传递与回调处理Android 端相对简单核心就两件事把 paydata 原样传给 SDK处理好 Activity 回调。传参时最容易出问题的是字符串被二次处理。比如用 Intent 传值时如果 paydata 里有号某些场景下会被解析成空格如果走 Bundle 且没设编码中文虽然 paydata 里不该有中文也会出问题。所以我一般要求客户端在拿到 paydata 后先做一次长度校验URL-safe Base64 的 2048 位密文应该是 342 个字符344 减去两个填充差了就是传参被改了。回调处理上Android 的支付结果是从onActivityResult回来的requestCode要和拉起时传的一致。这里的经验是不要相信客户端回调里的“成功”。回调只用来做 UI 提示关闭 loading、跳转结果页真正的订单状态必须等服务端异步通知。我见过太多次“客户端显示成功、服务端没收到通知、订单一直是待支付”的案例最后都是靠主动查询接口兜底解决的。5.2 iOS 侧URL Scheme 与回跳参数解析iOS 端麻烦在回跳。支付完成后App 会被通过注册的 URL Scheme 唤起回跳链接里带着结果参数。这一步有三个细节要处理好。第一Scheme 必须唯一且已在工程注册。多个 SDK 共用同一个 Scheme 会互相抢表现为“支付完成回到 App但页面没反应”。排查时看AppDelegate的openURL有没有被调到没调到就是 Scheme 没配对。第二回跳参数要做 URL 解码。有些实现会把结果参数编码后塞在 query 里直接取值会拿到%2B这种形态比较字符串永远不相等。第三iOS 13 之后要走SceneDelegate。如果工程已经切换到多场景模式AppDelegate的openURL可能根本不触发需要在scene(_:openURLContexts:)里处理。这个坑我们在一个新项目上踩过老代码照搬过去就是不工作查了大半天。- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id *)options { if ([[url scheme] isEqualToString:yourpaymentscheme]) { [[UPPaymentControl defaultControl] handlePaymentResult:url completeBlock:^(NSString *code, NSDictionary *data) { if ([code isEqualToString:success]) { // 仅更新 UI订单状态以服务端为准 } else if ([code isEqualToString:fail]) { // 失败提示 } else if ([code isEqualToString:cancel]) { // 取消 } }]; return YES; } return NO; }5.3 双端统一的最小接口契约为了让两端少吵架我一般会和服务端约定一个最小契约写进接口文档里。字段类型必须说明paydatastring是已做 URL-safe 编码的密文客户端不得再做任何加工expireAtlong是毫秒时间戳客户端可据此提示“支付超时”orderIdstring是商户订单号用于回调对账tnstring否仅用于日志上报和问题排查protocolVersionstring是协议版本便于灰度契约里那句“不得再做任何加工”看着像废话但真的有用。我遇到过客户端同学出于“保险”又做了一次 URL 编码结果 paydata 变长了一倍SDK 直接报参数非法两边互相甩锅甩了两天。6. 常见问题与排查实录6.1 高频故障速查表下面这张表是我这几年攒下来的基本覆盖了 90% 的联调问题。现象最可能原因快速定位方式处理办法双端都报“参数非法”密钥位数或格式不匹配解码 paydata 看长度是否 256 字节核对公私钥是否成对确认是 PKCS#8Android 正常、iOS 失败paydata 中/未编码打印收到的原始字符串看有无空格改用 URL-safe Base64 或补 URL 编码提示“交易已过期”TN 超时或被重复消费对比 expireAt 与当前时间加缓存复用客户端加超时提示拉起后无任何回调回调 Scheme 未注册或未命中断点看 openURL 是否触发检查 Scheme 配置与 SceneDelegate偶发解密失败时间戳/随机串被重复使用查同一订单的生成日志每次生成必须新随机串明文超长报错字段拼接后超过 245 字节打印明文长度精简字段或换 2048 位密钥服务端收到通知但客户端显示失败客户端回调早于服务端通知对比两个时间点UI 以服务端查询结果为准6.2 三个我踩过的真实坑坑一Base64 换行。早期用某个库的Base64.encode默认每 76 个字符插入一个换行符密文里凭空多了几个\n。服务端日志里看不出来终端里就是换行客户端一解密就失败。后来统一改成不带换行的编码器问题消失。这个坑的隐蔽之处在于肉眼看日志一切正常只有把字符串按字节打出来才能发现。坑二时间戳精度不一致。服务端用秒级时间戳客户端校验逻辑按毫秒算差了 1000 倍直接判定“时间偏差过大”。这类问题不会报“签名错误”而是报一个很模糊的“参数校验失败”非常难查。现在的做法是时间戳字段名直接写成timestampMs用命名把精度钉死。坑三灰度期间新旧协议混跑。服务端升级了参数排序规则但老版本 App 还在用旧规则解密导致一部分用户支付失败。这个问题的教训是协议版本必须显式传递服务端根据客户端上报的版本决定用哪套规则而不是“一刀切升级”。我们后来加的protocolVersion字段就是那时候补的。6.3 排查时的正确顺序遇到支付拉不起来我的排查顺序固定是这四步基本能在十分钟内定位。第一步看服务端返回的 paydata 长度。URL-safe Base64、2048 位密钥长度应该是 342。不是这个数问题在服务端。第二步看客户端实际收到的字符串。和返回的做逐字符比对重点看有没有空格、有没有%前缀。不一致问题在传输层。第三步看 SDK 的原始错误码。不要只看自己封装的“支付失败”提示把 SDK 返回的 code 打出来。SDK 的错误码通常能直接指向“解密失败”“参数缺失”“过期”这几类。第四步看 TN 状态。如果前三步都正常去网关后台查这笔 TN 是不是已经被消费过、是不是已过期。这一步能解决大部分“莫名其妙失败”的问题。7. 上线前的加固日志脱敏与监控埋点7.1 日志该怎么打才安全paydata 本质上是可解密的支付凭证全量落日志等于把凭证公开了。我的做法是只打指纹不打原文取 paydata 的 SHA-256 前 16 位作为指纹日志里记指纹、长度、生成时间、订单号。排查时用指纹反查既不影响定位也不会泄露内容。同样要脱敏的还有 TN。TN 虽然是短令牌且一次性但有效期内的泄露仍有风险。日志里保留前 6 位和后 4 位中间打星号足够定位到具体订单。7.2 值得盯的几个指标paydata 转换这个环节我一般会埋四个指标都很轻量但对稳定性帮助很大。转换成功率正常应该接近 100%掉到 99% 以下就说明有系统性问题。明文长度分布P99 接近 245 字节时说明字段设计该精简了。同订单重复生成次数平均值应该接近 1明显偏高说明缓存没生效或客户端在疯狂重试。从生成到拉起的时间差这个值如果接近 TN 有效期说明用户等待过久可以考虑把有效期提示做在 UI 上。7.3 一个容易被忽略的兜底主动查询不管客户端回调、服务端通知都可能丢。所以我会在订单详情页加一个“刷新状态”的按钮背后调主动查询接口。这个兜底看着土但在真实场景里救过很多次场——用户付了钱、通知丢了、页面一直转圈点一下刷新状态同步过来了投诉就没了。我个人在实际操作中的体会是支付这条链路上任何“应该会到”的消息都要当成“可能不到”来设计。paydata 转换做得再规范也只是把参数正确送达客户端真正决定用户体验的是结果不确定时你有没有一条可靠的退路。