SpringBoot3+Vue3竞赛系统开发实战:从状态流转到毕业设计落地
2026/9/16 3:41:11 网站建设 项目流程

简介:基于SpringBoot3与Vue.js3打造的大学生竞赛系统毕业设计项目,适合高校计算机专业毕业生、课程设计学生以及想实践前后端分离开发的技术爱好者,也可用于教学演示与校内竞赛场景。系统完整覆盖竞赛信息发布、学生在线报名、成绩查询与后台管理等核心环节,管理后台和用户前台均采用Vue3实现,后端由SpringBoot3提供接口服务,并配合MySQL8存储数据,整体设计简洁,便于二次开发。资源压缩包共6个文件,整体约80.7MB,包含项目源码、MySQL数据库脚本、需求文档、返修文档及演示录屏等,文件类型主要为zip、sql、docx和mp4,可据此快速部署运行并对照学习。项目含有清晰的需求说明与操作视频,能帮助读者理解从数据库设计到前后端联调的完整流程,也能为毕业设计答辩或课程展示提供支撑。目前已有129人学习,适合直接作为毕业设计参考或SpringBoot3+Vue3实践案例。

1. 竞赛系统没你想的那么复杂,关键是把状态流转想透

毕业设计做大学生竞赛系统,最怕的不是不会写代码,而是把需求当成“官网+表单”来做。真正的竞赛系统要处理报名截止、作品提交、评委打分、成绩公示这一整条状态链。SpringBoot3+Vue.js3正好能覆盖这条链:后端用REST接口管理状态,前端用Vue3响应式页面表达状态。2025年这个时间点,SpringBoot3已经全面转向JDK17和Jakarta规范,Vue3的生态也稳定了,用这两个做毕设既不会老气,又能保证能跑通。

要动手的话,第一步就是在IDEA里创建一个SpringBoot3项目。很多同学在这就卡住了,因为IDEA新建项目时的依赖组和JDK版本会直接影响后面的代码。这篇文章我会按从后端到前端再到联调部署的顺序,把竞赛系统里报名、作品、评审三个关键模块的写法拆开,给出能直接复制的代码和参数说明。新手跟着操作能跑通,有经验的也能从表设计和状态机里找到可以深入的点。

2. 用IDEA创建SpringBoot3项目,把竞赛系统的后端骨架立起来

2.1 为什么竞赛系统选SpringBoot3而不是SpringBoot2

SpringBoot3是2022年底发布的,到2025年已经是主流。对毕业设计来说,选SpringBoot3最大的理由是它强制使用JDK17及以上,这就解锁了record、switch新语法、text block这些让代码更短的功能。另外一个看不见但答辩能说出口的点是:SpringBoot3基于Spring Framework 6,把原来的javax命名空间全部换成了jakarta,这意味着数据传输、会话管理、依赖注入的底层实现都更新了一遍。

竞赛系统这种业务并不复杂,主要就是几个实体、几个状态、几个接口。SpringBoot3带来的启动性能提升和GraalVM原生镜像支持,虽然毕设用不上,但可以写进论文的“技术选型”部分。相比之下,SpringBoot2虽然有大量教程,但新项目再选2.x,在答辩时很容易被问“为什么不用新版本”。所以结论很简单:直接用SpringBoot3,配JDK17,不要犹豫。

2.2 IDEA里创建SpringBoot3项目的标准路径与pom依赖

如果你的电脑上已经有了IntelliJ IDEA,创建一个SpringBoot3项目的完整路径是:

  1. 打开IDEA,选择File → New → Project,左侧选Spring Initializr
  2. Project SDK选择本地已经安装的JDK 17或更高版本,如果没有就点击Add SDK → Download JDK,下载一个17。
  3. Dependencies里勾选Spring WebValidationMyBatis FrameworkMySQL Driver
  4. 填入Group(例如com.example)、Artifact(例如competition),直接点Create

创建成功后,pom.xml里应该包含下面这些核心依赖。这里我给一份可以直接用的版本组合:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.3.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

这段配置里有几个关键点。第一,MyBatis-Plus的starter在SpringBoot3下不叫mybatis-plus-boot-starter,而是mybatis-plus-spring-boot3-starter,版本用3.5.6以上才兼容JDK17。第二,SpringBoot3里MySQL驱动依赖是com.mysql:mysql-connector-j,不是以前的mysql:mysql-connector-java。第三,Lombok在编译时才用,所以optional标记成true可以避免打包时装进去。

2.3 表结构先行:竞赛系统最少需要哪几张表

直接写代码容易后面返工。我一般会先把表结构定下来,竞赛系统最核心的六张表如下:

