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