TrainEye源码解读-02. 登录认证 · 后端篇

覆盖文件:run.pyapp/main.pyapp/api/api.pyapp/api/deps.pyapp/api/endpoints/auth.pyapp/crud/user.pyapp/models/user.pyapp/models/revoked_token.pyapp/schemas/user.pyapp/db/session.pyapp/core/config.pyapp/core/audit_middleware.pyapp/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 环境变量里,就把它加到最前面。这样后续运行 yolotensorboard 等命令行工具时系统能找到它们。

💡 为什么需要这步?------虚拟环境激活时(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.usernamecurrent_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 内置的表单解析器,自动从请求体解析 usernamepassword 两个字段。⚠️ 所以前端(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=utf8client_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.pymain.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 返回

十四、验证任务(动手做)

  1. 看启动日志python run.py,观察 "Registered models" 和 "Tables in database" 两行日志,对照 app/models/ 的文件列表
  2. 数据库验证 :pgAdmin 打开 users 表,看看 admin 的 password 字段是不是 $pbkdf2-sha256$29000$... 开头的哈希;再查 revoked_tokensaudit_logs 表结构
  3. F12 抓包 :登录后随便删一个数据集(先备份),在 Network 里找到 DELETE 请求;然后在 pgAdmin 查 audit_logs 表------能看到刚才的删除记录(method=DELETE, path=/api/datasets/xx, username=admin)
  4. 改配置实验 :把 app/db/session.py 第 20 行的 echo=False 改成 echo=True,重启,登录一次------控制台会打印出每一步的 SQL 语句!看完改回来
  5. 改代码实验 :把 ACCESS_TOKEN_EXPIRE_MINUTES = 30 改成 1,重启,登录后等 1 分钟再操作------看前端如何被 401 踢回登录页
  6. 读接口文档 :浏览器打开 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 分钟过期 + 吊销表(防盗用)→ 三层权限依赖(防越权)→ 审计日志(留痕迹)。

相关推荐
AI导出鸭1 小时前
怎么让文心做表格?AI导出鸭苹果版将文心输出的管道表格智能解析为二维结构,一键导出为Excel或Word标准表格
人工智能·chatgpt·word·excel·ai导出鸭
ZJU_统一阿萨姆1 小时前
【算子开发】Reduction算子完全指南
人工智能·算法·语言模型
迪康coolmu1 小时前
企业文件外发防泄漏实践:从通道封堵到三层全链路管控
大数据·运维·网络·人工智能·安全·阿里云
苦猿的大模型日记1 小时前
Day49|投机解码:小模型帮大模型提速,为什么不一定更快?
人工智能
听你说321 小时前
三诺动态血糖仪i6搭载L5级小诺智能体,开启AI控糖新时代
人工智能
TMT星球1 小时前
AI智融·场景共生,奥维云网2026数字AI生态大会在南京圆满举办
人工智能
艾莉丝努力练剑4 小时前
【AI接入大模型SDK】ChatSDK示例验证
人工智能·学习·面试·大模型·sdk
wulitoud4 小时前
把 Claude、Codex、Gemini 的引擎换成本地模型:免费、离线、数据不出本机
人工智能·llama·claude·本地模型
火山引擎开发者社区7 小时前
TLS for DeepSeek Harness 可观测实践:从系统总览到会话复盘
人工智能