☰
Spring Boot防疫物资管理系统:管理员功能与权限实战解析
2026/10/13 7:32:26 网站建设 项目流程

最近在给某高校后勤处做一套基于Spring Boot的防疫物资调用归还管理系统,业务本身不算复杂——口罩、消毒液、防护服这些物资要有台账,各院系要能在线提交调用申请,用完剩下部分还能归还入库。但项目推进到第二周,需求方突然追加了一个模块,代号叫abo,内容简单直接:新增管理员账号。我本来以为这就是一个普通的用户管理功能,无非是做个表单、写个接口、存条记录,结果真正做起来才发现,它牵扯到了权限模型梳理、密码安全策略、接口幂等性与审计日志一整套东西。这篇文章就把这个项目从数据建模、接口设计、核心流程再到排错实录完整拆一遍,重点会放在新增管理员这个功能的实现链路上,给正在做类似Spring Boot管理系统的朋友一个可以直接参考的落地版本。

1. 需求拆解:高校物资管理到底要管什么

接手这个项目的时候,需求方给出的信息很零散,大概意思是"把Excel台账搬到网上,能申请物资、能还物资、能管管理员"。这种描述乍一听很清晰,但落到开发层面全是模糊地带——谁可以申请?申请之后要不要审批?归还之后库存怎么处理?管理员能不能自己给自己分配权限?这些如果不先拆清楚,后面写代码只会越写越乱。

1.1 三个核心业务场景:物资台账、调用申请、归还入库

这个系统的业务主线其实就三条。第一条是物资台账管理,也就是基础数据的维护,类似商品SKU的概念。每一种物资都要有名称、分类、规格、计量单位、库存上限、当前剩余量这些字段,管理员可以随时调整基础信息,但库存字段不能让人随便改,只能通过出入库操作去驱动变化。

第二条是调用申请。各院系或者部门提交一份调用单,里面可以包含多种物资,每项物资有对应的申请数量。调用单一旦创建,系统需要锁定这部分库存,实质上就是从总库存里扣减,但扣减的动作必须和单据的创建放在同一个事务里,避免出现单据建好了、库存却没扣的情况。这里我采用的是"预扣+确认"的思路,单据提交成功即视作调用完成,不搞多层审批,因为高校内部物资调用的链路很短,审批流反而拖慢效率。

第三条是归还入库。调用单位把没用完的物资还回来,管理员在系统里登记归还信息,包括对应的是哪张调用单、归还了哪类物资、归还数量是多少、物资状态是否完好,然后系统把库存回填。这一步容易忽略的点是:归还的物资状态可能存在损坏或过期的情况,不能简单全额回填,需要区分可继续使用数量和报损数量,报损部分走另外的处置记录。

1.2 角色权限模型的确定:为什么必须区分超级管理员与普通管理员

需求方最初只说"要有管理员账号管理功能",但我们自己梳理下来发现,如果把所有管理员都放在同一个权限级别上,会出现一个明显的管理漏洞——任何一个管理员都能修改其他管理员的信息,包括重置密码这类敏感操作,这在制度上是不可接受的。

因此我把角色拆成两级:超级管理员(SUPER_ADMIN)和普通管理员(ADMIN)。超级管理员负责系统配置、管理员账号的创建与停用、调用单据的最终归档;普通管理员则负责日常的物资出入库操作、调用单登记、归还登记。也就是说,新增管理员这个功能天然就是超级管理员权限下的专属能力。这个决定在后来的开发中帮了大忙,因为后续所有接口都只需要校验一个角色字段,就能确定请求者能否执行该操作,整个权限体系的代码实现成本非常低。

1.3 状态机设计:一份调用单从创建到归档的完整生命周期

第二个容易被忽略的是调用单的状态流转。很多人一上来就建一张表存订单数据,加一个status字段,用到的时候随手填个数字,结果状态之间没有任何约束,数据很快就脏了。

