文章标题

UniApp 前端调起微信支付 + SpringBoot 后端统一下单 + 支付回调 MQTT 设备推送完整实现(自助饺子售货机分表库存、分润业务)

文章标签

#UniApp #SpringBoot #微信支付 #小程序支付 #自助售货机 #MQTT 设备推送 #分表库存 #微信支付回调

文章目录

  1. 业务整体流程介绍
  2. UniApp 前端支付请求完整代码
  3. SpringBoot 后端统一下单接口 wx_pay_unifiedOrder
  4. 微信支付异步通知回调接口 wx_pay_notify(核心业务处理)
  5. 完整业务逻辑拆解 5.1 前端交互逻辑 5.2 后端下单校验、分表创建、库存判断、生成支付参数 5.3 支付回调:订单状态更新、库存出库、分润计算、MQTT 推送设备
  6. 现有代码存在的缺陷与风险
  7. 生产环境优化改造方案

一、业务整体流程介绍

本项目为校园 / 社区无人值守 AI 饺子自助售卖机完整支付链路,整体流程:

  1. 用户小程序选饺子商品,点击付款,前端携带设备码、手机号、用户 openid、商品列表请求后端统一下单接口;
  2. 后端做多层校验:Redis 登录 Token 校验、设备是否存在、用户手机号是否存在、库存是否充足;
  3. 自动创建当年分表(订单表、订单明细表、出入库表、分润表),组装微信统一下单参数调用微信支付 SDK;
  4. 数据库预创建销售订单、订单明细,返回微信支付 5 个核心签名参数给前端;
  5. UniApp 调用uni.requestPayment唤起微信原生支付弹窗;
  6. 用户支付完成后,微信服务器异步回调后端wx_pay_notify接口;
  7. 回调内校验微信签名、更新订单为已付款、扣减库存、自动计算渠道 / 投资人分润、通过 MQTT 下发订单信息给线下饺子机,设备自动煮饺子出餐。

技术栈:UniApp (Vue) + SpringBoot + 微信 JSAPI 支付 + Redis 登录校验 + MQTT 设备通信 + MySQL 年度分表 + JdbcTemplate 批量分润。


二、UniApp 前端支付请求完整代码

javascript

运行

PaymentMethod() {
	var that = this
	uni.showLoading({
		title: '加载中......'
	})
	uni.request({
		url: that.baseLocalUrl + "/API/wx_pay_unifiedOrder",
		method: 'POST',
		dataType: 'json',
		data: {
			code: that.code, // 设备编码
			phone: that.phone, // 用户手机号
			token: that.token, // 登录凭证Redis校验
			openid: that.openid, // 小程序用户openid
			body: '售卖机付款单', // 微信订单描述
			productJsonStr: JSON.stringify(that.list), // 选购商品数组JSON
			total_fee:1 // 测试固定金额,生产替换为实际总价
		},
		headers: {
			'Content-Type': "application/json;charset=utf-8"
		},
		success: result => {
			// 登录凭证失效
			if (result.data.status == 'token_fail') {
				uni.hideLoading()
				uni.showToast({ icon: 'none', title: '登录已失效,请重新登录', mask: true, duration: 2000 })
				return
			}
			// 设备不存在
			if (result.data.status == 'device_code_fail') {
				uni.hideLoading()
				uni.showToast({ icon: 'none', title: '该设备不存在', mask: true, duration: 2000 })
				return
			}
			// 用户手机号未注册
			if (result.data.status == 'user_phone_fail') {
				uni.hideLoading()
				uni.showToast({ icon: 'none', title: '用户信息不存在', mask: true, duration: 2000 })
				return
			}
			// 商品库存不足
			if (result.data.status == 'stock_fail') {
				uni.hideLoading()
				uni.showToast({ icon: 'none', title: '商品库存不足', mask: true, duration: 2000 })
				return
			}
			// 后端下单失败
			if (result.data.status == 'fail') {
				uni.hideLoading()
				uni.showToast({ icon: 'none', title: '创建订单失败', mask: true, duration: 2000 })
				return
			}
			// 下单成功,唤起微信支付
			uni.requestPayment({
				provider: 'wxpay',
				timeStamp: result.data.payParams.timeStamp,
				nonceStr: result.data.payParams.nonceStr,
				package: result.data.payParams.package,
				signType: result.data.payParams.signType,
				paySign: result.data.payParams.paySign,
				// 支付成功跳转订单页
				success: function(res) {
					uni.hideLoading()
					uni.navigateTo({ url: `/pagesOther/mOrder/mOrder?index=1` })
					console.log('支付成功回调:' + JSON.stringify(res));
				},
				// 用户取消/支付失败
				fail: function(err) {
					uni.hideLoading()
					console.log('支付失败:' + JSON.stringify(err));
				}
			});
		},
		// 网络请求异常
		fail(ex) {
			uni.hideLoading()
			console.log(ex.message)
			uni.showToast({ icon: 'none', title: ex.message, mask: true, duration: 2000 })
		}
	})
},

