简介:这是一份基于Spring Boot+Vue的学校赛事管理系统毕业设计完整工程,适合正在做毕设或需要前后端分离项目范本的开发者参考。系统覆盖系统设置与赛事管理两大主线:用户、角色、资源、日志等后台权限模块,以及比赛设置、参赛队伍、比赛人员、比赛场次等业务模块;权限部分基于Shiro的RBAC模型,接口请求使用Axios统一封装,适合学习登录认证与前后端数据交互的落地写法。后端采用Spring Boot、MyBatis-Plus与Shiro,前端使用Vue、iView、Vuex和Axios;环境涉及JDK1.8、Maven3.6、Node14、MySQL5.6与Redis,并附SQL脚本与运行说明,能够直接导入开发工具运行。压缩包共290个文件,以126个Java后端类、46个Vue页面和40个JS逻辑文件为主,另有VM模板、XML配置、图标字体等前端资源,整体约9.37MB,目录层次清晰,可对照模块快速定位代码。已有179人学习下载,适合需要完整掌握赛事管理系统设计思路、快速搭建可演示项目的人群。
1. 打开 Spring Boot + Vue 学校赛事系统源码,先别急着“一键运行”
答辩前一个月拿到手 zip 包,解压后基本是 frontend、backend、sql 三个目录。学校赛事管理系统和普通 CRUD 系统不一样,难点不在页面多,而在“报名”这个动作。登录态、角色权限、赛事状态、截止时间、重复校验五条环节叠在一起,任何一条链路断掉,系统就停在演示水平,答辩环节很容易被追问出漏洞。这里把 Spring Boot 后端与 Vue 前端的代码分工当作主线,从 SQL 表设计、后端认证与报名接口、前端路由与页面,到本地启动与排错逐步拆,最后用“报名人数统计”这条经典需求演示如何读懂、扩展这类项目。适合在校生做功能梳理,也适合刚转全栈的开发者判断一套源码能否在本地跑起来。
2. 赛事系统的业务边界与数据库建模
2.1 赛事的生命周期不是一张表能扛住的
学校赛事管理系统和学生信息管理系统最大的差异,在于数据之间存在状态流转。一个赛事从创建到归档,至少要经历草稿、报名中、进行中、已结束四个状态,每个状态之间还挂着时间约束。报名接口不能只看赛事状态,还要比较当前时间和报名开始、截止时间;赛事列表的默认排序也要把“报名中”的赛事置顶,再按结束时间升序排列。这些逻辑表面上是接口的事,实际在表结构设计阶段就决定了能不能写得干净。
不少项目为了图省事,把赛事和报名合并成一张表,再在赛事行里维护一个报名人数字段。这种做法在并发报名时会出错:两个学生同时提交,后提交的人读到旧的报名人数,加一之后把另一个人的提交覆盖掉。数据量小的时候看不出问题,但答辩演示时只要开两个浏览器同时点报名,页面就能展示数据不一致。常规的做法是赛事表和报名表彻底分开,报名人数用统计接口实时查出来。另外,成绩表也不要只设计一个 score 字段。编程赛按队伍参赛,演讲赛按个人参赛,体育赛还有初赛复赛,分数、等级、排名三种取值都应该用 score_type 字段区分开,前端再按赛事类型决定怎么展示。留出这个扩展位,比后期返工省太多事。
2.2 角色划分与权限分配的现实方案
| 角色 | 核心功能 | 后端接口粒度 |
|---|---|---|
| admin | 用户管理、赛事审核、数据统计 | /api/user/**、/api/competition/audit |
| teacher/judge | 创建赛事、管理报名名单、录入成绩 | /api/competition/**、/api/score/** |
| student | 浏览赛事、报名、查看成绩 | /api/competition/list、/api/registration/** |
权限模型在毕业设计里通常分成两种做法。第一种是角色写死在代码里,用 Spring Security 的注解@PreAuthorize("hasRole('ADMIN')")直接控制接口访问;第二种是数据库里放用户表、角色表、菜单表,做成动态菜单和动态权限。前者实现快,适合当前只有三个固定角色的系统;后者的前期工作量大,但后期改权限不用改代码。拿到源码后先看后端 WebSecurityConfig 里有没有权限表的装配,再去前端看菜单是不是根据接口返回生成的,就能判断这个项目的权限是硬编码还是动态的。如果源码里只有注解权限,答辩时最好不要说成“动态权限系统”。
安全配置还要注意放行清单和顺序。登录注册接口一般在antMatchers("/api/login", "/api/register").permitAll()中放行,其余接口一律.authenticated()。放行路径不要用/api/**这种过宽的写法,否则后端所有接口都处于未登录状态,前面的 JWT 权限控制全部失效。静态资源、Swagger 文档这类路径按需单独放行,相对孤立地猜通配符要稳妥。
2.3 数据库脚本与常见字段遗漏
sql 目录里的建表脚本是这次判断项目质量的第一份证据。一个常规的赛事主表结构如下:
-- 赛事主表 CREATE TABLE competition ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', title VARCHAR(100) NOT NULL COMMENT '赛事名称', category VARCHAR(50) COMMENT '赛事分类:technology/sports/arts', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-草稿 1-报名中 2-进行中 3-已结束', reg_start DATETIME COMMENT '报名开始时间', reg_end DATETIME COMMENT '报名截止时间', deleted TINYINT DEFAULT 0 COMMENT '逻辑删除标记', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_time (status, reg_end) ) ENGINE=InnoDB COMMENT='赛事主表';这段建表语句里值得注意的有三个点。第一,status和reg_end的组合索引对应“查询报名中的赛事并按截止时间排序”的场景,数据库能直接按索引顺序取出数据,不需要额外的 filesort。第二,deleted字段做逻辑删除,报名记录和成绩记录都是历史数据,物理删除会把统计口径搞乱。第三,时间字段用 DATETIME 不用 TIMESTAMP,避免 2038 年问题,也避免 MySQL 时区转换带来的一小时偏差。如果 SQL 脚本里这三个设计都具备,说明作者对表结构有基本思考,这套代码的可读性大概率不错;如果缺失,可以在答辩前补上,并在文档里写清设计意图。
在 MyBatis 的 XML 里,查询条件要统一用#{}预编译占位符,不要用${}拼表名以外的内容。${}直接做字符串拼接,用户输入里带上' or 1=1 --这类 SQL 注入万能密码绕过载体时,登录接口就会变成漏洞入口。表名、排序字段这种不能预编译的少数场景要单独做白名单校验,这是每个 Spring Boot 项目默认要在答辩时讲清楚的零分项。
3. Spring Boot 后端的认证、报名与状态枚举
3.1 分层结构与配置文件里的三个坑
后端代码的分层常见的是 controller、service、mapper、entity、config、util 六层。赛事系统的接口数量一般不会超过二十个,按业务模块纵向拆分比横向抽象基类更容易读。比如参赛名册查询按 competitionId 写一个专属 mapper 方法,比硬套一个通用 listAll 再在内存里过滤要直观得多,也方便后面分页。
application.yml 的配置是启动阶段最先出问题的地方:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/school_competition?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: school-competition-jwt-secret expire: 604800第一处坑在数据库连接串。serverTimezone不设置,MySQL 8 会直接报连接时区错误;设置成Asia/Shanghai后,前后端时间字段才不会被 Jackson 转成 UTC。第二处在驱动类名,Spring Boot 2.x 还兼容旧的com.mysql.jdbc.Driver,但如果版本太高(3.x),必须写成com.mysql.cj.jdbc.Driver,否则启动时找不到数据源驱动。第三处在逻辑删除配置,MyBatis-Plus 把deleted字段配置成逻辑删除后,普通查询会自动追加WHERE deleted = 0,但手动写在 XML 里的 SQL 不会自动追加,统计查询必须显式带上 deleted 条件,避免把逻辑删除数据算进去。
配置文件里的敏感项也要提前处理。数据库口令和 JWT 密钥属于典型的 yml 密文场景,本地调试可以先用明文跑通,但要留出环境变量占位符,比如password: ${DB_PASSWORD:123456},部署时通过环境变量注入,代码仓库里不出现真实口令。
3.2 JWT 登录与 BCrypt 密码校验
JWT 工具类是整个后端认证的起点,代码逻辑如下:
@Component public class JwtUtil { private final SecretKey key = Keys.hmacShaKeyFor( "school-competition-jwt-secret".getBytes(StandardCharsets.UTF_8)); private static final long EXPIRE_MS = 7 * 24 * 3600 * 1000L; public String createToken(Integer userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_MS)) .signWith(key) .compact(); } public Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } }这段用 jjwt 0.11.x 的 API,claim里放 userId 和 role,过期时间 7 天。secret 在配置里要保持 32 字节以上,太短会抛出弱密钥异常;生产环境从环境变量读取,不要写进 yml 明文。登录接口的校验逻辑是另一个高频追问点:
@PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { User user = userMapper.selectOne(new LambdaQueryWrapper<User>() .eq(User::getUsername, dto.getUsername())); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } String token = jwtUtil.createToken(user.getId(), user.getRole()); return Result.ok(Collections.singletonMap("token", token)); }这里有两个要点。第一,密码用 BCrypt 存储和校验,数据库里不允许出现明文密码;如果源码里直接比对 MD5,建议改成 BCryptPasswordEncoder 生成和校验。第二,SQL 注入防线的关键是 MyBatis 的#{}预编译,登录查询用了 LambdaQueryWrapper 也会自动预编译,不要把用户名或密码拼进字符串再传给 SQL。
3.3 报名接口的幂等校验与事务回滚
报名是赛事系统里最容易被追问的接口。核心逻辑一般收敛在一个事务方法里:
@Transactional(rollbackFor = Exception.class) public void register(Long competitionId, Long userId) { Competition comp = competitionMapper.selectById(competitionId); if (comp == null) { throw new BizException("赛事不存在"); } if (comp.getStatus() != 1 || LocalDateTime.now().isAfter(comp.getRegEnd())) { throw new BizException("当前不在报名时间内"); } Long count = registrationMapper.selectCount(new LambdaQueryWrapper<Registration>() .eq(Registration::getCompetitionId, competitionId) .eq(Registration::getUserId, userId)); if (count > 0) { throw new BizException("请勿重复报名"); } Registration registration = new Registration(); registration.setCompetitionId(competitionId); registration.setUserId(userId); registration.setStatus(1); registrationMapper.insert(registration); }这段代码体现了三层校验。第一层查赛事,确认赛事存在且处于报名中;第二层比较系统当前时间与regEnd,时间判断精确到分钟,服务器时间与浏览器时间不一致时以服务器为准;第三层按 competitionId 加 userId 查报名表,防止同一个人对同一赛事报名两次。这三层之外还要数据库再兜底一道,给 registration 表加UNIQUE KEY uk_comp_user (competition_id, user_id)。代码里的 select 检查在并发下存在时间差,两个人同时通过检查再入库,唯一索引能把第二条直接拦下来。这是把“防重复报名”从应用层下推到数据层的关键设计,答辩时讲出来会比只说代码判断高一个档次。
@Transactional(rollbackFor = Exception.class)这个注解要解释清楚。默认事务只在抛出 RuntimeException 时回滚,而 BizException 如果是受检异常,不指定 rollbackFor 就会提交已执行的操作,报名记录可能只写入一半。把异常回滚范围扩大,业务异常也全部回滚,是毕业设计里最常见也最容易被忽略的坑。
3.4 状态枚举替代散落的魔法数字
代码里会出现很多comp.getStatus() != 1这类写法,建议用一个枚举把所有状态收拢:
| 枚举值 | 状态 | 触发时机 |
|---|---|---|
| 0 | 草稿 | 管理员创建赛事 |
| 1 | 报名中 | 赛事发布 |
| 2 | 进行中 | 报名截止 |
| 3 | 已结束 | 成绩归档 |
对应的 Java 枚举:
public enum CompetitionStatus { DRAFT(0, "草稿"), REGISTERING(1, "报名中"), RUNNING(2, "进行中"), FINISHED(3, "已结束"); private final int value; private final String label; CompetitionStatus(int value, String label) { this.value = value; this.label = label; } // getter 省略 }用枚举后,报名接口的判断可以写成comp.getStatus() == CompetitionStatus.REGISTERING.getValue(),可读性更好,也不怕后端、前端、数据库三处的魔法数字对不上。前端拿到的状态值建议仍保持 0/1/2/3,由前端组件映射成中文标签,不要在接口里返回“报名中”这种文案,否则后续改了文案要改接口。
4. Vue 前端的路由、请求封装与赛事页面联动
4.1 Vue 项目初始化与依赖版本
前端目录解压后先看 package.json,确定是 Vue 2 + Element UI 还是 Vue 3 + Element Plus。两者的语法和生态差距明显,不要混用。安装依赖时一般直接跑:
cd frontend npm install npm run dev如果 npm install 失败,先把 node_modules 和 package-lock.json 删除再试;依然失败,就要检查 node 版本。Vue 2 项目在 node 14/16 下更稳,Vue 3 配合 node 16 以上版本。原包里如果已经带 node_modules,建议删掉重装,避免当前系统 node 版本和旧编译产物不兼容。请求地址中的前缀要在开发环境统一规范成/api,避免页面里散落着http://localhost:8080/xxx的绝对路径,否则换个端口就要全局替换。
4.2 路由守卫与登录态保持
前端路由表在 vue-router 4 中一般长这样:
const routes = [ { path: '/login', component: Login }, { path: '/', component: Layout, redirect: '/dashboard', children: [ { path: 'competition', component: CompetitionList, meta: { roles: ['student', 'teacher', 'admin'] } }, { path: 'competition/create', component: CompetitionCreate, meta: { roles: ['teacher', 'admin'] } }, { path: 'competition/detail/:id', component: CompetitionDetail, meta: { roles: ['student', 'teacher', 'admin'] } }, { path: 'registration/mine', component: MyRegistration, meta: { roles: ['student'] } } ] } ]这里的competition/detail/:id是典型的路由传参场景。从赛事列表跳详情页用router.push({ path: '/competition/detail/' + id })或带 name 的写法;在详情页里用this.$route.params.id(选项式 API)或useRoute().params.id(组合式 API)读取参数并请求详情接口。Vue 2 和 Vue 3 在这一处的写法差异很大,排查问题时先确认项目用的是哪一套。
路由守卫控制越权访问:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next('/login') } else if (token && to.meta.roles && !to.meta.roles.includes(store.getters.role)) { next('/403') } else { next() } })登录成功后的 token 放在 localStorage 里,每次请求由拦截器取出并放入 Authorization 头。角色信息从 JWT 解析或者单独从用户接口获取,二选一后存入 Vuex 或 Pinia,不要在路由守卫里每次都解析 token。后端如果没提供用户信息接口,直接从 JWT 的 claim 里读 role 也能接受,但要注意 JWT 过期后角色信息同步失效,需要重新登录。
4.3 统一请求封装与 401 处理
axios 封装是 Vue 项目的关键部分:
import axios from 'axios' import router from '@/router' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/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( res => res.data, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(err.response?.data?.message || '请求失败') return Promise.reject(err) } ) export default request拦截器解决三个问题。第一个是登录态注入,每个请求自动带上 Bearer token,不用在每个调用处重复写 header。第二个是统一处理 401,后端返回未认证时清 token 并跳登录页,前端不用在每个页面重复判断。第三个是错误提示,全局统一弹 message,页面里只关心成功分支。
要注意避免 401 跳转死循环。登录接口自身如果也走这个拦截器,密码错误返回的 401 会把用户重新送回登录页,页面一闪而过但没有任何提示。可以给请求配置传一个skipAuthRedirect: true的标记,在 401 处理时判断这个标记决定是否跳转,或者后端登录接口失败时统一返回业务错误码,不把 401 交到全局拦截层。
4.4 赛事列表页的报名状态联动
前端最常见的业务页面是赛事列表。赛事卡片要展示标题、状态标签、报名按钮,已报名的赛事按钮直接禁用:
<template> <el-card v-for="comp in list" :key="comp.id"> <h3>{{ comp.title }}</h3> <el-tag :type="statusTagMap[comp.status]">{{ statusTextMap[comp.status] }}</el-tag> <p>报名截止:{{ comp.regEnd }}</p> <el-button v-if="comp.status === 1 && !comp.alreadyRegistered" type="primary" @click="onRegister(comp.id)" :loading="registeringId === comp.id" >立即报名</el-button> <el-button v-else disabled>{{ comp.alreadyRegistered ? '已报名' : '不可报名' }}</el-button> </el-card> </template>这块逻辑的难点不在按钮,而在alreadyRegistered这个字段从哪来。最差的做法是列表接口返回后再给每条记录发一个“是否已报名”的单独请求,赛事一多就是 N+1 次请求。常见做法是在后端的列表 SQL 里用 EXISTS 子查询一次性算出来:
SELECT c.id, c.title, c.reg_end, c.status, EXISTS( SELECT 1 FROM registration r WHERE r.competition_id = c.id AND r.user_id = #{userId} ) AS already_registered FROM competition c WHERE c.deleted = 0 ORDER BY (c.status = 1) DESC, c.reg_end ASCORDER BY 里的(c.status = 1)让表达式为真的行排在最前面,实现“报名中的赛事置顶”。配合第 2 章的组合索引,这段查询在数据量不大时表现足够好,也是一个可以在答辩时主动讲的 SQL 细节。前端在onRegister成功后不要重新加载整个列表,直接把该卡片的 alreadyRegistered 置为 true,少一次接口请求,交互也更顺。
5. 本地启动的完整时序与常见坑位
5.1 后端启动:建库、导数据、调配置
启动顺序只有四步,但每一步都有对应的检查方法:
- 建库:
CREATE DATABASE school_competition DEFAULT CHARACTER SET utf8mb4;,数据库名要和 yml 里的 url 一致 - 导入 SQL:
mysql -u root -p school_competition < init.sql,导入后执行SHOW TABLES;确认核心表都建立 - 改配置:检查 yml 里的密码、端口、时区,尤其注意数据库连接串里的密码如果包含特殊字符要 URL 编码
- 启动:
mvn spring-boot:run或 IDEA 里直接运行主类,观察 console 日志出现 Tomcat started 才算成功
第 3 步是毕业设计翻车重灾区。源码包默认的密码是 123456,本地 MySQL 如果自设过 root 密码,启动时直接报 Access denied;如果 MySQL 没建过这个库,报 Unknown database。这两类错误都在 console 前三行就能看到原因,不要急着改代码,先检查连接串。
5.2 前端启动:依赖安装与接口转发
前端启动命令:
cd frontend npm install npm run dev开发环境前端跑在 3000 或 5173 端口,后端跑 8080,跨域问题通过 vue.config.js 里的路径转发解决,不需要后端开放 CORS:
// vue.config.js module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这段配置的作用是把前端发往/api的请求原样转发到后端 8080 端口。浏览器看到的是同源请求,不会触发跨域。如果不配这段转发,开发环境的每个请求都会先报 CORS 或者 404,很多初学者把锅甩给后端,其实问题在前端 devServer。
changeOrigin: true表示转发时把 Host 头改成目标地址,有些后端网关会校验 Host,这个参数不打开就会遇到诡异的 302。Vite 项目的写法是server.proxy,结构和 webpack 版本略有差异,用 Vite 时注意查对应版本配置。
5.3 启动过程三类典型报错与排查要点
| 报错现象 | 常见原因 | 排查顺序 |
|---|---|---|
| 后端启动即停止,报 Access denied / Unknown database | yml 数据库配置不对 | 先核对库名与账号密码,再确认 SQL 已导入 |
| 前端登录成功又立刻跳回登录页 | JWT 过期或 401 被全局拦截 | 查看 Network 面板的登录后请求,确认 Authorization 是否有值 |
| 前端白屏且控制台只有编译错误 | Vue 版本与 node 版本冲突 | 查看 package.json 的 vue 版本,切换 node 版本后重装依赖 |
第一类错误看 yml 的三行配置,大概率能解决。第二类要看 axios 拦截器,登录后的第一个接口如果返回 401,通常不是 token 失效,而是登录接口返回的字段名跟拦截器读的字段名不一致,比如后端返回{ data: { token: ... } },前端却读了res.data.token。第三类白屏后,把浏览器 devtools 的 console 面板打开,观察编译错误是语法问题还是依赖问题,语法报错会自动指向具体的组件文件。
6. 用一条分组查询看报名人数统计,顺便看懂 EXPLAIN
6.1 统计接口的正确写法和执行计划
赛事系统里最常被问到的扩展需求是“每个赛事的报名人数”。一个直接的实现是在赛事主表上加报名数字段,每次报名成功做 update;但并发下这种方式会丢更新。规范做法是实时聚合查询:
SELECT c.id, c.title, COUNT(r.id) AS reg_count FROM competition c LEFT JOIN registration r ON r.competition_id = c.id AND r.status = 1 AND r.deleted = 0 WHERE c.deleted = 0 GROUP BY c.id, c.title ORDER BY reg_count DESC;使用 LEFT JOIN 而不是 JOIN,是为了把没有报名的赛事也统计出来,reg_count 显示为 0。这个查询在数据量上去之后会变慢,这时要看它的执行计划,也就是慢 sql 优化里 explain 主要看哪些信息的问题。在 MySQL 命令行执行EXPLAIN SELECT ...时,重点看四个字段:type 表示访问类型,从 system、const、ref 到 index、all,出现 ref 说明走了索引且效率不错;key 是实际用到的索引,显示 NULL 就需要加索引;rows 是预估扫描行数,越小越好;Extra 里出现 Using temporary 或 Using filesort 说明 GROUP BY 或 ORDER BY 触发了临时表和文件排序,需要在 registration 表上建立(competition_id, status, deleted)的组合索引来消除。
改造完成后,接口只返回 id 和 reg_count 两个字段,首页的每个赛事卡片都能复用。答辩时若被问到为什么不用冗余字段,可以直接回答:聚合查询在数据量适中时性能足够,同时避免了并发写报名数据时计数不一致的问题;等系统真到了十万级报名量,再考虑用汇总表或缓存,而不是一上来就把统计逻辑散落在业务代码里。能讲清楚这一层取舍,比背出十条优化口诀更能体现对系统设计的理解。
本文还有配套的精品资源,点击获取