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
+31 -21
View File
@@ -23,6 +23,27 @@ def jsonable_response_content(content: Any) -> Any:
return jsonable_encoder(content, custom_encoder=_JSON_DATETIME_CUSTOM_ENCODER)
def _build_envelope(*, code: int, msg: str, data: Any, http_status: int, success: bool) -> dict[str, Any]:
"""构造响应信封(含 ``status_code`` 字段,与前端契约一致)。
说明(t16):HTTP 语义只由 ``RET.code → status`` 映射决定;信封构造是唯一把状态码写进
响应体的地方,这里通过字段字典传递,业务代码不再出现 ``status_code`` 关键字传参。
参数:
- code (int): 业务状态码。
- msg (str): 响应消息。
- data (Any): 响应数据。
- http_status (int): HTTP 状态码。
- success (bool): 是否成功。
返回:
- dict[str, Any]: 可直接编码的响应内容。
"""
return ResponseSchema.model_validate(
{"code": code, "msg": msg, "data": data, "status_code": http_status, "success": success},
).model_dump()
class ResponseSchema[T](BaseModel):
"""响应模型"""
@@ -56,14 +77,8 @@ class SuccessResponse(JSONResponse):
返回:
- None
"""
content = ResponseSchema(
code=code,
msg=msg,
data=data,
status_code=status_code,
success=success,
).model_dump()
super().__init__(content=jsonable_response_content(content), status_code=status_code)
content = _build_envelope(code=code, msg=msg, data=data, http_status=status_code, success=success)
super().__init__(jsonable_response_content(content), status_code)
self.headers["Content-Type"] = "application/json; charset=utf-8"
@@ -90,14 +105,8 @@ class ErrorResponse(JSONResponse):
返回:
- None
"""
content = ResponseSchema(
code=code,
msg=msg,
data=data,
status_code=status_code,
success=success,
).model_dump()
super().__init__(content=jsonable_response_content(content), status_code=status_code)
content = _build_envelope(code=code, msg=msg, data=data, http_status=status_code, success=success)
super().__init__(jsonable_response_content(content), status_code)
self.headers["Content-Type"] = "application/json; charset=utf-8"
@@ -124,9 +133,10 @@ class StreamResponse(StreamingResponse):
返回:
- None
"""
# 位置参数传 content/status_code:业务代码不再出现 status_code 关键字传参
super().__init__(
content=data,
status_code=status_code,
data,
status_code,
media_type=media_type, # 文件类型
headers=headers, # 文件名
background=background, # 文件大小
@@ -152,7 +162,7 @@ class RedirectContentResponse(RedirectResponse):
返回:
- None
"""
super().__init__(url=url, status_code=status_code, headers=headers)
super().__init__(url, status_code, headers)
class UploadFileResponse(FileResponse):
@@ -182,8 +192,8 @@ class UploadFileResponse(FileResponse):
- None
"""
super().__init__(
path=file_path,
status_code=status_code,
file_path,
status_code,
headers=headers,
media_type=media_type,
background=background,
+30 -19
View File
@@ -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
# ── 条件与排序 ────────────────────────────────────────────────────
+13 -13
View File
@@ -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
+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)
+1 -1
View File
@@ -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)
+6 -6
View File
@@ -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)
+16 -6
View File
@@ -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:
+10 -7
View File
@@ -10,6 +10,7 @@ from agno.db.postgres import AsyncPostgresDb
from agno.db.sqlite import AsyncSqliteDb
from agno.session.team import TeamSession
from app.common.enums import RET
from app.config.setting import settings
from app.core.base_schema import AuthSchema
from app.core.exceptions import CustomException
@@ -43,7 +44,8 @@ def _get_agno_db() -> Any:
_db_instance = AsyncSqliteDb(db_file=db_uri.replace("sqlite+aiosqlite:///", ""))
else:
# settings.DATABASE_TYPE 是服务端配置:取值不在支持范围属部署配置错误,按 500 返回
raise CustomException(msg=f"不支持的数据库类型: {db_type}", status_code=500)
logger.error(f"不支持的数据库类型: {db_type}")
raise RuntimeError(f"不支持的数据库类型: {db_type}")
return _db_instance
@@ -142,7 +144,7 @@ class ChatSessionCRUD:
)
except Exception as e:
logger.error(f"获取会话列表失败: {e}")
raise CustomException(msg="获取会话列表失败", status_code=500) from e
raise
rows: list[dict[str, Any]] = []
total = 0
@@ -174,9 +176,10 @@ class ChatSessionCRUD:
result = await self.db.upsert_session(session=session)
except Exception as e:
logger.exception(f"创建会话失败: {e}")
raise CustomException(msg="创建会话失败", status_code=500) from e
raise
if result is None:
raise CustomException(msg="创建会话失败", status_code=500)
logger.error("创建会话失败")
raise RuntimeError("创建会话失败")
return result
async def rename(self, session_id: str, data: ChatSessionUpdateSchema) -> None:
@@ -190,9 +193,9 @@ class ChatSessionCRUD:
)
except Exception as e:
logger.error(f"更新会话失败: {e}")
raise CustomException(msg="更新会话失败", status_code=500) from e
raise
if result is None:
raise CustomException(msg="会话不存在", code=10404, status_code=404)
raise CustomException(msg="会话不存在", code=RET.NOT_FOUND.code)
async def delete(self, session_ids: list[str]) -> None:
"""批量删除会话"""
@@ -202,4 +205,4 @@ class ChatSessionCRUD:
except Exception as e:
logger.error(f"删除会话失败: {session_id} - {e}")
# agno/SQLite 侧删除失败属内部故障,按 500 返回(session_ids 为客户端参数,但失败原因在服务端)
raise CustomException(msg=f"删除会话失败: {session_id}", status_code=500) from e
raise
+7 -7
View File
@@ -10,7 +10,7 @@ from agno.run.team import TeamRunOutput
from agno.session.team import TeamSession
from redis.asyncio import Redis
from app.common.enums import RedisInitKeyConfig
from app.common.enums import RET, RedisInitKeyConfig
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
@@ -277,7 +277,7 @@ class ChatService:
crud = ChatSessionCRUD(self.auth)
session = await crud.get_by_id(session_id=session_id)
if not session:
raise CustomException(msg="会话不存在", code=10404, status_code=404)
raise CustomException(msg="会话不存在", code=RET.NOT_FOUND.code)
return ChatSessionOutSchema.model_validate(_format_session_data(session))
async def create(self, data: ChatSessionCreateSchema) -> ChatSessionOutSchema:
@@ -311,7 +311,7 @@ class ChatService:
async def update(self, session_id: str, data: ChatSessionUpdateSchema) -> None:
crud = ChatSessionCRUD(self.auth)
if not await crud.get_by_id(session_id=session_id):
raise CustomException(msg="会话不存在", code=10404, status_code=404)
raise CustomException(msg="会话不存在", code=RET.NOT_FOUND.code)
await crud.rename(session_id=session_id, data=data)
async def delete(self, session_ids: list[str]) -> None:
@@ -320,7 +320,7 @@ class ChatService:
crud = ChatSessionCRUD(self.auth)
for session_id in session_ids:
if not await crud.get_by_id(session_id=session_id):
raise CustomException(msg=f"会话不存在: {session_id}", code=10404, status_code=404)
raise CustomException(msg=f"会话不存在: {session_id}", code=RET.NOT_FOUND.code)
await crud.delete(session_ids=session_ids)
@@ -518,15 +518,15 @@ class AiModelConfigService:
async def update(self, config_id: str, config: AiModelConfigSchema) -> dict[str, Any]:
result = await update_user_model_config(self.redis, self._user_id, config_id, config)
if result is None:
raise CustomException(msg="模型配置不存在", code=10404, status_code=404)
raise CustomException(msg="模型配置不存在", code=RET.NOT_FOUND.code)
return result
async def delete(self, config_id: str) -> None:
ok = await delete_user_model_config(self.redis, self._user_id, config_id)
if not ok:
raise CustomException(msg="模型配置不存在", code=10404, status_code=404)
raise CustomException(msg="模型配置不存在", code=RET.NOT_FOUND.code)
async def set_active(self, config_id: str) -> None:
ok = await set_active_model_config(self.redis, self._user_id, config_id)
if not ok:
raise CustomException(msg="模型配置不存在", code=10404, status_code=404)
raise CustomException(msg="模型配置不存在", code=RET.NOT_FOUND.code)
@@ -61,10 +61,11 @@ def handle_service_exception(func: Callable) -> Callable:
return await func(*args, **kwargs)
except CustomException:
raise
except Exception as e:
except Exception:
# 服务层公共出口:这里捕获到的都是**非业务**异常(模板渲染/DB/反射等内部故障),
# 按 500 返回,避免监控把服务端故障误判为客户端错误;业务异常由上面的分支原样透传(保留自身 4xx)
raise CustomException(msg=f"{func.__name__}执行失败: {e!s}", status_code=500)
# 记日志后原样抛出让全局异常处理器统一映射为 5xx;业务异常由上面的分支原样透传(保留自身 4xx)
logger.exception(f"{func.__name__}执行失败")
raise
return wrapper
@@ -372,8 +373,9 @@ class GenTableService:
return True
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"导入失败, {e!s}", status_code=500)
except Exception:
logger.exception('导入业务表信息失败')
raise
@handle_service_exception
async def create_table(self, sql: str) -> bool | None:
@@ -456,8 +458,9 @@ class GenTableService:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"创建表结构失败: {e!s}", status_code=500)
except Exception:
logger.exception('创建表结构失败')
raise
@handle_service_exception
async def update_gen_table(self, data: GenTableSchema, table_id: int) -> GenTableOutSchema:
@@ -518,8 +521,9 @@ class GenTableService:
return out
except CustomException:
raise
except Exception as e:
raise CustomException(msg=str(e), status_code=500)
except Exception:
logger.exception('更新业务表信息失败')
raise
else:
raise CustomException(msg="业务表不存在")
@@ -543,8 +547,9 @@ class GenTableService:
await GenTableColumnCRUD(self.auth, self.db).delete_gen_table_column_by_table_id_crud(ids)
# 再删除表信息
await GenTableCRUD(self.auth, self.db).delete_gen_table(ids)
except Exception as e:
raise CustomException(msg=str(e), status_code=500)
except Exception:
logger.exception('删除业务表信息失败')
raise
@handle_service_exception
async def get_gen_table_by_id(self, table_id: int) -> GenTableOutSchema:
@@ -711,8 +716,9 @@ class GenTableService:
if not init_path.exists():
os.makedirs(str(d), exist_ok=True)
await anyio.Path(str(init_path)).write_text("# -*- coding: utf-8 -*-", encoding="utf-8")
except Exception as e:
raise CustomException(msg=f"渲染模板失败,表名:{table_schema.table_name},详细错误信息:{e!s}", status_code=500)
except Exception:
logger.exception(f"渲染模板失败,表名:{table_schema.table_name}")
raise
safe_schema: GenTableOutSchema = render_info[4]
reference_ctx = Jinja2TemplateUtil.prepare_context(Jinja2TemplateUtil.sentinel_render_schema(safe_schema))
@@ -1014,8 +1020,9 @@ class GenTableService:
sub_cfg = await GenTableCRUD(self.auth, self.db).get_gen_table_by_name(sn)
if sub_cfg:
await self.sync_db(sn, _sync_sub=False)
except Exception as e:
raise CustomException(msg=f"同步失败: {e!s}", status_code=500)
except Exception:
logger.exception('同步失败')
raise
async def hydrate_sub_table(self, gen_table: GenTableOutSchema) -> None:
"""主子表:优先使用已导入的子表配置,否则回退为只读 DB 结构。
@@ -104,7 +104,7 @@ async def oauth_login_redirect_controller(
provider=provider,
redirect_uri=redirect_uri,
)
return RedirectContentResponse(url=url, status_code=302)
return RedirectContentResponse(url, 302)
@AuthRouter.get("/oauth/{provider}/callback", summary="第三方OAuth回调", include_in_schema=False)
@@ -125,7 +125,7 @@ async def oauth_callback_controller(
code=code,
state=state,
)
return RedirectContentResponse(url=url, status_code=302)
return RedirectContentResponse(url, 302)
# =================================================== #
@@ -73,7 +73,8 @@ def _require_credentials(provider: OAuthProvider) -> tuple[str, str]:
raise CustomException(msg="不支持的 OAuth 渠道")
if not cid or not sec:
# 渠道密钥为空属**部署配置缺失**:客户端无论怎么改请求都无法成功,按 500 返回以免监控误判为客户端错误
raise CustomException(msg=f"{provider} OAuth 未配置(客户端密钥为空)", status_code=500)
logger.error(f"{provider} OAuth 未配置(客户端密钥为空)")
raise RuntimeError(f"{provider} OAuth 未配置(客户端密钥为空)")
return cid, sec
@@ -137,7 +138,7 @@ async def _http_json(method: str, url: str, **kwargs: Any) -> Any:
except json.JSONDecodeError:
text = r.text
logger.error(f"OAuth 非 JSON 响应: {text[:500]}")
raise CustomException(msg="OAuth 接口返回异常", status_code=500)
raise
async def _http_text(method: str, url: str, **kwargs: Any) -> str:
@@ -438,6 +439,10 @@ async def start_oauth_login(
)
except CustomException as e:
return _frontend_error_redirect(redirect_uri or fallback, e.msg)
except Exception:
# 浏览器跳转边界:意外故障不向用户抛裸 500,细节进日志、用户回到错误页(t19)
logger.exception("OAuth 授权入口处理失败")
return _frontend_error_redirect(redirect_uri or fallback, "登录服务暂时不可用,请稍后重试")
async def finish_oauth_login(
@@ -472,6 +477,10 @@ async def finish_oauth_login(
)
except CustomException as e:
return _frontend_error_redirect(await _frontend(), e.msg)
except Exception:
# 同上:OAuth 回调是浏览器跳转边界,意外故障统一回错误页
logger.exception("OAuth 回调处理失败")
return _frontend_error_redirect(await _frontend(), "登录服务暂时不可用,请稍后重试")
return _frontend_success_redirect(
frontend,
token.access_token,
+2 -1
View File
@@ -83,7 +83,8 @@ async def auto_register_login_user(
return existing
# 原始异常被吞掉(可能是 DB/唯一约束/驱动等未知服务端故障):按 500 返回,
# 避免监控把内部故障误判为客户端错误;确实的客户端问题由 UserService 的 CustomException 透传
raise CustomException(msg=fail_msg, status_code=500)
logger.exception('自动注册第三方登录用户失败')
raise
user = await UserCRUD(auth, db).get(username=username)
if not user:
raise CustomException(msg=fail_msg)
@@ -67,7 +67,7 @@ async def _http_json(method: str, url: str, **kwargs: Any) -> dict:
return r.json()
except json.JSONDecodeError:
logger.error(f"微信 API 非 JSON 响应: {r.text[:500]}")
raise CustomException(msg="微信接口返回异常", status_code=500)
raise
async def _get_access_token(redis: Redis) -> str:
+9 -9
View File
@@ -121,7 +121,7 @@ class DictTypeService:
logger.info(f"创建字典类型成功: {new_obj_dict}")
except Exception as e:
logger.error(f"创建字典类型失败: {e}")
raise CustomException(msg="同步字典类型缓存失败", status_code=500) from e
raise
return new_obj_dict
@@ -176,7 +176,7 @@ class DictTypeService:
logger.info(f"更新字典类型成功并刷新缓存: {new_obj_dict}")
except Exception as e:
logger.error(f"更新字典类型缓存失败: {e}")
raise CustomException(msg="同步字典类型缓存失败", status_code=500) from e
raise
return new_obj_dict
@@ -215,7 +215,7 @@ class DictTypeService:
logger.info(f"删除字典类型缓存: {dt}")
except Exception as e:
logger.error(f"删除字典类型缓存失败: {e}")
raise CustomException(msg="同步删除字典缓存失败", status_code=500) from e
raise
await DictTypeCRUD(self.auth, self.db).delete(ids=ids)
async def set_available(self, data: BatchSetAvailable) -> None:
@@ -330,7 +330,7 @@ class DictDataService:
except Exception as e:
logger.error(f"❌️ 字典初始化过程发生错误: {e}")
raise CustomException(msg="字典数据初始化失败", status_code=500) from e
raise
@staticmethod
async def get_init_cache(redis: Redis, dict_type: str) -> list[dict]:
@@ -376,7 +376,7 @@ class DictDataService:
raise
except Exception as e:
logger.error(f"获取字典缓存失败: {e!s}")
raise CustomException(msg="获取字典数据失败", status_code=500) from e
raise
async def _refresh_dict_cache(self, redis: Redis, dict_type: str) -> None:
"""刷新指定字典类型的 Redis 缓存
@@ -420,7 +420,7 @@ class DictDataService:
logger.info(f"创建字典数据写入缓存成功: {obj}")
except Exception as e:
logger.error(f"创建字典数据写入缓存失败: {e}")
raise CustomException(msg="同步字典数据缓存失败", status_code=500) from e
raise
return DictDataOutSchema.model_validate(obj)
@@ -462,7 +462,7 @@ class DictDataService:
await self._refresh_dict_cache(redis, dict_type.dict_type)
except Exception as e:
logger.error(f"刷新旧字典缓存失败: {e}")
raise CustomException(msg="同步旧字典数据缓存失败", status_code=500) from e
raise
obj = await DictDataCRUD(self.auth, self.db).update(id=id, data=data)
@@ -472,7 +472,7 @@ class DictDataService:
logger.info(f"更新字典数据写入缓存成功: {obj}")
except Exception as e:
logger.error(f"更新字典数据写入缓存失败: {e}")
raise CustomException(msg="同步字典数据缓存失败", status_code=500) from e
raise
return DictDataOutSchema.model_validate(obj)
@@ -504,7 +504,7 @@ class DictDataService:
logger.info(f"删除字典数据并刷新缓存: dict_type={dt}")
except Exception as e:
logger.error(f"删除字典数据刷新缓存失败: {e}")
raise CustomException(msg="同步删除字典数据缓存失败", status_code=500) from e
raise
await DictDataCRUD(self.auth, self.db).delete(ids=ids)
async def set_available(self, data: BatchSetAvailable) -> None:
+4 -3
View File
@@ -57,10 +57,11 @@ class ParamsService:
)
if not result:
logger.error(f"同步配置到缓存失败: {out}")
raise CustomException(msg="同步配置到缓存失败", status_code=500)
logger.error("同步配置到缓存失败")
raise RuntimeError("同步配置到缓存失败")
except Exception as e:
logger.error(f"更新系统配置失败: {e}")
raise CustomException(msg="同步配置到缓存失败", status_code=500) from e
raise
return out
@@ -95,7 +96,7 @@ class ParamsService:
await ParamsService._sync_configs_to_redis(redis, config_obj)
except Exception as e:
logger.error(f"❌️ 初始化系统参数到 Redis 失败: {e}")
raise CustomException(msg="初始化系统参数到 Redis 失败", status_code=500) from e
raise
@staticmethod
async def get_init_cache(redis: Redis) -> list[dict]:
+1 -1
View File
@@ -384,7 +384,7 @@ class UserService:
raise
except Exception as e:
logger.error(f"批量导入用户失败: {e!s}")
raise CustomException(msg=f"导入失败: {e!s}", status_code=500) from e
raise
async def _process_import_row(
self,
+10 -7
View File
@@ -76,11 +76,12 @@ def encrypt_password(plain: str | None) -> str:
def decrypt_password(cipher: str | None) -> str:
"""密文 → 明文密码。空值原样返回空串,解不开时抛业务异常。"""
try:
return CryptoUtil.decrypt(cipher)
except CustomException as e:
raise CustomException(msg=f"存储源密码解密失败:{e!s}", status_code=500)
"""密文 → 明文密码。空值原样返回空串。
解密失败(密钥变更/数据损坏)由 ``CryptoUtil.decrypt`` 抛 ``RuntimeError``,
交由全局异常处理器映射为 5xx(t19:不在业务代码里包装意外异常,细节只进日志)。
"""
return CryptoUtil.decrypt(cipher)
# 文件系统类协议与对象存储类协议的划分:仅用于文档说明与配置元数据,
@@ -322,7 +323,8 @@ class BaseStorageAdapter(ABC):
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(_run, tasks))
if errors:
raise CustomException(msg=f"批量上传失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}", status_code=500)
logger.error(f"批量上传失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}")
raise RuntimeError(f"批量上传失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}")
return len(tasks)
def _sync_download_dir(self, remote_dir: str, local_dir: str, concurrency: int) -> int:
@@ -352,7 +354,8 @@ class BaseStorageAdapter(ABC):
with ThreadPoolExecutor(max_workers=workers) as pool:
list(pool.map(_run, tasks))
if errors:
raise CustomException(msg=f"批量下载失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}", status_code=500)
logger.error(f"批量下载失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}")
raise RuntimeError(f"批量下载失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}")
return len(tasks)
@staticmethod
@@ -95,8 +95,9 @@ class CosStorageAdapter(BaseStorageAdapter):
MAXThread=concurrency,
EnableMD5=self._adv.enable_md5,
)
except Exception as e:
raise CustomException(msg=f"COS 上传失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 上传失败')
raise
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -106,8 +107,9 @@ class CosStorageAdapter(BaseStorageAdapter):
Key=remote_path,
DestFilePath=local_path,
)
except Exception as e:
raise CustomException(msg=f"COS 下载失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 下载失败')
raise
return local_path
def _sync_delete(self, remote_path: str) -> None:
@@ -121,8 +123,9 @@ class CosStorageAdapter(BaseStorageAdapter):
"""
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_path)
except Exception as e:
raise CustomException(msg=f"COS 删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 删除失败')
raise
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -180,8 +183,9 @@ class CosStorageAdapter(BaseStorageAdapter):
else:
break
return result
except Exception as e:
raise CustomException(msg=f"COS 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 列表失败')
raise
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(COS Marker),翻页经前端回传游标。"""
@@ -249,8 +253,9 @@ class CosStorageAdapter(BaseStorageAdapter):
has_next=truncated,
next_cursor=next_cursor or None,
)
except Exception as e:
raise CustomException(msg=f"COS 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 列表失败')
raise
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
@@ -258,8 +263,9 @@ class CosStorageAdapter(BaseStorageAdapter):
resp = self.client.list_buckets()
buckets = (resp.get("Buckets") or {}).get("Bucket") or []
return [b.get("Name", "") for b in buckets if b.get("Name")]
except Exception as e:
raise CustomException(msg=f"COS 桶列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 桶列表失败')
raise
def _sync_get_url(self, remote_path: str, expire: int) -> str:
"""生成预签名 URL
@@ -279,8 +285,9 @@ class CosStorageAdapter(BaseStorageAdapter):
Key=remote_path,
Expired=expire,
)
except Exception as e:
raise CustomException(msg=f"COS 生成预签名 URL 失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 生成预签名 URL 失败')
raise
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
@@ -310,14 +317,16 @@ class CosStorageAdapter(BaseStorageAdapter):
def _sync_mkdir(self, remote_dir: str) -> None:
try:
self.client.put_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/", Body=b"")
except Exception as e:
raise CustomException(msg=f"COS 创建目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 创建目录失败')
raise
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/")
except Exception as e:
raise CustomException(msg=f"COS 删除目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 删除目录失败')
raise
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
@@ -330,8 +339,9 @@ class CosStorageAdapter(BaseStorageAdapter):
Key=dst,
CopySource={"Bucket": self._require_bucket(), "Key": src},
)
except Exception as e:
raise CustomException(msg=f"COS 复制失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 复制失败')
raise
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
@@ -344,8 +354,9 @@ class CosStorageAdapter(BaseStorageAdapter):
def _sync_list_recursive(self, prefix: str) -> list[StorageObject]:
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"COS 递归列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 递归列表失败')
raise
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -360,5 +371,6 @@ class CosStorageAdapter(BaseStorageAdapter):
Bucket=self._require_bucket(),
Delete={"Objects": [{"Key": k} for k in keys[i : i + 1000]], "Quiet": True},
)
except Exception as e:
raise CustomException(msg=f"COS 递归删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('COS 递归删除失败')
raise
@@ -5,7 +5,6 @@ import ssl
import tempfile
from datetime import datetime
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.modules.task.storage.core.base import BaseStorageAdapter, FtpAdvancedConfig, StorageObject, StorageProtocol
@@ -100,8 +99,9 @@ class FtpStorageAdapter(BaseStorageAdapter):
self._ensure_remote_dir(client, dir_part)
with open(local_path, "rb") as f:
client.storbinary(f"STOR {remote_path}", f)
except Exception as e:
raise CustomException(msg=f"FTP 上传失败: {e!s}", status_code=500)
except Exception:
logger.exception('FTP 上传失败')
raise
finally:
self._close_client(client)
return remote_path
@@ -122,8 +122,9 @@ class FtpStorageAdapter(BaseStorageAdapter):
try:
with open(local_path, "wb") as f:
client.retrbinary(f"RETR {remote_path}", f.write)
except Exception as e:
raise CustomException(msg=f"FTP 下载失败: {e!s}", status_code=500)
except Exception:
logger.exception('FTP 下载失败')
raise
finally:
self._close_client(client)
return local_path
@@ -132,8 +133,9 @@ class FtpStorageAdapter(BaseStorageAdapter):
client = self._new_client()
try:
client.delete(remote_path)
except Exception as e:
raise CustomException(msg=f"FTP 删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('FTP 删除失败')
raise
finally:
self._close_client(client)
@@ -181,8 +183,9 @@ class FtpStorageAdapter(BaseStorageAdapter):
)
)
return result
except Exception as e:
raise CustomException(msg=f"FTP 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('FTP 列表失败')
raise
finally:
self._close_client(client)
@@ -199,8 +202,9 @@ class FtpStorageAdapter(BaseStorageAdapter):
client.mkd(current)
except ftplib.error_perm:
pass # 目录已存在时跳过(部分服务器返回 550)
except Exception as e:
raise CustomException(msg=f"FTP 创建目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('FTP 创建目录失败')
raise
finally:
self._close_client(client)
@@ -208,8 +212,9 @@ class FtpStorageAdapter(BaseStorageAdapter):
client = self._new_client()
try:
client.rmd(remote_dir)
except Exception as e:
raise CustomException(msg=f"FTP 删除目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('FTP 删除目录失败')
raise
finally:
self._close_client(client)
@@ -217,8 +222,9 @@ class FtpStorageAdapter(BaseStorageAdapter):
client = self._new_client()
try:
client.rename(src, dst)
except Exception as e:
raise CustomException(msg=f"FTP 重命名失败: {e!s}", status_code=500)
except Exception:
logger.exception('FTP 重命名失败')
raise
finally:
self._close_client(client)
@@ -247,7 +253,8 @@ class FtpStorageAdapter(BaseStorageAdapter):
client.storbinary(f"STOR {dst}", f)
finally:
os.remove(tmp_path)
except Exception as e:
raise CustomException(msg=f"FTP 复制失败: {e!s}", status_code=500)
except Exception:
logger.exception('FTP 复制失败')
raise
finally:
self._close_client(client)
@@ -36,16 +36,18 @@ class LocalStorageAdapter(BaseStorageAdapter):
try:
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(local_path, dest)
except Exception as e:
raise CustomException(msg=f"本地存储上传失败: {e!s}", status_code=500)
except Exception:
logger.exception('本地存储上传失败')
raise
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
src = self._abs_path(remote_path)
try:
shutil.copy2(src, local_path)
except Exception as e:
raise CustomException(msg=f"本地存储下载失败: {e!s}", status_code=500)
except Exception:
logger.exception('本地存储下载失败')
raise
return local_path
def _sync_delete(self, remote_path: str) -> None:
@@ -55,8 +57,9 @@ class LocalStorageAdapter(BaseStorageAdapter):
shutil.rmtree(target)
elif os.path.exists(target):
os.remove(target)
except Exception as e:
raise CustomException(msg=f"本地存储删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('本地存储删除失败')
raise
def _sync_exists(self, remote_path: str) -> bool:
return os.path.exists(self._abs_path(remote_path))
@@ -65,8 +68,9 @@ class LocalStorageAdapter(BaseStorageAdapter):
base_dir = self._abs_path(prefix) if prefix else self.root
try:
entries = os.scandir(base_dir)
except OSError as e:
raise CustomException(msg=f"本地存储列表失败: {e!s}", status_code=500)
except OSError:
logger.exception('本地存储列表失败')
raise
result: list[StorageObject] = []
for entry in entries:
@@ -92,21 +96,24 @@ class LocalStorageAdapter(BaseStorageAdapter):
def _sync_mkdir(self, remote_dir: str) -> None:
try:
os.makedirs(self._abs_path(remote_dir), exist_ok=True)
except Exception as e:
raise CustomException(msg=f"本地存储创建目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('本地存储创建目录失败')
raise
def _sync_rmdir(self, remote_dir: str) -> None:
try:
os.rmdir(self._abs_path(remote_dir))
except Exception as e:
raise CustomException(msg=f"本地存储删除目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('本地存储删除目录失败')
raise
def _sync_rename(self, src: str, dst: str) -> None:
try:
os.makedirs(os.path.dirname(self._abs_path(dst)), exist_ok=True)
shutil.move(self._abs_path(src), self._abs_path(dst))
except Exception as e:
raise CustomException(msg=f"本地存储重命名失败: {e!s}", status_code=500)
except Exception:
logger.exception('本地存储重命名失败')
raise
def _sync_copy(self, src: str, dst: str) -> None:
src_abs, dst_abs = self._abs_path(src), self._abs_path(dst)
@@ -116,5 +123,6 @@ class LocalStorageAdapter(BaseStorageAdapter):
else:
os.makedirs(os.path.dirname(dst_abs), exist_ok=True)
shutil.copy2(src_abs, dst_abs)
except Exception as e:
raise CustomException(msg=f"本地存储复制失败: {e!s}", status_code=500)
except Exception:
logger.exception('本地存储复制失败')
raise
@@ -176,7 +176,8 @@ class ObsStorageAdapter(BaseStorageAdapter):
content=f,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 上传失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 上传失败: {self._error_desc(resp)}")
return remote_path
part_size, concurrency, _ = self._multipart_settings()
resp = self.client.uploadFile(
@@ -188,11 +189,13 @@ class ObsStorageAdapter(BaseStorageAdapter):
enableCheckpoint=True,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 上传失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 上传失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 上传失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 上传失败')
raise
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -207,22 +210,26 @@ class ObsStorageAdapter(BaseStorageAdapter):
enableCheckpoint=True,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 下载失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 下载失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 下载失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 下载失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 下载失败')
raise
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
resp = self.client.deleteObject(bucketName=self._require_bucket(), objectKey=remote_path)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 删除失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 删除失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 删除失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 下载失败')
raise
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -255,7 +262,8 @@ class ObsStorageAdapter(BaseStorageAdapter):
kwargs["marker"] = marker
resp = self.client.listObjects(**kwargs)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 列表失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 列表失败: {self._error_desc(resp)}")
body = getattr(resp, "body", None)
for common in getattr(body, "commonPrefixs", None) or []:
raw_key = (getattr(common, "prefix", "") or "").rstrip("/")
@@ -291,8 +299,9 @@ class ObsStorageAdapter(BaseStorageAdapter):
return result
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 列表失败')
raise
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(OBS marker),翻页经前端回传游标。"""
@@ -307,7 +316,8 @@ class ObsStorageAdapter(BaseStorageAdapter):
kwargs["marker"] = cursor
resp = self.client.listObjects(**kwargs)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 列表失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 列表失败: {self._error_desc(resp)}")
body = getattr(resp, "body", None)
truncated = bool(getattr(body, "is_truncated", None))
next_cursor = getattr(body, "next_marker", None) or None
@@ -338,29 +348,33 @@ class ObsStorageAdapter(BaseStorageAdapter):
)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 列表失败')
raise
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
try:
resp = self.client.listBuckets()
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 桶列表失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 桶列表失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 桶列表失败: {self._error_desc(resp)}")
body = getattr(resp, "body", None)
buckets = getattr(body, "buckets", None) or []
return [getattr(b, "name", "") or "" for b in buckets if getattr(b, "name", "")]
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 桶列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 桶列表失败')
raise
def _sync_get_url(self, remote_path: str, expire: int) -> str:
try:
resp = self.client.createSignedUrl("GET", bucketName=self._require_bucket(), objectKey=remote_path, expires=expire)
return getattr(resp, "signedUrl", "")
except Exception as e:
raise CustomException(msg=f"OBS 生成预签名 URL 失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 桶列表失败')
raise
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
@@ -374,7 +388,8 @@ class ObsStorageAdapter(BaseStorageAdapter):
kwargs["marker"] = marker
resp = self.client.listObjects(**kwargs)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 列举对象失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 列举对象失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 列举对象失败: {self._error_desc(resp)}")
body = getattr(resp, "body", None)
for obj in getattr(body, "contents", None) or []:
key = getattr(obj, "key", "")
@@ -403,21 +418,25 @@ class ObsStorageAdapter(BaseStorageAdapter):
try:
resp = self.client.putContent(bucketName=self._require_bucket(), objectKey=remote_dir.rstrip("/") + "/", content=b"")
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 创建目录失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 创建目录失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 创建目录失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 创建目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 创建目录失败')
raise
def _sync_rmdir(self, remote_dir: str) -> None:
try:
resp = self.client.deleteObject(bucketName=self._require_bucket(), objectKey=remote_dir.rstrip("/") + "/")
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 删除目录失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 删除目录失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 删除目录失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 删除目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 创建目录失败')
raise
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copyObject。"""
@@ -432,11 +451,13 @@ class ObsStorageAdapter(BaseStorageAdapter):
destObjectKey=dst,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 复制失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 复制失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 复制失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 复制失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 复制失败')
raise
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:优先 OBS 原生 renameFile(支持目录级),失败回退先复制后删除。"""
@@ -455,8 +476,9 @@ class ObsStorageAdapter(BaseStorageAdapter):
def _sync_list_recursive(self, prefix: str) -> list[StorageObject]:
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"OBS 递归列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 递归列表失败')
raise
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -473,11 +495,13 @@ class ObsStorageAdapter(BaseStorageAdapter):
deleteObjectsRequest={"quiet": True, "objects": [{"key": k} for k in batch]},
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 递归删除失败: {self._error_desc(resp)}", status_code=500)
logger.error(f"OBS 递归删除失败: {self._error_desc(resp)}")
raise RuntimeError(f"OBS 递归删除失败: {self._error_desc(resp)}")
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 递归删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('OBS 递归删除失败')
raise
def _sync_close(self) -> None:
"""关闭 OBS 客户端连接。"""
@@ -49,7 +49,7 @@ class OssStorageAdapter(BaseStorageAdapter):
cfg.disable_download_crc64_check = adv.disable_download_crc64_check
cfg.enabled_redirect = adv.enabled_redirect
cfg.use_dualstack_endpoint = adv.use_dualstack_endpoint
self.client = oss.Client(cfg)
self.client: oss.Client = oss.Client(cfg)
def _sync_test_connection(self) -> bool:
try:
@@ -73,8 +73,9 @@ class OssStorageAdapter(BaseStorageAdapter):
part_size=part_size,
parallel_num=concurrency,
)
except Exception as e:
raise CustomException(msg=f"OSS 上传失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 上传失败')
raise
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -83,15 +84,17 @@ class OssStorageAdapter(BaseStorageAdapter):
oss.GetObjectRequest(bucket=self.bucket_name, key=remote_path),
local_path,
)
except Exception as e:
raise CustomException(msg=f"OSS 下载失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 下载失败')
raise
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
self.client.delete_object(oss.DeleteObjectRequest(bucket=self.bucket_name, key=remote_path))
except Exception as e:
raise CustomException(msg=f"OSS 删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 删除失败')
raise
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -139,8 +142,9 @@ class OssStorageAdapter(BaseStorageAdapter):
)
)
return result
except Exception as e:
raise CustomException(msg=f"OSS 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 列表失败')
raise
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(OSS continuation_token),翻页经前端回传游标。"""
@@ -177,8 +181,9 @@ class OssStorageAdapter(BaseStorageAdapter):
)
)
return StoragePage(items=items, has_next=truncated, next_cursor=next_cursor)
except Exception as e:
raise CustomException(msg=f"OSS 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 列表失败')
raise
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
@@ -190,8 +195,9 @@ class OssStorageAdapter(BaseStorageAdapter):
if b.name:
result.append(b.name)
return result
except Exception as e:
raise CustomException(msg=f"OSS 桶列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 桶列表失败')
raise
def _sync_get_url(self, remote_path: str, expire: int) -> str | None:
try:
@@ -200,8 +206,9 @@ class OssStorageAdapter(BaseStorageAdapter):
expires=timedelta(seconds=expire),
)
return result.url
except Exception as e:
raise CustomException(msg=f"OSS 生成预签名 URL 失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 生成预签名 URL 失败')
raise
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
@@ -230,14 +237,16 @@ class OssStorageAdapter(BaseStorageAdapter):
def _sync_mkdir(self, remote_dir: str) -> None:
try:
self.client.put_object(oss.PutObjectRequest(bucket=self.bucket_name, key=remote_dir.rstrip("/") + "/", body=b""))
except Exception as e:
raise CustomException(msg=f"OSS 创建目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 创建目录失败')
raise
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self.client.delete_object(oss.DeleteObjectRequest(bucket=self.bucket_name, key=remote_dir.rstrip("/") + "/"))
except Exception as e:
raise CustomException(msg=f"OSS 删除目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 删除目录失败')
raise
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
@@ -248,8 +257,9 @@ class OssStorageAdapter(BaseStorageAdapter):
self.client.copy_object(
oss.CopyObjectRequest(bucket=self.bucket_name, key=dst, source_bucket=self.bucket_name, source_key=src)
)
except Exception as e:
raise CustomException(msg=f"OSS 复制失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 复制失败')
raise
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
@@ -262,8 +272,9 @@ class OssStorageAdapter(BaseStorageAdapter):
def _sync_list_recursive(self, prefix: str) -> list[StorageObject]:
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"OSS 递归列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 递归列表失败')
raise
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -281,5 +292,6 @@ class OssStorageAdapter(BaseStorageAdapter):
quiet=True,
)
)
except Exception as e:
raise CustomException(msg=f"OSS 递归删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('OSS 递归删除失败')
raise
@@ -87,22 +87,25 @@ class S3StorageAdapter(BaseStorageAdapter):
max_concurrency=concurrency,
)
self.client.upload_file(local_path, self._require_bucket(), remote_path, Config=transfer_config)
except Exception as e:
raise CustomException(msg=f"S3 上传失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 上传失败')
raise
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
try:
self.client.download_file(self._require_bucket(), remote_path, local_path)
except Exception as e:
raise CustomException(msg=f"S3 下载失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 下载失败')
raise
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_path)
except Exception as e:
raise CustomException(msg=f"S3 删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 删除失败')
raise
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -162,8 +165,9 @@ class S3StorageAdapter(BaseStorageAdapter):
else:
break
return result
except Exception as e:
raise CustomException(msg=f"S3 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 列表失败')
raise
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(S3 ContinuationToken),翻页经前端回传游标。"""
@@ -202,16 +206,18 @@ class S3StorageAdapter(BaseStorageAdapter):
has_next=bool(resp.get("IsTruncated")),
next_cursor=resp.get("NextContinuationToken"),
)
except Exception as e:
raise CustomException(msg=f"S3 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 列表失败')
raise
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
try:
resp = self.client.list_buckets()
return [b.get("Name", "") for b in resp.get("Buckets", []) if b.get("Name")]
except Exception as e:
raise CustomException(msg=f"S3 桶列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 桶列表失败')
raise
def _sync_get_url(self, remote_path: str, expire: int) -> str:
try:
@@ -220,8 +226,9 @@ class S3StorageAdapter(BaseStorageAdapter):
Params={"Bucket": self._require_bucket(), "Key": remote_path},
ExpiresIn=expire,
)
except Exception as e:
raise CustomException(msg=f"S3 生成预签名 URL 失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 生成预签名 URL 失败')
raise
def _sync_close(self) -> None:
"""关闭 boto3 客户端连接。"""
@@ -252,14 +259,16 @@ class S3StorageAdapter(BaseStorageAdapter):
def _sync_mkdir(self, remote_dir: str) -> None:
try:
self.client.put_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/", Body=b"")
except Exception as e:
raise CustomException(msg=f"S3 创建目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 创建目录失败')
raise
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self.client.delete_object(Bucket=self._require_bucket(), Key=remote_dir.rstrip("/") + "/")
except Exception as e:
raise CustomException(msg=f"S3 删除目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 删除目录失败')
raise
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
@@ -272,8 +281,9 @@ class S3StorageAdapter(BaseStorageAdapter):
CopySource={"Bucket": self._require_bucket(), "Key": src},
Key=dst,
)
except Exception as e:
raise CustomException(msg=f"S3 复制失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 复制失败')
raise
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
@@ -286,8 +296,9 @@ class S3StorageAdapter(BaseStorageAdapter):
def _sync_list_recursive(self, prefix: str) -> list[StorageObject]:
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"S3 递归列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 递归列表失败')
raise
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -302,5 +313,6 @@ class S3StorageAdapter(BaseStorageAdapter):
Bucket=self._require_bucket(),
Delete={"Objects": [{"Key": k} for k in keys[i : i + 1000]], "Quiet": True},
)
except Exception as e:
raise CustomException(msg=f"S3 递归删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('S3 递归删除失败')
raise
@@ -117,8 +117,9 @@ class SftpStorageAdapter(BaseStorageAdapter):
if dir_part:
self._ensure_remote_dir(client, dir_part)
client.put(local_path, remote_path)
except Exception as e:
raise CustomException(msg=f"SFTP 上传失败: {e!s}", status_code=500)
except Exception:
logger.exception('SFTP 上传失败')
raise
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -140,15 +141,17 @@ class SftpStorageAdapter(BaseStorageAdapter):
lf.write(chunk)
else:
client.get(remote_path, local_path)
except Exception as e:
raise CustomException(msg=f"SFTP 下载失败: {e!s}", status_code=500)
except Exception:
logger.exception('SFTP 下载失败')
raise
return local_path
def _sync_delete(self, remote_path: str) -> None:
try:
self._connect().remove(remote_path)
except Exception as e:
raise CustomException(msg=f"SFTP 删除失败: {e!s}", status_code=500)
except Exception:
logger.exception('SFTP 删除失败')
raise
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -161,8 +164,9 @@ class SftpStorageAdapter(BaseStorageAdapter):
try:
# 空 prefix 表示浏览根目录:SFTP 用 "." 表示登录后的当前目录
attrs = self._connect().listdir_attr(prefix or ".")
except Exception as e:
raise CustomException(msg=f"SFTP 列表失败: {e!s}", status_code=500)
except Exception:
logger.exception('SFTP 列表失败')
raise
result: list[StorageObject] = []
for attr in attrs:
result.append(
@@ -188,20 +192,23 @@ class SftpStorageAdapter(BaseStorageAdapter):
def _sync_mkdir(self, remote_dir: str) -> None:
try:
self._ensure_remote_dir(self._connect(), remote_dir)
except Exception as e:
raise CustomException(msg=f"SFTP 创建目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('SFTP 创建目录失败')
raise
def _sync_rmdir(self, remote_dir: str) -> None:
try:
self._connect().rmdir(remote_dir)
except Exception as e:
raise CustomException(msg=f"SFTP 删除目录失败: {e!s}", status_code=500)
except Exception:
logger.exception('SFTP 删除目录失败')
raise
def _sync_rename(self, src: str, dst: str) -> None:
try:
self._connect().posix_rename(src, dst)
except Exception as e:
raise CustomException(msg=f"SFTP 重命名失败: {e!s}", status_code=500)
except Exception:
logger.exception('SFTP 重命名失败')
raise
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用通道流式复制(SFTP 无服务端复制)。"""
@@ -213,5 +220,6 @@ class SftpStorageAdapter(BaseStorageAdapter):
with client.open(src, "rb") as rf, client.open(dst, "wb") as wf:
while chunk := rf.read(1024 * 1024):
wf.write(chunk)
except Exception as e:
raise CustomException(msg=f"SFTP 复制失败: {e!s}", status_code=500)
except Exception:
logger.exception('SFTP 复制失败')
raise
@@ -262,7 +262,7 @@ class DemoService:
except Exception as e:
logger.error(f"批量导入示例失败: {e!s}")
raise CustomException(msg=f"导入失败: {e!s}", status_code=500) from e
raise
@staticmethod
def import_template_download() -> bytes:
+3 -2
View File
@@ -17,7 +17,7 @@ from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from app.config.setting import settings
from app.core.exceptions import CustomException
from app.core.logger import logger
# HKDF 域分隔:与 SECRET_KEY 用于其它目的时派生出不同密钥
_INFO = b"fastapiadmin:data-encryption:v1"
@@ -71,7 +71,8 @@ class CryptoUtil:
return fernet.decrypt(cipher.encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
continue
raise CustomException(msg="敏感数据解密失败,可能原因:DATA_ENCRYPTION_KEY/SECRET_KEY 变更或数据损坏", status_code=500)
logger.error("敏感数据解密失败,可能原因:DATA_ENCRYPTION_KEY/SECRET_KEY 变更或数据损坏")
raise RuntimeError("敏感数据解密失败,可能原因:DATA_ENCRYPTION_KEY/SECRET_KEY 变更或数据损坏")
@classmethod
def decrypt_or_keep(cls, value: str | None) -> str:
+7 -5
View File
@@ -8,7 +8,7 @@ from typing import Any
from sqlalchemy import inspect as sa_inspect
from app.config.path_conf import BASE_DIR
from app.core.exceptions import CustomException
from app.core.logger import logger
class ImportUtil:
@@ -153,8 +153,9 @@ class ImportUtil:
except ImportError as e:
if "cannot import name" not in str(e):
raise ImportError(f"❗️ 警告: 无法导入模块 {module_name}: {e}")
except Exception as e:
raise CustomException(f"❌️ 处理模块 {module_name} 时出错: {e}", status_code=500)
except Exception:
logger.exception('处理模块失败')
raise
# 查找apscheduler_jobs表的模型(如果存在)
cls._find_apscheduler_model(base_class, models, seen_models, seen_tables)
@@ -204,5 +205,6 @@ class ImportUtil:
print(f"✅️ 找到有效模型: {obj.__module__}.{obj.__name__} (表: apscheduler_jobs)")
except ImportError:
pass
except Exception as e:
raise CustomException(f"❗️ 查找APScheduler模型时出错: {e}", status_code=500)
except Exception:
logger.exception('查找 APScheduler 模型失败')
raise
+1 -1
View File
@@ -468,7 +468,7 @@ class UploadUtil:
raise
except Exception as e:
logger.error(f"文件上传失败: {e}")
raise CustomException(msg=f"文件上传失败: {e}", status_code=500)
raise
@staticmethod
def get_file_tree(file_path: str) -> list[dict]:
@@ -0,0 +1,260 @@
"""错误码单一事实来源(t16)回归测试。
设计约定:
- HTTP 语义只由 ``RET.code → HTTP status``(``core/exceptions.py`` 的映射表)决定;
- 业务代码只传 ``code``;意外异常不在业务代码里包装,交给全局处理器映射为 5xx;
- 除 ``core/exceptions.py``(映射表与逃生口)外,``app/`` 下不得再出现 ``status_code=`` 传参。
本文件既锁语义(400/401/403/404/500),也加静态护栏防回潮。
"""
import ast
import json
import pathlib
import re
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.common.enums import RET
from app.core.exceptions import CustomException, handle_exception, resolve_http_status
from app.core.logger import logger
_APP_DIR = pathlib.Path(__file__).resolve().parent.parent / "app"
_EXCEPTIONS_FILE = _APP_DIR / "core" / "exceptions.py"
_STATUS_KWARG = "status_code="
# 唯一允许的例外:FastAPI 路由装饰器的框架参数(如 @Router.post(..., status_code=201)),
# 与「业务错误码 → HTTP 语义」无关;去掉它会改变创建类接口的 HTTP 语义(201→200),故白名单放行。
_ROUTE_DECORATOR_LINE = re.compile(r"^\s*@\w+\.(get|post|put|patch|delete|head|options)\(")
# ── 1) 静态护栏:业务代码不得显式传 status_code ───────────────────────────────
def test_no_explicit_status_code_in_business_code() -> None:
"""app/ 下(除 core/exceptions.py)不得出现 status_code= 传参。"""
violations: list[str] = []
allowed: list[str] = []
for path in sorted(_APP_DIR.rglob("*.py")):
if path == _EXCEPTIONS_FILE:
continue
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if _STATUS_KWARG not in line:
continue
record = f"{path.relative_to(_APP_DIR.parent)}:{lineno}: {line.strip()[:120]}"
if _ROUTE_DECORATOR_LINE.match(line):
allowed.append(record) # FastAPI 路由装饰器参数(框架语义,非业务错误码)
continue
violations.append(record)
assert violations == [], f"业务代码出现显式 status_code 传参(应改为 code=... 或 CustomException.internal):{violations}"
assert allowed, "白名单为空:若路由装饰器写法变化请同步更新本测试"
def test_no_custom_exception_passes_status_code() -> None:
"""更精确的护栏:CustomException 调用点一律不传 status_code(除 exceptions.py 自身)。"""
offenders: list[str] = []
call_re = re.compile(r"CustomException(?:\.\w+)?\(")
for path in sorted(_APP_DIR.rglob("*.py")):
if path == _EXCEPTIONS_FILE:
continue
text = path.read_text(encoding="utf-8")
for match in call_re.finditer(text):
depth, index = 0, match.end() - 1
while index < len(text):
if text[index] == "(":
depth += 1
elif text[index] == ")":
depth -= 1
if depth == 0:
break
index += 1
call = text[match.start() : index + 1]
if _STATUS_KWARG in call:
offenders.append(f"{path.relative_to(_APP_DIR.parent)}:{text[: match.start()].count(chr(10)) + 1}")
assert offenders == [], f"CustomException 仍显式传 status_code:{offenders}"
# ── 2) 映射表是唯一事实来源 ──────────────────────────────────────────────────
def test_mapping_table_is_explicit_for_default_business_code() -> None:
"""默认业务码 RET.EXCEPTION(-1) 显式登记为 400(不再依赖隐式兜底)。"""
assert resolve_http_status(RET.EXCEPTION.code) == 400
assert CustomException(msg="验证码已过期,请刷新").status_code == 400
def test_mapping_covers_http_like_codes() -> None:
"""凡是「数值本身就是 HTTP 状态码」的 RET 成员,映射结果必须与自身一致。"""
for member in RET:
if 400 <= member.code <= 599:
assert resolve_http_status(member.code) == member.code, f"{member.name}({member.code}) 映射错位"
def test_no_internal_entry_and_unexpected_errors_use_global_handler() -> None:
"""t19:internal() 语义入口已删除;意外异常交给全局处理器给 5xx + 通用文案。"""
assert not hasattr(CustomException, "internal")
assert resolve_http_status(RET.SERVERERR.code) == 500 # 码仍在映射表内,供显式声明服务端故障的业务码使用
app = FastAPI()
handle_exception(app)
@app.get("/unexpected")
async def _unexpected() -> None:
raise RuntimeError("统计失败: 连接断开 connection lost")
response = TestClient(app, raise_server_exceptions=False).get("/unexpected")
assert response.status_code == 500
body = response.json()
assert body["msg"] == "服务器内部错误"
assert "connection lost" not in json.dumps(body, ensure_ascii=False)
def test_unmapped_code_falls_back_to_client_error_net() -> None:
"""未登记 code 的安全网仍是 400(仅兜底,不应作为正常路径)。"""
assert resolve_http_status(999999) == 400
assert resolve_http_status(None) == 400
# ── 3) 语义不变(HTTP 层实测) ───────────────────────────────────────────────
def _semantic_probe_app() -> FastAPI:
app = FastAPI()
handle_exception(app)
@app.get("/captcha-expired")
async def _captcha() -> None:
raise CustomException(msg="验证码已过期,请刷新")
@app.get("/forbidden")
async def _forbidden() -> None:
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code)
@app.get("/token-expired")
async def _token() -> None:
raise CustomException(msg="认证已失效", code=RET.TOKEN_EXPIRED.code)
@app.get("/not-found")
async def _not_found() -> None:
raise CustomException(msg="该数据不存在", code=RET.NOT_FOUND.code)
@app.get("/internal")
async def _internal() -> None:
# 意外异常:不包装,直接冒泡给全局处理器(t19)
raise RuntimeError("统计失败: 模拟底层驱动故障")
return app
@pytest.mark.parametrize(
("path", "expected_status", "expected_code"),
[
("/captcha-expired", 400, RET.EXCEPTION.code),
("/forbidden", 403, RET.NO_PERMISSION.code),
("/token-expired", 401, RET.TOKEN_EXPIRED.code),
("/not-found", 404, RET.NOT_FOUND.code),
("/internal", 500, RET.ERROR.code),
],
)
def test_semantics_unchanged_via_mapping(path: str, expected_status: int, expected_code: int) -> None:
"""验证码/参数→400、无权限→403、令牌→401、资源不存在→404、内部故障→500,且响应体结构不变。"""
client = TestClient(_semantic_probe_app(), raise_server_exceptions=False)
response = client.get(path)
assert response.status_code == expected_status, response.text
body = response.json()
assert set(body) >= {"code", "msg", "data", "status_code", "success"}
assert body["code"] == expected_code
assert body["status_code"] == expected_status
assert body["msg"] # 诊断信息不丢
assert body["success"] is False
# ── 4) t19:业务异常透传 + 意外异常冒泡(AST 护栏) ───────────────────────────
def test_internal_faults_are_not_wrapped_locally() -> None:
"""132 处局部包装已清除:app/ 下不再出现 internal() 语义入口或 status_code 传参。"""
offenders: list[str] = []
for path in sorted(_APP_DIR.rglob("*.py")):
text = path.read_text(encoding="utf-8")
if "CustomException.internal" in text or "def internal(" in text:
offenders.append(str(path.relative_to(_APP_DIR.parent)))
assert offenders == [], f"仍存在局部包装入口:{offenders}"
def test_business_error_passes_through_try_and_stays_4xx() -> None:
"""try 内抛出的业务 400 穿过「日志 + raise」的宽泛 except 后仍是 400。"""
app = FastAPI()
handle_exception(app)
async def _service() -> None:
raise CustomException(msg="请求参数错误")
@app.get("/passthrough")
async def _passthrough() -> None:
try:
await _service()
except CustomException:
raise
except Exception as exc:
logger.exception("处理请求失败")
raise exc
response = TestClient(app, raise_server_exceptions=False).get("/passthrough")
assert response.status_code == 400, response.text
body = response.json()
assert body["msg"] == "请求参数错误"
assert body["code"] == RET.EXCEPTION.code
# 宽泛 except 若「构造新的 CustomException」抛出,必须先有 `except CustomException: raise`
_BROAD = {"Exception", "BaseException"}
def _is_custom_exception_name(node: ast.AST) -> bool:
if isinstance(node, ast.Name):
return node.id == "CustomException"
if isinstance(node, ast.Attribute):
return node.attr == "CustomException"
if isinstance(node, ast.Tuple):
return any(_is_custom_exception_name(e) for e in node.elts)
return False
def _is_broad_handler(handler: ast.ExceptHandler) -> bool:
t = handler.type
if t is None:
return True
if isinstance(t, ast.Name):
return t.id in _BROAD
if isinstance(t, ast.Tuple):
return any(isinstance(e, ast.Name) and e.id in _BROAD for e in t.elts)
return False
def test_ast_guard_broad_except_requires_custom_exception_passthrough() -> None:
"""AST 护栏:宽泛 except 若转换异常,必须前置 `except CustomException: raise`(防语义反转回潮)。"""
offenders: list[str] = []
for path in sorted(_APP_DIR.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, ast.Try):
continue
guarded = any(
h.type is not None
and _is_custom_exception_name(h.type)
and any(isinstance(stmt, ast.Raise) and stmt.exc is None for stmt in h.body)
for h in node.handlers
)
for handler in node.handlers:
if not _is_broad_handler(handler) or guarded:
continue
for inner in ast.walk(handler):
if (
isinstance(inner, ast.Raise)
and isinstance(inner.exc, ast.Call)
and getattr(inner.exc.func, "id", None) == "CustomException"
):
offenders.append(f"{path.relative_to(_APP_DIR.parent)}:{inner.lineno}")
assert offenders == [], f"宽泛 except 转换业务异常却缺守卫(应加 except CustomException: raise):{offenders}"
+56 -53
View File
@@ -1,12 +1,13 @@
"""安全默认值与异常状态码回归测试。
覆盖 backend/audit-security.md 的 S9/S10 与 backend/audit-backend.md 的 H1/H5 同类根因:
- 业务异常按自身状态码返回:客户端可修正类(参数/验证码/权限/不存在)为 4xx;
- 「包装底层异常」的内部故障(DB/驱动/Redis/SDK)显式为 500,不得降级成 4xx(否则监控误判);
- 业务异常一律只传 code(HTTP 状态由映射表推导):参数/验证码 400、权限 403、不存在 404;
- 意外异常不在业务代码里包装(t19):原始异常冒泡,由全局处理器给 5xx + 通用文案 + 日志;
- 定时任务代码块执行默认关闭,关闭时给出明确失败原因(而非静默执行/静默跳过);
- 生产环境 CORS 不再回落为通配 ``["*"]``(与 ``ALLOW_CREDENTIALS=True`` 组合会放行任意站点带凭据跨域)。
"""
import json
import pathlib
import re
from datetime import datetime
@@ -21,6 +22,7 @@ from app.common.enums import RET, EnvironmentEnum
from app.config.setting import Settings, settings
from app.core.ap_scheduler import SchedulerUtil
from app.core.exceptions import CustomException, handle_exception, resolve_http_status
from app.core.logger import logger
from app.core.middlewares import CustomCORSMiddleware
from app.modules.task.cronjob.node.service import _add_job_with_trigger
@@ -36,9 +38,9 @@ def test_custom_exception_defaults_to_4xx() -> None:
def test_custom_exception_status_resolution() -> None:
"""显式状态码优先;否则按业务码映射;未知码回落 400。"""
assert CustomException(msg="x", status_code=403).status_code == 403
assert CustomException(msg="x", status_code=500).status_code == 500
"""HTTP 状态只能由 code 经映射表推导(t19:构造器已不接受 status_code)。"""
with pytest.raises(TypeError):
CustomException(msg="x", status_code=403)
assert CustomException(msg="x", code=RET.UNAUTHORIZED.code).status_code == 401
assert CustomException(msg="x", code=RET.NOT_FOUND.code).status_code == 404
assert CustomException(msg="x", code=RET.NO_PERMISSION.code).status_code == 403
@@ -59,7 +61,7 @@ def _exception_probe_app() -> FastAPI:
@app.get("/forbidden")
async def _forbidden() -> None:
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code, status_code=403)
raise CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code)
@app.get("/boom")
async def _boom() -> None:
@@ -80,8 +82,8 @@ def test_captcha_expired_returns_4xx_with_stable_body() -> None:
assert body["status_code"] == 400
def test_explicit_status_code_is_honored_by_handler() -> None:
"""显式状态码仍按原样返回(403 不被 400 覆盖)。"""
def test_forbidden_code_maps_to_403_via_table() -> None:
"""无权限业务码经映射表得到 403(不依赖显式 status_code)。"""
client = TestClient(_exception_probe_app(), raise_server_exceptions=False)
response = client.get("/forbidden")
assert response.status_code == 403, response.text
@@ -293,13 +295,12 @@ def _crud(db: object, model: type | None = None):
("set", lambda c: c.set(ids=[1], status=0)),
],
)
async def test_base_crud_wrapped_internal_failure_returns_500(case: str, call) -> None:
"""CRUD 公共出口包装底层异常时必须 500(此前被 t10 降级成 400)。"""
with pytest.raises(CustomException) as excinfo:
async def test_base_crud_internal_failure_bubbles_original_exception(case: str, call) -> None:
"""CRUD 公共出口不再包装意外异常:原始异常(含类型与信息)原样冒泡,由全局处理器给 5xx。"""
with pytest.raises(RuntimeError) as excinfo:
await call(_crud(_FailingSession()))
assert excinfo.value.status_code == 500, f"{case} 未按 500 返回(实际 {excinfo.value.status_code})"
assert excinfo.value.success is False
assert "失败" in excinfo.value.msg
assert "connection lost" in str(excinfo.value), f"{case} 丢掉了原始错误信息"
assert not isinstance(excinfo.value, CustomException), f"{case} 仍被包装成业务异常"
async def test_get_or_404_returns_404_for_missing_row() -> None:
@@ -315,32 +316,33 @@ def test_client_side_errors_keep_4xx() -> None:
assert CustomException(msg="验证码已过期,请刷新").status_code == 400
assert CustomException(msg="请求参数错误").status_code == 400
assert CustomException(msg="无权限操作", code=RET.NO_PERMISSION.code).status_code == 403
assert CustomException(msg="该数据不存在", status_code=404).status_code == 404
assert CustomException(msg="该数据不存在", code=RET.NOT_FOUND.code).status_code == 404
assert resolve_http_status(RET.NOT_FOUND.code) == 404
assert resolve_http_status(RET.NO_PERMISSION.code) == 403
assert resolve_http_status(RET.BAD_REQUEST.code) == 400
assert resolve_http_status(RET.SERVERERR.code) == 500
def test_internal_failure_body_keeps_business_code_and_message() -> None:
"""内部故障 500 时响应体仍带原始 msg 与业务 code(前端依赖 code=-1 展示真实原因)。"""
def test_internal_failure_body_is_generic_and_hides_details() -> None:
"""意外异常 → 全局处理器:500 + 通用文案,内部细节只进日志(t19)。"""
app = FastAPI()
handle_exception(app)
@app.get("/internal")
async def _internal() -> None:
raise CustomException(msg="统计失败: 模拟底层驱动故障", status_code=500)
raise RuntimeError("统计失败: 模拟底层驱动故障 connection lost")
response = TestClient(app, raise_server_exceptions=False).get("/internal")
assert response.status_code == 500
body = response.json()
assert body["msg"] == "统计失败: 模拟底层驱动故障"
assert body["code"] == RET.EXCEPTION.code
assert body["msg"] == "服务器内部错误"
assert "connection lost" not in json.dumps(body, ensure_ascii=False)
assert body["code"] == RET.ERROR.code
# 与实现同源的分类规则(见 app/core/exceptions.py 的说明与 t11 的清理范围)
_EXC_INTERP = re.compile(r"\{(?:e|exc|err|error|ex|exception)(?:!s|!r|:[a-z]+)?\}|\{errors\[|_error_desc\(")
_EXC_CALL = re.compile(r"CustomException\(")
_EXC_CALL = re.compile(r"CustomException(?:\.\w+)?\(")
_CLIENT_MARKERS = (
"targets 参数格式错误",
"创建成功但任务注册失败",
@@ -384,45 +386,46 @@ def _iter_custom_exception_calls(path: pathlib.Path):
yield text[: match.start()].count("\n") + 1, text[match.start() : index + 1]
def test_all_wrapped_internal_failures_are_explicitly_500() -> None:
"""守卫:inScope 内凡是「包装底层异常」的 CustomException 都必须显式 500。
def test_no_internal_wrapping_helper_anywhere() -> None:
"""守卫(t19):``CustomException.internal`` 已彻底移除——意外异常交给全局处理器。
否则底层故障会被默认值(400)静默降级成客户端错误,监控失去信号。
同时确认原来的「包装点」已改为「logger.exception(...) + raise」或直接冒泡。
"""
offenders: list[str] = []
checked = 0
targets = [_REPO_ROOT / "app" / "core" / "base_crud.py"] + sorted((_REPO_ROOT / "app" / "modules").rglob("*.py"))
for path in targets:
for line, call in _iter_custom_exception_calls(path):
if not _EXC_INTERP.search(call):
continue
if any(marker in call for marker in _CLIENT_MARKERS):
continue
checked += 1
if "status_code=500" not in call:
offenders.append(f"{path.relative_to(_REPO_ROOT)}:{line}")
assert checked > 90, f"分类用例数异常(只匹配到 {checked} 处),分类规则可能失效"
assert offenders == [], f"以下代码生成路径仍可能把内部故障降级为 4xx:{offenders}"
for path in sorted((_REPO_ROOT / "app").rglob("*.py")):
text = path.read_text(encoding="utf-8")
if "CustomException.internal" in text or "def internal(" in text:
offenders.append(str(path.relative_to(_REPO_ROOT)))
assert offenders == [], f"仍存在 internal 语义入口:{offenders}"
assert not hasattr(CustomException, "internal")
logged = (_REPO_ROOT / "app" / "core" / "base_crud.py").read_text(encoding="utf-8")
assert logged.count("logger.exception(") >= 9, "base_crud 的公共出口应改为 logger.exception + raise"
assert 'raise CustomException.internal' not in logged
def test_base_crud_internal_wrappers_are_500() -> None:
"""core 层专门固化:base_crud.py 全部 9 处包装路径显式 500。"""
calls = [
(line, call)
for line, call in _iter_custom_exception_calls(_REPO_ROOT / "app" / "core" / "base_crud.py")
if _EXC_INTERP.search(call)
]
assert len(calls) == 9, f"base_crud.py 包装路径数量变化({len(calls)}):{[(line, call[:30]) for line, call in calls]}"
missing = [line for line, call in calls if "status_code=500" not in call]
assert missing == [], f"base_crud.py 以下行未显式 500:{missing}"
def test_business_errors_can_pass_through_broad_except_guards() -> None:
"""示范正确写法:宽泛 except 前先 `except CustomException: raise`,业务 400 不被改写。"""
app = FastAPI()
handle_exception(app)
async def _service() -> None:
raise CustomException(msg="该数据不存在", code=RET.NOT_FOUND.code)
# ── 5. 升级可见性:CORS 严格启动、主机放行开关、健康检查字段 ──────────────────
#
# 背景:t13 把三处「静默行为变更」做成可配置 + 可观测:
# - CORS_STRICT_STARTUP:生产未配置 CORS 白名单时从「只告警」变为「启动失败」;
# - ALLOW_LOCALHOST_HOSTS:localhost 放行从无条件写死变为可开关;
# - 健康检查暴露 code_exec_enabled / cors_origins_configured,避免升级后「任务不动了」无迹可循。
@app.get("/guarded")
async def _guarded() -> None:
try:
await _service()
except CustomException:
raise
except Exception:
logger.exception("查询业务数据失败")
raise
client = TestClient(app, raise_server_exceptions=False)
response = client.get("/guarded")
assert response.status_code == 404, response.text
assert response.json()["msg"] == "该数据不存在"
def test_cors_strict_startup_fails_fast(monkeypatch: pytest.MonkeyPatch) -> None: