☰
Spring Boot+Vue高校竞赛管理系统:从业务建模到部署上线全指南
2026/10/17 11:18:56 网站建设 项目流程

在学校里待过一段时间的人都知道,竞赛管理这件事看着简单,真做起来全是细节。我之前帮人梳理过好几版类似的“高校竞赛管理系统”,也经常看到有同学拿着Spring Boot加Vue的方案来问:为什么我的项目跑通了,但一换真实场景就各种报错?为什么明明表结构建好了,报名功能还是乱成一团?这篇文章我想把基于Spring Boot加Vue的高校竞赛管理系统从零到上线的完整思路讲清楚,从业务拆解、数据库设计、权限模型、核心接口,到前端页面联动、部署避坑,尽量把我踩过的和看别人踩过的坑都列出来。适合准备做课程设计、毕业设计,或者想在企业里搭一套内部竞赛管理工具的同学参考,也适合刚接触前后端分离开发的人拿来练手。

这类系统的核心其实不是“技术有多难”,而是“业务状态有多乱”。报名、审核、提交作品、评审、公示成绩,每个环节都有状态流转,每个角色都有不同诉求。把这些东西理清楚,代码只是顺带的事。

1. 竞赛管理系统到底在管什么:业务拆解与需求来源

1.1 为什么“竞赛报名”是系统的核心矛盾

我观察过不少学校里的竞赛组织流程,最常见的一种状态是:管理员在群里发通知、学生用在线表格填报名信息、赛事负责人每天手动整理名单、评委拿Excel打分、最后把结果打印出来公示。听起来没什么大问题,但一旦竞赛数量多起来、参与人数过千,这套手工流程就开始失控。

比如同一个学生同时报了三个团队,比如提交作品的截止时间到了还有人在改文件,比如评委打分口径不一致导致公示后被质疑。这些问题的根源都在于“报名”这个入口没有做规则约束。所以设计竞赛管理系统时,我建议把报名流程当成整个系统的心脏来对待,而不是把它当成一个普通增删改查页面。

围绕报名这个核心,系统的业务流程大概是这样:管理员发布竞赛并设置报名起止时间,学生查看竞赛详情并组队报名,管理员或负责老师审核报名资格,审核通过后学生在规定时间内提交作品,评委在评审窗口内打分,最后系统汇总成绩并支持公示。这个链路里每一次状态变化都要有记录,都要有权限控制,否则后期很难追溯。

1.2 四类用户和他们的真实操作路径

竞赛管理系统最常见的角色可以压缩成四类:管理员、学生、评审教师、普通游客(或者说未登录用户)。每类人在这套系统里的操作路径完全不同,做功能设计时一定要先以角色视角列一遍需求,而不是上来就画页面。

用表格表达会比较直观:

角色核心操作关注的数据典型页面
管理员发布竞赛、审核报名、管理用户、发布公告竞赛数量、报名人数、审核状态后台看板、竞赛管理、用户管理
学生浏览竞赛、组队报名、提交作品、查看成绩报名是否通过、作品提交截止时间、最终分数竞赛列表、报名页、作品上传
评审教师查看待评作品、打分、填写评语待评数量、已完成评审、成绩分布评审工作台
未登录用户浏览竞赛公告、查看竞赛介绍竞赛时间、参赛要求首页、竞赛详情

这个表不是让你直接拿去做数据库表,而是提醒你:一切功能设计都要回到“某个角色在某个阶段做什么事、有什么数据诉求”上。很多项目做到一半发现页面很多但不知道数据从哪来,就是因为一开始没有按角色把页面和数据字段对齐。

1.3 功能边界:哪些该做,哪些不该做

高校竞赛管理系统很容易被做成“大杂烩”。有人加在线答题,有人加聊天室,有人加复杂的数据大屏。我的建议是:除非你的场景和题目里明确要求,否则这些附加功能都别放在第一版。

第一版只要做清爽的五块:竞赛管理(发布、编辑、状态控制)、报名管理(组队、审核、名单导出)、作品管理(上传、下载、截止时间控制)、评审管理(分配评委、打分、汇总排名)、公告与用户管理。等核心链路跑通,再考虑扩展数据统计、消息通知、导出成绩单这些提升体验的功能。

