☰
SpringBoot+Vue校园二手图书交易平台:从数据库设计到订单状态机实战解析
2026/10/7 21:33:17 网站建设 项目流程

简介:基于Springboot与Vue技术栈打造的校园二手图书交易平台,是一套完整可运行的毕业设计源码项目,面向计算机专业学生,可用于毕业设计、课程设计及期末大作业。系统前端采用Vue框架搭配Element UI等界面组件,后端由Springboot提供接口服务,配以MySQL数据库脚本,功能覆盖图书发布、浏览检索、在线交易等核心模块,管理后台与用户端设计齐全,界面美观、操作简洁。

压缩包共751个文件,大小约25.71MB,主要包含121个Java后端源码、46个Vue页面组件、155个JavaScript脚本、48个CSS样式文件,以及162个SVG图标和丰富的GIF/JPG/PNG图片素材,另有SQL数据库脚本与1-install.bat、2-run.bat、3-build.bat部署辅助脚本,配套代码注释与使用文档,便于快速导入运行。项目经过严格调试,简单部署即可使用,已有387人学习下载,是高分毕设与实战练手的优质参考。

1. 二手书的流转难题:这套 SpringBoot + Vue 校园二手图书交易平台到底能解决什么

每年毕业季,宿舍楼下总堆着一箱箱卖不掉的旧教材和新考研书;开学季,新生又在为动辄上百一本的教材肉疼。学校不缺存量书,缺的是一个能挂出来、能被搜到、能约线下交接的线上货架。这个基于 SpringBoot + Vue 的校园二手图书交易平台,本质就是给校园场景做一套垂直的 C2C 二手书交易系统:学生注册登录后发布图书、浏览检索、下单购买,管理员处理图书审核和用户管理。它不碰物流、不碰在线支付,把交易闭环停在“线下自提+平台确认完成”,这正好卡在毕设的工作量适中、业务逻辑完整、前后端分离技术栈全用上的位置。适合两类人:一类是正在选毕设题目、想用主流技术栈稳稳拿高分的学生;另一类是刚学完 SpringBoot 和 Vue、想完整走一遍全栈项目的初级开发。这套方案的落地核心不在代码量,而在状态机的严谨性和前后端联调的细节,下面按一条可复现的路径逐步拆开。

2. 功能拆解与数据库建模:从角色反推表结构,能省一半返工

做任何管理系统,先定角色再定模块,最后反推表结构,这是一条不容易返工的路线。校园二手图书平台的用户侧只有两类:普通学生用户和管理员。学生能注册登录、发布图书、浏览检索、下单购买、管理自己的订单和图书;管理员除了用户管理,还要做图书审核和分类维护。别急着写代码,第一步把这些动作翻译成数据表,表设计的质量直接决定后面 Service 层要写多少补丁。

2.1 功能模块与角色权限:先画清楚谁能干什么

我一般会把需求拆成三张图:角色权限矩阵、页面清单、状态流转图。这个项目里角色简单,权限矩阵不必引入 Spring Security 那套重武器,用拦截器校验登录态、用角色字段区分管理员即可。

页面清单大致是这样:游客可访问首页、图书列表、图书详情、登录注册页;登录用户可以额外访问发布图书、我的在售、我的下单、我的接单、个人信息;管理员多一个后台管理页,里面包含用户列表、图书审核、分类列表。这套划分贴在 Vue Router 上就是一层路由守卫,贴在 SpringBoot 上就是一个拦截器,成本很低。

表结构上需要注意,订单表不要叫 order,它是 MySQL 的保留字,建表时要么加反引号要么直接叫 orders。很多人在这一步图省事用了 order,后面每次写 SQL 都要带着反引号,属于给自己挖坑。分类表我建议单独建一张 category,虽然图书表里直接存分类名也能跑,但管理员要改个分类名时就得批量 UPDATE,犯不上。

2.2 核心表结构:用户表、图书表与订单表的字段怎么落

后端采用 SpringBoot + MyBatis-Plus + MySQL 8,数据库初始化脚本里至少要建四张表:用户表、分类表、图书表、订单表。图书表是核心,状态字段撑起整个业务流转。

CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `phone` varchar(20) DEFAULT NULL COMMENT '联系电话,用于线下交接', `role` tinyint NOT NULL DEFAULT '0' COMMENT '0-学生 1-管理员', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-正常 1-禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `category` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL COMMENT '分类名', `sort` int DEFAULT '0' COMMENT '排序权重', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书分类表'; CREATE TABLE `book` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '书名', `author` varchar(100) DEFAULT NULL COMMENT '作者', `publisher` varchar(100) DEFAULT NULL COMMENT '出版社', `isbn` varchar(20) DEFAULT NULL COMMENT 'ISBN', `category_id` bigint DEFAULT NULL COMMENT '分类ID', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价', `price` decimal(10,2) NOT NULL COMMENT '售价', `quality` tinyint NOT NULL DEFAULT '0' COMMENT '成色:0-全新 1-九成 2-七成 3-有笔记', `book_desc` text COMMENT '图书描述', `cover_url` varchar(255) DEFAULT NULL COMMENT '封面图URL', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-在售 1-交易中 2-已售出 3-已下架', `seller_id` bigint NOT NULL COMMENT '卖家ID', `view_count` int DEFAULT '0' COMMENT '浏览次数', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_seller_id` (`seller_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表'; CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号,雪花算法生成', `book_id` bigint NOT NULL COMMENT '图书ID', `buyer_id` bigint NOT NULL COMMENT '买家ID', `seller_id` bigint NOT NULL COMMENT '卖家ID', `deal_price` decimal(10,2) NOT NULL COMMENT '成交价', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-待付款 1-待线下交接 2-已完成 3-已取消', `contact_place` varchar(255) DEFAULT NULL COMMENT '约见的交接地点', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL COMMENT '模拟支付时间', `finish_time` datetime DEFAULT NULL COMMENT '完成时间', `cancel_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_buyer_id` (`buyer_id`), KEY `idx_seller_id` (`seller_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

这段 SQL 里有几个选型细节值得说明。价格字段用 decimal(10,2) 而不是 double,浮点类型在金额计算上会有精度误差,答辩时被问到“为什么用 decimal”是个明显的加分点。user 表的 username 加了唯一索引,是防止注册接口并发时插入重复账号的兜底手段。图书表故意没建物理外键,只保留 seller_id 的普通索引,理由有两个:物理外键在删除用户时会带来一系列级联约束,毕设阶段的业务代码用逻辑外键配合 Service 层校验更灵活,而且 MyBatis-Plus 对物理外键没有任何天然支持。

2.3 状态字段与数据字典:用枚举和常量接口管理图书与订单状态

很多人在 Service 层直接写 if (book.getStatus() == 0),这个写法本身没问题,但项目里一旦有三处以上用到同一个状态值,就很容易把 0 和 1 的含义记混。更好的做法是定义常量接口或者枚举,把状态的可读性做出来。

图书状态我用四个值:0 在售、1 交易中(有人下单但还没完成交接)、2 已售出、3 已下架。订单状态用四个值:0 待付款、1 待线下交接、2 已完成、3 已取消。订单状态和图书状态是两套独立的流转逻辑,绝不能混在一个字段里。设计时有一条铁律:图书状态“交易中”对应的订单必须是“待线下交接”,这一步一致性要靠下单接口里的同步更新来保证。

public interface BookStatus { int ON_SALE = 0; int TRADING = 1; int SOLD = 2; int OFF_SHELF = 3; } public interface OrderStatus { int UNPAID = 0; int WAIT_PICKUP = 1; int FINISHED = 2; int CANCELED = 3; }

把魔法值提成常量后,代码里不会再出现裸奔的 0 和 1,而且前端下拉框展示、后端状态过滤、管理员后台统计都引用同一份定义。数据库增删改查写起来也更清晰,比如管理员后台要筛选“交易中的图书”,SQL 条件就是 book.status = 1 而不是靠记忆写 0。如果你时间充裕,还可以把这套常量同步维护到前端的 constant.js 里,前后端各留一份,并在接口返回时直接带回 statusText 文本,前端列表页就不需要自己再翻译一遍了。

3. SpringBoot 后端实现:从 JWT 登录到订单状态机

后端部分最核心的三个链路是登录鉴权、图书发布、订单流转。项目骨架推荐直接用 Spring Initializr 生成,依赖只加 Web、MySQL 驱动、MyBatis-Plus、Lombok、JWT 工具,不去引入 Spring Security。原因很现实:Security 的过滤器链配置在小型项目中占比太重,调试成本高,答辩时也不好三言两语讲清楚;而 JWT + 拦截器的方式每个接口的鉴权逻辑都是显式的,讲解时更有抓手。

3.1 项目骨架与依赖:一个干净的 SpringBoot 2.7 工程

SpringBoot 版本我建议锁在 2.7.x,不要一上来就选 3.x。SpringBoot 3 的 javax 包名全部迁移到了 jakarta,网上大量现成代码和教程还在用 javax.servlet,复制过来直接编译报错。2.7 是 SpringBoot 2 的最后一个大版本,稳定性和资料丰富度都最好,作为毕设足够。

pom.xml 里加上 MyBatis-Plus 和 JWT 依赖,注意 MyBatis-Plus 要选择适配 SpringBoot2 的版本:

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>

application.yml 里配置数据源和 MyBatis-Plus 的驼峰映射,这里有个高频坑:MySQL 8 之前的驱动参数和 8.0 之后不一样,url 上必须拼接 allowPublicKeyRetrieval=true,否则首次连接会报 Public Key Retrieval is not allowed:

spring: datasource: url: jdbc:mysql://localhost:3306/campus_book?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&characterEncoding=utf8mb4 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

serverTimezone 必须配成 Asia/Shanghai,不配的话数据库连接的默认时区可能不是东八区,后面查询时间数据会差 8 小时。log-impl 配置成 StdOutImpl 会在控制台打印每一条 SQL,联调阶段开着,排查问题时能直观看到 MyBatis-Plus 生成的语句,部署前再关掉。

3.2 登录鉴权链路:JWT 签发与拦截器放行规则

登录接口接收用户名和密码,密码用 BCrypt 加密存储,登录成功后签发 JWT,前端后续请求在请求头里带 Authorization。JWT 工具类封装两个方法:generateToken 和 parseToken。

@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private Long expire; public String generateToken(User user) { return Jwts.builder() .setSubject(user.getUsername()) .claim("userId", user.getId()) .claim("role", user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }

拦截器里做两件事:从请求头取 token,解析成功就把 userId 和 role 塞进 request 的 attribute 里;解析失败或过期直接返回 401。需要放行的路径集中在登录注册和图书公开浏览这两个模块。

public class AuthInterceptor extends HandlerInterceptorAdapter { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } try { Claims claims = jwtUtil.parseToken(token.replace("Bearer ", "")); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); return false; } } }

这段代码里最容易忽略的是 OPTIONS 请求的放行。浏览器跨域场景下,前端发 POST 请求时会先发一个 OPTIONS 预检请求,这个预检请求不带 Authorization 头,如果不放行,前端看到的就是 401 而不是真正的业务错误。另一个细节是 token 前缀 Bearer,这是一种约定俗成的规范写法,前端封装 Axios 时也会加同样的前缀,两边的字符串拼接保持一致即可。

3.3 图书发布与订单流转:状态机至少要管住三个动作

图书发布接口相对简单,前端表单提交,后端插入 book 表,status 默认 0。真正考验设计的是下单动作,它要同时改订单表和图书表的两条记录,必须放在一个事务里。我用状态机的方式管理订单流转,核心方法是 createOrder、cancelOrder、completeOrder。

@Transactional(rollbackFor = Exception.class) public Long createOrder(Long bookId, Long buyerId, String contactPlace) { Book book = bookMapper.selectById(bookId); if (book == null || book.getStatus() != BookStatus.ON_SALE) { throw new BizException("图书不存在或已被买走"); } if (book.getSellerId().equals(buyerId)) { throw new BizException("不能购买自己发布的图书"); } Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setBookId(bookId); order.setBuyerId(buyerId); order.setSellerId(book.getSellerId()); order.setDealPrice(book.getPrice()); order.setStatus(OrderStatus.UNPAID); order.setContactPlace(contactPlace); orderMapper.insert(order); book.setStatus(BookStatus.TRADING); bookMapper.updateById(book); return order.getId(); }

下单接口的校验有三个边界:图书必须处于在售状态、不能买自己的书、库存由状态字段代替。图书状态从在售改成交易中是一个临界操作,并发场景下两个买家同时下单同一本书,理论上都应该校验通过,但实际会出现超卖。要彻底解决得用乐观锁或者 SELECT FOR UPDATE,我在 book 表里加了一个 version 字段做乐观锁,update 时带上 version 条件,但毕设阶段如果讲解压力不大,这一步可以作为加分项写在论文里,代码里先保证事务一致性即可。

订单支付接口我做成模拟支付,不接任何真实支付渠道,接口逻辑是把订单状态从待付款改成待线下交接。取消订单和完成订单同理,cancelOrder 只允许卖家或买家操作,completeOrder 则把订单状态改成已完成的同时,把对应图书状态改成已售出。

3.4 分页查询与模糊检索的参数设计

图书列表是前端访问量最大的接口,支持按书名模糊搜索、按分类筛选、按价格区间筛选、按成色筛选,还要分页。MyBatis-Plus 的 Page 对象配合 LambdaQueryWrapper 能省掉大量样板代码。

public Page<Book> pageBooks(int pageNum, int pageSize, String keyword, Long categoryId, Integer minPrice, Integer maxPrice, Integer quality) { Page<Book> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Book> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Book::getStatus, BookStatus.ON_SALE) .like(StringUtils.hasText(keyword), Book::getTitle, keyword) .eq(categoryId != null, Book::getCategoryId, categoryId) .ge(minPrice != null, Book::getPrice, minPrice) .le(maxPrice != null, Book::getPrice, maxPrice) .eq(quality != null, Book::getQuality, quality) .orderByDesc(Book::getCreateTime); return bookMapper.selectPage(page, wrapper); }

条件构造器的每个 eq 和 like 都带一个前置布尔参数,这个参数是 MyBatis-Plus 的特色:当前置条件为 false 时,该条件自动不拼接。前端不用在 Controller 里写一堆 if else 判断参数是否为空,直接传参即可,代码干净很多。返回的分页对象里包含总数、当前页数据、总页数,前端表格组件直接消费。

4. Vue 前端与接口联调:路由、Axios 与图片上传

前端用 Vue 3 + Vite + Element Plus 是当前主流组合,Vue 3 的组合式 API 让业务逻辑的组织比 Vue 2 清晰不少,而且 Element Plus 组件库对表单、表格、弹窗的覆盖很完整,能省下大量样式调试时间。整体页面结构分成三块:游客可见区、用户中心区、管理后台区。

4.1 页面结构与路由设计:登录后动态挂载用户中心

路由表用静态路由加动态路由的组合。静态路由包含首页、图书列表、图书详情、登录注册页;动态路由在登录成功后根据角色字段追加用户中心、发布图书、订单管理、后台管理这些页面。之所以不在静态路由表里全写死,是为了让未登录用户直接访问 /order 时能被路由守卫拦下来。

const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/', component: Home, name: 'home' }, { path: '/books', component: BookList, name: 'bookList' }, { path: '/book/:id', component: BookDetail, name: 'bookDetail' }, { path: '/login', component: Login, name: 'login' } ] }); const userRoutes = [ { path: '/publish', component: PublishBook, name: 'publishBook', meta: { requiresAuth: true } }, { path: '/my-orders', component: MyOrders, name: 'myOrders', meta: { requiresAuth: true } }, { path: '/my-books', component: MyBooks, name: 'myBooks', meta: { requiresAuth: true } } ]; router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } });

路由模式这里有两个选择:createWebHashHistory 和 createWebHistory。前者 URL 里带 #,后者是 history 模式,URL 干净。但 history 模式有个大坑,后端不做路由回退时,刷新页面会 404。为了避免部署时的额外配置,我的建议是毕设阶段直接用 hash 模式,省下的时间去调业务逻辑更划算;如果坚持用 history 模式,后端必须配一个转发到 index.html 的接口,这个坑放到下一章细说。

4.2 Axios 封装与 Token 携带:拦截器里完成优雅登录

Axios 实例的封装是前端联动后端的第一步。统一配置 baseURL、请求超时时间,请求拦截器里把 token 塞进请求头,响应拦截器里统一处理 401 和业务错误码。每次接口调用都手动写一遍 token 处理,既容易漏又不好维护。

const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.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'); localStorage.removeItem('userInfo'); router.push('/login'); } ElMessage.error('网络请求失败,请稍后重试'); return Promise.reject(error); } );

baseURL 配成 /api 而不是完整地址,是为了配合 Vite 的 proxy 配置。开发环境下所有请求都会先打到前端开发服务器的 /api 路径,再由 Vite 代理转发到后端的 8080 端口;生产环境下前端打包产物由 SpringBoot 托管,/api 直接命中后端,天然同源。这种设计让开发和生产环境的前端代码零改动。后端 Controller 的 RequestMapping 也需要统一加 /api 前缀,前后端对路径的约定保持一致。

4.3 图片上传与跨域代理:本地与打包后的差异

图书封面上传用 Element Plus 的 el-upload 组件,接口指向后端的 /api/file/upload。后端接收 MultipartFile,把文件写到本地磁盘的上传目录,数据库里只存访问的相对路径。这里不建议把图片转成 base64 存数据库,文件越来越大时数据库会变成灾难。

<el-upload action="/api/file/upload" :headers="uploadHeaders" :on-success="handleUploadSuccess" :show-file-list="false" accept="image/jpeg,image/png,image/webp"> <el-button>上传封面</el-button> </el-upload>

el-upload 的 action 是上传地址,headers 必须带上 Authorization,否则上传请求会被后端的 JWT 拦截器拦下来。这个细节常被忽略,很多人前端调试时发现列表接口正常、上传接口 401,就是忘了传 token 头。后端 FileController 里保存文件时要注意两点:存储目录用配置项而不是硬编码路径;文件名用 UUID 重命名防止中文文件名乱码和路径穿越。

开发环境的跨域代理配置在 vite.config.js 里:

export default defineConfig({ plugins: [vue()], server: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } });

用了 proxy 之后,前端开发环境不会产生真正的跨域请求,浏览器的 Network 面板里请求地址是 localhost:3000/api/xxx,由 Vite 内部转发到 8080。这种情况下后端不需要额外配置 CORS;如果后端配了 CORS 又同时开 proxy,部分浏览器会出现 OPTIONS 预检请求被重复处理的怪问题,建议二选一。

4.4 Vue 环境配置与依赖安装的常见细节

Vue 项目初始化用 npm create vite 命令,选择 Vue 模板后进入目录执行 npm install 安装依赖。国内网络环境下建议先给 npm 配好镜像源,否则安装 Element Plus 和 Axios 时会卡在下载阶段。安装完依赖后有几个常见的启动问题:一是 Node 版本过低导致 Vite 启动报错,Vite 5 需要 Node 18 以上;二是 element-plus 按需导入配置没做全,组件显示异常;三是 .env.development 文件里没有配环境变量。

基础依赖安装命令:

npm install element-plus axios vue-router@4 pinia

按需导入 Element Plus 需要装 unplugin-auto-import 和 unplugin-vue-components 两个插件,并在 vite.config.js 里注册。嫌麻烦的话可以全量引入,import ElementPlus from 'element-plus'加上app.use(ElementPlus),毕设项目打包体积多几百 KB 无所谓,全量引入配置最稳。路由和状态管理库的版本要选对,Vue 3 对应的是 vue-router 4.x 和 pinia,装成 vue-router 3 会在启动时报各种奇怪错误。

5. 高分毕设避坑排查:数据库、打包与 SpringBoot 版本相关的 5 个坑

这一章说几个我和学生做项目过程中真实踩过、且网上提问频率最高的坑。每一条都按现象、原因、解决的顺序写,方便对应排查。

5.1 坑位一:MySQL 8 连接时报 Public Key Retrieval is not allowed

现象是启动 SpringBoot 项目后,第一次请求数据库相关接口直接 500,控制台报错Public Key Retrieval is not allowed。这个报错常见于 MySQL 8 的 caching_sha2_password 认证插件,客户端与服务器首次握手时需要用 RSA 公钥加密密码,默认情况下驱动不允许自动获取公钥。解决办法很简单,在数据库连接 URL 上加 allowPublicKeyRetrieval=true 参数。我见过有人为了解决这个报错,把 MySQL 的认证方式改回 mysql_native_password,这属于绕路,而且在新版本 MySQL 里越来越不推荐。顺带把 useSSL=false 也加上,本地开发环境没有 SSL 证书,再加 serverTimezone=Asia/Shanghai,这三个参数一次配全,后面少很多事。

5.2 坑位二:SpringBoot 版本太高导致 javax 包全部报红

现象是网上找的登录拦截器、文件上传代码复制到自己项目里,import javax.servlet 这一行直接提示找不到包。原因是 SpringBoot 3.x 把 Jakarta EE 规范里的 javax.* 包全部迁移到了 jakarta.*,Servlet API、注解、认证相关接口全是如此。如果你非要用最新版 SpringBoot 3,把所有的 javax.servlet 改成 jakarta.servlet,注意不只是 import 语句,有些内部调用的静态方法也要跟着换。我更建议直接选 SpringBoot 2.7.x,这个版本技术成熟、教程存量最大、MyBatis-Plus 和一些老依赖的兼容性最好,等毕设做完有余力再研究 3.x 的迁移也不迟。springboot 版本太高这个坑,每年答辩前都会有一批人栽在上面。

5.3 坑位三:Vue 打包放进 SpringBoot 后刷新页面 404

现象是前端本地开发一切正常,npm run build后把 dist 目录拷进 SpringBoot 的 resources/static 下,访问首页正常,但进入 /book/1 详情页后按 F5 刷新,浏览器直接白屏,控制台显示 404。原因是前端用了 history 模式路由,刷新时浏览器向 SpringBoot 请求 /book/1 这个路径,后端没有这个地址的资源,返回 404。三种解法任选一种:前端改成 hash 模式路由,URL 里带个 # 号,刷新时不会触发后端路由;或者后端加一个路由回退 Controller,把非 /api 开头的路径全部转发到 index.html;再或者用 Nginx 托管前端静态资源并配置 try_files。毕设项目我推荐第一种,改动最小,一行代码。

5.4 坑位四:日期字段查询出来比实际时间差 8 小时

现象是后端数据库里 create_time 存的是北京时间,但前端页面显示的时间慢 8 个小时。原因有两层:数据库连接 URL 里没配 serverTimezone,驱动用默认时区连接,或者后端 Jackson 序列化时用了 UTC 时区。解决方法是两端都锁定东八区,数据库 URL 里加 serverTimezone=Asia/Shanghai,SpringBoot 配置文件里加:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

这里要提醒,date-format 控制的是格式,time-zone 控制的是时区,两个都要配。只配格式不配时区,展示的仍然是 UTC 时间。排查这个问题的技巧是先把 MyBatis 的控制台 SQL 打印打开,看 SQL 里的时间参数是否正常,再判断是数据库层的问题还是序列化层的问题,不要一上来就改前端。

5.5 坑位五:文件上传路径在 Windows 能跑,部署到 Linux 就失败

现象是本地 Windows 开发上传图片一切正常,打包部署到 Linux 服务器后,上传报错,或者上传成功但图片访问不到。原因很直接:代码里把保存路径写成了D:/upload/这种硬编码,或者用字符串拼接了File.separator,换系统后路径不存在也创建不了目录。解决方法是把上传路径做成配置项,在 application.yml 里定义file.upload-dir,启动时检查目录是否存在,不存在就自动创建。

@Component public class FileStorageConfig implements ApplicationRunner { @Value("${file.upload-dir}") private String uploadDir; @Override public void run(ApplicationArguments args) { File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } } }

同时注意 Linux 下 SpringBoot 进程对目录要有写权限,部署时建议把上传目录放在 /home/ 或者 /data/ 这类非 root 目录下,不要直接丢在 /root 里,否则权限问题够折腾一阵子。图片访问映射也得配好,SpringBoot 需要把 /upload/** 路径映射到本地磁盘目录,不然前端拿到相对路径也加载不出图片。

6. 让方案能跑给老师看:全链路验收与演示数据设计

系统做完后,花半小时准备一套能撑住全场演示的数据和操作路径,远比临场随便点来得稳。我习惯用两个账号、五本图书、三笔订单把整个核心链路串起来。演示时先打开首页展示图书列表和搜索筛选,接着用买家账号登录,搜“高数”找到一本在售的书,点进详情下单,此时订单状态变成待付款;然后切换卖家账号,确认收到付款提示,把订单推到待线下交接;再回买家账号确认收货,订单完成,回到图书列表能看到这本书状态已经变成已售出。这套路径把用户注册、图书发布、搜索、下单、支付模拟、订单流转、图书状态同步全部覆盖,而且每一步都有界面变化可以展开讲。

验收时我习惯过一遍关键检查点:订单号是否为 20 位左右的不重复编号;同一个买家反复点击下单按钮是否只会生成一笔订单;卖家能不能下单自己的图书;被禁用用户是否能正常登录;图书列表页的分页参数是否在地址栏同步。这些细节是答辩时最容易被人钻空子的地方。我自己的教训是:遥控器测试永远发现不了问题,真拿两台设备同时操作一下,或者打开两个浏览器窗口对同一本书轮流下单,才能看到状态机写得到底对不对。

打包部署时记得后端先改数据库账号密码和上传路径,前端确认接口代理路径,然后npm run build把 dist 里的文件复制到后端 resources/static 目录,再mvn clean package打成一个 jar。运维只认识一个 jar 包,这本身就是这套架构最直观的优势。希望这篇拆解能帮你把每一步走实,少踩几个我已经替你踩过的坑。

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

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

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

立即咨询