初步实现数据加密、权限管理、日志审计等功能
This commit is contained in:
99
app/api/v1/endpoints/audit.py
Normal file
99
app/api/v1/endpoints/audit.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
审计日志 API 接口
|
||||
|
||||
仅管理员可访问
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from app.domain.schemas.audit import AuditLogResponse, AuditLogQuery
|
||||
from app.domain.schemas.user import UserInDB
|
||||
from app.infra.repositories.audit_repository import AuditRepository
|
||||
from app.auth.dependencies import get_user_repository, get_db
|
||||
from app.auth.permissions import get_current_admin
|
||||
from app.infra.db.postgresql.database import Database
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
async def get_audit_repository(db: Database = Depends(get_db)) -> AuditRepository:
|
||||
"""获取审计日志仓储"""
|
||||
return AuditRepository(db)
|
||||
|
||||
@router.get("/logs", response_model=List[AuditLogResponse])
|
||||
async def get_audit_logs(
|
||||
user_id: Optional[int] = Query(None, description="按用户ID过滤"),
|
||||
username: Optional[str] = Query(None, description="按用户名过滤"),
|
||||
action: Optional[str] = Query(None, description="按操作类型过滤"),
|
||||
resource_type: Optional[str] = Query(None, description="按资源类型过滤"),
|
||||
start_time: Optional[datetime] = Query(None, description="开始时间"),
|
||||
end_time: Optional[datetime] = Query(None, description="结束时间"),
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="限制记录数"),
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
audit_repo: AuditRepository = Depends(get_audit_repository)
|
||||
) -> List[AuditLogResponse]:
|
||||
"""
|
||||
查询审计日志(仅管理员)
|
||||
|
||||
支持按用户、时间、操作类型等条件过滤
|
||||
"""
|
||||
logs = await audit_repo.get_logs(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return logs
|
||||
|
||||
@router.get("/logs/count")
|
||||
async def get_audit_logs_count(
|
||||
user_id: Optional[int] = Query(None, description="按用户ID过滤"),
|
||||
username: Optional[str] = Query(None, description="按用户名过滤"),
|
||||
action: Optional[str] = Query(None, description="按操作类型过滤"),
|
||||
resource_type: Optional[str] = Query(None, description="按资源类型过滤"),
|
||||
start_time: Optional[datetime] = Query(None, description="开始时间"),
|
||||
end_time: Optional[datetime] = Query(None, description="结束时间"),
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
audit_repo: AuditRepository = Depends(get_audit_repository)
|
||||
) -> dict:
|
||||
"""
|
||||
获取审计日志总数(仅管理员)
|
||||
"""
|
||||
count = await audit_repo.get_log_count(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time
|
||||
)
|
||||
return {"count": count}
|
||||
|
||||
@router.get("/logs/my", response_model=List[AuditLogResponse])
|
||||
async def get_my_audit_logs(
|
||||
action: Optional[str] = Query(None, description="按操作类型过滤"),
|
||||
start_time: Optional[datetime] = Query(None, description="开始时间"),
|
||||
end_time: Optional[datetime] = Query(None, description="结束时间"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
audit_repo: AuditRepository = Depends(get_audit_repository)
|
||||
) -> List[AuditLogResponse]:
|
||||
"""
|
||||
查询当前用户的审计日志
|
||||
|
||||
普通用户只能查看自己的操作记录
|
||||
"""
|
||||
logs = await audit_repo.get_logs(
|
||||
user_id=current_user.id,
|
||||
action=action,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return logs
|
||||
@@ -1,52 +1,186 @@
|
||||
from typing import Annotated, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
from pydantic import BaseModel
|
||||
from typing import Annotated
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, create_refresh_token, verify_password
|
||||
from app.domain.schemas.user import UserCreate, UserResponse, UserLogin, Token
|
||||
from app.infra.repositories.user_repository import UserRepository
|
||||
from app.auth.dependencies import get_user_repository, get_current_active_user
|
||||
from app.domain.schemas.user import UserInDB
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 简易令牌验证(实际项目中应替换为 JWT/OAuth2 等)
|
||||
AUTH_TOKEN = "567e33c876a2" # 预设的有效令牌
|
||||
WHITE_LIST = ["/docs", "/openapi.json", "/redoc", "/api/v1/auth/login/"]
|
||||
|
||||
async def verify_token(authorization: Annotated[str, Header()] = None):
|
||||
# 检查请求头是否存在
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header missing")
|
||||
|
||||
# 提取 Bearer 后的令牌 (格式: Bearer <token>)
|
||||
try:
|
||||
token_type, token = authorization.split(" ", 1)
|
||||
if token_type.lower() != "bearer":
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
user_data: UserCreate,
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> UserResponse:
|
||||
"""
|
||||
用户注册
|
||||
|
||||
创建新用户账号
|
||||
"""
|
||||
# 检查用户名和邮箱是否已存在
|
||||
if await user_repo.user_exists(username=user_data.username):
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid authorization format. Use: Bearer <token>"
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered"
|
||||
)
|
||||
|
||||
if await user_repo.user_exists(email=user_data.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# 创建用户
|
||||
try:
|
||||
user = await user_repo.create_user(user_data)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create user"
|
||||
)
|
||||
return UserResponse.model_validate(user)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during user registration: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Registration failed"
|
||||
)
|
||||
|
||||
# 验证令牌
|
||||
if token != AUTH_TOKEN:
|
||||
raise HTTPException(status_code=403, detail="Invalid authentication token")
|
||||
|
||||
return True
|
||||
|
||||
def generate_access_token(username: str, password: str) -> str:
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> Token:
|
||||
"""
|
||||
根据用户名和密码生成JWT access token
|
||||
|
||||
参数:
|
||||
username: 用户名
|
||||
password: 密码
|
||||
|
||||
返回:
|
||||
JWT access token字符串
|
||||
用户登录(OAuth2 标准格式)
|
||||
|
||||
返回 JWT Access Token 和 Refresh Token
|
||||
"""
|
||||
# 验证用户(支持用户名或邮箱登录)
|
||||
user = await user_repo.get_user_by_username(form_data.username)
|
||||
if not user:
|
||||
# 尝试用邮箱登录
|
||||
user = await user_repo.get_user_by_email(form_data.username)
|
||||
|
||||
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user account"
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(subject=user.username)
|
||||
refresh_token = create_refresh_token(subject=user.username)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
if username != "tjwater" or password != "tjwater@123":
|
||||
raise ValueError("用户名或密码错误")
|
||||
@router.post("/login/simple", response_model=Token)
|
||||
async def login_simple(
|
||||
username: str,
|
||||
password: str,
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> Token:
|
||||
"""
|
||||
简化版登录接口(保持向后兼容)
|
||||
|
||||
直接使用 username 和 password 参数
|
||||
"""
|
||||
# 验证用户
|
||||
user = await user_repo.get_user_by_username(username)
|
||||
if not user:
|
||||
user = await user_repo.get_user_by_email(username)
|
||||
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user account"
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
access_token = create_access_token(subject=user.username)
|
||||
refresh_token = create_refresh_token(subject=user.username)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
token = "567e33c876a2"
|
||||
return token
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(
|
||||
current_user: UserInDB = Depends(get_current_active_user)
|
||||
) -> UserResponse:
|
||||
"""
|
||||
获取当前登录用户信息
|
||||
"""
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
@router.post("/login/")
|
||||
async def login(username: str, password: str) -> str:
|
||||
return generate_access_token(username, password)
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(
|
||||
refresh_token: str,
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> Token:
|
||||
"""
|
||||
刷新 Access Token
|
||||
|
||||
使用 Refresh Token 获取新的 Access Token
|
||||
"""
|
||||
from jose import jwt, JWTError
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate refresh token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
token_type: str = payload.get("type")
|
||||
|
||||
if username is None or token_type != "refresh":
|
||||
raise credentials_exception
|
||||
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
# 验证用户仍然存在且激活
|
||||
user = await user_repo.get_user_by_username(username)
|
||||
if not user or not user.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
# 生成新的 Access Token
|
||||
new_access_token = create_access_token(subject=user.username)
|
||||
|
||||
return Token(
|
||||
access_token=new_access_token,
|
||||
refresh_token=refresh_token, # 保持原 refresh token
|
||||
token_type="bearer",
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
180
app/api/v1/endpoints/user_management.py
Normal file
180
app/api/v1/endpoints/user_management.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
用户管理 API 接口
|
||||
|
||||
演示权限控制的使用
|
||||
"""
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from app.domain.schemas.user import UserResponse, UserUpdate, UserCreate
|
||||
from app.domain.models.role import UserRole
|
||||
from app.domain.schemas.user import UserInDB
|
||||
from app.infra.repositories.user_repository import UserRepository
|
||||
from app.auth.dependencies import get_user_repository, get_current_active_user
|
||||
from app.auth.permissions import get_current_admin, require_role, check_resource_owner
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_user: UserInDB = Depends(require_role(UserRole.ADMIN)),
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> List[UserResponse]:
|
||||
"""
|
||||
获取用户列表(仅管理员)
|
||||
"""
|
||||
users = await user_repo.get_all_users(skip=skip, limit=limit)
|
||||
return [UserResponse.model_validate(user) for user in users]
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
current_user: UserInDB = Depends(get_current_active_user),
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> UserResponse:
|
||||
"""
|
||||
获取用户详情
|
||||
|
||||
管理员可查看所有用户,普通用户只能查看自己
|
||||
"""
|
||||
# 检查权限
|
||||
if not check_resource_owner(user_id, current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have permission to view this user"
|
||||
)
|
||||
|
||||
user = await user_repo.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
user_update: UserUpdate,
|
||||
current_user: UserInDB = Depends(get_current_active_user),
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> UserResponse:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
管理员可更新所有用户,普通用户只能更新自己(且不能修改角色)
|
||||
"""
|
||||
# 检查用户是否存在
|
||||
target_user = await user_repo.get_user_by_id(user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# 权限检查
|
||||
is_owner = current_user.id == user_id
|
||||
is_admin = UserRole(current_user.role).has_permission(UserRole.ADMIN)
|
||||
|
||||
if not is_owner and not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have permission to update this user"
|
||||
)
|
||||
|
||||
# 非管理员不能修改角色和激活状态
|
||||
if not is_admin:
|
||||
if user_update.role is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can change user roles"
|
||||
)
|
||||
if user_update.is_active is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can change user active status"
|
||||
)
|
||||
|
||||
# 更新用户
|
||||
updated_user = await user_repo.update_user(user_id, user_update)
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update user"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> dict:
|
||||
"""
|
||||
删除用户(仅管理员)
|
||||
"""
|
||||
# 不能删除自己
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot delete your own account"
|
||||
)
|
||||
|
||||
success = await user_repo.delete_user(user_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return {"message": "User deleted successfully"}
|
||||
|
||||
@router.post("/{user_id}/activate")
|
||||
async def activate_user(
|
||||
user_id: int,
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> UserResponse:
|
||||
"""
|
||||
激活用户(仅管理员)
|
||||
"""
|
||||
user_update = UserUpdate(is_active=True)
|
||||
updated_user = await user_repo.update_user(user_id, user_update)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
|
||||
@router.post("/{user_id}/deactivate")
|
||||
async def deactivate_user(
|
||||
user_id: int,
|
||||
current_user: UserInDB = Depends(get_current_admin),
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> UserResponse:
|
||||
"""
|
||||
停用用户(仅管理员)
|
||||
"""
|
||||
# 不能停用自己
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="You cannot deactivate your own account"
|
||||
)
|
||||
|
||||
user_update = UserUpdate(is_active=False)
|
||||
updated_user = await user_repo.update_user(user_id, user_update)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(updated_user)
|
||||
@@ -12,6 +12,8 @@ from app.api.v1.endpoints import (
|
||||
misc,
|
||||
risk,
|
||||
cache,
|
||||
user_management, # 新增:用户管理
|
||||
audit, # 新增:审计日志
|
||||
)
|
||||
from app.api.v1.endpoints.network import (
|
||||
general,
|
||||
@@ -42,6 +44,8 @@ api_router = APIRouter()
|
||||
|
||||
# Core Services
|
||||
api_router.include_router(auth.router, tags=["Auth"])
|
||||
api_router.include_router(user_management.router, prefix="/users", tags=["User Management"]) # 新增
|
||||
api_router.include_router(audit.router, prefix="/audit", tags=["Audit Logs"]) # 新增
|
||||
api_router.include_router(project.router, tags=["Project"])
|
||||
|
||||
# Network Elements (Node/Link Types)
|
||||
|
||||
@@ -1,21 +1,94 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from typing import Annotated, Optional
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from app.core.config import settings
|
||||
from jose import jwt, JWTError
|
||||
from app.core.config import settings
|
||||
from app.domain.schemas.user import UserInDB, TokenPayload
|
||||
from app.infra.repositories.user_repository import UserRepository
|
||||
from app.infra.db.postgresql.database import Database
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/login/access-token")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
# 数据库依赖
|
||||
async def get_db(request: Request) -> Database:
|
||||
"""
|
||||
获取数据库实例
|
||||
|
||||
从 FastAPI app.state 中获取在启动时初始化的数据库连接
|
||||
"""
|
||||
if not hasattr(request.app.state, "db"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Database not initialized"
|
||||
)
|
||||
return request.app.state.db
|
||||
|
||||
async def get_user_repository(db: Database = Depends(get_db)) -> UserRepository:
|
||||
"""获取用户仓储实例"""
|
||||
return UserRepository(db)
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
user_repo: UserRepository = Depends(get_user_repository)
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前登录用户
|
||||
|
||||
从 JWT Token 中解析用户信息,并从数据库验证
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
token_type: str = payload.get("type", "access")
|
||||
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
|
||||
if token_type != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type. Access token required.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
return username
|
||||
|
||||
# 从数据库获取用户
|
||||
user = await user_repo.get_user_by_username(username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前活跃用户(必须是激活状态)
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
async def get_current_superuser(
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前超级管理员用户
|
||||
"""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough privileges. Superuser access required."
|
||||
)
|
||||
return current_user
|
||||
|
||||
106
app/auth/permissions.py
Normal file
106
app/auth/permissions.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
权限控制依赖项和装饰器
|
||||
|
||||
基于角色的访问控制(RBAC)
|
||||
"""
|
||||
from typing import Callable
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from app.domain.models.role import UserRole
|
||||
from app.domain.schemas.user import UserInDB
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
|
||||
def require_role(required_role: UserRole):
|
||||
"""
|
||||
要求特定角色或更高权限
|
||||
|
||||
用法:
|
||||
@router.get("/admin-only")
|
||||
async def admin_endpoint(user: UserInDB = Depends(require_role(UserRole.ADMIN))):
|
||||
...
|
||||
|
||||
Args:
|
||||
required_role: 需要的最低角色
|
||||
|
||||
Returns:
|
||||
依赖函数
|
||||
"""
|
||||
async def role_checker(
|
||||
current_user: UserInDB = Depends(get_current_active_user)
|
||||
) -> UserInDB:
|
||||
user_role = UserRole(current_user.role)
|
||||
|
||||
if not user_role.has_permission(required_role):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Insufficient permissions. Required role: {required_role.value}, "
|
||||
f"Your role: {user_role.value}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
# 预定义的权限检查依赖
|
||||
require_admin = require_role(UserRole.ADMIN)
|
||||
require_operator = require_role(UserRole.OPERATOR)
|
||||
require_user = require_role(UserRole.USER)
|
||||
|
||||
def get_current_admin(
|
||||
current_user: UserInDB = Depends(require_admin)
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前管理员用户
|
||||
|
||||
等同于 Depends(require_role(UserRole.ADMIN))
|
||||
"""
|
||||
return current_user
|
||||
|
||||
def get_current_operator(
|
||||
current_user: UserInDB = Depends(require_operator)
|
||||
) -> UserInDB:
|
||||
"""
|
||||
获取当前操作员用户(或更高权限)
|
||||
|
||||
等同于 Depends(require_role(UserRole.OPERATOR))
|
||||
"""
|
||||
return current_user
|
||||
|
||||
def check_resource_owner(user_id: int, current_user: UserInDB) -> bool:
|
||||
"""
|
||||
检查是否是资源拥有者或管理员
|
||||
|
||||
Args:
|
||||
user_id: 资源拥有者ID
|
||||
current_user: 当前用户
|
||||
|
||||
Returns:
|
||||
是否有权限
|
||||
"""
|
||||
# 管理员可以访问所有资源
|
||||
if UserRole(current_user.role).has_permission(UserRole.ADMIN):
|
||||
return True
|
||||
|
||||
# 检查是否是资源拥有者
|
||||
return current_user.id == user_id
|
||||
|
||||
def require_owner_or_admin(user_id: int):
|
||||
"""
|
||||
要求是资源拥有者或管理员
|
||||
|
||||
Args:
|
||||
user_id: 资源拥有者ID
|
||||
|
||||
Returns:
|
||||
依赖函数
|
||||
"""
|
||||
async def owner_or_admin_checker(
|
||||
current_user: UserInDB = Depends(get_current_active_user)
|
||||
) -> UserInDB:
|
||||
if not check_resource_owner(user_id, current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You don't have permission to access this resource"
|
||||
)
|
||||
return current_user
|
||||
|
||||
return owner_or_admin_checker
|
||||
@@ -1,3 +1,131 @@
|
||||
# Placeholder for audit logic
|
||||
async def log_audit_event(event_type: str, user_id: str, details: dict):
|
||||
pass
|
||||
"""
|
||||
审计日志模块
|
||||
|
||||
记录系统关键操作,用于安全审计和合规追踪
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuditAction:
|
||||
"""审计操作类型常量"""
|
||||
# 认证相关
|
||||
LOGIN = "LOGIN"
|
||||
LOGOUT = "LOGOUT"
|
||||
REGISTER = "REGISTER"
|
||||
PASSWORD_CHANGE = "PASSWORD_CHANGE"
|
||||
|
||||
# 数据操作
|
||||
CREATE = "CREATE"
|
||||
READ = "READ"
|
||||
UPDATE = "UPDATE"
|
||||
DELETE = "DELETE"
|
||||
|
||||
# 权限相关
|
||||
PERMISSION_CHANGE = "PERMISSION_CHANGE"
|
||||
ROLE_CHANGE = "ROLE_CHANGE"
|
||||
|
||||
# 系统操作
|
||||
CONFIG_CHANGE = "CONFIG_CHANGE"
|
||||
SYSTEM_START = "SYSTEM_START"
|
||||
SYSTEM_STOP = "SYSTEM_STOP"
|
||||
|
||||
async def log_audit_event(
|
||||
action: str,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
request_method: Optional[str] = None,
|
||||
request_path: Optional[str] = None,
|
||||
request_data: Optional[dict] = None,
|
||||
response_status: Optional[int] = None,
|
||||
error_message: Optional[str] = None,
|
||||
db = None # 新增:可选的数据库实例
|
||||
):
|
||||
"""
|
||||
记录审计日志
|
||||
|
||||
Args:
|
||||
action: 操作类型
|
||||
user_id: 用户ID
|
||||
username: 用户名
|
||||
resource_type: 资源类型
|
||||
resource_id: 资源ID
|
||||
ip_address: IP地址
|
||||
user_agent: User-Agent
|
||||
request_method: 请求方法
|
||||
request_path: 请求路径
|
||||
request_data: 请求数据(敏感字段需脱敏)
|
||||
response_status: 响应状态码
|
||||
error_message: 错误消息
|
||||
db: 数据库实例(可选,如果不提供则尝试获取)
|
||||
"""
|
||||
from app.infra.repositories.audit_repository import AuditRepository
|
||||
|
||||
try:
|
||||
# 脱敏敏感数据
|
||||
if request_data:
|
||||
request_data = sanitize_sensitive_data(request_data)
|
||||
|
||||
# 如果没有提供数据库实例,尝试获取(这在中间件中可能不可用)
|
||||
if db is None:
|
||||
# 在某些上下文中可能无法获取,此时静默失败
|
||||
logger.warning("No database instance provided for audit logging")
|
||||
return
|
||||
|
||||
audit_repo = AuditRepository(db)
|
||||
|
||||
await audit_repo.create_log(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_method=request_method,
|
||||
request_path=request_path,
|
||||
request_data=request_data,
|
||||
response_status=response_status,
|
||||
error_message=error_message
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Audit log created: action={action}, user={username or user_id}, "
|
||||
f"resource={resource_type}:{resource_id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# 审计日志失败不应影响业务流程
|
||||
logger.error(f"Failed to create audit log: {e}", exc_info=True)
|
||||
|
||||
def sanitize_sensitive_data(data: dict) -> dict:
|
||||
"""
|
||||
脱敏敏感数据
|
||||
|
||||
Args:
|
||||
data: 原始数据
|
||||
|
||||
Returns:
|
||||
脱敏后的数据
|
||||
"""
|
||||
sensitive_fields = [
|
||||
'password', 'passwd', 'pwd',
|
||||
'secret', 'token', 'api_key', 'apikey',
|
||||
'credit_card', 'ssn', 'social_security'
|
||||
]
|
||||
|
||||
sanitized = data.copy()
|
||||
|
||||
for key in sanitized:
|
||||
if isinstance(sanitized[key], dict):
|
||||
sanitized[key] = sanitize_sensitive_data(sanitized[key])
|
||||
elif any(sensitive in key.lower() for sensitive in sensitive_fields):
|
||||
sanitized[key] = "***REDACTED***"
|
||||
|
||||
return sanitized
|
||||
|
||||
@@ -3,9 +3,15 @@ from pydantic_settings import BaseSettings
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "TJWater Server"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
SECRET_KEY: str = "your-secret-key-here" # Change in production
|
||||
|
||||
# JWT 配置
|
||||
SECRET_KEY: str = "your-secret-key-here-change-in-production-use-openssl-rand-hex-32"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# 数据加密密钥 (使用 Fernet)
|
||||
ENCRYPTION_KEY: str = "" # 必须从环境变量设置
|
||||
|
||||
# Database Config (PostgreSQL)
|
||||
DB_NAME: str = "tjwater"
|
||||
|
||||
@@ -1,9 +1,87 @@
|
||||
# Placeholder for encryption logic
|
||||
from cryptography.fernet import Fernet
|
||||
from typing import Optional
|
||||
import base64
|
||||
import os
|
||||
|
||||
class Encryptor:
|
||||
"""
|
||||
使用 Fernet (对称加密) 实现数据加密/解密
|
||||
适用于加密敏感配置、用户数据等
|
||||
"""
|
||||
|
||||
def __init__(self, key: Optional[bytes] = None):
|
||||
"""
|
||||
初始化加密器
|
||||
|
||||
Args:
|
||||
key: 加密密钥,如果为 None 则从环境变量读取
|
||||
"""
|
||||
if key is None:
|
||||
key_str = os.getenv("ENCRYPTION_KEY")
|
||||
if not key_str:
|
||||
raise ValueError(
|
||||
"ENCRYPTION_KEY not found in environment variables. "
|
||||
"Generate one using: Encryptor.generate_key()"
|
||||
)
|
||||
key = key_str.encode()
|
||||
|
||||
self.fernet = Fernet(key)
|
||||
|
||||
def encrypt(self, data: str) -> str:
|
||||
return data # Implement actual encryption
|
||||
|
||||
"""
|
||||
加密字符串
|
||||
|
||||
Args:
|
||||
data: 待加密的明文字符串
|
||||
|
||||
Returns:
|
||||
Base64 编码的加密字符串
|
||||
"""
|
||||
if not data:
|
||||
return data
|
||||
|
||||
encrypted_bytes = self.fernet.encrypt(data.encode())
|
||||
return encrypted_bytes.decode()
|
||||
|
||||
def decrypt(self, data: str) -> str:
|
||||
return data # Implement actual decryption
|
||||
"""
|
||||
解密字符串
|
||||
|
||||
Args:
|
||||
data: Base64 编码的加密字符串
|
||||
|
||||
Returns:
|
||||
解密后的明文字符串
|
||||
"""
|
||||
if not data:
|
||||
return data
|
||||
|
||||
decrypted_bytes = self.fernet.decrypt(data.encode())
|
||||
return decrypted_bytes.decode()
|
||||
|
||||
@staticmethod
|
||||
def generate_key() -> str:
|
||||
"""
|
||||
生成新的 Fernet 加密密钥
|
||||
|
||||
Returns:
|
||||
Base64 编码的密钥字符串
|
||||
"""
|
||||
key = Fernet.generate_key()
|
||||
return key.decode()
|
||||
|
||||
encryptor = Encryptor()
|
||||
# 全局加密器实例(懒加载)
|
||||
_encryptor: Optional[Encryptor] = None
|
||||
|
||||
def get_encryptor() -> Encryptor:
|
||||
"""获取全局加密器实例"""
|
||||
global _encryptor
|
||||
if _encryptor is None:
|
||||
_encryptor = Encryptor()
|
||||
return _encryptor
|
||||
|
||||
# 向后兼容(延迟加载)
|
||||
def __getattr__(name):
|
||||
if name == "encryptor":
|
||||
return get_encryptor()
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
@@ -7,17 +7,72 @@ from app.core.config import settings
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
创建 JWT Access Token
|
||||
|
||||
Args:
|
||||
subject: 用户标识(通常是用户名或用户ID)
|
||||
expires_delta: 过期时间增量
|
||||
|
||||
Returns:
|
||||
JWT token 字符串
|
||||
"""
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
to_encode = {
|
||||
"exp": expire,
|
||||
"sub": str(subject),
|
||||
"type": "access",
|
||||
"iat": datetime.utcnow()
|
||||
}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def create_refresh_token(subject: Union[str, Any]) -> str:
|
||||
"""
|
||||
创建 JWT Refresh Token(长期有效)
|
||||
|
||||
Args:
|
||||
subject: 用户标识
|
||||
|
||||
Returns:
|
||||
JWT refresh token 字符串
|
||||
"""
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
to_encode = {
|
||||
"exp": expire,
|
||||
"sub": str(subject),
|
||||
"type": "refresh",
|
||||
"iat": datetime.utcnow()
|
||||
}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
验证密码
|
||||
|
||||
Args:
|
||||
plain_password: 明文密码
|
||||
hashed_password: 密码哈希
|
||||
|
||||
Returns:
|
||||
是否匹配
|
||||
"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""
|
||||
生成密码哈希
|
||||
|
||||
Args:
|
||||
password: 明文密码
|
||||
|
||||
Returns:
|
||||
bcrypt 哈希字符串
|
||||
"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
36
app/domain/models/role.py
Normal file
36
app/domain/models/role.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from enum import Enum
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""用户角色枚举"""
|
||||
ADMIN = "ADMIN" # 管理员 - 完全权限
|
||||
OPERATOR = "OPERATOR" # 操作员 - 可修改数据
|
||||
USER = "USER" # 普通用户 - 读写权限
|
||||
VIEWER = "VIEWER" # 观察者 - 仅查询权限
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
@classmethod
|
||||
def get_hierarchy(cls) -> dict:
|
||||
"""
|
||||
获取角色层级(数字越大权限越高)
|
||||
"""
|
||||
return {
|
||||
cls.VIEWER: 1,
|
||||
cls.USER: 2,
|
||||
cls.OPERATOR: 3,
|
||||
cls.ADMIN: 4,
|
||||
}
|
||||
|
||||
def has_permission(self, required_role: 'UserRole') -> bool:
|
||||
"""
|
||||
检查当前角色是否有足够权限
|
||||
|
||||
Args:
|
||||
required_role: 需要的最低角色
|
||||
|
||||
Returns:
|
||||
True if has permission
|
||||
"""
|
||||
hierarchy = self.get_hierarchy()
|
||||
return hierarchy[self] >= hierarchy[required_role]
|
||||
48
app/domain/schemas/audit.py
Normal file
48
app/domain/schemas/audit.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
class AuditLogCreate(BaseModel):
|
||||
"""创建审计日志"""
|
||||
user_id: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
action: str
|
||||
resource_type: Optional[str] = None
|
||||
resource_id: Optional[str] = None
|
||||
ip_address: Optional[str] = None
|
||||
user_agent: Optional[str] = None
|
||||
request_method: Optional[str] = None
|
||||
request_path: Optional[str] = None
|
||||
request_data: Optional[dict] = None
|
||||
response_status: Optional[int] = None
|
||||
error_message: Optional[str] = None
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
"""审计日志响应"""
|
||||
id: int
|
||||
user_id: Optional[int]
|
||||
username: Optional[str]
|
||||
action: str
|
||||
resource_type: Optional[str]
|
||||
resource_id: Optional[str]
|
||||
ip_address: Optional[str]
|
||||
user_agent: Optional[str]
|
||||
request_method: Optional[str]
|
||||
request_path: Optional[str]
|
||||
request_data: Optional[dict]
|
||||
response_status: Optional[int]
|
||||
error_message: Optional[str]
|
||||
timestamp: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class AuditLogQuery(BaseModel):
|
||||
"""审计日志查询参数"""
|
||||
user_id: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
action: Optional[str] = None
|
||||
resource_type: Optional[str] = None
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
skip: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=100, ge=1, le=1000)
|
||||
68
app/domain/schemas/user.py
Normal file
68
app/domain/schemas/user.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
from app.domain.models.role import UserRole
|
||||
|
||||
# ============================================
|
||||
# Request Schemas (输入)
|
||||
# ============================================
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""用户注册"""
|
||||
username: str = Field(..., min_length=3, max_length=50,
|
||||
description="用户名,3-50个字符")
|
||||
email: EmailStr = Field(..., description="邮箱地址")
|
||||
password: str = Field(..., min_length=6, max_length=100,
|
||||
description="密码,至少6个字符")
|
||||
role: UserRole = Field(default=UserRole.USER, description="用户角色")
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""用户登录"""
|
||||
username: str = Field(..., description="用户名或邮箱")
|
||||
password: str = Field(..., description="密码")
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""用户信息更新"""
|
||||
email: Optional[EmailStr] = None
|
||||
password: Optional[str] = Field(None, min_length=6, max_length=100)
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
# ============================================
|
||||
# Response Schemas (输出)
|
||||
# ============================================
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""用户信息响应(不含密码)"""
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
is_superuser: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class UserInDB(UserResponse):
|
||||
"""数据库中的用户(含密码哈希)"""
|
||||
hashed_password: str
|
||||
|
||||
# ============================================
|
||||
# Token Schemas
|
||||
# ============================================
|
||||
|
||||
class Token(BaseModel):
|
||||
"""JWT Token 响应"""
|
||||
access_token: str
|
||||
refresh_token: Optional[str] = None
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = Field(..., description="过期时间(秒)")
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""JWT Token Payload"""
|
||||
sub: str = Field(..., description="用户ID或用户名")
|
||||
exp: Optional[int] = None
|
||||
iat: Optional[int] = None
|
||||
type: str = Field(default="access", description="token类型: access 或 refresh")
|
||||
189
app/infra/audit/middleware.py
Normal file
189
app/infra/audit/middleware.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
审计日志中间件
|
||||
|
||||
自动记录关键HTTP请求到审计日志
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
from typing import Callable
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.core.audit import log_audit_event, AuditAction
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuditMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
审计中间件
|
||||
|
||||
自动记录以下操作:
|
||||
- 所有 POST/PUT/DELETE 请求
|
||||
- 登录/登出
|
||||
- 关键资源访问
|
||||
"""
|
||||
|
||||
# 需要审计的路径前缀
|
||||
AUDIT_PATHS = [
|
||||
# "/api/v1/auth/",
|
||||
# "/api/v1/users/",
|
||||
# "/api/v1/projects/",
|
||||
# "/api/v1/networks/",
|
||||
]
|
||||
|
||||
# [新增] 需要审计的 API Tags (在 Router 或 api 函数中定义 tags=["Audit"])
|
||||
AUDIT_TAGS = [
|
||||
"Audit",
|
||||
"Users",
|
||||
"Project",
|
||||
"Network General",
|
||||
"Junctions",
|
||||
"Pipes",
|
||||
"Reservoirs",
|
||||
"Tanks",
|
||||
"Pumps",
|
||||
"Valves",
|
||||
]
|
||||
|
||||
# 需要审计的HTTP方法
|
||||
AUDIT_METHODS = ["POST", "PUT", "DELETE", "PATCH"]
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# 提取开始时间
|
||||
start_time = time.time()
|
||||
|
||||
# 1. 预判是否需要读取Body (针对写操作)
|
||||
# 注意:我们暂时移除早期的 return,因为需要等待路由匹配后才能检查 Tag
|
||||
should_capture_body = request.method in ["POST", "PUT", "PATCH"]
|
||||
|
||||
request_data = None
|
||||
if should_capture_body:
|
||||
try:
|
||||
# 注意:读取 body 后需要重新设置,避免影响后续处理
|
||||
body = await request.body()
|
||||
if body:
|
||||
request_data = json.loads(body.decode())
|
||||
|
||||
# 重新构造请求以供后续使用
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": body}
|
||||
|
||||
request._receive = receive
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read request body for audit: {e}")
|
||||
|
||||
# 2. 执行请求 (FastAPI在此过程中进行路由匹配)
|
||||
response = await call_next(request)
|
||||
|
||||
# 3. 决定是否审计
|
||||
# 检查方法
|
||||
is_audit_method = request.method in self.AUDIT_METHODS
|
||||
# 检查路径
|
||||
is_audit_path = any(
|
||||
request.url.path.startswith(path) for path in self.AUDIT_PATHS
|
||||
)
|
||||
# [新增] 检查 Tags (从 request.scope 中获取匹配的路由信息)
|
||||
is_audit_tag = False
|
||||
route = request.scope.get("route")
|
||||
if route and hasattr(route, "tags"):
|
||||
is_audit_tag = any(tag in self.AUDIT_TAGS for tag in route.tags)
|
||||
|
||||
should_audit = is_audit_method or is_audit_path or is_audit_tag
|
||||
|
||||
if not should_audit:
|
||||
# 即便不审计,也要处理响应头中的时间(保持原有逻辑一致性)
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
|
||||
# 4. 提取审计所需信息
|
||||
user_id = None
|
||||
username = None
|
||||
|
||||
# 尝试从请求状态获取当前用户
|
||||
if hasattr(request.state, "user"):
|
||||
user = request.state.user
|
||||
user_id = getattr(user, "id", None)
|
||||
username = getattr(user, "username", None)
|
||||
|
||||
# 获取客户端信息
|
||||
ip_address = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent")
|
||||
|
||||
# 确定操作类型
|
||||
action = self._determine_action(request)
|
||||
resource_type, resource_id = self._extract_resource_info(request)
|
||||
|
||||
# 记录审计日志
|
||||
try:
|
||||
await log_audit_event(
|
||||
action=action,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=str(request.url.path),
|
||||
request_data=request_data,
|
||||
response_status=response.status_code,
|
||||
error_message=(
|
||||
None
|
||||
if response.status_code < 400
|
||||
else f"HTTP {response.status_code}"
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
# 审计失败不应影响响应
|
||||
logger.error(f"Failed to log audit event: {e}", exc_info=True)
|
||||
|
||||
# 添加处理时间到响应头
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
return response
|
||||
|
||||
def _determine_action(self, request: Request) -> str:
|
||||
"""根据请求路径和方法确定操作类型"""
|
||||
path = request.url.path.lower()
|
||||
method = request.method
|
||||
|
||||
# 认证相关
|
||||
if "login" in path:
|
||||
return AuditAction.LOGIN
|
||||
elif "logout" in path:
|
||||
return AuditAction.LOGOUT
|
||||
elif "register" in path:
|
||||
return AuditAction.REGISTER
|
||||
|
||||
# CRUD 操作
|
||||
if method == "POST":
|
||||
return AuditAction.CREATE
|
||||
elif method == "PUT" or method == "PATCH":
|
||||
return AuditAction.UPDATE
|
||||
elif method == "DELETE":
|
||||
return AuditAction.DELETE
|
||||
elif method == "GET":
|
||||
return AuditAction.READ
|
||||
|
||||
return f"{method}_REQUEST"
|
||||
|
||||
def _extract_resource_info(self, request: Request) -> tuple:
|
||||
"""从请求路径提取资源类型和ID"""
|
||||
path_parts = request.url.path.strip("/").split("/")
|
||||
|
||||
resource_type = None
|
||||
resource_id = None
|
||||
|
||||
# 尝试从路径中提取资源信息
|
||||
# 例如: /api/v1/users/123 -> resource_type=user, resource_id=123
|
||||
if len(path_parts) >= 4:
|
||||
resource_type = path_parts[3].rstrip("s") # 移除复数s
|
||||
|
||||
if len(path_parts) >= 5 and path_parts[4].isdigit():
|
||||
resource_id = path_parts[4]
|
||||
|
||||
return resource_type, resource_id
|
||||
220
app/infra/repositories/audit_repository.py
Normal file
220
app/infra/repositories/audit_repository.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
from app.infra.db.postgresql.database import Database
|
||||
from app.domain.schemas.audit import AuditLogCreate, AuditLogResponse
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuditRepository:
|
||||
"""审计日志数据访问层"""
|
||||
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
|
||||
async def create_log(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: str = "",
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
request_method: Optional[str] = None,
|
||||
request_path: Optional[str] = None,
|
||||
request_data: Optional[dict] = None,
|
||||
response_status: Optional[int] = None,
|
||||
error_message: Optional[str] = None
|
||||
) -> Optional[AuditLogResponse]:
|
||||
"""
|
||||
创建审计日志
|
||||
|
||||
Args:
|
||||
参数说明见 AuditLogCreate
|
||||
|
||||
Returns:
|
||||
创建的审计日志对象
|
||||
"""
|
||||
query = """
|
||||
INSERT INTO audit_logs (
|
||||
user_id, username, action, resource_type, resource_id,
|
||||
ip_address, user_agent, request_method, request_path,
|
||||
request_data, response_status, error_message
|
||||
)
|
||||
VALUES (
|
||||
%(user_id)s, %(username)s, %(action)s, %(resource_type)s, %(resource_id)s,
|
||||
%(ip_address)s, %(user_agent)s, %(request_method)s, %(request_path)s,
|
||||
%(request_data)s, %(response_status)s, %(error_message)s
|
||||
)
|
||||
RETURNING id, user_id, username, action, resource_type, resource_id,
|
||||
ip_address, user_agent, request_method, request_path,
|
||||
request_data, response_status, error_message, timestamp
|
||||
"""
|
||||
|
||||
try:
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {
|
||||
'user_id': user_id,
|
||||
'username': username,
|
||||
'action': action,
|
||||
'resource_type': resource_type,
|
||||
'resource_id': resource_id,
|
||||
'ip_address': ip_address,
|
||||
'user_agent': user_agent,
|
||||
'request_method': request_method,
|
||||
'request_path': request_path,
|
||||
'request_data': json.dumps(request_data) if request_data else None,
|
||||
'response_status': response_status,
|
||||
'error_message': error_message
|
||||
})
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return AuditLogResponse(**row)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating audit log: {e}")
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
async def get_logs(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100
|
||||
) -> List[AuditLogResponse]:
|
||||
"""
|
||||
查询审计日志
|
||||
|
||||
Args:
|
||||
user_id: 用户ID过滤
|
||||
username: 用户名过滤
|
||||
action: 操作类型过滤
|
||||
resource_type: 资源类型过滤
|
||||
start_time: 开始时间
|
||||
end_time: 结束时间
|
||||
skip: 跳过记录数
|
||||
limit: 限制记录数
|
||||
|
||||
Returns:
|
||||
审计日志列表
|
||||
"""
|
||||
# 构建动态查询
|
||||
conditions = []
|
||||
params = {'skip': skip, 'limit': limit}
|
||||
|
||||
if user_id is not None:
|
||||
conditions.append("user_id = %(user_id)s")
|
||||
params['user_id'] = user_id
|
||||
|
||||
if username:
|
||||
conditions.append("username = %(username)s")
|
||||
params['username'] = username
|
||||
|
||||
if action:
|
||||
conditions.append("action = %(action)s")
|
||||
params['action'] = action
|
||||
|
||||
if resource_type:
|
||||
conditions.append("resource_type = %(resource_type)s")
|
||||
params['resource_type'] = resource_type
|
||||
|
||||
if start_time:
|
||||
conditions.append("timestamp >= %(start_time)s")
|
||||
params['start_time'] = start_time
|
||||
|
||||
if end_time:
|
||||
conditions.append("timestamp <= %(end_time)s")
|
||||
params['end_time'] = end_time
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
query = f"""
|
||||
SELECT id, user_id, username, action, resource_type, resource_id,
|
||||
ip_address, user_agent, request_method, request_path,
|
||||
request_data, response_status, error_message, timestamp
|
||||
FROM audit_logs
|
||||
{where_clause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT %(limit)s OFFSET %(skip)s
|
||||
"""
|
||||
|
||||
try:
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, params)
|
||||
rows = await cur.fetchall()
|
||||
return [AuditLogResponse(**row) for row in rows]
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying audit logs: {e}")
|
||||
raise
|
||||
|
||||
async def get_log_count(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None
|
||||
) -> int:
|
||||
"""
|
||||
获取审计日志数量
|
||||
|
||||
Args:
|
||||
参数同 get_logs
|
||||
|
||||
Returns:
|
||||
日志总数
|
||||
"""
|
||||
conditions = []
|
||||
params = {}
|
||||
|
||||
if user_id is not None:
|
||||
conditions.append("user_id = %(user_id)s")
|
||||
params['user_id'] = user_id
|
||||
|
||||
if username:
|
||||
conditions.append("username = %(username)s")
|
||||
params['username'] = username
|
||||
|
||||
if action:
|
||||
conditions.append("action = %(action)s")
|
||||
params['action'] = action
|
||||
|
||||
if resource_type:
|
||||
conditions.append("resource_type = %(resource_type)s")
|
||||
params['resource_type'] = resource_type
|
||||
|
||||
if start_time:
|
||||
conditions.append("timestamp >= %(start_time)s")
|
||||
params['start_time'] = start_time
|
||||
|
||||
if end_time:
|
||||
conditions.append("timestamp <= %(end_time)s")
|
||||
params['end_time'] = end_time
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
|
||||
query = f"""
|
||||
SELECT COUNT(*) as count
|
||||
FROM audit_logs
|
||||
{where_clause}
|
||||
"""
|
||||
|
||||
try:
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, params)
|
||||
result = await cur.fetchone()
|
||||
return result['count'] if result else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error counting audit logs: {e}")
|
||||
return 0
|
||||
235
app/infra/repositories/user_repository.py
Normal file
235
app/infra/repositories/user_repository.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from app.infra.db.postgresql.database import Database
|
||||
from app.domain.schemas.user import UserCreate, UserUpdate, UserInDB
|
||||
from app.domain.models.role import UserRole
|
||||
from app.core.security import get_password_hash
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class UserRepository:
|
||||
"""用户数据访问层"""
|
||||
|
||||
def __init__(self, db: Database):
|
||||
self.db = db
|
||||
|
||||
async def create_user(self, user: UserCreate) -> Optional[UserInDB]:
|
||||
"""
|
||||
创建新用户
|
||||
|
||||
Args:
|
||||
user: 用户创建数据
|
||||
|
||||
Returns:
|
||||
创建的用户对象
|
||||
"""
|
||||
hashed_password = get_password_hash(user.password)
|
||||
|
||||
query = """
|
||||
INSERT INTO users (username, email, hashed_password, role, is_active, is_superuser)
|
||||
VALUES (%(username)s, %(email)s, %(hashed_password)s, %(role)s, TRUE, FALSE)
|
||||
RETURNING id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
"""
|
||||
|
||||
try:
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'hashed_password': hashed_password,
|
||||
'role': user.role.value
|
||||
})
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user: {e}")
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
async def get_user_by_id(self, user_id: int) -> Optional[UserInDB]:
|
||||
"""根据ID获取用户"""
|
||||
query = """
|
||||
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = %(user_id)s
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'user_id': user_id})
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
|
||||
return None
|
||||
|
||||
async def get_user_by_username(self, username: str) -> Optional[UserInDB]:
|
||||
"""根据用户名获取用户"""
|
||||
query = """
|
||||
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
WHERE username = %(username)s
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'username': username})
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
|
||||
return None
|
||||
|
||||
async def get_user_by_email(self, email: str) -> Optional[UserInDB]:
|
||||
"""根据邮箱获取用户"""
|
||||
query = """
|
||||
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = %(email)s
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'email': email})
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
|
||||
return None
|
||||
|
||||
async def get_all_users(self, skip: int = 0, limit: int = 100) -> List[UserInDB]:
|
||||
"""获取所有用户(分页)"""
|
||||
query = """
|
||||
SELECT id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %(limit)s OFFSET %(skip)s
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'skip': skip, 'limit': limit})
|
||||
rows = await cur.fetchall()
|
||||
return [UserInDB(**row) for row in rows]
|
||||
|
||||
async def update_user(self, user_id: int, user_update: UserUpdate) -> Optional[UserInDB]:
|
||||
"""
|
||||
更新用户信息
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
user_update: 更新数据
|
||||
|
||||
Returns:
|
||||
更新后的用户对象
|
||||
"""
|
||||
# 构建动态更新语句
|
||||
update_fields = []
|
||||
params = {'user_id': user_id}
|
||||
|
||||
if user_update.email is not None:
|
||||
update_fields.append("email = %(email)s")
|
||||
params['email'] = user_update.email
|
||||
|
||||
if user_update.password is not None:
|
||||
update_fields.append("hashed_password = %(hashed_password)s")
|
||||
params['hashed_password'] = get_password_hash(user_update.password)
|
||||
|
||||
if user_update.role is not None:
|
||||
update_fields.append("role = %(role)s")
|
||||
params['role'] = user_update.role.value
|
||||
|
||||
if user_update.is_active is not None:
|
||||
update_fields.append("is_active = %(is_active)s")
|
||||
params['is_active'] = user_update.is_active
|
||||
|
||||
if not update_fields:
|
||||
return await self.get_user_by_id(user_id)
|
||||
|
||||
query = f"""
|
||||
UPDATE users
|
||||
SET {', '.join(update_fields)}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %(user_id)s
|
||||
RETURNING id, username, email, hashed_password, role, is_active, is_superuser,
|
||||
created_at, updated_at
|
||||
"""
|
||||
|
||||
try:
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, params)
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
return UserInDB(**row)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user {user_id}: {e}")
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
async def delete_user(self, user_id: int) -> bool:
|
||||
"""
|
||||
删除用户
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
是否成功删除
|
||||
"""
|
||||
query = "DELETE FROM users WHERE id = %(user_id)s"
|
||||
|
||||
try:
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, {'user_id': user_id})
|
||||
return cur.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting user {user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def user_exists(self, username: str = None, email: str = None) -> bool:
|
||||
"""
|
||||
检查用户是否存在
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
email: 邮箱
|
||||
|
||||
Returns:
|
||||
是否存在
|
||||
"""
|
||||
conditions = []
|
||||
params = {}
|
||||
|
||||
if username:
|
||||
conditions.append("username = %(username)s")
|
||||
params['username'] = username
|
||||
|
||||
if email:
|
||||
conditions.append("email = %(email)s")
|
||||
params['email'] = email
|
||||
|
||||
if not conditions:
|
||||
return False
|
||||
|
||||
query = f"""
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM users WHERE {' OR '.join(conditions)}
|
||||
)
|
||||
"""
|
||||
|
||||
async with self.db.get_connection() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(query, params)
|
||||
result = await cur.fetchone()
|
||||
return result['exists'] if result else False
|
||||
26
app/main.py
26
app/main.py
@@ -10,6 +10,10 @@ from app.api.v1.router import api_router
|
||||
from app.infra.db.timescaledb.database import db as tsdb
|
||||
from app.infra.db.postgresql.database import db as pgdb
|
||||
from app.services.tjnetwork import open_project
|
||||
from app.core.config import settings
|
||||
|
||||
# 导入审计中间件
|
||||
from app.infra.audit.middleware import AuditMiddleware
|
||||
|
||||
|
||||
logger = logging.getLogger()
|
||||
@@ -29,6 +33,10 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
await tsdb.open()
|
||||
await pgdb.open()
|
||||
|
||||
# 将数据库实例存储到 app.state,供依赖项使用
|
||||
app.state.db = pgdb
|
||||
logger.info("Database connection pool initialized and stored in app.state")
|
||||
|
||||
if project_info.name:
|
||||
print(project_info.name)
|
||||
@@ -36,11 +44,19 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
yield
|
||||
# 清理资源
|
||||
tsdb.close()
|
||||
pgdb.close()
|
||||
await tsdb.close()
|
||||
await pgdb.close()
|
||||
logger.info("Database connections closed")
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
title=settings.PROJECT_NAME,
|
||||
description="TJWater Server - 供水管网智能管理系统",
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
# 配置 CORS 中间件
|
||||
app.add_middleware(
|
||||
@@ -53,6 +69,10 @@ app.add_middleware(
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# 添加审计中间件(可选,记录关键操作)
|
||||
# 如果需要启用审计日志,取消下面的注释
|
||||
# app.add_middleware(AuditMiddleware)
|
||||
|
||||
# Include Routers
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
# Legcy Routers without version prefix
|
||||
|
||||
Reference in New Issue
Block a user