前端逻辑说明

  1. 请求前弹出 Loading 遮罩,防止重复点击;
  2. 统一接收后端 5 种业务状态码,分别给出对应 Toast 提示;
  3. 下单成功拿到微信支付五要素,调用小程序原生uni.requestPayment拉起微信;
  4. 支付成功自动跳转我的订单页面,方便用户查看取餐码;
  5. 网络异常统一捕获提示,提升自助机用户体验。

三、SpringBoot 后端统一下单接口 wx_pay_unifiedOrder

java

运行

@ResponseBody
@RequestMapping(value = "/API/wx_pay_unifiedOrder", method = RequestMethod.POST, produces = "application/json;charset=UTF-8")
public Map<String, Object> wx_pay_unifiedOrder(@RequestBody JSONObject jsonParam) {
    Map<String, Object> map = new HashMap<>();
    try
    {
        // 1. 解析前端传参
        String code = jsonParam.getString("code");
        String openid = jsonParam.getString("openid");
        String phone = jsonParam.getString("phone");
        String token = jsonParam.getString("token");
        JSONArray jsonArray =jsonParam.getJSONArray("productJsonStr");
        String body = jsonParam.getString("body");
        String total_fee = jsonParam.getString("total_fee");

        // 2. Redis登录Token校验,一次性token校验后直接销毁
        boolean is_exist =  redisTemplate.hasKey(token);
        if(is_exist == false)
        {
            map.put("status","token_fail");
            return map;
        }else
        {
            redisTemplate.delete(token);
        }

        // 3. 初始化年度分表名称,自动检查表并创建
        SimpleDateFormat yearFormat = new SimpleDateFormat("yyyy");
        Date date = new Date();
        String tableName = "tb_pay_form_" + yearFormat.format(date);
        String tableItemName = "tb_pay_form_item_" + yearFormat.format(date);
        String outStockTableName = "tb_out_stock_" + yearFormat.format(date);
        String outStockItemTableName = "tb_out_stock_item_" + yearFormat.format(date);
        String inStockTableName = "tb_in_stock_" + yearFormat.format(date);
        String inStockItemTableName = "tb_in_stock_item_" + yearFormat.format(date);
        String profitAmountTableName = "tb_profit_amount_" + yearFormat.format(date);

        // 循环判断所有业务分表,不存在自动创建
        if (payFormNaticeService.tableExists(tableName) == false) {
            payFormNaticeService.createPayFormTable(tableName);
        }
        if (payFormNaticeService.tableExists(tableItemName) == false) {
            payFormNaticeService.createPayFormItemTable(tableItemName);
        }
        if (outStockNaticeService.tableExists(outStockTableName) == false) {
            outStockNaticeService.createOutStockTable(outStockTableName);
        }
        if (outStockNaticeService.tableExists(outStockItemTableName) == false) {
            outStockNaticeService.createOutStockItemTable(outStockItemTableName);
        }
        if (inStockNaticeService.tableExists(inStockTableName) == false) {
            inStockNaticeService.createInStockTable(inStockTableName);
        }
        if (inStockNaticeService.tableExists(inStockItemTableName) == false) {
            inStockNaticeService.createInStockItemTable(inStockItemTableName);
        }
        if (profitAmountNaticeService.tableExists(profitAmountTableName) == false) {
            profitAmountNaticeService.createTable(profitAmountTableName);
        }

        // 4. 校验设备是否存在
        List<Device> deviceList = deviceService.findDeviceByCode(code);
        if(deviceList.size() <= 0)
        {
            map.put("status","device_code_fail");
            return map;
        }
        // 5. 校验用户手机号是否存在
        List<UserInfo> userInfoList = userService.findByNamePhone(phone);
        if(userInfoList.size() <= 0)
        {
            map.put("status","user_phone_fail");
            return map;
        }

        // 6. 生成唯一商户订单号(年份+UUID,长度24位符合微信规范)
        UUID out_trade_no = UUID.randomUUID();
        String out_trade_noStr = out_trade_no.toString().replace("-", "");
        out_trade_noStr = out_trade_noStr.substring(0, 24);
        out_trade_noStr = yearFormat.format(date)+"@"+out_trade_noStr;

        // 7. 组装订单主表实体
        PayForm bean = new PayForm();
        bean.setName("售货机购买");
        bean.setAdd_date(new Date());
        bean.setId(out_trade_noStr);
        bean.setStatus("未取餐");
        bean.setWrite_off_status("未取餐");
        bean.setPay_status("未付款");
        bean.setDevice_id(deviceList.get(0).getId());
        bean.setUser_id(userInfoList.get(0).getId());

        // 8. 解析商品数组,组装订单明细
        List<PayFormItem> payFormItemList = new ArrayList<>();
        String pay_form_item_id = UUID.randomUUID().toString().replace("-", "");
        for (int i = 0; i < jsonArray.size(); i++) {
            JSONObject jsonObject = jsonArray.getJSONObject(i);
            JsonParser parser = new JsonParser();
            JsonObject productJsonObject = parser.parse(jsonObject.get("product").toString()).getAsJsonObject();
            PayFormItem payFormItem = new PayFormItem();
            payFormItem.setAdd_date(new Date());
            payFormItem.setId(yearFormat.format(date)+"@"+pay_form_item_id);
            payFormItem.setPay_form_id(out_trade_noStr);
            payFormItem.setName("销售单");
            payFormItem.setProduct_id(productJsonObject.get("id").toString());
            payFormItem.setQuantity(Integer.parseInt(jsonObject.get("buy_number").toString()));
            payFormItem.setAmount(Double.parseDouble(productJsonObject.get("price").toString()) *Integer.parseInt(jsonObject.get("buy_number").toString()));
            payFormItem.setStatus("启用");
            payFormItemList.add(payFormItem);
        }

        // 9. 调用微信统一下单SDK
        Map<String, String> params = new HashMap<>();
        params.put("body",body);
        params.put("out_trade_no",out_trade_noStr);
        params.put("total_fee",total_fee);
        params.put("spbill_create_ip","134.175.222.0");
        params.put("openid",openid);
        Map<String, String> payParams = wxPayService.unifiedOrder(params);
        if(payParams == null)
        {
            map.put("status","fail");
            return map;
        }

        // 10. 插入订单、明细、扣减库存;返回-3代表库存不足
        int insertResult =payFormNaticeService.inserPayFormTarget(tableName,tableItemName,profitAmountTableName,bean,payFormItemList,inStockTableName,inStockItemTableName,outStockTableName,outStockItemTableName);
        if(insertResult == -3){
            map.put("status", "stock_fail");
            return map;
        }
        if (insertResult > 0)
        {
            map.put("payParams",payParams);
            map.put("status","success");
            return map;
        }
        map.put("status", "fail");
        return map;
    } catch (Exception ex) {
        ex.printStackTrace();
    }
    map.put("status","fail");
    return map;
}

