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