:從Spring Boot到Vue 3的完整實踐)
這個項目嚴格來說是去年秋招間隙做出來的起因特別簡單用常見的刷題產(chǎn)品總覺得不順手錯題本太散、沒有知識點維度的統(tǒng)計、還總被推薦一些早就掌握的題。于是我就想著干脆自己造一個智能面試刷題系統(tǒng)既能滿足日常刷題需求又順便把前后端分離的架構(gòu)完整走一遍。整個項目從設計到第一版上線大概用了三周技術棧選的是 Spring Boot Vue 3前端走 RESTful API 跟后端通信核心特色是答題記錄驅(qū)動的一套推薦邏輯和可視化統(tǒng)計。這篇文章把我當時的決策過程、數(shù)據(jù)庫設計、實現(xiàn)細節(jié)、以及上線后踩過的坑都整理出來理論和代碼都有對正在做畢設或想加深架構(gòu)理解的開發(fā)者會比較有參考價值。1. 項目的真實起點一個刷題工具的需求演進1.1 痛點分析為什么需要專門做一套刷題系統(tǒng)刷題這件事看起來極其簡單注冊一個賬號然后對著題庫一題一題點就是了。但真正堅持用一個月以后會發(fā)現(xiàn)盲目刷題有一個非常致命的問題你根本不知道自己哪里薄弱只會被動地按照題目順序一路點下去今天錯的知識點明天就忘下周又錯一遍。我在做這個項目之前專門觀察了自己和身邊幾個準備面試的同學的使用習慣。大家刷題時最有價值的動作其實是提交答案那一刻的判斷過程和看解析后的復盤過程。但市面上通用產(chǎn)品大多只把判斷對錯這一步留給了你錯題回收、知識點統(tǒng)計、復習提醒這些環(huán)節(jié)要么很弱要么藏得很深。所以我自己做的第一版需求就非常明確要有一條完整的刷題—記錄—分析—推薦—再刷題閉環(huán)而不是一個單純堆砌題目的文檔系統(tǒng)。這個系統(tǒng)的用戶其實只有兩類一類是求職者需要在短期內(nèi)快速補足薄弱知識點另一類是運營題庫的人需要一個趁手的工具把題目和知識點維護好。畢設或者個人項目最怕的就是功能無限膨脹所以我在立項時就把邊界卡得很死不做社區(qū)、不做社交、不做課程售賣只做跟有效刷題強相關的功能。1.2 功能邊界劃定與角色設計第一版的功能清單是這樣定的。用戶端只有五個核心模塊注冊登錄、刷題練習、模擬面試、錯題本、學習統(tǒng)計。管理端是三個題庫管理、知識點管理、用戶數(shù)據(jù)概覽。登錄注冊這個模塊沒什么好說的唯一要注意的是密碼不能明文存我用的是標準加鹽哈希。刷題練習是主入口用戶可以選擇按順序刷、按知識點刷、或者直接進入智能推薦模式。模擬面試是一個獨立的會話式場景按照崗位方向和難度生成一套題答題過程中有倒計時結(jié)束后給出得分和解析。錯題本不是簡單收藏夾它要記錄每個錯題的錯誤次數(shù)、最近錯誤時間并且可以一鍵把錯題重新變成一套練習卷。學習統(tǒng)計則把答題記錄聚合起來按天輸出刷題熱力圖按知識點輸出正確率和掌握度雷達圖。管理端的題庫管理要覆蓋題目新增、編輯、上下架、批量導入。知識點管理維護一棵知識點樹每個題目可以掛到一個葉子節(jié)點上。用戶數(shù)據(jù)概覽我做得比較克制只展示注冊用戶數(shù)、刷題總量、平均正確率這些基礎指標。管理端和用戶端共用同一套后端接口權限靠 JWT 里的 role 字段區(qū)分后端在最外層攔截器里做角色校驗這樣兩個前端應用都能保持比較薄的邏輯。1.3 為什么堅持前后端分離很多在校同學做系統(tǒng)時習慣把頁面模板和后端渲染混在一起理由是這樣簡單減少跨域問題。但我個人強烈建議這類偏業(yè)務型的項目一開始就走前后端分離原因有三個。第一前端交互復雜度決定了它不適合服務端渲染。刷題頁面有倒計時、選項狀態(tài)、實時統(tǒng)計圖表、答題記錄更新這些都是典型的 SPA 場景用傳統(tǒng)模板一個個同步渲染會非常痛苦。第二一個后端 API 能同時喂給用戶端、管理端甚至以后接一個小程序代碼不需要重寫。第三前后端分離以后接口邊界變得清晰我可以先把接口文檔定好再讓兩個部分并行推進整個開發(fā)節(jié)奏會快很多。當然也要清醒認識到分離的代價跨域配置、登錄態(tài)管理、接口聯(lián)調(diào)都是以前不需要操心的東西。這些問題在后面的各部分里都有對應解法不存在無解的情況只是需要多學幾種新技能而已。2. 技術選型與架構(gòu)設計前后端分離的骨架搭建2.1 后端用什么、為什么不后悔后端技術棧是 Spring Boot 3.x Java 17 MyBatis-Plus MySQL 8 Redis。為什么這么選其實是在學習價值和落地速度之間做平衡。如果只是想跑通一個項目用 Node.js 寫接口可能更快但考慮到系統(tǒng)涉及復雜的數(shù)據(jù)統(tǒng)計、事務處理和權限模型Java 生態(tài)的穩(wěn)定性是實打?qū)嵉膬?yōu)勢。Spring Boot 幫我把絕大多數(shù)配置自動化了我只需要關注 Controller、Service、Mapper 這一層業(yè)務代碼。MyBatis-Plus 的單表 CRUD 和分頁插件非常好用條件構(gòu)造器寫動態(tài)查詢也很順手復雜統(tǒng)計我用自定義 XML 寫 SQL兩邊互不干擾。Redis 我用在兩個地方。第一是緩存知識點統(tǒng)計結(jié)果統(tǒng)計接口如果每次都去 MySQL 聚合全表數(shù)據(jù)量大了以后響應會明顯變慢所以我在寫入 answer_record 時會同步把對應知識點的統(tǒng)計緩存刪除下次讀取時再重新計算。第二是存放模擬面試的上下文一場面試過程中的進度、剩余時間、題目列表都存在 Redis 里過期時間設成兩小時既能防止接口頻繁命中數(shù)據(jù)庫也能自然清理掉不活躍的會話。2.2 前端選型與工程化配置前端用了 Vue 3 Vite Pinia Vue Router Element Plus這套組合現(xiàn)在基本是中小型管理系統(tǒng)的默認選項但選它不純粹是因為流行。Vue 3 的組合式 API 對答題頁這種邏輯密集的頁面特別友好。選項式 API 寫小頁面沒問題一旦頁面里有倒計時、答題狀態(tài)、抽題邏輯、提交反饋邏輯就會散落在各個生命周期鉤子里用 setup 語法可以把同一塊業(yè)務的 ref、computed、watch 聚在一起維護成本低一個量級。Pinia 比 Vuex 輕省掉 mutation 這層概念在個人項目里少寫不少樣板代碼。Element Plus 我只按需引入不整包加載避免首屏時間被組件庫拖垮。Vite 帶來的體驗提升是感知最強的。以前用 Webpack 啟動一套前后端分離項目冷啟動十幾秒都是常態(tài)改一行代碼還要等編譯。Vite 基于原生 ESM 實現(xiàn)按需編譯項目不管多大首啟基本都是秒級熱更新也是毫秒級反饋。開發(fā)刷題系統(tǒng)要反復在兩個頁面之間跳轉(zhuǎn)調(diào)整樣式這個體驗優(yōu)勢能省下大量時間。2.3 接口規(guī)范、權限模型與跨域處理前后端分離項目能不能順利聯(lián)調(diào)接口規(guī)范起決定性作用。我定義了一個很簡單的統(tǒng)一返回包裝類后端所有接口出參都是 { code, message, data }業(yè)務異常通過全局異常處理器轉(zhuǎn)成對應的 code前端只需要在響應攔截器里統(tǒng)一判斷 code。分頁接口統(tǒng)一接受 page 和 size返回 PageResult里面包含 total、records 兩個字段避免每個接口各搞一套返回結(jié)構(gòu)。資源命名走 RESTful 風格/api/questions、/api/answer-records、/api/exam-sessions。這類系統(tǒng)里面試題目和答題記錄都是完整資源用 RESTful 的方式描述非常自然。前端請求路徑不再出現(xiàn)動詞接口語義也更清晰。權限模型沒有上 Spring Security而是用 JWT 加攔截器的輕量方案。登錄成功后后端返回一個包含 userId 和 role 的 JWT前端放在 localStorage 里每次請求通過 axios 攔截器加在 Authorization 頭。后端攔截器對所有 /api/** 請求做 token 校驗管理端接口額外檢查 role 是否為管理員解析失敗或角色不對直接返回 401 和 403。這套方案對中小項目完全夠用代碼少也容易讀懂??缬騿栴}我分開處理。開發(fā)環(huán)境用 Vite 的 proxy 把 /api 代理到后端地址瀏覽器看到的是同源請求不存在跨域。生產(chǎn)環(huán)境用 Nginx 反向代理直接把對 /api 的請求轉(zhuǎn)發(fā)到 Java 服務同樣不需要后端開啟 CORS。這樣處理的好處是減少攻擊面也更貼近真實線上部署的形態(tài)。而且開發(fā)環(huán)境和生產(chǎn)環(huán)境的行為保持一致前端代碼不需要根據(jù)環(huán)境維護兩套 baseURL排查接口問題時只需要抓 Nginx 日志和 Java 應用日志兩處就夠了。3. 數(shù)據(jù)模型設計與核心業(yè)務實現(xiàn)3.1 數(shù)據(jù)庫到底建了幾張表我把表控制在六張核心表不建一堆中間表來增加維護成本。設計原則只有一個所有后續(xù)要做統(tǒng)計和推薦的數(shù)據(jù)必須在產(chǎn)生那一刻就能支撐查詢避免統(tǒng)計時到處 join。表名關鍵字段說明userid, username, password_hash, nickname, role用戶表role 區(qū)分普通用戶/管理員knowledge_pointid, name, parent_id, weight知識點樹weight 表示重要度questionid, type, difficulty, point_id, stem, options, answer, analysis, status題庫type 區(qū)分選擇題/簡答題/編程題answer_recordid, user_id, question_id, point_id, difficulty, is_correct, answer_content, use_time, created_at答題流水表業(yè)務核心wrong_bookid, user_id, question_id, error_count, last_wrong_at, review_status錯題狀態(tài)表exam_sessionid, user_id, mode, difficulty, question_ids, status, score模擬面試會話表這里我想強調(diào) answer_record 這張表。它本質(zhì)上是一張事實表每一行代表一次真實的刷題行為。字段里冗余了 point_id 和 difficulty雖然打破了盡量不冗余的潔癖但對統(tǒng)計性能幫助很大按知識點算正確率時不需要再回表查 question 表直接 group by point_id 就行。建議在這張表上建立聯(lián)合索引 (user_id, point_id, created_at)這是整個系統(tǒng)最高頻的查詢路徑。當初如果不建這個索引統(tǒng)計數(shù)據(jù)一多接口響應時間會從毫秒級一路漲到秒級。3.2 刷題主流程一次完整答題的數(shù)據(jù)流轉(zhuǎn)一次完整答題的數(shù)據(jù)流轉(zhuǎn)是整個項目里最核心的一條鏈路。用戶點開始刷題后前端向后端請求推薦題目后端根據(jù)推薦策略返回一道題用戶提交答案后端判斷對錯寫入 answer_record更新掌握度緩存和錯題本最后把正確答案、解析和新掌握度返回給前端。提交答案的處理邏輯用代碼寫是這樣Transactional public AnswerResult submitAnswer(SubmitRequest request) { Question question questionMapper.selectById(request.getQuestionId()); boolean correct question.getAnswer().equalsIgnoreCase(request.getAnswer()); AnswerRecord record new AnswerRecord(); record.setUserId(request.getUserId()); record.setQuestionId(question.getId()); record.setPointId(question.getPointId()); record.setDifficulty(question.getDifficulty()); record.setCorrect(correct); record.setUseTime(request.getUseTime()); answerRecordMapper.insert(record); masteryCache.evict(request.getUserId(), question.getPointId()); if (!correct) { wrongBookMapper.upsert(request.getUserId(), question.getId()); } return new AnswerResult(question, correct); }注意事務注解一定不能少。一開始我漏了事務結(jié)果出現(xiàn)用戶提交答案后answer_record 寫入了而錯題本沒有更新的情況自我排查時非常難受。因為在同一事務里要么全部成功要么全部回滾這條鏈路絕不能出現(xiàn)中間狀態(tài)。3.3 錯題本與刷題統(tǒng)計的底層邏輯錯題本用 upsert 語義實現(xiàn)這是一個容易想錯的地方。如果每次答錯都 insert 一條錯題記錄同一個題在錯題本里會出現(xiàn)多條展示時還要去重邏輯非常別扭。正確做法是在錯題表里用 (user_id, question_id) 建唯一索引答錯時執(zhí)行存在就累加錯誤次數(shù)不存在才插入的更新語句。復習狀態(tài)我是用一個 review_status 字段管理的初始值是等待復習用戶把錯題重新做對一次后變成已掌握下次再錯再回退。這個字段我一開始沒想清楚直接把錯題表當收藏夾用后來加了字段才讓復習一下變成可操作的動作。前端在錯題本里提供篩選條件只看待復習、只看已掌握加上一鍵復習按鈕比單純展示所有錯題實用得多。統(tǒng)計模塊相對直接。按天刷題量用一個 SQL 就能查出來SELECT DATE_FORMAT(created_at, %Y-%m-%d) AS day, COUNT(*) AS total FROM answer_record WHERE user_id #{userId} GROUP BY day ORDER BY day;知識點掌握度統(tǒng)計類似group by point_id 后計算正確率和答題量前端再用雷達圖渲染出來。這套統(tǒng)計鏈路不復雜但前提是 answer_record 表的結(jié)構(gòu)一開始就設計對數(shù)據(jù)來源不能亂統(tǒng)計結(jié)果才有意義。4. 智能推薦策略讓刷題有方向而不是瞎碰4.1 可解釋的推薦比看不見的玄學推薦更重要說到智能很多人第一反應是上協(xié)同過濾或者跑一個神經(jīng)網(wǎng)絡模型。但對這類個人項目來說效果并不好用戶和題目的交互矩陣極度稀疏冷啟動問題根本繞不開而且推薦結(jié)果無法解釋用戶會困惑為什么給我推這道題。我的設計原則是把推薦做成可解釋的。推薦一道題時前端可以明確展示推薦理由比如你最近關于二分查找的掌握度偏低推薦完成這道題。這種可解釋性既讓用戶信服也讓代碼邏輯更好維護。推薦鏈路只依賴 answer_record 和 knowledge_point 兩張表可控性很強后續(xù)想調(diào)整規(guī)則也只是改一個 Service 的事情。從實際使用反饋來看用戶對這種知道為什么推題的接受度比我預期高很多。4.2 掌握度計算與推薦優(yōu)先級掌握度是整個推薦策略的核心指標。我不采用簡單的歷史正確率因為正確率沒法體現(xiàn)時間衰減很久以前錯過的知識點如果最近一直做對應該認為它正在被掌握。所以用帶時間衰減的加權平均核心邏輯如下public double calculateMastery(ListAnswerRecord records) { double sumWeight 0.0; double sumCorrect 0.0; for (AnswerRecord record : records) { double days Duration.between(record.getCreatedAt(), LocalDateTime.now()).toDays(); double weight Math.exp(-days / 7.0); sumWeight weight; sumCorrect record.isCorrect() ? weight : 0.0; } return sumWeight 0 ? sumCorrect / sumWeight : 0.5; }衰減周期設成 7 天相當于一周前的答題權重衰減到約 37%兩周前基本忽略。這個參數(shù)我一開始是拍腦袋定的但實測體驗比純正確率平滑很多后來也就沒有再調(diào)。需要注意的是掌握度計算不要放在每次請求里實時掃全表正確做法是答題提交時更新知識點的掌握度緩存推薦接口直接讀緩存。推薦優(yōu)先級分三層。第一層是最薄弱知識點下、且重要度 weight 高的未做題目直接強推薦。第二層是掌握度在 0.4 到 0.6 之間的知識點說明處于邊界狀態(tài)需要加強。第三層是近期完全沒有刷過題的知識點用隨機池撈一兩道來保持覆蓋面。每次后端生成一個候選題池在池內(nèi)隨機選一道返回避免用戶連續(xù)刷到同一題。這套規(guī)則我用一個策略接口封裝每層策略只負責篩選候選題后面想加按崗位過濾或者按題目熱度排序也只是新增策略類的問題。4.3 模擬面試與智能組卷的輕量實現(xiàn)模擬面試模式是為了給用戶一個完整的作答節(jié)奏。用戶選擇崗位方向和難度后后端按知識點權重進行配額抽題保證一套試卷覆蓋多個知識點而不是全抽到同一個點。組卷邏輯聽起來復雜落實下來其實就是一個按知識點配額的抽樣算法先算出每個知識點要抽多少題再從該知識點的題目池里隨機抽抽過的題要放到排除集合里避免重復。核心思路用代碼寫是這樣MapLong, Integer quotaMap buildQuotaMap(knowledgePoints, totalCount); for (Map.EntryLong, Integer entry : quotaMap.entrySet()) { ListQuestion pool questionMapper.selectByPointAndDifficulty( entry.getKey(), difficulty, excludedIds); selected.addAll(randomPick(pool, entry.getValue())); }選擇題提交后立刻自動判分簡答題初版采用關鍵詞匹配評分把參考答案里預先標注的核心關鍵詞和用戶答案做包含比對按命中率給分編程題因為在線判題工程量太大第一版只展示參考答案讓用戶自評。分數(shù)統(tǒng)計和面試報告仍然完整生成用戶可以翻看每個知識點的表現(xiàn)。5. 前端落地與體驗打磨5.1 SPA應用的結(jié)構(gòu)規(guī)劃前端項目結(jié)構(gòu)按照視圖、狀態(tài)、網(wǎng)絡三層來劃分。src/views 只放頁面組件src/stores 放全局狀態(tài)src/api 統(tǒng)一封裝網(wǎng)絡請求。API 層不對業(yè)務頁面暴露 axios 實例只暴露 getQuestionById、submitAnswer 這樣的語義化方法。頁面組件里不出現(xiàn)任何請求方法邏輯只認 store 和 api 層的返回值。views 下目前有 Login、Dashboard、QuestionBank、Practice、MockInterview、Stats、AdminQuestion、AdminKnowledge 這些頁面。路由用 Vue Router 管理菜單和路由表一一對應用戶端和管理端通過不同的路由前綴和導航菜單隔離。目錄結(jié)構(gòu)清爽之后新加頁面很少會牽扯到舊代碼這個收益在開發(fā)到中后期體現(xiàn)得非常明顯。5.2 登錄態(tài)與路由守衛(wèi)的處理前后端分離最容易出問題的是登錄態(tài)。我的處理方案是token 同時存在 localStorage 和 Pinia 里頁面刷新時從 localStorage 恢復保證刷新后不丟登錄狀態(tài)。axios 請求攔截器統(tǒng)一在請求頭里加 Authorization響應攔截器遇到 401 時清掉用戶狀態(tài)并跳轉(zhuǎn)到登錄頁。核心代碼http.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) http.interceptors.response.use( res res.data, err { if (err.response?.status 401) { useUserStore().logout() router.push(/login) } return Promise.reject(err) } )路由守衛(wèi)是第二道防線。在 beforeEach 里判斷如果訪問的是需要登錄的頁面且本地沒有 token直接跳登錄頁。要注意把登錄頁、注冊頁加入白名單否則會出現(xiàn)登錄時跳轉(zhuǎn)登錄頁的循環(huán)問題。這個坑我踩過表現(xiàn)形式是用戶輸完賬號密碼剛要點登錄頁面就被守衛(wèi)踢回登錄頁現(xiàn)象非常詭異排查了幾分鐘才發(fā)現(xiàn)是白名單漏配導致的。5.3 從交互細節(jié)到性能優(yōu)化性能優(yōu)化我做了五件事。組件庫按需引入用 Vite 的插件自動把未使用的 Element Plus 組件搖掉路由懶加載每個頁面拆成獨立 chunk初始加載只下載首屏資源題目列表用分頁加條件篩選不做一次性全量加載ECharts 圖表按需引入只注冊用到的雷達圖和熱力圖模塊長列表的統(tǒng)計頁用虛擬滾動避免幾千個節(jié)點同時渲染。交互細節(jié)上答題頁最值得打磨。選項點擊后要有即時選中態(tài)提交過程中按鈕置灰防重復提交倒計時離開頁面時銷毀定時器防止內(nèi)存泄漏。還有一個容易忽略的點用戶從刷題頁跳到別的路由再回來答題狀態(tài)要能恢復我通過把當前題目ID和已選答案存在 Pinia 里來解決。這些細節(jié)不復雜但用戶感知非常明顯也是產(chǎn)品打磨和半成品之間的核心差別。6. 部署實戰(zhàn)與高頻踩坑記錄6.1 Nginx Spring Boot 的部署組合部署方案很簡單后端打成 jar 包用 systemd 守護進程跑在 8080 端口前端 npm run build 后把 dist 目錄扔給 Nginx。Nginx 同時承擔靜態(tài)文件服務和反向代理配置如下server { listen 80; server_name your-domain.com; root /var/www/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }這個配置有兩個容易踩的坑。一個是 proxy_pass 后面的路徑因為后端接口也是 /api 開頭所以保留了 /api/ 后綴避免路徑拼接錯誤另一個是 try_files 的 fallback 一定要落到 /index.html否則前端路由刷新時會出現(xiàn) 404。生產(chǎn)環(huán)境上線后我還加了 systemd 的自動重啟機制Java 進程如果意外退出會被自動拉起來省掉半夜爬起來重啟服務的情況。6.2 典型問題與排查速查表我把上線前后遇到的高頻問題整理成一張表基本覆蓋了這個項目里出現(xiàn)過的所有典型故障。現(xiàn)象原因解法頁面刷新后回到登錄頁沒有從 localStorage 恢復 token初始化時從 localStorage 寫入 Pinia401 后循環(huán)跳轉(zhuǎn)登錄頁白名單配置漏了登錄頁在路由守衛(wèi)里顯式放行 /login接口返回跨域錯誤開發(fā)代理沒配置Vite proxy 指向正確后端地址直接訪問異步路由刷新 404Nginx 沒有 fallback配置 try_files $uri $uri/ /index.html錯題本出現(xiàn)重復記錄沒有唯一索引 / 沒做 upsert加聯(lián)合唯一索引改 upsert SQL統(tǒng)計圖數(shù)據(jù)比實際少事務未提交或條件不一致統(tǒng)一使用 created_at 并按天分組模擬面試出現(xiàn)重復題目組卷抽樣沒有排除已選題用排除集合記錄已選題目 ID這些坑基本都屬于知道原理后一分鐘解決不知道原理要查半天的類型。我把它們寫進項目 README 的部署清單里后面維護或給其他人接手時會省掉很多溝通成本。6.3 做完這個項目我才明白的幾件事第一接口設計比實現(xiàn)接口更花時間但收益最大。我前期把接口文檔定義清楚后前后端開發(fā)幾乎不需要互相等待遇到的返工也比以往項目少很多。第二數(shù)據(jù)表設計決定功能上限。answer_record 冗余字段這個決定讓后面的統(tǒng)計、推薦、錯題分析都變得簡單如果當時圖省事不冗余現(xiàn)在每個統(tǒng)計接口都要回表 join性能和可讀性都會下降。第三前后端分離不是為了炫技它帶來的真正價值是讓用戶端、管理端、未來可能的客戶端共享一套業(yè)務能力把重復代碼壓縮到最低。最后再分享一個經(jīng)驗。這個項目最有價值的不是某個頁面寫得多漂亮而是那套刷題記錄—掌握度計算—推薦的數(shù)據(jù)鏈路。你在實現(xiàn)類似系統(tǒng)時不管智能功能做多深都要先把 answer_record 這張事實表設計好讓它能完整表達每一次刷題行為。數(shù)據(jù)扎實了后面加規(guī)律提醒、加復習計劃、加任何智能玩法都只是在一個穩(wěn)定的地基上添磚加瓦。我做完這個項目后再刷題最大的變化是心里清楚自己到底該補哪一塊而不是憑感覺亂翻題庫。