后端下单接口核心逻辑

  1. 登录校验:前端 Token 一次性使用,校验通过直接删除 Redis Key,防止重复下单;
  2. 分表自动创建:按年份拆分订单、库存、分润表,避免单表数据过大卡顿;
  3. 三层业务校验:设备合法性、用户手机号合法性、商品库存充足性;
  4. 商户订单号规范:拼接年份 + UUID,满足微信 24 位以内单号要求;
  5. 预生成订单:下单成功即插入订单数据,库存同步锁定,防止超卖;
  6. 对接微信 SDK:传入 openid、金额、商户单号,返回前端唤起支付所需 5 个签名参数。

四、微信支付异步通知回调接口 wx_pay_notify(业务核心)

java

运行

@ResponseBody
@RequestMapping(value = "/API/wx_pay_notify", method = RequestMethod.POST, produces = "application/json;charset=UTF-8")
public String wx_pay_notify(@RequestBody String notifyData) {
    String status = "";
    try
    {
        status = wxPayService.notify(notifyData);
        // 验签失败直接返回FAIL
        if(!"SUCCESS".equals(status)){
            return "FAIL";
        }
        // 解析微信回调XML参数
        Map<String, String> notifyMap = WXPayUtil.xmlToMap(notifyData);
        if (!WXPayUtil.isSignatureValid(notifyMap, key)) {
            return "FAIL";
        }
        if ("SUCCESS".equals(notifyMap.get("result_code"))) {
            String out_trade_no = (String) notifyMap.get("out_trade_no");
            String transaction_id = (String) notifyMap.get("transaction_id");
            SimpleDateFormat yearFormat = new SimpleDateFormat("yyyy");
            Date date = new Date();
            // 分表名称复用下单接口规则
            String tableName = "tb_pay_form_" + yearFormat.format(date);
            String tableItemName = "tb_pay_form_item_" + yearFormat.format(date);
            String outStockTableName = "tb_out_stock_" + yearFormat.format(date);
            String outStockItemTableName = "tb_out_stock_item_" + yearFormat.format(date);
            String profitAmountTableName = "tb_profit_amount_" + yearFormat.format(date);
            // 检查表,不存在自动创建
            if (payFormNaticeService.tableExists(tableName) == false) {
                payFormNaticeService.createPayFormTable(tableName);
            }
            if (payFormNaticeService.tableExists(tableItemName) == false) {
                payFormNaticeService.createPayFormItemTable(tableItemName);
            }
            if (outStockNaticeService.tableExists(outStockTableName) == false) {
                outStockNaticeService.createOutStockTable(outStockTableName);
            }
            if (outStockNaticeService.tableExists(outStockItemTableName) == false) {
                outStockNaticeService.createOutStockItemTable(outStockItemTableName);
            }
            if (profitAmountNaticeService.tableExists(profitAmountTableName) == false) {
                profitAmountNaticeService.createTable(profitAmountTableName);
            }
            // 更新订单状态为已付款,扣减库存
            int updateCount  =  payFormNaticeService.updatePayFormByStatusTarget(tableName,profitAmountTableName,out_trade_no,transaction_id,"未取餐","未取餐","已付款",outStockTableName,outStockItemTableName);
            if(updateCount > 0){
                // 查询完整订单信息,用于MQTT推送与分润计算
                List<PayForm> payFormList = payFormNaticeService.getPayFormByOutTradeNoTarget(tableName,tableItemName,out_trade_no);
                PayForm order = payFormList.get(0);
                // 1. 计算渠道/投资人分润
                String plan_profit_item_sql = "SELECT ppi.*,pp.id as 'plan_profit_id' from tb_plan_profit_item as ppi LEFT JOIN tb_plan_profit as pp ON ppi.plan_profit_id = pp.id LEFT JOIN tb_device as td ON pp.id = td.plan_profit_id where td.id = '"+order.getDevice_id()+"'";
                List<Map<String, Object>> profitRuleList = jdbcTemplate.queryForList(plan_profit_item_sql);
                StringBuilder batchProfitSql = new StringBuilder();
                // 遍历分润规则批量生成插入SQL
                for (Map<String, Object> rule : profitRuleList) {
                    double profitRate = Double.parseDouble(rule.get("profit").toString());
                    double totalAmount = order.getTotal_amount();
                    double profitTotal = totalAmount * profitRate;
                    double wxHandlingFee = profitTotal * 0.006; // 千六微信手续费
                    double realProfit = profitTotal - wxHandlingFee;
                    String insertSql =  " INSERT INTO tb_total_profit (id,name,org_type_id,device_id,in_amount,out_amount) "
                            +" VALUES ('"+order.getDevice_id()+"@"+rule.get("org_type_id").toString()+"', '分润', '"+rule.get("org_type_id").toString()+"','"+ order.getDevice_id()+"',"+realProfit+",0) "
                            +" ON DUPLICATE KEY UPDATE in_amount = in_amount + VALUES(in_amount); ";
                    batchProfitSql.append(insertSql);
                }
                // 批量执行分润台账插入
                jdbcTemplate.update(batchProfitSql.toString());

                // 2. MQTT推送订单至线下AI饺子机,设备收到自动制作饺子
                JSONObject mqttMsg = new JSONObject();
                mqttMsg.put("id",order.getId());
                mqttMsg.put("quantity",order.getTotal_quantity());
                mqttMsg.put("amount",order.getTotal_amount());
                SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                mqttMsg.put("add_date",sdf.format(order.getAdd_date()));
                String deviceCode = order.getDevice_code();
                String mqttTopic = "/NewOrder/" + deviceCode;
                Gson gson = new Gson();
                String mqttJson = gson.toJson(mqttMsg);
                // MQTT断线重连逻辑
                if(mqttClientService.isConnectStatus() == false) {
                    mqttClientService.initMqttClient();
                    Thread.sleep(50);
                }
                mqttClientService.publishSafe(order.getId(),mqttTopic, mqttJson);
                return "SUCCESS";
            }else{
                return "FAIL";
            }
        }
    } catch (Exception ex) {
        logger.error("支付回调处理异常",ex);
    }
    return "FAIL";
}

