Build a Smart Vending Cabinet with Spring Boot and WeChat Pay: Scan‑to‑Open and Auto‑Deduction
This article details a complete Spring Boot implementation of a smart vending cabinet that uses WeChat QR‑code scanning to open doors, reports purchased items, and automatically deducts payment via WeChat's no‑password delegated‑deduction API, covering configuration, database design, code examples, and error‑handling strategies.
Overall Business Flow
User scans the cabinet QR code; the request carries the device code and cabinet ID and redirects to a WeChat mini‑program authorization page.
The backend checks the device status and, if the user has not signed the WeChat Pay no‑password deduction agreement, returns a contract URL for the first‑time user.
After a successful contract, an open‑door command is sent to the hardware, unlocking the cabinet.
User takes items and closes the door; the hardware reports all taken SKUs and quantities via HTTP/MQTT.
The backend receives the report, calculates the total amount, and creates a pending settlement order.
The backend calls the WeChat Pay delegated‑deduction interface to charge the user automatically.
WeChat Pay asynchronously pushes the deduction result; the backend updates the order status.
On success: inventory is reduced, a consumption record is generated, and details are pushed to the user. On failure: a fallback refund workflow is triggered and an exception order is logged for manual review.
Exception handling covers long‑door‑open timeouts, missing device reports, and deduction failures, automatically closing the door and generating an exception ticket.
Key Points
Standard QR‑code payment requires pre‑payment, which can cause paid‑but‑not‑collected goods. The post‑payment model relies on WeChat's no‑password delegated‑deduction (contract) and separates the process into three stages—authorization‑open, item‑report, and automatic settlement—to ensure idempotency and fund safety.
Prerequisite: WeChat Pay Merchant Configuration
Required Credentials and Permissions
Enterprise WeChat Pay merchant number with completed enterprise verification.
Enable the "Delegated Deduction/No‑Password Payment" product (industry: self‑service retail/unmanned vending).
Configure merchant API certificate, APIv3 key, and payment callback URLs.
Set up a mini‑program or service account, bind the merchant number, and obtain AppID and AppSecret.
Configure the contract template: single‑transaction limit, contract validity, and no‑password deduction notice text.
Core Configuration (application.yml)
# 微信支付配置
wxpay:
mch-id: 1688888888
app-id: wx9999999999abcdef
api-v3-key: 1234567890abcdef1234567890abcdef
private-key-path: classpath:cert/apiclient_key.pem
platform-cert-path: classpath:cert/wechatpay_cert.pem
notify-url: https://xxx.com/api/wxpay/notify
contract-notify-url: https://xxx.com/api/wxpay/contractNotify
cabinet:
single-limit: 100
door-timeout: 120WeChat Pay SDK Dependencies
<!-- 微信支付v3官方SDK -->
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-java</artifactId>
<version>0.2.12</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.32</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>Database Core Tables
cabinet_info (Device Table)
CREATE TABLE cabinet_info (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
cabinet_code VARCHAR(32) NOT NULL COMMENT '设备唯一编码(二维码携带)',
cabinet_name VARCHAR(64) COMMENT '柜子名称',
address VARCHAR(255) COMMENT '摆放地址',
status TINYINT DEFAULT 1 COMMENT '0故障 1正常 2开门中',
create_time DATETIME DEFAULT NOW(),
update_time DATETIME DEFAULT NOW(),
UNIQUE uk_cabinet_code(cabinet_code)
);user_wx_contract (User Contract Table)
CREATE TABLE user_wx_contract (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
openid VARCHAR(64) NOT NULL COMMENT '用户微信openid',
contract_id VARCHAR(128) NOT NULL COMMENT '微信代扣协议ID',
contract_status TINYINT COMMENT '0未签约 1已签约 2已解约',
single_limit DECIMAL(10,2) COMMENT '单笔限额',
sign_time DATETIME COMMENT '签约时间',
UNIQUE uk_openid(openid)
);cabinet_order (Order Table)
CREATE TABLE cabinet_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL COMMENT '订单唯一号',
cabinet_code VARCHAR(32) NOT NULL COMMENT '设备编码',
openid VARCHAR(64) NOT NULL,
contract_id VARCHAR(128) COMMENT '代扣协议ID',
total_amount DECIMAL(10,2) DEFAULT 0 COMMENT '商品总金额',
status TINYINT DEFAULT 0 COMMENT '0开门中 1待扣款 2扣款成功 3扣款失败 4已退款',
goods_detail TEXT COMMENT '取货商品JSON',
open_door_time DATETIME COMMENT '开门时间',
close_door_time DATETIME COMMENT '关门上报时间',
wx_withdraw_no VARCHAR(128) COMMENT '微信代扣单号',
refund_no VARCHAR(128) COMMENT '退款单号',
create_time DATETIME DEFAULT NOW(),
update_time DATETIME DEFAULT NOW(),
UNIQUE uk_order_no(order_no),
INDEX idx_cabinet_code(cabinet_code),
INDEX idx_openid(openid)
);cabinet_goods (Inventory Table)
CREATE TABLE cabinet_goods (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
cabinet_code VARCHAR(32) NOT NULL,
sku_code VARCHAR(32) NOT NULL,
goods_name VARCHAR(64),
price DECIMAL(10,2) COMMENT '单价',
stock INT DEFAULT 0 COMMENT '当前库存',
UNIQUE uk_cabinet_sku(cabinet_code,sku_code)
);WeChat Pay V3 Configuration Class
import com.wechat.pay.java.core.Config;
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WxPayConfig {
@Value("${wxpay.mch-id}")
private String mchId;
@Value("${wxpay.api-v3-key}")
private String apiV3Key;
@Value("${wxpay.private-key-path}")
private String privateKeyPath;
@Value("${wxpay.platform-cert-path}")
private String platformCertPath;
@Bean
public Config wxPayConfig() {
return new RSAAutoCertificateConfig.Builder()
.merchantId(mchId)
.privateKeyFromClasspath(privateKeyPath)
.apiV3Key(apiV3Key)
.wechatPayCertificatesFromClasspath(platformCertPath)
.build();
}
}Module 1: Scan‑to‑Open & No‑Password Contract Flow
1.1 Scan Entry Controller
@RestController
@RequestMapping("/api/cabinet")
public class CabinetDoorController {
@Autowired
private CabinetInfoService cabinetInfoService;
@Autowired
private UserContractService contractService;
@Autowired
private CabinetOrderService orderService;
@Autowired
private CabinetHardwareClient hardwareClient;
/**
* 扫码开门入口
* @param cabinetCode 设备编码
* @param openid 用户微信openid
*/
@GetMapping("/scan/open")
public Result<?> scanOpenDoor(@RequestParam String cabinetCode, @RequestParam String openid) {
// 1. 校验设备是否存在、是否故障、是否已开门
CabinetInfo cabinet = cabinetInfoService.getByCode(cabinetCode);
if (cabinet == null) {
return Result.fail("设备不存在");
}
if (cabinet.getStatus() == 0) {
return Result.fail("设备故障,暂无法使用");
}
if (cabinet.getStatus() == 2) {
return Result.fail("柜门已开启,请先关闭柜门");
}
// 2. 查询用户是否已开通免密代扣协议
UserWxContract contract = contractService.getByOpenid(openid);
if (contract == null || contract.getContractStatus() != 1) {
// 返回签约跳转链接,前端跳转微信签约页面
String signUrl = contractService.getSignUrl(openid, cabinet.getSingleLimit());
return Result.success(Map.of("needSign", true, "signUrl", signUrl));
}
// 3. 已签约,生成预订单,下发开门指令给硬件
String orderNo = orderService.createPreOrder(cabinetCode, openid, contract.getContractId());
hardwareClient.openDoor(cabinetCode);
cabinetInfoService.updateStatus(cabinetCode, 2);
return Result.success(Map.of("needSign", false, "orderNo", orderNo, "msg", "柜门已开启,请取货"));
}
}1.2 No‑Password Contract Service
@Service
public class UserContractService {
@Value("${wxpay.app-id}")
private String appId;
@Value("${cabinet.single-limit}")
private BigDecimal singleLimit;
@Autowired
private Config wxPayConfig;
@Autowired
private UserWxContractMapper contractMapper;
// 生成免密签约链接
public String getSignUrl(String openid, BigDecimal limit) {
// 调用微信v3 委托代扣签约接口,生成跳转url(省略 SDK 细节)
return "https://wxpay签约跳转地址";
}
// 接收微信签约异步回调
@PostMapping("/api/wxpay/contractNotify")
public Result<?> contractNotify(@RequestBody String body) {
// 1. 校验微信签名
// 2. 解析回调参数:openid、contract_id、签约状态
// 3. 入库保存用户代扣协议
return Result.success();
}
}Module 2: Close‑Door Report & Automatic Deduction
2.1 Hardware Report Controller
@RestController
@RequestMapping("/api/hardware")
public class HardwareReportController {
@Autowired
private CabinetOrderService orderService;
/**
* 柜门关闭,硬件上报取货商品
*/
@PostMapping("/goods/report")
public Result<?> goodsReport(@RequestBody GoodsReportDTO dto) {
// dto: cabinetCode、orderNo、goodsList[{skuCode,num}]
orderService.handleCloseReport(dto);
return Result.success();
}
}2.2 Order Settlement Service
@Service
@Transactional
public class CabinetOrderService {
@Autowired
private CabinetOrderMapper orderMapper;
@Autowired
private UserWxContractMapper contractMapper;
@Autowired
private CabinetGoodsMapper goodsMapper;
@Autowired
private WxWithdrawService wxWithdrawService;
@Autowired
private CabinetInfoService cabinetInfoService;
// 处理关门上报,计算金额并发起代扣
public void handleCloseReport(GoodsReportDTO dto) {
// 1. 查询预订单,校验状态为「开门中」,防止重复上报
CabinetOrder order = orderMapper.selectByOrderNo(dto.getOrderNo());
if (order == null || !order.getStatus().equals(0)) {
throw new RuntimeException("订单非法,无需结算");
}
// 2. 遍历商品,计算总金额,校验库存
List<GoodsItem> goodsList = dto.getGoodsList();
BigDecimal total = BigDecimal.ZERO;
for (GoodsItem item : goodsList) {
CabinetGoods goods = goodsMapper.selectByCabinetSku(dto.getCabinetCode(), item.getSkuCode());
BigDecimal itemTotal = goods.getPrice().multiply(new BigDecimal(item.getNum()));
total = total.add(itemTotal);
}
// 3. 更新订单:商品明细、总金额、状态改为待扣款、关门时间
order.setGoodsDetail(JSON.toJSONString(goodsList));
order.setTotalAmount(total);
order.setCloseDoorTime(new Date());
order.setStatus(1);
orderMapper.updateById(order);
// 4. 同步扣减库存
for (GoodsItem item : goodsList) {
goodsMapper.deductStock(dto.getCabinetCode(), item.getSkuCode(), item.getNum());
}
// 5. 调用微信免密代扣接口
String withdrawNo = wxWithdrawService.createWithdraw(order.getContractId(), order.getOrderNo(), total, order.getOpenid());
order.setWxWithdrawNo(withdrawNo);
orderMapper.updateById(order);
// 6. 恢复设备状态为正常
cabinetInfoService.updateStatus(dto.getCabinetCode(), 1);
}
}2.3 WeChat No‑Password Deduction Wrapper
@Service
public class WxWithdrawService {
@Value("${wxpay.notify-url}")
private String notifyUrl;
@Autowired
private Config wxPayConfig;
/**
* 发起委托代扣
* @param contractId 用户免密协议ID
* @param outTradeNo 商户订单号
* @param amount 金额
* @param openid 用户openid
* @return 微信代扣单号
*/
public String createWithdraw(String contractId, String outTradeNo, BigDecimal amount, String openid) {
// 组装 v3 委托代扣请求参数并调用微信支付接口
// 返回微信内部代扣单号,存入订单
return "微信代扣单号";
}
}Module 3: Payment Notification & Refund
3.1 Payment Callback Controller
@RestController
@RequestMapping("/api/wxpay")
public class WxPayNotifyController {
@Autowired
private CabinetOrderService orderService;
@PostMapping("/notify")
public String payNotify(@RequestBody String body) {
// 1. 校验微信签名,过滤非法请求
// 2. 解析回调:商户订单号、扣款状态、微信代扣单号
JSONObject json = JSON.parseObject(body);
String outTradeNo = json.getString("out_trade_no");
String tradeState = json.getString("trade_state");
CabinetOrder order = orderService.getByOrderNo(outTradeNo);
if ("SUCCESS".equals(tradeState)) {
// 扣款成功
order.setStatus(2);
} else {
// 扣款失败,标记异常,后续自动退款
order.setStatus(3);
orderService.autoRefund(order);
}
orderMapper.updateById(order);
// 返回微信成功标识,否则微信会重复回调
return "<xml><return_code>SUCCESS</return_code></xml>";
}
}3.2 Refund Service
@Service
public class CabinetRefundService {
@Autowired
private Config wxPayConfig;
// 全额退款
public void refund(String wxWithdrawNo, String orderNo, BigDecimal amount) {
// 调用微信 V3 退款接口,原路退回用户余额/银行卡
}
}Idempotency & Risk Controls
Idempotent Report Interface – Use orderNo as a unique index; before processing, check order status to avoid duplicate deductions caused by network retries.
No‑Password Contract Permission – Enforce single‑transaction limit consistent with merchant configuration; prohibit high‑value items and block door opening after contract termination.
Automatic Door‑Close Fallback – Scheduled task scans devices stuck in "open" state for over 2 minutes, sends a close command, and creates an exception order.
Deduction Failure Refund + Manual Ticket – Immediate full refund on failure, push an exception ticket for operational review.
Signature Verification – All WeChat callbacks and hardware reports include signature checks to prevent forged requests.
Distributed Lock for Door Access – Ensure only one user can open a specific cabinet at a time.
Inventory Transaction Rollback – On deduction failure, rollback stock changes to keep inventory accurate.
Conclusion
The complete "scan‑to‑open, automatic deduction" vending cabinet system consists of four layers: WeChat no‑password contract, hardware communication, automatic order settlement, and payment callback with fallback mechanisms. Leveraging WeChat Pay's delegated‑deduction capability enables a post‑payment retail experience that dramatically improves user experience compared with traditional pre‑payment models. Critical safeguards include contract legality, idempotent hardware reporting, automatic refunds, device state locking, inventory transaction handling, and signature verification to avoid fund disputes, lost goods, or duplicate charges.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