边界清楚的好处是数据库表不会失控,权限模型不会混乱,整篇文章后续讲到的状态机和接口设计才能在一个可控的范围内展开。

2. 后端设计:Spring Boot的表结构、权限模型与状态机

2.1 数据库表清单:从用户登录到成绩发布的完整链路

我用的是经典的Spring Boot 2.7搭配MyBatis-Plus,数据库MySQL 8。这套组合在网上资料最多,遇到问题很容易搜到解决方案,对学生项目或者中小型内部系统来说完全够用。

表结构我建议按业务实体拆成这几张核心表。别一开始就设计二十张表,先把主链路跑通,再逐步拆子表。

表名作用关键字段
sys_user用户表,存登录账号和基本信息id, username, password, real_name, role, college, phone
competition竞赛表id, name, type, level, organizer, description, enroll_start, enroll_end, status
team团队表,对应一次报名的队伍id, competition_id, captain_id, team_name, member_count
team_member团队成员关联表id, team_id, user_id
work作品表id, competition_id, team_id, title, file_url, submit_time
review评审表id, work_id, reviewer_id, score, comment, review_time
announcement公告表id, title, content, create_time

这里有个细节:用户表我习惯命名为sys_user而不是user,因为user在MySQL里虽然不算特别严格的保留字,但直接用作表名容易在复杂查询、框架生成SQL时踩到意想不到的坑。换一个前缀成本极低,后面省心很多。

团队和团队成员为什么要拆两张表?因为一个团队有多个人,而团队本身要存队长、团队名称这些独立信息。如果不拆,要么只能一个人一个团队,要么就得冗余存字段。竞赛系统里最典型的场景是一个队长报名时把所有成员姓名、学号一起填进去,这个场景用team加team_member两张表是最自然的建模方式。

2.2 RBAC权限模型:为什么不用简单的role字段加if判断

很多新手做权限时喜欢在接口里写“if (user.getRole().equals("admin"))”,看起来简单直接,但角色一多就崩了。比如评审教师既能看到评审工作台,也能看到自己负责的竞赛列表;管理员能管所有模块。如果权限判断散落在各个接口里,改一个角色的权限就要翻遍全部Controller。

推荐的做法是RBAC思路,建sys_user、sys_role、sys_menu、sys_user_role、sys_role_menu五张表,把角色和菜单/接口权限做成对应关系。对毕业设计这个体量的系统,不一定页面精度到按钮级别,但至少做到“角色控制菜单可见性、后端拦截接口访问权限”。

具体的后端实现方式也很标准:按用户查出角色,按角色查出权限标识集合,在Spring Boot里用自定义注解加拦截器校验。比如定义一个@RequirePermission("competition:add")注解,拦截器里判断当前用户是否有这个权限标识,没有就直接返回403。这样权限逻辑集中在拦截器里,业务代码里不用到处写角色判断。

当然,如果项目时间确实紧张,也可以做一个简化版:用户表加role字段,前端根据角色动态显示菜单,后端做一个全局的角色匹配拦截,只区分“管理员/教师/学生”三类。我知道很多能跑通的方案都是这么做的,但一旦遇到“某个老师既是评审又是竞赛负责人”这种特殊人物,就得回来补RBAC,所以建议一步到位。

2.3 状态机:让报名、作品、评审不再混乱

竞赛管理系统里最容易被忽略的就是状态字段的设计。我见过很多项目的竞赛表里只有一个status,0表示未开始,1表示进行中,2表示已结束。看起来没问题,实际上处理“报名截止了但作品还在提交”这种情况时就说不清了。

我习惯把状态拆成两张表各自管理:竞赛状态和报名/作品状态。

竞赛本身的状态流转可以设计成:草稿 -> 报名中 -> 评审中 -> 已结束。管理员发布竞赛时,系统自动根据当前时间和报名起止时间计算应该处于什么状态。这里不需要定时任务去改状态,只要在查询接口里实时计算即可,展示时再转换成对应的文字。

报名状态则是:待审核 -> 已通过 -> 已拒绝,或者直接报名成功后进入待提交作品状态。作品状态则是:未提交 -> 已提交 -> 已评审。把状态拆细以后,前端页面就能根据状态决定按钮的禁用和跳转,后端也能根据状态拒绝非法操作,比如报名时间已过就不允许再insert报名记录。

可以用一张简表感受一下状态机设计前后对比:

