基于Spring Boot的高校竞赛信息管理系统开发实践
每年竞赛季高校里最忙的往往是两类人一类是负责竞赛组织的教务老师另一类是帮老师整理材料的学生助理。几十个竞赛项目、几百份报名表、一堆又一堆的Excel和PDF光是核对学生信息和报名时间就够折腾好几个通宵。我把这套基于Spring Boot的高校竞赛信息管理系统做完之后最大的感触不是哪个框架有多难而是信息管理这四个字背后其实藏着一整套被大多数毕设和Demo项目忽略掉的业务逻辑。这篇文章就把我从需求梳理、表结构设计、核心接口实现到最终部署踩过的坑完整写一遍给正在做同类系统的人一个能直接参考的蓝本。1. 竞赛信息管理到底在管什么需求梳理与功能拆解很多人拿到这个题目第一反应是做一个竞赛报名网站鼠标点几下就能报名管理员在后台看一眼名单。真做起来才发现竞赛管理远不止报名这一件事。1.1 一个反直觉的结论报名录入只是冰山一角我最初和某高校的老师聊需求时他对我说了一句特别实在的话我们缺的不是让学生填表的页面是能让院里几十个竞赛项目不再出错地跑完发布—报名—审核—比赛—评奖—归档这条链的东西。这句话改变了我的设计方向。竞赛信息管理系统表面上是面向学生的报名入口实际上的核心用户是三类人学生、指导老师/院系管理员、校级竞赛管理员。学生想看有哪些竞赛、什么时候报名、最后拿了什么奖老师要审核谁有资格参加、谁需要被调剂校级管理员要统一下发通知、掌握全校竞赛项目清单和获奖数据。这三类人的诉求叠加起来系统的功能边界就不是一个报名页面能覆盖的。1.2 三类用户与四条业务主线我最终把需求收敛成四条业务主线后面的所有设计都围绕这四条线展开竞赛全生命周期管理从竞赛信息录入、状态流转未发布、报名中、已截止、评审中、已结束到归档每一步都有对应的状态字段。学生报名与审批学生提交报名材料指导老师审核管理员确认名单整个过程要有留痕。成绩与获奖档案比赛结束后录入成绩、评定奖项并生成可导出的获奖名单、证书编号。通知与数据统计竞赛发布、截止、结果公布都要触发通知统计报表要能按学院、按年份、按竞赛级别汇总。确定这四条线之后系统的表结构设计就变得清晰了。因为每条主线都可以拆出一张或几张核心表而且彼此之间的关联关系是明确的。1.3 功能清单落定系统边界到底划在哪做毕设或课设最容易犯的毛病是什么都想加最后代码写了一万多行功能却没法串起来。我在这个项目里给自己定了一条边界不做在线考试、不做完整的OA审批流、不做聊天模块只做和竞赛信息强相关的功能。理由很简单竞赛管理系统最重要的指标是可维护性教务老师能自己上手发布竞赛、导出名单比系统里多十个炫酷功能更实际。最终的功能清单分为管理端和学生端两部分。管理端有竞赛信息维护、报名审核、成绩管理、获奖登记、公告发布、学生信息管理学生端有竞赛浏览、报名申请、我的报名记录、成绩查询。同时预留了指导老师的角色用来做院系维度的报名初审。2. 数据库设计六张核心表的建模思路与字段取舍数据库设计是整个系统的地基。地基歪了后面写什么都别扭。我的做法是先画出实体关系再一张张表去抠字段每做一个字段都想清楚它将来会被哪个查询用到。2.1 用户表与角色设计三种权限一次说清用户表是系统的入口表我用的字段包括id、username、password、real_name、role、student_no学号、college学院、major专业、grade年级、phone、email。这里有一个容易被忽略的点角色字段我建议直接用字符串ADMIN/TEACHER/STUDENT不要用0/1/2这种数字因为直接可读的枚举值在SQL排查和页面展示时都能少掉一层转换。密码一定要用加密算法存储Spring Security自带的BCryptPasswordEncoder就够用不要用MD5数据库泄露时MD5几乎是裸奔。学生信息为什么要单独存学号和学院因为后面统计某学院参赛人数各年级获奖占比时这些字段就是分组依据。如果报名表里没有冗余这些字段每次统计都要join用户表SQL会变得啰嗦而且在数据量大时性能会明显下降。2.2 竞赛信息表时间与状态字段的规范竞赛信息表(competition)的核心字段是id、name、level竞赛级别校级/省级/国家级、category竞赛类别、organizer主办方、description、registration_start、registration_end、competition_time、location、quota名额限制、status。这里最想提醒大家的是时间字段的统一。报名开始时间、报名截止时间、比赛时间这三个字段全部用datetime类型不要用varchar存字符串否则后面做当前时间是否在报名窗口内的判断时要反复做字符串转日期性能和正确性都吃亏。状态字段我建议用status存当前状态同时允许通过时间字段自动推导。比如系统启动时做一个定时任务把超过registration_end的竞赛状态从报名中改成已截止比赛结束且成绩已录入再由管理员手动置为已结束。为什么要自动加手动双保险因为有些竞赛会临时延期只靠自动推导会误改状态留手动入口才是真实的教务场景。2.3 报名表和成绩表数据的闭环设计报名表(signup)的字段id、competition_id、student_id、teacher_id、status、apply_reason、audit_remark、create_time、audit_time。status状态我设计了五个PENDING待审核、APPROVED已通过、REJECTED已驳回、CANCELLED已取消、WAITLIST候补。WAITLIST很关键因为竞赛都有名额限制前面的同学审核通过后因故退出候补名单能直接顶上避免重新组织报名。成绩表(score/award)的字段id、competition_id、student_id、signup_id、score、award_level、certificate_no。award_level用字典值存FIRST/SECOND/THIRD/EXCELLENCE一等奖/二等奖/三等奖/优秀奖并允许为NULL因为不是所有人都有奖。certificate_no是证书编号生成规则可以用年份竞赛ID序号保证唯一性。之所以要冗余signup_id关联是便于从报名记录反查整个参赛历程学生端我的获奖页面只需要查成绩表再关联竞赛表就能展示全部历史。2.4 字典表与参数表把可配置还给我另外两张辅助表在开发时容易被忽略但上线后非常有用。一张是字典表(dict)存竞赛级别、竞赛类别、奖项等级这些可枚举值的映射另一张是参数表(config)存系统级配置比如单场竞赛最多报名人数默认值证书编号前缀等。这样设计的直接好处是当学校新增一个竞赛类别时不需要改代码、不需要重新部署管理员在后台字典管理里加一条记录就行。对于没有专职开发人员维护的高校系统来说这种可配置性比什么都重要。3. 后端核心实现安全验证、报名状态机与成绩统计后端我用的是Spring Boot 2.x MyBatis-Plus MySQL这套组合在同类项目里最成熟遇到问题搜索时答案也多。下面挑三个容易做错的地方重点说。3.1 登录鉴权的落地方式登录鉴权我采用的是Sa-Token框架它比纯Shiro配置简单也比手写JWT拦截器省事。学生、老师、管理员三种角色共用一张用户表登录成功后把用户ID和角色写入会话前端发起请求时携带token后端通过注解做角色校验。实际开发里我补充了一个拦截器白名单放行登录接口和竞赛列表查询接口其他接口都必须验证token。前端路由也要配合比如管理端页面在未登录时直接重定向到登录页。这里有个小坑是前后端联调时容易前端觉得登录了、后端觉得没登录排查时先看前端请求头里是否带上了Authorization字段十有八九是请求拦截器配置漏了。3.2 报名接口如何防止重复报名和超时报错报名接口是整个系统里最需要谨慎处理的地方。我遇到过的真实问题有两个学生疯狂点报名按钮导致生成多条报名记录竞赛名额马上满时两个同学同时提交后端没做限制导致超员。第一个问题靠数据库唯一索引解决在signup表上加unique key(competition_id, student_id)从数据库层面保证一个竞赛一个学生只能有一条报名记录。第二个问题需要加事务和行锁核心代码如下Transactional(rollbackFor Exception.class) public synchronized Result signUp(SignupRequest req) { Competition competition competitionMapper.selectById(req.getCompetitionId()); // 1. 校验竞赛状态 if (!REGISTERING.equals(competition.getStatus())) { return Result.error(当前不在报名时间内); } // 2. 校验名额 Long count signupMapper.selectCount( new LambdaQueryWrapperSignup() .eq(Signup::getCompetitionId, req.getCompetitionId()) .in(Signup::getStatus, APPROVED, PENDING)); if (count competition.getQuota()) { return Result.error(报名人数已满); } // 3. 插入报名记录 Signup signup new Signup(); signup.setCompetitionId(req.getCompetitionId()); signup.setStudentId(req.getStudentId()); signup.setStatus(PENDING); signupMapper.insert(signup); return Result.success(); }这里有个经验之谈如果同时用synchronized加事务注解要注意事务的提交时机。synchronized锁住的是当前方法但事务提交发生在方法返回之后极端情况下第二个线程进入方法时第一个事务还没提交仍会读到旧的报名数量。更稳妥的做法是把校验和插入放到一个独立的事务方法里或者直接依赖数据库的唯一索引兜底。在单人并发量不高的管理系统中synchronized 唯一索引已经够用不必过度设计成分布式锁。3.3 成绩录入与获奖排名计算的两种模式成绩录入我做了两种模式一种是管理员在后台逐条录入另一种是Excel批量导入。批量导入用EasyExcel导入时先做模板校验包括学号是否存在、分数是否在0到100之间、竞赛是否存在全部通过才允许入库。获奖排名计算的逻辑说起来简单按分数从高到低排序然后按比例切一等奖、二等奖、三等奖。但实际中会遇到并列分数的问题比如第10名和第11名同分而一等奖名额恰好只有10个。我的处理方式是使用MySQL的窗口函数RANK()先按竞赛分组排序再根据排名区间判定奖项等级。这样并列名次会占用同一排名后面的人顺延比较符合真实评奖规则。SELECT student_id, score, RANK() OVER (PARTITION BY competition_id ORDER BY score DESC) AS rank_no FROM score WHERE competition_id #{competitionId}拿到排名数据后再根据一等奖取前5%、二等奖取前15%、三等奖取前30%的配置算出每个名次对应的奖项等级回填到成绩表。整个过程我用定时任务收尾比赛结束后自动计算并生成公示名单管理员确认后才对外发布。4. 前端页面与交互设计管理端和学生端的关键界面系统的界面部分学生端我用的是Vue Element UI管理端同样用Vue通过路由懒加载拆成两个不同的布局。之前很多人喜欢一套代码同时兼容学生和管理员实际开发下来发现复杂度更高不如干脆分成两个入口。4.1 学生端从找竞赛到看结果的完整体验学生端首页是竞赛列表按报名状态分Tab展示报名中、即将开始、已结束。每张竞赛卡片展示竞赛名称、级别、报名截止时间这几个最关键的信息点击卡片进入详情页详情页里有完整的竞赛介绍和报名按钮。报名按钮的交互要注意状态细化。系统至少要有四种按钮状态可报名、已报名待审核、报名人数已满、报名已截止。这四种状态下按钮颜色和文案必须不同而且要禁用不可点击否则学生反复点提交就会触发前面说的重复报名的坑。我的竞赛页面展示的是学生所有报名记录每一条要能看当前审核进度是通过了、被驳回了还是进了候补名单。被驳回时旁边要显示老师的审核意见这是我在试运行后被老师专门要求加的因为学生被驳回后往往不知道为什么只能到处问人有了意见之后沟通成本直线下降。4.2 管理端审批流程与数据可视化的必要性管理端的第一屏是数据概览我用ECharts做了四个核心指标本月竞赛数量、报名总人次、审核通过率、各学院参赛人数排行。为什么第一屏放统计图表因为管理者打开系统最想看到的是现在情况怎么样而不是先点进某个菜单。这也是我在回访中得到的真实反馈没有可视化的管理端会被当作只是个录入后台。报名审核页面是管理端使用频率最高的功能。列表要支持按竞赛、按状态、按学院三个维度筛选每条记录右侧有通过、驳回两个操作按钮点击驳回弹窗时强制要求填写原因。这里有一个体验优化审核时可以勾选连续审核系统自动加载下一条待审核记录这个功能被老师反复夸过因为它省掉了大量的重复点击。竞赛发布表单里有一个细节时间选择器要禁用已过去的日期报名截止时间必须晚于报名开始时间。这些校验在前端做一层后端接口也要做一层双保险防止有人绕过前端直接调接口。4.3 接口对不齐是前端联调最常见的坑前后端分离开发时最容易出现的坑就是接口路径和字段名对不上。所以我在项目初期就先写了接口文档把每个接口的请求参数、返回结构约定清楚。前后端都按这个文档开发联调时出问题的概率大幅降低。我自己遇到的典型问题有两个一个是日期格式后端返回的是2024-05-01T10:00:00前端组件要求的是2024-05-01 10:00:00不一致导致页面上显示一片空白另一个是分页参数后端接口从0开始前端组件从1开始第一次查询总是少一页数据。这些问题提前在文档里写清楚联调能快很多。5. 开发环境、调试部署与论文写作配套经验系统开发完成不等于项目结束部署上线和配套论文是毕设或课设里绕不开的两件事。这一部分是我自己折腾了很多次之后总结出的经验可以直接照抄。5.1 开发环境版本匹配不要跟风用最新版这个系统的开发环境我建议固定为JDK 1.8、Maven 3.6、MySQL 5.7或8.0、Spring Boot 2.7.x。别用Spring Boot 3.x和JDK 17的组合虽然新但很多第三方依赖和网上的教程都还停留在2.x时代遇到问题时能参考的资料少得多。application.yml里的关键配置我贴出来照着改就能跑server: port: 8080 servlet: context-path: /competition spring: datasource: url: jdbc:mysql://localhost:3306/competition_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0注意serverTimezoneAsia/Shanghai这一项MySQL 8.0默认时区是UTC不加这个参数插入数据库的时间会比本地时间早8个小时查出来数据全不对。这个问题我排查了整整一个下午才找到谁都想不到是时区在捣鬼。5.2 部署中遇到的三类问题与排查路径我把部署阶段踩过的坑总结成三类几乎每个做同类系统的人都会遇到。第一类是数据库连接失败。现象是项目启动报Access denied for user或Communications link failure。前者是账号密码或权限问题检查MySQL里的用户授权后者多半是IP和端口配置错误注意MySQL默认端口是3306如果本机装了多个MySQL实例端口可能被占用。第二类是端口冲突。Spring Boot默认8080如果本机有别的服务占用启动时会报Port already in use。Windows下用netstat -ano | findstr 8080找到占用进程的PID然后任务管理器结束它或者直接改配置文件里的端口。第三类是中文乱码。乱码出现的位置可能是页面展示、Excel导出、数据库存储。排查思路按这三层走数据库连接URL有没有加characterEncodingutf8配置文件里必须有数据库表本身的字符集是不是utf8mb4建表语句最后要加DEFAULT CHARSETutf8mb4前端页面有没有设置meta charsetUTF-8。三层都对了乱码基本消失。5.3 带论文文档的系统的常见问题很多人在这个项目上会配套写论文通常要凑够一万字以上。我的体会是论文最核心的不是字数而是技术实现章节能不能讲清楚数据流。建议按这个结构组织题目确定后先写需求分析把用例图和每条业务主线的流程描述放进去然后是系统设计包括架构图、功能模块图、数据库ER图和核心表结构再是系统实现选取登录、报名、成绩导出等3到4个关键功能贴核心代码并解释为什么这么写最后是系统测试列出测试用例表和测试结果包括功能测试、兼容性测试和简单的性能测试。这样写下来字数自然就够一万字了而且评审老师很容易看到工作量。系统界面截图我建议在论文附录里放一整套从学生端首页、报名详情、管理端审批页到最终的成绩公示每张图配一句话说明。这个前后对照可以减轻答辩时只看代码不知道页面长什么样的压力。最后再分享一个我在实际调优里觉得很有意思的细节报名截止时间的前一小时系统会自动给还没报名的学生发提醒通知。这个功能代码量不大但它是整个系统里被提起次数最多的功能之一。从业务价值来看它的作用比排行榜和统计图表都更突出——竞赛信息管理的本质是让对的人在对的时间知道对的事情。你如果时间充裕建议也把这个人性化的小功能加进自己的设计里它在答辩时很能说明你对业务场景的理解。