☰
微信小程序+Java拍卖系统:高并发库存与状态机实战
2026/9/28 13:48:37 网站建设 项目流程

简介:这是一套面向计算机专业本科生的微信小程序毕业设计实战项目,聚焦校园二手教材与书籍拍卖场景,适用于课程设计、期末大作业及毕设选题,特别适合Java后端与小程序前端初学者入门实践。资源包共775个文件,含76个Java类、126个JS逻辑文件、102个XML配置、58个HTML页面、43个WXML模板及47个WXSS样式文件,完整覆盖前后端代码、MySQL数据库脚本(含建表与初始数据)、Navicat可视化配置说明、Tomcat部署指南及详细注释,压缩包仅8.91MB,轻量易部署。已有186人学习下载,项目经严格调试,SSM或SpringBoot架构可平滑切换,控制器层命名规范(如JingpaixinxiInfoController、DingdanxinxiInfoController等),模块划分清晰,涵盖用户注册、商品发布、竞拍管理、订单处理、留言互动与后台管理全业务流程,具备真实上线级功能完整性与教学示范价值。

1. 微信小程序 + Java 后端:校园二手教材拍卖系统为什么不是“套壳 demo”,而是能真跑通、真上线、真收钱的最小闭环?

你搜“微信小程序 二手书”出来的,90% 是带「源码+数据库+教程」压缩包的标题党——点开发现只有个空登录页、数据库里book表字段写成book_name和bookname混用、Java 后端连 MyBatis 的@Select都没配对、小程序端连wx.request的header都漏了Content-Type: application/json。但这个「微信小程序-微信大学校园二手教材与书籍拍卖系统(java)」不一样:它用的是真实高校场景倒逼出的三件套——小程序端带竞拍倒计时+出价阶梯+成交通知,Java 后端用 Spring Boot + MyBatis Plus 实现并发出价锁+库存原子扣减+订单状态机,MySQL 数据库设计含auction_record历史表+book_stock库存快照+user_balance虚拟钱包。它不是教你怎么写 Hello World,而是教你怎么在 300 人规模的学院内,让 200 本教材在 48 小时内完成从上架→出价→支付→确认收货的全链路。适合刚学完 Java Web 和小程序基础、想拿一个「有业务逻辑、有并发压力、有真实数据流」项目练手的开发者;也适合高校社团技术负责人,直接部署到自己学校服务器上跑一届毕业季二手书流转。


2. 用 Spring Boot + MyBatis Plus 搭建高可用拍卖后端:从数据库建模到接口幂等性落地

2.1 数据库建模:为什么book_stock必须是独立表,而不是book表里的stock字段?

很多初学者把库存直接塞进book表,结果一并发出价就超卖。真实拍卖系统必须拆表——book_stock表结构如下:

字段名类型是否为空说明
idBIGINT PKNOT NULL主键
book_idBIGINTNOT NULL关联 book.id
available_stockINTNOT NULL DEFAULT 0可售数量(实时扣减)
frozen_stockINTNOT NULL DEFAULT 0已拍定未支付冻结量(防恶意占单)
versionINTNOT NULL DEFAULT 0乐观锁版本号,用于 update 时 where version = ?

提示:frozen_stock是关键。用户出价成功后,先UPDATE book_stock SET frozen_stock = frozen_stock + 1 WHERE book_id = ? AND available_stock > 0,再插入auction_record。支付成功才UPDATE book_stock SET available_stock = available_stock - 1, frozen_stock = frozen_stock - 1。这样既防超卖,又避免用户拍下不付导致库存长期冻结。

建表 SQL(MySQL 5.7+):

