微信小程序+Java拍卖系统:高并发库存与状态机实战
发布时间:2026/9/28 13:48:39来源:尧图网络
简介这是一套面向计算机专业本科生的微信小程序毕业设计实战项目聚焦校园二手教材与书籍拍卖场景适用于课程设计、期末大作业及毕设选题特别适合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.idavailable_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。这样既防超卖又避免用户拍下不付导致库存长期冻结。建表 SQLMySQL 5.7CREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT书籍库存快照表;2.2 出价接口用TransactionalSELECT FOR UPDATE 乐观锁三重保险防超卖核心逻辑不是简单update stock set stockstock-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 idupdateByIdWithVersion parameterTypecom.example.model.BookStock UPDATE book_stock SET frozen_stock frozen_stock 1, version version 1 WHERE id #{id} AND version #{version} !-- 严格校验旧版本 -- /update2.3 支付回调与状态机为什么不用if-else而用状态模式驱动订单流转拍卖订单有 5 种状态PENDING_PAYMENT待支付、PAID已支付、SHIPPED已发货、RECEIVED已签收、CANCELLED已取消。硬编码if(status1) doX(); else if(status2) 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为准。前端逻辑是请求/api/auction/detail?bookId123获取endTime: 2024-06-15T18:30:00计算remaining endTime - new Date()启动setInterval每秒刷新但每 30 秒重新拉一次接口校准剩余时间防长连接 drift。WXML 片段view classauction-timer text classtimer-label距结束/text text classtimer-value{{timeLeft.days}}/text天 text classtimer-value{{timeLeft.hours}}/text时 text classtimer-value{{timeLeft.minutes}}/text分 text classtimer-value{{timeLeft.seconds}}/text秒 /viewJS 逻辑Page.datadata: { 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 压力。小程序端需双保险UI 层锁按钮置灰 loading 图标请求层锁用isSubmitting标志位拦截重复调用。WXML 按钮button bindtaphandlePlaceBid disabled{{isSubmitting}} classbid-btn {{isSubmitting ? disabled : }} {{isSubmitting ? 出价中... : 出价}} /buttonJS 方法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 天内发送。实际落地步骤在出价成功页pages/bid-success/bid-success.wxml加订阅按钮button open-typesubscribeAuthori bind:submithandleSubscribe classsubscribe-btn 开启成交提醒 /buttonJS 中获取tmplIds并调用wx.requestSubscribeMessagehandleSubscribe(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); } }); }后端调用https://api.weixin.qq.com/cgi-bin/message/subscribe/send接口需 access_tokenpayload 示例{ touser: oAbc1234567890abcdef, template_id: xxx_xxx_xxx, page: pages/auction/detail?id123, 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。解决用 Lets 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/jsonSpring Boot 默认只解析application/json类型请求体。解决小程序端务必设置 header见 3.2 节代码或在 Spring Boot 全局配置spring.http.log-request-detailstrue开启日志观察Content-Type是否正确。4.3 现象并发出价时 MySQL 报Deadlock found when trying to get lock原因多个事务同时SELECT FOR UPDATE同一book_id行且按不同顺序更新如 A 先锁 book1 再锁 book2B 先锁 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。若每次出价都查 MySQLDB CPU 瞬间飙到 90%。但只用 Redis 缓存available_stock会带来一致性问题——MySQL 更新后 Redis 没及时失效。本系统采用「本地缓存Caffeine 分布式缓存Redis Lua 原子脚本」三级方案L1本地Caffeine CacheTTL10s抗突发流量L2Redis存储book_stock:{bookId}Hash 结构字段available,frozen,versionLua 脚本封装「查缓存 → 扣冻结量 → 写缓存 → 写 DB」原子操作避免多 round-trip。Redis 数据结构示例HGETALL book_stock:1001 1) available 2) 5 3) frozen 4) 2 5) version 6) 12Lua 脚本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 当轻量级消息队列支付成功后向 streamauction-payments写入事件MapString, 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) );单独起一个EventListener监听 stream用XREADGROUPPostConstruct public void startListening() { redisTemplate.execute( (RedisCallbackObject) connection - { connection.xReadGroup( auction-group, consumer-1, StreamOffset.fromStart(auction-payments), 1, // 每次读 1 条 0 // 不阻塞 ); return null; } ); }消费者处理发模板消息 更新订单状态 记录日志。好处支付服务与通知服务物理隔离一方故障不影响另一方支持水平扩展消费者消息可重放。5.3 小程序端离线兜底当网络断开时如何把出价请求暂存并自动重发用户点击「出价」时若断网不能只弹「网络错误」。本系统在小程序端实现「离线队列」使用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);在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 条出价请求全部成功落库零丢失。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网