ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring Boot校园二手交易系统:从数据库设计到并发避坑实战

Spring Boot校园二手交易系统:从数据库设计到并发避坑实战 简介基于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 或者 SSHStruts Spring Hibernate的老方案。Spring Boot 内置 Tomcatstarter 一键引入依赖不再需要写一堆 XML 配置这对课设的时间成本是决定性的。Spring Boot 生态里持久层用 MyBatis Plus 比原生 MyBatis 省事很多单表 CRUD 不用手写 SQL内置分页插件代码量能砍掉一半。前端方面如果是纯课设可以用 Thymeleaf 服务端渲染但如果你想在简历上写“前后端分离”就配一个 Vue 3 简单的页面接口统一返回 JSON。这里有个值得注意的点答辩时老师最爱问“你为什么选这个框架”。回答思路是——Spring Boot 解决配置繁琐和部署成本的问题MyBatis Plus 解决单表操作的重复劳动JWT 解决 Session 在集群环境下不共享的问题。把每个选型和一个具体痛点挂钩比背概念有说服力得多。2.2 数据库设计用户、商品、订单三张核心表怎么建才不被答辩老师问倒数据库设计是一个系统的地基表建不好后面全是补丁。校园二手交易系统最核心的就是三张表用户表、商品表、订单表。围绕它们可以有收藏表、浏览记录表但先保证核心三张表设计合理再考虑附加功能。用户表要注意几个细节username 要加唯一索引防止重复注册password 字段长度不要设成 32因为要用 BCrypt 加密密文长度超过 32status 字段做逻辑禁用不要物理删除用户。商品表要区分“库存”和“状态”。二手市场一个商品通常只有一件库存但设计上保留 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;三张表的字段设计有几个共同原则主键用自增 bigint 而不是 UUID因为 InnoDB 聚簇索引对自增主键性能最好课设阶段不需要考虑分布式 ID时间字段由数据库默认值管理代码里不手动 set避免各机器时钟不一致金额用 decimal 而不是 float/doublefloat 有精度损失涉及钱必须用定点数。提示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 里不写业务逻辑只做参数校验和调用 serviceservice 里不出现 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/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 groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies配置文件中最重要的三个配置项是数据源、MyBatis Plus 驼峰映射和 Jackson 时间格式。数据库驱动升级到 8.x 后连接串必须带 serverTimezone否则查出来的时间会差 8 小时这个问题放在第 5 章展开。# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/secondhand? useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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: GMT8driver-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 ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }Slf4j RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BizException.class) public ResultVoid handleBizException(BizException e) { log.warn(业务异常: {}, e.getMessage()); return Result.error(e.getCode(), e.getMessage()); } ExceptionHandler(Exception.class) public ResultVoid handleException(Exception e) { log.error(系统异常, e); return Result.error(500, 系统繁忙请稍后再试); } }这里的思路是BizException 是业务上能预判的错误比如“库存不足”“不能买自己的商品”这类错误要返回给用户看而 Exception 兜底处理未知异常避免堆栈信息直接暴露给前端也避免 500 页面白屏。定义好这套结构后业务代码里只需要throw new BizException(xxx)接口层不需要写 try catch。3.3 JWT 注册登录与拦截器无状态登录的落地写法登录模块要完成两件事注册时密码加密存储登录时签发 Token。密码加密不要用 MD5MD5 可以暴力破解用 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 ResultString login(RequestBody Valid LoginDTO dto) { LambdaQueryWrapperUser 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 ResultString 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 iddeductStock 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); }这段代码在单线程下运行没有任何问题但在高并发下两个请求同时查到 stock1都满足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 LambdaQueryWrapperOrder() .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 里显式加上serverTimezoneAsia/Shanghai同时在application.yml里配置spring.jackson.time-zone: GMT8让 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 keys 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/uploadWindows 上用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 GMT8)。我一般选择全局配置加字段注解双保险字段注解的优先级更高。5.7 常见的初始化必坑项补充还有一个课设特别常见的现象多人用同一台 MySQL 实例创建库表时没注意字符集导致插入中文变成???。解决方法是建库时显式指定DEFAULT CHARSETutf8mb4表定义里也写上不要依赖 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 三十秒就能启动。这个习惯从课设延伸到工作能帮你省下大量时间。希望这篇笔记帮到你至少让你少走几个通宵的弯路。本文还有配套的精品资源点击获取
返回列表