☰
Spring Boot实战:校园二手交易系统从数据库设计到并发防超卖全解析
2026/10/14 4:17:09 网站建设 项目流程

校园二手交易系统听起来不复杂,真正做起来涉及的链路却比想象中长很多。这套系统是我在深入接触 Spring Boot 之后完整做下来的第一个项目,从需求梳理、数据库设计,到接口开发、前后端联调,再到最后打 jar 包部署,整个过程走完一遍,踩了不少坑,也把以前零散的框架知识真正串到了一起。如果你正准备做一个 Spring Boot 实战项目,或者想拿一套校园二手交易平台来做毕业设计、提升项目经验,这篇内容可以给你一条能直接照着走的路线。

1. 项目整体设计与技术选型思路

1.1 校园二手场景的特点决定了设计核心

做校园二手平台,不能一上来就照搬大型电商系统的思路。校园用户以在校学生为主,商品集中在教材、数码产品、生活用品这几类,交易频率确实没法跟主流电商比,但每一笔交易都高度依赖信息真实度和线下履约。学生更在意的是能不能快速看到“谁在出什么价位的什么东西”,以及商品图片、成色描述是否靠谱。这意味着系统的第一优先级不是高并发,而是信息表达准确、流程清晰、操作门槛低。

我在需求阶段专门把校内常见的“跳蚤群”和“表白墙卖闲置”这两个场景梳理了一遍。信息都散落在聊天记录里,想找某个型号的教材要翻很久,图片不完整,价格也不透明。这个系统的核心价值,就是把“有人要卖、有人要买”这件事结构化:商品有固定字段、有图片、有分类、有价格,买家可以检索和筛选,交易流程有明确状态。这样设计出来的系统才不会跑偏成花哨的展示页。

1.2 技术栈选型与取舍

Spring Boot 是这个项目最合适的底座,这是它解决了传统 SSM 项目里最磨人的“配置地狱”问题。自动配置帮我把数据源、事务管理、Jackson 序列化这些基础能力全部接好了,我需要关注的只是业务代码本身。用 Spring Boot 做业务接口,基本就是写 Controller、Service、Mapper 三层,开发效率高很多。

持久层我选了 MyBatis-Plus。它保留了手动写 SQL 的灵活性,又提供了很实用的单表 CRUD 和分页插件。二手商品列表这种场景,经常要拼价格区间、分类、状态这些查询条件,MyBatis-Plus 的条件构造器写起来顺手,复杂统计再手写 XML SQL,两者配合很舒服。没有选 Spring Data JPA,是因为它的隐式查询规则对新手不友好,出现复杂 SQL 时反而不如 MyBatis 族直观。

认证方案上,我在 Session 和 JWT 之间犹豫过。校园项目并发量可控,Session 完全可行。但考虑到现在很多同学会把接口做成分离模式,我最后选择了 JWT + 拦截器的方案,登录成功后签发 token,前端后续请求放到请求头里,后端拦截器统一校验。JWT 无状态,水平扩展时不需要粘着某台服务器,这一点对以后部署到云服务器也友好。关于这个方案的坑,我后面单独写一节,因为不少人在拦截器放行路径上栽过跟头。

前端和部署也顺便说一句。早期我图省事,后端直接用模板渲染页面。后来项目扩展,加了管理后台,发现前后端耦合在一起很难维护,所以改成后端提供 JSON 接口,前端独立构建。如果你只是想把项目快速跑通,模板渲染完全够用;如果想让项目看起来更像真实生产环境,建议一开始就前后端分离。

1.3 系统模块划分

整个系统我拆成了几个清晰的域:用户域负责注册、登录、个人信息维护;商品域负责发布、浏览、搜索、下架;交易域负责下单、订单状态变化、订单查询;内容域负责收藏、评论、浏览记录;外加一个简单的后台管理域,用来做用户封禁和商品删除。

模块划分的时候我坚持了一个原则:交易相关的状态判断必须收敛到交易域里,其他域不能直接改订单状态。比如商品被删除后,订单数据不能跟着消失,订单详情需要保持完整性。这个原则在数据表设计上体现得很直接,下面细说。

2. 数据库设计与表结构拆解

2.1 用户表:字段设计、密码存储与头像处理

用户表是所有业务的基础,设计这里最容易犯的错误就是字段一把抓。我最终保留了核心字段:用户名、密码、昵称、邮箱、手机号、角色、头像地址、状态、注册时间。用户名做唯一索引,登录账号就用用户名或者邮箱都行。这里要注意,很多学校有学号登录的需求,但学号涉及个人敏感信息,不建议作为系统登录主键,宁可让用户自己填邮箱去注册。

