☰
SpringBoot学生用品采购系统:状态机、金额与库存并发实战
2026/10/10 11:10:29 网站建设 项目流程

简介:基于Java+SpringBoot的学生用品采购系统毕业设计论文,面向计算机专业毕业生和需要完成管理信息系统类课题的开发者,是一份完整的毕设论文资料。内容围绕传统采购信息管理效率低、易出错等痛点,阐述利用Eclipse、MySQL和SpringBoot框架构建自动化信息管理系统的过程,涵盖需求分析、系统设计、数据库表结构、功能模块实现与安全设计等核心部分。论文结构完整,包含中文摘要、英文Abstract、目录以及绪论、开发环境、系统实现等章节,方便读者按需查阅。整包仅含1个doc文档文件,压缩包大小约1.11MB,文本便于直接查阅、修改和打印。已有54人浏览学习,适合作为毕业设计选题参考、论文框架搭建和答辩准备的模板。通过阅读这份论文,可以了解SpringBoot在采购业务场景下的后端服务搭建思路、管理员与用户模块的权限划分,以及数据完整性约束和用户体验设计等实践细节,对完成同类系统设计具有较强的借鉴价值。

1. 学生用品采购系统:为什么毕设翻车点全在状态流转而不在增删改查

学生用品采购系统,听起来就是一个“管理员维护商品、学生下单、后台发货”的普通 CRUD,很多同学开题时也这么认为。但真正把代码写到验收阶段才发现,这类系统的难度从来不在增删改查,而在采购审批状态的流转、库存和金额的一致性、以及不同角色对同一笔单据的不同操作边界。一个典型场景:学生提交了采购申请,辅导员审核通过后,采购员入库时却发现库存已经被人先扣走了,或者金额因为走了“先存 Double 后转 BigDecimal”的路子而对不上账。本文用 SpringBoot + Java 拆一套能跑通、能答辩、能应对追问的学生用品采购系统方案,从表结构到主流程再到五个高频踩坑点,直接对着做就行。适合正在选题或已经开题、需要一份“代码和论文能对得上”的完整方案的开发者。

2. 先把地基打牢:SpringBoot 技术选型与三张核心表的建模思路

2.1 技术栈为什么选 SpringBoot 2.7 + MyBatis-Plus:教学兼容与开发效率的平衡

第一件要定下来的事不是代码,是技术栈版本。学生用品采购系统的受众是毕业设计,不是生产环境高并发项目,所以选型的第一原则是“导师看得懂、答辩问不倒、社区资料多”。SpringBoot 2.7.x 是目前教学和市面参考代码用得最广的版本线,和 JDK 8 兼容,不需要处理 SpringBoot 3.0 之后 Jakarta EE 命名空间迁移带来的一堆报错。MyBatis-Plus 提供BaseMapper内置方法,能让代码量少三分之一,同时保留了手写 SQL 的能力,答辩时可以解释“复杂查询我用手写 SQL 控制,避免全表扫描”。

认证与权限我用的是 Spring Security + JWT,没有用 Shiro。原因有两点:一是 Spring Security 和 SpringBoot 的过滤器链集成更自然,写一个OncePerRequestFilter就能解析 Token;二是答辩时被问到“权限控制怎么做”时,Spring Security 的过滤器链模型比 Shiro 的 Subject 模型更好讲清楚。JWT 的无状态特性也适合这个场景——采购系统和普通博客不同,学生端、审批端、管理员端共用同一套 API,Token 里带上角色信息后,后端通过@PreAuthorize就能控制接口访问。

数据库选 MySQL 8.0,统一 utf8mb4 字符集。注意要在连接串上显式加上serverTimezone=Asia/Shanghai,否则 LocalDateTime 和数据库时间之间会出现 8 小时时差,这个问题很多人在做订单时间统计时才发现。ORM 层我推荐直接用 MyBatis-Plus 3.5.x,配套mybatis-plus-boot-starter,不要自己手写 XML 映射,除非你需要非常复杂的多表联查。多表联查在本项目里不多,绝大多数场景都能用单表 CRUD + 内存组装解决。

下面是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-security</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

