ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序二维码支付与识别系统开发实战:从扫码到收款全链路

微信小程序二维码支付与识别系统开发实战:从扫码到收款全链路

简介:这是一套基于微信小程序平台的二维码支付与识别系统源码,面向希望快速搭建线上线下收款工具的商户、个人开发者及小程序学习者。系统整合了二维码生成、扫描解码、支付码创建、收款码制作、条形码识别、图像处理与支付接口集成等模块,可用于店铺前台、广告牌、名片等场景的快捷收款,也适合作为移动支付类课程设计或小程序练手项目。压缩包共18个文件,约61KB,以js逻辑脚本、wxss样式、json配置、wxml页面结构为主,另含png图标素材、说明文档与README,目录按pages、utils、images等模块划分,qrcode.js与util.js承担核心编解码与工具函数。目前已有87人学习下载。读者可从中获取完整的二维码生成与识别实现思路、小程序页面组织方式、支付码与收款码的创建流程,以及图像优化提升扫描准确率的处理参考,便于二次开发或集成到自有收款场景中。

1. 二维码支付与识别系统:从扫码到收款,一个小程序怎么把整条链路跑通

线下收款台前贴着静态收款码,顾客扫完还要手动输入金额;商户想做个能自动带金额的动态码,又不想接一堆硬件。这个场景下,微信小程序做二维码支付与识别系统是性价比很高的方案:小程序负责展示收款码、调起相机扫码、解析二维码内容,后端负责生成带参数的支付码和对接支付接口。整套链路的核心技术点就四个——二维码生成、二维码扫描、条形码识别、支付接口集成。适合谁?有微信小程序开发基础、想给商户做收款工具或个人收款助手的开发者。这篇笔记按「概念选型 → 生成与扫描实现 → 支付接口对接 → 避坑 → 进阶验证」的顺序,把每一步的命令、参数和翻车点讲清楚,新手能照着跑,熟手能直接看边界。

2. 二维码生成与扫描的技术选型:为什么不用原生 API 硬扛

2.1 二维码生成:前端画还是后端出图

二维码生成有两条路。第一条是纯前端用 canvas 画,微信小程序里常见做法是引入一个轻量 QR 码库,把字符串转成点阵再绘制到 canvas 上。第二条是后端生成图片,小程序用image组件加载。两条路的差别不在能不能用,而在参数控制权。

前端生成的好处是零网络延迟,改内容即时重绘,适合「收款码内容随金额变化」这种高频刷新场景。坏处是小程序包体积会涨,而且 canvas 在不同机型上的像素对齐有玄学,偶尔出现码边缘模糊导致扫不出来。后端生成的好处是图片质量稳定、可以缓存、可以带 logo,坏处是每次改金额都要请求一次接口。

我一般这样分:静态收款码(商户固定码)走后端生成一次,存 CDN;动态支付码(带订单号、金额)走前端生成,减少接口往返。下面是一个前端生成的最小实现思路,用 canvas 2D 接口绘制。

// 小程序页面内生成二维码点阵并绘制到 canvas // 依赖:一个纯 JS 的 QR 编码函数,输入字符串返回二维 0/1 矩阵 const qr = require('../../utils/qr-encoder.js'); Page({ data: { payContent: '' }, onReady() { this.drawQr('https://pay.example.com/order?id=1001&amt=9.90'); }, drawQr(content) { const matrix = qr.encode(content, { errorCorrectLevel: 'M' }); const ctx = wx.createCanvasContext('qrCanvas'); const size = 220; // 画布逻辑尺寸,单位 px const count = matrix.length; // 矩阵行列数,决定单格边长 const cell = size / count; ctx.setFillStyle('#FFFFFF'); ctx.fillRect(0, 0, size, size); ctx.setFillStyle('#000000'); for (let r = 0; r < count; r++) { for (let c = 0; c < count; c++) { if (matrix[r][c]) { // 加 0.5 偏移避免相邻格之间出现白缝 ctx.fillRect(c * cell, r * cell, cell + 0.5, cell + 0.5); } } } ctx.draw(); } });

逻辑说明:qr.encode把字符串转成布尔矩阵,errorCorrectLevel控制纠错等级,L/M/Q/H 对应约 7%/15%/25%/30% 的容错。收款码建议用 M,兼顾密度和容错。cell + 0.5是血泪经验,不加的话部分安卓机渲染时格子之间会有 1px 白线,扫码识别率下降。参数上,size不要低于 200,低于这个值在 5.5 寸屏上单格小于 2px,相机对焦稍慢就扫不出。