我在设计时明确了一组状态枚举:待领取(PENDING)、已完成(FINISHED)、部分归还(PART_RETURNED)、已归还(RETURNED)、已取消(CANCELLED)。一份调用单创建时是待领取状态,管理员确认物资出库后变为已完成;之后每发生一次归还登记,系统判断该单据下所有物资是否存在剩余可归还数量——如果还有部分没有归还,状态变成部分归还;全部归还完成则置为已归还。假如调用单位整单放弃,需要走取消操作,但取消的前提是该单据没有任何归还记录,否则不允许取消,只能走"部分归还"的流程。

public enum CallOrderStatus { PENDING(0, "待领取"), FINISHED(1, "已完成"), PART_RETURNED(2, "部分归还"), RETURNED(3, "已归还"), CANCELLED(4, "已取消"); private final int code; private final String desc; }

这套状态机在写Service层逻辑时起到了很好的约束作用,每次状态变更都从枚举定义的流转路径里去走,不存在随意赋值的空间。

2. 数据模型设计:五张核心表的字段规划与关系

项目的表结构我最初设计了三张表,后来在做管理员功能时扩展到了五张。很多初学者设计表的时候喜欢把能想到的字段全都塞进一张大表里,但实际开发中我更倾向于按业务边界拆表,每张表只承担一个明确职责,查询时通过关联去组装数据,代码可读性和后期维护性都会好很多。

2.1 物资表与库存字段的冗余设计

物资基础表material_info的核心字段如下:

CREATE TABLE material_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, material_name VARCHAR(64) NOT NULL COMMENT '物资名称', category VARCHAR(32) NOT NULL COMMENT '物资分类', spec VARCHAR(64) COMMENT '规格型号', unit VARCHAR(16) NOT NULL COMMENT '计量单位', total_stock INT NOT NULL DEFAULT 0 COMMENT '累计入库总量', remaining_stock INT NOT NULL DEFAULT 0 COMMENT '当前剩余库存', safety_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 );

我刻意把total_stock和remaining_stock都保留了。total_stock是历史累计入库量,用于统计报表;remaining_stock是当前可用库存,用于每次调用时的扣减判断。这两个字段属于典型的冗余设计,但不影响一致性,因为它们都是通过同一套事务代码去维护的,跟"查询时现场count"相比,这种做法的查询性能好很多,尤其在列表页要展示库存的时候,不用每行都做一次聚合计算。

2.2 调用单表与归还记录表的拆分逻辑

调用单相关的表我拆成了两张:call_order和call_order_item,一个是主单据,一个是单据明细。主单里存放的是单据号、申请单位、经办人、整体状态这类公共信息,明细表存放每个物资的申请数量、实际出库数量。

CREATE TABLE call_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '单据编号', department_name VARCHAR(64) NOT NULL COMMENT '申请单位', applicant_name VARCHAR(32) NOT NULL COMMENT '申请人', admin_id BIGINT NOT NULL COMMENT '操作管理员ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '单据状态', remark VARCHAR(255) DEFAULT NULL COMMENT '备注', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME DEFAULT NULL COMMENT '完成时间' ); CREATE TABLE call_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL COMMENT '调用单ID', material_id BIGINT NOT NULL COMMENT '物资ID', apply_quantity INT NOT NULL COMMENT '申请数量', actual_quantity INT NOT NULL COMMENT '实际出库数量', return_quantity INT NOT NULL DEFAULT 0 COMMENT '已归还数量' );

归还记录单独放在return_record表,它的作用不仅仅是一笔流水,还包含了质量信息。一次归还可能涉及多种物资,所以归还记录同样用主从结构,return_record主表记录归还单号、关联的调用单ID、归还单位、归还时间,return_record_item明细表记录每种物资归还了多少、其中报损多少、当前状态如何。把调用和归还拆成两套独立但相互关联的表结构,后续做"某物资在某个时间段内的调用归还对比报表"时会非常顺手。

2.3 管理员表的设计与密码处理策略

abo新增管理员这个功能对应的就是admin_user表,这是整个权限体系的核心载体。