场景简单status的方案分状态机的方案
报名截止、作品提交已开始status=2,无法区分competition_status=REVIEWING, work_status=NOT_SUBMITTED
学生撤销报名不能准确判断是否允许报名在待审核状态可撤销
评委重复打分可能覆盖原记录review表对(work_id, reviewer_id)加唯一约束
统计各竞赛报名人数要额外写逻辑直接按报名状态count

2.4 关键接口设计与防重复操作

后端接口设计遵循一个原则:一个页面一个聚合接口,一个动作一个提交接口。不要为了接口数量好看而拆得零碎。

登录认证用JWT加Spring Security或者拦截器都行。我比较推荐轻量方案:登录接口校验用户名密码后生成JWT,前端把token存在localStorage里,请求时放到Authorization头,后端拦截器解析token并放入ThreadLocal,业务代码里通过一个CurrentUserHolder取当前登录用户。这样很轻量,也好理解。

密码存储必须用BCrypt加密,这一点没有商量余地。明文密码在任何项目里都是红线,数据库一旦泄露,明文密码就是灾难。

防重复报名是竞赛系统里最常见的并发问题:同一个学生短时间连点两次报名按钮,可能产生两条报名记录。解决的办法有两个层面。数据库层面,给team_member表加(team_id, user_id)唯一索引,给team表加(competition_id, captain_id)唯一索引;应用层面,报名接口启动事务,插入前先查一次是否已存在。唯一索引是最后一道防线,事务加前置查询是常规拦截,两者配合基本不会出错。

文件上传接口倒是简单,但要特别注意两点:文件名一定要重命名,不要使用用户原始文件名直接落盘,否则中文名、特殊字符很容易出问题;上传后的文件路径要存在数据库里,做访问时通过映射路径读取,而不是直接把真实磁盘路径暴露出去。

3. 前端实现:Vue组件化开发与页面联动

3.1 技术选型:Vue2加Element UI为什么最适合这类项目

很多人在Vue2和Vue3之间纠结,我的建议很实际:Vue2加Element UI依然是这个项目的最稳妥组合。Element UI的组件文档极其完整,网上案例多到数不清,菜单、表格、表单、弹窗、分页这些常见功能开箱即用。Vue3对应的Element Plus也成熟了,如果是从零开始学,用Vue3也不是不行,但遇到版本兼容问题时踩坑概率更高。

组件库选好以后,页面结构推荐这样组织:views下面按模块建目录,比如views/competition、views/enroll、views/work、views/review、views/user,每个模块一个列表页加一个表单页。公共组件放在components,比如PageHeader、StatusTag、FileUpload这类复用性高的组件。这样代码结构清晰,后期扩展也方便。

3.2 路由权限与菜单的动态生成

前端权限控制最简单的实现是:登录时后端返回当前用户的角色和权限列表,前端根据角色生成菜单。后端sys_menu表里存菜单名称、路径、图标、权限标识,通过角色关联查询出当前用户可见菜单,登录成功以后一次返回给前端。

路由守卫是必须加的。在router.beforeEach里判断是否已登录:没有token就跳登录页,有token但是首次进入就拉取用户信息并动态添加路由。这样刷新页面也不会丢失权限,因为每次刷新后都会重新执行一次路由初始化。

有一点我特别提醒:前端路由守卫只是用户体验层面的过滤,真正的安全校验一定在后端接口上。前端跳进一个页面不代表能调接口,每个接口都要通过后端的权限断言。

3.3 报名、作品、成绩三种页面的状态处理逻辑

竞赛列表页是学生用户的第一站。这个页面的核心是“根据竞赛状态展示不同操作按钮”:报名中显示“去报名”,报名已截止显示“已截止”,评审中显示“查看作品”,已结束显示“查看成绩”。这个逻辑很简单,但一定要把后端返回的status和前端展示文字映射清楚。

报名页面有不少细节。学生进入报名页时,先要判断自己是否已经报名过这场竞赛;如果已经报名且审核通过,按钮应该显示“已报名”;如果是待审核状态,显示“审核中”;如果被拒绝,显示原因并且允许修改后重新提交。团队成员的输入建议做成动态增删行,每个成员行包含姓名、学号、学院等字段,提交时和后端team_member表一一对应。

