覆盖文件:
run.py、app/main.py、app/api/api.py、app/api/deps.py、app/api/endpoints/auth.py、app/crud/user.py、app/models/user.py、app/models/revoked_token.py、app/schemas/user.py、app/db/session.py、app/core/config.py、app/core/audit_middleware.py、app/db/init_db.py上一篇(01)讲了前端;本篇从"启动程序"开始,一路讲到"密码哈希校验",把登录链路后端部分全部打通。
一、后端请求流程图(本篇文章就是按这个顺序讲)
python run.py
→ run.py: 初始化数据库 → uvicorn 启动服务器
→ app/main.py: 创建 FastAPI 应用 → 挂载中间件 → 注册路由 → 启动时初始化
浏览器 POST /api/auth/token
→ (中间件) AuditMiddleware 记录审计(写操作才记)
→ api.py 找到 auth 路由
→ auth.py 的 login_for_access_token
→ crud/user.py 的 authenticate_user(查用户 + 验密码)
→ deps.py 的 create_access_token(签发 JWT)
→ 返回 {access_token, token_type, must_change_password}
之后每个请求
→ deps.py 的 get_current_user(解令牌 → 查吊销表 → 查用户)
二、run.py 逐行讲解(40 行)
这是程序入口 。你在 cmd 里执行
python run.py就是运行它。
python
1: import os
2: import sys
▶ 第 1~2 行:导入 Python 标准库。os = 操作系统接口(环境变量等),sys = Python 解释器接口。
python
4: # 确保虚拟环境 bin 目录在 PATH 中(PyCharm 运行配置用 SDK_HOME 指定解释器,
5: # 不 activate venv,导致 yolo/pip/tensorboard 等 venv/bin 下的 CLI 找不到)
6: _venv_bin = os.path.dirname(sys.executable)
7: if _venv_bin not in os.environ.get('PATH', '').split(os.pathsep):
8: os.environ['PATH'] = _venv_bin + os.pathsep + os.environ.get('PATH', '')
▶ 第 6 行:sys.executable = 当前正在运行的 Python 解释器的完整路径,os.path.dirname(...) = 取它的目录(虚拟环境的 Scripts/bin 目录)。
▶ 第 7~8 行:如果这个目录不在系统 PATH 环境变量里,就把它加到最前面。这样后续运行 yolo、tensorboard 等命令行工具时系统能找到它们。
💡 为什么需要这步?------虚拟环境激活时(activate)会自动设置 PATH,但 PyCharm 等 IDE 直接用解释器路径运行,不经过激活,所以手动补上。
python
10: import logging
11: import uvicorn
12: from app.db.init_db import init_db
▶ 第 10 行:logging = Python 日志库(在控制台打印带时间戳的信息)。
▶ 第 11 行:uvicorn = FastAPI 的 Web 服务器(负责真正的网络收发)。
▶ 第 12 行:从 app/db/init_db.py 导入 init_db 函数(建表、迁移、创建 admin 账号,见本篇第 12 节)。
python
15: # 配置日志
16: logging.basicConfig(
17: level=logging.INFO,
18: format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
19: )
20: logger = logging.getLogger(__name__)
▶ 第 16~19 行:配置日志格式:level=INFO(只显示 INFO 及以上级别)、format 定义了每行日志的样子(时间 - 模块名 - 级别 - 内容)。
▶ 第 20 行:__name__ 是"当前模块名"(这里运行主模块,值是 __main__)。之后用 logger.info(...) 打日志。
python
22: if __name__ == "__main__":
▶ 第 22 行:只有直接运行本文件时 (python run.py)才执行下面代码;被 import 时不执行。这是 Python 的标准入口写法。
python
23: try:
24: # Initialize database
25: logger.info("Initializing database...")
26: init_db()
27: logger.info("Database initialization completed successfully")
▶ 第 23 行:try: = 尝试执行,出错会被后面的 except(第 39 行)接住并打印错误。
▶ 第 25~26 行:调用建表初始化函数(详见本篇 12 节)。每次启动都会执行,它内部有"表已存在就跳过"的逻辑(幂等)。
python
29: # # Start TensorBoard service
30: # logger.info("Starting TensorBoard service...")
31: # if tensorboard_manager.start():
...
36: # Start server
37: logger.info("Starting server...")
38: uvicorn.run("app.main:app", host="0.0.0.0", port=9000, reload=True)
39: except Exception as e:
40: logger.error(f"Error during startup: {e}", exc_info=True)
▶ 第 29~35 行:被注释掉的 TensorBoard 启动代码(已迁移到 app/main.py 的启动事件里,见下一节)。
▶ 第 38 行:启动 Web 服务器。参数拆解:
"app.main:app":加载app/main.py文件里名为app的 FastAPI 实例host="0.0.0.0":监听所有网卡(局域网内其他电脑也能访问;只本机能用就写127.0.0.1)port=9000:端口号(浏览器访问http://localhost:9000)reload=True:开发模式 ------代码改了自动重启,方便调试。⚠️ 生产环境必须关闭
▶ 第 40 行:启动失败打印完整堆栈(exc_info=True= 带异常详细信息)。
三、app/main.py 逐行讲解(核心 262 行)
这是 FastAPI 应用的创建与配置中心:应用对象、中间件、静态文件、路由注册、启动/关闭事件都在这里。
3.1 导入部分(第 1~29 行)
python
1: import logging
2: import threading
3: from fastapi import FastAPI, Request, Depends
4: from fastapi.middleware.cors import CORSMiddleware
5: from fastapi.staticfiles import StaticFiles
6: from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, FileResponse
7: from fastapi.exceptions import RequestValidationError
8: from fastapi.security import OAuth2PasswordBearer
▶ 第 3 行:FastAPI(应用类)、Request(请求对象,可读请求头)、Depends(依赖注入,见 00 篇)。
▶ 第 4 行:CORSMiddleware = 跨域中间件(允许其他网站调用本 API,见第 142 行配置)。
▶ 第 5 行:StaticFiles = 静态文件服务(把 app/static/ 目录直接暴露成网址,见第 151 行)。
▶ 第 6 行:各类响应类型:HTMLResponse(返回网页)、JSONResponse(返回 JSON)、RedirectResponse(重定向)、FileResponse(返回文件下载)。
▶ 第 7 行:RequestValidationError = 请求数据校验失败的异常类(用于自定义错误处理,见第 159 行)。
▶ 第 8 行:OAuth2PasswordBearer = FastAPI 提供的"读取 Authorization 头"工具(在 deps.py 使用)。
python
10: from app.api.api import api_router
11: from app.core.config import settings
12: from app.services.process_monitor import process_monitor
13: from app.services.tensorboard_service import tensorboard_manager
14: from app.services.upload_service import start_cleanup
15: from app.services.monitoring_service import system_monitor
16: from app.patches.torch_load_patch import apply_patch
17: from app.core.audit_middleware import AuditMiddleware
▶ 第 10 行:导入路由集合 api_router(在 app/api/api.py,见本篇第 4 节)。
▶ 第 11 行:导入全局配置对象 settings(从 .env 读取,见本篇第 10 节)。
▶ 第 12~15 行:导入各个后台服务的单例:进程监控器、TensorBoard 管理器、上传清理器、系统监控器。
▶ 第 16 行:PyTorch 加载补丁(兼容不同 torch 版本加载模型)。
▶ 第 17 行:审计中间件(见本篇第 11 节)。
python
20: # 导入认证相关模块
21: from app.db.session import get_db
22: from app.api.deps import get_current_user
23: from app.models.user import User
24: logging_level = logging.DEBUG if settings.DEBUG else logging.INFO
25: logging.basicConfig(
26: level=logging_level,
27: format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
28: )
29: logger = logging.getLogger(__name__)
30: logger.info(f"应用启动模式: {'DEBUG' if settings.DEBUG else 'PRODUCTION'}")
▶ 第 24 行:根据 .env 的 DEBUG 开关决定日志级别(DEBUG 显示更详细日志)。
▶ 第 30 行:启动时打印当前模式,方便确认配置生效。
3.2 目录准备(第 31~42 行)
python
31: # 检查必要的目录
32: import os
33: for directory in [
34: "app/static/uploads",
35: "app/static/datasets",
36: "app/static/models",
37: "app/static/results",
38: "logs/tensorboard",
39: ]:
40: os.makedirs(directory, exist_ok=True)
41: logger.info(f"Ensured directory exists: {directory}")
▶ 第 33~40 行:确保这些目录存在(exist_ok=True = 已存在不报错)。uploads(上传临时文件)、datasets(数据集)、models(模型)、results(检测结果)、logs/tensorboard(训练日志)------这些目录是文件系统存储,和数据库存储互补(大文件放磁盘,元信息放数据库)。
3.3 创建应用(第 43~49 行)
python
43: app = FastAPI(
44: title=settings.PROJECT_NAME,
45: description="YOLOv8 Training and Detection Web Platform",
46: version="0.1.0",
47: )
48:
49: app.add_middleware(AuditMiddleware)
▶ 第 43~47 行:创建 FastAPI 应用实例 。title/description/version 会显示在自动生成的接口文档里(访问 http://localhost:9000/docs 可以看到)。
▶ 第 49 行:注册审计中间件(见本篇第 11 节)。
3.4 启动事件(第 51~139 行)
python
51: @app.on_event("startup")
52: async def startup_event():
53: logger.info("Application startup")
▶ 第 51~52 行:启动事件 ------服务器正式接收请求前执行一次。@app.on_event("startup") 是 FastAPI 的装饰器。async def = 异步函数(不阻塞主循环)。
python
55: try:
56: # 应用PyTorch加载补丁,确保使用weights_only=False
57: apply_patch()
58: logger.info("Applied PyTorch load patch: weights_only=False by default")
▶ 第 56~57 行:应用 torch 加载补丁(强制 weights_only=False,解决不同版本 torch 加载模型时的兼容问题------这是本项目踩过的坑,用补丁绕过去)。
python
60: # 检查数据库连接
61: from app.db.session import engine
62: from sqlalchemy import text
63: with engine.connect() as conn:
64: conn.execute(text("SELECT 1"))
65: logger.info("Database connection successful")
▶ 第 61 行:engine = 数据库连接引擎(见本篇第 9 节)。
▶ 第 63~64 行:with engine.connect() = 打开一个连接;text("SELECT 1") = 原生 SQL 语句,SELECT 1 是数据库世界的 1+1 测试------能执行说明连接正常。⚠️ 数据库没启动时,程序会在这里失败退出。
python
66: # 运行数据库迁移(添加 owner_id、is_password_changed 等新列)
67: from app.db.init_db import init_db
68: init_db()
69: logger.info("Database initialization and migration completed")
▶ 第 67~68 行:再次执行建表/迁移(run.py 也调过一次,这是双保险------直接 uvicorn 启动时也会执行)。
python
71: # 添加PyTorch安全全局变量
72: try:
73: import torch
74: # 兼容性: torch.serialization.add_safe_globals 在 torch 2.4+ 才引入
75: if not hasattr(torch.serialization, 'add_safe_globals'):
76: torch.serialization.add_safe_globals = lambda x: None
77: from torch.nn.modules.container import Sequential
78: from torch.nn import Module, ModuleList, ModuleDict
79: from ultralytics.nn.tasks import DetectionModel, SegmentationModel, ClassificationModel, PoseModel
80: from ultralytics.nn.modules import conv
81: from ultralytics.nn.modules import block
82: from ultralytics.nn.modules import head
83: torch.serialization.add_safe_globals([
84: Sequential, Module, ModuleList, ModuleDict,
85: DetectionModel, SegmentationModel, ClassificationModel, PoseModel,
86: conv.Conv
87: ])
88: logger.info("PyTorch and Ultralytics safe globals added successfully")
89: except ImportError as e:
90: logger.warning(f"Could not add PyTorch safe globals: {e}")
▶ 第 72~90 行:PyTorch 2.6 的"安全反序列化"白名单:加载模型文件(.pt)时,torch 只允许反序列化"白名单里的类"。这里把 Ultralytics 的模型类加进白名单,否则加载模型会报错。except ImportError = 没装 torch 的环境跳过(比如纯文档/测试环境)。
python
125: # 启动进程监控服务
126: process_monitor.start()
127: logger.info("Process monitor started")
128:
129: # 启动TensorBoard服务
130: if tensorboard_manager.start():
131: logger.info(f"TensorBoard started at {tensorboard_manager.get_url()}")
132: else:
133: logger.warning("Failed to start TensorBoard service")
134:
135: # 启动上传状态清理任务
136: start_cleanup()
137: logger.info("Upload status cleanup task started")
138: except Exception as e:
139: logger.error(f"Database connection failed: {e}")
▶ 第 126 行:启动进程监控线程(后台盯训练进程,见 06 篇)。
▶ 第 130 行:启动 TensorBoard(端口 6006)。
▶ 第 136 行:启动"清理过期上传记录"的定时任务。
▶ 第 138~139 行:以上任何一步失败都会被接住并记录(不会让服务器崩溃退出)。
3.5 CORS 与静态文件(第 141~156 行)
python
142: app.add_middleware(
143: CORSMiddleware,
144: allow_origins=["*"],
145: allow_credentials=True,
146: allow_methods=["*"],
147: allow_headers=["*"],
148: )
▶ 第 142~148 行:跨域配置。allow_origins=["*"] = 允许任何网站调用本 API。💡 安全说明:同源(前端/后端都在 localhost:9000)时 CORS 不参与;"*" 是开发便利,生产应限定域名。
python
151: app.mount("/static", StaticFiles(directory="app/static"), name="static")
▶ 第 151 行:静态文件映射 ------浏览器访问 http://localhost:9000/static/js/main.js 时,服务器直接返回 app/static/js/main.js 这个文件。前面 login.html 里的 <link href="static/libs/tabler.min.css"> 就是靠这行提供的。
python
154: logger.info(f"Static files directory: {os.path.abspath('app/static')}")
155: logger.info(f"CSS files: {os.listdir('app/static/css')}")
156: logger.info(f"JS files: {os.listdir('app/static/js')}")
▶ 第 155~156 行:启动时打印静态目录内容(便于排查文件缺失)。
3.6 错误处理(第 158~183 行)
python
159: @app.exception_handler(RequestValidationError)
160: async def validation_exception_handler(request: Request, exc: RequestValidationError):
161: logger.error(f"Validation error: {exc}")
162: return JSONResponse(
163: status_code=422,
164: content={"detail": str(exc)},
165: )
▶ 第 159~165 行:参数校验失败的全局处理器 。前端传的参数不符合 Pydantic 定义(如必填缺了、类型错了)时,返回 422 和错误详情,而不是 500。前端 .catch 里显示的"格式不对"提示大多来自这里。
python
167: @app.exception_handler(Exception)
168: async def general_exception_handler(request: Request, exc: Exception):
169: logger.error(f"Unhandled exception: {exc}", exc_info=True)
170: logger.error(f"Request path: {request.url.path}")
171: logger.error(f"Request method: {request.method}")
172: logger.error(f"Request headers: {request.headers}")
173: return JSONResponse(
174: status_code=500,
175: content={
176: "detail": "Internal server error",
177: "error": str(exc),
178: "path": request.url.path,
179: "method": request.method
180: },
181: )
▶ 第 167~181 行:未捕获异常的兜底 :打日志(含路径/方法/请求头,方便定位)并返回 500 JSON。⚠️ 第 177 行把错误详情直接返回给前端------开发方便,但生产环境会泄露内部信息,建议生产环境只返回 detail 不返回 error。
3.7 关闭事件(第 185~199 行)
python
186: @app.on_event("shutdown")
187: async def shutdown_event():
188: logger.info("Application shutdown")
189: process_monitor.stop()
190: if tensorboard_manager.stop():
191: logger.info("TensorBoard service stopped")
192: else:
193: logger.warning("Failed to stop TensorBoard service")
▶ 第 186~193 行:服务器关闭时停掉后台进程监控和 TensorBoard(清理资源)。
3.8 路由注册与页面路由(第 200~262 行)
python
201: app.include_router(api_router, prefix="/api")
▶ 第 201 行:注册所有 API 路由 ,统一挂在 /api 前缀下。api_router 来自 app/api/api.py(见本篇第 4 节)。所以接口地址都是 /api/xxx。
python
204: @app.get("/css/{file_path:path}", include_in_schema=False)
205: async def get_css(file_path: str):
206: return RedirectResponse(url=f"/static/css/{file_path}")
207:
209: @app.get("/js/{file_path:path}", include_in_schema=False)
210: async def get_js(file_path: str):
211: return RedirectResponse(url=f"/static/js/{file_path}")
▶ 第 204~211 行:兼容路由------旧版本页面引用的 /css/xxx 和 /js/xxx 地址自动重定向到 /static/css/xxx。{file_path:path} = 匹配任意路径段;include_in_schema=False = 不显示在接口文档里(内部兼容接口)。
python
214: @app.get("/favicon.ico", include_in_schema=False)
215: async def get_favicon():
216: return FileResponse("app/static/favicon.ico")
▶ 第 214~216 行:返回浏览器标签页小图标。
python
218: @app.get("/", response_class=HTMLResponse)
219: async def root(request: Request):
220: # 检查请求头中是否有Authorization
221: auth_header = request.headers.get("Authorization")
222: if auth_header:
223: try:
224: db = next(get_db())
225: user = get_current_user(db=db, token=auth_header.replace("Bearer ", ""))
226: if user:
227: with open("app/static/index.html", "r", encoding="utf-8") as f:
228: html_content = f.read()
229: return HTMLResponse(content=html_content)
230: except Exception:
231: pass
232:
233: # 检查Cookie中是否有token(备用认证方式)
234: cookies = request.cookies
235: if "access_token" in cookies:
236: try:
237: db = next(get_db())
238: user = get_current_user(db=db, token=cookies["access_token"])
239: if user:
240: with open("app/static/index.html", "r", encoding="utf-8") as f:
241: html_content = f.read()
242: return HTMLResponse(content=html_content)
243: except Exception:
244: pass
245:
246: # 没有认证信息或认证失败,重定向到登录页面
247: return RedirectResponse(url="/login")
▶ 第 219 行:主页路由 。访问 http://localhost:9000/ 时执行。
▶ 第 221~231 行:方式一------请求头里带 Authorization 令牌(前端 authenticatedFetch 方式):解析用户成功就返回主页面 HTML(index.html 文件内容)。
▶ 第 224 行:next(get_db()) ------ get_db 是生成器(见本篇 9 节),next() 手动取第一个值 = 拿到数据库会话。
▶ 第 234~244 行:方式二------Cookie 里有令牌(01 篇登录时存了 Cookie):同样验证,成功返回主页面。
▶ 第 247 行:两种方式都失败(未登录)→ 重定向到 /login。
💡 为什么第 225 行要 .replace("Bearer ", "")?------Authorization 头格式是 Bearer xxx,而 get_current_user 的 token 参数需要纯令牌,所以去掉前缀。
python
251: @app.get("/login", response_class=HTMLResponse)
252: async def login_page():
253: try:
254: with open("app/static/login.html", "r", encoding="utf-8") as f:
255: html_content = f.read()
256: return HTMLResponse(content=html_content)
257: except Exception as e:
258: logger.error(f"Error loading login.html: {e}")
259: return HTMLResponse(content="<h1>Error loading login page</h1>...", status_code=500)
▶ 第 251~259 行:/login 路由------读取 login.html 文件内容作为响应返回(01 篇讲解的那个页面)。
四、app/api/api.py 逐行讲解(29 行)
所有 API 路由的"总注册表"。每个功能模块在这里挂载 + 声明认证要求。
python
1: from fastapi import APIRouter, Depends
2:
3: from app.api.deps import get_current_active_user, get_current_admin_user
4: from app.api.endpoints import auth, datasets, models, training, detection, opencv, video, tracking, streaming, annotation, settings, tools, sync_detection, monitoring, comparison, sam3, audit, algorithms, counting
▶ 第 3 行:导入两个认证依赖(get_current_active_user = 登录即可,get_current_admin_user = 必须管理员)。
▶ 第 4 行:导入全部 19 个功能模块的路由对象。
python
6: api_router = APIRouter()
▶ 第 6 行:创建总路由对象(一个空的"容器")。
python
9: api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
▶ 第 9 行:注册认证模块,URL 前缀 /auth。⚠️ 没有加认证依赖------登录接口本身当然不能要求已登录!
python
12: api_router.include_router(sam3.router, prefix="/sam3", tags=["sam3"], dependencies=[Depends(get_current_active_user)])
13: api_router.include_router(datasets.router, prefix="/datasets", tags=["datasets"], dependencies=[Depends(get_current_active_user)])
...(每个模块一行,结构相同)
27: api_router.include_router(audit.router, prefix="/audit-logs", tags=["audit-logs"], dependencies=[Depends(get_current_admin_user)])
▶ 第 12~26 行:其余功能模块统一加 dependencies=[Depends(get_current_active_user)] ------登录是硬门槛 :未登录请求直接 401,请求根本进不到业务代码。这就是"全局认证"的实现------不用每个接口单独写判断。
▶ 第 27 行:审计日志模块要求管理员(普通用户看不了操作记录)。
五、app/api/deps.py 逐行讲解(122 行)------ 认证核心
这是整个项目安全体系的心脏:签发令牌、解析令牌、查用户、查权限。
5.1 配置与工具(第 1~34 行)
python
1: from typing import Optional
2: from datetime import datetime, timedelta
3: from fastapi import Depends, HTTPException, Query, status
4: from fastapi.security import OAuth2PasswordBearer
5: from jose import JWTError, jwt
6: from sqlalchemy.orm import Session
7: import uuid
8:
9: from app.core.config import settings
10: from app.crud.user import get_user_by_username
11: from app.db.session import get_db
12: from app.models.user import User, UserRole
13: from app.models.revoked_token import RevokedToken
14: from app.schemas.user import TokenData
▶ 第 5 行:jose 库 = 处理 JWT 令牌(jwt.encode 签发、jwt.decode 解析、JWTError 错误类)。
▶ 第 7 行:uuid = 生成全局唯一 ID(令牌的 jti 字段用)。
▶ 第 12 行:UserRole = 角色枚举(admin / operator),见本篇第 7 节。
python
16: SECRET_KEY = settings.SECRET_KEY
17: if not SECRET_KEY:
18: raise RuntimeError("SECRET_KEY must be configured in .env or environment variables")
19:
20: ALGORITHM = "HS256"
21: ACCESS_TOKEN_EXPIRE_MINUTES = 30
▶ 第 16 行:从配置读签名密钥 (.env 里的 SECRET_KEY)。
▶ 第 17~18 行:没有密钥直接抛异常拒绝启动(防止裸奔)。💡 SECRET_KEY 是令牌防伪的关键:令牌是用它"盖章"的,攻击者不知道密钥就伪造不了令牌。
▶ 第 20 行:HS256 = 签名算法(HMAC-SHA256,对称加密,签发和验证用同一个密钥)。
▶ 第 21 行:令牌有效期 30 分钟。
python
23: oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
▶ 第 23 行:定义"从请求头提取令牌"的工具。tokenUrl = 告诉前端"没令牌去哪个接口登录";auto_error=False = 没有令牌时不自动报错(返回 None,由我们自己决定怎么处理------因为还要支持 URL 参数带令牌的方式)。
5.2 签发令牌(第 25~34 行)
python
25: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
26: to_encode = data.copy()
27: to_encode["jti"] = str(uuid.uuid4())
28: if expires_delta:
29: expire = datetime.utcnow() + expires_delta
30: else:
31: expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
32: to_encode.update({"exp": expire})
33: encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
34: return encoded_jwt
▶ 第 25 行:签发令牌函数。data = 要写进令牌的信息(如 {"sub": "admin"}),expires_delta = 自定义有效期。
▶ 第 26 行:data.copy() = 复制一份(不修改调用方的字典)。
▶ 第 27 行:jti(JWT ID)= 令牌的唯一编号。用途:注销令牌 ------服务器把 jti 记入吊销表,之后这个令牌即使没过期也作废(见 5.3)。
▶ 第 28~31 行:算过期时间:datetime.utcnow()(当前 UTC 时间)+ 有效期。
▶ 第 32 行:把 exp(expire,过期时间)写进令牌数据。⚠️ exp 是 JWT 标准字段,解析时 jose 库会自动检查过期。
▶ 第 33 行:jwt.encode = 用密钥签名并生成令牌字符串。
▶ 第 34 行:返回 eyJhbGciOi... 开头的令牌字符串(三段:头.载荷.签名)。
5.3 解析令牌(第 36~74 行)
python
36: def get_current_user(
37: db: Session = Depends(get_db),
38: token: Optional[str] = Depends(oauth2_scheme),
39: query_token: Optional[str] = Query(None, alias="token")
40: ) -> User:
▶ 第 36~40 行:认证依赖的核心函数。三个参数都是"自动注入"的:
db:数据库会话token:从请求头Authorization: Bearer xxx提取的令牌(Depends(oauth2_scheme))query_token:从 URL 查询参数?token=xxx提取(01 篇appendToken就是为这个)
▶ 返回值标注-> User:成功返回用户对象,失败抛 HTTPException。
python
41: access_token = token or query_token
42: if not access_token:
43: raise HTTPException(
44: status_code=status.HTTP_401_UNAUTHORIZED,
45: detail="Could not validate credentials",
46: headers={"WWW-Authenticate": "Bearer"},
47: )
▶ 第 41 行:两种来源二选一(请求头优先)。
▶ 第 42~47 行:都没有 → 抛 401。headers={"WWW-Authenticate": "Bearer"} = 告诉客户端"请用 Bearer 方式认证"(标准做法)。
python
53: try:
54: payload = jwt.decode(access_token, SECRET_KEY, algorithms=[ALGORITHM])
55: username: str = payload.get("sub")
56: jti: str = payload.get("jti")
57: if username is None:
58: raise credentials_exception
59: token_data = TokenData(username=username)
60: except JWTError:
61: raise credentials_exception
▶ 第 54 行:解密并验证令牌 :签名对不对(防伪造)、过期没有(防重放)。失败抛 JWTError。
▶ 第 55 行:取出 sub 字段(签发时写入的用户名)。
▶ 第 56 行:取出 jti(唯一编号)。
▶ 第 57~58 行:没有用户名 → 拒绝。
▶ 第 60~61 行:任何 JWT 解析失败(过期/伪造/格式错)→ 拒绝(401)。
python
63: revoked = db.query(RevokedToken).filter(RevokedToken.jti == jti).first()
64: if revoked:
65: raise HTTPException(
66: status_code=status.HTTP_401_UNAUTHORIZED,
67: detail="Token has been revoked",
68: headers={"WWW-Authenticate": "Bearer"},
69: )
▶ 第 63 行:查吊销表 ------用 jti 查 revoked_tokens 表,判断这个令牌是不是已被注销(注销操作见 auth.py 的 /logout 接口)。
▶ 第 64~69 行:已注销 → 401。
python
71: user = get_user_by_username(db, username=token_data.username)
72: if user is None:
73: raise credentials_exception
74: return user
▶ 第 71 行:用令牌里的用户名查数据库拿用户对象。
▶ 第 72~73 行:用户不存在(被删了)→ 拒绝。
▶ 第 74 行:认证通过,返回用户对象 。之后业务代码就能用 current_user.username、current_user.role 了。
5.4 权限分级(第 76~122 行)
python
76: def get_current_active_user(
77: current_user: User = Depends(get_current_user)
78: ) -> User:
79: if not current_user.is_active:
80: raise HTTPException(
81: status_code=status.HTTP_400_BAD_REQUEST,
82: detail="Inactive user"
83: )
84: return current_user
▶ 第 76~84 行:在"已登录"基础上再检查 is_active(账号是否被禁用)。这就是 api.py 里所有模块用的依赖------依赖里套依赖,层层把关。
python
86: def get_current_admin_user(
87: current_user: User = Depends(get_current_active_user)
88: ) -> User:
89: if current_user.role != UserRole.ADMIN:
90: raise HTTPException(
91: status_code=status.HTTP_403_FORBIDDEN,
92: detail="Not enough permissions"
93: )
94: return current_user
▶ 第 86~94 行:管理员专属依赖:先通过"已登录且启用",再检查角色是 admin,否则 403。审计日志接口就用它。
python
96: _OPERATOR_PERMISSIONS = {
97: ("datasets", "read"), ("datasets", "write"),
98: ("models", "read"), ("models", "write"),
...
107: ("monitoring", "read"),
108: }
▶ 第 96~108 行:普通用户(operator)白名单:允许操作哪些"资源 × 动作"。每个元组 = 一种权限。管理员不受此限制。
python
110: def require_permission(resource: str, action: str):
111: def permission_checker(
112: current_user: User = Depends(get_current_active_user)
113: ) -> User:
114: if current_user.role == UserRole.ADMIN:
115: return current_user
116: if (resource, action) not in _OPERATOR_PERMISSIONS:
117: raise HTTPException(
118: status_code=status.HTTP_403_FORBIDDEN,
119: detail=f"Operation not permitted: {action} on {resource}"
120: )
121: return current_user
122: return permission_checker
▶ 第 110~122 行:细粒度权限检查器(工厂函数:调用一次,返回一个带参数记忆的检查函数)。
- 用法示例:某个接口加
Depends(require_permission("datasets", "write"))就只允许"有数据集写权限"的人 - 第 114~115 行:管理员直接放行
- 第 116~120 行:普通用户不在白名单 → 403
六、app/api/endpoints/auth.py 逐行讲解(254 行)
认证接口模块:登录、验证令牌、注销、改密码、用户 CRUD。
6.1 导入与辅助(第 1~39 行)
python
8: from app.api.deps import (
9: ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY,
10: create_access_token, get_current_active_user,
11: get_current_admin_user, oauth2_scheme,
12: )
17: from app.crud.user import (
18: authenticate_user, create_user, delete_user, get_user,
19: get_users, update_user, get_user_by_username, verify_password
20: )
27: from app.db.session import get_db
28: from app.models.user import User, UserRole
29: from app.models.revoked_token import RevokedToken
30: from app.schemas.user import User, UserCreate, UserUpdate, Token
31: from jose import jwt, JWTError
32: from pydantic import BaseModel
33:
34: router = APIRouter()
▶ 第 34 行:创建本模块的路由对象(api.py 第 9 行挂载它)。
python
37: class PasswordUpdate(BaseModel):
38: current_password: str
39: new_password: str
▶ 第 37~39 行:定义"改密码"请求的 JSON 结构(Pydantic 模型)。前端发 {"current_password": "...", "new_password": "..."} 时,FastAPI 自动校验并填充。
6.2 登录接口(第 41~68 行)
python
41: @router.post("/token", response_model=Token)
42: async def login_for_access_token(
43: form_data: OAuth2PasswordRequestForm = Depends(),
44: db: Session = Depends(get_db)
45: ):
46: """用户登录,获取访问令牌"""
▶ 第 41 行:路由装饰器------处理 POST /api/auth/token(URL 前缀 /auth + 这里 /token)。
response_model=Token:声明响应格式 ------返回前按Token模式(schemas/user.py 第 41 行)校验/过滤,保证前端拿到固定结构。
▶ 第 43 行:OAuth2PasswordRequestForm= FastAPI 内置的表单解析器,自动从请求体解析username和password两个字段。⚠️ 所以前端(01 篇第 84 行)必须用application/x-www-form-urlencoded格式发送,不能用 JSON。
▶ 第 44 行:注入数据库会话。
python
47: user = authenticate_user(db, form_data.username, form_data.password)
48: if not user:
49: raise HTTPException(
50: status_code=status.HTTP_401_UNAUTHORIZED,
51: detail="Incorrect username or password",
52: headers={"WWW-Authenticate": "Bearer"},
53: )
▶ 第 47 行:校验用户名密码 (函数在 crud/user.py,见本篇第 8 节)。
▶ 第 48~53 行:校验失败 → 401 + 统一错误文案 "Incorrect username or password"。💡 故意不告诉用户"是用户名错了还是密码错了",防止攻击者探测用户名是否存在。
python
55: # 更新用户最后登录时间
56: user.last_login = datetime.utcnow()
57: db.commit()
▶ 第 56~57 行:更新数据库里该用户的 last_login 字段为当前时间(这个字段在"用户管理"页面显示)。
python
59: access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
60: access_token = create_access_token(
61: data={"sub": user.username},
62: expires_delta=access_token_expires
63: )
64: return {
65: "access_token": access_token,
66: "token_type": "bearer",
67: "must_change_password": not user.is_password_changed
68: }
▶ 第 59~63 行:签发令牌。data={"sub": user.username}------sub(subject)= 标准字段,放用户名;30 分钟有效。
▶ 第 64~68 行:返回三件套:
access_token:令牌本体(前端存 localStorage)token_type: "bearer":令牌类型must_change_password: not user.is_password_changed:是否必须改密码 ------首次创建的 admin 用户is_password_changed=False,前端据此弹出强制改密码框。
6.3 验证令牌接口(第 70~75 行)
python
70: @router.post("/validate-token")
71: async def validate_token(
72: current_user: User = Depends(get_current_active_user)
73: ):
74: """验证访问令牌的有效性"""
75: return {"valid": True, "username": current_user.username, "role": current_user.role}
▶ 第 70~75 行:登录页 checkLoginStatus()(01 篇第 137 行)调用的接口。函数体只有一行 return ------因为 Depends(get_current_active_user) 已经把认证做完了:能走到这里说明令牌有效且用户可用。拿不到 valid 就说明令牌无效(返回 401)。
6.4 注销接口(第 77~94 行)
python
77: @router.post("/logout")
78: async def logout(
79: current_user: User = Depends(get_current_active_user),
80: db: Session = Depends(get_db),
81: token: str = Depends(oauth2_scheme)
82: ):
83: try:
84: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
85: jti = payload.get("jti")
86: exp = payload.get("exp")
87: if jti:
88: expires_at = datetime.fromtimestamp(exp) if exp else None
89: revoked = RevokedToken(jti=jti, username=current_user.username, expires_at=expires_at)
90: db.add(revoked)
91: db.commit()
92: except JWTError:
93: pass
94: return {"message": "Successfully logged out"}
▶ 第 84 行:解析当前令牌(已通过认证,理论不会失败)。
▶ 第 85 行:取 jti(令牌唯一编号)。
▶ 第 87~91 行:把 jti 记入吊销表 revoked_tokens------之后 deps.py 第 63 行查到它,令牌即刻作废。这就是"让令牌提前失效"的机制(JWT 本身只能等过期,吊销表让它能立即作废)。
▶ 第 92~93 行:解析失败(比如令牌已过期)就静默跳过------反正都要"登出成功"。
💡 本项目前端 logout()(01 篇)只清了本地,没调这个接口;如果调用,令牌会立即失效,更安全。
6.5 用户 CRUD 接口(第 96~254 行)
python
96: @router.post("/users", response_model=User)
97: async def create_new_user(
98: user: UserCreate,
99: db: Session = Depends(get_db),
100: current_user: User = Depends(get_current_admin_user)
101: ):
102: """创建新用户(管理员权限)"""
103: # 检查用户名是否已存在
104: db_user = get_user_by_username(db, username=user.username)
105: if db_user:
106: raise HTTPException(
107: status_code=status.HTTP_400_BAD_REQUEST,
108: detail="Username already registered"
109: )
110: return create_user(db=db, user=user)
▶ 第 96~101 行:创建用户接口。user: UserCreate = 自动把请求 JSON 解析并校验成 UserCreate 对象(密码长度 6~255、两次密码一致等校验都在 schemas 里,见本篇第 7 节);Depends(get_current_admin_user) = 只有管理员能调 。
▶ 第 104~109 行:先查重名,存在 → 400。
▶ 第 110 行:调 crud 创建(密码在 crud 里做哈希,见第 8 节)。
python
113: @router.get("/users/me", response_model=User)
114: async def read_users_me(
115: current_user: User = Depends(get_current_active_user)
116: ):
117: """获取当前用户信息"""
118: return current_user
▶ 第 113~118 行:main.js getCurrentUser()(01 篇第 44 行)调用的接口------返回当前登录用户(response_model=User 会过滤掉 password 字段,见 schemas 的 User 类)。
python
120: @router.put("/users/me/password")
121: async def update_current_user_password(
122: password_update: PasswordUpdate,
123: db: Session = Depends(get_db),
124: current_user: User = Depends(get_current_active_user)
125: ):
129: # 验证当前密码是否正确
130: if not verify_password(password_update.current_password, current_user.password):
131: raise HTTPException(
132: status_code=status.HTTP_401_UNAUTHORIZED,
133: detail="Current password is incorrect"
134: )
136: # 创建UserUpdate对象,包含当前用户的username、role和新密码
137: user_update = UserUpdate(
138: username=current_user.username,
139: role=current_user.role,
140: password=password_update.new_password
141: )
144: updated_user = update_user(db=db, db_user=current_user, user=user_update)
146: return {"message": "Password updated successfully"}
▶ 第 130 行:验证当前密码 (防止别人拿你电脑改你密码)。verify_password = 哈希比对(见第 8 节)。
▶ 第 137~141 行:组装更新对象(保留原 username/role,只改密码)。
▶ 第 144 行:调 crud 更新(内部会把新密码哈希)。
python
148: @router.post("/change-password")
149: async def change_password(...):
154: if not verify_password(password_update.current_password, current_user.password):
...
164: update_user(db=db, db_user=current_user, user=user_update)
165: current_user.is_password_changed = True
166: db.commit()
167: return {"message": "Password changed successfully"}
▶ 第 148~167 行:首次登录强制改密 接口------与上一个的区别:改完后把 is_password_changed 置为 True(下次登录 must_change_password 就变 false 了)。
python
169: @router.get("/users", response_model=List[User])
170: async def read_users(
171: skip: int = 0,
172: limit: int = 100,
...
177: users = get_users(db, skip=skip, limit=limit)
178: return users
▶ 第 169~178 行:用户列表接口(管理员)。skip/limit = 分页参数(跳过 N 条、最多取 M 条),前端用户管理页传 ?skip=0&limit=100。
python
180: @router.get("/users/{user_id}", response_model=User)
181: async def read_user(
182: user_id: int,
...
190: db_user = get_user(db, user_id=user_id)
191: if db_user is None:
192: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
197: # 检查权限
198: if current_user.role != UserRole.ADMIN and db_user.id != current_user.id:
199: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
204: return db_user
▶ 第 180~204 行:查看单个用户。💡 权限逻辑值得细看:普通用户只能看自己(第 198 行),管理员看所有人。这行就是 RBAC 的具体体现。
python
206: @router.put("/users/{user_id}", response_model=User)
207: async def update_existing_user(
...
214: db_user = get_user(db, user_id=user_id)
215: if db_user is None:
216: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, ...)
222: # 如果更新了用户名,检查新用户名是否已存在
223: if user.username and user.username != db_user.username:
224: existing_user = get_user_by_username(db, username=user.username)
225: if existing_user:
226: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, ...)
230: return update_user(db=db, db_user=db_user, user=user)
▶ 第 206~230 行:更新用户(管理员)。改名时检查重名(第 223~228 行)。
python
232: @router.delete("/users/{user_id}")
233: async def delete_existing_user(
...
240: # 不允许删除当前登录的管理员用户
241: if current_user.id == user_id:
242: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete current admin user")
246: db_user = get_user(db, user_id=user_id)
247: if db_user is None:
248: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, ...)
253: delete_user(db=db, db_user=db_user)
254: return {"message": "User deleted successfully"}
▶ 第 240~242 行:不能删除自己(防止管理员把自己账号删了导致系统无人管理)。
七、数据"图纸":models/user.py + schemas/user.py 逐行讲解
7.1 app/models/user.py(22 行)------ 数据库表图纸
python
1: from datetime import datetime
2: from enum import Enum
3: from sqlalchemy import Column, Integer, String, DateTime, Boolean
4:
5: from app.db.session import Base
▶ 第 3 行:从 SQLAlchemy 导入"列类型":Column = 一列,Integer = 整型,String(n) = 变长文本,DateTime = 时间,Boolean = 布尔。
▶ 第 5 行:Base = ORM 基类(见本篇第 9 节)。
python
7: class UserRole(str, Enum):
8: ADMIN = "admin"
9: OPERATOR = "operator"
▶ 第 7~9 行:角色枚举。str, Enum = 既是字符串又是枚举(这样可以直接和字符串比较)。两种角色:admin(管理员)、operator(操作员/普通用户)。
python
11: class User(Base):
12: __tablename__ = "users"
▶ 第 11~12 行:定义 User 类 = users 表。一个类 = 一张表。
python
14: id = Column(Integer, primary_key=True, index=True, autoincrement=True)
15: username = Column(String(50), unique=True, nullable=False, index=True)
16: password = Column(String(255), nullable=False)
17: role = Column(String(20), nullable=False, default=UserRole.OPERATOR.value)
18: is_active = Column(Boolean, default=True)
19: created_at = Column(DateTime, default=datetime.utcnow)
20: updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
21: last_login = Column(DateTime, nullable=True)
22: is_password_changed = Column(Boolean, default=False)
▶ 第 14 行:主键,自增(1,2,3...),带索引。
▶ 第 15 行:用户名------unique=True = 数据库层保证不重名;nullable=False = 必填。
▶ 第 16 行:密码列存的是哈希 (255 长度够放 pbkdf2 哈希),不是明文。⚠️ 重要安全点。
▶ 第 17 行:角色列,默认 operator。
▶ 第 18 行:是否启用,默认启用。
▶ 第 20 行:onupdate=datetime.utcnow = 每次更新行时自动刷新时间戳 (SQLAlchemy 特性)。
▶ 第 22 行:首次登录强制改密标记(默认 False = 未改过)。
7.2 app/models/revoked_token.py(13 行)
python
6: class RevokedToken(Base):
7: __tablename__ = "revoked_tokens"
9: id = Column(Integer, primary_key=True, index=True, autoincrement=True)
10: jti = Column(String(255), nullable=False, unique=True, index=True)
11: username = Column(String(50), nullable=True)
12: expires_at = Column(DateTime, nullable=True)
13: created_at = Column(DateTime, default=datetime.utcnow)
▶ 第 10 行:jti = 被吊销的令牌编号(唯一索引,查询快)。
▶ 第 11~12 行:记录是谁的令牌、原过期时间(便于后期清理)。
7.3 app/schemas/user.py(47 行)------ 接口数据"翻译官"
python
8: class UserBase(BaseModel):
9: username: str = Field(..., min_length=3, max_length=50)
10: role: UserRole
▶ 第 8~10 行:公共字段。Field(..., min_length=3, max_length=50) = 必填(... 表示必填)且长度 3~50。违反这些规则 FastAPI 自动返回 422,不用手写校验。
python
12: class UserCreate(UserBase):
13: password: str = Field(..., min_length=6, max_length=255)
14: confirm_password: str
15:
16: @validator('confirm_password')
17: def passwords_match(cls, v, values, **kwargs):
18: if 'password' in values and v != values['password']:
19: raise ValueError('Passwords do not match')
20: return v
▶ 第 12~14 行:创建用户请求模型(继承 UserBase,加上密码字段)。confirm_password = 确认密码。
▶ 第 16~20 行:@validator = 自定义校验器------确认密码与密码不一致时抛 ValueError(FastAPI 转成 422)。
python
22: class UserUpdate(UserBase):
23: password: Optional[str] = Field(None, min_length=6, max_length=255)
24: is_active: Optional[bool] = None
▶ 第 22~24 行:更新模型------密码/启用状态可省略(Optional + 默认 None)。
python
26: class User(UserBase):
27: id: int
28: is_active: bool
29: is_password_changed: bool = False
30: created_at: datetime
31: updated_at: datetime
32: last_login: Optional[datetime] = None
33:
34: class Config:
35: from_attributes = True
▶ 第 26~35 行:响应模型 ------前端拿到的用户长这样。注意:没有 password 字段 ,所以 response_model=User 时密码永远不会发给前端。
▶ 第 34~35 行:from_attributes = True = 允许从 ORM 对象直接转换(数据库 User 对象 → 这个响应模型)。
python
37: class UserLogin(BaseModel):
38: username: str
39: password: str
40:
41: class Token(BaseModel):
42: access_token: str
43: token_type: str = "bearer"
44: must_change_password: bool = False
45:
46: class TokenData(BaseModel):
47: username: Optional[str] = None
▶ 第 41~44 行:登录响应格式(auth.py 第 41 行的 response_model=Token)。
▶ 第 46~47 行:令牌解析出的信息容器(deps.py 第 59 行用)。
八、app/crud/user.py 逐行讲解(74 行)------ 数据库操作
crud = Create/Read/Update/Delete,数据层。这一层的职责:只管数据库,不做业务判断。
python
1: from typing import Optional, List
2: from sqlalchemy.orm import Session
3: from passlib.context import CryptContext
▶ 第 3 行:passlib = 密码哈希库。
python
8: # 密码加密上下文
9: # 使用pbkdf2_sha256替代bcrypt,以避免密码长度限制问题
10: pwd_context = CryptContext(
11: schemes=["pbkdf2_sha256"],
12: deprecated="auto",
13: pbkdf2_sha256__default_rounds=29000
14: )
▶ 第 10~14 行:配置密码哈希方案:pbkdf2_sha256(加盐 + 29000 轮迭代的哈希算法)。💡 为什么不用更常见的 bcrypt?------注释说了:bcrypt 有 72 字节密码长度限制,pbkdf2 没有。
▶ deprecated="auto" = 旧方案自动提示升级;__default_rounds=29000 = 哈希计算迭代次数(越大越慢越安全)。
python
16: def verify_password(plain_password: str, hashed_password: str) -> bool:
17: """验证密码是否匹配"""
18: return pwd_context.verify(plain_password, hashed_password)
▶ 第 16~18 行:密码比对 :pwd_context.verify 会重新计算哈希并与存储值比对。你永远无法从哈希反推出密码,只能"重算再比"。
python
20: def get_password_hash(password: str) -> str:
21: """生成密码哈希"""
22: return pwd_context.hash(password)
▶ 第 20~22 行:生成哈希。同一个人同密码,每次生成的哈希都不一样(随机盐),防止"彩虹表"攻击。
python
24: def get_user(db: Session, user_id: int) -> Optional[User]:
25: """根据ID获取用户"""
26: return db.query(User).filter(User.id == user_id).first()
▶ 第 26 行:SQLAlchemy 查询语法拆解:
db.query(User):查 users 表(生成 SQL:SELECT * FROM users).filter(User.id == user_id):加 WHERE 条件(WHERE id = ?).first():取第一条(LIMIT 1),没有则返回 None
python
28: def get_user_by_username(db: Session, username: str) -> Optional[User]:
29: """根据用户名获取用户"""
30: return db.query(User).filter(User.username == username).first()
▶ 第 30 行:同上,按用户名查。登录认证和查重都靠它。
python
32: def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]:
33: """获取用户列表"""
34: return db.query(User).offset(skip).limit(limit).all()
▶ 第 34 行:分页查询:offset(skip) = 跳过前 N 条,limit(limit) = 最多取 M 条,.all() = 取全部结果(列表)。
python
36: def create_user(db: Session, user: UserCreate) -> User:
37: """创建新用户"""
38: hashed_password = get_password_hash(user.password)
39: db_user = User(
40: username=user.username,
41: password=hashed_password,
42: role=user.role,
43: )
44: db.add(db_user)
45: db.commit()
46: db.refresh(db_user)
47: return db_user
▶ 第 38 行:先把密码哈希 (存库前必须哈希!)。
▶ 第 39~43 行:用哈希密码创建 User 对象。
▶ 第 44~46 行:db.add() = 加入会话(还没入库)→ db.commit() = 真正写入数据库 → db.refresh() = 从数据库重新读一遍(拿到自增的 id 等)。
▶ 第 47 行:返回带 id 的用户对象。
python
49: def update_user(db: Session, db_user: User, user: UserUpdate) -> User:
50: """更新用户信息"""
51: update_data = user.dict(exclude_unset=True)
52:
53: # 如果提供了新密码,进行哈希处理
54: if "password" in update_data:
55: update_data["password"] = get_password_hash(update_data["password"])
56:
57: for key, value in update_data.items():
58: setattr(db_user, key, value)
59:
60: db.commit()
61: db.refresh(db_user)
62: return db_user
▶ 第 51 行:user.dict(exclude_unset=True) = 只取调用方明确传了的字段 (没传的不覆盖,实现"部分更新")。
▶ 第 54~55 行:传了密码就哈希。
▶ 第 57~58 行:setattr(对象, 属性名, 值) = 动态给对象属性赋值(等价于 db_user.username = ...,只是属性名是循环变量)。
▶ 第 60~61 行:提交 + 刷新。
python
64: def delete_user(db: Session, db_user: User) -> None:
65: """删除用户"""
66: db.delete(db_user)
67: db.commit()
▶ 第 66~67 行:删除一行。
python
69: def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
70: """用户认证"""
71: user = get_user_by_username(db, username)
72: if not user or not verify_password(password, user.password):
73: return None
74: return user
▶ 第 71~74 行:登录认证的核心:按用户名查用户 → 用户不存在或密码不匹配 → 返回 None;都通过 → 返回用户。auth.py 第 47 行就是调它。
九、app/db/session.py 逐行讲解(47 行)------ 数据库连接
python
1: import logging
2: from fastapi import HTTPException
3: from sqlalchemy import create_engine, event
4: from sqlalchemy.ext.declarative import declarative_base
5: from sqlalchemy.orm import sessionmaker
6: from sqlalchemy.engine import Engine
7:
8: from app.core.config import settings
▶ 第 3~6 行:SQLAlchemy 核心组件。
python
16: try:
17: engine = create_engine(
18: str(settings.SQLALCHEMY_DATABASE_URI),
19: pool_pre_ping=True, # 测试连接是否有效
20: echo=False # 设置为 True 可以看到详细的 SQL 语句
21: )
22: logger.info("Database engine created successfully")
23: except Exception as e:
24: logger.error(f"Failed to create database engine: {e}", exc_info=True)
25: raise
▶ 第 17~21 行:创建连接引擎(连接池的管理者):
settings.SQLALCHEMY_DATABASE_URI= 连接串,如postgresql://postgres:密码@localhost/yolov8_platform(见第 10 节怎么拼出来)pool_pre_ping=True= 每次从池里取连接前先 ping 一下(连接断了自动换新的,避免"僵尸连接")echo=False= 不打印 SQL 日志;💡 想学习数据库操作?改成True,重启后控制台会打印每条 SQL------强烈推荐新手开着看一遍再改回来。
python
28: SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
▶ 第 28 行:创建"会话工厂"------SessionLocal() 会生成一个数据库会话。autocommit=False = 不自动提交(要手动 db.commit(),配合事务);autoflush=False = 查询前不自动刷新。
python
31: Base = declarative_base()
▶ 第 31 行:ORM 基类 ------所有 models/*.py 里的表类都继承它(class User(Base))。它负责把类映射成表。
python
33: # 依赖项
34: def get_db():
35: db = SessionLocal()
36: try:
37: yield db
38: except HTTPException:
39: # 业务异常(认证/权限/路由等)非数据库错误,原样抛出避免误记日志
40: raise
41: except Exception as e:
42: logger.error(f"Database session error: {e}", exc_info=settings.DEBUG)
43: raise
44: finally:
45: db.close()
▶ 第 34~45 行:FastAPI 依赖函数 (Depends(get_db) 用的):
- 第 35 行:创建会话
- 第 37 行:
yield db= 把会话交给请求处理流程 。注意yield是生成器------执行到这里"暂停并交出值",请求处理完后再回来执行后面的代码 - 第 38~43 行:异常处理(HTTPException 是业务错误原样抛;其他错误记日志再抛)
- 第 44~45 行:
finally= 无论成功失败都会执行:关闭会话。这就是"用完必关"的保证。
十、app/core/config.py 逐行讲解(81 行)------ 配置中心
python
8: class Settings(BaseSettings):
9: PROJECT_NAME: str = "YOLOv8 Training Platform"
10: API_V1_STR: str = "/api/v1"
11: DEBUG: bool = False # 全局DEBUG模式开关
12: SECRET_KEY: str = "" # JWT签名密钥,必须从.env配置
▶ 第 8~12 行:配置类。BaseSettings = Pydantic 的配置基类------能自动从环境变量和 .env 文件读取 。字段名 = 环境变量名(如 .env 里的 SECRET_KEY=xxx 自动读进 settings.SECRET_KEY)。
▶ 第 12 行:SECRET_KEY 默认空------deps.py 第 17 行检查到空会拒绝启动,强制要求配置。
python
14: # Database
15: POSTGRES_SERVER: str = "localhost"
16: POSTGRES_USER: str = "postgres"
17: POSTGRES_PASSWORD: str = "postgres"
18: POSTGRES_DB: str = "yolov8_platform"
19: SQLALCHEMY_DATABASE_URI: Optional[PostgresDsn] = None
▶ 第 15~19 行:数据库连接参数(默认值匹配 README 的"三步启动")。PostgresDsn = Pydantic 的 Postgres 连接串类型(自动校验格式)。
python
21: @field_validator("SQLALCHEMY_DATABASE_URI", mode="before")
22: def assemble_db_connection(cls, v: Optional[str], info) -> Any:
23: if isinstance(v, str):
24: return v
25:
26: # 从模型中获取值
27: values = {}
28: for field_name in ["POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_SERVER", "POSTGRES_DB"]:
29: try:
30: values[field_name] = info.data.get(field_name)
31: except (KeyError, AttributeError):
32: values[field_name] = None
33:
34: return PostgresDsn.build(
35: scheme="postgresql",
36: username=values["POSTGRES_USER"],
37: password=values["POSTGRES_PASSWORD"],
38: host=values["POSTGRES_SERVER"],
39: path=f"{values['POSTGRES_DB'] or ''}",
40: query="client_encoding=utf8"
41: )
▶ 第 21~24 行:@field_validator(..., mode="before") = 字段校验前执行的钩子。如果 .env 里已经直接给了完整的 URI 字符串,就直接用。
▶ 第 26~32 行:否则把上面四个 PostgreSQL 参数拼起来。
▶ 第 34~41 行:PostgresDsn.build = 拼出标准连接串 postgresql://user:password@host/db?client_encoding=utf8。client_encoding=utf8 = 中文不乱码。
python
44: # Paths
45: BASE_DIR: Path = Path(__file__).resolve().parent.parent.parent
46: STATIC_DIR: Path = BASE_DIR / "app" / "static"
47: UPLOADS_DIR: Path = STATIC_DIR / "uploads"
48: DATASETS_DIR: Path = STATIC_DIR / "datasets"
49: MODELS_DIR: Path = STATIC_DIR / "models"
50: RESULTS_DIR: Path = STATIC_DIR / "results"
51: TEMP_DIR: Path = STATIC_DIR / "temp"
▶ 第 45 行:Path(__file__).resolve() = 拿到本文件的绝对路径,.parent 上跳一层目录,.parent.parent.parent = 上跳三层 → 项目根目录。用代码定位项目根目录 ,不写死路径(换机器也能跑)。
▶ 第 46~51 行:各类存储目录(与 main.py 第 33 行创建的一致)。
python
53: # TensorBoard
54: TENSORBOARD_LOGS_DIR: Path = BASE_DIR / "logs" / "tensorboard"
55: TENSORBOARD_PORT: int = 6006
▶ 第 54~55 行:TensorBoard 配置。
python
64: class Config:
65: case_sensitive = True
66: env_file = ".env"
67: extra = "allow" # 允许额外的配置项
▶ 第 65 行:环境变量名大小写敏感。
▶ 第 66 行:指定读取 .env 文件 (项目根目录)。
▶ 第 67 行:.env 里有配置类没定义的字段也允许(不报错)。
python
69: settings = Settings()
▶ 第 69 行:创建全局唯一配置对象 ------其他文件 from app.core.config import settings 拿到的都是同一个实例。
python
71: # Create directories if they don't exist
72: for directory in [...]:
73: os.makedirs(directory, exist_ok=True)
▶ 第 71~80 行:导入时就自动创建所有存储目录(比 main.py 的检查更早,因为很多文件都 import settings)。
十一、app/core/audit_middleware.py 逐行讲解(47 行)------ 审计中间件
中间件 = 包裹在应用外的"安检门":每个请求先经过它(可以做检查/记录),然后才到具体路由。
python
1: from starlette.middleware.base import BaseHTTPMiddleware
2: from starlette.requests import Request
3: from jose import jwt, JWTError
▶ 第 1 行:Starlette(FastAPI 的底层框架)提供的中间件基类。
python
9: SENSITIVE_PREFIXES = ['/api/auth/users', '/api/models', '/api/settings', '/api/training', '/api/tools']
▶ 第 9 行:只对敏感接口记审计(用户管理、模型、设置、训练、工具)------避免所有请求都写日志(量太大)。
python
11: class AuditMiddleware(BaseHTTPMiddleware):
12: async def dispatch(self, request: Request, call_next):
13: response = await call_next(request)
▶ 第 12 行:dispatch = 中间件的入口。call_next(request) = "放行,让请求继续往后走(直到路由处理完)",await = 等它返回;response = 最终响应。
▶ 顺序说明:先 call_next(业务已执行完)再记录------这样能记录到响应状态码(比如记录"删除失败 404")。
python
15: if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'):
16: path = request.url.path
17: if any(path.startswith(prefix) for prefix in SENSITIVE_PREFIXES):
▶ 第 15 行:只记"写操作"(查询 GET 不记)。
▶ 第 17 行:any(...) = 任一前缀匹配就进入(路径是否以某个敏感前缀开头)。
python
18: username = None
19: auth_header = request.headers.get('Authorization', '')
20: if auth_header.startswith('Bearer '):
21: token = auth_header[7:]
22: try:
23: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
24: username = payload.get('sub')
25: except JWTError:
26: pass
▶ 第 19~26 行:从请求头里解出用户名(中间件阶段还没到认证依赖,所以自己解一次令牌)。auth_header[7:] = 去掉 "Bearer " 前缀(7 个字符)。
python
28: db = SessionLocal()
29: try:
30: parts = path.split('/')
31: log = AuditLog(
32: username=username,
33: method=request.method,
34: path=path,
35: resource_type=parts[2] if len(parts) > 2 else None,
36: resource_id=parts[3] if len(parts) > 3 else None,
37: ip_address=request.client.host if request.client else None,
38: status_code=response.status_code,
39: )
40: db.add(log)
41: db.commit()
42: except Exception:
43: db.rollback()
44: finally:
45: db.close()
▶ 第 30 行:path.split('/') 把 /api/training/5 拆成 ['', 'api', 'training', '5']。
▶ 第 35 行:parts[2] = training → 资源类型(哪类操作);第 36 行:parts[3] = 5 → 资源 ID(对哪个对象操作)。这就是审计日志"谁、何时、对什么、干了什么"的数据来源。
▶ 第 37 行:request.client.host = 客户端 IP。
▶ 第 42~45 行:写库失败回滚 + 无论如何关闭会话。⚠️ 注意:审计日志写失败绝不影响主业务(异常被接住吞掉)。
十二、app/db/init_db.py 概览(414 行)
建表 + 迁移脚本。启动时自动执行 (run.py 和 main.py 都调用了
init_db())。结构是"检查列是否存在 → 不存在就 ALTER TABLE 添加",所以它幂等(跑多少次都安全)。
python
16: def create_initial_admin():
17: """创建初始管理员用户"""
18: db = SessionLocal()
...
21: admin_user = get_user_by_username(db, username="admin")
22: if not admin_user:
25: admin_password = "admin@123" # 默认密码,用户应首次登录后修改
...
34: admin_user = create_user(db=db, user=user_in)
35: logger.info("Initial admin user created successfully")
38: db.close()
▶ 第 21~22 行:查有没有 admin 用户------已存在就不创建 (幂等)。
▶ 第 25 行:默认密码 admin@123(README 里说的初始账号)。⚠️ 生产环境务必先改。
▶ 第 34 行:走 crud 创建(密码自动哈希)。
python
41: def init_db():
44: # 测试数据库连接
45: with engine.connect() as conn:
46: conn.execute(text("SELECT 1"))
52: # 创建表
53: Base.metadata.create_all(bind=engine)
▶ 第 45~46 行:先测连接。
▶ 第 53 行:Base.metadata.create_all = 照"图纸"(models/ 里的类)把不存在的表建出来。💡 这就是为什么 models/ 里加一个新表类,重启系统表就自动出现。
python
56: inspector = inspect(engine)
57: tables = inspector.get_table_names()
61: for table in tables:
62: columns = [col['name'] for col in inspector.get_columns(table)]
63: logger.info(f"Columns in {table}: {columns}")
▶ 第 56~63 行:用 inspect 查看现有表结构(接下来判断"缺哪列"的依据)。
python
66: if 'training_tasks' in tables:
67: columns = [col['name'] for col in inspector.get_columns('training_tasks')]
68: if 'process_id' not in columns:
69: logger.info("Adding process_id column to training_tasks table")
70: with engine.connect() as conn:
71: conn.execute(text("ALTER TABLE training_tasks ADD COLUMN process_id VARCHAR(255)"))
75: conn.commit()
▶ 第 66~75 行:列级迁移 ------缺 process_id 列就 ALTER TABLE 补上。后面几十个"检查 + 补列"块都是同一个模式(process_id、input_path、is_external、password、role、last_login、is_password_changed、owner_id、task、counts...)。
python
382: # 存量数据迁移:将 owner_id 为 NULL 的数据归属给 admin 用户
383: for table_name in business_tables:
...
386: conn.execute(text(f"""
387: UPDATE {table_name}
388: SET owner_id = (SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1)
389: WHERE owner_id IS NULL
390: """))
▶ 第 386~390 行:数据迁移------老数据没有归属人(owner_id 为空)时,统一归给第一个 admin 用户。多用户功能的兼容处理。
python
411: # 创建初始管理员用户
412: create_initial_admin()
413: except Exception as e:
414: logger.error(f"Database initialization failed: {e}", exc_info=True)
415: raise
▶ 第 412 行:最后创建/确认 admin 账号。
▶ 第 413~415 行:失败抛异常(run.py 会捕获并停止启动------数据库初始化失败不该继续跑)。
十三、把登录全流程串起来(终极理解)
场景:admin 用户输入 admin@123 点登录
1. login.html submit 事件 → fetch POST /api/auth/token(表单格式)
2. 请求经过 AuditMiddleware:POST 且路径 /api/auth 不以 SENSITIVE 前缀开头 → 不记录
3. api.py: /auth 前缀 → auth.py 的 login_for_access_token
4. OAuth2PasswordRequestForm 解析出 username=admin, password=admin@123
5. authenticate_user():
a. get_user_by_username → SELECT * FROM users WHERE username='admin'
b. verify_password(admin@123, 哈希) → 重算比对 → 通过
6. user.last_login = now; db.commit() → UPDATE users SET last_login=... WHERE id=1
7. create_access_token({"sub": "admin"}) → 生成带 jti 和 exp(30分钟) 的 JWT
8. 返回 {access_token, token_type:"bearer", must_change_password}
9. 前端存 localStorage + Cookie → 跳转 /
10. / 路由验证(Authorization 头或 Cookie)→ 返回 index.html
11. main.js: getCurrentUser() → GET /api/auth/users/me(自动带头令牌)
12. deps.get_current_user: 解令牌 → 查吊销表 → 查用户 → 返回
13. auth.py read_users_me → 返回用户信息(response_model 过滤掉 password)
14. 前端显示用户名;role=admin → 显示管理员专属菜单
此后每个请求:
authenticatedFetch() 带 Authorization: Bearer xxx
→ 中间件(写操作敏感路径才记录)
→ api.py 找模块 → 认证依赖 get_current_active_user(401 拦截)
→ 路由函数 → services → crud → 数据库 → JSON 返回
十四、验证任务(动手做)
- 看启动日志 :
python run.py,观察 "Registered models" 和 "Tables in database" 两行日志,对照app/models/的文件列表 - 数据库验证 :pgAdmin 打开
users表,看看 admin 的 password 字段是不是$pbkdf2-sha256$29000$...开头的哈希;再查revoked_tokens、audit_logs表结构 - F12 抓包 :登录后随便删一个数据集(先备份),在 Network 里找到 DELETE 请求;然后在 pgAdmin 查
audit_logs表------能看到刚才的删除记录(method=DELETE, path=/api/datasets/xx, username=admin) - 改配置实验 :把
app/db/session.py第 20 行的echo=False改成echo=True,重启,登录一次------控制台会打印出每一步的 SQL 语句!看完改回来 - 改代码实验 :把
ACCESS_TOKEN_EXPIRE_MINUTES = 30改成1,重启,登录后等 1 分钟再操作------看前端如何被 401 踢回登录页 - 读接口文档 :浏览器打开
http://localhost:9000/docs(Swagger UI)------所有接口的自动文档,点开/api/auth/token可以直接在页面上测试登录!
十五、本篇小结
| 层 | 文件 | 职责 |
|---|---|---|
| 入口 | run.py |
初始化数据库 → uvicorn 启动 |
| 应用 | app/main.py |
建应用、挂中间件、注册路由、页面路由、错误处理 |
| 注册 | app/api/api.py |
19 个模块统一挂载 + 全局认证依赖 |
| 认证 | app/api/deps.py |
签发/解析 JWT、吊销检查、三级权限 |
| 接口 | app/api/endpoints/auth.py |
登录/验证/注销/改密/用户 CRUD |
| 数据 | app/crud/user.py |
查用户、建用户、密码哈希 |
| 图纸 | app/models/user.py |
users 表结构 |
| 翻译 | app/schemas/user.py |
接口输入输出格式(自动校验) |
| 连接 | app/db/session.py |
引擎、会话、get_db 依赖 |
| 配置 | app/core/config.py |
.env 读取、目录、连接串 |
| 审计 | app/core/audit_middleware.py |
写操作敏感接口记录 |
| 建表 | app/db/init_db.py |
幂等建表 + 迁移 + 初始 admin |
核心安全设计一句话总结:密码哈希存储(防泄露)→ 登录发 JWT 令牌(防每次传密码)→ 令牌 30 分钟过期 + 吊销表(防盗用)→ 三层权限依赖(防越权)→ 审计日志(留痕迹)。