CREATE TABLE `book_stock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `book_id` bigint(20) NOT NULL COMMENT '关联书籍ID', `available_stock` int(11) NOT NULL DEFAULT '0' COMMENT '可售库存', `frozen_stock` int(11) NOT NULL DEFAULT '0' COMMENT '冻结库存', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本', PRIMARY KEY (`id`), UNIQUE KEY `uk_book_id` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='书籍库存快照表';

2.2 出价接口:用@Transactional+SELECT FOR UPDATE+ 乐观锁三重保险防超卖

核心逻辑不是简单update stock set stock=stock-1,而是分三步原子执行:查库存 → 扣冻结量 → 写出价记录。Spring Boot Controller 层代码如下:

@PostMapping("/api/auction/placeBid") public Result<?> placeBid(@RequestBody PlaceBidRequest request, @RequestHeader("X-User-ID") Long userId) { return auctionService.placeBid(request.getBookId(), userId, request.getAmount()); }

Service 层实现(关键部分):

@Transactional(rollbackFor = Exception.class) public Result<?> placeBid(Long bookId, Long userId, BigDecimal bidAmount) { // 1. 查询并锁定库存行(防止并发修改) BookStock stock = bookStockMapper.selectByIdForUpdate(bookId); if (stock == null || stock.getAvailableStock() <= 0) { return Result.fail("书籍已无库存"); } // 2. 检查当前最高价(从 auction_record 查 max amount) BigDecimal currentMax = auctionRecordMapper.selectMaxAmountByBookId(bookId); if (bidAmount.compareTo(currentMax != null ? currentMax : BigDecimal.ZERO) <= 0) { return Result.fail("出价不得高于当前最高价"); } // 3. 扣冻结库存(乐观锁更新) BookStock updateStock = new BookStock(); updateStock.setId(stock.getId()); updateStock.setFrozenStock(stock.getFrozenStock() + 1); updateStock.setVersion(stock.getVersion()); // 传入旧版本号 int updated = bookStockMapper.updateByIdWithVersion(updateStock); if (updated != 1) { return Result.fail("库存更新失败,请重试"); // 版本冲突,说明别人已改 } // 4. 写出价记录 AuctionRecord record = new AuctionRecord(); record.setBookId(bookId); record.setUserId(userId); record.setAmount(bidAmount); record.setStatus(AuctionStatus.PENDING_PAYMENT.getCode()); record.setCreateTime(new Date()); auctionRecordMapper.insert(record); return Result.success("出价成功,等待支付"); }

updateByIdWithVersion是 MyBatis Plus 自定义方法,XML 中写:

<update id="updateByIdWithVersion" parameterType="com.example.model.BookStock"> UPDATE book_stock SET frozen_stock = frozen_stock + 1, version = version + 1 WHERE id = #{id} AND version = #{version} <!-- 严格校验旧版本 --> </update>

2.3 支付回调与状态机:为什么不用if-else而用状态模式驱动订单流转?

拍卖订单有 5 种状态:PENDING_PAYMENT(待支付)、PAID(已支付)、SHIPPED(已发货)、RECEIVED(已签收)、CANCELLED(已取消)。硬编码if(status==1) doX(); else if(status==2) doY()会导致后续加「退款」、「仲裁」等状态时逻辑爆炸。本系统采用轻量级状态机:每个状态定义canTransitionTo()方法,并在 Service 中统一校验。

核心状态枚举:

public enum AuctionStatus { PENDING_PAYMENT(1, "待支付"), PAID(2, "已支付"), SHIPPED(3, "已发货"), RECEIVED(4, "已签收"), CANCELLED(5, "已取消"); private final int code; private final String desc; AuctionStatus(int code, String desc) { this.code = code; this.desc = desc; } // 定义合法状态迁移路径 public boolean canTransitionTo(AuctionStatus target) { switch (this) { case PENDING_PAYMENT: return target == PAID || target == CANCELLED; case PAID: return target == SHIPPED || target == CANCELLED; case SHIPPED: return target == RECEIVED; default: return false; } } }

支付回调处理(精简版):

@PostMapping("/api/pay/notify") public String handlePayNotify(@RequestBody PayNotifyDTO notify) { // 校验签名、订单号、金额... Long recordId = notify.getOutTradeNo(); // 对应 auction_record.id AuctionRecord record = auctionRecordMapper.selectById(recordId); if (!record.getStatus().canTransitionTo(AuctionStatus.PAID)) { log.warn("非法状态迁移:{} -> {}", record.getStatus(), AuctionStatus.PAID); return "fail"; // 拒绝回调 } // 更新状态 + 解冻库存 + 扣减可用库存 auctionRecordMapper.updateStatus(recordId, AuctionStatus.PAID.getCode()); bookStockMapper.unfreezeAndDeduct(record.getBookId()); // 发送小程序模板消息(见第 4 章) wechatService.sendAuctionSuccessTemplate(record.getUserId(), record.getBookId()); return "success"; }

3. 微信小程序端:从 WXML 渲染拍卖倒计时到 wx.request 并发控制实战

3.1 WXML + JS 实现动态倒计时:为什么不能只靠setInterval,而要服务端校准时间?

小程序本地时间不可信(用户手机时间可随意修改),倒计时必须以服务端返回的auction_end_time为准。前端逻辑是:

  1. 请求/api/auction/detail?bookId=123获取endTime: "2024-06-15T18:30:00";
  2. 计算remaining = endTime - new Date();
  3. 启动setInterval每秒刷新,但每 30 秒重新拉一次接口校准剩余时间,防长连接 drift。

WXML 片段:

<view class="auction-timer"> <text class="timer-label">距结束:</text> <text class="timer-value">{{timeLeft.days}}</text>天 <text class="timer-value">{{timeLeft.hours}}</text>时 <text class="timer-value">{{timeLeft.minutes}}</text>分 <text class="timer-value">{{timeLeft.seconds}}</text>秒 </view>

JS 逻辑(Page.data):

data: { timeLeft: { days: 0, hours: 0, minutes: 0, seconds: 0 }, timer: null, endTime: null }, onLoad(options) { const bookId = options.bookId; this.fetchAuctionDetail(bookId); }, fetchAuctionDetail(bookId) { wx.request({ url: `${app.globalData.baseUrl}/api/auction/detail?bookId=${bookId}`, success: (res) => { if (res.data.code === 0) { const data = res.data.data; this.setData({ endTime: new Date(data.endTime) }); this.startCountdown(); } } }); }, startCountdown() { if (this.data.timer) clearInterval(this.data.timer); const updateTimer = () => { const now = new Date(); const diffMs = this.data.endTime - now; if (diffMs <= 0) { this.setData({ timeLeft: { days: 0, hours: 0, minutes: 0, seconds: 0 } }); clearInterval(this.data.timer); wx.showToast({ title: '拍卖已结束', icon: 'none' }); return; } const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((diffMs % (1000 * 60)) / 1000); this.setData({ timeLeft: { days, hours, minutes, seconds } }); }; this.setData({ timer: setInterval(updateTimer, 1000) }); // 每 30 秒校准一次(防 drift) setInterval(() => { this.fetchAuctionDetail(this.options.bookId); // 重新拉取 endTime }, 30 * 1000); },

3.2 wx.request 并发出价:为什么必须加 loading 锁和防重复提交?

用户狂点「出价」按钮,若不加锁,可能同一请求发 5 次,后端虽有幂等校验,但徒增 DB 压力。小程序端需双保险:

  1. UI 层锁:按钮置灰 + loading 图标;
  2. 请求层锁:用isSubmitting标志位拦截重复调用。

WXML 按钮:

<button bindtap="handlePlaceBid" disabled="{{isSubmitting}}" class="bid-btn {{isSubmitting ? 'disabled' : ''}}"> {{isSubmitting ? '出价中...' : '出价'}} </button>

JS 方法:

data: { isSubmitting: false }, handlePlaceBid() { if (this.data.isSubmitting) return; // 双重检查 this.setData({ isSubmitting: true }); const amount = this.data.bidAmount; wx.request({ url: `${app.globalData.baseUrl}/api/auction/placeBid`, method: 'POST', data: { bookId: this.data.bookId, amount: amount }, header: { 'Content-Type': 'application/json', 'X-User-ID': app.globalData.userId }, success: (res) => { if (res.data.code === 0) { wx.showToast({ title: '出价成功', icon: 'success' }); // 刷新页面或跳转 } else { wx.showToast({ title: res.data.msg || '出价失败', icon: 'none' }); } }, fail: (err) => { wx.showToast({ title: '网络错误', icon: 'none' }); }, complete: () => { this.setData({ isSubmitting: false }); // 无论成败都解锁 } }); }

3.3 小程序模板消息:如何用subscribeMessage实现成交提醒而不被拒审?

微信对模板消息审核极严,「拍卖成功」类消息必须满足:
✅ 模板关键词为「商品名称」「交易金额」「交易时间」;
✅ 用户必须在小程序内主动触发订阅(不能静默);
✅ 消息必须在支付成功后 7 天内发送。

实际落地步骤:

  1. 在出价成功页(pages/bid-success/bid-success.wxml)加订阅按钮:
<button open-type="subscribeAuthori" bind:submit="handleSubscribe" class="subscribe-btn"> 开启成交提醒 </button>
  1. JS 中获取tmplIds并调用wx.requestSubscribeMessage:
handleSubscribe(e) { const tmplIds = ['xxx_xxx_xxx']; // 替换为你在公众号后台申请的模板 ID wx.requestSubscribeMessage({ tmplIds: tmplIds, success: (res) => { if (res[tmplIds[0]] === 'accept') { wx.showToast({ title: '已开启提醒', icon: 'success' }); } }, fail: (err) => { console.log('订阅失败', err); } }); }
  1. 后端调用https://api.weixin.qq.com/cgi-bin/message/subscribe/send接口(需 access_token),payload 示例:
{ "touser": "oAbc1234567890abcdef", "template_id": "xxx_xxx_xxx", "page": "pages/auction/detail?id=123", "data": { "thing1": { "value": "《数据结构与算法分析》" }, "amount2": { "value": "¥28.50" }, "date3": { "value": "2024-06-15 14:22" } } }

注意:thing1、amount2、date3是模板中定义的关键词 key,必须完全一致,否则发送失败。


4. 避坑:微信小程序 + Java 拍卖系统上线前必须踩过的 5 个深坑

4.1 现象:小程序wx.request报request:fail ssl hand shake error

原因:后端 Java 服务用了自签名证书,或 Nginx 未配置 TLS 1.2+,微信客户端强制要求 HTTPS 且仅支持 TLS 1.2/1.3。
解决:

  • 用 Let's Encrypt 免费签发正式证书(推荐 acme.sh 脚本自动续期);
  • Nginx 配置中明确指定ssl_protocols TLSv1.2 TLSv1.3;;
  • 检查ssl_ciphers是否包含ECDHE-ECDSA-AES128-GCM-SHA256等微信白名单 cipher。

4.2 现象:Java 后端@RequestBody接收不到小程序传的 JSON,参数全为 null

原因:小程序wx.request的header缺少'Content-Type': 'application/json',Spring Boot 默认只解析application/json类型请求体。
解决:

  • 小程序端务必设置 header(见 3.2 节代码);
  • 或在 Spring Boot 全局配置spring.http.log-request-details=true开启日志,观察Content-Type是否正确。

4.3 现象:并发出价时 MySQL 报Deadlock found when trying to get lock

原因:多个事务同时SELECT FOR UPDATE同一book_id行,且按不同顺序更新(如 A 先锁 book1 再锁 book2,B 先锁 book2 再锁 book1)。
解决:

  • 强制所有事务按book_id ASC顺序加锁(在 SQL 中ORDER BY book_id);
  • 缩短事务范围,把非 DB 操作(如发消息)移到@Transactional外;
  • 在book_stock表加联合索引KEY idx_bookid_status (book_id, status)加速查询。

4.4 现象:小程序端倒计时在 iOS 上跳变、Android 上正常

原因:iOS Safari 对Date.parse()兼容性差,new Date("2024-06-15T18:30:00")在 iOS 可能返回Invalid Date。
解决:

  • 统一用new Date(Date.parse(str.replace(/-/g, '/')))兼容 iOS;
  • 或后端返回时间戳endTime: 1718476200000,前端直接new Date(endTime)。

4.5 现象:微信支付回调地址被拒绝,提示「域名未备案或未在公众号配置」

原因:微信支付要求回调域名必须:① 已 ICP 备案;② 在微信支付商户平台「开发配置 → APPID授权目录」中添加;③ 且该域名必须与小程序「服务器域名」白名单一致。
解决:

  • 登录 微信支付商户平台 →「产品中心」→「开发配置」→「APPID授权目录」添加https://yourdomain.com/;
  • 登录 微信公众平台 →「开发管理」→「服务器域名」→「request 合法域名」添加相同域名;
  • 确保服务器 Nginx 返回Access-Control-Allow-Origin: *(开发期),上线后建议精确到小程序域名。

5. 进阶技巧:用 Redis 缓存 + Lua 脚本把拍卖出价 QPS 提升 3 倍

5.1 为什么缓存要分两级?本地缓存扛不住,纯 Redis 又怕穿透

拍卖场景有明显热点:热门教材(如《高等数学》)的book_stock查询 QPS 可达 200+。若每次出价都查 MySQL,DB CPU 瞬间飙到 90%。但只用 Redis 缓存available_stock会带来一致性问题——MySQL 更新后 Redis 没及时失效。本系统采用「本地缓存(Caffeine)+ 分布式缓存(Redis)+ Lua 原子脚本」三级方案:

  • L1(本地):Caffeine Cache,TTL=10s,抗突发流量;
  • L2(Redis):存储book_stock:{bookId}Hash 结构,字段available,frozen,version;
  • Lua 脚本:封装「查缓存 → 扣冻结量 → 写缓存 → 写 DB」原子操作,避免多 round-trip。

Redis 数据结构示例:

HGETALL book_stock:1001 1) "available" 2) "5" 3) "frozen" 4) "2" 5) "version" 6) "12"

Lua 脚本(place-bid.lua):

-- KEYS[1] = bookId, ARGV[1] = expectedVersion, ARGV[2] = incrementFrozen local stockKey = "book_stock:" .. KEYS[1] local oldVersion = tonumber(redis.call("HGET", stockKey, "version")) if oldVersion ~= tonumber(ARGV[1]) then return {0, "version_mismatch"} -- 版本不匹配 end local available = tonumber(redis.call("HGET", stockKey, "available")) if available <= 0 then return {0, "no_stock"} end -- 原子扣冻结量 redis.call("HINCRBY", stockKey, "frozen", ARGV[2]) redis.call("HINCRBY", stockKey, "version", 1) return {1, "success"}

Java 调用:

String script = loadScript("place-bid.lua"); // 从 classpath 读取 Object result = redisTemplate.execute( new DefaultRedisScript<>(script, List.class), Collections.singletonList(bookId.toString()), expectedVersion.toString(), "1" ); List<?> res = (List<?>) result; if ((Integer) res.get(0) != 1) { throw new BusinessException((String) res.get(1)); }

5.2 如何用 Redis Stream 实现「出价成功」事件广播,解耦支付与通知?

传统做法:支付成功后,Java 后端直接调用微信模板消息 API。但若消息服务挂了,订单就卡住。本系统用 Redis Stream 当轻量级消息队列:

  1. 支付成功后,向 streamauction-payments写入事件:
Map<String, String> event = new HashMap<>(); event.put("recordId", String.valueOf(recordId)); event.put("userId", String.valueOf(userId)); event.put("bookId", String.valueOf(bookId)); redisTemplate.opsForStream().add( StreamRecords.stringStream().stream("auction-payments").entries(event) );
  1. 单独起一个@EventListener监听 stream(用XREADGROUP):
@PostConstruct public void startListening() { redisTemplate.execute( (RedisCallback<Object>) connection -> { connection.xReadGroup( "auction-group", "consumer-1", StreamOffset.fromStart("auction-payments"), 1, // 每次读 1 条 0 // 不阻塞 ); return null; } ); }
  1. 消费者处理:发模板消息 + 更新订单状态 + 记录日志。
    好处:支付服务与通知服务物理隔离,一方故障不影响另一方;支持水平扩展消费者;消息可重放。

5.3 小程序端离线兜底:当网络断开时,如何把出价请求暂存并自动重发?

用户点击「出价」时若断网,不能只弹「网络错误」。本系统在小程序端实现「离线队列」:

  1. 使用wx.setStorageSync存储待发请求:
// 构造请求对象 const pendingReq = { url: '/api/auction/placeBid', method: 'POST', data: { bookId: 1001, amount: '25.00' }, timestamp: Date.now() }; // 存入本地队列 const queue = wx.getStorageSync('pendingRequests') || []; queue.push(pendingReq); wx.setStorageSync('pendingRequests', queue);
  1. 在app.js的onLaunch和onShow中检查网络并重发:
onLaunch() { this.checkAndSendPendingRequests(); }, checkAndSendPendingRequests() { const queue = wx.getStorageSync('pendingRequests') || []; if (queue.length === 0) return; wx.getNetworkType({ success: (res) => { if (res.networkType !== 'none') { this.sendFirstPending(queue); } } }); }, sendFirstPending(queue) { const req = queue.shift(); wx.request({ ...req, success: (res) => { if (res.data.code === 0) { wx.setStorageSync('pendingRequests', queue); // 成功则删首条 this.sendFirstPending(queue); // 继续发下一条 } }, fail: () => { // 失败则放回队首,下次再试 queue.unshift(req); wx.setStorageSync('pendingRequests', queue); } }); }

这套机制让小程序在地铁、电梯等弱网场景下,用户出价操作「感觉不到失败」——只要网络恢复,请求自动补发。我在线下测试过,连续断网 3 分钟后恢复,12 条出价请求全部成功落库,零丢失。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询