密码存储必须加密。项目里用了 BCrypt 算法,同样的密码每次加密结果都不同,能抵抗彩虹表攻击,而且验证方法自带盐值处理。千万不要为了省事用 MD5 直接存,这个我在实测中见过太多项目翻车。用户头像我采用了本地存储路径方案,数据表里存的是相对路径,而不是完整 URL,这样换服务器域名时不用改数据库。

角色字段我用最简单的方式:1 普通用户,2 管理员。没有引入复杂的 RBAC 权限框架,因为这个系统的权限边界非常清晰,管理端只有商品审核和用户管理两类操作,自己写个注解判断角色就够了,引入框架反而增加理解成本。

2.2 商品表:核心字段、图片存储方案与索引策略

商品表是整个系统的信息中枢,字段设计直接影响页面展示和搜索效果。我的商品表核心字段包括:卖家 ID、标题、描述、分类、价格、原始价格、成色等级、图片、商品状态、浏览量、创建时间。成色等级我用了枚举数字,0 全新、1 几乎全新、2 轻微使用痕迹、3 明显使用痕迹,这样列表页可以直接做筛选,比让用户填“几成新”规范得多。

价格字段必须用整数分存储。Java 后端用 BigDecimal 计算,数据库存 decimal(10,2) 也行。但我要提醒一点,涉及比较运算时,一定要用 BigDecimal 而不是 double,否则会出现 0.1 加 0.2 不等于 0.3 这类诡异问题。这是真实生产环境里很基础的坑。

商品图片我采用了逗号拼接多个 URL 的方式存在一个字段里。有人会建议单独建图片表,其实对于校园项目来说完全没必要,一张商品图的数量不会超过 5 张,拆表只会让查询多一次关联。真正需要注意的反而是遍历时对数组做空值判断。

索引是商品表的重头戏。我在分类、状态、创建时间这几个字段上建了联合索引,查询条件常用“分类 + 在售状态 + 按时间排序”,联合索引能大幅度减少扫描行数。表关系我没用物理外键,理由有三点:一是外键会影响写入性能,二是校园二手数据本身一致性要求没那么高,三是物理外键在分库分表场景下基本没有意义。业务层做逻辑关联,删商品时手动检查有没有未完成订单,这种控制方式更灵活。

2.3 订单表:快照设计、状态字段与流水号生成

订单表最能体现一个交易系统的设计功底。我的订单表包含:订单号、商品 ID、商品快照标题、商品快照图片、商品快照价格、卖家 ID、买家 ID、订单状态、创建时间、完成时间。

为什么要做“快照”?因为订单一旦生成,这单交易的商品信息必须固定下来。如果买家 9 点下单,卖家 10 点把商品标题改成“已出”,订单详情页显示的还是下单那一刻的内容,这才符合交易凭证的逻辑。快照字段在插入订单时从商品表拷贝过来,之后绝不更新。

订单状态我设计了四个:待买家确认收货、交易成功、已取消、退款处理中。线下交易场景下,没有真实的支付回调,所以“待买家确认收货”这个动作就等价于确认交易完成。卖家和买家在状态流转上的权限不同,后面说状态机时再展开。

订单号我不用自增 ID,而是单独生成了一个业务订单号,规则是“yyyyMMddHHmmss + 随机数”。因为自增 ID 会暴露系统销量信息,而且并发插入时容易被人遍历订单。订单号在数据库做了唯一索引,防止同一个商品被同一买家瞬间创建两条重复订单。

2.4 辅助表:收藏、浏览记录与评论设计

收藏表很简单,核心就是用户 ID 和商品 ID 的唯一联合索引,保证同一个用户不能重复收藏同一件商品。浏览记录表字段是用户 ID、商品 ID、浏览时间,查询时只取该用户最近的 20 条。

评论表我用了自关联设计,parent_id 为 0 表示一级评论,不为 0 时表示回复某条评论。这种设计在校园二手场景足够用,既能把评论和回复放在一起展示,又不至于上到复杂的树形结构。评论归属到商品而非订单,因为用户对商品的真实评价可能发生在交易后,也可能发生在浏览后,绑定订单反而限制了场景。

3. 核心功能实现与关键环节

3.1 注册登录与登录拦截

注册接口的逻辑很直白:先校验用户名是否被占用,再校验两次密码是否一致,最后把密码用 BCrypt 加密后插入。真正要上心的是登录成功后的 token 签发和接口鉴权。

