基于Spring Boot与Vue的二手图书交易系统设计与实现
2026/9/18 18:38:53 网站建设 项目流程

简介:一份基于Spring Boot的二手图书交易系统Java毕业论文docx文档,适合计算机、软件工程等专业的学生在毕业设计阶段参考。论文以二手图书交易业务为场景,完整呈现了课题背景、研究意义、需求分析、系统设计、模块划分、系统实现与结论等章节,重点阐述了管理员与用户双角色的功能架构,以及用户、图书信息、留言板、系统和订单等核心模块的处理流程。文中还介绍了Spring Boot框架的高内聚低耦合设计思路、RESTful API接口风格和Spring Security安全机制,能帮助读者快速建立项目骨架并理解论文写作逻辑。资源为单个docx文件,大小5.33MB,含中英文摘要和完整目录,排版规范,可直接对照学习或作为论文模板。已有151人浏览学习,适合需要完成JavaWeb方向毕业设计、并希望获得系统开发与论文撰写双重参考的同学。

1. 从闲置书处理到订单闭环:二手图书交易系统的真实业务场景

每年毕业季和开学季,高校里积压的教材和课外书数量远超想象,二手书的流转效率却一直不高。这套基于 Spring Boot + Vue 的二手图书交易系统,表面看是一个标准的 Web 管理项目,实际拆开之后会发现它解决的是三个核心问题:图书信息如何被高效检索、用户和管理员两种角色如何分工、购物车到订单的交易链路如何在 Web 端完整走通。后端用 Spring Boot 提供 RESTful 接口,前端用 Vue 做页面交互,MySQL 负责业务数据持久化,三层职责清晰。对正在做类似课题的学生来说,值得关注的是它如何用最常规的技术栈完成一个可验收的闭环系统;对工程师而言,这套系统的表设计和接口分层也有可借鉴的边界划分思路。下面直接挑项目里最容易卡住的几个点拆开讲。

2. 基于 Spring Boot 的模块划分与 MySQL 数据表设计

2.1 两种角色和五个业务模块如何划分边界

系统的角色权限划分非常明确:前台用户负责浏览图书、查看公告、留言、操作购物车和下单;管理员负责用户管理、图书类型管理、图书信息管理、留言板管理和订单管理。这个边界直接决定了接口的设计方向,用户端接口以查询为主,写操作集中在购物车和订单两个域;管理员端则是完整的后台管理接口,包含增删改查以及状态流转操作。

从工程实现角度来看,我一般会按照模块、控制器、服务、Mapper 逐层拆解,而不是把逻辑堆在一个类里。比如图书信息这个模块,用户端只需要分页查询和详情查看,管理员端则需要完整的 CRUD 和库存修改能力,两者的接口粒度不同,但底层操作的是同一张 book_info 表。这种划分方式在后期扩展时优势明显,新增功能通常只在 Service 层增加方法,不需要改动表结构,也不会影响已有接口的返回格式。

2.2 用户、图书、留言板三张核心表的字段设计

用户表是整个系统权限控制的基础。除了账号、姓名、性别、邮箱、手机号码、头像这些展示字段之外,还必须有密码字段用于登录校验。密码不建议明文存储,常见做法是对密码做 BCrypt 加密,注册时加密入库,登录时用加密算法做匹配校验,即使数据库泄露也不会直接暴露用户密码。图书信息表的字段在这个系统里设计得更有业务感,包含图书名称、图书封面、图书类型、作者、出版社、发布日期、单限、库存、价格。这里的单限字段容易忽略,它代表单个用户最多可以购买的数量,在订单提交接口中需要结合购物车数量做二次校验,防止一个用户把热门教材的库存一次性清空。

表名核心字段业务作用
user账号、密码、姓名、手机号、头像登录鉴权、个人信息展示
book_info图书名称、类型、作者、库存、价格、单限图书检索、库存控制、价格展示
message_board用户名、留言内容、留言图片、回复内容用户与管理员之间的异步交互
cart用户id、图书id、图书名称、数量、单价购物车数据暂存与价格快照
order订单编号、用户id、商品信息、金额、状态交易记录、订单状态流转

