mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-09-25 13:51:05 +00:00
* 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: 更新后端依赖文件
282 lines
11 KiB
Python
282 lines
11 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from cli.runtime.plugin.scaffold import PluginFrontendVersionResolver
|
|
from exceptions.exception import ServiceException
|
|
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 AiChatConfigModel, AiChatRequestModel
|
|
from plugins.ai.service.ai_chat_service import AiChatService
|
|
from plugins.ai.utils.ai_util import AiUtil
|
|
from plugins.core.discovery.registry import PluginRegistry
|
|
from plugins.core.discovery.scanner import PluginScanner
|
|
from plugins.core.management.entity.vo.schemas import PluginModel
|
|
from plugins.core.validation.structure import PluginStructureChecker
|
|
|
|
BACKEND_ROOT = Path(__file__).resolve().parents[3]
|
|
PROJECT_ROOT = BACKEND_ROOT.parent
|
|
FRONTEND_ROOT = PROJECT_ROOT / 'ruoyi-fastapi-frontend'
|
|
FRONTEND_VERSION = PluginFrontendVersionResolver.resolve(FRONTEND_ROOT)
|
|
|
|
EXPECTED_AI_PERMISSIONS = {
|
|
'ai:model:list',
|
|
'ai:model:add',
|
|
'ai:model:edit',
|
|
'ai:model:remove',
|
|
'ai:model:query',
|
|
'ai:chat:list',
|
|
}
|
|
EXPECTED_AI_PYTHON_DEPENDENCIES = [
|
|
'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',
|
|
]
|
|
EXPECTED_AI_VUE2_NPM_DEPENDENCIES = [
|
|
'@antv/infographic^0.2.13',
|
|
'katex>=0.16.27',
|
|
'markstream-vue2^0.0.18',
|
|
'mermaid>=11.12.2',
|
|
'shiki^3.21.0',
|
|
'stream-markdown>=0.0.14',
|
|
]
|
|
EXPECTED_AI_VUE3_NPM_DEPENDENCIES = [
|
|
'@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',
|
|
]
|
|
EXPECTED_AI_VUE3_NPM_DEV_DEPENDENCIES = ['vite-plugin-monaco-editor-esm==2.0.2']
|
|
EXPECTED_DEFAULT_NUM_HISTORY_RUNS = 3
|
|
|
|
|
|
def test_ai_plugin_template_can_be_discovered() -> None:
|
|
"""校验仓库内 AI 插件可以被插件扫描器读取。"""
|
|
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
|
|
|
|
assert plugin.manifest.id == 'ai'
|
|
assert plugin.manifest.backend.module == 'plugins.ai'
|
|
assert plugin.manifest.backend.migrations == [
|
|
'migrations/mysql/001_init.sql',
|
|
'migrations/postgresql/001_init.sql',
|
|
]
|
|
assert plugin.manifest.backend.seeds == [
|
|
'seeds/mysql/ai_provider_type.sql',
|
|
'seeds/postgresql/ai_provider_type.sql',
|
|
]
|
|
assert plugin.manifest.frontend.menus[0].children[0].component == 'plugin/ai/model/index'
|
|
assert plugin.manifest.frontend.menus[0].children[1].component == 'plugin/ai/chat/index'
|
|
assert set(plugin.manifest.permission_codes) == EXPECTED_AI_PERMISSIONS
|
|
assert plugin.manifest.permission_name_map['ai:model:add'] == '新增模型'
|
|
assert plugin.manifest.dependencies.python == EXPECTED_AI_PYTHON_DEPENDENCIES
|
|
assert plugin.manifest.config.items == []
|
|
|
|
|
|
@pytest.mark.skipif(FRONTEND_VERSION != 'vue2', reason='当前项目不是 Vue 2 前端')
|
|
def test_ai_plugin_vue2_frontend_dependencies() -> None:
|
|
"""校验 Vue 2 项目的 AI 插件声明 Vue 2 专属前端依赖。"""
|
|
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
|
|
|
|
assert plugin.manifest.dependencies.npm == EXPECTED_AI_VUE2_NPM_DEPENDENCIES
|
|
assert plugin.manifest.dependencies.npm_dev == []
|
|
|
|
|
|
@pytest.mark.skipif(FRONTEND_VERSION != 'vue3', reason='当前项目不是 Vue 3 前端')
|
|
def test_ai_plugin_vue3_frontend_dependencies() -> None:
|
|
"""校验 Vue 3 项目的 AI 插件声明 Vue 3 专属前端依赖。"""
|
|
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
|
|
|
|
assert plugin.manifest.dependencies.npm == EXPECTED_AI_VUE3_NPM_DEPENDENCIES
|
|
assert plugin.manifest.dependencies.npm_dev == EXPECTED_AI_VUE3_NPM_DEV_DEPENDENCIES
|
|
|
|
|
|
def test_ai_plugin_runtime_paths_exist() -> None:
|
|
"""校验 AI 插件后端和前端运行路径存在。"""
|
|
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
|
|
registry = PluginRegistry.build(
|
|
[plugin],
|
|
[
|
|
PluginModel(
|
|
pluginId='ai',
|
|
pluginName='AI 管理',
|
|
version='0.1.0',
|
|
installedVersion='0.1.0',
|
|
enabled='0',
|
|
status='installed',
|
|
)
|
|
],
|
|
)
|
|
|
|
assert registry.get_enabled_controller_dirs() == [BACKEND_ROOT / 'plugins' / 'ai' / 'controller']
|
|
assert registry.get_enabled_entity_do_dirs() == [BACKEND_ROOT / 'plugins' / 'ai' / 'entity' / 'do']
|
|
assert (BACKEND_ROOT / 'plugins' / 'ai' / 'controller').is_dir()
|
|
assert (BACKEND_ROOT / 'plugins' / 'ai' / 'entity' / 'do').is_dir()
|
|
assert (FRONTEND_ROOT / 'plugins' / 'ai' / 'views' / 'model' / 'index.vue').is_file()
|
|
assert (FRONTEND_ROOT / 'plugins' / 'ai' / 'views' / 'chat' / 'index.vue').is_file()
|
|
assert not (FRONTEND_ROOT / 'src' / 'api' / 'ai').exists()
|
|
assert not (FRONTEND_ROOT / 'src' / 'views' / 'ai').exists()
|
|
|
|
|
|
def test_ai_plugin_structure_check_passes() -> None:
|
|
"""校验 AI 插件目录满足插件结构检查。"""
|
|
plugin = PluginScanner(BACKEND_ROOT / 'plugins').load_manifest(BACKEND_ROOT / 'plugins' / 'ai' / 'plugin.yaml')
|
|
result = PluginStructureChecker(BACKEND_ROOT).check(plugin)
|
|
|
|
assert result.ok is True
|
|
assert result.failed_items == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_chat_config_detail_returns_vo_when_config_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""校验 AI 对话配置不存在时服务返回 VO 默认模型,而不是 DO 对象。"""
|
|
|
|
async def fake_get_chat_config_detail_by_user_id(*_args: object, **_kwargs: object) -> None:
|
|
"""返回测试用 AI 聊天配置。"""
|
|
|
|
monkeypatch.setattr(
|
|
AiChatConfigDao,
|
|
'get_chat_config_detail_by_user_id',
|
|
fake_get_chat_config_detail_by_user_id,
|
|
)
|
|
|
|
result = await AiChatService.ai_chat_config_detail_services(query_db=object(), user_id=1)
|
|
|
|
assert isinstance(result, AiChatConfigModel)
|
|
assert result.num_history_runs == EXPECTED_DEFAULT_NUM_HISTORY_RUNS
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_chat_services_rejects_missing_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""校验 AI 对话在模型不存在时直接返回明确业务异常。"""
|
|
|
|
async def fake_get_ai_model_detail_by_id(*_args: object, **_kwargs: object) -> None:
|
|
"""返回测试用 AI 模型配置。"""
|
|
|
|
monkeypatch.setattr(AiModelDao, 'get_ai_model_detail_by_id', fake_get_ai_model_detail_by_id)
|
|
|
|
stream = AiChatService.chat_services(
|
|
query_db=object(),
|
|
chat_req=AiChatRequestModel(modelId=404, message='hi'),
|
|
user_id=1,
|
|
)
|
|
|
|
with pytest.raises(ServiceException) as exc_info:
|
|
await anext(stream)
|
|
assert exc_info.value.message == '模型不存在'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ai_chat_services_rejects_disabled_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""校验 AI 对话不能使用已停用模型。"""
|
|
|
|
async def fake_get_ai_model_detail_by_id(*_args: object, **_kwargs: object) -> dict[str, object]:
|
|
"""返回测试用 AI 模型配置。"""
|
|
return {
|
|
'model_id': 1,
|
|
'model_code': 'gpt-4o-mini',
|
|
'model_name': 'GPT',
|
|
'provider': 'OpenAI',
|
|
'api_key': 'encrypted',
|
|
'status': '1',
|
|
'support_reasoning': 'N',
|
|
'support_images': 'N',
|
|
}
|
|
|
|
monkeypatch.setattr(AiModelDao, 'get_ai_model_detail_by_id', fake_get_ai_model_detail_by_id)
|
|
|
|
stream = AiChatService.chat_services(
|
|
query_db=object(),
|
|
chat_req=AiChatRequestModel(modelId=1, message='hi'),
|
|
user_id=1,
|
|
)
|
|
|
|
with pytest.raises(ServiceException) as exc_info:
|
|
await anext(stream)
|
|
assert exc_info.value.message == '模型已停用'
|
|
|
|
|
|
def test_ai_model_factory_rejects_unknown_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""校验未知 AI provider 不会静默回退到 OpenAI。"""
|
|
|
|
class FakeModel:
|
|
"""
|
|
测试用模型类。
|
|
"""
|
|
|
|
def __init__(self, **kwargs: object) -> None:
|
|
"""初始化测试用 AI 模型提供者。"""
|
|
self.kwargs = kwargs
|
|
|
|
def fake_resolve_provider_class(cls: type[AiUtil], provider: str) -> type[FakeModel] | None:
|
|
"""返回测试用 AI 模型提供者类。"""
|
|
return FakeModel if provider == 'OpenAI' else None
|
|
|
|
monkeypatch.setattr(AiUtil, '_resolve_provider_class', classmethod(fake_resolve_provider_class))
|
|
|
|
with pytest.raises(ValueError, match='未知AI模型提供商'):
|
|
AiUtil.get_model_from_factory(
|
|
provider='UnknownAI',
|
|
model_code='demo',
|
|
model_name='Demo',
|
|
api_key='secret',
|
|
)
|
|
|
|
|
|
def test_ai_model_factory_validates_base_url_boundary() -> None:
|
|
"""校验 AI 模型工厂限制基础 URL 的信任边界。"""
|
|
assert AiUtil._validate_base_url('https://api.example.com/v1') == 'https://api.example.com/v1'
|
|
|
|
blocked_urls = (
|
|
'http://127.0.0.1:8000',
|
|
'http://10.0.0.1',
|
|
'http://localhost:8000',
|
|
'ftp://api.example.com',
|
|
)
|
|
for base_url in blocked_urls:
|
|
with pytest.raises(ValueError):
|
|
AiUtil._validate_base_url(base_url)
|
|
|
|
|
|
def test_ai_plugin_sql_assets_own_ai_schema_and_seed_data() -> None:
|
|
"""校验 AI 插件通过 SQL migration 和 seed 管理自身数据。"""
|
|
plugin_root = BACKEND_ROOT / 'plugins' / 'ai'
|
|
mysql_migration = (plugin_root / 'migrations' / 'mysql' / '001_init.sql').read_text(encoding='utf-8')
|
|
postgres_migration = (plugin_root / 'migrations' / 'postgresql' / '001_init.sql').read_text(encoding='utf-8')
|
|
mysql_seed = (plugin_root / 'seeds' / 'mysql' / 'ai_provider_type.sql').read_text(encoding='utf-8')
|
|
postgres_seed = (plugin_root / 'seeds' / 'postgresql' / 'ai_provider_type.sql').read_text(encoding='utf-8')
|
|
|
|
for sql_content in (mysql_migration, postgres_migration):
|
|
assert '-- AI模型表' in sql_content
|
|
assert '-- AI对话配置表' in sql_content
|
|
assert 'create table ai_models' in sql_content
|
|
assert 'create table ai_chat_config' in sql_content
|
|
for sql_content in (mysql_seed, postgres_seed):
|
|
assert '-- 初始化-字典类型表数据' in sql_content
|
|
assert '-- 初始化-字典数据表数据' in sql_content
|
|
assert 'ai_provider_type' in sql_content
|
|
assert 'OpenAI' in sql_content
|
|
assert 'sysdate()' in mysql_seed
|
|
assert 'current_timestamp' not in mysql_seed
|
|
assert 'current_timestamp' in postgres_seed
|
|
assert 'sysdate()' not in postgres_seed
|
|
assert 'auto_increment' in mysql_migration
|
|
assert 'engine=innodb' in mysql_migration
|
|
assert 'bigserial' not in mysql_migration
|
|
assert 'comment on column' not in mysql_migration
|
|
assert 'bigserial' in postgres_migration
|
|
assert 'comment on column' in postgres_migration
|
|
assert 'auto_increment' not in postgres_migration
|
|
assert 'engine=innodb' not in postgres_migration
|