单看这个标题,很容易觉得“这又是课程设计模板货”。但实际做过的人都知道,高校竞赛管理系统真正的难点根本不在增删改查,而是业务状态怎么转、权限怎么分、成绩怎么算、报名怎么防重复。标题里的这套组合——SpringBoot+Vue+MyBatis+MySQL,恰好是前后端分离项目里最稳妥、最不容易把自己绕进去的一套方案。这篇文章我不写营销话术,直接按我接触过的落地经验,把这个系统的设计思路、数据库建模、核心接口、前端联调和部署避坑完整拆一遍。适合准备做课设、毕业设计,或者想把院系竞赛管理从Excel里搬上线的同学参考。
先说明一下:项目标题没有给出完整源码细节,我只能基于这类系统最常见的实现套路和我个人的踩坑经验来补全。下面讲的设计和代码,你照着搭是能跑通的。
1. 先看业务全貌,再谈技术框架选型
1.1 竞赛管理涉及哪些角色和流程
很多人在动手写代码之前,喜欢先开一个SpringBoot项目再说。结果写到报名功能就会发现,业务方突然告诉你“一个学生可以以团队形式报名、最多3个人,还要上传作品文件”,于是之前设计好的单表直接推翻重来。
高校竞赛管理系统的业务核心我总结成一条主线:比赛创建->通知发布->学生报名->管理员审核->评委评审->成绩公布->获奖归档。围绕这条主线,参与角色至少分四类:
- 学生:查看比赛通知、在线报名、上传材料、查看审核结果和最终成绩。
- 管理员:维护比赛信息、审核报名资格、配置评委、发布成绩和证书信息。
- 评委:查看分配到的评审任务,在线打分,填写评语。
- 院系/校方账号:查看全校竞赛参与情况和获奖统计,导出报表。
我见过不少翻车项目,问题都出在“只做了学生的报名页,管理端只有个列表”。实际上管理员的操作压力比学生大得多。一个校级比赛,几百条报名记录需要批量审核,几十个评委需要分配评审范围,成绩公布后还要按院系导出统计表。这些管理端功能如果没提前规划,后面加班都补不回来。
1.2 SpringBoot+Vue+MyBatis+MySQL为什么是稳妥组合
技术选型的核心不是“谁最流行”,而是“谁最不容易出问题”。这个组合在中小型管理系统里属于下限很高的搭配。
- 后端用SpringBoot:自动配置、内嵌Tomcat、生态成熟。相比早期的SSH,省掉一堆XML配置,项目结构一眼就能看明白。尤其是做课设或者小型实训项目,SpringBoot的学习曲线比想象中平缓。
- 前端用Vue:组件化和响应式数据让管理后台的开发效率很高。页面之间跳转用路由,数据状态管理用统一的仓库,后端只要输出JSON,前端就能迅速拼出完整界面。用之前JSP+jQuery那套,列表渲染、表单校验、弹窗操作都容易写出重复代码,维护起来很累。
- 持久层用MyBatis:它是半自动SQL框架,SQL写在自己手上。竞赛管理系统里有很多报表统计、多表联查和动态条件筛选,比如“查询某院系所有已通过报名但未分配评委的团队”,这种SQL直接写在XML里比让JPA自动生成更可预期,也方便DBA调优。
- 数据库用MySQL:开源、免费、普及率高,存储这类业务数据完全足够。配合InnoDB事务和合理的索引,能应对几千上万人同时报名的压力。
有人问,用Spring Data JPA行不行?行,但如果你对SQL没那么熟,遇到复杂统计时改起来很被动。MyBatis的优势就是“SQL透明”,出了问题直接对着SQL排查就行,适合这个体量的管理系统。
1.3 前后端分离的项目结构怎么划分
常见的工程结构是分成两个目录:前端和后端。后端按功能包组织,前端按页面和组件组织。
后端典型的包结构如下:
com.example.contest ├── config // 跨域、拦截器、文件上传等配置 ├── controller // 接口层,只做参数接收和结果封装 ├── service // 业务逻辑层,事务也写在这里 ├── mapper // MyBatis的Mapper接口 ├── entity // 数据库表映射的实体类 ├── dto // 接口传参和返回的对象 ├── common // 统一返回结果、异常处理、工具类 └── resources/mapper // MyBatis XML文件前端常见的目录结构:
src ├── api // 每个模块的接口请求封装 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // 全局状态 └── views // 页面,按admin/student分目录这样划分的好处是,一个人开发时不会迷路,两个人协作时也不容易冲突。后端接口只负责业务逻辑,前端只负责交互和数据展示,联调的时候约定好JSON结构就行。
2. 数据库设计与状态机是成败关键
2.1 核心表结构与字段约定
数据库是这类系统最容易返工的部分。根据业务主线,我建议至少设计这些表:用户表、角色表、用户角色关联表、比赛表、报名表、评委分配表、评分表、通知公告表、成绩/奖项表。
用户表不用过于复杂,关键字段包括id、用户名、密码、姓名、所属院系、学号/工号、邮箱、手机号、创建时间。密码字段必须存加密后的密文,不要存明文。角色表通常是简单的字典表,管理员、评委、教师、学生这几种。
比赛表是比较核心的业务表,常见字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| title | varchar | 比赛名称 |
| type | varchar | 竞赛类型,理工/人文/艺术等 |
| level | tinyint | 级别:校赛/省赛/国赛 |
| organizer | varchar | 主办方 |
| start_time | datetime | 报名开始时间 |
| end_time | datetime | 报名结束时间 |
| review_start_time | datetime | 评审开始时间 |
| review_end_time | datetime | 评审结束时间 |
| description | text | 比赛说明 |
| max_team_count | int | 报名人数/队伍数上限 |
| status | tinyint | 当前状态,0草稿/1报名中/2评审中/3已结束 |
| create_time | datetime | 创建时间 |
报名表是关联用户和比赛的“业务事实表”,字段除了两个外键,还要有队伍名称、成员名单、联系电话、指导教师、状态、创建时间。这里我特别强调一下:成员名单不要用逗号拼接成一个字符串存进去,否则以后做每个学生的参赛统计时,SQL写得想哭。应该单独设计队伍成员表,或者至少用JSON数组字段配合查询函数,但更推荐单独子表。
评分表一般存:评委id、报名id、各项评分维度、总分、评语、打分时间。因为比赛有不同类别,评分维度也不一样,更合理的做法是设计一个“评分配置表”,把每个比赛的维度动态配置出来,评分表再去存每个维度的分数。这样换一个比赛类型,不用改表结构。
2.2 报名、审核、评审的状态如何流转
状态设计是否合理,直接决定后期开发效率。我见过太多项目用一个“status”字段到处打补丁,结果前端要显示不同按钮、后端要判断不同操作,互相纠缠成一团。
报名表的推荐状态流是:
0草稿 -> 1待审核 -> 2已通过 -> 3已驳回 -> 4已取消
如果报名时不需要先填材料草稿,可以从1开始。但即便不做草稿,保留一个“已取消”状态也是有必要的,因为用户报名后可能想退出比赛,你不能直接删掉记录,否则统计历史参与情况时数据就丢了。
比赛的推荐状态流是:
0未发布 -> 1报名中 -> 2评审中 -> 3已结束
每次状态变更要伴随着业务校验。比如比赛处于评审中时,就不能再允许学生报名;已结束的比赛不能再修改分数。这里有两条经验非常实用:
- 状态字段统一存整型,不要存“报名中”这种中文串,一是索引效率差,二是代码判断容易写错。
- 状态流转的合法性在校验里写清楚。比如“从1到2必须满足:结束时间已过且有至少一条有效报名记录”,否则比赛直接被前端一点按钮就跳到评审状态,后面数据就会乱。
2.3 索引、唯一键与事务并发控制
数据库设计里最容易忽略的就是同一张表的唯一约束和索引。竞赛系统有一个经典问题:同一个学生同一场比赛到底能不能重复报名?业务上一般不允许重复报名,但技术上如果不做限制,用户手抖点了两次提交按钮,就会产生两条报名记录。
最简单的防线是加一个联合唯一索引:
UNIQUE KEY uk_competition_user (competition_id, user_id)但这里有个坑:如果同一个学生被驳回之后想重新报名,而报名表里已经存在一条驳回记录,唯一索引就直接挡住了。所以要么增加一个状态字段参与唯一索引,要么用“逻辑删除”配合部分索引。MySQL里不支持条件唯一索引,所以我在项目里更常用一个替代方案:报名表里加一个active_flag,0表示已失效,1表示有效,同时把competition_id、user_id、active_flag做成联合唯一索引。这样已驳回或者已取消的记录标记为0,再次报名时重新插入一条有效记录,不会冲突。
并发控制方面,一个校级比赛可能在同一秒涌入几百个报名请求。如果只是先SELECT再INSERT,极可能出现两个请求都查到名额还剩1个,最后实际报名人数超限。解决办法是利用数据库事务和行级锁,在报名时先锁住比赛那一行:
@Transactional public void apply(RegisterRequest request) { Competition competition = competitionMapper.selectForUpdate(request.getCompetitionId()); if (competition == null || competition.getStatus() != 1) { throw new BizException("比赛未开放报名"); } int applyCount = registerMapper.countValidByCompetitionId(request.getCompetitionId()); if (applyCount >= competition.getMaxTeamCount()) { throw new BizException("报名人数已满"); } registerMapper.insert(request); }selectForUpdate对应的SQL是SELECT ... FOR UPDATE,它会把该比赛的记录行锁住。在事务提交之前,其它事务只能等待,这样名额判断就是串行的,不会超卖。这个方法在单机应用里非常可靠,不需要引入Redis锁或分布式锁。
3. 后端接口实现与MyBatis实战
3.1 登录认证与接口权限校验怎么接
管理系统不可能所有接口都对所有人开放。学生不能去调删除比赛的接口,评委不能去看未分配给他的报名数据。权限校验不能只靠前端隐藏按钮,后端必须每一层都防。
最简单实用的做法是JWT Token + SpringBoot拦截器。用户登录成功后,后端生成一个Token,里面包含用户id、用户名和角色信息;前端每次请求都把这个Token放在请求头里,后端用一个拦截器统一校验。
拦截器里的核心逻辑是:解析Token,取出当前用户id,查询角色权限,再把用户信息放进ThreadLocal里方便后续业务使用。代码大致是这样:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BizException("未登录"); } // 解析token,验证签名和有效期 Claims claims = JwtUtil.parseToken(token.replace("Bearer ", "")); String role = claims.get("role", String.class); UserContext.set(new CurrentUser(claims.get("userId", Long.class), role)); return true; } }角色判断建议配合自定义注解。在需要管理员权限的接口上标注@RequireRole("admin"),拦截器检查当前用户角色是否匹配。这样代码里一眼能看出接口的访问权限,比在方法体里写一堆if判断舒服。
需要注意的是,密码加密一定不要用MD5直接存。MD5可以被彩虹表秒破。实际项目里我都是用BCrypt做散列,校验时调用BCrypt.matches(rawPassword, encodedPassword),这样即使数据库泄露,密码也不容易被还原。
3.2 报名接口如何防止重复和超名额
前面说过数据库表要加唯一索引,代码层面还要做同样的校验。不要觉得“有数据库兜底,代码就不用判断了”,因为数据库的约束只能防止极端重复,接口还要给出友好提示。
一个完整的报名流程是:
- 判断比赛是否存在、是否处于报名中状态。
- 判断当前登录用户是否已经有效报名。
- 判断当前报名人数是否超过上限。
- 插入一条状态为“待审核”的报名记录。
- 如果有作品材料上传,先保存文件,再把文件路径和报名记录一起提交。
第2、3步如果分开执行会存在并发窗口,所以最好放在同一个事务里。事务里执行SELECT FOR UPDATE或者依赖唯一索引都可以。如果使用MyBatis-Plus,注意默认的selectById不带锁,需要自定义SQL。
此外,报名提交后的审核操作也建议写成独立接口:/admin/register/approve和/admin/register/reject。拒绝的时候必须传原因,学生端才能看到被拒绝的理由,否则学生只是一脸懵地看到状态从待审核变成已驳回,体验非常差。
3.3 评审打分的统计汇总逻辑
评审打分如果做成“每个评委随便填一个总分”,那统计起来很简单,但业务上不专业。规范一点的比赛评审,通常会分多个维度,比如“创新性30分、完成度40分、实用性20分、展示表达10分”,每个评委按维度打分。
我建议的评审数据模型是“评分记录”加“评分明细”两张表。评分记录存总分、评语、评委id、报名id;评分明细存每个维度的单项分。前端评审页面上把维度动态渲染出来,提交的时候一次性提交全部维度分数,后端在一个事务里同时插入记录和明细。
成绩统计时,如果规则是去掉最高分和最低分取平均,可以用这种简单清晰的Java逻辑:
public BigDecimal calculateFinalScore(Long registerId) { List<BigDecimal> scores = scoreMapper.selectTotalScores(registerId); if (scores.isEmpty()) { return BigDecimal.ZERO; } Collections.sort(scores); if (scores.size() <= 2) { return average(scores); } List<BigDecimal> validScores = scores.subList(1, scores.size() - 1); return average(validScores); }注意评分一旦提交,要允许评委修改吗?通常系统会设计成“评审期间可以修改,评审结束后锁定”。最简单做法是在评分表里加一个final_flag字段,管理员点“结束评审”后,把所有评分记录的final_flag置为1,之后接口再收到修改请求就直接拒绝。
3.4 文件上传与Excel导入导出
竞赛系统里最常见的文件场景是:学生上传作品压缩包、管理员批量导入报名名单、管理员导出汇总表。
文件上传时,实际开发中要限制几件事:文件类型、单个文件大小、存储路径。SpringBoot配置里可以这样写:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB文件存储不要直接放到项目根目录下,容易在重新部署时丢失。建议放到一个独立目录,比如/data/contest/upload,然后用配置项指定路径。数据库中保存的是相对路径,接口返回给前端时可以拼接一个虚拟映射地址。
Excel导出是管理员的刚需。几百条报名记录如果只能在网页上翻页看,管理员会崩溃。用POI或EasyExcel这类开源Excel处理库,后端查询出数据后直接生成Excel文件流,前端点击一个按钮下载就行。批量导入前一定要做好模板校验,比如“学号长度不对”“比赛id不存在”要在导入结果里给人提示,不能导入后静默丢失。
3.5 MyBatis动态SQL与复杂联查
MyBatis的XML文件里最常用到的就是<where>、<if>和<foreach>。比如报名列表页面对多个条件的组合筛选,SQL可以写成:
<select id="selectRegisterPage" resultType="com.example.contest.dto.RegisterVO"> SELECT r.id, r.status, r.team_name, u.user_name, c.title FROM register r LEFT JOIN user u ON r.user_id = u.id LEFT JOIN competition c ON r.competition_id = c.id <where> <if test="status != null"> AND r.status = #{status} </if> <if test="keyword != null and keyword != ''"> AND (u.user_name LIKE CONCAT('%', #{keyword}, '%') OR r.team_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="competitionId != null"> AND r.competition_id = #{competitionId} </if> </where> ORDER BY r.create_time DESC </select>这里要特别注意:所有参数值都用#{},不要用${}拼字符串。${}很可能引入SQL注入,用户如果在你查询关键词里带上' OR 1=1,整张表就暴露了。MyBatis的#{}是预编译占位符,不会有这个问题,但动态排序字段名这种情况确实只能用${},那就必须用白名单校验。
分页我建议引入PageHelper插件,一行代码就能完成物理分页:
PageHelper.startPage(pageNum, pageSize); List<RegisterVO> list = registerMapper.selectRegisterPage(param); PageInfo<RegisterVO> pageInfo = new PageInfo<>(list);只要你用了PageHelper,查询之前必须先起分页,否则分页参数会串到下一条SQL上,这个坑我踩过一次,排查了半小时才找到原因。
4. Vue前端联调与页面落地
4.1 项目初始化与路由权限控制
前端技术栈我默认使用Vue3配合Vite。相比Vue2的老构建工具,Vite启动速度更快,配置也更简洁。后台UI直接使用现成的组件库,能省下大量样式时间。
路由权限控制的核心思路是在路由配置里给需要登录的页面加上meta.requiresAuth字段。比如学生报名页要登录,登录页和首页不需要登录。然后通过全局前置守卫来判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else if (to.meta.role && !to.meta.role.includes(store.role)) { next('/403') } else { next() } })这层控制其实是“体验层”的控制。真正的安全还是后端接口的权限校验。前端路由守卫只是让用户不再看到不相关页面,减少误操作,但不能作为唯一的安全手段。
4.2 Axios封装、Token存储与拦截器
项目里不能每个页面直接调fetch,那样遇到Token过期、接口报错、重复处理状态码时会写大量重复代码。我习惯对HTTP请求库统一封装。
Token存储位置值得说一句:存localStorage最简单,但如果有安全考虑,可以存cookie并设置httpOnly。不过前后端分离项目里,为了简单可靠,很多团队仍然用localStorage,这属于取舍。至少不要存到sessionStorage,因为用户关掉标签页就丢了,体验不好。
请求拦截器里统一塞Token:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })响应拦截器里统一处理异常状态:
service.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )这样所有接口请求只要走这层封装,登录状态失效后就会统一跳回登录页,不用每个页面对接一遍。
4.3 管理后台的常用页面套路
竞管系统的后台页面翻来覆去就那么几类:列表页、表单页、详情页、看板页。把这些页面套路吃透,开发速度会快很多。
列表页的核心是三要素:筛选区、数据表格、分页栏。筛选区不要放太多条件,常用的就比赛名称、状态、院系、日期范围。表格列根据角色动态展示,比如评委登录后看不到学生手机号,这类敏感字段在接口层就该脱敏,而不是等前端去隐藏。
表单页主要用在管理员创建比赛、编辑比赛。这里前端要注意时间控件选择的日期格式,传给后端的格式必须和后端接收的格式一致。最常见的联调报错就是“前端传了2025-01-01,后端却要求2025-01-01 00:00:00”,导致数据落库后时间变成0点。
看板页可以用一个简单的图表库来做。展示本校报名趋势、各院系获奖数量、各类型比赛数量,这些数据接口用SQL聚合就能查出来,前端直接展示柱状图和饼图,比铺一堆数字表格直观得多。我第一次做完这个模块后,管理员满意度立刻上了一个台阶。
5. 环境搭建、部署上线与常见问题排查
5.1 推荐的开发环境版本组合
按2025年的环境,我建议如下组合:
| 组合 | 版本建议 | 说明 |
|---|---|---|
| JDK | 17 | SpringBoot 3.x要求JDK17以上 |
| SpringBoot | 3.2+ | 稳定,官方持续维护 |
| MyBatis | 3.5.x + mybatis-spring-boot-starter 3.0 | 兼容SpringBoot3 |
| MySQL | 8.0 | 用utf8mb4存储中文 |
| Node | 20+ | 配合Vite构建 |
| 前端框架 | Vue 3 + Vite | 项目结构更现代 |
如果你更熟悉SpringBoot 2.x,那JDK8同样能跑,但2025年还从2.x新起项目意义不大。SpringBoot 3.x最需要注意的是包名从javax.servlet换成了jakarta.servlet,网上很多老教程代码直接复制过来会编译不过,这是正常现象,不是你的环境有问题。
5.2 前后端启动与联调的完整顺序
第一步,创建数据库并导入SQL脚本。先确认MySQL版本,8.0和5.7的驱动参数有区别,尤其是时区问题。数据库连接串参考这个:
jdbc:mysql://localhost:3306/contest_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true第二步,修改后端配置里的数据库用户名和密码,然后启动后端服务。后端启动成功后,访问接口文档或者随便请求一个健康检查接口,确认返回JSON。
第三步,修改前端接口地址。开发环境一般用代理方式,前端请求的域名是/api,代理到http://localhost:8080。这样就不会有跨域问题,生产环境再用Nginx反向代理统一转发。
第四步,启动前端,在浏览器里访问登录页,用一个初始化的管理员账号登录,创建一条比赛数据,再注册一个学生账号测试报名流程。整个过程跑通一次,才算环境没问题。
5.3 高频报错速查表
我把实操里最常遇到的报错整理成了下面的速查表,每个都附了解决方向:
| 报错现象 | 可能原因 | 解决方案 |
|---|---|---|
| Access denied for user 'root'@'localhost' | 数据库用户名或密码错误 | 检查application配置 |
| Public Key Retrieval is not allowed | MySQL8认证方式问题 | 连接串加allowPublicKeyRetrieval=true |
| Unknown database 'contest_db' | 数据库未创建 | 先执行CREATE DATABASE |
| Table 'xxx' doesn't exist | SQL脚本未导入 | 核对导入顺序 |
| Invalid bound statement (not found) | Mapper接口和XML的namespace不匹配 | 检查mapper文件的namespace和id |
| 中文乱码 | 连接串缺编码参数或表不是utf8mb4 | 连接串加characterEncoding=utf8,改表字符集 |
| Port 8080 was already in use | 端口被占用 | 换端口或释放进程 |
| Failed to parse multipart servlet request | 上传文件超过大小限制 | 修改Multipart配置 |
| CORS跨域报错 | 前后端域名不一致且未配置跨域 | 后端配置CorsRegistry或前端代理 |
| 前端页面空白 | Vite版本冲突或入口文件报错 | 查看浏览器控制台和构建日志 |
跨域问题多说一句:开发阶段用Vite代理最省心,生产阶段用Nginx把/api转发到后端服务,不要在后端配置allowCredentials(true)的同时把allowedOrigins设为*,浏览器对跨域携带cookie和通配原产地是冲突的。这个问题在实际部署中出现频率非常高。
6. 实操下来最想提醒的几件事
6.1 规则优先于页面,配置高于重写
如果一个比赛有“作品查重”环节,请不要在前端写死查重逻辑,后端应该留一个可配置的开关,因为不同比赛查重要求完全不同。我见过太多项目,为某一个比赛定制了功能,换一场比赛就用不了。正确思路是:把比赛类型、评分维度、人数上限、是否允许组队、是否需要上传作品都做成配置。代码通用,业务靠数据驱动,这才是管理系统的生存之道。
6.2 数据与日志:前期多花十分钟,后期少熬两个夜
数据库里一定要放测试数据,而且是“像样的测试数据”。不要只插入三条用户记录就测试分页,看不出任何效果。比赛时间的测试值一定不要用“2025年1月1日”这种过去的日期,我每次联调都要提醒自己造数时把时间设置为当前时间的下个月,否则报名状态判断永远走不对。
然后就是日志。后端框架自带的日志功能,开发时一定要开启SQL日志。MyBatis打印SQL后,联调阶段遇到数据不对,第一时间看SQL执行了什么、参数是什么,能省下大量时间。生产环境再把日志级别调成只记录错误和业务操作,避免数据泄露和日志膨胀。
最后再分享一个个人习惯:任何核心状态变更,比如报名通过、驳回、比赛发布、评审完成,都记录一条操作日志。这个日志在答辩演示和出问题时都是最有说服力的证据。很多看起来神秘的数据异常,查到最后都是某个人在某个时间点点错了按钮,有了操作日志,几分钟就能定位到责任人,没有日志就只能整个系统翻源代码。