mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
feat(plugin): add source identity foundation (#6454)
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
"""已安装物理插件的来源身份合同与存量迁移决策。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
_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})/"
|
||||
r"[a-z0-9._-]{1,100}$"
|
||||
)
|
||||
OFFICIAL_PLUGIN_SOURCE_KEY = "github:jxxghp/moviepilot-plugins"
|
||||
|
||||
|
||||
class TrustedPluginSourceType(StrEnum):
|
||||
"""可被在线自动更新信任的来源类型。"""
|
||||
|
||||
UNKNOWN = "unknown"
|
||||
OFFICIAL = "official"
|
||||
THIRD_PARTY = "third_party"
|
||||
|
||||
|
||||
class PluginPayloadSourceType(StrEnum):
|
||||
"""最近一次已提交插件载荷的来源类型。"""
|
||||
|
||||
UNKNOWN = "unknown"
|
||||
OFFICIAL = "official"
|
||||
THIRD_PARTY = "third_party"
|
||||
LOCAL = "local"
|
||||
|
||||
|
||||
class PluginBindingBasis(StrEnum):
|
||||
"""物理插件来源身份的建立依据。"""
|
||||
|
||||
LEGACY_UNBOUND = "legacy_unbound"
|
||||
LOCAL_ONLY = "local_only"
|
||||
OFFICIAL_DEFAULT = "official_default"
|
||||
TOFU = "tofu"
|
||||
EXPLICIT_INSTALL = "explicit_install"
|
||||
EXPLICIT_SOURCE_CHANGE = "explicit_source_change"
|
||||
|
||||
|
||||
class PluginMarketAvailability(StrEnum):
|
||||
"""存量迁移观察市场候选时的可用状态。"""
|
||||
|
||||
AVAILABLE = "available"
|
||||
UNAVAILABLE = "unavailable"
|
||||
|
||||
|
||||
class PluginIdentityConflictError(RuntimeError):
|
||||
"""来源身份的首次创建或 revision 条件更新已失去竞争。"""
|
||||
|
||||
|
||||
def normalize_physical_plugin_id(plugin_id: str) -> str:
|
||||
"""校验物理插件 ID,并返回大小写无关的数据库身份键。"""
|
||||
if plugin_id != plugin_id.strip() or not _PLUGIN_ID_PATTERN.fullmatch(plugin_id):
|
||||
raise ValueError("插件 ID 必须以字母开头且只能包含 ASCII 字母或数字")
|
||||
return plugin_id.lower()
|
||||
|
||||
|
||||
def validate_online_source_key(source_key: str) -> str:
|
||||
"""校验由市场边界生成的稳定 GitHub 仓库身份键。"""
|
||||
value = source_key.strip().lower()
|
||||
if not _ONLINE_SOURCE_KEY_PATTERN.fullmatch(value):
|
||||
raise ValueError("在线插件来源必须使用 github:<owner>/<repository> 规范键")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_online_source_classification(
|
||||
source_key: str,
|
||||
source_type: TrustedPluginSourceType | PluginPayloadSourceType,
|
||||
) -> None:
|
||||
"""保证官方仓库键与官方来源类型始终双向一致。"""
|
||||
is_official_key = source_key == OFFICIAL_PLUGIN_SOURCE_KEY
|
||||
is_official_type = source_type.value == TrustedPluginSourceType.OFFICIAL.value
|
||||
if is_official_key != is_official_type:
|
||||
raise ValueError("官方来源类型只能对应 MoviePilot 官方插件仓库")
|
||||
|
||||
|
||||
def _validate_optional_text(
|
||||
value: str | None,
|
||||
*,
|
||||
field_name: str,
|
||||
max_length: int,
|
||||
) -> None:
|
||||
"""让应用层字符串合同与跨数据库列长度保持一致。"""
|
||||
if value is not None and len(value) > max_length:
|
||||
raise ValueError(f"{field_name} 长度不能超过 {max_length}")
|
||||
|
||||
|
||||
def _validate_receipt(receipt: str | None) -> str | None:
|
||||
"""校验内容收据为带算法前缀的十六进制摘要。"""
|
||||
if receipt is None:
|
||||
return None
|
||||
value = receipt.strip().lower()
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", value):
|
||||
raise ValueError("插件载荷收据必须为 sha256:<64 hex>")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginIdentity:
|
||||
"""一份物理插件的可信更新来源与当前载荷审计事实。"""
|
||||
|
||||
plugin_id: str
|
||||
normalized_plugin_id: str
|
||||
trusted_source_type: TrustedPluginSourceType
|
||||
trusted_source_key: str | None
|
||||
binding_basis: PluginBindingBasis
|
||||
payload_source_type: PluginPayloadSourceType
|
||||
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
|
||||
payload_receipt: str | None
|
||||
revision: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
bound_at: datetime | None
|
||||
payload_applied_at: datetime | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""拒绝不能作为后续来源门禁事实的矛盾状态。"""
|
||||
normalized_id = normalize_physical_plugin_id(self.plugin_id)
|
||||
if self.normalized_plugin_id != normalized_id:
|
||||
raise ValueError("normalized_plugin_id 必须是 plugin_id 的规范化物理身份")
|
||||
if self.revision < 1:
|
||||
raise ValueError("来源身份 revision 必须从 1 开始")
|
||||
if self.created_at.tzinfo is None or self.updated_at.tzinfo is None:
|
||||
raise ValueError("来源身份审计时间必须包含时区")
|
||||
if self.updated_at < self.created_at:
|
||||
raise ValueError("来源身份更新时间不能早于创建时间")
|
||||
for audit_time in (self.bound_at, self.payload_applied_at):
|
||||
if audit_time is not None and audit_time.tzinfo is None:
|
||||
raise ValueError("来源身份审计时间必须包含时区")
|
||||
if audit_time is not None and not self.created_at <= audit_time <= self.updated_at:
|
||||
raise ValueError("来源身份审计时间必须位于创建与更新时间之间")
|
||||
|
||||
trusted_key = (
|
||||
validate_online_source_key(self.trusted_source_key)
|
||||
if self.trusted_source_key
|
||||
else None
|
||||
)
|
||||
if self.trusted_source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
if trusted_key is not None or self.bound_at is not None:
|
||||
raise ValueError("未绑定身份不能携带可信来源或绑定时间")
|
||||
if self.binding_basis not in {
|
||||
PluginBindingBasis.LEGACY_UNBOUND,
|
||||
PluginBindingBasis.LOCAL_ONLY,
|
||||
}:
|
||||
raise ValueError("未绑定身份只能使用存量迁移或本地插件依据")
|
||||
else:
|
||||
if trusted_key is None or self.bound_at is None:
|
||||
raise ValueError("已绑定身份必须携带规范来源和绑定时间")
|
||||
if self.binding_basis in {
|
||||
PluginBindingBasis.LEGACY_UNBOUND,
|
||||
PluginBindingBasis.LOCAL_ONLY,
|
||||
}:
|
||||
raise ValueError("已绑定身份不能使用未绑定来源依据")
|
||||
_validate_online_source_classification(
|
||||
trusted_key,
|
||||
self.trusted_source_type,
|
||||
)
|
||||
if (
|
||||
self.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
and self.trusted_source_type is not TrustedPluginSourceType.OFFICIAL
|
||||
):
|
||||
raise ValueError("official_default 只能绑定官方来源")
|
||||
if (
|
||||
self.binding_basis is PluginBindingBasis.TOFU
|
||||
and self.trusted_source_type is not TrustedPluginSourceType.THIRD_PARTY
|
||||
):
|
||||
raise ValueError("TOFU 只能绑定唯一第三方在线来源")
|
||||
|
||||
payload_key = (
|
||||
validate_online_source_key(self.payload_source_key)
|
||||
if self.payload_source_key
|
||||
else None
|
||||
)
|
||||
if self.payload_source_type in {
|
||||
PluginPayloadSourceType.OFFICIAL,
|
||||
PluginPayloadSourceType.THIRD_PARTY,
|
||||
}:
|
||||
if payload_key is None:
|
||||
raise ValueError("在线载荷必须携带规范来源")
|
||||
_validate_online_source_classification(
|
||||
payload_key,
|
||||
self.payload_source_type,
|
||||
)
|
||||
elif payload_key is not None:
|
||||
raise ValueError("未知或本地载荷不能携带在线来源键")
|
||||
if self.payload_source_type is PluginPayloadSourceType.UNKNOWN:
|
||||
if any((
|
||||
self.declared_version,
|
||||
self.package_generation,
|
||||
self.system_version,
|
||||
self.supports_v3 is not None,
|
||||
self.supports_v3t is not None,
|
||||
self.payload_receipt,
|
||||
self.payload_applied_at,
|
||||
)):
|
||||
raise ValueError("未知载荷不能携带版本、兼容声明、收据或应用时间")
|
||||
else:
|
||||
if not self.declared_version or not self.package_generation:
|
||||
raise ValueError("已知载荷必须携带声明版本和包代际")
|
||||
if self.payload_applied_at is None or self.payload_receipt is None:
|
||||
raise ValueError("已知载荷必须携带应用时间和内容收据")
|
||||
if (
|
||||
self.payload_source_type is not PluginPayloadSourceType.LOCAL
|
||||
and (
|
||||
payload_key != trusted_key
|
||||
or self.payload_source_type.value
|
||||
!= self.trusted_source_type.value
|
||||
)
|
||||
):
|
||||
raise ValueError("在线载荷来源必须与可信更新来源一致")
|
||||
if (
|
||||
self.binding_basis is PluginBindingBasis.LOCAL_ONLY
|
||||
and self.payload_source_type is not PluginPayloadSourceType.LOCAL
|
||||
):
|
||||
raise ValueError("本地插件身份必须携带已提交的本地载荷事实")
|
||||
|
||||
if self.package_generation not in {None, "v1", "v2", "v3"}:
|
||||
raise ValueError("插件包代际必须为 v1、v2 或 v3")
|
||||
_validate_optional_text(
|
||||
self.declared_version,
|
||||
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))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PluginSourceCandidate:
|
||||
"""存量迁移可观察到的一个在线候选来源。"""
|
||||
|
||||
source_type: TrustedPluginSourceType
|
||||
source_key: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""候选只接受可绑定的规范在线来源。"""
|
||||
if self.source_type is TrustedPluginSourceType.UNKNOWN:
|
||||
raise ValueError("未知来源不能作为在线候选")
|
||||
object.__setattr__(self, "source_key", validate_online_source_key(self.source_key))
|
||||
_validate_online_source_classification(self.source_key, self.source_type)
|
||||
|
||||
|
||||
def plan_legacy_plugin_identity(
|
||||
*,
|
||||
plugin_id: str,
|
||||
market_availability: PluginMarketAvailability,
|
||||
online_candidates: tuple[PluginSourceCandidate, ...],
|
||||
is_virtual_instance: bool,
|
||||
now: datetime,
|
||||
) -> PluginIdentity | None:
|
||||
"""为存量物理插件建立更新绑定,不冒充当前载荷来源。"""
|
||||
if is_virtual_instance:
|
||||
return None
|
||||
normalized_id = normalize_physical_plugin_id(plugin_id)
|
||||
candidates = {
|
||||
(candidate.source_type, candidate.source_key): candidate
|
||||
for candidate in online_candidates
|
||||
}
|
||||
official = sorted(
|
||||
(
|
||||
candidate
|
||||
for candidate in candidates.values()
|
||||
if candidate.source_type is TrustedPluginSourceType.OFFICIAL
|
||||
),
|
||||
key=lambda candidate: candidate.source_key,
|
||||
)
|
||||
third_party = sorted(
|
||||
(
|
||||
candidate
|
||||
for candidate in candidates.values()
|
||||
if candidate.source_type is TrustedPluginSourceType.THIRD_PARTY
|
||||
),
|
||||
key=lambda candidate: candidate.source_key,
|
||||
)
|
||||
|
||||
trusted_type = TrustedPluginSourceType.UNKNOWN
|
||||
trusted_key = None
|
||||
basis = PluginBindingBasis.LEGACY_UNBOUND
|
||||
bound_at = None
|
||||
if market_availability is PluginMarketAvailability.AVAILABLE and official:
|
||||
trusted_type = TrustedPluginSourceType.OFFICIAL
|
||||
trusted_key = official[0].source_key
|
||||
basis = PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
bound_at = now
|
||||
elif (
|
||||
market_availability is PluginMarketAvailability.AVAILABLE
|
||||
and len(third_party) == 1
|
||||
):
|
||||
trusted_type = TrustedPluginSourceType.THIRD_PARTY
|
||||
trusted_key = third_party[0].source_key
|
||||
basis = PluginBindingBasis.TOFU
|
||||
bound_at = now
|
||||
|
||||
return PluginIdentity(
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=normalized_id,
|
||||
trusted_source_type=trusted_type,
|
||||
trusted_source_key=trusted_key,
|
||||
binding_basis=basis,
|
||||
payload_source_type=PluginPayloadSourceType.UNKNOWN,
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
payload_receipt=None,
|
||||
revision=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
bound_at=bound_at,
|
||||
payload_applied_at=None,
|
||||
)
|
||||
|
||||
|
||||
class PluginIdentityRepository(Protocol):
|
||||
"""来源身份条件写命令使用的无提交仓储端口。"""
|
||||
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""按规范化物理插件 ID 读取身份。"""
|
||||
|
||||
def stage_create(self, identity: PluginIdentity) -> None:
|
||||
"""暂存首次身份;唯一键竞争时抛出冲突错误。"""
|
||||
|
||||
def stage_replace(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> bool:
|
||||
"""按 revision 条件暂存替换,并返回是否赢得竞争。"""
|
||||
|
||||
|
||||
class PluginIdentityUnitOfWork(Protocol):
|
||||
"""来源身份条件写使用的事务端口。"""
|
||||
|
||||
def commit(self) -> None:
|
||||
"""提交当前条件写。"""
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""回滚当前条件写。"""
|
||||
|
||||
|
||||
class WritePluginIdentityCommand:
|
||||
"""以数据库 revision 原子创建或替换一份插件来源身份。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: PluginIdentityRepository,
|
||||
unit_of_work: PluginIdentityUnitOfWork,
|
||||
) -> None:
|
||||
"""保存仓储与事务所有者。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def execute(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""首次写要求记录不存在,后续写要求 revision 精确匹配。"""
|
||||
candidate = replace(
|
||||
identity,
|
||||
normalized_plugin_id=normalize_physical_plugin_id(identity.plugin_id),
|
||||
revision=1 if expected_revision is None else expected_revision + 1,
|
||||
)
|
||||
try:
|
||||
if expected_revision is None:
|
||||
if (
|
||||
candidate.binding_basis
|
||||
is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"首次插件身份不能伪装成已确认的来源变更"
|
||||
)
|
||||
self._repository.stage_create(candidate)
|
||||
else:
|
||||
current = self._repository.get(candidate.normalized_plugin_id)
|
||||
if current is None or current.revision != expected_revision:
|
||||
raise PluginIdentityConflictError(
|
||||
f"插件 {candidate.plugin_id} 的来源身份已被其他任务更新"
|
||||
)
|
||||
immutable_binding = (
|
||||
"plugin_id",
|
||||
"normalized_plugin_id",
|
||||
"trusted_source_type",
|
||||
"trusted_source_key",
|
||||
"binding_basis",
|
||||
"created_at",
|
||||
"bound_at",
|
||||
)
|
||||
if any(
|
||||
getattr(candidate, field_name) != getattr(current, field_name)
|
||||
for field_name in immutable_binding
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
"普通插件身份更新不能改变物理 ID 或可信来源绑定"
|
||||
)
|
||||
if candidate.updated_at < current.updated_at:
|
||||
raise PluginIdentityConflictError(
|
||||
"插件身份更新时间不能早于已提交记录"
|
||||
)
|
||||
if not self._repository.stage_replace(
|
||||
candidate,
|
||||
expected_revision=expected_revision,
|
||||
):
|
||||
raise PluginIdentityConflictError(
|
||||
f"插件 {candidate.plugin_id} 的来源身份已被其他任务更新"
|
||||
)
|
||||
self._unit_of_work.commit()
|
||||
return candidate
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,145 @@
|
||||
"""插件来源身份 Application Port 的 SQLAlchemy 实现。"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginPayloadSourceType,
|
||||
PluginIdentity,
|
||||
PluginIdentityConflictError,
|
||||
TrustedPluginSourceType,
|
||||
WritePluginIdentityCommand,
|
||||
normalize_physical_plugin_id,
|
||||
)
|
||||
from app.db.models.pluginidentity import PluginIdentity as IdentityModel
|
||||
from app.db.oper.pluginidentity import PluginIdentityOper
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
|
||||
|
||||
def _parse_datetime(value: str | None) -> datetime | None:
|
||||
"""把数据库 ISO 时间还原为带时区应用值。"""
|
||||
return datetime.fromisoformat(value) if value else None
|
||||
|
||||
|
||||
def _to_record(model: IdentityModel) -> PluginIdentity:
|
||||
"""把持久化模型映射为已校验的应用身份。"""
|
||||
return PluginIdentity(
|
||||
plugin_id=model.plugin_id,
|
||||
normalized_plugin_id=model.normalized_plugin_id,
|
||||
trusted_source_type=TrustedPluginSourceType(model.trusted_source_type),
|
||||
trusted_source_key=model.trusted_source_key,
|
||||
binding_basis=PluginBindingBasis(model.binding_basis),
|
||||
payload_source_type=PluginPayloadSourceType(model.payload_source_type),
|
||||
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,
|
||||
payload_receipt=model.payload_receipt,
|
||||
revision=model.revision,
|
||||
created_at=datetime.fromisoformat(model.created_at),
|
||||
updated_at=datetime.fromisoformat(model.updated_at),
|
||||
bound_at=_parse_datetime(model.bound_at),
|
||||
payload_applied_at=_parse_datetime(model.payload_applied_at),
|
||||
)
|
||||
|
||||
|
||||
def _to_model(identity: PluginIdentity) -> IdentityModel:
|
||||
"""把应用身份映射为不拥有事务的持久化模型。"""
|
||||
return IdentityModel(
|
||||
plugin_id=identity.plugin_id,
|
||||
normalized_plugin_id=identity.normalized_plugin_id,
|
||||
trusted_source_type=identity.trusted_source_type.value,
|
||||
trusted_source_key=identity.trusted_source_key,
|
||||
binding_basis=identity.binding_basis.value,
|
||||
payload_source_type=identity.payload_source_type.value,
|
||||
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,
|
||||
payload_receipt=identity.payload_receipt,
|
||||
revision=identity.revision,
|
||||
created_at=identity.created_at.isoformat(),
|
||||
updated_at=identity.updated_at.isoformat(),
|
||||
bound_at=identity.bound_at.isoformat() if identity.bound_at else None,
|
||||
payload_applied_at=(
|
||||
identity.payload_applied_at.isoformat()
|
||||
if identity.payload_applied_at
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _SqlAlchemyIdentityRepository:
|
||||
"""绑定一个调用方 Session 的来源身份仓储。"""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
"""保存由事务适配器拥有的 Session。"""
|
||||
self._oper = PluginIdentityOper(session)
|
||||
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""读取并映射指定来源身份。"""
|
||||
model = self._oper.get_by_plugin_id(plugin_id)
|
||||
return _to_record(model) if model else None
|
||||
|
||||
def stage_create(self, identity: PluginIdentity) -> None:
|
||||
"""暂存首次身份。"""
|
||||
try:
|
||||
self._oper.stage_create(_to_model(identity))
|
||||
except IntegrityError as error:
|
||||
raise PluginIdentityConflictError(
|
||||
f"插件 {identity.plugin_id} 的来源身份已存在"
|
||||
) from error
|
||||
|
||||
def stage_replace(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> bool:
|
||||
"""按 revision 条件暂存替换。"""
|
||||
return self._oper.stage_replace(
|
||||
_to_model(identity),
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
|
||||
class TransactionalPluginIdentityStore:
|
||||
"""为每次来源身份读写创建独占同步数据库会话。"""
|
||||
|
||||
def __init__(self, session_factory: Callable[[], Session]) -> None:
|
||||
"""保存由组合根提供的同步 Session 工厂。"""
|
||||
self._session_factory = session_factory
|
||||
|
||||
def get(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""在短会话内读取指定物理插件身份。"""
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return _SqlAlchemyIdentityRepository(session).get(
|
||||
normalize_physical_plugin_id(plugin_id)
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def compare_and_set(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""在一个事务内执行首次创建或 revision 条件替换。"""
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return WritePluginIdentityCommand(
|
||||
repository=_SqlAlchemyIdentityRepository(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
).execute(identity, expected_revision=expected_revision)
|
||||
finally:
|
||||
session.close()
|
||||
@@ -18,6 +18,10 @@ _MODEL_EXPORTS = {
|
||||
"OutboxMessage": ("app.db.models.outbox", "OutboxMessage"),
|
||||
"PassKey": ("app.db.models.passkey", "PassKey"),
|
||||
"PluginData": ("app.db.models.plugindata", "PluginData"),
|
||||
"PluginIdentity": (
|
||||
"app.db.models.pluginidentity",
|
||||
"PluginIdentity",
|
||||
),
|
||||
"Site": ("app.db.models.site", "Site"),
|
||||
"SiteIcon": ("app.db.models.siteicon", "SiteIcon"),
|
||||
"SiteStatistic": ("app.db.models.sitestatistic", "SiteStatistic"),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""已安装物理插件来源身份模型。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, CheckConstraint, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
|
||||
class PluginIdentity(Base):
|
||||
"""持久化一份大小写无关、可条件更新的物理插件来源身份。"""
|
||||
|
||||
id = get_id_column()
|
||||
plugin_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
normalized_plugin_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
trusted_source_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
trusted_source_key: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
binding_basis: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
payload_source_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
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)
|
||||
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)
|
||||
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
bound_at: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
payload_applied_at: Mapped[Optional[str]] = mapped_column(String(40))
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"normalized_plugin_id",
|
||||
name="uq_pluginidentity_normalized_plugin_id",
|
||||
),
|
||||
CheckConstraint(
|
||||
"normalized_plugin_id <> '' "
|
||||
"AND normalized_plugin_id = lower(normalized_plugin_id)",
|
||||
name="ck_pluginidentity_normalized_plugin_id",
|
||||
),
|
||||
CheckConstraint("revision >= 1", name="ck_pluginidentity_revision"),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""插件来源身份的数据访问原语。"""
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper, execute_dml
|
||||
from app.db.models.pluginidentity import PluginIdentity
|
||||
|
||||
|
||||
class PluginIdentityOper(DbOper):
|
||||
"""在调用方 Session 中查询并条件暂存插件来源身份。"""
|
||||
|
||||
def get_by_plugin_id(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""按规范化物理插件 ID 查询唯一身份。"""
|
||||
return self._execute_sync_query(
|
||||
lambda session: session.execute(
|
||||
select(PluginIdentity).where(
|
||||
PluginIdentity.normalized_plugin_id == plugin_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
)
|
||||
|
||||
def stage_create(self, identity: PluginIdentity) -> None:
|
||||
"""暂存首次身份并立即暴露数据库唯一键竞争。"""
|
||||
def stage(session: Session) -> None:
|
||||
"""加入并 flush 当前调用方事务。"""
|
||||
session.add(identity)
|
||||
session.flush()
|
||||
|
||||
self._execute_sync_write(stage)
|
||||
|
||||
def stage_replace(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> bool:
|
||||
"""仅在当前 revision 匹配时替换整份审计事实。"""
|
||||
values = {
|
||||
column.name: getattr(identity, column.name)
|
||||
for column in PluginIdentity.__table__.columns
|
||||
if column.name != "id"
|
||||
}
|
||||
return bool(
|
||||
self._execute_sync_write(
|
||||
lambda session: execute_dml(
|
||||
session,
|
||||
update(PluginIdentity)
|
||||
.where(
|
||||
PluginIdentity.normalized_plugin_id
|
||||
== identity.normalized_plugin_id,
|
||||
PluginIdentity.revision == expected_revision,
|
||||
)
|
||||
.values(**values),
|
||||
execution_options={"synchronize_session": False},
|
||||
)
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user