做毕设或练手项目碰上“基于SpringBoot的二手交易平台”,其实是个很有意思的信号。它既要求你熟悉常规CURD,又逼着你把交易流程、订单状态、数据一致性这些真实业务问题落到代码里。我见过不少同学拿这类题,最后只做出一个“能发帖、能留言”的展示系统,答辩时老师一问“用户下单之后库存怎么扣”“你这个订单状态谁在维护”,场面往往就冷场了。这篇文章我想拿这套题作为范本,从系统拆解、数据库设计、后端落地再到联调排查,完整过一遍。
它能帮你什么?
- 想找SpringBoot项目练手但不知从哪里入手的后端初学者
- 需要完成一个结构完整、能讲出设计深度的毕业设计/课程设计
- 做前后端分离项目时,对权限、订单状态、图片上传等模块还有疑问的开发者
我会对着实际开发过程来讲,尽量少讲教科书空话,多给能直接抄作业的表结构、状态定义和核心代码,也会把“为什么这样设计”掰开来说清楚。
1. 项目整体设计与思路拆解
1.1 这类系统的核心考察点
先把题目定位清楚。一个二手交易平台,说白了包含三个最核心的场景:卖家发布商品、买家搜索浏览、买卖双方完成交易。商品模块看起来不难,谁都会写增删改查;订单模块才是真正拉开差距的地方,比如买家下单时商品还被别人买走了怎么办、订单状态怎么流转、超时不付款是否需要取消。
所以我做系统设计时,第一件事不是打开IDEA直接建表,而是把业务闭环画清楚。前台用户侧支持注册登录、商品浏览、搜索筛选、发布商品、下单付款、查看订单;后台管理侧则保留商品审核、用户管理、订单管理、举报处理这些能力。
真正推荐的最小闭环如下:
- 注册登录 → JWT签发令牌,后端统一鉴权
- 卖家发布闲置 → 包含商品图片、描述、价格和库存数量
- 买家浏览搜索 → 分类、关键字、价格区间筛选
- 买家下单 → 锁定商品库存,生成待付款订单
- 买家确认状态 → 模拟支付或直接标记已付款,交易完成
能把上面这五步做利索,项目的内容量和逻辑深度就已经超过市面上很多“半成品”系统了。
1.2 技术栈方案怎么选
这套系统我建议采用SpringBoot + Vue加MySQL,走前后端分离结构。SpringBoot只做纯后端接口,前端用Vue框架单独起一个工程。选择前后端分离不是赶时髦,而是从项目可扩展性和开发并行度出发做的决定,后端同学只需要把接口文档维护好就行。
SpringBoot版本这块多说一句。很多刚入门的朋友一上来就跟着最新文档搭了SpringBoot 3.x,结果发现JDK版本不对、有的starter包没适配,直接把自己劝退。做毕设或课程项目,如果不是对新技术有特别强烈的执念,我推荐先用稳定且资料铺天盖地的SpringBoot 2.7.x。它在JDK8下跑得非常好,MyBatis、PageHelper、SpringDoc这些生态也早就适配完了,能少踩一半的坑。
至于ORM框架,MyBatis-Plus比原生MyBatis更适合这种业务型管理项目。单表CURD直接用内置方法,复杂一点的手写Mapper就行。一个中型的二手交易平台,90%的查询都是单表操作,只有订单详情之类需要联表查询,没必要为了炫技引入复杂框架。
技术栈整体可以照下面这张表来:
| 层级 | 技术选择 | 用途说明 |
|---|---|---|
| 后端基础 | SpringBoot 2.7.x | 项目脚手架、依赖管理 |
| ORM层 | MyBatis-Plus 3.5.x | 简化数据访问 |
| 数据库 | MySQL 8.x | 数据存储 |
| 接口文档 | SpringDoc + knife4j | 生成OpenAPI3接口文档 |
| 安全认证 | JWT + 拦截器 | 无状态登录鉴权 |
| 文件上传 | 本地磁盘 + 资源映射 | 存储商图文图 |
| 前端 | Vue3 + Element Plus | 页面搭建与交互 |
我没有把Redis和RabbitMQ写进去,并不是因为它们没有价值,而是做课设或毕设时,如果基础代码还hold不住,强行上一个消息中间件很容易变成“为用而用”。先把单体业务做扎实,后续扩展是水到渠成的事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与业务状态模型
2.1 核心表结构怎么规划
数据库设计直接决定了整个后端代码写起来顺不顺手。二手交易平台我建议至少拆出下面这些表:用户表、商品表、订单表、订单明细表、收藏表,如果后台需要做审核,就再加一个商品审核状态字段。下面是商品表和订单表参考结构:
sql复制CREATE TABLE `product` (
`id` bigint NOT NULL AUTO_INCREMENT,
`seller_id` bigint NOT NULL COMMENT '卖家ID',
`title` varchar(100) NOT NULL COMMENT '商品标题',
`description` text COMMENT '商品描述',
`category` varchar(32) DEFAULT NULL COMMENT '商品分类:数码/图书/生活用品等',
`price` decimal(10,2) NOT NULL COMMENT '售价',
`original_price` decimal(10,2) DEFAULT NULL COMMENT '原价或入手价',
`stock` int NOT NULL DEFAULT 1 COMMENT '库存数量,二手通常是1',
`cover_image` varchar(255) DEFAULT NULL COMMENT '封面图URL',
`images` text COMMENT '详细图URL,逗号分隔',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待审核 1在售 2已下架 3已售出',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单主表特意用了两个字段:buyer_id和seller_id。很多教程会只存买家ID,然后靠商品表反查卖家,这样的写法在高并发和复杂统计下会非常别扭。订单一旦沉淀下来,商品信息可能有变动,卖家与买家关系不能只依赖关联查询。
sql复制CREATE TABLE `orders` (
`id` bigint NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '业务订单号,展示给用户用',
`buyer_id` bigint NOT NULL COMMENT '买家ID',
`seller_id` bigint NOT NULL COMMENT '卖家ID',
`product_id` bigint NOT NULL COMMENT '商品ID',
`product_title` varchar(100) DEFAULT NULL COMMENT '商品标题快照',
`product_image` varchar(255) DEFAULT NULL COMMENT '商品图快照',
`price` decimal(10,2) NOT NULL COMMENT '成交单价',
`quantity` int NOT NULL DEFAULT 1 COMMENT '数量',
`total_amount` decimal(10,2) NOT NULL COMMENT '总金额',
`status` tinyint NOT NULL COMMENT '订单状态:见状态机',
`pay_time` datetime DEFAULT NULL,
`deliver_time` datetime DEFAULT NULL,
`finish_time` datetime DEFAULT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
商品表里给title和category建索引,订单表给buyer_id、seller_id、order_no建索引。数据量不算大的管理项目,索引不要建太多,每个索引都有维护成本。
2.2 订单状态机必须先想清楚
订单模块最容易出设计问题。没有状态机概念的人,会把状态写成“状态字段配几个数字”,然后不同接口想怎么改就怎么改,最终导致订单流乱掉。我更愿意提前把状态值和合法流转路线画出来:
| 状态值 | 枚举名 | 含义 | 谁可以触发下一步 | 下一步状态 |
|---|---|---|---|---|
| 0 | WAIT_PAY | 待付款 | 买家支付 | 1 待发货 |
| 1 | WAIT_DELIVER | 待发货 | 卖家发货 | 2 待收货 |
| 2 | WAIT_RECEIVE | 待收货 | 买家确认收货 | 3 已完成 |
| 3 | FINISHED | 已完成 | 无 | 无 |
| 4 | CANCELLED | 已取消 | 无 | 无 |
状态字段建议设成成tinyint,别直接用String存中文。后端接口层用枚举做语义映射,存库时用数字,展示时翻译成文字,这样的好处是数据库体积小、索引效率高,同时也避免出现“待发货”和“待发货 ”这种脏数据。
关于取消订单,我在这个项目里用的是简单方案:待付款状态,买家可以主动取消;卖家也可以把在售商品直接下架,但不会影响已经生成的订单。另一种常见的办法是加一个定时任务,超过30分钟未支付自动取消并恢复库存。二手交易这个场景,用户本来就不多,商家对锁定库存的容忍度较高,所以定时任务不是必须项。你能把状态图和触发规则讲清楚,比强行写一堆定时调度代码更能体现设计能力。
还有库存扣减。二手闲置大多库存是1,看上去没有并发问题,但商品数量一旦大于1,多人同时下单时库存就可能变成负数。这里要做一个关键处理:数据库更新带上库存条件。
java复制@Update("UPDATE product SET stock = stock - 1, version = version + 1 " +
"WHERE id = #{productId} AND stock > 0")
int deductStock(@Param("productId") Long productId);
stock > 0这个条件就是乐观锁最简单的实现。如果返回受影响的记录数等于0,说明商品已经被别人买走或者下架了,后端直接抛出“手慢了,商品已被拍下”这种提示就好。这种写法干净、性能好,也完全够毕业设计和中小体量项目使用。
3. 后端核心模块的落地实现
3.1 注册登录与权限控制
登录模块建议直接用JWT加拦截器,不要去搞复杂的Spring Security。Spring Security功能很强,但对一个二手交易平台来说学习成本太高,配置类一多反而容易出错。
JWT的逻辑链路非常清晰:用户输入账号密码,后端校验通过后生成一个token返回给前端;前端后续请求带上这个token,后端在拦截器里解析token,把当前登录用户ID解析出来放到ThreadLocal或请求属性中。这样Controller里就不需要每个方法都接收一个userId参数了,从token拿即可。
有几个容易翻车的地方需要特别留意。
第一,密码不能明文入库。推荐用BCrypt加密,BCryptPasswordEncoder这个类足够强大,自带盐,不需要额外存盐字段。
第二,拦截器里要放行的URL和需要拦截的URL要区分开。登录接口、注册接口、验证码接口、商品浏览列表这些应该匿名访问,但发布商品、下单、查看订单必须登录后访问。
第三,配置拦截器排除路径时,记得把swagger或knife4j的接口文档路径也放行。否则本地调试文档时,登录接口都没法看。
java复制public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization");
if (StringUtils.hasText(token) && token.startsWith("Bearer ")) {
token = token.substring(7);
}
// 解析失败或过期则直接返回401,并写一段JSON给前端
LoginUser user = JwtUtil.parseToken(token);
if (user == null) {
response.setStatus(401);
return false;
}
UserContextHolder.set(user);
return true;
}
}
有一点需要强调:后端任何涉及用户身份的接口,比如“删除我发布的商品”,都应该从登录上下文里拿用户ID再执行,而不是直接信任前端传过来的userId。前端传来的参数任何人都可以伪造,你辛苦做的权限控制,不能在这一环崩掉。
3.2 商品发布、图片上传与资源映射
商品发布流程里最麻烦的是图片上传。前端往往用Element Plus的Upload组件选择图片后就直接通过multipart/form-data传给后端,后端把图片存到磁盘某个目录,再把访问路径返回给前端。很多系统的图片存到本地磁盘,但数据库里存的是一个相对路径,比如/upload/2025/04/abc.jpg,前端就直接拼上后端域名访问。
这样需要额外配置一个静态资源映射,否则SpringBoot默认不托管/upload/**这种本地目录。
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String path = "file:" + System.getProperty("user.dir") + "/upload/";
registry.addResourceHandler("/upload/**").addResourceLocations(path);
}
}
这里有一个经验细节:外部访问前缀/upload/**和本地目录/upload/不要保存在代码里,最好写进application.yml配置项,防止后期换Linux服务器改路径时还得重新打包。
上传接口还需要限制文件类型和大小。最好在后端同时限制,不能只靠前端。有的同学只判断了前端后缀名,换成一个伪造后缀的文件也能传上去,这会成为安全隐患。
yaml复制spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 20MB
除了文件限制,商品发布的下架和删除操作必须校验当前登录用户和商品seller_id是否一致,否则任何登录用户都能把别人的商品删掉。这一步和登录鉴权一样重要,是最常见的越权漏洞。
3.3 商品列表、搜索与分页查询
商品列表接口尽量使用GET方法,查询参数通过Query对象接收。分页参数命名为currentPage和pageSize,或者pageNum和pageSize,要统一。前后端分离项目中,前端表格组件习惯于传1代表第一页,而MyBatis-Plus的默认分页拦截器只认current,不注意这点的接口,前端点击第2页时会发现返回的还是第1页数据。
MyBatis-Plus分页查询参考写法:
java复制public IPage<ProductVO> searchProducts(ProductQuery query) {
Page<Product> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StringUtils.hasText(query.getCategory()), Product::getCategory, query.getCategory())
.like(StringUtils.hasText(query.getKeyword()), Product::getTitle, query.getKeyword())
.between(query.getMinPrice() != null && query.getMaxPrice() != null,
Product::getPrice, query.getMinPrice(), query.getMaxPrice())
.eq(Product::getStatus, 1)
.orderByDesc(Product::getCreateTime);
return productMapper.selectPage(page, wrapper);
}
query是专门承接前端请求参数的对象,不要直接拿数据库实体类去接收,避免出现用Product实体接收一堆无意义筛选条件的情况。也可以单独返回一个ProductVO来屏蔽掉字段信息。
搜索这块,如果数据量继续涨上去,就可以考虑引入Elasticsearch或者MySQL全文索引,但起步阶段使用LIKE查询已经够了。能说出“当前量级下用MySQL就够,未来可以平滑迁移到ES”这句话,会显得你对自己的设计有清晰认知。
3.4 下单流程与事务控制
下单是一个典型的多表联动操作,至少涉及:校验商品状态、扣减库存、创建订单。这一步必须放在同一个数据库事务里执行。如果是手动写JDBC,就是逐条执行SQL然后手动commit、rollback;在SpringBoot里,只需要在Service方法上加@Transactional(rollbackFor = Exception.class)即可。
下单时,校验商品状态这一步容易被漏掉。有些系统只校验商品是否存在而忽略下架状态,这样用户在浏览页面停留时间长,别人已经提交订单后,他再下单就会把已售出的商品买下来,体验很不好。正确做法是把“status=1在售”作为UPDATE的前置条件,和库存扣减一起原子完成。
完整流程我推荐这样做:
- 根据商品ID查询商品信息,如果商品不存在或状态不在售,直接抛异常
- 执行扣减库存的UPDATE语句,检查受影响行数是否为1,如果为0说明商品状态已变化或库存不足
- 插入订单主表数据,生成一个业务订单号,比如用当前时间加随机数
- 记录订单明细快照(商品标题、商品图、成交单价)
- 事务提交,返回订单编号给前端
把“创建订单”做成事务之后,还要注意统一结果返回和全局异常处理。很多同学在每个Controller里手写Result.success(data),异常发生时就返回null或直接抛出500给前端,这样的代码写多了反而很乱。建议统一封装一个返回体类,再配一个@RestControllerAdvice全局异常拦截器。业务异常用自定义异常抛出,全局拦截后自动转化成对应错误码和友好提示。
4. 前后端联调与常见问题排查
4.1 跨域问题到底怎么处理
SpringBoot后端单独跑在8080端口,Vue前端单独跑在5173端口,前端请求后端必然存在跨域。跨域是浏览器层面的安全策略,解决跨域有三种常见手段:后端加@CrossOrigin注解、后端配置CorsFilter、前端配置Vite代理。
很多教程会推荐后端直接配跨域过滤器,因为改一个类就能解决,联调很省事。后端代码实现如下。
java复制@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
// 上线后可以把域名改具体,不要一直用*
config.addAllowedOriginPattern("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
需要提醒的是,如果你的前端请求带了Authorization请求头,后端允许的header里不能漏掉,addAllowedHeader设为*是更稳妥的做法。如果又用了allowCredentials(true),addAllowedOrigin就不能写成*,因为浏览器对携带凭证的跨域请求有额外限制,必须用具体的OriginPattern。这个组合会让很多初学者绕半天,记好上面这段即可。
Vite前端proxy配置则更像是生产环境的一种手段。开发时后端开了CORS过滤器,打包放到Nginx后,Nginx配置反向代理把/api请求转发到后端服务,就没有浏览器的跨域问题了。两条路可以并行理解。
4.2 分页组件回显、日期格式与404问题
联调阶段最容易出问题的往往不是业务逻辑,而是JSON序列化和框架版本兼容问题。这里整理了常见的坑点清单,按优先级排列:
| 问题现象 | 根因分析 | 处理方式 |
|---|---|---|
| 请求返回401,前端拿不到具体错误 | 拦截器直接返回状态码没写JSON | 在拦截器response.setStatus之前,手动写Result.fail(401,"未登录") |
| 跨域请求能到后端但前端报错 | 后端接受了OPTIONS预检请求却被拦截器拦下 | 拦截器中遇到OPTIONS请求直接放行 |
| 前后端分页页码不一致 | 后端把pageNum当数据库偏移量,或字段名不统一 |
统一使用pageNum、pageSize并在分页插件中正确设置 |
| LocalDateTime序列化后是数组,前端无法显示 | 缺少JavaTimeModule或者格式一致化配置 | 在application.yml中配置spring.jackson.date-format,并配置时区 |
| 时间比实际少了8小时 | JDBC连接串和系统时区不一致 | JDBC URL加serverTimezone=Asia/Shanghai,Jackson设置time-zone为GMT+8 |
| 接口文档页面打不开 | SpringBoot 2.6以上与SpringFox路径匹配冲突 | 使用SpringDoc替代SpringFox,不要硬用老版本兼容 |
| 上传图片后无法访问 | 静态资源配置未生效 | 检查资源映射的本地磁盘路径是否和数据库字段相对路径一致 |
| 文件大小超过默认限制直接失败 | SpringBoot默认单文件最大1MB | 在spring.servlet.multipart中调大数据限制 |
| 用户在交易中同时操作一个商品导致数据不对 | 缺少库存前置校验和事务或乐观锁 | UPDATE时带stock > 0条件,并加事务 |
分页查询返回结构也要提前商量好。MyBatis-Plus的IPage自带对象字段叫records、total、size、current。如果你要封装成便于小程序端渲染的数据格式,建议返回到PageVO,里面只写list和total两个字段,前端接起来很清楚。
4.3 本地跑起来,再打包部署
本地启动SpringBoot项目,最推荐的组合是IDEA + Maven + JDK8。项目创建时用Spring Initializr引入核心starter,数据库连接信息写在application.yml里。开发中更建议配置成多环境文件:
yaml复制spring:
profiles:
active: dev
这样把dev和prod环境参数分开。部署到服务器时用--spring.profiles.active=prod指定生产环境配置,不用改代码。打包命令直接用mvn clean package -DskipTests。需要注意的是如果你的服务器是Linux,本地文件上传路径就不能再用写死的Windows绝对路径,我上面提到的配置文件里单独维护路径,就是为这一步做准备。
前端打包一般是npm run build,生成的静态文件放到Nginx里,再把/api代理到后端应用地址。这些部署细节在实际工程中很常见,如果能说清楚“开发环境用dev配置、生成环境用prod配置”,会给人很规范的感觉。
5. 做完这套系统后,项目还能怎么走
如果你按上面的结构把系统从前到后完整实现了一版,这时候其实可以停下来好好复盘一下,而不是着急碰下一个新项目。关于这套二手交易系统我最后再分享几点判断,帮你判断架构的扩展点在哪里。
第一,权限模型可以再往前一步。现在只有单用户体系,但平台往往需要管理员后台。如果要扩展,最简单的方式是用户表加一个role字段,0普通用户 1管理员,然后为管理员单独开一组Controller。当然也可以引入Spring Security的@PreAuthorize注解做到方法级权限控制,但是学习成本会显著提高。
第二,消息通知可以做异步解耦。买家下单后通知卖家发货,目前最直接的办法就是买家刷新页面看到订单列表。后期如果为了体验,需要引入WebSocket或SSE推送“您有新的订单待发货”这种提醒,就可以把通知服务独立拆出来,此时消息队列才是真正的刚需,而不是初始阶段硬加。
第三,如果将来有用户之间站内聊天的需求,核心问题会从“如何聊天”变成“如何组织会话和消息顺序”。很多二手平台的被骗风险往往来自站外沟通,做一个站内会话系统需要单独建会话表和消息表,消息读取状态和离线推送都会进来。
从我的经验来说,把这类管理项目做完只是第一步,能讲出“如果数据量变大要怎么演进”“如果业务流程复杂要怎么拆服务”,才是这段开发经历最有价值的产物。技术在快速迭代,但拆解业务和建模的能力在任何一个框架下都适用,这是我认为做这个项目能带走的最核心的东西。