回调接口四大核心业务

  1. 微信签名校验:防止伪造回调请求,非法请求直接返回FAIL,微信会持续重试;
  2. 订单状态变更:将订单从「未付款」更新为「已付款」,同步扣减商品库存;
  3. 自动分润计算:根据设备绑定的分润方案,扣除 0.6% 微信手续费后,批量写入渠道 / 投资人收益台账;
  4. MQTT 下发订单给饺子机:支付成功推送订单编号、商品数量、金额到设备专属主题,无人值守机器自动启动煮饺流程,生成取餐码供用户扫码取餐。

五、现有代码存在的缺陷与线上风险

5.1 安全漏洞

  1. SQL 字符串拼接,存在注入风险 分润 SQL、设备查询 SQL 直接拼接device_idorg_type_id等参数,恶意字符可注入篡改数据;
  2. 微信回调无幂等控制 微信回调会多次重试,同一笔支付多次回调会重复扣库存、重复计算分润、多次推送 MQTT 消息,造成业务错乱;
  3. 固定微信支付 IP 写死 统一下单spbill_create_ip硬编码静态 IP,服务器更换公网 IP 后支付会失败。

5.2 稳定性问题

  1. 大量无意义Thread.sleep(20/50)阻塞线程,高并发下单、回调时拉长接口响应,容易触发微信超时重试;
  2. 全局超大 try-catch,异常无详细日志、无告警,线上故障无法快速定位;
  3. MQTT 发布无重试、无消息持久化,设备离线时订单丢失,用户付款不出餐;
  4. 分表创建串行循环执行,首次启动服务大量表校验阻塞接口。

