feat: 支持 v2 插件、独立前端和签名制品发布 (#130)

* feat: 支持 v2 插件、原生扩展与签名发布

* fix: 修复测试

* feat: 增强宿主 SDK、运行观测与制品维护能力

* feat: 增强文件传输、配置观测与本地开发能力

* feat: 支持 SSE 实时事件流并完善开发脚手架

* feat: 完善长连接鉴权复核与关闭回收

* feat: 增强生命周期与任务关闭可靠性

* feat: 收紧健康检查契约并补齐前端与真实网络验收

* feat: 增加SDK溯源与离线更新工具并验证跨版本兼容

* feat: 增加持久化业务示例与受控 Python 交付

* feat: 增加统一运行诊断与多worker状态观测

* feat: 增加固定目标发布等待与自动验收

* fix: 修复多环境插件部署前缀与代理配置不一致

* fix: 修复测试
This commit is contained in:
insistence
2026-10-09 23:49:45 +08:00
committed by GitHub
parent a89aec54aa
commit 4bd83bc7cd
287 changed files with 41945 additions and 331 deletions
@@ -0,0 +1,9 @@
__pycache__/
.pytest_cache/
*.pyc
target/
web/node_modules/
web/dist/
.env
*.pem
*.key
@@ -0,0 +1,224 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pyo3"
version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "700d18fa267b73b9b521fd7e13580e2f446916f176cacee1ab63fcc8191f1655"
dependencies = [
"libc",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
]
[[package]]
name = "pyo3-async-runtimes"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3ef68daa7316a3fac65e5e18b2203f010346de1c1c53456811a2624673ab046"
dependencies = [
"futures-channel",
"futures-util",
"once_cell",
"pin-project-lite",
"pyo3",
"tokio",
]
[[package]]
name = "pyo3-build-config"
version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b3fc0c4d08f6bb10e71fe39dfb9e2f59c6eb6854e22ec8092f50c69a4499adb"
dependencies = [
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6100e8a4b5eba53afaa5ed078364851a0b2499c553a44c31026b929049b49dc6"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.29.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6143877a16e82b5a727b7127ff4cd86858a24a28d745d72f43e6f227c7b1bdb3"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "__DISTRIBUTION__"
version = "1.0.0"
dependencies = [
"pyo3",
"pyo3-async-runtimes",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
@@ -0,0 +1,18 @@
[package]
name = "__DISTRIBUTION__"
version = "1.0.0"
edition = "2021"
publish = false
[lib]
name = "_native"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.29.2", features = ["abi3-py310"] }
pyo3-async-runtimes = { version = "0.29.0", features = ["tokio-runtime"] }
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
@@ -0,0 +1,112 @@
//! 编译为原生扩展的 ASGI 插件;身份认证与权限校验继续使用宿主 Python SDK。
use pyo3::prelude::*;
use pyo3::types::PyDict;
/// 声明子应用工厂,实际资源初始化交给生命周期回调。
#[pyfunction]
fn create_plugin<'py>(py: Python<'py>, _host: Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
let kwargs = PyDict::new(py);
kwargs.set_item("app_factory", wrap_pyfunction!(create_app, py)?)?;
py.import("plugins.core.sdk")?
.getattr("PluginDefinition")?
.call((), Some(&kwargs))
}
/// 为当前 worker 创建独立子应用,并通过宿主 SDK 绑定生命周期和接口权限。
#[pyfunction]
fn create_app<'py>(py: Python<'py>, host: Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
let sdk = py.import("plugins.core.sdk")?;
let lifespan = sdk.getattr("plugin_lifespan")?.call1((
host,
wrap_pyfunction!(startup, py)?,
wrap_pyfunction!(shutdown, py)?,
))?;
let kwargs = PyDict::new(py);
kwargs.set_item("title", "__PLUGIN_ID__")?;
kwargs.set_item("lifespan", lifespan)?;
kwargs.set_item("docs_url", py.None())?;
kwargs.set_item("redoc_url", py.None())?;
let app = py
.import("fastapi")?
.getattr("FastAPI")?
.call((), Some(&kwargs))?;
let options = PyDict::new(py);
options.set_item("permission", "__PLUGIN_ID__:view")?;
let endpoint = sdk
.getattr("plugin_endpoint")?
.call((wrap_pyfunction!(info, py)?,), Some(&options))?;
let route_options = PyDict::new(py);
route_options.set_item("summary", "获取插件信息接口")?;
route_options.set_item(
"description",
"用于获取当前插件标识、宿主API版本、运行引擎和服务器时间",
)?;
route_options.set_item("response_model", py.get_type::<PyDict>())?;
app.call_method(
"add_api_route",
("/api/info", endpoint),
Some(&route_options),
)?;
Ok(app)
}
/// 记录插件启动,并通过 Python 可等待对象返回就绪状态。
#[pyfunction]
fn startup<'py>(py: Python<'py>, host: Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
host.getattr("logger")?
.call_method1("info", ("__PLUGIN_ID__ started",))?;
pyo3_async_runtimes::tokio::future_into_py(py, async {
Python::attach(|py| {
let state = PyDict::new(py);
state.set_item("pluginReady", true)?;
Ok(state.unbind())
})
})
}
/// 演示插件关闭回调,仅释放插件自己创建的资源。
#[pyfunction]
fn shutdown<'py>(py: Python<'py>, host: Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
host.getattr("logger")?
.call_method1("info", ("__PLUGIN_ID__ stopped",))?;
// 宿主共享的数据库连接池和 Redis 客户端继续由宿主负责关闭。
pyo3_async_runtimes::tokio::future_into_py(py, async { Ok(()) })
}
/// 通过可等待对象返回健康结果,可扩展插件自有依赖检查。
#[pyfunction]
fn health<'py>(py: Python<'py>, _context: Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
pyo3_async_runtimes::tokio::future_into_py(py, async { Ok(true) })
}
#[pyfunction]
fn info<'py>(py: Python<'py>, context: Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
let host = context.getattr("host")?;
let plugin_id: String = host.getattr("plugin_id")?.extract()?;
let api_version: String = host.getattr("api_version")?.extract()?;
let timestamp: String = py
.import("utils.time_util")?
.getattr("TimezoneUtil")?
.call_method0("utc_now")?
.call_method0("isoformat")?
.extract()?;
pyo3_async_runtimes::tokio::future_into_py(py, async move {
Python::attach(|py| {
let result = PyDict::new(py);
result.set_item("pluginId", plugin_id)?;
result.set_item("hostApiVersion", api_version)?;
result.set_item("engine", "rust")?;
result.set_item("serverTime", timestamp)?;
Ok(result.unbind())
})
})
}
/// 导出清单和宿主运行时需要的 Python 扩展入口。
#[pymodule]
fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_function(wrap_pyfunction!(create_plugin, module)?)?;
module.add_function(wrap_pyfunction!(create_app, module)?)?;
module.add_function(wrap_pyfunction!(health, module)?)?;
Ok(())
}
@@ -0,0 +1,14 @@
[build-system]
requires = ["maturin>=1.15,<2"]
build-backend = "maturin"
[project]
name = "__DISTRIBUTION__"
version = "1.0.0"
requires-python = ">=3.10"
description = "RuoYi native ASGI plugin example"
[tool.maturin]
python-source = "python"
module-name = "ruoyi_plugin___PLUGIN_ID__._native"
bindings = "pyo3"
@@ -0,0 +1,85 @@
from datetime import datetime, timezone
from fastapi import FastAPI
from plugins.core.sdk import (
PluginDefinition,
PluginHostContext,
PluginRequestContext,
plugin_endpoint,
plugin_lifespan,
)
async def startup(host: PluginHostContext) -> dict[str, bool]:
"""
记录插件启动并返回就绪状态,可在此初始化插件自有资源。
:param host: 宿主提供的插件上下文
:return: 插件就绪状态
"""
host.logger.info("__PLUGIN_ID__ started")
return {"pluginReady": True}
async def shutdown(host: PluginHostContext) -> None:
"""
记录插件关闭,可在此释放插件自有资源并保留宿主共享连接池。
:param host: 宿主提供的插件上下文
:return: None
"""
host.logger.info("__PLUGIN_ID__ stopped")
# 仅释放插件自身创建的资源,不关闭宿主 Redis 或数据库连接池。
async def health(context: object) -> bool:
"""
返回本模板的健康检查结果,可在此扩展插件自有依赖检查。
:param context: 宿主调用健康检查时传入的上下文
:return: 插件是否健康
"""
return True
async def info(context: PluginRequestContext) -> dict[str, str]:
return {
"pluginId": context.host.plugin_id,
"hostApiVersion": context.host.api_version,
"engine": "python",
"serverTime": datetime.now(timezone.utc).isoformat(),
}
def create_app(host: PluginHostContext) -> FastAPI:
"""
创建受宿主权限保护的 ASGI 子应用并绑定插件生命周期。
:param host: 宿主提供的插件上下文
:return: 插件 ASGI 子应用
"""
app = FastAPI(
title="__PLUGIN_ID__",
docs_url=None,
redoc_url=None,
lifespan=plugin_lifespan(host, startup, shutdown),
)
app.add_api_route(
"/api/info",
plugin_endpoint(info, permission="__PLUGIN_ID__:view"),
summary="获取插件信息接口",
description="用于获取当前插件标识、宿主API版本、运行引擎和服务器时间",
response_model=dict[str, str],
)
return app
def create_plugin(host: PluginHostContext) -> PluginDefinition:
"""
声明插件能力,实际资源初始化交给生命周期回调。
:param host: 宿主提供的插件上下文
:return: 插件能力声明
"""
return PluginDefinition(app_factory=create_app)
@@ -0,0 +1,233 @@
import argparse
import json
import shutil
import stat
import unicodedata
from pathlib import Path, PureWindowsPath
from typing import Any
PLUGIN_ID = '__PLUGIN_ID__'
BUNDLE = __BUNDLE__
SOURCE = Path(__file__).resolve().parent
RELEASE_MANIFEST = 'release-files.json'
MANIFEST_VERSION = 1
MAX_MANIFEST_BYTES = 64 * 1024
MAX_SOURCE_FILES = 1024
MAX_PATH_LENGTH = 1024
MAX_PATH_COMPONENTS = 32
MAX_COMPONENT_LENGTH = 255
FIRST_PRINTABLE_CHARACTER = 32
DELETE_CHARACTER = 127
SOURCE_EXTENSIONS = {'.py', '.sql'}
FORBIDDEN_DIRECTORIES = {
'tests',
'test',
'keys',
'vendor',
'node_modules',
'__pycache__',
'build',
'dist',
'target',
'web',
}
FORBIDDEN_FILES = {'build_release.py', 'conftest.py', 'setup.py'}
WEB_EXTENSIONS = {
'.html',
'.js',
'.css',
'.svg',
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.ico',
'.woff',
'.woff2',
'.ttf',
}
REPARSE_POINT = getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400)
def checked_file(path: Path) -> Path:
"""
校验交付文件位于源码目录内,且不含链接、重解析点或硬链接。
:param path: 待交付的源码文件路径
:return: 通过检查的源码文件路径
"""
for part in (path, *path.parents):
if part == SOURCE:
break
metadata = part.lstat()
if part.is_symlink() or getattr(metadata, 'st_file_attributes', 0) & REPARSE_POINT:
raise ValueError(f'交付资源不能是链接或重解析点:{part}')
resolved = path.resolve()
if not resolved.is_relative_to(SOURCE) or not path.is_file():
raise ValueError(f'交付文件必须位于源码目录:{path}')
if path.stat().st_nlink > 1:
raise ValueError(f'交付文件不能是硬链接:{path}')
return path
def checked_relative_path(value: str) -> tuple[str, ...]:
"""
校验明确的跨平台相对路径,不接受通配符、隐藏组件或设备路径。
:param value: 清单或 bundle 中的相对路径
:return: 未经折叠或重写的路径组件
"""
if not isinstance(value, str):
raise ValueError('交付路径必须为字符串')
parts = value.split('/')
if (
not value
or len(value) > MAX_PATH_LENGTH
or len(parts) > MAX_PATH_COMPONENTS
or unicodedata.normalize('NFC', value) != value
or any(
not part
or part.startswith('.')
or part.endswith((' ', '.'))
or len(part) > MAX_COMPONENT_LENGTH
or PureWindowsPath(part).is_reserved()
or any(
char in '<>:"\\|?*[]' or ord(char) < FIRST_PRINTABLE_CHARACTER or ord(char) == DELETE_CHARACTER
for char in part
)
for part in parts
)
):
raise ValueError(f'交付清单包含不安全路径:{value!r}')
return tuple(parts)
def checked_inventory(names: list[str]) -> None:
"""
拒绝重复文件、文件与目录冲突以及任一级目录的大小写歧义。
:param names: 全部交付文件的相对路径
:return: None
"""
spellings: dict[str, str] = {}
files: set[str] = set()
directories: set[str] = set()
for name in names:
parts = checked_relative_path(name)
key = name.casefold()
if key in files:
raise ValueError(f'交付清单包含重复文件或大小写冲突:{name}')
files.add(key)
for count in range(1, len(parts) + 1):
prefix = '/'.join(parts[:count])
if spellings.setdefault(prefix.casefold(), prefix) != prefix:
raise ValueError(f'交付清单包含目录大小写冲突:{name}')
if count < len(parts):
directories.add(prefix.casefold())
if files & directories:
raise ValueError('交付清单存在文件与父目录冲突')
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
"""
解析 JSON 对象时拒绝重复字段,避免不同工具对同一清单作不同解释。
:param pairs: JSON 对象按原始顺序出现的字段
:return: 不包含重复字段的对象
"""
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f'交付清单包含重复字段:{key}')
result[key] = value
return result
def source_resources() -> list[Path]:
"""
按有大小和数量上限的显式清单读取 Python、SQL 资源,不扫描业务源码。
:return: 已通过路径、类型和链接检查的源码文件列表
"""
path = checked_file(SOURCE / RELEASE_MANIFEST)
with path.open('rb') as source:
content = source.read(MAX_MANIFEST_BYTES + 1)
if len(content) > MAX_MANIFEST_BYTES:
raise ValueError('release-files.json 超过 64 KiB 上限')
manifest = json.loads(content.decode('utf-8-sig'), object_pairs_hook=unique_object)
if (
not isinstance(manifest, dict)
or set(manifest) != {'schemaVersion', 'files'}
or type(manifest['schemaVersion']) is not int
or manifest['schemaVersion'] != MANIFEST_VERSION
):
raise ValueError('release-files.json 必须包含 schemaVersion: 1 和 files')
names = manifest['files']
if not isinstance(names, list) or not 1 <= len(names) <= MAX_SOURCE_FILES:
raise ValueError('交付清单 files 必须为包含 1 至 1024 项的列表')
checked_inventory(names)
for name in names:
parts = name.split('/')
filename = parts[-1].casefold()
if (
Path(name).suffix not in SOURCE_EXTENSIONS
or any(part.casefold() in FORBIDDEN_DIRECTORIES for part in parts[:-1])
or filename in FORBIDDEN_FILES
or filename.startswith('test_')
or filename.endswith('_test.py')
):
raise ValueError(f'仅允许业务 .py/.sql 文件,不能包含测试、密钥或开发资源:{name}')
return [checked_file(SOURCE / name) for name in names]
def bundle_resources() -> list[Path]:
"""
收集已构建 bundle,保留原有静态扩展名、隐藏文件和链接限制。
:return: 已通过检查的 web/dist 文件列表
"""
dist = SOURCE / 'web' / 'dist'
checked_file(dist / 'index.html')
resources = []
for path in sorted(dist.rglob('*')):
if path.is_symlink() or getattr(path.lstat(), 'st_file_attributes', 0) & REPARSE_POINT:
raise ValueError(f'bundle 不允许链接:{path}')
if not path.is_file():
continue
if path.suffix.lower() not in WEB_EXTENSIONS:
raise ValueError(f'bundle 存在非交付文件;不要包含源码、source map 或密钥:{path}')
resources.append(checked_file(path))
if not any(path.relative_to(dist).as_posix() == 'index.html' for path in resources):
raise ValueError('bundle 入口必须使用准确的 index.html 文件名')
return resources
def main() -> None:
"""
按显式文件清单组装新交付目录,不安装依赖、导入插件或生成签名。
:return: None
"""
parser = argparse.ArgumentParser(
description='按 release-files.json 组装交付文件;不安装依赖、不导入插件、不生成签名。'
)
parser.add_argument('--output', type=Path, required=True, help='全新输出目录(其中创建插件ID目录)')
output = parser.parse_args().output.resolve()
if output.exists() or output.is_relative_to(SOURCE) or SOURCE.is_relative_to(output):
parser.error('输出目录必须不存在,且不得与源码目录重叠')
resources = [checked_file(SOURCE / 'plugin.yaml'), *source_resources()]
if BUNDLE:
resources.extend(bundle_resources())
checked_inventory([path.relative_to(SOURCE).as_posix() for path in resources])
plugin = output / PLUGIN_ID
plugin.mkdir(parents=True)
for resource in resources:
target = plugin / resource.relative_to(SOURCE)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(resource, target)
print(json.dumps({'plugin': str(plugin), 'signed': False}, ensure_ascii=False))
if __name__ == '__main__':
main()
@@ -0,0 +1,74 @@
import logging
import os
from collections.abc import Awaitable, Callable
from pathlib import Path
import httpx
import pytest
from fastapi import Request
from starlette import status
from starlette.responses import Response
from plugins.core.discovery.scanner import PluginScanner
from plugins.core.runtime.entrypoint import PluginEntrypointLoader
from plugins.core.sdk import (
PluginHostContext,
PluginRequestContext,
await_plugin_callback,
)
NATIVE = __NATIVE__
DIRECTORY = os.environ.get("RUOYI_PLUGIN_DIRECTORY")
ROOT = Path(DIRECTORY).resolve() if DIRECTORY else Path(__file__).resolve().parents[1]
pytestmark = pytest.mark.skipif(
NATIVE and not DIRECTORY, reason="请先构建并设置 RUOYI_PLUGIN_DIRECTORY"
)
@pytest.mark.asyncio
async def test_plugin_contract_and_permission() -> None:
"""
验证插件工厂、生命周期、接口权限和健康检查合约。
:return: None
"""
plugin = PluginScanner(ROOT.parent).load_manifest(ROOT / "plugin.yaml")
host = PluginHostContext(
"__PLUGIN_ID__", ROOT, logger=logging.getLogger("__PLUGIN_ID__")
)
loader = PluginEntrypointLoader(plugin)
definition = loader.load(host)
app = definition.app_factory(host)
permissions = set()
@app.middleware("http")
async def inject_test_context(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""
注入本地请求上下文替身,不连接宿主数据库。
:param request: 已由宿主注入插件上下文的请求
:param call_next: 继续执行后续请求处理的回调
:return: 后续请求处理返回的响应
"""
request.state.plugin_context = PluginRequestContext(
host, object(), frozenset(permissions)
)
return await call_next(request)
async with (
app.router.lifespan_context(app),
httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://plugin.test"
) as client,
):
denied = await client.get("/api/info")
assert denied.status_code == status.HTTP_403_FORBIDDEN
permissions.add("__PLUGIN_ID__:view")
response = await client.get("/api/info")
assert response.status_code == status.HTTP_200_OK
assert response.json()["pluginId"] == "__PLUGIN_ID__"
assert response.json()["engine"] == "__RUNTIME__"
health = loader.load_callable(plugin.manifest.backend.health.checker)
assert await await_plugin_callback(health, host) is True
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>__PLUGIN_ID__ 本地开发</title>
<style>
body {
margin: 0;
font:
14px/1.6 system-ui,
sans-serif;
color: #243244;
background: #f4f6f8;
}
header {
padding: 16px 24px;
border-bottom: 1px solid #cbd5e1;
background: white;
}
h1 {
margin: 0;
font-size: 20px;
}
p {
margin: 6px 0 12px;
}
.controls {
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
input[type='number'] {
width: 80px;
}
select,
input,
button {
font: inherit;
}
button,
select {
min-height: 44px;
}
:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
iframe {
border: 0;
width: 100%;
min-height: 900px;
display: block;
}
</style>
</head>
<body>
<header>
<h1>__PLUGIN_ID__ · 模拟宿主</h1>
<p>仅使用本地模拟响应,不连接真实后端。此页面不进入生产构建。</p>
<div class="controls">
<label
>主题
<select id="theme">
<option value="light">浅色</option>
<option value="dark">深色</option>
</select></label
>
<label
>延迟(毫秒)
<input
id="delay"
type="number"
value="300"
min="0"
max="5000"
/></label>
<label
><input
id="failure"
type="checkbox"
/>模拟请求失败</label
>
<button
id="reload"
type="button"
>
重载插件
</button>
<button
id="logout"
type="button"
>
模拟退出
</button>
<span
id="status"
role="status"
>等待连接</span
>
</div>
</header>
<iframe
id="plugin-frame"
title="__PLUGIN_ID__ 插件页面"
></iframe>
<script
type="module"
src="/dev.js"
></script>
</body>
</html>
@@ -0,0 +1,62 @@
import { createPluginHostBridge, validatePluginSession } from './vendor/pluginBridge.js'
const element = (id) => document.getElementById(id)
const frame = element('plugin-frame')
let route = '/'
const bridge = createPluginHostBridge({
pluginId: '__PLUGIN_ID__',
session: validatePluginSession(
{
pluginId: '__PLUGIN_ID__',
bridgeVersion: 1,
uiBase: '/apps/__PLUGIN_ID__/ui/',
apiBase: '/apps/__PLUGIN_ID__/api/',
csrfToken: 'local-mock-no-credentials',
expiresIn: 3600,
},
'__PLUGIN_ID__'
),
getTarget: () => frame.contentWindow,
request: async ({ url, method, signal }) => {
await new Promise((resolve, reject) => {
if (signal.aborted) return reject(new Error('已取消'))
const cancel = () => {
clearTimeout(timer)
reject(new Error('已取消'))
}
const timer = setTimeout(
() => {
signal.removeEventListener('abort', cancel)
resolve()
},
Math.max(0, Math.min(5000, Number(element('delay').value) || 0))
)
signal.addEventListener('abort', cancel, { once: true })
})
if (element('failure').checked) throw new Error('模拟请求失败')
if (method !== 'get' || url !== '/apps/__PLUGIN_ID__/api/info')
throw new Error('模拟接口未实现')
return { pluginId: '__PLUGIN_ID__', serverTime: new Date().toISOString() }
},
getContext: () => ({
theme: { mode: element('theme').value },
language: 'zh-CN',
timeZone: 'Asia/Shanghai',
route,
}),
onReady: () => {
element('status').textContent = '模拟连接已建立'
},
onRoute: (next) => {
route = next
bridge.updateRoute(route)
},
})
frame.src = '/index.html'
element('theme').addEventListener('change', () => bridge.updatePreferences())
element('reload').addEventListener('click', () => frame.contentWindow.location.reload())
element('logout').addEventListener('click', () => {
bridge.destroy({ logout: true })
element('status').textContent = '模拟会话已退出;刷新整个页面可重新连接'
})
window.addEventListener('pagehide', () => bridge.destroy(), { once: true })
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>__PLUGIN_ID__</title>
</head>
<body>
<main id="workspace" aria-busy="true">
<header>
<div><p class="eyebrow">插件应用</p><h1>__PLUGIN_ID__</h1></div>
<button id="refresh" type="button" disabled>刷新状态</button>
</header>
<p id="notice" role="status" aria-live="polite">正在连接管理平台…</p>
<dl>
<div><dt>插件标识</dt><dd id="plugin-id">等待连接</dd></div>
<div><dt>服务端时间</dt><dd id="server-time">等待连接</dd></div>
</dl>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>
@@ -0,0 +1,86 @@
import { createPluginClient } from '../vendor/pluginBridge.js'
import './style.css'
// 统一按 ID 获取模板页面中的固定元素。
const element = (id) => document.getElementById(id)
let client
let context = {}
let connected = false
let ended = false
/**
* 更新连接提示,并区分错误状态。
*/
const showNotice = (text, error = false) => {
element('notice').textContent = text
element('notice').classList.toggle('error', error)
}
/**
* 同步请求忙碌状态;退出登录后保持刷新按钮禁用。
*/
const setBusy = (busy) => {
element('workspace').setAttribute('aria-busy', String(busy))
element('refresh').disabled = busy || ended
}
/**
* 保存宿主上下文,并应用当前主题和语言。
*/
const applyContext = (next) => {
context = { ...context, ...next }
document.documentElement.dataset.theme = context.theme?.mode || 'light'
document.documentElement.lang = context.language || 'zh-CN'
}
/**
* 通过宿主桥接会话查询插件状态,忽略退出登录后的响应。
*/
async function refresh() {
if (!connected || ended) return
setBusy(true)
try {
const result = await client.request({ method: 'GET', path: 'info' })
if (ended) return
element('plugin-id').textContent = result.pluginId
element('server-time').textContent = new Intl.DateTimeFormat(context.language || 'zh-CN', {
timeZone: context.timeZone || 'UTC', dateStyle: 'short', timeStyle: 'medium',
}).format(new Date(result.serverTime))
showNotice('连接正常,已获取最新状态。')
} catch (error) {
if (!ended) showNotice(`${error.message},可点击“刷新状态”重试。`, true)
} finally {
setBusy(false)
}
}
/**
* 建立或重建宿主会话,并订阅偏好、刷新和退出登录事件。
*/
async function connect() {
setBusy(true)
try {
client?.destroy()
const config = JSON.parse(element('ruoyi-plugin-config').textContent)
client = createPluginClient({ pluginId: config.pluginId })
client.subscribe(({ type, payload }) => {
if (type === 'preferences' || type === 'route') applyContext(payload)
if (type === 'refresh') refresh()
if (type === 'logout') {
ended = true
connected = false
client.destroy()
element('plugin-id').textContent = '会话已结束'
element('server-time').textContent = '—'
showNotice('登录已结束,请重新登录管理平台。')
setBusy(false)
}
})
applyContext(await client.ready)
connected = true
await refresh()
} catch (error) {
connected = false
showNotice(error.message || '请从管理平台打开插件页面。', true)
setBusy(false)
}
}
element('refresh').addEventListener('click', () => connected ? refresh() : connect())
// 页面离开时释放桥接层的事件监听和待处理请求。
window.addEventListener('pagehide', () => client?.destroy(), { once: true })
connect()
@@ -0,0 +1,8 @@
{
"name": "__DISTRIBUTION__-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": { "dev": "vite --host 127.0.0.1 --open /dev.html", "build": "vite build" },
"devDependencies": { "vite": "6.4.3" }
}
@@ -0,0 +1,41 @@
:root {
color-scheme: light;
--background: #f4f6f8;
--surface: #ffffff;
--text: #192b3a;
--muted: #50616f;
--border: #d6dfe6;
--accent: #075c9e;
--error: #a92121;
font: 16px/1.6 system-ui, sans-serif;
color: var(--text);
background: var(--background);
}
:root[data-theme='dark'] {
color-scheme: dark;
--background: #121c24;
--surface: #1b2b36;
--text: #edf4f8;
--muted: #b4c8d5;
--border: #405867;
--accent: #7cc4f7;
--error: #ffaaa2;
}
* { box-sizing: border-box; }
body { margin: 0; padding: clamp(16px, 4vw, 40px); }
main { max-width: 880px; margin-inline: auto; }
header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; }
h1 { margin: 0; font-size: clamp(24px, 5vw, 36px); overflow-wrap: anywhere; }
.eyebrow { margin: 0; color: var(--muted); }
button {
min-height: 44px; padding: 8px 18px; border: 1px solid var(--accent); border-radius: 6px;
background: var(--surface); color: var(--accent); font: inherit; cursor: pointer;
}
button:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
button:disabled { opacity: 0.6; cursor: default; }
#notice { min-height: 52px; margin-block: 24px; color: var(--muted); overflow-wrap: anywhere; }
#notice.error { color: var(--error); }
dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: 16px; }
dl > div { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 24px; }
dt { color: var(--muted); font-size: 14px; }
dd { margin: 8px 0 0; font-size: 18px; overflow-wrap: anywhere; }
@@ -0,0 +1,26 @@
export default {
// 使用相对资源路径,使 bundle 可由宿主从插件目录交付。
base: './',
plugins: [
{
name: 'local-plugin-config',
apply: 'serve',
transformIndexHtml(html, context) {
if (!['/', '/index.html'].includes(context.path)) return html
return {
html,
tags: [
{
tag: 'script',
attrs: { id: 'ruoyi-plugin-config', type: 'application/json' },
children: JSON.stringify({ pluginId: '__PLUGIN_ID__' }),
injectTo: 'head',
},
],
}
},
},
],
// 交付目录只包含构建产物,不生成源码映射文件。
build: { sourcemap: false },
}
@@ -7,6 +7,7 @@ from .backend import PluginBackendScaffoldTemplateBuilder
from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver
from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver
from .payload import PluginScaffoldPayloadBuilder, PluginScaffoldPlanPayload
from .v2 import PluginV2ScaffoldBuilder
class PluginScaffoldBuilder:
@@ -56,6 +57,16 @@ class PluginScaffoldBuilder:
:return: 插件模板写入计划
"""
self._validate_plugin_id(plugin_id)
normalized_template = (template or PluginScaffoldTemplateResolver.DEFAULT_TEMPLATE).strip()
if normalized_template in PluginScaffoldTemplateResolver.V2_TEMPLATES:
return PluginV2ScaffoldBuilder(self.backend_root, self.frontend_root).build_plan(
plugin_id,
template=normalized_template,
backend=backend,
frontend=frontend,
test=test,
frontend_version=frontend_version,
)
options = self._merge_options(
PluginScaffoldTemplateResolver.resolve(template),
backend=backend,
@@ -33,7 +33,8 @@ class PluginScaffoldTemplateResolver:
使用 Resolver 模式将模板名称转换为稳定的模板生成选项。
"""
SUPPORTED_TEMPLATES = {'minimal', 'backend-only', 'full-stack', 'scheduled-job', 'crud-page'}
V2_TEMPLATES = frozenset({'python-asgi', 'python-bundle', 'rust-asgi', 'rust-bundle'})
SUPPORTED_TEMPLATES = {'minimal', 'backend-only', 'full-stack', 'scheduled-job', 'crud-page'} | V2_TEMPLATES
DEFAULT_TEMPLATE = 'full-stack'
@classmethod
@@ -45,6 +46,16 @@ class PluginScaffoldTemplateResolver:
:return: 插件模板生成选项
"""
normalized_template = (template or cls.DEFAULT_TEMPLATE).strip() or cls.DEFAULT_TEMPLATE
if normalized_template in cls.V2_TEMPLATES:
return PluginScaffoldOptions(
backend=True,
frontend=normalized_template.endswith('-bundle'),
migration=False,
seed=False,
job=False,
config=False,
test=True,
)
if normalized_template == 'minimal':
return PluginScaffoldOptions(
backend=True,
@@ -0,0 +1,363 @@
import json
from importlib.resources import files
from pathlib import Path
from typing import Any
import yaml
from cli.runtime.plugin.sdk import PluginBridgeSdk
from .payload import PluginScaffoldPlanPayload
class PluginV2ScaffoldBuilder:
"""
独立 v2 插件源码工程模板构建器。
"""
def __init__(self, backend_root: Path, frontend_root: Path) -> None:
"""
初始化独立 v2 工程模板构建器。
:param backend_root: 宿主后端项目根目录
:param frontend_root: 宿主前端项目根目录,用于读取桥接 SDK
:return: None
"""
self.backend_root = backend_root
self.frontend_root = frontend_root
def build_plan(
self, plugin_id: str, *, template: str, backend: bool, frontend: bool, test: bool, frontend_version: str
) -> dict[str, Any]:
"""
生成独立源码工程的文件计划,不安装、启用或导入插件。
:param plugin_id: 插件 ID
:param template: v2 模板名称
:param backend: 是否创建后端工程
:param frontend: 是否创建前端工程
:param test: 是否生成本地合约测试
:param frontend_version: 旧版前端选项,v2 模板仅接受 auto
:return: 包含生成文件、目标目录与冲突信息的计划
"""
native = template.startswith('rust-')
bundle = template.endswith('-bundle')
if not backend:
raise ValueError('v2 ASGI 模板需要后端,不能使用 --frontend-only')
if bundle and not frontend:
raise ValueError('bundle 模板不能使用 --backend-only;仅 API 请使用 python-asgi 或 rust-asgi')
if frontend_version != 'auto':
raise ValueError('v2 模板不使用 --frontend-version;独立 bundle 与宿主 Vue 版本无关')
root = self.backend_root / 'plugin-projects' / plugin_id
replacements = {
'__PLUGIN_ID__': plugin_id,
'__DISTRIBUTION__': 'ruoyi-plugin-' + plugin_id.replace('_', '-'),
'__RUNTIME__': 'rust' if native else 'python',
'__BUNDLE__': str(bundle),
'__NATIVE__': str(native),
}
entries = [
(root / 'plugin.yaml', self._manifest(plugin_id, native=native, bundle=bundle)),
(root / 'README.md', self._readme(plugin_id, native=native, bundle=bundle, test=test)),
(root / '.gitignore', self._render('gitignore', replacements)),
]
if native:
for name in ('Cargo.toml', 'Cargo.lock', 'pyproject.toml', 'src/lib.rs'):
resource = name.replace('src/', '')
entries.append((root / name, self._render(f'native/{resource}', replacements)))
entries.append(
(
root / 'python' / f'ruoyi_plugin_{plugin_id}' / '__init__.py',
'',
)
)
else:
entries.extend(
(root / name, self._render(f'python/{name}', replacements))
for name in ('__init__.py', 'build_release.py')
)
entries.append(
(
root / 'release-files.json',
json.dumps({'schemaVersion': 1, 'files': ['__init__.py']}, indent=2) + '\n',
)
)
if test:
entries.append((root / 'tests' / 'test_plugin.py', self._render('test_plugin.py', replacements)))
if bundle:
entries.extend(
(root / 'web' / 'vendor' / sdk_name, content)
for sdk_name, content in PluginBridgeSdk(self.frontend_root).build_vendor_files().items()
)
entries.extend(
(root / 'web' / name, self._render(f'web/{name.removeprefix("src/")}', replacements))
for name in (
'index.html',
'dev.html',
'dev.js',
'package.json',
'vite.config.js',
'src/main.js',
'src/style.css',
)
)
payload = PluginScaffoldPlanPayload(
template=template,
backend=True,
frontend=bundle,
migration=False,
seed=False,
job=False,
config=False,
crud=False,
test=test,
backend_test=test,
frontend_test=False,
frontend_version=None,
target_dirs=[str(root)],
files=entries,
conflicts=[str(root)] if root.exists() else [],
).to_payload()
payload.update(manifestVersion=2, runtime='native' if native else 'python', sourceDir=root.as_posix())
return payload
@staticmethod
def _render(resource: str, replacements: dict[str, str]) -> str:
"""
读取打包在 CLI 中的模板资源并替换占位符。
:param resource: 相对于模板资源目录的文件名,不含 .tmpl 后缀
:param replacements: 模板占位符及其替换内容
:return: 完成占位符替换的模板内容
"""
content = files('cli.runtime.plugin.scaffold').joinpath(f'assets/{resource}.tmpl').read_text(encoding='utf-8')
for key, value in replacements.items():
content = content.replace(key, value)
return content
@staticmethod
def _manifest(plugin_id: str, *, native: bool, bundle: bool) -> str:
"""
生成显式 ASGI 入口、权限与前端交付声明。
:param plugin_id: 插件 ID
:param native: 是否生成 Rust 原生插件
:param bundle: 是否包含独立前端 bundle
:return: manifest v2 的 YAML 内容
"""
module = f'ruoyi_plugin_{plugin_id}' if native else f'plugins.{plugin_id}'
entry_module = f'{module}._native' if native else module
backend = {
'runtime': 'native' if native else 'python',
'integration': 'asgi',
'module': module,
'entrypoint': f'{entry_module}:create_plugin',
'asgi': {'mountPath': f'/apps/{plugin_id}', 'lifespan': 'managed'},
'health': {'checker': f'{entry_module}:health'},
}
if native:
backend['native'] = {'distribution': 'ruoyi-plugin-' + plugin_id.replace('_', '-')}
frontend: dict[str, Any] = {'delivery': {'type': 'bundle' if bundle else 'none'}}
if bundle:
frontend.update(
bundle={'directory': 'web/dist', 'entry': 'index.html', 'spaFallback': True},
menus=[
{
'name': plugin_id,
'path': plugin_id,
'component': 'PluginFrame',
'type': 'C',
'icon': 'component',
'perms': f'{plugin_id}:view',
}
],
)
return yaml.safe_dump(
{
'manifestVersion': 2,
'id': plugin_id,
'name': plugin_id,
'version': '1.0.0',
'description': '独立构建、通过宿主身份与权限访问的 ASGI 插件。',
'backend': backend,
'frontend': frontend,
'permissions': [{'code': f'{plugin_id}:view', 'name': '访问插件'}],
'compatibility': {'hostApiVersion': '^1.2.0', 'pythonVersion': '>=3.10'},
},
allow_unicode=True,
sort_keys=False,
)
@staticmethod
def _readme(plugin_id: str, *, native: bool, bundle: bool, test: bool) -> str:
"""
生成与模板类型匹配的构建、测试及维护发布说明。
:param plugin_id: 插件 ID
:param native: 是否生成 Rust 原生插件
:param bundle: 是否包含独立前端 bundle
:param test: 是否生成本地合约测试
:return: 工程 README 内容
"""
root = f'plugin-projects/{plugin_id}'
output = f'target/{plugin_id}-package'
build = (
f'python scripts/build_native_plugin.py --source {root} --output {output}'
if native
else f'python {root}/build_release.py --output {output}'
)
frontend_steps = (
f'```bash\nnpm --prefix {root}/web install\nnpm --prefix {root}/web run build\n```\n\n'
'提交生成的 package-lock.json;后续可用 `npm ci` 重现前端依赖。构建器只复制已构建的 '
'`web/dist`,不会自动执行 npm。桥接 SDK 和溯源清单已从生成时的宿主复制到 `web/vendor`,'
'检查及更新方式见下文。\n\n'
f'本地调试可运行 `npm --prefix {root}/web run dev`,打开 `/dev.html`。'
'模拟宿主支持主题、延迟、失败、重载及退出,使用模拟 `/api/info` 响应;业务接口需自行扩展 `web/dev.js`。'
'模拟页不进入生产构建,也不能替代真实权限、会话和传输加密验证。'
'同目录的 `pluginBridge.d.ts` 提供 JSON、文件及可协商 SSE 能力的类型;'
'实时事件示例与协议限制见宿主开发手册。\n\n'
if bundle
else ''
)
native_steps = (
'需预先安装 Rust 工具链和匹配目标平台的编译工具,以及 `maturin>=1.15,<2`。'
'模板包含 Cargo.lock,构建使用 `--locked`。二进制必须匹配部署系统、架构、Python ABI。\n\n'
if native
else 'Python 交付目录包含业务 Python 源码;签名验证不提供源码保护。\n\n'
)
packaging_note = (
'原生打包器复制扩展、发行包元数据、清单及声明的交付资源;不复制 Rust 工程。'
if native
else 'Python 打包脚本按 `release-files.json` 的 `files` 列表复制业务 `.py`、`.sql` 文件。'
'初始清单为 `{"schemaVersion": 1, "files": ["__init__.py"]}`;增加模块或迁移 SQL 时,'
'将每个文件的明确相对路径加入清单,例如 `models.py`、`migrations/mysql/001_init.sql`,再重新构建并测试。'
'`plugin.yaml` 固定纳入,bundle 模板自动纳入已构建的 `web/dist`,这两项不写入列表;'
'清单本身和构建脚本不进入交付。清单上限 64 KiB、1024 个文件,不支持目录或通配符。'
'隐藏路径、重复或大小写冲突、tests/keys/vendor 等开发目录、链接、重解析点和硬链接均会被拒绝。'
'所有资源校验通过后才创建输出目录;未列出的私有文件不会自动复制。'
)
tests = (
'Windows PowerShell:\n\n'
f'```powershell\n$env:RUOYI_PLUGIN_DIRECTORY = (Resolve-Path "{output}/{plugin_id}").Path\n'
f'python -m pytest {root}/tests -q\n```\n\n'
'macOS / Linux(Bash、Zsh):\n\n'
f'```bash\nexport RUOYI_PLUGIN_DIRECTORY="$(pwd)/{output}/{plugin_id}"\n'
f'python -m pytest {root}/tests -q\n```\n\n'
'测试使用宿主 SDK 和本地请求上下文替身,不连接数据库。Rust 模板未指定构建产物时明确跳过,'
'Python 模板也可直接测试源码目录。测试不代替真实登录、数据库和多 worker 发布验收。\n\n'
if test
else ''
)
sdk_steps = (
'## SDK 检查与更新\n\n'
'`web/vendor/pluginBridge.sdk.json` 记录 SDK 版本、桥协议版本、能力声明和来源摘要。'
'SDK 版本独立于桥协议 v1;两个源码文件的摘要按 UTF-8、LF 换行计算,'
'跨平台换行转换不会被误判为 SDK 改动。协议兼容不等于业务兼容。\n\n'
f'```bash\nruoyi plugin sdk check {root} --output json\n'
f'ruoyi plugin sdk update {root} --dry-run\n'
f'ruoyi plugin sdk update {root}\n```\n\n'
'`check` 只有 `current` 状态返回成功退出码;`outdated` 或其他状态均返回非零退出码。'
'`update --dry-run` 只预览,实际 `update` 命令直接写入,不需要额外的 `--yes`。'
'修改过或没有溯源记录的旧副本默认拒绝覆盖,需显式使用 `--force`。'
'每次实际更新都先将原 SDK 备份到本工程的 `.plugin-sdk-backups/`。\n\n'
'命令只使用本地宿主 SDK,不联网、不安装依赖、不更新业务源码或运行中的插件。'
f'更新后必须重新运行 `npm --prefix {root}/web run build`,'
'验证业务页面、类型与宿主能力,再重新打包并按维护流程发布。\n\n'
if bundle
else ''
)
access_note = (
'由宿主 PluginFrame 打开页面;前端通过 `createPluginClient.ready` 建立会话,'
'再调用 `request({method: "GET", path: "info"})`,不得读取或保存管理员 token。'
'SDK 仅访问当前插件 API 下的相对路径,不代理任意外部 URL、任意字节流或 WebSocket。\n\n'
'- `request`:JSON 请求和响应每条最多 64 KiB,默认超时 15 秒。\n'
'- `upload` / `download`:通过 `capabilities.files.version=1` 协商,'
'使用 Blob 传输文件,单次最多 10 MiB,默认超时 120 秒,支持进度和取消。\n'
'- `stream`:通过 `capabilities.streams.version=1` 协商只读 GET SSE,'
'每个页面最多 2 条连接,每条连接最长 5 分钟、累计最多 10 MiB,单条事件最多 64 KiB;'
'必须提供 `onEvent`,支持取消,不自动重连。\n\n'
'以上请求合计最多 8 个待处理任务;旧宿主未公布的能力会被拒绝,桥协议仍为 v1。'
'本模板仅生成 `/api/info`,文件和事件接口需自行实现并校验权限。'
'文件和 SSE 沿用普通 HTTPS 传输,不使用 JSON 加密信封;'
'若传输加密策略要求加密,需按宿主开发手册将具体接口路径加入 '
'`TRANSPORT_CRYPTO_EXCLUDE_PATHS`,不能排除整个插件 API 命名空间。'
'完整用法、鉴权和代理部署要求见宿主 `docs/plugin_development.md` 第 7.3 节。'
if bundle
else '先给角色授予插件权限,再通过宿主认证调用 API;子应用不提供独立登录。'
)
return f"""# {plugin_id}
这是 manifest v2 的 {'Rust native' if native else 'Python'} ASGI 源码工程{',包含独立前端 bundle' if bundle else ''}。
源码位于 `{root}`,创建工程不会安装、启用或导入插件。下面的命令均在宿主后端根目录执行。
先创建并激活 Python 3.10–3.13 环境;已有宿主环境可直接激活并使用,无需重复创建。
Windows PowerShell:
```powershell
python -m venv .venv
.\\.venv\\Scripts\\Activate.ps1
```
macOS / Linux(Bash、Zsh):
```bash
python3 -m venv .venv
source .venv/bin/activate
```
随后按宿主数据库类型安装依赖:MySQL 使用 `python -m pip install -r requirements.txt`,
PostgreSQL 使用 `python -m pip install -r requirements-pg.txt`。下文 `python` 指向已激活环境。
通用构建与发布命令适用于 Windows PowerShell、macOS 和 Linux;Shell 语法不同的步骤分别列出。
## 构建与测试
{frontend_steps}{native_steps}```bash
{build}
```
每次使用不存在的新输出目录。交付目录为 `{output}/{plugin_id}`;不要签名整个源码工程、wheel 输出目录或 Cargo 工程。
{packaging_note}
{tests}{sdk_steps}## 签名与维护发布
先在宿主启用 `PLUGIN_ARTIFACT_ENABLED`,设置制品存储位置和外部信任公钥文件;公钥必须授权 `{plugin_id}`。
发布者 Ed25519 PKCS8 私钥放在源码及交付目录之外。以下路径和大写占位符需要替换为实际值。
```bash
ruoyi plugin artifact build {output}/{plugin_id} target/{plugin_id}-1.0.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher
ruoyi plugin artifact verify target/{plugin_id}-1.0.0.rpk --env prod
ruoyi plugin artifact import target/{plugin_id}-1.0.0.rpk --env prod --allow-prod --yes
ruoyi plugin release plan {plugin_id} DIGEST --env prod
```
确认计划后停止全部宿主 worker,再执行以下维护操作。`DIGEST` 使用导入结果;`GENERATION` 使用 prepare 后 status 返回的值。
```bash
ruoyi plugin release prepare {plugin_id} DIGEST --maintenance --env prod --allow-prod --yes
ruoyi plugin release status --plugin-id {plugin_id} --env prod
ruoyi plugin release select {plugin_id} DIGEST --expected-generation GENERATION --expected-workers 1 --maintenance --env prod --allow-prod --yes
```
将 expected-workers 改为实际数量,保存 select 返回的新 generation;若随后 enable 或 rollback,则使用该操作返回的新代际。
通过进程管理器启动全部 worker 后,使用新的 `SELECTED_GENERATION` 等待发布就绪,不要复用上面输入的旧 `GENERATION`:
```bash
ruoyi plugin release wait {plugin_id} DIGEST --generation SELECTED_GENERATION --expected-workers 1 --timeout 300 --interval 2 --env prod --output json
```
wait 的 worker 数量应与 select 一致;四个固定目标均必填。只有全部存活 worker 加载同一目标且数量达到预期才返回 0。
`--timeout 0` 只查询并断言一次;尚未就绪、超时、目标变化、停用或 worker 故障均返回非零。wait 只读,不负责重启或变更目标。
`installedVersion` 是数据准备状态,选择目标不会立即切换运行代码。更新版本时同步清单{'、Cargo.toml、Cargo.lock 和 pyproject.toml' if native else ''}。
旧代码回滚必须兼容当前数据库,不撤销迁移。不要覆盖已加载二进制或修改不可变制品目录。
`plugins/{plugin_id}` 如有同名源码目录,会与制品发布冲突;在维护窗口备份并移出后才能导入。
原生插件与同源 iframe 均为可信扩展,不构成安全沙箱。
## 访问
权限为 `{plugin_id}:view`,接口为 `GET /apps/{plugin_id}/api/info`。请求沿用宿主认证与传输加密策略。
{access_note}
"""
@@ -0,0 +1,502 @@
import hashlib
import json
import os
import re
import stat
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from uuid import uuid4
import yaml
SDK_FILES = ('pluginBridge.js', 'pluginBridge.d.ts')
SDK_METADATA = 'pluginBridge.sdk.json'
VENDOR_FILES = (*SDK_FILES, SDK_METADATA)
HASH_ALGORITHM = 'sha256-utf8-lf'
SDK_SOURCE = {'project': 'RuoYi-Vue3-FastAPI', 'directory': 'ruoyi-fastapi-frontend/src/utils'}
METADATA_FIELDS = {'schemaVersion', 'sdkVersion', 'bridgeVersion', 'capabilities'}
SCHEMA_VERSION = 1
MANIFEST_VERSION = 2
class PluginBridgeSdk:
"""离线检查和更新独立 bundle 工程中的桥接 SDK 副本。"""
def __init__(self, frontend_root: Path) -> None:
"""
保存用于读取当前宿主 SDK 的前端目录,不初始化宿主运行环境。
:param frontend_root: 宿主前端工程根目录
:return: None
"""
self.frontend_root = Path(frontend_root)
def build_vendor_files(self) -> dict[str, str]:
"""
校验宿主 SDK 并生成使用 UTF-8、LF 和内容摘要的三个副本文件。
:return: 按固定文件名索引的文本,不包含时间戳或本机路径
"""
directory = self._safe_path(self.frontend_root / 'src' / 'utils')
contents: dict[str, str] = {}
for name in VENDOR_FILES:
path = self._safe_path(directory / name)
if not path.is_file():
raise ValueError(f'缺少宿主桥接 SDK:{path};请配置正确的前端工程目录')
contents[name] = self._text(path.read_bytes())
metadata = self._metadata(contents[SDK_METADATA], vendor=False)
self._validate_exports(contents, metadata)
metadata.update(
hashAlgorithm=HASH_ALGORITHM,
source=dict(SDK_SOURCE),
files={name: self._hash(contents[name]) for name in SDK_FILES},
)
contents[SDK_METADATA] = json.dumps(metadata, ensure_ascii=False, indent=2) + '\n'
return contents
def check(self, project: Path) -> dict[str, Any]:
"""
静态检查 v2 bundle 工程副本,仅与当前宿主完全一致时通过检查。
:param project: 含 plugin.yaml 的独立插件工程目录
:return: 副本状态、版本、协议兼容性与三个文件的摘要信息
"""
try:
current = self.build_vendor_files()
_, vendor = self._project_paths(project)
report, _ = self._inspect(vendor, current)
return report
except (OSError, ValueError, yaml.YAMLError) as exc:
return self._report('invalid', f'SDK 检查失败:{exc}')
def update(self, project: Path, *, dry_run: bool = False, force: bool = False) -> dict[str, Any]:
"""
备份并更新三个 SDK 文件,先写代码和类型、最后写来源记录。
:param project: 含 plugin.yaml 的独立插件工程目录
:param dry_run: 仅预演,不创建锁、目录、备份或文件
:param force: 允许备份后覆盖改动副本或纳管缺失、损坏的来源记录
:return: 更新结果、原状态、备份位置及更新后的检查信息
"""
backup: Path | None = None
previous: dict[str, Any] = self._report('invalid', 'SDK 更新尚未执行')
try:
current = self.build_vendor_files()
root, vendor = self._project_paths(project)
previous, _ = self._inspect(vendor, current)
refusal = self._refusal(previous, force=force)
if refusal:
return {**previous, 'ok': False, 'message': refusal, 'dryRun': dry_run, 'updated': False}
backup_root = self._safe_path(root / '.plugin-sdk-backups')
if backup_root.exists() and not backup_root.is_dir():
raise ValueError('SDK 备份路径必须是目录')
if dry_run or previous['status'] == 'current':
return {
**previous,
'ok': True,
'dryRun': dry_run,
'updated': False,
'backupDir': None,
'previousStatus': previous['status'],
'message': 'SDK 已与当前宿主一致' if previous['status'] == 'current' else '可备份并更新 SDK 副本',
}
with self._lock(root):
# 取得锁后重新读取,不能覆盖另一次更新之后的新状态。
root, vendor = self._project_paths(root)
previous, original = self._inspect(vendor, current)
refusal = self._refusal(previous, force=force)
if refusal:
return {**previous, 'ok': False, 'message': refusal, 'dryRun': False, 'updated': False}
if previous['status'] == 'current':
return {**previous, 'dryRun': False, 'updated': False, 'backupDir': None}
backup = self._backup(root, original)
vendor.mkdir(parents=True, exist_ok=True)
result = self._apply(vendor, current, original, backup)
return {
**result,
'dryRun': False,
'updated': True,
'backupDir': str(backup),
'previousStatus': previous['status'],
'message': 'SDK 副本已更新,原文件已备份;请重新构建并验证插件',
}
except (OSError, ValueError, yaml.YAMLError) as exc:
return {
**self._report('invalid', f'SDK 更新失败:{exc}'),
'dryRun': dry_run,
'updated': False,
'backupDir': str(backup) if backup is not None else None,
'previousStatus': previous['status'],
}
@staticmethod
def _text(content: bytes) -> str:
"""
解码 UTF-8 并统一换行,忽略编辑器添加的 UTF-8 BOM。
:param content: 文件原始字节
:return: 不含 BOM 且只使用 LF 的文本
"""
return content.decode('utf-8-sig').replace('\r\n', '\n').replace('\r', '\n')
@staticmethod
def _hash(content: str) -> str:
"""
计算归一文本的内容摘要,该摘要不是发布者签名。
:param content: 已归一为 LF 的文本
:return: UTF-8 字节的 SHA256 十六进制摘要
"""
return hashlib.sha256(content.encode('utf-8')).hexdigest()
@staticmethod
def _safe_path(path: Path) -> Path:
"""
逐级拒绝符号链接和 Windows 重解析点,避免操作越过实际工程路径。
:param path: 待使用的文件或目录路径
:return: 已检查现存路径组件的绝对路径
"""
absolute = Path(os.path.abspath(path))
for component in reversed((absolute, *absolute.parents)):
try:
info = component.lstat()
except FileNotFoundError:
continue
reparse = getattr(info, 'st_file_attributes', 0) & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0)
if stat.S_ISLNK(info.st_mode) or reparse:
raise ValueError(f'SDK 路径不能包含符号链接或重解析点:{component}')
return absolute
@classmethod
def _project_paths(cls, project: Path) -> tuple[Path, Path]:
"""
只读取 YAML 判定工程类型,固定副本位置而不导入插件入口。
:param project: 用户指定的插件工程根目录
:return: 工程根目录及固定的 web/vendor 目录
"""
root = cls._safe_path(project)
if not root.is_dir():
raise ValueError('插件工程目录不存在')
manifest_path = cls._safe_path(root / 'plugin.yaml')
manifest = yaml.safe_load(manifest_path.read_text(encoding='utf-8-sig'))
if not isinstance(manifest, dict) or type(manifest.get('manifestVersion')) is not int:
raise ValueError('SDK 命令只支持 manifestVersion: 2 的 bundle 工程')
frontend = manifest.get('frontend')
delivery = frontend.get('delivery') if isinstance(frontend, dict) else None
if (
manifest['manifestVersion'] != MANIFEST_VERSION
or not isinstance(delivery, dict)
or delivery.get('type') != 'bundle'
):
raise ValueError('SDK 命令只支持 manifestVersion: 2 的 bundle 工程')
vendor = cls._safe_path(root / 'web' / 'vendor')
if vendor.exists() and not vendor.is_dir():
raise ValueError('web/vendor 必须是目录')
for name in VENDOR_FILES:
path = cls._safe_path(vendor / name)
if path.exists() and not path.is_file():
raise ValueError(f'SDK 目标必须是普通文件:{name}')
return root, vendor
@staticmethod
def _metadata(content: str, *, vendor: bool) -> dict[str, Any]:
"""
校验版本与来源记录结构,不把自报摘要当作来源真实性证明。
:param content: 元数据 JSON 文本
:param vendor: 是否要求副本的摘要与来源字段
:return: 通过字段及类型检查的元数据
"""
metadata = json.loads(content)
fields = METADATA_FIELDS | ({'hashAlgorithm', 'source', 'files'} if vendor else set())
if not isinstance(metadata, dict) or set(metadata) != fields:
raise ValueError('桥接 SDK 元数据字段无效')
if type(metadata['schemaVersion']) is not int or metadata['schemaVersion'] != SCHEMA_VERSION:
raise ValueError('桥接 SDK 元数据 schemaVersion 无效')
version = metadata['sdkVersion']
if not isinstance(version, str) or not re.fullmatch(
r'(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)'
r'(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?',
version,
):
raise ValueError('桥接 SDK 的 sdkVersion 无效')
if type(metadata['bridgeVersion']) is not int or metadata['bridgeVersion'] <= 0:
raise ValueError('桥接 SDK 的 bridgeVersion 无效')
capabilities = metadata['capabilities']
if not isinstance(capabilities, dict) or any(
not isinstance(name, str) or not name or type(value) is not int or value <= 0
for name, value in capabilities.items()
):
raise ValueError('桥接 SDK 的 capabilities 无效')
if vendor:
hashes = metadata['files']
if metadata['hashAlgorithm'] != HASH_ALGORITHM or metadata['source'] != SDK_SOURCE:
raise ValueError('桥接 SDK 的摘要算法或来源记录无效')
if (
not isinstance(hashes, dict)
or set(hashes) != set(SDK_FILES)
or any(
not isinstance(value, str) or not re.fullmatch(r'[0-9a-f]{64}', value) for value in hashes.values()
)
):
raise ValueError('桥接 SDK 文件摘要记录无效')
return metadata
@staticmethod
def _wire_versions(content: str) -> list[str]:
"""
只识别固定格式的协议版本导出,不执行或推断任意 JavaScript 表达式。
:param content: JS 或类型声明内容
:return: 所有可明确识别的协议版本字面量
"""
return re.findall(
r'^\s*export\s+(?:declare\s+)?const\s+PLUGIN_BRIDGE_VERSION\s*[:=]\s*(\d+)\s*;?\s*$',
content,
re.MULTILINE,
)
@classmethod
def _validate_exports(cls, contents: dict[str, str], metadata: dict[str, Any]) -> None:
"""
静态核对 JS 与类型声明导出的 SDK 版本及通信协议版本。
:param contents: SDK 的 JS 和类型声明文本
:param metadata: 对应的元数据
:return: None
"""
for name in SDK_FILES:
sdk_versions = re.findall(
r'^\s*export\s+(?:declare\s+)?const\s+PLUGIN_BRIDGE_SDK_VERSION\s*[:=]\s*[\'"]([^\'"]+)[\'"]\s*;?\s*$',
contents[name],
re.MULTILINE,
)
bridge_versions = cls._wire_versions(contents[name])
if sdk_versions != [metadata['sdkVersion']] or bridge_versions != [str(metadata['bridgeVersion'])]:
raise ValueError(f'桥接 SDK 版本声明与元数据不一致:{name}')
@classmethod
def _protocol_compatible(
cls, contents: dict[str, str], current_wire: int, saved: dict[str, Any] | None
) -> bool | None:
"""
结合实际导出和来源记录判断协议,无法识别实际导出时保留未知。
:param contents: 可成功解码的本地副本内容
:param current_wire: 当前宿主的协议版本
:param saved: 有效的本地来源记录,缺失或损坏时为 None
:return: True 表示协议一致,False 表示明确不兼容,None 表示无法确认
"""
versions = [cls._wire_versions(contents.get(name, '')) for name in SDK_FILES]
if (saved is not None and saved['bridgeVersion'] != current_wire) or any(
value != str(current_wire) for values in versions for value in values
):
return False
return True if all(len(values) == 1 for values in versions) else None
@staticmethod
def _report(status: str, message: str) -> dict[str, Any]:
"""
创建稳定的检查结果字段,未知协议不伪报兼容。
:param status: current、outdated、modified、untracked、invalid 或 incompatible
:param message: 可读的检查说明
:return: 初始检查结果
"""
return {
'ok': status == 'current',
'status': status,
'sdkVersion': None,
'currentSdkVersion': None,
'protocolCompatible': None,
'updateAvailable': False,
'files': {},
'message': message,
'forceRequired': False,
}
@classmethod
def _snapshot(
cls, vendor: Path, latest: dict[str, Any]
) -> tuple[dict[str, bytes | None], dict[str, str], dict[str, dict[str, Any]]]:
"""
读取固定的三个文件,并保留不可解码文件的原始字节供备份。
:param vendor: 已完成路径检查的副本目录
:param latest: 当前宿主的来源记录
:return: 原始字节、可解码文本及文件摘要信息
"""
originals = {name: (vendor / name).read_bytes() if (vendor / name).exists() else None for name in VENDOR_FILES}
contents: dict[str, str] = {}
rows: dict[str, dict[str, Any]] = {}
for name, raw in originals.items():
row: dict[str, Any] = {'exists': raw is not None}
rows[name] = row
if name in SDK_FILES:
row.update(sha256=None, recordedSha256=None, currentSha256=latest['files'][name])
if raw is not None:
try:
contents[name] = cls._text(raw)
row['sha256'] = cls._hash(contents[name])
except UnicodeError:
row['error'] = '文件不是有效 UTF-8'
return originals, contents, rows
@classmethod
def _inspect(cls, vendor: Path, current: dict[str, str]) -> tuple[dict[str, Any], dict[str, bytes | None]]:
"""
将现存副本与记录摘要及当前宿主比较,保留原始字节供备份和回滚。
:param vendor: 已完成路径检查的 SDK 副本目录
:param current: 当前宿主规范化后的三个 SDK 文件
:return: 检查结果及原始文件快照
"""
latest = cls._metadata(current[SDK_METADATA], vendor=True)
originals, contents, rows = cls._snapshot(vendor, latest)
report = cls._report('untracked', 'SDK 副本缺少来源记录,需显式强制纳管')
report.update(currentSdkVersion=latest['sdkVersion'], updateAvailable=True, forceRequired=True, files=rows)
saved = None
if originals[SDK_METADATA] is not None:
try:
saved = cls._metadata(contents.get(SDK_METADATA, ''), vendor=True)
report['sdkVersion'] = saved['sdkVersion']
except ValueError as exc:
report.update(status='invalid', message=str(exc))
report['protocolCompatible'] = cls._protocol_compatible(contents, latest['bridgeVersion'], saved)
if report['protocolCompatible'] is False:
report.update(
status='incompatible',
message='SDK 通信协议版本不同,需要人工迁移',
updateAvailable=False,
forceRequired=False,
)
return report, originals
if saved is None:
return report, originals
for name in SDK_FILES:
report['files'][name]['recordedSha256'] = saved['files'][name]
if any(report['files'][name]['sha256'] != saved['files'][name] for name in SDK_FILES):
report.update(status='modified', message='SDK 文件缺失或已修改,默认不覆盖本地改动')
return report, originals
try:
cls._validate_exports(contents, saved)
except ValueError as exc:
report.update(status='invalid', message=str(exc))
return report, originals
report['forceRequired'] = False
if saved == latest:
report.update(status='current', ok=True, message='SDK 副本与当前宿主一致', updateAvailable=False)
else:
report.update(status='outdated', message='SDK 副本完整,但与当前宿主不同,可显式更新')
return report, originals
@staticmethod
def _refusal(report: dict[str, Any], *, force: bool) -> str | None:
"""
判定更新是否允许,强制模式不能绕过明确的通信协议不兼容。
:param report: 最新副本检查结果
:param force: 是否明确允许覆盖本地副本
:return: 拒绝说明;可更新时为 None
"""
if report['protocolCompatible'] is False:
return 'SDK 通信协议不兼容,不能通过强制更新绕过,请先人工迁移'
if report['forceRequired'] and not force:
return 'SDK 副本存在改动或缺少有效来源记录;请检查后使用 --force 备份并更新'
return None
@classmethod
@contextmanager
def _lock(cls, root: Path) -> Iterator[None]:
"""
用工程局部独占文件串行更新,不自动抢占可能仍被使用的旧锁。
:param root: 已验证的工程根目录
:return: 持有更新锁的上下文
"""
path = cls._safe_path(root / '.plugin-sdk.lock')
try:
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError as exc:
raise ValueError('SDK 更新锁已存在,请确认没有并发更新后再处理锁文件') from exc
try:
os.close(descriptor)
yield
finally:
cls._safe_path(path).unlink()
@classmethod
def _backup(cls, root: Path, originals: dict[str, bytes | None]) -> Path:
"""
在唯一目录保留原文件字节,不覆盖已有备份。
:param root: 已验证的工程根目录
:param originals: SDK 原始文件内容,None 表示原来不存在
:return: 本次更新的备份目录
"""
parent = cls._safe_path(root / '.plugin-sdk-backups')
parent.mkdir(exist_ok=True)
backup = parent / uuid4().hex
backup.mkdir()
for name, content in originals.items():
if content is not None:
(backup / name).write_bytes(content)
return backup
@classmethod
def _write(cls, path: Path, content: bytes) -> None:
"""
在同目录排他创建临时文件,再原子替换固定的 SDK 目标。
:param path: 待替换的 SDK 文件路径
:param content: 要保存的原始字节
:return: None
"""
cls._safe_path(path)
temporary = path.with_name(f'.{path.name}.{uuid4().hex}.tmp')
try:
with temporary.open('xb') as output:
output.write(content)
cls._safe_path(path)
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
@classmethod
def _apply(
cls, vendor: Path, current: dict[str, str], originals: dict[str, bytes | None], backup: Path
) -> dict[str, Any]:
"""
按元数据最后的顺序写入;失败时恢复全部原文件并保留备份。
:param vendor: 固定 SDK 副本目录
:param current: 当前宿主 SDK 文本
:param originals: 更新前的原始字节快照
:param backup: 可供人工恢复的原文件备份目录
:return: 更新后已核实为 current 的检查结果
"""
try:
for name in VENDOR_FILES:
cls._write(vendor / name, current[name].encode('utf-8'))
result, _ = cls._inspect(vendor, current)
if not result['ok']:
raise ValueError('SDK 更新后的内容校验失败')
return result
except BaseException as exc:
failures = []
for name in VENDOR_FILES:
try:
path = cls._safe_path(vendor / name)
if originals[name] is None:
path.unlink(missing_ok=True)
else:
cls._write(path, originals[name])
except (OSError, ValueError) as rollback_error: # noqa: PERF203 - 单个恢复失败不能跳过其他原文件。
failures.append(f'{name}: {rollback_error}')
if failures:
raise ValueError(f'SDK 更新失败且回滚未完成,请从 {backup} 恢复:{";".join(failures)}') from exc
raise
@@ -204,6 +204,21 @@ class CliPluginRuntimeService:
failure_code=self.runtime_error_code,
).to_payload()
async def diagnose_plugin_with_runtime(self, plugin_id: str) -> dict[str, object]:
"""
在静态诊断包上附加服务 worker 的只读快照,保留原诊断成功语义。
:param plugin_id: 待诊断的插件 ID
:return: 包含静态检查及独立运行观测的诊断包
"""
from plugins.core.runtime.diagnostics_query import read_cli_runtime_diagnostics # noqa: PLC0415
payload = await self.core_runtime.diagnose_plugin(plugin_id)
payload['runtime'] = await read_cli_runtime_diagnostics(
plugin_id, backend_root=Path(self._resolve_runtime_environment().get_backend_dir())
)
return payload
def lock_plugin_dependencies(
self,
plugin_id: str,