mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-15 21:35:31 +00:00
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:
@@ -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)
|
||||
+233
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user