CREATE TABLE admin_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT '登录账号', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', real_name VARCHAR(32) NOT NULL COMMENT '姓名', role TINYINT NOT NULL DEFAULT 1 COMMENT '角色 0超级管理员 1普通管理员', phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话', status TINYINT NOT NULL DEFAULT 1 COMMENT '账号状态 1启用 0停用', last_login_time DATETIME DEFAULT NULL COMMENT '最近登录时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );

关于密码存储,我强烈建议直接使用BCrypt加密,不要自己发明hash算法,更不要明文入库。BCrypt是自适应hash算法,每一次哈希会随机加盐,即使两个账号密码完全相同,生成的密文也不一样,这能有效防范彩虹表攻击。

2.4 表关联关系:为什么我不建物理外键

在五张核心表里,admin_id、order_id、material_id这些字段本质上都是外键,但我在建表时全部没有声明FOREIGN KEY约束,只建了普通索引去加速查询。很多从课程设计走过来的开发者会习惯性加物理外键,但企业级项目里物理外键的代价非常明显:每次插入或更新都要额外做一次约束检查,影响写入性能;更重要的是,一旦表之间有级联约束,在做数据归档、误删恢复时会非常痛苦。逻辑外键由应用层保证,写代码时通过事务控制,多表操作要么全部成功要么全部回滚,数据一致性完全可控。

3. Spring Boot工程搭建与分层架构:从零到能跑起来的基建

技术选型上我直接选了常规组合:Spring Boot 2.7.18 + MyBatis-Plus 3.5.x + MySQL 8.0 + Redis,JDK用的1.8。这个组合谈不上多新,但胜在稳定、资料多、团队上手快,加上需求方后续大概率要交给校内的人维护,技术栈越主流越好。

3.1 快速创建项目与依赖选型

项目初始化我习惯直接访问start.spring.io手动选依赖,而不是用IDE的内置模板,这样能更清楚自己到底引入了什么。核心依赖包括spring-boot-starter-web、spring-boot-starter-validation、mybatis-plus-boot-starter、mysql-connector-j、jjwt-api/impl/jackson,以及lombok。

有一个容易被坑的点是MyBatis-Plus版本和Spring Boot 2.x的兼容性,建议直接用3.5.3或以上版本,因为低版本对Java 8时间类型的支持存在坑,LocalDateTime字段的自动填充经常踩到。

3.2 统一响应体、全局异常处理与参数校验

开发接口的第一步不是写Controller,而是先定好统一响应结构和异常处理。我在项目里建了一个Result类,泛型设计,默认code为200表示成功,业务异常code为400或者500,所有Controller方法的返回值都包装成Result返回给前端,这样前端只需要写一个axios拦截器就能统一处理错误提示。

参数校验这块我直接用了javax.validation的注解体系,比如在新增管理员的入参DTO上标注@NotBlank、@Pattern这类注解,方法入口加@Validated,就能省掉一大坨手写校验逻辑。

