SpringBoot+Vue3实战:高校竞赛管理系统完整设计与部署指南
好的直接进入正题。手头这套东西严格来说不算一个“纯源码分享”而是一整套可以跑起来的完整业务闭环学生报名、教师评审、竞赛发布、成绩管理前后端分离技术栈又是目前就业市场上最主流的 Java SpringBoot Vue3 MyBatis MySQL 组合。我用它给在校生做课程设计和毕设指导也帮几个刚转行的朋友理顺过全栈项目的完整链路今天就把这套系统的拆解思路、核心实现和避坑记录一次讲清楚。1. 这个项目到底在做什么业务场景与整体设计拆解先聊业务再聊代码。很多人拿到一套“高校竞赛管理系统”源码第一反应是看表和看接口但更容易忽略的是竞赛管理这件事本质上是一套“多角色、多状态、有严格时间窗口”的审批流转系统。搞清楚这个你才知道为什么代码要这样拆、表要这样建。1.1 需求背后隐藏的四个核心角色任何高校竞赛活动绕过不掉的参与方至少有四类系统管理员通常是教务或学院老师、竞赛发起人可能是某个系部的负责老师、评审专家评委、以及参赛学生。有的学校还有院系二级管理员那就得多加一层数据权限控制。“管理员”不光是发公告更核心的职责是配置竞赛流程参数报名开始/截止时间、作品提交截止时间、评审轮次、是否允许跨校组队、每个队伍的最大人数上限。这些参数如果在代码里写死那这套系统换个学校就没法用了所以必须做成一张配置表或者竞赛主表上的字段。“学生端”看起来最轻其实工作流最长注册/登录 - 浏览竞赛列表 - 报名填成员信息- 上传作品附件 - 查看评审进度 - 最终查看成绩与获奖等级。这里每一步都牵涉到状态字段的流转。“评审端”的痛点则在于“批量”和“盲评”。评委进入系统后应该只看到待评审的作品列表点击某个作品后能够给分、写评语且在一定条件下隐藏学生姓名减少人情分干扰。1.2 为什么选了前后端分离 这三件套这套项目用 SpringBoot Vue3 MyBatis MySQL不是赶时髦而是基于实际的约束条件做的选择。第一SpringBoot 解决了“配置地狱”问题。原生 SSMSpringSpringMVCMyBatis时代光一个 spring 配置文件就要写几十行还要处理各种 jar 包版本冲突。SpringBoot 的自动配置让你把一个空项目跑起来只需要几分钟这对课程设计、毕业设计这种有严格时间节点Deadline的场景极为友好。第二Vue3 Vite 的组合是目前前端工程化的主流。相比 Vue2 的 Options APIVue3 的 Composition API组合式 API配合script setup语法代码逻辑复用更方便特别是多个页面都要用到的“报名表单校验”“文件上传进度条”这类逻辑抽成 composables 函数非常干净。第三MyBatis 的灵活性和可控性在竞赛管理这类报表较多的系统里比自动建表的 JPA/Hibernate 更顺手。成绩排名、报名统计、获奖情况导出这些复杂统计查询用 XML 里手写 SQL 可以精细控制每一行逻辑排查数据问题也直观。第四MySQL 就不用多说了开源、免费、大学机房和云服务器上最容易部署也是招聘需求里出现频率最高的关系型数据库。1.3 模块划分的边界决定你后续开发效率我见到太多人做管理系统一上来就写代码结果业务边界模糊一个 Controller 里混着用户、竞赛、文件上传三种接口。这套系统的模块划分是很有参考意义的系统管理模块用户、角色、菜单权限、字典数据。这一块用现成的若依RuoYi或自研轮子都可以但核心是 RBAC基于角色的访问控制模型。竞赛业务模块竞赛发布、竞赛审核、报名管理、队伍管理。这是整个系统的核心域代码上要独立成一个 package不要和系统管理模块混在一起。评审模块评审分配、在线打分、评语填写、结果汇总。这里要注意提交评审和保存草稿的区别事务边界要控制好。内容与文件模块公告发布、作品附件存储、图片上传。这类模块和业务耦合度低单独拆出来方便复用也方便后续换 OSS 对象存储。边界清晰的另一个好处是你可以安排小组内不同成员并行开发互相之间只需要约定好接口文档Swagger 或者 Apifox 在线文档不用天天因为改一个字段而大量冲突。2. 后端从零搭建SpringBoot 核心配置与数据库表设计思路讲完业务接下来要落地了。后端这块我会把项目里最关键的配置、数据库表结构和几个核心功能的实现思路展开讲这些部分也是面试时最容易被追问的细节。2.1 项目结构标准化与版本选型用 Spring Initializr 生成项目时别只盯着默认配置版本选型上有几个建议直接照抄SpringBoot 2.7.x 还是 3.x如果 JDK 是 8选 2.7.x 最稳妥如果是 JDK 17可以上 3.x。但要注意 SpringBoot 3 的 Jakarta 命名空间变化javax.*改成jakarta.*很多老版本的 MyBatis 相关 starter 不兼容。MyBatis 用 spring-boot-starter mybatis-spring-boot-starter 2.3.x配合 MyBatis 3.5.x这套组合工作良好。切记不要自己手动引入 mybatis 和 mybatis-spring 然后漏了适配层。MySQL 驱动mysql-connector-java版本要和 MySQL 服务端版本对应8.0.x 的驱动连 5.7 的库一般没问题但反过来不行。项目的包结构推荐用controller / service / mapper / entity / dto / vo / config / common这套经典分层。竞赛这类系统自己内部逻辑复杂但对外提供接口时要严格区分DTO接收前端参数和VO返回前端数据避免把数据库实体直接暴露给前端这是规范性问题也能防止一些敏感字段意外泄漏。2.2 数据库表设计的核心逻辑竞赛管理系统的表不算多但有几张表的字段设计很考究。我把最核心的几张表列出来你可以对照自己的需求调整竞赛表competition除了常见的标题、描述、封面图、主办方、状态字段一定要有registration_start_time、registration_end_time、review_start_time、review_end_time这几个时间字段。状态字段建议用整数status0草稿1报名中2评审中3已结束4已取消不要用字符串。报名表registration核心字段是competition_id、student_id队长、team_name、member_count、status0待审核1已通过2被驳回3已撤销。这里有一个关键设计是否要建独立的队伍表我的建议是竞赛报名通常是“一次性组队”把队伍信息和报名信息合并在一张表里更好维护否则还要做队伍表和报名表的关联凭空多一层复杂度。评审表review_record字段要有registration_id、reviewer_id、score、comment、status。注意要建一个唯一的业务索引(registration_id, reviewer_id)保证同一个评委不会对同一份作品重复评两次。评分字段建议保留两位小数DECIMAL(5,2)。用户表sys_user通用字段之外学生用户要加student_no学号、college学院、grade年级。这里有个很好用的小技巧不要把角色直接写在用户表里而要拆成sys_user、sys_role、sys_user_role三张表走标准的 RBAC 模型。后续要加“二级管理员”角色时只需要在角色表里插一条数据而不需要改任何表结构。2.3 MyBatis 分页查询和动态 SQL 的常用写法竞赛管理系统的列表页特别多竞赛列表、报名列表、评审列表每一处都需要“条件查询 分页”。第一版我直接用LIMIT offset, size手写后来发现代码到处重复而且拼接条件容易出错。后来统一引入了PageHelper 分页插件用法很简单// 在 Service 层调用 PageHelper.startPage(pageNum, pageSize); ListCompetitionVO list competitionMapper.selectCompetitionList(query); PageInfoCompetitionVO pageInfo new PageInfo(list);使用 PageHelper 有几个容易踩的坑提前打个预防针PageHelper.startPage()只对紧接着的下一条 SQL生效。不要在调用它之后再执行别的查询语句否则分页就会套错对象。查询方法返回类型要保持一致。PageHelper通过 MyBatis 拦截器修改 SQL 实现分页它返回的List实际是Page对象如果你在 Service 层把它new ArrayList(list)复制了分页信息就丢了。多表关联查询时如果你用PageHelperCount 查询会自动生成但遇到复杂度太高的 SQL比如嵌套子查询建议手写SelectProvider或 XML 中的select idcount...单独提供 count 语句避免统计不准。动态 SQL 也是高频需求。报名列表要支持“按竞赛名称模糊搜索 按状态精确过滤 按学院下拉选择”XML 里可以用where标签把动态条件包起来select idselectRegistrationList resultTypecom.example.vo.RegistrationVO SELECT r.id, r.team_name, r.status, c.title AS competition_title, u.student_no, u.real_name, u.college FROM registration r LEFT JOIN competition c ON r.competition_id c.id LEFT JOIN sys_user u ON r.student_id u.id where if testkeyword ! null and keyword ! AND (c.title LIKE CONCAT(%, #{keyword}, %) OR u.real_name LIKE CONCAT(%, #{keyword}, %) OR u.student_no LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND r.status #{status} /if if testcollege ! null and college ! AND u.college #{college} /if /where ORDER BY r.create_time DESC /select注意这里不要用SELECT *显式列出需要返回的字段一方面性能更好更重要的是返回的VO字段和entity字段往往不一样列出来才能避免映射错乱。2.4 SpringBoot 中容易忘的三处配置后端项目跑起来有三个细节配置最容易被忽略但影响非常大。第一处YAML 里要开启驼峰映射。数据库字段名一般用下划线比如team_nameJava 属性用驼峰teamName如果没有开启map-underscore-to-camel-case: true你会发现查出数据后字段全是 null排查半天以为是 SQL 写错了。mybatis: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml第二处全局异常处理要早做。不要在每个 Controller 里都写 try-catch用RestControllerAdvice统一拦截异常返回统一格式的ResultT。比如校验参数异常、业务异常、未知异常分别包装成不同的错误码。这样前端 Axios 拦截器可以根据code统一处理错误提示而不是每次单独写。第三处跨域配置。前后端分离开发时最常见的报错就是跨域。在 SpringBoot 里做一个全局 CORS 配置类允许本地开发地址http://localhost:5173Vite 默认端口访问Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)的时候allowedOriginPatterns(*)是允许所有来源但不丢失 Cookie 信息的做法。生产环境建议把*换成具体的域名。如果你测试接口发现 OPTIONS 请求预检不过多半是这里配置漏了。3. 前端 Vue3 页面架构与登录权限控制实现后端接口就绪之后前端是做整体页面框架和联调的核心环节。Vue3 的项目搭建步骤和页面组织逻辑我单独拉一个部分说因为这里也有不少容易忽略的实战细节。3.1 Vite 初始化项目与常用依赖安装前端项目用 Vite 创建速度快配置也简洁npm create vuelatest交互式选择时建议勾选Router、Pinia、ESLint和Prettier这些都是写中后台系统的刚需。接下来安装 UI 组件库和 HTTP 工具npm install element-plus npm install axios npm install sass -D这里有个实用建议Element Plus 的图标库要单独安装element-plus/icons-vue。很多时候你页面上图标不显示就是因为只装了主包没装图标包。全局注册一下图标就可以直接使用了import { ElementPlusIconsVue } from element-plus/icons-vue for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) }3.2 路由守卫与登录状态控制竞赛管理系统有三种角色前端必须在路由层面做权限控制。具体分两步第一步路由配置里给每个页面标记meta 权限字段比如{ path: /review/list, name: ReviewList, component: () import(/views/review/ReviewList.vue), meta: { roles: [admin, reviewer] } }第二步在全局前置守卫里判断router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path ! /login !token) { next(/login) return } const userRoles JSON.parse(localStorage.getItem(userRoles) || []) const requiredRoles to.meta.roles if (requiredRoles !requiredRoles.some(role userRoles.includes(role))) { next(/403) return } next() })只存 token 不存用户角色刷新页面后权限信息就会丢失所以我一般会把角色信息也存到localStorage。如果追求更严谨可以在App.vue的onMounted里调一次/api/user/info拉取最新用户信息覆盖本地缓存。3.3 Axios 请求封装与文件上传进度处理Axios 封装的重点不是“写一个 request.js”而是把错误码统一处理、token 自动附加、401 踢回登录页这些逻辑收敛在一起。我在项目里是这么做的const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer ${token} } return config }) service.interceptors.response.use( response { const { code, data, message } response.data if (code 200) { return data } ElMessage.error(message) return Promise.reject(new Error(message)) }, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } )上传作品附件这块用到 Element Plus 的el-upload组件要注意它的action属性只能写完整 URL不走上面的service实例。所以上传接口我单独封装const uploadFile (file) { const formData new FormData() formData.append(file, file) return service.post(/file/upload, formData, { headers: { Content-Type: multipart/form-data }, onUploadProgress: (progressEvent) { const percent Math.round((progressEvent.loaded / progressEvent.total) * 100) // 更新进度条 } }) }另外很多人会忽略的一件事upload 成功后的返回体里需要给后端回传文件的 URL 或文件 ID而不是把整个文件对象塞进报名表单。数据库里存一个attachment_url字段就够了文件本身交给后端管理。3.4 页面状态管理Pinia 到底存什么很多 Vue3 初学者的误区是把用户信息、菜单、各种接口数据全塞进 Pinia。这会导致刷新页面后全部丢失还要重新请求很麻烦。我的经验Pinia 只存储“跨页面共享且需要响应式”的数据。这个系统里真正符合条件的是用户信息昵称、头像、角色、侧边栏折叠状态、当前选择的竞赛筛选条件。竞赛列表数据、报名数据这些应该由各页面自己管理刷新后重新从后端拉。这既符合 RESTful 风格也能让状态管理工具保持轻量。export const useUserStore defineStore(user, { state: () ({ userInfo: JSON.parse(localStorage.getItem(userInfo) || {}), roles: JSON.parse(localStorage.getItem(userRoles) || []) }), actions: { setUserInfo(info) { this.userInfo info localStorage.setItem(userInfo, JSON.stringify(info)) }, logout() { this.userInfo {} this.roles [] localStorage.removeItem(token) localStorage.removeItem(userInfo) localStorage.removeItem(userRoles) } } })4. 前后端联调、打包部署和权限数据初始化系统开发完不是终点能让它在服务器上稳定跑起来、有初始数据可用才算完整交付。这一块我很少见有人把踩坑过程完整写出来今天多说几句。4.1 联调阶段的代理配置开发阶段最大的痛点是前后端端口不一致引发的跨域问题。除了在后端配置 CORS更推荐在前端 Vite 里配置开发代理让请求直接从浏览器发出由开发服务器转发到后端// vite.config.ts server: { host: 0.0.0.0, port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里写死的请求地址是/api/xxx浏览器看到的域名也是localhost:5173就不会有跨域报错。联调阶段保持这个代理配置等上了生产环境再改成 Nginx 反向代理。4.2 后端打包和前端部署的配合后端打包很简单mvn clean package会生成一个可执行 jar。有几个关于启动命令和外部配置的经验JVM 参数根据机器配置设置。在课程设计的答辩演示环境里一般服务器就 2G 内存启动参数不要太大方-Xms256m -Xmx512m就够用了否则容易因为内存不够启动失败nohup java -jar -Xms256m -Xmx512m competition-system.jar --spring.profiles.activeprod app.log 21 配置文件外置。将application.yml放到 jar 包同级目录的config/文件夹下SpringBoot 会优先读取外部配置。这样改数据库密码、调整文件上传路径时不用重新打包。前端部署就两步先构建npm run build生成dist文件夹然后把里面的文件放到 Nginx 的html目录下配置反向代理转发 API 请求server { listen 80; server_name your-domain.com; 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 $uri $uri/ /index.html;是关键没有这一行前端项目里用 history 模式路由时刷新页面会 404。4.3 首次运行的初始化数据竞赛管理系统至少要准备两类的初始数据不然登录后是空页面。一类是角色和账号管理员、评委、学生三种角色至少各一个测试账号密码统一用 BCrypt 加密存放。这里有个细节登录接口里用BCryptPasswordEncoder.matches()校验密码时要注意数据库里不能存明文这是基本功但审计的时候总能看到有人就直接把明文放进 SQL 初始化脚本里。另一类是竞赛字典数据竞赛类型学科竞赛/创新创业/文体竞赛、竞赛级别校级/省级/国家级、状态字典。这些可以做成一个data.sql脚本在项目启动时自动执行。但要小心 SpringBoot 的spring.sql.init.mode配置如果设置为always每次启动都会执行可能导致重复插入数据。建议设置成never在首次部署时手动执行一次即可。5. 高频报错与疑难杂症排查实录代码写完了部署上线了开发过程中那些奇奇怪怪的报错才是真正消耗时间的地方。我把自己在开发这套系统时遇到的、以及帮学生排查时的典型问题整理成清单按“症状-原因-解法”来写。5.1 后端高频报错对照表报错现象可能原因解决方案启动时报Failed to configure a DataSource没有配置数据源信息或配置类没有被扫描到检查application.yml中spring.datasource.url/username/password是否完整调用接口报Invalid bound statement (not found)Mapper 接口和 XML 文件没有绑定成功检查 XML 路径是否在mapper-locations配置中且 XML 中的 namespace 与接口全限定名一致PageHelper分页数据对不上startPage()后执行了多条 SQL把startPage()移到目标 SQL 前一行中间不插入其他数据库操作前端传日期后端收到nullJSON 解析日期格式配置缺失在application.yml配置spring.jackson.date-format: yyyy-MM-dd HH:mm:ss上传文件报FileSizeLimitExceededExceptionSpringBoot 默认单文件最大 1MB配置spring.servlet.multipart.max-file-size: 100MB和max-request-sizeTable doesnt exist但 SQL 执行没问题多数据源或数据库选错了确认连接的是目标库且表名大小写配置一致Linux 下表名区分大小写5.2 前端高频报错对照表报错现象可能原因解决方案Uncaught SyntaxError: Unexpected token 部署时刷新了非根路径Nginx 没配置 fallback检查 Nginxtry_files是否配置API request failed with ERR_CONNECTION_REFUSED前端代理地址或后端服务端口不对先 curl 后端接口确认服务存活再检查代理 targetReferenceError: process is not definedVite 项目里使用了 Node 环境变量用import.meta.env替代process.env组件库按需导入后按钮样式丢失样式文件没有全局引入Element Plus 需要在main.js中import element-plus/dist/index.css页面白屏但控制台无异常打包后的资源路径不对检查vite.config.ts中base: ./配置避免部署到子路径时资源 4045.3 数据问题排查三个实战案例去年有个学生部署后反馈评审打分保存不上。查了半天发现是他的review_record表里没有设置联合唯一索引两个评委同时打分会造成重复数据后写入的覆盖前面的。我让他加了联合索引并改成“插入存在则更新”的写法问题就解决了。还有一个典型问题是竞赛报名人数统计不准确。页面上显示“已报名人数198”实际上数据库里只有 100 条。后来发现是统计 SQL 里用了COUNT(*)但报名表包含被驳回和已撤销的数据没有过滤状态。改成COUNT(CASE WHEN status IN (1, 2) THEN 1 END)才解决。第三个问题更有代表性查询“我的竞赛”列表时学生能看到别人报名的竞赛。排查到最后是因为registration表查询条件漏了student_id #{currentUserId}。这类数据越权问题在管理系统里很常见联调时一定要用两个学生账号各报一次名交叉验证数据隔离是否正确。6. 这套系统后续还能怎么扩展系统做完能跑只是第一步。如果你想让这个项目在答辩时有亮点、或者作为面试项目拿出来聊以下几个扩展点投入产出比很高。引入 Redis 做热点数据缓存。竞赛列表是典型的读多写少场景可以把首页的“正在报名的竞赛”缓存到 Redis设置 5 分钟过期。这样既能讲清楚缓存穿透、缓存击穿、缓存一致性这些面试高频话题又能切实减轻数据库压力。接一个文件存储服务。现在是存在本地磁盘换一台服务器文件就丢了。改用 MinIO 或者阿里云 OSS只需要替换掉FileService接口的实现类业务代码完全不用动。这个扩展点能展示你对“接口隔离原则”的理解。加一个消息通知模块。比如报名审核通过后给队长发站内信作品提交截止前一天给所有未提交的队伍发提醒。基于 SpringBoot 的事件机制ApplicationEventPublisher来做或者直接用一个消息表轮询复杂度都不高但产品的完整度会高很多。Excel 导入导出。管理员经常要导出“获奖名单”“报名信息表”做存档或上报。用 EasyExcel 封装一个通用导入导出工具类记住要分批读写不要一次性把几十万行加载进内存。最后再分享一个和代码无关的经验无论这套源码你是买的、网上找的还是自己敲的拿到手第一件事一定不是急着改功能而是把数据库跑起来用一个测试账号把核心流程完整走一遍管理员发布竞赛 - 学生报名 - 评委打分 - 学生查成绩。只有先把流程跑通你才真正掌握这套系统的骨架后续改起来心里才有底。我在实际调试中体会最深的就是这一点代码是最不会骗人的流程通了一切才真正开始。