表名作用关键字段
user学生、教师、管理员共用id, username, password, role
competition竞赛基本信息id, name, description, start_time, end_time, status
signup报名记录id, competition_id, user_id, team_name, status
work作品记录id, signup_id, work_url, upload_time
judge评委分配id, competition_id, user_id
score评分记录id, work_id, judge_id, score, comment

注意signup表的status字段,我建议用整数保存,0表示已报名,1表示作品已提交,2表示已取消。状态不要用字符串中文,否则后面写查询条件非常难受。

对应地,在SpringBoot项目里写一个基础的实体类。这里用Lombok简化:

@Data @TableName("competition") public class Competition { @TableId(type = IdType.AUTO) private Long id; private String name; private String description; private LocalDateTime startTime; private LocalDateTime endTime; private Integer status; }

@TableName把实体映射到表,@TableId表示自增主键。LocalDateTime是Java 8后的时间类型,相比Date更不容易出错。后端所有关于时间的字段都建议用LocalDateTime,在Jackson序列化时会自动变成ISO格式字符串,前端处理起来很舒服。

2.4 初始化最容易踩的坑:IDEA创建SpringBoot3项目时报错

在IDEA里创建SpringBoot3项目时,最常见的问题是项目建好之后,启动类直接报红。原因通常有两种。

第一种是JDK版本没选对。SpringBoot3最低要求JDK17,如果你的IDEA默认SDK还是JDK8,创建项目时就要手动换成17。已经建好的项目,则打开File → Project Structure → ProjectSDKLanguage level都改成17,再在Settings → Build Tools → Maven → Runner里把JRE也改成17。

第二种是依赖下载慢或下载失败。国内网络环境下,建议在Maven的settings.xml里配置阿里云镜像,这样IDEA创建项目后的第一次构建会快很多。Maven仓库和SpringBoot没有强制关系,但很多同学把时间浪费在了等待下载上。另外,如果IDEA的Spring Initializr地址访问不稳定,可以把Server URL改成https://start.aliyun.com,阿里云的源同样能生成SpringBoot3项目,生成的依赖更贴近国内开发习惯。

3. 后端接口:竞赛报名、作品上传与评审打分的核心实现

3.1 用MyBatis-Plus还是JPA:只看这三点就够

毕业设计选持久层框架,我推荐MyBatis-Plus。原因有三个:第一,单表CRUD不需要写SQL,内置的BaseMapper已经提供了selectByIdinsertupdateById这些方法,能省掉大半基础代码;第二,分页查询很直接,用一个Page泛型对象就能搞定;第三,团队里几乎人人都见过MyBatis,答辩时被问到底层实现也不会心虚。

JPA(Spring Data JPA)虽然也简单,但遇到稍微复杂的多表联查,要么写@Query,要么用QClass,对毕业设计来说反而增加理解成本。MyBatis-Plus有一个有争议的点:它侵入性比较强,但如果只是做一个中小型竞赛系统,这个缺点是感受不到的。

数据库连接配置放application.yml

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/competition?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto

serverTimezone=Asia/Shanghai一定要加,否则高版本MySQL驱动会把时间差默认当成UTC,在本地跑的时候明明存的是上午10点,查出来变成下午6点。mybatis-plus.configuration.log-impl可以把SQL打印到控制台,开发阶段非常有帮助。

3.2 报名接口:事务、唯一索引和幂等

报名这个动作涉及两张表:先在signup表插入一条报名记录,再判断这个队伍是不是还在报名阶段。如果不在同一事务里做,可能出现“插入成功了,但竞赛已经截止”的脏数据。

先写一个报名用的Service方法:

@Service @RequiredArgsConstructor public class SignupService { private final SignupMapper signupMapper; private final CompetitionMapper competitionMapper; @Transactional(rollbackFor = Exception.class) public Long signUp(SignupRequest request) { Competition competition = competitionMapper.selectById(request.getCompetitionId()); if (competition == null) { throw new BizException("竞赛不存在"); } LocalDateTime now = LocalDateTime.now(); if (now.isBefore(competition.getStartTime()) || now.isAfter(competition.getEndTime())) { throw new BizException("当前不在报名时间范围内"); } Signup signup = new Signup(); signup.setCompetitionId(request.getCompetitionId()); signup.setUserId(request.getUserId()); signup.setTeamName(request.getTeamName()); signup.setStatus(0); signupMapper.insert(signup); return signup.getId(); } }

这段代码有几个关键点。@Transactional(rollbackFor = Exception.class)表示只要抛出异常就回滚,这个rollbackFor必须写,因为Spring默认只回滚RuntimeExceptionBizException是一个自定义业务异常,建议在项目里建一个,把错误信息统一抛出去,而不是直接返回一个空的Result对象。

更底层的幂等控制要放在数据库层面:在signup表建联合唯一索引,防止同一个用户重复报名同一个竞赛:

