ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于SpringBoot和Vue的竞赛管理系统:从JWT鉴权到部署实战

基于SpringBoot和Vue的竞赛管理系统:从JWT鉴权到部署实战 做竞赛管理系统这个选题我一直觉得是前后端分离项目里性价比最高的一类。业务模型清晰、角色边界明确、数据流转完整从用户到权限再到文件上传、状态流转、统计报表全都能覆盖到。尤其对于正在做毕业设计或者想系统练手全栈的同学来说一套基于SpringBoot Vue的大学生竞赛管理系统几乎把企业级开发里最常见的那些坑都踩了一遍JWT鉴权怎么做、文件上传怎么接、跨域怎么配、前端打包怎么塞进后端、路由守卫怎么拦截。这篇文章就把我这套系统的完整思路、核心代码、表设计和部署经验一次性讲清楚照着做你也能从零搭出一套能跑、能答辩、能演示的完整项目。1. 整体设计与技术选型先把业务想明白再动手1.1 角色划分与核心业务流程竞赛管理系统这个业务本质上就是“赛事全流程管理”。从管理员发布竞赛到学生报名、上传作品再到评委打分、成绩公示、证书下载一条线走完。传统的纸笔流程最大的问题是信息不透明学生不知道报名是否成功、评委之间分数互相看不到、管理员统计成绩全靠Excel。系统把这些环节数字化之后每步操作都有状态记录和时间戳权责清晰审计也方便。我在设计角色时划分了三种学生浏览竞赛列表、查看详情、报名参赛、上传作品、查看自己的成绩和证书。评委/教师对分配到名下的作品打分、填写评语、查看已评和待评列表。管理员用户管理、竞赛信息发布与状态控制、报名审核、评委分配、成绩统计与导出。这三种角色的权限边界要非常明确。学生不能看到评委打分页评委不能修改竞赛信息管理员不参与打分。权限控制如果做不好后续所有功能都会出现越权访问的漏洞。1.2 技术栈选型的三个理由这套系统我选择的是SpringBoot 2.7.x Vue 3 Element Plus MySQL 8 MyBatis Plus。选这套组合不是跟风而是有几个实际考量。第一个考量是SpringBoot版本。2.7.x是目前兼容性最稳的版本JDK 8和JDK 11都能跑各种第三方starter基本都能找到对应版本。SpringBoot 3.0之后强制要求JDK 17很多学生本机的JDK版本还是8一上来就报各种版本兼容错误光环境问题就能卡一整天。当然如果你本机已经是JDK 17直接用3.x也没问题只是要注意javax命名空间改成jakartaMyBatis Plus和相关依赖也要用适配版本。第二个考量是前端框架版本。Vue 3 Vite Element Plus是当前的主流组合Vite的启动速度比Webpack快一个量级开发体验好很多。组件库用Element Plus界面风格统一表格、表单、弹窗、上传组件都是现成的不用从零写UI。如果你更熟悉Vue 2 Element UI思路完全一样只是API细节略有差异不影响整体架构。第三个考量是持久层框架。MyBatis Plus在单表CRUD场景下几乎不用写SQL自带分页插件和条件构造器开发效率非常高。竞赛管理系统里大部分查询都是单表条件查询比如“查询某个竞赛下所有报名记录”“查询当前用户的所有作品”用LambdaQueryWrapper几行代码就搞定了没必要手写复杂的XML映射。1.3 数据库表设计的六个核心表数据库是整个系统的地基我踩过最深的坑就是表设计太随意改表结构比改代码痛苦十倍。竞赛系统核心表我设计如下前后端联调阶段的“接口字段和数据库字段对不上”问题源头就在这里。表名核心字段说明sys_userid, username, password, nickname, role, avatar, college, major, student_no, phone, email, status, create_time用户表role区分admin/teacher/studentcontestid, title, description, category, cover, max_team_members, registration_start_time, registration_end_time, contest_start_time, contest_end_time, status, create_time竞赛表status字段控制生命周期contest_registrationid, contest_id, user_id, team_name, team_members, contact_phone, status, create_time报名表status分为待审核/已通过/已拒绝work_submissionid, contest_id, user_id, registration_id, title, description, file_url, cover_url, submit_time, status作品表一个报名对应一个作品contest_scoreid, work_id, reviewer_id, score, comment, create_time评分表同一作品多个评委打分后取平均noticeid, title, content, type, owner_id, publish_time新闻公告表前台轮播和消息通知用重点说一下竞赛的status字段。我用了数字字典0表示草稿、1表示报名中、2表示评审中、3表示已结束。前端根据这个值控制按钮的可用状态后端在报名接口也校验当前时间是否在报名窗口内双重保险避免有人绕过前端直接调接口。另外一个关键设计是work_submission里的registration_id它把报名表和作品表关联起来。这样查询“某学生报名了哪些竞赛”和“某竞赛收了哪些作品”都非常方便。前期表设计时把外键关系理清后面写查询就是顺水推舟的事。2. 后端核心模块从登录鉴权到文件上传的完整闭环2.1 统一响应体与全局异常处理前后端分离项目接口返回格式必须统一。我封装了一个Result类所有接口返回{code, message, data}的结构。code为200表示成功401表示未登录403表示无权限500表示业务异常。这个封装看起来简单但能让前端拦截器的处理逻辑变得非常整洁。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }有了统一响应体还需要一个全局异常处理器把业务异常和系统异常分开处理。我是用RestControllerAdvice注解实现的捕获自定义的BusinessException抛出的异常时返回对应的错误码和提示语捕获Exception时统一返回500和“系统异常请稍后重试”避免把堆栈信息直接暴露给前端。这里有个开发习惯值得养成不要把try-catch写在每个Controller里而是让业务层抛出异常由全局处理器统一处理。代码会干净很多也不容易漏掉异常分支。2.2 Spring Security JWT登录鉴权实战登录鉴权是这类系统的重中之重。我用的是JWT无状态方案用户登录成功后后端签发一个包含用户ID、用户名、角色信息的Token前端请求时放在请求头的Authorization字段里后端拦截器解析Token并校验身份。这样做的好处是服务端不需要存储Session水平扩展时不需要做Session共享对部署和将来合入网关都很友好。JWT工具类核心代码如下注意设置过期时间和密钥Component public class JwtUtils { private static final String SECRET your-secret-key-your-secret-key; private static final long EXPIRATION 1000 * 60 * 60 * 24 * 7L; public String generateToken(Integer userId, String username, String role) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRATION)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }Security配置类是这个模块中最容易出错的地方。需要放行登录接口、验证码接口和静态资源路径其余接口全部走JWT过滤器。放行哪些路径看似简单但如果你把接口路径写错或者放行多了后面调接口时就会出现“明明登录成功了却一直401”的诡异问题。Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/login, /api/upload/**, /files/**).permitAll() .antMatchers(/api/admin/**).hasRole(admin) .antMatchers(/api/teacher/**).hasRole(teacher) .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); } }角色和路径的匹配规则要提前规划。我在实际开发中把接口路径做了约定/api/student/**学生接口、/api/teacher/**评委接口、/api/admin/**管理接口、/api/common/**公共接口。前端的API请求也遵循这个约定后端的鉴权配置就非常清晰。2.3 竞赛报名与作品提交状态机与并发控制报名和作品提交是竞赛系统的核心业务操作这两个接口在并发场景下特别容易出现脏数据。比如同一个学生重复提交报名或者报名截止时间刚过但请求还在路上。我处理的思路是双保险数据库层面加唯一索引代码层面做业务校验。报名表上加UNIQUE KEY uk_contest_user (contest_id, user_id)这样即使两个并发请求同时通过代码校验数据库也会拦截重复插入返回DuplicateKeyException。代码里再判断当前时间是否在报名窗口期、竞赛状态是否为“报名中”双重保障确保数据正确。作品提交的逻辑稍微复杂一点。学生在报名审核通过后才能上传作品上传后可以修改截止时间后锁定。我设计了works表的status字段0表示未提交、1表示已提交待评审、2表示已退回修改。评委退回作品后学生重新上传状态回到1进入评审队列。这个状态机设计是整个系统业务逻辑里最容易写乱的地方。我强烈建议先在纸上画出状态流转图待审核-已通过-已提交-已退回-已提交每个状态触发什么事件、谁触发、产生什么结果搞清楚再写代码。不然代码越写越乱改一个状态还要连带着改三四处逻辑。2.4 文件上传与静态资源映射竞赛作品通常是PDF、压缩包或者图片文件上传功能必不可少。SpringBoot的文件上传配置很简洁spring: servlet: multipart: max-file-size: 100MB max-request-size: 200MB文件上传的Controller稍微有点讲究需要做文件类型白名单校验、文件名重命名防路径穿越、按日期归档存储。我的实现逻辑是接收MultipartFile校验后缀名是否在允许列表里用UUID重新生成文件名存储到uploads/yyyy/MM/dd/目录下返回数据库里的相对路径。相对路径加上域名前缀就是完整的访问URL。前端上传PDF后需要预览这里有一个很实用的技巧如果浏览器可以直接打开PDF用iframe嵌; 但要注意Element Plus的upload组件默认会走form-data提交后端返回的JSON需要放在response里而不是自定义header里否则回显会有兼容问题。这个我后面在前端章节再展开讲。3. 前端Vue实践从脚手架到核心交互3.1 项目结构与路由设计前端项目我用Vite脚手架创建推荐的目录结构如下src/ api/ # 接口请求封装 auth.js contest.js work.js assets/ # 静态资源 components/ # 公共组件 CommonTable.vue FileUpload.vue router/ # 路由配置 index.js store/ # Pinia状态管理 user.js views/ admin/ # 管理端页面 student/ # 学生端页面 teacher/ # 评委端页面 Login.vue Home.vue路由设计上我用了动态路由和路由守卫结合的方式。静态路由只有登录页和首页其他页面根据用户角色动态生成。这样做的好处是角色菜单天然分离学生登录后看不到评委页面同时也减少了首屏加载的路由数量。路由守卫是必须写的不然用户手动改URL就能跳到没权限的页面router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else if (!token) { next(/login) } else { const userStore useUserStore() if (!userStore.userInfo) { userStore.fetchUserInfo().then(() { next() }).catch(() { next(/login) }) } else { next() } } })3.2 Axios请求封装与Token刷新逻辑Axios封装这项工作看似简单但却是前后端联调时体验好不好的决定性因素。我的封装思路是创建axios实例设置baseURL为/api请求拦截器里从localStorage取Token并放入Authorization头响应拦截器里统一处理HTTP状态码和业务状态码。const service axios.create({ baseURL: /api, timeout: 10000 }) 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 res response.data if (res.code 200) { return res } if (res.code 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(new Error(res.message)) }, error { return Promise.reject(error) } )特别是401的处理很多新手容易漏掉。Token过期后如果只是弹个“请重新登录”的提示用户就卡在页面上不知道该怎么办。跳转到登录页并清空本地存储是最直观的处理方式。3.3 核心页面交互竞赛列表、详情与倒计时竞赛列表页是学生接触系统的第一屏交互友好度直接影响使用体验。我用卡片形式展示竞赛信息每个卡片包含封面图、标题、竞赛类别、报名截止时间、当前状态标签。点击卡片跳转到详情页路由传递竞赛ID参数。这里最考验细节的是竞赛状态与按钮的联动。报名按钮要依据后端返回的status字段和当前时间动态计算status为1且当前时间在报名窗口内显示“立即报名”不在窗口内显示“报名未开始”或“已截止”status为2显示“评审中”status为3显示“已结束”。这些状态计算我放在前端写了一个工具函数export function getContestStatus(contest) { const now Date.now() const start new Date(contest.registrationStartTime).getTime() const end new Date(contest.registrationEndTime).getTime() if (contest.status 0) return { text: 未发布, type: info } if (contest.status 1 now start) return { text: 报名未开始, type: warning } if (contest.status 1 now start now end) return { text: 报名中, type: success } if (contest.status 1 now end) return { text: 报名已截止, type: danger } if (contest.status 2) return { text: 评审中, type: warning } if (contest.status 3) return { text: 已结束, type: info } }详情页我同时展示了竞赛介绍、时间线、赛事流程说明。倒计时组件用setInterval实现每秒钟更新剩余时间页面销毁时记得清除定时器。这个组件虽小但踩过的坑是路由切换后定时器没清掉导致组件销毁后还在执行setState控制台报一堆警告。3.4 文件上传组件与PDF预览系统最核心的上传场景是作品提交以PDF为主。Element Plus的el-upload组件提供了拖拽上传和进度显示但默认行为是选择文件后立即上传。我配置了:auto-uploadfalse用户选择文件后先展示在文件列表里点击“确认提交”才真正发起上传避免误选后无法撤销。上传成功后预览PDF我用了两种方案兼容不同场景。如果浏览器原生支持PDF预览直接新窗口打开文件URL就行。为了更好的展示体验我在前端页面内嵌了iframe将PDF文件URL作为src实测Chrome和Edge都能正常显示。如果需要更精细的控制比如指定页码、缩放可以使用pdf.js或者vue-pdf组件不过对于竞赛管理系统的场景iframe方案已经足够还省了一个大依赖。用户的头像预览也是一样的逻辑文件上传后返回相对路径前端拼接成完整URL用于img标签的src。这里注意一个问题开发环境下后端接口在8080端口前端在5173端口图片URL如果是相对路径浏览器会去5173端口找就404了。解决办法是用Vite的代理配置把/api和/files都代理到后端地址。4. 环境搭建、联调与部署实战4.1 本地环境准备与依赖配置开始写代码之前先把环境搞定不然后面踩的坑都是环境的锅。我建议本地环境如下JDK 8 或 11对应SpringBoot 2.7.xMaven 3.6配置阿里云镜像源否则依赖下载慢到怀疑人生Node 16Vite 4要求Node 14.18我推荐Node 16长期支持版MySQL 8.0字符集全部设置为utf8mb4否则中文乱码Maven的pom.xml核心依赖清单如下注意MyBatis Plus和SpringBoot的版本兼容关系parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies4.2 前后端联调跨域问题与接口规范前后端分离项目跨域问题几乎必然遇到。开发环境最省事的方案是Vite代理在vite.config.js里配置export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true }, /files: { target: http://localhost:8080, changeOrigin: true } } } })配置了代理之后前端代码里所有请求都写相对路径/api/xxx由Vite转发到8080端口。这种方式比后端配置CORS要优雅得多因为生产环境前端已经打包进后端工程本身就是同源的不存在跨域。如果后端非要用CORS我推荐写一个WebMvcConfigurer配置类而不是加CrossOrigin注解。CORS配置类写法如下注意要同时配置allowedOriginPatterns和allowedMethods否则预检请求会失败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); } }联调阶段另一个高频问题是时间格式不一致。后端实体类LocalDateTime序列化后默认是一长串数组前端无法直接渲染。需要在application.yml里配置Jackson的日期格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT84.3 Vue项目打包放进SpringBoot开发完成后部署最直接的方式是把Vue打包生成的dist目录塞进SpringBoot的静态资源目录。这个过程看似简单但有两个坑特别常见。第一步是修改Vite的base配置默认是/打包后资源路径是绝对路径部署到服务器子路径时会找不到静态资源。我建议改成相对路径export default defineConfig({ base: ./, build: { outDir: dist, assetsDir: static } })第二步是路由模式。如果前端路由用了history模式刷新页面时后端没有对应的路由处理器就会出现404。最简单的方案是后端加一个转发规则未匹配到的路径全部转发到index.html。如果不想动后端前端路由改用hash模式URL多一个#号但刷新永远正常。个人推荐hash模式省心。把dist下的文件复制到SpringBoot的src/main/resources/static目录然后启动后端访问http://localhost:8080/就是完整系统。这样整个应用只有一个Jar包部署到服务器只需要Java环境不用单独装Nginx。4.4 常见报错排查速查表写这套系统的过程中我把遇到过的高频报错整理成了一个速查表对照排查能省大量时间。现象可能原因解决方案前端请求后端404代理没配或路径拼错检查vite.config.js代理配置和API路径前缀登录后调用接口返回403Security配置放行路径不完整检查JWT过滤器注册顺序和路径匹配规则文件上传报错超过大小限制后端multipart配置未生效检查yaml配置中max-file-size和max-request-size中文乱码数据库字符集不是utf8mb4建库时指定utf8mb4连接串加characterEncodingutf8刷新页面404前端history路由没配转发用hash模式或后端加转发规则Token过期后接口一直报错前端没做401统一处理在响应拦截器里统一跳转登录页Vue启动报Node版本不兼容Node版本过低升级到Node 16或降低Vite版本还有几个容易忽略的小问题。SpringBoot启动时如果端口被占用控制台会直接报端口绑定失败搜索哪个进程占了8080端口杀掉或者改端口。MyBatis Plus分页插件需要单独配置PaginationInterceptor不配置的话分页不生效默认只查一条。另外前端打包时如果报内存溢出需要在package.json里配置build: vite build --max-old-space-size4096或者Node设置的NODE_OPTIONS变量。最后聊一点管理后台的统计报表功能。竞赛系统的管理者非常关注每个竞赛的报名人数、作品提交率、各学院参与人数分布。这些统计我用ECharts做曲线图和饼图后端提供一个聚合查询接口用MyBatis Plus的groupBy统计每个学院的人数。ECharts的引入方式很简单npm安装然后在需要用的组件里局部引入按需注册图表类型避免全量打包导致体积过大。还有评委打分环节的设计也要多说两句。传统做法是管理员给每个评委手动分配作品但作品数量一多就非常繁琐。我的做法是先把作品按类别分组评委可以主动认领作品也可以由管理员批量分配。打分维度我拆成了创新性、完整性、实用性三个子项每个子项记分总分自动汇总再取多个评委的平均分作为最终成绩。设置一个“成绩公示”开关控制学生是否能在前台看到自己的分数避免未出结果前分数泄露。这个项目后续可以考虑扩展的方向一个是接入消息通知模块报名成功和成绩公布时给用户发送站内信或者邮件另一个是增加数据可视化大屏页面把全校的竞赛参与情况用图表形式展示出来这个对答辩演示的加分效果是很明显的。再一个就是引入Redis缓存首页的竞赛列表和热门赛事情报减少MySQL的查询压力。我个人在实际维护这套系统的感受是业务功能本身不难难点都在细节的边界处理和数据状态的流转上。状态机的设计、权限控制的粒度、文件上传的健壮性、部署路径的兼容性这四块做好系统基本上就稳了。你在照着实现的时候如果遇到某个接口调不通或者某个状态不对先从数据表看一眼数据是不是脏了再往代码里排查绝大多数问题都是数据状态不符合预期导致的。
返回列表