
1. 项目背景与多端支付的整体设计思路1.1 为什么选择UniApp做多端支付UniApp这两年在前端圈子里热度一直不减最大的卖点就是一套代码编译到微信小程序、App、H5等多个平台。我们团队当时接这个项目的时候业务方提的需求特别直接一套商城系统要覆盖微信小程序、iOS/Android App、微信公众号H5三个入口所有端都要能正常支付订单数据要通会员体系要通。如果在以前这基本意味着三套前端代码、三套支付逻辑光是维护就够喝一壶的。最后我们选了UniApp作为统一前端框架支付相关的逻辑尽量收敛在一个公共模块里再针对各端差异做适配层处理。有人可能会问为什么不直接用纯原生开发如果只做一个App端原生确实没毛病。但业务方的核心诉求是覆盖尽可能多的流量入口小程序、H5、App一个都不能少用原生就得写三遍。用UniApp的话至少UI层和业务交互层能复用大部分代码支付这种链路长的模块只需要拆出平台差异部分单独处理。实际做下来支付核心代码里平台相关的大概占三成七成都是通用的这已经是很大的成本节约了。我个人的建议是如果你接手的项目是多端并行的商城、知识付费、内容付费类产品UniApp这个技术选型是成立的。它未必是性能最优解但从商业项目的投入产出比来看这个跨端方案值得认真考虑。接下来我把整条支付链路的细节、踩过的坑、以及最终沉淀下来的实现方案一次性讲清楚。1.2 多端支付的方案选型与技术底座支付方案的选型本质上是对“端”做矩阵分析。微信小程序端只能走微信支付而且必须用微信小程序的支付接口App端需要支持微信支付和支付宝支付两个渠道都得接入H5端在微信浏览器里可以走公众号支付JSAPI支付在外部浏览器里只能走支付宝的H5支付或者微信的H5支付Native支付其中微信H5支付还需要单独签约且必须配置支付目录和授权域名。我画过一张特别简单的矩阵表方便团队内部对齐运行端微信支付支付宝支付主要支付方式微信小程序支持不支持wx.requestPaymentApp-Android支持支持唤起App客户端App-iOS支持支持唤起App客户端H5-微信浏览器支持不支持JSAPI公众号支付H5-外部浏览器支持支持微信H5/Native、支付宝H5这个矩阵意味着后端下单接口在设计的时候必须接收一个“支付渠道”参数而不是写死。前端的UniApp在调用支付时也需要判断当前的运行环境。常用的API是uni.getSystemInfoSync和uni.getEnv配合uni.getProvider可以拿到当前端支持的支付服务商列表。我们用这个方式在模块初始化时动态获取可用的支付通道而不是硬编码这样后续扩展新渠道只需加映射不改业务代码。技术底座方面我们选了Vue 2版本的UniApp开发当时Vue 3版本还不够稳UI层用的uni-ui配合一套业务组件库状态管理用的Vuex网络请求统一封装了uni.request的Promise版。支付模块单独放在src/utils/payment.js里只导出几个方法createPayment(paymentData)、payByWechat(...)、payByAlipay(...)。后端接口只返回拉起支付所需的最小参数集合所有端都能认。1.3 支付流程的本质拆解很多人一听到支付就头皮发麻其实拆开来看所有端的支付核心就三步创建订单拿到预支付标识、用预支付标识拉起收银台、等回调更新订单状态。UniApp做多端支付说白了就是把这三步里的“拉起收银台”这个动作按平台做差异化适配。具体来说创建订单前端提交商品信息、金额、用户ID到后端后端生成订单号调用微信/支付宝的统一下单接口拿到prepay_id或trade_no。拉起收银台前端拿着这些参数调用uni.requestPayment平台底层会把它翻译成wx.requestPayment小程序端、plus.paymentApp端、或者跳转支付链接H5端。回调更新支付平台异步通知后端后端验签后更新订单状态再通过WebSocket或轮询通知前端刷新页面。明白这个底层的相似性之后再去看各端配置项就不会乱了。所有的差异都源自平台的安全要求和鉴权机制不同本质上都是对“这笔支付是可信的、资金流向是明确的”这个目标的加固。2. 各端支付的关键差异与配置要点2.1 微信小程序端支付参数与签名流程微信小程序支付走的是wx.requestPayment这个API。看起来简单但前置条件不少。首先小程序必须在微信公众平台完成认证然后开通微信支付商户号把小程序AppId和商户号做绑定。这个绑定关系是后续一切操作的基石申请下来之后要等微信审核一般1到3个工作日。小程序的支付参数前端真正需要用到的就是微信后端统一下单接口返回的那六个字段timeStamp、nonceStr、package注意这个package的值是prepay_idxxxx、signType、paySign。很多新手容易把package和prepay_id搞混实际上拉起支付时需要的是整个package字符串不是单独的prepay_id。后端生成paySign时用的签名串格式是固定的appIdxxxnonceStrxxxpackageprepay_idxxxsignTypeMD5timeStampxxx这里的appId是小程序的AppId而不是商户号。我们团队当时就有人在这踩了坑用商户号去拼签名串结果一直报签名错误。还有一个细节是时间戳字段小程序端叫timeStamp首字母小写但在拼接签名时字段顺序必须严格按字典序排列用MD5加密后转大写。前端拿到这些参数之后调用方式很简单uni.requestPayment({ provider: wxpay, timeStamp: paymentData.timeStamp, nonceStr: paymentData.nonceStr, package: paymentData.package, signType: MD5, paySign: paymentData.paySign, success: function(res) { // 支付成功等待后端回调确认 }, fail: function(err) { // 用户取消或支付失败 } });小程序端唯一要注意的就是参数类型。timeStamp在部分版本的微信里必须是字符串如果你从后端拿到的是Number类型建议直接toString再传避免iOS端偶发问题。2.2 App端微信支付与支付宝支付的集成差异App端是三个端里最折腾的。UniApp官方推荐用uni.requestPayment统一拉起底层自动判别是微信还是支付宝。但用好这个API的前提是原生工程里已经正确集成了对应的SDK。如果用HBuilderX云打包需要在manifest.json里配置微信支付的AppId、Universal LinksiOS以及支付宝的Scheme。如果用本地离线打包那还得去Android工程里配置WXPayEntryActivity在iOS工程里配置URL Scheme和Universal Links。这里要多说一句Universal Links。从iOS 9开始微信SDK要求App必须配置Universal Links才能做支付跳转这也是我踩过最深的一个坑。只配URL Scheme在iOS 13以上的系统里基本拉不起微信。配置Universal Links需要三步让后端配置一个apple-app-site-association文件并放到HTTPS根目录或指定路径、在Xcode里关联域名、在微信开放平台填写对应的Universal Links地址。每一步都容易被忽略但只要漏一步iOS拉起微信支付就会百分百失败。支付宝在App端就省心很多只需要在manifest.json里配置URL Scheme比如alipay2021000123456789然后uni.requestPayment的provider传alipay就行。需要注意的是iOS的URL Scheme必须是全局唯一的不能和其他App冲突否则系统会弹窗提示“无法打开”。2.3 H5端公众号支付的场景限制与处理方案H5端是很多人容易忽略的一个端但它其实坑最多。如果用户在微信浏览器里打开H5页面支付的唯一正解是公众号支付JSAPI支付前提是页面域名必须有对应的认证服务号并且和商户号绑定。前端流程大致是先通过OAuth2静默授权拿到用户的openid然后把openid传给后端后端再统一下单。静默授权是H5支付特别关键的一环。如果用户第一次访问微信会跳转到一个授权确认页这个页面体验还行但会多一步跳转。如果用户已经关注了服务号静默授权的成功率会提高很多。实际使用中我们直接去掉非静默授权只保留snsapi_base因为支付不需要用户的昵称头像只需要openid。H5端拉起支付的代码看起来和小程序类似但实际执行的是跳转window.location.href paymentData.mwebUrl; // 微信H5支付或JSAPI支付返回的跳转链接这里要注意JSAPI支付的mwebUrl并不是直接打开的而是需要在后端构造一个表单POST到微信的支付网关然后自动发起跳转。UniApp里处理这类跳转比较直接用window.location.href重定向就行但iOS的Safari对iframe内的跳转限制很严我们的方案是显式打开一个新的页面。H5端还有一个很折磨人的问题就是支付完成后如何回到原来的页面。微信支付成功后默认会停留在微信的支付成功页用户需要手动点击“完成”按钮才能回到商户页面。这里有一个安全兜底方案在订单创建时后端同时返回一个redirectUrlH5端在前端监控订单状态用轮询或WebSocket一旦检测到订单已支付就自动跳转回业务页。2.4 后端接口的设计原则好的后端支付接口设计应该是“轻前端、重后端”。前端只负责把业务数据提交给后端后端完成所有的签名、下单、验签动作前端拿到的只是可以直接使用的支付参数。千万不要在前端代码里拼接签名串更不要在前端存储商户私钥或AppSecret。我们的后端接口设计大致是这三个POST /api/pay/createOrder // 创建支付订单参数订单号、支付金额、支付渠道、用户ID POST /api/pay/notifyWechat // 微信支付异步回调 POST /api/pay/notifyAlipay // 支付宝异步回调 POST /api/pay/query // 主动查询订单状态createOrder接口在内部会做三件事查订单是否存在且待支付、生成支付平台所需的下单参数、调第三方统一下单接口获取预支付标识。这些逻辑串起来之后后端还要处理并发下单的情况同一个订单如果被用户重复点击支付按钮必须保证只创建一次预支付单否则用户可能被唤起两次收银台。3. 实战UniApp多端支付的完整实现3.1 前端支付模块的封装思路我习惯在UniApp项目里单独放一个src/utils/payment.js把所有支付相关的逻辑收敛起来。对外只暴露一个pay(productId)方法内部自动判定当前环境、走对应的流程。这样业务页面里用到支付就一行代码整洁又容易维护。这个模块的核心结构可以抽象成三层环境判断层先用uni.getEnv判断是小程序、App还是H5再通过uni.getProvider拿到当前环境支持的支付通道列表。业务封装层统一处理创建订单前的前置校验比如登录态检查、地址检查、库存锁定。支付调用层封装uni.requestPayment的差异化调用统一返回一个Promise让业务层用async/await处理成功失败。伪代码结构import { getPaymentProvider } from /utils/getPaymentProvider export async function pay(orderData) { // 1. 前置校验登录态、订单信息 if (!uni.getStorageSync(token)) { uni.navigateTo({ url: /pages/login/login }) return } // 2. 请求后端创建支付订单 const paymentParams await requestPaymentParams(orderData) // 3. 根据环境调用不同的支付API return new Promise((resolve, reject) { uni.requestPayment({ ...paymentParams, success: resolve, fail: reject }) }) }封装好的这个模块微信小程序端、App端、H5端都能直接调用唯一要改的是环境判断分支。我们后来为了照顾不同渠道的不同需求还加了一个payByChannel(channel, orderData)方法可以直接指定走微信还是支付宝。在支付成功后的回调处理上前端不要只依赖requestPayment的success回调。因为用户可能支付成功但网络抖动前端收不到result或支付平台已经扣款但回调延迟。哪怕前端显示失败了只要后端没收到异步通知也要提供订单查询接口做主动补偿。3.2 后端统一下单接口的设计与签名逻辑这一节我用Node.jsKoa为例讲一下统一下单接口是怎么实现的。后端要做的核心就是组参数、签名、发请求。微信支付统一下单接口的调起过程const crypto require(crypto) function buildSign(params, apiKey) { const keyList Object.keys(params).filter(k params[k] ! k ! sign) keyList.sort() const signStr keyList.map(k ${k}${params[k]}).join() key${apiKey} return crypto.createHash(md5).update(signStr).digest(hex).toUpperCase() } async function unifiedOrder(params) { const body { appid: params.appId, mch_id: params.mchId, nonce_str: params.nonceStr, body: params.body, out_trade_no: params.orderNo, total_fee: params.amount, // 单位为分 spbill_create_ip: params.clientIp, notify_url: params.notifyUrl, trade_type: params.tradeType, // JSAPI / APP / MWEB openid: params.openid || undefined // JSAPI支付必填 } body.sign buildSign(body, params.apiKey) // 转XML调用微信接口 const xml buildXml(body) const result await requestWechatApi(xml) return result }这里有几个极其容易被忽略的细节total_fee的单位是分不是元。商城下单页显示的是199.00传给微信后端之前必须转成19900。差一位小数点支付金额差百倍这个错一出现就是事故。sign字段本身不参与签名但是其他所有非空字段都要参与。签名字段名拼错了比如nonce_str写成noncestr也会导致验签失败。XML请求体的顺序其实无所谓但签名串必须按字典序排列。支付宝的后端接口思路和微信类似但参数格式是JSON签名算法是RSA2用的是应用私钥加密、支付宝公钥验签。支付宝对回调做了比较完善的文档说明验证也相对容易主要就是验签和校验支付宝返回的字段app_id、out_trade_no、total_amount。3.3 支付回调处理与订单状态流转回调是整个支付链路里最不能马虎的部分。微信和支付宝都是异步通知后端通知频率是阶梯式的25秒后第一次之后逐步拉长最长大概4天。回调处理的核心是幂等性因为同一个支付通知可能被推多次后端处理时必须保证不管收到几次都只更新一次订单状态。我在项目里用了一个非常简单的幂等策略用order_no作为唯一键先查当前订单状态。如果订单已经是paid了直接向支付平台返回success不再重复更新。这个判断放在更新数据库之前能有效防止并发下重复入库。微信支付回调解密验签的过程async function handleWechatNotify(ctx) { const xml ctx.request.body const data parseXml(xml) // 解析XML // 1. 验签 const sign data.sign delete data.sign if (buildSign(data, mchKey) ! sign) { ctx.body failXml(签名失败) return } // 2. 校验订单号和金额 const order await OrderModel.findOne({ order_no: data.out_trade_no }) if (!order || order.total_fee ! data.total_fee) { ctx.body failXml(订单校验失败) return } // 3. 幂等更新 if (order.status paid) { ctx.body successXml() return } await OrderModel.updateOne({ order_no: data.out_trade_no }, { status: paid, paid_at: Date.now() }) ctx.body successXml() }记住一个原则只有在数据处理完全成功后才返回success给支付平台。如果中途出现异常直接返回别的状态支付平台会自动重试通知这样反而能提高可靠性。3.4 manifest.json与各端配置细节UniApp在manifest.json里的配置是很多问题的来源。我见过不少同事改App图标的时候不小心把支付相关的key删掉导致线上支付突然挂掉。需要重点核查的项目微信小程序appid必须和商户号绑定的AppId一致。如果复制错一个字符调起支付时就会报“支付验证签名失败”。App-Android微信支付需要配置包名、签名MD5值这个签名是打包apk时用的证书指纹改了证书就必须去微信开放平台同步更新否则调起支付直接被拒。App-iOS需要配置Bundle Identifier和Universal Links。H5需要配置业务域名和支付授权目录。支付授权目录要精确到页面路径的上一级比如你的支付页面是https://shop.xxx.com/pay/index.html授权目录就应该是https://shop.xxx.com/pay/漏一层都会报错。打开manifest.json在“App模块配置”里确保勾选了Payment-微信支付和Payment-支付宝支付。如果你用的是云打包每次打包都会重新生成原生工程所以只要manifest配好就行。4. 常见问题与排查技巧实录4.1 微信小程序支付常见异常小程序端最典型的报错是“requestPayment:fail cancel”和“requestPayment:fail [payment微信:-1]”。前者是用户主动取消不算bug后者就复杂了通常是签名错误、AppId不匹配或者商户号配置有问题。排查这个-1错误我有一套固定的流程第一步打开微信开发者工具在Network面板里看一眼调起支付时的参数确认timeStamp是字符串且非空第二步用后端日志确认下单接口返回的prepay_id是否和前端拉起支付时传的package一致第三步检查小程序后台的支付关联状态看看开发者工具登录的微信号是不是该小程序的体验成员如果不是就提示“接口未配置”。还有一个容易被忽略的点如果开发者的微信没有绑定该小程序的AppId对应的商户号就算签名全对也会支付失败。所以新接手项目的时候第一件事就是确认开发者工具里登录的微信号在商户号后台有操作权限。4.2 App端支付集成常见异常App端频繁遇到的坑是“微信未安装”或者“跳转微信失败”。在Android模拟器上很多支付SDK根本拉不起微信因为模拟器没有安装微信应用这个属于环境问题不是代码问题。真机调试的时候必须保证手机上安装了微信并且版本不能太老。另外一个比较隐蔽的问题是Android的签名冲突。当你使用云打包发布的apk和你本地调试用的apk签名不一致时在微信开放平台登记的MD5签名值就失效了。这时候点击微信支付会提示“签名不正确”排查方法是去微信开放平台的AppID详情里确认签名再和你本地打包工具的证书指纹比对。如果遇到iOS上拉起微信没反应优先检查Universal Links。在Safari地址栏输入你的Universal Links域名能正常跳到App就说明配置对了跳不了就看apple-app-site-association文件是否可访问、TeamID和BundleID是否匹配。4.3 H5端支付的坑H5端最大的坑是“支付目录未配置”。微信要求JSAPI支付的页面域名必须和商户平台的支付授权目录一致而且这个目录是精确前缀匹配。我们的支付页面是https://www.example.com/pay/index.html当时配置的授权目录是https://www.example.com/结果手机端一直报“商户号该产品权限未开通”或者“参数错误”后来改成https://www.example.com/pay/才调通。很多刚接入的新手都会在这一步卡很久。还有OAuth授权回调域名的配置微信要求授权回调域名必须是服务号后台配置过的域名不能带路径。如果前端打开页面时调用静默授权接口一直失败先去看服务号后台的“网页授权域名”有没有填对。4.4 调试技巧与支付链路排查思路支付调试最怕黑盒。我分享几个提高排查效率的技巧后端统一打印支付下单日志。每个支付订单关联一个request_id从统一下单到回调全链路都带上这个ID出问题直接查日志。用微信支付商家的“交易中心”和支付宝的“商家中心”查真实交易。很多联调问题可以通过对比平台侧的订单状态和后端业务库的订单状态来定位。对于支付成功但前端无响应的场景直接调后端查询接口前端确认订单状态后引导用户刷新页面或自动跳转。最后说一个很多人没意识到的点支付调通了之后记得做一次“支付结果回查”的定时任务。比如每10分钟扫描一次待支付订单调用微信/支付宝的查单接口把超时未支付的订单标记关闭把已支付但漏回调的订单标记为成功。这一步能避免大量客诉。我个人的体会是多端支付这个功能代码本身并不复杂复杂的是它对配置、签名、回调这些“边缘条件”有极高的要求。只要把各端的差异梳理清楚把前后端的接口约束定义好把回调的幂等和补偿机制做到位整个支付模块就能稳定运行很长时间。后续如果再遇到新的支付渠道只需要按照同样的模式加一层适配不需要动核心业务逻辑。这也是UniApp做多端支付最有价值的地方——跨端能力加上合理的架构设计可以让你站在更高的维度管理整个支付体系。