@Data public class AdminCreateRequest { @NotBlank(message = "登录账号不能为空") @Pattern(regexp = "^[a-zA-Z0-9_]{4,20}$", message = "账号仅支持4-20位字母、数字、下划线") private String username; @NotBlank(message = "密码不能为空") @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{8,20}$", message = "密码需为8-20位且同时包含字母和数字") private String password; @NotBlank(message = "姓名不能为空") private String realName; @NotNull(message = "角色不能为空") private Integer role; private String phone; }

3.3 JWT登录态与自定义注解实现接口级权限控制

登录认证我选择了JWT,而不是传统Session方案。JWT无状态、跨域友好,适合前后端分离的项目。用户登录成功后,后端生成一个token,里面包含adminId和角色信息,前端把token存在localStorage里,每次请求带上Authorization头。

拦截器解析token后把当前登录人的信息放到ThreadLocal中,后续业务代码里直接通过AdminContext.getCurrentAdmin()取当前用户,非常方便。

权限控制我采用了一个自定义注解@RequireRole,标注在方法上,配合一个AOP切面去校验当前用户的角色。比如新增管理员接口标注@RequireRole(SUPER_ADMIN),这样只有超级管理员能访问,代码语义一目了然。

4. 新增管理员功能的完整实现链路

标题里的"abo"在项目里其实就是需求方对"管理员维护模块"的一个内部代号,当时产品文档也没做解释,我们就当作功能代号沿用了。这个模块包含新增、编辑、停用、重置密码、列表查询五个子功能,工作量集中在新增和权限约束这两块。

4.1 需求范围确认与隐藏规则

开发之前我和需求方对齐了新增管理员的边界条件,有几个规则如果不提前确认,后期返工成本极高。

第一个规则是:只有超级管理员可以新增管理员,普通管理员不能访问这个接口。第二个规则是:新账号的初始状态默认启用,但超级管理员创建账号后可以随时停用。第三个规则是:登录账号在系统内全局唯一,哪怕账号曾经被删除,也不能立刻复用到新账号上,防止历史审计数据关联错乱。第四个规则涉及到密码策略,管理员创建用户时可以指定初始密码,但会强制要求该账号在首次登录后修改密码。

因为首次登录改密码会多一个字段和流程,我在admin_user表里加了一个boolean字段force_password_change,默认是true,登录接口检测到这个字段就返回一个特定code,前端收到之后强制跳转到改密页面,改完再清掉标记。

4.2 Controller与Service层的实现细节

新增管理员的接口实现我采用controller -> service -> mapper三层结构。Controller只做参数接收和权限声明,业务逻辑全部下沉到Service。

@PostMapping("/admin") @RequireRole(role = RoleConstant.SUPER_ADMIN) public Result<Long> createAdmin(@RequestBody @Validated AdminCreateRequest request) { return Result.success(adminService.createAdmin(request)); }

Service层的核心逻辑有这几步:第一步检查当前登录用户是否有权限(虽然切面已经拦过,但Service里再判断一次做到双保险);第二步按用户名查库,确认不存在;第三步用BCrypt加密用户传入的原始密码;第四步组装AdminUser实体,设置默认字段;第五步插入数据库;第六步记录一条审计日志。

@Service public class AdminServiceImpl implements AdminService { @Override @Transactional(rollbackFor = Exception.class) public Long createAdmin(AdminCreateRequest request) { AdminUser current = AdminContext.getCurrentAdmin(); if (!RoleConstant.isSuperAdmin(current.getRole())) { throw new BusinessException("无权执行该操作"); } LambdaQueryWrapper<AdminUser> queryWrapper = Wrappers.lambdaQuery(); queryWrapper.eq(AdminUser::getUsername, request.getUsername()); if (adminMapper.selectCount(queryWrapper) > 0) { throw new BusinessException("登录账号已存在"); } AdminUser entity = new AdminUser(); entity.setUsername(request.getUsername()); entity.setPassword(BCrypt.hashpw(request.getPassword(), BCrypt.gensalt())); entity.setRealName(request.getRealName()); entity.setRole(request.getRole()); entity.setPhone(request.getPhone()); entity.setStatus(1); entity.setForcePasswordChange(true); adminMapper.insert(entity); auditLogService.record(current.getId(), "新建管理员", entity.getId()); return entity.getId(); } }

4.3 事务边界与审计日志的双保险

这个接口我加了@Transactional,确保"插入管理员+记录审计日志"要么同时成功,要么同时回滚。这里有个细节:审计日志表和服务数据表放在同一个事务里,可能有人觉得日志失败不该影响主流程,但从合规角度来说,管理员的每次创建、修改、停用操作必须能被追溯,日志和操作要么同时存在,要么同时不存在,不能出现账号创建成功但日志丢失的情况。

4.4 新增管理员功能打磨的细节

功能上线前我专门测了几个边界场景。

第一个是重复点击提交按钮。前端做了防重,但后端也要兜底,除了靠数据库的唯一索引捕获DuplicateKeyException之外,我还在Service层做了先查后插的操作。要说彻底解决并发下的重复提交,需要引入Redis锁或者数据库分布式锁,但考虑到这个系统是高校内部使用,并发量很低,先查后插加唯一索引已经完全够用,就没有过度设计。

第二个是密码强度的校验。我虽然使用正则做了限制,但前端和后端必须各校验一次,因为接口是可以绕过前端直接调用的。第三个是手机号格式,宽松校验,只校验位数和数字组成,避免因为填写习惯不同导致正常数据被拦。

我在开发过程中还发现一个需求方最初没提到的功能,就是管理员列表页需要支持按角色和状态筛选,并且要能区分停用账号。设计列表查询接口时返回的字段也不能包含密码字段,即使密文也不应该传出后端,可以在实体类上给password字段加@JsonProperty(access = Access.WRITE_ONLY)。

5. 物资调用与归还的主流程实现:库存扣减与状态流转

新增管理员这个功能做完之后,回头再看物资调用和归还的主流程,其实两个模块的核心难点都在事务和状态机逻辑上。这一部分我把实现思路完整列出来,因为它是整个系统最核心的业务功能。

5.1 调用物资的接口逻辑:查库存、扣数量、生成单据

调用物资的Service方法我采用的策略是:前端提交申请部门和申请明细列表,后端一次请求完成所有操作。整个方法加上@Transactional,内部按顺序执行这些步骤。

第一步,根据前端传来的部门名称、申请人姓名创建call_order主单,初始状态为待领取,生成一个唯一的order_no,格式类似CALL20250612001。第二步,遍历调用明细列表,对每种物资执行库存扣减操作。第三步,写入call_order_item明细记录。第四步,扣减成功后,把主单状态改为已完成。

这里必须说明为什么扣库存用update语句带条件而不是先查再更新,核心原因是要防止并发超卖。
int rows = materialMapper.decreaseStock(materialId, quantity); // 对应SQL: UPDATE material_info SET remaining_stock = remaining_stock - #{quantity} // WHERE id = #{materialId} AND remaining_stock >= #{quantity} if (rows == 0) { throw new BusinessException("物资库存不足或物资已停用"); }

这种"乐观锁风格"的扣减方式,靠MySQL的行锁保证同一条库存记录在同一时刻只有一个事务能成功扣减,返回的affected rows为0就说明扣减失败,直接从库里拿到了结果,不需要Java层面的同步控制。实际压测证明,这个方案可以轻松支撑几十路并发请求而不会出现超卖。

5.2 归还物资的接口逻辑:校验归属、更新明细、回填库存

归还接口的逻辑比调用复杂一点,因为要校验归还的物资是不是从这张调用单里发出去的,以及调用单有没有被取消。我在Service层的操作顺序是:先按orderId查调用单主表,校验状态不是CANCELLED;再逐项核对归还明细,确认申请物资ID和建议归还数量没有超过该明细的"可归还数量";随后插入return_record和return_record_item记录;再把call_order_item表里的return_quantity累加更新;最后回填库存。

int rows = materialMapper.increaseStock(item.getMaterialId(), item.getReturnQuantity()); // 回填库存不需要带剩余库存条件,因为归还只会增加库存

最关键的一步是状态判断:某一张调用单下所有物资明细的return_quantity都等于actual_quantity,说明全部归还了,主单状态更新为RETURNED;如果只有部分归还,主单状态更新为PART_RETURNED;如果一张单据从来没有任何归还记录,那就维持FINISHED状态。这里要用事务,因为数据完整性几乎全部集中在这一段逻辑上。

5.3 报表视角:库存台账与调用返还记录的可追溯性

系统做到这一步,需求方又加了一个统计分析模块的需求,要看每个学院的调用次数、各类物资的调用总数、归还率等数据。我没有单独新建统计分析表,而是把call_order_item和return_record_item作为数据源,写了一个聚合查询的Mapper方法,按物资维度分组统计调用总量和归还总量,归还率直接在SQL里算出来。这种实时统计方式在数据量不大的情况下效率足够,也避免冗余表带来的数据同步问题。

6. 开发中遇到的典型问题与排错实录

这个项目整体开发周期不到一个月,中间踩了不少坑,有几个问题排查过程比较典型,对做同类项目的同学可以参考一下。

6.1 日期格式化的坑:LocalDateTime与前端交互

项目里几乎所有表都有create_time、update_time字段,实体类用的是LocalDateTime。第一轮联调时前端反馈所有时间显示成"2025-06-12T10:30:00"这种带字母T的格式,看着不友好。排查后发现Spring Boot默认的Jackson序列化LocalDateTime时会使用ISO标准格式,而不是中国用户习惯的"yyyy-MM-dd HH:mm:ss"。

解决方案是在application.yml里配置全局的Jackson序列化规则:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

单配date-format对LocalDateTime无效,还得配合JavaTimeModule和自定义LocalDateTimeSerializer。更省事的方案是在实体类的createTime字段上单独加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),两种做法我都试过,项目里最终用了全局配置加字段注解双保险。