2.2 二维码扫描:wx.scanCode 的能力边界

微信小程序扫码用wx.scanCode,这是官方 API,不需要额外权限申请,但有几个参数必须搞清楚。

wx.scanCode({ onlyFromCamera: false, // false 允许从相册选图,true 只允许相机 scanType: ['qrCode', 'barCode'], // 同时支持二维码和条形码 success(res) { // res.result 是扫码内容字符串 // res.scanType 是实际识别到的类型,如 'QR_CODE' 或 'EAN_13' console.log('扫码结果', res.result, res.scanType); this.handleScanResult(res.result); }, fail(err) { // 用户取消也会进 fail,需要区分 if (err.errMsg.indexOf('cancel') === -1) { wx.showToast({ title: '识别失败,请重试', icon: 'none' }); } } });

逻辑说明:scanType数组里同时写qrCode和barCode,就能在一个扫码入口里同时处理二维码和商品条形码。onlyFromCamera设 false 时用户可以从相册选图,做收款工具时建议设 true,防止用户拿一张截图来伪造支付码。fail回调里用户主动取消也会触发,必须用errMsg判断,否则每次取消都弹「识别失败」很烦。

参数边界:scanCode返回的res.result是原始字符串,没有做任何解析。如果扫的是支付码,内容通常是 URL 或特定格式的字符串,需要自己写解析逻辑。条形码识别返回的是纯数字,长度 8 到 13 位不等,做商品查询时要按位数区分 EAN-8 和 EAN-13。

2.3 条形码识别与二维码解码的差异处理

条形码和二维码在识别后的处理逻辑完全不同。二维码内容可以是任意字符串,条形码基本是数字编码。做商户收款工具时,扫到条形码大概率是商品码,应该走商品查询;扫到二维码才走支付解析。区分方式就是看res.scanType。

handleScanResult(result, scanType) { if (scanType === 'QR_CODE') { // 支付码解析:约定格式 pay://order?no=xxx&amt=xxx if (result.startsWith('pay://')) { const query = result.split('?')[1]; const params = new URLSearchParams(query); this.confirmPayment(params.get('no'), params.get('amt')); } else { wx.showToast({ title: '非本系统支付码', icon: 'none' }); } } else if (scanType === 'EAN_13' || scanType === 'EAN_8') { // 商品条码,走商品库查询 this.queryProduct(result); } }

逻辑说明:用pay://前缀做协议约定,避免扫到其他平台的码误触发支付。URLSearchParams在小程序基础库 2.10.0 以上可用,低版本需要自己写 split 解析。条形码分支单独走商品查询,不要和支付逻辑混在一起,否则扫到商品码会报「支付码格式错误」。

3. 支付接口集成:从统一下单到回调验签的完整链路

3.1 支付接口集成的三个必调接口

微信支付在小程序里的接入,核心是三个接口:统一下单、调起支付、支付回调。统一下单在后端做,调起支付在小程序端做,回调也在后端做。很多人卡在签名和证书上,其实流程本身不复杂。

后端统一下单(以常见的服务端语言为例,这里用伪代码说明参数结构):

# 后端统一下单请求构造(示意,具体签名算法按支付平台文档实现) import hashlib, time, requests def create_order(out_trade_no, total_fee, openid): params = { 'appid': APPID, 'mch_id': MCH_ID, 'nonce_str': generate_nonce(), # 随机字符串,长度不超过32 'body': '收款-订单' + out_trade_no, # 商品描述 'out_trade_no': out_trade_no, # 商户订单号,唯一 'total_fee': total_fee, # 金额,单位分 'spbill_create_ip': '127.0.0.1', # 调用端 IP 'notify_url': 'https://your.domain/pay/notify', # 回调地址 'trade_type': 'JSAPI', # 小程序支付固定 JSAPI 'openid': openid # 用户 openid } params['sign'] = sign(params, API_KEY) # 按 key 排序后拼接再加密 resp = requests.post('https://api.mch.weixin.qq.com/pay/unifiedorder', data=to_xml(params)) return parse_xml(resp.text)

逻辑说明:out_trade_no必须全局唯一,重复下单会报错。total_fee单位是分,前端传金额时要乘 100 并取整,浮点数直接乘会有精度问题,用Math.round(amount * 100)。trade_type小程序固定JSAPI,不要写成NATIVE。notify_url必须是 HTTPS 且公网可达,本地开发用内网穿透工具临时映射一个地址。

小程序端调起支付:

// 后端返回 prepay_id 和签名后,小程序调起支付 wx.requestPayment({ timeStamp: String(res.timeStamp), // 秒级时间戳,字符串 nonceStr: res.nonceStr, // 随机字符串 package: 'prepay_id=' + res.prepayId, signType: 'RSA', // 新版用 RSA,老版用 MD5 paySign: res.paySign, // 后端算好的签名 success() { /* 支付成功,但以后端回调为准 */ }, fail(err) { /* 用户取消或支付失败 */ } });

逻辑说明:timeStamp必须是字符串,传数字在部分安卓机上会失败。package格式固定prepay_id=xxx。success回调只代表用户完成了支付动作,不代表钱到账,最终状态以后端收到的支付回调为准。这是最容易翻车的地方,很多人只信前端 success 就发货,结果回调延迟或失败时对不上账。

3.2 支付回调验签与幂等处理

支付回调是后端接口,支付平台会 POST 一个 XML 过来。处理逻辑分三步:验签、更新订单、返回成功。

# 支付回调处理(示意) def pay_notify(request): xml_data = request.body data = parse_xml(xml_data) # 第一步:验签,防止伪造回调 if not verify_sign(data, API_KEY): return xml_response('FAIL', '签名错误') # 第二步:幂等处理,同一订单回调可能重复到达 out_trade_no = data['out_trade_no'] if order_already_paid(out_trade_no): return xml_response('SUCCESS', 'OK') # 已处理过,直接返回成功 # 第三步:更新订单状态 if data['result_code'] == 'SUCCESS': mark_order_paid(out_trade_no, data['transaction_id']) return xml_response('SUCCESS', 'OK')

逻辑说明:验签用支付平台提供的公钥或 API 密钥,不验签等于把订单状态暴露给任何人。幂等处理必须有,支付平台在没收到 SUCCESS 响应时会按策略重试,同一笔订单可能回调多次,不判重会重复发货。返回内容必须是 XML 格式的 SUCCESS,返回 JSON 或纯文本支付平台不认,会一直重试。

参数上,transaction_id是支付平台的流水号,和商户的out_trade_no一起存,对账时用。回调地址不要做登录鉴权,支付平台不会带你的 token,做了鉴权反而收不到回调。

3.3 收款码与支付码的内容设计

收款码和支付码是两种东西。收款码是商户展示给顾客扫的,内容是商户标识;支付码是系统生成给顾客扫的,内容是订单信息。做商户收款工具时,收款码一般做成静态的,内容里带商户 ID,顾客扫码后进入小程序输入金额。支付码是动态的,内容里带订单号和金额,扫完直接确认支付。

内容格式建议用自定义协议前缀加参数,比如pay://merchant?id=M001表示商户收款码,pay://order?no=20240101&amt=9.90表示订单支付码。解析时先判断前缀,再解析参数。不要用纯 URL 做内容,因为 URL 可能被其他应用拦截,自定义协议在小程序内解析更可控。

4. 避坑与排查:二维码支付系统里最容易翻车的五件事

4.1 扫码识别率低,尤其是弱光环境

现象:同一张收款码,在室内正常光线下能扫,到了傍晚或灯光偏暗的摊位就频繁识别失败。

原因:wx.scanCode的识别依赖相机对焦和曝光,弱光下对焦慢,加上二维码本身对比度不够(比如深灰色底配黑色码),识别率断崖下降。

解决:生成二维码时保证前景纯黑#000000、背景纯白#FFFFFF,不要用品牌色做码点。码周围留至少 4 个模块宽度的白边(静区),否则相机取景框切到码边缘时无法定位。如果场景确实暗,在小程序扫码页加一个「打开闪光灯」的按钮,用wx.setKeepScreenOn保持屏幕常亮,减少用户操作中断。

4.2 支付回调收不到,订单一直待支付

现象:用户前端显示支付成功,但后端订单状态还是待支付,查日志没有收到回调。

原因:notify_url配置的是内网地址或本地 localhost,支付平台从公网访问不到。或者回调接口做了登录鉴权,支付平台的请求被拦截返回 401。

解决:notify_url必须是公网 HTTPS 地址,本地开发用内网穿透工具映射一个临时域名,上线后换成正式域名。回调接口单独放行,不做 token 校验,只做验签。排查时看支付平台的商户后台有没有回调记录,有记录但你的服务没收到,就是网络或鉴权问题;没记录,就是地址配错了。

4.3 金额精度问题导致对账差一分钱

现象:订单金额 9.9 元,前端传 9.9,后端乘 100 变成 989.9999999999999,取整后变 989 分,少收一分。

原因:JavaScript 浮点数运算精度问题,9.9 * 100不等于 990。

解决:前端传金额时就用整数分,或者用字符串传金额,后端用Decimal类型解析后再乘 100。不要在前端做amount * 100后直接传,用Math.round(amount * 100)取整。后端存金额统一用分做单位,展示时再除以 100。

4.4 二维码内容过长导致码密度过高扫不出

现象:支付码里塞了订单号、金额、时间戳、签名等一堆参数,生成的二维码密密麻麻,手机稍微远一点就扫不出。

原因:二维码容量有限,内容越长模块越多,同样尺寸下单个模块越小,相机解析难度越大。

解决:支付码内容只放必要参数,订单号用短编码,签名可以不放二维码里(扫码后后端校验)。如果参数确实多,用短链接服务把长内容转成短 URL 再生成二维码。或者把纠错等级从 H 降到 M,减少冗余模块。实测内容控制在 100 字符以内,220px 画布下识别率比较稳。

4.5 安卓和 iOS 扫码行为不一致

现象:同样的扫码代码,iOS 上正常,安卓上偶尔扫不出来或者返回的scanType不一样。

原因:不同机型相机对焦策略不同,安卓碎片化严重,部分机型对条形码和二维码的识别优先级不同。

解决:scanType数组里把qrCode放前面,让相机优先尝试二维码识别。测试时至少覆盖华为、小米、OPPO、vivo 各一台真机,不要只用开发者工具模拟。如果某机型持续有问题,在扫码页加一个「手动输入」入口兜底,用户扫不出时可以手动输码。

5. 进阶验证:用日志和压测确认支付链路真的可靠

5.1 给扫码和支付链路加可观测日志

上线前我在扫码成功、下单请求、支付回调三个节点各埋一条日志,格式统一带traceId,方便串起来查。

// 小程序端:扫码成功后生成 traceId 并透传 const traceId = 'scan_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); wx.scanCode({ success(res) { wx.request({ url: 'https://your.domain/api/order', data: { content: res.result, traceId }, success(orderRes) { console.log('[trace]', traceId, 'order created', orderRes.data.orderNo); } }); } });

后端收到traceId后,在下单日志和回调日志里都打出来。排查时用grep traceId就能把一次支付的全链路串起来。参数上,traceId不要用纯时间戳,并发高时可能重复,加随机后缀。

5.2 用沙箱环境做支付回调的重复投递测试

支付回调的幂等逻辑必须验证。我一般写一个脚本,模拟支付平台连续 POST 三次同样的回调数据到notify_url,看订单状态是否只更新一次、是否每次都返回 SUCCESS。

# 模拟重复回调,连续发三次相同数据 for i in 1 2 3; do curl -X POST https://your.domain/pay/notify \ -H "Content-Type: application/xml" \ -d @notify_sample.xml echo "--- round $i done ---" done

逻辑说明:notify_sample.xml是一份合法的回调数据样本,从支付平台文档里抄一份改一下订单号。三次请求后查数据库,订单状态应该只变一次,transaction_id只写一次。如果第二次回调把状态又改了一遍或者报了唯一键冲突,说明幂等没做好。

5.3 二维码生成质量的快速验证方法

生成二维码后不要只用微信扫,用系统相机也扫一遍。系统相机对二维码的识别算法和微信不同,能过系统相机的基本上所有扫码器都能过。另外把二维码截图放大到 200% 看边缘,如果码点边缘有灰边或锯齿,说明 canvas 绘制时没有做像素对齐,调整cell + 0.5的偏移量或者改用image方式由后端生成。

我自己的习惯是:每次改完二维码生成逻辑,先在一台旧安卓机上用系统相机扫,过了再上微信扫。旧机型能过,新机型基本没问题。这个习惯帮我省了很多「用户反馈扫不出」的售后。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进