
做高校竞赛管理系统这件事坦白说是被学校教务老师逼出来的。之前学校组织各类竞赛报名信息靠Excel汇总作品提交靠邮件评审打分靠纸质表一个赛程下来光催材料就得催三四天。后来我直接用Java SpringBootVue3MyBatisMySQL这套经典组合做了一个前后端分离的高校竞赛管理系统两周多时间从数据库设计到前后端联调全部落地。这篇文章把我整个设计和实现过程完整记录下来包括数据库表怎么拆、MyBatis分页插件怎么用、Vue3后台管理端怎么搭、前后端联调时踩了哪些坑正在做类似项目或者准备做毕业设计的可以直接参考。1. 项目概述这套高校竞赛管理系统到底解决什么问题1.1 系统面向的场景与核心功能高校里的竞赛管理听着简单实际一梳理全是琐碎流程。一次竞赛从发布通知、学生报名、指导教师审核、作品提交到评委打分、成绩公示、证书发放每个环节都涉及不同角色管理员要管竞赛全程教师既要指导学生报名也要参与评审学生要能报名、传作品、查成绩。线下Excel模式最大的问题是信息不同步——谁报名了、谁没提交、评审到哪一步全靠人工催问。所以我设计这套系统时核心功能就围绕这条业务链展开竞赛管理创建、发布、归档、用户管理学生、教师、管理员三角色、报名管理在线报名与审核、作品管理上传、查看、统计、评审管理打分、评语、汇总、数据统计参赛人数、作品数量、成绩分布。这些都是高校竞赛管理最基础也是最刚需的功能先把这些跑通再扩展证书管理、消息通知都不难。1.2 技术选型为什么是SpringBoot Vue3 MyBatis MySQL这个组合其实是经过对比后的选择。SpringBoot作为后端框架最大优势是自动配置和生态成熟一个工程建好就能快速开发接口不用像传统SSH那样写大量XML配置。MyBatis作为持久层框架胜在SQL完全可控竞赛管理这种业务里有很多复杂查询比如按竞赛类型统计报名人数、按角色查待办事项直接用SQL写出来最直观排查问题也方便比JPA那种自动生成SQL的方式更好定位问题。前端选Vue3而不是Vue2一方面是因为组合式APIComposition API写起来逻辑更集中另一方面Element Plus等组件库全面拥抱Vue3后台管理系统的表格、表单、弹窗这些常用组件一套就有。Vue3配合Vite开发环境下热更新非常快联调体验比老版本好了不少。MySQL在这个场景下完全够用竞赛管理系统的并发量不会特别高MySQL稳定、轻量、运维简单学校环境里也不需要上更重的数据库。提示这套组合的学习资料非常丰富但版本兼容问题确实存在。SpringBoot 2.x和3.x的差异、Vue3和Element Plus的版本匹配、MyBatis与分页插件的版本对应后面章节我会把容易出问题的坑单独拿出来写清楚。2. 系统整体设计与数据库建模2.1 模块划分与角色权限设计整个系统的模块划分我按业务角色和使用场景拆成了几个相对独立的部分。管理端是给管理员使用的包括竞赛管理、用户管理、数据统计、系统配置。教师端要能审核报名、评审作品、查看自己指导的学生。学生端则是报名竞赛、上传作品、查看个人成绩和通知公告。三个端共用同一套后端接口通过登录用户的角色标识来控制可访问的功能。权限控制这里前端路由守卫和后端接口校验要同时做。前端根据登录用户角色动态生成可访问的路由菜单避免用户看到无权限的操作入口但真正的安全屏障在后端每个需要权限的接口都要用拦截器校验角色。我见过不少项目只在前端隐藏按钮结果有人直接调接口就绕过了权限这在竞赛场景里会造成学生篡改成绩这类严重问题。角色权限的具体实现我用了比较轻量的方式用户表里存角色字段后端写一个自定义注解RequireRole(admin)加在需要权限的Controller方法上再加一个拦截器读取注解并判断当前登录用户的角色实现起来简单也不引入Spring Security这种重量级框架。如果后续权限逻辑复杂了再升级成Spring Security或Sa-Token也不迟。2.2 核心表结构设计与字段取舍数据库设计是整个系统的基础表结构设计不好后面写接口和页面都会很痛苦。我核心设计了五张主表用户表、竞赛表、报名表、作品表、评审表外加一张通知公告表。每张表的字段取舍都是在实践中趟过坑的说说几个关键设计。用户表sys_user除了账号、密码、姓名、角色这些基础字段建议加上avatar头像字段和phone手机号高校场景下很多通知是通过手机号联系的。密码不要明文存用BCrypt加密这是老生常谈但真的别省。竞赛表competition的字段里status状态字段非常关键我设计了DRAFT草稿、PUBLISHED报名中、ONGOING进行中、FINISHED已结束四个状态竞赛的每个状态决定了用户能否报名、能否提交作品这个状态机逻辑必须清晰。CREATE TABLE competition ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键ID, title varchar(200) NOT NULL COMMENT 竞赛名称, type varchar(50) NOT NULL COMMENT 竞赛类型学科/科技/文艺等, level varchar(20) NOT NULL COMMENT 竞赛级别校级/市级/省级/国家级, organizer varchar(100) DEFAULT NULL COMMENT 主办方, description text COMMENT 竞赛介绍, start_time datetime NOT NULL COMMENT 报名开始时间, end_time datetime NOT NULL COMMENT 报名截止时间, status varchar(20) NOT NULL DEFAULT DRAFT COMMENT 状态DRAFT/PUBLISHED/ONGOING/FINISHED, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 COMMENT竞赛信息表;报名表enrollment我加了一个team_name字段虽然很多竞赛是个人报名但团体赛的存在让我在设计时把团队信息考虑进去了。status字段区分PENDING待审核、PASSED通过、REJECTED拒绝报名是否通过由教师或管理员审核。作品表和评审表之间通过work_id关联评审表里存储评委的分数和评语分数用DECIMAL(5,2)类型避免浮点数精度问题。2.3 竞赛审核流转的状态模型最开始我以为状态管理很简单就是一个字段存字符串但实际开发中发现状态流转的逻辑如果不理顺会出现很多莫名其妙的Bug。比如学生提交了作品但竞赛还没到评审阶段这时候评委不应该看到这个作品再比如竞赛已经结束了学生还能报名这明显不合理。所以我把整个竞赛生命周期定义成一条明确的状态链DRAFT草稿→ PUBLISHED报名中→ ONGOING作品提交与评审→ FINISHED已结束后端封装一个状态流转校验的方法每次更新竞赛状态时都检查当前状态能否合法转到目标状态。比如只有PUBLISHED状态且当前时间在报名时间窗口内才允许学生报名只有ONGOING状态才允许提交作品。这个状态机逻辑我写在了Service层而不是散落在各个Controller里这样确保所有入口都走同一个校验流程。前端也需要注意状态联动的展示逻辑竞赛卡片上要显示当前状态标签按钮要根据状态禁用或显示。比如竞赛还是草稿状态时前端要隐藏去报名按钮已结束时显示查看结果。Vue3里这个可以用计算属性根据竞赛状态返回不同的按钮配置比在每个模板里写一堆v-if要清爽得多。3. 后端工程落地SpringBoot与MyBatis的关键细节3.1 项目结构与SpringBoot核心配置后端工程我采用了标准的三层架构Controller层接收请求、Service层处理业务逻辑、Mapper层操作数据库。额外加了一层DTO/VO对象用于参数接收和响应返回避免直接把实体类暴露给前端。目录结构大致是controller、service、mapper、entity、dto、vo、config、interceptor、common这几个包看起来常规但对后续维护来说非常关键。SpringBoot的核心配置都在application.yml里数据源、MyBatis、文件上传限制、日志级别都在这里配置。一个容易踩坑的地方是SpringBoot版本太高导致部分配置项不兼容比如spring.datasource的配置从SpringBoot 2.x到3.x就发生了变化。我用的SpringBoot 2.7.x配合MyBatis Spring Boot Starter 2.2.x这是比较稳定的一套组合。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/competition_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 servlet: multipart: max-file-size: 50MB max-request-size: 50MB jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.competition.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl注意map-underscore-to-camel-case这个配置数据库字段是create_time这种下划线风格Java属性是createTime驼峰风格不开启这个配置查询结果映射到实体类时字段会对不上全是null。这个配置我刚开始漏了排查了半小时才找到原因。生产环境建议把log-impl关掉不然每个SQL都会打印到日志里开发环境开着倒方便调试。3.2 MyBatis分页插件的正确用法竞赛管理系统的列表页非常多竞赛列表、报名列表、作品列表每页显示10条或20条数据这就必须要做分页。手动写SQL里的LIMIT当然可行但每次都要计算起始偏移量而且需要额外查询总数来做分页组件代码重复率太高。我的做法是引入PageHelper分页插件它是国内用得最多的MyBatis分页方案。PageHelper的使用方式很直接在Mapper查询方法之前调用PageHelper.startPage(pageNum, pageSize)然后紧接着执行查询插件会在SQL执行前自动拼接LIMIT子句并且通过拦截器查询总条数最后用PageInfo对象封装分页结果。一个需要注意的地方是startPage只能作用于接下来执行的第一个查询如果你在调用后还执行了别的查询分页会加到那个查询上逻辑就乱了。所以一定要写成调用startPage后立刻执行分页查询这种紧挨着的格式。public PageResultCompetitionVO getCompetitionPage(int pageNum, int pageSize, String keyword) { // 使用PageHelper.startPage开启分页必须是查询前的最后一句 PageHelper.startPage(pageNum, pageSize); ListCompetitionVO list competitionMapper.selectCompetitionList(keyword); // PageInfo里包含了pageNum、pageSize、total、pages等信息 PageInfoCompetitionVO pageInfo new PageInfo(list); PageResultCompetitionVO result new PageResult(); result.setList(pageInfo.getList()); result.setTotal(pageInfo.getTotal()); result.setPageNum(pageInfo.getPageNum()); result.setPageSize(pageInfo.getPageSize()); result.setPages(pageInfo.getPages()); return result; }还有一点要记住PageHelper.startPage如果接收到的pageNum小于1需要自己处理不然可能会出现负数偏移量的SQL。我通常会做一个参数的统一校验pageNum 1时重置为1pageSize 100时限制为100防止有人恶意传大参数拖垮数据库。分页插件还有一个多数据源时的注意事项但竞赛管理系统只有一个数据库这个暂时用不上。3.3 缓存机制与拦截器扩展MyBatis的缓存机制分两级理解清楚对优化查询很有帮助。一级缓存是SqlSession级别同一个SqlSession内同样的查询不会重复查数据库但Spring管理下每次Mapper操作都会新建SqlSession所以一级缓存作用有限。二级缓存是namespace级别也就是Mapper级别的缓存开启后同一个Mapper的查询结果可以跨SqlSession复用对竞赛公告这种几乎不变化的数据很有效。开启MyBatis二级缓存放在对应的Mapper XML文件里加一行cache/同时实体类要实现Serializable接口。但要注意开启二级缓存后如果对表做了增删改操作缓存会自动失效对实时性要求高的数据比如报名人数不要缓存。我在项目里只给字典类数据设置了二级缓存比如竞赛类型、级别这些枚举值对应的字典表。另外我自定义了一个MyBatis拦截器用于自动填充创建时间和更新时间。一开始我在每张表的插入和更新SQL里手动写NOW()后来发现太容易漏。用Intercepts注解标注拦截update和insert操作在SQL执行前动态设置字段值即可。这个思路和MyBatis-Plus的自动填充功能类似但不依赖额外框架一旦用顺手了后续新表加字段都省心。Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) Component public class AutoFillInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; if (parameter null) return invocation.proceed(); // 实体类存在createTime字段则自动填充 setFieldIfExists(parameter, createTime, LocalDateTime.now()); return invocation.proceed(); } }拦截器这块还有一个常见用途是做数据权限过滤。比如教师只能看见自己指导的竞赛和报名记录这个可以在拦截器里动态拼接SQL条件也可以直接在Mapper XML里通过调用UserContext.getCurrentUserId()来实现。我在实际项目中用了后一种方式简单直观缺点是SQL里出现了业务逻辑但现阶段用着更稳妥。4. 前端Vue3后台管理端的落地细节4.1 工程初始化与整体架构前端工程我用Vite创建的Vue3项目相比Vue CLIVite的冷启动速度快非常多开发体验完全不是一个级别。初始化命令很简单npm create vitelatest competition-frontend -- --template vue-ts不过如果你是纯JavaScript党也可以去掉vue-ts使用JS版本。我建议带TypeScript虽然初期写类型定义会多花一点时间但项目变大后组件的props、接口返回数据这些类型校验能省去大量排错时间。整个前端选型是Vue3 Vue Router Pinia Element Plus Axios。Pinia是Vue3官方推荐的状态管理库比Vuex的写法简洁很多去掉了mutations的概念直接在store里定义state和actions用起来非常顺手。我项目里的用户登录状态、用户信息都放在Pinia的userStore里刷新页面后从localStorage恢复。目录结构上src/views下按模块建子目录比如views/admin、views/teacher、views/studentsrc/api目录集中管理所有接口请求函数这个习惯非常重要。我见过很多项目把axios.get直接写在组件里结果接口路径散落各处改个后端地址要全局搜索替换。集中管理后每个模块一个JS文件比如api/competition.js里导出所有竞赛相关的请求方法组件里引用即可。4.2 核心页面与接口联调实现竞赛列表页是整个系统的核心页面集中体现了Vue3组合式API的优势。页面逻辑包含三块搜索条件表单、数据表格、分页组件。搜索条件是竞赛名称和状态表格展示竞赛标题、类型、级别、报名时间、状态、操作按钮操作按钮根据状态显示编辑、发布、查看报名等不同动作。用组合式API我可以把搜索条件、表格数据、分页信息、加载方法放在一个setup函数里逻辑内聚且清晰。一个核心点是onMounted时调用加载方法但搜索条件的监听要用watch而不是在搜索按钮上手动绑定事件这样更符合响应式思维。表格列里如果展示状态我会用Element Plus的el-tag标签通过一个映射函数把状态码转成对应的标签类型和文字比如DRAFT显示灰色草稿PUBLISHED显示蓝色报名中一眼就能看出竞赛当前处于哪个阶段。接口联调上前端请求后端接口时统一走封装好的request.js这个模块基于Axios封装了baseURL、请求拦截器、响应拦截器。请求拦截器在每次请求前从localStorage读token并加到请求头响应拦截器判断HTTP状态码和后端返回的code如果code表示未登录比如401就跳转登录页并给出提示。这样每个请求方法只要关注业务数据本身不需要重复处理错误提示和登录失效逻辑。// api/competition.js import request from /utils/request export function getCompetitionPage(params) { return request({ url: /api/competition/page, method: get, params }) } export function createCompetition(data) { return request({ url: /api/competition, method: post, data }) }4.3 Axios封装与权限控制Axios封装这块最容易出问题的是响应数据格式统一。后端接口返回的数据我固定用{ code, message, data }这个结构code为200表示成功非200则前端弹出message作为错误提示。这个规范必须在后端接口设计时统一好不然后端每个接口返回格式五花八门前端拦截器写起来非常痛苦。权限控制方面我实现了路由级别的动态菜单。用户登录后后端返回该用户的角色和权限列表前端根据权限列表过滤静态路由表生成当前用户可访问的动态路由。Vue Router提供了addRoute方法可以在登录成功后动态添加路由。学生登录时看不到管理后台的竞赛审核菜单教师登录时看不到用户管理菜单。前端权限只能起到优化体验的作用真正的安全校验一定在后端。我在前端会把用户角色存在Pinia和localStorage里用于控制页面和按钮的显示但每个接口调用后端时后端的拦截器还会再校验一次token和角色。之前遇到过一种情况是前端漏加了路由守卫用户不通过登录页直接访问某个URL页面虽然能打开但接口返回401触发axios拦截器自动跳回登录页这种体验依然是有保护的。5. 从零跑通完整流程环境准备与联调实录5.1 开发环境准备与初始化步骤要把这套系统跑起来开发环境主要有四样JDK 8或11、MySQL 5.7或8.0、Node.js 14以上、Maven 3.6以上。JDK版本不要追新SpringBoot 2.7.x配JDK 8是久经考验的组合用JDK 17以上可能遇到一些不兼容的依赖。MySQL建议装8.0驱动类名注意要写com.mysql.cj.jdbc.Driver这是MySQL 8.0的驱动类老版com.mysql.jdbc.Driver在8.0里已经废弃了。前端我用的是Element Plus版本上要确认是2.x以上才支持Vue3。如果你用npm install element-plus直接安装默认就是最新版没什么问题。但要注意如果项目初始化时选了Vue2的模板Element Plus会安装失败或运行报错这个在项目初始化时就要确认清楚。用Vite创建项目后官方的模板选择里要选vue或vue-ts不会生成Vue2项目这个坑相对少见。数据库初始化我把建库建表脚本放在sql/init.sql里包含所有主表和初始管理员账号用户名admin、密码123456用BCrypt加密后写死在SQL的insert语句里方便首次启动就能登录系统。同时预置一部分竞赛类型字典数据比如学科竞赛、创新创业、文体竞赛等避免第一次登录后系统里空荡荡的。5.2 一条完整业务链路的接口与页面联调我以一个典型的校级编程竞赛为例说明从后端到前端、从管理员到学生的完整业务链路怎么走通。这条链路同时也是我测试系统功能是否完善的标准一头走到尾基本功能就齐了。第一步管理员登录后创建竞赛填写竞赛名称2024校园程序设计大赛、类型选学科竞赛、级别校级、组织者计算机学院、报名开始和截止时间状态先存草稿。后端对应POST /api/competition接口CompetitionService里将status设为DRAFT并插入数据库。前端竞赛列表页刷新后这条记录以灰色草稿标签展示。第二步管理员发布竞赛状态从DRAFT流转到PUBLISHED。前端点击发布按钮调用PUT /api/competition/{id}/publish后端校验当前状态必须是DRAFT才能发布发布成功后竞赛在学生端的进行中的竞赛列表中出现学生可以报名。第三步学生注册登录后浏览竞赛详情点击立即报名填写团队名称和联系方式后提交。报名接口写入enrollment表状态为PENDING。第四步教师端收到待审核报名列表审核通过后学生端报名状态变为已通过学生可以上传作品。第五步竞赛截止后管理员把竞赛状态更新为ONGOING评委可以查看所有已通过报名学生的作品并打分。每个评委对每个作品只能评一次评审表通过work_id和reviewer_id联合唯一约束来保证。第六步评审完成后管理员把状态改为FINISHED系统自动汇总每个作品的平均分或总分学生端可以看到自己的成绩和排名。这条链路全部走通说明后端接口、前端页面、数据库表之间的关联关系都是正确的。6. 常见问题速查与个人避坑心得6.1 高频问题排查表开发过程中踩坑无数很多问题在网上查资料时都有人遇到但解法分散这里把我遇到的高频问题整理成一个速查表按错误现象、原因、解决方案三个维度列出来方便你直接对照排查。错误现象根本原因解决方案数据库连接失败报Access denied for user rootlocalhostMySQL账号密码错误或root账号不允许远程连接本地环境检查密码远程连接需授权GRANT ALL PRIVILEGES ON *.* TO root%查询结果返回字段全是null未开启MyBatis驼峰映射mybatis.configuration.map-underscore-to-camel-case: true分页查询total始终为0未在同一条Mapper操作前调用startPage或分页前有别的查询确保startPage后紧跟分页查询中间不执行其他查询前端请求接口跨域被拦截前后端不同端口未配置CORS后端添加CORS配置类允许指定来源allowedOriginPatterns(*)Vue3引入Element Plus组件不生效版本不兼容或未完整安装插件完整引入app.use(ElementPlus)检查element-plus版本为2.x文件上传报MaxUploadSizeExceededExceptionSpringBoot默认上传限制为1MB在application.yml中配置spring.servlet.multipart.max-file-size大小com.mysql.jdbc.Driver找不到MySQL 8.0驱动类名已变使用com.mysql.cj.jdbc.Driver并检查驱动依赖版本token失效但前端没有跳转登录页响应拦截器未处理401状态码在axios响应拦截器中判断HTTP状态码为401时清除本地用户信息并跳转/login6.2 我踩过的几个坑数据库连接串的时区问题记得加到serverTimezoneAsia/Shanghai。我第一次用MySQL 8.0时没加时区参数项目启动报错信息像天书一样搜了半天才发现是时区引起的。加了这个参数再配合JVM时区设置时间存储和展示才正常。如果不用JDBC连接的serverTimezone参数也可以在MySQL端执行SET GLOBAL time_zone 8:00但推荐在连接串里写清楚工程迁移时不会因为数据库环境不同而报错。前端报错排查时Vue3的报错信息比Vue2要详细但也更啰嗦。我的习惯是看编译信息里的第一条忽略后面的堆栈绝大多数是组件里某个属性没定义或某个方法不存在。Element Plus表单校验时v-model绑定的字段名要和rules里的字段名一致不一致的表单会不触发校验但又不会报错这种静默失败最让人头疼。再分享一个后端的事务经验报名操作包含两步插入报名记录和更新竞赛当前报名人数这两步必须放在同一个Transactional事务里。一开始没加事务注解出现并发报名时数据对不上加了之后才能保证要么都成功要么都回滚。所有涉及多表写入操作的接口务必要加上事务控制这是最容易被忽略但非常重要的习惯。还有一个关于接口参数校验的教训。我最初创建竞赛时前端传过来的start_time和end_time没有做非空和先后顺序校验结果有一条数据报名结束时间比开始时间还早导致前端时间选择器逻辑混乱学生端明明已经过了截止时间但状态还显示报名中。后来在后端CompetitionService里加了时间校验如果endTime早于startTime直接抛出业务异常并提示报名截止时间不能早于开始时间。这类业务规则校验一定要在后端做不能依赖前端因为API完全可能被绕过数据合法性和业务正确性必须由后端兜底。根据我个人经验如果你打算把这类项目投入到正式使用还有两个细节值得提前考虑。一个是文件存储作品上传如果直接用本地磁盘存储服务器重启后文件路径可能变化建议在配置里单独定义上传目录最好用对象存储服务替代本地磁盘。另一个是数据备份MySQL定时备份很重要哪怕最简单的方式每天凌晨用mysqldump导出一次SQL文件也能在关键时刻保住整个竞赛周期的数据。这些都是上线之后才会意识到的问题最好在开发阶段就规划进去。项目做到现在我的体会是高校竞赛管理系统这类业务本质上是信息流转系统把合适的消息在合适的时间推给合适的人技术上没有什么高不可攀的地方真正考验人的是对业务流程的理解和细心的数据建模。如果你正打算开发类似系统强烈建议先把业务流程图手画一遍再开始写数据库SQL思路理顺了后面的开发会顺畅很多。