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

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

* fix: 修复测试

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

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

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

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

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

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

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

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

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

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

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

* fix: 修复测试
This commit is contained in:
insistence
2026-10-09 23:49:45 +08:00
committed by GitHub
parent a89aec54aa
commit 4bd83bc7cd
287 changed files with 41945 additions and 331 deletions
@@ -0,0 +1,33 @@
# ASGI 子应用示例
本示例验证 v2 显式入口、子应用挂载、宿主 Bearer 认证、请求权限及 lifespan。它只提供 API;需要已接入的 PluginFrame、独立打包页面与浏览器会话桥时,参见 [bundle_demo](../bundle_demo/README.md)。
示例要求 Host API `^1.3.0`。`POST /apps/asgi_demo/api/echo/item-7?tag=first&tag=second` 使用相同的 `asgi_demo:view` 权限,接收 `{"message":"你好"}` JSON 请求体(消息长度 1–200),返回路径参数、多值查询参数、校验后的请求体及 `requestId`。实现通过 `plugin_endpoint(..., body_model=EchoBody)` 演示统一校验;认证方式与 `/api/info` 相同。
清单、入口与 SDK 约定见[插件开发手册](../../../../docs/plugin_development.md#510-v2-清单与能力组合),资源初始化和关闭方式见[ASGI 生命周期说明](../../../../docs/plugin_development.md#67-asgi-子应用与生命周期)。
以下命令在后端目录执行,适用于 Windows PowerShell、macOS 和 Linux。先按 [CLI 环境准备](../../../../docs/cli_usage.md#22-安装依赖)激活已安装宿主依赖的 Python 环境。
1. 将此目录复制到后端 `plugins/asgi_demo`。
2. 在已激活的 Python 环境中执行 `ruoyi plugin check asgi_demo --env=dev`。
3. 使用现有安装与启用命令执行 `ruoyi plugin install asgi_demo --env=dev --yes` 和 `ruoyi plugin enable asgi_demo --env=dev --yes`。
4. 重启后端。按原有角色权限管理授予 `asgi_demo:view`;管理员具有通配权限。
5. 请求 `GET /apps/asgi_demo/api/info`,携带当前宿主登录的 `Authorization: Bearer <token>`。若启用了传输加密,仍需遵守宿主加密协议。
入口函数快速返回 PluginDefinition,资源在 lifespan 中初始化和关闭。API 从 request.state.plugin_context 获取宿主鉴权后的身份,执行具体权限检查。数据库会话通过 host.session_factory 按请求创建,不在应用实例内保存请求会话。
服务模式仍执行既有生命周期写入限制;示例命令用于开发环境,不应将生产配置切到 dev 以绕过限制。生产变更使用维护部署流程。当前没有热挂载或热卸载。
## 签名制品交付
本例是 Python 源码插件,签名不改变源码交付方式。使用[开发手册第 15 节](../../../../docs/plugin_development.md#15-v2-签名制品与维护发布)配置宿主制品存储和外部发布者公钥,并授权 `asgi_demo`。发布者私钥必须在插件目录之外,以下路径需替换为实际的 Ed25519 PKCS8 PEM:
```bash
ruoyi plugin artifact build plugins/examples/python/asgi_demo target/asgi_demo-1.0.0.rpk --key-file ../signing-keys/publisher.pem --key-id publisher --env=dev --output=json
ruoyi plugin artifact verify target/asgi_demo-1.0.0.rpk --env=prod --output=json
ruoyi plugin artifact import target/asgi_demo-1.0.0.rpk --env=prod --allow-prod --yes --output=json
```
导入只验签、检查并登记不可变目录,不加载子应用、不运行迁移或 Hook。选择制品部署时不能同时保留 `plugins/asgi_demo` 源码目录;如已按上面的开发步骤安装过,需在维护窗口处理目录冲突,并为缺少制品准备证据的既有安装提升版本。
后续严格使用导入返回的 digest,停止全部 worker 后执行 `release prepare`,从最新状态读取 generation,再执行 `release select` 并重启。每个 worker 分别启动 lifespan,只有实际报告匹配目标并就绪才算发布完成。生产操作不支持热挂载/卸载;代码回滚也不降级数据库。完整命令与签名轮换、中断恢复方式均见开发手册。
@@ -0,0 +1,79 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from plugins.core.sdk import PluginDefinition, PluginHostContext, PluginRequestContext, plugin_endpoint
class EchoBody(BaseModel):
"""
SDK 请求体模型,约束示例消息长度。
"""
message: str = Field(min_length=1, max_length=200)
async def echo(context: PluginRequestContext) -> dict[str, Any]:
return {**context.request.to_payload(), 'requestId': context.request_id}
def create_plugin(host: PluginHostContext) -> PluginDefinition:
"""
声明子应用工厂,资源初始化交给子应用生命周期。
:param host: 宿主提供的插件上下文
:return: 插件能力声明
"""
return PluginDefinition(app_factory=create_app)
def create_app(host: PluginHostContext) -> FastAPI:
"""
为当前 worker 创建独立的 ASGI 子应用。
:param host: 宿主提供的插件上下文
:return: 包含权限检查和生命周期管理的子应用
"""
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[dict[str, str], None]:
"""
演示插件自有资源的启动与关闭。
:param app: 正在启动或关闭的 ASGI 子应用
:return: 可供请求访问的生命周期状态
"""
host.logger.info('ASGI 示例启动')
try:
yield {'message': '子应用资源已就绪'}
finally:
host.logger.info('ASGI 示例关闭')
app = FastAPI(title='ASGI 插件示例', lifespan=lifespan, docs_url=None, redoc_url=None)
@app.get(
'/api/info',
summary='获取ASGI插件信息接口',
description='用于获取当前插件标识、宿主API版本和子应用资源状态',
)
async def info(request: Request) -> dict[str, str]:
context = request.state.plugin_context
try:
context.require_permission('asgi_demo:view')
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
return {'pluginId': host.plugin_id, 'hostApiVersion': host.api_version, 'message': request.state.message}
app.add_api_route(
'/api/echo/{item_id}',
plugin_endpoint(echo, permission='asgi_demo:view', body_model=EchoBody),
methods=['POST'],
summary='验证ASGI插件请求上下文接口',
description='返回校验后的请求体、路径参数、多值查询参数与宿主追踪标识',
response_model=dict[str, Any],
)
return app
@@ -0,0 +1,22 @@
manifestVersion: 2
id: asgi_demo
name: ASGI 子应用示例
version: 1.0.0
description: 使用显式入口和宿主认证的独立 API 子应用。
backend:
runtime: python
integration: asgi
module: plugins.asgi_demo
entrypoint: plugins.asgi_demo:create_plugin
asgi:
mountPath: /apps/asgi_demo
lifespan: managed
frontend:
delivery:
type: none
permissions:
- code: asgi_demo:view
name: 查看 ASGI 示例
compatibility:
hostApiVersion: ^1.3.0
pythonVersion: '>=3.10'