☰
Spring Boot校园二手交易系统:从数据库设计到并发避坑实战
2026/9/29 15:15:18 网站建设 项目流程

简介:基于Java与MySQL技术的校园二手商品交易系统设计与实现文档,面向计算机专业学生、毕业设计人员及需要搭建同类交易平台的开发者。系统采用B/S架构,角色分为前端用户和管理员:前端用户可完成商品发布、浏览、购买、购物车与订单管理;管理员则可统一处理用户管理、商品审核和订单管理等后台操作,有效解决校园二手物品信息分散、交易效率低等常见问题。压缩包仅含1个docx文档,大小约399KB,内容以完整论文形式呈现,涵盖绪论、系统开发环境、可行性分析、数据库设计、功能模块实现及系统测试等核心章节,并附有中英文摘要和目录,便于按章节快速定位。目前已有109人学习,适合作为课程设计或毕业设计的参考资料,也能为后续二次开发或功能扩展提供直接思路。

1. 校园二手交易系统到底要做什么:先别急着写代码

毕业季的宿舍楼下,旧教材、考研资料、台灯风扇堆了一地,买卖双方全靠微信群接龙和表情包讨价还价,信息散、没审核、交易全靠人品。校园二手商品交易系统要解决的正是这个问题:把闲置商品发布、浏览筛选、下单支付、订单追踪收敛到一个 Web 系统里,让买卖双方有据可查。这类题目也是 Java 后端最典型的练手项目,学生做课程设计、毕业设计,甚至准备 java 面试时拿它复盘 Spring Boot 全家桶,都很合适。这篇笔记会把技术选型、数据库设计、核心接口实现和并发场景下的坑一次讲透,照着搭能跑,答辩能讲出深度。

2. 系统设计与技术选型:为什么 Spring Boot + MyBatis Plus 是课设最省心的组合

2.1 技术栈选型:从 Servlet/JSP、SSH 到 Spring Boot 的取舍

选技术栈之前先想清楚:这个项目要撑起什么。它需要用户注册登录、商品 CRUD、图片上传、下单流程、订单状态流转,并发量不大但要有基本的并发安全设计。这个定位决定了用单体应用最合适,不需要拆微服务。

现在做 Java 后端,我一般直接选 Spring Boot,而不是还在用 Servlet + JSP 或者 SSH(Struts + Spring + Hibernate)的老方案。Spring Boot 内置 Tomcat,starter 一键引入依赖,不再需要写一堆 XML 配置,这对课设的时间成本是决定性的。Spring Boot 生态里,持久层用 MyBatis Plus 比原生 MyBatis 省事很多:单表 CRUD 不用手写 SQL,内置分页插件,代码量能砍掉一半。前端方面,如果是纯课设可以用 Thymeleaf 服务端渲染,但如果你想在简历上写“前后端分离”,就配一个 Vue 3 简单的页面,接口统一返回 JSON。

这里有个值得注意的点:答辩时老师最爱问“你为什么选这个框架”。回答思路是——Spring Boot 解决配置繁琐和部署成本的问题,MyBatis Plus 解决单表操作的重复劳动,JWT 解决 Session 在集群环境下不共享的问题。把每个选型和一个具体痛点挂钩,比背概念有说服力得多。

2.2 数据库设计:用户、商品、订单三张核心表怎么建才不被答辩老师问倒

数据库设计是一个系统的地基,表建不好后面全是补丁。校园二手交易系统最核心的就是三张表:用户表、商品表、订单表。围绕它们可以有收藏表、浏览记录表,但先保证核心三张表设计合理,再考虑附加功能。

用户表要注意几个细节:username 要加唯一索引,防止重复注册;password 字段长度不要设成 32,因为要用 BCrypt 加密,密文长度超过 32;status 字段做逻辑禁用,不要物理删除用户。

商品表要区分“库存”和“状态”。二手市场一个商品通常只有一件库存,但设计上保留 stock 字段,因为有人会卖批量教材或同款多件。

