Spring Boot二手书交易系统核心设计与答辩技巧
简介基于Spring Boot MySQL Vue的校园二手书交易管理系统毕业设计资源面向计算机相关专业学生与初学Spring Boot开发者用于完成课程设计、毕业设计或快速搭建校园二手交易场景。系统涵盖首页、个人中心、用户管理、卖家用户管理、图书分类管理、二手图书管理、求购图书管理、求购回复管理、留言反馈、系统管理、订单管理等功能支持管理员、卖家、普通用户三类角色分工协作管理员可管理全部模块并维护个人资料用户可浏览二手书信息与修改个人账号。资源包大小约25.4MB主要文件类型为项目源码、毕业论文文档和答辩PPT便于直接导入IDE运行、阅读设计思路和准备答辩展示。目前已有441人学习适合需要完整可运行项目、论文框架及PPT模板的毕业设计参考者。1. 一套二手书交易系统最容易被答辩问倒的三个点做校园二手书交易管理系统这类 Spring Boot 毕业设计代码跑通不难难在答辩时被问“为什么这样设计”。这个项目拿的是同一套 Spring Boot MySQL Vue源码包里写作 veu实际就是 Vue的前后端分离结构功能覆盖用户、卖家用户、图书分类、二手图书、求购回复、留言反馈和订单管理。拆过好几套类似源码后我的判断是这套东西真正有信息量的地方不在 CRUD 本身而在三类角色权限怎么落库、订单状态怎么流转、以及答辩时怎么证明查询性能不是拍脑袋写的。下面按数据模型、后端实现、前端对接、答辩验证四层来拆。2. 数据模型与角色权限三张表设计决定了整个系统的复杂度拿到源码先别急着跑打开 SQL 文件看表结构。这套系统的核心不是“二手图书”表而是用户与卖家之间的关系设计。多数毕设源码会把用户和卖家拆成两张表再通过 user_id 关联这个项目也是这么做的但有个细节值得注意管理员没有单独建表而是复用 user 表并增加 role 字段做区分。2.1 角色落库的两种方式以及为什么选 role 字段常见做法是在 user 表里加role字段0 表示普通用户1 表示卖家2 表示管理员。这样登录接口只需要查一张表JWT 里塞一个 role 标识就能做权限控制。缺点也明显如果后续要给“卖家”增加审核状态、店铺信息、信用分字段全堆在 user 表里会越来越臃肿。另一种方式是卖家单独建表即 seller 表通过 user_id 与 user 表一对一关联。这个系统选的就是这种方式因为“卖家用户管理”是后台的一个独立菜单管理员需要审核卖家资质。单独建表后审核状态verify_status可以独立存在不需要动 user 表结构。CREATE TABLE seller ( id int NOT NULL AUTO_INCREMENT, user_id int NOT NULL COMMENT 关联user表的id, school varchar(64) DEFAULT NULL COMMENT 学校信息, contact varchar(32) DEFAULT NULL COMMENT 联系方式, verify_status tinyint DEFAULT 0 COMMENT 0待审核 1已通过 2已驳回, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里给user_id加了普通索引因为查询卖家详情、后台列表过滤都需要按 user_id 关联 user 表。另一个要留意的点是verify_status如果后台经常按这个字段过滤应该考虑联合索引(verify_status, create_time)否则数据量大时列表页会做全表扫描。很多毕设源码不会刻意建这个索引答辩时主动提出来反而是加分项。2.2 二手图书表的字段设计藏着两个易踩坑的点图书表是整个系统的信息中枢前端首页的图书列表、搜索、分类筛选、订单生成都围绕它。源码里的second_hand_book表核心字段如下CREATE TABLE second_hand_book ( id int NOT NULL AUTO_INCREMENT, title varchar(128) NOT NULL COMMENT 书名, author varchar(64) DEFAULT NULL, publisher varchar(128) DEFAULT NULL, isbn varchar(32) DEFAULT NULL, cover varchar(255) DEFAULT NULL COMMENT 封面图URL, original_price decimal(10,2) DEFAULT NULL COMMENT 原价, price decimal(10,2) NOT NULL COMMENT 售价, category_id int DEFAULT NULL COMMENT 图书分类ID, seller_id int DEFAULT NULL COMMENT seller表ID, status tinyint DEFAULT 0 COMMENT 0在售 1已售 2下架, description text, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个容易踩坑的点。第一status字段必须和订单状态联动图书“已售”不能只靠前端按钮切换而要在订单生成时通过事务同时更新图书状态。第二cover存的是 URL 而不是图片二进制图片文件要单独存储否则数据库体积会快速膨胀MySQL 的性能曲线会非常难看。如果本地运行建议把上传目录映射到项目外的绝对路径避免重启后文件丢失。分类表则简单很多只有id、name、sort_order三个字段。查询时通过图书表上的category_id关联前端导航栏加载的是分类列表点击后请求图书接口并携带categoryId参数。2.3 订单状态机从生成到完成的状态流转要能在答辩时画出来订单模块是这套系统里业务逻辑最重的地方。订单表至少要有订单号、图书 ID、买家 ID、卖家 ID、金额、状态、创建时间这几列。状态流转大概是用户点击购买生成订单待支付→ 模拟支付已支付→ 卖家确认发货已发货→ 用户确认收货已完成中间还要支持取消和退款。CREATE TABLE orders ( id int NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 唯一订单号, book_id int NOT NULL, buyer_id int NOT NULL COMMENT 买家user_id, seller_id int NOT NULL COMMENT 卖家user_id, amount decimal(10,2) NOT NULL, status tinyint DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已完成 4已取消, create_time datetime DEFAULT NULL, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;需要注意的是order_no必须唯一。有些源码直接用时间戳或UUID.randomUUID()存在重复或可读性差的问题。推荐格式是“yyyyMMddHHmmss 用户ID后四位 随机数”其中随机数用RandomStringUtils生成并给order_no加唯一索引兜底。创建订单时要用Transactional包裹插入订单记录、扣减图书库存或更新图书状态为已售必须同时成功否则会出现“订单生成了但图书还在卖”的脏数据。3. Spring Boot 后端实现从 Controller 到 SQL 的完整链路后端部分我直接挑三个最有代表性的接口来讲登录鉴权、图书分页查询、订单创建。这三个接口覆盖了参数校验、JWT 生成与拦截、MyBatis-Plus 条件构造、事务处理四个 Spring Boot 面试高频点。3.1 登录接口与 JWT 拦截器的搭法登录接口走/api/user/login接收用户名和密码校验通过后生成 token 返回前端。密码存储用 BCrypt 加密不要用 MD5。源码里通常会有JwtUtil工具类核心概念是token 里只放userId和role过期时间设为 24 小时拦截器里解析 token 后把用户信息放入ThreadLocal方便后续接口取当前登录人。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(token); if (token null || token.isEmpty()) { throw new BusinessException(未登录); } // 解析token过期或签名错误会抛异常 Claims claims JwtUtil.parseToken(token); UserContext.setUserId(claims.get(userId, Integer.class)); UserContext.setRole(claims.get(role, Integer.class)); return true; } }这段代码里UserContext是自定义的ThreadLocal工具类用完后必须在afterCompletion里清除否则线程池复用时会拿到上一个请求的用户信息属于典型的线上 bug。拦截器注册时注意要放行登录、注册、图书首页列表这几个接口不然前端还没拿到 token 就被拦住了。3.2 图书分页查询MyBatis-Plus 条件构造器的正确姿势图书列表接口要支持三个参数keyword书名模糊搜索、categoryId分类筛选、pageNum/pageSize分页。用 MyBatis-Plus 的LambdaQueryWrapper可以避免手写 XML。Override public IPageSecondHandBook searchBooks(String keyword, Integer categoryId, int pageNum, int pageSize) { LambdaQueryWrapperSecondHandBook wrapper new LambdaQueryWrapper(); wrapper.eq(SecondHandBook::getStatus, 0) // 只查在售图书 .eq(categoryId ! null, SecondHandBook::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), SecondHandBook::getTitle, keyword) .orderByDesc(SecondHandBook::getCreateTime); PageSecondHandBook page new Page(pageNum, pageSize); return secondHandBookMapper.selectPage(page, wrapper); }这里有几个细节值得展开。第一eq和like的第一个参数是布尔条件前端不传categoryId时自动跳过该条件比手动拼接 SQL 安全得多。第二orderByDesc按创建时间倒序保证新发布的图书排在前面这也对应了首页的展示逻辑。第三selectPage会自动执行一条 count 查询和一条数据查询返回的IPage对象里包含total和records字段前端分页组件依赖这两个字段。如果论文里写的是“使用 MyBatis-Plus 简化开发”答辩时大概率会被问“分页插件原理是什么”。答案要点是PaginationInnerInterceptor通过 MyBatis 的拦截器机制在 SQL 执行前动态拼接LIMIT语句并改写原 SQL 生成 count 查询。能答出“拦截器 SQL 改写”这两个词基本就过关了。3.3 创建订单的事务控制与库存扣减创建订单接口是检验事务理解的好题目。用户点击“立即购买”后后端要做三件事生成订单记录、把图书状态改为“已售”、给卖家发送站内信如果有通知模块。Transactional(rollbackFor Exception.class) public Order createOrder(Integer bookId, Integer buyerId) { // 1. 校验图书存在且状态为在售这里需要加行锁防止并发下单 SecondHandBook book secondHandBookMapper.selectByIdForUpdate(bookId); if (book null || book.getStatus() ! 0) { throw new BusinessException(图书不可购买); } // 2. 生成订单号 String orderNo generateOrderNo(buyerId); Order order new Order(); order.setOrderNo(orderNo); order.setBookId(bookId); order.setBuyerId(buyerId); order.setSellerId(book.getSellerId()); order.setAmount(book.getPrice()); order.setStatus(0); orderMapper.insert(order); // 3. 更新图书状态为已售 book.setStatus(1); secondHandBookMapper.updateById(book); return order; }selectByIdForUpdate是这串代码的关键。它会在数据库层面锁定这一行直到事务提交或回滚。不加锁的话两个人同时点击购买同一本书两个事务都读到“在售”状态就会产生一单双卖的问题。毕业设计里能主动加FOR UPDATE锁并且说清楚原因已经超过绝大部分同龄人了。注意Transactional只在通过 Spring 代理调用时生效同类内部方法调用this.createOrder(...)会导致事务失效。另一个坑是rollbackFor必须设置否则只拦截RuntimeException自定义异常如果继承自Exception就不会回滚。3.4 图片上传本地存储与静态资源映射图书封面上传走/api/upload接口接收MultipartFile保存到本地磁盘指定目录返回可访问的 URL。源码里常见的写法是上传到src/main/resources/static/upload/这样可以通过http://localhost:8080/upload/xxx.jpg直接访问。PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(文件不能为空); } String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String filename System.currentTimeMillis() _ UUID.randomUUID().toString().substring(0, 8) ext; String uploadDir System.getProperty(user.dir) /upload/; File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(uploadDir filename)); return Result.success(/upload/ filename); }存到项目根目录的upload/文件夹而不是 static 目录是因为重新打包部署时 static 会被覆盖。但要用 Spring Boot 把这个目录暴露为静态资源需要加一个配置类。否则即使文件存在浏览器也访问不到。4. Vue 前端对接与路由权限从环境搭建到接口联调前端部分源码里用的是 Vue开发环境需要 Node.js 16 以上。拿到源码后先npm install再把后端启动起来然后改前端配置里的代理地址指向本机http://localhost:8080。这个项目有专门的命令入口用npm run serve即可跑起来。4.1 环境准备和跨域处理前后端分离项目最常遇到的就是跨域问题。浏览器直接访问http://localhost:8081时请求后端http://localhost:8080会被浏览器的同源策略拦截。两种解法后端加CrossOrigin或全局跨域配置前端在vue.config.js里配置 devServer 代理把/api前缀的请求转发到 8080 端口。// vue.config.js module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }实际上changeOrigin设成true是为了让后端拿到的是代理服务器的 IP避免某些后端做来源校验时误判。生产环境部署时则要用 Nginx 做反向代理把前端打包后的 dist 目录和/api接口指到同一个域名下彻底规避跨域。4.2 路由守卫与角色控制的实现前端路由需要区分“无需登录页面”和“需要登录页面”。首页图书列表可以公开访问但“我的订单”“求购回复”“后台管理”必须登录。源码里的路由守卫写在router/index.js中router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else if (to.path /admin JSON.parse(localStorage.getItem(userInfo)).role ! 2) { next(/) } else { next() } })这段逻辑做了两层控制第一层校验是否登录第二层校验是否管理员。有个实际项目中容易忽略的问题userInfo如果存的字符串解析失败会直接抛异常所以正常做法是登录后把用户信息存到 Pinia或 Vuex里刷新时再从本地缓存恢复。源码如果用的是 Vuex就看store/modules/user.js里的state是否维护了userInfo注意在main.js里触发一次刷新操作避免刷新后角色判断失效。4.3 图书列表页与 axios 请求封装前端首页的图书列表用卡片布局展示封面、书名、售价右上角是分类筛选。这些 UI 组件在源码的views/book/BookList.vue里。接口请求统一封装在utils/request.js主要作用是自动附加 token、统一解析错误码、拦截 401 跳转登录。// utils/request.js import axios from axios import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[token] token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.msg || 请求失败)) } return res }, error { return Promise.reject(error) } ) export default request在页面中调用时用request.get(/book/list, { params: query })这样的写法。重点说两个容易出问题的参数一是params里的pageNum和pageSize要传给后端分页插件不然会查全量二是categoryId不选时不要传 0后端判空逻辑会把它当有效值查询结果变成空列表。所以前端切换分类时要先判断categoryId undefined才不携带该参数。5. 答辩演示的动线设计与 MySQL 慢查询排查技巧源码包里带了论文和答辩 PPT但演示环节最忌讳照着 PPT 念。我建议把答辩时间主要花在“演示一个完整交易链路”上用 10 分钟讲清楚“用户发布图书—买家求购—卖家回复—订单生成—后台审核”的流程然后在 2 分钟内用一个技术细节证明系统是自己的。5.1 演示前的数据准备启动项目后先造几组数据用管理员账号登录后台添加“教材”“文学”“计算机”三个分类再用卖家账号发布 2 本计算机教材和 1 本文艺类书最后用普通用户账号发布一条求购信息——比如“求购《算法导论》预算 40 元”。这样演示时前台首页、搜索、求购、回复、订单五个模块都能一气呵成地串联起来。订单生成的演示是核心。用户点击购买后要展示订单状态从“待支付”变为“已支付”再让卖家发货最后在用户端确认收货。如果时间充裕还可以展示一个异常场景卖家发布的书被买家买走后图书状态自动变为“已售”其他人搜到这本书点进去显示“已售出”。这个细节能证明事务和状态联动是真实做了的。5.2 用 EXPLAIN 验证查询性能答辩中常见的加分问题论文的“系统测试”章节通常只写功能测试性能测试往往一笔带过。答辩现场最容易被追问的就是“你的列表查询性能怎么样”。如果回答“数据量小不卡”印象分会低不少。正确的做法是主动打开 MySQL 的慢查询日志或直接跑 EXPLAINEXPLAIN SELECT * FROM second_hand_book WHERE status 0 AND category_id 2 AND title LIKE %Java% ORDER BY create_time DESC LIMIT 10;执行后看type字段和key字段。要求type至少是refkey显示idx_category或联合索引rows扫描行数不能太大。如果发现type为ALL说明没走索引。解决方法是在图书表上建联合索引ALTER TABLE second_hand_book ADD INDEX idx_status_category (status, category_id, create_time);这套索引的合理性在于status用等值条件过滤掉大部分已售和下架数据category_id缩小到单个分类create_time满足排序需求避免 MySQL 做filesort。答辩时说出“覆盖排序字段避免 filesort”这一句就比单纯说“我加了索引”高一个级别。5.3 两个常被问到的坑存储引擎与字符集MySQL 这块还有两个高频提问点。第一表引擎要统一用 InnoDB因为订单表和图书表之间有事务关联MyISAM 不支持行锁和事务并发下单时会出现不可重复读。源码中如果遇到 MyISAM 遗留表答辩前要改成 InnoDBALTER TABLE second_hand_book ENGINE InnoDB;第二字符集必须用utf8mb4不能用utf8。因为utf8在 MySQL 里最多存 3 个字节用户输入 emoji 表情或生僻字比如“”这类 CJK 扩展区汉字时会报Incorrect string value错误。把数据库和表的默认字符集设为utf8mb4同时连接串加上characterEncodingutf8即可绕开这个坑。本文还有配套的精品资源点击获取