最近我把一套基于 SpringBoot + Vue 的二手物品交易管理系统重新翻了出来,项目代号 bootpf,代码包名统一叫 com.bootpf。这套系统从用户注册、商品发布、浏览搜索、购物车、下订单,到后台的商品审核、用户管理和数据统计,基本把二手交易平台的主链路都覆盖了。如果你正打算用这套源码做毕业设计,或者想自己搭一个校园闲置交易网站,又或者刚学完 SpringBoot 和 Vue 想找个完整项目练手,这篇文章都可以直接抄作业。我尽量把设计思路、关键代码、部署细节和平时容易踩的坑都讲清楚,这样你拿到源码后不用每行代码去猜。
1. 项目全景:这套二手交易系统到底解决什么问题
1.1 业务模型拆解:谁在用、流程怎么走
二手物品交易系统的核心不是"发布一个商品"这么简单,背后的业务状态流转才是设计难点。前台用户分两类身份,普通人既是买家也是卖家。买家要能搜索商品、浏览详情、加购物车、下单;卖家要能发布商品、修改价格、下架商品、处理订单。后台管理员则负责商品审核、违规下架、用户封禁和订单监控。
我把整个系统拆成几个闭环看会更清楚。第一个闭环是商品发布到展示:用户填写标题、描述、分类、价格和图片,提交后商品状态默认是待审核,管理员审核通过后变成在售,前端列表才能搜到。第二个闭环是交易:买家下单后订单先生成待支付状态,模拟支付完成后进入待发货,卖家确认发货后进入待收货,买家确认收货后流程结束。第三个闭环是后台管理:管理员可以看到所有商品和订单,也能对异常状态做手工修正。bootpf 的后端模块就是按这三个闭环去拆的,而不是简单按表拆 CRUD。
这套设计对新手特别友好。每个状态字段都有明确的含义,状态转换可以通过简单的 if 判断实现,不需要引入复杂工作流引擎。如果后期想加"客服介入""举报处理"这类流程,再去考虑 Flowable 这类工作流组件也不迟。源码里所有状态我都推荐存 int 类型,注释写清楚,别为了省事用字符串,否则后面统计口径会乱。
1.2 技术选型不是拍脑袋:为什么是 SpringBoot + Vue + MyBatis + MySQL
很多项目一上来就追新,但二手交易系统这种业务,稳定和可控比版本新更值钱。
| 层次 | 选型 | 选择原因 |
|---|---|---|
| 后端框架 | SpringBoot | 内嵌 Tomcat,不用额外配置容器;自动装配省掉大量 XML;生态成熟,招人容易 |
| 持久层 | MyBatis | 复杂查询用 SQL 手写直观;不需要 Hibernate 的级联关系;对性能可控 |
| 前端 | Vue | 上手曲线平缓,组件化开发适合业务页面;生态里有大量后台管理模板 |
| 数据库 | MySQL | 部署简单、查询性能够用、网上问题答案多;小项目不用上 PostgreSQL 或云数据库 |
我见过不少团队把这类系统换成 Spring Cloud 微服务,业务量还没上来,先被注册中心、配置中心、链路追踪搞垮了。bootpf 这种单体架构是本阶段的最优解:一台服务器、一个 jar、一个前端静态目录就能跑,数据量在百万以下完全没压力。
用 MyBatis 也有一点要注意:它不会自动把数据库字段转成驼峰命名,要么在配置里开启 map-underscore-to-camel-case,要么写 resultMap。我习惯在 application.yml 里直接开启驼峰映射,这样实体类字段和数据库字段命名可以各用各的规范,少写很多 resultMap。
2. 数据库与后端:把业务变成表和接口
2.1 bootpf 工程目录与分层逻辑
拿到源码后先别急着跑,先把工程结构认清楚。bootpf 是典型的多模块 Maven 工程,大致长这样:
com.bootpf ├── bootpf-common // 通用模块:统一返回体、异常处理、工具类、过滤器 ├── bootpf-system // 核心业务:Service、Mapper、领域对象 ├── bootpf-admin // 后台管理接口:商品审核、用户管理、统计 ├── bootpf-portal // 前台接口:登录注册、商品查询、购物车、订单 └── bootpf-admin-ui // Vue 管理端源码(也可以单独放前端仓库)common 模块里最重要的是统一返回体 R 和全局异常处理器。你会发现所有 Controller 都返回 R,前端 Axios 拦截器也按 code 判断业务结果,而不是只看 HTTP 状态码。这样设计的好处是,业务异常比如"余额不足""商品已下架"可以用 code=50001 这类业务码返回,前端弹提示,后端自己也不用打印一脸堆栈。
system 模块里的 Service 层我建议按"用户域、商品域、交易域"划分,而不是按表写一个 Service。商品发布后要生成初始浏览数、写操作日志,这不是单纯 Save 能解决的。Mapper 尽量只做单表增删改查和简单关联查询,复杂的列表筛选可以单独建 Query 对象传参。
2.2 核心数据表:一张表说明状态机
数据库设计时我习惯先画状态图,再落表结构。商品表是核心中的核心,字段参考这样设计:
CREATE TABLE `product` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '发布人ID', `category_id` bigint DEFAULT NULL COMMENT '分类ID', `title` varchar(100) NOT NULL COMMENT '商品标题', `description` text COMMENT '商品描述', `price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '售价', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价/参考价', `cover` varchar(255) DEFAULT NULL COMMENT '封面图URL', `images` text COMMENT '图片URL,逗号分隔', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待审核 1在售 2已下架 3已售出', `view_count` int NOT NULL DEFAULT '0' COMMENT '浏览数', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_create` (`status`, `create_time`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='二手商品表';几个容易被忽略的点:金额字段必须用 decimal,不要用 float/double,否则 0.1 + 0.2 这种精度问题会在订单统计时暴露出来;图片用逗号分隔存在 text 字段,对入门项目来说查询简单,但注意不要用 LIKE 去查图片内容,属于无意义的全表扫描;浏览数这种计数可以单独放一张表或者用 Redis 累加,等数据量大再拆,当前阶段先别过度设计。
状态字段我用 tinyint 而不是 varchar,是因为状态判断在 SQL 里写status = 1比status = 'ON_SALE'更省空间,索引也友好。但一定要在代码枚举类里写清楚常量名,别写魔法数字。比如在 ProductStatus 枚举里定义 AUDITING(0)、ON_SALE(1),比散落在 Service 里的 if (p.getStatus() == 0) 好维护得多。
订单表同理,order_no 用业务号而不是自增 id 暴露给用户,防止别人通过订单号差值扒你的单量。order_no 的生成规则可以用时间戳加随机数,也可以直接用雪花算法,bootpf 源码里已经有工具类,直接调用就行。
2.3 MyBatis 持久层核心写法:动态 SQL 是精华
我见过不少人用 MyBatis 只会写死 SQL,结果列表页一加筛选条件就手足无措。二手商品列表的筛选维度很多:关键词、分类、价格区间、排序方式,这种情况必须靠动态 SQL。
<select id="selectProductPage" resultType="com.bootpf.domain.vo.ProductVO"> select p.id, p.title, p.price, p.cover, p.create_time, u.nickname as sellerName from product p left join user u on p.user_id = u.id <where> p.status = 1 <if test="keyword != null and keyword != ''"> and (p.title like concat('%', #{keyword}, '%') or p.description like concat('%', #{keyword}, '%')) </if> <if test="categoryId != null"> and p.category_id = #{categoryId} </if> <if test="minPrice != null"> and p.price >= #{minPrice} </if> <if test="maxPrice != null"> and p.price <= #{maxPrice} </if> </where> <choose> <when test="sort == 'price_asc'">order by p.price asc</when> <when test="sort == 'price_desc'">order by p.price desc</when> <otherwise>order by p.create_time desc</otherwise> </choose> </select>这里有两个关键点。一是模糊查询不要写成like '%#{keyword}%',MyBatis 里这种写法直接报错,要像我这样用concat('%', #{keyword}, '%')。二是排序字段如果是从前端传进来的,不要直接拼到 SQL 里,否则有被注入的风险。上面的 choose 写法把排序白名单化,前端传什么值都只能走固定几种排序,这是我从实际项目里学到的教训。
如果 Mapper 方法有多个参数,记得加 @Param 注解,否则 MyBatis 会按位置参数名报错。还有一个细节,更新语句尽量别用update product set title = #{title}, description = #{description}, price = #{price} where id = #{id}这种一揽子更新。如果要改的是状态字段,就写专门的 updateStatus,SQL 更短,行锁持有时间更短,并发性能更好。
2.4 分页插件与 MyBatis 缓存的正确用法
列表页分页是后台系统逃不掉的需求。bootpf 用的是 MyBatis 分页插件 PageHelper,用法其实很简单:
PageHelper.startPage(pageNum, pageSize); List<ProductVO> list = productMapper.selectProductPage(query); PageInfo<ProductVO> page = new PageInfo<>(list);配置在 yml 里加这一段:
pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: truereasonable: true的意思是页码越界时自动矫正,比如当前只有 5 页内容,你传 pageNum=99,它不会报错,而是返回最后一页。这个功能在真实项目里很实用,用户用搜索引擎跳转过来的旧链接可能页码早失效了,与其报错不如宽容处理。
但 PageHelper 有个经典坑:startPage 只对它之后执行的第一条 SQL 生效。如果你在 startPage 和查询之间不小心调用了别的 Mapper 方法,分页就错位了。另外它底层是拦截器改写 SQL,会多执行一条 count 查询,性能敏感的大表场景要注意。
再说 MyBatis 缓存。一级缓存默认开启,作用范围是同一个 SqlSession,平时 Controller 层每次请求都会新建 SqlSession,所以大部分人感知不到。二级缓存是 namespace 级别,开启后不同请求可以共享,但我觉得在二手交易这种数据实时性强的系统里,二级缓存弊大于利。商品价格变了、下架了,如果缓存没及时刷新,买家看到的还是旧数据。我的建议是:字典表、分类树这类几乎不变的数据可以开缓存;商品、订单、库存这种高频变化的数据,老老实实查 MySQL。真到了扛不住的时候,就上 Redis 做应用层缓存,别指望 MyBatis 二级缓存救火。
3. Vue 前端:从页面搭建到前后端联调
3.1 环境准备:Node、Vite、Vue DevTools 一次配好
前端部分如果是从零开始,环境配置经常比写代码还费劲。我建议统一用 LTS 版本的 Node,别追最新版,因为部分旧依赖和最新 Node 有兼容问题。vue 项目的创建方式有很多,bootpf 管理端用的是 Vue3 + Vite,执行这几步:
node -v npm create vite@latest bootpf-front -- --template vue cd bootpf-front npm install npm run dev如果拿到的是 Vue2 版本源码,一般是vue create bootpf-front,然后选择 Vue2 预设。两个版本差别最大的地方是组合式 API 和路由/状态管理的 API 形式,Vue2 用 options API,Vue3 用 setup 语法,联调阶段不要混着写。
安装依赖时最容易卡住的是网络问题,npm 在某些网络环境下载很慢。我自己习惯把镜像源切到国内 npm 镜像,执行npm config set registry https://registry.npmmirror.com后再 install,速度快得不是一点半点。装完依赖后建议装 Vue DevTools 浏览器插件。有时候你发现插件图标亮了但看不到组件结构,先检查是不是用的是生产环境构建。npm run dev启动的开发模式可以看到组件树,生产打包后的站点看不了,这个不是插件坏了。
3.2 路由参数:商品详情页的参数到底怎么传
前端多页面之间传参数是最容易出问题的点。二手商品列表跳详情页,我推荐用动态路径参数,而不是 query 参数。
const routes = [ { path: '/product/:id', component: ProductDetail, props: true }, { path: '/search', component: SearchPage }, ]列表页跳转时:
router.push(`/product/${item.id}`)详情页里接收:
import { useRoute } from 'vue-router' const route = useRoute() const productId = route.params.id为什么不用?id=123这种查询参数?因为查询参数是拼在 URL 后面的,看起来像product?id=123,容易被复制分享时篡改,而且部分页面在刷新后取参数的代码写得不严谨会丢。动态路径参数本身就是 URL 的一部分,刷新后依然还在,语义也更符合 RESTful。如果你一定要用 query,需要注意从route.query.id读取,并且做空值判断。
另外路由的查询参数还有一个坑:当组件复用时,Vue Router 为了性能不会重新创建组件,比如从商品 A 跳到商品 B,如果只是参数变了,组件实例还是同一个。这时必须在 watch 里监听 route 变化,重新拉数据,否则你看到的是上一个商品的详情。这个坑排查起来特别隐蔽,很多新手在详情页刷数据刷不出来,就是没监听路由变化。
3.3 Axios 封装与联调技巧
前端调后端接口最忌讳在一个页面里散落几十个 axios 调用,每个都要自己处理错误、加 token。bootpf 的做法是把 axios 实例封装成 api 模块,统一 baseURL 和拦截器。
import axios from 'axios' 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( res => { const code = res.data?.code if (code !== 0) { // 业务错误,统一弹提示 return Promise.reject(new Error(res.data.message)) } return res.data.data }, err => { if (err.response?.status === 401) { // 未登录,跳登录页 router.push('/login') } return Promise.reject(err) } ) export default request联调阶段最常见的两个问题就是跨域和字段名不匹配。跨域最简单的处理方式是 Vite 的 devServer proxy,把/api前缀的请求代理到后端地址,这样浏览器看到的还是同源请求,开发阶段根本不需要后端开 CORS。如果你非要后端开跨域,可以在 SpringBoot 里用 CorsFilter 或 WebMvcConfigurer 配置 allowedOriginPatterns,但生产环境我建议统一用 Nginx 反代,比后端 CORS 更干净。
字段名不匹配的坑更隐性。后端返回createTime,前端写create_time,页面显示就是 undefined。MyBatis 在返回时如果开启了驼峰映射,实体字段是 createTime,但 JSON 序列化默认还是 createTime,所以前端必须按驼峰读取。如果后端有的字段是 create_time,就需要在后端统一用 Jackson 配置或者写 VO 做转换,别让前端迁就一下、后端迁就一下,到最后两边都在改。
3.4 商品展示要不要上视频:m3u8 播放思路
二手平台商品往往只有图片,但一些高价值商品比如数码产品、乐器,视频展示能显著增加可信度。如果你想把商品视频加上去,前端播放可以直接用 video.js 或者 hls.js。视频文件如果是对外网直接暴露的 mp4,用 html5 video 标签就够了;如果是把视频切片成 HLS 流,也就是 m3u8 文件,那么原生 video 标签在 Safari 以外支持有限,需要引入 hls.js 来拉流。
import Hls from 'hls.js' const video = document.getElementById('video') if (Hls.isSupported()) { const hls = new Hls() hls.loadSource('/media/demo.m3u8') hls.attachMedia(video) }这个扩展点想清楚再动手。视频转码和存储成本比图片高一个量级,个人项目偶尔用用可以,真要大规模上得引入对象存储和转码服务。bootpf 源码里我留了 video_url 字段,但没做强制,你按需扩展就好。
4. 常见问题与排查实录
4.1 MyBatis 更新慢,问题可能不在 SQL 本身
有朋友拿 bootpf 跑订单状态更新,发现@Update执行的特别慢。这里我先给一个排查顺序:先看 SQL 命中索引没,再看是不是锁等待,最后才怀疑 MyBatis 配置。拿着 update 语句去 MySQL 里执行一次 explain,看 type 是不是 const 或者 range。如果显示 ALL,那就是没走索引。比如订单表按 order_no 更新,order_no 必须建唯一索引,否则每一单更新都是全表扫。
锁等待也经常被人忽略。你在事务里先查了商品,又更新订单,如果两条记录更新的顺序和另一个事务相反,就会死锁。建议锁顺序固定:先扣减商品库存,再更新订单,别一会反一会正。还有一种情况是更新语句带了大字段,比如 description 很长,一次改一行虽然行锁,但 binlog 同步成本高。解决方式是尽量只更新需要改的列,别把整个对象传进去更新。
4.2 全局 XSS 过滤器为什么会把上传的 PDF 弄坏
很多管理系统会在 SpringBoot 里加一个全局过滤器,把请求参数里的<script>等标签去掉,防止 XSS。但如果你在过滤器里直接读取了输入流去过滤,再重新包装请求,上传文件时就会出问题。因为 PDF、图片这类二进制内容本身就可能包含看似标签的字节序列,文本过滤会把它当成字符串替换,文件一上传就损坏。
解决方案其实不复杂。过滤器判断Content-Type,如果是以multipart/form-data开头并且包含文件上传,就直接放行,不要做参数清洗。如果非要过滤表单里的文本字段,也建议只处理文本类型的参数,用request.getParameterMap()拿值过滤后放回,别碰输入流。最后还要考虑请求体已经被读过了,后面 Spring MVC 解析 multipart 会拿不到流,所以要提前把流缓存起来。这个小坑在我带团队时出现不止一次,凡是上了全局过滤器再传 PDF 的项目,早晚会遇到。
4.3 SpringBoot 版本太高到底怪谁
2025 年聊新项目,SpringBoot 3.x 已经占了主流,但很多系统源码还是基于 2.x,网上教程也大量停留在 2.7。版本不是越高越好。SpringBoot 3.x 强制要求 JDK17,并且把原本javax开头的包名改成了jakarta。这意味着以前写javax.servlet.Filter、javax.validation的代码在 3.x 下直接编译不过。MyBatis Starter、PageHelper、Druid 这些组件,老版本对新 Boot 的支持也有缝隙。
如果你拿到 bootpf 源码跑不起来,先看报错是不是包名问题。报找不到javax.servlet说明源码还是 Boot2,但你用了新 JDK 和 Boot3 依赖。先检查 pom.xml 和启动类上面的注解,别急着改代码。个人建议:只要源码不是必须升级到 3.x,就保持原版本的 SpringBoot,把时间花在业务逻辑上。这套二手交易系统哪怕跑在 SpringBoot 2.7 上,稳定性也完全没问题,SpringBoot 版本高不高不影响用户是否愿意下单。
4.4 MySQL 连接报错的实战排查
部署阶段我遇到最多的问题就是应用服务器连不上 MySQL。一个常见报错是ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'。这个报错英文看着吓人,其实大多数情况就是 MySQL 服务没启动,或者客户端和服务端用的 socket 路径不一致。先执行systemctl status mysqld或者service mysql status,确认服务在跑;如果在跑但还报这个错,用 TCP 方式连一下:
mysql -h 127.0.0.1 -P 3306 -uroot -p跳过 socket 目录直接走 TCP,能绕过路径不统一的问题。另外注意 root 远程登录,MySQL 默认 root 可能只绑定了 localhost,你要给应用账号单独授权并指定允许访问的主机。还有一个容易踩的点是服务器防火墙和云安全组,端口没放行,应用和数据库部署在同一台机器也要确认 bind-address 不是 127.0.0.1。
5. 部署上线与后续扩展
5.1 打包、Nginx 和系统服务的完整链路
前面跑通后,最终要部署到服务器。后端的打包含几个重点:Maven 打成可执行 jar 后,里边的 application.yml 是开发环境的,生产环境要用外部配置覆盖:
java -jar bootpf-portal.jar --spring.profiles.active=prod --spring.config.location=/opt/bootpf/application-prod.yml这样数据库密码、Redis 地址都放在服务器文件里,不会跟着 jar 泄露。前端打包是npm run build,产物在 dist 目录,用 Nginx 托管。Nginx 做好两件事:静态文件交给前端,带/api的请求反向代理到后端口。
server { listen 80; root /opt/bootpf/dist; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }try_files那行必须写,否则前端 Vue Router 里像/product/123这种直接刷新的页面会 404。这个坑我听很多人讲过,后端接口明明好好的,前端路由一刷新就白屏,十有八九是 Nginx 没配 history 模式回退。
5.2 Linux 上 MySQL 安装与初始化细节
服务器装 MySQL 有两条路:包管理器安装和二进制包安装。包管理器最省事,CentOS 系用yum install mysql-server,Ubuntu 系用apt install mysql-server。装完先初始化 root 密码,再建业务库:
CREATE DATABASE bootpf DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER 'bootpf'@'%' IDENTIFIED BY 'your_password'; GRANT ALL PRIVILEGES ON bootpf.* TO 'bootpf'@'%'; FLUSH PRIVILEGES;字符集一定要指定 utf8mb4,否则商品描述里出现 emoji 表情会存不进去,乱码问题后面很难查。导入建表 SQL 时,建议用mysql -ubootpf -p bootpf < bootpf.sql这种命令行方式,比图形客户端更稳。
5.3 源码丢了,怎么从 jar 里抢救
有些朋友拿到开源或购买的源码,习惯性把工程删了,只剩服务器上的 jar。这时候想恢复源码,思路是有的:jar 里保留了编译后的 class 文件和 resources 下的 XML 配置文件,用 JD-GUI 或 CFR 这类工具可以反编译出 Java 代码。但我要给你泼盆冷水,反编译出来的代码是"可读"的,不代表是"可维护"的。注释全部丢失,泛型信息不完整,lambda 表达式会变成奇怪的内部类,如果编译时没加-parameters参数,方法参数名全部变成 arg0、arg1。
所以在 bootpf 这类项目上,最好的"抢救"不是反编译,而是提前把源码放进 Git。哪怕只是本地git init加提交,也比丢源码后再花一周时间反编译强得多。另外也提醒一下,反编译别人的商业系统的源码用于二次开发,可能涉及授权问题,自己排查故障看看逻辑可以,拿去发布就要谨慎。
5.4 项目还能怎么扩展
bootpf 的骨架完成后,后续扩展的方向很多。订单超时未支付需要自动关闭,可以用定时任务扫表,也可以用消息队列延迟消息;想提高搜索结果质量,可以引入 HanLP 分词后再做 MySQL 全文索引;管理员后台统计如果不想手动拼报表,可以对接成熟的报表服务器,把数据源接口暴露给报表模板,比如锐浪报表这类工具在国产项目里就经常被要求接。还有一个实用扩展是增加操作日志表,记录后台管理员的一举一动,这对校园平台或者公司内部流转系统来说,可以省掉很多扯皮。
我个人在实际维护里的体会是,这类系统最容易长出来的不是新功能,而是各种数据状态对不上的问题。因为二手交易的线下交付环节在系统外,买家确认收货和实际收货经常不一致,所以订单状态不能只靠用户点按钮,还得留一个管理员手动修正状态的口子。bootpf 里我保留了一个后台调整订单状态的接口,平时不开放权限,出问题时手动拉一下。这个看起来不起眼的功能,真正上线后救过我很多次,如果是我自己做,我建议你也一定留一个这样的后门。