独立做一个高校竞赛赛事管理系统的初衷,是帮学院教务科把一堆Excel报名表换成一套能真正跑起来的系统。当时全校学科竞赛管理完全靠微信群和表格,每年报名季,教务科光是合并各学院发来的报名汇总就要花两三天,评审阶段的材料分发更是靠U盘拷贝。这套springboot高校竞赛赛事管理系统(源码包版本23762)就是冲着这个场景去的:从赛事发布、学生报名、学院审核、作品提交,到专家盲评、成绩公示、统计报表,整条链路一个后台全部走完。如果你正在做毕设、课设,或者所在院系也想把竞赛管理流程线上化,这套系统的需求梳理、表结构设计、代码落地和部署踩坑过程都值得花几分钟看看。
1. 高校竞赛管理到底乱在哪:先梳理需求再做系统
很多刚接触这个题目的人,第一反应是把竞赛管理系统做成一个"报名表管理工具"——学生填信息、管理员导出Excel。真做起来才会发现,高校竞赛管理的业务复杂度比想象中高得多。教务处要的不只是"线上填表",而是能覆盖多角色协作、多流程状态、跨学院统计的闭环系统。
1.1 六类角色和两类核心流程
系统至少涉及六类用户:学生、指导教师、学院管理员、校级管理员(教务处)、评审专家、系统管理员。校级管理员负责发布竞赛、配置奖项和评分维度;学院管理员审核本院学生的报名资格;学生既可以个人报名,也可以组建团队参赛;指导教师需要确认学生提交的团队信息;评审专家在盲评阶段按维度打分。
业务流程表面上看是一根直线——发布赛事、报名、评审、公示——实际跑起来却有大量并行和回退。比如报名截止后,教务处常需要手动补录个别漏报的学生;评审阶段发现作品格式不合规,要退回去让学生重新提交;团队报名时一个学生跨学院组队,统计成果归属时又要重新计算。这些异常分支必须在设计阶段就想清楚,否则代码写到后半段必然返工。
1.2 统计口径是隐藏需求
教务科最头疼的往往不是报名,而是年底的学科竞赛成果统计:这个学院报了多少项、获得几个国家级奖项、哪些学生参与过哪些赛事,都要能按学院、按赛事、按学期拉出来。这要求设计时不能只做业务表,还要兼顾统计维度。
我在设计里给竞赛表增加了category(竞赛分类)和semester(所属学期)两个字段,报名表冗余了college_id(学生所属学院)。为什么要冗余?因为跨学院组队的时候,一个队伍可能涉及三个学院的学生,如果统计时每次都要通过学生表反查学院,SQL会写得非常痛苦。冗余存储带来的是统计SQL从复杂join变成简单where,对后期报表模块帮助很大。
1.3 流程状态不能只用一个字段
竞赛生命周期大致是:草稿 → 报名中 → 作品提交 → 评审中 → 成绩公示 → 已结束。看起来简单,但真正容易乱的是作品提交和报名各自有独立的时间窗口,很多系统只用status一个字段描述状态,导致判断"当前能不能报名"和"当前能不能传作品"时要写一堆分支逻辑。
我的做法是拆成两个字段:signup_status管报名审核状态,competition_status管赛事整体阶段。前者是业务状态,表示某条报名记录走到哪一步;后者是流程状态,表示整场赛事当前处于哪个阶段。两者独立推进,代码逻辑清晰很多,后台列表页的状态筛选也更直观。
2. 技术选型路线:为什么最终落在Spring Boot + MyBatis-Plus这套组合
项目名既然叫springboot高校竞赛赛事管理系统,技术栈的主干基本是确定的。但版本、持久层、权限框架、中间件具体怎么选,还是有不少值得推敲的地方。
2.1 Spring Boot用2.7.x而不是3.x
我最终选的是Spring Boot 2.7.x,JDK用8或11。原因很现实:高校机房和绝大多数教学环境,JDK版本不会太高;Spring Boot 3.x强制要求JDK 17,很多常用依赖(比如部分老版本POI、EasyExcel、MyBatis-Plus插件)在3.x下兼容性不如2.x稳定。另外2.x的资料量最大,遇到问题几乎都能搜到现成答案,这对毕设和真实部署都非常关键——稳定压倒一切。项目如果是要上生产环境当毕设展示,2.7是性价比最高的选择。
2.2 MyBatis-Plus和JPA之间我选了前者
ORM层我用了MyBatis-Plus,核心原因是它能在不牺牲SQL控制力的前提下大幅减少样板代码。竞赛管理系统的常规增删改查非常多,BaseMapper自带的insert/update/selectById直接复用,不必每张表手写接口和XML;而报名唯一性校验、评审分数聚合、统计报表这些场景又需要手写SQL,MyBatis-Plus的Wrapper机制和自定义XML刚好都能覆盖。JPA在简单CRUD上也很舒服,但到了复杂统计查询,要么写JPQL要么用原生SQL配合Entity映射,反而绕。分页更不用说,MyBatis-Plus有现成的PaginationInnerInterceptor,后端列表页直接.page()就完事。
| 对比项 | MyBatis-Plus | Spring Data JPA |
|---|---|---|
| 简单CRUD | BaseMapper自动实现 | Repository接口 |
| 复杂SQL | XML/注解SQL完全可控 | JPQL或原生SQL映射较繁琐 |
| 分页 | 内置分页插件 | Pageable参数 |
| 学习成本 | 低,接近原生MyBatis | 需要理解持久层上下文 |
2.3 前端选Vue3而不是服务端模板
前端我用了Vue3 + Element Plus + Vite,后端只提供JSON API。也许有人会问,为什么不用Thymeleaf一套搞定?因为竞赛系统的页面交互并不简单:多角色菜单切换、报名表单动态校验、评审打分面板、统计图表展示,前后端分离之后逻辑清晰很多,团队协作或者后期找人接手维护都更方便。Vite的启动速度比Webpack快一大截,开发期体验好,Element Plus的表单组件和表格组件恰好覆盖管理后台90%的场景,基本不用自己造轮子。
权限这块用了Sa-Token而不是Spring Security,因为竞赛管理系统没有OAuth2等多方登录需求。Sa-Token开箱就能提供登录、角色注解(@SaCheckRole)、会话管理,代码量比Spring Security少一半以上。如果你熟悉Security也可以用,但Sa-Token对中小型单应用项目确实更省心。
2.4 中间件尽量少,留给部署环境足够宽容度
在设计初期,我认真考虑过引入MinIO做文件存储、ActiveMQ做消息通知,但最终把这两者都放进了"扩展章节",主力方案保持轻量:文件存本地磁盘并按日期分目录,消息通知先不做。核心考虑是让这套系统能在"只有MySQL、没有其他中间件"的普通电脑上跑起来。Redis在我这个版本里是可选组件,配置里做了开关:有Redis则启动缓存、验证码、分布式并发锁;没有Redis就自动降级为本地缓存和数据库锁。这一条对毕设场景特别重要——演示环境越简单,出问题的概率越小。
3. 数据库设计里的几个关键决策:一次失败的报名记录给我的教训
数据库是这类管理系统的地基。我第一版设计吃掉过亏:报名表上没有唯一索引,测试时同一位学生针对同一场竞赛的报名接口连续点了两次,生成了两条记录,后台统计瞬间多了一个项目。虽然只是测试,但足以说明约束设计的重要性。
3.1 核心表结构和字段设计
完整表大概15张左右,这里列出最核心的几张:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| sys_user | id, username, password, real_name, role, college_id, user_no, phone, email | 用户表,role区分学生/教师/管理员/评审 |
| sys_college | id, college_name | 学院表 |
| competition | id, title, category_id, semester, reg_start/end_time, submit_start/end_time, status, award_rule, max_team_size, has_team | 竞赛主表,内置两个时间窗口和奖项规则 |
| signup | id, competition_id, student_id, college_id, team_name, teacher_id, admin_status, signup_status, submit_count | 报名表,个人和团队统一存储 |
| team_member | id, signup_id, student_no, student_name, college_id | 团队成员表,只有团队报名才有数据 |
| submission | id, signup_id, file_name, file_url, file_size, submit_version | 作品提交表,支持覆盖上传 |
| review_dimension | id, competition_id, dim_name, max_score | 评分维度表,每场竞赛可灵活配置 |
| review_score | id, submission_id, review_group_id, score, comment, status | 评审打分表,各维度分数汇总 |
3.2 报名唯一索引和补偿逻辑
signup表上必须有唯一索引uk_competition_student(competition_id, student_id)。但数据库唯一索引只是最后一道防线,业务代码不能依赖"先查再插"——高并发报名场景下,先查后插天然存在竞态,两个请求同时查不到记录,然后同时插入,就重复了。
我实际采用的方式是直接插入,然后捕获DuplicateKeyException:
try { signupMapper.insert(signup); } catch (DuplicateKeyException e) { return R.error("请勿重复报名"); }这种"插入即校验"的做法比selectCount + insert可靠得多,代码也更短。团队报名时,成员表里同样加了唯一索引uk_member_signup_student(signup_id, student_no),防止同一个学号在同一个队伍里被重复添加。
3.3 评审模型要支持评分维度动态配置
刚开始我照着普通需求设计了review_score表,里面只有一个score字段。做评审模块时发现不对——不同竞赛的评分标准差异很大,有的竞赛评委打分项是"创新性40分、实用性30分、完成度30分",有的只需要一个百分制总分。如果写死一个分数字段,系统换个赛事就要改表。
后来我把评分拆成review_dimension和review_score两张表:管理员在创建赛事时为每场竞赛配置若干评分维度(名称+满分值),评审专家提交时按维度分别打分,系统自动汇总求平均或加权平均。这样系统后续接入任何类型的赛事,都不需要动表结构。这个设计是评审模块的核心亮点,也直接决定了评审打分的灵活度。
3.4 学院信息冗余是统计刚需
很多刚做这类的同学会纠结要不要在signup表里冗余学院信息,我的答案是:冗余,而且要在多个位置冗余。因为跨学院组队时,成果归属既可能记到队长所在学院,也可能按参与学生分别计入各学院。我的方案是signup表存队长college_id,team_member表里每个成员也存自己的college_id,统计口径可以在后端配置。数据有冗余,但是可解释性强,统计SQL不需要做复杂子查询,一年几千条数据完全扛得住。
4. 核心功能模块的实现拆解:从赛事发布到成绩公示的完整链路
表结构定好之后,功能的实现其实就顺理成章了。但每个模块里还是有值得展开的细节。
4.1 报名模块:防重、组队、状态推进
学生端报名操作本身很简单:选竞赛、填团队信息、提交。真正要处理好的有三点:报名时间窗口判断、组队成员查重、状态推进。
时间判断不能只在前端做,后端也要用服务器当前时间跟报名截止时间比较——用户改自己电脑时间绕过前端限制的情况虽然不多,但不能留这个口子。组队成员查重则是SQL层面拦截,同一个学号在同一场竞赛里只能出现在一支队伍中。状态推进我设计了三个独立的审核标记:教师确认(teacher_status)、学院审核(admin_status)、报名最终状态(signup_status)。只有教师和学院都通过,signup_status才变成"报名成功"。
报名接口核心逻辑简化后大致如下:
@PostMapping("/signup") @SaCheckRole("student") public R signup(@RequestBody SignupVO vo) { // 1. 校验报名时间窗口 if (!competitionService.isInRegisterWindow(vo.getCompetitionId())) { return R.error("当前不在报名时间内"); } // 2. 查重:每个学生每场竞赛仅一条报名记录 Signup signup = new Signup(); signup.setCompetitionId(vo.getCompetitionId()); signup.setStudentId(StpUtil.getLoginIdAsLong()); signup.setCollegeId(currentThreadCollegeId()); // 3. 插入并捕获唯一键冲突 try { signupMapper.insert(signup); } catch (DuplicateKeyException e) { return R.error("您已报名该竞赛"); } return R.ok(); }4.2 文件上传:真正耗时间的坑在这
作品提交是竞赛系统里最能暴露问题模块。Spring Boot 老版本默认单文件大小限制是1MB,很多同学传个PDF都失败,第一反应就是代码写错了。实际上只要在配置文件里放开即可:
spring: servlet: multipart: max-file-size: 200MB max-request-size: 400MB文件上传还有几个必须提前踩平的坑:
- 文件类型校验不能只看扩展名,建议同时校验 Content-Type,防止别人改后缀传可执行文件。
- 文件名必须重命名,用
UUID + 原文件扩展名存储,既避免中文乱码,也防止路径穿越之类的安全风险。 - 存储路径按日期分目录,比如
upload/2026/06/,方便后续定期清理和追溯。 - 作品的覆盖提交用
submit_version字段记录版本号,每次上传版本加1,学生可以覆盖作品,但评审阶段锁定不再允许修改。
4.3 评审模块:盲评是怎么落地的
评审阶段最怕人情分干扰,所以设计上做了盲评处理。评审专家登录后,看到的作品列表只显示作品编号和文件名,不显示学生姓名、学号、学院。如果赛事要求严格盲评,作品正文里也不能出现个人信息痕迹,这个系统约束不了,我在上传页放了明确提示文案。
每个作品至少分配两位评审专家独立打分,成绩按维度汇总后取平均分。分配专家的逻辑是:管理员在评审管理页选择赛事,再勾选专家,系统为每个submission随机关联两位专家,最后提交时把专家和作品之间的对应关系固化到review_group表里。这样成绩公示阶段可以直接按作品聚合查询。
4.4 成绩公示与奖项自动统计
评审结束后,系统按作品均分排序,再根据competition表里配置的奖项数量自动生成一、二、三等奖。公示阶段用announcement表发布公示公告,公告里放最终名次表格。教务处要的年度统计报表,在统计页面按学期、学院、赛事维度生成。
排名统计的核心SQL大概长这样:
SELECT s.id AS signup_id, s.competition_id, ROUND(AVG(rs.score), 2) AS avg_score, COUNT(rs.id) AS review_count FROM signup s LEFT JOIN submission sub ON sub.signup_id = s.id LEFT JOIN review_score rs ON rs.submission_id = sub.id WHERE s.competition_id = #{competitionId} AND rs.status = 1 GROUP BY s.id ORDER BY avg_score DESC奖项自动生成时,注意一个边界:并列分数怎么处理。我的做法是先按均分排序,再按提交时间排序,分数相同先提交者优先。规则不复杂,但必须在后端写清楚,否则公示阶段可能出现"第四名和第五名分数一样但一个得奖一个没得"的尴尬。
4.5 数据隔离:学院管理员只能看本院数据
角色控制只是第一步,更关键的是数据范围隔离。学院管理员登录后,查询列表必须自动限制为本院数据。我的实现思路是:登录时把college_id写入Sa-Token会话,查询时通过ThreadLocal拿当前用户的数据权限条件,以LambdaQueryWrapper的.eq(Signup::getCollegeId, collegeId)方式全局拼接。
private QueryWrapper<Signup> collegeDataScope(QueryWrapper<Signup> wrapper) { Long collegeId = StpUtil.getSession().getLong("collegeId", 0L); if (collegeId != 0L) { wrapper.eq("college_id", collegeId); } return wrapper; }这套逻辑最大的好处是后续新增接口不容易漏——只要统一走这个Service方法,数据隔离自动生效。校级管理员登录时collegeId默认0,不做任何限制。
5. 部署、配置与源码包落地:从IDEA到服务器的一趟完整流程
这套系统的源码包版本23762,工程结构分四块:backend(Spring Boot工程)、frontend(Vue3工程)、sql(初始化脚本)、docs(接口文档与设计说明)。拿到包之后从零开始跑通,大概需要以下几步。
5.1 本地跑起来需要几步
- 用Navicat或命令行执行
sql/init.sql,初始化数据库和基础数据(含默认账号密码)。 - 打开
backend,等待Maven依赖下载完成,修改application.yml里的数据库连接地址和密码。 - 启动后端,端口默认8080。
- 打开
frontend,执行npm install,然后npm run dev,默认端口5173。 - 前端代理配置里把
/api转发到后端地址,两个服务一启动,系统就能登录。
后端启动后建议先看日志,确认没有连接数据库报错。第一次启动最常出问题的地方就是数据库URL里的时区参数,我下面会说。
5.2 application.yml里的关键配置
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/contest_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 200MB max-request-size: 400MB sa-token: token-name: satoken timeout: 2592000 is-concurrent: trueserverTimezone=Asia/Shanghai这一条务必注意。很多人本地跑得好好的,部署到云服务器后查出来时间相差8小时,就是URL里没加这个参数导致MySQL驱动使用了默认时区。
5.3 部署到Linux服务器
打包流程很简单:
cd backend mvn clean package -DskipTests scp target/contest-system.jar root@服务器IP:/opt/contest/服务器端启动命令:
nohup java -jar /opt/contest/contest-system.jar \ --spring.datasource.password=真实密码 \ --spring.servlet.multipart.max-file-size=200MB \ > /opt/contest/log.log 2>&1 &前端打包后把dist目录丢给Nginx即可。端口、数据库密码、上传目录这三项是部署时最容易出错的地方,建议提前写在部署文档里。
5.4 我踩过最值得说的四个坑
- Maven编译版本不匹配。IDEA自带JDK版本和
pom.xml里java.version不一致,编译直接报错。先执行java -version确认版本,再检查IDEA的Project Structure。 - 数据库时间差8小时。前面说的
serverTimezone=Asia/Shanghai,另外JSON返回的LocalDateTime建议统一用Jackson的yyyy-MM-dd HH:mm:ss格式,避免前端拿到ISO格式再转换。 - 跨域问题。前端
Vite的代理在本地有效,但打包交给Nginx后,后端必须开启CORS并放行Authorization请求头。Nginx反向代理/api到8080端口时也要注意proxy_set_header配置。 - 上传目录权限。Linux下如果没给上传目录写权限,启动后一切正常,但传文件就报
FileNotFoundException或者InputStream读取失败。给目录设置chmod -R 755能解决90%的问题。
我个人做完这套系统最大的体会是:竞赛管理系统的技术复杂度上限不高,但业务完整度下限很高。真正花时间的地方不是单个CRUD怎么写,而是把报名、组队、审核、提交、评审、公示这条链路上的状态流转和异常边界全部处理干净。如果后续想扩展,可以考虑在现有架构上接ActiveMQ做报名成功和评审结果的消息通知,把本地文件存储切到MinIO实现分布式存储,前端再加一个移动端H5报名入口,基本就达到院内日常使用的完整标准了。希望这份经验分享对要做同类系统的人有点帮助。