前阵子帮朋友落地了一套无人自助洗宠店小程序,整个项目从零做到可以上线,前后差不多两个半月。后端用的JAVA(Spring Boot + MyBatis-Plus + MySQL + Redis),前端是微信原生小程序,智能门锁、智能插座、水控全部通过HTTP接口对接,配合支付回调、倒计时结算,真正把一家店跑成了无人值守。这篇文章我想把整套实现方案、数据库设计、核心接口逻辑和一些可以直接拿去改改就能用的开源代码片段完整交底,给准备做类似项目的朋友一个参考底稿。如果你是小程序开发者、Java后端,或者正打算在社区、商圈投一家自助洗宠店,这篇内容应该能帮你少走不少弯路。
1. 项目拆解:无人自助洗宠店到底由哪些模块组成
1.1 业务场景全流程
先把无人洗宠店的实际使用场景完整走一遍。
用户走到门店门口,看到海报上的小程序码,微信扫码进入小程序,首页自动定位到这家门店,看到套餐列表:小型犬基础洗护、大型犬精洗、自助吹干+药浴等。用户选一个套餐,预约下午两点到三点,点击支付。支付成功后,小程序弹出门锁和对应工位的开启按钮。用户按下开锁,门锁打开,进入工位,把狗放进恒温浴缸,扫码启动吹水机,系统开始计时。洗完后用户点“结束使用”,系统按套餐原价加上超时费用结算,超时部分从余额或原支付渠道扣款,门锁再次解锁放用户出门。
整个过程没有店员,所以后台至少要做四件事:身份识别(openid/手机号)、库存控制(哪个工位空闲)、订单生命周期管理、设备联动控制。拆开来看就是六个模块:用户、门店、套餐、订单/支付、设备、运营后台。
这个流程里最容易出问题的是支付和设备联动。支付出问题,用户付了钱订单不更新;设备联动出问题,用户付了钱开不了门。我在后面会重点讲这两块的实现和避坑。
1.2 技术选型:为什么是Java + 原生小程序
我在选型的时候也纠结过。
如果只想做演示Demo,用Python Flask或者Node.js一周就能把接口全写完,小程序端甚至可以用uni-app一套代码发微信、支付宝两个端。但这是开实体店,不是课程设计,后端稳定性、支付安全性、团队可维护性都得考虑。
最终选Java有四个原因:
- 支付生态成熟。微信支付遇到问题,搜“Java微信支付v3”能找到大量答案和官方SDK,踩坑成本低。
- 并发场景好处理。无人店的峰谷明显,周末下午可能几十个用户同时下单开锁,Spring Boot + Redis面对这种量级很稳。
- 团队招人容易。Java后端在招聘市场上供给量大,后续接手成本低。
- 设备对接不受限。门锁、插座厂商都提供HTTP接口,Java作为服务端调它们没有任何障碍,生态里也有大量成熟的HTTP客户端和定时任务方案。
小程序端我也没犹豫,直接用原生。无人店核心页面就那几个:首页、门店列表、套餐详情、订单状态、支付回调。原生小程序稳定、包体积小、对硬件设备SDK的适配更直接,没必要为了“多端复用”强行引入一套跨端框架。像套餐选择页用到的radio单选框、自定义顶部标题这些功能,原生实现起来都很顺手。
1.3 系统模块划分
我习惯把这种系统拆成四个端:
- 小程序端:首页/门店定位、套餐展示、预约下单、支付结果、订单中心、工位状态。
- 后端服务:用户授权、套餐与库存、订单状态机、微信支付、退款、设备控制指令、定时任务(超时关单/自动结算)。
- 设备端:智能门锁、洗护设备、水表电表,每台设备有唯一deviceId,后端通过HTTP接口下发指令,设备端主动上报心跳。
- 运营后台:门店信息维护、套餐价格调整、设备上下线、退款审核、经营报表。
这四个端里,后端服务是大脑,订单状态机和支付回调是核心中的核心。下面逐步讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前的准备:注册、类目、支付和数据库设计
2.1 小程序注册、类目与开发配置
写代码之前先把微信侧的资质搞定,不然代码写完也上不了线。
小程序注册主体建议用企业或个体工商户。个人主体小程序不能开通微信支付,很多设备能力也受限,做无人付费场景基本行不通。
登录微信公众平台后,在后台选择类目。无人洗宠店在“生活服务”大类下找“宠物服务”,系统会要求上传营业执照和经营范围证明。这个环节千万别嫌麻烦,类目选错了后面支付审核过不去,甚至整个小程序会被限制支付功能,整改周期非常长。
开发者工具里要做几件事:
- 开发环境可以关闭“校验合法域名”开关,否则本地请求服务器会被拦。
- 申请并配置服务器域名。注意request合法域名和uploadFile合法域名是分开的,我一开始只配了request,结果图片上传一直报域名不合法。
- 如果后续要用WebSocket推送工位状态,还要在socket合法域名里加上地址。
如果你是第一次接触Java开发,环境变量要先配好。我遇到过不少同事,代码写完了,本地java -version都跑不起来,最后发现是JAVA_HOME没配对。网上搜“java环境变量配置详细教程”有很多,生产环境建议统一用JDK 8或11,别在服务器里混版本。
2.2 微信支付v3的开通与密钥管理
微信支付现在必须用v3接口,早期教程里的v2接口文档已经逐步下线。开通流程大致如下:
- 在微信支付商户平台注册商户号,完成对公账户验证。
- 商户号关联小程序AppID。
- 自己生成APIv3密钥(32位随机字符串,自己保存,微信不存储)。
- 下载商户API证书,包含证书文件和私钥文件,私钥文件有密码。
- 下载微信支付平台证书,用于回调验签。
- 在商户平台配置回调地址notify_url。
密钥管理有几个经验:
- APIv3密钥和商户私钥不能提交到Git仓库,可以用配置中心或环境变量注入。
- 回调地址必须是公网HTTPS,开发阶段用内网穿透工具联调,生产环境必须用正式域名。
- 官方提供的SDK,推荐用wechatpay-java,回调的验签、解密都封装好了,能少写很多底层逻辑。下面的代码片段我会讲如果自己实现需要怎么做,但生产环境用官方SDK更省心。
另外提一句,“微信小程序签名”这个关键词其实包含两层含义:一是微信登录时后端要用appid+secret换取openid,二是支付接口用商户私钥做RSA签名。这两块我都遇到过问题,后面在核心实现里详细说。
2.3 核心数据库表设计与状态机
数据库设计是这套系统最容易返工的地方。我最终的落库核心表主要有这几张:
| 表名 | 核心字段 | 说明 |
|---|---|---|
| t_user | openid, unionid, nickname, phone, balance | 用户表,openid唯一 |
| t_store | name, address, lat, lng, status | 门店表 |
| t_device | store_id, device_code, device_type, status | 设备表,门锁/插座/水控 |
| t_product | store_id, name, price, duration, unit_price | 洗护套餐表 |
| t_order | order_no, user_id, store_id, device_id, product_id, amount, pay_amount, status, start_time, end_time | 订单表,核心表 |
| t_pay_record | out_trade_no, transaction_id, order_id, amount, status | 支付流水表 |
| t_refund_record | refund_no, order_id, amount, status, reason | 退款流水表 |
| t_coupon_user | user_id, coupon_id, status | 用户优惠券 |
订单状态机是核心,定义一个主流程:
- WAIT_PAY 待支付
- PAID 已支付(待使用)
- USING 使用中
- FINISHED 已完成
- CLOSED 已关闭
- REFUNDING 退款中
- REFUNDED 已退款
流转规则:
- WAIT_PAY 可到 PAID(支付成功)或 CLOSED(超时/用户取消)
- PAID 可到 USING(设备开锁成功)或 REFUNDING(用户退款)
- USING 可到 FINISHED(手动结束或超时结算)
- REFUNDING 可到 REFUNDED(退款成功)
这个状态机不复杂,但一定要用代码约束,别靠业务代码里到处写if-else。第4部分我给了可以直接抄的枚举状态机代码片段。
3. 核心功能实现:从用户下单到设备落锁
3.1 登录与手机号授权
小程序端先调用wx.login拿到code,然后把code传给后端。后端调用微信官方接口code2Session,用appid + secret换取openid。openid是用户在这个小程序里的唯一身份,所有业务都以它为准。
手机号授权可以用微信的getPhoneNumber组件,把用户点击后拿到的code发给后端,后端再调用接口解密得到手机号。这里有个现实问题:这个接口不是所有主体都能用,部分个体户主体或低基础库版本会调用失败。所以我在早期版本做了一个降级逻辑:能拿到手机号就绑定,拿不到就只保存openid,用户可以在个人中心手动填。
登录态维护我用的是JWT,但跳过了Spring Security整套复杂配置,只写了一个简单的拦截器,校验Authorization头里的token,从Redis里取用户信息。无人店场景接口不多,这个方案足够。
3.2 预约、选套餐与库存锁定
先看套餐。套餐表里有一个duration字段,代表基础时长,比如“小型犬洗护120分钟 98元”。用户选套餐下单时,不是直接扣库存,而是先检查对应门店里有没有空闲工位。
空闲工位判断,我用Redis缓存保存每个device(工位)的当前状态:FREE、LOCKED、USING。下单时会用Redis的SETNX锁住某个工位,设置过期时间比如5分钟,防止同一工位被并发重复分配。
这里有一个细节:锁住工位后,订单状态是WAIT_PAY。用户如果5分钟内没支付,定时任务会扫描超时订单并释放工位。如果支付成功,工位状态才真正变成LOCKED,等用户实际开锁后变成USING。
预约时间段同理,我建了一张t_reserve_slot表,每个时间段是唯一的,用数据库唯一索引(store_id, slot_time)来防并发重复预约,比纯Redis判断更安全。电商小程序常见的秒杀、抢购也是这个套路,核心就是先锁后付、超时释放。
3.3 微信支付v3下单与回调验签实现
微信支付是这套系统最重的模块,单独拿出来讲。
下单流程:
- 后端生成业务订单号out_trade_no,存t_order表。
- 后端调用微信支付v3接口POST /v3/pay/transactions/jsapi,传入appid、mchid、description、out_trade_no、notify_url、amount.total(单位是分)、payer.openid。
- 微信返回prepay_id,后端拿着prepay_id再生成小程序端需要的支付参数(timeStamp、nonceStr、package=prepay_id=xxx、signType=RSA、paySign)。
- 小程序端拿到这些参数调用wx.requestPayment拉起收银台。
这里最容易出错的是签名。v3接口的请求头Authorization不是简单的Bearer token,而是WECHATPAY2-SHA256-RSA2048 mchid=...,nonce_str=...,timestamp=...,serial_no=...,signature=...。签名算法是:构造签名串method + "\n" + path + "\n" + timestamp + "\n" + nonce_str + "\n" + body + "\n",然后用商户私钥做SHA256withRSA签名。
回调验签一步都不能省。微信支付服务器会回调你配置的notify_url,请求头里带Wechatpay-Signature、Wechatpay-Serial、Wechatpay-Nonce、Wechatpay-Timestamp。你必须:
- 用Wechatpay-Serial找到对应的微信支付平台证书。
- 用平台证书公钥验签。
- 验签通过后才解密body里的resource字段(AES-256-GCM加密)。
- 根据out_trade_no和transaction_id更新订单状态。
有一个常见误区:验签是用微信支付平台证书的公钥,不是商户证书。我第一次就搞混了,回调验签一直失败。
3.4 设备控制与计费逻辑
设备对接是无人店和普通点单系统的最大区别。
门锁控制我封装了一个DeviceService,调用第三方智能门锁厂商的HTTP API。调用前要做签名,一般是deviceId + timestamp + secret拼接后做MD5,防止伪造指令。开锁成功后,后端做三件事:
- 把订单状态从PAID改成USING。
- 记录start_time。
- 把设备状态从LOCKED改成USING。
计费逻辑:套餐有基础时长,超过基础时长部分按分钟计费。用户点“结束使用”或系统检测到门锁关闭后,计算useMinutes,超出部分额外生成加时费。最终结算金额 = 套餐价格 + 加时费(如果有)。
实际落地时,我采用了“先买套餐后结束”的方式:如果超时,允许用户在线支付超时费后再离店,不涉及虚拟余额账户,审核风险小很多。如果是宠物店老板想做“办卡充值”模式,要提前和微信支付产品确认类目,因为“余额预充值”在部分类目下会被判定为虚拟支付,容易触发“小程序违规,支付功能暂时无法使用”。
设备心跳每30秒上报一次,后端收到后更新Redis里device的online状态。设备离线超过3分钟,运营后台提示“设备离线”,小程序端也会禁止用户继续下单,避免用户到了门店开不了门。
3.5 小程序端页面调用
小程序端代码结构大致是这样:
- pages/home:首页、门店定位、轮播图
- pages/store:门店详情、套餐列表
- pages/order/confirm:下单确认、选择预约时间
- pages/order/detail:订单详情、开锁按钮、剩余时间
- pages/pay/result:支付结果
- pages/user/index:个人中心、余额、优惠券
支付调用这块,小程序端拿到后端返回的支付参数后,直接用:
js复制wx.requestPayment({
timeStamp: res.timeStamp,
nonceStr: res.nonceStr,
package: res.package, // 注意这里是关键字的包装,不能改名
signType: 'RSA',
paySign: res.paySign,
success: () => {
// 支付成功,先拉取订单详情,等后端状态更新后再跳转
},
fail: (err) => {
// 用户取消或支付失败,释放工位
}
});
我调试时踩过一个坑:wx.requestPayment里的timeStamp一定是字符串,后端如果返回long类型,JSON序列化时可能被转换成数字,导致前端签名校验失败。解决办法是把支付参数全部按字符串返回。
还有一个小细节:小程序顶部标题建议在页面onLoad里用wx.setNavigationBarTitle动态设置,比如“Lucky洗宠·XX店”,不要写死在app.json。如果要自定义顶部导航高度,可以调用wx.getMenuButtonBoundingClientRect获取胶囊按钮位置,再动态计算导航栏高度,尤其要兼容iPhone X系列的刘海屏。
3.6 定时任务:超时关单与退款
无人店离不开定时任务。我用Spring自带的@Scheduled,每60秒跑两个任务:
- 扫描WAIT_PAY且创建时间超过5分钟的订单,调用微信支付关单接口,关单成功后把订单改成CLOSED,同时释放Redis里的工位锁。
- 扫描USING且end_time已经过期10分钟以上的订单,如果是先付后用的模型,直接自动结算并关锁;如果是结束后再付,就推送提醒。
退款流程做成异步:用户申请退款后,订单进入REFUNDING,后台调用退款接口,把回调结果更新到t_refund_record,成功后订单变REFUNDED。
定时任务必须幂等。我在处理逻辑里先查了数据库订单状态,再加了数据库乐观锁,避免同一张订单被两个线程各处理一次。订单状态机如果设计得好,定时任务的边界会非常清晰。
4. 可直接复制参考的开源代码片段
这部分我整理了5个核心代码片段,都是实际跑通过的裁剪版,方便直接改改就用。
4.1 微信支付v3请求签名工具类
自己实现v3签名其实不复杂,关键是拼对签名串。核心代码如下:
java复制public class WechatPayV3SignUtil {
private final PrivateKey merchantPrivateKey;
private final String mchId;
private final String serialNo;
public WechatPayV3SignUtil(String mchId, String serialNo, PrivateKey merchantPrivateKey) {
this.mchId = mchId;
this.serialNo = serialNo;
this.merchantPrivateKey = merchantPrivateKey;
}
/**
* 生成 HTTP Authorization 头
* @param method 请求方法,POST 或 GET
* @param path 请求路径,如 /v3/pay/transactions/jsapi
* @param body 请求体,GET 传空字符串
* @return Authorization 头的值
*/
public String buildAuthorization(String method, String path, String body) throws Exception {
long timestamp = System.currentTimeMillis() / 1000;
String nonceStr = UUID.randomUUID().toString().replace("-", "");
String message = method + "\n" + path + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n";
Signature sign = Signature.getInstance("SHA256withRSA");
sign.initSign(merchantPrivateKey);
sign.update(message.getBytes(StandardCharsets.UTF_8));
String signature = Base64.getEncoder().encodeToString(sign.sign());
return "WECHATPAY2-SHA256-RSA2048 "
+ "mchid=\"" + mchId + "\","
+ "nonce_str=\"" + nonceStr + "\","
+ "timestamp=\"" + timestamp + "\","
+ "serial_no=\"" + serialNo + "\","
+ "signature=\"" + signature + "\"";
}
}
注意几个容易踩的坑:
- 签名串每段之间是\n,最后一行body后面还要有一个\n,别漏。
- timestamp是秒级,不是毫秒级。
- 非对称加密算法写SHA256withRSA,不是SHA256RSA。
- nonce_str每次请求都要重新生成,不能复用。
4.2 支付回调验签与数据解密
回调验签的正确姿势是先验签、后解密。核心逻辑如下:
java复制public String verifyAndDecrypt(PayNotifyRequest request, String apiV3Key) throws Exception {
// 1. 验签
String signature = request.getHeader("Wechatpay-Signature");
String serial = request.getHeader("Wechatpay-Serial");
String nonce = request.getHeader("Wechatpay-Nonce");
String timestamp = request.getHeader("Wechatpay-Timestamp");
String body = request.getBody();
// 根据 serial 从缓存获取微信支付平台证书公钥
PublicKey publicKey = wechatPlatformCertificateManager.getPublicKey(serial);
String message = timestamp + "\n" + nonce + "\n" + body + "\n";
Signature sign = Signature.getInstance("SHA256withRSA");
sign.initVerify(publicKey);
sign.update(message.getBytes(StandardCharsets.UTF_8));
boolean ok = sign.verify(Base64.getDecoder().decode(signature));
if (!ok) {
throw new BizException("回调验签失败");
}
// 2. 解密 resource 里的 ciphertext
JSONObject resource = request.getBodyJson().getJSONObject("resource");
String associatedData = resource.getString("associated_data");
String resourceNonce = resource.getString("nonce");
String ciphertext = resource.getString("ciphertext");
// AES-256-GCM 解密
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(128,
Base64.getDecoder().decode(resourceNonce));
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8));
byte[] plainBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
return new String(plainBytes, StandardCharsets.UTF_8);
// 返回的 plainText 里包含 out_trade_no、transaction_id、trade_state 等
}
验签和解密都必须放在独立工具类里,别写在Controller里。生产环境我更推荐直接引入官方wechatpay-java SDK,把证书管理器、签名器、解密器托管起来,自己手写适合学习和报错排查。
4.3 订单状态机示例
状态机用枚举实现,可读性和扩展性都很好,这也是很常见的Java面试题考点。
java复制public enum OrderState {
WAIT_PAY("待支付"),
PAID("已支付"),
USING("使用中"),
FINISHED("已完成"),
CLOSED("已关闭"),
REFUNDING("退款中"),
REFUNDED("已退款");
private final String desc;
OrderState(String desc) {
this.desc = desc;
}
private static final Map<OrderState, Set<OrderState>> TRANSITIONS = new HashMap<>();
static {
TRANSITIONS.put(WAIT_PAY, new HashSet<>(Arrays.asList(PAID, CLOSED)));
TRANSITIONS.put(PAID, new HashSet<>(Arrays.asList(USING, REFUNDING)));
TRANSITIONS.put(USING, new HashSet<>(Collections.singletonList(FINISHED)));
TRANSITIONS.put(REFUNDING, new HashSet<>(Collections.singletonList(REFUNDED)));
}
public boolean canTransferTo(OrderState target) {
Set<OrderState> allowed = TRANSITIONS.get(this);
return allowed != null && allowed.contains(target);
}
}
业务层更新订单状态时统一校验:
java复制if (!order.getState().canTransferTo(targetState)) {
throw new BizException("订单状态流转异常: " + order.getState() + " -> " + targetState);
}
这个做法最直接的好处是不会出现“已关闭订单被支付成功回调改成已支付”这种脏数据。
4.4 智能锁控制与幂等
设备控制的伪代码:
java复制public void openDevice(Integer orderId, Long lockDeviceId) {
boolean locked = redisLock.lock("lock:device:" + lockDeviceId, 3, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("设备正在操作中,请重试");
}
try {
Order order = orderMapper.selectByOrderId(orderId);
if (!order.getState().canTransferTo(OrderState.USING)) {
throw new BizException("当前状态不允许开锁");
}
// 设备指令签名
String ts = String.valueOf(System.currentTimeMillis() / 1000);
String sign = DigestUtils.md5Hex(lockDeviceId + ts + deviceSecret);
// 调用第三方门锁 HTTP 接口
String resp = httpClient.post(
"http://device-gateway/api/v1/device/open",
JsonUtils.toJson(deviceId, ts, sign)
);
// 更新订单状态和开始时间
orderMapper.updateState(orderId, OrderState.PAID, OrderState.USING);
redisCache.set("device:state:" + lockDeviceId, "USING");
} finally {
redisLock.unlock("lock:device:" + lockDeviceId);
}
}
这里最关键的是加锁和状态校验。如果没有锁,用户双击开锁按钮会同时发出两次指令,门锁侧虽然有幂等,但订单状态被更新两次,容易产生脏记录。我在真实项目里就碰到过用户双击导致订单状态从“已支付”直接变成“已完成”的问题,查了半天才发现是并发更新。
4.5 小程序端支付与状态轮询
支付成功的回调里不要立刻跳转首页,先拉取一次订单详情,等后端状态已经变成PAID后再跳转。
js复制requestPayment(payParams).then(() => {
return getOrderDetail(this.data.orderId);
}).then((orderDetail) => {
if (orderDetail.state === 'PAID') {
wx.navigateTo({ url: '/pages/order/detail?orderId=' + this.data.orderId });
} else {
wx.showToast({ title: '支付结果确认中' });
}
});
如果要用明文scheme拉起小程序,注意scheme配置的path必须和分包路径完全一致,否则会报“配置分包路径不行”。我因为这个在联调时卡了半天,一直以为是小程序代码问题。
5. 落地时踩过的坑:支付、审核与无人场景
5.1 小程序违规、支付功能被限制的排查与整改
这个问题必须重点说,因为真的会直接影响门店营业。
我的项目在提审过程中收到过“支付功能暂时无法使用”的违规通知,原因是售卖“会员次卡”被判定为虚拟支付,而当时的类目又不支持虚拟支付类目。整改过程很痛苦:先下架了次卡商品,把“会员卡”改成“实体到店核销券”,重新提审,整个功能被冻结了差不多10天。
根据我的经验,导致无人洗宠店小程序支付违规的常见原因有几种:
- 类目和实际业务不一致,比如选了“工具”类目但卖服务。
- 页面存在诱导分享、诱导关注,比如“分享给3个好友解锁开锁权限”。
- 售卖虚拟商品或会员卡但没有对应的虚拟支付类目。
- 资质缺失,宠物服务类目需要营业执照含相关经营范围。
整改流程就是去微信公众平台查“违规记录”,按通知逐条改,改完提交申诉。申诉周期一般2到5个工作日,期间支付功能不可用,门店会直接停摆。所以务必要在项目上线前把类目、资质、商品形式都确认好。
5.2 微信支付v3对接常见报错
支付对接阶段最容易出现的问题我整理成一张速查表:
| 报错现象 | 可能原因 | 解决办法 |
|---|---|---|
| 调用下单接口返回签名错误 | Authorization签名串格式不对 / 用了错误的私钥 | 检查签名串的换行符,确认使用商户API证书私钥 |
| 报“证书序列号不存在” | 请求头serial_no和证书不匹配 | 检查是否用了正确的证书文件,密钥和证书要配套 |
| 回调验签一直失败 | 用商户证书公钥去验签 | 改用微信支付平台证书公钥验签 |
| NoClassDefFoundError: java/applet/Applet | 依赖冲突,常见于BouncyCastle版本问题 | 检查Spring Boot和BC库版本,统一版本号 |
| 下单金额不对 | 参数total单位是分,不是元 | 后端统一用Integer分,不要用Double元 |
| 支付回调地址收不到 | notify_url配置错误或回调失败 | 检查配置,测试时可在微信支付商户平台手动重试回调 |
其中NoClassDefFoundError那个坑印象最深,当时是因为项目里引了不同版本的BouncyCastle加密库,导致回调解密时类加载不到,后来通过dependency tree排查统一版本才解决。
另外,一个经常被忽略的小点:回调地址必须是正式的HTTPS域名,不能用IP。微信支付平台证书也需要定期更新,建议用官方工具自动下载。
5.3 无人店场景的并发、幂等与超时问题
无人店和普通点单系统的最大不同是“线下物理设备会真实响应”,所以后端必须处理好并发和幂等。
- 重复支付:用户点了两次支付,后端下单接口可能生成两个不同订单号。要保证数据库层面out_trade_no加了唯一索引,防止极端情况下生成重复单号。
- Redis计数问题:我统计当天门店营收时用Redis的incr,结果偶发报错“不是integer or out of range”,后来发现是JSON序列化把Long变成了String。解决方法是手动转为Long再操作。
- 定时任务重复执行:部署多台实例时,@Scheduled会在每台机器上执行一遍,扫单逻辑必须用分布式锁(Redisson)包一层,否则同时关单会产生大量异常日志。
- 设备回调重试:第三方门锁回调可能会重复推送,设备控制接口和回调都要做幂等,幂等key可以用orderId+operationType。
还有一个经验:无人场景下所有“开锁、关锁、开始计时、结束计时”的操作都要写入操作日志表。一旦发生客诉,翻日志定位比翻代码快十倍。
5.4 我的调试小技巧
最后分享几个提高效率的土办法。
- 早点把支付回调的验签逻辑写成单元测试。拿微信支付官方文档给的示例报文,在本地反复跑验签和解密,不要一上来就联调小程序,不然错误会被界面遮挡,问题定位极慢。
- 小程序联调时,微信开发者工具的Network面板足够看到所有请求和响应。手机端可以在代码里注入vConsole,能直接看console和network,不需要额外抓包工具。
- 支付回调改完配置后,如果一直收不到回调,可以去微信支付商户平台找到“支付回调”手动触发一次,能省很多等待时间。
- 如果要用明文scheme拉起小程序,scheme的path参数必须和分包路径一字不差。我以前就因为在scheme里写了主包路径,分包页面一直拉不起来。
- 环境准备阶段,JDK安装后一定先跑一遍java -version和javac -version,很多同事配完JAVA_HOME忘了配PATH,导致终端里还是旧版本。
整个项目做下来,我个人最大的感受是:无人自助洗宠店看上去是个硬件生意,但技术侧的核心其实就三件事——订单状态机不出错、支付回调不丢单、设备指令不重复。这三件事只要稳了,页面再怎么改都不怕。
如果你也有类似的想法,别一上来就打开IDE写代码。先花一天把业务流程走一遍,确认微信支付的类目、资质和商品形式都没问题,再动手。底层的支付、订单、设备模块,完全可以按这篇文章的代码片段先搭一套Demo跑通,再慢慢加门店、加地图、加营销功能。
最后再补一句,这套支付+订单+定时任务的骨架,不只适合洗宠店,小区自助洗衣房、共享茶室、健身舱,甚至普通小程序商城都能直接复用。做技术最值钱的就是把一套通用能力沉淀下来,换一个行业就能再活一次。
