资讯动态

Spring Boot+Vue3实战:二手图书交易系统全栈开发

发布时间:2026/9/18 18:06:25 来源:尧图企业网站定制
简介这是一份基于 Spring Boot 框架的二手图书交易系统 Java 毕业论文文档适合正在完成相关选题的计算机专业学生或需要参考毕业设计写作范式的开发者。文档完整呈现了从需求分析、系统设计到技术实现与结论评价的全过程划分管理员与用户两种角色围绕用户、图书信息、留言板、系统和订单五大模块展开采用 B/S 架构使用 Java 语言与 MySQL 数据库并引入 Spring Security 与 RESTful API 设计风格。内容还包含中英文摘要、目录、绪论、系统设计、技术架构、系统模块、系统实现、系统优点及结论等论文核心章节可作为论文写作结构、技术选型与系统设计的直观参考。压缩包内共 1 个文件为 docx 格式约 5.33MB内容排版完整、可直接查看已有 151 人学习适合需要快速理解 Spring Boot 二手图书交易系统整体方案的人群。1. 二手图书交易系统的边界先别急着写代码把“交易”两个字立住很多同学拿到“vue-springboot二手图书交易系统”这个题目第一反应是先把书的增删改查做出来。结果做完了才发现整个系统只有图书列表和后台管理买卖双方根本没有完整走完一笔交易。二手图书系统的核心是“交易”而不是“图书”难点不在书有多少字段而在于一本书从发布、被浏览、下单、确认收货中间的状态靠谁推进、靠什么规则拦住非法操作。用 Vue 3 Spring Boot Java 来搭这套系统是当前比较稳妥的全栈组合。Spring Boot 负责把交易规则写成稳定的 Java 接口Vue 负责把页面状态和接口数据组织起来前后端通过 REST API 完全分离。这个结构除了适合做毕业设计也适合把 Java 基础、Spring Boot 配置、拦截器、事务和异常处理这些面试和答辩都爱问的知识点落到真实代码里。下面按我平时搭这类交易系统的推进顺序来写先把后端数据模型和接口定稳再搭 Vue 前端的页面与路由接着处理鉴权和订单状态机最后补上种子数据和验收清单。每一步都有能直接抄走的代码和参数参数怎么改、失败时看哪里也会一起说明。2. Spring Boot 后端先定数据模型再谈接口实现写后端时我习惯先画表关系而不是先写 Controller。接口改起来容易表结构一旦确定改动成本会传导到 Service、Mapper 和前端页面。二手图书系统看起来只有书和订单但只要把“谁可以下架哪本书”“已上架的书能不能直接删”这几种边界情况列出来就会发现数据表和状态位才是真正的骨架而 Vue 和 Spring Boot 只是骨架上的表现层。动手写之前先把版本定下来。Spring Boot 3.x 生态干净、内置 Jackson 和 Validation 都好用但它要求 Java 17 起步如果本地环境还是 JDK 8选 Spring Boot 2.7 会更省事。遇到“springboot版本太高”导致的启动报错常见原因就是 JDK 版本不匹配先看一眼pom.xml里spring-boot-starter-parent的版本和java.version是否对应。2.1 用户、图书、订单三张表关系别设计成多对多表结构可以先用一个表格把骨架立住。毕业设计场景里不需要过度建模也不需要引入权限五表模型下面这三张表已经足够支撑“发布—交易—收货”的完整链路。表核心字段设计说明userid, username, password_hash, phone, role买家和卖家共用一张用户表role 字段区分 USER / ADMINsystem_bookid, seller_id, title, author, isbn, original_price, sell_price, condition_level, cover_url, statuscondition_level 存书本成色等级status 表示在售、下架、已售book_orderid, order_no, book_id, buyer_id, seller_id, amount, status, created_at, paid_at, completed_at价格冗余成订单快照避免书价被修改后影响历史订单这里有两点容易被答辩追问。第一为什么买家和卖家不分两张表二手交易里卖家只是 user 表中拥有图书记录的用户判断身份时通过system_book.seller_id和book_order.seller_id去关联即可。统一用户表让注册、登录、鉴权都只处理一套逻辑少一层 join后续想加支付宝支付回调也不用在用户维度上绕弯。第二图书和订单是一对多不要用多对多。一本书被下单后要从在售列表移除多对多意味着同一本书可以被多次下单订单状态机就直接崩溃了。数据库选型上毕业设计用 MySQL 8.x 是最常见的做法。字符集用 utf8mb4排序规则用 utf8mb4_general_ci否则中文检索和 emoji 会有小问题。主键推荐BIGINT AUTO_INCREMENT方便联调时肉眼识别答辩时解释起来也直观。如果你想让项目的“高级感”更强再用雪花 ID但要提前处理好前端 Long 精度丢失的问题这个坑在第 4 章展开。2.2 状态字段用枚举约束别在业务里写魔法数字图书的status字段如果直接用 0、1、2 散落在业务代码里一个月后再看就会分不清哪个数字代表“已售出”。正确做法是在 Java 里定义一个枚举数据库仍然存 int业务层只面向枚举编写判断逻辑。public enum BookStatus { ON_SALE(0, 在售), OFF_SHELF(1, 已下架), SOLD(2, 已售出); private final int code; private final String description; BookStatus(int code, String description) { this.code code; this.description description; } public static BookStatus fromCode(Integer code) { if (code null) { return null; } for (BookStatus status : values()) { if (status.code code) { return status; } } throw new IllegalArgumentException(未知的图书状态: code); } }如果用的是 Spring Data JPA有经验的开发人员不会直接给实体字段加Enumerated(EnumType.ORDINAL)因为枚举一旦插入新值索引位置就变了历史数据会全部错位。更稳妥的方式是实体中保存 Integer通过fromCode做转换。这个习惯同样适用于订单状态后面状态机会复用同一个模式。枚举的存在不是为了少写一个 if而是把状态迁移的合法性集中到一处。你在上架接口里设置状态为BookStatus.ON_SALE业务层写起来就是“这本书当前处于在售状态”而不是“status 等于 0”。这种写法能防止将来把 0 和 2 记反答辩时也可以直接说“用枚举管理状态避免魔法数字贯穿整个项目”这句话本身就是加分项。2.3 上架一本书的服务方法校验、初始状态、事务接口不要一上来就写满 CRUD挑一个核心场景“上架图书”讲清楚前端其他页面都照着这个模式补即可。下面的 Service 方法完成了三层事参数合法性校验、组装实体并设置初始状态、把记录写入数据库。Service RequiredArgsConstructor public class BookService { private final BookRepository bookRepository; Transactional(rollbackFor Exception.class) public Book publishBook(BookCreateRequest request, Long sellerId) { if (request.getSellPrice() null || request.getSellPrice().compareTo(BigDecimal.ZERO) 0) { throw new BizException(售价必须大于 0); } Book book new Book(); book.setSellerId(sellerId); book.setTitle(request.getTitle()); book.setAuthor(request.getAuthor()); book.setOriginalPrice(request.getOriginalPrice()); book.setSellPrice(request.getSellPrice()); book.setConditionLevel(request.getConditionLevel()); book.setStatus(BookStatus.ON_SALE); return bookRepository.save(book); } }Transactional(rollbackFor Exception.class)表示遇到任何运行时异常都回滚避免数据库出现半截数据。价格用BigDecimal而不是double浮点数在 Java 里做金额计算会产生 0.1 0.2 不等于 0.3 的问题这属于 Java 基础面试八股文里的高频考点。BizException是自定义业务异常配合RestControllerAdvice统一转成{ code: 400, message: 售价必须大于 0 }这种格式前端捕获后直接弹出 message 即可。Controller 层保持薄只做参数绑定和当前登录用户的注入RestController RequestMapping(/api/books) public class BookController { private final BookService bookService; PostMapping public ResultBookVO publish(RequestBody Valid BookCreateRequest request, RequestAttribute Long loginUserId) { Book book bookService.publishBook(request, loginUserId); return Result.success(BookVO.from(book)); } }RequestAttribute Long loginUserId是登录拦截器在处理请求时手动放入请求域的Controller 不需要自己解析 token。这样拆分的好处是安全逻辑只出现在拦截器里业务方法签名也能明确表达“当前是哪个用户在执行操作”。上架接口写完后可以先用 curl 验证curl -X POST http://localhost:8080/api/books \ -H Content-Type: application/json \ -H Authorization: Bearer eyJhbGciOiJIUzI1NiJ9 \ -d {title:深入理解Java虚拟机,author:周志明,sellPrice:35.00,conditionLevel:八成新}需要说明的是Authorization 头里的内容是登录接口动态返回的这里写的是一个占位示例。如果返回 401问题大概率出在拦截器没有放行登录接口或者loginUserId没有被正确注入排查顺序我会在第 4 章给出。3. Vue 前端vue-router、Pinia 与商品列表页的搭法后端接口稳定以后再回头看 Vue 前端就会轻松很多。前端要用到的核心工具只有四个Vue 3、Vue Router、Pinia 和 Axios。其中 Vue 负责页面组件Vue Router 负责 URL 与应用页面的映射Pinia 负责跨组件共享的登录态和购物车Axios 负责调用 Spring Boot 接口。如果你对 Vue 的生态还不够熟悉可能会在“vue-router 和 Pinia 哪个该装哪个不该装”上纠结。这里可以先把结论记住路由和状态管理是两个维度的东西路由管“页面地址”状态管理管“数据共享”二者不冲突。下面从初始化开始一张一张把页面搭起来。3.1 用 Vite 初始化项目并装齐依赖直接用 Vite 脚手架创建项目比手动配置 Webpack 省掉大量时间。Node 版本建议 18 以上否则 Vite 5 可能不兼容。npm create vitelatest book-market -- --template vue cd book-market npm install npm install vue-router4 pinia axios如果npm install因为网络原因卡住最常见做法是临时切换 npm 镜像源再装npm config get registry npm config set registry https://registry.npmmirror.com安装完成后项目结构里会出现src/main.js。这里有两个注意点Vite 的项目名会自动作为 npm 包名如果包名带了中文或空格需要手动改package.json里的 name另外--template vue创建的是 Vue 3 组合式 API 版本不要再用createApp外面的旧写法。接下来配置路由。二手图书系统的页面结构不复杂但有三种典型路径书籍列表、书籍详情、订单列表。详情页需要接收id参数订单页需要登录后才能访问所以在路由 meta 里标记requiresAuth。import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: BookList, component: () import(../views/BookList.vue) }, { path: /books/:id, name: BookDetail, component: () import(../views/BookDetail.vue) }, { path: /orders, name: MyOrders, component: () import(../views/MyOrders.vue), meta: { requiresAuth: true } }, { path: /login, name: Login, component: () import(../views/Login.vue) } ] const router createRouter({ history: createWebHistory(), routes }) export default routercreateWebHistory是 HTML5 History 模式URL 里没有#号地址看起来更干净。但要注意将这个前端部署到 Nginx 后必须把所有路径都重写到index.html否则刷新/books/3会 404。这在答辩演示时经常被问到可以先准备好一段 Nginx 配置location / { try_files $uri $uri/ /index.html; }路由里的懒加载写法() import(...)让 Vue 在访问对应路径时才加载组件首屏速度会好一些。这个优化在页面不多的时候效果不明显但它在面试时可以作为一个小的前端性能点来提。3.2 Pinia 还是 Vuex二手书这个场景选 PiniaVue 3 项目里Pinia 已经取代 Vuex 成为官方推荐的状态管理方案。两者都管理全局数据但 Pinia 的 API 更贴合组合式函数写法TypeScript 支持也更好。对一个二手图书交易系统来说真正需要全局共享的状态只有两个登录 token 和当前用户信息。书籍列表数据通常是页面级状态放在组件内部反而更清晰。用 Pinia 写一个用户 store登录成功后就由它保存 tokenimport { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || null) }), actions: { setLogin(token, userInfo) { this.token token this.userInfo userInfo localStorage.setItem(token, token) localStorage.setItem(userInfo, JSON.stringify(userInfo)) }, logout() { this.token this.userInfo null localStorage.removeItem(token) localStorage.removeItem(userInfo) } } })这段代码把 token 同时放进了 Pinia 和 localStorage。每次刷新页面Pinia 里的数据会重置再从 localStorage 恢复这个模式叫“本地持久化”。生产环境下更安全的做法是把 token 放到 HttpOnly Cookie 里JavaScript 读不到可以防 XSS 窃取。但毕业设计里 localStorage 更简单演示登录态在刷新后还在也更容易讲清流程所以我一般在课程项目里优先 localStorage同时会在答辩时主动说出上面这个安全取舍。订单模块是否需要购物车取决于你的业务设定。二手图书和电商平台不同同一本书只有一本不太需要购物车批量结算如果老师要求必须做购物车就可以把车中的条目存进 Pinia结算时一次性传给 Spring Boot 创建订单。这个场景用 Pinia 的另一个优势是store 里的 state 可以被 Vue DevTools 实时跟踪调试时能清楚看到 addToCart 是否生效。3.3 商品列表页分页、搜索防抖和 loading 状态商品列表页是整套系统的门面也是前后端联调时第一个会接触到的模块。先在BookList.vue里用 ref 定义查询条件再从 Spring Boot 的分页接口拉数据script setup import { ref, onMounted } from vue import axios from axios const books ref([]) const keyword ref() const currentPage ref(1) const pageSize ref(9) const total ref(0) const loading ref(false) async function loadBooks() { loading.value true const response await axios.get(/api/books, { params: { keyword: keyword.value, page: currentPage.value, size: pageSize.value } }) const pageData response.data.data books.value pageData.records total.value pageData.total loading.value false } function changePage(page) { currentPage.value page loadBooks() } onMounted(loadBooks) /script代码里的response.data.data两层结构来自后端统一返回的Result包装类第一层data是 HTTP body第二层才是真正的业务分页对象。如果抽成公共 axios 实例可以加一个响应拦截器把两层结构压平但那样要写额外代码这里先用最直观的写法。搜索框的处理有一个高频坑每次敲一个字就发一次请求会造成请求轰炸。常见做法是加一个 400ms 的防抖只有用户停手后才真正调用loadBooksimport { watch } from vue let timer null watch(keyword, () { clearTimeout(timer) timer setTimeout(() { currentPage.value 1 loadBooks() }, 400) })loading 状态不能省略。列表接口慢时没有 loading 会让用户以为页面死了连续点击分页还会产生重复请求。这里简单的处理是在请求前把loading.value设置为 true结束后再置为 false模板里的按钮根据 loading 状态禁用即可。如果要更进一步可以在请求前后用AbortController取消上一次请求但对论文演示来说防抖已经足够应付大多数场景。4. 联调排错与订单状态机答辩时最容易追问的三块前后端各自跑通后真正的挑战是联调。二手图书系统里最容易出问题且最容易被答辩评委追问的是登录鉴权、订单状态流转和数据精度。这一章把这三点拆开来解决每一块都给出可复制的配置与代码以及调试时的观察方法。4.1 JWT 登录与 Spring Boot 拦截器登录态到底怎么传登录流程不复杂前端把 username 和 password 发给/api/auth/loginSpring Boot 校验密码后生成 JWT 返回前端把 token 存进 Pinia。后续每个需要身份的接口前端在 axios 请求头里带上Authorization: Bearer token后端拦截器解析 token再把解析出来的登录用户 id 放到request属性里。定义一个拦截器只放行白名单和静态资源Component RequiredArgsConstructor public class JwtInterceptor implements HandlerInterceptor { private final JwtService jwtService; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { throw new BizException(未登录或登录已过期); } String token authHeader.substring(7); Long userId jwtService.parseUserId(token); request.setAttribute(loginUserId, userId); return true; } }authHeader.substring(7)是为了去掉 “Bearer ” 前缀只保留真正的 JWT 字符串。JwtService.parseUserId负责验证签名和有效期解析成功后返回保存在 token 里的 userId。把 userId 放进 request 属性后第 2 章的 Controller 里才能用RequestAttribute Long loginUserId取到。注册拦截器时需要注意不要把登录注册接口也拦下来Configuration RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { private final JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register); } }这里最容易犯的错是把白名单写错导致登录接口返回 401。排查办法是在拦截器preHandle里临时加一行日志打印请求 URI看它是否进了拦截逻辑。联调时如果前端报 401先去 Nginx 或 Spring Boot 日志里确认请求确实打到了/api/auth/login再确认目标接口没有被excludePathPatterns排除。4.2 订单状态机的设计与防重校验订单状态一旦流转起来就会产生很多 if 判断。比如待付款订单可以直接取消但已发货订单不能取消待收货订单只能确认收货不能再次发起退款。把这些规则写进枚举是约束状态迁移最可靠的方式public enum OrderStatus { PENDING_PAYMENT(0, 待付款), PENDING_SHIPMENT(1, 待发货), PENDING_RECEIPT(2, 待收货), COMPLETED(3, 已完成), CANCELLED(4, 已取消); private final int code; private final String description; OrderStatus(int code, String description) { this.code code; this.description description; } public boolean canTransitTo(OrderStatus target) { return switch (this) { case PENDING_PAYMENT - target PENDING_SHIPMENT || target CANCELLED; case PENDING_SHIPMENT - target PENDING_RECEIPT; case PENDING_RECEIPT - target COMPLETED; default - false; }; } }这段代码把“当前状态可以被流转到哪个状态”集中在一个方法里。新增一个状态时只需要改这里业务 Service 里不会再散落if (status 3 ...)这种魔数判断。Java 17 的 switch 表达式写法很简洁如果你用的 Spring Boot 2.7 配合 JDK 8则只能改成普通 switch 语句逻辑相同。确认收货的 Service 方法要同时校验归属关系和状态迁移合法性Transactional(rollbackFor Exception.class) public void confirmReceipt(Long orderId, Long buyerId) { BookOrder order orderRepository.findById(orderId) .orElseThrow(() - new BizException(订单不存在)); if (!order.getBuyerId().equals(buyerId)) { throw new BizException(只能确认自己的订单); } if (!order.getStatus().canTransitTo(OrderStatus.COMPLETED)) { throw new BizException(当前状态下不允许确认收货); } order.setStatus(OrderStatus.COMPLETED); order.setCompletedAt(LocalDateTime.now()); }除状态机外二手书还有一个并发问题两个买家同时下单同一本在售书。此时页面都是从“在售”列表里看到的这本书如果不加防护就可能生成两笔订单。最稳妥的办法是在下单时把这本书的status从在售原子地更新为已售更新行数为 0 则说明被别人抢先Modifying Query(update SystemBook b set b.status 2 where b.id :bookId and b.status 0) int markAsSold(Param(bookId) Long bookId);在 Service 中先调用markAsSold如果返回值是 0直接抛出“图书已被下单”异常。这个操作利用数据库行锁来防止超卖原理比用 Synchronized 更可靠也是 Spring Boot 面试里“乐观锁”这一类问题的活教材。4.3 跨域、时间格式与 Long 精度联调最常见的三个坑前端跑到 8080 端口后端跑到 8080 端口天然会产生跨域。跨域本质是浏览器同源策略的约束不是后端拒绝请求。开发环境处理跨域我一般不用后端加CrossOrigin而是用 Vite 的代理转发这样生产环境也不用改代码。在vite.config.js中配置export default { server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样前端请求/api/books时Vite 开发服务器会代理到http://localhost:8080/api/books前端代码里不需要写全路径。如果后端生产环境也要放开跨域再在WebConfig里加CorsRegistry配置两者不要重复启用。第二个常见坑是时间格式。Spring Boot 默认序列化LocalDateTime时输出的是数组比如[2025,6,1,10,30,0]Vue 拿到后不能直接展示。通用做法是在application.yml中配置 Jackson 的时间格式spring: jackson: time-zone: GMT8 date-format: yyyy-MM-dd HH:mm:ss注意spring.jackson.date-format对LocalDateTime并不总是生效更稳妥的方式是在实体对象上统一加JsonFormatJsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime createdAt;还可以配置全局 Jackson 自定义序列化器把所有LocalDateTime统一格式化这里不再展开。前端展示时用 dayjs 做二次格式化能避免“后端格式化一次前端又格式化一次”带来的时区混乱。第三个坑是 Long 精度。Spring Boot 的默认雪花 ID 或自增 ID 一旦超过 JavaScript 的Number.MAX_SAFE_INTEGER前端拿到后精度丢失/books/12345678901234567890可能变成/books/12345678901234567000。最简单的方法是让后端将 Long 类型输出为字符串JsonSerialize(using ToStringSerializer.class) private Long id;这个注解要加在实体id字段上。如果嫌麻烦也可以写一个全局 Jackson 配置序列化所有 Long 字段时自动转 string。这个坑等到订单详情打不开时才排查会浪费很多时间不如一开始就加上。5. 种子数据、启动顺序和“能不能跑给你看”的验收路径系统能跑通只是第一步答辩时让老师三分钟内看到完整的业务闭环才是更重要的能力。靠手工在页面上一点点录数据演示节奏会很拖沓而且容易暴露字段填错的小问题。我的做法是用 Spring Boot 的CommandLineRunner在项目启动时自动生成演示数据每次刷新数据库都能回到一个可预测的初始状态。5.1 用 CommandLineRunner 生成演示账号和示例图书定义一个新的DataSeeder组件它会在 Spring Boot 启动后自动执行run方法。注意先判断数据是否已存在避免每次启动都重复插入。Component RequiredArgsConstructor public class DataSeeder implements CommandLineRunner { private final UserRepository userRepository; private final BookRepository bookRepository; private final PasswordEncoder passwordEncoder; Override public void run(String... args) throws Exception { if (userRepository.count() 0) { return; } User admin new User(); admin.setUsername(admin); admin.setPassword(passwordEncoder.encode(123456)); admin.setRole(ADMIN); admin.setPhone(13800000000); userRepository.save(admin); User demoUser new User(); demoUser.setUsername(demo); demoUser.setPassword(passwordEncoder.encode(123456)); demoUser.setRole(USER); userRepository.save(demoUser); for (int i 1; i 6; i) { Book book new Book(); book.setSellerId(demoUser.getId()); book.setTitle(Java 核心技术 第 i 卷); book.setAuthor(Cay S. Horstmann); book.setSellPrice(new BigDecimal(29.90)); book.setConditionLevel(八成新); book.setStatus(BookStatus.ON_SALE); bookRepository.save(book); } } }这段代码的好处是无论数据库被怎么改只要删掉库重新运行一次就能恢复出两个账号和六本在售书。演示时先打开首页你能看到列表里固定出现六本书再登录 demo 用户去买其中一本整个流程非常顺畅。用PasswordEncoder加密密码而不是明文存储也能体现出基本的安全意识。5.2 演示用的验收路径启动有两步先启动 Spring Boot 后端等看到 “Started Application” 日志后再启动 Vue 前端。如果前端 5173 端口提示被占用改vite.config.js里的 port 即可。演示时可以按下面的顺序走一遍每一段都对应一个系统闭环步骤操作预期结果1打开首页看到 6 本在售书分页和搜索可用2登录 admin / 123456进入管理页面能下架违规图书3注册 demo / 123456 后登录停留原页面导航栏出现“我的订单”4进入一本书详情点击立即购买订单生成状态为“待付款”5模拟支付回调卖家看到待发货订单6卖家发货买家用 demo 确认收货订单状态变为“已完成”图书显示“已售出”这个清单既能指导开发进度也能作为答辩时的演示脚本。每做完一步在 Spring Boot 控制台看一次Hibernate的 SQL 日志比在数据库工具里查表直观得多。最后一个很实用的细节在 README 的开头用三行说清启动方式不要写十几页配置说明。把下面这几条命令放进“快速启动”一节答辩时照着敲即可。mysql -uroot -p sql/schema.sql cd backend mvn spring-boot:run cd frontend npm install npm run dev本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价