mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-15 05:19:14 +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,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()
|
||||
Reference in New Issue
Block a user