做企业级管理系统开发这几年我感触最深的一点是CRUD从来不是真正的难点难的是把业务规则、权限模型、并发控制这些琐碎的东西揉进一套结构里还不显得乱。在线考试系统恰好是这种“看着简单、做起来五脏俱全”的典型项目。一套基于SpringBootVueMyBatis搭建、以MySQL作为持久层的完整版在线考试管理系统几乎是目前中小型企业内部培训、学校考核、机构认证考试场景里最常见的落地组合。我最近刚把整套源码完整梳理了一遍从题库管理到自动判分从角色权限到部署上线中间踩过不少坑。这篇文章不打算把完整代码罗列一遍但核心设计、关键配置、选型理由和排查思路一次讲清楚。1. 项目全貌与技术选型为什么是SpringBootVueMyBatisMySQL1.1 在线考试系统的核心业务场景先想清楚一个问题一个能被称作“企业级”的在线考试系统和企业内部随便用Excel抽考的小工具差别到底在哪儿我个人的理解是三个字可运营。企业级场景里一套考试系统至少要覆盖三类角色管理员负责搭建题库、维护试题、安排考试场次教师或者叫出题人负责组卷、阅卷、查看成绩考生负责参加考试、查看得分。再往下拆核心业务流程就是“建题库 → 组试卷 → 安排考试 → 考生答题 → 自动判分/人工阅卷 → 成绩发布”。这六个环节听起来很简单但每个环节都有隐藏的技术问题题库要支持题型和难度分级组卷要支持随机抽题考试过程要控制倒计时和防作弊交卷要处理并发判分要区分客观题和主观题成绩要支持多维度统计。正因为业务覆盖全面在线考试系统经常被当作Java全栈开发者的综合能力试金石。它不涉及高并发中间件不需要分布式事务但又逼着你在单体范围内把事务、缓存、权限、状态流转这些基本功做扎实。把这个项目吃透再去碰更复杂的电商、支付类系统很多设计思想是直接平移的。1.2 技术栈选型的底层逻辑这套系统的技术栈选型是SpringBoot Vue MyBatis MySQL四个词全是当前企业管理系统的“标配”。但标配不等于随便选每一层的选型背后都有明确理由。后端用SpringBoot核心原因是生态成熟和约定优于配置。SpringBoot内置了Tomcat简化了依赖管理项目启动就是一个独立的Java进程这对中小型系统来说运维成本极低。如果换成Spring Cloud那套微服务方案考试系统这种规模反而会被注册中心、配置中心、网关拖累属于为了架构而架构。持久层用MyBatis而不是JPA是我在企业管理类项目里反复对比后的结论。教务、考试这类系统的查询条件极其灵活试卷要根据题型、难度、知识点动态筛选成绩要按班级、时间、分数段多维统计MyBatis的XML映射能让你精确控制每一条SQL不需要跟框架的自动查询规则搏斗。尤其是加了动态SQL之后复杂查询的编写和维护都非常直接。前端选Vue理由也很实际组件化开发模式适合把题库管理、考试答题、成绩统计这些模块拆成独立页面双括号语法和响应式数据流让开发效率明显优于原生JavaScript。配合Element Plus这类成熟组件库后台管理界面的搭建速度能提升一倍以上。MySQL则是通用性、易用性和运维成本综合下来最稳的选择。开发环境一台机器就能跑生产环境无论是云数据库还是自建服务器都有大量资料可查。整套选型就像用最常见的砖块搭一栋结构扎实的楼不追求形态奇特但保证住得舒服。1.3 目录结构拿到源码后先看哪里如果你拿到的是别人整理的完整源码不要急着点开一堆Java文件乱看先按这个顺序建立全局认知。exam-admin // Vue 前端工程 exam-server // SpringBoot 后端工程 ├── src/main/java/com/exam │ ├── controller // 接口层接收前端请求 │ ├── service // 业务层核心逻辑 │ ├── mapper // MyBatis Mapper接口 │ ├── entity // 实体类 │ ├── common // 统一响应、异常、工具类 │ └── config // 配置类安全、文件、跨域 ├── src/main/resources │ ├── mapper // MyBatis XML映射文件 │ └── application.yml └── sql // 数据库初始化脚本后端工程建议从application.yml读起数据源、端口、MyBatis配置都在这一个文件里然后看controller层的接口列表能快速搞懂系统暴露了哪些功能再顺着一个完整业务链路比如“创建考试”往下追service和mapper整个项目脉络就清晰了。前端工程则从router目录看起它决定了页面结构和权限控制的方式。2. 数据库建模与MyBatis持久层设计2.1 核心表结构设计与关系梳理数据库设计是我在这个项目里耗费时间最多的部分。考试系统的表数量不算多但表关系比普通的管理系统复杂一些尤其是“试卷”和“题目”之间不是简单的归属关系而是多对多。我最终的表结构可以归纳成四组用户与权限组sys_user用户表、sys_role角色表、sys_user_role用户角色关联表。三张表支撑RBAC权限模型。用户表里除了账号密码、姓名邮箱这些基础字段还设计了status字段用来做账号禁用。题库组exam_question题目表是核心字段包括题型单选、多选、判断、简答、难度等级、所属科目、题干内容、选项内容、正确答案、分数。这里有个细节选项内容我用的是JSON字符串存储比如[选项A,选项B,选项C,选项D]这样单选和多选都能复用同一条记录避免了为不同题型设计不同表的麻烦。试卷考试组exam_paper试卷表、exam_paper_question试卷题目关联表、exam_info考试场次表。试卷题目关联表是典型的多对多中间表额外增加了sort_order字段控制题目排序。考试场次表则记录了考试名称、开始时间、结束时间、考试时长、总分、关联的试卷ID。答题成绩组exam_record考试记录表和exam_record_answer答题明细表。这是系统里数据量增长最快的地方答题明细表设计时一定要给question_id和record_id加上联合索引否则考生数量一上来成绩统计的SQL会慢得让人头疼。这里我想重点解释一下“为什么需要中间表”。新手容易犯的错误是在试卷表里加一个question_ids字段用逗号拼接存储所有题目ID取数据时再拆字符串。这种做法在题目数量少的时候看不出问题但一旦涉及调换题目顺序、统计试卷分值、按题型分组查询SQL就会写得很扭曲。中间表虽然多了一次联表查询但换来了数据的规范性和灵活性——比如同一道题可以出现在不同试卷里靠的就是中间表而不是复制一份题目数据。2.2 MyBatis配置与XML映射实战MyBatis在这个项目里的使用方式我总结成三句话注解能用但不滥用XML负责复杂SQL配置必须明确。先看application.yml里跟MyBatis有关的基础配置mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.exam.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这几行配置的意义值得展开说。map-underscore-to-camel-case把数据库的create_time自动映射成Java实体里的createTime省掉了大量resultMap手写映射。log-impl使用标准输出打印SQL日志这是在开发阶段定位问题的重要手段——我调试MyBatis相关问题时的第一步永远是确认SQL到底被拼成了什么样。XML映射文件我按业务模块拆分QuestionMapper.xml管理题库的复杂查询PaperMapper.xml管理组卷和试卷查询ExamMapper.xml管理考试记录。拿题库查询举例当列表页需要根据题型、难度、科目、题干关键字组合筛选时动态SQL就派上了用场select idselectQuestionList resultTypecom.exam.entity.ExamQuestion SELECT * FROM exam_question where if testquestionType ! null and questionType ! AND question_type #{questionType} /if if testdifficulty ! null AND difficulty #{difficulty} /if if testsubjectId ! null AND subject_id #{subjectId} /if if testkeyword ! null and keyword ! AND (stem LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY id DESC /selectwhere标签自动处理多余的和前缀不需要人工判断AND还是WHERE这是MyBatis动态SQL里最实用的设计没有之一。如果生产环境遇到国产化数据库或者读写分离架构比如金仓、达梦这类数据库MyBatis层的优势更明显。因为SQL写在XML里切换数据库方言时只需要修改映射文件Java代码几乎不用动做读写分离时在配置里指定多个数据源mapper接口无需任何改动。这也是MyBatis在企业级项目里生命力顽强的核心原因。2.3 MyBatis缓存机制与性能优化关于MyBatis缓存面试题里常考实际项目中很多人却没真正用对过。MyBatis的一级缓存是SqlSession级别的同一个SqlSession执行两次相同查询会走缓存。但SpringBoot整合MyBatis后默认每个Mapper操作都使用独立的SqlSession一级缓存的作用非常有限基本可以忽略。二级缓存是namespace级别的需要手动开启。在MyBatis的XML映射文件中加一行配置就能启用但我在考试系统里刻意没有开启二级缓存。原因有两个第一题目数据不是纯读数据管理员修改题目后必须让考生立刻看到最新版本二级缓存会导致数据修改后查询仍然命中旧数据第二考试记录涉及考生隐私缓存在外层做控制更灵活。如果确实要对某些热点数据做缓存比如科目列表更推荐用Redis在Service层处理这样缓存粒度、失效策略都在业务代码的掌控范围内。MyBatis真正值得一提的性能优化是批量操作。在导入题库或者批量创建试卷时逐条insert会产生大量数据库往返。MyBatis的ExecutorType.BATCH配合foreach标签构造批量SQL性能提升非常明显insert idbatchInsertQuestions INSERT INTO exam_question ( question_type, difficulty, subject_id, stem, options, answer, score ) VALUES foreach collectionlist itemitem separator, ( #{item.questionType}, #{item.difficulty}, #{item.subjectId}, #{item.stem}, #{item.options}, #{item.answer}, #{item.score} ) /foreach /insert注意批量插入单条SQL的长度MySQL默认max_allowed_packet是4MB一次性塞几百道题没有问题但单批超过1000道题时建议拆分否则会报包长度超限。3. SpringBoot后端核心功能实现3.1 接口分层与统一响应机制后端工程的分层我严格遵循Controller-Service-Mapper三层结构。Controller层只负责接收参数、调用Service、返回结果Service层承载业务规则Mapper层只管数据访问。这三层一旦混在一起项目后期维护就是灾难。比如你在Controller里写了一段查询数据库的逻辑看起来很方便但第二个人接手时根本不知道这段逻辑是业务规则还是临时凑数的代码腐化就此开始。统一响应体是企业管理后台项目的必备组件。我定义了一个ResultT类包含code、message、data三个字段同时加了配套的全局异常处理器。这样所有接口的返回值格式一致前端就能在axios的拦截器里统一处理成功和失败分支不需要每个页面单独判断。实际开发时这个设计节省的时间非常可观。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(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }3.2 考试核心流程组卷、答题、判分在线考试系统真正的核心逻辑在Service层尤其体现在组卷、答题和判分三个环节。组卷的核心是随机抽题。我实现的方案是先按试卷配置读取组卷规则单选题多少道、难度分配比例、每题多少分然后按规则从题库中查询符合条件的题目集合最后在Java层用随机数打乱集合再截取指定数量。关键点在于查询条件要跟组卷规则对齐比如需要5道中等难度的单选题就应该直接在SQL里加上WHERE question_type 1 AND difficulty 2 AND subject_id #{subjectId} ORDER BY RAND() LIMIT 5而不是查出所有题再去内存里过滤。ORDER BY RAND()在大数据量下性能一般但题库规模通常在一万道以内实测完全够用。规模再大就改用TABLESAMPLE或者预先算好的随机ID范围。public void generatePaper(Long paperId, PaperConfig config) { // 1. 根据组卷规则逐类抽题 ListExamQuestion questions new ArrayList(); for (PaperRule rule : config.getRules()) { ListExamQuestion list questionMapper.randomSelect( rule.getSubjectId(), rule.getQuestionType(), rule.getDifficulty(), rule.getCount() ); questions.addAll(list); } // 2. 校验题量是否满足规则不满足直接抛出业务异常 // 3. 批量写入试卷题目关联表 }答题过程的核心是状态控制。一个考生同一时间只能有一场进行中的考试后台用考试记录表里的status字段控制0未开始、1进行中、2已交卷。每次提交答案时更新答题明细表交卷时检查记录状态防止重复交卷。判分环节我按题型区分处理客观题单选、多选、判断在交卷时自动判分程序比对考生答案与正确答案字符串即可多选时要设置“完全匹配才得分”还是“漏选得部分分”的规则这部分是业务规则注入点需要跟出题方反复确认主观题简答、论述则生成待阅卷列表由教师端进入人工阅卷页面打分。自动判分和人工阅卷的分值最终合并写入成绩表。这里有一个并发交卷的隐藏陷阱。考生交卷瞬间如果同时发来多个请求数据库里可能出现两条交卷记录。我的处理方案是在考试记录表上加status字段更新时使用条件UPDATE exam_record SET status 2 WHERE id #{recordId} AND status 1数据库层面用受影响行数判断是否重复交卷。如果受影响行数为0说明已经交过了直接丢弃本次请求。这个方案比在应用层加锁简单可靠也更好解释。3.3 权限控制与安全设计在线考试系统的权限模型是经典的RBAC基于角色的访问控制。我设置了三个角色管理员、教师、考生。管理员拥有一切权限教师可以管理题库和试卷、安排考试、批改主观题考生只能参加考试和查看成绩。接口层面的鉴权我采用了JWT加拦截器的组合。登录成功后后端签发JWT令牌前端每次请求在header中携带Authorization拦截器校验令牌合法性并解析出用户ID和角色信息存入ThreadLocal供后续业务使用。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains(/login)) { return true; } String token request.getHeader(Authorization); // 校验token合法性解析用户信息 // 校验当前用户是否有访问该接口的权限无权限则抛出异常 return true; } }角色权限校验我采用了自定义注解加AOP的方式。在Controller方法上加RequireRole(teacher)注解AOP切面拦截处理这种做法的优点是权限声明和接口定义放在一起代码可读性很强。实际开发中有个容易忽略的细节考生交卷接口和查看成绩接口不能被同一个人反复请求刷分所以权限控制之外我还在关键接口做了操作频率校验比如交卷后再次请求直接提示“已交卷”。3.4 MinIO对象存储集成在线考试系统中题干图片、考生头像、批量导入的附件都是文件类数据。直接存在MySQL的BLOB字段里不是不行但数据库体积膨胀太快备份和查询都会变慢。我选择用MinIO做对象存储它在本地环境部署简单兼容S3协议的API并且完全开源免费。SpringBoot整合MinIO的过程很简单。引入minio依赖后定义一个配置类把客户端注册为BeanBean public MinioClient minioClient() { return MinioClient.builder() .endpoint(http://localhost:9000) .credentials(minioadmin, minioadmin) .build(); }上传文件的核心逻辑是三个步骤先判断bucket是否存在不存在则创建然后调用putObject上传最后返回文件的访问路径。需要注意的是MinIO默认生成的访问链接是预签名URL有过期时间。如果题目图片要长期展示更合适的方式是把bucket的访问权限设为公开读上传后直接拼出固定的访问URL。public String uploadFile(MultipartFile file) { String fileName UUID.randomUUID().toString() . getExtension(file.getOriginalFilename()); // 判断bucket是否存在 boolean exists minioClient.bucketExists(BucketExistsArgs.builder().bucket(exam).build()); if (!exists) { minioClient.makeBucket(MakeBucketArgs.builder().bucket(exam).build()); } // 上传 minioClient.putObject(PutObjectArgs.builder() .bucket(exam) .object(fileName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return http://localhost:9000/exam/ fileName; }我用MinIO主要是为了存储题干中的图片素材。考试系统的试题编辑是富文本形式教师上传的图片进入MinIO数据库里保存图片URL这样无论前端是PC页面还是移动端H5都能稳定加载试题图片不会因为服务器磁盘扩容问题导致数据丢失。4. Vue前端工程化搭建与核心页面4.1 环境配置与项目初始化这是新手最容易被绊倒的地方。先说结论Vue前端环境的核心是Node.js不是Vue本身。安装Node.js时选择一个LTS版本比如18.x或20.x不要追最新的大版本因为新版Node可能与部分依赖不兼容。Node安装完成后npm自动可用但下载依赖时建议配置国内镜像源npm config set registry https://registry.npmmirror.com创建Vue工程时我直接用了官方脚手架。如果创建最基础的Vue 3项目执行npm create vuelatest这个命令会交互式地询问是否添加TypeScript、Router、Pinia、ESLint等根据项目需要勾选就行。开发完工程依赖安装完成后启动开发服务器npm install npm run dev这里要特别提醒一个让很多人抓狂的问题跨域。开发环境下Vue的默认地址是http://localhost:5173后端接口地址是http://localhost:8080端口不同浏览器会拦截跨域请求。解决方案有两种我推荐前端代理方案。在Vite工程根目录找到vite.config.js添加server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api/xxx就会被代理到后端的http://localhost:8080/api/xxx避免了在后端单独配置CORS的一切麻烦。如果后端也开启了CORS开发时有一个顺手小技巧在后端全局配置CORS允许前端地址http://localhost:5173生产环境前端打包后属于同源部署就不需要CORS了。4.2 动态路由与权限菜单在线考试系统有管理员、教师、考生三种角色后端给前端返回的菜单应该是不同的。管理员看到系统管理和全部业务菜单教师看不到考生管理考生只有考试中心和成绩查询。实现这个功能的最佳方式就是动态路由。我的实现思路是这样的用户登录成功后后端根据角色返回一份菜单和权限码列表前端拿到这份数据在Vue Router中通过addRoute方法动态注册路由菜单组件根据同样的数据渲染侧边栏。核心代码大致如下// 登录成功后动态注册路由 const modules import.meta.glob(../views/**/*.vue) function buildRoutes(menuList) { return menuList.map(item { return { path: item.path, name: item.name, component: modules[../views/${item.component}.vue], children: item.children ? buildRoutes(item.children) : [] } }) } router.addRoute(Layout, buildRoutes(menuList))动态路由有个经典坑刷新页面后动态路由丢失白屏报错。原因是Vue实例刷新时store里的菜单数据被清空路由还没来得及注册页面就找不到匹配组件。解决方案是在应用加载时先从本地持久化存储比如localStorage或Pinia的持久化插件里恢复用户信息和菜单数据再注册路由最后再挂载应用。我在前端工程里专门写了一个guard跳转子逻辑处理这个流程实际测试下来刷新、关闭浏览器重新打开、使用浏览器前进后退按钮路由都不会再丢失。4.3 考试答题页与音视频场景答题页是整个前端最复杂的页面因为它要同时处理倒计时、定时保存、试卷滚动定位、交卷确认多个状态。倒计时我使用setInterval实现每秒更新剩余时间时间归零时自动触发交卷。实现时有个细节setInterval会受浏览器后台标签页休眠影响用户切到别的标签页再回来倒计时可能没有按真实时间跳动。解决办法是在计时器里每次用当前时间戳减去考试开始时间戳来计算剩余时长而不是每秒把剩余秒数减一。这个细节我实测过很多次不处理的话用户很容易利用切标签页“暂停”考试。考试过程中答案的保存我采用“定时保存 离开保存”双保险”。定时每隔30秒调一次接口保存当前已答题目同时监听浏览器的beforeunload事件在刷新或关闭页面时尝试保存。这样即使考生中途断网重新登录后答案也不会丢太多。关于音视频场景在线考试系统偶尔会用到两个功能一是考生上传的视频答题素材二是监考直播流。如果前端要播放m3u8格式的视频流不需要额外安装浏览器插件用开源的hls.js即可。import Hls from hls.js function playM3u8(videoElement, url) { if (Hls.isSupported()) { const hls new Hls() hls.loadSource(url) hls.attachMedia(videoElement) } else if (videoElement.canPlayType(application/vnd.apple.mpegurl)) { // Safari原生支持m3u8直接赋给src即可 videoElement.src url } }这个兼容方案的必要性在于Safari和iOS设备对m3u8有原生支持而Chrome、Firefox等主流桌面浏览器必须借助hls.js才能播放。项目里遇到视频题或监控流直接复用这个函数就行不需要其他依赖。播放器功能可以简单到只有一个video标签加播放、暂停、进度条也可以集成video.js做更复杂的控制条但我在考试场景里刻意保持简单——避免考生通过播放器控制按钮中断考试流程。5. 常见坑点与排查实录5.1 MySQL连接与SSL报错把项目从开发环境搬到测试环境时最常碰到的就是数据库连接报错。如果用MySQL 8.0及以上版本连接字符串里不加SSL相关参数启动时会看到类似“SSL connection error”的报错。MySQL 8默认开启了SSL但本地和测试环境连接通常不需要加密通信。在JDBC连接串中加两个参数即可解决url: jdbc:mysql://localhost:3306/exam?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueserverTimezone参数同样重要不设置的话Java 8之后的时间类型和MySQL之间的转换可能报时区错误。allowPublicKeyRetrieval是MySQL 8使用caching_sha2_password认证方式时首次连接需要用到的参数不设置会在认证时报Public Key Retrieval is not allowed。另一个和MySQL相关的经典坑是驱动版本问题。SpringBoot 2.7默认使用MySQL Connector/J 8.0版本SpringBoot 3.x则需要确保驱动版本与数据库版本兼容。如果遇到驱动类找不到或字符集乱码优先检查pom.xml里的mysql-connector-java版本不要盲目升级到最新版找一个与SpringBoot版本匹配的稳定版本。5.2 SpringBoot版本太高引发的兼容性问题“版本太高”这个问题我用SpringBoot 3.x配合新版MyBatis Starter时真实遇到过。SpringBoot 3.0是一个分水岭它从Java EE规范的javax包迁移到了jakarta包。结果就是一些老版本的Spring Boot Starter或者自己写的拦截器、过滤器代码如果还在用javax.servlet.http.HttpServletRequest编译直接报错找不到类。解决方案分两条路一条是升级依赖到支持jakarta的新版本比如mybatis-spring-boot-starter选择3.0.3及以上另一条是自己的代码全部把javax.改成jakarta.。这里提醒一句如果项目里引入了大量老博客的示例代码复制粘贴前一定要先确认包名是javax还是jakarta这个坑会浪费不少时间。另外SpringBoot 3.x要求Java 17及以上服务器上如果装的是OpenJDK 8项目根本启动不了。所以选型时需要在“新版本特性”和“服务器环境兼容”之间做个平衡。如果团队的运维环境还在JDK 8老老实实用SpringBoot 2.7是更稳妥的选择如果是从零开始的新项目直接用SpringBoot 3.x加JDK 17提早解决兼容问题。5.3 MyBatis TypeHandler与特殊字段处理MyBatis默认的字段类型映射只处理基本类型、String、Date这些常见类型。考试系统的题目表里我用JSON字符串存选项集合、用逗号分隔存多选答案这些字段在Java实体里是ListString直接查询会报No constructor found或映射错误。解决方案是自定义TypeHandler。TypeHandler的工作流程可以理解为从数据库读出的字段值经过它的getResult方法加工成Java对象Java对象的属性经过它的setParameter方法加工成数据库字段值。我写了一个JsonStringListTypeHandler核心逻辑就是调用JSON.parseArray()和JSON.toJSONString()做转换然后在XML映射文件或注解里指定使用这个TypeHandler。MappedTypes(List.class) public class JsonStringListTypeHandler extends BaseTypeHandlerListString { Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, JSON.toJSONString(parameter)); } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { String value rs.getString(columnName); return value null ? null : JSON.parseArray(value, String.class); } // 重载另外两个getNullableResult方法 }把这个TypeHandler注册到MyBatis配置后实体类的List字段就能自动完成JSON和Java对象的互转。实际项目中我还遇到过把Date转成LocalDateTime、把Integer按Boolean读取的自定义需求思路完全一样。掌握了TypeHandlerMyBatis处理复杂字段就再也没有障碍了。5.4 前端跨域、打包与部署前端工程开发调试时用代理解决跨域但上线部署时最省事的方案是把前端打包后的静态资源直接放进SpringBoot工程的static目录由后端统一提供服务。Vue工程执行npm run build会生成dist目录里面的内容就是纯静态的HTML、JS、CSS文件。把这堆文件复制到SpringBoot项目的src/main/resources/static下重新打包启动浏览器访问http://服务器IP:8080/就能直接看到前端页面不涉及任何跨域问题。如果你用Nginx部署前端记得把/api前缀的请求反向代理到后端服务location /api/ { proxy_pass http://localhost:8080/api/; }每次重新部署还要注意一个问题刷新子路由页面时Nginx返回404。这是因为前端路由是History模式刷新时浏览器请求了实际的URL但服务器上没有这个路径的静态资源。解决方法是Nginx配置里加一个try_files规则找不到对应文件时回退到index.html。location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }这个规则几乎适用于所有Vue单页应用部署场景建议直接收藏。6. 从源码到二次开发我的几点扩展建议与实操心得项目源码整理完成之后我在真实落地过程中有了一些体会每一条都是在踩过坑之后总结出来的分享给做同类项目的朋友参考。第一点考试系统的状态机一定要设计清楚而不是靠散落的if-else。考试从“未开始”到“进行中”到“已交卷”再到“已阅卷”状态的推进有严格顺序。我在项目里用了一个枚举类统一管理状态流转所有状态变更都走同一个方法配合数据库的条件更新保证并发场景下不会出现状态跳变。听起来像是过度设计但实际维护时真的能救命——新同事接手后不会被散落的业务判断搞晕。第二点成绩统计报表可以提前做好。在线考试系统的价值不只在考试本身更在考完后的数据沉淀。如果项目交付给企业用户管理者一定会关心“各科目平均分”、“学生成绩分布”、“试题正确率统计”这些报表。我预留了几条统计SQL比如按科目统计平均分SELECT subject_id, COUNT(DISTINCT student_id) AS student_count, AVG(total_score) AS avg_score, MAX(total_score) AS max_score, MIN(total_score) AS min_score FROM exam_record GROUP BY subject_id;这几条SQL在开发阶段就能顺手验证一遍避免上线后临时加功能。第三点对接AI和智能出题是可以发展的方向。当前系统已经具备相对完整的题库数据唯一差的是一个基于知识点的智能组卷模块。如果要做二次扩展可以引入HanLP做题干分词和知识点标签抽取将题目按知识点自动归类再根据考生历史表现动态调整组卷策略这样系统就从“静态题库工具”变成了“自适应学习平台”。这套扩展逻辑不改变现有表结构只要在题目表上增加知识点字段再写一个解析任务就能跑起来。第四点开发环境的小工具值得花时间配置。比如MyBatis打印SQL的日志级别用标准输出打印虽然开发时直观但生产环境日志会爆炸建议生产环境关闭SQL日志数据库管理工具方面Navicat虽然是很多人的习惯选择但我个人更推荐开源免费的DBeaver功能完全够用也不会有授权问题。这些细节看起来不起眼却能实实在在提升日常开发效率。在线考试系统这个项目是那种“你越认真做越能学到东西”的项目。它没有复杂的高并发挑战但每个环节都有值得深挖的细节。把一套完整源码吃透比你漫无目的地刷一百个CRUD案例更有价值。希望这份梳理对正在做同类系统的朋友有帮助也欢迎交流实际项目中遇到的特殊问题。