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:
zhangtao
2026-10-04 05:12:32 +08:00
parent 007864051a
commit 07fa789d2e
39 changed files with 3031 additions and 265 deletions
+8 -6
View File
@@ -42,7 +42,8 @@ def _get_agno_db() -> Any:
elif db_type == "sqlite":
_db_instance = AsyncSqliteDb(db_file=db_uri.replace("sqlite+aiosqlite:///", ""))
else:
raise CustomException(msg=f"不支持的数据库类型: {db_type}")
# settings.DATABASE_TYPE 是服务端配置:取值不在支持范围属部署配置错误,按 500 返回
raise CustomException(msg=f"不支持的数据库类型: {db_type}", status_code=500)
return _db_instance
@@ -141,7 +142,7 @@ class ChatSessionCRUD:
)
except Exception as e:
logger.error(f"获取会话列表失败: {e}")
raise CustomException(msg="获取会话列表失败") from e
raise CustomException(msg="获取会话列表失败", status_code=500) from e
rows: list[dict[str, Any]] = []
total = 0
@@ -173,9 +174,9 @@ class ChatSessionCRUD:
result = await self.db.upsert_session(session=session)
except Exception as e:
logger.exception(f"创建会话失败: {e}")
raise CustomException(msg="创建会话失败") from e
raise CustomException(msg="创建会话失败", status_code=500) from e
if result is None:
raise CustomException(msg="创建会话失败")
raise CustomException(msg="创建会话失败", status_code=500)
return result
async def rename(self, session_id: str, data: ChatSessionUpdateSchema) -> None:
@@ -189,7 +190,7 @@ class ChatSessionCRUD:
)
except Exception as e:
logger.error(f"更新会话失败: {e}")
raise CustomException(msg="更新会话失败") from e
raise CustomException(msg="更新会话失败", status_code=500) from e
if result is None:
raise CustomException(msg="会话不存在", code=10404, status_code=404)
@@ -200,4 +201,5 @@ class ChatSessionCRUD:
await self.db.delete_session(session_id=session_id, user_id=self.user_id)
except Exception as e:
logger.error(f"删除会话失败: {session_id} - {e}")
raise CustomException(msg=f"删除会话失败: {session_id}") from e
# agno/SQLite 侧删除失败属内部故障,按 500 返回(session_ids 为客户端参数,但失败原因在服务端)
raise CustomException(msg=f"删除会话失败: {session_id}", status_code=500) from e
@@ -296,14 +296,17 @@ class GenUtils:
@classmethod
def split_column_type(cls, column_type: str) -> list[str]:
"""拆分列类型
"""拆分列类型中的长度/精度参数(**只返回校验过的数字**)。
原实现用 ``column_type.split("(")[1]`` 直接切分,会把括号内的任意文本带出去;
这里改为正则捕获 + 数字校验(与 template_safety.extract_type_params 同源规则)。
参数:
- column_type (str): 字段类型。
返回:
- list[str]: 拆分结果。
- list[str]: 形如 ["64"] 或 ["10", "2"];无有效参数时返回 []。
"""
if "(" in column_type and ")" in column_type:
return column_type.split("(")[1].split(")")[0].split(",")
return []
from .template_safety import extract_type_params
return list(extract_type_params(column_type))
@@ -1,3 +1,4 @@
import copy
import re
from datetime import datetime
from typing import Any
@@ -15,6 +16,24 @@ from .schema import (
GenTableColumnOutSchema,
GenTableOutSchema,
)
from .template_safety import (
column_type_base,
escape_template_text,
html_attr,
pick_sqlalchemy_type,
safe_ident,
safe_python_type,
vue_label,
)
# 哨兵文本:用于「用哨兵值渲染同一模板,得到结构骨架」的落盘前比对
# (见 template_safety.assert_python_structure / assert_text_structure)
SENTINEL_TEXT = "__SAFE_TEXT__"
# 自由文本字段:哨兵化只针对它们(保证结构比对有意义);标识符/类型/开关/长度等
# 必须保持真实值,否则模板分支会与骨架不一致,产生误报
_SENTINEL_TABLE_TEXT_FIELDS = ("table_name", "table_comment", "function_name", "business_name", "sub_table_name", "description")
_SENTINEL_COLUMN_TEXT_FIELDS = ("column_comment", "dict_type")
class Jinja2TemplateUtil:
@@ -75,6 +94,14 @@ class Jinja2TemplateUtil:
"snake_to_camel": CamelCaseUtil.snake_to_camel,
"get_sqlalchemy_type": cls.get_sqlalchemy_type,
"python_to_ts_type": cls.python_type_to_ts_type,
# 模板内按上下文安全化(详见 template_safety.py):
# 绝大多数值已在 prepare_context 统一处理,模板里可按需显式调用
"py_text": escape_template_text,
"py_ident": safe_ident,
"py_type": safe_python_type,
"vue_label": vue_label,
# 静态 HTML 属性值:实体转义(展示保真,不用 \uXXXX)
"html_attr": html_attr,
},
)
return cls._env
@@ -146,6 +173,194 @@ class Jinja2TemplateUtil:
segs.append(seg)
return "/".join(segs) if segs else "entity"
@classmethod
def safe_render_schema(cls, gen_table: GenTableOutSchema) -> GenTableOutSchema:
"""构造「仅用于模板渲染」的安全副本。
生成物是会被执行/被构建链消费的源码(``.py`` 会被 ``app/core/discover.py`` 启动时 import),
因此所有用户可控文本必须按所处上下文安全化后才能进模板。此处统一处理,
避免逐个模板/逐个字段遗漏;``autoescape=True`` 不适用(会把生成的代码本身转义坏)。
**从 schema 进入模板的值按四类显式处理**(实现见 template_safety.py):
1. 字符串类(注释/描述/功能名/表名…)→ ``escape_template_text``(``\\uXXXX``,仅用于字符串/注释上下文);
2. 标识符类(列名/类名/模块名/python_field)→ ``safe_ident``(含关键字与保留字排除);
3. 代码位置类(SQLAlchemy 类型、长度/精度)→ ``pick_sqlalchemy_type``:**只从常量白名单派生**,不接收自由文本;
4. 路径类(包名/模块名/业务路径/文件名)→ slug/白名单规范化(``business_name_to_path`` 等),不含 ``..`` 与分隔符。
展示位置(``.vue`` 文本节点与静态属性)另有 ``vue_label``/``html_attr``:HTML 实体转义,保真且不破坏标记结构。
参数:
- gen_table (GenTableOutSchema): 原始生成配置(不会被修改)。
返回:
- GenTableOutSchema: 深拷贝并按上下文安全化后的副本。
"""
safe_table = copy.deepcopy(gen_table)
cls._sanitize_schema_in_place(safe_table, set())
return safe_table
@classmethod
def sentinel_render_schema(cls, gen_table: GenTableOutSchema) -> GenTableOutSchema:
"""构造「哨兵值」副本:自由文本换成固定哨兵(空值保持为空),用于生成结构骨架。
与 :meth:`safe_render_schema` 的区别:这里不追求可展示/可用,只要求**结构中性**——
值里不含任何可能改变模板分支或语法结构的字符。用它渲染同一模板得到的 AST/行结构
即为「该模板的规范骨架」,落盘前与真实产物比对(见 template_safety 的结构闸门)。
参数:
- gen_table (GenTableOutSchema): 已安全化的渲染配置。
返回:
- GenTableOutSchema: 自由文本字段被替换为哨兵的深拷贝。
"""
sentinel_table = copy.deepcopy(gen_table)
cls._sentinelize_schema_in_place(sentinel_table, set())
return sentinel_table
@classmethod
def _sentinelize_schema_in_place(cls, table: GenTableOutSchema, seen: set[int]) -> None:
"""就地哨兵化自由文本字段(标识符/类型/开关/长度保持不变),带环保护。"""
if table is None or id(table) in seen:
return
seen.add(id(table))
for field in _SENTINEL_TABLE_TEXT_FIELDS:
if hasattr(table, field):
setattr(table, field, _sentinelize_value(getattr(table, field)))
for column in table.columns or []:
cls._sentinelize_column_in_place(column, seen)
if table.pk_column is not None:
cls._sentinelize_column_in_place(table.pk_column, seen)
if table.sub_table is not None:
cls._sentinelize_schema_in_place(table.sub_table, seen)
@staticmethod
def _sentinelize_column_in_place(column: GenTableColumnOutSchema, seen: set[int]) -> None:
"""就地哨兵化字段级自由文本(列名/类型/长度等保持原值)。"""
if column is None or id(column) in seen:
return
seen.add(id(column))
for field in _SENTINEL_COLUMN_TEXT_FIELDS:
if hasattr(column, field):
setattr(column, field, _sentinelize_value(getattr(column, field)))
@classmethod
def iter_free_text_values(cls, gen_table: GenTableOutSchema) -> list[str]:
"""枚举本次渲染里会进入模板的**自由文本值**(与哨兵化字段一一对应,按同样顺序)。
供 ``.vue``/``.ts`` 的行结构比对使用:把真实值与哨兵都抹成占位符后再逐行比较。
参数:
- gen_table (GenTableOutSchema): 已安全化的渲染配置。
返回:
- list[str]: 非空自由文本值列表。
"""
values: list[str] = []
seen: set[int] = set()
def walk(table: GenTableOutSchema) -> None:
if table is None or id(table) in seen:
return
seen.add(id(table))
for field in _SENTINEL_TABLE_TEXT_FIELDS:
text = getattr(table, field, None)
if text:
values.append(str(text))
for column in table.columns or []:
collect_column(column)
if table.pk_column is not None:
collect_column(table.pk_column)
if table.sub_table is not None:
walk(table.sub_table)
def collect_column(column: GenTableColumnOutSchema) -> None:
if column is None or id(column) in seen:
return
seen.add(id(column))
for field in _SENTINEL_COLUMN_TEXT_FIELDS:
text = getattr(column, field, None)
if text:
values.append(str(text))
walk(gen_table)
return values
@classmethod
def iter_render_dynamic_values(cls, gen_table: GenTableOutSchema) -> list[str]:
"""返回真实产物里可能出现的**所有动态文本形态**(原值 / 转义值 / 展示形态)。
供 ``.vue``/``.ts`` 的行结构比对把动态部分抹成占位符:同一个值可能以
``escape_template_text``(JS 字符串)、``html_attr``(静态属性)、``vue_label``(文本节点)
三种形态出现,三者都要抹掉,否则会与骨架产生假阳性。
参数:
- gen_table (GenTableOutSchema): 已安全化的渲染配置。
返回:
- list[str]: 动态文本形态列表。
"""
tokens: list[str] = []
for value in cls.iter_free_text_values(gen_table):
text = str(value)
tokens.extend([text, escape_template_text(text), html_attr(text), vue_label(text)])
return tokens
@classmethod
def _sanitize_schema_in_place(cls, table: GenTableOutSchema, seen: set[int]) -> None:
"""就地安全化一张表(表级文本 + 全部字段 + 子表),带环保护。
参数:
- table (GenTableOutSchema): 待处理的安全副本(会被修改)。
- seen (set[int]): 已处理对象 id 集合(主/子表互相引用时防重入)。
返回:
- None
"""
if table is None or id(table) in seen:
return
seen.add(id(table))
# 落进字符串字面量/注释/文档字符串的文本
table.table_name = escape_template_text(table.table_name)
table.table_comment = escape_template_text(table.table_comment)
table.function_name = escape_template_text(table.function_name)
# 落进标识符位置的值(类名 / 模块名 / 包名 / 外键列名)
table.class_name = safe_ident(table.class_name, default="")
table.module_name = safe_ident(table.module_name, default="")
table.package_name = safe_ident(table.package_name, default="")
table.sub_table_fk_name = safe_ident(table.sub_table_fk_name, default="")
# 业务名允许 a/b 多段,且参与目录派生,按文本处理即可(派生见 prepare_context)
table.business_name = escape_template_text(table.business_name)
table.sub_table_name = escape_template_text(table.sub_table_name)
for column in table.columns or []:
cls._sanitize_column_in_place(column, seen)
# pk_column 通常就是 columns 里的同一个对象(见 set_pk_column),必须靠 seen 去重,
# 否则同一字段被转义两次(\u0027 → \u005cu0027),产物里的文本会变形
if table.pk_column is not None:
cls._sanitize_column_in_place(table.pk_column, seen)
if table.sub_table is not None:
cls._sanitize_schema_in_place(table.sub_table, seen)
@staticmethod
def _sanitize_column_in_place(column: GenTableColumnOutSchema, seen: set[int]) -> None:
"""就地安全化单个字段:列名/类型注解按标识符处理,注释/字典类型按文本转义。
参数:
- column (GenTableColumnOutSchema): 待处理的安全副本(会被修改)。
- seen (set[int]): 已处理对象 id 集合(同一字段被 columns 与 pk_column 同时引用时防重复转义)。
返回:
- None
"""
if column is None or id(column) in seen:
return
seen.add(id(column))
column.column_name = safe_ident(column.column_name, default="")
column.python_type = safe_python_type(column.python_type)
column.python_field = safe_ident(column.python_field, default="")
column.column_comment = escape_template_text(column.column_comment)
column.dict_type = escape_template_text(column.dict_type)
@classmethod
def prepare_context(cls, gen_table: GenTableOutSchema) -> dict[str, Any]:
"""准备模板变量。
@@ -159,6 +374,13 @@ class Jinja2TemplateUtil:
# 处理options为None的情况
# if not gen_table.options:
# raise ValueError('请先完善生成配置信息')
# 目录/文件名派生值仍按原始业务名计算,保证既有产物路径不变;
# 其余进入模板的内容一律取安全副本(详见 template_safety.py)。
business_name_raw = (gen_table.business_name or "").strip()
business_path = cls.business_name_to_path(business_name_raw)
business_name_slug = cls.business_name_to_slug(business_name_raw)
gen_table = cls.safe_render_schema(gen_table)
class_name = gen_table.class_name or ""
package_name = (gen_table.package_name or "").strip()
module_name = (gen_table.module_name or "").strip()
@@ -169,8 +391,6 @@ class Jinja2TemplateUtil:
# - 分系统根:package_name = module_xxx
# - 目录固定为:module_xxx / module_name(不再额外使用业务名作为目录层级)
# - 权限前缀固定为:module_xxx:module_name(操作在模板里再拼 :query/:create...)
business_path = cls.business_name_to_path(business_name)
business_name_slug = cls.business_name_to_slug(business_name)
permission_prefix = ":".join([s for s in [package_name, module_name] if s])
api_route_prefix = cls.get_api_route_prefix(package_name)
@@ -262,17 +482,20 @@ class Jinja2TemplateUtil:
- dict[str, Any]: 子表模板上下文字典。
"""
ctx = cls.prepare_context(sub)
scn = (sub.class_name or GenUtils.convert_class_name(sub.table_name or "")).strip()
# 父表字段同样进入模板(parent_* / relationship 实参),必须取自安全副本
parent_safe = cls.safe_render_schema(parent)
sub_safe = cls.safe_render_schema(sub)
scn = (sub_safe.class_name or GenUtils.convert_class_name(sub_safe.table_name or "")).strip()
ctx["is_sub_entity"] = True
ctx["parent_class_name"] = parent.class_name or ""
ctx["parent_model_class_name"] = f"{parent.class_name}Model"
ctx["parent_table_name"] = parent.table_name or ""
ctx["parent_pk_column_name"] = (parent.pk_column.column_name if parent.pk_column else None) or "id"
ctx["parent_rel_name"] = SnakeCaseUtil.camel_to_snake(parent.class_name or "parent")
ctx["parent_class_name"] = parent_safe.class_name or ""
ctx["parent_model_class_name"] = f"{parent_safe.class_name}Model"
ctx["parent_table_name"] = parent_safe.table_name or ""
ctx["parent_pk_column_name"] = (parent_safe.pk_column.column_name if parent_safe.pk_column else None) or "id"
ctx["parent_rel_name"] = SnakeCaseUtil.camel_to_snake(parent_safe.class_name or "parent")
ctx["parent_list_rel_name"] = f"{SnakeCaseUtil.camel_to_snake(scn)}_list"
ctx["sub_table_fk_name"] = (parent.sub_table_fk_name or "").strip()
ctx["model_import_list"] = cls.get_model_import_list(sub, is_sub_entity=True)
ctx["schema_import_list"] = cls.get_schema_import_list(sub)
ctx["sub_table_fk_name"] = (parent_safe.sub_table_fk_name or "").strip()
ctx["model_import_list"] = cls.get_model_import_list(sub_safe, is_sub_entity=True)
ctx["schema_import_list"] = cls.get_schema_import_list(sub_safe)
return ctx
@classmethod
@@ -529,10 +752,9 @@ class Jinja2TemplateUtil:
if "[]" in column_type:
return "array"
# 提取基本类型
if "(" in column_type:
return column_type.split("(")[0]
return column_type
# 提取基本类型:走严格基名提取(只允许 [A-Za-z][A-Za-z0-9_ ]*,剔除 unsigned 等修饰词),
# 不直接 split 原始文本,避免把括号前的任意内容当作类型名带出去
return column_type_base(column_type)
@classmethod
def merge_same_imports(cls, imports: list[str], import_start: str) -> list[str]:
@@ -679,81 +901,39 @@ class Jinja2TemplateUtil:
@classmethod
def get_sqlalchemy_type(cls, column: Any) -> str:
"""获取 SQLAlchemy 类型。
"""获取 SQLAlchemy 类型(**代码位置** → 一律由常量白名单派生,见 template_safety)。
类型名只能来自 ``GenConstant.DB_TO_SQLALCHEMY`` 的值,长度/精度只能来自
``column_length.isdigit()`` 或 ``column_type`` 括号内的**数字捕获**;
不再使用 ``column_type.split("(")[1]`` 这类字符串切分(会把任意内容带进产物)。
参数:
- column (Any): 列对象或列类型字符串。
返回:
- str: SQLAlchemy 类型字符串。
- str: SQLAlchemy 类型字符串(如 ``String(64)`` / ``Numeric(10,2)`` / ``DateTime``)。
"""
# 获取column_type和column_length
column_type = column
column_length = None
# 检查是否是对象
column_type: Any = column
column_length: Any = None
if hasattr(column, "column_type"):
column_type = column.column_type or ""
column_length = column.column_length or None
column_type = getattr(column, "column_type", "") or ""
column_length = getattr(column, "column_length", None) or None
# 先按既有口径去掉 COLLATE / UNSIGNED,便于与常量表的键匹配
column_type = cls.normalize_db_column_type_for_mapping(column_type)
return pick_sqlalchemy_type(column_type, column_length)
# MySQL:仅 tinyint(1) 映射为 Boolean;其余 tinyint 走 SmallInteger(见 GenConstant.DB_TO_SQLALCHEMY)
ct_norm = (column_type or "").strip()
if settings.DATABASE_TYPE != "postgres" and ct_norm:
ct_lower = ct_norm.lower()
if ct_lower.startswith("tinyint(1)"):
return "Boolean"
# 首先尝试匹配完整类型(包括括号)
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, column_type)
def _sentinelize_value(value: Any) -> Any:
"""把非空文本替换为哨兵文本(空值/None 原样保留,避免改变模板的真值分支)。
# 特殊处理PostgreSQL类型
if settings.DATABASE_TYPE == "postgres":
if column_type.upper() == "BOOLEAN":
return "Boolean"
if column_type.upper() == "REAL" or column_type.upper() == "DOUBLE PRECISION":
return "Float"
if column_type.upper() == "TIMESTAMP":
return "DateTime"
if column_type.upper() == "JSONB":
return "JSONB"
if column_type.upper() == "UUID":
return "Uuid"
if column_type.upper() == "BYTEA":
return "LargeBinary"
参数:
- value (Any): 原始值。
# get_mapping_value_by_key_ignore_case 未命中时返回 "",须与 None 同样视为未匹配
if not sqlalchemy_type and "(" in column_type:
# 如果没有匹配到,再尝试剥离括号
column_type_list = column_type.split("(")
col_type = column_type_list[0]
# 将 'character' 映射为 'char' 以匹配常量定义
if col_type.lower() == "character":
col_type = "char"
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
# 如果是字符串类型且包含括号参数,保持原参数
if sqlalchemy_type in ["String", "CHAR"] or sqlalchemy_type in ["Numeric", "DECIMAL"]:
sqlalchemy_type += "(" + column_type_list[1]
elif not sqlalchemy_type:
# 处理没有括号的类型
col_type = column_type
# 将 'character' 映射为 'char' 以匹配常量定义
if col_type.lower() == "character":
col_type = "char"
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY, col_type)
# 如果是字符串类型且没有指定长度,使用column_length或默认255
if sqlalchemy_type in ["String", "CHAR"]:
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type += f"({length})"
# 对于已经匹配到的类型,如果是字符串类型且column有长度信息,添加长度
elif sqlalchemy_type in ["String", "CHAR"] and "(" not in sqlalchemy_type:
# 检查column_length是否有效
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type += f"({length})"
# 如果没有找到匹配的类型,使用String(column_length)或String(255)作为默认类型
if not sqlalchemy_type:
length = column_length if column_length and column_length.isdigit() else "255"
sqlalchemy_type = f"String({length})"
return sqlalchemy_type
返回:
- Any: 哨兵文本或原值。
"""
if value is None:
return None
text = str(value)
return SENTINEL_TEXT if text else text
@@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.base_schema import BaseQueryParam, BaseSchema, UserByQueryParam, UserBySchema
from .template_safety import is_safe_identifier, is_valid_column_type, normalize_column_type, reserved_word_reason
class GenDBTableSchema(BaseModel):
"""数据库中的表信息(跨方言统一结构)。
@@ -59,6 +61,71 @@ class GenTableColumnSchema(BaseModel):
dict_type: str | None = Field(default="", description="字典类型")
sort: int = Field(default=0, description="排序")
@field_validator("column_name", mode="before")
@classmethod
def validate_column_name(cls, v: str | None) -> str:
"""列名白名单校验:必须能直接充当 Python / JS 标识符。
列名会被原样写进 ``Mapped[..]`` 属性名、``class`` 字段名与前端 ``prop``,
是注入面之一(见 backend/audit-backend.md B1)。非法名(含引号/空格/换行/数字开头等)
在此直接拒绝,避免生成出语法不合法或可被注入的代码。
参数:
- v (str | None): 原始列名。
返回:
- str: 去空白后的合法列名;未填写时返回空串(沿用历史行为)。
异常:
- ValueError: 列名不是合法标识符时抛出。
"""
if v is None:
return ""
text = str(v).strip()
if not text:
return ""
if not is_safe_identifier(text):
reason = reserved_word_reason(text)
if reason:
raise ValueError(
f"列名 {text!r} 是 {reason},作为字段名会生成非法代码(如 class: Mapped[...]),"
f"请改名后再导入/保存(可改为 {text}_ 之类)",
)
raise ValueError(f"列名 {text!r} 不是合法的标识符(仅允许字母/数字/下划线,且不能以数字开头),代码生成器无法为它生成合法代码,请先改名")
return text
@field_validator("column_type", mode="before")
@classmethod
def validate_column_type(cls, v: str | None) -> str:
"""列类型形状白名单校验(结构化,非法即拒绝)。
允许的形状:``基名`` + 可选 ``(数字)`` / ``(数字,数字)`` + 可选 ``[]`` + 可选 ``unsigned``。
MySQL 的 ``enum(...)``/``set(...)`` 会先归一化为基名 ``enum``/``set``(其成员列表既无用途也含引号)。
为什么必须校验:列类型会参与「代码位置」派生(SQLAlchemy 类型名 + 长度/精度),
任意文本一旦被拼接进生成代码就等于注入面(见 backend/audit-backend.md B1)。
参数:
- v (str | None): 原始列类型。
返回:
- str: 归一化后的列类型;未填写时返回空串(由 SQLAlchemy 映射回落 String)。
异常:
- ValueError: 形状非法时抛出(报错带上列类型原文)。
"""
if v is None:
return ""
text = str(v).strip()
if not text:
return ""
if not is_valid_column_type(text):
raise ValueError(
f"列类型 {text!r} 形状非法:仅允许「类型名 + 可选(长度[,精度]) + 可选[] + 可选 unsigned」,例如 varchar(64)、decimal(10,2)、tinyint(1) unsigned;"
"含引号/分号/括号内非数字等内容会被拒绝(代码生成器不接收自由文本类型)",
)
return normalize_column_type(text)
class GenTableColumnOutSchema(GenTableColumnSchema, BaseSchema):
"""业务表字段输出模型
@@ -33,7 +33,7 @@ from app.utils.common_util import CamelCaseUtil, compute_menu_route_first_segmen
from .crud import GenTableColumnCRUD, GenTableCRUD
from .gen_util import GenUtils
from .jinja2_template_util import Jinja2TemplateUtil
from .jinja2_template_util import SENTINEL_TEXT, Jinja2TemplateUtil
from .schema import (
GenSyncColumnChange,
GenSyncPreviewSchema,
@@ -43,6 +43,7 @@ from .schema import (
GenTableQueryParam,
GenTableSchema,
)
from .template_safety import assert_python_structure, assert_text_structure
def handle_service_exception(func: Callable) -> Callable:
@@ -61,7 +62,9 @@ def handle_service_exception(func: Callable) -> Callable:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"{func.__name__}执行失败: {e!s}")
# 服务层公共出口:这里捕获到的都是**非业务**异常(模板渲染/DB/反射等内部故障),
# 按 500 返回,避免监控把服务端故障误判为客户端错误;业务异常由上面的分支原样透传(保留自身 4xx)
raise CustomException(msg=f"{func.__name__}执行失败: {e!s}", status_code=500)
return wrapper
@@ -370,7 +373,7 @@ class GenTableService:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"导入失败, {e!s}")
raise CustomException(msg=f"导入失败, {e!s}", status_code=500)
@handle_service_exception
async def create_table(self, sql: str) -> bool | None:
@@ -454,7 +457,7 @@ class GenTableService:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"创建表结构失败: {e!s}")
raise CustomException(msg=f"创建表结构失败: {e!s}", status_code=500)
@handle_service_exception
async def update_gen_table(self, data: GenTableSchema, table_id: int) -> GenTableOutSchema:
@@ -516,7 +519,7 @@ class GenTableService:
except CustomException:
raise
except Exception as e:
raise CustomException(msg=str(e))
raise CustomException(msg=str(e), status_code=500)
else:
raise CustomException(msg="业务表不存在")
@@ -541,7 +544,7 @@ class GenTableService:
# 再删除表信息
await GenTableCRUD(self.auth, self.db).delete_gen_table(ids)
except Exception as e:
raise CustomException(msg=str(e))
raise CustomException(msg=str(e), status_code=500)
@handle_service_exception
async def get_gen_table_by_id(self, table_id: int) -> GenTableOutSchema:
@@ -660,12 +663,40 @@ class GenTableService:
raise CustomException(msg="包名不能为空")
# 1. 先写代码文件(风险最高,放最前,失败不产生菜单孤儿数据)
async def _write_templates(templates: list[str], ctx: dict[str, Any], table_schema: GenTableOutSchema) -> None:
repo_root = BASE_DIR.parent.resolve()
async def _write_templates(
templates: list[str],
ctx: dict[str, Any],
table_schema: GenTableOutSchema,
*,
reference_ctx: dict[str, Any],
dynamic_values: list[str],
) -> None:
for template in templates:
try:
render_content = await env.get_template(template).render_async(**ctx)
tmpl = env.get_template(template)
render_content = await tmpl.render_async(**ctx)
# 同一模板用「哨兵值」再渲染一次,得到该模板的规范结构骨架
reference_content = await tmpl.render_async(**reference_ctx)
file_name = Jinja2TemplateUtil.get_file_name(template, table_schema)
full_path = BASE_DIR.parent.joinpath(file_name)
# 落盘前三重校验(详见 gencode/template_safety.py):
# ① 目标路径必须仍在仓库根内,防止路径穿越改写仓库外的文件;
# ② .py 产物必须语法合法,且**结构与哨兵骨架一致**(仅 ast.parse 发现不了注入);
# ③ .vue/.ts/.toml 退化为行结构比对,禁止出现骨架之外的语句/标签结构。
if not full_path.resolve().is_relative_to(repo_root):
raise CustomException(msg=f"生成路径越出仓库范围,已拒绝写入:{file_name}")
if file_name.endswith(".py"):
assert_python_structure(render_content, reference_content, filename=file_name)
else:
assert_text_structure(
render_content,
reference_content,
sentinel=SENTINEL_TEXT,
dynamic_values=dynamic_values,
filename=file_name,
)
gen_path = str(full_path)
os.makedirs(os.path.dirname(gen_path), exist_ok=True)
await anyio.Path(gen_path).write_text(render_content, encoding="utf-8")
@@ -681,16 +712,30 @@ class GenTableService:
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}")
raise CustomException(msg=f"渲染模板失败,表名:{table_schema.table_name},详细错误信息:{e!s}", status_code=500)
await _write_templates(render_info[0], render_info[2], gen_table_schema)
safe_schema: GenTableOutSchema = render_info[4]
reference_ctx = Jinja2TemplateUtil.prepare_context(Jinja2TemplateUtil.sentinel_render_schema(safe_schema))
dynamic_values = Jinja2TemplateUtil.iter_render_dynamic_values(safe_schema)
await _write_templates(render_info[0], render_info[2], gen_table_schema, reference_ctx=reference_ctx, dynamic_values=dynamic_values)
if gen_table_schema.sub and gen_table_schema.sub_table:
gen_table_schema.sub_table.package_name = gen_table_schema.package_name
if not (gen_table_schema.sub_table.module_name or "").strip():
gen_table_schema.sub_table.module_name = gen_table_schema.module_name
sub_ctx = Jinja2TemplateUtil.prepare_sub_render_context(gen_table_schema, gen_table_schema.sub_table)
sub_templates = Jinja2TemplateUtil.get_sub_table_template_list()
await _write_templates(sub_templates, sub_ctx, gen_table_schema.sub_table)
safe_sub = Jinja2TemplateUtil.safe_render_schema(gen_table_schema.sub_table)
sub_reference_ctx = Jinja2TemplateUtil.prepare_sub_render_context(
Jinja2TemplateUtil.sentinel_render_schema(safe_schema),
Jinja2TemplateUtil.sentinel_render_schema(safe_sub),
)
await _write_templates(
sub_templates,
sub_ctx,
gen_table_schema.sub_table,
reference_ctx=sub_reference_ctx,
dynamic_values=dynamic_values,
)
# 2. 代码成功写入后,再创建菜单(避免失败时产生孤儿菜单数据)
menu_crud = MenuCRUD(self.auth, self.db)
@@ -970,7 +1015,7 @@ class GenTableService:
if sub_cfg:
await self.sync_db(sn, _sync_sub=False)
except Exception as e:
raise CustomException(msg=f"同步失败: {e!s}")
raise CustomException(msg=f"同步失败: {e!s}", status_code=500)
async def hydrate_sub_table(self, gen_table: GenTableOutSchema) -> None:
"""主子表:优先使用已导入的子表配置,否则回退为只读 DB 结构。
@@ -1247,6 +1292,8 @@ class GenTableService:
await self.hydrate_sub_table(gen_table)
self._assert_master_sub_config_valid(gen_table)
context = Jinja2TemplateUtil.prepare_context(gen_table)
# 安全副本:与 prepare_context 内部使用同一函数得到,专门供「落盘前结构闸门」派生哨兵骨架与真实值集合
safe_table = Jinja2TemplateUtil.safe_render_schema(gen_table)
template_list = Jinja2TemplateUtil.get_template_list()
output_files = [Jinja2TemplateUtil.get_file_name(template, gen_table) for template in template_list]
return [template_list, output_files, context, gen_table]
return [template_list, output_files, context, gen_table, safe_table]
@@ -0,0 +1,621 @@
"""代码生成模板的输入安全处理。
背景(见 backend/audit-backend.md 的 B1)
======================================
代码生成器把「表/列元数据」渲染成 ``.py`` / ``.vue`` / ``.ts`` / ``.toml`` 源文件,产物会被写进
仓库,其中 ``backend/app/plugin/**`` 会在应用启动时被 ``app/core/discover.py`` 逐个
``import_module`` 执行。因此任何用户可控文本(表注释、列注释、字段名、功能名……)都不能原样
进入模板——否则「有代码生成权限」就等于「能在服务器上执行任意代码」。
为什么不用 ``autoescape=True``
=============================
生成物的目标是**代码**而不是 HTML:全局开启 autoescape 会把 ``<`` ``>`` ``&`` 变成 ``&lt;`` 等,
直接把生成出来的 Python / Vue 源码写坏。正确做法是**按上下文处理**:
- 落进「字符串字面量 / 注释 / 文档字符串」的文本 → :func:`escape_template_text`
(转义 ``\\ ' " 换行 反引号`` 等,Python / JS / TOML 的字符串都会把它解析回原字符,保真;
但源码层面不再出现能闭合字面量或换行的字符,因而无法逃出当前语法上下文)。
- 落进「标识符位置」的值(列名、类名、类型注解……)→ :func:`safe_ident`。
- 落进 ``.vue`` 模板「文本节点」的展示文本 → :func:`vue_label`(额外去掉尖括号与花括号)。
两层防线
========
1. 口径层:``gencode/schema.py`` 对 ``column_name`` 做白名单校验(拒绝非法标识符);
2. 渲染层:本模块的转义在 :meth:`Jinja2TemplateUtil.prepare_context` 统一施加,
即使数据来自物理库反射(绕过表单校验)也不会产出可执行注入。
"""
import ast
import keyword
import re
from app.common.constant import GenConstant
# 需要转义的字符 → \uXXXX 形式。
# 该形式在 Python 字符串(含三引号文档字符串)、JS 字符串 / 模板字符串、TOML basic string
# 中都会被解析回原字符,因此既安全又保真。
_ESCAPE_MAP: dict[str, str] = {
"\\": "\\u005c", # 反斜杠必须先处理,避免二次转义
"'": "\\u0027", # Python 单引号 / JS 单引号
'"': "\\u0022", # Python 双引号 / JS 双引号 / TOML basic string 定界符
"\n": "\\u000a",
"\r": "\\u000d",
"\t": "\\u0009",
"\v": "\\u000b",
"\f": "\\u000c",
"\0": "\\u0000",
"`": "\\u0060", # JS 模板字符串定界符
# U+2028/U+2029(行/段分隔符):在 JS 里被当作换行,可用来断行逃逸;在 Python 字符串里合法
"\u2028": "\\u2028",
"\u2029": "\\u2029",
}
# 其余控制字符:C0(换行/制表等已在 _ESCAPE_MAP 中显式处理)+ DEL(7F) + C1 控制符(80-9F)
_CONTROL_RE = re.compile(r"[\x01-\x08\x0e-\x1f\x7f-\x9f]")
# \uXXXX 转义的反解(展示位置使用)
_UNESCAPE_RE = re.compile(r"\\u([0-9a-fA-F]{4})")
# JS 模板字符串插值起始序列:$ 后紧跟 { 时必须打断,否则 ${...} 会被当成表达式执行
_JS_INTERPOLATION_RE = re.compile(r"\$\{")
# 合法标识符(Python / JS 通用子集)
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# 标识符规范化:去掉所有非 [A-Za-z0-9_]
_IDENT_STRIP_RE = re.compile(r"[^A-Za-z0-9_]")
# Vue 文本节点额外需要剔除的字符:尖括号与花括号会改变标记结构 / 触发 Vue 插值
_VUE_LABEL_STRIP_RE = re.compile(r"[<>{}]")
# HTML 文本/属性位置的实体转义(保真:浏览器渲染回原字符,而不是把 \uXXXX 当字面量显示)
_HTML_ESCAPE_MAP: dict[str, str] = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}
# 标识符长度上限(MySQL 标识符上限 64)
_IDENTIFIER_MAX_LENGTH = 64
# Python 关键字/软关键字:作为字段名会直接语法错误(如 column_name="class")
_PYTHON_RESERVED: frozenset[str] = frozenset(keyword.kwlist) | frozenset(keyword.softkwlist)
# JS/TS 保留字与字面量:作为裸标识符会语法错误或改变语义
_JS_RESERVED: frozenset[str] = frozenset(
{
"abstract", "arguments", "await", "boolean", "break", "byte", "case", "catch", "char", "class",
"const", "continue", "debugger", "default", "delete", "do", "double", "else", "enum", "eval",
"export", "extends", "false", "final", "finally", "float", "for", "function", "goto", "if",
"implements", "import", "in", "instanceof", "int", "interface", "let", "long", "native", "new",
"null", "package", "private", "protected", "public", "return", "short", "static", "super",
"switch", "synchronized", "this", "throw", "throws", "transient", "true", "try", "typeof",
"undefined", "var", "void", "volatile", "while", "with", "yield",
},
)
# 保留字命中时的前缀(保证仍是合法标识符,同时避免与关键字冲突)
_RESERVED_PREFIX = "_"
def escape_template_text(value: object) -> str:
"""把任意文本转义为「放进字符串字面量/注释/文档字符串都安全」的形式。
参数:
- value (object): 原始值(None 视为空串)。
返回:
- str: 转义后的文本;内容不含可闭合字符串字面量、可换行或可开启 JS 插值的字符。
"""
if value is None:
return ""
text = str(value)
for raw, escaped in _ESCAPE_MAP.items():
text = text.replace(raw, escaped)
text = _CONTROL_RE.sub(lambda m: f"\\u{ord(m.group()):04x}", text)
# ${ 打断为 \u0024{,避免在 JS 模板字符串里变成插值表达式
# 注意:替换值用 lambda 返回,re.sub 的字符串替换会把 \u 当成非法转义
return _JS_INTERPOLATION_RE.sub(lambda m: "\\u0024{", text)
def safe_ident(value: object, default: str = "entity") -> str:
"""把任意值规范为合法标识符(用于列名、类名、类型注解等位置)。
参数:
- value (object): 原始值。
- default (str): 空值/规范化后为空的兜底标识符。
返回:
- str: 形如 ``[A-Za-z_][A-Za-z0-9_]*`` 的标识符,长度不超过 64。
"""
text = "" if value is None else str(value).strip()
if not text:
return default
# 只替换非法字符,不改动已有合法标识符(避免 a__b / _x 这类名字被“规范化”后与库中列名不一致)
text = _IDENT_STRIP_RE.sub("_", text)
if text[0].isdigit():
text = f"_{text}"
# 关键字/保留字(Python + JS)不能作为裸标识符:加前缀而不是报错(用于类名/模块名/字段名等位置)
if text in _PYTHON_RESERVED or text in _JS_RESERVED:
text = f"{_RESERVED_PREFIX}{text}"
return text[:_IDENTIFIER_MAX_LENGTH]
def is_safe_identifier(value: object) -> bool:
"""判断值是否本身即为合法标识符(无需规范化)。
同时排除 Python 关键字与 JS/TS 保留字:它们作为裸标识符会出现语法错误
(如列名 ``class`` 会生成 ``class: Mapped[...]``)。
参数:
- value (object): 待判断的值。
返回:
- bool: 合法返回 True。
"""
if value is None:
return False
text = str(value).strip()
if not text or len(text) > _IDENTIFIER_MAX_LENGTH:
return False
if text in _PYTHON_RESERVED or text in _JS_RESERVED:
return False
return bool(_IDENTIFIER_RE.match(text))
def reserved_word_reason(value: object) -> str | None:
"""若值命中 Python 关键字或 JS 保留字,返回原因文案(供报错信息使用),否则 None。
参数:
- value (object): 待判断的值。
返回:
- str | None: 命中原因("Python 关键字" / "JS 保留字")。
"""
text = "" if value is None else str(value).strip()
if text in _PYTHON_RESERVED:
return "Python 关键字"
if text in _JS_RESERVED:
return "JS/TS 保留字"
return None
def unescape_template_text(value: object) -> str:
"""还原 :func:`escape_template_text` 产生的 ``\\uXXXX`` 转义(展示位置专用)。
HTML 文本/属性里不会解析 ``\\uXXXX``,若不还原就会被用户看成字面量。转义本身是**可逆**的
(原文本里的反斜杠也被转义为 ``\\u005c``),因此这里单趟还原即可精确拿回原文。
参数:
- value (object): 已转义的文本。
返回:
- str: 还原后的文本。
"""
if value is None:
return ""
return _UNESCAPE_RE.sub(lambda m: chr(int(m.group(1), 16)), str(value))
def html_escape(value: object) -> str:
"""HTML 实体转义(用于 ``.vue`` 的文本节点与静态属性值)。
与 :func:`escape_template_text` 的区别:这里追求**展示保真**——先还原 ``\\uXXXX``(HTML 不解析该转义),
再用实体转义,浏览器渲染回原字符。
参数:
- value (object): 文本(可能已带 ``\\uXXXX`` 转义)。
返回:
- str: 实体转义后的文本。
"""
if value is None:
return ""
text = unescape_template_text(value)
for raw, escaped in _HTML_ESCAPE_MAP.items():
text = text.replace(raw, escaped)
return text
def html_attr(value: object) -> str:
"""静态 HTML 属性值的安全化(还原转义 + 实体转义,保留引号语义与展示保真)。
参数:
- value (object): 文本。
返回:
- str: 可直接放进 ``attr="..."`` 的文本。
"""
return html_escape(value)
def vue_label(value: object) -> str:
"""把展示文本处理为可安全放进 ``.vue`` **文本节点**的形式。
策略(与 JS/Python 字符串上下文不同,见模块文档的「按上下文四类」):
- 先还原 ``\\uXXXX``(HTML 不解析该转义,否则用户会看到字面量);
- 剔除 ``< > { }``:改变标记结构 / 触发 Vue 插值的字符直接去掉;
- 其余字符用 **HTML 实体**转义:浏览器渲染回原字符,展示保真。
参数:
- value (object): 原始展示文本。
返回:
- str: 可安全放进文本节点的文本。
"""
if value is None:
return ""
return html_escape(_VUE_LABEL_STRIP_RE.sub("", unescape_template_text(value)))
def safe_python_type(value: object, default: str = "str") -> str:
"""把 ``python_type`` 规范为合法类型注解片段。
``python_type`` 由表单提交且直接落在 ``Mapped[{{ column.python_type }}]`` 这类注解位置,
必须限定为标识符形态(如 ``str``/``int``/``datetime``/``Decimal``)。
参数:
- value (object): 原始值。
- default (str): 兜底类型。
返回:
- str: 合法标识符形式的类型名。
"""
return safe_ident(value, default=default)
def assert_python_syntax(content: str, *, filename: str = "<generated>") -> None:
"""断言渲染产物是语法合法的 Python。
作为「转义/白名单是否仍然完备」的最后一道网:任何模板改动导致注入或语法破损时,
在**写盘前**就报错,而不是把坏文件写进会被启动期 import 的目录。
参数:
- content (str): 渲染后的源码文本。
- filename (str): 产物相对路径(用于报错定位)。
返回:
- None
异常:
- SyntaxError: 产物无法被 ast.parse 解析时抛出。
"""
try:
ast.parse(content)
except SyntaxError as exc:
raise SyntaxError(f"{filename} 渲染结果不是合法的 Python 代码:{exc.msg}(第 {exc.lineno} 行)") from exc
# ══════════════════════════════════════════════════════════════════════════════
# 「代码位置」类值:一律由**常量白名单**派生,不接收自由文本
# ══════════════════════════════════════════════════════════════════════════════
# column_type 的形状白名单(结构化,不含引号/分号/换行等可注入字符)
_COLUMN_TYPE_RE = re.compile(
r"^[A-Za-z][A-Za-z0-9_ ]*(\(\s*\d+(\s*,\s*\d+)?\s*\))?(\[\])*(\s+unsigned)?$",
)
# MySQL ENUM/SET 列表(合法的库类型,但含引号):整体归一化为基名后再做形状校验
_ENUM_SET_RE = re.compile(r"^(enum|set)\s*\(.*\)$", re.IGNORECASE)
# 从 column_type 的括号里取参数(只接受数字,绝不拼接原始子串)
_TYPE_PARAMS_RE = re.compile(r"\(\s*(\d+)(?:\s*,\s*(\d+))?\s*\)")
# 取出类型基名(字母/数字/下划线/空格,遇括号或方括号即止)
_TYPE_BASE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_ ]*")
# 基名里需要剔除的修饰词(它们不属于类型名,参与查表会导致 miss)
_BASE_MODIFIER_RE = re.compile(r"\s+(unsigned|zerofill)$", re.IGNORECASE)
# 映射常量值的白名单(类型名只能取自这里,杜绝自由文本进产物)
_SQLALCHEMY_TYPE_WHITELIST: frozenset[str] = frozenset(GenConstant.DB_TO_SQLALCHEMY.values())
# Boolean 由 MySQL tinyint(1) 规则显式产出(常量表中未必有该值),显式纳入白名单
_SQLALCHEMY_TYPE_WHITELIST = _SQLALCHEMY_TYPE_WHITELIST | {"Boolean"}
def _lookup_sqlalchemy_type(base: str) -> str:
"""按基名(大小写不敏感)在常量白名单中查表。
参数:
- base (str): 类型基名(已小写)。
返回:
- str: 命中的常量值;未命中返回空串。
"""
for key, value in GenConstant.DB_TO_SQLALCHEMY.items():
if str(key).lower() == base:
return str(value)
return ""
def normalize_column_type(column_type: object) -> str:
"""归一化列类型:去空白、把 ENUM/SET 列表压成基名。
参数:
- column_type (object): 原始列类型(如 ``VARCHAR(64)`` / ``ENUM('a','b')``)。
返回:
- str: 归一化后的类型文本(ENUM/SET 仅保留基名)。
"""
text = "" if column_type is None else str(column_type).strip()
if _ENUM_SET_RE.match(text):
return _TYPE_BASE_RE.match(text).group(0).strip() # type: ignore[union-attr]
return text
def is_valid_column_type(column_type: object) -> bool:
"""列类型形状校验:只允许「基名 + 可选(n)/(n,m) + 可选[] + 可选 unsigned」。
参数:
- column_type (object): 原始列类型。
返回:
- bool: 合法返回 True。
"""
text = normalize_column_type(column_type)
if not text:
return False
return bool(_COLUMN_TYPE_RE.match(text))
def column_type_base(column_type: object) -> str:
"""取列类型基名(用于白名单查表,不参与最终产物拼接)。
参数:
- column_type (object): 原始列类型。
返回:
- str: 基名(如 ``VARCHAR``);无法识别时为空串。
"""
text = normalize_column_type(column_type)
match = _TYPE_BASE_RE.match(text)
if not match:
return ""
base = _BASE_MODIFIER_RE.sub("", match.group(0).strip())
return base.strip()
def extract_type_params(column_type: object, column_length: object = None) -> tuple[str, ...]:
"""提取「长度/精度」参数,只接受纯数字。
取数优先级:``column_length.isdigit()`` → ``column_type`` 括号内正则捕获的数字。
**不对 column_type 做字符串切分拼接**(``split("(")[1]`` 会把任意内容带进产物)。
参数:
- column_type (object): 列类型。
- column_length (object): 列长度(表单/反射字段)。
返回:
- tuple[str, ...]: 形如 ("64",) 或 ("10", "2");无有效参数时为空元组。
"""
length = "" if column_length is None else str(column_length).strip()
if length.isdigit():
return (length,)
text = normalize_column_type(column_type)
match = _TYPE_PARAMS_RE.search(text)
if not match:
return ()
return tuple(part for part in match.groups() if part is not None)
def pick_sqlalchemy_type(column_type: object, column_length: object = None, *, db_type: str | None = None) -> str:
"""由常量白名单派生 SQLAlchemy 类型字符串(代码位置,不接收自由文本)。
规则:
- 类型名只能来自 ``GenConstant.DB_TO_SQLALCHEMY`` 的**值**(未命中回落 ``String``);
- 长度/精度只能来自 ``column_length.isdigit()`` 或 ``column_type`` 括号内的数字捕获;
- MySQL 下 ``tinyint(1)`` 保持 ``Boolean`` 语义。
参数:
- column_type (object): 列类型。
- column_length (object): 列长度。
- db_type (str | None): 数据库类型(默认取 settings.DATABASE_TYPE)。
返回:
- str: 形如 ``String(64)`` / ``Numeric(10,2)`` / ``DateTime`` 的类型字符串。
"""
from app.config.setting import settings # 延迟导入避免模块级循环
dialect = (db_type or settings.DATABASE_TYPE or "").lower()
base = column_type_base(column_type)
params = extract_type_params(column_type, column_length)
lowered = base.lower()
# MySQL:仅 tinyint(1) 映射为 Boolean(与既有行为一致)
if dialect != "postgres" and lowered == "tinyint" and params[:1] == ("1",):
return "Boolean"
# 查表(大小写不敏感):命中值必须落在白名单内
mapped = _lookup_sqlalchemy_type(lowered)
if not mapped and lowered == "character":
mapped = _lookup_sqlalchemy_type("char")
if mapped not in _SQLALCHEMY_TYPE_WHITELIST:
mapped = "String" # 白名单兜底
# 长度/精度:仅对需要参数的字符串与数值类型附加(数字已校验)
if params:
if mapped in {"String", "CHAR"}:
return f"{mapped}({params[0]})"
if mapped in {"Numeric", "DECIMAL"} and len(params) >= 2:
return f"{mapped}({params[0]},{params[1]})"
if mapped in {"String", "CHAR"}:
return f"{mapped}(255)"
return mapped
# ══════════════════════════════════════════════════════════════════════════════
# 落盘前的「结构闸门」:仅 ast.parse 不足以发现注入,必须比对骨架结构
# ══════════════════════════════════════════════════════════════════════════════
# 允许出现在生成产物里的顶层语句类型(白名单);出现其它类型即拒绝
_ALLOWED_TOP_LEVEL_NODES: tuple[type[ast.AST], ...] = (
ast.Import,
ast.ImportFrom,
ast.ClassDef,
ast.FunctionDef,
ast.AsyncFunctionDef,
ast.Assign,
ast.AnnAssign,
ast.Expr,
ast.If,
ast.Try,
ast.Pass,
)
def _node_structure(node: ast.AST) -> object:
"""把 AST 节点归一化为「结构签名」:保留节点类型与层级,抹掉具体取值/标识符。
参数:
- node (ast.AST): 待归一化的节点。
返回:
- object: 可比较的结构签名(元组树)。
"""
if isinstance(node, ast.Constant):
return ("Constant", type(node.value).__name__)
if isinstance(node, ast.Name):
return ("Name",) # 标识符具体名字不参与比较
if isinstance(node, ast.Attribute):
return ("Attribute", _node_structure(node.value))
if isinstance(node, ast.arg):
return ("arg", node.arg) # 形参名由模板决定,参与比较
if isinstance(node, ast.keyword):
return ("keyword", node.arg, _node_structure(node.value))
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
return (
type(node).__name__,
node.name,
tuple(_node_structure(item) for item in node.body),
tuple(_node_structure(item) for item in node.decorator_list),
tuple(_node_structure(item) for item in node.args.args),
tuple(_node_structure(item) for item in node.args.kwonlyargs),
node.args.vararg.arg if node.args.vararg else None,
node.args.kwarg.arg if node.args.kwarg else None,
)
if isinstance(node, ast.ClassDef):
return (
"ClassDef",
node.name,
tuple(_node_structure(item) for item in node.bases),
tuple(_node_structure(item) for item in node.body),
tuple(_node_structure(item) for item in node.decorator_list),
)
fields: list[object] = [type(node).__name__]
for _field_name, value in ast.iter_fields(node):
if isinstance(value, list):
fields.append(tuple(_node_structure(item) for item in value if isinstance(item, ast.AST)))
elif isinstance(value, ast.AST):
fields.append(_node_structure(value))
else:
fields.append(type(value).__name__)
return tuple(fields)
def python_structure(content: str) -> tuple[object, ...]:
"""解析源码并返回顶层结构签名(供骨架比对)。
参数:
- content (str): Python 源码。
返回:
- tuple[object, ...]: 顶层语句的结构签名序列。
"""
tree = ast.parse(content)
return tuple(_node_structure(node) for node in tree.body)
def assert_python_structure(content: str, reference: str, *, filename: str = "<generated>") -> None:
"""落盘前的语义/AST 闸门:真实产物必须与「哨兵值骨架」结构一致。
只做 ``ast.parse`` 不能作为安全网(注入出来的代码本身语法合法)。这里要求:
1. 产物语法合法;
2. 顶层语句类型必须在白名单内(Import/ClassDef/AnnAssign…);
3. 与用哨兵值渲染同一模板得到的**骨架**逐节点结构一致(语句数量、类型、装饰器、
形参、关键字参数名全部一致),任何因值而新增/改写的语句都会被发现。
参数:
- content (str): 真实渲染产物。
- reference (str): 哨兵值渲染产出的骨架。
- filename (str): 产物相对路径(报错定位)。
返回:
- None
异常:
- SyntaxError: 语法非法或结构不一致时抛出。
"""
try:
ast.parse(content)
except SyntaxError as exc:
raise SyntaxError(f"{filename} 渲染结果不是合法的 Python 代码:{exc.msg}(第 {exc.lineno} 行)") from exc
real_tree = ast.parse(content)
banned = [type(node).__name__ for node in real_tree.body if not isinstance(node, _ALLOWED_TOP_LEVEL_NODES)]
if banned:
raise SyntaxError(f"{filename} 出现白名单之外的顶层语句:{sorted(set(banned))}")
real = python_structure(content)
skeleton = python_structure(reference)
if real != skeleton:
raise SyntaxError(
f"{filename} 渲染产物结构与模板骨架不一致(疑似模板注入或转义缺口):"
f"真实 {len(real)} 条顶层语句 / 骨架 {len(skeleton)} 条",
)
_STR_LITERAL_RE = re.compile(r"\"(?:[^\"\\]|\\.)*\"|'(?:[^'\\]|\\.)*'")
def _normalize_text_line(line: str, tokens: tuple[str, ...]) -> str:
"""把一行里的「值」抹成占位符,只留下骨架(供 .vue/.ts 的粗粒度护栏比对)。
参数:
- line (str): 待归一化的行。
- tokens (tuple[str, ...]): 需要抹掉的动态值(哨兵 + 真实值,按长度降序)。
返回:
- str: 归一化后的行骨架。
"""
normalized = line
for token in tokens:
if token:
normalized = normalized.replace(token, "«»")
return _STR_LITERAL_RE.sub(lambda m: f"{m.group(0)[0]}«»{m.group(0)[0]}", normalized)
def assert_text_structure(
content: str,
reference: str,
*,
sentinel: str,
dynamic_values: object = (),
filename: str = "<generated>",
) -> None:
"""``.vue`` / ``.ts`` / ``.toml`` 产物的粗粒度结构护栏。
这些产物没有 AST 可比,退化为「行结构一致」:把每行的字符串字面量与哨兵值抹成占位符后,
真实产物与骨架的行数与逐行骨架必须完全一致。任何因值而新增的行(注入的 script/标签/语句)
或在一行内跑出字符串字面量的注入都会导致不一致。
参数:
- content (str): 真实渲染产物。
- reference (str): 哨兵值渲染产出的骨架。
- sentinel (str): 哨兵文本。
- dynamic_values (object): 真实值集合(会出现在真实产物里的动态文本,一并抹成占位符)。
- filename (str): 产物相对路径(报错定位)。
返回:
- None
异常:
- ValueError: 行结构不一致时抛出。
"""
tokens = tuple(sorted({sentinel, *[str(item) for item in dynamic_values if item]}, key=len, reverse=True))
real_lines = content.splitlines()
ref_lines = reference.splitlines()
if len(real_lines) != len(ref_lines):
raise ValueError(f"{filename} 产物行数与模板骨架不一致({len(real_lines)} vs {len(ref_lines)}),疑似注入")
for index, (real_line, ref_line) in enumerate(zip(real_lines, ref_lines, strict=True), 1):
if _normalize_text_line(real_line, tokens) != _normalize_text_line(ref_line, tokens):
raise ValueError(f"{filename} 第 {index} 行结构偏离模板骨架,疑似注入:{real_line.strip()[:120]!r}")
@@ -9,3 +9,11 @@ class ServiceInfoOut(BaseModel):
environment: str = Field(..., description="运行环境")
db_status: int = Field(..., description="数据库状态(0:异常 1:正常)")
redis_status: int = Field(..., description="Redis状态(0:异常 1:正常)")
code_exec_enabled: bool = Field(
default=False,
description="定时任务是否允许执行用户提交的代码块(SCHEDULER_ALLOW_CODE_EXEC);关闭时节点型的代码块任务不可调度",
)
cors_origins_configured: bool = Field(
default=True,
description="生产环境是否已显式配置 PROD_CORS_ORIGINS;false 表示生产已按安全默认拒绝全部跨域请求",
)
@@ -51,4 +51,8 @@ class HealthService:
environment=settings.ENVIRONMENT.value,
db_status=db_status,
redis_status=redis_status,
# 升级可见性:这两个开关会静默改变行为(代码块任务不可调度 / 生产拒绝全部跨域),
# 在健康检查里显式暴露,避免运维只看到"任务不动了"而不知原因。
code_exec_enabled=settings.SCHEDULER_ALLOW_CODE_EXEC,
cors_origins_configured=bool(settings.PROD_CORS_ORIGINS) if settings.ENVIRONMENT.value == "prod" else True,
)
@@ -72,7 +72,8 @@ def _require_credentials(provider: OAuthProvider) -> tuple[str, str]:
else:
raise CustomException(msg="不支持的 OAuth 渠道")
if not cid or not sec:
raise CustomException(msg=f"{provider} OAuth 未配置(客户端密钥为空)")
# 渠道密钥为空属**部署配置缺失**:客户端无论怎么改请求都无法成功,按 500 返回以免监控误判为客户端错误
raise CustomException(msg=f"{provider} OAuth 未配置(客户端密钥为空)", status_code=500)
return cid, sec
@@ -136,7 +137,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 接口返回异常")
raise CustomException(msg="OAuth 接口返回异常", status_code=500)
async def _http_text(method: str, url: str, **kwargs: Any) -> str:
+3 -1
View File
@@ -81,7 +81,9 @@ async def auto_register_login_user(
existing = await UserCRUD(auth, db).get(username=username)
if existing:
return existing
raise CustomException(msg=fail_msg)
# 原始异常被吞掉(可能是 DB/唯一约束/驱动等未知服务端故障):按 500 返回,
# 避免监控把内部故障误判为客户端错误;确实的客户端问题由 UserService 的 CustomException 透传
raise CustomException(msg=fail_msg, status_code=500)
user = await UserCRUD(auth, db).get(username=username)
if not user:
raise CustomException(msg=fail_msg)
@@ -67,7 +67,7 @@ async def _http_json(method: str, url: str, **kwargs: Any) -> dict:
return r.json()
except json.JSONDecodeError:
logger.error(f"微信 API 非 JSON 响应: {r.text[:500]}")
raise CustomException(msg="微信接口返回异常")
raise CustomException(msg="微信接口返回异常", status_code=500)
async def _get_access_token(redis: Redis) -> str:
+9 -9
View File
@@ -121,7 +121,7 @@ class DictTypeService:
logger.info(f"创建字典类型成功: {new_obj_dict}")
except Exception as e:
logger.error(f"创建字典类型失败: {e}")
raise CustomException(msg="同步字典类型缓存失败") from e
raise CustomException(msg="同步字典类型缓存失败", status_code=500) from e
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="同步字典类型缓存失败") from e
raise CustomException(msg="同步字典类型缓存失败", status_code=500) from e
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="同步删除字典缓存失败") from e
raise CustomException(msg="同步删除字典缓存失败", status_code=500) from e
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="字典数据初始化失败") from e
raise CustomException(msg="字典数据初始化失败", status_code=500) from e
@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="获取字典数据失败") from e
raise CustomException(msg="获取字典数据失败", status_code=500) from e
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="同步字典数据缓存失败") from e
raise CustomException(msg="同步字典数据缓存失败", status_code=500) from e
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="同步旧字典数据缓存失败") from e
raise CustomException(msg="同步旧字典数据缓存失败", status_code=500) from e
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="同步字典数据缓存失败") from e
raise CustomException(msg="同步字典数据缓存失败", status_code=500) from e
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="同步删除字典数据缓存失败") from e
raise CustomException(msg="同步删除字典数据缓存失败", status_code=500) from e
await DictDataCRUD(self.auth, self.db).delete(ids=ids)
async def set_available(self, data: BatchSetAvailable) -> None:
+3 -3
View File
@@ -57,10 +57,10 @@ class ParamsService:
)
if not result:
logger.error(f"同步配置到缓存失败: {out}")
raise CustomException(msg="同步配置到缓存失败")
raise CustomException(msg="同步配置到缓存失败", status_code=500)
except Exception as e:
logger.error(f"更新系统配置失败: {e}")
raise CustomException(msg="同步配置到缓存失败") from e
raise CustomException(msg="同步配置到缓存失败", status_code=500) from e
return out
@@ -95,7 +95,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 失败") from e
raise CustomException(msg="初始化系统参数到 Redis 失败", status_code=500) from e
@staticmethod
async def get_init_cache(redis: Redis) -> list[dict]:
+4 -1
View File
@@ -379,9 +379,12 @@ class UserService:
result += "\n错误信息:\n" + "\n".join(error_msgs)
return result
except CustomException:
# 业务异常原样透传(导入文件为空 / 缺少必要列 / 单行数据不合法等属客户端可修正,保留 400)
raise
except Exception as e:
logger.error(f"批量导入用户失败: {e!s}")
raise CustomException(msg=f"导入失败: {e!s}") from e
raise CustomException(msg=f"导入失败: {e!s}", status_code=500) from e
async def _process_import_row(
self,
@@ -14,6 +14,8 @@ class SchedulerStatusSchema(BaseModel):
status: str = Field(..., description="调度器状态(运行中/已暂停/已停止/未知)")
is_running: bool = Field(..., description="是否运行中")
job_count: int = Field(..., description="调度器任务数量")
code_exec_enabled: bool = Field(default=False, description="是否允许执行用户提交的代码块(SCHEDULER_ALLOW_CODE_EXEC)")
unschedulable_count: int = Field(default=0, description="已注册但当前不可执行的任务数(>0 时列表页会给出原因)")
class SchedulerJobSchema(BaseModel):
@@ -24,6 +26,8 @@ class SchedulerJobSchema(BaseModel):
trigger: str = Field(..., description="触发器")
next_run_time: str | None = Field(default=None, description="下次运行时间")
status: int = Field(..., description="任务状态(0:运行中 1:暂停中 2:已停止 3:未知)")
schedulable: bool = Field(default=True, description="当前是否可执行;false 表示已注册但执行时会被拒绝")
unschedulable_reason: str | None = Field(default=None, description="不可执行原因(含修复建议)")
class SchedulerJobModifySchema(BaseModel):
@@ -1,5 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.config.setting import settings
from app.core.ap_scheduler import SchedulerUtil
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
@@ -71,25 +72,36 @@ class JobService:
state = SchedulerUtil.get_scheduler_state()
is_running = SchedulerUtil.is_running()
jobs = SchedulerUtil.get_jobs()
# 可观测性(t15):把"已注册但当前不可执行"的任务显式打出并计数,
# 运维不必逐个点开才知道开关调整/处理器改名后哪些任务静默失效
unschedulable = SchedulerUtil.log_unschedulable_jobs(context="调度器状态查询")
return SchedulerStatusSchema(
status=JobService._SCHEDULER_STATE_MAP.get(state, "未知"),
is_running=is_running,
job_count=len(jobs),
code_exec_enabled=settings.SCHEDULER_ALLOW_CODE_EXEC,
unschedulable_count=len(unschedulable),
)
@staticmethod
def get_scheduler_jobs() -> list[SchedulerJobSchema]:
jobs = SchedulerUtil.get_jobs()
return [
SchedulerJobSchema(
id=job.id,
name=job.name,
trigger=str(job.trigger),
next_run_time=str(job.next_run_time) if job.next_run_time else None,
status=SchedulerUtil.get_job_status(job_id=job.id),
items: list[SchedulerJobSchema] = []
for job in jobs:
schedulable, reason = SchedulerUtil.describe_schedulability(SchedulerUtil.job_code_block(job))
items.append(
SchedulerJobSchema(
id=job.id,
name=job.name,
trigger=str(job.trigger),
# pending/未排程任务没有 next_run_time 属性(APScheduler 只在真正排程后设置),必须 getattr 兜底
next_run_time=str(job.next_run_time) if getattr(job, "next_run_time", None) else None,
status=SchedulerUtil.get_job_status(job_id=job.id),
schedulable=schedulable,
unschedulable_reason=None if schedulable else reason,
),
)
for job in jobs
]
return items
# ─── 调度器与任务运维(controller 唯一入口,禁止直调 SchedulerUtil)───
@@ -17,7 +17,14 @@ class NodeCreateSchema(BaseModel):
"""
name: str = Field(..., max_length=64, description="任务名称")
func: str | None = Field(default=None, description="代码块")
func: str | None = Field(
default=None,
description=(
"任务函数:两种写法——① 内置函数型 `builtin:<模块名>[.<函数名>]`(白名单解析后直接调用,不经 exec,"
"不受 SCHEDULER_ALLOW_CODE_EXEC 限制);② 原始代码块(需定义 handler(*args, **kwargs),等同服务器代码执行权限,"
"仅在 SCHEDULER_ALLOW_CODE_EXEC=true 时允许)"
),
)
args: str | None = Field(default=None, description="位置参数")
kwargs: str | None = Field(default=None, description="关键字参数")
coalesce: bool | None = Field(default=False, description="是否合并运行:是否在多个运行时间到期时仅运行作业一次")
@@ -102,6 +109,11 @@ class NodeOutSchema(NodeCreateSchema, BaseSchema, UserBySchema):
next_run_time: str | None = Field(default=None, description="下次运行时间")
last_run_time: str | None = Field(default=None, description="最近一次运行时间")
last_run_status: int | None = Field(default=None, description="最近一次运行状态(2:成功 3:失败)")
schedulable: bool | None = Field(
default=None,
description="当前是否可调度;false 表示「已启用也不会执行」(如代码块被禁用、内置处理器不存在、未配置函数)",
)
unschedulable_reason: str | None = Field(default=None, description="不可调度原因(含修复建议)")
model_config = ConfigDict(from_attributes=True)
@@ -11,7 +11,6 @@ from croniter import croniter
from sqlalchemy import false, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config.setting import settings
from app.core.ap_scheduler import SchedulerUtil, scheduler
from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.exceptions import CustomException
@@ -179,7 +178,17 @@ class NodeService:
node_id = str(item.id) if item.id is not None else ""
job = jobs.get(node_id)
if job:
item.next_run_time = str(job.next_run_time) if job.next_run_time else None
item.next_run_time = str(job.next_run_time) if getattr(job, "next_run_time", None) else None
# 可调度状态(t15):区分「已启用且可执行」与「已启用但当前不会执行」,
# 并把后者以 WARN 打出(带节点标识),避免升级/开关调整后的静默失效
schedulable, reason = SchedulerUtil.describe_schedulability(item.func)
item.schedulable = schedulable
item.unschedulable_reason = None if schedulable else reason
if not schedulable and item.status == 0:
_warn_unschedulable_node(node_id=node_id, name=item.name, reason=reason)
elif schedulable:
_clear_unschedulable_warning(node_id)
stmt = (
select(JobModel)
.where(JobModel.job_id == node_id, JobModel.is_deleted == false())
@@ -192,6 +201,41 @@ class NodeService:
item.last_run_status = row.status
# 「已启用但不可调度」的告警去重:同一节点 + 同一原因只告警一次,
# 避免列表接口被反复调用时刷屏;节点恢复可调度时清除记录(见 _enrich_runtime)。
_WARNED_UNSCHEDULABLE: dict[str, str] = {}
def _warn_unschedulable_node(*, node_id: str, name: str | None, reason: str | None) -> None:
"""对「已启用但当前不可调度」的节点打一次 WARN(带节点标识与原因)。
参数:
- node_id (str): 节点 ID(调度器 job id)。
- name (str | None): 节点名称。
- reason (str | None): 不可调度原因。
返回:
- None
"""
text = reason or ""
if _WARNED_UNSCHEDULABLE.get(node_id) == text:
return
_WARNED_UNSCHEDULABLE[node_id] = text
logger.warning(f"节点任务不可调度(已启用但不会执行): id={node_id} name={name!r} 原因={reason}")
def _clear_unschedulable_warning(node_id: str) -> None:
"""节点恢复可调度后清除其告警去重记录(下次再失效仍会告警)。
参数:
- node_id (str): 节点 ID。
返回:
- None
"""
_WARNED_UNSCHEDULABLE.pop(node_id, None)
# ── NodeModel 封装的调度操作(模块级函数,供 service / 启动自愈复用)────
@@ -281,8 +325,11 @@ def _add_job_with_trigger(
code_block = job_info.func
if not code_block or not code_block.strip():
raise ValueError("任务代码块不能为空")
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
raise CustomException(msg="服务已禁用定时任务代码执行(SCHEDULER_ALLOW_CODE_EXEC=False),无法注册代码块任务")
# 可调度性判定(单一事实来源):builtin: 引用走白名单、不经 exec,因此**开关关闭时也允许注册**;
# 只有"原始代码块"才会被 SCHEDULER_ALLOW_CODE_EXEC=False 拦住,并在提示里给出 builtin 替代写法。
schedulable, reason = SchedulerUtil.describe_schedulability(code_block)
if not schedulable:
raise CustomException(msg=reason)
jobstore = job_info.jobstore or "default"
executor = job_info.executor or "threadpool"
@@ -368,8 +415,10 @@ def run_node_once(node: NodeModel) -> str:
"""
if not node.func or not node.func.strip():
raise ValueError("任务代码块不能为空")
if not settings.SCHEDULER_ALLOW_CODE_EXEC:
raise CustomException(msg="服务已禁用定时任务代码执行(SCHEDULER_ALLOW_CODE_EXEC=False),无法执行代码块任务")
# 手动执行同样按可调度性判定:builtin 引用可直接执行;原始代码块在开关关闭时给出替代写法
schedulable, reason = SchedulerUtil.describe_schedulability(node.func)
if not schedulable:
raise CustomException(msg=reason)
temp_job_id = f"{node.id}{_MANUAL_JOB_PREFIX}{datetime.now():%Y%m%d%H%M%S}"
trigger = DateTrigger(run_date=datetime.now() + timedelta(seconds=0.1), timezone="Asia/Shanghai")
_add_job_with_trigger(node, trigger, job_id_override=temp_job_id, name_suffix=" - 手动执行")
@@ -80,7 +80,7 @@ def decrypt_password(cipher: str | None) -> str:
try:
return CryptoUtil.decrypt(cipher)
except CustomException as e:
raise CustomException(msg=f"存储源密码解密失败:{e!s}")
raise CustomException(msg=f"存储源密码解密失败:{e!s}", status_code=500)
# 文件系统类协议与对象存储类协议的划分:仅用于文档说明与配置元数据,
@@ -322,7 +322,7 @@ 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}")
raise CustomException(msg=f"批量上传失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}", status_code=500)
return len(tasks)
def _sync_download_dir(self, remote_dir: str, local_dir: str, concurrency: int) -> int:
@@ -352,7 +352,7 @@ 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}")
raise CustomException(msg=f"批量下载失败 {len(errors)}/{len(tasks)} 个文件,首个错误: {errors[0]!s}", status_code=500)
return len(tasks)
@staticmethod
@@ -96,7 +96,7 @@ class CosStorageAdapter(BaseStorageAdapter):
EnableMD5=self._adv.enable_md5,
)
except Exception as e:
raise CustomException(msg=f"COS 上传失败: {e!s}")
raise CustomException(msg=f"COS 上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -107,7 +107,7 @@ class CosStorageAdapter(BaseStorageAdapter):
DestFilePath=local_path,
)
except Exception as e:
raise CustomException(msg=f"COS 下载失败: {e!s}")
raise CustomException(msg=f"COS 下载失败: {e!s}", status_code=500)
return local_path
def _sync_delete(self, remote_path: str) -> None:
@@ -122,7 +122,7 @@ 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}")
raise CustomException(msg=f"COS 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -181,7 +181,7 @@ class CosStorageAdapter(BaseStorageAdapter):
break
return result
except Exception as e:
raise CustomException(msg=f"COS 列表失败: {e!s}")
raise CustomException(msg=f"COS 列表失败: {e!s}", status_code=500)
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(COS Marker),翻页经前端回传游标。"""
@@ -250,7 +250,7 @@ class CosStorageAdapter(BaseStorageAdapter):
next_cursor=next_cursor or None,
)
except Exception as e:
raise CustomException(msg=f"COS 列表失败: {e!s}")
raise CustomException(msg=f"COS 列表失败: {e!s}", status_code=500)
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
@@ -259,7 +259,7 @@ class CosStorageAdapter(BaseStorageAdapter):
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}")
raise CustomException(msg=f"COS 桶列表失败: {e!s}", status_code=500)
def _sync_get_url(self, remote_path: str, expire: int) -> str:
"""生成预签名 URL
@@ -280,7 +280,7 @@ class CosStorageAdapter(BaseStorageAdapter):
Expired=expire,
)
except Exception as e:
raise CustomException(msg=f"COS 生成预签名 URL 失败: {e!s}")
raise CustomException(msg=f"COS 生成预签名 URL 失败: {e!s}", status_code=500)
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
@@ -311,13 +311,13 @@ class CosStorageAdapter(BaseStorageAdapter):
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}")
raise CustomException(msg=f"COS 创建目录失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"COS 删除目录失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
@@ -331,7 +331,7 @@ class CosStorageAdapter(BaseStorageAdapter):
CopySource={"Bucket": self._require_bucket(), "Key": src},
)
except Exception as e:
raise CustomException(msg=f"COS 复制失败: {e!s}")
raise CustomException(msg=f"COS 复制失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
@@ -345,7 +345,7 @@ class CosStorageAdapter(BaseStorageAdapter):
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"COS 递归列表失败: {e!s}")
raise CustomException(msg=f"COS 递归列表失败: {e!s}", status_code=500)
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -361,4 +361,4 @@ class CosStorageAdapter(BaseStorageAdapter):
Delete={"Objects": [{"Key": k} for k in keys[i : i + 1000]], "Quiet": True},
)
except Exception as e:
raise CustomException(msg=f"COS 递归删除失败: {e!s}")
raise CustomException(msg=f"COS 递归删除失败: {e!s}", status_code=500)
@@ -101,7 +101,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
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}")
raise CustomException(msg=f"FTP 上传失败: {e!s}", status_code=500)
finally:
self._close_client(client)
return remote_path
@@ -123,7 +123,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
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}")
raise CustomException(msg=f"FTP 下载失败: {e!s}", status_code=500)
finally:
self._close_client(client)
return local_path
@@ -133,7 +133,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
try:
client.delete(remote_path)
except Exception as e:
raise CustomException(msg=f"FTP 删除失败: {e!s}")
raise CustomException(msg=f"FTP 删除失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -182,7 +182,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
)
return result
except Exception as e:
raise CustomException(msg=f"FTP 列表失败: {e!s}")
raise CustomException(msg=f"FTP 列表失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -200,7 +200,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
except ftplib.error_perm:
pass # 目录已存在时跳过(部分服务器返回 550)
except Exception as e:
raise CustomException(msg=f"FTP 创建目录失败: {e!s}")
raise CustomException(msg=f"FTP 创建目录失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -209,7 +209,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
try:
client.rmd(remote_dir)
except Exception as e:
raise CustomException(msg=f"FTP 删除目录失败: {e!s}")
raise CustomException(msg=f"FTP 删除目录失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -218,7 +218,7 @@ class FtpStorageAdapter(BaseStorageAdapter):
try:
client.rename(src, dst)
except Exception as e:
raise CustomException(msg=f"FTP 重命名失败: {e!s}")
raise CustomException(msg=f"FTP 重命名失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -248,6 +248,6 @@ class FtpStorageAdapter(BaseStorageAdapter):
finally:
os.remove(tmp_path)
except Exception as e:
raise CustomException(msg=f"FTP 复制失败: {e!s}")
raise CustomException(msg=f"FTP 复制失败: {e!s}", status_code=500)
finally:
self._close_client(client)
@@ -37,7 +37,7 @@ class LocalStorageAdapter(BaseStorageAdapter):
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(local_path, dest)
except Exception as e:
raise CustomException(msg=f"本地存储上传失败: {e!s}")
raise CustomException(msg=f"本地存储上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -45,7 +45,7 @@ class LocalStorageAdapter(BaseStorageAdapter):
try:
shutil.copy2(src, local_path)
except Exception as e:
raise CustomException(msg=f"本地存储下载失败: {e!s}")
raise CustomException(msg=f"本地存储下载失败: {e!s}", status_code=500)
return local_path
def _sync_delete(self, remote_path: str) -> None:
@@ -56,7 +56,7 @@ class LocalStorageAdapter(BaseStorageAdapter):
elif os.path.exists(target):
os.remove(target)
except Exception as e:
raise CustomException(msg=f"本地存储删除失败: {e!s}")
raise CustomException(msg=f"本地存储删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
return os.path.exists(self._abs_path(remote_path))
@@ -66,7 +66,7 @@ class LocalStorageAdapter(BaseStorageAdapter):
try:
entries = os.scandir(base_dir)
except OSError as e:
raise CustomException(msg=f"本地存储列表失败: {e!s}")
raise CustomException(msg=f"本地存储列表失败: {e!s}", status_code=500)
result: list[StorageObject] = []
for entry in entries:
@@ -93,20 +93,20 @@ class LocalStorageAdapter(BaseStorageAdapter):
try:
os.makedirs(self._abs_path(remote_dir), exist_ok=True)
except Exception as e:
raise CustomException(msg=f"本地存储创建目录失败: {e!s}")
raise CustomException(msg=f"本地存储创建目录失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"本地存储删除目录失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"本地存储重命名失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
src_abs, dst_abs = self._abs_path(src), self._abs_path(dst)
@@ -117,4 +117,4 @@ class LocalStorageAdapter(BaseStorageAdapter):
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}")
raise CustomException(msg=f"本地存储复制失败: {e!s}", status_code=500)
@@ -176,7 +176,7 @@ class ObsStorageAdapter(BaseStorageAdapter):
content=f,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}", status_code=500)
return remote_path
part_size, concurrency, _ = self._multipart_settings()
resp = self.client.uploadFile(
@@ -188,11 +188,11 @@ class ObsStorageAdapter(BaseStorageAdapter):
enableCheckpoint=True,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 上传失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 上传失败: {e!s}")
raise CustomException(msg=f"OBS 上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -207,22 +207,22 @@ class ObsStorageAdapter(BaseStorageAdapter):
enableCheckpoint=True,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 下载失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 下载失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 下载失败: {e!s}")
raise CustomException(msg=f"OBS 下载失败: {e!s}", status_code=500)
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)}")
raise CustomException(msg=f"OBS 删除失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 删除失败: {e!s}")
raise CustomException(msg=f"OBS 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -255,7 +255,7 @@ 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)}")
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}", status_code=500)
body = getattr(resp, "body", None)
for common in getattr(body, "commonPrefixs", None) or []:
raw_key = (getattr(common, "prefix", "") or "").rstrip("/")
@@ -292,7 +292,7 @@ class ObsStorageAdapter(BaseStorageAdapter):
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 列表失败: {e!s}")
raise CustomException(msg=f"OBS 列表失败: {e!s}", status_code=500)
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(OBS marker),翻页经前端回传游标。"""
@@ -307,7 +307,7 @@ 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)}")
raise CustomException(msg=f"OBS 列表失败: {self._error_desc(resp)}", status_code=500)
body = getattr(resp, "body", None)
truncated = bool(getattr(body, "is_truncated", None))
next_cursor = getattr(body, "next_marker", None) or None
@@ -339,28 +339,28 @@ class ObsStorageAdapter(BaseStorageAdapter):
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 列表失败: {e!s}")
raise CustomException(msg=f"OBS 列表失败: {e!s}", status_code=500)
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)}")
raise CustomException(msg=f"OBS 桶列表失败: {self._error_desc(resp)}", status_code=500)
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}")
raise CustomException(msg=f"OBS 桶列表失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"OBS 生成预签名 URL 失败: {e!s}", status_code=500)
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
@@ -374,7 +374,7 @@ 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)}")
raise CustomException(msg=f"OBS 列举对象失败: {self._error_desc(resp)}", status_code=500)
body = getattr(resp, "body", None)
for obj in getattr(body, "contents", None) or []:
key = getattr(obj, "key", "")
@@ -403,21 +403,21 @@ 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)}")
raise CustomException(msg=f"OBS 创建目录失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 创建目录失败: {e!s}")
raise CustomException(msg=f"OBS 创建目录失败: {e!s}", status_code=500)
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)}")
raise CustomException(msg=f"OBS 删除目录失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 删除目录失败: {e!s}")
raise CustomException(msg=f"OBS 删除目录失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copyObject。"""
@@ -432,11 +432,11 @@ class ObsStorageAdapter(BaseStorageAdapter):
destObjectKey=dst,
)
if not self._is_ok(resp):
raise CustomException(msg=f"OBS 复制失败: {self._error_desc(resp)}")
raise CustomException(msg=f"OBS 复制失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 复制失败: {e!s}")
raise CustomException(msg=f"OBS 复制失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:优先 OBS 原生 renameFile(支持目录级),失败回退先复制后删除。"""
@@ -456,7 +456,7 @@ class ObsStorageAdapter(BaseStorageAdapter):
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"OBS 递归列表失败: {e!s}")
raise CustomException(msg=f"OBS 递归列表失败: {e!s}", status_code=500)
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -473,11 +473,11 @@ 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)}")
raise CustomException(msg=f"OBS 递归删除失败: {self._error_desc(resp)}", status_code=500)
except CustomException:
raise
except Exception as e:
raise CustomException(msg=f"OBS 递归删除失败: {e!s}")
raise CustomException(msg=f"OBS 递归删除失败: {e!s}", status_code=500)
def _sync_close(self) -> None:
"""关闭 OBS 客户端连接。"""
@@ -74,7 +74,7 @@ class OssStorageAdapter(BaseStorageAdapter):
parallel_num=concurrency,
)
except Exception as e:
raise CustomException(msg=f"OSS 上传失败: {e!s}")
raise CustomException(msg=f"OSS 上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -84,14 +84,14 @@ class OssStorageAdapter(BaseStorageAdapter):
local_path,
)
except Exception as e:
raise CustomException(msg=f"OSS 下载失败: {e!s}")
raise CustomException(msg=f"OSS 下载失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"OSS 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -140,7 +140,7 @@ class OssStorageAdapter(BaseStorageAdapter):
)
return result
except Exception as e:
raise CustomException(msg=f"OSS 列表失败: {e!s}")
raise CustomException(msg=f"OSS 列表失败: {e!s}", status_code=500)
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(OSS continuation_token),翻页经前端回传游标。"""
@@ -178,7 +178,7 @@ 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}")
raise CustomException(msg=f"OSS 列表失败: {e!s}", status_code=500)
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
@@ -191,7 +191,7 @@ class OssStorageAdapter(BaseStorageAdapter):
result.append(b.name)
return result
except Exception as e:
raise CustomException(msg=f"OSS 桶列表失败: {e!s}")
raise CustomException(msg=f"OSS 桶列表失败: {e!s}", status_code=500)
def _sync_get_url(self, remote_path: str, expire: int) -> str | None:
try:
@@ -201,7 +201,7 @@ class OssStorageAdapter(BaseStorageAdapter):
)
return result.url
except Exception as e:
raise CustomException(msg=f"OSS 生成预签名 URL 失败: {e!s}")
raise CustomException(msg=f"OSS 生成预签名 URL 失败: {e!s}", status_code=500)
# ── 目录操作(对象存储以 key/ 占位对象模拟目录)──────────────────
@@ -231,13 +231,13 @@ class OssStorageAdapter(BaseStorageAdapter):
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}")
raise CustomException(msg=f"OSS 创建目录失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"OSS 删除目录失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
@@ -249,7 +249,7 @@ class OssStorageAdapter(BaseStorageAdapter):
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}")
raise CustomException(msg=f"OSS 复制失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
@@ -263,7 +263,7 @@ class OssStorageAdapter(BaseStorageAdapter):
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"OSS 递归列表失败: {e!s}")
raise CustomException(msg=f"OSS 递归列表失败: {e!s}", status_code=500)
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -282,4 +282,4 @@ class OssStorageAdapter(BaseStorageAdapter):
)
)
except Exception as e:
raise CustomException(msg=f"OSS 递归删除失败: {e!s}")
raise CustomException(msg=f"OSS 递归删除失败: {e!s}", status_code=500)
@@ -88,21 +88,21 @@ class S3StorageAdapter(BaseStorageAdapter):
)
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}")
raise CustomException(msg=f"S3 上传失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"S3 下载失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"S3 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -163,7 +163,7 @@ class S3StorageAdapter(BaseStorageAdapter):
break
return result
except Exception as e:
raise CustomException(msg=f"S3 列表失败: {e!s}")
raise CustomException(msg=f"S3 列表失败: {e!s}", status_code=500)
def _sync_list_page(self, prefix: str, page_size: int, cursor: str | None) -> StoragePage:
"""游标分页:单次 SDK 请求只拉一页(S3 ContinuationToken),翻页经前端回传游标。"""
@@ -203,7 +203,7 @@ class S3StorageAdapter(BaseStorageAdapter):
next_cursor=resp.get("NextContinuationToken"),
)
except Exception as e:
raise CustomException(msg=f"S3 列表失败: {e!s}")
raise CustomException(msg=f"S3 列表失败: {e!s}", status_code=500)
def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶。"""
@@ -211,7 +211,7 @@ class S3StorageAdapter(BaseStorageAdapter):
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}")
raise CustomException(msg=f"S3 桶列表失败: {e!s}", status_code=500)
def _sync_get_url(self, remote_path: str, expire: int) -> str:
try:
@@ -221,7 +221,7 @@ class S3StorageAdapter(BaseStorageAdapter):
ExpiresIn=expire,
)
except Exception as e:
raise CustomException(msg=f"S3 生成预签名 URL 失败: {e!s}")
raise CustomException(msg=f"S3 生成预签名 URL 失败: {e!s}", status_code=500)
def _sync_close(self) -> None:
"""关闭 boto3 客户端连接。"""
@@ -253,13 +253,13 @@ class S3StorageAdapter(BaseStorageAdapter):
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}")
raise CustomException(msg=f"S3 创建目录失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"S3 删除目录失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用服务端 copy_object。"""
@@ -273,7 +273,7 @@ class S3StorageAdapter(BaseStorageAdapter):
Key=dst,
)
except Exception as e:
raise CustomException(msg=f"S3 复制失败: {e!s}")
raise CustomException(msg=f"S3 复制失败: {e!s}", status_code=500)
def _sync_rename(self, src: str, dst: str) -> None:
"""重命名/移动:目录先复制后删除;文件 copy_object 后删源。"""
@@ -287,7 +287,7 @@ class S3StorageAdapter(BaseStorageAdapter):
try:
keys = self._list_all_keys(prefix)
except Exception as e:
raise CustomException(msg=f"S3 递归列表失败: {e!s}")
raise CustomException(msg=f"S3 递归列表失败: {e!s}", status_code=500)
return self._entries_from_keys(keys)
def _sync_delete_dir(self, remote_dir: str) -> None:
@@ -303,4 +303,4 @@ class S3StorageAdapter(BaseStorageAdapter):
Delete={"Objects": [{"Key": k} for k in keys[i : i + 1000]], "Quiet": True},
)
except Exception as e:
raise CustomException(msg=f"S3 递归删除失败: {e!s}")
raise CustomException(msg=f"S3 递归删除失败: {e!s}", status_code=500)
@@ -118,7 +118,7 @@ class SftpStorageAdapter(BaseStorageAdapter):
self._ensure_remote_dir(client, dir_part)
client.put(local_path, remote_path)
except Exception as e:
raise CustomException(msg=f"SFTP 上传失败: {e!s}")
raise CustomException(msg=f"SFTP 上传失败: {e!s}", status_code=500)
return remote_path
def _sync_download(self, remote_path: str, local_path: str) -> str:
@@ -141,14 +141,14 @@ class SftpStorageAdapter(BaseStorageAdapter):
else:
client.get(remote_path, local_path)
except Exception as e:
raise CustomException(msg=f"SFTP 下载失败: {e!s}")
raise CustomException(msg=f"SFTP 下载失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"SFTP 删除失败: {e!s}", status_code=500)
def _sync_exists(self, remote_path: str) -> bool:
try:
@@ -162,7 +162,7 @@ class SftpStorageAdapter(BaseStorageAdapter):
# 空 prefix 表示浏览根目录:SFTP 用 "." 表示登录后的当前目录
attrs = self._connect().listdir_attr(prefix or ".")
except Exception as e:
raise CustomException(msg=f"SFTP 列表失败: {e!s}")
raise CustomException(msg=f"SFTP 列表失败: {e!s}", status_code=500)
result: list[StorageObject] = []
for attr in attrs:
result.append(
@@ -189,19 +189,19 @@ class SftpStorageAdapter(BaseStorageAdapter):
try:
self._ensure_remote_dir(self._connect(), remote_dir)
except Exception as e:
raise CustomException(msg=f"SFTP 创建目录失败: {e!s}")
raise CustomException(msg=f"SFTP 创建目录失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"SFTP 删除目录失败: {e!s}", status_code=500)
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}")
raise CustomException(msg=f"SFTP 重命名失败: {e!s}", status_code=500)
def _sync_copy(self, src: str, dst: str) -> None:
"""复制:目录走基类递归实现;文件用通道流式复制(SFTP 无服务端复制)。"""
@@ -214,4 +214,4 @@ class SftpStorageAdapter(BaseStorageAdapter):
while chunk := rf.read(1024 * 1024):
wf.write(chunk)
except Exception as e:
raise CustomException(msg=f"SFTP 复制失败: {e!s}")
raise CustomException(msg=f"SFTP 复制失败: {e!s}", status_code=500)