我用 jjwt 库生成 token,密钥写在配置里,并给 token 设置 24 小时过期时间。登录成功后把用户 ID 和角色塞进 token 的 claim 里,前端拿到 token 存在本地,每次请求在 Authorization 头里带回来。

拦截器是这里最容易出错的地方。我写了一个 LoginInterceptor,它实现了 HandlerInterceptor,在 preHandle 里检查请求头有没有合法 token。拦截器注册时要调用排除路径,否则用户还没登录就访问登录、注册、商品浏览这些公开接口,全都被拦截下来。我一开始没加排除路径,结果前端同事调注册接口直接 401,这种问题排查起来非常浪费时间。

配置类里注册拦截器的代码大致如下:

@Configuration public class WebConfig implements WebMvcConfigurer { @Resource private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/**", "/api/product/list", "/api/product/detail/**", "/api/file/**"); } }

实际开发时,还可以把管理员接口单独做一个 AdminInterceptor,校验角色字段是不是管理员的枚举值。两个拦截器链式注册,代码结构很清晰。

3.2 商品发布、图片上传与访问映射

商品发布涉及两个动作:图片上传和商品信息入库。图片上传我单独做了个文件接口,接收 MultipartFile,校验文件大小和扩展名,然后用 UUID 重命名文件,避免上传两个相同名字的文件产生覆盖。

保存路径没写死,而是放在 application.yml 配置里,开发环境存本地目录,部署时改成服务器上的绝对路径。这一步如果写死,后面换服务器要改源码重新打包,非常痛苦。

文件保存完后返回相对访问路径,例如 /files/202409/abc123.jpg。商品接口把这个路径和其他信息一起提交。前端展示时直接拼接图片域名访问。

这里有一个容易踩的坑是静态资源映射。Spring Boot 默认不把你自定义的文件目录映射成可访问 URL,需要在配置类里手动加上:

@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadPath + "/"); }

这句话的意思是把 URL 中以 /files/ 开头的请求,映射到服务器磁盘上的 uploadPath 目录去读取文件。忘记配置的话,图片上传成功但访问永远 404。

图片上传接口还有个细节:文件大小限制。Spring Boot 默认单文件大小 1MB,可能不够用。我在配置里调到了 10MB。但也不要开太大,否则容易被恶意上传大文件打满磁盘,务必做扩展名白名单校验,图片只允许 jpg、png、webp。

3.3 商品列表检索与分页

商品列表是访问量最高的接口。我的检索条件支持分类、价格区间、成色等级、关键字、排序方式。关键字搜索这里我是用 title LIKE '%关键字%' 实现的,配合商品表不算大的前提,完全够用。如果以后商品量到几十万条,再考虑引入搜索引擎或者数据库全文索引,现阶段不必过度设计。

分页用 MyBatis-Plus 的分页插件,传页码和每页条数,插件会自动生成 limit 语句。返回对象里除了当前页数据,还附带总计数量和总页数,前端分页器需要的字段都有。

这里要注意排序字段做参数白名单。用户传什么排序字段我就拼接什么,会被 SQL 注入或者传进一个不存在的列名导致全表扫描。我做了严格校验,只允许 created_time 和 price 两个白名单字段,排序方向只允许 asc 和 desc。这个安全习惯建议一开始就养成。

详情页还要做浏览量自增,我的做法是点击详情时执行 UPDATE 语句让 view_count 加一,同时把商品详情返回。浏览量对二手排序有帮助,同类的还有一个“人气商品”功能,本质上就是按浏览量倒序查 Top N。

3.4 下单购买与并发防超卖

校园二手系统最典型的并发场景,是多个用户同时看中同一件商品并点击购买。如果按普通思路“先查询商品状态,再判断是否可下单,最后更新状态”,并发情况下非常容易出现同一件商品被卖出多次的脏数据。

正确做法是把判断和更新合并成一条原子 SQL:

@Transactional(rollbackFor = Exception.class) public void createOrder(Long productId, Long buyerId) { Product product = productMapper.selectByIdForUpdate(productId); if (product == null) { throw new BizException("商品不存在"); } if (product.getStatus() != ProductStatus.ON_SALE) { throw new BizException("商品已下架或已被拍下"); } int updated = productMapper.updateStatusWithLock(productId, ProductStatus.ON_SALE, ProductStatus.LOCKED); if (updated == 0) { throw new BizException("手慢了,商品已被其他同学拍下"); } Order order = buildOrder(product, buyerId); orderMapper.insert(order); }

这段代码有两个关键点。第一,select 用了 FOR UPDATE 悲观锁,把商品行锁住,其他事务在这个商品上会排队等待。第二,更新时用「状态条件 + 状态修改」控制并发,只有状态还是在售时才能改成已锁定,影响行数是 0 就说明冲突了。

事务方法里先锁后改,配合数据库乐观式条件更新,双保险。这里强调一下:不要在一开始就用“查询后再 update”这种写法,哪怕加了事务也会有幻读风险,必须让数据库来裁决状态变更的合法性。

3.5 交易状态机与用户操作边界

交易状态机是整个业务逻辑最核心的部分。我理清了商品状态和订单状态的对应关系,并把每个状态转换的触发条件表写进开发文档,避免业务代码里出现随意改状态的逻辑。

商品状态我设计了三个:0 在售、1 已锁定、2 已售出、3 已下架。当买家下单成功后,商品状态变成已锁定,防止其他人再下单。当订单确认交易成功后,商品状态变成已售出。当卖家主动下架商品,商品状态变成已下架,此时不能下单。

订单状态和商品状态联动,但不能等同。订单的状态表我在前面讲过,重点是权限划分:买家可以取消“待确认收货”状态的订单,卖家可以在“已锁定但未收款”时取消订单;一旦买家点击确认收货,订单进入成功状态,双方都不能再改。退款场景我单独处理成售后流程,不放在主状态机里。

这样设计是因为线下交易的特殊性。没有支付平台做仲裁,交易主要靠买卖双方自觉。状态机把每一步操作都记录清楚,万一产生纠纷,后台管理员能查得到是谁在什么时间改了状态,这其实比复杂的信用体系更实际有效。

4. 常见问题与排查技巧实录

4.1 图片上传成功但访问 404

这个坑几乎每个实践项目都会遇到。上传接口返回了文件路径,浏览器打开却是 404。排查方向其实很固定,先看文件是否真的保存在了服务器目录,再看 Spring 静态资源映射有没有配置。很多时候是配置文件里的路径最后忘了加斜杠,导致资源定位器拼接路径出错。Linux 部署时还要注意目录读写权限,建议统一用一个独立用户运行服务,目录权限设置成 755 或 750,避免权限问题。

4.2 多人同时下单导致同件商品被重复购买

没有并发控制时,两个用户几乎同时提交订单,后端两个线程都查询到商品在售,又都插入订单成功,数据库里就出现两条有效订单。这是典型的先查后写导致的竞态条件。

排查手段很简单,看一下同一时间段内的订单数据,发现同商品出现两条待收货订单基本就实锤了。解决办法就是上面说的锁行加条件更新。另外,订单表加上了(买家 ID,商品 ID)这个业务唯一约束作为最终兜底,就算代码再出问题,数据库也能挡住重复下单。这个双保险策略我觉得是所有交易系统都应该有的底线。

4.3 登录状态莫名其妙失效

JWT 登录失效多数情况不是 token 真的过期了,而是前端没有正确传递请求头,或者拦截器把应该公开的接口也拦了。我遇到过很离谱的情况:前端上传图片时请求没走统一封装,导致 Authorization 头丢失,接口一直返回 401。排查技巧是打开浏览器开发者工具,看请求网络面板里实际发出的请求头。

有一次我排查了很久,最后发现是配置了多个拦截器,静态资源路径没放行,图片加载的请求也被当成了未登录状态。这里提醒大家,上传图片的 /files/ 路径务必在拦截器排除列表里加上,否则会出现登录状态没问题、图片就是加载不出来的诡异现象。

还有 JWT 密钥泄漏的问题。密钥一定要放在配置文件外部,通过环境变量注入,不能提交到代码仓库。否则别人拿到密钥就能伪造任意用户的 token,这个风险级别的漏洞在实际部署时不可接受。

4.4 前后端联调时的格式与跨域问题

前后端分离开发时,最容易出现的是后端返回的数据格式跟前端预期不一致。比如时间字段后端返回的是 LocalDateTime 对象,Spring 默认序列化成 ISO 格式,但前端想显示的格式是 yyyy-MM-dd HH:mm:ss,经常出现解析失败。解决方法是全局配置 Jackson 的日期格式,写在 application.yml 里即可。

还有 Laravel 或其他前端框架下,Long 类型主键超过 JavaScript 安全整数范围时,数字精度会丢失。解决办法是把主键字段改用 String 序列化,或者在生成方案上直接使用雪花 ID(数据表里用 BIGINT,但接口返回时转成字符串)。我在项目里用了雪花 ID 配合 Jackson 全局配置,把所有 Long 都转成 String 返回,就不会碰到前端比较 ID 时发现末尾几位变成 0 的问题。

跨域问题主要是 CORS 配置。开发环境前端跑在 5173,后端跑在 8080,浏览器默认禁止跨域请求。我在后端配置了跨域过滤器,允许来源填写前端实际域名,注意不要用通配符,否则带 token 的请求会在预检阶段报错。这个踩坑点应该算前后端分离项目的标配问题了。

我整理了一份排查速查表,方便你对照定位问题:

现象高频原因处理建议
登录后接口仍返回 401拦截器未放行静态/公开路径检查排除路径和请求头
图片上传后 404缺少资源映射配置检查 addResourceHandlers
下单重复出现两条订单先查后写无锁控制改为锁行+条件更新
时间字段显示成英文格式Jackson 未配置时区application.yml 配日期格式
Long 主键前端信息丢失JS 超精度Long 转 String 返回
接口跨域请求失败CORS 配置不当配置白名单 + 允许请求头

5. 打包部署与配置细节

5.1 配置文件中的几个容易忽略的坑

application.yml 里藏着大量影响系统运行的细节。数据库连接串必须加上 serverTimezone 参数,并配置成 Asia/Shanghai,否则连接数据库时会报时区错误。连接池建议用 HikariCP,它集成在 Spring Boot 里,只需要调整最大连接数和连接超时时间。

上传文件的目录千万不要放在打包后的目录内部,否则每次重新部署都可能被覆盖。我习惯放在 /var/data/campus-secondhand/ 下,源码和运行时数据彻底分离。日志文件也是同理,配置 logback 时把日志目录放到固定路径,并设置按天滚动和最大保留天数,避免日志把磁盘撑爆。

生产环境我还给 JWT 密钥、数据库密码单独配置了环境变量,application.yml 里使用 ${JWT_SECRET} 这种占位符,安全性和可维护性兼顾。

5.2 用打好的 jar 包上线运行

后端打包成 jar 部署非常简单,构建命令是:

mvn clean package -DskipTests

打包成功后 target 目录下会生成可执行 jar,上传到服务器,再执行:

nohup java -jar campus-secondhand.jar --spring.profiles.active=prod > app.log 2>&1 &

命令里的 nohup 保证进程退出终端后继续运行。第一次跑起来后,先看日志有没有启动成功,再检查端口是否监听:

curl http://localhost:8080/api/product/list

能返回 JSON 数据基本就说明服务正常了。生产环境我更推荐用 systemd 管理进程,这样服务崩溃后能自动重启,配置也就多写一个 unit 文件的事,属于长期受益的投入。

数据库迁移也是个容易踩坑的点。开发环境我用了 MyBatis-Plus 的自动建表功能,但生产环境强烈建议手工执行 SQL 脚本,脚本按版本命名并记录在项目目录里,这是最朴素也最可靠的迁移方式。

5.3 后续可以继续做大的几个方向

这套系统做到后面,如果你想让项目简历上更有亮点,可以朝几个方向扩展。第一个是引入 Redis 做热点数据缓存,把热门商品列表和商品详情缓存起来,查询 QPS 能提升不少。第二个是引入消息通知机制,买家下单后卖家立刻收到通知,这个可以用 WebSocket 实现,比短信和邮件更轻量。第三个是增加信用评价体系,交易完成后买卖双方互相打标,让学长学姐的账号有更可信的口碑背书。

搜索功能目前用的是数据库模糊查询,商品量超过十万条之后,会明显出现慢查询。到那个阶段再引入搜索引擎,或者至少用上 MySQL 全文索引。不过这些都是后话,先把基础系统的业务闭环做完整、把并发和数据一致性处理干净,这个积累过程比堆功能重要得多。

我做完这个系统后最大的感受是:一个看似简单的校园二手平台,真正动手起来考验的是你对数据模型、事务边界、状态流转的把握能力。很多问题不在代码本身,而是在你愿不愿意先把业务流程想透。遇到并发下单、状态权限混乱这些问题时,多从数据库层面找原因,往往比在 Service 层死磕更高效。这套系统最后能被完整跑起来,靠的就是一遍一遍梳理边界条件和异常场景。如果你也在做类似的项目,建议你也把自己最常踩的坑记录下来,这些才是真正属于你的项目经验。

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

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

立即咨询