作品提交页面要注意截止时间的判断:前端拿到竞赛的work_end_time,在倒计时结束前允许上传,结束后禁用上传按钮,同时后端接口也要校验当前时间。前端校验只是提示,后端校验才是强制规则。

成绩公布页面则要区分“可查看”和“不可查看”两种状态。管理员没有公布成绩之前,学生只能看到“评审中”的占位文案;公布之后才显示分数和排名。我记得有同学直接把后端数据库里的成绩字段一下发到前端,结果学生通过网络请求直接看到了未公布的分数,这种低级错误一定不要犯。

3.4 axios封装与文件上传细节

axios封装算是老生常谈,但每次都能发现有人犯同样的错误。请求拦截器里从localStorage取token并添加到header,响应拦截器里统一处理业务码,业务码为零表示成功,非零弹错误信息,HTTP 401统一跳登录页。这套东西一定要在一开始就封装好,否则后面每个页面都在复制代码。

文件上传用el-upload组件时,组件默认走的是action属性指定的地址,但我们的项目需要带token,所以要在headers里动态设置Authorization。上传成功的回调里拿到后端返回的文件URL,把URL放到表单里一并提交,不要边传边提交,避免上传还没结束表单就提交了。

预览下载功能也不能忘。后端返回的文件URL如果是相对路径,前端拼接时注意不要重复加斜杠;如果文件名是中文,下载时要注意URL编码,否则浏览器可能解密失败。

4. 项目落地过程中绕不开的坑:权限、时间与文件

4.1 时间错乱的八小时之谜

前后端分离项目里,时间问题是我见过出现频率最高的问题。后端往前端返回LocalDateTime时,默认序列化可能带T,或者出现前端显示的时间比实际时间早八个小时的情况。

这个问题的根源是时区。MySQL连接串里要加serverTimezone=Asia/Shanghai,Spring Boot的application.yml里用jackson.date-format指定格式,同时设置time-zone: GMT+8。如果用了Fastjson,也要检查全局序列化配置是否指定了时区。

还有一种情况是前端拿到时间字符串以后,直接用JavaScript的new Date()解析,在不同浏览器里解析结果可能有细微差异。最稳妥的做法是前后端统一用"yyyy-MM-dd HH:mm:ss"字符串传递时间字段,需要计算倒计时时再由前端转换成时间戳。

4.2 Long类型ID在前端丢精度

这个坑极其隐蔽。数据库主键用的是bigint自增ID,MyBatis-Plus默认雪花算法生成的ID是19位Long类型,传到前端以后,JavaScript的Number类型最多安全表示2的53次方,超出部分精度丢失,表现在页面上就是最后一个或两位数字变成0。结果操作记录时发现ID对不上,数据窜了。

解决办法是在后端实体类的ID字段上加@JsonSerialize(using = ToStringSerializer.class),把Long序列化成字符串,前端拿到字符串类型的ID,既不丢精度也不影响后续请求。这个小配置虽然不起眼,但几乎所有项目里都会需要。

4.3 文件上传的边界:大小、类型与路径

文件上传相关的配置,如果不在工程里提前设置,等到部署到服务器再发现就晚了。

Spring Boot里要配置的内容包括:spring.servlet.multipart.max-file-size和spring.servlet.multipart.max-request-size,分别控制单个文件大小和单次请求总大小。如果前端用了el-upload,组件里也要设置limit和文件类型accept,两个层面一起限制才能防住大部分异常文件。

文件存储路径建议配在application.yml里,用一个自定义属性如file.upload-path,并设置成绝对路径。项目打包成jar运行以后,相对路径会因为工作目录不同而飘忽不定,一旦用相对路径存文件,重启以后文件访问404是常见结果。

