☰
基于SpringBoot+Vue+MyBatis的高校竞赛管理系统全栈实战
2026/10/20 3:35:41 网站建设 项目流程

学校里最热闹的时候,永远是各种竞赛集中开赛的月份。我在帮某高校改造他们的竞赛组织流程时,看到最多的是这样的场景:教务处的同事桌上堆着几个Excel文件,一份是报名表,一份是作品清单,还有一份是评委打分表,三份数据靠姓名和手机号手工匹配。有一年校级程序设计竞赛,报名人数500多人,光整理参赛名单就花了两个晚上。后来我基于SpringBoot+Vue+MyBatis+MySQL做了这套高校竞赛管理系统,才算是把整个流程从“人肉调度”变成了“流水线作业”。

这篇文章不讨论那种大而全的商业级平台,就围绕一个最务实的版本:SpringBoot做后端接口,Vue做管理端页面,MyBatis操作MySQL数据库。整套系统的核心价值是管理竞赛的全生命周期——从赛事发布、在线报名、作品上传、评委评分,到成绩公示和证书导出,全部在一条数据链路上闭环。适合给自己学校、学院或者培训机构做一套实用系统的小伙伴参考,也适合想用真实业务场景练手SpringBoot+Vue全栈的朋友。

1. 管理者视角下的真实痛点:为什么要从一个Excel战场里逃出来

1.1 三个场景暴露的流程问题

先说第一个场景:竞赛报名。传统的做法是发一个通知文件,里面带报名表的下载链接,学生填完发到指定邮箱,老师再人工汇总。听上去很简单,但实际情况是——有人用旧版表格交,有人漏填学号,有人发了邮件又补发一版,汇总的时候根本分不清哪个是最新版本。如果竞赛有名额限制,比如只能报200组,那先到先得这件事靠邮件时间判断,吵起架来谁都不服。

第二个场景是作品评审。校级竞赛通常分初赛和决赛,初赛是评委看作品文档,决赛是现场答辩。评委手上有打分表,但打分表往往是纸质版或者Word版,收集起来之后还要手动录入。偶尔还会出现同一评委用两套标准打分的情况,比如上午第一组打得很宽松,下午最后一组突然严格起来,等成绩汇总出来已经没有回头的余地了。

第三个场景是证书发放。获奖名单确定之后,要按姓名、奖项、指导教师逐条生成证书。Excel邮件合并能做,但每次规则一变,比如证书上要加竞赛类型、要写队伍成员而不是个人,就得重新调整模板。我见过一个学院的证书从初稿到定稿改了四版,每次都是导出之后才发现字段对不上。

这三个场景放在一起,其实揭示了一个共同问题:环节之间没有统一的数据载体。比赛信息、报名信息、作品信息、评分信息、结果信息,全部散落在不同的文件里。竞赛管理系统要做的第一件事,就是把这些信息收拢到同一套数据库里,让每个环节的数据都从上一个环节直接流过来,不再有人工搬运。

1.2 由业务痛点推导出来的系统边界

系统做到什么程度算“够用”?我给自己定的边界是八个字:覆盖全流程、不越权干预。

覆盖全流程意味着竞赛从创建到归档的每一步都要有对应的功能模块:管理员创建赛事并设置报名时间、竞赛类型、参赛人数上限;学生在线报名并提交作品;评委在线打分或者导入线下评分表;管理员发布成绩;系统根据成绩规则自动生成获奖名单;最后一键导出证书和统计报表。

不越权干预意味着系统不替代人工决策,只做流程和数据的支撑。比如初赛评委名单由管理员指定,但打分尺度由评委自己把控,系统不会去算“去掉最高分最低分”这种复杂规则,除非赛事规则里明确需要。这样做的原因是:高校竞赛的规则千变万化,一个学校用的规则换到另一个学校可能完全不适用,把业务规则做死反而会让系统变成下一个要绕过的东西。

1.3 三类用户的真实诉求

