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:
zhangtao
2026-10-04 05:44:21 +08:00
parent c73daa7eae
commit 644d262cad
31 changed files with 824 additions and 363 deletions
+79 -22
View File
@@ -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)