feat(plugin): persist declared metadata snapshots (#6467)

This commit is contained in:
InfinityPacer
2026-08-26 13:15:12 +08:00
committed by GitHub
parent bd0795082a
commit ac2a2b1129
26 changed files with 1374 additions and 147 deletions
+8 -8
View File
@@ -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,
+41 -1
View File
@@ -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:
"""负责插件市场索引映射、并发收集、代际合并和来源去重。"""
+207
View File
@@ -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()
}
+11 -15
View File
@@ -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,
+4
View File
@@ -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(
+13
View File
@@ -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,