参数说明:jjwt-api是 JWT 的 API 包,还需要配套jjwt-impl和jjwt-jackson,否则运行时会报ClassNotFoundException。Lombok 用@Data减少 getter/setter 代码,但答辩时如果被问“Entity 为什么不用手写 getter”,要能回答出“Lombok 在编译期生成字节码,减少样板代码”这一层。

2.2 三张核心表怎么设计:采购单主表、明细表、商品表,各管一段

学生用品采购系统的数据模型可以拆成三个核心域:商品域、采购单域、用户域。商品域解决“有什么可以买”,采购单域解决“谁在什么时候买了什么、审批到哪一步了”,用户域解决“谁能操作”。三张核心表——商品表、采购单主表、采购单明细表——必须在一开始就定清楚,因为后面所有业务逻辑都是围绕它们转。

商品表字段如下,注意 price 字段用DECIMAL(10,2),禁止用double。double在 Java 里是浮点数,0.1 + 0.2 会出现精度问题,采购金额一旦对不上账,答辩时会被导师一眼看穿。

CREATE TABLE `product` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '商品ID', `name` VARCHAR(64) NOT NULL COMMENT '商品名称', `category` VARCHAR(32) NOT NULL COMMENT '分类:文具/运动/数码/生活', `price` DECIMAL(10,2) NOT NULL COMMENT '单价,单位元', `stock` INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', `status` TINYINT 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, KEY `idx_category` (`category`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生用品商品表';

设计逻辑说明:status用 TINYINT 而不是字符串,因为后续做上下架过滤时WHERE status = 1比WHERE status = 'ON_SALE'走索引更直接。category加普通索引是因为采购系统最常见的统计维度是“某个分类下采购了多少件”,不在索引列上做函数运算才能命中索引。

采购单主表是整张表的枢纽。它记录一笔采购从“学生申请”到“采购员完成”的全部状态迁移过程。与普通商城订单不同,学生用品采购系统多了一层“审批”,而且审批人不是系统自动判断的——你需要在表里冗余存储applicant_id和approver_id,避免每次查状态都要 join 三张用户表。

CREATE TABLE `purchase_order` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '采购单号,格式YYYYMMDD+6位序列', `applicant_id` BIGINT NOT NULL COMMENT '申请人ID(学生)', `approver_id` BIGINT DEFAULT NULL COMMENT '审批人ID(辅导员/管理员)', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待初审 1初审通过 2已入库 3已驳回 4已取消', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '总金额,由明细汇总', `apply_reason` VARCHAR(255) DEFAULT NULL COMMENT '申请理由', `reject_reason` VARCHAR(255) DEFAULT NULL COMMENT '驳回理由', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_applicant` (`applicant_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购单主表';

这张表有两个容易在自测时忽略的点。第一,order_no必须唯一,而且建议在代码里用 Redis INCR 或数据库序列生成,不要用System.currentTimeMillis()——同一毫秒内并发创建时可能重复。第二,total_amount是冗余字段,它的值应当在明细表写入时汇总计算,而不是用前端传过来的值。前端的金额不可信,这在答辩时可以作为“安全性”的一个加分点。

采购单明细表记录每个采购单里包含哪些商品、数量多少、单价快照。为什么单价要快照?因为商品表里的 price 可能被管理员调整。如果采购单生成后商品涨价了,但学生已经被审批通过的金额不能变,所以明细表里必须存下单时的单价。

CREATE TABLE `purchase_order_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_id` BIGINT NOT NULL COMMENT '采购单主表ID', `product_id` BIGINT NOT NULL COMMENT '商品ID', `product_name` VARCHAR(64) NOT NULL COMMENT '商品名称快照', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', `quantity` INT NOT NULL COMMENT '采购数量', `subtotal` DECIMAL(10,2) NOT NULL COMMENT '小计 = price * quantity', KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购单明细表';

设计逻辑说明:product_name和price是故意冗余的,不是设计缺陷。商品被改名或调整价格后,历史采购单展示仍要还原当时的信息。你需要把它和主表通过order_id关联,查询时一次selectByOrderId拿回全部明细,避免在业务代码里循环单查数据库。

2.3 用户与角色:RBAC 模型怎么落成表,权限边界在哪一层控制