5.3 并发与数据一致性

  1. 库存扣减、订单插入无数据库事务,插入订单成功但库存不足时,会生成已付款但无货的脏订单;
  2. 分润批量 SQL 一次性拼接超长字符串,数据量大时超出数据库 SQL 长度限制,批量插入失败;
  3. 无分布式锁,多实例部署时回调并发更新同一条订单,引发数据覆盖。

5.4 代码规范缺陷

  1. 魔法值硬编码:0.006 手续费、分表前缀、30 分钟取餐超时、微信主题常量全部写死;
  2. 工具耦合严重:下单、库存、分润、MQTT 全部耦合在一个回调方法,职责不单一,难以维护;
  3. 前端测试金额total_fee:1硬编码,上线容易忘记修改造成金额异常。

六、生产环境优化改造方案

6.1 安全加固

  1. 全部 SQL 替换为JdbcTemplate预编译传参,杜绝字符串拼接;
  2. 回调增加幂等锁:Redis 存入paid_商户单号,处理前判断存在直接返回 SUCCESS,防止重复回调;
  3. 动态获取服务器公网 IP,移除硬编码spbill_create_ip

6.2 事务与并发保障

  1. 下单、库存扣减、订单插入添加@Transactional事务,全部成功或全部回滚;
  2. MQTT 消息持久化、本地缓存消息,设备离线时缓存消息,重连后补发;
  3. 分润拆分分批执行,限制单次批量条数,避免超长 SQL 报错。

6.3 代码解耦与规范优化

  1. 抽取常量类统一管理分表前缀、手续费、MQTT 主题、状态码;
  2. 拆分回调逻辑:订单更新、库存、分润、MQTT 推送拆分为独立私有方法;
  3. 删除所有无用Thread.sleep,使用异步线程池处理 MQTT、分润耗时逻辑,不阻塞回调响应;
  4. 前端total_fee由商品总价自动计算,移除测试写死 1 元的代码。

6.4 监控告警

  1. 全部异常打印完整堆栈,接入钉钉 / 邮件告警;
  2. 增加支付失败、库存不足、MQTT 离线、回调重试次数监控;
  3. 分表预创建改为项目启动一次性执行,不再每次接口请求循环检查表。

七、适用项目场景

  1. 校园 / 社区无人自助饺子机、售货机、取餐柜小程序支付系统;
  2. 微信 JSAPI 小程序支付后端完整落地模板;
  3. 带设备硬件联动(MQTT 下发指令)的自助零售支付业务;
  4. 多渠道投资人分润、年度分表库存管理的支付后台。