
简介这套基于Spring Boot的校园二手交易平台项目源码与数据库设计面向计算机专业高年级学生可作为课程设计与毕业设计参考项目由导师指导完成并获评98分涵盖用户管理、商品展示、交易撮合、订单处理等核心业务模块。资源包共有262个文件压缩后约3.88兆字节包含34个Java源代码文件、一个SQL数据库脚本、前端所需的HTML与CSS样式、JavaScript脚本、演示动图、备份文件以及多种配置文件目录结构清晰。系统采用Spring Boot、MyBatis Plus、Redis缓存与Shiro权限控制等主流技术实现前后端分离架构数据库遵循第三范式设计并附有系统设计说明书、数据库设计文档、部署指南与接口文档代码注释完整便于二次开发。目前已有81人学习浏览适合课程设计、毕业设计以及项目实战训练是一份经过编译调试和教学审核的完整学习资料。1. 校园二手交易平台为什么值得你用SpringBoot从头搭一遍每年开学季和毕业季校园里总有大量闲置书、小家电、自行车和数码产品但大部分交易仍停留在微信群接龙和朋友圈刷屏。这个标题指向的正是典型的Java后端练手项目基于SpringBoot框架实现用户注册登录、商品发布、分类检索、下单交易和订单管理。从技术栈上看它就是常见的WEB后端但业务链条覆盖了数据建模、文件存储、事务操作和定时任务足够撑起课程设计、毕设甚至简历上的项目经验。这类平台真正吃技术的点不在并发而在订单状态流转和数据一致性。商品浏览是高频操作可一旦涉及下单、付款、取消订单就需要把状态机设计清楚。如果你正在找一套能跑、能改、能讲明白的SpringBoot校园二手交易平台源码或者想自己手写一遍这篇笔记会按数据库设计、工程落地、交易链路、踩坑排查的顺序把每一步讲透。新手能照着跑通最小闭环熟手直接看参数边界和常见翻车点。2. 数据库设计先行用户、商品、订单三张核心表怎么立住2.1 用户表与商品表字段选型和索引设计校园二手平台的业务模型不复杂但表设计决定了后续所有功能的开发成本。我习惯先画ER图再写SQL因为实体关系一旦确定Controller和Service层基本就是照表写代码。这里最核心的三张表分别是用户表、商品表、订单表另外还要补一张订单流水表用来留痕。先看用户表。校园场景需要登记的字段比普通社区少但学号、院系、联系方式、信用分这几个不能省。学号要加唯一索引这是校园账号体系的天然主键联系方式建议存wechat和phone两个字段方便买卖双方线下沟通。密码字段只存哈希值不要存明文springboot后端配合加盐哈希是常规做法。信用分可以设计一个credit_score字段初始值设为100后续根据订单完成情况增减这会给答辩和面试加分。商品表是业务主表。核心字段包括发布人、分类、标题、描述、图片、价格、成色、状态、浏览量。价格必须用DECIMAL(10,2)而不是DOUBLE浮点类型在金额计算上会有精度误差这点在面试里容易被追问。状态字段status用TINYINT0表示上架中、1表示已售出、2表示下架后续订单状态机也要依赖这个字段做联动。图片字段建议存JSON数组字符串因为一个商品通常有多张图用逗号拼接不如JSON方便解析。成色字段用TINYINT对应全新、几乎全新、轻微使用痕迹、明显磨损这几档。索引设计上商品按买家视角做三个索引user_idstatus联合索引用于卖家管理我的商品列表category_idcreated_at联合索引用于分类浏览statuscreated_at用于首页最新商品流。不要对description这类长文本建索引MySQL对TEXT类型字段的索引支持有限检索靠后续引入全文索引或ES再说。2.2 订单表与交易流水状态字段不能省订单表是交易链路的核心它的设计直接决定了后续状态机好不好写。字段我建议这样排order_no业务订单号、product_id商品ID、seller_id卖家ID、buyer_id买家ID、price成交价、status当前状态、pay_time付款时间、complete_time完成时间、cancel_time取消时间、cancel_reason取消原因。这里每个时间字段单独建列而不是只放一个updated_at因为后面做超时订单统计时pay_time IS NULL AND created_at NOW() - INTERVAL 30 MINUTE这种SQL写起来非常顺手。订单状态建议用TINYINT存数字0待付款、1已付款待发货、2已发货待收货、3已完成、4已取消。为什么不用字符串因为状态流转校验在Java里用枚举常量判断更直观存数字省空间且索引效率高但一定要在Java枚举里写清每个数字的含义否则项目放一个月再回来看就成黑匣子了。订单号order_no要用业务规则生成常见做法是yyyyMMddHHmmss 用户ID后四位 随机数并加唯一索引不要依赖数据库自增ID对外暴露。订单流水表记录每个状态的变更痕迹字段为order_id、from_status、to_status、operator_id、remark、created_at。这张表不入核心业务逻辑但遇到买卖纠纷时能溯源也方便在答辩时讲清楚你的系统有完整审计链路。2.3 从ER图到建表SQL一份能直接跑的MySQL脚本下面是一份可以直接执行的MySQL建表脚本。版本兼容MySQL 5.7与8.0字符集统一用utf8mb4引擎用InnoDB。跑完这组SQL数据库层面的最小闭环就可以落地。CREATE DATABASE IF NOT EXISTS campus_trade DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE campus_trade; -- 用户表 CREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, student_no VARCHAR(20) NOT NULL COMMENT 学号唯一, username VARCHAR(30) NOT NULL COMMENT 昵称, password_hash VARCHAR(100) NOT NULL COMMENT 加盐后的密码哈希, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像URL, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, wechat VARCHAR(30) DEFAULT NULL COMMENT 微信号, department VARCHAR(50) DEFAULT NULL COMMENT 院系, credit_score INT NOT NULL DEFAULT 100 COMMENT 信用分, status TINYINT NOT NULL DEFAULT 1 COMMENT 账号状态0禁用1正常, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 商品分类表 CREATE TABLE t_category ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(30) NOT NULL COMMENT 分类名教材/数码/生活用品等, sort_order INT DEFAULT 0 COMMENT 排序权重, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品分类表; -- 商品表 CREATE TABLE t_product ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 商品ID, user_id BIGINT NOT NULL COMMENT 发布人ID, category_id INT NOT NULL COMMENT 分类ID, title VARCHAR(60) NOT NULL COMMENT 商品标题, description TEXT COMMENT 详细描述, images JSON DEFAULT NULL COMMENT 图片URL数组, price DECIMAL(10,2) NOT NULL COMMENT 价格, original_price DECIMAL(10,2) DEFAULT NULL COMMENT 原价参考, condition_level TINYINT DEFAULT NULL COMMENT 成色1全新2几乎全新3轻微痕迹4明显磨损, status TINYINT NOT NULL DEFAULT 0 COMMENT 0上架中1已售出2下架, view_count INT NOT NULL DEFAULT 0 COMMENT 浏览量, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_status (user_id, status), KEY idx_category_time (category_id, created_at), KEY idx_status_time (status, created_at), CONSTRAINT fk_product_user FOREIGN KEY (user_id) REFERENCES t_user (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; -- 订单表 CREATE TABLE t_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, product_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 0 COMMENT 0待付款1已付款待发货2已发货待收货3已完成4已取消, pay_time DATETIME DEFAULT NULL, complete_time DATETIME DEFAULT NULL, cancel_time DATETIME DEFAULT NULL, cancel_reason VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_product (product_id), KEY idx_seller (seller_id), KEY idx_buyer (buyer_id), KEY idx_status_time (status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; -- 订单流水表 CREATE TABLE t_order_log ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL COMMENT 订单表主键, from_status TINYINT DEFAULT NULL, to_status TINYINT NOT NULL, operator_id BIGINT DEFAULT NULL COMMENT 操作人, remark VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单流水表;几个参数说明。password_hash设置100字符长度因为BCrypt加密结果固定为60字符100字符有余量未来换加密算法不用改表。images字段用JSON类型MySQL 5.7以上支持查询时可以WHERE JSON_LENGTH(images) 0如果用TEXT类型存JSON字符串也能跑但没有JSON函数可用建议直接上JSON。外键fk_product_user在校园项目里可以保留它能保证商品必须归属于真实用户如果以后做分库分表外键会成为瓶颈但单库场景下用外键更稳妥。所有时间字段统一DATETIME配合DEFAULT CURRENT_TIMESTAMP应用层就不用手动塞created_at了。3. SpringBoot工程落地从骨架搭建到商品发布接口3.1 工程初始化Maven依赖与分层包结构有了表结构现在动手搭工程。无论你是找源码来改还是自己新建先确认JDK版本和SpringBoot版本匹配。这里有个血泪经验SpringBoot 3.x强制要求JDK17如果本地还是JDK8要么升JDK要么老老实实选SpringBoot 2.7.x。课程设计和毕设场景spring-boot-starter-parent用2.7.18配JDK8是最稳的组合大部分教学环境都是JDK8别因为版本太高把自己卡死在编译上。工程用Maven构建核心依赖就五个web、mybatis-plus、mysql-connector、lombok、validation。mybatis-plus是这里的关键选型它继承了MyBatis的SQL控制力又提供了BaseMapper的通用CRUD写校园项目时能省掉大量重复的XML。不用JPA的原因后面会讲。pom.xml关键内容如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies包结构按controller → service → mapper → entity四层拆。entity放数据库表对应的实体类mapper放MyBatis-Plus接口service写业务逻辑controller只做参数接收和响应封装。额外加一个common包放统一返回结果、异常处理、工具类。这个结构是springboot项目最主流的组织方式后续无论接到什么企业项目看到的都是这套骨架现在养成习惯不吃亏。application.yml里有一个参数很容易被忽略。MySQL连接串必须加serverTimezoneAsia/Shanghai否则驱动默认取JVM时区导致存入数据库的时间比北京时间早8小时。数据库连接池让SpringBoot默认的HikariCP来管不用额外引入maximum-pool-size在校园项目里设10就够。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_trade?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case打开后student_no自动映射到studentNo不用每个字段写TableField。logic-delete-field配置逻辑删除这样商品下架不会物理删行买家还能看到历史订单中的商品快照。3.2 商品发布链路Controller到Mapper的完整实现工程立起来之后先实现商品发布接口这条链路串起来后续的列表和详情接口都是同构的。发布流程是三件事接收前端参数、补全发布人信息、插入商品表。前端传过来的数据是ProductDTOService层把它转成Product实体再通过BaseMapper插入。DTO和Entity分离是这里最值得养成的好习惯不要让前端直接操作数据库实体一方面防止多余字段被赋值另一方面前端字段格式比如价格字符串转BigDecimal在DTO里消化掉。RestController RequestMapping(/api/product) public class ProductController { Resource private ProductService productService; PostMapping(/publish) public ResultLong publish(RequestBody Valid ProductPublishDTO dto, RequestAttribute(currentUserId) Long userId) { Long productId productService.publish(dto, userId); return Result.success(productId); } }Valid触发参数校验比如标题不能为空、价格必须大于0这是JSR-303的标准用法。RequestAttribute(currentUserId)是从拦截器里塞进去的当前登录用户ID后面讲拦截器配置。Controller层不写任何业务逻辑只负责把请求参数交给Service再把Service的结果包成统一返回对象。这样做的直接好处是后续调整返回结构只用改Result一个类。对应Service实现Service RequiredArgsConstructor public class ProductServiceImpl implements ProductService { private final ProductMapper productMapper; Override Transactional(rollbackFor Exception.class) public Long publish(ProductPublishDTO dto, Long userId) { Product product new Product(); BeanUtils.copyProperties(dto, product); product.setUserId(userId); product.setStatus(0); product.setViewCount(0); // 价格类型转换前端传String数据库存decimal product.setPrice(new BigDecimal(dto.getPriceStr())); productMapper.insert(product); return product.getId(); } }BeanUtils.copyProperties是Spring自带的属性拷贝工具DTO里同名属性自动复制过去。价格为什么要单独处理因为前端可能传字符串49.90直接拷贝到BigDecimal字段会类型转换报错所以DTO里用String接收价格Service层手动new BigDecimal()并做格式校验。Transactional(rollbackFor Exception.class)指定所有异常都回滚注意默认只回滚RuntimeException编译期异常不回滚这里显式声明是最严谨的写法。Mapper层最简单Mapper public interface ProductMapper extends BaseMapperProduct { }MyBatis-Plus的BaseMapper已经内置insert、selectById、updateById这些通用方法单表CRUD不需要写任何XML。这就是选MyBatis-Plus的核心理由单表操作全包多表关联再自己写SQL兼顾效率和灵活性。JPA虽然也能实现同样的功能但如果你的SQL基础不够扎实JPA的自动建表和懒加载问题会把排查成本拉高校园项目用MyBatis-Plus更实在。3.3 分页检索接口MyBatis-Plus的分页参数与排序商品列表页是这个平台流量最大的接口。首页要按发布时间倒序展示最新商品分类页要按分类过滤搜索结果按价格或者时间排序。MyBatis-Plus提供分页插件配置一个MybatisPlusInterceptor就可以让Page对象自动生成LIMIT语句。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(50L); // 单页最大50条防止有人一次拉全表 interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(50L)这个参数很关键不设的话前端传pageSize10000就能把整表拖走校园项目虽然数据量不大但这个习惯能避免未来线上接口被刷。商品分页查询Service写法Override public PageResultProductVO pageProducts(long page, long size, Integer categoryId, Integer sortType) { PageProduct pageParam new Page(page, size); LambdaQueryWrapperProduct wrapper Wrappers.lambdaQuery(); wrapper.eq(Product::getStatus, 0) .eq(categoryId ! null, Product::getCategoryId, categoryId) .orderByDesc(sortType null || sortType 0, Product::getCreatedAt) .orderByDesc(sortType ! null sortType 1, Product::getPrice) .orderByAsc(sortType ! null sortType 2, Product::getPrice); PageProduct result productMapper.selectPage(pageParam, wrapper); return PageResult.from(result); }这里的LambdaQueryWrapper用方法引用替代字符串列名编译期就能发现字段名拼写错误。eq(condition, column, val)第一个参数是布尔条件categoryId ! null时才会追加这个条件避免字符串拼接SQL的注入风险。排序参数用三个orderBy方法控制看起来冗余但比在Service层用if拼SQL更安全因为排序字段如果是前端传的字符串直接拼进orderBy就有注入隐患这里用白名单方式枚举了三种排序方式是这类接口最稳的做法。4. 交易链路与状态机订单流转才是平台的核心命脉4.1 订单状态机设计与不可变流转商品发布只是平台的门面真正决定项目质量的是订单从创建到完成的状态流转。校园二手交易没有物流系统状态比电商平台简洁但「谁在什么状态下能做什么操作」必须用代码锁死。我的做法是先定义一个状态枚举把每个状态的合法动作写清楚然后在Service里校验不合法直接抛业务异常。这个过程值得画一张状态图状态图就是你写代码时的对照表。待付款0买家可以取消订单超时30分钟未支付自动取消已付款待发货1卖家可以标记发货买家此时不能取消只能申请退款校园项目可以简化为联系管理员介入已发货待收货2买家可以确认收货卖家不能取消已完成3终态已取消4终态这个状态流转表写好后直接翻译成Java代码Getter AllArgsConstructor public enum OrderStatus { PENDING_PAY(0, 待付款), PAID(1, 已付款待发货), SHIPPED(2, 已发货待收货), COMPLETED(3, 已完成), CANCELLED(4, 已取消); private final int code; private final String desc; // 定义每个状态允许流转到哪些状态 public boolean canTransitTo(OrderStatus target) { switch (this) { case PENDING_PAY: return target PAID || target CANCELLED; case PAID: return target SHIPPED; case SHIPPED: return target COMPLETED; default: return false; } } }canTransitTo方法是状态机的核心它像一张全表任何订单更新前都先跑这个方法校验。把状态判断收敛到枚举里而不是散落在Service的各个if分支后续加新状态比如退款中只改这一处。这个设计在面试时讲出来面试官会知道你理解状态机的本质是收拢复杂度。4.2 创建订单与库存扣减乐观锁处理并发创建订单是整个系统并发压力最大的动作。两个买家同时看到一件商品先到先得不能出现超卖。经典方案是对商品表加version字段更新时带上旧版本号影响行数为0说明商品已被别人抢购。商品表加一个字段version INT NOT NULL DEFAULT 0。下单的Service逻辑如下Override Transactional(rollbackFor Exception.class) public Long createOrder(Long productId, Long buyerId) { // 1. 查商品必须带上乐观锁版本 Product product productMapper.selectById(productId); if (product null || product.getStatus() ! 0) { throw new BizException(商品不存在或已下架); } // 2. 扣减并发UPDATE t_product SET status1, versionversion1 // WHERE id#{id} AND status0 AND version#{version} int rows productMapper.lockProduct(productId, product.getVersion()); if (rows 0) { throw new BizException(手慢了商品已被拍下); } // 3. 执行到这里说明抢购成功生成订单 Order order new Order(); order.setOrderNo(generateOrderNo(buyerId)); order.setProductId(productId); order.setSellerId(product.getUserId()); order.setBuyerId(buyerId); order.setPrice(product.getPrice()); // 价格快照存订单 order.setStatus(OrderStatus.PENDING_PAY.getCode()); orderMapper.insert(order); return order.getId(); }从下单到订单建立整个流程放一个事务里商品状态翻转、订单插入、流水记录要么全部成功要么全部回滚。lockProduct是自定义SQL写在Mapper接口上Update(UPDATE t_product SET status 1, version version 1 WHERE id #{id} AND status 0 AND version #{version}) int lockProduct(Param(id) Long id, Param(version) Integer version);rows 0时抛出业务异常事务回滚订单不会插入。这个方案依赖数据库的行级锁UPDATE语句会在行上加锁第二个买家的事务会阻塞到第一个事务提交或回滚能保证不超卖。version字段是每次更新递增的UPDATE语句必须带上查询时的旧版本号如果别人已经改过影响行数就是0。注意lockProduct的WHERE条件里有status 0这是双保险即使某次更新忘了带版本号状态条件也能挡住已售出的商品。4.3 取消订单与超时自动关闭Scheduled定时任务校园平台最常见的操作是买家拍下后不付款卖家的商品被挂起。所以待付款订单必须有过期机制常见做法是30分钟自动取消并恢复商品为可售状态。这个功能用SpringBoot的Scheduled加一个轮询任务就能实现不用引入消息队列中间件。Component RequiredArgsConstructor Slf4j public class OrderTimeoutTask { private final OrderMapper orderMapper; private final ProductMapper productMapper; Scheduled(cron 0 */5 * * * ?) // 每5分钟扫描一次 Transactional(rollbackFor Exception.class) public void cancelTimeoutOrders() { // 查出所有超过30分钟仍未付款的待付款订单 LocalDateTime deadline LocalDateTime.now().minusMinutes(30); ListOrder timeoutOrders orderMapper.selectList( Wrappers.lambdaQuery(Order.class) .eq(Order::getStatus, OrderStatus.PENDING_PAY.getCode()) .lt(Order::getCreatedAt, deadline) .last(LIMIT 200) // 单次批量上限防止处理太多卡事务 ); for (Order order : timeoutOrders) { // 状态机校验待付款 - 已取消 if (!OrderStatus.PENDING_PAY.canTransitTo(OrderStatus.CANCELLED)) { continue; } order.setStatus(OrderStatus.CANCELLED.getCode()); order.setCancelTime(LocalDateTime.now()); order.setCancelReason(超时未支付系统自动取消); orderMapper.updateById(order); // 恢复商品状态为可售 Product product productMapper.selectById(order.getProductId()); if (product ! null product.getStatus() 1) { product.setStatus(0); productMapper.updateById(product); } log.info(自动取消超时订单{}, order.getOrderNo()); } } }cron 0 */5 * * * ?表示每5分钟执行一次这里用一个接近实际业务的频率来解释参数秒、分、时、日、月、周0 */5是第0秒触发每5分钟一次。Scheduled是单机定时任务的常规方案如果以后部署多实例同一个定时任务会在每台机器上各执行一遍此时要用分布式锁比如Redis的SETNX保证只有一个实例真正执行。校园项目的部署规模通常单实例就够但注释里要写明这个隐患面试时主动提出来能体现你的架构意识。deadline LocalDateTime.now().minusMinutes(30)这行的含义是查出创建时间早于当前时间减30分钟的订单注意不要用created_at与当前时间直接比较因为SQL里NOW()的时区受连接参数影响容易埋雷。limit 200是单批处理上限防止一次查出上万条超时订单导致事务过大后面的轮询会在下个周期继续处理剩余订单。5. 避坑记录校园二手平台最常见的6个翻车点5.1 数据库时间比北京时间少8小时现象是存入MySQL的created_at比本地时间晚8小时查出来再展示到页面上就更乱了。原因是MySQL连接串没配置serverTimezone驱动默认用JVM时区而JVM时区是UTC。解决方法是连接串加上serverTimezoneAsia/Shanghai同时确认MySQL服务端时区也设为东八区。这里有第二个坑如果连接串配了Asia/Shanghai但MySQL时区文件缺失连接会报错此时在MySQL执行SET GLOBAL time_zone 8:00最保险。5.2 商品图片路径404刷新后消失本地存储图片时最常犯的错误是把文件写到某个私人目录然后前端直接访问http://localhost:8080/upload/xxx.jpg结果404。原因是SpringBoot的默认静态资源路径只有classpath:/static/你写的文件不在这个目录下。解决方法是配置自定义静态资源映射让/upload/**指向磁盘目录Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath System.getProperty(user.dir) /upload/; registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }addResourceHandler(/upload/**)定义URL前缀addResourceLocations(file: uploadPath)映射到磁盘真实路径。注意file:前缀不能省否则SpringBoot会当成classpath路径处理。部署时不要把upload目录放在src/main/resources里否则重新打包就全丢了这是毕设项目最常见的翻车点。5.3 乐观锁版本号失效上一节写的lockProduct里用了WHERE ... AND version #{version}看起来没问题但如果你在Service里先selectById查出version执行更新前又被其他线程改了值update就会失败。很多同事手滑把version条件漏掉只用status0做防超卖并发一上来还是能卖出两单。排查思路是打开MyBatis的SQL日志log-impl: org.apache.ibatis.logging.stdout.StdOutImpl会打印完整SQL直接确认WHERE里有没有带version没带就是条件丢了。这种问题在单元测试里很难发现要并发压测才暴露所以配置压测工具很重要。5.4 Transactional事务悄悄失效现象是Service方法抛了异常数据库数据还是被改了。最常见原因有两个第一是方法被同类内部调用比如createOrder调了同类里的lockProductlockProduct上的Transactional不生效因为Spring的声明式事务基于代理同类调用绕过了代理第二是异常被catch了事务提交后才发现状态不对。解决方法是事务注解放在对外入口方法上内部方法不要加Transactionalcatch异常时要么重新抛出RuntimeException要么用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚。SpringBoot 2.x默认使用CGLIB代理同类内部调用不走代理这个坑不搞清楚排查起来非常费时。5.5 Scheduled定时任务重复执行部署多实例后超时订单有可能被多个实例各处理一遍。虽然上面的代码在循环里先判断了状态再更新但两个实例可能同时都读到PENDING_PAY的订单同时尝试更新就会产生重复日志。解决方法是引入Redis分布式锁在任务方法最外层加锁String lockKey task:order:timeout; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (!locked) { return; // 另一个实例已在执行 } try { cancelTimeoutOrders(); } finally { redisTemplate.delete(lockKey); }setIfAbsent配合过期时间10秒是Redis分布式锁的最小实现。finally里删锁是防止任务执行中抛异常导致锁永不释放。单实例部署可以不加锁但代码里预留这个机制以后扩容不用改逻辑。5.6 商品列表N1查询接口越来越慢现象是商品列表接口每页20条却执行了21条SQL。原因是查询商品后还要拿卖家昵称和头像直接循环里selectById查用户表。解决方法是先查出商品列表收集所有userId后用IN查询一次拿到全部用户Map再内存中组装。列表页能快一个数量级。顺便讲一个参数MyBatis-Plus的selectBatchIds可以一次传集合批量查询比循环单查优雅得多。所有列表接口都要按这个思路设计。6. 验收这套源码从接口测试到说服面试官的验证方法最后这块针对已经拿到源码或刚写完代码的人讲怎么验证这套系统确实可用、可讲。直接启动完Application主类就当成做完了是这个阶段最常见的错觉你要验证的是「状态机真的跑得通」「并发抢购真的不超卖」「索引真的生效」。这些验证动作做一遍也是你面试时最有说服力的素材。先验证状态机。用Postman或者curl跑一遍完整链路注册用户A和用户B → A发布商品 → B下单 → B支付校园项目可以模拟支付直接调接口更新状态→ A发货 → B确认收货 → 检查订单状态和商品状态。每一步之后查一次数据库比对t_order和t_product的状态字段。这个流程跑通后再测试异常链路B下单后不支付等定时任务跑一轮确认订单被取消且商品恢复上架。这两条链路就是面试时讲项目的主线建议写成接口自动化脚本每次改完代码重跑一遍。再验证并发安全。用JMeter起200个线程同时点击同一件商品的购买接口观察数据库里订单只有一条商品状态为已售出。跑完看日志里有没有「手慢了」异常抛出。这个测试能同时验证乐观锁和事务是否生效也是面试官最可能追问的点。如果并发测试发现异常优先检查lockProduct的SQL是不是真的带了version条件。最后用EXPLAIN验证索引。在MySQL客户端执行EXPLAIN SELECT * FROM t_product WHERE status 0 AND category_id 1 ORDER BY created_at DESC LIMIT 20确认key字段命中了idx_category_time或idx_status_time如果显示NULL说明这条查询没有走索引全表扫描。索引设计得再好没有EXPLAIN验证都是纸面功夫这一步的花费时间只要几分钟但效果立竿见影。代码规范上还有两个习惯值得现在养成。一个是所有状态字段的赋值必须从枚举取不要直接写数字比如order.setStatus(OrderStatus.PAID.getCode())而不是order.setStatus(1)数字魔法值在三个月后回看代码时完全读不懂。另一个是接口返回值统一用ResultT包装不要在某些接口直接返回实体类、某些返回Map统一结构让前端省心也让代码风格像一个正规项目而不是作业。这套方案做下来你手里有清晰的数据库模型、完整的订单状态机、可验证的并发控制手段校园二手交易平台的源码价值才能真正体现出来。我自己的项目交付习惯是每次改完状态机逻辑就重跑一遍全链路脚本直到所有测试绿色通过才敢说完成这个习惯帮我挡掉了不少上线时才发现的问题希望帮到你。本文还有配套的精品资源点击获取