ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

基于SpringBoot+Vue3的前后端分离知识竞赛系统实践

基于SpringBoot+Vue3的前后端分离知识竞赛系统实践 做信息知识赛系统这件事我前后折腾了两个版本。第一个版本是给一个培训机构做的内部竞赛平台需求很朴素在线答题、自动判分、成绩排行。当时时间紧我图省事直接把题库和答题逻辑堆在一个单体应用里前端用服务端模板渲染勉强能跑但后续加功能的时候差点把自己逼疯。第二个版本彻底重写采用 SpringBoot Vue3 MyBatis MySQL 的前后端分离架构也就是这个信息知识赛系统现在的样子。整套系统沉淀下来我觉得最值得记录的并不是“怎么把代码跑起来”而是整个设计过程中的技术选型逻辑、数据表如何建模、答题判分的事务一致性怎么保证、以及部署上线后那些摸爬滚打的优化过程。这个项目适合正在学习前后端分离开发的人参考也适合需要快速搭建竞赛答题类系统的团队直接借鉴。我会把核心实现思路、关键代码、踩坑记录都摊开来讲不会只贴一堆跑不通的碎片代码。1. 信息知识赛系统的业务边界不只做答题还要管人、管题、管成绩1.1 竞赛系统的基础角色与核心流程信息知识赛系统本质上是一个在线考试/竞赛平台但和学校里的期末考试系统有一个明显差异竞赛场景更强调即时反馈和排行榜激励。整套系统的核心流程其实不复杂管理员维护题库、创建一场比赛、配置比赛规则选手在指定时间内进入赛场答题提交后系统自动判分最终生成个人成绩和排行榜。围绕这个流程系统需要拆出三大模块用户模块管理选手和管理员的身份涉及注册、登录、权限控制。题库与比赛模块维护题目单选、多选、判断、填空、创建赛场、配置答题时间和题目数量。答题与成绩模块选手答题、自动判分、个人成绩记录、排行榜统计。这三个模块划分清楚之后前后端分离API的设计边界也就自然出来了。后端只需要按业务域暴露RESTful接口前端Vue3负责呈现和交互不用再像传统单体应用那样把页面逻辑和服务端渲染混在一起。1.2 为什么选前后端分离而不是继续用模板引擎如果你只是做一个小型内部工具用Thymeleaf或者JSP这种服务端渲染方案确实更快项目结构也更简单。但信息知识赛系统有一个特性管理员后台和选手端页面交互差异极大。后台是典型的管理系统布局侧边栏、表格、表单弹窗选手端则是答题倒计时、题目切换、实时进度。这两套UI如果都塞在服务端渲染里前端JS逻辑会非常臃肿后端的Controller层也会被迫去适配两类页面的数据拼装。前后端分离之后后端只做API提供方前端可以分别构建管理员端和选手端两套独立的Vue3应用开发和联调完全并行。还有一个隐性的好处后续如果要出一套小程序或者移动端App后端API可以直接复用不需要重写业务逻辑。对于竞赛系统这种需要短期上线、后续多有迭代需求的场景来说这个架构弹性非常重要。团队技术栈需要统一前端Vue3 Vite后端SpringBoot数据库MySQL。前后端通过JSON格式数据交互、JWT做身份认证联调时只需约定接口文档。这套组合的优势在后续开发和维护中体现得特别明显。2. 数据表设计与版本选型先理顺数据关系再写业务代码2.1 核心表结构设计思路数据表是整个系统的地基。信息知识赛系统的数据模型其实不复杂核心是四张表用户表、题目表、比赛表、答题记录表。命名上我统一采用小写下划线风格字段都给上注释方便后期维护。用户表的关键字段是角色字段用于区分管理员和普通选手题目表要区分题型而且要考虑到选项的存储方式这是第一次设计时容易踩坑的地方。答题记录表则要记录每次作答的内容和判分结果。我当时设计的时候经历了一次变更第一版的题目表直接用一个options字段以JSON字符串存储选项想着这样灵活但后续做题目统计和选项分析时很难直接用SQL处理。第二次重构时拆出了题目选项表和答题明细表虽然查询时关联表多了一层但灵活性和可维护性提升非常明显。如果你的系统未来有导出成绩、统计正确率、做试题分析的需求建议从一开始就按这种规范化表结构设计。2.2 SpringBoot版本的坑2.7还是3.x这可能是新手组项目时最容易踩的坑。SpringBoot 3.x 已经发布很长时间了新项目大概率会默认选择3.3或更高版本但有一个硬性门槛SpringBoot 3.x 强制要求 JDK17。如果你的团队本机环境还是 JDK8很多现网服务器确实是那会出现一启动就报错、Maven依赖下载失败等一堆莫名其妙的问题。我的建议是分成两种情况团队有统一的 JDK17 环境或者这是全新项目直接上 SpringBoot 3.x JDK17后续维护周期更长。团队还在用 JDK8或者要兼容老服务器那就老老实实选 SpringBoot 2.7.x 版本它是 2.x 系列的最后一个大版本稳定性和生态兼容性都非常好。我这个项目因为需要兼容团队现有的 JDK8 环境最终选的是 SpringBoot 2.7.18 JDK8 MyBatis 3.5.x。这套组合运行非常稳定而且网上遇到问题能搜到的解决方案最多不会因为版本太新而找不到答案。注意SpringBoot 2.7.x 和 3.x 在配置项上也有一些差异比如spring.redis.host在3.x变成了spring.data.redis.host从 2.7 迁移到 3.x 时得逐一排查。如果项目已经上线不建议在业务繁忙期直接升版本。2.3 “版本太高”的那些连带问题Banner、打包、依赖冲突网上关于“springboot版本太高”的搜索量一直不低说明很多人被版本问题坑过。除了上面说的 JDK 版本还有几个连带问题值得注意新版本SpringBoot对Maven和Gradle的版本有要求如果用太老的构建工具版本会直接报错。部分第三方starter没有及时跟进新版本比如一些老牌的验证码、Excel处理库在SpringBoot 3.x下可能不兼容Jakarta命名空间。高版本默认开启了更严格的依赖检查多模块项目里容易出现依赖冲突。再说个好玩的事SpringBoot 有一个 banner 生成器的小玩意可以在启动时打印一个自定义ASCII艺术字。很多教程喜欢拿这个当入门案例但要注意 —— 某些在线banner生成器通过特殊字体生成的字符里包含一些特殊控制字符直接贴到banner.txt里会导致控制台输出乱码。我踩过一次后来直接把banner.txt删了也顺便省了一点点启动时间。2.4 MySQL版本与字符集设置MySQL 我用的是 8.0。8.0 相比 5.7 在窗口函数、CTE、JSON支持上强太多而且默认字符集从 latin1 改成了 utf8mb4对中文内容尤其友好。需要特别注意的是建库建表时要显式指定字符集CREATE DATABASE IF NOT EXISTS info_contest DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;如果使用 5.7一定更要注意显式指定字符集否则出现中文乱码问题的根源可能就在这里。另外表设计时 id 字段建议使用BIGINT AUTO_INCREMENT不要用INT。你可能搜过“mysql中int5”这里有个细节MySQL 里的INT(5)并不限制存储范围只是显示宽度跟字段能存多少数值没有关系。所以不要指望用括号里的数字限制某个字段的取值范围这是新手容易理解的误区。成绩排名、排行榜的场景建议用DECIMAL(5,2)存储百分制成绩避免后续统计浮点误差。3. 让Shell真正可用从零到一的完整落地过程3.1 初始化项目骨架与统一响应封装后端项目我用 Spring Initializr 生成基础结构依赖选择 Web、MyBatis、MySQL Driver、Lombok、Validation。生成之后第一步不是写业务代码而是先做统一响应封装。前后端分离的项目前后端必须约定一套统一的响应格式否则接口联调时每个人都有自己的返回结构调试起来非常崩溃。我用的响应结构是这样的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(success); 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; } }所有 Controller 都返回这个统一结构前端 axios 拦截器里只需要判断code是否为 200不用每个页面单独处理错误分支。这里的code我自定义了一套401代表未登录或token失效403代表没有权限500代表服务端异常。前端拿到401时统一跳转到登录页这个设计在后续联调中省了大量砍逻辑的时间。3.2 登录鉴权与JWT的实现细节信息知识赛系统涉及管理员和选手两种角色必须有登录鉴权。我选的是 JWTJSON Web Token方案没有引入 Spring Security而是自己写了一个简单的拦截器来处理。为什么不用 Security因为用户的角色体系简单只有两种角色没有复杂的权限模型Security 的过滤器链反而显得重配置不当还会莫名其妙拦截静态资源。自己写拦截器代码可控性更高逻辑也直观。核心依赖是jjwt库登录成功后生成token把用户ID和角色编码塞进 token 的 claims 中public static String generateToken(Long userId, String role) { Calendar calendar Calendar.getInstance(); calendar.add(Calendar.SECOND, 7200); // token 有效期2小时 return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(calendar.getTime()) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }拦截器里通过 HandlerInterceptor 实现只拦截除了登录、注册以外的所有接口。核心逻辑是从请求头拿到Authorization: Bearer xxx解析 token 并将 userId 放入 request 的 attribute 中后续 Controller 可以直接获取当前登录用户。这里要注意一个最容易被忽视的点token 在有效期内无法手动失效如果用户改了密码或管理员封禁了某选手旧token依然有效。如果系统对安全性要求高需要引入 Redis 做 token 状态管理。3.3 MyBatis动态SQL与“if test indexof”的使用方法题库管理功能涉及多条件筛选按题型、知识点、难度、关键字搜索题目。这种多条件动态查询是 MyBatis 最擅长处理的问题核心就是动态SQL。在ExamQuestionMapper.xml中写动态SQL时除了常见的if、where标签还有一个技巧值得分享用if testkeyword ! null and keyword.indexOf(xx) ! -1来判断传入的字符串中是否包含某个特征。比如我需要在搜索时判断“传入的搜索词是否包含了指定前缀”可以在 Mapper 接口中传入一个字符串参数在 XML 中这样处理select idselectQuestionList resultTypecom.example.entity.Question SELECT * FROM exam_question where if testquestionType ! null AND question_type #{questionType} /if if testkeyword ! null and keyword ! AND (question_content LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null and categoryId 0 AND category_id #{categoryId} /if /where ORDER BY create_time DESC /select这个场景里隐含一个重要的字符串处理思路在test表达式中不能直接调用 Java 字符串的contains方法但indexOf() ! -1可以虽然臃肿但完全够用。如果你项目里用了 MyBatis-PlusWrapper 里面也支持类似的方式但基础 MyBatis 在实际项目中依然有大量场景不可替代尤其是复杂SQL和多表关联的时候。3.4 答题判分的核心逻辑事务与并发自动判分是竞赛系统的命脉。判分逻辑必须保证一致性选手提交答案后系统要同时完成三件事——保存答题明细、更新选手比赛成绩、更新排行榜数据。这三件事是一个原子操作任何一步失败都不能让其他两步生效所以必须用数据库事务。SpringBoot 里用Transactional注解直接搞定。但我遇到过一个问题当大量选手同时提交答题时因为事务里做了多个表的写操作数据库连接池一度被耗尽高峰期出现接口超时。后来做了两个优化将判分和保存成绩解耦先保存答题明细再异步更新成绩和排行榜。这样主线程只用操作一张表事务时间大幅缩短。排行榜数据不实时全量重算而是采用 Redis 的zset按分数排行赛后批量落库。异步处理我引入了Async注解配合线程池而不是直接用new Thread()。线程池统一管理并发资源不会因为大量请求直接打崩应用。Override Transactional(rollbackFor Exception.class) public SubmitResultVO submitAnswer(SubmitRequestVO requestVO) { // 1. 保存答题明细 // 2. 保存答题记录主表 // 3. 触发异步成绩更新 }这里有一个重要的经验事务和异步方法不要写在同一个类里否则Async会因为 Spring AOP 的代理机制失效异步方法变成了同步执行。具体原因是this调用不会经过代理对象Spring 无法拦截。我第一版就是写在了同一个Service里结果并发一上来性能直接下降排查了很久才发现是自调用问题。解法很简单把异步方法抽到另一个ScoreUpdateService中通过注入调用。3.5 逻辑删除的坑统计场景下需要“看见”已删除数据MyBatis-Plus 提供了逻辑删除功能这是配置TableLogic注解后所有查询和删除操作都会自动追加WHERE deleted 0条件。这个设计很方便但有一个隐藏的坑在竞赛系统里很致命成绩统计时需要回溯某些已经被管理员逻辑删除的题目默认查询无论如何都查不出来。场景是这样的比赛结束后我要统计一道错题的正确率来优化题库但其中几个选项有问题管理员已经删除了这道题。默认的MyBatis-Plus查询会把这条记录过滤掉结果统计数量对不上还找不到原因。解决方案有两种使用InterceptorIgnore注解在指定Mapper方法上临时忽略逻辑删除拦截InterceptorIgnore(tenantLine true) Select(SELECT * FROM exam_question WHERE question_id #{id}) Question selectIgnoreLogicDelete(Param(id) Long id);用自定义SQL直接写原生语句不走MyBatis-Plus内置方法。这里要说明一个容易混淆的细节如果自定义SQL是通过注解形式写在Mapper接口上逻辑删除拦截通常不生效因为拦截器主要作用于MyBatis-Plus生成的方法。但如果自定义SQL写在XML里同样可能不受逻辑删除限制具体要看配置。所以遇到“查不到数据”时先检查是不是被逻辑删除拦截了这是排查方向之一。基础操作中使用MP内置方法删除和查询时都会自动带条件但在报表统计场景就要特别注意这个“禁用逻辑删除”的问题。4. Vue3前台与后台双端分离开发选手端答题体验与管理员后台4.1 Vite Vue3项目基础搭建与目录划分前端我创建了两个独立项目一个是选手端一个是管理员端。虽然Vue3支持路由懒加载和动态组件但两个端UI差异太大放一起会导致构建体积越来越大访问速度受到明显影响。分离之后选手端打包产物只有几十KB首屏加载非常快。初始化命令用 Vite 就行比 Vue CLI 快很多npm create vitelatest contest-user -- --template vue npm create vitelatest contest-admin -- --template vueVite 的好处不用多说开发服务器启动快、热更新快Vue3 官方也推荐。装好之后我会先做一个基础目录规划main.js 里配好路由和状态管理。我没有使用 Pinia 做全局状态因为业务场景中需要跨页面共享的状态很少无非是用户token和当前比赛信息用 localStorage 加组件间传参就能解决过度设计反而增加维护成本。4.2 Vue3核心特性在答题模块中的应用computed与倒计时选手端答题页面是整个系统中交互最复杂的部分。题目的切换、选项选中状态、答题进度、剩余时间这些都是有状态的UI。Vue3 的组合式APIComposition API处理起来逻辑集中比 Options API 清晰得多。我最常用的是computed用来根据当前答案状态动态计算进度script setup import { ref, computed } from vue const answerMap ref({}) const questionTotal ref(10) const answeredCount computed(() { return Object.keys(answerMap.value).filter(key answerMap.value[key] ! ).length }) const progressPercent computed(() { if (questionTotal.value 0) return 0 return Math.round((answeredCount.value / questionTotal.value) * 100) }) /scriptcomputed是Vue3里被问得最多的高频API它和watch的最大区别是computed 关注的是“基于已有数据计算并返回新值”watch 关注的是“数据变化时执行副作用”。在做答题倒计时功能时我会把剩余时间用ref保存配合setInterval每秒修改const remainSeconds ref(1800) let timer null function startTimer() { timer setInterval(() { remainSeconds.value-- if (remainSeconds.value 0) { clearInterval(timer) autoSubmit() } }, 1000) }用ref包一层的时间变量才能在UI中响应式变化这是一个容易踩坑的点。直接把let remainSeconds 1800写在setup里页面是永远不会更新的。还需要注意在组件卸载时一定要清除定时器否则切路由后定时器还在跑会出现页面已经跳走了但自动交卷逻辑还在执行的诡异现象。4.3 动态路由与权限控制管理员后台最多的时候有两类入口管理员和选手模拟练习场景。如果不用权限控制任何登进系统的人都能访问后台接口这是严重的安全漏洞。我采用动态路由方案登录后根据返回的角色编码使用 Vue Router 的addRoute方法动态添加对应权限的路由表。核心思路是定义一份常量路由表登录页、首页、比赛列表和一份动态路由表后台管理页。用户登录成功后前端判断角色如果是admin就动态注册后台管理路由如果是user只注册选手相关的路由。const router createRouter({ history: createWebHistory(), routes: constantRoutes }) export function setupDynamicRoutes(role) { if (role admin) { adminRoutes.forEach(route { router.addRoute(route) }) } }这里注意一个很多教程不会明说的细节addRoute添加的路由是响应式的但如果用户刷新页面动态添加的路由会丢失因为前端内存被重新加载了。因此刷新后的路由重建逻辑必须放在路由守卫里每次进入应用时根据 token 中的角色重新注册。如果不做这一步程序员后台刷新一下就会白屏。4.4 axios封装与请求拦截axios 封装是所有前后端分离项目的标配。我会首先创建 axios 实例设置基础URL然后添加请求拦截器和响应拦截器。请求拦截器里附带 token响应拦截器里统一处理错误码import axios from axios import router from ../router 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 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(未登录)) } if (res.code ! 200) { return Promise.reject(new Error(res.message)) } return res.data }, error { return Promise.reject(error) } )把401处理放在拦截器里的好处是所有接口不用各自判断登录过期统一跳转登录页。业务代码里拿到的直接就是data部分不用层层剥开response.data.data这种冗余结构。这套封装写完以后整场开发里新增页面都只用写业务请求方法不用关心错误处理。4.5 Vite代理与后端联调开发环境联调时前端页面在localhost:5173后端接口在localhost:8080必然存在跨域问题。解决方式有两种后端配置 CORS 全局跨域或者前端 Vite 配置代理。我推荐用 Vite 代理因为开发环境走代理更接近生产环境部署方式而且后端不用额外改配置。在vite.config.js中export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/xxx会被代理转发到后端http://localhost:8080/api/xxx。生产环境部署时用 Nginx 将/api反向代理到 Java 服务即可前后端代码不用做任何修改。这个方案在本地开发、服务器部署、域名绑定三个环节中都验证过是目前最推荐的做法。5. 部署落地与线上问题排查MySQL安装、Docker部署、OOM、慢SQL5.1 MySQL部署方式选择本机安装还是Docker信息知识赛系统的部署方案我前后试过两种。第一种是本机安装 MySQL适合团队开发环境直接在官网下载安装包步骤比较繁琐官方安装包在 Windows 下还要做环境变量配置、初始化数据目录、注册服务对新手来说难度不小网上搜“mysql安装教程”和“mysql安装配置教程”的人数也证明了这是一个不低的门槛。第二种是 Docker 方式适合服务器环境一个命令就能拉起数据库。生产环境我用的是 Docker 部署 MySQLdocker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDyourpassword \ -e MYSQL_DATABASEinfo_contest \ --restartalways \ mysql:8.0用 Docker 之后最大的好处是可移植性非常强换一台服务器一条命令加一个数据目录挂载就能恢复整个数据库。需要注意容器删除后数据会丢失所以一定要挂载数据目录-v /opt/mysql-data:/var/lib/mysql如果你是在服务器上装 Docker 也遇到 pull 镜像慢的问题可以配置国内镜像源或者检查网络环境是否稳定这些在实际操作中都比改代码更耗时间。5.2 SpringBoot应用打包JDK8在Docker中的那点事我云服务器上安装的是 Docker Desktop 环境实际上服务器用的是 Docker Engine本地开发机用 Docker Desktop 测试镜像。项目的JDK版本是8SpringBoot 2.7 默认的打包方式打出来的是可执行 jar 包理论上java -jar app.jar就能运行。但容器部署时还是需要一个带 JDK8 基础镜像的 DockerfileFROM openjdk:8-jre-alpine LABEL maintaineryourname COPY target/info-contest.jar /app.jar ENV JAVA_OPTS-Xms256m -Xmx512m -Dfile.encodingUTF-8 ENTRYPOINT [sh, -c, java $JAVA_OPTS -jar /app.jar]这里有一个非常容易踩的坑SpringBoot 2.7 打包出的 jar 在 Docker 中启动时可能会出现java: outofmemoryerror: insufficient memory之类的内存不足错误。原因通常是 Docker 容器的内存限制过于紧张或者 JVM 默认按照宿主机内存大小分配堆内存结果容器配额不够。解决方式是显式指定 JVM 参数-Xms256m -Xmx512m限制堆内存大小并加上-XX:UseG1GC。还有一点如果基础镜像用的是openjdk:8-jre而不是alpine瘦身版镜像体积会多出上百MB按需取舍。基于容器限制来设置-Xmx参数有时候还可以用-XX:MaxRAMPercentage75.0替代固定数值让JVM按容器内存配额自动按比例分配这样在高并发和低负载场景下都能自适应。5.3 慢查询与索引优化排行榜为什么越跑越慢项目上线后跑了三场比赛排行榜接口开始出现明显的延迟。最开始我以为是服务器带宽和并发的问题后来查看日志发现一条 SQL 执行超过了 2 秒定位到成绩表的查询没走索引。原因很直白成绩表里user_id和contest_id字段没有建立联合索引数据量到了几万条之后全表扫描时间陡增。解决办法是加联合索引ALTER TABLE exam_score ADD INDEX idx_user_contest (user_id, contest_id);加完之后接口响应时间从2秒降到了100毫秒以内变化非常明显。这个经历给我一个很深的教训数据量小的时候感知不到索引的重要性但一定要在设计阶段就要做好索引规划。下面是建表时就用到的参考索引设计表名索引字段场景exam_questioncategory_id按分类筛选题目exam_questionquestion_type按题型筛选题目exam_scoreuser_id, contest_id查询用户某场比赛成绩exam_scorecontest_id, score排行榜按分数排序exam_questiondifficulty按难度筛选在 MySQL 中批量插入数据的场景下还遇到过int5这个关键词相关的疑惑。很多人在写 update 语句时会对整数字段做score score 5的累加操作并且在 MySQL 中这种表达式不是按照字符串拼接来处理的是真的数值相加。但要注意如果字段定义为INT累加超过溢出时 MySQL 会报错所以设计成绩或计数类字段时预留合理的字段范围很重要。5.4 MyBatis缓存一级缓存引发的数据不一致问题线上有一个诡异的问题同一个用户查询比赛成绩连续调用两次接口第一次返回了更新后的成绩第二次竟然返回了旧数据。花了大半天排查最后定位到是 MyBatis 一级缓存导致的。MyBatis 默认开启一级缓存作用范围是同一个 SqlSession 内。在 Spring 整合环境中每次请求都会新建 SqlSession看起来不会触发缓存问题但如果某个Service方法里多次执行了同一个查询而中间又更新了数据此时一级缓存可能会把第二次查询结果拦截住返回旧数据。解决方式有三种在 Mapper 查询语句上设置flushCachetrue强制每次查询都清空一级缓存。在更新操作后手动调用SqlSession.clearCache()但代码侵入性有点强。如果用了 MyBatis-Plus可以在查询时指定last(LIMIT 1)也不一定能完全绕过缓存最稳妥的还是结合具体场景控制。这个问题的核心是理解 MyBatis 的缓存机制。一级缓存默认开启二级缓存默认关闭。对于竞赛系统这种对数据一致性要求较高的场景我建议尽量不依赖MyBatis缓存而是把缓存层放在 Redis 上手动控制失效时机。这样在成绩、排行榜等数据变化频繁的场景下才能保证用户看不到过期数据。5.5 Vue3前端部署与Nginx配置前端打包后是一堆静态文件部署到 Nginx 时有一个常见的坑直接刷新页面会返回404。原因在于 Vue Router 使用的是 history 模式前端路由在浏览器上表现为真实的url路径但服务器上并没有对应的物理文件Nginx 找不到文件就返回404了。解决方案是配置 try_files将所有的路由请求都重定向到 index.htmlserver { listen 80; server_name yourdomain.com; root /opt/contest-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这是一份非常常见且能直接用的 Nginx 配置。静态文件交给 Nginx 托管Java 接口走反向代理Nginx 同时承担了静态资源服务器和反向代理的双重角色。部署完之后前端刷新不再404接口请求也走同域没有跨域问题。6. 从第一版到第二版我踩过的坑和重构思路这个信息知识赛系统做到今天前后经历了两次比较大的重构。第一次重构是前端从 Vue2 ElementUI 迁移到 Vue3 Vite这里涉及了组件库兼容性、响应式API的改写、路由模式升级等一堆问题工作量比想象中大。第二次重构是后端引入了 Redis 缓存排行榜和异步判分这部分改动主要针对并发场景因为第一版在真实比赛一开、几百人同时提交时数据库连接池差点被打满。如果让我重新做一遍我会在第一版就做三个决定数据表一定按规范化设计不图省事用JSON字段存复杂结构。业务逻辑中一定区分主流程和异步流程答题保存、判分、汇总、排行可以异步化。前端从一开始就用 Vue3 Vite用组合式API组织逻辑不要用 Options API 写旧的思路。关于 MySQL 和 MyBatis 的关系想再补充一句个人看法。MyBatis 是一个非常灵活的持久层框架它不像 JPA 那样给你一套完整的对象关系映射而是把SQL的控制权完全交给开发人员。这对信息知识赛系统这种有多条件查询、多表关联、事务一致性要求的场景反而更友好。因为你可以精确控制每一条SQL知道它执行了什么慢在哪。但这也意味着开发者必须自己保证SQL质量不能指望框架优化。所以写动态SQL时一定要慢下心多考虑是否走索引是否能避免SELECT *这些细节在数据量上来后都会显现出来。MyBatis 的工作原理简单说就是通过动态代理为 Mapper 接口生成代理对象解析 SQL 并执行把结果集通过反射映射成实体对象。真正理解这一点后你排查问题就会有的放矢而不是遇到Bug只能盲猜配置。这套系统目前的版本在功能上已经完整覆盖了信息知识赛项目的基本需求用户注册登录、题库管理、比赛创建、在线答题、自动判分、排行榜、成绩查询。后续计划中的扩展方向是支持自定义组卷规则、多级知识点树、赛后报告导出等。核心架构已经稳定扩展只是往上加模块的问题。最后分享一个我实际开发中的体会做这类系统技术难点其实不是让代码“跑起来”而是让整个项目在多人协作、快速迭代的过程中始终维持清晰的结构。前后端分离、统一接口规范、日志排查链路完整、部署可复现这些工程化的事做到位功能开发本身就只是时间问题。信息知识赛系统的源码是我逐步打磨出来的希望这篇拆解能帮你绕开那些我踩过的坑。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表