资讯动态

Spring Boot防疫物资管理系统:管理员功能与权限实战解析

发布时间:2026/10/13 7:32:35 来源:尧图企业网站定制
最近在给某高校后勤处做一套基于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 RedisJDK用的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 ResultLong 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(无权执行该操作); } LambdaQueryWrapperAdminUser 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: GMT8单配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。如果你也在做类似的管理系统建议把权限模型和物资主流程的状态机先设计清楚再动代码这会帮你省掉后面大量的改表改接口时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