ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot高校奖助学金系统:状态机与多级权限实战解析

Spring Boot高校奖助学金系统:状态机与多级权限实战解析 去年接手了一个某高校信息中心委托的校园信息化类项目其中一条业务线就是奖助学金管理。当时团队里有个新手看资料时直接问了一句“这不就是学生填表、老师审核、管理员导Excel吗”等真正把需求文档铺开、和数据中心的人对完字段他才知道自己错得有多离谱。这也是我今天想认真聊聊这套基于Java Spring Boot的高校奖助学金系统的原因。它不是那种“写几个增删改查交差”的课设而是一个需要同时处理贫困生认定、奖学金评定、助学金发放、材料归档、公示留痕、多级审核权限的真实业务系统。对于正在做毕设、或者刚进公司需要快速接手该类项目的同学来说弄清楚业务边界、状态流转、权限模型和部署细节比单纯把代码跑起来重要得多。这篇文章我会按自己的实际开发顺序来写先说清业务需求的上限和下限再讲技术选型与分层架构然后是数据模型、三级审核权限、核心流程的代码走查最后把本地运行和部署时最容易翻车的几个细节一次性说透。能帮你在复现这套源码时少走弯路也能让你给答辩老师或面试官讲系统时心里更有底。1. 先理解需求奖助学金系统的业务边界到底有多宽1.1 别把它当成一个简单的“申请表管理系统”很多第一次接触这类项目的同学拿到需求文档后习惯性画出“学生提交申请表、辅导员审核、管理员导出汇总”三条线然后就开始建表写接口。这样做到中期一定会返工因为高校奖助学金业务的本质是多项目、多批次、多角色、多状态的协同流转而不是单一表单的CRUD。以我做过的这个系统为例涉及的奖学金类别通常包括国家奖学金、国家励志奖学金、校级一等奖学金等助学金又分国家助学金、社会助学金、临时困难补助。不同项目有不同的申请条件、名额限制、评审规则和公示要求。更麻烦的是“家庭经济困难认定”这个前置环节——学生要先提交家庭情况调查表和相关佐证材料经过班级评议小组、院系资助专员、学校资助管理中心三级认定获得一个困难等级特殊困难、困难、一般困难之后才有资格申请某些助学金项目。如果一开始没把这些项目类型和认定流程建模清楚后面写代码时就会出现大量if/else嵌套比如“如果是国家助学金就必须先有困难认定结果”“如果是国家奖学金成绩排名必须在专业前10%”这类业务规则全部散落在Service层维护成本直线上升。1.2 管理端角色比想象中多学生、辅导员、院系专员、校级管理员系统使用者也远不止“学生”和“管理员”。一套完整的高校奖助学金系统前端用户至少包含四类身份学生填写申请、上传材料、查看审核进度、确认收款。辅导员/班主任负责班级评议、初步审核学生提交材料的真实性。院系资助专员统筹本院系的评审工作分名额、组织答辩、录入院系审核意见。校级管理员管理资助项目、参数配置、终审、公示、发放复核、数据统计。这意味着权限控制不能靠“用户表里加一个role字段”就完事。我在这套系统里采用的做法是RBAC基于角色的访问控制 数据范围隔离双管齐下。每个用户登录后后端会解析出他的角色列表和所属院系/班级ID查询数据时自动追加过滤条件比如辅导员只能看到自己班级的学生申请院系专员只能看到本学院的数据校级管理员才能看全量数据。1.3 流程状态是系统的灵魂从草稿到最终归档我见过不少半成品项目申请记录表里只有一个status字段值域写的是0和1分别代表“未审核”和“已通过”。这种设计在演示阶段能跑通但一遇到“退回修改”“院系通过后校级驳回”“公示期间被举报需要重新评议”就彻底崩了没有地方记录每次审核的意见和操作人出了问题根本追责不到人。规范的奖助学金流程至少包含这些状态状态含义下一步操作DRAFT学生暂存未提交学生提交SUBMITTED已提交待辅导员审核辅导员通过/驳回CLASS_PASSED班级评审通过上报院系DEPT_REVIEW院系审核中院系通过/退回DEPT_PASSED院系通过校级复核SCHOOL_REVIEW校级终审中终审通过/驳回APPROVED终审通过生成公示记录PUBLICIZING公示中公示期结束自动归档CONFIRMED已确认发放财务打款后确认REJECTED已驳回学生可重新编辑CANCELLED学生主动撤回流程终止每个状态之间不是任意跳转的代码里必须用状态机约束。这部分我在后面第5节会给出具体的落地写法。2. Spring Boot技术选型与分层架构为什么这套系统用这些技术不会过时2.1 技术栈逐项分析不是越新越好这套系统的题目里写了“Java Spring Boot”这个选择本身就很务实。Spring Boot在校园信息类系统中几乎成了事实标准原因很简单起步快、生态成熟、招人容易上手。我最终敲定的技术组合是Spring Boot 2.7.x稳定兼容性好Oracle JDK 8和OpenJDK 11都能跑。不要一上来就追Spring Boot 3除非你把JDK换到17否则版本对不上会踩一堆坑。MyBatis-Plus单表CRUD、分页、条件构造器非常省事适合业务集中在数据流转、没有复杂查询优化的系统。MySQL 8.0数据容量、事务支持都够用字符集一定选utf8mb4。Apache Shiro 或 Spring Security认证和权限二选一。我项目里用的Shiro因为它的注解式权限控制和Spring Boot集成比较简单适合管理员角色少、权限规则清晰的场景。Vue 2 Element UI后台管理界面的常规组合做表单、表格、树形菜单快。我见过有人把Spring Boot版本选成3.1.x又搭配了JDK 8结果启动时报UnsupportedClassVersionError花了一下午换环境。这类坑纯属自找成熟项目先看版本兼容矩阵再看功能。2.2 Controller-Service-Mapper三层但业务逻辑绝不允许堆在Controller分层架构看起来简单真正做到位的项目不多。我看到很多学生的代码Controller里直接撸查询逻辑把BaseMapper.selectList的返回结果包装一下就返回前端遇到需要事务的操作也不加Transactional这类代码在数据量小的时候看不出问题一旦审核流程并发操作就容易出现脏数据。我在这套系统里严格遵守三层职责Controller层只做参数接收、调用Service、返回统一结果对象不写任何if/else业务判断。Service层承载全部业务规则。包括状态校验、数据权限过滤、审批意见记录、调用外部服务。Mapper层只负责持久化操作复杂SQL写在XML里禁止链式拼SQL字符串。为了统一前后端交互格式我做了一个ResultT包装类结构是code message data每个接口都返回这个对象。前端拿到code 200再取data接口报错时弹出message省去了大量异常处理样板代码。2.3 为什么需要“状态机审批流”而不是散落的if/else一个奖学金申请从提交到终审通过中间要经历四五个角色、七八种状态。如果每个Service方法都写if (status 1 userRole 2)来控制到后面代码的复杂度是指数上升的。我采用的方案是用一个ProcessContext保存当前申请单和处理动作通过配置驱动状态跳转。核心思路是把每个状态节点定义成配置项状态之间的合法转换关系写在Map里改流程时不动业务代码只改配置。这个设计对“某高校今年增加一个学院审核环节”这类需求特别友好改配置比改一堆if分支稳得多。3. 核心数据模型六张表讲清资格、申请、审核、发放、公示、归档3.1 六张核心表之间的关系数据库建模是这套系统里性价比最高的一步。模型建好了后面业务代码写得行云流水模型建坏了后面每个接口都要临时join加字段。我最终梳理出的核心表包括表名作用关键字段t_student学生基本信息student_no, name, college_id, major, class_id, gradet_poverty_verify家庭经济困难认定student_no, verify_year, difficulty_level, reviewer, verify_timet_aid_project资助项目配置project_name, project_type, amount, quota, apply_start_time, apply_end_timet_application奖助学金申请单student_no, project_id, current_status, current_node, apply_timet_audit_record审核记录application_id, operator_id, action, opinion, audit_timet_grant_record发放记录application_id, actual_amount, grant_time, bank_card_no, confirm_status中间还有一张t_application_material存证明材料一张t_publicity_record存公示内容但核心流转就是上面六张。为什么把“审核记录”单独拆一张表而不是往申请单里不断拼字段因为一次申请可能被退回三次每次都要保留操作痕迹。存在同一行里要么字段爆炸要么历史意见被覆盖审计根本不通过。3.2 字段设计上容易被忽略的几个细节第一个细节是金额用decimal(10,2)禁止用float或double。奖助学金涉及的经费是需要对账的浮点运算会产生精度误差一旦差一分钱都说不清楚。所有金额相关的计算都在Java里用BigDecimal完成。第二个细节是状态字段不要用int裸存。我会用varchar存枚举名字如SUBMITTED、DEPT_REVIEW代码里对应Java枚举。这样排查问题时看一眼数据库就明白这条数据在哪个环节不用翻代码文档去回忆0代表什么、3代表什么。第三个细节是所有核心表都加create_time、update_time、deleted标记。前两个字段用于审计deleted标记用于逻辑删除。奖助学金数据涉及学生隐私和经费发放物理删除风险太大逻辑删除是底线。3.3 资助项目表是配置驱动流程的关键t_aid_project这张表往往被新手低估觉得它就是个简单的下拉框数据源。实际上它是整个系统的“规则引擎入口”。我在这张表里设计了这些字段资助项目类型、预算金额、名额总数、每人标准金额、申请开始时间、申请结束时间、报名条件描述、可申请的困难等级要求。到了申请季管理员配置好项目后学生端只能看到“当前可申请”的项目后端在提交申请时还会校验学生是否满足该项目的前置条件。这样一来国家助学金要求“已通过本年度困难认定”国家奖学金要求“成绩排名前10%”等规则都从代码中剥离出来了。规则变化时只更新配置代码一行不动。4. 三级审核的权限模型辅导员、院系资助专员、校级管理员的角色边界4.1 为什么说“认证是入口授权才是核心”登录认证做起来不难一个shiro登录就能搞定。真正容易出问题的是授权——同一个接口不同角色访问看到的数据是不同的。不少学生项目败在这一点走通登录后所有管理员能看到全部学生的家庭信息这在真实校园环境里属于严重的隐私事故。我在设计授权时做了三层控制接口级权限通过Shiro的注解控制谁能访问哪个接口。例如RequiresRoles(school_admin)标注校级管理员的接口。数据级权限Service层根据当前登录人所属学院和班级自动追加查询条件。辅导员查申请列表时SQL固定带上class_id ?。操作级权限不是所有角色都能点“通过”按钮。状态机里定义了动作的合法执行角色辅导员只能执行班级初评校级管理员才能执行终审。这样即使前端把“终审通过”的按钮不小心渲染出来后端也会因为角色不匹配拒绝操作不会出现越权数据变更。4.2 退回与驳回应有明确区分很多半成品项目只有一个“驳回”动作辅导员觉得材料缺了就让申请状态回到未提交学生重新填一次。看似简单实际上很不合理——到底是“材料还可以修改修改完再交回审核队列”还是“这个学生根本不符合本项目的申请资格整条申请作废”这两者的业务含义完全不同。我在这套系统里做了两个动作退回RETURN申请状态退回到上一步但记录不清除。学生修改资料后重新提交进入原审核节点之前的审核意见保留。驳回REJECT终局状态本次申请终止。学生如果想再申请只能新建申请单不能修改旧单继续提交。这两个动作在审核界面分别对应“退回修改”和“不符合条件”按钮。业务人员使用起来非常直观审计记录里也能清楚地区分出哪些是被退回后补交的、哪些是直接被否掉的。4.3 操作日志与审计留痕是刚需奖助学金的敏感性决定了系统中每一次审核动作都必须有完整留痕。t_audit_record表的每行记录包含操作人ID、操作人姓名、操作动作、审核意见、操作时间。公示期间如果有人质疑某条申请管理员可以按申请单号查出一条完整时间轴谁在什么时候做了什么事情、写了什么意见。我在写这部分的时候特意在Service层用了AOP切面自动把审核动作写入审计表而不是在每个Controller方法里手动调用。这样即使以后新加审核动作也不会漏记日志。5. 跑通一条完整审核链从学生申报到资金发放的代码走查5.1 学生提交申请事务内完成校验与状态初始化学生提交申请时后端要同时做几件事校验申请时限、校验困难等级是否符合、保存申请单基础数据、保存证明材料、初始化当前状态为SUBMITTED。这几步必须在同一个事务里。我贴一下核心Service代码省略了一些参数校验细节Transactional(rollbackFor Exception.class) public ResultLong submitApplication(ApplicationSubmitDTO dto) { Student student studentMapper.selectByNo(dto.getStudentNo()); AidProject project aidProjectMapper.selectById(dto.getProjectId()); // 1. 校验项目是否存在、是否在申请时间窗口内 if (project null) { return Result.fail(资助项目不存在); } LocalDateTime now LocalDateTime.now(); if (now.isBefore(project.getApplyStart()) || now.isAfter(project.getApplyEnd())) { return Result.fail(当前不在该项目申请时间内); } // 2. 校验前置条件以助学金为例必须有本年度的困难认定 PovertyVerify verify povertyVerifyMapper.selectCurrentYear(student.getStudentNo()); if (project.getNeedPovertyVerify() verify null) { return Result.fail(请先完成本年度家庭经济困难认定); } // 3. 保存申请单初始状态为 SUBMITTED Application app new Application(); app.setStudentNo(student.getStudentNo()); app.setProjectId(project.getId()); app.setCurrentStatus(ApplicationStatus.SUBMITTED.name()); app.setCurrentNode(AuditNode.CLASS_COUNSELOR.name()); applicationMapper.insert(app); // 4. 保存证明材料附件列表 if (CollectionUtils.isNotEmpty(dto.getMaterials())) { for (String url : dto.getMaterials()) { applicationMaterialMapper.insert(app.getId(), url); } } // 5. 记录一条系统操作日志提交动作本身也算审计数据 auditRecordMapper.insert(AuditRecord.createSubmitLog(app.getId(), student.getStudentNo())); return Result.success(app.getId()); }这里最值得学习的就是开头的多个前置校验。它们保证了脏数据根本进不了系统“在读生申请往年的奖学金”“没做困难认定就申请助学金”这些逻辑错误都被拦截在事务入口。5.2 辅导员审核状态推进与数据权限缺一不可辅导员审核的Service方法里有两个点非常容易做错。第一个是忘了校验这条申请单是不是自己班级学生的第二个是审核动作和执行角色不匹配。我写了一个通用的handleAudit方法既处理通过也处理退回/驳回Transactional(rollbackFor Exception.class) public ResultVoid handleAudit(AuditRequestDTO dto, User operator) { Application app applicationMapper.selectById(dto.getApplicationId()); if (app null) { return Result.fail(申请单不存在); } // 数据范围校验辅导员只能审核本班学生的申请 Student student studentMapper.selectByNo(app.getStudentNo()); if (!operator.getDeptId().equals(student.getClassId()) !operator.isSchoolAdmin()) { return Result.fail(无权审核该申请); } // 操作角色校验当前节点必须是班级辅导员审核 if (!AuditNode.CLASS_COUNSELOR.name().equals(app.getCurrentNode())) { return Result.fail(当前不在班级辅导员审核环节); } // 根据动作推进状态机 ApplicationStatus nextStatus processEngine.getNextStatus( ApplicationStatus.valueOf(app.getCurrentStatus()), AuditAction.valueOf(dto.getAction()) ); app.setCurrentStatus(nextStatus.name()); app.setCurrentNode(processEngine.getNextNode(nextStatus)); applicationMapper.updateById(app); // 写审核意见与审计记录 auditRecordMapper.insert(AuditRecord.createAuditLog( app.getId(), operator.getUserId(), dto.getAction(), dto.getOpinion(), LocalDateTime.now() )); return Result.success(null); }状态机的核心逻辑在processEngine里维护它是一个MapStatusSet, NextStatus的结构把非法跳转直接挡在门外。这样写的好处是无论代码里有多少个调用方状态推进的规则只存在于一处。5.3 公示与发放状态自动流转的边界场景终审通过后申请单进入公示状态。公示不是即刻完成的系统要生成一条公示记录设定公示开始时间和结束时间通常是5个工作日。公示期结束前管理员不能手工把它改成“已发放”否则资金台账和公示台账就对不上。这块我用了Spring的Scheduled定时任务来扫表。每半小时扫描一次所有PUBLICIZING状态且当前时间超过publicity_end_time的申请单自动推进到CONFIRMED状态。这样既满足了业务要求也避免了管理员每天手动“批量确认”的繁琐操作。发放确认的逻辑更严格记录实际发放金额、收款银行卡号、财务确认时间且一次事务只能操作一条记录。因为涉及资金任何批量update都有风险我宁可用for循环单条处理也不图快写一条update ... where in (...)。6. 本地运行与部署最容易翻车的环境细节6.1 JDK、Maven、MySQL版本的三方匹配拿到源码包后第一步不是打开IDE而是确认这三样东西的版本。我用这套系统时本机环境是JDK 8、Maven 3.6.3、MySQL 8.0项目源码里pom.xml指定的Spring Boot版本是2.7.x。如果你电脑装的是JDK 17且强行使用Spring Boot 2.7以下版本可能会遇到CGLIB代理兼容问题反之用Spring Boot 3.0却配JDK 8则直接启动失败。建议严格按照项目文档中标记的环境版本安装。如果文档没写先打开pom.xml看Spring Boot父依赖版本再对照官方兼容矩阵决定下载哪个JDK。6.2 数据库初始化别只导入表结构还要初始化基础数据很多同学卡在“启动成功但页面空白”这一步原因是只执行了init.sql里的建表语句没导入data.sql中的数据。这个系统的核心几个基础表必须要有初始化数据才能正常登录用户表和角色表里至少要有测试账号通常是一个学生、一个辅导员、一个校级管理员。院系表、班级表要有基础数据否则新增学生时外键校验过不去。资助项目表里要预置一条当前可申请的测试项目否则学生端申请列表是空的。我用一个很小的习惯解决这个问题每次跑通系统后立刻用mysqldump导出一份带数据的完整SQL文件作为下一次部署的种子数据包。比手抄测试账号高效得多。6.3 端口占用、Redis依赖、文件上传路径这三处经典坑第一是端口。Spring Boot默认8080排查启动失败时先看控制台第一行日志到底是Port 8080 was already in use还是其他报错。可以在application.yml里改server.port也可以在启动命令上加--server.port8090。第二是Redis。不少带“讲解视频”的项目会在业务里引入Redis做缓存但提供给你的源码里不一定把Redis连接信息改成你本地的。如果你看到启动日志卡在连接Redis的redis.clients.jedis.exceptions.JedisConnectionException就去application.yml里检查Redis地址和密码本机没有密码就删掉或置空。第三是文件上传。学生证明材料的图片路径如果写成绝对路径如/root/upload/在Windows上跑就会报FileNotFoundException。正确做法是在配置文件中定义file.upload-dir变量代码里用它拼路径运行时再根据平台指定。6.4 运行视频里不会告诉你的自查清单我把这套系统从源码到跑通反复整理过一个自查清单按顺序执行基本能解决90%的启动问题检查pom.xml中父依赖版本确定JDK版本匹配。检查MySQL连接串的useSSL和serverTimezone参数不配置会报时区错误。检查application.yml中数据库名、账号、密码是否和本地环境一致。检查Redis连接配置本地没有Redis则把配置注释掉或启动一个默认端口的Redis。检查前端项目和后端接口地址是否一致跨域配置是否允许了你实际使用的端口。启动后端后先访问/login相关接口确认认证模块能通再去碰业务模块。7. 我在接手这类学生项目时总结的几条实操建议7.1 先跑通再读代码最后才改需求我的习惯是先按照文档把系统整个跑一遍用测试账号把“学生申请-辅导员初审-院系审核-校级终审-公示-发放确认”这条主链路走通。走通的意义不只是验证环境更是建立对系统的认知地图每条数据在哪张表里、每次状态变化发生在哪个Service方法里。拿到手就急着改代码往往会在改完一个接口后导致流程断掉回头排查浪费大量时间。7.2 讲项目时重点讲状态机和数据权限比讲页面交互得分高答辩或面试时我观察到一个明显差异总分不高的同学在讲“我做了登录注册、做了表单增删改查”而高分回答往往在讲“这个系统的审核流程是如何用状态机保证数据一致性的”“如何防止辅导员越权看到其他班级的数据”。后者才是奖助学金系统真正的难点也是代码里最值得展开的部分。7.3 文档和讲解视频不要只录操作流程要录“为什么”如果你手头这套系统是要二次交付给别人的我强烈建议你在录制讲解视频时除了演示页面点击多花几分钟时间讲一个核心流程的代码执行过程。比如选中“提交申请”这个接口从前端调用到Controller、Service、Mapper每一步做了什么、事务边界在哪里、如果失败如何回滚。一次闭环的代码走查比十遍操作界面演示更能体现你对系统架构的把控力。这也是为什么我一直强调不要只把源码跑起来就交差。奖助学金系统的价值在于它对“业务规则、状态流转、角色权限”三者的严谨处理你真正掌握了这三点换一个业务领域同样能快速上手。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进