表设计上有一个细节必须注意:关联字段的数据类型必须保持一致。user 表主键如果使用 BIGINT,那么 cart 表的 user_id 和 order 表的 user_id 也必须使用 BIGINT,否则 JOIN 查询时 MySQL 会对索引列做隐式类型转换,导致索引失效,数据量上来之后查询性能会明显下降。

2.3 购物车与订单表:关联查询与冗余字段取舍

购物车表的设计采用了冗余字段策略。cart 表中直接保存图书名称、封面图和单价,而不是通过 book_id 实时 JOIN book_info 表查询。这样设计的原因是图书信息可以被管理员修改,而用户加入购物车时看到的价格是当时的价格,下单时应该以加入购物车时的价格为准。如果不做冗余,管理员一改价格,用户的购物车金额就跟着变,这在交易场景中是不可接受的。

CREATE TABLE cart ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT '用户ID,关联 user 表', book_id BIGINT NOT NULL COMMENT '图书ID,关联 book_info 表', book_name VARCHAR(100) NOT NULL COMMENT '图书名称,冗余字段', cover VARCHAR(255) DEFAULT NULL COMMENT '封面图 URL', price DECIMAL(10,2) NOT NULL COMMENT '加入购物车时的单价快照', quantity INT DEFAULT 1 COMMENT '购买数量', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间' );

这个建表语句中有几个字段值得说明。price 使用的是 DECIMAL 类型而不是 FLOAT 或 DOUBLE,原因是浮点数在二进制存储中无法精确表示小数金额,多次累计计算后会产生精度偏差,DECIMAL(10,2) 可以精确存储到分。quantity 字段设置了 DEFAULT 1,用户加入购物车时如果没传数量,数据库会自动填充 1,这个默认值可以避免代码里每次都要判断数量为空的情况。create_time 使用 DATETIME 类型并通过 DEFAULT CURRENT_TIMESTAMP 自动填充,省去了插入时手动设置时间的步骤。

提示:购物车表冗余图书名称和价格是刻意为之,不是设计缺陷。交易系统的原则是下单时锁定价格,订单生成后任何商品信息的变更都不应影响已生成的订单金额。

订单表的设计思路与购物车表一致,但增加了状态字段来管理订单生命周期。常见做法是定义 status 字段,0 表示待付款,1 表示已付款待发货,2 表示已发货,3 表示已完成,-1 表示已取消。每笔订单还要保存下单时的总金额和商品明细快照,保证售后对账时有据可查。

3. Spring Boot 后端分层实现:配置、实体与接口

3.1 项目初始化与 application.yml 配置要点

创建 Spring Boot 项目时,核心依赖选择 Spring Web、MySQL Driver 和 Lombok。如果使用 MyBatis-Plus 操作数据库,还需要引入 mybatis-plus-boot-starter。版本选择上,Spring Boot 2.7.x 目前仍是一个稳妥的选择,对 JDK 8 和 MySQL 5.7/8.0 都有较好的兼容性,不容易在环境配置上花费不必要的时间。

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/book_trade?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

这份配置里有两个参数非常关键。characterEncoding=utf8 必须存在,否则插入中文图书名称时会出现乱码,而且这个问题在本地开发时往往发现不了,部署到 Linux 服务器后才暴露。serverTimezone 建议显式指定为 Asia/Shanghai,MySQL 8.x 驱动默认使用 UTC 时区,如果不设置,存入数据库的时间和本地时间会相差 8 个小时,查出来的订单创建时间会出现偏差。

mybatis-plus 配置段中,map-underscore-to-camel-case 开启后,数据库的 book_name 字段会自动映射到实体类的 bookName 属性,不需要手写复杂的 ResultMap。log-impl 设置为 StdOutImpl 后,控制台会打印每一条执行的 SQL 语句,联调阶段排查问题时能看到 MyBatis 实际生成的 SQL,这对定位条件拼接错误非常有帮助。logic-delete-field 配置表示逻辑删除字段,开启后执行 delete 操作时 MyBatis-Plus 会自动把它转成 UPDATE 语句,把 deleted 字段置为 1,避免物理删除导致的历史数据丢失。

3.2 实体类映射与 MyBatis-Plus 基础使用

实体类直接对应数据库表,使用 Lombok 的 @Data 注解自动生成 getter 和 setter,用 @TableName 指定实体类对应的表名。

