
单看这个标题很容易觉得“这又是课程设计模板货”。但实际做过的人都知道高校竞赛管理系统真正的难点根本不在增删改查而是业务状态怎么转、权限怎么分、成绩怎么算、报名怎么防重复。标题里的这套组合——SpringBootVueMyBatisMySQL恰好是前后端分离项目里最稳妥、最不容易把自己绕进去的一套方案。这篇文章我不写营销话术直接按我接触过的落地经验把这个系统的设计思路、数据库建模、核心接口、前端联调和部署避坑完整拆一遍。适合准备做课设、毕业设计或者想把院系竞赛管理从Excel里搬上线的同学参考。先说明一下项目标题没有给出完整源码细节我只能基于这类系统最常见的实现套路和我个人的踩坑经验来补全。下面讲的设计和代码你照着搭是能跑通的。1. 先看业务全貌再谈技术框架选型1.1 竞赛管理涉及哪些角色和流程很多人在动手写代码之前喜欢先开一个SpringBoot项目再说。结果写到报名功能就会发现业务方突然告诉你“一个学生可以以团队形式报名、最多3个人还要上传作品文件”于是之前设计好的单表直接推翻重来。高校竞赛管理系统的业务核心我总结成一条主线比赛创建-通知发布-学生报名-管理员审核-评委评审-成绩公布-获奖归档。围绕这条主线参与角色至少分四类学生查看比赛通知、在线报名、上传材料、查看审核结果和最终成绩。管理员维护比赛信息、审核报名资格、配置评委、发布成绩和证书信息。评委查看分配到的评审任务在线打分填写评语。院系/校方账号查看全校竞赛参与情况和获奖统计导出报表。我见过不少翻车项目问题都出在“只做了学生的报名页管理端只有个列表”。实际上管理员的操作压力比学生大得多。一个校级比赛几百条报名记录需要批量审核几十个评委需要分配评审范围成绩公布后还要按院系导出统计表。这些管理端功能如果没提前规划后面加班都补不回来。1.2 SpringBootVueMyBatisMySQL为什么是稳妥组合技术选型的核心不是“谁最流行”而是“谁最不容易出问题”。这个组合在中小型管理系统里属于下限很高的搭配。后端用SpringBoot自动配置、内嵌Tomcat、生态成熟。相比早期的SSH省掉一堆XML配置项目结构一眼就能看明白。尤其是做课设或者小型实训项目SpringBoot的学习曲线比想象中平缓。前端用Vue组件化和响应式数据让管理后台的开发效率很高。页面之间跳转用路由数据状态管理用统一的仓库后端只要输出JSON前端就能迅速拼出完整界面。用之前JSPjQuery那套列表渲染、表单校验、弹窗操作都容易写出重复代码维护起来很累。持久层用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、用户名、密码、姓名、所属院系、学号/工号、邮箱、手机号、创建时间。密码字段必须存加密后的密文不要存明文。角色表通常是简单的字典表管理员、评委、教师、学生这几种。比赛表是比较核心的业务表常见字段如下字段名类型说明idbigint主键titlevarchar比赛名称typevarchar竞赛类型理工/人文/艺术等leveltinyint级别校赛/省赛/国赛organizervarchar主办方start_timedatetime报名开始时间end_timedatetime报名结束时间review_start_timedatetime评审开始时间review_end_timedatetime评审结束时间descriptiontext比赛说明max_team_countint报名人数/队伍数上限statustinyint当前状态0草稿/1报名中/2评审中/3已结束create_timedatetime创建时间报名表是关联用户和比赛的“业务事实表”字段除了两个外键还要有队伍名称、成员名单、联系电话、指导教师、状态、创建时间。这里我特别强调一下成员名单不要用逗号拼接成一个字符串存进去否则以后做每个学生的参赛统计时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_flag0表示已失效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) { ListBigDecimal scores scoreMapper.selectTotalScores(registerId); if (scores.isEmpty()) { return BigDecimal.ZERO; } Collections.sort(scores); if (scores.size() 2) { return average(scores); } ListBigDecimal 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 idselectRegisterPage resultTypecom.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 teststatus ! null AND r.status #{status} /if if testkeyword ! null and keyword ! AND (u.user_name LIKE CONCAT(%, #{keyword}, %) OR r.team_name LIKE CONCAT(%, #{keyword}, %)) /if if testcompetitionId ! null AND r.competition_id #{competitionId} /if /where ORDER BY r.create_time DESC /select这里要特别注意所有参数值都用#{}不要用${}拼字符串。${}很可能引入SQL注入用户如果在你查询关键词里带上 OR 11整张表就暴露了。MyBatis的#{}是预编译占位符不会有这个问题但动态排序字段名这种情况确实只能用${}那就必须用白名单校验。分页我建议引入PageHelper插件一行代码就能完成物理分页PageHelper.startPage(pageNum, pageSize); ListRegisterVO list registerMapper.selectRegisterPage(param); PageInfoRegisterVO 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因为用户关掉标签页就丢了体验不好。请求拦截器里统一塞Tokenservice.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年的环境我建议如下组合组合版本建议说明JDK17SpringBoot 3.x要求JDK17以上SpringBoot3.2稳定官方持续维护MyBatis3.5.x mybatis-spring-boot-starter 3.0兼容SpringBoot3MySQL8.0用utf8mb4存储中文Node20配合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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue第二步修改后端配置里的数据库用户名和密码然后启动后端服务。后端启动成功后访问接口文档或者随便请求一个健康检查接口确认返回JSON。第三步修改前端接口地址。开发环境一般用代理方式前端请求的域名是/api代理到http://localhost:8080。这样就不会有跨域问题生产环境再用Nginx反向代理统一转发。第四步启动前端在浏览器里访问登录页用一个初始化的管理员账号登录创建一条比赛数据再注册一个学生账号测试报名流程。整个过程跑通一次才算环境没问题。5.3 高频报错速查表我把实操里最常遇到的报错整理成了下面的速查表每个都附了解决方向报错现象可能原因解决方案Access denied for user rootlocalhost数据库用户名或密码错误检查application配置Public Key Retrieval is not allowedMySQL8认证方式问题连接串加allowPublicKeyRetrievaltrueUnknown database contest_db数据库未创建先执行CREATE DATABASETable xxx doesnt existSQL脚本未导入核对导入顺序Invalid bound statement (not found)Mapper接口和XML的namespace不匹配检查mapper文件的namespace和id中文乱码连接串缺编码参数或表不是utf8mb4连接串加characterEncodingutf8改表字符集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执行了什么、参数是什么能省下大量时间。生产环境再把日志级别调成只记录错误和业务操作避免数据泄露和日志膨胀。最后再分享一个个人习惯任何核心状态变更比如报名通过、驳回、比赛发布、评审完成都记录一条操作日志。这个日志在答辩演示和出问题时都是最有说服力的证据。很多看起来神秘的数据异常查到最后都是某个人在某个时间点点错了按钮有了操作日志几分钟就能定位到责任人没有日志就只能整个系统翻源代码。