實戰(zhàn):從數(shù)據(jù)庫設計到部署上線)
開門見山說個事前段時間幫朋友健身房做的那套管理系統(tǒng)前后大概折騰了一個多月從數(shù)據(jù)庫設計到前后端聯(lián)調(diào)再到部署上線踩的坑比想象中多。這套系統(tǒng)的技術棧就是標題里寫的Spring Boot Vue功能上覆蓋了會員管理、課程預約、教練排班、簽到統(tǒng)計這些健身房日常運營的核心場景。寫這篇東西不是為了秀技術就是想把這個項目從設計到落地的完整思路和實操過程記錄下來給準備做類似管理系統(tǒng)的朋友一個可以抄作業(yè)的參考。這篇文章適合正在做畢業(yè)設計、課程設計或者想自己搞一套前后端分離項目練手的人讀不管你是后端為主還是前端為主都能找到自己能復現(xiàn)的部分。1. 項目概述與需求拆解1.1 系統(tǒng)定位與核心功能健身管理系統(tǒng)這名字聽著大但落到實際業(yè)務上核心就是解決健身房日常運營里這些事誰來上課、誰買了卡、卡什么時候到期、教練的時間怎么排、會員約了課沒來怎么處理。說白了這套系統(tǒng)的本質是業(yè)務管理系統(tǒng)不是電商系統(tǒng)也不是社交平臺所有功能都圍繞人、卡、課、預約四個核心對象展開。我做的這套系統(tǒng)最終定了四個角色管理員、前臺、教練、會員。管理員負責全局配置和統(tǒng)計報表前臺負責會員辦卡和簽到操作教練管理自己的課程和學員會員則是通過前端頁面完成注冊、選課、預約、查看記錄這些自助操作。功能模塊上我把系統(tǒng)拆成了這樣幾塊會員管理會員信息的增刪改查、會員卡類型月卡、季卡、年卡管理、卡到期提醒。課程管理團操課程的發(fā)布、課程時間表、每節(jié)課的可預約人數(shù)、課程狀態(tài)可預約/已滿/已結束。教練管理教練基本信息、教練負責的課程列表、教練排班。預約管理會員預約課程、取消預約、簽到記錄、爽約標記。統(tǒng)計報表每日/每周的預約量、簽到率、熱門課程排行。公告通知運營公告的發(fā)布與展示。這套系統(tǒng)做下來最核心的業(yè)務閉環(huán)是會員注冊 → 查看課程表 → 預約課程 → 到店簽到 → 教練核銷。把這條鏈路跑通系統(tǒng)的主干就算立住了。1.2 為什么選Spring Boot Vue這套組合技術選型這個話題每次都能吵半天。我直接說我的結論Spring Boot Vue是目前做前后端分離項目最穩(wěn)的組合沒有之一。這不是因為它最先進而是因為它最適合這類中小型業(yè)務系統(tǒng)的開發(fā)節(jié)奏。后端用Spring Boot理由很實在。第一生態(tài)成熟MyBatis Plus、Spring Security、Redis這些組件都有非常完善的中文文檔和社區(qū)解決方案踩了坑基本都能搜到答案。第二開發(fā)效率高Spring Boot的自動配置和starter機制把大量繁瑣的配置工作省掉了一個main方法就能把服務跑起來對中小項目和單人開發(fā)尤其友好。第三Java本身的類型系統(tǒng)和Spring的依賴注入讓后期維護相對容易一個項目寫完之后半年再回來看代碼還是能讀懂的。前端用Vue理由同樣直接。Vue的學習曲線比React平緩模板語法直觀組件化開發(fā)模式適合這種以表單和表格為主的管理系統(tǒng)頁面。配合Element Plus組件庫后臺管理類頁面的開發(fā)速度非???。Vue Router負責路由跳轉Pinia管狀態(tài)Axios發(fā)請求這幾件套組合起來前端開發(fā)的工作量控制得很低。不過這里要提醒一點選Vue3還是Vue2這得想清楚。如果是從零開始的新項目直接上Vue3 Vite Element Plus。Vue2已經(jīng)停止維護了新項目沒必要再用組合式API都別扭的老版本。我這套系統(tǒng)就是用Vue3寫的后面所有代碼示例都是Vue3的寫法。2. 數(shù)據(jù)庫設計與后端核心實現(xiàn)2.1 數(shù)據(jù)庫表結構設計思路數(shù)據(jù)庫是這個系統(tǒng)的地基表設計的好壞直接決定后面寫業(yè)務代碼的體驗。我設計表的時候遵循了一個原則按業(yè)務對象拆表用業(yè)務ID關聯(lián)不做冗余字段。核心表一共有六張。會員表member的字段包括id主鍵、username、passwordBCrypt加密后存儲、real_name、phone、gender、card_type月卡/季卡/年卡、card_start_date、card_end_date、status正常/凍結、create_time。這里有個很重要的設計細節(jié)會員狀態(tài)和卡到期時間必須獨立存字段不要通過計算去判斷因為定時任務和查詢條件都要用到這兩個字段冗余一點換來查詢方便是值得的。課程表course的字段包括id、course_name、coach_id關聯(lián)教練表、start_time、end_time、max_people最大預約人數(shù)、booked_count已預約人數(shù)、status可預約/已滿/已結束、intro課程簡介。預約人數(shù)的控制是這個表的關鍵邏輯我后面在預約模塊會詳細講到并發(fā)控制的問題。預約表booking的字段包括id、member_id、course_id、booking_time、status已預約/已簽到/已取消/爽約、check_in_time。這張表是業(yè)務發(fā)生最頻繁的表所以索引要建好member_id和course_id都要加索引。教練表coach、公告表announcement、簽到表check_in相對簡單不展開寫了。另外提醒一句如果你用的是MySQL所有表的引擎都用InnoDB字符集用utf8mb4這樣能避免很多中文亂碼和事務支持的坑。2.2 Spring Boot項目分層結構項目結構上我采用了最標準的四層架構controller接口層→ service業(yè)務層→ mapper數(shù)據(jù)訪問層→ entity實體類。這是Spring Boot項目最經(jīng)典的分層方式每一層只做自己該做的事職責邊界清晰后面維護的時候找代碼非???。一個值得參考的包結構是這樣的com.example.fitness ├── controller/ # 接口層 ├── service/ # 業(yè)務層 │ ├── impl/ ├── mapper/ # MyBatis數(shù)據(jù)訪問接口 ├── entity/ # 數(shù)據(jù)庫實體 ├── dto/ # 請求/響應對象 ├── config/ # 配置類跨域、攔截器、WebMvc等 ├── common/ # 統(tǒng)一返回結果、異常處理、工具類 ├── security/ # JWT鑒權相關 └── FitnessApplication.java分層設計有個很容易被忽略的坑很多人在service層直接返回entity實體這是不對的。實體類是數(shù)據(jù)庫的映射不該直接把數(shù)據(jù)庫字段暴露給前端。我習慣單獨建一個dto包接口的入?yún)⒑统鰠⒍加胐to對象比如返回會員列表的時候用MemberDTO把必要的字段拼裝好再返回。這樣做的最大好處是數(shù)據(jù)庫表結構變化時接口的返回結構可以保持穩(wěn)定前端不會因為后端改了表結構就崩。再說說統(tǒng)一返回結果。我建了一個Result類固定結構是{code: 200, message: success, data: {}}所有接口都返回這個格式。這樣前端攔截器只需要判斷code就知道請求是否成功不用每個接口單獨去解析。這個習慣看著簡單但實際用起來非常爽尤其是前后端聯(lián)調(diào)的時候不會出現(xiàn)這個接口為什么返回的字段名不一樣這種問題。2.3 JWT登錄鑒權的實現(xiàn)方式管理系統(tǒng)里登錄鑒權必須做不做的話任何人都能調(diào)用接口把會員數(shù)據(jù)刪了。我用的方案是JWTJSON Web Token。這里說下整體設計流程。用戶登錄成功后后端生成一個有效期為2小時的token返回給前端。前端把token存在localStorage里每次請求Axios攔截器把它加到請求頭。后端寫了一個攔截器攔下所有非登錄接口的請求解析token并把用戶信息放到ThreadLocal里這樣后面每次業(yè)務操作都能拿到當前登錄人的信息。生成token的核心代碼大概是這樣的String token Jwts.builder() .setSubject(userId.toString()) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 7200000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();攔截器里解析token的過程就不貼完整代碼了有一個經(jīng)驗值得分享解析token異常要分類型處理。token過期和token被篡改返回的錯誤信息不應該一樣前者提示登錄已過期請重新登錄并跳轉登錄頁后者提示登錄狀態(tài)異常并清理本地存儲。這個細節(jié)直接影響用戶體驗我見過很多項目在這塊做得比較粗糙用戶被卡在一個沒有明確提示的報錯里。權限控制方面我用的是角色判斷。接口注解上加一個自定義的RequireRole(admin)攔截器里判斷當前登錄人的角色是否有權限訪問。不需要引入Spring Security這種重框架因為系統(tǒng)角色只有四種簡單的角色判斷足夠用引入重框架反而增加學習和調(diào)試成本。3. 前端Vue頁面設計與API對接3.1 前端技術棧與目錄結構前端這邊用的組合是Vue3 Vite Vue Router Pinia Axios Element Plus。沒有用TypeScript原因很實際團隊和后續(xù)接手的人都更熟JavaScript而且這套系統(tǒng)的類型復雜度不高TS的收益有限。不過如果你打算長期維護并且團隊水平可以上TS是加分項。目錄結構按功能劃分推薦這樣組織src/ ├── api/ # 接口請求定義 ├── assets/ # 靜態(tài)資源 ├── components/ # 公共組件 ├── router/ # 路由配置 ├── store/ # 全局狀態(tài)Pinia ├── utils/ # 工具函數(shù)request封裝等 ├── views/ # 頁面組件 │ ├── member/ # 會員管理 │ ├── course/ # 課程管理 │ ├── booking/ # 預約管理 │ ├── dashboard/ # 統(tǒng)計報表 │ └── login.vue # 登錄頁 └── App.vueapi目錄下每個模塊一個文件比如member.js、course.js、booking.js每個文件導出對應模塊的接口函數(shù)。這樣做的好處是頁面組件里不直接寫請求URL所有接口統(tǒng)一定義后端接口路徑變了只需要改一個文件。3.2 Axios封裝的關鍵點Axios封裝是前端項目的重中之重代碼量不大但設計得好不好直接決定開發(fā)效率和聯(lián)調(diào)體驗。我的request工具類做了三件事。第一請求攔截器從localStorage里取token有就加到請求頭的Authorization字段沒有就放行。第二響應攔截器解包統(tǒng)一返回結果里的codecode為200就返回data部分業(yè)務代碼里直接拿到業(yè)務數(shù)據(jù)不用每個頁面再解一層。code為401就清空本地存儲、跳轉登錄頁。第三錯誤統(tǒng)一彈提示網(wǎng)絡錯誤、服務器錯誤統(tǒng)一用Element Plus的Message組件提示頁面里不做重復的錯誤處理邏輯。// utils/request.js 核心結構 import axios from axios import { ElMessage } from element-plus const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } if (res.code 401) { localStorage.clear() window.location.href /login } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(網(wǎng)絡請求失敗請稍后重試) return Promise.reject(error) } ) export default service這樣封裝完后頁面里調(diào)接口就是非常簡潔的寫法比如會員列表import { listMember } from /api/member const getList async () { const data await listMember({ page: currentPage.value, size: 10 }) tableData.value data.records total.value data.total }3.3 路由守衛(wèi)與權限控制路由層面我用Vue Router的全局前置守衛(wèi)做登錄攔截。邏輯很簡單訪問任何一個頁面之前檢查有沒有token沒有就重定向到登錄頁。登錄之后就放行。管理員和會員看到的頁面不同這件事我用了動態(tài)路由的思路處理路由表分成兩部分公共路由直接注冊權限路由在登錄后根據(jù)角色動態(tài)添加。動態(tài)路由這個功能在管理系統(tǒng)里很實用但實現(xiàn)起來有個坑路由動態(tài)添加后退出登錄再切換賬號之前添加的路由不會自動清除。解決方法是退出登錄時調(diào)用router.options.routes重置路由或者直接用重定向刷新頁面讓路由表重建。我踩過這個坑用后者的方式解決了簡單可靠。Pinia在這里主要存兩類全局信息當前登錄用戶的基本信息用戶名、角色、頭像和菜單的展開收起狀態(tài)。用戶信息在登錄接口返回后存入store退出登錄時清空。整個項目用到的全局狀態(tài)不多Pinia的模塊拆成user.js和app.js兩個就夠了。4. 核心模塊實操課程預約全流程實現(xiàn)4.1 業(yè)務規(guī)則與流程分析課程預約是這套系統(tǒng)里業(yè)務邏輯最復雜的模塊也是并發(fā)壓力最大的模塊。如果預約功能做不好整個系統(tǒng)在真實場景下是扛不住的。預約的業(yè)務規(guī)則我梳理出了四條會員必須登錄且會員卡未到期才能預約。同一節(jié)課一個會員只能預約一次。課程狀態(tài)為可預約才能被預約已滿或已結束不能預約。取消預約后該會員可以重新預約同一節(jié)課如果還有名額。整個流程文字描述是這樣會員進入課程列表頁看到所有發(fā)布日期在當天及之后的課程每門課程卡片上顯示剩余名額。點擊預約按鈕前端彈出確認框確認后請求后端預約接口。后端校驗通過后寫入預約記錄同時課程表的已預約人數(shù)加一。頁面刷新后課程的剩余名額減少。4.2 后端接口實現(xiàn)與并發(fā)控制預約接口的URL是POST /api/booking請求體參數(shù)是{courseId: 1, memberId: 5}。Service層的bookCourse方法邏輯分五步查會員卡狀態(tài)、查課程當前狀態(tài)、查是否已預約、判斷是否約滿、插入預約記錄并更新已約人數(shù)。這里最大的坑是并發(fā)問題兩個會員同時點預約恰好都查到了剩余名額1個然后都執(zhí)行插入最后一節(jié)課超員了。解決辦法我用了數(shù)據(jù)庫層面的事務加悲觀鎖。查詢課程記錄時加上SELECT ... FOR UPDATE行級鎖鎖住課程行這樣同一時間只有一個請求能執(zhí)行后面的預約邏輯。Transactional public void bookCourse(Long memberId, Long courseId) { Member member memberMapper.selectById(memberId); if (member null || member.getStatus() ! 1 || member.getCardEndDate().before(new Date())) { throw new BizException(會員卡狀態(tài)異常無法預約); } Course course courseMapper.selectByIdForUpdate(courseId); if (course null || course.getStatus() ! 0) { throw new BizException(該課程當前不可預約); } int count bookingMapper.countByMemberAndCourse(memberId, courseId, 已預約); if (count 0) { throw new BizException(您已預約該課程請勿重復預約); } if (course.getBookedCount() course.getMaxPeople()) { throw new BizException(該課程名額已滿); } Booking booking new Booking(); booking.setMemberId(memberId); booking.setCourseId(courseId); booking.setStatus(已預約); booking.setBookingTime(new Date()); bookingMapper.insert(booking); course.setBookedCount(course.getBookedCount() 1); courseMapper.updateById(course); }這段代碼有三個要點。一是Transactional必須加事務保證插入預約記錄和更新課程人數(shù)是原子操作任何一個失敗都會回滾。二是selectByIdForUpdate這個方法是自己在Mapper里寫的加了FOR UPDATE關鍵字不是MyBatis Plus自動生成的。三是業(yè)務異常要主動拋出來讓全局異常處理器統(tǒng)一包裝成Result返回不要在Service里自己try-catch吞掉。4.3 前端頁面交互實現(xiàn)前端課程列表頁我用了Element Plus的Card組件展示課程卡片每張卡片顯示課程名、教練、時間、剩余名額底部是預約按鈕。剩余名額為0時按鈕禁用已經(jīng)預約過的課程按鈕文案變成已預約且不可再點。預約按鈕的點擊處理邏輯是const handleBook async (courseId) { try { await createBooking({ memberId: userStore.userInfo.id, courseId }) ElMessage.success(預約成功) await getCourseList() // 刷新列表 } catch (e) { // 錯誤提示已經(jīng)在攔截器里統(tǒng)一處理了 } }這里有個易踩的坑預約成功后一定要刷新課程列表但不要整頁刷新調(diào)用getCourseList重新拉數(shù)據(jù)就好。因為課程卡片上的剩余名額是列表數(shù)據(jù)里的字段不重新拉數(shù)據(jù)的話前端顯示的剩余名額還是之前的用戶連續(xù)預約兩節(jié)不同的課會看到名額不對。曾經(jīng)為了省事直接用location.reload()結果彈窗消失、頁面閃爍體驗非常差。4.4 聯(lián)調(diào)階段幾個容易踩的坑前后端聯(lián)調(diào)是整個過程里最容易出問題的時候我發(fā)現(xiàn)的問題多數(shù)集中在三個地方。第一是日期格式。后端LocalDateTime默認序列化出來的格式是2024-11-20T14:30:00帶一個T前端如果想顯示成2024-11-20 14:30要么后端統(tǒng)一配置Jackson的日期格式要么前端做格式化處理。我建議后端直接配置全局生效省得前端每處都處理。第二是字段命名風格。后端Java習慣用駝峰命名createTime數(shù)據(jù)庫習慣用下劃線create_timeMyBatis Plus開了駝峰映射之后沒問題。但前端聯(lián)調(diào)時看返回的JSON字段是駝峰還是下劃線必須前后端統(tǒng)一確認好不然就會出現(xiàn)頁面拿到的是undefined這種經(jīng)典問題。第三是跨域。前后端分離開發(fā)模式下前端跑在5173端口后端跑在8080端口必然觸發(fā)跨域。我在后端寫了一個CorsConfig配置類允許指定的前端源地址跨域。上線部署后跨域配置還不能刪因為Nginx反向代理的配置方式不同這步我在后面部署章節(jié)專門講。5. 部署上線與常見問題排查5.1 前后端打包與部署方案系統(tǒng)開發(fā)完成后部署上線這里分享一套我驗證過很多次的方案。前端用Nginx做靜態(tài)文件服務器和反向代理后端的Spring Boot應用打成jar包直接跑。前端打包執(zhí)行npm run build打包產(chǎn)物在dist目錄。把dist目錄上傳到服務器任意位置然后配置Nginx。后端打包執(zhí)行mvn clean package -DskipTests生成jar包后用nohup java -jar fitness-system.jar logs/fitness.log 21 啟動。生產(chǎn)環(huán)境建議用systemd管理進程這樣重啟方便還能開機自啟。Nginx配置有兩個關鍵點一是前端history路由模式必須配置try_files不然刷新頁面會404二是/api路徑要反向代理到后端端口。server { listen 80; server_name your-domain.com; root /www/fitness-front; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html這行是必須的。Vue Router默認使用history模式URL是/member/list這種真實路徑刷新時瀏覽器會向Nginx發(fā)起這個路徑的請求Nginx找不到對應的靜態(tài)文件就會404。try_files把所有未知路徑回退到index.html再由前端路由接管頁面就能正常顯示了。5.2 常見問題速查表做這套系統(tǒng)的過程中我整理了一張問題排查表都是實際遇到過的按照從高頻到低頻排列。問題現(xiàn)象可能原因解決方法前端請求接口報403/跨域Nginx沒配代理或后端跨域配置丟失檢查Nginx的location /api配置確認proxy_pass指向后端刷新頁面404路由history模式缺少try_files配置在Nginx location / 里加try_files $uri $uri/ /index.html預約接口數(shù)據(jù)不一致并發(fā)場景下沒有用悲觀鎖或事務確認查詢課程用了FOR UPDATE鎖方法加了Transactional中文亂碼數(shù)據(jù)庫字符集不是utf8mb4ALTER TABLE xxx CONVERT TO CHARACTER SET utf8mb4登錄后馬上失效token過期時間太短或時區(qū)問題檢查JWT的expiration時間和服務器時間是否一致打包后接口地址不對前端寫死了localhost用環(huán)境變量配置接口baseURL打包時注入正確的API地址上傳的圖片顯示不了靜態(tài)資源路徑配置問題把上傳目錄配置到Nginx靜態(tài)資源路徑或用OSS存儲排查問題的思路也很重要。我習慣先把問題分成三類前端問題、后端問題、部署環(huán)境問題。前端問題先看瀏覽器開發(fā)者工具的Network面板接口請求沒發(fā)出去就是前端問題發(fā)出去沒響應就是后端或代理問題。后端問題看日志Spring Boot的日志會打印完整的異常棧和SQL語句95%的問題看日志就能定位。部署環(huán)境問題基本集中在端口沒開、路徑不對、Nginx配置錯誤這三類。5.3 安全加固的幾個必做項剛部署上線時系統(tǒng)安全性是沒法用的暴露在外網(wǎng)必須做幾件事。密碼存儲必須用BCrypt就算數(shù)據(jù)庫被拖走明文密碼也不能泄露。接口層面要防SQL注入MyBatis Plus的#{}預編譯機制能擋住大部分注入攻擊但還是不要用拼接SQL的方式寫復雜動態(tài)查詢。管理后臺登錄接口要加驗證碼。最初做系統(tǒng)時沒加驗證碼結果上線的第二天就被掃到登錄接口開始密碼暴力嘗試。后來我引入了Hutool的驗證碼工具庫用后端生成驗證碼圖片登錄時校驗驗證碼。這步雖然增加了一點用戶體驗成本但在公網(wǎng)環(huán)境下是必須的。運維層面也有經(jīng)驗Spring Boot的actuator接口默認暴露了健康檢查、環(huán)境信息等端點如果你引入了這個依賴卻沒配置權限等于把服務器的一部分信息免費送給別人看。生產(chǎn)環(huán)境配置里要顯式關閉這些端點只保留health一個就夠了。6. 寫在最后的實操體會整個健身管理系統(tǒng)做下來我最深的體會是這類管理系統(tǒng)的核心難點不在技術,而在業(yè)務邏輯的嚴謹性。預約模塊的并發(fā)控制、會員卡到期時間的比較、狀態(tài)流轉的邊界條件這些看似不起眼的地方才是真正考驗工程師功力的地方。技術棧Spring Boot和Vue只是工具把業(yè)務想清楚才是根本。如果現(xiàn)在重新讓我做一遍我會先花更多時間畫清楚狀態(tài)流轉圖而不是急著寫代碼。另外想提一個后續(xù)可以擴展的方向這套系統(tǒng)目前只做了Web端如果健身房需要完全可以把前端換成微信小程序后端接口只要稍微調(diào)整鑒權方式就能復用基本不需要重寫業(yè)務邏輯。這個項目本身的價值不在于代碼量有多大而在于把一套真實業(yè)務場景完整落地了從需求到上線整個鏈路都走通了這個經(jīng)驗比代碼本身值錢。