6.2 事务失效的尴尬场面:同一个类内部调用

在写归还逻辑时出过一个经典问题:事务没有生效。具体表现是归还记录插入成功了,但库存回填失败后数据却还是写进去了,没有整体回滚。排查发现原因是ServiceA的方法a调用同一个类里的方法b,而b上标注了@Transactional,但Spring的事务是基于AOP代理实现的,同类内部调用不会走代理,b方法的事务注解相当于失效。

解决办法是把事务边界内的操作放到不同Service类中,或者通过自注入代理对象来调用。这个坑很多初学者都会踩,排查方式也简单:在方法内抛一个RuntimeException看数据是否回滚,如果没回滚基本就是代理失效。

6.3 跨域配置导致的前后端联调失败

前端开发环境跑在Vite默认的5173端口,后端跑在8080端口,必然存在跨域问题。我一开始在后端写了一个带addCorsMappings的WebMvcConfigurer配置类,结果登录接口仍然报跨域,排查了半天发现是因为JWT拦截器等Filter的执行顺序在Spring MVC的CORS处理之前,请求在被拦截器拒绝后响应头里没有加上CORS相关的Header,浏览器自然判定跨域失败。

最终方案是在拦截器里也加一层跨域响应头设置,或者直接使用CorsFilter并设置order为最高优先级,这样拦截器抛出的异常响应也能带CORS头,前端就能正常接收到错误信息而不是跨域报错。

