1. 起点:大学生竞赛管理系统这个毕设题有多大的含金量
我接触过不少准备做毕业设计的同学,很多人一听到“管理系统”三个字就觉得没有技术含量,脑子里先冒出“不就是增删改查么”这个想法。但实际做完“基于Spring Boot + Vue的大学生竞赛管理系统”这个题目之后,我的观点变了:管理系统类题目不是没含金量,而是绝大多数人只做了表层的增删改查,没把业务逻辑和工程化细节做透。这个题真正的难点在于如何把一场竞赛从“发布通知”到“报名”再到“作品提交”“评委打分”“获奖排名”的完整闭环,在系统里跑顺,并且让管理员、学生、评委三种角色各司其职。如果你能把这个闭环讲清楚,代码实现规范,答辩时老师很难挑出大毛病。
先把这个系统的边界定清楚。大学生竞赛管理系统,本质上是一个多角色、多流程的信息化平台。它的核心服务对象是三类用户:
- 管理员:创建竞赛、审核竞赛、管理参赛队伍、分配评委、发布公告、设置奖项比例;
- 学生/参赛选手:查看竞赛列表、在线报名、组队、上传作品、查看成绩;
- 评委/指导教师:对学生提交的作品进行评审打分,查看评分汇总。
如果再拆细一点,系统还应该包含学院的维度,比如按学院统计参赛人数、按竞赛类型分析获奖率。这些数据在后端数据库设计阶段就提前预留字段,后面做统计报表的时候会省很多事。
那为什么偏偏选 Spring Boot + Vue?我自己的判断是,这套组合在当前环境下有三个很实际的优势。第一,Spring Boot 把 Spring 家族繁杂的 XML 配置砍掉了大半,一个依赖加一个启动类就能跑起来,对没接触过企业级开发的本科生来说学习曲线相对平缓。第二,Vue 是目前前端生态里上手成本最低的框架之一,配合 Element Plus 这类组件库,能很快搭建出符合学校系统审美的管理界面。第三,网上可参考的案例和问答极其丰富,遇到问题搜一下基本都能找到解决方案,这对毕设周期卡得很紧的学生来说非常重要。当然,这套组合也是国内很多中小型公司实际在用的技术栈,所以做完这个项目不仅是为了答辩,写在简历上也有说服力。
2. 数据库设计先行:表结构决定开发会不会返工
我见过太多人一上来就写 Controller,写到一半发现缺字段,又回去改表,反复横跳浪费大量时间。做管理系统,数据库设计一定要走在前头。尤其是毕业设计这种一个人要干完全部活的项目,表关系如果理不顺,后面每个功能模块都会跟着难受。
2.1 核心实体与关系梳理
大学生竞赛管理系统里,最基本的实体有这几个:用户、竞赛、报名记录、作品、评分记录、公告。如果你把奖励机制也算进去,还应该有奖项配置表。
以竞赛为主线来看这些实体的关系:
- 一个管理员可以发布多个竞赛,所以用户和竞赛是 1 对 N;
- 一个学生可以报名多个竞赛,一个竞赛可以被多个学生报名,所以用户和竞赛之间是多对多关系,中间需要有报名表来承接;
- 一次报名对应一个作品,所以报名记录和作品是 1 对 1 的关系(按团队参赛时,一个团队一份作品);
- 一个竞赛对应多条评分记录,因为每个评委都会对同一份作品打分,所以竞赛和评分记录是 1 对 N,作品和评分记录也是 1 对 N;
- 公告则相对独立,属于管理员批量的内容发布。
多对多关系一定要通过中间表转化,这是数据库设计的基本素养,也是答辩提问率极高的点。中间表不能只是简单地存两个外键,报名状态、报名时间、团队名称这些字段都应该放进报名表里,否则后面查“某竞赛报名了多少人、哪些人还在审核中”这类问题时,你要么写很复杂的关联查询,要么就得冗余字段,都不划算。
2.2 建表的核心 SQL
我用 MySQL 来设计,字符集统一用 utf8mb4,不要问为什么不用 utf8,等你哪天遇到用户提交的作品说明里带了一个 emoji 表情导致存储报错的时候,就会感谢这个选择了。下面是几张核心表的精简版结构,保留了最重要字段,方便你理解整体设计。
用户表:
CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(64) NOT NULL COMMENT '登录名', `password` VARCHAR(128) NOT NULL COMMENT '密码密文', `real_name` VARCHAR(64) DEFAULT NULL COMMENT '姓名', `role` TINYINT NOT NULL COMMENT '角色: 1管理员 2学生 3评委', `college` VARCHAR(128) DEFAULT NULL COMMENT '学院', `student_no` VARCHAR(64) DEFAULT NULL COMMENT '学号', `email` VARCHAR(128) DEFAULT NULL, `phone` VARCHAR(32) DEFAULT NULL, `status` TINYINT DEFAULT 1 COMMENT '1启用 0禁用', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';竞赛表:
CREATE TABLE `competition` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL COMMENT '竞赛名称', `type` VARCHAR(64) DEFAULT NULL COMMENT '竞赛类型', `level` VARCHAR(32) DEFAULT NULL COMMENT '校级/省级/国家级', `description` TEXT COMMENT '竞赛简介', `start_time` DATETIME DEFAULT NULL COMMENT '报名开始时间', `end_time` DATETIME DEFAULT NULL COMMENT '报名截止时间', `status` TINYINT DEFAULT 0 COMMENT '0草稿 1报名中 2评审中 3已结束', `create_by` BIGINT DEFAULT NULL COMMENT '创建人', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status` (`status`), KEY `idx_type` (`type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='竞赛表';报名表和评分表:
CREATE TABLE `competition_entry` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `competition_id` BIGINT NOT NULL COMMENT '竞赛id', `student_id` BIGINT NOT NULL COMMENT '学生id', `team_name` VARCHAR(128) DEFAULT NULL COMMENT '队伍名称', `member_names` VARCHAR(500) DEFAULT NULL COMMENT '团队成员姓名,逗号分隔', `status` TINYINT DEFAULT 0 COMMENT '0待审核 1通过 2驳回', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_competition_id` (`competition_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报名表'; CREATE TABLE `score_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `competition_id` BIGINT NOT NULL, `entry_id` BIGINT NOT NULL COMMENT '报名id', `judge_id` BIGINT NOT NULL COMMENT '评委用户id', `score` DECIMAL(5,2) NOT NULL COMMENT '评分', `comment` VARCHAR(500) DEFAULT NULL COMMENT '评语', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_entry_judge` (`entry_id`, `judge_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评分记录表';这里有一个容易被忽略的细节:评分记录表的唯一索引。评委点击提交评分时,后端如果没做幂等处理,用户双击按钮就可能产生两条评分记录。加了uk_entry_judge(entry_id, judge_id)之后,数据库层面就直接兜底了,同一评委对同一作品只能存在一条评分记录,这比在 Service 层写一堆 if 判断要可靠得多。
2.3 数据库设计中的几个典型教训
讲几个我实际踩过、也见别人踩过的坑。
第一个,不要把所有跟用户相关的信息都塞进一张表。比如管理员可能需要维护评委的职称信息,学生需要维护指导老师信息,如果全部用一张用户表加一个 role 字段硬扛,后期扩展字段时这张表会被改成“大杂烩”。我见过有人把“指导老师姓名”和“学生学号”放在同一行的,查询时各种为 null,维护起来极度痛苦。正确做法是用户表只保留通用字段,再针对不同角色用扩展表或模块级字段去承接特有信息。
第二个,时间字段一定要用 datetime 而不是 varchar。别小看这个问题,报名截止判断在系统里是一个非常核心的逻辑。如果时间存成字符串,你会在比较“是否已截止”时到处写字符串转日期的代码,而且一旦格式不统一,数据直接没法比较。直接用 datetime,配合 SQL 的原生比较和索引,区块排序、筛选都干净利落。
第三个,状态字段建议用 TINYINT 加注释,而不是用字符串枚举。虽然硬编码的 0、1、2 可读性确实差,但在 Java 枚举类里做一层映射之后,代码可读性就回来了。数据库里用数字存状态,查询性能和存储占用都更好,而且不容易因为一个多打一个空格导致查不出数据。
3. 后端开发的节奏与细节
数据库设计完成,后端开发才算真正有了图纸。这个阶段不要急着写功能代码,先把工程结构和依赖搭好。Spring Boot 版本的选择上,我建议用 2.7.x 而不是刚出的 3.x。不是新版本不好,而是在毕业设计这个场景下,你大概率会找很多网上的教程和开源代码参考,而老版本生态下的示例代码一搜一大把,遇到诡异问题很容易查到解决方案。这一点在“Spring Boot 版本太高”导致依赖不兼容的求助帖里体现得淋漓尽致。Java 用 8 或者 11 都行,JDK 8 稳,JDK 11 也不差,看你自己机器上装了什么,别折腾。
3.1 项目初始化与依赖选择
创建一个 Spring Boot 项目时,核心依赖我建议这样选:
- spring-boot-starter-web:提供 MVC 能力,Controller 开发的基础;
- mybatis-plus-boot-starter:MyBatis Plus 封装了单表 CRUD,能省掉大量样板代码;
- mysql-connector-java:MySQL 驱动;
- jjwt 或 hutool-jwt:做登录令牌;
- lombok:简化实体类 getter/setter;
- spring-boot-starter-validation:参数校验。
MyBatis Plus 是我在后端开发里强烈推荐的组件。它在 MyBatis 基础上封装了 BaseMapper,像单表查询、分页查询、条件构造器这些高频操作,都能直接调用现成方法,不用手写 XML。手写 SQL 的场景通常只出现在多表关联查询里,比如查“竞赛报名人数排行”或者“评委已评分数量”,这种时候再用@Select注解或 XML 都不迟。
做配置的时候,application.yml里几个点需要注意:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/contest_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver 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: 0map-underscore-to-camel-case一定要打开,这样数据库的create_time才能自动映射到 Java 里实体类的createTime字段,不用每个字段都去写@TableField注解。
3.2 登录鉴权:用 JWT 而不是 Session
管理系统必然涉及权限控制,我也强烈建议用 JWT 而不是传统的 Session。原因很简单:JWT 天然适合前后端分离项目。用户登录成功后,后端返回一个加密的 token,前端存到 localStorage 里,之后每次请求都在请求头带上Authorization: Bearer <token>,后端拦截器校验 token 解析出用户身份,不需要在服务端保存会话状态,部署和横向扩展都方便。
核心代码大致是这样的逻辑:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { token = token.substring(7); // 解析token,校验签名和过期时间 Claims claims = JwtUtil.parseToken(token); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } // 未登录,返回401 response.setStatus(401); return false; } }需要注意的是,拦截器只管“有没有登录”和“token 是否合法”,具体的角色权限判断要再写一层。最简单的思路是从 token 里取出角色信息,在 Controller 层用自定义注解或手动判断进行控制。比如管理员的接口只有 role 等于 1 的请求才能调用,否则统一返回无权限。
3.3 核心接口设计与实现
后端接口设计要想清楚两个问题:谁能调用,返回什么。我列一下这套系统最核心的接口清单:
| 功能模块 | 请求方式 | 路径 | 说明 |
|---|---|---|---|
| 登录认证 | POST | /api/auth/login | 登录成功返回 token 和用户信息 |
| 竞赛管理 | GET | /api/competition/page | 分页查询竞赛列表 |
| 竞赛管理 | POST | /api/competition | 新建竞赛 |
| 竞赛管理 | PUT | /api/competition/{id} | 修改竞赛 |
| 竞赛管理 | DELETE | /api/competition/{id} | 删除竞赛 |
| 报名管理 | POST | /api/entry | 学生报名参赛 |
| 报名管理 | GET | /api/entry/my | 查看我的报名记录 |
| 作品管理 | POST | /api/work/upload | 上传作品附件 |
| 评分管理 | POST | /api/score | 评委提交评分 |
| 评分管理 | GET | /api/score/result/{competitionId} | 查看竞赛成绩排行 |
| 公告管理 | GET | /api/notice/list | 查看公告列表 |
接口路径统一以/api开头,方便后续做统一的前缀处理或者网关转发。分页查询统一返回自定义数据结构,比如Result<T>实体,里面放 code、message、data 三样东西。这里有一个很实际的建议:分页参数用current和size,而不是pageNum和pageSize,因为 MyBatis Plus 的Page对象默认就是用这两个字段命名的。
3.4 评分业务逻辑:去掉最高最低分的计算
评分功能是竞赛管理系统的业务重点,也是你在答辩时可以重点展开讲的地方。它的逻辑不仅仅是往score_record表里插一条数据,还要考虑几个实际问题:报名人数很多时,评委按什么顺序评分?一个人评完能不能修改?最终成绩怎么算?
我建议的实现方式是:评委在评审页面看到分配给自己的待评分列表,点击某个作品之后进入评分详情页,填写分数和评语,提交后数据落在评分表里。因为前面已经建了唯一索引,同一个评委重复提交同一份作品只会触发更新,不会重复插入。
最终成绩计算,一般竞赛会采用“去掉一个最高分、去掉一个最低分,然后取平均”的规则。这个逻辑用 SQL 写比较绕,但在 Java 里实现很直观:
public BigDecimal calculateFinalScore(Long entryId) { List<ScoreRecord> scores = scoreRecordMapper.selectList( new LambdaQueryWrapper<ScoreRecord>() .eq(ScoreRecord::getEntryId, entryId) ); if (scores.isEmpty()) { return BigDecimal.ZERO; } BigDecimal sum = scores.stream() .map(ScoreRecord::getScore) .reduce(BigDecimal.ZERO, BigDecimal::add); if (scores.size() >= 3) { BigDecimal max = scores.stream().map(ScoreRecord::getScore).max(BigDecimal::compareTo).get(); BigDecimal min = scores.stream().map(ScoreRecord::getScore).min(BigDecimal::compareTo).get(); return sum.subtract(max).subtract(min) .divide(BigDecimal.valueOf(scores.size() - 2), 2, RoundingMode.HALF_UP); } return sum.divide(BigDecimal.valueOf(scores.size()), 2, RoundingMode.HALF_UP); }这个逻辑里一个值得注意的地方是BigDecimal的精度处理。数据库里我用DECIMAL(5,2)存储,Java 里就不能再用double运算,否则会出现 10.0 - 9.3 = 0.699999 这种经典浮点问题。用BigDecimal并设置保留两位小数,既符合实际业务需求,也避免被测试数据打脸。
4. Vue 前端的开发与踩坑
后端把接口调通了,前端才能真正进入开发状态。我见过不少同学喜欢前端做一半、后端做一半,然后两边对接时发现接口入参对不上,来回改。我的习惯是后端先把 Controller 写完,接口文档用 Swagger 或者 Apifox 导出,前端按文档去对接,配合效率高得多。
4.1 前端工程化和组件库选择
前端这里我建议直接用 Vue 3 + Vite + Element Plus 的组合。相比 Vue 2 的 Options API,Vue 3 的 Composition API 在代码组织上更有条理,而且 Vite 的开发服务器冷启动速度比 webpack 快很多,调试体验不是一个级别。当然,如果你对 Vue 2 的 Options API 更熟,或者网上找的模板是基于 Vue 2 + Element UI 的,沿用也没问题,技术选型只要能支撑项目完成,就是合理的。
创建项目:
npm create vite@latest contest-front cd contest-front npm install npm install vue-router@4 pinia axios element-plus这里插一句,“npm install 报错”“vue 安装依赖失败”是网上出现频率极高的问题。绝大多数情况是网络问题或 node 版本问题。解决思路很简单:先node -v确认版本,再设置 npm 源为国内镜像,最后再删除 node_modules 重新安装。前端的依赖问题百分之七八十靠这三板斧能解决。
4.2 路由与动态菜单设计
前端路由按照角色来划分是比较合理的。系统里有管理员、学生、评委三种角色,路由虽然写在同一份配置里,但要根据登录用户的角色做过滤,避免学生在地址栏手动输入/admin/competition/manage就能打开管理员页面。
简单的做法是在路由元信息里声明meta.roles:
{ path: '/admin/competition/manage', name: 'CompetitionManage', component: () => import('@/views/admin/CompetitionManage.vue'), meta: { roles: ['ADMIN'] } }然后在全局路由守卫里检查:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (!token && to.path !== '/login') { next('/login'); return; } const role = localStorage.getItem('role'); if (to.meta.roles && !to.meta.roles.includes(role)) { next('/403'); return; } next(); });这段代码的逻辑虽然简单,但是把“未登录拦截”和“越权访问拦截”都处理到了。前端路由守卫是最后一道展示层的防线,真正的权限控制还是在后端接口级别,前后端双重校验,才能拿得出手。
4.3 axios 封装与响应拦截
前端和后端交互,我习惯把 axios 封装成一个独立模块,统一处理 baseURL、token 注入、错误提示三个问题。下面是封装的核心逻辑:
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.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default request把 token 的注入统一放到拦截器里,而不是在每次请求里手动添加,是一个很重要的工程化习惯。这样即使后面新增几十个接口,也不容易出现“某个请求忘了带 token 导致 401”的低级错误。401 跳回登录页的逻辑放在响应拦截器里也集中且可控。
4.4 作品上传组件的前端细节
竞赛管理系统里面,作品上传是使用频率很高的功能。Element Plus 的el-upload组件默认走的是 FormData 文件上传,你需要设定action属性指向上传接口的地址,同时把 token 放入 headers 才能让上传请求通过后端拦截器校验。
有一点很容易踩坑:el-upload的on-success回调里的返回值,并不一定就是后端返回的原始数据结构,因为 Element Plus 内部可能对响应做了处理。稳妥的做法是在on-success里打印出来确认一层再往下写业务逻辑,不要凭感觉认为response.data.url就一定存在。
文件上传的两种常用处理方式:
- 前端把文件传到后端服务器,后端存储到本地磁盘或云存储,返回文件访问 URL,再把 URL 存到数据库作品表里;
- 前端直接把文件转成 Base64 传到后端,由后端保存。这种方式只适合小型文件,图片还可以,视频和压缩包几 MB 甚至几十 MB,直接 Base64 会拖垮请求体,完全不建议。
5. 前后端联调时最容易翻车的场景
到了联调阶段,你会把前面所有模块拼在一起跑,这时候的问题往往不是功能逻辑本身,而是环境、路径、部署相关的“低级问题”。但恰恰是这些低级问题,最容易卡住人半天时间。
5.1 跨域问题:前后端分离的第一个拦路虎
前端跑在http://localhost:5173,后端跑在http://localhost:8080,浏览器默认会拦截跨域请求,这就是所谓的 CORS 问题。解决思路有两个,二选一即可。
方案一是后端开启 CORS 配置,在 Spring Boot 里加一个配置类:
@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); } }方案二是在前端开发环境配置代理,在 Vite 的vite.config.js里:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }我个人更推荐方案二。因为后端一旦把 CORS 配成allowedOriginPatterns("*"),虽然开发时很爽,但放到生产环境上等于允许任意域名跨域调用接口。用前端代理的话,生产环境通常直接由 Nginx 做反向代理,前后端天然同源,根本不存在跨域问题,配置也更安全。
5.2 Vue 打包后布局异常是怎么回事
很多热词里“vue 打包后布局异常”是被搜索频率很高的问题,我自己第一次部署这个项目时也遇到过。前端开发环境一切正常,npm run build之后把 dist 文件扔到服务器,打开页面发现样式完全乱了,图标也不显示。
这个问题大部分原因出在静态资源路径上。Vite 打包后的默认资源路径是相对路径还是绝对路径,取决于base配置。如果你把前端文件部署在一个子路径下,比如http://localhost:8080/contest,但 base 默认是/,那么所有资源都会从根路径去找,自然会 404,样式也就丢了。
解决办法很简单,在vite.config.js里根据部署路径设置 base:
export default defineConfig({ base: '/contest/' })如果前端文件打算直接丢进 Spring Boot 的static目录,和系统保持同域同端口,base 设成'./'相对路径往往是最省心的选择。不过相对路径也有它的副作用,比如 Vue Router 使用 HTML5 history 模式时,刷新子路径页面会 404,这时候要么改用 hash 模式,要么让后端把所有请求都转发到 index.html。
5.3 打包部署的完整过程
部署方案我推荐两种,你自己按场景选。
第一种是把 Vue 打包产物放进 Spring Boot 项目里,直接打成一个 jar 包运行。操作步骤是:
- 前端执行
npm run build,生成 dist 目录; - 把 dist 目录里的文件全部复制到
src/main/resources/static目录下; - 后端执行
mvn clean package -DskipTests打成 jar 包; java -jar contest-system.jar启动,浏览器直接访问 8080 端口,前端页面和后端接口都在同一个服务上。
这种方式对毕业设计演示和答辩非常方便,一个 jar 包搞定全部,老师想看什么功能,你打开浏览器就能演示。缺点是每次改前端代码,都得重新打包复制,迭代起来比较烦。
第二种是前后端分离部署,Vue 打包产物扔到 Nginx 的 html 目录,后端接口通过 Nginx 反向代理转发。这种方式更接近真实企业环境,配置好后前端访问/api会被 Nginx 转发到后端的 8080 端口。我建议有条件的同学优先选这种方案,因为部署过程涉及 Linux 命令、Nginx 配置,这些都可以写进文档里作为“系统部署”章节,能明显加分。
5.4 文件上传路径的环境差异
作品上传功能的文件存储路径,在 Windows 本地开发时你可能会写D:/upload/works,但部署到 Linux 服务器后这个路径就不存在了。正确的做法是把上传根目录配置到application.yml里,添加上传文件的静态资源映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/files/**") .addResourceLocations("file:" + uploadDir + "/"); } }这样文件上传后返回给前端的 URL 就是/files/xxx.jpg,前端能直接访问,而物理文件实际存到的是你配置的目录。这个做法相当于把“业务访问路径”和“物理存储路径”解耦了,换一台服务器部署时只需要改配置,不用改代码。
6. 性能优化与功能增强
系统做完能跑了,其实只算完成了 60%。如果要让它在答辩时经得起追问,让老师觉得你考虑问题深入,还需要做一些性能和功能层面的增强。这部分的投入产出比其实挺高的。
6.1 分页查询与索引优化
管理系统里最常用的查询,就是分页查竞赛列表。如果不在后端做分页,而是把全部数据查出来交给前端去切片,那么数据量一旦上千条,接口响应就会明显变慢,前端渲染也会卡顿。MyBatis Plus 提供了分页插件,配置之后只需要在 Service 层传入 Page 对象即可:
public Page<CompetitionVO> pageCompetitions(int current, int size, String keyword) { Page<Competition> page = new Page<>(current, size); LambdaQueryWrapper<Competition> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(keyword)) { wrapper.like(Competition::getTitle, keyword); } wrapper.orderByDesc(Competition::getCreateTime); return competitionMapper.selectPage(page, wrapper); }光分页还不够,数据库索引要跟上。竞赛表的status字段承担了大量查询筛选工作,报名表的competition_id、student_id也经常出现在 where 条件里。这些字段如果不建索引,数据量上来之后全表扫描的速度会让人崩溃。在正式数据库设计里,就给这些高频查询字段建立索引,是性价比极高的优化手段。
6.2 数据库备份与迁移策略
写毕业论文时,“数据库设计”这一章除了表结构,最好再提一下数据库的备份与恢复方案。备份并不复杂,用 MySQL 自带命令就能完成:
mysqldump -u root -p contest_db > contest_db_backup.sql恢复时执行:
mysql -u root -p contest_db < contest_db_backup.sql学校实验室的机器环境经常不固定,今天在这个电脑上开发,明天换一台电脑继续写。这时候有个完整备份文件在手,就不会担心环境迁移丢数据。你也可以顺便提一下用 Navicat 或者数据库同步工具做定时同步,这些在实际工作中会接触到,写上说明文档也会让老师觉得你不止停留在课本知识层面。不过要记住,任何同步工具都不应该替代每天写代码之前的本地备份习惯,我见过太多人同步配置没配对,反而把线上数据覆盖了。
6.3 主动加分的扩展功能方向
这里分享几个我在原有系统上扩展过、也确实在答辩时被老师肯定的功能,你可以根据自己的时间安排选择性实现。
第一个是 Excel 批量导入导出。管理端把学生名单导入系统,或者把竞赛成绩导出成 Excel 给学院存档,用的是 EasyExcel 库,几行代码就能实现。这个功能很接地气,学校教务处的老师会喜欢。
第二个是邮件通知。竞赛报名审核通过后,自动发邮件提醒学生确认。尤其是有多个竞赛同时进行时,系统在后台将结果汇总并发送邮件,体验会好很多。Spring Boot 的spring-boot-starter-mail封装得很完善,QQ 邮箱或学校邮箱配置成 SMTP 就能用。
第三个是作品视频在线预览。很多创意类、视频类竞赛提交的作品是视频文件,如果前端能直接播放视频,会显得系统很完整。这里有一个技术点:原始视频 mp4 格式大文件在浏览器里播放效率不高,更专业的做法是转成 m3u8 分片流,前端用 video.js 或 hls.js 播放。虽然这个功能开发成本稍微高一点,但一旦做出来,整体项目的完整度和技术深度会高出不少,也正好踩中当下前后端视频处理的实操方向。
7. 文档、答辩和我翻过的车
毕设不只是写代码,文档和答辩占的分数比重甚至比代码还高。很多同学代码功能都实现了,结果文档写得像流水账,答辩被老师一问就卡壳,最后分数反而不如代码写得少但能说会道的同学。这一节我就专门聊聊文档和答辩这两件事。
7.1 毕业论文和设计文档怎么写才不虚
文档的核心目标只有一句话:让老师快速看到“你做了什么、为什么这么做、效果怎么样”。框架我推荐按这种顺序走:
- 绪论:背景、国内外现状、研究意义;
- 需求分析:系统参与者、功能需求、非功能需求,配合用例图;
- 总体设计:系统架构图、功能模块划分、技术选型理由;
- 详细设计:数据库设计(表结构、E-R 图)、核心接口设计、关键功能流程图;
- 系统实现:每张核心页面截图 + 对应代码块 + 一句话实现思路;
- 系统测试:测试环境、功能测试用例表、测试结果;
- 总结与展望。
有一个观点我得强调:需求分析绝对不只是为了凑字数,它是整个系统开发的指南针。你写“学生可以查看竞赛信息并报名”,就对应了后端的竞赛查询接口和报名接口;你写“管理员可以审核报名”,就对应了报名状态的更新逻辑。文档里的每一个需求点,都要能在代码里找到落点,这样答辩时老师问你“这个需求怎么实现的”,你才能对答如流。
系统的关键界面截图要放高清大图,代码只贴最核心的部分,不要让文档变成代码堆积。老师看论文的时间有限,他要看到的是你的结构能力和总结能力,而不是复制粘贴的代码搬运能力。
7.2 答辩高频提问与应对话术
以我参与过答辩现场的经验看,老师对管理系统类项目的高频问题集中在这几个:
第一个,为什么选 Spring Boot + Vue?我的建议不是背概念,而是用“解决了什么问题”的角度去答。比如 Spring Boot 解决了传统 Spring 配置繁琐、部署不便的问题,Vue 解决了前端页面复用和交互复杂度的问题,前后端分离可以让开发和维护更灵活。
第二个,Spring Boot 自动装配的原理是什么?这个问题很多同学答不好,其实核心就一句话:Spring Boot 通过@EnableAutoConfiguration引入大量AutoConfiguration类,依据 classpath 中的依赖和application.yml里的配置,在条件注解的控制下,自动完成 Bean 的注册。你讲清楚“条件装配”这个概念,老师就会觉得你理解了本质。
第三个,如果报名人数暴增,系统会出现什么瓶颈,怎么处理?这是扩展性问题。你可以从两个层面答:数据库层面加索引、加缓存;应用层面对报名接口做幂等处理,防止重复提交。如果你还能提到把对象存储从本地磁盘换成云存储,再引入消息队列削峰,那就更出彩了,实际做没做先不讨论,至少证明你有架构视野。
第四个,数据库设计范式的理解?回答里带上三个要点:第一范式要求字段不可再分,第二范式要求非主键字段完全依赖主键,第三范式要求消除传递依赖。在这个系统里,把用户信息拆到多张表就是第三范式的体现,但又因为查询性能,部分统计字段保留了少量冗余,这是范式和反范式的取舍。这样答,有理论有实践。
7.3 我翻过的三个经典亏
第一个是密码明文存储。第一版系统我开发时图省事,密码直接拿明文存进了数据库,后来在演示阶段用测试账号登录,Navicat 打开用户表时全被旁边同学看见了,场面一度十分尴尬。后来我改成 BCrypt 加密存储,Spring Security 里现成的BCryptPasswordEncoder,加盐哈希,安全性提升一大截。毕业后复盘这件事,我得出一个结论:做毕设也不能降低安全底线,密码加密这种动作成本极低,收益却极高,答辩时也值得写在创新点里。
第二个是异常处理不规范。系统第一版写 Controller 时到处 try-catch,但很多 catch 之后把异常吞掉了,前端永远只能收到一个 200 但 data 为空的响应。后来我统一用@RestControllerAdvice做全局异常处理,业务异常抛自定义的BusinessException,未知异常由兜底处理器捕获并记录日志,前端才能可靠地拿到错误提示,调试效率终于正常了。
第三个是文件上传后删除用户导致的脏数据。学生报名并上传作品后,管理员把该学生账号禁用了,结果作品表里还残留着这个人的数据,导致评分列表出现查不到用户名的情况。这个问题后来我通过外键逻辑约束和 Service 层校验来解决,删除用户前先查关联数据,有关联就提示“该用户已存在报名记录,不允许直接删除”,而不是放出一个数据库底层的外键报错给用户看。这种细节在答辩时反而很加分,因为说明你考虑过业务闭环,而不是只会照着 CRUD 模板写代码。
最后的个人体会是,借这个毕设把 Spring Boot 和 Vue 这套技术栈从头到尾走一遍,收益远比想象中要大。当你真正把一个系统从数据库设计、后端接口、前端页面、部署上线到文档撰写完整地做下来,你学到的已经不只是教科书上的语法,而是“如何把一个模糊的需求变成可运行的产品”的能力。这套系统做完以后,你完全可以在这个基础上往里面加功能、换技术组件,让它变成自己简历上真正讲得出来的经历。