基于 Java + SpringBoot + SSM 的二手物品交易网站系统,在毕业设计和培训班项目里差不多是最常见的题目了。常见到什么程度?随便搜一下就有上百个模板。但"做一个能跑的二手交易网站"和"把一个二手交易系统讲明白、调明白、部署明白"是两码事。我最近刚完整地从零搭建和调试了一套这样的系统,从需求拆解、数据库建模到核心接口实现,再到并发问题和部署上线,过程不复杂但坑位密集。这篇就是我的完整复盘,给正在做毕设、项目实训或者想入门 Java 全栈的朋友做个参考,你不需要照抄代码,重点是理解背后的取舍逻辑。
1. 项目背景与需求边界:这个二手交易系统到底要解决什么问题
1.1 二手交易网站的业务根模型
一个二手交易平台,表面上看就一句话:有人卖,有人买,平台做撮合。但当你真的开始设计数据表的时候,"撮合"这两个字会衍生出一堆问题:
- 卖家是普通注册用户,不是商家,所以没有传统电商那种复杂的商家后台,全靠个人中心管理自己发布的商品
- 买家在C2C场景下通常不会走购物车,而是看中了直接下单,线下或者站内沟通
- 商品状态比普通电商复杂:待审核、在售、已售、下架、删除,这些状态之间不是任意跳转的
- 用户既是买家也是卖家,同一个账号要同时维护"我买到的"和"我卖出的"两套订单视角
我在动手之前先画了一张业务流程图,把所有可能的功能点全部列出来,然后用一句话筛选标准砍功能:这个功能如果不存在,核心交易链路会不会断?不会断的,一律砍掉或者标为可选。
最后定下来的功能清单如下:
用户端:
- 注册、登录、个人信息维护,包括头像上传
- 商品发布、编辑、上下架管理
- 商品列表浏览、关键词搜索、分类筛选、价格区间筛选
- 商品详情、收藏、留言咨询
- 下单购买、订单查看、取消订单
管理端:
- 用户管理,支持禁用/启用
- 商品审核与下架
- 商品分类管理
- 基础数据统计
购物车、优惠券、秒杀、站内聊天,这些统统一律不做。原因很直接:毕设或练手项目的核心价值在于把主链路做深做透,而不是把功能面铺得很宽。每次答辩我都听到同学说"我做了购物车",但当老师追问购物车的库存锁定逻辑时,就答不上来了。与其这样,不如把订单状态流转、并发下单这些真正有含金量的点做好。
1.2 角色模型与核心流程设计
系统一共三类角色:管理员、卖家、买家。需要特别说明的是,在C2C二手交易场景下,买家和卖家本质上都是普通用户,只是一个账号在不同业务节点上的不同身份。所以我强烈建议不要设计成 buyer 表和 seller 表两张表,而是统一用 user 表 + role 字段区分是否管理员。普通用户的"买"和"卖"身份,通过订单表里的 buyer_id 和 seller_id 来体现,根本不需要在用户层面做拆分。
这个设计决策直接影响订单表的结构。我在订单表里冗余存了 seller_id,虽然可以通过商品表 join 用户表查出来,但每次查订单都要拆两次 join,SQL 又长又慢。冗余字段虽然违反第三范式,但在这个场景下换来了查询效率和代码简洁度,属于典型的时间换空间的取舍。
核心流程我梳理成四条主链路,每条都对应一套状态机:
- 卖家发布商品 -> 管理员审核 -> 上架在售
- 买家浏览搜索 -> 查看详情 -> 下单 -> 卖家确认 -> 交易完成
- 买家取消订单 -> 商品状态回滚为在售
- 用户注册 -> 登录 -> 进入个人中心
其中订单状态机是最核心的:待付款、待发货、待收货、已完成、已取消。状态之间的跳转我全部在 Service 层用校验方法控制,而不是放任代码随意 set 字段。这样一来,哪怕后面接手的同学不熟悉业务,看到校验逻辑也能明白订单应该怎么流转。
2. 技术选型的底层逻辑:SpringBoot 与 SSM 的搭配怎么理解
2.1 标题里"SpringBoot + SSM"到底是什么组合
这个值得专门解释一下,因为太多人在这上面犯迷糊。
SSM 是 Spring + SpringMVC + MyBatis 的缩写,这是国内 Java Web 开发非常经典的一套组合。而 SpringBoot 是基于 Spring 的快速开发脚手架,它本身已经把 Spring 的核心功能涵盖了。所以严格来说,"SpringBoot + SSM"在字面上是有点重复的——你不可能在一个项目里同时用两个 Spring。
实际项目中大家说的"SpringBoot + SSM",指的是:SpringBoot 作为基础和自动配置框架,Web 层用 SpringMVC 的模式写 Controller,持久层用 MyBatis(或 MyBatis-Plus)做数据库操作。分层依然是 Controller - Service - Mapper 三层经典架构,但不再需要手写那些繁琐的 XML 配置。
我之所以用这套组合而不是传统的纯 SSM + XML 配置,核心就一个字:快。Spring 的 XML 配置对于老开发者来说是肌肉记忆,但对新手来说简直是地狱——数据源配置、事务管理器、组件扫描、视图解析器、MyBatis 的 SqlSessionFactory,每一处都有细微的坑。SpringBoot 用自动配置把这些全部收编了,我搭项目的时候从创建工程到跑通第一个接口,不到半小时。
2.2 持久层为什么选 MyBatis 而不选 JPA
持久层我没有用 Spring Data JPA,选的是 MyBatis。这纯粹是业务场景决定的:
第一,联表查询是高频操作。商品列表要展示分类名称、卖家昵称、收藏数量,一个页面就涉及三四张表的 join。JPA 的 Entity 关联虽然也能做,但延迟加载和 N+1 查询问题处理起来非常别扭。MyBatis 直接手写 SQL,join 逻辑写在 XML 里,复杂查询一眼就能看明白。
第二,动态 SQL 是搜索功能的刚需。用户在搜索页可能填了关键词、也可能勾选了分类、还可能拖了个价格区间。这些条件任意组合,MyBatis 的<where>+<if>标签天然支持,而 JPA 的 Specification 写起来又长又难读。
第三,答辩层面的考虑。用 MyBatis 意味着你能讲清楚每一条 SQL 的联表逻辑,能解释动态 SQL 的拼接过程。用 JPA 的话,很多同学被问到"底层的 SQL 长什么样"就直接卡壳了,只能含糊说框架自动生成。这种场景我见得太多了。
2.3 前端方案与配套组件
前端我用的 Thymeleaf 模板引擎 + Bootstrap + jQuery,没有搞前后端分离。这个选择在现在的技术讨论里可能会被质疑,但放在毕设和练手项目的场景下,其实是深思熟虑的:
- 不需要处理跨域问题。前后端分离必然要面对 CORS 或代理配置,这个坑能绕就绕
- 不需要维护两套工程。一个 SpringBoot 工程搞定全部,war 包或 jar 包部署都省心
- 服务端渲染天然支持 SEO,商品详情页能被搜索引擎收录
数据库用 MySQL 8.0,连接池用 Druid,密码加密用 BCrypt,这些都属于 Java Web 项目的标配,不需要太多解释。倒是 Redis 我特别说一下:很多培训机构喜欢把 Redis 硬塞进毕设里,理由往往是"加分项"。但如果你的项目里有多个模块操作缓存,又要考虑缓存穿透、雪崩、数据一致性,复杂度蹭蹭往上涨。这个项目体量下,热门商品列表用 Spring 自带的本地缓存就够了,何必给自己找麻烦。
3. 数据库建模:三张核心表如何撑起交易闭环
3.1 用户表:别把买家和卖家拆成两张表
用户表是整套系统的基石。字段设计如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 自增主键 |
| username | varchar(50) | 用户名,唯一索引 |
| password | varchar(100) | BCrypt 加密后的密码 |
| avatar | varchar(255) | 头像相对路径 |
| phone | varchar(20) | 手机号 |
| role | tinyint | 0 普通用户,1 管理员 |
| status | tinyint | 0 正常,1 禁用 |
| create_time | datetime | 注册时间 |
三个设计要点展开说:
第一,username 必须加唯一索引。注册接口如果没做唯一校验,两个请求同时进来就会插入两条相同用户名。虽然 Service 层做了查重,但查重和插入之间存在时间窗口,唯一索引是数据库层面的最后防线。
第二,密码绝对不能存明文。我见过不止一个同学的项目源码里,数据库存的密码就是 123456,答辩时老师看一眼数据库就不想继续问了。BCrypt 加密的好处是自带盐值,同一个密码每次加密结果都不一样,而且内置了强度参数,比 MD5 加固定盐安全得多。
第三,status 字段是给管理员用的。普通用户违反平台规则时,管理员可以一键禁用账号。这个字段的设计要配合登录拦截器一起用——登录时不仅校验密码,还要校验 status 是否为 0。
3.2 商品表:状态字段是设计灵魂
商品表是字段最多的表,我这里给出核心字段:
- id:自增主键
- user_id:发布者,关联用户表
- category_id:商品分类
- title:标题,长度控制在 50 字以内
- description:详细描述,TEXT 类型
- price:BigDecimal,精确到分
- original_price:原价,可选填
- cover_image:封面图
- images:多图地址,逗号分隔
- status:0 待审核,1 在售,2 已售,3 下架,4 删除
- view_count:浏览次数
- create_time、update_time
price 用 BigDecimal 而不是 double,这是交易类系统的铁律。double 在二进制里无法精确表示 0.1,当商品价格 99.99 经过多次运算后可能变成 99.9900001,累计误差在订单对账时就是灾难。
status 字段是整个商品表的灵魂。我在 Service 层提供了几个校验方法:查询详情时必须是 status=1 的商品才能正常浏览,下单时必须是 status=1 才能创建订单,编辑商品时必须是 status=1 或 3(下架后可重新编辑再上架)。这些规则不放在数据库层面,而是放在 Service 层,目的是让业务报错信息更友好。你要是用数据库约束,用户看到的是"SQLException",而用 Service 层校验,用户可以收到"该商品已下架"这样明确的提示。
3.3 订单表:状态机与并发控制
订单表是交易闭环的落点。核心字段:
- id
- order_no:订单号,唯一,我用的是时间戳 + 用户ID + 随机数拼接
- product_id:商品ID
- buyer_id:买家ID
- seller_id:卖家ID,冗余存储,避免重复 join
- price:成交价,下单时从商品表快照过来
- status:0 待付款,1 待发货,2 待收货,3 已完成,4 已取消
- create_time、pay_time、finish_time
订单号为什么要单独生成而不是直接用自增ID?因为自增ID会暴露系统真实的订单量,而且在打印订单、客服沟通时,一个纯数字的 ID 既不美观也容易被恶意遍历。时间戳 + 用户ID + 随机数的组合基本可以保证全局唯一。
下单是整个系统里唯一必须使用事务的接口。我的实现逻辑是:
@Transactional public void createOrder(Integer productId, Integer buyerId) { Product product = productMapper.selectById(productId); // 校验商品状态 if (product.getStatus() != ProductStatus.ON_SALE) { throw new BusinessException("商品不存在或已下架"); } // 创建订单 Order order = new Order(); // ... 填充订单字段 orderMapper.insert(order); // 更新商品状态,注意条件包含 status = 1 int rows = productMapper.updateStatus(productId, ProductStatus.ON_SALE, ProductStatus.SOLD); if (rows == 0) { throw new BusinessException("商品已被抢购"); } }这里最关键的是最后这条 update 语句。它的含义是"只有当商品当前是在售状态时,才能改成已售"。在高并发场景下,多个请求同时通过了前面的 status 校验,但数据库层面的条件更新会把它们卡住——只有一个人能更新成功,其他人都返回 0 行受影响。这就是典型的 CAS(Compare and Swap)思想,一句话就解决了超卖问题。
4. 核心接口的落地细节:登录、发布、搜索的实现
4.1 基于 Session + 拦截器的登录鉴权
因为没有做前后端分离,所以我没有用 JWT,而是选择了 Session + 拦截器这套最贴合服务端渲染的方案。登录接口的核心逻辑:
@PostMapping("/login") public String login(String username, String password, HttpSession session) { User user = userService.login(username, password); if (user == null) { return "redirect:/login?error=1"; } if (user.getStatus() == 1) { return "redirect:/login?forbidden=1"; } session.setAttribute("user", user); return "redirect:/index"; }登录成功后把完整的用户对象放进 Session。后续不管是个人中心、商品发布还是下单,所有需要身份的操作都从 Session 里取用户,不再查库。这里有个小优化:Session 里存的是完整用户对象,如果管理员改了用户昵称,旧数据还在 Session 里。简单的做法是每次进入个人中心时刷新一次 Session 里的用户信息,成本极低但能避免很多奇怪的展示问题。
拦截器的实现重点是放行规则。我第一次配置的时候把静态资源也拦了,结果整个页面的 CSS、JS、图片全部加载不出来,页面跟裸奔一样。正确的做法是注册拦截器时把所有需要免登录访问的路径都配置进去:
registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/index", "/product/**", "/category/**", "/search", "/static/**", "/upload/**");这里 /static/** 和 /upload/** 必须放行,前者是前端资源,后者是上传的图片。
4.2 商品发布与图片上传的完整流程
商品发布页面需要一个支持多文件上传的表单。前端的核心结构:
<form th:action="@{/product/add}" method="post" enctype="multipart/form-data"> <input type="text" name="title"> <input type="hidden" name="price"> <textarea name="description"></textarea> <select name="categoryId">...</select> <input type="file" name="files" multiple> <button type="submit">发布</button> </form>后端的接收逻辑:
@PostMapping("/product/add") public String addProduct(Product product, @RequestParam("files") MultipartFile[] files, HttpSession session) { if (files != null && files.length > 0) { List<String> paths = new ArrayList<>(); for (MultipartFile file : files) { String fileName = UUID.randomUUID().toString() + getExtension(file.getOriginalFilename()); String filePath = uploadDir + "/" + fileName; file.transferTo(new File(filePath)); paths.add("/upload/" + fileName); } product.setCoverImage(paths.get(0)); product.setImages(String.join(",", paths)); } // ... 保存商品 }这里有几个细节值得展开:
第一,文件名一定要用 UUID 重命名。原始文件名比如"毕业设计答辩PPT.jpg",如果直接落盘,两个用户在不同时间上传了同名的图片,后上传的会把先上传的覆盖掉。我见过某个项目因为这个问题,一个用户的商品图和商品描述对不上号,排查了两天才发现是文件名冲突导致图片被顶掉。
第二,Upload 目录要独立配置。不要在代码里写死绝对路径,而是在配置文件里定义,比如:
web: upload-path: /data/secondhand/upload/然后配置虚拟路径映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${web.upload-path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler(uploadPath + "/"); } }这样浏览器访问 /upload/xxx.png 时就会映射到服务器磁盘上的真实文件。部署后如果要迁移文件位置,只改配置文件就行,不用动代码。
第三,上传大小限制。前面提到过,SpringBoot 默认单个文件最大 1MB,必须调整。如果你用的是内嵌 Tomcat,还需要在 application.yml 里把 maxSwallowSize 也调大,否则大文件上传时连接会被强制关闭,报错还不是很好找。
4.3 动态 SQL 实现多条件搜索
搜索功能是二手交易系统和普通企业管理系统最大的区别。用户进来不一定带着明确目标,可能只搜关键词,可能只看某个分类,可能限制价格区间。这些条件全部可选,就要用 MyBatis 的动态 SQL 来拼。
核心的 select 语句我在前面已经贴过,这里补充三个容易踩坑的点:
第一,模糊搜索的写法。不要直接写 LIKE '%#{keyword}%',MyBatis 会报语法错误,因为 #{} 会被预编译成占位符,不能直接拼进 LIKE 的字符串里。正确写法是 CONCAT('%', #{keyword}, '%')。
第二,多表 join 后要注意字段名歧义。商品表和分类表都有 id 和 create_time 字段,select 里定义了别名就一定要在 WHERE 和 ORDER BY 里用别名。我之前就写过一次没有加别名的 order by create_time,MySQL 直接报了 ambiguous column 错误。
第三,分页参数的计算。前端传页码 pageNum 和每页数量 pageSize,后端计算 offset = (pageNum - 1) * pageSize,LIMIT 后面第一个参数是偏移量。注意 pageNum 从 1 开始,不是从 0 开始。
<select id="searchProducts" resultType="com.example.entity.Product"> SELECT p.*, c.name AS categoryName, u.username AS sellerName FROM product p LEFT JOIN category c ON p.category_id = c.id 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}, '%') </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> ORDER BY p.create_time DESC LIMIT #{offset}, #{pageSize} </select>分页这里我用的是手写 LIMIT,没有引入 PageHelper 插件。PageHelper 虽然方便,但它在多表 join 和嵌套查询时偶尔会出分页数据错乱的问题,手写 LIMIT 对毕设体量来说完全够用,而且答辩时能讲清楚分页原理。
5. 调试实录:我跑通这个系统时遇到的三类典型问题
5.1 依赖与版本兼容问题
SpringBoot 的项目虽然比纯 SSM 省心,但版本问题依然是最早遇到的拦路虎。我一开始用的是 SpringBoot 2.7.x,引入的是 mybatis-spring-boot-starter 2.3.1,启动时报了一个"Failed to configure a DataSource"的警告,看似是数据库没连上,实际上点击堆栈发现是 MyBatis 自动配置类没被识别。
排查过程是这样的:先检查 application.yml,数据库连接参数都对;再检查 mapper 接口,加了 @Mapper 注解;最后用 mvn dependency:tree 查看依赖树,发现是我手动指定的 mybatis-spring-boot-starter 版本和 SpringBoot 版本不兼容,自动配置的 class 被覆盖了。解决方式很简单:去掉手动指定的 version,改用 SpringBoot BOM 里统一管理的版本。
另外 MySQL 驱动的依赖也需要注意:MySQL 8.0 对应的是 mysql-connector-java 8.x,如果项目里还留着 5.x 的驱动,连接数据库时会报 Public Key Retrieval is not allowed 的错误。解决方式是在 JDBC URL 后面加 allowPublicKeyRetrieval=true&useSSL=false。
5.2 数据库时区与连接编码问题
这个项目在本地跑起来之后,页面上的时间比系统时间整整快了 8 个小时。查了半天发现是 MySQL 8.0 的时区配置问题——如果没有在 JDBC URL 里指定 serverTimezone,MySQL 默认使用 UTC 时区,而本地系统是东八区,所以差 8 个小时。
解决方案就是在连接 URL 里显式写好:
jdbc:mysql://localhost:3306/secondhand?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false顺便说一句,很多同学在这个问题上偷懒,不指定 characterEncoding,结果就是中文乱码接踵而至。商品描述里存的中文变成问号,排查链路通常是:先看数据库表字符集 -> utf8mb4 没问题 -> 再看连接 URL -> 没指定编码 -> 加上后 OK。这个坑基本每个 Java Web 项目都会踩一次,与其到时候排查,不如一开始就写全。
5.3 并发场景下的超卖与数据一致性
这是我用压力工具模拟 50 个并发请求下单同一件商品时暴露的问题。现象是商品只有一件,但订单表里却插入了 20 多条记录,都是同一个商品ID。
根因分析:下单接口里先查商品状态,再创建订单,最后改商品状态。三个步骤之间存在时间窗口,多个请求同时通过状态检查后,各自都认为自己抢到了商品。这是最典型的竞态条件。
我最终的解决方案是前面提到的 CAS 更新:
UPDATE product SET status = 2 WHERE id = ? AND status = 1把"状态检查 + 状态修改"合并成一条原子 SQL,数据库层面的行锁保证只有一个请求能更新成功。这样订单创建和商品状态更新必须同时成功,否则事务回滚。
这个问题非常值得在答辩时拿出来讲,因为它把一个看似简单的问题暴露出了系统设计的深层考量,是区分"会写代码"和"会设计系统"的分水岭。
6. 从能跑到能交付:部署、文档与答辩复盘
6.1 打包部署的两种方式
开发环境一切正常后,部署到服务器又是一轮折腾。SpringBoot 项目有两种打包方式:
第一种是打 war 包部署到独立 Tomcat。需要把启动类继承 SpringBootServletInitializer,同时改打包方式为 war。优点是可以复用服务器上已有的 Tomcat,缺点是多了部署目录的管理成本。
第二种是打 jar 包直接运行,SpringBoot 内嵌 Tomcat:
mvn clean package -DskipTests nohup java -jar secondhand-1.0.0.jar --spring.profiles.active=prod > app.log 2>&1 &我推荐 jar 方式,运维成本低,一条命令搞定启动。生产环境的配置用独立的 application-prod.yml,数据库地址、密码、上传路径全部从开发环境配置中剥离。
服务器上的 MySQL 一定要改掉默认的 root 密码,并且项目里连接数据库的账号不要直接用 root,而是单独建一个只有 secondhand 库权限的账号。这些虽然是安全底线,但很多练手项目的同学根本不在意,直到被人发现了才后悔。
6.2 文档的价值:为了三个月后的自己
项目交付时除了代码,文档同样重要。我维护了三份文档:
第一份是 README,写清楚项目是什么、技术栈、目录结构、如何部署。这份文档的目标读者是接手的同学或者一个月后的自己。
第二份是数据库设计说明,包含 ER 图、每张表的字段含义、状态字段的取值说明。我用表格把 status 的所有取值和对应含义列出来,远比看代码猜状态要高效。
第三份是踩坑记录,记录我调试过程中遇到的所有问题和解决方案。这份文档在答辩准备阶段尤其有用,因为老师最爱问"你遇到过什么问题,怎么解决的",有记录在手,回答起来从容得多。
说句实话,写文档这件事在项目开发阶段最容易被人忽略,但也是最值得投入的部分。你写的时候觉得是负担,三个月后回看会觉得是救星。特别是这种带源码、说明文档和调试手册的项目,一份清晰的文档能让你在交付和复盘时省下大量时间。
6.3 讲解与演示的节奏建议
如果这个项目要用于答辩或者作为作品展示,我的建议是讲解逻辑按这个顺序走:背景与需求 -> 技术选型 -> 数据库设计 -> 核心功能演示 -> 亮点与总结。
演示环节建议挑三个场景:第一次演示从注册到逛商品,展示系统整体的完成度;第二次演示发布商品加图片上传,展示比较完整的功能链路;第三次专门演示异常场景,比如重复下单、访问不存在的商品,展示系统的健壮性。
最核心的讲解点是并发超卖问题的处理和订单状态机的设计。这两个点能体现你对系统设计的理解深度,比演示一百个页面都有用。老师在考察时真正关注的是"这个项目有没有真做、有没有想明白",而不仅仅是"功能能不能点通"。
最后如果时间和精力允许,可以想想后续扩展方向。比如给系统加上收藏功能(一张 user_id + product_id 的关系表),加上站内留言(一张 sender_id + receiver_id + content + is_read 的消息表),甚至引入 Redis 做热门商品的缓存。这些扩展不用真的写完,但"能讲出扩展设计"本身就说明你对系统有全局认知。我在实际开发中的体会是:二手交易系统这种体量的项目,最大的价值不是代码量,而是它逼着你把用户、商品、订单三张表的关系想透,把状态流转的边界理清。把这些基本功打扎实,后面接触更复杂的系统时,你会感谢当时认真建模的自己。