@Data @TableName("book_info") public class BookInfo { @TableId(type = IdType.AUTO) private Long id; private String bookName; private String bookCover; private String bookType; private String author; private String publisher; private Date publishDate; private Integer singleLimit; private Integer stock; private BigDecimal price; }

@TableId 注解标注主键字段,IdType.AUTO 表示使用数据库自增策略,插入数据时不需要手动设置 id。这里的 stock 和 singleLimit 使用 Integer 类型而非 String,是因为库存扣减和数量校验都需要做数值运算,如果使用字符串类型,每次操作都要做类型转换,代码可读性和运行效率都会打折扣。price 使用 BigDecimal,原因和前面数据库表设计中的解释一致,金额计算必须使用精确数值类型。

需要注意的是,实体类字段名与数据库列名通过驼峰映射规则自动对应,bookName 对应 book_name,publishDate 对应 publish_date。如果数据库列名不遵循下划线命名规范,比如使用了 bookname 这种命名,就需要在字段上加 @TableField 注解指定列名,否则查询结果会是 null。

3.3 Controller - Service - Mapper 三层接口实现

图书信息查询是系统中最核心的接口。用户端需要分页查询和条件搜索,管理员端需要完整的增删改查。在 Spring Boot 项目中,我习惯将通用返回结果统一封装,使用 Result 类包装 code、message、data 三个字段,所有接口的返回格式保持一致,前端封装 axios 时可以统一处理响应数据。

@RestController @RequestMapping("/api/book") public class BookController { @Resource private BookService bookService; @GetMapping("/list") public Result<IPage<BookInfo>> list( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String bookName, @RequestParam(required = false) String bookType) { Page<BookInfo> pageParam = new Page<>(page, size); LambdaQueryWrapper<BookInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(bookName), BookInfo::getBookName, bookName) .eq(StringUtils.hasText(bookType), BookInfo::getBookType, bookType) .orderByDesc(BookInfo::getId); return Result.success(bookService.page(pageParam, wrapper)); } }

这段代码的核心是 LambdaQueryWrapper 条件构造器。like 方法的第一个参数是 boolean 类型,当 bookName 不为空时才拼接 LIKE 条件;eq 方法同理,用于图书类型精确匹配。这样做的好处是查询条件可以根据前端传递的参数动态组合,不需要为每一种查询组合单独编写 SQL,也不需要手动拼接字符串,避免了 SQL 注入风险。page 和 size 参数通过 @RequestParam 接收,defaultValue 属性设置默认值,前端不传参数时也能返回第一页数据。

orderByDesc(BookInfo::getId) 表示按 id 倒序排列,新上架的图书排在前面。图书列表的排序策略需要根据业务场景决定,按 id 倒序是最简单的做法,但如果图书数量大,更好的方案是按上架时间排序,这需要在表中增加上架时间字段并建立索引。

3.4 订单提交流程中的事务控制

订单提交流程涉及多个表的写操作:生成订单记录、扣减图书库存、清空购物车数据。这三步操作必须放在同一个事务里,否则会出现库存已经扣减但订单没有生成的问题。比如用户提交订单后,生成订单记录成功,扣减库存时抛出了异常,如果没有事务控制,订单数据已经写入数据库,库存却没有扣减,最终会导致订单和实际库存不一致。

@Transactional(rollbackFor = Exception.class) public Long submitOrder(OrderSubmitDTO dto) { Order order = new Order(); order.setUserId(dto.getUserId()); order.setTotalAmount(dto.getTotalAmount()); order.setStatus(0); orderMapper.insert(order); BookInfo book = bookMapper.selectById(dto.getBookId()); if (book.getStock() < dto.getQuantity()) { throw new BizException("库存不足"); } book.setStock(book.getStock() - dto.getQuantity()); bookMapper.updateById(book); cartMapper.delete(new LambdaQueryWrapper<Cart>() .eq(Cart::getUserId, dto.getUserId())); return order.getId(); }

@Transactional 注解默认只在 RuntimeException 抛出时回滚,所以必须显式设置 rollbackFor = Exception.class,这样 BizException 这类业务异常抛出后事务才能正确回滚。库存扣减的逻辑放在订单生成之后,并且要先判断库存是否充足,否则会出现超卖问题。如果需要更严谨的并发控制,常见做法是在 SQL 层加入库存判断条件,执行 UPDATE book_info SET stock = stock - 1 WHERE id = ? AND stock > 0,这样即使多个请求同时到达,数据库的锁机制也能保证库存不会被扣减成负数。

