Spring Boot+Vue赛事系统源码解读:从报名流程到数据库设计

发布时间:2026/9/17 5:21:04
Spring Boot+Vue赛事系统源码解读:从报名流程到数据库设计 简介这是一份基于Spring BootVue的学校赛事管理系统毕业设计完整工程适合正在做毕设或需要前后端分离项目范本的开发者参考。系统覆盖系统设置与赛事管理两大主线用户、角色、资源、日志等后台权限模块以及比赛设置、参赛队伍、比赛人员、比赛场次等业务模块权限部分基于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/auditteacher/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) ) ENGINEInnoDB COMMENT赛事主表;这段建表语句里值得注意的有三个点。第一status和reg_end的组合索引对应“查询报名中的赛事并按截止时间排序”的场景数据库能直接按索引顺序取出数据不需要额外的 filesort。第二deleted字段做逻辑删除报名记录和成绩记录都是历史数据物理删除会把统计口径搞乱。第三时间字段用 DATETIME 不用 TIMESTAMP避免 2038 年问题也避免 MySQL 时区转换带来的一小时偏差。如果 SQL 脚本里这三个设计都具备说明作者对表结构有基本思考这套代码的可读性大概率不错如果缺失可以在答辩前补上并在文档里写清设计意图。在 MyBatis 的 XML 里查询条件要统一用#{}预编译占位符不要用${}拼表名以外的内容。${}直接做字符串拼接用户输入里带上 or 11 --这类 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 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 的 APIclaim里放 userId 和 role过期时间 7 天。secret 在配置里要保持 32 字节以上太短会抛出弱密钥异常生产环境从环境变量读取不要写进 yml 明文。登录接口的校验逻辑是另一个高频追问点PostMapping(/login) public Result login(RequestBody LoginDTO dto) { User user userMapper.selectOne(new LambdaQueryWrapperUser() .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 LambdaQueryWrapperRegistration() .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-forcomp in list :keycomp.id h3{{ comp.title }}/h3 el-tag :typestatusTagMap[comp.status]{{ statusTextMap[comp.status] }}/el-tag p报名截止{{ comp.regEnd }}/p el-button v-ifcomp.status 1 !comp.alreadyRegistered typeprimary clickonRegister(comp.id) :loadingregisteringId comp.id 立即报名/el-button el-button v-else disabled{{ comp.alreadyRegistered ? 已报名 : 不可报名 }}/el-button /el-card /template这块逻辑的难点不在按钮而在alreadyRegistered这个字段从哪来。最差的做法是列表接口返回后再给每条记录发一个“是否已报名”的单独请求赛事一多就是 N1 次请求。常见做法是在后端的列表 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 一致导入 SQLmysql -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 databaseyml 数据库配置不对先核对库名与账号密码再确认 SQL 已导入前端登录成功又立刻跳回登录页JWT 过期或 401 被全局拦截查看 Network 面板的登录后请求确认 Authorization 是否有值前端白屏且控制台只有编译错误Vue 版本与 node 版本冲突查看 package.json 的 vue 版本切换 node 版本后重装依赖第一类错误看 yml 的三行配置大概率能解决。第二类要看 axios 拦截器登录后的第一个接口如果返回 401通常不是 token 失效而是登录接口返回的字段名跟拦截器读的字段名不一致比如后端返回{ data: { token: ... } }前端却读了res.data.token。第三类白屏后把浏览器 devtools 的 console 面板打开观察编译错误是语法问题还是依赖问题语法报错会自动指向具体的组件文件。6. 用一条分组查询看报名人数统计顺便看懂 EXPLAIN6.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 两个字段首页的每个赛事卡片都能复用。答辩时若被问到为什么不用冗余字段可以直接回答聚合查询在数据量适中时性能足够同时避免了并发写报名数据时计数不一致的问题等系统真到了十万级报名量再考虑用汇总表或缓存而不是一上来就把统计逻辑散落在业务代码里。能讲清楚这一层取舍比背出十条优化口诀更能体现对系统设计的理解。本文还有配套的精品资源点击获取