把学校那一套学科竞赛管理从线下纸质表搬到线上,是很多高校迟早要面对的事。我接手这个项目时,学校已有的旧系统还是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_id |
| sys_role | 角色表 | role_name, role_key |
| sys_menu | 菜单权限表 | path, component, perms |
| competition | 竞赛表 | title, type, status, start_time, end_time |
| registration | 报名表 | competition_id, user_id, status |
| work | 作品表 | registration_id, file_url, video_url |
| score | 评分表 | work_id, judge_id, score_value |
| announcement | 公告表 | 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 Map<Integer, List<Integer>> 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. SpringBoot+MyBatis后端实现:重点代码的实战写法
3.1 pom.xml的依赖选择:版本踩坑从脚手架开始
后端项目我是从 Spring Initializr 生成的,初创依赖只要了几个核心:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>这里有一个我实际踩过的版本坑。SpringBoot 3.x 发布后很多人直接生成最新版,但 3.x 要求 JDK 17+,学校服务器往往还在用 JDK 8。我在一开始用了 SpringBoot 3.0.5 + JDK 8,编译直接失败,后来老老实实把版本降到 2.7.x。所以如果你目标环境是 JDK 8,SpringBoot 直接选 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 id="selectCompetitionList" resultType="com.school.competition.entity.Competition"> SELECT * FROM competition <where> <if test="title != null and title != ''"> AND title LIKE CONCAT('%', #{title}, '%') </if> <if test="type != null and type != ''"> AND type = #{type} </if> <if test="statusList != null and statusList.size() > 0"> AND status IN <foreach collection="statusList" item="status" open="(" separator="," close=")"> #{status} </foreach> </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select><where>标签会自动去掉第一个多余的 AND,这个细节很实用。<foreach>处理状态列表过滤,避免了手动拼接 IN 子句时的 SQL 注入风险。
分页我直接用 MySQL 的 LIMIT 实现,没有引入 PageHelper。因为这个系统的数据量级是万级,LIMIT 完全够用,少一个依赖就少一分版本冲突的可能。如果是百万级数据的大系统,再考虑分页插件也不迟。
3.3 JWT登录拦截与角色权限校验:前后端分离的认证骨架
前端分离后,Session 那套方案就不好使了——因为前端和后端不同域,Cookie 的跨域传递麻烦,而且移动端调试也不方便。我选了 JWT(JSON 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 的组合式 API(Composition 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 封装是整个前端工程质量的关键。我的统一封装包含请求拦截器、响应拦截器和错误处理三部分。
请求拦截器的核心职责是"自动带 token":
http.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 比赛详情与成绩公示页的渲染细节
页面开发里工作量比较大的是比赛详情页和成绩公示页。
比赛详情页集成了赛事信息、报名时间倒计时、当前报名人数、在线报名按钮。倒计时我用了一个自定义 Hook:
function 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?useSSL=false&serverTimezone=Asia/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?useSSL=false&serverTimezone=Asia/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.active=prod指定生效的 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/xxx,Nginx 转发到后端,浏览器看到的是同源请求,不需要 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 server's identity verification"的警告。我在 JDBC 连接串里显式加useSSL=false,同时加serverTimezone=Asia/Shanghai解决时区问题。如果不处理时区,默认 UTC 会导致数据库时间比北京时间早 8 小时,成绩公示的时间全不对了。
连接串完整写法:
spring: datasource: url: jdbc:mysql://localhost:3306/competition?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true注意allowPublicKeyRetrieval=true这个参数——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 模型和业务状态机的设计,而不只是会调接口。
最后说一个做这类项目最容易被忽视的环节:部署文档和运维手册。代码写完只是完成了一半,留下清晰的数据库初始化脚本、部署步骤、常见问题排查文档,三周后你自己维护时也会感谢当时的坚持。项目不是一次性的,能跑起来的系统才真正有质变的开始。