我用访客关系把角色分成三类:学生、评委、管理员。

学生最关心的是三件事:报名是否成功、作品是否按要求提交、成绩和奖项是否准确。系统里对应的是:报名状态清晰可查、上传材料有格式校验和回显、成绩公示后能查看自己的证书。

评委最关心的是打分效率。所以评委端不需要做太花哨的功能,只需要看到分配给自己的作品列表、作品资料包、评分表单,然后快速打分提交。如果系统让评委先注册再登录再等待管理员审核,那评委大概率会直接打电话抱怨。

管理员是系统的核心用户,所有的不合理流程最终都由管理员来兜底。所以管理员的权限要足够灵活:能创建任意竞赛、能调整报名截止时间、能增删评委、能手动修正异常数据。系统不能替管理员做主,但要在关键操作上留下日志。

这个角色模型看起来简单,实际上决定了后面数据库表设计和接口权限控制的基本走向。很多人做这类系统时喜欢把学生、教师、管理员混在一张表里用role字段粗粒度区分,但后面一遇到“某用户既是学生又当评委”的场景就开始到处打补丁。所以我的建议是:角色权限用RBAC模型,角色与用户解耦,同一个账号可以挂多个角色,这样在教学场景里才跑得顺。

2. 技术栈选型复盘:这个组合不是随手拼的

2.1 为什么是SpringBoot而不是更重的框架

高校内部的系统通常需要快速交付、方便维护,毕业设计或者实训项目更看重代码结构清晰。SpringBoot在这类场景里几乎是标准答案:自动配置让项目能快速跑起来,内嵌Tomcat让部署少了一个环节,生态成熟意味着遇到问题搜一下就有答案。

有一点很多人忽略:SpringBoot的核心优势不是“新”,而是“约定大于配置”带来的低心智负担。一个团队里如果有人之前用过Spring MVC,他几乎不需要重新学习就能上手SpringBoot。而且SpringBoot本身并不绑定特定前端或者数据库方案,和Vue、MyBatis组合起来非常自然。相比之下,如果为了追求所谓“高并发”引入Spring Cloud全家桶,光服务注册、配置中心、网关这些组件就够折腾几个星期,对高校竞赛管理这个量级的业务来说是过度设计。

2.2 为什么是MyBatis而不是JPA/MyBatis-Plus

我在这个项目里选了原生MyBatis,而不是JPA或者MyBatis-Plus,原因很实际:竞赛管理系统的查询逻辑复杂,多表关联多,而且不同学校的字段需求差异大。

JPA的ORM思路是“以对象为中心”,适合业务规则稳定、实体关系清晰的系统。但竞赛系统里有很多动态查询条件,比如“查询所有报名了某竞赛并且状态为已通过且提交了作品的学生”,用JPA写条件拼接比较绕,性能问题也不容易直观看到。MyBatis直接把SQL写在XML里,每一行查询都看得见摸得着,写复杂查询的时候非常有掌控感。

至于没有用MyBatis-Plus,是因为这个项目里的单表CRUD并不算多,更多是联表统计和状态流转。MyBatis-Plus的代码生成器和LambdaQueryWrapper确实能减少样板代码,但考虑到演示和教学场景,保留原生MyBatis的写法和XML映射,反而更容易让人理解数据是怎么流动的。如果后续业务量变大、单表CRUD变多,再引入MyBatis-Plus也不迟。

2.3 为什么是MySQL以及单项目架构的取舍

MySQL在这里几乎是唯一选择。免费、部署简单、团队里人人都会、InnoDB引擎对事务支持到位。竞赛系统的数据量级,一个学校一年几十场赛事、每场几百人报名,MySQL处理起来毫无压力。真正让它成为唯一选择的原因其实是生态——可视化工具多,导出导入方便,出问题了一查就有解决方案。

