搞学科竞赛管理系统这种题目,在毕业设计和课程设计里出现频率极高。导员需要一个能管理报名、审核、发布成绩的后台,参赛学生需要一个能看到赛事信息、在线报名、上传作品、查成绩的入口,老师则需要一个能评审打分、导出数据的操作台。这种三方角色、多状态流转的项目,正好能完整展示SpringBoot+Vue全栈开发能力,所以很多同学选它作为毕设。本文就基于这个“SpringBoot+Vue学科竞赛管理平台”,把从数据库设计、后端接口、前端页面到部署上线的完整思路和实操细节都掰开揉碎讲一遍,重点说哪些坑是你自己写代码时容易忽略的。我做过好几个类似的管理系统,说实话,这类项目功能本身不难,真正拉开差距的是对业务状态的理解、权限控制的严谨性,以及接口和页面的细节处理。下面直接从需求分析开始,带着你逐步把平台从0到1搭起来。1. 从需求到架构:这个学科竞赛平台到底该怎么搭1.1 学科竞赛管理的痛点与功能拆解先想清楚一个问题:学科竞赛管理在日常工作中到底麻烦在哪?赛事信息发布渠道分散,学生看不到统一通知。报名靠Excel统计,人多容易漏、容易错。作品提交格式不统一,文件命名混乱。评委打分后数据汇总工作量大,还容易算错平均分。学生想查自己成绩只能等通知,状态不透明。所以,一个合格的竞赛管理平台,至少要覆盖完整业务闭环:用户登录 → 查看赛事 → 在线报名 → 上传作品 → 评委打分 → 管理员发布成绩 → 学生查看结果。平台用户大概分三类:角色核心诉求权限边界学生浏览赛事、报名、传作品、查成绩只能操作自己的报名记录教师/评委发布赛事、审核报名、打分可管理自己创建的赛事,对分配到的作品评分管理员全局配置、用户管理、数据汇总所有模块,可操作任何数据功能模块也就随之清晰了:用户管理、赛事管理、报名管理、作品管理、评分管理、成绩发布、公告通知、数据统计。看起来不少,但每个模块拆开并不复杂,典型的CRUD加状态流转。1.2 为什么选SpringBoot+Vue,而不是其他组合有同学问,用SSH行不行?用JSP行不行?不是不行,但你得考虑毕设答辩和后续维护。SpringBoot最大的优势是简化了Spring配置,内嵌Tomcat,打个jar包就能跑。配合MySQL做数据存储,是Java领域最主流、参考资料最多的组合。Vue作为前端框架,组件化开发让页面维护非常方便,而且Vue2或Vue3都有大量现成的后台管理模板可以借鉴。选择这个组合还有几个现实原因:前后端分离的架构更容易在答辩时讲清楚,一个专门讲后端RESTful接口,一个专门讲前端组件通信和路由控制。SpringBoot生态完善,Spring Security或JWT方案都很成熟,写权限控制有标准打法。Vue+Element UI做后台页面是真的快,表格、表单、弹窗都是现成组件,几天就能把页面撸完。对于学习者来说,这套技术栈的代表性足够强,毕业后写简历也能拿得出手。当然,你也可以用MyBatis-Plus操作数据库,甚至用Redis做缓存。但既然是课程设计级需求,我建议不要过度设计,默认单机部署,MySQL+SpringBoot+Vue就够用,稳定、好演示、不给自己挖坑。2. 数据库设计与核心表结构2.1 用户、角色与权限怎么建模表结构设计是这类项目的命脉,后端的业务逻辑基本都围绕表关系展开。我建议不要搞复杂的角色权限框架,就用最简单直接的“用户表 + 角色字段 + 中间关联表”来实现。用户表可以这么设计:CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', `password` VARCHAR(255) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` VARCHAR(50) COMMENT '真实姓名', `student_no` VARCHAR(20) COMMENT '学号', `email` VARCHAR(100), `phone` VARCHAR(20), `role` TINYINT NOT NULL DEFAULT 3 COMMENT '1-管理员 2-教师 3-学生', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 0-禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP );这里的role用TINYINT数字表示,1、2、3分别对应管理员、教师、学生。很多同学会用字符串存储角色,比如"admin"、"teacher"、"student",这也能行,但用数字更省空间,而且后端做判断时用int比较不容易写错。前端再根据数字映射成中文名称。不过需要注意,如果后续角色权限扩展更复杂,比如教师分成“评委”和“指导老师”,那单字段就不够用了。稳妥做法是加一张角色表和用户角色关联表,后端判断权限时用角色编码。对于毕设来说,单字段+TINYINT已经能应付,答辩时讲清取舍即可。密码绝对不能用明文存储。使用Spring Security的BCryptPasswordEncoder加密,即使数据库泄露,也不会直接暴露密码。我看到太多毕设代码把密码直接放在数据库里,这是重大安全隐患,答辩老师一眼就能挑出毛病。2.2 竞赛、报名、作品、评分表的设计要点业务核心表不能少,竞赛表(competition)、报名表(enrollment)、作品表(work)、评分表(score)是最基本的四张。另外,竞赛和评委的关系也比较关键,建议单独建一张竞赛评委关联表。竞赛表:CREATE TABLE `competition` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(100) NOT NULL COMMENT '竞赛名称', `description` TEXT COMMENT '竞赛简介', `type` VARCHAR(50) COMMENT '竞赛类别,如编程、数学建模', `start_time` DATETIME COMMENT '报名开始时间', `end_time` DATETIME COMMENT '报名结束时间', `status` TINYINT DEFAULT 0 COMMENT '0-未开始 1-报名中 2-进行中 3-已结束', `creator_id` INT COMMENT '创建人ID', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP );报名表:CREATE TABLE `enrollment` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `competition_id` INT NOT NULL, `user_id` INT NOT NULL, `team_name` VARCHAR(100) COMMENT '团队名称', `status` TINYINT DEFAULT 0 COMMENT '0-待审核 1-已通过 2-已拒绝 3-已取消', `apply_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_comp_user` (`competition_id`, `user_id`) );报名表上加唯一联合索引很有必要,防止同一个学生重复报名同一个竞赛。这个细节虽然小,但能体现你对数据库设计的思考。作品表:CREATE TABLE `work` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `enrollment_id` INT NOT NULL COMMENT '关联报名ID', `work_name` VARCHAR(100) COMMENT '作品名称', `work_file_url` VARCHAR(255) COMMENT '文件路径', `description` TEXT, `submit_time` DATETIME DEFAULT CURRENT_TIMESTAMP );评分表:CREATE TABLE `score` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `work_id` INT NOT NULL COMMENT '作品ID', `judge_id` INT NOT NULL COMMENT '评委用户ID', `score` DECIMAL(5,2) COMMENT '打分成绩', `comment` VARCHAR(500) COMMENT '评语', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_work_judge` (`work_id`, `judge_id`) );评分表上的唯一索引也是关键,一对作品一个评委只能打一次分,避免重复记录。成绩计算的时候,如果多个评委打分,可以取平均分,也可以去掉最高最低再平均,具体按竞赛规则来,后端Service里写清楚即可。竞赛与评委的关联表:CREATE TABLE `competition_judge` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `competition_id` INT NOT NULL, `judge_id` INT NOT NULL COMMENT '评委用户ID,关联user表' );这类关联表在可视化关系时很直观,在MyBatis的Mapper里做多表关联查询也方便。数据库设计阶段把主外键关系理清,后面写代码能省一半时间。外键约束可以看情况加,有些人喜欢在代码层维护关联关系,但至少逻辑外键要在设计文档里体现。3. SpringBoot后端核心实现3.1 项目结构与统一返回体设计后端项目结构我习惯按功能分包,而不是按层分包。按层分包是controller/service/dao三层各建一个包,把所有Controller堆在一起。按功能分包则是一个模块对应一个包,比如user、competition、enrollment、work、score,每个包里有Controller、Service、Mapper。这样当你需要改动某个业务时,只需要在一个包内操作,思路清晰得多。推荐结构示例:com.example.competition ├── controller │ ├── UserController.java │ ├── CompetitionController.java │ ├── EnrollmentController.java │ └── ... ├── service │ └── impl ├── mapper ├── entity ├── dto ├── vo ├── config └── common统一返回体是前后端分离项目必须做的事。没有统一返回格式,前端处理数据就要写一堆重复代码。我推荐一个简单的Result类:@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.msg = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(String msg) { Result<T> result = new Result<>(); result.code = 500; result.msg = msg; return result; } }code用200/500已经够用了,也可以细分:400代表参数错误、401代表未登录、403代表无权限。不过毕设阶段不用强行参照HTTP状态码,只要前后端约定一致即可。前端Axios统一拦截器里对code做判断,非200就弹错误提示,代码清晰又省事。3.2 JWT登录鉴权与角色权限控制登录鉴权方案推荐JWT,无状态,适合前后端分离,实现也不复杂。流程就是:用户输入用户名密码 → 后端校验 → 生成Token返回前端 → 前端每次请求带在Header里 → 后端拦截器校验Token并解析用户信息。先添加依赖,SpringBoot 2.x用:<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>注意jjwt 0.9.1依赖javax.xml.bind,JDK8没问题,如果你用JDK11以后,需要额外加jaxb-api,否则启动会报错。用SpringBoot 3.x的同学建议换jjwt 0.11.5,API有变化。JWT工具类核心方法就三四个:生成Token、解析Token、判断是否过期。Token里放userId和role就可以,不放敏感信息。密钥写死在配置里也行,最好用Base64编码的长字符串。登录接口逻辑:public Result<LoginVO> login(LoginDTO dto) { User user = userMapper.findByUsername(dto.getUsername()); if (user == null || !bcrypt.matches(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } if (user.getStatus() == 0) { return Result.error("账号已被禁用"); } String token = jwtUtils.generateToken(user.getId(), user.getRole()); LoginVO vo = new LoginVO(); vo.setToken(token); vo.setRole(user.getRole()); vo.setRealName(user.getRealName()); return Result.success(vo); }把role放进Token里很有用,后续拦截器解析出角色后可以直接做权限判断,省得每次请求都查数据库。当然,如果有人修改了角色,只靠Token里的信息不够安全。严谨的做法是在需要时再去数据库校验角色,但毕设不用过度设计。拦截器实现权限控制时,我建议用HandlerInterceptor,在preHandle里从Header取Token,无效则返回401错误。然后自定义一个注解,比如@RequireRole(role = 2),在Controller方法上标注需要哪种角色。拦截器里通过反射拿到方法上的注解再判断。这样写出来的代码比较乾净,答辩讲起来也清楚。3.3 竞赛报名和成绩管理的业务逻辑竞赛模块的接口无非是增删改查,但要处理好状态联动。比如报名截止时间到了,status自动变成进行中;报名未开始的竞赛不能报名;竞赛结束后不能再提交作品。这些规则写在Service层,别散落在Controller里。报名业务伪代码:@Transactional public Result<String> apply(EnrollmentDTO dto) { // 1. 检查竞赛存在且状态为报名中 Competition comp = competitionMapper.findById(dto.getCompetitionId()); if (comp == null || comp.getStatus() != 1) { return Result.error("竞赛不存在或不在报名时间"); } // 2. 检查是否已报名 int count = enrollmentMapper.selectCountByCompAndUser(dto.getCompetitionId(), currentUserId()); if (count > 0) { return Result.error("请勿重复报名"); } // 3. 创建报名记录 enrollment.setStatus(0); // 待审核 enrollmentMapper.insert(enrollment); return Result.success("报名成功,等待审核"); }注意@Transactional,如果后续还有作品表、审核表的同时操作,必须保证事务一致。这里要在Service层加事务而不是Controller层。成绩管理的核心是评委打分。普通用户肯定不能访问打分接口,所以接口上标@RequireRole(role = 2),只有教师角色能访问。成绩计算时涉及多个评委打分取平均,SQL可以这样写:Double avgScore = scoreMapper.getAvgScoreByWorkId(workId);对应Mapper XML:<select id="getAvgScoreByWorkId" resultType="java.lang.Double"> SELECT AVG(score) FROM score WHERE work_id = #{workId} </select>注意空结果处理,如果还没有任何评委打分,AVG返回null,需要判断一下。发布成绩时,管理员可以选择“一键公布”,实际上就是修改竞赛状态或者成绩表里的publish字段,学生端查询接口只返回已公布的成绩。4. Vue前端页面与接口联调4.1 前端工程结构和路由权限前端我用Vue3 + Vite + Element Plus,最近几年新项目多数这么搭。Vue2也可以,但Vue3是趋势,而且Element Plus在Vue3下表现很稳定。用Vite创建项目:npm create vite@latest competition-frontend -- --template vue cd competition-frontend npm install element-plus axios vue-router pinia npm run dev前端工程结构:src ├── api // 接口请求封装 │ ├── auth.js │ ├── competition.js │ └── ... ├── router // 路由配置 ├── store // 状态管理 ├── views // 页面 │ ├── login.vue │ ├── admin │ ├── teacher │ └── student ├── utils // 工具函数 └── App.vue路由权限分两块:一是未登录跳转登录页,二是不同角色访问不同菜单。最简单的方式是在路由meta里写role数组,然后路由守卫里判断当前用户角色是否在允许列表中。router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { const role = Number(localStorage.getItem('role')) const allowed = to.meta.roles if (allowed && !allowed.includes(role)) { next('/403') } else { next() } } })这个策略简单直接,适合大多数管理后台。菜单渲染时再根据角色过滤一次,前端不会把无权限的菜单展示给用户。4.2 核心页面设计与Axios封装后台管理系统页面就那几个套路:登录页、布局页、表格页、表单页、详情页。用Element Plus组件库能大幅提速,重点把Axios封装做好,这是前后端联调最关键的环节。Axios实例设置基础URL,并添加请求拦截器:import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' 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.msg || '请求失败') if (res.code === 401) { router.push('/login') } return Promise.reject(new Error(res.msg)) } return res.data }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )这段代码是前后端联调命脉。很多同学前后端对接不上,多半是BaseURL配错、跨域没解决或者拦截器处理不正确。做开发时Vite代理配置能解决跨域:// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端请求/api开头的地址,Vite在开发服务器阶段自动转发到后端8080端口,避免浏览器跨域拦截。接口文件推荐按模块拆分,不要把所有请求都写在一个大文件里。比如competition.js:import request from '../utils/request' export function getCompetitionList(params) { return request({ url: '/competition/list', method: 'get', params }) } export function createCompetition(data) { return request({ url: '/competition/create', method: 'post', data }) }4.3 表格、表单和状态流转的实现竞赛管理页面是典型的表格+弹窗组合。用el-table展示赛事列表,列包括竞赛名称、类别、报名时间、状态、操作按钮。状态列用el-tag显示不同颜色,这样一眼就能看清是报名中还是已结束。表单提交时注意日期格式化问题。Element Plus的el-date-picker默认返回Date对象或时间字符串,后端如果接收字符串,直接存MySQL的DATETIME即可。如果你用了Jackson,注意时区配置,否则数据库存储时间可能差了8小时。可在application.yml加:spring: jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss前端日期格式配置也要明确:<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="选择开始时间" />状态流转是业务难点。在报名管理页面,管理员或教师点击“通过”按钮,调用接口将报名记录状态从0改为1。前端操作后需要刷新列表或局部更新行数据。我的经验是,操作成功后不要直接修改当前行的status字段,而是重新调用列表接口刷新,这样后端最新状态才能同步到页面,避免因为复杂逻辑导致状态不一致。作品上传功能建议用el-upload组件,提交前设置文件大小限制和格式校验。后端用MultipartFile接收,保存到本地磁盘或者OSS。毕设一般存本地,把文件路径存到数据库,注意处理文件名冲突,比如使用UUID重命名:String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext;静态资源访问配置也要有,否则上传的文件浏览时404。SpringBoot里加:@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/"); } }这是最容易踩坑的点之一,我后面会专门列个问题清单。5. 部署上线与常见问题排查5.1 本地运行与打包部署先在本地把项目跑通。MySQL要建好数据库并导入SQL文件;后端启动时检查端口占用和数据库连接配置;前端npm install耐心等完,把代理配置好。全部跑通后再考虑打包部署。后端打包:mvn clean package -DskipTests生成的jar包直接运行:java -jar target/competition-0.0.1.jar如果想让前端也通过Nginx部署在同一个端口,先构建前端:npm run build构建产物在dist目录,把dist下的文件复制到Nginx的html目录,然后配置反向代理:server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那行很重要,因为Vue路由是history模式,直接刷新某个非根路径会404,需要这里回退到index.html。如果不想搞Nginx,也可以用Vite的base配置配合SpringBoot把前端静态文件打进后端jar包,也能运行,但Nginx方案更通用,图个省事。MySQL数据库连接配置要检查几项:useSSL是否关掉、serverTimezone是否设置。否则启动时会报时区错误或者SSL警告。我常用配置:url: jdbc:mysql://localhost:3306/competition?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword5.2 常见报错和解决方案这部分我直接整理成表格,都是实操中高频出现的问题,每个都值得提前排查。现象原因解决方案前端请求接口报CORS错误后端未配置跨域或前端代理未生效开发环境用Vite proxy;生产环境配Nginx反向代理后端启动Failed to configure a DataSourceMySQL没启动、库名不对、密码错误检查application.yml和数据库状态JWT解析报SignatureException密钥不一致,重启后每次生成新密钥固定配置密钥,不要每次new随机密钥上传文件后访问404未配置静态资源映射WebConfig配置addResourceHandlers,访问路径和保存路径对应查询结果中文乱码数据库字符集不是utf8mb4建库时指定utf8mb4,连接URL加characterEncoding=utf8java.sql.SQLException: Unknown column 'xxx'实体类字段与表字段对不上检查MyBatis映射或开启驼峰自动映射前端刷新页面404路由history模式但Nginx未配置try_filesNginx配置location /中的try_filesWhitelabel Error Page后端接口路径跟前端请求不匹配统一RestController里的基础路径与前端api封装保持一致另外再分享一个实际问题:很多同学在IDEA里开发时,后端改了代码启动,结果前端还是旧数据。这不是Bug,多半是后端启动失败,或者端口被旧进程占用。Windows下可以用netstat -ano | findstr 8080查端口占用,然后用taskkill杀进程。Mac/Linux用lsof -i:8080。这个操作一定要熟练,否则排查能浪费一晚上。关于MyBatis,建议在application.yml里开启驼峰映射:mybatis: configuration: map-underscore-to-camel-case: true这样数据库字段create_time可以自动映射到实体属性createTime,省去写一堆ResultMap。如果不开启,查询结果里createTime就全是null,很隐蔽。使用MyBatis-Plus的话更好办,自带BaseMapper几乎所有单表CRUD都免写XML。毕设用MyBatis-Plus会省很多时间,而且答辩完全可以解释为“减少样板代码,提高开发效率”。但要注意,复杂多表关联查询还是要手写XML,不能太依赖条件构造器。最后给出我的个人实操体会:做这种管理系统项目,开发顺序很重要。先搭建数据库和项目骨架,按模块推进,每个模块完成后立刻用Postman或Apifox测接口,别等全部写完了再一起测。前端联调时优先处理登录和用户信息接口,把Token流程跑通,再开发其他页面,因为所有业务接口都依赖登录状态。我遇到过好几个同学,页面写了一大堆,最后登录Token没通,全部请求都401,心态直接崩了。先把基础打牢,再往上面垒功能,看起来慢,实际是最快的路径。数据还在的时候多测几个边界情况:同一用户重复报名、非评委访问评分接口、报名截止后提交作品。这类异常场景往往就是课程设计答辩时的加分点,你提前处理好了,演示起来自然流畅。如果时间充裕,还可以把Excel导出功能加上,用EasyExcel把报名列表导出来,这也是很讨喜的亮点功能。
搞学科竞赛管理系统这种题目,在毕业设计和课程设计里出现频率极高。导员需要一个能管理报名、审核、发布成绩的后台,参赛学生需要一个能看到赛事信息、在线报名、上传作品、查成绩的入口,老师则需要一个能评审打分、导出数据的操作台。这种三方角色、多状态流转的项目,正好能完整展示SpringBoot+Vue全栈开发能力,所以很多同学选它作为毕设。本文就基于这个“SpringBoot+Vue学科竞赛管理平台”,把从数据库设计、后端接口、前端页面到部署上线的完整思路和实操细节都掰开揉碎讲一遍,重点说哪些坑是你自己写代码时容易忽略的。我做过好几个类似的管理系统,说实话,这类项目功能本身不难,真正拉开差距的是对业务状态的理解、权限控制的严谨性,以及接口和页面的细节处理。下面直接从需求分析开始,带着你逐步把平台从0到1搭起来。1. 从需求到架构:这个学科竞赛平台到底该怎么搭1.1 学科竞赛管理的痛点与功能拆解先想清楚一个问题:学科竞赛管理在日常工作中到底麻烦在哪?赛事信息发布渠道分散,学生看不到统一通知。报名靠Excel统计,人多容易漏、容易错。作品提交格式不统一,文件命名混乱。评委打分后数据汇总工作量大,还容易算错平均分。学生想查自己成绩只能等通知,状态不透明。所以,一个合格的竞赛管理平台,至少要覆盖完整业务闭环:用户登录 → 查看赛事 → 在线报名 → 上传作品 → 评委打分 → 管理员发布成绩 → 学生查看结果。平台用户大概分三类:角色核心诉求权限边界学生浏览赛事、报名、传作品、查成绩只能操作自己的报名记录教师/评委发布赛事、审核报名、打分可管理自己创建的赛事,对分配到的作品评分管理员全局配置、用户管理、数据汇总所有模块,可操作任何数据功能模块也就随之清晰了:用户管理、赛事管理、报名管理、作品管理、评分管理、成绩发布、公告通知、数据统计。看起来不少,但每个模块拆开并不复杂,典型的CRUD加状态流转。1.2 为什么选SpringBoot+Vue,而不是其他组合有同学问,用SSH行不行?用JSP行不行?不是不行,但你得考虑毕设答辩和后续维护。SpringBoot最大的优势是简化了Spring配置,内嵌Tomcat,打个jar包就能跑。配合MySQL做数据存储,是Java领域最主流、参考资料最多的组合。Vue作为前端框架,组件化开发让页面维护非常方便,而且Vue2或Vue3都有大量现成的后台管理模板可以借鉴。选择这个组合还有几个现实原因:前后端分离的架构更容易在答辩时讲清楚,一个专门讲后端RESTful接口,一个专门讲前端组件通信和路由控制。SpringBoot生态完善,Spring Security或JWT方案都很成熟,写权限控制有标准打法。Vue+Element UI做后台页面是真的快,表格、表单、弹窗都是现成组件,几天就能把页面撸完。对于学习者来说,这套技术栈的代表性足够强,毕业后写简历也能拿得出手。当然,你也可以用MyBatis-Plus操作数据库,甚至用Redis做缓存。但既然是课程设计级需求,我建议不要过度设计,默认单机部署,MySQL+SpringBoot+Vue就够用,稳定、好演示、不给自己挖坑。2. 数据库设计与核心表结构2.1 用户、角色与权限怎么建模表结构设计是这类项目的命脉,后端的业务逻辑基本都围绕表关系展开。我建议不要搞复杂的角色权限框架,就用最简单直接的“用户表 + 角色字段 + 中间关联表”来实现。用户表可以这么设计:CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', `password` VARCHAR(255) NOT NULL COMMENT 'BCrypt加密后的密码', `real_name` VARCHAR(50) COMMENT '真实姓名', `student_no` VARCHAR(20) COMMENT '学号', `email` VARCHAR(100), `phone` VARCHAR(20), `role` TINYINT NOT NULL DEFAULT 3 COMMENT '1-管理员 2-教师 3-学生', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 0-禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP );这里的role用TINYINT数字表示,1、2、3分别对应管理员、教师、学生。很多同学会用字符串存储角色,比如"admin"、"teacher"、"student",这也能行,但用数字更省空间,而且后端做判断时用int比较不容易写错。前端再根据数字映射成中文名称。不过需要注意,如果后续角色权限扩展更复杂,比如教师分成“评委”和“指导老师”,那单字段就不够用了。稳妥做法是加一张角色表和用户角色关联表,后端判断权限时用角色编码。对于毕设来说,单字段+TINYINT已经能应付,答辩时讲清取舍即可。密码绝对不能用明文存储。使用Spring Security的BCryptPasswordEncoder加密,即使数据库泄露,也不会直接暴露密码。我看到太多毕设代码把密码直接放在数据库里,这是重大安全隐患,答辩老师一眼就能挑出毛病。2.2 竞赛、报名、作品、评分表的设计要点业务核心表不能少,竞赛表(competition)、报名表(enrollment)、作品表(work)、评分表(score)是最基本的四张。另外,竞赛和评委的关系也比较关键,建议单独建一张竞赛评委关联表。竞赛表:CREATE TABLE `competition` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(100) NOT NULL COMMENT '竞赛名称', `description` TEXT COMMENT '竞赛简介', `type` VARCHAR(50) COMMENT '竞赛类别,如编程、数学建模', `start_time` DATETIME COMMENT '报名开始时间', `end_time` DATETIME COMMENT '报名结束时间', `status` TINYINT DEFAULT 0 COMMENT '0-未开始 1-报名中 2-进行中 3-已结束', `creator_id` INT COMMENT '创建人ID', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP );报名表:CREATE TABLE `enrollment` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `competition_id` INT NOT NULL, `user_id` INT NOT NULL, `team_name` VARCHAR(100) COMMENT '团队名称', `status` TINYINT DEFAULT 0 COMMENT '0-待审核 1-已通过 2-已拒绝 3-已取消', `apply_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_comp_user` (`competition_id`, `user_id`) );报名表上加唯一联合索引很有必要,防止同一个学生重复报名同一个竞赛。这个细节虽然小,但能体现你对数据库设计的思考。作品表:CREATE TABLE `work` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `enrollment_id` INT NOT NULL COMMENT '关联报名ID', `work_name` VARCHAR(100) COMMENT '作品名称', `work_file_url` VARCHAR(255) COMMENT '文件路径', `description` TEXT, `submit_time` DATETIME DEFAULT CURRENT_TIMESTAMP );评分表:CREATE TABLE `score` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `work_id` INT NOT NULL COMMENT '作品ID', `judge_id` INT NOT NULL COMMENT '评委用户ID', `score` DECIMAL(5,2) COMMENT '打分成绩', `comment` VARCHAR(500) COMMENT '评语', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_work_judge` (`work_id`, `judge_id`) );评分表上的唯一索引也是关键,一对作品一个评委只能打一次分,避免重复记录。成绩计算的时候,如果多个评委打分,可以取平均分,也可以去掉最高最低再平均,具体按竞赛规则来,后端Service里写清楚即可。竞赛与评委的关联表:CREATE TABLE `competition_judge` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `competition_id` INT NOT NULL, `judge_id` INT NOT NULL COMMENT '评委用户ID,关联user表' );这类关联表在可视化关系时很直观,在MyBatis的Mapper里做多表关联查询也方便。数据库设计阶段把主外键关系理清,后面写代码能省一半时间。外键约束可以看情况加,有些人喜欢在代码层维护关联关系,但至少逻辑外键要在设计文档里体现。3. SpringBoot后端核心实现3.1 项目结构与统一返回体设计后端项目结构我习惯按功能分包,而不是按层分包。按层分包是controller/service/dao三层各建一个包,把所有Controller堆在一起。按功能分包则是一个模块对应一个包,比如user、competition、enrollment、work、score,每个包里有Controller、Service、Mapper。这样当你需要改动某个业务时,只需要在一个包内操作,思路清晰得多。推荐结构示例:com.example.competition ├── controller │ ├── UserController.java │ ├── CompetitionController.java │ ├── EnrollmentController.java │ └── ... ├── service │ └── impl ├── mapper ├── entity ├── dto ├── vo ├── config └── common统一返回体是前后端分离项目必须做的事。没有统一返回格式,前端处理数据就要写一堆重复代码。我推荐一个简单的Result类:@Data public class Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.code = 200; result.msg = "操作成功"; result.data = data; return result; } public static <T> Result<T> error(String msg) { Result<T> result = new Result<>(); result.code = 500; result.msg = msg; return result; } }code用200/500已经够用了,也可以细分:400代表参数错误、401代表未登录、403代表无权限。不过毕设阶段不用强行参照HTTP状态码,只要前后端约定一致即可。前端Axios统一拦截器里对code做判断,非200就弹错误提示,代码清晰又省事。3.2 JWT登录鉴权与角色权限控制登录鉴权方案推荐JWT,无状态,适合前后端分离,实现也不复杂。流程就是:用户输入用户名密码 → 后端校验 → 生成Token返回前端 → 前端每次请求带在Header里 → 后端拦截器校验Token并解析用户信息。先添加依赖,SpringBoot 2.x用:<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>注意jjwt 0.9.1依赖javax.xml.bind,JDK8没问题,如果你用JDK11以后,需要额外加jaxb-api,否则启动会报错。用SpringBoot 3.x的同学建议换jjwt 0.11.5,API有变化。JWT工具类核心方法就三四个:生成Token、解析Token、判断是否过期。Token里放userId和role就可以,不放敏感信息。密钥写死在配置里也行,最好用Base64编码的长字符串。登录接口逻辑:public Result<LoginVO> login(LoginDTO dto) { User user = userMapper.findByUsername(dto.getUsername()); if (user == null || !bcrypt.matches(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } if (user.getStatus() == 0) { return Result.error("账号已被禁用"); } String token = jwtUtils.generateToken(user.getId(), user.getRole()); LoginVO vo = new LoginVO(); vo.setToken(token); vo.setRole(user.getRole()); vo.setRealName(user.getRealName()); return Result.success(vo); }把role放进Token里很有用,后续拦截器解析出角色后可以直接做权限判断,省得每次请求都查数据库。当然,如果有人修改了角色,只靠Token里的信息不够安全。严谨的做法是在需要时再去数据库校验角色,但毕设不用过度设计。拦截器实现权限控制时,我建议用HandlerInterceptor,在preHandle里从Header取Token,无效则返回401错误。然后自定义一个注解,比如@RequireRole(role = 2),在Controller方法上标注需要哪种角色。拦截器里通过反射拿到方法上的注解再判断。这样写出来的代码比较乾净,答辩讲起来也清楚。3.3 竞赛报名和成绩管理的业务逻辑竞赛模块的接口无非是增删改查,但要处理好状态联动。比如报名截止时间到了,status自动变成进行中;报名未开始的竞赛不能报名;竞赛结束后不能再提交作品。这些规则写在Service层,别散落在Controller里。报名业务伪代码:@Transactional public Result<String> apply(EnrollmentDTO dto) { // 1. 检查竞赛存在且状态为报名中 Competition comp = competitionMapper.findById(dto.getCompetitionId()); if (comp == null || comp.getStatus() != 1) { return Result.error("竞赛不存在或不在报名时间"); } // 2. 检查是否已报名 int count = enrollmentMapper.selectCountByCompAndUser(dto.getCompetitionId(), currentUserId()); if (count > 0) { return Result.error("请勿重复报名"); } // 3. 创建报名记录 enrollment.setStatus(0); // 待审核 enrollmentMapper.insert(enrollment); return Result.success("报名成功,等待审核"); }注意@Transactional,如果后续还有作品表、审核表的同时操作,必须保证事务一致。这里要在Service层加事务而不是Controller层。成绩管理的核心是评委打分。普通用户肯定不能访问打分接口,所以接口上标@RequireRole(role = 2),只有教师角色能访问。成绩计算时涉及多个评委打分取平均,SQL可以这样写:Double avgScore = scoreMapper.getAvgScoreByWorkId(workId);对应Mapper XML:<select id="getAvgScoreByWorkId" resultType="java.lang.Double"> SELECT AVG(score) FROM score WHERE work_id = #{workId} </select>注意空结果处理,如果还没有任何评委打分,AVG返回null,需要判断一下。发布成绩时,管理员可以选择“一键公布”,实际上就是修改竞赛状态或者成绩表里的publish字段,学生端查询接口只返回已公布的成绩。4. Vue前端页面与接口联调4.1 前端工程结构和路由权限前端我用Vue3 + Vite + Element Plus,最近几年新项目多数这么搭。Vue2也可以,但Vue3是趋势,而且Element Plus在Vue3下表现很稳定。用Vite创建项目:npm create vite@latest competition-frontend -- --template vue cd competition-frontend npm install element-plus axios vue-router pinia npm run dev前端工程结构:src ├── api // 接口请求封装 │ ├── auth.js │ ├── competition.js │ └── ... ├── router // 路由配置 ├── store // 状态管理 ├── views // 页面 │ ├── login.vue │ ├── admin │ ├── teacher │ └── student ├── utils // 工具函数 └── App.vue路由权限分两块:一是未登录跳转登录页,二是不同角色访问不同菜单。最简单的方式是在路由meta里写role数组,然后路由守卫里判断当前用户角色是否在允许列表中。router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { const role = Number(localStorage.getItem('role')) const allowed = to.meta.roles if (allowed && !allowed.includes(role)) { next('/403') } else { next() } } })这个策略简单直接,适合大多数管理后台。菜单渲染时再根据角色过滤一次,前端不会把无权限的菜单展示给用户。4.2 核心页面设计与Axios封装后台管理系统页面就那几个套路:登录页、布局页、表格页、表单页、详情页。用Element Plus组件库能大幅提速,重点把Axios封装做好,这是前后端联调最关键的环节。Axios实例设置基础URL,并添加请求拦截器:import axios from 'axios' import { ElMessage } from 'element-plus' import router from '../router' 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.msg || '请求失败') if (res.code === 401) { router.push('/login') } return Promise.reject(new Error(res.msg)) } return res.data }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )这段代码是前后端联调命脉。很多同学前后端对接不上,多半是BaseURL配错、跨域没解决或者拦截器处理不正确。做开发时Vite代理配置能解决跨域:// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端请求/api开头的地址,Vite在开发服务器阶段自动转发到后端8080端口,避免浏览器跨域拦截。接口文件推荐按模块拆分,不要把所有请求都写在一个大文件里。比如competition.js:import request from '../utils/request' export function getCompetitionList(params) { return request({ url: '/competition/list', method: 'get', params }) } export function createCompetition(data) { return request({ url: '/competition/create', method: 'post', data }) }4.3 表格、表单和状态流转的实现竞赛管理页面是典型的表格+弹窗组合。用el-table展示赛事列表,列包括竞赛名称、类别、报名时间、状态、操作按钮。状态列用el-tag显示不同颜色,这样一眼就能看清是报名中还是已结束。表单提交时注意日期格式化问题。Element Plus的el-date-picker默认返回Date对象或时间字符串,后端如果接收字符串,直接存MySQL的DATETIME即可。如果你用了Jackson,注意时区配置,否则数据库存储时间可能差了8小时。可在application.yml加:spring: jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss前端日期格式配置也要明确:<el-date-picker v-model="form.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" placeholder="选择开始时间" />状态流转是业务难点。在报名管理页面,管理员或教师点击“通过”按钮,调用接口将报名记录状态从0改为1。前端操作后需要刷新列表或局部更新行数据。我的经验是,操作成功后不要直接修改当前行的status字段,而是重新调用列表接口刷新,这样后端最新状态才能同步到页面,避免因为复杂逻辑导致状态不一致。作品上传功能建议用el-upload组件,提交前设置文件大小限制和格式校验。后端用MultipartFile接收,保存到本地磁盘或者OSS。毕设一般存本地,把文件路径存到数据库,注意处理文件名冲突,比如使用UUID重命名:String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newName = UUID.randomUUID().toString().replace("-", "") + ext;静态资源访问配置也要有,否则上传的文件浏览时404。SpringBoot里加:@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + System.getProperty("user.dir") + "/upload/"); } }这是最容易踩坑的点之一,我后面会专门列个问题清单。5. 部署上线与常见问题排查5.1 本地运行与打包部署先在本地把项目跑通。MySQL要建好数据库并导入SQL文件;后端启动时检查端口占用和数据库连接配置;前端npm install耐心等完,把代理配置好。全部跑通后再考虑打包部署。后端打包:mvn clean package -DskipTests生成的jar包直接运行:java -jar target/competition-0.0.1.jar如果想让前端也通过Nginx部署在同一个端口,先构建前端:npm run build构建产物在dist目录,把dist下的文件复制到Nginx的html目录,然后配置反向代理:server { listen 80; server_name localhost; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files那行很重要,因为Vue路由是history模式,直接刷新某个非根路径会404,需要这里回退到index.html。如果不想搞Nginx,也可以用Vite的base配置配合SpringBoot把前端静态文件打进后端jar包,也能运行,但Nginx方案更通用,图个省事。MySQL数据库连接配置要检查几项:useSSL是否关掉、serverTimezone是否设置。否则启动时会报时区错误或者SSL警告。我常用配置:url: jdbc:mysql://localhost:3306/competition?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword5.2 常见报错和解决方案这部分我直接整理成表格,都是实操中高频出现的问题,每个都值得提前排查。现象原因解决方案前端请求接口报CORS错误后端未配置跨域或前端代理未生效开发环境用Vite proxy;生产环境配Nginx反向代理后端启动Failed to configure a DataSourceMySQL没启动、库名不对、密码错误检查application.yml和数据库状态JWT解析报SignatureException密钥不一致,重启后每次生成新密钥固定配置密钥,不要每次new随机密钥上传文件后访问404未配置静态资源映射WebConfig配置addResourceHandlers,访问路径和保存路径对应查询结果中文乱码数据库字符集不是utf8mb4建库时指定utf8mb4,连接URL加characterEncoding=utf8java.sql.SQLException: Unknown column 'xxx'实体类字段与表字段对不上检查MyBatis映射或开启驼峰自动映射前端刷新页面404路由history模式但Nginx未配置try_filesNginx配置location /中的try_filesWhitelabel Error Page后端接口路径跟前端请求不匹配统一RestController里的基础路径与前端api封装保持一致另外再分享一个实际问题:很多同学在IDEA里开发时,后端改了代码启动,结果前端还是旧数据。这不是Bug,多半是后端启动失败,或者端口被旧进程占用。Windows下可以用netstat -ano | findstr 8080查端口占用,然后用taskkill杀进程。Mac/Linux用lsof -i:8080。这个操作一定要熟练,否则排查能浪费一晚上。关于MyBatis,建议在application.yml里开启驼峰映射:mybatis: configuration: map-underscore-to-camel-case: true这样数据库字段create_time可以自动映射到实体属性createTime,省去写一堆ResultMap。如果不开启,查询结果里createTime就全是null,很隐蔽。使用MyBatis-Plus的话更好办,自带BaseMapper几乎所有单表CRUD都免写XML。毕设用MyBatis-Plus会省很多时间,而且答辩完全可以解释为“减少样板代码,提高开发效率”。但要注意,复杂多表关联查询还是要手写XML,不能太依赖条件构造器。最后给出我的个人实操体会:做这种管理系统项目,开发顺序很重要。先搭建数据库和项目骨架,按模块推进,每个模块完成后立刻用Postman或Apifox测接口,别等全部写完了再一起测。前端联调时优先处理登录和用户信息接口,把Token流程跑通,再开发其他页面,因为所有业务接口都依赖登录状态。我遇到过好几个同学,页面写了一大堆,最后登录Token没通,全部请求都401,心态直接崩了。先把基础打牢,再往上面垒功能,看起来慢,实际是最快的路径。数据还在的时候多测几个边界情况:同一用户重复报名、非评委访问评分接口、报名截止后提交作品。这类异常场景往往就是课程设计答辩时的加分点,你提前处理好了,演示起来自然流畅。如果时间充裕,还可以把Excel导出功能加上,用EasyExcel把报名列表导出来,这也是很讨喜的亮点功能。