refactor(backend): 错误码收敛为单一事实来源,统一异常写法

错误码(t16)
- HTTP 语义只由 core/exceptions.py 的 RET.code → fastapi.status 映射决定;
  映射表显式登记 -1→400 与全部「数值即 HTTP 状态」的 RET 成员,_DEFAULT_BUSINESS_STATUS
  降为「新增码忘登记」的安全网
- 移除冗余 status_code:131 处调用点改为纯 code 表达;security.py/dependencies.py
  的 status_code=401/403 去除(映射表已给);get_or_404 → code=RET.NOT_FOUND.code
- 新增静态护栏(AST):app/ 下除 exceptions.py 外不得出现 status_code 传参,
  路由装饰器 201 白名单显式放行并断言白名单非空

异常写法统一(t19)
- 删除 CustomException.internal 及 132 处包装:意外异常直接冒泡,由全局
  @app.exception_handler(Exception) 统一映射 5xx + 通用文案 + 日志;
  需要运维上下文处改为 logger.exception("<业务动作>失败") + raise
- 构造器不再接受 status_code 参数(属性保留,由 code 派生)→ 结构上杜绝第二来源
- 修复 6 处「宽泛 except 把 400 包成 500」的语义反转(补 except CustomException: raise),
  并加 AST 护栏防止回潮
- 全局 SQLAlchemyError:非完整性错误由 400 改为 503(连接类)/500(其它),
  不再把驱动原文/异常类型拼进客户端 msg;顺带修掉不存在的
  status.HTTP_403_SERVICE_UNAVAILABLE 常量(命中即 AttributeError→500)

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