学生用品采购系统不需要复杂的组织架构,三张表就能撑起 RBAC:

  • 用户表sys_user:存登录账号、密码(BCrypt 加密)、姓名、学号/工号、角色 ID。
  • 角色表sys_role:存角色编码,如STUDENT、APPROVER、PURCHASER、ADMIN。
  • 用户角色关联表sys_user_role。

这里最容易被忽视的是“数据权限”而不是“功能权限”。角色决定能否访问某个接口,但“访问了这个接口能看到哪些数据”是另一回事。比如学生调用“查询我的采购单”接口,后端必须强制把applicant_id设为当前登录用户 ID,不能让学生传applicantId=100参数就看到别人的单子。

我在控制层用了一个自定义注解@RequireRole("STUDENT"),拦截器里解析 JWT 得到当前用户的角色和 ID,再放入ThreadLocal或请求上下文。后续 Service 层取当前用户只需要一个静态方法:

public class CurrentUserHolder { private static final ThreadLocal<CurrentUser> HOLDER = new ThreadLocal<>(); public static void set(CurrentUser user) { HOLDER.set(user); } public static CurrentUser get() { return HOLDER.get(); } public static Long getUserId() { CurrentUser user = HOLDER.get(); return user == null ? null : user.getId(); } public static void clear() { HOLDER.remove(); } }

逻辑说明:ThreadLocal的特点是每个线程一份数据,请求结束必须调用clear()删除,否则线程池复用时会拿到上一个用户的身份,造成越权。我一般在 Spring Security 的OncePerRequestFilter里解析完 Token 后set,在过滤器链结束后的finally里clear。另外一个细节:CurrentUser对象里不要放密码字段,只放 ID、用户名、角色编码,避免日志打印时把密码打出来。

3. 跑通采购主流程:从学生申请到采购员入库的完整代码与状态机

3.1 采购申请接口:前端传什么,后端信什么,两张表同时落库

采购申请这个环节,学生在页面上勾选商品、填数量、写申请理由,点击提交。后端要做的事不是“把前端传的数据存起来”,而是要重新计算金额、校验库存、生成采购单号,然后在一次事务里同时写入主表和明细表。这四件事缺一件,后面审批环节就会出问题。

先看前端要传的 DTO:

