mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat(plugin): 建立可信来源准入与安装恢复 (#6462)
This commit is contained in:
@@ -7,6 +7,9 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
BindLocalPluginIdentityCommand,
|
||||
BindOnlinePluginIdentityCommand,
|
||||
ChangePluginIdentitySourceCommand,
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginIdentityConflictError,
|
||||
@@ -143,3 +146,51 @@ class TransactionalPluginIdentityStore:
|
||||
).execute(identity, expected_revision=expected_revision)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def change_source(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""在独占事务内提交明确的在线来源转换。"""
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return ChangePluginIdentitySourceCommand(
|
||||
repository=_SqlAlchemyIdentityRepository(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
).execute(identity, expected_revision=expected_revision)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def bind_local(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""在独占事务内提交 legacy_unbound 到 local_only 的转换。"""
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return BindLocalPluginIdentityCommand(
|
||||
repository=_SqlAlchemyIdentityRepository(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
).execute(identity, expected_revision=expected_revision)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def bind_online(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""在独占事务内提交未绑定身份的首次在线来源绑定。"""
|
||||
session = self._session_factory()
|
||||
try:
|
||||
return BindOnlinePluginIdentityCommand(
|
||||
repository=_SqlAlchemyIdentityRepository(session),
|
||||
unit_of_work=SqlAlchemyUnitOfWork(session),
|
||||
).execute(identity, expected_revision=expected_revision)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
"""插件安装事务 Application Port 的同步 SQLAlchemy 实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.transaction import (
|
||||
PluginInstallationConflictError,
|
||||
PluginInstallationPhase,
|
||||
PluginInstallationRecord,
|
||||
PluginInstallationStore,
|
||||
)
|
||||
from app.db.models.pluginidentity import PluginIdentity as IdentityModel
|
||||
from app.db.models.plugininstallation import PluginInstallation
|
||||
|
||||
_INSTALLED_PLUGINS_KEY = "UserInstalledPlugins"
|
||||
AtomicMembershipUpdater = Callable[
|
||||
[
|
||||
str,
|
||||
Callable[
|
||||
[Session, object],
|
||||
tuple[PluginInstallationRecord, object],
|
||||
],
|
||||
],
|
||||
PluginInstallationRecord,
|
||||
]
|
||||
|
||||
|
||||
def _identity_from_model(model: IdentityModel) -> PluginIdentity:
|
||||
"""把同一 Session 读出的身份模型还原为应用记录。"""
|
||||
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=(
|
||||
datetime.fromisoformat(model.bound_at)
|
||||
if model.bound_at
|
||||
else None
|
||||
),
|
||||
payload_applied_at=(
|
||||
datetime.fromisoformat(model.payload_applied_at)
|
||||
if model.payload_applied_at
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _identity_model_values(identity: PluginIdentity) -> dict[str, object]:
|
||||
"""把应用身份映射为不含自增主键的模型列值。"""
|
||||
return {
|
||||
"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 TransactionalPluginInstallationStore(PluginInstallationStore):
|
||||
"""以单张事务表协调单插件 membership、来源身份和 phase。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], Session],
|
||||
update_membership_atomically: AtomicMembershipUpdater,
|
||||
) -> None:
|
||||
"""保存事务会话工厂和配置 membership 的窄原子写入口。"""
|
||||
self._session_factory = session_factory
|
||||
self.__update_membership_atomically = update_membership_atomically
|
||||
|
||||
def __session(self) -> Session:
|
||||
"""创建不会在提交后过期状态的短生命周期 Session。"""
|
||||
session = self._session_factory()
|
||||
session.expire_on_commit = False
|
||||
return session
|
||||
|
||||
@staticmethod
|
||||
def __now() -> str:
|
||||
"""生成带时区的持久化更新时间。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@staticmethod
|
||||
def __phase(value: PluginInstallationPhase | str) -> PluginInstallationPhase:
|
||||
"""把调用方 phase 转为受限枚举。"""
|
||||
try:
|
||||
return (
|
||||
value
|
||||
if isinstance(value, PluginInstallationPhase)
|
||||
else PluginInstallationPhase(value)
|
||||
)
|
||||
except ValueError as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"未知插件安装 phase: {value}"
|
||||
) from error
|
||||
|
||||
@staticmethod
|
||||
def __to_record(model: PluginInstallation) -> PluginInstallationRecord:
|
||||
"""把 ORM 行还原为经过应用层校验的事务记录。"""
|
||||
try:
|
||||
return PluginInstallationRecord(
|
||||
transaction_id=model.transaction_id,
|
||||
plugin_id=model.plugin_id,
|
||||
phase=PluginInstallationPhase(model.phase),
|
||||
membership_before=model.membership_before,
|
||||
membership_target=model.membership_target,
|
||||
identity_before_revision=model.identity_before_revision,
|
||||
identity_target_revision=model.identity_target_revision,
|
||||
package_existed=model.package_existed,
|
||||
persistent_backup_existed=model.persistent_backup_existed,
|
||||
created_at=datetime.fromisoformat(model.created_at),
|
||||
updated_at=datetime.fromisoformat(model.updated_at),
|
||||
schema_version=model.schema_version,
|
||||
)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件安装事务 {model.transaction_id} 的持久化状态无效"
|
||||
) from error
|
||||
|
||||
@staticmethod
|
||||
def __identity_query(session: Session, plugin_id: str) -> IdentityModel | None:
|
||||
"""读取并锁定指定插件的身份行。"""
|
||||
return session.execute(
|
||||
select(IdentityModel)
|
||||
.where(IdentityModel.normalized_plugin_id == plugin_id.lower())
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def __membership_state(current: object, plugin_id: str) -> bool:
|
||||
"""只读取目标插件 membership,不把其他插件写入事务快照。"""
|
||||
if current is None:
|
||||
return False
|
||||
if not isinstance(current, list) or any(
|
||||
not isinstance(item, str) for item in current
|
||||
):
|
||||
raise PluginInstallationConflictError(
|
||||
"UserInstalledPlugins 当前值不是 JSON 字符串数组"
|
||||
)
|
||||
normalized_id = plugin_id.lower()
|
||||
return any(item.lower() == normalized_id for item in current)
|
||||
|
||||
@staticmethod
|
||||
def __write_membership(
|
||||
current: object,
|
||||
plugin_id: str,
|
||||
target: bool,
|
||||
) -> list[str]:
|
||||
"""在配置写锁内只增删目标插件,保留其他插件并发变更。"""
|
||||
if current is None:
|
||||
values: list[str] = []
|
||||
elif isinstance(current, list) and all(
|
||||
isinstance(item, str) for item in current
|
||||
):
|
||||
values = list(current)
|
||||
else:
|
||||
raise PluginInstallationConflictError(
|
||||
"UserInstalledPlugins 当前值不是 JSON 字符串数组"
|
||||
)
|
||||
|
||||
normalized_id = plugin_id.lower()
|
||||
values = [item for item in values if item.lower() != normalized_id]
|
||||
if target:
|
||||
values.append(plugin_id)
|
||||
return values
|
||||
|
||||
@classmethod
|
||||
def __identity_revision(
|
||||
cls,
|
||||
session: Session,
|
||||
plugin_id: str,
|
||||
) -> int | None:
|
||||
"""读取锁定身份行的 revision;缺行表示 CAS 的 null。"""
|
||||
identity = cls.__identity_query(session, plugin_id)
|
||||
return identity.revision if identity is not None else None
|
||||
|
||||
@classmethod
|
||||
def __write_identity(
|
||||
cls,
|
||||
session: Session,
|
||||
plugin_id: str,
|
||||
identity: PluginIdentity | None,
|
||||
) -> None:
|
||||
"""在调用方事务中写入或删除目标插件身份。"""
|
||||
current = cls.__identity_query(session, plugin_id)
|
||||
if identity is None:
|
||||
if current is not None:
|
||||
session.delete(current)
|
||||
return
|
||||
if identity.plugin_id != plugin_id:
|
||||
raise PluginInstallationConflictError(
|
||||
"PluginIdentity target 与事务 plugin_id 不一致"
|
||||
)
|
||||
values = _identity_model_values(identity)
|
||||
if current is None:
|
||||
session.add(IdentityModel(**values))
|
||||
else:
|
||||
for key, value in values.items():
|
||||
setattr(current, key, value)
|
||||
|
||||
@staticmethod
|
||||
def __assert_target_identity(
|
||||
record: PluginInstallationRecord,
|
||||
identity: PluginIdentity,
|
||||
) -> None:
|
||||
"""确认目标身份属于当前插件且 revision 只前进一步。"""
|
||||
if identity.plugin_id != record.plugin_id:
|
||||
raise PluginInstallationConflictError(
|
||||
"PluginIdentity target 与事务 plugin_id 不一致"
|
||||
)
|
||||
expected_revision = (record.identity_before_revision or 0) + 1
|
||||
if identity.revision != expected_revision:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {record.transaction_id} 的 target identity revision "
|
||||
f"必须为 {expected_revision}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __require_row(session: Session, transaction_id: str) -> PluginInstallation:
|
||||
"""读取并锁定事务行,缺失时拒绝继续写入。"""
|
||||
row = session.execute(
|
||||
select(PluginInstallation)
|
||||
.where(PluginInstallation.transaction_id == transaction_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件安装事务不存在: {transaction_id}"
|
||||
)
|
||||
return cast(PluginInstallation, row)
|
||||
|
||||
@classmethod
|
||||
def __check_phase(
|
||||
cls,
|
||||
row: PluginInstallation,
|
||||
expected_phase: PluginInstallationPhase | str,
|
||||
) -> PluginInstallationPhase:
|
||||
"""执行写操作共用的 phase CAS。"""
|
||||
expected = cls.__phase(expected_phase)
|
||||
try:
|
||||
actual = PluginInstallationPhase(row.phase)
|
||||
except ValueError as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {row.transaction_id} 的 phase 无效: {row.phase}"
|
||||
) from error
|
||||
if actual is not expected:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {row.transaction_id} phase 已变化: "
|
||||
f"expected={expected.value}, actual={actual.value}"
|
||||
)
|
||||
return actual
|
||||
|
||||
@classmethod
|
||||
def __assert_before_state(
|
||||
cls,
|
||||
record: PluginInstallationRecord,
|
||||
session: Session,
|
||||
current_membership: object,
|
||||
) -> None:
|
||||
"""确认目标插件仍处于事务创建时的 before 状态。"""
|
||||
membership = cls.__membership_state(current_membership, record.plugin_id)
|
||||
revision = cls.__identity_revision(session, record.plugin_id)
|
||||
if membership != record.membership_before:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {record.transaction_id} 的插件 membership 发生漂移"
|
||||
)
|
||||
if revision != record.identity_before_revision:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {record.transaction_id} 的插件身份 revision 发生漂移"
|
||||
)
|
||||
|
||||
def create(self, record: PluginInstallationRecord) -> PluginInstallationRecord:
|
||||
"""原子预留单插件 journal 槽位并立即 flush 唯一键竞争。"""
|
||||
def reserve(
|
||||
session: Session,
|
||||
current_membership: object,
|
||||
) -> tuple[PluginInstallationRecord, object]:
|
||||
"""在配置写事务内阻断同一物理插件的未收尾 journal。"""
|
||||
existing = session.execute(
|
||||
select(PluginInstallation)
|
||||
.where(
|
||||
func.lower(PluginInstallation.plugin_id)
|
||||
== record.plugin_id.lower()
|
||||
)
|
||||
.with_for_update()
|
||||
).scalars().first()
|
||||
if existing is not None:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件 {record.plugin_id} 存在未收尾安装事务: "
|
||||
f"{existing.transaction_id} ({existing.phase})"
|
||||
)
|
||||
session.add(
|
||||
PluginInstallation(
|
||||
transaction_id=record.transaction_id,
|
||||
plugin_id=record.plugin_id,
|
||||
phase=record.phase.value,
|
||||
membership_before=record.membership_before,
|
||||
membership_target=record.membership_target,
|
||||
identity_before_revision=record.identity_before_revision,
|
||||
identity_target_revision=record.identity_target_revision,
|
||||
package_existed=record.package_existed,
|
||||
persistent_backup_existed=record.persistent_backup_existed,
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
schema_version=record.schema_version,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return record, current_membership
|
||||
|
||||
try:
|
||||
return cast(
|
||||
PluginInstallationRecord,
|
||||
self.__update_membership_atomically(
|
||||
_INSTALLED_PLUGINS_KEY,
|
||||
reserve,
|
||||
),
|
||||
)
|
||||
except IntegrityError as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件安装事务创建发生并发竞争: {record.transaction_id}"
|
||||
) from error
|
||||
|
||||
def get(self, transaction_id: str) -> PluginInstallationRecord | None:
|
||||
"""按事务 ID 读取记录。"""
|
||||
session = self.__session()
|
||||
try:
|
||||
row = session.execute(
|
||||
select(PluginInstallation).where(
|
||||
PluginInstallation.transaction_id == transaction_id
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return self.__to_record(row) if row else None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str | None = None,
|
||||
) -> list[PluginInstallationRecord]:
|
||||
"""按创建时间稳定列出事务记录。"""
|
||||
session = self.__session()
|
||||
try:
|
||||
statement = select(PluginInstallation).order_by(
|
||||
PluginInstallation.created_at,
|
||||
PluginInstallation.transaction_id,
|
||||
)
|
||||
if plugin_id is not None:
|
||||
statement = statement.where(PluginInstallation.plugin_id == plugin_id)
|
||||
return [
|
||||
self.__to_record(row)
|
||||
for row in session.execute(statement).scalars()
|
||||
]
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def set_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
membership_target: bool,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""按 phase CAS 登记目标 membership 和身份 revision,不写业务状态。"""
|
||||
if not isinstance(membership_target, bool):
|
||||
raise PluginInstallationConflictError("membership_target 必须是布尔值")
|
||||
session = self.__session()
|
||||
try:
|
||||
with session.begin():
|
||||
row = self.__require_row(session, transaction_id)
|
||||
self.__check_phase(row, expected_phase)
|
||||
record = self.__to_record(row)
|
||||
if identity_target is not None:
|
||||
self.__assert_target_identity(record, identity_target)
|
||||
row.membership_target = membership_target
|
||||
row.identity_target_revision = (
|
||||
identity_target.revision if identity_target is not None else None
|
||||
)
|
||||
row.updated_at = self.__now()
|
||||
session.flush()
|
||||
return self.__to_record(row)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def commit_target(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
identity_target: PluginIdentity | None,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> PluginInstallationRecord:
|
||||
"""原子提交目标 membership、身份 CAS 和 COMMITTED phase。"""
|
||||
def commit(
|
||||
session: Session,
|
||||
current_membership: object,
|
||||
) -> tuple[PluginInstallationRecord, list[str]]:
|
||||
"""在配置行锁持有期间完成事务行、身份和 membership 写入。"""
|
||||
row = self.__require_row(session, transaction_id)
|
||||
self.__check_phase(row, expected_phase)
|
||||
record = self.__to_record(row)
|
||||
if record.membership_target is None:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {transaction_id} 尚未设置 membership target"
|
||||
)
|
||||
if identity_target is not None:
|
||||
self.__assert_target_identity(record, identity_target)
|
||||
if identity_target.revision != record.identity_target_revision:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {transaction_id} 的 target identity revision 不匹配"
|
||||
)
|
||||
elif record.identity_target_revision is not None:
|
||||
raise PluginInstallationConflictError(
|
||||
f"事务 {transaction_id} 缺少 target identity"
|
||||
)
|
||||
|
||||
self.__assert_before_state(record, session, current_membership)
|
||||
updated_membership = self.__write_membership(
|
||||
current_membership,
|
||||
record.plugin_id,
|
||||
record.membership_target,
|
||||
)
|
||||
self.__write_identity(session, record.plugin_id, identity_target)
|
||||
row.phase = PluginInstallationPhase.COMMITTED.value
|
||||
row.updated_at = self.__now()
|
||||
session.flush()
|
||||
return self.__to_record(row), updated_membership
|
||||
|
||||
try:
|
||||
return cast(
|
||||
PluginInstallationRecord,
|
||||
self.__update_membership_atomically(
|
||||
_INSTALLED_PLUGINS_KEY,
|
||||
commit,
|
||||
),
|
||||
)
|
||||
except IntegrityError as error:
|
||||
raise PluginInstallationConflictError(
|
||||
f"插件 {transaction_id} 的身份提交发生唯一键竞争"
|
||||
) from error
|
||||
|
||||
def delete(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase CAS 删除事务记录;缺失记录按幂等删除处理。"""
|
||||
session = self.__session()
|
||||
try:
|
||||
with session.begin():
|
||||
row = session.execute(
|
||||
select(PluginInstallation)
|
||||
.where(PluginInstallation.transaction_id == transaction_id)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
return False
|
||||
self.__check_phase(row, expected_phase)
|
||||
session.delete(row)
|
||||
session.flush()
|
||||
return True
|
||||
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"),
|
||||
"PluginInstallation": (
|
||||
"app.db.models.plugininstallation",
|
||||
"PluginInstallation",
|
||||
),
|
||||
"PluginIdentity": (
|
||||
"app.db.models.pluginidentity",
|
||||
"PluginIdentity",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""插件安装事务的单表持久化模型。"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, get_id_column
|
||||
|
||||
|
||||
class PluginInstallation(Base):
|
||||
"""保存单插件 membership、身份 CAS revision 和持久备份状态。"""
|
||||
|
||||
id = get_id_column()
|
||||
transaction_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
plugin_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
phase: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
membership_before: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
membership_target: Mapped[Optional[bool]] = mapped_column(Boolean)
|
||||
identity_before_revision: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
identity_target_revision: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
package_existed: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
persistent_backup_existed: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
created_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"transaction_id",
|
||||
name="uq_plugininstallation_transaction_id",
|
||||
),
|
||||
Index("ix_plugininstallation_plugin_id", "plugin_id"),
|
||||
Index("ix_plugininstallation_phase", "phase"),
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
import copy
|
||||
import threading
|
||||
from typing import Any, Optional, Union
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional, TypeVar, Union
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base import DbOper
|
||||
@@ -9,6 +11,8 @@ from app.db.models.systemconfig import SystemConfig
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
"""
|
||||
@@ -80,6 +84,37 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
self._publish_value(key, value)
|
||||
return result
|
||||
|
||||
def update_atomically(
|
||||
self,
|
||||
key: Union[str, SystemConfigKey],
|
||||
mutation: Callable[[Session, Any], tuple[T, Any]],
|
||||
) -> T:
|
||||
"""在配置写锁内提交关联记录,并在事务成功后发布最终配置值。"""
|
||||
if isinstance(key, SystemConfigKey):
|
||||
key = key.value
|
||||
self._require_loaded()
|
||||
with self._write_lock:
|
||||
|
||||
def write(db: Session) -> tuple[T, Any]:
|
||||
"""锁定配置行,把关联写入与最终配置值放入同一事务。"""
|
||||
conf = db.execute(
|
||||
select(SystemConfig)
|
||||
.where(SystemConfig.key == key)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
current = copy.deepcopy(conf.value if conf else None)
|
||||
result, value = mutation(db, current)
|
||||
committed_value = copy.deepcopy(value)
|
||||
if conf:
|
||||
conf.value = committed_value
|
||||
else:
|
||||
db.add(SystemConfig(key=key, value=committed_value))
|
||||
return result, committed_value
|
||||
|
||||
result, committed_value = self._execute_sync_write(write)
|
||||
self._publish_value(key, committed_value)
|
||||
return result
|
||||
|
||||
def get(self, key: Optional[Union[str, SystemConfigKey]] = None) -> Any:
|
||||
"""
|
||||
获取系统设置
|
||||
|
||||
Reference in New Issue
Block a user