☰
SpringBoot校园二手交易平台实战:从数据库设计到订单状态机
2026/10/6 17:29:20 网站建设 项目流程

简介:这套基于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用TINYINT,0表示上架中、1表示已售出、2表示下架,后续订单状态机也要依赖这个字段做联动。图片字段建议存JSON数组字符串,因为一个商品通常有多张图,用逗号拼接不如JSON方便解析。成色字段用TINYINT,对应全新、几乎全新、轻微使用痕迹、明显磨损这几档。

索引设计上,商品按买家视角做三个索引:user_id+status联合索引用于卖家管理我的商品列表,category_id+created_at联合索引用于分类浏览,status+created_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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

包结构按controller → service → mapper → entity四层拆。entity放数据库表对应的实体类,mapper放MyBatis-Plus接口,service写业务逻辑,controller只做参数接收和响应封装。额外加一个common包放统一返回结果、异常处理、工具类。这个结构是springboot项目最主流的组织方式,后续无论接到什么企业项目,看到的都是这套骨架,现在养成习惯不吃亏。

application.yml里有一个参数很容易被忽略。MySQL连接串必须加serverTimezone=Asia/Shanghai,否则驱动默认取JVM时区,导致存入数据库的时间比北京时间早8小时。数据库连接池让SpringBoot默认的HikariCP来管,不用额外引入,maximum-pool-size在校园项目里设10就够。

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_trade?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false 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: 0

map-underscore-to-camel-case打开后,student_no自动映射到studentNo,不用每个字段写@TableField。logic-delete-field配置逻辑删除,这样商品下架不会物理删行,买家还能看到历史订单中的商品快照。

3.2 商品发布链路:Controller到Mapper的完整实现

工程立起来之后,先实现商品发布接口,这条链路串起来,后续的列表和详情接口都是同构的。发布流程是三件事:接收前端参数、补全发布人信息、插入商品表。前端传过来的数据是ProductDTO,Service层把它转成Product实体,再通过BaseMapper插入。DTO和Entity分离是这里最值得养成的好习惯,不要让前端直接操作数据库实体,一方面防止多余字段被赋值,另一方面前端字段格式(比如价格字符串转BigDecimal)在DTO里消化掉。

@RestController @RequestMapping("/api/product") public class ProductController { @Resource private ProductService productService; @PostMapping("/publish") public Result<Long> 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 BaseMapper<Product> { }

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)这个参数很关键,不设的话,前端传pageSize=10000就能把整表拖走,校园项目虽然数据量不大,但这个习惯能避免未来线上接口被刷。

商品分页查询Service写法:

@Override public PageResult<ProductVO> pageProducts(long page, long size, Integer categoryId, Integer sortType) { Page<Product> pageParam = new Page<>(page, size); LambdaQueryWrapper<Product> 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); Page<Product> 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 status=1, version=version+1 // WHERE id=#{id} AND status=0 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); List<Order> 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。解决方法是连接串加上serverTimezone=Asia/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条件漏掉,只用status=0做防超卖,并发一上来还是能卖出两单。排查思路是打开MyBatis的SQL日志,log-impl: org.apache.ibatis.logging.stdout.StdOutImpl会打印完整SQL,直接确认WHERE里有没有带version,没带就是条件丢了。这种问题在单元测试里很难发现,要并发压测才暴露,所以配置压测工具很重要。

5.4 @Transactional事务悄悄失效

现象是Service方法抛了异常,数据库数据还是被改了。最常见原因有两个:第一是方法被同类内部调用,比如createOrder调了同类里的lockProduct,lockProduct上的@Transactional不生效,因为Spring的声明式事务基于代理,同类调用绕过了代理;第二是异常被catch了,事务提交后才发现状态不对。

解决方法是事务注解放在对外入口方法上,内部方法不要加@Transactional;catch异常时要么重新抛出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 商品列表N+1查询,接口越来越慢

现象是商品列表接口每页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),数字魔法值在三个月后回看代码时完全读不懂。另一个是接口返回值统一用Result<T>包装,不要在某些接口直接返回实体类、某些返回Map,统一结构让前端省心,也让代码风格像一个正规项目而不是作业。

这套方案做下来,你手里有清晰的数据库模型、完整的订单状态机、可验证的并发控制手段,校园二手交易平台的源码价值才能真正体现出来。我自己的项目交付习惯是每次改完状态机逻辑就重跑一遍全链路脚本,直到所有测试绿色通过才敢说完成,这个习惯帮我挡掉了不少上线时才发现的问题,希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询