订单表的重点在 status 字段,这是整个系统最容易讲出技术含量的地方。订单状态流转要闭合:待付款 → 待发货(卖家确认) → 待收货(买家确认) → 已完成,加上已取消。二手交易的发货环节和电商不同,可以理解为卖家线下交付或校园自提。

CREATE TABLE `tb_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` varchar(32) NOT NULL COMMENT '登录名', `password` varchar(128) NOT NULL COMMENT 'BCrypt密文', `nickname` varchar(32) DEFAULT '' COMMENT '昵称', `phone` varchar(11) DEFAULT '' COMMENT '手机号', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1可用 0封禁', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `tb_goods` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `seller_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 '原价', `category` varchar(32) DEFAULT '' COMMENT '分类:教材/数码/生活/其他', `images` varchar(1000) DEFAULT '' COMMENT '逗号分隔的图片路径', `stock` int(11) NOT NULL DEFAULT '1' COMMENT '库存', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0在售 1下架 2已售出', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_seller` (`seller_id`), KEY `idx_status_time` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表'; CREATE TABLE `tb_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `goods_id` bigint(20) NOT NULL COMMENT '商品ID', `buyer_id` bigint(20) NOT NULL COMMENT '买家ID', `seller_id` bigint(20) NOT NULL COMMENT '卖家ID', `amount` decimal(10,2) NOT NULL COMMENT '成交价', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待付款 1待发货 2待收货 3已完成 4已取消', `remark` varchar(255) DEFAULT '' COMMENT '买家留言', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL COMMENT '付款时间', `finish_time` datetime DEFAULT NULL COMMENT '完成时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_buyer` (`buyer_id`), KEY `idx_seller` (`seller_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

三张表的字段设计有几个共同原则:主键用自增 bigint 而不是 UUID,因为 InnoDB 聚簇索引对自增主键性能最好,课设阶段不需要考虑分布式 ID;时间字段由数据库默认值管理,代码里不手动 set,避免各机器时钟不一致;金额用 decimal 而不是 float/double,float 有精度损失,涉及钱必须用定点数。

提示:order 是 MySQL 的保留字,所以表名用tb_order,字段名也避免直接用 order。这一条不长记性的人在写 SQL 时会翻车。

2.3 工程结构:用分包还是分层,决定你后面写代码顺不顺手

工程结构没有绝对标准,但有一致的倾向:按“先分层、再功能”组织,比纯按功能分包更适合这个体量的项目。把 controller、service、mapper、entity 作为顶层包,让每一层的职责一眼可见;在 service 下面再按业务域拆分 impl。

com.campus.secondhand ├── config # WebMvc、拦截器、跨域配置 ├── common # 统一返回结果、异常处理器、常量 ├── controller # 接口层,只做参数接收和结果封装 ├── service # 业务层接口 │ └── impl # 业务实现 ├── mapper # MyBatis Plus Mapper 接口 ├── entity # 数据库实体 ├── dto # 接收请求参数的模型 ├── vo # 返回给前端的视图模型 └── utils # JWT、订单号生成等工具

分包之后定义一个规矩:controller 里不写业务逻辑,只做参数校验和调用 service;service 里不出现 HttpServletRequest 这类 Web 对象,保证业务层可以独立测试。这样代码写到最后,答辩老师让你讲“某个功能是怎么实现的”,你可以很清晰地指出链路:Controller 接收参数 → Service 校验并处理 → Mapper 操作数据库,比在五六个文件里翻来翻去强得多。

3. 从零跑通 MVP:注册登录、商品发布、下单三步走

3.1 项目初始化:pom.xml 与配置文件一次写对

新建一个 Spring Boot 项目时,我习惯用 2.7.x + JDK8 的组合,兼容性最好,遇到坑时网上能查到的资料也最多。如果 JDK 版本是 17 以上,可以直接上 Spring Boot 3.x,但要注意 Spring Boot 3 的包名从 javax 改成了 jakarta,拦截器、Servlet 相关的 API 有变化。

