mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-14 21:07:40 +00:00
refactor(backend): 错误码收敛为单一事实来源,统一异常写法
错误码(t16)
- HTTP 语义只由 core/exceptions.py 的 RET.code → fastapi.status 映射决定;
映射表显式登记 -1→400 与全部「数值即 HTTP 状态」的 RET 成员,_DEFAULT_BUSINESS_STATUS
降为「新增码忘登记」的安全网
- 移除冗余 status_code:131 处调用点改为纯 code 表达;security.py/dependencies.py
的 status_code=401/403 去除(映射表已给);get_or_404 → code=RET.NOT_FOUND.code
- 新增静态护栏(AST):app/ 下除 exceptions.py 外不得出现 status_code 传参,
路由装饰器 201 白名单显式放行并断言白名单非空
异常写法统一(t19)
- 删除 CustomException.internal 及 132 处包装:意外异常直接冒泡,由全局
@app.exception_handler(Exception) 统一映射 5xx + 通用文案 + 日志;
需要运维上下文处改为 logger.exception("<业务动作>失败") + raise
- 构造器不再接受 status_code 参数(属性保留,由 code 派生)→ 结构上杜绝第二来源
- 修复 6 处「宽泛 except 把 400 包成 500」的语义反转(补 except CustomException: raise),
并加 AST 护栏防止回潮
- 全局 SQLAlchemyError:非完整性错误由 400 改为 503(连接类)/500(其它),
不再把驱动原文/异常类型拼进客户端 msg;顺带修掉不存在的
status.HTTP_403_SERVICE_UNAVAILABLE 常量(命中即 AttributeError→500)
验证:pytest 97 passed / ruff 全绿;AST 审计残留 0;护栏红绿测试通过;
线上实测验证码过期仍 400 并保留业务文案、无令牌 401。
This commit is contained in:
@@ -10,9 +10,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload, load_only, selectinload
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from app.common.enums import RET
|
||||
from app.core.base_model import ModelMixin
|
||||
from app.core.base_schema import AuthSchema, PageResultSchema
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
|
||||
OutSchemaType = TypeVar("OutSchemaType", bound=BaseModel)
|
||||
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||
@@ -77,8 +79,9 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
sql = sql.options(opt)
|
||||
result: Result = await self.db.execute(sql)
|
||||
return result.scalars().first()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"获取查询失败: {e!s}", status_code=500) from e
|
||||
except Exception:
|
||||
logger.exception('获取查询失败')
|
||||
raise
|
||||
|
||||
async def get_or_404(
|
||||
self,
|
||||
@@ -97,7 +100,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
kwargs["id"] = id
|
||||
obj = await self.get(preload=preload, include_deleted=include_deleted, **kwargs)
|
||||
if not obj:
|
||||
raise CustomException(msg=msg, status_code=404)
|
||||
raise CustomException(msg=msg, code=RET.NOT_FOUND.code)
|
||||
return out_schema.model_validate(obj) if out_schema else obj
|
||||
|
||||
async def exists(self, include_deleted: bool = False, **kwargs) -> bool:
|
||||
@@ -111,8 +114,9 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
count_sql = select(func.count()).select_from(self.model).where(*conditions)
|
||||
result: Result = await self.db.execute(count_sql)
|
||||
return result.scalar() or 0
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"统计失败: {e!s}", status_code=500) from e
|
||||
except Exception:
|
||||
logger.exception('统计失败')
|
||||
raise
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
@@ -133,8 +137,9 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
sql = sql.options(opt)
|
||||
result: Result = await self.db.execute(sql)
|
||||
return result.scalars().all()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"列表查询失败: {e!s}", status_code=500) from e
|
||||
except Exception:
|
||||
logger.exception('列表查询失败')
|
||||
raise
|
||||
|
||||
async def page(
|
||||
self,
|
||||
@@ -181,8 +186,9 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
has_next=offset + limit < total,
|
||||
items=items,
|
||||
)
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"分页查询失败: {e!s}", status_code=500) from e
|
||||
except Exception:
|
||||
logger.exception('分页查询失败')
|
||||
raise
|
||||
|
||||
# ── 写入 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -217,8 +223,9 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
obj = result.scalar_one()
|
||||
|
||||
return obj
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"创建失败: {e!s}", status_code=500) from e
|
||||
except Exception:
|
||||
logger.exception('创建失败')
|
||||
raise
|
||||
|
||||
async def update(self, id: int, data: UpdateSchemaType | dict[str, Any]) -> ModelType:
|
||||
"""更新记录。用 exclude_unset / exclude_none 准确表达前端意图。"""
|
||||
@@ -255,8 +262,9 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
return obj
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"更新失败: {e!s}", status_code=500) from e
|
||||
except Exception:
|
||||
logger.exception('更新失败')
|
||||
raise
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
"""软删除优先,无软删除则物理删除。"""
|
||||
@@ -271,8 +279,9 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
sql = delete(self.model).where(pk.in_(ids))
|
||||
await self.db.execute(sql)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"删除失败: {e!s}", status_code=500) from e
|
||||
except Exception:
|
||||
logger.exception('删除失败')
|
||||
raise
|
||||
|
||||
async def clear(self) -> None:
|
||||
"""清空整表。软删除模式下相当于"回收站清空",只清理已删标记的记录。"""
|
||||
@@ -285,8 +294,9 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
sql = delete(self.model)
|
||||
await self.db.execute(sql)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"清空失败: {e!s}", status_code=500) from e
|
||||
except Exception:
|
||||
logger.exception('清空失败')
|
||||
raise
|
||||
|
||||
async def set(self, ids: list[int], include_deleted: bool = False, **kwargs) -> None:
|
||||
"""批量更新。软删除模式下默认跳过已删除的记录。"""
|
||||
@@ -298,8 +308,9 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
sql = sql.values(**kwargs)
|
||||
await self.db.execute(sql)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"批量更新失败: {e!s}", status_code=500) from e
|
||||
except Exception:
|
||||
logger.exception('批量更新失败')
|
||||
raise
|
||||
|
||||
# ── 条件与排序 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -94,32 +94,32 @@ async def _authenticate(
|
||||
) -> AuthSchema:
|
||||
"""核心认证逻辑(HTTP 与 WebSocket 共享)"""
|
||||
if not token:
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code)
|
||||
|
||||
# 处理Bearer token(兼容无空格/无前缀输入,避免 IndexError)
|
||||
if token.startswith("Bearer"):
|
||||
token = token[len("Bearer") :].strip()
|
||||
if not token:
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code)
|
||||
|
||||
# 滑动模式下跳过 JWT exp 校验,由 Redis session TTL 决定实际有效期
|
||||
payload = decode_access_token(token, verify_exp=not settings.TOKEN_SLIDING_EXPIRE)
|
||||
if not payload or payload.is_refresh:
|
||||
raise CustomException(msg="非法凭证", code=RET.INVALID_CREDENTIALS.code, status_code=401)
|
||||
raise CustomException(msg="非法凭证", code=RET.INVALID_CREDENTIALS.code)
|
||||
|
||||
session_id = payload.sub
|
||||
if not session_id:
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code)
|
||||
|
||||
session_key = f"{RedisInitKeyConfig.USER_SESSION.key}:{session_id}"
|
||||
raw = await RedisCURD(redis).get(session_key)
|
||||
if not raw:
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code)
|
||||
user_info = json.loads(raw)
|
||||
|
||||
# 校验 session 数据完整性
|
||||
if not user_info.get("session_id"):
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code)
|
||||
|
||||
# 滑动过期续期:以 USER_SESSION 为存活判据与续期主体,且受绝对上限约束
|
||||
if settings.TOKEN_SLIDING_EXPIRE:
|
||||
@@ -147,7 +147,7 @@ async def _authenticate(
|
||||
)
|
||||
if expired:
|
||||
await crud.delete(session_key)
|
||||
raise CustomException(msg="会话超过最大存活时长,请重新登录", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
raise CustomException(msg="会话超过最大存活时长,请重新登录", code=RET.UNAUTHORIZED.code)
|
||||
# 续期必须落在存活判据(USER_SESSION)上,否则续期无效、判据形同虚设
|
||||
await crud.expire(key=session_key, expire=settings.REFRESH_TOKEN_EXPIRE_SECONDS)
|
||||
await crud.expire(
|
||||
@@ -157,16 +157,16 @@ async def _authenticate(
|
||||
|
||||
username = user_info.get("user_name")
|
||||
if not username:
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code)
|
||||
|
||||
user_status = user_info.get("user_status", 0)
|
||||
user_id = user_info.get("user_id")
|
||||
|
||||
if user_status == 1:
|
||||
raise CustomException(msg="用户已被停用", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
raise CustomException(msg="用户已被停用", code=RET.UNAUTHORIZED.code)
|
||||
|
||||
if not user_id:
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code, status_code=401)
|
||||
raise CustomException(msg="认证已失效", code=RET.UNAUTHORIZED.code)
|
||||
|
||||
# 每请求查库校验用户仍存在且未删除:token 只证明签发时身份,不证明现在。
|
||||
from app.modules.system.user.model import UserModel # 延迟导入:core 导入期不依赖业务层(守卫不变式 3)
|
||||
@@ -179,7 +179,7 @@ async def _authenticate(
|
||||
.first()
|
||||
)
|
||||
if not user_obj:
|
||||
raise CustomException(msg="用户不存在", code=RET.NOT_FOUND.code, status_code=401)
|
||||
raise CustomException(msg="用户不存在", code=RET.UNAUTHORIZED.code)
|
||||
|
||||
user = CoreUserSchema.model_validate(user_obj)
|
||||
return AuthSchema(
|
||||
@@ -225,10 +225,10 @@ class AuthPermission:
|
||||
user_permissions = set[Any](auth.permissions)
|
||||
|
||||
if not user_permissions:
|
||||
raise CustomException(msg="无权限操作", code=RET.FORBIDDEN.code, status_code=403)
|
||||
raise CustomException(msg="无权限操作", code=RET.FORBIDDEN.code)
|
||||
|
||||
if not any(perm in user_permissions for perm in self.permissions):
|
||||
logger.error(f"用户缺少任何所需的权限: {self.permissions}")
|
||||
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code, status_code=403)
|
||||
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code)
|
||||
|
||||
return auth
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError, ResponseValidationError
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.exc import DisconnectionError, IntegrityError, InterfaceError, SQLAlchemyError
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
@@ -42,33 +42,51 @@ def require_superadmin(func):
|
||||
# 避免模块导入期就打 DeprecationWarning
|
||||
_HTTP_422: int = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", 422)
|
||||
|
||||
# 业务错误码 → HTTP 状态码。只映射语义明确的码;其余业务异常统一按 _DEFAULT_BUSINESS_STATUS 返回。
|
||||
# 说明:历史上 CustomException 的 status_code 默认 500,导致「验证码过期」「参数校验失败」这类
|
||||
# 客户端错误也返回 500 —— 前端无法区分「我错了」与「服务器坏了」,监控/告警也被污染。
|
||||
# 业务错误码 → HTTP 状态码:**HTTP 语义的唯一事实来源**。
|
||||
# 约定(t16):
|
||||
# - 业务代码只传 code(`raise CustomException(msg=..., code=...)`),构造器不接受 status_code;
|
||||
# - 本表未命中的 code 才会落到 _DEFAULT_BUSINESS_STATUS(仅为「新增 code 忘了登记」的安全网,
|
||||
# 不应作为正常路径依赖);
|
||||
# - 历史上 status_code 默认 500 让「验证码过期」「参数校验失败」也返回 500,客户端无法区分
|
||||
# 「我错了」与「服务器坏了」,监控/告警同样被污染,故收敛到本表。
|
||||
_CODE_TO_HTTP_STATUS: dict[int, int] = {
|
||||
# ① 默认业务异常码:客户端可修正 → 400
|
||||
RET.EXCEPTION.code: status.HTTP_400_BAD_REQUEST,
|
||||
# ② 数值本身就是 HTTP 状态码的 RET 成员:显式登记(不再依赖安全网兜底)
|
||||
RET.BAD_REQUEST.code: status.HTTP_400_BAD_REQUEST,
|
||||
RET.UNAUTHORIZED.code: status.HTTP_401_UNAUTHORIZED,
|
||||
RET.FORBIDDEN.code: status.HTTP_403_FORBIDDEN,
|
||||
RET.NOT_FOUND.code: status.HTTP_404_NOT_FOUND,
|
||||
RET.BAD_METHOD.code: status.HTTP_405_METHOD_NOT_ALLOWED,
|
||||
RET.NOT_ACCEPTABLE.code: status.HTTP_406_NOT_ACCEPTABLE,
|
||||
RET.CONFLICT.code: status.HTTP_409_CONFLICT,
|
||||
RET.GONE.code: status.HTTP_410_GONE,
|
||||
RET.PRECONDITION_FAILED.code: status.HTTP_412_PRECONDITION_FAILED,
|
||||
RET.UNSUPPORTED_MEDIA_TYPE.code: status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
RET.UNPROCESSABLE_ENTITY.code: _HTTP_422,
|
||||
RET.TOO_MANY_REQUESTS.code: status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
RET.INTERNAL_SERVER_ERROR.code: status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
RET.NOT_IMPLEMENTED.code: status.HTTP_501_NOT_IMPLEMENTED,
|
||||
RET.BAD_GATEWAY.code: status.HTTP_502_BAD_GATEWAY,
|
||||
RET.SERVICE_UNAVAILABLE.code: status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
RET.GATEWAY_TIMEOUT.code: status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
RET.HTTP_VERSION_NOT_SUPPORTED.code: status.HTTP_505_HTTP_VERSION_NOT_SUPPORTED,
|
||||
# ③ 自定义业务码
|
||||
RET.DATAEXIST.code: status.HTTP_409_CONFLICT,
|
||||
RET.PARAMERR.code: status.HTTP_400_BAD_REQUEST,
|
||||
RET.TIMEOUT.code: status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
RET.RATE_LIMIT_EXCEEDED.code: status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
# 认证类业务码(对应前端 ResultEnum)
|
||||
# ④ 认证类业务码(对应前端 ResultEnum)
|
||||
RET.INVALID_TOKEN.code: status.HTTP_401_UNAUTHORIZED,
|
||||
RET.EXPIRED_TOKEN.code: status.HTTP_401_UNAUTHORIZED,
|
||||
RET.INVALID_CREDENTIALS.code: status.HTTP_401_UNAUTHORIZED,
|
||||
RET.TOKEN_EXPIRED.code: status.HTTP_401_UNAUTHORIZED,
|
||||
RET.NO_PERMISSION.code: status.HTTP_403_FORBIDDEN,
|
||||
# 显式声明为服务端错误的码(供内部包装器使用,如 CRUD「xx失败」兜底)
|
||||
# ⑤ 显式声明为服务端故障的业务码(供极少数已确认属服务端故障的业务场景使用)
|
||||
RET.SERVERERR.code: status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
|
||||
# 未映射业务码的默认状态:业务异常按「客户端请求错误」处理(而不是 500)
|
||||
# 未登记业务码的安全网:按「客户端请求错误」处理(正常路径应命中上面的表;新增 RET 码请同步登记)
|
||||
_DEFAULT_BUSINESS_STATUS: int = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@@ -87,32 +105,62 @@ def resolve_http_status(code: int | None) -> int:
|
||||
|
||||
|
||||
class CustomException(Exception):
|
||||
"""业务异常。
|
||||
"""业务异常:系统内**唯一**的业务错误写法是 ``raise CustomException(msg=..., code=...)``。
|
||||
|
||||
``status_code`` 未显式传入时按 ``code`` 推导(见 :func:`resolve_http_status`),
|
||||
默认 400(客户端可修正)而非 500;只有确属服务端故障的场景才应显式传 500,
|
||||
或使用 ``code=RET.SERVERERR.code``。
|
||||
约定(t19):
|
||||
- 构造器不接受 ``status_code``:HTTP 状态由 ``code`` 经 :func:`resolve_http_status` 推导
|
||||
(``status_code`` 只作只读派生属性),从结构上杜绝「业务码 + 显式状态码」两个事实来源;
|
||||
- **意外异常不要在这里包装**:让原始异常冒泡,由全局 ``Exception`` / ``SQLAlchemyError``
|
||||
处理器统一映射为 5xx + 通用文案 + 日志;需要运维上下文时在抛出处
|
||||
``logger.exception("<业务动作>失败")`` 后再 ``raise``(保留原始异常类型);
|
||||
- 宽泛 ``except`` 若确实要把异常转换成业务错误,必须先 ``except CustomException: raise``。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg: str = RET.EXCEPTION.msg,
|
||||
code: int = RET.EXCEPTION.code,
|
||||
status_code: int | None = None,
|
||||
data: Any | None = None,
|
||||
success: bool = False,
|
||||
) -> None:
|
||||
super().__init__(msg)
|
||||
self.status_code = status_code if status_code is not None else resolve_http_status(code)
|
||||
self.code = code
|
||||
self.msg = msg
|
||||
self.data = data
|
||||
self.success = success
|
||||
# 只读派生属性:HTTP 状态一律由 code 经映射表推导(构造器不再接受 status_code)
|
||||
self.status_code = resolve_http_status(code)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.msg
|
||||
|
||||
|
||||
# 连接类故障的文案特征(DBAPI 驱动差异大,异常类型优先、文案兜底)
|
||||
_CONNECTION_HINTS: tuple[str, ...] = (
|
||||
"connect",
|
||||
"connection",
|
||||
"server has gone away",
|
||||
"lost connection",
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"pool",
|
||||
"timed out",
|
||||
)
|
||||
|
||||
|
||||
def _is_connection_detail(detail: str | None) -> bool:
|
||||
"""判断异常细节是否属于「连接类」故障(→ 503 而不是 500)。
|
||||
|
||||
参数:
|
||||
- detail (str | None): 驱动返回的原始错误文本。
|
||||
|
||||
返回:
|
||||
- bool: 是否连接类。
|
||||
"""
|
||||
text = (detail or "").lower()
|
||||
return any(hint in text for hint in _CONNECTION_HINTS)
|
||||
|
||||
|
||||
def handle_exception(app: FastAPI) -> None:
|
||||
@app.exception_handler(CustomException)
|
||||
async def custom_exception_handler(request: Request, exc: CustomException) -> JSONResponse:
|
||||
@@ -169,13 +217,20 @@ def handle_exception(app: FastAPI) -> None:
|
||||
|
||||
@app.exception_handler(SQLAlchemyError)
|
||||
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) -> JSONResponse:
|
||||
exc_type = type(exc).__name__
|
||||
"""数据库异常统一出口。
|
||||
|
||||
分类(t19):
|
||||
- ``IntegrityError``:约束类问题,按具体原因给出可操作文案(409 等);
|
||||
- 其它:**不再返回 400**(把服务端故障说成客户端的错会让监控失明),
|
||||
连接类(InterfaceError/DisconnectionError/连接中断文案)→ 503,其余 → 500;
|
||||
- 文案不再拼 ``exc_type``/驱动原文,细节只进日志(``logger.opt(exception=...)`` 带堆栈)。
|
||||
"""
|
||||
if isinstance(exc, IntegrityError):
|
||||
detail = str(exc.orig) if exc.orig else str(exc)
|
||||
expose_detail = detail if settings.ENVIRONMENT != EnvironmentEnum.PROD else None
|
||||
if "connect" in detail or "connection" in detail:
|
||||
return ErrorResponse(msg="数据库连接失败", status_code=status.HTTP_403_SERVICE_UNAVAILABLE, data=expose_detail)
|
||||
logger.opt(exception=exc).error("[数据库异常] {} {}", request.method, request.url.path)
|
||||
if _is_connection_detail(detail):
|
||||
return ErrorResponse(msg="数据库连接失败", status_code=status.HTTP_503_SERVICE_UNAVAILABLE, data=expose_detail)
|
||||
if "Duplicate entry" in detail:
|
||||
return ErrorResponse(msg="数据重复,请检查唯一字段", status_code=status.HTTP_409_CONFLICT, data=expose_detail)
|
||||
if "foreign key constraint" in detail:
|
||||
@@ -184,9 +239,11 @@ def handle_exception(app: FastAPI) -> None:
|
||||
return ErrorResponse(msg="必填字段缺失", status_code=status.HTTP_409_CONFLICT, data=expose_detail)
|
||||
return ErrorResponse(msg="数据已存在或违反完整性约束", status_code=status.HTTP_409_CONFLICT, data=expose_detail)
|
||||
|
||||
logger.error("[数据库异常] {} {} | type={} | detail={}", request.method, request.url.path, exc_type, exc)
|
||||
data = str(exc) if settings.ENVIRONMENT != EnvironmentEnum.PROD else None
|
||||
return ErrorResponse(msg=f"数据库操作失败: {exc_type}", status_code=status.HTTP_400_BAD_REQUEST, data=data)
|
||||
detail = str(getattr(exc, "orig", None) or exc)
|
||||
logger.opt(exception=exc).error("[数据库异常] {} {}", request.method, request.url.path)
|
||||
if isinstance(exc, (InterfaceError, DisconnectionError)) or _is_connection_detail(detail):
|
||||
return ErrorResponse(msg="数据库连接失败", status_code=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
return ErrorResponse(msg="数据库操作失败", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_exception_handler(request: Request, exc: ValueError) -> JSONResponse:
|
||||
@@ -195,12 +252,12 @@ def handle_exception(app: FastAPI) -> None:
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def all_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
exc_type = type(exc).__name__
|
||||
logger.error(
|
||||
logger.opt(exception=exc).error(
|
||||
"[未捕获异常] {} {} | type={} | detail={}",
|
||||
request.method,
|
||||
request.url.path,
|
||||
exc_type,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
# 细节只进日志/堆栈:客户端只得到通用文案(生产不回显内部信息)
|
||||
return ErrorResponse(msg="服务器内部错误", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@@ -122,7 +122,7 @@ class CustomHTTPSRedirectMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.scheme != "https" and request.headers.get("X-Forwarded-Proto") != "https":
|
||||
url = request.url.replace(scheme="https")
|
||||
return RedirectResponse(url, status_code=301)
|
||||
return RedirectResponse(url, 301)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class CustomOAuth2PasswordBearer(OAuth2PasswordBearer):
|
||||
|
||||
if not authorization or scheme.lower() != settings.TOKEN_TYPE.lower():
|
||||
if self.auto_error:
|
||||
raise CustomException(msg="认证失败,请登录后再试", code=RET.TOKEN_EXPIRED.code, status_code=401)
|
||||
raise CustomException(msg="认证失败,请登录后再试", code=RET.TOKEN_EXPIRED.code)
|
||||
return None
|
||||
return token
|
||||
|
||||
@@ -130,7 +130,7 @@ def decode_access_token(token: str, verify_exp: bool = True) -> JWTPayloadSchema
|
||||
- CustomException: 解析失败时抛出,状态码为401。
|
||||
"""
|
||||
if not token:
|
||||
raise CustomException(msg="认证不存在,请重新登录", code=RET.TOKEN_EXPIRED.code, status_code=401)
|
||||
raise CustomException(msg="认证不存在,请重新登录", code=RET.TOKEN_EXPIRED.code)
|
||||
|
||||
try:
|
||||
options: dict = {}
|
||||
@@ -140,15 +140,15 @@ def decode_access_token(token: str, verify_exp: bool = True) -> JWTPayloadSchema
|
||||
|
||||
online_user_info = payload.get("sub")
|
||||
if not online_user_info:
|
||||
raise CustomException(msg="无效认证,请重新登录", code=RET.TOKEN_EXPIRED.code, status_code=401)
|
||||
raise CustomException(msg="无效认证,请重新登录", code=RET.TOKEN_EXPIRED.code)
|
||||
|
||||
return JWTPayloadSchema(**payload)
|
||||
|
||||
except (jwt.InvalidSignatureError, jwt.DecodeError):
|
||||
raise CustomException(msg="无效认证,请重新登录", code=RET.TOKEN_EXPIRED.code, status_code=401)
|
||||
raise CustomException(msg="无效认证,请重新登录", code=RET.TOKEN_EXPIRED.code)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise CustomException(msg="认证已过期,请重新登录", code=RET.TOKEN_EXPIRED.code, status_code=401)
|
||||
raise CustomException(msg="认证已过期,请重新登录", code=RET.TOKEN_EXPIRED.code)
|
||||
|
||||
except jwt.InvalidTokenError:
|
||||
raise CustomException(msg="token已失效,请重新登录", code=RET.TOKEN_EXPIRED.code, status_code=401)
|
||||
raise CustomException(msg="token已失效,请重新登录", code=RET.TOKEN_EXPIRED.code)
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.common.constant import DATE_DISPLAY_FMT, DATETIME_DISPLAY_FMT, TIME_DIS
|
||||
from app.common.enums import RET
|
||||
from app.config.setting import settings
|
||||
from app.core.exceptions import CustomException
|
||||
from app.core.logger import logger
|
||||
from app.utils.password_util import PwdUtil
|
||||
|
||||
# 自定义日期时间字符串类型
|
||||
@@ -80,8 +81,11 @@ def datetime_validator(value: str | datetime) -> datetime:
|
||||
return datetime.strptime(value, DATETIME_DISPLAY_FMT)
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
except Exception:
|
||||
raise CustomException(code=RET.ERROR.code, msg="无效的日期格式")
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("日期时间格式校验失败")
|
||||
raise CustomException(code=RET.ERROR.code, msg="无效的日期格式") from e
|
||||
|
||||
|
||||
def date_validator(value: str | date) -> date:
|
||||
@@ -101,8 +105,11 @@ def date_validator(value: str | date) -> date:
|
||||
return datetime.strptime(value, DATE_DISPLAY_FMT).date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
except Exception:
|
||||
raise CustomException(code=RET.ERROR.code, msg="无效的日期格式")
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("日期格式校验失败")
|
||||
raise CustomException(code=RET.ERROR.code, msg="无效的日期格式") from e
|
||||
|
||||
|
||||
def time_validator(value: str | time) -> time:
|
||||
@@ -122,8 +129,11 @@ def time_validator(value: str | time) -> time:
|
||||
return datetime.strptime(value, TIME_DISPLAY_FMT).time()
|
||||
if isinstance(value, time):
|
||||
return value
|
||||
except Exception:
|
||||
raise CustomException(code=RET.ERROR.code, msg="无效的时间格式")
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("时间格式校验失败")
|
||||
raise CustomException(code=RET.ERROR.code, msg="无效的时间格式") from e
|
||||
|
||||
|
||||
def email_validator(value: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user