From 2a055ba6481801e15199c8293b31fcf5c99dc9dd Mon Sep 17 00:00:00 2001 From: insistence <92962165+insistence@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:35:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20(#112)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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: 更新后端依赖文件 --- .gitignore | 2 +- README.md | 1 + ruoyi-fastapi-backend/.env.dev | 28 + ruoyi-fastapi-backend/.env.dockermy | 28 + ruoyi-fastapi-backend/.env.dockerpg | 28 + ruoyi-fastapi-backend/.env.prod | 28 + ruoyi-fastapi-backend/cli/core/app_builder.py | 1 + .../cli/groups/plugin/__init__.py | 3 + .../cli/groups/plugin/command.py | 38 + .../cli/groups/plugin/commands/__init__.py | 1 + .../groups/plugin/commands/configuration.py | 59 + .../cli/groups/plugin/commands/dependency.py | 205 ++ .../cli/groups/plugin/commands/developer.py | 109 + .../cli/groups/plugin/commands/discovery.py | 114 + .../cli/groups/plugin/commands/lifecycle.py | 312 +++ .../cli/groups/plugin/controller.py | 1222 +++++++++ .../cli/groups/plugin/exporter.py | 114 + .../cli/groups/plugin/options.py | 88 + .../cli/groups/plugin/payload.py | 154 ++ .../cli/groups/plugin/presenter.py | 924 +++++++ ruoyi-fastapi-backend/cli/guards.py | 39 + .../cli/runtime/plugin/__init__.py | 3 + .../cli/runtime/plugin/gateway.py | 70 + .../cli/runtime/plugin/scaffold/__init__.py | 26 + .../cli/runtime/plugin/scaffold/backend.py | 536 ++++ .../cli/runtime/plugin/scaffold/builder.py | 280 ++ .../cli/runtime/plugin/scaffold/frontend.py | 431 +++ .../runtime/plugin/scaffold/frontend_vue2.py | 235 ++ .../cli/runtime/plugin/scaffold/naming.py | 14 + .../cli/runtime/plugin/scaffold/options.py | 84 + .../cli/runtime/plugin/scaffold/payload.py | 146 + .../cli/runtime/plugin/service.py | 503 ++++ .../cli/runtime/plugin/support.py | 1044 ++++++++ ruoyi-fastapi-backend/common/constant.py | 16 + ruoyi-fastapi-backend/common/router.py | 42 +- ruoyi-fastapi-backend/config/env.py | 28 + ruoyi-fastapi-backend/config/get_db.py | 21 +- ruoyi-fastapi-backend/config/get_redis.py | 26 +- ruoyi-fastapi-backend/config/get_scheduler.py | 254 +- ruoyi-fastapi-backend/docs/cli_usage.md | 32 +- .../docs/plugin_development.md | 860 ++++++ .../docs/plugin_migration_failure_runbook.md | 68 + .../controller/plugin_controller.py | 916 +++++++ .../module_plugin/service/plugin_service.py | 148 + ruoyi-fastapi-backend/plugins/__init__.py | 0 ruoyi-fastapi-backend/plugins/ai/README.md | 113 + .../ai}/controller/ai_chat_controller.py | 32 +- .../ai}/controller/ai_model_controller.py | 11 +- .../ai}/dao/ai_chat_dao.py | 4 +- .../ai}/dao/ai_model_dao.py | 4 +- .../ai}/entity/do/ai_chat_do.py | 0 .../ai}/entity/do/ai_model_do.py | 0 .../ai}/entity/vo/ai_chat_vo.py | 0 .../ai}/entity/vo/ai_model_vo.py | 0 .../plugins/ai/migrations/.gitkeep | 0 .../plugins/ai/migrations/mysql/001_init.sql | 47 + .../ai/migrations/postgresql/001_init.sql | 79 + ruoyi-fastapi-backend/plugins/ai/plugin.yaml | 104 + .../plugins/ai/seeds/.gitkeep | 0 .../ai/seeds/mysql/ai_provider_type.sql | 121 + .../ai/seeds/postgresql/ai_provider_type.sql | 121 + .../ai}/service/ai_chat_service.py | 119 +- .../ai}/service/ai_model_service.py | 4 +- .../{ => plugins/ai}/utils/ai_util.py | 48 +- .../plugins/core/__init__.py | 0 .../plugins/core/capability.py | 148 + .../plugins/core/discovery/__init__.py | 3 + .../plugins/core/discovery/registry.py | 178 ++ .../plugins/core/discovery/scanner.py | 265 ++ .../plugins/core/environment.py | 205 ++ .../plugins/core/lifecycle/__init__.py | 3 + .../plugins/core/lifecycle/jobs.py | 295 ++ .../plugins/core/lifecycle/migration.py | 601 +++++ .../plugins/core/lifecycle/precheck.py | 192 ++ .../plugins/core/lifecycle/purge.py | 259 ++ .../plugins/core/lifecycle/script.py | 211 ++ .../plugins/core/lifecycle/seed.py | 165 ++ .../plugins/core/management/dao/dao.py | 946 +++++++ .../core/management/entity/do/models.py | 159 ++ .../core/management/entity/vo/schemas.py | 319 +++ .../plugins/core/management/service/config.py | 254 ++ .../core/management/service/gateway.py | 631 +++++ .../plugins/core/management/service/logs.py | 171 ++ .../plugins/core/management/service/menus.py | 371 +++ .../core/management/service/service.py | 1106 ++++++++ .../management/service/startup_gateway.py | 196 ++ .../plugins/core/manifest/__init__.py | 5 + .../plugins/core/manifest/menu_key.py | 38 + .../plugins/core/manifest/menu_tree.py | 101 + .../plugins/core/manifest/schema.py | 1159 ++++++++ .../plugins/core/runtime/__init__.py | 15 + .../plugins/core/runtime/application.py | 484 ++++ .../plugins/core/runtime/bootstrap.py | 126 + .../plugins/core/runtime/callable.py | 146 + .../plugins/core/runtime/entities.py | 130 + .../plugins/core/runtime/health.py | 241 ++ .../plugins/core/runtime/hooks.py | 174 ++ .../plugins/core/runtime/result.py | 38 + .../plugins/core/runtime/route_guard.py | 82 + .../plugins/core/runtime/service/__init__.py | 5 + .../plugins/core/runtime/service/audit.py | 70 + .../plugins/core/runtime/service/batch.py | 398 +++ .../plugins/core/runtime/service/config.py | 138 + .../plugins/core/runtime/service/context.py | 386 +++ .../core/runtime/service/dependencies.py | 376 +++ .../runtime/service/dependency_container.py | 55 + .../plugins/core/runtime/service/facade.py | 830 ++++++ .../plugins/core/runtime/service/gateway.py | 1249 +++++++++ .../runtime/service/lifecycle/__init__.py | 11 + .../core/runtime/service/lifecycle/common.py | 122 + .../core/runtime/service/lifecycle/enable.py | 648 +++++ .../core/runtime/service/lifecycle/install.py | 558 ++++ .../runtime/service/lifecycle/operations.py | 83 + .../core/runtime/service/lifecycle/purge.py | 433 +++ .../core/runtime/service/lifecycle/runner.py | 90 + .../core/runtime/service/lifecycle/upgrade.py | 597 +++++ .../core/runtime/service/lifecycle_lock.py | 267 ++ .../plugins/core/runtime/service/migration.py | 148 + .../core/runtime/service/migration_store.py | 322 +++ .../plugins/core/runtime/service/precheck.py | 156 ++ .../plugins/core/runtime/service/query.py | 425 +++ .../plugins/core/runtime/service/responses.py | 152 ++ .../plugins/core/runtime/service/tools.py | 55 + .../plugins/core/runtime/startup.py | 1147 ++++++++ .../core/runtime/startup_coordination.py | 62 + .../plugins/core/runtime/startup_gateway.py | 306 +++ .../plugins/core/runtime/support/__init__.py | 177 ++ .../core/runtime/support/batch_report.py | 383 +++ .../core/runtime/support/npm_package.py | 67 + .../core/runtime/support/payload/__init__.py | 106 + .../core/runtime/support/payload/audit.py | 132 + .../core/runtime/support/payload/base.py | 23 + .../core/runtime/support/payload/catalog.py | 423 +++ .../core/runtime/support/payload/common.py | 60 + .../core/runtime/support/payload/config.py | 424 +++ .../runtime/support/payload/dependencies.py | 221 ++ .../runtime/support/payload/documentation.py | 272 ++ .../core/runtime/support/payload/enable.py | 295 ++ .../core/runtime/support/payload/lifecycle.py | 403 +++ .../core/runtime/support/payload/plan.py | 646 +++++ .../core/runtime/support/payload/purge.py | 109 + .../core/runtime/support/payload/runtime.py | 559 ++++ .../runtime/support/payload/validation.py | 373 +++ .../plugins/core/runtime/support/precheck.py | 224 ++ ruoyi-fastapi-backend/plugins/core/state.py | 211 ++ ruoyi-fastapi-backend/plugins/core/types.py | 55 + ruoyi-fastapi-backend/plugins/core/utils.py | 27 + .../plugins/core/validation/__init__.py | 3 + .../plugins/core/validation/dependencies.py | 645 +++++ .../core/validation/dependency_policy.py | 1336 +++++++++ .../plugins/core/validation/manifest.py | 715 +++++ .../plugins/core/validation/menus.py | 227 ++ .../plugins/core/validation/plugin_deps.py | 790 ++++++ .../core/validation/python_requirements.py | 108 + .../plugins/core/validation/result.py | 45 + .../plugins/core/validation/structure.py | 685 +++++ .../plugins/core/validation/versioning.py | 293 ++ ruoyi-fastapi-backend/pyproject.toml | 3 + ruoyi-fastapi-backend/requirements-pg.txt | 13 +- ruoyi-fastapi-backend/requirements.txt | 13 +- ruoyi-fastapi-backend/server.py | 197 +- .../sql/ruoyi-fastapi-pg.sql | 356 ++- ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql | 267 +- ruoyi-fastapi-backend/tests/__init__.py | 0 .../tests/cli/root/test_contract_app_ops.py | 248 ++ .../tests/cli/root/test_guards.py | 13 + .../root/test_plugin_command_controller.py | 626 +++++ .../cli/root/test_plugin_file_adapter.py | 52 + .../tests/cli/root/test_plugin_lazy_import.py | 142 + .../tests/cli/root/test_plugin_payload.py | 209 ++ .../tests/cli/runtime/plugin/__init__.py | 0 .../tests/cli/runtime/plugin/conftest.py | 108 + .../test_runtime_dependency_allowlist.py | 73 + .../plugin/test_runtime_dependency_lock.py | 176 ++ .../runtime/plugin/test_runtime_gateway.py | 106 + .../runtime/plugin/test_runtime_scaffold.py | 376 +++ .../runtime/plugin/test_runtime_testing.py | 230 ++ .../tests/cli/runtime/test_app_runtime.py | 10 + .../tests/common/test_job_service.py | 35 + .../tests/common/test_router_register.py | 209 ++ .../config/test_redis_startup_logging.py | 41 + .../tests/config/test_scheduler_job_args.py | 34 + .../tests/config/test_scheduler_job_sync.py | 103 + .../config/test_scheduler_leader_lease.py | 328 +++ ruoyi-fastapi-backend/tests/conftest.py | 0 .../service}/test_common_file_security.py | 4 - .../service}/test_file_access_log.py | 4 - .../service}/test_file_acl.py | 4 - ...test_file_lifecycle_retention_execution.py | 4 - .../service}/test_file_management.py | 4 - .../service}/test_file_reconcile.py | 3 - .../service}/test_file_reference.py | 4 - .../service}/test_file_retention_policy.py | 4 - .../controller/test_plugin_controller.py | 602 +++++ .../service/test_plugin_service.py | 94 + .../plugins/core/discovery/test_registry.py | 155 ++ .../plugins/core/discovery/test_scanner.py | 766 ++++++ .../plugins/core/lifecycle/test_migration.py | 453 ++++ .../plugins/core/lifecycle/test_script.py | 103 + .../tests/plugins/core/lifecycle/test_seed.py | 128 + .../core/management/test_config_manager.py | 46 + .../core/management/test_menu_installer.py | 351 +++ .../core/management/test_plugin_state.py | 2378 +++++++++++++++++ .../tests/plugins/core/runtime/conftest.py | 13 + .../plugins/core/runtime/fakes/__init__.py | 36 + .../plugins/core/runtime/fakes/environment.py | 48 + .../plugins/core/runtime/fakes/factory.py | 99 + .../plugins/core/runtime/fakes/gateway.py | 381 +++ .../plugins/core/runtime/fakes/management.py | 382 +++ .../plugins/core/runtime/fakes/session.py | 63 + .../service/test_batch_dependencies.py | 708 +++++ .../core/runtime/service/test_catalog.py | 593 ++++ .../core/runtime/service/test_check_docs.py | 376 +++ .../runtime/service/test_facade_routing.py | 188 ++ .../runtime/service/test_gateway_ports.py | 135 + .../service/test_install_config_health.py | 1176 ++++++++ .../runtime/service/test_lifecycle_lock.py | 144 + .../runtime/service/test_lifecycle_runner.py | 70 + .../runtime/service/test_structure_checks.py | 204 ++ .../service/test_upgrade_enable_purge.py | 1627 +++++++++++ .../support/test_dependency_payloads.py | 121 + .../core/runtime/support/test_runtime.py | 77 + .../plugins/core/runtime/test_application.py | 426 +++ .../plugins/core/runtime/test_entities.py | 58 + .../tests/plugins/core/runtime/test_health.py | 133 + .../tests/plugins/core/runtime/test_hooks.py | 175 ++ .../core/runtime/test_plugin_bootstrap.py | 167 ++ .../tests/plugins/core/runtime/test_result.py | 19 + .../plugins/core/runtime/test_route_guard.py | 40 + .../plugins/core/runtime/test_startup.py | 1300 +++++++++ .../tests/plugins/core/test_capability.py | 52 + .../tests/plugins/core/test_state.py | 91 + .../core/validation/test_dependencies.py | 349 +++ .../core/validation/test_dependency_policy.py | 699 +++++ .../plugins/core/validation/test_manifest.py | 1011 +++++++ .../plugins/core/validation/test_menus.py | 128 + .../core/validation/test_plugin_deps.py | 251 ++ .../plugins/core/validation/test_structure.py | 570 ++++ .../core/validation/test_versioning.py | 38 + .../plugins/sample_plugins/test_ai_plugin.py | 281 ++ .../test_migrate_legacy_files.py | 4 - .../tests/server/test_plugin_runtime.py | 311 +++ .../tests/sql/test_plugin_schema.py | 119 + .../utils/test_application_leader_lease.py | 158 ++ .../{ => utils}/test_log_sanitize_util.py | 4 - .../tests/utils/test_startup_log_filter.py | 84 + ruoyi-fastapi-backend/utils/log_util.py | 10 +- ruoyi-fastapi-backend/utils/server_util.py | 125 +- ruoyi-fastapi-frontend/package.json | 1 + ruoyi-fastapi-frontend/plugins/ai/README.md | 81 + .../{src/api/ai => plugins/ai/api}/chat.js | 0 .../{src/api/ai => plugins/ai/api}/model.js | 0 .../ai/views}/chat/components/AiMessage.vue | 0 .../ai => plugins/ai/views}/chat/index.vue | 4 +- .../ai => plugins/ai/views}/model/index.vue | 2 +- .../src/api/system/plugin.js | 207 ++ .../src/store/modules/permission.js | 31 +- .../src/utils/pluginPlanFormatter.js | 190 ++ .../src/utils/pluginViewResolver.js | 26 + ruoyi-fastapi-frontend/src/utils/ruoyi.js | 8 +- .../plugin/components/PluginConfigDialog.vue | 366 +++ .../components/PluginDependencyDialog.vue | 342 +++ .../plugin/components/PluginDetailDialog.vue | 396 +++ .../components/PluginDiagnosticDialog.vue | 185 ++ .../plugin/components/PluginPlanDialog.vue | 246 ++ .../src/views/system/plugin/index.vue | 1798 +++++++++++++ .../tests/plugins/pluginViewResolver.test.js | 14 + .../tests/plugins/run-plugin-tests.js | 53 + 268 files changed, 64161 insertions(+), 539 deletions(-) create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/__init__.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/command.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/commands/__init__.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/commands/configuration.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/commands/dependency.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/commands/discovery.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/commands/lifecycle.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/controller.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/exporter.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/options.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/payload.py create mode 100644 ruoyi-fastapi-backend/cli/groups/plugin/presenter.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/__init__.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/gateway.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/__init__.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_vue2.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/naming.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/options.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/service.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/support.py create mode 100644 ruoyi-fastapi-backend/docs/plugin_development.md create mode 100644 ruoyi-fastapi-backend/docs/plugin_migration_failure_runbook.md create mode 100644 ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py create mode 100644 ruoyi-fastapi-backend/module_plugin/service/plugin_service.py create mode 100644 ruoyi-fastapi-backend/plugins/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/ai/README.md rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/controller/ai_chat_controller.py (83%) rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/controller/ai_model_controller.py (95%) rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/dao/ai_chat_dao.py (92%) rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/dao/ai_model_dao.py (95%) rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/entity/do/ai_chat_do.py (100%) rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/entity/do/ai_model_do.py (100%) rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/entity/vo/ai_chat_vo.py (100%) rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/entity/vo/ai_model_vo.py (100%) create mode 100644 ruoyi-fastapi-backend/plugins/ai/migrations/.gitkeep create mode 100644 ruoyi-fastapi-backend/plugins/ai/migrations/mysql/001_init.sql create mode 100644 ruoyi-fastapi-backend/plugins/ai/migrations/postgresql/001_init.sql create mode 100644 ruoyi-fastapi-backend/plugins/ai/plugin.yaml create mode 100644 ruoyi-fastapi-backend/plugins/ai/seeds/.gitkeep create mode 100644 ruoyi-fastapi-backend/plugins/ai/seeds/mysql/ai_provider_type.sql create mode 100644 ruoyi-fastapi-backend/plugins/ai/seeds/postgresql/ai_provider_type.sql rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/service/ai_chat_service.py (82%) rename ruoyi-fastapi-backend/{module_ai => plugins/ai}/service/ai_model_service.py (97%) rename ruoyi-fastapi-backend/{ => plugins/ai}/utils/ai_util.py (80%) create mode 100644 ruoyi-fastapi-backend/plugins/core/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/capability.py create mode 100644 ruoyi-fastapi-backend/plugins/core/discovery/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/discovery/registry.py create mode 100644 ruoyi-fastapi-backend/plugins/core/discovery/scanner.py create mode 100644 ruoyi-fastapi-backend/plugins/core/environment.py create mode 100644 ruoyi-fastapi-backend/plugins/core/lifecycle/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/lifecycle/jobs.py create mode 100644 ruoyi-fastapi-backend/plugins/core/lifecycle/migration.py create mode 100644 ruoyi-fastapi-backend/plugins/core/lifecycle/precheck.py create mode 100644 ruoyi-fastapi-backend/plugins/core/lifecycle/purge.py create mode 100644 ruoyi-fastapi-backend/plugins/core/lifecycle/script.py create mode 100644 ruoyi-fastapi-backend/plugins/core/lifecycle/seed.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/dao/dao.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/entity/do/models.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/entity/vo/schemas.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/service/config.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/service/gateway.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/service/logs.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/service/menus.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/service/service.py create mode 100644 ruoyi-fastapi-backend/plugins/core/management/service/startup_gateway.py create mode 100644 ruoyi-fastapi-backend/plugins/core/manifest/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/manifest/menu_key.py create mode 100644 ruoyi-fastapi-backend/plugins/core/manifest/menu_tree.py create mode 100644 ruoyi-fastapi-backend/plugins/core/manifest/schema.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/application.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/callable.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/entities.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/health.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/hooks.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/result.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/route_guard.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/audit.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/batch.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/config.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/context.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/dependency_container.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/gateway.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/common.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/enable.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/install.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/operations.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/purge.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/runner.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/upgrade.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle_lock.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/migration.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/migration_store.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/precheck.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/query.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/responses.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/service/tools.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/startup.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/startup_coordination.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/startup_gateway.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/batch_report.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/npm_package.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/audit.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/base.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/catalog.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/common.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/config.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/dependencies.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/documentation.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/enable.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/lifecycle.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/plan.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/purge.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/runtime.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/payload/validation.py create mode 100644 ruoyi-fastapi-backend/plugins/core/runtime/support/precheck.py create mode 100644 ruoyi-fastapi-backend/plugins/core/state.py create mode 100644 ruoyi-fastapi-backend/plugins/core/types.py create mode 100644 ruoyi-fastapi-backend/plugins/core/utils.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/__init__.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/dependencies.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/manifest.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/menus.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/plugin_deps.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/python_requirements.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/result.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/structure.py create mode 100644 ruoyi-fastapi-backend/plugins/core/validation/versioning.py create mode 100644 ruoyi-fastapi-backend/tests/__init__.py create mode 100644 ruoyi-fastapi-backend/tests/cli/root/test_plugin_command_controller.py create mode 100644 ruoyi-fastapi-backend/tests/cli/root/test_plugin_file_adapter.py create mode 100644 ruoyi-fastapi-backend/tests/cli/root/test_plugin_lazy_import.py create mode 100644 ruoyi-fastapi-backend/tests/cli/root/test_plugin_payload.py create mode 100644 ruoyi-fastapi-backend/tests/cli/runtime/plugin/__init__.py create mode 100644 ruoyi-fastapi-backend/tests/cli/runtime/plugin/conftest.py create mode 100644 ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_allowlist.py create mode 100644 ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_lock.py create mode 100644 ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_gateway.py create mode 100644 ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold.py create mode 100644 ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_testing.py create mode 100644 ruoyi-fastapi-backend/tests/common/test_job_service.py create mode 100644 ruoyi-fastapi-backend/tests/common/test_router_register.py create mode 100644 ruoyi-fastapi-backend/tests/config/test_redis_startup_logging.py create mode 100644 ruoyi-fastapi-backend/tests/config/test_scheduler_job_args.py create mode 100644 ruoyi-fastapi-backend/tests/config/test_scheduler_job_sync.py create mode 100644 ruoyi-fastapi-backend/tests/config/test_scheduler_leader_lease.py create mode 100644 ruoyi-fastapi-backend/tests/conftest.py rename ruoyi-fastapi-backend/tests/{ => module_admin/service}/test_common_file_security.py (99%) rename ruoyi-fastapi-backend/tests/{ => module_admin/service}/test_file_access_log.py (98%) rename ruoyi-fastapi-backend/tests/{ => module_admin/service}/test_file_acl.py (99%) rename ruoyi-fastapi-backend/tests/{ => module_admin/service}/test_file_lifecycle_retention_execution.py (99%) rename ruoyi-fastapi-backend/tests/{ => module_admin/service}/test_file_management.py (99%) rename ruoyi-fastapi-backend/tests/{ => module_admin/service}/test_file_reconcile.py (99%) rename ruoyi-fastapi-backend/tests/{ => module_admin/service}/test_file_reference.py (99%) rename ruoyi-fastapi-backend/tests/{ => module_admin/service}/test_file_retention_policy.py (97%) create mode 100644 ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_controller.py create mode 100644 ruoyi-fastapi-backend/tests/module_plugin/service/test_plugin_service.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/discovery/test_registry.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/discovery/test_scanner.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_migration.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_script.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/lifecycle/test_seed.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/management/test_config_manager.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/management/test_menu_installer.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/management/test_plugin_state.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/conftest.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/__init__.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/environment.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/factory.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/gateway.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/management.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/fakes/session.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_batch_dependencies.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_catalog.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_check_docs.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_facade_routing.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_gateway_ports.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_install_config_health.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_lifecycle_lock.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_lifecycle_runner.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_structure_checks.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/service/test_upgrade_enable_purge.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/support/test_dependency_payloads.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/support/test_runtime.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_application.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_entities.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_health.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_hooks.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_plugin_bootstrap.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_result.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_route_guard.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/runtime/test_startup.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/test_capability.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/test_state.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/validation/test_dependencies.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/validation/test_dependency_policy.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/validation/test_manifest.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/validation/test_menus.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/validation/test_plugin_deps.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/validation/test_structure.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/validation/test_versioning.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/sample_plugins/test_ai_plugin.py rename ruoyi-fastapi-backend/tests/{ => scripts}/test_migrate_legacy_files.py (98%) create mode 100644 ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py create mode 100644 ruoyi-fastapi-backend/tests/sql/test_plugin_schema.py create mode 100644 ruoyi-fastapi-backend/tests/utils/test_application_leader_lease.py rename ruoyi-fastapi-backend/tests/{ => utils}/test_log_sanitize_util.py (99%) create mode 100644 ruoyi-fastapi-backend/tests/utils/test_startup_log_filter.py create mode 100644 ruoyi-fastapi-frontend/plugins/ai/README.md rename ruoyi-fastapi-frontend/{src/api/ai => plugins/ai/api}/chat.js (100%) rename ruoyi-fastapi-frontend/{src/api/ai => plugins/ai/api}/model.js (100%) rename ruoyi-fastapi-frontend/{src/views/ai => plugins/ai/views}/chat/components/AiMessage.vue (100%) rename ruoyi-fastapi-frontend/{src/views/ai => plugins/ai/views}/chat/index.vue (99%) rename ruoyi-fastapi-frontend/{src/views/ai => plugins/ai/views}/model/index.vue (99%) create mode 100644 ruoyi-fastapi-frontend/src/api/system/plugin.js create mode 100644 ruoyi-fastapi-frontend/src/utils/pluginPlanFormatter.js create mode 100644 ruoyi-fastapi-frontend/src/utils/pluginViewResolver.js create mode 100644 ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginConfigDialog.vue create mode 100644 ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDependencyDialog.vue create mode 100644 ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDetailDialog.vue create mode 100644 ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDiagnosticDialog.vue create mode 100644 ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginPlanDialog.vue create mode 100644 ruoyi-fastapi-frontend/src/views/system/plugin/index.vue create mode 100644 ruoyi-fastapi-frontend/tests/plugins/pluginViewResolver.test.js create mode 100644 ruoyi-fastapi-frontend/tests/plugins/run-plugin-tests.js diff --git a/.gitignore b/.gitignore index 220a696..2e5dfcc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,7 @@ share/python-wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST +/MANIFEST # PyInstaller # Usually these files are written by a python script from a template diff --git a/README.md b/README.md index 9c881cd..912efa8 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ RuoYi-Vue3-FastAPI是一套全部开源的快速开发平台,毫无保留给 18. 代码生成:配置数据库表信息一键生成前后端代码(python、sql、vue、js),支持下载。 19. AI管理:提供AI模型管理和AI对话功能。 20. 文件管理:统一管理公开文件和受保护附件,支持访问控制、业务引用保护、操作审计、回收站、保留策略及存储对账。 +21. 插件系统:支持插件发现、安装、启停、升级、卸载与清理,提供依赖与配置管理、迁移与种子、定时任务、菜单权限、批量预演、健康诊断、操作审计及前后端插件脚手架。 ## 演示图 diff --git a/ruoyi-fastapi-backend/.env.dev b/ruoyi-fastapi-backend/.env.dev index 4c56f23..4d45df9 100644 --- a/ruoyi-fastapi-backend/.env.dev +++ b/ruoyi-fastapi-backend/.env.dev @@ -11,6 +11,8 @@ APP_HOST = '0.0.0.0' APP_PORT = 9099 # 应用版本 APP_VERSION= '1.9.0' +# 发布代际标识(生产滚动发布建议注入镜像 digest 或 commit SHA;留空时使用源码指纹) +APP_RELEASE_ID = '' # 应用是否开启热重载 APP_RELOAD = true # 应用工作进程数 @@ -29,6 +31,8 @@ APP_DISABLE_REDOC = false APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' # 可信代理跳数,单层Nginx代理通常为1 APP_TRUSTED_PROXY_HOPS = 1 +# 首次启动默认安装并启用的内置插件,多个值使用逗号分隔,留空表示不自动启用 +APP_DEFAULT_ENABLED_PLUGINS = 'ai' # -------- Jwt配置 -------- # Jwt秘钥 @@ -171,3 +175,27 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = '' TRANSPORT_CRYPTO_REQUIRED_PATHS = '' # 排除传输层加密的路径列表,多个值使用逗号分隔 TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource,/common/files,/system/file/download' + +# -------- 插件依赖安装策略配置 -------- +# 策略模式:可填单一模式,或按环境映射。可选 disabled、plan_only、explicit、locked、offline +PLUGIN_DEPENDENCY_POLICY_MODE = 'dev=explicit,test=plan_only,stage=locked,prod=plan_only' +# 生产环境是否允许真实安装插件声明的 Python/npm 依赖 +PLUGIN_DEPENDENCY_ALLOW_PROD_INSTALL = false +# 执行真实安装时是否要求显式确认 +PLUGIN_DEPENDENCY_REQUIRE_YES = true +# 是否强制依赖命中允许列表;开发环境默认关闭 +PLUGIN_DEPENDENCY_REQUIRE_ALLOWLIST = false +# 是否强制提供锁文件;开发环境默认关闭 +PLUGIN_DEPENDENCY_REQUIRE_LOCKFILE = false +# 默认锁文件路径,CLI --lockfile 会覆盖该值 +PLUGIN_DEPENDENCY_LOCKFILE = '' +# 默认允许列表路径 +PLUGIN_DEPENDENCY_ALLOWLIST = '' +# 离线制品根目录 +PLUGIN_DEPENDENCY_OFFLINE_DIR = '' +# 受控 PyPI 镜像地址,留空表示不改写 pip 安装参数 +PLUGIN_DEPENDENCY_PIP_INDEX_URL = '' +# 受控 npm registry 地址,留空表示不改写 npm 安装参数 +PLUGIN_DEPENDENCY_NPM_REGISTRY = '' +# 单条依赖安装命令超时时间(秒) +PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 600 diff --git a/ruoyi-fastapi-backend/.env.dockermy b/ruoyi-fastapi-backend/.env.dockermy index 78e24a1..a832c7c 100644 --- a/ruoyi-fastapi-backend/.env.dockermy +++ b/ruoyi-fastapi-backend/.env.dockermy @@ -11,6 +11,8 @@ APP_HOST = '0.0.0.0' APP_PORT = 9099 # 应用版本 APP_VERSION= '1.9.0' +# 发布代际标识(生产滚动发布建议注入镜像 digest 或 commit SHA;留空时使用源码指纹) +APP_RELEASE_ID = '' # 应用是否开启热重载 APP_RELOAD = false # 应用工作进程数 @@ -29,6 +31,8 @@ APP_DISABLE_REDOC = true APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' # 可信代理跳数,单层Nginx代理通常为1 APP_TRUSTED_PROXY_HOPS = 1 +# 首次启动默认安装并启用的内置插件,多个值使用逗号分隔,留空表示不自动启用 +APP_DEFAULT_ENABLED_PLUGINS = 'ai' # -------- Jwt配置 -------- # Jwt秘钥 @@ -171,3 +175,27 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = '' TRANSPORT_CRYPTO_REQUIRED_PATHS = '' # 排除传输层加密的路径列表,多个值使用逗号分隔 TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource,/common/files,/system/file/download' + +# -------- 插件依赖安装策略配置 -------- +# 策略模式:可填单一模式,或按环境映射。可选 disabled、plan_only、explicit、locked、offline +PLUGIN_DEPENDENCY_POLICY_MODE = 'dev=explicit,test=plan_only,stage=locked,prod=plan_only' +# 生产环境是否允许真实安装插件声明的 Python/npm 依赖 +PLUGIN_DEPENDENCY_ALLOW_PROD_INSTALL = false +# 执行真实安装时是否要求显式确认 +PLUGIN_DEPENDENCY_REQUIRE_YES = true +# Docker 生产环境默认要求依赖命中允许列表 +PLUGIN_DEPENDENCY_REQUIRE_ALLOWLIST = true +# Docker 生产环境默认要求锁文件 +PLUGIN_DEPENDENCY_REQUIRE_LOCKFILE = true +# 默认锁文件路径,CLI --lockfile 会覆盖该值 +PLUGIN_DEPENDENCY_LOCKFILE = '' +# 默认允许列表路径 +PLUGIN_DEPENDENCY_ALLOWLIST = '' +# 离线制品根目录 +PLUGIN_DEPENDENCY_OFFLINE_DIR = '' +# 受控 PyPI 镜像地址,留空表示不改写 pip 安装参数 +PLUGIN_DEPENDENCY_PIP_INDEX_URL = '' +# 受控 npm registry 地址,留空表示不改写 npm 安装参数 +PLUGIN_DEPENDENCY_NPM_REGISTRY = '' +# 单条依赖安装命令超时时间(秒) +PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 600 diff --git a/ruoyi-fastapi-backend/.env.dockerpg b/ruoyi-fastapi-backend/.env.dockerpg index 2f501e6..cffa8d9 100644 --- a/ruoyi-fastapi-backend/.env.dockerpg +++ b/ruoyi-fastapi-backend/.env.dockerpg @@ -11,6 +11,8 @@ APP_HOST = '0.0.0.0' APP_PORT = 9099 # 应用版本 APP_VERSION= '1.9.0' +# 发布代际标识(生产滚动发布建议注入镜像 digest 或 commit SHA;留空时使用源码指纹) +APP_RELEASE_ID = '' # 应用是否开启热重载 APP_RELOAD = false # 应用工作进程数 @@ -29,6 +31,8 @@ APP_DISABLE_REDOC = true APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' # 可信代理跳数,单层Nginx代理通常为1 APP_TRUSTED_PROXY_HOPS = 1 +# 首次启动默认安装并启用的内置插件,多个值使用逗号分隔,留空表示不自动启用 +APP_DEFAULT_ENABLED_PLUGINS = 'ai' # -------- Jwt配置 -------- # Jwt秘钥 @@ -171,3 +175,27 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = '' TRANSPORT_CRYPTO_REQUIRED_PATHS = '' # 排除传输层加密的路径列表,多个值使用逗号分隔 TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource,/common/files,/system/file/download' + +# -------- 插件依赖安装策略配置 -------- +# 策略模式:可填单一模式,或按环境映射。可选 disabled、plan_only、explicit、locked、offline +PLUGIN_DEPENDENCY_POLICY_MODE = 'dev=explicit,test=plan_only,stage=locked,prod=plan_only' +# 生产环境是否允许真实安装插件声明的 Python/npm 依赖 +PLUGIN_DEPENDENCY_ALLOW_PROD_INSTALL = false +# 执行真实安装时是否要求显式确认 +PLUGIN_DEPENDENCY_REQUIRE_YES = true +# Docker 生产环境默认要求依赖命中允许列表 +PLUGIN_DEPENDENCY_REQUIRE_ALLOWLIST = true +# Docker 生产环境默认要求锁文件 +PLUGIN_DEPENDENCY_REQUIRE_LOCKFILE = true +# 默认锁文件路径,CLI --lockfile 会覆盖该值 +PLUGIN_DEPENDENCY_LOCKFILE = '' +# 默认允许列表路径 +PLUGIN_DEPENDENCY_ALLOWLIST = '' +# 离线制品根目录 +PLUGIN_DEPENDENCY_OFFLINE_DIR = '' +# 受控 PyPI 镜像地址,留空表示不改写 pip 安装参数 +PLUGIN_DEPENDENCY_PIP_INDEX_URL = '' +# 受控 npm registry 地址,留空表示不改写 npm 安装参数 +PLUGIN_DEPENDENCY_NPM_REGISTRY = '' +# 单条依赖安装命令超时时间(秒) +PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 600 diff --git a/ruoyi-fastapi-backend/.env.prod b/ruoyi-fastapi-backend/.env.prod index 241f32c..b7c3ea9 100644 --- a/ruoyi-fastapi-backend/.env.prod +++ b/ruoyi-fastapi-backend/.env.prod @@ -11,6 +11,8 @@ APP_HOST = '0.0.0.0' APP_PORT = 9099 # 应用版本 APP_VERSION= '1.9.0' +# 发布代际标识(生产滚动发布建议注入镜像 digest 或 commit SHA;留空时使用源码指纹) +APP_RELEASE_ID = '' # 应用是否开启热重载 APP_RELOAD = false # 应用工作进程数 @@ -29,6 +31,8 @@ APP_DISABLE_REDOC = true APP_TRUSTED_PROXY_IPS = '127.0.0.1,::1' # 可信代理跳数,单层Nginx代理通常为1 APP_TRUSTED_PROXY_HOPS = 1 +# 首次启动默认安装并启用的内置插件,多个值使用逗号分隔,留空表示不自动启用 +APP_DEFAULT_ENABLED_PLUGINS = 'ai' # -------- Jwt配置 -------- # Jwt秘钥 @@ -171,3 +175,27 @@ TRANSPORT_CRYPTO_ENABLED_PATHS = '' TRANSPORT_CRYPTO_REQUIRED_PATHS = '' # 排除传输层加密的路径列表,多个值使用逗号分隔 TRANSPORT_CRYPTO_EXCLUDE_PATHS = '/openapi.json,/docs,/docs/oauth2-redirect,/redoc,/transport/crypto/frontend-config,/transport/crypto/public-key,/common/download,/common/download/resource,/common/files,/system/file/download' + +# -------- 插件依赖安装策略配置 -------- +# 策略模式:可填单一模式,或按环境映射。可选 disabled、plan_only、explicit、locked、offline +PLUGIN_DEPENDENCY_POLICY_MODE = 'dev=explicit,test=plan_only,stage=locked,prod=plan_only' +# 生产环境是否允许真实安装插件声明的 Python/npm 依赖 +PLUGIN_DEPENDENCY_ALLOW_PROD_INSTALL = false +# 执行真实安装时是否要求显式确认 +PLUGIN_DEPENDENCY_REQUIRE_YES = true +# 生产环境默认要求依赖命中允许列表 +PLUGIN_DEPENDENCY_REQUIRE_ALLOWLIST = true +# 生产环境默认要求锁文件 +PLUGIN_DEPENDENCY_REQUIRE_LOCKFILE = true +# 默认锁文件路径,CLI --lockfile 会覆盖该值 +PLUGIN_DEPENDENCY_LOCKFILE = '' +# 默认允许列表路径 +PLUGIN_DEPENDENCY_ALLOWLIST = '' +# 离线制品根目录 +PLUGIN_DEPENDENCY_OFFLINE_DIR = '' +# 受控 PyPI 镜像地址,留空表示不改写 pip 安装参数 +PLUGIN_DEPENDENCY_PIP_INDEX_URL = '' +# 受控 npm registry 地址,留空表示不改写 npm 安装参数 +PLUGIN_DEPENDENCY_NPM_REGISTRY = '' +# 单条依赖安装命令超时时间(秒) +PLUGIN_DEPENDENCY_INSTALL_TIMEOUT = 600 diff --git a/ruoyi-fastapi-backend/cli/core/app_builder.py b/ruoyi-fastapi-backend/cli/core/app_builder.py index 229ab60..a8d366d 100644 --- a/ruoyi-fastapi-backend/cli/core/app_builder.py +++ b/ruoyi-fastapi-backend/cli/core/app_builder.py @@ -351,6 +351,7 @@ DEFAULT_COMMAND_GROUP_REGISTRY = CliCommandGroupRegistry( 'crypto': 'cli.groups.crypto', 'gen': 'cli.groups.gen', 'dev': 'cli.groups.dev', + 'plugin': 'cli.groups.plugin', } ) DEFAULT_CLI_EXTENSION_REGISTRY = CliExtensionRegistry( diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/__init__.py b/ruoyi-fastapi-backend/cli/groups/plugin/__init__.py new file mode 100644 index 0000000..0a2282e --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/__init__.py @@ -0,0 +1,3 @@ +from .command import app + +__all__ = ['app'] diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/command.py b/ruoyi-fastapi-backend/cli/groups/plugin/command.py new file mode 100644 index 0000000..a535034 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/command.py @@ -0,0 +1,38 @@ +import importlib +from functools import lru_cache +from typing import TYPE_CHECKING + +import typer + +from .commands.configuration import register_configuration_commands +from .commands.dependency import register_dependency_commands +from .commands.developer import register_developer_commands +from .commands.discovery import register_discovery_commands +from .commands.lifecycle import register_lifecycle_commands + +if TYPE_CHECKING: + from .controller import PluginCommandController + +app = typer.Typer( + help='插件管理相关命令', + no_args_is_help=True, + context_settings={'help_option_names': ['-h', '--help']}, +) + + +@lru_cache(maxsize=1) +def _get_plugin_command_controller() -> 'PluginCommandController': + """ + 延迟获取插件命令控制器。 + + :return: 插件命令控制器 + """ + controller_class = importlib.import_module('cli.groups.plugin.controller').PluginCommandController + return controller_class() + + +register_discovery_commands(app, _get_plugin_command_controller) +register_configuration_commands(app, _get_plugin_command_controller) +register_dependency_commands(app, _get_plugin_command_controller) +register_lifecycle_commands(app, _get_plugin_command_controller) +register_developer_commands(app, _get_plugin_command_controller) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/__init__.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/configuration.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/configuration.py new file mode 100644 index 0000000..6280a06 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/configuration.py @@ -0,0 +1,59 @@ +from collections.abc import Callable +from typing import Annotated, Any, Literal + +import typer + +from cli.context import AllowProdOption, EnvOption, OutputOption, YesOption + +PluginConfigAction = Literal['get', 'set', 'export', 'import'] + + +def register_configuration_commands(app: typer.Typer, get_controller: Callable[[], Any]) -> None: + """ + 注册插件配置命令。 + + :param app: Typer 命令组 + :param get_controller: 插件命令控制器工厂 + :return: None + """ + + @app.command('config', help='查看或设置插件配置') + def config_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + action: Annotated[PluginConfigAction, typer.Argument(help='操作类型:get、set、export 或 import')], + pairs: Annotated[list[str] | None, typer.Argument(help='配置键值,例如 provider=openai')] = None, + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + reveal_secret: Annotated[bool, typer.Option('--reveal-secret', help='导出敏感配置明文')] = False, + output_file: Annotated[str, typer.Option('--output-file', help='配置导出 JSON 文件路径')] = '', + input_file: Annotated[str, typer.Option('--input-file', help='配置导入 JSON 文件路径')] = '', + ) -> None: + """ + 查看或设置插件配置。 + + :param plugin_id: 插件ID + :param action: 操作类型 + :param pairs: 配置键值列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param reveal_secret: 是否导出敏感配置明文 + :param output_file: 配置导出 JSON 文件路径 + :param input_file: 配置导入 JSON 文件路径 + :return: None + """ + get_controller().plugin_config( + plugin_id, + action, + pairs or [], + env, + output, + allow_prod=allow_prod, + yes=yes, + reveal_secret=reveal_secret, + output_file=output_file, + input_file=input_file, + ) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/dependency.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/dependency.py new file mode 100644 index 0000000..dae372e --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/dependency.py @@ -0,0 +1,205 @@ +from collections.abc import Callable +from typing import Annotated, Any, Literal + +import typer + +from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption +from cli.groups.plugin.options import ( + PluginDependencyAllowlistExampleCommandOptions, + PluginDependencyInstallCommandOptions, + PluginDependencyLockCommandOptions, +) + +PluginPrecheckOperation = Literal['install', 'enable', 'upgrade', 'uninstall', 'purge'] +PluginPlanOperation = Literal['install', 'enable', 'upgrade'] + + +def register_dependency_commands(app: typer.Typer, get_controller: Callable[[], Any]) -> None: + """ + 注册插件依赖、预检和计划命令。 + + :param app: Typer 命令组 + :param get_controller: 插件命令控制器工厂 + :return: None + """ + + @app.command('check-deps', help='检查插件依赖') + def check_deps_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + ) -> None: + """ + 检查插件依赖。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + get_controller().check_plugin_dependencies(plugin_id, env, output) + + @app.command('precheck', help='执行插件操作预检') + def precheck_command( + operation: Annotated[ + PluginPrecheckOperation, + typer.Argument(help='预检操作类型:install、enable、upgrade、uninstall 或 purge'), + ], + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + ) -> None: + """ + 执行插件操作预检。 + + :param operation: 预检操作类型 + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + get_controller().precheck_plugin(operation, plugin_id, env, output) + + @app.command('plan', help='生成插件批量操作拓扑计划') + def plan_command( + operation: Annotated[PluginPlanOperation, typer.Argument(help='计划操作类型:install、enable 或 upgrade')], + plugin_ids: Annotated[list[str] | None, typer.Argument(help='插件ID列表,不传则计划全部插件')] = None, + env: EnvOption = 'dev', + output: OutputOption = 'text', + ) -> None: + """ + 生成插件批量操作拓扑计划。 + + :param operation: 计划操作类型 + :param plugin_ids: 插件ID列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + get_controller().plan_plugins(operation, plugin_ids or [], env, output) + + @app.command('install-deps', help='安装插件依赖') + def install_deps_command( # noqa: PLR0913 + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + *, + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + policy_mode: Annotated[ + Literal['disabled', 'plan_only', 'explicit', 'locked', 'offline'] | None, + typer.Option('--policy-mode', help='临时覆盖插件依赖安装策略模式'), + ] = None, + allow_unlisted: Annotated[ + bool, + typer.Option('--allow-unlisted', help='dev 环境允许未命中 allowlist 的依赖仅告警'), + ] = False, + lockfile: Annotated[str, typer.Option('--lockfile', help='指定插件依赖锁文件路径')] = '', + offline_dir: Annotated[str, typer.Option('--offline-dir', help='指定插件离线依赖制品目录')] = '', + require_lockfile: Annotated[ + bool | None, + typer.Option('--require-lockfile/--no-require-lockfile', help='临时切换锁文件要求'), + ] = None, + ) -> None: + """ + 安装插件依赖。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :param policy_mode: 临时覆盖策略模式 + :param allow_unlisted: dev 环境是否允许未命中 allowlist 的依赖仅告警 + :param lockfile: 锁文件路径 + :param offline_dir: 离线制品目录 + :param require_lockfile: 是否要求锁文件 + :return: None + """ + get_controller().install_plugin_dependencies( + plugin_id, + env, + output, + options=PluginDependencyInstallCommandOptions( + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + policy_mode=policy_mode, + allow_unlisted=allow_unlisted, + lockfile=lockfile, + offline_dir=offline_dir, + require_lockfile=require_lockfile, + ), + ) + + @app.command('lock-deps', help='生成插件依赖锁文件模板') + def lock_deps_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + dry_run: DryRunOption = False, + output_path: Annotated[ + str, + typer.Option('--output-path', help='输出锁文件路径,默认写入插件目录 plugin.lock.yaml'), + ] = '', + offline_dir: Annotated[ + str, typer.Option('--offline-dir', help='从本地离线制品目录反填版本和 hash/integrity') + ] = '', + overwrite: Annotated[bool, typer.Option('--overwrite', help='覆盖已有锁文件')] = False, + ) -> None: + """ + 生成插件依赖锁文件模板。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param dry_run: 是否仅预演 + :param output_path: 输出锁文件路径 + :param offline_dir: 离线制品目录 + :param overwrite: 是否覆盖已有锁文件 + :return: None + """ + get_controller().lock_plugin_dependencies( + plugin_id, + env, + output, + options=PluginDependencyLockCommandOptions( + output_path=output_path, + offline_dir=offline_dir, + dry_run=dry_run, + overwrite=overwrite, + ), + ) + + @app.command('allowlist-example', help='生成插件依赖允许列表示例') + def allowlist_example_command( + env: EnvOption = 'dev', + output: OutputOption = 'text', + dry_run: DryRunOption = False, + output_path: Annotated[ + str, + typer.Option('--output-path', help='输出允许列表路径,默认写入 config/plugin_dependency_allowlist.yaml'), + ] = '', + overwrite: Annotated[bool, typer.Option('--overwrite', help='覆盖已有允许列表文件')] = False, + ) -> None: + """ + 生成插件依赖允许列表示例。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param dry_run: 是否仅预演 + :param output_path: 输出允许列表路径 + :param overwrite: 是否覆盖已有允许列表文件 + :return: None + """ + get_controller().generate_plugin_dependency_allowlist_example( + env, + output, + options=PluginDependencyAllowlistExampleCommandOptions( + output_path=output_path, + dry_run=dry_run, + overwrite=overwrite, + ), + ) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py new file mode 100644 index 0000000..8606c38 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py @@ -0,0 +1,109 @@ +from collections.abc import Callable +from typing import Annotated, Any, Literal + +import typer + +from cli.context import DryRunOption, EnvOption, OutputOption + +from ..options import PluginCreateCommandOptions + + +def register_developer_commands(app: typer.Typer, get_controller: Callable[[], Any]) -> None: + """ + 注册插件开发者命令。 + + :param app: Typer 命令组 + :param get_controller: 插件命令控制器工厂 + :return: None + """ + + @app.command('test', help='执行插件测试') + def test_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + keyword: Annotated[str, typer.Option('--keyword', '-k', help='pytest -k 过滤表达式')] = '', + maxfail: Annotated[int, typer.Option('--maxfail', min=0, help='最大失败数,0 表示不限制')] = 0, + quiet: Annotated[bool, typer.Option('--quiet', '-q', help='启用简洁输出')] = False, + frontend_build: Annotated[bool, typer.Option('--frontend-build', help='追加执行前端构建验收')] = False, + ) -> None: + """ + 执行插件测试。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param keyword: pytest 关键字过滤表达式 + :param maxfail: 最大失败数 + :param quiet: 是否启用简洁输出 + :param frontend_build: 是否追加执行前端构建验收 + :return: None + """ + get_controller().test_plugin( + plugin_id, + env, + output, + keyword=keyword, + maxfail=maxfail, + quiet=quiet, + frontend_build=frontend_build, + ) + + @app.command('create', help='创建插件开发模板') + def create_command( # noqa: PLR0913 + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + *, + env: EnvOption = 'dev', + output: OutputOption = 'text', + template: Annotated[ + str, + typer.Option('--template', help='插件模板:minimal、backend-only、full-stack、scheduled-job、crud-page'), + ] = 'full-stack', + frontend_version: Annotated[ + Literal['auto', 'vue2', 'vue3'], + typer.Option('--frontend-version', help='前端 Vue 版本:auto、vue2、vue3;auto 读取 package.json'), + ] = 'auto', + backend_only: Annotated[bool, typer.Option('--backend-only', help='只创建后端插件模板')] = False, + frontend_only: Annotated[bool, typer.Option('--frontend-only', help='只创建前端插件模板')] = False, + no_migration: Annotated[bool, typer.Option('--no-migration', help='不创建 migration 示例')] = False, + no_seed: Annotated[bool, typer.Option('--no-seed', help='不创建 seed 示例')] = False, + no_job: Annotated[bool, typer.Option('--no-job', help='不创建定时任务示例')] = False, + no_config: Annotated[bool, typer.Option('--no-config', help='不创建配置项示例')] = False, + no_test: Annotated[bool, typer.Option('--no-test', help='不创建测试样例')] = False, + dry_run: DryRunOption = False, + ) -> None: + """ + 创建插件开发模板。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param template: 插件模板名称 + :param frontend_version: 前端 Vue 版本 + :param backend_only: 是否只创建后端插件模板 + :param frontend_only: 是否只创建前端插件模板 + :param no_migration: 是否不创建 migration 示例 + :param no_seed: 是否不创建 seed 示例 + :param no_job: 是否不创建定时任务示例 + :param no_config: 是否不创建配置项示例 + :param no_test: 是否不创建测试样例 + :param dry_run: 是否仅预演 + :return: None + """ + get_controller().create_plugin( + plugin_id, + env, + output, + PluginCreateCommandOptions( + backend_only=backend_only, + frontend_only=frontend_only, + template=template, + frontend_version=frontend_version, + no_migration=no_migration, + no_seed=no_seed, + no_job=no_job, + no_config=no_config, + no_test=no_test, + dry_run=dry_run, + ), + ) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/discovery.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/discovery.py new file mode 100644 index 0000000..1e67e05 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/discovery.py @@ -0,0 +1,114 @@ +from collections.abc import Callable +from typing import Annotated, Any + +import typer + +from cli.context import EnvOption, OutputOption + + +def register_discovery_commands(app: typer.Typer, get_controller: Callable[[], Any]) -> None: + """ + 注册插件发现与诊断命令。 + + :param app: Typer 命令组 + :param get_controller: 插件命令控制器工厂 + :return: None + """ + + @app.command('list', help='查看本地插件列表') + def list_command( + env: EnvOption = 'dev', + output: OutputOption = 'text', + ) -> None: + """ + 查看本地插件列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + get_controller().list_plugins(env, output) + + @app.command('info', help='查看插件详情') + def info_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + ) -> None: + """ + 查看插件详情。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + get_controller().plugin_info(plugin_id, env, output) + + @app.command('check', help='检查插件依赖状态') + def check_command( + plugin_id: Annotated[str | None, typer.Argument(help='插件ID,不传则检查全部插件')] = None, + env: EnvOption = 'dev', + output: OutputOption = 'text', + ) -> None: + """ + 检查插件依赖状态。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + get_controller().check_plugin(plugin_id, env, output) + + @app.command('health', help='执行插件健康检查') + def health_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + ) -> None: + """ + 执行插件健康检查。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + get_controller().health_plugin(plugin_id, env, output) + + @app.command('diagnose', help='生成插件诊断包') + def diagnose_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + output_file: Annotated[str, typer.Option('--output-file', help='诊断包 JSON 导出文件路径')] = '', + ) -> None: + """ + 生成插件诊断包。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param output_file: 诊断包 JSON 导出文件路径 + :return: None + """ + get_controller().diagnose_plugin(plugin_id, env, output, output_file=output_file) + + @app.command('docs', help='生成插件 Markdown 文档片段') + def docs_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + output_file: Annotated[str, typer.Option('--output-file', help='Markdown 文档导出文件路径')] = '', + ) -> None: + """ + 生成插件 Markdown 文档片段。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param output_file: Markdown 文档导出文件路径 + :return: None + """ + get_controller().generate_plugin_docs(plugin_id, env, output, output_file=output_file) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/lifecycle.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/lifecycle.py new file mode 100644 index 0000000..176fd71 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/lifecycle.py @@ -0,0 +1,312 @@ +from collections.abc import Callable +from typing import Annotated, Any, Literal + +import typer + +from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption + +PluginBatchOperation = Literal['install', 'enable', 'upgrade'] +PluginMigrationStatus = Literal['running', 'success', 'failed', 'unknown'] + + +def register_lifecycle_commands(app: typer.Typer, get_controller: Callable[[], Any]) -> None: + """ + 注册插件生命周期命令。 + + :param app: Typer 命令组 + :param get_controller: 插件命令控制器工厂 + :return: None + """ + + @app.command('batch', help='按拓扑顺序批量执行插件操作') + def batch_command( + operation: Annotated[PluginBatchOperation, typer.Argument(help='批量操作类型:install、enable 或 upgrade')], + plugin_ids: Annotated[list[str] | None, typer.Argument(help='插件ID列表,不传则执行全部插件')] = None, + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + continue_on_error: Annotated[bool, typer.Option('--continue-on-error', help='失败后继续执行后续插件')] = False, + ) -> None: + """ + 按拓扑顺序批量执行插件操作。 + + :param operation: 批量操作类型 + :param plugin_ids: 插件ID列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: None + """ + get_controller().batch_plugins( + operation, + plugin_ids or [], + env, + output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + + @app.command('install', help='安装插件') + def install_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + ) -> None: + """ + 安装插件。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + get_controller().install_plugin( + plugin_id, + env, + output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ) + + @app.command('upgrade', help='升级插件') + def upgrade_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + ) -> None: + """ + 升级插件。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + get_controller().upgrade_plugin( + plugin_id, + env, + output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ) + + @app.command('enable', help='启用插件') + def enable_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + ) -> None: + """ + 启用插件。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + get_controller().set_plugin_enabled( + plugin_id, + env, + output, + enabled=True, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ) + + @app.command('disable', help='停用插件') + def disable_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + ) -> None: + """ + 停用插件。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + get_controller().set_plugin_enabled( + plugin_id, + env, + output, + enabled=False, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ) + + @app.command('uninstall', help='安全卸载插件(第一阶段等价于停用插件和菜单)') + def uninstall_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + ) -> None: + """ + 安全卸载插件。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + get_controller().uninstall_plugin( + plugin_id, + env, + output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ) + + @app.command('purge', help='物理清理插件平台元数据') + def purge_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + dry_run: DryRunOption = False, + ) -> None: + """ + 物理清理插件平台元数据。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + get_controller().purge_plugin( + plugin_id, + env, + output, + allow_prod=allow_prod, + yes=yes, + dry_run=dry_run, + ) + + @app.command('migration-list', help='查看插件 migration 历史') + def migration_list_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + status: Annotated[PluginMigrationStatus | None, typer.Option('--status', help='按状态过滤')] = None, + env: EnvOption = 'dev', + output: OutputOption = 'text', + ) -> None: + """ + 查看插件 migration 历史。 + + :param plugin_id: 插件ID + :param status: 执行状态 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + get_controller().list_plugin_migrations(plugin_id, status, env, output) + + @app.command('mark-success', help='人工标记插件 migration 为成功') + def mark_success_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + migration_path: Annotated[str, typer.Argument(help='migration 相对路径')], + note: Annotated[str, typer.Option('--note', help='人工恢复备注')] = '', + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + ) -> None: + """ + 人工标记插件 migration 为成功。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param note: 人工恢复备注 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + get_controller().mark_plugin_migration_success( + plugin_id, + migration_path, + env, + output, + note=note, + allow_prod=allow_prod, + yes=yes, + ) + + @app.command('mark-failed', help='人工标记插件 migration 为失败') + def mark_failed_command( + plugin_id: Annotated[str, typer.Argument(help='插件ID')], + migration_path: Annotated[str, typer.Argument(help='migration 相对路径')], + note: Annotated[str, typer.Option('--note', help='人工恢复备注')] = '', + env: EnvOption = 'dev', + output: OutputOption = 'text', + allow_prod: AllowProdOption = False, + yes: YesOption = False, + ) -> None: + """ + 人工标记插件 migration 为失败。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param note: 人工恢复备注 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + get_controller().mark_plugin_migration_failed( + plugin_id, + migration_path, + env, + output, + note=note, + allow_prod=allow_prod, + yes=yes, + ) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/controller.py b/ruoyi-fastapi-backend/cli/groups/plugin/controller.py new file mode 100644 index 0000000..e1e021b --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/controller.py @@ -0,0 +1,1222 @@ +import importlib +import json +import sys +from collections.abc import Callable +from typing import TYPE_CHECKING + +import click +import typer + +from cli.core import DEFAULT_CORE_SERVICES, CliContextFactory, CliExecutionService +from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, RUNTIME_ERROR, SUCCESS +from plugins.core.validation.dependency_policy import DependencyInstallPolicyConfig + +from .exporter import PluginCommandFileAdapter +from .options import ( + PluginCreateCommandOptions, + PluginDependencyAllowlistExampleCommandOptions, + PluginDependencyInstallCommandOptions, + PluginDependencyLockCommandOptions, +) +from .payload import PluginCommandPayloadAdapter +from .presenter import PluginCommandPresenter + +if TYPE_CHECKING: + from cli.runtime.plugin.service import CliPluginRuntimeService + from plugins.core.runtime.service import PluginRuntimeService + +PluginDependencyOutputCallback = Callable[[str, str], None] + + +class PluginCommandController: + """ + 插件命令控制器。 + """ + + def __init__( + self, + *, + context_factory: CliContextFactory | None = None, + execution_service: CliExecutionService | None = None, + presenter: PluginCommandPresenter | None = None, + plugin_runtime: 'CliPluginRuntimeService | None' = None, + ) -> None: + """ + 初始化插件命令控制器。 + + :param context_factory: CLI 上下文工厂 + :param execution_service: CLI 执行服务 + :param presenter: 插件命令文本渲染器 + :param plugin_runtime: 插件运行时服务 + :return: None + """ + self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory + self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service + self.presenter = presenter or PluginCommandPresenter() + self._plugin_runtime = plugin_runtime + + @property + def plugin_runtime(self) -> 'CliPluginRuntimeService': + """ + 延迟获取插件 CLI 运行时服务。 + + :return: 插件 CLI 运行时服务 + """ + if self._plugin_runtime is None: + self._plugin_runtime = importlib.import_module('cli.runtime.plugin').PLUGIN_RUNTIME + return self._plugin_runtime + + @property + def core_runtime(self) -> 'PluginRuntimeService': + """ + 获取插件核心运行时服务。 + + :return: 插件核心运行时服务 + """ + return self.plugin_runtime.core_runtime + + def list_plugins(self, env: str, output: str) -> None: + """ + 查看插件列表。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.core_runtime.list_plugins_with_state()) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_list_text, + failure_exit_code=RUNTIME_ERROR, + ) + + def _complete_plugin_payload( + self, + ctx: object, + payload: dict[str, object], + *, + text_builder: object, + success_exit_code: int = SUCCESS, + failure_exit_code: int = DEPENDENCY_ERROR, + ) -> None: + """ + 按插件 payload ok 字段统一完成命令输出。 + + :param ctx: CLI上下文 + :param payload: 插件操作负载 + :param text_builder: 文本构造器 + :param success_exit_code: 成功退出码 + :param failure_exit_code: 失败退出码 + :return: None + """ + payload = PluginCommandPayloadAdapter.adapt(payload) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=text_builder, + default_exit_code=self._resolve_plugin_exit_code( + payload, + success_exit_code=success_exit_code, + failure_exit_code=failure_exit_code, + ), + ) + + @staticmethod + def _resolve_plugin_exit_code( + payload: dict[str, object], + *, + success_exit_code: int, + failure_exit_code: int, + ) -> int: + """ + 按插件 payload 形状解析 CLI 退出码。 + + :param payload: 插件操作负载 + :param success_exit_code: 成功退出码 + :param failure_exit_code: 业务失败退出码 + :return: CLI 退出码 + """ + if bool(payload.get('ok', False)): + return success_exit_code + if payload.get('error'): + return RUNTIME_ERROR + + return failure_exit_code + + def plugin_info(self, plugin_id: str, env: str, output: str) -> None: + """ + 查看插件详情。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.core_runtime.get_plugin_info_with_state(plugin_id)) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_info_text, + failure_exit_code=RUNTIME_ERROR, + ) + + def check_plugin(self, plugin_id: str | None, env: str, output: str) -> None: + """ + 检查插件依赖状态。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.core_runtime.check_plugin(plugin_id) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_check_text, + ) + + def check_plugin_dependencies(self, plugin_id: str, env: str, output: str) -> None: + """ + 检查插件依赖。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.core_runtime.check_plugin_dependencies(plugin_id) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_dependency_text, + ) + + def precheck_plugin(self, operation: str, plugin_id: str, env: str, output: str) -> None: + """ + 执行插件操作预检。 + + :param operation: 预检操作类型 + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.core_runtime.precheck_plugin_operation(plugin_id, operation)) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_precheck_text, + ) + + def health_plugin(self, plugin_id: str, env: str, output: str) -> None: + """ + 执行插件健康检查。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.core_runtime.health_plugin(plugin_id)) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_health_text, + ) + + def diagnose_plugin(self, plugin_id: str, env: str, output: str, *, output_file: str = '') -> None: + """ + 生成插件诊断包。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param output_file: 诊断包 JSON 导出文件路径 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.core_runtime.diagnose_plugin(plugin_id)) + if output_file.strip(): + payload = PluginCommandFileAdapter.write_json_file( + payload, + output_file, + failure_message='插件诊断包导出失败', + ) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_diagnose_text, + ) + + def generate_plugin_docs(self, plugin_id: str, env: str, output: str, *, output_file: str = '') -> None: + """ + 生成插件 Markdown 文档片段。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param output_file: Markdown 文档导出文件路径 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.core_runtime.generate_plugin_docs(plugin_id) + if output_file.strip(): + payload = PluginCommandFileAdapter.write_markdown_file( + payload, + output_file, + content_key='markdown', + failure_message='插件文档导出失败', + ) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_docs_text, + ) + + def test_plugin( + self, + plugin_id: str, + env: str, + output: str, + *, + keyword: str = '', + maxfail: int = 0, + quiet: bool = False, + frontend_build: bool = False, + ) -> None: + """ + 执行插件测试。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param keyword: pytest 关键字过滤表达式 + :param maxfail: 最大失败数 + :param quiet: 是否启用简洁输出 + :param frontend_build: 是否执行前端构建验收 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.plugin_runtime.test_plugin( + plugin_id, + keyword=keyword, + maxfail=maxfail, + quiet=quiet, + frontend_build=frontend_build, + ) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_test_text, + ) + + def plan_plugins(self, operation: str, plugin_ids: list[str], env: str, output: str) -> None: + """ + 生成插件批量操作拓扑计划。 + + :param operation: 计划操作类型 + :param plugin_ids: 插件ID列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.core_runtime.plan_plugins(operation, plugin_ids or None) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_plan_text, + ) + + def batch_plugins( + self, + operation: str, + plugin_ids: list[str], + env: str, + output: str, + *, + allow_prod: bool, + yes: bool, + dry_run: bool, + continue_on_error: bool, + ) -> None: + """ + 按拓扑顺序批量执行插件操作。 + + :param operation: 批量操作类型 + :param plugin_ids: 插件ID列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='plugin batch', + ) + payload = self.execution_service.run_async( + self.core_runtime.batch_plugins( + operation, + plugin_ids or None, + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_batch_text, + ) + + def install_plugin_dependencies( + self, + plugin_id: str, + env: str, + output: str, + *, + options: PluginDependencyInstallCommandOptions, + ) -> None: + """ + 安装插件依赖。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param options: 依赖安装命令选项 + :return: None + """ + ctx = self.context_factory.build_regular( + env, + output, + options.allow_prod, + options.yes, + options.dry_run, + ) + policy_config = DependencyInstallPolicyConfig.from_environment( + env=env, + mode=options.policy_mode, + allow_prod=options.allow_prod, + allow_unlisted=options.allow_unlisted, + lockfile_path=options.lockfile or None, + offline_dir=options.offline_dir or None, + require_lockfile=options.require_lockfile, + ) + core_runtime = self.core_runtime + output_callback = self._build_dependency_install_output_callback(ctx) + if self._should_interactive_confirm_dependency_install(ctx, options): + preview_payload = core_runtime.install_plugin_dependencies( + plugin_id, + dry_run=True, + policy_config=policy_config, + confirmed=True, + ) + preview_payload['env'] = ctx.env + if self._dependency_install_policy_blocked(preview_payload): + payload = core_runtime.install_plugin_dependencies( + plugin_id, + dry_run=False, + policy_config=policy_config, + confirmed=True, + output_callback=output_callback, + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_dependency_install_text, + ) + return + typer.echo(self.presenter.build_dependency_install_text(preview_payload)) + if not self._confirm_dependency_install(ctx.env): + payload = self._build_dependency_install_cancel_payload(plugin_id, ctx.env, preview_payload) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_dependency_install_text, + ) + return + confirmed = True + else: + confirmed = options.yes + + payload = core_runtime.install_plugin_dependencies( + plugin_id, + dry_run=options.dry_run, + policy_config=policy_config, + confirmed=confirmed, + output_callback=output_callback, + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_dependency_install_text, + ) + + @staticmethod + def _build_dependency_install_output_callback(ctx: object) -> PluginDependencyOutputCallback | None: + """ + 为文本输出模式构建依赖安装实时输出回调。 + + :param ctx: CLI上下文 + :return: 实时输出回调;非文本模式返回 None + """ + if getattr(ctx, 'output', 'text') != 'text': + return None + + def output_callback(kind: str, text: str) -> None: + if text: + typer.echo(text, nl=False, err=kind == 'stderr') + + return output_callback + + def lock_plugin_dependencies( + self, + plugin_id: str, + env: str, + output: str, + *, + options: PluginDependencyLockCommandOptions, + ) -> None: + """ + 生成插件依赖锁文件模板。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param options: 依赖锁文件命令选项 + :return: None + """ + ctx = self.context_factory.build_regular( + env, + output, + False, + True, + options.dry_run, + ) + payload = self.plugin_runtime.lock_plugin_dependencies( + plugin_id, + output_path=options.output_path, + offline_dir=options.offline_dir, + dry_run=options.dry_run, + overwrite=options.overwrite, + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_dependency_lock_text, + ) + + def generate_plugin_dependency_allowlist_example( + self, + env: str, + output: str, + *, + options: PluginDependencyAllowlistExampleCommandOptions, + ) -> None: + """ + 生成插件依赖允许列表示例。 + + :param env: 当前命令运行环境 + :param output: 输出格式 + :param options: 允许列表示例命令选项 + :return: None + """ + ctx = self.context_factory.build_regular( + env, + output, + False, + True, + options.dry_run, + ) + payload = self.plugin_runtime.generate_plugin_dependency_allowlist_example( + output_path=options.output_path, + dry_run=options.dry_run, + overwrite=options.overwrite, + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_dependency_allowlist_example_text, + ) + + @staticmethod + def _should_interactive_confirm_dependency_install( + ctx: object, + options: PluginDependencyInstallCommandOptions, + ) -> bool: + """ + 判断插件依赖安装是否应进入 CLI 交互确认流程。 + + :param ctx: CLI上下文 + :param options: 依赖安装命令选项 + :return: 是否进入交互确认 + """ + return ( + not options.yes and not options.dry_run and getattr(ctx, 'output', 'text') == 'text' and sys.stdin.isatty() + ) + + @staticmethod + def _dependency_install_policy_blocked(payload: dict[str, object]) -> bool: + """ + 判断依赖安装预览中的策略是否已阻断真实安装。 + + :param payload: 依赖安装预览负载 + :return: 是否被策略阻断 + """ + policy = payload.get('policy') + return isinstance(policy, dict) and policy.get('allowed') is False + + @staticmethod + def _confirm_dependency_install(env: str) -> bool: + """ + 询问用户是否执行插件依赖安装。 + + :param env: 当前运行环境 + :return: 是否确认执行 + """ + try: + return bool(typer.confirm(f'确认执行插件依赖安装吗? 当前环境:{env}', default=False)) + except (click.Abort, EOFError, KeyboardInterrupt): + return False + + @staticmethod + def _build_dependency_install_cancel_payload( + plugin_id: str, + env: str, + preview_payload: dict[str, object], + ) -> dict[str, object]: + """ + 构建用户取消插件依赖安装的负载。 + + :param plugin_id: 插件ID + :param env: 当前运行环境 + :param preview_payload: 安装预览负载 + :return: 取消安装负载 + """ + return { + 'ok': False, + 'message': '已取消插件依赖安装', + 'pluginId': plugin_id, + 'env': env, + 'dryRun': False, + 'preview': preview_payload, + } + + def create_plugin( + self, + plugin_id: str, + env: str, + output: str, + options: PluginCreateCommandOptions, + ) -> None: + """ + 创建插件开发模板。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param options: 插件创建命令选项 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + if options.backend_only and options.frontend_only: + self.execution_service.complete_payload_with_text( + ctx, + { + 'ok': False, + 'message': '--backend-only 和 --frontend-only 不能同时使用', + }, + text_builder=self.presenter.build_create_text, + default_exit_code=ARGUMENT_ERROR, + ) + return + + payload = self.plugin_runtime.create_plugin( + plugin_id, + template=options.template, + backend=not options.frontend_only, + frontend=not options.backend_only, + migration=not options.no_migration, + seed=not options.no_seed, + job=not options.no_job, + config=not options.no_config, + test=not options.no_test, + frontend_version=options.frontend_version, + dry_run=options.dry_run, + ) + self.execution_service.complete_payload_with_text( + ctx, + payload, + text_builder=self.presenter.build_create_text, + default_exit_code=SUCCESS, + ) + + def install_plugin( + self, + plugin_id: str, + env: str, + output: str, + *, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> None: + """ + 安装插件。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='plugin install', + ) + payload = self.execution_service.run_async(self.core_runtime.install_plugin(plugin_id, dry_run=dry_run)) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_install_text, + ) + + def upgrade_plugin( + self, + plugin_id: str, + env: str, + output: str, + *, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> None: + """ + 升级插件。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='plugin upgrade', + ) + payload = self.execution_service.run_async(self.core_runtime.upgrade_plugin(plugin_id, dry_run=dry_run)) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_upgrade_text, + ) + + def set_plugin_enabled( + self, + plugin_id: str, + env: str, + output: str, + *, + enabled: bool, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> None: + """ + 更新插件启停状态。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param enabled: 是否启用 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + command_name = 'plugin enable' if enabled else 'plugin disable' + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name=command_name, + ) + payload = self.execution_service.run_async( + self.core_runtime.set_plugin_enabled(plugin_id, enabled=enabled, dry_run=dry_run) + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_enabled_text, + ) + + def uninstall_plugin( + self, + plugin_id: str, + env: str, + output: str, + *, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> None: + """ + 安全卸载插件。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='plugin uninstall', + ) + payload = self.execution_service.run_async(self.core_runtime.uninstall_plugin(plugin_id, dry_run=dry_run)) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_enabled_text, + ) + + def purge_plugin( + self, + plugin_id: str, + env: str, + output: str, + *, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> None: + """ + 物理清理插件平台元数据。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run, + command_name='plugin purge', + ) + payload = self.execution_service.run_async(self.core_runtime.purge_plugin(plugin_id, dry_run=dry_run)) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_purge_text, + ) + + def list_plugin_migrations(self, plugin_id: str, status: str | None, env: str, output: str) -> None: + """ + 查看插件 migration 历史。 + + :param plugin_id: 插件ID + :param status: 执行状态 + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.core_runtime.list_plugin_migrations(plugin_id, status)) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_migration_list_text, + ) + + def mark_plugin_migration_success( + self, + plugin_id: str, + migration_path: str, + env: str, + output: str, + *, + note: str, + allow_prod: bool, + yes: bool, + ) -> None: + """ + 人工标记插件 migration 为成功。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param note: 人工恢复备注 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + False, + command_name='plugin mark-success', + ) + payload = self.execution_service.run_async( + self.core_runtime.mark_plugin_migration_success(plugin_id, migration_path, note=note or None) + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_migration_mark_text, + ) + + def mark_plugin_migration_failed( + self, + plugin_id: str, + migration_path: str, + env: str, + output: str, + *, + note: str, + allow_prod: bool, + yes: bool, + ) -> None: + """ + 人工标记插件 migration 为失败。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param note: 人工恢复备注 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + False, + command_name='plugin mark-failed', + ) + payload = self.execution_service.run_async( + self.core_runtime.mark_plugin_migration_failed(plugin_id, migration_path, note=note or None) + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_migration_mark_text, + ) + + def plugin_config( + self, + plugin_id: str, + action: str, + pairs: list[str], + env: str, + output: str, + *, + allow_prod: bool, + yes: bool, + reveal_secret: bool = False, + output_file: str = '', + input_file: str = '', + ) -> None: + """ + 查看或设置插件配置。 + + :param plugin_id: 插件ID + :param action: 操作类型 + :param pairs: 配置键值列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param reveal_secret: 是否导出敏感配置明文 + :param output_file: 配置导出 JSON 文件路径 + :param input_file: 配置导入 JSON 文件路径 + :return: None + """ + if action == 'get': + self._get_plugin_config(plugin_id, env, output) + return + + if action == 'export': + self._export_plugin_config( + plugin_id, + env, + output, + allow_prod=allow_prod, + yes=yes, + reveal_secret=reveal_secret, + output_file=output_file, + ) + return + + if action == 'import': + self._import_plugin_config( + plugin_id, + env, + output, + allow_prod=allow_prod, + yes=yes, + input_file=input_file, + ) + return + + if action != 'set': + ctx = self.context_factory.build_readonly(env, output) + self.execution_service.complete_payload_with_text( + ctx, + { + 'ok': False, + 'message': '插件配置操作只支持 get、set、export 或 import', + 'pluginId': plugin_id, + }, + text_builder=self.presenter.build_config_text, + default_exit_code=ARGUMENT_ERROR, + ) + return + + self._set_plugin_config( + plugin_id, + pairs, + env, + output, + allow_prod=allow_prod, + yes=yes, + ) + + def _get_plugin_config(self, plugin_id: str, env: str, output: str) -> None: + """ + 读取插件配置。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :return: None + """ + ctx = self.context_factory.build_readonly(env, output) + payload = self.execution_service.run_async(self.core_runtime.get_plugin_config(plugin_id)) + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_config_text, + ) + + def _export_plugin_config( + self, + plugin_id: str, + env: str, + output: str, + *, + allow_prod: bool, + yes: bool, + reveal_secret: bool, + output_file: str, + ) -> None: + """ + 导出插件配置。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param reveal_secret: 是否导出敏感配置明文 + :param output_file: 配置导出 JSON 文件路径 + :return: None + """ + ctx = ( + self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run=True, + command_name='plugin config export', + ) + if reveal_secret + else self.context_factory.build_readonly(env, output) + ) + payload = self.execution_service.run_async( + self.core_runtime.export_plugin_config(plugin_id, reveal_secret=reveal_secret) + ) + if output_file.strip(): + payload = PluginCommandFileAdapter.write_json_file( + payload, + output_file, + failure_message='插件配置导出失败', + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_config_text, + ) + + def _import_plugin_config( + self, + plugin_id: str, + env: str, + output: str, + *, + allow_prod: bool, + yes: bool, + input_file: str, + ) -> None: + """ + 导入插件配置。 + + :param plugin_id: 插件ID + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param input_file: 配置导入 JSON 文件路径 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run=False, + command_name='plugin config import', + ) + values_payload = PluginCommandFileAdapter.read_config_import_file(input_file) + if not values_payload.get('ok', False): + self.execution_service.complete_payload_with_text( + ctx, + {'pluginId': plugin_id, **values_payload}, + text_builder=self.presenter.build_config_text, + default_exit_code=ARGUMENT_ERROR, + ) + return + payload = self.execution_service.run_async( + self.core_runtime.import_plugin_config(plugin_id, values_payload.get('values', {})) + ) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_config_text, + ) + + def _set_plugin_config( + self, + plugin_id: str, + pairs: list[str], + env: str, + output: str, + *, + allow_prod: bool, + yes: bool, + ) -> None: + """ + 更新插件配置。 + + :param plugin_id: 插件ID + :param pairs: 配置键值列表 + :param env: 当前命令运行环境 + :param output: 输出格式 + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :return: None + """ + ctx = self.context_factory.build_dangerous( + env, + output, + allow_prod, + yes, + dry_run=False, + command_name='plugin config set', + ) + try: + values = self._parse_config_pairs(pairs) + except ValueError as exc: + self.execution_service.complete_payload_with_text( + ctx, + {'ok': False, 'message': str(exc), 'pluginId': plugin_id}, + text_builder=self.presenter.build_config_text, + default_exit_code=ARGUMENT_ERROR, + ) + return + payload = self.execution_service.run_async(self.core_runtime.set_plugin_config(plugin_id, values)) + payload['env'] = ctx.env + self._complete_plugin_payload( + ctx, + payload, + text_builder=self.presenter.build_config_text, + ) + + @staticmethod + def _parse_config_pairs(pairs: list[str]) -> dict[str, str]: + """ + 解析配置键值参数。 + + :param pairs: 配置键值参数列表 + :return: 配置键值字典 + """ + values = {} + for pair in pairs: + if '=' not in pair: + raise ValueError(f'配置参数必须使用 key=value 格式:{pair}') + key, value = pair.split('=', 1) + try: + values[key] = json.loads(value) + except json.JSONDecodeError: + values[key] = value + return values diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/exporter.py b/ruoyi-fastapi-backend/cli/groups/plugin/exporter.py new file mode 100644 index 0000000..b0d1637 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/exporter.py @@ -0,0 +1,114 @@ +import json +from pathlib import Path +from typing import Any + +from cli.exit_codes import ARGUMENT_ERROR + + +class PluginCommandFileAdapter: + """ + 插件命令文件导入导出适配器。 + + 这些能力只服务 CLI 参数交互,不属于插件 core runtime。 + """ + + @classmethod + def write_markdown_file( + cls, + payload: dict[str, Any], + output_file: str, + *, + content_key: str, + failure_message: str, + ) -> dict[str, Any]: + """ + 写入 Markdown 文本文件。 + + :param payload: 原始命令负载 + :param output_file: 输出文件路径 + :param content_key: Markdown 内容字段名 + :param failure_message: 写入失败提示前缀 + :return: 附加导出结果后的命令负载 + """ + return cls._write_text_file( + payload, + output_file, + content=str(payload.get(content_key, '')), + failure_message=failure_message, + ) + + @classmethod + def write_json_file( + cls, + payload: dict[str, Any], + output_file: str, + *, + failure_message: str, + ) -> dict[str, Any]: + """ + 写入 JSON 文件。 + + :param payload: 原始命令负载 + :param output_file: 输出文件路径 + :param failure_message: 写入失败提示前缀 + :return: 附加导出结果后的命令负载 + """ + content = json.dumps(payload, ensure_ascii=False, indent=2, default=str) + return cls._write_text_file(payload, output_file, content=content, failure_message=failure_message) + + @staticmethod + def read_config_import_file(input_file: str) -> dict[str, Any]: + """ + 读取插件配置导入 JSON 文件。 + + :param input_file: 配置导入 JSON 文件路径 + :return: 配置导入负载 + """ + if not input_file.strip(): + return {'ok': False, 'message': '导入配置必须指定 --input-file', 'values': {}} + + input_path = Path(input_file).expanduser().resolve() + try: + raw_payload = json.loads(input_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + return {'ok': False, 'message': f'读取配置导入文件失败:{exc}', 'values': {}} + + if not isinstance(raw_payload, dict): + return {'ok': False, 'message': '配置导入文件必须是 JSON 对象', 'values': {}} + values = raw_payload.get('values', raw_payload) + if not isinstance(values, dict): + return {'ok': False, 'message': '配置导入文件 values 必须是 JSON 对象', 'values': {}} + + return {'ok': True, 'message': '配置导入文件读取完成', 'values': values} + + @staticmethod + def _write_text_file( + payload: dict[str, Any], + output_file: str, + *, + content: str, + failure_message: str, + ) -> dict[str, Any]: + """ + 写入文本文件并补充导出状态。 + + :param payload: 原始命令负载 + :param output_file: 输出文件路径 + :param content: 文件内容 + :param failure_message: 写入失败提示前缀 + :return: 附加导出结果后的命令负载 + """ + export_payload = dict(payload) + output_path = Path(output_file).expanduser().resolve() + try: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding='utf-8') + export_payload['outputFile'] = str(output_path) + export_payload['exported'] = True + except OSError as exc: + export_payload['ok'] = False + export_payload['message'] = f'{failure_message}:{exc}' + export_payload['outputFile'] = str(output_path) + export_payload['exported'] = False + export_payload['exit_code'] = ARGUMENT_ERROR + return export_payload diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/options.py b/ruoyi-fastapi-backend/cli/groups/plugin/options.py new file mode 100644 index 0000000..a4373e4 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/options.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from typing import Literal + + +@dataclass(frozen=True) +class PluginCreateCommandOptions: + """ + 插件创建命令选项。 + + :param backend_only: 是否只创建后端插件模板 + :param frontend_only: 是否只创建前端插件模板 + :param template: 插件模板名称 + :param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3 + :param no_migration: 是否不创建 migration 示例 + :param no_seed: 是否不创建 seed 示例 + :param no_job: 是否不创建定时任务示例 + :param no_config: 是否不创建配置项示例 + :param no_test: 是否不创建测试样例 + :param dry_run: 是否仅预演 + """ + + backend_only: bool = False + frontend_only: bool = False + template: str = 'full-stack' + frontend_version: Literal['auto', 'vue2', 'vue3'] = 'auto' + no_migration: bool = False + no_seed: bool = False + no_job: bool = False + no_config: bool = False + no_test: bool = False + dry_run: bool = False + + +@dataclass(frozen=True) +class PluginDependencyInstallCommandOptions: + """ + 插件依赖安装命令选项。 + + :param allow_prod: 是否允许生产环境危险命令 + :param yes: 是否跳过确认 + :param dry_run: 是否仅预演 + :param policy_mode: 临时覆盖策略模式 + :param allow_unlisted: dev 环境是否允许未命中 allowlist 的依赖仅告警 + :param lockfile: 锁文件路径 + :param offline_dir: 离线制品目录 + :param require_lockfile: 是否要求锁文件 + """ + + allow_prod: bool = False + yes: bool = False + dry_run: bool = False + policy_mode: str | None = None + allow_unlisted: bool = False + lockfile: str = '' + offline_dir: str = '' + require_lockfile: bool | None = None + + +@dataclass(frozen=True) +class PluginDependencyLockCommandOptions: + """ + 插件依赖锁文件模板命令选项。 + + :param output_path: 输出锁文件路径 + :param offline_dir: 离线制品目录 + :param dry_run: 是否仅预演 + :param overwrite: 是否覆盖已有锁文件 + """ + + output_path: str = '' + offline_dir: str = '' + dry_run: bool = False + overwrite: bool = False + + +@dataclass(frozen=True) +class PluginDependencyAllowlistExampleCommandOptions: + """ + 插件依赖允许列表示例命令选项。 + + :param output_path: 输出允许列表路径 + :param dry_run: 是否仅预演 + :param overwrite: 是否覆盖已有文件 + """ + + output_path: str = '' + dry_run: bool = False + overwrite: bool = False diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/payload.py b/ruoyi-fastapi-backend/cli/groups/plugin/payload.py new file mode 100644 index 0000000..7941b18 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/payload.py @@ -0,0 +1,154 @@ +from copy import deepcopy +from typing import Any + + +class PluginCommandPayloadAdapter: + """ + 插件 CLI 输出负载适配器。 + + 核心运行时 payload 同时服务管理接口和 CLI。CLI 在输出前通过本适配器消除 + `enabled` 的多重语义,不改变管理接口既有契约。 + """ + + _LIFECYCLE_OPERATIONS = {'enable', 'disable', 'uninstall'} + + @classmethod + def adapt(cls, payload: dict[str, Any]) -> dict[str, Any]: + """ + 生成语义明确的 CLI 输出负载。 + + :param payload: 核心运行时负载 + :return: CLI 输出负载 + """ + adapted_payload = deepcopy(payload) + cls._adapt_node(adapted_payload) + return adapted_payload + + @classmethod + def _adapt_node(cls, node: object, *, parent_key: str | None = None) -> None: + """ + 递归适配负载节点。 + + :param node: 当前负载节点 + :param parent_key: 当前节点在父对象中的字段名 + :return: None + """ + if isinstance(node, list): + for item in node: + cls._adapt_node(item, parent_key=parent_key) + return + if not isinstance(node, dict): + return + + cls._adapt_database_state(node, parent_key) + cls._adapt_lifecycle_target(node) + cls._adapt_action_state(node, parent_key) + cls._adapt_plan_plugin_state(node, parent_key) + cls._adapt_plugin_runtime_state(node) + cls._adapt_manifest_job_state(node, parent_key) + for key, value in list(node.items()): + cls._adapt_node(value, parent_key=key) + + @staticmethod + def _adapt_database_state(node: dict[str, Any], parent_key: str | None) -> None: + """ + 将数据库启停枚举转换为语义明确的布尔字段。 + + :param node: 当前负载节点 + :param parent_key: 当前节点在父对象中的字段名 + :return: None + """ + if parent_key != 'database' or 'enabled' not in node: + return + database_enabled = node.pop('enabled') + node['configuredEnabled'] = PluginCommandPayloadAdapter._normalize_enabled_value(database_enabled) + + @classmethod + def _adapt_lifecycle_target(cls, node: dict[str, Any]) -> None: + """ + 将生命周期结果中的目标启停值改为 targetEnabled。 + + :param node: 当前负载节点 + :return: None + """ + if node.get('operation') == 'purge' and 'enabled' in node: + node.pop('enabled') + return + if ( + node.get('operation') in cls._LIFECYCLE_OPERATIONS + and 'pluginId' in node + and 'dryRun' in node + and 'enabled' in node + ): + node['targetEnabled'] = node.pop('enabled') + + @staticmethod + def _adapt_action_state(node: dict[str, Any], parent_key: str | None) -> None: + """ + 将动作和清理计划项中的 enabled 改为 willRun。 + + :param node: 当前负载节点 + :param parent_key: 当前节点在父对象中的字段名 + :return: None + """ + is_action = parent_key == 'actions' and 'name' in node + is_purge_plan_item = parent_key == 'items' and 'destructive' in node and 'label' in node + if (is_action or is_purge_plan_item) and isinstance(node.get('enabled'), bool): + node['willRun'] = node.pop('enabled') + + @staticmethod + def _adapt_plan_plugin_state(node: dict[str, Any], parent_key: str | None) -> None: + """ + 将批量计划项中的数据库启停枚举改为 configuredEnabled。 + + :param node: 当前负载节点 + :param parent_key: 当前节点在父对象中的字段名 + :return: None + """ + if parent_key != 'items' or 'pluginId' not in node or 'ready' not in node or 'enabled' not in node: + return + configured_enabled = node.pop('enabled') + node['configuredEnabled'] = PluginCommandPayloadAdapter._normalize_enabled_value(configured_enabled) + + @staticmethod + def _adapt_plugin_runtime_state(node: dict[str, Any]) -> None: + """ + 将插件有效启用态改为 runtimeEnabled。 + + :param node: 当前负载节点 + :return: None + """ + if 'pluginId' not in node or 'status' not in node or 'enabled' not in node or 'dryRun' in node: + return + enabled = node.pop('enabled') + if isinstance(enabled, bool): + node['runtimeEnabled'] = enabled + return + node['configuredEnabled'] = PluginCommandPayloadAdapter._normalize_enabled_value(enabled) + + @staticmethod + def _adapt_manifest_job_state(node: dict[str, Any], parent_key: str | None) -> None: + """ + 将 manifest 任务默认启用配置改为 defaultEnabled。 + + :param node: 当前负载节点 + :param parent_key: 当前节点在父对象中的字段名 + :return: None + """ + if parent_key == 'jobs' and 'id' in node and isinstance(node.get('enabled'), bool): + node['defaultEnabled'] = node.pop('enabled') + + @staticmethod + def _normalize_enabled_value(value: object) -> object: + """ + 将数据库 0/1 枚举转换为布尔值,其余值原样保留。 + + :param value: 原始启停值 + :return: 规范化启停值 + """ + if isinstance(value, str) and value in {'0', '1'}: + return value == '0' + return value + + +__all__ = ['PluginCommandPayloadAdapter'] diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py b/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py new file mode 100644 index 0000000..047e367 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py @@ -0,0 +1,924 @@ +from cli.utils import SHELL_TEXT_FORMATTER + + +class PluginCommandPresenter: + """ + 插件命令文本渲染器。 + """ + + def build_list_text(self, payload: dict[str, object]) -> str: + """ + 将插件列表负载渲染为文本。 + + :param payload: 插件列表负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'count: {payload.get("count", 0)}', + ] + if 'databaseAvailable' in payload: + database_available = bool(payload.get('databaseAvailable', False)) + lines.append(f'database_available: {str(database_available).lower()}') + if not database_available: + database_error = SHELL_TEXT_FORMATTER.truncate_text(payload.get('databaseError', '') or '', 120) + lines.append(f'database_error: {database_error or "-"}') + plugins = payload.get('plugins') + if not isinstance(plugins, list) or not plugins: + lines.append('plugins: none') + return '\n'.join(lines) + + lines.append('plugins:') + lines.extend(self._build_plugin_summary_line(plugin) for plugin in plugins if isinstance(plugin, dict)) + return '\n'.join(lines) + + def build_info_text(self, payload: dict[str, object]) -> str: + """ + 将插件详情负载渲染为文本。 + + :param payload: 插件详情负载 + :return: 文本输出 + """ + plugin = payload.get('plugin') + if not isinstance(plugin, dict): + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + ] + ) + + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'plugin_id: {plugin.get("pluginId", "-")}', + f'name: {plugin.get("name", "-")}', + f'version: {plugin.get("version", "-")}', + f'installed_version: {plugin.get("installedVersion", "-") or "-"}', + f'runtime_enabled: {str(plugin.get("runtimeEnabled", False)).lower()}', + f'status: {plugin.get("status", "-")}', + f'source: {plugin.get("source", "-")}', + f'last_error: {SHELL_TEXT_FORMATTER.truncate_text(plugin.get("lastError", "") or "", 100) or "-"}', + f'description: {SHELL_TEXT_FORMATTER.truncate_text(plugin.get("description", ""), 100)}', + f'backend_path: {SHELL_TEXT_FORMATTER.truncate_text(plugin.get("backendPath", ""), 120)}', + f'frontend_path: {SHELL_TEXT_FORMATTER.truncate_text(plugin.get("frontendPath", "") or "", 120) or "-"}', + f'menu_count: {plugin.get("menuCount", 0)}', + f'permission_count: {plugin.get("permissionCount", 0)}', + ] + database = plugin.get('database') + if isinstance(database, dict): + lines.append(f'database_available: {str(database.get("available", False)).lower()}') + lines.append(f'database_installed: {str(database.get("installed", False)).lower()}') + if 'configuredEnabled' in database: + lines.append(f'configured_enabled: {str(database.get("configuredEnabled", False)).lower()}') + dependencies = plugin.get('dependencies') + if isinstance(dependencies, list): + lines.append(f'dependencies: {len(dependencies)}') + lines.extend(self._build_dependency_lines(dependencies)) + + return '\n'.join(lines) + + def build_check_text(self, payload: dict[str, object]) -> str: + """ + 将插件检查负载渲染为文本。 + + :param payload: 插件检查负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'count: {payload.get("count", 0)}', + ] + checks = payload.get('checks') + if not isinstance(checks, list) or not checks: + lines.append('checks: none') + return '\n'.join(lines) + + lines.append('checks:') + lines.extend(self._build_check_summary_line(check) for check in checks if isinstance(check, dict)) + return '\n'.join(lines) + + def build_dependency_text(self, payload: dict[str, object]) -> str: + """ + 将插件依赖检查负载渲染为文本。 + + :param payload: 插件依赖检查负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'dependency_ok: {str(payload.get("dependencyOk", False)).lower()}', + ] + dependencies = payload.get('dependencies') + if isinstance(dependencies, list): + lines.append(f'dependencies: {len(dependencies)}') + lines.extend(self._build_dependency_lines(dependencies)) + else: + lines.append('dependencies: none') + + return '\n'.join(lines) + + def build_precheck_text(self, payload: dict[str, object]) -> str: + """ + 将插件操作预检负载渲染为文本。 + + :param payload: 插件操作预检负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'operation: {payload.get("operation", "-")}', + f'dependency_ok: {str(payload.get("dependencyOk", False)).lower()}', + f'plugin_dependency_ok: {str(payload.get("pluginDependencyOk", False)).lower()}', + f'structure_ok: {str(payload.get("structureOk", False)).lower()}', + f'menu_conflict_ok: {str(payload.get("menuConflictOk", False)).lower()}', + f'database_available: {str(payload.get("databaseAvailable", False)).lower()}', + ] + actions = payload.get('actions') + lines.append(f'actions: {len(actions) if isinstance(actions, list) else 0}') + precheck = payload.get('precheck') + if isinstance(precheck, dict): + lines.append(f'dependencies: {len(precheck.get("dependencies", []))}') + lines.append(f'structure_errors: {len(precheck.get("structureErrors", []))}') + lines.append(f'menu_conflicts: {len(precheck.get("menuConflicts", []))}') + + return '\n'.join(lines) + + def build_health_text(self, payload: dict[str, object]) -> str: + """ + 将插件健康检查负载渲染为文本。 + + :param payload: 插件健康检查负载 + :return: 文本输出 + """ + health = payload.get('health') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + ] + if not isinstance(health, dict): + return '\n'.join(lines) + + lines.extend( + [ + f'status: {health.get("status", "-")}', + f'checker: {health.get("checker", "-") or "-"}', + f'duration_ms: {health.get("durationMs", 0)}', + ] + ) + if health.get('error'): + lines.append(f'error: {SHELL_TEXT_FORMATTER.truncate_text(health.get("error", ""), 120)}') + details = health.get('details') + if isinstance(details, dict): + lines.append(f'details: {len(details)}') + + return '\n'.join(lines) + + def build_diagnose_text(self, payload: dict[str, object]) -> str: + """ + 将插件诊断包负载渲染为文本。 + + :param payload: 插件诊断包负载 + :return: 文本输出 + """ + info = payload.get('info') + check = payload.get('check') + menu_plan = payload.get('menuPlan') + config = payload.get('config') + audit = payload.get('audit') + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + ] + if isinstance(info, dict): + lines.append(f'status: {info.get("status", "-")}') + lines.append(f'installed_version: {info.get("installedVersion", "-") or "-"}') + lines.append( + f'last_error: {SHELL_TEXT_FORMATTER.truncate_text(info.get("lastError", "") or "", 100) or "-"}' + ) + if isinstance(check, dict): + checks = check.get('checks') + check_items = checks if isinstance(checks, list) else [] + first_check = check_items[0] if check_items and isinstance(check_items[0], dict) else {} + lines.append(f'check_ok: {str(check.get("ok", False)).lower()}') + lines.append(f'dependency_count: {len(first_check.get("dependencies", [])) if first_check else 0}') + lines.append(f'structure_errors: {len(first_check.get("structureErrors", [])) if first_check else 0}') + lines.append(f'menu_conflicts: {len(first_check.get("menuConflicts", [])) if first_check else 0}') + if isinstance(menu_plan, dict): + lines.append( + f'menu_plan: total={menu_plan.get("total", 0)} | ' + f'permissions={menu_plan.get("permissionCount", 0)} | ' + f'enabled={menu_plan.get("enabledCount", 0)}' + ) + if isinstance(config, dict): + configs = config.get('configs') + lines.append(f'configs: {len(configs) if isinstance(configs, list) else 0}') + summary = config.get('summary') + if isinstance(summary, dict): + lines.append( + f'config_summary: total={summary.get("total", 0)} | ' + f'secret={summary.get("secretCount", 0)} | ' + f'missing_required={summary.get("missingRequiredCount", 0)}' + ) + if isinstance(audit, dict): + lines.append(f'audit_available: {str(audit.get("available", False)).lower()}') + if payload.get('outputFile'): + lines.append(f'output_file: {payload.get("outputFile")}') + lines.append(f'exported: {str(payload.get("exported", False)).lower()}') + + return '\n'.join(lines) + + def build_docs_text(self, payload: dict[str, object]) -> str: + """ + 将插件文档生成负载渲染为文本。 + + :param payload: 插件文档生成负载 + :return: 文本输出 + """ + if payload.get('ok') and isinstance(payload.get('markdown'), str) and not payload.get('outputFile'): + return str(payload.get('markdown', '')) + + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'format: {payload.get("format", "-")}', + f'length: {payload.get("length", 0)}', + ] + if payload.get('outputFile'): + lines.append(f'output_file: {payload.get("outputFile")}') + lines.append(f'exported: {str(payload.get("exported", False)).lower()}') + + return '\n'.join(lines) + + def build_test_text(self, payload: dict[str, object]) -> str: + """ + 将插件测试负载渲染为文本。 + + :param payload: 插件测试负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'keyword: {payload.get("keyword", "") or "-"}', + f'maxfail: {payload.get("maxfail", 0)}', + f'quiet: {str(payload.get("quiet", False)).lower()}', + f'frontend_build: {str(payload.get("frontendBuild", False)).lower()}', + ] + targets = payload.get('targets') + if isinstance(targets, list): + lines.append(f'targets: {len(targets)}') + lines.extend(f' - {target}' for target in targets) + command = payload.get('command') + if isinstance(command, list): + lines.append(f'command: {" ".join(str(item) for item in command)}') + results = payload.get('results') + if isinstance(results, list): + lines.append(f'results: {len(results)}') + for item in results: + if not isinstance(item, dict): + continue + test_result = item.get('test') if isinstance(item.get('test'), dict) else {} + lines.append( + f' - {item.get("kind", "-")}: {item.get("target", "-")} [{test_result.get("returnCode", "-")}]' + ) + test_payload = payload.get('test') + if isinstance(test_payload, dict): + lines.append(f'return_code: {test_payload.get("returnCode", "-")}') + stdout = str(test_payload.get('stdout', '') or '').strip() + stderr = str(test_payload.get('stderr', '') or '').strip() + if stdout: + lines.append(f'stdout: {SHELL_TEXT_FORMATTER.truncate_text(stdout, 300)}') + if stderr: + lines.append(f'stderr: {SHELL_TEXT_FORMATTER.truncate_text(stderr, 300)}') + + return '\n'.join(lines) + + def build_dependency_install_text(self, payload: dict[str, object]) -> str: + """ + 将插件依赖安装负载渲染为文本。 + + :param payload: 插件依赖安装负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'env: {payload.get("env", "-")}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + f'plan_count: {payload.get("planCount", 0)}', + ] + plan = payload.get('plan') + if isinstance(plan, list) and plan: + lines.append('plan:') + lines.extend(self._build_dependency_plan_line(item) for item in plan if isinstance(item, dict)) + else: + lines.append('plan: none') + + results = payload.get('results') + if isinstance(results, list): + lines.append(f'results: {len(results)}') + policy = payload.get('policy') + if isinstance(policy, dict): + lines.extend( + [ + f'policy_mode: {policy.get("mode", "-")}', + f'policy_allowed: {str(policy.get("allowed", False)).lower()}', + ] + ) + reasons = policy.get('reasons') + if isinstance(reasons, list) and reasons: + lines.append('policy_reasons:') + lines.extend(f' - {reason}' for reason in reasons) + requirements = policy.get('requirements') + if isinstance(requirements, list) and requirements: + lines.append('policy_requirements:') + lines.extend(f' - {requirement}' for requirement in requirements) + + return '\n'.join(lines) + + def build_dependency_lock_text(self, payload: dict[str, object]) -> str: + """ + 将插件依赖锁文件模板负载渲染为文本。 + + :param payload: 插件依赖锁文件模板负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'env: {payload.get("env", "-")}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + f'output_file: {payload.get("outputFile", "-")}', + f'written: {str(payload.get("written", False)).lower()}', + f'overwritten: {str(payload.get("overwritten", False)).lower()}', + f'entry_count: {payload.get("entryCount", 0)}', + f'artifact_count: {payload.get("artifactCount", 0)}', + ] + warnings = payload.get('warnings') + if isinstance(warnings, list) and warnings: + lines.append('warnings:') + lines.extend(f' - {warning}' for warning in warnings) + return '\n'.join(lines) + + def build_dependency_allowlist_example_text(self, payload: dict[str, object]) -> str: + """ + 将插件依赖允许列表示例负载渲染为文本。 + + :param payload: 插件依赖允许列表示例负载 + :return: 文本输出 + """ + if payload.get('ok') and isinstance(payload.get('allowlist'), str) and payload.get('dryRun'): + return str(payload.get('allowlist', '')) + + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'env: {payload.get("env", "-")}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + f'output_file: {payload.get("outputFile", "-")}', + f'written: {str(payload.get("written", False)).lower()}', + f'overwritten: {str(payload.get("overwritten", False)).lower()}', + ] + ) + + def build_plan_text(self, payload: dict[str, object]) -> str: + """ + 将插件批量操作拓扑计划负载渲染为文本。 + + :param payload: 插件批量操作拓扑计划负载 + :return: 文本输出 + """ + plan = payload.get('plan') + items = plan.get('items') if isinstance(plan, dict) else None + blockers = plan.get('blockers') if isinstance(plan, dict) else None + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'operation: {payload.get("operation", "-")}', + f'blocker_count: {plan.get("blockerCount", 0) if isinstance(plan, dict) else 0}', + ] + if isinstance(items, list) and items: + lines.append('plan:') + lines.extend(self._build_plugin_plan_line(item) for item in items if isinstance(item, dict)) + else: + lines.append('plan: none') + if isinstance(blockers, list) and blockers: + lines.append('blockers:') + lines.extend(self._build_plugin_plan_blocker_line(item) for item in blockers if isinstance(item, dict)) + else: + lines.append('blockers: none') + + return '\n'.join(lines) + + def build_batch_text(self, payload: dict[str, object]) -> str: + """ + 将插件批量执行负载渲染为文本。 + + :param payload: 插件批量执行负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'operation: {payload.get("operation", "-")}', + f'env: {payload.get("env", "-")}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + f'continue_on_error: {str(payload.get("continueOnError", False)).lower()}', + ] + plan = payload.get('plan') + if isinstance(plan, dict): + lines.append(f'plan_items: {len(plan.get("items", []))}') + lines.append(f'blocker_count: {plan.get("blockerCount", 0)}') + summary = payload.get('summary') + if isinstance(summary, dict): + lines.append( + f'summary: total={summary.get("total", 0)} | succeeded={summary.get("succeeded", 0)} | ' + f'failed={summary.get("failed", 0)} | skipped={summary.get("skipped", 0)}' + ) + executed = payload.get('executed') + if isinstance(executed, list) and executed: + lines.append('executed:') + lines.extend(self._build_batch_result_line(item) for item in executed if isinstance(item, dict)) + else: + lines.append('executed: none') + failed = payload.get('failed') + if isinstance(failed, dict): + lines.append( + f'failed: {failed.get("pluginId", "-")} | ' + f'operation: {failed.get("operation", "-")} | message: {failed.get("message", "-")}' + ) + if failed.get('suggestion'): + lines.append(f'suggestion: {failed.get("suggestion", "-")}') + else: + lines.append('failed: none') + + return '\n'.join(lines) + + def build_create_text(self, payload: dict[str, object]) -> str: + """ + 将插件创建负载渲染为文本。 + + :param payload: 插件创建负载 + :return: 文本输出 + """ + lines = [ + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + f'template: {payload.get("template", "-")}', + f'backend: {str(payload.get("backend", False)).lower()}', + f'frontend: {str(payload.get("frontend", False)).lower()}', + f'frontend_version: {payload.get("frontendVersion") or "-"}', + f'migration: {str(payload.get("migration", False)).lower()}', + f'seed: {str(payload.get("seed", False)).lower()}', + f'job: {str(payload.get("job", False)).lower()}', + f'config: {str(payload.get("config", False)).lower()}', + f'test: {str(payload.get("test", False)).lower()}', + ] + conflicts = payload.get('conflicts') + if isinstance(conflicts, list) and conflicts: + lines.append('conflicts:') + lines.extend(f' - {conflict}' for conflict in conflicts) + + files = payload.get('files') + if not isinstance(files, list) or not files: + lines.append('files: none') + return '\n'.join(lines) + + lines.append(f'files: {len(files)}') + lines.extend(f' - {file_payload.get("path", "-")}' for file_payload in files if isinstance(file_payload, dict)) + + return '\n'.join(lines) + + def build_install_text(self, payload: dict[str, object]) -> str: + """ + 将插件安装负载渲染为文本。 + + :param payload: 插件安装负载 + :return: 文本输出 + """ + lines = [ + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'env: {payload.get("env", "-")}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + f'dependency_ok: {str(payload.get("dependencyOk", False)).lower()}', + f'structure_ok: {str(payload.get("structureOk", True)).lower()}', + f'menu_conflict_ok: {str(payload.get("menuConflictOk", True)).lower()}', + ] + actions = payload.get('actions') + if isinstance(actions, list) and actions: + lines.append('actions:') + lines.extend(self._build_action_summary_line(action) for action in actions if isinstance(action, dict)) + else: + lines.append('actions: none') + + dependencies = payload.get('dependencies') + if isinstance(dependencies, list) and dependencies: + lines.append(f'dependencies: {len(dependencies)}') + else: + lines.append('dependencies: none') + + structure_errors = payload.get('structureErrors') + if isinstance(structure_errors, list) and structure_errors: + lines.append(f'structure_errors: {len(structure_errors)}') + else: + lines.append('structure_errors: none') + + menu_conflicts = payload.get('menuConflicts') + if isinstance(menu_conflicts, list) and menu_conflicts: + lines.append(f'menu_conflicts: {len(menu_conflicts)}') + else: + lines.append('menu_conflicts: none') + + self._append_lifecycle_migration_lines(lines, payload) + + return '\n'.join(lines) + + def build_upgrade_text(self, payload: dict[str, object]) -> str: + """ + 将插件升级负载渲染为文本。 + + :param payload: 插件升级负载 + :return: 文本输出 + """ + lines = [ + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'env: {payload.get("env", "-")}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + f'installed: {str(payload.get("installed", False)).lower()}', + f'installed_version: {payload.get("installedVersion", "-") or "-"}', + f'current_version: {payload.get("currentVersion", "-") or "-"}', + f'needs_upgrade: {str(payload.get("needsUpgrade", False)).lower()}', + f'database_available: {str(payload.get("databaseAvailable", True)).lower()}', + f'dependency_ok: {str(payload.get("dependencyOk", False)).lower()}', + f'structure_ok: {str(payload.get("structureOk", True)).lower()}', + f'menu_conflict_ok: {str(payload.get("menuConflictOk", True)).lower()}', + ] + actions = payload.get('actions') + if isinstance(actions, list) and actions: + lines.append('actions:') + lines.extend(self._build_action_summary_line(action) for action in actions if isinstance(action, dict)) + else: + lines.append('actions: none') + + structure_errors = payload.get('structureErrors') + if isinstance(structure_errors, list) and structure_errors: + lines.append(f'structure_errors: {len(structure_errors)}') + else: + lines.append('structure_errors: none') + + menu_conflicts = payload.get('menuConflicts') + if isinstance(menu_conflicts, list) and menu_conflicts: + lines.append(f'menu_conflicts: {len(menu_conflicts)}') + else: + lines.append('menu_conflicts: none') + + self._append_lifecycle_migration_lines(lines, payload) + + return '\n'.join(lines) + + def build_enabled_text(self, payload: dict[str, object]) -> str: + """ + 将插件启停负载渲染为文本。 + + :param payload: 插件启停负载 + :return: 文本输出 + """ + lines = [ + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'env: {payload.get("env", "-")}', + f'operation: {payload.get("operation", "-")}', + f'target_enabled: {str(payload.get("targetEnabled", False)).lower()}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + ] + actions = payload.get('actions') + if isinstance(actions, list) and actions: + lines.append('actions:') + lines.extend(self._build_action_summary_line(action) for action in actions if isinstance(action, dict)) + else: + lines.append('actions: none') + + return '\n'.join(lines) + + def build_purge_text(self, payload: dict[str, object]) -> str: + """ + 将插件物理清理负载渲染为文本。 + + :param payload: 插件物理清理负载 + :return: 文本输出 + """ + plan = payload.get('plan') + plan_items = plan.get('items') if isinstance(plan, dict) else None + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'env: {payload.get("env", "-")}', + f'operation: {payload.get("operation", "-")}', + f'dry_run: {str(payload.get("dryRun", False)).lower()}', + f'safe_mode: {str(payload.get("safeMode", False)).lower()}', + f'removes_source: {str(payload.get("removesSource", False)).lower()}', + f'destructive_count: {plan.get("destructiveCount", 0) if isinstance(plan, dict) else 0}', + ] + if isinstance(plan_items, list) and plan_items: + lines.append('plan:') + lines.extend(self._build_purge_plan_line(item) for item in plan_items if isinstance(item, dict)) + else: + lines.append('plan: none') + + hooks = payload.get('hooks') + if isinstance(hooks, list) and hooks: + lines.append(f'hooks: {len(hooks)}') + else: + lines.append('hooks: none') + + return '\n'.join(lines) + + def build_migration_list_text(self, payload: dict[str, object]) -> str: + """ + 将插件 migration 历史负载渲染为文本。 + + :param payload: 插件 migration 历史负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'status: {payload.get("status", "-") or "-"}', + f'count: {payload.get("count", 0)}', + ] + migrations = payload.get('migrations') + if not isinstance(migrations, list) or not migrations: + lines.append('migrations: none') + return '\n'.join(lines) + + lines.append('migrations:') + lines.extend( + self._build_migration_summary_line(migration) for migration in migrations if isinstance(migration, dict) + ) + return '\n'.join(lines) + + def build_migration_mark_text(self, payload: dict[str, object]) -> str: + """ + 将插件 migration 人工标记负载渲染为文本。 + + :param payload: 插件 migration 人工标记负载 + :return: 文本输出 + """ + return '\n'.join( + [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + f'env: {payload.get("env", "-")}', + f'operation: {payload.get("operation", "-")}', + f'migration_path: {payload.get("migrationPath", "-")}', + f'status: {payload.get("status", "-")}', + ] + ) + + def build_config_text(self, payload: dict[str, object]) -> str: + """ + 将插件配置负载渲染为文本。 + + :param payload: 插件配置负载 + :return: 文本输出 + """ + lines = [ + f'ok: {str(payload.get("ok", False)).lower()}', + f'message: {payload.get("message", "-")}', + f'plugin_id: {payload.get("pluginId", "-")}', + ] + configs = payload.get('configs') + if not isinstance(configs, list) or not configs: + lines.append('configs: none') + return '\n'.join(lines) + + lines.append(f'configs: {len(configs)}') + lines.extend(self._build_config_summary_line(config) for config in configs if isinstance(config, dict)) + + return '\n'.join(lines) + + @staticmethod + def _build_dependency_lines(dependencies: list[object]) -> list[str]: + """ + 构建依赖检查文本行。 + + :param dependencies: 依赖检查负载列表 + :return: 文本行列表 + """ + if not dependencies: + return ['dependencies_detail: none'] + lines = ['dependencies_detail:'] + lines.extend( + PluginCommandPresenter._build_dependency_summary_line(dependency) + for dependency in dependencies + if isinstance(dependency, dict) + ) + return lines + + @staticmethod + def _build_plugin_summary_line(plugin: dict[str, object]) -> str: + """ + 构建插件摘要文本行。 + + :param plugin: 插件摘要负载 + :return: 文本行 + """ + return ( + f' - {plugin.get("pluginId", "-")} | {plugin.get("name", "-")} | ' + f'version: {plugin.get("version", "-")} | ' + f'runtime_enabled: {str(plugin.get("runtimeEnabled", False)).lower()} | ' + f'status: {plugin.get("status", "-")}' + ) + + @staticmethod + def _build_config_summary_line(config: dict[str, object]) -> str: + """ + 构建插件配置摘要文本行。 + + :param config: 插件配置负载 + :return: 文本行 + """ + return ( + f' - {config.get("key", "-")} | {config.get("label", "-") or "-"} | ' + f'type: {config.get("type", "-")} | value: {config.get("value", "-")}' + ) + + @staticmethod + def _build_migration_summary_line(migration: dict[str, object]) -> str: + """ + 构建插件 migration 历史摘要文本行。 + + :param migration: migration 历史负载 + :return: 文本行 + """ + checksum = str(migration.get('migrationChecksum') or migration.get('checksum') or '') + migration_path = migration.get('migrationPath') or migration.get('migration_path') or '-' + status = migration.get('status', '-') + attempts = migration.get('attemptCount', migration.get('attempt_count', 0)) + version = migration.get('version', '-') or '-' + duration_ms = migration.get('durationMs', migration.get('duration_ms')) + duration_text = f' | duration_ms: {duration_ms}' if duration_ms is not None else '' + return ( + f' - {migration_path} | status: {status} | attempts: {attempts} | version: {version} | ' + f'checksum: {checksum[:12] or "-"}{duration_text}' + ) + + def _append_lifecycle_migration_lines(self, lines: list[str], payload: dict[str, object]) -> None: + """ + 追加生命周期 migration 结果和恢复建议文本。 + + :param lines: 文本行列表 + :param payload: 生命周期负载 + :return: None + """ + migration_recovery = payload.get('migrationRecovery') + if isinstance(migration_recovery, dict): + lines.append( + 'migration_recovery: ' + f'{migration_recovery.get("migrationPath", "-")} | ' + f'status: {migration_recovery.get("status", "-")} | ' + f'suggestion: {migration_recovery.get("suggestion", "-")}' + ) + + migrations = payload.get('migrations') + if not isinstance(migrations, list) or not migrations: + lines.append('migrations: none') + return + + lines.append('migrations:') + lines.extend( + self._build_migration_summary_line(migration) for migration in migrations if isinstance(migration, dict) + ) + + @staticmethod + def _build_dependency_plan_line(item: dict[str, object]) -> str: + """ + 构建依赖安装计划文本行。 + + :param item: 依赖安装计划项 + :return: 文本行 + """ + return ( + f' - {item.get("kind", "-")} | {item.get("requirement", "-")} | ' + f'workdir: {item.get("workdir", "-")} | command: {item.get("commandText", "-")}' + ) + + @staticmethod + def _build_purge_plan_line(item: dict[str, object]) -> str: + """ + 构建插件物理清理计划文本行。 + + :param item: 插件物理清理计划项 + :return: 文本行 + """ + will_run = item.get('willRun', item.get('enabled', False)) + return ( + f' - {item.get("name", "-")} | will_run: {str(will_run).lower()} | ' + f'destructive: {str(item.get("destructive", False)).lower()} | ' + f'count: {item.get("count", "-") if item.get("count") is not None else "-"} | ' + f'label: {item.get("label", "-")}' + ) + + @staticmethod + def _build_plugin_plan_line(item: dict[str, object]) -> str: + """ + 构建插件批量操作计划文本行。 + + :param item: 插件批量操作计划项 + :return: 文本行 + """ + return ( + f' - #{item.get("order", "-")} {item.get("pluginId", "-")} | ' + f'ready: {str(item.get("ready", False)).lower()} | ' + f'requested: {str(item.get("requested", False)).lower()} | ' + f'deps: {len(item.get("dependencies", []))}' + ) + + @staticmethod + def _build_plugin_plan_blocker_line(item: dict[str, object]) -> str: + """ + 构建插件批量操作计划阻塞项文本行。 + + :param item: 插件批量操作计划阻塞项 + :return: 文本行 + """ + return ( + f' - {item.get("pluginId", "-")} -> {item.get("dependencyId", "-")} | ' + f'status: {item.get("status", "-")} | message: {item.get("message", "-")}' + ) + + @staticmethod + def _build_batch_result_line(item: dict[str, object]) -> str: + """ + 构建插件批量执行结果文本行。 + + :param item: 插件批量执行结果项 + :return: 文本行 + """ + return ( + f' - {item.get("pluginId", "-")} | operation: {item.get("operation", "-")} | ' + f'ok: {str(item.get("ok", False)).lower()} | status: {item.get("status", "-")} | ' + f'duration_ms: {item.get("durationMs", 0)} | message: {item.get("message", "-")}' + ) + + @staticmethod + def _build_check_summary_line(check: dict[str, object]) -> str: + """ + 构建插件检查摘要文本行。 + + :param check: 插件检查负载 + :return: 文本行 + """ + missing_dependencies = check.get('missingDependencies', []) + unsatisfied_dependencies = check.get('unsatisfiedDependencies', []) + structure_errors = check.get('structureErrors', []) + menu_conflicts = check.get('menuConflicts', []) + return ( + f' - {check.get("pluginId", "-")} | ok: {str(check.get("ok", False)).lower()} | ' + f'missing: {len(missing_dependencies)} | unsatisfied: {len(unsatisfied_dependencies)} | ' + f'structure_errors: {len(structure_errors)} | menu_conflicts: {len(menu_conflicts)}' + ) + + @staticmethod + def _build_dependency_summary_line(dependency: dict[str, object]) -> str: + """ + 构建依赖检查摘要文本行。 + + :param dependency: 依赖检查负载 + :return: 文本行 + """ + return ( + f' - {dependency.get("kind", "-")}:{dependency.get("name", "-")} | ' + f'ok: {str(dependency.get("ok", False)).lower()} | ' + f'required: {dependency.get("requiredVersion", "-") or "-"} | ' + f'installed: {dependency.get("installedVersion", "-") or "-"}' + ) + + @staticmethod + def _build_action_summary_line(action: dict[str, object]) -> str: + """ + 构建安装动作摘要文本行。 + + :param action: 安装动作负载 + :return: 文本行 + """ + will_run = action.get('willRun', action.get('enabled', False)) + return f' - {action.get("name", "-")} | will_run: {str(will_run).lower()} | label: {action.get("label", "-")}' diff --git a/ruoyi-fastapi-backend/cli/guards.py b/ruoyi-fastapi-backend/cli/guards.py index 8f1c708..f92c458 100644 --- a/ruoyi-fastapi-backend/cli/guards.py +++ b/ruoyi-fastapi-backend/cli/guards.py @@ -204,6 +204,45 @@ DEFAULT_DANGEROUS_COMMAND_RULES: dict[str, DangerousCommandRule] = { 'gen create-table': DangerousCommandRule(command_name='gen create-table', risk_level='high', supports_dry_run=True), 'gen export': DangerousCommandRule(command_name='gen export', risk_level='high', supports_dry_run=True), 'gen sync-db': DangerousCommandRule(command_name='gen sync-db', risk_level='normal', supports_dry_run=False), + 'plugin install': DangerousCommandRule(command_name='plugin install', risk_level='high', supports_dry_run=True), + 'plugin install-deps': DangerousCommandRule( + command_name='plugin install-deps', + risk_level='high', + supports_dry_run=True, + ), + 'plugin upgrade': DangerousCommandRule(command_name='plugin upgrade', risk_level='high', supports_dry_run=True), + 'plugin batch': DangerousCommandRule(command_name='plugin batch', risk_level='high', supports_dry_run=True), + 'plugin enable': DangerousCommandRule(command_name='plugin enable', risk_level='normal', supports_dry_run=True), + 'plugin disable': DangerousCommandRule(command_name='plugin disable', risk_level='normal', supports_dry_run=True), + 'plugin config set': DangerousCommandRule( + command_name='plugin config set', + risk_level='normal', + supports_dry_run=False, + ), + 'plugin config import': DangerousCommandRule( + command_name='plugin config import', + risk_level='normal', + supports_dry_run=False, + ), + 'plugin config export': DangerousCommandRule( + command_name='plugin config export', + risk_level='normal', + supports_dry_run=False, + ), + 'plugin uninstall': DangerousCommandRule( + command_name='plugin uninstall', risk_level='normal', supports_dry_run=True + ), + 'plugin purge': DangerousCommandRule(command_name='plugin purge', risk_level='high', supports_dry_run=True), + 'plugin mark-success': DangerousCommandRule( + command_name='plugin mark-success', + risk_level='high', + supports_dry_run=False, + ), + 'plugin mark-failed': DangerousCommandRule( + command_name='plugin mark-failed', + risk_level='high', + supports_dry_run=False, + ), } DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY = DangerousCommandRuleRegistry(rules=DEFAULT_DANGEROUS_COMMAND_RULES) diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/__init__.py b/ruoyi-fastapi-backend/cli/runtime/plugin/__init__.py new file mode 100644 index 0000000..57912c5 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/__init__.py @@ -0,0 +1,3 @@ +from .service import PLUGIN_RUNTIME, CliPluginRuntimeService + +__all__ = ['PLUGIN_RUNTIME', 'CliPluginRuntimeService'] diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/gateway.py b/ruoyi-fastapi-backend/cli/runtime/plugin/gateway.py new file mode 100644 index 0000000..80b1200 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/gateway.py @@ -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) diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/__init__.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/__init__.py new file mode 100644 index 0000000..9c359a4 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/__init__.py @@ -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', +] diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py new file mode 100644 index 0000000..8c587e4 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py @@ -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 && 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 +/plugins/{plugin_id}/views/index.vue +``` +""" diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py new file mode 100644 index 0000000..c651e23 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py @@ -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 diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py new file mode 100644 index 0000000..7b418fc --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py @@ -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'(? 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""" +""" + + @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""" + + +""" + + @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}') diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_vue2.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_vue2.py new file mode 100644 index 0000000..1b35e95 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_vue2.py @@ -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""" +""" + + @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""" + + +""" + + @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') +""" diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/naming.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/naming.py new file mode 100644 index 0000000..ee49ff5 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/naming.py @@ -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) diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/options.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/options.py new file mode 100644 index 0000000..c9a8a88 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/options.py @@ -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}') diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py new file mode 100644 index 0000000..687185e --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py @@ -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() diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/service.py b/ruoyi-fastapi-backend/cli/runtime/plugin/service.py new file mode 100644 index 0000000..1cd6543 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/service.py @@ -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() diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/support.py b/ruoyi-fastapi-backend/cli/runtime/plugin/support.py new file mode 100644 index 0000000..fb3da05 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/support.py @@ -0,0 +1,1044 @@ +import base64 +import hashlib +import re +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + +import yaml + +from cli.exit_codes import RUNTIME_ERROR, SUCCESS +from plugins.core.utils import validate_plugin_id_value + +DEPENDENCY_OPERATOR_PATTERN = re.compile(r'==|!=|>=|<=|=|>|<|\^|~') +PYTHON_PACKAGE_SEPARATOR_PATTERN = re.compile(r'[-_.]+') +NPM_LOCKFILE_INTEGRITY_ALGORITHM = 'sha512' +PLUGIN_DEPENDENCY_ALLOWLIST_EXAMPLE_YAML = """# 插件外部依赖允许列表示例。 +# +# 使用方式: +# 1. 将本文件保存为 plugin_dependency_allowlist.yaml。 +# 2. 按团队实际批准的 Python/npm 包和版本范围调整。 +# 3. 在 .env.* 中配置 PLUGIN_DEPENDENCY_ALLOWLIST 指向该文件。 +# +# 版本范围应尽量写成可证明的连续闭合范围,例如 ">=2.0.0,<3.0.0"。 +# 未声明上界、通配版本、!=、^、~ 等无法证明完全包含的约束会被策略保守阻断。 + +python: + openai: + versions: + - ">=2.0.0,<3.0.0" + source: internal-pypi + reason: 示例:仅允许 OpenAI SDK 2.x + requests: + versions: + - ">=2.32.0,<3.0.0" + source: internal-pypi + reason: 示例:允许 requests 2.x 安全维护版本 +npm: + dayjs: + versions: + - ">=1.11.0,<2.0.0" + source: internal-npm + reason: 示例:允许 dayjs 1.x +npmDev: + vitest: + versions: + - ">=3.0.0,<4.0.0" + source: internal-npm + reason: 示例:仅允许开发依赖测试工具 +""" + + +@dataclass(frozen=True) +class PluginTestTarget: + """ + 插件测试目标。 + + :param kind: 测试目标类型 + :param target_path: 测试目标路径 + :param command: 测试执行命令 + :param workdir: 命令工作目录 + :param timeout: 命令超时时间 + """ + + kind: str + target_path: Path + command: list[str] + workdir: Path + timeout: int + + +@dataclass(frozen=True) +class PluginTestCommandResultPayload: + """ + 插件测试命令执行结果负载。 + """ + + completed: Any + + def to_payload(self) -> dict[str, Any]: + """ + 序列化为既有 CLI payload 契约。 + + :return: 插件测试命令执行结果负载 + """ + return { + 'returnCode': self.completed.returncode, + 'stdout': self.completed.stdout[-4000:] if self.completed.stdout else '', + 'stderr': self.completed.stderr[-4000:] if self.completed.stderr else '', + } + + +@dataclass(frozen=True) +class PluginTestResultItemPayload: + """ + 插件测试单目标执行结果负载。 + """ + + target: PluginTestTarget + completed: Any + + def to_payload(self) -> dict[str, Any]: + """ + 序列化为既有 CLI payload 契约。 + + :return: 插件测试单目标执行结果负载 + """ + return { + 'kind': self.target.kind, + 'target': str(self.target.target_path), + 'command': self.target.command, + 'workdir': str(self.target.workdir), + 'test': PluginTestCommandResultPayload(self.completed).to_payload(), + } + + +@dataclass(frozen=True) +class PluginTestMissingPayload: + """ + 插件测试目标缺失负载。 + """ + + plugin_id: str + expected_paths: list[Path] + + def to_payload(self) -> dict[str, Any]: + """ + 序列化为既有 CLI payload 契约。 + + :return: 插件测试目标缺失负载 + """ + return { + 'ok': False, + 'message': '插件测试目录不存在', + 'pluginId': self.plugin_id, + 'targets': [str(path) for path in self.expected_paths], + } + + +@dataclass(frozen=True) +class PluginTestExecutionPayload: + """ + 插件测试执行结果负载。 + """ + + plugin_id: str + keyword: str + maxfail: int + quiet: bool + frontend_build: bool + results: list[dict[str, Any]] + + def to_payload(self) -> dict[str, Any]: + """ + 序列化为既有 CLI payload 契约。 + + :return: 插件测试执行结果负载 + """ + ok = all(item['test']['returnCode'] == 0 for item in self.results) + + return { + 'ok': ok, + 'message': '插件测试执行完成' if ok else '插件测试执行失败', + 'pluginId': self.plugin_id, + 'targets': [item['target'] for item in self.results], + 'keyword': self.keyword, + 'maxfail': self.maxfail, + 'quiet': self.quiet, + 'frontendBuild': self.frontend_build, + 'results': self.results, + 'test': self.results[0]['test'] if len(self.results) == 1 else None, + 'command': self.results[0]['command'] if len(self.results) == 1 else None, + } + + +@dataclass(frozen=True) +class CliPluginRuntimeExceptionPayload: + """ + CLI 插件运行时异常负载。 + """ + + exception_payload: dict[str, Any] + failure_code: int = RUNTIME_ERROR + + def to_payload(self) -> dict[str, Any]: + """ + 序列化为既有 CLI payload 契约。 + + :return: CLI 插件运行时异常负载 + """ + return {**self.exception_payload, 'exit_code': self.failure_code} + + +@dataclass(frozen=True) +class CliPluginRuntimeExitCodePayload: + """ + CLI 插件运行时退出码负载。 + """ + + payload: dict[str, Any] + success_code: int = SUCCESS + failure_code: int = RUNTIME_ERROR + + def to_payload(self) -> dict[str, Any]: + """ + 序列化为既有 CLI payload 契约。 + + :return: 带退出码的 CLI 插件运行时负载 + """ + return {**self.payload, 'exit_code': self.success_code if self.payload.get('ok') else self.failure_code} + + +@dataclass(frozen=True) +class PluginDependencyLockfileTemplate: + """ + 插件依赖锁文件模板。 + + :param plugin_id: 插件ID + :param plugin_version: 插件版本 + :param generated_at: 生成时间 + :param python: Python 依赖锁定项 + :param npm: npm 依赖锁定项 + :param npm_dev: npmDev 依赖锁定项 + :param artifact_count: 已从离线制品反填的依赖项数量 + :param warnings: 生成过程告警 + """ + + plugin_id: str + plugin_version: str + generated_at: str + python: list[dict[str, object]] + npm: list[dict[str, object]] + npm_dev: list[dict[str, object]] + artifact_count: int = 0 + warnings: list[str] = field(default_factory=list) + + @property + def entry_count(self) -> int: + """ + 获取锁文件模板依赖项数量。 + + :return: 依赖项数量 + """ + return len(self.python) + len(self.npm) + len(self.npm_dev) + + def to_dict(self) -> dict[str, object]: + """ + 序列化为锁文件 YAML 字典。 + + :return: 锁文件字典 + """ + return { + 'plugin': self.plugin_id, + 'version': self.plugin_version, + 'generatedAt': self.generated_at, + 'python': self.python, + 'npm': self.npm, + 'npmDev': self.npm_dev, + } + + def to_yaml(self) -> str: + """ + 序列化为锁文件 YAML 文本。 + + :return: YAML 文本 + """ + return yaml.safe_dump(self.to_dict(), allow_unicode=True, sort_keys=False) + + +class PluginDependencyLockfileTemplateBuilder: + """ + 插件依赖锁文件模板构建器。 + + 该构建器只根据 manifest 声明生成待补全模板,不联网解析真实版本或哈希。 + """ + + @classmethod + def build(cls, manifest: Any, *, offline_dir: Path | str | None = None) -> PluginDependencyLockfileTemplate: + """ + 根据插件 manifest 构建锁文件模板。 + + :param manifest: 插件 manifest + :param offline_dir: 离线制品根目录 + :return: 锁文件模板 + """ + generated_at = datetime.now().astimezone().isoformat() + dependencies = manifest.dependencies + artifact_resolver = PluginDependencyOfflineArtifactResolver(offline_dir) + warnings: list[str] = [] + artifact_count = 0 + python_entries = [] + npm_entries = [] + npm_dev_entries = [] + for requirement in dependencies.python: + entry, artifact_warning = cls._build_python_entry(requirement, artifact_resolver=artifact_resolver) + python_entries.append(entry) + artifact_count += 1 if entry.get('resolvedVersion') else 0 + if artifact_warning: + warnings.append(artifact_warning) + for requirement in dependencies.npm: + entry, artifact_warning = cls._build_npm_entry( + requirement, + kind='npm', + artifact_resolver=artifact_resolver, + ) + npm_entries.append(entry) + artifact_count += 1 if entry.get('resolvedVersion') else 0 + if artifact_warning: + warnings.append(artifact_warning) + for requirement in dependencies.npm_dev: + entry, artifact_warning = cls._build_npm_entry( + requirement, + kind='npmDev', + artifact_resolver=artifact_resolver, + ) + npm_dev_entries.append(entry) + artifact_count += 1 if entry.get('resolvedVersion') else 0 + if artifact_warning: + warnings.append(artifact_warning) + return PluginDependencyLockfileTemplate( + plugin_id=manifest.id, + plugin_version=manifest.version, + generated_at=generated_at, + python=python_entries, + npm=npm_entries, + npm_dev=npm_dev_entries, + artifact_count=artifact_count, + warnings=warnings, + ) + + @staticmethod + def _extract_dependency_name(requirement: str) -> str: + """ + 提取依赖包名。 + + :param requirement: 依赖声明 + :return: 依赖包名 + """ + from plugins.core.validation.dependencies import DependencyRequirementParser # noqa: PLC0415 + + normalized_requirement = requirement.strip() + operator_match = DEPENDENCY_OPERATOR_PATTERN.search(normalized_requirement) + if operator_match: + package_name = normalized_requirement[: operator_match.start()].strip() + if package_name: + return package_name + return DependencyRequirementParser.parse(requirement).name + + @classmethod + def _build_python_entry( + cls, + requirement: str, + *, + artifact_resolver: 'PluginDependencyOfflineArtifactResolver', + ) -> tuple[dict[str, object], str | None]: + """ + 构建 Python 锁文件模板项。 + + :param requirement: Python 依赖声明 + :param artifact_resolver: 离线制品解析器 + :return: 锁文件模板项和告警 + """ + name = cls._extract_dependency_name(requirement) + artifact_match, artifact_warning = artifact_resolver.resolve_python(name, requirement) + return { + 'name': name, + 'requirement': requirement, + 'resolvedVersion': artifact_match.version if artifact_match else '', + 'hashes': artifact_match.hashes if artifact_match else [], + }, artifact_warning + + @classmethod + def _build_npm_entry( + cls, + requirement: str, + *, + kind: str, + artifact_resolver: 'PluginDependencyOfflineArtifactResolver', + ) -> tuple[dict[str, object], str | None]: + """ + 构建 npm 锁文件模板项。 + + :param requirement: npm 依赖声明 + :param kind: npm 依赖类型 + :param artifact_resolver: 离线制品解析器 + :return: 锁文件模板项和告警 + """ + name = cls._extract_dependency_name(requirement) + artifact_match, artifact_warning = artifact_resolver.resolve_npm(kind, name, requirement) + return { + 'name': name, + 'requirement': requirement, + 'resolvedVersion': artifact_match.version if artifact_match else '', + 'integrity': artifact_match.integrity if artifact_match else '', + }, artifact_warning + + +@dataclass(frozen=True) +class PluginDependencyOfflineArtifactMatch: + """ + 离线制品匹配结果。 + + :param path: 制品路径 + :param version: 制品版本 + :param hashes: Python 制品哈希 + :param integrity: npm 制品 SRI + """ + + path: Path + version: str + hashes: list[str] = field(default_factory=list) + integrity: str = '' + + +class PluginDependencyOfflineArtifactResolver: + """ + 插件离线依赖制品解析器。 + """ + + def __init__(self, offline_dir: Path | str | None = None) -> None: + """ + 初始化离线依赖制品解析器。 + + :param offline_dir: 离线制品根目录 + :return: None + """ + self.offline_dir = Path(offline_dir) if offline_dir else None + + def resolve_python( + self, + name: str, + requirement: str, + ) -> tuple[PluginDependencyOfflineArtifactMatch | None, str | None]: + """ + 匹配 Python 离线制品并生成哈希。 + + :param name: 依赖包名 + :param requirement: 依赖声明 + :return: 制品匹配结果和告警 + """ + if self.offline_dir is None: + return None, None + artifact_dir = self._artifact_dir('python') + candidates = ( + [ + (artifact_path, version) + for artifact_path in sorted(artifact_dir.iterdir()) + if (version := self._extract_python_artifact_version(artifact_path, name)) + ] + if artifact_dir is not None + else [] + ) + return self._build_python_match('python', name, requirement, candidates) + + def resolve_npm( + self, + kind: str, + name: str, + requirement: str, + ) -> tuple[PluginDependencyOfflineArtifactMatch | None, str | None]: + """ + 匹配 npm 离线制品并生成 SRI。 + + :param kind: 依赖类型 + :param name: 依赖包名 + :param requirement: 依赖声明 + :return: 制品匹配结果和告警 + """ + if self.offline_dir is None: + return None, None + artifact_dir = self._artifact_dir('npm') + normalized_name = self._normalize_npm_artifact_name(name) + candidates = ( + [ + (artifact_path, version) + for artifact_path in sorted(artifact_dir.glob(f'{normalized_name}-*.tgz')) + if (version := self._extract_npm_artifact_version(artifact_path, normalized_name)) + ] + if artifact_dir is not None + else [] + ) + return self._build_npm_match(kind, name, requirement, candidates) + + def _artifact_dir(self, kind: str) -> Path | None: + """ + 获取离线制品分类目录。 + + :param kind: 制品分类 + :return: 制品目录 + """ + if self.offline_dir is None: + return None + artifact_dir = self.offline_dir / kind + return artifact_dir if artifact_dir.is_dir() else None + + def _build_python_match( + self, + kind: str, + name: str, + requirement: str, + candidates: list[tuple[Path, str]], + ) -> tuple[PluginDependencyOfflineArtifactMatch | None, str | None]: + """ + 构建 Python 制品匹配结果。 + + :param kind: 依赖类型 + :param name: 依赖包名 + :param requirement: 依赖声明 + :param candidates: 候选制品 + :return: 制品匹配结果和告警 + """ + artifact_path, version, warning = self._select_artifact(kind, name, requirement, candidates) + if artifact_path is None or version is None: + return None, warning + artifact_hash = hashlib.sha256(artifact_path.read_bytes()).hexdigest() + return PluginDependencyOfflineArtifactMatch( + path=artifact_path, + version=version, + hashes=[f'sha256:{artifact_hash}'], + ), None + + def _build_npm_match( + self, + kind: str, + name: str, + requirement: str, + candidates: list[tuple[Path, str]], + ) -> tuple[PluginDependencyOfflineArtifactMatch | None, str | None]: + """ + 构建 npm 制品匹配结果。 + + :param kind: 依赖类型 + :param name: 依赖包名 + :param requirement: 依赖声明 + :param candidates: 候选制品 + :return: 制品匹配结果和告警 + """ + artifact_path, version, warning = self._select_artifact(kind, name, requirement, candidates) + if artifact_path is None or version is None: + return None, warning + integrity_digest = base64.b64encode( + hashlib.new(NPM_LOCKFILE_INTEGRITY_ALGORITHM, artifact_path.read_bytes()).digest() + ).decode('ascii') + return PluginDependencyOfflineArtifactMatch( + path=artifact_path, + version=version, + integrity=f'{NPM_LOCKFILE_INTEGRITY_ALGORITHM}-{integrity_digest}', + ), None + + def _select_artifact( + self, + kind: str, + name: str, + requirement: str, + candidates: list[tuple[Path, str]], + ) -> tuple[Path | None, str | None, str | None]: + """ + 从候选制品中选择一个满足声明的制品。 + + :param kind: 依赖类型 + :param name: 依赖包名 + :param requirement: 依赖声明 + :param candidates: 候选制品 + :return: 制品路径、版本和告警 + """ + if not candidates: + return None, None, f'未找到离线制品:{kind} {name}' + matching_candidates = [ + (artifact_path, version) + for artifact_path, version in candidates + if self._artifact_version_satisfies_requirement(version, requirement) + ] + if not matching_candidates: + candidate_versions = ', '.join(version for _, version in candidates) + return None, None, f'离线制品版本不满足声明:{kind} {name} {candidate_versions}' + if len(matching_candidates) > 1: + return None, None, f'离线制品不唯一:{kind} {name}' + artifact_path, version = matching_candidates[0] + return artifact_path, version, None + + @staticmethod + def _artifact_version_satisfies_requirement(version: str, requirement: str) -> bool: + """ + 判断制品版本是否满足 manifest 声明。 + + :param version: 制品版本 + :param requirement: 依赖声明 + :return: 是否满足声明 + """ + operator_match = DEPENDENCY_OPERATOR_PATTERN.search(requirement.strip()) + if operator_match is None: + return True + version_range = requirement[operator_match.start() :] + from plugins.core.validation.dependency_policy import version_satisfies_range # noqa: PLC0415 + + return version_satisfies_range(version, version_range) + + @classmethod + def _extract_python_artifact_version(cls, artifact_path: Path, name: str) -> str | None: + """ + 从 Python wheel/sdist 文件名中提取版本。 + + :param artifact_path: 制品路径 + :param name: 依赖包名 + :return: 制品版本 + """ + if artifact_path.suffix == '.whl': + return cls._extract_python_version_from_parts(artifact_path.stem.split('-'), name) + if artifact_path.name.endswith('.tar.gz'): + return cls._extract_python_version_from_parts(artifact_path.name.removesuffix('.tar.gz').split('-'), name) + return None + + @classmethod + def _extract_python_version_from_parts(cls, parts: list[str], name: str) -> str | None: + """ + 从 Python 制品文件名片段中提取版本。 + + :param parts: 文件名按短横线分隔后的片段 + :param name: 依赖包名 + :return: 制品版本 + """ + normalized_name = cls._normalize_python_artifact_name(name) + for index in range(1, len(parts)): + candidate_name = '-'.join(parts[:index]) + if cls._normalize_python_artifact_name(candidate_name) == normalized_name: + return parts[index] or None + return None + + @staticmethod + def _extract_npm_artifact_version(artifact_path: Path, normalized_name: str) -> str | None: + """ + 从 npm tgz 文件名中提取版本。 + + :param artifact_path: 制品路径 + :param normalized_name: 归一化 npm 制品包名 + :return: 制品版本 + """ + stem = artifact_path.name.removesuffix('.tgz') + expected_prefix = f'{normalized_name}-' + if not stem.startswith(expected_prefix): + return None + return stem.removeprefix(expected_prefix) or None + + @staticmethod + def _normalize_python_artifact_name(name: str) -> str: + """ + 归一化 Python 制品名。 + + :param name: 包名或文件名片段 + :return: 归一化名称 + """ + return PYTHON_PACKAGE_SEPARATOR_PATTERN.sub('-', name.strip()).lower() + + @staticmethod + def _normalize_npm_artifact_name(name: str) -> str: + """ + 归一化 npm 制品名。 + + :param name: 包名 + :return: 归一化名称 + """ + return name.strip().lower().replace('/', '-').lstrip('@') + + +class PluginDependencyLockPayloadBuilder: + """ + 插件依赖锁文件模板 payload 构建器。 + """ + + @staticmethod + def build_success_payload( + plugin_id: str, + lockfile_template: PluginDependencyLockfileTemplate, + output_path: Path, + *, + dry_run: bool, + written: bool, + overwritten: bool, + ) -> dict[str, object]: + """ + 构建锁文件模板生成成功 payload。 + + :param plugin_id: 插件ID + :param lockfile_template: 锁文件模板 + :param output_path: 输出路径 + :param dry_run: 是否仅预演 + :param written: 是否已写入文件 + :param overwritten: 是否覆盖了已有文件 + :return: payload + """ + return CliPluginRuntimeExitCodePayload( + { + 'ok': True, + 'message': '插件依赖锁文件模板生成完成', + 'pluginId': plugin_id, + 'dryRun': dry_run, + 'outputFile': str(output_path), + 'written': written, + 'overwritten': overwritten, + 'entryCount': lockfile_template.entry_count, + 'artifactCount': lockfile_template.artifact_count, + 'lockfile': lockfile_template.to_yaml(), + 'warnings': lockfile_template.warnings + or ( + [] + if lockfile_template.artifact_count == lockfile_template.entry_count + else [ + '锁文件模板尚未包含 resolvedVersion、hashes 或 integrity,需人工或 CI 补全后才能用于 locked/offline 策略。' + ] + ), + }, + success_code=SUCCESS, + failure_code=RUNTIME_ERROR, + ).to_payload() + + @staticmethod + def build_exists_payload(plugin_id: str, output_path: Path) -> dict[str, object]: + """ + 构建锁文件已存在 payload。 + + :param plugin_id: 插件ID + :param output_path: 输出路径 + :return: payload + """ + return CliPluginRuntimeExitCodePayload( + { + 'ok': False, + 'message': '插件依赖锁文件已存在,请传入 --overwrite 覆盖', + 'pluginId': plugin_id, + 'dryRun': False, + 'outputFile': str(output_path), + 'written': False, + }, + success_code=SUCCESS, + failure_code=RUNTIME_ERROR, + ).to_payload() + + @staticmethod + def build_not_found_payload(plugin_id: str) -> dict[str, object]: + """ + 构建插件不存在 payload。 + + :param plugin_id: 插件ID + :return: payload + """ + return CliPluginRuntimeExitCodePayload( + { + 'ok': False, + 'message': '插件不存在', + 'pluginId': plugin_id, + 'dryRun': False, + 'written': False, + }, + success_code=SUCCESS, + failure_code=RUNTIME_ERROR, + ).to_payload() + + +class PluginDependencyAllowlistExamplePayloadBuilder: + """ + 插件依赖允许列表示例 payload 构建器。 + """ + + @staticmethod + def build_success_payload( + output_path: Path, + *, + allowlist_text: str, + dry_run: bool, + written: bool, + overwritten: bool, + ) -> dict[str, object]: + """ + 构建允许列表示例生成成功 payload。 + + :param output_path: 输出路径 + :param allowlist_text: 允许列表 YAML 文本 + :param dry_run: 是否仅预演 + :param written: 是否已写入文件 + :param overwritten: 是否覆盖了已有文件 + :return: payload + """ + return CliPluginRuntimeExitCodePayload( + { + 'ok': True, + 'message': '插件依赖允许列表示例生成完成', + 'dryRun': dry_run, + 'outputFile': str(output_path), + 'written': written, + 'overwritten': overwritten, + 'allowlist': allowlist_text, + }, + success_code=SUCCESS, + failure_code=RUNTIME_ERROR, + ).to_payload() + + @staticmethod + def build_exists_payload(output_path: Path) -> dict[str, object]: + """ + 构建允许列表示例文件已存在 payload。 + + :param output_path: 输出路径 + :return: payload + """ + return CliPluginRuntimeExitCodePayload( + { + 'ok': False, + 'message': '插件依赖允许列表文件已存在,请传入 --overwrite 覆盖', + 'dryRun': False, + 'outputFile': str(output_path), + 'written': False, + }, + success_code=SUCCESS, + failure_code=RUNTIME_ERROR, + ).to_payload() + + +class PluginTestPlanBuilder: + """ + 插件 CLI 测试计划构建器。 + + 使用 Builder 模式发现后端 pytest 与前端 node 测试目标,并生成可执行命令。 + """ + + def __init__( + self, + *, + backend_root: Path, + frontend_root: Path, + python_executable: str, + node_executable: str = 'node', + timeout: int = 120, + ) -> None: + """ + 初始化插件 CLI 测试计划构建器。 + + :param backend_root: 后端项目根目录 + :param frontend_root: 前端项目根目录 + :param python_executable: Python 解释器 + :param node_executable: Node.js 可执行命令 + :param timeout: 命令超时时间 + :return: None + """ + self.backend_root = backend_root + self.frontend_root = frontend_root + self.python_executable = python_executable + self.node_executable = node_executable + self.timeout = timeout + + def build( + self, + plugin_id: str, + *, + keyword: str = '', + maxfail: int = 0, + quiet: bool = False, + frontend_build: bool = False, + ) -> list[PluginTestTarget]: + """ + 构建插件测试目标列表。 + + :param plugin_id: 插件ID + :param keyword: pytest `-k` 过滤表达式 + :param maxfail: pytest 最大失败数 + :param quiet: pytest 是否启用简洁输出 + :param frontend_build: 是否追加前端构建验收目标 + :return: 插件测试目标列表 + """ + validate_plugin_id_value(plugin_id) + targets = [] + backend_target = self.backend_root / 'tests' / 'plugins' / plugin_id + if backend_target.exists(): + targets.append( + PluginTestTarget( + kind='backend', + target_path=backend_target, + command=self._build_backend_command( + backend_target, + keyword=keyword, + maxfail=maxfail, + quiet=quiet, + ), + workdir=self.backend_root, + timeout=self.timeout, + ) + ) + + frontend_target = self.frontend_root / 'tests' / 'plugins' / plugin_id + if frontend_target.exists(): + targets.extend( + [ + PluginTestTarget( + kind='frontend', + target_path=test_file, + command=[self.node_executable, str(test_file)], + workdir=self.frontend_root, + timeout=self.timeout, + ) + for test_file in sorted(frontend_target.glob('*.test.js')) + ] + ) + if frontend_build and self.frontend_root.exists(): + targets.append( + PluginTestTarget( + kind='frontend-build', + target_path=self.frontend_root, + command=['npm', 'run', 'build:stage'], + workdir=self.frontend_root, + timeout=max(self.timeout, 300), + ) + ) + + return targets + + def expected_paths(self, plugin_id: str) -> list[Path]: + """ + 获取插件测试约定目录列表。 + + :param plugin_id: 插件ID + :return: 插件测试约定目录列表 + """ + return [ + self.backend_root / 'tests' / 'plugins' / plugin_id, + self.frontend_root / 'tests' / 'plugins' / plugin_id, + ] + + def _build_backend_command( + self, + target_path: Path, + *, + keyword: str, + maxfail: int, + quiet: bool, + ) -> list[str]: + """ + 构建插件 pytest 命令。 + + :param target_path: 测试目标路径 + :param keyword: pytest `-k` 过滤表达式 + :param maxfail: 最大失败数 + :param quiet: 是否启用简洁输出 + :return: pytest 命令参数列表 + """ + command = [self.python_executable, '-m', 'pytest'] + if quiet: + command.append('-q') + if keyword: + command.extend(['-k', keyword]) + if maxfail > 0: + command.append(f'--maxfail={maxfail}') + command.append(str(target_path)) + + return command + + +class PluginTestPayloadBuilder: + """ + 插件 CLI 测试负载构建器。 + + 使用 Builder 模式将测试目标和命令结果转换为稳定命令负载。 + """ + + @staticmethod + def build_command_result(completed: Any) -> dict[str, Any]: + """ + 构建 CLI 系统命令执行结果负载。 + + :param completed: 命令执行结果 + :return: 系统命令执行结果负载 + """ + return PluginTestCommandResultPayload(completed).to_payload() + + @staticmethod + def build_result_item(target: PluginTestTarget, completed: Any) -> dict[str, Any]: + """ + 构建单个插件测试结果项。 + + :param target: 插件测试目标 + :param completed: 命令执行结果 + :return: 插件测试结果项 + """ + return PluginTestResultItemPayload(target, completed).to_payload() + + @staticmethod + def with_exit_code( + payload: dict[str, Any], + *, + success_code: int = SUCCESS, + failure_code: int = RUNTIME_ERROR, + ) -> dict[str, Any]: + """ + 为插件测试负载补充退出码。 + + :param payload: 插件测试负载 + :param success_code: 成功退出码 + :param failure_code: 失败退出码 + :return: 带退出码的插件测试负载 + """ + return CliPluginRuntimeExitCodePayload( + payload, + success_code=success_code, + failure_code=failure_code, + ).to_payload() + + @staticmethod + def build_missing_payload(plugin_id: str, expected_paths: list[Path]) -> dict[str, Any]: + """ + 构建插件测试目标缺失负载。 + + :param plugin_id: 插件ID + :param expected_paths: 约定测试目录列表 + :return: 插件测试目标缺失负载 + """ + return PluginTestMissingPayload(plugin_id, expected_paths).to_payload() + + @staticmethod + def build_execution_payload( + plugin_id: str, + *, + keyword: str, + maxfail: int, + quiet: bool, + frontend_build: bool, + results: list[dict[str, Any]], + ) -> dict[str, Any]: + """ + 构建插件测试执行结果负载。 + + :param plugin_id: 插件ID + :param keyword: pytest `-k` 过滤表达式 + :param maxfail: 最大失败数 + :param quiet: 是否启用简洁输出 + :param frontend_build: 是否执行前端构建验收 + :param results: 测试结果项列表 + :return: 插件测试执行结果负载 + """ + return PluginTestExecutionPayload( + plugin_id=plugin_id, + keyword=keyword, + maxfail=maxfail, + quiet=quiet, + frontend_build=frontend_build, + results=results, + ).to_payload() diff --git a/ruoyi-fastapi-backend/common/constant.py b/ruoyi-fastapi-backend/common/constant.py index 4385cff..0487c12 100644 --- a/ruoyi-fastapi-backend/common/constant.py +++ b/ruoyi-fastapi-backend/common/constant.py @@ -143,6 +143,22 @@ class LockConstant: APP_STARTUP_LOCK_KEY = 'app:startup:lock' LOCK_EXPIRE_SECONDS = 60 LOCK_RENEWAL_INTERVAL = 20 + PLUGIN_STARTUP_READY_KEY = 'plugin:startup:ready' + PLUGIN_STARTUP_READY_EXPIRE_SECONDS = 2592000 + PLUGIN_STARTUP_FAILED_EXPIRE_SECONDS = 60 + PLUGIN_STARTUP_READY_WAIT_TIMEOUT_SECONDS = 1800 + PLUGIN_STARTUP_READY_WAIT_INTERVAL_SECONDS = 1 + PLUGIN_LIFECYCLE_LOCK_PREFIX = 'plugin:lifecycle:lock' + PLUGIN_LIFECYCLE_LOCK_EXPIRE_SECONDS = 1800 + + +class PluginRuntimeConstant: + """ + 插件运行时常量。 + """ + + PLUGIN_HOOK_TIMEOUT_SECONDS = 30 + PLUGIN_HEALTH_TIMEOUT_SECONDS = 5 class ApiNamespace: diff --git a/ruoyi-fastapi-backend/common/router.py b/ruoyi-fastapi-backend/common/router.py index f7106c5..77f7078 100644 --- a/ruoyi-fastapi-backend/common/router.py +++ b/ruoyi-fastapi-backend/common/router.py @@ -355,15 +355,20 @@ class RouterRegister: return sorted(routers, key=sort_key) - def _register_routers_to_app(self, routers: list[tuple[str, APIRouter]]) -> None: + def _register_routers_to_app( + self, + routers: list[tuple[str, APIRouter]], + dependencies: Sequence[params.Depends] | None = None, + ) -> None: """ 将路由注册到FastAPI应用 :param routers: 排序后的路由实例列表 + :param dependencies: 注册时附加到路由上的依赖项 :return: None """ for _attr_name, router in routers: - self.app.include_router(router=router) + self.app.include_router(router=router, dependencies=dependencies) def register_routers(self) -> None: """ @@ -373,12 +378,26 @@ class RouterRegister: """ # 查找所有controller目录下的py文件 controller_files = self._find_controller_files() + self._register_controller_files(controller_files) + + def _register_controller_files( + self, + controller_files: list[str], + dependencies: Sequence[params.Depends] | None = None, + ) -> None: + """ + 注册指定 controller 文件中的路由。 + + :param controller_files: controller文件路径列表 + :param dependencies: 注册时附加到路由上的依赖项 + :return: None + """ # 导入模块并获取路由实例 routers = self._import_module_and_get_routers(controller_files) # 按规则排序路由 sorted_routers = self._sort_routers(routers) # 注册路由到FastAPI应用 - self._register_routers_to_app(sorted_routers) + self._register_routers_to_app(sorted_routers, dependencies) def auto_register_routers(app: FastAPI) -> None: @@ -391,3 +410,20 @@ def auto_register_routers(app: FastAPI) -> None: # 使用路由注册器进行注册 router_register = RouterRegister(app) router_register.register_routers() + + +def auto_register_controller_files( + app: FastAPI, + controller_files: Sequence[str], + dependencies: Sequence[params.Depends] | None = None, +) -> None: + """ + 自动注册指定 controller 文件中的路由。 + + :param app: FastAPI对象 + :param controller_files: controller文件路径列表 + :param dependencies: 注册时附加到路由上的依赖项 + :return: None + """ + router_register = RouterRegister(app) + router_register._register_controller_files(list(controller_files), dependencies) diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index 25a7e68..ba85fcc 100644 --- a/ruoyi-fastapi-backend/config/env.py +++ b/ruoyi-fastapi-backend/config/env.py @@ -20,6 +20,7 @@ class AppSettings(BaseSettings): app_host: str = '0.0.0.0' app_port: int = 9099 app_version: str = '1.0.0' + app_release_id: str = '' app_reload: bool = True app_workers: int = 1 app_ip_location_query: bool = True @@ -29,6 +30,7 @@ class AppSettings(BaseSettings): app_disable_redoc: bool = False app_trusted_proxy_ips: str = '127.0.0.1,::1' app_trusted_proxy_hops: int = 1 + app_default_enabled_plugins: str = 'ai' class JwtSettings(BaseSettings): @@ -145,6 +147,24 @@ class TransportCryptoSettings(BaseSettings): ) +class PluginDependencyPolicySettings(BaseSettings): + """ + 插件依赖安装策略配置 + """ + + plugin_dependency_policy_mode: str = 'dev=explicit,test=plan_only,stage=locked,prod=plan_only' + plugin_dependency_allow_prod_install: bool = False + plugin_dependency_require_yes: bool = True + plugin_dependency_require_allowlist: bool | None = None + plugin_dependency_require_lockfile: bool | None = None + plugin_dependency_lockfile: str = '' + plugin_dependency_allowlist: str = '' + plugin_dependency_offline_dir: str = '' + plugin_dependency_pip_index_url: str = '' + plugin_dependency_npm_registry: str = '' + plugin_dependency_install_timeout: int = 600 + + class GenSettings: """ 代码生成配置 @@ -276,6 +296,12 @@ class GetConfig: """ return TransportCryptoSettings() + def get_plugin_dependency_policy_config(self) -> PluginDependencyPolicySettings: + """ + 获取插件依赖安装策略配置 + """ + return PluginDependencyPolicySettings() + def get_gen_config(self) -> GenSettings: """ 获取代码生成配置 @@ -339,6 +365,8 @@ RedisConfig = get_config.get_redis_config() LogConfig = get_config.get_log_config() # 传输层加解密配置 TransportCryptoConfig = get_config.get_transport_crypto_config() +# 插件依赖安装策略配置 +PluginDependencyPolicyConfig = get_config.get_plugin_dependency_policy_config() # 代码生成配置 GenConfig = get_config.get_gen_config() # 上传配置 diff --git a/ruoyi-fastapi-backend/config/get_db.py b/ruoyi-fastapi-backend/config/get_db.py index 43f838a..547b2bd 100644 --- a/ruoyi-fastapi-backend/config/get_db.py +++ b/ruoyi-fastapi-backend/config/get_db.py @@ -1,4 +1,5 @@ from collections.abc import AsyncGenerator +from typing import Literal from sqlalchemy.ext.asyncio import AsyncSession @@ -16,16 +17,26 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: yield current_db -async def init_create_table() -> None: +async def init_create_table( + *, + stage: Literal['platform', 'plugin_entities'] = 'platform', + log_success_enabled: bool = True, +) -> None: """ - 应用启动时初始化数据库连接 + 应用启动时初始化数据库元数据。 - :return: + :param stage: 建表阶段 + :param log_success_enabled: 是否输出阶段成功摘要 + :return: None """ - logger.info('🔎 初始化数据库连接...') + if log_success_enabled: + message = '🔎 初始化平台数据库元数据...' if stage == 'platform' else '🔎 同步插件实体表...' + logger.bind(database_init_stage=stage).info(message) async with async_engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - logger.info('✅️ 数据库连接成功') + if log_success_enabled: + message = '✅️ 平台数据库元数据初始化完成' if stage == 'platform' else '✅️ 插件实体表同步完成' + logger.bind(database_init_stage=stage).info(message) async def close_async_engine() -> None: diff --git a/ruoyi-fastapi-backend/config/get_redis.py b/ruoyi-fastapi-backend/config/get_redis.py index 559c677..ef1e6ba 100644 --- a/ruoyi-fastapi-backend/config/get_redis.py +++ b/ruoyi-fastapi-backend/config/get_redis.py @@ -41,36 +41,42 @@ class RedisUtil: @classmethod async def check_redis_connection( - cls, redis: aioredis.Redis, log_enabled: bool = True, log_start_enabled: bool | None = None + cls, + redis: aioredis.Redis, + log_enabled: bool = True, + log_start_enabled: bool | None = None, + log_error_enabled: bool | None = None, ) -> None: """ 检查redis连接状态 :param redis: redis对象 - :param log_enabled: 是否输出日志 + :param log_enabled: 是否输出成功日志 :param log_start_enabled: 是否输出开始连接日志 + :param log_error_enabled: 是否输出错误日志,未指定时沿用log_enabled :return: None """ if log_start_enabled is None: log_start_enabled = log_enabled + if log_error_enabled is None: + log_error_enabled = log_enabled if log_start_enabled: logger.info('🔎 开始连接redis...') try: connection = await redis.ping() - if not log_enabled: - return if connection: - logger.info('✅️ redis连接成功') - else: + if log_enabled: + logger.info('✅️ redis连接成功') + elif log_error_enabled: logger.error('❌️ redis连接失败') except AuthenticationError as e: - if log_enabled: + if log_error_enabled: logger.error(f'❌️ redis用户名或密码错误,详细错误信息:{e}') except RedisTimeoutError as e: - if log_enabled: + if log_error_enabled: logger.error(f'❌️ redis连接超时,详细错误信息:{e}') except RedisError as e: - if log_enabled: + if log_error_enabled: logger.error(f'❌️ redis连接错误,详细错误信息:{e}') @classmethod @@ -82,7 +88,7 @@ class RedisUtil: :return: """ await app.state.redis.close() - logger.info('✅️ 关闭redis连接成功') + logger.debug('✅️ 关闭redis连接成功') @classmethod async def init_sys_dict(cls, redis: FastAPI) -> None: diff --git a/ruoyi-fastapi-backend/config/get_scheduler.py b/ruoyi-fastapi-backend/config/get_scheduler.py index a60f31c..7016714 100644 --- a/ruoyi-fastapi-backend/config/get_scheduler.py +++ b/ruoyi-fastapi-backend/config/get_scheduler.py @@ -1,6 +1,7 @@ import asyncio import importlib import json +import random from asyncio import iscoroutinefunction from collections.abc import Callable from datetime import datetime, timedelta @@ -17,6 +18,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.combining import OrTrigger from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger +from apscheduler.util import obj_to_ref from redis import asyncio as aioredis from sqlalchemy.engine import Engine from sqlalchemy.ext.asyncio import AsyncEngine @@ -115,8 +117,10 @@ class SchedulerUtil: # 分布式锁相关类变量 _is_leader: bool = False _worker_id: str = WorkerIdUtil.get_worker_id(LogConfig.log_worker_id) + _application_lock_owner_token: str = StartupUtil.get_application_lock_owner_token(_worker_id) + _application_lock_renewal_task: asyncio.Task | None = None _redis: aioredis.Redis | None = None - _job_update_time_cache: dict[str, datetime] = {} + _job_update_time_cache: dict[str, datetime | None] = {} _sync_channel: str = 'scheduler:sync:request' _sync_listener_task: asyncio.Task | None = None _lock_lost_task: asyncio.Task | None = None @@ -128,6 +132,8 @@ class SchedulerUtil: _sync_min_interval_seconds: float = 2.0 _reacquire_task: asyncio.Task | None = None _reacquire_interval_seconds: float = 5.0 + _reacquire_jitter_seconds: float = 1.0 + _is_closing: bool = False _sync_async_engine: AsyncEngine | None = None _sync_async_sessionmaker: Any | None = None _disposed_sync_engines: bool = False @@ -138,6 +144,37 @@ class SchedulerUtil: _session_local: Any | None = None _scheduler_configured: bool = False + @staticmethod + def _parse_job_args(job_args: str | None) -> list[Any] | None: + """ + 解析任务位置参数。 + + :param job_args: 数据库中的任务位置参数 + :return: 位置参数列表 + """ + if not job_args: + return None + + try: + parsed_args = json.loads(job_args) + except json.JSONDecodeError: + return job_args.split(',') + + if isinstance(parsed_args, list): + return parsed_args + + return [parsed_args] + + @staticmethod + def _dump_job_args(args: tuple[Any, ...] | list[Any] | None) -> str: + """ + 序列化任务位置参数。 + + :param args: 调度器任务位置参数 + :return: 数据库存储字符串 + """ + return json.dumps(list(args), ensure_ascii=False) if args else '' + @classmethod def _get_jobstore_engine(cls) -> Engine: """ @@ -207,20 +244,81 @@ class SchedulerUtil: :return: """ cls._redis = redis - logger.info(f'🔎 Worker {cls._worker_id} 尝试获取 Application 锁...') + cls._is_closing = False + logger.debug(f'🔎 Worker {cls._worker_id} 尝试获取 Application 锁...') - acquired = await StartupUtil.acquire_startup_log_gate( + acquired = await StartupUtil.acquire_application_leader( redis=redis, lock_key=LockConstant.APP_STARTUP_LOCK_KEY, - worker_id=cls._worker_id, + owner_token=cls.get_application_lock_owner_token(), lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, ) if acquired: - await cls._start_scheduler_as_leader(redis) + await cls._activate_scheduler_as_leader(redis) else: cls._is_leader = False - logger.info(f'⏸️ Worker {cls._worker_id} 未持有 Application 锁,跳过 Scheduler 启动') + logger.debug(f'⏸️ Worker {cls._worker_id} 未持有 Application 锁,跳过 Scheduler 启动') + cls._ensure_reacquire_task() + + @classmethod + def get_application_lock_owner_token(cls) -> str: + """ + 获取当前进程的Application leader租约owner token。 + + :return: Application锁owner token + """ + cls._application_lock_owner_token = StartupUtil.get_application_lock_owner_token(cls._worker_id) + return cls._application_lock_owner_token + + @classmethod + def is_application_leader(cls) -> bool: + """ + 判断当前进程是否仍以Application leader身份运行。 + + :return: 是否为Application leader + """ + return cls._is_leader + + @classmethod + def start_application_lock_renewal(cls, redis: aioredis.Redis) -> asyncio.Task: + """ + 启动或复用当前进程的Application leader租约续期任务。 + + :param redis: Redis连接对象 + :return: Application锁续期任务 + """ + # server可能在Scheduler正式初始化前就获得租约;提前保存Redis以便启动失败时释放。 + cls._redis = redis + renewal_task = cls._application_lock_renewal_task + if renewal_task and not renewal_task.done(): + return renewal_task + cls._application_lock_renewal_task = StartupUtil.start_application_leader_renewal( + redis=redis, + lock_key=LockConstant.APP_STARTUP_LOCK_KEY, + owner_token=cls.get_application_lock_owner_token(), + lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, + interval_seconds=LockConstant.LOCK_RENEWAL_INTERVAL, + on_lock_lost=cls.on_lock_lost, + ) + return cls._application_lock_renewal_task + + @classmethod + async def stop_application_lock_renewal(cls) -> None: + """ + 停止当前进程的Application leader租约续期任务。 + + :return: None + """ + renewal_task = cls._application_lock_renewal_task + cls._application_lock_renewal_task = None + if not renewal_task or renewal_task.done(): + return + renewal_task.cancel() + try: + await renewal_task + except asyncio.CancelledError: + pass @classmethod async def _start_scheduler_as_leader(cls, redis: aioredis.Redis) -> None: @@ -242,6 +340,7 @@ class SchedulerUtil: job_list = await JobDao.get_job_list_for_scheduler(session) for item in job_list: cls._add_job_to_scheduler(item) + cls._refresh_job_update_cache(str(item.job_id), item.update_time) # 添加事件监听器 scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL) @@ -260,6 +359,33 @@ class SchedulerUtil: logger.info('✅️ 系统初始定时任务加载成功') + @classmethod + async def _activate_scheduler_as_leader(cls, redis: aioredis.Redis) -> None: + """ + 启动租约续期并以Application leader身份激活Scheduler。 + + Scheduler启动失败时立即停止续期并原子释放租约,避免故障worker继续占用 + Application leader身份。 + + :param redis: Redis连接对象 + :return: None + """ + cls.start_application_lock_renewal(redis) + try: + await cls._start_scheduler_as_leader(redis) + except Exception: + cls._is_leader = False + await cls.stop_application_lock_renewal() + try: + await StartupUtil.release_application_leader( + redis, + LockConstant.APP_STARTUP_LOCK_KEY, + cls.get_application_lock_owner_token(), + ) + except Exception: + logger.exception('❌ Scheduler启动失败后释放Application leader租约失败') + raise + @classmethod def on_lock_lost(cls) -> None: """ @@ -328,7 +454,7 @@ class SchedulerUtil: for job_id in jobs_to_remove: scheduler.remove_job(job_id=job_id) logger.info(f'🗑️ 同步移除任务: {job_id}') - cls._refresh_job_update_cache(job_id, None) + cls._invalidate_job_update_cache(job_id) jobs_to_add = db_enabled_ids - scheduler_job_ids for job_id in jobs_to_add: @@ -358,10 +484,11 @@ class SchedulerUtil: :return: 是否一致 """ job_state = scheduler_job.__getstate__() - job_kwargs = json.loads(job_info.job_kwargs) if job_info.job_kwargs else None - job_args = job_info.job_args.split(',') if job_info.job_args else None + job_kwargs = json.loads(job_info.job_kwargs) if job_info.job_kwargs else {} + job_args = cls._parse_job_args(job_info.job_args) or [] + job_func = cls._import_function(job_info.invoke_target) job_executor = job_info.job_executor - if iscoroutinefunction(cls._import_function(job_info.invoke_target)): + if iscoroutinefunction(job_func): job_executor = 'default' expected = { 'name': job_info.job_name, @@ -371,9 +498,9 @@ class SchedulerUtil: 'coalesce': job_info.misfire_policy == '2', 'max_instances': 3 if job_info.concurrent == '0' else 1, 'trigger': str(MyCronTrigger.from_crontab(job_info.cron_expression)), - 'args': tuple(job_args) if job_args else None, - 'kwargs': job_kwargs if job_kwargs else None, - 'func': str(cls._import_function(job_info.invoke_target)), + 'args': tuple(job_args), + 'kwargs': job_kwargs, + 'func': obj_to_ref(job_func), } current = { 'name': job_state.get('name'), @@ -383,9 +510,9 @@ class SchedulerUtil: 'coalesce': job_state.get('coalesce'), 'max_instances': job_state.get('max_instances'), 'trigger': str(job_state.get('trigger')), - 'args': job_state.get('args'), - 'kwargs': job_state.get('kwargs'), - 'func': str(job_state.get('func')), + 'args': tuple(job_state.get('args') or ()), + 'kwargs': dict(job_state.get('kwargs') or {}), + 'func': job_state.get('func'), } return expected == current @@ -421,9 +548,7 @@ class SchedulerUtil: :param job_update_time: 任务更新时间 :return: 是否跳过 """ - if job_update_time is None: - return False - return cls._job_update_time_cache.get(job_id) == job_update_time + return job_id in cls._job_update_time_cache and cls._job_update_time_cache[job_id] == job_update_time @classmethod def _refresh_job_update_cache(cls, job_id: str, job_update_time: datetime | None) -> None: @@ -434,10 +559,17 @@ class SchedulerUtil: :param job_update_time: 任务更新时间 :return: None """ - if job_update_time is not None: - cls._job_update_time_cache[job_id] = job_update_time - else: - cls._job_update_time_cache.pop(job_id, None) + cls._job_update_time_cache[job_id] = job_update_time + + @classmethod + def _invalidate_job_update_cache(cls, job_id: str) -> None: + """ + 移除任务更新时间缓存 + + :param job_id: 任务ID + :return: None + """ + cls._job_update_time_cache.pop(job_id, None) @classmethod async def request_scheduler_sync(cls) -> None: @@ -513,12 +645,21 @@ class SchedulerUtil: :return: None """ - if not cls._redis: + if cls._is_closing or not cls._redis: return if cls._reacquire_task and not cls._reacquire_task.done(): return cls._reacquire_task = asyncio.create_task(cls._run_reacquire_loop()) + @classmethod + def _get_reacquire_delay(cls) -> float: + """ + 获取带随机抖动的锁重新竞争间隔 + + :return: 重新竞争等待秒数 + """ + return cls._reacquire_interval_seconds + random.uniform(0, cls._reacquire_jitter_seconds) + @classmethod async def _run_reacquire_loop(cls) -> None: """ @@ -527,21 +668,29 @@ class SchedulerUtil: :return: None """ try: - while not cls._is_leader: + while not cls._is_leader and not cls._is_closing: + await asyncio.sleep(cls._get_reacquire_delay()) + if cls._is_closing: + break if not cls._redis: - await asyncio.sleep(cls._reacquire_interval_seconds) continue - acquired = await StartupUtil.acquire_startup_log_gate( - redis=cls._redis, - lock_key=LockConstant.APP_STARTUP_LOCK_KEY, - worker_id=cls._worker_id, - lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, - ) + try: + acquired = await StartupUtil.acquire_application_leader( + redis=cls._redis, + lock_key=LockConstant.APP_STARTUP_LOCK_KEY, + owner_token=cls.get_application_lock_owner_token(), + lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, + ) + except Exception as exc: + logger.error(f'❌ Application leader租约重新竞争失败:{exc}') + continue if acquired: - # 直接调用 _start_scheduler_as_leader,避免重复获取锁 - await cls._start_scheduler_as_leader(cls._redis) + try: + await cls._activate_scheduler_as_leader(cls._redis) + except Exception: + logger.exception('❌ 重新获得Application leader租约后恢复Scheduler失败') + continue return - await asyncio.sleep(cls._reacquire_interval_seconds) except asyncio.CancelledError: raise finally: @@ -698,7 +847,7 @@ class SchedulerUtil: return { 'func': job_func, 'trigger': MyCronTrigger.from_crontab(job_info.cron_expression), - 'args': job_info.job_args.split(',') if job_info.job_args else None, + 'args': cls._parse_job_args(job_info.job_args), 'kwargs': json.loads(job_info.job_kwargs) if job_info.job_kwargs else None, 'id': str(job_info.job_id), 'name': job_info.job_name, @@ -732,6 +881,8 @@ class SchedulerUtil: :return: """ + cls._is_closing = True + await cls.stop_application_lock_renewal() if cls._sync_listener_task: cls._sync_listener_task.cancel() try: @@ -766,12 +917,20 @@ class SchedulerUtil: if getattr(scheduler, 'running', False): scheduler.shutdown() logger.info('✅️ 关闭定时任务成功') - # 释放锁 - if cls._redis: - current_holder = await cls._redis.get(LockConstant.APP_STARTUP_LOCK_KEY) - if current_holder == cls._worker_id: - await cls._redis.delete(LockConstant.APP_STARTUP_LOCK_KEY) - logger.info(f'🔓 Worker {cls._worker_id} 释放 Application 锁') + # 必须在Redis连接池关闭前,原子释放当前进程持有的Application leader租约 + redis = cls._redis + cls._redis = None + try: + if redis: + released = await StartupUtil.release_application_leader( + redis, + LockConstant.APP_STARTUP_LOCK_KEY, + cls.get_application_lock_owner_token(), + ) + if released: + logger.info(f'🔓 Worker {cls._worker_id} 释放 Application 锁') + finally: + cls._is_leader = False @classmethod def _import_function(cls, func_path: str) -> Callable[..., Any]: @@ -809,6 +968,7 @@ class SchedulerUtil: if not cls._is_leader: return scheduler.add_job(**cls._prepare_scheduler_job_add(job_info)) + cls._refresh_job_update_cache(str(job_info.job_id), job_info.update_time) @classmethod def execute_scheduler_job_once(cls, job_info: JobModel) -> None: @@ -826,7 +986,7 @@ class SchedulerUtil: # 非应用锁 worker:直接执行函数(不通过 scheduler) if not cls._is_leader: logger.info(f'📍 当前 Worker 未持有 Application 锁,直接执行任务 {job_info.job_name}') - args = job_info.job_args.split(',') if job_info.job_args else [] + args = cls._parse_job_args(job_info.job_args) or [] kwargs = json.loads(job_info.job_kwargs) if job_info.job_kwargs else {} status = '0' exception_info = '' @@ -852,7 +1012,7 @@ class SchedulerUtil: scheduler.add_job( func=job_func, trigger=job_trigger, - args=job_info.job_args.split(',') if job_info.job_args else None, + args=cls._parse_job_args(job_info.job_args), kwargs=json.loads(job_info.job_kwargs) if job_info.job_kwargs else None, id=str(job_info.job_id), name=job_info.job_name, @@ -874,9 +1034,11 @@ class SchedulerUtil: # 非应用锁 worker 跳过操作(数据库状态是持久化的,持有应用锁时会根据状态加载) if not cls._is_leader: return + job_id = str(job_id) query_job = cls.get_scheduler_job(job_id=job_id) if query_job: - scheduler.remove_job(job_id=str(job_id)) + scheduler.remove_job(job_id=job_id) + cls._invalidate_job_update_cache(job_id) @classmethod def scheduler_event_listener(cls, event: SchedulerEvent) -> None: @@ -910,7 +1072,7 @@ class SchedulerUtil: invoke_target = query_job_info.get('func') # 获取调用函数位置参数(安全处理) args = query_job_info.get('args') - job_args = ','.join(str(arg) for arg in args) if args else '' + job_args = cls._dump_job_args(args) # 获取调用函数关键字参数 kwargs = query_job_info.get('kwargs') job_kwargs = json.dumps(kwargs) if kwargs else '{}' diff --git a/ruoyi-fastapi-backend/docs/cli_usage.md b/ruoyi-fastapi-backend/docs/cli_usage.md index e2763f9..112fde1 100644 --- a/ruoyi-fastapi-backend/docs/cli_usage.md +++ b/ruoyi-fastapi-backend/docs/cli_usage.md @@ -20,6 +20,7 @@ ruoyi [options] - `config` - `crypto` - `gen` +- `plugin` - `dev` - `completion` - `wizard` @@ -233,7 +234,34 @@ ruoyi dev lint cli --check-only ruoyi dev test tests --keyword sanitize --maxfail=1 -q ``` -### 4.6 Shell Completion 初始化 +### 4.6 插件开发 + +插件开发命令集中在 `plugin` 命令组,完整开发手册见 `docs/plugin_development.md`。 + +```bash +ruoyi plugin create demo --env=dev --template=full-stack --dry-run +ruoyi plugin create demo --env=dev --template=crud-page --frontend-version=vue2 --dry-run +ruoyi plugin check demo --env=dev +ruoyi plugin check-deps demo --env=dev +ruoyi plugin allowlist-example --env=dev --dry-run +ruoyi plugin allowlist-example --env=dev --output-path config/plugin_dependency_allowlist.yaml --overwrite +ruoyi plugin lock-deps demo --env=dev --dry-run +ruoyi plugin lock-deps demo --env=dev --offline-dir artifacts/plugin-dependencies --overwrite +ruoyi plugin install-deps demo --env=dev --dry-run +ruoyi plugin install-deps demo --env=dev --yes +ruoyi plugin install demo --env=dev --yes +ruoyi plugin test demo --env=dev +``` + +`allowlist-example` 会按需生成插件依赖允许列表示例,默认输出路径为 `config/plugin_dependency_allowlist.yaml`。仓库不再默认携带 `.example` 文件;需要模板时可先 `--dry-run` 查看,也可指定 `--output-path` 写入并按团队实际批准范围调整。 + +`plugin create` 默认使用 `--frontend-version=auto`,从目标前端 `package.json` 的 `vue` 依赖自动识别 Vue 2/3;只有无法识别或需要覆盖时才显式传入 `vue2` 或 `vue3`。插件清单通常保持跨版本一致;确实存在 Vue 绑定库等版本专属依赖时,允许各项目的 `plugin.yaml` 分别声明,并使用按前端版本自动选择的测试覆盖两套清单。 + +`lock-deps` 会根据 `plugin.yaml` 中声明的 Python/npm/npmDev 外部依赖生成 `plugin.lock.yaml` 模板。默认模式不联网解析版本,也不生成 hash/integrity;如传入 `--offline-dir`,命令会从已有本地 wheel/tgz 反填 `resolvedVersion`、Python `hashes` 和 npm/npmDev `integrity`。该命令仍不会下载、安装或访问 registry,无法反填的项需要由人工审核或 CI 发布流水线补齐后,才能用于 `locked` 或 `offline` 策略。 + +`install-deps` 是真实 Python/npm 依赖安装的唯一显式入口。文本 TTY 下可省略 `--yes`,CLI 会先输出依赖安装计划和策略判定,再询问是否执行;非 TTY、JSON 输出或 CI 场景应传 `--yes`,否则会由策略返回确认阻断。 + +### 4.7 Shell Completion 初始化 ```bash ruoyi completion doctor --output=json @@ -242,7 +270,7 @@ ruoyi completion install --activate ruoyi completion install --shell=bash --activate ``` -### 4.7 交互式向导与 TUI +### 4.8 交互式向导与 TUI ```bash ruoyi wizard app-run diff --git a/ruoyi-fastapi-backend/docs/plugin_development.md b/ruoyi-fastapi-backend/docs/plugin_development.md new file mode 100644 index 0000000..cf41b45 --- /dev/null +++ b/ruoyi-fastapi-backend/docs/plugin_development.md @@ -0,0 +1,860 @@ +# 插件开发手册 + +本文档面向插件开发者,说明如何在当前插件系统中创建、安装、启用、调试和发布插件。 + +## 1. 基本模型 + +插件由后端插件和可选前端插件组成。默认源码布局如下: + +```text +ruoyi-fastapi-backend/plugins// +ruoyi-fastapi-frontend/plugins// +``` + +运行时不会把前后端仓库名写死在各操作入口中。插件系统优先使用显式传入的目录,其次读取 +`RUOYI_PLUGIN_BACKEND_ROOT`/`RUOYI_BACKEND_ROOT` 和 +`RUOYI_PLUGIN_FRONTEND_ROOT`/`RUOYI_FRONTEND_ROOT`,再尝试从后端同级目录中识别前端工程; +最后才按后端目录名把 `backend` 推断为 `frontend`。非默认目录名的项目,应优先配置上述环境变量或在运行时注入目录。 + +后端插件必须包含 `plugin.yaml`。插件发现、安装、菜单、依赖、配置、迁移、种子数据和定时任务都以这个文件为入口。 + +`plugin.yaml` 只描述插件能力和资源。安装、启用、停用、升级等运行态由管理端或 CLI 生命周期命令维护;生命周期状态只使用 `discovered`、`installed`、`pending_upgrade`、`error`。 + +## 2. 快速开始 + +进入后端项目目录: + +```bash +cd ruoyi-fastapi-backend +``` + +使用脚手架创建插件: + +```bash +ruoyi plugin create demo --env=dev --template=full-stack +``` + +脚手架默认使用 `--frontend-version=auto`,会读取目标前端 `package.json` 的 `vue` 依赖,并自动生成 Vue 2(Element UI、Options API、CommonJS 测试)或 Vue 3(Element Plus、Composition API、ESM 测试)模板。通常无需传参;识别失败或需要覆盖时可显式指定: + +```bash +ruoyi plugin create demo --env=dev --template=crud-page --frontend-version=vue2 +ruoyi plugin create demo --env=dev --template=crud-page --frontend-version=vue3 +``` + +后端实现应在 Vue 2/3 项目间保持一致。`plugin.yaml` 通常只声明两个前端都使用的业务依赖;如果插件确实依赖不同的 Vue 绑定库或构建插件,允许各项目保留不同清单,但应分别提供 Vue 2/3 测试,并根据目标前端 `package.json` 自动选择执行。 + +常用模板: + +- `minimal`:最小插件。 +- `backend-only`:只生成后端插件。 +- `full-stack`:生成后端和前端插件。 +- `scheduled-job`:包含定时任务示例。 +- `crud-page`:包含 CRUD 页面示例。 + +先预览写入计划: + +```bash +ruoyi plugin create demo --env=dev --template=full-stack --dry-run +``` + +开发过程常用命令: + +```bash +ruoyi plugin check demo --env=dev +ruoyi plugin check-deps demo --env=dev +ruoyi plugin allowlist-example --env=dev --dry-run +ruoyi plugin allowlist-example --env=dev --output-path config/plugin_dependency_allowlist.yaml --overwrite +ruoyi plugin lock-deps demo --env=dev --dry-run +ruoyi plugin lock-deps demo --env=dev --offline-dir artifacts/plugin-dependencies --overwrite +ruoyi plugin install-deps demo --env=dev --dry-run +ruoyi plugin install-deps demo --env=dev --yes +ruoyi plugin install demo --env=dev --yes +ruoyi plugin enable demo --env=dev --yes +ruoyi plugin health demo --env=dev +ruoyi plugin test demo --env=dev +``` + +## 3. 目录结构 + +推荐后端结构: + +```text +plugins/demo/ + plugin.yaml + controller/ + demo_controller.py + service/ + demo_service.py + dao/ + entity/ + do/ + vo/ + hooks.py + jobs.py + migrations/ + mysql/001_init.sql + postgresql/001_init.sql + seeds/ + mysql/001_seed.sql + postgresql/001_seed.sql + README.md +``` + +推荐前端结构: + +```text +/plugins/demo/ + api/ + demo.js + views/ + index.vue + README.md +``` + +后端 Python 模块路径必须与插件 ID 对齐。例如插件 ID 为 `demo` 时,`backend.module` 必须是 `plugins.demo`。 + +## 4. plugin.yaml 示例 + +```yaml +manifestVersion: 1 +id: demo +name: 演示插件 +version: 0.1.0 +description: Demo plugin. + +metadata: + category: demo + tags: + - demo + - sample + author: RuoYi + license: MIT + homepage: "" + repository: "" + documentation: "" + +backend: + module: plugins.demo + routers: + autoScan: true + migrations: + - migrations/mysql/001_init.sql + - migrations/postgresql/001_init.sql + seeds: + - seeds/mysql/001_seed.sql + - seeds/postgresql/001_seed.sql + hooks: + onInstall: plugins.demo.hooks:on_install + onStartup: plugins.demo.hooks:on_startup + jobs: + - id: cleanup + name: 演示清理任务 + callable: plugins.demo.jobs.cleanup + trigger: cron + cronExpression: "0 0 * * * ?" + enabled: true + misfirePolicy: "3" + concurrent: "1" + +frontend: + basePath: demo + pluginId: demo + viewsPath: views + apiPath: api + delivery: + type: source + buildRequired: true + menus: + - name: 演示插件 + path: demo + component: Layout + perms: "" + type: M + orderNum: 10 + icon: example + children: + - name: 演示页面 + path: index + component: plugin/demo/index + routeName: DemoIndex + query: "" + isFrame: 1 + isCache: 0 + perms: demo:list + type: C + orderNum: 1 + +permissions: + - code: demo:list + name: 演示列表 + description: 查看演示页面 + - code: demo:add + name: 新增演示 + - code: demo:edit + name: 修改演示 + - code: demo:remove + name: 删除演示 + +dependencies: + python: + - requests>=2.32.0 + npm: + - dayjs>=1.11.0 + npmDev: [] + plugins: + - id: ai + version: ">=0.1.0" + description: 依赖 AI 插件能力 + +compatibility: + databases: + - mysql + - postgresql + +config: + items: + - key: api_url + label: API 地址 + type: string + default: "" + required: true + - key: audit_log + label: 记录日志 + type: boolean + default: true +``` + +注意事项: + +- `id` 只能使用小写字母、数字、下划线和中划线,并且必须以小写字母开头。 +- `admin`、`system`、`monitor`、`tool` 是保留插件 ID。 +- `permissions` 中必须声明菜单使用到的权限标识。 +- 菜单权限格式使用小写冒号分隔,例如 `demo:list`。 +- 插件组件路径必须使用 `plugin//`。 + +## 5. plugin.yaml 参数说明 + +### 5.1 顶层字段 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `manifestVersion` | `number` | `1` | 插件清单版本。当前支持 `1`。 | +| `id` | `string` | 必填 | 插件唯一标识。只能包含小写字母、数字、下划线和中划线,长度 2-64,必须以小写字母开头。不能使用 `admin`、`system`、`monitor`、`tool`。 | +| `name` | `string` | 必填 | 插件展示名称。 | +| `version` | `string` | 必填 | 插件源码版本,用于安装版本记录和升级判断。 | +| `description` | `string` | `""` | 插件说明。 | +| `metadata` | `object` | `{}` | 插件展示元数据。 | +| `backend` | `object` | 必填 | 后端能力声明。 | +| `frontend` | `object` | `{}` | 前端资源、菜单和交付声明。 | +| `permissions` | `object[] \| string[]` | `[]` | 插件权限声明列表。推荐对象写法;字符串简写会按 `code` 处理。 | +| `dependencies` | `object` | `{}` | Python、npm 和插件间依赖声明。 | +| `compatibility` | `object` | `{}` | 平台兼容性版本约束。 | +| `resources` | `object` | `{}` | 插件静态、上传、临时资源目录声明。 | +| `config` | `object` | `{}` | 插件配置项声明。 | + +### 5.2 metadata + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `category` | `string` | `""` | 插件分类。 | +| `tags` | `string[]` | `[]` | 插件标签,不能重复。 | +| `author` | `string` | `""` | 插件作者。 | +| `license` | `string` | `""` | 插件许可证。 | +| `homepage` | `string` | `""` | 插件主页地址。 | +| `repository` | `string` | `""` | 插件代码仓库地址。 | +| `documentation` | `string` | `""` | 插件文档地址。 | + +### 5.3 backend + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `module` | `string` | 必填 | 插件后端 Python 模块路径,必须是 `plugins.`。 | +| `routers` | `object` | `{ autoScan: true }` | 控制器自动扫描声明。 | +| `health` | `object` | `{}` | 健康检查声明。 | +| `migrations` | `string[]` | `[]` | 数据库迁移 SQL 脚本相对路径列表。 | +| `seeds` | `string[]` | `[]` | 初始化数据 SQL 脚本相对路径列表。 | +| `hooks` | `object` | `{}` | 生命周期钩子声明。 | +| `jobs` | `object[]` | `[]` | 插件定时任务声明。 | + +`backend.routers`: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `autoScan` | `boolean` | `true` | 是否按插件模块自动扫描并注册控制器。 | + +`backend.health`: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `checker` | `string \| null` | `null` | 健康检查 callable,格式为 `:`,例如 `plugins.demo.health:check`。 | + +`backend.hooks`: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `onInstall` | `string \| null` | `null` | 插件安装完成后的钩子。 | +| `onUpgrade` | `string \| null` | `null` | 插件升级完成后的钩子。 | +| `onStartup` | `string \| null` | `null` | 应用启动加载插件时执行的钩子。 | +| `onShutdown` | `string \| null` | `null` | 应用关闭插件时执行的钩子。 | +| `onPurge` | `string \| null` | `null` | 插件物理清理时执行的钩子。 | + +钩子路径格式统一为 `:`,例如 `plugins.demo.hooks:on_startup`。 + +`backend.jobs[]`: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 必填 | 插件内任务唯一标识。只能包含小写字母、数字、下划线和中划线,必须以小写字母开头。 | +| `name` | `string \| null` | `id` | 任务展示名称。 | +| `callable` | `string` | 必填 | 任务函数路径,格式为 `.`。 | +| `trigger` | `"cron"` | `"cron"` | 任务触发器类型。 | +| `cronExpression` | `string` | 必填 | cron 表达式,不能为空。 | +| `args` | `string[]` | `[]` | 位置参数列表。 | +| `kwargs` | `object` | `{}` | 关键字参数。 | +| `enabled` | `boolean` | `true` | 任务安装后的默认状态。 | +| `description` | `string` | `""` | 任务说明。 | +| `misfirePolicy` | `"1" \| "2" \| "3"` | `"3"` | 计划执行错误策略。`1` 立即执行,`2` 执行一次,`3` 放弃执行。 | +| `concurrent` | `"0" \| "1"` | `"1"` | 是否允许并发执行。`0` 允许,`1` 禁止。 | +| `executor` | `"default" \| "processpool"` | `"default"` | 任务执行器。 | + +### 5.4 frontend + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `pluginId` | `string \| null` | `id` | 前端插件目录名,必须与插件 ID 一致。 | +| `basePath` | `string \| null` | `id` | 前端基础路径。只能包含小写字母、数字、下划线、中划线和正斜杠。 | +| `viewsPath` | `string` | `"views"` | 前端视图目录。 | +| `apiPath` | `string` | `"api"` | 前端 API 目录。 | +| `delivery` | `object` | `{}` | 前端交付声明。 | +| `menus` | `object[]` | `[]` | 插件菜单树。 | + +`frontend.delivery`: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `type` | `"none" \| "source"` | `"none"` | 前端交付类型。存在菜单或 npm 依赖时会自动按源码交付处理。 | +| `buildRequired` | `boolean` | `false` | 前端资源是否需要构建后生效。源码交付时会自动视为需要构建。 | + +`frontend.menus[]`: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `name` | `string` | 必填 | 菜单名称。 | +| `path` | `string` | 必填 | 菜单路由路径。普通菜单只能包含小写字母、数字、下划线、中划线和正斜杠,必须以小写字母开头;外链菜单必须使用 `http://` 或 `https://` 地址。 | +| `component` | `string` | `"Layout"` | 组件路径。核心组件允许 `Layout`、`ParentView`、`InnerLink`;插件页面使用 `plugin//`。 | +| `perms` | `string` | `""` | 权限标识。非空时必须在顶层 `permissions` 中声明。 | +| `icon` | `string` | `"#"` | 菜单图标。 | +| `type` | `"M" \| "C" \| "F"` | `"C"` | 菜单类型。`M` 目录,`C` 菜单,`F` 按钮。 | +| `orderNum` | `number` | `0` | 菜单排序值。 | +| `query` | `string \| null` | `null` | 路由参数。 | +| `routeName` | `string \| null` | `null` | 路由名称。 | +| `isFrame` | `0 \| 1` | `1` | 是否为外链。沿用系统菜单字段约定,`0` 是,`1` 否。 | +| `isCache` | `0 \| 1` | `0` | 是否缓存。沿用系统菜单字段约定,`0` 缓存,`1` 不缓存。 | +| `visible` | `"0" \| "1"` | `"0"` | 菜单是否显示。沿用系统菜单字段约定。 | +| `status` | `"0" \| "1"` | `"0"` | 菜单状态。沿用系统菜单字段约定。 | +| `children` | `object[]` | `[]` | 子菜单列表,结构同 `frontend.menus[]`。 | + +### 5.5 permissions + +`permissions` 是插件声明的权限列表。菜单 `perms` 使用到的权限必须出现在这里。 + +```yaml +permissions: + - code: demo:list + name: 演示列表 + description: 查看演示页面 + - code: demo:add + name: 新增演示 +``` + +也支持字符串简写: + +```yaml +permissions: + - demo:list + - demo:add +``` + +`permissions[]`: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `code` | `string` | 必填 | 权限标识。也兼容使用 `perms` 或 `permission` 字段名。 | +| `name` | `string \| null` | `null` | 权限展示名称。未显式声明为菜单的权限会自动生成按钮菜单,此字段会作为按钮菜单名称。 | +| `description` | `string` | `""` | 权限说明。 | + +要求: + +- 权限不能重复。 +- 权限格式为小写冒号分隔,例如 `demo:list`、`demo:item:add`。 + +### 5.6 dependencies + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `python` | `string[]` | `[]` | Python 依赖声明,例如 `requests>=2.32.0`。 | +| `npm` | `string[]` | `[]` | 前端运行依赖声明,例如 `dayjs>=1.11.0`。 | +| `npmDev` | `string[]` | `[]` | 前端开发依赖声明。 | +| `plugins` | `object[]` | `[]` | 插件间依赖声明。 | + +`dependencies.plugins[]` 支持对象写法: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `id` | `string` | 必填 | 依赖插件 ID。 | +| `version` | `string \| null` | `null` | 依赖插件版本约束,例如 `>=1.0.0`。 | +| `description` | `string` | `""` | 依赖说明。 | + +也支持字符串简写: + +```yaml +dependencies: + plugins: + - ai>=0.1.0 +``` + +### 5.7 compatibility + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `backendVersion` | `string \| null` | `null` | 后端版本约束。 | +| `frontendVersion` | `string \| null` | `null` | 前端版本约束。 | +| `pythonVersion` | `string \| null` | `null` | Python 版本约束。 | +| `nodeVersion` | `string \| null` | `null` | Node.js 版本约束。 | +| `databases` | `("mysql" \| "postgresql")[]` | `[]` | 插件支持的数据库类型声明,不能重复。 | + +版本约束可以是版本号,也可以带比较操作符,例如 `>=3.10`、`^20.0.0`。 + +### 5.8 resources + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `static` | `string[]` | `[]` | 插件静态资源相对路径列表。 | +| `uploads` | `string[]` | `[]` | 插件上传资源相对路径列表。 | +| `temp` | `string[]` | `[]` | 插件临时资源相对路径列表。 | + +资源路径只能使用安全相对路径,不能重复。 + +### 5.9 config + +`config` 推荐使用 `items` 写法: + +```yaml +config: + items: + - key: api_url + label: API 地址 + type: string + default: "" +``` + +也支持列表写法: + +```yaml +config: + - key: api_url + label: API 地址 + default: "" +``` + +也支持对象简写: + +```yaml +config: + api_url: + label: API 地址 + default: "" + timeout_seconds: 30 +``` + +`config.items[]`: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `key` | `string` | 必填 | 配置键。只能包含小写字母、数字、下划线、中划线和点号,必须以小写字母开头。 | +| `label` | `string \| null` | `key` | 配置展示名称。 | +| `type` | `string` | `"string"` | 配置类型。支持 `string`、`number`、`boolean`、`select`、`textarea`、`password`、`json`。`text` 会按 `string` 处理,`switch` 会按 `boolean` 处理。 | +| `default` | JSON 值 | `null` | 默认值。支持字符串、数字、布尔、对象、数组和 `null`。`boolean`、`number`、`json` 会校验默认值类型。 | +| `required` | `boolean` | `false` | 是否必填。更新配置时会校验非空;必填但无默认值会产生检查提示。 | +| `group` | `string` | `"default"` | 配置分组,会作为配置元数据返回。 | +| `order` | `number` | `0` | 配置排序值,会作为配置元数据返回。 | +| `placeholder` | `string` | `""` | 输入占位提示,会作为配置元数据返回。 | +| `min` | `number \| null` | `null` | 数字配置最小值,仅 `number` 类型更新时生效。 | +| `max` | `number \| null` | `null` | 数字配置最大值,仅 `number` 类型更新时生效。 | +| `pattern` | `string \| null` | `null` | 字符串、文本、密码配置的正则校验表达式,仅 `string`、`textarea`、`password` 类型更新时生效。 | +| `description` | `string` | `""` | 配置说明,会在管理端配置表单中作为帮助文本展示。 | +| `options` | `object[]` | `[]` | `select` 类型选项列表,仅 `select` 类型生效。 | +| `secret` | `boolean` | `false` | 是否敏感配置。敏感配置导出时默认不输出明文。 | + +`config.items[].options[]`: + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `label` | `string` | 必填 | 选项展示名称。 | +| `value` | JSON 值 | 必填 | 选项值。 | + +`select` 类型必须声明 `options`,并且 `default` 必须位于 `options.value` 中。 + +配置校验和展示规则: + +- `password` 类型建议同时声明 `secret: true`,便于输入、导出和审计时统一脱敏。 +- `secret: true` 的配置不建议声明非空默认值。 +- `required: true` 会在更新配置时校验非空。 +- `min`、`max` 只对 `number` 生效,其他类型声明后会产生检查提示。 +- `pattern` 只对 `string`、`textarea`、`password` 生效,其他类型声明后会产生检查提示。 +- `options` 只对 `select` 生效,其他类型声明后会产生检查提示。 +- `group`、`order`、`placeholder` 会进入配置接口和导出元数据,可供插件自定义页面消费。 + +## 6. 后端开发约定 + +### 6.1 控制器 + +应用启动时会按 `backend.module` 自动扫描已启用插件的控制器。推荐将接口放在 `controller/` 目录,并保持与项目原有 FastAPI 控制器风格一致。 + +后端插件路由采用启动期挂载模型: + +- 新启用插件的后端 controller 需要重启应用后才会挂载到当前 FastAPI app。 +- 停用插件后,已挂载的插件路由仍保留在 app 路由表中,但请求会经过插件启用状态依赖拦截。 +- 插件 controller 的 `prefix` 必须位于当前插件命名空间内,例如 `/demo`、`/demo/items`、`/plugin/demo` 或 `/plugin/demo/items`,不能占用 `/system`、`/monitor` 等平台核心路径。 + +示例: + +```python +from fastapi import APIRouter + +demo_controller = APIRouter(prefix='/demo', tags=['demo']) + + +@demo_controller.get('/ping') +async def ping(): + return {'code': 200, 'msg': 'success', 'data': 'pong'} +``` + +控制器对象需要能被自动扫描发现,命名上建议延续现有 `xxx_controller` 风格。 + +### 6.2 数据模型、DAO 和 Service + +插件业务代码尽量放在插件目录内: + +- `entity/do/`:数据库模型。 +- `entity/vo/`:请求和响应模型。 +- `dao/`:数据库访问。 +- `service/`:业务编排。 + +插件代码可以复用项目已有的数据库 session、响应模型、权限装饰器和工具函数,但不要修改核心模块来服务单个插件。确实需要通用能力时,先沉淀到 `plugins/core` 或项目公共层。 + +### 6.3 Migration 和 Seed + +`backend.migrations` 和 `backend.seeds` 支持声明 SQL 脚本。推荐按数据库方言拆分目录: + +```text +migrations/mysql/001_init.sql +migrations/postgresql/001_init.sql +seeds/mysql/001_seed.sql +seeds/postgresql/001_seed.sql +``` + +要求: + +- migration 用于表结构。 +- seed 用于字典、默认配置等初始化数据。 +- migration 和 seed 都必须可重复执行。MySQL DDL 会隐式提交,后续 hook 或状态写入失败时平台无法自动回滚已应用的结构变更。 +- migration 执行前会先记录 `status=running`;成功后记录 `status=success`;失败后记录 `status=failed` 和错误摘要。 +- `running` 表示上次执行已开始但未记录成功或失败,平台会阻断自动重跑,需要人工确认数据库结构后标记为成功或失败。 +- migration 成功历史只认 `status=success`;已成功执行的 migration 文件不能修改,checksum 变化时必须恢复原文件或新增后续 migration。 +- SQL migration 应优先使用 `CREATE TABLE IF NOT EXISTS` 等幂等写法;复杂 DDL、存储过程或需要条件判断的变更建议改用 Python migration。 +- SQL migration 应尽量拆小,避免单个文件包含大量不可回滚 DDL;`ALTER TABLE`、索引和初始化数据尤其要考虑重复执行安全。 +- SQL 文件路径必须位于插件目录内。 +- MySQL 和 PostgreSQL 差异较大时分别维护脚本。 + +故障恢复入口: + +- CLI 查看历史:`ruoyi plugin migration-list --status running` +- CLI 标记成功:`ruoyi plugin mark-success --note "已人工确认结构完成"` +- CLI 标记失败:`ruoyi plugin mark-failed --note "未完成,允许修复后重试"` +- Web 管理页:插件详情的“依赖 / 执行历史”中查看 migration 状态,并执行人工标记。 + +详细排障流程见 [插件 Migration 故障处理手册](plugin_migration_failure_runbook.md)。 + +### 6.4 生命周期钩子 + +支持的钩子: + +- `onInstall` +- `onUpgrade` +- `onStartup` +- `onShutdown` +- `onPurge` + +声明格式: + +```yaml +backend: + hooks: + onInstall: plugins.demo.hooks:on_install +``` + +钩子函数可以同步或异步,可以不接收参数,也可以接收 `context`: + +```python +async def on_startup(context): + if not context.startup_write_enabled: + return + # 只在启动期单写者中执行全局写操作 +``` + +`context` 常用字段: + +- `plugin_id` +- `hook_name` +- `discovered_plugin` +- `app` +- `query_db` +- `startup_write_enabled` + +多 worker 启动时,所有 worker 都会加载运行时能力,但只有启动期单写者适合执行全局写操作。钩子里如果要写菜单、任务、配置、外部资源,应检查 `context.startup_write_enabled`。 + +### 6.5 定时任务 + +定时任务在 `backend.jobs` 中声明: + +```yaml +jobs: + - id: cleanup + name: 清理任务 + callable: plugins.demo.jobs.cleanup + trigger: cron + cronExpression: "0 0 * * * ?" + enabled: true + misfirePolicy: "3" + concurrent: "1" +``` + +`callable` 使用 `.` 格式。任务会写入系统任务表,由调度器按原系统机制执行。 + +## 7. 前端开发约定 + +插件前端代码放在: + +```text +ruoyi-fastapi-frontend/plugins// +``` + +菜单组件路径和真实 Vue 文件的映射关系: + +```text +plugin/demo/index -> ruoyi-fastapi-frontend/plugins/demo/views/index.vue +plugin/demo/report/list -> ruoyi-fastapi-frontend/plugins/demo/views/report/list.vue +``` + +只允许两类组件值: + +- 核心布局组件:`Layout`、`ParentView`、`InnerLink`。 +- 插件视图组件:`plugin//`。 + +前端 API 建议放在 `plugins//api/`,视图放在 `plugins//views/`。插件页面不需要加入主工程内置路由,菜单安装后由后端返回动态路由,前端 resolver 会自动定位插件视图。 + +## 8. 配置项 + +插件配置写在 `config.items` 中。支持类型: + +- `string` +- `number` +- `boolean` +- `select` +- `textarea` +- `password` +- `json` + +示例: + +```yaml +config: + items: + - key: provider + label: 默认供应商 + type: select + default: openai + required: true + options: + - label: OpenAI + value: openai + - label: Mistral + value: mistral + - key: api_key + label: API Key + type: password + default: "" + secret: true +``` + +配置命令: + +```bash +ruoyi plugin config demo get --env=dev +ruoyi plugin config demo set api_url=https://example.com --env=dev --yes +ruoyi plugin config demo export --env=dev --output-file=demo-config.json +ruoyi plugin config demo import --env=dev --input-file=demo-config.json --yes +``` + +敏感配置使用 `secret: true`,导出时默认不输出明文。 + +配置值会按类型进行序列化和反序列化:`boolean` 返回布尔值,`number` 返回数字,`json` 返回对象或数组。更新配置时,未在 `plugin.yaml` 中声明的配置键会被拒绝。 + +内置管理页会根据 `type` 渲染基础控件,支持必填校验、下拉选项、敏感输入和配置说明;更复杂的分组、排序或提示布局可以在插件自定义页面中消费配置元数据后自行实现。 + +## 9. 依赖管理 + +插件依赖声明在 `dependencies` 中: + +- `python`:Python 包。 +- `npm`:前端运行依赖。 +- `npmDev`:前端开发依赖。 +- `plugins`:插件间依赖。 + +检查和安装: + +```bash +ruoyi plugin check-deps demo --env=dev +ruoyi plugin allowlist-example --env=dev --dry-run +ruoyi plugin allowlist-example --env=dev --output-path config/plugin_dependency_allowlist.yaml --overwrite +ruoyi plugin lock-deps demo --env=dev --dry-run +ruoyi plugin lock-deps demo --env=dev --offline-dir artifacts/plugin-dependencies --overwrite +ruoyi plugin install-deps demo --env=dev --dry-run +ruoyi plugin install-deps demo --env=dev --yes +``` + +`allowlist-example` 按需生成插件依赖允许列表示例,默认输出到 `config/plugin_dependency_allowlist.yaml`;仓库不再默认携带 `.example` 文件。建议先用 `--dry-run` 查看模板,再写入正式 allowlist 并按团队实际批准范围调整。 + +`lock-deps` 默认生成锁文件模板,输出到 `plugins//plugin.lock.yaml`;如文件已存在,需要传 `--overwrite` 才会覆盖。默认模式不会联网解析真实版本,也不会写入 hash/integrity;如果传入 `--offline-dir`,命令会从已有本地 wheel/tgz 反填 `resolvedVersion`、Python `hashes` 和 npm/npmDev `integrity`。它仍不会下载、安装或访问 registry,未能反填的项发布前应由人工审核或 CI 流水线补齐。 + +如果当前终端是交互式 TTY,且输出格式为 text,也可以不传 `--yes`: + +```bash +ruoyi plugin install-deps demo --env=dev +``` + +CLI 会先输出 dry-run 预览和策略判定,再询问是否执行真实安装。非 TTY、JSON 输出和 CI 场景不会进入交互确认,真实安装应显式传 `--yes`。 + +应用启动时只做默认启用插件的依赖门禁,不会提示安装,也不会执行 `pip install` 或 `npm install`。缺少依赖时应先使用 `ruoyi plugin install-deps` 显式处理。 + +## 10. 安装、启用、升级和清理 + +生命周期命令: + +```bash +ruoyi plugin list --env=dev +ruoyi plugin info demo --env=dev +ruoyi plugin check demo --env=dev +ruoyi plugin precheck install demo --env=dev +ruoyi plugin install demo --env=dev --yes +ruoyi plugin enable demo --env=dev --yes +ruoyi plugin disable demo --env=dev --yes +ruoyi plugin upgrade demo --env=dev --yes +ruoyi plugin uninstall demo --env=dev --yes +ruoyi plugin purge demo --env=dev --yes +``` + +批量计划和批量执行: + +```bash +ruoyi plugin plan install demo --env=dev +ruoyi plugin batch install demo --env=dev --yes +ruoyi plugin batch enable --env=dev --yes +``` + +命令语义: + +- `install`:执行 migration、seed、菜单、配置、任务安装,并记录 `installed_version`。 +- `enable`:启用插件,并恢复菜单和任务状态。 +- `disable`:停用插件,并停用菜单和任务。 +- `uninstall`:安全卸载,保留可恢复数据。 +- `purge`:清理插件平台元数据,属于高风险操作。 + +生产环境执行危险操作需要显式传入 `--allow-prod --yes`。 + +## 11. 默认启用内置插件 + +内置插件自动初始化名单写在环境配置中: + +```env +APP_DEFAULT_ENABLED_PLUGINS=ai,demo +``` + +规则: + +- 多个插件用英文逗号分隔。 +- 留空表示不自动初始化默认启用插件。 +- 启动期只会初始化当前环境配置中的默认启用插件。 +- 用户在管理端停用或卸载插件后,数据库状态优先。 + +如果插件只作为可选能力,不要加入 `APP_DEFAULT_ENABLED_PLUGINS`。 + +## 12. 健康检查和诊断 + +可在 `backend.health.checker` 声明健康检查: + +```yaml +backend: + health: + checker: plugins.demo.health:check +``` + +格式为 `:`。健康检查命令: + +```bash +ruoyi plugin health demo --env=dev +ruoyi plugin diagnose demo --env=dev --output-file=demo-diagnose.json +ruoyi plugin docs demo --env=dev --output-file=demo.md +``` + +## 13. 测试和发布前检查 + +后端单插件测试: + +```bash +ruoyi plugin test demo --env=dev +``` + +直接运行 pytest: + +```bash +pytest tests/plugins/demo +``` + +代码格式和 lint: + +```bash +ruff format plugins/demo tests/plugins/demo +ruff check plugins/demo tests/plugins/demo +``` + +发布前建议至少执行: + +```bash +ruoyi plugin check demo --env=dev +ruoyi plugin check-deps demo --env=dev +ruoyi plugin precheck install demo --env=dev +ruoyi plugin install demo --env=dev --dry-run +ruoyi plugin test demo --env=dev +``` + +全栈插件还应执行前端构建检查: + +```bash +cd ../ruoyi-fastapi-frontend +npm run build:prod +``` + +## 14. 开发规范清单 + +提交前确认: + +- 插件 ID、后端模块、前端插件目录三者一致。 +- 菜单权限全部声明在顶层 `permissions`。 +- 菜单组件路径能映射到实际 Vue 文件。 +- SQL seed 可重复执行。 +- migration 和 seed 不写插件目录外文件。 +- 生命周期钩子中的全局写操作检查 `startup_write_enabled`。 +- 依赖声明完整,并通过 `check-deps`。 +- 插件状态只使用 `status` 的四个生命周期值。 diff --git a/ruoyi-fastapi-backend/docs/plugin_migration_failure_runbook.md b/ruoyi-fastapi-backend/docs/plugin_migration_failure_runbook.md new file mode 100644 index 0000000..da0353a --- /dev/null +++ b/ruoyi-fastapi-backend/docs/plugin_migration_failure_runbook.md @@ -0,0 +1,68 @@ +# 插件 Migration 故障处理手册 + +## 适用场景 + +本手册用于处理插件安装、升级过程中 `migration` 执行失败或中断的问题。 + +插件 migration 采用显式状态记录,不承诺自动回滚 MySQL DDL。平台会保留执行历史,并通过状态阻断不安全的自动重跑。 + +## 状态说明 + +| 状态 | 含义 | 处理方式 | +| --- | --- | --- | +| `success` | migration 已成功执行 | checksum 一致时自动跳过;checksum 变化时必须新增 migration 文件 | +| `failed` | migration 执行失败并记录错误 | 修复脚本幂等性或数据库结构后可重试 | +| `running` | 已开始执行但未记录成功或失败 | 人工确认数据库结构后标记 success 或 failed | +| `unknown` | 平台无法判断状态 | 人工核查后标记为明确状态 | + +## 常见处理流程 + +### running 状态 + +1. 查看 migration 历史。 + - CLI: `ruoyi plugin migration-list --status running` + - Web: 插件管理页打开插件详情,在“依赖 / 执行历史”中查看。 +2. 检查数据库结构是否已经按 migration 完成。 +3. 如果已完成,标记成功。 + - CLI: `ruoyi plugin mark-success --note "已人工确认结构完成"` + - Web: 点击执行历史中的“标记成功”。 +4. 如果未完成,标记失败。 + - CLI: `ruoyi plugin mark-failed --note "未完成,允许修复后重试"` + - Web: 点击执行历史中的“标记失败”。 +5. 修复 migration 幂等性或数据库结构后重新执行安装/升级。 + +### failed 状态 + +1. 查看错误信息和 `attempt_count`。 +2. 修复 migration 脚本,保证重复执行安全。 +3. 重新执行安装或升级。 +4. 如果已通过人工方式完成结构变更,可标记成功。 + +### checksum 变化 + +已成功执行的 migration 文件不能修改。 + +处理方式: + +- 恢复原 migration 文件内容;或 +- 新增一个后续 migration 文件承载变更。 + +## 插件作者约束 + +- SQL migration 应尽量拆小,避免单个文件包含大量不可回滚 DDL。 +- migration 必须可幂等重试,尤其是 `ALTER TABLE`、索引、初始化数据。 +- seed 和 hook 也应可重复执行,不依赖外层事务自动撤销副作用。 +- 不要通过修改已发布 migration 文件修正历史变更。 + +## 观测字段 + +`sys_plugin_migration` 会记录: + +- `status`: 当前执行状态。 +- `attempt_count`: 尝试次数。 +- `started_time`: 最近开始时间。 +- `finished_time`: 最近结束时间。 +- `update_time`: 最近状态更新时间。 +- `error_message`: 最近失败错误。 + +生命周期返回 payload 中的 `migrations` 会包含 `status` 和 `duration_ms`。当 migration 失败或中断需要人工处理时,payload 会包含 `migrationRecovery`,用于展示 migration 路径、状态和恢复建议。 diff --git a/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py b/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py new file mode 100644 index 0000000..62250c9 --- /dev/null +++ b/ruoyi-fastapi-backend/module_plugin/controller/plugin_controller.py @@ -0,0 +1,916 @@ +from collections.abc import Awaitable, Callable, Mapping +from typing import Annotated, Literal + +from fastapi import Form, Path, Query, Request, Response +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from common.annotation.log_annotation import Log +from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency +from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency +from common.enums import BusinessType +from common.router import APIRouterPro +from common.vo import DataResponseModel, PageResponseModel +from module_admin.entity.vo.user_vo import CurrentUserModel +from module_plugin.service.plugin_service import ( + PluginOperationService, + PluginService, + get_plugin_operation_service, + get_plugin_runtime_service, +) +from plugins.core.management.entity.vo.schemas import ( + PluginBatchActionModel, + PluginConfigImportModel, + PluginConfigUpdateModel, + PluginMigrationRecoveryModel, + PluginModel, + PluginOperationLogDetailModel, + PluginOperationLogExportQueryModel, + PluginOperationLogPageQueryModel, + PluginOperationLogRetentionModel, + PluginOperationLogRetentionResultModel, + PluginPageQueryModel, +) +from plugins.core.runtime.result import PluginOperationResult +from plugins.core.validation.dependency_policy import DependencyInstallPolicyConfig +from utils.common_util import bytes2file_response +from utils.log_util import logger +from utils.response_util import ResponseUtil + +plugin_controller = APIRouterPro( + prefix='/system/plugin', + order_num=6, + tags=['系统管理-插件管理'], + dependencies=[PreAuthDependency()], +) + + +def _public_plugin_payload(payload: Mapping[str, object]) -> dict[str, object]: + """ + 构造 Web API 可见的插件运行时 payload。 + + :param payload: 插件运行时负载 + :return: Web API 响应负载 + """ + public_payload = dict(payload) + public_payload.pop('exit_code', None) + + return public_payload + + +def _plugin_operation_response(payload: dict, default_message: str) -> Response: + """ + 按插件运行时 payload ok 字段统一构造操作响应。 + + :param payload: 插件运行时负载 + :param default_message: 默认响应消息 + :return: 响应对象 + """ + operation_result = PluginOperationResult.from_payload(payload, default_message=default_message) + response_payload = _public_plugin_payload(operation_result.payload) + if not operation_result.ok: + return ResponseUtil.failure(msg=operation_result.message, data=response_payload) + + return ResponseUtil.success(msg=operation_result.message, data=response_payload) + + +async def _execute_plugin_operation( + operation: Callable[[], Awaitable[dict]], + default_message: str, +) -> Response: + """ + 执行插件操作并统一处理异常与响应构造。 + + :param operation: 返回插件操作协程的可调用对象 + :param default_message: 默认响应消息 + :return: 响应对象 + """ + try: + payload = await operation() + except Exception: + logger.exception('插件操作执行异常:%s', default_message) + return ResponseUtil.failure(msg=default_message) + + logger.info(payload.get('message', default_message)) + return _plugin_operation_response(payload, default_message) + + +@plugin_controller.get( + '/list', + summary='获取插件分页列表接口', + description='用于获取插件分页列表', + response_model=PageResponseModel[PluginModel], + dependencies=[UserInterfaceAuthDependency('system:plugin:list')], +) +async def get_system_plugin_list( + request: Request, + plugin_page_query: Annotated[PluginPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + """ + 获取插件分页列表。 + + :param request: 请求对象 + :param plugin_page_query: 插件分页查询对象 + :param query_db: orm对象 + :return: 插件分页列表响应 + """ + plugin_page_query_result = await PluginService.get_plugin_page_list_services( + query_db, + plugin_page_query, + is_page=True, + ) + logger.info('获取成功') + + return ResponseUtil.success(model_content=plugin_page_query_result) + + +@plugin_controller.get( + '/plan', + summary='生成插件批量操作计划接口', + description='用于生成批量安装、启用或升级插件的拓扑排序计划', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def plan_system_plugins( + request: Request, + operation: Annotated[ + Literal['install', 'enable', 'upgrade'], Query(description='计划操作类型:install、enable 或 upgrade') + ], + plugin_ids: Annotated[list[str] | None, Query(alias='pluginIds', description='插件ID列表')] = None, +) -> Response: + """ + 生成插件批量操作拓扑计划。 + + :param request: 请求对象 + :param operation: 计划操作类型 + :param plugin_ids: 插件ID列表 + :return: 插件批量操作拓扑计划响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().plan_plugins_async(operation, plugin_ids), + '插件批量操作计划生成完成', + ) + + +@plugin_controller.get( + '/{plugin_id}/precheck', + summary='执行插件操作预检接口', + description='用于在安装、启用、升级、卸载或清理前执行统一预检', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def precheck_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + operation: Annotated[ + Literal['install', 'enable', 'upgrade', 'uninstall', 'purge'], + Query(description='预检操作类型:install、enable、upgrade、uninstall 或 purge'), + ], +) -> Response: + """ + 执行插件操作预检。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param operation: 预检操作类型 + :return: 插件操作预检响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().precheck_plugin_operation(plugin_id, operation), + '插件操作预检完成', + ) + + +@plugin_controller.post( + '/batch', + summary='批量执行插件操作接口', + description='用于按插件依赖拓扑顺序批量安装、启用或升级插件', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.UPDATE) +async def batch_system_plugins( + request: Request, + batch_action: PluginBatchActionModel, +) -> Response: + """ + 批量执行插件操作。 + + :param request: 请求对象 + :param batch_action: 插件批量执行请求体 + :return: 插件批量执行结果响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().batch_plugins( + batch_action.operation, + batch_action.plugin_ids, + dry_run=batch_action.dry_run, + continue_on_error=batch_action.continue_on_error, + ), + '插件批量操作完成', + ) + + +@plugin_controller.get( + '/operation-log/list', + summary='获取插件操作审计日志分页列表接口', + description='用于获取插件批量操作审计日志分页列表', + response_model=PageResponseModel[PluginOperationLogDetailModel], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def get_system_plugin_operation_log_list( + request: Request, + operation_log_page_query: Annotated[PluginOperationLogPageQueryModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + """ + 获取插件批量操作审计日志分页列表。 + + :param request: 请求对象 + :param operation_log_page_query: 插件批量操作审计日志分页查询对象 + :param query_db: orm对象 + :return: 插件批量操作审计日志分页列表响应 + """ + operation_log_page_result = await PluginService.get_plugin_operation_log_page_list_services( + query_db, + operation_log_page_query, + is_page=True, + ) + logger.info('获取插件批量操作审计日志成功') + + return ResponseUtil.success(model_content=operation_log_page_result) + + +@plugin_controller.post( + '/operation-log/export', + summary='导出插件操作审计日志接口', + description='用于导出当前符合查询条件的插件操作审计日志数据', + response_class=StreamingResponse, + responses={ + 200: { + 'description': '流式返回插件操作审计日志excel文件', + 'content': { + 'application/octet-stream': {}, + }, + } + }, + dependencies=[UserInterfaceAuthDependency('system:plugin:export')], +) +@Log(title='插件管理', business_type=BusinessType.EXPORT) +async def export_system_plugin_operation_log_list( + request: Request, + operation_log_export_query: Annotated[PluginOperationLogExportQueryModel, Form()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + """ + 导出插件操作审计日志。 + + :param request: 请求对象 + :param operation_log_export_query: 插件操作审计日志导出查询对象 + :param query_db: orm对象 + :return: 插件操作审计日志导出响应 + """ + operation_log_export_list = await PluginService.get_plugin_operation_log_export_list_services( + query_db, + operation_log_export_query, + ) + operation_dict = await PluginOperationService.get_plugin_operation_dict_services(query_db) + operation_log_export_result = PluginService.export_plugin_operation_log_list_services( + operation_log_export_list, + operation_dict, + ) + logger.info('导出插件操作审计日志成功') + + return ResponseUtil.streaming(data=bytes2file_response(operation_log_export_result)) + + +@plugin_controller.delete( + '/operation-log/retention', + summary='执行插件操作审计日志保留策略接口', + description='用于按保留天数预览或清理插件操作审计日志', + response_model=DataResponseModel[PluginOperationLogRetentionResultModel], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.CLEAN) +async def retain_system_plugin_operation_log( + request: Request, + retention_query: Annotated[PluginOperationLogRetentionModel, Query()], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + """ + 执行插件操作审计日志保留策略。 + + :param request: 请求对象 + :param retention_query: 插件操作审计日志保留策略查询对象 + :param query_db: orm对象 + :return: 插件操作审计日志保留策略执行响应 + """ + retention_result = await PluginService.retain_plugin_operation_log_services(query_db, retention_query) + await query_db.commit() + logger.info('插件操作审计日志保留策略执行完成') + + return ResponseUtil.success(data=retention_result, msg='插件操作审计日志保留策略执行完成') + + +@plugin_controller.get( + '/operation-log/{operation_id}', + summary='获取插件操作审计日志详情接口', + description='用于获取插件批量操作审计日志详情', + response_model=DataResponseModel[PluginOperationLogDetailModel], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def query_detail_system_plugin_operation_log( + request: Request, + operation_id: Annotated[int, Path(description='操作日志ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + """ + 获取插件批量操作审计日志详情。 + + :param request: 请求对象 + :param operation_id: 操作日志ID + :param query_db: orm对象 + :return: 插件批量操作审计日志详情响应 + """ + operation_log_detail_result = await PluginService.plugin_operation_log_detail_services(query_db, operation_id) + if not operation_log_detail_result: + logger.warning(f'插件批量操作审计日志不存在:{operation_id}') + return ResponseUtil.failure(msg='插件批量操作审计日志不存在') + logger.info(f'获取operation_id为{operation_id}的插件批量操作审计日志成功') + + return ResponseUtil.success(data=operation_log_detail_result) + + +@plugin_controller.get( + '/{plugin_id}/migrations', + summary='获取插件 migration 历史接口', + description='用于获取指定插件的 migration 执行历史', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def list_system_plugin_migrations( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + status: Annotated[ + Literal['running', 'success', 'failed', 'unknown'] | None, + Query(description='migration 执行状态'), + ] = None, +) -> Response: + """ + 获取插件 migration 历史。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param status: migration 执行状态 + :return: 插件 migration 历史响应 + """ + migration_result = await get_plugin_runtime_service().list_plugin_migrations(plugin_id, status) + logger.info(migration_result.get('message', '插件 migration 历史查询完成')) + + return _plugin_operation_response(migration_result, '插件 migration 历史查询完成') + + +@plugin_controller.post( + '/{plugin_id}/migrations/mark-success', + summary='人工标记插件 migration 成功接口', + description='用于人工确认指定 migration 已执行成功并更新历史状态', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.UPDATE) +async def mark_system_plugin_migration_success( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + recovery: PluginMigrationRecoveryModel, +) -> Response: + """ + 人工标记插件 migration 为成功。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param recovery: migration 人工恢复请求 + :return: 插件 migration 状态标记响应 + """ + mark_result = await get_plugin_runtime_service().mark_plugin_migration_success( + plugin_id, + recovery.migration_path, + note=recovery.note, + ) + logger.info(mark_result.get('message', '插件 migration 已人工标记为成功')) + + return _plugin_operation_response(mark_result, '插件 migration 已人工标记为成功') + + +@plugin_controller.post( + '/{plugin_id}/migrations/mark-failed', + summary='人工标记插件 migration 失败接口', + description='用于人工确认指定 migration 未完成并更新历史状态为可重试失败', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.UPDATE) +async def mark_system_plugin_migration_failed( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + recovery: PluginMigrationRecoveryModel, +) -> Response: + """ + 人工标记插件 migration 为失败。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param recovery: migration 人工恢复请求 + :return: 插件 migration 状态标记响应 + """ + mark_result = await get_plugin_runtime_service().mark_plugin_migration_failed( + plugin_id, + recovery.migration_path, + note=recovery.note, + ) + logger.info(mark_result.get('message', '插件 migration 已人工标记为失败')) + + return _plugin_operation_response(mark_result, '插件 migration 已人工标记为失败') + + +@plugin_controller.get( + '/{plugin_id}', + summary='获取插件详情接口', + description='用于获取指定插件的详情信息', + response_model=DataResponseModel[PluginModel], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def query_detail_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + """ + 获取插件详情。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param query_db: orm对象 + :return: 插件详情响应 + """ + plugin_detail_result = await PluginService.plugin_detail_services(query_db, plugin_id) + if not plugin_detail_result: + logger.warning(f'插件不存在:{plugin_id}') + return ResponseUtil.failure(msg='插件不存在') + logger.info(f'获取plugin_id为{plugin_id}的信息成功') + + return ResponseUtil.success(data=plugin_detail_result) + + +@plugin_controller.put( + '/{plugin_id}/enable', + summary='启用插件接口', + description='用于启用指定插件', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.UPDATE) +async def enable_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + query_db: Annotated[AsyncSession, DBSessionDependency()], +) -> Response: + """ + 启用插件。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param query_db: orm对象 + :return: 启用结果响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().set_plugin_enabled(plugin_id, enabled=True), + '插件启用完成', + ) + + +@plugin_controller.put( + '/{plugin_id}/disable', + summary='停用插件接口', + description='用于停用指定插件', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.UPDATE) +async def disable_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], +) -> Response: + """ + 停用插件。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :return: 停用结果响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().set_plugin_enabled(plugin_id, enabled=False), + '插件停用完成', + ) + + +@plugin_controller.get( + '/{plugin_id}/check', + summary='检查插件接口', + description='用于检查指定插件的目录结构、依赖和菜单冲突', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def check_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], +) -> Response: + """ + 检查插件。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :return: 插件检查结果响应 + """ + check_plugin_result = await get_plugin_runtime_service().check_plugin_async(plugin_id) + logger.info(check_plugin_result.get('message', '插件检查完成')) + + return _plugin_operation_response(check_plugin_result, '插件检查完成') + + +@plugin_controller.get( + '/{plugin_id}/health', + summary='执行插件健康检查接口', + description='用于执行指定插件声明的只读健康检查', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def health_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], +) -> Response: + """ + 执行插件健康检查。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :return: 插件健康检查响应 + """ + health_plugin_result = await get_plugin_runtime_service().health_plugin(plugin_id) + logger.info(health_plugin_result.get('message', '插件健康检查完成')) + + return _plugin_operation_response(health_plugin_result, '插件健康检查完成') + + +@plugin_controller.get( + '/{plugin_id}/diagnose', + summary='生成插件诊断包接口', + description='用于生成指定插件的只读诊断信息,包含详情、检查结果、配置脱敏快照和审计预留信息', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def diagnose_system_plugin( + request: Request, + query_db: Annotated[AsyncSession, DBSessionDependency()], + plugin_id: Annotated[str, Path(description='插件ID')], +) -> Response: + """ + 生成插件诊断包。 + + :param request: 请求对象 + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 插件诊断包响应 + """ + diagnose_plugin_result = await get_plugin_operation_service().diagnose_plugin_with_audit_services( + query_db, plugin_id + ) + logger.info(diagnose_plugin_result.get('message', '插件诊断包生成完成')) + + return _plugin_operation_response(diagnose_plugin_result, '插件诊断包生成完成') + + +@plugin_controller.get( + '/{plugin_id}/docs', + summary='生成插件文档接口', + description='用于根据 plugin.yaml 生成插件 Markdown 文档片段', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def generate_system_plugin_docs( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], +) -> Response: + """ + 生成插件 Markdown 文档片段。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :return: 插件文档生成响应 + """ + docs_result = get_plugin_runtime_service().generate_plugin_docs(plugin_id) + logger.info(docs_result.get('message', '插件文档生成完成')) + + return _plugin_operation_response(docs_result, '插件文档生成完成') + + +@plugin_controller.post( + '/{plugin_id}/install', + summary='安装插件接口', + description='用于安装指定插件', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.INSERT) +async def install_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + dry_run: Annotated[bool, Query(alias='dryRun', description='是否仅预演操作')] = False, +) -> Response: + """ + 安装插件。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param dry_run: 是否仅预演操作 + :param current_user: 当前登录用户 + :return: 插件安装结果响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().install_plugin( + plugin_id, + dry_run=dry_run, + operated_by=current_user.user.user_name, + ), + '插件安装完成', + ) + + +@plugin_controller.post( + '/{plugin_id}/upgrade', + summary='升级插件接口', + description='用于升级指定插件', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.UPDATE) +async def upgrade_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + dry_run: Annotated[bool, Query(alias='dryRun', description='是否仅预演操作')] = False, +) -> Response: + """ + 升级插件。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param dry_run: 是否仅预演操作 + :param current_user: 当前登录用户 + :return: 插件升级结果响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().upgrade_plugin( + plugin_id, + dry_run=dry_run, + operated_by=current_user.user.user_name, + ), + '插件升级完成', + ) + + +@plugin_controller.post( + '/{plugin_id}/uninstall', + summary='安全卸载插件接口', + description='用于安全卸载指定插件,第一阶段等价于停用插件和菜单', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.UPDATE) +async def uninstall_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + dry_run: Annotated[bool, Query(alias='dryRun', description='是否仅预演操作')] = False, +) -> Response: + """ + 安全卸载插件。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param dry_run: 是否仅预演操作 + :param current_user: 当前登录用户 + :return: 插件安全卸载结果响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().uninstall_plugin( + plugin_id, + dry_run=dry_run, + operated_by=current_user.user.user_name, + ), + '插件卸载完成', + ) + + +@plugin_controller.post( + '/{plugin_id}/purge', + summary='物理清理插件接口', + description='用于物理清理指定插件的平台元数据,默认不删除源码目录', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:remove')], +) +@Log(title='插件管理', business_type=BusinessType.DELETE) +async def purge_system_plugin( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + dry_run: Annotated[bool, Query(alias='dryRun', description='是否仅预演操作')] = False, +) -> Response: + """ + 物理清理插件平台元数据。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param dry_run: 是否仅预演操作 + :param current_user: 当前登录用户 + :return: 插件物理清理结果响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().purge_plugin( + plugin_id, + dry_run=dry_run, + operated_by=current_user.user.user_name, + ), + '插件物理清理完成', + ) + + +@plugin_controller.get( + '/{plugin_id}/config', + summary='获取插件配置接口', + description='用于获取指定插件的配置项', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def query_system_plugin_config( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], +) -> Response: + """ + 获取插件配置。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :return: 插件配置响应 + """ + plugin_config_result = await get_plugin_runtime_service().get_plugin_config(plugin_id) + logger.info(plugin_config_result.get('message', '插件配置读取完成')) + + return _plugin_operation_response(plugin_config_result, '插件配置读取完成') + + +@plugin_controller.put( + '/{plugin_id}/config', + summary='更新插件配置接口', + description='用于更新指定插件的配置项', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.UPDATE) +async def update_system_plugin_config( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + plugin_config: PluginConfigUpdateModel, +) -> Response: + """ + 更新插件配置。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param plugin_config: 插件配置更新对象 + :return: 插件配置更新响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().set_plugin_config( + plugin_id, + plugin_config.values, + ), + '插件配置已更新', + ) + + +@plugin_controller.get( + '/{plugin_id}/config/export', + summary='导出插件配置接口', + description='用于导出指定插件的配置快照,默认敏感配置保持脱敏', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:export')], +) +async def export_system_plugin_config( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + reveal_secret: Annotated[bool, Query(alias='revealSecret', description='是否导出敏感配置明文')] = False, +) -> Response: + """ + 导出插件配置。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param reveal_secret: 是否导出敏感配置明文 + :return: 插件配置导出响应 + """ + if reveal_secret: + return ResponseUtil.failure(msg='Web 端不允许导出敏感配置明文,请使用 CLI 通道') + plugin_config_result = await get_plugin_runtime_service().export_plugin_config( + plugin_id, + reveal_secret=False, + ) + logger.info(plugin_config_result.get('message', '插件配置导出完成')) + + return _plugin_operation_response(plugin_config_result, '插件配置导出完成') + + +@plugin_controller.post( + '/{plugin_id}/config/import', + summary='导入插件配置接口', + description='用于导入指定插件的配置快照', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:edit')], +) +@Log(title='插件管理', business_type=BusinessType.UPDATE) +async def import_system_plugin_config( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + plugin_config: PluginConfigImportModel, +) -> Response: + """ + 导入插件配置。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param plugin_config: 插件配置导入对象 + :return: 插件配置导入响应 + """ + return await _execute_plugin_operation( + lambda: get_plugin_runtime_service().import_plugin_config( + plugin_id, + plugin_config.values, + ), + '插件配置导入完成', + ) + + +@plugin_controller.get( + '/{plugin_id}/dependencies', + summary='检查插件依赖接口', + description='用于检查指定插件的 Python 和 npm 依赖', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def check_system_plugin_dependencies( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], +) -> Response: + """ + 检查插件依赖。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :return: 插件依赖检查响应 + """ + dependency_result = get_plugin_runtime_service().check_plugin_dependencies(plugin_id) + logger.info(dependency_result.get('message', '插件依赖检查完成')) + + return _plugin_operation_response(dependency_result, '插件依赖检查完成') + + +@plugin_controller.post( + '/{plugin_id}/dependencies/install', + summary='插件依赖安装计划接口', + description='用于生成指定插件的依赖安装计划,Web 第一版仅支持 dry-run', + response_model=DataResponseModel[dict], + dependencies=[UserInterfaceAuthDependency('system:plugin:query')], +) +async def install_system_plugin_dependencies( + request: Request, + plugin_id: Annotated[str, Path(description='插件ID')], + dry_run: Annotated[bool, Query(alias='dryRun', description='是否仅预演操作')] = True, +) -> Response: + """ + 生成插件依赖安装计划。 + + :param request: 请求对象 + :param plugin_id: 插件ID + :param dry_run: 是否仅预演操作 + :return: 插件依赖安装计划响应 + """ + dependency_result = get_plugin_runtime_service().install_plugin_dependencies( + plugin_id, + dry_run=dry_run if dry_run else True, + policy_config=DependencyInstallPolicyConfig(mode='plan_only', env='web'), + ) + logger.info(dependency_result.get('message', '插件依赖安装演练完成')) + + return _plugin_operation_response(dependency_result, '插件依赖安装演练完成') diff --git a/ruoyi-fastapi-backend/module_plugin/service/plugin_service.py b/ruoyi-fastapi-backend/module_plugin/service/plugin_service.py new file mode 100644 index 0000000..9a2da24 --- /dev/null +++ b/ruoyi-fastapi-backend/module_plugin/service/plugin_service.py @@ -0,0 +1,148 @@ +from typing import cast + +from sqlalchemy.ext.asyncio import AsyncSession + +from module_admin.dao.dict_dao import DictDataDao +from plugins.core.management.entity.vo.schemas import ( + PluginOperationLogDetailModel, + PluginOperationLogExportQueryModel, +) +from plugins.core.management.service.gateway import PluginManagementRuntimeGateway +from plugins.core.management.service.service import PluginService +from plugins.core.runtime.service import PluginRuntimeService +from plugins.core.runtime.service.dependency_container import PluginRuntimeGatewayOverrides +from plugins.core.runtime.service.lifecycle_lock import RedisPluginLifecycleLock +from plugins.core.runtime.service.responses import PluginDiagnoseResponse +from plugins.core.runtime.support import PluginAuditPayloadBuilder, PluginAuditSnapshotPayloadDict + +AUDIT_LOG_OVERFETCH_MULTIPLIER = 3 + + +class PluginOperationService: + """ + 插件管理操作服务。 + + 使用 Facade 模式复用插件应用运行时能力,为插件管理页面接口提供检查、安装和升级入口。 + """ + + def __init__(self, runtime_service: PluginRuntimeService | None = None) -> None: + """ + 初始化插件管理操作服务。 + + :param runtime_service: 插件运行时服务 + :return: None + """ + self.runtime_service = runtime_service or get_plugin_runtime_service() + + async def diagnose_plugin_with_audit_services( + self, + query_db: AsyncSession, + plugin_id: str, + *, + audit_limit: int = 5, + ) -> PluginDiagnoseResponse: + """ + 生成包含最近审计记录的插件诊断包。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param audit_limit: 最近审计记录数量 + :return: 插件诊断包负载 + """ + payload = cast('dict[str, object]', await self.runtime_service.diagnose_plugin(plugin_id)) + payload['audit'] = await self._build_recent_audit_snapshot(query_db, plugin_id, audit_limit=audit_limit) + + return cast('PluginDiagnoseResponse', payload) + + @classmethod + async def get_plugin_operation_dict_services(cls, query_db: AsyncSession) -> dict[str, str]: + """ + 获取插件管理页面使用的操作类型字典映射。 + + :param query_db: orm对象 + :return: 插件操作类型字典映射 + """ + dict_data_list = await DictDataDao.query_dict_data_list(query_db, 'plugin_operation_type') + + return { + dict_data.dict_value: dict_data.dict_label + for dict_data in dict_data_list + if dict_data and dict_data.dict_value + } + + @classmethod + async def _build_recent_audit_snapshot( + cls, + query_db: AsyncSession, + plugin_id: str, + *, + audit_limit: int, + ) -> PluginAuditSnapshotPayloadDict: + """ + 构建插件最近审计记录快照。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param audit_limit: 最近审计记录数量 + :return: 最近审计记录快照 + """ + operation_logs = await PluginService.get_plugin_operation_log_export_list_services( + query_db, + PluginOperationLogExportQueryModel(exportLimit=audit_limit * AUDIT_LOG_OVERFETCH_MULTIPLIER), + ) + return PluginAuditPayloadBuilder.build_recent_snapshot_payload( + plugin_id, + [ + operation_log + for operation_log in operation_logs + if isinstance(operation_log, PluginOperationLogDetailModel) + ], + audit_limit=audit_limit, + ) + + +_PLUGIN_OPERATION_SERVICE_CACHE: dict[str, PluginOperationService] = {} +_PLUGIN_RUNTIME_SERVICE_CACHE: dict[str, PluginRuntimeService] = {} + + +def get_plugin_runtime_service() -> PluginRuntimeService: + """ + 获取 Web 侧插件运行时服务单例。 + + :return: 插件运行时服务 + """ + runtime_service = _PLUGIN_RUNTIME_SERVICE_CACHE.get('default') + if runtime_service is None: + runtime_gateway = PluginManagementRuntimeGateway() + runtime_service = PluginRuntimeService( + gateways=PluginRuntimeGatewayOverrides( + config_gateway=runtime_gateway, + audit_gateway=runtime_gateway, + state_query_gateway=runtime_gateway, + migration_history_gateway=runtime_gateway, + purge_plan_gateway=runtime_gateway, + lifecycle_state_gateway=runtime_gateway, + lifecycle_uow_gateway=runtime_gateway, + migration_execution_gateway=runtime_gateway, + ), + model_gateway=runtime_gateway, + command_gateway=runtime_gateway, + lifecycle_lock=RedisPluginLifecycleLock(), + ) + _PLUGIN_RUNTIME_SERVICE_CACHE['default'] = runtime_service + + return runtime_service + + +def get_plugin_operation_service() -> PluginOperationService: + """ + 获取 Web 侧插件操作服务单例。 + + :return: 插件操作服务 + """ + operation_service = _PLUGIN_OPERATION_SERVICE_CACHE.get('default') + if operation_service is None: + operation_service = PluginOperationService() + _PLUGIN_OPERATION_SERVICE_CACHE['default'] = operation_service + + return operation_service diff --git a/ruoyi-fastapi-backend/plugins/__init__.py b/ruoyi-fastapi-backend/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruoyi-fastapi-backend/plugins/ai/README.md b/ruoyi-fastapi-backend/plugins/ai/README.md new file mode 100644 index 0000000..8535dfc --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/ai/README.md @@ -0,0 +1,113 @@ +# AI 管理后端插件 + +AI 管理插件提供模型配置、供应商字典、会话对话和流式输出能力。插件保留原有接口前缀和权限标识,安装后会在系统菜单中生成「AI 管理」目录,以及「模型管理」「AI 对话」两个页面。 + +## 功能 + +- 模型管理:维护模型编码、供应商、API Key、Base URL、温度、Token 限制、推理能力、图片能力和启停状态。 +- AI 对话:基于已启用模型发起对话,支持历史会话、系统提示词、温度配置、深度思考开关、图片输入和 SSE 流式返回。 +- 字典数据:安装时写入 `ai_provider_type` 字典,用于模型供应商下拉选择。 +- 多数据库脚本:内置 MySQL 和 PostgreSQL 的建表脚本与字典种子脚本。 +- 依赖声明:Python 与前端 npm 依赖统一声明在 `plugin.yaml`,由插件依赖检查与安装流程处理。 + +## 接口与权限 + +后端接口: + +- `/ai/model` +- `/ai/chat` + +权限标识: + +- `ai:model:list`:模型列表 +- `ai:model:add`:新增模型 +- `ai:model:edit`:修改模型 +- `ai:model:remove`:删除模型 +- `ai:model:query`:查询模型 +- `ai:chat:list`:AI 对话 + +## 目录说明 + +```text +plugins/ai/ + plugin.yaml + controller/ + service/ + dao/ + entity/ + utils/ + migrations/ + mysql/001_init.sql + postgresql/001_init.sql + seeds/ + mysql/ai_provider_type.sql + postgresql/ai_provider_type.sql + README.md +``` + +- `plugin.yaml`:插件清单,声明菜单、权限、依赖、迁移脚本和种子脚本。 +- `controller/`:FastAPI 控制器,由插件运行时自动扫描注册。 +- `service/`:AI 业务逻辑,包括模型解析、对话流式输出、历史会话和用户配置。 +- `dao/`:数据库访问层。 +- `entity/`:数据库模型与请求响应模型。 +- `utils/`:模型工厂、存储引擎等 AI 辅助能力。 +- `migrations/`:插件安装时执行的表结构脚本。 +- `seeds/`:插件安装时执行的初始化数据脚本。 + +## 配置来源 + +AI 插件不声明插件级默认配置。实际对话使用以下业务配置: + +- 模型管理:维护供应商、模型编码、API Key、Base URL、温度、Token 限制和模型能力。 +- AI 对话配置:维护用户维度的系统提示词、历史上下文、温度和视觉输入等偏好。 + +模型凭证按模型单独维护,API Key 会加密存储;不要把模型密钥写入插件清单或环境文件。 + +## 启用方式 + +AI 插件作为内置插件随项目提供。默认启用列表由环境变量 `APP_DEFAULT_ENABLED_PLUGINS` 控制,标准环境文件中已包含 `ai`。 + +常用命令: + +```bash +ruoyi plugin check ai --env=dev +ruoyi plugin install ai --env=dev --yes +ruoyi plugin enable ai --env=dev --yes +ruoyi plugin disable ai --env=dev --yes +``` + +首次在新环境启动或安装前,请先执行插件检查,确认 Python 依赖、前端依赖、菜单冲突、数据库脚本和目录结构都满足要求。 + +## 依赖说明 + +后端主要依赖由 `plugin.yaml` 声明,包括: + +- `agno` +- `openai` +- `anthropic` +- `cohere` +- `google-genai` +- `groq` +- `litellm` +- `mistralai` +- `ollama` +- 以及其他供应商 SDK + +插件启动期会检查已启用插件的 Python 依赖。缺失依赖时,CLI 启动流程会提示是否安装依赖;生产环境建议在发布阶段提前安装,避免运行期临时拉取依赖。 + +## 数据库 + +安装插件时会执行当前数据库类型对应的脚本: + +- MySQL:`migrations/mysql/001_init.sql`、`seeds/mysql/ai_provider_type.sql` +- PostgreSQL:`migrations/postgresql/001_init.sql`、`seeds/postgresql/ai_provider_type.sql` + +脚本按插件生命周期执行,支持重复检查和按数据库类型过滤。 + +## 开发注意 + +- 后端模块路径必须保持为 `plugins.ai`,与插件 ID 对齐。 +- 控制器文件放在 `controller/` 下,保持自动扫描可发现。 +- 菜单组件路径需要与前端插件目录保持一致,例如 `plugin/ai/model/index`。 +- 新增权限时,需要同时更新 `plugin.yaml` 的 `permissions` 和相关菜单或按钮权限。 +- 涉及 API Key、Token、凭证的配置应使用敏感配置和加密存储,不要写入日志。 diff --git a/ruoyi-fastapi-backend/module_ai/controller/ai_chat_controller.py b/ruoyi-fastapi-backend/plugins/ai/controller/ai_chat_controller.py similarity index 83% rename from ruoyi-fastapi-backend/module_ai/controller/ai_chat_controller.py rename to ruoyi-fastapi-backend/plugins/ai/controller/ai_chat_controller.py index 4255c7b..1b2cc3e 100644 --- a/ruoyi-fastapi-backend/module_ai/controller/ai_chat_controller.py +++ b/ruoyi-fastapi-backend/plugins/ai/controller/ai_chat_controller.py @@ -2,30 +2,38 @@ from typing import Annotated from fastapi import Body, Path, Request, Response from fastapi.responses import StreamingResponse +from sqlalchemy import ColumnElement from sqlalchemy.ext.asyncio import AsyncSession from common.annotation.cache_annotation import ApiCache, ApiCacheEvict from common.annotation.log_annotation import Log from common.annotation.rate_limit_annotation import ApiRateLimit, ApiRateLimitPreset +from common.aspect.data_scope import DataScopeDependency from common.aspect.db_seesion import DBSessionDependency +from common.aspect.interface_auth import UserInterfaceAuthDependency from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency from common.constant import ApiGroup, ApiNamespace from common.enums import BusinessType from common.router import APIRouterPro from common.vo import DataResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel -from module_ai.entity.vo.ai_chat_vo import ( +from plugins.ai.entity.do.ai_model_do import AiModels +from plugins.ai.entity.vo.ai_chat_vo import ( AiChatConfigModel, AiChatRequestModel, AiChatSessionBaseModel, AiChatSessionModel, ) -from module_ai.service.ai_chat_service import AiChatService +from plugins.ai.service.ai_chat_service import AiChatService +from plugins.ai.service.ai_model_service import AiModelService from utils.log_util import logger from utils.response_util import ResponseUtil ai_chat_controller = APIRouterPro( - prefix='/ai/chat', order_num=19, tags=['AI管理-AI对话'], dependencies=[PreAuthDependency()] + prefix='/ai/chat', + order_num=19, + tags=['AI管理-AI对话'], + dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('ai:chat:list')], ) @@ -49,8 +57,11 @@ async def send_chat_message( chat_req: AiChatRequestModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(AiModels)], ) -> StreamingResponse: user_id = current_user.user.user_id if current_user and current_user.user else 1 + if not current_user.user.admin: + await AiModelService.check_ai_model_data_scope_services(query_db, chat_req.model_id, data_scope_sql) chat_stream = AiChatService.chat_services(query_db, chat_req, user_id) logger.info(f'用户{user_id}发送对话消息成功') @@ -124,8 +135,12 @@ async def delete_chat_session( request: Request, session_id: Annotated[str, Path(description='会话ID')], query_db: Annotated[AsyncSession, DBSessionDependency()], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], ) -> Response: - delete_chat_session_result = await AiChatService.delete_chat_session_services(session_id) + delete_chat_session_result = await AiChatService.delete_chat_session_services( + session_id, + current_user.user.user_id, + ) logger.info(delete_chat_session_result.message) return ResponseUtil.success(msg=delete_chat_session_result.message) @@ -140,8 +155,12 @@ async def delete_chat_session( async def get_chat_session_detail( request: Request, session_id: Annotated[str, Path(description='会话ID')], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], ) -> Response: - chat_session_detail_result = await AiChatService.get_chat_session_detail_services(session_id) + chat_session_detail_result = await AiChatService.get_chat_session_detail_services( + session_id, + current_user.user.user_id, + ) logger.info(f'获取session_id为{session_id}的信息成功') return ResponseUtil.success(data=chat_session_detail_result) @@ -157,8 +176,9 @@ async def get_chat_session_detail( async def cancel_chat_run( request: Request, run_id: Annotated[str, Body(embed=True, description='运行ID', alias='runId')], + current_user: Annotated[CurrentUserModel, CurrentUserDependency()], ) -> Response: - cancel_result = await AiChatService.cancel_run_services(run_id) + cancel_result = await AiChatService.cancel_run_services(run_id, current_user.user.user_id) logger.info(cancel_result.message) return ResponseUtil.success(msg=cancel_result.message) diff --git a/ruoyi-fastapi-backend/module_ai/controller/ai_model_controller.py b/ruoyi-fastapi-backend/plugins/ai/controller/ai_model_controller.py similarity index 95% rename from ruoyi-fastapi-backend/module_ai/controller/ai_model_controller.py rename to ruoyi-fastapi-backend/plugins/ai/controller/ai_model_controller.py index 60292ab..131550c 100644 --- a/ruoyi-fastapi-backend/module_ai/controller/ai_model_controller.py +++ b/ruoyi-fastapi-backend/plugins/ai/controller/ai_model_controller.py @@ -17,14 +17,17 @@ from common.enums import BusinessType from common.router import APIRouterPro from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from module_admin.entity.vo.user_vo import CurrentUserModel -from module_ai.entity.do.ai_model_do import AiModels -from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel -from module_ai.service.ai_model_service import AiModelService +from plugins.ai.entity.do.ai_model_do import AiModels +from plugins.ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel +from plugins.ai.service.ai_model_service import AiModelService from utils.log_util import logger from utils.response_util import ResponseUtil ai_model_controller = APIRouterPro( - prefix='/ai/model', order_num=18, tags=['AI管理-模型管理'], dependencies=[PreAuthDependency()] + prefix='/ai/model', + order_num=18, + tags=['AI管理-模型管理'], + dependencies=[PreAuthDependency()], ) diff --git a/ruoyi-fastapi-backend/module_ai/dao/ai_chat_dao.py b/ruoyi-fastapi-backend/plugins/ai/dao/ai_chat_dao.py similarity index 92% rename from ruoyi-fastapi-backend/module_ai/dao/ai_chat_dao.py rename to ruoyi-fastapi-backend/plugins/ai/dao/ai_chat_dao.py index b7677e1..5e3bae7 100644 --- a/ruoyi-fastapi-backend/module_ai/dao/ai_chat_dao.py +++ b/ruoyi-fastapi-backend/plugins/ai/dao/ai_chat_dao.py @@ -1,8 +1,8 @@ from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from module_ai.entity.do.ai_chat_do import AiChatConfig -from module_ai.entity.vo.ai_chat_vo import AiChatConfigModel +from plugins.ai.entity.do.ai_chat_do import AiChatConfig +from plugins.ai.entity.vo.ai_chat_vo import AiChatConfigModel class AiChatConfigDao: diff --git a/ruoyi-fastapi-backend/module_ai/dao/ai_model_dao.py b/ruoyi-fastapi-backend/plugins/ai/dao/ai_model_dao.py similarity index 95% rename from ruoyi-fastapi-backend/module_ai/dao/ai_model_dao.py rename to ruoyi-fastapi-backend/plugins/ai/dao/ai_model_dao.py index 60aa59f..c4c0f1c 100644 --- a/ruoyi-fastapi-backend/module_ai/dao/ai_model_dao.py +++ b/ruoyi-fastapi-backend/plugins/ai/dao/ai_model_dao.py @@ -4,8 +4,8 @@ from sqlalchemy import ColumnElement, delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from common.vo import PageModel -from module_ai.entity.do.ai_model_do import AiModels -from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel +from plugins.ai.entity.do.ai_model_do import AiModels +from plugins.ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel from utils.page_util import PageUtil diff --git a/ruoyi-fastapi-backend/module_ai/entity/do/ai_chat_do.py b/ruoyi-fastapi-backend/plugins/ai/entity/do/ai_chat_do.py similarity index 100% rename from ruoyi-fastapi-backend/module_ai/entity/do/ai_chat_do.py rename to ruoyi-fastapi-backend/plugins/ai/entity/do/ai_chat_do.py diff --git a/ruoyi-fastapi-backend/module_ai/entity/do/ai_model_do.py b/ruoyi-fastapi-backend/plugins/ai/entity/do/ai_model_do.py similarity index 100% rename from ruoyi-fastapi-backend/module_ai/entity/do/ai_model_do.py rename to ruoyi-fastapi-backend/plugins/ai/entity/do/ai_model_do.py diff --git a/ruoyi-fastapi-backend/module_ai/entity/vo/ai_chat_vo.py b/ruoyi-fastapi-backend/plugins/ai/entity/vo/ai_chat_vo.py similarity index 100% rename from ruoyi-fastapi-backend/module_ai/entity/vo/ai_chat_vo.py rename to ruoyi-fastapi-backend/plugins/ai/entity/vo/ai_chat_vo.py diff --git a/ruoyi-fastapi-backend/module_ai/entity/vo/ai_model_vo.py b/ruoyi-fastapi-backend/plugins/ai/entity/vo/ai_model_vo.py similarity index 100% rename from ruoyi-fastapi-backend/module_ai/entity/vo/ai_model_vo.py rename to ruoyi-fastapi-backend/plugins/ai/entity/vo/ai_model_vo.py diff --git a/ruoyi-fastapi-backend/plugins/ai/migrations/.gitkeep b/ruoyi-fastapi-backend/plugins/ai/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ruoyi-fastapi-backend/plugins/ai/migrations/mysql/001_init.sql b/ruoyi-fastapi-backend/plugins/ai/migrations/mysql/001_init.sql new file mode 100644 index 0000000..052cc37 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/ai/migrations/mysql/001_init.sql @@ -0,0 +1,47 @@ +-- ---------------------------- +-- AI模型表 +-- ---------------------------- +drop table if exists ai_models; +create table ai_models ( + model_id bigint(20) not null auto_increment comment '模型主键', + model_code varchar(100) not null comment '模型编码', + model_name varchar(100) default null comment '模型名称', + provider varchar(50) not null comment '提供商', + model_sort int(4) not null comment '显示顺序', + api_key varchar(255) default null comment 'API Key', + base_url varchar(255) default null comment 'Base URL', + model_type varchar(50) default null comment '模型类型', + max_tokens int(11) default null comment '最大输出token', + temperature float default null comment '默认温度', + support_reasoning char(1) default 'N' comment '是否支持推理', + support_images char(1) default 'N' comment '是否支持图片', + status char(1) default '0' comment '模型状态', + user_id bigint(20) comment '用户ID', + dept_id bigint(20) comment '部门ID', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (model_id) +) engine=innodb auto_increment=1 comment = 'AI模型表'; + + +-- ---------------------------- +-- AI对话配置表 +-- ---------------------------- +drop table if exists ai_chat_config; +create table ai_chat_config ( + chat_config_id bigint(20) not null auto_increment comment '配置主键', + user_id bigint(20) not null unique comment '用户ID', + temperature float default null comment '默认温度', + add_history_to_context char(1) default '0' comment '是否添加历史记录(0是, 1否)', + num_history_runs int(4) default null comment '历史记录条数', + system_prompt text default null comment '系统提示词', + metrics_default_visible char(1) default '0' comment '默认显示指标(0是, 1否)', + vision_enabled char(1) default '1' comment '是否开启视觉(0是, 1否)', + image_max_size_mb int(4) default null comment '图片最大大小(MB)', + create_time datetime comment '创建时间', + update_time datetime comment '更新时间', + primary key (chat_config_id) +) engine=innodb auto_increment=1 comment = 'AI对话配置表'; diff --git a/ruoyi-fastapi-backend/plugins/ai/migrations/postgresql/001_init.sql b/ruoyi-fastapi-backend/plugins/ai/migrations/postgresql/001_init.sql new file mode 100644 index 0000000..0c2f9af --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/ai/migrations/postgresql/001_init.sql @@ -0,0 +1,79 @@ +-- ---------------------------- +-- AI模型表 +-- ---------------------------- +drop table if exists ai_models; +create table ai_models ( + model_id bigserial not null, + model_code varchar(100) not null, + model_name varchar(100) default null, + provider varchar(50) not null, + model_sort int4 not null, + api_key varchar(255) default null, + base_url varchar(255) default null, + model_type varchar(50) default null, + max_tokens integer default null, + temperature float default null, + support_reasoning char(1) default 'N', + support_images char(1) default 'N', + status char(1) default '0', + user_id bigint, + dept_id bigint, + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (model_id) +); +comment on table ai_models is 'AI模型表'; +comment on column ai_models.model_id is '模型主键'; +comment on column ai_models.model_code is '模型编码'; +comment on column ai_models.model_name is '模型名称'; +comment on column ai_models.provider is '提供商'; +comment on column ai_models.model_sort is '显示顺序'; +comment on column ai_models.api_key is 'API Key'; +comment on column ai_models.base_url is 'Base URL'; +comment on column ai_models.model_type is '模型类型'; +comment on column ai_models.max_tokens is '最大输出token'; +comment on column ai_models.temperature is '默认温度'; +comment on column ai_models.support_reasoning is '是否支持推理'; +comment on column ai_models.support_images is '是否支持图片'; +comment on column ai_models.status is '模型状态'; +comment on column ai_models.user_id is '用户ID'; +comment on column ai_models.dept_id is '部门ID'; +comment on column ai_models.create_by is '创建者'; +comment on column ai_models.create_time is '创建时间'; +comment on column ai_models.update_by is '更新者'; +comment on column ai_models.update_time is '更新时间'; +comment on column ai_models.remark is '备注'; + +-- ---------------------------- +-- AI对话配置表 +-- ---------------------------- +drop table if exists ai_chat_config; +create table ai_chat_config ( + chat_config_id bigserial not null, + user_id bigint not null unique, + temperature float default null, + add_history_to_context char(1) default '0', + num_history_runs int4 default null, + system_prompt text default null, + metrics_default_visible char(1) default '0', + vision_enabled char(1) default '1', + image_max_size_mb int4 default null, + create_time timestamp(0), + update_time timestamp(0), + primary key (chat_config_id) +); +comment on table ai_chat_config is 'AI对话配置表'; +comment on column ai_chat_config.chat_config_id is '配置主键'; +comment on column ai_chat_config.user_id is '用户ID'; +comment on column ai_chat_config.temperature is '默认温度'; +comment on column ai_chat_config.add_history_to_context is '是否添加历史记录(0是, 1否)'; +comment on column ai_chat_config.num_history_runs is '历史记录条数'; +comment on column ai_chat_config.system_prompt is '系统提示词'; +comment on column ai_chat_config.metrics_default_visible is '默认显示指标(0是, 1否)'; +comment on column ai_chat_config.vision_enabled is '是否开启视觉(0是, 1否)'; +comment on column ai_chat_config.image_max_size_mb is '图片最大大小(MB)'; +comment on column ai_chat_config.create_time is '创建时间'; +comment on column ai_chat_config.update_time is '更新时间'; diff --git a/ruoyi-fastapi-backend/plugins/ai/plugin.yaml b/ruoyi-fastapi-backend/plugins/ai/plugin.yaml new file mode 100644 index 0000000..385b7b8 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/ai/plugin.yaml @@ -0,0 +1,104 @@ +manifestVersion: 1 +id: ai +name: AI 管理 +version: 0.1.0 +description: AI 模型管理与智能对话插件。 + +metadata: + category: ai + tags: + - ai + - chat + author: insistence + license: MIT + +backend: + module: plugins.ai + routers: + autoScan: true + migrations: + - migrations/mysql/001_init.sql + - migrations/postgresql/001_init.sql + seeds: + - seeds/mysql/ai_provider_type.sql + - seeds/postgresql/ai_provider_type.sql + jobs: [] + +frontend: + basePath: ai + pluginId: ai + viewsPath: views + apiPath: api + delivery: + type: source + buildRequired: true + menus: + - name: AI 管理 + path: ai + component: Layout + perms: '' + type: M + orderNum: 4 + icon: ai-manage + children: + - name: 模型管理 + path: model + component: plugin/ai/model/index + routeName: AiModel + perms: ai:model:list + type: C + orderNum: 1 + icon: ai-model + - name: AI 对话 + path: chat + component: plugin/ai/chat/index + routeName: AiChat + perms: ai:chat:list + type: C + orderNum: 2 + icon: ai-chat + +permissions: + - code: ai:model:list + name: 模型列表 + - code: ai:model:add + name: 新增模型 + - code: ai:model:edit + name: 修改模型 + - code: ai:model:remove + name: 删除模型 + - code: ai:model:query + name: 查询模型 + - code: ai:chat:list + name: AI 对话 + +dependencies: + python: + - agno==2.4.8 + - anthropic==0.78.0 + - cerebras-cloud-sdk==1.67.0 + - cohere==5.20.4 + - google-genai==1.62.0 + - groq==1.0.0 + - litellm==1.81.8 + - llama-api-client==0.6.0 + - mistralai==1.12.0 + - ollama==0.6.1 + - openai==2.17.0 + - portkey-ai==2.1.0 + npm: + - '@antv/infographic^0.2.13' + - katex>=0.16.27 + - markstream-vue>=0.0.7-beta.6 + - mermaid>=11.12.2 + - shiki^3.21.0 + - stream-markdown>=0.0.14 + - stream-monaco>=0.0.17 + npmDev: + - vite-plugin-monaco-editor-esm==2.0.2 + plugins: [] + +compatibility: + databases: + - mysql + - postgresql diff --git a/ruoyi-fastapi-backend/plugins/ai/seeds/.gitkeep b/ruoyi-fastapi-backend/plugins/ai/seeds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ruoyi-fastapi-backend/plugins/ai/seeds/mysql/ai_provider_type.sql b/ruoyi-fastapi-backend/plugins/ai/seeds/mysql/ai_provider_type.sql new file mode 100644 index 0000000..cc6f0ca --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/ai/seeds/mysql/ai_provider_type.sql @@ -0,0 +1,121 @@ +-- ---------------------------- +-- 初始化-字典类型表数据 +-- ---------------------------- +insert into sys_dict_type(dict_name, dict_type, status, create_by, create_time, update_by, update_time, remark) +select 'AI模型提供商', 'ai_provider_type', '0', 'plugin:ai', sysdate(), '', null, 'AI模型提供商列表' +where not exists (select 1 from sys_dict_type where dict_type = 'ai_provider_type'); + +-- ---------------------------- +-- 初始化-字典数据表数据 +-- ---------------------------- +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 1, 'AIMLAPI', 'AIMLAPI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'AIMLAPI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'AIMLAPI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 2, 'Anthropic', 'Anthropic', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Anthropic' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Anthropic'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 3, 'Cerebras', 'Cerebras', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Cerebras' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Cerebras'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 4, 'CerebrasOpenAI', 'CerebrasOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'CerebrasOpenAI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'CerebrasOpenAI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 5, 'Cohere', 'Cohere', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Cohere' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Cohere'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 6, 'CometAPI', 'CometAPI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'CometAPI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'CometAPI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 7, 'DashScope', 'DashScope', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'DashScope' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'DashScope'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 8, 'DeepInfra', 'DeepInfra', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'DeepInfra' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'DeepInfra'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 9, 'DeepSeek', 'DeepSeek', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'DeepSeek' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'DeepSeek'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 10, 'Fireworks', 'Fireworks', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Fireworks' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Fireworks'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 11, 'Google', 'Google', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Google' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Google'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 12, 'Groq', 'Groq', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Groq' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Groq'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 13, 'HuggingFace', 'HuggingFace', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'HuggingFace' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'HuggingFace'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 14, 'LangDB', 'LangDB', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'LangDB' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LangDB'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 15, 'LiteLLM', 'LiteLLM', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'LiteLLM' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LiteLLM'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 16, 'LiteLLMOpenAI', 'LiteLLMOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'LiteLLMOpenAI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LiteLLMOpenAI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 17, 'LlamaCpp', 'LlamaCpp', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'LlamaCpp' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LlamaCpp'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 18, 'LMStudio', 'LMStudio', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'LMStudio' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LMStudio'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 19, 'Meta', 'Meta', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Meta' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Meta'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 20, 'Mistral', 'Mistral', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Mistral' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Mistral'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 21, 'N1N', 'N1N', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'N1N' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'N1N'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 22, 'Nebius', 'Nebius', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Nebius' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Nebius'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 23, 'Nexus', 'Nexus', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Nexus' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Nexus'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 24, 'Nvidia', 'Nvidia', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Nvidia' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Nvidia'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 25, 'Ollama', 'Ollama', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Ollama' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Ollama'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 26, 'OpenAI', 'OpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'OpenAI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'OpenAI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 27, 'OpenAIResponses', 'OpenAIResponses', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'OpenAIResponses' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'OpenAIResponses'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 28, 'OpenRouter', 'OpenRouter', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'OpenRouter' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'OpenRouter'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 29, 'Perplexity', 'Perplexity', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Perplexity' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Perplexity'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 30, 'Portkey', 'Portkey', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Portkey' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Portkey'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 31, 'Requesty', 'Requesty', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Requesty' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Requesty'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 32, 'Sambanova', 'Sambanova', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Sambanova' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Sambanova'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 33, 'SiliconFlow', 'SiliconFlow', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'SiliconFlow' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'SiliconFlow'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 34, 'Together', 'Together', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Together' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Together'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 35, 'Vercel', 'Vercel', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'Vercel' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Vercel'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 36, 'VLLM', 'VLLM', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'VLLM' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'VLLM'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 37, 'xAI', 'xAI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', sysdate(), '', null, 'xAI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'xAI'); diff --git a/ruoyi-fastapi-backend/plugins/ai/seeds/postgresql/ai_provider_type.sql b/ruoyi-fastapi-backend/plugins/ai/seeds/postgresql/ai_provider_type.sql new file mode 100644 index 0000000..61511ef --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/ai/seeds/postgresql/ai_provider_type.sql @@ -0,0 +1,121 @@ +-- ---------------------------- +-- 初始化-字典类型表数据 +-- ---------------------------- +insert into sys_dict_type(dict_name, dict_type, status, create_by, create_time, update_by, update_time, remark) +select 'AI模型提供商', 'ai_provider_type', '0', 'plugin:ai', current_timestamp, '', null, 'AI模型提供商列表' +where not exists (select 1 from sys_dict_type where dict_type = 'ai_provider_type'); + +-- ---------------------------- +-- 初始化-字典数据表数据 +-- ---------------------------- +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 1, 'AIMLAPI', 'AIMLAPI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'AIMLAPI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'AIMLAPI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 2, 'Anthropic', 'Anthropic', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Anthropic' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Anthropic'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 3, 'Cerebras', 'Cerebras', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Cerebras' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Cerebras'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 4, 'CerebrasOpenAI', 'CerebrasOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'CerebrasOpenAI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'CerebrasOpenAI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 5, 'Cohere', 'Cohere', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Cohere' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Cohere'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 6, 'CometAPI', 'CometAPI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'CometAPI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'CometAPI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 7, 'DashScope', 'DashScope', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'DashScope' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'DashScope'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 8, 'DeepInfra', 'DeepInfra', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'DeepInfra' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'DeepInfra'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 9, 'DeepSeek', 'DeepSeek', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'DeepSeek' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'DeepSeek'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 10, 'Fireworks', 'Fireworks', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Fireworks' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Fireworks'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 11, 'Google', 'Google', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Google' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Google'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 12, 'Groq', 'Groq', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Groq' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Groq'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 13, 'HuggingFace', 'HuggingFace', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'HuggingFace' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'HuggingFace'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 14, 'LangDB', 'LangDB', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'LangDB' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LangDB'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 15, 'LiteLLM', 'LiteLLM', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'LiteLLM' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LiteLLM'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 16, 'LiteLLMOpenAI', 'LiteLLMOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'LiteLLMOpenAI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LiteLLMOpenAI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 17, 'LlamaCpp', 'LlamaCpp', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'LlamaCpp' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LlamaCpp'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 18, 'LMStudio', 'LMStudio', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'LMStudio' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'LMStudio'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 19, 'Meta', 'Meta', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Meta' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Meta'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 20, 'Mistral', 'Mistral', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Mistral' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Mistral'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 21, 'N1N', 'N1N', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'N1N' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'N1N'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 22, 'Nebius', 'Nebius', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Nebius' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Nebius'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 23, 'Nexus', 'Nexus', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Nexus' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Nexus'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 24, 'Nvidia', 'Nvidia', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Nvidia' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Nvidia'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 25, 'Ollama', 'Ollama', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Ollama' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Ollama'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 26, 'OpenAI', 'OpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'OpenAI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'OpenAI'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 27, 'OpenAIResponses', 'OpenAIResponses', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'OpenAIResponses' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'OpenAIResponses'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 28, 'OpenRouter', 'OpenRouter', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'OpenRouter' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'OpenRouter'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 29, 'Perplexity', 'Perplexity', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Perplexity' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Perplexity'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 30, 'Portkey', 'Portkey', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Portkey' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Portkey'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 31, 'Requesty', 'Requesty', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Requesty' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Requesty'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 32, 'Sambanova', 'Sambanova', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Sambanova' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Sambanova'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 33, 'SiliconFlow', 'SiliconFlow', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'SiliconFlow' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'SiliconFlow'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 34, 'Together', 'Together', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Together' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Together'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 35, 'Vercel', 'Vercel', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'Vercel' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'Vercel'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 36, 'VLLM', 'VLLM', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'VLLM' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'VLLM'); +insert into sys_dict_data(dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, update_by, update_time, remark) +select 37, 'xAI', 'xAI', 'ai_provider_type', '', 'info', 'N', '0', 'plugin:ai', current_timestamp, '', null, 'xAI' +where not exists (select 1 from sys_dict_data where dict_type = 'ai_provider_type' and dict_value = 'xAI'); diff --git a/ruoyi-fastapi-backend/module_ai/service/ai_chat_service.py b/ruoyi-fastapi-backend/plugins/ai/service/ai_chat_service.py similarity index 82% rename from ruoyi-fastapi-backend/module_ai/service/ai_chat_service.py rename to ruoyi-fastapi-backend/plugins/ai/service/ai_chat_service.py index dfc5406..bd05061 100644 --- a/ruoyi-fastapi-backend/module_ai/service/ai_chat_service.py +++ b/ruoyi-fastapi-backend/plugins/ai/service/ai_chat_service.py @@ -10,15 +10,15 @@ from agno.db.base import SessionType from agno.media import Image from agno.run.agent import RunEvent, RunOutput, RunOutputEvent from agno.run.cancel import acancel_run +from agno.session import Session from sqlalchemy.ext.asyncio import AsyncSession from common.vo import CrudResponseModel from config.env import UploadConfig from exceptions.exception import ServiceException -from module_ai.dao.ai_chat_dao import AiChatConfigDao -from module_ai.dao.ai_model_dao import AiModelDao -from module_ai.entity.do.ai_chat_do import AiChatConfig -from module_ai.entity.vo.ai_chat_vo import ( +from plugins.ai.dao.ai_chat_dao import AiChatConfigDao +from plugins.ai.dao.ai_model_dao import AiModelDao +from plugins.ai.entity.vo.ai_chat_vo import ( AgentDataModel, AiChatConfigModel, AiChatRequestModel, @@ -29,8 +29,8 @@ from module_ai.entity.vo.ai_chat_vo import ( SessionDataModel, SessionMetricsModel, ) -from module_ai.entity.vo.ai_model_vo import AiModelModel -from utils.ai_util import AiUtil +from plugins.ai.entity.vo.ai_model_vo import AiModelModel +from plugins.ai.utils.ai_util import AiUtil from utils.common_util import CamelCaseUtil from utils.crypto_util import CryptoUtil @@ -38,7 +38,6 @@ if TYPE_CHECKING: from agno.models.message import Message from agno.run.team import TeamRunOutput from agno.run.workflow import WorkflowRunOutput - from agno.session import Session class AiChatService: @@ -145,22 +144,41 @@ class AiChatService: :return: 运行参数字典 """ run_kwargs: dict[str, Any] = {'stream': True, 'stream_events': True} - if not chat_req.images or not user_config.vision_enabled: + if not chat_req.images or user_config.vision_enabled != '0': return run_kwargs processed_images: list[Image] = [] for img in chat_req.images: - if img and img.startswith(UploadConfig.UPLOAD_PREFIX): - relative_path = img[len(UploadConfig.UPLOAD_PREFIX) :] - if relative_path.startswith('/'): - relative_path = relative_path[1:] - file_path = os.path.join(UploadConfig.UPLOAD_PATH, relative_path) - abs_path = os.path.abspath(file_path) - if os.path.exists(abs_path): - processed_images.append(Image(filepath=abs_path)) + abs_path = cls._resolve_upload_image_path(img) + if abs_path: + processed_images.append(Image(filepath=abs_path)) run_kwargs['images'] = processed_images return run_kwargs + @classmethod + def _resolve_upload_image_path(cls, image_path: str | None) -> str | None: + """ + 解析并校验上传目录内的图片路径。 + + :param image_path: 前端图片路径 + :return: 绝对图片路径 + """ + if not image_path or not image_path.startswith(UploadConfig.UPLOAD_PREFIX): + return None + + relative_path = image_path[len(UploadConfig.UPLOAD_PREFIX) :].lstrip('/\\') + abs_upload_path = os.path.abspath(UploadConfig.UPLOAD_PATH) + abs_path = os.path.abspath(os.path.join(abs_upload_path, relative_path)) + try: + if os.path.commonpath([abs_upload_path, abs_path]) != abs_upload_path: + return None + except ValueError: + return None + if not os.path.isfile(abs_path): + return None + + return abs_path + @classmethod def _convert_images_to_upload_paths(cls, images: list[Image] | None) -> list[str] | None: """ @@ -181,7 +199,7 @@ class AiChatService: abs_filepath = os.path.abspath(img.filepath) abs_upload_path = os.path.abspath(UploadConfig.UPLOAD_PATH) - if abs_filepath.startswith(abs_upload_path): + if os.path.commonpath([abs_upload_path, abs_filepath]) == abs_upload_path: relative_path = os.path.relpath(abs_filepath, abs_upload_path) # 转换路径分隔符为URL格式 url_path = relative_path.replace(os.sep, '/') @@ -267,15 +285,19 @@ class AiChatService: :return: 对话响应流 """ ai_model = await AiModelDao.get_ai_model_detail_by_id(query_db, chat_req.model_id) - model_config = AiModelModel(**CamelCaseUtil.transform_result(ai_model)) if ai_model else AiModelModel() - if not model_config: + if ai_model is None: raise ServiceException(message='模型不存在') + model_config = AiModelModel(**CamelCaseUtil.transform_result(ai_model)) + if model_config.status != '0': + raise ServiceException(message='模型已停用') user_config = await cls.ai_chat_config_detail_services(query_db, user_id) session_id = chat_req.session_id if not session_id: session_id = str(uuid.uuid4()) + else: + await cls._get_owned_session(AiUtil.get_storage_engine(), session_id, user_id) temperature = cls._resolve_temperature(user_config, model_config) is_reasoning = cls._resolve_is_reasoning(chat_req, model_config) @@ -311,7 +333,9 @@ class AiChatService: :return: 配置模型 """ chat_config = await AiChatConfigDao.get_chat_config_detail_by_user_id(query_db, user_id) - result = AiChatConfigModel(**CamelCaseUtil.transform_result(chat_config)) if chat_config else AiChatConfig() + result = ( + AiChatConfigModel(**CamelCaseUtil.transform_result(chat_config)) if chat_config else AiChatConfigModel() + ) return result @@ -385,32 +409,50 @@ class AiChatService: return result @classmethod - async def delete_chat_session_services(cls, session_id: str) -> CrudResponseModel: + async def _get_owned_session(cls, storage: Any, session_id: str, user_id: int) -> Session: + """ + 获取并校验当前用户拥有的会话。 + + :param storage: Agno 存储引擎 + :param session_id: 会话ID + :param user_id: 用户ID + :return: 会话对象 + """ + session: Session | None = await storage.get_session(session_id=session_id, session_type=SessionType.AGENT) + if not session: + raise ServiceException(message='会话不存在') + if str(session.user_id) != str(user_id): + raise ServiceException(message='无权访问该会话') + + return session + + @classmethod + async def delete_chat_session_services(cls, session_id: str, user_id: int) -> CrudResponseModel: """ 删除会话 :param session_id: 会话ID + :param user_id: 用户ID :return: 删除结果 """ storage = AiUtil.get_storage_engine() + await cls._get_owned_session(storage, session_id, user_id) delete_result = await storage.delete_session(session_id=session_id) if not delete_result: raise ServiceException(message='删除会话失败') return CrudResponseModel(is_success=True, message='删除成功') @classmethod - async def get_chat_session_detail_services(cls, session_id: str) -> AiChatSessionModel: + async def get_chat_session_detail_services(cls, session_id: str, user_id: int) -> AiChatSessionModel: """ 获取会话消息详情 :param session_id: 会话ID + :param user_id: 用户ID :return: 会话消息详情 """ storage = AiUtil.get_storage_engine() - session: Session | None = await storage.get_session(session_id=session_id, session_type=SessionType.AGENT) - - if not session: - raise ServiceException(message='会话不存在') + session = await cls._get_owned_session(storage, session_id, user_id) session_data: dict[str, Any] = session.session_data agent_data: dict[str, Any] = session.agent_data @@ -470,14 +512,37 @@ class AiChatService: return session_detail @classmethod - async def cancel_run_services(cls, run_id: str) -> CrudResponseModel: + async def cancel_run_services(cls, run_id: str, user_id: int) -> CrudResponseModel: """ 取消运行 :param run_id: 运行ID + :param user_id: 用户ID :return: 取消结果 """ + if not await cls._user_owns_run(run_id, user_id): + raise ServiceException(message='无权取消该运行') cancel_result = await acancel_run(run_id) if not cancel_result: raise ServiceException(message='取消运行失败') return CrudResponseModel(is_success=True, message='取消成功') + + @classmethod + async def _user_owns_run(cls, run_id: str, user_id: int) -> bool: + """ + 校验运行记录是否属于当前用户。 + + :param run_id: 运行ID + :param user_id: 用户ID + :return: 是否属于当前用户 + """ + storage = AiUtil.get_storage_engine() + sessions: list[Session] = await storage.get_sessions( + user_id=str(user_id), + session_type=SessionType.AGENT, + ) + for session in sessions: + for run in session.runs or []: + if str(getattr(run, 'run_id', '') or getattr(run, 'id', '')) == str(run_id): + return True + return False diff --git a/ruoyi-fastapi-backend/module_ai/service/ai_model_service.py b/ruoyi-fastapi-backend/plugins/ai/service/ai_model_service.py similarity index 97% rename from ruoyi-fastapi-backend/module_ai/service/ai_model_service.py rename to ruoyi-fastapi-backend/plugins/ai/service/ai_model_service.py index 15fc608..48b32c7 100644 --- a/ruoyi-fastapi-backend/module_ai/service/ai_model_service.py +++ b/ruoyi-fastapi-backend/plugins/ai/service/ai_model_service.py @@ -5,8 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.vo import CrudResponseModel, PageModel from exceptions.exception import ServiceException -from module_ai.dao.ai_model_dao import AiModelDao -from module_ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel +from plugins.ai.dao.ai_model_dao import AiModelDao +from plugins.ai.entity.vo.ai_model_vo import AiModelModel, AiModelPageQueryModel, DeleteAiModelModel from utils.common_util import CamelCaseUtil from utils.crypto_util import CryptoUtil diff --git a/ruoyi-fastapi-backend/utils/ai_util.py b/ruoyi-fastapi-backend/plugins/ai/utils/ai_util.py similarity index 80% rename from ruoyi-fastapi-backend/utils/ai_util.py rename to ruoyi-fastapi-backend/plugins/ai/utils/ai_util.py index e9bd4c7..39dd781 100644 --- a/ruoyi-fastapi-backend/utils/ai_util.py +++ b/ruoyi-fastapi-backend/plugins/ai/utils/ai_util.py @@ -1,5 +1,7 @@ +import ipaddress from importlib import import_module from typing import TYPE_CHECKING +from urllib.parse import urlparse from config.database import async_engine from config.env import DataBaseConfig @@ -126,6 +128,42 @@ class AiUtil: create_schema=False, ) + @classmethod + def _validate_base_url(cls, base_url: str | None) -> str | None: + """ + 校验模型服务基础地址,阻断内网和元数据地址。 + + :param base_url: 基础URL + :return: 校验后的基础URL + """ + if not base_url: + return None + + parsed_url = urlparse(base_url) + if parsed_url.scheme not in {'http', 'https'} or not parsed_url.hostname: + raise ValueError('Base URL 只支持 http/https 且必须包含主机名') + + hostname = parsed_url.hostname.strip().lower() + if hostname in {'localhost'} or hostname.endswith(('.localhost', '.local')): + raise ValueError('Base URL 不允许指向本机或内网地址') + + try: + ip_address = ipaddress.ip_address(hostname) + except ValueError: + return base_url + + if ( + ip_address.is_private + or ip_address.is_loopback + or ip_address.is_link_local + or ip_address.is_multicast + or ip_address.is_reserved + or ip_address.is_unspecified + ): + raise ValueError('Base URL 不允许指向本机或内网地址') + + return base_url + @classmethod def get_model_from_factory( cls, @@ -150,10 +188,11 @@ class AiUtil: :param max_tokens: 最大令牌数 :return: 模型实例 """ + safe_base_url = cls._validate_base_url(base_url) params = { 'id': model_code, 'name': model_name, - 'base_url': base_url, + 'base_url': safe_base_url, 'api_key': api_key, 'temperature': temperature, 'max_tokens': max_tokens, @@ -161,12 +200,11 @@ class AiUtil: } params = {k: v for k, v in params.items() if v is not None} if provider == 'Ollama': - params['host'] = base_url - if provider == 'DashScope' and not base_url: + params['host'] = safe_base_url + if provider == 'DashScope' and not safe_base_url: params['base_url'] = 'https://dashscope.aliyuncs.com/compatible-mode/v1' model_class = cls._resolve_provider_class(provider) if model_class is None: - # 未知提供商,回退到OpenAI - model_class = cls._resolve_provider_class('OpenAI') + raise ValueError(f'未知AI模型提供商:{provider}') return model_class(**params) diff --git a/ruoyi-fastapi-backend/plugins/core/__init__.py b/ruoyi-fastapi-backend/plugins/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruoyi-fastapi-backend/plugins/core/capability.py b/ruoyi-fastapi-backend/plugins/core/capability.py new file mode 100644 index 0000000..359c7fc --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/capability.py @@ -0,0 +1,148 @@ +from dataclasses import dataclass +from typing import Literal + +from plugins.core.discovery.scanner import DiscoveredPlugin + +PluginOperation = Literal[ + 'install', + 'uninstall', + 'enable', + 'disable', + 'upgrade', + 'purge', + 'dependency_install', + 'batch_install', + 'batch_enable', + 'batch_upgrade', +] + +STATE_CHANGE_OPERATIONS: set[str] = { + 'install', + 'uninstall', + 'enable', + 'disable', + 'upgrade', + 'purge', + 'dependency_install', + 'batch_install', + 'batch_enable', + 'batch_upgrade', +} + +SERVICE_MODE_REASON = ( + '当前为服务运行模式,插件路由、任务和生命周期资源需要重启后激活。请在开发模式或维护窗口中执行插件变更。' +) +BUILT_FRONTEND_REASON = '当前为已构建前端环境,该插件包含前端源码资源,需要在构建前安装依赖并重新构建前端后生效。' + + +@dataclass(frozen=True) +class PluginRuntimeCapability: + """ + 插件运行时操作能力。 + """ + + plugin_id: str + frontend_mode: str + backend_runtime_mode: str + has_frontend_resources: bool + frontend_build_required: bool + frontend_runtime_manageable: bool + backend_runtime_manageable: bool + runtime_manageable: bool + blocked_operations: list[str] + warnings: list[str] + + @property + def primary_reason(self) -> str: + """ + 获取首个阻断原因。 + + :return: 阻断原因 + """ + return self.warnings[0] if self.warnings else '' + + def allows(self, operation: str) -> bool: + """ + 判断指定操作是否允许。 + + :param operation: 操作类型 + :return: 是否允许 + """ + return operation not in self.blocked_operations + + def to_payload(self) -> dict[str, object]: + """ + 构建前后端通用能力负载。 + + :return: 能力负载 + """ + return { + 'pluginId': self.plugin_id, + 'frontendMode': self.frontend_mode, + 'backendRuntimeMode': self.backend_runtime_mode, + 'hasFrontendResources': self.has_frontend_resources, + 'frontendBuildRequired': self.frontend_build_required, + 'frontendRuntimeManageable': self.frontend_runtime_manageable, + 'backendRuntimeManageable': self.backend_runtime_manageable, + 'runtimeManageable': self.runtime_manageable, + 'blockedOperations': self.blocked_operations, + 'warnings': self.warnings, + 'primaryReason': self.primary_reason, + } + + +class PluginRuntimeCapabilityResolver: + """ + 插件运行时能力解析器。 + """ + + def __init__(self, *, frontend_mode: str, backend_runtime_mode: str) -> None: + """ + 初始化能力解析器。 + + :param frontend_mode: 前端运行模式 + :param backend_runtime_mode: 后端运行模式 + """ + self.frontend_mode = frontend_mode + self.backend_runtime_mode = backend_runtime_mode + + def resolve(self, discovered_plugin: DiscoveredPlugin) -> PluginRuntimeCapability: + """ + 解析单个插件运行时能力。 + + :param discovered_plugin: 已发现插件 + :return: 插件运行时能力 + """ + manifest = discovered_plugin.manifest + has_frontend_resources = bool( + manifest.frontend.menus or manifest.dependencies.npm or manifest.dependencies.npm_dev + ) + frontend_build_required = manifest.frontend.delivery.build_required or has_frontend_resources + frontend_runtime_manageable = not ( + self.frontend_mode == 'built' and has_frontend_resources and frontend_build_required + ) + backend_runtime_manageable = self.backend_runtime_mode == 'dev' + runtime_manageable = frontend_runtime_manageable and backend_runtime_manageable + + warnings = [] + if not backend_runtime_manageable: + warnings.append(SERVICE_MODE_REASON) + if not frontend_runtime_manageable: + warnings.append(BUILT_FRONTEND_REASON) + + blocked_operations: list[str] = [] + if not runtime_manageable: + blocked_operations = sorted(STATE_CHANGE_OPERATIONS) + + return PluginRuntimeCapability( + plugin_id=manifest.id, + frontend_mode=self.frontend_mode, + backend_runtime_mode=self.backend_runtime_mode, + has_frontend_resources=has_frontend_resources, + frontend_build_required=frontend_build_required, + frontend_runtime_manageable=frontend_runtime_manageable, + backend_runtime_manageable=backend_runtime_manageable, + runtime_manageable=runtime_manageable, + blocked_operations=blocked_operations, + warnings=warnings, + ) diff --git a/ruoyi-fastapi-backend/plugins/core/discovery/__init__.py b/ruoyi-fastapi-backend/plugins/core/discovery/__init__.py new file mode 100644 index 0000000..bfdcbc1 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/discovery/__init__.py @@ -0,0 +1,3 @@ +""" +插件发现与注册表分层包。 +""" diff --git a/ruoyi-fastapi-backend/plugins/core/discovery/registry.py b/ruoyi-fastapi-backend/plugins/core/discovery/registry.py new file mode 100644 index 0000000..ff28df0 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/discovery/registry.py @@ -0,0 +1,178 @@ +from dataclasses import dataclass +from pathlib import Path + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.state import PluginStateResolver, PluginStateSnapshot +from plugins.core.types import PluginStateRecord + + +@dataclass(frozen=True) +class RegisteredPlugin: + """ + 已注册插件运行时快照。 + """ + + discovered_plugin: DiscoveredPlugin + database_plugin: PluginStateRecord | None + enabled: bool + status: str + + @property + def plugin_id(self) -> str: + """ + 获取插件 ID。 + + :return: 插件 ID + """ + return self.discovered_plugin.manifest.id + + @property + def backend_path(self) -> Path: + """ + 获取插件后端路径。 + + :return: 插件后端路径 + """ + return self.discovered_plugin.backend_path + + +class PluginRegistry: + """ + 插件运行时注册表。 + + 使用 Registry 模式维护已发现插件与数据库状态合并后的运行时快照。 + """ + + def __init__(self, registered_plugins: list[RegisteredPlugin]) -> None: + """ + 初始化插件运行时注册表。 + + :param registered_plugins: 已注册插件运行时快照列表 + """ + self._registered_plugins = {plugin.plugin_id: plugin for plugin in registered_plugins} + + @classmethod + def build( + cls, + discovered_plugins: list[DiscoveredPlugin], + database_plugins: list[PluginStateRecord] | None = None, + ) -> 'PluginRegistry': + """ + 根据已发现插件和数据库状态构建插件运行时注册表。 + + :param discovered_plugins: 已发现插件列表 + :param database_plugins: 数据库插件状态列表 + :return: 插件运行时注册表 + """ + database_plugin_map = {plugin.plugin_id: plugin for plugin in database_plugins or []} + registered_plugins = [ + cls._build_registered_plugin(discovered_plugin, database_plugin_map.get(discovered_plugin.manifest.id)) + for discovered_plugin in discovered_plugins + ] + + return cls(registered_plugins) + + def get_plugin(self, plugin_id: str) -> RegisteredPlugin | None: + """ + 根据插件 ID 获取运行时插件快照。 + + :param plugin_id: 插件 ID + :return: 运行时插件快照 + """ + return self._registered_plugins.get(plugin_id) + + def list_plugins(self) -> list[RegisteredPlugin]: + """ + 获取全部运行时插件快照。 + + :return: 运行时插件快照列表 + """ + return list(self._registered_plugins.values()) + + def list_enabled_plugins(self) -> list[RegisteredPlugin]: + """ + 获取启用插件运行时快照列表。 + + :return: 启用插件运行时快照列表 + """ + return [plugin for plugin in self._registered_plugins.values() if plugin.enabled] + + def get_enabled_controller_dirs(self) -> list[Path]: + """ + 获取启用插件控制器目录列表。 + + :return: 控制器目录列表 + """ + controller_dirs = [ + plugin.backend_path / 'controller' + for plugin in self.list_enabled_plugins() + if plugin.discovered_plugin.manifest.backend.routers.auto_scan + ] + + return [controller_dir for controller_dir in controller_dirs if controller_dir.is_dir()] + + def get_enabled_entity_do_dirs(self) -> list[Path]: + """ + 获取启用插件 DO 实体目录列表。 + + :return: DO 实体目录列表 + """ + entity_do_dirs = [plugin.backend_path / 'entity' / 'do' for plugin in self.list_enabled_plugins()] + + return [entity_do_dir for entity_do_dir in entity_do_dirs if entity_do_dir.is_dir()] + + @staticmethod + def _build_registered_plugin( + discovered_plugin: DiscoveredPlugin, + database_plugin: PluginStateRecord | None, + ) -> RegisteredPlugin: + """ + 构建单个运行时插件快照。 + + :param discovered_plugin: 已发现插件 + :param database_plugin: 数据库插件状态 + :return: 运行时插件快照 + """ + enabled = PluginRegistry._resolve_enabled(discovered_plugin, database_plugin) + status = PluginRegistry._resolve_status(discovered_plugin, database_plugin, enabled) + + return RegisteredPlugin( + discovered_plugin=discovered_plugin, + database_plugin=database_plugin, + enabled=enabled, + status=status, + ) + + @staticmethod + def _resolve_enabled(discovered_plugin: DiscoveredPlugin, database_plugin: PluginStateRecord | None) -> bool: + """ + 解析插件启用状态。 + + :param discovered_plugin: 已发现插件 + :param database_plugin: 数据库插件状态 + :return: 是否启用 + """ + return PluginStateResolver.is_enabled(database_plugin) + + @staticmethod + def _resolve_status( + discovered_plugin: DiscoveredPlugin, + database_plugin: PluginStateRecord | None, + enabled: bool, + ) -> str: + """ + 解析插件运行时状态。 + + :param discovered_plugin: 已发现插件 + :param database_plugin: 数据库插件状态 + :param enabled: 是否启用 + :return: 插件运行时状态 + """ + return PluginStateResolver.resolve( + PluginStateSnapshot( + source_version=discovered_plugin.manifest.version, + installed_version=getattr(database_plugin, 'installed_version', None), + enabled=enabled, + current_status=getattr(database_plugin, 'status', None), + ) + ) diff --git a/ruoyi-fastapi-backend/plugins/core/discovery/scanner.py b/ruoyi-fastapi-backend/plugins/core/discovery/scanner.py new file mode 100644 index 0000000..072f22c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/discovery/scanner.py @@ -0,0 +1,265 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml +from pydantic import ValidationError + +from plugins.core.manifest.schema import PluginManifest, PluginManifestError, PluginManifestFactory + +PLUGIN_MANIFEST_NAME = 'plugin.yaml' +UNSUPPORTED_PLUGIN_MANIFEST_NAMES = ('plugin.yml',) + + +@dataclass(frozen=True) +class DiscoveredPlugin: + """ + 已发现插件。 + """ + + manifest: PluginManifest + backend_path: Path + manifest_path: Path + + +@dataclass(frozen=True) +class PluginDiscoveryError: + """ + 插件发现错误。 + + 用于在容错扫描模式下记录单个插件加载失败,便于上层隔离损坏插件。 + """ + + manifest_path: Path | None + plugin_dir: Path + error_message: str + + +@dataclass(frozen=True) +class PluginDiscoveryResult: + """ + 插件发现结果。 + + 同时承载成功发现的插件和加载失败明细,避免单个错误插件拖垮整体扫描。 + """ + + plugins: list[DiscoveredPlugin] = field(default_factory=list) + errors: list[PluginDiscoveryError] = field(default_factory=list) + + @property + def has_errors(self) -> bool: + """ + 判断是否存在发现错误。 + + :return: 是否存在错误 + """ + return bool(self.errors) + + +class PluginScanner: + """ + 插件扫描器。 + + 提供两种扫描语义: + + - :meth:`discover`:严格扫描,任一插件清单错误立即抛出 ``PluginManifestError``。 + - :meth:`discover_with_errors`:容错扫描,逐插件隔离失败,plugin.yml 文件名错误、 + YAML 损坏、清单校验失败等都作为对应插件的错误记录,不影响其他插件。 + """ + + def __init__(self, plugins_root: Path | str) -> None: + """ + 初始化插件扫描器。 + + :param plugins_root: 后端插件根目录 + """ + self.plugins_root = Path(plugins_root) + + def discover(self) -> list[DiscoveredPlugin]: + """ + 严格扫描插件目录并返回清单校验通过的插件。 + + 任一插件清单错误(包括 plugin.yml 文件名、YAML 损坏、清单校验失败)都会立即 + 抛出 ``PluginManifestError``,保持与历史行为兼容。需要逐插件隔离时请使用 + :meth:`discover_with_errors`。 + + :return: 已发现插件列表 + :raises PluginManifestError: 任一插件清单错误 + """ + if not self.plugins_root.exists(): + return [] + self._ensure_plugins_root_is_dir() + self._reject_unsupported_manifest_names_strict() + + return [ + self.load_manifest(manifest_path) + for manifest_path in sorted(self.plugins_root.glob(f'*/{PLUGIN_MANIFEST_NAME}')) + ] + + def discover_with_errors(self) -> PluginDiscoveryResult: + """ + 容错扫描插件目录,返回成功发现的插件及失败明细。 + + 单个插件的任何错误(plugin.yml 文件名、YAML 损坏、清单校验失败、目录名不一致等) + 都只隔离失败插件,不影响其他正常插件。根目录配置类错误(目录不存在、根路径不是 + 目录)仍以异常形式抛出。 + + :return: 插件发现结果 + :raises PluginManifestError: 根路径不是目录 + """ + if not self.plugins_root.exists(): + return PluginDiscoveryResult() + self._ensure_plugins_root_is_dir() + + result = PluginDiscoveryResult() + # 先收集 plugin.yml 文件名错误,按插件目录隔离 + unsupported_name_errors = self._collect_unsupported_manifest_name_errors() + result.errors.extend(unsupported_name_errors) + invalid_plugin_dirs = {error.plugin_dir for error in unsupported_name_errors} + # 再逐插件加载 plugin.yaml + for manifest_path in sorted(self.plugins_root.glob(f'*/{PLUGIN_MANIFEST_NAME}')): + if manifest_path.parent in invalid_plugin_dirs: + continue + try: + result.plugins.append(self.load_manifest(manifest_path)) + except Exception as exc: + result.errors.append( + PluginDiscoveryError( + manifest_path=manifest_path, + plugin_dir=manifest_path.parent, + error_message=str(exc), + ) + ) + return result + + def load_manifest(self, manifest_path: Path | str) -> DiscoveredPlugin: + """ + 加载单个插件清单。 + + :param manifest_path: 插件清单路径 + :return: 已发现插件 + :raises PluginManifestError: 清单不存在、不是文件、YAML 解析失败或清单校验失败 + """ + current_manifest_path = Path(manifest_path) + if not current_manifest_path.exists(): + raise PluginManifestError(f'插件清单不存在:{current_manifest_path}') + if not current_manifest_path.is_file(): + raise PluginManifestError(f'插件清单不是文件:{current_manifest_path}') + + raw_manifest = self._read_yaml(current_manifest_path) + try: + manifest = PluginManifestFactory.create(raw_manifest) + except ValidationError as exc: + error_summary = self._format_validation_errors(exc) + raise PluginManifestError(f'插件清单校验失败:{current_manifest_path},{error_summary}') from exc + + backend_path = current_manifest_path.parent + if backend_path.name != manifest.id: + raise PluginManifestError(f'插件目录名必须与插件 id 一致:目录={backend_path.name},id={manifest.id}') + + return DiscoveredPlugin(manifest=manifest, backend_path=backend_path, manifest_path=current_manifest_path) + + @staticmethod + def _read_yaml(manifest_path: Path) -> dict[str, Any]: + """ + 读取 YAML 清单文件。 + + :param manifest_path: 插件清单路径 + :return: YAML 解析后的字典 + :raises PluginManifestError: YAML 解析失败或内容不是对象 + """ + try: + with manifest_path.open('r', encoding='utf-8') as manifest_file: + data = yaml.safe_load(manifest_file) or {} + except yaml.YAMLError as exc: + raise PluginManifestError(f'插件清单 YAML 解析失败:{manifest_path}') from exc + + if not isinstance(data, dict): + raise PluginManifestError(f'插件清单必须是 YAML 对象:{manifest_path}') + return data + + @staticmethod + def _format_validation_errors(exc: ValidationError, *, limit: int = 5) -> str: + """ + 格式化 Pydantic 字段校验错误摘要。 + + :param exc: Pydantic 校验异常 + :param limit: 最大错误数量 + :return: 错误摘要 + """ + formatted_errors = [] + for error in exc.errors()[:limit]: + location = '.'.join(str(part) for part in error.get('loc', ())) or '' + message = error.get('msg', '校验失败') + formatted_errors.append(f'{location}: {message}') + + remaining_count = max(0, len(exc.errors()) - limit) + if remaining_count: + formatted_errors.append(f'另有 {remaining_count} 个错误') + + return ';'.join(formatted_errors) + + def _ensure_plugins_root_is_dir(self) -> None: + """ + 校验插件根路径是目录。 + + :raises PluginManifestError: 根路径不是目录 + :return: None + """ + if not self.plugins_root.is_dir(): + raise PluginManifestError(f'插件根路径不是目录:{self.plugins_root}') + + def _reject_unsupported_manifest_names_strict(self) -> None: + """ + 严格模式下检查不支持的插件清单文件名,任一存在立即抛错。 + + :raises PluginManifestError: 存在不支持的清单文件名 + :return: None + """ + for manifest_name in UNSUPPORTED_PLUGIN_MANIFEST_NAMES: + unsupported_manifest_paths = sorted(self.plugins_root.glob(f'*/{manifest_name}')) + if unsupported_manifest_paths: + raise PluginManifestError( + f'插件清单文件名必须为 {PLUGIN_MANIFEST_NAME}:{unsupported_manifest_paths[0]}' + ) + + def _collect_unsupported_manifest_name_errors(self) -> list[PluginDiscoveryError]: + """ + 容错模式下收集不支持的清单文件名错误,按插件目录隔离。 + + :return: 发现错误列表 + """ + errors: list[PluginDiscoveryError] = [] + for manifest_name in UNSUPPORTED_PLUGIN_MANIFEST_NAMES: + errors.extend( + [ + PluginDiscoveryError( + manifest_path=manifest_path, + plugin_dir=manifest_path.parent, + error_message=f'插件清单文件名必须为 {PLUGIN_MANIFEST_NAME}:{manifest_path}', + ) + for manifest_path in sorted(self.plugins_root.glob(f'*/{manifest_name}')) + ] + ) + return errors + + +def discover_plugins(plugins_root: Path | str) -> list[DiscoveredPlugin]: + """ + 便捷函数:严格扫描插件目录。 + + :param plugins_root: 后端插件根目录 + :return: 已发现插件列表 + :raises PluginManifestError: 任一插件清单错误 + """ + return PluginScanner(plugins_root).discover() + + +def discover_plugins_with_errors(plugins_root: Path | str) -> PluginDiscoveryResult: + """ + 便捷函数:容错扫描插件目录。 + + :param plugins_root: 后端插件根目录 + :return: 插件发现结果 + """ + return PluginScanner(plugins_root).discover_with_errors() diff --git a/ruoyi-fastapi-backend/plugins/core/environment.py b/ruoyi-fastapi-backend/plugins/core/environment.py new file mode 100644 index 0000000..ce5f981 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/environment.py @@ -0,0 +1,205 @@ +import os +from pathlib import Path +from typing import Literal + +from config.env import AppConfig + +PluginFrontendMode = Literal['dev', 'built'] +PluginBackendRuntimeMode = Literal['dev', 'service'] + +BACKEND_ROOT_ENV_NAMES = ('RUOYI_PLUGIN_BACKEND_ROOT', 'RUOYI_BACKEND_ROOT') +FRONTEND_ROOT_ENV_NAMES = ('RUOYI_PLUGIN_FRONTEND_ROOT', 'RUOYI_FRONTEND_ROOT') + + +class PluginRuntimeEnvironmentService: + """ + 插件运行时环境服务。 + + 为插件运行时提供后端目录和 Python 可执行文件等路径信息。 + """ + + def __init__( + self, + backend_root: Path | str | None = None, + frontend_root: Path | str | None = None, + python_executable: str | None = None, + ) -> None: + """ + 初始化插件运行时环境服务。 + + :param backend_root: 后端项目根目录 + :param frontend_root: 前端项目根目录 + :param python_executable: Python 可执行文件路径 + :return: None + """ + self.backend_root = self._resolve_backend_root(backend_root) + self.frontend_root = self._resolve_frontend_root(self.backend_root, frontend_root) + self.python_executable = python_executable or 'python' + self.frontend_mode = self._get_frontend_mode() + self.backend_runtime_mode = self._get_backend_runtime_mode() + + @staticmethod + def _resolve_backend_root(backend_root: Path | str | None) -> Path: + """ + 解析后端项目根目录。 + + :param backend_root: 显式传入的后端项目根目录 + :return: 后端项目根目录 + """ + if backend_root: + return Path(backend_root).resolve() + configured_backend_root = PluginRuntimeEnvironmentService._first_env_path(BACKEND_ROOT_ENV_NAMES) + if configured_backend_root: + return configured_backend_root.resolve() + return Path(__file__).resolve().parents[2] + + @staticmethod + def _resolve_frontend_root(backend_root: Path, frontend_root: Path | str | None) -> Path: + """ + 解析前端项目根目录。 + + 解析顺序为:显式参数、环境变量、后端同级目录中的前端工程、按后端目录名推断。 + + :param backend_root: 后端项目根目录 + :param frontend_root: 显式传入的前端项目根目录 + :return: 前端项目根目录 + """ + if frontend_root: + return Path(frontend_root).resolve() + configured_frontend_root = PluginRuntimeEnvironmentService._first_env_path(FRONTEND_ROOT_ENV_NAMES) + if configured_frontend_root: + return configured_frontend_root.resolve() + sibling_frontend_root = PluginRuntimeEnvironmentService._find_sibling_frontend_root(backend_root) + if sibling_frontend_root: + return sibling_frontend_root.resolve() + return backend_root.parent / PluginRuntimeEnvironmentService._infer_frontend_dir_name(backend_root.name) + + @staticmethod + def _first_env_path(env_names: tuple[str, ...]) -> Path | None: + """ + 读取第一个已配置的目录环境变量。 + + :param env_names: 环境变量名称列表 + :return: 已配置目录,未配置时返回 None + """ + for env_name in env_names: + value = os.getenv(env_name, '').strip() + if value: + return Path(value) + return None + + @staticmethod + def _find_sibling_frontend_root(backend_root: Path) -> Path | None: + """ + 从后端同级目录中寻找前端工程。 + + :param backend_root: 后端项目根目录 + :return: 前端项目根目录,未找到时返回 None + """ + parent = backend_root.parent + if not parent.is_dir(): + return None + candidates = [ + path + for path in parent.iterdir() + if path.is_dir() + and path != backend_root + and (path / 'package.json').is_file() + and (path / 'plugins').is_dir() + ] + if not candidates: + return None + return sorted(candidates)[0] + + @staticmethod + def _infer_frontend_dir_name(backend_dir_name: str) -> str: + """ + 根据后端目录名推断前端目录名。 + + :param backend_dir_name: 后端目录名 + :return: 推断出的前端目录名 + """ + if 'backend' in backend_dir_name: + return backend_dir_name.replace('backend', 'frontend', 1) + return 'frontend' + + @staticmethod + def _get_frontend_mode() -> PluginFrontendMode: + """ + 根据应用运行环境获取插件前端模式。 + + :return: 插件前端模式 + """ + if AppConfig.app_env == 'dev': + return 'dev' + return 'built' + + @staticmethod + def _get_backend_runtime_mode() -> PluginBackendRuntimeMode: + """ + 根据应用运行环境获取插件后端运行模式。 + + :return: 插件后端运行模式 + """ + if AppConfig.app_env == 'dev': + return 'dev' + return 'service' + + def get_backend_dir(self) -> str: + """ + 获取后端项目根目录。 + + :return: 后端项目根目录绝对路径 + """ + return str(self.backend_root) + + def get_backend_plugins_dir(self) -> str: + """ + 获取后端插件根目录。 + + :return: 后端插件根目录绝对路径 + """ + return str(self.backend_root / 'plugins') + + def get_frontend_dir(self) -> str: + """ + 获取前端项目根目录。 + + :return: 前端项目根目录绝对路径 + """ + return str(self.frontend_root) + + def get_frontend_plugins_dir(self) -> str: + """ + 获取前端插件根目录。 + + :return: 前端插件根目录绝对路径 + """ + return str(self.frontend_root / 'plugins') + + def get_python_executable(self) -> str: + """ + 获取 Python 可执行文件。 + + :return: Python 可执行文件路径 + """ + return self.python_executable + + def get_frontend_mode(self) -> PluginFrontendMode: + """ + 获取插件前端运行模式。 + + :return: 插件前端运行模式 + """ + return self.frontend_mode + + def get_backend_runtime_mode(self) -> PluginBackendRuntimeMode: + """ + 获取插件后端运行模式。 + + :return: 插件后端运行模式 + """ + return self.backend_runtime_mode + + +PLUGIN_RUNTIME_ENVIRONMENT = PluginRuntimeEnvironmentService() diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/__init__.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/__init__.py new file mode 100644 index 0000000..bb1f05a --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/__init__.py @@ -0,0 +1,3 @@ +""" +插件生命周期能力分层包。 +""" diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/jobs.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/jobs.py new file mode 100644 index 0000000..188d3db --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/jobs.py @@ -0,0 +1,295 @@ +import json +from datetime import datetime + +from sqlalchemy import delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from module_admin.dao.job_dao import JobDao +from module_admin.entity.do.job_do import SysJob +from module_admin.entity.vo.job_vo import JobModel +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.manifest.schema import PluginJobManifest +from plugins.core.utils import escape_sql_like + + +class PluginJobModelBuilder: + """ + 插件任务模型构建器。 + + 使用 Builder 模式将插件 manifest 中的任务声明转换为系统已有的 `JobModel`, + 让插件任务复用平台原有定时任务表、调度器和日志能力。 + """ + + JOB_GROUP = 'default' + JOB_NAME_MAX_LENGTH = 64 + REMARK_PREFIX = '[plugin-job]' + + @classmethod + def build(cls, plugin_id: str, job: PluginJobManifest, *, enabled: bool = True) -> JobModel: + """ + 构建系统定时任务模型。 + + :param plugin_id: 插件ID + :param job: 插件定时任务声明 + :param enabled: 任务是否启用 + :return: 系统定时任务模型 + """ + cls._validate_callable_belongs_to_plugin(plugin_id, job.callable) + return JobModel( + jobName=cls.build_job_name(plugin_id, job.id), + jobGroup=cls.JOB_GROUP, + jobExecutor=job.executor, + invokeTarget=job.callable, + jobArgs=json.dumps(job.args, ensure_ascii=False) if job.args else '', + jobKwargs=json.dumps(job.kwargs, ensure_ascii=False) if job.kwargs else '', + cronExpression=job.cron_expression, + misfirePolicy=job.misfire_policy, + concurrent=job.concurrent, + status='0' if enabled and job.enabled else '1', + createBy='plugin', + updateBy='plugin', + remark=cls.build_remark(plugin_id, job), + ) + + @classmethod + def _validate_callable_belongs_to_plugin(cls, plugin_id: str, callable_path: str) -> None: + """ + 校验插件任务 callable 模块归属。 + + 定时任务由系统调度器直接 importlib 导入执行,需在此二次校验模块前缀归属, + 与 PluginManifest 清单校验形成防御纵深。 + + :param plugin_id: 插件ID + :param callable_path: 任务调用目标 + :return: None + """ + expected_prefix = f'plugins.{plugin_id}.' + if not callable_path.startswith(expected_prefix): + raise ValueError(f'插件任务 callable 必须归属当前插件模块({expected_prefix}):{callable_path}') + + @classmethod + def build_job_name(cls, plugin_id: str, job_id: str) -> str: + """ + 构建插件任务在系统任务表中的任务名称。 + + :param plugin_id: 插件ID + :param job_id: 插件内任务ID + :return: 系统任务名称 + """ + return f'{plugin_id}:{job_id}' + + @classmethod + def build_remark(cls, plugin_id: str, job: PluginJobManifest) -> str: + """ + 构建插件任务备注。 + + :param plugin_id: 插件ID + :param job: 插件定时任务声明 + :return: 任务备注 + """ + description = job.description or f'插件 {plugin_id} 声明的定时任务 {job.id}' + return f'{cls.REMARK_PREFIX} {plugin_id}:{job.id} {description}'[:500] + + +class PluginJobRepository: + """ + 插件任务仓储。 + + 插件任务复用系统 `sys_job` 表,但插件专用查询和批量清理逻辑收敛在插件模块内, + 避免向原任务 DAO 暴露插件语义。 + """ + + def __init__(self, query_db: AsyncSession) -> None: + """ + 初始化插件任务仓储。 + + :param query_db: orm对象 + :return: None + """ + self.query_db = query_db + + async def get_job_detail_by_name_group(self, job_name: str, job_group: str) -> SysJob | None: + """ + 根据任务名称和任务组获取插件任务。 + + :param job_name: 任务名称 + :param job_group: 任务组名 + :return: 定时任务信息对象 + """ + return ( + ( + await self.query_db.execute( + select(SysJob).where( + SysJob.job_name == job_name, + SysJob.job_group == job_group, + ) + ) + ) + .scalars() + .first() + ) + + async def pause_jobs_by_name_prefix(self, job_name_prefix: str) -> None: + """ + 根据任务名称前缀暂停插件任务。 + + :param job_name_prefix: 任务名称前缀 + :return: None + """ + escaped_prefix = escape_sql_like(job_name_prefix) + escaped_remark_prefix = escape_sql_like(f'{PluginJobModelBuilder.REMARK_PREFIX} {job_name_prefix}') + await self.query_db.execute( + update(SysJob) + .where( + SysJob.job_name.like(f'{escaped_prefix}%', escape='\\'), + SysJob.remark.like(f'{escaped_remark_prefix}%', escape='\\'), + ) + .values(status='1') + ) + + async def count_jobs_by_name_prefix(self, job_name_prefix: str) -> int: + """ + 根据任务名称前缀统计插件任务。 + + :param job_name_prefix: 任务名称前缀 + :return: 定时任务数量 + """ + escaped_prefix = escape_sql_like(job_name_prefix) + escaped_remark_prefix = escape_sql_like(f'{PluginJobModelBuilder.REMARK_PREFIX} {job_name_prefix}') + job_count = ( + await self.query_db.execute( + select(func.count()) + .select_from(SysJob) + .where( + SysJob.job_name.like(f'{escaped_prefix}%', escape='\\'), + SysJob.remark.like(f'{escaped_remark_prefix}%', escape='\\'), + ) + ) + ).scalar_one() + + return int(job_count) + + async def delete_jobs_by_name_prefix(self, job_name_prefix: str) -> None: + """ + 根据任务名称前缀删除插件任务。 + + :param job_name_prefix: 任务名称前缀 + :return: None + """ + escaped_prefix = escape_sql_like(job_name_prefix) + escaped_remark_prefix = escape_sql_like(f'{PluginJobModelBuilder.REMARK_PREFIX} {job_name_prefix}') + await self.query_db.execute( + delete(SysJob).where( + SysJob.job_name.like(f'{escaped_prefix}%', escape='\\'), + SysJob.remark.like(f'{escaped_remark_prefix}%', escape='\\'), + ) + ) + + async def delete_plugin_jobs_except(self, plugin_id: str, desired_job_names: set[str]) -> None: + """ + 删除不再由插件 manifest 声明的插件任务。 + + 仅删除同时满足插件任务名称空间和平台 ownership remark 的记录,避免误删用户手工 + 创建的同名前缀任务。 + + :param plugin_id: 插件ID + :param desired_job_names: manifest 当前声明的完整任务名称集合 + :return: None + """ + job_name_prefix = f'{plugin_id}:' + escaped_prefix = escape_sql_like(job_name_prefix) + escaped_remark_prefix = escape_sql_like(f'{PluginJobModelBuilder.REMARK_PREFIX} {job_name_prefix}') + query = delete(SysJob).where( + SysJob.job_name.like(f'{escaped_prefix}%', escape='\\'), + SysJob.remark.like(f'{escaped_remark_prefix}%', escape='\\'), + ) + if desired_job_names: + query = query.where(SysJob.job_name.not_in(sorted(desired_job_names))) + await self.query_db.execute(query) + + +class PluginJobInstaller: + """ + 插件定时任务安装器。 + + 使用 Installer 模式负责将启用插件的任务声明幂等写入 `sys_job`,并在插件停用或 + 出错时暂停对应任务。 + """ + + def __init__(self, query_db: AsyncSession) -> None: + """ + 初始化插件定时任务安装器。 + + :param query_db: orm对象 + :return: None + """ + self.query_db = query_db + self.repository = PluginJobRepository(query_db) + + async def install_plugin_jobs(self, discovered_plugin: DiscoveredPlugin, *, enabled: bool = True) -> list[JobModel]: + """ + 安装单个插件声明的定时任务。 + + :param discovered_plugin: 已发现插件对象 + :param enabled: 插件是否启用 + :return: 已写入的任务模型列表 + """ + installed_jobs = [] + manifest = discovered_plugin.manifest + desired_job_names = {PluginJobModelBuilder.build_job_name(manifest.id, job.id) for job in manifest.backend.jobs} + await self.repository.delete_plugin_jobs_except(manifest.id, desired_job_names) + for job in manifest.backend.jobs: + job_model = PluginJobModelBuilder.build(manifest.id, job, enabled=enabled) + await self.upsert_plugin_job(job_model) + installed_jobs.append(job_model) + + return installed_jobs + + async def upsert_plugin_job(self, job_model: JobModel) -> SysJob: + """ + 幂等写入系统任务。 + + :param job_model: 系统定时任务模型 + :return: 写入后的系统任务对象 + """ + existing_job = await self.repository.get_job_detail_by_name_group(job_model.job_name, job_model.job_group) + now = datetime.now() + if existing_job: + ownership_prefix = f'{PluginJobModelBuilder.REMARK_PREFIX} {job_model.job_name}' + if not str(existing_job.remark or '').startswith(ownership_prefix): + raise ValueError(f'插件任务与非插件任务重名,拒绝覆盖:{job_model.job_name}({job_model.job_group})') + await self.query_db.execute( + update(SysJob) + .where( + SysJob.job_id == existing_job.job_id, + SysJob.job_name == existing_job.job_name, + SysJob.job_group == existing_job.job_group, + ) + .values( + job_executor=job_model.job_executor, + invoke_target=job_model.invoke_target, + job_args=job_model.job_args, + job_kwargs=job_model.job_kwargs, + cron_expression=job_model.cron_expression, + misfire_policy=job_model.misfire_policy, + concurrent=job_model.concurrent, + status=job_model.status, + update_by=job_model.update_by, + update_time=now, + remark=job_model.remark, + ) + ) + return existing_job + + job_model.create_time = now + job_model.update_time = now + return await JobDao.add_job_dao(self.query_db, job_model) + + async def pause_plugin_jobs(self, plugin_id: str) -> None: + """ + 暂停指定插件的所有任务。 + + :param plugin_id: 插件ID + :return: None + """ + await self.repository.pause_jobs_by_name_prefix(f'{plugin_id}:') diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/migration.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/migration.py new file mode 100644 index 0000000..5ee70ee --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/migration.py @@ -0,0 +1,601 @@ +import asyncio +import hashlib +import inspect +import time +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +from config.env import DataBaseConfig +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.lifecycle.script import PluginLifecycleScriptHelper + +SUPPORTED_MIGRATION_SUFFIXES = {'.py', '.sql'} +MIGRATION_ERROR_MESSAGE_MAX_LENGTH = 1000 + + +class PluginMigrationError(RuntimeError): + """ + 插件 migration 可恢复错误。 + """ + + def __init__( + self, + message: str, + *, + migration_path: str, + status: str, + recovery_suggestion: str, + ) -> None: + """ + 初始化插件 migration 错误。 + + :param message: 错误消息 + :param migration_path: migration 相对插件根目录路径 + :param status: 当前 migration 状态 + :param recovery_suggestion: 恢复建议 + """ + super().__init__(message) + self.migration_path = migration_path + self.status = status + self.recovery_suggestion = recovery_suggestion + + def to_recovery_payload(self) -> dict[str, object]: + """ + 转换为恢复建议负载。 + + :return: migration 恢复建议负载 + """ + return { + 'migrationPath': self.migration_path, + 'status': self.status, + 'suggestion': self.recovery_suggestion, + } + + +@dataclass(frozen=True) +class PluginMigrationHistoryRecord: + """ + 插件 migration 历史记录。 + + :param checksum: migration 内容校验值 + :param status: 执行状态 + :param error_message: 失败错误信息 + """ + + checksum: str + status: str = 'success' + error_message: str | None = None + + +@dataclass(frozen=True) +class PluginMigrationResult: + """ + 插件 migration 执行结果。 + + :param migration_path: migration 相对插件根目录路径 + :param module_name: migration 模块名 + :param statement_count: SQL 语句数量 + :param checksum: migration 内容校验值 + :param skipped: 是否跳过执行 + :param status: 执行状态 + :param duration_ms: 执行耗时,单位毫秒 + :param recovery_suggestion: 恢复建议 + """ + + migration_path: str + module_name: str + statement_count: int = 0 + checksum: str | None = None + skipped: bool = False + status: str = 'success' + duration_ms: int = 0 + recovery_suggestion: str | None = None + + +class PluginMigrationHistoryStore: + """ + 插件 migration 历史存储接口。 + """ + + async def get_record( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + ) -> PluginMigrationHistoryRecord | None: + """ + 获取 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: migration 执行历史,不存在时返回 None + """ + raise NotImplementedError + + async def get_checksum(self, query_db: Any, plugin_id: str, migration_path: str) -> str | None: + """ + 获取已执行 migration 的内容校验值。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: 内容校验值,不存在时返回 None + """ + record = await self.get_record(query_db, plugin_id, migration_path) + if not record or record.status != 'success': + return None + return record.checksum + + async def record_running( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + ) -> None: + """ + 记录 migration 开始执行。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: None + """ + raise NotImplementedError + + async def record_success( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + ) -> None: + """ + 记录 migration 成功执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: None + """ + raise NotImplementedError + + async def record_failure( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + error_message: str, + ) -> None: + """ + 记录 migration 执行失败历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: 已解析 SQL 语句数量 + :param error_message: 失败错误信息 + :return: None + """ + raise NotImplementedError + + +class PluginMigrationRunner: + """ + 插件 migration 运行器。 + + 使用 Command Runner 模式按 manifest 声明顺序执行插件结构迁移脚本。 + Python migration 模块需要暴露 `run(query_db)` 函数,SQL migration 会按分号拆分并逐条执行。 + """ + + def __init__( + self, + discovered_plugin: DiscoveredPlugin, + history_store: PluginMigrationHistoryStore | None = None, + *, + manage_execution_transaction: bool = False, + ) -> None: + """ + 初始化插件 migration 运行器。 + + :param discovered_plugin: 已发现插件对象 + :param history_store: migration 执行历史存储 + :param manage_execution_transaction: 是否由 runner 提交或回滚执行 session + :return: None + """ + self.discovered_plugin = discovered_plugin + self.history_store = history_store + self.manage_execution_transaction = manage_execution_transaction + + async def run(self, query_db: Any) -> list[PluginMigrationResult]: + """ + 执行插件清单声明的 migration。 + + :param query_db: orm对象 + :return: migration 执行结果列表 + """ + return [ + await self._run_migration(migration_path, query_db) + for migration_path in self._filter_current_database_migrations( + self.discovered_plugin.manifest.backend.migrations + ) + ] + + async def _run_migration(self, migration_path: str, query_db: Any) -> PluginMigrationResult: + """ + 执行单个 migration。 + + :param migration_path: migration 相对插件根目录路径 + :param query_db: orm对象 + :return: migration 执行结果 + """ + migration_file = self._resolve_migration_file(migration_path) + checksum = await asyncio.to_thread(self._calculate_checksum, migration_file) + existing_record = await self._get_existing_record(query_db, migration_path) + if existing_record: + if existing_record.status == 'success': + if existing_record.checksum != checksum: + raise PluginMigrationError( + f'插件 migration 已执行但内容校验值变化:{migration_path},请新增 migration 文件而不是修改历史文件', + migration_path=migration_path, + status='success', + recovery_suggestion='请恢复原 migration 文件内容,或新增一个后续 migration 文件承载变更。', + ) + return PluginMigrationResult( + migration_path=migration_path, + module_name=self._build_migration_module_name(migration_file), + checksum=checksum, + skipped=True, + status='success', + ) + if existing_record.status == 'running': + raise PluginMigrationError( + f'插件 migration 上次执行仍处于 running 状态:{migration_path},' + '请确认数据库结构状态后手动处理迁移历史', + migration_path=migration_path, + status='running', + recovery_suggestion=( + '请检查数据库结构是否已应用;若已成功应用,执行 mark-success;' + '若未完成,执行 mark-failed 后修复并重试。' + ), + ) + if existing_record.status != 'failed': + raise PluginMigrationError( + f'插件 migration 历史状态不支持自动执行:{migration_path},状态:{existing_record.status}', + migration_path=migration_path, + status=existing_record.status, + recovery_suggestion='请人工确认 migration 历史状态,并通过恢复命令标记为 success 或 failed。', + ) + + started_at = time.perf_counter() + await self._record_running(query_db, migration_path, checksum) + try: + if migration_file.suffix == '.sql': + result = await self._run_sql_migration(migration_path, migration_file, query_db, checksum) + else: + result = await self._run_python_migration(migration_path, migration_file, query_db, checksum) + await self._commit_execution_transaction(query_db) + except Exception as exc: + with suppress(Exception): + await self._rollback_execution_transaction(query_db) + await self._record_failure(query_db, migration_path, checksum, str(exc)) + await self._commit_execution_transaction(query_db) + raise PluginMigrationError( + f'插件 migration 执行失败:{migration_path},{exc}', + migration_path=migration_path, + status='failed', + recovery_suggestion='请修复 migration 幂等性或数据库结构问题后重试;必要时人工确认后标记成功。', + ) from exc + + duration_ms = max(int((time.perf_counter() - started_at) * 1000), 0) + result = PluginMigrationResult( + migration_path=result.migration_path, + module_name=result.module_name, + statement_count=result.statement_count, + checksum=result.checksum, + skipped=result.skipped, + status='success', + duration_ms=duration_ms, + ) + + try: + await self._record_success(query_db, result) + except Exception as exc: + raise PluginMigrationError( + f'插件 migration 已执行,但成功历史记录失败:{migration_path},{exc}', + migration_path=migration_path, + status='running', + recovery_suggestion=( + '请检查数据库结构是否已应用;若已成功应用,执行 mark-success;' + '若未完成,执行 mark-failed 后修复并重试。' + ), + ) from exc + + return result + + async def _commit_execution_transaction(self, query_db: Any) -> None: + """ + 提交 migration 执行事务。 + + :param query_db: migration 执行 session + :return: None + """ + if not self.manage_execution_transaction: + return + + await self._call_execution_transaction_method(query_db, 'commit') + + async def _rollback_execution_transaction(self, query_db: Any) -> None: + """ + 回滚 migration 执行事务。 + + :param query_db: migration 执行 session + :return: None + """ + if not self.manage_execution_transaction: + return + + await self._call_execution_transaction_method(query_db, 'rollback') + + async def _call_execution_transaction_method(self, query_db: Any, method_name: str) -> None: + """ + 调用 migration 执行 session 的事务方法。 + + :param query_db: migration 执行 session + :param method_name: 事务方法名 + :return: None + """ + method = getattr(query_db, method_name, None) + if not callable(method): + raise RuntimeError(f'migration 执行 session 缺少 {method_name} 方法') + + result = method() + if inspect.isawaitable(result): + await result + + async def _run_python_migration( + self, + migration_path: str, + migration_file: Path, + query_db: Any, + checksum: str, + ) -> PluginMigrationResult: + """ + 执行 Python migration。 + + :param migration_path: migration 相对插件根目录路径 + :param migration_file: migration 文件绝对路径 + :param query_db: orm对象 + :param checksum: migration 内容校验值 + :return: migration 执行结果 + """ + migration_module = self._load_migration_module(migration_file) + migration_runner = getattr(migration_module, 'run', None) + if not callable(migration_runner): + raise RuntimeError(f'插件 migration 必须暴露 run(query_db) 函数:{migration_path}') + + result = migration_runner(query_db) + if inspect.isawaitable(result): + await result + + return PluginMigrationResult( + migration_path=migration_path, + module_name=migration_module.__name__, + checksum=checksum, + ) + + async def _run_sql_migration( + self, + migration_path: str, + migration_file: Path, + query_db: Any, + checksum: str, + ) -> PluginMigrationResult: + """ + 执行 SQL migration。 + + :param migration_path: migration 相对插件根目录路径 + :param migration_file: migration 文件绝对路径 + :param query_db: orm对象 + :param checksum: migration 内容校验值 + :return: migration 执行结果 + """ + statements = await asyncio.to_thread(self._load_sql_statements, migration_file) + for statement in statements: + await query_db.execute(text(statement)) + + return PluginMigrationResult( + migration_path=migration_path, + module_name=self._build_migration_module_name(migration_file), + statement_count=len(statements), + checksum=checksum, + ) + + async def _get_existing_record(self, query_db: Any, migration_path: str) -> PluginMigrationHistoryRecord | None: + """ + 获取 migration 已有执行历史。 + + :param query_db: orm对象 + :param migration_path: migration 相对路径 + :return: migration 执行历史 + """ + if not self.history_store: + return None + + return await self.history_store.get_record(query_db, self.discovered_plugin.manifest.id, migration_path) + + async def _record_running(self, query_db: Any, migration_path: str, checksum: str) -> None: + """ + 记录 migration 开始执行。 + + :param query_db: orm对象 + :param migration_path: migration 相对路径 + :param checksum: migration 内容校验值 + :return: None + """ + if not self.history_store: + return + + await self.history_store.record_running( + query_db, + self.discovered_plugin.manifest.id, + migration_path, + checksum, + self.discovered_plugin.manifest.version, + self._get_statement_count(migration_path), + ) + + async def _record_success(self, query_db: Any, result: PluginMigrationResult) -> None: + """ + 记录 migration 成功执行历史。 + + :param query_db: orm对象 + :param result: migration 执行结果 + :return: None + """ + if not self.history_store or not result.checksum: + return + + await self.history_store.record_success( + query_db, + self.discovered_plugin.manifest.id, + result.migration_path, + result.checksum, + self.discovered_plugin.manifest.version, + result.statement_count, + ) + + async def _record_failure( + self, + query_db: Any, + migration_path: str, + checksum: str, + error_message: str, + ) -> None: + """ + 记录 migration 失败历史。 + + :param query_db: orm对象 + :param migration_path: migration 相对路径 + :param checksum: migration 内容校验值 + :param error_message: 失败错误信息 + :return: None + """ + if not self.history_store: + return + + try: + statement_count = self._get_statement_count(migration_path) + except Exception: + statement_count = 0 + await self.history_store.record_failure( + query_db, + self.discovered_plugin.manifest.id, + migration_path, + checksum, + self.discovered_plugin.manifest.version, + statement_count, + error_message[:MIGRATION_ERROR_MESSAGE_MAX_LENGTH], + ) + + def _get_statement_count(self, migration_path: str) -> int: + """ + 获取 SQL migration 语句数量。 + + :param migration_path: migration 相对路径 + :return: SQL 语句数量 + """ + migration_file = self._resolve_migration_file(migration_path) + if migration_file.suffix != '.sql': + return 0 + return len(self._load_sql_statements(migration_file)) + + def _load_sql_statements(self, migration_file: Path) -> list[str]: + """ + 加载 SQL migration 语句列表。 + + :param migration_file: migration 文件绝对路径 + :return: SQL 语句列表 + """ + return PluginLifecycleScriptHelper.split_sql_statements(migration_file.read_text(encoding='utf-8')) + + def _resolve_migration_file(self, migration_path: str) -> Path: + """ + 解析 migration 文件绝对路径。 + + :param migration_path: migration 相对插件根目录路径 + :return: migration 文件绝对路径 + """ + return PluginLifecycleScriptHelper.resolve_file( + self.discovered_plugin.backend_path, + migration_path, + supported_suffixes=SUPPORTED_MIGRATION_SUFFIXES, + label='migration', + ) + + @classmethod + def _filter_current_database_migrations(cls, migration_paths: list[str]) -> list[str]: + """ + 过滤当前数据库方言不匹配的 migration。 + + :param migration_paths: migration 相对路径列表 + :return: 当前数据库需要执行的 migration 列表 + """ + return PluginLifecycleScriptHelper.filter_current_database_paths( + migration_paths, + root_dir='migrations', + database_type=DataBaseConfig.db_type, + ) + + def _load_migration_module(self, migration_file: Path) -> Any: + """ + 加载 migration Python 模块。 + + :param migration_file: migration 文件绝对路径 + :return: migration 模块 + """ + module_name = self._build_migration_module_name(migration_file) + return PluginLifecycleScriptHelper.load_module(module_name, migration_file, label='migration') + + @staticmethod + def _calculate_checksum(migration_file: Path) -> str: + """ + 计算 migration 文件内容校验值。 + + :param migration_file: migration 文件绝对路径 + :return: SHA256 内容校验值 + """ + return hashlib.sha256(migration_file.read_bytes()).hexdigest() + + def _build_migration_module_name(self, migration_file: Path) -> str: + """ + 构建 migration 模块名。 + + :param migration_file: migration 文件绝对路径 + :return: migration 模块名 + """ + return PluginLifecycleScriptHelper.build_module_name( + self.discovered_plugin.manifest.id, + self.discovered_plugin.backend_path, + migration_file, + ) diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/precheck.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/precheck.py new file mode 100644 index 0000000..4aef291 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/precheck.py @@ -0,0 +1,192 @@ +from dataclasses import dataclass + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.lifecycle.migration import PluginMigrationRunner +from plugins.core.lifecycle.seed import PluginSeedRunner +from plugins.core.validation.result import PluginValidationIssue + + +@dataclass(frozen=True) +class PluginLifecycleScriptPrecheckResult: + """ + 插件生命周期脚本预检结果。 + """ + + issues: list[PluginValidationIssue] + + @property + def ok(self) -> bool: + """ + 判断生命周期脚本预检是否存在阻断错误。 + + :return: 是否不存在 error 级问题 + """ + return not self.error_issues + + @property + def error_issues(self) -> list[PluginValidationIssue]: + """ + 获取 error 级预检问题。 + + :return: error 级问题列表 + """ + return [issue for issue in self.issues if issue.level == 'error'] + + @property + def warning_issues(self) -> list[PluginValidationIssue]: + """ + 获取 warning 级预检问题。 + + :return: warning 级问题列表 + """ + return [issue for issue in self.issues if issue.level == 'warning'] + + +class PluginLifecycleScriptPrechecker: + """ + 插件生命周期脚本执行前预检器。 + """ + + def __init__(self, discovered_plugin: DiscoveredPlugin, migration_runner: PluginMigrationRunner) -> None: + """ + 初始化生命周期脚本预检器。 + + :param discovered_plugin: 已发现插件对象 + :param migration_runner: migration 运行器 + :return: None + """ + self.discovered_plugin = discovered_plugin + self.migration_runner = migration_runner + + async def check(self, query_db: object) -> PluginLifecycleScriptPrecheckResult: + """ + 检查 migration 历史和 seed 执行计划。 + + :param query_db: orm对象 + :return: 生命周期脚本预检结果 + """ + issues = [] + issues.extend(await self._check_migrations(query_db)) + issues.extend(self._check_seeds()) + + return PluginLifecycleScriptPrecheckResult(issues=issues) + + async def _check_migrations(self, query_db: object) -> list[PluginValidationIssue]: + """ + 检查已执行 migration 是否被修改,并输出待执行计划。 + + :param query_db: orm对象 + :return: migration 预检问题列表 + """ + issues = [] + migration_paths = PluginMigrationRunner._filter_current_database_migrations( + self.discovered_plugin.manifest.backend.migrations + ) + for migration_path in migration_paths: + migration_file = self.migration_runner._resolve_migration_file(migration_path) + checksum = PluginMigrationRunner._calculate_checksum(migration_file) + existing_record = await self.migration_runner._get_existing_record(query_db, migration_path) + if existing_record and existing_record.status == 'running': + issues.append( + PluginValidationIssue( + level='error', + category='lifecycle', + kind='migration_running', + path=f'backend.migrations.{migration_path}', + message=f'插件 migration 上次执行仍处于 running 状态:{migration_path}', + suggestion='请确认数据库结构状态后手动处理迁移历史', + ) + ) + continue + if existing_record and existing_record.status not in {'success', 'failed'}: + issues.append( + PluginValidationIssue( + level='error', + category='lifecycle', + kind='migration_unknown_status', + path=f'backend.migrations.{migration_path}', + message=f'插件 migration 历史状态不支持自动执行:{migration_path},状态:{existing_record.status}', + suggestion='请确认数据库结构状态后手动处理迁移历史', + ) + ) + continue + existing_checksum = ( + existing_record.checksum if existing_record and existing_record.status == 'success' else None + ) + if existing_checksum and existing_checksum != checksum: + issues.append( + PluginValidationIssue( + level='error', + category='lifecycle', + kind='migration_checksum_changed', + path=f'backend.migrations.{migration_path}', + message=f'插件 migration 已执行但内容已变化:{migration_path}', + suggestion='请新增 migration 文件,不要修改已执行的历史 migration', + ) + ) + continue + issues.append( + PluginValidationIssue( + level='warning', + category='lifecycle', + kind=self._build_migration_status_issue_kind(existing_record), + path=f'backend.migrations.{migration_path}', + ok=True, + message=( + f'插件 migration 已执行且校验值一致,将跳过:{migration_path}' + if existing_checksum + else self._build_migration_pending_message(migration_path, existing_record) + ), + ) + ) + + return issues + + @staticmethod + def _build_migration_status_issue_kind(existing_record: object | None) -> str: + """ + 构建 migration 状态预检问题类型。 + + :param existing_record: migration 历史记录 + :return: 预检问题类型 + """ + status = getattr(existing_record, 'status', None) + if status == 'success': + return 'migration_already_recorded' + if status == 'failed': + return 'migration_failed_retry_pending' + return 'migration_pending' + + @staticmethod + def _build_migration_pending_message(migration_path: str, existing_record: object | None) -> str: + """ + 构建 migration 待执行提示。 + + :param migration_path: migration 相对路径 + :param existing_record: migration 历史记录 + :return: 待执行提示 + """ + if getattr(existing_record, 'status', None) == 'failed': + return f'插件 migration 上次执行失败,将在实际操作时重试:{migration_path}' + return f'插件 migration 将在实际操作时执行:{migration_path}' + + def _check_seeds(self) -> list[PluginValidationIssue]: + """ + 输出 seed 执行计划提示。 + + :return: seed 预检问题列表 + """ + seed_paths = PluginSeedRunner._filter_current_database_seeds(self.discovered_plugin.manifest.backend.seeds) + + return [ + PluginValidationIssue( + level='warning', + category='lifecycle', + kind='seed_pending', + path=f'backend.seeds.{seed_path}', + ok=True, + message=f'插件 seed 将在实际操作时执行:{seed_path}', + suggestion='请确保 seed 脚本可重复执行,避免重复初始化数据', + ) + for seed_path in seed_paths + ] diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/purge.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/purge.py new file mode 100644 index 0000000..5c162f9 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/purge.py @@ -0,0 +1,259 @@ +from dataclasses import dataclass + +from plugins.core.discovery.scanner import DiscoveredPlugin + + +@dataclass(frozen=True) +class PluginPurgePlanItem: + """ + 插件物理清理计划项。 + + :param name: 计划项名称 + :param label: 计划项展示名称 + :param enabled: 是否启用该计划项 + :param destructive: 是否为破坏性操作 + :param count: 计划项数量 + :param target: 计划项目标 + """ + + name: str + label: str + enabled: bool + destructive: bool + count: int | None = None + target: str | None = None + + +@dataclass(frozen=True) +class PluginPurgePlan: + """ + 插件物理清理计划。 + + :param plugin_id: 插件ID + :param items: 清理计划项列表 + :param removes_source: 是否删除源码目录 + :param requires_hook: 是否需要执行清理钩子 + """ + + plugin_id: str + items: list[PluginPurgePlanItem] + removes_source: bool + requires_hook: bool + + @property + def destructive_count(self) -> int: + """ + 获取破坏性清理项数量。 + + :return: 破坏性清理项数量 + """ + return len([item for item in self.items if item.enabled and item.destructive]) + + +class PluginPurgePlanner: + """ + 插件物理清理计划生成器。 + + 使用 Planner 模式生成 dry-run 和实际执行共享的可审计清理范围。 + """ + + @classmethod + def build_plan( + cls, + discovered_plugin: DiscoveredPlugin, + *, + menu_count: int = 0, + config_count: int = 0, + migration_count: int = 0, + job_count: int = 0, + ) -> PluginPurgePlan: + """ + 构建插件物理清理计划。 + + :param discovered_plugin: 已发现插件对象 + :param menu_count: 插件菜单数量 + :param config_count: 插件配置数量 + :param migration_count: 插件 migration 历史数量 + :param job_count: 插件任务数量 + :return: 插件物理清理计划 + """ + manifest = discovered_plugin.manifest + items = [ + PluginPurgePlanItem( + name='disable_plugin', + label='停用插件和菜单', + enabled=True, + destructive=False, + target=manifest.id, + ), + PluginPurgePlanItem( + name='delete_plugin_menus', + label='删除插件菜单关联和插件菜单', + enabled=menu_count > 0, + destructive=True, + count=menu_count, + ), + PluginPurgePlanItem( + name='delete_plugin_configs', + label='删除插件配置', + enabled=config_count > 0, + destructive=True, + count=config_count, + ), + PluginPurgePlanItem( + name='delete_plugin_migrations', + label='删除插件 migration 历史', + enabled=migration_count > 0, + destructive=True, + count=migration_count, + ), + PluginPurgePlanItem( + name='delete_plugin_jobs', + label='删除插件定时任务', + enabled=job_count > 0, + destructive=True, + count=job_count, + ), + PluginPurgePlanItem( + name='run_purge_hook', + label='执行插件 onPurge 钩子', + enabled=bool(manifest.backend.hooks.on_purge), + destructive=True, + target=manifest.backend.hooks.on_purge, + ), + *cls._build_resource_items(manifest), + PluginPurgePlanItem( + name='delete_plugin_state', + label='删除插件状态记录', + enabled=True, + destructive=True, + count=1, + ), + PluginPurgePlanItem( + name='remove_source', + label='删除插件源码目录', + enabled=False, + destructive=True, + target=str(discovered_plugin.backend_path), + ), + ] + + return PluginPurgePlan( + plugin_id=manifest.id, + items=items, + removes_source=False, + requires_hook=bool(manifest.backend.hooks.on_purge), + ) + + @classmethod + def build_metadata_plan( + cls, + plugin_id: str, + *, + state_count: int = 0, + menu_count: int = 0, + config_count: int = 0, + migration_count: int = 0, + job_count: int = 0, + ) -> PluginPurgePlan: + """ + 为源码已经缺失的孤儿插件构建平台元数据清理计划。 + + metadata-only 清理无法推断业务表和文件资源,也不会伪装执行 onPurge; + 计划只包含平台能够按插件 ID 明确归属的资源。 + + :param plugin_id: 插件ID + :param state_count: 插件状态记录数量 + :param menu_count: 插件菜单数量 + :param config_count: 插件配置数量 + :param migration_count: 插件 migration 历史数量 + :param job_count: 插件任务数量 + :return: 插件物理清理计划 + """ + items = [ + PluginPurgePlanItem( + name='disable_plugin', + label='停用插件和菜单', + enabled=state_count > 0, + destructive=False, + count=state_count, + target=plugin_id, + ), + PluginPurgePlanItem( + name='delete_plugin_menus', + label='删除插件菜单关联和插件菜单', + enabled=menu_count > 0, + destructive=True, + count=menu_count, + ), + PluginPurgePlanItem( + name='delete_plugin_configs', + label='删除插件配置', + enabled=config_count > 0, + destructive=True, + count=config_count, + ), + PluginPurgePlanItem( + name='delete_plugin_migrations', + label='删除插件 migration 历史', + enabled=migration_count > 0, + destructive=True, + count=migration_count, + ), + PluginPurgePlanItem( + name='delete_plugin_jobs', + label='删除插件定时任务', + enabled=job_count > 0, + destructive=True, + count=job_count, + ), + PluginPurgePlanItem( + name='delete_plugin_state', + label='删除插件状态记录', + enabled=state_count > 0, + destructive=True, + count=state_count, + ), + PluginPurgePlanItem( + name='remove_source', + label='删除插件源码目录', + enabled=False, + destructive=True, + ), + ] + return PluginPurgePlan( + plugin_id=plugin_id, + items=items, + removes_source=False, + requires_hook=False, + ) + + @staticmethod + def _build_resource_items(manifest: object) -> list[PluginPurgePlanItem]: + """ + 构建插件声明资源的清理提示项。 + + :param manifest: 插件 manifest + :return: 插件资源清理提示项列表 + """ + resources = getattr(manifest, 'resources', None) + if resources is None: + return [] + resource_groups = [ + ('resource_static', '插件静态资源需显式处理', getattr(resources, 'static', [])), + ('resource_uploads', '插件上传资源需显式处理', getattr(resources, 'uploads', [])), + ('resource_temp', '插件临时资源需显式处理', getattr(resources, 'temp', [])), + ] + + return [ + PluginPurgePlanItem( + name=name, + label=label, + enabled=bool(paths), + destructive=False, + count=len(paths), + target=', '.join(paths), + ) + for name, label, paths in resource_groups + if paths + ] diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/script.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/script.py new file mode 100644 index 0000000..0b440db --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/script.py @@ -0,0 +1,211 @@ +import importlib.util +import re +from pathlib import Path +from types import ModuleType + +SQL_LINE_COMMENT_PATTERN = re.compile(r'^\s*--') +SQL_DELIMITER_PATTERN = re.compile(r'^\s*DELIMITER\b', re.IGNORECASE) +DATABASE_DIALECT_DIRS = {'mysql', 'postgresql'} + + +class PluginLifecycleScriptHelper: + """ + 插件生命周期脚本通用工具。 + + migration 和 seed 都支持 Python/SQL 文件、数据库方言目录和插件根目录约束, + 这里集中这些纯辅助逻辑,避免两个 runner 各自维护一套实现。 + """ + + @staticmethod + def split_sql_statements(sql_content: str) -> list[str]: + """ + 将 SQL 文件内容拆分为语句列表。 + + :param sql_content: SQL 文件内容 + :return: SQL 语句列表 + """ + for line in sql_content.splitlines(): + if SQL_DELIMITER_PATTERN.match(line): + raise RuntimeError('插件 SQL 脚本暂不支持 DELIMITER,请改用 Python migration 或拆分为简单 SQL 文件') + + statement_chunks = [] + current_chars = [] + quote_char = None + line_has_content = False + index = 0 + while index < len(sql_content): + char = sql_content[index] + next_char = sql_content[index + 1] if index + 1 < len(sql_content) else '' + + if quote_char is None and char == '-' and next_char == '-' and not line_has_content: + index = PluginLifecycleScriptHelper._skip_sql_line_comment(sql_content, index + 2) + line_has_content = False + continue + + index, quote_char, statement_complete = PluginLifecycleScriptHelper._consume_sql_character( + sql_content, + index, + current_chars, + quote_char, + ) + if statement_complete: + statement_chunks.append(''.join(current_chars)) + current_chars = [] + + line_has_content = PluginLifecycleScriptHelper._update_sql_line_content_state(char, line_has_content) + index += 1 + + if current_chars: + statement_chunks.append(''.join(current_chars)) + + return [statement.strip().removesuffix(';').strip() for statement in statement_chunks if statement.strip()] + + @staticmethod + def _consume_sql_character( + sql_content: str, + index: int, + current_chars: list[str], + quote_char: str | None, + ) -> tuple[int, str | None, bool]: + """ + 消费一个 SQL 字符,并返回新的索引、引号状态和语句是否结束。 + + :param sql_content: SQL 文件内容 + :param index: 当前字符索引 + :param current_chars: 当前语句字符缓存 + :param quote_char: 当前引号状态 + :return: 新索引、新引号状态、语句是否结束 + """ + char = sql_content[index] + next_char = sql_content[index + 1] if index + 1 < len(sql_content) else '' + if quote_char is None and char in {"'", '"', '`'}: + current_chars.append(char) + return index, char, False + if quote_char == char: + current_chars.append(char) + if char == "'" and next_char == "'": + current_chars.append(next_char) + return index + 1, quote_char, False + return index, None, False + if quote_char is not None and char == '\\' and next_char: + current_chars.extend([char, next_char]) + return index + 1, quote_char, False + if quote_char is None and char == ';': + return index, quote_char, True + + current_chars.append(char) + return index, quote_char, False + + @staticmethod + def _update_sql_line_content_state(char: str, line_has_content: bool) -> bool: + """ + 更新当前行是否已有非空白内容。 + + :param char: 当前字符 + :param line_has_content: 当前行是否已有内容 + :return: 更新后的当前行内容状态 + """ + if char == '\n': + return False + if not char.isspace(): + return True + return line_has_content + + @staticmethod + def _skip_sql_line_comment(sql_content: str, index: int) -> int: + """ + 跳过 SQL 行注释。 + + :param sql_content: SQL 文件内容 + :param index: 注释起始位置之后的索引 + :return: 下一段内容的索引 + """ + while index < len(sql_content) and sql_content[index] != '\n': + index += 1 + return index + 1 if index < len(sql_content) else index + + @staticmethod + def filter_current_database_paths( + script_paths: list[str], + *, + root_dir: str, + database_type: str, + ) -> list[str]: + """ + 过滤当前数据库方言不匹配的脚本。 + + :param script_paths: 脚本相对路径列表 + :param root_dir: 方言目录父级,例如 migrations 或 seeds + :param database_type: 当前数据库类型 + :return: 当前数据库需要执行的脚本列表 + """ + filtered_paths = [] + for script_path in script_paths: + path_parts = Path(script_path).parts + dialect_dir = path_parts[1] if len(path_parts) > 1 and path_parts[0] == root_dir else None + if dialect_dir in DATABASE_DIALECT_DIRS and dialect_dir != database_type: + continue + filtered_paths.append(script_path) + + return filtered_paths + + @staticmethod + def resolve_file( + plugin_root: Path, + script_path: str, + *, + supported_suffixes: set[str], + label: str, + ) -> Path: + """ + 解析生命周期脚本文件绝对路径。 + + :param plugin_root: 插件后端根目录 + :param script_path: 脚本相对插件根目录路径 + :param supported_suffixes: 支持的文件后缀 + :param label: 脚本类型展示名 + :return: 脚本文件绝对路径 + """ + script_file = (plugin_root / script_path).resolve() + resolved_plugin_root = plugin_root.resolve() + if resolved_plugin_root not in script_file.parents: + raise RuntimeError(f'插件 {label} 路径不能越过插件根目录:{script_path}') + if script_file.suffix not in supported_suffixes: + raise RuntimeError(f'插件 {label} 仅支持 Python 或 SQL 文件:{script_path}') + if not script_file.is_file(): + raise RuntimeError(f'插件 {label} 文件不存在:{script_path}') + + return script_file + + @staticmethod + def load_module(module_name: str, script_file: Path, *, label: str) -> ModuleType: + """ + 加载生命周期 Python 脚本模块。 + + :param module_name: 模块名 + :param script_file: 脚本文件绝对路径 + :param label: 脚本类型展示名 + :return: Python 模块 + """ + module_spec = importlib.util.spec_from_file_location(module_name, script_file) + if module_spec is None or module_spec.loader is None: + raise RuntimeError(f'插件 {label} 模块加载失败:{script_file}') + script_module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(script_module) + + return script_module + + @staticmethod + def build_module_name(plugin_id: str, plugin_root: Path, script_file: Path) -> str: + """ + 构建生命周期脚本模块名。 + + :param plugin_id: 插件ID + :param plugin_root: 插件后端根目录 + :param script_file: 脚本文件绝对路径 + :return: 模块名 + """ + relative_path = script_file.relative_to(plugin_root).with_suffix('') + module_suffix = '.'.join(relative_path.parts) + + return f'plugins.{plugin_id}.{module_suffix}' diff --git a/ruoyi-fastapi-backend/plugins/core/lifecycle/seed.py b/ruoyi-fastapi-backend/plugins/core/lifecycle/seed.py new file mode 100644 index 0000000..5c93a28 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/lifecycle/seed.py @@ -0,0 +1,165 @@ +import inspect +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from sqlalchemy import text + +from config.env import DataBaseConfig +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.lifecycle.script import PluginLifecycleScriptHelper + +SUPPORTED_SEED_SUFFIXES = {'.py', '.sql'} + + +@dataclass(frozen=True) +class PluginSeedResult: + """ + 插件 seed 执行结果。 + """ + + seed_path: str + module_name: str + statement_count: int = 0 + + +class PluginSeedRunner: + """ + 插件 seed 运行器。 + + 使用 Command Runner 模式按 manifest 声明顺序执行插件初始化脚本。 + Python seed 模块需要暴露 `run(query_db)` 函数,SQL seed 会按分号拆分并逐条执行。 + """ + + def __init__(self, discovered_plugin: DiscoveredPlugin) -> None: + """ + 初始化插件 seed 运行器。 + + :param discovered_plugin: 已发现插件对象 + """ + self.discovered_plugin = discovered_plugin + + async def run(self, query_db: Any) -> list[PluginSeedResult]: + """ + 执行插件清单声明的 seed。 + + :param query_db: orm对象 + :return: seed 执行结果列表 + """ + return [ + await self._run_seed(seed_path, query_db) + for seed_path in self._filter_current_database_seeds(self.discovered_plugin.manifest.backend.seeds) + ] + + async def _run_seed(self, seed_path: str, query_db: Any) -> PluginSeedResult: + """ + 执行单个 seed。 + + :param seed_path: seed 相对插件根目录路径 + :param query_db: orm对象 + :return: seed 执行结果 + """ + seed_file = self._resolve_seed_file(seed_path) + if seed_file.suffix == '.sql': + return await self._run_sql_seed(seed_path, seed_file, query_db) + + return await self._run_python_seed(seed_path, seed_file, query_db) + + async def _run_python_seed(self, seed_path: str, seed_file: Path, query_db: Any) -> PluginSeedResult: + """ + 执行 Python seed。 + + :param seed_path: seed 相对插件根目录路径 + :param seed_file: seed 文件绝对路径 + :param query_db: orm对象 + :return: seed 执行结果 + """ + seed_module = self._load_seed_module(seed_file) + seed_runner = getattr(seed_module, 'run', None) + if not callable(seed_runner): + raise RuntimeError(f'插件 seed 必须暴露 run(query_db) 函数:{seed_path}') + + result = seed_runner(query_db) + if inspect.isawaitable(result): + await result + + return PluginSeedResult(seed_path=seed_path, module_name=seed_module.__name__) + + async def _run_sql_seed(self, seed_path: str, seed_file: Path, query_db: Any) -> PluginSeedResult: + """ + 执行 SQL seed。 + + :param seed_path: seed 相对插件根目录路径 + :param seed_file: seed 文件绝对路径 + :param query_db: orm对象 + :return: seed 执行结果 + """ + statements = self._load_sql_statements(seed_file) + for statement in statements: + await query_db.execute(text(statement)) + + return PluginSeedResult( + seed_path=seed_path, + module_name=self._build_seed_module_name(seed_file), + statement_count=len(statements), + ) + + def _load_sql_statements(self, seed_file: Path) -> list[str]: + """ + 加载 SQL seed 语句列表。 + + :param seed_file: seed 文件绝对路径 + :return: SQL 语句列表 + """ + return PluginLifecycleScriptHelper.split_sql_statements(seed_file.read_text(encoding='utf-8')) + + def _resolve_seed_file(self, seed_path: str) -> Path: + """ + 解析 seed 文件绝对路径。 + + :param seed_path: seed 相对插件根目录路径 + :return: seed 文件绝对路径 + """ + return PluginLifecycleScriptHelper.resolve_file( + self.discovered_plugin.backend_path, + seed_path, + supported_suffixes=SUPPORTED_SEED_SUFFIXES, + label='seed', + ) + + @classmethod + def _filter_current_database_seeds(cls, seed_paths: list[str]) -> list[str]: + """ + 过滤当前数据库方言不匹配的 seed。 + + :param seed_paths: seed 相对路径列表 + :return: 当前数据库需要执行的 seed 列表 + """ + return PluginLifecycleScriptHelper.filter_current_database_paths( + seed_paths, + root_dir='seeds', + database_type=DataBaseConfig.db_type, + ) + + def _load_seed_module(self, seed_file: Path) -> Any: + """ + 加载 seed Python 模块。 + + :param seed_file: seed 文件绝对路径 + :return: seed 模块 + """ + module_name = self._build_seed_module_name(seed_file) + return PluginLifecycleScriptHelper.load_module(module_name, seed_file, label='seed') + + def _build_seed_module_name(self, seed_file: Path) -> str: + """ + 构建 seed 模块名。 + + :param seed_file: seed 文件绝对路径 + :return: seed 模块名 + """ + return PluginLifecycleScriptHelper.build_module_name( + self.discovered_plugin.manifest.id, + self.discovered_plugin.backend_path, + seed_file, + ) diff --git a/ruoyi-fastapi-backend/plugins/core/management/dao/dao.py b/ruoyi-fastapi-backend/plugins/core/management/dao/dao.py new file mode 100644 index 0000000..65fd40f --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/dao/dao.py @@ -0,0 +1,946 @@ +from collections.abc import Sequence +from datetime import datetime, time +from typing import Any + +from sqlalchemy import delete, func, or_, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import PageModel +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.do.role_do import SysRoleMenu +from module_admin.entity.vo.menu_vo import MenuModel +from plugins.core.management.entity.do.models import ( + SysPlugin, + SysPluginConfig, + SysPluginMenu, + SysPluginMigration, + SysPluginOperationLog, +) +from plugins.core.management.entity.vo.schemas import ( + PluginConfigModel, + PluginMenuModel, + PluginMigrationModel, + PluginModel, + PluginOperationLogExportQueryModel, + PluginOperationLogModel, + PluginOperationLogPageQueryModel, + PluginPageQueryModel, +) +from plugins.core.utils import escape_sql_like +from utils.page_util import PageUtil + +PLUGIN_MODEL_RUNTIME_FIELDS = { + 'capability', + 'metadata', + 'backend', + 'frontend', + 'permissions', + 'config', + 'dependencies', + 'plugin_dependencies', +} + + +class PluginDao: + """ + 插件系统数据库操作层。 + """ + + @classmethod + async def get_plugin_by_id(cls, db: AsyncSession, plugin_id: str) -> SysPlugin | None: + """ + 根据插件 ID 查询插件。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: 插件信息对象 + """ + plugin = (await db.execute(select(SysPlugin).where(SysPlugin.plugin_id == plugin_id))).scalars().first() + + return plugin + + @classmethod + async def get_plugin_list(cls, db: AsyncSession) -> Sequence[SysPlugin]: + """ + 查询插件列表。 + + :param db: orm对象 + :return: 插件信息列表 + """ + plugin_list = (await db.execute(select(SysPlugin).order_by(SysPlugin.plugin_id))).scalars().all() + + return plugin_list + + @classmethod + async def get_plugin_page_list( + cls, + db: AsyncSession, + query_object: PluginPageQueryModel, + is_page: bool = False, + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取插件列表。 + + :param db: orm对象 + :param query_object: 插件查询对象 + :param is_page: 是否开启分页 + :return: 插件列表分页对象或插件列表 + """ + query = ( + select(SysPlugin) + .where( + SysPlugin.plugin_id.like(f'%{query_object.plugin_id}%') if query_object.plugin_id else True, + SysPlugin.plugin_name.like(f'%{query_object.plugin_name}%') if query_object.plugin_name else True, + SysPlugin.enabled == query_object.enabled if query_object.enabled else True, + SysPlugin.status == query_object.status if query_object.status else True, + SysPlugin.source == query_object.source if query_object.source else True, + ) + .order_by(SysPlugin.plugin_id) + .distinct() + ) + plugin_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, + query, + query_object.page_num, + query_object.page_size, + is_page, + ) + + return plugin_list + + @classmethod + async def add_plugin(cls, db: AsyncSession, plugin: PluginModel) -> SysPlugin: + """ + 新增插件。 + + :param db: orm对象 + :param plugin: 插件信息对象 + :return: 新增后的插件信息对象 + """ + db_plugin = SysPlugin(**cls.dump_plugin_persistence_payload(plugin)) + db.add(db_plugin) + await db.flush() + + return db_plugin + + @staticmethod + def dump_plugin_persistence_payload(plugin: PluginModel) -> dict[str, Any]: + """ + 序列化插件数据库持久化字段。 + + :param plugin: 插件信息对象 + :return: 可写入 sys_plugin 的字段字典 + """ + return plugin.model_dump(exclude_unset=True, exclude=PLUGIN_MODEL_RUNTIME_FIELDS) + + @classmethod + async def update_plugin(cls, db: AsyncSession, plugin: dict) -> None: + """ + 更新插件。 + + :param db: orm对象 + :param plugin: 插件更新字典 + :return: None + """ + await db.execute(update(SysPlugin), [plugin]) + + @classmethod + async def delete_plugin(cls, db: AsyncSession, plugin_id: str) -> None: + """ + 删除插件记录。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: None + """ + await db.execute(delete(SysPlugin).where(SysPlugin.plugin_id == plugin_id)) + + @classmethod + async def count_plugin_menus(cls, db: AsyncSession, plugin_id: str) -> int: + """ + 统计插件菜单关联数量。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: 插件菜单关联数量 + """ + matched_count = ( + await db.execute( + select(func.count()).select_from(SysPluginMenu).where(SysPluginMenu.plugin_id == plugin_id) + ) + ).scalar_one() + + return int(matched_count) + + @classmethod + async def get_plugin_menu_list(cls, db: AsyncSession, plugin_id: str) -> Sequence[SysPluginMenu]: + """ + 查询插件菜单关联列表。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: 插件菜单关联列表 + """ + plugin_menu_list = ( + (await db.execute(select(SysPluginMenu).where(SysPluginMenu.plugin_id == plugin_id))).scalars().all() + ) + + return plugin_menu_list + + @classmethod + async def get_plugin_menu_by_key(cls, db: AsyncSession, plugin_id: str, menu_key: str) -> SysPluginMenu | None: + """ + 根据插件菜单自然键查询菜单关联。 + + :param db: orm对象 + :param plugin_id: 插件ID + :param menu_key: 插件内菜单自然键 + :return: 插件菜单关联对象 + """ + plugin_menu = ( + ( + await db.execute( + select(SysPluginMenu).where( + SysPluginMenu.plugin_id == plugin_id, + SysPluginMenu.menu_key == menu_key, + ) + ) + ) + .scalars() + .first() + ) + + return plugin_menu + + @classmethod + async def add_plugin_menu(cls, db: AsyncSession, plugin_menu: PluginMenuModel) -> SysPluginMenu: + """ + 新增插件菜单关联。 + + :param db: orm对象 + :param plugin_menu: 插件菜单关联对象 + :return: 新增后的插件菜单关联对象 + """ + db_plugin_menu = SysPluginMenu(**plugin_menu.model_dump(exclude_unset=True)) + db.add(db_plugin_menu) + await db.flush() + + return db_plugin_menu + + @classmethod + async def update_plugin_menu_by_key(cls, db: AsyncSession, plugin_menu: PluginMenuModel) -> None: + """ + 根据插件菜单自然键更新插件菜单关联。 + + :param db: orm对象 + :param plugin_menu: 插件菜单关联对象 + :return: None + """ + await db.execute( + update(SysPluginMenu) + .where(SysPluginMenu.plugin_id == plugin_menu.plugin_id, SysPluginMenu.menu_key == plugin_menu.menu_key) + .values(menu_id=plugin_menu.menu_id) + ) + + @classmethod + async def update_plugin_menu_key_by_menu_id(cls, db: AsyncSession, plugin_menu: PluginMenuModel) -> None: + """ + 根据菜单 ID 更新当前插件的菜单自然键。 + + :param db: orm对象 + :param plugin_menu: 插件菜单关联对象 + :return: None + """ + await db.execute( + update(SysPluginMenu) + .where( + SysPluginMenu.plugin_id == plugin_menu.plugin_id, + SysPluginMenu.menu_id == plugin_menu.menu_id, + ) + .values(menu_key=plugin_menu.menu_key) + ) + + @classmethod + async def delete_plugin_menus(cls, db: AsyncSession, plugin_id: str) -> None: + """ + 删除插件菜单关联。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: None + """ + await db.execute(delete(SysPluginMenu).where(SysPluginMenu.plugin_id == plugin_id)) + + @classmethod + async def delete_plugin_menus_by_ids(cls, db: AsyncSession, plugin_id: str, menu_ids: list[int]) -> None: + """ + 根据菜单 ID 删除指定插件的菜单 ownership 关联。 + + :param db: orm对象 + :param plugin_id: 插件ID + :param menu_ids: 菜单ID列表 + :return: None + """ + if menu_ids: + await db.execute( + delete(SysPluginMenu).where( + SysPluginMenu.plugin_id == plugin_id, + SysPluginMenu.menu_id.in_(menu_ids), + ) + ) + + @classmethod + async def delete_sys_menus_by_ids(cls, db: AsyncSession, menu_ids: list[int]) -> None: + """ + 根据菜单 ID 删除系统菜单及角色菜单关联。 + + :param db: orm对象 + :param menu_ids: 菜单ID列表 + :return: None + """ + if menu_ids: + await db.execute(delete(SysRoleMenu).where(SysRoleMenu.menu_id.in_(menu_ids))) + await db.execute(delete(SysMenu).where(SysMenu.menu_id.in_(menu_ids))) + + @classmethod + async def count_plugin_migrations(cls, db: AsyncSession, plugin_id: str) -> int: + """ + 统计插件 migration 历史数量。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: migration 历史数量 + """ + matched_count = ( + await db.execute( + select(func.count()).select_from(SysPluginMigration).where(SysPluginMigration.plugin_id == plugin_id) + ) + ).scalar_one() + + return int(matched_count) + + @classmethod + async def delete_plugin_migrations(cls, db: AsyncSession, plugin_id: str) -> None: + """ + 删除插件 migration 历史。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: None + """ + await db.execute(delete(SysPluginMigration).where(SysPluginMigration.plugin_id == plugin_id)) + + @classmethod + async def get_plugin_migration_by_path( + cls, + db: AsyncSession, + plugin_id: str, + migration_path: str, + ) -> SysPluginMigration | None: + """ + 根据插件 ID 和 migration 路径查询执行历史。 + + :param db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: migration 执行历史对象 + """ + plugin_migration = ( + ( + await db.execute( + select(SysPluginMigration).where( + SysPluginMigration.plugin_id == plugin_id, + SysPluginMigration.migration_path == migration_path, + ) + ) + ) + .scalars() + .first() + ) + + return plugin_migration + + @classmethod + async def get_plugin_migration_list( + cls, + db: AsyncSession, + plugin_id: str, + status: str | None = None, + ) -> Sequence[SysPluginMigration]: + """ + 查询插件 migration 历史列表。 + + :param db: orm对象 + :param plugin_id: 插件ID + :param status: 执行状态 + :return: migration 执行历史列表 + """ + migration_list = ( + ( + await db.execute( + select(SysPluginMigration) + .where( + SysPluginMigration.plugin_id == plugin_id, + SysPluginMigration.status == status if status else True, + ) + .order_by(SysPluginMigration.migration_path) + ) + ) + .scalars() + .all() + ) + + return migration_list + + @classmethod + async def update_plugin_migration_status( + cls, + db: AsyncSession, + plugin_id: str, + migration_path: str, + status: str, + error_message: str | None, + ) -> SysPluginMigration | None: + """ + 更新插件 migration 执行历史状态。 + + :param db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param status: 执行状态 + :param error_message: 失败错误信息 + :return: 更新后的 migration 执行历史对象 + """ + existing_plugin_migration = await cls.get_plugin_migration_by_path(db, plugin_id, migration_path) + if not existing_plugin_migration: + return None + + now = datetime.now() + await db.execute( + update(SysPluginMigration) + .where( + SysPluginMigration.plugin_id == plugin_id, + SysPluginMigration.migration_path == migration_path, + ) + .values(status=status, error_message=error_message, finished_time=now, update_time=now) + ) + await db.flush() + + return await cls.get_plugin_migration_by_path(db, plugin_id, migration_path) + + @classmethod + async def add_plugin_migration( + cls, + db: AsyncSession, + plugin_migration: PluginMigrationModel, + ) -> SysPluginMigration: + """ + 新增插件 migration 执行历史。 + + :param db: orm对象 + :param plugin_migration: 插件 migration 执行历史对象 + :return: 新增后的 migration 执行历史对象 + """ + payload = plugin_migration.model_dump(exclude_unset=True) + now = datetime.now() + cls._apply_migration_observability_payload(payload, None, now) + if payload.get('status') == 'success' and 'error_message' not in payload: + payload['error_message'] = None + existing_plugin_migration = await cls.get_plugin_migration_by_path( + db, + plugin_migration.plugin_id, + plugin_migration.migration_path, + ) + if existing_plugin_migration: + cls._apply_migration_observability_payload(payload, existing_plugin_migration, now) + await db.execute( + update(SysPluginMigration) + .where( + SysPluginMigration.plugin_id == plugin_migration.plugin_id, + SysPluginMigration.migration_path == plugin_migration.migration_path, + ) + .values(**payload) + ) + await db.flush() + return existing_plugin_migration + + db_plugin_migration = SysPluginMigration(**payload) + db.add(db_plugin_migration) + await db.flush() + + return db_plugin_migration + + @staticmethod + def _apply_migration_observability_payload( + payload: dict[str, Any], + existing_plugin_migration: SysPluginMigration | None, + now: datetime, + ) -> None: + """ + 补充 migration 状态观测字段。 + + :param payload: 待写入 payload + :param existing_plugin_migration: 已有 migration 历史 + :param now: 当前时间 + :return: None + """ + status = payload.get('status', 'success') + payload.setdefault('update_time', now) + if status == 'running': + payload.setdefault('started_time', now) + payload.setdefault('finished_time', None) + payload['attempt_count'] = int(getattr(existing_plugin_migration, 'attempt_count', 0) or 0) + 1 + return + + if status in {'success', 'failed'}: + payload.setdefault('finished_time', now) + + @classmethod + async def get_plugin_config_list(cls, db: AsyncSession, plugin_id: str) -> Sequence[SysPluginConfig]: + """ + 查询插件配置列表。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: 插件配置列表 + """ + plugin_config_list = ( + (await db.execute(select(SysPluginConfig).where(SysPluginConfig.plugin_id == plugin_id))).scalars().all() + ) + + return plugin_config_list + + @classmethod + async def count_plugin_configs(cls, db: AsyncSession, plugin_id: str) -> int: + """ + 统计插件配置数量。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: 插件配置数量 + """ + matched_count = ( + await db.execute( + select(func.count()).select_from(SysPluginConfig).where(SysPluginConfig.plugin_id == plugin_id) + ) + ).scalar_one() + + return int(matched_count) + + @classmethod + async def delete_plugin_configs(cls, db: AsyncSession, plugin_id: str) -> None: + """ + 删除插件配置。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: None + """ + await db.execute(delete(SysPluginConfig).where(SysPluginConfig.plugin_id == plugin_id)) + + @classmethod + async def delete_plugin_configs_except(cls, db: AsyncSession, plugin_id: str, config_keys: set[str]) -> None: + """ + 删除不再由 manifest 声明的插件配置。 + + :param db: orm对象 + :param plugin_id: 插件ID + :param config_keys: 当前 manifest 配置键集合 + :return: None + """ + query = delete(SysPluginConfig).where(SysPluginConfig.plugin_id == plugin_id) + if config_keys: + query = query.where(SysPluginConfig.config_key.not_in(sorted(config_keys))) + await db.execute(query) + + @classmethod + async def get_plugin_config_by_key( + cls, + db: AsyncSession, + plugin_id: str, + config_key: str, + ) -> SysPluginConfig | None: + """ + 根据插件 ID 和配置键名查询插件配置。 + + :param db: orm对象 + :param plugin_id: 插件ID + :param config_key: 配置键名 + :return: 插件配置对象 + """ + plugin_config = ( + ( + await db.execute( + select(SysPluginConfig).where( + SysPluginConfig.plugin_id == plugin_id, + SysPluginConfig.config_key == config_key, + ) + ) + ) + .scalars() + .first() + ) + + return plugin_config + + @classmethod + async def add_plugin_config(cls, db: AsyncSession, plugin_config: PluginConfigModel) -> SysPluginConfig: + """ + 新增插件配置。 + + :param db: orm对象 + :param plugin_config: 插件配置对象 + :return: 新增后的插件配置对象 + """ + db_plugin_config = SysPluginConfig(**plugin_config.model_dump(exclude_unset=True)) + db.add(db_plugin_config) + await db.flush() + + return db_plugin_config + + @classmethod + async def update_plugin_config(cls, db: AsyncSession, plugin_config: dict) -> None: + """ + 更新插件配置。 + + :param db: orm对象 + :param plugin_config: 插件配置更新字典 + :return: None + """ + await db.execute(update(SysPluginConfig), [plugin_config]) + + @classmethod + async def add_plugin_operation_log( + cls, + db: AsyncSession, + operation_log: PluginOperationLogModel, + ) -> SysPluginOperationLog: + """ + 新增插件批量操作审计日志。 + + :param db: orm对象 + :param operation_log: 插件批量操作审计日志对象 + :return: 新增后的插件批量操作审计日志对象 + """ + db_operation_log = SysPluginOperationLog(**operation_log.model_dump(exclude_unset=True)) + db.add(db_operation_log) + await db.flush() + + return db_operation_log + + @classmethod + async def get_plugin_operation_log_page_list( + cls, + db: AsyncSession, + query_object: PluginOperationLogPageQueryModel, + is_page: bool = False, + ) -> PageModel | list[dict[str, Any]]: + """ + 根据查询参数获取插件批量操作审计日志列表。 + + :param db: orm对象 + :param query_object: 插件批量操作审计日志查询对象 + :param is_page: 是否开启分页 + :return: 插件批量操作审计日志分页对象或列表 + """ + query = ( + cls._build_operation_log_query(query_object).order_by(SysPluginOperationLog.operation_id.desc()).distinct() + ) + operation_log_list: PageModel | list[dict[str, Any]] = await PageUtil.paginate( + db, + query, + query_object.page_num, + query_object.page_size, + is_page, + ) + + return operation_log_list + + @classmethod + async def get_plugin_operation_log_export_list( + cls, + db: AsyncSession, + query_object: PluginOperationLogExportQueryModel, + ) -> list[dict[str, Any]]: + """ + 根据查询参数获取插件批量操作审计日志导出列表。 + + :param db: orm对象 + :param query_object: 插件批量操作审计日志导出查询对象 + :return: 插件批量操作审计日志导出列表 + """ + query = ( + cls._build_operation_log_query(query_object) + .order_by(SysPluginOperationLog.operation_id.desc()) + .limit(query_object.export_limit) + .distinct() + ) + export_list = await PageUtil.paginate(db, query, page_num=1, page_size=query_object.export_limit, is_page=False) + + return export_list + + @classmethod + def _build_operation_log_query( + cls, + query_object: PluginOperationLogPageQueryModel | PluginOperationLogExportQueryModel, + ) -> Any: + """ + 构建插件操作审计日志基础查询。 + + :param query_object: 插件操作审计日志查询对象 + :return: SQLAlchemy 查询对象 + """ + return select(SysPluginOperationLog).where( + SysPluginOperationLog.operation == query_object.operation if query_object.operation else True, + SysPluginOperationLog.status == query_object.status if query_object.status else True, + cls._build_plugin_ids_filter(query_object.plugin_id), + cls._build_operation_log_time_filter(query_object), + ) + + @staticmethod + def _build_plugin_ids_filter(plugin_id: str | None) -> Any: + """ + 构建插件 ID JSON 数组边界匹配条件。 + + :param plugin_id: 插件ID + :return: SQLAlchemy 过滤条件 + """ + if not plugin_id: + return True + + escaped_plugin_id = escape_sql_like(plugin_id) + return or_( + SysPluginOperationLog.plugin_ids == f'["{plugin_id}"]', + SysPluginOperationLog.plugin_ids.like(f'["{escaped_plugin_id}",%', escape='\\'), + SysPluginOperationLog.plugin_ids.like(f'%, "{escaped_plugin_id}",%', escape='\\'), + SysPluginOperationLog.plugin_ids.like(f'%, "{escaped_plugin_id}"]', escape='\\'), + SysPluginOperationLog.plugin_ids.like(f'%,"{escaped_plugin_id}",%', escape='\\'), + SysPluginOperationLog.plugin_ids.like(f'%,"{escaped_plugin_id}"]', escape='\\'), + ) + + @staticmethod + def _build_operation_log_time_filter( + query_object: PluginOperationLogPageQueryModel | PluginOperationLogExportQueryModel, + ) -> Any: + """ + 构建插件操作审计日志时间范围条件。 + + :param query_object: 插件操作审计日志查询对象 + :return: SQLAlchemy 时间范围条件或 True + """ + if not query_object.begin_time or not query_object.end_time: + return True + + return SysPluginOperationLog.create_time.between( + datetime.combine(datetime.strptime(query_object.begin_time, '%Y-%m-%d'), time(00, 00, 00)), + datetime.combine(datetime.strptime(query_object.end_time, '%Y-%m-%d'), time(23, 59, 59)), + ) + + @classmethod + async def get_plugin_operation_log_by_id( + cls, + db: AsyncSession, + operation_id: int, + ) -> SysPluginOperationLog | None: + """ + 根据操作日志 ID 查询插件批量操作审计日志。 + + :param db: orm对象 + :param operation_id: 操作日志ID + :return: 插件批量操作审计日志对象 + """ + operation_log = ( + (await db.execute(select(SysPluginOperationLog).where(SysPluginOperationLog.operation_id == operation_id))) + .scalars() + .first() + ) + + return operation_log + + @classmethod + async def count_plugin_operation_logs_before( + cls, + db: AsyncSession, + cutoff_time: datetime, + ) -> int: + """ + 统计早于指定时间的插件批量操作审计日志数量。 + + :param db: orm对象 + :param cutoff_time: 清理截止时间 + :return: 插件批量操作审计日志数量 + """ + matched_count = ( + await db.execute( + select(func.count(SysPluginOperationLog.operation_id)).where( + SysPluginOperationLog.create_time < cutoff_time + ) + ) + ).scalar_one() + + return int(matched_count) + + @classmethod + async def delete_plugin_operation_logs_before( + cls, + db: AsyncSession, + cutoff_time: datetime, + ) -> int: + """ + 删除早于指定时间的插件批量操作审计日志。 + + :param db: orm对象 + :param cutoff_time: 清理截止时间 + :return: 删除的插件批量操作审计日志数量 + """ + delete_result = await db.execute( + delete(SysPluginOperationLog).where(SysPluginOperationLog.create_time < cutoff_time) + ) + + return int(delete_result.rowcount or 0) + + @classmethod + async def get_sys_menu_by_id(cls, db: AsyncSession, menu_id: int) -> SysMenu | None: + """ + 根据菜单 ID 查询系统菜单。 + + :param db: orm对象 + :param menu_id: 菜单ID + :return: 系统菜单对象 + """ + menu = (await db.execute(select(SysMenu).where(SysMenu.menu_id == menu_id))).scalars().first() + + return menu + + @classmethod + async def get_sys_menu_by_perms(cls, db: AsyncSession, perms: str) -> SysMenu | None: + """ + 根据权限标识查询系统菜单。 + + :param db: orm对象 + :param perms: 权限标识 + :return: 系统菜单对象 + """ + menu = (await db.execute(select(SysMenu).where(SysMenu.perms == perms))).scalars().first() + + return menu + + @classmethod + async def get_plugin_menu_by_menu_id(cls, db: AsyncSession, menu_id: int) -> SysPluginMenu | None: + """ + 根据菜单 ID 查询插件菜单关联。 + + :param db: orm对象 + :param menu_id: 菜单ID + :return: 插件菜单关联对象 + """ + plugin_menu = ( + (await db.execute(select(SysPluginMenu).where(SysPluginMenu.menu_id == menu_id))).scalars().first() + ) + + return plugin_menu + + @classmethod + async def get_sys_menu_by_route( + cls, + db: AsyncSession, + parent_id: int, + path: str, + component: str, + ) -> SysMenu | None: + """ + 根据父级、路由路径和组件路径查询系统菜单。 + + :param db: orm对象 + :param parent_id: 父菜单ID + :param path: 路由路径 + :param component: 组件路径 + :return: 系统菜单对象 + """ + menu = ( + ( + await db.execute( + select(SysMenu).where( + SysMenu.parent_id == parent_id, + SysMenu.path == path, + SysMenu.component == component, + ) + ) + ) + .scalars() + .first() + ) + + return menu + + @classmethod + async def get_sys_menu_by_name_path( + cls, + db: AsyncSession, + parent_id: int, + menu_name: str, + path: str, + ) -> SysMenu | None: + """ + 根据父级、菜单名称和路由路径查询系统菜单。 + + :param db: orm对象 + :param parent_id: 父菜单ID + :param menu_name: 菜单名称 + :param path: 路由路径 + :return: 系统菜单对象 + """ + menu = ( + ( + await db.execute( + select(SysMenu).where( + SysMenu.parent_id == parent_id, + SysMenu.menu_name == menu_name, + SysMenu.path == path, + ) + ) + ) + .scalars() + .first() + ) + + return menu + + @classmethod + async def add_sys_menu(cls, db: AsyncSession, menu: MenuModel) -> SysMenu: + """ + 新增系统菜单。 + + :param db: orm对象 + :param menu: 菜单对象 + :return: 新增后的系统菜单对象 + """ + db_menu = SysMenu(**menu.model_dump(exclude_unset=True)) + db.add(db_menu) + await db.flush() + + return db_menu + + @classmethod + async def update_sys_menu(cls, db: AsyncSession, menu: dict) -> None: + """ + 更新系统菜单。 + + :param db: orm对象 + :param menu: 菜单更新字典 + :return: None + """ + await db.execute(update(SysMenu), [menu]) + + @classmethod + async def update_sys_menu_status_by_ids(cls, db: AsyncSession, menu_ids: list[int], status: str) -> None: + """ + 批量更新系统菜单状态。 + + :param db: orm对象 + :param menu_ids: 菜单ID列表 + :param status: 菜单状态 + :return: None + """ + if menu_ids: + await db.execute(update(SysMenu).where(SysMenu.menu_id.in_(menu_ids)).values(status=status)) diff --git a/ruoyi-fastapi-backend/plugins/core/management/entity/do/models.py b/ruoyi-fastapi-backend/plugins/core/management/entity/do/models.py new file mode 100644 index 0000000..88b30c1 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/entity/do/models.py @@ -0,0 +1,159 @@ +from datetime import datetime + +from sqlalchemy import CHAR, BigInteger, CheckConstraint, Column, DateTime, Integer, String, Text, UniqueConstraint + +from config.database import Base +from config.env import DataBaseConfig +from utils.common_util import SqlalchemyUtil + + +class SysPlugin(Base): + """ + 插件信息表。 + + 字段说明通过 SQLAlchemy Column 的 comment 声明。 + """ + + __tablename__ = 'sys_plugin' + __table_args__ = ( + CheckConstraint("enabled in ('0', '1')", name='ck_sys_plugin_enabled'), + CheckConstraint( + "status in ('discovered', 'installed', 'pending_upgrade', 'error')", + name='ck_sys_plugin_status', + ), + {'comment': '插件信息表'}, + ) + + plugin_id = Column(String(64), primary_key=True, nullable=False, comment='插件ID') + plugin_name = Column(String(128), nullable=False, comment='插件名称') + version = Column(String(32), nullable=False, comment='当前源码版本') + installed_version = Column(String(32), nullable=True, comment='已安装版本') + enabled = Column(CHAR(1), nullable=False, server_default='0', comment='是否启用(0启用 1停用)') + status = Column(String(32), nullable=False, server_default='discovered', comment='插件状态') + source = Column(String(32), nullable=False, server_default='local', comment='插件来源') + backend_path = Column(String(255), nullable=True, comment='后端插件相对路径') + frontend_path = Column(String(255), nullable=True, comment='前端插件相对路径') + last_error = Column(String(1000), nullable=True, comment='最近一次错误信息') + description = Column(String(500), nullable=True, comment='插件说明') + create_by = Column(String(64), nullable=True, server_default="''", comment='创建者') + create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间') + update_by = Column(String(64), nullable=True, server_default="''", comment='更新者') + update_time = Column( + DateTime, + nullable=True, + default=datetime.now, + onupdate=datetime.now, + comment='更新时间', + ) + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) + + +class SysPluginMenu(Base): + """ + 插件和菜单关联表。 + + 字段说明通过 SQLAlchemy Column 的 comment 声明。 + """ + + __tablename__ = 'sys_plugin_menu' + __table_args__ = ( + UniqueConstraint('plugin_id', 'menu_key', name='uk_sys_plugin_menu_key'), + {'comment': '插件和菜单关联表'}, + ) + + plugin_id = Column(String(64), primary_key=True, nullable=False, comment='插件ID') + menu_id = Column(BigInteger, primary_key=True, nullable=False, comment='菜单ID') + menu_key = Column(String(255), nullable=False, comment='插件内菜单自然键') + create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间') + + +class SysPluginMigration(Base): + """ + 插件 migration 执行历史表。 + + 字段说明通过 SQLAlchemy Column 的 comment 声明。 + """ + + __tablename__ = 'sys_plugin_migration' + __table_args__ = {'comment': '插件 migration 执行历史表'} + + plugin_id = Column(String(64), primary_key=True, nullable=False, comment='插件ID') + migration_path = Column(String(255), primary_key=True, nullable=False, comment='migration 相对路径') + migration_checksum = Column(String(64), nullable=False, comment='migration 内容校验值') + version = Column(String(32), nullable=True, comment='执行时插件版本') + statement_count = Column(Integer, nullable=False, default=0, comment='SQL 语句数量') + status = Column(String(32), nullable=False, server_default='success', comment='执行状态') + error_message = Column(Text, nullable=True, comment='失败错误信息') + attempt_count = Column(Integer, nullable=False, default=0, comment='尝试次数') + started_time = Column(DateTime, nullable=True, comment='最近开始时间') + finished_time = Column(DateTime, nullable=True, comment='最近结束时间') + create_time = Column(DateTime, nullable=True, default=datetime.now, comment='执行时间') + update_time = Column(DateTime, nullable=True, onupdate=datetime.now, comment='更新时间') + + +class SysPluginConfig(Base): + """ + 插件配置表。 + + 字段说明通过 SQLAlchemy Column 的 comment 声明。 + """ + + __tablename__ = 'sys_plugin_config' + __table_args__ = {'comment': '插件配置表'} + + plugin_id = Column(String(64), primary_key=True, nullable=False, comment='插件ID') + config_key = Column(String(128), primary_key=True, nullable=False, comment='配置键名') + config_label = Column(String(128), nullable=True, comment='配置展示名称') + config_type = Column(String(32), nullable=False, server_default='string', comment='配置值类型') + config_value = Column(Text, nullable=True, comment='配置值') + default_value = Column(Text, nullable=True, comment='默认配置值') + required = Column(CHAR(1), nullable=False, server_default='1', comment='是否必填(0是 1否)') + secret = Column(CHAR(1), nullable=False, server_default='1', comment='是否敏感(0是 1否)') + options = Column(Text, nullable=True, comment='配置选项JSON') + description = Column(String(500), nullable=True, comment='配置说明') + create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间') + update_time = Column( + DateTime, + nullable=True, + default=datetime.now, + onupdate=datetime.now, + comment='更新时间', + ) + + +class SysPluginOperationLog(Base): + """ + 插件批量操作审计日志表。 + + 字段说明通过 SQLAlchemy Column 的 comment 声明。 + """ + + __tablename__ = 'sys_plugin_operation_log' + __table_args__ = {'comment': '插件批量操作审计日志表'} + + operation_id = Column( + BigInteger().with_variant(Integer, 'sqlite'), + primary_key=True, + autoincrement=True, + nullable=False, + comment='操作日志ID', + ) + operation = Column(String(32), nullable=False, comment='操作类型') + plugin_ids = Column(Text, nullable=True, comment='目标插件ID JSON') + dry_run = Column(CHAR(1), nullable=False, server_default='1', comment='是否预演(0是 1否)') + continue_on_error = Column(CHAR(1), nullable=False, server_default='1', comment='失败后是否继续(0是 1否)') + status = Column(String(32), nullable=False, comment='执行状态') + summary = Column(Text, nullable=True, comment='执行汇总JSON') + result = Column(Text, nullable=True, comment='完整执行结果JSON') + create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间') + remark = Column( + String(500), + nullable=True, + server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type), + comment='备注', + ) diff --git a/ruoyi-fastapi-backend/plugins/core/management/entity/vo/schemas.py b/ruoyi-fastapi-backend/plugins/core/management/entity/vo/schemas.py new file mode 100644 index 0000000..e16006e --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/entity/vo/schemas.py @@ -0,0 +1,319 @@ +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from plugins.core.state import PluginStatus +from plugins.core.types import PluginConfigValue + +PluginEnabled = Literal['0', '1'] + + +class PluginModel(BaseModel): + """ + 插件信息表对应 pydantic 模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + plugin_id: str = Field(description='插件ID') + plugin_name: str | None = Field(default=None, description='插件名称') + version: str | None = Field(default=None, description='当前源码版本') + installed_version: str | None = Field(default=None, description='已安装版本') + enabled: PluginEnabled | None = Field(default=None, description='是否启用(0启用 1停用)') + status: PluginStatus | None = Field(default=None, description='插件状态') + source: str | None = Field(default=None, description='插件来源') + backend_path: str | None = Field(default=None, description='后端插件相对路径') + frontend_path: str | None = Field(default=None, description='前端插件相对路径') + last_error: str | None = Field(default=None, description='最近一次错误信息') + description: str | None = Field(default=None, description='插件说明') + create_by: str | None = Field(default=None, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, description='备注') + capability: dict[str, Any] | None = Field(default=None, description='插件运行时操作能力') + metadata: dict[str, Any] | None = Field(default=None, description='插件展示元数据') + backend: dict[str, Any] | None = Field(default=None, description='插件后端声明摘要') + frontend: dict[str, Any] | None = Field(default=None, description='插件前端声明摘要') + permissions: list[dict[str, Any]] | None = Field(default=None, description='插件权限声明') + config: list[dict[str, Any]] | None = Field(default=None, description='插件配置声明') + dependencies: dict[str, Any] | None = Field(default=None, description='插件依赖声明') + plugin_dependencies: list[dict[str, Any]] | None = Field(default=None, description='插件依赖声明') + + +class PluginQueryModel(BaseModel): + """ + 插件管理不分页查询模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + plugin_id: str | None = Field(default=None, description='插件ID') + plugin_name: str | None = Field(default=None, description='插件名称') + enabled: PluginEnabled | None = Field(default=None, description='是否启用(0启用 1停用)') + status: PluginStatus | None = Field(default=None, description='插件状态') + source: str | None = Field(default=None, description='插件来源') + + +class PluginPageQueryModel(PluginQueryModel): + """ + 插件管理分页查询模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class PluginBatchActionModel(BaseModel): + """ + 插件批量执行请求体模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + operation: Literal['install', 'enable', 'upgrade'] = Field(description='批量操作类型:install、enable 或 upgrade') + plugin_ids: list[str] | None = Field(default=None, description='插件ID列表') + dry_run: bool = Field(default=True, description='是否仅预演操作') + continue_on_error: bool = Field(default=False, description='失败后是否继续执行后续插件') + + +class PluginOperationLogModel(BaseModel): + """ + 插件批量操作审计日志表对应 pydantic 模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + operation_id: int | None = Field(default=None, description='操作日志ID') + operation: str = Field(description='操作类型') + plugin_ids: str | None = Field(default=None, description='目标插件ID JSON') + dry_run: PluginEnabled = Field(default='1', description='是否预演(0是 1否)') + continue_on_error: PluginEnabled = Field(default='1', description='失败后是否继续(0是 1否)') + status: str = Field(description='执行状态') + summary: str | None = Field(default=None, description='执行汇总JSON') + result: str | None = Field(default=None, description='完整执行结果JSON') + create_time: datetime | None = Field(default=None, description='创建时间') + remark: str | None = Field(default=None, description='备注') + + +class PluginOperationLogQueryModel(BaseModel): + """ + 插件批量操作审计日志不分页查询模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + plugin_id: str | None = Field(default=None, description='插件ID') + operation: str | None = Field(default=None, description='操作类型') + status: str | None = Field(default=None, description='执行状态') + begin_time: str | None = Field(default=None, description='开始时间') + end_time: str | None = Field(default=None, description='结束时间') + + +class PluginOperationLogPageQueryModel(PluginOperationLogQueryModel): + """ + 插件批量操作审计日志分页查询模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + page_num: int = Field(default=1, description='当前页码') + page_size: int = Field(default=10, description='每页记录数') + + +class PluginOperationLogExportQueryModel(PluginOperationLogQueryModel): + """ + 插件批量操作审计日志导出查询模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + export_limit: int = Field(default=5000, ge=1, le=50000, description='导出最大记录数') + + +class PluginOperationLogDetailModel(BaseModel): + """ + 插件批量操作审计日志详情模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + operation_id: int | None = Field(default=None, description='操作日志ID') + operation: str = Field(description='操作类型') + plugin_ids: list[str] = Field(default_factory=list, description='目标插件ID列表') + dry_run: bool = Field(default=False, description='是否预演') + continue_on_error: bool = Field(default=False, description='失败后是否继续') + status: str = Field(description='执行状态') + summary: dict[str, object] = Field(default_factory=dict, description='执行汇总') + result: dict[str, object] = Field(default_factory=dict, description='完整执行结果') + create_time: datetime | None = Field(default=None, description='创建时间') + remark: str | None = Field(default=None, description='备注') + + +class PluginOperationLogRetentionModel(BaseModel): + """ + 插件批量操作审计日志保留策略模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + retention_days: int = Field(default=180, ge=0, description='审计日志保留天数,0表示清理当前时间之前的全部日志') + dry_run: bool = Field(default=True, description='是否仅预览清理结果') + + +class PluginOperationLogRetentionResultModel(BaseModel): + """ + 插件批量操作审计日志保留策略执行结果模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + retention_days: int = Field(description='审计日志保留天数') + cutoff_time: datetime = Field(description='清理截止时间') + matched_count: int = Field(description='匹配保留策略的日志数量') + deleted_count: int = Field(description='已删除日志数量') + dry_run: bool = Field(description='是否仅预览清理结果') + + +class PluginMenuModel(BaseModel): + """ + 插件和菜单关联表对应 pydantic 模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + plugin_id: str = Field(description='插件ID') + menu_id: int = Field(description='菜单ID') + menu_key: str = Field(description='插件内菜单自然键') + create_time: datetime | None = Field(default=None, description='创建时间') + + +class PluginMigrationModel(BaseModel): + """ + 插件 migration 执行历史表对应 pydantic 模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + plugin_id: str = Field(description='插件ID') + migration_path: str = Field(description='migration 相对路径') + migration_checksum: str = Field(description='migration 内容校验值') + version: str | None = Field(default=None, description='执行时插件版本') + statement_count: int = Field(default=0, description='SQL 语句数量') + status: str = Field(default='success', description='执行状态') + error_message: str | None = Field(default=None, description='失败错误信息') + attempt_count: int = Field(default=0, description='尝试次数') + started_time: datetime | None = Field(default=None, description='最近开始时间') + finished_time: datetime | None = Field(default=None, description='最近结束时间') + create_time: datetime | None = Field(default=None, description='执行时间') + update_time: datetime | None = Field(default=None, description='更新时间') + + +class PluginMigrationRecoveryModel(BaseModel): + """ + 插件 migration 人工恢复请求模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + migration_path: str = Field(description='migration 相对路径') + note: str | None = Field(default=None, description='人工恢复备注') + + +class PluginConfigModel(BaseModel): + """ + 插件配置表对应 pydantic 模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + plugin_id: str = Field(description='插件ID') + config_key: str = Field(description='配置键名') + config_label: str | None = Field(default=None, description='配置展示名称') + config_type: str = Field(default='string', description='配置值类型') + config_value: str | None = Field(default=None, description='配置值') + default_value: str | None = Field(default=None, description='默认配置值') + required: PluginEnabled = Field(default='1', description='是否必填(0是 1否)') + secret: PluginEnabled = Field(default='1', description='是否敏感(0是 1否)') + options: str | None = Field(default=None, description='配置选项JSON') + description: str | None = Field(default=None, description='配置说明') + create_time: datetime | None = Field(default=None, description='创建时间') + update_time: datetime | None = Field(default=None, description='更新时间') + + +class PluginConfigValueModel(BaseModel): + """ + 插件配置值模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + key: str = Field(description='配置键名') + label: str | None = Field(default=None, description='配置展示名称') + type: str = Field(default='string', description='配置值类型') + value: PluginConfigValue = Field(default=None, description='配置值') + default: PluginConfigValue = Field(default=None, description='默认配置值') + required: bool = Field(default=False, description='是否必填') + secret: bool = Field(default=False, description='是否敏感') + group: str = Field(default='default', description='配置分组') + order: int = Field(default=0, description='配置排序值') + placeholder: str = Field(default='', description='配置输入占位提示') + min: float | None = Field(default=None, description='数字配置最小值') + max: float | None = Field(default=None, description='数字配置最大值') + pattern: str | None = Field(default=None, description='字符串配置正则表达式') + options: list[dict[str, PluginConfigValue]] = Field(default_factory=list, description='配置选项列表') + description: str | None = Field(default=None, description='配置说明') + + +class PluginConfigUpdateModel(BaseModel): + """ + 插件配置更新模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + values: dict[str, PluginConfigValue] = Field(default_factory=dict, description='待更新的插件配置键值') + + +class PluginConfigImportModel(BaseModel): + """ + 插件配置导入模型。 + + 模型字段说明通过 Field 的 description 声明。 + """ + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True) + + values: dict[str, PluginConfigValue] = Field(default_factory=dict, description='待导入的插件配置键值') diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/config.py b/ruoyi-fastapi-backend/plugins/core/management/service/config.py new file mode 100644 index 0000000..65f33c8 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/service/config.py @@ -0,0 +1,254 @@ +import json +import re +from typing import cast + +from plugins.core.management.entity.vo.schemas import PluginConfigModel, PluginConfigValueModel +from plugins.core.manifest.schema import PluginConfigItemManifest +from plugins.core.types import PluginConfigValue +from utils.crypto_util import CryptoUtil +from utils.log_util import logger + + +class PluginConfigManager: + """ + 插件配置管理器。 + + 使用 Manager 模式集中处理配置声明、配置值序列化、默认值安装和脱敏输出。 + """ + + MASK_VALUE = '******' + ENCRYPTED_PREFIX = 'enc:v1:' + + @classmethod + def build_config_model(cls, plugin_id: str, item: PluginConfigItemManifest) -> PluginConfigModel: + """ + 根据 manifest 配置项构建数据库配置模型。 + + :param plugin_id: 插件ID + :param item: 插件配置项声明 + :return: 插件配置数据库模型 + """ + serialized_default = cls.serialize_config_value(item.default, secret=item.secret) + + return PluginConfigModel( + pluginId=plugin_id, + configKey=item.key, + configLabel=item.label, + configType=item.type, + configValue=serialized_default, + defaultValue=serialized_default, + required='0' if item.required else '1', + secret='0' if item.secret else '1', + options=cls.serialize_value([option.model_dump() for option in item.options]), + description=item.description, + ) + + @classmethod + def build_config_value( + cls, + config: object, + manifest_item: PluginConfigItemManifest | None = None, + *, + reveal_secret: bool = False, + ) -> PluginConfigValueModel: + """ + 构建面向插件管理接口和运行时负载的插件配置值。 + + :param config: 数据库配置对象 + :param manifest_item: manifest 配置项声明 + :param reveal_secret: 是否展示敏感配置原值 + :return: 插件配置值模型 + """ + secret = getattr(config, 'secret', '1') == '0' + raw_value = getattr(config, 'config_value', None) + config_type = getattr(config, 'config_type', 'string') + value = cls.deserialize_config_value(raw_value, config_type, secret=secret) + if secret and not reveal_secret and value not in (None, ''): + value = cls.MASK_VALUE + default = cls.deserialize_config_value(getattr(config, 'default_value', None), config_type, secret=secret) + if secret and not reveal_secret and default not in (None, ''): + default = cls.MASK_VALUE + config_key = config.config_key + + return PluginConfigValueModel( + key=config_key, + label=getattr(config, 'config_label', None) or getattr(manifest_item, 'label', None), + type=getattr(config, 'config_type', None) or getattr(manifest_item, 'type', 'string'), + value=value, + default=default, + required=getattr(config, 'required', '1') == '0', + secret=secret, + group=getattr(manifest_item, 'group', 'default'), + order=getattr(manifest_item, 'order', 0), + placeholder=getattr(manifest_item, 'placeholder', ''), + min=getattr(manifest_item, 'min_value', None), + max=getattr(manifest_item, 'max_value', None), + pattern=getattr(manifest_item, 'pattern', None), + options=cls.deserialize_options(getattr(config, 'options', None)), + description=getattr(config, 'description', None) or getattr(manifest_item, 'description', None), + ) + + @classmethod + def migrate_config_secret_storage( + cls, + config: object, + item: PluginConfigItemManifest, + ) -> str | None: + """ + 在 manifest 修改 secret 属性时迁移已有配置值的存储格式。 + + :param config: 已有数据库配置对象 + :param item: 当前 manifest 配置声明 + :return: 使用新 secret 策略序列化后的配置值 + """ + old_secret = getattr(config, 'secret', '1') == '0' + if old_secret == item.secret: + return getattr(config, 'config_value', None) + + current_value = cls.deserialize_config_value( + getattr(config, 'config_value', None), + getattr(config, 'config_type', 'string'), + secret=old_secret, + ) + return cls.serialize_config_value(current_value, secret=item.secret) + + @classmethod + def serialize_value(cls, value: PluginConfigValue) -> str | None: + """ + 序列化插件配置值。 + + :param value: 原始配置值 + :return: 字符串配置值 + """ + if value is None: + return None + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False) + + @classmethod + def serialize_config_value(cls, value: PluginConfigValue, *, secret: bool = False) -> str | None: + """ + 序列化插件配置值,敏感值加密落库。 + + :param value: 原始配置值 + :param secret: 是否敏感配置 + :return: 可落库字符串 + """ + serialized_value = cls.serialize_value(value) + if not secret or serialized_value in (None, ''): + return serialized_value + + return f'{cls.ENCRYPTED_PREFIX}{CryptoUtil.encrypt(serialized_value)}' + + @classmethod + def deserialize_value(cls, value: str | None, config_type: str = 'string') -> PluginConfigValue: + """ + 反序列化插件配置值。 + + :param value: 字符串配置值 + :param config_type: 配置值类型 + :return: 反序列化后的配置值 + """ + if value is None: + return None + if config_type == 'boolean': + return str(value).lower() in {'1', 'true', 'yes', 'on'} + if config_type == 'number': + try: + return int(value) if float(value).is_integer() else float(value) + except ValueError: + return value + if config_type == 'json': + try: + return cast('PluginConfigValue', json.loads(value)) + except json.JSONDecodeError: + return value + return value + + @classmethod + def deserialize_config_value( + cls, + value: str | None, + config_type: str = 'string', + *, + secret: bool = False, + ) -> PluginConfigValue: + """ + 反序列化插件配置值,敏感配置必须是平台加密格式。 + + :param value: 数据库存储值 + :param config_type: 配置类型 + :param secret: 是否敏感配置 + :return: 反序列化后的配置值 + """ + raw_value = value + if secret and raw_value not in (None, ''): + if not isinstance(raw_value, str) or not raw_value.startswith(cls.ENCRYPTED_PREFIX): + raise ValueError('敏感插件配置不是加密存储格式') + raw_value = CryptoUtil.decrypt(raw_value.removeprefix(cls.ENCRYPTED_PREFIX)) + + return cls.deserialize_value(raw_value, config_type) + + @classmethod + def deserialize_options(cls, value: str | None) -> list[dict[str, PluginConfigValue]]: + """ + 反序列化配置选项。 + + :param value: 配置选项 JSON 字符串 + :return: 配置选项列表 + """ + if not value: + return [] + try: + options = json.loads(value) + except json.JSONDecodeError as exc: + logger.warning(f'⚠️ 插件配置选项 JSON 解析失败:{exc}') + return [{'parseError': '配置选项 JSON 解析失败'}] + if not isinstance(options, list): + logger.warning('⚠️ 插件配置选项 JSON 内容不是数组') + return [{'parseError': '配置选项 JSON 内容不是数组'}] + return cast('list[dict[str, PluginConfigValue]]', options) + + @classmethod + def validate_update_value(cls, item: PluginConfigItemManifest, value: PluginConfigValue) -> None: + """ + 校验配置更新值。 + + :param item: 插件配置项声明 + :param value: 待更新配置值 + :return: None + """ + if item.required and value in (None, ''): + raise ValueError(f'配置 {item.key} 不能为空') + if item.type == 'boolean' and not isinstance(value, bool): + raise ValueError(f'配置 {item.key} 必须是布尔值') + if item.type == 'number' and (not isinstance(value, int | float) or isinstance(value, bool)): + raise ValueError(f'配置 {item.key} 必须是数字') + if item.type == 'select' and item.options: + allowed_values = [option.value for option in item.options] + if value not in allowed_values: + raise ValueError(f'配置 {item.key} 不在允许的选项范围内') + if item.type == 'number': + cls._validate_number_range(item, value) + if ( + item.pattern + and item.type in {'string', 'textarea', 'password'} + and value not in (None, '') + and not re.fullmatch(item.pattern, str(value)) + ): + raise ValueError(f'配置 {item.key} 不匹配正则约束') + + @classmethod + def _validate_number_range(cls, item: PluginConfigItemManifest, value: int | float) -> None: + """ + 校验数字配置值范围。 + + :param item: 插件配置项声明 + :param value: 待更新配置值 + :return: None + """ + if item.min_value is not None and value < item.min_value: + raise ValueError(f'配置 {item.key} 不能小于 {item.min_value}') + if item.max_value is not None and value > item.max_value: + raise ValueError(f'配置 {item.key} 不能大于 {item.max_value}') diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py b/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py new file mode 100644 index 0000000..a3d3028 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/service/gateway.py @@ -0,0 +1,631 @@ +import subprocess +from collections.abc import Mapping +from importlib import import_module +from typing import TYPE_CHECKING + +from plugins.core.lifecycle.migration import PluginMigrationRunner +from plugins.core.runtime.service.gateway import ( + AsyncSessionFactoryProtocol, + PluginCommandOutputCallback, + PluginManagementServiceProtocol, + run_plugin_command, +) +from plugins.core.runtime.service.migration_store import PluginDatabaseMigrationHistoryStore +from plugins.core.types import PluginConfigValue, PluginStateRecord + +if TYPE_CHECKING: + from plugins.core.management.entity.vo.schemas import ( + PluginConfigUpdateModel, + PluginConfigValueModel, + PluginMigrationModel, + PluginOperationLogDetailModel, + PluginOperationLogExportQueryModel, + ) + + +class PluginManagementLifecycleUnitOfWork: + """ + 插件管理生命周期主事务工作单元。 + """ + + def __init__( + self, + async_session_local: AsyncSessionFactoryProtocol, + plugin_service: type[PluginManagementServiceProtocol], + ) -> None: + """ + 初始化生命周期主事务工作单元。 + + :param async_session_local: 异步数据库会话工厂 + :param plugin_service: 插件管理服务类 + :return: None + """ + self.async_session_local = async_session_local + self.plugin_service = plugin_service + self.session_context: object | None = None + self.session: object | None = None + + async def __aenter__(self) -> 'PluginManagementLifecycleUnitOfWork': + """ + 打开生命周期主事务会话。 + + :return: 生命周期主事务工作单元 + """ + self.session_context = self.async_session_local() + self.session = await self.session_context.__aenter__() + return self + + async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None: + """ + 关闭生命周期主事务会话。 + + :param exc_type: 异常类型 + :param exc: 异常对象 + :param traceback: 异常堆栈 + :return: None + """ + if self.session_context is None: + return + await self.session_context.__aexit__(exc_type, exc, traceback) + self.session_context = None + self.session = None + + async def check_installed_menu_conflicts(self, discovered_plugin: object) -> list[object]: + """ + 检查已安装菜单冲突。 + + :param discovered_plugin: 已发现插件 + :return: 菜单冲突列表 + """ + return await self.plugin_service.check_installed_menu_conflict_services(self.session, discovered_plugin) + + async def upsert_discovered_plugin( + self, + discovered_plugin: object, + backend_root: object, + frontend_root: object | None = None, + ) -> object: + """ + 写入或更新已发现插件。 + + :param discovered_plugin: 已发现插件 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :return: 插件模型 + """ + return await self.plugin_service.upsert_discovered_plugin_services( + self.session, + discovered_plugin, + backend_root, + frontend_root, + ) + + async def install_plugin_menu(self, discovered_plugin: object, *, enabled: bool) -> None: + """ + 安装插件菜单。 + + :param discovered_plugin: 已发现插件 + :param enabled: 是否启用菜单 + :return: None + """ + await self.plugin_service.install_plugin_menu_services(self.session, discovered_plugin, enabled=enabled) + + async def install_plugin_default_config(self, discovered_plugin: object) -> list[object]: + """ + 安装插件默认配置。 + + :param discovered_plugin: 已发现插件 + :return: 插件配置列表 + """ + return await self.plugin_service.install_plugin_default_config_services(self.session, discovered_plugin) + + async def install_plugin_jobs(self, discovered_plugin: object, *, enabled: bool) -> None: + """ + 同步单个插件任务。 + + :param discovered_plugin: 已发现插件 + :param enabled: 插件任务是否启用 + :return: None + """ + await self.plugin_service.install_plugin_job_services( + self.session, + discovered_plugin, + enabled=enabled, + ) + + async def mark_plugin_installed(self, discovered_plugin: object) -> object: + """ + 标记插件已安装。 + + :param discovered_plugin: 已发现插件 + :return: 插件模型 + """ + return await self.plugin_service.mark_plugin_installed_services(self.session, discovered_plugin) + + async def build_plugin_purge_plan(self, discovered_plugin: object) -> object: + """ + 构建插件物理清理计划。 + + :param discovered_plugin: 已发现插件 + :return: 插件物理清理计划 + """ + return await self.plugin_service.build_plugin_purge_plan_services(self.session, discovered_plugin) + + async def purge_plugin_metadata(self, discovered_plugin: object) -> object: + """ + 清理插件平台元数据。 + + :param discovered_plugin: 已发现插件 + :return: 插件物理清理计划 + """ + return await self.plugin_service.purge_plugin_services(self.session, discovered_plugin) + + async def build_plugin_purge_plan_by_id(self, plugin_id: str) -> object: + """ + 按插件 ID 构建孤儿元数据清理计划。 + + :param plugin_id: 插件ID + :return: 插件物理清理计划 + """ + return await self.plugin_service.build_plugin_purge_plan_by_id_services(self.session, plugin_id) + + async def purge_plugin_metadata_by_id(self, plugin_id: str) -> object: + """ + 按插件 ID 清理孤儿元数据。 + + :param plugin_id: 插件ID + :return: 插件物理清理计划 + """ + return await self.plugin_service.purge_plugin_metadata_by_id_services(self.session, plugin_id) + + async def commit(self) -> None: + """ + 提交生命周期主事务。 + + :return: None + """ + await self.session.commit() + + +class PluginManagementRuntimeGateway: + """ + 插件管理状态运行时基础设施适配器。 + + 该对象负责将插件运行时端口适配到平台管理状态能力,包括数据库会话、 + 插件管理服务、VO 构造和系统命令执行;可被 Web 管理入口和 CLI 共同复用。 + """ + + @staticmethod + def get_async_session_local() -> AsyncSessionFactoryProtocol: + """ + 获取异步数据库会话工厂。 + + :return: 异步数据库会话工厂 + """ + return import_module('config.database').AsyncSessionLocal + + @staticmethod + def get_plugin_service() -> type[PluginManagementServiceProtocol]: + """ + 获取插件服务类。 + + :return: 插件服务类 + """ + return import_module('plugins.core.management.service.service').PluginService + + def open_lifecycle_unit_of_work(self) -> PluginManagementLifecycleUnitOfWork: + """ + 打开插件生命周期主事务工作单元。 + + :return: 插件生命周期主事务工作单元 + """ + return PluginManagementLifecycleUnitOfWork(self.get_async_session_local(), self.get_plugin_service()) + + async def list_plugin_states(self) -> list[PluginStateRecord]: + """ + 获取插件状态列表。 + + :return: 插件状态列表 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + return await plugin_service.get_plugin_list_services(session) + + async def get_plugin_state(self, plugin_id: str) -> PluginStateRecord | None: + """ + 获取插件状态。 + + :param plugin_id: 插件ID + :return: 插件状态 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + return await plugin_service.plugin_detail_services(session, plugin_id) + + @staticmethod + def build_operation_log_export_query(export_limit: int) -> 'PluginOperationLogExportQueryModel': + """ + 构建插件操作日志导出查询对象。 + + :param export_limit: 导出数量上限 + :return: 插件操作日志导出查询对象 + """ + plugin_vo = import_module('plugins.core.management.entity.vo.schemas') + return plugin_vo.PluginOperationLogExportQueryModel(exportLimit=export_limit) + + @staticmethod + def build_config_update(values: dict[str, PluginConfigValue]) -> 'PluginConfigUpdateModel': + """ + 构建插件配置更新对象。 + + :param values: 配置键值 + :return: 插件配置更新对象 + """ + plugin_vo = import_module('plugins.core.management.entity.vo.schemas') + return plugin_vo.PluginConfigUpdateModel(values=values) + + async def get_plugin_config( + self, + discovered_plugin: object, + *, + reveal_secret: bool = False, + ) -> list['PluginConfigValueModel']: + """ + 获取插件配置。 + + :param discovered_plugin: 已发现插件 + :param reveal_secret: 是否展示敏感配置原值 + :return: 插件配置列表 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + configs = await plugin_service.get_plugin_config_services( + session, + discovered_plugin, + reveal_secret=reveal_secret, + ) + return configs + + async def update_plugin_config( + self, + discovered_plugin: object, + values: dict[str, PluginConfigValue], + ) -> list['PluginConfigValueModel']: + """ + 更新插件配置。 + + :param discovered_plugin: 已发现插件 + :param values: 配置键值 + :return: 插件配置列表 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + await self._ensure_plugin_installed(session, plugin_service, discovered_plugin.manifest.id) + configs = await plugin_service.update_plugin_config_services( + session, + discovered_plugin, + self.build_config_update(values), + ) + await session.commit() + return configs + + async def set_plugin_config( + self, + discovered_plugin: object, + values: dict[str, PluginConfigValue], + *, + audit_operation: str, + success_message: str, + ) -> list['PluginConfigValueModel']: + """ + 在同一事务中更新插件配置并记录审计日志。 + + :param discovered_plugin: 已发现插件 + :param values: 配置键值 + :param audit_operation: 审计操作类型 + :param success_message: 操作成功提示 + :return: 插件配置列表 + """ + from plugins.core.runtime.support import PluginConfigPayloadBuilder # noqa: PLC0415 + + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + await self._ensure_plugin_installed(session, plugin_service, discovered_plugin.manifest.id) + before_configs = await plugin_service.get_plugin_config_services( + session, + discovered_plugin, + reveal_secret=True, + ) + configs = await plugin_service.update_plugin_config_services( + session, + discovered_plugin, + self.build_config_update(values), + ) + audit_payload = PluginConfigPayloadBuilder.build_audit_payload( + discovered_plugin.manifest.id, + operation=audit_operation, + values=values, + before_configs=before_configs, + after_configs=configs, + message=success_message, + ) + await plugin_service.add_plugin_operation_log_services( + session, + audit_payload, + dry_run=False, + continue_on_error=False, + ) + await session.commit() + return configs + + @staticmethod + async def _ensure_plugin_installed( + session: object, + plugin_service: type[PluginManagementServiceProtocol], + plugin_id: str, + ) -> None: + """ + 拒绝为尚未安装的插件创建或更新持久化配置。 + + :param session: 数据库会话 + :param plugin_service: 插件管理服务类 + :param plugin_id: 插件ID + :return: None + :raises ValueError: 插件尚未安装 + """ + if not await plugin_service.is_plugin_installed_services(session, plugin_id): + raise ValueError(f'插件尚未安装,不能修改配置:{plugin_id}') + + async def add_plugin_operation_log( + self, + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> None: + """ + 记录插件操作审计日志。 + + :param payload: 操作日志负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续 + :return: None + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + await plugin_service.add_plugin_operation_log_services( + session, + dict(payload), + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + await session.commit() + + async def list_plugin_operation_logs(self, *, export_limit: int) -> list['PluginOperationLogDetailModel']: + """ + 获取插件操作审计日志列表。 + + :param export_limit: 导出数量上限 + :return: 插件操作日志详情列表 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + return await plugin_service.get_plugin_operation_log_export_list_services( + session, + self.build_operation_log_export_query(export_limit), + ) + + async def mark_plugin_error(self, plugin_id: str, error_message: str) -> bool: + """ + 标记插件错误状态。 + + :param plugin_id: 插件ID + :param error_message: 错误信息 + :return: 是否标记成功 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + result = await plugin_service.mark_plugin_error_services(session, plugin_id, error_message) + if getattr(result, 'is_success', False): + await session.commit() + return True + return False + + async def list_plugin_migrations( + self, + plugin_id: str, + status: str | None = None, + ) -> list['PluginMigrationModel']: + """ + 查询插件 migration 历史。 + + :param plugin_id: 插件ID + :param status: 执行状态 + :return: 插件 migration 历史列表 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + return await plugin_service.get_plugin_migration_list_services(session, plugin_id, status) + + async def get_plugin_migration(self, plugin_id: str, migration_path: str) -> 'PluginMigrationModel | None': + """ + 获取插件 migration 历史。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: 插件 migration 历史 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + return await plugin_service.get_plugin_migration_services(session, plugin_id, migration_path) + + async def mark_plugin_migration_status( + self, + plugin_id: str, + migration_path: str, + status: str, + error_message: str | None = None, + ) -> 'PluginMigrationModel | None': + """ + 人工标记插件 migration 历史状态。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param status: 执行状态 + :param error_message: 失败错误信息 + :return: 插件 migration 历史 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + migration = await plugin_service.mark_plugin_migration_status_services( + session, + plugin_id, + migration_path, + status, + error_message, + ) + if migration: + await session.commit() + return migration + + async def build_plugin_purge_plan(self, discovered_plugin: object) -> object: + """ + 构建插件物理清理计划。 + + :param discovered_plugin: 已发现插件 + :return: 插件物理清理计划 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + return await plugin_service.build_plugin_purge_plan_services(session, discovered_plugin) + + async def set_plugin_enabled_state( + self, + plugin_id: str, + enabled: bool, + discovered_plugin: object | None = None, + ) -> object: + """ + 更新插件启停状态,并在启用时同步插件菜单。 + + :param plugin_id: 插件ID + :param enabled: 是否启用 + :param discovered_plugin: 已发现插件 + :return: 操作响应 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + response = await plugin_service.update_plugin_enabled_services( + session, + plugin_id, + enabled, + discovered_plugin, + ) + if getattr(response, 'is_success', False): + if enabled and discovered_plugin is not None: + await plugin_service.install_plugin_menu_services(session, discovered_plugin, enabled=True) + await session.commit() + return response + + async def mark_plugin_uninstalled_state(self, plugin_id: str) -> object: + """ + 标记插件安全卸载。 + + :param plugin_id: 插件ID + :return: 操作响应 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as session: + response = await plugin_service.mark_plugin_uninstalled_services(session, plugin_id) + if getattr(response, 'is_success', False): + await session.commit() + return response + + async def run_plugin_migrations(self, discovered_plugin: object) -> object: + """ + 使用独立执行事务运行插件 migration。 + + :param discovered_plugin: 已发现插件 + :return: migration 执行结果列表 + """ + async_session_local = self.get_async_session_local() + plugin_service = self.get_plugin_service() + async with async_session_local() as migration_session: + return await PluginMigrationRunner( + discovered_plugin, + PluginDatabaseMigrationHistoryStore.with_model_gateway( + plugin_service, + self, + async_session_local, + ), + manage_execution_transaction=True, + ).run(migration_session) + + @staticmethod + def build_migration_record( + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + status: str = 'success', + error_message: str | None = None, + ) -> 'PluginMigrationModel': + """ + 构建插件 migration 执行历史对象。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: 插件 migration 执行历史对象 + """ + plugin_vo = import_module('plugins.core.management.entity.vo.schemas') + return plugin_vo.PluginMigrationModel( + pluginId=plugin_id, + migrationPath=migration_path, + migrationChecksum=checksum, + version=version, + statementCount=statement_count, + status=status, + errorMessage=error_message, + ) + + @staticmethod + def run_command( + command: list[str], + workdir: str, + *, + timeout: int | None = None, + output_callback: PluginCommandOutputCallback | None = None, + ) -> subprocess.CompletedProcess[str]: + """ + 执行系统命令。 + + :param command: 命令参数列表 + :param workdir: 命令工作目录 + :param timeout: 命令超时时间 + :param output_callback: 实时输出回调 + :return: 命令执行结果 + """ + return run_plugin_command( + command, + workdir, + timeout=timeout, + output_callback=output_callback, + ) diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/logs.py b/ruoyi-fastapi-backend/plugins/core/management/service/logs.py new file mode 100644 index 0000000..d00f1fe --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/service/logs.py @@ -0,0 +1,171 @@ +import json +from collections.abc import Mapping +from typing import cast + +from plugins.core.management.entity.vo.schemas import PluginOperationLogDetailModel, PluginOperationLogModel +from utils.log_util import logger + + +class PluginOperationLogBuilder: + """ + 插件操作审计日志构建器。 + + 使用 Builder 模式集中处理操作结果 payload 与审计日志模型、详情模型和导出行之间的转换。 + """ + + @staticmethod + def build_export_row( + operation_log: PluginOperationLogDetailModel, + operation_dict: dict[str, str] | None = None, + ) -> dict[str, object]: + """ + 构建插件批量操作审计日志导出行。 + + :param operation_log: 插件批量操作审计日志详情 + :param operation_dict: 插件操作类型字典 + :return: 插件批量操作审计日志导出行 + """ + operation_dict = operation_dict or {} + + return { + 'operationId': operation_log.operation_id, + 'operation': operation_dict.get(operation_log.operation, operation_log.operation), + 'pluginIds': ','.join(operation_log.plugin_ids), + 'dryRun': '是' if operation_log.dry_run else '否', + 'continueOnError': '是' if operation_log.continue_on_error else '否', + 'status': operation_log.status, + 'summary': json.dumps(operation_log.summary, ensure_ascii=False), + 'remark': operation_log.remark, + 'createTime': operation_log.create_time, + } + + @classmethod + def build_detail(cls, operation_log: Mapping[str, object]) -> PluginOperationLogDetailModel: + """ + 构建插件批量操作审计日志详情。 + + :param operation_log: 插件批量操作审计日志字典 + :return: 插件批量操作审计日志详情 + """ + return PluginOperationLogDetailModel( + operationId=operation_log.get('operationId'), + operation=operation_log.get('operation') or '-', + pluginIds=cls.deserialize_json_list(operation_log.get('pluginIds')), + dryRun=operation_log.get('dryRun') == '0', + continueOnError=operation_log.get('continueOnError') == '0', + status=operation_log.get('status') or '-', + summary=cls.deserialize_json_dict(operation_log.get('summary')), + result=cls.deserialize_json_dict(operation_log.get('result')), + createTime=operation_log.get('createTime'), + remark=operation_log.get('remark'), + ) + + @staticmethod + def deserialize_json_dict(value: object) -> dict[str, object]: + """ + 反序列化 JSON 字典。 + + :param value: JSON 字符串 + :return: 字典对象 + """ + if not isinstance(value, str) or not value: + return {} + try: + result = json.loads(value) + except json.JSONDecodeError as exc: + logger.warning(f'插件操作审计日志 JSON 字典解析失败:{exc}') + return {'parseError': 'JSON 解析失败'} + + if not isinstance(result, dict): + logger.warning('插件操作审计日志 JSON 内容不是对象') + return {'parseError': 'JSON 内容不是对象'} + + return cast('dict[str, object]', result) + + @staticmethod + def deserialize_json_list(value: object) -> list[str]: + """ + 反序列化 JSON 字符串列表。 + + :param value: JSON 字符串 + :return: 字符串列表 + """ + if not isinstance(value, str) or not value: + return [] + try: + result = json.loads(value) + except json.JSONDecodeError: + return [] + + return [str(item) for item in result] if isinstance(result, list) else [] + + @classmethod + def build_model( + cls, + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> PluginOperationLogModel: + """ + 根据插件操作结果构建审计日志模型。 + + :param payload: 插件操作结果负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: 插件操作审计日志模型 + """ + summary_value = payload.get('summary') + summary = cast('dict[str, object]', summary_value) if isinstance(summary_value, dict) else {} + plugin_ids = cls.resolve_plugin_ids(payload) + + return PluginOperationLogModel( + operation=str(payload.get('operation', 'unknown')), + pluginIds=json.dumps(plugin_ids, ensure_ascii=False), + dryRun='0' if dry_run else '1', + continueOnError='0' if continue_on_error else '1', + status=cls.resolve_status(payload), + summary=json.dumps(summary, ensure_ascii=False), + result=json.dumps(dict(payload), ensure_ascii=False, default=str), + remark=str(payload.get('message', ''))[:500] or None, + ) + + @staticmethod + def resolve_plugin_ids(payload: Mapping[str, object]) -> list[str]: + """ + 解析插件操作审计日志的目标插件 ID。 + + :param payload: 插件操作结果负载 + :return: 目标插件 ID 列表 + """ + plan_value = payload.get('plan') + plan = cast('dict[str, object]', plan_value) if isinstance(plan_value, dict) else {} + ordered_plugin_ids = plan.get('orderedPluginIds') if isinstance(plan.get('orderedPluginIds'), list) else [] + if ordered_plugin_ids: + return [str(plugin_id) for plugin_id in ordered_plugin_ids] + plugin_id = payload.get('pluginId') + if plugin_id: + return [str(plugin_id)] + + return [] + + @staticmethod + def resolve_status(payload: Mapping[str, object]) -> str: + """ + 解析插件操作审计状态。 + + :param payload: 插件操作结果负载 + :return: 审计状态 + """ + if payload.get('dryRun'): + return 'dry_run' + plan_value = payload.get('plan') + plan = cast('dict[str, object]', plan_value) if isinstance(plan_value, dict) else {} + if plan.get('blockerCount', 0): + return 'blocked' + summary_value = payload.get('summary') + summary = cast('dict[str, object]', summary_value) if isinstance(summary_value, dict) else {} + if summary.get('failed', 0): + return 'failed' + + return 'success' if payload.get('ok', False) else 'failed' diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/menus.py b/ruoyi-fastapi-backend/plugins/core/management/service/menus.py new file mode 100644 index 0000000..aeb3cf5 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/service/menus.py @@ -0,0 +1,371 @@ +from datetime import datetime + +from sqlalchemy.ext.asyncio import AsyncSession + +from module_admin.entity.do.menu_do import SysMenu +from module_admin.entity.vo.menu_vo import MenuModel +from plugins.core.management.dao.dao import PluginDao +from plugins.core.management.entity.vo.schemas import PluginMenuModel +from plugins.core.manifest.menu_key import InstalledPluginMenu, PluginMenuKeyBuilder +from plugins.core.manifest.schema import PluginManifest, PluginMenuManifest, PluginPermissionManifest +from utils.log_util import logger + +ROOT_MENU_ID = 0 +AUTO_BUTTON_ORDER_START = 1000 +PERMISSION_PREFIX_PART_COUNT = 2 +PERMISSION_BUTTON_LABELS = { + 'add': '新增', + 'edit': '修改', + 'remove': '删除', + 'delete': '删除', + 'query': '查询', + 'export': '导出', + 'import': '导入', + 'list': '列表', + 'view': '查看', +} + + +class PluginMenuInstaller: + """ + 插件管理模块菜单安装器。 + + 使用 Service Object 模式封装插件菜单幂等写入、状态切换和关联维护逻辑。 + """ + + def __init__(self, query_db: AsyncSession) -> None: + """ + 初始化插件菜单安装器。 + + :param query_db: orm对象 + :return: None + """ + self.query_db = query_db + + async def install_manifest_menus(self, manifest: PluginManifest) -> list[InstalledPluginMenu]: + """ + 安装插件清单中的菜单。 + + :param manifest: 插件清单 + :return: 已安装插件菜单快照列表 + """ + installed_menus = [] + seen_menu_keys = set() + for menu in manifest.frontend.menus: + installed_menus.extend( + await self._install_menu_tree( + plugin_id=manifest.id, + menu=menu, + parent_id=ROOT_MENU_ID, + parent_key=manifest.id, + seen_menu_keys=seen_menu_keys, + ) + ) + installed_menus.extend( + await self._install_permission_button_menus( + manifest, + installed_menus, + seen_menu_keys, + ) + ) + await self._remove_stale_menus(manifest.id, seen_menu_keys) + + return installed_menus + + async def set_plugin_menu_status(self, plugin_id: str, status: str) -> None: + """ + 更新插件菜单状态。 + + :param plugin_id: 插件ID + :param status: 菜单状态(0正常 1停用) + :return: None + """ + plugin_menus = await PluginDao.get_plugin_menu_list(self.query_db, plugin_id) + menu_ids = [plugin_menu.menu_id for plugin_menu in plugin_menus] + await PluginDao.update_sys_menu_status_by_ids(self.query_db, menu_ids, status) + + async def _install_menu_tree( + self, + plugin_id: str, + menu: PluginMenuManifest, + parent_id: int, + parent_key: str, + seen_menu_keys: set[str], + ) -> list[InstalledPluginMenu]: + """ + 递归安装插件菜单树。 + + :param plugin_id: 插件ID + :param menu: 插件菜单声明 + :param parent_id: 父菜单ID + :param parent_key: 父级菜单自然键 + :param seen_menu_keys: 当前插件已处理自然键集合 + :return: 已安装插件菜单快照列表 + """ + menu_key = PluginMenuKeyBuilder.build(menu, parent_key) + if menu_key in seen_menu_keys: + raise ValueError(f'插件 {plugin_id} 存在重复菜单自然键:{menu_key}') + seen_menu_keys.add(menu_key) + + sys_menu = await self._upsert_menu(plugin_id, menu, menu_key, parent_id) + installed_menu = InstalledPluginMenu(menu_id=sys_menu.menu_id, menu_key=menu_key, manifest_menu=menu) + installed_menus = [installed_menu] + + for child_menu in menu.children: + installed_menus.extend( + await self._install_menu_tree( + plugin_id=plugin_id, + menu=child_menu, + parent_id=sys_menu.menu_id, + parent_key=menu_key, + seen_menu_keys=seen_menu_keys, + ) + ) + + return installed_menus + + async def _install_permission_button_menus( + self, + manifest: PluginManifest, + installed_menus: list[InstalledPluginMenu], + seen_menu_keys: set[str], + ) -> list[InstalledPluginMenu]: + """ + 为未显式声明为菜单的权限生成按钮菜单。 + + :param manifest: 插件清单 + :param installed_menus: 已安装菜单快照列表 + :param seen_menu_keys: 当前插件已处理自然键集合 + :return: 自动生成的按钮菜单快照列表 + """ + declared_menu_permissions = {installed_menu.manifest_menu.perms for installed_menu in installed_menus} + missing_permissions = [ + permission_manifest + for permission_manifest in manifest.permissions + if permission_manifest.code and permission_manifest.code not in declared_menu_permissions + ] + if not missing_permissions: + return [] + + auto_buttons = [] + for index, permission in enumerate(missing_permissions, start=1): + parent_menu = self._find_permission_parent_menu(permission.code, installed_menus) + if not parent_menu: + continue + button_menu = self._build_permission_button_menu(permission, index) + auto_buttons.extend( + await self._install_menu_tree( + plugin_id=manifest.id, + menu=button_menu, + parent_id=parent_menu.menu_id, + parent_key=parent_menu.menu_key, + seen_menu_keys=seen_menu_keys, + ) + ) + + return auto_buttons + + @classmethod + def _find_permission_parent_menu( + cls, + permission: str, + installed_menus: list[InstalledPluginMenu], + ) -> InstalledPluginMenu | None: + """ + 查找权限按钮应挂载的页面菜单。 + + :param permission: 权限标识 + :param installed_menus: 已安装菜单快照列表 + :return: 父级菜单快照 + """ + candidate_menus = [ + installed_menu for installed_menu in installed_menus if installed_menu.manifest_menu.type != 'F' + ] + if not candidate_menus: + return None + + permission_prefix = cls._permission_prefix(permission) + for installed_menu in candidate_menus: + menu_permission = installed_menu.manifest_menu.perms + if menu_permission and cls._permission_prefix(menu_permission) == permission_prefix: + return installed_menu + + return candidate_menus[-1] + + @staticmethod + def _permission_prefix(permission: str) -> str: + """ + 提取权限前缀。 + + :param permission: 权限标识 + :return: 权限前缀 + """ + parts = permission.split(':', maxsplit=PERMISSION_PREFIX_PART_COUNT) + return ( + ':'.join(parts[:PERMISSION_PREFIX_PART_COUNT]) if len(parts) >= PERMISSION_PREFIX_PART_COUNT else permission + ) + + @staticmethod + def _build_permission_button_menu(permission: PluginPermissionManifest, order_index: int) -> PluginMenuManifest: + """ + 构建权限按钮菜单声明。 + + :param permission: 权限声明 + :param order_index: 自动按钮排序序号 + :return: 按钮菜单声明 + """ + action = permission.code.rsplit(':', maxsplit=1)[-1] + return PluginMenuManifest( + name=permission.name or PERMISSION_BUTTON_LABELS.get(action, action), + path=action, + component='', + perms=permission.code, + type='F', + orderNum=AUTO_BUTTON_ORDER_START + order_index, + icon='#', + ) + + async def _upsert_menu( + self, + plugin_id: str, + menu: PluginMenuManifest, + menu_key: str, + parent_id: int, + ) -> SysMenu: + """ + 写入或更新单个插件菜单。 + + :param plugin_id: 插件ID + :param menu: 插件菜单声明 + :param menu_key: 插件菜单自然键 + :param parent_id: 父菜单ID + :return: 系统菜单对象 + """ + sys_menu = await self._find_existing_menu(plugin_id, menu_key) + menu_model = self._build_menu_model( + plugin_id, + menu, + parent_id, + sys_menu.menu_id if sys_menu else None, + sys_menu.remark if sys_menu else None, + ) + + if sys_menu: + await PluginDao.update_sys_menu(self.query_db, menu_model.model_dump(exclude_unset=True)) + if not await PluginDao.get_plugin_menu_by_key(self.query_db, plugin_id, menu_key): + logger.warning(f'⚠️ 插件 {plugin_id} 复用已有菜单:{menu_key}') + await self._upsert_plugin_menu(plugin_id, sys_menu.menu_id, menu_key) + return await PluginDao.get_sys_menu_by_id(self.query_db, sys_menu.menu_id) or sys_menu + + new_menu = await PluginDao.add_sys_menu(self.query_db, menu_model) + await self._upsert_plugin_menu(plugin_id, new_menu.menu_id, menu_key) + + return new_menu + + async def _find_existing_menu( + self, + plugin_id: str, + menu_key: str, + ) -> SysMenu | None: + """ + 查找已有系统菜单。 + + :param plugin_id: 插件ID + :param menu_key: 插件菜单自然键 + :return: 系统菜单对象 + """ + plugin_menu = await PluginDao.get_plugin_menu_by_key(self.query_db, plugin_id, menu_key) + if plugin_menu: + return await PluginDao.get_sys_menu_by_id(self.query_db, plugin_menu.menu_id) + return None + + async def _upsert_plugin_menu(self, plugin_id: str, menu_id: int, menu_key: str) -> None: + """ + 写入或更新插件菜单关联。 + + :param plugin_id: 插件ID + :param menu_id: 菜单ID + :param menu_key: 插件菜单自然键 + :return: None + """ + plugin_menu_model = PluginMenuModel(pluginId=plugin_id, menuId=menu_id, menuKey=menu_key) + existing_plugin_menu = await PluginDao.get_plugin_menu_by_key(self.query_db, plugin_id, menu_key) + if existing_plugin_menu and existing_plugin_menu.menu_id != menu_id: + await PluginDao.update_plugin_menu_by_key(self.query_db, plugin_menu_model) + elif not existing_plugin_menu: + existing_menu_owner = await PluginDao.get_plugin_menu_by_menu_id(self.query_db, menu_id) + if existing_menu_owner and existing_menu_owner.plugin_id == plugin_id: + await PluginDao.update_plugin_menu_key_by_menu_id(self.query_db, plugin_menu_model) + else: + await PluginDao.add_plugin_menu(self.query_db, plugin_menu_model) + + async def _remove_stale_menus(self, plugin_id: str, desired_menu_keys: set[str]) -> None: + """ + 删除已不在当前 manifest 中的插件菜单及角色授权。 + + :param plugin_id: 插件ID + :param desired_menu_keys: manifest 当前菜单自然键集合 + :return: None + """ + plugin_menus = await PluginDao.get_plugin_menu_list(self.query_db, plugin_id) + stale_menu_ids = [ + plugin_menu.menu_id for plugin_menu in plugin_menus if plugin_menu.menu_key not in desired_menu_keys + ] + await PluginDao.delete_plugin_menus_by_ids(self.query_db, plugin_id, stale_menu_ids) + await PluginDao.delete_sys_menus_by_ids(self.query_db, stale_menu_ids) + + @staticmethod + def _build_menu_model( + plugin_id: str, + menu: PluginMenuManifest, + parent_id: int, + menu_id: int | None = None, + existing_remark: str | None = None, + ) -> MenuModel: + """ + 构建系统菜单模型。 + + :param plugin_id: 插件ID + :param menu: 插件菜单声明 + :param parent_id: 父菜单ID + :param menu_id: 菜单ID + :param existing_remark: 已有菜单备注 + :return: 系统菜单模型 + """ + menu_remark = PluginMenuInstaller._build_remark(plugin_id, existing_remark) + return MenuModel( + menuId=menu_id, + menuName=menu.name, + parentId=parent_id, + orderNum=menu.order_num, + path=menu.path, + component=menu.component, + query=menu.query, + routeName=menu.route_name, + isFrame=menu.is_frame, + isCache=menu.is_cache, + menuType=menu.type, + visible=menu.visible, + status=menu.status, + perms=menu.perms or None, + icon=menu.icon, + updateTime=datetime.now(), + remark=menu_remark, + ) + + @staticmethod + def _build_remark(plugin_id: str, existing_remark: str | None = None) -> str: + """ + 构建插件菜单备注。 + + :param plugin_id: 插件ID + :param existing_remark: 已有菜单备注 + :return: 菜单备注 + """ + plugin_remark = f'plugin:{plugin_id}' + if not existing_remark: + return plugin_remark + if plugin_remark in existing_remark: + return existing_remark + + return f'{existing_remark} | {plugin_remark}' diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/service.py b/ruoyi-fastapi-backend/plugins/core/management/service/service.py new file mode 100644 index 0000000..e44f6cf --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/service/service.py @@ -0,0 +1,1106 @@ +from collections.abc import Mapping +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from common.vo import CrudResponseModel, PageModel +from plugins.core.capability import STATE_CHANGE_OPERATIONS, PluginRuntimeCapabilityResolver +from plugins.core.discovery.scanner import DiscoveredPlugin, PluginScanner +from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT +from plugins.core.lifecycle.jobs import PluginJobInstaller, PluginJobRepository +from plugins.core.lifecycle.purge import PluginPurgePlan, PluginPurgePlanner +from plugins.core.management.dao.dao import PluginDao +from plugins.core.management.entity.vo.schemas import ( + PluginConfigModel, + PluginConfigUpdateModel, + PluginConfigValueModel, + PluginMenuModel, + PluginMigrationModel, + PluginModel, + PluginOperationLogDetailModel, + PluginOperationLogExportQueryModel, + PluginOperationLogModel, + PluginOperationLogPageQueryModel, + PluginOperationLogRetentionModel, + PluginOperationLogRetentionResultModel, + PluginPageQueryModel, + PluginStatus, +) +from plugins.core.management.service.config import PluginConfigManager +from plugins.core.management.service.logs import PluginOperationLogBuilder +from plugins.core.management.service.menus import PluginMenuInstaller +from plugins.core.manifest.menu_tree import PluginMenuTree +from plugins.core.state import PluginStateResolver, PluginStateSnapshot, PluginStateTransitionTable +from plugins.core.validation.dependencies import PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX +from plugins.core.validation.menus import PluginMenuConflictItem +from utils.common_util import CamelCaseUtil +from utils.excel_util import ExcelUtil +from utils.page_util import PageUtil + + +class PluginService: + """ + 插件系统服务层。 + """ + + ORPHAN_PLUGIN_REASON = '插件源码不存在,仅允许物理清理平台元数据' + + @classmethod + async def get_plugin_list_services(cls, query_db: AsyncSession) -> list[PluginModel]: + """ + 获取插件列表。 + + :param query_db: orm对象 + :return: 插件信息列表 + """ + plugin_list = await PluginDao.get_plugin_list(query_db) + + return [PluginModel(**CamelCaseUtil.transform_result(plugin)) for plugin in plugin_list] + + @classmethod + async def get_plugin_page_list_services( + cls, + query_db: AsyncSession, + query_object: PluginPageQueryModel, + is_page: bool = True, + *, + backend_root: Path | None = None, + frontend_root: Path | None = None, + ) -> PageModel | list[dict[str, Any]]: + """ + 获取插件分页列表。 + + :param query_db: orm对象 + :param query_object: 插件分页查询对象 + :param is_page: 是否开启分页 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :return: 插件分页列表或插件列表 + """ + backend_root = backend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_backend_plugins_dir()) + frontend_root = frontend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_plugins_dir()) + discovered_plugins = PluginScanner(backend_root).discover() + database_plugins = await PluginDao.get_plugin_list(query_db) + database_plugin_map = {plugin.plugin_id: plugin for plugin in database_plugins} + discovered_plugin_ids = {plugin.manifest.id for plugin in discovered_plugins} + plugin_items = [ + cls._build_plugin_model( + discovered_plugin, + backend_root, + frontend_root, + database_plugin_map.get(discovered_plugin.manifest.id), + ).model_dump(by_alias=True) + for discovered_plugin in discovered_plugins + ] + plugin_items.extend( + cls._build_orphan_plugin_model(plugin).model_dump(by_alias=True) + for plugin in database_plugins + if plugin.plugin_id not in discovered_plugin_ids + ) + plugin_items = cls._filter_plugin_page_items(plugin_items, query_object) + + if is_page: + return PageUtil.get_page_obj(plugin_items, query_object.page_num, query_object.page_size) + + return plugin_items + + @classmethod + async def plugin_detail_services( + cls, + query_db: AsyncSession, + plugin_id: str, + *, + backend_root: Path | None = None, + frontend_root: Path | None = None, + ) -> PluginModel | None: + """ + 获取插件详情。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :return: 插件信息对象 + """ + backend_root = backend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_backend_plugins_dir()) + frontend_root = frontend_root or Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_plugins_dir()) + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + discovered_plugin = cls._get_discovered_plugin(backend_root, plugin_id) + if discovered_plugin: + return cls._build_plugin_model(discovered_plugin, backend_root, frontend_root, plugin) + + return cls._build_orphan_plugin_model(plugin) if plugin else None + + @classmethod + async def upsert_discovered_plugin_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + backend_root: Path, + frontend_root: Path | None = None, + ) -> PluginModel: + """ + 写入或更新已发现插件。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :return: 插件信息对象 + """ + manifest = discovered_plugin.manifest + existing_plugin = await PluginDao.get_plugin_by_id(query_db, manifest.id) + plugin_model = cls._build_plugin_model(discovered_plugin, backend_root, frontend_root, existing_plugin) + + if existing_plugin: + await PluginDao.update_plugin(query_db, PluginDao.dump_plugin_persistence_payload(plugin_model)) + else: + await PluginDao.add_plugin(query_db, plugin_model) + + return plugin_model + + @classmethod + async def update_plugin_enabled_services( + cls, + query_db: AsyncSession, + plugin_id: str, + enabled: bool, + discovered_plugin: DiscoveredPlugin | None = None, + ) -> CrudResponseModel: + """ + 更新插件启停状态。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param enabled: 是否启用 + :param discovered_plugin: 已发现插件对象,用于启用时恢复声明任务 + :return: 操作响应 + """ + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + if not plugin: + return CrudResponseModel(is_success=False, message='插件不存在') + if enabled and not getattr(plugin, 'installed_version', None): + return CrudResponseModel(is_success=False, message='插件尚未安装,不能启用') + + operation = 'enable' if enabled else 'disable' + status = PluginStateTransitionTable.resolve_target(getattr(plugin, 'status', None), operation) + if status is None: + return CrudResponseModel(is_success=False, message='插件状态不允许执行当前启停操作') + enabled_value = PluginStateResolver.enabled_to_db_value(enabled) + update_payload = { + 'plugin_id': plugin_id, + 'enabled': enabled_value, + 'status': status, + 'update_time': datetime.now(), + } + if enabled: + update_payload['last_error'] = None + await PluginDao.update_plugin(query_db, update_payload) + await PluginMenuInstaller(query_db).set_plugin_menu_status(plugin_id, '0' if enabled else '1') + job_installer = PluginJobInstaller(query_db) + if enabled and discovered_plugin: + await job_installer.install_plugin_jobs(discovered_plugin, enabled=True) + elif not enabled: + await job_installer.pause_plugin_jobs(plugin_id) + + return CrudResponseModel(is_success=True, message='启用成功' if enabled else '停用成功') + + @classmethod + async def mark_plugin_installed_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> PluginModel: + """ + 标记插件安装完成。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件信息对象 + """ + manifest = discovered_plugin.manifest + plugin = await PluginDao.get_plugin_by_id(query_db, manifest.id) + enabled = getattr(plugin, 'enabled', None) if plugin else '0' + status = PluginStateTransitionTable.resolve_target(getattr(plugin, 'status', None), 'install') or 'installed' + update_payload = { + 'plugin_id': manifest.id, + 'version': manifest.version, + 'installed_version': manifest.version, + 'status': status, + 'last_error': None, + 'update_time': datetime.now(), + } + await PluginDao.update_plugin(query_db, update_payload) + + return PluginModel( + pluginId=manifest.id, + pluginName=manifest.name, + version=manifest.version, + installedVersion=manifest.version, + enabled=enabled, + status=status, + source='local', + backendPath=str(discovered_plugin.backend_path), + description=manifest.description, + updateTime=update_payload['update_time'], + ) + + @classmethod + async def mark_plugin_uninstalled_services(cls, query_db: AsyncSession, plugin_id: str) -> CrudResponseModel: + """ + 标记插件已卸载。 + + 卸载不同于停用:停用保留 installed_version,卸载清空安装版本,使本地插件回到可安装状态。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 操作响应 + """ + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + if not plugin: + return CrudResponseModel(is_success=False, message='插件不存在') + + plugin_menus = await PluginDao.get_plugin_menu_list(query_db, plugin_id) + menu_ids = [plugin_menu.menu_id for plugin_menu in plugin_menus] + update_payload = { + 'plugin_id': plugin_id, + 'installed_version': None, + 'enabled': '1', + 'status': 'discovered', + 'last_error': None, + 'update_time': datetime.now(), + } + await PluginDao.update_plugin(query_db, update_payload) + await PluginDao.delete_plugin_menus(query_db, plugin_id) + await PluginDao.delete_sys_menus_by_ids(query_db, menu_ids) + await PluginJobInstaller(query_db).pause_plugin_jobs(plugin_id) + + return CrudResponseModel(is_success=True, message='卸载成功') + + @classmethod + async def install_plugin_menu_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + *, + enabled: bool, + ) -> None: + """ + 安装指定插件菜单并设置菜单启停状态。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param enabled: 插件菜单是否启用 + :return: None + """ + menu_installer = PluginMenuInstaller(query_db) + await menu_installer.install_manifest_menus(discovered_plugin.manifest) + await menu_installer.set_plugin_menu_status(discovered_plugin.manifest.id, '0' if enabled else '1') + + @classmethod + async def install_plugin_default_config_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> list[PluginConfigModel]: + """ + 安装插件默认配置。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件配置模型列表 + """ + installed_configs = [] + manifest = discovered_plugin.manifest + existing_configs = await PluginDao.get_plugin_config_list(query_db, manifest.id) + existing_config_map = {config.config_key: config for config in existing_configs} + desired_config_keys = {item.key for item in manifest.config.items} + for item in manifest.config.items: + existing_config = existing_config_map.get(item.key) + config_model = PluginConfigManager.build_config_model(manifest.id, item) + if existing_config: + migrated_config_value = PluginConfigManager.migrate_config_secret_storage(existing_config, item) + await PluginDao.update_plugin_config( + query_db, + { + 'plugin_id': manifest.id, + 'config_key': item.key, + 'config_label': config_model.config_label, + 'config_type': config_model.config_type, + 'config_value': migrated_config_value, + 'default_value': config_model.default_value, + 'required': config_model.required, + 'secret': config_model.secret, + 'options': config_model.options, + 'description': config_model.description, + 'update_time': datetime.now(), + }, + ) + else: + await PluginDao.add_plugin_config(query_db, config_model) + installed_configs.append(config_model) + + await PluginDao.delete_plugin_configs_except(query_db, manifest.id, desired_config_keys) + return installed_configs + + @classmethod + async def install_plugin_job_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + *, + enabled: bool, + ) -> None: + """ + 将单个插件的任务资源同步到 manifest 期望状态。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param enabled: 插件任务是否允许启用 + :return: None + """ + await PluginJobInstaller(query_db).install_plugin_jobs(discovered_plugin, enabled=enabled) + + @classmethod + async def get_plugin_config_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + *, + reveal_secret: bool = False, + ) -> list[PluginConfigValueModel]: + """ + 获取插件配置列表。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param reveal_secret: 是否展示敏感配置原值 + :return: 插件配置值列表 + """ + config_list = await PluginDao.get_plugin_config_list(query_db, discovered_plugin.manifest.id) + config_map = {config.config_key: config for config in config_list} + + return [ + PluginConfigManager.build_config_value( + config_map.get(item.key) or PluginConfigManager.build_config_model(discovered_plugin.manifest.id, item), + item, + reveal_secret=reveal_secret, + ) + for item in discovered_plugin.manifest.config.items + ] + + @classmethod + async def is_plugin_installed_services(cls, query_db: AsyncSession, plugin_id: str) -> bool: + """ + 判断插件是否已经完成安装。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 是否已安装 + """ + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + return bool(plugin and plugin.installed_version) + + @classmethod + async def update_plugin_config_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + update_model: PluginConfigUpdateModel, + ) -> list[PluginConfigValueModel]: + """ + 更新插件配置。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param update_model: 插件配置更新模型 + :return: 更新后的插件配置值列表 + """ + await cls.install_plugin_default_config_services(query_db, discovered_plugin) + manifest_items = {item.key: item for item in discovered_plugin.manifest.config.items} + for key, value in update_model.values.items(): + item = manifest_items.get(key) + if not item: + raise ValueError(f'插件未声明配置:{key}') + if item.secret and value == PluginConfigManager.MASK_VALUE: + continue + PluginConfigManager.validate_update_value(item, value) + await PluginDao.update_plugin_config( + query_db, + { + 'plugin_id': discovered_plugin.manifest.id, + 'config_key': key, + 'config_value': PluginConfigManager.serialize_config_value(value, secret=item.secret), + 'update_time': datetime.now(), + }, + ) + + return await cls.get_plugin_config_services(query_db, discovered_plugin) + + @classmethod + async def add_plugin_operation_log_services( + cls, + query_db: AsyncSession, + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> PluginOperationLogModel: + """ + 记录插件批量操作审计日志。 + + :param query_db: orm对象 + :param payload: 插件批量执行结果负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: 插件批量操作审计日志模型 + """ + operation_log = PluginOperationLogBuilder.build_model( + payload, + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + db_operation_log = await PluginDao.add_plugin_operation_log(query_db, operation_log) + + return PluginOperationLogModel(**CamelCaseUtil.transform_result(db_operation_log)) + + @classmethod + async def get_plugin_operation_log_page_list_services( + cls, + query_db: AsyncSession, + query_object: PluginOperationLogPageQueryModel, + is_page: bool = True, + ) -> PageModel | list[dict[str, Any]]: + """ + 获取插件批量操作审计日志分页列表。 + + :param query_db: orm对象 + :param query_object: 插件批量操作审计日志分页查询对象 + :param is_page: 是否开启分页 + :return: 插件批量操作审计日志分页列表或列表 + """ + page_result = await PluginDao.get_plugin_operation_log_page_list(query_db, query_object, is_page) + if not isinstance(page_result, PageModel): + return page_result + + page_result.rows = [PluginOperationLogBuilder.build_detail(row) for row in page_result.rows] + + return page_result + + @classmethod + async def get_plugin_operation_log_export_list_services( + cls, + query_db: AsyncSession, + query_object: PluginOperationLogExportQueryModel, + ) -> list[PluginOperationLogDetailModel]: + """ + 获取插件批量操作审计日志导出列表。 + + :param query_db: orm对象 + :param query_object: 插件批量操作审计日志导出查询对象 + :return: 插件批量操作审计日志导出详情列表 + """ + operation_log_list = await PluginDao.get_plugin_operation_log_export_list(query_db, query_object) + + return [PluginOperationLogBuilder.build_detail(operation_log) for operation_log in operation_log_list] + + @classmethod + def export_plugin_operation_log_list_services( + cls, + operation_log_list: list[PluginOperationLogDetailModel], + operation_dict: dict[str, str] | None = None, + ) -> bytes: + """ + 导出插件批量操作审计日志。 + + :param operation_log_list: 插件批量操作审计日志导出详情列表 + :param operation_dict: 插件操作类型字典 + :return: 插件批量操作审计日志 Excel 二进制数据 + """ + export_list = [ + PluginOperationLogBuilder.build_export_row(operation_log, operation_dict) + for operation_log in operation_log_list + ] + mapping_dict = { + 'operationId': '日志编号', + 'operation': '操作类型', + 'pluginIds': '目标插件', + 'dryRun': '是否预演', + 'continueOnError': '失败后继续', + 'status': '执行状态', + 'summary': '执行汇总', + 'remark': '备注', + 'createTime': '创建时间', + } + + return ExcelUtil.export_list2excel(export_list, mapping_dict) + + @classmethod + async def retain_plugin_operation_log_services( + cls, + query_db: AsyncSession, + retention_model: PluginOperationLogRetentionModel, + ) -> PluginOperationLogRetentionResultModel: + """ + 按保留策略清理插件批量操作审计日志。 + + :param query_db: orm对象 + :param retention_model: 插件批量操作审计日志保留策略模型 + :return: 插件批量操作审计日志保留策略执行结果 + """ + cutoff_time = datetime.now() - timedelta(days=retention_model.retention_days) + matched_count = await PluginDao.count_plugin_operation_logs_before(query_db, cutoff_time) + deleted_count = 0 + if not retention_model.dry_run: + deleted_count = await PluginDao.delete_plugin_operation_logs_before(query_db, cutoff_time) + + return PluginOperationLogRetentionResultModel( + retentionDays=retention_model.retention_days, + cutoffTime=cutoff_time, + matchedCount=matched_count, + deletedCount=deleted_count, + dryRun=retention_model.dry_run, + ) + + @classmethod + async def plugin_operation_log_detail_services( + cls, + query_db: AsyncSession, + operation_id: int, + ) -> PluginOperationLogDetailModel | None: + """ + 获取插件批量操作审计日志详情。 + + :param query_db: orm对象 + :param operation_id: 操作日志ID + :return: 插件批量操作审计日志详情 + """ + operation_log = await PluginDao.get_plugin_operation_log_by_id(query_db, operation_id) + if not operation_log: + return None + + return PluginOperationLogBuilder.build_detail(CamelCaseUtil.transform_result(operation_log)) + + @classmethod + async def check_installed_menu_conflict_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> list[PluginMenuConflictItem]: + """ + 检查目标插件与数据库已存在菜单的权限冲突。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 菜单冲突检查项列表 + """ + conflicts = [] + for menu in PluginMenuTree.flatten(discovered_plugin.manifest.frontend.menus): + if not menu.perms: + continue + sys_menu = await PluginDao.get_sys_menu_by_perms(query_db, menu.perms) + if not sys_menu: + continue + plugin_menu = await PluginDao.get_plugin_menu_by_menu_id(query_db, sys_menu.menu_id) + conflict_plugin_id = plugin_menu.plugin_id if plugin_menu else None + if conflict_plugin_id == discovered_plugin.manifest.id: + continue + conflict_label = conflict_plugin_id or 'core' + conflicts.append( + PluginMenuConflictItem( + kind='installed_permission', + plugin_id=discovered_plugin.manifest.id, + conflict_plugin_id=conflict_plugin_id, + value=menu.perms, + message=( + f'插件 {discovered_plugin.manifest.id} 权限 {menu.perms} ' + f'与已存在菜单 {sys_menu.menu_id}({conflict_label})冲突' + ), + ) + ) + + return conflicts + + @classmethod + async def mark_plugin_error_services( + cls, query_db: AsyncSession, plugin_id: str, error_message: str + ) -> CrudResponseModel: + """ + 标记插件错误。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param error_message: 错误信息 + :return: 操作响应 + """ + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + if not plugin: + return CrudResponseModel(is_success=False, message='插件不存在') + + await PluginDao.update_plugin( + query_db, + { + 'plugin_id': plugin_id, + 'status': PluginStateTransitionTable.resolve_target(getattr(plugin, 'status', None), 'mark_error') + or 'error', + 'last_error': error_message[:1000], + 'update_time': datetime.now(), + }, + ) + await PluginMenuInstaller(query_db).set_plugin_menu_status(plugin_id, '1') + await PluginJobInstaller(query_db).pause_plugin_jobs(plugin_id) + + return CrudResponseModel(is_success=True, message='插件状态已标记为异常') + + @classmethod + async def recover_plugin_dependency_error_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> CrudResponseModel: + """ + 在启动依赖重新满足后恢复插件状态。 + + 仅恢复由启动依赖检查写入的 error。恢复目标由已安装版本和当前源码版本 + 重新推导,未安装插件回到 discovered,已安装插件回到 installed 或 + pending_upgrade;同时恢复启动前的启用意图并清除历史依赖错误。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 操作响应 + """ + plugin_id = discovered_plugin.manifest.id + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + if not plugin: + return CrudResponseModel(is_success=False, message='插件不存在') + + last_error = getattr(plugin, 'last_error', None) + if ( + getattr(plugin, 'status', None) != 'error' + or not isinstance(last_error, str) + or not last_error.startswith(PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX) + ): + return CrudResponseModel(is_success=False, message='插件不是启动依赖检查异常状态') + + installed_version = getattr(plugin, 'installed_version', None) + desired_enabled = PluginStateResolver.db_value_to_enabled( + getattr(plugin, 'enabled', None), + fallback=False, + ) + target_status = PluginStateResolver.resolve( + PluginStateSnapshot( + source_version=discovered_plugin.manifest.version, + installed_version=installed_version, + enabled=desired_enabled, + current_status=None, + ) + ) + await PluginDao.update_plugin( + query_db, + { + 'plugin_id': plugin_id, + 'status': target_status, + 'last_error': None, + 'update_time': datetime.now(), + }, + ) + + return CrudResponseModel(is_success=True, message=f'插件启动依赖已恢复,状态:{target_status}') + + @classmethod + async def build_plugin_purge_plan_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> PluginPurgePlan: + """ + 构建插件物理清理计划。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件物理清理计划 + """ + plugin_id = discovered_plugin.manifest.id + menu_count = await PluginDao.count_plugin_menus(query_db, plugin_id) + config_count = await PluginDao.count_plugin_configs(query_db, plugin_id) + migration_count = await PluginDao.count_plugin_migrations(query_db, plugin_id) + job_count = await PluginJobRepository(query_db).count_jobs_by_name_prefix(f'{plugin_id}:') + + return PluginPurgePlanner.build_plan( + discovered_plugin, + menu_count=menu_count, + config_count=config_count, + migration_count=migration_count, + job_count=job_count, + ) + + @classmethod + async def purge_plugin_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> PluginPurgePlan: + """ + 清理插件平台元数据。 + + 该方法只清理由平台拥有的插件记录、菜单关联、配置、migration 历史和插件任务; + 插件业务数据需由运行时在调用本方法前通过 on_purge 钩子显式清理。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 执行前构建的插件物理清理计划 + """ + plugin_id = discovered_plugin.manifest.id + plan = await cls.build_plugin_purge_plan_services(query_db, discovered_plugin) + await cls._purge_plugin_metadata_by_id(query_db, plugin_id) + + return plan + + @classmethod + async def build_plugin_purge_plan_by_id_services( + cls, + query_db: AsyncSession, + plugin_id: str, + ) -> PluginPurgePlan: + """ + 为源码已缺失的插件按 ID 构建平台元数据清理计划。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 插件物理清理计划 + """ + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + menu_count = await PluginDao.count_plugin_menus(query_db, plugin_id) + config_count = await PluginDao.count_plugin_configs(query_db, plugin_id) + migration_count = await PluginDao.count_plugin_migrations(query_db, plugin_id) + job_count = await PluginJobRepository(query_db).count_jobs_by_name_prefix(f'{plugin_id}:') + + return PluginPurgePlanner.build_metadata_plan( + plugin_id, + state_count=1 if plugin else 0, + menu_count=menu_count, + config_count=config_count, + migration_count=migration_count, + job_count=job_count, + ) + + @classmethod + async def purge_plugin_metadata_by_id_services( + cls, + query_db: AsyncSession, + plugin_id: str, + ) -> PluginPurgePlan: + """ + 按插件 ID 清理平台拥有的孤儿元数据。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 执行前构建的插件物理清理计划 + """ + plan = await cls.build_plugin_purge_plan_by_id_services(query_db, plugin_id) + await cls._purge_plugin_metadata_by_id(query_db, plugin_id) + + return plan + + @classmethod + async def _purge_plugin_metadata_by_id(cls, query_db: AsyncSession, plugin_id: str) -> None: + """ + 删除平台能够按插件 ID 确认归属的元数据。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: None + """ + plugin_menus = await PluginDao.get_plugin_menu_list(query_db, plugin_id) + menu_ids = [plugin_menu.menu_id for plugin_menu in plugin_menus] + + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + if plugin: + await cls.update_plugin_enabled_services(query_db, plugin_id, enabled=False) + await PluginDao.delete_plugin_menus(query_db, plugin_id) + await PluginDao.delete_sys_menus_by_ids(query_db, menu_ids) + await PluginDao.delete_plugin_configs(query_db, plugin_id) + await PluginDao.delete_plugin_migrations(query_db, plugin_id) + await PluginJobRepository(query_db).delete_jobs_by_name_prefix(f'{plugin_id}:') + await PluginDao.delete_plugin(query_db, plugin_id) + + @classmethod + async def upsert_plugin_menu_services( + cls, query_db: AsyncSession, plugin_id: str, menu_id: int, menu_key: str + ) -> PluginMenuModel: + """ + 写入插件菜单关联。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param menu_id: 菜单ID + :param menu_key: 插件内菜单自然键 + :return: 插件菜单关联对象 + """ + existing_plugin_menu = await PluginDao.get_plugin_menu_by_key(query_db, plugin_id, menu_key) + plugin_menu_model = PluginMenuModel(pluginId=plugin_id, menuId=menu_id, menuKey=menu_key) + + if existing_plugin_menu and existing_plugin_menu.menu_id != menu_id: + await PluginDao.update_plugin_menu_by_key(query_db, plugin_menu_model) + elif not existing_plugin_menu: + await PluginDao.add_plugin_menu(query_db, plugin_menu_model) + + return plugin_menu_model + + @classmethod + async def get_plugin_migration_services( + cls, + query_db: AsyncSession, + plugin_id: str, + migration_path: str, + ) -> PluginMigrationModel | None: + """ + 获取插件 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: 插件 migration 执行历史对象 + """ + plugin_migration = await PluginDao.get_plugin_migration_by_path(query_db, plugin_id, migration_path) + if not plugin_migration: + return None + + return PluginMigrationModel(**CamelCaseUtil.transform_result(plugin_migration)) + + @classmethod + async def get_plugin_migration_list_services( + cls, + query_db: AsyncSession, + plugin_id: str, + status: str | None = None, + ) -> list[PluginMigrationModel]: + """ + 获取插件 migration 执行历史列表。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param status: 执行状态 + :return: 插件 migration 执行历史列表 + """ + plugin_migrations = await PluginDao.get_plugin_migration_list(query_db, plugin_id, status) + + return [PluginMigrationModel(**CamelCaseUtil.transform_result(item)) for item in plugin_migrations] + + @classmethod + async def add_plugin_migration_services( + cls, + query_db: AsyncSession, + plugin_migration: PluginMigrationModel, + ) -> PluginMigrationModel: + """ + 新增插件 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_migration: 插件 migration 执行历史对象 + :return: 插件 migration 执行历史对象 + """ + await PluginDao.add_plugin_migration(query_db, plugin_migration) + + return plugin_migration + + @classmethod + async def mark_plugin_migration_status_services( + cls, + query_db: AsyncSession, + plugin_id: str, + migration_path: str, + status: str, + error_message: str | None = None, + ) -> PluginMigrationModel | None: + """ + 人工标记插件 migration 执行历史状态。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param status: 执行状态 + :param error_message: 失败错误信息 + :return: 更新后的插件 migration 执行历史对象 + """ + plugin_migration = await PluginDao.update_plugin_migration_status( + query_db, + plugin_id, + migration_path, + status, + error_message, + ) + if not plugin_migration: + return None + + return PluginMigrationModel(**CamelCaseUtil.transform_result(plugin_migration)) + + @staticmethod + def _build_plugin_model( + discovered_plugin: DiscoveredPlugin, + backend_root: Path, + frontend_root: Path | None, + existing_plugin: object | None, + ) -> PluginModel: + """ + 根据发现结果和数据库状态构建插件信息模型。 + + :param discovered_plugin: 已发现插件对象 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :param existing_plugin: 数据库中已有插件对象 + :return: 插件信息模型 + """ + manifest = discovered_plugin.manifest + capability = PluginRuntimeCapabilityResolver( + frontend_mode=PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_mode(), + backend_runtime_mode=PLUGIN_RUNTIME_ENVIRONMENT.get_backend_runtime_mode(), + ).resolve(discovered_plugin) + current_enabled = getattr(existing_plugin, 'enabled', None) + enabled = current_enabled if current_enabled is not None else '0' + installed_version = getattr(existing_plugin, 'installed_version', None) + current_status = getattr(existing_plugin, 'status', None) + last_error = getattr(existing_plugin, 'last_error', None) + status = PluginService._resolve_status(manifest.version, installed_version, enabled, current_status) + frontend_path = frontend_root / manifest.frontend.plugin_id if frontend_root else None + frontend_menus = PluginMenuTree.flatten(manifest.frontend.menus) + + return PluginModel( + pluginId=manifest.id, + pluginName=manifest.name, + version=manifest.version, + installedVersion=installed_version, + enabled=enabled, + status=status, + source='local', + backendPath=str(discovered_plugin.backend_path.relative_to(backend_root)), + frontendPath=str(frontend_path.relative_to(frontend_root)) if frontend_path and frontend_root else None, + lastError=last_error, + description=manifest.description, + updateTime=datetime.now(), + capability=capability.to_payload(), + metadata=manifest.metadata.model_dump(by_alias=True), + backend={ + 'module': manifest.backend.module, + 'autoScanRouters': manifest.backend.routers.auto_scan, + 'migrations': manifest.backend.migrations, + 'seeds': manifest.backend.seeds, + 'jobs': [job.model_dump(by_alias=True) for job in manifest.backend.jobs], + }, + frontend={ + 'pluginId': manifest.frontend.plugin_id, + 'basePath': manifest.frontend.base_path, + 'viewsPath': manifest.frontend.views_path, + 'apiPath': manifest.frontend.api_path, + 'delivery': manifest.frontend.delivery.model_dump(by_alias=True), + 'menus': [menu.model_dump(by_alias=True) for menu in frontend_menus], + }, + permissions=[permission.model_dump(by_alias=True) for permission in manifest.permissions], + config=[config_item.model_dump(by_alias=True) for config_item in manifest.config.items], + dependencies={ + 'python': manifest.dependencies.python, + 'npm': manifest.dependencies.npm, + 'npmDev': manifest.dependencies.npm_dev, + }, + pluginDependencies=[dependency.model_dump(by_alias=True) for dependency in manifest.dependencies.plugins], + ) + + @classmethod + def _build_orphan_plugin_model(cls, plugin: object) -> PluginModel: + """ + 构建源码缺失但平台元数据仍存在的孤儿插件视图。 + + 孤儿记录只能执行平台元数据物理清理,其他生命周期操作均依赖缺失的 + manifest 和源码,因此通过 capability 明确阻断。 + + :param plugin: 数据库插件状态对象 + :return: 孤儿插件信息模型 + """ + model = PluginModel(**CamelCaseUtil.transform_result(plugin)) + blocked_operations = sorted(STATE_CHANGE_OPERATIONS - {'purge'}) + return model.model_copy( + update={ + 'source': 'orphan', + 'capability': { + 'pluginId': model.plugin_id, + 'frontendMode': PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_mode(), + 'backendRuntimeMode': PLUGIN_RUNTIME_ENVIRONMENT.get_backend_runtime_mode(), + 'hasFrontendResources': False, + 'frontendBuildRequired': False, + 'frontendRuntimeManageable': False, + 'backendRuntimeManageable': False, + 'runtimeManageable': False, + 'blockedOperations': blocked_operations, + 'warnings': [cls.ORPHAN_PLUGIN_REASON], + 'primaryReason': cls.ORPHAN_PLUGIN_REASON, + }, + } + ) + + @staticmethod + def _resolve_status( + version: str, + installed_version: str | None, + enabled: str, + current_status: str | None = None, + ) -> PluginStatus: + """ + 解析插件状态。 + + :param version: 当前源码版本 + :param installed_version: 已安装版本 + :param enabled: 启停状态 + :param current_status: 当前数据库状态 + :return: 插件状态 + """ + return PluginStateResolver.resolve( + PluginStateSnapshot( + source_version=version, + installed_version=installed_version, + enabled=PluginStateResolver.db_value_to_enabled(enabled, fallback=False), + current_status=current_status, + ) + ) + + @staticmethod + def _filter_plugin_page_items( + plugin_items: list[dict[str, Any]], + query_object: PluginPageQueryModel, + ) -> list[dict[str, Any]]: + """ + 根据插件管理页面查询条件过滤插件列表。 + + :param plugin_items: 插件列表项 + :param query_object: 插件分页查询对象 + :return: 过滤后的插件列表项 + """ + + def contains(value: object, keyword: str | None) -> bool: + return keyword is None or keyword in str(value or '') + + return [ + item + for item in plugin_items + if contains(item.get('pluginId'), query_object.plugin_id) + and contains(item.get('pluginName'), query_object.plugin_name) + and (query_object.enabled is None or item.get('enabled') == query_object.enabled) + and (query_object.status is None or item.get('status') == query_object.status) + and (query_object.source is None or item.get('source') == query_object.source) + ] + + @staticmethod + def _get_discovered_plugin(backend_root: Path, plugin_id: str) -> DiscoveredPlugin | None: + """ + 从本地插件目录获取指定插件发现结果。 + + :param backend_root: 后端插件根目录 + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + for discovered_plugin in PluginScanner(backend_root).discover(): + if discovered_plugin.manifest.id == plugin_id: + return discovered_plugin + + return None diff --git a/ruoyi-fastapi-backend/plugins/core/management/service/startup_gateway.py b/ruoyi-fastapi-backend/plugins/core/management/service/startup_gateway.py new file mode 100644 index 0000000..5ceb3f4 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/management/service/startup_gateway.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from plugins.core.management.dao.dao import PluginDao +from plugins.core.management.service.gateway import PluginManagementRuntimeGateway +from plugins.core.management.service.service import PluginService +from plugins.core.state import PluginStateResolver + +if TYPE_CHECKING: + from pathlib import Path + + from sqlalchemy.ext.asyncio import AsyncSession + + from common.vo import CrudResponseModel + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.management.entity.vo.schemas import PluginMigrationModel, PluginModel + + +class PluginManagementStartupGateway: + """ + 插件启动期管理端口适配器。 + """ + + async def list_plugins(self, query_db: AsyncSession) -> list[Any]: + """ + 获取数据库插件状态列表。 + + :param query_db: orm对象 + :return: 插件状态列表 + """ + return await PluginService.get_plugin_list_services(query_db) + + async def install_plugin_resources( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + *, + enabled: bool, + ) -> None: + """ + 在同一事务中同步单个插件菜单、配置和任务。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param enabled: 插件资源是否启用 + :return: None + """ + await PluginService.install_plugin_menu_services(query_db, discovered_plugin, enabled=enabled) + await PluginService.install_plugin_default_config_services(query_db, discovered_plugin) + await PluginService.install_plugin_job_services(query_db, discovered_plugin, enabled=enabled) + + async def mark_plugin_error( + self, + query_db: AsyncSession, + plugin_id: str, + error_message: str, + ) -> CrudResponseModel: + """ + 标记插件运行时异常。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param error_message: 错误信息 + :return: 操作响应 + """ + return await PluginService.mark_plugin_error_services(query_db, plugin_id, error_message) + + async def recover_plugin_dependency_error( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> CrudResponseModel: + """ + 恢复启动依赖检查异常的插件状态。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 操作响应 + """ + return await PluginService.recover_plugin_dependency_error_services(query_db, discovered_plugin) + + async def upsert_discovered_plugin( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + backend_root: Path, + frontend_root: Path | None = None, + ) -> PluginModel: + """ + 写入或更新已发现插件。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :return: 插件信息 + """ + return await PluginService.upsert_discovered_plugin_services( + query_db, + discovered_plugin, + backend_root, + frontend_root, + ) + + async def mark_plugin_installed( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> PluginModel: + """ + 标记插件安装完成。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件信息 + """ + return await PluginService.mark_plugin_installed_services(query_db, discovered_plugin) + + async def get_plugin_migration( + self, + query_db: AsyncSession, + plugin_id: str, + migration_path: str, + ) -> PluginMigrationModel | None: + """ + 获取插件 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: 插件 migration 执行历史 + """ + return await PluginService.get_plugin_migration_services(query_db, plugin_id, migration_path) + + async def add_plugin_migration( + self, + query_db: AsyncSession, + plugin_migration: PluginMigrationModel, + ) -> PluginMigrationModel: + """ + 新增插件 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_migration: 插件 migration 执行历史 + :return: 插件 migration 执行历史 + """ + return await PluginService.add_plugin_migration_services(query_db, plugin_migration) + + def build_migration_record( + self, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + status: str = 'success', + error_message: str | None = None, + ) -> PluginMigrationModel: + """ + 构建插件 migration 执行历史对象。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: 插件 migration 执行历史对象 + """ + return PluginManagementRuntimeGateway.build_migration_record( + plugin_id, + migration_path, + checksum, + version, + statement_count, + status, + error_message, + ) + + +class PluginManagementRouteStateGateway: + """ + 插件路由状态读取适配器。 + """ + + @staticmethod + async def is_plugin_enabled(query_db: AsyncSession, plugin_id: str) -> bool: + """ + 判断插件是否启用。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 插件是否启用 + """ + plugin = await PluginDao.get_plugin_by_id(query_db, plugin_id) + return PluginStateResolver.is_enabled(plugin) diff --git a/ruoyi-fastapi-backend/plugins/core/manifest/__init__.py b/ruoyi-fastapi-backend/plugins/core/manifest/__init__.py new file mode 100644 index 0000000..14d3a76 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/manifest/__init__.py @@ -0,0 +1,5 @@ +""" +插件 manifest 分层包。 +""" + +from plugins.core.manifest.schema import * # noqa: F403 diff --git a/ruoyi-fastapi-backend/plugins/core/manifest/menu_key.py b/ruoyi-fastapi-backend/plugins/core/manifest/menu_key.py new file mode 100644 index 0000000..71db36b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/manifest/menu_key.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass + +from plugins.core.manifest.schema import PluginMenuManifest + + +@dataclass(frozen=True) +class InstalledPluginMenu: + """ + 已安装插件菜单快照。 + """ + + menu_id: int + menu_key: str + manifest_menu: PluginMenuManifest + + +class PluginMenuKeyBuilder: + """ + 插件菜单自然键构建器。 + + 使用 Strategy 思路将菜单自然键计算逻辑集中封装,便于后续调整匹配规则时 + 不影响菜单安装流程。 + """ + + @classmethod + def build(cls, menu: PluginMenuManifest, parent_key: str) -> str: + """ + 构建插件菜单自然键。 + + :param menu: 插件菜单声明 + :param parent_key: 父级菜单自然键 + :return: 插件菜单自然键 + """ + if menu.type == 'F': + return f'button:{parent_key}/{menu.name}#{menu.perms}' + if menu.perms: + return f'perm:{menu.perms}' + return f'route:{parent_key}/{menu.path}#{menu.component}' diff --git a/ruoyi-fastapi-backend/plugins/core/manifest/menu_tree.py b/ruoyi-fastapi-backend/plugins/core/manifest/menu_tree.py new file mode 100644 index 0000000..e1b844d --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/manifest/menu_tree.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from plugins.core.manifest.schema import PluginManifest, PluginMenuManifest + +MIN_PLUGIN_COMPONENT_PARTS = 3 + + +class PluginMenuTree: + """ + 插件菜单树领域工具。 + + 统一处理 manifest 菜单树的遍历、统计、权限和组件路径解析,避免校验、管理和 + 运行时负载构建各自维护递归逻辑。 + """ + + @classmethod + def flatten(cls, menus: list[PluginMenuManifest]) -> list[PluginMenuManifest]: + """ + 展平插件菜单树。 + + :param menus: 插件菜单声明列表 + :return: 展平后的插件菜单列表 + """ + flattened_menus = [] + for menu in menus: + flattened_menus.append(menu) + flattened_menus.extend(cls.flatten(menu.children)) + + return flattened_menus + + @classmethod + def count(cls, menus: list[PluginMenuManifest]) -> int: + """ + 统计插件菜单树节点数量。 + + :param menus: 插件菜单声明列表 + :return: 菜单节点数量 + """ + return len(cls.flatten(menus)) + + @classmethod + def collect_permissions(cls, menus: list[PluginMenuManifest]) -> set[str]: + """ + 收集插件菜单树中的权限标识。 + + :param menus: 插件菜单声明列表 + :return: 菜单权限标识集合 + """ + return {menu.perms for menu in cls.flatten(menus) if menu.perms} + + @classmethod + def collect_route_paths( + cls, + menus: list[PluginMenuManifest], + parent_path: str = '', + ) -> list[str]: + """ + 收集插件菜单树中的完整路由路径。 + + :param menus: 插件菜单声明列表 + :param parent_path: 父级菜单路径 + :return: 完整路由路径列表 + """ + route_paths = [] + for menu in menus: + current_path = f'{parent_path}/{menu.path}' if parent_path else menu.path + if menu.type != 'F': + route_paths.append(current_path) + route_paths.extend(cls.collect_route_paths(menu.children, current_path)) + + return route_paths + + @staticmethod + def is_plugin_component(component: str) -> bool: + """ + 判断菜单组件是否引用插件前端视图。 + + :param component: 菜单组件路径 + :return: 是否为插件组件路径 + """ + return component.startswith('plugin/') + + @staticmethod + def resolve_plugin_view_path(manifest: PluginManifest, component: str) -> Path | None: + """ + 将插件组件路径解析为前端插件内视图路径。 + + :param manifest: 插件清单 + :param component: 菜单组件路径 + :return: Vue 文件相对路径 + """ + parts = component.split('/') + plugin_id = manifest.frontend.plugin_id or manifest.id + if len(parts) < MIN_PLUGIN_COMPONENT_PARTS or parts[0] != 'plugin' or parts[1] != plugin_id: + return None + + return Path(manifest.frontend.views_path, *parts[2:]).with_suffix('.vue') diff --git a/ruoyi-fastapi-backend/plugins/core/manifest/schema.py b/ruoyi-fastapi-backend/plugins/core/manifest/schema.py new file mode 100644 index 0000000..632ba6c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/manifest/schema.py @@ -0,0 +1,1159 @@ +import re +from collections import Counter +from typing import Any, Literal + +from packaging.requirements import InvalidRequirement +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator + +from plugins.core.manifest.menu_tree import PluginMenuTree +from plugins.core.types import PluginConfigValue +from plugins.core.utils import PLUGIN_ID_PATTERN_TEXT, validate_plugin_id_value +from plugins.core.validation.python_requirements import PythonRequirementParser + +RESERVED_PLUGIN_IDS = {'admin', 'system', 'monitor', 'tool'} +PERMISSION_PATTERN = re.compile(r'^[a-z][a-z0-9_-]*(?::[a-z][a-z0-9_-]*)+$') +HOOK_PATTERN = re.compile(r'^[A-Za-z_][\w.]*:[A-Za-z_]\w*$') +CONFIG_KEY_PATTERN = re.compile(r'^[a-z][a-z0-9_.-]{0,127}$') +JOB_ID_PATTERN = re.compile(r'^[a-z][a-z0-9_-]{0,63}$') +JOB_CALLABLE_PATTERN = re.compile(r'^[A-Za-z_][\w.]*\.[A-Za-z_]\w*$') +PLUGIN_DEPENDENCY_PATTERN = re.compile( + rf'^\s*({PLUGIN_ID_PATTERN_TEXT})\s*([<>=!~^]{{1,2}})?\s*([A-Za-z0-9_.+\-!*]+)?\s*$' +) +VERSION_CONSTRAINT_PATTERN = re.compile(r'^([<>=!~^]{1,2})?[A-Za-z0-9_.+\-!*]+$') +ROUTE_PATH_PATTERN = re.compile(r'^[a-z][a-z0-9_-]*(/[a-z][a-z0-9_-]*)*$') +EXTERNAL_URL_PATTERN = re.compile(r'^https?://\S+$') +FRONTEND_RELATIVE_PATH_PATTERN = re.compile(r'^[a-z][a-z0-9_-]*(/[a-z][a-z0-9_-]*)*$') +PLUGIN_COMPONENT_SEGMENT_PATTERN = re.compile(r'^[a-z][a-z0-9_-]*$') +RESOURCE_RELATIVE_PATH_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]*(/[A-Za-z0-9][A-Za-z0-9_.-]*)*$') +CORE_FRONTEND_COMPONENTS = {'Layout', 'ParentView', 'InnerLink'} +MIN_PLUGIN_COMPONENT_PARTS = 3 +SUPPORTED_MANIFEST_VERSION = 1 + + +class PluginManifestError(ValueError): + """ + 插件清单校验异常。 + """ + + +class RouterManifest(BaseModel): + """ + 插件路由声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + auto_scan: bool = Field(default=True, alias='autoScan', description='是否自动扫描插件控制器') + + +class HealthManifest(BaseModel): + """ + 插件健康检查声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + checker: str | None = Field(default=None, description='插件健康检查 callable,格式为 :') + + @field_validator('checker') + @classmethod + def validate_checker(cls, value: str | None) -> str | None: + """ + 校验健康检查 callable 声明。 + + :param value: 健康检查 callable 声明 + :return: 校验后的健康检查 callable 声明 + """ + if value is None or HOOK_PATTERN.match(value): + return value + raise ValueError('健康检查 checker 必须使用 : 格式') + + +class HookManifest(BaseModel): + """ + 插件生命周期钩子声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + on_install: str | None = Field(default=None, alias='onInstall', description='插件安装钩子') + on_upgrade: str | None = Field(default=None, alias='onUpgrade', description='插件升级钩子') + on_startup: str | None = Field(default=None, alias='onStartup', description='插件启动钩子') + on_shutdown: str | None = Field(default=None, alias='onShutdown', description='插件关闭钩子') + on_purge: str | None = Field(default=None, alias='onPurge', description='插件物理清理钩子') + + @field_validator('on_install', 'on_upgrade', 'on_startup', 'on_shutdown', 'on_purge') + @classmethod + def validate_hook(cls, value: str | None) -> str | None: + """ + 校验生命周期钩子声明。 + + :param value: 钩子声明字符串 + :return: 校验后的钩子声明字符串 + """ + if value is None or HOOK_PATTERN.match(value): + return value + raise ValueError('生命周期钩子必须使用 : 格式') + + +class PluginJobManifest(BaseModel): + """ + 插件定时任务声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + id: str = Field(description='插件内定时任务唯一标识') + name: str | None = Field(default=None, description='定时任务展示名称') + callable: str = Field(description='定时任务调用目标,格式为 Python 模块路径加函数名') + trigger: Literal['cron'] = Field(default='cron', description='定时任务触发器类型') + cron_expression: str = Field(alias='cronExpression', description='cron 执行表达式') + args: list[str] = Field(default_factory=list, description='位置参数列表') + kwargs: dict[str, Any] = Field(default_factory=dict, description='关键字参数字典') + enabled: bool = Field(default=True, description='是否默认启用任务') + description: str = Field(default='', description='任务说明') + misfire_policy: Literal['1', '2', '3'] = Field( + default='3', + alias='misfirePolicy', + description='计划执行错误策略(1立即执行 2执行一次 3放弃执行)', + ) + concurrent: Literal['0', '1'] = Field(default='1', description='是否并发执行(0允许 1禁止)') + executor: Literal['default', 'processpool'] = Field(default='default', description='任务执行器') + + @field_validator('id') + @classmethod + def validate_job_id(cls, value: str) -> str: + """ + 校验插件任务 ID。 + + :param value: 插件任务 ID + :return: 校验后的插件任务 ID + """ + if not JOB_ID_PATTERN.match(value): + raise ValueError('插件任务 id 只能包含小写字母、数字、下划线和中划线,并且必须以小写字母开头') + return value + + @field_validator('callable') + @classmethod + def validate_callable(cls, value: str) -> str: + """ + 校验插件任务调用目标格式。 + + :param value: 任务调用目标 + :return: 校验后的任务调用目标 + """ + if not JOB_CALLABLE_PATTERN.match(value): + raise ValueError('插件任务 callable 必须使用 . 格式') + return value + + @field_validator('name') + @classmethod + def validate_name(cls, value: str | None) -> str | None: + """ + 校验插件任务展示名称。 + + :param value: 任务展示名称 + :return: 校验后的任务展示名称 + """ + if value is not None and not value.strip(): + raise ValueError('插件任务 name 不能为空白字符串') + return value + + @field_validator('cron_expression') + @classmethod + def validate_cron_expression_text(cls, value: str) -> str: + """ + 校验插件任务 cron 表达式文本。 + + :param value: cron 表达式 + :return: 校验后的 cron 表达式 + """ + if not value.strip(): + raise ValueError('插件任务 cronExpression 不能为空') + return value + + @model_validator(mode='after') + def fill_name(self) -> 'PluginJobManifest': + """ + 填充任务展示名称默认值。 + + :return: 填充后的任务声明 + """ + if self.name is None: + self.name = self.id + return self + + +class BackendManifest(BaseModel): + """ + 插件后端声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + module: str = Field(description='插件后端 Python 模块路径') + routers: RouterManifest = Field(default_factory=RouterManifest, description='插件路由声明') + health: HealthManifest = Field(default_factory=HealthManifest, description='插件健康检查声明') + migrations: list[str] = Field(default_factory=list, description='数据库迁移脚本列表') + seeds: list[str] = Field(default_factory=list, description='初始化数据脚本列表') + hooks: HookManifest = Field(default_factory=HookManifest, description='生命周期钩子声明') + jobs: list[PluginJobManifest] = Field(default_factory=list, description='插件定时任务声明列表') + + @field_validator('module') + @classmethod + def validate_module(cls, value: str) -> str: + """ + 校验插件后端模块路径。 + + :param value: 后端模块路径 + :return: 校验后的后端模块路径 + """ + if not value.strip(): + raise ValueError('backend.module 不能为空') + return value + + @model_validator(mode='after') + def validate_unique_jobs(self) -> 'BackendManifest': + """ + 校验插件定时任务 ID 唯一性。 + + :return: 校验后的后端声明 + """ + job_id_counts = Counter(job.id for job in self.jobs) + duplicated_job_ids = {job_id for job_id, count in job_id_counts.items() if count > 1} + if duplicated_job_ids: + raise ValueError(f'插件任务 id 不能重复:{"、".join(sorted(duplicated_job_ids))}') + return self + + +class PluginDependencyManifest(BaseModel): + """ + 插件间依赖声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + id: str = Field(description='依赖插件ID') + version: str | None = Field(default=None, description='依赖插件版本约束,例如 >=1.0.0') + description: str = Field(default='', description='依赖说明') + + @model_validator(mode='before') + @classmethod + def normalize_dependency(cls, value: Any) -> dict[str, Any]: + """ + 兼容字符串和对象两种插件依赖声明。 + + :param value: 原始插件依赖声明 + :return: 规范化后的插件依赖声明 + """ + if isinstance(value, str): + matched_dependency = PLUGIN_DEPENDENCY_PATTERN.match(value) + if not matched_dependency: + raise ValueError('插件依赖字符串必须使用 格式') + plugin_id, operator, version = matched_dependency.groups() + return {'id': plugin_id, 'version': f'{operator}{version}' if operator and version else None} + if isinstance(value, dict): + return value + raise ValueError('插件依赖必须是字符串或对象') + + @field_validator('id') + @classmethod + def validate_plugin_id(cls, value: str) -> str: + """ + 校验依赖插件 ID。 + + :param value: 依赖插件 ID + :return: 校验后的依赖插件 ID + """ + return validate_plugin_id_value(value, field_name='依赖插件 id') + + @field_validator('version') + @classmethod + def validate_version_constraint(cls, value: str | None) -> str | None: + """ + 校验插件版本约束。 + + :param value: 版本约束 + :return: 校验后的版本约束 + """ + if value is None: + return value + if not VERSION_CONSTRAINT_PATTERN.match(value): + raise ValueError('依赖插件 version 必须是版本号或带操作符的版本约束') + return value + + +class DependencyManifest(BaseModel): + """ + 插件依赖声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + python: list[str] = Field(default_factory=list, description='Python 依赖声明') + npm: list[str] = Field(default_factory=list, description='前端 npm 依赖声明') + npm_dev: list[str] = Field(default_factory=list, alias='npmDev', description='前端 npm 开发依赖声明') + plugins: list[PluginDependencyManifest] = Field(default_factory=list, description='插件间依赖声明') + + @field_validator('python') + @classmethod + def validate_python_requirements(cls, value: list[str]) -> list[str]: + """ + 校验 Python 依赖符合 PEP 508。 + + :param value: Python 依赖声明列表 + :return: 校验后的 Python 依赖声明列表 + """ + for requirement in value: + cls._validate_python_requirement(requirement) + return value + + @staticmethod + def _validate_python_requirement(requirement: str) -> None: + """ + 校验单条 Python 依赖声明。 + + :param requirement: Python 依赖声明 + :return: None + """ + try: + PythonRequirementParser.parse(requirement) + except InvalidRequirement as exc: + raise ValueError(f'Python 依赖声明无效:{requirement}') from exc + + @model_validator(mode='after') + def validate_unique_plugins(self) -> 'DependencyManifest': + """ + 校验依赖插件 ID 唯一性。 + + :return: 校验后的依赖声明 + """ + plugin_id_counts = Counter(plugin.id for plugin in self.plugins) + duplicated_plugin_ids = {plugin_id for plugin_id, count in plugin_id_counts.items() if count > 1} + if duplicated_plugin_ids: + raise ValueError(f'依赖插件 id 不能重复:{"、".join(sorted(duplicated_plugin_ids))}') + return self + + +class PluginMetadataManifest(BaseModel): + """ + 插件展示元数据声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + category: str = Field(default='', description='插件分类') + tags: list[str] = Field(default_factory=list, description='插件标签') + author: str = Field(default='', description='插件作者') + license: str = Field(default='', description='插件许可证') + homepage: str = Field(default='', description='插件主页') + repository: str = Field(default='', description='插件代码仓库') + documentation: str = Field(default='', description='插件文档地址') + + @field_validator('category', 'author', 'license') + @classmethod + def strip_text(cls, value: str) -> str: + """ + 清理展示元数据文本。 + + :param value: 原始文本 + :return: 清理后的文本 + """ + return value.strip() + + @field_validator('homepage', 'repository', 'documentation') + @classmethod + def validate_url(cls, value: str, info: ValidationInfo) -> str: + """ + 校验展示元数据地址。 + + :param value: 原始地址 + :param info: 字段信息 + :return: 校验后的地址 + """ + value = value.strip() + if value and not EXTERNAL_URL_PATTERN.match(value): + raise ValueError(f'metadata.{info.field_name} 必须是 http/https 地址') + return value + + @field_validator('tags') + @classmethod + def validate_tags(cls, value: list[str]) -> list[str]: + """ + 清理并校验插件标签。 + + :param value: 原始标签列表 + :return: 清理后的标签列表 + """ + tags = [tag.strip() for tag in value if tag.strip()] + tag_counts = Counter(tags) + duplicated_tags = {tag for tag, count in tag_counts.items() if count > 1} + if duplicated_tags: + raise ValueError(f'插件 metadata.tags 不能重复:{"、".join(sorted(duplicated_tags))}') + return tags + + +class CompatibilityManifest(BaseModel): + """ + 插件平台兼容性声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + backend_version: str | None = Field(default=None, alias='backendVersion', description='后端版本约束') + frontend_version: str | None = Field(default=None, alias='frontendVersion', description='前端版本约束') + python_version: str | None = Field(default=None, alias='pythonVersion', description='Python 版本约束') + node_version: str | None = Field(default=None, alias='nodeVersion', description='Node.js 版本约束') + databases: list[Literal['mysql', 'postgresql']] = Field(default_factory=list, description='支持的数据库类型') + + @field_validator('backend_version', 'frontend_version', 'python_version', 'node_version') + @classmethod + def validate_version_constraint(cls, value: str | None) -> str | None: + """ + 校验平台兼容性版本约束。 + + :param value: 版本约束 + :return: 校验后的版本约束 + """ + if value is None: + return value + if not VERSION_CONSTRAINT_PATTERN.match(value): + raise ValueError('compatibility 版本约束必须是版本号或带操作符的版本约束') + return value + + @field_validator('databases') + @classmethod + def validate_unique_databases( + cls, value: list[Literal['mysql', 'postgresql']] + ) -> list[Literal['mysql', 'postgresql']]: + """ + 校验数据库类型声明不重复。 + + :param value: 数据库类型列表 + :return: 校验后的数据库类型列表 + """ + database_counts = Counter(value) + duplicated_databases = {database for database, count in database_counts.items() if count > 1} + if duplicated_databases: + raise ValueError(f'compatibility.databases 不能重复:{"、".join(sorted(duplicated_databases))}') + return value + + +class PluginResourceManifest(BaseModel): + """ + 插件资源声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + static: list[str] = Field(default_factory=list, description='插件静态资源相对路径列表') + uploads: list[str] = Field(default_factory=list, description='插件上传资源相对路径列表') + temp: list[str] = Field(default_factory=list, description='插件临时资源相对路径列表') + + @field_validator('static', 'uploads', 'temp') + @classmethod + def validate_resource_paths(cls, value: list[str]) -> list[str]: + """ + 校验资源相对路径列表。 + + :param value: 资源相对路径列表 + :return: 校验后的资源相对路径列表 + """ + path_counts = Counter(value) + duplicated_paths = {path for path, count in path_counts.items() if count > 1} + if duplicated_paths: + raise ValueError(f'插件资源路径不能重复:{"、".join(sorted(duplicated_paths))}') + invalid_paths = [path for path in value if not RESOURCE_RELATIVE_PATH_PATTERN.match(path)] + if invalid_paths: + raise ValueError(f'插件资源路径必须是安全相对路径:{"、".join(sorted(invalid_paths))}') + return value + + +class PluginPermissionManifest(BaseModel): + """ + 插件权限声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + code: str = Field(validation_alias=AliasChoices('code', 'perms', 'permission'), description='权限标识') + name: str | None = Field(default=None, description='权限展示名称') + description: str = Field(default='', description='权限说明') + + @model_validator(mode='before') + @classmethod + def normalize_string_permission(cls, value: Any) -> Any: + """ + 兼容 permissions 字符串简写。 + + :param value: 原始权限声明 + :return: 权限对象声明 + """ + if isinstance(value, str): + return {'code': value} + return value + + @field_validator('code') + @classmethod + def validate_code(cls, value: str) -> str: + """ + 校验权限标识。 + + :param value: 权限标识 + :return: 校验后的权限标识 + """ + if not PERMISSION_PATTERN.match(value): + raise ValueError(f'插件权限格式无效:{value}') + return value + + @field_validator('name') + @classmethod + def validate_name(cls, value: str | None) -> str | None: + """ + 校验权限展示名称。 + + :param value: 权限展示名称 + :return: 校验后的权限展示名称 + """ + if value is None: + return value + value = value.strip() + if not value: + raise ValueError('插件权限 name 不能为空白字符串') + return value + + @field_validator('description') + @classmethod + def validate_description(cls, value: str) -> str: + """ + 清理权限说明。 + + :param value: 权限说明 + :return: 清理后的权限说明 + """ + return value.strip() + + +class PluginConfigOptionManifest(BaseModel): + """ + 插件配置选项声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + label: str = Field(description='配置选项展示名称') + value: PluginConfigValue = Field(description='配置选项值') + + +class PluginConfigItemManifest(BaseModel): + """ + 插件配置项声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + key: str = Field(description='配置键名') + label: str | None = Field(default=None, description='配置展示名称') + type: Literal['string', 'number', 'boolean', 'select', 'textarea', 'password', 'json'] = Field( + default='string', + description='配置值类型', + ) + default: PluginConfigValue = Field(default=None, description='默认配置值') + required: bool = Field(default=False, description='是否必填') + group: str = Field(default='default', description='配置分组') + order: int = Field(default=0, description='配置排序值') + placeholder: str = Field(default='', description='配置输入占位提示') + min_value: float | None = Field(default=None, alias='min', description='数字配置最小值') + max_value: float | None = Field(default=None, alias='max', description='数字配置最大值') + pattern: str | None = Field(default=None, description='字符串配置正则表达式') + description: str = Field(default='', description='配置说明') + options: list[PluginConfigOptionManifest] = Field(default_factory=list, description='配置选项列表') + secret: bool = Field(default=False, description='是否敏感配置') + + @field_validator('type', mode='before') + @classmethod + def normalize_type(cls, value: str | None) -> str: + """ + 规范化配置项类型。 + + :param value: 原始配置项类型 + :return: 规范化后的配置项类型 + """ + if value is None: + return 'string' + type_aliases = { + 'text': 'string', + 'switch': 'boolean', + } + + return type_aliases.get(str(value), str(value)) + + @field_validator('key') + @classmethod + def validate_key(cls, value: str) -> str: + """ + 校验配置键名。 + + :param value: 配置键名 + :return: 校验后的配置键名 + """ + if not CONFIG_KEY_PATTERN.match(value): + raise ValueError('配置 key 只能包含小写字母、数字、下划线、中划线和点号,并且必须以小写字母开头') + return value + + @model_validator(mode='after') + def fill_label(self) -> 'PluginConfigItemManifest': + """ + 填充配置展示名称默认值。 + + :return: 填充后的配置项声明 + """ + if self.label is None: + self.label = self.key + return self + + @model_validator(mode='after') + def validate_options_and_default(self) -> 'PluginConfigItemManifest': + """ + 校验配置选项、默认值和增强约束。 + + :return: 校验后的配置项声明 + """ + self._validate_select_options() + self._validate_default_value() + self._validate_number_range() + self._validate_pattern() + return self + + @field_validator('group', 'placeholder') + @classmethod + def validate_optional_text(cls, value: str) -> str: + """ + 校验配置增强文本字段。 + + :param value: 配置增强文本字段 + :return: 校验后的配置增强文本字段 + """ + return value.strip() + + def _validate_select_options(self) -> None: + """ + 校验 select 配置选项。 + + :return: None + """ + if self.type != 'select': + return + if not self.options: + raise ValueError(f'配置 {self.key} 类型为 select 时必须声明 options') + option_values = [option.value for option in self.options] + option_value_counts = Counter(option_values) + duplicated_values = {value for value, count in option_value_counts.items() if count > 1} + if duplicated_values: + raise ValueError(f'配置 {self.key} 的 options.value 不能重复') + if self.default is not None and self.default not in option_values: + raise ValueError(f'配置 {self.key} 的 default 必须位于 options.value 中') + + def _validate_default_value(self) -> None: + """ + 校验配置默认值类型。 + + :return: None + """ + if self.default is None: + return + if self.type == 'boolean' and not isinstance(self.default, bool): + raise ValueError(f'配置 {self.key} 的 default 必须是布尔值') + if self.type == 'number' and (not isinstance(self.default, int | float) or isinstance(self.default, bool)): + raise ValueError(f'配置 {self.key} 的 default 必须是数字') + if self.type == 'json' and not isinstance(self.default, dict | list): + raise ValueError(f'配置 {self.key} 的 default 必须是对象或数组') + + def _validate_number_range(self) -> None: + """ + 校验数字配置范围。 + + :return: None + """ + if self.min_value is not None and self.max_value is not None and self.min_value > self.max_value: + raise ValueError(f'配置 {self.key} 的 min 不能大于 max') + if self.default is None or self.type != 'number': + return + if self.min_value is not None and self.default < self.min_value: + raise ValueError(f'配置 {self.key} 的 default 不能小于 min') + if self.max_value is not None and self.default > self.max_value: + raise ValueError(f'配置 {self.key} 的 default 不能大于 max') + + def _validate_pattern(self) -> None: + """ + 校验字符串配置正则表达式。 + + :return: None + """ + if not self.pattern: + return + try: + pattern = re.compile(self.pattern) + except re.error as exc: + raise ValueError(f'配置 {self.key} 的 pattern 不是合法正则表达式:{exc}') from exc + if ( + self.default is not None + and self.type in {'string', 'textarea', 'password'} + and not pattern.fullmatch(str(self.default)) + ): + raise ValueError(f'配置 {self.key} 的 default 不匹配 pattern') + + +class PluginConfigManifest(BaseModel): + """ + 插件配置声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + items: list[PluginConfigItemManifest] = Field(default_factory=list, description='插件配置项列表') + + @model_validator(mode='before') + @classmethod + def normalize_config(cls, value: Any) -> dict[str, Any]: + """ + 兼容多种插件配置声明写法。 + + :param value: 原始配置声明 + :return: 规范化后的配置声明 + """ + if value is None: + return {'items': []} + if isinstance(value, list): + return {'items': value} + if isinstance(value, dict) and 'items' in value: + return value + if isinstance(value, dict): + items = [] + for key, item in value.items(): + if isinstance(item, dict): + items.append({'key': key, **item}) + else: + items.append({'key': key, 'default': item}) + return {'items': items} + raise ValueError('插件 config 必须是对象或配置项列表') + + @model_validator(mode='after') + def validate_unique_keys(self) -> 'PluginConfigManifest': + """ + 校验配置键名唯一性。 + + :return: 校验后的配置声明 + """ + key_counts = Counter(item.key for item in self.items) + duplicated_keys = {key for key, count in key_counts.items() if count > 1} + if duplicated_keys: + raise ValueError(f'插件配置 key 不能重复:{"、".join(sorted(duplicated_keys))}') + return self + + +class PluginMenuManifest(BaseModel): + """ + 插件菜单声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + name: str = Field(description='菜单名称') + path: str = Field(description='路由路径') + component: str = Field(default='Layout', description='组件路径') + perms: str = Field(default='', description='权限标识') + icon: str = Field(default='#', description='菜单图标') + type: Literal['M', 'C', 'F'] = Field(default='C', description='菜单类型') + order_num: int = Field(default=0, alias='orderNum', description='显示顺序') + query: str | None = Field(default=None, description='路由参数') + route_name: str | None = Field(default=None, alias='routeName', description='路由名称') + is_frame: Literal[0, 1] = Field(default=1, alias='isFrame', description='是否为外链(0是 1否)') + is_cache: Literal[0, 1] = Field(default=0, alias='isCache', description='是否缓存(0缓存 1不缓存)') + visible: Literal['0', '1'] = Field(default='0', description='是否显示') + status: Literal['0', '1'] = Field(default='0', description='菜单状态') + children: list['PluginMenuManifest'] = Field(default_factory=list, description='子菜单列表') + + @field_validator('name', 'path') + @classmethod + def validate_required_text(cls, value: str) -> str: + """ + 校验菜单必填文本。 + + :param value: 菜单文本 + :return: 校验后的菜单文本 + """ + if not value.strip(): + raise ValueError('菜单 name 和 path 不能为空') + return value + + @field_validator('query', 'route_name') + @classmethod + def validate_optional_text(cls, value: str | None) -> str | None: + """ + 校验菜单可选文本。 + + :param value: 菜单可选文本 + :return: 校验后的菜单可选文本 + """ + if value is None: + return value + return value.strip() + + @field_validator('perms') + @classmethod + def validate_perms(cls, value: str) -> str: + """ + 校验菜单权限标识。 + + :param value: 菜单权限标识 + :return: 校验后的菜单权限标识 + """ + if not value: + return value + if not PERMISSION_PATTERN.match(value): + raise ValueError('菜单 perms 必须使用小写冒号分隔格式,例如 demo:list') + return value + + @field_validator('path') + @classmethod + def validate_path(cls, value: str) -> str: + """ + 校验菜单路径格式。 + + :param value: 菜单路径 + :return: 校验后的菜单路径 + """ + if not ROUTE_PATH_PATTERN.match(value) and not EXTERNAL_URL_PATTERN.match(value): + raise ValueError('菜单 path 必须是插件路由路径或 http/https 外链地址') + return value + + @model_validator(mode='after') + def validate_component_for_menu_type(self) -> 'PluginMenuManifest': + """ + 校验菜单组件和菜单类型的基础关系。 + + :return: 校验后的菜单声明 + """ + if self.type != 'F' and not self.component.strip(): + raise ValueError('非按钮菜单 component 不能为空') + if self.is_frame == 0 and not EXTERNAL_URL_PATTERN.match(self.path): + raise ValueError('外链菜单 path 必须是 http/https 地址') + if self.is_frame == 1 and EXTERNAL_URL_PATTERN.match(self.path): + raise ValueError('非外链菜单 path 不能是 http/https 地址') + return self + + +class FrontendDeliveryManifest(BaseModel): + """ + 插件前端交付声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + type: Literal['none', 'source'] = Field(default='none', description='前端交付类型') + build_required: bool = Field(default=False, alias='buildRequired', description='前端资源是否需要构建后生效') + + +class FrontendManifest(BaseModel): + """ + 插件前端声明。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + plugin_id: str | None = Field(default=None, alias='pluginId', description='前端插件目录名') + base_path: str | None = Field(default=None, alias='basePath', description='前端基础路径') + views_path: str = Field(default='views', alias='viewsPath', description='前端视图目录') + api_path: str = Field(default='api', alias='apiPath', description='前端 API 目录') + delivery: FrontendDeliveryManifest = Field(default_factory=FrontendDeliveryManifest, description='前端交付声明') + menus: list[PluginMenuManifest] = Field(default_factory=list, description='菜单声明列表') + + @field_validator('plugin_id') + @classmethod + def validate_plugin_id(cls, value: str | None) -> str | None: + """ + 校验前端插件目录名格式。 + + :param value: 前端插件目录名 + :return: 校验后的前端插件目录名 + """ + return validate_plugin_id_value(value, field_name='frontend.pluginId') if value is not None else value + + @field_validator('base_path') + @classmethod + def validate_base_path(cls, value: str | None) -> str | None: + """ + 校验前端基础路径格式。 + + :param value: 前端基础路径 + :return: 校验后的前端基础路径 + """ + if value is not None and not FRONTEND_RELATIVE_PATH_PATTERN.match(value): + raise ValueError('frontend.basePath 只能包含小写字母、数字、下划线、中划线和正斜杠') + return value + + @field_validator('views_path', 'api_path') + @classmethod + def validate_relative_path(cls, value: str) -> str: + """ + 校验前端相对目录路径格式。 + + :param value: 前端相对目录路径 + :return: 校验后的前端相对目录路径 + """ + if not FRONTEND_RELATIVE_PATH_PATTERN.match(value): + raise ValueError('frontend viewsPath/apiPath 只能包含小写字母、数字、下划线、中划线和正斜杠') + return value + + +class PluginManifest(BaseModel): + """ + 插件主清单。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + manifest_version: Literal[1] = Field( + default=SUPPORTED_MANIFEST_VERSION, + alias='manifestVersion', + description='插件清单版本', + ) + id: str = Field(description='插件唯一标识') + name: str = Field(description='插件名称') + version: str = Field(description='插件版本') + description: str = Field(default='', description='插件说明') + metadata: PluginMetadataManifest = Field(default_factory=PluginMetadataManifest, description='插件展示元数据') + backend: BackendManifest = Field(description='后端声明') + frontend: FrontendManifest = Field(default_factory=FrontendManifest, description='前端声明') + permissions: list[PluginPermissionManifest] = Field(default_factory=list, description='权限声明列表') + dependencies: DependencyManifest = Field(default_factory=DependencyManifest, description='依赖声明') + compatibility: CompatibilityManifest = Field(default_factory=CompatibilityManifest, description='平台兼容性声明') + resources: PluginResourceManifest = Field(default_factory=PluginResourceManifest, description='插件资源声明') + config: PluginConfigManifest = Field(default_factory=PluginConfigManifest, description='插件配置声明') + + @field_validator('id') + @classmethod + def validate_plugin_id(cls, value: str) -> str: + """ + 校验插件 ID。 + + :param value: 插件 ID + :return: 校验后的插件 ID + """ + validate_plugin_id_value(value, field_name='插件 id') + if value in RESERVED_PLUGIN_IDS: + raise ValueError(f'插件 id 不能使用保留名称:{value}') + return value + + @field_validator('name', 'version') + @classmethod + def validate_required_text(cls, value: str) -> str: + """ + 校验插件必填文本。 + + :param value: 插件文本 + :return: 校验后的插件文本 + """ + if not value.strip(): + raise ValueError('插件 name 和 version 不能为空') + return value + + @field_validator('permissions') + @classmethod + def validate_permissions(cls, value: list[PluginPermissionManifest]) -> list[PluginPermissionManifest]: + """ + 校验插件权限声明。 + + :param value: 权限声明列表 + :return: 校验后的权限声明列表 + """ + permission_code_counts = Counter(permission.code for permission in value) + duplicated_permissions = {permission for permission, count in permission_code_counts.items() if count > 1} + if duplicated_permissions: + raise ValueError(f'插件权限不能重复:{"、".join(sorted(duplicated_permissions))}') + return value + + @property + def permission_codes(self) -> list[str]: + """ + 获取权限标识列表。 + + :return: 权限标识列表 + """ + return [permission.code for permission in self.permissions] + + @property + def permission_name_map(self) -> dict[str, str]: + """ + 获取权限展示名称映射。 + + :return: 权限标识到展示名称的映射 + """ + return {permission.code: permission.name for permission in self.permissions if permission.name} + + @model_validator(mode='after') + def fill_frontend_defaults(self) -> 'PluginManifest': + """ + 填充前端声明默认值并执行跨字段校验。 + + :return: 填充默认值并完成校验后的插件清单 + """ + if self.frontend.plugin_id is None: + self.frontend.plugin_id = self.id + if self.frontend.base_path is None: + self.frontend.base_path = self.id + self._fill_frontend_delivery_defaults() + self._validate_backend_module() + self._validate_frontend_plugin_id() + self._validate_plugin_dependencies() + self._validate_permission_prefixes() + self._validate_menu_permissions_declared() + self._validate_menu_components() + self._validate_unique_menu_paths() + self._validate_job_callable_prefixes() + return self + + def _validate_backend_module(self) -> None: + """ + 校验后端模块路径与插件 ID 一致。 + + :return: None + """ + expected_module = f'plugins.{self.id}' + if self.backend.module != expected_module: + raise ValueError(f'backend.module 必须为 {expected_module}') + + def _validate_frontend_plugin_id(self) -> None: + """ + 校验前端插件目录名与插件 ID 一致。 + + :return: None + """ + if self.frontend.plugin_id != self.id: + raise ValueError(f'frontend.pluginId 必须与插件 id 一致:{self.id}') + + def _validate_permission_prefixes(self) -> None: + """ + 校验插件权限标识必须使用插件 ID 前缀。 + + :return: None + """ + expected_prefix = f'{self.id}:' + permission_set = set(self.permission_codes) | PluginMenuTree.collect_permissions(self.frontend.menus) + invalid_permissions = sorted( + permission for permission in permission_set if not permission.startswith(expected_prefix) + ) + if invalid_permissions: + raise ValueError(f'插件权限必须使用 {expected_prefix} 前缀:{"、".join(invalid_permissions)}') + + def _fill_frontend_delivery_defaults(self) -> None: + """ + 根据前端声明和 npm 依赖补全前端交付默认值。 + + :return: None + """ + has_frontend_resources = bool(self.frontend.menus or self.dependencies.npm or self.dependencies.npm_dev) + if has_frontend_resources and self.frontend.delivery.type == 'none': + self.frontend.delivery.type = 'source' + if self.frontend.delivery.type == 'source': + self.frontend.delivery.build_required = True + + def _validate_plugin_dependencies(self) -> None: + """ + 校验插件间依赖声明。 + + :return: None + """ + if any(plugin.id == self.id for plugin in self.dependencies.plugins): + raise ValueError('插件不能依赖自身') + + def _validate_menu_permissions_declared(self) -> None: + """ + 校验菜单权限已在顶层 permissions 中声明。 + + :return: None + """ + menu_permissions = PluginMenuTree.collect_permissions(self.frontend.menus) + undeclared_permissions = sorted(menu_permissions - set(self.permission_codes)) + if undeclared_permissions: + raise ValueError(f'菜单权限必须在 permissions 中声明:{"、".join(undeclared_permissions)}') + + def _validate_menu_components(self) -> None: + """ + 校验菜单组件路径符合插件前端视图或核心布局组件约定。 + + :return: None + """ + for menu in PluginMenuTree.flatten(self.frontend.menus): + if menu.type == 'F' and not menu.component: + continue + if menu.component in CORE_FRONTEND_COMPONENTS: + continue + if not menu.component.startswith('plugin/'): + raise ValueError(f'菜单 component 只允许核心布局组件或插件视图路径:{menu.component}') + self._validate_plugin_component(menu.component) + + def _validate_plugin_component(self, component: str) -> None: + """ + 校验插件视图组件路径。 + + :param component: 菜单组件路径 + :return: None + """ + parts = component.split('/') + if len(parts) < MIN_PLUGIN_COMPONENT_PARTS or parts[0] != 'plugin': + raise ValueError(f'插件组件路径必须使用 plugin// 格式:{component}') + if parts[1] != self.frontend.plugin_id: + raise ValueError(f'插件组件路径必须引用当前插件目录:{component}') + invalid_segments = [part for part in parts[2:] if not PLUGIN_COMPONENT_SEGMENT_PATTERN.match(part)] + if invalid_segments: + raise ValueError(f'插件组件路径包含非法片段:{component}') + + def _validate_unique_menu_paths(self) -> None: + """ + 校验菜单路由路径在当前插件内不重复。 + + :return: None + """ + route_paths = PluginMenuTree.collect_route_paths(self.frontend.menus) + route_path_counts = Counter(route_paths) + duplicated_paths = {route_path for route_path, count in route_path_counts.items() if count > 1} + if duplicated_paths: + raise ValueError(f'菜单 path 不能重复:{"、".join(sorted(duplicated_paths))}') + + def _validate_job_callable_prefixes(self) -> None: + """ + 校验插件定时任务 callable 必须归属当前插件模块。 + + 定时任务由系统调度器直接 importlib 导入执行,不经过 PluginCallableLoader 的模块归属 + 校验,因此必须在清单校验阶段限制 callable 模块前缀,防止插件执行任意模块的函数。 + + :return: None + """ + expected_prefix = f'plugins.{self.id}.' + invalid_callables = [job.callable for job in self.backend.jobs if not job.callable.startswith(expected_prefix)] + if invalid_callables: + raise ValueError(f'插件任务 callable 必须使用 {expected_prefix} 前缀:{"、".join(invalid_callables)}') + + +PluginMenuManifest.model_rebuild() + + +class PluginManifestFactory: + """ + 插件清单工厂。 + + 使用工厂模式集中处理从原始字典到 `PluginManifest` 的构造,避免发现器直接依赖 + Pydantic 的创建细节。 + """ + + @classmethod + def create(cls, raw_manifest: dict[str, Any]) -> PluginManifest: + """ + 从原始字典创建插件清单。 + + :param raw_manifest: YAML 解析得到的原始清单字典 + :return: 插件清单模型 + """ + return PluginManifest.model_validate(raw_manifest) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/__init__.py b/ruoyi-fastapi-backend/plugins/core/runtime/__init__.py new file mode 100644 index 0000000..b2a3666 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/__init__.py @@ -0,0 +1,15 @@ +""" +插件运行时能力分层包。 +""" + +from plugins.core.runtime.health import PluginHealthChecker, PluginHealthContext, PluginHealthResult +from plugins.core.runtime.hooks import PluginHookContext, PluginHookResult, PluginHookRunner + +__all__ = [ + 'PluginHealthChecker', + 'PluginHealthContext', + 'PluginHealthResult', + 'PluginHookContext', + 'PluginHookResult', + 'PluginHookRunner', +] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/application.py b/ruoyi-fastapi-backend/plugins/core/runtime/application.py new file mode 100644 index 0000000..695b2fa --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/application.py @@ -0,0 +1,484 @@ +import asyncio +import json +import time +from collections.abc import Awaitable, Callable, Mapping +from functools import cache + +from fastapi import FastAPI + +from common.constant import LockConstant +from plugins.core.runtime.startup import PluginRuntimeStartupManager +from plugins.core.runtime.startup_coordination import PluginStartupGenerationResolver +from utils.log_util import logger + +from .service.lifecycle_lock import NoopPluginLifecycleLock, PluginLifecycleLock + + +class PluginApplicationRuntime: + """ + 应用入口侧插件运行时适配器。 + + server.py 只依赖该适配器提供的高层扩展点,插件实体导入、二次建表、启动资源安装、 + 多 worker ready barrier 和生命周期钩子等细节留在插件运行时内部。 + """ + + def __init__( + self, + startup_manager: PluginRuntimeStartupManager | None = None, + *, + ready_key: str = LockConstant.PLUGIN_STARTUP_READY_KEY, + ready_expire_seconds: int = LockConstant.PLUGIN_STARTUP_READY_EXPIRE_SECONDS, + failed_expire_seconds: int = LockConstant.PLUGIN_STARTUP_FAILED_EXPIRE_SECONDS, + ready_wait_timeout_seconds: int = LockConstant.PLUGIN_STARTUP_READY_WAIT_TIMEOUT_SECONDS, + ready_wait_interval_seconds: int = LockConstant.PLUGIN_STARTUP_READY_WAIT_INTERVAL_SECONDS, + lifecycle_lock: PluginLifecycleLock | None = None, + startup_generation: str | None = None, + ) -> None: + """ + 初始化插件应用运行时适配器。 + + :param startup_manager: 插件启动协调器 + :param ready_key: 插件启动 ready 标记 key + :param ready_expire_seconds: ready 标记过期时间 + :param failed_expire_seconds: 失败标记过期时间 + :param ready_wait_timeout_seconds: 等待 ready 超时时间 + :param ready_wait_interval_seconds: 等待 ready 轮询间隔 + :param lifecycle_lock: 插件生命周期全局锁 + :param startup_generation: 测试或部署显式注入的启动代际 + :return: None + """ + self.startup_manager = startup_manager or PluginRuntimeStartupManager() + self.ready_key = ready_key + self.ready_expire_seconds = ready_expire_seconds + self.failed_expire_seconds = failed_expire_seconds + self.ready_wait_timeout_seconds = ready_wait_timeout_seconds + self.ready_wait_interval_seconds = ready_wait_interval_seconds + self.lifecycle_lock = lifecycle_lock or NoopPluginLifecycleLock() + self.startup_generation = startup_generation + + def bind_app(self, app: FastAPI) -> None: + """ + 绑定插件运行时到 FastAPI app。 + + :param app: FastAPI对象 + :return: None + """ + app.state.plugin_application_runtime = self + self.startup_manager.bind_app(app) + + def prepare_metadata(self, app: FastAPI) -> None: + """ + 准备插件平台自身元数据。 + + :param app: FastAPI对象 + :return: None + """ + self._ensure_bound(app) + self.startup_manager.import_builtin_entities() + + async def startup( + self, + app: FastAPI, + *, + create_tables: Callable[[], Awaitable[None]], + ) -> None: + """ + 启动插件运行时。 + + 同一发布代际只有一个 worker 能在全局生命周期锁内执行写入;其他 worker 等待 + 当前代际 ready 后仅执行进程本地激活。插件启动不再依赖长期持有的调度器主节点锁。 + + :param app: FastAPI对象 + :param create_tables: 数据库建表回调 + :return: None + """ + self._ensure_bound(app) + generation = self.resolve_startup_generation() + app.state.plugin_startup_generation = generation + app.state.plugin_startup_write_enabled = False + + ready_state = await self.get_startup_state(app, generation) + logger.bind( + startup_generation=generation, + plugin_startup_role='reader', + ready_status=ready_state.get('status', 'missing'), + ).debug('🔎 开始检查插件启动代际 ready 状态') + activated, stale_ready_reported = await self._process_startup_state( + app, + generation, + ready_state, + stale_ready_reported=False, + ready_message='复用当前代际插件 ready,开始本地激活', + failed_message='检测到当前代际插件启动 failed marker', + ) + if activated: + return + + deadline = time.monotonic() + self.ready_wait_timeout_seconds + wait_reported = False + while time.monotonic() < deadline: + async with self.lifecycle_lock.lock('__runtime__', f'startup:{generation}') as lock_result: + if lock_result.acquired: + ready_state = await self.get_startup_state(app, generation) + activated, stale_ready_reported = await self._process_startup_state( + app, + generation, + ready_state, + stale_ready_reported=stale_ready_reported, + ready_message='锁内复用当前代际插件 ready,开始本地激活', + failed_message='锁内检测到当前代际插件启动 failed marker', + ) + if activated: + return + logger.bind( + startup_generation=generation, + plugin_startup_role='writer', + ready_status=ready_state.get('status', 'missing'), + ).info('🎯 当前 worker 成为插件启动 writer') + await self._run_startup_writer(app, generation, create_tables) + return + + ready_state = await self.get_startup_state(app, generation) + activated, stale_ready_reported = await self._process_startup_state( + app, + generation, + ready_state, + stale_ready_reported=stale_ready_reported, + ready_message='等待结束,复用当前代际插件 ready', + failed_message='等待期间检测到当前代际插件启动 failed marker', + ) + if activated: + return + if not wait_reported: + logger.bind( + startup_generation=generation, + plugin_startup_role='reader', + ready_status=ready_state.get('status', 'missing'), + ).debug('⏳ 等待插件启动 writer 发布当前代际 ready') + wait_reported = True + await asyncio.sleep(self.ready_wait_interval_seconds) + + logger.bind( + startup_generation=generation, + plugin_startup_role='reader', + ready_status=ready_state.get('status', 'missing'), + ).error('❌ 等待插件启动 ready 超时') + raise TimeoutError(f'等待插件启动代际 {generation} ready 超时') + + async def _process_startup_state( + self, + app: FastAPI, + generation: str, + ready_state: Mapping[str, str], + *, + stale_ready_reported: bool, + ready_message: str, + failed_message: str, + ) -> tuple[bool, bool]: + """ + 处理一次插件启动状态读取结果。 + + :param app: FastAPI对象 + :param generation: 插件启动代际 + :param ready_state: 当前ready状态 + :param stale_ready_reported: 是否已记录过期ready告警 + :param ready_message: 复用ready时的日志 + :param failed_message: 发现failed marker时的日志 + :return: 是否已完成reader激活、是否已记录过期ready告警 + """ + ready_status = ready_state.get('status', 'missing') + reader_logger = logger.bind( + startup_generation=generation, + plugin_startup_role='reader', + ready_status=ready_status, + ) + if ready_status == 'success': + if await self.requires_startup_write(): + if not stale_ready_reported: + logger.bind( + startup_generation=generation, + plugin_startup_role='reader', + ready_status=ready_status, + stale_ready_ignored=True, + startup_write_required=True, + startup_write_reason='missing_default_plugin_state', + ).warning('⚠️ 数据库默认插件状态缺失,忽略当前代际旧 ready 标记') + return False, True + logger.bind( + startup_generation=generation, + plugin_startup_role='reader', + ready_status=ready_status, + plugin_install_lifecycle='skipped', + plugin_resource_sync='skipped', + plugin_entity_table_sync='skipped', + ).info( + '⏭️ 复用插件 ready 状态,跳过启动期全局写入:' + 'plugin_install_lifecycle=skipped,' + 'plugin_resource_sync=skipped,' + 'plugin_entity_table_sync=skipped' + ) + reader_logger.debug(f'🔄 {ready_message}') + await self._activate_startup_reader(app, generation) + return True, stale_ready_reported + if ready_status == 'failed': + reader_logger.error(f'❌ {failed_message}') + raise RuntimeError(self._build_startup_failure_message(ready_state)) + return False, stale_ready_reported + + async def requires_startup_write(self) -> bool: + """ + 校验 ready 标记对应的数据库状态是否仍然完整。 + + 测试替身或旧适配器未实现该能力时保持原有行为;生产启动管理器会检查 + 默认启用插件是否仍具备安装状态。 + + :return: 是否必须重新执行启动期写入 + """ + checker = getattr(type(self.startup_manager), 'requires_startup_write', None) + if checker is None: + return False + return bool(await self.startup_manager.requires_startup_write()) + + async def _run_startup_writer( + self, + app: FastAPI, + generation: str, + create_tables: Callable[[], Awaitable[None]], + ) -> None: + """ + 执行当前代际唯一的插件启动写入。 + + :param app: FastAPI对象 + :param generation: 插件启动代际 + :param create_tables: 数据库建表回调 + :return: None + """ + app.state.plugin_startup_write_enabled = True + writer_logger = logger.bind( + startup_generation=generation, + plugin_startup_role='writer', + ready_status='initializing', + ) + writer_logger.info('🔄 开始同步插件全局启动资源') + await self.clear_startup_ready(app, generation) + try: + await self.startup_manager.prepare_enabled_plugins(app, startup_write_enabled=True) + await create_tables() + await self.startup_manager.activate_enabled_plugins(app, startup_write_enabled=True) + except Exception as exc: + await self.mark_startup_failed(app, generation, exc) + logger.bind( + startup_generation=generation, + plugin_startup_role='writer', + ready_status='failed', + ).exception('❌ 插件全局启动资源同步失败,已写入 failed marker') + raise + await self.mark_startup_ready(app, generation) + logger.bind( + startup_generation=generation, + plugin_startup_role='writer', + ready_status='success', + ).info('✅ 插件全局启动资源同步完成') + self._log_startup_completed(app, generation, role='writer') + + async def _activate_startup_reader(self, app: FastAPI, generation: str) -> None: + """ + 在当前 worker 执行无全局写入的本地插件激活。 + + :param app: FastAPI对象 + :param generation: 插件启动代际 + :return: None + """ + reader_logger = logger.bind( + startup_generation=generation, + plugin_startup_role='reader', + ready_status='success', + ) + reader_logger.debug('🔄 开始执行插件本地实体、Hook和路由激活') + await self.startup_manager.prepare_enabled_plugins(app, startup_write_enabled=False) + await self.startup_manager.activate_enabled_plugins(app, startup_write_enabled=False) + reader_logger.debug('✅ 插件本地实体、Hook和路由激活完成') + self._log_startup_completed(app, generation, role='reader') + + @staticmethod + def _log_startup_completed(app: FastAPI, generation: str, *, role: str) -> None: + """ + 为每个成功启动的worker输出一条稳定可见的插件运行时摘要。 + + writer和reader内部过程仍按原有级别记录;该INFO摘要用于避免命中ready后 + 只有DEBUG日志而表现为插件日志随机缺失。 + + :param app: FastAPI对象 + :param generation: 插件启动代际 + :param role: 当前worker的插件启动角色 + :return: None + """ + plugin_registry = getattr(app.state, 'plugin_registry', None) + enabled_plugin_ids = ( + sorted(str(plugin.plugin_id) for plugin in plugin_registry.list_enabled_plugins()) + if plugin_registry is not None + else [] + ) + enabled_plugins = ','.join(enabled_plugin_ids) or 'none' + logger.bind( + startup_generation=generation, + plugin_startup_role=role, + ready_status='success', + enabled_plugin_ids=enabled_plugin_ids, + enabled_plugin_count=len(enabled_plugin_ids), + ).info(f'✅ 插件运行时启动完成:role={role},generation={generation[:8]},enabled={enabled_plugins}') + + async def shutdown(self, app: FastAPI) -> None: + """ + 关闭插件运行时。 + + :param app: FastAPI对象 + :return: None + """ + self._ensure_bound(app) + startup_write_enabled = bool(getattr(app.state, 'plugin_startup_write_enabled', False)) + await self.startup_manager.shutdown(app, startup_write_enabled=startup_write_enabled) + + async def clear_startup_ready(self, app: FastAPI, generation: str | None = None) -> None: + """ + 清除指定代际的插件启动 ready 标记。 + + :param app: FastAPI对象 + :param generation: 插件启动代际 + :return: None + """ + resolved_generation = generation or self.resolve_startup_generation() + await app.state.redis.delete(self.build_ready_key(resolved_generation)) + + async def mark_startup_ready(self, app: FastAPI, generation: str | None = None) -> None: + """ + 标记指定代际已完成插件启动写入。 + + :param app: FastAPI对象 + :param generation: 插件启动代际 + :return: None + """ + resolved_generation = generation or self.resolve_startup_generation() + await app.state.redis.set( + self.build_ready_key(resolved_generation), + json.dumps({'generation': resolved_generation, 'status': 'success'}, ensure_ascii=False), + ex=self.ready_expire_seconds, + ) + writer_logger = logger.bind( + startup_generation=resolved_generation, + plugin_startup_role='writer', + ready_status='success', + ) + failed_plugin_ids = getattr(app.state, 'plugin_dependency_failed_plugin_ids', set()) + if failed_plugin_ids: + writer_logger.warning( + f'⚠️ 插件启动协调已完成,依赖检查失败插件已隔离:{"、".join(sorted(failed_plugin_ids))}' + ) + return + writer_logger.info('✅ 插件启动资源已就绪') + + async def mark_startup_failed(self, app: FastAPI, generation: str, error: Exception) -> None: + """ + 标记指定插件启动代际初始化失败。 + + :param app: FastAPI对象 + :param generation: 插件启动代际 + :param error: 启动异常 + :return: None + """ + await app.state.redis.set( + self.build_ready_key(generation), + json.dumps( + { + 'generation': generation, + 'status': 'failed', + 'error': str(error)[:1000], + }, + ensure_ascii=False, + ), + ex=self.failed_expire_seconds, + ) + + async def get_startup_state(self, app: FastAPI, generation: str) -> dict[str, str]: + """ + 读取指定代际的插件启动状态。 + + :param app: FastAPI对象 + :param generation: 插件启动代际 + :return: 启动状态 + """ + raw_state = await app.state.redis.get(self.build_ready_key(generation)) + if not raw_state: + return {} + try: + state = json.loads(raw_state) + except (TypeError, json.JSONDecodeError): + return {} + if not isinstance(state, dict) or state.get('generation') != generation: + return {} + return {str(key): str(value) for key, value in state.items()} + + def resolve_startup_generation(self) -> str: + """ + 解析当前插件启动代际。 + + :return: 插件启动代际 + """ + if self.startup_generation: + return self.startup_generation + generation_resolver = PluginStartupGenerationResolver(self.startup_manager.builder.backend_root) + self.startup_generation = generation_resolver.resolve() + return self.startup_generation + + def build_ready_key(self, generation: str) -> str: + """ + 构建代际隔离的 ready key。 + + :param generation: 插件启动代际 + :return: Redis key + """ + return f'{self.ready_key}:{generation}' + + @staticmethod + def _build_startup_failure_message(state: Mapping[str, str]) -> str: + """ + 构建启动失败错误消息。 + + :param state: 启动状态 + :return: 错误消息 + """ + error_message = state.get('error') or '未知错误' + return f'插件启动代际 {state.get("generation", "")} 初始化失败:{error_message}' + + def _ensure_bound(self, app: FastAPI) -> None: + """ + 确保插件运行时已绑定到 app。 + + :param app: FastAPI对象 + :return: None + """ + if getattr(app.state, 'plugin_application_runtime', None) is not self: + self.bind_app(app) + + +@cache +def get_plugin_application_runtime() -> PluginApplicationRuntime: + """ + 获取应用插件运行时适配器。 + + :return: 应用插件运行时适配器 + """ + from plugins.core.management.service.startup_gateway import ( # noqa: PLC0415 + PluginManagementRouteStateGateway, + PluginManagementStartupGateway, + ) + from plugins.core.runtime.service.lifecycle_lock import RedisPluginLifecycleLock # noqa: PLC0415 + + startup_manager = PluginRuntimeStartupManager( + management_gateway=PluginManagementStartupGateway(), + route_state_gateway=PluginManagementRouteStateGateway(), + ) + return PluginApplicationRuntime( + startup_manager=startup_manager, + lifecycle_lock=RedisPluginLifecycleLock(), + ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py b/ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py new file mode 100644 index 0000000..f74c8b3 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/bootstrap.py @@ -0,0 +1,126 @@ +from pathlib import Path + +from pydantic import BaseModel, Field + +from plugins.core.discovery.registry import PluginRegistry +from plugins.core.discovery.scanner import ( + DiscoveredPlugin, + PluginDiscoveryError, + PluginScanner, +) +from plugins.core.environment import PluginRuntimeEnvironmentService +from plugins.core.runtime.entities import EntityModuleImporter +from plugins.core.types import PluginStateRecord +from utils.log_util import logger + + +class PluginEntityImportFailure(BaseModel): + """ + 插件实体导入失败结果。 + """ + + plugin_id: str = Field(description='插件ID') + error_message: str = Field(description='错误信息') + + +class PluginEntityImportResult(BaseModel): + """ + 插件实体导入结果。 + """ + + imported_count: int = Field(default=0, description='成功导入实体模块数量') + failures: list[PluginEntityImportFailure] = Field(default_factory=list, description='实体导入失败结果列表') + + +class PluginRuntimeBuilder: + """ + 插件运行时构建器。 + + 使用 Builder 模式封装插件发现和运行时注册表构建过程,便于后续逐步加入数据库状态、 + 依赖检查和生命周期处理。 + """ + + def __init__(self, backend_root: Path | str | None = None) -> None: + """ + 初始化插件运行时构建器。 + + :param backend_root: 后端项目根目录 + """ + self.backend_root = Path(backend_root) if backend_root else Path(__file__).resolve().parents[3] + self.plugins_root = self.backend_root / 'plugins' + self.frontend_plugins_root = Path( + PluginRuntimeEnvironmentService(backend_root=self.backend_root).get_frontend_plugins_dir() + ) + self.entity_importer = EntityModuleImporter(self.backend_root) + self._discovered_plugins: list[DiscoveredPlugin] | None = None + self._discovery_errors: list[PluginDiscoveryError] | None = None + + def discover_plugins(self) -> list[DiscoveredPlugin]: + """ + 发现后端插件。 + + 单个损坏插件不会影响其他正常插件,失败明细记录在 :attr:`discovery_errors` 中, + 便于上层日志和监控。根目录配置类错误(目录不存在、文件名非法等)仍以异常形式抛出。 + + :return: 已发现插件列表 + """ + if self._discovered_plugins is not None: + return self._discovered_plugins + discovery_result = PluginScanner(self.plugins_root).discover_with_errors() + self._discovered_plugins = discovery_result.plugins + self._discovery_errors = discovery_result.errors + for error in discovery_result.errors: + logger.error(f'❌ 插件扫描失败,已隔离损坏插件:目录={error.plugin_dir},错误:{error.error_message}') + + return self._discovered_plugins + + @property + def discovery_errors(self) -> list[PluginDiscoveryError]: + """ + 获取插件扫描错误明细。 + + :return: 插件扫描错误明细列表 + """ + if self._discovery_errors is None: + self.discover_plugins() + return self._discovery_errors or [] + + def build_registry(self, database_plugins: list[PluginStateRecord] | None = None) -> PluginRegistry: + """ + 构建插件运行时注册表。 + + :param database_plugins: 数据库插件状态列表 + :return: 插件运行时注册表 + """ + return PluginRegistry.build(self.discover_plugins(), database_plugins) + + def import_builtin_entities(self) -> None: + """ + 导入内置业务模块实体。 + + :return: None + """ + self.entity_importer.import_builtin_entities() + + def import_plugin_entities(self, plugin_registry: PluginRegistry) -> PluginEntityImportResult: + """ + 导入启用插件实体。 + + :param plugin_registry: 插件运行时注册表 + :return: 插件实体导入结果 + """ + import_result = PluginEntityImportResult() + for plugin in plugin_registry.list_enabled_plugins(): + entity_do_dir = plugin.backend_path / 'entity' / 'do' + if not entity_do_dir.is_dir(): + continue + try: + imported_modules = self.entity_importer.import_entity_dirs([entity_do_dir], strict=True) + import_result.imported_count += len(imported_modules) + except Exception as exc: + logger.exception(f'❌ 插件实体导入失败:{plugin.plugin_id},错误:{exc}') + import_result.failures.append( + PluginEntityImportFailure(plugin_id=plugin.plugin_id, error_message=str(exc)) + ) + + return import_result diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/callable.py b/ruoyi-fastapi-backend/plugins/core/runtime/callable.py new file mode 100644 index 0000000..eefa663 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/callable.py @@ -0,0 +1,146 @@ +import importlib +import importlib.util +import sys +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType + +from plugins.core.discovery.scanner import DiscoveredPlugin + + +@dataclass(frozen=True) +class LoadedPluginCallable: + """ + 已加载的插件 callable。 + + :param module_name: 完整 Python 模块名 + :param callable_name: callable 名称 + :param callable_object: callable 对象 + """ + + module_name: str + callable_name: str + callable_object: Callable[..., object] + + +class PluginCallableLoader: + """ + 插件 callable 加载器。 + + 使用 Loader 模式统一插件生命周期钩子、健康检查等 manifest callable 的模块边界校验、 + 本地文件加载和 Python import fallback。 + """ + + def __init__(self, discovered_plugin: DiscoveredPlugin, *, label: str) -> None: + """ + 初始化插件 callable 加载器。 + + :param discovered_plugin: 已发现插件对象 + :param label: callable 类型标签,用于错误信息 + :return: None + """ + self.discovered_plugin = discovered_plugin + self.label = label + + def load(self, callable_path: str) -> LoadedPluginCallable: + """ + 加载插件 callable。 + + :param callable_path: manifest 中声明的 callable 路径,格式为 : + :return: 已加载 callable + """ + module_path, callable_name = callable_path.split(':', maxsplit=1) + module_name = self.resolve_module_name(module_path) + module = self.import_module(module_name) + callable_object = getattr(module, callable_name, None) + if not callable(callable_object): + raise RuntimeError(f'插件 {self.label} 不存在或不可调用:{callable_path}') + + return LoadedPluginCallable( + module_name=module_name, + callable_name=callable_name, + callable_object=callable_object, + ) + + def resolve_module_name(self, module_path: str) -> str: + """ + 解析插件 callable 模块名。 + + :param module_path: manifest 中声明的模块路径 + :return: 完整 Python 模块名 + """ + plugin_module = self.discovered_plugin.manifest.backend.module + if module_path == plugin_module or module_path.startswith(f'{plugin_module}.'): + return module_path + if module_path.startswith('plugins.'): + raise RuntimeError(f'{self.label} 只能指向当前插件模块:{module_path}') + + return f'{plugin_module}.{module_path}' + + def import_module(self, module_name: str) -> ModuleType: + """ + 导入插件 callable 模块。 + + :param module_name: 完整 Python 模块名 + :return: Python 模块对象 + """ + module_file = self.resolve_module_file(module_name) + if module_file: + return self.load_module_from_file(module_name, module_file) + + backend_root = self.resolve_backend_root() + backend_root_text = str(backend_root) + path_inserted = backend_root_text not in sys.path + if path_inserted: + sys.path.insert(0, backend_root_text) + try: + return importlib.import_module(module_name) + finally: + if path_inserted: + sys.path.remove(backend_root_text) + + def resolve_module_file(self, module_name: str) -> Path | None: + """ + 解析当前插件 callable 模块文件。 + + :param module_name: 完整 Python 模块名 + :return: 模块文件路径 + """ + plugin_module = self.discovered_plugin.manifest.backend.module + if module_name != plugin_module and not module_name.startswith(f'{plugin_module}.'): + return None + + relative_module = module_name.removeprefix(plugin_module).lstrip('.') + if not relative_module: + module_file = self.discovered_plugin.backend_path / '__init__.py' + else: + module_file = self.discovered_plugin.backend_path.joinpath(*relative_module.split('.')).with_suffix('.py') + + return module_file if module_file.is_file() else None + + @staticmethod + def load_module_from_file(module_name: str, module_file: Path) -> ModuleType: + """ + 从文件加载插件 callable 模块。 + + :param module_name: 完整 Python 模块名 + :param module_file: 模块文件路径 + :return: Python 模块对象 + """ + module_spec = importlib.util.spec_from_file_location(module_name, module_file) + if module_spec is None or module_spec.loader is None: + raise RuntimeError(f'插件 callable 模块加载失败:{module_file}') + module = importlib.util.module_from_spec(module_spec) + sys.modules[module_name] = module + module_spec.loader.exec_module(module) + + return module + + def resolve_backend_root(self) -> Path: + """ + 解析后端工程根目录。 + + :return: 后端工程根目录 + """ + return self.discovered_plugin.backend_path.resolve().parents[1] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/entities.py b/ruoyi-fastapi-backend/plugins/core/runtime/entities.py new file mode 100644 index 0000000..a643db0 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/entities.py @@ -0,0 +1,130 @@ +import importlib +import sys +from pathlib import Path +from types import ModuleType + +from utils.log_util import logger + + +class EntityModuleImporter: + """ + 实体模块导入器。 + + 使用 Template Method 模式统一实体文件扫描、模块名转换和导入流程,确保 + SQLAlchemy 在执行 `Base.metadata.create_all` 前可以收集到已启用模块的 DO 元数据。 + """ + + def __init__(self, backend_root: Path | str | None = None) -> None: + """ + 初始化实体模块导入器。 + + :param backend_root: 后端项目根目录 + """ + self.backend_root = Path(backend_root) if backend_root else Path(__file__).resolve().parents[3] + self.backend_root = self.backend_root.resolve() + if str(self.backend_root) not in sys.path: + sys.path.insert(0, str(self.backend_root)) + self._extend_plugins_package_path() + + def import_builtin_entities(self) -> list[ModuleType]: + """ + 导入内置业务模块实体。 + + :return: 已导入模块列表 + """ + return self.import_entity_dirs(self.get_builtin_entity_do_dirs(), strict=True) + + def import_plugin_entities(self, entity_do_dirs: list[Path]) -> list[ModuleType]: + """ + 导入启用插件实体。 + + :param entity_do_dirs: 插件 entity/do 目录列表 + :return: 已导入模块列表 + """ + return self.import_entity_dirs(entity_do_dirs, strict=False) + + def get_builtin_entity_do_dirs(self) -> list[Path]: + """ + 获取内置业务模块 entity/do 目录列表。 + + :return: 内置 entity/do 目录列表 + """ + return sorted( + entity_do_dir for entity_do_dir in self.backend_root.glob('module_*/entity/do') if entity_do_dir.is_dir() + ) + + def import_entity_dirs(self, entity_do_dirs: list[Path], strict: bool) -> list[ModuleType]: + """ + 导入指定 entity/do 目录下的实体模块。 + + :param entity_do_dirs: entity/do 目录列表 + :param strict: 是否在导入失败时抛出异常 + :return: 已导入模块列表 + """ + imported_modules = [] + for entity_file in self._find_entity_files(entity_do_dirs): + imported_module = self._import_entity_file(entity_file, strict) + if imported_module: + imported_modules.append(imported_module) + + return imported_modules + + def _extend_plugins_package_path(self) -> None: + """ + 扩展已加载 plugins 包的搜索路径。 + + :return: None + """ + plugins_root = self.backend_root / 'plugins' + if not plugins_root.is_dir(): + return + try: + plugins_package = importlib.import_module('plugins') + except ModuleNotFoundError: + return + package_path = getattr(plugins_package, '__path__', None) + if package_path is not None and str(plugins_root) not in package_path: + package_path.append(str(plugins_root)) + + def _find_entity_files(self, entity_do_dirs: list[Path]) -> list[Path]: + """ + 查找实体文件。 + + :param entity_do_dirs: entity/do 目录列表 + :return: 实体文件列表 + """ + entity_files = [] + for entity_do_dir in entity_do_dirs: + if entity_do_dir.is_dir(): + entity_files.extend(entity_do_dir.glob('[!_]*.py')) + + return sorted(entity_file.resolve() for entity_file in entity_files) + + def _import_entity_file(self, entity_file: Path, strict: bool) -> ModuleType | None: + """ + 导入单个实体文件。 + + :param entity_file: 实体文件路径 + :param strict: 是否在导入失败时抛出异常 + :return: 已导入模块,导入失败且非严格模式时返回 None + """ + module_name = self._to_module_name(entity_file) + try: + return importlib.import_module(module_name) + except Exception as exc: + logger.exception(f'❌ 实体模块导入失败:{module_name},错误:{exc}') + if strict: + raise + return None + + def _to_module_name(self, entity_file: Path) -> str: + """ + 将实体文件路径转换为 Python 模块路径。 + + :param entity_file: 实体文件路径 + :return: Python 模块路径 + """ + relative_path = entity_file.relative_to(self.backend_root) + module_path = relative_path.with_suffix('') + + return '.'.join(module_path.parts) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/health.py b/ruoyi-fastapi-backend/plugins/core/runtime/health.py new file mode 100644 index 0000000..c2155de --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/health.py @@ -0,0 +1,241 @@ +import asyncio +import inspect +from dataclasses import dataclass +from time import perf_counter +from typing import Any, cast + +from common.constant import PluginRuntimeConstant +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.runtime.callable import LoadedPluginCallable, PluginCallableLoader +from plugins.core.types import JSONObject + + +@dataclass(frozen=True) +class PluginHealthContext: + """ + 插件健康检查上下文。 + + :param plugin_id: 插件 ID + :param discovered_plugin: 已发现插件对象 + :param app: FastAPI 应用对象 + :param query_db: orm对象 + """ + + plugin_id: str + discovered_plugin: DiscoveredPlugin + app: Any | None = None + query_db: Any | None = None + + +@dataclass(frozen=True) +class PluginHealthResult: + """ + 插件健康检查结果。 + + :param plugin_id: 插件 ID + :param ok: 健康检查是否通过 + :param status: 健康状态 + :param message: 健康检查说明 + :param checker: 健康检查声明路径 + :param duration_ms: 检查耗时毫秒 + :param details: 健康检查扩展详情 + :param error: 健康检查异常信息 + """ + + plugin_id: str + ok: bool + status: str + message: str + checker: str | None + duration_ms: float + details: JSONObject + error: str | None = None + + +class PluginHealthChecker: + """ + 插件健康检查器。 + + 使用 Strategy + Command Runner 思路执行插件 manifest 中声明的只读健康检查 callable。 + """ + + def __init__( + self, + discovered_plugin: DiscoveredPlugin, + *, + timeout_seconds: float | None = None, + ) -> None: + """ + 初始化插件健康检查器。 + + :param discovered_plugin: 已发现插件对象 + :param timeout_seconds: 异步健康检查超时时间 + :return: None + """ + self.discovered_plugin = discovered_plugin + self.timeout_seconds = timeout_seconds or PluginRuntimeConstant.PLUGIN_HEALTH_TIMEOUT_SECONDS + + async def check(self, *, app: Any | None = None, query_db: Any | None = None) -> PluginHealthResult: + """ + 执行插件健康检查。 + + :param app: FastAPI 应用对象 + :param query_db: orm对象 + :return: 插件健康检查结果 + """ + checker_path = self.discovered_plugin.manifest.backend.health.checker + started_at = perf_counter() + if not checker_path: + return self._build_result( + ok=True, + status='unknown', + message='插件未声明健康检查', + checker=None, + started_at=started_at, + ) + + try: + checker_callable = self._load_checker_callable(checker_path) + context = PluginHealthContext( + plugin_id=self.discovered_plugin.manifest.id, + discovered_plugin=self.discovered_plugin, + app=app, + query_db=query_db, + ) + raw_result = await self._invoke_checker_with_timeout(checker_callable, context) + return self._normalize_result(raw_result, checker_path, started_at) + except asyncio.TimeoutError: + return self._build_result( + ok=False, + status='timeout', + message='插件健康检查执行超时', + checker=checker_path, + started_at=started_at, + error=f'插件健康检查执行超时,超过 {self.timeout_seconds} 秒', + ) + except Exception as exc: + return self._build_result( + ok=False, + status='error', + message='插件健康检查执行失败', + checker=checker_path, + started_at=started_at, + error=str(exc), + ) + + def _load_checker_callable(self, checker_path: str) -> LoadedPluginCallable: + """ + 加载健康检查 callable。 + + :param checker_path: 健康检查声明路径 + :return: 健康检查 callable + """ + return PluginCallableLoader(self.discovered_plugin, label='健康检查').load(checker_path) + + async def _invoke_checker_with_timeout( + self, + checker_callable: LoadedPluginCallable, + context: PluginHealthContext, + ) -> object: + """ + 在超时约束内执行健康检查 callable。 + + :param checker_callable: 已加载的健康检查 callable + :param context: 健康检查上下文 + :return: 健康检查原始返回值 + """ + callable_object = checker_callable.callable_object + if not inspect.iscoroutinefunction(callable_object): + raise TypeError('插件健康检查必须使用 async def 声明,平台不会在线程中执行不可终止的同步检查') + raw_result = self._invoke_checker(checker_callable, context) + if inspect.isawaitable(raw_result): + return await asyncio.wait_for(raw_result, timeout=self.timeout_seconds) + + return raw_result + + @staticmethod + def _invoke_checker(checker_callable: LoadedPluginCallable, context: PluginHealthContext) -> object: + """ + 调用健康检查 callable。 + + :param checker_callable: 已加载的健康检查 callable + :param context: 健康检查上下文 + :return: 健康检查原始返回值 + """ + callable_object = checker_callable.callable_object + signature = inspect.signature(callable_object) + if not signature.parameters: + return callable_object() + + return callable_object(context) + + def _normalize_result(self, raw_result: object, checker_path: str, started_at: float) -> PluginHealthResult: + """ + 规范化健康检查返回值。 + + :param raw_result: 健康检查原始返回值 + :param checker_path: 健康检查声明路径 + :param started_at: 检查开始时间 + :return: 插件健康检查结果 + """ + if isinstance(raw_result, dict): + raw_details = raw_result.get('details') + ok = bool(raw_result.get('ok', raw_result.get('healthy', True))) + return self._build_result( + ok=ok, + status=str(raw_result.get('status') or ('healthy' if ok else 'unhealthy')), + message=str(raw_result.get('message') or ('插件健康检查通过' if ok else '插件健康检查未通过')), + checker=checker_path, + started_at=started_at, + details=cast('JSONObject', raw_details) if isinstance(raw_details, dict) else {}, + ) + if isinstance(raw_result, bool): + return self._build_result( + ok=raw_result, + status='healthy' if raw_result else 'unhealthy', + message='插件健康检查通过' if raw_result else '插件健康检查未通过', + checker=checker_path, + started_at=started_at, + ) + + return self._build_result( + ok=True, + status='healthy', + message='插件健康检查通过', + checker=checker_path, + started_at=started_at, + ) + + def _build_result( + self, + *, + ok: bool, + status: str, + message: str, + checker: str | None, + started_at: float, + details: JSONObject | None = None, + error: str | None = None, + ) -> PluginHealthResult: + """ + 构建健康检查结果。 + + :param ok: 健康检查是否通过 + :param status: 健康状态 + :param message: 健康检查说明 + :param checker: 健康检查声明路径 + :param started_at: 检查开始时间 + :param details: 健康检查扩展详情 + :param error: 健康检查异常信息 + :return: 插件健康检查结果 + """ + return PluginHealthResult( + plugin_id=self.discovered_plugin.manifest.id, + ok=ok, + status=status, + message=message, + checker=checker, + duration_ms=round((perf_counter() - started_at) * 1000, 3), + details=details or {}, + error=error, + ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py b/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py new file mode 100644 index 0000000..ff9391d --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/hooks.py @@ -0,0 +1,174 @@ +import asyncio +import inspect +from dataclasses import dataclass +from typing import Any + +from common.constant import PluginRuntimeConstant +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.runtime.callable import LoadedPluginCallable, PluginCallableLoader +from utils.log_util import logger + + +@dataclass(frozen=True) +class PluginHookContext: + """ + 插件生命周期钩子上下文。 + + :param plugin_id: 插件 ID + :param hook_name: 钩子名称 + :param discovered_plugin: 已发现插件对象 + :param app: FastAPI 应用对象 + :param query_db: orm对象 + :param startup_write_enabled: 当前 worker 是否允许执行启动期全局写入 + :param startup_generation: 插件启动代际 + :param plugin_startup_role_at_creation: Hook 创建时的插件启动角色 + """ + + plugin_id: str + hook_name: str + discovered_plugin: DiscoveredPlugin + app: Any | None = None + query_db: Any | None = None + startup_write_enabled: bool = True + startup_generation: str | None = None + plugin_startup_role_at_creation: str = 'writer' + + +@dataclass(frozen=True) +class PluginHookResult: + """ + 插件生命周期钩子执行结果。 + + :param hook_name: 钩子名称 + :param hook_path: 钩子声明路径 + :param module_name: 钩子模块名 + """ + + hook_name: str + hook_path: str + module_name: str + + +class PluginHookRunner: + """ + 插件生命周期钩子运行器。 + + 使用 Command Runner 模式解析并执行 `plugin.yaml` 中声明的生命周期钩子。 + 钩子必须使用 async def 声明,签名支持 `hook()` 或 `hook(context)`。 + + 同步函数在线程池超时后无法被 Python 安全终止,可能在生命周期事务已经回滚后 + 继续产生副作用,因此平台拒绝执行同步生命周期钩子。 + """ + + def __init__( + self, + discovered_plugin: DiscoveredPlugin, + *, + timeout_seconds: float | None = None, + ) -> None: + """ + 初始化插件生命周期钩子运行器。 + + :param discovered_plugin: 已发现插件对象 + :param timeout_seconds: 钩子执行超时时间 + """ + self.discovered_plugin = discovered_plugin + self.timeout_seconds = timeout_seconds or PluginRuntimeConstant.PLUGIN_HOOK_TIMEOUT_SECONDS + + async def run( + self, + hook_name: str, + *, + app: Any | None = None, + query_db: Any | None = None, + startup_write_enabled: bool = True, + ) -> PluginHookResult | None: + """ + 执行指定生命周期钩子。 + + :param hook_name: 钩子名称,例如 `on_install` + :param app: FastAPI 应用对象 + :param query_db: orm对象 + :param startup_write_enabled: 当前 worker 是否允许执行启动期全局写入 + :return: 钩子执行结果,未声明时返回 None + """ + hook_path = getattr(self.discovered_plugin.manifest.backend.hooks, hook_name, None) + if not hook_path: + return None + + hook_callable = self._load_hook_callable(hook_path) + startup_generation = None + if app is not None and getattr(app, 'state', None) is not None: + startup_generation = getattr(app.state, 'plugin_startup_generation', None) + plugin_startup_role = 'writer' if startup_write_enabled else 'reader' + context = PluginHookContext( + plugin_id=self.discovered_plugin.manifest.id, + hook_name=hook_name, + discovered_plugin=self.discovered_plugin, + app=app, + query_db=query_db, + startup_write_enabled=startup_write_enabled, + startup_generation=startup_generation, + plugin_startup_role_at_creation=plugin_startup_role, + ) + with logger.contextualize( + plugin_id=context.plugin_id, + plugin_hook=hook_name, + startup_generation=startup_generation, + plugin_startup_role_at_creation=plugin_startup_role, + startup_write_enabled=startup_write_enabled, + origin_hook=hook_name, + created_during_startup=hook_name == 'on_startup', + ): + logger.debug('🔄 开始执行插件生命周期钩子') + try: + await self._invoke_hook_with_timeout(hook_callable, context) + except asyncio.TimeoutError as exc: + raise TimeoutError(f'生命周期钩子执行超时:{hook_name},超过 {self.timeout_seconds} 秒') from exc + logger.debug('✅ 插件生命周期钩子执行完成') + + return PluginHookResult(hook_name=hook_name, hook_path=hook_path, module_name=hook_callable.module_name) + + def _load_hook_callable(self, hook_path: str) -> LoadedPluginCallable: + """ + 加载生命周期钩子函数。 + + :param hook_path: 钩子声明路径 + :return: 已加载的生命周期钩子函数 + """ + return PluginCallableLoader(self.discovered_plugin, label='生命周期钩子').load(hook_path) + + async def _invoke_hook_with_timeout( + self, + hook_callable: LoadedPluginCallable, + context: PluginHookContext, + ) -> None: + """ + 在超时约束内执行生命周期钩子。 + + :param hook_callable: 已加载的钩子函数 + :param context: 钩子上下文 + :return: None + """ + callable_object = hook_callable.callable_object + if not inspect.iscoroutinefunction(callable_object): + raise TypeError('生命周期钩子必须使用 async def 声明,平台不会在线程中执行不可终止的同步钩子') + result = self._invoke_hook(hook_callable, context) + if inspect.isawaitable(result): + await asyncio.wait_for(result, timeout=self.timeout_seconds) + + @staticmethod + def _invoke_hook(hook_callable: LoadedPluginCallable, context: PluginHookContext) -> object: + """ + 调用生命周期钩子函数。 + + :param hook_callable: 已加载的钩子函数 + :param context: 钩子上下文 + :return: 钩子函数返回值 + """ + callable_object = hook_callable.callable_object + signature = inspect.signature(callable_object) + if not signature.parameters: + return callable_object() + + return callable_object(context) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/result.py b/ruoyi-fastapi-backend/plugins/core/runtime/result.py new file mode 100644 index 0000000..92d8731 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/result.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping + + +@dataclass(frozen=True) +class PluginOperationResult: + """ + 插件操作结果视图。 + """ + + payload: Mapping[str, object] + ok: bool + message: str + + @classmethod + def from_payload( + cls, + payload: Mapping[str, object], + *, + default_message: str = '插件操作完成', + ) -> PluginOperationResult: + """ + 从插件运行时 payload 构建结果视图。 + + :param payload: 插件运行时负载 + :param default_message: 默认消息 + :return: 插件操作结果视图 + """ + return cls( + payload=payload, + ok=bool(payload.get('ok', False)), + message=str(payload.get('message') or default_message), + ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/route_guard.py b/ruoyi-fastapi-backend/plugins/core/runtime/route_guard.py new file mode 100644 index 0000000..ff9c707 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/route_guard.py @@ -0,0 +1,82 @@ +from typing import Protocol, runtime_checkable + +from fastapi import Depends, params +from sqlalchemy.ext.asyncio import AsyncSession + +from config.get_db import get_db +from exceptions.exception import PermissionException + + +@runtime_checkable +class PluginRouteStateGateway(Protocol): + """ + 插件路由状态读取端口。 + """ + + async def is_plugin_enabled(self, db: AsyncSession, plugin_id: str) -> bool: + """ + 判断插件路由是否允许访问。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: 插件是否启用 + """ + + +class UnavailablePluginRouteStateGateway: + """ + 不可用的插件路由状态读取端口。 + """ + + @staticmethod + async def is_plugin_enabled(db: AsyncSession, plugin_id: str) -> bool: + """ + 判断插件路由是否允许访问。 + + :param db: orm对象 + :param plugin_id: 插件ID + :return: 插件是否启用 + :raises RuntimeError: 默认端口不提供状态读取能力 + """ + raise RuntimeError('插件路由状态适配器不可用') + + +class CheckPluginEnabled: + """ + 校验插件路由运行时启用状态。 + """ + + def __init__(self, plugin_id: str, state_gateway: PluginRouteStateGateway | None = None) -> None: + """ + 初始化插件路由状态校验器。 + + :param plugin_id: 插件ID + :param state_gateway: 插件路由状态读取端口 + """ + self.plugin_id = plugin_id + self.state_gateway = state_gateway or UnavailablePluginRouteStateGateway() + + async def __call__(self, db: AsyncSession = Depends(get_db)) -> bool: + """ + 执行插件启用状态校验。 + + :param db: orm对象 + :return: 是否通过 + """ + if not await self.state_gateway.is_plugin_enabled(db, self.plugin_id): + raise PermissionException(data='', message='插件未启用,接口不可访问') + return True + + +def PluginEnabledDependency( # noqa: N802 + plugin_id: str, + state_gateway: PluginRouteStateGateway | None = None, +) -> params.Depends: + """ + 插件路由启用状态依赖。 + + :param plugin_id: 插件ID + :param state_gateway: 插件路由状态读取端口 + :return: 插件启用状态依赖 + """ + return Depends(CheckPluginEnabled(plugin_id, state_gateway)) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/__init__.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/__init__.py new file mode 100644 index 0000000..a5edb45 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/__init__.py @@ -0,0 +1,5 @@ +from .facade import PluginRuntimeService + +__all__ = [ + 'PluginRuntimeService', +] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/audit.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/audit.py new file mode 100644 index 0000000..ef7bf2e --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/audit.py @@ -0,0 +1,70 @@ +from collections.abc import Mapping + +from plugins.core.runtime.support import PluginRuntimePayloadBuilder +from utils.log_util import logger + +from .dependency_container import PluginRuntimeDependencies + + +class PluginAuditUseCase: + """ + 插件操作审计和失败状态记录 use case。 + """ + + def __init__(self, dependencies: PluginRuntimeDependencies) -> None: + """ + 初始化插件审计 use case。 + + :param dependencies: 插件运行时依赖容器 + """ + self.dependencies = dependencies + + async def record_plugin_operation_log( + self, + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> None: + """ + 记录插件操作审计日志。 + + dry-run 不调用该方法,保持预演无写入语义。 + + :param payload: 插件操作结果负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: None + """ + await self.dependencies.audit_gateway.add_plugin_operation_log( + dict(payload), + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + + async def record_plugin_failure_state( + self, + payload: Mapping[str, object], + default_message: str, + ) -> None: + """ + 记录插件操作失败状态。 + + 失败状态写入仅作为可恢复运行状态提示,不改变原始操作返回结果。 + + :param payload: 插件操作返回负载 + :param default_message: 缺省失败信息 + :return: None + """ + if payload.get('ok') is not False: + return + plugin_id = payload.get('pluginId') + if not isinstance(plugin_id, str) or not plugin_id: + return + + error_message = PluginRuntimePayloadBuilder.build_failure_state_message(payload, default_message) + try: + await self.dependencies.audit_gateway.mark_plugin_error(plugin_id, error_message) + except Exception: + logger.exception('记录插件失败状态失败:plugin_id=%s', plugin_id) + return diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/batch.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/batch.py new file mode 100644 index 0000000..f627a7b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/batch.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Protocol, cast + +from plugins.core.runtime.support import ( + BatchOperationResultPayload, + PluginBatchReportBuilder, + PluginPayloadBuilder, + PluginRuntimePayloadBuilder, +) +from plugins.core.validation.plugin_deps import PluginBatchOperation, PluginDependencyPlanBuilder + +if TYPE_CHECKING: + from collections.abc import Mapping + + from plugins.core.capability import PluginRuntimeCapability + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.types import PluginStateRecord + + from .context import PluginRuntimeContextService + from .dependency_container import PluginRuntimeDependencies + from .responses import PluginBatchResponse, PluginLifecycleResponse, PluginPlanResponse + + +class PluginBatchRuntimeOperations(Protocol): + """ + 批量工作流所需的运行时协作能力。 + """ + + async def install_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + ) -> PluginLifecycleResponse: + """ + 安装插件。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录操作日志 + :return: 插件安装负载 + """ + + async def set_plugin_enabled( + self, + plugin_id: str, + *, + enabled: bool, + dry_run: bool = False, + record_operation_log: bool = True, + ) -> PluginLifecycleResponse: + """ + 设置插件启用状态。 + + :param plugin_id: 插件ID + :param enabled: 是否启用 + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录操作日志 + :return: 插件启停负载 + """ + + async def upgrade_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + ) -> PluginLifecycleResponse: + """ + 升级插件。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录操作日志 + :return: 插件升级负载 + """ + + async def record_plugin_operation_log( + self, + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> None: + """ + 记录插件操作审计日志。 + + :param payload: 插件操作结果负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续 + :return: None + """ + + async def execute_batch_plugin_item( + self, + operation: PluginBatchOperation, + plugin_id: str, + ) -> BatchOperationResultPayload: + """ + 执行单个批量插件操作项。 + + :param operation: 批量操作类型 + :param plugin_id: 插件ID + :return: 单插件操作结果负载 + """ + + +class PluginBatchUseCase: + """ + 插件批量计划和批量执行 use case。 + """ + + def __init__( + self, + dependencies: PluginRuntimeDependencies, + runtime_operations: PluginBatchRuntimeOperations, + context: PluginRuntimeContextService, + ) -> None: + """ + 初始化插件批量 use case。 + + :param dependencies: 插件运行时依赖容器 + :param runtime_operations: 批量工作流所需的运行时协作能力 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.runtime_operations = runtime_operations + self.context = context + + def _discover_plugins(self, backend_root: Path) -> list[DiscoveredPlugin]: + """ + 发现本地插件。 + + :param backend_root: 后端项目根目录 + :return: 已发现插件列表 + """ + return self.context.discover_plugins(backend_root) + + def _load_database_plugin_states_sync(self) -> list[PluginStateRecord]: + """ + 以同步方式读取数据库插件状态列表。 + + :return: 数据库插件状态列表 + """ + return self.context.load_database_plugin_states_sync() + + def _load_database_plugin_states_sync_with_error(self) -> tuple[list[PluginStateRecord], str | None]: + """ + 以同步方式读取数据库插件状态列表,并保留失败原因。 + + :return: 数据库插件状态列表和错误信息 + """ + return self.context.load_database_plugin_states_sync_with_error() + + async def _load_database_plugin_states_with_error(self) -> tuple[list[PluginStateRecord], str | None]: + """ + 以异步方式读取数据库插件状态列表,并保留失败原因。 + + :return: 数据库插件状态列表和错误信息 + """ + return await self.context.load_database_plugin_states_with_error() + + def _resolve_plugin_capability(self, discovered_plugin: DiscoveredPlugin) -> PluginRuntimeCapability: + """ + 解析插件运行时操作能力。 + + :param discovered_plugin: 已发现插件 + :return: 插件运行时能力 + """ + return self.context.resolve_plugin_capability(discovered_plugin) + + def plan_plugins( + self, + operation: PluginBatchOperation, + plugin_ids: list[str] | None = None, + ) -> PluginPlanResponse: + """ + 生成插件批量操作拓扑计划。 + + .. note:: 本方法内部以同步方式读取数据库,禁止在异步上下文中调用, + 异步场景请使用 :meth:`plan_plugins_async`。 + + :param operation: 批量操作类型 + :param plugin_ids: 插件ID列表 + :return: 插件批量操作拓扑计划负载 + """ + try: + database_plugins, database_error = self._load_database_plugin_states_sync_with_error() + return self._build_plan_plugins_payload(operation, plugin_ids, database_plugins, database_error) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('生成插件批量操作计划失败', exc) + + async def plan_plugins_async( + self, + operation: PluginBatchOperation, + plugin_ids: list[str] | None = None, + ) -> PluginPlanResponse: + """ + 异步生成插件批量操作拓扑计划。 + + :param operation: 批量操作类型 + :param plugin_ids: 插件ID列表 + :return: 插件批量操作拓扑计划负载 + """ + try: + database_plugins, database_error = await self._load_database_plugin_states_with_error() + return self._build_plan_plugins_payload(operation, plugin_ids, database_plugins, database_error) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('生成插件批量操作计划失败', exc) + + def _build_plan_plugins_payload( + self, + operation: PluginBatchOperation, + plugin_ids: list[str] | None, + database_plugins: list[PluginStateRecord], + database_error: str | None, + ) -> PluginPlanResponse: + """ + 构建插件批量操作拓扑计划负载。 + + :param operation: 批量操作类型 + :param plugin_ids: 插件ID列表 + :param database_plugins: 数据库插件状态列表 + :param database_error: 数据库读取错误 + :return: 插件批量操作拓扑计划负载 + """ + try: + if operation not in {'install', 'enable', 'upgrade'}: + return PluginRuntimePayloadBuilder.build_invalid_operation_payload( + None, + operation, + message=f'插件计划操作不支持:{operation}', + ) + + backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + discovered_plugins = self._discover_plugins(backend_root) + plan = PluginDependencyPlanBuilder(discovered_plugins, database_plugins).build_plan( + operation, + plugin_ids, + ) + payload = PluginPayloadBuilder.build_plan_payload(plan, database_error) + capability_blockers = self._collect_capability_blockers( + discovered_plugins, + operation, + plugin_ids or plan.requested_plugin_ids, + ) + if capability_blockers: + payload['ok'] = False + payload['message'] = '插件批量操作计划存在环境阻断项' + payload['capabilityBlockers'] = capability_blockers + return cast('PluginPlanResponse', payload) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('生成插件批量操作计划失败', exc) + + def _collect_capability_blockers( + self, + discovered_plugins: list[DiscoveredPlugin], + operation: PluginBatchOperation, + requested_plugin_ids: list[str], + ) -> list[dict[str, object]]: + """ + 收集插件能力阻断项。 + + :param discovered_plugins: 已发现插件列表 + :param operation: 批量操作类型 + :param requested_plugin_ids: 请求的插件ID列表 + :return: 能力阻断项列表 + """ + blocked_operation = f'batch_{operation}' + target_plugin_ids = set(requested_plugin_ids) + blockers: list[dict[str, object]] = [] + for discovered_plugin in discovered_plugins: + if target_plugin_ids and discovered_plugin.manifest.id not in target_plugin_ids: + continue + capability = self._resolve_plugin_capability(discovered_plugin) + if capability.allows(blocked_operation): + continue + blockers.append( + { + 'pluginId': discovered_plugin.manifest.id, + 'operation': blocked_operation, + 'message': capability.primary_reason or '当前环境不允许执行该插件操作', + 'capability': capability.to_payload(), + } + ) + + return blockers + + async def batch_plugins( + self, + operation: PluginBatchOperation, + plugin_ids: list[str] | None = None, + *, + dry_run: bool = False, + continue_on_error: bool = False, + ) -> PluginBatchResponse: + """ + 批量执行插件安装、启用或升级。 + + 执行前会先生成拓扑计划;当计划存在阻塞项时不会执行任何写操作。 + + :param operation: 批量操作类型 + :param plugin_ids: 插件ID列表 + :param dry_run: 是否仅预演 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: 插件批量执行结果负载 + """ + try: + plan_payload = cast('dict[str, object]', await self.plan_plugins_async(operation, plugin_ids)) + if not plan_payload.get('ok', False): + plan_payload = PluginBatchReportBuilder.build_plan_blocked_payload( + plan_payload, + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + if not dry_run: + await self.runtime_operations.record_plugin_operation_log( + plan_payload, + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + return cast('PluginBatchResponse', plan_payload) + if dry_run: + return cast( + 'PluginBatchResponse', + PluginBatchReportBuilder.build_dry_run_payload( + plan_payload, + continue_on_error=continue_on_error, + ), + ) + + reports = [] + failed = None + executable_plugin_ids = PluginBatchReportBuilder.resolve_executable_plugin_ids(plan_payload) + for plugin_id in executable_plugin_ids: + report, result = await PluginBatchReportBuilder.run_item( + operation, + plugin_id, + self.runtime_operations.execute_batch_plugin_item, + ) + reports.append(report) + if not report.ok: + failed = failed or PluginBatchReportBuilder.build_failed_payload(report, result) + if not continue_on_error: + break + + payload = PluginBatchReportBuilder.build_execution_payload( + plan_payload, + reports, + failed, + continue_on_error=continue_on_error, + ) + await self.runtime_operations.record_plugin_operation_log( + payload, + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + + return cast('PluginBatchResponse', payload) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('插件批量操作失败', exc) + + async def execute_batch_plugin_item( + self, + operation: PluginBatchOperation, + plugin_id: str, + ) -> BatchOperationResultPayload: + """ + 执行单个批量插件操作项。 + + :param operation: 批量操作类型 + :param plugin_id: 插件ID + :return: 单插件操作结果负载 + """ + if operation == 'install': + return cast( + 'BatchOperationResultPayload', + await self.runtime_operations.install_plugin(plugin_id, dry_run=False, record_operation_log=False), + ) + if operation == 'enable': + return cast( + 'BatchOperationResultPayload', + await self.runtime_operations.set_plugin_enabled( + plugin_id, + enabled=True, + dry_run=False, + record_operation_log=False, + ), + ) + if operation == 'upgrade': + return cast( + 'BatchOperationResultPayload', + await self.runtime_operations.upgrade_plugin(plugin_id, dry_run=False, record_operation_log=False), + ) + return PluginRuntimePayloadBuilder.build_batch_item_unsupported_payload(operation, plugin_id) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/config.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/config.py new file mode 100644 index 0000000..e13cd48 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/config.py @@ -0,0 +1,138 @@ +from typing import cast + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.runtime.support import ( + PluginConfigPayloadBuilder, + PluginPayloadBuilder, + PluginRuntimePayloadBuilder, +) +from plugins.core.types import PluginConfigValue + +from .context import PluginRuntimeContextService +from .dependency_container import PluginRuntimeDependencies +from .responses import PluginConfigExportResponse, PluginConfigImportResponse, PluginConfigStateResponse + + +class PluginConfigUseCase: + """ + 插件配置 use case。 + """ + + def __init__(self, dependencies: PluginRuntimeDependencies, context: PluginRuntimeContextService) -> None: + """ + 初始化插件配置 use case。 + + :param dependencies: 插件运行时依赖容器 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.context = context + + def _get_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None: + """ + 根据插件 ID 获取已发现插件。 + + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + return self.context.get_discovered_plugin(plugin_id) + + async def get_plugin_config(self, plugin_id: str, *, reveal_secret: bool = False) -> PluginConfigStateResponse: + """ + 获取插件配置。 + + :param plugin_id: 插件ID + :param reveal_secret: 是否展示敏感配置原值 + :return: 插件配置负载 + """ + try: + discovered_plugin = self._get_discovered_plugin(plugin_id) + if not discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) + + configs = await self.dependencies.config_gateway.get_plugin_config( + discovered_plugin, + reveal_secret=reveal_secret, + ) + + return PluginConfigPayloadBuilder.build_read_payload(plugin_id, configs) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('读取插件配置失败', exc) + + async def export_plugin_config(self, plugin_id: str, *, reveal_secret: bool = False) -> PluginConfigExportResponse: + """ + 导出插件配置快照。 + + :param plugin_id: 插件ID + :param reveal_secret: 是否导出敏感配置明文 + :return: 插件配置导出负载 + """ + payload = cast('dict[str, object]', await self.get_plugin_config(plugin_id, reveal_secret=reveal_secret)) + if not payload.get('ok', False): + return PluginConfigPayloadBuilder.build_export_failure_payload( + plugin_id, + payload, + reveal_secret=reveal_secret, + ) + + configs = payload.get('configs') if isinstance(payload.get('configs'), list) else [] + return PluginConfigPayloadBuilder.build_export_payload(plugin_id, configs, reveal_secret=reveal_secret) + + async def set_plugin_config( + self, + plugin_id: str, + values: dict[str, PluginConfigValue], + *, + audit_operation: str = 'config_set', + success_message: str = '插件配置已更新', + ) -> PluginConfigStateResponse: + """ + 更新插件配置。 + + :param plugin_id: 插件ID + :param values: 配置键值 + :param audit_operation: 审计操作类型 + :param success_message: 操作成功提示 + :return: 插件配置更新负载 + """ + try: + discovered_plugin = self._get_discovered_plugin(plugin_id) + if not discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) + + configs = await self.dependencies.config_gateway.set_plugin_config( + discovered_plugin, + values, + audit_operation=audit_operation, + success_message=success_message, + ) + + return PluginConfigPayloadBuilder.build_update_payload( + plugin_id, + operation=audit_operation, + message=success_message, + configs=configs, + ) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('更新插件配置失败', exc) + + async def import_plugin_config( + self, plugin_id: str, values: dict[str, PluginConfigValue] + ) -> PluginConfigImportResponse: + """ + 导入插件配置。 + + :param plugin_id: 插件ID + :param values: 待导入配置键值 + :return: 插件配置导入负载 + """ + payload = cast( + 'dict[str, object]', + await self.set_plugin_config( + plugin_id, + values, + audit_operation='config_import', + success_message='插件配置导入完成', + ), + ) + return PluginConfigPayloadBuilder.build_import_payload(plugin_id, payload, values) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/context.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/context.py new file mode 100644 index 0000000..c6d5115 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/context.py @@ -0,0 +1,386 @@ +import asyncio +from pathlib import Path +from time import monotonic + +from plugins.core.capability import PluginRuntimeCapability, PluginRuntimeCapabilityResolver +from plugins.core.discovery.registry import PluginRegistry +from plugins.core.discovery.scanner import ( + DiscoveredPlugin, + PluginDiscoveryError, + PluginDiscoveryResult, + PluginScanner, +) +from plugins.core.lifecycle.migration import PluginMigrationRunner +from plugins.core.lifecycle.precheck import PluginLifecycleScriptPrechecker +from plugins.core.runtime.support import PluginPrecheckContext +from plugins.core.types import PluginStateRecord +from plugins.core.validation.manifest import PluginManifestChecker, PluginManifestCheckResult +from plugins.core.validation.menus import PluginMenuConflictChecker +from plugins.core.validation.plugin_deps import ( + PluginDependencyChecker as InterPluginDependencyChecker, +) +from plugins.core.validation.plugin_deps import ( + PluginDependencyCheckResult, +) +from plugins.core.validation.result import PluginValidationIssue +from plugins.core.validation.structure import PluginStructureChecker +from utils.log_util import logger + +from .dependency_container import PluginRuntimeDependencies +from .migration_store import PluginMigrationHistoryGatewayStore +from .responses import PluginRuntimeBlockedPayload, PluginRuntimeBlockedPayloadDict + +PLUGIN_DISCOVERY_CACHE_TTL_SECONDS = 2.0 +DATABASE_PLUGIN_STATE_SYNC_LOOP_ERROR = '当前事件循环内不能同步读取数据库插件状态,已返回空列表' + + +class PluginRuntimeContextService: + """ + 插件应用运行时上下文服务。 + + 集中提供插件发现、注册表构建、数据库状态读取和预检上下文构建能力, + 让 runtime facade 和组合式 use case 只关注插件操作编排。 + """ + + def __init__(self, dependencies: PluginRuntimeDependencies) -> None: + """ + 初始化插件运行时上下文服务。 + + :param dependencies: 插件运行时依赖容器 + """ + self.dependencies = dependencies + self._discovered_plugins_cache: dict[Path, tuple[float, list[DiscoveredPlugin]]] = {} + self._discovery_errors_cache: dict[Path, list[PluginDiscoveryError]] = {} + + def build_registry(self) -> PluginRegistry: + """ + 构建本地插件注册表。 + + :return: 插件注册表 + """ + backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + return PluginRegistry.build(self.discover_plugins(backend_root)) + + def get_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None: + """ + 根据插件 ID 获取已发现插件。 + + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + return self.get_discovered_plugin_from_list(self.discover_plugins(backend_root), plugin_id) + + @staticmethod + def get_discovered_plugin_from_list( + discovered_plugins: list[DiscoveredPlugin], + plugin_id: str, + ) -> DiscoveredPlugin | None: + """ + 从已发现插件列表中查找指定插件。 + + :param discovered_plugins: 已发现插件列表 + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + for discovered_plugin in discovered_plugins: + if discovered_plugin.manifest.id == plugin_id: + return discovered_plugin + + return None + + async def load_database_plugin_state(self, plugin_id: str) -> tuple[PluginStateRecord | None, str | None]: + """ + 读取数据库插件状态。 + + :param plugin_id: 插件ID + :return: 数据库插件状态和错误信息 + """ + try: + return await self.dependencies.state_query_gateway.get_plugin_state(plugin_id), None + except Exception as exc: + logger.exception(f'读取数据库插件状态失败:{plugin_id},{exc}') + return None, str(exc) + + async def load_database_plugin_states_with_error(self) -> tuple[list[PluginStateRecord], str | None]: + """ + 读取数据库插件状态列表,并保留失败原因。 + + :return: 数据库插件状态列表和错误信息 + """ + try: + return await self.dependencies.state_query_gateway.list_plugin_states(), None + except Exception as exc: + logger.exception(f'读取数据库插件状态列表失败:{exc}') + return [], str(exc) + + async def load_database_plugin_states(self) -> list[PluginStateRecord]: + """ + 读取数据库插件状态列表。 + + :return: 数据库插件状态列表 + """ + database_plugins, _database_error = await self.load_database_plugin_states_with_error() + return database_plugins + + def load_database_plugin_states_sync_with_error(self) -> tuple[list[PluginStateRecord], str | None]: + """ + 以同步方式读取数据库插件状态列表,并保留失败原因。 + + :return: 数据库插件状态列表和错误信息 + """ + if not self.has_plugin_dependencies(): + return [], None + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(self.load_database_plugin_states_with_error()) + logger.warning(DATABASE_PLUGIN_STATE_SYNC_LOOP_ERROR) + return [], DATABASE_PLUGIN_STATE_SYNC_LOOP_ERROR + + def load_database_plugin_states_sync(self) -> list[PluginStateRecord]: + """ + 以同步方式读取数据库插件状态列表。 + + :return: 数据库插件状态列表 + """ + database_plugins, _database_error = self.load_database_plugin_states_sync_with_error() + return database_plugins + + def has_plugin_dependencies(self) -> bool: + """ + 判断当前本地插件是否声明了插件间依赖。 + + :return: 是否存在插件间依赖声明 + """ + backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + return any(plugin.manifest.dependencies.plugins for plugin in self.discover_plugins(backend_root)) + + def resolve_plugin_capability(self, discovered_plugin: DiscoveredPlugin) -> PluginRuntimeCapability: + """ + 解析插件运行时操作能力。 + + :param discovered_plugin: 已发现插件 + :return: 插件运行时能力 + """ + return PluginRuntimeCapabilityResolver( + frontend_mode=self.dependencies.runtime_environment.get_frontend_mode(), + backend_runtime_mode=self.dependencies.runtime_environment.get_backend_runtime_mode(), + ).resolve(discovered_plugin) + + def with_plugin_capability( + self, + payload: dict[str, object], + discovered_plugin: DiscoveredPlugin | None, + ) -> dict[str, object]: + """ + 为运行时响应负载附加插件操作能力。 + + :param payload: 运行时响应负载 + :param discovered_plugin: 已发现插件 + :return: 附加能力后的响应负载 + """ + if discovered_plugin: + payload['capability'] = self.resolve_plugin_capability(discovered_plugin).to_payload() + return payload + + def build_operation_blocked_payload( + self, + discovered_plugin: DiscoveredPlugin, + operation: str, + *, + dry_run: bool | None = None, + ) -> PluginRuntimeBlockedPayloadDict | None: + """ + 构建运行模式阻断负载。 + + :param discovered_plugin: 已发现插件 + :param operation: 操作类型 + :param dry_run: 是否预演 + :return: 阻断负载,不阻断时返回 None + """ + capability = self.resolve_plugin_capability(discovered_plugin) + if capability.allows(operation): + return None + return PluginRuntimeBlockedPayload( + ok=False, + status='blocked', + operation=operation, + plugin_id=discovered_plugin.manifest.id, + message='当前环境不允许执行该插件操作', + suggestion='请在开发模式或维护窗口中执行插件变更,并重启后端或重新构建前端。', + capability=capability.to_payload(), + dry_run=dry_run, + exit_code=1, + ).to_payload(exclude_none=True) + + async def check_inter_plugin_dependencies( + self, + discovered_plugin: DiscoveredPlugin, + discovered_plugins: list[DiscoveredPlugin], + ) -> PluginDependencyCheckResult: + """ + 检查插件间依赖。 + + :param discovered_plugin: 当前已发现插件 + :param discovered_plugins: 全量已发现插件列表 + :return: 插件间依赖检查结果 + """ + if not discovered_plugin.manifest.dependencies.plugins: + return PluginDependencyCheckResult(plugin_id=discovered_plugin.manifest.id, items=[]) + database_plugins = await self.load_database_plugin_states() + return InterPluginDependencyChecker(discovered_plugins, database_plugins).check_manifest( + discovered_plugin.manifest + ) + + async def check_enabled_plugin_dependents( + self, + plugin_id: str, + discovered_plugins: list[DiscoveredPlugin], + ) -> PluginDependencyCheckResult: + """ + 检查指定插件是否仍被已启用插件依赖。 + + :param plugin_id: 被停用或卸载的插件ID + :param discovered_plugins: 全量已发现插件列表 + :return: 被依赖方检查结果 + """ + has_direct_dependents = any( + dependency.id == plugin_id + for discovered_plugin in discovered_plugins + for dependency in discovered_plugin.manifest.dependencies.plugins + ) + if not has_direct_dependents: + return PluginDependencyCheckResult(plugin_id=plugin_id, items=[]) + + database_plugins = await self.load_database_plugin_states() + return InterPluginDependencyChecker(discovered_plugins, database_plugins).check_enabled_dependents(plugin_id) + + async def build_precheck_context( + self, + backend_root: Path, + discovered_plugin: DiscoveredPlugin, + discovered_plugins: list[DiscoveredPlugin], + ) -> PluginPrecheckContext: + """ + 构建插件操作预检上下文。 + + :param backend_root: 后端项目根目录 + :param discovered_plugin: 当前插件 + :param discovered_plugins: 已发现插件列表 + :return: 插件操作预检上下文 + """ + frontend_root = Path(self.dependencies.runtime_environment.get_frontend_dir()) + frontend_plugins_root = Path(self.dependencies.runtime_environment.get_frontend_plugins_dir()) + dependency_result = self.dependencies.dependency_checker.check_manifest(discovered_plugin.manifest) + manifest_result = PluginManifestChecker(backend_root=backend_root, frontend_root=frontend_root).check( + discovered_plugin.manifest + ) + manifest_result = await self._check_lifecycle_scripts(discovered_plugin, manifest_result) + plugin_dependency_result = await self.check_inter_plugin_dependencies(discovered_plugin, discovered_plugins) + structure_result = PluginStructureChecker(backend_root, frontend_plugins_root).check(discovered_plugin) + menu_conflict_result = PluginMenuConflictChecker().check(discovered_plugin, discovered_plugins) + + return PluginPrecheckContext.build( + dependency_result, + manifest_result, + plugin_dependency_result, + structure_result, + menu_conflict_result, + ) + + async def _check_lifecycle_scripts( + self, + discovered_plugin: DiscoveredPlugin, + manifest_result: PluginManifestCheckResult, + ) -> PluginManifestCheckResult: + """ + 检查 migration 历史和 seed 执行计划,并合并到 manifest 预检结果。 + + :param discovered_plugin: 当前插件 + :param manifest_result: 原始 manifest 检查结果 + :return: 合并生命周期脚本预检后的 manifest 检查结果 + """ + if not discovered_plugin.manifest.backend.migrations and not discovered_plugin.manifest.backend.seeds: + return manifest_result + try: + migration_runner = PluginMigrationRunner( + discovered_plugin, + PluginMigrationHistoryGatewayStore(self.dependencies.migration_history_gateway), + ) + script_result = await PluginLifecycleScriptPrechecker(discovered_plugin, migration_runner).check(object()) + except Exception as exc: + logger.exception(f'插件生命周期脚本预检失败:{discovered_plugin.manifest.id},{exc}') + return PluginManifestCheckResult( + plugin_id=manifest_result.plugin_id, + issues=[ + *manifest_result.issues, + PluginRuntimeContextService._build_lifecycle_script_precheck_issue(exc), + ], + ) + + return PluginManifestCheckResult( + plugin_id=manifest_result.plugin_id, + issues=[*manifest_result.issues, *script_result.issues], + ) + + @staticmethod + def _build_lifecycle_script_precheck_issue(exc: Exception) -> PluginValidationIssue: + """ + 构建生命周期脚本预检异常问题。 + + :param exc: 原始异常 + :return: 统一校验问题 + """ + return PluginValidationIssue( + level='error', + category='lifecycle', + kind='lifecycle_script_precheck_failed', + path='backend', + message=f'插件生命周期脚本预检失败:{exc}', + suggestion='请检查 migration/seed 声明和脚本文件', + ) + + def discover_plugins(self, backend_root: Path) -> list[DiscoveredPlugin]: + """ + 发现本地插件。 + + 使用容错扫描,单个损坏插件不会影响其他插件。损坏插件的错误明细可通过 + :meth:`get_discovery_errors` 获取。 + + :param backend_root: 后端项目根目录 + :return: 已发现插件列表 + """ + return self.discover_plugins_with_errors(backend_root).plugins + + def discover_plugins_with_errors(self, backend_root: Path) -> PluginDiscoveryResult: + """ + 发现本地插件并返回错误明细。 + + :param backend_root: 后端项目根目录 + :return: 插件发现结果 + """ + resolved_backend_root = backend_root.resolve() + cached_entry = self._discovered_plugins_cache.get(resolved_backend_root) + if cached_entry is not None: + cached_at, cached_plugins = cached_entry + if monotonic() - cached_at <= PLUGIN_DISCOVERY_CACHE_TTL_SECONDS: + cached_errors = self._discovery_errors_cache.get(resolved_backend_root, []) + return PluginDiscoveryResult(plugins=list(cached_plugins), errors=list(cached_errors)) + + discovery_result = PluginScanner(resolved_backend_root / 'plugins').discover_with_errors() + self._discovered_plugins_cache[resolved_backend_root] = (monotonic(), discovery_result.plugins) + self._discovery_errors_cache[resolved_backend_root] = list(discovery_result.errors) + for error in discovery_result.errors: + logger.warning(f'插件扫描失败,已隔离损坏插件:目录={error.plugin_dir},错误:{error.error_message}') + return discovery_result + + def get_discovery_errors(self, backend_root: Path) -> list[PluginDiscoveryError]: + """ + 获取本地插件扫描错误明细。 + + :param backend_root: 后端项目根目录 + :return: 扫描错误明细列表 + """ + self.discover_plugins_with_errors(backend_root) + return list(self._discovery_errors_cache.get(backend_root.resolve(), [])) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py new file mode 100644 index 0000000..eec4327 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py @@ -0,0 +1,376 @@ +import asyncio +from pathlib import Path +from typing import cast + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.runtime.support import ( + PluginDependencyInstallPayloadBuilder, + PluginNpmPackageJsonSynchronizer, + PluginPayloadBuilder, + PluginRuntimePayloadBuilder, +) +from plugins.core.validation.dependencies import ( + DependencyCheckResult, + PluginDependencyInstallPlanner, +) +from plugins.core.validation.dependency_policy import ( + DependencyInstallPolicyConfig, + DependencyInstallPolicyEvaluator, +) + +from .context import PluginRuntimeContextService +from .dependency_container import PluginRuntimeDependencies +from .gateway import PluginCommandOutputCallback +from .responses import PluginDependencyInstallResponse, PluginRuntimeBlockedPayloadDict + +PLUGIN_DEPENDENCY_INSTALL_TIMEOUT_SECONDS = 600 + + +class PluginDependencyUseCase: + """ + 插件 Python/npm 依赖检查和安装 use case。 + """ + + def __init__(self, dependencies: PluginRuntimeDependencies, context: PluginRuntimeContextService) -> None: + """ + 初始化插件依赖 use case。 + + :param dependencies: 插件运行时依赖容器 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.context = context + + def _get_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None: + """ + 根据插件 ID 获取已发现插件。 + + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + return self.context.get_discovered_plugin(plugin_id) + + def _build_operation_blocked_payload( + self, + discovered_plugin: DiscoveredPlugin, + operation: str, + *, + dry_run: bool | None = None, + ) -> PluginRuntimeBlockedPayloadDict | None: + """ + 构建运行模式阻断负载。 + + :param discovered_plugin: 已发现插件 + :param operation: 操作类型 + :param dry_run: 是否预演 + :return: 阻断负载,不阻断时返回 None + """ + return cast( + 'PluginRuntimeBlockedPayloadDict | None', + self.context.build_operation_blocked_payload(discovered_plugin, operation, dry_run=dry_run), + ) + + def _with_plugin_capability( + self, + payload: dict[str, object], + discovered_plugin: DiscoveredPlugin | None, + ) -> dict[str, object]: + """ + 为运行时响应负载附加插件操作能力。 + + :param payload: 运行时响应负载 + :param discovered_plugin: 已发现插件 + :return: 附加能力后的响应负载 + """ + return cast('dict[str, object]', self.context.with_plugin_capability(payload, discovered_plugin)) + + @staticmethod + def _with_dependency_install_metadata( + payload: dict[str, object], + *, + confirmed: bool, + ) -> dict[str, object]: + """ + 为 standalone 依赖安装审计补充稳定操作元数据。 + + :param payload: 依赖安装负载 + :param confirmed: 是否已显式确认 + :return: 补充元数据后的负载 + """ + payload['operation'] = 'dependency_install' + payload['confirmed'] = confirmed + return payload + + def install_plugin_dependencies( + self, + plugin_id: str, + *, + dry_run: bool = False, + policy_config: DependencyInstallPolicyConfig | None = None, + confirmed: bool = False, + output_callback: PluginCommandOutputCallback | None = None, + ) -> PluginDependencyInstallResponse: + """ + 安装插件依赖。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :param output_callback: 依赖安装实时输出回调 + :return: 插件依赖安装负载 + """ + try: + discovered_plugin = self._get_discovered_plugin(plugin_id) + if not discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id, dry_run=dry_run) + blocked_payload = self._build_operation_blocked_payload( + discovered_plugin, + 'dependency_install', + dry_run=dry_run, + ) + if blocked_payload: + return blocked_payload + + dependency_result = self.dependencies.dependency_checker.check_manifest(discovered_plugin.manifest) + return self.install_plugin_dependencies_from_result( + plugin_id, + dependency_result, + dry_run=dry_run, + discovered_plugin=discovered_plugin, + policy_config=policy_config, + confirmed=confirmed, + output_callback=output_callback, + ) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('安装插件依赖失败', exc) + + def install_plugin_dependencies_from_result( + self, + plugin_id: str, + dependency_result: DependencyCheckResult, + *, + dry_run: bool = False, + discovered_plugin: DiscoveredPlugin | None = None, + policy_config: DependencyInstallPolicyConfig | None = None, + confirmed: bool = False, + output_callback: PluginCommandOutputCallback | None = None, + ) -> PluginDependencyInstallResponse: + """ + 根据既有依赖检查结果生成计划并执行依赖安装。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param dry_run: 是否仅预演 + :param discovered_plugin: 已发现插件,传入后避免重复扫描插件目录 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :param output_callback: 依赖安装实时输出回调 + :return: 插件依赖安装负载 + """ + discovered_plugin = discovered_plugin or self._get_discovered_plugin(plugin_id) + install_plan = PluginDependencyInstallPlanner( + frontend_root=Path(self.dependencies.runtime_environment.get_frontend_dir()) + ).build_plan(dependency_result) + resolved_policy_config = policy_config or DependencyInstallPolicyConfig.from_environment() + policy_decision = DependencyInstallPolicyEvaluator(resolved_policy_config).evaluate( + install_plan, + confirmed=confirmed, + ) + install_plan_items = policy_decision.install_plan_items + if dry_run: + payload = PluginDependencyInstallPayloadBuilder.build_dry_run_payload( + plugin_id, + dependency_result, + install_plan_items, + policy_decision, + ) + payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed) + return cast( + 'PluginDependencyInstallResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + if not install_plan.has_actions: + payload = PluginDependencyInstallPayloadBuilder.build_satisfied_payload( + plugin_id, + dependency_result, + install_plan_items, + policy_decision, + ) + payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed) + return cast( + 'PluginDependencyInstallResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + if not policy_decision.allowed: + payload = PluginDependencyInstallPayloadBuilder.build_payload( + plugin_id=plugin_id, + dependency_result=dependency_result, + install_plan_items=install_plan_items, + dry_run=False, + ok=False, + message='插件依赖安装被策略阻断', + policy_decision=policy_decision, + ) + payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed) + return cast( + 'PluginDependencyInstallResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + + install_results = [] + total = len(install_plan_items) + for index, item in enumerate(install_plan_items, start=1): + self._emit_install_status(output_callback, index, total, item.requirement, '开始安装') + try: + completed = self.dependencies.command_gateway.run_command( + item.command, + item.workdir, + timeout=resolved_policy_config.install_timeout_seconds, + output_callback=output_callback, + ) + except Exception: + self._emit_install_status(output_callback, index, total, item.requirement, '安装中断') + raise + install_results.append(PluginPayloadBuilder.build_dependency_install_result(item, completed)) + status = '安装完成' if completed.returncode == 0 else f'安装失败(退出码 {completed.returncode})' + self._emit_install_status(output_callback, index, total, item.requirement, status) + PluginNpmPackageJsonSynchronizer.sync_successful_items(install_plan_items, install_results) + payload = PluginDependencyInstallPayloadBuilder.build_execution_payload( + plugin_id, + dependency_result, + install_plan_items, + install_results, + policy_decision, + ) + payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed) + return cast( + 'PluginDependencyInstallResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + + async def install_plugin_dependencies_from_result_async( + self, + plugin_id: str, + dependency_result: DependencyCheckResult, + *, + dry_run: bool = False, + discovered_plugin: DiscoveredPlugin | None = None, + policy_config: DependencyInstallPolicyConfig | None = None, + confirmed: bool = False, + output_callback: PluginCommandOutputCallback | None = None, + ) -> PluginDependencyInstallResponse: + """ + 根据既有依赖检查结果异步生成计划并执行依赖安装。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param dry_run: 是否仅预演 + :param discovered_plugin: 已发现插件,传入后避免重复扫描插件目录 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :param output_callback: 依赖安装实时输出回调 + :return: 插件依赖安装负载 + """ + discovered_plugin = discovered_plugin or self._get_discovered_plugin(plugin_id) + install_plan = PluginDependencyInstallPlanner( + frontend_root=Path(self.dependencies.runtime_environment.get_frontend_dir()) + ).build_plan(dependency_result) + resolved_policy_config = policy_config or DependencyInstallPolicyConfig.from_environment() + policy_decision = DependencyInstallPolicyEvaluator(resolved_policy_config).evaluate( + install_plan, + confirmed=confirmed, + ) + install_plan_items = policy_decision.install_plan_items + if dry_run: + payload = PluginDependencyInstallPayloadBuilder.build_dry_run_payload( + plugin_id, + dependency_result, + install_plan_items, + policy_decision, + ) + payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed) + return cast( + 'PluginDependencyInstallResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + if not install_plan.has_actions: + payload = PluginDependencyInstallPayloadBuilder.build_satisfied_payload( + plugin_id, + dependency_result, + install_plan_items, + policy_decision, + ) + payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed) + return cast( + 'PluginDependencyInstallResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + if not policy_decision.allowed: + payload = PluginDependencyInstallPayloadBuilder.build_payload( + plugin_id=plugin_id, + dependency_result=dependency_result, + install_plan_items=install_plan_items, + dry_run=False, + ok=False, + message='插件依赖安装被策略阻断', + policy_decision=policy_decision, + ) + payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed) + return cast( + 'PluginDependencyInstallResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + + install_results = [] + total = len(install_plan_items) + for index, item in enumerate(install_plan_items, start=1): + self._emit_install_status(output_callback, index, total, item.requirement, '开始安装') + try: + completed = await asyncio.to_thread( + self.dependencies.command_gateway.run_command, + item.command, + item.workdir, + timeout=resolved_policy_config.install_timeout_seconds, + output_callback=output_callback, + ) + except Exception: + self._emit_install_status(output_callback, index, total, item.requirement, '安装中断') + raise + install_results.append(PluginPayloadBuilder.build_dependency_install_result(item, completed)) + status = '安装完成' if completed.returncode == 0 else f'安装失败(退出码 {completed.returncode})' + self._emit_install_status(output_callback, index, total, item.requirement, status) + PluginNpmPackageJsonSynchronizer.sync_successful_items(install_plan_items, install_results) + payload = PluginDependencyInstallPayloadBuilder.build_execution_payload( + plugin_id, + dependency_result, + install_plan_items, + install_results, + policy_decision, + ) + payload = self._with_dependency_install_metadata(cast('dict[str, object]', payload), confirmed=confirmed) + return cast( + 'PluginDependencyInstallResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + + @staticmethod + def _emit_install_status( + output_callback: PluginCommandOutputCallback | None, + index: int, + total: int, + requirement: str, + status: str, + ) -> None: + """ + 输出单项依赖安装状态。 + + :param output_callback: 依赖安装实时输出回调 + :param index: 当前安装项序号 + :param total: 安装项总数 + :param requirement: 依赖声明 + :param status: 当前状态 + :return: None + """ + if output_callback is not None: + output_callback('status', f'[{index}/{total}] {status}:{requirement}\n') diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/dependency_container.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/dependency_container.py new file mode 100644 index 0000000..cb9f2e7 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/dependency_container.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass + +from plugins.core.environment import PluginRuntimeEnvironmentService +from plugins.core.validation.dependencies import PluginDependencyChecker + +from .gateway import ( + PluginAuditGateway, + PluginCommandRunnerGateway, + PluginConfigGateway, + PluginLifecycleStateGateway, + PluginLifecycleUnitOfWorkGateway, + PluginManagementModelGateway, + PluginMigrationExecutionGateway, + PluginMigrationHistoryGateway, + PluginPurgePlanGateway, + PluginStateQueryGateway, +) + + +@dataclass +class PluginRuntimeGatewayOverrides: + """ + 插件运行时窄端口覆盖项。 + + 该对象用于测试或特殊组合根显式覆盖某个窄端口,避免 facade 构造器随着端口拆分持续膨胀。 + """ + + config_gateway: PluginConfigGateway | None = None + audit_gateway: PluginAuditGateway | None = None + state_query_gateway: PluginStateQueryGateway | None = None + migration_history_gateway: PluginMigrationHistoryGateway | None = None + purge_plan_gateway: PluginPurgePlanGateway | None = None + lifecycle_state_gateway: PluginLifecycleStateGateway | None = None + lifecycle_uow_gateway: PluginLifecycleUnitOfWorkGateway | None = None + migration_execution_gateway: PluginMigrationExecutionGateway | None = None + + +@dataclass +class PluginRuntimeDependencies: + """ + 插件运行时基础依赖集合。 + """ + + runtime_environment: PluginRuntimeEnvironmentService + dependency_checker: PluginDependencyChecker + config_gateway: PluginConfigGateway + audit_gateway: PluginAuditGateway + state_query_gateway: PluginStateQueryGateway + migration_history_gateway: PluginMigrationHistoryGateway + purge_plan_gateway: PluginPurgePlanGateway + lifecycle_state_gateway: PluginLifecycleStateGateway + lifecycle_uow_gateway: PluginLifecycleUnitOfWorkGateway + migration_execution_gateway: PluginMigrationExecutionGateway + model_gateway: PluginManagementModelGateway + command_gateway: PluginCommandRunnerGateway diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py new file mode 100644 index 0000000..dad5ce4 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py @@ -0,0 +1,830 @@ +import asyncio +from collections.abc import Awaitable, Callable, Mapping +from typing import cast + +from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT, PluginRuntimeEnvironmentService +from plugins.core.runtime.support import PluginRuntimePayloadBuilder +from plugins.core.types import PluginConfigValue +from plugins.core.validation.dependencies import NpmDependencyInspector, PluginDependencyChecker +from plugins.core.validation.dependency_policy import DependencyInstallPolicyConfig + +from .audit import PluginAuditUseCase +from .batch import PluginBatchUseCase +from .config import PluginConfigUseCase +from .context import PluginRuntimeContextService +from .dependencies import PluginDependencyUseCase +from .dependency_container import PluginRuntimeDependencies, PluginRuntimeGatewayOverrides +from .gateway import ( + DefaultPluginCommandRunnerGateway, + PluginCommandOutputCallback, + PluginCommandRunnerGateway, + PluginManagementModelGateway, + UnavailablePluginAuditGateway, + UnavailablePluginConfigGateway, + UnavailablePluginLifecycleStateGateway, + UnavailablePluginLifecycleUnitOfWorkGateway, + UnavailablePluginManagementModelGateway, + UnavailablePluginMigrationExecutionGateway, + UnavailablePluginMigrationHistoryGateway, + UnavailablePluginPurgePlanGateway, + UnavailablePluginStateQueryGateway, +) +from .lifecycle import PluginEnableUseCase, PluginInstallUseCase, PluginPurgeUseCase, PluginUpgradeUseCase +from .lifecycle_lock import NoopPluginLifecycleLock, PluginLifecycleLock +from .migration import MigrationRecoveryStatus, PluginMigrationUseCase +from .precheck import PluginPrecheckUseCase +from .query import PluginQueryUseCase +from .responses import ( + PluginBatchItemExecutionResponse, + PluginBatchResponse, + PluginCatalogInfoResponse, + PluginCatalogListResponse, + PluginCheckResponse, + PluginConfigExportResponse, + PluginConfigImportResponse, + PluginConfigStateResponse, + PluginDependencyCheckResponse, + PluginDependencyInstallResponse, + PluginDiagnoseResponse, + PluginDocumentationResponse, + PluginHealthResponse, + PluginLifecycleResponse, + PluginPlanResponse, + PluginPrecheckResponse, +) +from .tools import PluginToolUseCase + + +class PluginRuntimeService: + """ + 插件应用运行时服务。 + + 使用 Facade + 组合式 use case 管理插件查询、检查、生命周期、配置、测试和模板等核心能力。 + 数据库状态、管理模型和命令执行等外部依赖通过显式端口注入。 + """ + + def __init__( + self, + *, + runtime_environment: PluginRuntimeEnvironmentService | None = None, + dependency_checker: PluginDependencyChecker | None = None, + gateways: PluginRuntimeGatewayOverrides | None = None, + model_gateway: PluginManagementModelGateway | None = None, + command_gateway: PluginCommandRunnerGateway | None = None, + lifecycle_lock: PluginLifecycleLock | None = None, + ) -> None: + """ + 初始化插件应用运行时服务。 + + :param runtime_environment: 插件运行时环境服务 + :param dependency_checker: 插件依赖检查器 + :param gateways: 插件运行时窄端口覆盖项 + :param model_gateway: 插件管理模型工厂网关 + :param command_gateway: 插件命令执行网关 + :param lifecycle_lock: 插件生命周期操作锁 + :return: None + """ + resolved_environment = runtime_environment or PLUGIN_RUNTIME_ENVIRONMENT + resolved_dependency_checker = dependency_checker or PluginDependencyChecker( + npm_inspector=NpmDependencyInspector(frontend_root=resolved_environment.get_frontend_dir()), + frontend_mode=resolved_environment.get_frontend_mode(), + ) + gateway_overrides = gateways or PluginRuntimeGatewayOverrides() + self._replace_dependencies( + PluginRuntimeDependencies( + runtime_environment=resolved_environment, + dependency_checker=resolved_dependency_checker, + config_gateway=gateway_overrides.config_gateway or UnavailablePluginConfigGateway(), + audit_gateway=gateway_overrides.audit_gateway or UnavailablePluginAuditGateway(), + state_query_gateway=gateway_overrides.state_query_gateway or UnavailablePluginStateQueryGateway(), + migration_history_gateway=( + gateway_overrides.migration_history_gateway or UnavailablePluginMigrationHistoryGateway() + ), + purge_plan_gateway=gateway_overrides.purge_plan_gateway or UnavailablePluginPurgePlanGateway(), + lifecycle_state_gateway=( + gateway_overrides.lifecycle_state_gateway or UnavailablePluginLifecycleStateGateway() + ), + lifecycle_uow_gateway=( + gateway_overrides.lifecycle_uow_gateway or UnavailablePluginLifecycleUnitOfWorkGateway() + ), + migration_execution_gateway=( + gateway_overrides.migration_execution_gateway or UnavailablePluginMigrationExecutionGateway() + ), + model_gateway=model_gateway or UnavailablePluginManagementModelGateway(), + command_gateway=command_gateway or DefaultPluginCommandRunnerGateway(), + ) + ) + self.lifecycle_lock = lifecycle_lock or NoopPluginLifecycleLock() + self._background_audit_tasks: set[asyncio.Task[None]] = set() + + def _replace_dependencies(self, dependencies: PluginRuntimeDependencies) -> None: + """ + 替换插件运行时依赖容器并刷新组合 use case。 + + :param dependencies: 新的插件运行时依赖容器 + :return: None + """ + self.dependencies = dependencies + self.context = PluginRuntimeContextService(dependencies) + self.audit = PluginAuditUseCase(dependencies) + self.batch = PluginBatchUseCase(dependencies, runtime_operations=self, context=self.context) + self.config = PluginConfigUseCase(dependencies, context=self.context) + self.dependency = PluginDependencyUseCase(dependencies, context=self.context) + self.enable = PluginEnableUseCase(dependencies, runtime_operations=self, context=self.context) + self.install = PluginInstallUseCase(dependencies, runtime_operations=self, context=self.context) + self.migration = PluginMigrationUseCase(dependencies) + self.precheck = PluginPrecheckUseCase(dependencies, context=self.context) + self.purge = PluginPurgeUseCase(dependencies, runtime_operations=self, context=self.context) + self.query = PluginQueryUseCase(dependencies, runtime_operations=self, context=self.context) + self.tools = PluginToolUseCase(dependencies, context=self.context) + self.upgrade = PluginUpgradeUseCase(dependencies, runtime_operations=self, context=self.context) + + def set_dependency_checker(self, dependency_checker: PluginDependencyChecker) -> None: + """ + 替换插件依赖检查器。 + + :param dependency_checker: 新的依赖检查器 + :return: None + """ + self.dependencies.dependency_checker = dependency_checker + + def refresh_dependency_checker(self) -> None: + """ + 刷新插件 Python/npm 依赖检查器。 + + :return: None + """ + self.set_dependency_checker( + PluginDependencyChecker( + npm_inspector=NpmDependencyInspector( + frontend_root=self.dependencies.runtime_environment.get_frontend_dir(), + ), + frontend_mode=self.dependencies.runtime_environment.get_frontend_mode(), + ) + ) + + def list_plugins(self) -> PluginCatalogListResponse: + """ + 获取本地插件列表。 + + :return: 插件列表负载 + """ + return cast('PluginCatalogListResponse', self.query.list_plugins()) + + async def list_plugins_with_state(self) -> PluginCatalogListResponse: + """ + 获取合并数据库状态的本地插件列表。 + + :return: 插件列表负载 + """ + return cast('PluginCatalogListResponse', await self.query.list_plugins_with_state()) + + def get_plugin_info(self, plugin_id: str) -> PluginCatalogInfoResponse: + """ + 获取插件详情。 + + :param plugin_id: 插件ID + :return: 插件详情负载 + """ + return cast('PluginCatalogInfoResponse', self.query.get_plugin_info(plugin_id)) + + async def get_plugin_info_with_state(self, plugin_id: str) -> PluginCatalogInfoResponse: + """ + 获取包含数据库状态的插件详情。 + + :param plugin_id: 插件ID + :return: 插件详情负载 + """ + return cast('PluginCatalogInfoResponse', await self.query.get_plugin_info_with_state(plugin_id)) + + def check_plugin(self, plugin_id: str | None = None) -> PluginCheckResponse: + """ + 检查插件依赖状态。 + + :param plugin_id: 插件ID,未传入时检查全部插件 + :return: 插件检查负载 + """ + return cast('PluginCheckResponse', self.query.check_plugin(plugin_id)) + + async def check_plugin_async(self, plugin_id: str | None = None) -> PluginCheckResponse: + """ + 异步检查插件依赖状态。 + + :param plugin_id: 插件ID,未传入时检查全部插件 + :return: 插件检查负载 + """ + return cast('PluginCheckResponse', await self.query.check_plugin_async(plugin_id)) + + def check_plugin_dependencies(self, plugin_id: str) -> PluginDependencyCheckResponse: + """ + 检查插件依赖状态。 + + :param plugin_id: 插件ID + :return: 插件依赖检查负载 + """ + return cast('PluginDependencyCheckResponse', self.query.check_plugin_dependencies(plugin_id)) + + async def health_plugin(self, plugin_id: str) -> PluginHealthResponse: + """ + 执行插件健康检查。 + + :param plugin_id: 插件ID + :return: 插件健康检查负载 + """ + return cast('PluginHealthResponse', await self.query.health_plugin(plugin_id)) + + async def diagnose_plugin(self, plugin_id: str) -> PluginDiagnoseResponse: + """ + 生成插件诊断包。 + + :param plugin_id: 插件ID + :return: 插件诊断包负载 + """ + return cast('PluginDiagnoseResponse', await self.query.diagnose_plugin(plugin_id)) + + async def get_plugin_config(self, plugin_id: str, *, reveal_secret: bool = False) -> PluginConfigStateResponse: + """ + 获取插件配置。 + + :param plugin_id: 插件ID + :param reveal_secret: 是否展示敏感配置原值 + :return: 插件配置负载 + """ + return cast( + 'PluginConfigStateResponse', await self.config.get_plugin_config(plugin_id, reveal_secret=reveal_secret) + ) + + async def export_plugin_config(self, plugin_id: str, *, reveal_secret: bool = False) -> PluginConfigExportResponse: + """ + 导出插件配置快照。 + + :param plugin_id: 插件ID + :param reveal_secret: 是否导出敏感配置明文 + :return: 插件配置导出负载 + """ + return cast( + 'PluginConfigExportResponse', await self.config.export_plugin_config(plugin_id, reveal_secret=reveal_secret) + ) + + async def set_plugin_config( + self, + plugin_id: str, + values: dict[str, PluginConfigValue], + *, + audit_operation: str = 'config_set', + success_message: str = '插件配置已更新', + ) -> PluginConfigStateResponse: + """ + 更新插件配置。 + + :param plugin_id: 插件ID + :param values: 配置键值 + :param audit_operation: 审计操作类型 + :param success_message: 操作成功提示 + :return: 插件配置更新负载 + """ + return cast( + 'PluginConfigStateResponse', + await self.config.set_plugin_config( + plugin_id, + values, + audit_operation=audit_operation, + success_message=success_message, + ), + ) + + async def import_plugin_config( + self, plugin_id: str, values: dict[str, PluginConfigValue] + ) -> PluginConfigImportResponse: + """ + 导入插件配置。 + + :param plugin_id: 插件ID + :param values: 待导入配置键值 + :return: 插件配置导入负载 + """ + return cast('PluginConfigImportResponse', await self.config.import_plugin_config(plugin_id, values)) + + async def precheck_plugin_operation(self, plugin_id: str, operation: str) -> PluginPrecheckResponse: + """ + 执行插件操作预检。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :return: 插件操作预检负载 + """ + return cast('PluginPrecheckResponse', await self.precheck.precheck_plugin_operation(plugin_id, operation)) + + def plan_plugins(self, operation: str, plugin_ids: list[str] | None = None) -> PluginPlanResponse: + """ + 生成插件批量操作拓扑计划。 + + :param operation: 批量操作类型 + :param plugin_ids: 插件ID列表 + :return: 插件批量操作拓扑计划负载 + """ + return cast('PluginPlanResponse', self.batch.plan_plugins(operation, plugin_ids)) + + async def plan_plugins_async(self, operation: str, plugin_ids: list[str] | None = None) -> PluginPlanResponse: + """ + 异步生成插件批量操作拓扑计划。 + + :param operation: 批量操作类型 + :param plugin_ids: 插件ID列表 + :return: 插件批量操作拓扑计划负载 + """ + return cast('PluginPlanResponse', await self.batch.plan_plugins_async(operation, plugin_ids)) + + async def batch_plugins( + self, + operation: str, + plugin_ids: list[str] | None = None, + *, + dry_run: bool = False, + continue_on_error: bool = False, + ) -> PluginBatchResponse: + """ + 批量执行插件安装、启用或升级。 + + :param operation: 批量操作类型 + :param plugin_ids: 插件ID列表 + :param dry_run: 是否仅预演 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: 插件批量执行结果负载 + """ + return cast( + 'PluginBatchResponse', + await self.batch.batch_plugins( + operation, + plugin_ids, + dry_run=dry_run, + continue_on_error=continue_on_error, + ), + ) + + async def execute_batch_plugin_item(self, operation: str, plugin_id: str) -> PluginBatchItemExecutionResponse: + """ + 执行单个批量插件操作项。 + + :param operation: 批量操作类型 + :param plugin_id: 插件ID + :return: 单插件操作结果负载 + """ + return cast( + 'PluginBatchItemExecutionResponse', await self.batch.execute_batch_plugin_item(operation, plugin_id) + ) + + def install_plugin_dependencies( + self, + plugin_id: str, + *, + dry_run: bool = False, + policy_config: DependencyInstallPolicyConfig | None = None, + confirmed: bool = False, + record_operation_log: bool = True, + output_callback: PluginCommandOutputCallback | None = None, + ) -> PluginDependencyInstallResponse: + """ + 安装插件依赖。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :param record_operation_log: 是否记录插件操作审计日志 + :param output_callback: 依赖安装实时输出回调 + :return: 插件依赖安装负载 + """ + if output_callback is None: + dependency_payload = self.dependency.install_plugin_dependencies( + plugin_id, + dry_run=dry_run, + policy_config=policy_config, + confirmed=confirmed, + ) + else: + dependency_payload = self.dependency.install_plugin_dependencies( + plugin_id, + dry_run=dry_run, + policy_config=policy_config, + confirmed=confirmed, + output_callback=output_callback, + ) + payload = cast('PluginDependencyInstallResponse', dependency_payload) + if record_operation_log and not dry_run: + self._record_plugin_operation_log_sync(payload, dry_run=False, continue_on_error=False) + return payload + + def _record_plugin_operation_log_sync( + self, + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> None: + """ + 从同步入口记录插件操作审计日志。 + + :param payload: 操作结果负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续 + :return: None + """ + record_coro = self.record_plugin_operation_log( + payload, + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + asyncio.run(record_coro) + return + task = running_loop.create_task(record_coro) + self._background_audit_tasks.add(task) + task.add_done_callback(self._background_audit_tasks.discard) + + def install_plugin_dependencies_from_result( + self, + plugin_id: str, + dependency_result: object, + *, + dry_run: bool = False, + discovered_plugin: object | None = None, + policy_config: DependencyInstallPolicyConfig | None = None, + confirmed: bool = False, + ) -> PluginDependencyInstallResponse: + """ + 根据既有依赖检查结果生成计划并执行依赖安装。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param dry_run: 是否仅预演 + :param discovered_plugin: 已发现插件 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :return: 插件依赖安装负载 + """ + return cast( + 'PluginDependencyInstallResponse', + self.dependency.install_plugin_dependencies_from_result( + plugin_id, + dependency_result, + dry_run=dry_run, + discovered_plugin=discovered_plugin, + policy_config=policy_config, + confirmed=confirmed, + ), + ) + + async def install_plugin_dependencies_from_result_async( + self, + plugin_id: str, + dependency_result: object, + *, + dry_run: bool = False, + discovered_plugin: object | None = None, + policy_config: DependencyInstallPolicyConfig | None = None, + confirmed: bool = False, + ) -> PluginDependencyInstallResponse: + """ + 根据既有依赖检查结果异步生成计划并执行依赖安装。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param dry_run: 是否仅预演 + :param discovered_plugin: 已发现插件 + :param policy_config: 依赖安装策略配置 + :param confirmed: 是否已显式确认 + :return: 插件依赖安装负载 + """ + return cast( + 'PluginDependencyInstallResponse', + await self.dependency.install_plugin_dependencies_from_result_async( + plugin_id, + dependency_result, + dry_run=dry_run, + discovered_plugin=discovered_plugin, + policy_config=policy_config, + confirmed=confirmed, + ), + ) + + async def record_plugin_operation_log( + self, + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> None: + """ + 记录插件操作审计日志。 + + :param payload: 插件操作结果负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续 + :return: None + """ + await self.audit.record_plugin_operation_log( + cast('dict[str, object]', payload), + dry_run=dry_run, + continue_on_error=continue_on_error, + ) + + async def record_plugin_failure_state(self, payload: Mapping[str, object], default_message: str) -> None: + """ + 记录插件操作失败状态。 + + :param payload: 插件操作返回负载 + :param default_message: 缺省失败信息 + :return: None + """ + await self.audit.record_plugin_failure_state(cast('dict[str, object]', payload), default_message) + + async def list_plugin_migrations(self, plugin_id: str, status: str | None = None) -> dict[str, object]: + """ + 查询插件 migration 历史。 + + :param plugin_id: 插件ID + :param status: 执行状态 + :return: 插件 migration 历史负载 + """ + return await self.migration.list_plugin_migrations(plugin_id, status) + + async def mark_plugin_migration_success( + self, + plugin_id: str, + migration_path: str, + *, + note: str | None = None, + record_operation_log: bool = True, + ) -> dict[str, object]: + """ + 人工标记插件 migration 为成功。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param note: 人工恢复备注 + :param record_operation_log: 是否记录插件操作审计日志 + :return: 插件 migration 状态标记负载 + """ + return await self._mark_plugin_migration_status( + plugin_id, + migration_path, + 'success', + note=note, + record_operation_log=record_operation_log, + ) + + async def mark_plugin_migration_failed( + self, + plugin_id: str, + migration_path: str, + *, + note: str | None = None, + record_operation_log: bool = True, + ) -> dict[str, object]: + """ + 人工标记插件 migration 为失败。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param note: 人工恢复备注 + :param record_operation_log: 是否记录插件操作审计日志 + :return: 插件 migration 状态标记负载 + """ + return await self._mark_plugin_migration_status( + plugin_id, + migration_path, + 'failed', + note=note, + record_operation_log=record_operation_log, + ) + + async def _mark_plugin_migration_status( + self, + plugin_id: str, + migration_path: str, + status: MigrationRecoveryStatus, + *, + note: str | None, + record_operation_log: bool, + ) -> dict[str, object]: + """ + 在生命周期锁内人工标记插件 migration 状态。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param status: 目标状态 + :param note: 人工恢复备注 + :param record_operation_log: 是否记录插件操作审计日志 + :return: 插件 migration 状态标记负载 + """ + operation = f'migration_mark_{status}' + async with self.lifecycle_lock.lock(plugin_id, operation) as lock_result: + if not lock_result.acquired: + return cast( + 'dict[str, object]', + PluginRuntimePayloadBuilder.build_invalid_operation_payload( + plugin_id, + operation, + message=lock_result.message, + ), + ) + payload = await self.migration.mark_plugin_migration_status( + plugin_id, + migration_path, + status, + note=note, + ) + + if record_operation_log and payload.get('ok') is True: + await self.record_plugin_operation_log(payload, dry_run=False, continue_on_error=False) + + return payload + + async def install_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + operated_by: str | None = None, + ) -> PluginLifecycleResponse: + """ + 安装插件并按需记录审计日志。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :param operated_by: 操作者用户名,非预演时写入审计日志 + :return: 插件安装结果负载 + """ + return await self._run_with_lifecycle_lock( + plugin_id, + 'install', + dry_run=dry_run, + operation=lambda: self.install.install_plugin( + plugin_id, + dry_run=dry_run, + record_operation_log=record_operation_log, + operated_by=operated_by, + ), + ) + + async def set_plugin_enabled( + self, + plugin_id: str, + *, + enabled: bool, + dry_run: bool = False, + record_operation_log: bool = True, + ) -> PluginLifecycleResponse: + """ + 更新插件启停状态并按需记录审计日志。 + + :param plugin_id: 插件ID + :param enabled: 是否启用 + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :return: 插件启停结果负载 + """ + operation = 'enable' if enabled else 'disable' + return await self._run_with_lifecycle_lock( + plugin_id, + operation, + dry_run=dry_run, + operation=lambda: self.enable.set_plugin_enabled( + plugin_id, + enabled=enabled, + dry_run=dry_run, + record_operation_log=record_operation_log, + ), + ) + + async def uninstall_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + operated_by: str | None = None, + ) -> PluginLifecycleResponse: + """ + 安全卸载插件。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :param operated_by: 操作者用户名,非预演时写入审计日志 + :return: 插件卸载结果负载 + """ + return await self._run_with_lifecycle_lock( + plugin_id, + 'uninstall', + dry_run=dry_run, + operation=lambda: self.enable.uninstall_plugin( + plugin_id, + dry_run=dry_run, + record_operation_log=record_operation_log, + operated_by=operated_by, + ), + ) + + async def purge_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + operated_by: str | None = None, + ) -> PluginLifecycleResponse: + """ + 物理清理插件平台元数据并按需记录审计日志。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :param operated_by: 操作者用户名,非预演时写入审计日志 + :return: 插件物理清理结果负载 + """ + return await self._run_with_lifecycle_lock( + plugin_id, + 'purge', + dry_run=dry_run, + operation=lambda: self.purge.purge_plugin( + plugin_id, + dry_run=dry_run, + record_operation_log=record_operation_log, + operated_by=operated_by, + ), + ) + + async def upgrade_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + operated_by: str | None = None, + ) -> PluginLifecycleResponse: + """ + 升级插件并按需记录审计日志。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :param operated_by: 操作者用户名,非预演时写入审计日志 + :return: 插件升级结果负载 + """ + return await self._run_with_lifecycle_lock( + plugin_id, + 'upgrade', + dry_run=dry_run, + operation=lambda: self.upgrade.upgrade_plugin( + plugin_id, + dry_run=dry_run, + record_operation_log=record_operation_log, + operated_by=operated_by, + ), + ) + + async def _run_with_lifecycle_lock( + self, + plugin_id: str, + lock_operation: str, + *, + dry_run: bool, + operation: Callable[[], Awaitable[PluginLifecycleResponse]], + ) -> PluginLifecycleResponse: + """ + 在插件生命周期分布式锁内执行写操作。 + + :param plugin_id: 插件ID + :param lock_operation: 锁定的操作类型 + :param dry_run: 是否仅预演 + :param operation: 实际操作 + :return: 插件生命周期操作结果 + """ + if dry_run: + return await operation() + async with self.lifecycle_lock.lock(plugin_id, lock_operation) as lock_result: + if not lock_result.acquired: + return cast( + 'PluginLifecycleResponse', + PluginRuntimePayloadBuilder.build_invalid_operation_payload( + plugin_id, + lock_operation, + message=lock_result.message, + ), + ) + return await operation() + + def generate_plugin_docs(self, plugin_id: str) -> PluginDocumentationResponse: + """ + 生成插件 Markdown 文档片段。 + + :param plugin_id: 插件ID + :return: 插件文档生成负载 + """ + return self.tools.generate_plugin_docs(plugin_id) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/gateway.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/gateway.py new file mode 100644 index 0000000..d9d6837 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/gateway.py @@ -0,0 +1,1249 @@ +from __future__ import annotations + +import subprocess +import threading +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeAlias, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import Mapping + from contextlib import AbstractAsyncContextManager + from pathlib import Path + + from sqlalchemy.ext.asyncio import AsyncSession + + from common.vo import CrudResponseModel + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.lifecycle.migration import PluginMigrationResult + from plugins.core.lifecycle.purge import PluginPurgePlan + from plugins.core.management.entity.vo.schemas import ( + PluginConfigModel, + PluginConfigUpdateModel, + PluginConfigValueModel, + PluginMigrationModel, + PluginModel, + PluginOperationLogDetailModel, + PluginOperationLogExportQueryModel, + ) + from plugins.core.types import PluginConfigValue, PluginStateRecord + from plugins.core.validation.menus import PluginMenuConflictItem + +PluginCommandOutputKind = Literal['status', 'stdout', 'stderr'] +PluginCommandOutputCallback: TypeAlias = Callable[[PluginCommandOutputKind, str], None] + + +def run_plugin_command( + command: list[str], + workdir: str, + *, + timeout: int | None = None, + output_callback: PluginCommandOutputCallback | None = None, +) -> subprocess.CompletedProcess[str]: + """ + 执行插件系统命令,并可选实时转发标准输出和错误输出。 + + :param command: 命令参数列表 + :param workdir: 命令工作目录 + :param timeout: 命令超时时间 + :param output_callback: 实时输出回调 + :return: 命令执行结果 + """ + if output_callback is None: + return subprocess.run( + command, + cwd=workdir, + capture_output=True, + text=True, + check=False, + timeout=timeout, + ) + + process = subprocess.Popen( + command, + cwd=workdir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + stdout_parts: list[str] = [] + stderr_parts: list[str] = [] + callback_lock = threading.Lock() + + def consume_stream( + stream: Any, + kind: Literal['stdout', 'stderr'], + output_parts: list[str], + ) -> None: + """持续读取并转发单个子进程输出流。""" + try: + for text in iter(stream.readline, ''): + output_parts.append(text) + with callback_lock: + output_callback(kind, text) + finally: + stream.close() + + stdout_thread = threading.Thread( + target=consume_stream, + args=(process.stdout, 'stdout', stdout_parts), + daemon=True, + ) + stderr_thread = threading.Thread( + target=consume_stream, + args=(process.stderr, 'stderr', stderr_parts), + daemon=True, + ) + stdout_thread.start() + stderr_thread.start() + + try: + return_code = process.wait(timeout=timeout) + except subprocess.TimeoutExpired as exc: + process.kill() + process.wait() + stdout_thread.join() + stderr_thread.join() + raise subprocess.TimeoutExpired( + command, + timeout, + output=''.join(stdout_parts), + stderr=''.join(stderr_parts), + ) from exc + + stdout_thread.join() + stderr_thread.join() + return subprocess.CompletedProcess( + args=command, + returncode=return_code, + stdout=''.join(stdout_parts), + stderr=''.join(stderr_parts), + ) + + +@runtime_checkable +class AsyncSessionFactoryProtocol(Protocol): + """ + 异步数据库会话工厂协议。 + """ + + def __call__(self) -> AbstractAsyncContextManager[AsyncSession]: + """ + 创建异步数据库会话上下文。 + + :return: 异步数据库会话上下文 + """ + + +@runtime_checkable +class PluginManagementServiceProtocol(Protocol): + """ + 插件运行时依赖的管理服务协议。 + """ + + @classmethod + async def get_plugin_list_services(cls, query_db: AsyncSession) -> list[PluginStateRecord]: + """ + 获取插件状态列表。 + + :param query_db: orm对象 + :return: 插件状态列表 + """ + + @classmethod + async def plugin_detail_services(cls, query_db: AsyncSession, plugin_id: str) -> PluginStateRecord | None: + """ + 获取插件详情。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 插件状态 + """ + + @classmethod + async def upsert_discovered_plugin_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + backend_root: Path, + frontend_root: Path | None = None, + ) -> PluginModel: + """ + 写入或更新已发现插件。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :return: 插件信息 + """ + + @classmethod + async def update_plugin_enabled_services( + cls, + query_db: AsyncSession, + plugin_id: str, + enabled: bool, + discovered_plugin: DiscoveredPlugin | None = None, + ) -> CrudResponseModel: + """ + 更新插件启停状态。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param enabled: 是否启用 + :param discovered_plugin: 已发现插件对象 + :return: 操作响应 + """ + + @classmethod + async def mark_plugin_installed_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> PluginModel: + """ + 标记插件安装完成。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件信息 + """ + + @classmethod + async def mark_plugin_uninstalled_services(cls, query_db: AsyncSession, plugin_id: str) -> CrudResponseModel: + """ + 标记插件卸载完成。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 操作响应 + """ + + @classmethod + async def install_plugin_menu_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + *, + enabled: bool, + ) -> None: + """ + 安装插件菜单。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param enabled: 是否启用 + :return: None + """ + + @classmethod + async def install_plugin_default_config_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> list[PluginConfigModel]: + """ + 安装插件默认配置。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件配置列表 + """ + + @classmethod + async def install_plugin_job_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + *, + enabled: bool, + ) -> None: + """ + 同步单个插件任务。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件 + :param enabled: 插件任务是否启用 + :return: None + """ + + @classmethod + async def get_plugin_config_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + *, + reveal_secret: bool = False, + ) -> list[PluginConfigValueModel]: + """ + 获取插件配置。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param reveal_secret: 是否展示敏感配置原值 + :return: 插件配置值列表 + """ + + @classmethod + async def is_plugin_installed_services(cls, query_db: AsyncSession, plugin_id: str) -> bool: + """ + 判断插件是否已经完成安装。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 是否已安装 + """ + + @classmethod + async def update_plugin_config_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + update_model: PluginConfigUpdateModel, + ) -> list[PluginConfigValueModel]: + """ + 更新插件配置。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param update_model: 配置更新对象 + :return: 插件配置值列表 + """ + + @classmethod + async def add_plugin_operation_log_services( + cls, + query_db: AsyncSession, + payload: dict[str, Any], + *, + dry_run: bool, + continue_on_error: bool, + ) -> object: + """ + 记录插件操作审计日志。 + + :param query_db: orm对象 + :param payload: 操作结果负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续 + :return: 操作日志模型 + """ + + @classmethod + async def get_plugin_operation_log_export_list_services( + cls, + query_db: AsyncSession, + query_object: PluginOperationLogExportQueryModel, + ) -> list[PluginOperationLogDetailModel]: + """ + 获取插件操作日志导出列表。 + + :param query_db: orm对象 + :param query_object: 操作日志导出查询对象 + :return: 操作日志详情列表 + """ + + @classmethod + async def check_installed_menu_conflict_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> list[PluginMenuConflictItem]: + """ + 检查已安装菜单冲突。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 菜单冲突列表 + """ + + @classmethod + async def mark_plugin_error_services( + cls, query_db: AsyncSession, plugin_id: str, error_message: str + ) -> CrudResponseModel: + """ + 标记插件错误。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param error_message: 错误信息 + :return: 操作响应 + """ + + @classmethod + async def build_plugin_purge_plan_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> PluginPurgePlan: + """ + 构建插件清理计划。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件清理计划 + """ + + @classmethod + async def purge_plugin_services( + cls, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> PluginPurgePlan: + """ + 清理插件平台元数据。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件清理计划 + """ + + @classmethod + async def build_plugin_purge_plan_by_id_services( + cls, + query_db: AsyncSession, + plugin_id: str, + ) -> PluginPurgePlan: + """ + 按插件 ID 构建孤儿元数据清理计划。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 插件清理计划 + """ + + @classmethod + async def purge_plugin_metadata_by_id_services( + cls, + query_db: AsyncSession, + plugin_id: str, + ) -> PluginPurgePlan: + """ + 按插件 ID 清理孤儿元数据。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :return: 插件清理计划 + """ + + @classmethod + async def get_plugin_migration_services( + cls, + query_db: AsyncSession, + plugin_id: str, + migration_path: str, + ) -> PluginMigrationModel | None: + """ + 获取插件 migration 历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: 插件 migration 历史 + """ + + @classmethod + async def get_plugin_migration_list_services( + cls, + query_db: AsyncSession, + plugin_id: str, + status: str | None = None, + ) -> list[PluginMigrationModel]: + """ + 获取插件 migration 历史列表。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param status: 执行状态 + :return: 插件 migration 历史列表 + """ + + @classmethod + async def add_plugin_migration_services( + cls, + query_db: AsyncSession, + plugin_migration: PluginMigrationModel, + ) -> PluginMigrationModel: + """ + 新增插件 migration 历史。 + + :param query_db: orm对象 + :param plugin_migration: 插件 migration 历史 + :return: 插件 migration 历史 + """ + + @classmethod + async def mark_plugin_migration_status_services( + cls, + query_db: AsyncSession, + plugin_id: str, + migration_path: str, + status: str, + error_message: str | None = None, + ) -> PluginMigrationModel | None: + """ + 人工标记插件 migration 历史状态。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param status: 执行状态 + :param error_message: 失败错误信息 + :return: 插件 migration 历史 + """ + + +@runtime_checkable +class PluginConfigGateway(Protocol): + """ + 插件配置网关协议。 + """ + + async def get_plugin_config( + self, + discovered_plugin: DiscoveredPlugin, + *, + reveal_secret: bool = False, + ) -> list[PluginConfigValueModel]: + """ + 获取插件配置。 + + :param discovered_plugin: 已发现插件 + :param reveal_secret: 是否展示敏感配置原值 + :return: 插件配置列表 + """ + + async def update_plugin_config( + self, + discovered_plugin: DiscoveredPlugin, + values: dict[str, PluginConfigValue], + ) -> list[PluginConfigValueModel]: + """ + 更新插件配置。 + + :param discovered_plugin: 已发现插件 + :param values: 配置键值 + :return: 插件配置列表 + """ + + async def set_plugin_config( + self, + discovered_plugin: DiscoveredPlugin, + values: dict[str, PluginConfigValue], + *, + audit_operation: str, + success_message: str, + ) -> list[PluginConfigValueModel]: + """ + 在同一事务中更新插件配置并记录审计日志。 + + :param discovered_plugin: 已发现插件 + :param values: 配置键值 + :param audit_operation: 审计操作类型 + :param success_message: 操作成功提示 + :return: 插件配置列表 + """ + + +@runtime_checkable +class PluginAuditGateway(Protocol): + """ + 插件审计网关协议。 + """ + + async def list_plugin_operation_logs(self, *, export_limit: int) -> list[PluginOperationLogDetailModel]: + """ + 获取插件操作审计日志列表。 + + :param export_limit: 导出数量上限 + :return: 插件操作日志详情列表 + """ + + async def add_plugin_operation_log( + self, + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> None: + """ + 记录插件操作审计日志。 + + :param payload: 操作日志负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续 + :return: None + """ + + async def mark_plugin_error(self, plugin_id: str, error_message: str) -> bool: + """ + 标记插件错误状态。 + + :param plugin_id: 插件ID + :param error_message: 错误信息 + :return: 是否标记成功 + """ + + +@runtime_checkable +class PluginStateQueryGateway(Protocol): + """ + 插件状态查询网关协议。 + """ + + async def list_plugin_states(self) -> list[PluginStateRecord]: + """ + 获取插件状态列表。 + + :return: 插件状态列表 + """ + + async def get_plugin_state(self, plugin_id: str) -> PluginStateRecord | None: + """ + 获取插件状态。 + + :param plugin_id: 插件ID + :return: 插件状态 + """ + + +@runtime_checkable +class PluginMigrationHistoryGateway(Protocol): + """ + 插件 migration 历史网关协议。 + """ + + async def list_plugin_migrations( + self, + plugin_id: str, + status: str | None = None, + ) -> list[PluginMigrationModel]: + """ + 查询插件 migration 历史。 + + :param plugin_id: 插件ID + :param status: 执行状态 + :return: 插件 migration 历史列表 + """ + + async def get_plugin_migration(self, plugin_id: str, migration_path: str) -> PluginMigrationModel | None: + """ + 获取插件 migration 历史。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: 插件 migration 历史 + """ + + async def mark_plugin_migration_status( + self, + plugin_id: str, + migration_path: str, + status: str, + error_message: str | None = None, + ) -> PluginMigrationModel | None: + """ + 人工标记插件 migration 历史状态。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param status: 执行状态 + :param error_message: 失败错误信息 + :return: 插件 migration 历史 + """ + + +@runtime_checkable +class PluginPurgePlanGateway(Protocol): + """ + 插件清理计划网关协议。 + """ + + async def build_plugin_purge_plan(self, discovered_plugin: DiscoveredPlugin) -> PluginPurgePlan: + """ + 构建插件物理清理计划。 + + :param discovered_plugin: 已发现插件 + :return: 插件物理清理计划 + """ + + +@runtime_checkable +class PluginLifecycleStateGateway(Protocol): + """ + 插件生命周期状态写入网关协议。 + """ + + async def set_plugin_enabled_state( + self, + plugin_id: str, + enabled: bool, + discovered_plugin: DiscoveredPlugin | None = None, + ) -> CrudResponseModel: + """ + 更新插件启停状态,并在启用时同步插件菜单。 + + :param plugin_id: 插件ID + :param enabled: 是否启用 + :param discovered_plugin: 已发现插件 + :return: 操作响应 + """ + + async def mark_plugin_uninstalled_state(self, plugin_id: str) -> CrudResponseModel: + """ + 标记插件安全卸载。 + + :param plugin_id: 插件ID + :return: 操作响应 + """ + + +@runtime_checkable +class PluginLifecycleUnitOfWork(Protocol): + """ + 插件生命周期主事务工作单元协议。 + """ + + session: AsyncSession + + async def check_installed_menu_conflicts(self, discovered_plugin: DiscoveredPlugin) -> list[PluginMenuConflictItem]: + """ + 检查已安装菜单冲突。 + + :param discovered_plugin: 已发现插件 + :return: 菜单冲突列表 + """ + + async def upsert_discovered_plugin( + self, + discovered_plugin: DiscoveredPlugin, + backend_root: Path, + frontend_root: Path | None = None, + ) -> PluginModel: + """ + 写入或更新已发现插件。 + + :param discovered_plugin: 已发现插件 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :return: 插件模型 + """ + + async def install_plugin_menu(self, discovered_plugin: DiscoveredPlugin, *, enabled: bool) -> None: + """ + 安装插件菜单。 + + :param discovered_plugin: 已发现插件 + :param enabled: 是否启用菜单 + :return: None + """ + + async def install_plugin_default_config(self, discovered_plugin: DiscoveredPlugin) -> list[PluginConfigModel]: + """ + 安装插件默认配置。 + + :param discovered_plugin: 已发现插件 + :return: 插件配置列表 + """ + + async def install_plugin_jobs(self, discovered_plugin: DiscoveredPlugin, *, enabled: bool) -> None: + """ + 同步单个插件任务。 + + :param discovered_plugin: 已发现插件 + :param enabled: 插件任务是否启用 + :return: None + """ + + async def mark_plugin_installed(self, discovered_plugin: DiscoveredPlugin) -> PluginModel: + """ + 标记插件已安装。 + + :param discovered_plugin: 已发现插件 + :return: 插件模型 + """ + + async def build_plugin_purge_plan(self, discovered_plugin: DiscoveredPlugin) -> PluginPurgePlan: + """ + 构建插件物理清理计划。 + + :param discovered_plugin: 已发现插件 + :return: 插件物理清理计划 + """ + + async def purge_plugin_metadata(self, discovered_plugin: DiscoveredPlugin) -> PluginPurgePlan: + """ + 清理插件平台元数据。 + + :param discovered_plugin: 已发现插件 + :return: 插件物理清理计划 + """ + + async def build_plugin_purge_plan_by_id(self, plugin_id: str) -> PluginPurgePlan: + """ + 按插件 ID 构建孤儿元数据清理计划。 + + :param plugin_id: 插件ID + :return: 插件物理清理计划 + """ + + async def purge_plugin_metadata_by_id(self, plugin_id: str) -> PluginPurgePlan: + """ + 按插件 ID 清理孤儿元数据。 + + :param plugin_id: 插件ID + :return: 插件物理清理计划 + """ + + async def commit(self) -> None: + """ + 提交生命周期主事务。 + + :return: None + """ + + +@runtime_checkable +class PluginLifecycleUnitOfWorkGateway(Protocol): + """ + 插件生命周期主事务工作单元网关协议。 + """ + + def open_lifecycle_unit_of_work(self) -> AbstractAsyncContextManager[PluginLifecycleUnitOfWork]: + """ + 打开生命周期主事务工作单元。 + + :return: 生命周期主事务工作单元上下文 + """ + + +@runtime_checkable +class PluginMigrationExecutionGateway(Protocol): + """ + 插件 migration 独立执行网关协议。 + """ + + async def run_plugin_migrations(self, discovered_plugin: DiscoveredPlugin) -> list[PluginMigrationResult]: + """ + 使用独立执行事务运行插件 migration。 + + :param discovered_plugin: 已发现插件 + :return: migration 执行结果列表 + """ + + +@runtime_checkable +class PluginManagementModelGateway(Protocol): + """ + 插件管理模型工厂网关协议。 + """ + + def build_operation_log_export_query(self, export_limit: int) -> PluginOperationLogExportQueryModel: + """ + 构建插件操作日志导出查询对象。 + + :param export_limit: 导出数量上限 + :return: 插件操作日志导出查询对象 + """ + + def build_config_update(self, values: dict[str, PluginConfigValue]) -> PluginConfigUpdateModel: + """ + 构建插件配置更新对象。 + + :param values: 配置键值 + :return: 插件配置更新对象 + """ + + def build_migration_record( + self, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + status: str = 'success', + error_message: str | None = None, + ) -> PluginMigrationModel: + """ + 构建插件 migration 执行历史对象。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: 插件 migration 执行历史对象 + """ + + +@runtime_checkable +class PluginCommandRunnerGateway(Protocol): + """ + 插件命令执行网关协议。 + """ + + def run_command( + self, + command: list[str], + workdir: str, + *, + timeout: int | None = None, + output_callback: PluginCommandOutputCallback | None = None, + ) -> subprocess.CompletedProcess[str]: + """ + 执行系统命令。 + + :param command: 命令参数列表 + :param workdir: 命令工作目录 + :param timeout: 命令超时时间 + :param output_callback: 实时输出回调 + :return: 命令执行结果 + """ + + +class UnavailablePluginStateQueryGateway: + """ + 不可用的插件状态查询网关。 + """ + + @staticmethod + async def list_plugin_states() -> list[PluginStateRecord]: + """ + 获取插件状态列表。 + + :return: 插件状态列表 + :raises RuntimeError: 默认网关不提供插件状态查询能力 + """ + raise RuntimeError('插件运行时缺少插件状态查询适配器') + + @staticmethod + async def get_plugin_state(plugin_id: str) -> PluginStateRecord | None: + """ + 获取插件状态。 + + :param plugin_id: 插件ID + :return: 插件状态 + :raises RuntimeError: 默认网关不提供插件状态查询能力 + """ + raise RuntimeError('插件运行时缺少插件状态查询适配器') + + +class UnavailablePluginMigrationHistoryGateway: + """ + 不可用的插件 migration 历史网关。 + """ + + @staticmethod + async def list_plugin_migrations( + plugin_id: str, + status: str | None = None, + ) -> list[PluginMigrationModel]: + """ + 查询插件 migration 历史。 + + :param plugin_id: 插件ID + :param status: 执行状态 + :return: 插件 migration 历史列表 + :raises RuntimeError: 默认网关不提供插件 migration 历史能力 + """ + raise RuntimeError('插件运行时缺少 migration 历史适配器') + + @staticmethod + async def get_plugin_migration(plugin_id: str, migration_path: str) -> PluginMigrationModel | None: + """ + 获取插件 migration 历史。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: 插件 migration 历史 + :raises RuntimeError: 默认网关不提供插件 migration 历史能力 + """ + raise RuntimeError('插件运行时缺少 migration 历史适配器') + + @staticmethod + async def mark_plugin_migration_status( + plugin_id: str, + migration_path: str, + status: str, + error_message: str | None = None, + ) -> PluginMigrationModel | None: + """ + 人工标记插件 migration 历史状态。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param status: 执行状态 + :param error_message: 失败错误信息 + :return: 插件 migration 历史 + :raises RuntimeError: 默认网关不提供插件 migration 历史能力 + """ + raise RuntimeError('插件运行时缺少 migration 历史适配器') + + +class UnavailablePluginPurgePlanGateway: + """ + 不可用的插件清理计划网关。 + """ + + @staticmethod + async def build_plugin_purge_plan(discovered_plugin: DiscoveredPlugin) -> PluginPurgePlan: + """ + 构建插件物理清理计划。 + + :param discovered_plugin: 已发现插件 + :return: 插件物理清理计划 + :raises RuntimeError: 默认网关不提供插件清理计划能力 + """ + raise RuntimeError('插件运行时缺少清理计划适配器') + + +class UnavailablePluginLifecycleStateGateway: + """ + 不可用的插件生命周期状态写入网关。 + """ + + @staticmethod + async def set_plugin_enabled_state( + plugin_id: str, + enabled: bool, + discovered_plugin: DiscoveredPlugin | None = None, + ) -> CrudResponseModel: + """ + 更新插件启停状态。 + + :param plugin_id: 插件ID + :param enabled: 是否启用 + :param discovered_plugin: 已发现插件 + :return: 操作响应 + :raises RuntimeError: 默认网关不提供生命周期状态写入能力 + """ + raise RuntimeError('插件运行时缺少生命周期状态适配器') + + @staticmethod + async def mark_plugin_uninstalled_state(plugin_id: str) -> CrudResponseModel: + """ + 标记插件安全卸载。 + + :param plugin_id: 插件ID + :return: 操作响应 + :raises RuntimeError: 默认网关不提供生命周期状态写入能力 + """ + raise RuntimeError('插件运行时缺少生命周期状态适配器') + + +class UnavailablePluginLifecycleUnitOfWorkGateway: + """ + 不可用的插件生命周期主事务工作单元网关。 + """ + + @staticmethod + def open_lifecycle_unit_of_work() -> AbstractAsyncContextManager[PluginLifecycleUnitOfWork]: + """ + 打开生命周期主事务工作单元。 + + :return: 生命周期主事务工作单元上下文 + :raises RuntimeError: 默认网关不提供生命周期主事务能力 + """ + raise RuntimeError('插件运行时缺少生命周期主事务适配器') + + +class UnavailablePluginMigrationExecutionGateway: + """ + 不可用的插件 migration 独立执行网关。 + """ + + @staticmethod + async def run_plugin_migrations(discovered_plugin: DiscoveredPlugin) -> list[PluginMigrationResult]: + """ + 使用独立执行事务运行插件 migration。 + + :param discovered_plugin: 已发现插件 + :return: migration 执行结果列表 + :raises RuntimeError: 默认网关不提供 migration 执行能力 + """ + raise RuntimeError('插件运行时缺少 migration 执行适配器') + + +class UnavailablePluginConfigGateway: + """ + 不可用的插件配置网关。 + """ + + @staticmethod + async def get_plugin_config( + discovered_plugin: DiscoveredPlugin, + *, + reveal_secret: bool = False, + ) -> list[PluginConfigValueModel]: + """ + 获取插件配置。 + + :param discovered_plugin: 已发现插件 + :param reveal_secret: 是否展示敏感配置原值 + :return: 插件配置列表 + :raises RuntimeError: 默认网关不提供插件配置能力 + """ + raise RuntimeError('插件运行时缺少插件配置适配器') + + @staticmethod + async def update_plugin_config( + discovered_plugin: DiscoveredPlugin, + values: dict[str, PluginConfigValue], + ) -> list[PluginConfigValueModel]: + """ + 更新插件配置。 + + :param discovered_plugin: 已发现插件 + :param values: 配置键值 + :return: 插件配置列表 + :raises RuntimeError: 默认网关不提供插件配置能力 + """ + raise RuntimeError('插件运行时缺少插件配置适配器') + + @staticmethod + async def set_plugin_config( + discovered_plugin: DiscoveredPlugin, + values: dict[str, PluginConfigValue], + *, + audit_operation: str, + success_message: str, + ) -> list[PluginConfigValueModel]: + """ + 在同一事务中更新插件配置并记录审计日志。 + + :param discovered_plugin: 已发现插件 + :param values: 配置键值 + :param audit_operation: 审计操作类型 + :param success_message: 操作成功提示 + :return: 插件配置列表 + :raises RuntimeError: 默认网关不提供插件配置能力 + """ + raise RuntimeError('插件运行时缺少插件配置适配器') + + +class UnavailablePluginAuditGateway: + """ + 不可用的插件审计网关。 + """ + + @staticmethod + async def list_plugin_operation_logs(*, export_limit: int) -> list[PluginOperationLogDetailModel]: + """ + 获取插件操作审计日志列表。 + + :param export_limit: 导出数量上限 + :return: 插件操作日志详情列表 + :raises RuntimeError: 默认网关不提供插件审计能力 + """ + raise RuntimeError('插件运行时缺少插件审计适配器') + + @staticmethod + async def add_plugin_operation_log( + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> None: + """ + 记录插件操作审计日志。 + + :param payload: 操作日志负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续 + :raises RuntimeError: 默认网关不提供插件审计能力 + """ + raise RuntimeError('插件运行时缺少插件审计适配器') + + @staticmethod + async def mark_plugin_error(plugin_id: str, error_message: str) -> bool: + """ + 标记插件错误状态。 + + :param plugin_id: 插件ID + :param error_message: 错误信息 + :return: 是否标记成功 + :raises RuntimeError: 默认网关不提供插件审计能力 + """ + raise RuntimeError('插件运行时缺少插件审计适配器') + + +class UnavailablePluginManagementModelGateway: + """ + 不可用的插件管理模型工厂网关。 + """ + + @staticmethod + def build_operation_log_export_query(export_limit: int) -> PluginOperationLogExportQueryModel: + """ + 构建插件操作日志导出查询对象。 + + :param export_limit: 导出数量上限 + :return: 插件操作日志导出查询对象 + :raises RuntimeError: 默认网关不提供管理状态 VO 适配器 + """ + raise RuntimeError('插件运行时缺少操作日志查询适配器') + + @staticmethod + def build_config_update(values: dict[str, PluginConfigValue]) -> PluginConfigUpdateModel: + """ + 构建插件配置更新对象。 + + :param values: 配置键值 + :return: 插件配置更新对象 + :raises RuntimeError: 默认网关不提供管理状态 VO 适配器 + """ + raise RuntimeError('插件运行时缺少配置更新适配器') + + @staticmethod + def build_migration_record( + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + status: str = 'success', + error_message: str | None = None, + ) -> PluginMigrationModel: + """ + 构建插件 migration 执行历史对象。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: 插件 migration 执行历史对象 + :raises RuntimeError: 默认网关不提供管理状态 VO 适配器 + """ + raise RuntimeError('插件运行时缺少 migration 历史记录适配器') + + +class DefaultPluginCommandRunnerGateway: + """ + 默认插件命令执行网关。 + """ + + @staticmethod + def run_command( + command: list[str], + workdir: str, + *, + timeout: int | None = None, + output_callback: PluginCommandOutputCallback | None = None, + ) -> subprocess.CompletedProcess[str]: + """ + 执行系统命令。 + + :param command: 命令参数列表 + :param workdir: 命令工作目录 + :param timeout: 命令超时时间 + :param output_callback: 实时输出回调 + :return: 命令执行结果 + """ + return run_plugin_command( + command, + workdir, + timeout=timeout, + output_callback=output_callback, + ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/__init__.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/__init__.py new file mode 100644 index 0000000..6aa9a0c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/__init__.py @@ -0,0 +1,11 @@ +from .enable import PluginEnableUseCase +from .install import PluginInstallUseCase +from .purge import PluginPurgeUseCase +from .upgrade import PluginUpgradeUseCase + +__all__ = [ + 'PluginEnableUseCase', + 'PluginInstallUseCase', + 'PluginPurgeUseCase', + 'PluginUpgradeUseCase', +] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/common.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/common.py new file mode 100644 index 0000000..c56be55 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/common.py @@ -0,0 +1,122 @@ +from pathlib import Path +from typing import Any, Protocol, cast + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.runtime.support import PluginPrecheckContext + +from ..context import PluginRuntimeContextService +from ..responses import PluginLifecycleResponse, PluginRuntimeBlockedPayloadDict + + +class PluginLifecycleSessionContext(Protocol): + """ + 带数据库会话的生命周期上下文。 + """ + + session_context: Any | None + session: Any | None + + +class PluginLifecycleUseCaseSupport: + """ + 生命周期 use case 公共协作能力。 + """ + + context: PluginRuntimeContextService + + def _discover_plugins(self, backend_root: Path) -> list[DiscoveredPlugin]: + """ + 发现本地插件。 + + :param backend_root: 后端项目根目录 + :return: 已发现插件列表 + """ + return self.context.discover_plugins(backend_root) + + def _get_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None: + """ + 根据插件 ID 获取已发现插件。 + + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + return self.context.get_discovered_plugin(plugin_id) + + def _get_discovered_plugin_from_list( + self, + discovered_plugins: list[DiscoveredPlugin], + plugin_id: str, + ) -> DiscoveredPlugin | None: + """ + 从已发现插件列表中查找指定插件。 + + :param discovered_plugins: 已发现插件列表 + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + return self.context.get_discovered_plugin_from_list(discovered_plugins, plugin_id) + + def _build_operation_blocked_payload( + self, + discovered_plugin: DiscoveredPlugin, + operation: str, + *, + dry_run: bool | None = None, + ) -> PluginRuntimeBlockedPayloadDict | None: + """ + 构建运行模式阻断负载。 + + :param discovered_plugin: 已发现插件 + :param operation: 操作类型 + :param dry_run: 是否预演 + :return: 阻断负载,不阻断时返回 None + """ + return self.context.build_operation_blocked_payload(discovered_plugin, operation, dry_run=dry_run) + + async def _build_precheck_context( + self, + backend_root: Path, + discovered_plugin: DiscoveredPlugin, + discovered_plugins: list[DiscoveredPlugin], + ) -> PluginPrecheckContext: + """ + 构建插件操作预检上下文。 + + :param backend_root: 后端项目根目录 + :param discovered_plugin: 当前插件 + :param discovered_plugins: 已发现插件列表 + :return: 插件操作预检上下文 + """ + return await self.context.build_precheck_context(backend_root, discovered_plugin, discovered_plugins) + + def _with_plugin_capability( + self, + payload: PluginLifecycleResponse, + discovered_plugin: DiscoveredPlugin | None, + ) -> PluginLifecycleResponse: + """ + 为运行时响应负载附加插件操作能力。 + + :param payload: 运行时响应负载 + :param discovered_plugin: 已发现插件 + :return: 附加能力后的响应负载 + """ + return cast( + 'PluginLifecycleResponse', + self.context.with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + + async def _close_lifecycle_session(self, context: object) -> None: + """ + 关闭生命周期上下文中的数据库会话。 + + :param context: 生命周期上下文 + :return: None + """ + lifecycle_context = cast('PluginLifecycleSessionContext', context) + session_context = lifecycle_context.session_context + if session_context is None: + return + await session_context.__aexit__(None, None, None) + lifecycle_context.session_context = None + lifecycle_context.session = None diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/enable.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/enable.py new file mode 100644 index 0000000..ceb380c --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/enable.py @@ -0,0 +1,648 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from plugins.core.runtime.support import ( + PluginEnablePayloadBuilder, + PluginLifecyclePayloadBuilder, + PluginPayloadBuilder, + PluginPrecheckContext, + PluginRuntimePayloadBuilder, +) + +from .common import PluginLifecycleUseCaseSupport +from .runner import PluginLifecycleStep, PluginLifecycleStepFailed, PluginLifecycleStepRunner + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from plugins.core.discovery.scanner import DiscoveredPlugin + + from ..context import PluginRuntimeContextService + from ..dependency_container import PluginRuntimeDependencies + from ..responses import PluginLifecycleResponse + from .operations import PluginLifecycleRuntimeOperations + + +@dataclass(slots=True) +class PluginEnabledLifecycleContext: + """ + 插件启停声明式生命周期上下文。 + """ + + plugin_id: str + enabled: bool + dry_run: bool + operation: str + backend_root: Path | None = None + discovered_plugins: list[DiscoveredPlugin] | None = None + discovered_plugin: DiscoveredPlugin | None = None + precheck: PluginPrecheckContext | None = None + actions: list[dict[str, object]] | None = None + dependency_payload: dict[str, object] | None = None + response: dict[str, object] | None = None + session: AsyncSession | None = None + plugin_service: object | None = None + session_context: object | None = None + + +@dataclass(slots=True) +class PluginUninstallLifecycleContext: + """ + 插件卸载声明式生命周期上下文。 + """ + + plugin_id: str + dry_run: bool + backend_root: Path | None = None + discovered_plugins: list[DiscoveredPlugin] | None = None + discovered_plugin: DiscoveredPlugin | None = None + precheck: PluginPrecheckContext | None = None + actions: list[dict[str, object]] | None = None + dependency_payload: dict[str, object] | None = None + response: dict[str, object] | None = None + session: AsyncSession | None = None + plugin_service: object | None = None + session_context: object | None = None + + +class PluginEnableUseCase(PluginLifecycleUseCaseSupport): + """ + 插件启停和安全卸载 use case。 + """ + + def __init__( + self, + dependencies: PluginRuntimeDependencies, + runtime_operations: PluginLifecycleRuntimeOperations, + context: PluginRuntimeContextService, + ) -> None: + """ + 初始化插件启停 use case。 + + :param dependencies: 插件运行时依赖容器 + :param runtime_operations: 生命周期工作流所需的运行时协作能力 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.runtime_operations = runtime_operations + self.context = context + + async def _build_enabled_dependents_payload( + self, + plugin_id: str, + discovered_plugins: list[DiscoveredPlugin], + ) -> dict[str, object]: + """ + 构建已启用依赖方检查负载。 + + :param plugin_id: 被停用或卸载的插件ID + :param discovered_plugins: 已发现插件列表 + :return: 依赖方检查负载 + """ + dependent_result = await self.context.check_enabled_plugin_dependents(plugin_id, discovered_plugins) + return cast('dict[str, object]', PluginEnablePayloadBuilder.build_dependency_payload(dependent_result)) + + async def _disable_plugin( + self, + plugin_id: str, + discovered_plugin: DiscoveredPlugin | None, + discovered_plugins: list[DiscoveredPlugin], + *, + dry_run: bool = False, + ) -> PluginLifecycleResponse: + """ + 停用插件并在写库前检查已启用依赖方。 + + :param plugin_id: 插件ID + :param discovered_plugin: 已发现插件 + :param discovered_plugins: 已发现插件列表 + :param dry_run: 是否仅预演 + :return: 插件停用结果负载 + """ + context = PluginEnabledLifecycleContext( + plugin_id=plugin_id, + enabled=False, + dry_run=dry_run, + operation='disable', + discovered_plugin=discovered_plugin, + discovered_plugins=discovered_plugins, + ) + try: + result = await PluginLifecycleStepRunner(self._build_disable_steps()).run(context) + if result.stop: + await self._close_enabled_session(result.context) + return result.stop.payload + return self._build_enabled_success_payload(result.context) + except PluginLifecycleStepFailed as exc: + await self._close_enabled_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '更新插件启停状态失败', + exc.original_error, + plugin_id=plugin_id, + failed_step=exc.step_name, + ) + except Exception as exc: + await self._close_enabled_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '更新插件启停状态失败', + exc, + plugin_id=plugin_id, + failed_step='check_enabled_dependents', + ) + + def _build_disable_steps(self) -> list[PluginLifecycleStep[PluginEnabledLifecycleContext]]: + """ + 构建插件停用声明式生命周期步骤。 + + :return: 插件停用步骤列表 + """ + return [ + PluginLifecycleStep('check_enabled_dependents', self._enabled_step_check_enabled_dependents), + PluginLifecycleStep('update_enabled_state', self._enabled_step_update_enabled_state), + ] + + async def set_plugin_enabled( + self, + plugin_id: str, + *, + enabled: bool, + dry_run: bool = False, + record_operation_log: bool = True, + ) -> PluginLifecycleResponse: + """ + 更新插件启停状态并按需记录审计日志。 + + :param plugin_id: 插件ID + :param enabled: 是否启用 + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :return: 插件启停结果负载 + """ + payload = await self._set_plugin_enabled(plugin_id, enabled=enabled, dry_run=dry_run) + payload_view = cast('dict[str, object]', payload) + payload_view['operation'] = 'enable' if enabled else 'disable' + if enabled and not dry_run: + await self.runtime_operations.record_plugin_failure_state(payload_view, '插件启用失败') + if record_operation_log and not dry_run: + await self.runtime_operations.record_plugin_operation_log( + payload_view, + dry_run=dry_run, + continue_on_error=False, + ) + + return payload + + async def _set_plugin_enabled( + self, plugin_id: str, *, enabled: bool, dry_run: bool = False + ) -> PluginLifecycleResponse: + """ + 更新插件启停状态。 + + :param plugin_id: 插件ID + :param enabled: 是否启用 + :param dry_run: 是否仅预演 + :return: 插件启停结果负载 + """ + operation = 'enable' if enabled else 'disable' + context = PluginEnabledLifecycleContext( + plugin_id=plugin_id, + enabled=enabled, + dry_run=dry_run, + operation=operation, + ) + try: + result = await PluginLifecycleStepRunner(self._build_enabled_steps(enabled)).run(context) + if result.stop: + await self._close_enabled_session(result.context) + return result.stop.payload + if not result.context.enabled: + return await self._disable_plugin( + result.context.plugin_id, + result.context.discovered_plugin, + result.context.discovered_plugins or [], + dry_run=result.context.dry_run, + ) + return self._build_enabled_success_payload(result.context) + except PluginLifecycleStepFailed as exc: + await self._close_enabled_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '更新插件启停状态失败', + exc.original_error, + plugin_id=plugin_id, + failed_step=exc.step_name, + ) + except Exception as exc: + await self._close_enabled_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '更新插件启停状态失败', + exc, + plugin_id=plugin_id, + failed_step=f'prepare_{operation}', + ) + + def _build_enabled_steps(self, enabled: bool) -> list[PluginLifecycleStep[PluginEnabledLifecycleContext]]: + """ + 构建插件启停声明式生命周期步骤。 + + :param enabled: 是否启用 + :return: 插件启停步骤列表 + """ + steps = [PluginLifecycleStep('discover_plugin', self._enabled_step_discover_plugin)] + if enabled: + steps.extend( + [ + PluginLifecycleStep('build_precheck', self._enabled_step_build_precheck), + PluginLifecycleStep('update_enabled_state', self._enabled_step_update_enabled_state), + ] + ) + + return steps + + async def _enabled_step_discover_plugin( + self, + context: PluginEnabledLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 发现插件并检查运行模式阻断。 + + :param context: 插件启停上下文 + :return: 阻断 payload 或 None + """ + context.backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + context.discovered_plugins = self._discover_plugins(context.backend_root) + context.discovered_plugin = self._get_discovered_plugin_from_list( + context.discovered_plugins, + context.plugin_id, + ) + if context.discovered_plugin: + return self._build_operation_blocked_payload( + context.discovered_plugin, + context.operation, + dry_run=context.dry_run, + ) + if context.enabled: + return PluginPayloadBuilder.build_plugin_not_found_payload( + context.plugin_id, + operation=context.operation, + enabled=context.enabled, + dry_run=context.dry_run, + ) + + return None + + async def _enabled_step_build_precheck( + self, + context: PluginEnabledLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 构建启用预检。 + + :param context: 插件启停上下文 + :return: dry-run 或阻断 payload + """ + context.precheck = await self._build_precheck_context( + cast('Path', context.backend_root), + cast('DiscoveredPlugin', context.discovered_plugin), + context.discovered_plugins or [], + ) + context.actions = PluginPayloadBuilder.build_enabled_actions( + context.enabled, + context.precheck.plugin_dependency_result.ok, + ) + context.dependency_payload = cast( + 'dict[str, object]', + PluginEnablePayloadBuilder.build_dependency_payload(context.precheck.plugin_dependency_result), + ) + if not context.dry_run: + return PluginLifecyclePayloadBuilder.build_first_precheck_blocker_payload( + context.plugin_id, + operation=context.operation, + actions=context.actions, + precheck=context.precheck, + extra_payload={'operation': context.operation, 'enabled': context.enabled}, + ) + + payload = PluginLifecyclePayloadBuilder.build_operation_dry_run_payload( + context.plugin_id, + operation=context.operation, + message='插件启停演练完成,未执行实际写入', + actions=context.actions, + precheck=context.precheck, + extra_payload={ + 'enabled': context.enabled, + **(context.dependency_payload or {}), + }, + ) + return self._with_plugin_capability(payload, context.discovered_plugin) + + async def _enabled_step_check_enabled_dependents( + self, + context: PluginEnabledLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 检查已启用依赖方。 + + :param context: 插件启停上下文 + :return: dry-run 或依赖阻断 payload + """ + context.dependency_payload = await self._build_enabled_dependents_payload( + context.plugin_id, + context.discovered_plugins or [], + ) + if context.dry_run: + payload = PluginEnablePayloadBuilder.build_dry_run_payload( + context.plugin_id, + operation=context.operation, + enabled=context.enabled, + dependency_payload=context.dependency_payload, + ) + return self._with_plugin_capability(payload, context.discovered_plugin) + if bool(context.dependency_payload.get('pluginDependencyOk', True)): + return None + + payload = PluginEnablePayloadBuilder.build_dependency_blocker_payload( + context.plugin_id, + operation=context.operation, + enabled=context.enabled, + dependency_payload=context.dependency_payload, + ) + return self._with_plugin_capability(payload, context.discovered_plugin) + + async def _enabled_step_update_enabled_state( + self, + context: PluginEnabledLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 更新插件启用状态。 + + :param context: 插件启停上下文 + :return: 更新失败 payload 或 None + """ + context.response = await self.dependencies.lifecycle_state_gateway.set_plugin_enabled_state( + context.plugin_id, + context.enabled, + context.discovered_plugin, + ) + if not context.response.is_success: + return PluginEnablePayloadBuilder.build_update_failure_payload( + context.plugin_id, + operation=context.operation, + enabled=context.enabled, + message=context.response.message, + ) + + return None + + def _build_enabled_success_payload( + self, + context: PluginEnabledLifecycleContext, + ) -> PluginLifecycleResponse: + """ + 构建插件启停成功负载。 + + :param context: 插件启停上下文 + :return: 插件启停成功负载 + """ + payload = PluginEnablePayloadBuilder.build_success_payload( + context.plugin_id, + operation=context.operation, + enabled=context.enabled, + message=context.response.message, + dependency_payload=context.dependency_payload or {}, + ) + return self._with_plugin_capability(payload, context.discovered_plugin) + + async def _close_enabled_session(self, context: PluginEnabledLifecycleContext) -> None: + """ + 关闭插件启停数据库会话。 + + :param context: 插件启停上下文 + :return: None + """ + await self._close_lifecycle_session(context) + + async def uninstall_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + operated_by: str | None = None, + ) -> PluginLifecycleResponse: + """ + 安全卸载插件。 + + 卸载不删除源码和业务数据,但会移除插件菜单及平台菜单归属数据。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :param operated_by: 操作者用户名,非预演时写入审计日志 + :return: 插件卸载结果负载 + """ + result = await self._uninstall_plugin(plugin_id, dry_run=dry_run) + result = PluginEnablePayloadBuilder.build_uninstall_payload(cast('dict[str, object]', result), dry_run=dry_run) + result_view = cast('dict[str, object]', result) + if record_operation_log and not dry_run: + if operated_by is not None: + result_view['operatedBy'] = operated_by + await self.runtime_operations.record_plugin_operation_log( + result_view, + dry_run=dry_run, + continue_on_error=False, + ) + + return result + + async def _uninstall_plugin(self, plugin_id: str, *, dry_run: bool = False) -> PluginLifecycleResponse: + """ + 标记插件卸载并停用关联运行资源。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :return: 插件卸载结果负载 + """ + context = PluginUninstallLifecycleContext(plugin_id=plugin_id, dry_run=dry_run) + try: + result = await PluginLifecycleStepRunner(self._build_uninstall_steps()).run(context) + if result.stop: + await self._close_uninstall_session(result.context) + return result.stop.payload + return self._build_uninstall_success_payload(result.context) + except PluginLifecycleStepFailed as exc: + await self._close_uninstall_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '插件卸载失败', + exc.original_error, + plugin_id=plugin_id, + failed_step=exc.step_name, + ) + except Exception as exc: + await self._close_uninstall_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '插件卸载失败', + exc, + plugin_id=plugin_id, + failed_step='prepare_uninstall', + ) + + def _build_uninstall_steps(self) -> list[PluginLifecycleStep[PluginUninstallLifecycleContext]]: + """ + 构建插件卸载声明式生命周期步骤。 + + :return: 插件卸载步骤列表 + """ + return [ + PluginLifecycleStep('discover_plugin', self._uninstall_step_discover_plugin), + PluginLifecycleStep('check_enabled_dependents', self._uninstall_step_check_enabled_dependents), + PluginLifecycleStep('build_precheck', self._uninstall_step_build_precheck), + PluginLifecycleStep('mark_uninstalled', self._uninstall_step_mark_uninstalled), + ] + + async def _uninstall_step_discover_plugin( + self, + context: PluginUninstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 发现卸载目标插件。 + + :param context: 插件卸载上下文 + :return: 阻断 payload 或 None + """ + context.backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + context.discovered_plugins = self._discover_plugins(context.backend_root) + context.discovered_plugin = self._get_discovered_plugin_from_list( + context.discovered_plugins, + context.plugin_id, + ) + if not context.discovered_plugin: + return None + + return self._build_operation_blocked_payload( + context.discovered_plugin, + 'uninstall', + dry_run=context.dry_run, + ) + + async def _uninstall_step_check_enabled_dependents( + self, + context: PluginUninstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 检查卸载目标的已启用依赖方。 + + :param context: 插件卸载上下文 + :return: dry-run 或依赖阻断 payload + """ + context.dependency_payload = await self._build_enabled_dependents_payload( + context.plugin_id, + context.discovered_plugins or [], + ) + plugin_dependency_ok = bool(context.dependency_payload.get('pluginDependencyOk', True)) + if context.dry_run and not context.discovered_plugin: + return PluginEnablePayloadBuilder.build_dry_run_payload( + context.plugin_id, + operation='uninstall', + enabled=False, + dependency_payload=context.dependency_payload, + ) + if not context.dry_run and not plugin_dependency_ok: + payload = PluginEnablePayloadBuilder.build_dependency_blocker_payload( + context.plugin_id, + operation='uninstall', + enabled=False, + dependency_payload=context.dependency_payload, + ) + return self._with_plugin_capability(payload, context.discovered_plugin) + + return None + + async def _uninstall_step_build_precheck( + self, + context: PluginUninstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 构建卸载预检。 + + :param context: 插件卸载上下文 + :return: dry-run payload 或 None + """ + if not context.discovered_plugin: + return None + context.precheck = await self._build_precheck_context( + cast('Path', context.backend_root), + context.discovered_plugin, + context.discovered_plugins or [], + ) + plugin_dependency_ok = bool((context.dependency_payload or {}).get('pluginDependencyOk', True)) + context.actions = PluginPayloadBuilder.build_enabled_actions(False, plugin_dependency_ok) + if not context.dry_run: + return None + + payload = PluginLifecyclePayloadBuilder.build_operation_dry_run_payload( + context.plugin_id, + operation='uninstall', + message='插件卸载演练完成,未执行实际写入', + actions=context.actions, + precheck=context.precheck, + extra_payload={ + 'enabled': False, + **(context.dependency_payload or {}), + }, + ok_from_precheck=False, + ) + return self._with_plugin_capability(payload, context.discovered_plugin) + + async def _uninstall_step_mark_uninstalled( + self, + context: PluginUninstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 标记插件卸载。 + + :param context: 插件卸载上下文 + :return: 更新失败 payload 或 None + """ + context.response = await self.dependencies.lifecycle_state_gateway.mark_plugin_uninstalled_state( + context.plugin_id, + ) + if context.response.is_success: + return None + + return PluginEnablePayloadBuilder.build_update_failure_payload( + context.plugin_id, + operation='uninstall', + enabled=False, + message=context.response.message, + ) + + def _build_uninstall_success_payload( + self, + context: PluginUninstallLifecycleContext, + ) -> PluginLifecycleResponse: + """ + 构建插件卸载成功负载。 + + :param context: 插件卸载上下文 + :return: 插件卸载成功负载 + """ + payload = PluginEnablePayloadBuilder.build_success_payload( + context.plugin_id, + operation='uninstall', + enabled=False, + message=context.response.message, + dependency_payload=context.dependency_payload or {}, + ) + return self._with_plugin_capability(payload, context.discovered_plugin) + + async def _close_uninstall_session(self, context: PluginUninstallLifecycleContext) -> None: + """ + 关闭插件卸载数据库会话。 + + :param context: 插件卸载上下文 + :return: None + """ + await self._close_lifecycle_session(context) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/install.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/install.py new file mode 100644 index 0000000..39d6389 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/install.py @@ -0,0 +1,558 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from plugins.core.lifecycle.migration import PluginMigrationError +from plugins.core.lifecycle.seed import PluginSeedRunner +from plugins.core.runtime.hooks import PluginHookRunner +from plugins.core.runtime.support import ( + PluginLifecyclePayloadBuilder, + PluginPayloadBuilder, + PluginPrecheckContext, + PluginRuntimePayloadBuilder, +) + +from .common import PluginLifecycleUseCaseSupport +from .runner import PluginLifecycleStep, PluginLifecycleStepFailed, PluginLifecycleStepRunner + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.lifecycle.migration import PluginMigrationResult + from plugins.core.lifecycle.seed import PluginSeedResult + from plugins.core.runtime.hooks import PluginHookResult + + from ..context import PluginRuntimeContextService + from ..dependency_container import PluginRuntimeDependencies + from ..responses import PluginLifecycleResponse + from .operations import PluginLifecycleRuntimeOperations + + +@dataclass(slots=True) +class PluginInstallLifecycleContext: + """ + 插件安装声明式生命周期上下文。 + """ + + plugin_id: str + dry_run: bool + backend_root: Path | None = None + discovered_plugins: list[DiscoveredPlugin] | None = None + discovered_plugin: DiscoveredPlugin | None = None + precheck: PluginPrecheckContext | None = None + actions: list[dict[str, object]] | None = None + dependency_install_view: dict[str, object] | None = None + plugin: object | None = None + installed_configs: list[object] | None = None + migration_results: list[PluginMigrationResult] | None = None + seed_results: list[PluginSeedResult] | None = None + hook_result: PluginHookResult | None = None + session: AsyncSession | None = None + lifecycle_uow: object | None = None + session_context: object | None = None + + +class PluginInstallUseCase(PluginLifecycleUseCaseSupport): + """ + 插件安装 use case。 + """ + + def __init__( + self, + dependencies: PluginRuntimeDependencies, + runtime_operations: PluginLifecycleRuntimeOperations, + context: PluginRuntimeContextService, + ) -> None: + """ + 初始化插件安装 use case。 + + :param dependencies: 插件运行时依赖容器 + :param runtime_operations: 生命周期工作流所需的运行时协作能力 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.runtime_operations = runtime_operations + self.context = context + + async def install_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + operated_by: str | None = None, + ) -> PluginLifecycleResponse: + """ + 安装插件并按需记录审计日志。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :param operated_by: 操作者用户名,非预演时写入审计日志 + :return: 插件安装结果负载 + """ + payload = await self._install_plugin(plugin_id, dry_run=dry_run) + payload_view = cast('dict[str, object]', payload) + payload_view['operation'] = 'install' + if not dry_run: + await self.runtime_operations.record_plugin_failure_state(payload_view, '插件安装失败') + if record_operation_log and not dry_run: + if operated_by is not None: + payload_view['operatedBy'] = operated_by + await self.runtime_operations.record_plugin_operation_log( + payload_view, + dry_run=dry_run, + continue_on_error=False, + ) + + return payload + + async def _install_plugin(self, plugin_id: str, *, dry_run: bool = False) -> PluginLifecycleResponse: + """ + 安装插件。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :return: 插件安装结果负载 + """ + context = PluginInstallLifecycleContext(plugin_id=plugin_id, dry_run=dry_run) + try: + result = await PluginLifecycleStepRunner(self._build_install_steps()).run(context) + if result.stop: + await self._close_install_session(result.context) + return result.stop.payload + payload = self._build_install_success_payload(result.context) + payload['operation'] = 'install' + return self._with_plugin_capability(payload, result.context.discovered_plugin) + except PluginLifecycleStepFailed as exc: + await self._close_install_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '插件安装失败', + exc.original_error, + plugin_id=plugin_id, + failed_step=exc.step_name, + extra_payload=self._build_migration_failure_extra(exc.original_error), + ) + except Exception as exc: + await self._close_install_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '插件安装失败', + exc, + plugin_id=plugin_id, + failed_step='prepare_install', + extra_payload=self._build_migration_failure_extra(exc), + ) + + @staticmethod + def _build_migration_failure_extra(error: Exception) -> dict[str, object] | None: + """ + 构建 migration 失败恢复建议负载。 + + :param error: 原始异常 + :return: 额外异常负载 + """ + if not isinstance(error, PluginMigrationError): + return None + + return {'migrationRecovery': error.to_recovery_payload()} + + def _build_install_steps(self) -> list[PluginLifecycleStep[PluginInstallLifecycleContext]]: + """ + 构建插件安装声明式生命周期步骤。 + + :return: 插件安装步骤列表 + """ + return [ + PluginLifecycleStep('discover_plugin', self._install_step_discover_plugin), + PluginLifecycleStep('build_precheck', self._install_step_build_precheck), + PluginLifecycleStep('install_dependencies', self._install_step_install_dependencies), + PluginLifecycleStep('build_post_dependency_precheck', self._install_step_build_post_dependency_precheck), + PluginLifecycleStep('open_session', self._install_step_open_session), + PluginLifecycleStep('check_installed_menu_conflicts', self._install_step_check_installed_menu_conflicts), + PluginLifecycleStep('upsert_plugin', self._install_step_upsert_plugin), + PluginLifecycleStep('install_menus', self._install_step_install_menus), + PluginLifecycleStep('install_configs', self._install_step_install_configs), + PluginLifecycleStep('install_jobs', self._install_step_install_jobs), + PluginLifecycleStep('run_migrations', self._install_step_run_migrations), + PluginLifecycleStep('run_seeds', self._install_step_run_seeds), + PluginLifecycleStep('run_install_hook', self._install_step_run_install_hook), + PluginLifecycleStep('mark_installed', self._install_step_mark_installed), + PluginLifecycleStep('commit', self._install_step_commit), + PluginLifecycleStep('close_session', self._install_step_close_session), + ] + + async def _install_step_discover_plugin( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 发现插件并检查运行模式阻断。 + + :param context: 插件安装上下文 + :return: 阻断 payload 或 None + """ + context.backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + context.discovered_plugins = self._discover_plugins(context.backend_root) + context.discovered_plugin = self._get_discovered_plugin_from_list(context.discovered_plugins, context.plugin_id) + if not context.discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(context.plugin_id) + + return self._build_operation_blocked_payload( + context.discovered_plugin, + 'install', + dry_run=context.dry_run, + ) + + async def _install_step_build_precheck( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 构建安装预检和动作计划。 + + :param context: 插件安装上下文 + :return: dry-run 或阻断 payload + """ + self._refresh_install_actions(await self._build_install_precheck(context), context) + if context.dry_run: + payload = PluginLifecyclePayloadBuilder.build_install_dry_run_payload( + context.plugin_id, + context.actions or [], + cast('PluginPrecheckContext', context.precheck), + ) + payload['operation'] = 'install' + return self._with_plugin_capability(payload, context.discovered_plugin) + + return PluginLifecyclePayloadBuilder.build_first_precheck_blocker_payload( + context.plugin_id, + operation='install', + actions=context.actions or [], + precheck=cast('PluginPrecheckContext', context.precheck), + ) + + async def _install_step_install_dependencies( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 生成缺失依赖安装计划。 + + :param context: 插件安装上下文 + :return: 依赖缺失阻断 payload 或 None + """ + dependency_install_payload = await self.runtime_operations.install_plugin_dependencies_from_result_async( + context.plugin_id, + cast('PluginPrecheckContext', context.precheck).dependency_result, + dry_run=True, + discovered_plugin=context.discovered_plugin, + ) + context.dependency_install_view = cast('dict[str, object]', dependency_install_payload) + if context.dependency_install_view.get('dependencyOk', False): + return None + + return PluginLifecyclePayloadBuilder.build_precheck_blocker_payload( + context.plugin_id, + message='插件依赖缺失,安装已中止,请先显式安装依赖', + actions=context.actions or [], + precheck=cast('PluginPrecheckContext', context.precheck), + extra_payload={'dependencyInstall': context.dependency_install_view}, + ) + + async def _install_step_build_post_dependency_precheck( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 依赖安装后重新预检。 + + :param context: 插件安装上下文 + :return: 依赖阻断 payload 或 None + """ + precheck = await self._build_install_precheck(context) + if context.dependency_install_view is not None: + context.dependency_install_view['postCheck'] = PluginPayloadBuilder.build_dependency_check_payload( + context.plugin_id, + precheck.dependency_result, + ) + self._refresh_install_actions(precheck, context) + + return PluginLifecyclePayloadBuilder.build_dependency_blocker_payload( + context.plugin_id, + actions=context.actions or [], + precheck=precheck, + dependency_install_payload=context.dependency_install_view or {}, + ) + + async def _install_step_open_session( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 打开安装数据库会话。 + + :param context: 插件安装上下文 + :return: None + """ + context.session_context = self.dependencies.lifecycle_uow_gateway.open_lifecycle_unit_of_work() + context.lifecycle_uow = await context.session_context.__aenter__() + context.session = context.lifecycle_uow.session + + return None + + async def _install_step_check_installed_menu_conflicts( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 检查已安装菜单冲突。 + + :param context: 插件安装上下文 + :return: 菜单冲突 payload 或 None + """ + installed_menu_conflicts = await context.lifecycle_uow.check_installed_menu_conflicts( + context.discovered_plugin, + ) + if not installed_menu_conflicts: + return None + + return PluginLifecyclePayloadBuilder.build_installed_menu_conflict_payload( + context.plugin_id, + message='插件菜单与已安装菜单存在冲突,安装已中止', + actions=context.actions or [], + precheck=cast('PluginPrecheckContext', context.precheck), + installed_menu_conflicts=installed_menu_conflicts, + ) + + async def _install_step_upsert_plugin( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 写入插件基础状态。 + + :param context: 插件安装上下文 + :return: None + """ + context.plugin = await context.lifecycle_uow.upsert_discovered_plugin( + context.discovered_plugin, + Path(self.dependencies.runtime_environment.get_backend_plugins_dir()), + Path(self.dependencies.runtime_environment.get_frontend_plugins_dir()), + ) + + return None + + async def _install_step_install_menus( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 安装插件菜单。 + + :param context: 插件安装上下文 + :return: None + """ + plugin_enabled = getattr(context.plugin, 'enabled', '0') == '0' + await context.lifecycle_uow.install_plugin_menu( + context.discovered_plugin, + enabled=plugin_enabled, + ) + + return None + + async def _install_step_install_configs( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 安装插件默认配置。 + + :param context: 插件安装上下文 + :return: None + """ + context.installed_configs = await context.lifecycle_uow.install_plugin_default_config( + context.discovered_plugin, + ) + + return None + + async def _install_step_install_jobs( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 同步插件定时任务。 + + :param context: 插件安装上下文 + :return: None + """ + plugin_enabled = getattr(context.plugin, 'enabled', '0') == '0' + await context.lifecycle_uow.install_plugin_jobs( + context.discovered_plugin, + enabled=plugin_enabled, + ) + + return None + + async def _install_step_run_migrations( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 执行插件 migration。 + + :param context: 插件安装上下文 + :return: None + """ + context.migration_results = await self.dependencies.migration_execution_gateway.run_plugin_migrations( + context.discovered_plugin, + ) + + return None + + async def _install_step_run_seeds( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 执行插件 seed。 + + :param context: 插件安装上下文 + :return: None + """ + context.seed_results = await PluginSeedRunner(context.discovered_plugin).run(context.session) + + return None + + async def _install_step_run_install_hook( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 执行插件安装钩子。 + + :param context: 插件安装上下文 + :return: None + """ + context.hook_result = await PluginHookRunner(context.discovered_plugin).run( + 'on_install', + query_db=context.session, + ) + + return None + + async def _install_step_mark_installed( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 标记插件已安装。 + + :param context: 插件安装上下文 + :return: None + """ + context.plugin = await context.lifecycle_uow.mark_plugin_installed( + context.discovered_plugin, + ) + + return None + + async def _install_step_commit( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 提交安装事务。 + + :param context: 插件安装上下文 + :return: None + """ + await context.lifecycle_uow.commit() + + return None + + async def _install_step_close_session( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 关闭安装数据库会话。 + + :param context: 插件安装上下文 + :return: None + """ + if context.session_context is not None: + await self._close_install_session(context) + + return None + + async def _close_install_session(self, context: PluginInstallLifecycleContext) -> None: + """ + 关闭安装数据库会话。 + + :param context: 插件安装上下文 + :return: None + """ + await self._close_lifecycle_session(context) + + async def _build_install_precheck( + self, + context: PluginInstallLifecycleContext, + ) -> PluginPrecheckContext: + """ + 构建安装预检上下文。 + + :param context: 插件安装上下文 + :return: 插件预检上下文 + """ + return await self._build_precheck_context( + cast('Path', context.backend_root), + cast('DiscoveredPlugin', context.discovered_plugin), + context.discovered_plugins or [], + ) + + def _refresh_install_actions( + self, + precheck: PluginPrecheckContext, + context: PluginInstallLifecycleContext, + ) -> None: + """ + 刷新安装预检和动作计划。 + + :param precheck: 插件预检上下文 + :param context: 插件安装上下文 + :return: None + """ + context.precheck = precheck + context.actions = PluginPayloadBuilder.build_install_actions( + cast('DiscoveredPlugin', context.discovered_plugin), + precheck.dependency_result.ok, + precheck.plugin_dependency_result.ok, + precheck.structure_result.ok, + precheck.menu_conflict_result.ok, + ) + + def _build_install_success_payload( + self, + context: PluginInstallLifecycleContext, + ) -> PluginLifecycleResponse: + """ + 构建安装成功负载。 + + :param context: 插件安装上下文 + :return: 安装成功负载 + """ + return PluginLifecyclePayloadBuilder.build_success_payload( + context.plugin_id, + message='插件安装完成', + actions=context.actions or [], + precheck=cast('PluginPrecheckContext', context.precheck), + plugin=context.plugin, + installed_configs=context.installed_configs, + migration_results=context.migration_results, + seed_results=context.seed_results, + hook_result=context.hook_result, + extra_payload={'dependencyInstall': context.dependency_install_view or {}}, + ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/operations.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/operations.py new file mode 100644 index 0000000..2318a56 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/operations.py @@ -0,0 +1,83 @@ +from collections.abc import Mapping +from typing import Protocol + +from ..context import PluginRuntimeContextService +from ..dependency_container import PluginRuntimeDependencies +from ..responses import PluginDependencyInstallResponse + + +class PluginLifecycleRuntimeOperations(Protocol): + """ + 生命周期工作流所需的运行时协作能力。 + """ + + dependencies: PluginRuntimeDependencies + context: PluginRuntimeContextService + + async def record_plugin_operation_log( + self, + payload: Mapping[str, object], + *, + dry_run: bool, + continue_on_error: bool, + ) -> None: + """ + 记录插件操作审计日志。 + + :param payload: 插件操作结果负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续 + :return: None + """ + + async def record_plugin_failure_state(self, payload: Mapping[str, object], default_message: str) -> None: + """ + 记录插件操作失败状态。 + + :param payload: 插件操作返回负载 + :param default_message: 缺省失败信息 + :return: None + """ + + def install_plugin_dependencies_from_result( + self, + plugin_id: str, + dependency_result: object, + *, + dry_run: bool = False, + discovered_plugin: object | None = None, + ) -> PluginDependencyInstallResponse: + """ + 根据既有依赖检查结果生成计划并执行依赖安装。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param dry_run: 是否仅预演 + :param discovered_plugin: 已发现插件 + :return: 插件依赖安装负载 + """ + + async def install_plugin_dependencies_from_result_async( + self, + plugin_id: str, + dependency_result: object, + *, + dry_run: bool = False, + discovered_plugin: object | None = None, + ) -> PluginDependencyInstallResponse: + """ + 根据既有依赖检查结果异步生成计划并执行依赖安装。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param dry_run: 是否仅预演 + :param discovered_plugin: 已发现插件 + :return: 插件依赖安装负载 + """ + + def refresh_dependency_checker(self) -> None: + """ + 刷新插件 Python/npm 依赖检查器。 + + :return: None + """ diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/purge.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/purge.py new file mode 100644 index 0000000..439351d --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/purge.py @@ -0,0 +1,433 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from plugins.core.runtime.hooks import PluginHookRunner +from plugins.core.runtime.support import ( + PluginEnablePayloadBuilder, + PluginLifecyclePayloadBuilder, + PluginPayloadBuilder, + PluginPrecheckContext, + PluginPurgePayloadBuilder, + PluginRuntimePayloadBuilder, +) + +from .common import PluginLifecycleUseCaseSupport +from .runner import PluginLifecycleStep, PluginLifecycleStepFailed, PluginLifecycleStepRunner + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.runtime.hooks import PluginHookResult + + from ..context import PluginRuntimeContextService + from ..dependency_container import PluginRuntimeDependencies + from ..responses import PluginLifecycleResponse + from .operations import PluginLifecycleRuntimeOperations + + +@dataclass(slots=True) +class PluginPurgeLifecycleContext: + """ + 插件物理清理声明式生命周期上下文。 + """ + + plugin_id: str + dry_run: bool + backend_root: Path | None = None + discovered_plugins: list[DiscoveredPlugin] | None = None + discovered_plugin: DiscoveredPlugin | None = None + precheck: PluginPrecheckContext | None = None + dependency_payload: dict[str, object] | None = None + actions: list[dict[str, object]] | None = None + plan: object | None = None + hook_result: PluginHookResult | None = None + session: AsyncSession | None = None + lifecycle_uow: object | None = None + session_context: object | None = None + + +class PluginPurgeUseCase(PluginLifecycleUseCaseSupport): + """ + 插件物理清理 use case。 + """ + + def __init__( + self, + dependencies: PluginRuntimeDependencies, + runtime_operations: PluginLifecycleRuntimeOperations, + context: PluginRuntimeContextService, + ) -> None: + """ + 初始化插件物理清理 use case。 + + :param dependencies: 插件运行时依赖容器 + :param runtime_operations: 生命周期工作流所需的运行时协作能力 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.runtime_operations = runtime_operations + self.context = context + + async def _build_enabled_dependents_payload( + self, + plugin_id: str, + discovered_plugins: list[DiscoveredPlugin], + ) -> dict[str, object]: + """ + 构建已启用依赖方检查负载。 + + :param plugin_id: 被物理清理的插件ID + :param discovered_plugins: 已发现插件列表 + :return: 依赖方检查负载 + """ + dependent_result = await self.context.check_enabled_plugin_dependents(plugin_id, discovered_plugins) + return cast('dict[str, object]', PluginEnablePayloadBuilder.build_dependency_payload(dependent_result)) + + async def purge_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + operated_by: str | None = None, + ) -> PluginLifecycleResponse: + """ + 物理清理插件平台元数据并按需记录审计日志。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :param operated_by: 操作者用户名,非预演时写入审计日志 + :return: 插件物理清理结果负载 + """ + payload = await self._purge_plugin(plugin_id, dry_run=dry_run) + payload_view = cast('dict[str, object]', payload) + payload_view['operation'] = 'purge' + if record_operation_log and not dry_run: + if operated_by is not None: + payload_view['operatedBy'] = operated_by + await self.runtime_operations.record_plugin_operation_log( + payload_view, + dry_run=dry_run, + continue_on_error=False, + ) + + return payload + + async def _purge_plugin(self, plugin_id: str, *, dry_run: bool = False) -> PluginLifecycleResponse: + """ + 物理清理插件平台元数据。 + + purge 与 uninstall 语义隔离:uninstall 只停用插件,purge 会删除平台拥有的插件状态、 + 菜单关联、配置、migration 历史和插件任务。业务数据只能通过插件显式声明的 on_purge 钩子清理。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :return: 插件物理清理结果负载 + """ + context = PluginPurgeLifecycleContext(plugin_id=plugin_id, dry_run=dry_run) + try: + if not self._get_discovered_plugin(plugin_id): + return await self._purge_orphan_plugin_metadata(plugin_id, dry_run=dry_run) + result = await PluginLifecycleStepRunner(self._build_purge_steps()).run(context) + if result.stop: + await self._close_purge_session(result.context) + return result.stop.payload + payload = PluginPurgePayloadBuilder.build_success_payload( + result.context.plugin_id, + result.context.plan, + result.context.hook_result, + ) + return self._with_plugin_capability(payload, result.context.discovered_plugin) + except PluginLifecycleStepFailed as exc: + await self._close_purge_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '插件物理清理失败', + exc.original_error, + plugin_id=plugin_id, + failed_step=exc.step_name, + ) + except Exception as exc: + await self._close_purge_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '插件物理清理失败', + exc, + plugin_id=plugin_id, + failed_step='prepare_purge', + ) + + async def _purge_orphan_plugin_metadata( + self, + plugin_id: str, + *, + dry_run: bool, + ) -> PluginLifecycleResponse: + """ + 在插件源码缺失时按 ID 清理平台可确认归属的孤儿元数据。 + + 源码缺失意味着无法执行 onPurge,也无法推断插件业务表和文件资源; + 因此该路径只处理插件状态、菜单、配置、migration 历史和平台托管任务。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :return: 插件物理清理结果负载 + """ + backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + discovered_plugins = self._discover_plugins(backend_root) + dependency_payload = await self._build_enabled_dependents_payload(plugin_id, discovered_plugins) + session_context = self.dependencies.lifecycle_uow_gateway.open_lifecycle_unit_of_work() + lifecycle_uow = await session_context.__aenter__() + try: + plan = await lifecycle_uow.build_plugin_purge_plan_by_id(plugin_id) + if not any(item.enabled for item in plan.items): + return PluginPayloadBuilder.build_plugin_not_found_payload( + plugin_id, + operation='purge', + dry_run=dry_run, + ) + + if dry_run: + payload = PluginPurgePayloadBuilder.build_dry_run_payload(plugin_id, plan) + payload.update( + { + 'metadataOnly': True, + 'warnings': ['插件源码不存在,无法执行 onPurge 或清理插件自有业务资源'], + **dependency_payload, + } + ) + return cast('PluginLifecycleResponse', payload) + + if not bool(dependency_payload.get('pluginDependencyOk', True)): + return PluginEnablePayloadBuilder.build_dependency_blocker_payload( + plugin_id, + operation='purge', + enabled=False, + dependency_payload=dependency_payload, + message='插件仍被已启用插件依赖,孤儿元数据清理已中止', + ) + + await lifecycle_uow.purge_plugin_metadata_by_id(plugin_id) + await lifecycle_uow.commit() + payload = PluginPurgePayloadBuilder.build_success_payload(plugin_id, plan, None) + payload.update( + { + 'metadataOnly': True, + 'warnings': ['插件源码不存在,已跳过 onPurge;插件自有业务资源需人工确认'], + } + ) + return cast('PluginLifecycleResponse', payload) + finally: + await session_context.__aexit__(None, None, None) + + def _build_purge_steps(self) -> list[PluginLifecycleStep[PluginPurgeLifecycleContext]]: + """ + 构建插件物理清理声明式生命周期步骤。 + + :return: 插件物理清理步骤列表 + """ + return [ + PluginLifecycleStep('discover_plugin', self._purge_step_discover_plugin), + PluginLifecycleStep('discover_plugins', self._purge_step_discover_plugins), + PluginLifecycleStep('build_precheck', self._purge_step_build_precheck), + PluginLifecycleStep('check_enabled_dependents', self._purge_step_check_enabled_dependents), + PluginLifecycleStep('build_purge_plan', self._purge_step_build_purge_plan), + PluginLifecycleStep('check_purge_blockers', self._purge_step_check_purge_blockers), + PluginLifecycleStep('run_purge_hook', self._purge_step_run_purge_hook), + PluginLifecycleStep('purge_metadata', self._purge_step_purge_metadata), + PluginLifecycleStep('commit', self._purge_step_commit), + ] + + async def _purge_step_discover_plugin( + self, + context: PluginPurgeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 发现物理清理目标插件。 + + :param context: 插件物理清理上下文 + :return: 阻断 payload 或 None + """ + context.discovered_plugin = self._get_discovered_plugin(context.plugin_id) + if not context.discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload( + context.plugin_id, + operation='purge', + dry_run=context.dry_run, + ) + + return self._build_operation_blocked_payload( + context.discovered_plugin, + 'purge', + dry_run=context.dry_run, + ) + + async def _purge_step_discover_plugins( + self, + context: PluginPurgeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 发现本地插件列表。 + + :param context: 插件物理清理上下文 + :return: None + """ + context.backend_root = context.discovered_plugin.backend_path.parent.parent + context.discovered_plugins = self._discover_plugins(context.backend_root) + + return None + + async def _purge_step_build_precheck( + self, + context: PluginPurgeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 构建物理清理预检。 + + :param context: 插件物理清理上下文 + :return: None + """ + context.precheck = await self._build_precheck_context( + cast('Path', context.backend_root), + cast('DiscoveredPlugin', context.discovered_plugin), + context.discovered_plugins or [], + ) + context.actions = PluginRuntimePayloadBuilder.build_precheck_actions( + 'purge', + context.discovered_plugin, + context.precheck, + ) + + return None + + async def _purge_step_check_enabled_dependents( + self, + context: PluginPurgeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 检查物理清理目标的已启用依赖方。 + + :param context: 插件物理清理上下文 + :return: None + """ + context.dependency_payload = await self._build_enabled_dependents_payload( + context.plugin_id, + context.discovered_plugins or [], + ) + + return None + + async def _purge_step_build_purge_plan( + self, + context: PluginPurgeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 构建物理清理计划。 + + :param context: 插件物理清理上下文 + :return: None + """ + context.session_context = self.dependencies.lifecycle_uow_gateway.open_lifecycle_unit_of_work() + context.lifecycle_uow = await context.session_context.__aenter__() + context.session = context.lifecycle_uow.session + context.plan = await context.lifecycle_uow.build_plugin_purge_plan( + context.discovered_plugin, + ) + + return None + + async def _purge_step_check_purge_blockers( + self, + context: PluginPurgeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 检查物理清理 dry-run 和依赖阻断。 + + :param context: 插件物理清理上下文 + :return: dry-run 或阻断 payload + """ + if context.dry_run: + payload = PluginLifecyclePayloadBuilder.build_operation_dry_run_payload( + context.plugin_id, + operation='purge', + message='插件物理清理演练完成,未执行实际删除', + actions=context.actions or [], + precheck=cast('PluginPrecheckContext', context.precheck), + extra_payload={ + 'safeMode': False, + 'removesSource': context.plan.removes_source, + 'plan': PluginPayloadBuilder.build_purge_plan(context.plan), + **(context.dependency_payload or {}), + }, + ok_from_precheck=False, + ) + return self._with_plugin_capability(payload, context.discovered_plugin) + if bool((context.dependency_payload or {}).get('pluginDependencyOk', True)): + return None + + payload = PluginEnablePayloadBuilder.build_dependency_blocker_payload( + context.plugin_id, + operation='purge', + enabled=False, + dependency_payload=context.dependency_payload or {}, + message='插件仍被已启用插件依赖,物理清理已中止', + ) + return self._with_plugin_capability(payload, context.discovered_plugin) + + async def _purge_step_run_purge_hook( + self, + context: PluginPurgeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 执行物理清理钩子。 + + :param context: 插件物理清理上下文 + :return: None + """ + context.hook_result = await PluginHookRunner(context.discovered_plugin).run( + 'on_purge', + query_db=context.session, + ) + + return None + + async def _purge_step_purge_metadata( + self, + context: PluginPurgeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 清理插件平台元数据。 + + :param context: 插件物理清理上下文 + :return: None + """ + await context.lifecycle_uow.purge_plugin_metadata(context.discovered_plugin) + + return None + + async def _purge_step_commit( + self, + context: PluginPurgeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 提交物理清理事务。 + + :param context: 插件物理清理上下文 + :return: None + """ + await context.lifecycle_uow.commit() + await self._close_purge_session(context) + + return None + + async def _close_purge_session(self, context: PluginPurgeLifecycleContext) -> None: + """ + 关闭物理清理数据库会话。 + + :param context: 插件物理清理上下文 + :return: None + """ + await self._close_lifecycle_session(context) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/runner.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/runner.py new file mode 100644 index 0000000..ca746bd --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/runner.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +from ..responses import PluginLifecycleResponse + +TContext = TypeVar('TContext') +TStepResult = PluginLifecycleResponse | None + + +class PluginLifecycleStepFailed(Exception): + """ + 插件生命周期步骤执行失败。 + """ + + def __init__(self, step_name: str, original_error: Exception) -> None: + """ + 初始化生命周期步骤失败异常。 + + :param step_name: 失败步骤名称 + :param original_error: 原始异常 + """ + super().__init__(str(original_error)) + self.step_name = step_name + self.original_error = original_error + + +@dataclass(slots=True) +class PluginLifecycleStepStop: + """ + 插件生命周期步骤中止结果。 + """ + + step_name: str + payload: PluginLifecycleResponse + + +@dataclass(slots=True) +class PluginLifecycleStep(Generic[TContext]): + """ + 插件生命周期声明式步骤。 + """ + + name: str + handler: Callable[[TContext], Awaitable[TStepResult]] + + +@dataclass(slots=True) +class PluginLifecycleStepRunResult(Generic[TContext]): + """ + 插件生命周期步骤运行结果。 + """ + + context: TContext + stop: PluginLifecycleStepStop | None = None + + +class PluginLifecycleStepRunner(Generic[TContext]): + """ + 插件生命周期声明式步骤运行器。 + """ + + def __init__(self, steps: list[PluginLifecycleStep[TContext]]) -> None: + """ + 初始化生命周期步骤运行器。 + + :param steps: 生命周期步骤列表 + """ + self.steps = steps + + async def run(self, context: TContext) -> PluginLifecycleStepRunResult[TContext]: + """ + 按顺序执行生命周期步骤。 + + :param context: 生命周期上下文 + :return: 生命周期运行结果 + """ + for step in self.steps: + try: + payload = await step.handler(context) + except Exception as exc: + raise PluginLifecycleStepFailed(step.name, exc) from exc + if payload is not None: + return PluginLifecycleStepRunResult(context=context, stop=PluginLifecycleStepStop(step.name, payload)) + + return PluginLifecycleStepRunResult(context=context) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/upgrade.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/upgrade.py new file mode 100644 index 0000000..2babbca --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle/upgrade.py @@ -0,0 +1,597 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from plugins.core.lifecycle.migration import PluginMigrationError +from plugins.core.lifecycle.seed import PluginSeedRunner +from plugins.core.runtime.hooks import PluginHookRunner +from plugins.core.runtime.support import ( + PluginLifecyclePayloadBuilder, + PluginPayloadBuilder, + PluginPrecheckContext, + PluginRuntimePayloadBuilder, +) + +from .common import PluginLifecycleUseCaseSupport +from .runner import PluginLifecycleStep, PluginLifecycleStepFailed, PluginLifecycleStepRunner + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.lifecycle.migration import PluginMigrationResult + from plugins.core.lifecycle.seed import PluginSeedResult + from plugins.core.runtime.hooks import PluginHookResult + from plugins.core.types import PluginStateRecord + + from ..context import PluginRuntimeContextService + from ..dependency_container import PluginRuntimeDependencies + from ..responses import PluginLifecycleResponse + from .operations import PluginLifecycleRuntimeOperations + + +@dataclass(slots=True) +class PluginUpgradeLifecycleContext: + """ + 插件升级声明式生命周期上下文。 + """ + + plugin_id: str + dry_run: bool + backend_root: Path | None = None + discovered_plugins: list[DiscoveredPlugin] | None = None + discovered_plugin: DiscoveredPlugin | None = None + precheck: PluginPrecheckContext | None = None + actions: list[dict[str, object]] | None = None + database_plugin: PluginStateRecord | None = None + database_error: str | None = None + version_state: dict[str, object] | None = None + plugin: object | None = None + installed_configs: list[object] | None = None + migration_results: list[PluginMigrationResult] | None = None + seed_results: list[PluginSeedResult] | None = None + hook_result: PluginHookResult | None = None + session: AsyncSession | None = None + lifecycle_uow: object | None = None + session_context: object | None = None + + +class PluginUpgradeUseCase(PluginLifecycleUseCaseSupport): + """ + 插件升级 use case。 + """ + + def __init__( + self, + dependencies: PluginRuntimeDependencies, + runtime_operations: PluginLifecycleRuntimeOperations, + context: PluginRuntimeContextService, + ) -> None: + """ + 初始化插件升级 use case。 + + :param dependencies: 插件运行时依赖容器 + :param runtime_operations: 生命周期工作流所需的运行时协作能力 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.runtime_operations = runtime_operations + self.context = context + + async def _load_database_plugin_state(self, plugin_id: str) -> tuple[PluginStateRecord | None, str | None]: + """ + 读取数据库插件状态。 + + :param plugin_id: 插件ID + :return: 数据库插件状态和错误信息 + """ + return await self.context.load_database_plugin_state(plugin_id) + + async def upgrade_plugin( + self, + plugin_id: str, + *, + dry_run: bool = False, + record_operation_log: bool = True, + operated_by: str | None = None, + ) -> PluginLifecycleResponse: + """ + 升级插件并按需记录审计日志。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :param record_operation_log: 是否记录插件操作审计日志 + :param operated_by: 操作者用户名,非预演时写入审计日志 + :return: 插件升级结果负载 + """ + payload = await self._upgrade_plugin(plugin_id, dry_run=dry_run) + payload_view = cast('dict[str, object]', payload) + payload_view['operation'] = 'upgrade' + if not dry_run: + await self.runtime_operations.record_plugin_failure_state(payload_view, '插件升级失败') + if record_operation_log and not dry_run: + if operated_by is not None: + payload_view['operatedBy'] = operated_by + await self.runtime_operations.record_plugin_operation_log( + payload_view, + dry_run=dry_run, + continue_on_error=False, + ) + + return payload + + async def _upgrade_plugin(self, plugin_id: str, *, dry_run: bool = False) -> PluginLifecycleResponse: + """ + 升级插件。 + + :param plugin_id: 插件ID + :param dry_run: 是否仅预演 + :return: 插件升级结果负载 + """ + context = PluginUpgradeLifecycleContext(plugin_id=plugin_id, dry_run=dry_run) + try: + result = await PluginLifecycleStepRunner(self._build_upgrade_steps()).run(context) + if result.stop: + await self._close_upgrade_session(result.context) + return result.stop.payload + payload = self._build_upgrade_success_payload(result.context) + payload['operation'] = 'upgrade' + return self._with_plugin_capability(payload, result.context.discovered_plugin) + except PluginLifecycleStepFailed as exc: + await self._close_upgrade_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '插件升级失败', + exc.original_error, + plugin_id=plugin_id, + failed_step=exc.step_name, + extra_payload=self._build_migration_failure_extra(exc.original_error), + ) + except Exception as exc: + await self._close_upgrade_session(context) + return PluginRuntimePayloadBuilder.build_exception_payload( + '插件升级失败', + exc, + plugin_id=plugin_id, + failed_step='prepare_upgrade', + extra_payload=self._build_migration_failure_extra(exc), + ) + + @staticmethod + def _build_migration_failure_extra(error: Exception) -> dict[str, object] | None: + """ + 构建 migration 失败恢复建议负载。 + + :param error: 原始异常 + :return: 额外异常负载 + """ + if not isinstance(error, PluginMigrationError): + return None + + return {'migrationRecovery': error.to_recovery_payload()} + + def _build_upgrade_steps(self) -> list[PluginLifecycleStep[PluginUpgradeLifecycleContext]]: + """ + 构建插件升级声明式生命周期步骤。 + + :return: 插件升级步骤列表 + """ + return [ + PluginLifecycleStep('discover_plugin', self._upgrade_step_discover_plugin), + PluginLifecycleStep('build_precheck', self._upgrade_step_build_precheck), + PluginLifecycleStep('load_installed_plugin', self._upgrade_step_load_installed_plugin), + PluginLifecycleStep('check_upgrade_blockers', self._upgrade_step_check_upgrade_blockers), + PluginLifecycleStep('open_session', self._upgrade_step_open_session), + PluginLifecycleStep('check_installed_menu_conflicts', self._upgrade_step_check_installed_menu_conflicts), + PluginLifecycleStep('upsert_plugin', self._upgrade_step_upsert_plugin), + PluginLifecycleStep('install_menus', self._upgrade_step_install_menus), + PluginLifecycleStep('install_configs', self._upgrade_step_install_configs), + PluginLifecycleStep('install_jobs', self._upgrade_step_install_jobs), + PluginLifecycleStep('run_migrations', self._upgrade_step_run_migrations), + PluginLifecycleStep('run_seeds', self._upgrade_step_run_seeds), + PluginLifecycleStep('run_upgrade_hook', self._upgrade_step_run_upgrade_hook), + PluginLifecycleStep('mark_installed', self._upgrade_step_mark_installed), + PluginLifecycleStep('commit', self._upgrade_step_commit), + PluginLifecycleStep('close_session', self._upgrade_step_close_session), + ] + + async def _upgrade_step_discover_plugin( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 发现插件并检查运行模式阻断。 + + :param context: 插件升级上下文 + :return: 阻断 payload 或 None + """ + context.backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + context.discovered_plugins = self._discover_plugins(context.backend_root) + context.discovered_plugin = self._get_discovered_plugin_from_list(context.discovered_plugins, context.plugin_id) + if not context.discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(context.plugin_id) + + return self._build_operation_blocked_payload( + context.discovered_plugin, + 'upgrade', + dry_run=context.dry_run, + ) + + async def _upgrade_step_build_precheck( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 构建升级预检和动作计划。 + + :param context: 插件升级上下文 + :return: dry-run payload 或 None + """ + precheck = await self._build_upgrade_precheck(context) + self._refresh_upgrade_actions(precheck, context) + if not context.dry_run: + return None + + context.database_plugin, context.database_error = await self._load_database_plugin_state(context.plugin_id) + context.version_state = PluginPayloadBuilder.build_upgrade_version_state( + context.discovered_plugin, + context.database_plugin, + ) + payload = PluginPayloadBuilder.build_upgrade_dry_run_payload( + context.plugin_id, + self._build_upgrade_operation_payload(context), + database_error=context.database_error, + ) + payload['operation'] = 'upgrade' + return self._with_plugin_capability(payload, context.discovered_plugin) + + async def _upgrade_step_load_installed_plugin( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 读取已安装插件状态。 + + :param context: 插件升级上下文 + :return: None + """ + context.database_plugin = await self.dependencies.state_query_gateway.get_plugin_state(context.plugin_id) + context.version_state = PluginPayloadBuilder.build_upgrade_version_state( + context.discovered_plugin, + context.database_plugin, + ) + + return None + + async def _upgrade_step_open_session( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 打开升级生命周期主事务工作单元。 + + :param context: 插件升级上下文 + :return: None + """ + context.session_context = self.dependencies.lifecycle_uow_gateway.open_lifecycle_unit_of_work() + context.lifecycle_uow = await context.session_context.__aenter__() + context.session = context.lifecycle_uow.session + + return None + + async def _upgrade_step_check_upgrade_blockers( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 检查升级执行前阻断。 + + :param context: 插件升级上下文 + :return: 阻断 payload 或 None + """ + blocker_payload = PluginRuntimePayloadBuilder.build_upgrade_pre_execution_blocker( + context.plugin_id, + context.version_state or {}, + context.actions or [], + cast('PluginPrecheckContext', context.precheck), + ) + if blocker_payload: + return blocker_payload + if not (context.version_state or {}).get('needsUpgrade'): + payload = PluginLifecyclePayloadBuilder.build_upgrade_latest_payload( + context.plugin_id, + context.version_state or {}, + cast('PluginPrecheckContext', context.precheck), + ) + payload['operation'] = 'upgrade' + return self._with_plugin_capability(payload, context.discovered_plugin) + + return PluginLifecyclePayloadBuilder.build_first_precheck_blocker_payload( + context.plugin_id, + operation='upgrade', + actions=context.actions or [], + precheck=cast('PluginPrecheckContext', context.precheck), + extra_payload=context.version_state or {}, + ) + + async def _upgrade_step_check_installed_menu_conflicts( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 检查已安装菜单冲突。 + + :param context: 插件升级上下文 + :return: 菜单冲突 payload 或 None + """ + installed_menu_conflicts = await context.lifecycle_uow.check_installed_menu_conflicts( + context.discovered_plugin, + ) + if not installed_menu_conflicts: + return None + + return PluginLifecyclePayloadBuilder.build_installed_menu_conflict_payload( + context.plugin_id, + message='插件菜单与已安装菜单存在冲突,升级已中止', + actions=context.actions or [], + precheck=cast('PluginPrecheckContext', context.precheck), + installed_menu_conflicts=installed_menu_conflicts, + extra_payload=context.version_state or {}, + ) + + async def _upgrade_step_upsert_plugin( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 更新插件基础状态。 + + :param context: 插件升级上下文 + :return: None + """ + await context.lifecycle_uow.upsert_discovered_plugin( + context.discovered_plugin, + Path(self.dependencies.runtime_environment.get_backend_plugins_dir()), + Path(self.dependencies.runtime_environment.get_frontend_plugins_dir()), + ) + + return None + + async def _upgrade_step_install_menus( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 安装已启用插件菜单。 + + :param context: 插件升级上下文 + :return: None + """ + plugin_enabled = getattr(context.database_plugin, 'enabled', '1') == '0' + await context.lifecycle_uow.install_plugin_menu( + context.discovered_plugin, + enabled=plugin_enabled, + ) + + return None + + async def _upgrade_step_install_configs( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 安装新增默认配置。 + + :param context: 插件升级上下文 + :return: None + """ + context.installed_configs = await context.lifecycle_uow.install_plugin_default_config( + context.discovered_plugin, + ) + + return None + + async def _upgrade_step_install_jobs( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 同步升级后插件定时任务。 + + :param context: 插件升级上下文 + :return: None + """ + plugin_enabled = getattr(context.database_plugin, 'enabled', '1') == '0' + await context.lifecycle_uow.install_plugin_jobs( + context.discovered_plugin, + enabled=plugin_enabled, + ) + + return None + + async def _upgrade_step_run_migrations( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 执行升级 migration。 + + :param context: 插件升级上下文 + :return: None + """ + context.migration_results = await self.dependencies.migration_execution_gateway.run_plugin_migrations( + context.discovered_plugin, + ) + + return None + + async def _upgrade_step_run_seeds( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 执行升级 seed。 + + :param context: 插件升级上下文 + :return: None + """ + context.seed_results = await PluginSeedRunner(context.discovered_plugin).run(context.session) + + return None + + async def _upgrade_step_run_upgrade_hook( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 执行升级钩子。 + + :param context: 插件升级上下文 + :return: None + """ + context.hook_result = await PluginHookRunner(context.discovered_plugin).run( + 'on_upgrade', + query_db=context.session, + ) + + return None + + async def _upgrade_step_mark_installed( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 标记插件已安装。 + + :param context: 插件升级上下文 + :return: None + """ + context.plugin = await context.lifecycle_uow.mark_plugin_installed( + context.discovered_plugin, + ) + + return None + + async def _upgrade_step_commit( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 提交升级事务。 + + :param context: 插件升级上下文 + :return: None + """ + await context.lifecycle_uow.commit() + + return None + + async def _upgrade_step_close_session( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse | None: + """ + 关闭升级数据库会话。 + + :param context: 插件升级上下文 + :return: None + """ + await self._close_upgrade_session(context) + + return None + + async def _build_upgrade_precheck( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginPrecheckContext: + """ + 构建升级预检上下文。 + + :param context: 插件升级上下文 + :return: 插件预检上下文 + """ + return await self._build_precheck_context( + cast('Path', context.backend_root), + cast('DiscoveredPlugin', context.discovered_plugin), + context.discovered_plugins or [], + ) + + def _refresh_upgrade_actions( + self, + precheck: PluginPrecheckContext, + context: PluginUpgradeLifecycleContext, + ) -> None: + """ + 刷新升级预检和动作计划。 + + :param precheck: 插件预检上下文 + :param context: 插件升级上下文 + :return: None + """ + context.precheck = precheck + context.actions = PluginPayloadBuilder.build_upgrade_actions( + cast('DiscoveredPlugin', context.discovered_plugin), + precheck.dependency_result.ok, + precheck.plugin_dependency_result.ok, + precheck.structure_result.ok, + precheck.menu_conflict_result.ok, + ) + + def _build_upgrade_operation_payload( + self, + context: PluginUpgradeLifecycleContext, + ) -> dict[str, object]: + """ + 构建升级预检操作 payload。 + + :param context: 插件升级上下文 + :return: 升级操作 payload + """ + precheck = cast('PluginPrecheckContext', context.precheck) + return { + 'versionState': context.version_state or {}, + 'dependencyResult': precheck.dependency_result, + 'pluginDependencyResult': precheck.plugin_dependency_result, + 'structureResult': precheck.structure_result, + 'menuConflictResult': precheck.menu_conflict_result, + 'actions': context.actions or [], + 'manifestOk': precheck.manifest_result.ok, + 'manifestIssues': precheck.manifest_issues, + 'manifestWarnings': precheck.manifest_warnings, + 'pluginDependencyErrors': precheck.plugin_dependency_errors, + 'structureErrors': precheck.structure_errors, + 'menuConflicts': precheck.menu_conflicts, + } + + def _build_upgrade_success_payload( + self, + context: PluginUpgradeLifecycleContext, + ) -> PluginLifecycleResponse: + """ + 构建升级成功负载。 + + :param context: 插件升级上下文 + :return: 升级成功负载 + """ + return PluginLifecyclePayloadBuilder.build_success_payload( + context.plugin_id, + message='插件升级完成', + actions=context.actions or [], + precheck=cast('PluginPrecheckContext', context.precheck), + plugin=context.plugin, + installed_configs=context.installed_configs, + migration_results=context.migration_results, + seed_results=context.seed_results, + hook_result=context.hook_result, + extra_payload=context.version_state or {}, + ) + + async def _close_upgrade_session(self, context: PluginUpgradeLifecycleContext) -> None: + """ + 关闭升级数据库会话。 + + :param context: 插件升级上下文 + :return: None + """ + await self._close_lifecycle_session(context) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle_lock.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle_lock.py new file mode 100644 index 0000000..3fb5652 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/lifecycle_lock.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol +from uuid import uuid4 + +from redis.exceptions import RedisError + +from common.constant import LockConstant +from config.get_redis import RedisUtil +from exceptions.exception import ServiceException +from utils.log_util import logger + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from redis import asyncio as aioredis + + +@dataclass(frozen=True) +class PluginLifecycleLockResult: + """ + 插件生命周期操作锁获取结果。 + """ + + acquired: bool + message: str = '' + + +class PluginLifecycleLockLost(ServiceException): + """ + 插件生命周期锁在操作期间丢失。 + """ + + def __str__(self) -> str: + """ + 返回可读错误消息。 + + :return: 错误消息 + """ + return self.message or '' + + +class PluginLifecycleLock(Protocol): + """ + 插件生命周期操作锁接口。 + """ + + def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + """ + 获取插件生命周期操作锁。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :return: 锁获取结果上下文 + """ + + +class NoopPluginLifecycleLock: + """ + 空插件生命周期锁,用于测试和离线运行时。 + """ + + @asynccontextmanager + async def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + """ + 返回已获取锁结果。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :return: 锁获取结果上下文 + """ + yield PluginLifecycleLockResult(acquired=True) + + +class RedisPluginLifecycleLock: + """ + 基于 Redis 的插件生命周期操作分布式锁。 + + 插件生命周期操作会写入菜单、任务、配置等共享资源,因此生产环境使用全局锁串行化 + 写操作,避免不同插件并发安装/升级时绕过应用层幂等检查。 + """ + + _RELEASE_SCRIPT = """ + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + end + return 0 + """ + _RENEW_SCRIPT = """ + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("expire", KEYS[1], ARGV[2]) + end + return 0 + """ + + def __init__(self, expire_seconds: int | None = None) -> None: + """ + 初始化 Redis 插件生命周期锁。 + + :param expire_seconds: 锁自动过期时间 + :return: None + """ + self.expire_seconds = expire_seconds or LockConstant.PLUGIN_LIFECYCLE_LOCK_EXPIRE_SECONDS + + @asynccontextmanager + async def lock(self, plugin_id: str, operation: str) -> AsyncIterator[PluginLifecycleLockResult]: + """ + 获取插件生命周期操作锁。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :return: 锁获取结果上下文 + """ + redis: aioredis.Redis | None = None + lock_key = self._build_lock_key() + lock_value = f'{plugin_id}:{operation}:{uuid4()}' + acquired = False + renewal_task: asyncio.Task | None = None + body_failed = False + try: + try: + redis = await RedisUtil.create_redis_pool(log_enabled=False) + acquired = bool(await redis.set(lock_key, lock_value, nx=True, ex=self.expire_seconds)) + if not acquired: + yield PluginLifecycleLockResult( + acquired=False, + message='插件生命周期操作正在执行中,请稍后重试', + ) + return + renewal_task = self._start_lock_renewal(redis, lock_key, lock_value, asyncio.current_task()) + except RedisError as exc: + yield PluginLifecycleLockResult( + acquired=False, + message=f'插件生命周期操作锁不可用:{exc}', + ) + return + + try: + yield PluginLifecycleLockResult(acquired=True) + except asyncio.CancelledError: + body_failed = True + renewal_error = self._get_renewal_error(renewal_task) + if renewal_error: + raise renewal_error from None + raise + except BaseException: + body_failed = True + raise + finally: + if redis is not None: + if renewal_task is not None: + renewal_error = await self._stop_lock_renewal(renewal_task) + if renewal_error and not body_failed: + raise renewal_error + if acquired: + with suppress(RedisError): + await redis.eval(self._RELEASE_SCRIPT, 1, lock_key, lock_value) + await redis.close() + + def _start_lock_renewal( + self, + redis: aioredis.Redis, + lock_key: str, + lock_value: str, + owner_task: asyncio.Task | None = None, + ) -> asyncio.Task: + """ + 启动生命周期锁续期任务。 + + :param redis: Redis 连接对象 + :param lock_key: 锁 key + :param lock_value: 锁值 + :param owner_task: 持锁执行生命周期操作的任务 + :return: 续期任务 + """ + return asyncio.create_task(self._renew_lock_loop(redis, lock_key, lock_value, owner_task)) + + async def _renew_lock_loop( + self, + redis: aioredis.Redis, + lock_key: str, + lock_value: str, + owner_task: asyncio.Task | None = None, + ) -> None: + """ + 周期性续期生命周期锁。 + + :param redis: Redis 连接对象 + :param lock_key: 锁 key + :param lock_value: 锁值 + :param owner_task: 持锁执行生命周期操作的任务 + :return: None + """ + interval_seconds = self._renew_interval_seconds() + while True: + await asyncio.sleep(interval_seconds) + try: + renewed = await redis.eval(self._RENEW_SCRIPT, 1, lock_key, lock_value, self.expire_seconds) + if not renewed: + message = '插件生命周期操作锁已丢失,操作已中断' + logger.error(f'❌ {message}') + if owner_task is not None: + owner_task.cancel() + raise PluginLifecycleLockLost(data='', message=message) + except RedisError as exc: + message = f'插件生命周期操作锁续期失败,操作已中断:{exc}' + logger.error(f'❌ {message}') + if owner_task is not None: + owner_task.cancel() + raise PluginLifecycleLockLost(data='', message=message) from exc + + @staticmethod + def _get_renewal_error(renewal_task: asyncio.Task | None) -> PluginLifecycleLockLost | None: + """ + 获取续期任务失败原因。 + + :param renewal_task: 续期任务 + :return: 续期失败异常 + """ + if renewal_task is None or not renewal_task.done() or renewal_task.cancelled(): + return None + try: + exc = renewal_task.exception() + except asyncio.CancelledError: + return None + return exc if isinstance(exc, PluginLifecycleLockLost) else None + + async def _stop_lock_renewal(self, renewal_task: asyncio.Task) -> PluginLifecycleLockLost | None: + """ + 停止锁续期任务并返回续期失败原因。 + + :param renewal_task: 续期任务 + :return: 续期失败异常 + """ + if not renewal_task.done(): + renewal_task.cancel() + try: + await renewal_task + except asyncio.CancelledError: + return None + except PluginLifecycleLockLost as exc: + return exc + return self._get_renewal_error(renewal_task) + + def _renew_interval_seconds(self) -> int: + """ + 计算锁续期间隔。 + + :return: 续期间隔秒数 + """ + return max(1, self.expire_seconds // 3) + + @staticmethod + def _build_lock_key() -> str: + """ + 构建插件生命周期操作锁 key。 + + 当前采用全局串行化设计:所有插件的生命周期操作共用一把锁,防止并行安装/卸载 + 操作在共享资源(菜单表、配置表、sys_job 表、插件状态)上产生竞态。如未来需要 + 允许无依赖插件并行操作,可将 plugin_id 纳入 lock_key 并配合共享资源的细粒度锁。 + + :return: 锁 key + """ + return f'{LockConstant.PLUGIN_LIFECYCLE_LOCK_PREFIX}:global' diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/migration.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/migration.py new file mode 100644 index 0000000..5780e75 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/migration.py @@ -0,0 +1,148 @@ +from typing import Literal + +from plugins.core.runtime.support import PluginRuntimePayloadBuilder + +from .dependency_container import PluginRuntimeDependencies + +MigrationRecoveryStatus = Literal['success', 'failed'] +MIGRATION_MANUAL_TRANSITIONS: dict[MigrationRecoveryStatus, set[str]] = { + 'success': {'running', 'failed', 'unknown'}, + 'failed': {'running', 'unknown'}, +} + + +class PluginMigrationUseCase: + """ + 插件 migration 历史查询和人工恢复 use case。 + """ + + def __init__(self, dependencies: PluginRuntimeDependencies) -> None: + """ + 初始化插件 migration use case。 + + :param dependencies: 插件运行时依赖容器 + """ + self.dependencies = dependencies + + async def list_plugin_migrations(self, plugin_id: str, status: str | None = None) -> dict[str, object]: + """ + 查询插件 migration 历史。 + + :param plugin_id: 插件ID + :param status: 执行状态 + :return: 插件 migration 历史负载 + """ + try: + migrations = await self.dependencies.migration_history_gateway.list_plugin_migrations(plugin_id, status) + + return { + 'ok': True, + 'message': '插件 migration 历史查询完成', + 'pluginId': plugin_id, + 'status': status, + 'count': len(migrations), + 'migrations': [self._dump_migration_model(migration) for migration in migrations], + } + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload( + '查询插件 migration 历史失败', + exc, + plugin_id=plugin_id, + ) + + async def mark_plugin_migration_status( + self, + plugin_id: str, + migration_path: str, + status: MigrationRecoveryStatus, + *, + note: str | None = None, + ) -> dict[str, object]: + """ + 人工标记插件 migration 状态。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param status: 目标状态 + :param note: 人工恢复备注 + :return: 插件 migration 状态标记负载 + """ + try: + error_message = None if status == 'success' else note or '人工标记为失败' + existing_migration = await self.dependencies.migration_history_gateway.get_plugin_migration( + plugin_id, + migration_path, + ) + if not existing_migration: + return PluginRuntimePayloadBuilder.build_invalid_operation_payload( + plugin_id, + f'migration_mark_{status}', + message='插件 migration 历史不存在', + ) + current_status = getattr(existing_migration, 'status', 'success') or 'success' + if current_status not in MIGRATION_MANUAL_TRANSITIONS[status]: + label = '成功' if status == 'success' else '失败' + return PluginRuntimePayloadBuilder.build_invalid_operation_payload( + plugin_id, + f'migration_mark_{status}', + message=f'插件 migration 当前状态为 {current_status},不能人工标记为{label}', + ) + migration = await self.dependencies.migration_history_gateway.mark_plugin_migration_status( + plugin_id, + migration_path, + status, + error_message, + ) + if not migration: + return PluginRuntimePayloadBuilder.build_invalid_operation_payload( + plugin_id, + f'migration_mark_{status}', + message='插件 migration 历史不存在', + ) + + label = '成功' if status == 'success' else '失败' + payload = { + 'ok': True, + 'message': f'插件 migration 已人工标记为{label}', + 'operation': f'migration_mark_{status}', + 'pluginId': plugin_id, + 'migrationPath': migration_path, + 'status': status, + 'migration': self._dump_migration_model(migration), + } + if note: + payload['note'] = note + return payload + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload( + '人工标记插件 migration 状态失败', + exc, + plugin_id=plugin_id, + ) + + @staticmethod + def _dump_migration_model(migration: object) -> dict[str, object]: + """ + 序列化 migration 历史模型。 + + :param migration: migration 历史模型 + :return: migration 历史字典 + """ + model_dump = getattr(migration, 'model_dump', None) + if callable(model_dump): + return model_dump(by_alias=True) + + return { + 'pluginId': getattr(migration, 'plugin_id', None), + 'migrationPath': getattr(migration, 'migration_path', None), + 'migrationChecksum': getattr(migration, 'migration_checksum', None), + 'version': getattr(migration, 'version', None), + 'statementCount': getattr(migration, 'statement_count', 0), + 'status': getattr(migration, 'status', None), + 'errorMessage': getattr(migration, 'error_message', None), + 'attemptCount': getattr(migration, 'attempt_count', 0), + 'startedTime': getattr(migration, 'started_time', None), + 'finishedTime': getattr(migration, 'finished_time', None), + 'createTime': getattr(migration, 'create_time', None), + 'updateTime': getattr(migration, 'update_time', None), + } diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/migration_store.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/migration_store.py new file mode 100644 index 0000000..303d158 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/migration_store.py @@ -0,0 +1,322 @@ +from typing import TYPE_CHECKING, Any + +from plugins.core.lifecycle.migration import PluginMigrationHistoryRecord, PluginMigrationHistoryStore + +from .gateway import ( + AsyncSessionFactoryProtocol, + PluginManagementModelGateway, + PluginManagementServiceProtocol, + PluginMigrationHistoryGateway, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + +class PluginDatabaseMigrationHistoryStore(PluginMigrationHistoryStore): + """ + 插件数据库 migration 历史存储。 + + 使用 Adapter 模式将插件 core runner 需要的历史接口适配到外部 migration 历史服务。 + """ + + def __init__( + self, + plugin_service: type[PluginManagementServiceProtocol], + async_session_local: AsyncSessionFactoryProtocol | None = None, + ) -> None: + """ + 初始化插件数据库 migration 历史存储。 + + :param plugin_service: 插件服务类 + :param async_session_local: 独立数据库会话工厂 + :return: None + """ + self.plugin_service = plugin_service + self.model_gateway: PluginManagementModelGateway | None = None + self.async_session_local = async_session_local + + @classmethod + def with_model_gateway( + cls, + plugin_service: type[PluginManagementServiceProtocol], + model_gateway: PluginManagementModelGateway, + async_session_local: AsyncSessionFactoryProtocol | None = None, + ) -> 'PluginDatabaseMigrationHistoryStore': + """ + 使用模型工厂网关构建 migration 历史存储。 + + :param plugin_service: 插件服务类 + :param model_gateway: 插件管理模型工厂网关 + :param async_session_local: 独立数据库会话工厂 + :return: migration 历史存储 + """ + store = cls(plugin_service, async_session_local) + store.model_gateway = model_gateway + return store + + async def get_record( + self, + query_db: 'AsyncSession', + plugin_id: str, + migration_path: str, + ) -> PluginMigrationHistoryRecord | None: + """ + 获取 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: migration 执行历史 + """ + plugin_migration = await self.plugin_service.get_plugin_migration_services( + query_db, + plugin_id, + migration_path, + ) + if not plugin_migration: + return None + return _build_migration_history_record(plugin_migration) + + async def record_running( + self, + query_db: 'AsyncSession', + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + ) -> None: + """ + 记录 migration 开始执行。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: None + """ + if self.model_gateway is None: + raise RuntimeError('插件运行时缺少 migration 历史记录模型网关') + await self._add_plugin_migration( + query_db, + self.model_gateway.build_migration_record( + plugin_id, + migration_path, + checksum, + version, + statement_count, + 'running', + ), + ) + + async def record_success( + self, + query_db: 'AsyncSession', + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + ) -> None: + """ + 记录 migration 成功执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: None + """ + if self.model_gateway is None: + raise RuntimeError('插件运行时缺少 migration 历史记录模型网关') + await self._add_plugin_migration( + query_db, + self.model_gateway.build_migration_record( + plugin_id, + migration_path, + checksum, + version, + statement_count, + ), + ) + + async def record_failure( + self, + query_db: 'AsyncSession', + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + error_message: str, + ) -> None: + """ + 记录 migration 执行失败历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :param error_message: 失败错误信息 + :return: None + """ + if self.model_gateway is None: + raise RuntimeError('插件运行时缺少 migration 历史记录模型网关') + await self._add_plugin_migration( + query_db, + self.model_gateway.build_migration_record( + plugin_id, + migration_path, + checksum, + version, + statement_count, + 'failed', + error_message, + ), + ) + + async def _add_plugin_migration(self, query_db: 'AsyncSession', plugin_migration: object) -> None: + """ + 写入 migration 历史,优先使用独立会话提交。 + + :param query_db: 当前生命周期 orm对象 + :param plugin_migration: migration 历史模型 + :return: None + """ + if self.async_session_local is None: + await self.plugin_service.add_plugin_migration_services(query_db, plugin_migration) + return + + async with self.async_session_local() as session: + await self.plugin_service.add_plugin_migration_services(session, plugin_migration) + await session.commit() + + +class PluginMigrationHistoryGatewayStore(PluginMigrationHistoryStore): + """ + 插件 migration 历史查询端口适配器。 + + 仅用于生命周期脚本预检读取历史状态,执行期写入仍由 PluginDatabaseMigrationHistoryStore 负责。 + """ + + def __init__(self, migration_history_gateway: PluginMigrationHistoryGateway) -> None: + """ + 初始化 migration 历史查询适配器。 + + :param migration_history_gateway: migration 历史查询端口 + :return: None + """ + self.migration_history_gateway = migration_history_gateway + + async def get_record( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + ) -> PluginMigrationHistoryRecord | None: + """ + 获取 migration 执行历史。 + + :param query_db: 预检上下文占位对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: migration 执行历史 + """ + plugin_migration = await self.migration_history_gateway.get_plugin_migration(plugin_id, migration_path) + if not plugin_migration: + return None + return _build_migration_history_record(plugin_migration) + + async def record_running( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + ) -> None: + """ + 禁止通过预检查询适配器写入 running 历史。 + + :param query_db: 预检上下文占位对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :raises RuntimeError: 该适配器不支持写入 + """ + raise RuntimeError('migration 历史查询适配器不支持写入执行历史') + + async def record_success( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + ) -> None: + """ + 禁止通过预检查询适配器写入 success 历史。 + + :param query_db: 预检上下文占位对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :raises RuntimeError: 该适配器不支持写入 + """ + raise RuntimeError('migration 历史查询适配器不支持写入执行历史') + + async def record_failure( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + error_message: str, + ) -> None: + """ + 禁止通过预检查询适配器写入 failed 历史。 + + :param query_db: 预检上下文占位对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :param error_message: 失败错误信息 + :raises RuntimeError: 该适配器不支持写入 + """ + raise RuntimeError('migration 历史查询适配器不支持写入执行历史') + + +def _build_migration_history_record(plugin_migration: object) -> PluginMigrationHistoryRecord: + """ + 将管理层 migration 模型转换为 core runner 历史记录。 + + :param plugin_migration: 管理层 migration 模型 + :return: core migration 历史记录 + """ + migration_checksum = getattr(plugin_migration, 'migration_checksum', None) or getattr( + plugin_migration, + 'migrationChecksum', + '', + ) + return PluginMigrationHistoryRecord( + checksum=migration_checksum, + status=getattr(plugin_migration, 'status', 'success'), + error_message=getattr(plugin_migration, 'error_message', None), + ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/precheck.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/precheck.py new file mode 100644 index 0000000..5265f3d --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/precheck.py @@ -0,0 +1,156 @@ +from pathlib import Path +from typing import cast + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.lifecycle.purge import PluginPurgePlan +from plugins.core.runtime.support import PluginPayloadBuilder, PluginPrecheckContext, PluginRuntimePayloadBuilder +from plugins.core.types import PluginStateRecord +from plugins.core.validation.plugin_deps import PluginBatchOperation +from utils.log_util import logger + +from .context import PluginRuntimeContextService +from .dependency_container import PluginRuntimeDependencies +from .responses import PluginPrecheckResponse + + +class PluginPrecheckUseCase: + """ + 插件预检 use case。 + """ + + def __init__(self, dependencies: PluginRuntimeDependencies, context: PluginRuntimeContextService) -> None: + """ + 初始化插件预检 use case。 + + :param dependencies: 插件运行时依赖容器 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.context = context + + def _discover_plugins(self, backend_root: Path) -> list[DiscoveredPlugin]: + """ + 发现本地插件。 + + :param backend_root: 后端项目根目录 + :return: 已发现插件列表 + """ + return self.context.discover_plugins(backend_root) + + def _get_discovered_plugin_from_list( + self, + discovered_plugins: list[DiscoveredPlugin], + plugin_id: str, + ) -> DiscoveredPlugin | None: + """ + 从已发现插件列表中查找指定插件。 + + :param discovered_plugins: 已发现插件列表 + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + return self.context.get_discovered_plugin_from_list(discovered_plugins, plugin_id) + + async def _load_database_plugin_state(self, plugin_id: str) -> tuple[PluginStateRecord | None, str | None]: + """ + 读取数据库插件状态。 + + :param plugin_id: 插件ID + :return: 数据库插件状态和错误信息 + """ + return await self.context.load_database_plugin_state(plugin_id) + + async def _build_precheck_context( + self, + backend_root: Path, + discovered_plugin: DiscoveredPlugin, + discovered_plugins: list[DiscoveredPlugin], + ) -> PluginPrecheckContext: + """ + 构建插件操作预检上下文。 + + :param backend_root: 后端项目根目录 + :param discovered_plugin: 当前插件 + :param discovered_plugins: 已发现插件列表 + :return: 插件操作预检上下文 + """ + return await self.context.build_precheck_context(backend_root, discovered_plugin, discovered_plugins) + + def _with_plugin_capability( + self, + payload: dict[str, object], + discovered_plugin: DiscoveredPlugin | None, + ) -> dict[str, object]: + """ + 为运行时响应负载附加插件操作能力。 + + :param payload: 运行时响应负载 + :param discovered_plugin: 已发现插件 + :return: 附加能力后的响应负载 + """ + return cast('dict[str, object]', self.context.with_plugin_capability(payload, discovered_plugin)) + + async def precheck_plugin_operation( + self, plugin_id: str, operation: PluginBatchOperation + ) -> PluginPrecheckResponse: + """ + 执行插件操作预检。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :return: 插件操作预检负载 + """ + if operation not in ('install', 'enable', 'upgrade', 'uninstall', 'purge'): + return PluginRuntimePayloadBuilder.build_invalid_operation_payload( + plugin_id, + operation, + message='插件预检操作只支持 install、enable、upgrade、uninstall 或 purge', + ) + + try: + backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + discovered_plugins = self._discover_plugins(backend_root) + discovered_plugin = self._get_discovered_plugin_from_list(discovered_plugins, plugin_id) + if not discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id, operation=operation) + + precheck = await self._build_precheck_context(backend_root, discovered_plugin, discovered_plugins) + database_plugin, database_error = await self._load_database_plugin_state(plugin_id) + actions = PluginRuntimePayloadBuilder.build_precheck_actions(operation, discovered_plugin, precheck) + version_state = PluginPayloadBuilder.build_upgrade_version_state(discovered_plugin, database_plugin) + purge_plan = None + purge_plan_error = None + if operation == 'purge': + purge_plan, purge_plan_error = await self._build_precheck_purge_plan(discovered_plugin) + payload = PluginRuntimePayloadBuilder.build_precheck_payload( + plugin_id, + operation, + precheck=precheck, + version_state=version_state, + actions=actions, + database_error=database_error, + purge_plan=purge_plan, + purge_plan_error=purge_plan_error, + ) + return cast( + 'PluginPrecheckResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('插件操作预检失败', exc) + + async def _build_precheck_purge_plan( + self, + discovered_plugin: DiscoveredPlugin, + ) -> tuple[PluginPurgePlan | None, str | None]: + """ + 构建插件预检物理清理计划。 + + :param discovered_plugin: 已发现插件 + :return: 插件物理清理计划和构建错误 + """ + try: + return await self.dependencies.purge_plan_gateway.build_plugin_purge_plan(discovered_plugin), None + except Exception as exc: + logger.warning(f'插件 {discovered_plugin.manifest.id} 预检物理清理计划构建失败:{exc}') + return None, str(exc) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/query.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/query.py new file mode 100644 index 0000000..69370c6 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/query.py @@ -0,0 +1,425 @@ +from pathlib import Path +from typing import Protocol, cast + +from plugins.core.capability import PluginRuntimeCapability +from plugins.core.discovery.registry import PluginRegistry +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.runtime.health import PluginHealthChecker +from plugins.core.runtime.support import ( + PluginAuditPayloadBuilder, + PluginConfigPayloadBuilder, + PluginPayloadBuilder, + PluginPrecheckContext, + PluginRuntimePayloadBuilder, +) +from plugins.core.types import PluginStateRecord +from plugins.core.validation.manifest import PluginManifestChecker +from plugins.core.validation.menus import PluginMenuConflictChecker +from plugins.core.validation.plugin_deps import ( + PluginDependencyChecker as InterPluginDependencyChecker, +) +from plugins.core.validation.structure import PluginStructureChecker + +from .context import PluginRuntimeContextService +from .dependency_container import PluginRuntimeDependencies +from .responses import ( + PluginAuditSnapshotResponse, + PluginCatalogInfoResponse, + PluginCatalogListResponse, + PluginCheckResponse, + PluginConfigStateResponse, + PluginDependencyCheckResponse, + PluginDiagnoseResponse, + PluginHealthResponse, +) + +AUDIT_LOG_OVERFETCH_MULTIPLIER = 3 + + +class PluginQueryRuntimeOperations(Protocol): + """ + 查询诊断所需的运行时协作能力。 + """ + + async def get_plugin_config(self, plugin_id: str, *, reveal_secret: bool = False) -> PluginConfigStateResponse: + """ + 获取插件配置。 + + :param plugin_id: 插件ID + :param reveal_secret: 是否展示敏感配置原值 + :return: 插件配置负载 + """ + + +class PluginQueryUseCase: + """ + 插件查询 use case。 + """ + + def __init__( + self, + dependencies: PluginRuntimeDependencies, + runtime_operations: PluginQueryRuntimeOperations, + context: PluginRuntimeContextService, + ) -> None: + """ + 初始化插件查询 use case。 + + :param dependencies: 插件运行时依赖容器 + :param runtime_operations: 查询诊断所需的运行时协作能力 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.runtime_operations = runtime_operations + self.context = context + + def _build_registry(self) -> PluginRegistry: + """ + 构建本地插件注册表。 + + :return: 插件注册表 + """ + return self.context.build_registry() + + def _get_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None: + """ + 根据插件 ID 获取已发现插件。 + + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + return self.context.get_discovered_plugin(plugin_id) + + async def _load_database_plugin_state(self, plugin_id: str) -> tuple[PluginStateRecord | None, str | None]: + """ + 读取数据库插件状态。 + + :param plugin_id: 插件ID + :return: 数据库插件状态和错误信息 + """ + return await self.context.load_database_plugin_state(plugin_id) + + def _load_database_plugin_states_sync(self) -> list[PluginStateRecord]: + """ + 以同步方式读取数据库插件状态列表。 + + :return: 数据库插件状态列表 + """ + return self.context.load_database_plugin_states_sync() + + def _load_database_plugin_states_sync_with_error(self) -> tuple[list[PluginStateRecord], str | None]: + """ + 以同步方式读取数据库插件状态列表,并保留失败原因。 + + :return: 数据库插件状态列表和错误信息 + """ + return self.context.load_database_plugin_states_sync_with_error() + + async def _load_database_plugin_states_with_error(self) -> tuple[list[PluginStateRecord], str | None]: + """ + 以异步方式读取数据库插件状态列表,并保留失败原因。 + + :return: 数据库插件状态列表和错误信息 + """ + return await self.context.load_database_plugin_states_with_error() + + def _resolve_plugin_capability(self, discovered_plugin: DiscoveredPlugin) -> PluginRuntimeCapability: + """ + 解析插件运行时操作能力。 + + :param discovered_plugin: 已发现插件 + :return: 插件运行时能力 + """ + return self.context.resolve_plugin_capability(discovered_plugin) + + def _with_plugin_capability( + self, + payload: dict[str, object], + discovered_plugin: DiscoveredPlugin | None, + ) -> dict[str, object]: + """ + 为运行时响应负载附加插件操作能力。 + + :param payload: 运行时响应负载 + :param discovered_plugin: 已发现插件 + :return: 附加能力后的响应负载 + """ + return cast('dict[str, object]', self.context.with_plugin_capability(payload, discovered_plugin)) + + def list_plugins(self) -> PluginCatalogListResponse: + """ + 获取本地插件列表。 + + :return: 插件列表负载 + """ + try: + registry = self._build_registry() + payload = PluginPayloadBuilder.build_plugin_list_payload(registry.list_plugins()) + plugin_items = cast('list[dict[str, object]]', payload['plugins']) + for item, plugin in zip(plugin_items, registry.list_plugins(), strict=False): + self._with_plugin_capability(item, plugin.discovered_plugin) + return cast('PluginCatalogListResponse', payload) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('读取插件列表失败', exc) + + async def list_plugins_with_state(self) -> PluginCatalogListResponse: + """ + 获取合并数据库状态的本地插件列表。 + + :return: 插件列表负载 + """ + try: + database_plugins, database_error = await self._load_database_plugin_states_with_error() + backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + registry = PluginRegistry.build( + self.context.discover_plugins(backend_root), + database_plugins, + ) + payload = PluginPayloadBuilder.build_plugin_list_payload(registry.list_plugins()) + payload['databaseAvailable'] = database_error is None + payload['databaseError'] = database_error + plugin_items = cast('list[dict[str, object]]', payload['plugins']) + for item, plugin in zip(plugin_items, registry.list_plugins(), strict=False): + self._with_plugin_capability(item, plugin.discovered_plugin) + return cast('PluginCatalogListResponse', payload) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('读取插件列表失败', exc) + + def get_plugin_info(self, plugin_id: str) -> PluginCatalogInfoResponse: + """ + 获取插件详情。 + + :param plugin_id: 插件ID + :return: 插件详情负载 + """ + try: + registry = self._build_registry() + plugin = registry.get_plugin(plugin_id) + if not plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) + dependency_result = self.dependencies.dependency_checker.check_manifest(plugin.discovered_plugin.manifest) + return cast( + 'PluginCatalogInfoResponse', + PluginPayloadBuilder.build_plugin_info_payload( + plugin, + dependency_result.items, + capability=self._resolve_plugin_capability(plugin.discovered_plugin), + ), + ) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('读取插件详情失败', exc) + + async def get_plugin_info_with_state(self, plugin_id: str) -> PluginCatalogInfoResponse: + """ + 获取包含数据库状态的插件详情。 + + :param plugin_id: 插件ID + :return: 插件详情负载 + """ + try: + registry = self._build_registry() + plugin = registry.get_plugin(plugin_id) + if not plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) + + database_plugin, database_error = await self._load_database_plugin_state(plugin_id) + if database_plugin: + plugin = PluginRegistry.build([plugin.discovered_plugin], [database_plugin]).get_plugin(plugin_id) + + dependency_result = self.dependencies.dependency_checker.check_manifest(plugin.discovered_plugin.manifest) + return cast( + 'PluginCatalogInfoResponse', + PluginPayloadBuilder.build_plugin_info_payload( + plugin, + dependency_result.items, + database_error=database_error, + capability=self._resolve_plugin_capability(plugin.discovered_plugin), + ), + ) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('读取插件详情失败', exc) + + def check_plugin(self, plugin_id: str | None = None) -> PluginCheckResponse: + """ + 检查插件依赖状态。 + + :param plugin_id: 插件ID,未传入时检查全部插件 + :return: 插件检查负载 + """ + try: + database_plugins, database_error = self._load_database_plugin_states_sync_with_error() + return self._build_check_plugin_payload(plugin_id, database_plugins, database_error) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('插件检查失败', exc) + + async def check_plugin_async(self, plugin_id: str | None = None) -> PluginCheckResponse: + """ + 异步检查插件依赖状态。 + + :param plugin_id: 插件ID,未传入时检查全部插件 + :return: 插件检查负载 + """ + try: + database_plugins, database_error = await self._load_database_plugin_states_with_error() + return self._build_check_plugin_payload(plugin_id, database_plugins, database_error) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('插件检查失败', exc) + + def _build_check_plugin_payload( + self, + plugin_id: str | None, + database_plugins: list[PluginStateRecord], + database_error: str | None, + ) -> PluginCheckResponse: + """ + 构建插件检查负载。 + + :param plugin_id: 插件ID,未传入时检查全部插件 + :param database_plugins: 数据库插件状态列表 + :param database_error: 数据库读取错误 + :return: 插件检查负载 + """ + try: + backend_root = Path(self.dependencies.runtime_environment.get_backend_dir()) + frontend_root = Path(self.dependencies.runtime_environment.get_frontend_dir()) + frontend_plugins_root = Path(self.dependencies.runtime_environment.get_frontend_plugins_dir()) + registry = self._build_registry() + plugins = registry.list_plugins() + if plugin_id: + plugin = registry.get_plugin(plugin_id) + if not plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) + plugins = [plugin] + + checks = [] + all_discovered_plugins = [plugin.discovered_plugin for plugin in registry.list_plugins()] + plugin_dependency_checker = InterPluginDependencyChecker(all_discovered_plugins, database_plugins) + for plugin in plugins: + dependency_result = self.dependencies.dependency_checker.check_manifest( + plugin.discovered_plugin.manifest + ) + manifest_result = PluginManifestChecker(backend_root=backend_root, frontend_root=frontend_root).check( + plugin.discovered_plugin.manifest + ) + plugin_dependency_result = plugin_dependency_checker.check_manifest(plugin.discovered_plugin.manifest) + structure_result = PluginStructureChecker(backend_root, frontend_plugins_root).check( + plugin.discovered_plugin + ) + menu_conflict_result = PluginMenuConflictChecker().check( + plugin.discovered_plugin, + all_discovered_plugins, + ) + precheck = PluginPrecheckContext.build( + dependency_result, + manifest_result, + plugin_dependency_result, + structure_result, + menu_conflict_result, + ) + check_item = PluginPayloadBuilder.build_check_item(plugin.plugin_id, precheck) + checks.append( + cast( + 'dict[str, object]', + self._with_plugin_capability(cast('dict[str, object]', check_item), plugin.discovered_plugin), + ) + ) + + return cast('PluginCheckResponse', PluginPayloadBuilder.build_check_payload(checks, database_error)) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('插件检查失败', exc) + + def check_plugin_dependencies(self, plugin_id: str) -> PluginDependencyCheckResponse: + """ + 检查插件依赖状态。 + + :param plugin_id: 插件ID + :return: 插件依赖检查负载 + """ + try: + discovered_plugin = self._get_discovered_plugin(plugin_id) + if not discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) + + dependency_result = self.dependencies.dependency_checker.check_manifest(discovered_plugin.manifest) + payload = PluginPayloadBuilder.build_dependency_check_payload(plugin_id, dependency_result) + return cast( + 'PluginDependencyCheckResponse', + self._with_plugin_capability(cast('dict[str, object]', payload), discovered_plugin), + ) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('检查插件依赖失败', exc) + + async def health_plugin(self, plugin_id: str) -> PluginHealthResponse: + """ + 执行插件健康检查。 + + :param plugin_id: 插件ID + :return: 插件健康检查负载 + """ + try: + discovered_plugin = self._get_discovered_plugin(plugin_id) + if not discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) + + health_result = await PluginHealthChecker(discovered_plugin).check() + return PluginRuntimePayloadBuilder.build_health_response_payload(plugin_id, health_result) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('插件健康检查失败', exc) + + async def diagnose_plugin(self, plugin_id: str) -> PluginDiagnoseResponse: + """ + 生成插件诊断包。 + + 诊断包只读聚合 manifest、依赖检查、结构检查、菜单冲突、配置脱敏快照和审计预留信息。 + + :param plugin_id: 插件ID + :return: 插件诊断包负载 + """ + info_payload = cast('dict[str, object]', await self.get_plugin_info_with_state(plugin_id)) + if not info_payload.get('ok', False): + return PluginRuntimePayloadBuilder.build_diagnose_failure_payload(plugin_id, info_payload) + + check_payload = cast('dict[str, object]', await self.check_plugin_async(plugin_id)) + config_payload = cast( + 'dict[str, object]', await self.runtime_operations.get_plugin_config(plugin_id, reveal_secret=False) + ) + config_payload['summary'] = PluginConfigPayloadBuilder.build_diagnostic_summary(config_payload.get('configs')) + audit_payload = await self._build_recent_audit_snapshot(plugin_id) + discovered_plugin = self._get_discovered_plugin(plugin_id) + menu_plan = ( + PluginPayloadBuilder.build_menu_diagnostic_plan(discovered_plugin) + if discovered_plugin + else PluginRuntimePayloadBuilder.build_empty_menu_plan() + ) + + return PluginRuntimePayloadBuilder.build_diagnose_payload( + plugin_id, + info_payload=info_payload, + check_payload=check_payload, + menu_plan=menu_plan, + config_payload=config_payload, + audit_payload=audit_payload, + ) + + async def _build_recent_audit_snapshot( + self, plugin_id: str, *, audit_limit: int = 5 + ) -> PluginAuditSnapshotResponse: + """ + 构建最近审计记录快照。 + + :param plugin_id: 插件ID + :param audit_limit: 最近审计记录数量 + :return: 最近审计记录快照 + """ + try: + operation_logs = await self.dependencies.audit_gateway.list_plugin_operation_logs( + export_limit=max(audit_limit * AUDIT_LOG_OVERFETCH_MULTIPLIER, audit_limit), + ) + except Exception as exc: + return PluginAuditPayloadBuilder.build_recent_snapshot_failure(exc) + + return PluginAuditPayloadBuilder.build_recent_snapshot_payload( + plugin_id, + operation_logs, + audit_limit=audit_limit, + ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/responses.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/responses.py new file mode 100644 index 0000000..71df8d8 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/responses.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import TypeAlias + +from pydantic import Field + +from plugins.core.runtime.support import ( + BatchOperationResultPayload, + PluginAuditSnapshotFailurePayloadDict, + PluginAuditSnapshotPayloadDict, + PluginCheckPayloadDict, + PluginConfigExportFailurePayloadDict, + PluginConfigExportPayloadDict, + PluginConfigImportPayloadDict, + PluginConfigStatePayloadDict, + PluginDependencyCheckPayloadDict, + PluginDependencyInstallPayloadDict, + PluginDocumentationPayloadDict, + PluginEnableStatePayloadDict, + PluginEnableUpdateFailurePayloadDict, + PluginLifecyclePayloadDict, + PluginNotFoundPayloadDict, + PluginPlanResponsePayload, + PluginPurgeStatePayloadDict, + PluginRuntimeBatchItemUnsupportedPayloadDict, + PluginRuntimeDiagnoseFailurePayloadDict, + PluginRuntimeDiagnosePayloadDict, + PluginRuntimeExceptionPayloadDict, + PluginRuntimeHealthResponsePayloadDict, + PluginRuntimeInvalidOperationPayloadDict, + PluginRuntimePrecheckPayloadDict, + PluginRuntimeUpgradeBlockerPayloadDict, + PluginSafeUninstallPayloadDict, +) +from plugins.core.runtime.support.payload.base import PluginPayloadModel + + +class PluginCatalogListResponsePayload(PluginPayloadModel): + """ + 插件列表响应 payload。 + """ + + ok: bool + count: int + plugins: list[dict[str, object]] + database_available: bool | None = Field(default=None, alias='databaseAvailable') + database_error: str | None = Field(default=None, alias='databaseError') + + +class PluginCatalogInfoResponsePayload(PluginPayloadModel): + """ + 插件详情响应 payload。 + """ + + ok: bool + plugin: dict[str, object] + + +class PluginBatchRunResponsePayload(PluginPayloadModel): + """ + 插件批量执行响应 payload。 + """ + + ok: bool | None = None + message: str | None = None + dry_run: bool | None = Field(default=None, alias='dryRun') + continue_on_error: bool | None = Field(default=None, alias='continueOnError') + executed: list[dict[str, object]] | None = None + failed: dict[str, object] | None = None + summary: dict[str, object] | None = None + exit_code: int | None = None + + +class PluginRuntimeBlockedPayload(PluginPayloadModel): + """ + 插件运行模式阻断响应 payload。 + """ + + ok: bool + status: str + operation: str + plugin_id: str = Field(alias='pluginId') + message: str + suggestion: str + capability: dict[str, object] + dry_run: bool | None = Field(default=None, alias='dryRun') + exit_code: int + + +PluginCatalogListResponseDict: TypeAlias = dict[str, object] +PluginCatalogInfoResponseDict: TypeAlias = dict[str, object] +PluginBatchRunResponseDict: TypeAlias = dict[str, object] +PluginRuntimeBlockedPayloadDict: TypeAlias = dict[str, object] + + +PluginRuntimeBlockedResponse: TypeAlias = PluginRuntimeBlockedPayloadDict +PluginRuntimeFailureResponse: TypeAlias = ( + PluginNotFoundPayloadDict + | PluginRuntimeExceptionPayloadDict + | PluginRuntimeInvalidOperationPayloadDict + | PluginRuntimeBlockedResponse +) +PluginCatalogListResponse: TypeAlias = PluginCatalogListResponseDict | PluginRuntimeExceptionPayloadDict +PluginCatalogInfoResponse: TypeAlias = PluginCatalogInfoResponseDict | PluginRuntimeFailureResponse +PluginCheckResponse: TypeAlias = PluginCheckPayloadDict | PluginRuntimeFailureResponse +PluginDependencyCheckResponse: TypeAlias = PluginDependencyCheckPayloadDict | PluginRuntimeFailureResponse +PluginHealthResponse: TypeAlias = PluginRuntimeHealthResponsePayloadDict | PluginRuntimeFailureResponse +PluginConfigStateResponse: TypeAlias = PluginConfigStatePayloadDict | PluginRuntimeFailureResponse +PluginConfigExportResponse: TypeAlias = PluginConfigExportPayloadDict | PluginConfigExportFailurePayloadDict +PluginConfigImportResponse: TypeAlias = PluginConfigImportPayloadDict +PluginPrecheckResponse: TypeAlias = PluginRuntimePrecheckPayloadDict | PluginRuntimeFailureResponse +PluginPlanResponse: TypeAlias = PluginPlanResponsePayload | PluginRuntimeInvalidOperationPayloadDict +PluginBatchResponse: TypeAlias = PluginPlanResponse | PluginBatchRunResponseDict | PluginRuntimeExceptionPayloadDict +PluginBatchItemExecutionResponse: TypeAlias = ( + BatchOperationResultPayload + | PluginLifecyclePayloadDict + | PluginEnableStatePayloadDict + | PluginRuntimeBatchItemUnsupportedPayloadDict + | PluginRuntimeExceptionPayloadDict +) +PluginDependencyInstallResponse: TypeAlias = PluginDependencyInstallPayloadDict | PluginRuntimeFailureResponse +PluginLifecycleResponse: TypeAlias = ( + PluginLifecyclePayloadDict + | PluginEnableStatePayloadDict + | PluginEnableUpdateFailurePayloadDict + | PluginSafeUninstallPayloadDict + | PluginPurgeStatePayloadDict + | PluginRuntimeUpgradeBlockerPayloadDict + | PluginRuntimeFailureResponse +) +PluginDocumentationResponse: TypeAlias = ( + PluginDocumentationPayloadDict | PluginNotFoundPayloadDict | PluginRuntimeExceptionPayloadDict +) +PluginAuditSnapshotResponse: TypeAlias = PluginAuditSnapshotPayloadDict | PluginAuditSnapshotFailurePayloadDict +PluginDiagnoseResponse: TypeAlias = ( + PluginRuntimeDiagnosePayloadDict | PluginRuntimeDiagnoseFailurePayloadDict | PluginRuntimeExceptionPayloadDict +) +PluginManagementOperationResponse: TypeAlias = ( + PluginCheckResponse + | PluginPrecheckResponse + | PluginHealthResponse + | PluginDiagnoseResponse + | PluginDocumentationResponse + | PluginLifecycleResponse + | PluginConfigStateResponse + | PluginConfigExportResponse + | PluginConfigImportResponse + | PluginDependencyCheckResponse + | PluginPlanResponse + | PluginBatchResponse + | PluginDependencyInstallResponse +) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/tools.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/tools.py new file mode 100644 index 0000000..d961e27 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/tools.py @@ -0,0 +1,55 @@ +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.runtime.support import ( + PluginDocumentationBuilder, + PluginDocumentationPayloadDict, + PluginNotFoundPayloadDict, + PluginPayloadBuilder, + PluginRuntimeExceptionPayloadDict, + PluginRuntimePayloadBuilder, +) + +from .context import PluginRuntimeContextService +from .dependency_container import PluginRuntimeDependencies + + +class PluginToolUseCase: + """ + 插件运行时工具 use case。 + """ + + def __init__(self, dependencies: PluginRuntimeDependencies, context: PluginRuntimeContextService) -> None: + """ + 初始化插件运行时工具 use case。 + + :param dependencies: 插件运行时依赖容器 + :param context: 插件运行时上下文服务 + """ + self.dependencies = dependencies + self.context = context + + def _get_discovered_plugin(self, plugin_id: str) -> DiscoveredPlugin | None: + """ + 根据插件 ID 获取已发现插件。 + + :param plugin_id: 插件ID + :return: 已发现插件对象 + """ + return self.context.get_discovered_plugin(plugin_id) + + def generate_plugin_docs( + self, plugin_id: str + ) -> PluginDocumentationPayloadDict | PluginNotFoundPayloadDict | PluginRuntimeExceptionPayloadDict: + """ + 生成插件 Markdown 文档片段。 + + :param plugin_id: 插件ID + :return: 插件文档生成负载 + """ + try: + discovered_plugin = self._get_discovered_plugin(plugin_id) + if not discovered_plugin: + return PluginPayloadBuilder.build_plugin_not_found_payload(plugin_id) + + return PluginDocumentationBuilder.build_payload(plugin_id, discovered_plugin) + except Exception as exc: + return PluginRuntimePayloadBuilder.build_exception_payload('插件文档生成失败', exc) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/startup.py b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py new file mode 100644 index 0000000..818c317 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/startup.py @@ -0,0 +1,1147 @@ +import asyncio +import sys +from collections.abc import Callable +from dataclasses import replace +from pathlib import Path +from typing import Any + +from fastapi import FastAPI + +from common.router import auto_register_controller_files +from config.database import AsyncSessionLocal +from config.env import AppConfig +from config.get_db import get_db +from plugins.core.discovery.registry import PluginRegistry, RegisteredPlugin +from plugins.core.lifecycle.migration import ( + PluginMigrationHistoryRecord, + PluginMigrationHistoryStore, + PluginMigrationRunner, +) +from plugins.core.lifecycle.seed import PluginSeedRunner +from plugins.core.runtime.bootstrap import PluginRuntimeBuilder +from plugins.core.runtime.hooks import PluginHookRunner +from plugins.core.runtime.route_guard import ( + PluginEnabledDependency, + PluginRouteStateGateway, + UnavailablePluginRouteStateGateway, +) +from plugins.core.runtime.service.gateway import DefaultPluginCommandRunnerGateway, PluginCommandRunnerGateway +from plugins.core.runtime.startup_gateway import ( + PluginStartupManagementGateway, + UnavailablePluginStartupManagementGateway, +) +from plugins.core.validation.dependencies import ( + PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX, + DependencyCheckResult, + PluginDependencyInstallPlanner, + PythonDependencyInspector, +) +from plugins.core.validation.structure import PluginStructureChecker +from utils.log_util import logger + + +class PluginStartupMigrationHistoryStore(PluginMigrationHistoryStore): + """ + 启动期插件 migration 历史存储适配器。 + """ + + def __init__(self, management_gateway: PluginStartupManagementGateway, async_session_local: Any = None) -> None: + """ + 初始化启动期 migration 历史存储。 + + :param management_gateway: 插件启动期管理端口 + :param async_session_local: 独立数据库会话工厂 + :return: None + """ + self.management_gateway = management_gateway + self.async_session_local = async_session_local + + async def get_record( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + ) -> PluginMigrationHistoryRecord | None: + """ + 获取 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: migration 执行历史 + """ + plugin_migration = await self.management_gateway.get_plugin_migration(query_db, plugin_id, migration_path) + if not plugin_migration: + return None + return PluginMigrationHistoryRecord( + checksum=plugin_migration.migration_checksum, + status=getattr(plugin_migration, 'status', 'success'), + error_message=getattr(plugin_migration, 'error_message', None), + ) + + async def record_running( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + ) -> None: + """ + 记录 migration 开始执行。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: None + """ + await self._add_plugin_migration( + query_db, + self.management_gateway.build_migration_record( + plugin_id, + migration_path, + checksum, + version, + statement_count, + 'running', + ), + ) + + async def record_success( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + ) -> None: + """ + 记录 migration 成功执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: None + """ + await self._add_plugin_migration( + query_db, + self.management_gateway.build_migration_record( + plugin_id, + migration_path, + checksum, + version, + statement_count, + ), + ) + + async def record_failure( + self, + query_db: Any, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + error_message: str, + ) -> None: + """ + 记录 migration 执行失败历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :param error_message: 失败错误信息 + :return: None + """ + await self._add_plugin_migration( + query_db, + self.management_gateway.build_migration_record( + plugin_id, + migration_path, + checksum, + version, + statement_count, + 'failed', + error_message, + ), + ) + + async def _add_plugin_migration(self, query_db: Any, plugin_migration: Any) -> None: + """ + 写入 migration 历史,优先使用独立会话提交。 + + :param query_db: 当前启动期 orm对象 + :param plugin_migration: migration 历史模型 + :return: None + """ + if self.async_session_local is None: + await self.management_gateway.add_plugin_migration(query_db, plugin_migration) + return + + async with self.async_session_local() as session: + await self.management_gateway.add_plugin_migration(session, plugin_migration) + await session.commit() + + +class PluginRuntimeStartupManager: + """ + 插件运行时启动协调器。 + + 将插件发现、实体导入、资源安装、路由注册和生命周期钩子从应用入口中隔离出来, + 让 server.py 只保留高层启动顺序。 + """ + + def __init__( + self, + builder: PluginRuntimeBuilder | None = None, + management_gateway: PluginStartupManagementGateway | None = None, + route_state_gateway: PluginRouteStateGateway | None = None, + python_dependency_inspector: PythonDependencyInspector | None = None, + python_dependency_inspector_factory: Callable[[], PythonDependencyInspector] | None = None, + command_runner_gateway: PluginCommandRunnerGateway | None = None, + default_enabled_builtin_plugin_ids: set[str] | None = None, + ) -> None: + """ + 初始化插件运行时启动协调器。 + + :param builder: 插件运行时构建器 + :param management_gateway: 插件启动期管理端口 + :param route_state_gateway: 插件路由状态读取端口 + :param python_dependency_inspector: Python 依赖检查器 + :param python_dependency_inspector_factory: Python 依赖检查器工厂 + :param command_runner_gateway: 插件命令执行网关 + :param default_enabled_builtin_plugin_ids: 首次启动默认安装启用的内置插件 ID 集合 + :return: None + """ + self.builder = builder or PluginRuntimeBuilder() + self.management_gateway = management_gateway or UnavailablePluginStartupManagementGateway() + self.route_state_gateway = route_state_gateway or UnavailablePluginRouteStateGateway() + self.python_dependency_inspector_factory = python_dependency_inspector_factory or PythonDependencyInspector + self.python_dependency_inspector = python_dependency_inspector or self.python_dependency_inspector_factory() + self.command_runner_gateway = command_runner_gateway or DefaultPluginCommandRunnerGateway() + self.default_enabled_builtin_plugin_ids = ( + self.parse_default_enabled_builtin_plugin_ids(AppConfig.app_default_enabled_plugins) + if default_enabled_builtin_plugin_ids is None + else set(default_enabled_builtin_plugin_ids) + ) + + def bind_app(self, app: FastAPI) -> None: + """ + 绑定插件运行时对象到 FastAPI state。 + + :param app: FastAPI对象 + :return: None + """ + app.state.plugin_runtime_startup = self + app.state.plugin_runtime_builder = self.builder + if not hasattr(app.state, 'plugin_registry'): + app.state.plugin_registry = self.builder.build_registry() + if not hasattr(app.state, 'plugin_routes_registered'): + app.state.plugin_routes_registered = False + + def import_builtin_entities(self) -> None: + """ + 导入内置业务模块实体。 + + :return: None + """ + self.builder.import_builtin_entities() + + @staticmethod + def parse_default_enabled_builtin_plugin_ids(plugin_ids: str) -> set[str]: + """ + 解析默认启用内置插件配置。 + + :param plugin_ids: 逗号分隔的插件 ID 配置 + :return: 插件 ID 集合 + """ + return {plugin_id.strip() for plugin_id in plugin_ids.split(',') if plugin_id.strip()} + + async def prepare_enabled_plugins(self, app: FastAPI, *, startup_write_enabled: bool = True) -> None: + """ + 准备启用插件运行时实体。 + + :param app: FastAPI对象 + :param startup_write_enabled: 是否允许执行启动期写库操作 + :return: None + """ + default_dependency_failed_plugin_ids: set[str] = set() + if startup_write_enabled: + default_dependency_failed_plugin_ids = await self.sync_default_enabled_builtin_plugin_install_states() + await self.load_registry_from_database(app) + dependency_failed_plugin_ids = await self.check_enabled_plugin_python_dependencies( + app, + startup_write_enabled=startup_write_enabled, + ) + app.state.plugin_dependency_failed_plugin_ids = ( + default_dependency_failed_plugin_ids | dependency_failed_plugin_ids + ) + self.disable_runtime_plugins(app, dependency_failed_plugin_ids) + import_failed_plugin_ids = await self.import_enabled_plugin_entities( + app, + startup_write_enabled=startup_write_enabled, + ) + self.disable_runtime_plugins(app, import_failed_plugin_ids) + + async def requires_startup_write(self) -> bool: + """ + 判断当前数据库状态是否要求重新执行启动期全局写入。 + + Redis ready 标记用于协调同一代际的并发 worker,但它的生命周期可能长于 + 数据库本身。数据库被重建、清空或恢复旧快照后,默认启用插件可能重新缺少 + 安装状态,此时不能复用旧 ready 标记。 + + :return: 是否需要重新执行启动期写入 + """ + if not self.default_enabled_builtin_plugin_ids: + return False + + discovered_plugin_ids = { + plugin.manifest.id + for plugin in self.builder.discover_plugins() + if plugin.manifest.id in self.default_enabled_builtin_plugin_ids + } + if not discovered_plugin_ids: + return False + + async for query_db in get_db(): + plugin_list = await self.management_gateway.list_plugins(query_db) + database_plugin_map = {plugin.plugin_id: plugin for plugin in plugin_list} + return any( + self._should_sync_default_enabled_builtin_plugin(database_plugin_map.get(plugin_id)) + for plugin_id in discovered_plugin_ids + ) + + return True + + async def check_enabled_plugin_python_dependencies( + self, + app: FastAPI, + *, + startup_write_enabled: bool = True, + ) -> set[str]: + """ + 检查启用插件的 Python 依赖,缺失时标记插件运行时异常。 + + :param app: FastAPI对象 + :param startup_write_enabled: 是否允许执行启动期写库操作 + :return: 依赖检查失败的插件 ID 集合 + """ + plugin_registry = getattr(app.state, 'plugin_registry', None) + if plugin_registry is None: + return set() + + self.python_dependency_inspector.refresh() + failed_plugin_ids: set[str] = set() + recovered_plugins: list[RegisteredPlugin] = [] + for plugin in self._list_dependency_check_plugins(plugin_registry): + python_requirements = plugin.discovered_plugin.manifest.dependencies.python + failed_messages = [] + if python_requirements: + dependency_result = self._check_plugin_python_dependencies(plugin.plugin_id, python_requirements) + failed_messages = self._build_dependency_failed_messages(dependency_result) + if not failed_messages: + if startup_write_enabled and self._has_startup_dependency_error(plugin): + recovered_plugins.append(plugin) + continue + failed_plugin_ids.add(plugin.plugin_id) + error_message = self._build_dependency_startup_error_message(plugin.plugin_id, failed_messages) + logger.bind( + plugin_id=plugin.plugin_id, + startup_generation=getattr(app.state, 'plugin_startup_generation', None), + plugin_startup_role_at_creation='writer' if startup_write_enabled else 'reader', + startup_write_enabled=startup_write_enabled, + ).error(f'❌ {error_message}') + if startup_write_enabled: + await self.mark_plugin_runtime_error(app, plugin.plugin_id, error_message) + + if recovered_plugins: + await self.recover_plugin_dependency_errors(app, recovered_plugins) + return failed_plugin_ids + + @classmethod + def _list_dependency_check_plugins(cls, plugin_registry: PluginRegistry) -> list[RegisteredPlugin]: + """ + 获取本次启动需要检查 Python 依赖的插件。 + + 除当前启用插件外,还要重新检查上次因启动依赖失败而被隔离的插件, + 避免其进入 error 状态后在后续启动中被启用态过滤器永久跳过。 + + :param plugin_registry: 插件运行时注册表 + :return: 需要检查依赖的插件列表 + """ + plugins = list(plugin_registry.list_enabled_plugins()) + checked_plugin_ids = {plugin.plugin_id for plugin in plugins} + for plugin in plugin_registry.list_plugins(): + if plugin.plugin_id in checked_plugin_ids: + continue + if cls._has_startup_dependency_error(plugin): + plugins.append(plugin) + checked_plugin_ids.add(plugin.plugin_id) + return plugins + + @staticmethod + def _has_startup_dependency_error(plugin: RegisteredPlugin) -> bool: + """ + 判断插件是否仅因启动依赖检查失败而处于异常状态。 + + :param plugin: 插件运行时快照 + :return: 是否为启动依赖异常 + """ + database_plugin = plugin.database_plugin + last_error = getattr(database_plugin, 'last_error', None) if database_plugin else None + return ( + getattr(database_plugin, 'status', None) == 'error' + and isinstance(last_error, str) + and last_error.startswith(PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX) + ) + + async def recover_plugin_dependency_errors( + self, + app: FastAPI, + plugins: list[RegisteredPlugin], + ) -> None: + """ + 恢复启动依赖重新满足的插件状态,并刷新运行时注册表。 + + 仅处理带启动依赖错误前缀的插件,其他 migration、实体导入或 hook + 异常仍保持 error,避免启动时误清除真实故障。 + + :param app: FastAPI对象 + :param plugins: 依赖已恢复的插件列表 + :return: None + """ + recovered = False + async for query_db in get_db(): + for plugin in plugins: + result = await self.management_gateway.recover_plugin_dependency_error( + query_db, + plugin.discovered_plugin, + ) + if result.is_success: + recovered = True + logger.info(f'✅ 插件启动依赖已恢复:{plugin.plugin_id}') + continue + logger.warning(f'⚠️ 插件启动依赖恢复状态写入失败:{plugin.plugin_id},原因:{result.message}') + if recovered: + await query_db.commit() + else: + await query_db.rollback() + if recovered: + await self.load_registry_from_database(app) + + def _check_plugin_python_dependencies( + self, + plugin_id: str, + python_requirements: list[str], + ) -> DependencyCheckResult: + """ + 检查单个插件 Python 依赖。 + + :param plugin_id: 插件ID + :param python_requirements: Python 依赖声明列表 + :return: 依赖检查结果 + """ + return DependencyCheckResult( + plugin_id=plugin_id, items=self.python_dependency_inspector.check(python_requirements) + ) + + @staticmethod + def _build_dependency_failed_messages(dependency_result: DependencyCheckResult) -> list[str]: + """ + 构建依赖检查失败消息。 + + :param dependency_result: 依赖检查结果 + :return: 失败消息列表 + """ + return [item.message for item in dependency_result.items if not item.ok] + + @staticmethod + def _build_dependency_startup_error_message(plugin_id: str, failed_messages: list[str]) -> str: + """ + 构建包含修复命令的启动依赖检查失败消息。 + + :param plugin_id: 插件ID + :param failed_messages: 依赖检查失败消息 + :return: 启动依赖检查失败消息 + """ + install_command = f'ruoyi plugin install-deps {plugin_id} --env={AppConfig.app_env} --yes' + return ( + f'{PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX}{";".join(failed_messages)};安装依赖请执行:{install_command}' + ) + + async def _prompt_and_install_plugin_python_dependencies( + self, + plugin_id: str, + dependency_result: DependencyCheckResult, + ) -> bool: + """ + 在 TTY 环境中询问是否安装插件 Python 依赖。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :return: 是否已尝试安装依赖 + """ + if not self._can_prompt_dependency_install(): + return False + failed_messages = self._build_dependency_failed_messages(dependency_result) + print(f'插件 {plugin_id} 缺少启动所需 Python 依赖:', file=sys.stderr) + for message in failed_messages: + print(f'- {message}', file=sys.stderr) + answer = (await asyncio.to_thread(input, '是否立即安装缺失依赖?[y/N] ')).strip().lower() + if answer not in {'y', 'yes'}: + return False + await self._install_plugin_python_dependencies(dependency_result) + self.python_dependency_inspector = self.python_dependency_inspector_factory() + return True + + @staticmethod + def _can_prompt_dependency_install() -> bool: + """ + 判断当前启动环境是否支持交互确认。 + + :return: 是否支持交互确认 + """ + _ = AppConfig.app_workers + _ = sys.stdin.isatty() + return False + + async def _install_plugin_python_dependencies(self, dependency_result: DependencyCheckResult) -> None: + """ + 安装插件 Python 依赖。 + + :param dependency_result: 依赖检查结果 + :return: None + """ + install_plan = PluginDependencyInstallPlanner().build_plan(dependency_result) + for item in install_plan.items: + if item.kind != 'python': + continue + completed = await asyncio.to_thread( + self.command_runner_gateway.run_command, + item.command, + item.workdir, + ) + if completed.returncode == 0: + logger.info(f'✅ 插件 {dependency_result.plugin_id} Python 依赖安装完成:{item.requirement}') + continue + logger.error( + f'❌ 插件 {dependency_result.plugin_id} Python 依赖安装失败:{item.requirement},' + f'returncode={completed.returncode},stderr={completed.stderr[-500:]}' + ) + + async def activate_enabled_plugins(self, app: FastAPI, *, startup_write_enabled: bool = True) -> None: + """ + 激活启用插件运行时资源。 + + :param app: FastAPI对象 + :param startup_write_enabled: 是否允许执行启动期写库操作 + :return: None + """ + if startup_write_enabled: + await self.sync_enabled_plugin_install_states(app) + await self.install_enabled_plugin_resources(app) + await self.run_enabled_plugin_hooks(app, 'on_startup', startup_write_enabled=startup_write_enabled) + self.register_enabled_plugin_routers(app, startup_write_enabled=startup_write_enabled) + + async def shutdown(self, app: FastAPI, *, startup_write_enabled: bool = True) -> None: + """ + 执行插件运行时关闭接入流程。 + + :param app: FastAPI对象 + :param startup_write_enabled: 是否允许执行启动期写库操作 + :return: None + """ + await self.run_enabled_plugin_hooks(app, 'on_shutdown', startup_write_enabled=startup_write_enabled) + + async def load_registry_from_database(self, app: FastAPI) -> None: + """ + 从数据库插件状态构建运行时插件注册表。 + + :param app: FastAPI对象 + :return: None + """ + async for query_db in get_db(): + plugin_list = await self.management_gateway.list_plugins(query_db) + app.state.plugin_registry = self.builder.build_registry(plugin_list) + + async def import_enabled_plugin_entities( + self, + app: FastAPI, + *, + startup_write_enabled: bool = True, + ) -> set[str]: + """ + 导入启用插件实体并标记导入失败插件。 + + :param app: FastAPI对象 + :param startup_write_enabled: 是否允许执行启动期写库操作 + :return: 实体导入失败的插件 ID 集合 + """ + plugin_registry = getattr(app.state, 'plugin_registry', None) + if plugin_registry is None: + return set() + + import_result = self.builder.import_plugin_entities(plugin_registry) + failed_plugin_ids: set[str] = set() + for failure in import_result.failures: + failed_plugin_ids.add(failure.plugin_id) + logger.bind( + plugin_id=failure.plugin_id, + startup_generation=getattr(app.state, 'plugin_startup_generation', None), + plugin_startup_role_at_creation='writer' if startup_write_enabled else 'reader', + startup_write_enabled=startup_write_enabled, + ).error(f'❌ 插件实体导入失败:{failure.error_message}') + if startup_write_enabled: + await self.mark_plugin_runtime_error(app, failure.plugin_id, failure.error_message) + + return failed_plugin_ids + + async def install_enabled_plugin_resources(self, app: FastAPI) -> None: + """ + 逐插件同步启用插件的启动期资源。 + + 每个插件使用独立事务,单个插件资源声明或数据库写入失败时仅隔离该插件, + 不再回滚其他插件或阻断宿主应用启动。 + + :param app: FastAPI对象 + :return: None + """ + plugin_registry = getattr(app.state, 'plugin_registry', None) + if plugin_registry is None: + return + + plugins = list(plugin_registry.list_enabled_plugins()) + for plugin in plugins: + await self.install_plugin_resources_with_isolation(app, plugin) + + async def install_plugin_resources_with_isolation( + self, + app: FastAPI, + plugin: RegisteredPlugin, + ) -> None: + """ + 在独立事务中同步单个插件资源,失败时隔离该插件。 + + :param app: FastAPI对象 + :param plugin: 插件运行时快照 + :return: None + """ + with logger.contextualize( + plugin_id=plugin.plugin_id, + plugin_startup_role_at_creation='writer', + startup_write_enabled=True, + ): + logger.info('🔄 开始同步单插件启动资源') + try: + async for query_db in get_db(): + try: + await self.management_gateway.install_plugin_resources( + query_db, + plugin.discovered_plugin, + enabled=True, + ) + await query_db.commit() + except Exception: + await query_db.rollback() + raise + except Exception as exc: + error_message = f'插件启动资源同步失败:{exc}' + logger.exception(f'❌ {error_message}') + await self.mark_plugin_runtime_error(app, plugin.plugin_id, error_message) + return + logger.info('✅ 单插件启动资源同步完成') + + async def sync_enabled_plugin_install_states(self, app: FastAPI) -> None: + """ + 将默认启用且尚未持久化安装状态的插件标记为已安装。 + + :param app: FastAPI对象 + :return: None + """ + plugin_registry = getattr(app.state, 'plugin_registry', None) + if plugin_registry is None: + return + + plugins_to_sync = [ + plugin + for plugin in plugin_registry.list_enabled_plugins() + if self._should_sync_plugin_install_state(plugin) + ] + if not plugins_to_sync: + return + + for plugin in plugins_to_sync: + await self.sync_plugin_install_with_isolation(app, plugin) + + await self.load_registry_from_database(app) + + async def sync_plugin_install_with_isolation( + self, + app: FastAPI, + plugin: RegisteredPlugin, + ) -> None: + """ + 执行单插件启动安装,失败时记录错误并继续其他插件。 + + :param app: FastAPI对象 + :param plugin: 插件运行时快照 + :return: None + """ + try: + await self.sync_plugin_install(plugin.discovered_plugin, enabled=True) + except Exception as exc: + error_message = f'插件启动安装失败:{exc}' + logger.exception(f'❌ {plugin.plugin_id} {error_message}') + await self.mark_plugin_runtime_error(app, plugin.plugin_id, error_message) + + async def sync_plugin_install(self, discovered_plugin: Any, *, enabled: bool) -> None: + """ + 使用独立事务执行单个插件的启动期安装生命周期。 + + 启动期首次安装与管理端安装保持相同的关键步骤:结构校验、发现状态写入、 + 资源同步、migration、seed、on_install 钩子和最终安装状态写入。 + + :param discovered_plugin: 已发现插件对象 + :param enabled: 插件资源是否启用 + :return: None + """ + plugin_id = discovered_plugin.manifest.id + with logger.contextualize( + plugin_id=plugin_id, + plugin_startup_role_at_creation='writer', + startup_write_enabled=True, + ): + logger.info('🔄 开始执行插件启动安装生命周期') + self.validate_plugin_structure(discovered_plugin) + async for query_db in get_db(): + try: + await self.management_gateway.upsert_discovered_plugin( + query_db, + discovered_plugin, + self.builder.plugins_root, + self.builder.frontend_plugins_root, + ) + await self.management_gateway.install_plugin_resources( + query_db, + discovered_plugin, + enabled=enabled, + ) + await self.run_plugin_install_scripts(query_db, discovered_plugin) + await self.run_plugin_install_hook(query_db, discovered_plugin) + await self.management_gateway.mark_plugin_installed(query_db, discovered_plugin) + await query_db.commit() + except Exception: + await query_db.rollback() + raise + logger.info('✅ 插件启动安装生命周期执行完成') + + @staticmethod + async def run_plugin_install_hook(query_db: Any, discovered_plugin: Any) -> None: + """ + 执行插件首次安装钩子。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: None + """ + await PluginHookRunner(discovered_plugin).run('on_install', query_db=query_db) + + def validate_plugin_structure(self, discovered_plugin: Any) -> None: + """ + 校验启动期首次安装插件的目录和声明引用。 + + :param discovered_plugin: 已发现插件对象 + :return: None + :raises ValueError: 插件结构校验失败 + """ + result = PluginStructureChecker( + self.builder.backend_root, + self.builder.frontend_plugins_root, + ).check(discovered_plugin) + if result.ok: + return + messages = ';'.join(item.message for item in result.failed_items) + raise ValueError(f'插件结构校验失败:{messages}') + + @staticmethod + def _should_sync_plugin_install_state(plugin: RegisteredPlugin) -> bool: + """ + 判断启用插件是否需要在启动期同步安装状态。 + + :param plugin: 插件运行时快照 + :return: 是否需要同步 + """ + database_plugin = plugin.database_plugin + if database_plugin is None: + return True + return ( + not getattr(database_plugin, 'installed_version', None) + and getattr(database_plugin, 'status', None) == 'discovered' + ) + + async def run_plugin_install_scripts(self, query_db: Any, discovered_plugin: Any) -> None: + """ + 执行插件安装期数据库脚本。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: None + """ + async with AsyncSessionLocal() as migration_session: + await PluginMigrationRunner( + discovered_plugin, + PluginStartupMigrationHistoryStore(self.management_gateway, AsyncSessionLocal), + manage_execution_transaction=True, + ).run(migration_session) + await self.run_plugin_seed_scripts(query_db, discovered_plugin) + + @staticmethod + async def run_plugin_seed_scripts(query_db: Any, discovered_plugin: Any) -> None: + """ + 执行插件 seed 脚本。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: None + """ + await PluginSeedRunner(discovered_plugin).run(query_db) + + def register_enabled_plugin_routers( + self, + app: FastAPI, + *, + startup_write_enabled: bool = True, + ) -> None: + """ + 注册启用插件 controller 路由。 + + :param app: FastAPI对象 + :param startup_write_enabled: 当前worker是否为插件启动writer + :return: None + """ + if getattr(app.state, 'plugin_routes_registered', False): + return + plugin_registry = getattr(app.state, 'plugin_registry', None) + plugin_ids = [] + if plugin_registry: + plugin_ids = [ + plugin.plugin_id + for plugin in plugin_registry.list_enabled_plugins() + if plugin.discovered_plugin.manifest.backend.routers.auto_scan + ] + for plugin_id in plugin_ids: + with logger.contextualize( + plugin_id=plugin_id, + startup_generation=getattr(app.state, 'plugin_startup_generation', None), + plugin_startup_role_at_creation='writer' if startup_write_enabled else 'reader', + startup_write_enabled=startup_write_enabled, + ): + controller_files = self._find_plugin_controller_files([plugin_id]) + controller_files = self._filter_plugin_controller_files_by_route_prefix(plugin_id, controller_files) + if controller_files: + auto_register_controller_files( + app, + controller_files, + dependencies=[PluginEnabledDependency(plugin_id, self.route_state_gateway)], + ) + app.state.plugin_routes_registered = True + + def _filter_plugin_controller_files_by_route_prefix(self, plugin_id: str, controller_files: list[str]) -> list[str]: + """ + 启动期再次校验插件 controller 路由前缀,避免绕过预检注册宿主命名空间路由。 + + :param plugin_id: 插件ID + :param controller_files: controller 文件路径列表 + :return: 通过命名空间校验的 controller 文件路径列表 + """ + checker = PluginStructureChecker(self.builder.backend_root) + valid_controller_files = [] + for controller_file in controller_files: + check_items = checker.check_controller_file_route_prefixes(plugin_id, Path(controller_file)) + if not check_items: + logger.error(f'❌ 插件 {plugin_id} controller 路由前缀无法静态确认,启动期跳过注册:{controller_file}') + continue + failed_items = [item for item in check_items if not item.ok] + if failed_items: + logger.error( + f'❌ 插件 {plugin_id} controller 路由前缀非法,启动期跳过注册:' + f'{"、".join(item.message for item in failed_items)}' + ) + continue + valid_controller_files.append(controller_file) + + return valid_controller_files + + def _find_plugin_controller_files(self, plugin_ids: list[str]) -> list[str]: + """ + 查找启用插件 controller 目录下的路由文件。 + + :param plugin_ids: 插件ID列表 + :return: 插件controller文件路径列表 + """ + backend_root = self.builder.backend_root + plugins_root = backend_root / 'plugins' + controller_files = [] + for plugin_id in plugin_ids: + controller_dir = plugins_root / plugin_id / 'controller' + if not controller_dir.is_dir(): + continue + controller_files.extend(str(path) for path in controller_dir.glob('[!_]*.py')) + + return sorted(controller_files) + + async def run_enabled_plugin_hooks( + self, + app: FastAPI, + hook_name: str, + *, + startup_write_enabled: bool = True, + ) -> None: + """ + 执行启用插件生命周期钩子。 + + :param app: FastAPI对象 + :param hook_name: 钩子名称 + :param startup_write_enabled: 是否允许执行启动期写库操作 + :return: None + """ + plugin_registry = getattr(app.state, 'plugin_registry', None) + if plugin_registry is None: + return + + for plugin in plugin_registry.list_enabled_plugins(): + await self.run_single_plugin_hook( + app, + plugin, + hook_name, + startup_write_enabled=startup_write_enabled, + ) + + async def run_single_plugin_hook( + self, + app: FastAPI, + plugin: RegisteredPlugin, + hook_name: str, + *, + startup_write_enabled: bool = True, + ) -> None: + """ + 执行单个插件生命周期钩子并处理运行时异常。 + + :param app: FastAPI对象 + :param plugin: 插件运行时快照 + :param hook_name: 钩子名称 + :param startup_write_enabled: 是否允许执行启动期写库操作 + :return: None + """ + try: + await PluginHookRunner(plugin.discovered_plugin).run( + hook_name, + app=app, + startup_write_enabled=startup_write_enabled, + ) + except Exception as exc: + logger.bind( + plugin_id=plugin.plugin_id, + plugin_hook=hook_name, + startup_generation=getattr(app.state, 'plugin_startup_generation', None), + plugin_startup_role_at_creation='writer' if startup_write_enabled else 'reader', + startup_write_enabled=startup_write_enabled, + origin_hook=hook_name, + ).exception(f'❌ 插件生命周期钩子执行失败:{exc}') + if startup_write_enabled: + await self.mark_plugin_runtime_error(app, plugin.plugin_id, str(exc)) + elif hook_name == 'on_startup': + self.disable_runtime_plugins(app, {plugin.plugin_id}) + + async def mark_plugin_runtime_error(self, app: FastAPI, plugin_id: str, error_message: str) -> None: + """ + 标记插件运行时异常并刷新运行时注册表。 + + :param app: FastAPI对象 + :param plugin_id: 插件ID + :param error_message: 错误信息 + :return: None + """ + async for query_db in get_db(): + result = await self.management_gateway.mark_plugin_error(query_db, plugin_id, error_message) + if not result.is_success: + await query_db.rollback() + registered_plugin = self.get_registered_plugin(app, plugin_id) + if registered_plugin: + await self.management_gateway.upsert_discovered_plugin( + query_db, + registered_plugin.discovered_plugin, + self.builder.plugins_root, + self.builder.frontend_plugins_root, + ) + result = await self.management_gateway.mark_plugin_error(query_db, plugin_id, error_message) + if result.is_success: + await query_db.commit() + else: + await query_db.rollback() + logger.warning(f'⚠️ 插件运行时异常状态写入失败:{plugin_id},原因:{result.message}') + await self.load_registry_from_database(app) + + async def sync_default_enabled_builtin_plugin_install_states(self) -> set[str]: + """ + 首次启动时将内置默认启用插件写入数据库安装状态。 + + 安装脚本执行前先校验 Python 依赖,避免 migration/seed 导入缺失依赖导致启动 + 提前失败。缺失依赖的插件被标记为 error 并隔离,不影响其他插件继续启动。 + + :return: 依赖检查失败的默认启用插件 ID 集合 + """ + if not self.default_enabled_builtin_plugin_ids: + return set() + + discovered_plugins = [ + plugin + for plugin in self.builder.discover_plugins() + if plugin.manifest.id in self.default_enabled_builtin_plugin_ids + ] + if not discovered_plugins: + return set() + + failed_plugin_ids: set[str] = set() + async for query_db in get_db(): + plugin_list = await self.management_gateway.list_plugins(query_db) + database_plugin_map = {plugin.plugin_id: plugin for plugin in plugin_list} + plugins_to_sync = [ + plugin + for plugin in discovered_plugins + if self._should_sync_default_enabled_builtin_plugin(database_plugin_map.get(plugin.manifest.id)) + ] + if not plugins_to_sync: + return failed_plugin_ids + + self.python_dependency_inspector.refresh() + for plugin in plugins_to_sync: + plugin_id = plugin.manifest.id + dependency_failed_messages = self._check_default_plugin_python_dependencies(plugin) + if dependency_failed_messages: + failed_plugin_ids.add(plugin_id) + error_message = self._build_dependency_startup_error_message( + plugin_id, + dependency_failed_messages, + ) + logger.error(f'❌ {plugin_id} {error_message}') + await self.mark_discovered_plugin_startup_error(plugin, error_message) + continue + try: + await self.sync_plugin_install(plugin, enabled=True) + except Exception as exc: + failed_plugin_ids.add(plugin_id) + error_message = f'插件启动安装失败:{exc}' + logger.exception(f'❌ {plugin_id} {error_message}') + await self.mark_discovered_plugin_startup_error(plugin, error_message) + return failed_plugin_ids + + async def mark_discovered_plugin_startup_error( + self, + discovered_plugin: Any, + error_message: str, + ) -> None: + """ + 以独立事务持久化启动期插件错误,确保安装事务回滚后仍可观测。 + + :param discovered_plugin: 已发现插件对象 + :param error_message: 错误信息 + :return: None + """ + plugin_id = discovered_plugin.manifest.id + async for query_db in get_db(): + try: + await self.management_gateway.upsert_discovered_plugin( + query_db, + discovered_plugin, + self.builder.plugins_root, + self.builder.frontend_plugins_root, + ) + result = await self.management_gateway.mark_plugin_error(query_db, plugin_id, error_message) + if not result.is_success: + raise RuntimeError(result.message) + await query_db.commit() + except Exception: + await query_db.rollback() + logger.exception(f'❌ 插件启动异常状态写入失败:{plugin_id}') + + def _check_default_plugin_python_dependencies(self, discovered_plugin: Any) -> list[str]: + """ + 校验内置默认启用插件的 Python 依赖。 + + :param discovered_plugin: 已发现插件 + :return: 依赖检查失败消息列表 + """ + python_requirements = discovered_plugin.manifest.dependencies.python + if not python_requirements: + return [] + dependency_result = self._check_plugin_python_dependencies( + discovered_plugin.manifest.id, + python_requirements, + ) + return self._build_dependency_failed_messages(dependency_result) + + @staticmethod + def _should_sync_default_enabled_builtin_plugin(database_plugin: Any | None) -> bool: + """ + 判断内置默认启用插件是否需要启动期初始化安装状态。 + + :param database_plugin: 数据库插件状态 + :return: 是否需要初始化 + """ + if database_plugin is None: + return True + return ( + not getattr(database_plugin, 'installed_version', None) + and getattr(database_plugin, 'status', None) == 'discovered' + and getattr(database_plugin, 'enabled', None) == '0' + ) + + @staticmethod + def disable_runtime_plugins(app: FastAPI, plugin_ids: set[str]) -> None: + """ + 在当前 worker 的运行时注册表中停用指定插件,避免非写入 worker 继续导入或注册失败插件。 + + :param app: FastAPI对象 + :param plugin_ids: 需要在当前 worker 跳过的插件 ID 集合 + :return: None + """ + if not plugin_ids: + return + plugin_registry = getattr(app.state, 'plugin_registry', None) + if plugin_registry is None: + return + app.state.plugin_registry = PluginRegistry( + [ + replace(plugin, enabled=False, status='error') if plugin.plugin_id in plugin_ids else plugin + for plugin in plugin_registry.list_plugins() + ] + ) + + @staticmethod + def get_registered_plugin(app: FastAPI, plugin_id: str) -> RegisteredPlugin | None: + """ + 从应用运行时注册表获取插件快照。 + + :param app: FastAPI对象 + :param plugin_id: 插件ID + :return: 插件运行时快照 + """ + plugin_registry = getattr(app.state, 'plugin_registry', None) + if plugin_registry is None: + return None + + return plugin_registry.get_plugin(plugin_id) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/startup_coordination.py b/ruoyi-fastapi-backend/plugins/core/runtime/startup_coordination.py new file mode 100644 index 0000000..b3c23ef --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/startup_coordination.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from hashlib import sha256 +from pathlib import Path + +from config.env import AppConfig + +PLUGIN_STARTUP_FINGERPRINT_SUFFIXES = frozenset({'.py', '.sql', '.yaml', '.yml'}) + + +class PluginStartupGenerationResolver: + """ + 插件启动代际解析器。 + + 生产部署可通过 ``APP_RELEASE_ID`` 显式提供发布代际。未配置时,对应用版本和插件 + 后端源码生成稳定指纹,使同一发布的多个 worker 共享代际,而源码变化后的滚动发布 + 不会复用旧版本 ready 状态。 + """ + + def __init__(self, backend_root: Path | str, *, release_id: str | None = None) -> None: + """ + 初始化插件启动代际解析器。 + + :param backend_root: 后端项目根目录 + :param release_id: 显式发布标识 + """ + self.backend_root = Path(backend_root).resolve() + self.release_id = release_id if release_id is not None else AppConfig.app_release_id + + def resolve(self) -> str: + """ + 解析当前插件启动代际。 + + :return: 可用于 Redis key 的稳定代际摘要 + """ + digest = sha256() + explicit_release_id = self.release_id.strip() + if explicit_release_id: + digest.update(b'release:') + digest.update(explicit_release_id.encode()) + return digest.hexdigest()[:24] + + digest.update(f'app-version:{AppConfig.app_version}\n'.encode()) + plugins_root = self.backend_root / 'plugins' + if not plugins_root.is_dir(): + return digest.hexdigest()[:24] + + source_files = ( + path + for path in plugins_root.rglob('*') + if path.is_file() + and path.suffix.lower() in PLUGIN_STARTUP_FINGERPRINT_SUFFIXES + and '__pycache__' not in path.parts + ) + for source_file in sorted(source_files): + relative_path = source_file.relative_to(self.backend_root).as_posix() + digest.update(relative_path.encode()) + digest.update(b'\0') + digest.update(source_file.read_bytes()) + digest.update(b'\0') + + return digest.hexdigest()[:24] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/startup_gateway.py b/ruoyi-fastapi-backend/plugins/core/runtime/startup_gateway.py new file mode 100644 index 0000000..f72c60b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/startup_gateway.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NoReturn, Protocol, runtime_checkable + +if TYPE_CHECKING: + from pathlib import Path + + from sqlalchemy.ext.asyncio import AsyncSession + + from common.vo import CrudResponseModel + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.management.entity.vo.schemas import PluginMigrationModel, PluginModel + + +@runtime_checkable +class PluginStartupManagementGateway(Protocol): + """ + 插件启动期依赖的管理端口。 + """ + + async def list_plugins(self, query_db: AsyncSession) -> list[Any]: + """ + 获取数据库插件状态列表。 + + :param query_db: orm对象 + :return: 插件状态列表 + """ + + async def install_plugin_resources( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + *, + enabled: bool, + ) -> None: + """ + 在同一事务中同步单个插件的菜单、配置和任务资源。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param enabled: 插件资源是否启用 + :return: None + """ + + async def mark_plugin_error( + self, + query_db: AsyncSession, + plugin_id: str, + error_message: str, + ) -> CrudResponseModel: + """ + 标记插件运行时异常。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param error_message: 错误信息 + :return: 操作响应 + """ + + async def recover_plugin_dependency_error( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> CrudResponseModel: + """ + 恢复启动依赖检查异常的插件状态。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 操作响应 + """ + + async def upsert_discovered_plugin( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + backend_root: Path, + frontend_root: Path | None = None, + ) -> PluginModel: + """ + 写入或更新已发现插件。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :return: 插件信息 + """ + + async def mark_plugin_installed( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> PluginModel: + """ + 标记插件安装完成。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件信息 + """ + + async def get_plugin_migration( + self, + query_db: AsyncSession, + plugin_id: str, + migration_path: str, + ) -> PluginMigrationModel | None: + """ + 获取插件 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: 插件 migration 执行历史 + """ + + async def add_plugin_migration( + self, + query_db: AsyncSession, + plugin_migration: PluginMigrationModel, + ) -> PluginMigrationModel: + """ + 新增插件 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_migration: 插件 migration 执行历史 + :return: 插件 migration 执行历史 + """ + + def build_migration_record( + self, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + status: str = 'success', + error_message: str | None = None, + ) -> PluginMigrationModel: + """ + 构建插件 migration 执行历史对象。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: 插件 migration 执行历史对象 + """ + + +class UnavailablePluginStartupManagementGateway: + """ + 不可用的插件启动期管理端口。 + """ + + @staticmethod + def _raise_unavailable() -> NoReturn: + """ + 抛出启动期管理端口不可用异常。 + + :return: NoReturn + :raises RuntimeError: 默认端口不提供管理服务能力 + """ + raise RuntimeError('插件启动期缺少管理服务适配器') + + async def list_plugins(self, query_db: AsyncSession) -> list[Any]: + """ + 获取数据库插件状态列表。 + + :param query_db: orm对象 + :return: 插件状态列表 + """ + self._raise_unavailable() + + async def install_plugin_resources( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + *, + enabled: bool, + ) -> None: + """ + 同步单个插件资源。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param enabled: 插件资源是否启用 + :return: None + """ + self._raise_unavailable() + + async def mark_plugin_error( + self, + query_db: AsyncSession, + plugin_id: str, + error_message: str, + ) -> CrudResponseModel: + """ + 标记插件运行时异常。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param error_message: 错误信息 + :return: 操作响应 + """ + self._raise_unavailable() + + async def recover_plugin_dependency_error( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> CrudResponseModel: + """ + 恢复启动依赖检查异常的插件状态。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 操作响应 + """ + self._raise_unavailable() + + async def upsert_discovered_plugin( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + backend_root: Path, + frontend_root: Path | None = None, + ) -> PluginModel: + """ + 写入或更新已发现插件。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :param backend_root: 后端插件根目录 + :param frontend_root: 前端插件根目录 + :return: 插件信息 + """ + self._raise_unavailable() + + async def mark_plugin_installed( + self, + query_db: AsyncSession, + discovered_plugin: DiscoveredPlugin, + ) -> PluginModel: + """ + 标记插件安装完成。 + + :param query_db: orm对象 + :param discovered_plugin: 已发现插件对象 + :return: 插件信息 + """ + self._raise_unavailable() + + async def get_plugin_migration( + self, + query_db: AsyncSession, + plugin_id: str, + migration_path: str, + ) -> PluginMigrationModel | None: + """ + 获取插件 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :return: 插件 migration 执行历史 + """ + self._raise_unavailable() + + async def add_plugin_migration( + self, + query_db: AsyncSession, + plugin_migration: PluginMigrationModel, + ) -> PluginMigrationModel: + """ + 新增插件 migration 执行历史。 + + :param query_db: orm对象 + :param plugin_migration: 插件 migration 执行历史 + :return: 插件 migration 执行历史 + """ + self._raise_unavailable() + + def build_migration_record( + self, + plugin_id: str, + migration_path: str, + checksum: str, + version: str, + statement_count: int, + status: str = 'success', + error_message: str | None = None, + ) -> PluginMigrationModel: + """ + 构建插件 migration 执行历史对象。 + + :param plugin_id: 插件ID + :param migration_path: migration 相对路径 + :param checksum: 内容校验值 + :param version: 执行时插件版本 + :param statement_count: SQL 语句数量 + :return: 插件 migration 执行历史对象 + """ + self._raise_unavailable() diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/__init__.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/__init__.py new file mode 100644 index 0000000..ffb8eeb --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/__init__.py @@ -0,0 +1,177 @@ +from ..result import PluginOperationResult +from .batch_report import ( + BatchFailedPayload, + BatchItemReportPayload, + BatchItemRunner, + BatchOperationResultPayload, + BatchPlanPayload, + BatchSummaryPayload, + PluginBatchItemReport, + PluginBatchReportBuilder, +) +from .npm_package import PluginNpmPackageJsonSynchronizer +from .payload import ( + ActionPayload, + CommandResultPayload, + DependencyInstallPlanItemPayload, + DependencyInstallResultPayload, + DependencyItemPayload, + MenuConflictItemPayload, + PluginCatalogDatabaseStatePayloadDict, + PluginCatalogSummaryPayloadDict, + PluginCheckItemPayloadDict, + PluginCheckPayloadDict, + PluginDependencyCheckPayloadDict, + PluginDependencyItemPayload, + PluginManifestConfigItemPayload, + PluginManifestJobItemPayload, + PluginMenuDiagnosticPlanItemPayload, + PluginMenuDiagnosticPlanPayload, + PluginNotFoundPayloadDict, + PluginPayloadBuilder, + PluginPlanBlockerPayload, + PluginPlanItemPayload, + PluginPlanPayloadDict, + PluginPlanResponsePayload, + PluginValidationPayloadBuilderProtocol, + PurgePlanItemPayload, + PurgePlanPayload, + StructureItemPayload, + UpgradeDryRunPayloadContext, + UpgradeDryRunPayloadDict, + ValidationIssuePayload, + VersionStatePayload, +) +from .payload.audit import ( + PluginAuditItemPayloadDict, + PluginAuditPayloadBuilder, + PluginAuditSnapshotFailurePayloadDict, + PluginAuditSnapshotPayloadDict, +) +from .payload.config import ( + PluginConfigAuditChangePayload, + PluginConfigAuditPayloadDict, + PluginConfigAuditSummaryPayload, + PluginConfigDiagnosticSummaryPayloadDict, + PluginConfigExportFailurePayloadDict, + PluginConfigExportPayloadDict, + PluginConfigImportPayloadDict, + PluginConfigPayloadBuilder, + PluginConfigStatePayloadDict, +) +from .payload.dependencies import ( + DependencyInstallReturnCodePayload, + PluginDependencyInstallPayloadBuilder, + PluginDependencyInstallPayloadDict, +) +from .payload.documentation import PluginDocumentationBuilder, PluginDocumentationPayloadDict +from .payload.enable import ( + PluginEnableDependencyPayloadDict, + PluginEnablePayloadBuilder, + PluginEnableStatePayloadDict, + PluginEnableUpdateFailurePayloadDict, + PluginSafeUninstallPayloadDict, +) +from .payload.lifecycle import ( + PluginLifecyclePayloadBuilder, + PluginLifecyclePayloadDict, + PluginLifecyclePrecheckProtocol, +) +from .payload.purge import PluginPurgePayloadBuilder, PluginPurgeStatePayloadDict +from .payload.runtime import ( + PluginRuntimeBatchItemUnsupportedPayloadDict, + PluginRuntimeDiagnoseFailurePayloadDict, + PluginRuntimeDiagnosePayloadDict, + PluginRuntimeExceptionPayloadDict, + PluginRuntimeHealthPayloadDict, + PluginRuntimeHealthResponsePayloadDict, + PluginRuntimeInvalidOperationPayloadDict, + PluginRuntimePayloadBuilder, + PluginRuntimePrecheckPayloadDict, + PluginRuntimePrecheckProtocol, + PluginRuntimeUpgradeBlockerPayloadDict, +) +from .precheck import PluginPrecheckContext + +__all__ = [ + 'ActionPayload', + 'BatchFailedPayload', + 'BatchItemReportPayload', + 'BatchItemRunner', + 'BatchOperationResultPayload', + 'BatchPlanPayload', + 'BatchSummaryPayload', + 'CommandResultPayload', + 'DependencyInstallPlanItemPayload', + 'DependencyInstallResultPayload', + 'DependencyInstallReturnCodePayload', + 'DependencyItemPayload', + 'MenuConflictItemPayload', + 'PluginAuditItemPayloadDict', + 'PluginAuditPayloadBuilder', + 'PluginAuditSnapshotFailurePayloadDict', + 'PluginAuditSnapshotPayloadDict', + 'PluginBatchItemReport', + 'PluginBatchReportBuilder', + 'PluginCatalogDatabaseStatePayloadDict', + 'PluginCatalogSummaryPayloadDict', + 'PluginCheckItemPayloadDict', + 'PluginCheckPayloadDict', + 'PluginConfigAuditChangePayload', + 'PluginConfigAuditPayloadDict', + 'PluginConfigAuditSummaryPayload', + 'PluginConfigDiagnosticSummaryPayloadDict', + 'PluginConfigExportFailurePayloadDict', + 'PluginConfigExportPayloadDict', + 'PluginConfigImportPayloadDict', + 'PluginConfigPayloadBuilder', + 'PluginConfigStatePayloadDict', + 'PluginDependencyCheckPayloadDict', + 'PluginDependencyInstallPayloadBuilder', + 'PluginDependencyInstallPayloadDict', + 'PluginDependencyItemPayload', + 'PluginDocumentationBuilder', + 'PluginDocumentationPayloadDict', + 'PluginEnableDependencyPayloadDict', + 'PluginEnablePayloadBuilder', + 'PluginEnableStatePayloadDict', + 'PluginEnableUpdateFailurePayloadDict', + 'PluginLifecyclePayloadBuilder', + 'PluginLifecyclePayloadDict', + 'PluginLifecyclePrecheckProtocol', + 'PluginManifestConfigItemPayload', + 'PluginManifestJobItemPayload', + 'PluginMenuDiagnosticPlanItemPayload', + 'PluginMenuDiagnosticPlanPayload', + 'PluginNotFoundPayloadDict', + 'PluginNpmPackageJsonSynchronizer', + 'PluginOperationResult', + 'PluginPayloadBuilder', + 'PluginPlanBlockerPayload', + 'PluginPlanItemPayload', + 'PluginPlanPayloadDict', + 'PluginPlanResponsePayload', + 'PluginPrecheckContext', + 'PluginPurgePayloadBuilder', + 'PluginPurgeStatePayloadDict', + 'PluginRuntimeBatchItemUnsupportedPayloadDict', + 'PluginRuntimeDiagnoseFailurePayloadDict', + 'PluginRuntimeDiagnosePayloadDict', + 'PluginRuntimeExceptionPayloadDict', + 'PluginRuntimeHealthPayloadDict', + 'PluginRuntimeHealthResponsePayloadDict', + 'PluginRuntimeInvalidOperationPayloadDict', + 'PluginRuntimePayloadBuilder', + 'PluginRuntimePrecheckPayloadDict', + 'PluginRuntimePrecheckProtocol', + 'PluginRuntimeUpgradeBlockerPayloadDict', + 'PluginSafeUninstallPayloadDict', + 'PluginValidationPayloadBuilderProtocol', + 'PurgePlanItemPayload', + 'PurgePlanPayload', + 'StructureItemPayload', + 'UpgradeDryRunPayloadContext', + 'UpgradeDryRunPayloadDict', + 'ValidationIssuePayload', + 'VersionStatePayload', +] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/batch_report.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/batch_report.py new file mode 100644 index 0000000..36cece3 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/batch_report.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from time import perf_counter +from typing import TypeAlias + +from pydantic import Field + +from plugins.core.runtime.support.payload.base import PluginPayloadModel +from plugins.core.validation.plugin_deps import PluginBatchOperation + + +class BatchOperationResult(PluginPayloadModel): + """ + 批量单项运行时结果 payload。 + """ + + ok: bool | None = None + message: str | None = None + plugin_dependency_errors: object | None = Field(default=None, alias='pluginDependencyErrors') + structure_errors: object | None = Field(default=None, alias='structureErrors') + menu_conflicts: object | None = Field(default=None, alias='menuConflicts') + error: object | None = None + + +class BatchSummary(PluginPayloadModel): + """ + 批量执行汇总 payload。 + """ + + total: int + succeeded: int + failed: int + skipped: int + + +@dataclass(frozen=True) +class PluginBatchItemReport: + """ + 插件批量执行单项报告。 + + :param plugin_id: 插件ID + :param operation: 批量操作类型 + :param ok: 是否执行成功 + :param status: 执行状态 + :param message: 执行消息 + :param duration_ms: 耗时毫秒数 + :param suggestion: 失败建议 + """ + + plugin_id: str + operation: PluginBatchOperation + ok: bool + status: str + message: str + duration_ms: int + suggestion: str + + +class BatchFailedItem(PluginPayloadModel): + """ + 批量失败项 payload。 + """ + + plugin_id: str = Field(alias='pluginId') + operation: str | None = None + ok: bool | None = None + status: str | None = None + message: str | None = None + duration_ms: int | None = Field(default=None, alias='durationMs') + suggestion: str | None = None + result: dict[str, object] + + +class PluginBatchRunPayload(PluginPayloadModel): + """ + 插件批量执行响应 payload。 + """ + + ok: bool | None = None + message: str + operation: str | None = None + database_available: bool | None = Field(default=None, alias='databaseAvailable') + database_error: str | None = Field(default=None, alias='databaseError') + plan: dict[str, object] | None = None + dry_run: bool = Field(alias='dryRun') + continue_on_error: bool = Field(alias='continueOnError') + executed: list[dict[str, object]] + failed: dict[str, object] | None + summary: dict[str, object] + + +BatchOperationResultPayload: TypeAlias = dict[str, object] +BatchSummaryPayload: TypeAlias = dict[str, object] +BatchItemReportPayload: TypeAlias = dict[str, object] +BatchFailedPayload: TypeAlias = dict[str, object] +BatchPlanPayload: TypeAlias = Mapping[str, object] +BatchItemRunner: TypeAlias = Callable[[PluginBatchOperation, str], Awaitable[BatchOperationResultPayload]] + + +class PluginBatchReportBuilder: + """ + 插件批量执行报告构建器。 + + 使用 Builder 模式统一生成批量执行单项报告和汇总信息。 + """ + + @classmethod + async def run_item( + cls, + operation: PluginBatchOperation, + plugin_id: str, + runner: BatchItemRunner, + ) -> tuple[PluginBatchItemReport, BatchOperationResultPayload]: + """ + 执行单个插件批量操作并构建报告。 + + :param operation: 批量操作类型 + :param plugin_id: 插件ID + :param runner: 异步执行函数 + :return: 单项报告和原始执行结果 + """ + started_at = perf_counter() + result = await runner(operation, plugin_id) + duration_ms = int((perf_counter() - started_at) * 1000) + report = cls.build_item_report(operation, plugin_id, result, duration_ms) + + return report, result + + @classmethod + def build_item_report( + cls, + operation: PluginBatchOperation, + plugin_id: str, + result: BatchOperationResultPayload, + duration_ms: int, + ) -> PluginBatchItemReport: + """ + 构建单个插件批量操作报告。 + + :param operation: 批量操作类型 + :param plugin_id: 插件ID + :param result: 原始执行结果 + :param duration_ms: 耗时毫秒数 + :return: 单项报告 + """ + ok = bool(result.get('ok', False)) + + return PluginBatchItemReport( + plugin_id=plugin_id, + operation=operation, + ok=ok, + status='success' if ok else 'failed', + message=str(result.get('message', '-')), + duration_ms=duration_ms, + suggestion='' if ok else cls.build_failure_suggestion(operation, plugin_id, result), + ) + + @staticmethod + def build_summary(reports: list[PluginBatchItemReport], total: int) -> BatchSummaryPayload: + """ + 构建插件批量执行汇总。 + + :param reports: 单项报告列表 + :param total: 计划执行总数 + :return: 汇总负载 + """ + failed = len([report for report in reports if not report.ok]) + succeeded = len([report for report in reports if report.ok]) + + return BatchSummary( + total=total, + succeeded=succeeded, + failed=failed, + skipped=max(total - succeeded - failed, 0), + ).to_payload() + + @staticmethod + def resolve_executable_plugin_ids(plan_payload: BatchPlanPayload) -> list[str]: + """ + 从批量计划负载中解析实际执行插件 ID。 + + 计划会展示目标插件的依赖闭包,但执行阶段只执行用户显式请求的插件,避免依赖插件状态影响 + 用户本次选择的插件安装或升级。 + + :param plan_payload: 插件批量计划负载 + :return: 实际执行插件 ID 列表 + """ + plan = plan_payload.get('plan', {}) + if not isinstance(plan, Mapping): + return [] + requested_plugin_ids = plan.get('requestedPluginIds') + ordered_plugin_ids = plan.get('orderedPluginIds') + if not isinstance(requested_plugin_ids, list) or not requested_plugin_ids: + return ordered_plugin_ids if isinstance(ordered_plugin_ids, list) else [] + if not isinstance(ordered_plugin_ids, list): + return requested_plugin_ids + + requested_plugin_id_set = set(requested_plugin_ids) + return [plugin_id for plugin_id in ordered_plugin_ids if plugin_id in requested_plugin_id_set] + + @classmethod + def build_plan_blocked_payload( + cls, + plan_payload: BatchPlanPayload, + *, + dry_run: bool, + continue_on_error: bool, + ) -> dict[str, object]: + """ + 构建插件批量计划阻断负载。 + + :param plan_payload: 插件批量计划负载 + :param dry_run: 是否预演 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: 插件批量计划阻断负载 + """ + total = cls._count_planned_items(plan_payload) + return PluginBatchRunPayload.model_validate( + { + **plan_payload, + 'dryRun': dry_run, + 'continueOnError': continue_on_error, + 'executed': [], + 'failed': None, + 'summary': cls.build_summary([], total), + 'message': '插件批量操作计划存在阻塞项,未执行任何写操作', + } + ).to_payload() + + @classmethod + def build_dry_run_payload( + cls, + plan_payload: BatchPlanPayload, + *, + continue_on_error: bool, + ) -> dict[str, object]: + """ + 构建插件批量执行预演负载。 + + :param plan_payload: 插件批量计划负载 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: 插件批量执行预演负载 + """ + total = cls._count_planned_items(plan_payload) + return PluginBatchRunPayload.model_validate( + { + **plan_payload, + 'message': '插件批量操作演练完成,未执行实际写入', + 'dryRun': True, + 'continueOnError': continue_on_error, + 'executed': [], + 'failed': None, + 'summary': cls.build_summary([], total), + } + ).to_payload() + + @classmethod + def build_failed_payload( + cls, + report: PluginBatchItemReport, + result: BatchOperationResultPayload, + ) -> BatchFailedPayload: + """ + 构建插件批量执行失败项负载。 + + :param report: 单项报告 + :param result: 单项原始执行结果 + :return: 失败项负载 + """ + return BatchFailedItem.model_validate({**cls.dump_item_report(report), 'result': result}).to_payload( + exclude_none=True + ) + + @classmethod + def build_execution_payload( + cls, + plan_payload: BatchPlanPayload, + reports: list[PluginBatchItemReport], + failed: BatchFailedPayload | None, + *, + continue_on_error: bool, + ) -> dict[str, object]: + """ + 构建插件批量执行结果负载。 + + :param plan_payload: 插件批量计划负载 + :param reports: 单项执行报告列表 + :param failed: 首个失败项负载 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: 插件批量执行结果负载 + """ + ok = failed is None + return PluginBatchRunPayload.model_validate( + { + **plan_payload, + 'ok': ok, + 'message': cls.build_batch_message(ok, continue_on_error), + 'dryRun': False, + 'continueOnError': continue_on_error, + 'executed': [cls.dump_item_report(report) for report in reports], + 'failed': failed, + 'summary': cls.build_summary(reports, cls._count_planned_items(plan_payload)), + } + ).to_payload() + + @staticmethod + def build_batch_message(ok: bool, continue_on_error: bool) -> str: + """ + 构建批量执行结果消息。 + + :param ok: 批量执行是否全部成功 + :param continue_on_error: 失败后是否继续执行后续插件 + :return: 批量执行结果消息 + """ + if ok: + return '插件批量操作完成' + if continue_on_error: + return '插件批量操作完成,存在失败项' + + return '插件批量操作中止' + + @staticmethod + def dump_item_report(report: PluginBatchItemReport) -> BatchItemReportPayload: + """ + 转换单项报告为插件运行时负载。 + + :param report: 单项报告 + :return: 单项报告负载 + """ + return { + 'pluginId': report.plugin_id, + 'operation': report.operation, + 'ok': report.ok, + 'status': report.status, + 'message': report.message, + 'durationMs': report.duration_ms, + 'suggestion': report.suggestion, + } + + @staticmethod + def build_failure_suggestion( + operation: PluginBatchOperation, + plugin_id: str, + result: BatchOperationResultPayload, + ) -> str: + """ + 构建插件批量执行失败建议。 + + :param operation: 批量操作类型 + :param plugin_id: 插件ID + :param result: 原始执行结果 + :return: 失败建议 + """ + if result.get('pluginDependencyErrors'): + return f'先执行 ruoyi plugin plan {operation} {plugin_id} 查看插件依赖阻塞项' + if result.get('structureErrors'): + return f'先执行 ruoyi plugin check {plugin_id} 修复插件目录结构问题' + if result.get('menuConflicts'): + return f'先执行 ruoyi plugin check {plugin_id} 查看菜单或权限冲突' + if result.get('error'): + return '查看错误详情并修复后重新执行批量命令' + + return f'先单独执行 ruoyi plugin {operation} {plugin_id} --dry-run 定位失败原因' + + @staticmethod + def _count_planned_items(plan_payload: BatchPlanPayload) -> int: + """ + 统计插件批量计划项数量。 + + :param plan_payload: 插件批量计划负载 + :return: 计划项数量 + """ + plan = plan_payload.get('plan', {}) + if not isinstance(plan, Mapping): + return 0 + requested_plugin_ids = plan.get('requestedPluginIds') + if isinstance(requested_plugin_ids, list) and requested_plugin_ids: + return len(requested_plugin_ids) + + ordered_plugin_ids = plan.get('orderedPluginIds') + return len(ordered_plugin_ids) if isinstance(ordered_plugin_ids, list) else 0 diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/npm_package.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/npm_package.py new file mode 100644 index 0000000..a4c5fb4 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/npm_package.py @@ -0,0 +1,67 @@ +import json +from pathlib import Path + +from plugins.core.validation.dependencies import DependencyInstallPlanItem, DependencyRequirementParser + +from .payload.dependencies import DependencyInstallReturnCodePayload + + +class PluginNpmPackageJsonSynchronizer: + """ + 插件 npm 依赖声明同步器。 + + npm install 可能会把 package.json 写成 npm 自己解析后的版本形式,这里按 plugin.yaml + 中声明的约束回写根 package.json,确保依赖声明与插件 manifest 保持一致。 + """ + + @classmethod + def sync_successful_items( + cls, + install_plan_items: list[DependencyInstallPlanItem], + install_results: list[DependencyInstallReturnCodePayload], + ) -> None: + """ + 同步安装成功的 npm 依赖声明。 + + :param install_plan_items: 依赖安装计划项 + :param install_results: 依赖安装执行结果 + :return: None + """ + for item, result in zip(install_plan_items, install_results, strict=False): + if item.kind not in {'npm', 'npmDev'} or result['returnCode'] != 0: + continue + cls.sync_item(item) + + @staticmethod + def sync_item(item: DependencyInstallPlanItem) -> None: + """ + 同步单个 npm 依赖版本声明。 + + :param item: 依赖安装计划项 + :return: None + """ + package_json_path = Path(item.workdir) / 'package.json' + if not package_json_path.is_file(): + return + + package_json = json.loads(package_json_path.read_text(encoding='utf-8')) + dependency_field = 'devDependencies' if item.kind == 'npmDev' else 'dependencies' + dependencies = package_json.setdefault(dependency_field, {}) + parsed_dependency = DependencyRequirementParser.parse(item.requirement) + version = parsed_dependency.required_version + if version: + if version.startswith('=='): + version = version[2:] + elif version.startswith('='): + version = version[1:] + dependencies[parsed_dependency.name] = version + else: + dependencies.setdefault(parsed_dependency.name, '*') + + package_json_path.write_text( + json.dumps(package_json, ensure_ascii=False, indent=2) + '\n', + encoding='utf-8', + ) + + +__all__ = ['PluginNpmPackageJsonSynchronizer'] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/__init__.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/__init__.py new file mode 100644 index 0000000..da71d90 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/__init__.py @@ -0,0 +1,106 @@ +from .catalog import ( + PluginCatalogDatabaseStatePayload, + PluginCatalogDatabaseStatePayloadDict, + PluginCatalogPayloadMixin, + PluginCatalogSummaryPayload, + PluginCatalogSummaryPayloadDict, + PluginManifestConfigItemPayload, + PluginManifestJobItemPayload, + PluginMenuDiagnosticPlanItemPayload, + PluginMenuDiagnosticPlanPayload, +) +from .common import PluginCommonPayloadMixin, PluginNotFoundPayload, PluginNotFoundPayloadDict +from .dependencies import ( + DependencyInstallReturnCodePayload, + PluginDependencyInstallPayloadBuilder, + PluginDependencyInstallPayloadDict, +) +from .plan import ( + ActionPayload, + CommandResultPayload, + DependencyInstallPlanItemPayload, + DependencyInstallResultPayload, + PluginPlanBlockerPayload, + PluginPlanItemPayload, + PluginPlanPayload, + PluginPlanPayloadDict, + PluginPlanPayloadMixin, + PluginPlanResponsePayload, + PurgePlanItemPayload, + PurgePlanPayload, + UpgradeDryRunPayload, + UpgradeDryRunPayloadContext, + UpgradeDryRunPayloadDict, + VersionStatePayload, +) +from .validation import ( + DependencyItemPayload, + MenuConflictItemPayload, + PluginCheckItemPayloadDict, + PluginCheckPayload, + PluginCheckPayloadDict, + PluginDependencyCheckPayload, + PluginDependencyCheckPayloadDict, + PluginDependencyItemPayload, + PluginValidationPayloadBuilderProtocol, + PluginValidationPayloadMixin, + StructureItemPayload, + ValidationIssuePayload, +) + + +class PluginPayloadBuilder( + PluginCatalogPayloadMixin, + PluginValidationPayloadMixin, + PluginPlanPayloadMixin, + PluginCommonPayloadMixin, +): + """ + 插件运行时负载构建器。 + + 使用 mixin 组合插件目录、校验、计划和生命周期动作等纯负载构建能力。 + """ + + +__all__ = [ + 'ActionPayload', + 'CommandResultPayload', + 'DependencyInstallPlanItemPayload', + 'DependencyInstallResultPayload', + 'DependencyInstallReturnCodePayload', + 'DependencyItemPayload', + 'MenuConflictItemPayload', + 'PluginCatalogDatabaseStatePayload', + 'PluginCatalogDatabaseStatePayloadDict', + 'PluginCatalogSummaryPayload', + 'PluginCatalogSummaryPayloadDict', + 'PluginCheckItemPayloadDict', + 'PluginCheckPayload', + 'PluginCheckPayloadDict', + 'PluginDependencyCheckPayload', + 'PluginDependencyCheckPayloadDict', + 'PluginDependencyInstallPayloadBuilder', + 'PluginDependencyInstallPayloadDict', + 'PluginDependencyItemPayload', + 'PluginManifestConfigItemPayload', + 'PluginManifestJobItemPayload', + 'PluginMenuDiagnosticPlanItemPayload', + 'PluginMenuDiagnosticPlanPayload', + 'PluginNotFoundPayload', + 'PluginNotFoundPayloadDict', + 'PluginPayloadBuilder', + 'PluginPlanBlockerPayload', + 'PluginPlanItemPayload', + 'PluginPlanPayload', + 'PluginPlanPayloadDict', + 'PluginPlanResponsePayload', + 'PluginValidationPayloadBuilderProtocol', + 'PurgePlanItemPayload', + 'PurgePlanPayload', + 'StructureItemPayload', + 'UpgradeDryRunPayload', + 'UpgradeDryRunPayloadContext', + 'UpgradeDryRunPayloadDict', + 'ValidationIssuePayload', + 'VersionStatePayload', +] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/audit.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/audit.py new file mode 100644 index 0000000..74b4fd1 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/audit.py @@ -0,0 +1,132 @@ +from collections.abc import Mapping +from typing import Protocol, TypeAlias, cast + +from pydantic import Field + +from .base import PluginPayloadModel + + +class PluginAuditItemPayload(PluginPayloadModel): + """ + 插件审计单项 payload。 + """ + + operation_id: object = Field(alias='operationId') + operation: object + plugin_ids: list[object] = Field(alias='pluginIds') + dry_run: bool = Field(alias='dryRun') + continue_on_error: bool = Field(alias='continueOnError') + status: object + summary: object + create_time: object = Field(alias='createTime') + remark: object + + +class PluginAuditSnapshotPayload(PluginPayloadModel): + """ + 插件最近审计快照 payload。 + """ + + available: bool + count: int + items: list[Mapping[str, object]] + + +class PluginAuditSnapshotFailurePayload(PluginPayloadModel): + """ + 插件最近审计快照读取失败 payload。 + """ + + available: bool + message: str + items: list[Mapping[str, object]] + + +PluginAuditItemPayloadDict: TypeAlias = dict[str, object] +PluginAuditSnapshotPayloadDict: TypeAlias = dict[str, object] +PluginAuditSnapshotFailurePayloadDict: TypeAlias = dict[str, object] + + +class SupportsAuditModelDump(Protocol): + """ + 支持审计记录别名序列化的对象协议。 + """ + + def model_dump(self, *, by_alias: bool = False) -> Mapping[str, object]: + """ + 序列化审计记录。 + + :param by_alias: 是否使用字段别名 + :return: 审计记录 payload + """ + ... + + +class PluginAuditPayloadBuilder: + """ + 插件审计负载构建器。 + """ + + @staticmethod + def build_recent_snapshot_failure(error: Exception) -> PluginAuditSnapshotFailurePayloadDict: + """ + 构建最近审计快照读取失败负载。 + + :param error: 异常对象 + :return: 最近审计快照读取失败负载 + """ + return PluginAuditSnapshotFailurePayload( + available=False, + message=f'最近审计快照读取失败:{error}', + items=[], + ).to_payload() + + @classmethod + def build_recent_snapshot_payload( + cls, + plugin_id: str, + operation_logs: list[object], + *, + audit_limit: int, + ) -> PluginAuditSnapshotPayloadDict: + """ + 构建最近审计快照负载。 + + :param plugin_id: 插件ID + :param operation_logs: 审计记录列表 + :param audit_limit: 最近审计记录数量 + :return: 最近审计快照负载 + """ + recent_logs = [ + operation_log for operation_log in operation_logs if plugin_id in getattr(operation_log, 'plugin_ids', []) + ][:audit_limit] + return PluginAuditSnapshotPayload( + available=True, + count=len(recent_logs), + items=[ + cast('SupportsAuditModelDump', operation_log).model_dump(by_alias=True) + if hasattr(operation_log, 'model_dump') + else cls.build_item_payload(operation_log) + for operation_log in recent_logs + ], + ).to_payload() + + @staticmethod + def build_item_payload(operation_log: object) -> PluginAuditItemPayloadDict: + """ + 构建审计记录负载。 + + :param operation_log: 审计记录对象 + :return: 审计记录负载 + """ + return PluginAuditItemPayload( + operation_id=getattr(operation_log, 'operation_id', None), + operation=getattr(operation_log, 'operation', '-'), + plugin_ids=list(cast('list[object]', getattr(operation_log, 'plugin_ids', []))), + dry_run=bool(getattr(operation_log, 'dry_run', False)), + continue_on_error=bool(getattr(operation_log, 'continue_on_error', False)), + status=getattr(operation_log, 'status', '-'), + summary=getattr(operation_log, 'summary', {}), + create_time=getattr(operation_log, 'create_time', None), + remark=getattr(operation_log, 'remark', None), + ).to_payload() diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/base.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/base.py new file mode 100644 index 0000000..4c27d80 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/base.py @@ -0,0 +1,23 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class PluginPayloadModel(BaseModel): + """ + 插件运行时 payload 模型基类。 + """ + + model_config = ConfigDict(populate_by_name=True, extra='forbid') + + def to_payload(self, *, exclude_none: bool = False) -> dict[str, Any]: + """ + 序列化为现有运行时 dict payload 契约。 + + :param exclude_none: 是否排除 None 字段 + :return: payload 字典 + """ + return self.model_dump(by_alias=True, exclude_none=exclude_none) + + +__all__ = ['PluginPayloadModel'] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/catalog.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/catalog.py new file mode 100644 index 0000000..5db4b15 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/catalog.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeAlias + +from pydantic import Field + +from plugins.core.discovery.registry import RegisteredPlugin +from plugins.core.manifest.menu_tree import PluginMenuTree + +from .base import PluginPayloadModel + +if TYPE_CHECKING: + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.manifest.schema import ( + PluginConfigItemManifest, + PluginJobManifest, + PluginMenuManifest, + PluginPermissionManifest, + ) + from plugins.core.types import PluginStateRecord, SupportsToPayload + from plugins.core.validation.dependencies import DependencyCheckItem + + +class PluginCatalogSummaryPayload(PluginPayloadModel): + """ + 插件目录摘要 payload。 + """ + + plugin_id: str = Field(alias='pluginId') + name: str + version: str + enabled: bool + status: str + description: str + backend_path: str = Field(alias='backendPath') + menu_count: int = Field(alias='menuCount') + permission_count: int = Field(alias='permissionCount') + capability: dict[str, object] | None + + +class PluginCatalogDatabaseStatePayload(PluginPayloadModel): + """ + 插件目录数据库状态 payload。 + """ + + available: bool + installed: bool + error: str | None = None + installed_version: str | None = Field(default=None, alias='installedVersion') + enabled: str | None + status: str | None + last_error: str | None = Field(default=None, alias='lastError') + + +class PluginManifestConfigItemPayload(PluginPayloadModel): + """ + manifest 配置声明 payload。 + """ + + key: str + label: str | None + type: str + default: object + required: bool + secret: bool + group: str + order: int + placeholder: str + min_value: float | None = Field(alias='min') + max_value: float | None = Field(alias='max') + pattern: str | None + description: str + options: list[dict[str, object]] + + +class PluginManifestPermissionItemPayload(PluginPayloadModel): + """ + manifest 权限声明 payload。 + """ + + code: str + name: str | None + description: str + + +class PluginMenuDiagnosticPlanItemPayload(PluginPayloadModel): + """ + 菜单诊断计划项 payload。 + """ + + name: str + path: str + component: str + perms: str + type: str + query: str | None + route_name: str | None = Field(alias='routeName') + is_frame: int = Field(alias='isFrame') + is_cache: int = Field(alias='isCache') + visible: str + status: str + children: int + + +class PluginMenuDiagnosticPlanPayload(PluginPayloadModel): + """ + 菜单诊断计划 payload。 + """ + + total: int + permission_count: int = Field(alias='permissionCount') + enabled_count: int = Field(alias='enabledCount') + visible_count: int = Field(alias='visibleCount') + items: list[dict[str, object]] + + +class PluginManifestJobItemPayload(PluginPayloadModel): + """ + manifest 定时任务声明 payload。 + """ + + id: str + name: str | None + callable: str + trigger: str + cron_expression: str = Field(alias='cronExpression') + args: list[str] + kwargs: dict[str, object] + enabled: bool + description: str + misfire_policy: str = Field(alias='misfirePolicy') + concurrent: str + executor: str + + +PluginCatalogSummaryPayloadDict: TypeAlias = dict[str, object] +PluginCatalogDatabaseStatePayloadDict: TypeAlias = dict[str, object] + + +class PluginCatalogPayloadMixin: + """ + 插件目录、详情和 manifest 基础信息负载构建能力。 + """ + + @classmethod + def build_plugin_list_payload(cls, plugins: list[RegisteredPlugin]) -> dict[str, object]: + """ + 构建插件列表负载。 + + :param plugins: 已注册插件列表 + :return: 插件列表负载 + """ + plugin_items = [ + cls.build_plugin_summary(plugin.discovered_plugin, plugin.enabled, plugin.status) for plugin in plugins + ] + return {'ok': True, 'count': len(plugin_items), 'plugins': plugin_items} + + @classmethod + def build_plugin_info_payload( + cls, + plugin: RegisteredPlugin | DiscoveredPlugin, + dependency_items: list[DependencyCheckItem], + *, + database_error: str | None = None, + capability: SupportsToPayload | None = None, + ) -> dict[str, object]: + """ + 构建插件详情响应负载。 + + :param plugin: 已注册插件运行时快照或已发现插件 + :param dependency_items: 依赖检查项列表 + :param database_error: 数据库状态读取错误信息 + :return: 插件详情响应负载 + """ + return { + 'ok': True, + 'plugin': cls.build_plugin_detail( + plugin, + dependency_items, + database_error=database_error, + capability=capability, + ), + } + + @classmethod + def build_plugin_summary( + cls, + plugin: DiscoveredPlugin, + enabled: bool, + status: str, + capability: SupportsToPayload | None = None, + ) -> PluginCatalogSummaryPayloadDict: + """ + 构建插件摘要负载。 + + :param plugin: 已发现插件 + :param enabled: 是否启用 + :param status: 插件状态 + :return: 插件摘要负载 + """ + manifest = plugin.manifest + return PluginCatalogSummaryPayload( + plugin_id=manifest.id, + name=manifest.name, + version=manifest.version, + enabled=enabled, + status=status, + description=manifest.description, + backend_path=str(plugin.backend_path), + menu_count=PluginMenuTree.count(manifest.frontend.menus), + permission_count=len(manifest.permissions), + capability=capability.to_payload() if capability else None, + ).to_payload() + + @classmethod + def build_plugin_detail( + cls, + plugin: RegisteredPlugin | DiscoveredPlugin, + dependency_items: list[DependencyCheckItem], + *, + database_error: str | None = None, + capability: SupportsToPayload | None = None, + ) -> dict[str, object]: + """ + 构建插件详情负载。 + + :param plugin: 已注册插件运行时快照或已发现插件 + :param dependency_items: 依赖检查项列表 + :param database_error: 数据库状态读取错误信息 + :return: 插件详情负载 + """ + discovered_plugin = plugin.discovered_plugin if isinstance(plugin, RegisteredPlugin) else plugin + database_plugin = plugin.database_plugin if isinstance(plugin, RegisteredPlugin) else None + enabled = plugin.enabled if isinstance(plugin, RegisteredPlugin) else False + status = plugin.status if isinstance(plugin, RegisteredPlugin) else 'discovered' + manifest = discovered_plugin.manifest + installed_version = getattr(database_plugin, 'installed_version', None) + last_error = getattr(database_plugin, 'last_error', None) + source = getattr(database_plugin, 'source', None) or 'local' + frontend_path = getattr(database_plugin, 'frontend_path', None) + + return { + **cls.build_plugin_summary(discovered_plugin, enabled, status, capability=capability), + 'installedVersion': installed_version, + 'source': source, + 'lastError': last_error, + 'frontendPath': frontend_path, + 'database': cls.build_database_state(database_plugin, database_error), + 'backend': { + 'module': manifest.backend.module, + 'autoScanRouters': manifest.backend.routers.auto_scan, + 'migrations': manifest.backend.migrations, + 'seeds': manifest.backend.seeds, + 'jobs': [cls.build_manifest_job_item(job) for job in manifest.backend.jobs], + }, + 'frontend': { + 'pluginId': manifest.frontend.plugin_id, + 'basePath': manifest.frontend.base_path, + 'viewsPath': manifest.frontend.views_path, + 'apiPath': manifest.frontend.api_path, + 'delivery': { + 'type': manifest.frontend.delivery.type, + 'buildRequired': manifest.frontend.delivery.build_required, + }, + }, + 'permissions': cls.build_manifest_permission_items(manifest.permissions), + 'config': cls.build_manifest_config_items(manifest.config.items), + 'pluginDependencies': [ + { + 'id': dependency.id, + 'version': dependency.version, + 'description': dependency.description, + } + for dependency in manifest.dependencies.plugins + ], + 'dependencies': [cls.build_dependency_item(item) for item in dependency_items], + } + + @staticmethod + def build_manifest_permission_items( + permissions: list[PluginPermissionManifest], + ) -> list[dict[str, object]]: + """ + 构建 manifest 权限声明 payload。 + + :param permissions: 权限声明列表 + :return: 权限声明 payload 列表 + """ + return [ + PluginManifestPermissionItemPayload( + code=permission.code, + name=permission.name, + description=permission.description, + ).to_payload() + for permission in permissions + ] + + @staticmethod + def build_manifest_config_items( + config_items: list[PluginConfigItemManifest], + ) -> list[dict[str, object]]: + """ + 构建 manifest 配置声明负载。 + + :param config_items: manifest 配置项声明列表 + :return: 配置声明负载列表 + """ + return [ + PluginManifestConfigItemPayload( + key=item.key, + label=item.label, + type=item.type, + default=item.default, + required=item.required, + secret=item.secret, + group=item.group, + order=item.order, + placeholder=item.placeholder, + min_value=item.min_value, + max_value=item.max_value, + pattern=item.pattern, + description=item.description, + options=[option.model_dump() for option in item.options], + ).to_payload() + for item in config_items + ] + + @classmethod + def build_menu_diagnostic_plan(cls, discovered_plugin: DiscoveredPlugin) -> dict[str, object]: + """ + 构建插件菜单诊断计划。 + + :param discovered_plugin: 已发现插件 + :return: 菜单诊断计划负载 + """ + menus = PluginMenuTree.flatten(discovered_plugin.manifest.frontend.menus) + permission_menus = [menu for menu in menus if menu.perms] + enabled_menus = [menu for menu in menus if menu.status == '0'] + visible_menus = [menu for menu in menus if menu.visible == '0'] + + return PluginMenuDiagnosticPlanPayload( + total=len(menus), + permission_count=len(permission_menus), + enabled_count=len(enabled_menus), + visible_count=len(visible_menus), + items=[cls._build_menu_plan_item(menu) for menu in menus], + ).to_payload() + + @classmethod + def _build_menu_plan_item(cls, menu: PluginMenuManifest) -> dict[str, object]: + """ + 构建菜单诊断计划项。 + + :param menu: 插件菜单声明 + :return: 菜单诊断计划项 + """ + return PluginMenuDiagnosticPlanItemPayload( + name=menu.name, + path=menu.path, + component=menu.component, + perms=menu.perms, + type=menu.type, + query=menu.query, + routeName=menu.route_name, + isFrame=menu.is_frame, + isCache=menu.is_cache, + visible=menu.visible, + status=menu.status, + children=len(menu.children), + ).to_payload() + + @staticmethod + def build_manifest_job_item(job: PluginJobManifest) -> dict[str, object]: + """ + 构建 manifest 定时任务声明负载。 + + :param job: manifest 定时任务声明 + :return: 定时任务声明负载 + """ + return PluginManifestJobItemPayload( + id=job.id, + name=job.name, + callable=job.callable, + trigger=job.trigger, + cron_expression=job.cron_expression, + args=job.args, + kwargs=job.kwargs, + enabled=job.enabled, + description=job.description, + misfire_policy=job.misfire_policy, + concurrent=job.concurrent, + executor=job.executor, + ).to_payload() + + @staticmethod + def build_database_state( + database_plugin: PluginStateRecord | None, + database_error: str | None = None, + ) -> PluginCatalogDatabaseStatePayloadDict: + """ + 构建插件数据库状态负载。 + + :param database_plugin: 数据库插件状态对象 + :param database_error: 数据库状态读取错误信息 + :return: 数据库状态负载 + """ + if database_error: + return PluginCatalogDatabaseStatePayload( + available=False, + installed=False, + error=database_error, + enabled=None, + status=None, + ).to_payload(exclude_none=True) + + return PluginCatalogDatabaseStatePayload( + available=True, + installed=database_plugin is not None, + installed_version=database_plugin.installed_version if database_plugin else None, + enabled=database_plugin.enabled if database_plugin else None, + status=database_plugin.status if database_plugin else None, + last_error=database_plugin.last_error if database_plugin else None, + ).to_payload() diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/common.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/common.py new file mode 100644 index 0000000..efdc641 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/common.py @@ -0,0 +1,60 @@ +from typing import TypeAlias + +from pydantic import Field + +from .base import PluginPayloadModel + + +class PluginNotFoundPayload(PluginPayloadModel): + """ + 插件不存在 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + operation: str | None = None + dry_run: bool | None = Field(default=None, alias='dryRun') + enabled: bool | None = None + + +PluginNotFoundPayloadDict: TypeAlias = dict[str, object] + + +class PluginCommonPayloadMixin: + """ + 插件运行时通用 payload 构建能力。 + """ + + @staticmethod + def build_plugin_not_found_payload( + plugin_id: str, + *, + operation: str | None = None, + dry_run: bool | None = None, + enabled: bool | None = None, + ) -> PluginNotFoundPayloadDict: + """ + 构建插件不存在负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param dry_run: 是否预演 + :param enabled: 是否启用 + :return: 插件不存在负载 + """ + return PluginNotFoundPayload( + ok=False, + message=f'插件不存在:{plugin_id}', + plugin_id=plugin_id, + operation=operation, + dry_run=dry_run, + enabled=enabled, + ).to_payload(exclude_none=True) + + +__all__ = [ + 'PluginCommonPayloadMixin', + 'PluginNotFoundPayload', + 'PluginNotFoundPayloadDict', +] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/config.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/config.py new file mode 100644 index 0000000..dc2b29a --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/config.py @@ -0,0 +1,424 @@ +from collections.abc import Mapping +from typing import TypeAlias, cast + +from pydantic import Field + +from plugins.core.types import PluginConfigValue, SupportsModelDump + +from .base import PluginPayloadModel + + +class PluginConfigStatePayload(PluginPayloadModel): + """ + 插件配置读取/更新 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + configs: list[dict[str, object]] + operation: str | None = None + + +class PluginConfigExportFailurePayload(PluginPayloadModel): + """ + 插件配置导出失败 payload。 + """ + + ok: object + message: object + plugin_id: str = Field(alias='pluginId') + reveal_secret: bool = Field(alias='revealSecret') + values: dict[str, object] + metadata: list[dict[str, object]] + + +class PluginConfigExportPayload(PluginPayloadModel): + """ + 插件配置导出 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + reveal_secret: bool = Field(alias='revealSecret') + configs: list[dict[str, object]] + values: dict[str, object] + metadata: list[dict[str, object]] + + +class PluginConfigDiagnosticSummaryPayload(PluginPayloadModel): + """ + 插件配置诊断摘要 payload。 + """ + + total: int + secret_count: int = Field(alias='secretCount') + required_count: int = Field(alias='requiredCount') + configured_count: int = Field(alias='configuredCount') + missing_required_count: int = Field(alias='missingRequiredCount') + missing_required_keys: list[str] = Field(alias='missingRequiredKeys') + masked: bool + + +class PluginConfigImportPayload(PluginPayloadModel): + """ + 插件配置导入 payload。 + """ + + ok: object | None = None + message: object | None = None + plugin_id: object | None = Field(default=None, alias='pluginId') + operation: str | None = None + configs: list[dict[str, object]] | None = None + imported_keys: list[str] = Field(alias='importedKeys') + + +class PluginConfigAuditChangePayload(PluginPayloadModel): + """ + 插件配置审计变更项 payload。 + """ + + key: str + label: object + secret: bool + before: object + after: object + + +class PluginConfigAuditSummaryPayload(PluginPayloadModel): + """ + 插件配置审计摘要 payload。 + """ + + changed_count: int = Field(alias='changedCount') + changed_keys: list[str] = Field(alias='changedKeys') + changes: list[dict[str, object]] + + +class PluginConfigAuditPayload(PluginPayloadModel): + """ + 插件配置审计 payload。 + """ + + ok: bool + operation: str + plugin_id: str = Field(alias='pluginId') + message: str + summary: dict[str, object] + + +PluginConfigStatePayloadDict: TypeAlias = dict[str, object] +PluginConfigExportFailurePayloadDict: TypeAlias = dict[str, object] +PluginConfigExportPayloadDict: TypeAlias = dict[str, object] +PluginConfigDiagnosticSummaryPayloadDict: TypeAlias = dict[str, object] +PluginConfigImportPayloadDict: TypeAlias = dict[str, object] +PluginConfigAuditPayloadDict: TypeAlias = dict[str, object] + + +class PluginConfigPayloadBuilder: + """ + 插件配置负载构建器。 + + 使用 Builder 模式集中处理配置诊断摘要、配置导出和配置变更审计负载。 + """ + + @classmethod + def build_read_payload(cls, plugin_id: str, configs: list[SupportsModelDump]) -> PluginConfigStatePayloadDict: + """ + 构建插件配置读取负载。 + + :param plugin_id: 插件ID + :param configs: 插件配置模型列表 + :return: 插件配置读取负载 + """ + return cls._build_state_payload( + plugin_id=plugin_id, + message='插件配置读取完成', + configs=configs, + ) + + @staticmethod + def build_export_failure_payload( + plugin_id: str, + payload: Mapping[str, object], + *, + reveal_secret: bool, + ) -> PluginConfigExportFailurePayloadDict: + """ + 构建插件配置导出失败负载。 + + :param plugin_id: 插件ID + :param payload: 配置读取失败负载 + :param reveal_secret: 是否导出敏感配置明文 + :return: 插件配置导出失败负载 + """ + export_payload = dict(payload) + export_payload.update( + { + 'pluginId': plugin_id, + 'revealSecret': reveal_secret, + 'values': {}, + 'metadata': [], + } + ) + return PluginConfigExportFailurePayload.model_validate(export_payload).to_payload() + + @staticmethod + def build_diagnostic_summary(configs: object) -> PluginConfigDiagnosticSummaryPayloadDict: + """ + 构建插件配置诊断摘要。 + + :param configs: 插件配置明细列表 + :return: 配置诊断摘要 + """ + config_items = [config for config in configs if isinstance(config, dict)] if isinstance(configs, list) else [] + secret_count = sum(1 for config in config_items if bool(config.get('secret'))) + required_count = sum(1 for config in config_items if bool(config.get('required'))) + missing_required_keys = [ + str(config.get('key', '-')) + for config in config_items + if bool(config.get('required')) and not config.get('value') + ] + configured_count = sum( + 1 + for config in config_items + if config.get('value') not in (None, '') and str(config.get('value')) != '******' + ) + + return PluginConfigDiagnosticSummaryPayload( + total=len(config_items), + secret_count=secret_count, + required_count=required_count, + configured_count=configured_count, + missing_required_count=len(missing_required_keys), + missing_required_keys=missing_required_keys, + masked=secret_count > 0, + ).to_payload() + + @classmethod + def build_export_payload( + cls, + plugin_id: str, + configs: list[object], + *, + reveal_secret: bool = False, + ) -> PluginConfigExportPayloadDict: + """ + 构建插件配置导出负载。 + + :param plugin_id: 插件ID + :param configs: 插件配置列表 + :param reveal_secret: 是否导出敏感配置明文 + :return: 插件配置导出负载 + """ + config_items = [cast('dict[str, object]', config) for config in configs if isinstance(config, dict)] + return PluginConfigExportPayload( + ok=True, + message='插件配置导出完成', + plugin_id=plugin_id, + reveal_secret=reveal_secret, + configs=config_items, + values={ + config.get('key'): cast('PluginConfigValue', config.get('value')) + for config in config_items + if isinstance(config.get('key'), str) + }, + metadata=[cls._build_export_metadata(config) for config in config_items], + ).to_payload() + + @classmethod + def build_update_payload( + cls, + plugin_id: str, + *, + operation: str, + message: str, + configs: list[SupportsModelDump], + ) -> PluginConfigStatePayloadDict: + """ + 构建插件配置更新负载。 + + :param plugin_id: 插件ID + :param operation: 配置操作类型 + :param message: 成功提示 + :param configs: 更新后的配置模型列表 + :return: 插件配置更新负载 + """ + return cls._build_state_payload( + plugin_id=plugin_id, + message=message, + configs=configs, + operation=operation, + ) + + @staticmethod + def build_import_payload( + plugin_id: str, + payload: Mapping[str, object], + values: dict[str, PluginConfigValue], + ) -> PluginConfigImportPayloadDict: + """ + 构建插件配置导入负载。 + + :param plugin_id: 插件ID + :param payload: 配置更新负载 + :param values: 待导入配置键值 + :return: 插件配置导入负载 + """ + if payload.get('ok', False): + import_payload = dict(payload) + import_payload['importedKeys'] = sorted(values) + return PluginConfigImportPayload.model_validate(import_payload).to_payload(exclude_none=True) + + import_payload = dict(payload) + import_payload.setdefault('pluginId', plugin_id) + import_payload['importedKeys'] = [] + return PluginConfigImportPayload.model_validate(import_payload).to_payload(exclude_none=True) + + @classmethod + def build_audit_payload( + cls, + plugin_id: str, + *, + operation: str, + values: dict[str, PluginConfigValue], + before_configs: list[SupportsModelDump], + after_configs: list[SupportsModelDump], + message: str, + ) -> PluginConfigAuditPayloadDict: + """ + 构建插件配置变更审计负载。 + + :param plugin_id: 插件ID + :param operation: 配置操作类型 + :param values: 本次请求更新的配置键值 + :param before_configs: 更新前配置列表 + :param after_configs: 更新后配置列表 + :param message: 审计备注信息 + :return: 插件配置变更审计负载 + """ + before_map = cls._build_audit_map(before_configs) + after_map = cls._build_audit_map(after_configs) + changed_keys = sorted(str(key) for key in values) + changed_items = [ + cls._build_audit_item(key, before_map.get(key, {}), after_map.get(key, {})) for key in changed_keys + ] + + return PluginConfigAuditPayload( + ok=True, + operation=operation, + plugin_id=plugin_id, + message=message, + summary=PluginConfigAuditSummaryPayload( + changed_count=len(changed_items), + changed_keys=changed_keys, + changes=changed_items, + ).to_payload(), + ).to_payload() + + @staticmethod + def _build_state_payload( + *, + plugin_id: str, + message: str, + configs: list[SupportsModelDump], + operation: str | None = None, + ) -> PluginConfigStatePayloadDict: + """ + 构建插件配置读取/更新状态负载。 + + :param plugin_id: 插件ID + :param message: 响应消息 + :param configs: 插件配置模型列表 + :param operation: 配置操作类型 + :return: 插件配置读取/更新状态负载 + """ + return PluginConfigStatePayload( + ok=True, + message=message, + plugin_id=plugin_id, + configs=[cast('dict[str, object]', config.model_dump(by_alias=True)) for config in configs], + operation=operation, + ).to_payload(exclude_none=True) + + @staticmethod + def _build_export_metadata(config: Mapping[str, object]) -> dict[str, object]: + """ + 构建不包含配置值的导出元数据。 + + :param config: 配置项 + :return: 配置导出元数据 + """ + return { + key: config.get(key) + for key in ( + 'key', + 'label', + 'type', + 'default', + 'required', + 'secret', + 'group', + 'order', + 'placeholder', + 'min', + 'max', + 'pattern', + 'options', + 'description', + ) + } + + @staticmethod + def _build_audit_map(configs: list[SupportsModelDump]) -> dict[str, dict[str, PluginConfigValue]]: + """ + 构建按配置键索引的审计配置映射。 + + :param configs: 插件配置模型列表 + :return: 按配置键索引的配置审计映射 + """ + config_map: dict[str, dict[str, PluginConfigValue]] = {} + for config in configs: + payload = config.model_dump(by_alias=True) if hasattr(config, 'model_dump') else {} + if not isinstance(payload, dict) or not isinstance(payload.get('key'), str): + continue + config_map[payload['key']] = cast('dict[str, PluginConfigValue]', payload) + + return config_map + + @classmethod + def _build_audit_item( + cls, + key: str, + before_config: dict[str, PluginConfigValue], + after_config: dict[str, PluginConfigValue], + ) -> PluginConfigAuditChangePayload: + """ + 构建单个配置项的脱敏变更摘要。 + + :param key: 配置键 + :param before_config: 变更前配置负载 + :param after_config: 变更后配置负载 + :return: 单个配置项脱敏变更摘要 + """ + secret = bool(before_config.get('secret') or after_config.get('secret')) + + return PluginConfigAuditChangePayload( + key=key, + label=after_config.get('label') or before_config.get('label'), + secret=secret, + before=cls._mask_audit_value(before_config.get('value'), secret), + after=cls._mask_audit_value(after_config.get('value'), secret), + ).to_payload() + + @staticmethod + def _mask_audit_value(value: PluginConfigValue, secret: bool) -> PluginConfigValue: + """ + 对配置审计值执行敏感信息脱敏。 + + :param value: 配置值 + :param secret: 是否敏感配置 + :return: 脱敏后的配置值 + """ + return '******' if secret and value is not None else value diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/dependencies.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/dependencies.py new file mode 100644 index 0000000..9944dc9 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/dependencies.py @@ -0,0 +1,221 @@ +from typing import TypeAlias + +from pydantic import Field + +from plugins.core.validation.dependencies import DependencyCheckResult, DependencyInstallPlanItem +from plugins.core.validation.dependency_policy import DependencyInstallPolicyDecision + +from .base import PluginPayloadModel +from .plan import PluginPlanPayloadMixin +from .validation import PluginValidationPayloadMixin + + +class PluginDependencyInstallPayload(PluginPayloadModel): + """ + 插件依赖安装 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + dependency_ok: bool = Field(alias='dependencyOk') + dependencies: list[dict[str, object]] + missing_dependencies: list[str] = Field(alias='missingDependencies') + unsatisfied_dependencies: list[str] = Field(alias='unsatisfiedDependencies') + dry_run: bool = Field(alias='dryRun') + plan: list[dict[str, object]] + plan_count: int = Field(alias='planCount') + results: list[dict[str, object]] | None = None + policy: dict[str, object] | None = None + + +class DependencyInstallReturnCode(PluginPayloadModel): + """ + 依赖安装命令返回码 payload。 + """ + + return_code: int = Field(alias='returnCode') + + +PluginDependencyInstallPayloadDict: TypeAlias = dict[str, object] +DependencyInstallReturnCodePayload: TypeAlias = dict[str, int] + + +class PluginDependencyInstallPayloadBuilder: + """ + 插件依赖安装负载构建器。 + + 使用 Builder 模式统一依赖安装命令的 dry-run、无需安装和执行结果负载。 + """ + + @classmethod + def build_payload( + cls, + *, + plugin_id: str, + dependency_result: DependencyCheckResult, + install_plan_items: list[DependencyInstallPlanItem], + dry_run: bool, + ok: bool, + message: str, + results: list[dict[str, object]] | None = None, + include_results: bool = False, + policy_decision: DependencyInstallPolicyDecision | None = None, + ) -> PluginDependencyInstallPayloadDict: + """ + 从依赖检查结果构建依赖安装 payload。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param install_plan_items: 依赖安装计划项列表 + :param dry_run: 是否预演 + :param ok: 操作是否成功 + :param message: 操作消息 + :param results: 安装结果列表 + :param include_results: 是否包含安装执行结果 + :param policy_decision: 依赖安装策略判定 + :return: 插件依赖安装 payload + """ + plan = [PluginPlanPayloadMixin.build_dependency_install_plan_item(item) for item in install_plan_items] + payload = PluginDependencyInstallPayload( + ok=ok, + message=message, + plugin_id=plugin_id, + dependency_ok=dependency_result.ok, + dependencies=[PluginValidationPayloadMixin.build_dependency_item(item) for item in dependency_result.items], + missing_dependencies=[item.name for item in dependency_result.missing_items], + unsatisfied_dependencies=[item.name for item in dependency_result.unsatisfied_items], + dry_run=dry_run, + plan=plan, + plan_count=len(plan), + results=results or [] if include_results else None, + policy=policy_decision.to_payload() if policy_decision else None, + ) + + return payload.to_payload(exclude_none=True) + + @classmethod + def build_base_payload( + cls, + plugin_id: str, + dependency_result: DependencyCheckResult, + install_plan_items: list[DependencyInstallPlanItem], + *, + dry_run: bool, + policy_decision: DependencyInstallPolicyDecision | None = None, + ) -> PluginDependencyInstallPayloadDict: + """ + 构建插件依赖安装基础负载。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param install_plan_items: 依赖安装计划项列表 + :param dry_run: 是否预演 + :param policy_decision: 依赖安装策略判定 + :return: 插件依赖安装基础负载 + """ + return cls.build_payload( + plugin_id=plugin_id, + dependency_result=dependency_result, + install_plan_items=install_plan_items, + dry_run=dry_run, + ok=dependency_result.ok, + message='插件依赖已满足' if dependency_result.ok else '插件依赖存在问题', + policy_decision=policy_decision, + ) + + @classmethod + def build_dry_run_payload( + cls, + plugin_id: str, + dependency_result: DependencyCheckResult, + install_plan_items: list[DependencyInstallPlanItem], + policy_decision: DependencyInstallPolicyDecision | None = None, + ) -> PluginDependencyInstallPayloadDict: + """ + 构建插件依赖安装预演负载。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param install_plan_items: 依赖安装计划项列表 + :param policy_decision: 依赖安装策略判定 + :return: 插件依赖安装预演负载 + """ + return cls.build_payload( + plugin_id=plugin_id, + dependency_result=dependency_result, + install_plan_items=install_plan_items, + dry_run=True, + ok=True, + message='插件依赖安装演练完成,未执行实际安装', + policy_decision=policy_decision, + ) + + @classmethod + def build_satisfied_payload( + cls, + plugin_id: str, + dependency_result: DependencyCheckResult, + install_plan_items: list[DependencyInstallPlanItem], + policy_decision: DependencyInstallPolicyDecision | None = None, + ) -> PluginDependencyInstallPayloadDict: + """ + 构建插件依赖已满足负载。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param install_plan_items: 依赖安装计划项列表 + :param policy_decision: 依赖安装策略判定 + :return: 插件依赖已满足负载 + """ + return cls.build_payload( + plugin_id=plugin_id, + dependency_result=dependency_result, + install_plan_items=install_plan_items, + dry_run=False, + ok=True, + message='插件依赖已满足,无需安装', + include_results=True, + policy_decision=policy_decision, + ) + + @classmethod + def build_execution_payload( + cls, + plugin_id: str, + dependency_result: DependencyCheckResult, + install_plan_items: list[DependencyInstallPlanItem], + install_results: list[dict[str, object]], + policy_decision: DependencyInstallPolicyDecision | None = None, + ) -> PluginDependencyInstallPayloadDict: + """ + 构建插件依赖安装执行结果负载。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :param install_plan_items: 依赖安装计划项列表 + :param install_results: 依赖安装命令执行结果列表 + :param policy_decision: 依赖安装策略判定 + :return: 插件依赖安装执行结果负载 + """ + install_ok = all(result['returnCode'] == 0 for result in install_results) + return cls.build_payload( + plugin_id=plugin_id, + dependency_result=dependency_result, + install_plan_items=install_plan_items, + dry_run=False, + ok=install_ok, + message='插件依赖安装完成' if install_ok else '插件依赖安装存在失败项', + results=install_results, + include_results=True, + policy_decision=policy_decision, + ) + + +__all__ = [ + 'DependencyInstallReturnCode', + 'DependencyInstallReturnCodePayload', + 'PluginDependencyInstallPayload', + 'PluginDependencyInstallPayloadBuilder', + 'PluginDependencyInstallPayloadDict', +] diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/documentation.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/documentation.py new file mode 100644 index 0000000..95d1d43 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/documentation.py @@ -0,0 +1,272 @@ +from typing import Literal, TypeAlias + +from pydantic import Field + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.manifest.menu_tree import PluginMenuTree + +from .base import PluginPayloadModel + + +class PluginDocumentationPayload(PluginPayloadModel): + """ + 插件文档生成 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + format: Literal['markdown'] + markdown: str + length: int + + +PluginDocumentationPayloadDict: TypeAlias = dict[str, object] + + +class PluginDocumentationBuilder: + """ + 插件文档片段构建器。 + + 使用 Builder 模式将插件 manifest 转换为 Markdown 文档片段,供插件运行时和模板复用。 + """ + + @classmethod + def build_payload(cls, plugin_id: str, discovered_plugin: DiscoveredPlugin) -> PluginDocumentationPayloadDict: + """ + 构建插件文档生成负载。 + + :param plugin_id: 插件ID + :param discovered_plugin: 已发现插件 + :return: 插件文档生成负载 + """ + markdown = cls.build_markdown(discovered_plugin) + return cls.build_payload_from_markdown(plugin_id, markdown) + + @staticmethod + def build_payload_from_markdown(plugin_id: str, markdown: str) -> PluginDocumentationPayloadDict: + """ + 根据 Markdown 内容构建插件文档生成负载。 + + :param plugin_id: 插件ID + :param markdown: Markdown 文档内容 + :return: 插件文档生成负载 + """ + return PluginDocumentationPayload( + ok=True, + message='插件文档生成完成', + plugin_id=plugin_id, + format='markdown', + markdown=markdown, + length=len(markdown), + ).to_payload() + + @classmethod + def build_markdown(cls, discovered_plugin: DiscoveredPlugin) -> str: + """ + 构建插件 Markdown 文档片段。 + + :param discovered_plugin: 已发现插件 + :return: Markdown 文档内容 + """ + manifest = discovered_plugin.manifest + sections = [ + f'# {manifest.name}', + '', + f'- 插件ID:`{manifest.id}`', + f'- 版本:`{manifest.version}`', + f'- 后端模块:`{manifest.backend.module}`', + f'- 前端目录:`{manifest.frontend.plugin_id}`', + f'- 说明:{manifest.description or "-"}', + '', + *cls._build_frontend_section(discovered_plugin), + *cls._build_permission_section(discovered_plugin), + *cls._build_config_section(discovered_plugin), + *cls._build_dependency_section(discovered_plugin), + *cls._build_job_section(discovered_plugin), + *cls._build_lifecycle_section(discovered_plugin), + *cls._build_migration_seed_section(discovered_plugin), + ] + + return '\n'.join(sections).rstrip() + '\n' + + @classmethod + def _build_frontend_section(cls, discovered_plugin: DiscoveredPlugin) -> list[str]: + """ + 构建前端菜单文档段落。 + + :param discovered_plugin: 已发现插件 + :return: Markdown 行列表 + """ + menus = PluginMenuTree.flatten(discovered_plugin.manifest.frontend.menus) + lines = ['## 菜单', ''] + if not menus: + return [*lines, '无菜单声明。', ''] + + lines.extend(['| 名称 | 路径 | 组件 | 权限 | 类型 |', '| --- | --- | --- | --- | --- |']) + lines.extend( + f'| {menu.name} | `{menu.path}` | `{menu.component}` | `{menu.perms or "-"}` | `{menu.type}` |' + for menu in menus + ) + lines.append('') + return lines + + @staticmethod + def _build_permission_section(discovered_plugin: DiscoveredPlugin) -> list[str]: + """ + 构建权限文档段落。 + + :param discovered_plugin: 已发现插件 + :return: Markdown 行列表 + """ + permissions = discovered_plugin.manifest.permissions + lines = ['## 权限', ''] + if not permissions: + return [*lines, '无权限声明。', ''] + + lines.extend(['| 权限标识 | 展示名称 | 说明 |', '| --- | --- | --- |']) + lines.extend( + f'| `{permission.code}` | {permission.name or "-"} | {permission.description or "-"} |' + for permission in permissions + ) + lines.append('') + return lines + + @staticmethod + def _build_config_section(discovered_plugin: DiscoveredPlugin) -> list[str]: + """ + 构建配置文档段落。 + + :param discovered_plugin: 已发现插件 + :return: Markdown 行列表 + """ + config_items = discovered_plugin.manifest.config.items + lines = ['## 配置', ''] + if not config_items: + return [*lines, '无配置声明。', ''] + + lines.extend( + [ + '| Key | 标签 | 类型 | 必填 | 敏感 | 默认值 | 分组 | 说明 |', + '| --- | --- | --- | --- | --- | --- | --- | --- |', + ] + ) + lines.extend( + '| ' + + ' | '.join( + [ + f'`{item.key}`', + item.label or '-', + f'`{item.type}`', + str(item.required).lower(), + str(item.secret).lower(), + '`******`' if item.secret and item.default is not None else f'`{item.default}`', + item.group, + item.description or '-', + ] + ) + + ' |' + for item in config_items + ) + lines.append('') + return lines + + @staticmethod + def _build_dependency_section(discovered_plugin: DiscoveredPlugin) -> list[str]: + """ + 构建依赖文档段落。 + + :param discovered_plugin: 已发现插件 + :return: Markdown 行列表 + """ + dependencies = discovered_plugin.manifest.dependencies + lines = ['## 依赖', ''] + if not dependencies.python and not dependencies.npm and not dependencies.npm_dev and not dependencies.plugins: + return [*lines, '无依赖声明。', ''] + + lines.append('### Python') + lines.extend(f'- `{item}`' for item in dependencies.python) + if not dependencies.python: + lines.append('- 无') + lines.extend(['', '### NPM']) + lines.extend(f'- `{item}`' for item in dependencies.npm) + if not dependencies.npm: + lines.append('- 无') + lines.extend(['', '### NPM 开发依赖']) + lines.extend(f'- `{item}`' for item in dependencies.npm_dev) + if not dependencies.npm_dev: + lines.append('- 无') + lines.extend(['', '### 插件']) + lines.extend( + f'- `{item.id}` {item.version or ""} {item.description or ""}'.rstrip() for item in dependencies.plugins + ) + if not dependencies.plugins: + lines.append('- 无') + lines.append('') + return lines + + @staticmethod + def _build_job_section(discovered_plugin: DiscoveredPlugin) -> list[str]: + """ + 构建定时任务文档段落。 + + :param discovered_plugin: 已发现插件 + :return: Markdown 行列表 + """ + jobs = discovered_plugin.manifest.backend.jobs + lines = ['## 定时任务', ''] + if not jobs: + return [*lines, '无定时任务声明。', ''] + + lines.extend(['| ID | 名称 | Callable | Cron | 默认启用 |', '| --- | --- | --- | --- | --- |']) + lines.extend( + f'| `{job.id}` | {job.name or "-"} | `{job.callable}` | `{job.cron_expression}` | ' + f'{str(job.enabled).lower()} |' + for job in jobs + ) + lines.append('') + return lines + + @staticmethod + def _build_lifecycle_section(discovered_plugin: DiscoveredPlugin) -> list[str]: + """ + 构建生命周期文档段落。 + + :param discovered_plugin: 已发现插件 + :return: Markdown 行列表 + """ + hooks = discovered_plugin.manifest.backend.hooks + hook_items = [ + ('onInstall', hooks.on_install), + ('onUpgrade', hooks.on_upgrade), + ('onStartup', hooks.on_startup), + ('onShutdown', hooks.on_shutdown), + ('onPurge', hooks.on_purge), + ] + lines = ['## 生命周期钩子', ''] + if not any(hook for _, hook in hook_items): + return [*lines, '无生命周期钩子声明。', ''] + + lines.extend(f'- `{name}`:`{hook}`' for name, hook in hook_items if hook) + lines.append('') + return lines + + @staticmethod + def _build_migration_seed_section(discovered_plugin: DiscoveredPlugin) -> list[str]: + """ + 构建 migration 和 seed 文档段落。 + + :param discovered_plugin: 已发现插件 + :return: Markdown 行列表 + """ + backend = discovered_plugin.manifest.backend + lines = ['## Migration 与 Seed', '', '### Migrations'] + lines.extend(f'- `{migration}`' for migration in backend.migrations) + if not backend.migrations: + lines.append('- 无') + lines.extend(['', '### Seeds']) + lines.extend(f'- `{seed}`' for seed in backend.seeds) + if not backend.seeds: + lines.append('- 无') + lines.append('') + return lines diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/enable.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/enable.py new file mode 100644 index 0000000..66ed1eb --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/enable.py @@ -0,0 +1,295 @@ +from collections.abc import Mapping +from typing import TypeAlias + +from pydantic import Field + +from plugins.core.validation.plugin_deps import PluginDependencyCheckResult + +from . import PluginPayloadBuilder +from .base import PluginPayloadModel + + +class PluginEnableDependencyPayload(PluginPayloadModel): + """ + 插件启停依赖检查 payload。 + """ + + plugin_dependency_ok: bool = Field(alias='pluginDependencyOk') + plugin_dependency_errors: list[dict[str, object]] = Field(alias='pluginDependencyErrors') + plugin_dependencies: list[dict[str, object]] = Field(alias='pluginDependencies') + + +class PluginEnableStatePayload(PluginPayloadModel): + """ + 插件启停状态 payload。 + """ + + ok: bool + message: object + plugin_id: str = Field(alias='pluginId') + operation: str + enabled: bool + dry_run: bool = Field(alias='dryRun') + actions: list[dict[str, object]] + plugin_dependency_ok: object | None = Field(default=None, alias='pluginDependencyOk') + plugin_dependency_errors: object | None = Field(default=None, alias='pluginDependencyErrors') + plugin_dependencies: object | None = Field(default=None, alias='pluginDependencies') + + +class PluginEnableUpdateFailurePayload(PluginPayloadModel): + """ + 插件启停写入失败 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + operation: str + enabled: bool + dry_run: bool = Field(alias='dryRun') + + +class PluginSafeUninstallPayload(PluginPayloadModel): + """ + 插件安全卸载 payload。 + """ + + ok: object | None = None + message: object | None = None + plugin_id: object | None = Field(default=None, alias='pluginId') + operation: str + enabled: bool | None = None + dry_run: bool | None = Field(default=None, alias='dryRun') + safe_mode: bool = Field(alias='safeMode') + removes_source: bool = Field(alias='removesSource') + removes_menus: bool = Field(alias='removesMenus') + actions: list[dict[str, object]] | None = None + precheck: dict[str, object] | None = None + manifest_ok: object | None = Field(default=None, alias='manifestOk') + dependency_ok: object | None = Field(default=None, alias='dependencyOk') + plugin_dependency_ok: object | None = Field(default=None, alias='pluginDependencyOk') + structure_ok: object | None = Field(default=None, alias='structureOk') + menu_conflict_ok: object | None = Field(default=None, alias='menuConflictOk') + manifest_issues: object | None = Field(default=None, alias='manifestIssues') + manifest_warnings: object | None = Field(default=None, alias='manifestWarnings') + plugin_dependency_errors: object | None = Field(default=None, alias='pluginDependencyErrors') + structure_errors: object | None = Field(default=None, alias='structureErrors') + menu_conflicts: object | None = Field(default=None, alias='menuConflicts') + dependencies: object | None = None + plugin_dependencies: object | None = Field(default=None, alias='pluginDependencies') + error: object | None = None + failed_step: str | None = Field(default=None, alias='failedStep') + capability: dict[str, object] | None = None + + +PluginEnableDependencyPayloadDict: TypeAlias = dict[str, object] +PluginEnableStatePayloadDict: TypeAlias = dict[str, object] +PluginEnableUpdateFailurePayloadDict: TypeAlias = dict[str, object] +PluginSafeUninstallPayloadDict: TypeAlias = dict[str, object] + + +class PluginEnablePayloadBuilder: + """ + 插件启停负载构建器。 + + 使用 Builder 模式集中启用、停用和安全卸载的负载拼装。 + """ + + @classmethod + def build_dependency_payload( + cls, plugin_dependency_result: PluginDependencyCheckResult + ) -> PluginEnableDependencyPayloadDict: + """ + 构建插件启用依赖检查负载。 + + :param plugin_dependency_result: 插件间依赖检查结果 + :return: 插件启用依赖检查负载 + """ + return PluginEnableDependencyPayload( + plugin_dependency_ok=plugin_dependency_result.ok, + plugin_dependency_errors=[ + PluginPayloadBuilder.build_plugin_dependency_item(item) + for item in plugin_dependency_result.failed_items + ], + plugin_dependencies=[ + PluginPayloadBuilder.build_plugin_dependency_item(item) for item in plugin_dependency_result.items + ], + ).to_payload() + + @staticmethod + def build_dependency_blocker_payload( + plugin_id: str, + *, + operation: str, + enabled: bool, + dependency_payload: Mapping[str, object], + message: str | None = None, + ) -> PluginEnableStatePayloadDict: + """ + 构建插件启用依赖阻断负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param enabled: 是否启用 + :param dependency_payload: 插件依赖检查负载 + :param message: 自定义阻断提示 + :return: 插件启用依赖阻断负载 + """ + plugin_dependency_ok = bool(dependency_payload.get('pluginDependencyOk', True)) + resolved_message = message or ( + '插件仍被已启用插件依赖,操作已中止' + if operation in ('disable', 'uninstall') + else '插件间依赖检查失败,启用已中止' + ) + return PluginEnableStatePayload.model_validate( + { + 'ok': False, + 'message': resolved_message, + 'pluginId': plugin_id, + 'operation': operation, + 'enabled': enabled, + 'dryRun': False, + 'actions': PluginPayloadBuilder.build_enabled_actions(enabled, plugin_dependency_ok), + **dependency_payload, + } + ).to_payload(exclude_none=True) + + @staticmethod + def build_dry_run_payload( + plugin_id: str, + *, + operation: str, + enabled: bool, + dependency_payload: Mapping[str, object], + ) -> PluginEnableStatePayloadDict: + """ + 构建插件启停预演负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param enabled: 是否启用 + :param dependency_payload: 插件依赖检查负载 + :return: 插件启停预演负载 + """ + return PluginEnablePayloadBuilder._build_state_payload( + plugin_id=plugin_id, + operation=operation, + enabled=enabled, + dry_run=True, + ok=True, + message='插件启停演练完成,未执行实际写入', + dependency_payload=dependency_payload, + ) + + @staticmethod + def build_update_failure_payload( + plugin_id: str, + *, + operation: str, + enabled: bool, + message: str, + ) -> PluginEnableUpdateFailurePayloadDict: + """ + 构建插件启停写入失败负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param enabled: 是否启用 + :param message: 失败提示 + :return: 插件启停写入失败负载 + """ + return PluginEnableUpdateFailurePayload( + ok=False, + message=message, + plugin_id=plugin_id, + operation=operation, + enabled=enabled, + dry_run=False, + ).to_payload() + + @staticmethod + def build_success_payload( + plugin_id: str, + *, + operation: str, + enabled: bool, + message: str, + dependency_payload: Mapping[str, object], + ) -> PluginEnableStatePayloadDict: + """ + 构建插件启停成功负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param enabled: 是否启用 + :param message: 成功提示 + :param dependency_payload: 插件依赖检查负载 + :return: 插件启停成功负载 + """ + return PluginEnablePayloadBuilder._build_state_payload( + plugin_id=plugin_id, + operation=operation, + enabled=enabled, + dry_run=False, + ok=True, + message=message, + dependency_payload=dependency_payload, + ) + + @staticmethod + def build_uninstall_payload(result: Mapping[str, object], *, dry_run: bool) -> PluginSafeUninstallPayloadDict: + """ + 构建插件安全卸载负载。 + + :param result: 插件停用结果负载 + :param dry_run: 是否预演 + :return: 插件安全卸载负载 + """ + uninstall_payload = dict(result) + uninstall_payload.update( + { + 'operation': 'uninstall', + 'message': '插件卸载演练完成,未执行实际写入' if dry_run else result.get('message', '插件卸载完成'), + 'safeMode': True, + 'removesSource': False, + 'removesMenus': True, + } + ) + return PluginSafeUninstallPayload.model_validate(uninstall_payload).to_payload(exclude_none=True) + + @staticmethod + def _build_state_payload( + *, + plugin_id: str, + operation: str, + enabled: bool, + dry_run: bool, + ok: bool, + message: str, + dependency_payload: Mapping[str, object], + ) -> PluginEnableStatePayloadDict: + """ + 构建插件启停状态负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param enabled: 是否启用 + :param dry_run: 是否预演 + :param ok: 操作是否成功 + :param message: 响应消息 + :param dependency_payload: 插件依赖检查负载 + :return: 插件启停状态负载 + """ + plugin_dependency_ok = bool(dependency_payload.get('pluginDependencyOk', True)) + return PluginEnableStatePayload.model_validate( + { + 'ok': ok, + 'message': message, + 'pluginId': plugin_id, + 'operation': operation, + 'enabled': enabled, + 'dryRun': dry_run, + 'actions': PluginPayloadBuilder.build_enabled_actions(enabled, plugin_dependency_ok), + **dependency_payload, + } + ).to_payload(exclude_none=True) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/lifecycle.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/lifecycle.py new file mode 100644 index 0000000..dd50d0f --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/lifecycle.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, TypeAlias, cast + +from pydantic import Field + +from . import PluginPayloadBuilder +from .base import PluginPayloadModel + +if TYPE_CHECKING: + from collections.abc import Mapping + + from plugins.core.lifecycle.migration import PluginMigrationResult + from plugins.core.lifecycle.seed import PluginSeedResult + from plugins.core.runtime.hooks import PluginHookResult + from plugins.core.types import SupportsModelDump + from plugins.core.validation.menus import PluginMenuConflictItem + + from .plan import ActionPayload, VersionStatePayload + from .validation import MenuConflictItemPayload + + +class SupportsOk(Protocol): + """ + 支持 ok 属性的检查结果协议。 + """ + + ok: bool + + +class PluginLifecyclePrecheckProtocol(Protocol): + """ + lifecycle payload 所需的预检上下文协议。 + """ + + ok: bool + dependency_result: SupportsOk + manifest_result: SupportsOk + plugin_dependency_result: SupportsOk + structure_result: SupportsOk + menu_conflict_result: SupportsOk + operation_payload: Mapping[str, object] + check_payload: Mapping[str, object] + menu_conflicts: list[MenuConflictItemPayload] + + +class PluginLifecyclePayload(PluginPayloadModel): + """ + 插件生命周期通用 payload。 + """ + + ok: bool | None = None + message: str | None = None + plugin_id: str | None = Field(default=None, alias='pluginId') + dry_run: bool | None = Field(default=None, alias='dryRun') + operation: str | None = None + actions: list[dict[str, object]] | None = None + precheck: dict[str, object] | None = None + plugin: dict[str, object] | None = None + configs: list[dict[str, object]] | None = None + migrations: list[dict[str, object]] | None = None + seeds: list[dict[str, object]] | None = None + hooks: list[dict[str, object]] | None = None + menu_conflicts: list[dict[str, object]] | None = Field(default=None, alias='menuConflicts') + menu_conflict_ok: object | None = Field(default=None, alias='menuConflictOk') + manifest_ok: object | None = Field(default=None, alias='manifestOk') + dependency_ok: object | None = Field(default=None, alias='dependencyOk') + plugin_dependency_ok: object | None = Field(default=None, alias='pluginDependencyOk') + structure_ok: object | None = Field(default=None, alias='structureOk') + manifest_issues: object | None = Field(default=None, alias='manifestIssues') + manifest_warnings: object | None = Field(default=None, alias='manifestWarnings') + plugin_dependency_errors: object | None = Field(default=None, alias='pluginDependencyErrors') + structure_errors: object | None = Field(default=None, alias='structureErrors') + dependencies: object | None = None + plugin_dependencies: object | None = Field(default=None, alias='pluginDependencies') + installed: bool | None = None + installed_version: str | None = Field(default=None, alias='installedVersion') + current_version: str | None = Field(default=None, alias='currentVersion') + needs_upgrade: bool | None = Field(default=None, alias='needsUpgrade') + dependency_install: object | None = Field(default=None, alias='dependencyInstall') + enabled: bool | None = None + safe_mode: bool | None = Field(default=None, alias='safeMode') + removes_source: bool | None = Field(default=None, alias='removesSource') + plan: dict[str, object] | None = None + + +PluginLifecyclePayloadDict: TypeAlias = dict[str, object] + + +def _lifecycle_payload(payload: Mapping[str, object]) -> PluginLifecyclePayloadDict: + """ + 序列化生命周期 payload,保留历史额外字段。 + + :param payload: 生命周期 payload 字典 + :return: 运行时 payload 字典 + """ + return PluginLifecyclePayload.model_validate(payload).to_payload(exclude_none=True) + + +def _object_payload(value: object) -> dict[str, object]: + """ + 将生命周期执行结果对象转换为 payload 字典。 + + :param value: 生命周期执行结果对象 + :return: payload 字典 + """ + return dict(vars(value)) + + +class PluginLifecyclePayloadBuilder: + """ + 插件安装与升级生命周期负载构建器。 + + 使用 Builder 模式集中安装、升级流程中的 dry-run、阻断和成功结果负载。 + """ + + @staticmethod + def build_install_dry_run_payload( + plugin_id: str, + actions: list[ActionPayload], + precheck: PluginLifecyclePrecheckProtocol, + ) -> PluginLifecyclePayloadDict: + """ + 构建插件安装预演负载。 + + :param plugin_id: 插件ID + :param actions: 安装动作清单 + :param precheck: 插件操作预检上下文 + :return: 插件安装预演负载 + """ + return _lifecycle_payload( + { + 'ok': True, + 'message': '插件安装演练完成,未执行实际写入', + 'pluginId': plugin_id, + 'dryRun': True, + 'actions': actions, + **precheck.operation_payload, + } + ) + + @staticmethod + def build_precheck_blocker_payload( + plugin_id: str, + *, + message: str, + actions: list[ActionPayload], + precheck: PluginLifecyclePrecheckProtocol, + dry_run: bool = False, + extra_payload: Mapping[str, object] | None = None, + ) -> PluginLifecyclePayloadDict: + """ + 构建插件安装或升级预检阻断负载。 + + :param plugin_id: 插件ID + :param message: 阻断提示 + :param actions: 动作清单 + :param precheck: 插件操作预检上下文 + :param dry_run: 是否预演 + :param extra_payload: 额外负载 + :return: 插件安装或升级预检阻断负载 + """ + return _lifecycle_payload( + { + 'ok': False, + 'message': message, + 'pluginId': plugin_id, + 'dryRun': dry_run, + **(extra_payload or {}), + 'actions': actions, + **precheck.operation_payload, + } + ) + + @classmethod + def build_first_precheck_blocker_payload( + cls, + plugin_id: str, + *, + operation: str, + actions: list[ActionPayload], + precheck: PluginLifecyclePrecheckProtocol, + extra_payload: Mapping[str, object] | None = None, + ) -> PluginLifecyclePayloadDict | None: + """ + 按统一优先级构建首个预检阻断负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param actions: 动作清单 + :param precheck: 插件操作预检上下文 + :param extra_payload: 额外负载 + :return: 预检阻断负载,无需阻断时返回 None + """ + operation_label = { + 'install': '安装', + 'enable': '启用', + 'upgrade': '升级', + 'uninstall': '卸载', + 'purge': '物理清理', + }.get(operation, '操作') + blocker_specs = [ + (precheck.manifest_result.ok, 'manifestOk', f'插件 manifest 检查失败,{operation_label}已中止'), + ( + precheck.plugin_dependency_result.ok, + 'pluginDependencyOk', + f'插件间依赖检查失败,{operation_label}已中止', + ), + (precheck.structure_result.ok, 'structureOk', f'插件结构检查失败,{operation_label}已中止'), + (precheck.menu_conflict_result.ok, 'menuConflictOk', f'插件菜单存在冲突,{operation_label}已中止'), + ] + for ok, payload_key, message in blocker_specs: + if ok: + continue + return cls.build_precheck_blocker_payload( + plugin_id, + message=message, + actions=actions, + precheck=precheck, + extra_payload={**(extra_payload or {}), payload_key: False}, + ) + + return None + + @classmethod + def build_dependency_blocker_payload( + cls, + plugin_id: str, + *, + actions: list[ActionPayload], + precheck: PluginLifecyclePrecheckProtocol, + dependency_install_payload: Mapping[str, object], + ) -> PluginLifecyclePayloadDict | None: + """ + 构建依赖自动安装后仍未满足时的阻断负载。 + + :param plugin_id: 插件ID + :param actions: 动作清单 + :param precheck: 插件操作预检上下文 + :param dependency_install_payload: 依赖安装执行负载 + :return: 依赖阻断负载,无需阻断时返回 None + """ + if precheck.dependency_result.ok: + return None + + return cls.build_precheck_blocker_payload( + plugin_id, + message='插件依赖检查失败,安装已中止', + actions=actions, + precheck=precheck, + extra_payload={'dependencyOk': False, 'dependencyInstall': dependency_install_payload}, + ) + + @staticmethod + def build_operation_dry_run_payload( + plugin_id: str, + *, + operation: str, + message: str, + actions: list[ActionPayload], + precheck: PluginLifecyclePrecheckProtocol, + extra_payload: Mapping[str, object] | None = None, + ok_from_precheck: bool = True, + ) -> PluginLifecyclePayloadDict: + """ + 构建统一预检后的操作预演负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param message: 预演提示 + :param actions: 动作清单 + :param precheck: 插件操作预检上下文 + :param extra_payload: 额外负载 + :param ok_from_precheck: 是否使用预检结果决定操作是否成功 + :return: 操作预演负载 + """ + ok = precheck.ok if ok_from_precheck else True + return _lifecycle_payload( + { + 'ok': ok, + 'message': message if ok else '插件操作预检存在问题,未执行实际写入', + 'pluginId': plugin_id, + 'operation': operation, + 'dryRun': True, + 'actions': actions, + **precheck.operation_payload, + 'precheck': precheck.check_payload, + **(extra_payload or {}), + } + ) + + @staticmethod + def build_installed_menu_conflict_payload( + plugin_id: str, + *, + message: str, + actions: list[ActionPayload], + precheck: PluginLifecyclePrecheckProtocol, + installed_menu_conflicts: list[PluginMenuConflictItem], + extra_payload: Mapping[str, object] | None = None, + ) -> PluginLifecyclePayloadDict: + """ + 构建已安装菜单冲突阻断负载。 + + :param plugin_id: 插件ID + :param message: 阻断提示 + :param actions: 动作清单 + :param precheck: 插件操作预检上下文 + :param installed_menu_conflicts: 已安装菜单冲突列表 + :param extra_payload: 额外负载 + :return: 已安装菜单冲突阻断负载 + """ + menu_conflicts = [ + *precheck.menu_conflicts, + *[PluginPayloadBuilder.build_menu_conflict_item(item) for item in installed_menu_conflicts], + ] + return _lifecycle_payload( + { + 'ok': False, + 'message': message, + 'pluginId': plugin_id, + 'dryRun': False, + **(extra_payload or {}), + 'actions': actions, + **precheck.operation_payload, + 'menuConflicts': menu_conflicts, + 'menuConflictOk': False, + } + ) + + @staticmethod + def build_upgrade_latest_payload( + plugin_id: str, + version_state: VersionStatePayload, + precheck: PluginLifecyclePrecheckProtocol, + ) -> PluginLifecyclePayloadDict: + """ + 构建插件无需升级负载。 + + :param plugin_id: 插件ID + :param version_state: 插件升级版本状态 + :param precheck: 插件操作预检上下文 + :return: 插件无需升级负载 + """ + return _lifecycle_payload( + { + 'ok': True, + 'message': '插件已是最新版本,无需升级', + 'pluginId': plugin_id, + 'dryRun': False, + **version_state, + 'actions': [], + **precheck.operation_payload, + } + ) + + @staticmethod + def build_success_payload( + plugin_id: str, + *, + message: str, + actions: list[ActionPayload], + precheck: PluginLifecyclePrecheckProtocol, + plugin: SupportsModelDump, + installed_configs: list[SupportsModelDump], + migration_results: list[PluginMigrationResult], + seed_results: list[PluginSeedResult], + hook_result: PluginHookResult | None, + extra_payload: Mapping[str, object] | None = None, + ) -> PluginLifecyclePayloadDict: + """ + 构建插件安装或升级成功负载。 + + :param plugin_id: 插件ID + :param message: 成功提示 + :param actions: 动作清单 + :param precheck: 插件操作预检上下文 + :param plugin: 插件数据库模型 + :param installed_configs: 已安装配置列表 + :param migration_results: migration 执行结果列表 + :param seed_results: seed 执行结果列表 + :param hook_result: 生命周期钩子执行结果 + :param extra_payload: 额外负载 + :return: 插件安装或升级成功负载 + """ + return _lifecycle_payload( + { + 'ok': True, + 'message': message, + 'pluginId': plugin_id, + 'dryRun': False, + **(extra_payload or {}), + 'actions': actions, + **precheck.operation_payload, + 'plugin': cast('dict[str, object]', plugin.model_dump(by_alias=True)), + 'configs': [ + cast('dict[str, object]', config.model_dump(by_alias=True)) for config in installed_configs + ], + 'migrations': [_object_payload(migration_result) for migration_result in migration_results], + 'seeds': [_object_payload(seed_result) for seed_result in seed_results], + 'hooks': [_object_payload(hook_result)] if hook_result else [], + } + ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/plan.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/plan.py new file mode 100644 index 0000000..ae69960 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/plan.py @@ -0,0 +1,646 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, TypeAlias, cast + +from pydantic import Field + +from plugins.core.manifest.menu_tree import PluginMenuTree +from plugins.core.validation.versioning import PluginVersionComparator + +from .base import PluginPayloadModel + +if TYPE_CHECKING: + from subprocess import CompletedProcess + + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.lifecycle.purge import PluginPurgePlan, PluginPurgePlanItem + from plugins.core.validation.dependencies import DependencyCheckResult, DependencyInstallPlanItem + from plugins.core.validation.plugin_deps import ( + PluginDependencyCheckResult, + PluginDependencyPlan, + PluginDependencyPlanBlocker, + PluginDependencyPlanItem, + ) + from plugins.core.validation.structure import PluginStructureCheckResult + + +class SupportsOk(Protocol): + """ + 支持 ok 属性的检查结果协议。 + """ + + ok: bool + + +class PluginPlanBlockerPayload(PluginPayloadModel): + """ + 插件批量操作计划阻塞项 payload。 + """ + + plugin_id: str = Field(alias='pluginId') + dependency_id: str = Field(alias='dependencyId') + status: str + message: str + + +class PluginPlanItemPayload(PluginPayloadModel): + """ + 插件批量操作计划项 payload。 + """ + + plugin_id: str = Field(alias='pluginId') + name: str + version: str + operation: str + order: int + requested: bool + dependencies: list[str] + installed_version: str | None = Field(alias='installedVersion') + enabled: str | None + status: str | None + ready: bool + blockers: list[dict[str, object]] + + +class PluginPlanPayload(PluginPayloadModel): + """ + 插件批量操作拓扑计划 payload。 + """ + + operation: str + ok: bool + requested_plugin_ids: list[str] = Field(alias='requestedPluginIds') + ordered_plugin_ids: list[str] = Field(alias='orderedPluginIds') + items: list[dict[str, object]] + blockers: list[dict[str, object]] + blocker_count: int = Field(alias='blockerCount') + + +class PluginPlanResponsePayload(PluginPayloadModel): + """ + 插件批量操作计划响应 payload。 + """ + + ok: bool + message: str + operation: str + database_available: bool = Field(alias='databaseAvailable') + database_error: str | None = Field(alias='databaseError') + plan: dict[str, object] + + +class DependencyInstallPlanItemPayload(PluginPayloadModel): + """ + 依赖安装计划项 payload。 + """ + + kind: str + requirement: str + name: str + command: list[str] + command_text: str = Field(alias='commandText') + workdir: str + reason: str + status: str + + +class DependencyInstallResultPayload(PluginPayloadModel): + """ + 依赖安装执行结果 payload。 + """ + + kind: str + requirement: str + name: str + command: list[str] + command_text: str = Field(alias='commandText') + workdir: str + return_code: int = Field(alias='returnCode') + stdout: str + stderr: str + + +class CommandResultPayload(PluginPayloadModel): + """ + 系统命令执行结果 payload。 + """ + + return_code: int = Field(alias='returnCode') + stdout: str + stderr: str + + +class PurgePlanItemPayload(PluginPayloadModel): + """ + 插件物理清理计划项 payload。 + """ + + name: str + label: str + enabled: bool + destructive: bool + count: int | None + target: str | None + + +class PurgePlanPayload(PluginPayloadModel): + """ + 插件物理清理计划 payload。 + """ + + plugin_id: str = Field(alias='pluginId') + removes_source: bool = Field(alias='removesSource') + requires_hook: bool = Field(alias='requiresHook') + destructive_count: int = Field(alias='destructiveCount') + items: list[dict[str, object]] + + +class ActionPayload(PluginPayloadModel): + """ + 插件操作动作项 payload。 + """ + + name: str | None = None + label: str | None = None + enabled: bool | None = None + count: int | None = None + ok: bool | None = None + hook: str | None = None + target_enabled: bool | None = Field(default=None, alias='targetEnabled') + target_status: str | None = Field(default=None, alias='targetStatus') + + +class VersionStatePayload(PluginPayloadModel): + """ + 插件升级版本状态 payload。 + """ + + installed: bool + installed_version: str | None = Field(alias='installedVersion') + current_version: str = Field(alias='currentVersion') + needs_upgrade: bool = Field(alias='needsUpgrade') + + +UpgradeDryRunPayloadContext: TypeAlias = dict[str, object] + + +class UpgradeDryRunPayload(PluginPayloadModel): + """ + 插件升级 dry-run payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + dry_run: bool = Field(alias='dryRun') + installed: bool + installed_version: str | None = Field(alias='installedVersion') + current_version: str = Field(alias='currentVersion') + needs_upgrade: bool = Field(alias='needsUpgrade') + database_available: bool = Field(alias='databaseAvailable') + database_error: str | None = Field(alias='databaseError') + dependency_ok: bool = Field(alias='dependencyOk') + manifest_ok: bool = Field(alias='manifestOk') + plugin_dependency_ok: bool = Field(alias='pluginDependencyOk') + structure_ok: bool = Field(alias='structureOk') + menu_conflict_ok: bool = Field(alias='menuConflictOk') + actions: list[dict[str, object]] + manifest_issues: list[dict[str, object]] = Field(alias='manifestIssues') + manifest_warnings: list[dict[str, object]] = Field(alias='manifestWarnings') + plugin_dependency_errors: list[dict[str, object]] = Field(alias='pluginDependencyErrors') + structure_errors: list[dict[str, object]] = Field(alias='structureErrors') + menu_conflicts: list[dict[str, object]] = Field(alias='menuConflicts') + dependencies: list[dict[str, object]] + plugin_dependencies: list[dict[str, object]] = Field(alias='pluginDependencies') + + +PluginPlanPayloadDict: TypeAlias = dict[str, object] +UpgradeDryRunPayloadDict: TypeAlias = dict[str, object] + + +class PluginPlanPayloadMixin: + """ + 插件依赖拓扑、执行动作和物理清理计划负载构建能力。 + """ + + @staticmethod + def build_plugin_plan_blocker(item: PluginDependencyPlanBlocker) -> PluginPlanBlockerPayload: + """ + 构建插件批量操作计划阻塞项负载。 + + :param item: 插件批量操作计划阻塞项 + :return: 插件批量操作计划阻塞项负载 + """ + return PluginPlanBlockerPayload( + plugin_id=item.plugin_id, + dependency_id=item.dependency_id, + status=item.status, + message=item.message, + ).to_payload() + + @classmethod + def build_plugin_plan_item(cls, item: PluginDependencyPlanItem) -> PluginPlanItemPayload: + """ + 构建插件批量操作计划项负载。 + + :param item: 插件批量操作计划项 + :return: 插件批量操作计划项负载 + """ + return PluginPlanItemPayload( + plugin_id=item.plugin_id, + name=item.name, + version=item.version, + operation=item.operation, + order=item.order, + requested=item.requested, + dependencies=item.dependencies, + installed_version=item.installed_version, + enabled=item.enabled, + status=item.status, + ready=item.ready, + blockers=[cls.build_plugin_plan_blocker(blocker) for blocker in item.blockers], + ).to_payload() + + @classmethod + def build_plugin_plan(cls, plan: PluginDependencyPlan) -> PluginPlanPayloadDict: + """ + 构建插件批量操作拓扑计划负载。 + + :param plan: 插件批量操作拓扑计划 + :return: 插件批量操作拓扑计划负载 + """ + return PluginPlanPayload( + operation=plan.operation, + ok=plan.ok, + requested_plugin_ids=plan.requested_plugin_ids, + ordered_plugin_ids=plan.ordered_plugin_ids, + items=[cls.build_plugin_plan_item(item) for item in plan.items], + blockers=[cls.build_plugin_plan_blocker(blocker) for blocker in plan.blockers], + blocker_count=len(plan.blockers), + ).to_payload() + + @classmethod + def build_plan_payload( + cls, + plan: PluginDependencyPlan, + database_error: str | None = None, + ) -> PluginPlanResponsePayload: + """ + 构建插件批量操作计划响应负载。 + + :param plan: 插件批量操作拓扑计划 + :param database_error: 数据库状态读取错误 + :return: 插件批量操作计划响应负载 + """ + return PluginPlanResponsePayload( + ok=plan.ok, + message='插件批量操作计划生成完成' if plan.ok else '插件批量操作计划存在阻塞项', + operation=plan.operation, + database_available=database_error is None, + database_error=database_error, + plan=cls.build_plugin_plan(plan), + ).to_payload() + + @staticmethod + def build_dependency_install_plan_item(item: DependencyInstallPlanItem) -> DependencyInstallPlanItemPayload: + """ + 构建依赖安装计划项负载。 + + :param item: 依赖安装计划项 + :return: 依赖安装计划项负载 + """ + return DependencyInstallPlanItemPayload( + kind=item.kind, + requirement=item.requirement, + name=item.name, + command=item.command, + command_text=' '.join(item.command), + workdir=item.workdir, + reason=item.reason, + status='planned', + ).to_payload() + + @staticmethod + def build_dependency_install_result( + item: DependencyInstallPlanItem, + completed: CompletedProcess[str], + ) -> DependencyInstallResultPayload: + """ + 构建依赖安装执行结果负载。 + + :param item: 依赖安装计划项 + :param completed: 命令执行结果 + :return: 依赖安装执行结果 + """ + return DependencyInstallResultPayload( + kind=item.kind, + requirement=item.requirement, + name=item.name, + command=item.command, + command_text=' '.join(item.command), + workdir=item.workdir, + return_code=completed.returncode, + stdout=completed.stdout[-2000:], + stderr=completed.stderr[-2000:], + ).to_payload() + + @staticmethod + def build_command_result(completed: CompletedProcess[str]) -> CommandResultPayload: + """ + 构建通用系统命令执行结果负载。 + + :param completed: 命令执行结果 + :return: 系统命令执行结果负载 + """ + return CommandResultPayload( + return_code=completed.returncode, + stdout=completed.stdout[-4000:] if completed.stdout else '', + stderr=completed.stderr[-4000:] if completed.stderr else '', + ).to_payload() + + @staticmethod + def build_purge_plan_item(item: PluginPurgePlanItem) -> PurgePlanItemPayload: + """ + 构建插件物理清理计划项负载。 + + :param item: 插件物理清理计划项 + :return: 插件物理清理计划项负载 + """ + return PurgePlanItemPayload( + name=item.name, + label=item.label, + enabled=item.enabled, + destructive=item.destructive, + count=item.count, + target=item.target, + ).to_payload() + + @classmethod + def build_purge_plan(cls, plan: PluginPurgePlan) -> PurgePlanPayload: + """ + 构建插件物理清理计划负载。 + + :param plan: 插件物理清理计划 + :return: 插件物理清理计划负载 + """ + return PurgePlanPayload( + plugin_id=plan.plugin_id, + removes_source=plan.removes_source, + requires_hook=plan.requires_hook, + destructive_count=plan.destructive_count, + items=[cls.build_purge_plan_item(item) for item in plan.items], + ).to_payload() + + @classmethod + def build_install_actions( + cls, + discovered_plugin: DiscoveredPlugin, + dependency_ok: bool, + plugin_dependency_ok: bool, + structure_ok: bool, + menu_conflict_ok: bool, + ) -> list[ActionPayload]: + """ + 构建插件安装动作计划。 + + :param discovered_plugin: 已发现插件 + :param dependency_ok: 依赖检查是否通过 + :param plugin_dependency_ok: 插件间依赖检查是否通过 + :param structure_ok: 结构检查是否通过 + :param menu_conflict_ok: 菜单冲突检查是否通过 + :return: 安装动作计划 + """ + manifest = discovered_plugin.manifest + return [ + {'name': 'upsert_plugin', 'label': '写入或更新插件状态', 'enabled': True}, + { + 'name': 'install_menus', + 'label': '幂等写入菜单和权限', + 'enabled': bool(manifest.frontend.menus), + 'count': PluginMenuTree.count(manifest.frontend.menus), + }, + { + 'name': 'install_configs', + 'label': '写入默认插件配置', + 'enabled': bool(manifest.config.items), + 'count': len(manifest.config.items), + }, + {'name': 'check_dependencies', 'label': '检查依赖声明', 'enabled': True, 'ok': dependency_ok}, + { + 'name': 'check_plugin_dependencies', + 'label': '检查插件间依赖', + 'enabled': bool(manifest.dependencies.plugins), + 'ok': plugin_dependency_ok, + 'count': len(manifest.dependencies.plugins), + }, + {'name': 'check_structure', 'label': '检查插件结构', 'enabled': True, 'ok': structure_ok}, + { + 'name': 'check_menu_conflicts', + 'label': '检查菜单和权限冲突', + 'enabled': bool(manifest.frontend.menus), + 'ok': menu_conflict_ok, + }, + { + 'name': 'run_migrations', + 'label': '执行 migration 脚本', + 'enabled': bool(manifest.backend.migrations), + 'count': len(manifest.backend.migrations), + }, + { + 'name': 'run_seeds', + 'label': '执行 seed 脚本', + 'enabled': bool(manifest.backend.seeds), + 'count': len(manifest.backend.seeds), + }, + { + 'name': 'run_install_hook', + 'label': '执行安装生命周期钩子', + 'enabled': bool(manifest.backend.hooks.on_install), + 'hook': manifest.backend.hooks.on_install, + }, + ] + + @classmethod + def build_upgrade_actions( + cls, + discovered_plugin: DiscoveredPlugin, + dependency_ok: bool, + plugin_dependency_ok: bool, + structure_ok: bool, + menu_conflict_ok: bool, + ) -> list[ActionPayload]: + """ + 构建插件升级动作计划。 + + :param discovered_plugin: 已发现插件 + :param dependency_ok: 依赖检查是否通过 + :param plugin_dependency_ok: 插件间依赖检查是否通过 + :param structure_ok: 结构检查是否通过 + :param menu_conflict_ok: 菜单冲突检查是否通过 + :return: 升级动作计划 + """ + manifest = discovered_plugin.manifest + return [ + {'name': 'check_installed_version', 'label': '检查已安装版本', 'enabled': True}, + {'name': 'upsert_plugin', 'label': '刷新插件元数据', 'enabled': True}, + { + 'name': 'install_menus', + 'label': '幂等更新菜单和权限', + 'enabled': bool(manifest.frontend.menus), + 'count': PluginMenuTree.count(manifest.frontend.menus), + }, + { + 'name': 'install_configs', + 'label': '刷新默认插件配置', + 'enabled': bool(manifest.config.items), + 'count': len(manifest.config.items), + }, + {'name': 'check_dependencies', 'label': '检查依赖声明', 'enabled': True, 'ok': dependency_ok}, + { + 'name': 'check_plugin_dependencies', + 'label': '检查插件间依赖', + 'enabled': bool(manifest.dependencies.plugins), + 'ok': plugin_dependency_ok, + 'count': len(manifest.dependencies.plugins), + }, + {'name': 'check_structure', 'label': '检查插件结构', 'enabled': True, 'ok': structure_ok}, + { + 'name': 'check_menu_conflicts', + 'label': '检查菜单和权限冲突', + 'enabled': bool(manifest.frontend.menus), + 'ok': menu_conflict_ok, + }, + { + 'name': 'run_migrations', + 'label': '执行 migration 脚本', + 'enabled': bool(manifest.backend.migrations), + 'count': len(manifest.backend.migrations), + }, + { + 'name': 'run_seeds', + 'label': '执行 seed 脚本', + 'enabled': bool(manifest.backend.seeds), + 'count': len(manifest.backend.seeds), + }, + { + 'name': 'run_upgrade_hook', + 'label': '执行升级生命周期钩子', + 'enabled': bool(manifest.backend.hooks.on_upgrade), + 'hook': manifest.backend.hooks.on_upgrade, + }, + {'name': 'mark_installed', 'label': '更新已安装版本', 'enabled': True}, + ] + + @staticmethod + def build_upgrade_version_state( + discovered_plugin: DiscoveredPlugin, + database_plugin: object | None, + ) -> VersionStatePayload: + """ + 构建插件升级版本状态。 + + :param discovered_plugin: 已发现插件 + :param database_plugin: 数据库插件状态 + :return: 升级版本状态 + """ + installed_version = getattr(database_plugin, 'installed_version', None) + current_version = discovered_plugin.manifest.version + return VersionStatePayload( + installed=database_plugin is not None and bool(installed_version), + installed_version=installed_version, + current_version=current_version, + needs_upgrade=PluginVersionComparator.is_upgrade_available(installed_version, current_version), + ).to_payload() + + @classmethod + def build_upgrade_dry_run_payload( + cls, + plugin_id: str, + payload_context: UpgradeDryRunPayloadContext, + database_error: str | None = None, + ) -> UpgradeDryRunPayloadDict: + """ + 构建插件升级 dry-run 负载。 + + :param plugin_id: 插件ID + :param payload_context: dry-run 负载上下文 + :param database_error: 数据库状态读取错误 + :return: 插件升级 dry-run 负载 + """ + version_state = payload_context['versionState'] + dependency_result = cast('DependencyCheckResult', payload_context['dependencyResult']) + plugin_dependency_result = cast('PluginDependencyCheckResult', payload_context['pluginDependencyResult']) + structure_result = cast('PluginStructureCheckResult', payload_context['structureResult']) + menu_conflict_result = cast('SupportsOk', payload_context['menuConflictResult']) + return UpgradeDryRunPayload.model_validate( + { + 'ok': True, + 'message': '插件升级演练完成,未执行实际写入', + 'pluginId': plugin_id, + 'dryRun': True, + **version_state, + 'databaseAvailable': database_error is None, + 'databaseError': database_error, + 'dependencyOk': dependency_result.ok, + 'manifestOk': payload_context['manifestOk'], + 'pluginDependencyOk': plugin_dependency_result.ok, + 'structureOk': structure_result.ok, + 'menuConflictOk': menu_conflict_result.ok, + 'actions': payload_context['actions'], + 'manifestIssues': payload_context['manifestIssues'], + 'manifestWarnings': payload_context['manifestWarnings'], + 'pluginDependencyErrors': payload_context['pluginDependencyErrors'], + 'structureErrors': payload_context['structureErrors'], + 'menuConflicts': payload_context['menuConflicts'], + 'dependencies': [cls.build_dependency_item(item) for item in dependency_result.items], + 'pluginDependencies': [ + cls.build_plugin_dependency_item(item) for item in plugin_dependency_result.items + ], + } + ).to_payload() + + @staticmethod + def build_enabled_actions(enabled: bool, plugin_dependency_ok: bool = True) -> list[ActionPayload]: + """ + 构建插件启停动作计划。 + + :param enabled: 是否启用 + :param plugin_dependency_ok: 插件间依赖检查是否通过 + :return: 插件启停动作计划 + """ + actions = [ + { + 'name': 'update_plugin_enabled', + 'label': '更新插件启停状态', + 'enabled': True, + 'targetEnabled': enabled, + }, + { + 'name': 'update_plugin_menu_status', + 'label': '更新插件菜单状态', + 'enabled': True, + 'targetStatus': '0' if enabled else '1', + }, + ] + if enabled: + actions.insert( + 0, + { + 'name': 'check_plugin_dependencies', + 'label': '检查插件间依赖', + 'enabled': True, + 'ok': plugin_dependency_ok, + }, + ) + elif not plugin_dependency_ok: + actions.insert( + 0, + { + 'name': 'check_plugin_dependents', + 'label': '检查被依赖关系', + 'enabled': True, + 'ok': False, + }, + ) + + return actions diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/purge.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/purge.py new file mode 100644 index 0000000..aef5324 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/purge.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeAlias + +from pydantic import Field + +from . import PluginPayloadBuilder +from .base import PluginPayloadModel + +if TYPE_CHECKING: + from plugins.core.lifecycle.purge import PluginPurgePlan + + +class PluginPurgeStatePayload(PluginPayloadModel): + """ + 插件物理清理状态 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + operation: str + dry_run: bool = Field(alias='dryRun') + safe_mode: bool = Field(alias='safeMode') + removes_source: bool = Field(alias='removesSource') + plan: dict[str, object] + hooks: list[dict[str, object]] | None = None + + +PluginPurgeStatePayloadDict: TypeAlias = dict[str, object] + + +class PluginPurgePayloadBuilder: + """ + 插件物理清理负载构建器。 + + 使用 Builder 模式集中 purge dry-run 和执行成功负载。 + """ + + @staticmethod + def build_dry_run_payload(plugin_id: str, plan: PluginPurgePlan) -> PluginPurgeStatePayloadDict: + """ + 构建插件物理清理预演负载。 + + :param plugin_id: 插件ID + :param plan: 插件物理清理计划 + :return: 插件物理清理预演负载 + """ + return PluginPurgePayloadBuilder._build_state_payload( + plugin_id=plugin_id, + plan=plan, + dry_run=True, + message='插件物理清理演练完成,未执行实际删除', + ) + + @staticmethod + def build_success_payload( + plugin_id: str, + plan: PluginPurgePlan, + hook_result: object | None, + ) -> PluginPurgeStatePayloadDict: + """ + 构建插件物理清理成功负载。 + + :param plugin_id: 插件ID + :param plan: 插件物理清理计划 + :param hook_result: 清理钩子执行结果 + :return: 插件物理清理成功负载 + """ + return PluginPurgePayloadBuilder._build_state_payload( + plugin_id=plugin_id, + plan=plan, + dry_run=False, + message='插件物理清理完成', + hook_result=hook_result, + ) + + @staticmethod + def _build_state_payload( + *, + plugin_id: str, + plan: PluginPurgePlan, + dry_run: bool, + message: str, + hook_result: object | None = None, + ) -> PluginPurgeStatePayloadDict: + """ + 构建插件物理清理状态负载。 + + :param plugin_id: 插件ID + :param plan: 插件物理清理计划 + :param dry_run: 是否预演 + :param message: 响应消息 + :param hook_result: 清理钩子执行结果 + :return: 插件物理清理状态负载 + """ + payload: PluginPurgeStatePayloadDict = { + 'ok': True, + 'message': message, + 'pluginId': plugin_id, + 'operation': 'purge', + 'dryRun': dry_run, + 'safeMode': False, + 'removesSource': plan.removes_source, + 'plan': PluginPayloadBuilder.build_purge_plan(plan), + } + if not dry_run: + payload['hooks'] = [vars(hook_result)] if hook_result else [] + return PluginPurgeStatePayload.model_validate(payload).to_payload(exclude_none=True) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/runtime.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/runtime.py new file mode 100644 index 0000000..6295160 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/runtime.py @@ -0,0 +1,559 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, TypeAlias + +from pydantic import Field + +from utils.log_util import logger + +from . import PluginPayloadBuilder +from .base import PluginPayloadModel + +if TYPE_CHECKING: + from collections.abc import Mapping + + from plugins.core.discovery.scanner import DiscoveredPlugin + from plugins.core.lifecycle.purge import PluginPurgePlan + from plugins.core.types import JSONObject + from plugins.core.validation.plugin_deps import PluginBatchOperation + + from .catalog import PluginMenuDiagnosticPlanPayload + from .plan import ActionPayload, VersionStatePayload + + +class SupportsOk(Protocol): + """ + 支持 ok 属性的检查结果协议。 + """ + + ok: bool + + +class PluginRuntimePrecheckProtocol(Protocol): + """ + runtime payload 所需的预检上下文协议。 + """ + + ok: bool + dependency_result: SupportsOk + manifest_result: SupportsOk + plugin_dependency_result: SupportsOk + structure_result: SupportsOk + menu_conflict_result: SupportsOk + operation_payload: Mapping[str, object] + check_payload: Mapping[str, object] + + +class PluginHealthResultProtocol(Protocol): + """ + runtime 健康检查结果协议。 + """ + + plugin_id: str + ok: bool + status: str + message: str + checker: str | None + duration_ms: float + details: JSONObject + error: str | None + + +class PluginRuntimeDiagnosePayload(PluginPayloadModel): + """ + 插件诊断包 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + info: object + check: dict[str, object] + menu_plan: dict[str, object] = Field(alias='menuPlan') + config: dict[str, object] + audit: dict[str, object] + + +class PluginRuntimeHealthPayload(PluginPayloadModel): + """ + 插件健康检查 payload。 + """ + + plugin_id: str = Field(alias='pluginId') + ok: bool + status: str + message: str + checker: str | None + duration_ms: float = Field(alias='durationMs') + details: object + error: str | None + + +class PluginRuntimeHealthResponsePayload(PluginPayloadModel): + """ + 插件健康检查响应 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + health: dict[str, object] + + +class PluginRuntimePrecheckPayload(PluginPayloadModel): + """ + 插件操作预检 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + operation: str + database_available: bool = Field(alias='databaseAvailable') + database_error: str | None = Field(alias='databaseError') + purge_plan_error: str | None = Field(default=None, alias='purgePlanError') + installed: bool | None = None + installed_version: str | None = Field(default=None, alias='installedVersion') + current_version: str | None = Field(default=None, alias='currentVersion') + needs_upgrade: bool | None = Field(default=None, alias='needsUpgrade') + manifest_ok: bool | None = Field(default=None, alias='manifestOk') + dependency_ok: bool | None = Field(default=None, alias='dependencyOk') + plugin_dependency_ok: bool | None = Field(default=None, alias='pluginDependencyOk') + structure_ok: bool | None = Field(default=None, alias='structureOk') + menu_conflict_ok: bool | None = Field(default=None, alias='menuConflictOk') + manifest_issues: list[dict[str, object]] | None = Field(default=None, alias='manifestIssues') + manifest_warnings: list[dict[str, object]] | None = Field(default=None, alias='manifestWarnings') + plugin_dependency_errors: list[dict[str, object]] | None = Field(default=None, alias='pluginDependencyErrors') + structure_errors: list[dict[str, object]] | None = Field(default=None, alias='structureErrors') + menu_conflicts: list[dict[str, object]] | None = Field(default=None, alias='menuConflicts') + dependencies: list[dict[str, object]] | None = None + plugin_dependencies: list[dict[str, object]] | None = Field(default=None, alias='pluginDependencies') + actions: list[dict[str, object]] + precheck: dict[str, object] + plan: dict[str, object] | None = None + + +class PluginRuntimeExceptionPayload(PluginPayloadModel): + """ + 插件运行时异常 payload。 + """ + + ok: bool + message: str + error: str + plugin_id: str | None = Field(default=None, alias='pluginId') + failed_step: str | None = Field(default=None, alias='failedStep') + migration_recovery: object | None = Field(default=None, alias='migrationRecovery') + + +class PluginRuntimeInvalidOperationPayload(PluginPayloadModel): + """ + 插件非法操作 payload。 + """ + + ok: bool + message: str + operation: str + plugin_id: str | None = Field(default=None, alias='pluginId') + + +class PluginRuntimeBatchItemUnsupportedPayload(PluginPayloadModel): + """ + 插件批量单项不支持 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + + +class PluginRuntimeDiagnoseFailurePayload(PluginPayloadModel): + """ + 插件诊断包失败 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + info: dict[str, object] + + +class PluginRuntimeUpgradeBlockerPayload(PluginPayloadModel): + """ + 插件升级前置阻断 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + dry_run: bool = Field(alias='dryRun') + installed: bool + installed_version: str | None = Field(alias='installedVersion') + current_version: str = Field(alias='currentVersion') + needs_upgrade: bool = Field(alias='needsUpgrade') + manifest_ok: bool = Field(alias='manifestOk') + dependency_ok: bool = Field(alias='dependencyOk') + plugin_dependency_ok: bool | None = Field(default=None, alias='pluginDependencyOk') + structure_ok: bool | None = Field(default=None, alias='structureOk') + menu_conflict_ok: bool | None = Field(default=None, alias='menuConflictOk') + manifest_issues: list[dict[str, object]] | None = Field(default=None, alias='manifestIssues') + manifest_warnings: list[dict[str, object]] | None = Field(default=None, alias='manifestWarnings') + plugin_dependency_errors: list[dict[str, object]] | None = Field(default=None, alias='pluginDependencyErrors') + structure_errors: list[dict[str, object]] | None = Field(default=None, alias='structureErrors') + menu_conflicts: list[dict[str, object]] | None = Field(default=None, alias='menuConflicts') + dependencies: list[dict[str, object]] | None = None + plugin_dependencies: list[dict[str, object]] | None = Field(default=None, alias='pluginDependencies') + actions: list[dict[str, object]] + + +PluginRuntimeDiagnosePayloadDict: TypeAlias = dict[str, object] +PluginRuntimeHealthPayloadDict: TypeAlias = dict[str, object] +PluginRuntimeHealthResponsePayloadDict: TypeAlias = dict[str, object] +PluginRuntimePrecheckPayloadDict: TypeAlias = dict[str, object] +PluginRuntimeExceptionPayloadDict: TypeAlias = dict[str, object] +PluginRuntimeInvalidOperationPayloadDict: TypeAlias = dict[str, object] +PluginRuntimeBatchItemUnsupportedPayloadDict: TypeAlias = dict[str, object] +PluginRuntimeDiagnoseFailurePayloadDict: TypeAlias = dict[str, object] +PluginRuntimeUpgradeBlockerPayloadDict: TypeAlias = dict[str, object] + + +class PluginRuntimePayloadBuilder: + """ + 插件运行时通用负载构建器。 + """ + + @staticmethod + def build_exception_payload( + message: str, + error: Exception, + *, + plugin_id: str | None = None, + failed_step: str | None = None, + extra_payload: Mapping[str, object] | None = None, + ) -> PluginRuntimeExceptionPayloadDict: + """ + 构建运行时异常负载。 + + :param message: 异常场景提示 + :param error: 异常对象 + :param plugin_id: 插件ID + :param failed_step: 失败生命周期步骤 + :param extra_payload: 额外结构化负载 + :return: 运行时异常负载 + """ + logger.exception(f'{message}:{error}') + return PluginRuntimeExceptionPayload( + ok=False, + message=message, + error=str(error), + plugin_id=plugin_id, + failed_step=failed_step, + **(extra_payload or {}), + ).to_payload(exclude_none=True) + + @staticmethod + def build_invalid_operation_payload( + plugin_id: str | None, + operation: str, + *, + message: str, + ) -> PluginRuntimeInvalidOperationPayloadDict: + """ + 构建非法操作负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param message: 错误提示 + :return: 非法操作负载 + """ + return PluginRuntimeInvalidOperationPayload( + ok=False, + message=message, + operation=operation, + plugin_id=plugin_id, + ).to_payload(exclude_none=True) + + @staticmethod + def build_health_response_payload( + plugin_id: str, health_result: PluginHealthResultProtocol + ) -> PluginRuntimeHealthResponsePayloadDict: + """ + 构建插件健康检查响应负载。 + + :param plugin_id: 插件ID + :param health_result: 插件健康检查结果 + :return: 插件健康检查响应负载 + """ + return PluginRuntimeHealthResponsePayload( + ok=health_result.ok, + message=health_result.message, + plugin_id=plugin_id, + health=PluginRuntimePayloadBuilder.build_health_payload(health_result), + ).to_payload() + + @staticmethod + def build_batch_item_unsupported_payload( + operation: PluginBatchOperation, plugin_id: str + ) -> PluginRuntimeBatchItemUnsupportedPayloadDict: + """ + 构建批量单项不支持负载。 + + :param operation: 批量操作类型 + :param plugin_id: 插件ID + :return: 批量单项不支持负载 + """ + return PluginRuntimeBatchItemUnsupportedPayload( + ok=False, + message=f'插件批量操作不支持:{operation}', + plugin_id=plugin_id, + ).to_payload() + + @staticmethod + def build_diagnose_failure_payload( + plugin_id: str, info_payload: Mapping[str, object] + ) -> PluginRuntimeDiagnoseFailurePayloadDict: + """ + 构建插件诊断包失败负载。 + + :param plugin_id: 插件ID + :param info_payload: 插件详情负载 + :return: 插件诊断包失败负载 + """ + return PluginRuntimeDiagnoseFailurePayload( + ok=False, + message='插件诊断包生成失败', + plugin_id=plugin_id, + info=dict(info_payload), + ).to_payload() + + @staticmethod + def build_empty_menu_plan() -> PluginMenuDiagnosticPlanPayload: + """ + 构建空插件菜单诊断计划。 + + :return: 空插件菜单诊断计划 + """ + return {'total': 0, 'permissionCount': 0, 'enabledCount': 0, 'visibleCount': 0, 'items': []} + + @staticmethod + def build_diagnose_payload( + plugin_id: str, + *, + info_payload: Mapping[str, object], + check_payload: Mapping[str, object], + menu_plan: PluginMenuDiagnosticPlanPayload, + config_payload: Mapping[str, object], + audit_payload: Mapping[str, object], + ) -> PluginRuntimeDiagnosePayloadDict: + """ + 构建插件诊断包负载。 + + :param plugin_id: 插件ID + :param info_payload: 插件详情负载 + :param check_payload: 插件检查负载 + :param menu_plan: 菜单诊断计划 + :param config_payload: 配置诊断负载 + :param audit_payload: 最近审计负载 + :return: 插件诊断包负载 + """ + ok = bool(info_payload.get('ok')) and bool(check_payload.get('ok')) and bool(config_payload.get('ok')) + return PluginRuntimeDiagnosePayload( + ok=ok, + message='插件诊断包生成完成' if ok else '插件诊断包生成完成,发现问题', + plugin_id=plugin_id, + info=info_payload.get('plugin'), + check=dict(check_payload), + menu_plan=dict(menu_plan), + config=dict(config_payload), + audit=dict(audit_payload), + ).to_payload() + + @staticmethod + def build_precheck_payload( + plugin_id: str, + operation: PluginBatchOperation, + *, + precheck: PluginRuntimePrecheckProtocol, + version_state: VersionStatePayload, + actions: list[ActionPayload], + database_error: str | None, + purge_plan: PluginPurgePlan | None = None, + purge_plan_error: str | None = None, + ) -> PluginRuntimePrecheckPayloadDict: + """ + 构建插件操作预检负载。 + + :param plugin_id: 插件ID + :param operation: 操作类型 + :param precheck: 插件操作预检上下文 + :param version_state: 插件升级版本状态 + :param actions: 操作动作清单 + :param database_error: 数据库状态读取错误信息 + :param purge_plan: 插件物理清理计划 + :param purge_plan_error: 插件物理清理计划构建错误 + :return: 插件操作预检负载 + """ + check_payload = dict(precheck.check_payload) + if purge_plan_error: + check_payload['warnings'] = [ + *[str(warning) for warning in check_payload.get('warnings', [])], + f'插件物理清理计划构建失败:{purge_plan_error}', + ] + payload: PluginRuntimePrecheckPayloadDict = { + 'ok': precheck.ok and purge_plan_error is None, + 'message': '插件操作预检通过' if precheck.ok and purge_plan_error is None else '插件操作预检存在问题', + 'pluginId': plugin_id, + 'operation': operation, + 'databaseAvailable': database_error is None, + 'databaseError': database_error, + 'purgePlanError': purge_plan_error, + **version_state, + 'actions': actions, + **precheck.operation_payload, + 'precheck': check_payload, + } + if purge_plan: + payload['plan'] = PluginPayloadBuilder.build_purge_plan(purge_plan) + + result = PluginRuntimePrecheckPayload.model_validate(payload).to_payload() + if purge_plan_error is None: + result.pop('purgePlanError', None) + return result + + @staticmethod + def build_health_payload(health_result: PluginHealthResultProtocol) -> PluginRuntimeHealthPayloadDict: + """ + 构建插件健康检查负载。 + + :param health_result: 插件健康检查结果 + :return: 插件健康检查负载 + """ + return PluginRuntimeHealthPayload( + plugin_id=health_result.plugin_id, + ok=health_result.ok, + status=health_result.status, + message=health_result.message, + checker=health_result.checker, + duration_ms=health_result.duration_ms, + details=health_result.details, + error=health_result.error, + ).to_payload() + + @staticmethod + def build_failure_state_message(payload: Mapping[str, object], default_message: str) -> str: + """ + 构建插件失败状态错误信息。 + + :param payload: 插件操作返回负载 + :param default_message: 缺省失败信息 + :return: 失败状态错误信息 + """ + message = payload.get('message') or default_message + error = payload.get('error') + if error: + return f'{message}:{error}'[:1000] + + return str(message)[:1000] + + @staticmethod + def build_precheck_actions( + operation: PluginBatchOperation, + discovered_plugin: DiscoveredPlugin, + precheck: PluginRuntimePrecheckProtocol, + ) -> list[ActionPayload]: + """ + 构建插件操作预检动作清单。 + + :param operation: 操作类型 + :param discovered_plugin: 已发现插件 + :param precheck: 插件操作预检上下文 + :return: 动作清单 + """ + if operation == 'install': + return PluginPayloadBuilder.build_install_actions( + discovered_plugin, + precheck.dependency_result.ok, + precheck.plugin_dependency_result.ok, + precheck.structure_result.ok, + precheck.menu_conflict_result.ok, + ) + if operation == 'upgrade': + return PluginPayloadBuilder.build_upgrade_actions( + discovered_plugin, + precheck.dependency_result.ok, + precheck.plugin_dependency_result.ok, + precheck.structure_result.ok, + precheck.menu_conflict_result.ok, + ) + if operation == 'enable': + return PluginPayloadBuilder.build_enabled_actions(True, precheck.plugin_dependency_result.ok) + if operation == 'uninstall': + return PluginPayloadBuilder.build_enabled_actions(False, True) + + return [{'name': 'build_purge_plan', 'label': '生成插件物理清理计划', 'enabled': True}] + + @staticmethod + def build_upgrade_pre_execution_blocker( + plugin_id: str, + version_state: VersionStatePayload, + actions: list[ActionPayload], + precheck: PluginRuntimePrecheckProtocol, + ) -> PluginRuntimeUpgradeBlockerPayloadDict | None: + """ + 构建插件升级前置阻断负载。 + + :param plugin_id: 插件ID + :param version_state: 插件升级版本状态 + :param actions: 升级动作计划 + :param precheck: 插件操作预检上下文 + :return: 阻断负载,不需要阻断时返回 None + """ + if not version_state['installed']: + return PluginRuntimePayloadBuilder._build_upgrade_blocker_payload( + plugin_id=plugin_id, + message='插件尚未安装,升级已中止', + version_state=version_state, + actions=actions, + precheck=precheck, + ) + if not precheck.manifest_result.ok: + return PluginRuntimePayloadBuilder._build_upgrade_blocker_payload( + plugin_id=plugin_id, + message='插件 manifest 检查失败,升级已中止', + version_state=version_state, + actions=actions, + precheck=precheck, + ) + + return None + + @staticmethod + def _build_upgrade_blocker_payload( + *, + plugin_id: str, + message: str, + version_state: VersionStatePayload, + actions: list[ActionPayload], + precheck: PluginRuntimePrecheckProtocol, + ) -> PluginRuntimeUpgradeBlockerPayloadDict: + """ + 构建插件升级前置阻断负载。 + + :param plugin_id: 插件ID + :param message: 阻断提示 + :param version_state: 插件升级版本状态 + :param actions: 升级动作计划 + :param precheck: 插件操作预检上下文 + :return: 插件升级前置阻断负载 + """ + return PluginRuntimeUpgradeBlockerPayload.model_validate( + { + 'ok': False, + 'message': message, + 'pluginId': plugin_id, + 'dryRun': False, + **version_state, + 'actions': actions, + **precheck.operation_payload, + } + ).to_payload() diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/validation.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/validation.py new file mode 100644 index 0000000..e379106 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/validation.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol, TypeAlias + +from pydantic import Field + +from plugins.core.validation.result import PluginValidationLevelResolver, ValidationLevel + +from .base import PluginPayloadModel + +if TYPE_CHECKING: + from collections.abc import Mapping + + from plugins.core.validation.dependencies import DependencyCheckItem, DependencyCheckResult + from plugins.core.validation.menus import PluginMenuConflictItem + from plugins.core.validation.plugin_deps import PluginDependencyCheckItem + from plugins.core.validation.result import PluginValidationIssue + from plugins.core.validation.structure import PluginStructureCheckItem + + +class DependencyItemPayload(PluginPayloadModel): + """ + Python/npm 依赖检查项 payload。 + """ + + kind: str + requirement: str + name: str + installed: bool + version_satisfied: bool = Field(alias='versionSatisfied') + installed_version: str | None = Field(alias='installedVersion') + declared_version: str | None = Field(default=None, alias='declaredVersion') + required_version: str | None = Field(alias='requiredVersion') + ok: bool + status: str + level: ValidationLevel + message: str + + +class PluginDependencyItemPayload(PluginPayloadModel): + """ + 插件间依赖检查项 payload。 + """ + + plugin_id: str = Field(alias='pluginId') + dependency_id: str = Field(alias='dependencyId') + required_version: str | None = Field(alias='requiredVersion') + installed_version: str | None = Field(alias='installedVersion') + status: str + ok: bool + level: ValidationLevel + message: str + + +class ValidationIssuePayload(PluginPayloadModel): + """ + manifest 校验问题项 payload。 + """ + + level: ValidationLevel + category: str + kind: str + path: str + ok: bool + message: str + suggestion: str + + +class StructureItemPayload(PluginPayloadModel): + """ + 插件结构检查项 payload。 + """ + + kind: str + path: str + ok: bool + level: ValidationLevel + message: str + suggestion: str + + +class MenuConflictItemPayload(PluginPayloadModel): + """ + 菜单冲突检查项 payload。 + """ + + kind: str + plugin_id: str = Field(alias='pluginId') + conflict_plugin_id: str | None = Field(alias='conflictPluginId') + value: str + ok: bool + level: ValidationLevel + message: str + + +class PluginDependencyCheckPayload(PluginPayloadModel): + """ + 插件依赖检查 payload。 + """ + + ok: bool + message: str + plugin_id: str = Field(alias='pluginId') + dependency_ok: bool = Field(alias='dependencyOk') + dependencies: list[dict[str, object]] + missing_dependencies: list[str] = Field(alias='missingDependencies') + unsatisfied_dependencies: list[str] = Field(alias='unsatisfiedDependencies') + + +class PluginCheckItemPayload(PluginPayloadModel): + """ + 插件检查单项 payload。 + """ + + plugin_id: str = Field(alias='pluginId') + ok: bool + manifest_ok: bool | None = Field(default=None, alias='manifestOk') + dependency_ok: bool | None = Field(default=None, alias='dependencyOk') + plugin_dependency_ok: bool | None = Field(default=None, alias='pluginDependencyOk') + structure_ok: bool | None = Field(default=None, alias='structureOk') + menu_conflict_ok: bool | None = Field(default=None, alias='menuConflictOk') + dependencies: list[dict[str, object]] | None = None + plugin_dependencies: list[dict[str, object]] | None = Field(default=None, alias='pluginDependencies') + plugin_dependency_errors: list[dict[str, object]] | None = Field(default=None, alias='pluginDependencyErrors') + manifest_issues: list[dict[str, object]] | None = Field(default=None, alias='manifestIssues') + manifest_warnings: list[dict[str, object]] | None = Field(default=None, alias='manifestWarnings') + structure: list[dict[str, object]] | None = None + missing_dependencies: list[str] | None = Field(default=None, alias='missingDependencies') + unsatisfied_dependencies: list[str] | None = Field(default=None, alias='unsatisfiedDependencies') + structure_errors: list[dict[str, object]] | None = Field(default=None, alias='structureErrors') + menu_conflicts: list[dict[str, object]] | None = Field(default=None, alias='menuConflicts') + + +class PluginCheckPayload(PluginPayloadModel): + """ + 插件检查聚合 payload。 + """ + + ok: bool + message: str + count: int + database_available: bool = Field(alias='databaseAvailable') + database_error: str | None = Field(alias='databaseError') + checks: list[dict[str, object]] + + +DependencyItemPayloadDict: TypeAlias = dict[str, object] +PluginDependencyItemPayloadDict: TypeAlias = dict[str, object] +ValidationIssuePayloadDict: TypeAlias = dict[str, object] +StructureItemPayloadDict: TypeAlias = dict[str, object] +MenuConflictItemPayloadDict: TypeAlias = dict[str, object] +PluginDependencyCheckPayloadDict: TypeAlias = dict[str, object] +PluginCheckItemPayloadDict: TypeAlias = dict[str, object] +PluginCheckPayloadDict: TypeAlias = dict[str, object] + + +class PluginValidationPayloadBuilderProtocol(Protocol): + """ + 插件校验 payload builder 协议。 + """ + + @staticmethod + def build_dependency_item(item: DependencyCheckItem) -> DependencyItemPayloadDict: + """ + 构建依赖检查项负载。 + """ + ... + + @staticmethod + def build_plugin_dependency_item(item: PluginDependencyCheckItem) -> PluginDependencyItemPayloadDict: + """ + 构建插件间依赖检查项负载。 + """ + ... + + @staticmethod + def build_validation_issue(item: PluginValidationIssue) -> ValidationIssuePayloadDict: + """ + 构建统一校验问题项负载。 + """ + ... + + @staticmethod + def build_structure_item(item: PluginStructureCheckItem) -> StructureItemPayloadDict: + """ + 构建结构检查项负载。 + """ + ... + + @staticmethod + def build_menu_conflict_item(item: PluginMenuConflictItem) -> MenuConflictItemPayloadDict: + """ + 构建菜单冲突检查项负载。 + """ + ... + + +class PluginCheckPrecheckProtocol(Protocol): + """ + 插件检查单项所需的预检上下文协议。 + """ + + ok: bool + + @property + def check_payload(self) -> Mapping[str, object]: + """ + 获取插件检查命令通用负载片段。 + """ + ... + + +class PluginValidationPayloadMixin: + """ + 插件依赖、结构、manifest 和菜单冲突检查结果负载构建能力。 + """ + + @staticmethod + def build_dependency_item(item: DependencyCheckItem) -> DependencyItemPayloadDict: + """ + 构建依赖检查项负载。 + + :param item: 依赖检查项 + :return: 依赖检查项负载 + """ + return DependencyItemPayload( + kind=item.kind, + requirement=item.requirement, + name=item.name, + installed=item.installed, + version_satisfied=item.version_satisfied, + installed_version=item.installed_version, + declared_version=item.declared_version, + required_version=item.required_version, + ok=item.ok, + status=item.status, + level=PluginValidationLevelResolver.from_ok(item.ok), + message=item.message, + ).to_payload() + + @staticmethod + def build_plugin_dependency_item(item: PluginDependencyCheckItem) -> PluginDependencyItemPayloadDict: + """ + 构建插件间依赖检查项负载。 + + :param item: 插件间依赖检查项 + :return: 插件间依赖检查项负载 + """ + return PluginDependencyItemPayload( + plugin_id=item.plugin_id, + dependency_id=item.dependency_id, + required_version=item.required_version, + installed_version=item.installed_version, + status=item.status, + ok=item.ok, + level=PluginValidationLevelResolver.from_ok(item.ok), + message=item.message, + ).to_payload() + + @staticmethod + def build_validation_issue(item: PluginValidationIssue) -> ValidationIssuePayloadDict: + """ + 构建统一校验问题项负载。 + + :param item: 统一校验问题项 + :return: 统一校验问题项负载 + """ + return ValidationIssuePayload( + level=item.level, + category=item.category, + kind=item.kind, + path=item.path, + ok=item.ok, + message=item.message, + suggestion=item.suggestion, + ).to_payload() + + @staticmethod + def build_structure_item(item: PluginStructureCheckItem) -> StructureItemPayloadDict: + """ + 构建结构检查项负载。 + + :param item: 结构检查项 + :return: 结构检查项负载 + """ + return StructureItemPayload( + kind=item.kind, + path=item.path, + ok=item.ok, + level=item.level, + message=item.message, + suggestion=item.suggestion, + ).to_payload() + + @staticmethod + def build_menu_conflict_item(item: PluginMenuConflictItem) -> MenuConflictItemPayloadDict: + """ + 构建菜单冲突检查项负载。 + + :param item: 菜单冲突检查项 + :return: 菜单冲突检查项负载 + """ + return MenuConflictItemPayload( + kind=item.kind, + plugin_id=item.plugin_id, + conflict_plugin_id=item.conflict_plugin_id, + value=item.value, + ok=False, + level='error', + message=item.message, + ).to_payload() + + @classmethod + def build_dependency_check_payload( + cls, + plugin_id: str, + dependency_result: DependencyCheckResult, + ) -> PluginDependencyCheckPayloadDict: + """ + 构建插件依赖检查负载。 + + :param plugin_id: 插件ID + :param dependency_result: 依赖检查结果 + :return: 插件依赖检查负载 + """ + return PluginDependencyCheckPayload( + ok=dependency_result.ok, + message='插件依赖已满足' if dependency_result.ok else '插件依赖存在问题', + plugin_id=plugin_id, + dependency_ok=dependency_result.ok, + dependencies=[cls.build_dependency_item(item) for item in dependency_result.items], + missing_dependencies=[item.name for item in dependency_result.missing_items], + unsatisfied_dependencies=[item.name for item in dependency_result.unsatisfied_items], + ).to_payload() + + @staticmethod + def build_check_item(plugin_id: str, precheck: PluginCheckPrecheckProtocol) -> PluginCheckItemPayloadDict: + """ + 构建插件检查单项负载。 + + :param plugin_id: 插件ID + :param precheck: 插件操作预检上下文 + :return: 插件检查单项负载 + """ + return PluginCheckItemPayload.model_validate( + { + 'pluginId': plugin_id, + 'ok': precheck.ok, + **precheck.check_payload, + } + ).to_payload(exclude_none=True) + + @staticmethod + def build_check_payload( + checks: list[PluginCheckItemPayloadDict], + database_error: str | None = None, + ) -> PluginCheckPayloadDict: + """ + 构建插件检查聚合负载。 + + :param checks: 插件检查单项负载列表 + :param database_error: 数据库状态读取错误 + :return: 插件检查聚合负载 + """ + ok = all(check['ok'] for check in checks) + return PluginCheckPayload( + ok=ok, + message='插件检查通过' if ok else '插件检查存在问题', + count=len(checks), + database_available=database_error is None, + database_error=database_error, + checks=checks, + ).to_payload() diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/precheck.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/precheck.py new file mode 100644 index 0000000..6d79ab3 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/precheck.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, TypeAlias + +from pydantic import Field + +from plugins.core.runtime.support.payload.validation import ( + DependencyItemPayload, + MenuConflictItemPayload, + PluginDependencyItemPayload, + StructureItemPayload, + ValidationIssuePayload, +) + +from .payload import PluginPayloadBuilder +from .payload.base import PluginPayloadModel + +if TYPE_CHECKING: + from plugins.core.validation.dependencies import DependencyCheckResult + from plugins.core.validation.manifest import PluginManifestCheckResult + from plugins.core.validation.menus import PluginMenuConflictResult + from plugins.core.validation.plugin_deps import PluginDependencyCheckResult + from plugins.core.validation.structure import PluginStructureCheckResult + + +PrecheckOperationPayloadDict: TypeAlias = dict[ + str, + bool + | list[DependencyItemPayload] + | list[PluginDependencyItemPayload] + | list[ValidationIssuePayload] + | list[StructureItemPayload] + | list[MenuConflictItemPayload], +] +PrecheckCheckPayloadDict: TypeAlias = dict[ + str, + bool + | list[str] + | list[DependencyItemPayload] + | list[PluginDependencyItemPayload] + | list[ValidationIssuePayload] + | list[StructureItemPayload] + | list[MenuConflictItemPayload], +] + + +class PluginPrecheckOperationPayload(PluginPayloadModel): + """ + 插件预检操作片段 payload。 + """ + + manifest_ok: bool = Field(alias='manifestOk') + dependency_ok: bool = Field(alias='dependencyOk') + plugin_dependency_ok: bool = Field(alias='pluginDependencyOk') + structure_ok: bool = Field(alias='structureOk') + menu_conflict_ok: bool = Field(alias='menuConflictOk') + manifest_issues: list[dict[str, object]] = Field(alias='manifestIssues') + manifest_warnings: list[dict[str, object]] = Field(alias='manifestWarnings') + plugin_dependency_errors: list[dict[str, object]] = Field(alias='pluginDependencyErrors') + structure_errors: list[dict[str, object]] = Field(alias='structureErrors') + menu_conflicts: list[dict[str, object]] = Field(alias='menuConflicts') + dependencies: list[dict[str, object]] + plugin_dependencies: list[dict[str, object]] = Field(alias='pluginDependencies') + + +class PluginPrecheckCheckPayload(PluginPrecheckOperationPayload): + """ + 插件预检检查片段 payload。 + """ + + structure: list[dict[str, object]] + missing_dependencies: list[str] = Field(alias='missingDependencies') + unsatisfied_dependencies: list[str] = Field(alias='unsatisfiedDependencies') + + +@dataclass(frozen=True) +class PluginPrecheckContext: + """ + 插件操作预检上下文。 + + 使用 Context Object 模式聚合依赖、manifest、插件间依赖、结构和菜单冲突检查结果, + 避免插件运行时在安装、升级和检查流程中重复拼装 payload。 + + :param dependency_result: Python/npm 依赖检查结果 + :param manifest_result: manifest 非阻断检查结果 + :param plugin_dependency_result: 插件间依赖检查结果 + :param structure_result: 插件结构检查结果 + :param menu_conflict_result: 菜单冲突检查结果 + :param manifest_issues: manifest error 负载 + :param manifest_warnings: manifest warning 负载 + :param plugin_dependency_errors: 插件依赖错误负载 + :param structure_errors: 结构错误负载 + :param menu_conflicts: 菜单冲突负载 + :param dependencies: Python/npm 依赖检查负载 + :param plugin_dependencies: 插件依赖检查负载 + :param structure: 结构检查完整负载 + :param missing_dependencies: 缺失依赖名称列表 + :param unsatisfied_dependencies: 版本不满足依赖名称列表 + """ + + dependency_result: object + manifest_result: object + plugin_dependency_result: object + structure_result: object + menu_conflict_result: object + manifest_issues: list[dict[str, object]] + manifest_warnings: list[dict[str, object]] + plugin_dependency_errors: list[dict[str, object]] + structure_errors: list[dict[str, object]] + menu_conflicts: list[dict[str, object]] + dependencies: list[dict[str, object]] + plugin_dependencies: list[dict[str, object]] + structure: list[dict[str, object]] + missing_dependencies: list[str] + unsatisfied_dependencies: list[str] + + @classmethod + def build( + cls, + dependency_result: DependencyCheckResult, + manifest_result: PluginManifestCheckResult, + plugin_dependency_result: PluginDependencyCheckResult, + structure_result: PluginStructureCheckResult, + menu_conflict_result: PluginMenuConflictResult, + ) -> PluginPrecheckContext: + """ + 从各类检查结果构建预检上下文。 + + :param dependency_result: Python/npm 依赖检查结果 + :param manifest_result: manifest 非阻断检查结果 + :param plugin_dependency_result: 插件间依赖检查结果 + :param structure_result: 插件结构检查结果 + :param menu_conflict_result: 菜单冲突检查结果 + :return: 插件操作预检上下文 + """ + return cls( + dependency_result=dependency_result, + manifest_result=manifest_result, + plugin_dependency_result=plugin_dependency_result, + structure_result=structure_result, + menu_conflict_result=menu_conflict_result, + manifest_issues=[PluginPayloadBuilder.build_validation_issue(item) for item in manifest_result.issues], + manifest_warnings=[ + PluginPayloadBuilder.build_validation_issue(item) for item in manifest_result.warning_issues + ], + plugin_dependency_errors=[ + PluginPayloadBuilder.build_plugin_dependency_item(item) + for item in plugin_dependency_result.failed_items + ], + structure_errors=[ + PluginPayloadBuilder.build_structure_item(item) for item in structure_result.failed_items + ], + menu_conflicts=[PluginPayloadBuilder.build_menu_conflict_item(item) for item in menu_conflict_result.items], + dependencies=[PluginPayloadBuilder.build_dependency_item(item) for item in dependency_result.items], + plugin_dependencies=[ + PluginPayloadBuilder.build_plugin_dependency_item(item) for item in plugin_dependency_result.items + ], + structure=[PluginPayloadBuilder.build_structure_item(item) for item in structure_result.items], + missing_dependencies=[item.name for item in dependency_result.missing_items], + unsatisfied_dependencies=[item.name for item in dependency_result.unsatisfied_items], + ) + + @property + def ok(self) -> bool: + """ + 判断阻断性预检是否全部通过。 + + :return: 阻断性预检是否全部通过 + """ + return ( + self.dependency_result.ok + and self.manifest_result.ok + and self.plugin_dependency_result.ok + and self.structure_result.ok + and self.menu_conflict_result.ok + ) + + @property + def operation_payload(self) -> PrecheckOperationPayloadDict: + """ + 构建安装和升级操作通用负载片段。 + + :return: 安装和升级操作通用负载片段 + """ + return PluginPrecheckOperationPayload( + manifest_ok=self.manifest_result.ok, + dependency_ok=self.dependency_result.ok, + plugin_dependency_ok=self.plugin_dependency_result.ok, + structure_ok=self.structure_result.ok, + menu_conflict_ok=self.menu_conflict_result.ok, + manifest_issues=self.manifest_issues, + manifest_warnings=self.manifest_warnings, + plugin_dependency_errors=self.plugin_dependency_errors, + structure_errors=self.structure_errors, + menu_conflicts=self.menu_conflicts, + dependencies=self.dependencies, + plugin_dependencies=self.plugin_dependencies, + ).to_payload() + + @property + def check_payload(self) -> PrecheckCheckPayloadDict: + """ + 构建插件检查命令通用负载片段。 + + :return: 插件检查命令通用负载片段 + """ + return PluginPrecheckCheckPayload( + manifest_ok=self.manifest_result.ok, + dependency_ok=self.dependency_result.ok, + plugin_dependency_ok=self.plugin_dependency_result.ok, + structure_ok=self.structure_result.ok, + menu_conflict_ok=self.menu_conflict_result.ok, + dependencies=self.dependencies, + plugin_dependencies=self.plugin_dependencies, + plugin_dependency_errors=self.plugin_dependency_errors, + manifest_issues=self.manifest_issues, + manifest_warnings=self.manifest_warnings, + structure=self.structure, + missing_dependencies=self.missing_dependencies, + unsatisfied_dependencies=self.unsatisfied_dependencies, + structure_errors=self.structure_errors, + menu_conflicts=self.menu_conflicts, + ).to_payload() diff --git a/ruoyi-fastapi-backend/plugins/core/state.py b/ruoyi-fastapi-backend/plugins/core/state.py new file mode 100644 index 0000000..f84845f --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/state.py @@ -0,0 +1,211 @@ +from dataclasses import dataclass +from typing import Literal + +from plugins.core.validation.versioning import PluginVersionComparator + +PluginStatus = Literal['discovered', 'installed', 'pending_upgrade', 'error'] +PluginStateOperation = Literal[ + 'discover', + 'install', + 'disable', + 'enable', + 'upgrade_available', + 'upgrade', + 'mark_error', +] + + +@dataclass(frozen=True) +class PluginStateTransition: + """ + 插件状态流转规则。 + + :param source: 来源状态,None 表示数据库中尚无插件状态 + :param operation: 状态流转操作 + :param target: 目标状态 + :param description: 流转说明 + """ + + source: PluginStatus | None + operation: PluginStateOperation + target: PluginStatus + description: str + + +@dataclass(frozen=True) +class PluginStateSnapshot: + """ + 插件状态输入快照。 + + :param source_version: 当前源码版本 + :param installed_version: 已安装版本 + :param enabled: 是否启用 + :param current_status: 当前持久化状态 + """ + + source_version: str | None + installed_version: str | None + enabled: bool + current_status: str | None = None + + +class PluginStateResolver: + """ + 插件状态解析器。 + + 使用 State Resolver 模式集中维护插件管理和运行时注册表共享的状态流转规则。 + """ + + @classmethod + def resolve(cls, snapshot: PluginStateSnapshot) -> PluginStatus: + """ + 根据源码版本、安装版本、启停状态和当前状态解析插件状态。 + + :param snapshot: 插件状态输入快照 + :return: 插件状态 + """ + if snapshot.current_status == 'error': + return 'error' + if not snapshot.installed_version and snapshot.current_status in {None, 'discovered'}: + return 'discovered' + if cls._needs_upgrade(snapshot.installed_version, snapshot.source_version): + return 'pending_upgrade' + return 'installed' if snapshot.installed_version else 'discovered' + + @staticmethod + def is_enabled(database_plugin: object | None) -> bool: + """ + 解析插件启用状态。 + + :param database_plugin: 数据库插件状态对象 + :return: 是否启用 + """ + if not database_plugin: + return False + if getattr(database_plugin, 'status', None) == 'error': + return False + if not getattr(database_plugin, 'installed_version', None): + return False + database_enabled = getattr(database_plugin, 'enabled', None) if database_plugin else None + if database_enabled is not None: + return database_enabled == '0' + return False + + @staticmethod + def enabled_to_db_value(enabled: bool) -> str: + """ + 将布尔启停状态转换为数据库枚举值。 + + :param enabled: 是否启用 + :return: 数据库启停枚举值,`0` 表示启用,`1` 表示停用 + """ + return '0' if enabled else '1' + + @staticmethod + def db_value_to_enabled(enabled: str | None, fallback: bool) -> bool: + """ + 将数据库启停枚举值转换为布尔值。 + + :param enabled: 数据库启停枚举值 + :param fallback: 数据库值为空时使用的默认值 + :return: 是否启用 + """ + if enabled is None: + return fallback + return enabled == '0' + + @classmethod + def is_database_plugin_enabled(cls, database_plugin: object | None, fallback: bool = False) -> bool: + """ + 判断数据库插件状态是否表示可用启用态。 + + :param database_plugin: 数据库插件状态对象 + :param fallback: 数据库对象或启停值为空时使用的默认值 + :return: 是否为可用启用态 + """ + if not database_plugin: + return fallback + if getattr(database_plugin, 'status', None) == 'error': + return False + return cls.db_value_to_enabled(getattr(database_plugin, 'enabled', None), fallback=fallback) + + @staticmethod + def _needs_upgrade(installed_version: str | None, source_version: str | None) -> bool: + """ + 判断源码版本是否高于已安装版本。 + + :param installed_version: 已安装版本 + :param source_version: 当前源码版本 + :return: 是否需要升级 + """ + return bool( + installed_version + and source_version + and PluginVersionComparator.is_upgrade_available(installed_version, source_version) + ) + + +class PluginStateTransitionTable: + """ + 插件状态流转表。 + + 使用 Table Driven 模式集中维护可观察的插件状态流转,供文档、测试和后续预检复用。 + """ + + _TRANSITIONS = ( + PluginStateTransition(None, 'discover', 'discovered', '首次扫描到本地插件'), + PluginStateTransition('discovered', 'install', 'installed', '插件安装完成'), + PluginStateTransition('discovered', 'disable', 'discovered', '未安装插件被显式停用'), + PluginStateTransition('discovered', 'mark_error', 'error', '插件安装或启动失败'), + PluginStateTransition('installed', 'disable', 'installed', '插件被停用'), + PluginStateTransition('installed', 'enable', 'installed', '插件被启用'), + PluginStateTransition('installed', 'upgrade_available', 'pending_upgrade', '发现源码版本高于已安装版本'), + PluginStateTransition('installed', 'mark_error', 'error', '插件运行或操作失败'), + PluginStateTransition('pending_upgrade', 'upgrade', 'installed', '插件升级完成'), + PluginStateTransition('pending_upgrade', 'disable', 'pending_upgrade', '待升级插件被停用'), + PluginStateTransition('pending_upgrade', 'enable', 'pending_upgrade', '待升级插件被启用'), + PluginStateTransition('pending_upgrade', 'mark_error', 'error', '插件升级或运行失败'), + PluginStateTransition('error', 'disable', 'error', '异常插件被显式停用'), + PluginStateTransition('error', 'install', 'installed', '异常插件重新安装成功'), + PluginStateTransition('error', 'upgrade', 'installed', '异常插件升级修复完成'), + PluginStateTransition('error', 'mark_error', 'error', '异常插件再次记录失败信息'), + ) + + @classmethod + def list_transitions(cls) -> list[PluginStateTransition]: + """ + 获取全部插件状态流转规则。 + + :return: 插件状态流转规则列表 + """ + return list(cls._TRANSITIONS) + + @classmethod + def can_transition(cls, source: PluginStatus | None, operation: PluginStateOperation) -> bool: + """ + 判断状态操作是否允许执行。 + + :param source: 来源状态,None 表示数据库中尚无插件状态 + :param operation: 状态流转操作 + :return: 是否允许流转 + """ + return cls.resolve_target(source, operation) is not None + + @classmethod + def resolve_target( + cls, + source: PluginStatus | None, + operation: PluginStateOperation, + ) -> PluginStatus | None: + """ + 解析状态操作的目标状态。 + + :param source: 来源状态,None 表示数据库中尚无插件状态 + :param operation: 状态流转操作 + :return: 目标状态,不允许流转时返回 None + """ + for transition in cls._TRANSITIONS: + if transition.source == source and transition.operation == operation: + return transition.target + + return None diff --git a/ruoyi-fastapi-backend/plugins/core/types.py b/ruoyi-fastapi-backend/plugins/core/types.py new file mode 100644 index 0000000..353b13b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/types.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Any, Protocol, TypeAlias + +from pydantic import JsonValue as PydanticJsonValue + +JSONScalar: TypeAlias = str | int | float | bool | None +JSONValue: TypeAlias = PydanticJsonValue +JSONObject: TypeAlias = dict[str, JSONValue] +Payload: TypeAlias = dict[str, JSONValue] +PluginConfigValue: TypeAlias = JSONValue + + +class SupportsModelDump(Protocol): + """ + 支持 Pydantic 风格序列化的对象协议。 + """ + + def model_dump(self, *, by_alias: bool = False) -> dict[str, Any]: + """ + 序列化模型。 + + :param by_alias: 是否使用字段别名 + :return: 序列化后的字典 + """ + ... + + +class SupportsToPayload(Protocol): + """ + 支持运行时 payload 序列化的对象协议。 + """ + + def to_payload(self) -> dict[str, object]: + """ + 序列化为 payload 字典。 + + :return: payload 字典 + """ + ... + + +class PluginStateRecord(Protocol): + """ + 插件数据库状态记录协议。 + """ + + plugin_id: str + installed_version: str | None + enabled: str | None + status: str | None + last_error: str | None + source: str | None + backend_path: str | None + frontend_path: str | None diff --git a/ruoyi-fastapi-backend/plugins/core/utils.py b/ruoyi-fastapi-backend/plugins/core/utils.py new file mode 100644 index 0000000..e046808 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/utils.py @@ -0,0 +1,27 @@ +import re + +PLUGIN_ID_PATTERN_TEXT = r'[a-z][a-z0-9_]{1,63}' +PLUGIN_ID_PATTERN = re.compile(rf'^{PLUGIN_ID_PATTERN_TEXT}$') + + +def validate_plugin_id_value(plugin_id: str, *, field_name: str = '插件ID') -> str: + """ + 校验插件 ID。 + + :param plugin_id: 插件 ID + :param field_name: 错误提示中的字段名称 + :return: 校验后的插件 ID + """ + if not PLUGIN_ID_PATTERN.match(plugin_id): + raise ValueError(f'{field_name}必须以小写字母开头,且只能包含小写字母、数字和下划线,长度为2-64') + return plugin_id + + +def escape_sql_like(value: str) -> str: + """ + 转义 SQL LIKE 模式中的通配符。 + + :param value: 原始字面量 + :return: 可安全拼接到 LIKE 模式中的字面量 + """ + return value.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') diff --git a/ruoyi-fastapi-backend/plugins/core/validation/__init__.py b/ruoyi-fastapi-backend/plugins/core/validation/__init__.py new file mode 100644 index 0000000..1de9edb --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/__init__.py @@ -0,0 +1,3 @@ +""" +插件校验能力分层包。 +""" diff --git a/ruoyi-fastapi-backend/plugins/core/validation/dependencies.py b/ruoyi-fastapi-backend/plugins/core/validation/dependencies.py new file mode 100644 index 0000000..c770fed --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/dependencies.py @@ -0,0 +1,645 @@ +import json +import re +import sys +from dataclasses import dataclass +from importlib import metadata +from pathlib import Path +from typing import Literal + +from packaging.requirements import InvalidRequirement + +from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT +from plugins.core.manifest.schema import PluginManifest +from plugins.core.validation.python_requirements import PythonRequirementParser +from plugins.core.validation.versioning import PluginVersionComparator, PluginVersionConstraintMatcher + +DependencyKind = Literal['python', 'npm', 'npmDev'] +DependencyStatus = Literal['checked', 'skipped'] +DEPENDENCY_PATTERN = re.compile(r'^\s*([A-Za-z0-9_.@/\-]+)\s*([<>=!~^]{1,2})?\s*([A-Za-z0-9_.+\-!*]+)?\s*$') +PYTHON_PACKAGE_SEPARATOR_PATTERN = re.compile(r'[-_.]+') +PLUGIN_STARTUP_DEPENDENCY_ERROR_PREFIX = '插件启动依赖检查失败:' + + +@dataclass(frozen=True) +class DependencyCheckItem: + """ + 单条依赖检查结果。 + """ + + kind: DependencyKind + requirement: str + name: str + installed: bool + version_satisfied: bool + installed_version: str | None + required_version: str | None + message: str + status: DependencyStatus = 'checked' + declared_version: str | None = None + + @property + def ok(self) -> bool: + """ + 判断依赖检查是否通过。 + + :return: 是否通过 + """ + return self.status == 'skipped' or (self.installed and self.version_satisfied) + + +@dataclass(frozen=True) +class DependencyCheckResult: + """ + 插件依赖检查结果。 + """ + + plugin_id: str + items: list[DependencyCheckItem] + + @property + def ok(self) -> bool: + """ + 判断插件依赖检查是否整体通过。 + + :return: 是否通过 + """ + return all(item.ok for item in self.items) + + @property + def missing_items(self) -> list[DependencyCheckItem]: + """ + 获取缺失依赖列表。 + + :return: 缺失依赖列表 + """ + return [item for item in self.items if item.status != 'skipped' and not item.installed] + + @property + def unsatisfied_items(self) -> list[DependencyCheckItem]: + """ + 获取版本不满足依赖列表。 + + :return: 版本不满足依赖列表 + """ + return [ + item for item in self.items if item.status != 'skipped' and item.installed and not item.version_satisfied + ] + + +@dataclass(frozen=True) +class DependencyInstallPlanItem: + """ + 依赖安装计划项。 + """ + + kind: DependencyKind + requirement: str + name: str + command: list[str] + workdir: str + reason: str + + +@dataclass(frozen=True) +class DependencyInstallPlan: + """ + 插件依赖安装计划。 + """ + + plugin_id: str + items: list[DependencyInstallPlanItem] + + @property + def has_actions(self) -> bool: + """ + 判断安装计划是否包含待执行动作。 + + :return: 是否包含待执行动作 + """ + return bool(self.items) + + +@dataclass(frozen=True) +class ParsedDependency: + """ + 已解析依赖声明。 + """ + + name: str + operator: str | None + version: str | None + + @property + def required_version(self) -> str | None: + """ + 获取完整版本约束。 + + :return: 完整版本约束 + """ + if not self.operator or not self.version: + return None + return f'{self.operator}{self.version}' + + +class DependencyRequirementParser: + """ + 依赖声明解析器。 + + 使用 Strategy 模式为 Python 与 npm 提供共享的轻量依赖声明解析能力。 + + 注意:该解析器基于正则,不支持 PEP 508 的逗号范围(``openai>=2,<3``)和 marker。 + Python 依赖请使用 :class:`PythonRequirementParser`。 + """ + + @classmethod + def parse(cls, requirement: str) -> ParsedDependency: + """ + 解析依赖声明。 + + :param requirement: 依赖声明 + :return: 已解析依赖声明 + """ + matched_requirement = DEPENDENCY_PATTERN.match(requirement) + if not matched_requirement: + return ParsedDependency(name=requirement.strip(), operator=None, version=None) + name, operator, version = matched_requirement.groups() + + return ParsedDependency(name=name, operator=operator, version=version) + + +class VersionConstraintMatcher: + """ + 版本约束匹配器。 + """ + + @classmethod + def is_satisfied(cls, installed_version: str | None, parsed_dependency: ParsedDependency) -> bool: + """ + 判断已安装版本是否满足依赖声明。 + + :param installed_version: 已安装版本 + :param parsed_dependency: 已解析依赖声明 + :return: 是否满足 + """ + if not installed_version: + return False + if not parsed_dependency.operator or not parsed_dependency.version: + return True + if parsed_dependency.operator in {'^', '~'}: + return PluginVersionConstraintMatcher.match_compatible( + installed_version, + parsed_dependency.version, + parsed_dependency.operator, + ) + + comparison = PluginVersionConstraintMatcher.is_satisfied( + installed_version, + parsed_dependency.operator, + parsed_dependency.version, + ) + if not comparison and not cls._can_compare(installed_version, parsed_dependency.version): + return cls._match_text_version(installed_version, parsed_dependency) + + return comparison + + @staticmethod + def _match_text_version(installed_version: str, parsed_dependency: ParsedDependency) -> bool: + """ + 匹配非纯数字版本声明。 + + :param installed_version: 已安装版本 + :param parsed_dependency: 已解析依赖声明 + :return: 是否满足 + """ + if parsed_dependency.operator in {'==', '='}: + return installed_version == parsed_dependency.version + if parsed_dependency.operator == '!=': + return installed_version != parsed_dependency.version + + return True + + @staticmethod + def _can_compare(installed_version: str, required_version: str) -> bool: + """ + 判断两个版本是否可按插件版本规则比较。 + + :param installed_version: 已安装版本 + :param required_version: 约束版本 + :return: 是否可比较 + """ + return PluginVersionComparator.compare(installed_version, required_version) is not None + + +class PythonDependencyInspector: + """ + Python 依赖检查器。 + """ + + def __init__(self, installed_packages: dict[str, str] | None = None) -> None: + """ + 初始化 Python 依赖检查器。 + + :param installed_packages: 已安装 Python 包版本映射 + """ + self._installed_packages_overridden = installed_packages is not None + self.installed_packages = ( + self._normalize_installed_packages(installed_packages) + if installed_packages is not None + else self._load_installed_packages() + ) + + def refresh(self) -> None: + """ + 刷新当前 Python 环境的已安装包快照。 + + 显式传入 ``installed_packages`` 的检查器保持固定快照,便于测试和离线检查; + 从运行环境创建的检查器会重新读取 distribution 元数据。 + + :return: None + """ + if self._installed_packages_overridden: + return + self.installed_packages = self._load_installed_packages() + + def check(self, requirements: list[str]) -> list[DependencyCheckItem]: + """ + 检查 Python 依赖声明。 + + :param requirements: Python 依赖声明列表 + :return: 依赖检查结果列表 + """ + return [self._check_requirement(requirement) for requirement in requirements] + + def _check_requirement(self, requirement: str) -> DependencyCheckItem: + """ + 检查单条 Python 依赖声明。 + + 含 marker 的声明先评估 marker,不适用当前环境时返回 ``status='skipped'``, + 避免合法的条件依赖阻断插件启动。 + + :param requirement: Python 依赖声明 + :return: 依赖检查结果 + """ + try: + parsed = PythonRequirementParser.parse(requirement) + except InvalidRequirement as exc: + return DependencyCheckItem( + kind='python', + requirement=requirement, + name=requirement.strip(), + installed=False, + version_satisfied=False, + installed_version=None, + required_version=None, + message=f'Python 依赖声明无效:{requirement},{exc}', + ) + if not parsed.is_marker_applicable(): + return DependencyCheckItem( + kind='python', + requirement=requirement, + name=parsed.name, + installed=False, + version_satisfied=True, + installed_version=None, + required_version=parsed.required_version, + message=f'Python 依赖 marker 不适用当前环境,已跳过:{parsed.name}', + status='skipped', + ) + + installed_version = self.installed_packages.get(self._normalize_package_name(parsed.name)) + installed = installed_version is not None + version_satisfied = parsed.is_version_satisfied(installed_version) + message = self._build_message( + parsed.name, + installed, + version_satisfied, + installed_version, + parsed.required_version, + ) + + return DependencyCheckItem( + kind='python', + requirement=requirement, + name=parsed.name, + installed=installed, + version_satisfied=version_satisfied, + installed_version=installed_version, + required_version=parsed.required_version, + message=message, + ) + + @staticmethod + def _load_installed_packages() -> dict[str, str]: + """ + 读取当前 Python 环境已安装包版本。 + + :return: 已安装包版本映射 + """ + return { + PythonDependencyInspector._normalize_package_name(distribution.metadata['Name']): distribution.version + for distribution in metadata.distributions() + } + + @classmethod + def _normalize_installed_packages(cls, installed_packages: dict[str, str]) -> dict[str, str]: + """ + 归一化已安装 Python 包名称映射。 + + :param installed_packages: 已安装 Python 包版本映射 + :return: 归一化包名后的版本映射 + """ + return {cls._normalize_package_name(name): version for name, version in installed_packages.items()} + + @staticmethod + def _normalize_package_name(name: str) -> str: + """ + 按 PEP 503 规则归一化 Python distribution 名称。 + + :param name: Python 包名 + :return: 归一化包名 + """ + return PYTHON_PACKAGE_SEPARATOR_PATTERN.sub('-', name).lower() + + @staticmethod + def _build_message( + name: str, + installed: bool, + version_satisfied: bool, + installed_version: str | None, + required_version: str | None, + ) -> str: + """ + 构建 Python 依赖检查消息。 + + :param name: 包名 + :param installed: 是否已安装 + :param version_satisfied: 版本是否满足 + :param installed_version: 已安装版本 + :param required_version: 版本约束 + :return: 检查消息 + """ + if not installed: + return f'Python 依赖未安装:{name}' + if not version_satisfied: + return f'Python 依赖版本不满足:{name} installed={installed_version} required={required_version}' + return f'Python 依赖已满足:{name}' + + +class NpmDependencyInspector: + """ + npm 依赖检查器。 + """ + + def __init__( + self, frontend_root: Path | str | None = None, installed_packages: dict[str, str] | None = None + ) -> None: + """ + 初始化 npm 依赖检查器。 + + :param frontend_root: 前端项目根目录 + :param installed_packages: 已声明 npm 包版本映射 + """ + self.frontend_root = ( + Path(frontend_root) if frontend_root else Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_dir()) + ) + self.installed_packages = ( + installed_packages if installed_packages is not None else self._load_installed_packages() + ) + + def check(self, requirements: list[str], *, dev: bool = False) -> list[DependencyCheckItem]: + """ + 检查 npm 依赖声明。 + + :param requirements: npm 依赖声明列表 + :param dev: 是否为 npm 开发依赖 + :return: 依赖检查结果列表 + """ + return [self._check_requirement(requirement, dev=dev) for requirement in requirements] + + def skip(self, requirements: list[str], *, dev: bool = False) -> list[DependencyCheckItem]: + """ + 跳过 npm 依赖实际检查。 + + :param requirements: npm 依赖声明列表 + :param dev: 是否为 npm 开发依赖 + :return: 跳过检查结果列表 + """ + return [self._skip_requirement(requirement, dev=dev) for requirement in requirements] + + def _skip_requirement(self, requirement: str, *, dev: bool = False) -> DependencyCheckItem: + """ + 构建单条 npm 依赖跳过结果。 + + :param requirement: npm 依赖声明 + :param dev: 是否为 npm 开发依赖 + :return: 依赖跳过结果 + """ + parsed_dependency = DependencyRequirementParser.parse(requirement) + return DependencyCheckItem( + kind='npmDev' if dev else 'npm', + requirement=requirement, + name=parsed_dependency.name, + installed=False, + version_satisfied=True, + installed_version=None, + required_version=parsed_dependency.required_version, + message='当前为已构建前端环境,前端依赖需在构建前安装。', + status='skipped', + ) + + def _check_requirement(self, requirement: str, *, dev: bool = False) -> DependencyCheckItem: + """ + 检查单条 npm 依赖声明。 + + :param requirement: npm 依赖声明 + :param dev: 是否为 npm 开发依赖 + :return: 依赖检查结果 + """ + parsed_dependency = DependencyRequirementParser.parse(requirement) + installed_version = self.installed_packages.get(parsed_dependency.name) + version_satisfied = VersionConstraintMatcher.is_satisfied( + self._normalize_npm_version(installed_version), parsed_dependency + ) + installed = installed_version is not None + + return DependencyCheckItem( + kind='npmDev' if dev else 'npm', + requirement=requirement, + name=parsed_dependency.name, + installed=installed, + version_satisfied=version_satisfied, + installed_version=installed_version, + required_version=parsed_dependency.required_version, + message=self._build_message(parsed_dependency, installed, version_satisfied, installed_version), + declared_version=installed_version, + ) + + def _load_installed_packages(self) -> dict[str, str]: + """ + 读取前端 package.json 中声明的 npm 包版本。 + + :return: npm 包版本映射 + """ + package_json_path = self.frontend_root / 'package.json' + if not package_json_path.is_file(): + return {} + package_json = json.loads(package_json_path.read_text(encoding='utf-8')) + dependencies = package_json.get('dependencies', {}) + dev_dependencies = package_json.get('devDependencies', {}) + + return {**dependencies, **dev_dependencies} + + @staticmethod + def _normalize_npm_version(version: str | None) -> str | None: + """ + 归一化 package.json 中的 npm 版本声明。 + + :param version: npm 版本声明 + :return: 归一化版本 + """ + if not version: + return None + return version.lstrip('^~>=<') + + @staticmethod + def _build_message( + parsed_dependency: ParsedDependency, + installed: bool, + version_satisfied: bool, + installed_version: str | None, + ) -> str: + """ + 构建 npm 依赖检查消息。 + + :param parsed_dependency: 已解析依赖声明 + :param installed: 是否已声明 + :param version_satisfied: 版本是否满足 + :param installed_version: 已声明版本 + :return: 检查消息 + """ + if not installed: + return f'npm 依赖未声明:{parsed_dependency.name}' + if not version_satisfied: + return f'npm 依赖版本不满足:{parsed_dependency.name} declared={installed_version} required={parsed_dependency.required_version}' + return f'npm 依赖已满足:{parsed_dependency.name}' + + +class PluginDependencyChecker: + """ + 插件依赖检查器。 + """ + + def __init__( + self, + python_inspector: PythonDependencyInspector | None = None, + npm_inspector: NpmDependencyInspector | None = None, + frontend_mode: Literal['dev', 'built'] = 'dev', + ) -> None: + """ + 初始化插件依赖检查器。 + + :param python_inspector: Python 依赖检查器 + :param npm_inspector: npm 依赖检查器 + """ + self.python_inspector = python_inspector or PythonDependencyInspector() + self.npm_inspector = npm_inspector or NpmDependencyInspector() + self.frontend_mode = frontend_mode + + def check_manifest(self, manifest: PluginManifest) -> DependencyCheckResult: + """ + 检查插件清单依赖。 + + :param manifest: 插件清单 + :return: 插件依赖检查结果 + """ + items = [] + items.extend(self.python_inspector.check(manifest.dependencies.python)) + if self.frontend_mode == 'built': + items.extend(self.npm_inspector.skip(manifest.dependencies.npm)) + items.extend(self.npm_inspector.skip(manifest.dependencies.npm_dev, dev=True)) + else: + items.extend(self.npm_inspector.check(manifest.dependencies.npm)) + items.extend(self.npm_inspector.check(manifest.dependencies.npm_dev, dev=True)) + + return DependencyCheckResult(plugin_id=manifest.id, items=items) + + +class PluginDependencyInstallPlanner: + """ + 插件依赖安装计划生成器。 + + 使用 Planner 模式将依赖检查结果转换为可审计、可 dry-run 的安装计划。 + """ + + def __init__(self, python_executable: str | None = None, frontend_root: Path | str | None = None) -> None: + """ + 初始化插件依赖安装计划生成器。 + + :param python_executable: Python 可执行文件路径 + :param frontend_root: 前端工程根目录 + """ + self.python_executable = python_executable or sys.executable + self.frontend_root = ( + Path(frontend_root) if frontend_root else Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_dir()) + ) + + def build_plan(self, dependency_result: DependencyCheckResult) -> DependencyInstallPlan: + """ + 构建依赖安装计划。 + + :param dependency_result: 依赖检查结果 + :return: 依赖安装计划 + """ + items = [ + self._build_plan_item(item) + for item in dependency_result.items + if item.status != 'skipped' and (not item.installed or not item.version_satisfied) + ] + + return DependencyInstallPlan(plugin_id=dependency_result.plugin_id, items=items) + + def _build_plan_item(self, item: DependencyCheckItem) -> DependencyInstallPlanItem: + """ + 构建单个依赖安装计划项。 + + :param item: 依赖检查项 + :return: 依赖安装计划项 + """ + if item.kind == 'python': + return DependencyInstallPlanItem( + kind=item.kind, + requirement=item.requirement, + name=item.name, + command=[self.python_executable, '-m', 'pip', 'install', item.requirement], + workdir=str(Path.cwd()), + reason=item.message, + ) + + command = ['npm', 'install', self._build_npm_install_target(item)] + if item.kind == 'npmDev': + command.insert(2, '--save-dev') + + return DependencyInstallPlanItem( + kind=item.kind, + requirement=item.requirement, + name=item.name, + command=command, + workdir=str(self.frontend_root), + reason=item.message, + ) + + @staticmethod + def _build_npm_install_target(item: DependencyCheckItem) -> str: + """ + 构建 npm install 目标声明。 + + :param item: 依赖检查项 + :return: npm install 目标 + """ + parsed_dependency = DependencyRequirementParser.parse(item.requirement) + if not parsed_dependency.operator or not parsed_dependency.version: + return parsed_dependency.name + if parsed_dependency.operator in {'==', '='}: + return f'{parsed_dependency.name}@{parsed_dependency.version}' + + return f'{parsed_dependency.name}@{parsed_dependency.required_version}' diff --git a/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py b/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py new file mode 100644 index 0000000..963ead4 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py @@ -0,0 +1,1336 @@ +import base64 +import hashlib +import os +import re +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any, Literal + +import yaml +from packaging.requirements import InvalidRequirement +from pydantic import ValidationError + +from plugins.core.validation.dependencies import ( + DependencyInstallPlan, + DependencyInstallPlanItem, + DependencyKind, + DependencyRequirementParser, +) +from plugins.core.validation.python_requirements import PythonRequirementParser +from plugins.core.validation.versioning import PluginVersionComparator, PluginVersionConstraintMatcher + +DependencyInstallPolicyMode = Literal['disabled', 'plan_only', 'explicit', 'locked', 'offline'] + +PYTHON_PACKAGE_SEPARATOR_PATTERN = re.compile(r'[-_.]+') +DEPENDENCY_OPERATOR_PATTERN = re.compile(r'==|!=|>=|<=|=|>|<|\^|~') +VERSION_CONSTRAINT_PATTERN = re.compile(r'^(==|!=|>=|<=|=|>|<|\^|~)?\s*([A-Za-z0-9_.+\-!*]+)$') +SUPPORTED_NPM_INTEGRITY_ALGORITHMS = {'sha256', 'sha384', 'sha512'} + + +@dataclass(frozen=True) +class DependencyInstallPolicyConfig: + """ + 插件依赖安装策略配置。 + """ + + mode: DependencyInstallPolicyMode | None = None + env: str = 'dev' + allow_prod: bool = False + allow_prod_install: bool = False + require_yes: bool = True + require_lockfile: bool | None = None + require_allowlist: bool | None = None + allow_unlisted: bool = False + lockfile_path: Path | str | None = None + offline_dir: Path | str | None = None + allowlist_path: Path | str | None = None + pip_index_url: str | None = None + npm_registry: str | None = None + install_timeout_seconds: int = 600 + + def __post_init__(self) -> None: + """ + 归一化路径和默认模式。 + + :return: None + """ + object.__setattr__(self, 'env', (self.env or 'dev').strip() or 'dev') + object.__setattr__(self, 'mode', self.mode or self._default_mode(self.env)) + if self.lockfile_path is not None: + object.__setattr__(self, 'lockfile_path', Path(self.lockfile_path)) + if self.offline_dir is not None: + object.__setattr__(self, 'offline_dir', Path(self.offline_dir)) + if self.allowlist_path is not None: + object.__setattr__(self, 'allowlist_path', Path(self.allowlist_path)) + + @classmethod + def from_environment( + cls, + *, + env: str | None = None, + mode: DependencyInstallPolicyMode | None = None, + allow_prod: bool = False, + allow_unlisted: bool = False, + lockfile_path: Path | str | None = None, + offline_dir: Path | str | None = None, + require_lockfile: bool | None = None, + ) -> 'DependencyInstallPolicyConfig': + """ + 从环境变量和 CLI 覆盖参数构建策略配置。 + + :param env: 当前运行环境 + :param mode: 策略模式覆盖 + :param allow_prod: 是否允许生产环境危险安装 + :param allow_unlisted: 是否允许 dev 环境未命中 allowlist 的依赖仅告警 + :param lockfile_path: 锁文件路径 + :param offline_dir: 离线制品目录 + :param require_lockfile: 是否要求锁文件 + :return: 策略配置 + """ + settings = cls._load_environment_settings() + resolved_env = env or os.getenv('APP_ENV', 'dev') + return cls( + mode=mode or cls._read_policy_mode(resolved_env, settings=settings), + env=resolved_env, + allow_prod=allow_prod, + allow_prod_install=cls._read_bool( + 'PLUGIN_DEPENDENCY_ALLOW_PROD_INSTALL', + attr='plugin_dependency_allow_prod_install', + settings=settings, + default=False, + ), + require_yes=cls._read_bool( + 'PLUGIN_DEPENDENCY_REQUIRE_YES', + attr='plugin_dependency_require_yes', + settings=settings, + default=True, + ), + require_lockfile=( + require_lockfile + if require_lockfile is not None + else cls._read_optional_bool( + 'PLUGIN_DEPENDENCY_REQUIRE_LOCKFILE', + attr='plugin_dependency_require_lockfile', + settings=settings, + ) + ), + require_allowlist=cls._read_optional_bool( + 'PLUGIN_DEPENDENCY_REQUIRE_ALLOWLIST', + attr='plugin_dependency_require_allowlist', + settings=settings, + ), + allow_unlisted=allow_unlisted, + lockfile_path=lockfile_path + or cls._read_value(settings, 'plugin_dependency_lockfile', 'PLUGIN_DEPENDENCY_LOCKFILE') + or None, + offline_dir=offline_dir + or cls._read_value(settings, 'plugin_dependency_offline_dir', 'PLUGIN_DEPENDENCY_OFFLINE_DIR') + or None, + allowlist_path=cls._read_value(settings, 'plugin_dependency_allowlist', 'PLUGIN_DEPENDENCY_ALLOWLIST') + or None, + pip_index_url=cls._read_value( + settings, 'plugin_dependency_pip_index_url', 'PLUGIN_DEPENDENCY_PIP_INDEX_URL' + ) + or None, + npm_registry=cls._read_value(settings, 'plugin_dependency_npm_registry', 'PLUGIN_DEPENDENCY_NPM_REGISTRY') + or None, + install_timeout_seconds=cls._read_int( + 'PLUGIN_DEPENDENCY_INSTALL_TIMEOUT', + attr='plugin_dependency_install_timeout', + settings=settings, + default=600, + ), + ) + + @staticmethod + def _default_mode(env: str) -> DependencyInstallPolicyMode: + """ + 获取环境默认策略模式。 + + :param env: 当前运行环境 + :return: 默认策略模式 + """ + env_mode_map: dict[str, DependencyInstallPolicyMode] = { + 'dev': 'explicit', + 'test': 'plan_only', + 'stage': 'locked', + 'prod': 'plan_only', + } + return env_mode_map.get(env, 'plan_only') + + @classmethod + def _load_environment_settings(cls) -> Any | None: + """ + 延迟加载统一配置入口中的插件依赖策略配置。 + + :return: 插件依赖策略配置对象,缺失时返回 None + """ + try: + from config.env import get_config # noqa: PLC0415 + except ImportError: + return None + getter = getattr(get_config, 'get_plugin_dependency_policy_config', None) + if getter is None: + return None + try: + return getter() + except ValidationError as exc: + raise ValueError(f'非法插件依赖策略布尔配置:{exc}') from exc + + @staticmethod + def _read_value(settings: Any | None, attr: str, env_name: str, default: Any = None) -> Any: + """ + 从统一配置入口读取配置值,缺失时回退到环境变量。 + + :param settings: 统一配置对象 + :param attr: 配置对象属性名 + :param env_name: 环境变量名称 + :param default: 默认值 + :return: 配置值 + """ + if settings is not None and hasattr(settings, attr): + value = getattr(settings, attr) + if value is not None: + return value + return os.getenv(env_name, default) + + @classmethod + def _read_policy_mode(cls, env: str, *, settings: Any | None = None) -> DependencyInstallPolicyMode: + """ + 读取策略模式环境变量。 + + :param env: 当前运行环境 + :param settings: 统一配置对象 + :return: 策略模式 + """ + value = str( + cls._read_value(settings, 'plugin_dependency_policy_mode', 'PLUGIN_DEPENDENCY_POLICY_MODE', '') + ).strip() + if not value: + return cls._default_mode(env) + if '=' not in value: + return cls._normalize_mode(value, cls._default_mode(env)) + entries = dict( + part.split('=', maxsplit=1) + for part in value.split(',') + if '=' in part and part.split('=', maxsplit=1)[0].strip() + ) + if env not in entries: + return cls._default_mode(env) + return cls._normalize_mode(entries[env], cls._default_mode(env)) + + @staticmethod + def _normalize_mode(value: str, default: DependencyInstallPolicyMode) -> DependencyInstallPolicyMode: + """ + 归一化策略模式。 + + :param value: 原始模式 + :param default: 默认模式 + :return: 策略模式 + """ + normalized_value = value.strip() + if normalized_value in {'disabled', 'plan_only', 'explicit', 'locked', 'offline'}: + return normalized_value # type: ignore[return-value] + if not normalized_value: + return default + raise ValueError(f'非法插件依赖安装策略模式:{value}') + + @staticmethod + def _parse_bool(value: object, *, name: str) -> bool: + """ + 解析布尔配置,拒绝未知字符串。 + + :param value: 原始配置值 + :param name: 配置名称 + :return: 布尔值 + """ + if isinstance(value, bool): + return value + normalized_value = str(value).strip().lower() + if normalized_value in {'1', 'true', 'yes', 'on'}: + return True + if normalized_value in {'0', 'false', 'no', 'off'}: + return False + raise ValueError(f'非法插件依赖策略布尔配置:{name}={value}') + + @classmethod + def _read_bool(cls, name: str, *, default: bool, attr: str, settings: Any | None = None) -> bool: + """ + 读取布尔环境变量。 + + :param name: 环境变量名称 + :param attr: 配置对象属性名 + :param settings: 统一配置对象 + :param default: 默认值 + :return: 布尔值 + """ + value = cls._read_value(settings, attr, name) + if value is None or value == '': + return default + return cls._parse_bool(value, name=name) + + @classmethod + def _read_optional_bool(cls, name: str, *, attr: str, settings: Any | None = None) -> bool | None: + """ + 读取可空布尔环境变量。 + + :param name: 环境变量名称 + :param attr: 配置对象属性名 + :param settings: 统一配置对象 + :return: 布尔值或 None + """ + value = cls._read_value(settings, attr, name) + if value is None or value == '': + return None + return cls._parse_bool(value, name=name) + + @classmethod + def _read_int(cls, name: str, *, attr: str, settings: Any | None, default: int) -> int: + """ + 读取整数配置。 + + :param name: 环境变量名称 + :param attr: 配置对象属性名 + :param settings: 统一配置对象 + :param default: 默认值 + :return: 整数值 + """ + value = cls._read_value(settings, attr, name, default) + if value is None or value == '': + return default + return int(value) + + @property + def resolved_require_lockfile(self) -> bool: + """ + 判断当前策略是否要求锁文件。 + + :return: 是否要求锁文件 + """ + if self.require_lockfile is not None: + return self.require_lockfile + return self.mode in {'locked', 'offline'} or self.env in {'stage', 'prod'} + + @property + def resolved_require_allowlist(self) -> bool: + """ + 判断当前策略是否要求允许列表。 + + :return: 是否要求允许列表 + """ + if self.require_allowlist is not None: + return self.require_allowlist + return self.env in {'stage', 'prod'} + + +@dataclass(frozen=True) +class DependencyInstallPolicyItemDecision: + """ + 单条依赖安装计划的策略判定。 + """ + + kind: DependencyKind + name: str + requirement: str + allowed: bool + reasons: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + requirements: list[str] = field(default_factory=list) + locked_version: str | None = None + artifact_path: str | None = None + artifact_verified: bool | None = None + + def to_payload(self) -> dict[str, object]: + """ + 转换为响应 payload。 + + :return: 策略判定 payload + """ + payload: dict[str, object] = { + 'kind': self.kind, + 'name': self.name, + 'requirement': self.requirement, + 'allowed': self.allowed, + 'reasons': self.reasons, + 'warnings': self.warnings, + 'requirements': self.requirements, + } + if self.locked_version: + payload['lockedVersion'] = self.locked_version + if self.artifact_path: + payload['artifactPath'] = self.artifact_path + if self.artifact_verified is not None: + payload['artifactVerified'] = self.artifact_verified + return payload + + +@dataclass(frozen=True) +class DependencyInstallPolicyDecision: + """ + 依赖安装策略判定结果。 + """ + + allowed: bool + mode: DependencyInstallPolicyMode + reasons: list[str] + warnings: list[str] + requirements: list[str] + items: list[DependencyInstallPolicyItemDecision] + install_plan_items: list[DependencyInstallPlanItem] + + def to_payload(self) -> dict[str, object]: + """ + 转换为响应 payload。 + + :return: 策略判定 payload + """ + return { + 'allowed': self.allowed, + 'mode': self.mode, + 'reasons': self.reasons, + 'warnings': self.warnings, + 'requirements': self.requirements, + 'items': [item.to_payload() for item in self.items], + } + + +@dataclass(frozen=True) +class DependencyLockEntry: + """ + 锁文件中的单条依赖。 + """ + + kind: DependencyKind + name: str + requirement: str + resolved_version: str | None + hashes: list[str] = field(default_factory=list) + integrity: str | None = None + + +@dataclass(frozen=True) +class DependencyLockfile: + """ + 插件依赖锁文件。 + """ + + path: Path + entries: dict[tuple[DependencyKind, str], DependencyLockEntry] + + @classmethod + def load(cls, path: Path | str | None) -> 'DependencyLockfile | None': + """ + 加载锁文件。 + + :param path: 锁文件路径 + :return: 锁文件对象,缺失时返回 None + """ + if path is None: + return None + lockfile_path = Path(path) + if not lockfile_path.is_file(): + return None + data = yaml.safe_load(lockfile_path.read_text(encoding='utf-8')) or {} + entries: dict[tuple[DependencyKind, str], DependencyLockEntry] = {} + for kind in ('python', 'npm', 'npmDev'): + for raw_entry in data.get(kind, []) or []: + if not isinstance(raw_entry, dict): + continue + name = str(raw_entry.get('name', '')).strip() + if not name: + continue + entry = DependencyLockEntry( + kind=kind, + name=name, + requirement=str(raw_entry.get('requirement', '') or '').strip(), + resolved_version=str(raw_entry.get('resolvedVersion', '') or '').strip() or None, + hashes=[str(item) for item in raw_entry.get('hashes', []) or []], + integrity=str(raw_entry.get('integrity', '') or '').strip() or None, + ) + entries[(kind, normalize_dependency_name(kind, name))] = entry + + return cls(path=lockfile_path, entries=entries) + + def get_entry(self, item: DependencyInstallPlanItem) -> DependencyLockEntry | None: + """ + 获取安装计划项对应的锁定依赖。 + + :param item: 安装计划项 + :return: 锁文件项 + """ + return self.entries.get((item.kind, normalize_dependency_name(item.kind, item.name))) + + +@dataclass(frozen=True) +class DependencyVersionBound: + """ + 依赖版本范围边界。 + """ + + version: str + inclusive: bool + + +@dataclass(frozen=True) +class DependencyVersionRange: + """ + 可保守比较的依赖版本范围。 + """ + + lower: DependencyVersionBound | None = None + upper: DependencyVersionBound | None = None + exact: str | None = None + + +@dataclass(frozen=True) +class DependencyAllowlist: + """ + 插件依赖允许列表。 + """ + + entries: dict[DependencyKind, dict[str, dict[str, Any]]] + + @classmethod + def load(cls, path: Path | str | None) -> 'DependencyAllowlist': + """ + 加载允许列表。 + + :param path: 允许列表路径 + :return: 允许列表 + """ + if path is None or not Path(path).is_file(): + return cls(entries={'python': {}, 'npm': {}, 'npmDev': {}}) + data = yaml.safe_load(Path(path).read_text(encoding='utf-8')) or {} + entries: dict[DependencyKind, dict[str, dict[str, Any]]] = {'python': {}, 'npm': {}, 'npmDev': {}} + for kind in entries: + kind_entries = data.get(kind, {}) or {} + if not isinstance(kind_entries, dict): + continue + entries[kind] = { + normalize_dependency_name(kind, str(name)): entry if isinstance(entry, dict) else {} + for name, entry in kind_entries.items() + } + return cls(entries=entries) + + def is_allowed(self, item: DependencyInstallPlanItem) -> bool: + """ + 判断依赖是否命中允许列表。 + + :param item: 安装计划项 + :return: 是否允许 + """ + entry = self.entries.get(item.kind, {}).get(normalize_dependency_name(item.kind, item.name)) + if entry is None: + return False + versions = [str(version) for version in entry.get('versions', []) or []] + if not versions: + return True + if item.kind == 'python': + try: + requested_version_text = PythonRequirementParser.parse(item.requirement).required_version + except InvalidRequirement: + return False + if not requested_version_text: + return False + requested_range = parse_version_range(requested_version_text) + else: + requested_version_text = extract_dependency_required_version(item.requirement) + requested_range = parse_dependency_requirement_range(item.requirement) + if requested_range is None: + return requested_version_text in versions + if requested_version_text in versions: + return True + for version_range in versions: + allowed_range = parse_version_range(version_range) + if allowed_range is not None and version_range_contains(allowed_range, requested_range): + return True + return False + + +class DependencyArtifactStore: + """ + 离线依赖制品仓库。 + """ + + def __init__(self, offline_dir: Path | str | None) -> None: + """ + 初始化离线制品仓库。 + + :param offline_dir: 离线制品根目录 + :return: None + """ + self.offline_dir = Path(offline_dir) if offline_dir is not None else None + + def find_artifact(self, entry: DependencyLockEntry) -> Path | None: + """ + 查找锁定依赖对应的离线制品。 + + :param entry: 锁文件项 + :return: 制品路径 + """ + if self.offline_dir is None or not entry.resolved_version: + return None + if entry.kind == 'python': + return self._find_python_artifact(entry) + return self._find_npm_artifact(entry) + + def verify_artifact(self, entry: DependencyLockEntry, artifact_path: Path) -> str | None: + """ + 校验离线制品内容完整性。 + + :param entry: 锁文件项 + :param artifact_path: 制品路径 + :return: 不匹配原因,匹配时返回 None + """ + if entry.kind == 'python': + return self._verify_python_artifact(entry, artifact_path) + return self._verify_npm_artifact(entry, artifact_path) + + def _find_python_artifact(self, entry: DependencyLockEntry) -> Path | None: + """ + 查找 Python 离线制品。 + + :param entry: 锁文件项 + :return: 制品路径 + """ + artifact_dir = self.offline_dir / 'python' if self.offline_dir else None + if artifact_dir is None or not artifact_dir.is_dir() or not entry.resolved_version: + return None + normalized_name = normalize_dependency_name('python', entry.name).replace('-', '[-_]') + patterns = [ + f'{normalized_name}-{entry.resolved_version}*.whl', + f'{normalized_name}-{entry.resolved_version}*.tar.gz', + ] + for artifact_path in artifact_dir.iterdir(): + if any(re.fullmatch(pattern.replace('*', '.*'), artifact_path.name) for pattern in patterns): + return artifact_path + return None + + def _find_npm_artifact(self, entry: DependencyLockEntry) -> Path | None: + """ + 查找 npm 离线制品。 + + :param entry: 锁文件项 + :return: 制品路径 + """ + artifact_dir = self.offline_dir / 'npm' if self.offline_dir else None + if artifact_dir is None or not artifact_dir.is_dir() or not entry.resolved_version: + return None + normalized_name = normalize_dependency_name(entry.kind, entry.name).replace('/', '-').lstrip('@') + candidates = sorted(artifact_dir.glob(f'{normalized_name}-{entry.resolved_version}.tgz')) + return candidates[0] if candidates else None + + @staticmethod + def _verify_python_artifact(entry: DependencyLockEntry, artifact_path: Path) -> str | None: + """ + 校验 Python 离线制品 sha256。 + + :param entry: 锁文件项 + :param artifact_path: 制品路径 + :return: 不匹配原因,匹配时返回 None + """ + expected_hashes = [ + hash_value.split(':', maxsplit=1)[1].strip().lower() + for hash_value in entry.hashes + if hash_value.lower().startswith('sha256:') + ] + if not expected_hashes: + return f'锁文件缺少可校验 Python sha256:{entry.kind} {entry.name}' + actual_hash = hashlib.sha256(artifact_path.read_bytes()).hexdigest().lower() + if actual_hash not in expected_hashes: + return f'离线制品哈希不匹配:{entry.kind} {entry.name} {entry.resolved_version}' + return None + + @staticmethod + def _verify_npm_artifact(entry: DependencyLockEntry, artifact_path: Path) -> str | None: + """ + 校验 npm 离线制品 integrity。 + + :param entry: 锁文件项 + :param artifact_path: 制品路径 + :return: 不匹配原因,匹配时返回 None + """ + if not entry.integrity: + return f'锁文件缺少可校验 npm integrity:{entry.kind} {entry.name}' + artifact_bytes = artifact_path.read_bytes() + found_supported_integrity = False + for token in entry.integrity.split(): + if '-' not in token: + continue + algorithm, expected_digest = token.split('-', maxsplit=1) + algorithm = algorithm.lower() + if algorithm not in SUPPORTED_NPM_INTEGRITY_ALGORITHMS: + continue + found_supported_integrity = True + actual_digest = base64.b64encode(hashlib.new(algorithm, artifact_bytes).digest()).decode('ascii') + if normalize_sri_digest(actual_digest) == normalize_sri_digest(expected_digest): + return None + if not found_supported_integrity: + return f'锁文件缺少可校验 npm integrity:{entry.kind} {entry.name}' + return f'离线制品 integrity 不匹配:{entry.kind} {entry.name} {entry.resolved_version}' + + +class DependencyInstallPolicyEvaluator: + """ + 插件依赖安装策略判定器。 + """ + + def __init__(self, config: DependencyInstallPolicyConfig | None = None) -> None: + """ + 初始化策略判定器。 + + :param config: 策略配置 + :return: None + """ + self.config = config or DependencyInstallPolicyConfig.from_environment() + + def evaluate( + self, install_plan: DependencyInstallPlan, *, confirmed: bool = False + ) -> DependencyInstallPolicyDecision: + """ + 判定依赖安装计划是否允许真实执行。 + + :param install_plan: 依赖安装计划 + :param confirmed: 是否已显式确认 + :return: 策略判定结果 + """ + if not install_plan.has_actions: + return DependencyInstallPolicyDecision( + allowed=True, + mode=self.config.mode or 'plan_only', + reasons=[], + warnings=[], + requirements=[], + items=[], + install_plan_items=[], + ) + + global_reasons, global_requirements = self._build_global_blockers(confirmed) + allowlist = DependencyAllowlist.load(self.config.allowlist_path) + lockfile = DependencyLockfile.load(self.config.lockfile_path) + artifact_store = DependencyArtifactStore(self.config.offline_dir) + rewritten_items: list[DependencyInstallPlanItem] = [] + item_decisions: list[DependencyInstallPolicyItemDecision] = [] + + if self.config.resolved_require_lockfile and lockfile is None: + append_unique(global_requirements, '需要 plugin.lock.yaml') + if self.config.mode == 'offline' and self.config.offline_dir is None: + append_unique(global_requirements, '需要离线制品目录') + + for item in install_plan.items: + item_decision, rewritten_item = self._evaluate_item( + item, + allowlist=allowlist, + lockfile=lockfile, + artifact_store=artifact_store, + global_blocked=bool(global_reasons or global_requirements), + ) + rewritten_items.append(rewritten_item) + item_decisions.append(item_decision) + + lockfile_reasons = self._build_lockfile_extra_reasons(lockfile, install_plan) + global_reasons.extend(reason for reason in lockfile_reasons if reason not in global_reasons) + + reasons = list_unique([*global_reasons, *(reason for item in item_decisions for reason in item.reasons)]) + requirements = list_unique( + [*global_requirements, *(requirement for item in item_decisions for requirement in item.requirements)] + ) + warnings = list_unique([warning for item in item_decisions for warning in item.warnings]) + allowed = not reasons and not requirements and all(item.allowed for item in item_decisions) + + return DependencyInstallPolicyDecision( + allowed=allowed, + mode=self.config.mode or 'plan_only', + reasons=reasons, + warnings=warnings, + requirements=requirements, + items=item_decisions, + install_plan_items=rewritten_items, + ) + + def _build_global_blockers(self, confirmed: bool) -> tuple[list[str], list[str]]: + """ + 构建全局阻断原因和前置要求。 + + :param confirmed: 是否已显式确认 + :return: 阻断原因和前置要求 + """ + reasons: list[str] = [] + requirements: list[str] = [] + if self.config.mode == 'disabled': + reasons.append('插件依赖真实安装已禁用') + if self.config.mode == 'plan_only': + reasons.append('当前策略仅允许生成依赖安装计划') + if self.config.env == 'prod' and self.config.mode != 'plan_only': + if not self.config.allow_prod_install: + reasons.append('生产环境禁止真实依赖安装') + if not self.config.allow_prod: + requirements.append('需要 --allow-prod 确认生产环境安装') + if self.config.require_yes and not confirmed and self.config.mode not in {'disabled', 'plan_only'}: + requirements.append('需要显式确认 --yes') + return reasons, requirements + + def _evaluate_item( + self, + item: DependencyInstallPlanItem, + *, + allowlist: DependencyAllowlist, + lockfile: DependencyLockfile | None, + artifact_store: DependencyArtifactStore, + global_blocked: bool, + ) -> tuple[DependencyInstallPolicyItemDecision, DependencyInstallPlanItem]: + """ + 判定单条安装计划并生成可能改写后的计划项。 + + :param item: 安装计划项 + :param allowlist: 允许列表 + :param lockfile: 锁文件 + :param artifact_store: 离线制品仓库 + :param global_blocked: 是否存在全局阻断 + :return: 单项判定和安装计划项 + """ + reasons: list[str] = [] + warnings: list[str] = [] + requirements: list[str] = [] + rewritten_item = item + locked_version: str | None = None + artifact_path: str | None = None + artifact_verified: bool | None = None + + if self.config.mode in {'disabled', 'plan_only'}: + reasons.append('当前策略不允许执行真实安装') + + self._evaluate_allowlist(item, allowlist, reasons, warnings) + lock_entry = self._evaluate_lockfile(item, lockfile, reasons) + if lock_entry and lock_entry.resolved_version: + locked_version = lock_entry.resolved_version + rewritten_item = self._build_locked_plan_item(item, lock_entry) + + if self.config.mode == 'offline' and lock_entry and lock_entry.resolved_version: + artifact = artifact_store.find_artifact(lock_entry) + if artifact is None: + reasons.append(f'缺少离线制品:{item.kind} {item.name} {lock_entry.resolved_version}') + else: + artifact_path = str(artifact) + integrity_reason = artifact_store.verify_artifact(lock_entry, artifact) + if integrity_reason: + reasons.append(integrity_reason) + artifact_verified = False + else: + artifact_verified = True + rewritten_item = self._build_offline_plan_item(item, lock_entry, artifact) + + allowed = not global_blocked and not reasons and not requirements + return ( + DependencyInstallPolicyItemDecision( + kind=item.kind, + name=item.name, + requirement=item.requirement, + allowed=allowed, + reasons=reasons, + warnings=warnings, + requirements=requirements, + locked_version=locked_version, + artifact_path=artifact_path, + artifact_verified=artifact_verified, + ), + rewritten_item, + ) + + def _evaluate_allowlist( + self, + item: DependencyInstallPlanItem, + allowlist: DependencyAllowlist, + reasons: list[str], + warnings: list[str], + ) -> None: + """ + 评估允许列表。 + + :param item: 安装计划项 + :param allowlist: 允许列表 + :param reasons: 阻断原因 + :param warnings: 告警列表 + :return: None + """ + if allowlist.is_allowed(item): + return + message = f'依赖未命中允许列表:{item.kind} {item.name}' + if self.config.resolved_require_allowlist: + reasons.append(message) + return + if self.config.allowlist_path or self.config.allow_unlisted: + warnings.append(message) + + def _evaluate_lockfile( + self, + item: DependencyInstallPlanItem, + lockfile: DependencyLockfile | None, + reasons: list[str], + ) -> DependencyLockEntry | None: + """ + 评估锁文件。 + + :param item: 安装计划项 + :param lockfile: 锁文件 + :param reasons: 阻断原因 + :return: 锁文件项 + """ + if not self.config.resolved_require_lockfile: + return None + if lockfile is None: + return None + lock_entry = lockfile.get_entry(item) + if lock_entry is None or lock_entry.requirement != item.requirement: + reasons.append(f'锁文件缺少匹配依赖:{item.kind} {item.name} {item.requirement}') + return None + if not lock_entry.resolved_version: + reasons.append(f'锁文件缺少 resolvedVersion:{item.kind} {item.name}') + return None + if not self._lockfile_version_satisfies_requirement(item, lock_entry): + reasons.append( + f'锁文件 resolvedVersion 不满足依赖声明:{item.kind} {item.name} ' + f'{lock_entry.resolved_version} not in {item.requirement}' + ) + return None + if item.kind == 'python' and not lock_entry.hashes: + reasons.append(f'锁文件缺少 Python 哈希:{item.kind} {item.name}') + if item.kind in {'npm', 'npmDev'} and not lock_entry.integrity: + reasons.append(f'锁文件缺少 npm integrity:{item.kind} {item.name}') + return lock_entry + + @staticmethod + def _lockfile_version_satisfies_requirement( + item: DependencyInstallPlanItem, + lock_entry: DependencyLockEntry, + ) -> bool: + """ + 判断锁文件 resolvedVersion 是否满足插件依赖声明。 + + :param item: 安装计划项 + :param lock_entry: 锁文件项 + :return: 是否满足 + """ + if not lock_entry.resolved_version: + return False + if item.kind == 'python': + try: + parsed_requirement = PythonRequirementParser.parse(item.requirement) + except InvalidRequirement: + return False + return parsed_requirement.is_version_satisfied(lock_entry.resolved_version) + required_version = extract_dependency_required_version(item.requirement) + if not required_version: + return True + return version_satisfies_range(lock_entry.resolved_version, required_version) + + @staticmethod + def _build_lockfile_extra_reasons( + lockfile: DependencyLockfile | None, + install_plan: DependencyInstallPlan, + ) -> list[str]: + """ + 构建锁文件额外安装项阻断原因。 + + :param lockfile: 锁文件 + :param install_plan: 安装计划 + :return: 阻断原因 + """ + if lockfile is None: + return [] + plan_keys = { + (item.kind, normalize_dependency_name(item.kind, item.name), item.requirement) + for item in install_plan.items + } + reasons = [] + for entry in lockfile.entries.values(): + key = (entry.kind, normalize_dependency_name(entry.kind, entry.name), entry.requirement) + if key not in plan_keys: + reasons.append(f'锁文件包含未声明依赖:{entry.kind} {entry.name} {entry.requirement}') + return reasons + + def _build_locked_plan_item( + self, + item: DependencyInstallPlanItem, + lock_entry: DependencyLockEntry, + ) -> DependencyInstallPlanItem: + """ + 构建锁定版本安装计划项。 + + :param item: 原始安装计划项 + :param lock_entry: 锁文件项 + :return: 改写后的安装计划项 + """ + if item.kind == 'python': + command = [item.command[0], '-m', 'pip', 'install'] + if self.config.pip_index_url: + command.extend(['--index-url', self.config.pip_index_url]) + command.append(f'{lock_entry.name}=={lock_entry.resolved_version}') + return replace(item, command=command, requirement=f'{lock_entry.name}=={lock_entry.resolved_version}') + + command = ['npm', 'install'] + if item.kind == 'npmDev': + command.append('--save-dev') + if self.config.npm_registry: + command.extend(['--registry', self.config.npm_registry]) + command.append(f'{lock_entry.name}@{lock_entry.resolved_version}') + return replace(item, command=command, requirement=f'{lock_entry.name}@{lock_entry.resolved_version}') + + @staticmethod + def _build_offline_plan_item( + item: DependencyInstallPlanItem, + lock_entry: DependencyLockEntry, + artifact_path: Path, + ) -> DependencyInstallPlanItem: + """ + 构建离线安装计划项。 + + :param item: 原始安装计划项 + :param lock_entry: 锁文件项 + :param artifact_path: 本地制品路径 + :return: 改写后的安装计划项 + """ + if item.kind == 'python': + command = [ + item.command[0], + '-m', + 'pip', + 'install', + '--no-index', + '--find-links', + str(artifact_path.parent), + f'{lock_entry.name}=={lock_entry.resolved_version}', + ] + return replace(item, command=command, requirement=f'{lock_entry.name}=={lock_entry.resolved_version}') + + command = ['npm', 'install'] + if item.kind == 'npmDev': + command.append('--save-dev') + command.extend([str(artifact_path), '--offline']) + return replace(item, command=command, requirement=str(artifact_path)) + + +def normalize_dependency_name(kind: DependencyKind, name: str) -> str: + """ + 归一化依赖名称。 + + :param kind: 依赖类型 + :param name: 依赖名称 + :return: 归一化名称 + """ + normalized_name = name.strip() + if kind == 'python': + return PYTHON_PACKAGE_SEPARATOR_PATTERN.sub('-', normalized_name).lower() + return normalized_name.lower() + + +def extract_dependency_constraints(requirement: str) -> list[str]: + """ + 从依赖声明中提取版本约束列表。 + + :param requirement: 依赖声明或版本范围 + :return: 版本约束列表 + """ + normalized_requirement = requirement.strip() + if not normalized_requirement: + return [] + operator_match = DEPENDENCY_OPERATOR_PATTERN.search(normalized_requirement) + constraint_text = normalized_requirement[operator_match.start() :] if operator_match else normalized_requirement + return [constraint.strip() for constraint in constraint_text.split(',') if constraint.strip()] + + +def extract_dependency_required_version(requirement: str) -> str | None: + """ + 提取依赖声明中的原始版本约束文本。 + + :param requirement: 依赖声明 + :return: 原始版本约束文本 + """ + constraints = extract_dependency_constraints(requirement) + return ','.join(constraints) if constraints else None + + +def parse_dependency_requirement_range(requirement: str) -> DependencyVersionRange | None: + """ + 解析依赖声明中的版本范围。 + + :param requirement: 依赖声明 + :return: 可比较的版本范围 + """ + constraints = extract_dependency_constraints(requirement) + return parse_constraints_as_range(constraints) + + +def parse_version_range(version_range: str) -> DependencyVersionRange | None: + """ + 解析允许列表版本范围。 + + :param version_range: 版本范围 + :return: 可比较的版本范围 + """ + return parse_constraints_as_range(extract_dependency_constraints(version_range)) + + +def parse_constraints_as_range(constraints: list[str]) -> DependencyVersionRange | None: + """ + 将约束列表解析为单一连续范围。 + + :param constraints: 版本约束列表 + :return: 可比较的版本范围 + """ + if not constraints: + return None + lower: DependencyVersionBound | None = None + upper: DependencyVersionBound | None = None + exact: str | None = None + for constraint in constraints: + matched_constraint = VERSION_CONSTRAINT_PATTERN.match(constraint) + if not matched_constraint: + return None + operator, version = matched_constraint.groups() + operator = operator or '==' + if operator in {'^', '~', '!='}: + return None + if operator in {'==', '='}: + exact = version + lower = DependencyVersionBound(version=version, inclusive=True) + upper = DependencyVersionBound(version=version, inclusive=True) + continue + if operator in {'>=', '>'}: + lower = select_tighter_lower_bound( + lower, + DependencyVersionBound(version=version, inclusive=operator == '>='), + ) + if lower is None: + return None + continue + if operator in {'<=', '<'}: + upper = select_tighter_upper_bound( + upper, + DependencyVersionBound(version=version, inclusive=operator == '<='), + ) + if upper is None: + return None + continue + return None + return DependencyVersionRange(lower=lower, upper=upper, exact=exact) + + +def select_tighter_lower_bound( + current: DependencyVersionBound | None, + candidate: DependencyVersionBound, +) -> DependencyVersionBound | None: + """ + 选择更严格的下界。 + + :param current: 当前下界 + :param candidate: 候选下界 + :return: 更严格下界 + """ + if current is None: + return candidate + comparison = compare_dependency_versions(candidate.version, current.version) + if comparison is None: + return None + if comparison > 0: + return candidate + if comparison < 0: + return current + return candidate if current.inclusive and not candidate.inclusive else current + + +def select_tighter_upper_bound( + current: DependencyVersionBound | None, + candidate: DependencyVersionBound, +) -> DependencyVersionBound | None: + """ + 选择更严格的上界。 + + :param current: 当前上界 + :param candidate: 候选上界 + :return: 更严格上界 + """ + if current is None: + return candidate + comparison = compare_dependency_versions(candidate.version, current.version) + if comparison is None: + return None + if comparison < 0: + return candidate + if comparison > 0: + return current + return candidate if current.inclusive and not candidate.inclusive else current + + +def version_range_contains(allowed_range: DependencyVersionRange, requested_range: DependencyVersionRange) -> bool: + """ + 判断允许范围是否完整包含请求范围。 + + :param allowed_range: 允许列表范围 + :param requested_range: 依赖声明范围 + :return: 是否包含 + """ + if requested_range.exact: + return version_range_contains_version(allowed_range, requested_range.exact) + if allowed_range.exact: + return False + return lower_bound_contains(allowed_range.lower, requested_range.lower) and upper_bound_contains( + allowed_range.upper, + requested_range.upper, + ) + + +def version_range_contains_version(version_range: DependencyVersionRange, version: str) -> bool: + """ + 判断版本是否落入范围。 + + :param version_range: 版本范围 + :param version: 版本 + :return: 是否落入范围 + """ + if version_range.exact: + return PluginVersionComparator.equals(version, version_range.exact) + if version_range.lower: + lower_comparison = compare_dependency_versions(version, version_range.lower.version) + if lower_comparison is None or lower_comparison < 0: + return False + if lower_comparison == 0 and not version_range.lower.inclusive: + return False + if version_range.upper: + upper_comparison = compare_dependency_versions(version, version_range.upper.version) + if upper_comparison is None or upper_comparison > 0: + return False + if upper_comparison == 0 and not version_range.upper.inclusive: + return False + return True + + +def lower_bound_contains( + allowed_lower: DependencyVersionBound | None, + requested_lower: DependencyVersionBound | None, +) -> bool: + """ + 判断请求下界是否被允许下界覆盖。 + + :param allowed_lower: 允许范围下界 + :param requested_lower: 请求范围下界 + :return: 是否覆盖 + """ + if allowed_lower is None: + return True + if requested_lower is None: + return False + comparison = compare_dependency_versions(requested_lower.version, allowed_lower.version) + if comparison is None: + return False + if comparison > 0: + return True + if comparison < 0: + return False + return allowed_lower.inclusive or not requested_lower.inclusive + + +def upper_bound_contains( + allowed_upper: DependencyVersionBound | None, + requested_upper: DependencyVersionBound | None, +) -> bool: + """ + 判断请求上界是否被允许上界覆盖。 + + :param allowed_upper: 允许范围上界 + :param requested_upper: 请求范围上界 + :return: 是否覆盖 + """ + if allowed_upper is None: + return True + if requested_upper is None: + return False + comparison = compare_dependency_versions(requested_upper.version, allowed_upper.version) + if comparison is None: + return False + if comparison < 0: + return True + if comparison > 0: + return False + return allowed_upper.inclusive or not requested_upper.inclusive + + +def compare_dependency_versions(left: str, right: str) -> int | None: + """ + 比较依赖版本。 + + :param left: 左侧版本 + :param right: 右侧版本 + :return: 比较结果 + """ + return PluginVersionComparator.compare(left, right) + + +def version_satisfies_range(version: str, version_range: str) -> bool: + """ + 判断版本是否满足逗号分隔的版本范围。 + + :param version: 版本 + :param version_range: 版本范围 + :return: 是否满足 + """ + constraints = [constraint.strip() for constraint in version_range.split(',') if constraint.strip()] + if not constraints: + return True + for constraint in constraints: + parsed_dependency = DependencyRequirementParser.parse(f'pkg{constraint}') + if not PluginVersionConstraintMatcher.is_satisfied( + version, + parsed_dependency.operator, + parsed_dependency.version, + ): + return False + return True + + +def normalize_sri_digest(digest: str) -> str: + """ + 归一化 SRI base64 摘要。 + + :param digest: 摘要 + :return: 去除填充后的摘要 + """ + return digest.strip().rstrip('=') + + +def append_unique(items: list[str], item: str) -> None: + """ + 追加唯一字符串。 + + :param items: 字符串列表 + :param item: 待追加字符串 + :return: None + """ + if item not in items: + items.append(item) + + +def list_unique(items: list[str]) -> list[str]: + """ + 保持顺序去重。 + + :param items: 字符串列表 + :return: 去重结果 + """ + result: list[str] = [] + for item in items: + append_unique(result, item) + return result + + +__all__ = [ + 'DependencyAllowlist', + 'DependencyArtifactStore', + 'DependencyInstallPolicyConfig', + 'DependencyInstallPolicyDecision', + 'DependencyInstallPolicyEvaluator', + 'DependencyInstallPolicyItemDecision', + 'DependencyInstallPolicyMode', + 'DependencyLockEntry', + 'DependencyLockfile', +] diff --git a/ruoyi-fastapi-backend/plugins/core/validation/manifest.py b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py new file mode 100644 index 0000000..910e701 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py @@ -0,0 +1,715 @@ +import json +import platform +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar + +from config.env import DataBaseConfig +from plugins.core.environment import PluginRuntimeEnvironmentService +from plugins.core.manifest.menu_tree import PluginMenuTree +from plugins.core.manifest.schema import PluginManifest +from plugins.core.validation.dependencies import DependencyRequirementParser +from plugins.core.validation.python_requirements import PythonRequirementParser +from plugins.core.validation.result import PluginValidationIssue +from plugins.core.validation.versioning import PluginVersionConstraintMatcher + +COMPATIBILITY_CONSTRAINT_PATTERN = re.compile(r'^\s*([<>=!~^]{1,2})?\s*([A-Za-z0-9_.+\-!*]+)\s*$') +UNRESOLVED_NODE_VERSION = object() + + +@dataclass(frozen=True) +class PluginManifestCheckResult: + """ + 插件 manifest 非阻断检查结果。 + + :param plugin_id: 插件 ID + :param issues: manifest 检查问题项列表 + """ + + plugin_id: str + issues: list[PluginValidationIssue] + + @property + def ok(self) -> bool: + """ + 判断 manifest 检查是否存在阻断错误。 + + :return: 是否不存在 error 级问题 + """ + return not self.error_issues + + @property + def error_issues(self) -> list[PluginValidationIssue]: + """ + 获取 error 级问题项。 + + :return: error 级问题项列表 + """ + return [issue for issue in self.issues if issue.level == 'error'] + + @property + def warning_issues(self) -> list[PluginValidationIssue]: + """ + 获取 warning 级问题项。 + + :return: warning 级问题项列表 + """ + return [issue for issue in self.issues if issue.level == 'warning'] + + +class PluginManifestChecker: + """ + 插件 manifest 非阻断检查器。 + + 使用 Checker 模式承载不适合放入 Pydantic 强校验的提示类规则。 + """ + + _node_version_cache: ClassVar[object | str | None] = UNRESOLVED_NODE_VERSION + + def __init__( + self, + *, + backend_root: Path | None = None, + frontend_root: Path | None = None, + python_version: str | None = None, + node_version: str | None = None, + ) -> None: + """ + 初始化插件 manifest 非阻断检查器。 + + :param backend_root: 后端项目根目录 + :param frontend_root: 前端项目根目录 + :param python_version: 当前 Python 版本 + :param node_version: 当前 Node.js 版本 + :return: None + """ + self.backend_root = backend_root or Path(__file__).resolve().parents[3] + self.frontend_root = frontend_root or Path( + PluginRuntimeEnvironmentService(backend_root=self.backend_root).get_frontend_dir() + ) + self.python_version = python_version or platform.python_version() + self.node_version = node_version + + def check(self, manifest: PluginManifest) -> PluginManifestCheckResult: + """ + 检查插件 manifest 提示类问题。 + + :param manifest: 插件 manifest + :return: manifest 检查结果 + """ + issues = [] + issues.extend(self._check_secret_config_defaults(manifest)) + issues.extend(self._check_secret_config_type_alignment(manifest)) + issues.extend(self._check_unpinned_dependencies(manifest)) + issues.extend(self._check_resources_without_purge_hook(manifest)) + issues.extend(self._check_required_config_without_default(manifest)) + issues.extend(self._check_ineffective_config_constraints(manifest)) + issues.extend(self._check_permissions_without_plugin_prefix(manifest)) + issues.extend(self._check_frontend_menus_without_permissions(manifest)) + issues.extend(self._check_button_menus_without_permission(manifest)) + issues.extend(self._check_button_menu_structure(manifest)) + issues.extend(self._check_permission_button_parent(manifest)) + issues.extend(self._check_lifecycle_script_order(manifest)) + issues.extend(self._check_enabled_jobs_without_health_checker(manifest)) + issues.extend(self._check_unpaired_runtime_hooks(manifest)) + issues.extend(self._check_compatibility(manifest)) + + return PluginManifestCheckResult(plugin_id=manifest.id, issues=issues) + + def _check_secret_config_defaults(self, manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查敏感配置默认值声明。 + + :param manifest: 插件 manifest + :return: 敏感配置默认值问题项列表 + """ + issues = [] + for config_item in manifest.config.items: + if not config_item.secret or config_item.default in (None, ''): + continue + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='secret_config_default', + path=f'config.items.{config_item.key}.default', + message=f'敏感配置 {config_item.key} 声明了非空默认值', + suggestion='建议删除默认值,改为安装后在插件配置中录入', + ok=True, + ) + ) + + return issues + + @staticmethod + def _check_secret_config_type_alignment(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查敏感配置类型和 secret 标记是否一致。 + + :param manifest: 插件 manifest + :return: 敏感配置类型一致性问题项列表 + """ + issues = [] + for config_item in manifest.config.items: + if config_item.type == 'password' and not config_item.secret: + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='password_config_without_secret', + path=f'config.items.{config_item.key}.secret', + message=f'密码配置 {config_item.key} 未声明 secret=true', + suggestion='建议为 password 类型配置显式声明 secret=true,避免配置展示和导出时泄露敏感值', + ok=True, + ) + ) + if config_item.secret and config_item.type != 'password': + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='secret_config_non_password_type', + path=f'config.items.{config_item.key}.type', + message=f'敏感配置 {config_item.key} 的类型不是 password', + suggestion='建议将敏感配置类型设置为 password,便于前端输入控件、导出和审计统一脱敏处理', + ok=True, + ) + ) + + return issues + + def _check_unpinned_dependencies(self, manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查未声明版本约束的依赖。 + + :param manifest: 插件 manifest + :return: 未声明版本约束问题项列表 + """ + issues = [] + for dependency_kind, requirements in ( + ('python', manifest.dependencies.python), + ('npm', manifest.dependencies.npm), + ('npmDev', manifest.dependencies.npm_dev), + ): + for index, requirement in enumerate(requirements): + if dependency_kind == 'python': + parsed_python = PythonRequirementParser.parse(requirement) + name = parsed_python.name + has_version_constraint = bool(parsed_python.required_version) + else: + parsed_requirement = DependencyRequirementParser.parse(requirement) + name = parsed_requirement.name + has_version_constraint = bool(parsed_requirement.required_version) + if has_version_constraint: + continue + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='dependency_unpinned', + path=f'dependencies.{dependency_kind}.{index}', + message=f'{dependency_kind} 依赖 {name} 未声明版本约束', + suggestion='建议为插件依赖声明最小版本或兼容版本范围,降低环境漂移风险', + ok=True, + ) + ) + for dependency in manifest.dependencies.plugins: + if dependency.version: + continue + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='plugin_dependency_unpinned', + path=f'dependencies.plugins.{dependency.id}.version', + message=f'插件依赖 {dependency.id} 未声明版本约束', + suggestion='建议声明依赖插件的最小版本或兼容版本范围', + ok=True, + ) + ) + + return issues + + @staticmethod + def _check_resources_without_purge_hook(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查资源声明是否缺少物理清理钩子。 + + :param manifest: 插件 manifest + :return: 资源清理提示问题项列表 + """ + resources = manifest.resources + resource_count = len(resources.static) + len(resources.uploads) + len(resources.temp) + if resource_count == 0 or manifest.backend.hooks.on_purge: + return [] + + return [ + PluginValidationIssue( + level='warning', + category='manifest', + kind='resources_without_purge_hook', + path='resources', + message='插件声明了资源清单,但未声明 onPurge 清理钩子', + suggestion='如资源需要随插件物理清理,请通过 backend.hooks.onPurge 显式实现清理逻辑', + ok=True, + ) + ] + + @staticmethod + def _check_required_config_without_default(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查必填配置是否缺少默认值。 + + :param manifest: 插件 manifest + :return: 必填配置默认值提示问题项列表 + """ + issues = [] + for config_item in manifest.config.items: + if not config_item.required or config_item.default not in (None, ''): + continue + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='required_config_without_default', + path=f'config.items.{config_item.key}.default', + message=f'必填配置 {config_item.key} 未声明默认值', + suggestion='建议提供安全默认值,或在插件 README 中明确安装后必须配置该项', + ok=True, + ) + ) + + return issues + + @staticmethod + def _check_ineffective_config_constraints(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查配置项声明了当前类型不会生效的增强约束。 + + :param manifest: 插件 manifest + :return: 无效配置增强约束问题项列表 + """ + issues = [] + for config_item in manifest.config.items: + if config_item.type != 'number' and ( + config_item.min_value is not None or config_item.max_value is not None + ): + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='ineffective_config_constraint', + path=f'config.items.{config_item.key}.min/max', + message=f'配置 {config_item.key} 不是 number 类型,min/max 约束不会生效', + suggestion='仅 number 类型配置支持 min/max;请删除该约束或将配置类型改为 number', + ok=True, + ) + ) + if config_item.type not in {'string', 'textarea', 'password'} and config_item.pattern: + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='ineffective_config_constraint', + path=f'config.items.{config_item.key}.pattern', + message=f'配置 {config_item.key} 不是文本类型,pattern 约束不会生效', + suggestion='仅 string、textarea、password 类型配置支持 pattern;请删除该约束或调整配置类型', + ok=True, + ) + ) + if config_item.type != 'select' and config_item.options: + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='ineffective_config_constraint', + path=f'config.items.{config_item.key}.options', + message=f'配置 {config_item.key} 不是 select 类型,options 声明不会生效', + suggestion='仅 select 类型配置支持 options;请删除 options 或将配置类型改为 select', + ok=True, + ) + ) + + return issues + + @staticmethod + def _check_permissions_without_plugin_prefix(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查权限标识是否使用插件 ID 前缀。 + + :param manifest: 插件 manifest + :return: 权限前缀提示问题项列表 + """ + expected_prefix = f'{manifest.id}:' + permission_set = set(manifest.permission_codes) | PluginMenuTree.collect_permissions(manifest.frontend.menus) + declared_permissions = { + permission for permission in permission_set if not permission.startswith(expected_prefix) + } + return [ + PluginValidationIssue( + level='warning', + category='manifest', + kind='permission_without_plugin_prefix', + path=f'permissions.{permission}', + message=f'权限标识 {permission} 未使用插件 ID 前缀', + suggestion=f'建议使用 {expected_prefix}: 格式,降低与平台或其他插件权限冲突的风险', + ok=True, + ) + for permission in sorted(declared_permissions) + ] + + @staticmethod + def _check_frontend_menus_without_permissions(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查前端菜单是否完全缺少权限声明。 + + :param manifest: 插件 manifest + :return: 前端菜单权限提示问题项列表 + """ + if not manifest.frontend.menus: + return [] + if not any(menu.type != 'F' for menu in PluginMenuTree.flatten(manifest.frontend.menus)): + return [] + if manifest.permission_codes or PluginMenuTree.collect_permissions(manifest.frontend.menus): + return [] + + return [ + PluginValidationIssue( + level='warning', + category='manifest', + kind='frontend_menus_without_permissions', + path='frontend.menus', + message='插件声明了前端菜单,但没有声明任何权限标识', + suggestion='建议至少为页面菜单声明 perms 并在顶层 permissions 中同步声明,便于角色授权和菜单可见性统一管理', + ok=True, + ) + ] + + @staticmethod + def _check_button_menus_without_permission(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查按钮菜单是否声明权限标识。 + + :param manifest: 插件 manifest + :return: 按钮菜单权限提示问题项列表 + """ + issues = [] + for menu in PluginMenuTree.flatten(manifest.frontend.menus): + if menu.type != 'F' or menu.perms: + continue + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='button_menu_without_permission', + path=f'frontend.menus.{menu.path}.perms', + message=f'按钮菜单 {menu.name} 未声明权限标识', + suggestion='建议为按钮菜单声明 perms,并在顶层 permissions 中同步声明,便于前端权限指令和后端权限校验统一控制', + ok=True, + ) + ) + + return issues + + @staticmethod + def _check_button_menu_structure(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查按钮菜单结构是否混入路由菜单语义。 + + :param manifest: 插件 manifest + :return: 按钮菜单结构提示问题项列表 + """ + issues = [] + for menu in PluginMenuTree.flatten(manifest.frontend.menus): + if menu.type != 'F': + continue + if menu.component: + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='button_menu_with_component', + path=f'frontend.menus.{menu.path}.component', + message=f'按钮菜单 {menu.name} 声明了 component', + suggestion='按钮菜单只用于权限动作,不参与路由渲染;建议将 component 留空', + ok=True, + ) + ) + if menu.children: + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='button_menu_with_children', + path=f'frontend.menus.{menu.path}.children', + message=f'按钮菜单 {menu.name} 声明了子菜单', + suggestion='按钮菜单不应承载子菜单;建议将子菜单移动到目录或页面菜单下', + ok=True, + ) + ) + + return issues + + @staticmethod + def _check_permission_button_parent(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查自动权限按钮是否存在可挂载的页面或目录菜单。 + + :param manifest: 插件 manifest + :return: 自动权限按钮父菜单提示问题项列表 + """ + menu_permissions = PluginMenuTree.collect_permissions(manifest.frontend.menus) + auto_button_permissions = sorted(set(manifest.permission_codes) - menu_permissions) + if not auto_button_permissions: + return [] + has_parent_menu = any(menu.type != 'F' for menu in PluginMenuTree.flatten(manifest.frontend.menus)) + if has_parent_menu: + return [] + + return [ + PluginValidationIssue( + level='warning', + category='manifest', + kind='permission_without_menu_parent', + path=f'permissions.{permission}', + message=f'权限标识 {permission} 未声明菜单承载,且插件没有可挂载按钮的页面或目录菜单', + suggestion='如该权限需要分配给角色,建议声明页面/目录菜单,或显式声明按钮菜单承载该权限', + ok=True, + ) + for permission in auto_button_permissions + ] + + @staticmethod + def _check_lifecycle_script_order(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查 migration 和 seed 声明顺序是否稳定。 + + :param manifest: 插件 manifest + :return: 生命周期脚本顺序提示问题项列表 + """ + issues = [] + for field_name, script_paths in ( + ('migrations', manifest.backend.migrations), + ('seeds', manifest.backend.seeds), + ): + sorted_paths = sorted(script_paths) + if script_paths == sorted_paths: + continue + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='script_order_unsorted', + path=f'backend.{field_name}', + message=f'backend.{field_name} 未按文件名顺序声明', + suggestion='插件 migration 和 seed 会按 manifest 列表顺序执行;建议按文件名升序声明,降低执行顺序出错风险', + ok=True, + ) + ) + + return issues + + @staticmethod + def _check_enabled_jobs_without_health_checker(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查默认启用任务是否缺少健康检查声明。 + + :param manifest: 插件 manifest + :return: 定时任务健康检查提示问题项列表 + """ + enabled_jobs = [job for job in manifest.backend.jobs if job.enabled] + if not enabled_jobs or manifest.backend.health.checker: + return [] + + return [ + PluginValidationIssue( + level='warning', + category='manifest', + kind='enabled_jobs_without_health_checker', + path='backend.health.checker', + message='插件声明了默认启用的定时任务,但未声明健康检查 callable', + suggestion='建议为包含默认启用任务的插件声明 backend.health.checker,便于运维侧确认任务依赖状态', + ok=True, + ) + ] + + @staticmethod + def _check_unpaired_runtime_hooks(manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查启动和关闭钩子是否成对声明。 + + :param manifest: 插件 manifest + :return: 生命周期钩子配对提示问题项列表 + """ + hooks = manifest.backend.hooks + if bool(hooks.on_startup) == bool(hooks.on_shutdown): + return [] + + missing_hook = 'onShutdown' if hooks.on_startup else 'onStartup' + declared_hook = 'onStartup' if hooks.on_startup else 'onShutdown' + return [ + PluginValidationIssue( + level='warning', + category='manifest', + kind='unpaired_runtime_hook', + path=f'backend.hooks.{missing_hook}', + message=f'插件声明了 {declared_hook},但未声明 {missing_hook}', + suggestion='建议启动和关闭钩子成对声明,确保运行时资源可以完整初始化和释放', + ok=True, + ) + ] + + def _check_compatibility(self, manifest: PluginManifest) -> list[PluginValidationIssue]: + """ + 检查插件平台兼容性声明。 + + :param manifest: 插件 manifest + :return: 平台兼容性问题项列表 + """ + compatibility = manifest.compatibility + checks = [ + ('backendVersion', compatibility.backend_version, self._read_backend_version, 'backend'), + ('frontendVersion', compatibility.frontend_version, self._read_frontend_version, 'frontend'), + ('pythonVersion', compatibility.python_version, lambda: self.python_version, 'python'), + ('nodeVersion', compatibility.node_version, self._resolve_node_version, 'node'), + ] + issues = [] + for field_name, constraint, version_loader, target_name in checks: + if not constraint: + continue + current_version = version_loader() + if current_version is None: + issues.append( + PluginValidationIssue( + level='warning', + category='manifest', + kind='compatibility_unknown', + path=f'compatibility.{field_name}', + message=f'无法读取当前 {target_name} 版本,跳过兼容性判断', + suggestion='确认运行环境可读取版本信息后重新执行插件检查', + ok=True, + ) + ) + continue + if self._version_satisfied(current_version, constraint): + continue + issues.append( + PluginValidationIssue( + level='error', + category='manifest', + kind='compatibility_unsatisfied', + path=f'compatibility.{field_name}', + message=f'{target_name} 版本不满足插件兼容性声明:current={current_version} required={constraint}', + suggestion='升级平台运行环境或调整插件 compatibility 声明', + ok=False, + ) + ) + + current_database = DataBaseConfig.db_type + if compatibility.databases and current_database not in compatibility.databases: + issues.append( + PluginValidationIssue( + level='error', + category='manifest', + kind='compatibility_unsatisfied', + path='compatibility.databases', + message=( + 'database 类型不满足插件兼容性声明:' + f'current={current_database} required={", ".join(compatibility.databases)}' + ), + suggestion='切换到插件支持的数据库,或调整插件 compatibility.databases 声明', + ok=False, + ) + ) + + return issues + + def _read_backend_version(self) -> str | None: + """ + 读取后端项目版本。 + + :return: 后端项目版本,读取失败时返回 None + """ + return self._read_pyproject_version(self.backend_root / 'pyproject.toml') + + def _read_frontend_version(self) -> str | None: + """ + 读取前端项目版本。 + + :return: 前端项目版本,读取失败时返回 None + """ + package_json = self.frontend_root / 'package.json' + try: + payload = json.loads(package_json.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return None + version = payload.get('version') if isinstance(payload, dict) else None + + return str(version) if version else None + + @staticmethod + def _read_pyproject_version(pyproject_path: Path) -> str | None: + """ + 读取 pyproject.toml 中的项目版本。 + + :param pyproject_path: pyproject.toml 路径 + :return: 项目版本,读取失败时返回 None + """ + try: + for line in pyproject_path.read_text(encoding='utf-8').splitlines(): + stripped_line = line.strip() + if stripped_line.startswith('version ='): + return stripped_line.split('=', maxsplit=1)[1].strip().strip('"\'') + except OSError: + return None + + return None + + def _resolve_node_version(self) -> str | None: + """ + 解析当前 Node.js 版本。 + + :return: Node.js 版本,读取失败时返回 None + """ + if self.node_version is not None: + return self.node_version + cached_node_version = self.__class__._node_version_cache + if cached_node_version is not UNRESOLVED_NODE_VERSION: + return cached_node_version if isinstance(cached_node_version, str) else None + try: + completed = subprocess.run( + ['node', '--version'], + capture_output=True, + text=True, + check=False, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + self.__class__._node_version_cache = None + return None + if completed.returncode != 0: + self.__class__._node_version_cache = None + return None + + resolved_node_version = completed.stdout.strip().lstrip('v') or None + self.__class__._node_version_cache = resolved_node_version + return resolved_node_version + + @staticmethod + def _version_satisfied(current_version: str, constraint: str) -> bool: + """ + 判断当前版本是否满足兼容性约束。 + + :param current_version: 当前版本 + :param constraint: 兼容性约束 + :return: 是否满足 + """ + matched_constraint = COMPATIBILITY_CONSTRAINT_PATTERN.match(constraint) + if not matched_constraint: + return True + operator, required_version = matched_constraint.groups() + operator = operator or '==' + + return PluginVersionConstraintMatcher.is_satisfied(current_version, operator, required_version) diff --git a/ruoyi-fastapi-backend/plugins/core/validation/menus.py b/ruoyi-fastapi-backend/plugins/core/validation/menus.py new file mode 100644 index 0000000..e7f1668 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/menus.py @@ -0,0 +1,227 @@ +from dataclasses import dataclass + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.manifest.schema import PluginMenuManifest + + +@dataclass(frozen=True) +class PluginMenuConflictItem: + """ + 插件菜单冲突检查项。 + + :param kind: 冲突类型 + :param plugin_id: 当前插件 ID + :param value: 冲突值 + :param message: 冲突说明 + :param conflict_plugin_id: 冲突插件 ID + """ + + kind: str + plugin_id: str + value: str + message: str + conflict_plugin_id: str | None = None + + +@dataclass(frozen=True) +class PluginMenuConflictResult: + """ + 插件菜单冲突检查结果。 + + :param plugin_id: 插件 ID + :param items: 冲突检查项列表 + """ + + plugin_id: str + items: list[PluginMenuConflictItem] + + @property + def ok(self) -> bool: + """ + 判断菜单冲突检查是否通过。 + + :return: 是否通过 + """ + return not self.items + + +@dataclass(frozen=True) +class PluginMenuSnapshot: + """ + 插件菜单快照。 + + :param plugin_id: 插件 ID + :param menu: 插件菜单声明 + :param menu_key: 插件内菜单自然键 + """ + + plugin_id: str + menu: PluginMenuManifest + menu_key: str + + +class PluginMenuConflictChecker: + """ + 插件菜单冲突检查器。 + + 使用 Checker 模式在安装前检查 manifest 层面的菜单自然键和权限冲突。 + """ + + def check( + self, + plugin: DiscoveredPlugin, + all_plugins: list[DiscoveredPlugin] | None = None, + ) -> PluginMenuConflictResult: + """ + 检查指定插件菜单冲突。 + + :param plugin: 当前待检查插件 + :param all_plugins: 全量已发现插件列表 + :return: 菜单冲突检查结果 + """ + all_plugin_list = all_plugins or [plugin] + snapshots_by_plugin = { + discovered_plugin.manifest.id: self._build_menu_snapshots(discovered_plugin) + for discovered_plugin in all_plugin_list + } + current_snapshots = snapshots_by_plugin.get(plugin.manifest.id, []) + items = [] + items.extend(self._check_duplicate_menu_keys(plugin.manifest.id, current_snapshots)) + items.extend(self._check_duplicate_permissions(plugin.manifest.id, current_snapshots, snapshots_by_plugin)) + + return PluginMenuConflictResult(plugin_id=plugin.manifest.id, items=items) + + def _check_duplicate_menu_keys( + self, + plugin_id: str, + snapshots: list[PluginMenuSnapshot], + ) -> list[PluginMenuConflictItem]: + """ + 检查同一插件内菜单自然键重复。 + + :param plugin_id: 插件 ID + :param snapshots: 当前插件菜单快照列表 + :return: 冲突检查项列表 + """ + seen_keys = set() + conflicts = [] + for snapshot in snapshots: + if snapshot.menu_key in seen_keys: + conflicts.append( + PluginMenuConflictItem( + kind='duplicate_menu_key', + plugin_id=plugin_id, + value=snapshot.menu_key, + message=f'插件 {plugin_id} 存在重复菜单自然键:{snapshot.menu_key}', + ) + ) + seen_keys.add(snapshot.menu_key) + + return conflicts + + def _check_duplicate_permissions( + self, + plugin_id: str, + current_snapshots: list[PluginMenuSnapshot], + snapshots_by_plugin: dict[str, list[PluginMenuSnapshot]], + ) -> list[PluginMenuConflictItem]: + """ + 检查不同插件之间权限标识重复。 + + :param plugin_id: 当前插件 ID + :param current_snapshots: 当前插件菜单快照列表 + :param snapshots_by_plugin: 全量插件菜单快照 + :return: 冲突检查项列表 + """ + permission_owner_map = self._build_permission_owner_map(snapshots_by_plugin) + conflicts = [] + for snapshot in current_snapshots: + if not snapshot.menu.perms: + continue + conflict_plugin_id = permission_owner_map.get(snapshot.menu.perms) + if conflict_plugin_id and conflict_plugin_id != plugin_id: + conflicts.append( + PluginMenuConflictItem( + kind='duplicate_permission', + plugin_id=plugin_id, + conflict_plugin_id=conflict_plugin_id, + value=snapshot.menu.perms, + message=(f'插件 {plugin_id} 权限 {snapshot.menu.perms} 与插件 {conflict_plugin_id} 冲突'), + ) + ) + + return conflicts + + def _build_permission_owner_map( + self, + snapshots_by_plugin: dict[str, list[PluginMenuSnapshot]], + ) -> dict[str, str]: + """ + 构建权限标识归属映射。 + + :param snapshots_by_plugin: 全量插件菜单快照 + :return: 权限标识与插件 ID 映射 + """ + permission_owner_map = {} + for plugin_id, snapshots in snapshots_by_plugin.items(): + for snapshot in snapshots: + if snapshot.menu.perms and snapshot.menu.perms not in permission_owner_map: + permission_owner_map[snapshot.menu.perms] = plugin_id + + return permission_owner_map + + def _build_menu_snapshots(self, plugin: DiscoveredPlugin) -> list[PluginMenuSnapshot]: + """ + 构建插件菜单快照列表。 + + :param plugin: 已发现插件 + :return: 菜单快照列表 + """ + snapshots = [] + for menu in plugin.manifest.frontend.menus: + snapshots.extend( + self._build_menu_tree_snapshots( + plugin_id=plugin.manifest.id, + menu=menu, + parent_key=plugin.manifest.id, + ) + ) + + return snapshots + + def _build_menu_tree_snapshots( + self, + plugin_id: str, + menu: PluginMenuManifest, + parent_key: str, + ) -> list[PluginMenuSnapshot]: + """ + 递归构建菜单树快照。 + + :param plugin_id: 插件 ID + :param menu: 插件菜单声明 + :param parent_key: 父菜单自然键 + :return: 菜单快照列表 + """ + menu_key = self._build_menu_key(menu, parent_key) + snapshots = [PluginMenuSnapshot(plugin_id=plugin_id, menu=menu, menu_key=menu_key)] + for child_menu in menu.children: + snapshots.extend(self._build_menu_tree_snapshots(plugin_id, child_menu, menu_key)) + + return snapshots + + @staticmethod + def _build_menu_key(menu: PluginMenuManifest, parent_key: str) -> str: + """ + 构建插件菜单自然键。 + + :param menu: 插件菜单声明 + :param parent_key: 父级菜单自然键 + :return: 插件菜单自然键 + """ + if menu.type == 'F': + return f'button:{parent_key}/{menu.name}#{menu.perms}' + if menu.perms: + return f'perm:{menu.perms}' + + return f'route:{parent_key}/{menu.path}#{menu.component}' diff --git a/ruoyi-fastapi-backend/plugins/core/validation/plugin_deps.py b/ruoyi-fastapi-backend/plugins/core/validation/plugin_deps.py new file mode 100644 index 0000000..518625d --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/plugin_deps.py @@ -0,0 +1,790 @@ +from dataclasses import dataclass +from typing import Literal + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.manifest.schema import PluginDependencyManifest, PluginManifest +from plugins.core.state import PluginStateResolver +from plugins.core.types import PluginStateRecord +from plugins.core.validation.dependencies import DependencyRequirementParser, ParsedDependency +from plugins.core.validation.versioning import PluginVersionConstraintMatcher + +PluginDependencyStatus = Literal[ + 'satisfied', + 'missing', + 'not_installed', + 'disabled', + 'version_unsatisfied', + 'cycle', + 'dependent', +] +PluginBatchOperation = Literal['install', 'enable', 'upgrade', 'uninstall', 'purge'] +PluginDependencyPlanBlockerStatus = Literal[ + 'missing', + 'not_installed', + 'disabled', + 'version_unsatisfied', + 'source_version_unsatisfied', + 'cycle', + 'unknown_operation', +] + + +class PluginDependencyVersionMatcher: + """ + 插件依赖版本匹配器。 + + 使用 Matcher 模式将插件依赖声明中的版本约束转换为统一版本匹配逻辑, + 避免检查器和计划构建器分别解析约束。 + """ + + @staticmethod + def is_satisfied(installed_version: str | None, version_constraint: str | None) -> bool: + """ + 判断插件版本是否满足约束。 + + :param installed_version: 已安装或源码版本 + :param version_constraint: 版本约束 + :return: 是否满足 + """ + if not version_constraint: + return True + parsed_dependency = PluginDependencyVersionMatcher._parse_constraint(version_constraint) + return PluginVersionConstraintMatcher.is_satisfied( + installed_version, + parsed_dependency.operator, + parsed_dependency.version, + ) + + @staticmethod + def _parse_constraint(version_constraint: str) -> ParsedDependency: + """ + 解析插件版本约束。 + + :param version_constraint: 版本约束 + :return: 已解析依赖声明 + """ + normalized_constraint = version_constraint.strip() + if normalized_constraint.startswith(('>=', '<=', '==', '!=', '>', '<', '=', '^', '~')): + return DependencyRequirementParser.parse(f'plugin{normalized_constraint}') + + parsed_dependency = DependencyRequirementParser.parse(f'plugin=={normalized_constraint}') + return parsed_dependency + + +@dataclass(frozen=True) +class PluginDependencyCheckItem: + """ + 插件间依赖检查项。 + """ + + plugin_id: str + dependency_id: str + required_version: str | None + installed_version: str | None + status: PluginDependencyStatus + message: str + + @property + def ok(self) -> bool: + """ + 判断插件依赖检查项是否通过。 + + :return: 是否通过 + """ + return self.status == 'satisfied' + + +@dataclass(frozen=True) +class PluginDependencyCheckResult: + """ + 插件间依赖检查结果。 + """ + + plugin_id: str + items: list[PluginDependencyCheckItem] + + @property + def ok(self) -> bool: + """ + 判断插件间依赖是否整体通过。 + + :return: 是否通过 + """ + return all(item.ok for item in self.items) + + @property + def failed_items(self) -> list[PluginDependencyCheckItem]: + """ + 获取失败的插件依赖检查项。 + + :return: 失败检查项列表 + """ + return [item for item in self.items if not item.ok] + + +@dataclass(frozen=True) +class PluginDependencyPlanBlocker: + """ + 插件批量操作计划阻塞项。 + """ + + plugin_id: str + dependency_id: str + status: PluginDependencyPlanBlockerStatus + message: str + + +@dataclass(frozen=True) +class PluginDependencyPlanItem: + """ + 插件批量操作计划项。 + """ + + plugin_id: str + name: str + version: str + operation: PluginBatchOperation + order: int + requested: bool + dependencies: list[str] + installed_version: str | None + enabled: str | None + status: str | None + blockers: list[PluginDependencyPlanBlocker] + + @property + def ready(self) -> bool: + """ + 判断当前计划项是否可执行。 + + :return: 是否可执行 + """ + return not self.blockers + + +@dataclass(frozen=True) +class PluginDependencyPlan: + """ + 插件批量操作拓扑计划。 + """ + + operation: PluginBatchOperation + requested_plugin_ids: list[str] + ordered_plugin_ids: list[str] + items: list[PluginDependencyPlanItem] + blockers: list[PluginDependencyPlanBlocker] + + @property + def ok(self) -> bool: + """ + 判断插件批量操作计划是否可执行。 + + :return: 是否可执行 + """ + return not self.blockers + + +class PluginDependencyChecker: + """ + 插件间依赖检查器。 + + 使用 Checker 模式校验插件之间的存在性、安装状态、启用状态、版本约束和循环依赖。 + """ + + def __init__( + self, + discovered_plugins: list[DiscoveredPlugin], + database_plugins: list[PluginStateRecord] | None = None, + ) -> None: + """ + 初始化插件间依赖检查器。 + + :param discovered_plugins: 已发现插件列表 + :param database_plugins: 数据库插件状态列表 + :return: None + """ + self.discovered_plugin_map = {plugin.manifest.id: plugin for plugin in discovered_plugins} + self.database_plugin_map = {plugin.plugin_id: plugin for plugin in database_plugins or []} + + def check_manifest(self, manifest: PluginManifest) -> PluginDependencyCheckResult: + """ + 检查单个插件清单的插件间依赖。 + + :param manifest: 插件清单 + :return: 插件间依赖检查结果 + """ + items = [self._check_dependency(manifest.id, dependency) for dependency in manifest.dependencies.plugins] + items.extend(self._check_cycles(manifest.id)) + + return PluginDependencyCheckResult(plugin_id=manifest.id, items=items) + + def check_enabled_dependents(self, plugin_id: str) -> PluginDependencyCheckResult: + """ + 检查指定插件是否仍被已启用插件依赖。 + + :param plugin_id: 被停用或卸载的插件ID + :return: 被依赖方检查结果 + """ + target_plugin = self.discovered_plugin_map.get(plugin_id) + target_database_plugin = self.database_plugin_map.get(plugin_id) + installed_version = self._resolve_installed_version(target_plugin, target_database_plugin) + items: list[PluginDependencyCheckItem] = [] + for dependent_id, dependency in PluginDependencyGraph(self.discovered_plugin_map).find_direct_dependents( + plugin_id + ): + database_plugin = self.database_plugin_map.get(dependent_id) + if not getattr(database_plugin, 'installed_version', None): + continue + if not PluginStateResolver.is_database_plugin_enabled(database_plugin): + continue + items.append( + PluginDependencyCheckItem( + plugin_id=dependent_id, + dependency_id=plugin_id, + required_version=dependency.version, + installed_version=installed_version, + status='dependent', + message=f'插件正在被已启用插件依赖:{dependent_id} -> {plugin_id}', + ) + ) + + return PluginDependencyCheckResult(plugin_id=plugin_id, items=items) + + def _check_dependency( + self, + plugin_id: str, + dependency: PluginDependencyManifest, + ) -> PluginDependencyCheckItem: + """ + 检查单条插件依赖。 + + :param plugin_id: 当前插件ID + :param dependency: 插件依赖声明 + :return: 插件依赖检查项 + """ + discovered_plugin = self.discovered_plugin_map.get(dependency.id) + database_plugin = self.database_plugin_map.get(dependency.id) + installed_version = self._resolve_installed_version(discovered_plugin, database_plugin) + if not discovered_plugin: + return self._build_item(plugin_id, dependency, None, 'missing', f'依赖插件不存在:{dependency.id}') + if not database_plugin or not getattr(database_plugin, 'installed_version', None): + return self._build_item( + plugin_id, + dependency, + installed_version, + 'not_installed', + f'依赖插件未安装:{dependency.id}', + ) + if not PluginStateResolver.is_database_plugin_enabled(database_plugin): + return self._build_item( + plugin_id, + dependency, + installed_version, + 'disabled', + f'依赖插件未启用:{dependency.id}', + ) + if not PluginDependencyVersionMatcher.is_satisfied(installed_version, dependency.version): + return self._build_item( + plugin_id, + dependency, + installed_version, + 'version_unsatisfied', + f'依赖插件版本不满足:{dependency.id} installed={installed_version} required={dependency.version}', + ) + + return self._build_item( + plugin_id, + dependency, + installed_version, + 'satisfied', + f'依赖插件已满足:{dependency.id}', + ) + + def _check_cycles(self, plugin_id: str) -> list[PluginDependencyCheckItem]: + """ + 检查从当前插件出发的循环依赖。 + + :param plugin_id: 当前插件ID + :return: 循环依赖检查项列表 + """ + cycle_path = PluginDependencyGraph(self.discovered_plugin_map).find_cycle_from(plugin_id) + if not cycle_path: + return [] + + cycle_text = ' -> '.join(cycle_path) + return [ + PluginDependencyCheckItem( + plugin_id=plugin_id, + dependency_id=cycle_path[-1], + required_version=None, + installed_version=None, + status='cycle', + message=f'插件依赖存在循环:{cycle_text}', + ) + ] + + @staticmethod + def _resolve_installed_version( + discovered_plugin: DiscoveredPlugin | None, + database_plugin: PluginStateRecord | None, + ) -> str | None: + """ + 解析依赖插件已安装版本。 + + :param discovered_plugin: 已发现插件 + :param database_plugin: 数据库插件状态 + :return: 已安装版本 + """ + installed_version = getattr(database_plugin, 'installed_version', None) + if installed_version: + return installed_version + return discovered_plugin.manifest.version if discovered_plugin else None + + @staticmethod + def _build_item( + plugin_id: str, + dependency: PluginDependencyManifest, + installed_version: str | None, + status: PluginDependencyStatus, + message: str, + ) -> PluginDependencyCheckItem: + """ + 构建插件依赖检查项。 + + :param plugin_id: 当前插件ID + :param dependency: 插件依赖声明 + :param installed_version: 已安装版本 + :param status: 检查状态 + :param message: 检查消息 + :return: 插件依赖检查项 + """ + return PluginDependencyCheckItem( + plugin_id=plugin_id, + dependency_id=dependency.id, + required_version=dependency.version, + installed_version=installed_version, + status=status, + message=message, + ) + + +class PluginDependencyGraph: + """ + 插件依赖图。 + + 使用 Graph 模式为插件间依赖提供循环检测,并为后续拓扑排序保留扩展点。 + """ + + def __init__(self, discovered_plugin_map: dict[str, DiscoveredPlugin]) -> None: + """ + 初始化插件依赖图。 + + :param discovered_plugin_map: 已发现插件映射 + :return: None + """ + self.discovered_plugin_map = discovered_plugin_map + + def find_cycle_from(self, plugin_id: str) -> list[str]: + """ + 查找从指定插件出发的循环依赖路径。 + + :param plugin_id: 插件ID + :return: 循环依赖路径,不存在时返回空列表 + """ + return self._find_cycle(plugin_id, [], set()) + + def find_direct_dependents(self, plugin_id: str) -> list[tuple[str, PluginDependencyManifest]]: + """ + 查找直接依赖指定插件的插件。 + + :param plugin_id: 被依赖插件ID + :return: 依赖方插件ID和依赖声明列表 + """ + return [ + (dependent_id, dependency) + for dependent_id, discovered_plugin in sorted(self.discovered_plugin_map.items()) + for dependency in discovered_plugin.manifest.dependencies.plugins + if dependency.id == plugin_id + ] + + def _find_cycle(self, plugin_id: str, path: list[str], visited: set[str]) -> list[str]: + """ + 深度优先查找循环依赖。 + + :param plugin_id: 当前插件ID + :param path: 当前访问路径 + :param visited: 已访问插件ID集合 + :return: 循环依赖路径 + """ + if plugin_id in path: + cycle_start = path.index(plugin_id) + return [*path[cycle_start:], plugin_id] + if plugin_id in visited: + return [] + visited.add(plugin_id) + + discovered_plugin = self.discovered_plugin_map.get(plugin_id) + if not discovered_plugin: + return [] + for dependency in discovered_plugin.manifest.dependencies.plugins: + cycle_path = self._find_cycle(dependency.id, [*path, plugin_id], visited) + if cycle_path: + return cycle_path + + return [] + + +class PluginDependencyPlanBuilder: + """ + 插件批量操作拓扑计划生成器。 + + 使用 Planner 模式为批量安装、启用和升级生成依赖优先的执行顺序,并输出阻塞原因。 + """ + + def __init__( + self, + discovered_plugins: list[DiscoveredPlugin], + database_plugins: list[PluginStateRecord] | None = None, + ) -> None: + """ + 初始化插件批量操作拓扑计划生成器。 + + :param discovered_plugins: 已发现插件列表 + :param database_plugins: 数据库插件状态列表 + :return: None + """ + self.discovered_plugin_map = {plugin.manifest.id: plugin for plugin in discovered_plugins} + self.database_plugin_map = {plugin.plugin_id: plugin for plugin in database_plugins or []} + + def build_plan( + self, + operation: PluginBatchOperation, + plugin_ids: list[str] | None = None, + ) -> PluginDependencyPlan: + """ + 构建插件批量操作拓扑计划。 + + :param operation: 批量操作类型 + :param plugin_ids: 指定插件ID列表,不传时计划全部已发现插件 + :return: 插件批量操作拓扑计划 + """ + requested_plugin_ids = plugin_ids or sorted(self.discovered_plugin_map) + closure, closure_blockers = self._collect_dependency_closure(requested_plugin_ids) + ordered_plugin_ids, topology_blockers = self._sort_dependency_first(closure) + blockers_by_plugin = self._group_blockers([*closure_blockers, *topology_blockers]) + items = [ + self._build_plan_item( + plugin_id, + order, + operation, + requested=plugin_id in requested_plugin_ids, + existing_blockers=blockers_by_plugin.get(plugin_id, []), + ) + for order, plugin_id in enumerate(ordered_plugin_ids, start=1) + ] + all_blockers = [*closure_blockers, *topology_blockers] + for item in items: + all_blockers.extend(item.blockers) + all_blockers = self._deduplicate_blockers(all_blockers) + + return PluginDependencyPlan( + operation=operation, + requested_plugin_ids=requested_plugin_ids, + ordered_plugin_ids=ordered_plugin_ids, + items=items, + blockers=all_blockers, + ) + + def _collect_dependency_closure( + self, + plugin_ids: list[str], + ) -> tuple[set[str], list[PluginDependencyPlanBlocker]]: + """ + 收集目标插件及其递归依赖闭包。 + + :param plugin_ids: 目标插件ID列表 + :return: 插件依赖闭包和阻塞项列表 + """ + closure: set[str] = set() + blockers: list[PluginDependencyPlanBlocker] = [] + visiting: list[str] = [] + + def visit(plugin_id: str, requested_by: str) -> None: + if plugin_id in visiting: + blockers.extend(self._build_cycle_blockers([*visiting[visiting.index(plugin_id) :], plugin_id])) + return + discovered_plugin = self.discovered_plugin_map.get(plugin_id) + if not discovered_plugin: + blockers.append( + PluginDependencyPlanBlocker( + plugin_id=requested_by, + dependency_id=plugin_id, + status='missing', + message=f'依赖插件不存在:{plugin_id}', + ) + ) + return + if plugin_id in closure: + return + visiting.append(plugin_id) + closure.add(plugin_id) + for dependency in discovered_plugin.manifest.dependencies.plugins: + visit(dependency.id, plugin_id) + visiting.pop() + + for plugin_id in plugin_ids: + visit(plugin_id, plugin_id) + + return closure, blockers + + def _sort_dependency_first( + self, + plugin_ids: set[str], + ) -> tuple[list[str], list[PluginDependencyPlanBlocker]]: + """ + 对插件依赖闭包执行依赖优先拓扑排序。 + + :param plugin_ids: 插件ID集合 + :return: 排序后的插件ID列表和阻塞项列表 + """ + ordered_plugin_ids: list[str] = [] + blockers: list[PluginDependencyPlanBlocker] = [] + visiting: list[str] = [] + visited: set[str] = set() + + def visit(plugin_id: str) -> None: + if plugin_id in visited: + return + if plugin_id in visiting: + cycle_path = [*visiting[visiting.index(plugin_id) :], plugin_id] + blockers.extend(self._build_cycle_blockers(cycle_path)) + return + + discovered_plugin = self.discovered_plugin_map.get(plugin_id) + if not discovered_plugin: + return + visiting.append(plugin_id) + for dependency in discovered_plugin.manifest.dependencies.plugins: + if dependency.id in plugin_ids: + visit(dependency.id) + visiting.pop() + visited.add(plugin_id) + ordered_plugin_ids.append(plugin_id) + + for plugin_id in sorted(plugin_ids): + visit(plugin_id) + + return ordered_plugin_ids, blockers + + @staticmethod + def _build_cycle_blockers(cycle_path: list[str]) -> list[PluginDependencyPlanBlocker]: + """ + 构建循环依赖阻塞项。 + + :param cycle_path: 循环依赖路径 + :return: 循环依赖阻塞项列表 + """ + cycle_text = ' -> '.join(cycle_path) + return [ + PluginDependencyPlanBlocker( + plugin_id=plugin_id, + dependency_id=cycle_path[-1], + status='cycle', + message=f'插件依赖存在循环:{cycle_text}', + ) + for plugin_id in set(cycle_path) + ] + + def _build_plan_item( + self, + plugin_id: str, + order: int, + operation: PluginBatchOperation, + *, + requested: bool, + existing_blockers: list[PluginDependencyPlanBlocker], + ) -> PluginDependencyPlanItem: + """ + 构建单个插件批量操作计划项。 + + :param plugin_id: 插件ID + :param order: 执行顺序 + :param operation: 批量操作类型 + :param requested: 是否为用户显式指定插件 + :param existing_blockers: 已收集的阻塞项 + :return: 插件批量操作计划项 + """ + discovered_plugin = self.discovered_plugin_map[plugin_id] + database_plugin = self.database_plugin_map.get(plugin_id) + blockers = [ + *existing_blockers, + *self._build_dependency_blockers(discovered_plugin.manifest, operation), + ] + return PluginDependencyPlanItem( + plugin_id=plugin_id, + name=discovered_plugin.manifest.name, + version=discovered_plugin.manifest.version, + operation=operation, + order=order, + requested=requested, + dependencies=[dependency.id for dependency in discovered_plugin.manifest.dependencies.plugins], + installed_version=getattr(database_plugin, 'installed_version', None), + enabled=getattr(database_plugin, 'enabled', None), + status=getattr(database_plugin, 'status', None), + blockers=blockers, + ) + + def _build_dependency_blockers( + self, + manifest: PluginManifest, + operation: PluginBatchOperation, + ) -> list[PluginDependencyPlanBlocker]: + """ + 根据操作类型构建插件依赖阻塞项。 + + :param manifest: 插件清单 + :param operation: 批量操作类型 + :return: 插件依赖阻塞项列表 + """ + blockers = [] + for dependency in manifest.dependencies.plugins: + blockers.extend(self._build_single_dependency_blocker(manifest, dependency, operation)) + + return blockers + + def _build_single_dependency_blocker( + self, + manifest: PluginManifest, + dependency: PluginDependencyManifest, + operation: PluginBatchOperation, + ) -> list[PluginDependencyPlanBlocker]: + """ + 检查单个依赖的阻塞项。 + + :param manifest: 插件清单 + :param dependency: 插件依赖声明 + :param operation: 批量操作类型 + :return: 依赖阻塞项列表 + """ + discovered_dependency = self.discovered_plugin_map.get(dependency.id) + if not discovered_dependency: + return [] + + database_dependency = self.database_plugin_map.get(dependency.id) + source_version = discovered_dependency.manifest.version + installed_version = getattr(database_dependency, 'installed_version', None) + + if not PluginDependencyVersionMatcher.is_satisfied(source_version, dependency.version): + return [ + self._build_blocker( + manifest.id, + dependency.id, + 'source_version_unsatisfied', + f'依赖插件源码版本不满足:{dependency.id} source={source_version} required={dependency.version}', + ) + ] + + if operation == 'install': + if database_dependency and not PluginStateResolver.is_database_plugin_enabled(database_dependency): + return [ + self._build_blocker( + manifest.id, + dependency.id, + 'disabled', + f'依赖插件未启用:{dependency.id}', + ) + ] + return [] + + if not database_dependency or not installed_version: + return [ + self._build_blocker( + manifest.id, + dependency.id, + 'not_installed', + f'依赖插件未安装:{dependency.id}', + ) + ] + + if operation == 'enable' and not PluginDependencyVersionMatcher.is_satisfied( + installed_version, + dependency.version, + ): + return [ + self._build_blocker( + manifest.id, + dependency.id, + 'version_unsatisfied', + ( + f'依赖插件版本不满足:{dependency.id} ' + f'installed={installed_version} required={dependency.version}' + ), + ) + ] + + if operation == 'upgrade' and not PluginStateResolver.is_database_plugin_enabled(database_dependency): + return [ + self._build_blocker( + manifest.id, + dependency.id, + 'disabled', + f'依赖插件未启用:{dependency.id}', + ) + ] + + return [] + + @staticmethod + def _build_blocker( + plugin_id: str, + dependency_id: str, + status: PluginDependencyPlanBlockerStatus, + message: str, + ) -> PluginDependencyPlanBlocker: + """ + 构建插件批量操作计划阻塞项。 + + :param plugin_id: 插件ID + :param dependency_id: 依赖插件ID + :param status: 阻塞状态 + :param message: 阻塞说明 + :return: 插件批量操作计划阻塞项 + """ + return PluginDependencyPlanBlocker( + plugin_id=plugin_id, + dependency_id=dependency_id, + status=status, + message=message, + ) + + @staticmethod + def _group_blockers( + blockers: list[PluginDependencyPlanBlocker], + ) -> dict[str, list[PluginDependencyPlanBlocker]]: + """ + 按插件 ID 分组阻塞项。 + + :param blockers: 阻塞项列表 + :return: 阻塞项分组 + """ + blocker_map: dict[str, list[PluginDependencyPlanBlocker]] = {} + for blocker in blockers: + blocker_map.setdefault(blocker.plugin_id, []).append(blocker) + + return blocker_map + + @staticmethod + def _deduplicate_blockers( + blockers: list[PluginDependencyPlanBlocker], + ) -> list[PluginDependencyPlanBlocker]: + """ + 去重插件批量操作计划阻塞项。 + + :param blockers: 阻塞项列表 + :return: 去重后的阻塞项列表 + """ + blocker_map = { + (blocker.plugin_id, blocker.dependency_id, blocker.status, blocker.message): blocker for blocker in blockers + } + + return list(blocker_map.values()) diff --git a/ruoyi-fastapi-backend/plugins/core/validation/python_requirements.py b/ruoyi-fastapi-backend/plugins/core/validation/python_requirements.py new file mode 100644 index 0000000..b57d031 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/python_requirements.py @@ -0,0 +1,108 @@ +from dataclasses import dataclass, field + +from packaging.requirements import Requirement +from packaging.version import InvalidVersion + + +@dataclass(frozen=True) +class ParsedPythonRequirement: + """ + 已解析 PEP 508 Python 依赖声明。 + + 保留 packaging 的解析结果,供 manifest 校验、运行时检查和依赖安装策略 + 共享同一套名称、版本范围和环境 marker 语义。 + """ + + raw: str + requirement: Requirement = field(repr=False) + + @property + def name(self) -> str: + """ + 获取规范化包名。 + + :return: Python 包名 + """ + return self.requirement.name + + @property + def specifier(self) -> str | None: + """ + 获取版本约束。 + + :return: PEP 440 版本约束 + """ + return str(self.requirement.specifier) if self.requirement.specifier else None + + @property + def required_version(self) -> str | None: + """ + 获取完整版本约束。 + + :return: PEP 440 版本约束 + """ + return self.specifier + + @property + def marker(self) -> str | None: + """ + 获取环境 marker。 + + :return: PEP 508 marker + """ + return str(self.requirement.marker) if self.requirement.marker else None + + @property + def url(self) -> str | None: + """ + 获取直接引用地址。 + + :return: PEP 508 直接引用地址 + """ + return self.requirement.url + + def is_marker_applicable(self) -> bool: + """ + 判断依赖 marker 是否适用于当前环境。 + + :return: 是否适用于当前环境 + """ + return self.requirement.marker is None or self.requirement.marker.evaluate() + + def is_version_satisfied(self, installed_version: str | None) -> bool: + """ + 判断已安装版本是否满足 PEP 440 约束。 + + 对无法解析或不满足约束的版本统一返回 False,避免依赖策略 fail-open。 + + :param installed_version: 已安装或锁定版本 + :return: 是否满足版本约束 + """ + if not installed_version: + return False + if not self.requirement.specifier: + return True + try: + return self.requirement.specifier.contains(installed_version) + except InvalidVersion: + return False + + +class PythonRequirementParser: + """ + PEP 508 Python 依赖声明解析器。 + + 解析失败时保留 packaging 的 ``InvalidRequirement`` 异常,由 manifest 和 + 运行时边界分别转换为明确的校验错误或失败结果。 + """ + + @staticmethod + def parse(requirement: str) -> ParsedPythonRequirement: + """ + 解析 PEP 508 Python 依赖声明。 + + :param requirement: Python 依赖声明 + :return: 已解析 Python 依赖声明 + :raises InvalidRequirement: 依赖声明不符合 PEP 508 + """ + return ParsedPythonRequirement(raw=requirement, requirement=Requirement(requirement)) diff --git a/ruoyi-fastapi-backend/plugins/core/validation/result.py b/ruoyi-fastapi-backend/plugins/core/validation/result.py new file mode 100644 index 0000000..d82ed0b --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/result.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import Literal + +ValidationLevel = Literal['error', 'warning', 'info'] + + +@dataclass(frozen=True) +class PluginValidationIssue: + """ + 插件校验问题项。 + + :param level: 校验等级 + :param category: 校验分类 + :param kind: 校验项类型 + :param path: 问题路径或目标 + :param message: 问题说明 + :param suggestion: 修复建议 + :param ok: 当前校验项是否通过 + """ + + level: ValidationLevel + category: str + kind: str + path: str + message: str + suggestion: str = '' + ok: bool = False + + +class PluginValidationLevelResolver: + """ + 插件校验等级解析器。 + + 使用 Strategy 模式集中约定校验项等级,避免 CLI、API 和前端分别推断。 + """ + + @staticmethod + def from_ok(ok: bool) -> ValidationLevel: + """ + 根据通过状态解析校验等级。 + + :param ok: 校验项是否通过 + :return: 校验等级 + """ + return 'info' if ok else 'error' diff --git a/ruoyi-fastapi-backend/plugins/core/validation/structure.py b/ruoyi-fastapi-backend/plugins/core/validation/structure.py new file mode 100644 index 0000000..4cbbfb9 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/structure.py @@ -0,0 +1,685 @@ +import ast +from dataclasses import dataclass +from pathlib import Path + +from plugins.core.discovery.scanner import DiscoveredPlugin +from plugins.core.environment import PluginRuntimeEnvironmentService +from plugins.core.manifest.menu_tree import PluginMenuTree +from plugins.core.manifest.schema import PluginJobManifest, PluginManifest, PluginMenuManifest +from plugins.core.validation.result import PluginValidationIssue, PluginValidationLevelResolver, ValidationLevel +from utils.cron_util import CronUtil + +MAX_PLUGIN_JOB_NAME_LENGTH = 64 +SUPPORTED_SEED_SUFFIXES = {'.py', '.sql'} +SUPPORTED_MIGRATION_SUFFIXES = {'.py', '.sql'} +ROUTER_FACTORY_NAMES = {'APIRouter', 'APIRouterPro'} + + +@dataclass(frozen=True) +class PluginStructureCheckItem: + """ + 插件结构检查项。 + """ + + kind: str + path: str + ok: bool + message: str + suggestion: str = '' + + @property + def level(self) -> ValidationLevel: + """ + 获取结构检查项等级。 + + :return: 检查等级 + """ + return PluginValidationLevelResolver.from_ok(self.ok) + + def to_issue(self) -> PluginValidationIssue: + """ + 转换为统一校验问题项。 + + :return: 统一校验问题项 + """ + return PluginValidationIssue( + level=self.level, + category='structure', + kind=self.kind, + path=self.path, + message=self.message, + suggestion=self.suggestion, + ok=self.ok, + ) + + +@dataclass(frozen=True) +class PluginStructureCheckResult: + """ + 插件结构检查结果。 + """ + + plugin_id: str + items: list[PluginStructureCheckItem] + + @property + def ok(self) -> bool: + """ + 判断结构检查是否整体通过。 + + :return: 是否通过 + """ + return all(item.ok for item in self.items) + + @property + def failed_items(self) -> list[PluginStructureCheckItem]: + """ + 获取失败检查项列表。 + + :return: 失败检查项列表 + """ + return [item for item in self.items if not item.ok] + + @property + def issues(self) -> list[PluginValidationIssue]: + """ + 获取统一校验问题项列表。 + + :return: 统一校验问题项列表 + """ + return [item.to_issue() for item in self.items] + + +class PluginStructureChecker: + """ + 插件结构检查器。 + + 使用 Checker 模式集中验证 manifest 引用的后端目录、seed 文件和前端页面是否存在。 + """ + + def __init__(self, backend_root: Path | str, frontend_root: Path | str | None = None) -> None: + """ + 初始化插件结构检查器。 + + :param backend_root: 后端项目根目录 + :param frontend_root: 前端插件根目录,默认使用运行时环境解析出的 plugins 目录 + """ + self.backend_root = Path(backend_root) + self.frontend_root = ( + Path(frontend_root) + if frontend_root + else Path(PluginRuntimeEnvironmentService(backend_root=self.backend_root).get_frontend_plugins_dir()) + ) + + def check(self, discovered_plugin: DiscoveredPlugin) -> PluginStructureCheckResult: + """ + 检查插件结构。 + + :param discovered_plugin: 已发现插件对象 + :return: 插件结构检查结果 + """ + manifest = discovered_plugin.manifest + items = [ + self._check_dir('backend_root', discovered_plugin.backend_path), + self._check_file('manifest', discovered_plugin.manifest_path), + ] + if manifest.backend.routers.auto_scan: + controller_dir = discovered_plugin.backend_path / 'controller' + items.append(self._check_dir('controller_dir', controller_dir)) + items.extend(self._check_controller_route_prefixes(discovered_plugin, controller_dir)) + + entity_dir = discovered_plugin.backend_path / 'entity' / 'do' + if entity_dir.exists(): + items.append(self._check_dir('entity_do_dir', entity_dir)) + + items.extend(self._check_migration_files(discovered_plugin)) + items.extend(self._check_seed_files(discovered_plugin)) + items.extend(self._check_hooks(discovered_plugin)) + items.extend(self._check_jobs(discovered_plugin)) + items.extend(self._check_frontend(discovered_plugin)) + + return PluginStructureCheckResult(plugin_id=manifest.id, items=items) + + def _check_controller_route_prefixes( + self, + discovered_plugin: DiscoveredPlugin, + controller_dir: Path, + ) -> list[PluginStructureCheckItem]: + """ + 检查插件 controller 路由前缀是否位于插件命名空间内。 + + :param discovered_plugin: 已发现插件对象 + :param controller_dir: controller 目录 + :return: 路由前缀检查项列表 + """ + if not controller_dir.is_dir(): + return [] + + items = [] + for controller_file in sorted(controller_dir.glob('[!_]*.py')): + items.extend(self.check_controller_file_route_prefixes(discovered_plugin.manifest.id, controller_file)) + + return items + + def check_controller_file_route_prefixes( + self, + plugin_id: str, + controller_file: Path, + ) -> list[PluginStructureCheckItem]: + """ + 检查单个 controller 文件内的路由前缀。 + + :param plugin_id: 插件ID + :param controller_file: controller 文件 + :return: 路由前缀检查项列表 + """ + try: + module_ast = ast.parse(controller_file.read_text(encoding='utf-8'), filename=str(controller_file)) + except SyntaxError as exc: + return [ + PluginStructureCheckItem( + kind='controller_route_prefix', + path=str(controller_file), + ok=False, + message=f'controller 文件语法错误,无法检查路由前缀:{exc}', + ) + ] + + items = [] + for node in ast.walk(module_ast): + if not isinstance(node, ast.Call) or not self._is_router_factory_call(node): + continue + + prefix = self._get_router_prefix_literal(node) + path = f'{controller_file}:{getattr(node, "lineno", 1)}' + ok = prefix is not None and self._is_plugin_route_prefix(plugin_id, prefix) + items.append( + PluginStructureCheckItem( + kind='controller_route_prefix', + path=path, + ok=ok, + message=( + f'controller 路由前缀位于插件命名空间内:{prefix}' + if ok + else f'controller 路由前缀必须位于插件 {plugin_id} 命名空间内:{prefix or "<未声明>"}' + ), + suggestion=f'请使用 /{plugin_id} 或 /plugin/{plugin_id} 作为路由前缀根路径', + ) + ) + + return items + + @staticmethod + def _is_router_factory_call(node: ast.Call) -> bool: + """ + 判断 AST 调用是否为 APIRouter/APIRouterPro 构造。 + + :param node: AST 调用节点 + :return: 是否为路由构造调用 + """ + if isinstance(node.func, ast.Name): + return node.func.id in ROUTER_FACTORY_NAMES + if isinstance(node.func, ast.Attribute): + return node.func.attr in ROUTER_FACTORY_NAMES + return False + + @staticmethod + def _get_router_prefix_literal(node: ast.Call) -> str | None: + """ + 提取路由构造调用中的 prefix 字符串字面量。 + + :param node: AST 调用节点 + :return: prefix 字符串,未声明或非字面量时返回 None + """ + for keyword in node.keywords: + if ( + keyword.arg == 'prefix' + and isinstance(keyword.value, ast.Constant) + and isinstance(keyword.value.value, str) + ): + return keyword.value.value + return None + + @staticmethod + def _is_plugin_route_prefix(plugin_id: str, prefix: str) -> bool: + """ + 判断路由前缀是否属于插件命名空间。 + + :param plugin_id: 插件ID + :param prefix: 路由前缀 + :return: 是否属于插件命名空间 + """ + plugin_prefix = f'/{plugin_id}' + nested_plugin_prefix = f'/plugin/{plugin_id}' + return prefix in (plugin_prefix, nested_plugin_prefix) or prefix.startswith( + (f'{plugin_prefix}/', f'{nested_plugin_prefix}/') + ) + + def _check_hooks(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]: + """ + 检查插件生命周期钩子声明。 + + :param discovered_plugin: 已发现插件对象 + :return: 生命周期钩子检查项列表 + """ + items = [] + hooks = discovered_plugin.manifest.backend.hooks + hook_mapping = { + 'on_install': hooks.on_install, + 'on_upgrade': hooks.on_upgrade, + 'on_startup': hooks.on_startup, + 'on_shutdown': hooks.on_shutdown, + 'on_purge': hooks.on_purge, + } + for hook_name, hook_path in hook_mapping.items(): + if not hook_path: + continue + items.append(self._check_hook_boundary(discovered_plugin.manifest.backend.module, hook_name, hook_path)) + items.append(self._check_hook_callable_importable(discovered_plugin, hook_name, hook_path)) + + return items + + def _check_hook_callable_importable( + self, + discovered_plugin: DiscoveredPlugin, + hook_name: str, + hook_path: str, + ) -> PluginStructureCheckItem: + """ + 检查生命周期钩子 callable 是否可以导入。 + + :param discovered_plugin: 已发现插件对象 + :param hook_name: 生命周期钩子名称 + :param hook_path: 生命周期钩子声明 + :return: 结构检查项 + """ + try: + module_path, callable_name = hook_path.split(':', maxsplit=1) + module_name = self._resolve_hook_module_name(discovered_plugin.manifest.backend.module, module_path) + module_file = self._resolve_plugin_module_file(discovered_plugin, module_name) + ok = self._module_file_declares_async_callable(module_file, callable_name) + except Exception as exc: + return PluginStructureCheckItem( + kind='hook_callable', + path=f'{hook_name}:{hook_path}', + ok=False, + message=f'生命周期钩子不可静态解析:{exc}', + ) + + return PluginStructureCheckItem( + kind='hook_callable', + path=f'{hook_name}:{hook_path}', + ok=ok, + message=( + f'生命周期钩子是异步函数:{hook_path}' + if ok + else f'生命周期钩子必须是使用 async def 声明的顶层函数:{hook_path}' + ), + ) + + @staticmethod + def _check_hook_boundary(backend_module: str, hook_name: str, hook_path: str) -> PluginStructureCheckItem: + """ + 检查生命周期钩子是否位于当前插件后端模块内。 + + :param backend_module: 插件后端模块路径 + :param hook_name: 生命周期钩子名称 + :param hook_path: 生命周期钩子声明 + :return: 结构检查项 + """ + module_path = hook_path.split(':', maxsplit=1)[0] + resolved_module_path = PluginStructureChecker._resolve_hook_module_name(backend_module, module_path) + ok = resolved_module_path == backend_module or resolved_module_path.startswith(f'{backend_module}.') + return PluginStructureCheckItem( + kind='hook_boundary', + path=f'{hook_name}:{hook_path}', + ok=ok, + message=f'生命周期钩子位于插件模块内:{hook_path}' if ok else f'生命周期钩子越界:{hook_path}', + ) + + @staticmethod + def _resolve_hook_module_name(backend_module: str, module_path: str) -> str: + """ + 解析生命周期钩子模块名。 + + :param backend_module: 插件后端模块路径 + :param module_path: manifest 中声明的钩子模块路径 + :return: 完整 Python 模块名 + """ + if module_path == backend_module or module_path.startswith(f'{backend_module}.'): + return module_path + if module_path.startswith('plugins.'): + return module_path + + return f'{backend_module}.{module_path}' + + def _check_jobs(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]: + """ + 检查插件定时任务声明。 + + :param discovered_plugin: 已发现插件对象 + :return: 定时任务检查项列表 + """ + items = [] + manifest = discovered_plugin.manifest + for job in manifest.backend.jobs: + items.append(self._check_job_name_length(manifest.id, job)) + items.append(self._check_job_callable_boundary(manifest.backend.module, job)) + items.append(self._check_job_callable_importable(job)) + items.append(self._check_job_cron_expression(job)) + + return items + + @staticmethod + def _check_job_name_length(plugin_id: str, job: PluginJobManifest) -> PluginStructureCheckItem: + """ + 检查插件任务映射到系统任务表后的名称长度。 + + :param plugin_id: 插件ID + :param job: 插件定时任务声明 + :return: 结构检查项 + """ + job_name = f'{plugin_id}:{job.id}' + ok = len(job_name) <= MAX_PLUGIN_JOB_NAME_LENGTH + return PluginStructureCheckItem( + kind='job_name', + path=job_name, + ok=ok, + message=f'任务名称长度有效:{job_name}' if ok else f'任务名称超过 64 字符:{job_name}', + ) + + @staticmethod + def _check_job_callable_boundary( + backend_module: str, + job: PluginJobManifest, + ) -> PluginStructureCheckItem: + """ + 检查任务 callable 是否位于当前插件后端模块内。 + + :param backend_module: 插件后端模块路径 + :param job: 插件定时任务声明 + :return: 结构检查项 + """ + ok = job.callable.startswith(f'{backend_module}.') + return PluginStructureCheckItem( + kind='job_callable_boundary', + path=job.callable, + ok=ok, + message=f'任务 callable 位于插件模块内:{job.callable}' if ok else f'任务 callable 越界:{job.callable}', + ) + + def _check_job_callable_importable(self, job: PluginJobManifest) -> PluginStructureCheckItem: + """ + 检查任务 callable 是否可通过源码静态解析。 + + :param job: 插件定时任务声明 + :return: 结构检查项 + """ + try: + module_path, callable_name = job.callable.rsplit('.', 1) + module_file = self._resolve_backend_module_file(module_path) + ok = self._module_file_declares_callable(module_file, callable_name) + except Exception as exc: + return PluginStructureCheckItem( + kind='job_callable', + path=job.callable, + ok=False, + message=f'任务 callable 不可静态解析:{exc}', + ) + + return PluginStructureCheckItem( + kind='job_callable', + path=job.callable, + ok=ok, + message=f'任务 callable 可调用:{job.callable}' if ok else f'任务 callable 不是可调用对象:{job.callable}', + ) + + def _resolve_backend_module_file(self, module_path: str) -> Path: + """ + 解析后端模块对应的 Python 文件。 + + :param module_path: Python 模块路径 + :return: 模块文件路径 + """ + module_file = self.backend_root / Path(*module_path.split('.')).with_suffix('.py') + if module_file.is_file(): + return module_file + + package_init = self.backend_root / Path(*module_path.split('.')) / '__init__.py' + if package_init.is_file(): + return package_init + + raise ImportError(f'无法找到模块文件:{module_path}') + + def _resolve_plugin_module_file(self, discovered_plugin: DiscoveredPlugin, module_path: str) -> Path: + """ + 解析插件模块对应的 Python 文件,不导入执行模块。 + + :param discovered_plugin: 已发现插件对象 + :param module_path: Python 模块路径 + :return: 模块文件路径 + """ + backend_module = discovered_plugin.manifest.backend.module + if module_path == backend_module: + module_file = discovered_plugin.backend_path / '__init__.py' + elif module_path.startswith(f'{backend_module}.'): + relative_module = module_path.removeprefix(backend_module).lstrip('.') + module_file = discovered_plugin.backend_path.joinpath(*relative_module.split('.')).with_suffix('.py') + else: + module_file = self.backend_root / Path(*module_path.split('.')).with_suffix('.py') + + if module_file.is_file(): + return module_file + + raise ImportError(f'无法找到模块文件:{module_path}') + + @staticmethod + def _module_file_declares_callable(module_file: Path, callable_name: str) -> bool: + """ + 通过 AST 判断模块是否声明了顶层可调用符号。 + + :param module_file: 模块文件路径 + :param callable_name: callable 名称 + :return: 是否声明了可调用符号 + """ + module_ast = ast.parse(module_file.read_text(encoding='utf-8'), filename=str(module_file)) + return any( + isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) and node.name == callable_name + for node in module_ast.body + ) + + @staticmethod + def _module_file_declares_async_callable(module_file: Path, callable_name: str) -> bool: + """ + 通过 AST 判断模块是否声明了顶层异步函数。 + + :param module_file: 模块文件路径 + :param callable_name: callable 名称 + :return: 是否声明了异步函数 + """ + module_ast = ast.parse(module_file.read_text(encoding='utf-8'), filename=str(module_file)) + return any(isinstance(node, ast.AsyncFunctionDef) and node.name == callable_name for node in module_ast.body) + + @staticmethod + def _check_job_cron_expression(job: PluginJobManifest) -> PluginStructureCheckItem: + """ + 检查任务 cron 表达式。 + + :param job: 插件定时任务声明 + :return: 结构检查项 + """ + ok = CronUtil.validate_cron_expression(job.cron_expression) + return PluginStructureCheckItem( + kind='job_cron', + path=f'{job.id}:{job.cron_expression}', + ok=ok, + message=( + f'任务 cron 表达式有效:{job.cron_expression}' if ok else f'任务 cron 表达式无效:{job.cron_expression}' + ), + ) + + def _check_migration_files(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]: + """ + 检查 migration 文件。 + + :param discovered_plugin: 已发现插件对象 + :return: migration 检查项列表 + """ + items = [] + for migration_path in discovered_plugin.manifest.backend.migrations: + items.append( + self._check_plugin_relative_file('migration_file', discovered_plugin.backend_path, migration_path) + ) + items.append(self._check_migration_type(migration_path)) + + return items + + @staticmethod + def _check_migration_type(migration_path: str) -> PluginStructureCheckItem: + """ + 检查 migration 文件类型。 + + :param migration_path: migration 相对插件根目录路径 + :return: 结构检查项 + """ + ok = Path(migration_path).suffix in SUPPORTED_MIGRATION_SUFFIXES + return PluginStructureCheckItem( + kind='migration_type', + path=migration_path, + ok=ok, + message=f'支持的 migration:{migration_path}' if ok else f'暂不支持的 migration 类型:{migration_path}', + ) + + def _check_seed_files(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]: + """ + 检查 seed 文件。 + + :param discovered_plugin: 已发现插件对象 + :return: seed 检查项列表 + """ + items = [] + for seed_path in discovered_plugin.manifest.backend.seeds: + items.append(self._check_plugin_relative_file('seed_file', discovered_plugin.backend_path, seed_path)) + items.append(self._check_seed_type(seed_path)) + + return items + + @staticmethod + def _check_seed_type(seed_path: str) -> PluginStructureCheckItem: + """ + 检查 seed 文件类型。 + + :param seed_path: seed 相对插件根目录路径 + :return: 结构检查项 + """ + ok = Path(seed_path).suffix in SUPPORTED_SEED_SUFFIXES + return PluginStructureCheckItem( + kind='seed_type', + path=seed_path, + ok=ok, + message=f'支持的 seed:{seed_path}' if ok else f'暂不支持的 seed 类型:{seed_path}', + ) + + def _check_frontend(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStructureCheckItem]: + """ + 检查前端插件目录和菜单组件。 + + :param discovered_plugin: 已发现插件对象 + :return: 前端检查项列表 + """ + manifest = discovered_plugin.manifest + plugin_view_menus = [ + menu + for menu in PluginMenuTree.flatten(manifest.frontend.menus) + if PluginMenuTree.is_plugin_component(menu.component) + ] + frontend_plugin_root = self.frontend_root / (manifest.frontend.plugin_id or manifest.id) + if not plugin_view_menus: + return [] + + items = [self._check_dir('frontend_root', frontend_plugin_root)] + items.append(self._check_dir('frontend_views_path', frontend_plugin_root / manifest.frontend.views_path)) + items.append(self._check_dir('frontend_api_path', frontend_plugin_root / manifest.frontend.api_path)) + items.extend(self._check_plugin_view(frontend_plugin_root, manifest, menu) for menu in plugin_view_menus) + + return items + + def _check_plugin_view( + self, + frontend_plugin_root: Path, + manifest: PluginManifest, + menu: PluginMenuManifest, + ) -> PluginStructureCheckItem: + """ + 检查插件菜单组件对应的 Vue 页面。 + + :param frontend_plugin_root: 前端插件根目录 + :param manifest: 插件清单 + :param menu: 菜单声明 + :return: 页面检查项 + """ + view_path = PluginMenuTree.resolve_plugin_view_path(manifest, menu.component) + if view_path is None: + return PluginStructureCheckItem( + kind='frontend_view', + path=menu.component, + ok=False, + message=f'插件组件路径格式错误:{menu.component}', + ) + + return self._check_file('frontend_view', frontend_plugin_root / view_path) + + @staticmethod + def _check_dir(kind: str, path: Path) -> PluginStructureCheckItem: + """ + 检查目录是否存在。 + + :param kind: 检查类型 + :param path: 目录路径 + :return: 结构检查项 + """ + return PluginStructureCheckItem( + kind=kind, + path=str(path), + ok=path.is_dir(), + message=f'目录存在:{path}' if path.is_dir() else f'目录不存在:{path}', + ) + + @staticmethod + def _check_file(kind: str, path: Path) -> PluginStructureCheckItem: + """ + 检查文件是否存在。 + + :param kind: 检查类型 + :param path: 文件路径 + :return: 结构检查项 + """ + return PluginStructureCheckItem( + kind=kind, + path=str(path), + ok=path.is_file(), + message=f'文件存在:{path}' if path.is_file() else f'文件不存在:{path}', + ) + + @classmethod + def _check_plugin_relative_file(cls, kind: str, plugin_root: Path, relative_path: str) -> PluginStructureCheckItem: + """ + 检查插件相对路径文件是否存在且未越过插件根目录。 + + :param kind: 检查类型 + :param plugin_root: 插件后端根目录 + :param relative_path: 相对插件根目录路径 + :return: 结构检查项 + """ + file_path = (plugin_root / relative_path).resolve() + resolved_plugin_root = plugin_root.resolve() + if resolved_plugin_root not in file_path.parents: + return PluginStructureCheckItem( + kind=kind, + path=relative_path, + ok=False, + message=f'文件路径不能越过插件根目录:{relative_path}', + suggestion='请使用插件目录内的相对路径', + ) + + return cls._check_file(kind, file_path) diff --git a/ruoyi-fastapi-backend/plugins/core/validation/versioning.py b/ruoyi-fastapi-backend/plugins/core/validation/versioning.py new file mode 100644 index 0000000..b912157 --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/validation/versioning.py @@ -0,0 +1,293 @@ +import re +from dataclasses import dataclass +from itertools import zip_longest + +VERSION_PATTERN = re.compile( + r'^v?(?P\d+(?:\.\d+)*)(?:[-_\.]?(?Pa|alpha|b|beta|rc|dev|pre|preview)(?P\d*)?)?(?:\+.*)?$', + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class PluginVersion: + """ + 插件版本值对象。 + + 使用 Value Object 模式封装插件版本解析和比较所需的结构化信息。 + """ + + raw: str + release: tuple[int, ...] + prerelease_label: str | None = None + prerelease_number: int = 0 + + @property + def is_prerelease(self) -> bool: + """ + 判断版本是否为预发布版本。 + + :return: 是否为预发布版本 + """ + return self.prerelease_label is not None + + +class PluginVersionParser: + """ + 插件版本解析器。 + + 使用 Parser 模式将常见语义版本字符串解析为可比较的版本值对象。 + """ + + @classmethod + def parse(cls, version: str | None) -> PluginVersion | None: + """ + 解析插件版本。 + + :param version: 插件版本字符串 + :return: 插件版本值对象,非标准版本返回 None + """ + if not version: + return None + matched_version = VERSION_PATTERN.match(version.strip()) + if not matched_version: + return None + release = tuple(int(part) for part in matched_version.group('release').split('.')) + prerelease_label = cls.normalize_prerelease_label(matched_version.group('prerelease')) + prerelease_number_text = matched_version.group('prenum') or '0' + + return PluginVersion( + raw=version, + release=release, + prerelease_label=prerelease_label, + prerelease_number=int(prerelease_number_text), + ) + + @staticmethod + def normalize_prerelease_label(label: str | None) -> str | None: + """ + 规范化预发布标识。 + + :param label: 原始预发布标识 + :return: 规范化后的预发布标识 + """ + if label is None: + return None + label_map = { + 'a': 'alpha', + 'b': 'beta', + 'pre': 'preview', + } + + return label_map.get(label.lower(), label.lower()) + + +class PluginVersionComparator: + """ + 插件版本比较器。 + + 使用 Comparator 模式提供插件版本相等、排序和升级判断能力。 + """ + + PRERELEASE_ORDER = { + 'dev': 0, + 'alpha': 1, + 'beta': 2, + 'preview': 3, + 'rc': 4, + } + + @classmethod + def compare(cls, left: str | None, right: str | None) -> int | None: + """ + 比较两个插件版本。 + + :param left: 左侧版本 + :param right: 右侧版本 + :return: 左侧大于右侧返回 1,等于返回 0,小于返回 -1,无法解析返回 None + """ + left_version = PluginVersionParser.parse(left) + right_version = PluginVersionParser.parse(right) + if left_version is None or right_version is None: + return None + + release_comparison = cls.compare_release(left_version.release, right_version.release) + if release_comparison != 0: + return release_comparison + + return cls.compare_prerelease(left_version, right_version) + + @staticmethod + def compare_release(left: tuple[int, ...], right: tuple[int, ...]) -> int: + """ + 比较版本发布号。 + + :param left: 左侧发布号 + :param right: 右侧发布号 + :return: 左侧大于右侧返回 1,等于返回 0,小于返回 -1 + """ + for left_part, right_part in zip_longest(left, right, fillvalue=0): + if left_part > right_part: + return 1 + if left_part < right_part: + return -1 + + return 0 + + @classmethod + def compare_prerelease(cls, left: PluginVersion, right: PluginVersion) -> int: + """ + 比较版本预发布号。 + + :param left: 左侧版本 + :param right: 右侧版本 + :return: 左侧大于右侧返回 1,等于返回 0,小于返回 -1 + """ + if not left.is_prerelease and not right.is_prerelease: + return 0 + if not left.is_prerelease: + return 1 + if not right.is_prerelease: + return -1 + left_label_order = cls.PRERELEASE_ORDER.get(left.prerelease_label or '', -1) + right_label_order = cls.PRERELEASE_ORDER.get(right.prerelease_label or '', -1) + if left_label_order != right_label_order: + return 1 if left_label_order > right_label_order else -1 + if left.prerelease_number == right.prerelease_number: + return 0 + + return 1 if left.prerelease_number > right.prerelease_number else -1 + + @classmethod + def equals(cls, left: str | None, right: str | None) -> bool: + """ + 判断两个插件版本是否等价。 + + :param left: 左侧版本 + :param right: 右侧版本 + :return: 是否等价 + """ + comparison = cls.compare(left, right) + if comparison is None: + return (left or '') == (right or '') + + return comparison == 0 + + @classmethod + def is_upgrade_available(cls, installed_version: str | None, source_version: str | None) -> bool: + """ + 判断源码版本是否高于已安装版本。 + + :param installed_version: 已安装版本 + :param source_version: 源码版本 + :return: 是否存在可升级版本 + """ + if not installed_version or not source_version: + return False + comparison = cls.compare(source_version, installed_version) + if comparison is None: + return source_version != installed_version + + return comparison > 0 + + +class PluginVersionConstraintMatcher: + """ + 插件版本约束匹配器。 + + 使用 Matcher 模式匹配插件版本与版本约束。 + """ + + @classmethod + def is_satisfied(cls, version: str | None, operator: str | None, required_version: str | None) -> bool: + """ + 判断插件版本是否满足约束。 + + :param version: 插件版本 + :param operator: 版本操作符 + :param required_version: 约束版本 + :return: 是否满足 + """ + if not version: + return False + if not operator or not required_version: + return True + if operator in {'^', '~'}: + return cls.match_compatible(version, required_version, operator) + + comparison = PluginVersionComparator.compare(version, required_version) + if comparison is None: + return cls.match_text_version(version, operator, required_version) + + operator_matchers = { + '==': comparison == 0, + '=': comparison == 0, + '>=': comparison >= 0, + '<=': comparison <= 0, + '>': comparison > 0, + '<': comparison < 0, + '!=': comparison != 0, + } + + return operator_matchers.get(operator, False) + + @classmethod + def match_compatible(cls, version: str, required_version: str, operator: str) -> bool: + """ + 匹配兼容版本约束。 + + :param version: 插件版本 + :param required_version: 约束版本 + :param operator: 兼容版本操作符 + :return: 是否满足兼容约束 + """ + parsed_version = PluginVersionParser.parse(version) + parsed_required_version = PluginVersionParser.parse(required_version) + if parsed_version is None or parsed_required_version is None: + return version == required_version or version.startswith(f'{required_version}.') + if PluginVersionComparator.compare(version, required_version) == -1: + return False + if operator == '~': + return cls._release_prefix(parsed_version.release, 2) == cls._release_prefix( + parsed_required_version.release, + 2, + ) + + return cls._major(parsed_version.release) == cls._major(parsed_required_version.release) + + @staticmethod + def match_text_version(version: str, operator: str, required_version: str) -> bool: + """ + 匹配非标准文本版本。 + + :param version: 插件版本 + :param operator: 版本操作符 + :param required_version: 约束版本 + :return: 是否满足文本版本约束 + """ + if operator in {'==', '='}: + return version == required_version + if operator == '!=': + return version != required_version + + return False + + @staticmethod + def _major(release: tuple[int, ...]) -> int: + """ + 获取主版本号。 + + :param release: 发布号 + :return: 主版本号 + """ + return release[0] if release else 0 + + @staticmethod + def _release_prefix(release: tuple[int, ...], length: int) -> tuple[int, ...]: + """ + 获取发布号前缀。 + + :param release: 发布号 + :param length: 前缀长度 + :return: 发布号前缀 + """ + return tuple(release[index] if index < len(release) else 0 for index in range(length)) diff --git a/ruoyi-fastapi-backend/pyproject.toml b/ruoyi-fastapi-backend/pyproject.toml index 61d9261..9f41eba 100644 --- a/ruoyi-fastapi-backend/pyproject.toml +++ b/ruoyi-fastapi-backend/pyproject.toml @@ -11,5 +11,8 @@ requires-python = ">=3.10" [project.scripts] ruoyi = "cli.main:main" +[tool.pytest.ini_options] +pythonpath = ["."] + [tool.setuptools.packages.find] include = ["cli*"] diff --git a/ruoyi-fastapi-backend/requirements-pg.txt b/ruoyi-fastapi-backend/requirements-pg.txt index 43d7532..6d17713 100644 --- a/ruoyi-fastapi-backend/requirements-pg.txt +++ b/ruoyi-fastapi-backend/requirements-pg.txt @@ -1,26 +1,15 @@ -agno==2.4.8 aiofiles==25.1.0 alembic==1.18.3 -anthropic==0.78.0 APScheduler==3.11.2 async-lru==2.1.0 asyncpg==0.31.0 bcrypt==5.0.0 -cerebras-cloud-sdk==1.67.0 -cohere==5.20.4 fastapi[all]==0.128.2 -google-genai==1.62.0 -groq==1.0.0 -litellm==1.81.8 -llama-api-client==0.6.0 loguru==0.7.3 -mistralai==1.12.0 -ollama==0.6.1 -openai==2.17.0 openpyxl==3.1.5 +packaging>=26.0 pandas==2.3.3 Pillow==12.1.1 -portkey-ai==2.1.0 psutil==7.2.2 psycopg2==2.9.11 pydantic>=2.11.4 diff --git a/ruoyi-fastapi-backend/requirements.txt b/ruoyi-fastapi-backend/requirements.txt index 5c17e51..7dd6d09 100644 --- a/ruoyi-fastapi-backend/requirements.txt +++ b/ruoyi-fastapi-backend/requirements.txt @@ -1,26 +1,15 @@ -agno==2.4.8 aiofiles==25.1.0 alembic==1.18.3 -anthropic==0.78.0 APScheduler==3.11.2 async-lru==2.1.0 asyncmy==0.2.11 bcrypt==5.0.0 -cerebras-cloud-sdk==1.67.0 -cohere==5.20.4 fastapi[all]==0.128.2 -google-genai==1.62.0 -groq==1.0.0 -litellm==1.81.8 -llama-api-client==0.6.0 loguru==0.7.3 -mistralai==1.12.0 -ollama==0.6.1 -openai==2.17.0 openpyxl==3.1.5 +packaging>=26.0 pandas==2.3.3 Pillow==12.1.1 -portkey-ai==2.1.0 psutil==7.2.2 pydantic>=2.11.4 pydantic-validation-decorator==0.1.5 diff --git a/ruoyi-fastapi-backend/server.py b/ruoyi-fastapi-backend/server.py index c635503..8829757 100644 --- a/ruoyi-fastapi-backend/server.py +++ b/ruoyi-fastapi-backend/server.py @@ -13,6 +13,7 @@ from config.get_scheduler import SchedulerUtil from exceptions.handle import handle_exception from middlewares.handle import handle_middleware from module_admin.service.log_service import LogAggregatorService +from plugins.core.runtime.application import get_plugin_application_runtime from sub_applications.handle import handle_sub_applications from utils.common_util import worship from utils.log_util import logger @@ -38,23 +39,41 @@ async def _stop_background_tasks(app: FastAPI) -> None: :param app: FastAPI对象 :return: None """ - log_task = getattr(app.state, 'log_aggregator_task', None) - if log_task: - log_task.cancel() + try: + log_task = getattr(app.state, 'log_aggregator_task', None) + if log_task: + log_task.cancel() + try: + await log_task + except asyncio.CancelledError: + pass + finally: try: - await log_task - except asyncio.CancelledError: - pass - lock_task = getattr(app.state, 'lock_renewal_task', None) - if lock_task: - lock_task.cancel() + # Scheduler负责停止续期并释放Application租约,必须先于Redis连接池关闭。 + await SchedulerUtil.close_system_scheduler() + finally: + try: + await RedisUtil.close_redis_pool(app) + finally: + await close_async_engine() + + +async def _shutdown_application_runtime(app: FastAPI) -> None: + """ + 关闭插件运行时并保证基础设施资源始终释放。 + + :param app: FastAPI对象 + :return: None + """ + try: + if getattr(app.state, 'plugin_application_runtime_started', False): + await get_plugin_application_runtime().shutdown(app) + finally: try: - await lock_task - except asyncio.CancelledError: - pass - await RedisUtil.close_redis_pool(app) - await SchedulerUtil.close_system_scheduler() - await close_async_engine() + await _stop_background_tasks(app) + finally: + # 所有sink均使用enqueue=True,进程退出前必须等待插件Hook等尾部日志落盘。 + await logger.complete() # 生命周期事件 @@ -67,70 +86,108 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: :return: None """ app.state.redis = await RedisUtil.create_redis_pool(log_enabled=False) - startup_log_enabled = await StartupUtil.acquire_startup_log_gate( - redis=app.state.redis, - lock_key=LockConstant.APP_STARTUP_LOCK_KEY, - worker_id=SchedulerUtil._worker_id, - lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, - ) - app.state.startup_log_enabled = startup_log_enabled - - # 获取锁成功后立即启动锁续期任务,避免初始化时间过长导致锁过期 - if startup_log_enabled: - app.state.lock_renewal_task = StartupUtil.start_lock_renewal( + app.state.plugin_application_runtime_started = False + try: + application_lock_owner_token = SchedulerUtil.get_application_lock_owner_token() + application_leader = await StartupUtil.acquire_application_leader( redis=app.state.redis, lock_key=LockConstant.APP_STARTUP_LOCK_KEY, - worker_id=SchedulerUtil._worker_id, + owner_token=application_lock_owner_token, lock_expire_seconds=LockConstant.LOCK_EXPIRE_SECONDS, - interval_seconds=LockConstant.LOCK_RENEWAL_INTERVAL, - on_lock_lost=SchedulerUtil.on_lock_lost, ) + app.state.application_leader = application_leader + app.state.application_lock_owner_token = application_lock_owner_token - with logger.contextualize(startup_phase=True, startup_log_enabled=startup_log_enabled): - logger.info(f'⏰️ {AppConfig.app_name}开始启动') - if startup_log_enabled: + # 获取锁成功后立即启动锁续期任务,避免初始化时间过长导致锁过期 + if application_leader: + SchedulerUtil.start_application_lock_renewal(app.state.redis) + + startup_logger = logger.bind( + startup_phase='application_startup', + startup_role='application_leader', + ) + if application_leader: + startup_logger.info(f'⏰️ {AppConfig.app_name}开始启动') worship() TransportKeyProvider.validate_runtime_configuration() - await init_create_table() - await RedisUtil.check_redis_connection(app.state.redis, log_enabled=startup_log_enabled) - await RedisUtil.init_sys_dict(app.state.redis) - await RedisUtil.init_sys_config(app.state.redis) - await _start_background_tasks(app) + await _initialize_application_runtime(app, application_leader=application_leader) - if startup_log_enabled: - # 短暂等待确保下面的启动日志在最后打印 - await asyncio.sleep(0.5) - logger.info(f'🚀 {AppConfig.app_name}启动成功') - host = AppConfig.app_host - port = AppConfig.app_port - if host == '0.0.0.0': - local_ip = IPUtil.get_local_ip() - network_ips = IPUtil.get_network_ips() - else: - local_ip = host - network_ips = [host] + # 初始化期间可能因续期失败失去租约;此时不得继续输出leader专属成功摘要。 + application_leader = application_leader and SchedulerUtil.is_application_leader() + app.state.application_leader = application_leader + if application_leader: + # 短暂等待确保下面的启动日志在最后打印 + await asyncio.sleep(1) + startup_logger.info(f'🚀 {AppConfig.app_name}启动成功') + host = AppConfig.app_host + port = AppConfig.app_port + if host == '0.0.0.0': + local_ip = IPUtil.get_local_ip() + network_ips = IPUtil.get_network_ips() + else: + local_ip = host + network_ips = [host] - app_links = [f'🏠 Local: http://{local_ip}:{port}'] - app_links.extend(f'📡 Network: http://{ip}:{port}' for ip in network_ips) - logger.opt(colors=True).info('💻 应用地址:\n' + '\n'.join(app_links)) + app_links = [f'🏠 Local: http://{local_ip}:{port}'] + app_links.extend(f'📡 Network: http://{ip}:{port}' for ip in network_ips) + logger.opt(colors=True).info('💻 应用地址:\n' + '\n'.join(app_links)) - if not AppConfig.app_disable_swagger: - swagger_links = [f'🏠 Local: http://{local_ip}:{port}{APIDocsUtil.docs_url()}'] - swagger_links.extend( - f'📡 Network: http://{ip}:{port}{APIDocsUtil.docs_url()}' for ip in network_ips - ) - logger.opt(colors=True).info('📄 Swagger文档:\n' + '\n'.join(swagger_links)) + if not AppConfig.app_disable_swagger: + swagger_links = [f'🏠 Local: http://{local_ip}:{port}{APIDocsUtil.docs_url()}'] + swagger_links.extend( + f'📡 Network: http://{ip}:{port}{APIDocsUtil.docs_url()}' for ip in network_ips + ) + logger.opt(colors=True).info('📄 Swagger文档:\n' + '\n'.join(swagger_links)) - if not AppConfig.app_disable_redoc: - redoc_links = [f'🏠 Local: http://{local_ip}:{port}{APIDocsUtil.redoc_url()}'] - redoc_links.extend( - f'📡 Network: http://{ip}:{port}{APIDocsUtil.redoc_url()}' for ip in network_ips - ) - logger.opt(colors=True).info('📚 ReDoc文档:\n' + '\n'.join(redoc_links)) - yield - shutdown_log_enabled = getattr(app.state, 'startup_log_enabled', False) - with logger.contextualize(startup_phase=True, startup_log_enabled=shutdown_log_enabled): - await _stop_background_tasks(app) + if not AppConfig.app_disable_redoc: + redoc_links = [f'🏠 Local: http://{local_ip}:{port}{APIDocsUtil.redoc_url()}'] + redoc_links.extend( + f'📡 Network: http://{ip}:{port}{APIDocsUtil.redoc_url()}' for ip in network_ips + ) + logger.opt(colors=True).info('📚 ReDoc文档:\n' + '\n'.join(redoc_links)) + # 确保启动阶段的插件摘要在ASGI lifespan启动完成前已写入stdout和日志文件。 + await logger.complete() + yield + finally: + await _shutdown_application_runtime(app) + + +async def _initialize_application_runtime(app: FastAPI, application_leader: bool) -> None: + """ + 初始化应用运行时资源。 + + :param app: FastAPI对象 + :param application_leader: 当前worker是否为Application leader + :return: None + """ + plugin_runtime = get_plugin_application_runtime() + plugin_runtime.prepare_metadata(app) + + await init_create_table( + stage='platform', + log_success_enabled=application_leader, + ) + + async def create_plugin_entity_tables() -> None: + """在插件 writer 导入实体后同步插件表。""" + await init_create_table( + stage='plugin_entities', + log_success_enabled=True, + ) + + await plugin_runtime.startup( + app, + create_tables=create_plugin_entity_tables, + ) + app.state.plugin_application_runtime_started = True + await RedisUtil.check_redis_connection( + app.state.redis, + log_enabled=application_leader, + log_error_enabled=True, + ) + await RedisUtil.init_sys_dict(app.state.redis) + await RedisUtil.init_sys_config(app.state.redis) + await _start_background_tasks(app) def create_app() -> FastAPI: @@ -162,7 +219,9 @@ def create_app() -> FastAPI: handle_middleware(app) # 加载全局异常处理方法 handle_exception(app) - # 自动注册路由 + # 自动注册内置路由 auto_register_routers(app) + # 初始化插件应用运行时 + get_plugin_application_runtime().bind_app(app) return app diff --git a/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql b/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql index 05f5581..6d570f9 100644 --- a/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql +++ b/ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql @@ -245,7 +245,6 @@ comment on table sys_menu is '菜单权限表'; insert into sys_menu values(1, '系统管理', 0, '1', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', current_timestamp, '', null, '系统管理目录'); insert into sys_menu values(2, '系统监控', 0, '2', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', current_timestamp, '', null, '系统监控目录'); insert into sys_menu values(3, '系统工具', 0, '3', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', current_timestamp, '', null, '系统工具目录'); -insert into sys_menu values(4, 'AI 管理', 0, '4', 'ai', null, '', '', 1, 0, 'M', '0', '0', '', 'bug', 'admin', current_timestamp, '', null, 'AI 管理目录'); insert into sys_menu values(99, '若依官网', 0, '99', 'http://ruoyi.vip', null, '', '', 0, 0, 'M', '0', '0', '', 'guide', 'admin', current_timestamp, '', null, '若依官网地址'); -- 二级菜单 insert into sys_menu values(100, '用户管理', 1, '1', 'user', 'system/user/index', '', '', 1, 0, 'C', '0', '0', 'system:user:list', 'user', 'admin', current_timestamp, '', null, '用户管理菜单'); @@ -257,19 +256,18 @@ insert into sys_menu values(105, '字典管理', 1, '6', 'dict', insert into sys_menu values(106, '参数设置', 1, '7', 'config', 'system/config/index', '', '', 1, 0, 'C', '0', '0', 'system:config:list', 'edit', 'admin', current_timestamp, '', null, '参数设置菜单'); insert into sys_menu values(107, '通知公告', 1, '8', 'notice', 'system/notice/index', '', '', 1, 0, 'C', '0', '0', 'system:notice:list', 'message', 'admin', current_timestamp, '', null, '通知公告菜单'); insert into sys_menu values(108, '日志管理', 1, '9', 'log', '', '', '', 1, 0, 'M', '0', '0', '', 'log', 'admin', current_timestamp, '', null, '日志管理菜单'); -insert into sys_menu values(121, '文件管理', 1, '10', 'file', 'system/file/index', '', '', 1, 0, 'C', '0', '0', 'system:file:list', 'documentation', 'admin', current_timestamp, '', null, '文件管理菜单'); +insert into sys_menu values(119, '文件管理', 1, '10', 'file', 'system/file/index', '', '', 1, 0, 'C', '0', '0', 'system:file:list', 'documentation', 'admin', current_timestamp, '', null, '文件管理菜单'); +insert into sys_menu values(120, '插件管理', 1, '11', 'plugin', 'system/plugin/index', '', '', 1, 0, 'C', '0', '0', 'system:plugin:list', 'component', 'admin', current_timestamp, '', null, '插件管理菜单'); insert into sys_menu values(109, '在线用户', 2, '1', 'online', 'monitor/online/index', '', '', 1, 0, 'C', '0', '0', 'monitor:online:list', 'online', 'admin', current_timestamp, '', null, '在线用户菜单'); insert into sys_menu values(110, '定时任务', 2, '2', 'job', 'monitor/job/index', '', '', 1, 0, 'C', '0', '0', 'monitor:job:list', 'job', 'admin', current_timestamp, '', null, '定时任务菜单'); insert into sys_menu values(111, '数据监控', 2, '3', 'druid', 'monitor/druid/index', '', '', 1, 0, 'C', '0', '0', 'monitor:druid:list', 'druid', 'admin', current_timestamp, '', null, '数据监控菜单'); insert into sys_menu values(112, '服务监控', 2, '4', 'server', 'monitor/server/index', '', '', 1, 0, 'C', '0', '0', 'monitor:server:list', 'server', 'admin', current_timestamp, '', null, '服务监控菜单'); insert into sys_menu values(113, '缓存监控', 2, '5', 'cache', 'monitor/cache/index', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis', 'admin', current_timestamp, '', null, '缓存监控菜单'); insert into sys_menu values(114, '缓存列表', 2, '6', 'cacheList', 'monitor/cache/list', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis-list', 'admin', current_timestamp, '', null, '缓存列表菜单'); -insert into sys_menu values(120, '传输加密', 2, '7', 'transportCrypto', 'monitor/transportCrypto/index', '', '', 1, 0, 'C', '0', '0', 'monitor:transportCrypto:list', 'chart', 'admin', current_timestamp, '', null, '传输加密监控菜单'); +insert into sys_menu values(118, '传输加密', 2, '7', 'transportCrypto', 'monitor/transportCrypto/index', '', '', 1, 0, 'C', '0', '0', 'monitor:transportCrypto:list', 'chart', 'admin', current_timestamp, '', null, '传输加密监控菜单'); insert into sys_menu values(115, '表单构建', 3, '1', 'build', 'tool/build/index', '', '', 1, 0, 'C', '0', '0', 'tool:build:list', 'build', 'admin', current_timestamp, '', null, '表单构建菜单'); insert into sys_menu values(116, '代码生成', 3, '2', 'gen', 'tool/gen/index', '', '', 1, 0, 'C', '0', '0', 'tool:gen:list', 'code', 'admin', current_timestamp, '', null, '代码生成菜单'); insert into sys_menu values(117, '系统接口', 3, '3', 'swagger', 'tool/swagger/index', '', '', 1, 0, 'C', '0', '0', 'tool:swagger:list', 'swagger', 'admin', current_timestamp, '', null, '系统接口菜单'); -insert into sys_menu values(118, '模型管理', 4, '1', 'model', 'ai/model/index', '', '', 1, 0, 'C', '0', '0', 'ai:model:list', 'form', 'admin', current_timestamp, '', null, '模型管理菜单'); -insert into sys_menu values(119, 'AI 对话', 4, '2', 'chat', 'ai/chat/index', '', '', 1, 0, 'C', '0', '0', 'ai:chat:list', 'wechat', 'admin', current_timestamp, '', null, 'AI 对话菜单'); -- 三级菜单 insert into sys_menu values(500, '操作日志', 108, '1', 'operlog', 'monitor/operlog/index', '', '', 1, 0, 'C', '0', '0', 'monitor:operlog:list', 'form', 'admin', current_timestamp, '', null, '操作日志菜单'); insert into sys_menu values(501, '登录日志', 108, '2', 'logininfor', 'monitor/logininfor/index', '', '', 1, 0, 'C', '0', '0', 'monitor:logininfor:list', 'logininfor', 'admin', current_timestamp, '', null, '登录日志菜单'); @@ -321,14 +319,19 @@ insert into sys_menu values(1036, '公告新增', 107, '2', '#', '', '', '', 1, insert into sys_menu values(1037, '公告修改', 107, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:edit', '#', 'admin', current_timestamp, '', null, ''); insert into sys_menu values(1038, '公告删除', 107, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:remove', '#', 'admin', current_timestamp, '', null, ''); -- 文件管理按钮 -insert into sys_menu values(1065, '文件查询', 121, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:query', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1066, '文件下载', 121, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:download', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1067, '文件删除', 121, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:remove', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1068, '文件授权', 121, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:edit', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1069, '文件转移', 121, '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:transfer', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1070, '文件恢复', 121, '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:restore', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1071, '文件清理', 121, '7', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:purge', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1072, '存储对账', 121, '8', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:reconcile', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1061, '文件查询', 119, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1062, '文件下载', 119, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:download', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1063, '文件删除', 119, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:remove', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1064, '文件授权', 119, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1065, '文件转移', 119, '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:transfer', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1066, '文件恢复', 119, '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:restore', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1067, '文件清理', 119, '7', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:purge', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1068, '存储对账', 119, '8', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:reconcile', '#', 'admin', current_timestamp, '', null, ''); +-- 插件管理按钮 +insert into sys_menu values(1069, '插件查询', 120, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:plugin:query', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1070, '插件修改', 120, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:plugin:edit', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1071, '插件列表', 120, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:plugin:list', '#', 'admin', current_timestamp, '', null, ''); +insert into sys_menu values(1072, '插件导出', 120, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:plugin:export', '#', 'admin', current_timestamp, '', null, ''); -- 操作日志按钮 insert into sys_menu values(1039, '操作查询', 500, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:query', '#', 'admin', current_timestamp, '', null, ''); insert into sys_menu values(1040, '操作删除', 500, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:remove', '#', 'admin', current_timestamp, '', null, ''); @@ -356,11 +359,6 @@ insert into sys_menu values(1057, '生成删除', 116, '3', '#', '', '', '', 1, insert into sys_menu values(1058, '导入代码', 116, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:import', '#', 'admin', current_timestamp, '', null, ''); insert into sys_menu values(1059, '预览代码', 116, '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:preview', '#', 'admin', current_timestamp, '', null, ''); insert into sys_menu values(1060, '生成代码', 116, '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:code', '#', 'admin', current_timestamp, '', null, ''); --- 模型管理按钮 -insert into sys_menu values(1061, '模型查询', 118, '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:query', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1062, '模型新增', 118, '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:add', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1063, '模型修改', 118, '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:edit', '#', 'admin', current_timestamp, '', null, ''); -insert into sys_menu values(1064, '模型删除', 118, '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:remove', '#', 'admin', current_timestamp, '', null, ''); -- ---------------------------- -- 6、用户和角色关联表 用户N-1角色 @@ -400,7 +398,6 @@ comment on table sys_role_menu is '角色和菜单关联表'; insert into sys_role_menu values (2, 1); insert into sys_role_menu values (2, 2); insert into sys_role_menu values (2, 3); -insert into sys_role_menu values (2, 4); insert into sys_role_menu values (2, 100); insert into sys_role_menu values (2, 101); insert into sys_role_menu values (2, 102); @@ -416,6 +413,8 @@ insert into sys_role_menu values (2, 111); insert into sys_role_menu values (2, 112); insert into sys_role_menu values (2, 113); insert into sys_role_menu values (2, 114); +insert into sys_role_menu values (2, 118); +insert into sys_role_menu values (2, 119); insert into sys_role_menu values (2, 120); insert into sys_role_menu values (2, 115); insert into sys_role_menu values (2, 116); @@ -483,6 +482,18 @@ insert into sys_role_menu values (2, 1057); insert into sys_role_menu values (2, 1058); insert into sys_role_menu values (2, 1059); insert into sys_role_menu values (2, 1060); +insert into sys_role_menu values (2, 1061); +insert into sys_role_menu values (2, 1062); +insert into sys_role_menu values (2, 1063); +insert into sys_role_menu values (2, 1064); +insert into sys_role_menu values (2, 1065); +insert into sys_role_menu values (2, 1066); +insert into sys_role_menu values (2, 1067); +insert into sys_role_menu values (2, 1068); +insert into sys_role_menu values (2, 1069); +insert into sys_role_menu values (2, 1070); +insert into sys_role_menu values (2, 1071); +insert into sys_role_menu values (2, 1072); -- ---------------------------- -- 8、角色和部门关联表 角色1-N部门 @@ -612,7 +623,7 @@ insert into sys_dict_type values(8, '通知类型', 'sys_notice_type', insert into sys_dict_type values(9, '通知状态', 'sys_notice_status', '0', 'admin', current_timestamp, '', null, '通知状态列表'); insert into sys_dict_type values(10, '操作类型', 'sys_oper_type', '0', 'admin', current_timestamp, '', null, '操作类型列表'); insert into sys_dict_type values(11, '系统状态', 'sys_common_status', '0', 'admin', current_timestamp, '', null, '登录状态列表'); -insert into sys_dict_type values(12, 'AI模型提供商', 'ai_provider_type', '0', 'admin', current_timestamp, '', null, 'AI模型提供商列表'); +insert into sys_dict_type values(12, '插件操作类型', 'plugin_operation_type', '0', 'admin', current_timestamp, '', null, '插件操作类型列表'); -- ---------------------------- -- 12、字典数据表 @@ -687,43 +698,21 @@ insert into sys_dict_data values(29, 8, '生成代码', '8', insert into sys_dict_data values(30, 9, '清空数据', '9', 'sys_oper_type', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '清空操作'); insert into sys_dict_data values(31, 1, '成功', '0', 'sys_common_status', '', 'primary', 'N', '0', 'admin', current_timestamp, '', null, '正常状态'); insert into sys_dict_data values(32, 2, '失败', '1', 'sys_common_status', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '停用状态'); -insert into sys_dict_data values(33, 1, 'AIMLAPI', 'AIMLAPI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'AIMLAPI'); -insert into sys_dict_data values(34, 2, 'Anthropic', 'Anthropic', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Anthropic'); -insert into sys_dict_data values(35, 3, 'Cerebras', 'Cerebras', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Cerebras'); -insert into sys_dict_data values(36, 4, 'CerebrasOpenAI', 'CerebrasOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'CerebrasOpenAI'); -insert into sys_dict_data values(37, 5, 'Cohere', 'Cohere', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Cohere'); -insert into sys_dict_data values(38, 6, 'CometAPI', 'CometAPI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'CometAPI'); -insert into sys_dict_data values(39, 7, 'DashScope', 'DashScope', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'DashScope'); -insert into sys_dict_data values(40, 8, 'DeepInfra', 'DeepInfra', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'DeepInfra'); -insert into sys_dict_data values(41, 9, 'DeepSeek', 'DeepSeek', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'DeepSeek'); -insert into sys_dict_data values(42, 10, 'Fireworks', 'Fireworks', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Fireworks'); -insert into sys_dict_data values(43, 11, 'Google', 'Google', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Google'); -insert into sys_dict_data values(44, 12, 'Groq', 'Groq', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Groq'); -insert into sys_dict_data values(45, 13, 'HuggingFace', 'HuggingFace', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'HuggingFace'); -insert into sys_dict_data values(46, 14, 'LangDB', 'LangDB', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LangDB'); -insert into sys_dict_data values(47, 15, 'LiteLLM', 'LiteLLM', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LiteLLM'); -insert into sys_dict_data values(48, 16, 'LiteLLMOpenAI', 'LiteLLMOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LiteLLMOpenAI'); -insert into sys_dict_data values(49, 17, 'LlamaCpp', 'LlamaCpp', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LlamaCpp'); -insert into sys_dict_data values(50, 18, 'LMStudio', 'LMStudio', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'LMStudio'); -insert into sys_dict_data values(51, 19, 'Meta', 'Meta', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Meta'); -insert into sys_dict_data values(52, 20, 'Mistral', 'Mistral', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Mistral'); -insert into sys_dict_data values(53, 21, 'N1N', 'N1N', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'N1N'); -insert into sys_dict_data values(54, 22, 'Nebius', 'Nebius', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Nebius'); -insert into sys_dict_data values(55, 23, 'Nexus', 'Nexus', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Nexus'); -insert into sys_dict_data values(56, 24, 'Nvidia', 'Nvidia', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Nvidia'); -insert into sys_dict_data values(57, 25, 'Ollama', 'Ollama', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Ollama'); -insert into sys_dict_data values(58, 26, 'OpenAI', 'OpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'OpenAI'); -insert into sys_dict_data values(59, 27, 'OpenAIResponses', 'OpenAIResponses', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'OpenAIResponses'); -insert into sys_dict_data values(60, 28, 'OpenRouter', 'OpenRouter', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'OpenRouter'); -insert into sys_dict_data values(61, 29, 'Perplexity', 'Perplexity', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Perplexity'); -insert into sys_dict_data values(62, 30, 'Portkey', 'Portkey', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Portkey'); -insert into sys_dict_data values(63, 31, 'Requesty', 'Requesty', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Requesty'); -insert into sys_dict_data values(64, 32, 'Sambanova', 'Sambanova', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Sambanova'); -insert into sys_dict_data values(65, 33, 'SiliconFlow', 'SiliconFlow', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'SiliconFlow'); -insert into sys_dict_data values(66, 34, 'Together', 'Together', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Together'); -insert into sys_dict_data values(67, 35, 'Vercel', 'Vercel', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'Vercel'); -insert into sys_dict_data values(68, 36, 'VLLM', 'VLLM', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'VLLM'); -insert into sys_dict_data values(69, 37, 'xAI', 'xAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, 'xAI'); +insert into sys_dict_data values(33, 1, '安装', 'install', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', current_timestamp, '', null, '插件安装'); +insert into sys_dict_data values(34, 2, '启用', 'enable', 'plugin_operation_type', '', 'success', 'N', '0', 'admin', current_timestamp, '', null, '插件启用'); +insert into sys_dict_data values(35, 3, '停用', 'disable', 'plugin_operation_type', '', 'warning', 'N', '0', 'admin', current_timestamp, '', null, '插件停用'); +insert into sys_dict_data values(36, 4, '升级', 'upgrade', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', current_timestamp, '', null, '插件升级'); +insert into sys_dict_data values(37, 5, '卸载', 'uninstall', 'plugin_operation_type', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '插件卸载'); +insert into sys_dict_data values(38, 6, '清理', 'purge', 'plugin_operation_type', '', 'danger', 'N', '0', 'admin', current_timestamp, '', null, '插件清理'); +insert into sys_dict_data values(39, 7, '批量', 'batch', 'plugin_operation_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, '插件批量操作'); +insert into sys_dict_data values(40, 8, '批量安装', 'batch_install', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', current_timestamp, '', null, '插件批量安装'); +insert into sys_dict_data values(41, 9, '批量启用', 'batch_enable', 'plugin_operation_type', '', 'success', 'N', '0', 'admin', current_timestamp, '', null, '插件批量启用'); +insert into sys_dict_data values(42, 10, '批量升级', 'batch_upgrade', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', current_timestamp, '', null, '插件批量升级'); +insert into sys_dict_data values(43, 11, '配置保存', 'config_set', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', current_timestamp, '', null, '插件配置保存'); +insert into sys_dict_data values(44, 12, '配置更新', 'config_update', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', current_timestamp, '', null, '插件配置更新'); +insert into sys_dict_data values(45, 13, '配置导入', 'config_import', 'plugin_operation_type', '', 'warning', 'N', '0', 'admin', current_timestamp, '', null, '插件配置导入'); +insert into sys_dict_data values(46, 14, '配置导出', 'config_export', 'plugin_operation_type', '', 'warning', 'N', '0', 'admin', current_timestamp, '', null, '插件配置导出'); +insert into sys_dict_data values(47, 99, '未知操作', 'unknown', 'plugin_operation_type', '', 'info', 'N', '0', 'admin', current_timestamp, '', null, '插件未知操作'); -- ---------------------------- -- 13、参数配置表 @@ -766,6 +755,7 @@ insert into sys_config values(5, '账号自助-是否开启用户注册功能', insert into sys_config values(6, '用户登录-黑名单列表', 'sys.login.blackIPList', '', 'Y', 'admin', current_timestamp, '', null, '设置登录IP黑名单限制,多个匹配项以;分隔,支持匹配(*通配、网段)'); insert into sys_config values(7, '用户管理-初始密码修改策略', 'sys.account.initPasswordModify', '1', 'Y', 'admin', current_timestamp, '', null, '0:初始密码修改策略关闭,没有任何提示,1:提醒用户,如果未修改初始密码,则在登录时就会提醒修改密码对话框'); insert into sys_config values(8, '用户管理-账号密码更新周期', 'sys.account.passwordValidateDays', '0', 'Y', 'admin', current_timestamp, '', null, '密码更新周期(填写数字,数据初始化值为0不限制,若修改必须为大于0小于365的正整数),如果超过这个周期登录系统时,则在登录时就会提醒修改密码对话框'); +insert into sys_config values(9, '插件管理-操作审计保留天数', 'sys.plugin.operationLogRetentionDays', '180', 'Y', 'admin', current_timestamp, '', null, '插件操作审计日志默认保留天数,0表示清理当前时间之前的全部日志'); -- ---------------------------- -- 14、系统访问记录 @@ -1025,87 +1015,7 @@ comment on column gen_table_column.update_time is '更新时间'; comment on table gen_table_column is '代码生成业务表字段'; -- ---------------------------- --- 20、AI模型表 --- ---------------------------- -drop table if exists ai_models; -create table ai_models ( - model_id bigserial not null, - model_code varchar(100) not null, - model_name varchar(100) default null, - provider varchar(50) not null, - model_sort int4 not null, - api_key varchar(255) default null, - base_url varchar(255) default null, - model_type varchar(50) default null, - max_tokens integer default null, - temperature float default null, - support_reasoning char(1) default 'N', - support_images char(1) default 'N', - status char(1) default '0', - user_id bigint, - dept_id bigint, - create_by varchar(64) default '', - create_time timestamp(0), - update_by varchar(64) default '', - update_time timestamp(0), - remark varchar(500) default null, - primary key (model_id) -); -comment on table ai_models is 'AI模型表'; -comment on column ai_models.model_id is '模型主键'; -comment on column ai_models.model_code is '模型编码'; -comment on column ai_models.model_name is '模型名称'; -comment on column ai_models.provider is '提供商'; -comment on column ai_models.model_sort is '显示顺序'; -comment on column ai_models.api_key is 'API Key'; -comment on column ai_models.base_url is 'Base URL'; -comment on column ai_models.model_type is '模型类型'; -comment on column ai_models.max_tokens is '最大输出token'; -comment on column ai_models.temperature is '默认温度'; -comment on column ai_models.support_reasoning is '是否支持推理'; -comment on column ai_models.support_images is '是否支持图片'; -comment on column ai_models.status is '模型状态'; -comment on column ai_models.user_id is '用户ID'; -comment on column ai_models.dept_id is '部门ID'; -comment on column ai_models.create_by is '创建者'; -comment on column ai_models.create_time is '创建时间'; -comment on column ai_models.update_by is '更新者'; -comment on column ai_models.update_time is '更新时间'; -comment on column ai_models.remark is '备注'; - --- ---------------------------- --- 21、AI对话配置表 --- ---------------------------- -drop table if exists ai_chat_config; -create table ai_chat_config ( - chat_config_id bigserial not null, - user_id bigint not null unique, - temperature float default null, - add_history_to_context char(1) default '0', - num_history_runs int4 default null, - system_prompt text default null, - metrics_default_visible char(1) default '0', - vision_enabled char(1) default '1', - image_max_size_mb int4 default null, - create_time timestamp(0), - update_time timestamp(0), - primary key (chat_config_id) -); -comment on table ai_chat_config is 'AI对话配置表'; -comment on column ai_chat_config.chat_config_id is '配置主键'; -comment on column ai_chat_config.user_id is '用户ID'; -comment on column ai_chat_config.temperature is '默认温度'; -comment on column ai_chat_config.add_history_to_context is '是否添加历史记录(0是, 1否)'; -comment on column ai_chat_config.num_history_runs is '历史记录条数'; -comment on column ai_chat_config.system_prompt is '系统提示词'; -comment on column ai_chat_config.metrics_default_visible is '默认显示指标(0是, 1否)'; -comment on column ai_chat_config.vision_enabled is '是否开启视觉(0是, 1否)'; -comment on column ai_chat_config.image_max_size_mb is '图片最大大小(MB)'; -comment on column ai_chat_config.create_time is '创建时间'; -comment on column ai_chat_config.update_time is '更新时间'; - --- ---------------------------- --- 22、文件信息表 +-- 20、文件信息表 -- ---------------------------- drop table if exists sys_file_info; create table sys_file_info ( @@ -1169,7 +1079,7 @@ comment on column sys_file_info.deleted_time is '移入回收站时间'; comment on column sys_file_info.del_flag is '删除标志'; -- ---------------------------- --- 23、文件业务引用表 +-- 21、文件业务引用表 -- ---------------------------- drop table if exists sys_file_reference; create table sys_file_reference ( @@ -1197,7 +1107,7 @@ comment on column sys_file_reference.create_by is '创建者'; comment on column sys_file_reference.create_time is '创建时间'; -- ---------------------------- --- 24、文件业务保留策略表 +-- 22、文件业务保留策略表 -- ---------------------------- drop table if exists sys_file_retention_policy; create table sys_file_retention_policy ( @@ -1222,7 +1132,7 @@ comment on column sys_file_retention_policy.update_by is '更新者'; comment on column sys_file_retention_policy.update_time is '更新时间'; -- ---------------------------- --- 25、文件保留期限提醒表 +-- 23、文件保留期限提醒表 -- ---------------------------- drop table if exists sys_file_retention_notice; create table sys_file_retention_notice ( @@ -1251,7 +1161,7 @@ comment on column sys_file_retention_notice.read_by is '读取者'; comment on column sys_file_retention_notice.read_time is '读取时间'; -- ---------------------------- --- 26、文件访问控制表 +-- 24、文件访问控制表 -- ---------------------------- drop table if exists sys_file_acl; create table sys_file_acl ( @@ -1285,7 +1195,7 @@ comment on column sys_file_acl.create_time is '创建时间'; comment on column sys_file_acl.del_flag is '删除标志'; -- ---------------------------- --- 27、文件访问审计表 +-- 25、文件访问审计表 -- ---------------------------- drop table if exists sys_file_access_log; create table sys_file_access_log ( @@ -1324,7 +1234,7 @@ comment on column sys_file_access_log.operation_detail is '操作详情'; comment on column sys_file_access_log.access_time is '访问时间'; -- ---------------------------- --- 28、文件存储对账任务表 +-- 26、文件存储对账任务表 -- ---------------------------- drop table if exists sys_file_reconcile_run; create table sys_file_reconcile_run ( @@ -1363,7 +1273,7 @@ comment on column sys_file_reconcile_run.finished_time is '完成时间'; comment on column sys_file_reconcile_run.error_message is '失败原因'; -- ---------------------------- --- 29、文件存储对账异常表 +-- 27、文件存储对账异常表 -- ---------------------------- drop table if exists sys_file_reconcile_issue; create table sys_file_reconcile_issue ( @@ -1427,6 +1337,162 @@ comment on column sys_file_reconcile_issue.handled_by is '处理人'; comment on column sys_file_reconcile_issue.handled_time is '处理时间'; comment on column sys_file_reconcile_issue.quarantine_key is '隔离区相对路径'; +-- ---------------------------- +-- 28、插件信息表 +-- ---------------------------- +drop table if exists sys_plugin; +create table sys_plugin ( + plugin_id varchar(64) not null, + plugin_name varchar(128) not null, + version varchar(32) not null, + installed_version varchar(32) default null, + enabled char(1) not null default '0', + status varchar(32) not null default 'discovered', + source varchar(32) not null default 'local', + backend_path varchar(255) default null, + frontend_path varchar(255) default null, + last_error varchar(1000) default null, + description varchar(500) default null, + create_by varchar(64) default '', + create_time timestamp(0), + update_by varchar(64) default '', + update_time timestamp(0), + remark varchar(500) default null, + primary key (plugin_id), + constraint ck_sys_plugin_enabled check (enabled in ('0', '1')), + constraint ck_sys_plugin_status check (status in ('discovered', 'installed', 'pending_upgrade', 'error')) +); +comment on table sys_plugin is '插件信息表'; +comment on column sys_plugin.plugin_id is '插件ID'; +comment on column sys_plugin.plugin_name is '插件名称'; +comment on column sys_plugin.version is '当前源码版本'; +comment on column sys_plugin.installed_version is '已安装版本'; +comment on column sys_plugin.enabled is '是否启用(0启用 1停用)'; +comment on column sys_plugin.status is '插件状态'; +comment on column sys_plugin.source is '插件来源'; +comment on column sys_plugin.backend_path is '后端插件相对路径'; +comment on column sys_plugin.frontend_path is '前端插件相对路径'; +comment on column sys_plugin.last_error is '最近一次错误信息'; +comment on column sys_plugin.description is '插件说明'; +comment on column sys_plugin.create_by is '创建者'; +comment on column sys_plugin.create_time is '创建时间'; +comment on column sys_plugin.update_by is '更新者'; +comment on column sys_plugin.update_time is '更新时间'; +comment on column sys_plugin.remark is '备注'; + +-- ---------------------------- +-- 29、插件和菜单关联表 +-- ---------------------------- +drop table if exists sys_plugin_menu; +create table sys_plugin_menu ( + plugin_id varchar(64) not null, + menu_id bigint not null, + menu_key varchar(255) not null, + create_time timestamp(0), + primary key (plugin_id, menu_id), + constraint uk_sys_plugin_menu_key unique (plugin_id, menu_key) +); +comment on table sys_plugin_menu is '插件和菜单关联表'; +comment on column sys_plugin_menu.plugin_id is '插件ID'; +comment on column sys_plugin_menu.menu_id is '菜单ID'; +comment on column sys_plugin_menu.menu_key is '插件内菜单自然键'; +comment on column sys_plugin_menu.create_time is '创建时间'; + +-- ---------------------------- +-- 30、插件 migration 执行历史表 +-- ---------------------------- +drop table if exists sys_plugin_migration; +create table sys_plugin_migration ( + plugin_id varchar(64) not null, + migration_path varchar(255) not null, + migration_checksum varchar(64) not null, + version varchar(32) default null, + statement_count int4 not null default 0, + status varchar(32) not null default 'success', + error_message text, + attempt_count int4 not null default 0, + started_time timestamp(0), + finished_time timestamp(0), + create_time timestamp(0), + update_time timestamp(0), + primary key (plugin_id, migration_path) +); +comment on table sys_plugin_migration is '插件 migration 执行历史表'; +comment on column sys_plugin_migration.plugin_id is '插件ID'; +comment on column sys_plugin_migration.migration_path is 'migration 相对路径'; +comment on column sys_plugin_migration.migration_checksum is 'migration 内容校验值'; +comment on column sys_plugin_migration.version is '执行时插件版本'; +comment on column sys_plugin_migration.statement_count is 'SQL 语句数量'; +comment on column sys_plugin_migration.status is '执行状态'; +comment on column sys_plugin_migration.error_message is '失败错误信息'; +comment on column sys_plugin_migration.attempt_count is '尝试次数'; +comment on column sys_plugin_migration.started_time is '最近开始时间'; +comment on column sys_plugin_migration.finished_time is '最近结束时间'; +comment on column sys_plugin_migration.create_time is '执行时间'; +comment on column sys_plugin_migration.update_time is '更新时间'; + +-- ---------------------------- +-- 31、插件配置表 +-- ---------------------------- +drop table if exists sys_plugin_config; +create table sys_plugin_config ( + plugin_id varchar(64) not null, + config_key varchar(128) not null, + config_label varchar(128) default null, + config_type varchar(32) not null default 'string', + config_value text, + default_value text, + required char(1) not null default '1', + secret char(1) not null default '1', + options text, + description varchar(500) default null, + create_time timestamp(0), + update_time timestamp(0), + primary key (plugin_id, config_key) +); +comment on table sys_plugin_config is '插件配置表'; +comment on column sys_plugin_config.plugin_id is '插件ID'; +comment on column sys_plugin_config.config_key is '配置键名'; +comment on column sys_plugin_config.config_label is '配置展示名称'; +comment on column sys_plugin_config.config_type is '配置值类型'; +comment on column sys_plugin_config.config_value is '配置值'; +comment on column sys_plugin_config.default_value is '默认配置值'; +comment on column sys_plugin_config.required is '是否必填(0是 1否)'; +comment on column sys_plugin_config.secret is '是否敏感(0是 1否)'; +comment on column sys_plugin_config.options is '配置选项JSON'; +comment on column sys_plugin_config.description is '配置说明'; +comment on column sys_plugin_config.create_time is '创建时间'; +comment on column sys_plugin_config.update_time is '更新时间'; + +-- ---------------------------- +-- 32、插件批量操作审计日志表 +-- ---------------------------- +drop table if exists sys_plugin_operation_log; +create table sys_plugin_operation_log ( + operation_id bigserial not null, + operation varchar(32) not null, + plugin_ids text, + dry_run char(1) not null default '1', + continue_on_error char(1) not null default '1', + status varchar(32) not null, + summary text, + result text, + create_time timestamp(0), + remark varchar(500) default null, + primary key (operation_id) +); +comment on table sys_plugin_operation_log is '插件批量操作审计日志表'; +comment on column sys_plugin_operation_log.operation_id is '操作日志ID'; +comment on column sys_plugin_operation_log.operation is '操作类型'; +comment on column sys_plugin_operation_log.plugin_ids is '目标插件ID JSON'; +comment on column sys_plugin_operation_log.dry_run is '是否预演(0是 1否)'; +comment on column sys_plugin_operation_log.continue_on_error is '失败后是否继续(0是 1否)'; +comment on column sys_plugin_operation_log.status is '执行状态'; +comment on column sys_plugin_operation_log.summary is '执行汇总JSON'; +comment on column sys_plugin_operation_log.result is '完整执行结果JSON'; +comment on column sys_plugin_operation_log.create_time is '创建时间'; +comment on column sys_plugin_operation_log.remark is '备注'; + CREATE OR REPLACE FUNCTION "find_in_set"(int8, varchar) RETURNS "pg_catalog"."bool" AS $BODY$ DECLARE diff --git a/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql b/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql index c2d3468..2173b08 100644 --- a/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql +++ b/ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql @@ -162,7 +162,6 @@ create table sys_menu ( insert into sys_menu values('1', '系统管理', '0', '1', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', sysdate(), '', null, '系统管理目录'); insert into sys_menu values('2', '系统监控', '0', '2', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', sysdate(), '', null, '系统监控目录'); insert into sys_menu values('3', '系统工具', '0', '3', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', sysdate(), '', null, '系统工具目录'); -insert into sys_menu values('4', 'AI 管理', '0', '4', 'ai', null, '', '', 1, 0, 'M', '0', '0', '', 'ai-manage', 'admin', sysdate(), '', null, 'AI 管理目录'); insert into sys_menu values('99', '若依官网', '0', '99', 'http://ruoyi.vip', null, '', '', 0, 0, 'M', '0', '0', '', 'guide', 'admin', sysdate(), '', null, '若依官网地址'); -- 二级菜单 insert into sys_menu values('100', '用户管理', '1', '1', 'user', 'system/user/index', '', '', 1, 0, 'C', '0', '0', 'system:user:list', 'user', 'admin', sysdate(), '', null, '用户管理菜单'); @@ -174,19 +173,18 @@ insert into sys_menu values('105', '字典管理', '1', '6', 'dict', insert into sys_menu values('106', '参数设置', '1', '7', 'config', 'system/config/index', '', '', 1, 0, 'C', '0', '0', 'system:config:list', 'edit', 'admin', sysdate(), '', null, '参数设置菜单'); insert into sys_menu values('107', '通知公告', '1', '8', 'notice', 'system/notice/index', '', '', 1, 0, 'C', '0', '0', 'system:notice:list', 'message', 'admin', sysdate(), '', null, '通知公告菜单'); insert into sys_menu values('108', '日志管理', '1', '9', 'log', '', '', '', 1, 0, 'M', '0', '0', '', 'log', 'admin', sysdate(), '', null, '日志管理菜单'); -insert into sys_menu values('121', '文件管理', '1', '10', 'file', 'system/file/index', '', '', 1, 0, 'C', '0', '0', 'system:file:list', 'documentation', 'admin', sysdate(), '', null, '文件管理菜单'); +insert into sys_menu values('119', '文件管理', '1', '10', 'file', 'system/file/index', '', '', 1, 0, 'C', '0', '0', 'system:file:list', 'documentation', 'admin', sysdate(), '', null, '文件管理菜单'); +insert into sys_menu values('120', '插件管理', '1', '11', 'plugin', 'system/plugin/index', '', '', 1, 0, 'C', '0', '0', 'system:plugin:list', 'component', 'admin', sysdate(), '', null, '插件管理菜单'); insert into sys_menu values('109', '在线用户', '2', '1', 'online', 'monitor/online/index', '', '', 1, 0, 'C', '0', '0', 'monitor:online:list', 'online', 'admin', sysdate(), '', null, '在线用户菜单'); insert into sys_menu values('110', '定时任务', '2', '2', 'job', 'monitor/job/index', '', '', 1, 0, 'C', '0', '0', 'monitor:job:list', 'job', 'admin', sysdate(), '', null, '定时任务菜单'); insert into sys_menu values('111', '数据监控', '2', '3', 'druid', 'monitor/druid/index', '', '', 1, 0, 'C', '0', '0', 'monitor:druid:list', 'druid', 'admin', sysdate(), '', null, '数据监控菜单'); insert into sys_menu values('112', '服务监控', '2', '4', 'server', 'monitor/server/index', '', '', 1, 0, 'C', '0', '0', 'monitor:server:list', 'server', 'admin', sysdate(), '', null, '服务监控菜单'); insert into sys_menu values('113', '缓存监控', '2', '5', 'cache', 'monitor/cache/index', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis', 'admin', sysdate(), '', null, '缓存监控菜单'); insert into sys_menu values('114', '缓存列表', '2', '6', 'cacheList', 'monitor/cache/list', '', '', 1, 0, 'C', '0', '0', 'monitor:cache:list', 'redis-list', 'admin', sysdate(), '', null, '缓存列表菜单'); -insert into sys_menu values('120', '传输加密', '2', '7', 'transportCrypto', 'monitor/transportCrypto/index', '', '', 1, 0, 'C', '0', '0', 'monitor:transportCrypto:list', 'chart', 'admin', sysdate(), '', null, '传输加密监控菜单'); +insert into sys_menu values('118', '传输加密', '2', '7', 'transportCrypto', 'monitor/transportCrypto/index', '', '', 1, 0, 'C', '0', '0', 'monitor:transportCrypto:list', 'chart', 'admin', sysdate(), '', null, '传输加密监控菜单'); insert into sys_menu values('115', '表单构建', '3', '1', 'build', 'tool/build/index', '', '', 1, 0, 'C', '0', '0', 'tool:build:list', 'build', 'admin', sysdate(), '', null, '表单构建菜单'); insert into sys_menu values('116', '代码生成', '3', '2', 'gen', 'tool/gen/index', '', '', 1, 0, 'C', '0', '0', 'tool:gen:list', 'code', 'admin', sysdate(), '', null, '代码生成菜单'); insert into sys_menu values('117', '系统接口', '3', '3', 'swagger', 'tool/swagger/index', '', '', 1, 0, 'C', '0', '0', 'tool:swagger:list', 'swagger', 'admin', sysdate(), '', null, '系统接口菜单'); -insert into sys_menu values('118', '模型管理', '4', '1', 'model', 'ai/model/index', '', '', 1, 0, 'C', '0', '0', 'ai:model:list', 'ai-model', 'admin', sysdate(), '', null, '模型管理菜单'); -insert into sys_menu values('119', 'AI 对话', '4', '2', 'chat', 'ai/chat/index', '', '', 1, 0, 'C', '0', '0', 'ai:chat:list', 'ai-chat', 'admin', sysdate(), '', null, 'AI 对话菜单'); -- 三级菜单 insert into sys_menu values('500', '操作日志', '108', '1', 'operlog', 'monitor/operlog/index', '', '', 1, 0, 'C', '0', '0', 'monitor:operlog:list', 'form', 'admin', sysdate(), '', null, '操作日志菜单'); insert into sys_menu values('501', '登录日志', '108', '2', 'logininfor', 'monitor/logininfor/index', '', '', 1, 0, 'C', '0', '0', 'monitor:logininfor:list', 'logininfor', 'admin', sysdate(), '', null, '登录日志菜单'); @@ -238,14 +236,19 @@ insert into sys_menu values('1036', '公告新增', '107', '2', '#', '', '', '', insert into sys_menu values('1037', '公告修改', '107', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:edit', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1038', '公告删除', '107', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:notice:remove', '#', 'admin', sysdate(), '', null, ''); -- 文件管理按钮 -insert into sys_menu values('1065', '文件查询', '121', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:query', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1066', '文件下载', '121', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:download', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1067', '文件删除', '121', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:remove', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1068', '文件授权', '121', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:edit', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1069', '文件转移', '121', '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:transfer', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1070', '文件恢复', '121', '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:restore', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1071', '文件清理', '121', '7', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:purge', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1072', '存储对账', '121', '8', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:reconcile', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1061', '文件查询', '119', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1062', '文件下载', '119', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:download', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1063', '文件删除', '119', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:remove', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1064', '文件授权', '119', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1065', '文件转移', '119', '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:transfer', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1066', '文件恢复', '119', '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:restore', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1067', '文件清理', '119', '7', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:purge', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1068', '存储对账', '119', '8', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:file:reconcile', '#', 'admin', sysdate(), '', null, ''); +-- 插件管理按钮 +insert into sys_menu values('1069', '插件查询', '120', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:plugin:query', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1070', '插件修改', '120', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:plugin:edit', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1071', '插件列表', '120', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:plugin:list', '#', 'admin', sysdate(), '', null, ''); +insert into sys_menu values('1072', '插件导出', '120', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'system:plugin:export', '#', 'admin', sysdate(), '', null, ''); -- 操作日志按钮 insert into sys_menu values('1039', '操作查询', '500', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:query', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1040', '操作删除', '500', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'monitor:operlog:remove', '#', 'admin', sysdate(), '', null, ''); @@ -273,11 +276,6 @@ insert into sys_menu values('1057', '生成删除', '116', '3', '#', '', '', '', insert into sys_menu values('1058', '导入代码', '116', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:import', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1059', '预览代码', '116', '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:preview', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1060', '生成代码', '116', '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:code', '#', 'admin', sysdate(), '', null, ''); --- 模型管理按钮 -insert into sys_menu values('1061', '模型查询', '118', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:query', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1062', '模型新增', '118', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:add', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1063', '模型修改', '118', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:edit', '#', 'admin', sysdate(), '', null, ''); -insert into sys_menu values('1064', '模型删除', '118', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'ai:model:remove', '#', 'admin', sysdate(), '', null, ''); -- ---------------------------- @@ -313,7 +311,6 @@ create table sys_role_menu ( insert into sys_role_menu values ('2', '1'); insert into sys_role_menu values ('2', '2'); insert into sys_role_menu values ('2', '3'); -insert into sys_role_menu values ('2', '4'); insert into sys_role_menu values ('2', '100'); insert into sys_role_menu values ('2', '101'); insert into sys_role_menu values ('2', '102'); @@ -329,6 +326,8 @@ insert into sys_role_menu values ('2', '111'); insert into sys_role_menu values ('2', '112'); insert into sys_role_menu values ('2', '113'); insert into sys_role_menu values ('2', '114'); +insert into sys_role_menu values ('2', '118'); +insert into sys_role_menu values ('2', '119'); insert into sys_role_menu values ('2', '120'); insert into sys_role_menu values ('2', '115'); insert into sys_role_menu values ('2', '116'); @@ -396,6 +395,18 @@ insert into sys_role_menu values ('2', '1057'); insert into sys_role_menu values ('2', '1058'); insert into sys_role_menu values ('2', '1059'); insert into sys_role_menu values ('2', '1060'); +insert into sys_role_menu values ('2', '1061'); +insert into sys_role_menu values ('2', '1062'); +insert into sys_role_menu values ('2', '1063'); +insert into sys_role_menu values ('2', '1064'); +insert into sys_role_menu values ('2', '1065'); +insert into sys_role_menu values ('2', '1066'); +insert into sys_role_menu values ('2', '1067'); +insert into sys_role_menu values ('2', '1068'); +insert into sys_role_menu values ('2', '1069'); +insert into sys_role_menu values ('2', '1070'); +insert into sys_role_menu values ('2', '1071'); +insert into sys_role_menu values ('2', '1072'); -- ---------------------------- -- 8、角色和部门关联表 角色1-N部门 @@ -492,7 +503,7 @@ insert into sys_dict_type values(8, '通知类型', 'sys_notice_type', insert into sys_dict_type values(9, '通知状态', 'sys_notice_status', '0', 'admin', sysdate(), '', null, '通知状态列表'); insert into sys_dict_type values(10, '操作类型', 'sys_oper_type', '0', 'admin', sysdate(), '', null, '操作类型列表'); insert into sys_dict_type values(11, '系统状态', 'sys_common_status', '0', 'admin', sysdate(), '', null, '登录状态列表'); -insert into sys_dict_type values(12, 'AI模型提供商', 'ai_provider_type', '0', 'admin', sysdate(), '', null, 'AI模型提供商列表'); +insert into sys_dict_type values(12, '插件操作类型', 'plugin_operation_type', '0', 'admin', sysdate(), '', null, '插件操作类型列表'); -- ---------------------------- @@ -550,43 +561,21 @@ insert into sys_dict_data values(29, 8, '生成代码', '8', insert into sys_dict_data values(30, 9, '清空数据', '9', 'sys_oper_type', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '清空操作'); insert into sys_dict_data values(31, 1, '成功', '0', 'sys_common_status', '', 'primary', 'N', '0', 'admin', sysdate(), '', null, '正常状态'); insert into sys_dict_data values(32, 2, '失败', '1', 'sys_common_status', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '停用状态'); -insert into sys_dict_data values(33, 1, 'AIMLAPI', 'AIMLAPI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'AIMLAPI'); -insert into sys_dict_data values(34, 2, 'Anthropic', 'Anthropic', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Anthropic'); -insert into sys_dict_data values(35, 3, 'Cerebras', 'Cerebras', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Cerebras'); -insert into sys_dict_data values(36, 4, 'CerebrasOpenAI', 'CerebrasOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'CerebrasOpenAI'); -insert into sys_dict_data values(37, 5, 'Cohere', 'Cohere', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Cohere'); -insert into sys_dict_data values(38, 6, 'CometAPI', 'CometAPI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'CometAPI'); -insert into sys_dict_data values(39, 7, 'DashScope', 'DashScope', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'DashScope'); -insert into sys_dict_data values(40, 8, 'DeepInfra', 'DeepInfra', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'DeepInfra'); -insert into sys_dict_data values(41, 9, 'DeepSeek', 'DeepSeek', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'DeepSeek'); -insert into sys_dict_data values(42, 10, 'Fireworks', 'Fireworks', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Fireworks'); -insert into sys_dict_data values(43, 11, 'Google', 'Google', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Google'); -insert into sys_dict_data values(44, 12, 'Groq', 'Groq', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Groq'); -insert into sys_dict_data values(45, 13, 'HuggingFace', 'HuggingFace', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'HuggingFace'); -insert into sys_dict_data values(46, 14, 'LangDB', 'LangDB', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LangDB'); -insert into sys_dict_data values(47, 15, 'LiteLLM', 'LiteLLM', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LiteLLM'); -insert into sys_dict_data values(48, 16, 'LiteLLMOpenAI', 'LiteLLMOpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LiteLLMOpenAI'); -insert into sys_dict_data values(49, 17, 'LlamaCpp', 'LlamaCpp', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LlamaCpp'); -insert into sys_dict_data values(50, 18, 'LMStudio', 'LMStudio', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'LMStudio'); -insert into sys_dict_data values(51, 19, 'Meta', 'Meta', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Meta'); -insert into sys_dict_data values(52, 20, 'Mistral', 'Mistral', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Mistral'); -insert into sys_dict_data values(53, 21, 'N1N', 'N1N', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'N1N'); -insert into sys_dict_data values(54, 22, 'Nebius', 'Nebius', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Nebius'); -insert into sys_dict_data values(55, 23, 'Nexus', 'Nexus', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Nexus'); -insert into sys_dict_data values(56, 24, 'Nvidia', 'Nvidia', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Nvidia'); -insert into sys_dict_data values(57, 25, 'Ollama', 'Ollama', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Ollama'); -insert into sys_dict_data values(58, 26, 'OpenAI', 'OpenAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'OpenAI'); -insert into sys_dict_data values(59, 27, 'OpenAIResponses', 'OpenAIResponses', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'OpenAIResponses'); -insert into sys_dict_data values(60, 28, 'OpenRouter', 'OpenRouter', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'OpenRouter'); -insert into sys_dict_data values(61, 29, 'Perplexity', 'Perplexity', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Perplexity'); -insert into sys_dict_data values(62, 30, 'Portkey', 'Portkey', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Portkey'); -insert into sys_dict_data values(63, 31, 'Requesty', 'Requesty', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Requesty'); -insert into sys_dict_data values(64, 32, 'Sambanova', 'Sambanova', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Sambanova'); -insert into sys_dict_data values(65, 33, 'SiliconFlow', 'SiliconFlow', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'SiliconFlow'); -insert into sys_dict_data values(66, 34, 'Together', 'Together', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Together'); -insert into sys_dict_data values(67, 35, 'Vercel', 'Vercel', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'Vercel'); -insert into sys_dict_data values(68, 36, 'VLLM', 'VLLM', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'VLLM'); -insert into sys_dict_data values(69, 37, 'xAI', 'xAI', 'ai_provider_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, 'xAI'); +insert into sys_dict_data values(33, 1, '安装', 'install', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', sysdate(), '', null, '插件安装'); +insert into sys_dict_data values(34, 2, '启用', 'enable', 'plugin_operation_type', '', 'success', 'N', '0', 'admin', sysdate(), '', null, '插件启用'); +insert into sys_dict_data values(35, 3, '停用', 'disable', 'plugin_operation_type', '', 'warning', 'N', '0', 'admin', sysdate(), '', null, '插件停用'); +insert into sys_dict_data values(36, 4, '升级', 'upgrade', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', sysdate(), '', null, '插件升级'); +insert into sys_dict_data values(37, 5, '卸载', 'uninstall', 'plugin_operation_type', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '插件卸载'); +insert into sys_dict_data values(38, 6, '清理', 'purge', 'plugin_operation_type', '', 'danger', 'N', '0', 'admin', sysdate(), '', null, '插件清理'); +insert into sys_dict_data values(39, 7, '批量', 'batch', 'plugin_operation_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, '插件批量操作'); +insert into sys_dict_data values(40, 8, '批量安装', 'batch_install', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', sysdate(), '', null, '插件批量安装'); +insert into sys_dict_data values(41, 9, '批量启用', 'batch_enable', 'plugin_operation_type', '', 'success', 'N', '0', 'admin', sysdate(), '', null, '插件批量启用'); +insert into sys_dict_data values(42, 10, '批量升级', 'batch_upgrade', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', sysdate(), '', null, '插件批量升级'); +insert into sys_dict_data values(43, 11, '配置保存', 'config_set', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', sysdate(), '', null, '插件配置保存'); +insert into sys_dict_data values(44, 12, '配置更新', 'config_update', 'plugin_operation_type', '', 'primary', 'N', '0', 'admin', sysdate(), '', null, '插件配置更新'); +insert into sys_dict_data values(45, 13, '配置导入', 'config_import', 'plugin_operation_type', '', 'warning', 'N', '0', 'admin', sysdate(), '', null, '插件配置导入'); +insert into sys_dict_data values(46, 14, '配置导出', 'config_export', 'plugin_operation_type', '', 'warning', 'N', '0', 'admin', sysdate(), '', null, '插件配置导出'); +insert into sys_dict_data values(47, 99, '未知操作', 'unknown', 'plugin_operation_type', '', 'info', 'N', '0', 'admin', sysdate(), '', null, '插件未知操作'); -- ---------------------------- @@ -615,6 +604,7 @@ insert into sys_config values(5, '账号自助-是否开启用户注册功能', insert into sys_config values(6, '用户登录-黑名单列表', 'sys.login.blackIPList', '', 'Y', 'admin', sysdate(), '', null, '设置登录IP黑名单限制,多个匹配项以;分隔,支持匹配(*通配、网段)'); insert into sys_config values(7, '用户管理-初始密码修改策略', 'sys.account.initPasswordModify', '1', 'Y', 'admin', sysdate(), '', null, '0:初始密码修改策略关闭,没有任何提示,1:提醒用户,如果未修改初始密码,则在登录时就会提醒修改密码对话框'); insert into sys_config values(8, '用户管理-账号密码更新周期', 'sys.account.passwordValidateDays', '0', 'Y', 'admin', sysdate(), '', null, '密码更新周期(填写数字,数据初始化值为0不限制,若修改必须为大于0小于365的正整数),如果超过这个周期登录系统时,则在登录时就会提醒修改密码对话框'); +insert into sys_config values(9, '插件管理-操作审计保留天数', 'sys.plugin.operationLogRetentionDays', '180', 'Y', 'admin', sysdate(), '', null, '插件操作审计日志默认保留天数,0表示清理当前时间之前的全部日志'); -- ---------------------------- @@ -776,58 +766,8 @@ create table gen_table_column ( primary key (column_id) ) engine=innodb auto_increment=1 comment = '代码生成业务表字段'; - -- ---------------------------- --- 20、AI模型表 --- ---------------------------- -drop table if exists ai_models; -create table ai_models ( - model_id bigint(20) not null auto_increment comment '模型主键', - model_code varchar(100) not null comment '模型编码', - model_name varchar(100) default null comment '模型名称', - provider varchar(50) not null comment '提供商', - model_sort int(4) not null comment '显示顺序', - api_key varchar(255) default null comment 'API Key', - base_url varchar(255) default null comment 'Base URL', - model_type varchar(50) default null comment '模型类型', - max_tokens int(11) default null comment '最大输出token', - temperature float default null comment '默认温度', - support_reasoning char(1) default 'N' comment '是否支持推理', - support_images char(1) default 'N' comment '是否支持图片', - status char(1) default '0' comment '模型状态', - user_id bigint(20) comment '用户ID', - dept_id bigint(20) comment '部门ID', - create_by varchar(64) default '' comment '创建者', - create_time datetime comment '创建时间', - update_by varchar(64) default '' comment '更新者', - update_time datetime comment '更新时间', - remark varchar(500) default null comment '备注', - primary key (model_id) -) engine=innodb auto_increment=1 comment = 'AI模型表'; - - --- ---------------------------- --- 21、AI对话配置表 --- ---------------------------- -drop table if exists ai_chat_config; -create table ai_chat_config ( - chat_config_id bigint(20) not null auto_increment comment '配置主键', - user_id bigint(20) not null unique comment '用户ID', - temperature float default null comment '默认温度', - add_history_to_context char(1) default '0' comment '是否添加历史记录(0是, 1否)', - num_history_runs int(4) default null comment '历史记录条数', - system_prompt text default null comment '系统提示词', - metrics_default_visible char(1) default '0' comment '默认显示指标(0是, 1否)', - vision_enabled char(1) default '1' comment '是否开启视觉(0是, 1否)', - image_max_size_mb int(4) default null comment '图片最大大小(MB)', - create_time datetime comment '创建时间', - update_time datetime comment '更新时间', - primary key (chat_config_id) -) engine=innodb auto_increment=1 comment = 'AI对话配置表'; - - --- ---------------------------- --- 22、文件信息表 +-- 20、文件信息表 -- ---------------------------- drop table if exists sys_file_info; create table sys_file_info ( @@ -866,7 +806,7 @@ create table sys_file_info ( -- ---------------------------- --- 23、文件业务引用表 +-- 21、文件业务引用表 -- ---------------------------- drop table if exists sys_file_reference; create table sys_file_reference ( @@ -886,7 +826,7 @@ create table sys_file_reference ( -- ---------------------------- --- 24、文件业务保留策略表 +-- 22、文件业务保留策略表 -- ---------------------------- drop table if exists sys_file_retention_policy; create table sys_file_retention_policy ( @@ -903,7 +843,7 @@ create table sys_file_retention_policy ( -- ---------------------------- --- 25、文件保留期限提醒表 +-- 23、文件保留期限提醒表 -- ---------------------------- drop table if exists sys_file_retention_notice; create table sys_file_retention_notice ( @@ -923,7 +863,7 @@ create table sys_file_retention_notice ( -- ---------------------------- --- 26、文件访问控制表 +-- 24、文件访问控制表 -- ---------------------------- drop table if exists sys_file_acl; create table sys_file_acl ( @@ -946,7 +886,7 @@ create table sys_file_acl ( -- ---------------------------- --- 27、文件访问审计表 +-- 25、文件访问审计表 -- ---------------------------- drop table if exists sys_file_access_log; create table sys_file_access_log ( @@ -971,7 +911,7 @@ create table sys_file_access_log ( -- ---------------------------- --- 28、文件存储对账任务表 +-- 26、文件存储对账任务表 -- ---------------------------- drop table if exists sys_file_reconcile_run; create table sys_file_reconcile_run ( @@ -996,7 +936,7 @@ create table sys_file_reconcile_run ( -- ---------------------------- --- 29、文件存储对账异常表 +-- 27、文件存储对账异常表 -- ---------------------------- drop table if exists sys_file_reconcile_issue; create table sys_file_reconcile_issue ( @@ -1032,3 +972,100 @@ create table sys_file_reconcile_issue ( key idx_sys_file_reconcile_issue_file (file_id), key idx_sys_file_reconcile_issue_run (last_run_id) ) engine=innodb auto_increment=1 comment = '文件存储对账异常表'; + +-- ---------------------------- +-- 28、插件信息表 +-- ---------------------------- +drop table if exists sys_plugin; +create table sys_plugin ( + plugin_id varchar(64) not null comment '插件ID', + plugin_name varchar(128) not null comment '插件名称', + version varchar(32) not null comment '当前源码版本', + installed_version varchar(32) default null comment '已安装版本', + enabled char(1) not null default '0' comment '是否启用(0启用 1停用)', + status varchar(32) not null default 'discovered' comment '插件状态', + source varchar(32) not null default 'local' comment '插件来源', + backend_path varchar(255) default null comment '后端插件相对路径', + frontend_path varchar(255) default null comment '前端插件相对路径', + last_error varchar(1000) default null comment '最近一次错误信息', + description varchar(500) default null comment '插件说明', + create_by varchar(64) default '' comment '创建者', + create_time datetime comment '创建时间', + update_by varchar(64) default '' comment '更新者', + update_time datetime comment '更新时间', + remark varchar(500) default null comment '备注', + primary key (plugin_id), + constraint ck_sys_plugin_enabled check (enabled in ('0', '1')), + constraint ck_sys_plugin_status check (status in ('discovered', 'installed', 'pending_upgrade', 'error')) +) engine=innodb comment = '插件信息表'; + +-- ---------------------------- +-- 29、插件和菜单关联表 +-- ---------------------------- +drop table if exists sys_plugin_menu; +create table sys_plugin_menu ( + plugin_id varchar(64) not null comment '插件ID', + menu_id bigint(20) not null comment '菜单ID', + menu_key varchar(255) not null comment '插件内菜单自然键', + create_time datetime comment '创建时间', + primary key (plugin_id, menu_id), + unique key uk_sys_plugin_menu_key (plugin_id, menu_key) +) engine=innodb comment = '插件和菜单关联表'; + +-- ---------------------------- +-- 30、插件 migration 执行历史表 +-- ---------------------------- +drop table if exists sys_plugin_migration; +create table sys_plugin_migration ( + plugin_id varchar(64) not null comment '插件ID', + migration_path varchar(255) not null comment 'migration 相对路径', + migration_checksum varchar(64) not null comment 'migration 内容校验值', + version varchar(32) default null comment '执行时插件版本', + statement_count int not null default 0 comment 'SQL 语句数量', + status varchar(32) not null default 'success' comment '执行状态', + error_message text comment '失败错误信息', + attempt_count int not null default 0 comment '尝试次数', + started_time datetime comment '最近开始时间', + finished_time datetime comment '最近结束时间', + create_time datetime comment '执行时间', + update_time datetime comment '更新时间', + primary key (plugin_id, migration_path) +) engine=innodb comment = '插件 migration 执行历史表'; + +-- ---------------------------- +-- 31、插件配置表 +-- ---------------------------- +drop table if exists sys_plugin_config; +create table sys_plugin_config ( + plugin_id varchar(64) not null comment '插件ID', + config_key varchar(128) not null comment '配置键名', + config_label varchar(128) default null comment '配置展示名称', + config_type varchar(32) not null default 'string' comment '配置值类型', + config_value text comment '配置值', + default_value text comment '默认配置值', + required char(1) not null default '1' comment '是否必填(0是 1否)', + secret char(1) not null default '1' comment '是否敏感(0是 1否)', + options text comment '配置选项JSON', + description varchar(500) default null comment '配置说明', + create_time datetime comment '创建时间', + update_time datetime comment '更新时间', + primary key (plugin_id, config_key) +) engine=innodb comment = '插件配置表'; + +-- ---------------------------- +-- 32、插件批量操作审计日志表 +-- ---------------------------- +drop table if exists sys_plugin_operation_log; +create table sys_plugin_operation_log ( + operation_id bigint(20) not null auto_increment comment '操作日志ID', + operation varchar(32) not null comment '操作类型', + plugin_ids text comment '目标插件ID JSON', + dry_run char(1) not null default '1' comment '是否预演(0是 1否)', + continue_on_error char(1) not null default '1' comment '失败后是否继续(0是 1否)', + status varchar(32) not null comment '执行状态', + summary text comment '执行汇总JSON', + result text comment '完整执行结果JSON', + create_time datetime comment '创建时间', + remark varchar(500) default null comment '备注', + primary key (operation_id) +) engine=innodb comment = '插件批量操作审计日志表'; diff --git a/ruoyi-fastapi-backend/tests/__init__.py b/ruoyi-fastapi-backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py b/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py index 4c9040d..e83797b 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py @@ -1,7 +1,11 @@ import subprocess from collections.abc import Callable +from pathlib import Path from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS +from cli.runtime.plugin.scaffold import PluginFrontendVersionResolver + +FRONTEND_ROOT = Path(__file__).resolve().parents[4] / 'ruoyi-fastapi-frontend' def test_app_config_json_output_is_pure_json( @@ -309,3 +313,247 @@ def test_ops_server_info_json_output_has_stable_contract( first_disk = server['sysFiles'][0] assert set(first_disk) == {'dirName', 'sysTypeName', 'typeName', 'total', 'used', 'free', 'usage'} assert all(isinstance(value, str) for value in first_disk.values()) + + +def test_plugin_list_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('plugin', 'list', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert set(payload) == {'ok', 'count', 'plugins', 'databaseAvailable', 'databaseError'} + assert payload['ok'] is True + assert isinstance(payload['count'], int) + assert isinstance(payload['databaseAvailable'], bool) + assert isinstance(payload['plugins'], list) + if payload['plugins']: + assert isinstance(payload['plugins'][0]['runtimeEnabled'], bool) + assert 'enabled' not in payload['plugins'][0] + + +def test_plugin_check_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('plugin', 'check', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert set(payload) == {'ok', 'message', 'count', 'databaseAvailable', 'databaseError', 'checks'} + assert isinstance(payload['ok'], bool) + assert isinstance(payload['message'], str) + assert isinstance(payload['count'], int) + assert isinstance(payload['databaseAvailable'], bool) + assert isinstance(payload['checks'], list) + if payload['checks']: + check_payload = payload['checks'][0] + assert { + 'pluginId', + 'ok', + 'dependencies', + 'structure', + 'missingDependencies', + 'unsatisfiedDependencies', + 'structureErrors', + 'menuConflicts', + }.issubset(check_payload) + + +def test_plugin_check_deps_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('plugin', 'check-deps', 'ai', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert { + 'ok', + 'message', + 'pluginId', + 'dependencyOk', + 'dependencies', + 'missingDependencies', + 'unsatisfiedDependencies', + }.issubset(payload) + assert payload['pluginId'] == 'ai' + assert isinstance(payload['dependencies'], list) + + +def test_plugin_install_deps_dry_run_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('plugin', 'install-deps', 'ai', '--dry-run', '--yes', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['pluginId'] == 'ai' + assert payload['dryRun'] is True + assert isinstance(payload['plan'], list) + assert isinstance(payload['planCount'], int) + + +def test_plugin_plan_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('plugin', 'plan', 'install', 'ai', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert payload['operation'] == 'install' + assert 'plan' in payload + assert isinstance(payload['plan']['orderedPluginIds'], list) + assert isinstance(payload['plan']['items'], list) + assert isinstance(payload['plan']['blockers'], list) + assert isinstance(payload['plan']['blockerCount'], int) + + +def test_plugin_plan_rejects_invalid_operation_at_cli_boundary( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_cli_command('plugin', 'plan', 'remove', 'ai', '--env=dev', '--output=json') + + assert completed.returncode != SUCCESS + assert 'remove' in completed.stderr + + +def test_plugin_batch_dry_run_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('plugin', 'batch', 'install', 'ai', '--dry-run', '--yes', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode in {SUCCESS, DEPENDENCY_ERROR} + assert payload['operation'] == 'install' + assert payload['dryRun'] is True + assert payload['continueOnError'] is False + assert 'plan' in payload + assert isinstance(payload['executed'], list) + assert isinstance(payload['summary'], dict) + assert set(payload['summary']) == {'total', 'succeeded', 'failed', 'skipped'} + assert payload['failed'] is None + + +def test_plugin_list_text_output_has_stable_structure( + run_text_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + completed = run_text_cli_command('plugin', 'list', '--env=dev', '--output=text') + + assert completed.returncode == SUCCESS + assert completed.stdout.startswith('OK SUCCESS\n') + assert 'count:' in completed.stdout + assert 'plugins:' in completed.stdout + + +def test_plugin_create_dry_run_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('plugin', 'create', 'contract_demo', '--dry-run', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['pluginId'] == 'contract_demo' + assert payload['dryRun'] is True + assert payload['backend'] is True + assert payload['frontend'] is True + assert payload['frontendVersion'] == PluginFrontendVersionResolver.resolve(FRONTEND_ROOT) + assert payload['migration'] is True + assert payload['seed'] is True + assert payload['job'] is True + assert payload['config'] is True + assert isinstance(payload['targetDirs'], list) + assert isinstance(payload['files'], list) + assert payload['conflicts'] == [] + + +def test_plugin_create_optional_parts_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'plugin', + 'create', + 'contract_minimal', + '--dry-run', + '--backend-only', + '--no-migration', + '--no-seed', + '--no-job', + '--no-config', + '--env=dev', + '--output=json', + ) + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['pluginId'] == 'contract_minimal' + assert payload['frontend'] is False + assert payload['migration'] is False + assert payload['seed'] is False + assert payload['job'] is False + assert payload['config'] is False + + +def test_plugin_create_frontend_version_override_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'plugin', + 'create', + 'contract_vue2', + '--dry-run', + '--frontend-only', + '--frontend-version=vue2', + '--env=dev', + '--output=json', + ) + payload = parse_json_stdout(completed) + + view_payload = next(file for file in payload['files'] if str(file['path']).endswith('/views/index.vue')) + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['frontendVersion'] == 'vue2' + assert 'slot="header"' in view_payload['content'] + + +def test_plugin_install_dry_run_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command( + 'plugin', 'install', 'missing_plugin', '--dry-run', '--yes', '--env=dev', '--output=json' + ) + payload = parse_json_stdout(completed) + + assert completed.returncode != SUCCESS + assert payload['ok'] is False + assert payload['pluginId'] == 'missing_plugin' + assert isinstance(payload['message'], str) + + +def test_plugin_upgrade_dry_run_json_output_has_stable_contract( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], +) -> None: + completed = run_cli_command('plugin', 'upgrade', 'ai', '--dry-run', '--yes', '--env=dev', '--output=json') + payload = parse_json_stdout(completed) + + assert completed.returncode == SUCCESS + assert payload['ok'] is True + assert payload['pluginId'] == 'ai' + assert payload['dryRun'] is True + assert 'installedVersion' in payload + assert 'currentVersion' in payload + assert 'needsUpgrade' in payload + assert 'databaseAvailable' in payload + assert isinstance(payload['actions'], list) diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_guards.py b/ruoyi-fastapi-backend/tests/cli/root/test_guards.py index 7759955..5b97329 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_guards.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_guards.py @@ -38,6 +38,19 @@ def test_dangerous_command_rules_cover_expected_commands() -> None: 'gen create-table', 'gen export', 'gen sync-db', + 'plugin install', + 'plugin install-deps', + 'plugin upgrade', + 'plugin batch', + 'plugin enable', + 'plugin disable', + 'plugin config set', + 'plugin config import', + 'plugin config export', + 'plugin uninstall', + 'plugin purge', + 'plugin mark-success', + 'plugin mark-failed', } diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_command_controller.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_command_controller.py new file mode 100644 index 0000000..3265cc7 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_command_controller.py @@ -0,0 +1,626 @@ +import asyncio +import inspect +import sys +from typing import Any + +from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, RUNTIME_ERROR +from cli.groups.plugin.controller import PluginCommandController +from cli.groups.plugin.options import ( + PluginDependencyAllowlistExampleCommandOptions, + PluginDependencyInstallCommandOptions, + PluginDependencyLockCommandOptions, +) + + +class FakeContextFactory: + """ + 测试用 CLI 上下文工厂。 + """ + + def build_dangerous( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + *, + command_name: str, + ) -> Any: + """构造危险命令上下文。""" + return type('FakeContext', (), {'env': env, 'output': output})() + + def build_regular( + self, + env: str, + output: str, + allow_prod: bool, + yes: bool, + dry_run: bool, + ) -> Any: + """构造普通命令上下文。""" + return type( + 'FakeContext', + (), + {'env': env, 'output': output, 'allow_prod': allow_prod, 'yes': yes, 'dry_run': dry_run}, + )() + + def build_readonly(self, env: str, output: str) -> Any: + """构造只读命令上下文。""" + return type('FakeContext', (), {'env': env, 'output': output})() + + +class FakeExecutionService: + """ + 测试用 CLI 执行服务。 + """ + + def __init__(self) -> None: + """初始化测试执行服务。""" + self.completed_payload: dict[str, Any] | None = None + self.default_exit_code: int | None = None + + def run_async(self, value: Any) -> Any: + """直接返回测试中的 awaitable 结果。""" + if inspect.isawaitable(value): + return asyncio.run(value) + return value + + def complete_payload_with_text( + self, + ctx: Any, + payload: dict[str, Any], + *, + text_builder: Any, + default_exit_code: int, + ) -> None: + """记录完成参数。""" + self.completed_payload = payload + self.default_exit_code = default_exit_code + + +class FakePresenter: + """ + 测试用 presenter。 + """ + + @staticmethod + def build_list_text(payload: dict[str, Any]) -> str: + """构造列表文本。""" + return str(payload) + + @staticmethod + def build_info_text(payload: dict[str, Any]) -> str: + """构造详情文本。""" + return str(payload) + + @staticmethod + def build_enabled_text(payload: dict[str, Any]) -> str: + """构造启停文本。""" + return str(payload) + + @staticmethod + def build_install_text(payload: dict[str, Any]) -> str: + """构造安装文本。""" + return str(payload) + + @staticmethod + def build_check_text(payload: dict[str, Any]) -> str: + """构造检查文本。""" + return str(payload) + + @staticmethod + def build_config_text(payload: dict[str, Any]) -> str: + """构造配置文本。""" + return str(payload) + + @staticmethod + def build_dependency_install_text(payload: dict[str, Any]) -> str: + """构造依赖安装文本。""" + return str(payload) + + @staticmethod + def build_dependency_lock_text(payload: dict[str, Any]) -> str: + """构造依赖锁文件文本。""" + return str(payload) + + @staticmethod + def build_dependency_allowlist_example_text(payload: dict[str, Any]) -> str: + """构造允许列表示例文本。""" + return str(payload) + + +class FakeCoreRuntime: + """ + 测试用核心插件运行时。 + """ + + def __init__(self) -> None: + """初始化测试用核心插件运行时。""" + self.dependency_install_calls: list[dict[str, Any]] = [] + self.config_set_calls: list[dict[str, Any]] = [] + self.list_with_state_called = False + + async def list_plugins_with_state(self) -> dict[str, Any]: + """返回合并状态后的插件列表。""" + self.list_with_state_called = True + return { + 'ok': True, + 'count': 1, + 'databaseAvailable': True, + 'databaseError': None, + 'plugins': [ + { + 'pluginId': 'demo', + 'enabled': True, + 'status': 'installed', + } + ], + } + + async def install_plugin(self, plugin_id: str, *, dry_run: bool = False) -> dict[str, Any]: + """返回失败安装结果。""" + return {'ok': False, 'message': '插件安装失败', 'pluginId': plugin_id} + + @staticmethod + def check_plugin(plugin_id: str | None = None) -> dict[str, Any]: + """返回缺失 ok 字段的检查结果。""" + return {'message': '插件检查结果缺失 ok', 'pluginId': plugin_id} + + async def set_plugin_config(self, plugin_id: str, values: dict[str, Any]) -> dict[str, Any]: + """返回失败配置更新结果。""" + self.config_set_calls.append({'plugin_id': plugin_id, 'values': values}) + return {'ok': False, 'message': '插件配置更新失败', 'pluginId': plugin_id, 'values': values} + + def install_plugin_dependencies( + self, + plugin_id: str, + *, + dry_run: bool = False, + policy_config: object | None = None, + confirmed: bool = False, + output_callback: object | None = None, + ) -> dict[str, Any]: + """返回依赖安装测试结果。""" + self.dependency_install_calls.append( + { + 'plugin_id': plugin_id, + 'dry_run': dry_run, + 'policy_config': policy_config, + 'confirmed': confirmed, + 'output_callback': output_callback, + } + ) + return { + 'ok': True, + 'message': '插件依赖安装演练完成', + 'pluginId': plugin_id, + 'dryRun': dry_run, + 'policyConfig': policy_config, + 'confirmed': confirmed, + } + + +class FakePluginRuntime: + """ + 测试用插件 CLI 运行时。 + """ + + def __init__(self) -> None: + """初始化测试用插件 CLI 运行时。""" + self.core_runtime = FakeCoreRuntime() + self.dependency_lock_calls: list[dict[str, Any]] = [] + self.dependency_allowlist_example_calls: list[dict[str, Any]] = [] + + def lock_plugin_dependencies( + self, + plugin_id: str, + *, + output_path: str = '', + offline_dir: str = '', + dry_run: bool = False, + overwrite: bool = False, + ) -> dict[str, Any]: + """返回依赖锁文件测试结果。""" + self.dependency_lock_calls.append( + { + 'plugin_id': plugin_id, + 'output_path': output_path, + 'offline_dir': offline_dir, + 'dry_run': dry_run, + 'overwrite': overwrite, + } + ) + return { + 'ok': True, + 'message': '插件依赖锁文件模板生成完成', + 'pluginId': plugin_id, + 'outputFile': output_path, + 'offlineDir': offline_dir, + 'dryRun': dry_run, + 'overwrite': overwrite, + } + + def generate_plugin_dependency_allowlist_example( + self, + *, + output_path: str = '', + dry_run: bool = False, + overwrite: bool = False, + ) -> dict[str, Any]: + """返回允许列表示例测试结果。""" + self.dependency_allowlist_example_calls.append( + { + 'output_path': output_path, + 'dry_run': dry_run, + 'overwrite': overwrite, + } + ) + return { + 'ok': True, + 'message': '插件依赖允许列表示例生成完成', + 'outputFile': output_path, + 'dryRun': dry_run, + 'overwrite': overwrite, + } + + +def test_list_plugins_uses_database_state_aware_query() -> None: + """校验插件列表 CLI 使用合并数据库状态的查询入口。""" + execution_service = FakeExecutionService() + plugin_runtime = FakePluginRuntime() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=plugin_runtime, + ) + + controller.list_plugins('dev', 'json') + + assert plugin_runtime.core_runtime.list_with_state_called is True + assert execution_service.completed_payload is not None + assert execution_service.completed_payload['plugins'][0]['runtimeEnabled'] is True + assert 'enabled' not in execution_service.completed_payload['plugins'][0] + assert execution_service.completed_payload['plugins'][0]['status'] == 'installed' + + +def test_install_plugin_uses_failure_exit_code_when_payload_is_not_ok() -> None: + """校验插件安装失败时 CLI 使用失败退出码。""" + execution_service = FakeExecutionService() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=FakePluginRuntime(), + ) + + controller.install_plugin('demo', 'dev', 'text', allow_prod=False, yes=True, dry_run=False) + + assert execution_service.completed_payload is not None + assert execution_service.completed_payload['ok'] is False + assert execution_service.default_exit_code == DEPENDENCY_ERROR + + +def test_check_plugin_uses_failure_exit_code_when_payload_has_no_ok() -> None: + """校验插件检查结果缺失 ok 时 CLI 使用失败退出码。""" + execution_service = FakeExecutionService() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=FakePluginRuntime(), + ) + + controller.check_plugin('demo', 'dev', 'text') + + assert execution_service.completed_payload is not None + assert 'ok' not in execution_service.completed_payload + assert execution_service.default_exit_code == DEPENDENCY_ERROR + + +def test_plugin_config_set_uses_failure_exit_code_when_payload_is_not_ok() -> None: + """校验插件配置更新失败时 CLI 使用失败退出码。""" + execution_service = FakeExecutionService() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=FakePluginRuntime(), + ) + + controller.plugin_config( + 'demo', + 'set', + ['provider="openai"'], + 'dev', + 'text', + allow_prod=False, + yes=True, + ) + + assert execution_service.completed_payload is not None + assert execution_service.completed_payload['ok'] is False + assert execution_service.completed_payload['values'] == {'provider': 'openai'} + assert execution_service.default_exit_code == DEPENDENCY_ERROR + + +def test_plugin_config_set_argument_error_uses_structured_payload() -> None: + """校验插件配置参数格式错误时 CLI 走统一 payload 输出。""" + execution_service = FakeExecutionService() + plugin_runtime = FakePluginRuntime() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=plugin_runtime, + ) + + controller.plugin_config( + 'demo', + 'set', + ['badpair'], + 'dev', + 'text', + allow_prod=False, + yes=True, + ) + + assert plugin_runtime.core_runtime.config_set_calls == [] + assert execution_service.completed_payload is not None + assert execution_service.completed_payload['ok'] is False + assert execution_service.completed_payload['message'] == '配置参数必须使用 key=value 格式:badpair' + assert execution_service.default_exit_code == ARGUMENT_ERROR + + +def test_plugin_payload_with_error_uses_runtime_error_exit_code() -> None: + """校验带 error 的插件运行时异常负载由 CLI 映射为运行时错误退出码。""" + payload = {'ok': False, 'message': '插件配置导入失败', 'error': 'database unavailable'} + + exit_code = PluginCommandController._resolve_plugin_exit_code( + payload, + success_exit_code=0, + failure_exit_code=DEPENDENCY_ERROR, + ) + + assert exit_code == RUNTIME_ERROR + + +def test_install_plugin_dependencies_passes_policy_config_to_runtime() -> None: + """校验 CLI install-deps 将策略参数收口后传给运行时。""" + execution_service = FakeExecutionService() + plugin_runtime = FakePluginRuntime() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=plugin_runtime, + ) + + controller.install_plugin_dependencies( + 'demo', + 'stage', + 'text', + options=PluginDependencyInstallCommandOptions( + allow_prod=True, + yes=True, + dry_run=False, + policy_mode='locked', + allow_unlisted=True, + lockfile='plugins/demo/plugin.lock.yaml', + offline_dir='artifacts/plugin-dependencies', + require_lockfile=True, + ), + ) + + assert execution_service.completed_payload is not None + policy_config = execution_service.completed_payload['policyConfig'] + assert execution_service.completed_payload['confirmed'] is True + assert policy_config.mode == 'locked' + assert policy_config.env == 'stage' + assert policy_config.allow_prod is True + assert policy_config.allow_unlisted is True + assert str(policy_config.lockfile_path) == 'plugins/demo/plugin.lock.yaml' + assert str(policy_config.offline_dir) == 'artifacts/plugin-dependencies' + assert policy_config.require_lockfile is True + assert len(plugin_runtime.core_runtime.dependency_install_calls) == 1 + assert callable(plugin_runtime.core_runtime.dependency_install_calls[0]['output_callback']) + + +def test_install_plugin_dependencies_json_output_disables_live_progress() -> None: + """校验 JSON 输出不会注入依赖安装进度文本。""" + execution_service = FakeExecutionService() + plugin_runtime = FakePluginRuntime() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=plugin_runtime, + ) + + controller.install_plugin_dependencies( + 'demo', + 'dev', + 'json', + options=PluginDependencyInstallCommandOptions(yes=True), + ) + + assert plugin_runtime.core_runtime.dependency_install_calls[0]['output_callback'] is None + + +def test_dependency_install_output_callback_routes_stderr_separately(monkeypatch: Any) -> None: + """校验依赖安装实时输出保留 stdout 和 stderr 通道。""" + emitted: list[tuple[str, bool]] = [] + monkeypatch.setattr( + 'typer.echo', + lambda text, *, nl, err: emitted.append((text, err)), + ) + ctx = type('FakeContext', (), {'output': 'text'})() + + output_callback = PluginCommandController._build_dependency_install_output_callback(ctx) + + assert output_callback is not None + output_callback('status', '[1/1] 开始安装\n') + output_callback('stdout', 'downloading\n') + output_callback('stderr', 'warning\n') + assert emitted == [ + ('[1/1] 开始安装\n', False), + ('downloading\n', False), + ('warning\n', True), + ] + + +def test_install_plugin_dependencies_tty_confirm_previews_then_executes( + monkeypatch: Any, +) -> None: + """校验 TTY 交互安装会先生成预览,确认后再执行真实安装。""" + execution_service = FakeExecutionService() + plugin_runtime = FakePluginRuntime() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=plugin_runtime, + ) + monkeypatch.setattr(sys.stdin, 'isatty', lambda: True) + monkeypatch.setattr('typer.confirm', lambda *args, **kwargs: True) + emitted_lines: list[str] = [] + monkeypatch.setattr('typer.echo', emitted_lines.append) + + controller.install_plugin_dependencies( + 'demo', + 'dev', + 'text', + options=PluginDependencyInstallCommandOptions(dry_run=False, yes=False), + ) + + assert [call['dry_run'] for call in plugin_runtime.core_runtime.dependency_install_calls] == [True, False] + assert [call['confirmed'] for call in plugin_runtime.core_runtime.dependency_install_calls] == [True, True] + assert emitted_lines + assert execution_service.completed_payload is not None + assert execution_service.completed_payload['dryRun'] is False + assert execution_service.completed_payload['confirmed'] is True + + +def test_install_plugin_dependencies_tty_decline_does_not_execute_real_install( + monkeypatch: Any, +) -> None: + """校验 TTY 交互拒绝后不会执行真实依赖安装。""" + execution_service = FakeExecutionService() + plugin_runtime = FakePluginRuntime() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=plugin_runtime, + ) + monkeypatch.setattr(sys.stdin, 'isatty', lambda: True) + monkeypatch.setattr('typer.confirm', lambda *args, **kwargs: False) + monkeypatch.setattr('typer.echo', lambda _: None) + + controller.install_plugin_dependencies( + 'demo', + 'dev', + 'text', + options=PluginDependencyInstallCommandOptions(dry_run=False, yes=False), + ) + + assert [call['dry_run'] for call in plugin_runtime.core_runtime.dependency_install_calls] == [True] + assert execution_service.completed_payload is not None + assert execution_service.completed_payload['ok'] is False + assert execution_service.completed_payload['message'] == '已取消插件依赖安装' + + +def test_install_plugin_dependencies_non_tty_without_yes_does_not_preview( + monkeypatch: Any, +) -> None: + """校验非 TTY 未传 --yes 时不进入交互预览,交给策略返回确认阻断。""" + execution_service = FakeExecutionService() + plugin_runtime = FakePluginRuntime() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=plugin_runtime, + ) + monkeypatch.setattr(sys.stdin, 'isatty', lambda: False) + + controller.install_plugin_dependencies( + 'demo', + 'dev', + 'text', + options=PluginDependencyInstallCommandOptions(dry_run=False, yes=False), + ) + + assert [call['dry_run'] for call in plugin_runtime.core_runtime.dependency_install_calls] == [False] + assert plugin_runtime.core_runtime.dependency_install_calls[0]['confirmed'] is False + + +def test_lock_plugin_dependencies_passes_options_to_runtime() -> None: + """校验 CLI lock-deps 将锁文件参数收口后传给运行时。""" + execution_service = FakeExecutionService() + plugin_runtime = FakePluginRuntime() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=plugin_runtime, + ) + + controller.lock_plugin_dependencies( + 'demo', + 'dev', + 'text', + options=PluginDependencyLockCommandOptions( + output_path='plugins/demo/plugin.lock.yaml', + offline_dir='artifacts/plugin-dependencies', + dry_run=True, + overwrite=True, + ), + ) + + assert execution_service.completed_payload is not None + assert execution_service.completed_payload['dryRun'] is True + assert execution_service.completed_payload['overwrite'] is True + assert plugin_runtime.dependency_lock_calls == [ + { + 'plugin_id': 'demo', + 'output_path': 'plugins/demo/plugin.lock.yaml', + 'offline_dir': 'artifacts/plugin-dependencies', + 'dry_run': True, + 'overwrite': True, + } + ] + + +def test_generate_plugin_dependency_allowlist_example_passes_options_to_runtime() -> None: + """校验 CLI allowlist-example 将参数收口后传给运行时。""" + execution_service = FakeExecutionService() + plugin_runtime = FakePluginRuntime() + controller = PluginCommandController( + context_factory=FakeContextFactory(), + execution_service=execution_service, + presenter=FakePresenter(), + plugin_runtime=plugin_runtime, + ) + + controller.generate_plugin_dependency_allowlist_example( + 'dev', + 'text', + options=PluginDependencyAllowlistExampleCommandOptions( + output_path='config/plugin_dependency_allowlist.yaml', + dry_run=True, + overwrite=True, + ), + ) + + assert execution_service.completed_payload is not None + assert execution_service.completed_payload['dryRun'] is True + assert execution_service.completed_payload['overwrite'] is True + assert plugin_runtime.dependency_allowlist_example_calls == [ + { + 'output_path': 'config/plugin_dependency_allowlist.yaml', + 'dry_run': True, + 'overwrite': True, + } + ] diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_file_adapter.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_file_adapter.py new file mode 100644 index 0000000..390e0aa --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_file_adapter.py @@ -0,0 +1,52 @@ +import json +from pathlib import Path + +from cli.groups.plugin.exporter import PluginCommandFileAdapter + + +def test_plugin_command_file_adapter_writes_markdown_file(tmp_path: Path) -> None: + """校验插件命令文件适配器写入 Markdown 文件。""" + output_file = tmp_path / 'docs' / 'plugin.md' + + payload = PluginCommandFileAdapter.write_markdown_file( + {'ok': True, 'markdown': '# Demo'}, + str(output_file), + content_key='markdown', + failure_message='插件文档导出失败', + ) + + assert payload['ok'] is True + assert payload['exported'] is True + assert payload['outputFile'] == str(output_file) + assert output_file.read_text(encoding='utf-8') == '# Demo' + + +def test_plugin_command_file_adapter_writes_json_file(tmp_path: Path) -> None: + """校验插件命令文件适配器写入 JSON 文件。""" + output_file = tmp_path / 'diagnose.json' + + payload = PluginCommandFileAdapter.write_json_file( + {'ok': True, 'pluginId': 'demo'}, + str(output_file), + failure_message='插件诊断包导出失败', + ) + + assert payload['exported'] is True + assert json.loads(output_file.read_text(encoding='utf-8')) == {'ok': True, 'pluginId': 'demo'} + + +def test_plugin_command_file_adapter_reads_config_import_values(tmp_path: Path) -> None: + """校验插件命令文件适配器读取配置导入文件。""" + input_file = tmp_path / 'config.json' + input_file.write_text('{"values": {"enabled": true}}\n', encoding='utf-8') + + payload = PluginCommandFileAdapter.read_config_import_file(str(input_file)) + + assert payload == {'ok': True, 'message': '配置导入文件读取完成', 'values': {'enabled': True}} + + +def test_plugin_command_file_adapter_reports_missing_config_import_file() -> None: + """校验插件命令文件适配器报告缺少配置导入文件。""" + payload = PluginCommandFileAdapter.read_config_import_file('') + + assert payload == {'ok': False, 'message': '导入配置必须指定 --input-file', 'values': {}} diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_lazy_import.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_lazy_import.py new file mode 100644 index 0000000..8cc1379 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_lazy_import.py @@ -0,0 +1,142 @@ +import importlib +import json +import subprocess +import sys +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[3] + + +def inspect_cold_import(module_name: str, observed_modules: tuple[str, ...]) -> dict[str, bool]: + """在隔离解释器中检查目标模块的冷导入边界。""" + script = ( + 'import importlib, json, sys; ' + f'importlib.import_module({module_name!r}); ' + f'print(json.dumps({{name: name in sys.modules for name in {observed_modules!r}}}))' + ) + completed = subprocess.run( + [sys.executable, '-c', script], + cwd=BACKEND_ROOT, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def test_cli_plugin_cold_import_boundaries() -> None: + """校验插件 CLI 冷导入不会越过约定的模块边界。""" + cases = [ + ( + 'cli.main', + ( + 'cli.groups.plugin.command', + 'cli.groups.plugin.controller', + 'cli.runtime.plugin.service', + 'plugins.core.runtime.service', + ), + (False, False, False, False), + ), + ( + 'cli.runtime.plugin', + ( + 'cli.runtime.plugin.service', + 'cli.runtime.plugin.gateway', + 'plugins.core.environment', + 'plugins.core.runtime.service', + ), + (True, True, False, False), + ), + ( + 'cli.groups.plugin.command', + ( + 'cli.groups.plugin.controller', + 'cli.runtime.plugin.service', + 'plugins.core.runtime.service', + ), + (False, False, False), + ), + ] + + for module_name, observed_modules, expected_values in cases: + imported = inspect_cold_import(module_name, observed_modules) + assert tuple(imported.values()) == expected_values + + +def test_plugin_commands_have_stable_workflow_ownership() -> None: + """校验插件命令的工作流归属保持稳定。""" + command_module = importlib.import_module('cli.groups.plugin.command') + expected_modules = { + **dict.fromkeys(('list', 'info', 'check', 'health', 'diagnose', 'docs'), 'discovery'), + 'config': 'configuration', + **dict.fromkeys( + ('check-deps', 'precheck', 'plan', 'install-deps', 'lock-deps', 'allowlist-example'), + 'dependency', + ), + **dict.fromkeys( + ( + 'batch', + 'install', + 'upgrade', + 'enable', + 'disable', + 'uninstall', + 'purge', + 'migration-list', + 'mark-success', + 'mark-failed', + ), + 'lifecycle', + ), + **dict.fromkeys(('test', 'create'), 'developer'), + } + + actual_modules = { + command.name: command.callback.__module__.removeprefix('cli.groups.plugin.commands.') + for command in command_module.app.registered_commands + } + + assert actual_modules == expected_modules + app_builder = importlib.import_module('cli.core.app_builder') + assert app_builder.DEFAULT_COMMAND_GROUP_REGISTRY.command_modules['plugin'] == 'cli.groups.plugin' + + +def test_plugin_controller_and_runtime_are_created_lazily() -> None: + """校验插件控制器和运行时仅在实际使用时创建。""" + script = """ +import importlib +import json +import sys + +command_module = importlib.import_module('cli.groups.plugin.command') +initial = { + 'cache': command_module._get_plugin_command_controller.cache_info().currsize, + 'controller': 'cli.groups.plugin.controller' in sys.modules, +} +controller = command_module._get_plugin_command_controller() +after_controller = { + 'cache': command_module._get_plugin_command_controller.cache_info().currsize, + 'controller': 'cli.groups.plugin.controller' in sys.modules, + 'runtime': 'cli.runtime.plugin.service' in sys.modules, +} +_ = controller.plugin_runtime +after_runtime = { + 'runtime': 'cli.runtime.plugin.service' in sys.modules, + 'core': 'plugins.core.runtime.service' in sys.modules, +} +print(json.dumps({'initial': initial, 'afterController': after_controller, 'afterRuntime': after_runtime})) +""" + completed = subprocess.run( + [sys.executable, '-c', script], + cwd=BACKEND_ROOT, + check=True, + capture_output=True, + text=True, + ) + observed = json.loads(completed.stdout) + + assert observed == { + 'initial': {'cache': 0, 'controller': False}, + 'afterController': {'cache': 1, 'controller': True, 'runtime': False}, + 'afterRuntime': {'runtime': True, 'core': False}, + } diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_payload.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_payload.py new file mode 100644 index 0000000..c41317d --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_payload.py @@ -0,0 +1,209 @@ +from cli.groups.plugin.payload import PluginCommandPayloadAdapter +from cli.groups.plugin.presenter import PluginCommandPresenter + + +def test_plugin_cli_payload_normalizes_database_enabled_enum() -> None: + """校验 CLI 不再泄漏数据库 enabled 的 0/1 枚举。""" + payload = { + 'ok': True, + 'plugin': { + 'pluginId': 'demo', + 'enabled': False, + 'status': 'error', + 'database': { + 'available': True, + 'enabled': '0', + 'status': 'error', + }, + }, + } + + adapted_payload = PluginCommandPayloadAdapter.adapt(payload) + + assert adapted_payload['plugin']['runtimeEnabled'] is False + assert 'enabled' not in adapted_payload['plugin'] + assert adapted_payload['plugin']['database']['configuredEnabled'] is True + assert 'enabled' not in adapted_payload['plugin']['database'] + assert payload['plugin']['database']['enabled'] == '0' + + +def test_plugin_cli_payload_distinguishes_lifecycle_target_and_actions() -> None: + """校验生命周期目标状态与动作执行状态不再共用 enabled。""" + payload = { + 'ok': False, + 'pluginId': 'demo', + 'operation': 'enable', + 'enabled': True, + 'dryRun': False, + 'actions': [ + { + 'name': 'update_plugin_enabled', + 'label': '更新插件启停状态', + 'enabled': True, + } + ], + } + + adapted_payload = PluginCommandPayloadAdapter.adapt(payload) + + assert adapted_payload['targetEnabled'] is True + assert 'enabled' not in adapted_payload + assert adapted_payload['actions'][0]['willRun'] is True + assert 'enabled' not in adapted_payload['actions'][0] + + +def test_plugin_cli_payload_removes_irrelevant_purge_enabled_state() -> None: + """校验物理清理结果不再输出无意义的 enabled 字段。""" + payload = { + 'ok': False, + 'pluginId': 'demo', + 'operation': 'purge', + 'enabled': False, + 'dryRun': False, + } + + adapted_payload = PluginCommandPayloadAdapter.adapt(payload) + + assert 'enabled' not in adapted_payload + assert 'targetEnabled' not in adapted_payload + + +def test_plugin_cli_payload_normalizes_purge_and_batch_plan_items() -> None: + """校验清理动作开关和批量计划插件状态使用各自语义字段。""" + purge_payload = { + 'plan': { + 'items': [ + { + 'name': 'remove_menus', + 'label': '删除菜单', + 'enabled': True, + 'destructive': True, + } + ] + } + } + batch_payload = { + 'plan': { + 'items': [ + { + 'pluginId': 'demo', + 'ready': True, + 'enabled': '1', + } + ] + } + } + + adapted_purge_payload = PluginCommandPayloadAdapter.adapt(purge_payload) + adapted_batch_payload = PluginCommandPayloadAdapter.adapt(batch_payload) + + assert adapted_purge_payload['plan']['items'][0]['willRun'] is True + assert 'enabled' not in adapted_purge_payload['plan']['items'][0] + assert adapted_batch_payload['plan']['items'][0]['configuredEnabled'] is False + assert 'enabled' not in adapted_batch_payload['plan']['items'][0] + + +def test_plugin_cli_payload_names_manifest_job_default_state() -> None: + """校验 manifest 任务启用配置明确标识为默认值。""" + payload = { + 'plugin': { + 'pluginId': 'demo', + 'status': 'installed', + 'enabled': True, + 'backend': { + 'jobs': [ + { + 'id': 'cleanup', + 'enabled': True, + } + ] + }, + } + } + + adapted_payload = PluginCommandPayloadAdapter.adapt(payload) + + assert adapted_payload['plugin']['runtimeEnabled'] is True + assert adapted_payload['plugin']['backend']['jobs'][0]['defaultEnabled'] is True + assert 'enabled' not in adapted_payload['plugin']['backend']['jobs'][0] + + +def test_plugin_cli_payload_normalizes_persisted_plugin_model_state() -> None: + """校验安装结果中的数据库插件模型不会被误标为运行时启用态。""" + payload = { + 'ok': True, + 'pluginId': 'demo', + 'operation': 'install', + 'dryRun': False, + 'plugin': { + 'pluginId': 'demo', + 'status': 'installed', + 'enabled': '0', + }, + } + + adapted_payload = PluginCommandPayloadAdapter.adapt(payload) + + assert adapted_payload['plugin']['configuredEnabled'] is True + assert 'runtimeEnabled' not in adapted_payload['plugin'] + assert 'enabled' not in adapted_payload['plugin'] + + +def test_plugin_cli_presenter_uses_explicit_enabled_labels() -> None: + """校验 CLI 文本使用 runtime/configured/target/will_run 明确区分启停语义。""" + presenter = PluginCommandPresenter() + list_text = presenter.build_list_text( + { + 'ok': True, + 'count': 1, + 'databaseAvailable': True, + 'databaseError': None, + 'plugins': [ + { + 'pluginId': 'demo', + 'name': 'Demo', + 'version': '1.0.0', + 'runtimeEnabled': True, + 'status': 'installed', + } + ], + } + ) + info_text = presenter.build_info_text( + { + 'ok': True, + 'plugin': { + 'pluginId': 'demo', + 'runtimeEnabled': False, + 'database': { + 'available': True, + 'installed': True, + 'configuredEnabled': True, + }, + }, + } + ) + enabled_text = presenter.build_enabled_text( + { + 'message': '启用失败', + 'pluginId': 'demo', + 'env': 'dev', + 'operation': 'enable', + 'targetEnabled': True, + 'dryRun': False, + 'actions': [ + { + 'name': 'update_plugin_enabled', + 'label': '更新插件启停状态', + 'willRun': True, + } + ], + } + ) + + assert 'database_available: true' in list_text + assert 'runtime_enabled: true' in list_text + assert 'runtime_enabled: false' in info_text + assert 'configured_enabled: true' in info_text + assert 'target_enabled: true' in enabled_text + assert 'will_run: true' in enabled_text diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/__init__.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/conftest.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/conftest.py new file mode 100644 index 0000000..4167f11 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/conftest.py @@ -0,0 +1,108 @@ +import sys +from pathlib import Path +from subprocess import CompletedProcess + +from cli.runtime.plugin.service import CliPluginRuntimeService +from plugins.core.environment import PluginRuntimeEnvironmentService +from plugins.core.validation.dependencies import ( + NpmDependencyInspector, + PluginDependencyChecker, + PythonDependencyInspector, +) + +EXPECTED_FRONTEND_BUILD_TIMEOUT = 300 + + +class FakeRuntimeEnvironment: + """ + 测试用插件 CLI 运行时环境服务。 + """ + + def __init__(self, backend_dir: Path, frontend_dir: Path | None = None) -> None: + """初始化测试用插件 CLI 运行时环境服务。""" + self.backend_dir = backend_dir + self.frontend_dir = frontend_dir or Path( + PluginRuntimeEnvironmentService(backend_root=backend_dir).get_frontend_dir() + ) + + def get_backend_dir(self) -> str: + """获取后端项目根目录。""" + return str(self.backend_dir) + + def get_backend_plugins_dir(self) -> str: + """获取后端插件根目录。""" + return str(self.backend_dir / 'plugins') + + def get_frontend_dir(self) -> str: + """获取前端项目根目录。""" + return str(self.frontend_dir) + + def get_frontend_plugins_dir(self) -> str: + """获取前端插件根目录。""" + return str(self.frontend_dir / 'plugins') + + @staticmethod + def get_frontend_mode() -> str: + """获取测试用前端运行模式。""" + return 'dev' + + @staticmethod + def get_backend_runtime_mode() -> str: + """获取测试用后端运行模式。""" + return 'dev' + + @staticmethod + def get_python_executable() -> str: + """获取测试用 Python 解释器。""" + return sys.executable + + +class FakePluginRuntimeGateway: + """ + 测试用插件 CLI 运行时适配器。 + """ + + def __init__(self) -> None: + """初始化测试用插件 CLI 运行时适配器。""" + self.completed_process = CompletedProcess(args=[], returncode=0, stdout='1 passed\n', stderr='') + self.commands: list[tuple[list[str], str, int | None]] = [] + + def run_command( + self, + command: list[str], + workdir: str, + *, + timeout: int | None = None, + ) -> CompletedProcess[str]: + """记录测试用系统命令。""" + self.commands.append((command, workdir, timeout)) + return self.completed_process + + +def build_runtime(backend_root: Path, frontend_root: Path | None = None) -> CliPluginRuntimeService: + """构建测试用插件 CLI 运行时服务。""" + return CliPluginRuntimeService( + runtime_environment=FakeRuntimeEnvironment(backend_root, frontend_root), + dependency_checker=PluginDependencyChecker( + python_inspector=PythonDependencyInspector(installed_packages={'openai': '2.17.0'}), + npm_inspector=NpmDependencyInspector(installed_packages={'vue': '3.5.26'}), + ), + ) + + +def build_runtime_with_gateway( + backend_root: Path, + gateway: FakePluginRuntimeGateway, + frontend_root: Path | None = None, +) -> CliPluginRuntimeService: + """构建带测试运行时适配器的插件 CLI 运行时服务。""" + return CliPluginRuntimeService( + runtime_environment=FakeRuntimeEnvironment(backend_root, frontend_root), + dependency_checker=PluginDependencyChecker( + python_inspector=PythonDependencyInspector(installed_packages={'openai': '2.17.0'}), + npm_inspector=NpmDependencyInspector(installed_packages={'vue': '3.5.26'}), + ), + management_gateway=gateway, + model_gateway=gateway, + command_gateway=gateway, + ) diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_allowlist.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_allowlist.py new file mode 100644 index 0000000..16d5008 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_allowlist.py @@ -0,0 +1,73 @@ +from pathlib import Path + +import yaml + +from .conftest import build_runtime + + +def test_plugin_runtime_allowlist_example_dry_run_returns_yaml_without_writing(tmp_path: Path) -> None: + """校验允许列表示例 dry-run 只返回 YAML,不写入默认文件。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + + payload = runtime.generate_plugin_dependency_allowlist_example(dry_run=True) + + output_path = backend_root / 'config' / 'plugin_dependency_allowlist.yaml' + assert payload['ok'] is True + assert payload['dryRun'] is True + assert payload['written'] is False + assert payload['outputFile'] == str(output_path) + assert not output_path.exists() + allowlist = yaml.safe_load(payload['allowlist']) + assert allowlist['python']['openai']['versions'] == ['>=2.0.0,<3.0.0'] + assert allowlist['npm']['dayjs']['source'] == 'internal-npm' + assert allowlist['npmDev']['vitest']['versions'] == ['>=3.0.0,<4.0.0'] + + +def test_plugin_runtime_allowlist_example_writes_relative_output_path(tmp_path: Path) -> None: + """校验允许列表示例命令可以写入后端根目录相对路径。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + + payload = runtime.generate_plugin_dependency_allowlist_example( + output_path='config/team_allowlist.yaml', + ) + + output_path = backend_root / 'config' / 'team_allowlist.yaml' + assert payload['ok'] is True + assert payload['dryRun'] is False + assert payload['written'] is True + assert payload['outputFile'] == str(output_path) + assert output_path.read_text(encoding='utf-8') == payload['allowlist'] + + +def test_plugin_runtime_allowlist_example_rejects_output_path_escape(tmp_path: Path) -> None: + """校验允许列表示例输出路径不能逃逸后端项目根目录。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + escaped_allowlist = tmp_path / 'escaped_allowlist.yaml' + + payload = runtime.generate_plugin_dependency_allowlist_example(output_path='../escaped_allowlist.yaml') + + assert payload['ok'] is False + assert '输出路径' in str(payload['error']) + assert escaped_allowlist.exists() is False + + +def test_plugin_runtime_allowlist_example_rejects_existing_file_without_overwrite(tmp_path: Path) -> None: + """校验允许列表示例文件已存在时必须显式 overwrite。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + output_path = backend_root / 'config' / 'team_allowlist.yaml' + output_path.parent.mkdir(parents=True) + output_path.write_text('python: {}\n', encoding='utf-8') + + payload = runtime.generate_plugin_dependency_allowlist_example( + output_path='config/team_allowlist.yaml', + ) + + assert payload['ok'] is False + assert payload['written'] is False + assert payload['outputFile'] == str(output_path) + assert '已存在' in payload['message'] + assert output_path.read_text(encoding='utf-8') == 'python: {}\n' diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_lock.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_lock.py new file mode 100644 index 0000000..8c94796 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_lock.py @@ -0,0 +1,176 @@ +import base64 +import hashlib +from pathlib import Path + +import yaml + +from .conftest import build_runtime + +EXPECTED_LOCK_ENTRY_COUNT = 2 + + +def write_demo_dependency_manifest(backend_root: Path) -> None: + """为 demo 插件补充外部依赖声明。""" + manifest_path = backend_root / 'plugins' / 'demo' / 'plugin.yaml' + manifest = yaml.safe_load(manifest_path.read_text(encoding='utf-8')) + manifest['dependencies'] = { + 'python': ['openai>=2.0.0,<3.0.0'], + 'npm': ['dayjs>=1.11.0,<2.0.0'], + 'npmDev': [], + 'plugins': [], + } + manifest_path.write_text(yaml.safe_dump(manifest, allow_unicode=True, sort_keys=False), encoding='utf-8') + + +def test_plugin_runtime_lock_dependencies_dry_run_returns_lockfile_template(tmp_path: Path) -> None: + """校验插件依赖锁文件 dry-run 只返回锁文件模板,不写文件。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + runtime.create_plugin('demo', frontend=True, dry_run=False) + write_demo_dependency_manifest(backend_root) + + payload = runtime.lock_plugin_dependencies('demo', dry_run=True) + + assert payload['ok'] is True + assert payload['dryRun'] is True + assert payload['pluginId'] == 'demo' + assert payload['outputFile'] == str(backend_root / 'plugins' / 'demo' / 'plugin.lock.yaml') + assert payload['written'] is False + assert payload['entryCount'] == EXPECTED_LOCK_ENTRY_COUNT + assert 'resolvedVersion' in payload['warnings'][0] + assert not (backend_root / 'plugins' / 'demo' / 'plugin.lock.yaml').exists() + + lockfile = yaml.safe_load(payload['lockfile']) + assert lockfile['plugin'] == 'demo' + assert lockfile['version'] == '0.1.0' + assert lockfile['python'][0]['name'] == 'openai' + assert lockfile['python'][0]['requirement'] == 'openai>=2.0.0,<3.0.0' + assert lockfile['python'][0]['resolvedVersion'] == '' + assert lockfile['python'][0]['hashes'] == [] + assert lockfile['npm'][0]['name'] == 'dayjs' + assert lockfile['npm'][0]['integrity'] == '' + + +def test_plugin_runtime_lock_dependencies_writes_default_lockfile(tmp_path: Path) -> None: + """校验插件依赖锁文件命令默认写入插件目录下的 plugin.lock.yaml。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + runtime.create_plugin('demo', frontend=True, dry_run=False) + write_demo_dependency_manifest(backend_root) + + payload = runtime.lock_plugin_dependencies('demo') + + lockfile_path = backend_root / 'plugins' / 'demo' / 'plugin.lock.yaml' + assert payload['ok'] is True + assert payload['written'] is True + assert payload['outputFile'] == str(lockfile_path) + assert lockfile_path.is_file() + assert yaml.safe_load(lockfile_path.read_text(encoding='utf-8'))['plugin'] == 'demo' + + +def test_plugin_runtime_lock_dependencies_rejects_existing_file_without_overwrite(tmp_path: Path) -> None: + """校验已有锁文件时必须显式 overwrite。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + runtime.create_plugin('demo', frontend=True, dry_run=False) + write_demo_dependency_manifest(backend_root) + lockfile_path = backend_root / 'plugins' / 'demo' / 'plugin.lock.yaml' + lockfile_path.write_text('plugin: existing\n', encoding='utf-8') + + payload = runtime.lock_plugin_dependencies('demo') + + assert payload['ok'] is False + assert payload['written'] is False + assert payload['outputFile'] == str(lockfile_path) + assert '已存在' in payload['message'] + assert lockfile_path.read_text(encoding='utf-8') == 'plugin: existing\n' + + +def test_plugin_runtime_lock_dependencies_rejects_output_path_escape(tmp_path: Path) -> None: + """校验插件依赖锁文件输出路径不能逃逸后端项目根目录。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + runtime.create_plugin('demo', frontend=True, dry_run=False) + write_demo_dependency_manifest(backend_root) + escaped_lockfile = tmp_path / 'escaped.lock.yaml' + + payload = runtime.lock_plugin_dependencies('demo', output_path='../escaped.lock.yaml') + + assert payload['ok'] is False + assert '输出路径' in str(payload['error']) + assert escaped_lockfile.exists() is False + + +def test_plugin_runtime_lock_dependencies_fills_lockfile_from_offline_artifacts(tmp_path: Path) -> None: + """校验锁文件模板可以从本地离线制品反填版本和完整性校验值。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + runtime.create_plugin('demo', frontend=True, dry_run=False) + write_demo_dependency_manifest(backend_root) + offline_dir = tmp_path / 'artifacts' + python_artifact = offline_dir / 'python' / 'openai-2.17.0-py3-none-any.whl' + npm_artifact = offline_dir / 'npm' / 'dayjs-1.11.19.tgz' + python_artifact.parent.mkdir(parents=True) + npm_artifact.parent.mkdir(parents=True) + python_artifact.write_bytes(b'wheel') + npm_artifact.write_bytes(b'tgz') + + payload = runtime.lock_plugin_dependencies('demo', dry_run=True, offline_dir=str(offline_dir)) + + assert payload['ok'] is True + assert payload['artifactCount'] == EXPECTED_LOCK_ENTRY_COUNT + assert payload['warnings'] == [] + lockfile = yaml.safe_load(payload['lockfile']) + assert lockfile['python'][0]['resolvedVersion'] == '2.17.0' + assert lockfile['python'][0]['hashes'] == [f'sha256:{hashlib.sha256(b"wheel").hexdigest()}'] + assert lockfile['npm'][0]['resolvedVersion'] == '1.11.19' + expected_integrity = base64.b64encode(hashlib.sha512(b'tgz').digest()).decode('ascii') + assert lockfile['npm'][0]['integrity'] == f'sha512-{expected_integrity}' + + +def test_plugin_runtime_lock_dependencies_filters_offline_artifacts_by_requirement(tmp_path: Path) -> None: + """校验多个离线制品中只有一个满足版本声明时可以自动匹配。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + runtime.create_plugin('demo', frontend=True, dry_run=False) + write_demo_dependency_manifest(backend_root) + offline_dir = tmp_path / 'artifacts' + old_python_artifact = offline_dir / 'python' / 'openai-1.9.0-py3-none-any.whl' + valid_python_artifact = offline_dir / 'python' / 'openai-2.17.0-py3-none-any.whl' + old_npm_artifact = offline_dir / 'npm' / 'dayjs-0.9.0.tgz' + valid_npm_artifact = offline_dir / 'npm' / 'dayjs-1.11.19.tgz' + valid_python_artifact.parent.mkdir(parents=True) + valid_npm_artifact.parent.mkdir(parents=True) + old_python_artifact.write_bytes(b'old-wheel') + valid_python_artifact.write_bytes(b'wheel') + old_npm_artifact.write_bytes(b'old-tgz') + valid_npm_artifact.write_bytes(b'tgz') + + payload = runtime.lock_plugin_dependencies('demo', dry_run=True, offline_dir=str(offline_dir)) + + assert payload['ok'] is True + assert payload['warnings'] == [] + lockfile = yaml.safe_load(payload['lockfile']) + assert lockfile['python'][0]['resolvedVersion'] == '2.17.0' + assert lockfile['npm'][0]['resolvedVersion'] == '1.11.19' + + +def test_plugin_runtime_lock_dependencies_warns_when_offline_artifact_missing(tmp_path: Path) -> None: + """校验本地离线制品缺失时保留锁文件占位并返回 warning。""" + backend_root = tmp_path / 'backend' + runtime = build_runtime(backend_root) + runtime.create_plugin('demo', frontend=True, dry_run=False) + write_demo_dependency_manifest(backend_root) + offline_dir = tmp_path / 'artifacts' + + payload = runtime.lock_plugin_dependencies('demo', dry_run=True, offline_dir=str(offline_dir)) + + assert payload['ok'] is True + assert payload['artifactCount'] == 0 + assert any('未找到离线制品:python openai' in warning for warning in payload['warnings']) + assert any('未找到离线制品:npm dayjs' in warning for warning in payload['warnings']) + lockfile = yaml.safe_load(payload['lockfile']) + assert lockfile['python'][0]['resolvedVersion'] == '' + assert lockfile['python'][0]['hashes'] == [] + assert lockfile['npm'][0]['resolvedVersion'] == '' + assert lockfile['npm'][0]['integrity'] == '' diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_gateway.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_gateway.py new file mode 100644 index 0000000..d14a3e0 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_gateway.py @@ -0,0 +1,106 @@ +from pathlib import Path + +from cli.runtime.plugin.service import CliPluginRuntimeService +from plugins.core.validation.dependencies import PluginDependencyChecker +from tests.cli.runtime.plugin.conftest import ( + FakePluginRuntimeGateway, + FakeRuntimeEnvironment, + build_runtime, + build_runtime_with_gateway, +) + +SHARED_CORE_RUNTIME_METHODS = ( + 'list_plugins', + 'get_plugin_info_with_state', + 'check_plugin', + 'check_plugin_dependencies', + 'precheck_plugin_operation', + 'health_plugin', + 'diagnose_plugin', + 'generate_plugin_docs', + 'plan_plugins', + 'batch_plugins', + 'install_plugin_dependencies', + 'install_plugin', + 'upgrade_plugin', + 'set_plugin_enabled', + 'uninstall_plugin', + 'purge_plugin', + 'list_plugin_migrations', + 'mark_plugin_migration_success', + 'mark_plugin_migration_failed', + 'get_plugin_config', + 'export_plugin_config', + 'import_plugin_config', + 'set_plugin_config', +) + + +def test_plugin_runtime_core_runtime_initializes_without_getattr_recursion(tmp_path: Path) -> None: + """校验 CLI 插件运行时延迟初始化 core runtime 时不会触发 __getattr__ 递归。""" + backend_root = tmp_path / 'ruoyi-fastapi-backend' + backend_root.mkdir() + + runtime = build_runtime(backend_root) + core_runtime = runtime.core_runtime + + assert core_runtime is runtime.core_runtime + assert core_runtime is not runtime + + +def test_plugin_runtime_exposes_only_cli_workflow_surface(tmp_path: Path) -> None: + """校验 CLI 运行时只暴露命令工作流所需接口。""" + backend_root = tmp_path / 'ruoyi-fastapi-backend' + backend_root.mkdir() + runtime = build_runtime(backend_root) + runtime.core_runtime.dynamic_core_only = 'hidden' + + assert not hasattr(runtime, '_missing_internal') + assert not hasattr(runtime, 'dynamic_core_only') + assert [method for method in SHARED_CORE_RUNTIME_METHODS if hasattr(runtime, method)] == [] + assert all( + hasattr(runtime, method) + for method in ( + 'create_plugin', + 'test_plugin', + 'lock_plugin_dependencies', + 'generate_plugin_dependency_allowlist_example', + ) + ) + + +def test_plugin_runtime_uses_core_environment_by_default() -> None: + """校验 CLI 插件运行时默认使用 core 插件运行时环境。""" + runtime = CliPluginRuntimeService() + + core_runtime = runtime.core_runtime + + assert core_runtime.dependencies.runtime_environment is runtime.dependencies.runtime_environment + assert hasattr(core_runtime.dependencies.runtime_environment, 'get_frontend_mode') + + +def test_plugin_runtime_dependencies_are_exposed_through_container(tmp_path: Path) -> None: + """校验 CLI 插件运行时通过集中依赖容器暴露运行时依赖。""" + backend_root = tmp_path / 'ruoyi-fastapi-backend' + backend_root.mkdir() + runtime_gateway = FakePluginRuntimeGateway() + runtime = build_runtime_with_gateway(backend_root, runtime_gateway) + + assert runtime.dependencies.runtime_environment.get_backend_dir() == str(backend_root) + assert runtime.dependencies.management_gateway is runtime_gateway + assert runtime.dependencies.model_gateway is runtime_gateway + assert runtime.dependencies.command_gateway is runtime_gateway + + +def test_plugin_runtime_passes_lifecycle_lock_to_core_runtime(tmp_path: Path) -> None: + """校验 CLI 创建核心插件运行时时会显式注入生命周期锁。""" + backend_root = tmp_path / 'ruoyi-fastapi-backend' + backend_root.mkdir() + lifecycle_lock = object() + runtime = CliPluginRuntimeService( + runtime_environment=FakeRuntimeEnvironment(backend_root), + dependency_checker=PluginDependencyChecker(), + lifecycle_lock=lifecycle_lock, + ) + + assert runtime.core_runtime.lifecycle_lock is lifecycle_lock diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold.py new file mode 100644 index 0000000..bc63757 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold.py @@ -0,0 +1,376 @@ +import json +from pathlib import Path + +from cli.runtime.plugin.service import CliPluginRuntimeService + +from .conftest import FakeRuntimeEnvironment, build_runtime + + +def write_frontend_package(frontend_root: Path, dependencies: dict[str, str]) -> None: + """写入测试用前端 package.json。""" + frontend_root.mkdir(parents=True, exist_ok=True) + (frontend_root / 'package.json').write_text( + json.dumps({'dependencies': dependencies}), + encoding='utf-8', + ) + + +class LazyPluginGateway: + """ + 测试用 CLI 插件网关,验证开发者能力可懒解析运行时依赖。 + """ + + def __init__(self, backend_root: Path) -> None: + """初始化测试用 CLI 插件网关。""" + self.backend_root = backend_root + self.runtime_environment_requested = False + + def get_core_runtime_environment(self) -> FakeRuntimeEnvironment: + """获取测试运行时环境。""" + self.runtime_environment_requested = True + return FakeRuntimeEnvironment(self.backend_root) + + @staticmethod + def build_exception_payload(message: str, exc: Exception) -> dict[str, object]: + """构建测试异常负载。""" + return {'ok': False, 'message': message, 'error': str(exc)} + + +def test_plugin_runtime_create_plugin_lazily_resolves_runtime_environment(tmp_path: Path) -> None: + """校验插件模板创建会通过 CLI 网关懒解析运行时环境。""" + backend_root = tmp_path / 'backend' + backend_root.mkdir() + plugin_gateway = LazyPluginGateway(backend_root) + runtime = CliPluginRuntimeService(plugin_gateway=plugin_gateway) + + payload = runtime.create_plugin('demo', dry_run=True) + + assert payload['ok'] is True + assert plugin_gateway.runtime_environment_requested is True + + +def test_plugin_runtime_create_plugin_dry_run_does_not_write_files(tmp_path: Path) -> None: + """校验插件模板 dry-run 只返回写入计划,不写文件。""" + project_root = tmp_path / 'project' + backend_root = project_root / 'ruoyi-fastapi-backend' + backend_root.mkdir(parents=True) + + payload = build_runtime(backend_root).create_plugin('demo', dry_run=True) + + assert payload['ok'] is True + assert payload['dryRun'] is True + assert payload['template'] == 'full-stack' + assert payload['backend'] is True + assert payload['frontend'] is True + assert payload['frontendVersion'] == 'vue3' + assert payload['test'] is True + assert payload['backendTest'] is True + assert payload['frontendTest'] is True + assert str(backend_root / 'tests' / 'plugins' / 'demo') in payload['targetDirs'] + assert str(project_root / 'ruoyi-fastapi-frontend' / 'tests' / 'plugins' / 'demo') in payload['targetDirs'] + assert payload['files'] + assert not (backend_root / 'plugins' / 'demo' / 'plugin.yaml').exists() + assert not (backend_root / 'tests' / 'plugins' / 'demo' / 'test_ping.py').exists() + assert not (project_root / 'ruoyi-fastapi-frontend' / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js').exists() + + +def test_plugin_runtime_create_plugin_rejects_unsafe_plugin_id(tmp_path: Path) -> None: + """校验插件模板创建会拒绝不安全的插件ID。""" + project_root = tmp_path / 'project' + backend_root = project_root / 'ruoyi-fastapi-backend' + backend_root.mkdir(parents=True) + + payload = build_runtime(backend_root).create_plugin('../../evil', dry_run=True) + + assert payload['ok'] is False + assert '插件ID必须' in str(payload['error']) + assert not (project_root / 'evil').exists() + + +def test_plugin_runtime_create_plugin_uses_runtime_frontend_dir(tmp_path: Path) -> None: + """校验插件模板创建使用运行时环境提供的前端目录。""" + project_root = tmp_path / 'project' + backend_root = project_root / 'api-server' + frontend_root = project_root / 'web-client' + backend_root.mkdir(parents=True) + + payload = build_runtime(backend_root, frontend_root=frontend_root).create_plugin('demo') + + assert payload['ok'] is True + assert (backend_root / 'plugins' / 'demo' / 'plugin.yaml').is_file() + assert (frontend_root / 'plugins' / 'demo' / 'views' / 'index.vue').is_file() + assert (frontend_root / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js').is_file() + + +def test_plugin_runtime_create_plugin_auto_detects_vue2_frontend(tmp_path: Path) -> None: + """校验脚手架会从 package.json 自动识别 Vue 2 并生成对应语法。""" + project_root = tmp_path / 'project' + backend_root = project_root / 'api-server' + frontend_root = project_root / 'web-client' + backend_root.mkdir(parents=True) + write_frontend_package(frontend_root, {'vue': '^2.7.16', 'element-ui': '^2.15.14'}) + + payload = build_runtime(backend_root, frontend_root=frontend_root).create_plugin('demo', template='crud-page') + + view_content = (frontend_root / 'plugins' / 'demo' / 'views' / 'index.vue').read_text(encoding='utf-8') + test_content = (frontend_root / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js').read_text(encoding='utf-8') + assert payload['ok'] is True + assert payload['frontendVersion'] == 'vue2' + assert ' + + diff --git a/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDependencyDialog.vue b/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDependencyDialog.vue new file mode 100644 index 0000000..adc9553 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDependencyDialog.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDetailDialog.vue b/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDetailDialog.vue new file mode 100644 index 0000000..83e90ce --- /dev/null +++ b/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDetailDialog.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDiagnosticDialog.vue b/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDiagnosticDialog.vue new file mode 100644 index 0000000..8ab2e08 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginDiagnosticDialog.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginPlanDialog.vue b/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginPlanDialog.vue new file mode 100644 index 0000000..9171f63 --- /dev/null +++ b/ruoyi-fastapi-frontend/src/views/system/plugin/components/PluginPlanDialog.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/src/views/system/plugin/index.vue b/ruoyi-fastapi-frontend/src/views/system/plugin/index.vue new file mode 100644 index 0000000..24d752d --- /dev/null +++ b/ruoyi-fastapi-frontend/src/views/system/plugin/index.vue @@ -0,0 +1,1798 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/tests/plugins/pluginViewResolver.test.js b/ruoyi-fastapi-frontend/tests/plugins/pluginViewResolver.test.js new file mode 100644 index 0000000..f6d0c46 --- /dev/null +++ b/ruoyi-fastapi-frontend/tests/plugins/pluginViewResolver.test.js @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict' + +import { resolvePluginViewPath } from '../../src/utils/pluginViewResolver.js' + +assert.equal(resolvePluginViewPath('plugin/demo/index'), '../../../plugins/demo/views/index.vue') +assert.equal(resolvePluginViewPath('/plugin/demo/detail/list'), '../../../plugins/demo/views/detail/list.vue') +assert.equal(resolvePluginViewPath('system/user/index'), '') +assert.equal(resolvePluginViewPath('plugin/demo'), '') +assert.equal(resolvePluginViewPath('plugin/demo/../admin'), '') +assert.equal(resolvePluginViewPath('plugin/demo//index'), '') +assert.equal(resolvePluginViewPath('plugin/Demo/index'), '') +assert.equal(resolvePluginViewPath('plugin/demo/index.vue'), '') +assert.equal(resolvePluginViewPath('plugin/demo/detail\\list'), '') +assert.equal(resolvePluginViewPath(null), '') diff --git a/ruoyi-fastapi-frontend/tests/plugins/run-plugin-tests.js b/ruoyi-fastapi-frontend/tests/plugins/run-plugin-tests.js new file mode 100644 index 0000000..f4fa632 --- /dev/null +++ b/ruoyi-fastapi-frontend/tests/plugins/run-plugin-tests.js @@ -0,0 +1,53 @@ +import { readdirSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const collectTestFiles = (root) => { + const entries = readdirSync(root, { withFileTypes: true }) + const testFiles = [] + + for (const entry of entries) { + const entryPath = join(root, entry.name) + if (entry.isDirectory()) { + testFiles.push(...collectTestFiles(entryPath)) + continue + } + if (entry.isFile() && entry.name.endsWith('.test.js')) { + testFiles.push(entryPath) + } + } + + return testFiles.sort() +} + +const testFiles = collectTestFiles(__dirname) + +if (testFiles.length === 0) { + console.error('No plugin tests found') + process.exitCode = 1 +} else { + let failedCount = 0 + + for (const testFile of testFiles) { + const testName = relative(__dirname, testFile) + + try { + await import(pathToFileURL(testFile).href) + console.log(`ok ${testName}`) + } catch (error) { + failedCount += 1 + console.error(`not ok ${testName}`) + console.error(error?.stack ?? error) + } + } + + if (failedCount > 0) { + console.error(`Plugin tests failed: ${failedCount}/${testFiles.length}`) + process.exitCode = 1 + } else { + console.log(`Plugin tests passed: ${testFiles.length}`) + } +}