FastAPI 实战汇总版 学生信息管理系统——前后端分离 技术栈 后端:FastAPI + SQLAlchemy
前端:Vue3 + Element-plus
数据库:MySQL
所需工具 Pycharm、WebStorm、Postman
项目功能 管理员
学生
登录、注册
查看、修改个人信息
查看课程、选课
查看成绩
查看、修改评价
环境介绍 开发环境版本:
Python3.10.11、Node.js16.9.0、Vue3、MySQL5.7.31
官方文档 Node.js:https://nodejs.org/en
Vue:https://cn.vuejs.org/
Vite:https://cn.vitejs.dev/guide/
Element-Plus:https://element-plus.org/zh-CN/
FastAPI:https://fastapi.tiangolo.com/zh/
SQLAlchemy:https://docs.sqlalchemy.org/en/20/dialects/mysql.html#module-sqlalchemy.dialects.mysql.mysqldb
字节 Icon 库:https://iconpark.oceanengine.com/official
图片素材库:https://iconscout.com/
项目初始化 前端 Vue 安装 Node.js 进入官网 https://nodejs.org/download/release/v16.19.0/ 安装好 node.js
安装 vue-cli 然后使用npm install -g @vue/cli命令安装 vue-cli
创建 vue 项目 使用 vite 进行构建
通过npm create vite@latest studentfontend -- --template vue构建前端项目
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ➜ ~ cd Documents/PythonCode ➜ PythonCode mkdir studentProject ➜ PythonCode cd studentProject ➜ studentProject npm create vite@latest studentfontend -- --template vue Scaffolding project in /Users/zachary/Documents/PythonCode/studentProject/studentfontend... Done. Now run: cd studentfontend npm install npm run dev ➜ studentProject cd studentfontend ➜ studentfontend ls README.md package.json src index.html public vite.config.js
npm 源 如果 npm 命令运行的时候很卡的话,尝试换源
npm config set registry [https://registry.npmmirror.com](https://registry.npmmirror.com)
打开 vue 项目 使用 WebStorm 打开创建好的前端项目
先什么都不做,尝试把项目运行起来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 ➜ studentfontend npm install npm WARN EBADENGINE Unsupported engine { npm WARN EBADENGINE package: '@vitejs/plugin-vue@5.0.4', npm WARN EBADENGINE required: { node: '^18.0.0 || >=20.0.0' }, npm WARN EBADENGINE current: { node: 'v16.19.0', npm: '8.19.3' } npm WARN EBADENGINE } npm WARN EBADENGINE Unsupported engine { npm WARN EBADENGINE package: 'rollup@4.12.1', npm WARN EBADENGINE required: { node: '>=18.0.0', npm: '>=8.0.0' }, npm WARN EBADENGINE current: { node: 'v16.19.0', npm: '8.19.3' } npm WARN EBADENGINE } npm WARN EBADENGINE Unsupported engine { npm WARN EBADENGINE package: 'vite@5.1.5', npm WARN EBADENGINE required: { node: '^18.0.0 || >=20.0.0' }, npm WARN EBADENGINE current: { node: 'v16.19.0', npm: '8.19.3' } npm WARN EBADENGINE } up to date in 649ms ➜ studentfontend npm run dev > studentfontend@0.0.0 dev > vite VITE v5.1.5 ready in 814 ms ➜ Local: http://127.0.0.1:5173/ ➜ Network: use --host to expose ➜ press h + enter to show help
项目整改 现在我们把这个项目做好看些,为了学生信息管理系统做准备的,
需要把路由写好,同时修改主题色,还有 request http 的请求封装,选择一个自己喜欢的页面标题及 icon
index.html 修改项目下的 index.html 文件
1 2 3 4 5 6 7 8 9 10 11 12 13 <!DOCTYPE html > <html lang ="en" > <head > <meta charset ="UTF-8" /> <link rel ="icon" href ="/favicon.ico" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" /> <title > 学生信息管理系统</title > </head > <body > <div id ="app" > </div > <script type ="module" src ="/src/main.js" > </script > </body > </html >
css&imgs 删除/assets下的vue.svg
在/src/assets路径下创建一个css和imgs目录
在创建好的/css目录下分别创建global.css和index.scss
全局 css 样式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 * { box-sizing : border-box; } body { margin : 0 ; padding : 0 ; color : #252424 ; } a { text-decoration : none; } .card { background-color : rgb (255 , 255 , 255 ); border-radius : 5px ; padding : 10px ; box-shadow : 0 0 10px rgba (0 , 0 , 0 , 0.1 ); }
主题色配置文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @forward "element-plus/theme-chalk/src/common/var.scss" with ( $colors : ( "primary" : ( "base" : #0 a7fce ), "success" : ( "base" : #08 b41f ), "warning" : ( "base" : #e8af56 ), "danger" : ( "base" : #ef3030 ), "info" : ( "base" : #5 d66ea ) ) );
路由设置 在/src路径下创建一个router目录,在其中创建一个index.js文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import { createRouter, createWebHistory } from "vue-router" ;const router = createRouter ({ history : createWebHistory (import .meta .env .BASE_URL ), routes : [ { path : "/" , name : "Manager" , component : () => import ("@/views/Manager.vue" ), redirect : "/home" , children : [ { path : "home" , name : "Home" , component : () => import ("@/views/manager/Home.vue" ), }, ], }, ], }); export default router;
request 请求 /src路径下创建一个utils目录,在其中创建一个request.js文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import { ElMessage } from "element-plus" ;import router from "../router" ;import axios from "axios" ;const request = axios.create ({ baseURL : import .meta .env .VITE_BASE_URL , timeout : 30000 , }); request.interceptors .request .use ( (config ) => { config.headers ["Content-Type" ] = "application/json;charset=utf-8" ; return config; }, (error ) => { return Promise .reject (error); } ); request.interceptors .response .use ( (response ) => { let res = response.data ; if (response.config .responseType === "blob" ) { return res; } if (typeof res === "string" ) { res = res ? JSON .parse (res) : res; } if (res.code === "401" ) { ElMessage .error (res.msg ); router.push ("/login" ); } return res; }, (error ) => { console .log ("err" + error); return Promise .reject (error); } ); export default request;
views /src路径下的/components修改为/views目录,将其中的HelloWorld.vue修改成Manager.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 <template> <div> <div style="height: 60px; background-color: #eae8e8; display: flex; align-items: center; border-bottom: 1px solid #c4c2c2" > <div style="flex: 1"> <div style="padding-left: 20px; display: flex; align-items: center"> <img src="@/assets/imgs/logo.png" alt="" style="width: 40px" /> <div style="font-weight: bold; font-size: 24px; margin-left: 5px"> 学生信息管理系统 </div> </div> </div> <div style="width: fit-content; padding-right: 10px; display: flex; align-items: center;" > <img src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" alt="" style="width: 40px; height: 40px" /> <span style="margin-left: 5px">管理员</span> </div> </div> <div style="display: flex"> <div style="width: 200px; border-right: 1px solid #f3eeee; min-height: calc(100vh - 60px)" > <el-menu router style="border: none" :default-active="$route.path" :default-openeds="['/home']" > <el-menu-item index="/home"> <el-icon><HomeFilled /></el-icon> <span>系统首页</span> </el-menu-item> <el-menu-item index="/person"> <el-icon><User /></el-icon> <span>个人资料</span> </el-menu-item> <el-menu-item index="login" @click="logout"> <el-icon><SwitchButton /></el-icon> <span>退出系统</span> </el-menu-item> </el-menu> </div> <div style="flex: 1; width: 0; background-color: #eaeaee; padding: 10px"> <router-view /> </div> </div> </div> </template> <script setup> import { useRoute } from "vue-router"; const $route = useRoute(); console.log($route.path); const logout = () => { localStorage.removeItem("student-user"); }; </script> <style scoped> .el-menu-item.is-active { background-color: #c3d7d3 !important; } .el-menu-item:hover { color: #0c98d5; } :deep(th) { color: #333; } </style>
接着在/src/views路径下创建一个manager目录,在其中创建一个Home.vue文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 <template> <div class="welcome-container"> <div class="custom-card bg-white shadow rounded p-4 mb-5"> <h2 class="text-center text-primary">欢迎来到本系统</h2> <p class="text-center mt-3"> 您好 <span :style="{ color: '#116ca9' }">{{ user.username }}</span >,祝您使用愉快! </p> </div> </div> </template> <script setup> import request from "@/utils/request"; const user = JSON.parse(localStorage.getItem("student-user") || "{}"); </script> <style scoped> .welcome-container { display: flex; justify-content: center; align-items: center; min-height: calc(100vh - 100px); /* 根据实际项目需要调整高度 */ } .custom-card { max-width: 400px; border: none; } </style>
App.vue 修改项目中的/src/App.vue
1 2 3 <template> <RouterView /> </template>
main.js 修改项目中的/src/main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { createApp } from "vue" ;import App from "./App.vue" ;import router from "./router" ;import ElementPlus from "element-plus" ;import zhCn from "element-plus/dist/locale/zh-cn.mjs" ;import * as ElementPlusIconsVue from "@element-plus/icons-vue" ;import "@/assets/css/global.css" ;const app = createApp (App );app.use (router); app.use (ElementPlus , { locale : zhCn, }); app.mount ("#app" ); for (const [key, component] of Object .entries (ElementPlusIconsVue )) { app.component (key, component); }
.env 在项目路径下创建两个文件.env.development、.env.production
1 VITE_BASE_URL='http://localhost:9090'
1 VITE_BASE_URL='http://:9090'
json 配置 修改项目路径下的文件:package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 { "name" : "studentfontend" , "version" : "0.0.0" , "private" : true , "scripts" : { "dev" : "vite" , "build" : "vite build" , "preview" : "vite preview" } , "dependencies" : { "@element-plus/icons-vue" : "^2.1.0" , "axios" : "^1.6.2" , "element-plus" : "^2.4.2" , "sass" : "^1.69.5" , "unplugin-element-plus" : "^0.8.0" , "vue" : "^3.3.4" , "vue-router" : "^4.2.5" } , "devDependencies" : { "@vitejs/plugin-vue" : "^4.4.0" , "unplugin-auto-import" : "^0.16.7" , "unplugin-vue-components" : "^0.25.2" , "vite" : "^4.4.11" } }
vite.config.js 修改项目路径下的文件:vite.config.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import { fileURLToPath, URL } from "node:url" ;import { defineConfig } from "vite" ;import vue from "@vitejs/plugin-vue" ;import AutoImport from "unplugin-auto-import/vite" ;import Components from "unplugin-vue-components/vite" ;import { ElementPlusResolver } from "unplugin-vue-components/resolvers" ;import ElementPlus from "unplugin-element-plus/vite" ;export default defineConfig ({ plugins : [ vue (), AutoImport ({ resolvers : [ElementPlusResolver ({ importStyle : "sass" })], }), Components ({ resolvers : [ElementPlusResolver ({ importStyle : "sass" })], }), ElementPlus ({ useSource : true , }), ], resolve : { alias : { "@" : fileURLToPath (new URL ("./src" , import .meta .url )), }, }, css : { preprocessorOptions : { scss : { additionalData : ` @use "@/assets/css/index.scss" as *; ` , }, }, }, });
logo 修改一下 logo 和标题的 icon
分别放在/public/favicon.ico和/src/assets/imgs/logo.png路径下
删除/public/vite.svg
可以去这个网站下载https://iconscout.com/icons
https://iconscout.com/free-icon/library-2642818 ,将该图片保存为/src/assets/imgs/logo.png
或者https://iconscout.com/free-icon/student-79
https://iconscout.com/free-icon/student-reading-2909468 ,保存为/public/favicon.ico
运行 命令运行 1 2 3 4 5 6 7 8 9 10 11 12 13 14 ➜ studentfontend npm install added 93 packages, removed 1 package, and changed 5 packages in 16s ➜ studentfontend npm run dev > studentfontend@0.0.0 dev > vite VITE v4.5.2 ready in 1169 ms ➜ Local: http://127.0.0.1:5173/ ➜ Network: use --host to expose ➜ press h to show help
一键运行 通过配置一个启动项,便捷运行项目
后端 FastAPI 创建空项目 新建一个项目进入 FastAPI 的世界
先创建项目文件夹
1 2 3 4 5 6 7 8 ➜ studentProject ll total 0 drwxr-xr-x@ 16 zachary staff 512B 3 10 20:25 studentfontend ➜ studentProject mkdir studentbackend ➜ studentProject ll total 0 drwxr-xr-x@ 2 zachary staff 64B 3 10 20:41 studentbackend drwxr-xr-x@ 16 zachary staff 512B 3 10 20:25 studentfontend
然后使用 Pycharm 创建一个空项目
依赖包安装 需要安装一个 FastAPI 的依赖包
命令安装 pip install fastapi[all]
requirements.txt 安装
实现一个 api 创建 项目目录下创建一个main.py
1 2 3 4 5 6 7 8 9 10 11 from fastapi import FastAPIapp = FastAPI() @app.get("/" ) async def hello (): return {"message" : "Hello World" }
运行 需要使用 fastapi 提供的一个uvicornASGI 网关服务器来启动 api 服务
命令运行 uvicorn main:app --reload
一键运行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import uvicornfrom fastapi import FastAPIapp = FastAPI() @app.get("/" ) async def hello (): return {"message" : "Hello World" } if __name__ == "__main__" : uvicorn.run("main:app" , reload=True )
以上两种方式均可以实现,具体看个人习惯
项目整改 .env 这个文件用于存放环境变量,包括项目的运行 ip、端口号等,后面的数据库环境变量也在这里存放
1 2 HOST = "localhost" PORT = "9090"
common 在项目路径下创建一个/common的 package,用于实现公共类或者公共方法
环境变量配置文件 在/common包下创建一个config.py文件,实现对环境变量的获取
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import osfrom pathlib import Pathfrom dotenv import load_dotenvclass Config : def __init__ (self ): dotenv_path = Path(__file__).parent.parent / ".env" load_dotenv(dotenv_path=dotenv_path) self ._env = dict (os.environ) @property def env (self ): return self ._env config = Config()
在/common包下创建一个constant.py文件,用于配置常量
1 2 3 4 5 6 7 from common.config import configHOST = config.env.get("HOST" ) PORT = config.env.get("PORT" )
返回类 Result 实现一下 api 的通用返回类,在/common包下创建一个result.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 from pydantic import BaseModelclass ResultBase : code: str msg: str data: dict class ResultModel (BaseModel, ResultBase): pass class Result (ResultBase ): def __init__ (self, code, msg, data ): self .code = code self .msg = msg self .data = data @classmethod def success (cls, data: object = None , code: str = "200" , msg: str = "success" ): if not data: data = {} return cls(code, msg, data) @classmethod def error (cls, data: object = None , code: str = "500" , msg: str = "error" ): if not data: data = {} return cls(code, msg, data)
exception 创建一个/exception的 package 用于自定义异常
api 创建一个/api的 package,用于创建后端的 API,实际就是 Controller 层
init .py用于创建 FastAPI 应用初始化
1 2 3 4 5 6 from fastapi import FastAPIapp = FastAPI()
adminApi.py 创建一个 hello fastapi 接口测试一下项目
1 2 3 4 5 6 7 8 9 10 from api import appfrom common.result import Result@app.get("/" ) async def hello (): return Result.success()
然后新增了一个 api 文件之后需要给 api 的init .py 说明一下新增了一个 api 文件
1 2 3 4 5 6 7 8 from fastapi import FastAPIapp = FastAPI() from api import adminApi
service 创建一个/service的 package 用于实现 Service 层的业务代码
model 创建一个/model的 package 用于实现数据库的映射类
main 用于启动 FastAPI 的主入口程序
1 2 3 4 5 6 7 8 9 10 from api import appimport uvicornfrom common.constant import HOST, PORTif __name__ == '__main__' : uvicorn.run("main:app" , host=HOST, port=int (PORT), reload=True )
运行 直接运行 main.py 文件即可
出现这个就是访问成功啦
至此,我们的前端后端项目就都初始化好了
管理员登录 前端 Login 首先绘制一个登录页面
参考 element-plus 官网提供的表单https://element-plus.org/zh-CN/component/form.html
在项目路径/src/views下创建一个Login.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 <template> <div> <div class="login-container"> <div style="width: 420px" class="login-box"> <div class="title">学生信息管理系统 - 登录</div> <el-form :model="data.form"> <el-form-item> <el-input style="height: 40px;font-size: 18px" prefix-icon="Avatar" v-model="data.form.username" placeholder="请输入账号" /> </el-form-item> <el-form-item> <el-input style="height: 40px;font-size: 18px" prefix-icon="Lock" v-model="data.form.password" placeholder="请输入密码" /> </el-form-item> <el-form-item> <el-button type="primary" style="width: 100%;font-size: 18px" >登 录</el-button > </el-form-item> </el-form> <div style="margin-top:35px;text-align: right;font-size: 15px"> 还没有账号?请<a href="/register">注册</a> </div> </div> </div> </div> </template> <script setup> import { reactive } from "vue"; const data = reactive({ form: {}, }); </script> <style scoped> .login-container { min-height: 100vh; overflow: hidden; display: flex; align-items: center; justify-content: center; background-image: url("@/assets/imgs/login_background.png"); background-size: cover; } .login-box { background-color: rgb(255, 255, 255, 50%); box-shadow: 0 0 10px rgba(84, 221, 245, 0.41); padding: 30px; } .title { font-weight: bold; font-size: 30px; text-align: center; margin-bottom: 35px; } </style>
背景图片 添加一张登录背景图片到/src/assets/imags/login_background.png
https://iconscout.com/free-illustration/children-are-doing-chemical-experiments-10946611
路由添加 在/src/router/index.js中添加一下上面这个Login的路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import { createRouter, createWebHistory } from "vue-router" ;const router = createRouter ({ history : createWebHistory (import .meta .env .BASE_URL ), routes : [ { path : "/" , name : "Manager" , component : () => import ("@/views/Manager.vue" ), redirect : "/home" , children : [ { path : "home" , name : "Home" , component : () => import ("@/views/manager/Home.vue" ), }, ], }, { path : "/login" , name : "Login" , component : () => import ("@/views/Login.vue" ), }, ], }); export default router;
通过http://localhost:5173/login 可以访问到登录页面
表单校验和登录 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 <template> <div> <div class="login-container"> <div style="width: 420px" class="login-box"> <div class="title">学生信息管理系统 - 登录</div> <el-form :model="data.form" ref="formRef" :rules="rules"> <el-form-item prop="username"> <el-input style="height: 40px;font-size: 18px" prefix-icon="Avatar" v-model="data.form.username" placeholder="请输入账号" /> </el-form-item> <el-form-item prop="password"> <el-input show-password style="height: 40px;font-size: 18px" prefix-icon="Lock" v-model="data.form.password" placeholder="请输入密码" /> </el-form-item> <el-form-item> <el-button type="primary" style="width: 100%;font-size: 18px" @click="login" >登 录</el-button > </el-form-item> </el-form> <div style="margin-top:35px;text-align: right;font-size: 15px"> 还没有账号?请<a href="/register">注册</a> </div> </div> </div> </div> </template> <script setup> import { reactive, ref } from "vue"; import request from "@/utils/request"; import { ElMessage } from "element-plus"; import router from "@/router"; const data = reactive({ form: {}, }); const rules = reactive({ username: [{ required: true, message: "请输入账号", trigger: "blur" }], password: [{ required: true, message: "请输入密码", trigger: "blur" }], }); const formRef = ref(); const login = () => { formRef.value.validate((valid) => { if (valid) { request .post("/login", data.form) .then((res) => { if (res.code === "200") { localStorage.setItem("student-user", JSON.stringify(res.data)); ElMessage.success("登录成功"); router.push("/home"); } else { ElMessage.error(res.msg); } }) .catch((err) => { ElMessage.error(err.response?.data?.msg || err.message); }); } }); }; </script> <style scoped> .login-container { min-height: 100vh; overflow: hidden; display: flex; align-items: center; justify-content: center; background-image: url("@/assets/imgs/login_background.png"); background-size: cover; } .login-box { background-color: rgb(255, 255, 255, 50%); box-shadow: 0 0 10px rgba(84, 221, 245, 0.41); padding: 30px; } .title { font-weight: bold; font-size: 30px; text-align: center; margin-bottom: 35px; } </style>
后端 数据库 IDE 连接 MySQL
创建库 首先在数据库中创建一个 student_info 库
创建 admin 表
插入一条数据
数据库配置 查看 sqlalchemy 官方文档https://docs.sqlalchemy.org/en/20/dialects/mysql.html#module-sqlalchemy.dialects.mysql.mysqldb
requirements.txt 通过 sqlalchemy 连接数据库需要增加两个驱动
1 2 3 fastapi[all] mysqlclient==2.1.1 SQLAlchemy==2.0.23
.env 在.env 文件下添加数据库的配置信息
1 2 3 4 5 6 7 8 9 HOST = "localhost" PORT = "9090" MYSQL_DIALECT = "mysql+mysqldb" MYSQL_HOST = "localhost" MYSQL_PORT = "3306" MYSQL_USER = "root" MYSQL_PASSWORD = "980226" MYSQL_DATABASE = "student_info"
constant.py 添加数据库配置常量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from common.config import configHOST = config.env.get("HOST" ) PORT = config.env.get("PORT" ) MYSQL_DIALECT = config.env.get("MYSQL_DIALECT" ) MYSQL_HOST = config.env.get("MYSQL_HOST" ) MYSQL_PORT = config.env.get("MYSQL_PORT" ) MYSQL_USER = config.env.get("MYSQL_USER" ) MYSQL_PASSWORD = config.env.get("MYSQL_PASSWORD" ) MYSQL_DATABASE = config.env.get("MYSQL_DATABASE" )
model/init .py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from sqlalchemy import create_enginefrom sqlalchemy.orm import DeclarativeBase, sessionmakerfrom common.constant import *class Base (DeclarativeBase ): pass engine = create_engine( f"{MYSQL_DIALECT} ://{MYSQL_USER} :{MYSQL_PASSWORD} @{MYSQL_HOST} :{MYSQL_PORT} /{MYSQL_DATABASE} ?charset=utf8mb4" , echo=True ) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)
管理员实体类定义 在/model下创建一个admin.py文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from pydantic import BaseModelfrom sqlalchemy import Integer, Stringfrom sqlalchemy.orm import Mapped, mapped_columnfrom model import Baseclass Admin (Base ): __tablename__ = "admin" id : Mapped[int ] = mapped_column(Integer, primary_key=True , nullable=False ) username: Mapped[str ] = mapped_column(String(255 ), nullable=False ) password: Mapped[str ] = mapped_column(String(255 ), nullable=False ) class AdminModel (BaseModel ): username: str password: str
管理员登录的 api 接口 为了实现每一个 api 都能拿到一个连接数据库的 session,在/model/__init__.py中实现一个 session 的获取方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 from sqlalchemy import create_enginefrom sqlalchemy.orm import DeclarativeBase, sessionmakerfrom common.constant import *class Base (DeclarativeBase ): pass engine = create_engine( f"{MYSQL_DIALECT} ://{MYSQL_USER} :{MYSQL_PASSWORD} @{MYSQL_HOST} :{MYSQL_PORT} /{MYSQL_DATABASE} ?charset=utf8mb4" , echo=True ) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) def get_db_session (): session = Session() try : yield session finally : session.close()
adminApi 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from fastapi import Body, Dependsfrom fastapi.encoders import jsonable_encoderfrom api import appfrom common.result import Result, ResultModelfrom model import Session, get_db_sessionfrom model.admin import AdminModelfrom service.adminService import AdminService@app.post("/login" , response_model=ResultModel ) async def login (admin: AdminModel = Body(... ), db_session: Session = Depends(get_db_session ) ): dbadmin = AdminService.login(admin, db_session) return Result.success(jsonable_encoder(dbadmin))
adminService 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from sqlalchemy import selectfrom exception.customException import UserNotFoundException, PasswordNotMatchExceptionfrom model import Sessionfrom model.admin import Admin, AdminModelclass AdminService : @staticmethod def login (admin: AdminModel, db_session: Session ) -> Admin: query = select(Admin).where(Admin.username == admin.username) result = db_session.execute(query).scalars().first() if not result: raise UserNotFoundException("用户不存在" ) if result.password != admin.password: raise PasswordNotMatchException("身份验证未通过" ) return result
exception 创建自定义异常的exception的 package
然后创建一个customException.py
1 2 3 4 5 6 7 8 9 10 11 class UserNotFoundException (Exception ): def __init__ (self, message: str ): self .message = message class PasswordNotMatchException (Exception ): def __init__ (self, message: str ): self .message = message
exceptionHandler 在/api包下面创建一个exceptionHandler.py用于处理异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 from fastapi.encoders import jsonable_encoderfrom starlette.responses import JSONResponsefrom api import appfrom fastapi import Requestfrom common.result import Resultfrom exception.customException import UserNotFoundException, PasswordNotMatchException@app.exception_handler(Exception ) async def exception_handler (request: Request, exc: Exception ): result = Result.error(code="500" , msg=str (exc)) return JSONResponse(status_code=500 , content=jsonable_encoder(result)) @app.exception_handler(UserNotFoundException ) async def user_not_fount_exception_handler (request: Request, exc: UserNotFoundException ): result = Result.error(code="404" , msg=exc.message) return JSONResponse(status_code=404 , content=jsonable_encoder(result)) @app.exception_handler(PasswordNotMatchException ) async def password_not_fount_exception_handler (request: Request, exc: PasswordNotMatchException ): result = Result.error(code="401" , msg=exc.message) return JSONResponse(status_code=401 , content=jsonable_encoder(result))
/api/init .py 1 2 3 4 5 6 7 8 from fastapi import FastAPIapp = FastAPI() from api import adminApi, exceptionHandler
测试 postman 测试后端接口 正确登录:
密码错误:
账号错误:
前后端测试 跨域问题
发生了跨域 CORS 的问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from fastapi import FastAPIfrom starlette.middleware.cors import CORSMiddlewareapp = FastAPI() origins = ["http://localhost:5173" , "http://127.0.0.1:5173" ] app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True , allow_methods=["*" ], allow_headers=["*" ], ) from api import adminApi, exceptionHandler
正确账号密码:
错误账号:
错误密码:
数据库密码 为了安全,数据库中的 password 不应该以明文显示,需要做个加密
这里需要使用到一个库 bcrypt
1 2 3 4 fastapi[all] mysqlclient==2.1.1 SQLAlchemy==2.0.23 bcrypt==4.1.1
密码加密 通过hashed_pw = bcrypt.hashpw(password.encode(), bcrypt.gensalt()) 依据生成的盐值,生成 hash 后的密码值,结果是字节码
1 2 3 4 5 6 7 8 9 10 (venv) ➜ studentbackend python3 Python 3.10.11 (v3.10.11:7d4cc5aa85, Apr 4 2023, 19:05:19) [Clang 13.0.0 (clang-1300.0.29.30)] on darwin Type "help", "copyright", "credits" or "license" for more information. > >> import bcrypt > >> password = "admin" > >> hashed_pw = bcrypt.hashpw(password.encode(), bcrypt.gensalt()) > >> print (hashed_pw) b'$2b$12$ttbSmj8jD5rW/c0XdkBZyOZI5F7GP.kMuBMWgE2.yyzreJCWdwAum' > >> print (hashed_pw.decode('utf-8' )) $ 2b$12$ttbSmj8jD5rW /c0XdkBZyOZI5F7GP.kMuBMWgE2.yyzreJCWdwAum
将密码对应的加密密码 $2b$12$ttbSmj8jD5rW/c0XdkBZyOZI5F7GP.kMuBMWgE2.yyzreJCWdwAum 替换到数据库中的 admin 账号中(这一步操作其实应该是,在注册的时候将这个密码值写入数据库 de~)
密码验证 通过bcrypt.check(check_pw.encode(), hashed_pw)检验密码是否匹配
给 Admin 实体类定义一个密码检查方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import bcryptfrom pydantic import BaseModelfrom sqlalchemy import Integer, Stringfrom sqlalchemy.orm import Mapped, mapped_columnfrom model import Baseclass Admin (Base ): __tablename__ = "admin" id : Mapped[int ] = mapped_column(Integer, primary_key=True , nullable=False ) username: Mapped[str ] = mapped_column(String(255 ), nullable=False ) password: Mapped[str ] = mapped_column(String(255 ), nullable=False ) def password_check (self, password ): return bcrypt.checkpw(password.encode(), self .password.encode()) class AdminModel (BaseModel ): username: str password: str
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 from sqlalchemy import selectfrom exception.customException import UserNotFoundException, PasswordNotMatchExceptionfrom model import Sessionfrom model.admin import Admin, AdminModelclass AdminService : @staticmethod def login (admin: AdminModel, db_session: Session ) -> Admin: query = select(Admin).where(Admin.username == admin.username) result = db_session.execute(query).scalars().first() if not result: raise UserNotFoundException("用户不存在" ) if result.password_check(admin.password) is False : raise PasswordNotMatchException("身份验证未通过" ) return result
前后端测试 密码错误:
密码正确:
JWT 权限校验 在退出登录之后,依旧可以进入到主页面;同样后续其他 api 接口也需要在登录之后才能调用,需要加上 token,使用 JWT 实现。
前端 首先从前端这块处理,除了 login 和后续会增加的 register 页面不需要登录才能访问,其他页面,都需要登录用户之后才能访问,否则自动跳转回 login 页面
请求头 给请求头带上 token,提供给后端进行验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 import { ElMessage } from "element-plus" ;import router from "../router" ;import axios from "axios" ;const request = axios.create ({ baseURL : import .meta .env .VITE_BASE_URL , timeout : 30000 , }); request.interceptors .request .use ( (config ) => { const user = JSON .parse (localStorage .getItem ("student-user" )); config.headers ["Content-Type" ] = "application/json;charset=utf-8" ; if (user) { config.headers .Authorization = "Bearer ${user.token}" ; } return config; }, (error ) => { return Promise .reject (error); } ); request.interceptors .response .use ( (response ) => { let res = response.data ; if (response.config .responseType === "blob" ) { return res; } if (typeof res === "string" ) { res = res ? JSON .parse (res) : res; } if (res.code === "401" ) { ElMessage .error (res.msg ); router.push ("/login" ); } return res; }, (error ) => { console .log ("err" + error); return Promise .reject (error); } ); export default request;
路由 实现在没有 token 的情况下无法进入主页而跳转到 login 页面,在路由守卫中进行判断和处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import {createRouter, createWebHistory} from 'vue-router' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'Manager', component: () => import('@/views/Manager.vue'), redirect: '/home', children: [ { path: 'home', name: 'Home', component: () => import('@/views/manager/Home.vue'), meta: {requiresAuth: true} }, ] }, { path: '/login', name: 'Login', component: () => import('@/views/Login.vue') } ] }); router.beforeEach((to, from, next) => { const requiresAuth = to.matched.some(record => record.meta.requiresAuth); const user = JSON.parse(localStorage.getItem('student-user')); if (requiresAuth && !user) { // 如果目标路由需要认证,并且用户未登录 next('/login'); // 跳转到登录页面 } else { next(); // 如果目标路由不需要认证,或者用户已登录,则正常导航到目标路由 } }) export default router