ALTER TABLE signup ADD UNIQUE KEY uk_competition_user (competition_id, user_id);

插入时如果违反唯一约束,Spring会抛出DuplicateKeyException,在全局异常处理器里把它转换成“你已报名过该竞赛”即可。

3.3 作品上传:本地存储和OSS之间怎么选

竞赛系统里作品上传常见的做法是保存文件的访问URL,而不是把文件存进数据库。毕业设计用本地静态目录映射就够了,不必要为了一个答辩项目去配置对象存储。

上传接口的Controller可以这样写:

@RestController @RequestMapping("/api/work") public class WorkController { @Value("${file.upload-dir}") private String uploadDir; @PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file, @RequestParam("signupId") Long signupId) { String originalFilename = file.getOriginalFilename(); String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); String newName = System.currentTimeMillis() + "_" + signupId + extension; Path path = Path.of(uploadDir, newName); try { Files.createDirectories(path.getParent()); file.transferTo(path); // 这里拼接访问地址,例如 /files/xxx.pdf String url = "/files/" + newName; workService.createWork(signupId, url); return Result.success(url); } catch (IOException e) { throw new BizException("文件保存失败"); } } }

注意file.getOriginalFilename()不能直接作为存储文件名,否则会存在路径穿越和重名覆盖问题。上面的代码用System.currentTimeMillis() + signupId拼了一个新文件名,既避免重名,也能通过文件名反查报名记录。在配置类里还要把这个本地目录暴露成静态资源:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadDir + "/"); } }

这个配置里uploadDirapplication.yml中定义,比如D:/competition_files/。如果不在IDEA代码里加这句,访问/files/xxx.pdf会返回404。

3.4 评审打分:评委和成绩一定要拆成两张表

很多毕业设计会把评委的分数直接塞在作品表里,比如加一个score字段。这样做的问题是一份作品如果被多个评委打分,后写的人会覆盖先写的人。正确做法是score表存一条一条的评分记录,一个评委对一份作品只能有一条评分。

用MyBatis-Plus的selectOneLambdaQueryWrapper来判断是否已经评过分:

public void score(ScoreRequest request) { LambdaQueryWrapper<Score> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Score::getWorkId, request.getWorkId()); wrapper.eq(Score::getJudgeId, request.getJudgeId()); Score old = scoreMapper.selectOne(wrapper); if (old != null) { old.setScore(request.getScore()); old.setComment(request.getComment()); scoreMapper.updateById(old); return; } Score score = new Score(); score.setWorkId(request.getWorkId()); score.setJudgeId(request.getJudgeId()); score.setScore(request.getScore()); score.setComment(request.getComment()); scoreMapper.insert(score); }

这里用LambdaQueryWrapper避免手写SQL字符串,类型安全。selectOne如果有超过一条记录会抛异常,所以在数据库层面也要给(work_id, judge_id)加唯一索引。

查询作品列表时,可以用SQL聚合出平均分,而不是在Java里循环:

SELECT work_id, AVG(score) AS avg_score, COUNT(*) AS judge_count FROM score WHERE work_id IN (...) GROUP BY work_id;

4. 前端Vue3工程:Vite初始化、路由注册到页面联调

4.1 用Vite搭Vue3项目并手动装Element Plus

后端接口有了,前端直接从命令行初始化。Vite是目前最成熟的Vue3构建工具,比旧版Vue CLI快一个数量级。

npm create vite@latest competition-ui -- --template vue cd competition-ui npm install npm install element-plus axios pinia vue-router npm run dev

--template vue生成的是Vue3单页应用的基础结构。element-plus提供界面组件,axios用于请求后端,pinia做状态管理,vue-router处理页面跳转。装完后在main.js里全局引入Element Plus:

import { createApp } from 'vue' import App from './App.vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' const app = createApp(App) app.use(ElementPlus) app.mount('#app')

Element Plus的样式文件必须单独引入,不引入的话页面上只有光秃秃的HTML结构,按钮和表格完全没有样式。如果你想要组件自动按需引入,可以安装unplugin-auto-importunplugin-vue-components,但毕业设计项目全局引入已经足够,省去配置时间。

4.2 axios请求封装:拿到后端数据之前先统一错误处理

前端调用接口最忌讳每个页面都写一遍try/catch。我习惯把axios实例和响应拦截器放到一个request.js里,然后其他页面直接导入这个实例。

// src/utils/request.js import axios from 'axios' 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( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } else { ElMessage.error('网络异常,请稍后重试') } return Promise.reject(error) } ) export default request

baseURL设置为/api,是为了后面用Vite的代理转发请求到后端8080端口,这样开发时前端就 8080 端口、后端也 8080 端口,不会跨域。在vite.config.js里加代理:

export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