6.4 不同角色看到不同菜单的实现思路

最后一个不算bug但很有代表性的功能:前端菜单需要根据角色动态渲染。超级管理员能看到"管理员管理""物资台账""调用单管理"等菜单,普通管理员只能看到"物资台账""调用单管理",看不到任何账号维护入口。

实现思路是后端登录接口返回当前用户的角色信息,前端在路由守卫里根据角色字段做菜单过滤。我在设计路由meta时给每个菜单路由加了roles数组,例如管理员管理路由的roles是["SUPER_ADMIN"],前端在生成菜单时直接把当前角色不在roles数组内的路由过滤掉。

这套方案简单可靠,也不要求额外维护权限表。如果后续权限粒度细化到按钮级别,再扩展成"角色-菜单-按钮"的三级权限模型也不迟,但现阶段完全没有必要。

写到这里,整个系统的核心实现链路已经完整了。回到标题里的"abo新增管理员"这个功能,我的体会是:单一功能本身不难,难的是把它放进一个已有业务的权限体系里时要考虑的各种边界——账号唯一性、密码存储、角色约束、审计追踪、参数校验、事务一致性。这些细节叠加在一起,才是一个能真正上线给后勤人员用的系统,而不是一个只存在于接口文档里的demo。如果你也在做类似的管理系统,建议把权限模型和物资主流程的状态机先设计清楚再动代码,这会帮你省掉后面大量的改表改接口时间。

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

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

立即咨询