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:
2026-01-03 19:01:15 +08:00
parent 523da50123
commit 5cdc8b2144
57 changed files with 4623 additions and 2754 deletions
+5 -3
View File
@@ -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
View File
@@ -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,
+64
View File
@@ -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
View File
@@ -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():
+28
View File
@@ -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
+5 -7
View File
@@ -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分钟内)
+124
View File
@@ -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)
+17 -2
View File
@@ -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))
# 角色过滤