mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-15 05:19:17 +00:00
fix(backend): 安全默认值、异常语义、代码生成注入面与定时任务内置处理器
本批为 t10 → t13 → t15 三轮实现 + 一轮复核驳回整改的最终形态(pytest 83 passed / ruff clean,
已部署并线上验证)。
异常语义
- CustomException 默认状态码 500 → 400(约 433 处裸调用不再被计为服务端故障),
且「包装底层异常」的 130 处显式恢复 500;get_or_404 → 404;响应体结构不变
代码生成注入面(复核 blocker F1,二次修复)
- column_type 不再做字符串拼接:改为常量白名单派生(类型名 ∈ DB_TO_SQLALCHEMY ∪ {Boolean},
长度仅取数字/正则捕获),彻底移除 split("(")[1]
- 列类型加形状白名单;字符串/注释/文档字符串用 \uXXXX,标识符过 safe_ident(含 Python/JS 关键字),
.vue 文本与静态属性改 HTML 实体
- 落盘闸门由 ast.parse 升级为 assert_python_structure(语法 + 顶层语句白名单 + 与哨兵骨架逐节点比对)
与 assert_text_structure(.vue/.ts/.toml 行结构);U+2028/2029 与 C1 控制符纳入转义
安全默认值
- 生产 CORS 不再回落 ["*"](未配置即拒绝跨域,可选 CORS_STRICT_STARTUP 直接启动失败)
- OAUTH_ALLOWED_HOSTS 不再默认 "*";新增 ALLOW_LOCALHOST_HOSTS 控制 Host 校验范围
- ALLOWED_HOSTS 收窄为按配置派生;健康检查暴露 code_exec_enabled / cors_origins_configured
定时任务
- SCHEDULER_ALLOW_CODE_EXEC 默认 False;新增 builtin:<模块名>[.<函数名>] 内置处理器路线(不经 exec)
- 判定收敛到 describe_schedulability;列表返回 schedulable / unschedulable_reason;
启动 reconcile 与状态查询对「已启用但不可调度」打 WARN(去重、恢复后清除)
- 加固:内置处理器必须在本模块内定义或经 HANDLERS 声明,拒绝模块内导入的外部对象(复核 R1)
- 顺带修复:_WARNED_UNSCHEDULABLE 的 set.pop TypeError、job.next_run_time 缺失导致的列表 500
测试:新增 tests/test_generator_template_safety.py、test_security_defaults.py、
test_scheduler_schedulability.py(含恶意载荷、闸门红绿、静态护栏)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -94,6 +96,16 @@ class SchedulerUtil:
|
||||
"""定时任务 SDK — 封装 APScheduler 核心操作与任务执行记录落库。"""
|
||||
|
||||
redis_instance: Redis | None = None
|
||||
# ── 内置处理器白名单(「脚本类」与「内置函数类」任务的分界)────────────────
|
||||
# 节点任务 func 形如 "builtin:<模块名>[.<函数名>]"(模块限定在 handlers 包内)时,
|
||||
# 由注册表解析后**直接调用**,不经过 exec —— 因此不受 SCHEDULER_ALLOW_CODE_EXEC 限制。
|
||||
# 这样该开关只关闭"执行用户提交代码块"的能力,而不是整个调度特性。
|
||||
BUILTIN_PREFIX = "builtin:"
|
||||
BUILTIN_HANDLERS_MODULE = "app.modules.task.cronjob.node.handlers"
|
||||
# 名称白名单:仅允许「模块名」或「模块名.函数名」,禁止点号穿越 / 括号 / 特殊字符
|
||||
_BUILTIN_NAME_RE = re.compile(r"^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$")
|
||||
_builtin_handlers: dict[str, Callable[..., Any]] = {}
|
||||
|
||||
# 多 worker 选主状态:_redis 为空视为单机直跑;_leader_token 非空表示本进程持有调度锁
|
||||
_redis: Redis | None = None
|
||||
_leader_token: str | None = None
|
||||
@@ -244,6 +256,16 @@ class SchedulerUtil:
|
||||
continue
|
||||
want_ids.add(str(node.id))
|
||||
|
||||
# 可观测性(t15):把"已启用但当前不会执行"的节点与存量 job 显式打出来,
|
||||
# 否则开关调整后只会表现为"任务不动了",运维无从定位
|
||||
for node in nodes:
|
||||
if node.status != 0 or not node.trigger:
|
||||
continue
|
||||
schedulable, reason = cls.describe_schedulability(node.func)
|
||||
if not schedulable:
|
||||
logger.warning(f"节点任务不可调度(已启用但不会执行): id={node.id} name={node.name!r} 原因={reason}")
|
||||
cls.log_unschedulable_jobs(context="启动自愈")
|
||||
|
||||
for job_id in existing:
|
||||
if job_id.isdigit() and job_id not in want_ids:
|
||||
try:
|
||||
@@ -419,7 +441,7 @@ class SchedulerUtil:
|
||||
"status": status,
|
||||
"error": _humanize_job_error(detail) if status == JOB_STATUS_FAILED else None,
|
||||
"result": None if status == JOB_STATUS_FAILED else (str(detail)[:4000] if detail is not None else None),
|
||||
"next_run_time": str(job.next_run_time) if job and job.next_run_time else None,
|
||||
"next_run_time": str(getattr(job, "next_run_time", None)) if getattr(job, "next_run_time", None) else None,
|
||||
"job_state": SchedulerUtil._get_job_state(job) if job else None,
|
||||
}
|
||||
cls._record_job_log(record)
|
||||
@@ -434,7 +456,7 @@ class SchedulerUtil:
|
||||
pass
|
||||
return
|
||||
# 一次性 date 计划执行完成(无下次运行时间)后移除
|
||||
if job and job.next_run_time is None and isinstance(getattr(job, "trigger", None), DateTrigger):
|
||||
if job and getattr(job, "next_run_time", None) is None and isinstance(getattr(job, "trigger", None), DateTrigger):
|
||||
try:
|
||||
SchedulerUtil.remove_job(job_id=job_id)
|
||||
logger.info(f"一次性 date 任务 {job_id} 已完成,已从调度器移除")
|
||||
@@ -508,7 +530,8 @@ class SchedulerUtil:
|
||||
job = cls.get_job(job_id=str(job_id))
|
||||
if not job:
|
||||
return 3
|
||||
if job.next_run_time is None:
|
||||
# pending/未排程任务没有 next_run_time 属性(APScheduler 只在真正排程后设置),必须 getattr 兜底
|
||||
if getattr(job, "next_run_time", None) is None:
|
||||
return 1
|
||||
if scheduler.state == 0:
|
||||
return 2
|
||||
@@ -540,16 +563,170 @@ class SchedulerUtil:
|
||||
logger.info(f"任务 {job_id} 已触发立即执行,临时任务 ID: {temp_job_id}")
|
||||
return temp_job
|
||||
|
||||
@classmethod
|
||||
def describe_schedulability(cls, code_block: str | None) -> tuple[bool, str | None]:
|
||||
"""判断某个 func(代码块 / ``builtin:`` 引用)当前是否可调度。
|
||||
|
||||
单一事实来源:创建/更新守卫、节点列表状态字段、调度器巡检日志都调用它,
|
||||
避免各处对 ``SCHEDULER_ALLOW_CODE_EXEC`` 的理解不一致。
|
||||
|
||||
参数:
|
||||
- code_block (str | None): 节点的 func 字段。
|
||||
|
||||
返回:
|
||||
- tuple[bool, str | None]: (是否可调度, 不可调度原因)。
|
||||
"""
|
||||
raw = (code_block or "").strip()
|
||||
if not raw:
|
||||
return False, "未配置函数或代码块(节点不会执行)"
|
||||
if raw.startswith(cls.BUILTIN_PREFIX):
|
||||
try:
|
||||
cls.resolve_builtin_handler(raw)
|
||||
except ValueError as exc:
|
||||
return False, f"内置处理器不可用:{exc}"
|
||||
return True, None
|
||||
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
|
||||
return False, (
|
||||
"服务端已禁用代码块执行(SCHEDULER_ALLOW_CODE_EXEC=False);"
|
||||
"请改用内置函数型任务——把 func 改为 builtin:<模块名>[.<函数名>](不经 exec,不受该开关限制)"
|
||||
)
|
||||
return True, None
|
||||
|
||||
@classmethod
|
||||
def job_code_block(cls, job: Job | None) -> str | None:
|
||||
"""从调度器 job 的 args 中取回 func(约定见 _add_job_with_trigger: args=[job_id, code_block, *args])。
|
||||
|
||||
参数:
|
||||
- job (Job | None): 调度器任务。
|
||||
|
||||
返回:
|
||||
- str | None: func 原文;无法取得时返回 None。
|
||||
"""
|
||||
args = list(getattr(job, "args", None) or [])
|
||||
if len(args) < 2 or args[1] is None:
|
||||
return None
|
||||
return str(args[1])
|
||||
|
||||
@classmethod
|
||||
def iter_unschedulable_jobs(cls) -> list[tuple[str, str, str]]:
|
||||
"""列出调度器中「已注册但当前不可执行」的任务:[(job_id, job_name, reason)]。
|
||||
|
||||
用于运维可观测性:开关调整或内置处理器改名后,存量任务不会静默失效。
|
||||
|
||||
返回:
|
||||
- list[tuple[str, str, str]]: 不可调度任务清单。
|
||||
"""
|
||||
result: list[tuple[str, str, str]] = []
|
||||
for job in cls.get_jobs():
|
||||
schedulable, reason = cls.describe_schedulability(cls.job_code_block(job))
|
||||
if not schedulable and reason:
|
||||
result.append((str(job.id), str(job.name or ""), reason))
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def log_unschedulable_jobs(cls, *, context: str) -> list[tuple[str, str, str]]:
|
||||
"""把「已注册但不可执行」的任务以 WARN 打出(带具体任务标识),返回同一清单。
|
||||
|
||||
参数:
|
||||
- context (str): 调用场景(如 "启动自愈" / "调度器状态查询"),便于日志定位。
|
||||
|
||||
返回:
|
||||
- list[tuple[str, str, str]]: 不可调度任务清单。
|
||||
"""
|
||||
found = cls.iter_unschedulable_jobs()
|
||||
for job_id, job_name, reason in found:
|
||||
logger.warning(f"[{context}] 调度器中的任务当前不可执行: id={job_id} name={job_name!r} 原因={reason}")
|
||||
return found
|
||||
|
||||
@classmethod
|
||||
def register_builtin_handler(cls, name: str, handler: Callable[..., Any]) -> None:
|
||||
"""注册一个内置处理器(供无 exec 的「内置函数型任务」使用)。
|
||||
|
||||
参数:
|
||||
- name (str): 名称(``func = "builtin:<name>"`` 时引用)。
|
||||
- handler (Callable): 可调用对象。
|
||||
|
||||
返回:
|
||||
- None
|
||||
"""
|
||||
cls._builtin_handlers[name] = handler
|
||||
|
||||
@classmethod
|
||||
def resolve_builtin_handler(cls, code_block: str | None) -> Callable[..., Any] | None:
|
||||
"""解析 ``builtin:<name>`` 引用;非内置引用返回 None。
|
||||
|
||||
参数:
|
||||
- code_block (str | None): 节点的 func 字段。
|
||||
|
||||
返回:
|
||||
- Callable | None: 内置处理器;不是内置引用时返回 None。
|
||||
|
||||
异常:
|
||||
- ValueError: 引用形态非法、模块/函数不存在或不是可调用对象时抛出
|
||||
(**绝不回退到 exec**,避免"看起来是内置任务、实际被执行成代码块")。
|
||||
"""
|
||||
raw = (code_block or "").strip()
|
||||
if not raw.startswith(cls.BUILTIN_PREFIX):
|
||||
return None
|
||||
name = raw[len(cls.BUILTIN_PREFIX) :].strip()
|
||||
if not cls._BUILTIN_NAME_RE.match(name):
|
||||
raise ValueError(f"内置处理器名称非法(仅允许 模块名[.函数名],实际 {raw!r})")
|
||||
|
||||
cached = cls._builtin_handlers.get(name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
module_name, _, func_name = name.partition(".")
|
||||
func_name = func_name or module_name
|
||||
import importlib
|
||||
|
||||
module_path = f"{cls.BUILTIN_HANDLERS_MODULE}.{module_name}"
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ValueError(f"内置处理器模块不存在:{module_path}") from exc
|
||||
handler = getattr(module, func_name, None)
|
||||
if handler is None or not callable(handler):
|
||||
raise ValueError(f"内置处理器不可用({module_path}.{func_name} 不存在或不可调用)")
|
||||
# 安全加固(复核 R1):只接受「在本模块内定义」或「该模块显式声明」的可调用对象。
|
||||
# 否则 handler 模块里任何一条 `from os import system` 之类的导入,都会变成
|
||||
# 绕过 SCHEDULER_ALLOW_CODE_EXEC 的无限制执行入口。
|
||||
declared = getattr(module, "HANDLERS", None)
|
||||
if isinstance(declared, dict):
|
||||
if declared.get(func_name) is not handler:
|
||||
raise ValueError(f"内置处理器未在该模块的 HANDLERS 中声明:{module_path}.{func_name}")
|
||||
elif getattr(handler, "__module__", None) != module.__name__:
|
||||
raise ValueError(
|
||||
f"内置处理器必须在本模块内定义或经 HANDLERS 声明:{module_path}.{func_name} "
|
||||
f"实际来自 {getattr(handler, '__module__', '未知来源')},已拒绝"
|
||||
)
|
||||
cls._builtin_handlers[name] = handler
|
||||
return handler
|
||||
|
||||
@classmethod
|
||||
def _task_wrapper(cls, job_id: str | int, code_block: str | None, *args, **kwargs):
|
||||
"""任务执行包装器,执行自定义代码块(同步版本,用于 ThreadPoolExecutor)
|
||||
|
||||
安全提示:code_block 来自页面提交,exec 等同给所有能创建任务的人
|
||||
服务器代码执行权限。生产环境应设置 SCHEDULER_ALLOW_CODE_EXEC=False
|
||||
关闭该能力,仅保留内置函数型任务。
|
||||
两类任务:
|
||||
- **内置函数型**(``code_block = "builtin:<模块名>[.<函数名>]"``):走白名单注册表直接调用,
|
||||
**不使用 exec**,因此不受 ``SCHEDULER_ALLOW_CODE_EXEC`` 限制;
|
||||
- **代码块型**(其它内容):``exec`` 等于给所有能创建任务的人服务器代码执行权限,**默认关闭**,
|
||||
关闭时执行入口直接失败并把原因写入执行日志(而不是静默跳过)。
|
||||
"""
|
||||
if code_block and code_block.strip() and not settings.SCHEDULER_ALLOW_CODE_EXEC:
|
||||
message = f"任务 {job_id} 含用户提交代码块,已因 SCHEDULER_ALLOW_CODE_EXEC=False 拒绝执行"
|
||||
# 内置函数型任务(builtin:<name>):白名单解析后直接调用,不经过 exec,
|
||||
# 因此即使 SCHEDULER_ALLOW_CODE_EXEC=False 也能正常调度
|
||||
raw_code = (code_block or "").strip()
|
||||
if raw_code.startswith(cls.BUILTIN_PREFIX):
|
||||
handler = cls.resolve_builtin_handler(raw_code)
|
||||
logger.info(f"任务 {job_id} 以内置处理器执行(不经 exec): {raw_code}")
|
||||
return handler(*args, **kwargs) if handler else None
|
||||
|
||||
if raw_code and not settings.SCHEDULER_ALLOW_CODE_EXEC:
|
||||
message = (
|
||||
f"任务 {job_id} 含用户提交的代码块,已拒绝执行:服务端默认禁止定时任务执行代码块。"
|
||||
f"如确需该能力,请显式设置 SCHEDULER_ALLOW_CODE_EXEC=true(等同授予服务器代码执行权限,请谨慎评估);"
|
||||
f"若只需内置能力,可把 func 改为 builtin:<模块名>[.<函数名>]"
|
||||
)
|
||||
logger.error(message)
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
result: Result = await self.db.execute(sql)
|
||||
return result.scalars().first()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"获取查询失败: {e!s}") from e
|
||||
raise CustomException(msg=f"获取查询失败: {e!s}", status_code=500) from e
|
||||
|
||||
async def get_or_404(
|
||||
self,
|
||||
@@ -89,12 +89,15 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
include_deleted: bool = False,
|
||||
**kwargs,
|
||||
) -> ModelType | OutSchemaType:
|
||||
"""查不到直接抛异常。支持 id 快捷入参,也支持 **kwargs 传多个条件。"""
|
||||
"""查不到直接抛异常。支持 id 快捷入参,也支持 **kwargs 传多个条件。
|
||||
|
||||
「资源不存在」属客户端可修正的结果:按 404 返回(业务 code 保持默认,前端仍展示 msg)。
|
||||
"""
|
||||
if id is not None:
|
||||
kwargs["id"] = id
|
||||
obj = await self.get(preload=preload, include_deleted=include_deleted, **kwargs)
|
||||
if not obj:
|
||||
raise CustomException(msg=msg)
|
||||
raise CustomException(msg=msg, status_code=404)
|
||||
return out_schema.model_validate(obj) if out_schema else obj
|
||||
|
||||
async def exists(self, include_deleted: bool = False, **kwargs) -> bool:
|
||||
@@ -109,7 +112,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
result: Result = await self.db.execute(count_sql)
|
||||
return result.scalar() or 0
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"统计失败: {e!s}") from e
|
||||
raise CustomException(msg=f"统计失败: {e!s}", status_code=500) from e
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
@@ -131,7 +134,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
result: Result = await self.db.execute(sql)
|
||||
return result.scalars().all()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"列表查询失败: {e!s}") from e
|
||||
raise CustomException(msg=f"列表查询失败: {e!s}", status_code=500) from e
|
||||
|
||||
async def page(
|
||||
self,
|
||||
@@ -179,7 +182,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
items=items,
|
||||
)
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"分页查询失败: {e!s}") from e
|
||||
raise CustomException(msg=f"分页查询失败: {e!s}", status_code=500) from e
|
||||
|
||||
# ── 写入 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -215,7 +218,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
|
||||
return obj
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"创建失败: {e!s}") from e
|
||||
raise CustomException(msg=f"创建失败: {e!s}", status_code=500) from e
|
||||
|
||||
async def update(self, id: int, data: UpdateSchemaType | dict[str, Any]) -> ModelType:
|
||||
"""更新记录。用 exclude_unset / exclude_none 准确表达前端意图。"""
|
||||
@@ -253,7 +256,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
except CustomException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"更新失败: {e!s}") from e
|
||||
raise CustomException(msg=f"更新失败: {e!s}", status_code=500) from e
|
||||
|
||||
async def delete(self, ids: list[int]) -> None:
|
||||
"""软删除优先,无软删除则物理删除。"""
|
||||
@@ -269,7 +272,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
await self.db.execute(sql)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"删除失败: {e!s}") from e
|
||||
raise CustomException(msg=f"删除失败: {e!s}", status_code=500) from e
|
||||
|
||||
async def clear(self) -> None:
|
||||
"""清空整表。软删除模式下相当于"回收站清空",只清理已删标记的记录。"""
|
||||
@@ -283,7 +286,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
await self.db.execute(sql)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"清空失败: {e!s}") from e
|
||||
raise CustomException(msg=f"清空失败: {e!s}", status_code=500) from e
|
||||
|
||||
async def set(self, ids: list[int], include_deleted: bool = False, **kwargs) -> None:
|
||||
"""批量更新。软删除模式下默认跳过已删除的记录。"""
|
||||
@@ -296,7 +299,7 @@ class CRUDBase[ModelType: ModelMixin, CreateSchemaType, UpdateSchemaType]:
|
||||
await self.db.execute(sql)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
raise CustomException(msg=f"批量更新失败: {e!s}") from e
|
||||
raise CustomException(msg=f"批量更新失败: {e!s}", status_code=500) from e
|
||||
|
||||
# ── 条件与排序 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -38,17 +38,72 @@ def require_superadmin(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
# starlette 新版本把 HTTP_422_UNPROCESSABLE_ENTITY 标为弃用,这里取新常量并兼容旧版本,
|
||||
# 避免模块导入期就打 DeprecationWarning
|
||||
_HTTP_422: int = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", 422)
|
||||
|
||||
# 业务错误码 → HTTP 状态码。只映射语义明确的码;其余业务异常统一按 _DEFAULT_BUSINESS_STATUS 返回。
|
||||
# 说明:历史上 CustomException 的 status_code 默认 500,导致「验证码过期」「参数校验失败」这类
|
||||
# 客户端错误也返回 500 —— 前端无法区分「我错了」与「服务器坏了」,监控/告警也被污染。
|
||||
_CODE_TO_HTTP_STATUS: dict[int, int] = {
|
||||
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.CONFLICT.code: status.HTTP_409_CONFLICT,
|
||||
RET.UNPROCESSABLE_ENTITY.code: _HTTP_422,
|
||||
RET.TOO_MANY_REQUESTS.code: status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
RET.SERVICE_UNAVAILABLE.code: status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
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)
|
||||
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)
|
||||
_DEFAULT_BUSINESS_STATUS: int = status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def resolve_http_status(code: int | None) -> int:
|
||||
"""把业务错误码解析为 HTTP 状态码。
|
||||
|
||||
参数:
|
||||
- code (int | None): 业务错误码。
|
||||
|
||||
返回:
|
||||
- int: 对应的 HTTP 状态码;未映射的码返回 _DEFAULT_BUSINESS_STATUS。
|
||||
"""
|
||||
if code is None:
|
||||
return _DEFAULT_BUSINESS_STATUS
|
||||
return _CODE_TO_HTTP_STATUS.get(int(code), _DEFAULT_BUSINESS_STATUS)
|
||||
|
||||
|
||||
class CustomException(Exception):
|
||||
"""业务异常。
|
||||
|
||||
``status_code`` 未显式传入时按 ``code`` 推导(见 :func:`resolve_http_status`),
|
||||
默认 400(客户端可修正)而非 500;只有确属服务端故障的场景才应显式传 500,
|
||||
或使用 ``code=RET.SERVERERR.code``。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg: str = RET.EXCEPTION.msg,
|
||||
code: int = RET.EXCEPTION.code,
|
||||
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
status_code: int | None = None,
|
||||
data: Any | None = None,
|
||||
success: bool = False,
|
||||
) -> None:
|
||||
super().__init__(msg)
|
||||
self.status_code = status_code
|
||||
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
|
||||
@@ -61,11 +116,15 @@ class CustomException(Exception):
|
||||
def handle_exception(app: FastAPI) -> None:
|
||||
@app.exception_handler(CustomException)
|
||||
async def custom_exception_handler(request: Request, exc: CustomException) -> JSONResponse:
|
||||
logger.error(
|
||||
"[自定义异常] {} {} | code={} | msg={} | data={}",
|
||||
# 4xx 是「客户端可修正」的业务结果,5xx 才是服务端故障:分级记录,
|
||||
# 避免每个校验失败都打 ERROR 把真正的故障信号淹没。
|
||||
log = logger.error if exc.status_code >= status.HTTP_500_INTERNAL_SERVER_ERROR else logger.warning
|
||||
log(
|
||||
"[自定义异常] {} {} | code={} | status={} | msg={} | data={}",
|
||||
request.method,
|
||||
request.url.path,
|
||||
exc.code,
|
||||
exc.status_code,
|
||||
exc.msg,
|
||||
exc.data,
|
||||
)
|
||||
@@ -96,7 +155,7 @@ def handle_exception(app: FastAPI) -> None:
|
||||
request.url.path,
|
||||
errors,
|
||||
)
|
||||
return ErrorResponse(msg=str(msg), status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, data=errors)
|
||||
return ErrorResponse(msg=str(msg), status_code=_HTTP_422, data=errors)
|
||||
|
||||
@app.exception_handler(ResponseValidationError)
|
||||
async def response_validation_handler(request: Request, exc: ResponseValidationError) -> JSONResponse:
|
||||
|
||||
Reference in New Issue
Block a user