1. 项目概述
1.1 这个预约系统到底解决什么问题
先说一个我观察到的现象。大学宿舍楼、公寓楼里的自助洗衣房,通常就几台洗衣机,高峰时段排队是家常便饭。我见过最夸张的情况是晚上九点半,洗衣房里七八个学生抱着盆子围着两台洗衣机,有人干脆把衣服放桶里扔那儿“人肉占位”,有人洗到一半被人把衣服掏出来扔在旁边——这种体验我不想多描述,做过这类项目的朋友应该都有画面感。
微信小程序的自助洗衣房洗衣机预约系统,核心就是干这件事:用手机远程查看哪些机器空闲、预约锁定某台机器、到点使用、在线支付,把“人肉排队抢机器”变成“预约锁机”。它跟共享单车、共享充电宝的底层逻辑高度相似,但有一个很关键的区别——洗衣机的使用时长不是固定的,有人洗标准模式35分钟,有人洗强力模式50分钟,这就意味着预约系统必须有“允许排队”和“超时释放”的机制,不然一台机器被预约后迟迟没人来,其他人只能干等,预约反而制造了新矛盾。
这个系统适合谁来参考?如果你正在做大学校园类的毕设、创业项目,或者你所在的小区、公寓有自助洗衣设备想加上预约能力,又或者你纯粹想搞懂“预约锁单+支付回调”这套通用业务模型,这篇文章都值得你花十几分钟看完。我会把核心的表结构设计、状态机流转、待支付锁定的超时释放、小程序端和Spring Boot后端的对接方式都拆开讲,最后再整理几个我实际开发中踩过的坑。
1.2 系统整体架构预览
我先把这个系统的完整链路画一遍,方便大家从宏观上有个概念。整体采用市面上最主流、也最好维护的“前后端分离 + 小程序端”架构:
- 小程序端:原生微信小程序,负责用户登录、查看洗衣机列表、预约、支付、查看订单。
- 后端:Spring Boot 2.x,提供预约、订单、支付、设备状态等REST接口。
- 数据库:MySQL,存储设备、订单、用户、支付回执等核心数据。
- 缓存:Redis,承载高频状态读写和分布式锁,尤其在“多用户抢同一台洗衣机”场景下,Redis是保证并发正确性的核心。
- 微信支付:使用V3版接口,走Native下单,支付结果通过回调通知后端更新订单状态。
选择原生小程序而不是Uniapp或者Taro,主要是考虑洗衣房预约这个场景页面不复杂,就列表、详情、订单几个页面,原生写起来最稳,而且微信支付、订阅消息这些原生能力接入起来最直接。后端用Spring Boot是生态成熟,网上资料多,遇到问题好排查。
从业务上看,这个系统有两条并发主线,一条是“用户视角”的预约流程——选设备、提交预约、锁定设备、支付完成、到店使用;另一条是“运维视角”的设备状态流转——空闲、已锁定、使用中、离线。这两条线通过数据库里的订单状态和设备状态互相联动,是整个项目最核心的部分,也是我接下来会重点拆解的。
2. 核心需求与关键设计思路
2.1 需求拆解:预约系统不是简单的“下单”
我在动手写代码之前,先把洗衣房预约的需求从用户痛点和设备管理两个维度拆了一遍。跟大家分享一下我对这个系统的需求分析过程。
从用户角度,一个预约操作要顺滑,得满足几个条件:能实时看到每台洗衣机的在线状态、占用状态和剩余时间;能提前预约锁定,而不是到了现场才发现没机器;预约了但不能无限期霸占,不然对其他人不公平;支付流程要简单直接,别搞一堆折扣计算把用户绕晕。
从管理员和设备角度,需要关注的事更多:每台洗衣机的状态要能自动流转,比如被预约、被使用、被取消后都要及时更新;要对订单做超时管理——用户预约了不付款怎么办?洗衣机坏了怎么办?重复预约怎么避免?这些都要在需求阶段就想清楚。
基于这些分析,我把系统拆成了四个核心模块:设备管理模块(设备列表、在线状态、锁定/释放)、预约下单模块(选择时间段、生成待支付订单、锁定设备)、支付与回调模块(微信支付下单、支付成功回调处理、订单状态同步)、订单管理模块(订单列表、取消订单、历史记录)。再加上基础的用户登录和消息通知,基本上就是完整的业务闭环了。
这个架构思路和很多共享经济类项目是相通的——先想清楚状态怎么流转、异常怎么兜底、并发怎么控制,再去写代码,这样开发周期会短很多。
2.2 技术选型:为什么是Spring Boot + 原生小程序 + MySQL + Redis
选型这块,我直接给出我最终用的技术栈,然后说清楚每个选择的理由。首先要说明的是,市面上的同类系统有人用Node.js做后端,有人用Uniapp打包小程序,但我在项目中选了Spring Boot老搭档,就是为了“稳妥”二字。
后端用Spring Boot 2.x,理由不用多讲,生态里什么东西都有,尤其微信支付V3的SDK对Java的支持是最完整的,参考资料最多。数据库MySQL存储订单这类关系型数据,天然适合事务化管理,一个订单从创建到支付完成,中间任何一步失败都能靠事务回滚保证数据一致。Redis在这里扮演的角色有两个,一是缓存设备实时状态,避免用户疯狂刷新列表时把数据库打垮;二是充当分布式锁,解决并发抢锁的核心问题,这个细节我后面会详细展开。小程序端原生开发,不引入重框架,因为洗衣房页面数量不超过10个,原生语法写起来反而更清爽。
关于微信支付,我直接对接的V3接口。好多人嫌V3麻烦,觉得V2验签简单,但从2023年开始微信支付已经逐步收紧V2的API,新商户直接强制V3,所以没必要再走老路。V3用证书加解密和验签,私钥和证书的管理要做好,后面我会专门写一段对接流程。
2.3 核心难点:并发预约和状态一致性
在正式开写之前,我把这个项目最难的部分提前标了出来:并发预约同一台机器时的数据一致性。大家可以想象一下这个场景:洗衣房一台3号洗衣机显示空闲,宿舍楼里同时有5个学生看到了,一秒钟之内5个人同时点击“预约”按钮。如果代码不处理并发,可能出现的结果是——5个人都看到了“预约成功”的提示,但机器只有一台。这个就是典型的超卖问题,跟秒杀系统里库存超卖一模一样。
解决方法主要有三种思路:数据库乐观锁、Redis分布式锁、数据库唯一索引。我最后采用的是“Redis分布式锁 + 数据库状态校验”双保险。具体逻辑是这样的:用户点预约时,先拿洗衣机的设备ID去Redis抢一把锁,抢到锁的人才有资格继续往下走,抢不到就提示“手慢了,这台机器刚刚被别人预约了”。抢到锁之后进入数据库事务,先把设备状态从“空闲”更新为“锁定”,如果更新影响的记录数是1,说明预约成功;如果影响记录数是0,说明这个设备已经被别人改过了,直接回滚。
这套方案现在几乎成了共享类项目的标准解法。有个细节要提一下:锁一定要设置过期时间,不然用户拿到锁后网络抖动崩溃了,锁永远不会释放,其他用户就永远抢不到这台机器。我习惯把锁的过期时间设置为10秒,配合守护续期模式,基本能兼顾性能和安全性。
3. 数据库设计与状态机实现
3.1 数据表结构设计
数据库是整个预约系统的地基。我把表结构设计成下面这样,大家可以参考一下。核心表一共四张:设备表、用户表、订单表、支付回调表。
设备表(washer_device)是记录洗衣机基本信息的地方,字段包括:
CREATE TABLE washer_device ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '设备ID', device_no VARCHAR(32) NOT NULL COMMENT '设备编号,如W001', location VARCHAR(128) NOT NULL COMMENT '所在位置,如3号楼1层洗衣房', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0空闲 1锁定 2使用中 3离线', mode VARCHAR(16) NOT NULL COMMENT '工作模式:standard/quick/heavy', duration_minutes INT NOT NULL DEFAULT 35 COMMENT '标准时长(分钟)', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT='洗衣机设备表';订单表(washer_order)是业务核心:
CREATE TABLE washer_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT '订单号', user_id BIGINT NOT NULL COMMENT '用户ID', device_id BIGINT NOT NULL COMMENT '设备ID', device_no VARCHAR(32) NOT NULL, amount DECIMAL(10,2) NOT NULL COMMENT '订单金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待支付 1已支付 2使用中 3已完成 4已取消 5已过期', expire_time DATETIME NOT NULL COMMENT '支付截止时间', pay_time DATETIME DEFAULT NULL, start_time DATETIME DEFAULT NULL COMMENT '使用开始时间', end_time DATETIME DEFAULT NULL COMMENT '使用结束时间', cancel_reason VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_device_id (device_id), KEY idx_status (status) ) COMMENT='预约订单表';支付回调表(payment_callback)用于异步处理微信支付结果:
CREATE TABLE payment_callback ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, transaction_id VARCHAR(64) NOT NULL COMMENT '微信支付订单号', callback_data TEXT COMMENT '原始回调报文', process_status TINYINT DEFAULT 0 COMMENT '0未处理 1已处理', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT='支付回调记录表';设计这三张表的时候,有几个小细节值得注意。第一个是订单状态,我用了从0到5的六个状态,而不是简单的“待支付/已支付”,因为实际业务里一定会有超时取消、用户取消、流程异常这些情况,状态分得细一些,后面的逻辑才好写。第二个是订单号的唯一索引,下单和回调两个环节都依赖订单号来关联数据,这里必须加唯一约束,否则一旦出现重复订单号,支付对账会乱成一锅粥。
3.2 核心状态机流转
状态机是这个系统的灵魂,我把每个状态之间的跳转条件画在下面(以文字形式描述):
设备状态:空闲 → 锁定(用户提交待支付订单) → 使用中(用户支付完成开始使用) → 空闲(使用结束释放设备) 设备状态:空闲 → 锁定(用户提交待支付订单) → 空闲(订单超时取消或用户主动取消)
订单状态:待支付 → 已支付(支付回调成功) → 使用中(到店扫码开始使用) → 已完成(使用时长结束) 订单状态:待支付 → 已取消(用户在支付截止前手动取消) 订单状态:待支付 → 已过期(超过支付截止时间未支付)
这里有个特别要强调的业务决策:用户提交预约后,系统产生的订单是“待支付”状态,同时把设备锁定。这个锁定时长我设置为15分钟,15分钟内用户支付则进入下一步,15分钟没支付订单自动过期,设备自动释放回“空闲”状态。为什么要设置待支付锁定时间,很多人可能没仔细想过:如果取消锁定时间,用户提交了预约就直接占住机器,但又迟迟不付款,其他用户就没法用了,这比现场排队效率还低。加上15分钟这个阈值,既给了用户充足的操作时间,又不会让机器被长时间空置。
状态机用代码实现时,我建议用枚举常量配合 Service 层的条件判断,不要散落一堆魔法数字:
public enum OrderStatus { PENDING_PAY(0, "待支付"), PAID(1, "已支付"), USING(2, "使用中"), FINISHED(3, "已完成"), CANCELLED(4, "已取消"), EXPIRED(5, "已过期"); private final int code; private final String desc; // 省略构造函数和getter }3.3 关键实现:待支付超时任务的两种方案
待支付订单的超时处理,市面上有两条路:数据库定时轮询和Redis延迟队列。我拿这个项目来说一下两种方案的实际取舍。
方案一是Spring定时任务加数据库轮询,每30秒扫一次订单表,找出所有状态为待支付且expire_time小于当前时间的订单,批量更新为已过期,同时释放设备。这个方案实现简单,几行代码搞定,但有个隐患:随着订单量增大,扫描全表会越来越慢;而且轮询的间隔决定了过期的实时性最多是30秒,这在订单量大的时候会加剧“机器被无效锁定”的时间窗口。
方案二是Redis延迟队列。下单时把订单号放到一个Sorted Set里,score设为过期时间戳,然后写一个后台线程每秒钟取一次队首,判断是否到期,到期则处理。这个方案实时性高,对数据库的压力也小得多,但代码量会明显增加,还要注意Redis full key的维护成本。
我的建议是:如果你做的是毕设或者几百人使用的小场景,直接选方案一,干净利落,别人看你代码也好懂;如果你要做的是商业级项目、上千台设备那种,再考虑方案二。我自己在这个项目里用的是方案一加了个小优化——在expire_time字段上建索引,扫描的时候只取需要的数据列,实测几万条订单量的场景下完全跑得动。
注意:处理超时订单时,一定要先把订单状态改为“已过期”,再去释放设备,顺序不能反。如果先释放设备,订单状态更新时数据库突然异常,会出现机器空闲但订单还挂着待支付的情况,用户再支付就成功不了,体验很差。
4. 小程序端开发与后端接口对接
4.1 小程序页面设计与核心交互流程
小程序端的页面我设计了四个:首页(洗衣机列表)、预约详情页、订单列表页、个人中心页。首页是整个体验的门面,需要直观展示出每台洗衣机的状态:设备编号、所在位置、当前状态(空闲/使用中/离线)、剩余时间。我用了卡片的展示形式,空闲的卡片高亮可点击,使用中的置灰并显示倒计时,离线的直接半透明遮罩处理。
预约详情页是核心操作页,用户点击一张空闲卡片跳转过来,能看到这台洗衣机的详细信息:支持的模式、预计时长、单价、当前状态。底下一个大按钮,文案会根据状态变化自动调整:“立即预约”或者“已被锁定,去看看其他吧”。用户点击预约,后端接口成功返回订单号后,按钮变成“去支付”,跳转调起微信支付。
订单列表页呈现用户的全部订单,每一单显示设备编号、金额、状态和操作按钮。我记得有一个交互细节特别值得提:待支付状态的订单,按钮是“去支付”和“取消订单”,已支付状态显示“扫码使用”,使用中显示“剩余时间倒计时”,已完成显示“再来一单”。“再来一单”这个功能看着不起眼,实际上非常提升复购率——用户用得顺手,下一次直接一键预约同一台机器。
个人中心页比较简单,就是微信头像昵称、历史订单统计、客服入口。这里提醒一句:微信头像昵称填写能力2022年之后就调整了,现在需要通过头像昵称填写能力获取,不能直接拿openid当昵称展示,这两者的差别在于前者是用户主动授权的,后者是系统生成的匿名ID。
4.2 后端接口清单与关键代码实现
后端接口我设计了七个核心接口,先列个清单,大家有个整体认知:
| 接口名 | 方法 | 路径 | 功能说明 |
|---|---|---|---|
| 微信登录 | POST | /api/user/login | code换openid,返回token |
| 设备列表 | GET | /api/device/list | 按洗衣房位置分组返回设备实时状态 |
| 创建预约 | POST | /api/order/create | 创建待支付订单并锁定设备 |
| 取消预约 | POST | /api/order/cancel | 取消待支付订单并释放设备 |
| 微信支付 | POST | /api/pay/wxpay | 调起微信支付V3统一下单 |
| 支付回调 | POST | /api/pay/callback | 微信支付结果异步通知 |
| 订单列表 | GET | /api/order/list | 当前用户的所有订单 |
创建设备预约是整个系统的核心接口,我给大家看一下实现逻辑。整个方法串了Redis锁、设备状态校验、订单创建三个环节:
@Transactional(rollbackFor = Exception.class) public OrderCreateVO createOrder(Long userId, Long deviceId) { // 1. 通过Redis分布式锁防止并发重复预约 String lockKey = "washer:lock:" + deviceId; boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (!locked) { throw new BizException("手慢了,这台洗衣机刚被别人预约了"); } try { // 2. 数据库乐观校验,只有设备处于空闲状态才能预约 WasherDevice device = deviceMapper.selectByIdForUpdate(deviceId); if (device == null || device.getStatus() != 0) { throw new BizException("设备不存在或已被占用"); } // 3. 创建待支付订单,设备状态改为锁定 WasherOrder order = new WasherOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setDeviceId(deviceId); order.setAmount(device.getPrice()); order.setStatus(OrderStatus.PENDING_PAY.getCode()); order.setExpireTime(LocalDateTime.now().plusMinutes(15)); orderMapper.insert(order); deviceMapper.updateStatus(deviceId, 0, 1); // 空闲->锁定 return OrderCreateVO.of(order); } finally { // 4. 无论如何都释放锁,防止死锁 redisTemplate.delete(lockKey); } }这套代码的核心就是“一锁二验三更新”:先拿分布式锁拦住并发,再用数据库行锁和状态判断保证数据准确,最后统一在事务里更新订单和设备状态。我测试过用JMeter模拟50个并发同时点击预约同一台设备,最终只有1个请求能成功创建订单,其余49个全部收到了友好提示,没有一例出现数据库状态错乱。
4.3 微信支付V3对接全流程
支付对接是预约系统里最容易被卡住的环节。我把整个流程梳理一下,先看下面这个对接时序,心里有个整体概念:
用户点“去支付” → 小程序端调后端 /api/pay/wxpay → 后端用商户私钥生成V3签名,调用微信支付统一下单接口 → 微信返回prepay_id → 后端用该ID生成小程序调起支付的参数(时间戳、随机串、签名等) → 返回给小程序端 → 小程序端调用wx.requestPayment调起微信支付 → 用户输入密码完成支付 → 微信服务器向后端 /api/pay/callback 发异步回调 → 后端验签、解密、更新订单状态 → 小程序端轮询或通过订阅消息感知支付结果,跳转“使用中”页面。
V3跟V2最大的区别是签名方式从MD5改成了基于SHA256withRSA的非对称签名,请求头里要带上Authorization头,格式大概长这样:
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="商户号",nonce_str="随机字符串",signature="签名值",timestamp="时间戳",serial_no="证书序列号"这个签名算法我自己写的时候绕了不少弯路,建议大家直接用官方Java SDK里的工具类,不要去手动拼。核心逻辑就是商户私钥对“HTTP方法 + 请求路径 + 时间戳 + nonce + 请求体”拼接成的明文做SHA256withRSA签名:
String message = httpMethod + "\n" + urlPath + "\n" + timestamp + "\n" + nonceStr + "\n" + body + "\n"; Signature sign = Signature.getInstance("SHA256withRSA"); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); byte[] signed = sign.sign();支付回调的处理要格外小心,微信会“至少通知一次、多次通知”,所以回调接口必须是幂等的——同一笔订单收到多次回调,返回的结果都是一样的。我的处理方式是先查支付回调表,这个transactionId已经处理过就直接返回成功,不再更新订单。
@PostMapping("/api/pay/callback") public String wxPayCallback(@RequestBody String requestBody, @RequestHeader("Wechatpay-Signature") String signature, @RequestHeader("Wechatpay-Timestamp") String timestamp, @RequestHeader("Wechatpay-Nonce") String nonce) { // 1. 验签 boolean valid = payService.verifySignature(requestBody, signature, timestamp, nonce); if (!valid) { return "FAIL"; } // 2. 解密回调报文 PayCallbackData data = payService.decryptCallback(requestBody); // 3. 先查回调表,幂等判断 if (payCallbackMapper.existsByTransactionId(data.getTransactionId())) { return "SUCCESS"; // 已经处理过,直接返回成功 } // 4. 保存回调记录,更新订单状态为已支付 payCallbackMapper.insert(data.toEntity()); orderMapper.updateStatusByOrderNo(data.getOutTradeNo(), OrderStatus.PENDING_PAY.getCode(), OrderStatus.PAID.getCode()); return "SUCCESS"; }重要提醒:处理支付回调时,更新订单状态不能直接无条件UPDATE。一定要加条件“status=0”(待支付才能变已支付)。为什么?如果订单已经超时被定时任务改成“已过期”,用户这时候花钱支付成功了,回调把状态改成“已支付”,就会出现“钱付了但订单过期”的尴尬情况。实测处理方式是:若订单已过期,回调里要把钱原路退回,并给用户发送退款通知。
4.4 小程序端调用支付与状态同步
小程序端调起支付这一步,很多人会在参数格式上栽跟头。V3的支付参数需要后端返回五个字段:timeStamp(时间戳)、nonceStr(随机串)、package(格式为prepay_id=xxx)、signType(RSA)、paySign(签名)。其中paySign的签名原文是appId + 时间戳 + nonceStr + package 拼接的字符串。
小程序端拿到参数后直接调 wx.requestPayment:
wx.requestPayment({ timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.package, signType: 'RSA', paySign: res.data.paySign, success: (payRes) => { wx.showToast({ title: '支付成功', icon: 'success' }); // 跳转到订单详情或使用中页面 wx.redirectTo({ url: '/pages/order/detail?orderNo=' + orderNo }); }, fail: (err) => { wx.showToast({ title: '支付取消', icon: 'none' }); } });这里有个实战经验:支付成功的回调不一定是实时的,微信支付的成功以服务器收到的回调为准,小程序端的success回调只能作为UI提示层使用。所以在支付成功后,小程序端最好再调用一下“查询订单状态”接口,以服务端返回的订单状态为最终结果。如果服务端还没收到回调,可以做一个3秒的轮询,最多轮询5次,超过时间提示“支付结果确认中,请稍后在订单页查看”。这个机制虽然看着冗余,但在弱网环境下实测非常有用,能避免用户支付成功后页面状态迟迟不更新的恼人体验。
4.5 订阅消息通知用户
预约系统里还有一个体验加分项——微信订阅消息。什么意思呢?就是用户预约成功后,小程序可以向用户发送一条服务通知,提醒“您预约的3号洗衣机将在15:30开始使用,请准时到场”。使用完成后,可以再发一条“洗衣完成,请及时取走衣物”。
接入订阅消息的原因是:用户提交预约后,过个半小时可能忘了这件事,尤其是课多的时候,等想起来过去一看,机器被锁着,别人用不了,自己也洗不了。一次性订阅消息可以在用户点击预约时弹出授权,同意后订阅消息就有了推送机会。
实现上,在小程序端调用 wx.requestSubscribeMessage,授权模板ID:
wx.requestSubscribeMessage({ tmplIds: ['模板ID_预约提醒', '模板ID_洗衣完成'], success: (res) => { // 用户同意后后端才能发订阅消息 } });注意:一次性订阅消息每次授权只能发送一条,用户如果取消了授权,后续就发不出去了。所以我在设计上是在创建订单前弹一次订阅授权,同时生成两条订阅机会,但一天内不能给同一个用户重复发两条相同的消息,需要在后端做去重判断。
5. 常见问题与排查经验实录
这一节把我开发过程中真实踩过的坑和一些高频问题整理一下。这些问题如果我当时能提前知道,至少能省一周的排查时间。
5.1 并发测试时预约成功但订单未生成,是怎么回事
这个现象是我在联调时遇到的:用JMeter模拟并发访问创建订单接口,返回成功,但数据库里没有对应的订单记录。排查后发现是事务和锁的嵌套问题。
我的createOrder方法上标了@Transactional,Redis加锁的代码在事务外不应该存在问题,但我当时漏了一个点——redisTemplate.setIfAbsent拿到锁之后,进入方法体内执行数据库操作,但这些操作所在的线程和锁释放的代码不在同一个事务块里。出现问题的场景是:用户A拿到锁,创建订单成功,事务尚未提交,锁已释放;用户B马上拿到锁,查询设备时发现设备状态还没有变成“锁定”(因为用户A的事务还没提交),于是B也创建了订单,导致设备状态被覆盖。
解决办法是把事务边界扩大,让“加锁 → 业务操作 → 释放锁”整个过程都在事务方法内部执行,或者改为在事务提交后再释放锁。Spring里可以用TransactionSynchronizationManager.registerSynchronization在事务提交后回调删除Redis锁。这个问题的本质是分布式锁和数据库事务的边界没有对齐,这也是并发系统里最容易犯的错误之一。
5.2 微信支付回调验签一直失败怎么排查
支付回调验签失败的原因我遇到过三种:证书序列号对不上、时间戳偏差太大、回调原始报文没有用原始字符串验签。
先说证书序列号。V3验签要用微信支付平台证书的公钥,这个证书和你的商户私钥证书是两个东西,很多人搞混。商户证书是你在商户平台下载的,用来签名请求;平台证书是微信的证书,用来验证微信回调的签名。验签时拿的是平台证书的序列号,跟请求头里带过来的Wechatpay-Serial比对,两边不一致就说明证书下载得不对。
时间戳偏差这个好解决,微信要求时间戳与服务器时间差不能超过5分钟,超出就会验签失败。服务器系统时间不准或者时区设置错误经常会触发这个问题。Linux服务器统一用UTC时区,然后在小程序后端里做北京时间转换就行。
验签的报文一定是原始请求体字符串,不能是解析后的JSON对象重新序列化的字符串。因为JSON字段顺序、空格、转义不同,重新序列化后签名原文就对不上了。我在代码里直接用@RequestBody String requestBody接收,不做任何处理,拿到原始字符串去验签,这是最稳妥的。
5.3 用户扫错设备码怎么办
洗衣房一般会在每台洗衣机上贴一个二维码,二维码内容是这个设备的唯一编码。用户到了现场扫了机器上的码,系统根据设备编码绑定订单开始使用。
这里有个场景:用户预约的是3号机,到了现场拿着手机扫了5号机的码。如果不做校验,就会出现3号机在后台处于“使用中”但实际没人操作,5号机显示“空闲”但已经被人扫码绑定的状态错乱。我的解法是下单时记录设备ID,扫码时校验扫码设备ID和订单设备ID是否一致,不一致就提示“您预约的是3号机,请确认后再扫码”。这个校验看着简单,但少了它整个系统的管理状态就会失控。
5.4 支付成功后订单状态没有更新
这个问题的原因七成出在回调接口被防火墙或者网关拦截了。微信支付的回调是微信服务器主动请求你的公网接口,如果你部署在内网环境或者有Nginx拦截规则没放行,回调根本到不了后端。
排查思路是:先去微信商户平台查一下这笔订单的“支付通知记录”,看看回调触发了多少次、返回的HTTP状态码是多少;然后去后端日志里搜回调接口的请求记录,看有没有收到过微信的请求。如果日志里完全没有记录,大概率是网关层拦截了,放行对应路径就好。
还有一个小概率原因:回调接口写的是@RequestBody SomeDto,微信回调报文的Content-Type是application/json,但部分版本的Spring对回调报文的字段映射有大小写兼容问题。比如微信返回的字段名是out_trade_no,Java里映射成outTradeNo是没问题的,但个别字段比如transaction_id如果配置错了注解名,解析出来就是null,后续更新订单时就因为找不到transactionId而退出。遇到这类问题,最笨也最有效的方法是先把整个原始报文打印到日志里,人工比照一下字段。
5.5 超时取消任务把已支付订单取消了
这个错误我记忆犹新。当时我写定时任务的逻辑是“把所有待支付且过期时间小于当前时间的订单置为过期”,第一次上线测试没问题,但有一次用户反馈“我刚付完钱,订单却显示已过期”。查完日志发现,定时任务和支付回调几乎在同一秒执行,定时任务先跑,把订单状态从待支付改成了过期,支付回调后到,发现订单状态不是待支付,就直接退出了,导致订单状态一直停留在“已过期”但实际支付成功了。
这个问题有两种修法:一是定时任务更新订单时,除了状态和过期时间条件,再加上一个“创建时间早于当前时间1分钟”的条件,给支付回调留出缓冲时间;二是参考我前面说的回调处理方案,支付回调发现订单已过期时,自动发起原路退款并记录异常单。我在正式环境用的是“双管齐下”:定时任务加缓冲时间,回调里也做了退款兜底。
5.6 小程序上线审核容易被拒的几个点
最后说一下上线审核方面容易踩的问题。洗衣房预约系统涉及支付功能,微信审核主要有几个关注点:一是小程序类目要选对,涉及支付的需要选择“生活服务 > 生活缴费”或“工具 > 信息查询”等对应类目,且需要提供相应的资质;二是虚拟支付限制,实物商品或线下服务可以用微信支付,但虚拟商品(比如会员、金币)不能用微信支付,做洗衣预约这种线下实体服务没问题;三是用户隐私协议,小程序后台要填写用户隐私保护指引,手机号、位置信息等敏感信息需要明确说明使用目的。
还有一个小程序审核的细节:体验版二维码只能在开发者工具里生成,发给微信团队的审核人员预览时,他们一般要求你上传一个“审核专用版本”,这个版本的页面要保证在没有真实用户登录的情况下也能正常浏览主要功能。我当时因为首页依赖登录态获取设备列表,没做登录态兼容,被驳回了一次,后来改成未登录时展示默认测试数据才过审。
6. 性能优化与扩展思考
6.1 设备列表实时刷新方案优化
首页的设备列表需要展示实时状态,但总不能每个用户下拉刷新一次就去数据库全表查询一次。我做了一个缓存优化:设备状态和剩余时间放到Redis里,key是设备ID,value是设备状态JSON串,过期时间30秒。用户请求设备列表时先查Redis,查不到再回源数据库并回填缓存。设备状态变更(预约、支付完成、使用结束)的时候,主动删除对应设备的缓存。
这个方案实测在500人同时在线的场景下,接口响应时间稳定在100ms以内,数据库的压力也小了很多。如果设备规模再大,可以再引入推送(WebSocket)代替轮询,但洗衣房项目一般没这个必要,30秒的轮询间隔足够及时了。
6.2 预约场景还能怎么扩展
整个系统的核心模型其实不局限于洗衣机预约,它可以延展到很多场景:自习室座位预约、健身房器材预约、会议室预约、共享打印机预约。状态机的核心逻辑都是“空闲→锁定→使用→释放”,只是业务参数不同。如果你在写毕业设计,可以在这个基础上加上管理员后台、数据统计大屏、设备故障上报这些功能,项目完整度会高很多。
我实际操作中还有一个体会:预约系统的核心价值不在技术多炫酷,而在排队效率和用户体验的提升。传统排队是“先到先得”,预约系统是“先约先得”,本质上解决的是高峰期资源错配的问题。把这个业务逻辑想透了,再去写代码,很多细节上的取舍都会变得清晰。