mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat(plugin): persist declared metadata snapshots (#6467)
This commit is contained in:
+43
-22
@@ -28,6 +28,7 @@ from app.api.principal import ApiPrincipal
|
||||
from app.api.response import ResponseAPIRouter
|
||||
from app.application.commands import init_commands
|
||||
from app.application.configuration import get_api_runtime_config_snapshot, get_configured_system_config
|
||||
from app.application.plugin.catalog import apply_declared_metadata_fallback
|
||||
from app.application.plugin.config import PluginConfigCommand
|
||||
from app.application.plugin.folders import remove_plugin_from_folders
|
||||
from app.application.plugin.gateway import get_plugin_install_service
|
||||
@@ -234,14 +235,21 @@ async def _get_plugin_history_detail(
|
||||
installed_plugin = next(
|
||||
(
|
||||
plugin
|
||||
for plugin in plugin_manager.get_local_plugins()
|
||||
if plugin.id == plugin_id and plugin.installed
|
||||
for plugin in plugin_manager.get_installed_plugins()
|
||||
if plugin.id == plugin_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not installed_plugin:
|
||||
return None
|
||||
|
||||
identity = await get_plugin_persistence().get_identity(plugin_id)
|
||||
if identity is not None:
|
||||
installed_plugin = apply_declared_metadata_fallback(
|
||||
[installed_plugin],
|
||||
{identity.normalized_plugin_id: identity},
|
||||
)[0]
|
||||
|
||||
local_repo_plugin = next(
|
||||
(plugin for plugin in plugin_manager.get_local_repo_plugins() if plugin.id == plugin_id),
|
||||
None,
|
||||
@@ -249,27 +257,35 @@ async def _get_plugin_history_detail(
|
||||
if local_repo_plugin:
|
||||
return _merge_plugin_market_metadata(installed_plugin, local_repo_plugin)
|
||||
|
||||
if installed_plugin.repo_url:
|
||||
trusted_repo_url = None
|
||||
if identity is not None and identity.trusted_source_key:
|
||||
owner_repo = identity.trusted_source_key.removeprefix("github:")
|
||||
trusted_repo_url = f"https://github.com/{owner_repo}"
|
||||
|
||||
if trusted_repo_url:
|
||||
market_plugin = await _get_market_plugin_from_repo(
|
||||
plugin_manager, plugin_id, installed_plugin.repo_url, force
|
||||
plugin_manager, plugin_id, trusted_repo_url, force
|
||||
)
|
||||
if not market_plugin:
|
||||
logger.debug(f"插件 {plugin_id} 未从来源仓库获取到更新说明,返回本地插件信息")
|
||||
return installed_plugin
|
||||
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
|
||||
|
||||
market_plugin = next(
|
||||
(
|
||||
plugin
|
||||
for plugin in await plugin_manager.async_get_online_plugins(force=force)
|
||||
if plugin.id == plugin_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not market_plugin:
|
||||
return installed_plugin
|
||||
return installed_plugin
|
||||
|
||||
return _merge_plugin_market_metadata(installed_plugin, market_plugin)
|
||||
|
||||
async def _installed_plugins_with_declared_metadata(
|
||||
plugin_manager: PluginRuntime,
|
||||
) -> list[_SchemaPlugin]:
|
||||
"""批量读取身份,并为未加载插件补齐已提交的展示声明。"""
|
||||
plugins = plugin_manager.get_installed_plugins()
|
||||
identities = await get_plugin_persistence().list_identities(
|
||||
[plugin.id for plugin in plugins if plugin.id]
|
||||
)
|
||||
return apply_declared_metadata_fallback(
|
||||
plugins,
|
||||
{identity.normalized_plugin_id: identity for identity in identities},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", summary="所有插件", response_model=List[_SchemaPlugin])
|
||||
@@ -281,14 +297,13 @@ async def all_plugins(
|
||||
"""
|
||||
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||
"""
|
||||
# 本地插件
|
||||
plugin_manager = get_plugin_manager()
|
||||
local_plugins = plugin_manager.get_local_plugins()
|
||||
# 已安装插件
|
||||
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
|
||||
installed_plugins = plugin_manager.get_installed_plugins()
|
||||
if state == "installed":
|
||||
return plugin_manager.get_installed_plugins()
|
||||
return await _installed_plugins_with_declared_metadata(plugin_manager)
|
||||
|
||||
# 本地插件
|
||||
local_plugins = plugin_manager.get_local_plugins()
|
||||
# 未安装的本地插件
|
||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
|
||||
# 本地插件仓库目录中的插件
|
||||
@@ -305,7 +320,10 @@ async def all_plugins(
|
||||
if state == "market":
|
||||
# 返回未安装的本地插件
|
||||
return not_installed_plugins
|
||||
return local_plugins
|
||||
return (
|
||||
await _installed_plugins_with_declared_metadata(plugin_manager)
|
||||
+ not_installed_plugins
|
||||
)
|
||||
|
||||
# 插件市场插件清单
|
||||
market_plugins = []
|
||||
@@ -328,7 +346,10 @@ async def all_plugins(
|
||||
return market_plugins
|
||||
|
||||
# 返回所有插件
|
||||
return installed_plugins + market_plugins
|
||||
return (
|
||||
await _installed_plugins_with_declared_metadata(plugin_manager)
|
||||
+ market_plugins
|
||||
)
|
||||
|
||||
|
||||
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -60,14 +61,13 @@ class PluginInstallAdmission:
|
||||
payload_receipt: str,
|
||||
applied_at: datetime,
|
||||
declared_version: str | None = None,
|
||||
manifest_matches_payload: bool = True,
|
||||
) -> PluginIdentity:
|
||||
"""在载荷落盘并生成收据后构造唯一数据库提交目标。"""
|
||||
current = self.identity_before
|
||||
plugin_id = current.plugin_id if current else self.candidate.plugin_id
|
||||
metadata = self.candidate.dto if isinstance(self.candidate.dto, Mapping) else {}
|
||||
system_version = metadata.get("system_version")
|
||||
supports_v3 = metadata.get("v3")
|
||||
supports_v3t = metadata.get("v3t")
|
||||
installed_version = declared_version or self.candidate.plugin_version
|
||||
source_binding_changed = (
|
||||
self.trusted_source_type is not TrustedPluginSourceType.UNKNOWN
|
||||
and (
|
||||
@@ -89,13 +89,13 @@ class PluginInstallAdmission:
|
||||
if isinstance(self.candidate, PluginLocalCandidate)
|
||||
else self.candidate.source_key
|
||||
),
|
||||
declared_version=declared_version or self.candidate.plugin_version,
|
||||
declared_version=installed_version,
|
||||
package_generation=self.candidate.package_generation,
|
||||
system_version=(
|
||||
system_version if isinstance(system_version, str) else None
|
||||
declared_metadata=PluginDeclaredMetadata.from_package(
|
||||
metadata,
|
||||
declaration_version=self.candidate.plugin_version,
|
||||
manifest_matches_payload=manifest_matches_payload,
|
||||
),
|
||||
supports_v3=supports_v3 if isinstance(supports_v3, bool) else None,
|
||||
supports_v3t=supports_v3t if isinstance(supports_v3t, bool) else None,
|
||||
payload_receipt=payload_receipt,
|
||||
revision=(current.revision + 1) if current else 1,
|
||||
created_at=current.created_at if current else applied_at,
|
||||
|
||||
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.application.plugin.identity import PluginIdentity
|
||||
from app.schemas.plugin import Plugin
|
||||
|
||||
MarketLoader = Callable[[str, Optional[str], bool], Optional[dict[str, dict]]]
|
||||
AsyncMarketLoader = Callable[
|
||||
@@ -17,6 +19,44 @@ PluginMapper = Callable[[str, dict, str, list[str], int, Optional[str]], Any]
|
||||
ProgressCallback = Callable[..., Any]
|
||||
|
||||
|
||||
def apply_declared_metadata_fallback(
|
||||
plugins: Sequence[Plugin],
|
||||
identities: Mapping[str, PluginIdentity],
|
||||
) -> list[Plugin]:
|
||||
"""用已提交快照补齐加载失败插件,不覆盖真实运行态字段。"""
|
||||
result: list[Plugin] = []
|
||||
for plugin in plugins:
|
||||
identity = identities.get((plugin.id or "").lower())
|
||||
if (
|
||||
identity is None
|
||||
or identity.declared_metadata is None
|
||||
or identity.declared_version is None
|
||||
):
|
||||
result.append(plugin)
|
||||
continue
|
||||
fallback = identity.declared_metadata.display_fallback(
|
||||
installed_version=identity.declared_version
|
||||
)
|
||||
updates: dict[str, str] = {}
|
||||
if not plugin.plugin_version:
|
||||
updates["plugin_version"] = fallback["plugin_version"]
|
||||
if (
|
||||
(not plugin.plugin_name or plugin.plugin_name == plugin.id)
|
||||
and "plugin_name" in fallback
|
||||
):
|
||||
updates["plugin_name"] = fallback["plugin_name"]
|
||||
if not plugin.plugin_desc and "plugin_desc" in fallback:
|
||||
updates["plugin_desc"] = fallback["plugin_desc"]
|
||||
if not plugin.plugin_icon and "plugin_icon" in fallback:
|
||||
updates["plugin_icon"] = fallback["plugin_icon"]
|
||||
if not plugin.plugin_author and "plugin_author" in fallback:
|
||||
updates["plugin_author"] = fallback["plugin_author"]
|
||||
if not plugin.plugin_label and "plugin_label" in fallback:
|
||||
updates["plugin_label"] = fallback["plugin_label"]
|
||||
result.append(plugin.model_copy(update=updates) if updates else plugin)
|
||||
return result
|
||||
|
||||
|
||||
class PluginCatalogService:
|
||||
"""负责插件市场索引映射、并发收集、代际合并和来源去重。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""已提交插件载荷的版本化 package 声明快照。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
_RUNTIME_FIELD_PATTERN = re.compile(r"^v[1-9][0-9]*t?$")
|
||||
_MANIFEST_TEXT_FIELDS = (
|
||||
"name",
|
||||
"description",
|
||||
"icon",
|
||||
"author",
|
||||
"system_version",
|
||||
)
|
||||
|
||||
|
||||
def _optional_text(
|
||||
value: object,
|
||||
*,
|
||||
max_length: int | None = None,
|
||||
) -> str | None:
|
||||
"""把外部可选文本规范为非空字符串。"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if max_length is not None and len(normalized) > max_length:
|
||||
raise ValueError(f"插件声明文本长度不能超过 {max_length}")
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _labels(value: object) -> list[str]:
|
||||
"""把历史逗号字符串和字符串数组统一为有序标签列表。"""
|
||||
if isinstance(value, str):
|
||||
items = value.split(",")
|
||||
elif isinstance(value, list):
|
||||
items = value
|
||||
else:
|
||||
return []
|
||||
return [
|
||||
normalized
|
||||
for item in items
|
||||
if isinstance(item, str) and (normalized := item.strip())
|
||||
]
|
||||
|
||||
|
||||
def _normalize_manifest(value: object) -> dict[str, object]:
|
||||
"""仅保留当前载荷展示和声明消费者需要的 package 字段。"""
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
manifest: dict[str, object] = {}
|
||||
for field_name in _MANIFEST_TEXT_FIELDS:
|
||||
if normalized := _optional_text(
|
||||
value.get(field_name),
|
||||
max_length=128 if field_name == "system_version" else None,
|
||||
):
|
||||
manifest[field_name] = normalized
|
||||
labels = _labels(value.get("labels"))
|
||||
if labels:
|
||||
manifest["labels"] = labels
|
||||
level = value.get("level")
|
||||
if isinstance(level, int) and not isinstance(level, bool):
|
||||
manifest["level"] = level
|
||||
release = value.get("release")
|
||||
if isinstance(release, bool):
|
||||
manifest["release"] = release
|
||||
return manifest
|
||||
|
||||
|
||||
def _normalize_runtime(value: object) -> dict[str, bool]:
|
||||
"""保留可向后扩展的宿主代际和运行时变体布尔声明。"""
|
||||
if not isinstance(value, Mapping):
|
||||
return {}
|
||||
return {
|
||||
str(field_name): field_value
|
||||
for field_name, field_value in value.items()
|
||||
if (
|
||||
isinstance(field_name, str)
|
||||
and _RUNTIME_FIELD_PATTERN.fullmatch(field_name)
|
||||
and isinstance(field_value, bool)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _encode(value: Mapping[str, object]) -> str:
|
||||
"""生成不可从外部原地修改的稳定内部表示。"""
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginDeclaredMetadata:
|
||||
"""封装一份受限、版本化且不参与来源授信的 package 声明。"""
|
||||
|
||||
_encoded: str = field(repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""拒绝绕过工厂构造的非规范内部状态。"""
|
||||
normalized = self._normalize_storage(json.loads(self._encoded))
|
||||
object.__setattr__(self, "_encoded", _encode(normalized))
|
||||
|
||||
@classmethod
|
||||
def from_package(
|
||||
cls,
|
||||
package: Mapping[str, Any],
|
||||
*,
|
||||
declaration_version: str | None,
|
||||
manifest_matches_payload: bool,
|
||||
) -> "PluginDeclaredMetadata":
|
||||
"""从安装候选生成与载荷一起提交的受限声明快照。"""
|
||||
runtime = _normalize_runtime(package)
|
||||
value = {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"declaration_version": _optional_text(declaration_version),
|
||||
"manifest_matches_payload": manifest_matches_payload,
|
||||
"manifest": _normalize_manifest(package),
|
||||
"runtime": runtime,
|
||||
}
|
||||
return cls(_encode(value))
|
||||
|
||||
@classmethod
|
||||
def from_storage(cls, value: object) -> "PluginDeclaredMetadata":
|
||||
"""校验数据库 JSON,并忽略当前合同未消费的额外字段。"""
|
||||
return cls(_encode(cls._normalize_storage(value)))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_storage(value: object) -> dict[str, object]:
|
||||
"""验证快照结构,同时宽容丢弃非法可选展示字段。"""
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("插件声明快照必须是 JSON 对象")
|
||||
if value.get("schema_version") != _SCHEMA_VERSION:
|
||||
raise ValueError("插件声明快照 schema_version 不受支持")
|
||||
manifest_matches_payload = value.get("manifest_matches_payload")
|
||||
if not isinstance(manifest_matches_payload, bool):
|
||||
raise ValueError("插件声明快照必须说明 manifest 是否对应当前载荷")
|
||||
return {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"declaration_version": _optional_text(
|
||||
value.get("declaration_version")
|
||||
),
|
||||
"manifest_matches_payload": manifest_matches_payload,
|
||||
"manifest": _normalize_manifest(value.get("manifest")),
|
||||
"runtime": _normalize_runtime(value.get("runtime")),
|
||||
}
|
||||
|
||||
@property
|
||||
def declaration_version(self) -> str | None:
|
||||
"""返回生成该快照的 package 条目版本。"""
|
||||
return _optional_text(self.to_json().get("declaration_version"))
|
||||
|
||||
@property
|
||||
def manifest_matches_payload(self) -> bool:
|
||||
"""说明该 package 声明是否与当前已提交载荷对应。"""
|
||||
return bool(self.to_json()["manifest_matches_payload"])
|
||||
|
||||
def runtime_support(self, runtime_name: str) -> bool | None:
|
||||
"""读取一个规范运行时声明;缺省保持未声明语义。"""
|
||||
if not _RUNTIME_FIELD_PATTERN.fullmatch(runtime_name):
|
||||
raise ValueError("运行时声明必须使用 v<数字> 或 v<数字>t")
|
||||
runtime = self.to_json().get("runtime")
|
||||
if not isinstance(runtime, dict):
|
||||
return None
|
||||
value = runtime.get(runtime_name)
|
||||
return value if isinstance(value, bool) else None
|
||||
|
||||
def display_fallback(self, *, installed_version: str) -> dict[str, str]:
|
||||
"""生成插件加载失败时可安全补齐的展示字段。"""
|
||||
manifest = self.to_json().get("manifest")
|
||||
fallback = {
|
||||
"plugin_version": installed_version,
|
||||
}
|
||||
if not isinstance(manifest, dict):
|
||||
return fallback
|
||||
display_fields = {
|
||||
"name": "plugin_name",
|
||||
"description": "plugin_desc",
|
||||
"icon": "plugin_icon",
|
||||
"author": "plugin_author",
|
||||
}
|
||||
for source_name, target_name in display_fields.items():
|
||||
value = manifest.get(source_name)
|
||||
if isinstance(value, str):
|
||||
fallback[target_name] = value
|
||||
labels = manifest.get("labels")
|
||||
if isinstance(labels, list):
|
||||
label = ",".join(item for item in labels if isinstance(item, str))
|
||||
if label:
|
||||
fallback["plugin_label"] = label
|
||||
return fallback
|
||||
|
||||
def to_json(self) -> dict[str, object]:
|
||||
"""返回可持久化且与内部状态相互隔离的 JSON 副本。"""
|
||||
value: object = json.loads(self._encoded)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("插件声明快照内部状态必须是 JSON 对象")
|
||||
return {
|
||||
str(field_name): field_value
|
||||
for field_name, field_value in value.items()
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
|
||||
_PLUGIN_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9]{0,127}$")
|
||||
_ONLINE_SOURCE_KEY_PATTERN = re.compile(
|
||||
r"^github:[a-z0-9](?:[a-z0-9-]{0,38})/"
|
||||
@@ -116,9 +118,7 @@ class PluginIdentity:
|
||||
payload_source_key: str | None
|
||||
declared_version: str | None
|
||||
package_generation: str | None
|
||||
system_version: str | None
|
||||
supports_v3: bool | None
|
||||
supports_v3t: bool | None
|
||||
declared_metadata: PluginDeclaredMetadata | None
|
||||
payload_receipt: str | None
|
||||
revision: int
|
||||
created_at: datetime
|
||||
@@ -200,9 +200,7 @@ class PluginIdentity:
|
||||
if any((
|
||||
self.declared_version,
|
||||
self.package_generation,
|
||||
self.system_version,
|
||||
self.supports_v3 is not None,
|
||||
self.supports_v3t is not None,
|
||||
self.declared_metadata,
|
||||
self.payload_receipt,
|
||||
self.payload_applied_at,
|
||||
)):
|
||||
@@ -210,6 +208,8 @@ class PluginIdentity:
|
||||
else:
|
||||
if not self.declared_version or not self.package_generation:
|
||||
raise ValueError("已知载荷必须携带声明版本和包代际")
|
||||
if self.declared_metadata is None:
|
||||
raise ValueError("已知载荷必须携带已提交 package 声明快照")
|
||||
if self.payload_applied_at is None or self.payload_receipt is None:
|
||||
raise ValueError("已知载荷必须携带应用时间和内容收据")
|
||||
if (
|
||||
@@ -234,11 +234,6 @@ class PluginIdentity:
|
||||
field_name="插件声明版本",
|
||||
max_length=64,
|
||||
)
|
||||
_validate_optional_text(
|
||||
self.system_version,
|
||||
field_name="插件系统版本要求",
|
||||
max_length=128,
|
||||
)
|
||||
object.__setattr__(self, "trusted_source_key", trusted_key)
|
||||
object.__setattr__(self, "payload_source_key", payload_key)
|
||||
object.__setattr__(self, "payload_receipt", _validate_receipt(self.payload_receipt))
|
||||
@@ -320,9 +315,7 @@ def plan_legacy_plugin_identity(
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
declared_metadata=None,
|
||||
payload_receipt=None,
|
||||
revision=1,
|
||||
created_at=now,
|
||||
@@ -356,6 +349,9 @@ class PluginIdentityStore(Protocol):
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的来源身份。"""
|
||||
|
||||
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
|
||||
"""批量读取指定物理插件的来源身份。"""
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
|
||||
@@ -356,6 +356,10 @@ class PluginInstallCommand:
|
||||
payload_receipt=receipt,
|
||||
applied_at=self.__clock(),
|
||||
declared_version=release_version,
|
||||
manifest_matches_payload=(
|
||||
release_version is None
|
||||
or release_version == candidate.plugin_version
|
||||
),
|
||||
)
|
||||
await self.__await_side_effect(
|
||||
self.__persistence.set_installation_target(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
@@ -166,6 +167,9 @@ class PluginIdentityPersistence(Protocol):
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取一个物理插件的来源身份。"""
|
||||
|
||||
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
|
||||
"""批量读取指定物理插件的来源身份。"""
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
@@ -202,6 +206,15 @@ class PluginPersistenceService:
|
||||
"""在数据库 worker 中读取插件来源身份。"""
|
||||
return await self.__executor.run(partial(self.__identities.get, plugin_id))
|
||||
|
||||
async def list_identities(
|
||||
self,
|
||||
plugin_ids: Sequence[str],
|
||||
) -> list[PluginIdentity]:
|
||||
"""在一次数据库任务中批量读取插件来源身份。"""
|
||||
return await self.__executor.run(
|
||||
partial(self.__identities.list, tuple(plugin_ids))
|
||||
)
|
||||
|
||||
async def migrate_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""插件来源身份 Application Port 的 SQLAlchemy 实现。"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
BindLocalPluginIdentityCommand,
|
||||
BindOnlinePluginIdentityCommand,
|
||||
@@ -40,9 +41,11 @@ def _to_record(model: IdentityModel) -> PluginIdentity:
|
||||
payload_source_key=model.payload_source_key,
|
||||
declared_version=model.declared_version,
|
||||
package_generation=model.package_generation,
|
||||
system_version=model.system_version,
|
||||
supports_v3=model.supports_v3,
|
||||
supports_v3t=model.supports_v3t,
|
||||
declared_metadata=(
|
||||
PluginDeclaredMetadata.from_storage(model.declared_metadata)
|
||||
if model.declared_metadata is not None
|
||||
else None
|
||||
),
|
||||
payload_receipt=model.payload_receipt,
|
||||
revision=model.revision,
|
||||
created_at=datetime.fromisoformat(model.created_at),
|
||||
@@ -64,9 +67,11 @@ def _to_model(identity: PluginIdentity) -> IdentityModel:
|
||||
payload_source_key=identity.payload_source_key,
|
||||
declared_version=identity.declared_version,
|
||||
package_generation=identity.package_generation,
|
||||
system_version=identity.system_version,
|
||||
supports_v3=identity.supports_v3,
|
||||
supports_v3t=identity.supports_v3t,
|
||||
declared_metadata=(
|
||||
identity.declared_metadata.to_json()
|
||||
if identity.declared_metadata is not None
|
||||
else None
|
||||
),
|
||||
payload_receipt=identity.payload_receipt,
|
||||
revision=identity.revision,
|
||||
created_at=identity.created_at.isoformat(),
|
||||
@@ -92,6 +97,13 @@ class _SqlAlchemyIdentityRepository:
|
||||
model = self._oper.get_by_plugin_id(plugin_id)
|
||||
return _to_record(model) if model else None
|
||||
|
||||
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
|
||||
"""批量读取并映射指定来源身份。"""
|
||||
return [
|
||||
_to_record(model)
|
||||
for model in self._oper.list_by_plugin_ids(plugin_ids)
|
||||
]
|
||||
|
||||
def stage_create(self, identity: PluginIdentity) -> None:
|
||||
"""暂存首次身份。"""
|
||||
try:
|
||||
@@ -131,6 +143,20 @@ class TransactionalPluginIdentityStore:
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def list(self, plugin_ids: Sequence[str]) -> list[PluginIdentity]:
|
||||
"""在一个短会话内批量读取规范化插件身份。"""
|
||||
normalized_ids = tuple(
|
||||
dict.fromkeys(
|
||||
normalize_physical_plugin_id(plugin_id)
|
||||
for plugin_id in plugin_ids
|
||||
)
|
||||
)
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return _SqlAlchemyIdentityRepository(session).list(normalized_ids)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.plugin.declaration import PluginDeclaredMetadata
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
@@ -50,9 +51,11 @@ def _identity_from_model(model: IdentityModel) -> PluginIdentity:
|
||||
payload_source_key=model.payload_source_key,
|
||||
declared_version=model.declared_version,
|
||||
package_generation=model.package_generation,
|
||||
system_version=model.system_version,
|
||||
supports_v3=model.supports_v3,
|
||||
supports_v3t=model.supports_v3t,
|
||||
declared_metadata=(
|
||||
PluginDeclaredMetadata.from_storage(model.declared_metadata)
|
||||
if model.declared_metadata is not None
|
||||
else None
|
||||
),
|
||||
payload_receipt=model.payload_receipt,
|
||||
revision=model.revision,
|
||||
created_at=datetime.fromisoformat(model.created_at),
|
||||
@@ -82,9 +85,11 @@ def _identity_model_values(identity: PluginIdentity) -> dict[str, object]:
|
||||
"payload_source_key": identity.payload_source_key,
|
||||
"declared_version": identity.declared_version,
|
||||
"package_generation": identity.package_generation,
|
||||
"system_version": identity.system_version,
|
||||
"supports_v3": identity.supports_v3,
|
||||
"supports_v3t": identity.supports_v3t,
|
||||
"declared_metadata": (
|
||||
identity.declared_metadata.to_json()
|
||||
if identity.declared_metadata is not None
|
||||
else None
|
||||
),
|
||||
"payload_receipt": identity.payload_receipt,
|
||||
"revision": identity.revision,
|
||||
"created_at": identity.created_at.isoformat(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""已安装物理插件来源身份模型。"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, Integer, String, UniqueConstraint
|
||||
from sqlalchemy import JSON, CheckConstraint, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
@@ -21,9 +21,9 @@ class PluginIdentity(Base):
|
||||
payload_source_key: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
declared_version: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
package_generation: Mapped[Optional[str]] = mapped_column(String(8))
|
||||
system_version: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
supports_v3: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
supports_v3t: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
declared_metadata: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSON(none_as_null=True)
|
||||
)
|
||||
payload_receipt: Mapped[Optional[str]] = mapped_column(String(71))
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
created_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""插件来源身份的数据访问原语。"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -25,6 +26,23 @@ class PluginIdentityOper(DbOper):
|
||||
)
|
||||
)
|
||||
|
||||
def list_by_plugin_ids(
|
||||
self,
|
||||
plugin_ids: Sequence[str],
|
||||
) -> list[PluginIdentity]:
|
||||
"""批量读取规范化物理插件 ID 对应的身份。"""
|
||||
if not plugin_ids:
|
||||
return []
|
||||
return list(
|
||||
self._execute_sync_query(
|
||||
lambda session: session.execute(
|
||||
select(PluginIdentity).where(
|
||||
PluginIdentity.normalized_plugin_id.in_(plugin_ids)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
)
|
||||
|
||||
def stage_create(self, identity: PluginIdentity) -> None:
|
||||
"""暂存首次身份并立即暴露数据库唯一键竞争。"""
|
||||
def stage(session: Session) -> None:
|
||||
|
||||
Reference in New Issue
Block a user