架构上我坚持了单项目单体应用,没有拆前后端分离的微服务。有人会问:你这系统不是已经Vue+SpringBoot前后端分离了吗?前后端分离指的是部署时前端静态资源由Nginx托管、后端只提供接口,这和微服务拆分成多个可独立部署的服务完全是两回事。高校竞赛管理系统做成单体应用,部署时一个Jar包加一个前端dist目录就能搞定,出了问题也容易排查。拆微服务的收益在这个量级上是零,成本却很高。

2.4 权限模型的一个意外收获

选型时还有一件事让我下定决心用Spring Security:高校竞赛系统里,用户通常会跨角色使用。一个老师可能是某个比赛的评委,同时也是另一个比赛的指导教师;一个研究生可能既是参赛学生,又帮导师管理赛事。这种场景下,如果权限设计是按“用户表-角色表-权限表”拆开的RBAC,就很容易处理:给同一个用户分配多个角色即可。

我在这个项目里做的角色只有三个:学生、评委、管理员。学生可以看到自己报名的竞赛和提交的作品;评委可以看到分配给自己的评审任务;管理员拥有一切操作权限。权限控制粒度控制在“菜单/接口”层面,没有细化到字段级。字段级权限在竞赛系统里没必要,反而会拖慢开发进度。

3. 核心数据模型设计:竞赛生命周期的一张表怎么拆

3.1 数据表总览与字段设计要点

竞赛管理系统的核心表,我按业务链路拆成了这么几张:

表名主要作用核心字段
user用户账号信息username, password, real_name, email, phone, avatar
role/permission角色和权限role_code, permission_code
competition竞赛基本信息title, type, description, start_time, end_time, max_team_num
competition_round赛程阶段(初赛/决赛)round_name, round_order, start_time, end_time
signup报名记录competition_id, user_id, team_name, status, signup_time
team_member团队成员关系team_id, user_id, member_role
work作品提交signup_id, round_id, file_url, file_name, submit_time, status
review_task评委分配任务competition_id, round_id, reviewer_id, status
score评分记录review_task_id, work_id, score, comment, creater_time
award获奖结果competition_id, signup_id, award_level, certificate_no

字段命名建议统一用小写下划线风格,因为MySQL在Linux环境下对表名列名大小写敏感,万一迁移到云服务器上,大小写不一致会报找不到表的错误。

3.2 状态机:竞赛从草稿到归档的流转

竞赛表里我单独留了一个status字段,包含草稿、报名中、评审中、公示中、已结束、已归档六个状态。这个状态字段是整个系统的“主干道”,很多功能都是围绕它来判断是否可用的:报名接口只在“报名中”状态开放,作品上传只在对应轮次的开放时间内允许,成绩发布之后竞赛自动切到“公示中”。

这里要特别小心状态的回退。比如说赛事已经进入“评审中”,管理员发现报名时间设置错了,想改回去重新报名,这时候不能简单改一下状态字段了事。因为此前可能已经有评委打了分,如果把竞赛回退到报名中,已经产生的评分数据怎么处理?一个比较稳妥的做法是:回退操作必须加二次确认,并且把回退动作写成一条操作日志,记录谁在什么时候回退了,以及回退前的状态是什么。业务上允许回退,但系统必须留下痕迹。

看一下典型的流转过程,其实和真实赛事节奏完全对应:管理员创建赛事草稿,填写完整信息后发布,状态变为报名中;报名截止后管理员点击进入评审,系统校验报名人数不为零,状态变为评审中;全部评分提交后,管理员按规则生成获奖名单,状态变为公示中;公示期结束,管理员确认归档,状态变为已结束。

3.3 容易拍脑袋设计的关联表

有两个关联表的设计,我觉得值得单独说一说。

一个是团队报名场景下的成员表。很多初学者会把参赛成员直接放进报名表里,用member1、member2、member3这种冗余字段。这样建表确实简单,但带来的问题是:如果要查询“某学生在哪些竞赛里参赛过”,得查三个字段,而且成员个数变化时表结构就要改。我的做法是拆出一张team_member表,报名记录是主体,成员是附属在报名记录上的多行数据。这样无论一个队伍三个人还是五个人,都能自然存下。