提示:事务控制要避免在方法内部捕获异常后不抛出,这会导致事务管理器感知不到异常,无法触发回滚。正确做法是捕获异常后记录日志,然后继续抛出运行时异常。

4. Vue 前端实现与后端接口联调实战

4.1 Vue 项目结构与路由配置

前端项目使用 Vite 创建,目录结构按 views、components、router、api 四个维度组织。views 目录放页面级组件,components 放可复用组件,router 管理路由,api 集中定义所有向后端发起的请求。这样拆分之后,一个页面对应一个 views 文件,接口请求集中在 api 目录,维护时不需要在组件代码里搜索请求地址。

// router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', component: () => import('../views/Home.vue') }, { path: '/book/:id', component: () => import('../views/BookDetail.vue') }, { path: '/cart', component: () => import('../views/Cart.vue'), meta: { requiresAuth: true } }, { path: '/order', component: () => import('../views/Order.vue'), meta: { requiresAuth: true } }, { path: '/login', component: () => import('../views/Login.vue') } ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })

路由配置的两个关键点:一是使用动态 import 实现组件懒加载,路由匹配时才加载对应的组件文件,避免首屏一次性加载全部页面资源;二是通过 meta.requiresAuth 标记需要登录才能访问的页面,在全局前置守卫中检查 localStorage 中是否存在 token,未登录用户访问购物车或订单页面时会被重定向到登录页。token 存储在 localStorage 中,刷新浏览器后登录状态不会丢失,这是前端保持会话状态的常用方案。

4.2 axios 请求封装:统一处理返回体和错误码

前后端分离的项目,每个请求都需要携带 token,响应也需要做统一的错误处理和提示。直接在每个页面里写 axios 请求会造成大量重复代码,所以对 axios 做二次封装是联调前必须完成的工作。

// api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } ElMessage.error('网络请求异常') return Promise.reject(error) } )

baseURL 设置为 /api,所有请求路径都会自动加上这个前缀,与后端 Controller 的 @RequestMapping("/api/book") 对应。request 拦截器从 localStorage 取出 token 后追加到 Authorization 请求头,后端拦截器从请求头中解析 token 并校验用户身份。response 拦截器对后端返回的统一结构做判断,code 不等于 200 时弹出错误提示并 reject,业务代码中就不需要每个页面都写错误判断逻辑。401 状态码表示 token 过期或未登录,此时清除本地 token 并跳转到登录页,用户重新登录后可以继续操作。

4.3 图书列表页和购物车组件的实现

图书列表页是用户进入系统后看到的第一个核心页面,承担图书展示和搜索的入口功能。页面加载时调用后端分页接口查询第一页数据,用户输入搜索关键词后重新请求,通过 Element Plus 的表格组件和分页组件完成展示。

<template> <div class="book-list"> <el-input v-model="query.bookName" placeholder="搜索图书名称" @keyup.enter="loadBooks(1)" /> <el-table :data="books" stripe> <el-table-column prop="bookName" label="书名" /> <el-table-column prop="author" label="作者" /> <el-table-column prop="price" label="价格" /> <el-table-column label="操作"> <template #default="{ row }"> <el-button type="primary" @click="addToCart(row)">加入购物车</el-button> </template> </el-table-column> </el-table> <el-pagination :current-page="query.page" :page-size="query.size" :total="total" @current-change="loadBooks" /> </div> </template> <script setup> import { ref, reactive, onMounted } from 'vue' import { getBookList, addCart } from '@/api/book' import { ElMessage } from 'element-plus' const books = ref([]) const total = ref(0) const query = reactive({ page: 1, size: 10, bookName: '' }) async function loadBooks(page) { query.page = page const data = await getBookList(query) books.value = data.records total.value = data.total } function addToCart(row) { addCart({ bookId: row.id, quantity: 1 }).then(() => { ElMessage.success('已加入购物车') }) } onMounted(() => loadBooks(1)) </script>

