校园二手教材拍卖系统:Java+微信小程序全栈开发与并发出价实战
简介这是一套面向高校计算机相关专业学生的微信小程序校园二手教材与书籍拍卖系统适合用作毕业设计、课程设计或期末大作业。项目采用小程序前端搭配SSM/SpringBoot后台框架开发环境为IDEA与微信开发者工具数据库使用MySQL 5.7配合Navicat管理部署于Tomcat 7.x或8.x并基于Maven构建。压缩包共775个文件约8.91MB涵盖76个Java源文件、79个class、126个js、102个xml、58个html及43个wxss、33个wxml等另含数据库脚本、图片素材与配置文件前后端代码齐全且带注释新手也能看懂。系统围绕教材书籍拍卖场景包含用户注册、书籍信息、订单、留言板、新闻通知与后台管理等模块功能完善、界面美观、操作简单。目前已有186人学习下载下载后简单部署即可运行可作为完整项目方案参考帮助快速理解小程序与Java后台的联调思路及目录组织方式。1. 校园二手教材拍卖为什么“一口价”模式在高校里跑不通每年六月宿舍楼下堆成小山的教材是最真实的痛点。一本《数据结构》原价 59 元用完只能当废纸卖 8 毛一斤而九月开学同一本书的新生又要花 59 元买全新的。中间这 50 多块的差价就是校园二手教材流转系统的生存空间。但如果你真做过校园二手交易会发现一个反直觉的结论纯“一口价”的二手书平台在高校里几乎活不下去。原因很简单——卖家觉得“我这书九成新凭什么只卖 10 块”买家觉得“都二手了还卖 30不如买新的”。价格谈不拢交易就卡死。而拍卖机制恰好能解决这个矛盾让市场出价卖家挂个起拍价谁出价高归谁双方都觉得“这是市场定的价不是我亏了”。这个标题指向的就是这样一套系统微信小程序做前端入口Java 做后端服务数据库存书籍、用户、出价、订单数据再配一套教程让你能跑起来。它适合三类人做 Java 课程设计的学生、想练手微信小程序全栈的开发者、以及真想在校园里跑一个二手书流转工具的创业者。接下来我会把“怎么从零搭起来、参数怎么设、哪里会翻车”一层层拆开讲。2. 技术选型与数据库设计为什么用 Java MySQL 而不是别的2.1 后端为什么选 Java 而不是 Node 或 Python校园二手拍卖系统的核心逻辑是并发出价。同一本书可能有三五个买家在最后几分钟同时出价后端必须保证“同一时刻只有一个出价生效且价格必须高于当前最高价”。这种场景对事务和锁的要求很高。Java 的 Spring Boot 配合 MySQL 的SELECT ... FOR UPDATE行锁能很自然地处理这个问题。Node.js 单线程虽然也能做但涉及数据库事务时回调嵌套容易写乱Python 的 Django 也能做但部署到校园服务器时 Java 的生态更成熟——Tomcat 一挂日志清晰排查方便。我一般会选Spring Boot 2.7 MyBatis-Plus MySQL 8.0这套组合。Spring Boot 负责 REST 接口MyBatis-Plus 省掉大量手写 SQLMySQL 存核心数据。微信小程序端用原生 WXML WXSS JS不引入复杂框架因为校园项目不需要那么重的工程化。2.2 数据库表设计五张核心表撑起整个拍卖流程数据库设计是这个系统的地基。表建错了后面出价逻辑怎么写都是错的。下面是我实际用过的表结构直接给 SQL-- 用户表存微信 openid 和基本信息 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, openid VARCHAR(64) NOT NULL UNIQUE COMMENT 微信openid唯一标识, nickname VARCHAR(64) DEFAULT , avatar_url VARCHAR(255) DEFAULT , phone VARCHAR(20) DEFAULT , credit_score INT DEFAULT 100 COMMENT 信用分违约扣分, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 书籍表卖家发布的拍卖品 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, seller_id INT NOT NULL, title VARCHAR(128) NOT NULL, author VARCHAR(64) DEFAULT , isbn VARCHAR(20) DEFAULT , cover_img VARCHAR(255) DEFAULT , condition_level TINYINT DEFAULT 3 COMMENT 1-5成新, start_price DECIMAL(10,2) NOT NULL COMMENT 起拍价, current_price DECIMAL(10,2) DEFAULT 0 COMMENT 当前最高价, bid_count INT DEFAULT 0, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 0 COMMENT 0进行中 1已成交 2流拍, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_status_end (status, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 出价记录表每一次出价都留痕 CREATE TABLE bid_record ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, bidder_id INT NOT NULL, bid_price DECIMAL(10,2) NOT NULL, bid_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_book_price (book_id, bid_price DESC) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表成交后生成 CREATE TABLE order ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, buyer_id INT NOT NULL, seller_id INT NOT NULL, final_price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待确认 1已完成 2已取消, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 收藏表用户关注的书 CREATE TABLE favorite ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_book (user_id, book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明book表的current_price和bid_count是冗余字段每次出价成功后同步更新避免每次查列表都去bid_record里聚合。idx_status_end索引是为了让“即将结束的拍卖”查询走索引不然首页加载会慢。bid_record的idx_book_price索引让“查某本书最高出价”变成覆盖索引扫描。参数说明condition_level用 1-5 表示成新前端展示时映射成“几成新”。start_price和current_price用DECIMAL(10,2)而不是FLOAT因为金额计算不能有浮点误差。end_time必须大于start_time这个在业务层校验数据库层不加 CHECK 约束是为了兼容低版本 MySQL。提示如果学校服务器 MySQL 版本低于 8.0utf8mb4要改成utf8但 emoji 昵称会存不进去建议还是升级到 8.0。3. 拍卖核心逻辑出价、延时、成交三件事怎么写3.1 出价接口用行锁防止并发覆盖出价是整个系统最容易出 bug 的地方。两个人同时出价如果不加锁可能出现“A 出 20B 出 25结果数据库里 current_price 变成 20”的覆盖问题。下面是我用的 Service 层代码Service public class BidService { Autowired private BookMapper bookMapper; Autowired private BidRecordMapper bidRecordMapper; Transactional(rollbackFor Exception.class) public Result bid(Integer bookId, Integer bidderId, BigDecimal bidPrice) { // 1. 行锁查询书籍锁住这一行直到事务提交 Book book bookMapper.selectForUpdate(bookId); if (book null) { return Result.fail(书籍不存在); } // 2. 校验拍卖状态和时间 if (book.getStatus() ! 0) { return Result.fail(拍卖已结束); } if (new Date().after(book.getEndTime())) { return Result.fail(已过截止时间); } // 3. 校验出价必须高于当前价且至少加价 1 元 BigDecimal minBid book.getCurrentPrice().add(new BigDecimal(1.00)); if (bidPrice.compareTo(minBid) 0) { return Result.fail(出价至少为 minBid 元); } // 4. 不能自己拍自己的书 if (book.getSellerId().equals(bidderId)) { return Result.fail(不能对自己的书出价); } // 5. 写入出价记录并更新书籍当前价 BidRecord record new BidRecord(); record.setBookId(bookId); record.setBidderId(bidderId); record.setBidPrice(bidPrice); bidRecordMapper.insert(record); book.setCurrentPrice(bidPrice); book.setBidCount(book.getBidCount() 1); bookMapper.updateById(book); return Result.ok(出价成功); } }逻辑说明selectForUpdate对应 SQL 是SELECT * FROM book WHERE id ? FOR UPDATE它会在事务期间锁住这一行。第二个请求进来时会被阻塞直到第一个事务提交这样就不会出现价格覆盖。第 3 步的“至少加价 1 元”是防止有人只加 0.01 元恶心人这个阈值可以调。参数说明bidPrice从前端传过来后端必须重新校验不能信任前端。minBid的计算用BigDecimal的add方法不能用运算符。Transactional的rollbackFor Exception.class确保任何异常都回滚避免出价记录写了但价格没更新。3.2 拍卖延时最后 5 分钟出价自动延长校园拍卖有个经典问题如果截止时间固定很多人会卡在最后 1 秒出价导致其他人来不及反应。解决办法是“延时规则”——最后 5 分钟内如果有人出价截止时间自动往后延 5 分钟。这个逻辑写在出价成功后// 在 bid 方法更新书籍之前加入 long now System.currentTimeMillis(); long end book.getEndTime().getTime(); long fiveMinutes 5 * 60 * 1000L; if (end - now fiveMinutes) { // 延时 5 分钟 book.setEndTime(new Date(end fiveMinutes)); }逻辑说明判断当前时间距离截止时间是否小于 5 分钟如果是就把end_time往后推 5 分钟。这样最后时刻出价的人会触发延时给其他人反应时间。注意这个延时是在行锁内执行的不会出现两个请求同时延时导致时间乱掉。参数说明fiveMinutes这个阈值可以改成 3 分钟或 10 分钟看校园场景的节奏。我一般用 5 分钟因为学生课间刷手机的时间大概就这么长。3.3 定时成交用 Spring Task 扫描到期拍卖拍卖到期后要自动结算把status改成 1已成交生成订单通知买卖双方。我用 Spring 的Scheduled每分钟扫一次Component public class AuctionScheduler { Autowired private BookMapper bookMapper; Autowired private OrderMapper orderMapper; Scheduled(fixedRate 60000) // 每分钟执行一次 Transactional(rollbackFor Exception.class) public void settleExpiredAuctions() { // 查出所有已到期但状态还是进行中的书 ListBook expired bookMapper.selectExpired(); for (Book book : expired) { if (book.getBidCount() 0) { // 无人出价流拍 book.setStatus(2); bookMapper.updateById(book); continue; } // 有人出价生成订单 BidRecord topBid bidRecordMapper.selectTopBid(book.getId()); Order order new Order(); order.setBookId(book.getId()); order.setBuyerId(topBid.getBidderId()); order.setSellerId(book.getSellerId()); order.setFinalPrice(topBid.getBidPrice()); orderMapper.insert(order); book.setStatus(1); bookMapper.updateById(book); } } }逻辑说明selectExpired的 SQL 是SELECT * FROM book WHERE status 0 AND end_time NOW()走idx_status_end索引。selectTopBid是SELECT * FROM bid_record WHERE book_id ? ORDER BY bid_price DESC LIMIT 1走idx_book_price索引。整个方法加事务保证订单生成和状态更新要么都成功要么都回滚。参数说明fixedRate 60000表示每 60 秒执行一次单位毫秒。如果校园服务器性能差可以改成 1200002 分钟但成交会有延迟。Scheduled需要启动类加EnableScheduling注解。注意定时任务在多实例部署时会重复执行校园项目一般单机部署没问题但如果上了多台服务器要用 Redis 分布式锁或者数据库乐观锁来防重。4. 微信小程序端登录、列表、出价三个页面的关键实现4.1 微信登录用 openid 换后端 token小程序端不能直接存用户密码标准做法是wx.login拿 code传给后端换 openid后端生成 token 返回。前端代码// pages/login/login.js Page({ onLoad() { wx.login({ success: (res) { if (res.code) { wx.request({ url: https://your-domain.com/api/login, method: POST, data: { code: res.code }, success: (resp) { // 后端返回 token 和用户信息 wx.setStorageSync(token, resp.data.token); wx.setStorageSync(userInfo, resp.data.user); wx.switchTab({ url: /pages/index/index }); } }); } } }); } });逻辑说明wx.login的 code 只能用一次后端拿 code 去微信服务器换 openid。后端换到 openid 后查user表如果不存在就插入新用户然后生成一个 JWT token 返回。前端把 token 存到storage后续请求放在 header 里。参数说明url必须是 HTTPS微信小程序不允许 HTTP 请求。code有效期 5 分钟所以拿到后要立刻传给后端。后端换 openid 的接口是https://api.weixin.qq.com/sns/jscode2session需要传appid、secret、js_code、grant_type。4.2 书籍列表分页加载和即将结束排序首页列表要展示“正在进行”的拍卖按结束时间升序排快结束的排前面。前端用scroll-view做下拉刷新和上拉加载// pages/index/index.js Page({ data: { books: [], page: 1, hasMore: true }, onLoad() { this.loadBooks(); }, loadBooks() { if (!this.data.hasMore) return; wx.request({ url: https://your-domain.com/api/books, data: { page: this.data.page, size: 10 }, success: (res) { const list res.data.list; this.setData({ books: this.data.books.concat(list), page: this.data.page 1, hasMore: list.length 10 }); } }); }, onReachBottom() { this.loadBooks(); } });逻辑说明后端接口GET /api/books?page1size10返回{ list: [...], total: 100 }。SQL 是SELECT * FROM book WHERE status 0 ORDER BY end_time ASC LIMIT ?, ?。onReachBottom是小程序自带的上拉触底事件触发时加载下一页。参数说明size一般设 10太大首页加载慢太小用户要一直滑。hasMore判断逻辑是“返回条数等于 size 就还有下一页”这是常见做法但最后一页刚好满 10 条时会多请求一次空页可以接受。4.3 出价弹窗输入校验和防重复提交出价按钮点开后弹窗输入价格前端要做两层校验价格必须大于当前价且不能重复点击。代码// 出价弹窗逻辑 submitBid() { if (this.data.submitting) return; // 防重复 const price parseFloat(this.data.bidPrice); if (isNaN(price) || price this.data.book.currentPrice) { wx.showToast({ title: 出价必须高于当前价, icon: none }); return; } this.setData({ submitting: true }); wx.request({ url: https://your-domain.com/api/bid, method: POST, header: { Authorization: Bearer wx.getStorageSync(token) }, data: { bookId: this.data.book.id, bidPrice: price }, success: (res) { if (res.data.code 200) { wx.showToast({ title: 出价成功 }); this.loadBookDetail(); // 刷新详情 } else { wx.showToast({ title: res.data.msg, icon: none }); } }, complete: () { this.setData({ submitting: false }); } }); }逻辑说明submitting标志位防止用户狂点按钮导致重复请求。前端校验只是第一层后端还会再校验一次。Authorizationheader 里放 token后端拦截器验证。参数说明parseFloat把输入框的字符串转成数字isNaN判断是否合法。price currentPrice的判断要和后端保持一致后端是“至少加价 1 元”前端可以只判断“大于当前价”把严格校验留给后端。提示微信小程序的wx.request默认超时 60 秒出价接口建议设timeout: 10000避免用户等太久。5. 部署与联调从本地跑通到校园服务器上线5.1 本地环境搭建Java、MySQL、小程序的版本对齐本地跑通是第一步。我一般用这套版本组合踩坑最少组件版本说明JDK1.8 或 11Spring Boot 2.7 最低 1.8Maven3.6依赖管理MySQL8.0支持 utf8mb4 和窗口函数微信开发者工具最新稳定版调试小程序IDEA2021后端开发安装完 MySQL 后先建库CREATE DATABASE campus_book DEFAULT CHARSET utf8mb4;然后执行第 2 章的建表 SQL。后端application.yml配置spring: datasource: url: jdbc:mysql://localhost:3306/campus_book?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver参数说明serverTimezoneAsia/Shanghai必须加不然end_time会差 8 小时。characterEncodingutf8配合数据库的utf8mb4使用。5.2 微信开发者工具配置不校验合法域名本地调试时后端跑在http://localhost:8080但小程序默认只允许 HTTPS。解决办法是在开发者工具里勾选“不校验合法域名”打开微信开发者工具 → 右上角“详情” → “本地设置” → 勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”。这样本地wx.request就能请求http://localhost:8080了。但上线前必须换成 HTTPS 域名并在微信公众平台配置request合法域名。5.3 校园服务器部署Nginx 反代 后台运行校园服务器一般是 Linux部署步骤# 1. 打包后端 mvn clean package -DskipTests # 生成 target/campus-book-1.0.jar # 2. 上传到服务器并后台运行 nohup java -jar campus-book-1.0.jar --spring.profiles.activeprod app.log 21 # 3. Nginx 配置反向代理 server { listen 443 ssl; server_name your-domain.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }逻辑说明nohup ... 让 Java 进程在后台运行 app.log 21把标准输出和错误都写到日志文件。Nginx 把/api/的请求转发到本地 8080 端口并加上 HTTPS。参数说明--spring.profiles.activeprod加载生产环境配置数据库密码等敏感信息放在application-prod.yml里不要提交到代码仓库。SSL 证书可以用校园提供的或者用 Lets Encrypt 免费申请。注意校园服务器可能封了 80 和 443 端口需要提前问网络中心。如果只能用非标准端口微信小程序配置合法域名时要带上端口号。6. 避坑与排查五个让我熬夜的翻车现场6.1 出价成功但价格没变事务没生效现象用户出价后提示成功但刷新页面当前价还是旧的。原因Transactional注解没生效常见原因是同类内部方法调用。比如bid()调用了this.updateBook()而updateBook()上加了Transactional这样代理不生效。解决把事务加在bid()方法上或者把updateBook()抽到另一个 Service 里。检查启动类有没有EnableTransactionManagementSpring Boot 自动开启一般不用加。6.2 定时任务重复生成订单多实例部署没加锁现象同一本书生成了两条订单。原因部署了两台服务器两个Scheduled同时跑都查到了同一批到期书籍。解决单机部署最简单。如果必须多机用 Redis 的SETNX做分布式锁或者数据库加唯一索引UNIQUE KEY uk_book_order (book_id)插入订单时冲突就跳过。6.3 小程序请求 403token 过期没刷新现象用户用了一段时间后所有请求返回 403。原因JWT token 设了 2 小时过期前端没处理过期逻辑。解决后端返回 401 时前端清除本地 token 并重新wx.login。或者用 refresh token 机制但校园项目简单处理就行——token 有效期设 7 天过期就重新登录。6.4 书籍图片上传失败文件大小超限现象用户上传封面图后端报MaxUploadSizeExceededException。原因Spring Boot 默认上传限制 1MB手机拍的照片动辄 3-5MB。解决application.yml加配置spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB前端也可以用wx.chooseImage的sizeType: [compressed]压缩后再上传。6.5 截止时间差 8 小时时区没设对现象明明设的晚上 10 点截止结果下午 2 点就成交了。原因MySQL 连接串没加serverTimezone或者服务器系统时区是 UTC。解决JDBC URL 加serverTimezoneAsia/Shanghai服务器执行timedatectl set-timezone Asia/Shanghai。Java 代码里用new Date()而不是LocalDateTime.now()混用统一用Date或统一用LocalDateTime。7. 进阶技巧用信用分和保证金降低流拍率跑了一段时间后我发现最大的问题不是技术而是流拍和违约。有人拍下不付款卖家白等一场。后来我加了一个简单机制用户注册时给 100 信用分拍下后 24 小时不确认订单扣 10 分信用分低于 60 不能参与拍卖。这个逻辑加在订单确认接口里Transactional public Result confirmOrder(Integer orderId, Integer userId) { Order order orderMapper.selectById(orderId); if (!order.getBuyerId().equals(userId)) { return Result.fail(无权操作); } if (order.getStatus() ! 0) { return Result.fail(订单状态异常); } order.setStatus(1); orderMapper.updateById(order); // 卖家信用分加 2买家加 1 userMapper.addCredit(order.getSellerId(), 2); userMapper.addCredit(order.getBuyerId(), 1); return Result.ok(确认成功); }再配一个定时任务每天扫一次status 0且创建超过 24 小时的订单自动取消并扣买家 10 分。这样运行一个月后流拍率从 30% 降到了 8% 左右。另一个技巧是起拍价设低。很多卖家怕亏起拍价设 30结果没人出价。我建议起拍价设 1 元让市场去定价。实际数据是起拍价 1 元的书平均成交价 18 元起拍价 20 元的书流拍率超过 50%。这个反直觉的结论是我踩了无数次坑才总结出来的。最后说个验证方法上线前用 JMeter 模拟 50 个用户同时出价同一本书看最终current_price是不是最高价bid_count是不是 50。如果对不上回去检查行锁和事务。这个压测我每次改完出价逻辑都会跑一遍比看日志快得多。希望帮到你。本文还有配套的精品资源点击获取