注意这里的逻辑:前端请求/api/work/list,Vite开发服务器会把它转发到http://localhost:8080/api/work/list,后端也不需要在Controller里额外加@CrossOrigin。部署时再用nginx做一层同样的转发即可。

4.3 竞赛列表页:状态、按钮和倒计时

竞赛列表页是该系统里最常被展示的页面。它需要根据status显示不同的操作区域。目前设计是赛前显示“即将开始”,报名中显示“立即报名”,已截止显示“查看作品”。在Vue3里可以用计算属性判断:

<script setup> import { ref, computed } from 'vue' import request from '../utils/request' const list = ref([]) const loadData = async () => { const data = await request.get('/competition/list') list.value = data } const statusText = (item) => { const now = Date.now() if (now < new Date(item.startTime).getTime()) { return { text: '即将开始', disabled: true } } if (now > new Date(item.endTime).getTime()) { return { text: '已截止', disabled: true } } return { text: '立即报名', disabled: false } } const signUp = async (item) => { await request.post('/signup', { competitionId: item.id }) ElMessage.success('报名成功') loadData() } loadData() </script> <template> <el-card v-for="item in list" :key="item.id"> <h3>{{ item.name }}</h3> <el-button :disabled="statusText(item).disabled" @click="signUp(item)"> {{ statusText(item).text }} </el-button> </el-card> </template>

这里statusText返回一个对象,既能显示文案,又能控制按钮禁用。new Date(item.startTime).getTime()把字符串时间转成时间戳,注意后端返回的LocalDateTime经过Jackson序列化后,默认格式形如2025-05-01T10:00:00,前端用new Date解析时,建议先在application.yml里配置date-formatyyyy-MM-dd HH:mm:ss,或者保持ISO格式。如果格式不对,比较结果会NaN。

4.4 前端路由守卫:未登录的用户别想进后台

竞赛系统的“我的报名”和“作品上传”页面都要求登录,要防止用户直接输URL绕过。在Vue Router里注册一个全局前置守卫:

// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', redirect: '/competitions' }, { path: '/login', component: () => import('../views/LoginView.vue') }, { path: '/competitions', component: () => import('../views/CompetitionList.vue') }, { path: '/my-signups', component: () => import('../views/MySignups.vue'), meta: { requiresAuth: true } } ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } }) export default router

meta.requiresAuth是Vue Router支持的一种约定,写在路由记录里。整体逻辑不复杂,但它会把你学过的路由守卫融进项目,答辩时稍微展开讲一下“前端路由分为权限路由和公共路由”是加分项。这里还需要和后端配合,后端拦截器的思路也一样:除了/api/user/login外,其余接口都检查Authorization头,缺失或非法就返回401。只有两端同时做控制,才算完整的一套认证体系。

5. 这套系统的部署验证与外行也能看懂的答辩细节

5.1 docker-compose一键启动后端+前端+MySQL

如果论文里写着“系统可一键部署”,那最好真的能一键。给项目根目录加一个docker-compose.yml,三行配置就可以把三个服务拉起来。

services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: competition ports: - "3306:3306" backend: build: ./backend depends_on: - mysql ports: - "8080:8080" frontend: build: ./frontend depends_on: - backend ports: - "80:80"

后端Dockerfile里先用maven package打出jar包,再将jar复制到运行镜像。前端则用Nginx做宿主,构建时先把Vue项目打包成静态文件,再复制到nginx的html目录。这一步如果着急,直接用npm run build出来后在dist文件夹上起个nginx也算完成。

5.2 三个容易扣分的隐藏点

第一个是错误码设计。后端不要返回HTTP 500带异常堆栈,而是统一返回code/message/data的三段结构。答辩时演示一个“报名超时”的操作,页面能弹出友好提示,比截图里满屏红色堆栈强得多。

第二个是数据库时间差。MySQL8默认时区是UTC,如果serverTimezone没配对,定时发布的竞赛时间会整体偏移8小时。项目里所有时间比较都建议在后端做,前端只负责展示。

第三个是空指针。查询作品平均分时,如果作品还没有任何人打分,SQL查询结果是null,包装成Long返回时直接拆箱会炸。建议score字段用基本类型包装类,或者SQL里用COALESCE(AVG(score), 0)

5.3 五分钟的演示脚本与性能观察

演示顺序建议定为:管理员创建竞赛,学生报名,上传作品,评委打分,普通用户看到成绩。演示时把后端日志开着,让SQL语句和请求日志出现在控制台里,这会让评审觉得代码是真实运行的。另外用浏览器开发者工具Network面板提前调低网络速度,展示加载状态和错误提示的UI反馈。如果还想加一点性能指标,可以在JMeter里模拟100个用户同时报名,观察后端的接口耗时和数据库连接数是否稳定。这一项放进论文的“系统测试”章节,答辩时被追问的余地也更大。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询