又到了毕业设计的季节。如果你在网上反复搜过“springboot vue 二手物品交易 boot 代码”,大概率是选题选到了这个方向,或者正被导师一句“做一个系统吧”架上了梁山。二手交易平台确实是被选得最多的毕设方向之一,原因很现实:业务大家都懂,功能边界清晰,技术栈又正好踩在主流上。用 Spring Boot 写后端、Vue 写前端、MySQL 存数据,既能避开冷门技术带来的环境灾难,又能把软件工程里需求分析、系统设计、开发实现、测试验收这一整套流程完整走一遍。这篇文章是我带过好几个类似项目之后整理的经验,内容包括选题逻辑、数据库设计、核心功能代码怎么写、论文怎么组织、答辩怎么避坑,适合正在赶进度、或者想把这个项目真正跑明白的人。所谓“boot 代码”,说到底就是一份能构建、能运行、能演示的 Spring Boot 工程,但关键不在于你能搜到多少代码,而在于每一块功能你能不能讲清楚、改得动。
1. 项目定位与选题逻辑:为什么是二手交易平台 + Spring Boot
1.1 二手交易需求与毕设的匹配点
先聊选题。很多人看到“二手交易系统”会觉得太普通,没有亮点。但毕设和商业项目不一样,毕设考察的是你能否独立完成一个完整的信息系统。二手交易平台在业务复杂度上非常适中:有用户、商品、订单几个核心实体,有登录、发布、检索、下单几条基本链路,既不会简单到只有一张表增删改查,也不会复杂到牵扯支付、物流、分布式事务这些根本讲不透的东西。
更重要的是,这套业务和生活场景贴得很近。答辩的时候老师问“你为什么要做这个系统”,你可以非常自然地回答“校园里二手书籍、二手数码产品交易需求大,目前缺少一个专注该场景的平台”。这个说法既真实,又不需要编造什么高大上的行业痛点。系统的可扩展性也强,后续想加个评论、加个管理员统计报表、甚至对接微信支付,都有明确的位置可以插进去,这正好对应论文里的“系统展望与改进”。
1.2 技术组合的取舍:Spring Boot + Vue 到底好在哪
Spring Boot 在这个题目里的优势是“省心”。传统的 SSM 项目要写一堆 XML 配置,配置数据源、配置事务、配置扫描包,光折腾环境就能消耗一个礼拜。Spring Boot 用自动装配把大部分配置直接约定了,内置 Tomcat,写一个带@RestController的类就能提供接口,这对毕设节奏来说简直是救命。
Vue 这边则是“好写界面”。用 Vue 组件可以把导航栏、商品卡片、订单状态标签拆成独立组件,页面之间通过 Vue Router 跳转,数据通过 Axios 请求后端接口。前后端分离以后,前端开发不依赖后端联调,你可以在本地 mock 数据把页面全部写完,最后再对接真实接口。这种开发方式在论文里也特别好讲,画一张架构图,把“浏览器 → Vue 前端 → 后端接口 → 数据库”的链路交代清楚,工作量描述就非常醒目。
技术栈选型上,我的建议是后端 Spring Boot 2.7.x,配合 MyBatis-Plus 做数据访问;前端 Vue 2 + Element UI,或者 Vue 3 + Element Plus,按你自己熟悉的来。如果时间紧张,选 Vue 2 生态最稳,网上的资料也最多。数据库用 MySQL 5.7 或 8.0 都可以。这套组合在部署和演示时最不容易出幺蛾子。
1.3 系统功能范围:先圈定边界再谈实现
毕设最容易犯的错误就是功能堆得太多。我见过有人把秒杀、聊天、推荐算法全塞进二手交易系统,结果没一个模块做完整。正确做法是先圈定 MVP,也就是最小可用功能集。
- 用户模块:注册、登录、退出、修改个人信息
- 商品模块:发布商品、编辑商品、下架/重新上架、商品列表、商品详情
- 搜索与分类:按关键词搜索、按分类筛选、按价格区间过滤
- 收藏模块:收藏/取消收藏、我的收藏列表
- 交易模块:买家发起购买、卖家处理订单、买卖双方确认完成
- 个人中心:我发布的、我买到的、我卖出的
在此基础上,如果进度有余力,再考虑加管理员后台,做用户管理和商品审核。功能清单越早定死,后面的时间就越宽裕。论文里的“需求分析”章节也直接从这个功能清单展开,每一块画个用例图就可以。
2. 架构设计与数据库建模:先把地基打结实
2.1 前后端分离架构的关键约定
二手交易系统的架构属于典型的单体前后端分离:Vue 负责页面渲染和交互,Spring Boot 只负责提供 JSON 接口。项目结构上分成前端secondhand-front和后端secondhand-server两个目录,各自独立开发、独立启动。
请求链路是:浏览器 → Vue Router 匹配路由 → 页面组件 → Axios 发起 HTTP 请求 → Spring Boot Controller → Service → Mapper → MySQL,然后数据原路返回。为了让前端统一处理返回结果,后端接口要约定一个统一响应体,我一般习惯用三个字段:
public class Result<T> { private Integer code; // 200 成功,500 失败 private String msg; // 提示信息 private T data; // 业务数据 }所有 Controller 的方法都返回Result,前端 Axios 拦截响应后先判断code,再决定是提示错误还是渲染数据。跨域问题也要提前处理,开发时前端跑在 8080,后端跑在 8081,必须允许跨域。最简单的做法是在后端加一个全局 CORS 配置。
候选代码(经验设置):
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }前后端分离还有个小细节:接口前缀统一加/api,比如/api/user/login、/api/goods/list。这样后端写 Controller 的时候路径更清晰,将来部署到同一域名下,也可以通过 Nginx 的/api规则做代理转发。
2.2 数据库表设计:五张核心表就够了
二手交易系统的核心数据关系并不复杂,核心表建议控制在五到六张,千万不要一开始就把表拆得特别碎。我在项目里常用的表结构大概是这样的:
| 表名 | 作用 | 核心字段 |
|---|---|---|
| t_user | 用户表 | id, username, password, nickname, avatar, phone, create_time |
| t_goods | 商品表 | id, user_id, category_id, title, description, price, cover_image, status, create_time |
| t_favorite | 收藏表 | id, user_id, goods_id, create_time |
| t_order | 订单表 | id, order_no, goods_id, buyer_id, seller_id, price, status, create_time |
| t_category | 分类表 | id, name, sort |
商品表里的status字段很关键,我给你一个约定:0 代表上架中,1 代表已下架,2 代表已售出。买家搜索商品时,SQL 条件里必须加上status = 0,否则下架和已售出的商品就会暴露出来。这个字段是整个商品模块的状态开关,前后端都要对应好。
订单表的status我一般这样设计:0 代表已取消,1 代表买家已下单待卖家处理,2 代表卖家已同意,3 代表买家确认收货完成。这里不需要做得太复杂,不用引入“退货”“退款”“超时自动关闭”这些状态,因为那是商业系统才需要考虑的流程。毕设只要能把一条订单从创建走到完成讲清楚,就已经满足要求。
分类表可能有人觉得没必要,认为在商品表里直接存分类名称就行。但加了分类表以后,商品列表的分类筛选就变成按category_id关联查询,前端下拉框也从这张表动态加载,性能和可维护性都更好。数据库设计在论文里是要重点写的一张 E-R 图,多一张正式的关系表,图会好看很多。
2.3 设计决策背后的权衡:JWT、MyBatis-Plus 和图片存储
问到“为什么用 JWT 而不用 Session”,这是答辩大概率会碰到的问题。我的理解是:前后端分离环境下,Session 依赖服务端保存状态,如果将来有多个后端实例,Session 同步是个麻烦;JWT 把用户信息加密放到客户端,后端只负责验证签名,天然适合分布式场景。对毕设来说,JWT 的实现也比 Session 配置更容易解释,生成令牌、校验令牌、前端携带令牌,一条链路非常清晰。
数据访问层我推荐 MyBatis-Plus,核心原因是单表 CRUD 不用自己写 SQL。继承一个BaseMapper<T>,就有现成的selectById、selectList、insert、updateById。分页查询也有内置的Page对象。这对不熟悉 SQL 的同学非常友好,而且它的LambdaQueryWrapper做条件查询极大减少了字符串拼接出错的可能。
图片存储的问题更要提前想清楚。毕设阶段不要一上来就配置 OSS,本地磁盘存储完全够用。后端接收上传的MultipartFile,把文件写到项目根目录下的upload文件夹,然后把相对路径存到数据库,并配置静态资源映射让浏览器可以通过 URL 访问。这个方案零成本、无依赖、演示可靠,唯一要做的就是启动时确认 upload 目录存在。
3. 核心功能实现拆解:从登录到成交的完整链路
3.1 登录鉴权流程:JWT 令牌的生成与拦截
登录是每个系统的门面,实现方式直接决定了后续接口能否正常工作。整套流程分五步:
- 用户提交用户名和密码
- 后端查 t_user 表,用 BCrypt 校验密码
- 校验通过后,生成 JWT 令牌,把 userId 放进去
- 前端把 token 存到 localStorage
- 后续请求通过拦截器自动在请求头带上 token
密码加密必须用加密算法,不要明文存库。Spring Security 的BCryptPasswordEncoder是最省事的选择,即使不和 Spring Security 集成,单独拿来加密、校验也没问题。生成 JWT 的代码建议封成一个工具类,方便所有接口使用:
public class JwtUtil { // 注意真正使用时要放到配置里,不要直接写死在代码中 private static final String SECRET = "secondhand-app-secret"; public static String createToken(Integer userId, String username) { return Jwts.builder() .claim("userId", userId) .claim("username", username) .setExpiration(new Date(System.currentTimeMillis() + 7L * 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody(); } }前端需要配置一个 Axios 拦截器,让每次请求都自动携带 token:
// request.js import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:把 token 放进 header service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config })后端写一个拦截器,拦截除了登录、注册、商品列表以外的所有请求。如果请求头里没有 token 或者 token 解析失败,直接返回 401。这个拦截器不要做得太复杂,能判断放行和拦截即可。
进入商品发布、下单这些操作型接口时,从 token 里取出 userId,作为当前用户 ID。这里有一点要提醒:不要盲目相信前端传过来的 userId,所有敏感操作必须以 token 里的 userId 为准,否则别人改一下参数就能操作你的账号,这类问题在答辩时容易被老师抓住。
3.2 商品发布与图片上传:弄清这几个坑就能跑通
商品发布页面是前端工作量最大的部分。表单字段包括标题、分类、描述、价格、封面图、详情图。在 Element UI 里,可以用 Form 组件加上图片上传组件完成。提交时,把文本字段和图片链接一起组装成对象,调用/api/goods/add接口。
图片上传我建议做成独立接口,前端先把图片传上去拿到返回的 URL,再随商品信息一起提交。不要尝试把图片转成 Base64 直接存数据库,那样数据表会非常臃肿,接口响应也会很慢。
后端接收图片的核心逻辑如下:
@PostMapping("/upload") public Result<String> 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().replace("-", "") + ext; File dir = new File("upload"); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, fileName)); return Result.ok("/upload/" + fileName); }生成唯一文件名这步很关键。如果直接用用户上传的原始文件名,两个用户上传同名图片就会互相覆盖,中文文件名还有乱码问题。加上时间戳和 UUID 组合,可以基本保证唯一。文件大小也要限制,我一般控制在单张 5MB 以内。
文件保存路径要配套静态资源映射,否则前端拿到了/upload/xxx.jpg也访问不到。在 Spring Boot 里添加一个 WebMvc 配置:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 将 /upload/** 路径映射到本地磁盘目录 registry.addResourceHandler("/upload/**") .addResourceLocations("file:upload/"); } }注意file:upload/是相对路径,启动项目时当前目录就是项目根目录,所以图片会落在项目文件夹下的upload目录中。演示的时候不要移动这个目录,否则图片就找不到了。更稳妥的做法是用绝对路径写成file:D:/work/secondhand/upload/,但这样换电脑运行就不方便,你看自己情况选择。
3.3 商品列表与条件搜索:模糊查询 + 动态条件拼接
商品列表页要支持几个筛选项:关键词、分类、价格区间、最新/价格排序。这类“多条件组合查询”是最适合体现基本功的地方,不建议写死 SQL,更不建议用字符串拼 SQL。MyBatis-Plus 的LambdaQueryWrapper可以优雅解决:
public Page<Goods> searchGoods(String keyword, Integer categoryId, Double minPrice, Double maxPrice, int pageNum, int pageSize) { Page<Goods> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(keyword), Goods::getTitle, keyword) .eq(categoryId != null, Goods::getCategoryId, categoryId) .ge(minPrice != null, Goods::getPrice, minPrice) .le(maxPrice != null, Goods::getPrice, maxPrice) .eq(Goods::getStatus, 0) .orderByDesc(Goods::getCreateTime); return goodsMapper.selectPage(page, wrapper); }这里用到了条件构造器的一个特性:第一个参数是布尔值,只有为 true 时才拼接后面的条件。比如categoryId是 null,eq这行就会自动跳过,不会漏出category_id = null这种错误 SQL。这个写法在论文里可以重点讲一层:通过条件构造器实现动态 SQL 的拼接,既保证查询灵活,又从框架层面规避了 SQL 注入风险。
价格排序我单独说一句。orderByDesc(Goods::getPrice)是按价格降序,如果想做“最新优先”,就按create_time排序。前端列表的花费数量和排序方式,最好通过参数传递,不要把排序写死在 SQL 里。
搜索的返回结果建议包裹成分页对象,里面包含总记录数total、当前页数据records、当前页码current、总页数pages。前端用el-pagination组件直接对接,数据格式完全兼容。
3.4 订单状态机:从“我想要”到“成交”的闭环
交易模块是这个项目和普通 CRUD 系统拉开差距的地方。订单不是一个简单的增删改查,它内部有状态流转,需要认真设计。
我的简化交易流程是:买家在商品详情页点击“立即购买”,后端先校验商品是否存在、是否处于上架状态、是不是本人发布的商品,然后创建一条订单,订单状态为“待卖家处理”。卖家在“我卖出的”列表里看到这条订单,可以选择“同意”或“拒绝”。如果同意,订单进入“待完成”;买家确认收到商品后,点击“确认完成”,订单状态变为“已完成”。
对应的状态值:
| 状态值 | 含义 | 触发动作 |
|---|---|---|
| 1 | 待卖家处理 | 买家提交订单 |
| 2 | 卖家已同意 | 卖家同意交易 |
| 3 | 已完成 | 买家确认收货 |
| 0 | 已取消 | 卖家拒绝或任一环节取消 |
后端只需要写一个状态变更的接口,用参数传递目标状态:
@PostMapping("/order/updateStatus") public Result<Void> updateStatus(@RequestParam Integer orderId, @RequestParam Integer status, @RequestParam Integer userId) { Order order = orderMapper.selectById(orderId); // 判断操作者是不是订单买卖双方之一,防止越权操作 if (!order.getBuyerId().equals(userId) && !order.getSellerId().equals(userId)) { return Result.error("无权操作该订单"); } order.setStatus(status); orderMapper.updateById(order); return Result.ok(); }表面上看只是更新了个数字,但项目里真正要关注的是状态流转的合法性。比如订单已经“已完成”,就不能再变回“待处理”。这里我建议给可以转换的状态做一个校验:如果是卖家操作,就把当前状态从 1 改成 2 或 0;如果是买家操作,就把当前状态从 2 改成 3。虽然毕设不要求写特别复杂的状态机,但你要能在答辩时讲出“为什么要校验状态的流转顺序”这个问题。
商品和订单之间有个一致性问题:商品一旦被买走(订单进入待处理),商品的状态就不应该还是 0。所以买家下单时,后端要同步把商品status改成 1(下架),这样其他买家再搜就搜不到了。如果卖家拒绝交易,再把商品状态改回 0。这个联动关系,在答辩里是很好的业务逻辑亮点。
4. 论文写作、演示与答辩:代码写好只是第一步
4.1 论文结构怎么组织,截图怎么留
很多同学代码写完了,论文却拖到最后一刻。其实论文结构和项目开发是同步进行的。二手交易系统这种题目,论文的经典章节组织方式如下:
- 绪论:背景、意义、国内外现状、论文结构
- 关键技术介绍:Spring Boot、Vue、MyBatis-Plus、MySQL
- 系统分析:可行性分析、需求分析、用例图、功能需求和非功能需求
- 系统设计:总体架构图、模块设计、数据库 E-R 图、数据表设计
- 系统实现:逐模块放截图和核心代码讲解
- 系统测试:功能测试用例表、部分性能或兼容性测试
- 总结与展望
我觉得最容易被忽略的是第 6 章测试。老师非常看重系统是否经过验证,你不一定真的跑过完整的自动化测试,但一定要有一张“测试用例表”,列出功能点、操作步骤、预期结果、实际结果、是否通过。
从开发第一天就要养成截图留档的习惯。每完成一个模块,比如注册页、商品发布、订单列表,都截图保存。写论文的时候,系统实现章节就是把这些截图按模块顺序贴进去,然后在每一张图下面写一两段说明,讲讲这个页面调用了哪些接口、核心逻辑是什么、遇到了什么问题。这样论文系统实现章节至少能轻松写出一万字的初稿,完全不用临时补图。
4.2 演示环境准备与常见报错清单
答辩演示是翻车事故的高发区,最大的原因是环境不一致。我建议你在答辩前至少完整演示两遍:第一遍把项目从零启动到能登录,第二遍完整走一遍发商品、下单、成交的流程。这是对整个项目状态最有效的检验。
常见报错我整理成一张速查表:
| 报错现象 | 原因 | 处理方法 |
|---|---|---|
| 前端请求接口 404 | 后端未启动,或路径拼写不一致 | 先在后端浏览器直接访问接口地址确认 |
| 数据库连不上 Access denied | MySQL 用户名密码不对 | 检查 application.yml 里的账号密码 |
| 中文乱码 | 数据库或连接配置没有指定 UTF-8 | 建库时指定 utf8mb4,URL 加 characterEncoding=utf8 |
| 图片加载不出来 | 静态资源映射或路径错误 | 试访问 /upload/文件名,检查拦截器是否放行了该路径 |
| Node 启动报错 | node_modules 依赖缺失或版本不一致 | 删除 node_modules 重新 npm install |
| 端口被占用 | 本机 8080/8081 已被其他程序占用 | 改端口,或者杀掉占用进程 |
一个很容易忽略的问题是数据库时区。如果你用的是 MySQL 8.0,连不上会报时区错误,需要在 JDBC URL 里增加serverTimezone=Asia/Shanghai。此外,后端拦截器要记得把/upload/**这类静态资源路径放行,否则图片请求也会被拦截。
4.3 答辩高频问题快查表
答辩时老师一般不会让你现场写代码,但一定会通过提问来验证你对项目的理解。下面几个问题,几乎是必问的:
为什么选择前后端分离架构?回答要点:前后端职责清晰,前端关注页面交互,后端关注业务逻辑,开发时可并行;便于多端扩展,同一个后端接口可以服务 Web、小程序等;部署时通过 Nginx 转发 API 请求即可。
JWT 和 Session 有什么区别?回答要点:Session 存储在服务端,需要维持会话状态;JWT 是自包含令牌,服务端无状态,验证签名即可。在前后端分离场景下 JWT 更合适,扩展性更好。
你的项目有什么难点?这是最容易答空的问题。你要说出一到两个具体点,比如:订单状态流转和商品状态的联动,需要考虑并发场景下重复下单的问题;图片上传要处理文件名冲突和静态资源映射。说自己真实做过的东西,哪怕不复杂,也比说“本项目很完善”有说服力得多。
密码是怎么存储的?回答要点:使用 BCrypt 加盐哈希,不能明文存储,即使数据库泄露也无法直接得到原密码。
系统如何防止 SQL 注入?回答要点:使用 MyBatis-Plus 条件构造器,参数经过预编译处理,而不是字符串拼接 SQL。框架层面已经做了防护。
如果同一商品被两个买家同时下单怎么办?回答要点:可以在下单前再次检查商品状态并加上数据库乐观锁或悲观锁,毕设场景下可以先用 SQL 的update t_goods set status = 1 where id = ? and status = 0这种条件更新保证只有一个请求成功。这个问题能把你的思考深度展示出来。
最后分享一点实际体会
带过不少同学做这类项目,我最深的感觉是:二手交易平台这种毕设,技术上并不难,难的是“能不能把整个项目讲成一个完整自洽的故事”。故事的主线就是一条买家发起购买并最终成交的交易链路,所有模块都围绕这条主线展开,数据库表按这条链路去设计,论文的章节按这条链路去安排。只要主线不散,你的系统就不会被评价为“像拼接出来的”。
建议你完成后把代码整理到 GitHub 或 Gitee,README 写清楚运行步骤,这既是给老师的交付物,也是给自己的一份记录。论文里的“系统测试”尽量用真实测试数据,测试用例表格列清楚,最后把演示流程在答辩前完整跑通一遍,操作时慢一点,边操作边解释每一步做了什么。做到这些,这个题目拿一个不错的成绩基本就稳了。