每年的毕业季,大学宿舍楼下就像一场没有组织方的临时集市。教材五块钱一本,吉他五十块推走,可这些东西挂在闲鱼上一个月都未必卖得掉——同城跨校见面太难了,运费比货贵。反过来,新生入学买全新的书桌、台灯,又花掉不少冤枉钱。校园里缺的不是二手货,缺的是一个让同校学生能直接碰面交易的通道。这也是为什么 springboot 校园闲置物品交易管理系统这类题目常年出现在毕业设计清单上——它既踩中了真实需求,又恰好能把 Spring Boot 的核心能力完整练一遍。
这篇文章不是给你凑代码量的模板,而是从选题、版本选型、建表、写接口到部署的完整拆解。我会把每个关键决策背后的原因讲清楚,包括那些会把你卡住一整天的坑。适合准备做类似课题的学生,也适合想快速了解 Spring Boot 完整业务闭环的开发者。内容偏实战,能直接照着做。
1. 选题价值与技术闭环:为什么校园闲置交易平台适合做成 Spring Boot 项目
1.1 校园场景下的真实供需错配
做毕设最容易犯的错,是选一个自己都说服不了自己的题目。图书管理系统、员工考勤系统,做完自己都知道没人用,答辩时被老师问一句"这个系统的核心难点在哪",场面就会很尴尬。
校园闲置交易不一样。毕业季的宿舍楼道里,成箱的书、晾衣架、台灯、电竞椅都在等人带走;开学季的新生群里,到处有人在问哪里能买到便宜的二手自行车。校园内的交易有个天然优势——信任。同一个学校,学号可查,宿舍楼下就能见面,根本不需要物流。但现实是,这种交易目前大多发生在 QQ 群和朋友圈里,信息杂乱、没有检索能力、交易纠纷也无处追溯。
一个同校范围内的闲置交易平台,解决的就是这个真实痛点。它比闲鱼更适合校园场景:认证用户限定在校内、线下当面交易、按校区筛选距离。从答辩角度讲,这个题目也天然带业务故事,老师一听就知道你在做什么、为什么做。
1.2 一个项目覆盖 Spring Boot 全链路能力
选这个题目的另一个好处是技术覆盖面完整。它不是单纯的 CRUD,而是几乎每层都有"有点意思"的地方:
- 用户体系:注册、登录、JWT 认证、密码加密,这是所有系统的基础能力。
- 商品管理:发布、修改、上下架、图片多文件上传、分类筛选、关键词搜索。
- 交易链路:下单、支付(可简化)、发货、收货、取消、超时回滚,涉及状态机设计和并发控制。
- 系统能力:定时任务(自动取消超时订单)、全局异常处理、分页查询、事务控制。
- 部署运维:前后端分离后的跨域问题、Nginx 反向代理、服务器部署、配置文件环境隔离。
这些能力单独拎出来哪一个都不算难,但合在一起,正好是一个完整业务系统的缩影。我见过太多学生把 Spring Boot 项目做成"数据表格管理工具",就是因为只写了 CRUD,没有把状态流转和并发边界做进去。而闲置交易这个场景,天然要求你处理"同一个商品不能被两个人同时买走""订单超时怎么办"这类真实业务问题,这些才是答辩时的得分点。
1.3 项目边界与技术栈选型思路
先明确一点:要做成什么样,才算这个题目"完成了"?
我建议的核心闭环是:用户注册登录 → 发布商品 → 浏览搜索 → 下单 → 卖家确认 → 买家确认收货 → 交易完成。这一条链路跑通,整个项目的主干就立住了。收藏、举报、评价、聊天都是加分项,不决定项目生死。
技术栈我推荐这样定:
| 层次 | 技术选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.18 或 3.x | 版本选择后面专门讲 |
| ORM | MyBatis Plus 3.5.x | 单表 CRUD 几乎不用写 SQL,分页插件好用 |
| 数据库 | MySQL 8.0 | 稳定、资料多 |
| 认证方案 | JWT + 拦截器 | 前后端分离标配,比 Session 省心 |
| 前端 | Vue 3 + Element Plus + Vite | 与 Spring Boot 接口对接,组件现成 |
| 构建工具 | Maven | 遇到问题好搜资料,最稳妥 |
| 部署 | 宝塔 + Docker 或直接java -jar | 看服务器环境而定 |
其实也可以用 Thymeleaf 模板方案,一个 Spring Boot 应用把页面和接口都包了,工作量小不少。但如果你是计算机相关专业,我还是建议上前后端分离。一是面试和答辩时前后端分离是个成熟架构,解释起来有东西讲;二是 Vue 在学校的课程里大概率学过或者马上要学;三是时间成本也就多一到两周,完全可接受。
2. 工程初始化与版本选型:那些让你开局就卡住的坑
2.1 Spring Boot 版本怎么选:2.7 还是 3.x
很多学生拿到题目,第一件事就是去 start.spring.io 点一个最新版,结果开局就被折磨。
Spring Boot 3.x 的几个变化必须心里有数:
- 强制 JDK 17。如果电脑装的是 JDK 8,想跑 3.x 得先升级环境。学校机房和老教程基本都是 JDK 8,这本身就是一道坎。
javax改jakarta。Spring Boot 3 里,Servlet、Validation 等一大批包名的前缀从javax.*换成了jakarta.*。网上大量老教程代码直接复制过来全是红色报错,对新手极不友好。- 部分组件需要匹配版本。MyBatis Plus 要 3.5.3 以上才支持 Spring Boot 3,Druid 连接池也得用新版本,乱配就起不来。
我的建议是:如果你是第一次做 Spring Boot 项目、网上的参考资料大多是博客和 B 站老视频,直接选 2.7.18。这是 Spring Boot 2.x 最后一个版本,2023 年仍在维护,JDK 8 就能跑,网上搜任何问题都能找到对应解决方案。不要担心"版本太老会不会过时",对于单体业务系统来说,2.7.18 完全够用。
如果你的老师明确要求新技术栈,或者你自己想用新版本,那就选 Spring Boot 3.2.x + JDK 17,并且坚持查 3.x 的相关文档,别被 2.x 的代码误导。
2.2 Maven 构建与阿里云镜像配置
构建工具我推荐 Maven,理由很实际:Gradle 虽然配置更灵活,但校园网环境下依赖下载速度本来就慢,Gradle 的仓库配置稍不注意就卡死,而 Maven 的镜像配置网上教程一抓一大把,遇到问题更容易排查。
第一个建议是配置阿里云镜像仓库。默认的 Maven 中央仓库在国内下载依赖时经常抽风,尤其是首次构建要拉几百 MB 的依赖,慢的能等一两个小时。在settings.xml里加镜像:
<mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>阿里云公共仓库</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>改完之后,依赖下载速度会明显提升。
另外提一个细节:用 IDEA 创建 Spring Boot 项目时,Server URL一栏可以换成https://start.aliyun.com,国内网络环境下加载项目模板会更快,生成的pom.xml也会自动带上阿里云仓库配置,省不少事。
2.3 分层分包:dto 和 vo 不分是后期改接口的噩梦
Spring Boot 没有强制规定项目结构,但做业务系统,分包一定要清爽。我常用的结构是这样的:
com.campus.trade ├── controller // 接口层,只做参数接收和响应组装 ├── service // 业务逻辑层,拆接口和 impl │ └── impl ├── mapper // MyBatis Plus 的 Mapper 接口 ├── entity // 数据库实体,与表字段一一对应 ├── dto // 前端传入的参数对象 ├── vo // 返回给前端的对象 ├── config // 配置类:CORS、拦截器、静态资源映射等 ├── common // 统一返回结果、分页对象、状态枚举 ├── exception // 全局异常处理 └── utils // JWT、文件上传等工具类这里我想强调 dto 和 vo 分开这件事,新手最容易忽略。如果直接把 entity 返回给前端,会出现两个问题:一是表结构里的内部字段(比如 seller_id、delete_flag)会暴露给前端,既不安全也不专业;二是前端的入参和实体字段不一定对齐,比如前端只传了一部分字段,你拿 entity 去接收,字段缺失的坑就来了。
正确的做法是:入参用 DTO 接收,出参用 VO 返回,实体只在 service 和 mapper 之间流转。前期多写两个类,后期改接口字段时你会感谢这个决定。
2.4 多环境配置:开发和生产配置分离
单机的application.yml写死数据库账号密码,在本地开发没问题,部署到服务器就很尴尬。我的做法是拆成三个文件:
application.yml:公共配置,比如应用名、MyBatis Plus 配置、JWT 密钥等。application-dev.yml:本地开发环境,连接本地 MySQL,开启 SQL 日志。application-prod.yml:生产环境,连接服务器数据库,关闭日志,上传路径指向服务器磁盘。
启动时用参数指定环境:
java -jar campus-trade.jar --spring.profiles.active=prod这样本地开发不会误连生产库,服务器上也不会出现"连不上本地数据库"的问题。多环境配置是实际项目的基本功,也是答辩时能顺手讲出来的亮点。
3. 数据模型设计:从表结构到状态机的完整推演
3.1 六张核心表怎么定义
数据模型是整个系统的地基,表建歪了,后面写多少代码都别扭。以我的经验,闲置交易系统这六张表基本够用:
| 表名 | 用途 | 关键字段 |
|---|---|---|
user | 用户表 | username, password, nickname, avatar, phone, school |
category | 商品分类 | name, sort |
product | 商品表 | user_id, category_id, title, description, price, quality, status |
product_image | 商品图片 | product_id, url, sort |
trade_order | 订单表 | order_no, product_id, seller_id, buyer_id, price, order_status |
favorite | 收藏表 | user_id, product_id |
这里有一个非常重要的提醒:表名千万不要用order。order是 MySQL 的保留关键字,你写select * from order直接报语法错误。我第一次做项目时就踩过这个坑,后来统一改成trade_order,再没出过问题。类似的还有user,虽然 MySQL 8 里不是保留字,但为了避免歧义,有些团队也会用sys_user,这个看个人习惯。
商品表的结构我贴一下,方便参考:
CREATE TABLE `product` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '发布者id', `category_id` bigint(20) NOT NULL COMMENT '分类id', `title` varchar(100) NOT NULL COMMENT '标题', `description` text COMMENT '详细描述', `price` decimal(10,2) NOT NULL COMMENT '售价', `original_price` decimal(10,2) DEFAULT NULL COMMENT '原价,可为空', `quality` tinyint(4) DEFAULT '5' COMMENT '成色,1-10分', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1在售 2已锁定 3已售出 4已下架', `cover_image` varchar(255) DEFAULT NULL COMMENT '封面图地址', `view_count` int(11) NOT NULL DEFAULT '0' COMMENT '浏览量', `delete_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), KEY `idx_status_category` (`status`, `category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='闲置商品表';几个字段设计的细节说明一下:
价格用DECIMAL(10,2)而不是float或double。浮点数在计算机里是二进制近似存储,0.1 + 0.2 这样的计算会产生精度误差。涉及钱,必须用定点数。
逻辑删除用delete_flag而不是物理删除。用户删掉一条商品记录,如果直接 delete 掉,关联的订单数据会跟着出问题。用逻辑删除,数据还在,只是查询时过滤掉,安全得多。MyBatis Plus 里有现成的@TableLogic注解,配置上之后,删除操作自动变成 update。
时间字段用datetime而不是timestamp。虽然 timestamp 省一点空间,但 2038 年会溢出的老问题先不说,datetime 的可读性也更好。MySQL 8 里直接让create_time默认取当前时间,代码里就不用每次手动 set。
3.2 商品与订单状态机:用状态值杜绝业务混乱
业务系统最容易翻车的地方,就是对"状态"的管理。如果不设计好状态流转,Service 里就是一堆 if else 到处散落,改一个状态还要全局搜索哪里判断漏了。
商品状态,我定义了四个:
| 状态值 | 含义 | 说明 |
|---|---|---|
| 1 | 在售 | 刚发布或正常可被购买的闲置 |
| 2 | 已锁定 | 买家已下单未完成交易,暂时不可被其他人购买 |
| 3 | 已售出 | 交易完成 |
| 4 | 已下架 | 卖家手动下架或删除了商品 |
订单状态,我建议用 10、20、30 这种十位步进值,方便以后在任意两个状态之间插入新状态,不用改枚举值。核心流程:
| 订单状态 | 含义 | 触发动作 | 商品状态联动 |
|---|---|---|---|
| 10 | 待付款 | 买家下单 | 商品状态改为 2(锁定) |
| 50 | 已取消 | 超时未支付/卖家取消 | 商品状态回滚为 1 |
| 20 | 待发货 | 买家线下付款确认 | 商品保持锁定 |
| 30 | 待收货 | 卖家标记已交付 | 商品保持锁定 |
| 40 | 已完成 | 买家确认收货 | 商品状态改为 3(已售出) |
这套状态机设计好之后,所有业务逻辑都围绕 "状态 + 动作" 来写,每个操作方法里先校验当前状态是否允许执行这个动作,不允许就直接抛异常。改动状态只允许通过方法进行,避免到处 set 字段。这个设计在答辩时非常加分,老师问"订单流程怎么保证不混乱",直接把这个状态表拿出来讲。
3.3 并发下单:条件更新和唯一索引双保险
校园闲置交易的并发量不会很大,但"同一个商品同时被两个人下单"是一个必须防住的逻辑漏洞。假设一个热门商品,两个买家同时点了"我要买",如果代码是"先查询商品状态,状态为在售再插入订单",这两个请求可能同时读到"在售",然后各自创建订单,商品被卖了两遍。
解决的思路是:用数据库条件更新保证原子性。在创建订单之前,先执行一条更新语句:
UPDATE product SET status = 2, buyer_locked = #{userId} WHERE id = #{productId} AND status = 1这条语句是原子的,数据库层面会加行锁。两个并发请求同时执行,只有一条 update 的影响行数为 1,拿到影响行数为 0 的那个请求就可以直接返回"商品已被购买"。
在 MyBatis Plus 里用 LambdaUpdateWrapper 写:
boolean locked = productService.update( new LambdaUpdateWrapper<Product>() .set(Product::getStatus, 2) .set(Product::getBuyerLocked, userId) .eq(Product::getId, productId) .eq(Product::getStatus, 1) // 关键条件:只有当前状态是"在售"才更新 ); if (locked) { // 创建订单 } else { throw new BusinessException("商品已被下单或已下架"); }再配合trade_order表对product_id加唯一索引,保证同一商品最多只有一条成功创建的订单记录,双保险。这个逻辑不算深,但属于面试和答辩都爱问的"并发安全性"问题。
3.4 索引、逻辑删除、状态字段的取舍
数据量小的时候索引不重要,但表结构设计阶段还是要把索引留好。商品表的status + category_id联合索引,是为了支撑首页按分类筛选在售商品的常用查询;订单表的seller_id和buyer_id各建一个普通索引,是为了查询"我卖出的"和"我买到的"两类订单列表时能走索引。
delete_flag字段我建议放一张通用配置里,所有需要逻辑删除的表都统一字段名和类型。查询时记得过滤条件要带delete_flag = 0,MyBatis Plus 里的@TableLogic注解会自动帮你加这个条件,不用每次手动写。但注意,联表查询时逻辑删除的过滤条件不会自动拼接,你得自己带上,这是实际操作中很容易漏掉的一个点。
还有一个容易被忽略的细节是buyer_locked字段。我把它放在商品表里,标记当前锁定商品的买家是谁。这样在商品详情页可以直接显示"已有人下单,正在交易中",也能在超时取消订单时知道该把商品回滚给哪个状态。如果你不想加这个字段,也可以从订单表反查,但每次都要多一次查询,不如直接冗余在商品表里方便。
4. 核心业务链路实现:从登录到交易闭环的代码拆解
4.1 JWT 认证链路:拦截器加 ThreadLocal 的完整实现
登录方案我直接选了 JWT,而不是传统的 Session。原因很简单:前后端分离部署后,接口域名和前端页面域名可能不一样,Session 的 Cookie 跨域处理比较麻烦。JWT 是无状态的,前端把 token 放在请求头里,后端每次解析即可。
流程是这样的:
- 用户登录,校验用户名密码,通过后生成 token 返回前端。
- 前端把 token 存到 localStorage,请求时放在
Authorization请求头。 - 后端写一个拦截器,拦截所有需要登录的接口,解析 token。
- 解析成功后,把当前用户信息放进 ThreadLocal,后续业务代码里随时取。
JWT 工具类核心代码:
public class JwtUtil { private static final SecretKey KEY = Keys.hmacShaKeyFor("campus-trade-secret-key-2024".getBytes(StandardCharsets.UTF_8)); public static String generate(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600 * 1000)) // 7天过期 .signWith(KEY) .compact(); } public static Claims parse(String token) { return Jwts.parserBuilder() .setSigningKey(KEY) .build() .parseClaimsJws(token) .getBody(); } }这里注意一个坑:网上很多教程用的是 jjwt 0.9.x 的 API,Jwts.parser()在老版本是那样调,但新版本(0.11+)改成了Jwts.parserBuilder()。如果你用的依赖是新版,按老教程写会直接编译不过,这是 JWT 相关教程里最容易踩的版本坑。
Interceptor 拦截器的核心逻辑:
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { return true; // 放行预检请求,否则跨域请求会被拦截 } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); } try { Claims claims = JwtUtil.parse(token); LoginUser loginUser = new LoginUser(); loginUser.setUserId(claims.get("userId", Long.class)); loginUser.setUsername(claims.getSubject()); UserContext.set(loginUser); // 存入 ThreadLocal return true; } catch (Exception e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"登录状态已过期,请重新登录\"}"); return false; } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserContext.remove(); // 请求结束后清除,防止线程池复用导致用户数据串号 } }ThreadLocal 的使用有个必须注意的点:每次请求结束一定要 remove。Web 容器用的是线程池,线程处理完一个请求后不会销毁,而是归还给线程池。如果你不清理 ThreadLocal,下一个请求复用了同一个线程,就会读到上一个用户的信息,这就是"用户数据串号",属于严重 Bug。
4.2 商品发布:文件上传与事务边界
商品发布接口接收的是一组字段外加多张图片。Spring Boot 里处理 multipart 文件上传,需要改一下配置:
spring: servlet: multipart: max-file-size: 5MB max-request-size: 20MBmax-file-size限制单个文件大小,max-request-size限制整个请求的大小。如果不设置,默认单个文件 1MB,发一张清晰的手机实拍图都不够用。
文件保存我强烈建议不要存到项目内的相对路径。原因在于,项目打包成 jar 后,内部路径是只读的,而且getRealPath这类方法在 jar 部署下行为会变得不可靠。正确做法是把图片存到服务器磁盘的固定目录,比如D:/campus-upload/(Windows 开发机)或/data/upload/(Linux 服务器),然后通过静态资源映射对外提供访问。
配置静态资源映射:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath = System.getProperty("user.dir") + File.separator + "upload" + File.separator; registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }注意file:前缀不能少,否则 Spring 不会把它当成本地文件路径来解析。
文件名一定要重命名。用户上传的文件名可能是毕业设计终极版(1).jpg、微信图片_20240101120000.jpg,如果直接存储,可能出现空格、中文乱码、路径注入问题。我用的是 UUID + 原文件扩展名:
String ext = StringUtils.getFilenameExtension(file.getOriginalFilename()); String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext;商品主表和图片表的写入要放在同一个事务里。如果主表保存成功、图片批量插入时出错,就会出现商品没有封面图的尴尬情况。Service 方法上加@Transactional(rollbackFor = Exception.class),图片插入失败就一起回滚。rollbackFor指定所有异常都回滚,因为默认只回滚 RuntimeException,像 IOException 这类检查异常是不会回的。
4.3 商品列表:分页、筛选与 VO 组装
首页商品列表和搜索结果的实现,用 MyBatis Plus 的分页插件最顺手。先配置分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }查询接口接收的参数一般有:分类 id、成色区间、价格区间、关键词、页码、每页条数。用 LambdaQueryWrapper 动态拼接条件:
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1) // 只查在售 .eq(categoryId != null, Product::getCategoryId, categoryId) .ge(minPrice != null, Product::getPrice, minPrice) .le(maxPrice != null, Product::getPrice, maxPrice) .like(StringUtils.hasText(keyword), Product::getTitle, keyword) .orderByDesc(Product::getCreateTime); Page<Product> page = productMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);这里.like是模糊搜索,底层是 SQL 的LIKE '%keyword%'。当商品量超过几千条时性能会有问题,但毕设规模完全不用纠结。真要优化,后面可以上全文索引或者分词搜索,属于加分项,不在主链路内。
返回给前端时,不要直接返回实体列表。商品的实体里有 user_id、delete_flag 这种字段,不该暴露给前端。我习惯把查出来的实体列表转成 VO,VO 里包含商品 id、标题、价格、封面图、成色、发布时间,再把发布者的昵称和头像 join 填充进去。这一步用离线计算的方式,先查出所有商品的 user_id 集合,再批量查用户信息,用 Map 组装,避免循环里查 N 次数据库。
4.4 交易闭环:下单、发货、确认收货的状态流转
下单接口是整套系统的核心,逻辑要严谨。整体分为四步:
- 校验商品状态:必须是"在售",商品存在且未删除。
- 条件更新商品状态为"已锁定",更新失败直接返回"手慢了,商品已被下单"。
- 创建订单,状态为"待付款"。
- 返回订单号和订单信息给前端。
创建订单时,订单号不要用数据库自增 id 当作业务号,太容易暴露数据量。我用的规则是yyyyMMddHHmmss + 4位随机数,比如202405201530452847。并发高时可能碰撞,但毕设场景内已经足够。
String orderNo = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + String.format("%04d", ThreadLocalRandom.current().nextInt(10000));订单的状态流转我全部封装在 service 里,对外暴露createOrder、cancelOrder、sellerDeliver、buyerConfirm四个方法。每个方法的第一步都是校验当前订单状态,例如:
public void buyerConfirm(Long orderId, Long userId) { TradeOrder order = getOrderById(orderId); if (order == null || !order.getBuyerId().equals(userId)) { throw new BusinessException("订单不存在或无权操作"); } if (order.getOrderStatus() != 30) { throw new BusinessException("当前订单状态不允许确认收货"); } // 修改订单状态为已完成 // 将商品状态改为已售出 }为什么每一处都要校验状态?因为接口理论上可以被任意调用,如果买家在"待付款"状态就调了"确认收货",整个订单流转就乱了。宁可每个方法多几行校验代码,也不能让状态值被随意修改。
订单里还有一个"取消订单"的场景,需要分角色处理。买家可以在卖家发货之前取消,卖家也可以在买家付款前取消。取消后把商品状态回滚为"在售"。如果订单已经进入"待收货",取消操作就要禁止,只能走售后流程。这块逻辑可以单独梳理一个文档,答辩时老师特别爱问。
4.5 定时清理超时订单:@Scheduled 的实践要点
用户下单后如果不付钱(校园场景下大多是线下见面付款),订单会一直挂在"待付款"状态,商品也被锁死,别人买不了。必须有一个定时任务来扫掉这种脏数据。
Spring Boot 的写法很简单,启动类上开个@EnableScheduling,然后在方法上标@Scheduled:
@Component public class OrderTimeoutTask { @Scheduled(cron = "0 */5 * * * ?") // 每5分钟执行一次 public void cancelTimeoutOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(30); List<TradeOrder> timeoutOrders = orderMapper.selectList( new LambdaQueryWrapper<TradeOrder>() .eq(TradeOrder::getOrderStatus, 10) // 待付款 .lt(TradeOrder::getCreateTime, deadline) ); for (TradeOrder order : timeoutOrders) { cancelOrderInternal(order); // 幂等处理:再次校验状态再更新 } } }cron 表达式0 */5 * * * ?表示从第 0 秒开始,每 5 分钟的任意秒执行。这个?是 Spring 的 cron 里"不指定"的含义,和*差不多但用于日/星期字段,别写错了。
定时任务里有几个注意点。
第一,任务要做幂等保护。cancelOrderInternal里更新订单状态时,仍然要加条件WHERE order_status = 10,保证即使任务重复执行,也不会把已经取消的订单再取消一遍。
第二,批量操作时要控制数量。如果积压了大量超时订单,一次全部处理可能耗时过久,建议加个 limit,每轮最多处理几百条。
第三,部署多个实例时 @Scheduled 会重复执行。单机部署不存在这个问题,如果将来做成微服务部署多副本,需要引入 ShedLock 或改用 xxl-job,这里提一句,答辩时可以主动跟老师讲自己了解这个局限性。
定时任务和商品状态回滚必须放在同一个事务里,否则订单取消了、商品还锁着,下次就没人能买这个商品了。我当时就是因为订单和商品状态分属两个事务,生产环境出现过几次商品"锁死",排查好久才发现是事务没包住。
5. 部署上线与报错自救:从本地跑通到服务器稳定运行
5.1 打包、配置外置与上传目录规划
本地开发跑通后,部署服务器是另一个世界。第一步先打包:
mvn clean package -DskipTests执行完target目录下会有一个可执行的 jar 包。-DskipTests跳过测试,不然打包时如果某个测试用例挂了,构建会直接失败。
服务器上的运行方式,我推荐用宝塔面板 + Docker Compose 的组合,省心、可复现、部署日志直观。docker-compose.yml大概长这样:
version: '3' services: app: image: openjdk:17-jdk-slim container_name: campus-trade-app volumes: - ./app.jar:/app/app.jar - /data/upload:/data/upload - ./application-prod.yml:/app/config/application-prod.yml ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: prod TZ: Asia/Shanghai restart: always这个配置里包含了几个不容易想到的点:
TZ: Asia/Shanghai一定要设,否则容器内默认 UTC 时间,比北京时间慢 8 小时,订单超时定时任务会算错截止时间。- 上传目录挂载出来,把
/data/upload目录映射到宿主机磁盘,否则容器一重建,用户传的图片全没了。 - 配置文件外置,把
application-prod.yml放在外部,改数据库密码、改端口不用重新打包镜像。
启动命令:
docker-compose up -d查看日志:
docker logs -f campus-trade-app第一次启动如果起不来,先看日志,90% 的问题是数据库连不上或者配置写错。
5.2 开发和生产环境的跨域与 Nginx 落地
前后端分离项目,跨域问题是绕不开的。本地开发时解决跨域有两种办法:
第一种是前端用 Vite 代理,在vite.config.js里配置:
export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })开发环境请求/api/login时,Vite 会自动转发到后端的 8080 端口,浏览器层面没有跨域。这个方案的优点是零后端改动,前端接口代码保持/api/xxx相对路径,生产环境再用 Nginx 同样转发,不用改代码。
第二种是后端配置全局 CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns("*")是 Spring Boot 2.4 之后的写法,老版本的allowedOrigins("*")在带 Cookie 时会出问题。这里提醒一句,很多人环境配好了还是报跨域,多半是版本 API 不对。
生产环境的推荐方案是直接上 Nginx 反向代理,接口路径和静态资源都交给 Nginx 处理,前后端不需要关心跨域。一个典型的 server 配置:
server { listen 80; server_name your-domain.com; # 前端页面 location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } # 后端接口 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 上传的图片 location /upload/ { alias /data/upload/; } # 上传文件大小限制 client_max_body_size 20m; }有两个地方特别容易踩坑。
location /api/的proxy_pass,如果带/api/前缀,后端 controller 里也必须统一带/api,或者用去掉前缀的代理写法。我建议后端统一用/api作为所有接口的前缀,这样前后端约定清楚,代理配置也简单。
client_max_body_size不设置的话,Nginx 默认限制 1MB,前端传几张图直接报 413 Request Entity Too Large。这个报错排查起来很隐蔽,因为日志里只显示 Nginx 的错误,后端完全没收到请求。
5.3 高频问题速查表
做这个项目时遇到的报错,我整理成一张速查表,基本都是网上问得最多的:
| 报错/现象 | 根因 | 解决办法 |
|---|---|---|
连接数据库报Public Key Retrieval is not allowed | MySQL 8 默认 caching_sha2_password 加密 | JDBC URL 加allowPublicKeyRetrieval=true |
| 数据库中文乱码 | 连接 URL 没指定编码 | JDBC URL 加characterEncoding=utf8,表结构用 utf8mb4 |
Table doesn't exist | 表名大小写问题 | MySQL 的lower_case_table_names配置为 1,注意在初始化时设置 |
| 上传图片 413 | Nginx 限制了 body 大小 | 加client_max_body_size 20m; |
| 后端收到请求但返回 404 | 前端请求的路径没带/api前缀 | 统一接口前缀检查 Nginx 的 proxy_pass 规则 |
| jar 包启动后立刻退出 | 端口被占用或数据库连不上 | 看启动日志,docker logs或java -jar前台启动 |
| 图片能上传但访问 404 | 静态资源映射没配对 | 检查addResourceLocations的路径,file:前缀不能少 |
Token 解析报错JWT signature does not match | JWT 密钥不一致 | 检查 JWT 密钥,application.yml和代码里必须同一份 |
| IDEA 自动装配报错,但项目能跑 | 编译器没配置 Lombok 注解处理 | 安装 Lombok 插件,开启annotation processing |
最后一个是新手重灾区。IDEA 里装 Lombok 后,如果annotation processing没开,@Slf4j、@Getter、@Setter这些注解在编译期不生效,代码里全是红色报错,但 Maven 构建又能通过。处理方式是在 IDEA 的设置里搜索annotation processing,勾选Enable annotation processing。
5.4 给答辩加分的扩展方向
如果主链路已经全部跑通,还有时间想加分,按投入产出比排序:
Redis 缓存热门商品与首页数据。把首页列表和商品详情缓存起来,每次查询先走缓存,再查数据库。技术上能讲出"缓存穿透、缓存雪崩、缓存一致性"这几个概念,答辩时很加分。工作量大概两天。
操作日志 AOP 切面。通过注解记录用户的关键操作(发布商品、下单、取消),写进日志表。Spring AOP 是面试高频考点,也容易展示。
敏感词过滤。发布商品时对标题和描述做敏感词过滤,基于 DFA 算法实现一个简单的敏感词工具类,几百行代码,但能讲的东西非常多。
站内消息通知。商品被下单时通知卖家,用 WebSocket 做实时提醒。工作量稍大,但演示效果比较抓眼球。
更智能的搜索。用 HanLP 中文分词配合全文索引,把商品标题搜索的准确度提升一个档次。普通
LIKE搜索搜"苹果笔记本"时,分词器能拆出"苹果""笔记本"两个词分别匹配,体验完全不一样。
我没有把消息队列、大数据计算这类中间件列进来,原因是它们和校园闲置交易平台的核心业务没直接关系。主动在答辩时讲"我分析过这个场景的并发量,单体应用加 Redis 已经足够,引入 MQ 属于过度设计"反而是很加分的务实态度。
最后说点实际的。我带过的学生里,凡是能把"商品状态如何流转""并发下单怎么防重复""超时订单怎么处理"这三个问题讲清楚的,答辩基本都很顺畅。反而是那些急着堆功能、搭高深架构的,被老师顺着状态流转一路追问,最后答不上来。所以如果你时间有限,优先把交易主链路打磨扎实,把异常边界处理干净,这比任何花哨的技术栈都管用。