做毕业设计最烦的事情是什么?大概率是“题目拿了一个月,代码还停在Hello World”。如果是MySQL、SSM那一套的老系统还好,照着crud糊弄一下也能过,可一旦涉及像水产品交易这种带业务场景、带订单流转、带库存和价格联动的管理类系统,很多同学的Spring Boot水平就到了瓶颈。恰好我近期折腾完一套“Spring Boot水产品交易管理系统”的毕业设计源码(编号35842),趁着思路还热乎,把核心设计、实现细节、常见坑位和排查套路完整写出来,给正在做同类系统的同学一个能直接“抄作业”的参考。
这套系统其实就是典型的交易+管理双核心:面向普通用户(买家)和运营方(管理员),围绕水产品的商品信息、分类、库存、订单、支付对接、售后状态做全流程闭环。技术上用的是Spring Boot 2.7 + MyBatis Plus + JWT + Vue 3 + Element Plus 这套组合,数据库用MySQL 8.0,开发工具IDEA。源码里自带基础数据脚本和文档,能直接跑起来演示,二次开发也能省掉大量从零搭框架的时间。无论你是正在选毕设题目,还是已经定了但没思路,这篇都能给你一块完整的拼图。
1. 从题目到需求:水产品交易系统的定位与核心链路拆解
1.1 这类毕设题目的考核点到底在哪
水产品交易管理系统,名字听着不算起眼,但把题目拆开看,它实际上兜住了商业系统里绝大多数关键环节:商品管理(多规格、多维度的分类)、订单生命周期(下单、支付、发货、收货、取消)、库存联动(预占、扣减、回滚)、用户体系(买家、管理员、商户多种角色)。一个Spring Boot项目能把这几条链路走通,答辩时能讲的东西就非常多了。
这个题目的考核重心不在于算法或高并发,而在于业务建模的完整度,以及Spring Boot工程结构的规范程度。老师大概率会关注这几个点:数据库表设计是否合理,订单与库存的关系有没有闭环,JWT认证有没有做角色权限控制,代码分层是否清晰,前端是否能有基本的管理台交互。
所以我在搭建这个系统的时候,刻意没有跟风上“微服务全家桶”,而是老老实实把单体架构做好。单体是毕业设计里最稳妥、也最好答辩的选择。你完全能理直气壮地说“单体优先是架构权衡的结果”,而不是被面试官问得哑口无言。
1.2 用户是谁,核心场景是什么
做项目之前先搞清楚角色的诉求。水产品交易系统里,最核心的角色是两种:C端买家和管理员。
- 买家:浏览水产品列表、按分类筛选、查看详情、加入购物车、下单、在线支付、查看订单状态、处理收货或申请售后、维护个人信息。
- 管理员:维护用户账号状态、管理商品分类、商品上下架、设置库存和价格、处理订单(发货、关闭异常单)、查看销售统计与数据面板。
有一点要注意,很多同学会把“买家下单”和“管理员上架”混在一个Controller里写,这在答辩时是会被追问的。系统里我拆得非常明确,admin接口统一走后台路由,用户端走openApi,JWT里通过角色字段区分访问范围,数据权限也做了隔离,这一点后期写在论文里非常加分。
1.3 功能模块清单与优先级设计
按毕设的交付习惯,我把功能拆成两个大端,再用一个公共的认证模块撑着。功能拆解如下:
用户端(小程序/Web商城端)
- 注册、登录、token续期
- 商品分类多级浏览、关键字检索
- 商品详情页(库存、价格、图片、简介)
- 购物车添加、修改数量、批量结算
- 订单确认页、地址管理
- 在线支付(模拟支付通道)、订单状态机流转
- 收货地址CRUD、个人订单查询、订单取消/售后申请
管理端(后台管理台)
- 控制台数据概览(今日订单数、销售额Top5、库存预警)
- 用户管理(列表、冻结/解冻)
- 分类管理(增删改查、排序)
- 商品管理(新增规格、上下架、库存调整)
- 订单管理(查询、发货、关闭、导出)
- 系统日志(登录日志、操作日志)
另外加了一个比较轻量的数据初始化和SQL脚本,方便不同环境直接起库。整体功能控制在“够用但不至于赶工”的粒度,毕业设计阶段没必要上物流配送和会员积分那一套,把核心闭环做扎实就赢了。
2. 技术选型与工程结构:为什么这么配,有没有坑
2.1 技术栈清单与版本选择
这个项目我采用了非常主流、网上资料最多、踩坑成本最低的一套组合。技术版本过于激进反而容易在环境上花大量时间,大家不要盲目追新。
| 技术组件 | 选用版本 | 选型理由 |
|---|---|---|
| JDK | 1.8 | 大多数学校机房和服务器环境仍以1.8为主,兼容性好 |
| Spring Boot | 2.7.18 | 稳定,第三方集成资料丰富,避免3.x和javax/jakarta切换的坑 |
| MyBatis Plus | 3.5.3 | 单表CRUD零SQL,复杂查询用注解即可 |
| MySQL | 8.0.x | 成熟稳定,支持JSON字段(用于商品规格快照) |
| JWT | jjwt 0.11.5 | 无状态认证,适合前后端分离项目 |
| Hutool | 5.8.x | 工具库,减少重复代码(树形结构、日期处理、加密) |
| Vue 3 + Element Plus | 3.x | 管理后台开发效率高,组件齐全 |
| Maven | 3.6+ | 依赖管理,毕业设计标配 |
选Spring Boot 2.7而不是3.x,是有具体原因的。网上大量现成案例、老师的PPT、答辩组老师的认知,基本都是2.x体系,换到3.x后很多配置类名和starter名称变化会造成不必要的信息差。而且到了部署阶段,2.7对JDK8的兼容会让你省心很多。
2.2 工程目录与三层架构划分
很多同学的工程坏就坏在一股脑往controller里塞代码。这个题目虽然数据量不大,但代码组织规范一点,后面查问题和扩展都会舒服很多。我采用的包结构如下:
com.example.aquatic ├── common // 全局返回结果、异常处理、常量、枚举 ├── config // 跨域、MyBatis Plus分页插件、JWT拦截器配置 ├── controller // 前端接口入口,按模块拆分文件 ├── service // 业务核心,接口+实现分离 ├── mapper // MyBatis Plus接口层 ├── entity // 数据库实体映射 ├── dto // 前端参数模型 ├── vo // 视图返回模型 └── utils // JWT工具、日期工具等按照这样的划分,代码的可读性比什么都能往controller里塞的版本高一大截。写论文的时候,画软件的架构图也会非常清晰,不会出现“一张图里面全是箭头和调用”的尴尬场景。
2.3 前后端分离还是服务端渲染
如果你是一个人在做毕设,不追求“前后端分离”这个亮点标签,那用Thymeleaf这种服务端渲染确实会更省事。但考虑到现在答辩组的老师普遍会关注“工程化”概念,而且后台管理界面的交互如果只用JQuery来实现会很费功夫,我最终选择了Vue 3 + Element Plus做管理台前端,用户端做一套简单但功能齐全的Web商城页面。
前后端分离之后,CORS跨域问题需要处理好。全局CORS配置一下也不复杂,关键是得注意,自定义拦截器里面token验证失败时的响应,不能被CORS拦截掉,否则前端会同时面临跨域和401双重迷惑,这里我后面会在常见问题里详细展开。
2.4 为什么还需要一套可运行的SQL初始化脚本
源码压缩包里我放了一个database.sql,里面包含建库建表语句和基础数据。这么做的原因很实在:大多数同学拿到源码后的第一动作不是读文档,而是期望一键跑起来。如果没有预置的分类数据和几个演示商品,你第一次登录进去看到空空如也的界面,很容易误以为系统挂掉了。初始化脚本里放默认管理员账号(admin / admin123)、测试买家账号以及若干商品数据,演示效率直接翻倍。
3. 数据库设计:水产品交易系统的表结构与关系梳理
3.1 核心表清单
这张表是整套系统的地基。按业务域划分,我建了8张核心表:
- user_account:用户账号(角色、状态、手机号、昵称、头像)
- product_category:水产品分类(支持两级树形)
- product_info:商品基本信息(名称、图片、描述、上下架状态)
- product_sku:商品规格库存(重量区间、价格、库存、预警值)
- shopping_cart:购物车记录
- order_info:订单主表(订单号、状态、金额、实付、用户ID、地址快照)
- order_item:订单明细表(商品快照、数量、单价快照)
- address_info:收货地址表
在此基础上还有access_log(登录日志)和oper_log(操作日志)两张辅助表,用于支撑后端管理台的数据展示和审计诉求。
这个设计里加了一个“快照”思路,说一下为什么。对于订单类的系统,商品名称、价格、规格都是会变的,如果你下单之后在订单明细里去关联商品表的最新价格,会出大问题(比如后台改了价格,导致历史订单金额错乱)。所以下单时把商品名称、单价、规格、图片等信息冗余一份到order_item表,这就是典型的“用空间换正确性”的建模思路。蛋糕店下单会打印小票,小票上的价格和现在蛋糕店的挂牌价无关,就是这个道理。
3.2 关键表字段设计与索引思路
用户表的核心字段:
CREATE TABLE `user_account` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录用户名', `password` varchar(100) NOT NULL COMMENT 'BCrypt加密后的密码', `phone` varchar(20) DEFAULT NULL, `nickname` varchar(50) DEFAULT NULL, `user_role` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1买家 2管理员', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1正常 0冻结', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户账号表';注意username一定要加唯一索引,这是用户体系的第一道保障。密码这里我没有用MD5,而是采用了BCrypt加密,Spring Security里的BCryptPasswordEncoder可以直接做校验,安全性好很多,也方便在论文里写一句“采用BCrypt哈希对用户口令进行保护”。
商品SKU表的字段设计要重点说,因为水产品的“规格”和衣服鞋子不一样,更多是重量区间(比如0.5-1kg、1-1.5kg),同一品种、不同规格价格可能完全不同:
CREATE TABLE `product_sku` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `product_id` bigint(20) NOT NULL COMMENT '关联商品ID', `sku_name` varchar(100) DEFAULT NULL COMMENT '规格名称,如:0.5-1kg', `price` decimal(10,2) NOT NULL COMMENT '售价', `original_price` decimal(10,2) DEFAULT NULL COMMENT '划线原价', `stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存', `safety_stock` int(11) DEFAULT '10' COMMENT '库存预警值', `status` tinyint(4) DEFAULT '1' COMMENT '1启用 0停用', PRIMARY KEY (`id`), KEY `idx_product_id` (`product_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品规格库存表';订单状态我用的是tINYINT类型,0已取消、10待支付、20已支付/待发货、30已发货/待收货、40已完成、50售后中。这里不建议直接用字符串状态,因为排序筛选和统计的性能差别很大,而且用整数枚举在代码里写状态机时也更干净。
CREATE TABLE `order_info` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` bigint(20) NOT NULL, `total_amount` decimal(10,2) NOT NULL COMMENT '商品原价总额', `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额', `order_status` tinyint(4) NOT NULL DEFAULT '10' COMMENT '订单状态', `receiver_name` varchar(50) NOT NULL, `receiver_phone` varchar(20) NOT NULL, `receiver_address` varchar(200) NOT NULL, `pay_time` datetime DEFAULT NULL, `deliver_time` datetime DEFAULT NULL, `finish_time` datetime DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`order_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';订单号我采用“yyyyMMddHHmmss + 随机数”的方式生成,不用数据库自增ID当订单号,是为了避免订单号被猜测——你在小店下单后如果小票单号是“1、2、3”,隔壁桌猜一下就能拿你的号去冒领餐品。生成逻辑用Hutool的IdUtil.getSnowflakeNextIdStr(),简单可靠。
3.3 商品分类的表结构怎么设计才不low
很多糙快猛的毕设会把分类做成一张单表用parent_id自关联,这次我也没有免俗。不过加了一个排序字段sort和状态字段status。需要注意的是,删除分类之前,一定要判断该分类下是否存在商品。写一个count查询,如果商品数量大于0,直接抛业务异常提示管理员先去转移商品。否则删完分类之后,商品列表会出现空分类的“孤儿数据”,看起来非常拉胯。
4. 核心功能实战:认证、商品、购物车与订单状态机
4.1 JWT认证设计与拦截器配置
JWT在前后端分离项目里几乎是标配,核心思路是用户登录成功后,服务端签发一个包含用户ID、角色、过期时间的token。后续请求在Header里带上Authorization: Bearer ,拦截器解析并校验合法性,然后直接把用户信息放入ThreadLocal供Service层直接使用。
工具类如下(浓缩版):
public class JwtUtil { private static final String SECRET = "your-secret-key-your-secret-key"; private static final long EXPIRE = 1000 * 60 * 60 * 24L; // 24小时 public static String generateToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .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(); } }注意一个非常容易被忽视的细节:SECRET的长度必须>=32字节,否则HS256会报错。许多同学卡在一个极其隐蔽的WeakKeyException上,折腾一晚上,其实就是密钥长度不够。这里建议直接用至少32个字符以上的随机串。
拦截器里做了一个区分:未登录可以访问商品列表、商品详情;但下单、查看订单、购物车相关接口必须登录。把这些白名单路径加上:
registry.addInterceptor(jwtInterceptor()) .addPathPatterns("/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/api/open/**", "/error" );管理端接口统一以/api/admin为前缀,拦截器里再判断一下角色字段,不是2的话就抛403。这是最粗粒度也最有效的管理权限防线。
4.2 商品列表的分页与多条件检索
商品检索这一块,用了MyBatis Plus的分页插件,加上LambdaQueryWrapper做多条件拼接。核心代码是这样:
public IPage<ProductInfoVO> queryProductPage(ProductQueryDTO dto) { Page<ProductInfo> page = new Page<>(dto.getPageNum(), dto.getPageSize()); LambdaQueryWrapper<ProductInfo> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(ProductInfo::getStatus, 1); if (StrUtil.isNotBlank(dto.getKeyword())) { wrapper.and(w -> w.like(ProductInfo::getProductName, dto.getKeyword()) .or().like(ProductInfo::getProductDesc, dto.getKeyword())); } if (dto.getCategoryId() != null) { wrapper.eq(ProductInfo::getCategoryId, dto.getCategoryId()); } wrapper.orderByDesc(ProductInfo::getCreateTime); Page<ProductInfo> result = productInfoMapper.selectPage(page, wrapper); // 转换VO,并填充SKU列表与库存状态 return convertToVO(result); }关键词检索这里如果直接用“name like or desc like”可能会把完全无关的“野生捕捞三文鱼”也搜出来,因为desc字段里可能包含某个关键词的片段。可以用一个优化的做法:把搜索目标缩小到name和categoryName,借助categoryName反查分类ID,再做等值过滤。效果更好,性能也OK。
4.3 购物车与结算:从勾选到生成订单的完整链路
用户加购时,前端只传SKU ID和数量。后端要做的校验包括:商品是否上架、SKU是否启用、库存是否足够。购物车表和业务场景挂钩,不需要搞太复杂,一个userId + skuId 的唯一键即可,数量做累加或覆盖都可以。
核心下单的逻辑是这次的重头戏,完整的链路是这样:
- 参数接收一个“订单提交DTO”,里面包含skuId列表、对应数量、收货地址ID。
- 加锁预占库存(这里我用了MySQL悲观锁,后续会讲为什么不用乐观锁)。
- 查询SKU及关联商品,校验状态和库存。
- 按最新价格计算出总金额、实付金额(可以把运费减免写死在常量里)。
- 生成订单主表记录、循环生成订单明细记录,同时写入“商品快照”。
- 扣减库存,清空已购买的购物车项。
- 中间任何一步失败,要么整体事务回滚,要么补偿恢复库存。
用代码来表达这一步,Service层的方法大致长这样:
@Transactional(rollbackFor = Exception.class) public OrderInfo createOrder(OrderCreateDTO dto, Long userId) { // 1. 地址校验 AddressInfo address = addressInfoMapper.selectById(dto.getAddressId()); if (address == null || !address.getUserId().equals(userId)) { throw new BizException("收货地址不存在"); } // 2. 预占库存:for update锁住SKU行 List<OrderItem> itemList = new ArrayList<>(); BigDecimal totalAmount = BigDecimal.ZERO; for (OrderItemDTO item : dto.getItems()) { ProductSku sku = productSkuMapper.selectByIdForUpdate(item.getSkuId()); if (sku == null || sku.getStatus() != 1) { throw new BizException("商品规格不存在或已下架"); } if (sku.getStock() < item.getQuantity()) { throw new BizException("商品库存不足:" + sku.getSkuName()); } ProductInfo product = productInfoMapper.selectById(sku.getProductId()); // 组装明细快照 OrderItem orderItem = new OrderItem(); orderItem.setProductId(product.getId()); orderItem.setProductName(product.getProductName()); orderItem.setSkuName(sku.getSkuName()); orderItem.setPrice(sku.getPrice()); orderItem.setQuantity(item.getQuantity()); orderItem.setPicUrl(product.getMainPic()); itemList.add(orderItem); totalAmount = totalAmount.add(sku.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } // 3. 生成订单主表 OrderInfo orderInfo = new OrderInfo(); orderInfo.setOrderNo(generateOrderNo()); orderInfo.setUserId(userId); orderInfo.setTotalAmount(totalAmount); orderInfo.setPayAmount(totalAmount); orderInfo.setOrderStatus(10); // ... 地址字段写入 orderInfoMapper.insert(orderInfo); // 4. 写入明细并扣库存、清购物车 for (OrderItem item : itemList) { item.setOrderId(orderInfo.getId()); orderItemMapper.insert(item); productSkuMapper.deductStock(item.getSkuId(), item.getQuantity()); } shoppingCartMapper.deleteByUserAndSkuIds(userId, dto.getSkuIds()); return orderInfo; }这里有个很值得在答辩里展开的点:为什么使用selectByIdForUpdate这种悲观锁,而不是乐观锁的版本号方案。原因在于,订单创建和库存扣减是一个强一致性的高频操作,水产品交易系统里的并发峰值不高,悲观锁简单可靠,能有效避免超卖。如果未来真的要做高并发扩展,再替换成Redis分布式锁或乐观库存扣减也不迟。毕业设计阶段用悲观锁,把正确性放在第一位,完全站得住脚。
4.4 订单状态机的流转实现
订单状态机是整个系统的灵魂,也是最容易写成一坨乱麻的地方。我选择了“状态枚举 + 状态修改方法”的集中式管理模式:
public enum OrderStatusEnum { CANCELED(0, "已取消"), UNPAID(10, "待支付"), PAID(20, "待发货"), DELIVERED(30, "待收货"), FINISHED(40, "已完成"), AFTER_SALE(50, "售后中"); private final Integer code; private final String desc; // 构造器、getter略 }每次状态变更,都在Service层封装一个独立方法,不允许直接对order_status字段做裸更新。例如:
- cancelOrder:仅允许从待支付状态流转到已取消,同时解锁库存。
- payOrder:仅允许从待支付流转到已支付,记录支付时间。
- deliverOrder:管理员操作,仅允许从已支付流转到已发货。
- confirmReceive:仅允许从已发货流转到已完成,记录完成时间。
- applyAfterSale:仅允许从已完成(签收时间内)流转到售后中,并冻结相应商品库存。
在取消订单这里有一个高概率踩坑的地方:取消时必须恢复库存,而且只能恢复该订单明细对应的SKU库存。如果用“一次性给SKU加回一个固定数字”去处理,最后库存就乱了。正确做法是遍历order_item,按skuId和quantity进行加回:
public void cancelOrder(Long orderId, Long userId) { OrderInfo order = orderInfoMapper.selectById(orderId); if (order == null || !order.getUserId().equals(userId)) { throw new BizException("订单不存在"); } if (order.getOrderStatus() != OrderStatusEnum.UNPAID.getCode()) { throw new BizException("当前状态不可取消"); } // 回滚库存 List<OrderItem> items = orderItemMapper.selectList( new LambdaQueryWrapper<OrderItem>().eq(OrderItem::getOrderId, orderId)); for (OrderItem item : items) { productSkuMapper.restoreStock(item.getSkuId(), item.getQuantity()); } order.setOrderStatus(OrderStatusEnum.CANCELED.getCode()); orderInfoMapper.updateById(order); }4.5 模拟支付的设计思路
支付单独做一个微服务或者对接沙箱太重了,毕设场景下不需要。要设计的是“模拟支付”流程:下单后订单状态是待支付,前端页面提供一个“去支付”按钮,后端提供一个payOrder接口,用一个固定的模拟支付账号直接置为已支付即可。为了增加答辩的表演效果,可以在支付接口里写死一个1秒的延时,前端弹窗展示“支付处理中”,让整个过程显得更真实。
public void payOrder(Long orderId, Long userId) { // 模拟第三方支付回调延迟 // 实际项目中这里应校验支付平台签名,此处简化 OrderInfo order = orderInfoMapper.selectById(orderId); if (order == null || !order.getUserId().equals(userId)) { throw new BizException("订单不存在"); } if (order.getOrderStatus() != OrderStatusEnum.UNPAID.getCode()) { throw new BizException("订单状态异常,无法支付"); } order.setOrderStatus(OrderStatusEnum.PAID.getCode()); order.setPayTime(LocalDateTime.now()); orderInfoMapper.updateById(order); }这里其实可以加一个“支付流水表”来记录模拟交易,但这个功能在毕业设计里属于加分项,时间不够完全可以不写。
5. 管理后台与数据面板:让项目从“能用”到“好看”
5.1 管理端前端页面与后端接口设计
后台管理页面用的Vue 3 + Element Plus,组件风格统一,开发效率高。核心页面包括仪表盘、商品管理、订单管理、用户管理、分类管理。每个页面都对应一组RESTful接口:
- GET /api/admin/dashboard/summary:获取今日订单数、今日销售额、用户总数、商品总数
- GET /api/admin/product/page:分页查询商品(含关键字、分类筛选)
- POST /api/admin/product:新增商品(含SKU列表)
- PUT /api/admin/product/{id}:修改商品与SKU
- DELETE /api/admin/product/{id}:删除商品(逻辑删除)
- GET /api/admin/order/page:分页查询订单(按状态、订单号、用户手机号筛选)
- POST /api/admin/order/{orderId}/deliver:订单发货
- GET /api/admin/user/page:分页查询用户
- PUT /api/admin/user/{id}/status:冻结/解冻用户
管理端的接口数据在返回时,Service层统一转换为VO,杜绝直接把Entity吐给前端,避免密码等敏感字段泄露。
举例说明订单管理列表的分页查询:
public IPage<OrderAdminVO> queryAdminOrderPage(OrderAdminQueryDTO dto) { Page<OrderInfo> page = new Page<>(dto.getPageNum(), dto.getPageSize()); LambdaQueryWrapper<OrderInfo> wrapper = new LambdaQueryWrapper<>(); if (dto.getOrderStatus() != null) { wrapper.eq(OrderInfo::getOrderStatus, dto.getOrderStatus()); } if (StrUtil.isNotBlank(dto.getOrderNo())) { wrapper.like(OrderInfo::getOrderNo, dto.getOrderNo()); } if (StrUtil.isNotBlank(dto.getUserPhone())) { // 先按手机号查出用户ID,再过滤订单 List<Long> userIds = userAccountMapper.selectUserIdsByPhone(dto.getUserPhone()); if (CollUtil.isEmpty(userIds)) { return new Page<>(dto.getPageNum(), dto.getPageSize()); } wrapper.in(OrderInfo::getUserId, userIds); } wrapper.orderByDesc(OrderInfo::getCreateTime); // 关联查询用户昵称 Page<OrderInfo> result = orderInfoMapper.selectPage(page, wrapper); return convertToAdminVO(result); }5.2 仪表盘统计:给答辩PPT喂数据
仪表盘是管理后台的门面,也是答辩时老师第一眼看到的东西。我做了四个统计卡片:今日订单数、今日销售额、商品总数、注册用户数;再加上“最近一周订单趋势”柱状图。后端用一个聚合查询来完成:
public DashboardSummaryVO getSummary() { DashboardSummaryVO vo = new DashboardSummaryVO(); vo.setTodayOrderCount(orderInfoMapper.selectCount( new LambdaQueryWrapper<OrderInfo>() .ge(OrderInfo::getCreateTime, LocalDate.now().atStartOfDay()))); vo.setTodaySales(orderInfoMapper.sumTodaySales(LocalDate.now().atStartOfDay())); vo.setProductCount(productInfoMapper.selectCount(null)); vo.setUserCount(userAccountMapper.selectCount( new LambdaQueryWrapper<UserAccount>().eq(UserAccount::getUserRole, 1))); return vo; }这个sumTodaySales可以是一条自定义SQL,在Mapper里写:
@Select("SELECT COALESCE(SUM(pay_amount), 0) FROM order_info " + "WHERE order_status IN (20,30,40) AND pay_time >= #{startTime}") BigDecimal sumTodaySales(LocalDateTime startTime);“最近一周订单趋势”则用了一个GROUP BY日期的查询,按天聚合订单数和销售额,前端拿到后直接渲成ECharts柱状图。这一块是答辩现场的视觉记忆点,值得多花半小时做。
5.3 文件上传如何处理
商品图片的上传,我没有引入OSS或者MinIO,而是存到本地磁盘,再通过一个静态资源映射暴露URL。Spring Boot里配置:
spring: web: resources: static-locations: file:${upload.path}/或者在配置类里加一个资源映射:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceHandler("file:" + uploadPath + "/"); }上传接口接收MultipartFile,保存时用UUID重命名文件,防止中文文件名乱码和路径穿越问题:
public String uploadFile(MultipartFile file) { if (file.isEmpty()) { throw new BizException("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = StrUtil.subAfter(originalFilename, ".", true); String fileName = IdUtil.fastSimpleUUID() + "." + ext; File dest = new File(uploadPath, fileName); try { file.transferTo(dest); } catch (IOException e) { throw new BizException("文件上传失败"); } return "/upload/" + fileName; }需要注意一点:在Windows本地跑没问题,但部署到Linux服务器后,路径分隔符和权限都要检查。为了省心,可以在配置里把上传路径放到项目外部(比如/opt/aquatic/upload),这样打jar包后也能灵活指向。
6. 排查实录:Spring Boot水产品系统里最常见的6个大坑
6.1 坑位一:MyBatis Plus分页失效,查询返回全表
这是新手最高频的坑,分页插件没生效。检查点:
- 是否在Config类里注册了
PaginationInnerInterceptor。 - 是否把拦截器加到了
MybatisPlusInterceptor而不是直接加了个空bean。
正确的配置长这样:
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }每一条都需要,缺一不可。如果注册了还不行,检查mybatis-plus版本是否和Spring Boot 2.7兼容,3.5.x基本都没问题。
6.2 坑位二:日期时间字段返回给前端变成了时间戳
LocalDateTime默认序列化为数组或者时间戳的问题困扰很多人。解决方式有两种,一是在字段上加@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss"),但字段一多就很烦;更推荐二,在application.yml里全局配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8如果实体里用了LocalDateTime,还需要引入jackson-datatype-jsr310依赖(Spring Boot starter-web里通常已经带上了),这样返回前端的日期就是规范的字符串了。
6.3 坑位三:前端请求接口跨域报错,偶尔还带401
跨域配置和JWT拦截器同时存在时容易出现优先级问题。我的做法是用过滤器实现CORS,而不是在Controller上加@CrossOrigin注解。
一个容易踩的细节:如果使用自定义拦截器拦截所有请求,且拦截器在token校验失败时直接返回401的JSON,此时如果前端发的是预检请求OPTIONS,后端没有对OPTIONS放行,就会出现“跨域测试通过,但真实请求被拦截”的诡异现象。对策很简单:拦截器里对HttpMethod.OPTIONS直接放行:
if (request.getMethod().equals(HttpMethod.OPTIONS.name())) { return true; }6.4 坑位四:库存扣减出现负数(超卖)
这个问题在并发量稍高一点时就会出现。由于我选择了悲观锁,本质上已经解决了。但有一种情况会导致问题依旧——多次点击下单按钮。前端要做按钮防重复提交,后端也要在创建订单接口入口处做幂等处理(可以用Redis存一个用户级别的短时间锁,或用“订单号唯一索引”兜底),双保险最稳。
6.5 坑位五:修改商品时SKU更新逻辑错误
很多同学在做“编辑商品”功能时,会把前端传来的SKU列表全删了再重新插入。这样做有两个问题:一是历史订单明细里的sku_id会被置为失效,如果后续要退款退货会有很大隐患;二是自增ID不断增长,而且如果删除操作失败会导致数据混乱。我的做法是:前端传的SKU里带id的做更新,不带id的做新增;同时找出数据库中不存在于前端列表的SKU,做逻辑删除。虽然逻辑多了几行,但数据的完整性和可追溯性完全不一样。
6.6 坑位六:数据库时间与本地时间相差8小时
这个问题的根源是serverTimezone配置和MySQL的时区不一致。推荐在JDBC连接串里明确指定:
jdbc:mysql://localhost:3306/aquatic?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true同时检查MySQL全局时区:
SET GLOBAL time_zone = '+8:00'; SET SESSION time_zone = '+8:00';如果Java层面已经用的LocalDateTime,通常不会有太大问题;但如果项目里用了new Date()传给MySQL的datetime字段,就要注意两者之间的时区差异。
7. 我的一线实操心得与这个小项目的扩展价值
整套系统从建表到前后端联调通,大概花了三周左右的业余时间。给我最大的感受是:做毕设项目,最难的不是某个技术点不会,而是把散落的需求串成一条能自圆其说的业务闭环。比如订单和库存一定要联动,商品SKU和商品信息一定要拆开,登录认证和角色权限一定要分清,这些都是这个题目能带给你的核心收获。
如果要在这个基础上做扩展,我建议按这几个方向选一个深挖即可:一是加一个基于ECharts的销售趋势分析面板,把订单数据按周按月聚合;二是接一个真实的微信支付沙箱或支付宝沙箱,把模拟支付替换成真实流程;三是给买家端做一个微信小程序端,复用现有API,工作量可控但答辩亮点非常足;四是引入Redis缓存首页商品列表和分类信息,减少数据库压力,顺带讲清楚了“缓存穿透、击穿、雪崩”的解决方案。这些中的任何一个,都能让你的项目在答辩时比同组同学明显高出一截。
最后再说一个细节。源码里我是自带README文档和数据库初始化脚本的,但千万别把“能跑”当成“做完”。写论文时,数据库设计的ER图、订单状态机的状态图、JWT认证流程图,这“三张图”基本决定了老师对你系统的第一印象。把细节打磨好,让老师看到你对业务和代码是有整体把控的,比展示多少个页面都管用。
希望这篇拆解能帮到正在和Spring Boot系统死磕的你。如果在搭建过程中遇到报错,rebuild一下项目,看下控制台有没有SQL异常,再对着上面六个坑逐一排查,大概率能顺下来。