1. 为什么我要做这个竞赛管理系统从教务员的表格地狱说起每到竞赛季高校里就会上演一场几乎没有悬念的混乱教务处发通知各学院教务员下载Excel报名表学生填完发回去教务员手动汇总再按比赛项目拆分给评委评委打完分用微信或邮件传回Excel最后再人工汇总排名。中间只要有一个学院改一下选手名单整个表格链条就要重来一遍。更麻烦的是同一个学生报名多个赛项、跨学院组队、作品文件名不规范、截止时间傻傻分不清楚这些情况让一个简单的事情变成了每周都要花两天处理的负担。这套企业级高校竞赛管理系统源码就是冲着这些问题去的。它基于SpringBoot Vue MyBatis MySQL这套主流前后端分离架构实现覆盖了竞赛从“发布—报名—作品提交—评审—成绩公示—证书导出”的完整闭环。你拿到源码后不需要从零搭框架把数据库脚本导入、改一下配置文件、启动前后端就能跑起来一套可用的竞赛管理平台。如果你是做课程设计、毕业设计或者是学院里负责竞赛管理的老师和学生团队这套源码都是很好的起点。一方面它的业务场景足够真实面试时能讲清楚的东西很多另一方面它的技术栈非常主流SpringBoot、Vue、MyBatis、MySQL这些关键词在招聘需求里出现频率非常高。我写这篇文章主要是把我在搭建这个系统时的设计思路、表结构规划、关键代码实现、环境配置过程和踩过的坑都梳理一遍给你一个能照着走的完整参考。所谓“企业级”并不是说用了什么高深的技术而是从权限控制、接口规范、异常处理、部署方式这些工程细节上按真实业务标准来做而不是把几个增删改查页面拼在一起。2. 技术栈分工SpringBoot、Vue、MyBatis、MySQL各自承担什么很多人拿到一套源码第一反应是“先把项目跑起来”但如果不理解各个组件之间的边界后面改需求、加功能的时候就会乱套。我先花点篇幅讲清楚这套架构里每个部分的职责这对你二次开发很重要。2.1 前后端分离架构是怎么分工的这套系统采用的是典型的前后端分离模式。前端是一个 Vue 工程负责页面渲染、表单校验、交互反馈后端是一个 SpringBoot 工程提供 RESTful API统一接收前端请求处理后返回 JSON 数据MyBatis 是后端的持久层框架负责把 Java 代码里的方法调用翻译成 SQL 语句与 MySQL 数据库交互。打个比方Vue 是餐厅的前厅负责接待、点单、上菜SpringBoot 是后厨和前厅之间的调度台接到菜单后安排做菜MyBatis 是传菜口的菜单翻译官把调度台的指令翻译成厨师MySQL能听懂的具体做法。每一层各管各的事哪一层出问题都能单独排查。这套系统选择前后端分离还有一个实际好处以后如果想出一个微信小程序端后端接口完全可以复用只需要另写一套小程序前端就行。2.2 后端选型为什么是 SpringBoot后端框架我在早期考虑过两个方向一个是 SSM 全手动整合Spring SpringMVC MyBatis另一个是 SpringBoot。最后选定 SpringBoot最核心的原因是它内置了 Tomcat不需要再单独配置外部容器并且自动配置机制把大量样板配置省掉了。竞赛管理系统虽然业务不算特别复杂但同样涉及登录鉴权、文件上传、定时任务、邮件通知这些常见需求SpringBoot 的 starter 体系能帮你快速集成这些组件。另外一点很现实SpringBoot 的人才生态和学习资料是所有 Java 后端框架里最丰富的遇到问题在社区里基本都能搜到答案。对做课程设计或毕设的同学来说这个因素非常重要。2.3 ORM 选型MyBatis 比 JPA 更适合这个场景MyBatis 和 JPAHibernate的争论由来已久。在竞赛管理这个场景里我更推荐 MyBatis原因有三第一竞赛管理系统里有大量动态查询需求。比如报名列表要按“竞赛名称 学院 报名状态 报名时间段”任意组合筛选这种动态 SQL 在 MyBatis 的 XML 里用if标签非常顺手JPA 则需要写 Specification 或 QueryDSL心智负担更大。第二管理类系统的核心价值就是数据报表。竞赛报名人数统计、各学院参赛率、各赛项获奖分布这些 SQL 往往需要多表 JOIN 和条件聚合MyBatis 直接把 SQL 抓在开发者手里调优更方便。第三MyBatis 的学习曲线比 JPA 平缓对于水平层次不齐的团队来说新人上手成本低很多。我在后面的章节中会详细讲 MyBatis 的分页插件、缓存和动态 SQL 用法这些都是本项目里实际用到的点。2.4 数据库MySQL 为什么不二之选MySQL 在高校和中小型项目中的普及度就不用多说了。竞赛管理系统的数据量级正常一年下来也就是几万条报名记录、几千个作品文件MySQL 对这种量级完全绰绰有余。选择 MySQL 8.0 版本的话还能用到窗口函数、JSON 类型这些比较现代的特性例如统计各个竞赛项目的报名人数用窗口函数可以写得很优雅。需要提醒一个细节MySQL 5.7 和 8.0 的 JDBC 驱动类名不一样5.7 用的是com.mysql.jdbc.Driver8.0 用的是com.mysql.cj.jdbc.Driver。如果你下载的源码默认按 8.0 配置但本机装的是 5.7启动时会直接报驱动类错误这个坑我在后面的部署章节会再强调一遍。技术组件职责定位本项目中的具体作用SpringBoot应用容器 接口框架提供 RESTful API、统一异常处理、事务管理、定时任务Vue前端渲染与交互页面路由、登录态管理、表单校验、文件上传组件MyBatisSQL 映射与持久层框架动态 SQL、分页查询、多表联查、批量插入MySQL数据存储用户、角色、竞赛、队伍、作品、评分等核心数据落库3. 核心数据表设计思路比赛、报名、作品、评分怎么建模数据库设计是整个系统的地基。表设计得合理后面写业务代码非常顺设计不合理到了写“报名人数统计”这种功能时就会发现各种表之间对不上恨不得重新开始。我对这套系统的表结构规划是围绕业务生命周期来拆分的。3.1 六大核心业务域我把整个系统的表结构划分为六个域系统管理域、竞赛管理域、队伍与报名域、作品域、评分域、通知域。系统管理域用户表、角色表、菜单表、用户角色关联表。这一套是经典的 RBAC 权限模型用来支撑管理员、教师、学生、评委这四类角色的登录和权限控制。竞赛管理域竞赛主表、竞赛类别字典。记录竞赛名称、级别国家级/省级/校级、类别学科竞赛/创新创业/文体活动、主办单位、报名起止时间、竞赛时间、状态草稿/报名中/评审中/已结束。队伍与报名域队伍表、队员表、报名记录表。支持一个人或多人组队报名队长创建队伍后可以邀请队员加入。作品域作品表。记录作品名称、作品文件路径、作品简介、提交时间、版本号支持学生在截止时间前多次覆盖上传新版本。评分域评分表、评分项表。支持多个评委对同一支队伍从不同维度创新性、完成度、实用性、现场表现打分最终取平均分或加权分。通知域站内信表。系统在报名成功、作品提交、成绩公布等节点自动给相关用户发送消息。3.2 核心表结构示例竞赛表、队伍表、评分表下面给出三张核心表的建表 SQL以 MySQL 8.0 为例。完整的源码里还有更多表和字段这里挑关键部分展示。CREATE TABLE competition ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, name VARCHAR(100) NOT NULL COMMENT 竞赛名称, category VARCHAR(50) NOT NULL COMMENT 竞赛类别, level TINYINT NOT NULL COMMENT 竞赛级别1国家级 2省级 3校级, org_name VARCHAR(100) DEFAULT NULL COMMENT 主办单位, description TEXT COMMENT 竞赛简介, apply_start_time DATETIME DEFAULT NULL COMMENT 报名开始时间, apply_end_time DATETIME DEFAULT NULL COMMENT 报名截止时间, competition_time DATETIME DEFAULT NULL COMMENT 竞赛开始时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0草稿 1报名中 2评审中 3已结束 4已取消, create_by BIGINT DEFAULT NULL COMMENT 创建人, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_status (status), KEY idx_apply_end_time (apply_end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT竞赛信息表;这里有几个我特别想强调的设计细节第一状态为什么用TINYINT而不是直接用字符串。之前我也图省事用过VARCHAR存“报名中”“已结束”后来发现一旦需要改状态名要么写一堆 UPDATE 语句要么改业务代码。用数字配合枚举类状态流转用代码统一控制展示层的名字随时可以改。第二时间字段统一用DATETIME。TIMESTAMP有 2038 年问题和时区换算问题DATETIME不依赖数据库时区设置只要后端在存入前统一使用当前系统时间就不会出现隔 8 小时、差 13 小时这种诡异问题。第三索引不是越多越好但查询频率高的字段一定要建。status和apply_end_time是系统首页“正在报名的竞赛列表”这个高频查询的过滤条件所以加索引create_time这种只是用来排序的字段数据量上来之前可以先不加。队伍表和评分表的设计思路更贴近业务逻辑CREATE TABLE team ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, competition_id BIGINT NOT NULL COMMENT 竞赛ID, team_name VARCHAR(100) NOT NULL COMMENT 队伍名称, captain_id BIGINT NOT NULL COMMENT 队长用户ID, member_count INT NOT NULL DEFAULT 1 COMMENT 队员人数, review_status TINYINT NOT NULL DEFAULT 0 COMMENT 审核状态0待审核 1已通过 2已拒绝, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_competition_team (competition_id, team_name), KEY idx_captain (captain_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT参赛队伍表;队伍表里我建了一个uk_competition_team联合唯一索引保证同一个竞赛下不允许出现同名队伍。这个索引在应用层判断之外又加了一道数据库层面的保障防止高并发下两个人同时创建同名队伍导致数据错乱。评分表是评委打分功能的落点CREATE TABLE score ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, competition_id BIGINT NOT NULL COMMENT 竞赛ID, team_id BIGINT NOT NULL COMMENT 队伍ID, judge_id BIGINT NOT NULL COMMENT 评委用户ID, score_item_id BIGINT NOT NULL COMMENT 评分项ID, score DECIMAL(5,2) NOT NULL COMMENT 得分, comment VARCHAR(500) DEFAULT NULL COMMENT 评语, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 打分时间, PRIMARY KEY (id), UNIQUE KEY uk_judge_team_item (team_id, judge_id, score_item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评分明细表;uk_judge_team_item这个联合唯一索引很关键它保证同一个评委对同一支队伍的同一个评分项只能打一次分重试提交时会触发 SQL 层报错然后由后端捕获并返回“您已打过分”的提示而不是生成多条脏数据。3.3 用户角色与权限表四类角色一套模型用户权限这块我不建议把每个角色都建一张表那会给查询带来很大麻烦。标准做法是五张表用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。前端的路由菜单、按钮级别的权限点都能用这套模型控制。本系统的四类角色职责角色主要权限系统管理员用户管理、角色分配、所有竞赛的增删改查、系统配置学院管理员管理本院学生的账号查看、本院报名数据的导出、本院竞赛数据查看教师/评委创建竞赛、审核报名、管理作品、打分评审学生查看竞赛、组队报名、上传作品、查看个人成绩4. 后端编码的关键环节接口规范、MyBatis实践与异常处理表结构确定之后后端编码才能真正展开。这一章我重点讲三个部分RESTful 接口怎么规划、MyBatis 的分页和缓存怎么用才不出问题、全局异常处理和事务控制怎么做。4.1 统一返回结构与全局异常前后端分离项目里如果每个接口返回的数据结构都不一样前端处理起来会非常痛苦。我在项目里定义了一个通用的返回类型ResultT所有接口统一返回它public class ResultT { private Integer code; // 200成功500异常401未登录 private String message; // 提示信息 private T data; // 实际业务数据 public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(Integer code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }与之配套的是一个全局异常处理器用RestControllerAdvice统一兜底。这样业务代码里想报错只需要抛一个自定义的业务异常不用在每个接口里写 try-catchRestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BusinessException.class) public Result? handleBusinessException(BusinessException e) { return Result.error(e.getCode(), e.getMessage()); } ExceptionHandler(Exception.class) public Result? handleException(Exception e) { log.error(系统异常, e); return Result.error(500, 系统繁忙请稍后重试); } }这个规范看起来很基础但很多课程设计级别的项目里根本看不到接口里到处是裸返回的 Map。有了这一层前端 axios 的响应拦截器只需要判断code 200其它情况直接弹 message逻辑非常统一。4.2 MyBatis 的 XML 映射和动态 SQL 实战MyBatis 在本项目里主要用在两个场景单表简单操作直接用注解复杂查询写 XML。注解方式比如通过主键查用户、更新报名状态这类代码非常少但像“查询某个竞赛的报名列表支持按队伍名称和审核状态筛选并且关联出队长姓名和队伍人数”这种就必须用 XML 动态 SQLselect idselectApplyList resultTypecom.example.vo.ApplyVO SELECT t.id, t.team_name, t.review_status, t.member_count, u.name AS captain_name, c.name AS competition_name FROM team t LEFT JOIN user u ON t.captain_id u.id LEFT JOIN competition c ON t.competition_id c.id where if testcompetitionId ! null AND t.competition_id #{competitionId} /if if testteamName ! null and teamName ! AND t.team_name LIKE CONCAT(%, #{teamName}, %) /if if testreviewStatus ! null AND t.review_status #{reviewStatus} /if /where ORDER BY t.create_time DESC /selectwhere标签会自动处理掉第一个条件前面的AND这个细节很多人初学时不理解以为必须手动写WHERE 11其实完全没必要MyBatis 已经处理好了。4.3 分页插件是怎么用的为什么有时会失效管理系统里“列表页 分页”是最常见的需求。我这里用的是MyBatis 分页插件 PageHelper用法比较直接PageHelper.startPage(pageNum, pageSize); ListApplyVO list applyMapper.selectApplyList(query); PageInfoApplyVO pageInfo new PageInfo(list);核心思路就是在你要执行的查询方法前调用PageHelper.startPage()它会拦截下一条 SQL自动拼接LIMIT ? OFFSET ?然后查出总数封装到PageInfo里。但是这里有几个坑我必须提醒你第一startPage()之后只能跟一条查询语句如果你中间还执行了别的查询分页逻辑就乱了。我见过有人在一个方法里先查了个字典表再查列表结果分页总数完全不对。第二多表 JOIN 查询时PageHelper 生成的 count 语句偶尔会因为不够智能而出错。解决办法是手动指定 count 查询或者把查询拆成两步先分页查主表 ID再查详情这在大数据量场景下性能反而更好。第三使用 PageHelper 一定要确保它和 MyBatis 的版本兼容。SpringBoot 3.x MyBatis 3.5.x 的整合方式和老版本有些差异如果你在 XML 里配置拦截器的方式不对运行时会报PageHelper无法被加载的错误。4.4 MyBatis 缓存的理解竞赛系统里该不该开二级缓存热词里反复出现“mybatis缓存”这里我也把缓存在本系统中的应用讲清楚。MyBatis 的一级缓存默认是开启的作用于同一个 SqlSession。但在 SpringBoot 整合环境下每次 Mapper 方法执行都会新开和关闭 SqlSession所以一级缓存的存在感很弱无需特别配置。二级缓存是跨 SqlSession 的配置后同一个 namespace 下的查询结果可以被多个 SqlSession 共享。听起来不错但我在这套系统里强烈建议不要开启二级缓存或者只对极少数的字典表开启。原因有两点第一竞赛管理数据是典型的“读多改也多”场景。报名一旦进入审核阶段队长的手机号、队员名单都会变实体缓存很容易脏。一旦某个用户查到了旧数据会直接影响报名操作。第二MyBatis 的二级缓存默认粒度非常粗一个 namespace 对应一张表的全部查询结果如果这个表参与了 JOIN 查询缓存失效策略很难处理精准。所以如果真想在报名列表这种热点接口上做性能优化我更推荐在 Service 层加 Spring Cache用Cacheable注解做细粒度的缓存并且设置较短的过期时间比如报名截止前五分钟内的数据允许短时间缓存。4.5 报名接口的事务与并发控制“报名”这个动作表面上是插入一条队伍记录和几条队员记录但实际涉及多个表的写操作。如果第一个表插成功了、第二个表插失败了数据库里就会出现只有队长没有队员的脏队伍。所以我给报名接口加上了Transactional事务注解任何一步异常都整体回滚。另外还有一个高并发问题同一个竞赛报名截止时间的最后十分钟学生会集中点击“提交报名”。如果不加控制可能出现同一个学号被成功插入到多支队伍的情况。除了在队员表上建uk_member_student唯一索引兜底之外我还会在 Service 层做一次前置校验并且合理利用数据库锁。这里用到的核心手段就是唯一索引兜底 前置检查 事务三层保证数据不会乱。4.6 登录鉴权和文件上传的注意事项登录鉴权我用的是 JWTJSON Web Token方案。用户登录成功后后端生成一个 token 返回给前端前端存到 localStorage 或者状态管理库里。后续每次请求在 Header 里带上Authorization: Bearer token后端写一个拦截器统一解析 token解析成功就放行失败就返回 401。这里有一个非常容易踩的坑要放行登录接口和部分公开的查询接口比如“获取竞赛列表”和“查看竞赛详情”这些接口学生未登录也能看。拦截器配置里要维护一个白名单否则前端开发联调时经常被未登录弹窗打断。文件上传也是竞赛系统的高频功能。学生提交作品时后端要校验文件扩展名防止有人传 exe 或 js 伪装成 pdf、限制文件大小一般单个作品不超过 50MB、按日期分目录存储避免单目录文件过多。我在配置里限制了 SpringBoot 的上传大小spring: servlet: multipart: max-file-size: 50MB max-request-size: 200MB前端的 Upload 组件也要同步限制否则用户等到文件传完才收到超限提示体验很差。5. Vue前端与后端联调路由、状态、跨域这些细节后端接口写好了前端才能开始真正的工作。我在这套系统里使用 Vue 2 Element UI 作为默认实现因为它的社区资料最丰富、对新人最友好遇到问题基本都能查到解决方案。如果你下载的源码是 Vue 3 Element Plus 版本核心思路是一样的只是 API 细节有差异。5.1 前端工程结构和路由规划前端工程入口是src/main.js涉及的主要目录有src/api目录按业务模块封装的接口调用函数例如competition.js、user.jssrc/router目录路由配置和导航守卫src/store目录Vuex 的登录状态管理src/views目录页面组件按模块分子目录。路由规划是这样的未登录用户访问/login登录页、/competition竞赛列表页、/competition/:id竞赛详情页登录后可以访问/dashboard个人工作台、/competition/:id/apply报名页、/work/upload作品提交页、/score打分管理页。我在路由的meta里配置了roles字段配合全局前置守卫做权限控制router.beforeEach((to, from, next) { const token store.state.token; if (to.path ! /login !token) { next(/login); } else if (to.meta.roles !to.meta.roles.includes(store.state.user.role)) { next(/403); } else { next(); } });5.2 axios 封装请求拦截器加 token响应拦截器统一处理 code如果每个组件里都直接http.get(...)后期一旦需要统一处理 401 跳转、错误弹窗都要改一遍。所以我把 axios 实例单独封装在src/utils/request.js里import axios from axios; import { Message } from element-ui; import router from /router; const request axios.create({ baseURL: process.env.VUE_APP_BASE_URL || /api, timeout: 10000 }); request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); request.interceptors.response.use( response { const res response.data; if (res.code ! 200) { Message.error(res.message || 请求失败); if (res.code 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(new Error(res.message)); } return res; }, error { Message.error(error.message || 网络异常); return Promise.reject(error); } );这样每个具体的业务接口只需要关心成功返回的res.data其它逻辑全部收敛到拦截器里。5.3 跨域的两种解决方式代理与Nginx前后端分离开发中最常见的问题就是跨域。前端跑在http://localhost:8080后端跑在http://localhost:9000前端直接发请求必然被浏览器拦截。开发环境我在vue.config.js里配置了 devServer 代理module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:9000, changeOrigin: true, pathRewrite: { ^/api: /api } } } } };生产环境则用 Nginx 做反向代理把/api开头的请求转发到后端的 Java 进程。这两种方式的核心思路是一样的让浏览器始终只访问前端自己的域名由代理把请求转发给后端从根源上规避跨域限制。我不建议在后端直接把 CORS 配置成allowOrigins(*)那在带 Cookie 认证的项目里会有安全问题。5.4 作品上传组件配置 action、携带 token、限制格式用 Element UI 的 Upload 组件做文件上传很简单但有两个细节要注意一是 upload 组件默认提交时不带自定义 Header需要手动设置headers参数把 token 带上否则后端拦截器会判定未登录把上传请求拦掉el-upload action/api/work/upload :headersuploadHeaders :before-uploadbeforeUpload :on-successhandleUploadSuccess namefile /el-uploaddata() { return { uploadHeaders: { Authorization: Bearer ${localStorage.getItem(token)} } }; }二是before-upload钩子里一定要校验文件类型和大小在文件还没有真正上传之前就拦住不合法请求既省流量又提升用户体验beforeUpload(file) { const allowTypes [pdf, zip, rar, doc, docx]; const ext file.name.split(.).pop().toLowerCase(); if (!allowTypes.includes(ext)) { this.$message.error(不支持的文件格式); return false; } if (file.size 50 * 1024 * 1024) { this.$message.error(文件大小不能超过50MB); return false; } return true; }5.5 竞赛列表页的倒计时与动态路由跳转竞赛列表页我做了卡片式展示每张卡片显示竞赛名称、级别标签、报名截止时间还有实时倒计时。倒计时用setInterval每一秒更新一次组件销毁的时候记得clearInterval清除定时器否则页面在后台挂着一直计算会造成不必要的内存消耗。从列表页跳到详情页我用的是动态路由传参// 列表页跳转 this.$router.push({ path: /competition/${row.id} }); // 详情页取参数 const competitionId this.$route.params.id;这里要注意params方式传参如果用户直接刷新详情页参数不会丢失因为参数在 URL 里而如果你用query方式传一个对象类型的数据刷新后可能因为序列化问题变成[object Object]。所以动态路由参数尽量从 URL 的params中获取对象类型的数据不要塞进路由参数里应该调用接口重新获取。6. 环境搭建与启动部署从零跑通这套系统的完整过程一套源码拿到手第一步永远是让它在本机跑起来然后才谈得上读代码、改需求。这一章我按实际步骤写清楚整个环境搭建过程尽量覆盖常见的坑。6.1 环境准备清单JDK 1.8 或 11推荐 JDK 8兼容性最好Maven 3.6Node.js 14.xVue 2 项目建议使用 Node 14 或 16Node 20 版本太高可能报依赖兼容问题MySQL 5.7 或 8.0IDEA后端开发、VSCode 或 WebStorm前端开发Nginx可选生产部署用6.2 数据库初始化在 MySQL 中新建数据库并导入源码里提供的sql脚本CREATE DATABASE IF NOT EXISTS competition_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE competition_system; SOURCE /path/to/sql/init.sql;导入完成后可以执行SHOW TABLES;验证一下应该能看到用户表、竞赛表、队伍表等十几张表。6.3 后端配置和启动修改application.yml中数据库连接信息这是整个启动过程最容易出问题的地方server: port: 9000 spring: datasource: url: jdbc:mysql://localhost:3306/competition_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true这里几个配置项的作用我解释一下serverTimezoneAsia/Shanghai是必须的否则 JDBC 连接时会报“无法识别服务器时区”的错误useSSLfalse能避免 SSL 握手警告allowPublicKeyRetrievaltrue是 MySQL 8.0 加密连接时需要的选项不加可能报 Public Key Retrieval 错误。对了如果你本机装的是 MySQL 5.7要把driver-class-name改成com.mysql.jdbc.Driver并且 URL 里不要带allowPublicKeyRetrieval这个参数。修改完成后在 IDEA 里直接运行启动类的main方法控制台出现Tomcat started on port(s): 9000就说明后端启动成功。可以用curl http://localhost:9000/api/competition/list快速验证。6.4 前端安装依赖和启动进入前端工程目录安装依赖npm install npm run devnpm install这一步是前端最常见的坑。如果你用的是 Node 16 以上老项目里的node-sass可能编译失败。稳妥的做法是优先使用源码里自带的package-lock.json的版本如果实在报错可以把node-sass替换成sass或者把package.json里依赖版本改成与你 Node 版本兼容的版本。如果下载依赖速度慢先配置淘宝镜像npm config set registry https://registry.npmmirror.com启动后浏览器访问http://localhost:8080能打开登录页就说明前后端联调通了。6.5 生产部署构建后端 jar 包和前端静态文件本地跑通之后部署到服务器就是另一个故事了但其实思路很简单。后端打包成可执行 jarmvn clean package -DskipTests java -jar target/competition-system.jar --spring.profiles.activeprod前端执行构建生成dist静态目录npm run build然后把dist目录放到 Nginx 的站点目录里并配置代理转发server { listen 80; server_name your-domain.com; location / { root /opt/competition/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:9000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html这一行很关键。Vue 是单页应用使用 history 模式路由时如果用户直接访问https://your-domain.com/competition/123这个地址Nginx 找不到对应的静态文件就会 404。加上这一行后所有路由都会回退到index.html由前端路由自己解析 URL。6.6 一键启动脚本为了方便日常开发我写了一个简单的启动脚本避免每次都要手动起两个进程#!/bin/bash # dev_start.sh # 启动后端 echo Starting SpringBoot backend... cd /opt/competition/backend nohup java -jar competition-system.jar logs/backend.log 21 # 启动前端开发服务或直接构建后由Nginx托管 echo Starting Vue frontend... cd /opt/competition/frontend npm run serve生产环境更推荐用 systemd 管理后端进程设置开机自启和崩溃自动重启比nohup更可靠。如果项目是给学院长期使用的不要嫌麻烦这一步值得做。7. 从“能跑”到“好用”我给这套系统做的几个关键加固一套源码如果只是“能跑”是没有竞争力的。我在实际使用过程中发现有几个细节是竞赛系统真正“好用”的分水岭分享给你做参考。7.1 评审盲评评委不应该看到选手姓名高校竞赛的公平性要求很多时候比商业项目更严格所以在评分模块里我专门做了“盲评”设计。评委打分时列表上显示的是队伍编号和作品名称而非真实姓名和学号。这个功能的实现不复杂只要在查询打分列表的 SQL 里不关联用户姓名并给队伍生成一个短编号即可SELECT t.id AS team_id, CONCAT(T, LPAD(t.id, 4, 0)) AS team_no, w.name AS work_name, ...这个细节在课程设计中很亮眼因为绝大多数人做竞赛管理系统根本想不到这一层。真实比赛里评委名单和选手姓名是脱敏的这样做既公平又避免了很多不必要的麻烦。7.2 数据权限学院管理员只能看自己学院的数据系统里“系统管理员”和“学院管理员”是两个不同层级的账号。学院管理员登录后默认只能看到本学院学生的报名记录没有全局数据的权限。这个需求在真实场景中非常重要否则学校有二十个学院每个学院的负责人都能看到全校报名数据信息就失控了。实现上我在队伍表里冗余了一个college_id字段查列表时自动拼上当前登录用户的学院条件在服务端过滤而不是靠前端隐藏按钮实现。前端隐藏只是视觉效果请求还是能发出去安全边界必须放在后端。7.3 作品上传的覆盖与版本记录竞赛作品提交有个高频场景学生先交了一个半成品截止前又改了新版本。如果系统只做“覆盖”操作一旦学生误传了文件又没有保留原版老师也很头疼。我这里是每次上传都保留历史版本作品表加了一个version字段重复提交时版本号加 1记录新的文件路径同时保留前一个版本的路径。学生端能看到历史的提交记录老师审核时也能看到“版本 1 提交于 10月12日版本 2 提交于 10月19日”。这个功能不算复杂但对使用体验提升非常大。7.4 报名截止的兜底判断报名时间的校验绝对不能只依赖前端倒计时。恶意用户完全可以绕过前端直接调接口。所以后端在报名接口里又做了一层校验用数据库当前时间和apply_end_time比较if (LocalDateTime.now().isAfter(endTime)) { throw new BusinessException(400, 报名已截止); }更严谨的做法是直接用UPDATE competition SET status 已结束 WHERE id ? AND status 报名中 AND apply_end_time NOW()这种原子操作去锁状态避免并发时判断时差的问题。项目里我至少保证在 Service 层有这层判断数据库的索引和唯一约束做兜底。7.5 后续可以怎么扩展这套系统的扩展空间还是很大的。目前已经实现了核心闭环但还有几个方向值得继续做成绩公示后生成获奖证书 PDF通过 Java 的 PDF 模板引擎批量导出对接学校已有的统一身份认证系统让学生用学号直接登录不用单独注册增加 Excel 批量导入老师可以直接把线下收集的 Excel 报名表导入系统增加消息通知入口在报名成功、作品截止、成绩发布时通过邮件或企业微信机器人推送。我在实际使用中发现竞赛管理系统最难的部分不是技术而是把各个角色学生、老师、评委、学院管理员的使用习惯理解透然后变成合理的数据模型和流程。技术只是在为这些流程服务。最后再分享一点个人体会拿到这套源码之后不要急着改代码先按着上面的步骤把它完整跑一遍然后用 Navicat 或 MySQL Workbench 把几张核心表画成 ER 图再对照后端的 Controller、Service、Mapper 三层代码去看数据流转。这个过程做完你对 SpringBoot Vue MyBatis MySQL 这套架构的理解会有一个质的提升。后面无论是改功能、做毕设还是应对面试都会很有底气。