Spring Boot校园二手平台实战:从数据库设计到并发防超卖

发布时间:2026/10/7 14:55:48
Spring Boot校园二手平台实战:从数据库设计到并发防超卖 简介面向计算机相关专业正在准备毕业设计的学生以及需要项目实战练习的学习者这份基于SpringBoot的校园二手市场平台源码包完整包含了后端业务逻辑、前端页面与数据库脚本覆盖用户注册登录、商品发布、浏览检索、下单购买、订单管理等核心功能代码结构清晰环境配置齐全可直接导入运行也可作为课程设计和期末大作业的参考模板。压缩包共收录289个文件其中以Java源文件、Vue页面、JavaScript脚本为三大主要部分同时附有SQL数据库脚本、XML配置、图片素材和Maven构建文件整体体积约6.9MB目录层次分明方便按前后端和资源配置模块进行查阅与二次开发对于理解真实项目结构也很有帮助。目前已有94人学习或下载过这套项目。该毕业设计在导师指导下完成评审得到99分高分除了完整源码和数据库还额外整理有启动运行所需的各项配置说明能帮助初学者减少环境搭建障碍快速理解SpringBoot与Vue结合的开发模式同时提供了数据库初始化脚本让本地部署更为顺畅。1. 校园二手平台这个题目难的根本不是增删改查如果你打开一个“基于Spring Boot的校园二手市场平台”的源码压缩包第一感觉大概率是这不就是一个商品的增删改查吗用户注册、发布商品、浏览列表、下单、成交——CRUD 四件套齐全好像没什么技术含量。等你真把数据库脚本导入 MySQL、启动项目、点开页面走完一遍流程才会发现在“下单”这个动作背后商品状态怎么流转、库存什么时候扣、订单超时怎么关、同一件商品被两个人同时拍下怎么办才是这个毕设真正的分水岭。写这套源码的人是否用心全看这些地方有没有处理干净。这个方向适合三类人一是 Java 后端基础还行、想用一个完整项目串起 Spring Boot、MyBatis-Plus、MySQL、JWT 这套常见技术栈的毕业生二是想把毕设做成“能演示、能答辩、能扩展”而不只是能启动的同学三是刚工作不久、想拿一个真实业务模型练手的前端或初级开发。这篇笔记会带你从数据库设计一直走到本地跑通、接口自测和并发场景验证把最容易翻车的几个地方提前排掉。2. 数据库设计先用四张核心表把业务边界立住2.1 用户表不要把密码字段做成 varchar 就完事校园二手平台的前提是“校园”所以用户表至少要包含学号/工号、学院、角色这几个字段方便后续做“仅本校可见”或者“按校区筛选”。密码字段我见过很多低分毕设直接明文存答辩时老师一查数据库就露馅。正确做法是存入 BCrypt 或 SHA-256 加盐后的哈希值登录时用 Spring Security 的BCryptPasswordEncoder.matches()校验而不是SELECT * FROM user WHERE password ?。用户表一个容易忽略的字段是status用来禁用违规用户。很多毕设只做“注册、登录、改头像”没有禁用逻辑导致二手市场上充斥着发广告的机器人账号。你的表结构里多一个status字段后面写拦截器时顺手判断答辩被问到“怎么防止恶意用户”时就有的说。CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt哈希, student_no VARCHAR(20) DEFAULT NULL COMMENT 学号, college VARCHAR(50) DEFAULT NULL COMMENT 学院, phone VARCHAR(20) DEFAULT NULL COMMENT 联系方式, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像URL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;这里的核心逻辑是UNIQUE KEY uk_username和status字段。前者保证注册接口并发调用时数据库层能拦住重复用户名后者给后面的权限校验留出口子。很多源码喜欢把所有字段都设为NOT NULL但像student_no这种信息可能晚点才补齐设成DEFAULT NULL更贴近真实业务。2.2 商品表交易系统的地基是 status 字段不是 title商品表是整个平台的灵魂。我见过不少“二手市场”毕业设计商品表只有id, title, price, description, seller_id连status都没有商品上下架靠物理删除。这在答辩时很容易被追问B 已经下单付款了A 又把商品删了订单怎么闭环二手商品的合理状态流转是1 在售 → 2 已被下单锁定→ 3 已成交 → 4 已下架。这里的关键点是“已被下单”必须是一个独立状态而不只是把buyer_id填上就算完。当买家点击“立即购买”但还没付款时商品要立刻进入锁定状态否则另一个人也能下单同一件货就卖了两次。CREATE TABLE goods ( id BIGINT NOT NULL AUTO_INCREMENT, seller_id BIGINT NOT NULL, title VARCHAR(100) NOT NULL COMMENT 商品标题, description TEXT COMMENT 商品描述, price DECIMAL(10,2) NOT NULL COMMENT 价格元, original_price DECIMAL(10,2) DEFAULT NULL COMMENT 原价/参考价, images VARCHAR(1024) DEFAULT NULL COMMENT 图片URL逗号分隔, category VARCHAR(20) DEFAULT NULL COMMENT 分类教材/数码/生活, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在售 2锁定 3成交 4下架, publish_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_seller (seller_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表;status加索引这一点很重要——列表页最核心的查询就是WHERE status 1没索引时商品量上来后查询会明显变慢。images用逗号分隔存储是毕设里最常见的做法比单独建一张图片表简单但你要知道它的上限单张图片 URL 太长会撑爆字段所以线上图片服务一般用短链接毕设里存本地相对路径即可。ON UPDATE CURRENT_TIMESTAMP能省掉手动维护update_time的代码属于低成本高收益的设置。2.3 订单表与收藏表状态字段别用 int 裸奔订单表建议把“订单状态”设计成tinyint同时在注释里写清楚每个值代表什么避免两个月后自己都看不懂。比较稳的流转是1 待付款 → 2 待发货/待面交 → 3 已完成 → 4 已取消。把订单状态和商品状态解耦是因为订单可以取消但商品可以重新上架两者不是一一对应的。CREATE TABLE trade_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号业务唯一, goods_id BIGINT NOT NULL, seller_id BIGINT NOT NULL, buyer_id BIGINT NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 成交价下单时锁定, status TINYINT NOT NULL DEFAULT 1 COMMENT 1待付款 2待面交 3完成 4取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, finish_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_buyer (buyer_id), KEY idx_seller (seller_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;订单表上最值得注意的坑是下单时要把price从商品表冗余一份到订单里。为什么因为卖家改价或商品表字段被误更新时订单里的历史价格不能被联动修改。这在答辩里是一个可以主动讲出来的设计亮点“我的订单表做了价格快照防止商品改价影响历史订单。”除此之外order_no不要用数据库自增 ID业务上需要一串不易猜测的编号常见做法是yyyyMMdd 用户ID后四位 随机数/时间戳通过唯一键兜底防重复。收藏表就比较简单核心是联合唯一索引防止同一用户对同一商品收藏两次CREATE TABLE favorite ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_goods (user_id, goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收藏表;2.4 与 MyBatis-Plus 配合实体类、逻辑删除与自动填充用 MyBatis-Plus 做数据访问时有两点配置能让开发效率明显提升。第一是逻辑删除给商品表加一个deleted字段实体上打TableLogic这样业务层调removeById()时实际执行的是UPDATE goods SET deleted 1 WHERE id ?所有查询自动带deleted 0条件。这比物理删除安全得多售后纠纷时有据可查。第二是字段自动填充。create_time和update_time可以在实体类上声明TableField(fill FieldFill.INSERT)然后实现MetaObjectHandler统一给这两个字段赋值。这样所有 Service 代码里就不用重复写setCreateTime(new Date())减少遗漏。很多毕设源码里时间字段一会儿有值一会儿为空八成就是没做自动填充。3. 核心实现从 JWT 登录到下单事务的四个关键代码3.1 配置文件端口、数据源、MyBatis-Plus 分页插件先看application.yml。毕设里最常见的部署方式是打 jar 包本地跑所以端口和数据源要放开配置。MyBatis-Plus 的map-underscore-to-camel-case默认就是开启的帮我们省去写resultMap的功夫。但分页插件必须手动注册否则Page对象查出来永远是全表数据。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_market?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0关键参数是serverTimezoneAsia/Shanghai不指定的话 MySQL 8 连接时会报时区错误StdOutImpl会在控制台打印每条 SQL调试阶段务必开启。逻辑删除字段配置在global-config下后实体类里只要有个deleted字段即可SQL 层面自动处理。文件上传大小限制也用在这里毕设阶段 5MB 足够不限制的话容易被恶意上传超大文件把磁盘写满。3.2 登录与 JWT 拦截Token 里存什么过期时间调多少登录接口的逻辑很简单根据用户名查用户用BCryptPasswordEncoder校验密码通过后签发 JWT。JWT 里不建议塞整个用户对象放userId和username就够了别放手机号、密码等敏感数据。公共密钥和过期时间放到配置里不要把密钥硬编码在代码里否则答辩时老师让你改配置还得重新编译。public class JwtUtil { private static final String SECRET your-secret-key; private static final long EXPIRE 24 * 60 * 60 * 1000L; // 24小时 public static String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }这里的核心参数是过期时间。毕设演示场景通常只有一两个小时24 小时足够如果把过期时间调到 7 天安全性明显下降——Token 一旦泄露别人一星期内都可以冒充你操作。在拦截器里解析 Token 后把userId放进ThreadLocal或HttpServletRequest的 attribute 中后续 Service 层就无需再从参数里传用户 ID。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || token.isEmpty()) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录\}); return false; } try { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { response.setStatus(401); return false; } } }这里有个毕设项目常见的细节前端请求头里一般带Authorization: Bearer xxx所以代码里用replace(Bearer , )去掉前缀。有的源码没处理这个前缀导致前端怎么调都是 401排查半天。Token 失效的统一返回格式里建议带上code401方便前端拦截器识别后跳回登录页。3.3 发布商品事务在这个方法里不重要校验才重要发布商品的 Service 并不复杂核心是字段校验和状态初始化。价格必须大于 0标题不能为空图片地址不能超过长度。很多毕设把校验写在 Controller 里一长串if很影响阅读建议抽出validateGoods()方法。MyBatis-Plus 的insert会自动填充主键所以插入后就能拿到goods.getId()。Service public class GoodsServiceImpl implements GoodsService { Override public Long publish(Goods goods, Long sellerId) { if (goods.getPrice() null || goods.getPrice().compareTo(BigDecimal.ZERO) 0) { throw new BusinessException(价格必须大于0); } if (goods.getTitle() null || goods.getTitle().length() 100) { throw new BusinessException(标题不能为空且不超过100字); } goods.setSellerId(sellerId); goods.setStatus(1); goods.setDeleted(0); goodsMapper.insert(goods); return goods.getId(); } }这里为什么不加Transactional因为只有一个 insert 操作没有多表联动加了也是多余。但在下单方法里事务就变得至关重要因为要同时修改订单表、商品表的状态任何一步失败都要整体回滚。3.4 下单逻辑乐观锁 状态校验双保险防超卖这是整个项目最有技术含量的方法也是答辩时老师最爱追问的地方。核心需求是A 和 B 同时看到一件“在售”商品同时点击购买系统只能让一个人成功。实现思路是层层设防第一层在商品表上增加version字段乐观锁。更新商品状态时带上WHERE status 1 AND version ?更新成功后version自增这样并发请求只有一个能更新成功。MyBatis-Plus 里给实体加Version注解并配置乐观锁插件即可不需要手写 SQL 条件。第二层订单表插入前再次校验商品状态是否为“在售”。事务开始时处于REPEATABLE_READ隔离级别下读到的商品状态是快照但乐观锁更新在写数据时会重新检查所以真正拦截并发的是乐观锁状态校验只是防御纵深。Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long goodsId, Long buyerId) { // 1. 查询商品必须是在售状态 Goods goods goodsMapper.selectById(goodsId); if (goods null || goods.getStatus() ! 1) { throw new BusinessException(商品已下架或已被购买); } // 2. 乐观锁更新商品状态: 1 在售 - 2 锁定 goods.setStatus(2); int rows goodsMapper.update(goods, new LambdaUpdateWrapperGoods() .eq(Goods::getId, goodsId) .eq(Goods::getStatus, 1) .eq(Goods::getVersion, goods.getVersion())); if (rows 0) { throw new BusinessException(手慢了商品已被别人抢先下单); } // 3. 创建订单价格从商品表快照 TradeOrder order new TradeOrder(); order.setOrderNo(generateOrderNo(buyerId)); order.setGoodsId(goodsId); order.setSellerId(goods.getSellerId()); order.setBuyerId(buyerId); order.setPrice(goods.getPrice()); order.setStatus(1); tradeOrderMapper.insert(order); return order; }这里的关键参数是Transactional(rollbackFor Exception.class)。Spring 默认只回滚 RuntimeException如果你自定义的BusinessException继承的是Exception不加rollbackFor的话事务不会回滚就会出现“商品锁定成功但订单创建失败”的数据不一致。rows 0的检查就是在乐观锁失败时给用户一个友好提示而不是抛出底层异常让前端看到 500。4. 本地跑通从 SQL 导入到第一个接口请求的完整命令4.1 建库建表先执行数据库脚本再启动不要指望 JPA 自动建表拿到源码包后第一步永远是导入数据库脚本。有些同学直接mvn spring-boot:run然后控制台报“Table campus_market.goods doesnt exist”才开始找 SQL 文件。正确顺序是先建库再导表MySQL 8 的字符集建议utf8mb4不然存 Emoji 表情会报错。mysql -u root -p -e CREATE DATABASE IF NOT EXISTS campus_market DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; mysql -u root -p campus_market campus_market.sql导入后用SHOW TABLES;确认表数量与预期一致顺手检查一下建表语句里是否每一张表都有主键。这一步很快但很多启动失败都源于此。跑通后再改application.yml里的账号密码默认 root 密码如果不是 root记得改配置否则启动时会一直卡在数据源初始化失败。4.2 用 Maven 启动项目几个常用命令备查项目本身依赖 Maven 管理启动前先确认本地 JDK 版本。Spring Boot 2.x 用 JDK 8 或 11 都没问题如果用了 JDK 17 可能碰到 Lombok 版本不兼容报错信息是cannot find symbol这个时候不是代码问题而是 Lombok 版本太老换 1.18.30 以上即可。cd campus-market mvn clean package -DskipTests java -jar target/campus-market-0.0.1-SNAPSHOT.jarMaven 打包时报错先看是不是依赖没下载完用mvn clean package -DskipTests跳过测试通常能规避很多无关测试用例的干扰。首次打包会下载大量依赖网络不稳时仓库会留下损坏的 lastUpdated 文件常见处理方式是把本地仓库里对应的.lastUpdated删掉后重新编译。启动成功后访问http://localhost:8080能看到 Swagger 文档或前端页面说明启动成功然后在控制台日志里确认Tomcat started on port 8080。4.3 前后端分离时的静态资源坑Vue 打包产物放哪里如果源码带了前端页面大概率是 Vue 项目打包后的 dist 目录。最省事的做法是让后端把 dist 复制到src/main/resources/static目录下再执行mvn packageSpring Boot 就会把它打进 jar 包。注意如果前端用了 history 路由模式后端还需要写一个 forwarding 配置把所有非接口路径转发到index.html否则刷新页面就是 404。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{spring:[^\\.]*}).setViewName(forward:/index.html); } }这个配置只对单页应用有效接口路径不会落到这个规则里因为/api/**已经被RestController接管。如果是页面放本地静态文件还需要配置虚拟路径映射图片目录比如registry.addResourceHandler(/upload/**).addResourceLocations(file:D:/upload/)不然上传的图片在页面上永远显示不出来。5. 踩坑记录我从这类源码里常遇到的五个问题5.1 启动类放错包Controller 全部 404现象项目能启动没有任何报错但访问任何接口都是 404后台日志里也看不到请求日志。原因XxxApplication启动类放在com.example.demo包下而 Controller 放在com.example.controller包下Spring Boot 默认只扫描启动类所在包及其子包com.example.controller不在扫描范围内。解决把启动类移到所有类的最顶层包如com.campus.market或者在启动类上加ComponentScan(basePackages com.campus)。这是最常见的毕设启动失败原因没有之一。5.2 中文存进数据库变成问号现象页面上填的中文描述刷新后变成????。原因数据库连接串没带characterEncodingutf8或表本身是latin1字符集。解决连接串统一改成jdbc:mysql://localhost:3306/campus_market?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai另外确认建表语句里是CHARSETutf8mb4。已经变成问号的数据没法恢复只能删掉重录。5.3 事务不生效异常没回滚商品锁了订单没生成现象下单接口偶尔会出现商品状态变成“锁定”但订单表里没有记录。原因Transactional加在 Service 类的内部方法调用上——比如 Controller 调this.createOrder()但createOrder是私有方法或被同类中另一个方法调用此时 Spring 代理不生效。解决Transactional注解要加在 public 方法上且不能是同类内部this调用。自定义业务异常继承RuntimeException或者显式指定rollbackFor Exception.class避免检查型异常导致事务像没加一样。5.4 图片上传成功但页面显示不出来现象上传接口返回 200但img标签 src 指向/upload/2025/xxx.jpg时 404。原因文件确实写入了本地磁盘但 Spring Boot 没有把/upload/**这个 URL 路径映射到磁盘目录。解决在配置类中加上资源映射把/upload/**映射到工作目录下的upload文件夹。注意路径分隔符 Windows 用盘符Linux 用/毕设一般只在一台机器演示直接写绝对路径即可。5.5 前端页面拿到了 Token 但每请求还是 401现象登录接口返回的 Token 复制到 Postman 手动测试没问题但浏览器里一直 401。原因axios 请求拦截器没把 Token 放进请求头或者放了但名字不是后端读的Authorization。解决后端拦截器里明确读request.getHeader(Authorization)前端就在 axios 请求拦截器里统一设置config.headers[Authorization] Bearer token。有时候是前后端字段大小写不一致比如后端读authorization前端设AuthorizationHTTP 头默认不区分大小写但用 Spring 读取时尽量统一成标准写法。6. 进阶验证用接口自测和并发模拟判断这套源码值不值得用拿到任何一套源码先别急着改业务花一个下午把核心链路验证一遍比看一百行注释都管用。我一般按三条链路测登录鉴权链路、商品发布→列表→详情链路、下单→订单状态流转链路。工具用 Postman 或者写一个简单的 JUnit 集成测试都可以。接口自测重点看四个返回未带 Token 访问受保护接口是不是 401重复下同一件商品第二次是否提示已售卖家是否可以购买自己的商品业务上应当禁止订单取消后商品是否恢复为“在售”状态。这四个点覆盖了权限、并发、异常状态、补偿逻辑能把这四点跑通的毕设源码底子基本不差。# 1. 登录获取 Token curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:test,password:123456} # 2. 携带 Token 发布商品 curl -X POST http://localhost:8080/api/goods \ -H Authorization: Bearer token \ -H Content-Type: application/json \ -d {title:高等数学第五版,price:25.00,category:教材}模拟并发的正确姿势是用 JMeter、Postman Runner 或者直接写一段多线程代码。最简单的方式是 Java 里用CountDownLatch让 10 个线程同时调下单接口观察成功数。预期结果必须是 1 个成功、9 个失败返回“商品已被购买”如果出现 2 个成功说明乐观锁没生效或者事务隔离级别有问题优先排查Version字段和乐观锁插件是否注册。SpringBootTest class OrderConcurrencyTest { Test void testConcurrentOrder() throws InterruptedException { int threadCount 10; CountDownLatch latch new CountDownLatch(threadCount); AtomicInteger success new AtomicInteger(0); for (int i 0; i threadCount; i) { new Thread(() - { try { orderService.createOrder(1L, 2L); success.incrementAndGet(); } catch (Exception ignored) { } finally { latch.countDown(); } }).start(); } latch.await(); System.out.println(成功下单数: success.get()); } }这里有个测试层面的细节Transactional在测试方法上会把操作回滚所以并发测试类不要加Transactional否则每个线程在自己的事务里看不到彼此修改的数据测试结果失真。跑完后去数据库确认商品 status 等于 2、订单表只有一条记录才是真正通过。订单超时关单是这个项目里最常见的进阶需求。毕设阶段不需要引入消息队列用Scheduled定时任务扫描超过 30 分钟未支付的订单即可注意定时任务方法上要加EnableScheduling开启调度。扫描逻辑里把订单状态从 1 改为 4同时把商品 status 从 2 改回 1这两步操作必须在同一个事务里否则可能出现订单取消但商品仍是锁定状态。Component public class OrderTimeoutTask { Scheduled(fixedRate 60000) Transactional public void closeExpiredOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListTradeOrder expiredOrders orderMapper.selectList(new LambdaQueryWrapperTradeOrder() .eq(TradeOrder::getStatus, 1) .lt(TradeOrder::getCreateTime, deadline)); for (TradeOrder order : expiredOrders) { order.setStatus(4); orderMapper.updateById(order); Goods goods goodsMapper.selectById(order.getGoodsId()); if (goods ! null goods.getStatus() 2) { goods.setStatus(1); goodsMapper.updateById(goods); } } } }fixedRate 60000表示每分钟跑一次演示时你不想等 30 分钟就把它改成 50005 秒但时间校验逻辑别动——用 sql 比较create_time DATE_SUB(NOW(), INTERVAL 30 MINUTE)而不是在 Java 代码里硬编码分钟数这样改定时频率只是扫描更快关单延迟仍然保持在 30 分钟。定时任务在单机部署下没问题但多实例部署时会重复执行需要在订单表加一个lock_time或使用分布式锁来解决这是后话。最后说一个我自己的习惯拿到任何毕设源码第一步永远是把application.yml里的密码和密钥换掉第二步是把数据库脚本里的测试数据清空重导第三步才是启动看效果。源码里自带的演示账号和弱密钥是答辩时最容易被扣分的小问题——老师登录你的系统看到明文密码和123456对你整个项目的印象分立刻就下来了。希望这篇笔记能帮你少走点弯路把这套 Spring Boot 校园二手市场项目跑稳、讲清、改出自己的亮点。本文还有配套的精品资源点击获取