mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat: body rules 支持嵌套路径并增强前端验证体验
- 后端 apply_body_rules 支持点号分隔的嵌套路径(如 metadata.user.name) - 支持 \. 转义字面量点号(如 config\.v1.enabled) - 配置导出导入升级至 v2.2,新增 SystemConfig 支持 - 前端请求体规则编辑器改用 JSON 格式输入,增加实时验证指示 - 用量记录表格新增移动端卡片视图,优化筛选器响应式布局
This commit is contained in:
@@ -910,6 +910,8 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
||||
)
|
||||
|
||||
# 导出 Provider Models
|
||||
# 注意:提供商模型(Model)必须关联全局模型(GlobalModel)才能参与路由
|
||||
# 导入时未关联 GlobalModel 的模型会被跳过,这是业务规则而非 bug
|
||||
models = db.query(Model).filter(Model.provider_id == provider.id).all()
|
||||
models_data = []
|
||||
for model in models:
|
||||
@@ -988,6 +990,27 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
||||
"connect_timeout": ldap_config.connect_timeout,
|
||||
}
|
||||
|
||||
# 导出 SystemConfig 配置
|
||||
from src.models.database import SystemConfig
|
||||
|
||||
# 敏感配置项需要解密导出
|
||||
SENSITIVE_CONFIG_KEYS = {"smtp_password"}
|
||||
system_configs = db.query(SystemConfig).all()
|
||||
system_configs_data = []
|
||||
for cfg in system_configs:
|
||||
cfg_data = {
|
||||
"key": cfg.key,
|
||||
"value": cfg.value,
|
||||
"description": cfg.description,
|
||||
}
|
||||
# 解密敏感配置
|
||||
if cfg.key in SENSITIVE_CONFIG_KEYS and cfg.value:
|
||||
try:
|
||||
cfg_data["value"] = crypto_service.decrypt(cfg.value)
|
||||
except Exception as e:
|
||||
logger.debug(f"解密 SystemConfig '{cfg.key}' 失败: {e}")
|
||||
system_configs_data.append(cfg_data)
|
||||
|
||||
# 导出 OAuth Providers 配置
|
||||
from src.models.database import OAuthProvider
|
||||
|
||||
@@ -1021,12 +1044,13 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
||||
)
|
||||
|
||||
return {
|
||||
"version": "2.1",
|
||||
"version": "2.2",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"global_models": global_models_data,
|
||||
"providers": providers_data,
|
||||
"ldap_config": ldap_data,
|
||||
"oauth_providers": oauth_data,
|
||||
"system_configs": system_configs_data,
|
||||
}
|
||||
|
||||
|
||||
@@ -1086,9 +1110,9 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
# 验证配置版本(支持 2.0 和 2.1)
|
||||
# 验证配置版本(支持 2.0、2.1 和 2.2)
|
||||
version = payload.get("version")
|
||||
if version not in ("2.0", "2.1"):
|
||||
if version not in ("2.0", "2.1", "2.2"):
|
||||
raise InvalidRequestException(f"不支持的配置版本: {version}")
|
||||
|
||||
# 获取导入选项
|
||||
@@ -1097,6 +1121,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
providers_data = payload.get("providers", [])
|
||||
ldap_data = payload.get("ldap_config") # 2.1 新增
|
||||
oauth_data = payload.get("oauth_providers", []) # 2.1 新增
|
||||
system_configs_data = payload.get("system_configs", []) # 2.2 新增
|
||||
|
||||
stats = {
|
||||
"global_models": {"created": 0, "updated": 0, "skipped": 0},
|
||||
@@ -1106,6 +1131,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
"models": {"created": 0, "updated": 0, "skipped": 0},
|
||||
"ldap": {"created": 0, "updated": 0, "skipped": 0},
|
||||
"oauth": {"created": 0, "updated": 0, "skipped": 0},
|
||||
"system_configs": {"created": 0, "updated": 0, "skipped": 0}, # 2.2 新增
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
@@ -1402,6 +1428,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
stats["keys_to_fetch"].append(new_key.id)
|
||||
|
||||
# 导入 Models
|
||||
# 注意:提供商模型(Model)必须关联全局模型(GlobalModel)才能参与路由
|
||||
# 未关联 GlobalModel 的模型会被跳过,这是业务规则而非 bug
|
||||
for model_data in prov_data.get("models", []):
|
||||
global_model_name = model_data.get("global_model_name")
|
||||
if not global_model_name:
|
||||
@@ -1653,6 +1681,49 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
db.add(new_oauth)
|
||||
stats["oauth"]["created"] += 1
|
||||
|
||||
# 导入 SystemConfig(2.2 新增)
|
||||
if system_configs_data:
|
||||
from src.models.database import SystemConfig
|
||||
|
||||
# 敏感配置项需要加密存储
|
||||
SENSITIVE_CONFIG_KEYS = {"smtp_password"}
|
||||
|
||||
for cfg_item in system_configs_data:
|
||||
cfg_key = cfg_item.get("key")
|
||||
if not cfg_key:
|
||||
stats["errors"].append("跳过无 key 的 SystemConfig 配置")
|
||||
continue
|
||||
|
||||
existing_cfg = (
|
||||
db.query(SystemConfig).filter(SystemConfig.key == cfg_key).first()
|
||||
)
|
||||
|
||||
cfg_value = cfg_item.get("value")
|
||||
# 加密敏感配置
|
||||
if cfg_key in SENSITIVE_CONFIG_KEYS and cfg_value:
|
||||
cfg_value = crypto_service.encrypt(cfg_value)
|
||||
|
||||
if existing_cfg:
|
||||
if merge_mode == "skip":
|
||||
stats["system_configs"]["skipped"] += 1
|
||||
elif merge_mode == "error":
|
||||
raise InvalidRequestException(f"SystemConfig '{cfg_key}' 已存在")
|
||||
elif merge_mode == "overwrite":
|
||||
existing_cfg.value = cfg_value
|
||||
existing_cfg.description = cfg_item.get(
|
||||
"description", existing_cfg.description
|
||||
)
|
||||
existing_cfg.updated_at = datetime.now(timezone.utc)
|
||||
stats["system_configs"]["updated"] += 1
|
||||
else:
|
||||
new_cfg = SystemConfig(
|
||||
key=cfg_key,
|
||||
value=cfg_value,
|
||||
description=cfg_item.get("description"),
|
||||
)
|
||||
db.add(new_cfg)
|
||||
stats["system_configs"]["created"] += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
# 失效缓存
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
@@ -147,6 +148,150 @@ def build_test_request_body(
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
def _parse_path(path: str) -> list[str]:
|
||||
"""
|
||||
解析点号路径,支持转义(用 \\.
|
||||
表示字面量点号)。
|
||||
|
||||
Examples:
|
||||
"metadata.user.name" -> ["metadata", "user", "name"]
|
||||
"config\\.v1.enabled" -> ["config.v1", "enabled"]
|
||||
|
||||
约束:
|
||||
- 不允许空段(例如:".a" / "a." / "a..b"),遇到则返回空列表表示无效路径。
|
||||
- 仅对 "\\." 做特殊处理;其他反斜杠组合按字面量保留。
|
||||
"""
|
||||
raw = (path or "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
parts: list[str] = []
|
||||
current: list[str] = []
|
||||
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
ch = raw[i]
|
||||
if ch == "\\" and i + 1 < len(raw) and raw[i + 1] == ".":
|
||||
current.append(".")
|
||||
i += 2
|
||||
continue
|
||||
|
||||
if ch == ".":
|
||||
if not current:
|
||||
return []
|
||||
parts.append("".join(current))
|
||||
current = []
|
||||
i += 1
|
||||
continue
|
||||
|
||||
current.append(ch)
|
||||
i += 1
|
||||
|
||||
if not current:
|
||||
return []
|
||||
|
||||
parts.append("".join(current))
|
||||
return parts
|
||||
|
||||
|
||||
def _get_nested_value(obj: dict[str, Any], path: str) -> tuple[bool, Any]:
|
||||
"""
|
||||
获取嵌套值
|
||||
|
||||
Returns:
|
||||
(found, value) - found 为 True 时 value 有效
|
||||
"""
|
||||
parts = _parse_path(path)
|
||||
if not parts:
|
||||
return False, None
|
||||
|
||||
current: Any = obj
|
||||
for key in parts:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return False, None
|
||||
return True, current
|
||||
|
||||
|
||||
def _set_nested_value(obj: dict[str, Any], path: str, value: Any) -> bool:
|
||||
"""
|
||||
设置嵌套值,自动创建中间层级。
|
||||
|
||||
当中间层存在但不是 dict 时,会覆盖为 dict 后继续写入(覆写语义)。
|
||||
|
||||
Returns:
|
||||
True: 写入成功
|
||||
False: 路径无效(空/含空段等)
|
||||
"""
|
||||
parts = _parse_path(path)
|
||||
if not parts:
|
||||
return False
|
||||
|
||||
current: dict[str, Any] = obj
|
||||
for key in parts[:-1]:
|
||||
next_val = current.get(key)
|
||||
if not isinstance(next_val, dict):
|
||||
next_val = {}
|
||||
current[key] = next_val
|
||||
current = next_val
|
||||
|
||||
current[parts[-1]] = value
|
||||
return True
|
||||
|
||||
|
||||
def _delete_nested_value(obj: dict[str, Any], path: str) -> bool:
|
||||
"""
|
||||
删除嵌套值
|
||||
|
||||
Returns:
|
||||
True: 删除成功
|
||||
False: 路径不存在或无效
|
||||
"""
|
||||
parts = _parse_path(path)
|
||||
if not parts:
|
||||
return False
|
||||
|
||||
current: Any = obj
|
||||
for key in parts[:-1]:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return False
|
||||
if not isinstance(current, dict):
|
||||
return False
|
||||
|
||||
if isinstance(current, dict) and parts[-1] in current:
|
||||
del current[parts[-1]]
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _rename_nested_value(obj: dict[str, Any], from_path: str, to_path: str) -> bool:
|
||||
"""
|
||||
重命名嵌套值(移动到新路径)
|
||||
|
||||
Returns:
|
||||
True: 重命名成功
|
||||
False: 源路径不存在或路径无效
|
||||
"""
|
||||
src = (from_path or "").strip()
|
||||
dst = (to_path or "").strip()
|
||||
if not src or not dst:
|
||||
return False
|
||||
if src == dst:
|
||||
found, _ = _get_nested_value(obj, src)
|
||||
return found
|
||||
|
||||
found, value = _get_nested_value(obj, src)
|
||||
if not found:
|
||||
return False
|
||||
|
||||
_delete_nested_value(obj, src)
|
||||
_set_nested_value(obj, dst, value)
|
||||
return True
|
||||
|
||||
|
||||
def apply_body_rules(
|
||||
body: dict[str, Any],
|
||||
rules: list[dict[str, Any]],
|
||||
@@ -155,10 +300,14 @@ def apply_body_rules(
|
||||
"""
|
||||
应用请求体规则
|
||||
|
||||
路径语法:
|
||||
- 使用点号分隔层级:metadata.user.name
|
||||
- 转义字面量点号:config\\.v1.enabled -> key "config.v1" 下的 "enabled"
|
||||
|
||||
支持的规则类型:
|
||||
- set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
||||
- set: 设置/覆盖字段 {"action": "set", "path": "metadata.user_id", "value": 123}
|
||||
- drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||
- rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
||||
- rename: 重命名字段 {"action": "rename", "from": "old.key", "to": "new.key"}
|
||||
|
||||
Args:
|
||||
body: 原始请求体
|
||||
@@ -171,32 +320,64 @@ def apply_body_rules(
|
||||
if not rules:
|
||||
return body
|
||||
|
||||
# 复制一份,避免修改原始数据
|
||||
result = dict(body)
|
||||
# 深拷贝,避免修改原始数据(尤其是嵌套 dict)
|
||||
result = copy.deepcopy(body)
|
||||
protected = protected_keys or PROTECTED_BODY_FIELDS
|
||||
protected_lower = frozenset(str(k).lower() for k in protected)
|
||||
|
||||
for rule in rules:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
|
||||
action = rule.get("action")
|
||||
if not isinstance(action, str):
|
||||
continue
|
||||
action = action.strip().lower()
|
||||
|
||||
if action == "set":
|
||||
path = rule.get("path", "")
|
||||
raw_path = rule.get("path", "")
|
||||
if not isinstance(raw_path, str):
|
||||
continue
|
||||
path = raw_path.strip()
|
||||
value = rule.get("value")
|
||||
if path and path not in protected:
|
||||
result[path] = value
|
||||
parts = _parse_path(path)
|
||||
if not parts:
|
||||
continue
|
||||
if parts[0].lower() in protected_lower:
|
||||
continue
|
||||
_set_nested_value(result, path, value)
|
||||
|
||||
elif action == "drop":
|
||||
path = rule.get("path", "")
|
||||
if path and path not in protected:
|
||||
result.pop(path, None)
|
||||
raw_path = rule.get("path", "")
|
||||
if not isinstance(raw_path, str):
|
||||
continue
|
||||
path = raw_path.strip()
|
||||
parts = _parse_path(path)
|
||||
if not parts:
|
||||
continue
|
||||
if parts[0].lower() in protected_lower:
|
||||
continue
|
||||
_delete_nested_value(result, path)
|
||||
|
||||
elif action == "rename":
|
||||
from_key = rule.get("from", "")
|
||||
to_key = rule.get("to", "")
|
||||
if from_key and to_key:
|
||||
# 两个 key 都不能是受保护的
|
||||
if from_key not in protected and to_key not in protected:
|
||||
if from_key in result:
|
||||
result[to_key] = result.pop(from_key)
|
||||
raw_from = rule.get("from", "")
|
||||
raw_to = rule.get("to", "")
|
||||
if not isinstance(raw_from, str) or not isinstance(raw_to, str):
|
||||
continue
|
||||
from_path = raw_from.strip()
|
||||
to_path = raw_to.strip()
|
||||
if not from_path or not to_path:
|
||||
continue
|
||||
from_parts = _parse_path(from_path)
|
||||
to_parts = _parse_path(to_path)
|
||||
if not from_parts or not to_parts:
|
||||
continue
|
||||
|
||||
# 受保护字段只检查顶层 key
|
||||
if from_parts[0].lower() in protected_lower or to_parts[0].lower() in protected_lower:
|
||||
continue
|
||||
|
||||
_rename_nested_value(result, from_path, to_path)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
Reference in New Issue
Block a user