:開發(fā)部署與避坑指南)
做后臺開發(fā)這些年考試系統(tǒng)這類項目我前后接手過不少版本有學校拿來期末測評的有企業(yè)內(nèi)部做培訓認證的還有金融機構(gòu)拿來做上崗考核的。需求大同小異但源碼質(zhì)量真的天差地別。最近花時間把一套基于 SpringBoot Vue MyBatis MySQL 的在線考試系統(tǒng)完整跑了一遍順手做了二次開發(fā)和部署調(diào)優(yōu)今天把項目骨架、核心實現(xiàn)和那些容易踩的坑一次性說清楚。不管你是剛?cè)肼毜?Java 后端還是想拿考試系統(tǒng)做畢業(yè)設計、接外包項目的開發(fā)者這套東西都值得仔細看一遍。它沒有堆砌花哨技術(shù)而是把在線考試最核心的“組卷、答題、閱卷、統(tǒng)計”閉環(huán)串得比較完整屬于那種能直接落到生產(chǎn)環(huán)境再改改就能用的項目。1. 這套系統(tǒng)實際解決什么問題角色、閉環(huán)與核心模塊1.1 企業(yè)級考試系統(tǒng)的典型痛點很多中小企業(yè)的培訓考試還在用紙質(zhì)問卷或者 Excel 統(tǒng)計組織一場考試要發(fā)卷、收卷、判分、錄成績?nèi)肆Τ杀痉浅8?。在線考試系統(tǒng)要解決的不只是“把題目放到網(wǎng)頁上”而是要把題庫管理、隨機組卷、限時作答、自動閱卷、成績報表這一整條鏈路打通。這套系統(tǒng)最值得看的地方就是它沒有把業(yè)務邏輯堆在 Controller 里而是按模塊拆開了。后端管理端負責用戶管理、題庫管理、考試管理、成績管理學生端負責報名考試、在線打開試卷、提交答案、查看成績。前后端通過 RESTful API 通信接口邊界清楚二次開發(fā)時不容易改一處崩一片。1.2 角色權(quán)限模型系統(tǒng)涉及三類核心角色分別是管理員、教師和考生。權(quán)限設計直接決定了很多頁面和接口是否要展示。角色核心操作管理員用戶管理、學科/題庫維護、考試發(fā)布與撤回、全局公告、數(shù)據(jù)統(tǒng)計教師題目錄入與批量導入、手動組卷/隨機組卷、手動閱卷、導出成績單考生查看可參加的考試、在線答題、自動提交、查看歷史成績與錯題權(quán)限這塊用 JWT 攔截器配合前端 Vue Router 導航守衛(wèi)做后端每個接口按角色校驗前端則根據(jù)登錄用戶角色動態(tài)生成可訪問的路由菜單。具體實現(xiàn)我在第 4 節(jié)、第 5 節(jié)會細說。1.3 一次完整考試的業(yè)務流理解這個系統(tǒng)的關(guān)鍵不是看單個接口而是盯住考試狀態(tài)機草稿、已發(fā)布、進行中、已結(jié)束。一個管理員創(chuàng)建考試后要先指定考試名稱、總時長、總分、及格分然后選擇科目并從題庫中抽題或手動選題組成試卷。發(fā)布后考生才能看到并報名??忌c擊開始考試后系統(tǒng)記錄開考時間到點自動交卷后端按預設規(guī)則判分最后生成成績單和匯總統(tǒng)計。這套閉環(huán)跑通之后再往上加防作弊、視頻監(jiān)考、錯題本這些功能就會容易很多因為邊界很清楚。2. 為什么是這套技術(shù)組合SpringBoot Vue MyBatis MySQL 的選型復盤2.1 MyBatis 比 JPA 更適合考試系統(tǒng)這種復雜查詢場景很多人會問為什么不用 Spring Data JPA我的實際體會是考試系統(tǒng)的查詢場景很偏“報表型”。比如統(tǒng)計某個科目的及格率、導出某場考試所有考生的明細分數(shù)SQL 里動不動就是多表 join、子查詢、條件動態(tài)拼接。JPA 在這種場景下寫起來非常憋屈要么寫 JPQL要么回退到原生 SQL。而 MyBatis 的 Mapper XML 天然支持動態(tài) SQL一張考試記錄明細表十幾個可選查詢條件拼在一起用where、if就能輕松搞定SQL 還能拿出去在 Navicat 里直接驗證。另外這類“企業(yè)級源碼”如果要用起來交付給客戶之后實施人員大概率是能看懂 SQL 的但未必熟悉 JPA 的關(guān)聯(lián)映射。MyBatis 的 XML 文件夠直白出問題也容易定位。2.2 Vue 負責交互SpringBoot 只管業(yè)務前端這塊Vue 做單頁應用的優(yōu)勢在于頁面狀態(tài)好管理尤其是答題過程中大量數(shù)據(jù)需要在前端暫存。項目里 Vue 通過 Axios 請求后端接口登錄后把 token 存到 localStorage每次請求通過請求攔截器自動攜帶 Authorization 頭。這里要提醒一點既然選擇了前后端分離后端就不要再在模板里寫頁面跳轉(zhuǎn)了。我見過不少考試系統(tǒng)源碼寫著 Vue 前端結(jié)果后端 Controller 還返回 ModelAndView前端框架等于白引入。這套項目里前后端的邊界是清晰的接口只返回 JSON路由只由前端控制這樣部署時前端可以丟 Nginx也可以打包進 SpringBoot 的 static 目錄。2.3 MySQL 版本選型和表字符集問題數(shù)據(jù)庫用的是 MySQL 5.7 或 8.0 都能跑。如果你本地裝的是 MySQL 8.0連接驅(qū)動必須用com.mysql.cj.jdbc.DriverJDBC URL 里建議顯式加上時區(qū)和 SSL 參數(shù)。建庫時一定要用 utf8mb4不然考生姓名里帶個生僻字或者表情符號插入時直接報錯。CREATE DATABASE IF NOT EXISTS exam_system DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;字符集這種事看似不起眼但往往是在正式上線后往題庫里導入真實題目時才炸出來。3. 數(shù)據(jù)模型是考試系統(tǒng)的地基核心表設計思路3.1 六張核心表如何各司其職看源碼第一件事不是讀代碼而是打開數(shù)據(jù)庫腳本看表結(jié)構(gòu)。這套系統(tǒng)的表設計很典型核心就是下面這些表名作用t_user用戶表存登錄賬號、密碼、姓名、角色、狀態(tài)t_subject學科表比如 Java、數(shù)據(jù)庫、市場營銷t_question題目表單選題、多選題、判斷題t_exam考試表存考試基本信息、時長、及格分、組卷方式t_paper試卷表記錄每次考試實際生成的題目集合t_exam_record考試記錄表記錄每個考生參與考試的狀態(tài)和得分t_answer_record答題明細表記錄每道題考生填寫的答案用戶、題目、考試屬于基礎數(shù)據(jù)真正把業(yè)務串起來的是t_paper和t_exam_record這兩張關(guān)系表。3.2 試卷與答題記錄的字段設計試卷表設計上有一個容易踩坑的點它到底要存“題目的完整內(nèi)容”還是只存“題目 ID 的關(guān)聯(lián)關(guān)系”我的建議是只存關(guān)聯(lián)題目內(nèi)容始終以t_question表為準。這樣題目如果被老師更新過所有歷史試卷會自然同步。缺點是組卷后如果題目被刪了歷史試卷會關(guān)聯(lián)不上所以源碼里通常對已有考試引用的題目做了刪除保護刪除前先查一遍引用數(shù)量。答題明細表字段大致這樣CREATE TABLE t_answer_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, record_id BIGINT NOT NULL COMMENT 考試記錄ID, question_id BIGINT NOT NULL COMMENT 題目ID, user_answer VARCHAR(500) COMMENT 考生答案如A,B,D, is_correct TINYINT DEFAULT 0 COMMENT 是否正確, score DECIMAL(5,1) DEFAULT 0 COMMENT 本題得分, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_record (record_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;這里把用戶答案和得分冗余在明細表里查詢成績明細時不用反復 join 題目表做邏輯判斷。數(shù)據(jù)庫設計的時候適當冗余是省性能最直接的方式。3.3 索引怎么設計才不會被查詢拖死考試系統(tǒng)一旦上線最容易膨脹的是t_exam_record每個考生每場考試一條記錄配合明細表就是幾十萬甚至上百萬行。索引必須提前規(guī)劃好t_exam_record至少要有(user_id, exam_id)的聯(lián)合唯一索引防止同一考生重復提交。t_answer_record要對record_id建立普通索引查詢成績明細走索引非常快。t_question要按(subject_id, question_type)建聯(lián)合索引因為隨機抽題和列表篩選都依賴這兩個字段。很多源碼在表結(jié)構(gòu)里連索引都不加本地跑著沒問題一旦考試人數(shù)上來成績統(tǒng)計接口會把數(shù)據(jù)庫拖到 CPU 百分百。索引這塊必須自己動手補。4. SpringBoot 后端實現(xiàn)重點從登錄鑒權(quán)到自動閱卷的完整鏈路4.1 JWT 登錄與接口鑒權(quán)登錄接口校驗用戶名密碼后生成 JWTtoken 里包含用戶 ID、角色、昵稱這些非敏感信息。后端寫一個 HandlerInterceptor 或 Filter統(tǒng)一攔截除登錄、公告等白名單之外的所有接口校驗 Authorization 頭里面的 token。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String auth request.getHeader(Authorization); if (auth null || !auth.startsWith(Bearer )) { throw new AuthException(401, 未登錄或token過期); } Claims claims JwtUtil.parseToken(auth.substring(7)); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } }這里的關(guān)鍵點在于攔截器里解析完 token 后把用戶信息放進 request objectController 方法里直接用RequestAttribute取避免每次請求都重新查數(shù)據(jù)庫。雖然 jjwt 版本之間 API 差異有點大但整體思路是通用的。4.2 隨機組卷別迷信 ORDER BY RAND()源碼里隨機組卷最直觀的實現(xiàn)是ORDER BY RAND() LIMIT n這個寫法在題庫幾百條數(shù)據(jù)時很爽但題庫上萬后MySQL 會對全表每一行生成隨機數(shù)再排序性能非常拉胯。select idrandomSelect resultTypeQuestion SELECT * FROM t_question WHERE subject_id #{subjectId} AND question_type #{type} ORDER BY RAND() LIMIT #{count} /select生產(chǎn)環(huán)境下我一般會改成基于主鍵隨機區(qū)間的方式select idrandomSelectByOffset resultTypeQuestion SELECT * FROM t_question WHERE subject_id #{subjectId} AND question_type #{type} AND id ( SELECT FLOOR(RAND() * (SELECT MAX(id) FROM t_question)) 1 ) ORDER BY id LIMIT #{count} /select這種方案也有個隱患如果題目 ID 中間有斷層可能一次取不夠數(shù)量。穩(wěn)妥做法是先查出符合條件的id列表放進內(nèi)存再用程序隨機打亂后取前 N 個通過IN查詢題目詳情。題目量不大的情況下性能差別不大但思路要清楚。4.3 自動閱卷的判分邏輯設計自動閱卷是整個系統(tǒng)最不能出錯的模塊。單選和判斷題好辦直接比對標準答案和考生答案。多選題要特別注意順序問題考生選 A,B 和 B,A 應該算同一答案拼接前必須排序后再比較。至于多選題給分策略不同企業(yè)要求不一樣。有的全對才給分有的選對一個給一半分。源碼里默認是全對才得分但我在改造時會把策略放到考試配置里用score_strategy字段區(qū)分“全對得分”和“部分給分”。這樣不同科目可以有不同的給分規(guī)則。提交答案之后閱卷和成績匯總必須放在同一個事務里Transactional public void submitExam(SubmitRequest request) { ExamRecord record saveAnswerRecords(request); calculateScore(record); updateExamRecord(record); }不加事務的話一旦閱卷邏輯報錯考生答案存進去了但成績沒算出來復查時會非常痛苦。5. Vue 前端關(guān)鍵實現(xiàn)路由、狀態(tài)管理和答題體驗5.1 動態(tài)路由和權(quán)限菜單的坑前端首次登錄后拿到用戶角色然后按角色把對應的路由動態(tài)加進去。Vue Router 4 里用router.addRoute可以逐條添加。const roleRouteMap { admin: adminRoutes, teacher: teacherRoutes, student: studentRoutes } router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token) return next(/login) const role localStorage.getItem(role) if (!hasAddedRoutes) { roleRouteMap[role].forEach(r router.addRoute(r)) hasAddedRoutes true return next({ ...to, replace: true }) } next() })很多人用動態(tài)路由時遇到刷新后菜單丟失原因是 Vuex state 在頁面刷新后會重置動態(tài)添加的路由是沒了但代碼里沒有重新加載用戶信息的邏輯。解決方式很簡單刷新后從 token 里解析角色或者向后端請求一次用戶信息再重新 addRoute。另外hasAddedRoutes這個標記必須放在模塊級變量而不是 Vuex 里否則刷新后還是會丟。5.2 答題狀態(tài)管理和本地草稿備份考試頁面最怕的是什么用戶答了半小時題一個誤刷新或者網(wǎng)絡抖動全部答案清空。所以答題記錄不能只存在組件內(nèi)部要么放 Vuex 里要么配合 localStorage 持久化。我的做法是用戶每選一道題就把答案寫入 Vuex 對象answers同時用 localStorage 保存一份備份。開始考試時先檢查 localStorage 里是否有未提交的答案快照有的話提示用戶恢復。提交成功后立刻清空快照。這樣即使考試中途瀏覽器崩潰重新進入頁面也能續(xù)答考生的體驗會好很多。倒計時部分用setInterval每秒更新剩余時間到 0 秒自動觸發(fā)提交。這里要處理一個關(guān)鍵問題前端倒計時不準所以后端在開考時會返回服務端時間和考試截止時間前端用時間戳差值計算剩余時長而不是本地時間計算避免考生手動改系統(tǒng)時間作弊。5.3 打包放進 SpringBoot 的兩種常見做法開發(fā)調(diào)試時前端用 Vite / Webpack 的 devServer訪問后端接口用代理配置把/api轉(zhuǎn)發(fā)到http://localhost:8080。正式部署時前端執(zhí)行構(gòu)建生成dist目錄。第一種做法是把dist整體復制到后端src/main/resources/static隨 Maven 打包進 jar訪問同一個端口。這種方案適合內(nèi)部系統(tǒng)部署簡單。第二種是前端單獨部署到 Nginx后端單獨起 jar通過反向代理轉(zhuǎn)發(fā)請求。更推薦第二種因為前端靜態(tài)資源更新不需要重新打后端包。如果選第一種記得在vue.config.js里設置module.exports { publicPath: ./, outputDir: dist }不然靜態(tài)資源路徑會按/根路徑加載放到 jar 里打開頁面全是白屏。6. 從零跑通這套源碼部署全流程與高頻問題排查6.1 環(huán)境準備和啟動順序先把整套環(huán)境列出來缺哪個補哪個。依賴推薦版本說明JDK8 或 11SpringBoot 2.x 都能跑Maven3.6后臺依賴管理Node.js14/16/18Vue 前端構(gòu)建MySQL5.7 或 8.0數(shù)據(jù)庫啟動順序不要亂。第一步導入數(shù)據(jù)庫腳本第二步改application.yml里的數(shù)據(jù)庫用戶名密碼第三步啟動后端第四步起來前端。后端起來后先訪問 Swagger 接口文檔或者直接登錄接口確認數(shù)據(jù)庫連接沒問題再折騰前端。# 后端打包啟動 mvn clean package -DskipTests java -jar target/exam-system.jar # 前端構(gòu)建 cd frontend npm install npm run build6.2 MySQL 連接報錯集中排查鏈路數(shù)據(jù)庫連接是這套源碼報錯最高發(fā)的位置。我踩過的典型問題有這幾個第一個是 SSL 連接錯誤控制臺出現(xiàn)SSL connection error。這不是數(shù)據(jù)庫壞了而是 MySQL 8 默認要求 SSL 握手本地開發(fā)環(huán)境不需要。JDBC URL 里加兩個參數(shù)useSSLfalseallowPublicKeyRetrievaltrue。后者尤其重要因為 MySQL 8 默認的caching_sha2_password認證插件在非 SSL 連接下拉取公鑰時需要顯式允許。第二個是 Windows 下 MySQL Installer 報E0434352。這基本不是 MySQL 本身的問題而是 Windows 環(huán)境里缺少對應版本的 .NET 桌面運行時。處理辦法是檢查并安裝 .NET Framework 4.7.2 或更高版本再重新跑安裝程序。第三個是時區(qū)問題日志里出現(xiàn)The server time zone value ?D1ú±ê×?ê±?? is unrecognized。URL 里加上serverTimezoneAsia/Shanghai就解決了。6.3 MyBatis 配置“文件找不到”和“駝峰不映射”的坑把源碼下載下來后最容易出現(xiàn)的問題就是啟動時報Invalid bound statement (not found)。這通常是 Mapper 接口和 XML 文件沒有建立對應關(guān)系。檢查兩處Mapper 接口的包路徑要和 XML 的namespace完全一致application.yml里mybatis.mapper-locations要寫成classpath*:mapper/*.xml注意是classpath*而不是classpath。另一個隱性坑是數(shù)據(jù)庫字段real_name映射不到realName。如果 XML 里resultType用實體類而沒有寫resultMap一定要在 MyBatis 全局配置里開啟mybatis: configuration: map-underscore-to-camel-case: true6.4 Vue 構(gòu)建報錯和接口跨域問題前端npm install如果因為網(wǎng)絡問題失敗可以把 registry 換成國內(nèi)鏡像然后再裝。構(gòu)建報 Node 版本不兼容時先看項目里用的 Vue CLI 還是 ViteVue CLI 老項目對 Node 新版經(jīng)常會有 OpenSSL 的 hash 報錯加一句NODE_OPTIONS--openssl-legacy-provider能臨時繞過去。前端聯(lián)調(diào)接口最容易碰到跨域。開發(fā)環(huán)境用 devServer 的 proxy 代理生產(chǎn)環(huán)境用 Nginx 的反向代理。Nginx 配置時注意把/api/的請求轉(zhuǎn)發(fā)到后端端口location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }如果 Vue Router 用的是 history 模式刷新頁面時 Nginx 還要配try_files $uri $uri/ /index.html;否則會出現(xiàn)刷新后 404 的詭異問題。7. 從“能跑”到“能用”這套源碼值得優(yōu)先擴展的方向源碼拿到手能啟動只是第一步。真正要落地到企業(yè)場景我建議優(yōu)先做這幾件事。第一把題庫隨機組卷從ORDER BY RAND()改成內(nèi)存打散或者加一張題號索引表做隨機偏移。這是最影響考試創(chuàng)建時性能的一個點。第二給答題明細和考試成績增加異步導出。成績單導出很容易做成同步接口數(shù)據(jù)量一大前端等超時很常見。簡單做法是導出任務落到表里后臺線程生成 Excel考生端點導出時返回“正在生成”的狀態(tài)再輪詢獲取下載鏈接。第三如果要支持聽力題、視頻題這類資源型題目可以把音視頻文件放到 MinIO 這樣的對象存儲里SpringBoot 只管生成臨時訪問憑證前端用播放器拉流。題目表里增加一個resource_url字段考試時考生端根據(jù)資源類型渲染音頻或視頻播放器和純文字題共用一套答題記錄邏輯擴展成本很低。第四并發(fā)量上來以后緩存是繞不開的。考試基礎信息、已發(fā)布試卷這類讀多寫少的數(shù)據(jù)可以放 Redis但考生答案和成績絕不能只寫 Redis 不回寫 MySQL必須以數(shù)據(jù)庫為準。這套源碼最適合的用法不是我說的這些功能它都有而是它把考試系統(tǒng)里最穩(wěn)定的那套骨架搭好了你這個拿到手的人可以在上面很清晰地做取舍和替換。我實際部署中最常遇到的情況反而是很多人先急著改頁面結(jié)果后端連數(shù)據(jù)庫都沒連上。所以最后一句話還是那句老話先跑通再架構(gòu)先看表再改碼。把數(shù)據(jù)庫腳本和最小閉環(huán)跑起來比什么都重要。