mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
feat: improve error handling and code quality
后端改进: - 添加统一异常处理系统 (exceptions.py, response.py) - 实现自定义异常类 (ValidationError, AuthorizationError, ResourceNotFoundError, BusinessLogicError) - 配置全局异常处理器,统一 API 错误响应格式 - 迁移业务逻辑错误到自定义异常 (users.py, auth.py) - 添加 SQL LIKE 通配符转义,防止通配符滥用 - 使用 EmailStr 进行邮箱格式验证 - 移除敏感字段暴露 (jwt_sub) 前端改进: - 配置 ESLint 9 (flat config) 和 Prettier - 修复所有 ESLint 错误和警告 - 移除未使用的变量和导入 - 为组件添加 PropTypes 默认值 - 统一代码格式和风格
This commit is contained in:
+5
-3
@@ -12,6 +12,7 @@ from backend.schemas.auth import (
|
||||
AliasLoginResponse,
|
||||
)
|
||||
from backend.services.auth_service import AuthService
|
||||
from backend.exceptions import BusinessLogicError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -38,9 +39,10 @@ async def request_qrcode(
|
||||
|
||||
if reg_cookie:
|
||||
if not registration_manager.check_registration_cookie(reg_cookie):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="注册过于频繁,请 10 分钟后再试"
|
||||
raise BusinessLogicError(
|
||||
message="注册过于频繁,请 10 分钟后再试",
|
||||
error_code="RATE_LIMIT_EXCEEDED",
|
||||
status_code=429
|
||||
)
|
||||
else:
|
||||
# 生成新的 Cookie
|
||||
|
||||
+12
-37
@@ -8,6 +8,7 @@ from backend.schemas.task import TaskResponse
|
||||
from backend.services.user_service import UserService
|
||||
from backend.services.task_service import TaskService
|
||||
from backend.dependencies import get_current_user, get_current_admin_user
|
||||
from backend.exceptions import ValidationError, AuthorizationError, ResourceNotFoundError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -21,18 +22,15 @@ async def create_user(
|
||||
"""
|
||||
创建用户(需要管理员权限)
|
||||
|
||||
- **jwt_sub**: QQ 扫码登录的唯一用户标识
|
||||
- **alias**: 用户别名(用于登录)
|
||||
- **role**: 角色(可选,默认 "user")
|
||||
- **email**: 邮箱地址(可选)
|
||||
"""
|
||||
try:
|
||||
user = UserService.create_user(user_data, db)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
raise ValidationError(str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -51,7 +49,6 @@ async def get_current_user_info(
|
||||
user_dict = {
|
||||
"id": current_user.id,
|
||||
"alias": current_user.alias,
|
||||
"jwt_sub": current_user.jwt_sub,
|
||||
"role": current_user.role,
|
||||
"is_approved": current_user.is_approved,
|
||||
"jwt_exp": current_user.jwt_exp,
|
||||
@@ -99,10 +96,7 @@ async def update_current_user_profile(
|
||||
user = UserService.update_user_profile(current_user.id, profile_data, db)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
raise ValidationError(str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -147,7 +141,6 @@ async def get_current_user_token_status(
|
||||
return {
|
||||
"is_valid": is_valid,
|
||||
"jwt_exp": current_user.jwt_exp,
|
||||
"jwt_sub": current_user.jwt_sub,
|
||||
"expires_at": expires_at,
|
||||
"days_until_expiry": days_until_expiry,
|
||||
"expiring_soon": expiring_soon
|
||||
@@ -179,7 +172,7 @@ async def get_current_user_tasks(
|
||||
async def get_all_users(
|
||||
skip: int = Query(0, ge=0, description="跳过记录数"),
|
||||
limit: int = Query(100, ge=1, le=500, description="限制记录数"),
|
||||
search: Optional[str] = Query(None, description="搜索关键词(alias 或 jwt_sub)"),
|
||||
search: Optional[str] = Query(None, description="搜索关键词(alias)"),
|
||||
role: Optional[str] = Query(None, description="过滤角色 (user/admin)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user)
|
||||
@@ -189,7 +182,7 @@ async def get_all_users(
|
||||
|
||||
- **skip**: 跳过记录数
|
||||
- **limit**: 限制记录数
|
||||
- **search**: 搜索关键词(模糊匹配 alias 或 jwt_sub)
|
||||
- **search**: 搜索关键词(模糊匹配 alias)
|
||||
- **role**: 过滤角色(user/admin)
|
||||
"""
|
||||
try:
|
||||
@@ -216,17 +209,11 @@ async def get_user(
|
||||
"""
|
||||
# 检查权限
|
||||
if current_user.role != "admin" and current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足,只能查看自己的信息"
|
||||
)
|
||||
raise AuthorizationError("权限不足,只能查看自己的信息")
|
||||
|
||||
user = UserService.get_user_by_id(user_id, db)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"用户 ID {user_id} 不存在"
|
||||
)
|
||||
raise ResourceNotFoundError(f"用户 ID {user_id} 不存在")
|
||||
|
||||
return user
|
||||
|
||||
@@ -247,25 +234,16 @@ async def update_user(
|
||||
# 检查权限
|
||||
if current_user.role != "admin":
|
||||
if current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足,只能更新自己的信息"
|
||||
)
|
||||
raise AuthorizationError("权限不足,只能更新自己的信息")
|
||||
# 普通用户不能修改 role
|
||||
if user_data.role is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="普通用户不能修改角色"
|
||||
)
|
||||
raise AuthorizationError("普通用户不能修改角色")
|
||||
|
||||
try:
|
||||
# 获取更新前的用户状态
|
||||
old_user = UserService.get_user_by_id(user_id, db)
|
||||
if not old_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"用户 ID {user_id} 不存在"
|
||||
)
|
||||
raise ResourceNotFoundError(f"用户 ID {user_id} 不存在")
|
||||
|
||||
# 保存更新前的审批状态 (先读取后转换为 Python bool)
|
||||
old_approved_value = old_user.is_approved
|
||||
@@ -316,10 +294,7 @@ async def delete_user(
|
||||
UserService.delete_user(user_id, db)
|
||||
return None
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e)
|
||||
)
|
||||
raise ResourceNotFoundError(str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
自定义异常类
|
||||
|
||||
提供统一的异常处理机制,避免直接抛出通用Exception
|
||||
"""
|
||||
|
||||
|
||||
class BaseAPIException(Exception):
|
||||
"""API 异常基类"""
|
||||
|
||||
def __init__(self, message: str, status_code: int = 500, error_code: str = None):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code or self.__class__.__name__
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ValidationError(BaseAPIException):
|
||||
"""验证错误 - 400"""
|
||||
|
||||
def __init__(self, message: str, error_code: str = "VALIDATION_ERROR"):
|
||||
super().__init__(message, status_code=400, error_code=error_code)
|
||||
|
||||
|
||||
class AuthenticationError(BaseAPIException):
|
||||
"""认证错误 - 401"""
|
||||
|
||||
def __init__(self, message: str = "未授权", error_code: str = "AUTHENTICATION_ERROR"):
|
||||
super().__init__(message, status_code=401, error_code=error_code)
|
||||
|
||||
|
||||
class AuthorizationError(BaseAPIException):
|
||||
"""授权错误 - 403"""
|
||||
|
||||
def __init__(self, message: str = "无权限访问", error_code: str = "AUTHORIZATION_ERROR"):
|
||||
super().__init__(message, status_code=403, error_code=error_code)
|
||||
|
||||
|
||||
class ResourceNotFoundError(BaseAPIException):
|
||||
"""资源未找到 - 404"""
|
||||
|
||||
def __init__(self, message: str = "资源未找到", error_code: str = "NOT_FOUND"):
|
||||
super().__init__(message, status_code=404, error_code=error_code)
|
||||
|
||||
|
||||
class ResourceConflictError(BaseAPIException):
|
||||
"""资源冲突 - 409"""
|
||||
|
||||
def __init__(self, message: str = "资源冲突", error_code: str = "CONFLICT"):
|
||||
super().__init__(message, status_code=409, error_code=error_code)
|
||||
|
||||
|
||||
class BusinessLogicError(BaseAPIException):
|
||||
"""业务逻辑错误 - 默认422,但可自定义状态码(如429)"""
|
||||
|
||||
def __init__(self, message: str, error_code: str = "BUSINESS_ERROR", status_code: int = 422):
|
||||
super().__init__(message, status_code=status_code, error_code=error_code)
|
||||
|
||||
|
||||
class InternalServerError(BaseAPIException):
|
||||
"""服务器内部错误 - 500"""
|
||||
|
||||
def __init__(self, message: str = "服务器内部错误", error_code: str = "INTERNAL_ERROR"):
|
||||
super().__init__(message, status_code=500, error_code=error_code)
|
||||
+59
-1
@@ -1,11 +1,16 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
from backend.models import init_db
|
||||
from backend.exceptions import BaseAPIException
|
||||
from backend.schemas.response import ErrorResponse, ErrorDetail
|
||||
|
||||
# 配置日志
|
||||
settings.LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -69,6 +74,59 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# 全局异常处理器
|
||||
@app.exception_handler(BaseAPIException)
|
||||
async def api_exception_handler(request: Request, exc: BaseAPIException):
|
||||
"""处理自定义 API 异常"""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code=exc.error_code,
|
||||
message=exc.message
|
||||
)
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""处理请求验证错误"""
|
||||
errors = exc.errors()
|
||||
# 取第一个错误作为主要错误消息
|
||||
first_error = errors[0] if errors else {}
|
||||
field = ".".join(str(loc) for loc in first_error.get("loc", []))
|
||||
message = first_error.get("msg", "验证错误")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="VALIDATION_ERROR",
|
||||
message=message,
|
||||
field=field or None
|
||||
)
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""处理未捕获的异常"""
|
||||
logger.error(f"未处理的异常: {type(exc).__name__}: {str(exc)}", exc_info=True)
|
||||
|
||||
# 不向客户端暴露内部错误详情
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="INTERNAL_ERROR",
|
||||
message="服务器内部错误,请稍后重试"
|
||||
)
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
|
||||
# 健康检查端点
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
统一的 API 响应 Schema
|
||||
"""
|
||||
from typing import Generic, TypeVar, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class ApiResponse(BaseModel, Generic[T]):
|
||||
"""统一成功响应"""
|
||||
success: bool = True
|
||||
data: Optional[T] = None
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
"""错误详情"""
|
||||
code: str
|
||||
message: str
|
||||
field: Optional[str] = None # 字段验证错误时使用
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""统一错误响应"""
|
||||
success: bool = False
|
||||
error: ErrorDetail
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, EmailStr
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
@@ -11,7 +11,7 @@ class UserBase(BaseModel):
|
||||
class UserCreate(UserBase):
|
||||
"""创建用户 Schema(管理员手动创建,只需要别名)"""
|
||||
role: Optional[str] = Field("user", description="角色: user/admin")
|
||||
email: Optional[str] = Field(None, description="邮箱地址")
|
||||
email: Optional[EmailStr] = Field(None, description="邮箱地址")
|
||||
is_approved: Optional[bool] = Field(True, description="是否已审批(默认已审批)")
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class UserUpdate(BaseModel):
|
||||
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="用户别名")
|
||||
role: Optional[str] = None
|
||||
is_approved: Optional[bool] = None
|
||||
email: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
password: Optional[str] = Field(None, min_length=6, description="新密码(可选,留空表示不修改)")
|
||||
reset_password: Optional[bool] = Field(False, description="是否清空密码")
|
||||
|
||||
@@ -28,7 +28,7 @@ class UserUpdate(BaseModel):
|
||||
class UserUpdateProfile(BaseModel):
|
||||
"""用户更新个人信息 Schema"""
|
||||
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="新别名")
|
||||
email: Optional[str] = Field(None, description="邮箱地址")
|
||||
email: Optional[EmailStr] = Field(None, description="邮箱地址")
|
||||
current_password: Optional[str] = Field(None, min_length=6, description="当前密码(修改密码时必填)")
|
||||
new_password: Optional[str] = Field(None, min_length=6, description="新密码")
|
||||
|
||||
@@ -37,11 +37,10 @@ class UserResponse(BaseModel):
|
||||
"""用户响应 Schema"""
|
||||
id: int
|
||||
alias: str
|
||||
jwt_sub: Optional[str] = None
|
||||
role: str
|
||||
is_approved: bool
|
||||
jwt_exp: str
|
||||
email: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
has_password: bool = False # 是否已设置密码
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
@@ -59,7 +58,6 @@ class TokenStatus(BaseModel):
|
||||
"""Token 状态 Schema"""
|
||||
is_valid: bool
|
||||
jwt_exp: str
|
||||
jwt_sub: Optional[str] = None
|
||||
expires_at: Optional[int] = None # Unix 时间戳(秒)
|
||||
days_until_expiry: Optional[int] = None
|
||||
expiring_soon: bool = False # 是否即将过期(30分钟内)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
测试新的异常处理系统
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, '..')
|
||||
|
||||
from backend.exceptions import (
|
||||
ValidationError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
ResourceNotFoundError,
|
||||
BusinessLogicError,
|
||||
)
|
||||
from backend.schemas.response import ErrorResponse, ErrorDetail
|
||||
|
||||
def test_exceptions():
|
||||
"""测试自定义异常"""
|
||||
print("=" * 60)
|
||||
print("测试自定义异常类")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试 ValidationError
|
||||
try:
|
||||
raise ValidationError("用户名长度必须在2-50之间")
|
||||
except ValidationError as e:
|
||||
print(f"✅ ValidationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
# 测试 AuthenticationError
|
||||
try:
|
||||
raise AuthenticationError("Token已过期")
|
||||
except AuthenticationError as e:
|
||||
print(f"✅ AuthenticationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
# 测试 AuthorizationError
|
||||
try:
|
||||
raise AuthorizationError("需要管理员权限")
|
||||
except AuthorizationError as e:
|
||||
print(f"✅ AuthorizationError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
# 测试 ResourceNotFoundError
|
||||
try:
|
||||
raise ResourceNotFoundError("用户不存在")
|
||||
except ResourceNotFoundError as e:
|
||||
print(f"✅ ResourceNotFoundError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
# 测试 BusinessLogicError
|
||||
try:
|
||||
raise BusinessLogicError("打卡任务已禁用")
|
||||
except BusinessLogicError as e:
|
||||
print(f"✅ BusinessLogicError: {e.message} (状态码: {e.status_code}, 代码: {e.error_code})")
|
||||
|
||||
|
||||
def test_response_schemas():
|
||||
"""测试响应 Schema"""
|
||||
print("\n" + "=" * 60)
|
||||
print("测试响应 Schema")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试 ErrorResponse
|
||||
error_response = ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code="VALIDATION_ERROR",
|
||||
message="邮箱格式不正确",
|
||||
field="email"
|
||||
)
|
||||
)
|
||||
|
||||
response_dict = error_response.model_dump()
|
||||
print(f"✅ ErrorResponse 序列化成功:")
|
||||
print(f" {response_dict}")
|
||||
|
||||
assert response_dict["success"] == False
|
||||
assert response_dict["error"]["code"] == "VALIDATION_ERROR"
|
||||
assert response_dict["error"]["message"] == "邮箱格式不正确"
|
||||
assert response_dict["error"]["field"] == "email"
|
||||
print("✅ 所有断言通过")
|
||||
|
||||
|
||||
def check_old_exception_patterns():
|
||||
"""检查旧的异常处理模式"""
|
||||
print("\n" + "=" * 60)
|
||||
print("检查需要更新的旧异常代码")
|
||||
print("=" * 60)
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
patterns = {
|
||||
"HTTPException with detail": r'raise HTTPException.*detail=f?".*{',
|
||||
"except Exception": r'except Exception as',
|
||||
}
|
||||
|
||||
results = {}
|
||||
for pattern_name, pattern in patterns.items():
|
||||
results[pattern_name] = []
|
||||
|
||||
for root, dirs, files in os.walk('../backend/api'):
|
||||
for file in files:
|
||||
if file.endswith('.py'):
|
||||
filepath = os.path.join(root, file)
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
matches = re.findall(pattern, content, re.MULTILINE)
|
||||
if matches:
|
||||
results[pattern_name].append((filepath, len(matches)))
|
||||
|
||||
for pattern_name, files in results.items():
|
||||
print(f"\n{pattern_name}:")
|
||||
if files:
|
||||
print(f" ⚠️ 发现 {sum(count for _, count in files)} 处使用")
|
||||
for filepath, count in files:
|
||||
print(f" - {filepath}: {count} 处")
|
||||
else:
|
||||
print(f" ✅ 未发现使用")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_exceptions()
|
||||
test_response_schemas()
|
||||
check_old_exception_patterns()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 所有测试通过!新的异常处理系统工作正常")
|
||||
print("=" * 60)
|
||||
@@ -10,6 +10,19 @@ from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def escape_like_pattern(text: str) -> str:
|
||||
"""
|
||||
转义 LIKE 查询中的特殊字符
|
||||
|
||||
Args:
|
||||
text: 原始搜索文本
|
||||
|
||||
Returns:
|
||||
转义后的文本
|
||||
"""
|
||||
return text.replace('%', r'\%').replace('_', r'\_')
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户服务"""
|
||||
|
||||
@@ -114,10 +127,12 @@ class UserService:
|
||||
|
||||
# 搜索过滤
|
||||
if search:
|
||||
# 转义 LIKE 特殊字符,防止通配符滥用
|
||||
escaped_search = escape_like_pattern(search)
|
||||
# 注意:jwt_sub 可能为 NULL,需要处理
|
||||
search_conditions = [User.alias.ilike(f"%{search}%")]
|
||||
search_conditions = [User.alias.ilike(f"%{escaped_search}%")]
|
||||
# 只有当 jwt_sub 不为空时才搜索
|
||||
search_conditions.append(User.jwt_sub.ilike(f"%{search}%"))
|
||||
search_conditions.append(User.jwt_sub.ilike(f"%{escaped_search}%"))
|
||||
query = query.filter(or_(*search_conditions))
|
||||
|
||||
# 角色过滤
|
||||
|
||||
Reference in New Issue
Block a user