開發(fā)實戰(zhàn):Vue+Spring Boot+Node.js)
學生證丟了在不少高校至今還是一件跑斷腿的事。我認識的一位輔導員朋友每周都要在紙質申請表堆里翻來找去哪個學生卡在哪個環(huán)節(jié)得打電話一個個問。學生那邊更窩火上午跑教務處交表下午被告知缺照片補好再交又趕上周末。后來我們幫某高校的理學院做了一套學生證丟失補辦管理系統(tǒng)前端用Vue開發(fā)、整個工程跑在Node.js環(huán)境里后端接口統(tǒng)一由Spring Boot提供。系統(tǒng)上線后補辦周期從平均一周壓到了兩天最讓我滿意的是整個流程這是終于能看得見、查得著了。這篇文章我會把從需求梳理到開發(fā)落地的完整過程寫出來包括技術選型的原因、數(shù)據(jù)庫怎么設計、狀態(tài)機怎么定義、以及我踩過的幾個典型坑。不管你是要給學生處做類似管理平臺還是想?yún)⒖记昂蠖朔蛛x的方案這些內容應該都能派上用場。1. 學生證補辦業(yè)務里的真實痛點與流程重構先別急著打開IDE寫代碼做這類管理系統(tǒng)最忌諱的就是一上來就建表。補辦學生證只是表面需求真正要解決的是流程混亂、信息不透明、責任不清晰這三件麻煩事。1.1 傳統(tǒng)手工補辦流程到底差在哪原來這套高校的流程是學生丟了證先到所在學院輔導員那里領一張紙質申請表手填個人信息再貼照片經常照片尺寸不對然后找輔導員簽字再送到教務處窗口審核后交工本費最后過一兩周去領證。這過程至少有四個坑申請表是紙質的容易丟、容易填錯而且一旦進入審批鏈誰也說不清它現(xiàn)在在哪個抽屜里。照片沒有統(tǒng)一規(guī)范有人交一寸藍底有人交兩寸紅底制證員拿到手里還得手動裁剪。繳費和制證是兩條線財務處記錄和學生證制作進度對不上月底對賬靠人工。沒有防重復機制個別學生一學期丟三次也能補三次學校完全掌握不了丟失頻率。這些坑不是靠Excel能解決的需要一個有狀態(tài)流轉、角色權限和留痕機制的系統(tǒng)來托底。1.2 重新定義角色與流程分支開發(fā)前我們和教務科的老師開了三次會把流程固化成了這樣學生在線發(fā)起補辦申請?zhí)顚懟拘畔⑸蟼髯C件照查看進度。輔導員收到申請后核實身份和材料是否齊全確認是否為本人、是否在在讀狀態(tài)。教務管理員復核繳費狀態(tài)和照片規(guī)格分配制證任務。制證人員按批次制作完成后點可領取。學生處主管查看全局數(shù)據(jù)導出統(tǒng)計報表。同時流程并不是一條直線有時需要駁回。所以狀態(tài)機必須是可回退的而不是只管往下推。我們最終設計了如下狀態(tài)草稿 - 待輔導員審核 - 待教務審核 - 待繳費 - 已完成繳費 - 制證中 - 可領取 - 已領取 \-- 審核駁回 - 已駁回可重新編輯提交駁回的設計很關鍵。很多初做管理系統(tǒng)的人只做了審批通過和拒絕但拒絕后學生怎么辦我們是讓學生能修改申請材料后重新提交而不是徹底鎖死否則學生只能線下再跑一趟系統(tǒng)反而成了負擔。1.3 權限與操作留痕權限模型我們用了基于角色的訪問控制后端用Spring Security實現(xiàn)前端用Vue Router的路由守衛(wèi)控制頁面跳轉。學生端只能看到我的申請輔導員端只能看到本班級的待辦列表教務處能看到所有流程數(shù)據(jù)。所有狀態(tài)變更都寫進一張審計日志表記錄操作人、操作時間、從哪個狀態(tài)變到哪個狀態(tài)。千萬別小看這個設計上線第三周就出現(xiàn)了輔導員誤點通過的情況就是因為有日志和狀態(tài)回退才在幾分鐘內恢復原樣。2. 技術選型為什么偏偏是Vue Node.js環(huán)境 Spring Boot標題寫的是nodejsvue基于springboot可能有人會覺得矛盾——Node.js和Spring Boot不是兩套后端嗎其實這里的意思很明確前端工程化基于Node.js用Vue框架開發(fā)而后端服務用Spring Boot構建。2.1 Node.js在整個系統(tǒng)中真正干的事Node.js在前后端分離架構里承擔的是前端基礎設施的角色。我們用Vite作為構建工具NPM管理依賴開發(fā)時啟動一個本地開發(fā)服務器請求通過代理轉發(fā)到后端。前端源碼是.vue單文件組件編譯產物是純靜態(tài)資源交給Nginx托管。有人會問那能不能不裝Node.js直接改代碼不行。Vue的源碼不能直接在瀏覽器里跑需要經過Babel轉譯、Webpack/Vite打包、CSS預編譯等步驟這些工具鏈全部是Node.js寫的。所以標題里的nodejsvue其實是在描述前端那一套標準工程實踐。我們可以用下面的表格簡單理清兩者分工技術棧負責范圍關鍵工具Node.js前端構建、依賴管理、開發(fā)代理NPM、Vite、BabelVue 3頁面交互、組件化、路由控制Vue Router、Pinia、Element PlusSpring Boot后端API、鑒權、業(yè)務邏輯、持久化Spring MVC、Spring Security、MyBatis PlusMySQL數(shù)據(jù)存儲InnoDB 引擎2.2 Spring Boot后端為什么合適Spring Boot最吸引我的地方不是它重而是它的生態(tài)整齊。做這個系統(tǒng)我們需要用戶認證、接口鑒權、參數(shù)校驗、事務管理、日志這些Spring Boot都有成熟的起步依賴寫上幾個注解就能用省去了造輪子的時間。例如文件上傳Spring Boot的MultipartFile接口直接支持請求頭解析和文件保存權限控制用PreAuthorize(hasRole(ADMIN))就可以在接口層做攔截數(shù)據(jù)庫操作我們用MyBatis Plus單表增刪改查幾乎不用寫SQL。對于一個以業(yè)務流轉為主的管理系統(tǒng)穩(wěn)定性和可維護性比花哨的框架更重要。Spring Boot的自動配置機制能減少大量XML配置新同學接手也容易上手。2.3 對比下來其他方案輸在哪里之前我們衡量過要不要用Node.js的Express或NestJS直接寫后端前后端都用JS。但后來否了原因有兩個學校信息中心現(xiàn)有服務器上大多是Java技術棧運維同學對JVM監(jiān)控、Tomcat容器更熟引入一套Node后端會增加部署復雜度。這個系統(tǒng)的核心是審批流和權限控制Java生態(tài)在這塊的方案更成熟比如Spring Security的過濾器鏈就比Node里手動寫JWT中間件要更體系化。當然如果你們的團隊是純前端出身可以完全用Node做后端。我們這里只是出于運維習慣選擇了Spring Boot并沒有好壞之分。3. 數(shù)據(jù)庫建模從學生檔案到補辦記錄一條鏈數(shù)據(jù)模型決定了業(yè)務能走多遠。這套系統(tǒng)的核心表其實不多但表之間關系要理清楚尤其是一人多證的情況要提前防住。3.1 核心數(shù)據(jù)表設計思路一共7張業(yè)務表各司其職表名用途關鍵字段sys_user用戶統(tǒng)一表學號/工號、密碼、角色student_info學生檔案姓名、學院、班級、證件照、在讀狀態(tài)lost_application補辦申請主表申請人、學號、申請原因、當前狀態(tài)review_record審核記錄表申請ID、審核人、審核動作、備注payment_record繳費記錄表申請ID、金額、支付單號、繳費時間card_production制證記錄表申請ID、制證人、完成時間audit_log操作日志表操作人、操作類型、詳情有一個細節(jié)需要注意student_info表里的證件照是個URL地址不存二進制。數(shù)據(jù)庫里保存路徑文件本身放到服務器磁盤通過靜態(tài)資源映射訪問。這樣數(shù)據(jù)庫體積小備份也快。3.2 防重復補辦的“學號身份證狀態(tài)”聯(lián)合校驗很多系統(tǒng)做出來才發(fā)現(xiàn)學生會重復提交。比如上午提交了一塊下午又提交一塊前端沒控制后端也不查結果生成兩條申請。我們在lost_application表里加了唯一索引ALTER TABLE lost_application ADD UNIQUE KEY uk_student_valid (student_id, status_valid_flag);但嚴格來說狀態(tài)字段會變化沒法直接做唯一索引。所以我在應用層用了加鎖的寫法在Spring Boot服務里通過數(shù)據(jù)庫的SELECT ... FOR UPDATE鎖住該學生的檔案記錄再判斷是否已有未完結的申請。Transactional public ApplicationVO createApplication(Long studentId) { StudentInfo student studentInfoMapper.selectByIdForUpdate(studentId); Long pendingCount lostApplicationMapper.countPending(studentId); if (pendingCount 0) { throw new BusinessException(你還有待處理的補辦申請不能重復提交); } // 新建申請... }這個selectByIdForUpdate在MySQL的InnoDB引擎下會鎖定該行直到事務結束避免兩個請求同時進入判斷邏輯。上線后我用壓測模擬并發(fā)提交兩百個請求同時進來最終只生成了兩條記錄符合預期。3.3 還應該加的字段申請原因和緊急程度如果這個系統(tǒng)做成通用產品我不會漏掉申請原因和緊急程度。學生證丟失的原因分很多種被盜、自然遺失、損壞、換防偽卡。不同原因需要不同的審核關注度。比如被盜的要上傳派出所報案證明損壞的要交回舊證。我們這次的系統(tǒng)只做了原因枚舉沒有強制上傳證明文件但下一次迭代應該把材料清單做成可配置的。這樣如果政策變化管理員可以在后臺編輯需要上傳的材料類型不用改代碼。4. 前端實現(xiàn)狀態(tài)機可視化與文件上傳前端主要面向三類使用者學生、審核人、管理員。每個角色的訴求不一樣。學生要的是我提交了現(xiàn)在到哪一步了審核人要的是積壓了多少待辦點開就能看信息管理員要的是全流程數(shù)據(jù)能統(tǒng)計、能導出。4.1 路由設計與權限控制前端我們用Vue 3 Vue Router Pinia。路由分四塊/login登錄頁/student學生端包括申請頁、申請列表、進度詳情/approval審核端包括輔導員待辦、教務待辦/admin管理端包括補辦統(tǒng)計、黑名單、參數(shù)配置登錄成功后后端返回JWT令牌和一個角色標識。前端將用戶信息存到Pinia并在路由守衛(wèi)里判斷router.beforeEach((to, from, next) { const user useUserStore(); if (to.meta.roles !to.meta.roles.includes(user.role)) { next(/403); return; } next(); });這里要注意前端路由守衛(wèi)只是提升體驗真正安全還是要依靠后端每個接口的權限校驗。否則有人手動改了前端緩存繞過路由直接調用接口也能訪問敏感數(shù)據(jù)。4.2 進度時間軸組件學生最關心的就是進展。我們沒有用復雜的WebSocket而是采用輪詢加緩存的方式。申請詳情頁每隔30秒調一次/application/progress/{id}拿到狀態(tài)后更新界面。前端狀態(tài)展示用了一個自定義時間軸組件根據(jù)狀態(tài)字段渲染不同顏色的節(jié)點template el-steps :activeactiveStep align-center el-step title提交申請 description填寫信息并上傳照片 / el-step title輔導員審核 description核實學籍與材料 / el-step title教務審核 description復核信息與照片 / el-step title繳費 description繳納工本費 / el-step title制證 description等待證件制作 / el-step title領取 description前往指定地點領取 / /el-steps /template輪詢實現(xiàn)簡單不會給后端造成壓力。每隔30秒全校補辦用戶同時在線也就幾百個請求完全扛得住。如果以后規(guī)模大了再換成WebSocket或者SSE不遲。4.3 證件照上傳前端壓縮后端校驗這個環(huán)節(jié)最坑。學生用手機拍的照片動不動3~5MB原始尺寸幾千萬像素如果不處理直接傳不僅慢還容易把服務器磁盤塞滿。我們的做法是前端用Canvas做圖片壓縮統(tǒng)一調整為300×400像素的JPG大小控制在100KB以內。async function compressImage(file) { const img await createImageBitmap(file); const canvas document.createElement(canvas); canvas.width 300; canvas.height 400; const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, 300, 400); const compressedBlob await new Promise(resolve { canvas.toBlob(resolve, image/jpeg, 0.85); }); return new File([compressedBlob], file.name, { type: image/jpeg }); }后端也不能完全信任前端必須在Controller層再次校驗文件大小和類型if (file.getSize() 200 * 1024) { throw new BusinessException(圖片大小不能超過200KB); } if (!image/jpeg.equals(file.getContentType())) { throw new BusinessException(僅支持JPG格式); }為什么堅持JPG因為校園卡和印刷端的兼容性最好PNG有時候色彩空間不對打印出來顏色發(fā)灰??刂普掌袷侥軜O大降低制證部的返工率。5. 踩坑實錄這幾個坑代碼評審都攔不住再周密的計劃落地時總會遇到意外。這一部分記錄的是我們上線前后遇到的最典型的四個問題每個都是真實調試過的。5.1 Node版本不一致導致本地能跑、服務器構建失敗某個團隊成員本機是Node 14另一個是Node 18。有一個esbuild相關的依賴在Node 14上能安裝但運行時會有告警在Node 18上直接編譯報錯。排查了半天后來統(tǒng)一在項目根目錄放了.nvmrc文件寫死v18.16.0并用package.json里的engines字段約束{ engines: { node: 18.0.0, npm: 9.0.0 } }這里我的建議是一個團隊務必統(tǒng)一Node主版本。不同大版本的二進制模塊差異很大node_modules里很多原生模塊都依賴Node ABI版本換版本后必須刪除node_modules重裝否則會出現(xiàn)各種莫名其妙的運行時錯誤。5.2 跨域導致登錄接口拿不到Cookie開發(fā)環(huán)境下前端跑在localhost:5173后端跑在localhost:8080跨域問題必然出現(xiàn)。我們用代理解決在Vite的vite.config.js里配置server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }但生產環(huán)境上線后學生通過https://tsgl.school.edu.cn訪問后端接口部署在https://api.school.edu.cn依然存在跨域。我們直接在Spring Boot的CORS配置里顯式允許了前端域名同時設置了allowCredentials(true)。注意如果要允許攜帶CookieallowedOrigins不能使用通配符*必須寫具體的域名比如https://tsgl.school.edu.cn。如果實在需要多個域名可以寫一個動態(tài)CORS配置從數(shù)據(jù)庫讀取白名單。這個坑讓我們的登錄態(tài)調試花了整整一下午。5.3 并發(fā)提交導致重復補辦記錄前面已經提到過selectByIdForUpdate的方案。這里補一個真實事故描述上線第一天有個班的學生集中補辦有兩個人幾乎同時點擊提交按鈕。如果沒有數(shù)據(jù)庫鎖兩個請求都會查到無待處理申請從而各創(chuàng)建一條記錄。幸運的是我們提前做了鎖擋住了。但后來發(fā)現(xiàn)還有一個漏洞學生可以繞過前端按鈕連點通過Postman直接調用接口重復注冊不同的學號。最終我們除了鎖還在申請接口上加了防重令牌機制。這個防重令牌就是我們自己做的一個簡單TokenBucket前端在申請時先向后端要一個one-time-token后端緩存到Redis提交時校驗并刪除。如果重復提交同一個令牌則直接拒絕。5.4 照片無法訪問靜態(tài)資源路徑配置問題照片上傳后保存在服務器/data/student-card/images/目錄我們想在瀏覽器里通過https://api.school.edu.cn/files/images/xxx.jpg直接訪問。但默認Spring Boot的static目錄是classpath:/static/我們放在了外部磁盤目錄需要通過WebMvcConfigurer去映射Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceLocations(file:/data/student-card/); }這里還有一個安全細節(jié)上傳的文件名不能使用原始文件名否則可能包含../等路徑穿越字符。我們用的是UUID.randomUUID().toString()生成新文件名擴展名強行改為.jpg既避免路徑穿越也統(tǒng)一格式。千萬別用Math.random()拼文件名一定要用UUID或時間戳加隨機數(shù)。6. 從能用到好用我們踩過之后總結出的優(yōu)化方向系統(tǒng)上線兩個多月基本信息可以說非常平穩(wěn)。但說實話如果現(xiàn)在讓我重新做一遍我會在開始前就規(guī)劃好以下三點。6.1 消息通知不能靠學生主動查現(xiàn)在的進度查詢是學生自己登錄系統(tǒng)看。很多學生忙起來根本不會天天登系統(tǒng)結果審核通過了也不知道一直卡著不去繳費。下一次迭代一定要加上微信或郵件通知。最經濟的方式是接入郵件服務在審核狀態(tài)變化時通過Spring Boot的事件機制發(fā)送郵件給申請人。具體做法是在業(yè)務完成后發(fā)布一個事件applicationEventPublisher.publishEvent(new ReviewPassedEvent(applicationId));然后寫一個監(jiān)聽器去負責推送。不要把發(fā)郵件的代碼寫在業(yè)務方法里否則郵件服務故障會直接拖垮主流程。異步事件解耦是這里最值得學習的設計思路。6.2 統(tǒng)計報表讓管理者看到卡點在哪管理端的統(tǒng)計功能我是后來才補的。最初只做了列表但教務處長過來問這個月丟了證最多的是哪個學院平均補辦時長多少天我一下子答不上來。后來我加了幾張視圖按學院維度統(tǒng)計申請次數(shù)按狀態(tài)維度統(tǒng)計滯留時間。真正有價值的指標是當前平均每個狀態(tài)停留多少小時。這能直接暴露流程痛點。比如數(shù)據(jù)告訴我們輔導員審核平均要1.5天而制證環(huán)節(jié)只要4小時那我們就知道優(yōu)化方向在審批端而不是制作端。用SQL直接查詢SELECT status, AVG(TIMESTAMPDIFF(HOUR, created_at, updated_at)) AS avg_hours FROM lost_application WHERE created_at DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY status;6.3 環(huán)境配置與數(shù)據(jù)庫腳本的統(tǒng)一管理最開始每個人的數(shù)據(jù)庫建表語句都在本地執(zhí)行結果測試環(huán)境少了一個索引生產環(huán)境字段名不一致團隊聯(lián)調時浪費了大量時間。后來引入了Flyway做數(shù)據(jù)庫版本管理所有建表語句作為遷移腳本放到項目倉庫啟動時自動按順序執(zhí)行。Flyway的好處是讓環(huán)境遷移變成代碼評審的一部分。我還建議把不同環(huán)境的配置寫在不同application-{profile}.yml里比如application-dev.yml、application-prod.yml避免改一處影響全局。團隊項目里這幾乎是必須的要求可以少吵很多架。最后的實操筆記如果你正在接手類似的補辦系統(tǒng)我最后想分享兩點心得體會第一一定要把駁回后可修改再提交這個交互想清楚很多審批系統(tǒng)做成了不可逆的直線結果學生在系統(tǒng)里申請失敗后還是得跑線下這就違背了做線上化的初衷。第二上線前務必要做一次并發(fā)模擬測試哪怕只是簡單的幾十個線程同時提交也能暴露掉很多事務和鎖的問題。就這個學生證補辦系統(tǒng)本身而言我們目前還在優(yōu)化電子學生證的對接接口。后續(xù)如果能把電子二維碼和紙質學生證關聯(lián)起來補辦頻率應該還會進一步下降。感謝在開發(fā)過程中一起加班改流程的某學院的林老師和負責制證的王師傅他們給出的業(yè)務反饋遠比任何技術文檔都珍貴。