mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
278 lines
8.3 KiB
Python
278 lines
8.3 KiB
Python
"""
|
|
插件持久化数据查询、投影与写用例。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Callable
|
|
from typing import Any, Optional, Protocol
|
|
|
|
from app.application.security.secrets import is_secret_setting_key
|
|
from app.schemas.common import JsonData
|
|
|
|
|
|
class PluginDataQueryRepository(Protocol):
|
|
"""
|
|
Agent 插件数据查询所需的类型化异步端口。
|
|
"""
|
|
|
|
async def get(self, plugin_id: str, key: str) -> JsonData:
|
|
"""
|
|
读取插件指定键的数据。
|
|
|
|
:param plugin_id: 目标插件的物理标识
|
|
:param key: 插件持久化数据的键
|
|
:return: 该键的 JSON 数据;不存在时为 None
|
|
"""
|
|
...
|
|
|
|
async def list(self, plugin_id: str) -> dict[str, JsonData]:
|
|
"""
|
|
读取插件全部键值并在 Session 内完成投影。
|
|
|
|
:param plugin_id: 目标插件的物理标识
|
|
:return: 插件全部数据组成的键值映射,无记录时为空字典
|
|
"""
|
|
...
|
|
|
|
|
|
class PluginDataMutationRepository(Protocol):
|
|
"""
|
|
插件数据删除命令所需的无提交仓储端口。
|
|
"""
|
|
|
|
def stage_delete(self, plugin_id: str) -> None:
|
|
"""
|
|
暂存目标插件的全部持久化数据删除。
|
|
|
|
:param plugin_id: 目标插件的物理标识
|
|
"""
|
|
...
|
|
|
|
|
|
class UnitOfWork(Protocol):
|
|
"""
|
|
插件数据同步写用例所需的事务端口。
|
|
"""
|
|
|
|
def commit(self) -> None:
|
|
"""
|
|
提交当前逻辑操作。
|
|
"""
|
|
...
|
|
|
|
def rollback(self) -> None:
|
|
"""
|
|
回滚当前逻辑操作。
|
|
"""
|
|
...
|
|
|
|
|
|
class DeletePluginDataCommand:
|
|
"""
|
|
在一个显式事务中删除目标插件的全部持久化数据。
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
repository: PluginDataMutationRepository,
|
|
unit_of_work: UnitOfWork,
|
|
) -> None:
|
|
"""
|
|
保存无提交仓储和事务所有者。
|
|
"""
|
|
self._repository = repository
|
|
self._unit_of_work = unit_of_work
|
|
|
|
def execute(self, plugin_id: str) -> None:
|
|
"""
|
|
暂存并提交删除;任一步失败时回滚并传播原异常。
|
|
"""
|
|
try:
|
|
self._repository.stage_delete(plugin_id)
|
|
self._unit_of_work.commit()
|
|
except Exception:
|
|
self._unit_of_work.rollback()
|
|
raise
|
|
|
|
|
|
DEFAULT_PLUGIN_DATA_PREVIEW_CHARS = 12_000
|
|
MAX_PLUGIN_DATA_PREVIEW_CHARS = 50_000
|
|
PLUGIN_DATA_KEY_PREVIEW_LIMIT = 50
|
|
PLUGIN_DATA_TRUNCATION_SUFFIX = "\n...(插件数据内容过长,已截断)"
|
|
|
|
|
|
def clamp_preview_chars(max_chars: Optional[int]) -> int:
|
|
"""
|
|
约束插件数据预览长度,避免结果无限膨胀。
|
|
"""
|
|
if max_chars is None:
|
|
return DEFAULT_PLUGIN_DATA_PREVIEW_CHARS
|
|
return max(512, min(int(max_chars), MAX_PLUGIN_DATA_PREVIEW_CHARS))
|
|
|
|
|
|
def build_preview_payload(value: Any, max_chars: Optional[int]) -> tuple[bool, int, int, str]:
|
|
"""
|
|
稳定序列化插件数据并生成有界预览。
|
|
"""
|
|
serialized = json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
|
preview_limit = clamp_preview_chars(max_chars)
|
|
if len(serialized) <= preview_limit:
|
|
return False, len(serialized), len(serialized), serialized
|
|
preview = serialized[:preview_limit] + PLUGIN_DATA_TRUNCATION_SUFFIX
|
|
return True, len(serialized), len(preview), preview
|
|
|
|
|
|
class PluginDataQueryService:
|
|
"""
|
|
查询已安装插件数据并对大结果执行稳定裁剪。
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
repository: PluginDataQueryRepository,
|
|
snapshot: Callable[[str], Optional[dict[str, Any]]],
|
|
) -> None:
|
|
"""
|
|
注入插件数据仓储和安装态快照查询函数。
|
|
"""
|
|
self._repository = repository
|
|
self._snapshot = snapshot
|
|
|
|
async def query(
|
|
self,
|
|
plugin_id: str,
|
|
*,
|
|
key: Optional[str] = None,
|
|
max_chars: Optional[int] = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
读取单键或全部数据,并返回上下文安全的投影。
|
|
"""
|
|
plugin = self._snapshot(plugin_id)
|
|
if plugin is None:
|
|
raise ValueError(f"插件 {plugin_id} 不存在")
|
|
if key:
|
|
value = await self._repository.get(plugin_id, key)
|
|
if value is None:
|
|
return {
|
|
**plugin,
|
|
"key": key,
|
|
"found": False,
|
|
"message": f"插件 {plugin_id} 没有数据项 {key}",
|
|
}
|
|
truncated, total_chars, returned_chars, preview = build_preview_payload(value, max_chars)
|
|
result = {
|
|
**plugin,
|
|
"key": key,
|
|
"found": True,
|
|
"truncated": truncated,
|
|
"total_chars": total_chars,
|
|
"returned_chars": returned_chars,
|
|
}
|
|
result["value_preview" if truncated else "value"] = preview if truncated else value
|
|
return result
|
|
data = await self._repository.list(plugin_id)
|
|
keys = list(data)
|
|
result = {
|
|
**plugin,
|
|
"count": len(data),
|
|
"keys": keys[:PLUGIN_DATA_KEY_PREVIEW_LIMIT],
|
|
"keys_truncated": len(keys) > PLUGIN_DATA_KEY_PREVIEW_LIMIT,
|
|
}
|
|
truncated, total_chars, returned_chars, preview = build_preview_payload(data, max_chars)
|
|
result.update(
|
|
{
|
|
"truncated": truncated,
|
|
"total_chars": total_chars,
|
|
"returned_chars": returned_chars,
|
|
"data_preview" if truncated else "data": (preview if truncated else data),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def plugin_data_value_type(value: JsonData) -> str:
|
|
"""
|
|
把插件 JSON 值映射为不包含内容的稳定类型名称。
|
|
"""
|
|
if value is None:
|
|
return "null"
|
|
if type(value) is bool:
|
|
return "boolean"
|
|
if type(value) in (int, float):
|
|
return "number"
|
|
if type(value) is str:
|
|
return "string"
|
|
if type(value) is list:
|
|
return "array"
|
|
if type(value) is dict:
|
|
return "object"
|
|
return "unknown"
|
|
|
|
|
|
def plugin_data_serialized_chars(value: JsonData) -> Optional[int]:
|
|
"""
|
|
计算合法 JSON 值的紧凑字符数,异常对象不执行自定义字符串化。
|
|
"""
|
|
try:
|
|
return len(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
class PluginDataSummaryService:
|
|
"""
|
|
构建不包含插件持久化原值的有界诊断摘要。
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
repository: PluginDataQueryRepository,
|
|
snapshot: Callable[[str], Optional[dict[str, Any]]],
|
|
) -> None:
|
|
"""
|
|
注入插件数据仓储和安装态快照查询函数。
|
|
"""
|
|
self._repository = repository
|
|
self._snapshot = snapshot
|
|
|
|
async def summarize(self, plugin_id: str) -> dict[str, Any]:
|
|
"""
|
|
返回键名、类型、大小和敏感标记,不返回或字符串化数据值。
|
|
"""
|
|
plugin = self._snapshot(plugin_id)
|
|
if plugin is None:
|
|
raise ValueError(f"插件 {plugin_id} 不存在")
|
|
|
|
data = await self._repository.list(plugin_id)
|
|
items = []
|
|
total_chars = 0
|
|
for key, value in list(data.items())[:PLUGIN_DATA_KEY_PREVIEW_LIMIT]:
|
|
serialized_chars = plugin_data_serialized_chars(value)
|
|
if serialized_chars is not None:
|
|
total_chars += serialized_chars
|
|
items.append(
|
|
{
|
|
"key": str(key),
|
|
"value_type": plugin_data_value_type(value),
|
|
"serialized_chars": serialized_chars,
|
|
"sensitive": is_secret_setting_key(key),
|
|
}
|
|
)
|
|
|
|
if len(data) > PLUGIN_DATA_KEY_PREVIEW_LIMIT:
|
|
for value in list(data.values())[PLUGIN_DATA_KEY_PREVIEW_LIMIT:]:
|
|
serialized_chars = plugin_data_serialized_chars(value)
|
|
if serialized_chars is not None:
|
|
total_chars += serialized_chars
|
|
|
|
return {
|
|
**plugin,
|
|
"count": len(data),
|
|
"total_chars": total_chars,
|
|
"keys": items,
|
|
"keys_truncated": len(data) > PLUGIN_DATA_KEY_PREVIEW_LIMIT,
|
|
}
|