这段代码使用了 Vue 3 的 script setup 语法,所有逻辑都收敛在组件内部。getBookList 和 addCart 是从 api 模块导入的请求函数,调用后返回 Promise,通过 async/await 获取结果。关键点是加入购物车时前端只传递 bookId 和 quantity,不传递价格,这样做是为了防止用户修改请求参数篡改价格,购物车中显示的价格由后端根据数据库中的图书信息自动填充。

el-pagination 分页组件的 current-change 事件会在页码变化时触发,调用 loadBooks 并传入新的页码。total 字段由后端分页接口返回,数据类型是 Long,在 Vue 模板中直接渲染即可。这里有一个常见的坑,如果后端返回的分页结构是 records 和 total 字段,而前端解析时误用了 content 或 totalElements,列表会渲染为空,联调时需要先确认后端分页对象的字段名。

4.4 跨域问题与登录状态的 token 传递

前端开发服务器端口是 5173,后端接口端口是 8080,浏览器会因为同源策略拦截跨域请求。解决开发环境的跨域问题,常见做法是在 Vite 配置文件中设置代理,而不是在后端开启 CORS。

// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

proxy 配置把 /api 开头的请求转发到 http://localhost:8080,changeOrigin: true 表示允许修改请求头中的 Origin 字段,后端看到的是同源请求,不需要额外处理 CORS。这样浏览器发出的请求全部走相对路径,开发环境和生产环境的差异只在代理层,前端代码中不需要判断环境来切换接口地址。部署到服务器后,常见做法是用 Nginx 做反向代理,将 /api 路径转发到后端服务,配置逻辑和 Vite 的 proxy 是一致的。

5. 系统测试与上线前的排错清单

5.1 功能测试用例的设计思路

系统测试在论文中采用黑盒和白盒结合的方式,实际开发中黑盒测试覆盖功能场景,白盒测试关注分支覆盖。测试用例的设计重点要放在异常路径上,很多系统上线后出问题,不是正常流程有 bug,而是对异常情况的处理不够充分。比如库存不足时下单、验证码错误时重复提交、token 过期后继续操作,这些场景在设计用例时都要覆盖到。

用例编号测试场景操作步骤预期结果
TC01用户注册输入已存在的账号提示账号已存在,不写入数据库
TC02用户登录输入错误密码提示用户名或密码错误
TC03图书搜索按书名关键词模糊搜索返回匹配的图书列表
TC04加入购物车未登录点击加入购物车跳转到登录页
TC05提交订单库存充足时下单订单生成成功,库存减少
TC06提交订单库存不足时下单提示库存不足,订单不生成

TC04 这类用例验证的是路由守卫是否生效,未登录用户访问需要鉴权的页面时,应被重定向到登录页,而不是看到空白页面或报错信息。TC06 验证的是事务控制是否正确,下单失败后,之前生成的订单记录应该被回滚,购物车数据也不能被清空。

5.2 高频故障的定位思路

联调和测试阶段有两个高频故障值得关注。第一个是中文乱码,自查顺序是:先确认数据库表的字符集是否为 utf8mb4,再检查连接 URL 中是否包含 characterEncoding=utf8,最后检查 HTML 页面和接口响应头中的 Content-Type 是否声明了 UTF-8 编码。第二个是接口返回 404 或 405,先检查 Controller 上 @RequestMapping 的路径和前端请求路径是否完全一致,再检查请求方法是否匹配,比如前端用了 POST 而 Controller 只映射了 @GetMapping。

排查接口问题时,先用 curl 直接请求后端接口,确认后端返回正常后再排查前端,这样能快速定位问题发生在哪一层。

curl -X GET "http://localhost:8080/api/book/list?page=1&size=10&bookName=Java" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx"

如果 curl 返回正常数据,说明后端接口没有问题,问题出在前端代理配置或请求参数上。如果 curl 返回 401,检查 token 的生成和校验逻辑,确认登录接口返回的 token 是否被前端正确存储和传递。如果返回 500,查看后端控制台堆栈日志,重点检查 SQL 语句是否报错、字段映射是否匹配、数据库连接是否可用。其他常见问题还包括库存表更新出现死锁时考虑调整事务隔离级别、图片上传路径不正确导致封面图无法显示、Spring Boot 内嵌 Tomcat 端口被占用导致服务启动失败等,按日志信息逐层排查即可。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询