從數(shù)據(jù)庫設(shè)計(jì)到自動(dòng)判分的完整實(shí)踐)
在線考試平臺(tái)這個(gè)題在Spring Boot相關(guān)的課程設(shè)計(jì)和畢業(yè)設(shè)計(jì)里出現(xiàn)頻率真的非常高。它表面上是一個(gè)“系統(tǒng)開發(fā)”題目實(shí)際做起來卻要覆蓋多角色權(quán)限、題庫組織、組卷策略、考試狀態(tài)流轉(zhuǎn)、自動(dòng)判分、成績(jī)統(tǒng)計(jì)這一整條業(yè)務(wù)鏈路。相比單純的增刪改查這類項(xiàng)目更考驗(yàn)?zāi)銓?duì)業(yè)務(wù)流程的理解以及碰到邊界情況時(shí)能不能把邏輯做嚴(yán)謹(jǐn)。我當(dāng)時(shí)做這個(gè)項(xiàng)目時(shí)花了大量時(shí)間在數(shù)據(jù)庫設(shè)計(jì)和判分一致性上后期反而很輕松。今天就把完整的設(shè)計(jì)思路、核心表結(jié)構(gòu)、關(guān)鍵代碼邏輯以及那些在調(diào)試中踩過的坑一次說清楚。無論你是準(zhǔn)備做課程設(shè)計(jì)還是想拿一個(gè)完整的Spring Boot練手項(xiàng)目這篇文章都能直接參考。1. 項(xiàng)目整體設(shè)計(jì)與技術(shù)選型1.1 為什么在線考試平臺(tái)用Spring Boot最合適先說實(shí)話課設(shè)和畢設(shè)階段完全不需要上微服務(wù)那一套。在線考試平臺(tái)的用戶量和使用場(chǎng)景很集中業(yè)務(wù)邊界也很清楚本質(zhì)上就是一個(gè)標(biāo)準(zhǔn)的單體Web系統(tǒng)。Spring Boot在這種場(chǎng)景下簡(jiǎn)直是量身定做的自帶內(nèi)嵌Tomcat打包成jar文件就能跑把“本地起服務(wù)”的成本降到極低。同學(xué)拿到源碼后裝好JDK、MySQL、Maven改一下數(shù)據(jù)庫連接就能把項(xiàng)目跑起來這在后期答辯演示時(shí)優(yōu)勢(shì)很大。Spring Boot還有一個(gè)實(shí)際好處生態(tài)對(duì)這類系統(tǒng)覆蓋得很好。登錄鑒權(quán)有Spring Security或JWT持久層用MyBatis-PlusExcel導(dǎo)入用POI或EasyExcel統(tǒng)計(jì)圖表直接前端引入ECharts每個(gè)環(huán)節(jié)都有大量可查的資料。相比SSM要手動(dòng)配置一大堆XMLSpring Boot用起步依賴把常見的配置都收攏了新手只需要關(guān)注自己代碼的部分即可。我試過很多次對(duì)時(shí)間緊的同學(xué)來說Spring Boot確實(shí)是性價(jià)比最高的選擇。從答辯角度講Spring Boot還能引出“自動(dòng)配置”“內(nèi)嵌容器”“起步依賴”“約定優(yōu)于配置”這些高頻面試點(diǎn)。你只要在項(xiàng)目里把JWT鑒權(quán)、攔截器、全局異常處理、事務(wù)控制都用上面試官問起來就有具體案例可以講而不是停留在“我調(diào)用過某個(gè)接口”這種層面。1.2 角色與權(quán)限設(shè)計(jì)三類角色是考試平臺(tái)的標(biāo)配學(xué)生、教師、管理員。我建議把它們放在同一張用戶表里用role字段區(qū)分不要拆成三張表。原因是登錄入口只有一個(gè)所有角色的校驗(yàn)邏輯可以統(tǒng)一走同一套JWT鑒權(quán)后續(xù)擴(kuò)展角色也很方便。學(xué)生查看考試列表、開始考試、交卷后查看分?jǐn)?shù)和答卷解析、查看個(gè)人成績(jī)曲線。教師維護(hù)題庫、創(chuàng)建試卷、手動(dòng)選題或隨機(jī)組卷、在線閱卷、查看所授課程的成績(jī)統(tǒng)計(jì)。管理員管理系統(tǒng)基礎(chǔ)數(shù)據(jù)比如課程、班級(jí)、用戶賬號(hào)重置密碼查看全站統(tǒng)計(jì)。權(quán)限控制在課設(shè)階段最實(shí)用的方案是攔截器加注解不需要把Spring Security整個(gè)搬進(jìn)來。實(shí)現(xiàn)方式是這樣的定義一個(gè)RequireRole注解標(biāo)注在Controller方法上攔截器里解析當(dāng)前登錄用戶的角色角色不匹配就返回統(tǒng)一格式的錯(cuò)誤信息。這種方式代碼量不大而且很好講清楚權(quán)限設(shè)計(jì)的思路。1.3 核心模塊劃分整個(gè)系統(tǒng)按功能拆成六大模塊系統(tǒng)管理、用戶認(rèn)證、題庫管理、試卷管理、在線考試、成績(jī)統(tǒng)計(jì)。其中組卷和在線考試是這個(gè)項(xiàng)目區(qū)別于普通管理系統(tǒng)的核心也是設(shè)計(jì)文檔里最值得展開寫的內(nèi)容。動(dòng)手寫代碼之前建議先把業(yè)務(wù)狀態(tài)線畫出來教師創(chuàng)建試卷并發(fā)布考試 - 學(xué)生進(jìn)入考試生成試卷快照 - 學(xué)生答題并提交 - 客觀題自動(dòng)判分 - 主觀題進(jìn)入人工閱卷 - 成績(jī)發(fā)布 - 學(xué)生查看成績(jī)和解析。這個(gè)流程里每個(gè)節(jié)點(diǎn)都有自己獨(dú)立的表結(jié)構(gòu)和狀態(tài)字段理清了后面寫代碼會(huì)順很多。2. 數(shù)據(jù)庫設(shè)計(jì)考試系統(tǒng)的地基考試平臺(tái)的大部分邏輯問題根源在表結(jié)構(gòu)上。表關(guān)系設(shè)計(jì)好了業(yè)務(wù)代碼只是蓋樓表關(guān)系設(shè)計(jì)亂了后面所有模塊都會(huì)跟著打補(bǔ)丁。下面按模塊拆開講。2.1 用戶與課程模塊用戶表是最基礎(chǔ)的表我一般在user表之外還會(huì)設(shè)計(jì)課程表和學(xué)生選課表這樣學(xué)生和教師的關(guān)系不會(huì)綁死在一個(gè)字段里。用戶表字段大概是這樣id、username、password、real_name、role、student_no、class_name、status、create_time。注意密碼不能存明文建議用BCrypt加密。很多同學(xué)喜歡用MD5但實(shí)際項(xiàng)目里BCrypt在每次加密時(shí)生成不同的鹽相同密碼加密后的結(jié)果都不一樣安全性明顯更好。課程表字段比較簡(jiǎn)單id、course_name、teacher_id、class_name、create_time。選課表把學(xué)生和課程關(guān)聯(lián)起來id、student_id、course_id。為什么要單獨(dú)設(shè)計(jì)選課表因?yàn)橐粋€(gè)學(xué)生可以選多門課一門課也有多個(gè)學(xué)生典型的多對(duì)多關(guān)系必須用中間表承接。2.2 題庫與試卷模塊題庫表是整個(gè)系統(tǒng)的核心資產(chǎn)。我常用的字段是id、course_id、type、title、options、answer、analysis、score、difficulty、create_time。type字段區(qū)分單選、多選、判斷、簡(jiǎn)答四種題型。options字段的存儲(chǔ)方式我用的是“A.xxx|B.xxx|C.xxx|D.xxx”這種字符串格式簡(jiǎn)單直觀。你當(dāng)然也可以用JSON格式更規(guī)范。但對(duì)課設(shè)而言字符串足夠只要寫入時(shí)做好格式校驗(yàn)后面判分就不會(huì)出問題。判斷題答案我喜歡存“T”和“F”不存漢字的“正確/錯(cuò)誤”。這樣判分就是簡(jiǎn)單的字符串比較省去各種編碼和空格問題。試卷表字段可以設(shè)計(jì)成id、paper_name、course_id、total_score、duration、start_time、end_time、status、create_by。考試時(shí)間范圍、考試時(shí)長(zhǎng)、總分這些關(guān)鍵業(yè)務(wù)屬性都放在這里。另外需要單獨(dú)建一張?jiān)嚲眍}目關(guān)聯(lián)表paper_question字段包括id、paper_id、question_id、sort。這里有個(gè)細(xì)節(jié)組卷時(shí)生成的題目順序要固化成sort字段不要讓學(xué)生每次刷新看到的題目順序都不一樣否則考試體驗(yàn)很差。下面是一張核心建表SQL示例CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT 所屬課程, type TINYINT NOT NULL COMMENT 1單選 2多選 3判斷 4簡(jiǎn)答, title VARCHAR(500) NOT NULL COMMENT 題干, options VARCHAR(1000) DEFAULT NULL COMMENT 選項(xiàng)用|分隔, answer VARCHAR(255) NOT NULL COMMENT 標(biāo)準(zhǔn)答案, analysis VARCHAR(1000) DEFAULT NULL COMMENT 解析, score INT NOT NULL DEFAULT 5 COMMENT 分值, difficulty TINYINT DEFAULT 1 COMMENT 1簡(jiǎn)單 2中等 3困難, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.3 考試記錄與快照設(shè)計(jì)考試記錄表exam_record跟蹤每一次考試實(shí)例id、paper_id、student_id、start_time、submit_time、status、objective_score、subjective_score、total_score。status字段標(biāo)記考試狀態(tài)比如ongoing、submitted、graded這是整個(gè)考試流程的狀態(tài)機(jī)核心。學(xué)生每次作答的明細(xì)放在answer_record表id、exam_record_id、question_id、student_answer、is_correct、score??忌峤灰坏李}就保存一道題交卷時(shí)再統(tǒng)一判分。這里特別想強(qiáng)調(diào)“快照”這個(gè)設(shè)計(jì)。學(xué)生在進(jìn)入考試那一刻系統(tǒng)要把試卷題目、題目分?jǐn)?shù)、標(biāo)準(zhǔn)答案一起復(fù)制到考試記錄里而不是交卷時(shí)再去題庫實(shí)時(shí)讀數(shù)據(jù)。我第一次做的時(shí)候沒有快照結(jié)果老師考試中途改了一道題的答案之前已經(jīng)交卷學(xué)生的成績(jī)?nèi)兞?。這個(gè)教訓(xùn)讓我意識(shí)到考試系統(tǒng)的一致性不能依賴“隨時(shí)可變的業(yè)務(wù)數(shù)據(jù)”??煺兆侄螘?huì)多占一點(diǎn)存儲(chǔ)空間但換來的是極高的數(shù)據(jù)穩(wěn)定性這個(gè)交易完全值得。3. 核心功能模塊實(shí)現(xiàn)與實(shí)操拆解3.1 登錄鑒權(quán)與全局用戶態(tài)登錄模塊我推薦用JWT方案。用戶登錄成功后后端把用戶id、角色、用戶名放進(jìn)token的claims里前端后續(xù)請(qǐng)求在Authorization頭帶上token后端配置攔截器統(tǒng)一解析。為什么不用傳統(tǒng)的Session課設(shè)階段用Session其實(shí)也沒問題但JWT更接近現(xiàn)在主流的接口風(fēng)格代碼寫出來也更好講。用jjwt庫代碼量不大String token Jwts.builder() .setSubject(userId.toString()) .claim(role, user.getRole()) .claim(username, user.getUsername()) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 60 * 12)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();密鑰放在application.yml里不要硬編碼。攔截器里解析token失敗就返回401成功就把用戶信息放進(jìn)ThreadLocalService層需要當(dāng)前用戶時(shí)直接取。這個(gè)方案對(duì)前端也很友好后面如果想把頁面升級(jí)成Vue分離版接口可以原封不動(dòng)地復(fù)用。3.2 題庫管理與Excel批量導(dǎo)入錄入題目是最耗時(shí)的工作所以題庫模塊一定要支持Excel批量導(dǎo)入。Apache POI是常用方案讀取xlsx文件后遍歷每一行解析出題型、題干、選項(xiàng)、答案、解析、分值然后批量寫入數(shù)據(jù)庫。批量導(dǎo)入最常見的坑有幾個(gè)xls和xlsx格式兼容問題建議統(tǒng)一使用xlsx讀取方式不一樣??招泻涂諉卧駮?huì)解析出null需要判空處理否則入庫時(shí)報(bào)錯(cuò)。答案格式不規(guī)范比如多選題寫成“A,B,C ”帶空格或“ABC”不帶逗號(hào)。導(dǎo)入時(shí)最好統(tǒng)一清洗成規(guī)范格式。導(dǎo)入完成要給教師明確的反饋成功多少條、失敗多少條、哪一行哪一列格式有問題。不要悶頭導(dǎo)入完就什么都不提示。這里可以寫一個(gè)導(dǎo)入結(jié)果對(duì)象包含成功數(shù)量和失敗原因列表。3.3 手動(dòng)/自動(dòng)組卷邏輯組卷是考試平臺(tái)最有“業(yè)務(wù)味”的模塊。手動(dòng)組卷比較好理解教師從題目列表里勾選題目加入試卷設(shè)置分值保存時(shí)校驗(yàn)總分。自動(dòng)組卷我建議做成“按題型設(shè)置數(shù)量按難度分布抽題”。核心是隨機(jī)抽題SQLListLong questionIds questionMapper.selectList( new LambdaQueryWrapperQuestion() .eq(Question::getCourseId, courseId) .eq(Question::getType, type) .eq(Question::getDifficulty, difficulty) .last(ORDER BY RAND() LIMIT count) ).stream().map(Question::getId).collect(Collectors.toList());MySQL的ORDER BY RAND()在幾十萬級(jí)數(shù)據(jù)量的場(chǎng)景下會(huì)有性能問題但課設(shè)階段的題庫規(guī)模小直接使用完全沒問題。如果你想把項(xiàng)目做得更亮眼可以在設(shè)計(jì)文檔里專門提一句這個(gè)方法的適用邊界再引出其他更優(yōu)的隨機(jī)方案面試時(shí)這是加分點(diǎn)。組卷保存前一定要校驗(yàn)總分。比如試卷設(shè)置了100分但勾選的題目加起來只有90分必須給出提示。我見過不少項(xiàng)目這里沒有校驗(yàn)學(xué)生考完成績(jī)統(tǒng)計(jì)總是對(duì)不上后來查原因就是總分虛標(biāo)。3.4 在線考試、自動(dòng)判分與成績(jī)統(tǒng)計(jì)在線考試模塊是整個(gè)系統(tǒng)最需要謹(jǐn)慎的地方核心在狀態(tài)管理。學(xué)生點(diǎn)擊“開始考試”后后端先創(chuàng)建一條exam_record狀態(tài)設(shè)為ongoing記錄start_time。答題時(shí)前端每保存一題就調(diào)用一次接口后端把答案寫入answer_record。交卷有兩種入口學(xué)生主動(dòng)點(diǎn)擊交卷或考試時(shí)間到觸發(fā)自動(dòng)交卷。前端倒計(jì)時(shí)只是體驗(yàn)優(yōu)化負(fù)責(zé)在最后一秒提醒用戶真正的超時(shí)兜底邏輯必須在后端。后端交卷方法要做的事檢查exam_record當(dāng)前狀態(tài)如果已提交直接拒絕防止重復(fù)提交。遍歷所有answer_record對(duì)客觀題自動(dòng)判分。累加客觀題得分主觀題標(biāo)記為待閱卷。更新exam_record狀態(tài)和成績(jī)字段??陀^題判分邏輯很直接String correct question.getAnswer(); String studentAnswer answerRecord.getStudentAnswer(); if (single.equals(question.getType()) || judge.equals(question.getType())) { boolean isCorrect correct.equalsIgnoreCase(studentAnswer.trim()); answerRecord.setCorrect(isCorrect); if (isCorrect) { answerRecord.setScore(question.getScore()); } }多選題的判分規(guī)則可以根據(jù)需要定義。常用的有“全對(duì)才得分”和“多選/漏選得一半分”兩種。實(shí)際項(xiàng)目里規(guī)則的定義應(yīng)放在單獨(dú)的類里通過一個(gè)接口類定義判分策略不同的題型用不同的實(shí)現(xiàn)。這樣做的好處是后續(xù)如果要加不定項(xiàng)選擇這類新題型直接擴(kuò)展實(shí)現(xiàn)類就行不需要改動(dòng)判分主流程。成績(jī)統(tǒng)計(jì)建議做三個(gè)維度班級(jí)平均分、分?jǐn)?shù)段分布、每題正確率。前端用ECharts展示柱狀圖和折線圖底部數(shù)據(jù)從后端統(tǒng)計(jì)接口讀。統(tǒng)計(jì)SQL會(huì)有一些連表查詢建議用注解SQL或XML寫清楚別在Java代碼里拼字符串SQL。4. 開發(fā)過程中的那些坑與解決方法4.1 時(shí)間與時(shí)區(qū)時(shí)區(qū)問題幾乎每個(gè)項(xiàng)目都會(huì)遇到。癥狀很典型數(shù)據(jù)庫存的時(shí)間比實(shí)際時(shí)間少8小時(shí)或者前端拿到的時(shí)間是一個(gè)數(shù)組而不是字符串。解決辦法分三部分MySQL連接串加上serverTimezoneAsia/Shanghai。實(shí)體類時(shí)間字段統(tǒng)一用LocalDateTime不要Date和LocalDateTime混用。在application.yml里配置Jackson格式化讓LocalDateTime輸出成字符串。配置Jackson的寫法spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8這三個(gè)配置都設(shè)置好后時(shí)間問題基本能一次性解決。4.2 試卷快照與答案一致性前面說快照設(shè)計(jì)專門打了一個(gè)伏筆這里再展開??荚囅到y(tǒng)不能因?yàn)楹笈_(tái)改題影響已經(jīng)產(chǎn)生的成績(jī)。我最初做的時(shí)候answer_record里只存了question_id交卷后從題庫實(shí)時(shí)讀題目解析和答案。后來老師考完修改了一道題的答案“歷史考試”的解析頁面也跟著變了數(shù)據(jù)完全對(duì)不上。正確做法是在answer_record表里冗余存儲(chǔ)題目?jī)?nèi)容、標(biāo)準(zhǔn)答案、題目分?jǐn)?shù)。也就是快照。作答明細(xì)里看到的就是考試那一刻的試卷跟題庫里后來怎么改都無關(guān)。這是考試系統(tǒng)最核心的數(shù)據(jù)一致性設(shè)計(jì)寫文檔時(shí)要著重講。4.3 POI的版本沖突POI的版本沖突是導(dǎo)入功能最常見的報(bào)錯(cuò)源頭。表現(xiàn)是運(yùn)行時(shí)拋出NoSuchMethodError排查起來比較費(fèi)勁。原因通常是項(xiàng)目里同時(shí)存在多個(gè)org.apache.poi的間接依賴版本不一致。我的建議是能用EasyExcel就盡量用EasyExcel它內(nèi)部把poi版本統(tǒng)一管理了少操很多心。如果必須直接用POI那就檢查所有pom依賴?yán)镉袥]有引入多個(gè)不同版本的poi和poi-ooxml只保留一個(gè)統(tǒng)一的版本。4.4 分頁插件與事務(wù)控制MyBatis-Plus分頁必須配置PaginationInnerInterceptor否則page方法返回的數(shù)據(jù)不對(duì)這是最常見的問題之一。很多同學(xué)寫了分頁代碼但忘了注冊(cè)插件查出來的total永遠(yuǎn)是0還以為是SQL寫錯(cuò)了。配置方式很簡(jiǎn)單在MyBatis配置類里添加一個(gè)MybatisPlusInterceptor Bean即可。事務(wù)控制要重點(diǎn)放在交卷接口上。交卷和判分是一個(gè)整體操作任何一步報(bào)錯(cuò)都必須回滾不能讓學(xué)生交了卷但成績(jī)沒有生成。方法上加上Transactional同時(shí)注意不要把耗時(shí)操作放在事務(wù)里。比如自動(dòng)判分時(shí)循環(huán)里一條條更新數(shù)據(jù)庫整個(gè)事務(wù)時(shí)間會(huì)拉長(zhǎng)。更優(yōu)的做法是先循環(huán)判斷結(jié)果收集要更新的對(duì)象最后統(tǒng)一調(diào)用updateBatchById批量更新能明顯縮短事務(wù)耗時(shí)。5. 常見問題排查速查與項(xiàng)目擴(kuò)展方向5.1 常見問題速查表現(xiàn)象可能原因解決方案啟動(dòng)報(bào)數(shù)據(jù)庫連接失敗連接URL、用戶名、密碼配置錯(cuò)誤檢查jdbc地址確認(rèn)MySQL服務(wù)已啟動(dòng)核對(duì)用戶權(quán)限時(shí)間輸出為數(shù)組格式Jackson不支持LocalDateTime默認(rèn)序列化配置JavaTimeModule或統(tǒng)一時(shí)間戳格式列表接口返回總數(shù)為0MyBatis-Plus分頁插件未注冊(cè)添加PaginationInnerInterceptor上傳Excel后中文亂碼文件流解析未指定字符集正確設(shè)置讀取編碼處理后統(tǒng)一UTF-8入庫交卷后成績(jī)沒生成判分邏輯異?;蚴聞?wù)未提交查看日志檢查是否有未處理的簡(jiǎn)答題導(dǎo)致流程中斷前端跨域報(bào)錯(cuò)后端未配置CORS配置WebMvcConfigurer添加跨域規(guī)則頁面刷新后題目順序變化隨機(jī)抽題結(jié)果未保存sort字段組卷時(shí)固化paper_question中的順序5.2 生產(chǎn)級(jí)擴(kuò)展方向如果時(shí)間允許這幾個(gè)方向特別適合作為答辯和面試亮點(diǎn)防切屏功能監(jiān)聽瀏覽器頁面可見性變化切屏超過設(shè)定次數(shù)后自動(dòng)交卷或在后臺(tái)標(biāo)記異常記錄。題目順序和選項(xiàng)隨機(jī)學(xué)生進(jìn)入考試時(shí)把試卷快照里的題目順序、選項(xiàng)順序打亂。這個(gè)功能做起來不復(fù)雜但能從“防作弊”角度展示思考深度。Redis緩存熱點(diǎn)試卷把經(jīng)常被學(xué)生同時(shí)訪問的試卷緩存起來減少數(shù)據(jù)庫壓力。在線閱卷頁面為簡(jiǎn)答題提供評(píng)分界面支持按得分點(diǎn)關(guān)鍵詞快速給分。WebSocket強(qiáng)制收卷教師可以實(shí)時(shí)推送收卷指令到在線考試的學(xué)生端。這些擴(kuò)展每一個(gè)都足夠在答辯證里單獨(dú)講兩分鐘而且實(shí)現(xiàn)難度可控。不要一上來就全部鋪開選一個(gè)做透就行。我個(gè)人在實(shí)際操作中的體會(huì)是做這類項(xiàng)目最重要的一步是把數(shù)據(jù)庫設(shè)計(jì)想清楚特別是快照和狀態(tài)字段。代碼本身大多是對(duì)表數(shù)據(jù)的流轉(zhuǎn)和加工但如果你在設(shè)計(jì)階段就預(yù)留好冗余字段、狀態(tài)字段、時(shí)間字段后面會(huì)遇到的問題至少減少一半。還有一點(diǎn)最終交付時(shí)源碼、數(shù)據(jù)庫腳本和一份能按步驟跑起來的說明文檔都要齊全。課程設(shè)計(jì)和畢業(yè)設(shè)計(jì)考察的恰恰是你把完整流程想清楚并交付清楚的能力。如果你正卡在某個(gè)模塊上優(yōu)先看數(shù)據(jù)庫設(shè)計(jì)和判分邏輯這兩塊它們是最容易出現(xiàn)隱性bug、也最值得花時(shí)間打磨的地方。把這條主線跑通之后這個(gè)項(xiàng)目能做到的深度遠(yuǎn)不止一個(gè)普通的“系統(tǒng)開發(fā)”課設(shè)那么簡(jiǎn)單。