:前后端分離架構(gòu)與狀態(tài)流轉(zhuǎn)設(shè)計(jì))
簡介本資源為基于Python的貧困生資助管理系統(tǒng)畢業(yè)設(shè)計(jì)項(xiàng)目源碼與數(shù)據(jù)庫采用前后端分離架構(gòu)面向計(jì)算機(jī)相關(guān)專業(yè)正在做畢業(yè)設(shè)計(jì)的學(xué)生及需要項(xiàng)目實(shí)戰(zhàn)練習(xí)的學(xué)習(xí)者。項(xiàng)目經(jīng)導(dǎo)師指導(dǎo)并認(rèn)可通過評(píng)審分98分源碼均經(jīng)本地編譯運(yùn)行與嚴(yán)格調(diào)試難度適中可滿足學(xué)習(xí)與使用需求。壓縮包共755個(gè)文件約12.96MB涵蓋40個(gè)py后端源碼、38個(gè)vue前端組件、27個(gè)html頁面、51個(gè)css與162個(gè)js樣式腳本以及2個(gè)sql數(shù)據(jù)庫腳本、若干圖片與字體資源并附安裝、運(yùn)行、構(gòu)建等bat腳本便于快速部署。目前已有108人學(xué)習(xí)下載。讀者可獲得完整可運(yùn)行的前后端分離項(xiàng)目代碼、數(shù)據(jù)庫腳本與目錄結(jié)構(gòu)參考適合作為畢業(yè)設(shè)計(jì)選題模板或課程實(shí)戰(zhàn)案例幫助理解資助申請(qǐng)、審核、信息管理等模塊的實(shí)現(xiàn)思路與排錯(cuò)方法。1. 貧困生資助管理系統(tǒng)從一張 Excel 臺(tái)賬到前后端分離的完整落地每年秋季學(xué)期某高校學(xué)工部門的老師都要面對(duì)同一件頭疼事幾千份家庭經(jīng)濟(jì)困難認(rèn)定申請(qǐng)表堆在桌上Excel 臺(tái)賬改了又改版本從最終版一路排到最終版不再改可一到公示環(huán)節(jié)還是有人發(fā)現(xiàn)數(shù)據(jù)對(duì)不上。這不是誰不認(rèn)真而是用表格管流程本身就撐不住申請(qǐng)—審核—公示—發(fā)放—?dú)w檔這條鏈路?;?Python 的貧困生資助管理系統(tǒng)要解決的正是這個(gè)問題把認(rèn)定流程、資助等級(jí)、發(fā)放記錄搬進(jìn)一個(gè)帶權(quán)限、帶狀態(tài)流轉(zhuǎn)、帶審計(jì)痕跡的 Web 系統(tǒng)里。它適合正在做畢業(yè)設(shè)計(jì)、需要一套能跑通、能講清架構(gòu)、又能真實(shí)演示業(yè)務(wù)閉環(huán)的同學(xué)也適合想拿它當(dāng)練手項(xiàng)目、補(bǔ)齊前后端分離經(jīng)驗(yàn)的開發(fā)者。下面我按自己搭這類系統(tǒng)的順序把選型、建表、接口、聯(lián)調(diào)和踩坑一次講透。2. 技術(shù)選型與數(shù)據(jù)庫設(shè)計(jì)為什么這套組合最適合畢業(yè)設(shè)計(jì)2.1 后端選 Django 還是 Flask先看你要不要自帶后臺(tái)做資助管理系統(tǒng)繞不開一個(gè)現(xiàn)實(shí)你需要一個(gè)能管理用戶、角色、權(quán)限的后臺(tái)。Django 自帶 admin、ORM、認(rèn)證體系開箱就能拿到用戶管理和權(quán)限控制Flask 更輕但用戶、角色、權(quán)限這套東西你得自己搭工作量會(huì)翻倍。我的建議是如果這是畢業(yè)設(shè)計(jì)選 Django Django REST FrameworkDRF把精力留給業(yè)務(wù)邏輯而不是造輪子。前端這邊Vue 3 Element Plus 是最穩(wěn)的組合。Element Plus 的表格、表單、分頁、彈窗組件齊全學(xué)工類系統(tǒng)大量是列表 表單 審批的界面用它開發(fā)效率高答辯演示也好看。前后端通過 JSON 交互前端跑在 5173 端口后端跑在 8000 端口用 CORS 打通。數(shù)據(jù)庫選 MySQL 8.0。資助系統(tǒng)涉及金額、等級(jí)、狀態(tài)需要事務(wù)保證一致性MySQL 的 InnoDB 引擎夠用而且部署資料多答辯時(shí)老師問起來也好解釋。2.2 核心表結(jié)構(gòu)五張表撐起整條資助鏈路資助系統(tǒng)的數(shù)據(jù)模型其實(shí)不復(fù)雜關(guān)鍵是別把學(xué)生申請(qǐng)審核發(fā)放揉在一張表里。我一般拆成五張核心表表名作用關(guān)鍵字段student學(xué)生基礎(chǔ)信息id、學(xué)號(hào)、姓名、院系、年級(jí)、班級(jí)application困難認(rèn)定申請(qǐng)id、學(xué)生id、申請(qǐng)學(xué)年、困難等級(jí)、狀態(tài)、提交時(shí)間review_record審核記錄id、申請(qǐng)id、審核人id、審核結(jié)果、意見、審核時(shí)間grant_record資助發(fā)放記錄id、申請(qǐng)id、金額、發(fā)放時(shí)間、發(fā)放狀態(tài)sys_user系統(tǒng)用戶id、賬號(hào)、密碼、角色、關(guān)聯(lián)學(xué)生id這里有個(gè)設(shè)計(jì)要點(diǎn)application表里的狀態(tài)字段是整個(gè)系統(tǒng)的靈魂。它決定了這條申請(qǐng)現(xiàn)在處于哪一步常見取值是draft草稿、submitted已提交、reviewing審核中、approved已通過、rejected已駁回、granted已發(fā)放。狀態(tài)流轉(zhuǎn)必須由后端控制前端只能發(fā)起請(qǐng)求不能直接改狀態(tài)否則數(shù)據(jù)就亂了。-- 困難認(rèn)定申請(qǐng)主表狀態(tài)字段是流程控制的核心 CREATE TABLE application ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL COMMENT 關(guān)聯(lián)學(xué)生id, academic_year VARCHAR(20) NOT NULL COMMENT 申請(qǐng)學(xué)年如2024-2025, difficulty_level TINYINT DEFAULT NULL COMMENT 困難等級(jí)1一般 2困難 3特別困難, status VARCHAR(20) NOT NULL DEFAULT draft COMMENT 流程狀態(tài), submit_time DATETIME DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_student (student_id), INDEX idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表時(shí)把status和student_id都加上索引因?yàn)榱斜眄搸缀跤肋h(yuǎn)在按狀態(tài)篩選、按學(xué)生查詢。difficulty_level允許為空是因?yàn)椴莞咫A段學(xué)生還沒填完強(qiáng)制非空會(huì)導(dǎo)致保存草稿失敗——這個(gè)坑我后面還會(huì)提。2.3 用 Django ORM 把表映射成模型表建好后用 Django 的模型類對(duì)應(yīng)上后續(xù)所有查詢都走 ORM避免手寫 SQL 拼接帶來的注入風(fēng)險(xiǎn)。# models.py 核心模型定義 from django.db import models class Student(models.Model): student_no models.CharField(max_length20, uniqueTrue, verbose_name學(xué)號(hào)) name models.CharField(max_length30, verbose_name姓名) department models.CharField(max_length50, verbose_name院系) grade models.CharField(max_length10, verbose_name年級(jí)) class Meta: db_table student class Application(models.Model): STATUS_CHOICES [ (draft, 草稿), (submitted, 已提交), (reviewing, 審核中), (approved, 已通過), (rejected, 已駁回), (granted, 已發(fā)放), ] student models.ForeignKey(Student, on_deletemodels.CASCADE) academic_year models.CharField(max_length20) difficulty_level models.SmallIntegerField(nullTrue, blankTrue) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultdraft) submit_time models.DateTimeField(nullTrue, blankTrue) class Meta: db_table applicationon_deletemodels.CASCADE表示學(xué)生被刪除時(shí)其申請(qǐng)一并刪除這在測(cè)試階段方便清理數(shù)據(jù)但生產(chǎn)環(huán)境更穩(wěn)妥的做法是用PROTECT防止誤刪學(xué)生導(dǎo)致歷史資助記錄丟失。畢業(yè)設(shè)計(jì)里用 CASCADE 沒問題但答辯時(shí)如果被問到數(shù)據(jù)安全能說出這個(gè)區(qū)別是加分項(xiàng)。3. 后端接口開發(fā)把狀態(tài)流轉(zhuǎn)寫成不可繞過的規(guī)則3.1 用 DRF 寫第一個(gè)申請(qǐng)列表接口前后端分離的核心是接口。先寫一個(gè)查詢當(dāng)前學(xué)生申請(qǐng)列表的接口把認(rèn)證、序列化、分頁三件事串起來。# views.py 申請(qǐng)列表與創(chuàng)建接口 from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from .models import Application from .serializers import ApplicationSerializer class ApplicationListView(APIView): permission_classes [IsAuthenticated] def get(self, request): # 普通學(xué)生只能看自己的申請(qǐng)管理員可看全部 if request.user.role student: qs Application.objects.filter(student__userrequest.user) else: qs Application.objects.all() status_filter request.query_params.get(status) if status_filter: qs qs.filter(statusstatus_filter) serializer ApplicationSerializer(qs.order_by(-id), manyTrue) return Response({code: 0, data: serializer.data})這段代碼有兩個(gè)關(guān)鍵點(diǎn)。第一權(quán)限判斷寫在視圖里根據(jù)request.user.role決定數(shù)據(jù)范圍學(xué)生永遠(yuǎn)看不到別人的申請(qǐng)這是數(shù)據(jù)隔離的底線。第二status作為查詢參數(shù)做可選篩選前端切換標(biāo)簽頁時(shí)只傳一個(gè)參數(shù)后端不用改代碼。order_by(-id)保證最新的申請(qǐng)排在最前符合使用習(xí)慣。3.2 狀態(tài)流轉(zhuǎn)接口誰能改、改成什么必須寫死資助系統(tǒng)最容易翻車的地方就是狀態(tài)被隨意修改。學(xué)生提交后自己又改回草稿或者審核員跳過審核中直接標(biāo)已通過流程就亂了。解決辦法是把合法流轉(zhuǎn)寫成一個(gè)映射表任何修改都先校驗(yàn)。# 狀態(tài)流轉(zhuǎn)規(guī)則當(dāng)前狀態(tài) - 允許流轉(zhuǎn)到的狀態(tài) ALLOWED_TRANSITIONS { draft: [submitted], submitted: [reviewing, rejected], reviewing: [approved, rejected], approved: [granted], rejected: [draft], # 駁回后允許學(xué)生修改重新提交 granted: [], # 已發(fā)放為終態(tài) } class ApplicationStatusView(APIView): permission_classes [IsAuthenticated] def post(self, request, pk): app Application.objects.get(pkpk) target request.data.get(status) # 校驗(yàn)?zāi)繕?biāo)狀態(tài)是否在允許范圍內(nèi) if target not in ALLOWED_TRANSITIONS.get(app.status, []): return Response({code: 4001, msg: 非法的狀態(tài)流轉(zhuǎn)}, status400) # 只有審核角色能操作審核類狀態(tài) if target in (reviewing, approved, rejected) and request.user.role ! reviewer: return Response({code: 4003, msg: 無審核權(quán)限}, status403) app.status target app.save() return Response({code: 0, msg: 操作成功})ALLOWED_TRANSITIONS這張表是整個(gè)流程的黑匣子所有合法路徑一目了然。granted對(duì)應(yīng)空列表意味著一旦發(fā)放就不能再改這是終態(tài)保護(hù)。角色校驗(yàn)和狀態(tài)校驗(yàn)分開寫是因?yàn)樗鼈兓卮鸬氖莾蓚€(gè)不同問題這個(gè)狀態(tài)能不能到以及這個(gè)人有沒有資格操作。分開后報(bào)錯(cuò)信息更精確前端也好提示。3.3 審核記錄每一次操作都要留痕狀態(tài)改了但誰改的、什么時(shí)候改的、什么意見必須單獨(dú)記一張表。這不是可選項(xiàng)資助系統(tǒng)涉及公平性沒有審核記錄公示階段被質(zhì)疑時(shí)你拿不出證據(jù)。# 審核時(shí)同步寫入審核記錄 class ReviewView(APIView): permission_classes [IsAuthenticated] def post(self, request, pk): app Application.objects.get(pkpk) result request.data.get(result) # pass 或 reject comment request.data.get(comment, ) target approved if result pass else rejected if target not in ALLOWED_TRANSITIONS.get(app.status, []): return Response({code: 4001, msg: 當(dāng)前狀態(tài)不可審核}, status400) app.status target app.save() ReviewRecord.objects.create( applicationapp, reviewerrequest.user, resultresult, commentcomment ) return Response({code: 0, msg: 審核完成})審核和寫記錄放在同一個(gè)請(qǐng)求里用數(shù)據(jù)庫事務(wù)包起來更穩(wěn)Django 里加transaction.atomic裝飾器。如果審核成功但記錄寫入失敗數(shù)據(jù)就不一致了這種問題在答辯演示時(shí)一旦出現(xiàn)很難解釋。4. 前端聯(lián)調(diào)與權(quán)限控制讓界面跟著角色走4.1 用 axios 封裝統(tǒng)一請(qǐng)求把 token 帶上前端所有請(qǐng)求都走一個(gè)封裝好的 axios 實(shí)例避免每個(gè)頁面重復(fù)寫 baseURL 和請(qǐng)求頭。// request.js 統(tǒng)一請(qǐng)求封裝 import axios from axios const request axios.create({ baseURL: http://127.0.0.1:8000/api, timeout: 10000, }) // 請(qǐng)求攔截自動(dòng)攜帶 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 響應(yīng)攔截統(tǒng)一處理錯(cuò)誤碼 request.interceptors.response.use( res res.data, err { if (err.response err.response.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(err) } ) export default request攔截器做了兩件事請(qǐng)求發(fā)出前自動(dòng)加 token響應(yīng)回來后統(tǒng)一處理 401。401 意味著登錄過期直接清 token 跳登錄頁比在每個(gè)頁面單獨(dú)判斷省事得多。timeout設(shè) 10 秒本地開發(fā)夠用如果后端查詢慢可以調(diào)大但別設(shè)成無限等待否則接口掛了頁面會(huì)一直轉(zhuǎn)圈。4.2 路由守衛(wèi)不同角色進(jìn)不同頁面學(xué)生登錄后應(yīng)該看到我的申請(qǐng)審核員看到待審核列表管理員看到全部數(shù)據(jù)。這個(gè)分流在路由層面做比在頁面里判斷更干凈。// router/index.js 路由守衛(wèi) router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.meta.requiresAuth !token) { next(/login) // 未登錄跳登錄頁 } else if (to.meta.roles !to.meta.roles.includes(role)) { next(/403) // 角色不符跳無權(quán)限頁 } else { next() } })路由的meta里聲明requiresAuth和roles守衛(wèi)統(tǒng)一判斷。這樣新增頁面時(shí)只要配好 meta不用改守衛(wèi)邏輯。注意前端路由守衛(wèi)只是體驗(yàn)優(yōu)化真正的權(quán)限校驗(yàn)必須在后端做前端能繞過后端不能省。4.3 申請(qǐng)表單草稿和提交走同一個(gè)接口學(xué)生填表時(shí)經(jīng)常填一半要保存所以表單要有保存草稿和提交兩個(gè)按鈕但后端可以用同一個(gè)創(chuàng)建/更新接口靠狀態(tài)字段區(qū)分。// 提交申請(qǐng)action 為 save 存草稿為 submit 直接提交 async function submitForm(action) { const payload { academic_year: form.academicYear, difficulty_level: form.level, status: action submit ? submitted : draft } const res await request.post(/applications/, payload) if (res.code 0) { ElMessage.success(action submit ? 提交成功 : 草稿已保存) } }這里status由前端傳但后端必須再校驗(yàn)一次學(xué)生只能傳draft或submitted不能傳approved。前端傳什么不可信這是接口安全的基本功。5. 避坑與排查那些讓我返工三次的問題5.1 跨域報(bào)錯(cuò) CORS別只會(huì)加一個(gè)頭現(xiàn)象前端調(diào)接口瀏覽器控制臺(tái)報(bào)has been blocked by CORS policy請(qǐng)求根本沒到后端。原因前后端端口不同瀏覽器同源策略攔截。有人圖省事在響應(yīng)里加Access-Control-Allow-Origin: *但帶 token 的請(qǐng)求攜帶憑證時(shí)通配符不被允許。解決用django-cors-headers配置里寫明確的前端地址并開啟憑證支持。# settings.py 跨域配置 INSTALLED_APPS [corsheaders] MIDDLEWARE.insert(0, corsheaders.middleware.CorsMiddleware) CORS_ALLOW_CREDENTIALS True CORS_ALLOWED_ORIGINS [http://localhost:5173, http://127.0.0.1:5173]注意CorsMiddleware要盡量放在中間件靠前位置否則可能被其他中間件提前攔截。5.2 保存草稿失敗因?yàn)樽侄卧O(shè)了非空現(xiàn)象點(diǎn)保存草稿報(bào) 400提示difficulty_level不能為空。原因建表時(shí)把困難等級(jí)設(shè)成了NOT NULL但草稿階段學(xué)生還沒選等級(jí)。解決把difficulty_level改成允許為空模型里加nullTrue, blankTrue。凡是草稿階段可能沒填的字段都要允許為空等提交時(shí)再用業(yè)務(wù)校驗(yàn)強(qiáng)制必填。5.3 列表分頁后篩選失效現(xiàn)象前端切到第二頁篩選條件丟了又回到全部數(shù)據(jù)。原因篩選參數(shù)只存在組件狀態(tài)里翻頁請(qǐng)求沒帶上。解決把篩選條件統(tǒng)一存到一個(gè)查詢對(duì)象里翻頁和篩選都從這個(gè)對(duì)象取值拼參數(shù)。別在翻頁時(shí)只傳page把status、academic_year一起帶上。5.4 金額字段用浮點(diǎn)數(shù)對(duì)賬差幾分錢現(xiàn)象發(fā)放金額累加后和臺(tái)賬差 0.01。原因數(shù)據(jù)庫用FLOAT存金額浮點(diǎn)運(yùn)算有精度誤差。解決金額一律用DECIMAL(10,2)Django 里用DecimalField(max_digits10, decimal_places2)。涉及錢的地方浮點(diǎn)數(shù)就是后悔藥都買不回來的坑。5.5 刪除學(xué)生導(dǎo)致資助記錄消失現(xiàn)象測(cè)試時(shí)刪了一個(gè)學(xué)生歷史發(fā)放記錄跟著沒了。原因外鍵用了CASCADE。解決涉及歷史記錄的關(guān)聯(lián)改成PROTECT刪除前先檢查是否有關(guān)聯(lián)數(shù)據(jù)。測(cè)試環(huán)境圖方便用 CASCADE上線前一定要改回來。6. 進(jìn)階技巧把系統(tǒng)從能跑做到敢演示到這一步系統(tǒng)基本能跑通了但畢業(yè)設(shè)計(jì)答辯和真實(shí)使用之間還差幾個(gè)細(xì)節(jié)。我一般會(huì)補(bǔ)三樣?xùn)|西成本不高但演示效果和可信度提升明顯。第一樣是數(shù)據(jù)導(dǎo)出。學(xué)工老師最需要的功能之一就是把篩選后的名單導(dǎo)成 Excel。用openpyxl在后端生成文件前端用responseType: blob接收下載。# 導(dǎo)出資助名單為 Excel from openpyxl import Workbook from django.http import HttpResponse def export_grants(request): wb Workbook() ws wb.active ws.append([學(xué)號(hào), 姓名, 困難等級(jí), 資助金額, 發(fā)放時(shí)間]) qs GrantRecord.objects.select_related(application__student) for g in qs: s g.application.student ws.append([s.student_no, s.name, g.application.difficulty_level, float(g.amount), g.grant_time.strftime(%Y-%m-%d)]) resp HttpResponse( content_typeapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet ) resp[Content-Disposition] attachment; filenamegrants.xlsx wb.save(resp) return respselect_related一次性把關(guān)聯(lián)的學(xué)生信息查出來避免循環(huán)里逐條查庫導(dǎo)致 N1 問題。導(dǎo)出幾百條數(shù)據(jù)時(shí)這個(gè)優(yōu)化能讓響應(yīng)從幾秒降到幾百毫秒。第二樣是操作日志。除了審核記錄登錄、導(dǎo)出、刪除這類敏感操作也建議記一條日志字段就四個(gè)操作人、操作類型、目標(biāo)對(duì)象、時(shí)間。答辯時(shí)老師問怎么保證數(shù)據(jù)可追溯這張表就是答案。第三樣是狀態(tài)機(jī)的前端可視化。在申請(qǐng)?jiān)斍轫撚貌襟E條Element Plus 的el-steps把draft → submitted → reviewing → approved → granted畫出來當(dāng)前狀態(tài)高亮。這個(gè)改動(dòng)很小但演示時(shí)一眼就能讓評(píng)委看懂流程比口頭解釋強(qiáng)得多。最后說個(gè)我自己的習(xí)慣每次改完?duì)顟B(tài)流轉(zhuǎn)規(guī)則我都會(huì)手動(dòng)把六種狀態(tài)兩兩組合跑一遍看哪些該攔、哪些該放。這個(gè)笨辦法幫我抓到過好幾次規(guī)則漏洞——比如駁回后重新提交這條路徑一開始忘了允許學(xué)生被駁回后就卡死了。系統(tǒng)能不能用往往不取決于功能多而取決于異常路徑有沒有堵死。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取