简介:这是一套面向计算机专业本科生的微信小程序毕业设计实战项目,聚焦校园二手教材与书籍拍卖场景,适用于课程设计、期末大作业及毕设选题,特别适合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表结构如下:
| 字段名 | 类型 | 是否为空 | 说明 |
|---|---|---|---|
| id | BIGINT PK | NOT NULL | 主键 |
| book_id | BIGINT | NOT NULL | 关联 book.id |
| available_stock | INT | NOT NULL DEFAULT 0 | 可售数量(实时扣减) |
| frozen_stock | INT | NOT NULL DEFAULT 0 | 已拍定未支付冻结量(防恶意占单) |
| version | INT | NOT 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为准。前端逻辑是:
- 请求
/api/auction/detail?bookId=123获取endTime: "2024-06-15T18:30:00"; - 计算
remaining = endTime - new Date(); - 启动
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 压力。小程序端需双保险:
- UI 层锁:按钮置灰 + loading 图标;
- 请求层锁:用
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 天内发送。
实际落地步骤:
- 在出价成功页(
pages/bid-success/bid-success.wxml)加订阅按钮:
<button open-type="subscribeAuthori" bind:submit="handleSubscribe" class="subscribe-btn"> 开启成交提醒 </button>- 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); } }); }- 后端调用
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 当轻量级消息队列:
- 支付成功后,向 stream
auction-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) );- 单独起一个
@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; } ); }- 消费者处理:发模板消息 + 更新订单状态 + 记录日志。
好处:支付服务与通知服务物理隔离,一方故障不影响另一方;支持水平扩展消费者;消息可重放。
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 条出价请求全部成功落库,零丢失。
希望帮到你。
本文还有配套的精品资源,点击获取