mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-22 21:06:32 +00:00
1. 重构后端API路由、CRUD与模块结构,整合日志管理,移除废弃demo代码 2. 优化前端组件类型定义、样式与路由配置,修复权限判断逻辑 3. 调整默认排序规则、滚动条样式与工具类函数,更新依赖与配置文件 4. 修复多处类型不匹配与默认值问题,完善表单与菜单验证逻辑
402 lines
13 KiB
Python
402 lines
13 KiB
Python
import json
|
||
import time
|
||
from collections.abc import AsyncGenerator
|
||
|
||
from fastapi import Depends, Query, Request
|
||
from redis.asyncio.client import Redis
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from app.common.enums import RedisInitKeyConfig
|
||
from app.config.setting import settings
|
||
from app.core.base_schema import AuthSchema
|
||
from app.core.database import async_db_session
|
||
from app.core.exceptions import CustomException
|
||
from app.core.logger import logger
|
||
from app.core.redis_crud import RedisCURD
|
||
from app.core.security import OAuth2Schema, decode_access_token
|
||
from app.core.tenant import get_current_tenant_id as _get_ctx_tenant_id
|
||
|
||
# 套餐菜单权限缓存: {tenant_id: (timestamp, [menu_ids])}
|
||
_package_menu_cache: dict[int, tuple[float, list[int]]] = {}
|
||
|
||
|
||
async def db_getter() -> AsyncGenerator[AsyncSession, None]:
|
||
"""数据库会话 — 请求级生命周期管理。
|
||
|
||
一个 HTTP 请求内所有写入共享同一个事务,要么全成功,要么全失败。
|
||
自动提交(无异常时)或回滚(异常时)。
|
||
"""
|
||
async with async_db_session() as session:
|
||
async with session.begin():
|
||
yield session
|
||
|
||
|
||
async def redis_getter(request: Request) -> Redis:
|
||
"""获取Redis连接
|
||
|
||
参数:
|
||
- request (Request): 请求对象
|
||
|
||
返回:
|
||
- Redis: Redis连接
|
||
"""
|
||
return request.app.state.redis
|
||
|
||
|
||
async def get_current_tenant_id() -> int | None:
|
||
"""获取当前请求的租户 ID 依赖注入函数。
|
||
|
||
从 ContextVar 中读取租户 ID(由 TenantMiddleware 设置)。
|
||
非认证路径(白名单)返回 None。
|
||
|
||
返回:
|
||
int | None: 当前租户 ID,未设置时返回 None。
|
||
"""
|
||
return _get_ctx_tenant_id()
|
||
|
||
|
||
# ── Token 解析 & 验证辅助函数 ──
|
||
|
||
|
||
def _decode_token_info(token: str) -> tuple[dict, str]:
|
||
"""解码 JWT token 返回 (user_info, session_id)
|
||
|
||
参数:
|
||
token: JWT token 字符串
|
||
|
||
返回:
|
||
(user_info, session_id): 用户信息字典和会话 ID
|
||
"""
|
||
payload = decode_access_token(token)
|
||
if not payload or not hasattr(payload, "is_refresh") or payload.is_refresh:
|
||
raise CustomException(msg="非法凭证", code=10401, status_code=401)
|
||
|
||
online_user_info = payload.sub
|
||
user_info = (
|
||
online_user_info if isinstance(online_user_info, dict) else json.loads(online_user_info)
|
||
)
|
||
session_id = user_info.get("session_id")
|
||
if not session_id:
|
||
raise CustomException(msg="认证已失效", code=10401, status_code=401)
|
||
|
||
return user_info, session_id
|
||
|
||
|
||
async def _check_token_online(redis: Redis, session_id: str) -> None:
|
||
"""检查 token 是否在线(Redis 中存在对应 session)
|
||
|
||
参数:
|
||
redis: Redis 连接
|
||
session_id: 会话 ID
|
||
"""
|
||
online_ok = await RedisCURD(redis).exists(
|
||
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}"
|
||
)
|
||
if not online_ok:
|
||
raise CustomException(msg="认证已失效", code=10401, status_code=401)
|
||
|
||
|
||
async def _try_sliding_refresh(redis: Redis, session_id: str) -> None:
|
||
"""滑动过期续期(仅在 token 剩余不足一半时触发)
|
||
|
||
参数:
|
||
redis: Redis 连接
|
||
session_id: 会话 ID
|
||
"""
|
||
if not settings.TOKEN_SLIDING_EXPIRE:
|
||
return
|
||
|
||
ttl = await RedisCURD(redis).ttl(
|
||
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}"
|
||
)
|
||
if ttl > 0 and ttl < settings.ACCESS_TOKEN_EXPIRE_MINUTES // 2:
|
||
await RedisCURD(redis).expire(
|
||
key=f"{RedisInitKeyConfig.ACCESS_TOKEN.key}:{session_id}",
|
||
expire=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||
)
|
||
await RedisCURD(redis).expire(
|
||
key=f"{RedisInitKeyConfig.REFRESH_TOKEN.key}:{session_id}",
|
||
expire=settings.REFRESH_TOKEN_EXPIRE_MINUTES,
|
||
)
|
||
|
||
|
||
# ── 用户加载辅助函数 ──
|
||
|
||
|
||
async def _load_user_from_db(db: AsyncSession, username: str):
|
||
"""从数据库加载用户(含角色、菜单、部门、职位全量预加载)
|
||
|
||
使用原始查询以绕过 CRUDBase 的权限过滤,确保用户认证阶段不受数据权限影响。
|
||
所有关系链均 eager-loaded,调用方可在会话关闭后安全访问对象属性。
|
||
|
||
参数:
|
||
db: 数据库会话
|
||
username: 用户名
|
||
|
||
返回:
|
||
UserModel: 已全量加载的用户 ORM 对象
|
||
"""
|
||
from app.api.v1.module_system.role.model import RoleModel
|
||
from app.api.v1.module_system.user.model import UserModel
|
||
|
||
stmt = (
|
||
select(UserModel)
|
||
.options(
|
||
selectinload(UserModel.dept),
|
||
selectinload(UserModel.roles).selectinload(RoleModel.menus),
|
||
selectinload(UserModel.positions),
|
||
selectinload(UserModel.created_by),
|
||
)
|
||
.where(UserModel.username == username, UserModel.is_deleted == False) # noqa: E712
|
||
)
|
||
result = await db.execute(stmt)
|
||
user = result.scalars().first()
|
||
if not user:
|
||
raise CustomException(msg="用户不存在", code=10401, status_code=401)
|
||
if user.status == 1:
|
||
raise CustomException(msg="用户已被停用", code=10401, status_code=401)
|
||
|
||
# 过滤不可用的角色和职位(在会话内完成,确保关联数据已加载)
|
||
if hasattr(user, "roles"):
|
||
user.roles = [role for role in user.roles if role and role.status]
|
||
if hasattr(user, "positions"):
|
||
user.positions = [pos for pos in user.positions if pos and pos.status]
|
||
|
||
return user
|
||
|
||
|
||
# ── 依赖注入函数 ──
|
||
|
||
|
||
async def get_current_user(
|
||
request: Request,
|
||
db: AsyncSession = Depends(db_getter),
|
||
redis: Redis = Depends(redis_getter),
|
||
token: str = Depends(OAuth2Schema),
|
||
) -> AuthSchema:
|
||
"""获取当前用户
|
||
|
||
用户查询使用独立的只读数据库会话(不参与请求事务,查询完成后立即释放快照),
|
||
返回的 auth.db 指向请求级事务会话供后续写操作使用。
|
||
|
||
参数:
|
||
- request (Request): 请求对象
|
||
- db (AsyncSession): 请求级事务会话
|
||
- redis (Redis): Redis连接
|
||
- token (str): 访问令牌
|
||
|
||
返回:
|
||
- AuthSchema: 认证信息模型
|
||
"""
|
||
if not token:
|
||
raise CustomException(msg="认证已失效", code=10401, status_code=401)
|
||
|
||
# 处理Bearer token
|
||
if token.startswith("Bearer"):
|
||
token = token.split(" ")[1]
|
||
|
||
# 优先使用 TenantMiddleware 缓存在 scope 中的 JWT 解码结果(避免重复解码)
|
||
cached_payload = request.scope.get("_jwt_payload")
|
||
cached_user_info = request.scope.get("_jwt_user_info")
|
||
|
||
if cached_payload and cached_user_info:
|
||
payload = cached_payload
|
||
user_info = cached_user_info
|
||
else:
|
||
payload = decode_access_token(token)
|
||
if not payload or not hasattr(payload, "is_refresh") or payload.is_refresh:
|
||
raise CustomException(msg="非法凭证", code=10401, status_code=401)
|
||
|
||
online_user_info = payload.sub
|
||
user_info = (
|
||
online_user_info if isinstance(online_user_info, dict) else json.loads(online_user_info)
|
||
)
|
||
|
||
session_id = user_info.get("session_id")
|
||
if not session_id:
|
||
raise CustomException(msg="认证已失效", code=10401, status_code=401)
|
||
|
||
# Redis 在线检查 + 滑动续期
|
||
await _check_token_online(redis, session_id)
|
||
await _try_sliding_refresh(redis, session_id)
|
||
|
||
username = user_info.get("user_name")
|
||
if not username:
|
||
raise CustomException(msg="认证已失效", code=10401, status_code=401)
|
||
tenant_id = user_info.get("tenant_id")
|
||
|
||
# 用户查询使用独立只读会话(不参与请求事务,查询后立即释放快照)
|
||
async with async_db_session() as lookup_db:
|
||
user = await _load_user_from_db(lookup_db, username)
|
||
|
||
# 设置请求上下文
|
||
request.scope["user_id"] = user.id
|
||
request.scope["user_username"] = user.username
|
||
request.scope["session_id"] = session_id
|
||
request.scope["session_info"] = user_info
|
||
|
||
# 返回的 auth.db 指向请求级事务会话,供后续读写操作使用
|
||
auth = AuthSchema(db=db, tenant_id=tenant_id, check_data_scope=False)
|
||
auth.user = user
|
||
return auth
|
||
|
||
|
||
async def get_current_user_ws(
|
||
token: str = Query(..., description="认证token"),
|
||
db: AsyncSession = Depends(db_getter),
|
||
redis: Redis = Depends(redis_getter),
|
||
) -> AuthSchema:
|
||
"""获取当前用户(WebSocket专用,从查询参数获取token)
|
||
|
||
参数:
|
||
- token (str): 认证token
|
||
- db (AsyncSession): 数据库会话
|
||
- redis (Redis): Redis连接
|
||
|
||
返回:
|
||
- AuthSchema: 认证信息模型
|
||
"""
|
||
return await _verify_token(token, db, redis)
|
||
|
||
|
||
async def _verify_token(
|
||
token: str,
|
||
db: AsyncSession,
|
||
redis: Redis,
|
||
) -> AuthSchema:
|
||
"""验证token并返回用户信息(共享核心逻辑)
|
||
|
||
参数:
|
||
- token (str): 认证token
|
||
- db (AsyncSession): 数据库会话
|
||
- redis (Redis): Redis连接
|
||
|
||
返回:
|
||
- AuthSchema: 认证信息模型
|
||
"""
|
||
if not token:
|
||
raise CustomException(msg="认证已失效", code=10401, status_code=401)
|
||
|
||
# 处理Bearer token(如果通过查询参数传递时包含Bearer前缀)
|
||
if token.startswith("Bearer"):
|
||
token = token.split(" ")[1]
|
||
|
||
user_info, session_id = _decode_token_info(token)
|
||
await _check_token_online(redis, session_id)
|
||
await _try_sliding_refresh(redis, session_id)
|
||
|
||
username = user_info.get("user_name")
|
||
if not username:
|
||
raise CustomException(msg="认证已失效", code=10401, status_code=401)
|
||
tenant_id = user_info.get("tenant_id")
|
||
|
||
user = await _load_user_from_db(db, username)
|
||
|
||
auth = AuthSchema(db=db, tenant_id=tenant_id, check_data_scope=False)
|
||
auth.user = user
|
||
return auth
|
||
|
||
|
||
# ── 套餐菜单缓存 ──
|
||
|
||
|
||
async def _get_cached_tenant_menu_ids(auth: AuthSchema, tenant_id: int) -> list[int]:
|
||
"""获取租户可用菜单 ID,带 60s 进程级缓存
|
||
|
||
套餐菜单变更频率极低,缓存可大幅减少 AuthPermission 的 DB 查询次数。
|
||
|
||
参数:
|
||
auth: 认证信息
|
||
tenant_id: 租户 ID
|
||
|
||
返回:
|
||
可用菜单 ID 列表
|
||
"""
|
||
cached = _package_menu_cache.get(tenant_id)
|
||
if cached and time.time() - cached[0] < 60:
|
||
return cached[1]
|
||
|
||
from app.api.v1.module_platform.package.service import PackageService
|
||
|
||
result = await PackageService.get_tenant_available_menu_ids(auth, tenant_id)
|
||
_package_menu_cache[tenant_id] = (time.time(), result)
|
||
return result
|
||
|
||
|
||
# ── 权限验证类 ──
|
||
|
||
|
||
class AuthPermission:
|
||
"""权限验证类"""
|
||
|
||
def __init__(
|
||
self,
|
||
permissions: list[str] | None = None,
|
||
check_data_scope: bool = True,
|
||
) -> None:
|
||
"""
|
||
初始化权限验证
|
||
|
||
参数:
|
||
- permissions (list[str] | None): 权限标识列表。
|
||
- check_data_scope (bool): 是否启用严格模式校验。
|
||
"""
|
||
self.permissions = permissions or []
|
||
self.check_data_scope = check_data_scope
|
||
|
||
async def __call__(self, auth: AuthSchema = Depends(get_current_user)) -> AuthSchema:
|
||
"""
|
||
调用权限验证
|
||
|
||
参数:
|
||
- auth (AuthSchema): 认证信息对象。
|
||
|
||
返回:
|
||
- AuthSchema: 认证信息对象。
|
||
"""
|
||
auth.check_data_scope = self.check_data_scope
|
||
|
||
# 超级管理员直接通过
|
||
if auth.user and auth.user.is_superuser:
|
||
return auth
|
||
|
||
# 无需验证权限
|
||
if not self.permissions:
|
||
return auth
|
||
|
||
# 超级管理员权限标识
|
||
if "*" in self.permissions or "*:*:*" in self.permissions:
|
||
return auth
|
||
|
||
# 检查用户是否有角色
|
||
if not auth.user or not auth.user.roles:
|
||
raise CustomException(msg="无权限操作", code=10403, status_code=403)
|
||
|
||
# 收集角色权限(附带 menu_id 用于套餐过滤)
|
||
role_perms: dict[str, int] = {}
|
||
for role in auth.user.roles:
|
||
if role.status != 0:
|
||
continue
|
||
for menu in role.menus:
|
||
if menu.status == 0 and menu.permission:
|
||
role_perms[menu.permission] = menu.id
|
||
|
||
if not role_perms:
|
||
raise CustomException(msg="无权限操作", code=10403, status_code=403)
|
||
|
||
# 租户用户:权限必须受套餐菜单约束(带 60s 进程级缓存)
|
||
if auth.tenant_id:
|
||
allowed_ids = set(await _get_cached_tenant_menu_ids(auth, auth.tenant_id))
|
||
user_permissions = {p for p, mid in role_perms.items() if mid in allowed_ids}
|
||
else:
|
||
user_permissions = set(role_perms.keys())
|
||
|
||
# 权限验证 - 满足任一权限即可
|
||
if not any(perm in user_permissions for perm in self.permissions):
|
||
logger.error(f"用户缺少任何所需的权限: {self.permissions}")
|
||
raise CustomException(msg="无权限操作", code=10403, status_code=403)
|
||
|
||
return auth
|