ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

微信小程序云开发支付与蓝牙打印全流程实操

微信小程序云开发支付与蓝牙打印全流程实操 最近给客户做了一整套微信小程序点单系统订单支付后要同时出小票和标签。整套链路用的是微信小程序云开发支付走云开发支付接口打印走蓝牙热敏打印机与标签打印机。这套方案从最初被各种问题卡住到后来跑通并稳定上线中间踩了不少坑。这篇文章把完整流程、核心代码、关键参数的取舍以及排查思路一次性写清楚给打算用云开发做订单支付和票据打印的朋友一份可以直接参考的实操记录。先说清楚这套内容适合谁你如果是个人开发者、小团队或者在一家没有专职后端的公司做小程序想少买服务器、少维护证书最好还希望支付和打印链路一体打通那这篇文章正好合适。我这里不聊花哨的架构只讲怎么把订单从创建到支付成功再到小票机打印小票、标签机打印标签这条链路完整落地。1. 方案选型与整体流程设计为什么用云开发支付为什么打印走蓝牙1.1 支付链路方案对比自建后端 vs 云开发做微信小程序支付第一条岔路口就是选后端方案。传统做法是自己买一台服务器申请微信支付商户号在服务端集成微信支付 API配置 APIv3 证书还要处理支付回调、退款回调、证书过期等问题。这套方案功能上限高适合有专职服务端团队、后续要做复杂业务系统的情况。但如果你的项目只是一个小程序流量不大、开发周期紧还要考虑服务器费用和运维成本那微信小程序云开发方案明显更省事。云开发自带cloud.cloudPay.unifiedOrder这个支付接口在云函数里直接调用不用自己申请商户证书不用处理复杂的签名逻辑微信支付后端会直接回调你指定的云函数。整个支付链路的代码量比自建后端少一大截而且天然和云开发的数据库、云存储打通。我实际对比过两种方案云开发支付在功能上完全够用只有一点要提前确认要确保你的微信小程序 AppID 和微信支付商户号已经完成关联绑定否则云开发支付接口会报权限错误。这个绑定不是代码问题是配置问题第一次做容易忽略。还有一个场景要注意如果你用的开发框架是 uni-app依然可以走云开发这套支付方案。因为底层仍然是小程序本身的wx.cloud和wx.requestPayment能力uni-app 只是帮你做了一层跨端封装支付接口的调用方式和原生小程序基本一样。1.2 小票与标签打印链路设计本地蓝牙是性价比最高的路线支付成功以后怎么触发小票打印和标签打印这是整套方案里最容易想歪的地方。我第一次做的时候企图在云端支付回调里直接触发打印后来发现根本不现实打印机连的是用户或商家本地的手机/平板云端不可能直接驱动一个局域网内的蓝牙设备。所以打印这条链路必须分两段第一段是云端支付回调只负责把订单状态更新为“已支付”第二段是打印端比如收银台的平板或店员手机主动去数据库里发现新支付的订单然后通过蓝牙连接打印机完成小票和标签的打印。打印机选型上小票机我用的是常见的 58mm 蓝牙热敏小票机指令集是 ESC/POS标签机用的是一台 TSC 系列的蓝牙标签打印机指令集是 TSPL。两者都支持蓝牙 4.0 以上连接。这类设备在电商平台非常成熟价格也不高。这里重点说一个设计决策打印内容统一用 Canvas 绘制成位图再通过蓝牙把位图数据发给打印机。为什么不直接用文本指令因为小票机和标签机内置的字符编码经常不一致中文内容在文本模式下很容易乱码而且每个品牌的指令扩展也不一样。转成位图以后内容只跟你的画布显示有关跟打印机字库无关跨品牌兼容性最好。实测下来这个方案省去了我们后期换打印机品牌时的大量适配工作。1.3 完整流程的文字版梳理在我这套方案里整个流程是这样的用户在小程序端提交订单数据库写入一条待支付订单。小程序端调用云函数payOrder云函数校验订单状态后调用cloud.cloudPay.unifiedOrder拿到支付参数。小程序端用wx.requestPayment拉起微信支付。微信支付后端处理完成后回调云函数payCallback云函数把订单状态更新为已支付。收银终端打印端定时或通过监听机制发现“已支付且未打印”的订单。打印端连接小票打印机用 Canvas 绘制小票内容并转成位图通过蓝牙发出打印指令。如果订单包含标签需求再切换到标签打印机用 TSPL 指令的 BITMAP 方式打印标签。打印成功后回写订单的打印状态避免重复打印。这个流程每一步都有对应的代码和关键参数下面逐个拆开讲。2. 微信支付下单与回调落库云函数完整实现2.1 订单表结构与状态机的关键设计订单集合我命名为orders核心字段如下字段类型说明orderNostring业务订单号全局唯一用作微信支付的 outTradeNoopenidstring下单用户标识totalFeenumber订单金额单位是分statusnumber0待支付 1已支付 2已退款 3已完成payTimedate支付成功时间itemsarray商品明细打印小票时使用printStatusnumber0未打印 1已打印 2打印失败createTimedate下单时间updateTimedate更新时间这里有个很重要的思路printStatus和支付状态status必须分开。因为支付成功和打印成功是两件独立的事支付回调只负责支付状态打印端负责打印状态。如果把两个状态混在一个字段里一旦打印机缺纸整个订单状态就会卡住影响后续退款、发货等操作。订单号生成规则我用的是yyyyMMddHHmmss 6位随机数再通过数据库唯一索引兜底。其实在云开发里没有唯一索引这么细的控制所以我会在生成订单号时多做一次查重同时写代码时加一个“若 outTradeNo 重复则终止支付”的校验避免微信支付报OUT_TRADE_NO_USED。2.2 云函数统一下单与返回支付参数支付下单的云函数完整代码大概是这样的// cloudfunctions/payOrder/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) exports.main async (event, context) { const { orderId, openid } event const db cloud.database() const orderRes await db.collection(orders).doc(orderId).get() const order orderRes.data if (order.openid ! openid) { return { code: -1, msg: 无权操作此订单 } } if (order.status ! 0) { return { code: -1, msg: 订单状态异常请刷新后重试 } } const res await cloud.cloudPay.unifiedOrder({ body: order.title || 商品订单, outTradeNo: order.orderNo, spbillCreateIp: 127.0.0.1, subMchId: 你的微信支付商户号, totalFee: order.totalFee, envId: 你的云环境ID, functionName: payCallback }) return { code: 0, payment: res.payment } }里面几个参数我单独解释一下。spbillCreateIp传127.0.0.1是云开发支付文档里给的推荐写法因为云函数调用微信支付接口时微信侧看到的是微信内部出口 IP这个字段对普通小程序支付没有实际业务意义。totalFee的单位是分不是元。这里有个经典坑如果订单金额是19.9元转成分的时候很多人会写parseInt(19.9 * 100)结果变成1989因为浮点数精度问题。正确写法是Math.round(19.9 * 100)或者在存储订单时就以分为单位计算好这个要在下单那一刻就处理干净别在支付环节再转换。小程序端拉起支付的代码const res await wx.cloud.callFunction({ name: payOrder, data: { orderId } }) if (res.result.code 0) { const { payment } res.result wx.requestPayment({ ...payment, success() { // 注意不要在这里直接把订单标记为已支付 }, fail(err) { // 用户取消支付或支付失败 } }) }支付成功的success回调里我建议只做 UI 提示不要急着改订单状态。原因是支付成功之后微信可能还没把回调消息推送到云函数此时改状态容易和回调逻辑冲突。最终订单状态以支付回调云函数更新的结果为准。2.3 支付回调与幂等更新订单状态支付回调云函数是整个支付链路的“最终裁判”。代码长这样// cloudfunctions/payCallback/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) exports.main async (event, context) { const { returnCode, resultCode, outTradeNo, totalFee } event if (returnCode SUCCESS resultCode SUCCESS) { const db cloud.database() const orderRes await db.collection(orders).where({ orderNo: outTradeNo }).get() if (orderRes.data.length 0) { const order orderRes.data[0] if (order.status 0) { await db.collection(orders).doc(order._id).update({ data: { status: 1, payTime: db.serverDate(), payType: WXPAY, updateTime: db.serverDate() } }) } else { // 已经处理过直接返回成功避免微信重复回调 console.log(重复回调订单号, outTradeNo) } } } return { errcode: 0, errmsg: ok } }为什么这里要加if (order.status 0)判断因为微信支付回调在某些异常场景下会重试如果回调处理函数不是幂等的同一个订单可能被处理两次订单状态会被重复覆盖。加了这个判断以后第二次回调进来发现已经是“已支付”状态就直接跳过更新逻辑只回一个成功响应给微信侧。有个细节值得注意微信客户端拉起支付后如果用户一直没退出支付页面回调可能已经执行完了但用户还没看到支付成功的页面。这种情况下前端可以自己拉取一次订单状态来判断是否支付成功而不是依赖wx.requestPayment的success回调。2.4 退款、投诉回调这些扩展怎么处理线下餐饮、零售场景经常会遇到退款。云开发支付的退款接口是cloud.cloudPay.refund调用方式和下单类似const res await cloud.cloudPay.refund({ subMchId: 你的微信支付商户号, outTradeNo: order.orderNo, outRefundNo: R Date.now(), totalFee: order.totalFee, refundFee: actualRefundFee })退款完成后同样会回调到你配置的云函数里面的returnCode、resultCode字段会标识退款是否成功。如果想做券码核销、余额原路退回这类业务也是在这个回调里面扩展。投诉回调这方面微信支付商户平台提供了投诉管理能力可以配置投诉通知地址。云开发没有独立的“投诉回调”接口但你可以把投诉事件转成一个 HTTP 触发器或者写一个定时任务去拉取未处理投诉然后推送到管理员微信或者订阅消息里。对于小团队项目最简单的方式是每天定时查一次投诉列表有问题人工处理成本最低。3. 小票与标签打印的完整实现蓝牙连接、Canvas 位图与指令透传3.1 蓝牙连接打印机的通用封装小程序端蓝牙连接打印机本质上就是标准的 BLE 连接流程。我封装了一个BleManager核心步骤是这样的打开蓝牙适配器wx.openBluetoothAdapter。搜索设备wx.startBluetoothDevicesDiscovery监听wx.onBluetoothDeviceFound根据设备名称关键词找到打印机。停止搜索发起连接wx.createBLEConnection。获取服务wx.getBLEDeviceServices。获取特征值wx.getBLEDeviceCharacteristics找到具备写入权限的 characteristic。通过wx.writeBLECharacteristicValue写入打印数据。关键代码// utils/bleManager.js class BleManager { openAdapter() { return new Promise((resolve, reject) { wx.openBluetoothAdapter({ success: resolve, fail: reject }) }) } findDevice(nameKeyword, timeout 10000) { return new Promise((resolve, reject) { wx.startBluetoothDevicesDiscovery({ allowDuplicatesKey: false }) const timer setTimeout(() { wx.stopBluetoothDevicesDiscovery() reject(new Error(搜索超时)) }, timeout) wx.onBluetoothDeviceFound(res { const device res.devices.find(item item.name item.name.includes(nameKeyword)) if (device) { clearTimeout(timer) wx.stopBluetoothDevicesDiscovery() resolve(device) } }) }) } async connect(deviceId) { await this.closeIfConnected(deviceId) await wx.createBLEConnection({ deviceId }) await this.sleep(300) return this.findServiceAndChar(deviceId) } async findServiceAndChar(deviceId) { const servicesRes await wx.getBLEDeviceServices({ deviceId }) for (const service of servicesRes.services) { const charsRes await wx.getBLEDeviceCharacteristics({ deviceId, serviceId: service.uuid }) for (const char of charsRes.characteristics) { const canWrite char.properties.write || char.properties.writeNoResponse if (canWrite) { return { serviceId: service.uuid, charId: char.uuid } } } } throw new Error(未找到可写特征值) } async write(deviceId, serviceId, charId, buffer) { const chunkSize 20 for (let i 0; i buffer.byteLength; i chunkSize) { const chunk buffer.slice(i, i chunkSize) await this.writeChunk(deviceId, serviceId, charId, chunk) await this.sleep(20) } } writeChunk(deviceId, serviceId, charId, buffer) { return new Promise((resolve, reject) { wx.writeBLECharacteristicValue({ deviceId, serviceId, characteristicId: charId, value: buffer, success: resolve, fail: reject }) }) } sleep(ms) { return new Promise(resolve setTimeout(resolve, ms)) } async closeIfConnected(deviceId) { try { await wx.closeBLEConnection({ deviceId }) } catch (e) { // 未连接过忽略即可 } } }这个封装的几个要点我直接说结论第一每次writeBLECharacteristicValue只写 20 字节这是小程序蓝牙 API 在大部分机型上的安全值实测要久。第二分包发完后必须加sleep(20)不加这个间隔很多打印机蓝牙模块处理不过来会丢包最后打印出来的内容缺行或者乱码。第三Android 平台上搜索蓝牙设备需要定位权限这个不是代码问题是要在app.json里申请scope.userLocation不然有时候搜不到任何设备。3.2 小票内容生成Canvas 绘制与 ESC/POS 位图指令小票机的打印内容包含店名、订单号、商品明细、金额、支付方式、时间等。我用 Canvas 画一张宽 384px 的高清图按内容高度自适应然后用wx.canvasGetImageData取像素转成 1bit 黑白位图再通过 ESC/POS 的位图指令发给打印机。画布内容生成的简化逻辑function drawReceipt(canvas, orderInfo) { const ctx canvas.getContext(2d) const width 384 let y 20 ctx.fillStyle #ffffff ctx.fillRect(0, 0, width, 4000) ctx.fillStyle #000000 ctx.font bold 28px sans-serif ctx.textAlign center ctx.fillText(orderInfo.shopName, width / 2, y) y 50 ctx.font 20px sans-serif ctx.textAlign left ctx.fillText(订单号 orderInfo.orderNo, 20, y) y 30 // 分隔线 ctx.fillRect(20, y, width - 40, 2) y 20 // 商品明细 ctx.font 22px sans-serif orderInfo.items.forEach(item { ctx.fillText(item.name, 20, y) ctx.textAlign right ctx.fillText(item.price x item.count, width - 20, y) ctx.textAlign left y 30 }) // 金额 ctx.font bold 30px sans-serif ctx.fillText(合计 (orderInfo.totalFee / 100).toFixed(2), 20, y 20) // 其他信息同理 }拿到画布图像数据后转成 1bit 位图function rgbTo1bit(imgData, threshold 128) { const { width, height, data } imgData const bytesPerRow Math.ceil(width / 8) const bitmap new Uint8Array(bytesPerRow * height) for (let y 0; y height; y) { for (let x 0; x width; x) { const idx (y * width x) * 4 const gray 0.299 * data[idx] 0.587 * data[idx 1] 0.114 * data[idx 2] if (gray threshold) { const byteIndex y * bytesPerRow Math.floor(x / 8) bitmap[byteIndex] | (0x80 (x % 8)) } } } return { bitmap, bytesPerRow, height, width } }这个位图的threshold阈值可以微调。如果小票打出来整体偏黑就把它调大比如160如果打印出来内容很淡就调小比如90。大多数情况下128就挺好。构造打印指令function buildReceiptBitmapCmd(bitmap, widthPx, bytesPerRow, height) { let cmds [0x1b, 0x40] // ESC 初始化打印机 const nL widthPx % 256 const nH Math.floor(widthPx / 256) // 每 24 个像素一行用 ESC * 33 双倍密度打印 for (let y 0; y height; y 24) { cmds.push(0x1b, 0x2a, 33, nL, nH) for (let row 0; row 24; row) { for (let byte 0; byte bytesPerRow; byte) { if (y row height) { cmds.push(bitmap[(y row) * bytesPerRow byte]) } else { cmds.push(0) } } } } cmds.push(0x1d, 0x56, 65, 0) // GS V A 自动切纸 return new Uint8Array(cmds) }注意这里的ESC * 33是 24 点双倍密度位图模式打印宽度按实际像素点计算。如果你的画布宽 384px那么widthPx就是 384nL 384 % 256 128、nH 1。实际打出来的小票宽度正好覆盖 58mm 热敏纸。3.3 标签内容生成TSPL 指令与 BITMAP 位图传输标签打印我用 TSPL 指令集。TSPL 相比 ESC/POS 更简洁常见指令就几个SIZE定义标签尺寸GAP定义标签间隙CLS清除图像缓冲区BITMAP打印位图PRINT触发打印。TSPL 最舒服的地方在于它支持直接指定标签尺寸比如 60mm x 40mm那么指令就是SIZE 60 mm, 40 mm GAP 2 mm, 0 CLS然后我用 Canvas 绘制一张宽 480px、高 320px 的黑白图对应 203dpi 的标签分辨率不算太高但打印文字和简单条码效果足够转成 1bit 位图后用BITMAP指令发送。TSPL 的BITMAP指令格式是BITMAP x,y,width_bytes,height,0,data其中width_bytes是指图像宽度占用的字节数。如果画布宽度 480px那么width_bytes 480 / 8 60。data是位图的十六进制字符串比如FF00AA这种不是直接塞二进制 ArrayBuffer。这是我一开始最容易被坑的地方很多文档没写清楚直接把二进制数据塞进去打印出来全是乱的。构造指令的代码function buildTsplBitmapCmd(bitmap, widthBytes, height) { const hexData Array.from(bitmap).map(b b.toString(16).padStart(2, 0)).join() let cmd cmd SIZE 60 mm, 40 mm\r\n cmd GAP 2 mm, 0\r\n cmd CLS\r\n cmd BITMAP 0,0,${widthBytes},${height},0,${hexData}\r\n cmd PRINT 1,1\r\n return new TextEncoder().encode(cmd) }这里有个值得注意的点TSPL 指令里换行符是\r\n不是单纯的\n。有些打印机对\n也能识别但我遇到过个别型号识别不了导致最后一条指令吞掉。统一用\r\n最稳。标签内容画布上我一般会包含商品名称、规格、数量、订单号、日期有必要的话还可以画一个简单的一维条码。TSPL 本身有原生条码指令BARCODE但要在 Canvas 画布上画一维条码也不复杂我直接用 Canvas 的fillRect按条码编码规则画出来这样整个打印内容保持“一切皆位图”的统一性省得在指令层混用。3.4 打印队列与重试机制避免重复打印生产环境里不能只有“连接打印机、发数据”这一步你还得考虑打印队列。我设计了一个本地打印队列结构很简单const printQueue [ { type: receipt, orderNo: xxx, status: pending, retry: 0 }, { type: label, orderNo: xxx, status: pending, retry: 0 } ]打印端轮询到新的已支付订单后根据订单里的商品类型决定放入小票任务、标签任务或者两个都放。然后循环处理队列先连接对应打印机连接成功后把队列里所有同类型任务一次性打完再断开连接。为什么不在支付完成后立刻逐条去连打印机、打一张、断开、再连另一台因为蓝牙连接是一个非常耗时的操作频繁连接断开不仅慢还容易失败。更合理的做法是攒一批同类型打印任务连上一次打印机全部打完再断开。这样效率能翻好几倍。重试机制也很重要。打印任务失败后不能无限重试我默认重试 3 次每次间隔 1 秒。3 次还失败就把任务状态改为failed并在前端提示“打印失败请检查打印机缺纸或连接状态”。同时保留一个“重新打印”按钮让操作员在修复问题后可以一键重打。4. 支付与打印常见问题排查从报错现象到解决方案速查4.1 支付环节的高频坑云开发支付第一个容易踩的坑是商户号没有绑定。调用cloud.cloudPay.unifiedOrder报错基本就是这类权限问题处理方式是去微信小程序后台的“微信支付”模块把小程序 AppID 和商户号绑定好再用绑定商户号的 AppID 发布云函数。第二个高频坑是回调用云函数没有部署。如果你改了payCallback的代码但没有上传部署支付调单会一直失败。云开发不像本地开发有热更新云函数改完必须重新“上传并部署云端安装依赖”。第三个坑是支付金额单位。totalFee是整数分不是元。我有一次在测试环境配了一笔0.01元的订单测试时反复拉起支付失败最后发现 totalFee 传的是字符串1微信支付要求 number 类型。字段类型不匹配也会报错。第四个坑是用户支付成功后订单状态没更新。排查思路先看云函数日志里回调事件有没有进来再查订单号outTradeNo是否和数据库里的orderNo完全一致有时候是一个多余的空格、大小写不一致导致的匹配失败。还有一个容易被忽略的点微信支付投诉回调。如果商户收到用户投诉平台会通过回调地址通知你。在云开发方案里没有现成的“投诉回调云函数”配置我的做法是在小程序后台配置一个 HTTPS 触发器或者用定时任务轮询“投诉管理”接口。对于订单量不大的项目每天定时查一次投诉人工处理完全够用。4.2 蓝牙打印高频坑蓝牙打印最头疼的是乱码和丢包。乱码通常是因为用了文本指令打印中文打印机内部字库和你要打的编码不一致。切换到 Canvas 位图方案以后这个乱码问题直接消失。丢包表现为打印内容缺行、缺列或者打了一半突然停了。通常是两个原因一是写得太快writeBLECharacteristicValue连续无间隔发送打印机来不及处理。解决办法就是分包 20 字节加 20 毫秒间隔必要时把间隔加到 30 毫秒牺牲一点速度换稳定性。二是数据量太大超过打印机缓冲区这时候可以把单张图片分成几个区块分批发区块之间加一个sleep(200)。iOS 和 Android 的蓝牙体验差异很大。iOS 上如果之前连接过打印机重新连接前一定要先wx.closeBLEConnection不然会报“设备已连接”。Android 上搜索设备经常因为没开定位权限而搜不到这是系统限制不是代码问题。还有一个场景打印端平板息屏后蓝牙连接会被系统回收。所以打印前最好先做一次连接检测失败就自动进入重连流程不要假设连接还活着。4.3 标签打印高频坑标签打印最典型的坑是标签位置偏移。第一张标签位置正常后面的标签越打越偏这通常是因为GAP参数和实际标签纸的间距不一致。解决方法是拿一把尺子量一下标签纸的间隙然后修改GAP参数或者让打印机执行一次自动测纸。第二个坑是 TSPL 位图数据的格式。BITMAP后面的 data 必须是大写十六进制字符串而且不能有空格。如果你把二进制 ArrayBuffer 直接拼进去打印出来的图就完全不可识别。第三个坑是标签尺寸和画布尺寸不匹配。比如打印机里装的是 40mm x 30mm 的标签纸但你在SIZE指令里写了 60mm x 40mm打印内容就会超出标签纸范围出现裁切或者错位。画布的分辨率也要按标签纸实际尺寸来计算不要想当然。4.4 常见问题速查表现象可能原因处理方法拉起支付失败/报错商户号未绑定、金额为0、参数类型不对检查小程序后台商户绑定确认 totalFee 为整数分支付成功但订单状态没变回调云函数未部署、outTradeNo 不匹配查看云函数日志确认回调事件字段检查订单号数据库订单状态反复变化回调没有幂等处理增加status 0判断小票打印中文乱码文本指令编码问题改用 Canvas 位图方案小票内容缺行蓝牙写入过快丢包增加分包间隔每包 20 字节 sleep 20ms打印出来全黑/全白位图阈值不对或反色调整 threshold检查gray threshold方向蓝牙搜不到打印机距离远、未定位权限Android 开启定位权限靠近打印机标签越打越偏GAP 参数不对确认标签间隙执行自动测纸标签位图打印内容乱BITMAP 数据格式错误确认是十六进制字符串且为大写打印机连接后无反应设备没电或未进纸检查电源、缺纸状态最后再分享一点实际操作中的体会整套流程跑完以后我最大的感受是打印模块一定要在产品设计的第一天就抽象成一个独立的 service跟订单页、支付页完整解耦。不管是小票机、标签机还是以后想换云打印机只要把打印内容和指令生成部分拆开后续扩展起来会很省心。另外云开发支付回调有一个天然优势就是所有支付状态变化都能在数据库里留下完整记录。我的做法是专门建一个payLogs集合把每一次回调的returnCode、resultCode、outTradeNo、totalFee原样记录下来。线上出了问题先查这个集合基本一眼就能定位是支付侧的问题还是业务侧的问题。还有一个小技巧打印端轮询新订单的时候不要只按支付状态过滤要加一个“打印状态不等于已打印”的条件。这样即使某次打印失败重启打印端后也能自动把漏打的订单捞出来重新打印不会丢单。这套机制上线以后我这边基本上没再接到“客户付款了但没出小票”的投诉。
返回列表