SpringBoot+JavaWeb实现高校奖学金申报评定管理系统

发布时间:2026/9/26 7:24:29
SpringBoot+JavaWeb实现高校奖学金申报评定管理系统 每年九月高校学生资助工作办公室的电脑里总会堆满几十上百份命名混乱的申请表——“成绩单_张三”“奖学金_最终版(2)”“国家奖学金2024 改”.xlsx。负责评定的老师要在一堆材料里反复核对成绩排名、证书真伪、综合测评加分项再手工算加权分、组织评审会、打印公示名单。材料口径不统一、重复申报、审核进度一问三不知这些老问题每年都在重复。我这次落地的这套SpringBoot基于JavaWeb的高校奖学金申报评定管理系统就是想把这些琐碎又容易出错的环节线上化让学生在线申报、辅导员初审、学院评审、学生处终审和公示全流程可追踪。如果你正在做类似的毕设或者小中型的JavaWeb管理系统这篇实现记录应该能帮你少走不少弯路从需求拆解、技术选型、数据库设计到核心模块代码和实测踩坑我都会写出来。1. 奖学金评定那套人工流程到底有多少坑1.1 材料收集与资格核对Excel大战先说最伤人的第一个环节材料收集。国家奖学金、校级一等奖学金、单项奖学金每个奖项申报条件不一样有的卡成绩排名前10%有的卡综合测评分数有的还要求志愿服务时长。线下模式下学生交上来的申报表五花八门有人用老表格有人漏填学号有人在荣誉栏里写的奖项名称和证书不一致。老师收完材料后得人工在Excel里比对成绩单、核对排名光这一项就够忙活两三天。资格核对还有个隐性问题重复申报。一个学生成绩不错既满足国家奖学金条件也满足校级一等奖条件但学校规定同一学年只能获评一项。线下靠人眼分辨数据一多就容易漏。系统要做的事情之一就是把这个“是否已申请同类奖项”的校验放到提交动作里从源头挡掉重复数据。1.2 哪些环节必须自动哪些要保留人为决策我在设计时没有追求“全自动评审”。奖学金评定这个过程数据计算可以自动化但最终“这个人该不该获奖”的决策必须留给人。比如综合成绩可以由考试成绩、综合测评、荣誉加分按权重自动算出来但评审委员会对某个学生的书面材料给出的主观评价系统只负责记录不负责评判。所以系统的自动化边界是这样定的自动完成资格条件核对、重复申报拦截、成绩加权计算、公示期时间判断、名单汇总导出。留给人做审核意见填写、是否通过的表态、评委评分、公示期后的最终确认。这个边界很重要。很多同类系统失败就是因为试图让代码替评委做决定最后被用户抛弃。1.3 五个角色五套视图梳理业务流程后我划分出五个角色每个角色看到和操作的都不一样角色核心职责主要操作学生提交申请填写申报表、上传证明材料、查看进度辅导员班级初审核实材料真实性、填写审核意见学院评审员学院复审给推荐人选打分、确认推荐名单学生处经办人校级终审核定名单、发起公示、导出报表系统管理员系统配置奖项设置、权重配置、用户管理、数据统计角色权限必须落到每个接口上不能只靠前端按钮隐藏。后面我会专门讲权限这块的代码实现。2. SpringBootJavaWeb这份技术选型是怎么定下来的2.1 为什么SpringBoot几乎是这道题的“标准答案”项目标题里同时出现“SpringBoot”和“JavaWeb”这个组合说起来其实很自然。传统的JavaWeb项目需要配置Tomcat、写一堆web.xml、手动集成Spring和MyBatis、处理各种jar包版本冲突。等这些都整完光环境搭建就能耗掉一个新手三五天。SpringBoot把这些全收敛了内嵌Tomcat、自动配置、约定优于配置一个spring-boot-starter-web依赖就把Spring MVC那一套拉齐了。对奖学金申报评定这种典型CRUD加少量业务规则的中小型管理系统SpringBoot带来的收益非常直接我可以用一个可执行Jar包把服务跑起来本地开发和部署都不需要额外安装Tomcat。而且SpringBoot的生态成熟文档多遇到问题搜一下基本都有答案——这在毕设和实际项目里都特别重要。我的基础版本用的SpringBoot 2.7.x为什么不用3.x3.x虽然新但要求JDK17部分老版本的MyBatis-PLus、代码生成器兼容性会出问题。在“稳定跑通”这个目标下JDK8 SpringBoot2.7确实更稳。如果你不是毕设而是从零做新项目用3.x也完全可以但要注意配套依赖版本。2.2 前端用Thymeleaf还是前后端分离“JavaWeb”这个关键词容易让人联想到JSP但这个年代我强烈不建议新项目再上JSP。JSP的调试体验差前后端耦合严重和SpringBoot的自动化配置也有不少冲突点。我的选择是服务端渲染的Thymeleaf页面上用Layui和Bootstrap做布局和表格组件。这么选不是没有纠结过。前后端分离的方案我熟SpringBoot提供REST接口前端用Vue Element UI最后nginx部署。但具体到奖学金评定这个场景我需要考虑一个现实问题毕设或者中小型项目人力有限前后端分离意味着要维护两套代码、处理跨域、处理Token刷新复杂度翻倍。Thymeleaf的方式一个Controller既给数据又返回视图开发效率高不少页面效果也够用。如果以后想改成前后端分离Controller层的设计可以提前留好口子业务逻辑全部下沉到ServiceController只做参数接收和结果返回后面接Vue时把Controller改成RestController就能复用整个Service层。2.3 数据访问层我为什么选MyBatis-Plus而不是JPA奖学金申报系统有一个明显特点查询条件多、表关联多、动态SQL多。学生列表要按照学院、年级、成绩区间、申报状态组合筛选申报记录要连带查询学生姓名、班级、奖项名称。这种场景下MyBatis-Plus的优势就体现出来了内置BaseMapper单表CRUD零SQL。LambdaQueryWrapper可以写类型安全的条件构造不用拼字符串。分页插件做了物理分页和PageHelper相比不需要额外的分页方言配置。复杂多表查询时就写XML Mapper很直接。JPASpring Data JPA我也用过它在单表操作上确实优雅但涉及复杂查询时实体关系维护和多表JOIN会让人头疼。奖学金系统里经常出现“多表联查动态条件”用MyBatis-Plus我可以在XML里把SQL写成最直观的JOIN语句后期调优也方便。2.4 项目分层结构我最终的工程结构是这样照着这个结构写后续扩展不会乱com.example.scholarship ├── controller ├── service │ ├── impl ├── mapper ├── entity ├── dto ├── vo ├── config ├── common │ ├── exception │ ├── result │ ├── enums └── utilsController只负责参数接收和结果包装Service里写业务规则Mapper只做数据访问。DTO负责接口入参出参定义和数据库Entity分开。如果一个Controller超过100行我就开始考虑是不是有业务逻辑没下沉到Service。3. 先定状态机再建表申报评定系统的数据基石3.1 从申报到公示状态机是整个系统的主心骨拿到需求后我做的第一件事不是建表而是画状态流转。因为奖学金申报的核心不是“增删改查”而是一条申报记录从草稿到最终获奖需要经历哪些状态、每一步谁能执行什么动作。这个状态机定清楚了所有接口的设计就都有了依据。我定义的状态枚举如下状态编码状态含义谁可操作可流转到DRAFT草稿学生SUBMITTEDSUBMITTED已提交待班级初审辅导员CLASS_APPROVED / CLASS_REJECTEDCLASS_APPROVED班级初审通过学院评审员COLLEGE_APPROVED / COLLEGE_REJECTEDCOLLEGE_APPROVED学院推荐学生处经办人SCHOOL_APPROVED / SCHOOL_REJECTEDSCHOOL_APPROVED校级审定通过系统自动IN_PUBLICITYIN_PUBLICITY公示中管理员CONFIRMED / CANCELLEDCONFIRMED已确定获奖管理员ENDCLASS_REJECTED班级驳回学生DRAFT / CANCELLEDCOLLEGE_REJECTED学院驳回学生DRAFT / CANCELLEDSCHOOL_REJECTED校级驳回学生DRAFT / CANCELLED这个状态机有个关键设计被驳回的申请不是直接变成CANCELLED而是回到DRAFT允许学生修改后重新提交。因为人工填表总有笔误直接判“死刑”会让学生找老师线下改反而违背了系统初衷。重新提交时会保留原有的审核记录保证整个评定的可追溯性。3.2 核心表结构设计表设计我尽量控制在8张表之内既覆盖业务又不过度设计。核心表如下表名用途关键字段sys_user用户表id, username, password, real_name, role, college_idsys_college学院/班级组织表id, parent_id, name, levelscholarship_item奖项配置表id, name, year, quota, apply_start, apply_end, conditionsscholarship_application申报主表id, student_id, item_id, semester, status, total_score, apply_timematerial_file证明材料表id, application_id, file_name, file_path, file_type, upload_timereview_record审核记录表id, application_id, reviewer_id, action, opinion, create_timepublicity_record公示记录表id, item_id, publish_time, end_time, contentsys_config系统配置表id, config_key, config_value存权重、文件路径等申报主表是整个系统的核心我拆出来仔细说一下。student_id关联学生用户item_id关联申报的奖项semester代表学年学期status存的是上一节说的状态编码total_score存加权计算后的总分。一个学生同一学年同一奖项只能有一条有效记录这个约束我同时放到数据库层和应用层。数据库层用联合唯一索引ALTER TABLE scholarship_application ADD UNIQUE KEY uk_student_item_semester (student_id, item_id, semester);应用层再在提交前查一遍双重保险。只做应用层校验有个风险两个请求同时通过校验同时写库就产生了重复数据。唯一索引的存在就是为了兜底这种情况。3.3 审核记录采用追加式设计审核记录表我特意设计成“只追加、不修改”。每一条审核动作都插入一条新记录而不是在申报主表上更新“审核意见”字段。这样做的原因很实际奖学金评定是要被监督的如果审核意见被覆盖出了争议无法回溯。追加式审核记录天然形成一条完整的审批链哪天学生申诉“为什么我被驳回”拉出这张表的记录就能说清楚。审核动作的枚举值我定义为PASS、REJECT、PUBLISH、CONFIRM、CANCEL配合状态机使用。审核记录表里还冗余存了一份“审核后状态”和“审核前状态”这样即使未来状态机改了历史记录也完整可靠。3.4 数据库层面的几个重要细节索引规划上高频查询集中在scholarship_application的item_id、student_id、status三个字段。我加了组合索引(item_id, status)因为最常见的查询是“某个奖项下所有处于某状态的学生”。随着数据量上来这个索引收益非常明显。另外一个细节是金额字段用DECIMAL而不是DOUBLE。虽然这个系统里金额只是展示用但既然是奖学金系统钱相关的一律用精确数值类型。计算加权分时用BigDecimal这个坑我在后面还会细讲。4. 权限、申报、审核、公示四个核心模块的落地细节4.1 基于角色的权限控制与数据隔离权限控制我用的Spring Security做基础认证但没启用它那套复杂的授权表达式而是自定义了RequireRole注解配合拦截器实现接口级权限校验。原因很简单Spring Security自带的RBAC模型功能强大但对这种固定五角色的系统来说配置偏重自定义注解反而直观。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }Interceptor里解析当前用户的角色如果不在注解允许范围内直接返回403。权限之外更关键的是数据隔离。这个问题是后端系统的核心难点学生登录后只能看到自己的申请辅导员登录后只能看到自己本班学生的申请学院评审只能看到本学院。如果所有角色查的都是同一张全量数据表后面必然出乱子。我的做法是在查询Service里根据当前登录角色动态拼接数据权限条件。MyBatis-Plus的LambdaQueryWrapper帮了大忙public PageApplicationVO pageQuery(ApplicationQuery query, LoginUser user) { LambdaQueryWrapperScholarshipApplication wrapper new LambdaQueryWrapper(); if (user.isRole(STUDENT)) { wrapper.eq(ScholarshipApplication::getStudentId, user.getUserId()); } else if (user.isRole(COUNSELOR)) { ListLong classStudentIds getStudentIdsByClass(user.getCollegeId()); wrapper.in(ScholarshipApplication::getStudentId, classStudentIds); } else if (user.isRole(COLLEGE_REVIEWER)) { wrapper.apply(EXISTS (SELECT 1 FROM sys_user u WHERE u.college_id {0} AND u.id scholarship_application.student_id), user.getCollegeId()); } }这里用EXISTS子查询处理学院评审的数据隔离比IN在大数据量下性能更稳。辅导员因为和学生是直接的班级关系用IN查出本班学生ID列表反而更直观。4.2 申报提交事务边界和文件上传处理学生端申报提交接口是整个系统最繁忙的接口它要同时干几件事插入申报主表、插入证明材料记录、校验是否重复申报、把草稿状态改成已提交。这几步必须在一个事务里否则可能出现“申请表提交了但材料没存上”的脏数据。Transactional(rollbackFor Exception.class) public Long submit(ApplicationCreateDTO dto, LoginUser student) { // 1. 校验申报时间是否在开放期内 ScholarshipItem item scholarshipItemMapper.selectById(dto.getItemId()); if (!item.isInApplyWindow(LocalDateTime.now())) { throw new BusinessException(当前不在申报时间内); } // 2. 校验重复申报 Long count applicationMapper.selectCount(new LambdaQueryWrapperScholarshipApplication() .eq(ScholarshipApplication::getStudentId, student.getUserId()) .eq(ScholarshipApplication::getItemId, dto.getItemId()) .eq(ScholarshipApplication::getSemester, dto.getSemester())); if (count 0) { throw new BusinessException(本学年已申报该奖项请勿重复提交); } // 3. 插入申报主表 状态置为SUBMITTED // 4. 保存证明材料文件记录 }注意rollbackFor Exception.class这个属性。Spring的Transactional默认只在遇到RuntimeException时才回滚如果业务方法里抛的是自定义的CheckedException事务是不会回滚的。这里我自定义的BusinessException继承自RuntimeException确保任何业务异常都能触发回滚。文件上传这块我用的是本地磁盘存储生产环境一般会换OSS或者MinIO。上传时做了白名单校验private static final SetString ALLOWED_EXT Set.of(pdf, jpg, jpeg, png); public String uploadMaterial(MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext StringUtils.getFilenameExtension(originalFilename); if (ext null || !ALLOWED_EXT.contains(ext.toLowerCase())) { throw new BusinessException(不支持的文件类型); } if (file.getSize() 10 * 1024 * 1024) { throw new BusinessException(文件大小不能超过10MB); } // 检查MIME类型 Content-Type 必须以 image/ 或 application/pdf 开头 // 生成UUID文件名落盘 }扩展名校验是最基本的一关真正严谨的还要看文件的MIME类型。因为浏览器上传文件时伪造一个名为“xxx.pdf”的HTML文件是完全做得到的。只校验扩展名等于给XSS攻击留了个口子。4.3 成绩加权计算BigDecimal远比double可靠奖学金评定里有一个核心计算逻辑综合成绩 学习成绩 × 70% 综合测评成绩 × 20% 荣誉加分 × 10%。这个比例不是写死的管理员可以在系统配置表里动态调整。我第一次实现时图方便用的double结果在分数对比时出现了灵异现象86.1明明大于86.05程序却判断false。原因就是IEEE754浮点精度丢失——double在二进制表示下无法精确存储0.1这类小数。后来全部改成BigDecimalpublic BigDecimal calculateTotalScore(BigDecimal academicScore, BigDecimal comprehensiveScore, BigDecimal honorScore) { BigDecimal result academicScore.multiply(new BigDecimal(0.7)) .add(comprehensiveScore.multiply(new BigDecimal(0.2))) .add(honorScore.multiply(new BigDecimal(0.1))); return result.setScale(2, RoundingMode.HALF_UP); }BigDecimal有两个坑要提醒一是构造时优先用字符串构造器new BigDecimal(0.7)不要用new BigDecimal(0.7)后者本身就是不精确的二是运算时别忘了设置保留小数位数和舍入模式否则遇到除不尽的小数会抛ArithmeticException。4.4 审核流转状态迁移的服务抽象审核接口我有意做了一个状态迁移的公共方法而不是在每个审核动作里散落地去改状态。因为散落改状态的问题是后期维护噩梦今天这个审核接口忘了更新状态明天那个接口忘了写审核记录系统状态就会乱掉。private void doReview(Long applicationId, LoginUser reviewer, String action, String opinion, StatusEnum targetStatus) { ScholarshipApplication app getById(applicationId); // 校验当前操作者是否有权审核 checkReviewPermission(app, reviewer); // 校验状态是否合法只能从允许的前置状态迁移 if (!app.getStatus().canTransitTo(targetStatus)) { throw new BusinessException(当前状态不允许该操作); } app.setStatus(targetStatus); updateById(app); // 写入一条审核记录 reviewRecordService.record(app.getId(), reviewer.getId(), action, opinion); }这样才能保证一件事每一次状态变化都有对应的审核记录每一次状态变化都是显式、可控的。审核意见字段我用了一个500字以内的VARCHAR存储足够。5. 实测踩坑复盘文件上传、并发申报、报表导出5.1 全局过滤器处理上传PDF时的XSS拦截问题系统上线试运行阶段遇到一个很典型的坑我在全局加了一个XSS过滤器对所有请求参数做防XSS清洗结果上传PDF材料时直接报错。排查发现XSS过滤器默认对POST请求体的内容进行了字符替换把PDF二进制流当成了字符串处理导致文件数据被破坏。解决办法是在XSS过滤器的doFilter里对multipart/form-data类型的请求直接放行只清洗普通的表单参数和JSON参数。因为文件内容本身不应该被当作文本去处理这是一个很容易被忽视的坑if (contentType ! null contentType.contains(multipart/form-data)) { chain.doFilter(request, response); return; }这个方案的副作用是如果学生PDF文件名里带了script之类的字符不会被清洗但文件名本身我做的是UUID重命名所以这个风险也就被顺便消掉了。5.2 并发申报与唯一索引的真香时刻有一年申报截止当晚系统日志里出现了两条非常接近的重复申报记录。原因是学生在截止时间前连点了两次“提交”两个请求几乎同时到达应用层的重复校验都没拦住。当时查日志时倒吸了一口凉气——线上已经出现重复数据了。这再次证明了前面说的应用层校验永远只是优化体验数据库层的唯一约束才是最后一道防线。修复方案除了保留唯一索引之外我还把提交接口加上了防重处理在Redis里为“学生ID 奖项ID 学年”设置一个SETNX的key第一次提交成功才写入第二次提交直接提示“请勿重复操作”。5.3 导出名单Excel时的内存溢出导出获奖名单功能一开始用的Apache POI的XSSFWorkbook几百人的名单完全没问题。但有一次导全校的助学金名单接近3000条数据、每条还带好几个备注字段直接在导出时内存飙高JVM直接报OOM。原因很简单XSSFWorkbook是把整个Excel内容构建在内存里的。9000行、几十列的表格构建过程中会产生大量对象内存自然扛不住。这个问题的处理办法是换成EasyExcel它底层是流式读写的类似一边写磁盘一边生成文件内存占用和常量差不多EasyExcel.write(outputStream, AwardExcelVO.class) .sheet(获奖名单) .doWrite(dataList);这里的dataList可以是从数据库流式查询返回的Cursor结果集配合MyBatis的Select游标查询真正意义上做到了“查询一条写一行”。导出性能从原来的几十秒降到三秒以内内存占用稳定在100MB上下。5.4 公示期校验的一个小业务Bug公示期的判断还有一个看起来简单但容易写错的点公示开始当天的0点到公示结束当天的24点到底怎么算。最初我写的判断是now.isBefore(endTime)导致公示结束当天0点时就认为公示已经结束这类边界Bug在测试时不容易发现因为测试数据很少真的把时间卡在边界上。正确写法是公示结束时间用endTime.plusDays(1).atStartOfDay()保证结束当天全天仍算公示期内。这种时间边界问题在奖学金系统的任何时间窗口申报期、评审期、公示期都会出现建议写工具类统一处理。最后再分享两个提升体验的细节第一通知机制。审批流转发生后我接入了简单的站内信学生登录后能看到“你的申请已被班级驳回材料不清晰”这类消息。没有引入短信和邮件因为这是个内部系统站内信完全够了。第二按钮权限。前端页面根据角色隐藏了按钮比如学生看不到“审核通过”按钮。记住这只是用户体验的一部分真正的权限控制一定要在接口层面完成。前端隐藏只是为了不让用户看到无权限的操作入口不是为了防破解。这套系统从零搭到跑通大概花了两周其中第一周全在梳理业务流程和状态机真正写代码的时间反而不多。如果你也在做类似的申报评定系统我的建议是先把状态机画明白再动手建表写代码。业务模型清晰了代码只是把模型翻译成实现而已。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询