mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
651 lines
23 KiB
Python
651 lines
23 KiB
Python
"""
|
||
系统管理 API 的应用用例与外部能力端口。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||
from contextlib import AbstractAsyncContextManager, AbstractContextManager
|
||
from dataclasses import dataclass
|
||
from typing import TYPE_CHECKING, Any, Protocol
|
||
from urllib.parse import urlparse
|
||
|
||
from app.application.classification.contract import ClassificationPolicyStateCorruptError
|
||
from app.application.configuration import RuntimeSettingsService, SystemConfigService
|
||
from app.runtime.scheduling import TimerUtils
|
||
from app.schemas.common import JsonData
|
||
from app.schemas.exception import PluginMutationRejectedError
|
||
from app.schemas.system import SystemUpdateStatus, SystemUpdateTarget
|
||
from app.schemas.types import SystemConfigKey
|
||
|
||
if TYPE_CHECKING:
|
||
from app.application.community.medals import CommunityMedalService
|
||
from app.application.github_auth import GithubAuthService
|
||
from app.application.rules import AsyncRuleGroupMutationService
|
||
|
||
PLUGIN_MARKET_WIKI_URL = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Wiki/main/plugin.md"
|
||
_DATABASE_BACKUP_SETTING_KEYS = {
|
||
"DB_BACKUP_ENABLE",
|
||
"DB_BACKUP_CRON",
|
||
"DB_BACKUP_ON_UPGRADE",
|
||
"DB_BACKUP_PATH",
|
||
"DB_BACKUP_RETENTION_DAYS",
|
||
"DB_BACKUP_MAX_COUNT",
|
||
}
|
||
|
||
|
||
class LogNotFoundError(FileNotFoundError):
|
||
"""
|
||
表示请求的日志标识或文件不属于可读取日志集合。
|
||
"""
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class LogFileData:
|
||
"""
|
||
日志归档中的稳定文件名与内容。
|
||
|
||
:param name: 日志归档中使用的相对文件名
|
||
:param content: 日志文件的完整字节内容
|
||
"""
|
||
|
||
name: str
|
||
content: bytes
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class SystemOperationResult:
|
||
"""
|
||
系统管理用例返回给传输层的稳定执行结果。
|
||
|
||
:param success: 系统操作是否成功或已受理
|
||
:param message: 可向用户展示的结果或错误说明,None 表示无需额外说明
|
||
:param data: 操作返回的附加业务数据,None 表示没有附加数据
|
||
"""
|
||
|
||
success: bool
|
||
message: str | None = None
|
||
data: Any = None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class MarketFetchResult:
|
||
"""
|
||
Wiki 市场清单传输端口返回的状态与仓库列表。
|
||
|
||
:param status_code: Wiki 文档请求的 HTTP 状态码,未获得响应时为 None
|
||
:param repos: 从 Wiki 文档中解析并规范化的市场仓库地址列表
|
||
"""
|
||
|
||
status_code: int | None
|
||
repos: list[str]
|
||
|
||
|
||
class SystemLogPort(Protocol):
|
||
"""
|
||
日志文件发现、读取与轮询的异步端口。
|
||
"""
|
||
|
||
async def read(self, logfile: str) -> str:
|
||
"""
|
||
读取一个受准入保护的日志文件。
|
||
|
||
:param logfile: 受准入规则约束的日志文件标识,不是任意文件系统路径
|
||
:return: 日志文件的文本内容
|
||
"""
|
||
...
|
||
|
||
async def follow(
|
||
self,
|
||
logfile: str,
|
||
length: int,
|
||
disconnected: Callable[[], Awaitable[bool]],
|
||
) -> AsyncIterator[str]:
|
||
"""
|
||
返回历史尾部及后续新增日志行。
|
||
|
||
:param logfile: 受准入规则约束的日志文件标识
|
||
:param length: 首次发送的历史尾部行数
|
||
:param disconnected: 异步判断客户端是否断开,返回 True 时停止继续跟随
|
||
:return: 先包含历史尾部、随后产生新增日志行的异步迭代器
|
||
"""
|
||
...
|
||
|
||
async def collect(self, name: str) -> list[LogFileData]:
|
||
"""
|
||
读取一个主程序或插件日志归档集合。
|
||
|
||
:param name: 主程序或插件日志集合的名称
|
||
:return: 可供归档下载的日志文件名及字节内容列表
|
||
"""
|
||
...
|
||
|
||
|
||
class PluginMarketPort(Protocol):
|
||
"""
|
||
读取并解析 Wiki 插件市场清单的传输端口。
|
||
"""
|
||
|
||
async def fetch(self, url: str) -> MarketFetchResult:
|
||
"""
|
||
读取指定 Wiki 文档并返回规范化仓库列表。
|
||
|
||
:param url: 待读取的 Wiki 市场清单文档 URL
|
||
:return: HTTP 状态码及解析后的规范化市场仓库列表
|
||
"""
|
||
...
|
||
|
||
|
||
class ConfigurationEventPort(Protocol):
|
||
"""
|
||
发布系统配置变更事实的端口。
|
||
"""
|
||
|
||
async def publish(self, key: Any, value: JsonData = None) -> None:
|
||
"""
|
||
发布已持久化的配置变更,并在返回前完成同步重载处理器。
|
||
|
||
:param key: 已提交变更的系统配置键
|
||
:param value: 已持久化的配置值,None 表示无附加值或值为空
|
||
"""
|
||
...
|
||
|
||
|
||
class ServerInfoPort(Protocol):
|
||
"""
|
||
MoviePilot Server 查询能力的最小端口。
|
||
"""
|
||
|
||
async def user_global(self) -> dict[str, Any]:
|
||
"""
|
||
返回当前用户的服务端权限投影。
|
||
|
||
:return: 当前用户在 MoviePilot Server 上的权限与权益数据
|
||
"""
|
||
...
|
||
|
||
async def usage(self) -> dict[str, Any]:
|
||
"""
|
||
返回匿名安装版本统计。
|
||
|
||
:return: MoviePilot Server 提供的匿名安装版本统计数据
|
||
"""
|
||
...
|
||
|
||
|
||
class ReleaseCatalogPort(Protocol):
|
||
"""
|
||
读取 GitHub Release 目录的传输端口。
|
||
"""
|
||
|
||
async def list(self) -> list[dict[str, Any]] | None:
|
||
"""
|
||
返回 Release 列表,读取失败时返回空值。
|
||
|
||
:return: 版本发布条目列表,读取失败时为 None
|
||
"""
|
||
...
|
||
|
||
|
||
class RuntimeFeaturePort(Protocol):
|
||
"""
|
||
查询本机可选运行能力的端口。
|
||
"""
|
||
|
||
def snapshot(self) -> dict[str, bool]:
|
||
"""
|
||
返回 Rust 加速等本机能力快照。
|
||
|
||
:return: 本机可选功能名称到可用状态的映射
|
||
"""
|
||
...
|
||
|
||
|
||
class SystemControlPort(Protocol):
|
||
"""
|
||
主进程更新和重启控制端口。
|
||
"""
|
||
|
||
def can_restart(self) -> bool:
|
||
"""
|
||
返回当前部署环境是否支持受管重启。
|
||
|
||
:return: 当前部署支持宿主管理的重启时为 True
|
||
"""
|
||
...
|
||
|
||
def restart(self) -> tuple[bool, str]:
|
||
"""
|
||
请求重启并返回兼容结果。
|
||
|
||
:return: 重启请求是否受理及说明;受理后由宿主生命周期执行重启
|
||
"""
|
||
...
|
||
|
||
def upgrade_dev(self) -> tuple[bool, str]:
|
||
"""
|
||
执行开发分支更新。
|
||
|
||
:return: 开发分支更新是否成功及结果说明
|
||
"""
|
||
...
|
||
|
||
|
||
class SystemUpdatePort(Protocol):
|
||
"""
|
||
后台 Release 更新状态机端口。
|
||
"""
|
||
|
||
def status(self) -> SystemUpdateStatus:
|
||
"""
|
||
读取当前后台更新状态。
|
||
|
||
:return: 主程序和资源更新项的当前阶段、版本与下载进度
|
||
"""
|
||
...
|
||
|
||
def check(self) -> SystemUpdateStatus:
|
||
"""
|
||
立即检查正式版本更新。
|
||
|
||
:return: 主程序和资源更新项的当前阶段、版本与下载进度
|
||
"""
|
||
...
|
||
|
||
def download(self, target: SystemUpdateTarget = "application") -> SystemUpdateStatus:
|
||
"""
|
||
启动指定升级类型的后台下载并返回当前状态。
|
||
|
||
:param target: 更新目标:application 为主程序,resources 为资源,all 为全部,默认主程序
|
||
:return: 启动下载后立即可见的更新状态,后续进度需继续查询
|
||
"""
|
||
...
|
||
|
||
def prepare_install(self, target: SystemUpdateTarget = "application") -> tuple[bool, str]:
|
||
"""
|
||
确认消费指定升级类型的已校验制品。
|
||
|
||
:param target: 更新目标:application 为主程序,resources 为资源,all 为全部,默认主程序
|
||
:return: 是否成功准备安装及说明;实际应用制品由后续重启流程完成
|
||
"""
|
||
...
|
||
|
||
def cancel_install(self, message: str) -> None:
|
||
"""
|
||
在重启失败时撤销安装请求。
|
||
|
||
:param message: 撤销安装请求的原因,供状态展示与排查
|
||
"""
|
||
...
|
||
|
||
|
||
class LlmCapabilityPort(Protocol):
|
||
"""
|
||
校验 LLM 服务端工具能力的端口。
|
||
"""
|
||
|
||
def validate(self, env: dict[str, Any], settings: RuntimeSettingsService) -> str | None:
|
||
"""
|
||
校验本次设置变更是否请求了不可用能力。
|
||
|
||
:param env: 本次请求修改的 LLM 设置字段与新值
|
||
:param settings: 读取未被本次请求覆盖的现有运行设置
|
||
:return: 不可用能力的说明;校验通过时为 None
|
||
"""
|
||
...
|
||
|
||
|
||
class SystemService:
|
||
"""
|
||
集中执行 System API 的日志、设置、市场和进程控制用例。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
settings: RuntimeSettingsService,
|
||
system_config: SystemConfigService,
|
||
logs: SystemLogPort,
|
||
market: PluginMarketPort,
|
||
events: ConfigurationEventPort,
|
||
server: ServerInfoPort,
|
||
releases: ReleaseCatalogPort,
|
||
features: RuntimeFeaturePort,
|
||
control: SystemControlPort,
|
||
updates: SystemUpdatePort,
|
||
llm: LlmCapabilityPort,
|
||
plugin_mutation: Callable[[str], AbstractContextManager[None]],
|
||
rule_group_mutation: Callable[[], AbstractAsyncContextManager[AsyncRuleGroupMutationService]],
|
||
github_auth: GithubAuthService | None = None,
|
||
github_community: CommunityMedalService | None = None,
|
||
) -> None:
|
||
"""
|
||
保存由启动组合根注入的全部外部端口和 GitHub 授权服务。
|
||
"""
|
||
self.settings = settings
|
||
self._system_config = system_config
|
||
self._logs = logs
|
||
self._market = market
|
||
self._events = events
|
||
self._server = server
|
||
self._releases = releases
|
||
self._features = features
|
||
self._control = control
|
||
self._updates = updates
|
||
self._llm = llm
|
||
self._plugin_mutation = plugin_mutation
|
||
self._rule_group_mutation = rule_group_mutation
|
||
self.github_auth = github_auth
|
||
self.github_community = github_community
|
||
|
||
async def publish_config_changed(
|
||
self,
|
||
key: Any,
|
||
value: JsonData = None,
|
||
) -> None:
|
||
"""
|
||
发布一项已经持久化完成的系统配置变更。
|
||
"""
|
||
await self._events.publish(key, value)
|
||
|
||
async def read_log(self, logfile: str) -> str:
|
||
"""
|
||
读取日志并按旧接口语义倒序返回。
|
||
"""
|
||
text = await self._logs.read(logfile)
|
||
return "\n".join(text.split("\n")[::-1])
|
||
|
||
async def follow_log(
|
||
self,
|
||
logfile: str,
|
||
length: int,
|
||
disconnected: Callable[[], Awaitable[bool]],
|
||
) -> AsyncIterator[str]:
|
||
"""
|
||
取得日志轮询数据源,传输层只负责 SSE framing。
|
||
"""
|
||
return await self._logs.follow(logfile, length, disconnected)
|
||
|
||
async def collect_logs(self, name: str) -> list[LogFileData]:
|
||
"""
|
||
读取日志归档条目,传输层只负责 ZIP framing。
|
||
"""
|
||
return await self._logs.collect(name)
|
||
|
||
async def update_environment(self, env: dict[str, Any]) -> SystemOperationResult:
|
||
"""
|
||
校验并执行一批可变部署设置更新。
|
||
"""
|
||
validation_error = self._llm.validate(env, self.settings)
|
||
if validation_error:
|
||
return SystemOperationResult(False, validation_error)
|
||
validation_error = self._validate_database_backup_config(env)
|
||
if validation_error:
|
||
return SystemOperationResult(False, validation_error)
|
||
result = self.settings.update_many(env)
|
||
success_updates = {key: value for key, value in result.items() if value[0]}
|
||
failed_updates = {key: value for key, value in result.items() if value[0] is False}
|
||
if self.github_community is not None and {"GITHUB_TOKEN", "COMMUNITY_MEDALS_ENABLE"}.intersection(success_updates):
|
||
self.github_community.invalidate()
|
||
if "GITHUB_TOKEN" in success_updates and self.github_auth is not None:
|
||
await self.github_auth.forget_oauth_metadata()
|
||
if failed_updates:
|
||
if success_updates:
|
||
# update_many 已经逐项提交,部分失败不能吞掉已成功配置的实时重载。
|
||
await self._events.publish(success_updates.keys())
|
||
return SystemOperationResult(
|
||
False,
|
||
", ".join(value[1] for value in failed_updates.values()),
|
||
{"success_updates": success_updates, "failed_updates": failed_updates},
|
||
)
|
||
if success_updates:
|
||
await self._events.publish(success_updates.keys())
|
||
return SystemOperationResult(
|
||
True,
|
||
"所有配置项更新成功",
|
||
{"success_updates": success_updates},
|
||
)
|
||
|
||
async def sync_plugin_market(self, wiki_url: str | None) -> SystemOperationResult:
|
||
"""
|
||
读取 Wiki 清单、合并本地仓库并更新运行设置。
|
||
"""
|
||
source_url = (wiki_url or PLUGIN_MARKET_WIKI_URL).strip()
|
||
if not self._is_allowed_wiki_url(source_url):
|
||
return SystemOperationResult(False, "不支持的 Wiki 同步地址")
|
||
fetched = await self._market.fetch(source_url)
|
||
if fetched.status_code is None:
|
||
return SystemOperationResult(False, "无法访问 Wiki 插件仓库清单")
|
||
if fetched.status_code != 200:
|
||
return SystemOperationResult(False, f"访问 Wiki 插件仓库清单失败,状态码:{fetched.status_code}")
|
||
if not fetched.repos:
|
||
return SystemOperationResult(False, "未在 Wiki 中识别到插件仓库地址")
|
||
local_repos = self._split_repos(self.settings.get("PLUGIN_MARKET", ""))
|
||
local_keys = {repo.lower() for repo in local_repos}
|
||
added_count = sum(repo.lower() not in local_keys for repo in fetched.repos)
|
||
merged_repos = self._merge_repos(local_repos, fetched.repos)
|
||
merged_value = ",".join(merged_repos)
|
||
success, message = self.settings.update("PLUGIN_MARKET", merged_value)
|
||
if success:
|
||
await self._events.publish("PLUGIN_MARKET", merged_value)
|
||
elif success is None:
|
||
success = True
|
||
return SystemOperationResult(
|
||
bool(success),
|
||
message,
|
||
{
|
||
"value": merged_value,
|
||
"repos": merged_repos,
|
||
"wiki_repos": fetched.repos,
|
||
"added_count": added_count,
|
||
"total_count": len(merged_repos),
|
||
"source_url": source_url,
|
||
},
|
||
)
|
||
|
||
async def update_setting(self, key: str, value: JsonData) -> SystemOperationResult:
|
||
"""
|
||
按配置所有权更新运行设置或持久化系统配置。
|
||
"""
|
||
if self.settings.contains(key):
|
||
success, message = self.settings.update(key, value)
|
||
if success:
|
||
await self._events.publish(key, value)
|
||
elif success is None:
|
||
success = True
|
||
return SystemOperationResult(bool(success), message)
|
||
if key not in {item.value for item in SystemConfigKey}:
|
||
return SystemOperationResult(False, f"配置项 '{key}' 不存在")
|
||
if isinstance(value, list):
|
||
value = list(filter(None, value)) or None
|
||
try:
|
||
with self._plugin_mutation(key):
|
||
event_value: JsonData = value
|
||
if key == SystemConfigKey.UserFilterRuleGroups.value:
|
||
current = self._system_config.get(SystemConfigKey.UserFilterRuleGroups)
|
||
expected = self._dict_list(current)
|
||
definitions = self._dict_list(value)
|
||
async with self._rule_group_mutation() as mutation:
|
||
await mutation.apply(definitions, expected_rule_groups=expected)
|
||
event_value = list[JsonData](definitions)
|
||
success = True
|
||
else:
|
||
write_result = (
|
||
await self._system_config.async_set_with_normalized_value(
|
||
key,
|
||
value,
|
||
)
|
||
)
|
||
success = write_result.changed
|
||
event_value = write_result.normalized_value
|
||
if success:
|
||
await self._events.publish(key, event_value)
|
||
# None 表示值未变化,仍是成功完成;只有明确 False 才代表持久化失败。
|
||
return SystemOperationResult(
|
||
success is not False,
|
||
data=event_value if key == SystemConfigKey.Directories.value else None,
|
||
)
|
||
except (
|
||
ClassificationPolicyStateCorruptError,
|
||
PluginMutationRejectedError,
|
||
ValueError,
|
||
) as error:
|
||
return SystemOperationResult(False, str(error))
|
||
|
||
async def user_global(self) -> dict[str, Any]:
|
||
"""
|
||
返回 MoviePilot Server 用户权限投影。
|
||
"""
|
||
return await self._server.user_global()
|
||
|
||
async def usage(self) -> dict[str, Any]:
|
||
"""
|
||
返回安装版本统计。
|
||
"""
|
||
return await self._server.usage()
|
||
|
||
async def releases(self) -> list[dict[str, Any]] | None:
|
||
"""
|
||
返回 GitHub Release 目录。
|
||
"""
|
||
return await self._releases.list()
|
||
|
||
def runtime_features(self) -> dict[str, bool]:
|
||
"""
|
||
返回本机可选运行能力快照。
|
||
"""
|
||
return self._features.snapshot()
|
||
|
||
def restart(self) -> SystemOperationResult:
|
||
"""
|
||
执行受管重启。
|
||
"""
|
||
if not self._control.can_restart():
|
||
return SystemOperationResult(False, "当前运行环境不支持重启操作!")
|
||
success, message = self._control.restart()
|
||
return SystemOperationResult(success, message)
|
||
|
||
def upgrade(self, mode: str | None) -> SystemOperationResult:
|
||
"""
|
||
仅保留兼容的开发分支更新入口。
|
||
"""
|
||
if str(mode or "").strip().lower() != "dev":
|
||
return SystemOperationResult(False, "Release 更新请使用 /system/update/check、download 和 install 接口")
|
||
if not self._control.can_restart():
|
||
return SystemOperationResult(False, "当前运行环境不支持升级操作!")
|
||
success, message = self._control.upgrade_dev()
|
||
return SystemOperationResult(success, message)
|
||
|
||
def update_status(self) -> SystemUpdateStatus:
|
||
"""
|
||
读取后台更新状态。
|
||
"""
|
||
return self._updates.status()
|
||
|
||
def check_update(self) -> SystemUpdateStatus:
|
||
"""
|
||
立即检查正式版本更新。
|
||
"""
|
||
return self._updates.check()
|
||
|
||
def download_update(self, target: SystemUpdateTarget = "application") -> SystemOperationResult:
|
||
"""
|
||
校验环境并启动指定升级类型的后台下载。
|
||
"""
|
||
if not self._control.can_restart():
|
||
return SystemOperationResult(False, "当前运行环境不支持升级操作!")
|
||
status = self._updates.download(target)
|
||
if status.updates:
|
||
errors = [item.error for item in status.updates if (target == "all" or item.type == target) and item.error]
|
||
return SystemOperationResult(not errors, ";".join(errors) if errors else None, status)
|
||
return SystemOperationResult(status.state != "failed", status.error, status)
|
||
|
||
def install_update(self, target: SystemUpdateTarget = "application") -> SystemOperationResult:
|
||
"""
|
||
确认指定制品并安排 Docker worker 在受管重启前完成替换。
|
||
"""
|
||
if not self._control.can_restart():
|
||
return SystemOperationResult(False, "当前运行环境不支持升级操作!")
|
||
prepared, message = self._updates.prepare_install(target)
|
||
if not prepared:
|
||
return SystemOperationResult(False, message)
|
||
success, restart_message = self._control.restart()
|
||
if not success:
|
||
self._updates.cancel_install(restart_message)
|
||
return SystemOperationResult(False, restart_message)
|
||
return SystemOperationResult(True, message)
|
||
|
||
def _validate_database_backup_config(self, env: dict[str, Any]) -> str | None:
|
||
"""
|
||
在批量写入前校验数据库备份策略。
|
||
"""
|
||
if not _DATABASE_BACKUP_SETTING_KEYS.intersection(env):
|
||
return None
|
||
cron = str(env.get("DB_BACKUP_CRON", self.settings.get("DB_BACKUP_CRON")) or "").strip()
|
||
if cron:
|
||
try:
|
||
TimerUtils.normalize_schedule_trigger("cron", cron, self.settings.get("TZ"))
|
||
except (TypeError, ValueError):
|
||
return "数据库备份周期格式不正确"
|
||
backup_path = env.get("DB_BACKUP_PATH", self.settings.get("DB_BACKUP_PATH"))
|
||
if backup_path is not None and not isinstance(backup_path, str):
|
||
return "数据库备份目录必须是路径字符串"
|
||
for key, label in (
|
||
("DB_BACKUP_RETENTION_DAYS", "数据库备份过期天数"),
|
||
("DB_BACKUP_MAX_COUNT", "数据库备份最大保留份数"),
|
||
):
|
||
value = env.get(key, self.settings.get(key))
|
||
if isinstance(value, bool):
|
||
return f"{label}必须是大于等于 0 的整数"
|
||
try:
|
||
converted = int(value)
|
||
except (TypeError, ValueError):
|
||
return f"{label}必须是大于等于 0 的整数"
|
||
if converted < 0 or str(value).strip() != str(converted):
|
||
return f"{label}必须是大于等于 0 的整数"
|
||
return None
|
||
|
||
@staticmethod
|
||
def _is_allowed_wiki_url(wiki_url: str) -> bool:
|
||
"""
|
||
限制 Wiki 同步到维护者控制的固定文档源。
|
||
"""
|
||
parsed = urlparse(wiki_url)
|
||
return (
|
||
parsed.scheme == "https"
|
||
and (parsed.hostname or "").lower() == "raw.githubusercontent.com"
|
||
and bool(re.fullmatch(r"/jxxghp/MoviePilot-Wiki/[^/]+/plugin\.md", parsed.path))
|
||
)
|
||
|
||
@staticmethod
|
||
def _split_repos(value: Any) -> list[str]:
|
||
"""
|
||
拆分并规范化逗号分隔的仓库地址。
|
||
"""
|
||
return SystemService._merge_repos([], str(value or "").split(","))
|
||
|
||
@staticmethod
|
||
def _merge_repos(existing: list[str], incoming: list[str]) -> list[str]:
|
||
"""
|
||
按来源顺序合并仓库地址并执行大小写不敏感去重。
|
||
"""
|
||
merged: list[str] = []
|
||
seen: set[str] = set()
|
||
for repo in (*existing, *incoming):
|
||
normalized = str(repo or "").strip().rstrip("/")
|
||
key = normalized.lower()
|
||
if normalized and key not in seen:
|
||
seen.add(key)
|
||
merged.append(normalized)
|
||
return merged
|
||
|
||
@staticmethod
|
||
def _dict_list(value: JsonData) -> list[dict[str, Any]]:
|
||
"""
|
||
只保留规则组列表中的字典定义并复制输入。
|
||
"""
|
||
if not isinstance(value, list):
|
||
return []
|
||
return [dict(item) for item in value if isinstance(item, dict)]
|