<!-- pom.xml 核心依赖 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</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>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

配置文件中最重要的三个配置项是数据源、MyBatis Plus 驼峰映射和 Jackson 时间格式。数据库驱动升级到 8.x 后,连接串必须带 serverTimezone,否则查出来的时间会差 8 小时;这个问题放在第 5 章展开。

# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/secondhand? useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver 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 spring.jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

driver-class-name 用com.mysql.cj.jdbc.Driver,这是 MySQL 8.x 驱动的标准写法。MyBatis Plus 的逻辑删除配置会让你在实体里加deleted字段,之后每次 delete 都变成 update,对课设来说够用,也方便后期加数据统计。

注意:log-impl 在开发环境打开,能直接在控制台看到 SQL 语句和参数,排查问题非常有用;部署时可以去掉。

3.2 统一返回结果与全局异常处理:代码里最容易被忽略的地基

前后端分离的接口风格要统一,不然前端处理起来要写一堆 if else。我通常定义一个 Result 类,所有接口都返回这个结构。同时在 common 包里写全局异常处理器,业务异常和系统异常分开处理。

@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(Integer code, String message) { Result<T> result = new Result<>(); result.setCode(code); result.setMessage(message); return result; } }
@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BizException.class) public Result<Void> handleBizException(BizException e) { log.warn("业务异常: {}", e.getMessage()); return Result.error(e.getCode(), e.getMessage()); } @ExceptionHandler(Exception.class) public Result<Void> handleException(Exception e) { log.error("系统异常", e); return Result.error(500, "系统繁忙,请稍后再试"); } }

这里的思路是:BizException 是业务上能预判的错误,比如“库存不足”“不能买自己的商品”,这类错误要返回给用户看;而 Exception 兜底处理未知异常,避免堆栈信息直接暴露给前端,也避免 500 页面白屏。定义好这套结构后,业务代码里只需要throw new BizException("xxx"),接口层不需要写 try catch。

3.3 JWT 注册登录与拦截器:无状态登录的落地写法

登录模块要完成两件事:注册时密码加密存储,登录时签发 Token。密码加密不要用 MD5,MD5 可以暴力破解,用 Spring Security 里的 BCryptPasswordEncoder,虽然引入了额外的依赖,但安全性是质的提升。

@Component public class JwtUtil { // 密钥必须 >= 32 字节,否则 jjwt 在运行时直接抛异常 private static final String SECRET = "campus-second-hand-trade-jwt-secret-key-2024"; private static final long EXPIRE = 7 * 24 * 3600 * 1000L; public String createToken(Long userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }

登录接口先查用户,再比对 BCrypt 密码,成功后生成 Token 返回。之后每次请求浏览器或前端在 Header 里带上Authorization: Bearer token。

@PostMapping("/login") public Result<String> login(@RequestBody @Valid LoginDTO dto) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUsername, dto.getUsername()); User user = userMapper.selectOne(wrapper); if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) { return Result.error(400, "用户名或密码错误"); } if (user.getStatus() == 0) { return Result.error(400, "账号已被封禁"); } String token = jwtUtil.createToken(user.getId(), user.getUsername()); return Result.success(token); }

除登录注册接口外,其他接口都要校验 Token。实现方式用 Spring MVC 的拦截器,在 preHandle 里解析 Token,把 userId 放入 request 属性供 Controller 使用。