另一个是评委和竞赛的关系。有段时间我见过一种设计,在competition表里加一个reviewer_ids字段,用逗号分隔存储多个评委ID。这样查询“某评委负责哪些比赛”就变成了find_in_set操作,数据量小的时候没什么问题,但一旦评委数量增长、或者需要判断评委是否已完成全部评分任务,这种设计就非常难扩展。正确做法是单独建一张review_task表,每个评委和每个阶段生成一条独立的记录,评分完成就更新这条记录的状态。后面做统计时直接按表查询,效率高得多。

4. 功能模块实现顺序与关键代码赏析

4.1 推荐实现顺序:先跑通闭环再做花活

我在动手写这个项目时,模块实现顺序很有讲究。很多人习惯从最简单的“用户登录注册”开始,然后做用户管理、竞赛管理,结果做到报名模块的时候发现时间已经过去一半了,最核心的评分和证书导出反而草草带过。

建议的顺序是:先做竞赛管理和报名,再做评委评分,再做成绩与证书,最后做管理端的数据看板和用户管理。原因是前两个模块铺开了整个系统的核心数据链路:竞赛表、报名表、作品表,后面所有功能都围绕这几张表运行。只要这条链路通了,系统就算成功了一大半。

登录注册确实要先做,因为所有接口都需要知道当前请求是谁发起的。但不要在这里堆功能,比如密码找回、手机号验证码、第三方登录,这些都可以等主流程跑通之后再补。

4.2 接口设计的几个原则和一段代码

接口设计上我遵循三个原则:RESTful风格、状态码统一、参数严格校验。比如报名相关的接口:

  • POST /api/competition/{id}/signup 创建报名
  • GET /api/competition/{id}/signup/my 获取我的报名记录
  • POST /api/work 提交作品
  • DELETE /api/work/{id} 删除作品(仅限未开始评审前)
  • POST /api/score 提交评分

创建报名的后端逻辑大概是这样的:

