SpringBoot+Vue前后端分离重构高校学科竞赛管理系统:从数据库设计到Tomcat部署 把学校那一套学科竞赛管理从线下纸质表搬到线上是很多高校迟早要面对的事。我接手这个项目时学校已有的旧系统还是JSP那套老架构每次一到报名高峰期就卡死评委打分还得拿着纸质评分表手动汇总光统计分就占了老师大半天时间。所以这次重构我直接选了 SpringBoot Vue MyBatis MySQL 这套前后端分离组合整套做完之后从赛事发布、在线报名、教师审核、作品提交、评委打分到成绩公示一条链路全打通了。这篇文章我会把完整源码结构、数据库设计思路、后端接口实现、前端动态菜单对接以及最后部署到 Tomcat 的整个流程都摊开讲一遍。尤其会重点聊聊那些文档里不会写的坑——比如跨域配置、MySQL连接串SSL报错、Vue打包后的刷新404、MinIO接入时的权限设置。如果你正在做一个类似的管理系统选题或者想把前后端分离的完整流程走一遍这篇应该能给你省掉不少踩坑时间。1. 为什么学科竞赛平台值得用前后端分离重构1.1 旧系统痛点一个JSP时代的遗留项目我之前看过旧系统的代码感触很深。它的问题是所有页面逻辑都写在 JSP 里Java 代码、HTML 标签、SQL 语句全搅在一个文件里。要改一个按钮颜色也得找到那个 JSP 文件改完再重启 Tomcat。页面里嵌了c:forEach循环嵌套c:if三层起步每次改需求都得在几十行标签里来回找。更麻烦的是前端资源、后端接口、数据库访问全部耦合在一套 Web 应用里前端想动就得等后端编译后端改接口又怕影响页面渲染。到了比赛报名那天几十个队伍同时刷新页面Tomcat 默认线程池扛不住连接数一高就直接雪崩。这些不是我编的是真实发生过的场景。所以这次重构我的目标很明确不打算继续在旧代码上打补丁直接推倒重来用前后端分离的方式做一版新的。所谓前后端分离核心就一句话——前端只负责页面渲染和用户交互后端只负责提供接口和数据两者通过 HTTP/JSON 通信。前端是 Vue 单页应用后端是纯 RESTful API互不干扰各改各的。1.2 技术选型不是炫技而是解决具体问题技术选型这一块我认真对比过几套方案。SpringBoot 的价值在于约定优于配置以前 SSM 要写一大堆 XML 配置SpringBoot 通过自动配置基本省掉了这部分内嵌 Tomcat 也让我在本地跑起来只要一条命令。Vue 选择它的理由也很实际——组件化开发非常适合这类后台管理系统报名表单、比赛卡片、成绩表格都能拆成独立组件复用而且 Vue 在国内社区活跃遇到问题搜解决方案很容易。MyBatis 和 JPA 之间我选了 MyBatis。原因是竞赛平台涉及大量复杂查询——按状态筛选比赛、统计各学院报名人数、评委打分汇总排名。MyBatis 的 XML 里可以写精细控制的动态 SQL而 JPA 虽然实体映射方便但遇到复杂表关联时生成的 SQL 往往不是你想要的调起来反而费劲。MySQL 就不用多说了学校预算有限MySQL 免费、部署简单、运维生态成熟一个 8G 内存的小服务器完全够用。这套组合不是最新最潮的但它足够稳定、足够可控对一个要长期维护的高校项目来说是稳妥的选择。提示如果团队里有现成的若依框架RuoYi使用经验直接基于它二次开发也是一种路径。不过我还是建议自己搭一遍哪怕只是最小骨架你会对每个组件的协作关系理解深得多。1.3 源码结构一屏看完后端和前端的分工项目采用了完全分离的目录结构后端和前端是两个独立工程互不嵌套competition-platform/ ├── backend/ # SpringBoot 后端工程 │ ├── src/main/java/com/school/competition/ │ │ ├── controller/ # 接口层只做参数接收和结果返回 │ │ ├── service/ # 业务层核心业务逻辑 │ │ ├── mapper/ # MyBatis 数据访问层 │ │ ├── entity/ # 数据库实体映射 │ │ ├── config/ # 配置类跨域、拦截器、MinIO、WebMvc │ │ ├── common/ # 统一返回包装、异常处理 │ │ └── util/ # JWT、文件处理等工具 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML 映射文件 │ └── application.yml # 主配置 └── frontend/ # Vue 前端工程 ├── src/ │ ├── api/ # 接口请求封装 │ ├── views/ # 页面组件 │ ├── router/ # 路由配置 │ ├── store/ # 状态管理Pinia │ ├── components/ # 通用组件 │ └── utils/ # 封装的 axios 等工具 └── vite.config.js # 开发代理配置这个结构的核心逻辑就是各司其职。后端按 MVC 分层前端按页面和功能模块组织。代码写久了你会发现这种清晰的分层带来的最大收益不是某个技术细节而是协作效率——前端和后端可以并行开发接口只要提前约定好字段谁都不用等谁。2. 核心业务模块与数据库设计的几个关键决策2.1 从比赛到发榜六张核心业务表的事件链竞赛平台看起来简单但把完整流程捋一遍后你会发现它其实是一条事件链赛事发布 → 学生报名 → 教师审核 → 作品提交 → 评委打分 → 成绩公示。每一步都有状态流转每一步都可能被退回或修改。我在设计表结构时围绕这条链路梳理了核心业务表表名作用关键字段sys_user用户表username, password, real_name, role_idsys_role角色表role_name, role_keysys_menu菜单权限表path, component, permscompetition竞赛表title, type, status, start_time, end_timeregistration报名表competition_id, user_id, statuswork作品表registration_id, file_url, video_urlscore评分表work_id, judge_id, score_valueannouncement公告表title, content, publish_time这八张表是核心。重点说一下 registration报名表——我特意把报名和作品分开而不是把作品信息直接塞在报名记录里。因为一个队伍提交作品前可能会修改多次而且报名状态和作品状态不同步报名审核通过不代表作品已经提交。竞赛表里我加了一个status字段用整数枚举表示0草稿1报名中2评审中3已结束4已归档。所有页面上的按钮显隐都根据这个状态来判断比如评审中就不能再报名。这个设计在后端代码里省了大量的 if-else 判断逻辑。2.2 报名约束和成绩冗余表设计里藏的两个小机关表结构设计里面有两个让我印象深刻的决策值得单独拿出来讲。第一个是报名唯一性约束。一个学生同一场比赛只能报一次这是硬性规则。除了在代码里做校验我还在数据库层面加了唯一索引ALTER TABLE registration ADD UNIQUE INDEX uk_competition_user (competition_id, user_id);为什么要双层校验因为并发场景下如果两个请求同时进来光靠代码里的先查再插是可能两个请求都通过校验的。数据库唯一索引是最后一道防线保证了数据层面的绝对唯一。我在做这个项目之前就吃过这种并发穿透的亏所以这次直接加了索引。第二个是成绩冗余。score 表里每一条记录是一张评分表的明细但如果公示页面要显示总分排名每次实时从 score 表聚合计算几百条记录虽然数据库能扛住但页面响应就会变慢。我的做法是在 competition 表里加了total_score和ranking字段评分结束后由后端统一计算并写回。查询公示列表时只需要查竞赛表不用每次 join 评分明细。这种冗余设计在互联网公司叫空间换时间在学校项目里同样适用。关键是冗余的字段要由明确的计算流程来维护不能在多个地方随意更新否则数据很容易不一致。2.3 权限设计用RBAC但菜单表要能动态扩展平台里涉及四类角色管理员、教师评委、学生、学院教务。权限设计我用了经典的 RBAC基于角色的访问控制模型——用户挂角色角色挂菜单权限。具体实现是三张核心表加两张关联表sys_user、sys_role、sys_menu以及 sys_user_role、sys_role_menu。sys_menu 表的设计是这次的一个重点因为它直接驱动了前端的动态路由和侧边栏渲染。表里的字段包括父菜单ID、菜单名称、路由路径path、前端组件地址component、菜单类型目录/菜单/按钮、权限标识perms。举个例子评委角色登录后他的 menu 列表里只有比赛评审作品评分这两个菜单前端根据这个列表动态注册路由侧边栏就只会渲染他有权看到的菜单。学生登录看到的则是赛事报名我的作品我的成绩。这里有一个细节要提醒按钮级别权限用 perms 字段控制。比如删除比赛是一个按钮操作普通教师可能只有查看权没有删除权。前端在渲染按钮前会先判断当前用户是否拥有对应的 perms 标识没有就直接不渲染这个按钮而不是渲染了再弹无权限。2.4 字段预留与状态机给需求变更留后路做这类系统最怕的就是需求变动。比如比赛场地一开始可能只有一个文本字段后来可能要加校区楼栋具体教室再后来又要存地图坐标。如果表结构写死了每次改需求都要改表、改实体类、改接口相当痛苦。我在这套表设计里做了一个目前看来很值的决定给核心业务表都预留了ext字段类型是 JSON 字符串。扩展信息先统一存到 ext 里等需求稳定了再把高频使用的字段抽成独立列。这样做虽然不够范式化但在实际项目中非常实用——它让表结构不轻易变动前端接口也保持稳定。竞赛状态流转我也做了统一管理在 Service 层写了一个状态机判断核心逻辑是一个状态只能从特定前置状态变更而来。比如评审中不能直接从草稿跳转必须先经过报名中。代码里用一个 Map 维护状态流转规则private static final MapInteger, ListInteger STATUS_TRANSITIONS new HashMap(); static { // 草稿状态只能进入报名中或废弃 STATUS_TRANSITIONS.put(0, Arrays.asList(1, 4)); // 报名中可进入评审中或回到草稿 STATUS_TRANSITIONS.put(1, Arrays.asList(2, 0)); // 评审中可进入已结束 STATUS_TRANSITIONS.put(2, Collections.singletonList(3)); }改状态时先查这个 Map 校验不合法直接抛出业务异常。这套机制的收益是业务逻辑集中在一处不会出现A 接口把状态改成 3、B 接口又把状态改回 1这种混乱。3. SpringBootMyBatis后端实现重点代码的实战写法3.1 pom.xml的依赖选择版本踩坑从脚手架开始后端项目我是从 Spring Initializr 生成的初创依赖只要了几个核心dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency这里有一个我实际踩过的版本坑。SpringBoot 3.x 发布后很多人直接生成最新版但 3.x 要求 JDK 17学校服务器往往还在用 JDK 8。我在一开始用了 SpringBoot 3.0.5 JDK 8编译直接失败后来老老实实把版本降到 2.7.x。所以如果你目标环境是 JDK 8SpringBoot 直接选 2.7.x 就行别追新。MyBatis 的 Starter 也要注意SpringBoot 2.7.x 对应的是 mybatis-spring-boot-starter 2.3.x 这个版本序列别用成 3.0 的。这个坑还挺隐蔽因为启动报错时的提示信息不太直观。3.2 MyBatis的XML动态SQL报名名额校验与列表分页MyBatis 我用 XML 方式写复杂 SQL注解方式处理简单查询。两者分工清楚了维护起来很舒服。举个例子赛事管理后台的分页列表筛选条件包含比赛名称模糊查询、比赛类型精确匹配、比赛状态多选、时间范围过滤。这种场景用注解写会非常痛苦但 XML 里的where标签和if标签可以轻松搞定select idselectCompetitionList resultTypecom.school.competition.entity.Competition SELECT * FROM competition where if testtitle ! null and title ! AND title LIKE CONCAT(%, #{title}, %) /if if testtype ! null and type ! AND type #{type} /if if teststatusList ! null and statusList.size() 0 AND status IN foreach collectionstatusList itemstatus open( separator, close) #{status} /foreach /if /where ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} /selectwhere标签会自动去掉第一个多余的 AND这个细节很实用。foreach处理状态列表过滤避免了手动拼接 IN 子句时的 SQL 注入风险。分页我直接用 MySQL 的 LIMIT 实现没有引入 PageHelper。因为这个系统的数据量级是万级LIMIT 完全够用少一个依赖就少一分版本冲突的可能。如果是百万级数据的大系统再考虑分页插件也不迟。3.3 JWT登录拦截与角色权限校验前后端分离的认证骨架前端分离后Session 那套方案就不好使了——因为前端和后端不同域Cookie 的跨域传递麻烦而且移动端调试也不方便。我选了 JWTJSON Web Token做认证。流程是这样的用户登录 → 后端校验用户名密码 → 生成 JWT里面包含用户ID、用户名、角色 → 返回给前端 → 前端把它存在 localStorage → 每次请求在 Header 里带Authorization: Bearer token→ 后端拦截器校验 token 有效性把用户信息放进 ThreadLocal。拦截器核心代码Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Claims claims JwtUtil.parseToken(token); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } } response.setStatus(401); return false; } }注册拦截器时要注意放行白名单——登录接口、公告查询这些不需要认证就能访问的路径必须在WebMvcConfigurer里用excludePathPatterns排除掉否则前端在登录前调接口全是 401。角色权限校验我用了一个自定义注解RequireRole标注在 Controller 方法上配合拦截器一起工作RequireRole({ADMIN, TEACHER}) PostMapping(/review) public Result reviewCompetition(RequestBody ReviewRequest request) { // 只有管理员和教师可以执行审核 }实现思路是在拦截器里判断 HandlerMethod 上是否有这个注解有则取出注解里的角色列表与 token 解析出的角色做比对。这种方式比写死在业务代码里优雅太多新增接口时只管标注解就行。3.4 MinIO接入与文件上传作品附件不再占Tomcat磁盘作品提交功能涉及文件上传包括图片、文档、视频。最开始的方案是直接存本地磁盘但很快就发现问题文件堆积后磁盘不够、Tomcat 重启后文件路径变化、后端服务器一旦挂掉文件全丢。后来我把文件存储换成了 MinIO 对象存储。MinIO 是开源软件可以部署在内网数据安全性高接口兼容 Amazon S3。SpringBoot 集成 MinIO 的核心是配置客户端Configuration public class MinioConfig { Value(${minio.endpoint}) private String endpoint; Value(${minio.access-key}) private String accessKey; Value(${minio.secret-key}) private String secretKey; Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }上传接口的核心操作是创建桶、设置桶权限、然后上传并返回访问 URL。这里有一个我踩过坑的细节MinIO 的 endpoint 要区分内网地址和外网地址。后端部署在服务器上用内网 endpoint比如 192.168.x.x:9000上传速度快但前端浏览器要访问文件如果返回的 URL 是内网地址学生宿舍就访问不到。解决办法是上传时用内网地址返回给前端的 URL 手动拼外网域名或者在 Nginx 里对 MinIO 做一层反向代理。作品视频上传后前端还要能播放。初期直接放 MP4 文件遇到大视频加载卡顿。后来我了解了 HLS 的玩法把视频转成 m3u8 切片前端用 hls.js 播放。Vue 里引入 hls.js 就两三行代码import Hls from hls.js; if (Hls.isSupported()) { const hls new Hls(); hls.loadSource(videoUrl); hls.attachMedia(videoElement); }m3u8 的切片协议对网络带宽要求低拖动进度也流畅体验比直接放 MP4 好很多。转码我用的是 ffmpeg 命令行工具写了一个批处理脚本作品上传后自动转码切片再把 m3u8 地址存到 work 表里。4. Vue前端动态菜单、接口封装与页面渲染经验4.1 Vue工程搭建与路由配置为什么我用Vue 3组合式API前端我用了 Vue 3 Vite Element Plus Pinia 这套组合。Vite 启动速度比 Webpack 快很多开发体验提升明显。Vue 3 的组合式 APIComposition API让逻辑复用变得更自然——报名页面里的倒计时逻辑、表单校验逻辑都可以抽成独立函数不再像 Options API 那样所有东西都堆在 data/methods/computed 里。工程结构上我把路由分成了两类constantRoutes静态路由和dynamicRoutes动态路由。静态路由包含登录页、404页、首页框架动态路由就是根据登录用户的角色动态加载的菜单页面具体在 4.3 小节展开。开发环境下的跨域代理配置在 vite.config.js 里export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样开发时前端请求/api/xxx会自动代理到后端的 8080 端口不需要后端处理跨域。但上线后跨域问题就要交给 Nginx 反向代理来处理这个后面部署章节会细说。4.2 Axios拦截器与401统一处理接口报错不再一锅粥axios 封装是整个前端工程质量的关键。我的统一封装包含请求拦截器、响应拦截器和错误处理三部分。请求拦截器的核心职责是自动带 tokenhttp.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });响应拦截器处理两件事业务错误码统一提示和401 跳转登录http.interceptors.response.use( response { const res response.data; // 后端返回的包装结构{ code, msg, data } if (res.code ! 200) { ElMessage.error(res.msg || 请求失败); return Promise.reject(new Error(res.msg)); } return res; }, error { if (error.response error.response.status 401) { ElMessage.error(登录已过期请重新登录); localStorage.removeItem(token); router.push(/login); } else { ElMessage.error(网络错误请稍后重试); } return Promise.reject(error); } );这里最重要的思路是后端返回的数据统一包装成{ code, msg, data }结构前端在拦截器里已经拆包业务页面拿到的是纯净的 data不需要每个页面都写一遍res.code 200的判断。出错时全局弹一条提示页面代码也清爽得多。4.3 动态菜单按角色渲染前端权限的一次性到位动态菜单的实现是前后端配合的典型场景。登录成功后前端拿到用户信息和角色然后请求一个专门的菜单接口// 登录成功 const res await login(form); localStorage.setItem(token, res.data.token); // 获取当前用户的动态菜单 const menuRes await getMenus(); const menus menuRes.data; // 树形结构菜单数据 // 遍历菜单生成动态路由 const dynamicRoutes generateRoutes(menus); dynamicRoutes.forEach(route router.addRoute(route));generateRoutes的核心逻辑是根据后端返回的 component 字符串映射到前端实际的组件对象const modules import.meta.glob(../views/**/*.vue); function generateRoutes(menus) { const routes []; menus.forEach(menu { const route { path: menu.path, name: menu.name, component: menu.component ? modules[../views/${menu.component}.vue] : null, children: menu.children ? generateRoutes(menu.children) : [] }; routes.push(route); }); return routes; }Vite 的环境下用import.meta.glob可以批量导入所有页面组件这样后端存的是组件路径字符串前端拿到后能映射成真正的组件。侧边栏的渲染数据结构也来自同一个菜单接口这样菜单显示什么、路由能跳哪里是由后端一个数据源驱动的想改权限只需要改数据库不需要重新发版前端。有一个很关键的细节刷新页面时动态路由会丢失。因为路由是登录后临时 addRoute 的刷新后 Pinia 状态清空动态路由就没了页面会白屏或 404。解决方案是在路由的全局前置守卫里判断如果本地有 token 但 Pinia 里没有菜单数据就先调一次 getMenus 重新生成路由再放行。这个坑几乎每个做动态路由的人都会踩我第一次上线时就是这个原因导致刷新直接白屏。4.4 比赛详情与成绩公示页的渲染细节页面开发里工作量比较大的是比赛详情页和成绩公示页。比赛详情页集成了赛事信息、报名时间倒计时、当前报名人数、在线报名按钮。倒计时我用了一个自定义 Hookfunction useCountdown(endTime) { const remainTime ref(); const timer ref(null); onMounted(() { update(); timer.value setInterval(update, 1000); }); onBeforeUnmount(() clearInterval(timer.value)); const update () { const diff new Date(endTime).getTime() - Date.now(); if (diff 0) { remainTime.value 报名已截止; clearInterval(timer.value); return; } const hours Math.floor(diff / (1000 * 60 * 60)); const minutes Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); const seconds Math.floor((diff % (1000 * 60)) / 1000); remainTime.value ${hours}小时${minutes}分钟${seconds}秒; }; return { remainTime }; }成绩公示页比较特殊它的访问范围不只是参赛学生全校师生可能都会点进来看。所以这个页面做了静态化处理——发榜时后端计算总分和排名后写入库前端公示页只需要读 competition 表不用关联评分明细。这样公示页面即使被大量访问后端压力也不大。只读一份数据 普通列表渲染性能根本不是瓶颈。5. Tomcat部署前后端分离项目的完整流程与踩坑记录5.1 打包前的配置分离环境差异不该改代码本地开发和线上部署的环境差异很大最典型的就是数据库地址、Redis 地址、MinIO 地址和文件访问域名。我的做法是用 Maven 的 profile 实现多环境配置# application-dev.yml 本地开发环境 server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/competition?useSSLfalseserverTimezoneAsia/Shanghai username: root password: root123 minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin# application-prod.yml 线上环境 server: port: 8080 spring: datasource: url: jdbc:mysql://192.168.1.100:3306/competition?useSSLfalseserverTimezoneAsia/Shanghai username: competition password: prod_pass_xxx minio: endpoint: http://192.168.1.100:9000 access-key: minio_prod secret-key: minio_prod_key打包时通过-Dspring.profiles.activeprod指定生效的 profile代码完全不需要改。这里有一条铁律线上环境的密码不要用明文写在 JVM 参数里而是通过环境变量注入。比如application-prod.yml里写${DB_PASSWORD}部署脚本里 export 环境变量配置文件和代码库分开管理。前端也一样我建了.env.development和.env.production两个文件# .env.production VITE_API_BASE_URL/api为什么生产环境的请求路径直接用/api而不是写死域名因为部署后前后端可能共用同一个域名通过 Nginx 的路径转发来区分这样就不需要因为域名变化重新打包了。5.2 部署方案Nginx托管前端与Tomcat部署后端怎么分工前后端分离项目部署有主流方案和保守方案我先说主流方案再说保守方案。主流方案推荐前端 build 后生成 dist 静态文件由 Nginx 托管同时 Nginx 把/api路径的请求反向代理到后端的 8080 端口。后端 SpringBoot 项目打成 jar 包直接运行或者打成 war 包放 Tomcat。我在生产环境用的 Nginx 配置要点server { listen 80; server_name competition.example.edu.cn; # 前端静态资源 root /var/www/competition/dist; index index.html; # 前端路由是 history 模式刷新时要回退到 index.html location / { 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html;这行就是解决刷新白屏的关键——前端是 Vue Router 的 history 模式直接访问/competition/123这个 URL 时服务器上并没有这个真实文件Nginx 就会把这请求回退到 index.html由前端路由接管。最后一步跨域问题在上线后其实已经不存在了前端和后端在同一个域名下前端请求/api/xxxNginx 转发到后端浏览器看到的是同源请求不需要 CORS。如果学生直接通过 IP 访问后端或者前后端分属不同域名才需要额外配置跨域。我见过不少人在生产环境还让后端开着 CorsFilter 允许所有域名这其实是不安全的。保守方案如果学校只给了一台 Windows 服务器不想装 Nginx那也可以全放 Tomcat。后端打 war 包放 webapps前端 dist 目录复制到 webapps/ROOT两个应用同时跑在 8080 端口。但这种方式有一个隐患前端静态资源和后端接口混在一个端口下需要后端额外处理上下文路径问题而且热更新和资源缓存都不如 Nginx 高效。如果你的服务器 Linux Nginx 一条龙能做到优先用主流方案。5.3 MySQL初始化、SSL连接错误和时区问题数据库这块我遇到过的三个问题值得分享。第一是 MySQL 版本选择。我测试时用了 MySQL 8.0线上服务器装的还是 MySQL 5.7这块坑主要是 jwt 0.9.1 依赖了 javax.xml.bind 相关的类JDK 8 环境下没问题但 JDK 11 会报错。这是单独的问题。MySQL 8.0 和 5.7 的主要差异在于认证插件——8.0 默认用 caching_sha2_password而老版本驱动只支持 mysql_native_password。如果驱动版本不对连接时会报认证失败。解决方法是使用 mysql-connector-j 8.0.x 并确保版本和数据库匹配。第二是 SSL 连接错误。用 Navicat 或客户端连接 MySQL 时偶尔会看到类似Establishing SSL connection without servers identity verification的警告。我在 JDBC 连接串里显式加useSSLfalse同时加serverTimezoneAsia/Shanghai解决时区问题。如果不处理时区默认 UTC 会导致数据库时间比北京时间早 8 小时成绩公示的时间全不对了。连接串完整写法spring: datasource: url: jdbc:mysql://localhost:3306/competition?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue注意allowPublicKeyRetrievaltrue这个参数——MySQL 8.0 在某些情况下特别是使用 caching_sha2_password 插件时会要求客户端先获取 RSA 公钥这个参数控制是否允许客户端自动获取。不加它时Navicat 或程序连接偶尔会报Public Key Retrieval is not allowed。第三是数据库初始化。我写了一个 schema.sql 和 data.sql导入时先用 root 创建数据库指定 utf8mb4 字符集和 utf8mb4_general_ci 排序规则。utf8mb4 是一个必须注意的细节它支持 emoji 表情和全量中文字符而 utf8 只支持基础 BMP 字符。竞赛报名时如果学生在备注里放了一个 emoji用 utf8 直接写入会报错。CREATE DATABASE IF NOT EXISTS competition DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;5.4 部署后常见问题排查链路从404到跨域部署上去之后大概率会遇到三个典型问题我把排查链路整理一下。第一个是登录接口 404。前端登录后请求/api/login浏览器 F12 显示 404。先确认后端服务是否启动成功curl http://localhost:8080/api/login。如果本机通但外部不通查防火墙是否放行了 8080。如果 Nginx 转发 404检查proxy_pass是否带了/——proxy_pass http://127.0.0.1:8080;和proxy_pass http://127.0.0.1:8080/;转发后的路径是有区别的前者保留原始 URI后者会重写根路径。这个细节很隐蔽。第二个是页面能打开但接口请求报跨域CORS。前面说过同域名部署下不应该有跨域问题。如果真有先看浏览器请求的 URL 是不是完整域名再看 Nginx 的 location 匹配是否命中了/api前缀。还有一种情况是后端开了 CORS 又叠加了 Nginx 的转发导致预检请求被拦截器反而挡掉。方案是生产环境后端关闭 CORS由 Nginx 做同源代理逻辑最清晰。第三个是上传文件后访问 404。这个分两种情况文件存在 MinIO 但返回的 URL 不对——MinIO 返回的地址是 endpoint 加 bucket 加文件名如果你上传时用的是内网 IP那前端从公网访问必然 404。另一种是 MinIO 桶权限设置成了 private需要给桶设置只读策略mc anonymous set download myminio/competition-files记得用mc命令或控制台给桶加好匿名访问策略不然就算 URL 对了浏览器也会因为没有文件读取权限而看到 XML 错误或 403。排查的核心思路前后端分离部署问题90% 是三个点找错——URL 路径没对上、代理转发没配对、防火墙/安全组挡了端口。按照先本机后外网先后端后前端的顺序排查基本半小时内能找到根因。6. 这个项目做完后我的几点复盘思考6.1 对前后端分离这件事的认识转变做完这个项目我对前后端分离的理解比以前深了一层。它不只是一个技术架构选择更是一种团队协作模式的重构。以前写 JSP前端改样式要等后端编译后端改接口要担心页面被搞坏。分离之后面试官常问接口联调到底在调什么——其实就是把提前约定好的字段格式和实际返回逐一对齐前端 mock 数据先行开发后端按接口文档同步实现两边并行推进。我们当时用 Apifox 管理接口文档每个接口的入参、出参、错误码都先定义好开发和测试的效率提升非常明显。这个项目也让我意识到技术选型的关键不是追求最新框架而是找到团队能力圈内最稳的组合。SpringBoot Vue MyBatis MySQL 这套方案虽然普通但团队成员熟悉、社区资料多、网上踩坑方案一搜一大把对长期维护的项目来说这恰恰是最值钱的优点。6.2 可以继续扩展的三个方向如果这个项目要继续迭代我会优先考虑三个方向。一是消息通知。比赛状态变更、报名审核结果、成绩发布目前都需要用户主动刷新页面查看体验不够好。可以引入 WebSocket 或者接入一个消息推送服务状态变更时实时通知学生和评委。二是成绩导出。成绩公示后学院需要把获奖名单导成 Excel 上报教务处。后端用 Apache POI 或 EasyExcel 生成 xlsx 文件前端一键下载这个功能虽然小但需求量很大。三是数据分析。把参赛人数趋势、各学院获奖分布、赛事类型热度做成可视化看板引入 ECharts 画图。用现在积攒的 MySQL 数据直接做聚合查询不需要额外引入大数据组件投入产出比非常高。提示如果你准备拿这个项目作为毕业设计或课设强烈建议在答辩演示前把权限控制和状态流转这两块讲透。面试官和答辩老师最常问的就是这两个点——它们能体现你真正理解了 RBAC 模型和业务状态机的设计而不只是会调接口。最后说一个做这类项目最容易被忽视的环节部署文档和运维手册。代码写完只是完成了一半留下清晰的数据库初始化脚本、部署步骤、常见问题排查文档三周后你自己维护时也会感谢当时的坚持。项目不是一次性的能跑起来的系统才真正有质变的开始。