简介:在Java后端开发中,SpringBoot凭借自动配置机制大幅降低了环境搭建成本,已成为构建企业级应用的主流框架。而校园二手交易平台作为典型的业务闭环项目,天然覆盖用户认证、商品状态机、订单并发控制等核心场景,非常适合用来理解框架原理与工程实践的配合。围绕这一主题,文章从业务模块拆分出发,梳理了用户、商品、交易三大核心域的表结构设计方法,强调了价格用分存储、状态流转、事务边界等细节;同时深入解析了基于乐观锁的防超卖下单逻辑,以及JWT鉴权、文件上传、Docker部署等落地技术。对于正在准备毕业设计或希望提升SpringBoot实战能力的开发者,这套完整方案既提供了可复用的数据库设计方法论,也给出了从源码到可运行系统的避坑指南。 做毕设选题的时候,十个Java方向的人里有九个会刷到“校园二手交易平台”,这句话毫不夸张。但真正把源码下载下来能顺利跑起来、还能跟面试官讲清楚技术点的人,十个里未必有一个。我做过一套基于SpringBoot的校园二手交易平台,源码和数据库设计都完整整理过,这中间踩了不少坑——SpringBoot版本兼容、MySQL表结构反复推倒重来、并发下单一不小心就超卖。这篇文章就把这套项目的经验从头梳理一遍,不堆砌源码文件,而是把模块划分、数据库设计、核心代码、部署坑点,以及面试怎么答都讲透。
无论你是刚学完Java基础想找项目练手,还是准备用SpringBoot做毕业设计,或者想从“会抄代码”进步到“能讲设计”,这篇都值得认真看完。
1. 为什么这个项目比“合租系统”“博客系统”更适合做Java练手项目
1.1 校园二手交易的业务闭环决定了它的技术含量
很多人选毕设题目时,会考虑图书管理系统、博客系统、在线商城。这些系统确实简单,但做完之后你会发现,它们大多只是“多个表的CRUD”,没有多少业务逻辑可讲。校园二手交易平台不一样,它有自己的完整业务闭环:学生注册后进行校园身份认证;认证通过后可以发布闲置;买家通过搜索、分类、收藏找到商品;买卖双方经过站内聊天沟通细节;买家下单后走站内余额模拟支付;卖家发货、买家确认收货;交易完成后双方可以互相评价;如果出现问题还有举报和申诉流程;管理员在后端进行商品审核、用户封禁、数据统计。
这套流程决定了项目不是一堆孤立的表,而是各个模块相互咬合的状态流转。比如商品不是删掉就消失,而是有草稿、待审核、在售、预订、已售、强制下架的状态路径;订单不是简单的insert,而是先检查商品是否在售,再乐观锁更新商品状态,最后创建订单、扣减余额。这些逻辑写下来,才是真正的“业务开发”。
1.2 为什么要选SpringBoot+MyBatis-Plus这套组合
选型的理由很多人答不上来。我的看法是,SpringBoot的价值不是“快”,而是它通过自动配置把环境差异收敛掉了。你在A机器上能跑,换到B机器也能跑,这对训练项目和实际交付太重要了。MyBatis-Plus把单表CRUD封装好了,写起来比原生MyBatis少很多代码,但它不是万能药,像订单状态变更、库存扣减这种强一致性的写操作,我都是写自定义SQL加乐观锁,完全依赖BaseMapper是不合适的。
我在这套项目里的推荐组合是:SpringBoot 2.7.18 + JDK 8 + MySQL 8.0 + MyBatis-Plus 3.5.3 + Redis + MinIO + JWT。这套组合偏保守,但稳定。后面章节我会专门说为什么不要一上来就追SpringBoot 3.x。
2. 模块拆解:先画清楚“人、货、单”三个核心域
2.1 用户域:不是简单地建一张user表
用户模块最容易被做成一张只包含账号密码的user表,我见过很多半成品就是这么干的。但校园二手平台有个特殊点:用户需要证明自己是该校学生。于是在user表外,我还设计了一张student_certification表,用来存学号、姓名、所在院系、学生卡图片URL、认证状态。user表本身只放通用登录字段,认证信息单独放,原因是认证是一次性的低频操作,拆开既能减少user表宽度,也方便后台审核时只查认证表。
user表核心字段:id、username、password、nickname、avatar、phone、role(0普通用户,1管理员)、status(0禁用,1正常)、points(信誉积分)、create_time、update_time。密码存的是BCrypt加密后的字符串,不是明文。这里有一个容易被忽略的点:用户状态和登录逻辑要联动。如果账号被管理员禁用,登录时不能只查用户名和密码,还要校验status字段,否则被处罚的用户依然可以正常登录。
2.2 商品域:发布到下架的完整状态机
商品域的灵魂是状态。如果你的goods表里只有一个status字段,但代码里没有一套状态流转逻辑,那和普通文章表没有区别。我的状态设计为:0草稿、1待审核、2在售、3预订中、4已售出、5下架、6管理员强制下架。为什么要有待审核?因为不是所有学生都会规范发布商品,后台管理员需要审核图片和标题,防止出现违规品类。为什么要区分“预订中”和“已售出”?因为线下交易存在“我先预订,然后当面交付”的环节,没有这个状态,用户并发下单就控制不住。
商品表字段包括id、user_id(发布者)、category_id、title、description、price(单位分)、original_price、quality(成色)、image_urls(JSON数组存图集)、status、stock_version(乐观锁版本号)、view_count、create_time、update_time。价格我建议统一用“分”来表示,不要用double,否则订单金额计算会出现浮点误差,这是个老生常谈但很容易踩的坑。
2.3 交易域:二手交易不是标准电商
订单是所有模块里最容易出错的。二手交易和标准电商最大的差异是:库存永远是1,不允许超卖。标准电商可以扣减库存,二手场景则应该是“下单即锁定商品”,成功创建订单后,商品状态从在售变为预订中,其他人不能再下单。所以order表不能单纯当作订单记录表,它实际承担了商品状态的“触发器”。
订单表字段:id、order_no、goods_id、buyer_id、seller_id、amount(单位分)、status(0待支付,1待发货,2待收货,3已完成,4退款中,5已关闭)、pay_type(0站内余额,1线下交付)、pay_time、deliver_time、receive_time、close_time、remark、create_time。另外还要一张trade_record表,用来记录每次余额扣减和收入增加,类似流水账,对账和排查问题都用得上。
3. 数据库设计:9张核心表+4张扩展表,表和表之间的业务逻辑要能讲明白
3.1 核心表的结构设计与建表SQL
数据库设计不能一上来就建表,而是先把业务对象拆成“主表+子表+状态+流水”四个层次。以订单为例:orders是主表,记录一次交易的核心内容;订单操作记录是流水表,记录状态变更历史;如果涉及退款,还要有refund_record表。下表是我归纳出的核心表清单:
| 表名 | 职责 | 关键字段 |
|---|---|---|
| user | 账号登录与基本信息 | username、password、role、status |
| student_certification | 学生认证资料 | user_id、student_no、campus_card_url、auth_status |
| goods_category | 商品分类 | name、parent_id、sort |
| goods | 商品信息 | user_id、category_id、status、stock_version |
| goods_image | 商品图片 | goods_id、url、sort |
| orders | 订单主表 | order_no、goods_id、buyer_id、seller_id、status |
| trade_record | 余额流水 | user_id、type、amount、balance_before、balance_after |
| favorite | 收藏 | user_id、goods_id、create_time |
| message | 站内私信 | from_user_id、to_user_id、content、is_read |
这里给出goods表的建表SQL,注意索引和注释:
CREATE TABLE `goods` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '发布者ID', `category_id` bigint(20) DEFAULT NULL COMMENT '分类ID', `title` varchar(120) NOT NULL COMMENT '标题', `description` text COMMENT '描述', `price` bigint(20) NOT NULL COMMENT '价格,单位:分', `original_price` bigint(20) DEFAULT NULL COMMENT '原价,单位:分', `quality` tinyint(4) DEFAULT NULL COMMENT '成色1-10', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0草稿 1待审核 2在售 3预订中 4已售出 5下架 6强制下架', `stock_version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', `view_count` bigint(20) DEFAULT '0' COMMENT '浏览量', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`,`status`), KEY `idx_user_id` (`user_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';注意几个设计细节:
- 价格用bigint存“分”,不用decimal,避免MyBatis里类型转换麻烦,也方便Long接收
- status加上category_id联合索引,因为首页分类展示是最常见的查询场景
- 图片不单独建表,用goods.image_urls字段存JSON数组,减少一次关联查询;如果对图片查询很频繁再拆表
3.2 扩展表:留言、举报、通知、浏览记录
核心表之外,要支撑完整业务闭环,还需要四张扩展表。
- message表(站内私信):from_user_id、to_user_id、content、is_read、create_time。站点内聊天时,需要根据from_user_id和to_user_id查会话记录,这里可以加联合索引(from_user_id, to_user_id, create_time)
- report表(举报):target_type(举报商品还是评论)、target_id、reason、status、handle_user_id、handle_time。后台管理员处理举报后,状态要能回溯
- notice表(通知):user_id、title、content、type、is_read。比如订单状态变化、审核结果,都要推通知给用户
- browse_record表(浏览记录):user_id、goods_id、browse_time。用浏览记录做“猜你喜欢”时,查询最近N条记录
这些扩展表不一定每个页面都用到,但设计时先留好,回头加功能时就不用再动大表。我的习惯是:凡是需要追溯历史的信息,都单独建流水表,不要在原表上改得面目全非。
3.3 索引和事务设计:索引不是越多越好,事务也不是越宽越好
很多初学者拿到表结构后,把所有字段都加一遍索引,这是不对的。索引会降低写入速度,占用磁盘空间。这个项目里真正热点查询是:
- 商品列表按分类、状态、时间排序
- 商品标题模糊搜索
- 订单按买家或卖家查列表
- 交易流水按用户查
对应的索引就是goods(category_id, status, create_time)、goods(title)全文索引(或简单用LIKE)、orders(buyer_id, status)、orders(seller_id, status)、trade_record(user_id, create_time)。注意不要给description加索引,大字段加索引没意义。
事务设计更重要。下单这个操作,需要把“更新商品状态”“创建订单”“扣减买家余额”“增加卖家余额”“写入交易流水”放在同一个事务里,任何一个失败都要回滚。另外,事务不是包得越宽越好。查询操作不要加@Transactional,否则数据库连接占用时间太长,高并发时会把连接池打满。
4. 核心源码解析:把登录、商品发布、下单这三段代码吃透
4.1 JWT登录鉴权的完整链路
登录模块我用JWT做无状态鉴权。服务端不保存Session,客户端每次请求在Header里带一个Authorization: Bearer token,拦截器校验token合法性,并从token里取出当前用户ID和角色。这样做的优点是方便前后端分离,缺点是需要自己处理token过期和续期。
JwtUtil核心代码:
public class JwtUtil { private static final String SECRET = "your-secret-key"; public static String generateToken(User user) { return Jwts.builder() .setSubject(user.getId().toString()) .claim("username", user.getUsername()) .claim("role", user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 3600_000)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Long getUserId(String token) { Claims claims = Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); return Long.valueOf(claims.getSubject()); } }拦截器里要注意两点:一是白名单放行,比如登录、注册、商品浏览、图片访问不需要token;二是用户被禁用后已经发出的token要立即失效。我的处理方式是在Redis里存一个黑名单,管理员封禁用户时把该用户的token版本号加1,拦截器里比对版本号,不匹配就拒绝访问。
4.2 商品发布的实现:文件上传与参数校验
商品发布有两个核心点:图片上传和字段校验。图片上传我用MinIO做对象存储,不把图片文件存到数据库。MinIO兼容S3协议,本地部署很方便,和OSS相比没有外网依赖。上传流程是:前端用MultipartFile上传图片,后端把文件流转成输入流写入MinIO,返回图片URL,然后把URL数组存入goods.image_urls字段。
代码示例:
@PostMapping("/goods") public Result addGoods(@RequestBody @Valid GoodsDTO dto, @RequestHeader("Authorization") String token) { Long userId = JwtUtil.getUserId(token); goodsService.addGoods(userId, dto); return Result.ok(); }GoodsDTO里用javax.validation注解做入参校验,比如@NotBlank(message = "标题不能为空")、@NotNull(message = "价格不能为空")。这里有个小经验:不要相信前端传的任何值,特别是status。用户提交商品时status只能固定为待审核,不能让前端传什么就存什么,否则可以绕过审核直接上架。
4.3 下单防并发:乐观锁把“库存为1”的坑填平
下单是这个项目里最有技术含量的方法。前面说过,二手商品库存永远是1,所以不能用常规的“先查库存再扣减”,否则并发请求同时通过检查就会超卖。我的方案是使用自定义SQL加乐观锁:
@Transactional(rollbackFor = Exception.class) public Order createOrder(Long goodsId, Long buyerId) { Goods goods = goodsMapper.selectById(goodsId); if (goods == null || !goods.getStatus().equals(GoodsStatus.ON_SALE)) { throw new BizException("商品不存在或不在售"); } int rows = goodsMapper.lockStock(goodsId, goods.getStockVersion()); if (rows == 0) { throw new BizException("手慢了,商品已被下单"); } // 生成订单号、创建订单、扣减买家余额、增加卖家余额 // 记录交易流水 return order; }对应的Mapper XML:
<update id="lockStock"> update goods set status = 3, stock_version = stock_version + 1 where id = #{goodsId} and status = 2 and stock_version = #{stockVersion} </update>这段SQL的作用是:只有当商品仍然是“在售”状态且版本号没有变化时,才会更新成功。update影响行数为0,说明商品已经被别人下单或状态已变化。我之所以不用select for update悲观锁,是因为同一件商品并发下单的冲突概率其实不高,乐观锁在绝大多数情况下不会冲突,对数据库的压力更小;而悲观锁会让所有请求都排队等待,商品详情页也可能被锁影响。
5. 从源码到可运行:SpringBoot版本、MySQL初始化、Docker部署这些坑一次说清
5.1 环境选型:JDK8还是17?SpringBoot 2.7.x还是3.x?
我见过很多新手用IDEA创建项目时默认生成SpringBoot 3.x,然后引入老教程的MyBatis-Plus和各类工具,结果就是一堆jar包冲突。SpringBoot 3.x基于Jakarta EE,之前写javax.validation、javax.servlet的地方全部要改成jakarta.*,MyBatis-Plus必须用3.5.5以上版本,很多老博客里的写法直接编译不过。
所以个人建议:做校园二手交易这个体量的项目,直接选择SpringBoot 2.7.18,JDK用8或11。它支持JDK17,但不用新特性就不影响。等以后真正需要升级再迁,现阶段没必要给自己增加“迁移类错误”的排查负担。
5.2 MySQL初始化和application.yml的完整配置
数据库初始化时要注意,如果MySQL是8.0,连接URL必须加上时区和SSL参数,否则启动会报错。我的application.yml关键配置如下:
spring: datasource: url: jdbc:mysql://localhost:3306/campus_secondhand?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl jwt: secret: your-secret-key expire: 3600000启动前先执行CREATE DATABASE campus_secondhand DEFAULT CHARACTER SET utf8mb4;,再导入SQL脚本。这里容易踩的一个坑是数据库字符集没设utf8mb4,导致发布商品时中文表情符号存不进去。
5.3 Docker部署和常见报错
Docker部署SpringBoot项目其实很简单,本质是打成一个jar包再丢进容器。我给一个最简Dockerfile:
FROM openjdk:8-jdk-alpine VOLUME /tmp ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar EXPOSE 8080 ENTRYPOINT ["java","-jar","/app.jar"]构建命令:
mvn clean package -DskipTests docker build -t campus-secondhand:1.0 . docker run -d -p 8080:8080 --name campus campus-secondhand:1.0下面是我排查过程中经常遇到的几个问题:
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
The server time zone value is unrecognized | MySQL连接URL缺少时区参数 | 在url后加serverTimezone=Asia/Shanghai |
Failed to configure a DataSource | 启动时没有找到数据源配置 | 检查application.yml是否在classpath,数据库是否已启动 |
java.io.FileNotFoundException: /tmp/... | 容器内没有上传目录权限 | Dockerfile里加RUN mkdir -p /data/upload并挂载volume |
OutOfMemoryError: insufficient memory | 容器内存限制 | 启动命令加-Xmx512m |
6. 这套代码藏在SpringBoot框架里的面试考点(防八股问倒)
6.1 自动配置到底是怎么回事
“SpringBoot为什么能自动配置”是必问题。套用这个项目回答最合适:项目里引入了spring-boot-starter-data-redis,SpringBoot通过@EnableAutoConfiguration去读取META-INF/spring.factories或AutoConfiguration.imports里的自动配置类,再配合@ConditionalOnClass(RedisOperations.class)、@ConditionalOnMissingBean这类条件注解,自动帮我们创建RedisTemplate、StringRedisTemplate这些Bean。
表面上是“自动配置”,本质上还是条件和工厂的结合。如果你需要定制,可以在自己的配置类里声明一个RedisTemplate的Bean,@ConditionalOnMissingBean检测到已经存在,就跳过自动创建。这段逻辑平时不显眼,但理解了之后,排查“为什么我配置没生效”会快很多。
6.2 项目中哪些地方用到了AOP和事务
事务用的是@Transactional。面试官特别喜欢问“事务失效场景”,我在项目里实际踩过:createOrder方法调用本类另一个事务方法,结果内部方法的@Transactional没生效。原因很简单,Spring的事务是通过AOP代理实现的,同类调用走的是this而不是代理对象,所以代理切面根本没执行。解决办法要么把内部方法拆到另一个Service,要么用AopContext.currentProxy()。
AOP除了事务,我还用它做管理员操作审计日志。比如审核商品、处理举报这类后台操作,定义一个自定义注解@AdminLog,再写一个@Aspect切面,在方法执行前后记录操作人、操作类型、操作参数、耗时。这样比在每个Controller里手写日志要清爽得多,也展示了你对AOP的理解。
6.3 表设计通用方法论,和WMS数据库设计是同一个套路
有人问“WMS系统怎么设计数据库表”时,我意识到其实和二手交易平台设计是同一套方法论。可以把“系统里所有重要业务对象”拆成四层:
- 主表:记录核心对象及其当前状态,例如订单表、入库单表
- 子表/明细表:记录核心对象包含的具体条目,例如订单明细、入库单明细
- 流水表:记录所有历史变更,例如交易流水、库存变动流水
- 唯一业务单号:每张主表都生成一个全局唯一的业务编号(order_no、入库单号),并建立唯一索引,用于幂等和问题追溯
这套方法论不是只能用在校园二手平台,WMS里的库存管理、ERP里的订单处理,本质上都是这样一层一层拆出来的。聊到这个,不仅能体现你会写SQL,还能体现你对系统设计的思考深度。
7. 持续迭代方向:不要把项目停在“演示可用”
7.1 什么情况下不需要拆微服务
很多人做完单体项目就想往上堆Nacos、Gateway、Feign,觉得微服务显得“高级”。我也曾经这么做过,但最后发现,没有千万级用户、没有多团队并行开发,微服务带来的服务治理成本远大于收益。校园二手交易平台用单体架构完全合理,真正需要演进的方向不是拆服务,而是把单体的代码质量和可观测性做好,比如加接口限流、日志采集、慢SQL监控。
7.2 想真正落地,还需要补什么
要让这套系统在学校真正投入使用,至少要补齐这几块:
- 真实支付:微信/支付宝需要商户资质,校园场景可以用模拟支付演示,但代码里要预留支付回调接口
- 消息推送:下单后需要通知卖家,可以用RabbitMQ做异步通知,避免下单响应时间被邮件或短信拖慢
- 内容审核:发布商品时图片和文字需要先经过云服务审核,防止违规内容
- 数据脱敏:管理员查看用户列表时,手机号、学号不能明文展示
- 接口防刷:用Redis实现滑动窗口限流,避免商品详情和登录接口被恶意刷
最后分享一个很实际的调试技巧。下单并发是一个容易翻车也特别容易出彩的测试点,在本地启动项目后,用JMeter建20个线程同时抢同一件商品,观察请求返回和数据库里该商品的stock_version、status变化。我第一次跑的时候出现了两条订单同时成功的现象,后来正是在这条复现路径上定位到问题——更新SQL漏了status=2条件,导致已经预订的商品还能被update。很多表面上“有源码就完事”的项目,恰恰在这种并发细节里见高下。
本文还有配套的精品资源,点击获取