Merge pull request #6666 from Aqr-K/feat/plugin-instance-target-enablement

feat(plugin): 插件实例默认调用目标与启停,未指定实例时不再兜底取第一个
This commit is contained in:
Aqr-K
2026-09-13 16:37:27 +08:00
committed by GitHub
parent 30d6d9e146
commit d44480d739
54 changed files with 2853 additions and 73 deletions

View File

@@ -4,7 +4,16 @@ from __future__ import annotations
from typing import Any, List, Optional, Self, cast
from sqlalchemy import JSON, Index, String, UniqueConstraint, select
from sqlalchemy import (
JSON,
Boolean,
Index,
String,
UniqueConstraint,
column,
false,
select,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
@@ -31,6 +40,20 @@ class PluginInstance(Base):
``log_expires_at`` 为空表示覆盖不过期。过期判定在读取时惰性执行,实现见
``app.runtime.log``,库里只存原样设置,不存已折算的结果。
``is_enabled`` 是「这份配置是否应当被实例化并启动」的唯一判据,也是卸载与恢复的
开关:置假即卸载,配置与展示信息原样留在这一行等待再次启用,因而不需要另设一个
卸载时间列去表达同一件事。行被删掉才是彻底清理。这一列的价值就是把「在册」与
「有配置」拆开——此前两者绑死,想保住配置就只能留着一个在跑的实例。
它与该实例此刻是否在运行无关——运行态由运行时持有、不落盘;也与插件自身在
``config_data`` 里按场景判定的业务开关无关。
``is_default_target`` 标记该实例是否为所属源插件的默认调用目标,即外部调用只给
插件 ID、没给实例 ID 时应当选中的那一行;与该实例是本体还是分身无关,两者都可能
被选为默认调用目标。「同一源插件至多一个默认调用目标」这条不变量由
``ux_plugininstance_default_target`` 条件唯一索引在数据库层强制,只索引置位的行,
不靠应用层纪律:置位要先清旧再置新,两条 DML 之间的窗口只有库层约束拦得住。
表名由 ``Base`` 按类名自动派生为小写 ``plugininstance``。
"""
@@ -40,6 +63,14 @@ class PluginInstance(Base):
plugin_name: Mapped[Optional[str]] = mapped_column(String(255))
plugin_desc: Mapped[Optional[str]] = mapped_column(String(255))
plugin_icon: Mapped[Optional[str]] = mapped_column(String(255))
# server_default 与迁移 DDL 保持一致:只写 Python 端 default 时 create_all 建出的
# 表不带 DEFAULT,与迁移建出的表结构不同,alembic 会一直报出差异
is_default_target: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
is_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
log_level: Mapped[Optional[str]] = mapped_column(String(16))
log_expires_at: Mapped[Optional[str]] = mapped_column(String(40))
config_data: Mapped[Optional[Any]] = mapped_column(JSON)
@@ -49,6 +80,16 @@ class PluginInstance(Base):
__table_args__ = (
UniqueConstraint("instance_id", name="uq_plugininstance_instance_id"),
Index("ix_plugininstance_source_plugin_id", "source_plugin_id"),
# 条件谓词按方言各给一份:布尔列与 True 比较时 SQLite 编译为 ``IS 1``、
# PostgreSQL 编译为 ``IS true``。谓词整个丢失会退化成「每个源插件只能有一行
# 实例」,把插件分身整个锁死,因此不能只给一份共用
Index(
"ux_plugininstance_default_target",
"source_plugin_id",
unique=True,
sqlite_where=column("is_default_target", Boolean).is_(True),
postgresql_where=column("is_default_target", Boolean).is_(True),
),
)
@property
@@ -63,10 +104,15 @@ class PluginInstance(Base):
本体行会在插件首次存配置时被隐式建出,配置被删掉后若各列皆空就只剩身份列,
留着会让「有哪些插件登记过本体设置」的枚举逐步失真,因而可以回收。分身行不
适用:分身的存在本身就由这一行表达,清空设置不等于删除分身。
启用位必须计入:它是本体的装载判据,一个启用中的本体行清空配置后若被当成
空行回收掉,该插件下次启动就再也不会被加载。
"""
return not any(
(
self.config_data is not None,
self.is_default_target,
self.is_enabled,
self.log_level,
self.log_expires_at,
self.plugin_name,