public class PurchaseOrderCreateDTO { // 前端只传商品ID和数量,不传金额 private List<ItemDTO> items; @NotBlank(message = "申请理由不能为空") private String applyReason; @Data public static class ItemDTO { @NotNull(message = "商品ID不能为空") private Long productId; @Min(value = 1, message = "数量至少为1") @Max(value = 999, message = "数量不能超过999") private Integer quantity; } }

为什么不让前端传totalAmount?因为前端计算金额可以被篡改,而且一旦商品价格在提交瞬间发生变化,前端拿到的还是旧价格。后端拿到productId后,重新查商品表获取当前单价,然后计算小计和总价。这才是服务端应有的姿态——前端参数只能作为业务意图的输入,不能作为金额和库存的真相来源。

Service 层代码逻辑如下,注意整个过程加了@Transactional,保证主表和明细表要么同时写入,要么同时回滚。

@Service @RequiredArgsConstructor public class PurchaseOrderServiceImpl implements PurchaseOrderService { private final PurchaseOrderMapper orderMapper; private final PurchaseOrderItemMapper itemMapper; private final ProductMapper productMapper; // 订单号生成器,包装了雪花算法或数据库序列 private final OrderNoGenerator orderNoGenerator; @Override @Transactional(rollbackFor = Exception.class) public Long createOrder(PurchaseOrderCreateDTO dto) { // 1. 逐条校验商品是否存在且上架 BigDecimal totalAmount = BigDecimal.ZERO; List<PurchaseOrderItem> items = new ArrayList<>(); for (PurchaseOrderCreateDTO.ItemDTO itemDTO : dto.getItems()) { Product product = productMapper.selectById(itemDTO.getProductId()); if (product == null || product.getStatus() == 0) { throw new BizException("商品 " + itemDTO.getProductId() + " 不存在或已下架"); } // 2. 计算小计:使用 BigDecimal 避免浮点误差 BigDecimal subtotal = product.getPrice() .multiply(BigDecimal.valueOf(itemDTO.getQuantity())); totalAmount = totalAmount.add(subtotal); // 3. 组装明细对象,记录商品名和单价快照 PurchaseOrderItem item = new PurchaseOrderItem(); item.setProductId(product.getId()); item.setProductName(product.getName()); item.setPrice(product.getPrice()); item.setQuantity(itemDTO.getQuantity()); item.setSubtotal(subtotal); items.add(item); } // 4. 创建主表记录 PurchaseOrder order = new PurchaseOrder(); order.setOrderNo(orderNoGenerator.next()); order.setApplicantId(CurrentUserHolder.getUserId()); order.setStatus(0); order.setTotalAmount(totalAmount); order.setApplyReason(dto.getApplyReason()); orderMapper.insert(order); // 5. 批量插入明细表 for (PurchaseOrderItem item : items) { item.setOrderId(order.getId()); itemMapper.insert(item); } return order.getId(); } }

逻辑说明:@Transactional(rollbackFor = Exception.class)里的rollbackFor必须写,默认配置下 RuntimeException 才会回滚,而BizException如果继承的是 Exception,不加rollbackFor就会产生“主表写入成功、明细表写入失败但没回滚”的脏数据。orderMapper.insert(order)后 MyBatis-Plus 会自动回填自增主键到order.getId(),所以后面给明细表设置orderId不需要再查一次。

3.2 审批与驳回的状态机:把 if-else 收敛成一张状态迁移表

审批是采购系统里最容易写出“屎山”的环节。如果直接在 Service 里写if (order.getStatus() == 0) { ... } else if (order.getStatus() == 1) { ... },刚开始只有 5 个状态还能看,等到你后续加了“部分入库”“驳回后重新提交”“采购单超时自动关闭”这些状态,if-else 会膨胀到没法维护。

我用的方案是独立出一个PurchaseStatus枚举,内部维护“合法迁移路径”,任何一次状态变更都要先经过canTransitionTo(current, target)校验。这里展示枚举的设计思路:

public enum PurchaseStatus { PENDING(0, "待初审"), APPROVED(1, "审批通过"), STOCK_IN(2, "已入库"), REJECTED(3, "已驳回"), CANCELLED(4, "已取消"); private final int code; private final String desc; // 定义每状态下一步允许走到的目标状态 private static final Map<Integer, Set<Integer>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(0, new HashSet<>(Arrays.asList(1, 3, 4))); // 待初审 -> 通过/驳回/取消 TRANSITIONS.put(1, new HashSet<>(Arrays.asList(2, 4))); // 通过 -> 入库/取消 TRANSITIONS.put(2, new HashSet<>()); // 入库是终态 TRANSITIONS.put(3, new HashSet<>(Arrays.asList(0, 4))); // 驳回 -> 重新提交/取消 TRANSITIONS.put(4, new HashSet<>()); // 取消是终态 } public static void checkTransition(int fromStatus, int toStatus) { Set<Integer> allowed = TRANSITIONS.get(fromStatus); if (allowed == null || !allowed.contains(toStatus)) { throw new BizException( "非法状态迁移: " + getDesc(fromStatus) + " -> " + getDesc(toStatus)); } } }

使用这个枚举后,审批方法的代码会非常短。因为非法路径在校验阶段就拦截了,Service 里不需要再重复写一堆 if(不是这个状态就不能操作)的判断。状态机的价值不只是在代码层面少写几行,更重要的是答辩时你可以画一张状态迁移图放在论文里,图上每个箭头对应枚举里一个TRANSITIONS条目,答辩老师问“为什么这个状态能跳到那个状态”,你指着图讲就行。

审批通过后的动作除了改状态,还要写approver_id和审批时间。很多同学在这里会漏掉approver_id的更新,导致后续统计“谁审批了这个单子”时查不到数据。

@Override @Transactional(rollbackFor = Exception.class) public void approve(Long orderId) { PurchaseOrder order = orderMapper.selectById(orderId); if (order == null) { throw new BizException("采购单不存在"); } // 核心校验:只有当前状态为待初审时才能通过 PurchaseStatus.checkTransition(order.getStatus(), PurchaseStatus.APPROVED.getCode()); // 校验归属:审批人不能审批自己提交的单子 if (order.getApplicantId().equals(CurrentUserHolder.getUserId())) { throw new BizException("不能审批本人提交的采购单"); } PurchaseOrder update = new PurchaseOrder(); update.setId(orderId); update.setStatus(PurchaseStatus.APPROVED.getCode()); update.setApproverId(CurrentUserHolder.getUserId()); orderMapper.updateById(update); }

细节说明:更新时不要直接拿order对象全量更新,而是新建一个只包含要修改字段的update对象,调用updateById。MyBatis-Plus 默认只更新非 NULL 字段,这样可以避免将并发下其他线程已经修改的reject_reason或total_amount给覆盖掉。这是一个很典型的“部分更新”场景,也是你在答辩时可以主动讲的代码亮点。

3.3 入库操作:先校验后扣库存,还是先扣库存后落单?

入库(确认到货)是采购流程的最后一个业务动作。它和订单创建不同,采购单在审批通过后并不直接改变库存,而是生成一个“待入库”记录。等到采购员确认收到货后,才把商品的实际库存增加。

这里的关键问题:如果审批通过后就提前把库存加上了,但后续采购单被取消或入库失败,库存会莫名增加;如果审批通过后不加库存,就要在入库动作里单独处理“增加库存”。后者是正解,而且顺序必须严格:先更新采购单状态为“已入库”,再更新商品表stock = stock + quantity。顺序反了会怎样?如果先加库存、后改状态,中间发生异常回滚时库存已经改了但采购单还是“审批通过”,下一次入库会再扣一次。更合理的做法是让入库动作在事务边界内同时完成两条 UPDATE,配合乐观锁避免重复入库。

“重复入库”是这个环节最高频的 bug。一个采购员快速点了两次“确认入库”按钮,如果代码没有幂等保护,库存会被加两次。用一个更新条件来兜底:

@Override @Transactional(rollbackFor = Exception.class) public void stockIn(Long orderId) { PurchaseOrder order = orderMapper.selectById(orderId); if (order == null) { throw new BizException("采购单不存在"); } // 幂等保护:只要状态不是审批通过,直接拒绝 if (order.getStatus() != PurchaseStatus.APPROVED.getCode()) { throw new BizException("当前采购单状态不可入库"); } // 使用条件更新,防止并发重复入库 UpdateWrapper<PurchaseOrder> wrapper = new UpdateWrapper<>(); wrapper.eq("id", orderId) .eq("status", PurchaseStatus.APPROVED.getCode()); PurchaseOrder update = new PurchaseOrder(); update.setStatus(PurchaseStatus.STOCK_IN.getCode()); int updated = orderMapper.update(update, wrapper); if (updated == 0) { throw new BizException("入库失败,采购单已被处理或状态已变更"); } // 更新商品库存:stock = stock + quantity,注意这里用 SQL 自增而非读取后写回 List<PurchaseOrderItem> items = itemMapper.selectByOrderId(orderId); for (PurchaseOrderItem item : items) { productMapper.increaseStock(item.getProductId(), item.getQuantity()); } }

increaseStock对应的 SQL 要写成:

UPDATE product SET stock = stock + #{delta}, update_time = NOW() WHERE id = #{productId}

说明:用stock = stock + #{delta}而不是selectById后在 Java 里old + quantity再updateById,是为了避免并发覆盖。两个线程同时读到 stock=10,分别加 2 和 3,如果走读改写,后写的那个会覆盖先写的,最终变成 13 或 12 而不是 15。直接 SQL 自增才是正解。这也是“并发安全”在答辩中最容易拿分的点之一。

4. 钱和库存不能拍脑袋算:金额精度、库存扣减与并发边界

4.1 BigDecimal 还是 double:金额计算的三条铁律

学生用品采购系统涉及钱的地方不多,无非就是单价、数量、小计、总金额、驳回退款。即便如此,金额计算也必须遵守三条铁律,否则等你好不容易跑通全流程,结果0.1 + 0.2 != 0.3在测试中跳出来,心态直接崩。

铁律一:数据库字段用DECIMAL(10,2),Java 实体类字段用BigDecimal,禁止用double或float。double的二进制浮点表示无法精确表达 0.1,这会导致小计和总金额对不上。涉及金额的表结构从第一版就定好,不要等到写完再改。铁律二:前端提交的任何金额字段都当成“不可信输入”,后端一律根据商品单价重新计算。规则是:单价以数据库为准,数量以校验后的前端参数为准,小计和总金额永远在后端算。铁律三:BigDecimal构造时用new BigDecimal("0.1")或BigDecimal.valueOf(0.1),禁止用new BigDecimal(0.1)——后者传入的是 double 类型,依旧会得到不精确值。

对比一下错误和正确写法:

// 错误:底层是二进制浮点,结果 1.2000000000000002 等诡异小数 BigDecimal bad = new BigDecimal(0.1).add(new BigDecimal(1.1)); // 正确:字符串构造或 valueOf 都精确 BigDecimal good = new BigDecimal("0.1").add(BigDecimal.valueOf(1.1));

BigDecimal.valueOf(0.1)内部调的是Double.toString(),所以精度不会丢失。另外,金额比较用compareTo而不是equals,因为equals会同时比较标度,1.0和1.00用 equals 会返回 false,但compareTo为 0。做库存不足校验时经常会写if (totalAmount.compareTo(zero) <= 0),这个习惯早期就要养成。

培训或论文里如果涉及金额统计(比如“某月份采购总额”),聚合函数也要用SUM(price * quantity)在数据库里算,不要把所有明细拉到 Java 内存里循环累加。一次性拉取几百条没感觉,等数据量到几万条时,内存开销和 GC 压力会让人怀疑人生。数据库聚合和加索引是两件事,别混为一谈。

4.2 库存扣减的两种写法:乐观锁与 SQL 自增什么时候用

库存字段在采购系统里承担的是“参考库存”和“可下单库存”的双重角色。学生提交采购单时需要检查库存够不够,但这里要分清楚:采购单创建和普通商城订单扣减不同,采购单在“审批通过”之前不能动库存,只能在“入库”时增加库存。所以库存扣减其实发生在两个位置——一个是商品上架时补货(数量增加),另一个是彩蛋中的“出库给到学生”场景(数量减少)。

如果你在毕业设计里做的是带“学生实际领取”环节的完整闭环,那么出库扣库存时要考虑并发问题。比如两个学生同时提交领用申请,系统查库存剩余 1 件,两人都通过校验,都执行了扣减,库存变成 -1。解决思路任选一种:

第一种是“乐观锁 + where 条件”,适合并发量不高的毕设场景:

// 扣库存 int updated = productMapper.deductStockIfEnough(productId, quantity); if (updated == 0) { throw new BizException("库存不足或商品已下架"); }

对应的 SQL:

UPDATE product SET stock = stock - #{quantity} WHERE id = #{productId} AND stock >= #{quantity}

这个 SQL 的巧妙之处在于把“检查库存”和“扣减库存”合并成一条原子操作。数据库行锁保证了两个并发事务不会同时把 stock 扣成负数。updated == 0即代表库存不足,不用先 select 再 update。这是最推荐在毕业设计里使用的方式,既简单又能体现对并发控制的认知。

第二种是“悲观锁”,SELECT ... FOR UPDATE。它适合高并发扣减场景,但毕设中用悲观锁反而容易被追问“你了解锁的粒度吗”,如果答不好会扣分。所以我的习惯是:库存扣减一律用乐观锁 + 条件更新,只有采购单状态流转这种必须互斥的场景才用行锁。

还有一种思路是库存放在 Redis 里用 Lua 脚本扣减,但那是生产级别的方案,毕设引入 Redis 会增加部署复杂度,而且论文里不好解释清楚。除非你的题目明确写了“基于 Redis 的高并发采购系统”,否则不建议。

4.3 金额一致性自测:一组数据跑完创建、审批、入库全链路

写完了代码,怎么验证金额和库存是对的呢?不要靠肉眼。写一个 tiny 的测试方法,模拟一笔采购全流程,事后断言库存和总额是否和公式一致。我一般把这种测试放在 Service 层,用@SpringBootTest拉起完整上下文,跑完直接查数据库验证。

@SpringBootTest class PurchaseFlowTest { @Autowired private PurchaseOrderService orderService; @Autowired private ProductMapper productMapper; @Test void shouldCreateAndStockInWithCorrectAmount() { // 准备:把某商品价格设为 12.50,库存 100 // 创建采购单:2 件,期望总金额 25.00 PurchaseOrderCreateDTO dto = new PurchaseOrderCreateDTO(); PurchaseOrderCreateDTO.ItemDTO item = new PurchaseOrderCreateDTO.ItemDTO(); item.setProductId(1L); item.setQuantity(2); dto.setItems(Collections.singletonList(item)); dto.setApplyReason("测颜色"); Long orderId = orderService.createOrder(dto); PurchaseOrder order = orderMapper.selectById(orderId); Assertions.assertEquals(0, new BigDecimal("25.00").compareTo(order.getTotalAmount())); // 审批通过后入库,校验库存变成 102 orderService.approve(orderId); orderService.stockIn(orderId); Product product = productMapper.selectById(1L); Assertions.assertEquals(102, product.getStock()); } }

运行这个测试后,你不仅验证了金额和库存,还把主表状态机“待初审 -> 审批通过 -> 已入库”的三跳全部走了一遍。如果某一步状态校验写错了,测试会直接抛异常。这套测试建议保留在项目里,答辩时打开 IDE 跑一遍给老师看,比任何 PPT 截图都有说服力。

5. 采购系统最容易忽略的五类问题:权限、事务、库存与前端参数等

5.1 自己审批自己的采购单:越权操作是怎么漏掉的

现象:学生用户登录后,在审批列表里竟然能看到自己提交的申请,并且审批通过后状态正常流转。

原因:后端在做审批接口时只校验了“当前用户是否有审批角色”,没有校验“这条采购单是否归当前用户管”。如果数据权限只依赖前端菜单隐藏,那绕过前端直接调 API 就能越权。

解决:审批接口里补充所有权判断,审批人 ID 不能等于申请人 ID,同时在查询列表 SQL 里加WHERE approver_id = 当前用户或者“部门维度”的数据过滤。我的做法在上面审批代码里已经体现:先用order.getApplicantId().equals(CurrentUserHolder.getUserId())拦截,再更新状态。如果你觉得每个接口都写归属判断很啰嗦,可以把公共逻辑抽成OrderPermissionChecker.checkCanOperate(order, currentUserId, requiredStatus)方法。

5.2 事务没回滚:状态已经改成已入库,库存却没变

现象:入库时出现异常,页面上看到采购单状态是“已入库”,但商品表的 stock 没有增加。

原因:stockIn方法里状态更新和库存自增分别调用了两个 Mapper,如果只给 Service 方法加了@Transactional,但increaseStock这个方法被default修饰或者 Mapper 上没有加@Transactional,那事务边界可能没覆盖到。另一个常见原因是异常被try-catch吞掉了,事务无法感知到运行时异常,导致“捕获了异常但事务仍然提交”。

解决:所有写操作集中在 Service 方法内,方法上统一加@Transactional(rollbackFor = Exception.class);Service 内部不要用try-catch包住 Mapper 调用,让异常抛到事务代理层。如果需要捕获异常做日志,在 catch 块里重新抛出RuntimeException或在 catch 里手动设置TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。

5.3 金额对不上:数据库是 DECIMAL,实体是 BigDecimal,前端却返回了 string

现象:前端页面显示价格“25.0”,导入 Excel 后变成“25”,统计报表里“25.00”和“25.0”分别各占一行。

原因:Jackson 序列化 BigDecimal 时,默认输出25.0,去尾随零后可能是25。而 MySQL 里的 DECIMAL(10,2) 格式化是25.00。两边精度不一致不是 bug,是展示层没对齐格式。

解决:后端统一用一个@JsonFormat配置金额字段的序列化格式:

@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "0.00") private BigDecimal price;

它会把 BigDecimal 序列化为"25.00"这种字符串,避免前端解析时损失精度。很多同学都踩过这个坑却不知道为什么——其实根源在 JSON 序列化规则和数据库精度规则不属于同一层,必须主动对齐。

5.4 前端传了“已审批”状态直接入库:后端没校验状态,接口被人为调用

现象:用 Postman 直接调/api/order/stockIn接口,传入一个“待初审”状态的采购单 ID,代码竟然入库了。

原因:stockIn方法只校验了订单存在性,没校验状态,或者校验时用了错误的状态值还有一种可能是状态比较用了==比较 Integer,导致Integer == Integer在值为 127 以内能用但太大时失效,推荐用equals比较。

解决:在入库方法开头完整校验状态当前是否为“审批通过”,同时加入幂等保护。校验逻辑放在 Service 层,不要依赖任何前端控制。另外,所有状态字段既然用了 TINYINT,Java 侧对应就用Integer或Byte,不要在代码里把它当int去==,统一用equals。

5.5 超时订单仍然能入库:只做了功能,没做时间校验

现象:导师验收时问“如果采购单审批通过一个月还没入库,钱一直是冻结状态,会不会有问题”。你愣了,因为代码里根本没想过时间维度。

原因:状态机只覆盖了“操作状态流转”,没有覆盖“时间触发状态流转”这种业务规则。比如审核通过后 30 天内未入库,系统要自动取消并释放额度。

解决:方案很多,最轻量的是在stockIn入库前校验创建时间和当前时间差。但更优雅的是用一个定时任务统一处理过期未入库的订单。做法:先加一个expire_time字段,审批通过时更新为“当前时间 + 30 天”。定时任务每 5 分钟扫一次到期的采购单,把所有status=1 AND expire_time < NOW()的单子改成“已过期”,或新增一个状态。早年我接了某高校的一个模拟项目,就是这类问题没处理,导致应付账款对不上,最后被要求在验收前一周内紧急补上定时任务。这是很有价值的前车之鉴。

6. 想拿高分,答辩时把这三个关键词讲清楚就够了

6.1 状态机带来的工程化收益:一张图讲清楚所有流转

很多毕设只实现了“状态字段 + if 判断”,而你在代码里用了PurchaseStatus枚举加TRANSITIONS表,这本就是答辩时可以主动引导的一个亮点。但前提是你能从两个维度把它讲透——第一,为什么用状态机;第二,状态机在这里到底管住了什么问题。

推荐的说法是:“状态机把系统里所有的合法路径集中在一个地方管理,避免业务逻辑散落在多个 Service 方法里。以采购单为例,待初审状态下只允许通过、驳回、取消三种操作;一旦入库,就进入终态,任何写操作都会被拒绝。这样非法操作在入口处就会被拦截,而不是走到一半报错。”

配合论文里的一张状态迁移图表达,把五张拓扑图对应的TRANSITIONS每个箭头都按“业务意义”讲一遍:学生提交、辅导员审批、采购员入库、驳回后重新提交、取消。这张图要画得足够大,打印出来贴答辩 PPT 里,比放十页代码截图管用。

6.2 幂等保护与乐观锁:用两个具体现象证明你处理过并发

毕设答辩时,导师问“你这个系统能并发吗”的概率极高。不要只回答“能”,要给现象、原因和解决。我建议你准备一个真实验证过的自测用例:在测试里模拟两个线程同时调用stockIn,把第二个入库请求拦下来并断言它的返回结果。然后把这个问题归纳为“重复提交 + 并发覆盖”,给出你的两个解法——条件更新 + SQL 自增。这两招说起来有细节,是一个真正的工程化心法,很多毕业设计中连提都不提。

6.3 从“演示能跑”到“验证能过”:一条验收命令打通全部用例

最后一章不写新功能,教你一套验收习惯。每次动工后,统一执行:

mvn clean test

约两分钟内,完整跑完所有 Service 层测试——创建采购单、校验金额、审批、入库、并发防重。如果BUILD SUCCESS,说明核心链路没破。接着用一个只读接口查采购单列表,确认数据和预期一致。这一套流程下来,基本能覆盖代码以外容易翻车最惨的金额、库存、状态三个环节。

以前接某个跨平台毕业设计辅导项目时,A同学在验收前三天忍痛把所有double改成BigDecimal,顺手加了上面这套测试,结果现场演示一笔 45 元的采购单在审批——入库全流程里金额分毫不差,导师当场给了一个亮眼的成绩。这几乎是每个做这个方向的开发者会经历的一次醒悟。方向做对了,毕业设计就是一门手艺活。希望今天这篇实战笔记能帮你省下那些不必要的时间,把精力留给更值得打磨的细节,真心希望帮到你。

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

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

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

立即咨询