feat: 新增插件系统 (#112)

* feat: 初始化插件系统

* refactor: 收口插件系统运行时重构

* perf: 优化插件系统类型提示

* fix&perf: 修复和优化插件系统

* fix: 修复gitignore规则误忽略插件文件的问题

* fix: 修复运行时插件根路径算错的问题

* fix: 加强插件发现和路由注册的防护措施

* revert: 回滚定时任务白名单

* fix: 移除未使用的应用路由注册探测

* revert: 恢复部分代码

* perf: 优化插件系统

* docs: 新增插件开发文档

* perf: 优化插件管理模块

* perf: 提升插件系统核心能力

* refactor: 重构生命周期 step runner

* fix: 修复lint错误

* test: 清理测试用例

* test: 调整测试目录名称

* fix: 修复前后端目录硬编码的问题

* fix: 修复插件系统安全性缺口

* refactor: 重新设计插件生命周期 Migration 事务与回滚

* perf: 优化插件系统边界问题

* refactor: 重构当前插件系统的依赖体系设计

* perf: 优化代码

* perf: 优化代码

* fix: 修复代码合并问题

* fix: 修复bug

* perf: 优化代码

* perf&fix: 优化代码和修复bug

* docs: 优化文档格式

* feat: 适配Vue2版本

* docs: 更新README文档

* fix: 修复ruff lint错误

* chore: 更新后端依赖文件
This commit is contained in:
insistence
2026-07-28 20:35:18 +08:00
committed by GitHub
parent 25acc9427c
commit 2a055ba648
268 changed files with 64161 additions and 539 deletions
@@ -0,0 +1,3 @@
from .service import PLUGIN_RUNTIME, CliPluginRuntimeService
__all__ = ['PLUGIN_RUNTIME', 'CliPluginRuntimeService']
@@ -0,0 +1,70 @@
from importlib import import_module
from typing import Any
class PluginRuntimeGateway:
"""
插件 CLI 运行时网关。
该对象负责延迟加载插件核心运行时与管理适配器,避免导入
`cli.runtime.plugin` 时立即加载 `plugins.core`。
"""
@staticmethod
def get_core_runtime_service_class() -> Any:
"""
获取插件核心运行时服务类。
:return: 插件核心运行时服务类
"""
return import_module('plugins.core.runtime.service').PluginRuntimeService
@staticmethod
def get_core_runtime_gateway_overrides_class() -> Any:
"""
获取插件核心运行时窄端口覆盖项类。
:return: 插件核心运行时窄端口覆盖项类
"""
return import_module('plugins.core.runtime.service.dependency_container').PluginRuntimeGatewayOverrides
@staticmethod
def get_management_runtime_gateway() -> Any:
"""
获取插件管理运行时适配器。
:return: 插件管理运行时适配器实例
"""
gateway_class = import_module('plugins.core.management.service.gateway').PluginManagementRuntimeGateway
return gateway_class()
@staticmethod
def get_core_runtime_environment() -> Any:
"""
获取插件核心运行时环境服务。
:return: 插件核心运行时环境服务
"""
return import_module('plugins.core.environment').PLUGIN_RUNTIME_ENVIRONMENT
@staticmethod
def get_core_lifecycle_lock() -> Any:
"""
获取插件核心生命周期分布式锁。
:return: 插件核心生命周期分布式锁
"""
lock_class = import_module('plugins.core.runtime.service.lifecycle_lock').RedisPluginLifecycleLock
return lock_class()
@staticmethod
def build_exception_payload(message: str, exc: Exception) -> dict[str, object]:
"""
构建插件核心异常负载。
:param message: 异常场景提示
:param exc: 异常对象
:return: 异常负载
"""
payload_builder = import_module('plugins.core.runtime.support').PluginRuntimePayloadBuilder
return payload_builder.build_exception_payload(message, exc)
@@ -0,0 +1,26 @@
from .backend import PluginBackendScaffoldTemplateBuilder
from .builder import PluginScaffoldBuilder
from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver
from .naming import PluginScaffoldNaming
from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver
from .payload import (
PluginScaffoldConflictPayload,
PluginScaffoldPayloadBuilder,
PluginScaffoldPlanPayload,
PluginScaffoldSuccessPayload,
)
__all__ = [
'FrontendVersion',
'PluginBackendScaffoldTemplateBuilder',
'PluginFrontendScaffoldTemplateBuilder',
'PluginFrontendVersionResolver',
'PluginScaffoldBuilder',
'PluginScaffoldConflictPayload',
'PluginScaffoldNaming',
'PluginScaffoldOptions',
'PluginScaffoldPayloadBuilder',
'PluginScaffoldPlanPayload',
'PluginScaffoldSuccessPayload',
'PluginScaffoldTemplateResolver',
]
@@ -0,0 +1,536 @@
from .naming import PluginScaffoldNaming
from .options import PluginScaffoldOptions
class PluginBackendScaffoldTemplateBuilder:
"""
后端插件模板内容构建器。
"""
@staticmethod
def build_manifest(plugin_id: str, options: PluginScaffoldOptions) -> str:
"""
构建后端插件清单内容。
:param plugin_id: 插件ID
:param options: 插件模板生成选项
:return: 后端插件清单内容
"""
migrations = ' []' if not options.migration else '\n - migrations/001_init.sql'
seeds = ' []' if not options.seed else '\n - seeds/001_seed.sql'
jobs = (
' []'
if not options.job
else f"""
- id: heartbeat
name: {plugin_id} 心跳
callable: plugins.{plugin_id}.jobs.heartbeat
trigger: cron
cronExpression: '0 0/30 * * * ?'
enabled: false
description: {plugin_id} 插件定时任务声明示例"""
)
config = (
''
if not options.config
else """
config:
items:
- key: enabled_feature
label: 示例开关
type: boolean
default: true
required: false
description: 示例插件开关
- key: api_key
label: 示例密钥
type: password
default: ''
required: false
secret: true
description: 敏感配置示例,请在安装后填写"""
)
frontend_menus = (
f"""
menus:
- name: {plugin_id}
path: {plugin_id}
component: plugin/{plugin_id}/index
perms: {plugin_id}:list
type: C
icon: '#'"""
if options.frontend
else """
menus: []"""
)
return f"""id: {plugin_id}
name: {plugin_id}
version: 0.1.0
description: {plugin_id} 插件
backend:
module: plugins.{plugin_id}
routers:
autoScan: true
migrations:{migrations}
seeds:{seeds}
hooks:
onInstall: hooks:on_install
onUpgrade: hooks:on_upgrade
onStartup: hooks:on_startup
onShutdown: hooks:on_shutdown
onPurge: hooks:on_purge
jobs:{jobs}
frontend:
basePath: {plugin_id}
pluginId: {plugin_id}
viewsPath: views
apiPath: api
{frontend_menus}
permissions:
- {plugin_id}:list
dependencies:
python: []
npm: []
npmDev: []
plugins: []
{config}
"""
@staticmethod
def build_controller(plugin_id: str) -> str:
"""
构建后端控制器模板内容。
:param plugin_id: 插件ID
:return: 后端控制器模板内容
"""
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
return f"""from common.router import APIRouterPro
from plugins.{plugin_id}.service.{plugin_id}_service import {service_class_name}Service
router = APIRouterPro(prefix='/{plugin_id}', tags=['{plugin_id}'])
@router.get('/ping')
async def ping() -> dict[str, str]:
\"\"\"
插件探活接口。
:return: 插件探活结果
\"\"\"
return {service_class_name}Service.ping()
"""
@staticmethod
def build_crud_controller(plugin_id: str) -> str:
"""
构建后端 CRUD 控制器模板内容。
:param plugin_id: 插件ID
:return: 后端 CRUD 控制器模板内容
"""
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
return f"""from common.router import APIRouterPro
from plugins.{plugin_id}.service.{plugin_id}_service import {service_class_name}Service
router = APIRouterPro(prefix='/{plugin_id}', tags=['{plugin_id}'])
@router.get('/ping')
async def ping() -> dict[str, str]:
\"\"\"
插件探活接口。
:return: 插件探活结果
\"\"\"
return {service_class_name}Service.ping()
@router.get('/items')
async def list_items(keyword: str = '') -> dict[str, object]:
\"\"\"
查询示例数据列表。
:param keyword: 名称关键字
:return: 示例数据分页结果
\"\"\"
return {service_class_name}Service.list_items(keyword)
@router.post('/items')
async def create_item(payload: dict[str, object]) -> dict[str, object]:
\"\"\"
创建示例数据。
:param payload: 示例数据负载
:return: 创建后的示例数据
\"\"\"
return {service_class_name}Service.create_item(payload)
@router.put('/items/{{item_id}}')
async def update_item(item_id: int, payload: dict[str, object]) -> dict[str, object]:
\"\"\"
更新示例数据。
:param item_id: 示例数据ID
:param payload: 示例数据负载
:return: 更新后的示例数据
\"\"\"
return {service_class_name}Service.update_item(item_id, payload)
@router.delete('/items/{{item_id}}')
async def delete_item(item_id: int) -> dict[str, object]:
\"\"\"
删除示例数据。
:param item_id: 示例数据ID
:return: 删除结果
\"\"\"
return {service_class_name}Service.delete_item(item_id)
"""
@staticmethod
def build_service(plugin_id: str) -> str:
"""
构建后端服务模板内容。
:param plugin_id: 插件ID
:return: 后端服务模板内容
"""
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
return f"""class {service_class_name}Service:
\"\"\"
{plugin_id} 插件服务。
\"\"\"
@classmethod
def ping(cls) -> dict[str, str]:
\"\"\"
返回插件探活结果。
:return: 插件探活结果
\"\"\"
return {{'message': '{plugin_id} plugin ok'}}
"""
@staticmethod
def build_crud_service(plugin_id: str) -> str:
"""
构建后端 CRUD 服务模板内容。
:param plugin_id: 插件ID
:return: 后端 CRUD 服务模板内容
"""
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
return f"""class {service_class_name}Service:
\"\"\"
{plugin_id} 插件 CRUD 示例服务。
第一版模板使用内存数据演示 controller/service 分层,实际业务可替换为 dao/entity 实现。
\"\"\"
_items = [
{{'itemId': 1, 'itemName': '{plugin_id} 示例', 'status': '0', 'remark': '插件 CRUD 模板数据'}},
]
@classmethod
def ping(cls) -> dict[str, str]:
\"\"\"
返回插件探活结果。
:return: 插件探活结果
\"\"\"
return {{'message': '{plugin_id} plugin ok'}}
@classmethod
def list_items(cls, keyword: str = '') -> dict[str, object]:
\"\"\"
查询示例数据列表。
:param keyword: 名称关键字
:return: 示例数据分页结果
\"\"\"
rows = [
item
for item in cls._items
if not keyword or keyword.lower() in str(item.get('itemName', '')).lower()
]
return {{'rows': rows, 'total': len(rows)}}
@classmethod
def create_item(cls, payload: dict[str, object]) -> dict[str, object]:
\"\"\"
创建示例数据。
:param payload: 示例数据负载
:return: 创建后的示例数据
\"\"\"
next_id = max([int(item['itemId']) for item in cls._items], default=0) + 1
item = {{
'itemId': next_id,
'itemName': str(payload.get('itemName') or '未命名'),
'status': str(payload.get('status') or '0'),
'remark': str(payload.get('remark') or ''),
}}
cls._items.append(item)
return item
@classmethod
def update_item(cls, item_id: int, payload: dict[str, object]) -> dict[str, object]:
\"\"\"
更新示例数据。
:param item_id: 示例数据ID
:param payload: 示例数据负载
:return: 更新后的示例数据
\"\"\"
for item in cls._items:
if item['itemId'] != item_id:
continue
item.update(
{{
'itemName': str(payload.get('itemName') or item.get('itemName')),
'status': str(payload.get('status') or item.get('status')),
'remark': str(payload.get('remark') or ''),
}}
)
return item
return {{'itemId': item_id, 'itemName': '', 'status': '1', 'remark': 'not found'}}
@classmethod
def delete_item(cls, item_id: int) -> dict[str, object]:
\"\"\"
删除示例数据。
:param item_id: 示例数据ID
:return: 删除结果
\"\"\"
before_count = len(cls._items)
cls._items = [item for item in cls._items if item['itemId'] != item_id]
return {{'deleted': len(cls._items) < before_count, 'itemId': item_id}}
"""
@staticmethod
def build_hooks(plugin_id: str) -> str:
"""
构建后端生命周期钩子模板内容。
:param plugin_id: 插件ID
:return: 后端生命周期钩子模板内容
"""
return f"""from plugins.core.runtime.hooks import PluginHookContext
from utils.log_util import logger
async def on_install(context: PluginHookContext) -> None:
\"\"\"
插件安装生命周期钩子。
:param context: 插件生命周期钩子上下文
:return: None
\"\"\"
logger.info('{plugin_id} plugin install hook executed')
async def on_upgrade(context: PluginHookContext) -> None:
\"\"\"
插件升级生命周期钩子。
:param context: 插件生命周期钩子上下文
:return: None
\"\"\"
logger.info('{plugin_id} plugin upgrade hook executed')
async def on_startup(context: PluginHookContext) -> None:
\"\"\"
插件启动生命周期钩子。
:param context: 插件生命周期钩子上下文
:return: None
\"\"\"
logger.info('{plugin_id} plugin startup hook executed')
async def on_shutdown(context: PluginHookContext) -> None:
\"\"\"
插件关闭生命周期钩子。
:param context: 插件生命周期钩子上下文
:return: None
\"\"\"
logger.info('{plugin_id} plugin shutdown hook executed')
async def on_purge(context: PluginHookContext) -> None:
\"\"\"
插件物理清理生命周期钩子。
:param context: 插件生命周期钩子上下文
:return: None
\"\"\"
logger.info('{plugin_id} plugin purge hook executed')
"""
@staticmethod
def build_jobs(plugin_id: str) -> str:
"""
构建后端定时任务模板内容。
:param plugin_id: 插件ID
:return: 后端定时任务模板内容
"""
return f"""from utils.log_util import logger
def heartbeat() -> None:
\"\"\"
插件心跳定时任务。
:return: None
\"\"\"
logger.info('{plugin_id} plugin heartbeat job executed')
"""
@staticmethod
def build_migration(plugin_id: str) -> str:
"""
构建后端 migration 模板内容。
:param plugin_id: 插件ID
:return: 后端 migration 模板内容
"""
return f"""-- {plugin_id} plugin initial migration.
-- Add plugin tables or schema changes here.
"""
@staticmethod
def build_seed(plugin_id: str) -> str:
"""
构建后端 seed 模板内容。
:param plugin_id: 插件ID
:return: 后端 seed 模板内容
"""
return f"""-- {plugin_id} plugin initial seed.
-- Add idempotent initialization data here.
"""
@staticmethod
def build_test(plugin_id: str) -> str:
"""
构建后端插件 pytest 样例。
:param plugin_id: 插件ID
:return: 后端插件测试样例内容
"""
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
return f"""from plugins.{plugin_id}.service.{plugin_id}_service import {service_class_name}Service
def test_{plugin_id}_service_ping() -> None:
\"\"\"
校验插件服务探活返回稳定负载。
:return: None
\"\"\"
assert {service_class_name}Service.ping() == {{'message': '{plugin_id} plugin ok'}}
"""
@staticmethod
def build_crud_test(plugin_id: str) -> str:
"""
构建后端插件 CRUD pytest 样例。
:param plugin_id: 插件ID
:return: 后端插件 CRUD 测试样例内容
"""
service_class_name = PluginScaffoldNaming.to_class_name(plugin_id)
return f"""from plugins.{plugin_id}.service.{plugin_id}_service import {service_class_name}Service
def test_{plugin_id}_service_ping() -> None:
\"\"\"
校验插件服务探活返回稳定负载。
:return: None
\"\"\"
assert {service_class_name}Service.ping() == {{'message': '{plugin_id} plugin ok'}}
def test_{plugin_id}_service_crud_flow() -> None:
\"\"\"
校验插件 CRUD 示例服务返回稳定负载。
:return: None
\"\"\"
created = {service_class_name}Service.create_item({{'itemName': '测试数据', 'status': '0'}})
listed = {service_class_name}Service.list_items('测试')
updated = {service_class_name}Service.update_item(created['itemId'], {{'itemName': '测试数据2'}})
deleted = {service_class_name}Service.delete_item(created['itemId'])
assert listed['total'] >= 1
assert updated['itemName'] == '测试数据2'
assert deleted['deleted'] is True
"""
@staticmethod
def build_readme(plugin_id: str) -> str:
"""
构建后端 README 内容。
:param plugin_id: 插件ID
:return: 后端 README 内容
"""
return f"""# {plugin_id} backend plugin
Backend plugin scaffold generated by `ruoyi plugin create`.
## Structure
- `plugin.yaml`: backend manifest, menus, permissions and dependencies.
- `controller/`: FastAPI routers discovered when the plugin is enabled.
- `service/`: plugin service classes.
- `dao/`: plugin data access classes.
- `entity/do/`: SQLAlchemy models imported before table creation.
- `entity/vo/`: Pydantic request and response models.
- `hooks.py`: lifecycle hook examples declared in `plugin.yaml`.
- `jobs.py`: scheduled job example declared in `plugin.yaml`.
- `migrations/`: database migration scripts declared in `plugin.yaml`.
- `seeds/`: initialization scripts declared in `plugin.yaml`.
- `tests/plugins/{plugin_id}/`: pytest examples for this plugin.
- frontend project `tests/plugins/{plugin_id}/`: frontend node tests for this plugin.
## Commands
```bash
ruoyi plugin check {plugin_id}
pytest tests/plugins/{plugin_id}
cd <frontend-project> && node tests/plugins/{plugin_id}/pluginView.test.js
ruoyi plugin install {plugin_id} --dry-run
ruoyi plugin install {plugin_id} --yes
ruoyi plugin enable {plugin_id} --yes
ruoyi plugin disable {plugin_id} --yes
ruoyi plugin upgrade {plugin_id} --dry-run
ruoyi plugin uninstall {plugin_id} --yes
ruoyi plugin purge {plugin_id} --dry-run
ruoyi plugin config get {plugin_id}
```
## Frontend View
The menu component `plugin/{plugin_id}/index` maps to:
```text
<frontend-project>/plugins/{plugin_id}/views/index.vue
```
"""
@@ -0,0 +1,280 @@
from pathlib import Path
from typing import Any
from plugins.core.utils import validate_plugin_id_value
from .backend import PluginBackendScaffoldTemplateBuilder
from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver
from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver
from .payload import PluginScaffoldPayloadBuilder, PluginScaffoldPlanPayload
class PluginScaffoldBuilder:
"""
插件模板构建器。
使用 Builder 模式生成后端与前端插件模板文件计划,并在确认无冲突后落地。
"""
def __init__(self, backend_root: Path, frontend_root: Path) -> None:
"""
初始化插件模板构建器。
:param backend_root: 后端项目根目录
:param frontend_root: 前端项目根目录
"""
self.backend_root = backend_root
self.frontend_root = frontend_root
def build_plan(
self,
plugin_id: str,
*,
template: str = PluginScaffoldTemplateResolver.DEFAULT_TEMPLATE,
backend: bool,
frontend: bool,
migration: bool = True,
seed: bool = True,
job: bool = True,
config: bool = True,
test: bool = True,
frontend_version: str = PluginFrontendVersionResolver.AUTO,
) -> dict[str, Any]:
"""
构建插件模板写入计划。
:param plugin_id: 插件ID
:param template: 插件模板名称
:param backend: 是否创建后端插件模板
:param frontend: 是否创建前端插件模板
:param migration: 是否创建 migration 示例
:param seed: 是否创建 seed 示例
:param job: 是否创建定时任务示例
:param config: 是否创建配置项示例
:param test: 是否创建测试样例
:param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3
:return: 插件模板写入计划
"""
self._validate_plugin_id(plugin_id)
options = self._merge_options(
PluginScaffoldTemplateResolver.resolve(template),
backend=backend,
frontend=frontend,
migration=migration,
seed=seed,
job=job,
config=config,
test=test,
)
if not options.backend and not options.frontend:
raise ValueError('backend 和 frontend 至少需要创建一个')
files = []
target_dirs = []
effective_backend_test = options.test and options.backend
effective_frontend_test = options.test and options.frontend
resolved_frontend_version = (
PluginFrontendVersionResolver.resolve(self.frontend_root, frontend_version) if options.frontend else None
)
if options.backend:
backend_plugin_root = self.backend_root / 'plugins' / plugin_id
target_dirs.append(str(backend_plugin_root))
if effective_backend_test:
target_dirs.append(str(self.backend_root / 'tests' / 'plugins' / plugin_id))
files.extend(self._build_backend_files(plugin_id, backend_plugin_root, options))
if options.frontend:
assert resolved_frontend_version is not None
frontend_plugin_root = self.frontend_root / 'plugins' / plugin_id
target_dirs.append(str(frontend_plugin_root))
if effective_frontend_test:
target_dirs.append(str(self.frontend_root / 'tests' / 'plugins' / plugin_id))
files.extend(
self._build_frontend_files(
plugin_id,
frontend_plugin_root,
options,
frontend_version=resolved_frontend_version,
)
)
conflicts = [target_dir for target_dir in target_dirs if Path(target_dir).exists()]
return PluginScaffoldPlanPayload(
template=template or PluginScaffoldTemplateResolver.DEFAULT_TEMPLATE,
backend=options.backend,
frontend=options.frontend,
migration=options.migration,
seed=options.seed,
job=options.job,
config=options.config,
crud=options.crud,
test=effective_backend_test or effective_frontend_test,
backend_test=effective_backend_test,
frontend_test=effective_frontend_test,
frontend_version=resolved_frontend_version,
target_dirs=target_dirs,
files=files,
conflicts=conflicts,
).to_payload()
build_conflict_payload = staticmethod(PluginScaffoldPayloadBuilder.build_conflict_payload)
build_success_payload = staticmethod(PluginScaffoldPayloadBuilder.build_success_payload)
@classmethod
def _validate_plugin_id(cls, plugin_id: str) -> None:
"""
校验插件模板 ID。
:param plugin_id: 插件ID
:return: None
"""
validate_plugin_id_value(plugin_id)
@staticmethod
def _merge_options(
base_options: PluginScaffoldOptions,
*,
backend: bool,
frontend: bool,
migration: bool,
seed: bool,
job: bool,
config: bool,
test: bool,
) -> PluginScaffoldOptions:
"""
合并模板预设和命令行开关。
:param base_options: 模板预设选项
:param backend: 是否创建后端插件模板
:param frontend: 是否创建前端插件模板
:param migration: 是否创建 migration 示例
:param seed: 是否创建 seed 示例
:param job: 是否创建定时任务示例
:param config: 是否创建配置项示例
:param test: 是否创建测试样例
:return: 合并后的插件模板生成选项
"""
return PluginScaffoldOptions(
backend=base_options.backend and backend,
frontend=base_options.frontend and frontend,
migration=base_options.migration and migration,
seed=base_options.seed and seed,
job=base_options.job and job,
config=base_options.config and config,
test=base_options.test and test,
crud=base_options.crud,
)
def apply_plan(self, scaffold_plan: dict[str, Any]) -> None:
"""
执行插件模板写入计划。
:param scaffold_plan: 插件模板写入计划
:return: None
"""
for file_payload in scaffold_plan['files']:
file_path = Path(file_payload['path'])
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(file_payload['content'], encoding='utf-8')
def _build_backend_files(
self,
plugin_id: str,
plugin_root: Path,
options: PluginScaffoldOptions,
) -> list[tuple[Path, str]]:
"""
构建后端插件模板文件。
:param plugin_id: 插件ID
:param plugin_root: 后端插件根目录
:param options: 插件模板生成选项
:return: 文件路径和内容列表
"""
files = [
(plugin_root / 'plugin.yaml', PluginBackendScaffoldTemplateBuilder.build_manifest(plugin_id, options)),
(
plugin_root / 'controller' / f'{plugin_id}_controller.py',
PluginBackendScaffoldTemplateBuilder.build_crud_controller(plugin_id)
if options.crud
else PluginBackendScaffoldTemplateBuilder.build_controller(plugin_id),
),
(
plugin_root / 'service' / f'{plugin_id}_service.py',
PluginBackendScaffoldTemplateBuilder.build_crud_service(plugin_id)
if options.crud
else PluginBackendScaffoldTemplateBuilder.build_service(plugin_id),
),
(plugin_root / 'hooks.py', PluginBackendScaffoldTemplateBuilder.build_hooks(plugin_id)),
(plugin_root / 'README.md', PluginBackendScaffoldTemplateBuilder.build_readme(plugin_id)),
]
if options.job:
files.append((plugin_root / 'jobs.py', PluginBackendScaffoldTemplateBuilder.build_jobs(plugin_id)))
if options.migration:
files.append(
(
plugin_root / 'migrations' / '001_init.sql',
PluginBackendScaffoldTemplateBuilder.build_migration(plugin_id),
)
)
if options.seed:
files.append(
(plugin_root / 'seeds' / '001_seed.sql', PluginBackendScaffoldTemplateBuilder.build_seed(plugin_id))
)
if options.test:
files.append(
(
self.backend_root / 'tests' / 'plugins' / plugin_id / 'test_ping.py',
PluginBackendScaffoldTemplateBuilder.build_crud_test(plugin_id)
if options.crud
else PluginBackendScaffoldTemplateBuilder.build_test(plugin_id),
)
)
return files
def _build_frontend_files(
self,
plugin_id: str,
plugin_root: Path,
options: PluginScaffoldOptions,
*,
frontend_version: FrontendVersion,
) -> list[tuple[Path, str]]:
"""
构建前端插件模板文件。
:param plugin_id: 插件ID
:param plugin_root: 前端插件根目录
:param options: 插件模板生成选项
:param frontend_version: 已解析的前端 Vue 版本
:return: 文件路径和内容列表
"""
files = [
(
plugin_root / 'api' / f'{plugin_id}.js',
PluginFrontendScaffoldTemplateBuilder.build_crud_api(plugin_id)
if options.crud
else PluginFrontendScaffoldTemplateBuilder.build_api(plugin_id),
),
(
plugin_root / 'views' / 'index.vue',
PluginFrontendScaffoldTemplateBuilder.build_crud_view(plugin_id, frontend_version)
if options.crud
else PluginFrontendScaffoldTemplateBuilder.build_view(plugin_id, frontend_version),
),
(
plugin_root / 'README.md',
PluginFrontendScaffoldTemplateBuilder.build_readme(plugin_id, frontend_version),
),
]
if options.test:
files.append(
(
self.frontend_root / 'tests' / 'plugins' / plugin_id / 'pluginView.test.js',
PluginFrontendScaffoldTemplateBuilder.build_test(plugin_id, frontend_version),
)
)
return files
@@ -0,0 +1,431 @@
import json
import re
from pathlib import Path
from typing import Literal, cast
from .frontend_vue2 import PluginVue2FrontendScaffoldTemplateBuilder
from .naming import PluginScaffoldNaming
FrontendVersion = Literal['vue2', 'vue3']
class PluginFrontendVersionResolver:
"""
前端 Vue 版本解析器。
"""
AUTO = 'auto'
DEFAULT_VERSION: FrontendVersion = 'vue3'
SUPPORTED_VALUES = (AUTO, 'vue2', 'vue3')
@classmethod
def resolve(cls, frontend_root: Path, requested_version: str = AUTO) -> FrontendVersion:
"""
解析脚手架应使用的 Vue 版本。
显式版本优先;auto 模式读取目标前端 package.json。临时目录等没有
package.json 的场景保持历史行为,默认生成 Vue 3 模板。
:param frontend_root: 前端项目根目录
:param requested_version: auto、vue2 或 vue3
:return: 解析后的 Vue 版本
"""
normalized_version = (requested_version or cls.AUTO).strip().lower()
if normalized_version not in cls.SUPPORTED_VALUES:
supported = ''.join(cls.SUPPORTED_VALUES)
raise ValueError(f'frontend_version 仅支持 {supported},当前值:{requested_version}')
if normalized_version != cls.AUTO:
return cast('FrontendVersion', normalized_version)
package_json_path = frontend_root / 'package.json'
if not package_json_path.is_file():
return cls.DEFAULT_VERSION
try:
package_payload = json.loads(package_json_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f'读取前端 package.json 失败:{package_json_path}{exc}') from exc
if not isinstance(package_payload, dict):
raise ValueError(f'前端 package.json 顶层必须是对象:{package_json_path}')
dependencies = cls._collect_dependencies(package_payload)
vue_version = cls._resolve_vue_dependency(dependencies.get('vue'))
if vue_version is not None:
return vue_version
if 'element-ui' in dependencies:
return 'vue2'
if 'element-plus' in dependencies:
return 'vue3'
raise ValueError(f'无法从 {package_json_path} 识别 Vue 版本,请使用 --frontend-version vue2 或 vue3 显式指定')
@staticmethod
def _collect_dependencies(package_payload: dict[str, object]) -> dict[str, object]:
"""
合并 dependencies 和 devDependencies。
:param package_payload: package.json 负载
:return: 依赖映射
"""
dependencies: dict[str, object] = {}
for key in ('devDependencies', 'dependencies'):
section = package_payload.get(key)
if isinstance(section, dict):
dependencies.update(section)
return dependencies
@staticmethod
def _resolve_vue_dependency(version_spec: object) -> FrontendVersion | None:
"""
从 npm Vue 版本约束中提取主版本。
:param version_spec: Vue npm 版本约束
:return: Vue 版本,无法识别时返回 None
"""
if not isinstance(version_spec, str):
return None
match = re.search(r'(?<!\d)([23])(?:\.\d+)?', version_spec)
if match is None:
return None
return cast('FrontendVersion', f'vue{match.group(1)}')
class PluginVue3FrontendScaffoldTemplateBuilder:
"""
前端插件模板内容构建器。
"""
@staticmethod
def build_api(plugin_id: str) -> str:
"""
构建前端 API 模板内容。
:param plugin_id: 插件ID
:return: 前端 API 模板内容
"""
return f"""import request from '@/utils/request'
export function ping{PluginScaffoldNaming.to_class_name(plugin_id)}() {{
return request({{
url: '/{plugin_id}/ping',
method: 'get'
}})
}}
"""
@staticmethod
def build_crud_api(plugin_id: str) -> str:
"""
构建前端 CRUD API 模板内容。
:param plugin_id: 插件ID
:return: 前端 CRUD API 模板内容
"""
class_name = PluginScaffoldNaming.to_class_name(plugin_id)
return f"""import request from '@/utils/request'
export function ping{class_name}() {{
return request({{
url: '/{plugin_id}/ping',
method: 'get'
}})
}}
export function list{class_name}Items(query) {{
return request({{
url: '/{plugin_id}/items',
method: 'get',
params: query
}})
}}
export function add{class_name}Item(data) {{
return request({{
url: '/{plugin_id}/items',
method: 'post',
data
}})
}}
export function update{class_name}Item(itemId, data) {{
return request({{
url: '/{plugin_id}/items/' + itemId,
method: 'put',
data
}})
}}
export function del{class_name}Item(itemId) {{
return request({{
url: '/{plugin_id}/items/' + itemId,
method: 'delete'
}})
}}
"""
@staticmethod
def build_view(plugin_id: str) -> str:
"""
构建前端视图模板内容。
:param plugin_id: 插件ID
:return: 前端视图模板内容
"""
return f"""<template>
<div class=\"app-container\">
<el-card shadow=\"never\">
<template #header>{plugin_id}</template>
<div>{plugin_id} plugin</div>
</el-card>
</div>
</template>
"""
@staticmethod
def build_crud_view(plugin_id: str) -> str:
"""
构建前端 CRUD 视图模板内容。
:param plugin_id: 插件ID
:return: 前端 CRUD 视图模板内容
"""
class_name = PluginScaffoldNaming.to_class_name(plugin_id)
return f"""<template>
<div class=\"app-container\">
<el-form :model=\"queryParams\" :inline=\"true\">
<el-form-item label=\"名称\">
<el-input v-model=\"queryParams.keyword\" placeholder=\"请输入名称\" clearable />
</el-form-item>
<el-form-item>
<el-button type=\"primary\" icon=\"Search\" @click=\"getList\">搜索</el-button>
<el-button icon=\"Refresh\" @click=\"resetQuery\">重置</el-button>
<el-button type=\"primary\" plain icon=\"Plus\" @click=\"handleAdd\">新增</el-button>
</el-form-item>
</el-form>
<el-table v-loading=\"loading\" :data=\"itemList\" border>
<el-table-column label=\"ID\" prop=\"itemId\" width=\"90\" align=\"center\" />
<el-table-column label=\"名称\" prop=\"itemName\" min-width=\"180\" />
<el-table-column label=\"状态\" prop=\"status\" width=\"90\" align=\"center\">
<template #default=\"scope\">
<el-tag :type=\"scope.row.status === '0' ? 'success' : 'info'\">{{{{ scope.row.status === '0' ? '正常' : '停用' }}}}</el-tag>
</template>
</el-table-column>
<el-table-column label=\"备注\" prop=\"remark\" min-width=\"220\" />
<el-table-column label=\"操作\" width=\"150\" align=\"center\">
<template #default=\"scope\">
<el-button link type=\"primary\" icon=\"Edit\" @click=\"handleUpdate(scope.row)\">修改</el-button>
<el-button link type=\"danger\" icon=\"Delete\" @click=\"handleDelete(scope.row)\">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :title=\"dialogTitle\" v-model=\"open\" width=\"520px\" append-to-body>
<el-form ref=\"itemRef\" :model=\"form\" :rules=\"rules\" label-width=\"90px\">
<el-form-item label=\"名称\" prop=\"itemName\">
<el-input v-model=\"form.itemName\" placeholder=\"请输入名称\" />
</el-form-item>
<el-form-item label=\"状态\" prop=\"status\">
<el-radio-group v-model=\"form.status\">
<el-radio label=\"0\">正常</el-radio>
<el-radio label=\"1\">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label=\"备注\" prop=\"remark\">
<el-input v-model=\"form.remark\" type=\"textarea\" :rows=\"3\" />
</el-form-item>
</el-form>
<template #footer>
<div class=\"dialog-footer\">
<el-button type=\"primary\" @click=\"submitForm\">确 定</el-button>
<el-button @click=\"open = false\">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name=\"{class_name}Plugin\">
import {{
add{class_name}Item,
del{class_name}Item,
list{class_name}Items,
update{class_name}Item
}} from '../api/{plugin_id}'
const {{ proxy }} = getCurrentInstance()
const loading = ref(false)
const open = ref(false)
const dialogTitle = ref('')
const itemList = ref([])
const queryParams = reactive({{
keyword: ''
}})
const form = reactive({{
itemId: undefined,
itemName: '',
status: '0',
remark: ''
}})
const rules = {{
itemName: [{{ required: true, message: '名称不能为空', trigger: 'blur' }}]
}}
function resetForm() {{
form.itemId = undefined
form.itemName = ''
form.status = '0'
form.remark = ''
}}
function getList() {{
loading.value = true
list{class_name}Items(queryParams).then(response => {{
const data = response.data || response
itemList.value = data.rows || []
}}).finally(() => {{
loading.value = false
}})
}}
function resetQuery() {{
queryParams.keyword = ''
getList()
}}
function handleAdd() {{
resetForm()
dialogTitle.value = '新增{plugin_id}'
open.value = true
}}
function handleUpdate(row) {{
resetForm()
form.itemId = row.itemId
form.itemName = row.itemName
form.status = row.status
form.remark = row.remark
dialogTitle.value = '修改{plugin_id}'
open.value = true
}}
function submitForm() {{
proxy.$refs.itemRef.validate(valid => {{
if (!valid) {{
return
}}
const request = form.itemId ? update{class_name}Item(form.itemId, form) : add{class_name}Item(form)
request.then(() => {{
proxy.$modal.msgSuccess('保存成功')
open.value = false
getList()
}})
}})
}}
function handleDelete(row) {{
proxy.$modal.confirm('确认删除数据\"' + row.itemName + '\"吗?').then(function () {{
return del{class_name}Item(row.itemId)
}}).then(() => {{
proxy.$modal.msgSuccess('删除成功')
getList()
}})
}}
getList()
</script>
"""
@staticmethod
def build_readme(plugin_id: str) -> str:
"""
构建前端 README 内容。
:param plugin_id: 插件ID
:return: 前端 README 内容
"""
return f"""# {plugin_id} frontend plugin
Frontend plugin scaffold generated by `ruoyi plugin create`.
## Structure
- `api/`: request wrappers used by plugin pages.
- `views/`: Vue pages loaded by backend menu component paths.
- `../../tests/plugins/{plugin_id}/`: frontend node tests for plugin view resolving.
## Route Component
The backend menu component `plugin/{plugin_id}/index` maps to:
```text
plugins/{plugin_id}/views/index.vue
```
"""
@staticmethod
def build_test(plugin_id: str) -> str:
"""
构建前端插件 node 测试样例。
:param plugin_id: 插件ID
:return: 前端插件测试样例内容
"""
return f"""import assert from 'node:assert/strict'
import {{ existsSync }} from 'node:fs'
import {{ dirname, resolve }} from 'node:path'
import {{ fileURLToPath }} from 'node:url'
import {{ resolvePluginViewPath }} from '../../../src/utils/pluginViewResolver.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const frontendRoot = resolve(__dirname, '../../..')
const viewPath = resolve(frontendRoot, 'plugins', '{plugin_id}', 'views', 'index.vue')
assert.equal(resolvePluginViewPath('plugin/{plugin_id}/index'), '../../../plugins/{plugin_id}/views/index.vue')
assert.equal(existsSync(viewPath), true)
console.log('{plugin_id} plugin frontend tests passed')
"""
class PluginFrontendScaffoldTemplateBuilder:
"""
根据目标 Vue 版本分派前端插件模板。
"""
@staticmethod
def build_api(plugin_id: str) -> str:
return PluginVue3FrontendScaffoldTemplateBuilder.build_api(plugin_id)
@staticmethod
def build_crud_api(plugin_id: str) -> str:
return PluginVue3FrontendScaffoldTemplateBuilder.build_crud_api(plugin_id)
@classmethod
def build_view(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str:
return cls._resolve_builder(frontend_version).build_view(plugin_id)
@classmethod
def build_crud_view(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str:
return cls._resolve_builder(frontend_version).build_crud_view(plugin_id)
@classmethod
def build_readme(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str:
return cls._resolve_builder(frontend_version).build_readme(plugin_id)
@classmethod
def build_test(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str:
return cls._resolve_builder(frontend_version).build_test(plugin_id)
@staticmethod
def _resolve_builder(
frontend_version: FrontendVersion,
) -> type[PluginVue2FrontendScaffoldTemplateBuilder] | type[PluginVue3FrontendScaffoldTemplateBuilder]:
if frontend_version == 'vue2':
return PluginVue2FrontendScaffoldTemplateBuilder
if frontend_version == 'vue3':
return PluginVue3FrontendScaffoldTemplateBuilder
raise ValueError(f'不支持的 Vue 版本:{frontend_version}')
@@ -0,0 +1,235 @@
from .naming import PluginScaffoldNaming
class PluginVue2FrontendScaffoldTemplateBuilder:
"""
Vue 2 前端插件模板内容构建器。
"""
@staticmethod
def build_view(plugin_id: str) -> str:
"""
构建 Vue 2 前端视图模板内容。
:param plugin_id: 插件ID
:return: 前端视图模板内容
"""
return f"""<template>
<div class=\"app-container\">
<el-card shadow=\"never\">
<div slot=\"header\">{plugin_id}</div>
<div>{plugin_id} plugin</div>
</el-card>
</div>
</template>
"""
@staticmethod
def build_crud_view(plugin_id: str) -> str:
"""
构建 Vue 2 前端 CRUD 视图模板内容。
:param plugin_id: 插件ID
:return: 前端 CRUD 视图模板内容
"""
class_name = PluginScaffoldNaming.to_class_name(plugin_id)
return f"""<template>
<div class=\"app-container\">
<el-form ref=\"queryForm\" :model=\"queryParams\" size=\"small\" :inline=\"true\">
<el-form-item label=\"名称\">
<el-input v-model=\"queryParams.keyword\" placeholder=\"请输入名称\" clearable />
</el-form-item>
<el-form-item>
<el-button type=\"primary\" icon=\"el-icon-search\" size=\"mini\" @click=\"getList\">搜索</el-button>
<el-button icon=\"el-icon-refresh\" size=\"mini\" @click=\"resetQuery\">重置</el-button>
<el-button type=\"primary\" plain icon=\"el-icon-plus\" size=\"mini\" @click=\"handleAdd\">新增</el-button>
</el-form-item>
</el-form>
<el-table v-loading=\"loading\" :data=\"itemList\" border>
<el-table-column label=\"ID\" prop=\"itemId\" width=\"90\" align=\"center\" />
<el-table-column label=\"名称\" prop=\"itemName\" min-width=\"180\" />
<el-table-column label=\"状态\" prop=\"status\" width=\"90\" align=\"center\">
<template slot-scope=\"scope\">
<el-tag :type=\"scope.row.status === '0' ? 'success' : 'info'\">{{{{ scope.row.status === '0' ? '正常' : '停用' }}}}</el-tag>
</template>
</el-table-column>
<el-table-column label=\"备注\" prop=\"remark\" min-width=\"220\" />
<el-table-column label=\"操作\" width=\"150\" align=\"center\">
<template slot-scope=\"scope\">
<el-button type=\"text\" icon=\"el-icon-edit\" size=\"mini\" @click=\"handleUpdate(scope.row)\">修改</el-button>
<el-button type=\"text\" icon=\"el-icon-delete\" size=\"mini\" @click=\"handleDelete(scope.row)\">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog :title=\"dialogTitle\" :visible.sync=\"open\" width=\"520px\" append-to-body>
<el-form ref=\"itemForm\" :model=\"form\" :rules=\"rules\" label-width=\"90px\">
<el-form-item label=\"名称\" prop=\"itemName\">
<el-input v-model=\"form.itemName\" placeholder=\"请输入名称\" />
</el-form-item>
<el-form-item label=\"状态\" prop=\"status\">
<el-radio-group v-model=\"form.status\">
<el-radio label=\"0\">正常</el-radio>
<el-radio label=\"1\">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label=\"备注\" prop=\"remark\">
<el-input v-model=\"form.remark\" type=\"textarea\" :rows=\"3\" />
</el-form-item>
</el-form>
<div slot=\"footer\" class=\"dialog-footer\">
<el-button type=\"primary\" @click=\"submitForm\">确 定</el-button>
<el-button @click=\"open = false\">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {{
add{class_name}Item,
del{class_name}Item,
list{class_name}Items,
update{class_name}Item
}} from '../api/{plugin_id}'
export default {{
name: '{class_name}Plugin',
data() {{
return {{
loading: false,
open: false,
dialogTitle: '',
itemList: [],
queryParams: {{
keyword: ''
}},
form: {{
itemId: undefined,
itemName: '',
status: '0',
remark: ''
}},
rules: {{
itemName: [{{ required: true, message: '名称不能为空', trigger: 'blur' }}]
}}
}}
}},
created() {{
this.getList()
}},
methods: {{
resetFormData() {{
this.form = {{
itemId: undefined,
itemName: '',
status: '0',
remark: ''
}}
}},
getList() {{
this.loading = true
list{class_name}Items(this.queryParams).then(response => {{
const data = response.data || response
this.itemList = data.rows || []
}}).finally(() => {{
this.loading = false
}})
}},
resetQuery() {{
this.queryParams.keyword = ''
this.getList()
}},
handleAdd() {{
this.resetFormData()
this.dialogTitle = '新增{plugin_id}'
this.open = true
}},
handleUpdate(row) {{
this.resetFormData()
this.form = {{
itemId: row.itemId,
itemName: row.itemName,
status: row.status,
remark: row.remark
}}
this.dialogTitle = '修改{plugin_id}'
this.open = true
}},
submitForm() {{
this.$refs.itemForm.validate(valid => {{
if (!valid) {{
return
}}
const request = this.form.itemId
? update{class_name}Item(this.form.itemId, this.form)
: add{class_name}Item(this.form)
request.then(() => {{
this.$modal.msgSuccess('保存成功')
this.open = false
this.getList()
}})
}})
}},
handleDelete(row) {{
this.$modal.confirm('确认删除数据\"' + row.itemName + '\"吗?').then(function () {{
return del{class_name}Item(row.itemId)
}}).then(() => {{
this.$modal.msgSuccess('删除成功')
this.getList()
}})
}}
}}
}}
</script>
"""
@staticmethod
def build_readme(plugin_id: str) -> str:
"""
构建 Vue 2 前端 README 内容。
:param plugin_id: 插件ID
:return: 前端 README 内容
"""
return f"""# {plugin_id} frontend plugin
Vue 2 frontend plugin scaffold generated by `ruoyi plugin create`.
## Structure
- `api/`: request wrappers used by plugin pages.
- `views/`: Vue pages loaded by backend menu component paths.
- `../../tests/plugins/{plugin_id}/`: frontend node tests for plugin view resolving.
## Route Component
The backend menu component `plugin/{plugin_id}/index` maps to:
```text
plugins/{plugin_id}/views/index.vue
```
"""
@staticmethod
def build_test(plugin_id: str) -> str:
"""
构建 Vue 2 前端插件 node 测试样例。
:param plugin_id: 插件ID
:return: 前端插件测试样例内容
"""
return f"""const assert = require('assert').strict
const {{ existsSync }} = require('fs')
const {{ resolve }} = require('path')
const {{ resolvePluginViewPath }} = require('../../../src/utils/pluginViewResolver')
const frontendRoot = resolve(__dirname, '../../..')
const viewPath = resolve(frontendRoot, 'plugins', '{plugin_id}', 'views', 'index.vue')
assert.equal(resolvePluginViewPath('plugin/{plugin_id}/index'), './{plugin_id}/views/index.vue')
assert.equal(existsSync(viewPath), true)
console.log('{plugin_id} plugin frontend tests passed')
"""
@@ -0,0 +1,14 @@
class PluginScaffoldNaming:
"""
插件模板命名转换工具。
"""
@staticmethod
def to_class_name(plugin_id: str) -> str:
"""
将插件 ID 转换为类名前缀。
:param plugin_id: 插件ID
:return: 类名前缀
"""
return ''.join(part.capitalize() for part in plugin_id.replace('-', '_').split('_') if part)
@@ -0,0 +1,84 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class PluginScaffoldOptions:
"""
插件模板生成选项。
:param backend: 是否生成后端模板
:param frontend: 是否生成前端模板
:param migration: 是否生成 migration 示例
:param seed: 是否生成 seed 示例
:param job: 是否生成定时任务示例
:param config: 是否生成配置项示例
:param test: 是否生成测试样例
:param crud: 是否生成 CRUD 页面示例
"""
backend: bool = True
frontend: bool = True
migration: bool = True
seed: bool = True
job: bool = True
config: bool = True
test: bool = True
crud: bool = False
class PluginScaffoldTemplateResolver:
"""
插件模板预设解析器。
使用 Resolver 模式将模板名称转换为稳定的模板生成选项。
"""
SUPPORTED_TEMPLATES = {'minimal', 'backend-only', 'full-stack', 'scheduled-job', 'crud-page'}
DEFAULT_TEMPLATE = 'full-stack'
@classmethod
def resolve(cls, template: str) -> PluginScaffoldOptions:
"""
解析插件模板预设。
:param template: 插件模板名称
:return: 插件模板生成选项
"""
normalized_template = (template or cls.DEFAULT_TEMPLATE).strip() or cls.DEFAULT_TEMPLATE
if normalized_template == 'minimal':
return PluginScaffoldOptions(
backend=True,
frontend=False,
migration=False,
seed=False,
job=False,
config=False,
test=True,
)
if normalized_template == 'backend-only':
return PluginScaffoldOptions(backend=True, frontend=False)
if normalized_template == 'full-stack':
return PluginScaffoldOptions()
if normalized_template == 'scheduled-job':
return PluginScaffoldOptions(
backend=True,
frontend=False,
migration=False,
seed=False,
job=True,
config=False,
test=True,
)
if normalized_template == 'crud-page':
return PluginScaffoldOptions(
backend=True,
frontend=True,
migration=True,
seed=True,
job=False,
config=True,
test=True,
crud=True,
)
supported_templates = ', '.join(sorted(cls.SUPPORTED_TEMPLATES))
raise ValueError(f'插件模板只支持:{supported_templates}')
@@ -0,0 +1,146 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from cli.exit_codes import RUNTIME_ERROR
@dataclass(frozen=True)
class PluginScaffoldPlanPayload:
"""
插件模板写入计划负载。
"""
template: str
backend: bool
frontend: bool
migration: bool
seed: bool
job: bool
config: bool
crud: bool
test: bool
backend_test: bool
frontend_test: bool
frontend_version: str | None
target_dirs: list[str]
files: list[tuple[Path, str]]
conflicts: list[str]
def to_payload(self) -> dict[str, Any]:
"""
序列化为既有 CLI payload 契约。
:return: 插件模板写入计划负载
"""
return {
'backend': self.backend,
'frontend': self.frontend,
'template': self.template,
'migration': self.migration,
'seed': self.seed,
'job': self.job,
'config': self.config,
'crud': self.crud,
'test': self.test,
'backendTest': self.backend_test,
'frontendTest': self.frontend_test,
'frontendVersion': self.frontend_version,
'targetDirs': self.target_dirs,
'files': [{'path': str(path), 'content': content} for path, content in self.files],
'conflicts': self.conflicts,
}
@dataclass(frozen=True)
class PluginScaffoldSuccessPayload:
"""
插件模板创建成功负载。
"""
plugin_id: str
scaffold_plan: dict[str, Any]
dry_run: bool
def to_payload(self) -> dict[str, Any]:
"""
序列化为既有 CLI payload 契约。
:return: 插件模板创建成功负载
"""
return {
'ok': True,
'message': '插件模板预演完成' if self.dry_run else '插件模板创建成功',
'pluginId': self.plugin_id,
'dryRun': self.dry_run,
**self.scaffold_plan,
}
@dataclass(frozen=True)
class PluginScaffoldConflictPayload:
"""
插件模板目录冲突负载。
"""
plugin_id: str
scaffold_plan: dict[str, Any]
dry_run: bool
failure_code: int = RUNTIME_ERROR
def to_payload(self) -> dict[str, Any]:
"""
序列化为既有 CLI payload 契约。
:return: 插件模板目录冲突负载
"""
return {
'ok': False,
'message': '插件目录已存在,拒绝覆盖',
'pluginId': self.plugin_id,
'dryRun': self.dry_run,
**self.scaffold_plan,
'exit_code': self.failure_code,
}
class PluginScaffoldPayloadBuilder:
"""
插件模板创建响应负载构建器。
"""
@staticmethod
def build_conflict_payload(
plugin_id: str,
scaffold_plan: dict[str, Any],
*,
dry_run: bool,
failure_code: int = RUNTIME_ERROR,
) -> dict[str, Any]:
"""
构建插件模板目录冲突负载。
:param plugin_id: 插件ID
:param scaffold_plan: 插件模板写入计划
:param dry_run: 是否预演
:param failure_code: 失败退出码
:return: 插件模板目录冲突负载
"""
return PluginScaffoldConflictPayload(
plugin_id,
scaffold_plan,
dry_run=dry_run,
failure_code=failure_code,
).to_payload()
@staticmethod
def build_success_payload(plugin_id: str, scaffold_plan: dict[str, Any], *, dry_run: bool) -> dict[str, Any]:
"""
构建插件模板创建成功负载。
:param plugin_id: 插件ID
:param scaffold_plan: 插件模板写入计划
:param dry_run: 是否预演
:return: 插件模板创建成功负载
"""
return PluginScaffoldSuccessPayload(plugin_id, scaffold_plan, dry_run=dry_run).to_payload()
@@ -0,0 +1,503 @@
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
from cli.exit_codes import RUNTIME_ERROR, SUCCESS
from .gateway import PluginRuntimeGateway
from .scaffold import PluginScaffoldBuilder
from .support import (
PLUGIN_DEPENDENCY_ALLOWLIST_EXAMPLE_YAML,
CliPluginRuntimeExceptionPayload,
PluginDependencyAllowlistExamplePayloadBuilder,
PluginDependencyLockfileTemplateBuilder,
PluginDependencyLockPayloadBuilder,
PluginTestPayloadBuilder,
PluginTestPlanBuilder,
)
PYTEST_COMMAND_TIMEOUT_SECONDS = 120
@dataclass(frozen=True)
class CliPluginRuntimeDependencies:
"""
CLI 插件运行时依赖容器。
"""
runtime_environment: object | None = None
dependency_checker: object | None = None
management_gateway: object | None = None
model_gateway: object | None = None
command_gateway: object | None = None
lifecycle_lock: object | None = None
class CliPluginRuntimeService:
"""
插件 CLI 运行时服务。
该服务负责为 CLI 组装核心插件运行时,并只承载 CLI 专属开发命令能力,
例如插件测试执行、插件模板创建和本地辅助文件生成。
"""
def __init__(
self,
*,
success_code: int = SUCCESS,
runtime_error_code: int = RUNTIME_ERROR,
runtime_environment: object | None = None,
dependency_checker: object | None = None,
management_gateway: object | None = None,
model_gateway: object | None = None,
command_gateway: object | None = None,
lifecycle_lock: object | None = None,
plugin_gateway: PluginRuntimeGateway | None = None,
) -> None:
"""
初始化插件 CLI 运行时服务。
:param success_code: CLI 成功退出码
:param runtime_error_code: CLI 运行失败退出码
:param runtime_environment: 插件运行时环境服务
:param dependency_checker: 插件依赖检查器
:param management_gateway: 插件管理运行时适配器
:param model_gateway: 插件管理模型工厂网关
:param command_gateway: 插件命令执行网关
:param lifecycle_lock: 插件生命周期操作锁
:param plugin_gateway: 插件 CLI 运行时网关
:return: None
"""
self.success_code = success_code
self.runtime_error_code = runtime_error_code
self.plugin_gateway = plugin_gateway or PluginRuntimeGateway()
self.dependencies = CliPluginRuntimeDependencies(
runtime_environment=runtime_environment,
dependency_checker=dependency_checker,
management_gateway=management_gateway,
model_gateway=model_gateway,
command_gateway=command_gateway,
lifecycle_lock=lifecycle_lock,
)
self._core_runtime: Any | None = None
@property
def core_runtime(self) -> Any:
"""
延迟获取插件核心运行时服务。
:return: 插件核心运行时服务
"""
if self._core_runtime is None:
runtime_service_class = self.plugin_gateway.get_core_runtime_service_class()
management_gateway = self._resolve_management_gateway()
gateway_overrides_class = self.plugin_gateway.get_core_runtime_gateway_overrides_class()
self._core_runtime = runtime_service_class(
runtime_environment=self._resolve_runtime_environment(),
dependency_checker=self.dependencies.dependency_checker,
gateways=self._build_gateway_overrides(gateway_overrides_class, management_gateway),
model_gateway=self._resolve_model_gateway(),
command_gateway=self._resolve_command_gateway(),
lifecycle_lock=self._resolve_lifecycle_lock(),
)
return self._core_runtime
def _resolve_runtime_environment(self) -> object:
"""
解析插件核心运行时环境服务。
:return: 插件核心运行时环境服务
"""
if self.dependencies.runtime_environment is not None:
return self.dependencies.runtime_environment
runtime_environment = self.plugin_gateway.get_core_runtime_environment()
self.dependencies = replace(self.dependencies, runtime_environment=runtime_environment)
return runtime_environment
def _load_management_gateway(self) -> object:
"""
解析插件管理运行时适配器。
:return: 插件管理运行时适配器
"""
management_gateway = self.plugin_gateway.get_management_runtime_gateway()
self.dependencies = replace(
self.dependencies,
management_gateway=self.dependencies.management_gateway or management_gateway,
model_gateway=self.dependencies.model_gateway or management_gateway,
command_gateway=self.dependencies.command_gateway or management_gateway,
)
return management_gateway
def _resolve_management_gateway(self) -> object:
"""
解析插件管理运行时适配器。
:return: 插件管理运行时适配器
"""
if self.dependencies.management_gateway is None:
self._load_management_gateway()
return self.dependencies.management_gateway
@staticmethod
def _build_gateway_overrides(gateway_overrides_class: object, management_gateway: object) -> object:
"""
构建插件核心运行时窄端口覆盖项。
:param gateway_overrides_class: 插件核心运行时窄端口覆盖项类
:param management_gateway: 插件管理运行时适配器
:return: 插件核心运行时窄端口覆盖项
"""
return gateway_overrides_class(
config_gateway=management_gateway,
audit_gateway=management_gateway,
state_query_gateway=management_gateway,
migration_history_gateway=management_gateway,
purge_plan_gateway=management_gateway,
lifecycle_state_gateway=management_gateway,
lifecycle_uow_gateway=management_gateway,
migration_execution_gateway=management_gateway,
)
def _resolve_model_gateway(self) -> object:
"""
解析插件核心运行时模型工厂网关。
:return: 插件核心运行时模型工厂网关
"""
if self.dependencies.model_gateway is None:
self._resolve_management_gateway()
return self.dependencies.model_gateway
def _resolve_command_gateway(self) -> object:
"""
解析插件核心运行时命令执行网关。
:return: 插件核心运行时命令执行网关
"""
if self.dependencies.command_gateway is None:
self._resolve_management_gateway()
return self.dependencies.command_gateway
def _resolve_lifecycle_lock(self) -> object:
"""
解析插件核心生命周期操作锁。
:return: 插件核心生命周期操作锁
"""
if self.dependencies.lifecycle_lock is not None:
return self.dependencies.lifecycle_lock
lifecycle_lock = self.plugin_gateway.get_core_lifecycle_lock()
self.dependencies = replace(self.dependencies, lifecycle_lock=lifecycle_lock)
return lifecycle_lock
def _build_exception_payload(self, message: str, exc: Exception) -> dict[str, object]:
"""
构建 CLI 插件运行时异常负载。
:param message: 异常场景提示
:param exc: 异常对象
:return: 异常负载
"""
return CliPluginRuntimeExceptionPayload(
self.plugin_gateway.build_exception_payload(message, exc),
failure_code=self.runtime_error_code,
).to_payload()
def lock_plugin_dependencies(
self,
plugin_id: str,
*,
output_path: str = '',
offline_dir: str = '',
dry_run: bool = False,
overwrite: bool = False,
) -> dict[str, object]:
"""
生成插件依赖锁文件模板。
:param plugin_id: 插件ID
:param output_path: 输出锁文件路径
:param offline_dir: 离线制品根目录
:param dry_run: 是否仅预演
:param overwrite: 是否覆盖已有文件
:return: 插件依赖锁文件模板负载
"""
try:
runtime_environment = self._resolve_runtime_environment()
discovered_plugin = self._find_discovered_plugin(plugin_id)
if discovered_plugin is None:
return PluginDependencyLockPayloadBuilder.build_not_found_payload(plugin_id)
backend_root = Path(runtime_environment.get_backend_dir())
resolved_output_path = self._resolve_lockfile_output_path(
backend_root,
discovered_plugin.backend_path,
output_path,
)
lockfile_template = PluginDependencyLockfileTemplateBuilder.build(
discovered_plugin.manifest,
offline_dir=offline_dir or None,
)
if resolved_output_path.exists() and not overwrite and not dry_run:
return PluginDependencyLockPayloadBuilder.build_exists_payload(plugin_id, resolved_output_path)
written = False
overwritten = resolved_output_path.exists()
if not dry_run:
resolved_output_path.parent.mkdir(parents=True, exist_ok=True)
resolved_output_path.write_text(lockfile_template.to_yaml(), encoding='utf-8')
written = True
return PluginDependencyLockPayloadBuilder.build_success_payload(
plugin_id,
lockfile_template,
resolved_output_path,
dry_run=dry_run,
written=written,
overwritten=overwritten and written,
)
except Exception as exc:
return self._build_exception_payload('生成插件依赖锁文件模板失败', exc)
def _find_discovered_plugin(self, plugin_id: str) -> Any | None:
"""
根据插件ID发现本地插件。
:param plugin_id: 插件ID
:return: 已发现插件
"""
from plugins.core.discovery.scanner import PluginScanner # noqa: PLC0415
runtime_environment = self._resolve_runtime_environment()
return next(
(
discovered_plugin
for discovered_plugin in PluginScanner(runtime_environment.get_backend_plugins_dir()).discover()
if discovered_plugin.manifest.id == plugin_id
),
None,
)
@staticmethod
def _resolve_lockfile_output_path(backend_root: Path, plugin_path: Path, output_path: str) -> Path:
"""
解析锁文件输出路径。
:param backend_root: 后端项目根目录
:param plugin_path: 插件目录
:param output_path: 用户指定输出路径
:return: 输出路径
"""
if not output_path:
return plugin_path / 'plugin.lock.yaml'
return CliPluginRuntimeService._resolve_backend_output_path(backend_root, output_path)
def generate_plugin_dependency_allowlist_example(
self,
*,
output_path: str = '',
dry_run: bool = False,
overwrite: bool = False,
) -> dict[str, object]:
"""
生成插件依赖允许列表示例。
:param output_path: 输出允许列表路径
:param dry_run: 是否仅预演
:param overwrite: 是否覆盖已有文件
:return: 允许列表示例负载
"""
try:
runtime_environment = self._resolve_runtime_environment()
backend_root = Path(runtime_environment.get_backend_dir())
resolved_output_path = self._resolve_allowlist_example_output_path(backend_root, output_path)
if resolved_output_path.exists() and not overwrite and not dry_run:
return PluginDependencyAllowlistExamplePayloadBuilder.build_exists_payload(resolved_output_path)
written = False
overwritten = resolved_output_path.exists()
if not dry_run:
resolved_output_path.parent.mkdir(parents=True, exist_ok=True)
resolved_output_path.write_text(PLUGIN_DEPENDENCY_ALLOWLIST_EXAMPLE_YAML, encoding='utf-8')
written = True
return PluginDependencyAllowlistExamplePayloadBuilder.build_success_payload(
resolved_output_path,
allowlist_text=PLUGIN_DEPENDENCY_ALLOWLIST_EXAMPLE_YAML,
dry_run=dry_run,
written=written,
overwritten=overwritten and written,
)
except Exception as exc:
return self._build_exception_payload('生成插件依赖允许列表示例失败', exc)
@staticmethod
def _resolve_allowlist_example_output_path(backend_root: Path, output_path: str) -> Path:
"""
解析允许列表示例输出路径。
:param backend_root: 后端项目根目录
:param output_path: 用户指定输出路径
:return: 输出路径
"""
if not output_path:
return backend_root / 'config' / 'plugin_dependency_allowlist.yaml'
return CliPluginRuntimeService._resolve_backend_output_path(backend_root, output_path)
@staticmethod
def _resolve_backend_output_path(backend_root: Path, output_path: str) -> Path:
"""
解析 CLI 输出路径,并限制在后端项目目录内。
:param backend_root: 后端项目根目录
:param output_path: 用户指定输出路径
:return: 规范化后的输出路径
"""
raw_output_path = Path(output_path)
resolved_backend_root = backend_root.resolve(strict=False)
resolved_output_path = (
raw_output_path if raw_output_path.is_absolute() else resolved_backend_root / raw_output_path
).resolve(strict=False)
try:
resolved_output_path.relative_to(resolved_backend_root)
except ValueError as exc:
raise ValueError(f'输出路径必须位于后端项目目录内:{output_path}') from exc
return resolved_output_path
def test_plugin(
self,
plugin_id: str,
*,
keyword: str = '',
maxfail: int = 0,
quiet: bool = False,
frontend_build: bool = False,
) -> dict[str, object]:
"""
执行插件测试样例。
:param plugin_id: 插件ID
:param keyword: pytest `-k` 过滤表达式
:param maxfail: 最大失败数,0 表示不限制
:param quiet: 是否启用简洁输出
:param frontend_build: 是否执行前端构建验收
:return: 插件测试执行结果负载
"""
try:
runtime_environment = self._resolve_runtime_environment()
command_gateway = self._resolve_command_gateway()
backend_root = Path(runtime_environment.get_backend_dir())
frontend_root = Path(runtime_environment.get_frontend_dir())
test_plan_builder = PluginTestPlanBuilder(
backend_root=backend_root,
frontend_root=frontend_root,
python_executable=runtime_environment.get_python_executable(),
timeout=PYTEST_COMMAND_TIMEOUT_SECONDS,
)
targets = test_plan_builder.build(
plugin_id,
keyword=keyword,
maxfail=maxfail,
quiet=quiet,
frontend_build=frontend_build,
)
if not targets:
return PluginTestPayloadBuilder.with_exit_code(
PluginTestPayloadBuilder.build_missing_payload(
plugin_id,
test_plan_builder.expected_paths(plugin_id),
),
success_code=self.success_code,
failure_code=self.runtime_error_code,
)
results = []
for target in targets:
completed = command_gateway.run_command(
target.command,
str(target.workdir),
timeout=target.timeout,
)
results.append(PluginTestPayloadBuilder.build_result_item(target, completed))
return PluginTestPayloadBuilder.with_exit_code(
PluginTestPayloadBuilder.build_execution_payload(
plugin_id,
keyword=keyword,
maxfail=maxfail,
quiet=quiet,
frontend_build=frontend_build,
results=results,
),
success_code=self.success_code,
failure_code=self.runtime_error_code,
)
except Exception as exc:
return self._build_exception_payload('插件测试执行失败', exc)
def create_plugin( # noqa: PLR0913
self,
plugin_id: str,
*,
template: str = 'full-stack',
backend: bool = True,
frontend: bool = True,
migration: bool = True,
seed: bool = True,
job: bool = True,
config: bool = True,
test: bool = True,
frontend_version: str = 'auto',
dry_run: bool = False,
) -> dict[str, object]:
"""
创建插件开发模板。
:param plugin_id: 插件ID
:param template: 插件模板名称
:param backend: 是否创建后端插件模板
:param frontend: 是否创建前端插件模板
:param migration: 是否创建 migration 示例
:param seed: 是否创建 seed 示例
:param job: 是否创建定时任务示例
:param config: 是否创建配置项示例
:param test: 是否创建测试样例
:param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3
:param dry_run: 是否仅预演
:return: 插件创建结果负载
"""
try:
runtime_environment = self._resolve_runtime_environment()
scaffold = PluginScaffoldBuilder(
Path(runtime_environment.get_backend_dir()),
frontend_root=Path(runtime_environment.get_frontend_dir()),
)
scaffold_plan = scaffold.build_plan(
plugin_id,
template=template,
backend=backend,
frontend=frontend,
migration=migration,
seed=seed,
job=job,
config=config,
test=test,
frontend_version=frontend_version,
)
if scaffold_plan['conflicts']:
return PluginScaffoldBuilder.build_conflict_payload(
plugin_id,
scaffold_plan,
dry_run=dry_run,
failure_code=self.runtime_error_code,
)
if not dry_run:
scaffold.apply_plan(scaffold_plan)
return PluginScaffoldBuilder.build_success_payload(plugin_id, scaffold_plan, dry_run=dry_run)
except Exception as exc:
return self._build_exception_payload('创建插件模板失败', exc)
PLUGIN_RUNTIME = CliPluginRuntimeService()
File diff suppressed because it is too large Load Diff