每年三月份开始,高校的创新创业学院基本就进入了“竞赛季”。挑战杯、互联网+、数学建模、电子设计大赛,加上各院系自己组织的专业竞赛,几十个赛事几乎同时启动,而管理这些赛事的方式还停留在Excel时代——报名表在QQ群里传来传去,评审打分靠纸质表格汇总,最后统计奖项时教务老师对着几个G的文件翻到半夜。这种状态持续久了,总有人要动手改变。
我做的这套高校竞赛管理系统,就是用Java + SpringBoot + SSM这套技术栈,把竞赛从发布、报名、审核、评审到奖项管理、证书导出全流程搬到了线上。这篇文章不打算做成那种“系统包括XX模块,实现了XX功能”的泛泛介绍,而是从一个实际开发者的角度,把整个项目的技术选型、数据库设计、核心功能实现、权限控制、部署踩坑这几个关键环节讲透。写给自己记录,也写给正在做Java课设、毕业设计或者想拿竞赛管理系统练手的同学参考。
1. 需求分析:竞赛管理到底需要解决哪些实际问题
很多同学拿到这类题目,第一反应就是列功能清单:管理员模块、教师模块、学生模块,然后每个模块塞一堆增删改查。这样做出来的系统,演示的时候看起来功能齐全,实际用起来却处处别扭。真正搞过竞赛管理的人会告诉你,这个场景里最痛的点其实很集中。
1.1 竞赛组织者最头疼的三件事
整个竞赛流程里,最耗费人力的三个环节分别是:报名信息收集与审核、评审材料汇总与打分、获奖数据统计与证书生成。
报名环节的问题在于渠道分散。同一个竞赛,有的院系用问卷收集,有的院系用Excel上报,还有的直接让学生发邮件。信息格式五花八门,学号写错的、手机号少一位的、指导教师名字打错的,全靠人肉校对。我发现实际项目中,报名信息校验规则往往是代码之外最容易返工的地方。
评审环节的问题是打分不透明。传统模式下评委拿到的可能是一堆纸质材料,打分表靠人工汇总,一旦出现分数统计错误,复核起来非常麻烦。而且竞赛通常有初赛、复赛两个阶段,每个阶段的材料提交状态、评审状态、分数录入状态都需要跟踪。
奖项管理的问题就更现实了——获奖名单定了之后,要给获奖学生导证书、按院系统计获奖数据、为后续的保研加分和奖学金评定提供数据支撑。这些工作如果全部靠手工操作,竞赛规模一上来,整个流程就会卡死。
1.2 系统边界与角色梳理
梳理完痛点,系统的角色和边界就很清晰了。我最终确定三个核心角色:
学生端负责浏览竞赛信息、在线报名、上传作品材料、查看报名状态和获奖结果。教师端分为两类职责,一类是作为指导教师,确认学生报名中的指导教师信息;另一类是作为评委,对待评审的竞赛进行打分。管理员端则统筹全部流程,包括竞赛发布、报名审核、评委分配、奖项设定、数据导出。
需要提醒的是,很多课设项目会把管理员功能做得极其臃肿,恨不得把所有权限都塞给管理员。我的建议是:管理员只做配置和审核,不应该承担录入具体业务数据的工作。比如评委打分就是评委的职责,管理员顶多看统计结果,如果管理员又能发文又能打分又能定奖项,系统就没有存在的必要了。
1.3 明确非功能性需求
除了功能需求,这套系统还有两个容易被忽略的非功能约束。一个是并发报名的场景:热门竞赛开放报名后,头几个小时可能涌入几百个请求,如果后端不做任何缓存或限流,数据库连接池很容易被打满。另一个是文件存储:作品材料通常包含PDF、压缩包,单个文件可能几十MB,系统的上传大小限制、存储路径规划、文件命名策略都要提前设计好。
我把这些需求整理清楚之后,才开始了技术选型和数据库设计。需求阶段多花两天时间,后面写代码能省两星期。
2. 技术选型与项目骨架:SpringBoot + SSM这套组合的内在逻辑
项目标题里Java、SpringBoot、SSM这三个词并列出现,很多新手会疑惑:SpringBoot和SSM到底什么关系?这两个不是互斥的方案吗?
2.1 一句话理清SpringBoot与SSM的关系
SSM这个词是Spring + SpringMVC + MyBatis三个框架的统称,在很多大学课程和教材里,它是“SSH(Struts + Spring + Hibernate)”之后的主流JavaWeb组合。但SpringBoot的定位完全不同——SpringBoot不是替换掉Spring或SpringMVC,而是对Spring生态做了自动配置和简化封装,让开发者不需要写大量的XML配置就能启动一个Spring应用。
所以一个基于SpringBoot的项目,核心依赖里其实天然包含Spring和SpringMVC,你只要再引入MyBatis或者MyBatis-Plus的starter,这个项目本质上就是SpringBoot版的SSM。很多课设题目里的“SpringBoot+SSM”,实际落地时都是这种形态:SpringBoot做项目骨架,SpringMVC做请求分发,MyBatis做数据库映射。
我看过不少同学在这个地方犯了轴,非要强行把Spring的XML配置搬到SpringBoot项目里,结果折腾半天。正确做法是拥抱SpringBoot的starter机制和自动配置,把精力放在业务逻辑上。
2.2 为什么不用Spring Cloud或前后端分离架构
也常有同学问我:现在不都流行微服务吗?你为什么不用Spring Cloud?竞赛管理系统这种规模,硬上微服务绝对是灾难。微服务解决的是多团队协作、独立部署、弹性伸缩的问题,而高校竞赛管理这种业务,单体应用完全能撑住,用一个SpringBoot应用打包成JAR直接部署反而最省心。
前端方面我也选择了服务端渲染为主的方案,用Thymeleaf模板引擎做页面。原因很简单:这套系统的使用场景是校园内网环境,参与者是学生和老师,界面清晰易用比炫酷交互重要得多。前后端完全分离意味着要单独维护Vue项目、处理跨域、设计API文档,课设答辩时的演示复杂度也更高。如果你确实想做前后端分离,我建议后端提供RESTful接口,前端用Vue3 + Element Plus,但要做好工作量加倍的准备。
2.3 项目目录结构设计
项目骨架我按分层架构来组织,com.example.contest应该分包清晰,避免所有代码堆在几个类里:
com.example.contest ├── controller(接口层) │ ├── admin(后台管理端接口) │ ├── teacher(教师端接口) │ ├── student(学生端接口) │ └── common(公共接口) ├── service(业务逻辑层) │ ├── impl │ └── 各业务接口 ├── mapper(数据访问层) ├── entity(实体类) ├── dto(数据传输对象) ├── vo(视图对象) ├── config(配置类,含拦截器、文件上传等) ├── utils(工具类) └── exception(统一异常处理)实体类、DTO、VO为什么要分开?我见过很多项目直接用实体类接收前端参数,又把实体类直接返回给前端。这样做的后果是:一旦数据库表结构调整,接口返回格式就跟着变,前端跟着遭殃。我的做法是Controller层只接触DTO和VO,Entity只在Mapper层使用,层与层之间通过对象转换工具(比如MapStruct或BeanUtils)做映射。虽然代码量多一点,但后期维护舒服得多。
3. 数据库设计:核心表结构剖析与关键设计决策
竞赛管理系统的业务逻辑不算特别复杂,但表与表之间的关联关系需要花心思设计。我在数据库设计阶段画了完整的ER图,这里讲几个最核心的表和几个关键设计决策。
3.1 核心表结构
用户表(sys_user)是所有角色的基础,字段包含user_id、username、password、real_name、role(枚举:0学生/1教师/2管理员)、school、college、major、class_name、phone、email。
竞赛表(competition)记录竞赛基本信息:comp_id、comp_name、comp_type(竞赛类别)、organizer(主办方)、start_time、end_time、register_start_time、register_end_time、description、status(托管给状态流转模块)、cover_url。
报名表(enrollment)是整个系统的核心关联表,字段包括enroll_id、comp_id、user_id、teacher_id(指导教师)、team_name(团队项目必填)、work_title(作品名称)、work_desc、work_file_url、status。我特别在报名表冗余了comp_id和user_id,这是为了之后做列表查询时避免大量的多表join。
评审表(review_record):review_id、comp_id、enroll_id、reviewer_id(评委用户ID)、score、comment、review_time。注意这里没有直接用评委姓名,而是存reviewer_id回用户表查,避免评委改名或信息变更导致的历史数据错乱。
奖项表(award):award_id、comp_id、enroll_id、award_level(一等奖/二等奖/三等奖/优秀奖)、award_type(个人奖/团队奖)、verified_status(公示状态)。
3.2 为什么报名表要单独设一个指导教师字段
竞赛项目的指导教师在后续证书打印、加分审核里都是关键信息。有的系统把指导教师做成用户表的关联,让教师去认领学生,这在大型跨校竞赛里很复杂。我这里选择在报名表里允许学生填写指导教师姓名和工号,由教师在教师端确认关联,这样既保证了数据完整性,又不至于把报名流程搞得过于复杂。
3.3 状态字段设计的一个教训
初版设计里,我用一个state字段表示报名状态:0待审核、1通过、2拒绝、3已取消。后来发现不够用——竞赛的状态和报名的状态混在一起,查询逻辑变得非常别扭。第二次设计时我把状态拆开:竞赛有竞赛的状态(草稿/报名中/评审中/已结束),报名有报名的状态(待审核/已通过/已拒绝/已撤回),评审有评审的状态(待评审/已评分/已公示)。三个状态互相独立,各管各的流转,代码链路清晰很多。
3.4 数据库索引与性能考量
竞赛报名场景下,最频繁的查询是按竞赛ID查报名列表、按用户ID查我报名的竞赛。所以对enrollment表的comp_id、user_id分别建了复合索引。另外在搜索竞赛列表时,经常按竞赛名称模糊查询,我给competition表的comp_name加了索引,实际测试中数据量到几万条后,查询性能依然稳定。不过模糊查询最好不要用前导通配符的写法,否则索引会失效。
4. 核心功能模块实现:从发布竞赛到导出证书的完整链路
这部分是整个系统的重头戏,我按业务流程逐个模块讲实现思路,里面穿插一些关键代码。
4.1 竞赛发布与状态流转
管理员发布竞赛时,需要配置报名起止时间、竞赛起止时间、参赛限制条件(比如是否允许跨校组队、人数上限)。这里的核心难点是状态自动流转,我采用定时任务加状态检查双保险:
报名截止后,定时任务每天凌晨扫描一次competition表,把已经过了register_end_time但状态还是“报名中”的竞赛自动置为“评审中”。这样即使管理员忘了手动关闭报名,系统也能保持状态一致性。定时任务用Spring的@Scheduled注解实现,一分钟内搞定。
4.2 报名与院系审核流程
学生报名逻辑相对简单,插入enrollment记录,同时校验是否重复报名、是否在报名时间内。校验逻辑不能只靠前端表单,后端必须做同样的拦截,防止有人绕过前端直接调接口刷数据。
院系审核环节我设计成二级审核:先由院系管理员审核参赛资格,再由竞赛主办方管理员审核作品材料。这样做的好处是避免一个老师面对几百条报名记录逐一核对学籍信息,院系审核能把一大部分问题前置消化掉。审核操作走同一个接口,通过type参数区分级别,代码里用策略模式处理不同审核类型的业务规则差异。
这里贴一下核心的报名校验代码片段(简化版):
public void enroll(EnrollDTO dto) { // 1. 竞赛是否存在且状态为报名中 Competition comp = competitionMapper.selectById(dto.getCompId()); if (comp == null || comp.getStatus() != CompetitionStatus.REGISTERING) { throw new BizException("竞赛不存在或不在报名时间内"); } // 2. 是否重复报名 int count = enrollmentMapper.countByUserAndComp(dto.getUserId(), dto.getCompId()); if (count > 0) { throw new BizException("请勿重复报名"); } // 3. 校验参赛人数上限和组队信息 // 4. 入库 enrollmentMapper.insert(convertToEntity(dto)); }4.3 评审打分模块的权限控制
评审环节有个容易踩坑的地方:评委只能看到分配给自己评审的报名作品,不能看到全部参赛者名单,否则容易出现人情分和评审公正性问题。
我的设计是review_assign表,专门存评委和报名记录的分配关系。管理员在分配评委时,按“同一竞赛下随机分配”或“按院系回避”两种策略生成分配记录。评委登录后,只能查到自己名下待评审的记录,打分接口提交时会再次校验这条enrollId是否确实分配给了当前评委。双重校验的目的是防越权,这一点在后端必须坚持,不能因为加了菜单控制就认为安全了。
4.4 奖项管理与数据导出
奖项管理包括初评结果导入、终评结果录入、公示与发布。为了兼容线下评审的场景,我做了Excel模板导入功能:管理员下载模板,在线下打分表里填完分数后上传,系统自动解析Excel并将成绩写入review_record。
最终获奖名单确定后,按学院汇总获奖数据、导出证书附件。这里我用POI操作Excel和Word模板。证书生成的核心逻辑是基于Word模板写入替换变量,导出的获奖名单Excel支持多级表头和按字段排序。代码实现不值得大书特书,但有个细节值得分享:证书生成是IO密集型操作并发量高时很容易卡顿,我直接加了CompletableFuture异步处理,前端显示“生成中”状态,完成后提示下载。
5. 登录鉴权与权限控制:三种角色的后端防线设计
课程设计答辩时评委老师最爱问的问题就是“你的系统如何防止学生访问管理员接口”,这部分如果答不好,整个项目的技术评分都会被拉低。我的处理思路分三层。
5.1 登录态与Token方案
考虑到校园网环境和管理成本,我没有引入JWT,而是采用Redis存储登录态的方案。学生或教师登录成功后,后端生成一个UUID作为token,存到Redis并设置过期时间,同时把token返回给前端,前端请求时放入Header。后续每次请求都由拦截器校验token是否有效,并取出当前用户信息放入ThreadLocal。
方案选型的原因:JWT的优势是无状态、适合分布式环境,但竞赛管理系统就部署在一台服务器上,Redis存登录态反而更容易控制会话失效和强制下线操作。管理员在后台封禁某个违规用户时,直接删掉对应的Redis key就能立即生效,JWT就没这么方便。
5.2 拦截器的双关卡设计
我在SpringBoot里注册了HandlerInterceptor做登录拦截和权限拦截。登录拦截校验token是否存在,权限拦截则校验当前用户角色是否允许访问该接口。为了保证代码不散落各处,我给每个需要权限的接口定义了角色字符串注解,凭注解判断。
@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 放行登录接口和静态资源 if (handler instanceof HandlerMethod) { HandlerMethod hm = (HandlerMethod) handler; RequireRole requireRole = hm.getMethodAnnotation(RequireRole.class); if (requireRole != null) { // 从Redis取当前登录用户 UserAuth auth = getCurrentUser(request); if (auth == null) { throw new UnauthorizedException(); } if (!Arrays.asList(requireRole.value()).contains(auth.getRole())) { throw new ForbiddenException(); } } } return true; } }接口上只需要加一行注解:
@RequireRole({Role.ADMIN}) public void publishCompetition(...) { ... }5.3 数据级权限与防越权操作
角色级权限拦住了接口,但同一角色内的数据隔离还要做。比如学生只能修改自己的报名记录,不能把别人的报名信息改了。这个问题在Controller里我统一通过从ThreadLocal获取当前登录用户,再以其user_id作为数据操作条件实现。需要特别小心的是更新操作,避免用前端传过来的userId覆盖后端从Session拿到的userId,一旦被篡改,越权漏洞就出现了。
密码存储方面,明晃晃存MD5的课设代码太多了。我直接用BCrypt加密,它自带随机盐,相同的密码每次加密结果也不同,爆破成本高很多。这段代码不复杂,Spring Security的crypto包里就有BCryptPasswordEncoder,单独引进来用就行,不需要引入整套Security。
6. 开发联调中碰到的实际问题与排查思路
这部分的价值在于帮你少走弯路。我在开发过程中遇到的一堆问题里,挑几个最具代表性的说说。
6.1 数据库连接的时区与SSL问题
首次启动项目连数据库就报错了,提示Communications link failure。检查半天发现是MySQL连接串的问题,我的MySQL是8.0,默认开启了SSL和时区校验。解决方法是在jdbc连接串里加上useSSL=false和serverTimezone=Asia/Shanghai。
6.2 文件上传大小限制引起的血泪教训
学生上传作品压缩包时,后端接口始终返回空指针或异常。我翻遍代码也没找到逻辑问题,最后发现是SpringBoot内嵌Tomcat默认上传大小限制只有1MB。在application.yml里加了配置才解决:
spring: servlet: multipart: max-file-size: 100MB max-request-size: 200MB这点很容易被忽略,因为本地测试传小文件看不出问题,现场演示传个大压缩包就当场翻车。另外,上传路径也要规划好,我建议配置文件上传目录为外部绝对路径,并把该目录映射为静态资源目录,避免项目重新打包后上传文件丢失。
6.3 MyBatis的驼峰映射与分页问题
使用MyBatis时,如果数据库字段是下划线命名(比如register_end_time),而实体类是驼峰命名(registerEndTime),默认映射对不上。我在配置里开启了mapUnderscoreToCamelCase=true,这样MyBatis自动完成下划线到驼峰的映射。
分页方面,我直接用了PageHelper插件,一个PageHelper.startPage()就搞定,但要注意它只能在紧接着的查询语句前生效,如果中间插了其他SQL,分页就会串页。这个坑网上讨论很多,实际用的时候确实能碰到。
6.4 初版代码中暴露的事务管理问题
报名入库这个操作,我起初没加@Transactional,结果在“插入报名记录+更新竞赛当前报名人数”两个操作同时执行时,如果第二个操作抛出异常,第一条报名记录就提交进去了,数据就脏了。后来我给所有涉及多表写入的Service方法都加了@Transactional注解。要注意的是@Transactional默认只对RuntimeException回滚,如果方法内部catch了异常并吞掉,事务是不会回滚的。
实践中,一个好的思路是让事务边界保持在Service层,Controller只做参数接收和结果返回,千万别在Controller里直接操作多个Mapper——那会让事务管理完全失控。
6.5 部署方式与上线后的缓存清理
项目打包成JAR通过nohup部署,端口选了8081,配合Nginx做反向代理。上线后发现修改了竞赛公告但前端页面没有变化,排查后是浏览器缓存了旧的静态资源。我在Nginx配置了对静态资源的缓存过期时间,并给静态资源URL加上了版本号参数,每次发版时更新版本号强制刷新。
另外,管理员在后台调整系统参数后,学生端如果用的是Redis缓存,需要注意及时同步或设置合理的缓存过期时间。我在这部分选择了“写操作后主动失效Redis缓存”的方式,代码里不复杂,但能避免大量用户看到过期数据。
7. 验收清单与可扩展方向
项目进入收尾阶段,我整理了一份自测清单,这份清单后来也被我用作答辩演示步骤。首先是三个角色分别走一遍完整流程:学生报名某竞赛、教师审核并评审打分、管理员发布竞赛和获奖公示。然后是边界场景测试:竞赛报名截止后是否还能提交报名、重复报名是否被拦截、评委是否能查看非分配记录的评分页面。
线上评审这种形态,其实还有一个“匿名评审”的优化空间。竞赛公平性是高校竞赛的生命线,如果后续迭代,我会把它做成可选模式:评委端隐藏参赛者姓名和学校,只显示作品编号和文档内容。技术上改动量不大,但公信力提升非常明显。
另外一个值得扩展的方向是数据可视化看板。管理员首页可以展示各类竞赛的报名趋势、院系参与排行、获奖学生分布热力图,这些数据从现有表结构里都能直接聚合,只是需要加几个统计接口和图表页面。
钛actual项目交付后,运行了一个完整的竞赛季,几十场竞赛的报名、评审、获奖公示流程都在系统里跑通了。做这种管理系统最大的感触是:代码本身的难度从来不是瓶颈,真正花时间的反而是在需求理解的深度和细节处理的周到程度上。如果你也想做类似的项目,我建议先把业务流程图从头到尾画一遍,再把每个环节的异常情况列一遍,然后再动笔写代码。这个顺序对了,进度反而最快。