@PostMapping("/{competitionId}/signup") public Result signup(@PathVariable Long competitionId, @RequestBody SignupRequest request, @AuthenticationPrincipal UserInfo user) { // 1. 校验竞赛是否存在且处于报名中 Competition competition = competitionMapper.selectById(competitionId); if (competition == null || competition.getStatus() != CompetitionStatus.SIGNUP) { return Result.error("竞赛不存在或不在报名时间内"); } if (LocalDateTime.now().isBefore(competition.getStartTime()) || LocalDateTime.now().isAfter(competition.getEndTime())) { return Result.error("不在报名时间范围内"); } // 2. 校验名额是否已满 Long count = signupMapper.countByCompetitionId(competitionId); if (count >= competition.getMaxTeamNum()) { return Result.error("报名人数已满"); } // 3. 校验当前用户是否已报名(唯一索引兜底) Signup existing = signupMapper.selectByCompetitionAndUser(competitionId, user.getId()); if (existing != null) { return Result.error("您已报名该竞赛,请勿重复报名"); } // 4. 插入报名记录,创建作品占位记录 Signup signup = new Signup(); signup.setCompetitionId(competitionId); signup.setUserId(user.getId()); signup.setStatus(SignupStatus.REGISTERED); signup.setCreateTime(LocalDateTime.now()); signupMapper.insert(signup); return Result.success(signup); }

这里有个细节:第2步检查名额时存在并发风险,后面会专门讲,在数据库层面还需要一件防身武器。

4.3 前端页面的组织方式与交互细节

前端用Vue 3加Element Plus,页面按角色分三个入口:学生工作台、评委工作台、管理后台。

学生工作台的主页就做一件事:列出当前正在报名的竞赛,附带“立即报名”按钮。报名成功后可以进入竞赛详情页,上传作品、查看作品状态、查看成绩。评委工作台更简单,登录后看到分配给自己的评审任务列表,点进去是作品列表加评分表单,每个作品一个分数一段评语,提交评分后任务状态更新。管理后台是最复杂的,有竞赛列表、报名管理、评委分配、成绩管理、证书管理、用户管理这些子页面。

前端的路由守卫要配合后端权限做双重校验。后端接口肯定要校验权限,但前端的路由守卫可以先拦截第一次访问,避免用户点进一个没有权限的页面看到满屏报错。比如一个学生直接访问/admin/competition,前端路由守卫发现自己没有管理员角色,就直接跳转到首页并给出提示。

5. 实战中踩过的坑:并发、文件、权限与细节

5.1 并发报名超限:代码检查+唯一索引的兜底

在测试阶段,我安排了一次模拟并发报名实验:同一个竞赛,名额上限100人,使用并发工具同时发起120个报名请求。结果最终报名成功的记录有110条,超员了。原因很简单:判断“是否还有名额”和“插入报名记录”这两个操作之间有一段空隙,多个请求同时通过了数量检查,然后同时执行了插入。

解决方法是双保险。第一层是业务代码里继续做数量判断,这层能拦住大部分正常情况;第二层在数据库层解决,通过唯一索引和事务兜底。唯一索引用来防止同一用户重复报名,事务加锁用来解决超员。

具体做法是给signup表加一个唯一索引:

ALTER TABLE signup ADD UNIQUE INDEX uk_signup_user_competition (competition_id, user_id);

然后在报名逻辑里,把“查询数量+插入”放到同一个事务中,并在查询竞赛信息时使用for update锁住竞赛行:

@Transactional public Result signup(Long competitionId, UserInfo user) { Competition competition = competitionMapper.selectByIdForUpdate(competitionId); // 其余逻辑同前... }

这样并发请求到达时,只有一个事务能拿到锁,其他事务排队等待。这招在学校场景里已经够用,不需要引入Redis分布式锁,毕竟一个几百人的竞赛不至于真的产生每秒上千次并发。

5.2 文件上传的安全边界:类型、大小、存储路径

作品上传是这个系统最容易翻车的地方。第一次实现时,我允许上传任意文件类型,也没限制上传大小,结果有同学上传了一个800MB的视频,直接把服务器的磁盘撑爆了。

现在的做法是:在前端限制文件类型(只允许pdf、zip、rar、jpg、png),同时限制大小不超过100MB;在后端再做一次校验,类型以文件的MIME类型和服务端检测为准,不能只看文件后缀名。因为后缀名是可以伪造的,把恶意脚本改名为.jpg上传,只检查后缀会被骗过。

存储路径上也踩过坑。最简单的方式是上传到项目根目录的static/upload下,但这样下次重新部署时文件很容易丢。更稳的做法是把文件上传到服务器上一个固定的绝对路径,比如/opt/contest_files/,然后在数据库中只保存文件的相对路径或者访问URL。这样应用重新打包发布,文件不受影响。

还有一个用户体验问题:学生上传作品后,必须允许他在评审开始前自行替换文件。有同学初赛交了文档,发现格式不符合要求,需要撤回重传。系统设计上要区分“评审开始前可修改”和“评审开始后锁定”,这个逻辑用竞赛的批注状态字段判断即可。

5.3 权限漏洞:不做接口级校验等于裸奔

前端只控制按钮显示,是很多练手项目通病。比如前端给管理员显示“删除用户”按钮,普通用户看不到,但接口本身没有做权限校验,随便调一下接口就能删除用户。我在项目测试阶段专门写了一个爬虫脚本,模拟普通学生用户去调用管理员的接口,结果好几个接口直接返回成功。

解决方式是在Spring Security的配置里统一配置接口访问规则,比如/api/admin/**只有管理员角色可以访问,/api/reviewer/**只有评委角色可以访问。然后在Controller层再写一层注解校验,这样即使有人绕过了网关或者直接通过Swagger访问接口,后端也会拒绝。

还需要注意的是接口越权,比如一个学生用户传自己的userId参数,去查询其他人的报名记录。这种问题单纯靠角色权限是不够的,必须在业务代码里校验“被操作资源的所有者是否是当前用户”。查询报名记录时要限定userId等于当前登录用户,查询成绩时也要校验当前用户是否是该队伍成员。

5.4 时区、Excel导出和更多小问题

MySQL默认时区通常是UTC或者系统时区,而Java应用用的可能是Asia/Shanghai。如果两边不一致,用户在页面看到的报名时间可能比实际时间早了8个小时。解决方式是连接串上显式指定时区:

jdbc:mysql://localhost:3306/contest?serverTimezone=Asia/Shanghai&characterEncoding=utf8

同时Java里统一使用LocalDateTime,不要在代码里手写new Date()再去格式化,这样最容易出错。

另外Excel导出也是坑。使用Apache POI导出成绩单时,如果数据量超过几万行,内存消耗会非常明显。我在一次模拟导出3000行成绩时,JVM内存直接飙升到800MB。优化方式是使用SXSSFWorkbook配合流式写入,或者干脆分页导出,一次导500行,分多次完成。竞赛系统的数据量通常不大,但把Excel导出写成流式写法,用户等起来也快很多。

6. 部署、验收和下一步:从“能跑”到“好用”

6.1 打包部署与演示数据的准备

系统完成后,部署环节也值得复盘。前端执行npm run build后生成dist目录,后端执行mvn clean package打成jar包。部署时有两种选择:一是把dist目录放进SpringBoot的resources/static下,这样前后端合并成一个jar包,部署最简单;二是用Nginx托管前端静态资源,加上反向代理到后端接口。

我更推荐第二种,因为Nginx可以对上传文件的访问路径做更灵活的控制,还能配置接口请求的跨域转发。Nginx配置里有一段很关键:

location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

这样可以避免前端的跨域问题,把/api开头的请求统一转给SpringBoot。测试环境访问前端页面时不需要额外配代理跨域,联调效率提升明显。

演示数据要提前准备。管理系统验收的时候最尴尬的是打开页面看到一片空白。我在数据库里准备了三个用户、两个竞赛、十几条报名记录、若干评分记录,还手工生成了一张证书,这样评审或者答辩时点到任何一个页面都有数据可看,演示效果完全不一样。

6.2 后续扩展方向:赛程冲突检测、数据看板、消息提醒

系统跑起来之后,我从使用方那里收集了不少反馈,这些方向可以作为后续扩展的重点。

第一个是赛程冲突检测。很多学校同一个学期内多个竞赛并行,学生们可能同时报名了好几个竞赛,但答辩时间撞车了。系统可以在发布赛事时,导入其他赛事的日程,当新赛事的时间范围与已有赛事重叠时给出提示。

第二个是管理端数据看板。用图表展示每个学院的参赛人数、竞赛成功率、历年报名趋势。这个功能可以在系统跑通一年后,有了历史数据再上,效果会很好。图表组件用ECharts就够了,后端只需要提供对应时间的统计接口。

第三个是消息通知。报名成功、作品被退回、成绩发布,这些关键节点都应该主动通知学生。最简单的方案是接入邮件服务,学生注册时必填邮箱,关键节点自动发邮件。更轻量级的方案是站内信,在系统首页做一个消息中心。校园场景里微信通知最方便,但实现成本高一些,可以留到二期。

我自己在实际部署中最深的一点体会是:管理系统这种项目,技术上几乎没有未知难点,难点全在业务梳理和细节打磨上。把流程想清楚、把权限边界划清楚、把并发和文件这类隐藏的坑填平,系统的可用性就比大多数临时拼凑的版本高出一大截。每个学校的具体竞赛规则都不一样,但一个稳定的数据主干道,永远能应对各种规则的变化。

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

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

立即咨询