mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-14 05:00:07 +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,215 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
TASK_PLUGIN_ID = 'task_demo'
|
||||
LEGACY_TASK_ID = '0123456789abcdef0123456789abcdef'
|
||||
LEGACY_CREATED_AT = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
||||
EXPECTED_MIGRATIONS = 2
|
||||
|
||||
|
||||
def build_task_artifacts(source: Path, root: Path, trust_file: Path) -> tuple[Path, Path]:
|
||||
"""
|
||||
运行示例交付脚本,签名当前版本及仅用于迁移验收的旧结构夹具。
|
||||
|
||||
:param source: 包含已构建 web/dist 的任务示例源码目录
|
||||
:param root: 本次验收专用且尚不存在的输出目录
|
||||
:param trust_file: 本次验收使用的临时发布者公钥文件
|
||||
:return: 旧结构夹具和当前业务插件的签名制品路径
|
||||
"""
|
||||
import yaml # noqa: PLC0415
|
||||
from cryptography.hazmat.primitives import serialization # noqa: PLC0415
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: PLC0415
|
||||
|
||||
from plugins.core.artifacts import build_artifact # noqa: PLC0415
|
||||
|
||||
if root.exists():
|
||||
raise ValueError('Task integration output must be a new directory')
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(source / 'build_release.py'), '--output', str(root / 'package')],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f'Task package build failed: {result.stderr[-4000:]}')
|
||||
current = root / 'package' / TASK_PLUGIN_ID
|
||||
manifest = yaml.safe_load((current / 'plugin.yaml').read_text(encoding='utf-8'))
|
||||
baseline = root / 'baseline' / TASK_PLUGIN_ID
|
||||
baseline.mkdir(parents=True)
|
||||
manifest['version'] = '1.0.0'
|
||||
manifest['frontend'] = {'delivery': {'type': 'none'}}
|
||||
manifest['backend'].pop('health', None)
|
||||
manifest['backend']['migrations'] = [
|
||||
item for item in manifest['backend']['migrations'] if item.endswith('/001_init.sql')
|
||||
]
|
||||
for name in manifest['backend']['migrations']:
|
||||
destination = baseline / name
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(current / name, destination)
|
||||
(baseline / 'plugin.yaml').write_text(yaml.safe_dump(manifest, allow_unicode=True), encoding='utf-8')
|
||||
# 此夹具仅提供 1.0 数据结构;不启动旧业务 API,也不冒充历史发布包。
|
||||
(baseline / '__init__.py').write_text(
|
||||
'from fastapi import FastAPI\nfrom plugins.core.sdk import PluginDefinition\n'
|
||||
'def create_plugin(host):\n return PluginDefinition(app_factory=lambda context: FastAPI())\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
key = Ed25519PrivateKey.generate()
|
||||
public = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||
trust = json.loads(trust_file.read_text(encoding='utf-8'))
|
||||
trust['keys'].append(
|
||||
{'keyId': 'task-integration', 'publicKey': base64.b64encode(public).decode(), 'pluginIds': [TASK_PLUGIN_ID]}
|
||||
)
|
||||
trust_file.write_text(json.dumps(trust), encoding='utf-8')
|
||||
baseline_archive, current_archive = root / 'baseline.rpk', root / 'current.rpk'
|
||||
build_artifact(baseline, baseline_archive, key, 'task-integration')
|
||||
build_artifact(current, current_archive, key, 'task-integration')
|
||||
return baseline_archive, current_archive
|
||||
|
||||
|
||||
async def exercise_task_delivery(service: Any, sessions: Any, source: Path, root: Path) -> dict[str, Any]:
|
||||
"""
|
||||
从受控交付目录验证签名、维护迁移、目标选择、数据保留和真实业务事务。
|
||||
|
||||
:param service: 已注入隔离数据库及生命周期锁的发布服务
|
||||
:param sessions: 隔离数据库的异步会话工厂
|
||||
:param source: 包含已构建页面的 task_demo 源码目录
|
||||
:param root: 当前任务验收专用的新输出目录
|
||||
:return: 可用于 CI 输出的验收结果摘要
|
||||
"""
|
||||
from sqlalchemy import select, text # noqa: PLC0415
|
||||
|
||||
from plugins.core.management.entity.do.models import SysPluginMigration # noqa: PLC0415
|
||||
|
||||
baseline, current = await asyncio.to_thread(build_task_artifacts, source, root, service.config.trust_file)
|
||||
first = await service.catalog.import_artifact(baseline)
|
||||
first_prepared = await service.prepare(TASK_PLUGIN_ID, first['digest'], maintenance=True, actor='task-it')
|
||||
await service.select(
|
||||
TASK_PLUGIN_ID, first['digest'], expected_generation=first_prepared['generation'], maintenance=True
|
||||
)
|
||||
async with sessions() as db, db.begin():
|
||||
await db.execute(
|
||||
text(
|
||||
'INSERT INTO ruoyi_plugin_task_demo (id, title, description, status, created_at, updated_at) '
|
||||
'VALUES (:id, :title, :description, :status, :created, :updated)'
|
||||
),
|
||||
{
|
||||
'id': LEGACY_TASK_ID,
|
||||
'title': '升级前的任务',
|
||||
'description': '保留已有业务数据',
|
||||
'status': 'todo',
|
||||
'created': LEGACY_CREATED_AT,
|
||||
'updated': LEGACY_CREATED_AT,
|
||||
},
|
||||
)
|
||||
latest = await service.catalog.import_artifact(current)
|
||||
prepared = await service.prepare(TASK_PLUGIN_ID, latest['digest'], maintenance=True, actor='task-it')
|
||||
await service.prepare(TASK_PLUGIN_ID, latest['digest'], maintenance=True, actor='task-it')
|
||||
selected = await service.select(
|
||||
TASK_PLUGIN_ID, latest['digest'], expected_generation=prepared['generation'], maintenance=True
|
||||
)
|
||||
async with sessions() as db:
|
||||
history = (
|
||||
await db.scalars(select(SysPluginMigration).where(SysPluginMigration.plugin_id == TASK_PLUGIN_ID))
|
||||
).all()
|
||||
if len(history) != EXPECTED_MIGRATIONS or any(
|
||||
item.status != 'success' or item.attempt_count != 1 for item in history
|
||||
):
|
||||
raise AssertionError('Task migrations must succeed exactly once per database dialect')
|
||||
artifact = await service.catalog.get(latest['digest'])
|
||||
plugin = service.catalog.discovered(artifact)
|
||||
await exercise_task_api(plugin, sessions)
|
||||
await service.catalog.get(latest['digest'])
|
||||
if list(plugin.backend_path.rglob('*.pyc')):
|
||||
raise AssertionError('Task runtime wrote bytecode into the immutable artifact')
|
||||
return {
|
||||
'pluginId': TASK_PLUGIN_ID,
|
||||
'version': artifact.version,
|
||||
'digest': latest['digest'],
|
||||
'generation': selected['generation'],
|
||||
'migrations': len(history),
|
||||
'retainedRows': 1,
|
||||
'crud': True,
|
||||
'readOnlyDenied': True,
|
||||
}
|
||||
|
||||
|
||||
async def exercise_task_api(plugin: Any, sessions: Any) -> None:
|
||||
"""
|
||||
从签名交付目录加载当前业务 API,注入权限并验证持久化 CRUD。
|
||||
|
||||
:param plugin: 从签名制品目录发现的任务插件
|
||||
:param sessions: 隔离数据库会话工厂
|
||||
:return: None
|
||||
"""
|
||||
from httpx import ASGITransport, AsyncClient # noqa: PLC0415
|
||||
|
||||
from plugins.core.runtime.entrypoint import PluginEntrypointLoader # noqa: PLC0415
|
||||
from plugins.core.sdk import PluginHostContext, PluginRequestContext # noqa: PLC0415
|
||||
|
||||
host = PluginHostContext(TASK_PLUGIN_ID, plugin.backend_path, session_factory=sessions)
|
||||
definition = PluginEntrypointLoader(plugin).load(host)
|
||||
app = definition.app_factory(host)
|
||||
permissions = frozenset({'task_demo:view', 'task_demo:write'})
|
||||
|
||||
@app.middleware('http')
|
||||
async def authenticated_fixture(request: Any, call_next: Any) -> Any:
|
||||
request.state.plugin_context = PluginRequestContext(host, user=None, permissions=permissions)
|
||||
return await call_next(request)
|
||||
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url='http://task-integration') as client:
|
||||
old = await client.get(f'/api/tasks/{LEGACY_TASK_ID}')
|
||||
old.raise_for_status()
|
||||
retained = old.json()
|
||||
if (
|
||||
retained['title'] != '升级前的任务'
|
||||
or retained['description'] != '保留已有业务数据'
|
||||
or retained['status'] != 'todo'
|
||||
or retained['priority'] != 'normal'
|
||||
or datetime.fromisoformat(retained['createdAt'].replace('Z', '+00:00')) != LEGACY_CREATED_AT
|
||||
):
|
||||
raise AssertionError('Task migration changed existing business data')
|
||||
permissions = frozenset({'task_demo:view'})
|
||||
denied_writes = [
|
||||
await client.post('/api/tasks', json={'title': '禁止的写入'}),
|
||||
await client.put(f'/api/tasks/{LEGACY_TASK_ID}', json={'title': '禁止的改动'}),
|
||||
await client.delete(f'/api/tasks/{LEGACY_TASK_ID}'),
|
||||
]
|
||||
if any(response.status_code != HTTPStatus.FORBIDDEN for response in denied_writes):
|
||||
raise AssertionError('Read-only role was allowed to mutate a task')
|
||||
if (await client.get(f'/api/tasks/{LEGACY_TASK_ID}')).json() != retained:
|
||||
raise AssertionError('Denied writes changed the legacy task')
|
||||
permissions = frozenset({'task_demo:view', 'task_demo:write'})
|
||||
created = await client.post('/api/tasks', json={'title': '升级后新增', 'priority': 'high'})
|
||||
created.raise_for_status()
|
||||
task_id = created.json()['id']
|
||||
updated = await client.put(
|
||||
f'/api/tasks/{task_id}',
|
||||
json={'title': '升级后更新', 'description': '事务已提交', 'status': 'done', 'priority': 'high'},
|
||||
)
|
||||
updated.raise_for_status()
|
||||
saved = await client.get(f'/api/tasks/{task_id}')
|
||||
saved.raise_for_status()
|
||||
expected_fields = {'title': '升级后更新', 'description': '事务已提交', 'status': 'done', 'priority': 'high'}
|
||||
if any(saved.json()[key] != value for key, value in expected_fields.items()):
|
||||
raise AssertionError('Task update did not persist every editable field')
|
||||
page = await client.get('/api/tasks', params={'page': 1, 'pageSize': 1, 'status': 'done'})
|
||||
page.raise_for_status()
|
||||
if page.json()['total'] != 1 or page.json()['items'][0]['id'] != task_id:
|
||||
raise AssertionError('Task filtering or pagination lost committed changes')
|
||||
deleted = await client.delete(f'/api/tasks/{task_id}')
|
||||
deleted.raise_for_status()
|
||||
if (await client.get(f'/api/tasks/{task_id}')).status_code != HTTPStatus.NOT_FOUND:
|
||||
raise AssertionError('Task deletion was not committed')
|
||||
if (await client.get('/api/tasks')).json()['total'] != 1:
|
||||
raise AssertionError('CRUD changed the legacy task or committed an unauthorized write')
|
||||
Reference in New Issue
Block a user