如果文件是要公开访问的,比如竞赛附件、公告附件,后端要配置一个静态资源映射,把/files/**映射到上传路径目录。如果是登录后才能看的作品文件,就不能简单做静态映射,要通过下载接口做权限判断后再输出文件流。

4.4 并发报名场景下的脏数据问题

真实竞赛场景里,报名往往集中在截止前的最后半小时。那段时间用户疯狂点击,如果系统没有并发保护,很容易出现报名人数超限、同一成员重复加入多队、同一个队伍被提交多次的问题。

我建议做两层保护。第一层是数据库唯一约束,这个前面已经说过,team_member表对(team_id, user_id)建唯一索引,team表对(competition_id, captain_id)建唯一索引。第二层是报名接口的事务控制,用@Transactional把“检查是否已报名”和“插入报名记录”包在一个事务里,避免检查之后插入之前被别人抢先。

如果是高要求的场景,还可以在报名接口入口加一个synchronized或者分布式锁,锁的粒度精确到竞赛ID加用户ID。对普通校内竞赛来说,数据库唯一索引加事务基本足够,不需要引入Redis做分布式锁。

4.5 Excel批量导入的学生名单

有些管理员希望一步导入整个学院的学生用户,而不是一个一个手动新增。这个功能不建议用POI的原始API硬写,直接用阿里EasyExcel,它的读写API很简洁,内存占用也低。

导入时最容易出的问题有两个:一是模板里的列头和学生上传的表头不一致,导致解析错位;二是重复用户名的处理策略不明确。我的做法是:先给管理员提供一个固定的Excel模板下载,后端解析时用监听器逐行读取,遇到同名的用户,默认更新已有记录的手机号和学院信息,不新增重复账号。解析完生成导入结果摘要,显示“新增X人,更新Y人,失败Z条及原因”。

5. 部署上线:从开发环境到服务器的一次完整旅程

5.1 本地调试环境的版本搭配

前后端版本搭配建议用一个表锁死,不要随便升级:

组件推荐版本说明
JDK8 或 11Spring Boot 2.7 兼容性最好
Spring Boot2.7.x稳定且资料多,不要轻易用3.0以上改坑
MyBatis-Plus3.5.x自带分页插件和代码生成器
MySQL5.7 或 8.08.0注意驱动名和时区配置差异
Node.js14 或 16对应Vue2生态最稳
Vue2.6.x配合Element UI 2.15.x

这个组合不是说绝对最新,而是“一切以文档多、社区答案多、踩坑成本低为优先”。等你把项目吃透,再往新版本迁移也不迟。

5.2 前后端分离部署:Nginx反向代理与接口代理

打包部署的流程是这样的:前端在项目目录下执行npm run build,生成dist目录;后端执行mvn clean package,生成jar包。服务器上如果只有一个Nginx,就把dist里的静态文件放到Nginx的HTML目录,然后配置一个location /api/,把请求代理到本地的Spring Boot端口。

一个典型配置片段大致如下:

server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /files/ { proxy_pass http://127.0.0.1:8080/files/; } }

这里有几个容易踩的点:try_files那行一定要加上,否则前端路由一刷新就404;proxy_pass后面是否带/api/会影响实际转发路径,一定要仔细对一遍;如果文件上传也要走Nginx,记得在http块里调整client_max_body_size,默认1MB会让你上传作品直接报413。

后端服务的启动命令建议写到启动脚本里,比如nohup java -jar competition-system.jar --spring.profiles.active=prod > app.log 2>&1 &。配置文件区分开发和生产环境,生产环境里数据库密码和JWT密钥通过外部配置或环境变量注入。

5.3 上线前必须检查的配置清单

每次上线前,我都会按下面这个清单过一遍,避免“本地能跑、服务器跑不了”的尴尬。

一是数据库:确认表结构已初始化,账号密码正确,编码用utf8mb4,连接串带时区参数。二是配置文件:检查上传路径是否有读写权限,JWT密钥是否换成足够长的随机串,是否关闭了Spring Boot的DevTools自动重启。三是Nginx:检查client_max_body_size,检查静态资源是否带try_files,检查代理路径是否对。四是防火墙与安全组:确认只有80、443和SSH端口对外开放,后端8080端口不要暴露到公网。五是备份:数据库定时备份,哪怕用一个简单的crontab加mysqldump也是一层保障。

这些检查项都不复杂,但它们决定了一个项目能不能从“我的电脑上能跑”变成“服务器上能稳定用”。


做了这么多套类似项目,我最大的感受是:所谓“系统”不是比谁用了多新的框架,而是比谁把业务状态理得足够清楚。一个报名状态字段的定义方式,就能决定这个系统后面是越用越顺还是越改越烂。如果你正在做自己的竞赛管理系统,我建议先把角色和状态流转在纸上画清楚,再动手写第一行代码,那些看似不起眼的唯一索引、时区配置、权限注解,才是撑起一个系统稳定运行的地基。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询