public class LoginInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; public LoginInterceptor(JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { try { Claims claims = jwtUtil.parseToken(token.substring(7)); request.setAttribute("userId", claims.getSubject()); return true; } catch (Exception e) { // token 过期、被篡改、签名不匹配都会走到这里 } } response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } }

拦截器里有两个细节值得说:一是 OPTIONS 请求要放行,否则前后端分离跨域时预检请求直接 401;二是把 userId 放在 request attribute 而不是创建一个 ThreadLocal,虽然 ThreadLocal 更优雅,但拦截器里做 set 操作,轻量简单不容易内存泄漏。

提示:Token 过期时间不要设太长,7 天对校园交易场景合适。如果要做“记住我”功能,再单独签一个 30 天的长 Token,不要直接拉长主 Token 的有效期。

3.4 商品发布与图片上传:本地存储还是对象存储

商品发布的核心是图片上传。课设阶段我不建议引入 OSS 对象存储,一是要开通云服务,二是答辩时老师会追问“你项目的成本”。用本地磁盘存储最直接,但要把路径设计好。

@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file, HttpServletRequest request) { if (file.isEmpty()) { return Result.error(400, "上传文件不能为空"); } // 限制文件大小在 5MB 以内 if (file.getSize() > 5 * 1024 * 1024) { return Result.error(400, "图片大小不能超过5MB"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = System.currentTimeMillis() + UUID.randomUUID().toString().replace("-", "") + ext; String basePath = System.getProperty("user.dir") + "/upload/"; File dir = new File(basePath); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(basePath + filename)); } catch (IOException e) { return Result.error(500, "文件上传失败"); } return Result.success("/upload/" + filename); }

注意System.getProperty("user.dir")拿到的是项目启动时的工作目录,IDEA 里一般是项目根目录。图片存到项目根目录/upload/下,再通过 WebMvc 配置一个资源映射,让/upload/**能访问到磁盘上的文件。

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath = System.getProperty("user.dir") + "/upload/"; registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor(jwtUtil)) .addPathPatterns("/**") .excludePathPatterns("/auth/**", "/goods/public/**", "/upload/**"); } }

这段配置同时做了两件事:静态资源映射和拦截器注册。excludePathPatterns 要把登录注册、公开商品列表、图片访问都排除掉,否则前端连个图片都加载不出来。

3.5 下单流程:订单状态机的设计与接口约束

下单是整个系统里业务逻辑最重的一步,也是答辩老师最喜欢深挖的地方。先规定状态流转规则,再写代码,不要边写边想。

订单状态的流转路径是:买家下单创建订单(待付款)→ 模拟支付(待发货)→ 卖家确认发货(待收货)→ 买家确认收货(已完成)。任意状态下买家可以取消订单,取消后商品要恢复库存。

@Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderDTO dto) { Goods goods = goodsMapper.selectById(dto.getGoodsId()); if (goods == null || goods.getStatus() != 0) { throw new BizException("商品已下架"); } if (goods.getSellerId().equals(dto.getBuyerId())) { throw new BizException("不能购买自己发布的商品"); } // 扣减库存,返回受影响行数判断是否成功 int rows = goodsMapper.deductStock(goods.getId()); if (rows == 0) { throw new BizException("商品库存不足"); } // 生成业务订单号:时间戳 + 用户ID后四位 + 随机数 Order order = new Order(); order.setOrderNo(generateOrderNo(dto.getBuyerId())); order.setGoodsId(goods.getId()); order.setBuyerId(dto.getBuyerId()); order.setSellerId(goods.getSellerId()); order.setAmount(goods.getPrice()); order.setStatus(0); orderMapper.insert(order); return order.getId(); }

这里的关键点是扣库存和创建订单在同一个事务里,任何一个失败都要回滚。deductStock对应的 SQL 用了条件更新,这是并发安全的第一道防线:

<update id="deductStock"> update tb_goods set stock = stock - 1, update_time = now() where id = #{id} and stock > 0 and status = 0 </update>

这条 SQL 的巧妙之处在于,stock > 0是条件的一部分。两个请求同时执行时,数据库的行锁会让它们串行执行,第二个请求因为 stock 已经变成 0,更新行数为 0,代码里就能识别出“抢购失败”。这是第 4 章并发问题的核心,下单模块先把这条 SQL 写对,后面就省事一半。

4. 并发与数据一致性:超卖、重复下单不是面试题而是真问题

4.1 超卖为什么会发生:先复现,再理解

“超卖”这个词在交易系统里指的是卖出的商品数量超过了实际库存。课设答辩时你说自己解决了超卖问题,老师一定会追问“怎么解决的”。先来看一个典型的错误实现:

// 错误示范:先查询再判断 Goods goods = goodsMapper.selectById(goodsId); if (goods.getStock() > 0) { // 模拟业务耗时,此时另一个请求也通过了库存判断 goods.setStock(goods.getStock() - 1); goodsMapper.updateById(goods); orderMapper.insert(order); }

这段代码在单线程下运行没有任何问题,但在高并发下,两个请求同时查到 stock=1,都满足stock > 0,然后都执行减一,库存变成 0,订单却创建了两条。“先查再改”天然存在竞态窗口,即使加 synchronized 也只能管单个 JVM 实例,部署多实例就失效了。

正确的思路是:把“判断库存有余量”和“扣减库存”合并成一条原子操作,让数据库的行锁来兜底,也就是第 3 章里那条update ... and stock > 0的写法。任何框架层面的锁都比不上数据库行锁在这个场景里的可靠性。

4.2 乐观锁与条件更新:让数据库帮我们做决策

MyBatis Plus 自带的乐观锁插件是另一种方案:给商品表加 version 字段,更新时带上 version 条件,版本号不匹配就更新失败。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }

实体类加上@Version注解的版本字段。执行 update 时,MyBatis Plus 会自动把 SQL 改成set stock = stock - 1, version = version + 1 where id = ? and version = ?。但这里有个天然的短板:对于“库存扣到 0 就停止”的业务,条件更新比乐观锁更直接——因为条件更新不需要每张表加 version 字段,也不存在版本号不断增长的问题。我建议优先用条件更新,把乐观锁用在上架编辑的场景。

4.3 重复下单与幂等性设计:前端防不住的事

前端按钮点击一次后禁用,能防住手快的用户,但防不住网络重试、脚本调用。后端要做的是一套幂等机制:同一个买家在同一时间段内对同一商品只能生成一个有效订单。

实现方式有两种。第一种是数据库层兜底:订单表加唯一索引uk_buyer_goods,字段为buyer_id和goods_id,插入时如果重复就报错,业务层捕获异常后返回“请勿重复下单”。这种方案简单,但要保证“已取消的订单不占用唯一索引”,所以唯一索引只能建在状态为有效订单上,实现起来要加一个冗余字段。

第二种是 Redis 幂等键:下单前先按buyer:goods的 key 设置一个带过期时间的标记,set 成功才允许下单,下单完成后删除标记。这种方案更灵活,但课设如果没引入 Redis,可以用数据库代替。

// 基于数据库的幂等校验 Long count = orderMapper.selectCount(new LambdaQueryWrapper<Order>() .eq(Order::getBuyerId, dto.getBuyerId()) .eq(Order::getGoodsId, dto.getGoodsId()) .in(Order::getStatus, 0, 1, 2)); // 待付款、待发货、待收货 if (count > 0) { throw new BizException("你已下过单,请勿重复操作"); }

注意这里只查状态为进行中的订单,已取消和已完成的订单不阻塞后续购买。同时在业务层再加一个分布式锁,用商品 ID 作为锁的 key,锁获取不到就提示“商品正在交易中”。

4.4 缓存一致性:引入 Redis 之后的新问题

如果商品列表加了 Redis 缓存,用来扛首页流量,那么卖家修改商品信息后,缓存会变成脏数据。这就是数据一致性问题的来源。

常见做法是 Cache Aside Pattern:读请求先查缓存,缓存没有则查数据库并回填;写请求先更新数据库,再删除缓存。要注意顺序不能反——先删缓存再更新数据库,中间会有并发请求把旧数据写回缓存。

public Goods getGoodsDetail(Long goodsId) { String key = "goods:detail:" + goodsId; Object cache = redisTemplate.opsForValue().get(key); if (cache != null) { return JSON.parseObject(cache.toString(), Goods.class); } Goods goods = goodsMapper.selectById(goodsId); if (goods != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES); } return goods; } public void updateGoods(Goods goods) { goodsMapper.updateById(goods); redisTemplate.delete("goods:detail:" + goods.getId()); }

这个模式的价值在于:缓存删除失败时,最多导致一次缓存穿透回源数据库,不会出现长时间的数据错乱。如果要做二次保险,可以在更新数据库前把缓存标记为“预热中”,但课设阶段没必要把架构复杂度拉到那个程度,把 Redis 讲清楚、把为什么先更新数据库后删缓存讲明白,就已经超过大部分同题目的作品了。

5. 整合测试与避坑排查:答辩前必须趟过的六个坑

5.1 数据库时区导致的日期错位 8 小时

现象:前端页面上创建时间比实际时间晚了 8 小时,明明下午 3 点发布的商品,页面显示早上 7 点。

原因:MySQL 驱动 8.x 版默认连接时区是 UTC,而中国在东八区,两个时间差 8 小时。如果没在 JDBC URL 里指定 serverTimezone,驱动就拿系统默认时区去读,本地开发和服务器部署常常不是一个时区,问题就出现了。

解决:在 JDBC URL 里显式加上serverTimezone=Asia/Shanghai,同时在application.yml里配置spring.jackson.time-zone: GMT+8,让 Jackson 序列化时间时也使用东八区。这两处都配了,数据库读写和接口返回才一致。

5.2 MyBatis Plus 驼峰映射失效

现象:数据库字段有create_time,实体类属性是createTime,查询出来的 createTime 是 null,其他字段正常。

原因:MyBatis Plus 默认开启了下划线转驼峰,这一点通常不出问题。出问题的是当你手写了 XML 的 resultMap,或者从网上抄了一段自定义映射代码,resultMap 里的 column 写的是数据库字段名,property 写的是属性名,但只列了一部分字段,没列的字段就映射不上。

解决:优先用 MyBatis Plus 的默认自动映射,不要手写 resultMap。如果必须自定义,把实体类所有字段都列全,或者检查map-underscore-to-camel-case配置项是否被误改为 false。

5.3 JWT 密钥太短导致启动即报错

现象:项目启动时报错,异常信息类似The signing key's size is 512 bits, which is less than the minimum allowed for HS256。

原因:jjwt 0.9.x 对 HS256 算法要求密钥至少 256 位,也就是 32 个字节。很多人习惯写SECRET = "123456",字节数不足,JWT 生成 Token 时直接抛异常。

解决:密钥最少 32 个字符,并且不要用纯数字或纯字母的简单序列。建议用一个由大小写字母、数字、符号混合的字符串,长度 32 位以上。如果你把密钥写死在代码里,注意别提交到公开的 Git 仓库,答辩时的演示环境没必要追求完美,但这个习惯要养成。

5.4 图片上传后重启服务就丢失

现象:上传的图片当时能访问,重启应用后发现图片 404。

原因:把图片写到了target/classes/目录或者项目相对路径下的临时目录。Spring Boot 在 IDEA 中运行和打包成 jar 运行的工作目录不同,打包后System.getProperty("user.dir")可能指向 jar 所在的目录,如果再清理 target 目录,文件就没了。

解决:上传目录不要放在项目编译输出目录里,单独在项目根目录或磁盘固定位置建upload目录,比如 Linux 上用/data/campus-secondhand/upload,Windows 上用D:/upload。把路径做成配置项,部署时通过配置文件改,不要写死在代码里。这是生产环境的基本功,答辩时讲到这反而是加分项。

5.5 事务注解自调用失效

现象:下单接口在 Controller 里调用同一个类的另一个方法,方法上标了@Transactional,但库存扣减后抛异常,数据库没有回滚。

原因:Spring 事务是通过 AOP 代理实现的,只有外部调用代理对象的接口方法时,事务注解才生效。同一个类里 this.method() 调用绕过代理,事务不生效。这几乎是 Java 面试里事务模块的必考题,也是实际开发里最容易踩的坑。

解决:把事务加到 Controller 调用的那个方法上,也就是入口方法;不要在同类方法之间做带有事务注解的相互调用。如果确实需要拆分方法,可以把事务方法放到另一个 Service 里注入调用,或者使用AopContext.currentProxy()获取代理对象。

5.6 接口返回的日期是数组格式

现象:前端拿到的createTime是[2024, 5, 20, 15, 30, 0]这种数组,没法直接展示。

原因:Jackson 序列化LocalDateTime时,默认走的是 JavaTimeModule 的默认格式,序列化成数组结构。Spring Boot 的spring.jackson.date-format配置对Date类型有效,对 Java 8 的LocalDateTime不生效。

解决:在application.yml里增加spring.jackson.serialization.write-dates-as-timestamps: false,让 LocalDateTime 按 ISO 格式输出;更稳妥的方案是为 LocalDateTime 自定义 ObjectMapper 的序列化器,或者在实体字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")。我一般选择全局配置加字段注解双保险,字段注解的优先级更高。

5.7 常见的初始化必坑项补充

还有一个课设特别常见的现象:多人用同一台 MySQL 实例,创建库表时没注意字符集,导致插入中文变成???。解决方法是建库时显式指定DEFAULT CHARSET=utf8mb4,表定义里也写上,不要依赖 MySQL 默认的 latin1 字符集。如果库已经建错了,用ALTER TABLE tb_goods CONVERT TO CHARACTER SET utf8mb4转换。

排查这些问题时,一个好习惯是先在控制台看 SQL 日志。MyBatis Plus 配置了 StdOutImpl 后,每一条执行的 SQL、参数、影响行数都会打印出来。很多“玄学”问题比如字段映射不上、条件没生效,看一遍日志就清楚了,不要盯着代码干猜。

6. 进阶:给系统加上能写进简历的最后一公里

核心交易流程跑通之后,再往深做两个点,就能让这个课设从“人人都会做”变成“有技术亮点”。

第一个点是订单状态流转的代码重构。把 if else 判断状态的逻辑换成策略模式,这是设计模式 java 实现里最容易讲清楚的一个案例。定义一个订单状态处理器接口,每个状态对应一个实现类,负责校验当前状态能否执行该操作以及状态变更后的动作。

public interface OrderStatusHandler { boolean support(Integer status); void handle(Order order, Integer targetStatus); } @Component public class PendingPayHandler implements OrderStatusHandler { @Override public boolean support(Integer status) { return status == 0; } @Override public void handle(Order order, Integer targetStatus) { if (targetStatus != 1 && targetStatus != 4) { throw new BizException("待付款订单只能去支付或取消"); } // 执行支付或取消逻辑 } }

这个改动的价值不是炫技,而是当你要加“超时自动取消”功能时,只需要在待付款处理器里加一段定时任务逻辑,不会碰其他状态的代码。面试时把这个讲出来,比背八股文里的策略模式定义有说服力得多。

第二个点是查看别人简历时总能看到“Redis”这三个字。如果你确实在系统里用到了 Redis 做商品浏览缓存,把第 4 章那个 Cache Aside 模式实现出来,并写清楚“为什么先更新数据库后删缓存”,这一条哪怕只占一页简历,面试官问起来你都能接住。如果没实现,不要往简历上写,面试官追问一个具体场景就露馅。

最后说一个我做这类系统一直保持的习惯:交付前把数据库初始化脚本、启动步骤、默认账号密码写进一个 README,放在项目根目录。答辩现场最常见的情况是老师换一台机器让你跑,没有 README 你手忙脚乱翻代码,有 README 三十秒就能启动。这个习惯从课设延伸到工作,能帮你省下大量时间。希望这篇笔记帮到你,至少让你少走几个通宵的弯路。

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

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

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

立即咨询