refactor: 统一多前端工程架构、插件配置与 CI (#131)

* refactor: 重构项目架构

* refactor: 重构ci和测试

* fix: 修复测试

* feat: 新增更多E2E测试

* fix: 修复E2E测试
This commit is contained in:
insistence
2026-10-10 21:15:22 +08:00
committed by GitHub
parent 4bd83bc7cd
commit 025b0822df
1167 changed files with 94684 additions and 5231 deletions
+3
View File
@@ -0,0 +1,3 @@
pytest==9.1.1
PyYAML==6.0.3
ruff==0.16.1
+2
View File
@@ -0,0 +1,2 @@
# CI 脚本和测试与后端使用相同的 Python 风格。
extend = "../ruoyi-fastapi-backend/ruff.toml"
+72
View File
@@ -0,0 +1,72 @@
import argparse
import json
import os
import subprocess
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
CONFIGURATIONS = (
('docker.env', 'docker-compose.my.yml', 'my'),
('docker.env', 'docker-compose.pg.yml', 'pg'),
('ruoyi-fastapi-test/docker.env', 'ruoyi-fastapi-test/docker-compose.test.my.yml', 'my'),
('ruoyi-fastapi-test/docker.env', 'ruoyi-fastapi-test/docker-compose.test.pg.yml', 'pg'),
)
def validate_configuration(config: dict[str, Any], framework: str, engine: str, root: Path) -> None:
"""
核对 Compose 实际解析后的前后端框架及 nginx 来源。
:param config: docker compose config 的 JSON 结果
:param framework: 本次选择的框架
:param engine: my 或 pg
:param root: 仓库根目录
:return: None
"""
expected = (root / 'ruoyi-fastapi-frontend' / framework / 'web').resolve()
services = config['services']
frontend = services['ruoyi-frontend']
build = frontend['build']
if Path(build['context']).resolve() != expected:
raise ValueError(f'{framework}: 前端构建目录不一致')
if not (expected / build.get('dockerfile', 'Dockerfile')).is_file():
raise ValueError(f'{framework}: 前端 Dockerfile 不存在')
volumes = [
volume for volume in frontend.get('volumes', []) if volume.get('target') == '/etc/nginx/conf.d/default.conf'
]
nginx = expected / 'bin' / f'nginx.docker{engine}.conf'
if len(volumes) != 1 or Path(volumes[0]['source']).resolve() != nginx or not nginx.is_file():
raise ValueError(f'{framework}: nginx 配置未选择同一框架')
backend = services[f'ruoyi-backend-{engine}']
if backend['environment'].get('RUOYI_PLUGIN_FRONTEND_FRAMEWORK') != framework:
raise ValueError(f'{framework}: 后端插件框架与前端不一致')
def main() -> None:
"""
使用两种框架解析全部生产和测试 Compose,无需启动容器。
:return: None
"""
parser = argparse.ArgumentParser()
parser.add_argument('--docker', default='docker')
args = parser.parse_args()
for framework in ('vue2', 'vue3'):
for env_file, compose_file, engine in CONFIGURATIONS:
result = subprocess.run(
[args.docker, 'compose', '--env-file', env_file, '-f', compose_file, 'config', '--format', 'json'],
cwd=ROOT,
env={**os.environ, 'FRONTEND_FRAMEWORK': framework},
check=True,
capture_output=True,
text=True,
encoding='utf-8',
timeout=60,
)
validate_configuration(json.loads(result.stdout), framework, engine, ROOT)
print(f'{framework}: {compose_file} PASS')
if __name__ == '__main__':
main()
+39
View File
@@ -0,0 +1,39 @@
import argparse
import xml.etree.ElementTree as ET
from pathlib import Path
def check_report(path: Path, *, allow_skips: bool = False) -> tuple[int, int]:
"""
验证测试确实执行,专用验收任务不允许缺少依赖而全部跳过。
:param path: pytest JUnit 报告路径
:param allow_skips: 是否允许普通跨平台回归中的预期跳过
:return: 实际执行及跳过的测试数量
"""
root = ET.parse(path).getroot()
cases = root.findall('.//testcase')
skipped = sum(case.find('skipped') is not None for case in cases)
executed = len(cases) - skipped
failures = sum(case.find('failure') is not None or case.find('error') is not None for case in cases)
if executed == 0 or failures or (skipped and not allow_skips):
raise ValueError(f'{path}: 执行 {executed},跳过 {skipped},失败 {failures}')
return executed, skipped
def main() -> None:
"""
检查报告并将执行数量写入 CI 摘要。
:return: None
"""
parser = argparse.ArgumentParser()
parser.add_argument('report', type=Path)
parser.add_argument('--allow-skips', action='store_true')
args = parser.parse_args()
executed, skipped = check_report(args.report, allow_skips=args.allow_skips)
print(f'{args.report}: executed={executed}, skipped={skipped}')
if __name__ == '__main__':
main()
+245
View File
@@ -0,0 +1,245 @@
import argparse
import json
import os
import subprocess
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
FRAMEWORKS = ('vue2', 'vue3')
PYTHON_VERSIONS = ('3.10', '3.11', '3.12', '3.13')
CATEGORIES = ('quality', 'backend', 'frontend', 'integration', 'e2e', 'native')
FRONTEND_FILE_PATH_PARTS = 4
# 将影响范围规则集中列出,便于逐条审核;拆散分支会掩盖任务之间的联动。
def build_plan(paths: list[str], *, full: bool = False) -> dict[str, Any]: # noqa: PLR0912, PLR0915
"""
根据源码、共享依赖及测试的影响范围生成 CI 执行计划。
:param paths: 变更文件,重命名需要同时传入旧路径和新路径
:param full: 是否执行完整兼容矩阵
:return: 分类开关、矩阵和必跑任务
"""
projects: set[tuple[str, str]] = set()
browsers: set[str] = set()
networks: set[str] = set()
backend = native = database = release = False
normalized = [path.replace('\\', '/').removeprefix('./') for path in paths]
shared = full or any(
path.startswith(('.github/', 'ruoyi-fastapi-backend/requirements'))
or path
in (
'ruoyi-fastapi-backend/pyproject.toml',
'ruoyi-fastapi-backend/tests/conftest.py',
'ruoyi-fastapi-test/requirements.txt',
'.gitignore',
)
for path in normalized
)
if shared:
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
browsers.update(FRAMEWORKS)
networks.update(FRAMEWORKS)
backend = native = database = release = True
for path in normalized:
if path.lower().endswith('.md'):
continue
parts = path.split('/')
if path.startswith('ruoyi-fastapi-backend/'):
backend = True
relative = path.removeprefix('ruoyi-fastapi-backend/')
if not relative.startswith(('tests/', 'ruff.toml')):
browsers.update(FRAMEWORKS)
if relative.startswith(('common/', 'config/', 'utils/', 'sql/', 'scripts/verify_timezone_database')):
database = True
if relative.startswith(
(
'plugins/',
'module_plugin/',
'cli/',
'tests/plugins/',
'tests/cli/',
'tests/module_plugin/',
'tests/sql/',
)
):
native = release = True
if relative == 'scripts/build_native_plugin.py':
native = True
if relative.startswith(
('plugins/', 'module_plugin/', 'common/', 'config/', 'middlewares/', 'utils/')
) or relative in ('server.py', 'app.py', 'module_admin/service/login_service.py'):
networks.update(FRAMEWORKS)
if relative.startswith(('config/', 'sql/', 'scripts/plugin_', 'tests/server/')) or relative in (
'server.py',
'app.py',
):
release = True
if relative.startswith('tests/plugins/integration/') or relative in (
'tests/scheduler_helpers.py',
'tests/plugins/core/runtime/test_browser_session.py',
):
networks.update(FRAMEWORKS)
if relative in (
'plugins/core/frontend.py',
'plugins/core/environment.py',
'plugins/core/manifest/schema.py',
):
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
if relative.startswith('plugins/examples/python/bundle_demo/web/'):
projects.update((framework, 'web') for framework in FRAMEWORKS)
elif (
len(parts) >= FRONTEND_FILE_PATH_PARTS
and parts[0] == 'ruoyi-fastapi-frontend'
and parts[1] in FRAMEWORKS
and parts[2] in ('web', 'mobile')
):
framework, target = parts[1:3]
projects.add((framework, target))
if target == 'web':
browsers.add(framework)
networks.add(framework)
if target == 'web' and (parts[-1].startswith('pluginBridge') or parts[-1] == 'package.json'):
backend = native = release = True
elif path.startswith('ruoyi-fastapi-frontend/'):
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
browsers.update(FRAMEWORKS)
networks.update(FRAMEWORKS)
elif path.startswith('ruoyi-fastapi-test/frontend/'):
projects.update((framework, 'mobile') for framework in FRAMEWORKS)
elif path.startswith('ruoyi-fastapi-test/time-contract/'):
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
database = True
browsers.update(FRAMEWORKS)
elif path.startswith('ruoyi-fastapi-test/') or path in (
'docker.env',
'docker-compose.my.yml',
'docker-compose.pg.yml',
):
browsers.update(FRAMEWORKS)
if 'docker' in path or path.endswith('.sql'):
database = True
required = {
'quality': True,
'backend': backend,
'frontend': bool(projects),
'integration': database or release or bool(networks),
'e2e': bool(browsers),
'native': native,
}
return {
'full': full,
'required': required,
'frontend_matrix': {
'include': [{'framework': framework, 'target': target} for framework, target in sorted(projects)]
},
'e2e_matrix': {
'include': [
{'framework': framework, 'database': engine, 'python': version}
for framework in sorted(browsers)
for engine in ('mysql', 'postgresql')
for version in (PYTHON_VERSIONS if full else ('3.12',))
]
},
'network_frameworks': sorted(networks),
'database': database,
'release': release,
'paths': normalized,
}
def check_results(plan: dict[str, Any], needs: dict[str, Any]) -> list[str]:
"""
核验必跑任务,拒绝失败、取消、缺失和意外跳过。
:param plan: 选路任务生成的计划
:param needs: GitHub needs 上下文
:return: 不满足门禁的原因
"""
errors = []
if needs.get('plan', {}).get('result') != 'success':
errors.append('CI 计划未成功生成')
required = plan.get('required', {})
if set(required) != set(CATEGORIES) or not all(isinstance(value, bool) for value in required.values()):
return [*errors, 'CI 计划缺少完整的任务分类']
for category in CATEGORIES:
result = needs.get(category, {}).get('result')
allowed = ('success',) if required[category] else ('success', 'skipped')
if result not in allowed:
errors.append(f'{category}: {result or "missing"},预期 {"/".join(allowed)}')
return errors
def changed_paths(base: str) -> list[str]:
"""
从 Git 获取变更文件,保留删除及重命名前后的路径。
:param base: 已检出的比较基准提交
:return: 相对仓库根目录的路径
"""
result = subprocess.run(
['git', 'diff', '--no-renames', '--name-only', '-z', base, 'HEAD'],
cwd=ROOT,
check=True,
capture_output=True,
timeout=60,
)
return [path for path in result.stdout.decode('utf-8').split('\0') if path]
def write_outputs(plan: dict[str, Any]) -> None:
"""
输出 GitHub 任务参数及可审阅的执行摘要。
:param plan: 本次运行计划
:return: None
"""
payload = {
**plan['required'],
**{key: value for key, value in plan.items() if key not in ('required', 'paths')},
'plan_json': plan,
}
if output := os.environ.get('GITHUB_OUTPUT'):
with Path(output).open('a', encoding='utf-8') as stream:
for key, value in payload.items():
stream.write(f'{key}={json.dumps(value, ensure_ascii=False, separators=(",", ":"))}\n')
if summary := os.environ.get('GITHUB_STEP_SUMMARY'):
with Path(summary).open('a', encoding='utf-8') as stream:
stream.write('## CI 执行计划\n\n| 分类 | 执行 |\n|---|---|\n')
for category, required in plan['required'].items():
stream.write(f'| {category} | {"运行" if required else "无相关改动"} |\n')
stream.write(f'\n完整矩阵:{plan["full"]}\n')
print(json.dumps(plan, ensure_ascii=False, indent=2))
def main() -> None:
"""
执行变更选路或最终结果校验。
:return: None
"""
parser = argparse.ArgumentParser()
parser.add_argument('command', choices=('plan', 'gate'))
args = parser.parse_args()
if args.command == 'gate':
plan = json.loads(os.environ.get('PLAN_JSON') or '{}')
needs = json.loads(os.environ.get('NEEDS_JSON') or '{}')
errors = check_results(plan, needs)
if errors:
raise SystemExit('\n'.join(errors))
print('所有应执行的 CI 分类均已通过。')
return
event = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8'))
event_name = os.environ['GITHUB_EVENT_NAME']
full = event_name in ('push', 'schedule') or (
str(event.get('inputs', {}).get('full', 'true')).lower() == 'true' and event_name == 'workflow_dispatch'
)
base = event.get('pull_request', {}).get('base', {}).get('sha', '')
paths = changed_paths(base) if base else ['.github/workflows/ci.yml']
write_outputs(build_plan(paths, full=full))
if __name__ == '__main__':
main()
@@ -0,0 +1,23 @@
import subprocess
import sys
from pathlib import Path
import yaml
def main() -> None:
"""
从 AI 插件清单安装测试依赖,避免 CI 维护第二份版本列表。
:return: None
"""
backend = Path(__file__).resolve().parents[2] / 'ruoyi-fastapi-backend'
manifest = yaml.safe_load((backend / 'plugins/ai/plugin.yaml').read_text(encoding='utf-8'))
requirements = manifest['dependencies']['python']
if not requirements or not all(isinstance(item, str) and not item.startswith('-') for item in requirements):
raise ValueError('AI 插件 Python 依赖清单无效')
subprocess.run([sys.executable, '-m', 'pip', 'install', *requirements], check=True, timeout=900)
if __name__ == '__main__':
main()
+184
View File
@@ -0,0 +1,184 @@
import argparse
import asyncio
import json
import os
import re
import threading
import time
from collections.abc import Awaitable, Callable
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from playwright.async_api import Route, async_playwright, expect
ROOT = Path(__file__).resolve().parents[2]
TOKEN = 'isolated-mobile-smoke-token'
USER = {
'userId': 7,
'userName': 'ci-user',
'nickName': '移动端 CI',
'avatar': '',
'phonenumber': '13800000000',
'email': 'mobile-ci@example.test',
'createTime': '2026-01-01T00:00:00Z',
'timeZone': 'America/New_York',
}
def create_api_handler(errors: list[str], observed: list[dict[str, Any]]) -> Callable[[Route], Awaitable[None]]:
"""
创建隔离 API 处理函数,向共享记录追加请求及异常。
:param errors: 本次验收收集的异常说明
:param observed: 实际发出的 API 请求记录
:return: 供浏览器路由使用的异步处理函数
"""
async def api(route: Route) -> None:
"""
隔离 API 边界,记录真实构建发出的认证和时区请求。
:param route: 当前浏览器 API 请求
:return: None
"""
request = route.request
path = urlparse(request.url).path
headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*'}
if request.method == 'OPTIONS':
await route.fulfill(status=204, headers=headers)
return
observed.append(
{
'path': path,
'method': request.method,
'authorization': request.headers.get('authorization'),
'timezone': request.headers.get('x-timezone'),
}
)
payload: dict[str, Any]
if path == '/transport/crypto/frontend-config':
payload = {
'code': 200,
'data': {
'transportCryptoEnabled': False,
'transportCryptoMode': 'off',
'transportCryptoActive': False,
'configExpireAt': int(time.time()) + 600,
},
}
elif path == '/captchaImage':
payload = {'code': 200, 'captchaEnabled': False}
elif path == '/login':
if 'ci-user' not in (request.post_data or ''):
errors.append('登录请求未包含输入的用户名')
payload = {'code': 200, 'token': TOKEN}
elif path == '/getInfo':
payload = {
'code': 200,
'user': USER,
'roles': ['admin'],
'permissions': ['*:*:*'],
'appTimezone': 'Asia/Shanghai',
}
elif path == '/system/user/profile':
payload = {'code': 200, 'data': USER, 'postGroup': 'CI', 'roleGroup': '管理员'}
elif path == '/logout':
payload = {'code': 200}
else:
errors.append(f'未预期的 API 请求:{path}')
await route.fulfill(status=500, json={'code': 500, 'msg': 'unexpected smoke request'}, headers=headers)
return
await route.fulfill(json=payload, headers=headers)
return api
async def run_smoke(framework: str, base_url: str, output: Path) -> None:
"""
使用真实 H5 构建验证登录、持久化身份和个人资料页。
:param framework: vue2 或 vue3
:param base_url: 仅用于本次测试的静态文件服务器
:param output: 浏览器报告目录
:return: None
"""
logs: list[str] = []
errors: list[str] = []
observed: list[dict[str, Any]] = []
api = create_api_handler(errors, observed)
async with async_playwright() as playwright:
executable = os.environ.get('RUOYI_SMOKE_BROWSER_EXECUTABLE')
browser = await playwright.chromium.launch(
headless=True, **({'executable_path': executable} if executable else {})
)
context = await browser.new_context(viewport={'width': 390, 'height': 844}, timezone_id='UTC')
await context.tracing.start(screenshots=True, snapshots=True, sources=True)
await context.route('http://localhost:9099/**', api)
page = await context.new_page()
page.on('pageerror', lambda error: errors.append(str(error)))
page.on('console', lambda message: logs.append(f'{message.type}: {message.text}'))
failed = False
try:
await page.goto(f'{base_url}/#/pages/login')
await expect(page.locator('input[type="text"]')).to_be_visible(timeout=30000)
await expect(page.locator('input')).to_have_count(2, timeout=15000)
await page.locator('input[type="text"]').fill('ci-user')
await page.locator('input[type="password"]').fill('ci-test-password')
await page.locator('uni-button').filter(has_text=re.compile(r'登\s*录')).click()
await expect(page).to_have_url(re.compile(r'/pages/index'), timeout=30000)
await page.goto(f'{base_url}/#/pages/mine/info/index')
await expect(page.get_by_text(USER['nickName'], exact=True)).to_be_visible(timeout=30000)
await expect(page.get_by_text('2025-12-31 19:00:00', exact=True)).to_be_visible(timeout=15000)
profile_requests = [request for request in observed if request['path'] == '/system/user/profile']
assert profile_requests, '个人资料页未发送真实 API 请求'
assert all(request['authorization'] == f'Bearer {TOKEN}' for request in profile_requests)
assert all(request['timezone'] == 'America/New_York' for request in profile_requests)
assert not errors, errors
(output / 'result.json').write_text(
json.dumps({'framework': framework, 'ok': True, 'requests': observed}, ensure_ascii=False, indent=2),
encoding='utf-8',
)
print(f'{framework}: H5 login, persisted session and timezone profile PASS')
except BaseException:
failed = True
await page.screenshot(path=str(output / 'failure.png'), full_page=True)
(output / 'page.html').write_text(await page.content(), encoding='utf-8')
raise
finally:
(output / 'browser.log').write_text('\n'.join([*logs, *errors]), encoding='utf-8')
await context.tracing.stop(**({'path': str(output / 'trace.zip')} if failed else {}))
await context.close()
await browser.close()
def main() -> None:
"""
在独立临时端口启动 H5 静态服务,结束后关闭自身创建的资源。
:return: None
"""
parser = argparse.ArgumentParser()
parser.add_argument('--framework', choices=('vue2', 'vue3'), required=True)
args = parser.parse_args()
distribution = ROOT / 'ruoyi-fastapi-frontend' / args.framework / 'mobile/dist/build/h5'
if not (distribution / 'index.html').is_file():
raise SystemExit(f'请先构建 H5:{distribution}')
output = ROOT / 'target/ci/mobile-smoke' / args.framework
output.mkdir(parents=True, exist_ok=True)
server = ThreadingHTTPServer(('127.0.0.1', 0), partial(SimpleHTTPRequestHandler, directory=str(distribution)))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
asyncio.run(run_smoke(args.framework, f'http://127.0.0.1:{server.server_port}', output))
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
if __name__ == '__main__':
main()
+91
View File
@@ -0,0 +1,91 @@
import importlib.util
from copy import deepcopy
from pathlib import Path
from types import ModuleType
from typing import Any
import pytest
ROOT = Path(__file__).resolve().parents[2]
def load_script(name: str) -> ModuleType:
"""
按文件路径加载 CI 脚本,避免依赖仓库外的模块搜索路径。
:param name: 不含扩展名的脚本名称
:return: 已执行模块顶层定义的脚本模块
"""
spec = importlib.util.spec_from_file_location(name, ROOT / '.github/scripts' / f'{name}.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
compose = load_script('check_compose')
reports = load_script('check_test_report')
@pytest.fixture
def configuration() -> dict[str, Any]:
"""提供前端、nginx 和后端框架一致的 Compose 配置。"""
frontend = ROOT / 'ruoyi-fastapi-frontend/vue2/web'
return {
'services': {
'ruoyi-frontend': {
'build': {'context': str(frontend), 'dockerfile': 'Dockerfile'},
'volumes': [
{'source': str(frontend / 'bin/nginx.dockermy.conf'), 'target': '/etc/nginx/conf.d/default.conf'}
],
},
'ruoyi-backend-my': {'environment': {'RUOYI_PLUGIN_FRONTEND_FRAMEWORK': 'vue2'}},
}
}
def test_compose_accepts_consistent_framework(configuration: dict[str, Any]) -> None:
"""框架一致的 Compose 配置可以通过校验。"""
compose.validate_configuration(configuration, 'vue2', 'my', ROOT)
@pytest.mark.parametrize('component', ['build', 'nginx', 'backend'])
def test_compose_rejects_mixed_framework_deployment(configuration: dict[str, Any], component: str) -> None:
"""前端、nginx 或后端混用框架时拒绝部署配置。"""
invalid = deepcopy(configuration)
if component == 'build':
invalid['services']['ruoyi-frontend']['build']['context'] = str(ROOT / 'ruoyi-fastapi-frontend/vue3/web')
elif component == 'nginx':
invalid['services']['ruoyi-frontend']['volumes'][0]['source'] = str(
ROOT / 'ruoyi-fastapi-frontend/vue3/web/bin/nginx.dockermy.conf'
)
else:
invalid['services']['ruoyi-backend-my']['environment']['RUOYI_PLUGIN_FRONTEND_FRAMEWORK'] = 'vue3'
with pytest.raises(ValueError):
compose.validate_configuration(invalid, 'vue2', 'my', ROOT)
@pytest.mark.parametrize(
'cases',
['', '<testcase><skipped /></testcase>', '<testcase><failure /></testcase>', '<testcase><error /></testcase>'],
)
def test_report_rejects_empty_skipped_or_failed_verification(tmp_path: Path, cases: str) -> None:
"""空报告、全部跳过和失败用例都不能视为验收成功。"""
report = tmp_path / 'results.xml'
report.write_text(f'<testsuites><testsuite>{cases}</testsuite></testsuites>', encoding='utf-8')
with pytest.raises(ValueError):
reports.check_report(report)
def test_expected_platform_skip_still_requires_executed_tests(tmp_path: Path) -> None:
"""允许预期平台跳过时仍要求实际执行测试。"""
report = tmp_path / 'results.xml'
report.write_text(
'<testsuites><testsuite><testcase/><testcase><skipped/></testcase></testsuite></testsuites>', encoding='utf-8'
)
assert reports.check_report(report, allow_skips=True) == (1, 1)
def test_missing_report_is_not_success(tmp_path: Path) -> None:
"""报告缺失时不能视为验收成功。"""
with pytest.raises(FileNotFoundError):
reports.check_report(tmp_path / 'missing.xml')
+169
View File
@@ -0,0 +1,169 @@
import importlib.util
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'ci_plan.py'
SPEC = importlib.util.spec_from_file_location('ci_plan', SCRIPT)
ci = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(ci)
FRONTEND_PROJECT_COUNT = 4
STANDARD_E2E_COMBINATION_COUNT = 4
FULL_E2E_COMBINATION_COUNT = 16
def test_readme_change_only_runs_quality() -> None:
"""仅文档变更时只执行质量检查。"""
assert ci.build_plan(['README.md'])['required'] == {
'quality': True,
'backend': False,
'frontend': False,
'integration': False,
'e2e': False,
'native': False,
}
@pytest.mark.parametrize(
'path',
[
'ruoyi-fastapi-backend/cli/groups/plugin/options.py',
'ruoyi-fastapi-backend/cli/groups/plugin/controller.py',
'ruoyi-fastapi-backend/cli/runtime/plugin/service.py',
'ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_release_controller.py',
'ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py',
],
)
def test_shared_plugin_changes_retain_windows_and_release_coverage(path: str) -> None:
"""共享插件变更保留原生模块及发布验收覆盖。"""
plan = ci.build_plan([path])
assert plan['required']['backend']
assert plan['required']['native']
assert plan['release']
def test_native_builder_change_runs_native_verification() -> None:
"""原生构建器变更必须触发原生验收。"""
plan = ci.build_plan(['ruoyi-fastapi-backend/scripts/build_native_plugin.py'])
assert plan['required']['native']
def test_bundle_example_change_runs_both_web_consumers() -> None:
"""示例开发代理由两版 Web 测试直接引用,变更时均应执行。"""
plan = ci.build_plan(['ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev/mockRequest.js'])
assert plan['frontend_matrix'] == {
'include': [
{'framework': 'vue2', 'target': 'web'},
{'framework': 'vue3', 'target': 'web'},
]
}
@pytest.mark.parametrize(
'path',
[
'ruoyi-fastapi-backend/module_admin/service/login_service.py',
'ruoyi-fastapi-backend/tests/scheduler_helpers.py',
'ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py',
],
)
def test_shared_auth_and_test_helpers_keep_both_network_suites(path: str) -> None:
"""共享鉴权和测试夹具变更触发两版网络验收。"""
plan = ci.build_plan([path])
assert plan['required']['backend']
assert plan['required']['integration']
assert plan['network_frameworks'] == ['vue2', 'vue3']
@pytest.mark.parametrize(
'path', ['.github/workflows/ci.yml', '.github/scripts/ci_plan.py', 'ruoyi-fastapi-backend/requirements-test.txt']
)
def test_ci_and_dependency_changes_select_every_category(path: str) -> None:
"""CI 及共享依赖变更覆盖全部任务分类。"""
plan = ci.build_plan([path])
assert all(plan['required'].values())
assert len(plan['frontend_matrix']['include']) == FRONTEND_PROJECT_COUNT
assert len(plan['e2e_matrix']['include']) == STANDARD_E2E_COMBINATION_COUNT
def test_mobile_change_does_not_build_other_projects_or_web_e2e() -> None:
"""单个移动端变更只选中对应工程。"""
plan = ci.build_plan(['ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js'])
assert plan['frontend_matrix'] == {'include': [{'framework': 'vue2', 'target': 'mobile'}]}
assert not plan['required']['e2e']
@pytest.mark.parametrize(
'path',
[
'ruoyi-fastapi-test/frontend/mobile-harness.mjs',
'ruoyi-fastapi-test/frontend/mobile-behavior.mjs',
],
)
def test_shared_mobile_harness_runs_both_mobile_projects(path: str) -> None:
"""共享移动端测试工具变更同时验证两版工程。"""
plan = ci.build_plan([path])
assert plan['required']['frontend']
assert plan['frontend_matrix'] == {
'include': [
{'framework': 'vue2', 'target': 'mobile'},
{'framework': 'vue3', 'target': 'mobile'},
]
}
assert not plan['required']['e2e']
def test_web_change_selects_matching_framework_and_both_databases() -> None:
"""Web 变更使用对应框架并覆盖两类数据库。"""
plan = ci.build_plan(['ruoyi-fastapi-frontend/vue2/web/src/views/login.vue'])
assert plan['network_frameworks'] == ['vue2']
assert {(row['framework'], row['database'], row['python']) for row in plan['e2e_matrix']['include']} == {
('vue2', 'mysql', '3.12'),
('vue2', 'postgresql', '3.12'),
}
def test_shared_time_contract_runs_all_frontends_and_database_checks() -> None:
"""共享时间契约触发全部前端及数据库检查。"""
plan = ci.build_plan(['ruoyi-fastapi-test/time-contract/fixtures.json'])
assert len(plan['frontend_matrix']['include']) == FRONTEND_PROJECT_COUNT
assert plan['database']
def test_framework_move_keeps_both_sides_in_plan() -> None:
"""跨框架移动文件保留来源和目标两侧验证。"""
plan = ci.build_plan(
['ruoyi-fastapi-frontend/vue2/web/src/utils/demo.js', 'ruoyi-fastapi-frontend/vue3/web/src/utils/demo.js']
)
assert plan['network_frameworks'] == ['vue2', 'vue3']
def test_full_run_preserves_python_database_and_framework_combinations() -> None:
"""完整运行保留 Python、数据库和框架的全部组合。"""
plan = ci.build_plan([], full=True)
assert all(plan['required'].values())
assert len(plan['e2e_matrix']['include']) == FULL_E2E_COMBINATION_COUNT
assert {row['python'] for row in plan['e2e_matrix']['include']} == {'3.10', '3.11', '3.12', '3.13'}
@pytest.mark.parametrize('result', ['failure', 'cancelled', 'skipped', None])
def test_gate_rejects_unsuccessful_required_job(result: str | None) -> None:
"""必跑任务失败、取消、跳过或缺失均不能通过门禁。"""
plan = ci.build_plan(['README.md'])
needs = {'plan': {'result': 'success'}, **{name: {'result': 'skipped'} for name in ci.CATEGORIES}}
needs['quality']['result'] = result
assert ci.check_results(plan, needs)
def test_gate_allows_only_unselected_jobs_to_skip() -> None:
"""只有未选中的任务允许跳过。"""
plan = ci.build_plan(['README.md'])
needs = {'plan': {'result': 'success'}, **{name: {'result': 'skipped'} for name in ci.CATEGORIES}}
needs['quality']['result'] = 'success'
assert ci.check_results(plan, needs) == []
def test_gate_fails_closed_when_plan_or_job_is_missing() -> None:
"""缺少执行计划或任务结果时门禁必须拒绝放行。"""
assert ci.check_results({}, {})
assert ci.check_results(ci.build_plan(['README.md']), {'plan': {'result': 'success'}})
-64
View File
@@ -1,64 +0,0 @@
name: Backend Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
pytest:
runs-on: ubuntu-latest
env:
# Typer otherwise forces ANSI help output when GITHUB_ACTIONS is set.
_TYPER_FORCE_DISABLE_TERMINAL: "1"
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: |
ruoyi-fastapi-backend/requirements.txt
ruoyi-fastapi-backend/plugins/ai/plugin.yaml
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt pytest pytest-asyncio aiosqlite
- name: Install AI plugin dependencies
run: |
pip install \
agno==2.8.6 \
anthropic==0.120.2 \
cerebras-cloud-sdk==1.91.0 \
cohere==7.0.8 \
google-genai==2.16.0 \
groq==1.6.0 \
litellm==1.94.0 \
llama-api-client==0.6.0 \
mistralai==2.8.0 \
ollama==0.6.2 \
openai==2.52.0 \
portkey-ai==2.3.4
- name: Run backend tests
run: pytest -q
- name: Check timezone behavior under independent host timezones
run: |
for zone in UTC Asia/Shanghai America/New_York; do
TZ="$zone" python -m pytest -q tests/common/test_types.py tests/common/test_query_time.py tests/utils/test_time_util.py tests/config/test_scheduler_database.py tests/utils/test_cron_util.py tests/module_admin/controller/test_job_controller.py tests/config/test_scheduler_job_persistence.py
done
+110
View File
@@ -0,0 +1,110 @@
name: CI / Backend
on:
workflow_call:
permissions:
contents: read
env:
_TYPER_FORCE_DISABLE_TERMINAL: '1'
PYTHONUTF8: '1'
jobs:
unit-contract:
name: Python ${{ matrix.python }}
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12', '3.13']
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: |
ruoyi-fastapi-backend/requirements*.txt
ruoyi-fastapi-backend/plugins/ai/plugin.yaml
- name: Install test dependencies
run: python -m pip install -r requirements.txt -r requirements-test.txt
- name: Install AI plugin dependencies from its manifest
run: python ../.github/scripts/install_ai_test_dependencies.py
- name: Run backend unit and contract tests
run: python -m pytest tests -q -m 'not browser and not native and not integration' --strict-markers --durations=20 --junitxml=target/ci/backend.xml
- name: Verify tests were executed
run: python ../.github/scripts/check_test_report.py target/ci/backend.xml --allow-skips
- uses: actions/upload-artifact@v6
if: always()
with:
name: backend-python-${{ matrix.python }}
path: ruoyi-fastapi-backend/target/ci/
retention-days: 7
windows-contract:
name: Windows CLI and paths
runs-on: windows-latest
timeout-minutes: 20
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-test.txt
- name: Verify CLI, paths, artifacts and portable runtime contracts
run: >-
python -m pytest tests/cli
tests/plugins/core/test_frontend_layout.py tests/plugins/core/validation/test_frontend_dependencies.py
tests/plugins/core/native tests/plugins/core/runtime/test_host_services.py tests/plugins/core/runtime/test_job_dispatcher.py
tests/plugins/core/artifacts tests/plugins/core/deployment
tests/plugins/core/management/test_release_dao.py tests/plugins/core/management/test_artifact_views.py
tests/module_plugin/controller/test_plugin_release_controller.py tests/sql/test_plugin_release_schema.py
tests/plugins/examples/test_task_demo.py
-m 'not native and not browser and not integration' -q --strict-markers --durations=15 --junitxml=target/ci/windows-contract.xml
- run: python ../.github/scripts/check_test_report.py target/ci/windows-contract.xml --allow-skips
- uses: actions/upload-artifact@v6
if: always()
with:
name: backend-windows-contract
path: ruoyi-fastapi-backend/target/ci/
retention-days: 7
timezone:
name: Host timezone contracts
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-test.txt
- name: Verify UTC, Shanghai and New York in separate processes
shell: bash
run: |
for zone in UTC Asia/Shanghai America/New_York; do
report="target/ci/timezone-${zone//\//_}.xml"
TZ="$zone" python -m pytest -q tests/common/test_types.py tests/common/test_query_time.py tests/utils/test_time_util.py tests/config/test_scheduler_database.py tests/utils/test_cron_util.py tests/module_admin/controller/test_job_controller.py tests/config/test_scheduler_job_persistence.py --junitxml="$report"
python ../.github/scripts/check_test_report.py "$report"
done
- uses: actions/upload-artifact@v6
if: always()
with:
name: backend-timezones
path: ruoyi-fastapi-backend/target/ci/
retention-days: 7
+111
View File
@@ -0,0 +1,111 @@
name: CI / E2E
on:
workflow_call:
inputs:
matrix_json:
type: string
required: true
permissions:
contents: read
jobs:
browser:
name: ${{ matrix.framework }} / ${{ matrix.database }} / Python ${{ matrix.python }}
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix: ${{ fromJSON(inputs.matrix_json) }}
env:
COMPOSE_PROJECT_NAME: ruoyi-ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }}
COMPOSE_FILE: ${{ github.workspace }}/ruoyi-fastapi-test/target/e2e/compose.json
FRONTEND_FRAMEWORK: ${{ matrix.framework }}
E2E_OUTPUT_DIR: ${{ github.workspace }}/ruoyi-fastapi-test/target/e2e/browser
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-test/requirements.txt
- name: Install test runner and Chromium
run: |
python -m pip install -r ruoyi-fastapi-test/requirements.txt
python -m playwright install --with-deps chromium
- name: Verify reusable browser and isolated contexts
working-directory: ruoyi-fastapi-test
run: |
python -m pytest -m harness -q --junitxml=target/e2e/harness.xml
python ../.github/scripts/check_test_report.py target/e2e/harness.xml
- name: Generate isolated Compose project
run: |
python ruoyi-fastapi-test/prepare_e2e_compose.py \
--database '${{ matrix.database }}' --framework '${{ matrix.framework }}' \
--python-version '${{ matrix.python }}' --project "$COMPOSE_PROJECT_NAME" \
--output "$COMPOSE_FILE"
docker compose config --quiet
- uses: docker/setup-buildx-action@v3
- name: Build frontend with reusable layers
uses: docker/build-push-action@v6
with:
context: ruoyi-fastapi-frontend/${{ matrix.framework }}/web
file: ruoyi-fastapi-frontend/${{ matrix.framework }}/web/Dockerfile
load: true
tags: ${{ env.COMPOSE_PROJECT_NAME }}-ruoyi-frontend:ci
cache-from: type=gha,scope=e2e-frontend-${{ matrix.framework }}
cache-to: type=gha,scope=e2e-frontend-${{ matrix.framework }},mode=max
- name: Select database Dockerfile
id: database
env:
DATABASE: ${{ matrix.database }}
run: |
if [ "$DATABASE" = mysql ]; then
echo 'suffix=my' >> "$GITHUB_OUTPUT"
else
echo 'suffix=pg' >> "$GITHUB_OUTPUT"
fi
- name: Build backend with reusable layers
uses: docker/build-push-action@v6
with:
context: ruoyi-fastapi-backend
file: ruoyi-fastapi-backend/Dockerfile.${{ steps.database.outputs.suffix }}
load: true
tags: ${{ env.COMPOSE_PROJECT_NAME }}-ruoyi-backend-${{ steps.database.outputs.suffix }}:ci
build-args: |
PYTHON_VERSION=${{ matrix.python }}
PIP_INDEX_URL=https://pypi.org/simple
cache-from: type=gha,scope=e2e-backend-${{ matrix.database }}-py${{ matrix.python }}
cache-to: type=gha,scope=e2e-backend-${{ matrix.database }}-py${{ matrix.python }},mode=max
- name: Wait for healthy database, API and frontend proxy
run: |
docker compose up --detach --no-build --wait --wait-timeout 240
echo "TEST_FRONTEND_URL=http://$(docker compose port ruoyi-frontend 80)" >> "$GITHUB_ENV"
echo "TEST_BACKEND_URL=http://$(docker compose port ruoyi-backend-${{ steps.database.outputs.suffix }} 9099)" >> "$GITHUB_ENV"
- name: Run all application pages and functional E2E checks
working-directory: ruoyi-fastapi-test
run: |
python -m pytest -m e2e -v --durations=20 --junitxml=target/e2e/junit.xml
python ../.github/scripts/check_test_report.py target/e2e/junit.xml
- name: Collect service diagnostics
if: always()
run: |
mkdir -p ruoyi-fastapi-test/target/e2e
if [ -f "$COMPOSE_FILE" ]; then
docker compose ps --all > ruoyi-fastapi-test/target/e2e/services.txt 2>&1 || true
docker compose logs --no-color --timestamps > ruoyi-fastapi-test/target/e2e/services.log 2>&1 || true
fi
- name: Remove only this test project's containers and data
if: always()
run: |
if [ -f "$COMPOSE_FILE" ]; then
docker compose down --volumes --remove-orphans --timeout 20
fi
- uses: actions/upload-artifact@v6
if: always()
with:
name: e2e-${{ matrix.framework }}-${{ matrix.database }}-py${{ matrix.python }}
path: ruoyi-fastapi-test/target/e2e
retention-days: 7
if-no-files-found: warn
+116
View File
@@ -0,0 +1,116 @@
name: CI / Frontend
on:
workflow_call:
inputs:
matrix_json:
description: 'JSON matrix with framework and target entries under include'
required: true
type: string
full:
description: 'Reserved for callers requesting the full project matrix'
required: false
default: false
type: boolean
permissions:
contents: read
jobs:
frontend:
name: ${{ matrix.framework }} / ${{ matrix.target }}
runs-on: ubuntu-latest
timeout-minutes: 35
strategy:
fail-fast: false
matrix: ${{ fromJSON(inputs.matrix_json) }}
env:
NODE_OPTIONS: ${{ matrix.framework == 'vue2' && '--openssl-legacy-provider' || '' }}
defaults:
run:
working-directory: ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install the Vue 2 Mobile package manager
if: matrix.framework == 'vue2' && matrix.target == 'mobile'
run: npm install --global yarn@1.22.22
- uses: pnpm/action-setup@v5
if: matrix.framework == 'vue3' && matrix.target == 'mobile'
with:
package_json_file: ruoyi-fastapi-frontend/vue3/mobile/package.json
- name: Locate the package cache
id: package-cache
env:
FRAMEWORK: ${{ matrix.framework }}
TARGET: ${{ matrix.target }}
run: |
if [ "$TARGET" = web ]; then
echo "path=$(npm config get cache)" >> "$GITHUB_OUTPUT"
elif [ "$FRAMEWORK" = vue2 ]; then
echo "path=$(yarn cache dir --silent)" >> "$GITHUB_OUTPUT"
else
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
fi
- uses: actions/cache@v5
with:
path: ${{ steps.package-cache.outputs.path }}
# Lockfiles are intentionally not tracked in these projects.
key: ${{ runner.os }}-node22-${{ matrix.framework }}-${{ matrix.target }}-${{ hashFiles(format('ruoyi-fastapi-frontend/{0}/{1}/package.json', matrix.framework, matrix.target)) }}
restore-keys: |
${{ runner.os }}-node22-${{ matrix.framework }}-${{ matrix.target }}-
- name: Install Web dependencies
if: matrix.target == 'web'
run: npm install --no-audit --no-fund --package-lock=false
- name: Install Vue 2 Mobile dependencies
if: matrix.framework == 'vue2' && matrix.target == 'mobile'
run: yarn install --non-interactive
- name: Install Vue 3 Mobile dependencies
if: matrix.framework == 'vue3' && matrix.target == 'mobile'
run: pnpm install --no-frozen-lockfile
- name: Run unit and component behavior tests
run: npm run test:unit
- name: Verify time contracts in three host timezones
run: |
for zone in UTC Asia/Shanghai America/New_York; do
TZ="$zone" npm run test:contract
done
- name: Verify the Vue 2 Mobile browser bundle without native Intl
if: matrix.framework == 'vue2' && matrix.target == 'mobile'
run: npm run test:bundle
- name: Build Web production and Docker targets
if: matrix.target == 'web'
run: |
npm run build:prod
npm run build:docker
- name: Build Mobile H5
if: matrix.target == 'mobile'
run: npm run build:h5
- uses: actions/setup-python@v6
if: matrix.target == 'mobile'
with:
python-version: '3.12'
- name: Install the Mobile smoke browser
if: matrix.target == 'mobile'
run: |
python -m pip install playwright==1.62.0
python -m playwright install --with-deps chromium
- name: Verify Mobile H5 login and profile
if: matrix.target == 'mobile'
working-directory: .
run: python .github/scripts/mobile_smoke.py --framework ${{ matrix.framework }}
- name: Build Mobile WeChat target
if: matrix.target == 'mobile'
run: npm run build:mp-weixin
- name: Upload frontend diagnostics
if: always()
uses: actions/upload-artifact@v6
with:
name: frontend-${{ matrix.framework }}-${{ matrix.target }}
path: |
target/ci/mobile-smoke/${{ matrix.framework }}/
ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }}/test-results/
if-no-files-found: ignore
retention-days: 7
+218
View File
@@ -0,0 +1,218 @@
name: CI / Integration
on:
workflow_call:
inputs:
database:
type: boolean
default: false
release:
type: boolean
default: false
network_frameworks:
type: string
default: '[]'
full:
type: boolean
default: false
permissions:
contents: read
jobs:
database-timezone:
name: MySQL and PostgreSQL time contracts
if: inputs.database
runs-on: ubuntu-latest
timeout-minutes: 20
env:
TZ: America/New_York
PYTHONUTF8: '1'
defaults:
run:
working-directory: ruoyi-fastapi-backend
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: timezone_test
TZ: Asia/Shanghai
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost -ptimezone_test"
--health-interval=10s --health-timeout=5s --health-retries=12
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: timezone_test
TZ: America/New_York
PGTZ: America/New_York
ports: ['5432:5432']
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s --health-timeout=5s --health-retries=12
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-pg.txt -r requirements-test.txt
- name: Configure isolated database verification
run: |
mkdir -p target/ci/timezone
python - <<'PY'
import json
from pathlib import Path
for dialect, port, username, database in [('mysql', 3306, 'root', 'mysql'), ('postgresql', 5432, 'postgres', 'postgres')]:
source = dict(db_type=dialect, db_host='127.0.0.1', db_port=port, db_username=username,
db_password='timezone_test', db_database=database)
Path(f'target/ci/timezone/{dialect}.json').write_text(json.dumps(source))
PY
- name: Verify drivers, CRUD and scheduler persistence
run: |
python -m scripts.verify_timezone_database --config target/ci/timezone/mysql.json --report target/ci/timezone/mysql-report.json
python -m scripts.verify_timezone_database --config target/ci/timezone/postgresql.json --report target/ci/timezone/postgresql-report.json
- uses: actions/upload-artifact@v6
if: always()
with:
name: integration-database-timezones
path: ruoyi-fastapi-backend/target/ci/timezone/*-report.json
retention-days: 7
task-bundle:
name: Task plugin bundle
if: inputs.release
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/package.json
- run: npm install --no-audit --no-fund --package-lock=false
- run: npm test
- run: npm run build
- uses: actions/upload-artifact@v6
with:
name: integration-task-bundle
path: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dist/
if-no-files-found: error
retention-days: 1
release:
name: Plugin maintenance / Python ${{ matrix.python }}
needs: task-bundle
if: inputs.release
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
python: ${{ fromJSON(inputs.full && '["3.10","3.11","3.12","3.13"]' || '["3.12"]') }}
defaults:
run:
working-directory: ruoyi-fastapi-backend
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: ci-only-mysql
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost -pci-only-mysql"
--health-interval=10s --health-timeout=5s --health-retries=12
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: ci-only-postgres
ports: ['5432:5432']
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s --health-timeout=5s --health-retries=12
redis:
image: redis:6.2
ports: ['6379:6379']
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s --health-timeout=5s --health-retries=12
env:
RUOYI_PLUGIN_IT_ALLOW: '1'
RUOYI_IT_MYSQL_PASSWORD: ci-only-mysql
RUOYI_IT_PG_PASSWORD: ci-only-postgres
RUOYI_IT_REDIS_PASSWORD: ''
PYTHONUTF8: '1'
_TYPER_FORCE_DISABLE_TERMINAL: '1'
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v6
with:
name: integration-task-bundle
path: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dist
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-pg.txt
- name: Verify MySQL and PostgreSQL maintenance releases
shell: bash
run: |
mkdir -p target/ci/release
python scripts/plugin_release_integration.py --engine mysql 2>&1 | tee target/ci/release/mysql.log
python scripts/plugin_release_integration.py --engine postgresql 2>&1 | tee target/ci/release/postgresql.log
- uses: actions/upload-artifact@v6
if: always()
with:
name: integration-release-${{ matrix.python }}
path: ruoyi-fastapi-backend/target/ci/release/
retention-days: 7
network:
name: ${{ matrix.framework }} proxy and plugin sessions
if: inputs.network_frameworks != '[]'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
framework: ${{ fromJSON(inputs.network_frameworks) }}
defaults:
run:
working-directory: ruoyi-fastapi-backend
env:
RUOYI_PLUGIN_NETWORK_SMOKE: '1'
RUOYI_PLUGIN_FRONTEND_FRAMEWORK: ${{ matrix.framework }}
NODE_OPTIONS: ${{ matrix.framework == 'vue2' && '--openssl-legacy-provider' || '' }}
DB_SOURCES: '{"primary":{"db_type":"mysql","db_host":"127.0.0.1","db_port":3306,"db_username":"network-smoke","db_password":"network-smoke","db_database":"network-smoke"}}'
JWT_SECRET_KEY: isolated-plugin-network-smoke-ci-only
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-frontend/${{ matrix.framework }}/web/package.json
- run: python -m pip install -r requirements.txt -r requirements-test.txt playwright==1.62.0
- run: python -m playwright install --with-deps chromium
- run: npm --prefix ../ruoyi-fastapi-frontend/${{ matrix.framework }}/web install --no-audit --no-fund --package-lock=false
- name: Verify HTTPS proxy, iframe sessions and long connections
run: python -m pytest tests/plugins/integration/test_network_smoke.py -m browser --strict-markers -v --junitxml=target/plugin-network-smoke/${{ matrix.framework }}/results.xml
- run: python ../.github/scripts/check_test_report.py target/plugin-network-smoke/${{ matrix.framework }}/results.xml
- uses: actions/upload-artifact@v6
if: always()
with:
name: integration-network-${{ matrix.framework }}
path: ruoyi-fastapi-backend/target/plugin-network-smoke/${{ matrix.framework }}/
retention-days: 7
+80
View File
@@ -0,0 +1,80 @@
name: CI / Native
on:
workflow_call:
permissions:
contents: read
env:
PYTHONUTF8: '1'
_TYPER_FORCE_DISABLE_TERMINAL: '1'
jobs:
rust-quality:
name: Rust format and clippy
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- run: rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy
- run: rustup default 1.99.0
- uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/target
key: rust-quality-${{ runner.os }}-1.99.0-${{ hashFiles('**/Cargo.lock') }}
- run: cargo fmt --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --check
- run: cargo clippy --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --locked -- -D warnings
runtime:
name: ${{ matrix.os }} / Python ${{ matrix.python }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python: ['3.10', '3.11', '3.12', '3.13']
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- run: python -m pip install -r requirements.txt -r requirements-test.txt 'maturin>=1.15,<2'
- run: rustup toolchain install 1.99.0 --profile minimal
- run: rustup default 1.99.0
- uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/target
key: rust-native-${{ runner.os }}-${{ matrix.python }}-1.99.0-${{ hashFiles('**/Cargo.lock') }}
- name: Build and assemble source-free plugin
run: python scripts/build_native_plugin.py --output target/native-package
- name: Exercise real native runtime and signed artifact loading
env:
RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo
run: python -m pytest tests/plugins/native tests/plugins/core/deployment -m native --strict-markers -q --durations=15 --junitxml=target/ci/native.xml
- name: Require native tests to run without missing-artifact skips
run: python ../.github/scripts/check_test_report.py target/ci/native.xml
- uses: actions/upload-artifact@v6
if: always()
with:
name: native-${{ runner.os }}-${{ matrix.python }}
path: ruoyi-fastapi-backend/target/ci/
retention-days: 7
+54
View File
@@ -0,0 +1,54 @@
name: CI / Quality
on:
workflow_call:
permissions:
contents: read
jobs:
checks:
name: Code and CI contracts
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: .github/requirements.txt
- run: python -m pip install -r .github/requirements.txt
- name: Python lint and format
run: |
ruff check ruoyi-fastapi-backend ruoyi-fastapi-test .github/scripts .github/tests
ruff format --check ruoyi-fastapi-backend ruoyi-fastapi-test .github/scripts .github/tests
- name: Test CI routing, failure gates and deployment contracts
run: python -m pytest .github/tests -q --junitxml=target/ci/quality.xml
- name: Validate every Compose framework selection
run: python .github/scripts/check_compose.py
- name: Install actionlint
env:
ACTIONLINT_VERSION: '1.7.12'
run: |
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl --fail --location --retry 3 "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" --output "${RUNNER_TEMP}/${archive}"
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ${RUNNER_TEMP}/${archive}" | sha256sum --check
tar -xzf "${RUNNER_TEMP}/${archive}" -C "$RUNNER_TEMP" actionlint
"$RUNNER_TEMP/actionlint" -color
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-frontend/*/web/package.json
- name: Check Web formatting with the existing project configurations
run: |
for framework in vue2 vue3; do
(cd "ruoyi-fastapi-frontend/$framework/web" && npx --yes prettier@3.6.2 --check .)
done
- uses: actions/upload-artifact@v6
if: always()
with:
name: quality-reports
path: target/ci/
retention-days: 7
+106
View File
@@ -0,0 +1,106 @@
name: CI
on:
pull_request:
branches: [master]
push:
branches: [master]
schedule:
- cron: '21 18 * * *'
workflow_dispatch:
inputs:
full:
description: Run the full compatibility and end-to-end matrix
type: boolean
default: true
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
plan:
name: Plan
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
backend: ${{ steps.plan.outputs.backend }}
frontend: ${{ steps.plan.outputs.frontend }}
integration: ${{ steps.plan.outputs.integration }}
e2e: ${{ steps.plan.outputs.e2e }}
native: ${{ steps.plan.outputs.native }}
full: ${{ steps.plan.outputs.full }}
frontend_matrix: ${{ steps.plan.outputs.frontend_matrix }}
e2e_matrix: ${{ steps.plan.outputs.e2e_matrix }}
network_frameworks: ${{ steps.plan.outputs.network_frameworks }}
database: ${{ steps.plan.outputs.database }}
release: ${{ steps.plan.outputs.release }}
plan_json: ${{ steps.plan.outputs.plan_json }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: plan
run: python .github/scripts/ci_plan.py plan
quality:
name: Quality
needs: plan
uses: ./.github/workflows/ci-quality.yml
backend:
name: Backend
needs: plan
if: needs.plan.outputs.backend == 'true'
uses: ./.github/workflows/ci-backend.yml
frontend:
name: Frontend
needs: plan
if: needs.plan.outputs.frontend == 'true'
uses: ./.github/workflows/ci-frontend.yml
with:
matrix_json: ${{ needs.plan.outputs.frontend_matrix }}
full: ${{ needs.plan.outputs.full == 'true' }}
integration:
name: Integration
needs: plan
if: needs.plan.outputs.integration == 'true'
uses: ./.github/workflows/ci-integration.yml
with:
database: ${{ needs.plan.outputs.database == 'true' }}
release: ${{ needs.plan.outputs.release == 'true' }}
network_frameworks: ${{ needs.plan.outputs.network_frameworks }}
full: ${{ needs.plan.outputs.full == 'true' }}
e2e:
name: E2E
needs: plan
if: needs.plan.outputs.e2e == 'true'
uses: ./.github/workflows/ci-e2e.yml
with:
matrix_json: ${{ needs.plan.outputs.e2e_matrix }}
native:
name: Native
needs: plan
if: needs.plan.outputs.native == 'true'
uses: ./.github/workflows/ci-native.yml
result:
name: Result
if: always()
needs: [plan, quality, backend, frontend, integration, e2e, native]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- name: Require every selected category to succeed
env:
PLAN_JSON: ${{ needs.plan.outputs.plan_json }}
NEEDS_JSON: ${{ toJSON(needs) }}
run: python .github/scripts/ci_plan.py gate
-97
View File
@@ -1,97 +0,0 @@
name: Native Plugin
on:
push:
branches: [master]
paths:
- 'ruoyi-fastapi-backend/plugins/core/**'
- 'ruoyi-fastapi-backend/plugins/examples/rust/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**'
- 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py'
- 'ruoyi-fastapi-backend/scripts/build_native_plugin.py'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact*.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/command.py'
- 'ruoyi-fastapi-frontend/src/utils/pluginBridge*'
- 'ruoyi-fastapi-backend/pyproject.toml'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.py'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py'
- 'ruoyi-fastapi-backend/tests/plugins/**'
- '.github/workflows/native-plugin.yml'
pull_request:
branches: [master]
paths:
- 'ruoyi-fastapi-backend/plugins/core/**'
- 'ruoyi-fastapi-backend/plugins/examples/rust/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**'
- 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py'
- 'ruoyi-fastapi-backend/scripts/build_native_plugin.py'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact*.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py'
- 'ruoyi-fastapi-backend/cli/groups/plugin/command.py'
- 'ruoyi-fastapi-frontend/src/utils/pluginBridge*'
- 'ruoyi-fastapi-backend/pyproject.toml'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.py'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py'
- 'ruoyi-fastapi-backend/tests/plugins/**'
- '.github/workflows/native-plugin.yml'
workflow_dispatch:
jobs:
native:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python: ['3.10', '3.11', '3.12', '3.13']
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: ruoyi-fastapi-backend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements.txt
- name: Install host and build dependencies
run: python -m pip install -r requirements.txt 'maturin>=1.15,<2' pytest pytest-asyncio aiosqlite
- name: Install Rust
run: rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy
- name: Select Rust
run: rustup default 1.99.0
- name: Check Rust formatting
run: cargo fmt --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --check
- name: Verify v1 and v2 plugin scaffolds
run: python -m pytest tests/cli/runtime/plugin/test_runtime_scaffold.py tests/cli/runtime/plugin/test_runtime_scaffold_v2.py -q
- name: Verify SDK provenance and offline update commands
run: python -m pytest tests/cli/runtime/plugin/test_runtime_sdk.py tests/cli/root/test_plugin_sdk_commands.py -q
- name: Verify persistent task plugin
run: python -m pytest tests/plugins/examples/test_task_demo.py -q
- name: Build and assemble source-free plugin
run: python scripts/build_native_plugin.py --output target/native-package
- name: Check Rust code
run: cargo clippy --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --locked -- -D warnings
- name: Exercise native library through the host runtime
env:
RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo
run: python -m pytest tests/plugins/native tests/plugins/core/native tests/plugins/core/runtime/test_host_services.py tests/plugins/core/runtime/test_job_dispatcher.py -q
- name: Verify signed artifacts and maintenance releases
env:
RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo
run: python -m pytest tests/plugins/core/artifacts tests/plugins/core/deployment tests/plugins/core/management/test_release_dao.py tests/plugins/core/management/test_artifact_views.py tests/module_plugin/controller/test_plugin_release_controller.py tests/cli/root/test_plugin_artifact_commands.py tests/cli/root/test_plugin_release_wait.py tests/sql/test_plugin_release_schema.py -q
-174
View File
@@ -1,174 +0,0 @@
name: Playwright Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
mysql-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache Docker layers
uses: actions/cache@v5
with:
path: /tmp/.docker_cache
key: ${{ runner.os }}-docker-${{ matrix.python-version }}-${{ hashFiles('**/Dockerfile*', '**/requirements.txt') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create temporary Dockerfile with Python ${{ matrix.python-version }}
run: |
# Save original Dockerfile.my
cp ruoyi-fastapi-backend/Dockerfile.my ruoyi-fastapi-backend/Dockerfile.my.bak
# Create temporary Dockerfile with target Python version and default PyPI index
sed -e "s/FROM python:3.10/FROM python:${{ matrix.python-version }}/g" \
-e 's| -i https://pypi.tuna.tsinghua.edu.cn/simple||g' \
ruoyi-fastapi-backend/Dockerfile.my.bak > ruoyi-fastapi-backend/Dockerfile.my
- name: Start services with docker-compose
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.my.yml up -d --build
- name: Wait for services to be ready
run: |
cd ruoyi-fastapi-test
# Wait for backend to be running
timeout 180 bash -c 'until docker compose -f docker-compose.test.my.yml ps ruoyi-backend-my | grep -q "Up"; do sleep 5; done'
# Wait for frontend to be running
timeout 120 bash -c 'until docker compose -f docker-compose.test.my.yml ps ruoyi-frontend | grep -q "Up"; do sleep 5; done'
# Additional wait for services to be fully ready and listening on ports
sleep 30
# Check that backend is actually responding on the API endpoint
echo "Checking if backend service is ready..."
for i in {1..30}; do
if curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service is ready!"
break
fi
echo "Waiting for backend service to be ready... ($i/30)"
sleep 5
done
# Final check
if ! curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service failed to start properly. Checking logs..."
docker logs ruoyi-backend-my-test
exit 1
fi
- name: Run tests
run: |
cd ruoyi-fastapi-test
pip install -r requirements.txt
playwright install
pytest -v
- name: Stop services
if: always()
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.my.yml down
- name: Restore original Dockerfile.my
if: always()
run: |
# Restore original Dockerfile.my after test
mv ruoyi-fastapi-backend/Dockerfile.my.bak ruoyi-fastapi-backend/Dockerfile.my
pg-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache Docker layers
uses: actions/cache@v5
with:
path: /tmp/.docker_cache
key: ${{ runner.os }}-docker-${{ matrix.python-version }}-${{ hashFiles('**/Dockerfile*', '**/requirements.txt') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create temporary Dockerfile with Python ${{ matrix.python-version }}
run: |
# Save original Dockerfile.pg
cp ruoyi-fastapi-backend/Dockerfile.pg ruoyi-fastapi-backend/Dockerfile.pg.bak
# Create temporary Dockerfile with target Python version and default PyPI index
sed -e "s/FROM python:3.10/FROM python:${{ matrix.python-version }}/g" \
-e 's| -i https://pypi.tuna.tsinghua.edu.cn/simple||g' \
ruoyi-fastapi-backend/Dockerfile.pg.bak > ruoyi-fastapi-backend/Dockerfile.pg
- name: Start services with docker-compose
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.pg.yml up -d --build
- name: Wait for services to be ready
run: |
cd ruoyi-fastapi-test
# Wait for backend to be running
timeout 180 bash -c 'until docker compose -f docker-compose.test.pg.yml ps ruoyi-backend-pg | grep -q "Up"; do sleep 5; done'
# Wait for frontend to be running
timeout 120 bash -c 'until docker compose -f docker-compose.test.pg.yml ps ruoyi-frontend | grep -q "Up"; do sleep 5; done'
# Additional wait for services to be fully ready and listening on ports
sleep 30
# Check that backend is actually responding on the API endpoint
echo "Checking if backend service is ready..."
for i in {1..30}; do
if curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service is ready!"
break
fi
echo "Waiting for backend service to be ready... ($i/30)"
sleep 5
done
# Final check
if ! curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service failed to start properly. Checking logs..."
docker logs ruoyi-backend-pg-test
exit 1
fi
- name: Run tests
run: |
cd ruoyi-fastapi-test
pip install -r requirements.txt
playwright install
pytest -v
- name: Stop services
if: always()
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.pg.yml down
- name: Restore original Dockerfile.pg
if: always()
run: |
# Restore original Dockerfile.pg after test
mv ruoyi-fastapi-backend/Dockerfile.pg.bak ruoyi-fastapi-backend/Dockerfile.pg
-69
View File
@@ -1,69 +0,0 @@
name: Plugin Frontend Tests
on:
push:
branches: [master]
paths:
- 'ruoyi-fastapi-frontend/src/api/system/plugin.js'
- 'ruoyi-fastapi-frontend/src/components/PluginFrame/**'
- 'ruoyi-fastapi-frontend/src/views/system/plugin/**'
- 'ruoyi-fastapi-frontend/src/utils/**'
- 'ruoyi-fastapi-frontend/src/plugins/**'
- 'ruoyi-fastapi-frontend/src/router/**'
- 'ruoyi-fastapi-frontend/src/store/modules/permission.js'
- 'ruoyi-fastapi-frontend/src/store/modules/user.js'
- 'ruoyi-fastapi-frontend/src/permission.js'
- 'ruoyi-fastapi-frontend/plugins/**'
- 'ruoyi-fastapi-frontend/tests/plugins/**'
- 'ruoyi-fastapi-frontend/tests/run-tests.js'
- 'ruoyi-fastapi-frontend/package.json'
- 'ruoyi-fastapi-frontend/package-lock.json'
- 'ruoyi-fastapi-frontend/vite.config.js'
- 'ruoyi-fastapi-frontend/vite/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/**'
- '.github/workflows/plugin-frontend.yml'
pull_request:
branches: [master]
paths:
- 'ruoyi-fastapi-frontend/src/api/system/plugin.js'
- 'ruoyi-fastapi-frontend/src/components/PluginFrame/**'
- 'ruoyi-fastapi-frontend/src/views/system/plugin/**'
- 'ruoyi-fastapi-frontend/src/utils/**'
- 'ruoyi-fastapi-frontend/src/plugins/**'
- 'ruoyi-fastapi-frontend/src/router/**'
- 'ruoyi-fastapi-frontend/src/store/modules/permission.js'
- 'ruoyi-fastapi-frontend/src/store/modules/user.js'
- 'ruoyi-fastapi-frontend/src/permission.js'
- 'ruoyi-fastapi-frontend/plugins/**'
- 'ruoyi-fastapi-frontend/tests/plugins/**'
- 'ruoyi-fastapi-frontend/tests/run-tests.js'
- 'ruoyi-fastapi-frontend/package.json'
- 'ruoyi-fastapi-frontend/package-lock.json'
- 'ruoyi-fastapi-frontend/vite.config.js'
- 'ruoyi-fastapi-frontend/vite/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/**'
- '.github/workflows/plugin-frontend.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
plugins:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: ruoyi-fastapi-frontend
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-frontend/package.json
- name: Install frontend dependencies
# This repository does not commit npm lockfiles, so npm ci is not available.
run: npm install --no-audit --no-fund --package-lock=false
- name: Verify plugin bridge, sessions and management views
run: npm run test:plugin
@@ -1,63 +0,0 @@
name: Plugin Network Smoke
on:
pull_request:
branches: [master]
paths:
- 'ruoyi-fastapi-backend/plugins/**'
- 'ruoyi-fastapi-backend/module_plugin/**'
- 'ruoyi-fastapi-backend/module_admin/service/login_service.py'
- 'ruoyi-fastapi-backend/common/**'
- 'ruoyi-fastapi-backend/config/**'
- 'ruoyi-fastapi-backend/middlewares/**'
- 'ruoyi-fastapi-backend/utils/**'
- 'ruoyi-fastapi-backend/tests/**'
- 'ruoyi-fastapi-backend/requirements.txt'
- 'ruoyi-fastapi-backend/pyproject.toml'
- 'ruoyi-fastapi-frontend/**'
- '.github/workflows/plugin-network-smoke.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
browser:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: ruoyi-fastapi-backend
env:
RUOYI_PLUGIN_NETWORK_SMOKE: '1'
# The fixture replaces account, DB and Redis access; no external service is used.
DB_SOURCES: '{"primary":{"db_type":"mysql","db_host":"127.0.0.1","db_port":3306,"db_username":"network-smoke","db_password":"network-smoke","db_database":"network-smoke"}}'
JWT_SECRET_KEY: 'isolated-plugin-network-smoke-ci-only'
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements.txt
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
cache-dependency-path: ruoyi-fastapi-frontend/package.json
- name: Install host and browser test dependencies
run: python -m pip install -r requirements.txt pytest pytest-asyncio aiosqlite playwright
- name: Install Chromium
run: python -m playwright install --with-deps chromium
- name: Install frontend dependencies
# This repository does not commit npm lockfiles, so npm ci is not available.
run: npm --prefix ../ruoyi-fastapi-frontend install --no-audit --no-fund --package-lock=false
- name: Verify HTTPS proxy, iframe sessions and long connections
run: python -m pytest tests/plugins/integration/test_network_smoke.py -v --junitxml=target/plugin-network-smoke/results.xml
- name: Upload browser trace, screenshots and proxy log
if: always()
uses: actions/upload-artifact@v4
with:
name: plugin-network-smoke
path: ruoyi-fastapi-backend/target/plugin-network-smoke/
retention-days: 7
@@ -1,94 +0,0 @@
name: Plugin Release Integration
on:
pull_request:
branches: [master]
paths:
- 'ruoyi-fastapi-backend/plugins/core/**'
- 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**'
- 'ruoyi-fastapi-frontend/src/utils/pluginBridge.js'
- 'ruoyi-fastapi-backend/config/**'
- 'ruoyi-fastapi-backend/cli/groups/plugin/**'
- 'ruoyi-fastapi-backend/cli/runtime/plugin/**'
- 'ruoyi-fastapi-backend/module_plugin/**'
- 'ruoyi-fastapi-backend/server.py'
- 'ruoyi-fastapi-backend/app.py'
- 'ruoyi-fastapi-backend/sql/ruoyi-fastapi*.sql'
- 'ruoyi-fastapi-backend/scripts/plugin_release_integration.py'
- 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py'
- 'ruoyi-fastapi-backend/tests/plugins/**'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py'
- 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_diagnostics.py'
- 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/**'
- 'ruoyi-fastapi-backend/tests/module_plugin/**'
- 'ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py'
- 'ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py'
- 'ruoyi-fastapi-backend/pyproject.toml'
- 'ruoyi-fastapi-backend/requirements.txt'
- 'ruoyi-fastapi-backend/requirements-pg.txt'
- '.github/workflows/plugin-release-integration.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12', '3.13']
defaults:
run:
working-directory: ruoyi-fastapi-backend
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: ci-only-mysql
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost"
--health-interval=10s --health-timeout=5s --health-retries=12
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: ci-only-postgres
ports: ['5432:5432']
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s --health-timeout=5s --health-retries=12
redis:
image: redis:6.2
ports: ['6379:6379']
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s --health-timeout=5s --health-retries=12
env:
RUOYI_PLUGIN_IT_ALLOW: '1'
RUOYI_IT_MYSQL_PASSWORD: ci-only-mysql
RUOYI_IT_PG_PASSWORD: ci-only-postgres
RUOYI_IT_REDIS_PASSWORD: ''
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- name: Install host dependencies
run: python -m pip install -r requirements.txt -r requirements-pg.txt
- uses: actions/setup-node@v5
with:
node-version: '22'
- name: Test and build task plugin bundle
run: |
npm --prefix plugins/examples/python/task_demo/web install --no-audit --no-fund --package-lock=false
npm --prefix plugins/examples/python/task_demo/web test
npm --prefix plugins/examples/python/task_demo/web run build
- name: Verify MySQL and Redis maintenance releases
run: python scripts/plugin_release_integration.py --engine mysql
- name: Verify PostgreSQL and Redis maintenance releases
run: python scripts/plugin_release_integration.py --engine postgresql
-33
View File
@@ -1,33 +0,0 @@
name: Ruff Check
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
lint-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Run linting
run: |
ruff check ruoyi-fastapi-backend
ruff check ruoyi-fastapi-test
- name: Run format check
run: |
ruff format ruoyi-fastapi-backend --check
ruff format ruoyi-fastapi-test --check
-106
View File
@@ -1,106 +0,0 @@
name: Timezone Integration
on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
jobs:
databases:
runs-on: ubuntu-latest
env:
TZ: America/New_York
PYTHONUTF8: '1'
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: timezone_test
TZ: Asia/Shanghai
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h localhost -ptimezone_test"
--health-interval=10s --health-timeout=5s --health-retries=12
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: timezone_test
TZ: America/New_York
PGTZ: America/New_York
ports: ['5432:5432']
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s --health-timeout=5s --health-retries=12
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.10'
cache: pip
cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt
- name: Install verification dependencies
working-directory: ruoyi-fastapi-backend
run: python -m pip install -r requirements.txt asyncpg psycopg2-binary pytest pytest-asyncio aiosqlite
- name: Create isolated source configurations
working-directory: ruoyi-fastapi-backend
run: |
mkdir -p .cache/timezone-ci
python - <<'PY'
import json
from pathlib import Path
for dialect, port, username, database in [('mysql', 3306, 'root', 'mysql'), ('postgresql', 5432, 'postgres', 'postgres')]:
source = dict(db_type=dialect, db_host='127.0.0.1', db_port=port, db_username=username,
db_password='timezone_test', db_database=database)
Path(f'.cache/timezone-ci/{dialect}.json').write_text(json.dumps(source))
PY
- name: Verify empty databases, drivers, generated CRUD and persistent schedulers
working-directory: ruoyi-fastapi-backend
run: |
python -m scripts.verify_timezone_database --config .cache/timezone-ci/mysql.json --report .cache/timezone-ci/mysql-report.json
python -m scripts.verify_timezone_database --config .cache/timezone-ci/postgresql.json --report .cache/timezone-ci/postgresql-report.json
- uses: actions/upload-artifact@v6
if: always()
with:
name: timezone-database-reports
path: ruoyi-fastapi-backend/.cache/timezone-ci/*-report.json
include-hidden-files: true
web-app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: npm
# Lockfiles are ignored by this repository; cache by the committed manifest.
cache-dependency-path: ruoyi-fastapi-frontend/package.json
- uses: pnpm/action-setup@v5
with:
package_json_file: ruoyi-fastapi-app/package.json
- name: Install Web dependencies
working-directory: ruoyi-fastapi-frontend
run: npm install --no-package-lock
- name: Install App dependencies
working-directory: ruoyi-fastapi-app
run: pnpm install --no-frozen-lockfile
- name: Run time contracts in three host timezones
run: node ruoyi-fastapi-test/time-contract/run-host-timezones.mjs
- name: Build Web targets
working-directory: ruoyi-fastapi-frontend
run: |
npm run build:prod
npm run build:docker
- name: Build App targets
working-directory: ruoyi-fastapi-app
run: |
pnpm build:h5
pnpm build:mp-weixin
- name: Validate all Compose configurations
run: |
docker compose -f docker-compose.my.yml config --quiet
docker compose -f docker-compose.pg.yml config --quiet
docker compose -f ruoyi-fastapi-test/docker-compose.test.my.yml config --quiet
docker compose -f ruoyi-fastapi-test/docker-compose.test.pg.yml config --quiet
+122 -122
View File
@@ -1,6 +1,6 @@
# 更新日志
## RuoYi-Vue3-FastAPI v1.10.0
## RuoYi-FastAPI v1.10.0
### 项目依赖
@@ -8,68 +8,68 @@
### 新增功能
1. 新增ApiCache接口缓存装饰器和ApiCacheEvict接口缓存失效装饰器 ([#97](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/97))。
2. 新增接口限流装饰器 ([#99](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/99))。
3. 新增传输加密功能 ([#100](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/100))。
4. 新增日志脱敏功能 ([#101](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/101))。
5. 新增cli系统 ([#103](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/103))。
6. 新增文件管理功能 ([#111](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/111))。
7. 新增插件系统 ([#112](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/112))。
8. 新增自动生成jwt密钥 ([#116](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/116))。
9. 新增锁定屏幕功能 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
10. 字典类型列表新增抽屉效果详细信息 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
11. 添加持久化标签页开关功能 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
12. 支持表格列显隐状态记忆 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
13. 代码生成支持表单布局选项 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
14. 新增树分割组件TreePanel ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
15. 新增Excel导入组件ExcelImportDialog ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
16. 用户列表新增抽屉效果详细信息 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
17. 新增标签页样式chrome风格 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
18. 新增菜单路由地址和名称的校验规则 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
19. 首页新增通知公告消息提醒 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
20. 定时任务详情补充执行时间字段 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
21. 部门管理支持批量保存排序 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
22. 菜单管理支持批量保存排序 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
23. 菜单管理列表新增类型显示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
24. 新增代码生成详情页功能 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
25. 通知公告新增详细显示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
26. 通知公告新增阅读用户列表 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
27. 用户密码支持自定义配置规则 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
1. 新增ApiCache接口缓存装饰器和ApiCacheEvict接口缓存失效装饰器 ([#97](https://github.com/insistence/RuoYi-FastAPI/pull/97))。
2. 新增接口限流装饰器 ([#99](https://github.com/insistence/RuoYi-FastAPI/pull/99))。
3. 新增传输加密功能 ([#100](https://github.com/insistence/RuoYi-FastAPI/pull/100))。
4. 新增日志脱敏功能 ([#101](https://github.com/insistence/RuoYi-FastAPI/pull/101))。
5. 新增cli系统 ([#103](https://github.com/insistence/RuoYi-FastAPI/pull/103))。
6. 新增文件管理功能 ([#111](https://github.com/insistence/RuoYi-FastAPI/pull/111))。
7. 新增插件系统 ([#112](https://github.com/insistence/RuoYi-FastAPI/pull/112))。
8. 新增自动生成jwt密钥 ([#116](https://github.com/insistence/RuoYi-FastAPI/pull/116))。
9. 新增锁定屏幕功能 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
10. 字典类型列表新增抽屉效果详细信息 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
11. 添加持久化标签页开关功能 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
12. 支持表格列显隐状态记忆 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
13. 代码生成支持表单布局选项 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
14. 新增树分割组件TreePanel ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
15. 新增Excel导入组件ExcelImportDialog ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
16. 用户列表新增抽屉效果详细信息 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
17. 新增标签页样式chrome风格 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
18. 新增菜单路由地址和名称的校验规则 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
19. 首页新增通知公告消息提醒 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
20. 定时任务详情补充执行时间字段 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
21. 部门管理支持批量保存排序 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
22. 菜单管理支持批量保存排序 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
23. 菜单管理列表新增类型显示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
24. 新增代码生成详情页功能 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
25. 通知公告新增详细显示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
26. 通知公告新增阅读用户列表 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
27. 用户密码支持自定义配置规则 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
### BUG修复
1. 修复日志输出格式问题 ([#104](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/104))。
2. 修复文件服务安全性问题 ([#111](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/111))。
3. 修复windows下应用启动日志异常的问题 ([#113](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/113))。
4. 修复tui卡顿的问题 ([#114](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/114))。
5. 修复E2E测试失败的问题 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
1. 修复日志输出格式问题 ([#104](https://github.com/insistence/RuoYi-FastAPI/pull/104))。
2. 修复文件服务安全性问题 ([#111](https://github.com/insistence/RuoYi-FastAPI/pull/111))。
3. 修复windows下应用启动日志异常的问题 ([#113](https://github.com/insistence/RuoYi-FastAPI/pull/113))。
4. 修复tui卡顿的问题 ([#114](https://github.com/insistence/RuoYi-FastAPI/pull/114))。
5. 修复E2E测试失败的问题 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
### 代码优化
1. 补充演示模式限制路由 ([#115](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/115))。
2. 优化字典类型属性提醒说明 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
3. 优化顶部导航更多菜单路由截取 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
4. 优化RightToolbar搜索栏切换动画 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
5. 优化操作日志详细页面 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
7. 优化页签功能&支持全屏按钮操作 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
8. 优化tag全屏为页签内区域 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
9. 菜单搜索支持文本高亮&数量提示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
10. 优化页签显示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
11. 优化菜单主题风格 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
12. 黑暗模式下全屏不需要设置背景 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
13. 暗黑浅色主题优化 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
14. 优化快速点击页签刷新出现404问题 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
15. 代码生成修改拖拽时显示手指样式 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
16. 优化登录页暗黑模式下样式 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
17. 优化白名单支持对通配符路径匹配 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
18. 优化定时任务详情页展示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
19. 优化点击任务名称查看详细 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
20. 更换router4过期方法next ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
21. 优化暗黑模式下主题样式 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
22. 优化暗黑模式详情样式 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。
23. 优化jwt解析异常处理机制 ([#118](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/118))。
1. 补充演示模式限制路由 ([#115](https://github.com/insistence/RuoYi-FastAPI/pull/115))。
2. 优化字典类型属性提醒说明 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
3. 优化顶部导航更多菜单路由截取 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
4. 优化RightToolbar搜索栏切换动画 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
5. 优化操作日志详细页面 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
7. 优化页签功能&支持全屏按钮操作 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
8. 优化tag全屏为页签内区域 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
9. 菜单搜索支持文本高亮&数量提示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
10. 优化页签显示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
11. 优化菜单主题风格 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
12. 黑暗模式下全屏不需要设置背景 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
13. 暗黑浅色主题优化 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
14. 优化快速点击页签刷新出现404问题 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
15. 代码生成修改拖拽时显示手指样式 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
16. 优化登录页暗黑模式下样式 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
17. 优化白名单支持对通配符路径匹配 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
18. 优化定时任务详情页展示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
19. 优化点击任务名称查看详细 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
20. 更换router4过期方法next ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
21. 优化暗黑模式下主题样式 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
22. 优化暗黑模式详情样式 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。
23. 优化jwt解析异常处理机制 ([#118](https://github.com/insistence/RuoYi-FastAPI/pull/118))。
## RuoYi-Vue3-FastAPI v1.9.0
## RuoYi-FastAPI v1.9.0
### 项目依赖
@@ -77,52 +77,52 @@
### 新增功能
1. 新增AI管理模块 ([#69](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/69))。
2. 新增移动端模块 ([#73](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/73))。
3. 新增多worker运行支持 ([#76](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/76))。
4. 应用新增演示模式 ([#78](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/78))。
1. 新增AI管理模块 ([#69](https://github.com/insistence/RuoYi-FastAPI/pull/69))。
2. 新增移动端模块 ([#73](https://github.com/insistence/RuoYi-FastAPI/pull/73))。
3. 新增多worker运行支持 ([#76](https://github.com/insistence/RuoYi-FastAPI/pull/76))。
4. 应用新增演示模式 ([#78](https://github.com/insistence/RuoYi-FastAPI/pull/78))。
### BUG修复
1. 修复代码生成controller模板删除接口query_db参数异常的问题 ([#63](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/63))。
2. 修复登录接口response_model声明错误 ([#71](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/71))。
3. 修复无法直接通过后端地址访问API文档的问题 ([#74](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/74))。
4. 修复create_app重复执行的问题 ([#84](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/84))。
1. 修复代码生成controller模板删除接口query_db参数异常的问题 ([#63](https://github.com/insistence/RuoYi-FastAPI/pull/63))。
2. 修复登录接口response_model声明错误 ([#71](https://github.com/insistence/RuoYi-FastAPI/pull/71))。
3. 修复无法直接通过后端地址访问API文档的问题 ([#74](https://github.com/insistence/RuoYi-FastAPI/pull/74))。
4. 修复create_app重复执行的问题 ([#84](https://github.com/insistence/RuoYi-FastAPI/pull/84))。
### 代码重构
1. 移除对python3.9的支持 ([#67](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/67))。
1. 移除对python3.9的支持 ([#67](https://github.com/insistence/RuoYi-FastAPI/pull/67))。
### 代码优化
1. 优化alembic处理表模型逻辑,避免无关表影响 ([#68](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/68))。
2. 优化代码生成后端模板 ([#72](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/72))。
3. 自动注册路由出错时抛出异常以便于调试 ([#79](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/79))。
1. 优化alembic处理表模型逻辑,避免无关表影响 ([#68](https://github.com/insistence/RuoYi-FastAPI/pull/68))。
2. 优化代码生成后端模板 ([#72](https://github.com/insistence/RuoYi-FastAPI/pull/72))。
3. 自动注册路由出错时抛出异常以便于调试 ([#79](https://github.com/insistence/RuoYi-FastAPI/pull/79))。
4. 优化部分页面字段tooltip说明 (#80)。
5. 优化项目启动速度 ([#82](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/82))。
6. 优化暗黑模式切换效果 ([#83](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/83))。
7. 优化热重载模式或单worker下scheduler的任务状态同步机制 ([#85](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/85))。
8. 优化防重提交间隔时间可自定义 ([#87](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/87))。
9. 优化验证码计算结果为非负数 ([#88](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/88))。
10. 优化ci测试稳定性 ([#90](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/90))。
5. 优化项目启动速度 ([#82](https://github.com/insistence/RuoYi-FastAPI/pull/82))。
6. 优化暗黑模式切换效果 ([#83](https://github.com/insistence/RuoYi-FastAPI/pull/83))。
7. 优化热重载模式或单worker下scheduler的任务状态同步机制 ([#85](https://github.com/insistence/RuoYi-FastAPI/pull/85))。
8. 优化防重提交间隔时间可自定义 ([#87](https://github.com/insistence/RuoYi-FastAPI/pull/87))。
9. 优化验证码计算结果为非负数 ([#88](https://github.com/insistence/RuoYi-FastAPI/pull/88))。
10. 优化ci测试稳定性 ([#90](https://github.com/insistence/RuoYi-FastAPI/pull/90))。
## RuoYi-Vue3-FastAPI v1.8.1
## RuoYi-FastAPI v1.8.1
### 新增功能
1. 新增E2E测试 ([#57](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/57))。
1. 新增E2E测试 ([#57](https://github.com/insistence/RuoYi-FastAPI/pull/57))。
### BUG修复
1. 修复DictTag组件渲染异常的问题 ([#59](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/59))。
1. 修复DictTag组件渲染异常的问题 ([#59](https://github.com/insistence/RuoYi-FastAPI/pull/59))。
### 代码优化
1. 优化数据权限依赖 ([#55](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/55))。
2. 动态导入定时任务函数,移除eval ([#56](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/56))。
3. 优化pg版本的docker compose配置文件 ([#61](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/61))。
1. 优化数据权限依赖 ([#55](https://github.com/insistence/RuoYi-FastAPI/pull/55))。
2. 动态导入定时任务函数,移除eval ([#56](https://github.com/insistence/RuoYi-FastAPI/pull/56))。
3. 优化pg版本的docker compose配置文件 ([#61](https://github.com/insistence/RuoYi-FastAPI/pull/61))。
## RuoYi-Vue3-FastAPI v1.8.0
## RuoYi-FastAPI v1.8.0
### 项目依赖
@@ -143,8 +143,8 @@
### BUG修复
1. 修复单账号登录模式下强退功能失效的问题 [#52](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/52)。
2. 确保ApschedulerJobs字段类型与apscheduler默认创建的表字段类型一致 [#53](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/53)。
1. 修复单账号登录模式下强退功能失效的问题 [#52](https://github.com/insistence/RuoYi-FastAPI/issues/52)。
2. 确保ApschedulerJobs字段类型与apscheduler默认创建的表字段类型一致 [#53](https://github.com/insistence/RuoYi-FastAPI/issues/53)。
3. 修复磁盘存在异常时服务监控无法正常运行的问题。
4. 移除代码生成表业务表外键,修复无法删除的问题。
5. 修复固定头部时出现的导航栏偏移问题。
@@ -175,7 +175,7 @@
14. 优化字典组件值宽松匹配。
15. 默认固定头部。
## RuoYi-Vue3-FastAPI v1.7.1
## RuoYi-FastAPI v1.7.1
### 项目依赖
@@ -184,13 +184,13 @@
### BUG修复
1. 修复代码生成controller模板编辑接口异常生成字段的问题。
2. 移除passlib直接使用bcrypt修复密码校验异常的问题 [#48](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/48) [#49](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/49)。
2. 移除passlib直接使用bcrypt修复密码校验异常的问题 [#48](https://github.com/insistence/RuoYi-FastAPI/issues/48) [#49](https://github.com/insistence/RuoYi-FastAPI/issues/49)。
### 代码优化
1. 代码生成do模板补充表描述。
## RuoYi-Vue3-FastAPI v1.7.0
## RuoYi-FastAPI v1.7.0
### 项目依赖
@@ -221,7 +221,7 @@
### BUG修复
1. 修复logout接口未按照app_same_time_login配置项动态判断的问题 [#IBZZ1S](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/IBZZ1S)。
1. 修复logout接口未按照app_same_time_login配置项动态判断的问题 [#IBZZ1S](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/IBZZ1S)。
2. 修复上传组件被多次引用拖动仅对第一个有效的问题。
### 代码优化
@@ -241,7 +241,7 @@
2. 调整do与sql使其相互适配以支持alembic。
3. 富文本复制粘贴图片上传至url。
## RuoYi-Vue3-FastAPI v1.6.2
## RuoYi-FastAPI v1.6.2
### 新增功能
@@ -250,20 +250,20 @@
### BUG修复
1. 修复日志管理时间查询报错 [#27](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/27)。
2. 修复定时任务状态暂停时执行单次任务会触发cron表达式的问题 [#31](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/31)。
1. 修复日志管理时间查询报错 [#27](https://github.com/insistence/RuoYi-FastAPI/issues/27)。
2. 修复定时任务状态暂停时执行单次任务会触发cron表达式的问题 [#31](https://github.com/insistence/RuoYi-FastAPI/issues/31)。
3. 修复修改字典类型时获取dict_code异常的问题。
4. 修复修改字典类型时字典数据更新时间异常的问题。
5. 修复代码生成模板时间查询问题 [#28](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/28)。
5. 修复代码生成模板时间查询问题 [#28](https://github.com/insistence/RuoYi-FastAPI/issues/28)。
6. 修复用户导出缺失部门名称的问题。
### 代码优化
1. 优化代码生成新增和编辑字段显示和渲染。
2. pagination更换成flex布局。
3. 优化代码生成vue模板 [#23](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/23)。
3. 优化代码生成vue模板 [#23](https://github.com/insistence/RuoYi-FastAPI/issues/23)。
## RuoYi-Vue3-FastAPI v1.6.1
## RuoYi-FastAPI v1.6.1
### 项目依赖
@@ -282,7 +282,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
3. 引入泛型修复as_query和as_form装饰模型文档丢失的问题。
4. 修复代码生成主子表vo模板可能缺失NotBlank的问题。
## RuoYi-Vue3-FastAPI v1.6.0
## RuoYi-FastAPI v1.6.0
### 项目依赖
@@ -304,7 +304,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
1. 修复默认关闭Tags-Views时,内链页面打不开。
2. 修复删除当前登录用户拦截失效的问题。
3. 修复定时任务目标字符串规则校验不全的问题。
4. 修复执行单次任务时会覆盖已启用任务的问题 [#IBEKD2](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/IBEKD2)。
4. 修复执行单次任务时会覆盖已启用任务的问题 [#IBEKD2](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/IBEKD2)。
5. 修复个人中心特殊字符密码修改失败问题。
### 代码优化
@@ -317,7 +317,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
6. 优化TopNav内链菜单点击没有高亮。
7. ResponseUtil补充完整参数。
## RuoYi-Vue3-FastAPI v1.5.1
## RuoYi-FastAPI v1.5.1
### 新增功能
@@ -329,7 +329,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
2. 校检文件名是否包含特殊字符。
3. 移除已弃用的log_decorator装饰器。
## RuoYi-Vue3-FastAPI v1.5.0
## RuoYi-FastAPI v1.5.0
### 新增功能
@@ -337,7 +337,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
### BUG修复
1. 修复DictTag组件控制台抛异常的问题 [#IAYSVZ](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/IAYSVZ)。
1. 修复DictTag组件控制台抛异常的问题 [#IAYSVZ](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/IAYSVZ)。
2. 修复登录日志导出文件名称错误的问题。
### 代码回滚
@@ -351,7 +351,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
3. 优化参数设置页面。
4. 优化上传图片带域名时不增加前缀。
## RuoYi-Vue3-FastAPI v1.4.0
## RuoYi-FastAPI v1.4.0
### 项目依赖
@@ -375,7 +375,7 @@ pip install fastapi[all]==0.115.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
1. 优化前端登录请求方法。
## RuoYi-Vue3-FastAPI v1.3.3
## RuoYi-FastAPI v1.3.3
### 项目依赖
@@ -392,7 +392,7 @@ pip install fastapi[all]==0.115.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
1. 优化在线用户模块前后端字段描述一致。
2. 日志装饰器异常处理增加logger打印日志。
## RuoYi-Vue3-FastAPI v1.3.2
## RuoYi-FastAPI v1.3.2
### 新增功能
@@ -400,14 +400,14 @@ pip install fastapi[all]==0.115.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
### BUG修复
1. 修复分页函数计算has_next错误的问题 [#10](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/10)。
1. 修复分页函数计算has_next错误的问题 [#10](https://github.com/insistence/RuoYi-FastAPI/issues/10)。
2. 修复定时任务监听函数中事件没有job_id报错的问题。
### 代码优化
1. 优化添加中间件函数注释。
## RuoYi-Vue3-FastAPI v1.3.1
## RuoYi-FastAPI v1.3.1
### BUG修复
@@ -417,7 +417,7 @@ pip install fastapi[all]==0.115.0 -i https://pypi.tuna.tsinghua.edu.cn/simple
1. 补充定时任务违规字符串。
## RuoYi-Vue3-FastAPI v1.3.0
## RuoYi-FastAPI v1.3.0
### 项目依赖
@@ -459,14 +459,14 @@ ALTER TABLE sys_menu ADD COLUMN route_name varchar(50) DEFAULT '';
3. 各模块`vo`层使用`Field`声明字段。
4. 优化API文档字段描述显示。
## RuoYi-Vue3-FastAPI v1.2.2
## RuoYi-FastAPI v1.2.2
### BUG修复
1. 修复删除定时任务时未移除调度中任务的问题。
2. 修复菜单生成路由时组件条件判断错误的问题。
## RuoYi-Vue3-FastAPI v1.2.1
## RuoYi-FastAPI v1.2.1
### BUG修复
@@ -477,7 +477,7 @@ ALTER TABLE sys_menu ADD COLUMN route_name varchar(50) DEFAULT '';
1. 修改代理localhost为127.0.0.1以适配部分设备解析localhost异常的问题。
## RuoYi-Vue3-FastAPI v1.2.0
## RuoYi-FastAPI v1.2.0
### 重要说明
@@ -505,7 +505,7 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。
1. 修复用户管理模块重置用户密码时会异常重置用户岗位和角色的问题。
2. 修复清空定时任务日志异常的问题。
## RuoYi-Vue3-FastAPI v1.1.3
## RuoYi-FastAPI v1.1.3
### 新增功能
@@ -516,7 +516,7 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。
1. 修复通知公告列表查询前后端字段不一致的问题。
2. 修复个人中心修改基本资料后端异常的问题。
## RuoYi-Vue3-FastAPI v1.1.2
## RuoYi-FastAPI v1.1.2
### 新增功能
@@ -530,17 +530,17 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。
1. 使用@lru_cache缓存ip归属区域查询结果,避免重复调用ip归属区域查询接口以优化性能。
## RuoYi-Vue3-FastAPI v1.1.1
## RuoYi-FastAPI v1.1.1
### BUG修复
1. 修复编辑定时任务时更新的信息未同步至scheduler的问题 [#I9EK56](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I9EK56)。
2. 修复编辑角色数据权限时后端异常的问题 [#I9ENQN](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I9ENQN)。
1. 修复编辑定时任务时更新的信息未同步至scheduler的问题 [#I9EK56](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I9EK56)。
2. 修复编辑角色数据权限时后端异常的问题 [#I9ENQN](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I9ENQN)。
3. 修复菜单配置路由参数不生效的问题。
4. 修复获取路由信息时菜单排序不生效的问题。
5. 修复添加菜单时是否外链和是否缓存回显异常的问题。
## RuoYi-Vue3-FastAPI v1.1.0
## RuoYi-FastAPI v1.1.0
### 新增功能
@@ -550,10 +550,10 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。
### BUG修复
1. 修复token本身过期时退出登录接口异常的问题 [#I9CBWT](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I9CBWT)。
2. 修复系统版本号或浏览器版本号无法获取时登录异常的问题 [#I9CYNM](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I9CYNM)。
1. 修复token本身过期时退出登录接口异常的问题 [#I9CBWT](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I9CBWT)。
2. 修复系统版本号或浏览器版本号无法获取时登录异常的问题 [#I9CYNM](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I9CYNM)。
## RuoYi-Vue3-FastAPI v1.0.3
## RuoYi-FastAPI v1.0.3
### 新增功能
@@ -561,10 +561,10 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。
### BUG修复
1. 修复外链菜单无法打开的问题 [#I95KBK](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I95KBK)。
2. 修复添加和编辑菜单页面中是否缓存和是否外链字段回显异常的问题 [#I95KBK](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I95KBK)。
1. 修复外链菜单无法打开的问题 [#I95KBK](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I95KBK)。
2. 修复添加和编辑菜单页面中是否缓存和是否外链字段回显异常的问题 [#I95KBK](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I95KBK)。
## RuoYi-Vue3-FastAPI v1.0.2
## RuoYi-FastAPI v1.0.2
### 新增功能
@@ -579,7 +579,7 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。
1. 调整参数设置、部门管理、字典管理、定时任务、日志管理、角色管理、菜单管理模块部分接口权限标识。
## RuoYi-Vue3-FastAPI v1.0.1
## RuoYi-FastAPI v1.0.1
### 项目依赖
@@ -595,9 +595,9 @@ pip install fastapi[all]==0.109.1 -i https://mirrors.aliyun.com/pypi/simple/
1. 日志管理模块新增字段排序查询。
## RuoYi-Vue3-FastAPI v1.0.0
## RuoYi-FastAPI v1.0.0
RuoYi-Vue3-FastAPI第一个版本发布啦!
RuoYi-FastAPI第一个版本发布啦!
此版本功能如下:
1. 用户管理:用户是系统操作者,该功能主要完成系统用户配置。
2. 角色管理:角色菜单权限分配。
+66 -32
View File
@@ -1,28 +1,25 @@
<h1 align="center">
<img alt="logo" src="./ruoyi-fastapi-frontend/src/assets/logo/logo.png">
<img alt="logo" src="./ruoyi-fastapi-frontend/vue3/web/src/assets/logo/logo.png">
</h1>
<h1 align="center" style="margin: 30px 0 30px; font-weight: bold;">RuoYi-Vue3-FastAPI</h1>
<h4 align="center">基于RuoYi-Vue3+FastAPI前后端分离的快速开发框架</h4>
<h1 align="center" style="margin: 30px 0 30px; font-weight: bold;">RuoYi-FastAPI</h1>
<h4 align="center">基于 FastAPI 的前后端分离快速开发框架</h4>
<p align="center">
<a href="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/stargazers">
<img alt="Gitee" src="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/badge/star.svg?theme=dark">
<a href="https://gitee.com/insistence2022/RuoYi-FastAPI/stargazers">
<img alt="Gitee" src="https://gitee.com/insistence2022/RuoYi-FastAPI/badge/star.svg?theme=dark">
</a>
<a href="https://github.com/insistence/RuoYi-Vue3-FastAPI">
<img alt="Github" src="https://img.shields.io/github/stars/insistence/RuoYi-Vue3-FastAPI?style=social">
<a href="https://github.com/insistence/RuoYi-FastAPI">
<img alt="Github" src="https://img.shields.io/github/stars/insistence/RuoYi-FastAPI?style=social">
</a>
<a href="https://github.com/insistence/RuoYi-Vue3-FastAPI/actions?query=branch%3Amaster+event%3Apush+workflow%3A%22%22Playwright+Tests%22%22">
<img alt="Playwright Tests" src="https://github.com/insistence/RuoYi-Vue3-FastAPI/workflows/Playwright Tests/badge.svg">
<a href="https://github.com/insistence/RuoYi-FastAPI/actions/workflows/ci.yml">
<img alt="CI" src="https://github.com/insistence/RuoYi-FastAPI/actions/workflows/ci.yml/badge.svg?branch=master">
</a>
<a href="https://github.com/insistence/RuoYi-Vue3-FastAPI/actions?query=branch%3Amaster+event%3Apush+workflow%3A%22%22Ruff+Check%22%22">
<img alt="Ruff Check" src="https://github.com/insistence/RuoYi-Vue3-FastAPI/workflows/Ruff Check/badge.svg">
</a>
<a href="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI">
<a href="https://gitee.com/insistence2022/RuoYi-FastAPI">
<img alt="project version" src="https://img.shields.io/badge/version-1.10.0-brightgreen.svg">
</a>
<a href="https://github.com/astral-sh/ruff">
<img alt="Ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json">
</a>
<a href="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/blob/master/LICENSE">
<a href="https://gitee.com/insistence2022/RuoYi-FastAPI/blob/master/LICENSE">
<img alt="LICENSE" src="https://img.shields.io/github/license/mashape/apistatus.svg">
</a>
<img alt="node version" src="https://img.shields.io/badge/node-≥18-blue">
@@ -33,16 +30,13 @@
## 平台简介
RuoYi-Vue3-FastAPI是一套全部开源的快速开发平台,毫无保留给个人及企业免费使用。
RuoYi-FastAPI是一套全部开源的快速开发平台,毫无保留给个人及企业免费使用。
* 前端采用Vue3、Element Plus,基于<u>[RuoYi-Vue3](https://github.com/yangzongzhuan/RuoYi-Vue3)</u>前端项目修改。
* 移动端采用uni-app、Vue3、Vite,内置tailwindcss,基于<u>[RuoYi-App](https://github.com/yangzongzhuan/RuoYi-App)</u>项目修改。
* Web 前端提供 Vue2 + Element UI 和 Vue3 + Element Plus 两套工程,分别基于<u>[RuoYi-Vue](https://github.com/yangzongzhuan/RuoYi-Vue)</u>和<u>[RuoYi-Vue3](https://github.com/yangzongzhuan/RuoYi-Vue3)</u>修改。
* 移动端提供 uni-app 的 Vue2、Vue3 两套工程,内置 tailwindcss,基于<u>[RuoYi-App](https://github.com/yangzongzhuan/RuoYi-App)</u>项目修改。
* 后端采用FastAPI、sqlalchemy、MySQL(PostgreSQL)、Redis、OAuth2 & Jwt。
* 权限认证使用OAuth2 & Jwt,支持多终端认证系统。
* 支持加载动态权限菜单,多方式轻松权限控制。
* Vue2版本:
* Gitte仓库地址:<https://gitee.com/insistence2022/RuoYi-Vue-FastAPI>
* GitHub仓库地址:<https://github.com/insistence/RuoYi-Vue-FastAPI>
* 纯Python版本:
* Gitte仓库地址:<https://gitee.com/insistence2022/dash-fastapi-admin>
* GitHub仓库地址:<https://github.com/insistence/Dash-FastAPI-Admin>
@@ -200,6 +194,23 @@ RuoYi-Vue3-FastAPI是一套全部开源的快速开发平台,毫无保留给
## 项目开发及发布相关
### 工程目录
前端目录约定为 `ruoyi-fastapi-frontend/<framework>/{web,mobile}`,其中 `<framework>` 是前端框架标识,当前为 `vue2`、`vue3`。
```text
RuoYi-FastAPI/
├── ruoyi-fastapi-backend/ # 共用 FastAPI 后端
├── ruoyi-fastapi-frontend/
│ ├── vue2/
│ │ ├── web/ # Vue2 + Element UI
│ │ └── mobile/ # uni-app + Vue2
│ └── vue3/
│ ├── web/ # Vue3 + Element Plus + Vite
│ └── mobile/ # uni-app + Vue3 + Vite
└── ruoyi-fastapi-test/ # 端到端与跨工程契约测试
```
### 传输层加解密配置说明
后端密钥配置与轮换说明:[ruoyi-fastapi-backend/docs/transport_crypto_config.md](./ruoyi-fastapi-backend/docs/transport_crypto_config.md)
@@ -208,17 +219,18 @@ RuoYi-Vue3-FastAPI是一套全部开源的快速开发平台,毫无保留给
```bash
# 克隆项目
git clone https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI.git
git clone https://gitee.com/insistence2022/RuoYi-FastAPI.git
# 进入项目根目录
cd RuoYi-Vue3-FastAPI
cd RuoYi-FastAPI
```
#### 前端
#### Web 前端
```bash
# 进入前端目录
cd ruoyi-fastapi-frontend
# 从仓库根目录进入 Vue3 Web 工程
cd ruoyi-fastapi-frontend/vue3/web
# 使用 Vue2 时改为:cd ruoyi-fastapi-frontend/vue2/web
# 安装依赖
npm install 或 yarn --registry=https://registry.npmmirror.com
@@ -230,11 +242,15 @@ npm install --registry=https://registry.npmmirror.com
npm run dev 或 yarn dev
```
#### 移动端
两个 Web 工程默认都使用 `80` 端口,开发代理均指向同一个 `9099` 后端。同时运行时,请分别配置不同的前端端口。
Vue2 Web 使用 Vue CLI 4 / webpack 4,在 Node.js 18 及以上启动或构建前,需要设置 `NODE_OPTIONS=--openssl-legacy-provider`:PowerShell 执行 `$env:NODE_OPTIONS = '--openssl-legacy-provider'`,Bash 执行 `export NODE_OPTIONS=--openssl-legacy-provider`。Docker 和构建 CI 已配置此选项。
#### Vue3 移动端
```bash
# 进入移动端目录
cd ruoyi-fastapi-app
# 从仓库根目录进入移动端工程
cd ruoyi-fastapi-frontend/vue3/mobile
# 安装依赖
npm install -g pnpm
@@ -247,7 +263,21 @@ pnpm dev:h5
pnpm dev:mp-weixin
```
移动端详细文档请参考:[ruoyi-fastapi-app/README.md](./ruoyi-fastapi-app/README.md)
#### Vue2 移动端
```bash
# 从仓库根目录进入移动端工程
cd ruoyi-fastapi-frontend/vue2/mobile
yarn install
# 启动 H5
yarn dev:h5
# 启动微信小程序
yarn dev:mp-weixin
```
移动端详细文档请参考:[Vue3 移动端](./ruoyi-fastapi-frontend/vue3/mobile/README.md)、[Vue2 移动端](./ruoyi-fastapi-frontend/vue2/mobile/README.md)。
#### 后端
@@ -289,7 +319,9 @@ ruoyi app run --env=dev
### 发布
#### 前端
#### Web 前端
先进入要发布的 `ruoyi-fastapi-frontend/vue2/web` 或 `ruoyi-fastapi-frontend/vue3/web` 工程,再运行:
```bash
# 构建测试环境
@@ -313,16 +345,18 @@ ruoyi app run --env=prod
> ⚠️ **警告:** 默认未做数据持久化配置,请注意数据备份或自行配置持久化
Compose 通过前端框架标识选择 Web 工程。仓库根目录的 `docker.env` 默认设置 `FRONTEND_FRAMEWORK=vue3`,当前可选值为 `vue2`、`vue3`;改为 `vue2` 可切换到 Vue2 Web。执行下方命令时,通过 `--env-file docker.env` 显式加载配置。该变量同时选择 `ruoyi-fastapi-frontend/<framework>/web` 构建目录与对应的 nginx 配置,并通过 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK` 传入后端,使插件依赖检查和安装使用相同框架分类。各框架共用后端服务与数据库配置;本次部署只构建所选 Web 工程,不会构建移动端。
#### MySQL版本
```bash
docker compose -f docker-compose.my.yml up -d --build
docker compose --env-file docker.env -f docker-compose.my.yml up -d --build
```
#### PostgreSQL版本
```bash
docker compose -f docker-compose.pg.yml up -d --build
docker compose --env-file docker.env -f docker-compose.pg.yml up -d --build
```
## 交流与赞助
+3 -2
View File
@@ -2,7 +2,7 @@ services:
# 前端服务
ruoyi-frontend:
build:
context: ./ruoyi-fastapi-frontend
context: ./ruoyi-fastapi-frontend/${FRONTEND_FRAMEWORK:-vue3}/web
dockerfile: Dockerfile
image: ruoyi-frontend:latest
container_name: ruoyi-frontend
@@ -11,7 +11,7 @@ services:
ports:
- "12580:80"
volumes:
- ./ruoyi-fastapi-frontend/bin/nginx.dockermy.conf:/etc/nginx/conf.d/default.conf
- ./ruoyi-fastapi-frontend/${FRONTEND_FRAMEWORK:-vue3}/web/bin/nginx.dockermy.conf:/etc/nginx/conf.d/default.conf
depends_on:
- ruoyi-backend-my
networks:
@@ -26,6 +26,7 @@ services:
container_name: ruoyi-backend-my
environment:
TZ: UTC
RUOYI_PLUGIN_FRONTEND_FRAMEWORK: ${FRONTEND_FRAMEWORK:-vue3}
ports:
- "19099:9099"
depends_on:
+3 -2
View File
@@ -2,7 +2,7 @@ services:
# 前端服务
ruoyi-frontend:
build:
context: ./ruoyi-fastapi-frontend
context: ./ruoyi-fastapi-frontend/${FRONTEND_FRAMEWORK:-vue3}/web
dockerfile: Dockerfile
image: ruoyi-frontend:latest
container_name: ruoyi-frontend
@@ -11,7 +11,7 @@ services:
ports:
- "12580:80"
volumes:
- ./ruoyi-fastapi-frontend/bin/nginx.dockerpg.conf:/etc/nginx/conf.d/default.conf
- ./ruoyi-fastapi-frontend/${FRONTEND_FRAMEWORK:-vue3}/web/bin/nginx.dockerpg.conf:/etc/nginx/conf.d/default.conf
depends_on:
- ruoyi-backend-pg
networks:
@@ -26,6 +26,7 @@ services:
container_name: ruoyi-backend-pg
environment:
TZ: UTC
RUOYI_PLUGIN_FRONTEND_FRAMEWORK: ${FRONTEND_FRAMEWORK:-vue3}
ports:
- "19099:9099"
depends_on:
+1
View File
@@ -0,0 +1 @@
FRONTEND_FRAMEWORK=vue3
-3
View File
@@ -1,3 +0,0 @@
import * as time from '../src/utils/time.js'
import { testTimeContract } from '../../ruoyi-fastapi-test/time-contract/contract.mjs'
testTimeContract(time)
+12
View File
@@ -0,0 +1,12 @@
.git
.venv
venv
__pycache__
*.pyc
.pytest_cache
.ruff_cache
.cache
target
logs
*.log
*.egg-info
+4 -2
View File
@@ -1,12 +1,14 @@
FROM python:3.10
ARG PYTHON_VERSION=3.10
FROM python:${PYTHON_VERSION}
ENV TZ=UTC
WORKDIR /app
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# 复制源代码
COPY . .
# 安装依赖
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
RUN pip install --no-cache-dir -r requirements.txt -i ${PIP_INDEX_URL}
# 暴露端口
EXPOSE 9099
+4 -2
View File
@@ -1,12 +1,14 @@
FROM python:3.10
ARG PYTHON_VERSION=3.10
FROM python:${PYTHON_VERSION}
ENV TZ=UTC
WORKDIR /app
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
# 复制源代码
COPY . .
# 安装依赖
RUN pip install --no-cache-dir -r requirements-pg.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
RUN pip install --no-cache-dir -r requirements-pg.txt -i ${PIP_INDEX_URL}
# 暴露端口
EXPOSE 9099
@@ -1,11 +1,11 @@
from collections.abc import Callable
from typing import Annotated, Any, Literal
from typing import Annotated, Any
import typer
from cli.context import DryRunOption, EnvOption, OutputOption
from ..options import PluginCreateCommandOptions
from ..options import PluginCreateCommandOptions, PluginFrontendFrameworkChoice
def register_developer_commands(app: typer.Typer, get_controller: Callable[[], Any]) -> None:
@@ -63,9 +63,11 @@ def register_developer_commands(app: typer.Typer, get_controller: Callable[[], A
'v2:python-asgi、python-bundle、rust-asgi、rust-bundle(生成独立源码工程)',
),
] = 'full-stack',
frontend_version: Annotated[
Literal['auto', 'vue2', 'vue3'],
typer.Option('--frontend-version', help='v1 前端 Vue 版本:auto、vue2、vue3;v2 bundle 不使用宿主 Vue'),
frontend_framework: Annotated[
PluginFrontendFrameworkChoice,
typer.Option(
'--frontend-framework', help='宿主框架,auto 自动识别;v1 源码模板仅支持 vue2、vue3,v2 模板仅接受 auto'
),
] = 'auto',
backend_only: Annotated[bool, typer.Option('--backend-only', help='只创建后端插件模板')] = False,
frontend_only: Annotated[bool, typer.Option('--frontend-only', help='只创建前端插件模板')] = False,
@@ -83,7 +85,7 @@ def register_developer_commands(app: typer.Typer, get_controller: Callable[[], A
:param env: 当前命令运行环境
:param output: 输出格式
:param template: 插件模板名称
:param frontend_version: 前端 Vue 版本
:param frontend_framework: 前端框架标识,auto 表示自动识别
:param backend_only: 是否只创建后端插件模板
:param frontend_only: 是否只创建前端插件模板
:param no_migration: 是否不创建 migration 示例
@@ -102,7 +104,7 @@ def register_developer_commands(app: typer.Typer, get_controller: Callable[[], A
backend_only=backend_only,
frontend_only=frontend_only,
template=template,
frontend_version=frontend_version,
frontend_framework=frontend_framework,
no_migration=no_migration,
no_seed=no_seed,
no_job=no_job,
@@ -1,6 +1,5 @@
import importlib
import json
import os
from pathlib import Path
from typing import Annotated, Any
@@ -10,9 +9,15 @@ from cli.context import CliContext, DryRunOption, OutputOption
from cli.core import DEFAULT_CORE_SERVICES
from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, RUNTIME_ERROR, SUCCESS
from ..options import PluginFrontendFrameworkChoice
FrontendRootOption = Annotated[
Path | None,
typer.Option('--frontend-root', help='作为 SDK 来源的宿主前端目录,默认相邻前端或 RUOYI_PLUGIN_FRONTEND_ROOT'),
typer.Option('--frontend-root', help='SDK 宿主工程或前端容器目录,默认使用所选框架的 web 工程'),
]
FrontendFrameworkOption = Annotated[
PluginFrontendFrameworkChoice,
typer.Option('--frontend-framework', help='宿主工程框架;auto 使用环境变量或默认 vue3'),
]
@@ -50,24 +55,28 @@ def _execute_sdk(
*,
dry_run: bool = False,
force: bool = False,
frontend_framework: PluginFrontendFrameworkChoice = 'auto',
) -> None:
"""
仅加载离线 SDK 文件服务,使用统一 CLI 输出和非零失败退出码。
:param operation: check 或 update
:param project: 独立 bundle 源码工程目录
:param frontend_root: 可选宿主前端源码目录
:param frontend_root: 可选宿主前端工程根目录或包含框架分类的聚合目录
:param output: text 或 json
:param dry_run: 是否只预演更新
:param force: 是否备份后替换改动过或未跟踪的 SDK
:param frontend_framework: SDK 来源宿主的前端框架标识
:return: None
"""
try:
root = frontend_root or Path(
os.environ.get('RUOYI_PLUGIN_FRONTEND_ROOT')
or Path(__file__).resolve().parents[5] / 'ruoyi-fastapi-frontend'
frontend_root_resolver = importlib.import_module('plugins.core.frontend').resolve_frontend_root
resolved_frontend_root = frontend_root_resolver(
Path(__file__).resolve().parents[4], frontend_root, frontend_framework
)
service = importlib.import_module('cli.runtime.plugin.sdk').PluginBridgeSdk(
resolved_frontend_root, frontend_framework=frontend_framework
)
service = importlib.import_module('cli.runtime.plugin.sdk').PluginBridgeSdk(root)
payload = (
service.check(project) if operation == 'check' else service.update(project, dry_run=dry_run, force=force)
)
@@ -101,16 +110,18 @@ def register_sdk_commands(app: typer.Typer) -> None:
project: Annotated[Path, typer.Argument(help='含 plugin.yaml 的独立 bundle 源码工程目录')],
frontend_root: FrontendRootOption = None,
output: OutputOption = 'text',
frontend_framework: FrontendFrameworkOption = 'auto',
) -> None:
"""
检查源码工程的 SDK 来源与本地修改,不写文件或连接外部服务。
:param project: 独立 bundle 源码工程目录
:param frontend_root: 可选宿主前端源码目录
:param frontend_root: 可选宿主前端工程根目录或包含框架分类的聚合目录
:param output: 输出格式
:param frontend_framework: SDK 来源宿主的前端框架标识
:return: None
"""
_execute_sdk('check', project, frontend_root, output)
_execute_sdk('check', project, frontend_root, output, frontend_framework=frontend_framework)
@sdk_app.command('update', help='备份后更新 SDK 的三个文件;不会构建、安装或重启插件')
def update_sdk(
@@ -119,15 +130,25 @@ def register_sdk_commands(app: typer.Typer) -> None:
output: OutputOption = 'text',
dry_run: DryRunOption = False,
force: Annotated[bool, typer.Option('--force', help='备份后纳管旧副本或替换本地 SDK 修改')] = False,
frontend_framework: FrontendFrameworkOption = 'auto',
) -> None:
"""
显式更新源码工程的 SDK;默认拒绝覆盖用户改动或来源不明的副本。
:param project: 独立 bundle 源码工程目录
:param frontend_root: 可选宿主前端源码目录
:param frontend_root: 可选宿主前端工程根目录或包含框架分类的聚合目录
:param output: 输出格式
:param dry_run: 是否仅预演而不写入文件
:param force: 是否备份后替换改动或未跟踪副本
:param frontend_framework: SDK 来源宿主的前端框架标识
:return: None
"""
_execute_sdk('update', project, frontend_root, output, dry_run=dry_run, force=force)
_execute_sdk(
'update',
project,
frontend_root,
output,
dry_run=dry_run,
force=force,
frontend_framework=frontend_framework,
)
@@ -661,7 +661,7 @@ class PluginCommandController:
job=not options.no_job,
config=not options.no_config,
test=not options.no_test,
frontend_version=options.frontend_version,
frontend_framework=options.frontend_framework,
dry_run=options.dry_run,
)
self.execution_service.complete_payload_with_text(
@@ -1,6 +1,8 @@
from dataclasses import dataclass
from typing import Literal
PluginFrontendFrameworkChoice = Literal['auto', 'vue2', 'vue3']
@dataclass(frozen=True)
class PluginCreateCommandOptions:
@@ -10,7 +12,7 @@ class PluginCreateCommandOptions:
:param backend_only: 是否只创建后端插件模板
:param frontend_only: 是否只创建前端插件模板
:param template: 插件模板名称
:param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3
:param frontend_framework: 前端框架标识,auto 表示自动识别;源码模板仅支持 vue2、vue3
:param no_migration: 是否不创建 migration 示例
:param no_seed: 是否不创建 seed 示例
:param no_job: 是否不创建定时任务示例
@@ -22,7 +24,7 @@ class PluginCreateCommandOptions:
backend_only: bool = False
frontend_only: bool = False
template: str = 'full-stack'
frontend_version: Literal['auto', 'vue2', 'vue3'] = 'auto'
frontend_framework: PluginFrontendFrameworkChoice = 'auto'
no_migration: bool = False
no_seed: bool = False
no_job: bool = False
@@ -556,7 +556,7 @@ class PluginCommandPresenter:
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'frontend_framework: {payload.get("frontendFramework") 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()}',
@@ -1,6 +1,6 @@
from .backend import PluginBackendScaffoldTemplateBuilder
from .builder import PluginScaffoldBuilder
from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver
from .frontend import FrontendFramework, PluginFrontendFrameworkResolver, PluginFrontendScaffoldTemplateBuilder
from .naming import PluginScaffoldNaming
from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver
from .payload import (
@@ -11,10 +11,10 @@ from .payload import (
)
__all__ = [
'FrontendVersion',
'FrontendFramework',
'PluginBackendScaffoldTemplateBuilder',
'PluginFrontendFrameworkResolver',
'PluginFrontendScaffoldTemplateBuilder',
'PluginFrontendVersionResolver',
'PluginScaffoldBuilder',
'PluginScaffoldConflictPayload',
'PluginScaffoldNaming',
@@ -95,8 +95,7 @@ permissions:
dependencies:
python: []
npm: []
npmDev: []
frontend: {{}}
plugins: []
{config}
"""
@@ -1,10 +1,12 @@
from collections.abc import Callable
from pathlib import Path
from typing import Any
from plugins.core.frontend import select_frontend_root
from plugins.core.utils import validate_plugin_id_value
from .backend import PluginBackendScaffoldTemplateBuilder
from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver
from .frontend import FrontendFramework, PluginFrontendFrameworkResolver, PluginFrontendScaffoldTemplateBuilder
from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver
from .payload import PluginScaffoldPayloadBuilder, PluginScaffoldPlanPayload
from .v2 import PluginV2ScaffoldBuilder
@@ -17,15 +19,23 @@ class PluginScaffoldBuilder:
使用 Builder 模式生成后端与前端插件模板文件计划,并在确认无冲突后落地。
"""
def __init__(self, backend_root: Path, frontend_root: Path) -> None:
def __init__(
self,
backend_root: Path,
frontend_root: Path,
frontend_framework_resolver: Callable[[Path], str] | None = None,
) -> None:
"""
初始化插件模板构建器。
:param backend_root: 后端项目根目录
:param frontend_root: 前端项目根目录
:param frontend_root: 前端工程根目录或包含框架分类的聚合目录
:param frontend_framework_resolver: 宿主框架解析函数,保留运行环境中选择的框架
:return: None
"""
self.backend_root = backend_root
self.frontend_root = frontend_root
self.frontend_root = select_frontend_root(frontend_root)
self.frontend_framework_resolver = frontend_framework_resolver
def build_plan(
self,
@@ -39,7 +49,7 @@ class PluginScaffoldBuilder:
job: bool = True,
config: bool = True,
test: bool = True,
frontend_version: str = PluginFrontendVersionResolver.AUTO,
frontend_framework: str = PluginFrontendFrameworkResolver.AUTO,
) -> dict[str, Any]:
"""
构建插件模板写入计划。
@@ -53,19 +63,22 @@ class PluginScaffoldBuilder:
:param job: 是否创建定时任务示例
:param config: 是否创建配置项示例
:param test: 是否创建测试样例
:param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3
:param frontend_framework: 目标前端框架标识,auto 表示自动识别;源码模板仅支持 vue2、vue3
:return: 插件模板写入计划
"""
self._validate_plugin_id(plugin_id)
frontend_root = select_frontend_root(self.frontend_root, frontend_framework)
normalized_template = (template or PluginScaffoldTemplateResolver.DEFAULT_TEMPLATE).strip()
if normalized_template in PluginScaffoldTemplateResolver.V2_TEMPLATES:
return PluginV2ScaffoldBuilder(self.backend_root, self.frontend_root).build_plan(
return PluginV2ScaffoldBuilder(
self.backend_root, frontend_root, frontend_framework_resolver=self.frontend_framework_resolver
).build_plan(
plugin_id,
template=normalized_template,
backend=backend,
frontend=frontend,
test=test,
frontend_version=frontend_version,
frontend_framework=frontend_framework,
)
options = self._merge_options(
PluginScaffoldTemplateResolver.resolve(template),
@@ -84,8 +97,8 @@ class PluginScaffoldBuilder:
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
resolved_frontend_framework = (
self._resolve_frontend_framework(frontend_root, frontend_framework) if options.frontend else None
)
if options.backend:
backend_plugin_root = self.backend_root / 'plugins' / plugin_id
@@ -94,17 +107,17 @@ class PluginScaffoldBuilder:
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
assert resolved_frontend_framework is not None
frontend_plugin_root = 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))
target_dirs.append(str(frontend_root / 'tests' / 'plugins' / plugin_id))
files.extend(
self._build_frontend_files(
plugin_id,
frontend_plugin_root,
options,
frontend_version=resolved_frontend_version,
frontend_framework=resolved_frontend_framework,
)
)
@@ -122,7 +135,7 @@ class PluginScaffoldBuilder:
test=effective_backend_test or effective_frontend_test,
backend_test=effective_backend_test,
frontend_test=effective_frontend_test,
frontend_version=resolved_frontend_version,
frontend_framework=resolved_frontend_framework,
target_dirs=target_dirs,
files=files,
conflicts=conflicts,
@@ -131,6 +144,18 @@ class PluginScaffoldBuilder:
build_conflict_payload = staticmethod(PluginScaffoldPayloadBuilder.build_conflict_payload)
build_success_payload = staticmethod(PluginScaffoldPayloadBuilder.build_success_payload)
def _resolve_frontend_framework(self, frontend_root: Path, requested_framework: str) -> str:
"""
优先使用命令显式框架,其次沿用宿主运行环境的框架选择。
:param frontend_root: 已选中的前端工程目录
:param requested_framework: 命令指定的框架标识
:return: 已解析的前端框架标识
"""
if (requested_framework or 'auto').strip().lower() == 'auto' and self.frontend_framework_resolver is not None:
return self.frontend_framework_resolver(frontend_root)
return PluginFrontendFrameworkResolver.resolve(frontend_root, requested_framework)
@classmethod
def _validate_plugin_id(cls, plugin_id: str) -> None:
"""
@@ -251,7 +276,7 @@ class PluginScaffoldBuilder:
plugin_root: Path,
options: PluginScaffoldOptions,
*,
frontend_version: FrontendVersion,
frontend_framework: FrontendFramework,
) -> list[tuple[Path, str]]:
"""
构建前端插件模板文件。
@@ -259,7 +284,7 @@ class PluginScaffoldBuilder:
:param plugin_id: 插件ID
:param plugin_root: 前端插件根目录
:param options: 插件模板生成选项
:param frontend_version: 已解析的前端 Vue 版本
:param frontend_framework: 已解析的前端框架标识
:return: 文件路径和内容列表
"""
files = [
@@ -271,20 +296,20 @@ class PluginScaffoldBuilder:
),
(
plugin_root / 'views' / 'index.vue',
PluginFrontendScaffoldTemplateBuilder.build_crud_view(plugin_id, frontend_version)
PluginFrontendScaffoldTemplateBuilder.build_crud_view(plugin_id, frontend_framework)
if options.crud
else PluginFrontendScaffoldTemplateBuilder.build_view(plugin_id, frontend_version),
else PluginFrontendScaffoldTemplateBuilder.build_view(plugin_id, frontend_framework),
),
(
plugin_root / 'README.md',
PluginFrontendScaffoldTemplateBuilder.build_readme(plugin_id, frontend_version),
PluginFrontendScaffoldTemplateBuilder.build_readme(plugin_id, frontend_framework),
),
]
if options.test:
files.append(
(
self.frontend_root / 'tests' / 'plugins' / plugin_id / 'pluginView.test.js',
PluginFrontendScaffoldTemplateBuilder.build_test(plugin_id, frontend_version),
plugin_root.parents[1] / 'tests' / 'plugins' / plugin_id / 'pluginView.test.js',
PluginFrontendScaffoldTemplateBuilder.build_test(plugin_id, frontend_framework),
)
)
@@ -1,94 +1,8 @@
import json
import re
from pathlib import Path
from typing import Literal, cast
from plugins.core.frontend import FrontendFramework, PluginFrontendFrameworkResolver # noqa: F401
from .frontend_vue2 import PluginVue2FrontendScaffoldTemplateBuilder
from .naming import PluginScaffoldNaming
FrontendVersion = Literal['vue2', 'vue3']
class PluginFrontendVersionResolver:
"""
前端 Vue 版本解析器。
"""
AUTO = 'auto'
DEFAULT_VERSION: FrontendVersion = 'vue3'
SUPPORTED_VALUES = (AUTO, 'vue2', 'vue3')
@classmethod
def resolve(cls, frontend_root: Path, requested_version: str = AUTO) -> FrontendVersion:
"""
解析脚手架应使用的 Vue 版本。
显式版本优先;auto 模式读取目标前端 package.json。临时目录等没有
package.json 的场景保持历史行为,默认生成 Vue 3 模板。
:param frontend_root: 前端项目根目录
:param requested_version: auto、vue2 或 vue3
:return: 解析后的 Vue 版本
"""
normalized_version = (requested_version or cls.AUTO).strip().lower()
if normalized_version not in cls.SUPPORTED_VALUES:
supported = '、'.join(cls.SUPPORTED_VALUES)
raise ValueError(f'frontend_version 仅支持 {supported},当前值:{requested_version}')
if normalized_version != cls.AUTO:
return cast('FrontendVersion', normalized_version)
package_json_path = frontend_root / 'package.json'
if not package_json_path.is_file():
return cls.DEFAULT_VERSION
try:
package_payload = json.loads(package_json_path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f'读取前端 package.json 失败:{package_json_path}({exc})') from exc
if not isinstance(package_payload, dict):
raise ValueError(f'前端 package.json 顶层必须是对象:{package_json_path}')
dependencies = cls._collect_dependencies(package_payload)
vue_version = cls._resolve_vue_dependency(dependencies.get('vue'))
if vue_version is not None:
return vue_version
if 'element-ui' in dependencies:
return 'vue2'
if 'element-plus' in dependencies:
return 'vue3'
raise ValueError(f'无法从 {package_json_path} 识别 Vue 版本,请使用 --frontend-version vue2 或 vue3 显式指定')
@staticmethod
def _collect_dependencies(package_payload: dict[str, object]) -> dict[str, object]:
"""
合并 dependencies 和 devDependencies。
:param package_payload: package.json 负载
:return: 依赖映射
"""
dependencies: dict[str, object] = {}
for key in ('devDependencies', 'dependencies'):
section = package_payload.get(key)
if isinstance(section, dict):
dependencies.update(section)
return dependencies
@staticmethod
def _resolve_vue_dependency(version_spec: object) -> FrontendVersion | None:
"""
从 npm Vue 版本约束中提取主版本。
:param version_spec: Vue npm 版本约束
:return: Vue 版本,无法识别时返回 None
"""
if not isinstance(version_spec, str):
return None
match = re.search(r'(?<!\d)([23])(?:\.\d+)?', version_spec)
if match is None:
return None
return cast('FrontendVersion', f'vue{match.group(1)}')
class PluginVue3FrontendScaffoldTemplateBuilder:
"""
@@ -393,7 +307,7 @@ console.log('{plugin_id} plugin frontend tests passed')
class PluginFrontendScaffoldTemplateBuilder:
"""
根据目标 Vue 版本分派前端插件模板。
根据目标前端框架分派已实现的插件源码模板。
"""
@staticmethod
@@ -405,27 +319,27 @@ class PluginFrontendScaffoldTemplateBuilder:
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)
def build_view(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue3') -> str:
return cls._resolve_builder(frontend_framework).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)
def build_crud_view(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue3') -> str:
return cls._resolve_builder(frontend_framework).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)
def build_readme(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue3') -> str:
return cls._resolve_builder(frontend_framework).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)
def build_test(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue3') -> str:
return cls._resolve_builder(frontend_framework).build_test(plugin_id)
@staticmethod
def _resolve_builder(
frontend_version: FrontendVersion,
frontend_framework: FrontendFramework,
) -> type[PluginVue2FrontendScaffoldTemplateBuilder] | type[PluginVue3FrontendScaffoldTemplateBuilder]:
if frontend_version == 'vue2':
if frontend_framework == 'vue2':
return PluginVue2FrontendScaffoldTemplateBuilder
if frontend_version == 'vue3':
if frontend_framework == 'vue3':
return PluginVue3FrontendScaffoldTemplateBuilder
raise ValueError(f'不支持的 Vue 版本:{frontend_version}')
raise ValueError(f'暂不支持前端框架 {frontend_framework} 的源码模板,目前仅支持 vue2、vue3')
@@ -23,7 +23,7 @@ class PluginScaffoldPlanPayload:
test: bool
backend_test: bool
frontend_test: bool
frontend_version: str | None
frontend_framework: str | None
target_dirs: list[str]
files: list[tuple[Path, str]]
conflicts: list[str]
@@ -46,7 +46,7 @@ class PluginScaffoldPlanPayload:
'test': self.test,
'backendTest': self.backend_test,
'frontendTest': self.frontend_test,
'frontendVersion': self.frontend_version,
'frontendFramework': self.frontend_framework,
'targetDirs': [format_cli_path(path) for path in self.target_dirs],
'files': [{'path': path.as_posix(), 'content': content} for path, content in self.files],
'conflicts': self.conflicts,
@@ -1,4 +1,5 @@
import json
from collections.abc import Callable
from importlib.resources import files
from pathlib import Path
from typing import Any
@@ -15,19 +16,26 @@ class PluginV2ScaffoldBuilder:
独立 v2 插件源码工程模板构建器。
"""
def __init__(self, backend_root: Path, frontend_root: Path) -> None:
def __init__(
self,
backend_root: Path,
frontend_root: Path,
frontend_framework_resolver: Callable[[Path], str] | None = None,
) -> None:
"""
初始化独立 v2 工程模板构建器。
:param backend_root: 宿主后端项目根目录
:param frontend_root: 宿主前端项目根目录,用于读取桥接 SDK
:param frontend_framework_resolver: 宿主框架解析函数,仅 bundle 复制 SDK 时调用
:return: None
"""
self.backend_root = backend_root
self.frontend_root = frontend_root
self.frontend_framework_resolver = frontend_framework_resolver
def build_plan(
self, plugin_id: str, *, template: str, backend: bool, frontend: bool, test: bool, frontend_version: str
self, plugin_id: str, *, template: str, backend: bool, frontend: bool, test: bool, frontend_framework: str
) -> dict[str, Any]:
"""
生成独立源码工程的文件计划,不安装、启用或导入插件。
@@ -37,7 +45,7 @@ class PluginV2ScaffoldBuilder:
:param backend: 是否创建后端工程
:param frontend: 是否创建前端工程
:param test: 是否生成本地合约测试
:param frontend_version: 旧版前端选项,v2 模板仅接受 auto
:param frontend_framework: 宿主前端框架选项,v2 模板仅接受 auto
:return: 包含生成文件、目标目录与冲突信息的计划
"""
native = template.startswith('rust-')
@@ -46,8 +54,8 @@ class PluginV2ScaffoldBuilder:
raise ValueError('v2 ASGI 模板需要后端,不能使用 --frontend-only')
if bundle and not frontend:
raise ValueError('bundle 模板不能使用 --backend-only;仅 API 请使用 python-asgi 或 rust-asgi')
if frontend_version != 'auto':
raise ValueError('v2 模板不使用 --frontend-version;独立 bundle 与宿主 Vue 版本无关')
if frontend_framework != 'auto':
raise ValueError('v2 模板的 --frontend-framework 仅接受 auto;独立 bundle 与宿主前端框架无关')
root = self.backend_root / 'plugin-projects' / plugin_id
replacements = {
@@ -86,9 +94,16 @@ class PluginV2ScaffoldBuilder:
if test:
entries.append((root / 'tests' / 'test_plugin.py', self._render('test_plugin.py', replacements)))
if bundle:
host_framework = (
self.frontend_framework_resolver(self.frontend_root)
if self.frontend_framework_resolver is not None
else 'auto'
)
entries.extend(
(root / 'web' / 'vendor' / sdk_name, content)
for sdk_name, content in PluginBridgeSdk(self.frontend_root).build_vendor_files().items()
for sdk_name, content in PluginBridgeSdk(self.frontend_root, frontend_framework=host_framework)
.build_vendor_files()
.items()
)
entries.extend(
(root / 'web' / name, self._render(f'web/{name.removeprefix("src/")}', replacements))
@@ -115,7 +130,7 @@ class PluginV2ScaffoldBuilder:
test=test,
backend_test=test,
frontend_test=False,
frontend_version=None,
frontend_framework=None,
target_dirs=[str(root)],
files=entries,
conflicts=[str(root)] if root.exists() else [],
@@ -11,11 +11,19 @@ from uuid import uuid4
import yaml
from plugins.core.frontend import (
PluginFrontendFrameworkResolver,
resolve_frontend_framework_request,
select_frontend_root,
validate_frontend_framework,
)
SDK_FILES = ('pluginBridge.js', 'pluginBridge.d.ts')
SDK_METADATA = 'pluginBridge.sdk.json'
VENDOR_FILES = (*SDK_FILES, SDK_METADATA)
HASH_ALGORITHM = 'sha256-utf8-lf'
SDK_SOURCE = {'project': 'RuoYi-Vue3-FastAPI', 'directory': 'ruoyi-fastapi-frontend/src/utils'}
SDK_PROJECT = 'RuoYi-FastAPI'
SDK_SOURCE = {'project': SDK_PROJECT, 'directory': 'ruoyi-fastapi-frontend/vue3/web/src/utils'}
METADATA_FIELDS = {'schemaVersion', 'sdkVersion', 'bridgeVersion', 'capabilities'}
SCHEMA_VERSION = 1
MANIFEST_VERSION = 2
@@ -24,14 +32,16 @@ MANIFEST_VERSION = 2
class PluginBridgeSdk:
"""离线检查和更新独立 bundle 工程中的桥接 SDK 副本。"""
def __init__(self, frontend_root: Path) -> None:
def __init__(self, frontend_root: Path, frontend_framework: str = 'auto') -> None:
"""
保存用于读取当前宿主 SDK 的前端目录,不初始化宿主运行环境。
:param frontend_root: 宿主前端工程根目录
:param frontend_root: 宿主前端工程根目录或包含框架分类的聚合目录
:param frontend_framework: SDK 来源宿主的框架标识,auto 表示自动识别
:return: None
"""
self.frontend_root = Path(frontend_root)
self.frontend_root = select_frontend_root(frontend_root, frontend_framework)
self.frontend_framework = resolve_frontend_framework_request(self.frontend_root, frontend_framework)
def build_vendor_files(self) -> dict[str, str]:
"""
@@ -48,9 +58,14 @@ class PluginBridgeSdk:
contents[name] = self._text(path.read_bytes())
metadata = self._metadata(contents[SDK_METADATA], vendor=False)
self._validate_exports(contents, metadata)
self._safe_path(self.frontend_root / 'package.json')
frontend_framework = PluginFrontendFrameworkResolver.resolve(self.frontend_root, self.frontend_framework)
metadata.update(
hashAlgorithm=HASH_ALGORITHM,
source=dict(SDK_SOURCE),
source={
'project': SDK_PROJECT,
'directory': f'ruoyi-fastapi-frontend/{frontend_framework}/web/src/utils',
},
files={name: self._hash(contents[name]) for name in SDK_FILES},
)
contents[SDK_METADATA] = json.dumps(metadata, ensure_ascii=False, indent=2) + '\n'
@@ -234,8 +249,9 @@ class PluginBridgeSdk:
raise ValueError('桥接 SDK 的 capabilities 无效')
if vendor:
hashes = metadata['files']
if metadata['hashAlgorithm'] != HASH_ALGORITHM or metadata['source'] != SDK_SOURCE:
raise ValueError('桥接 SDK 的摘要算法或来源记录无效')
if metadata['hashAlgorithm'] != HASH_ALGORITHM:
raise ValueError('桥接 SDK 的摘要算法无效')
PluginBridgeSdk._validate_source(metadata['source'])
if (
not isinstance(hashes, dict)
or set(hashes) != set(SDK_FILES)
@@ -246,6 +262,26 @@ class PluginBridgeSdk:
raise ValueError('桥接 SDK 文件摘要记录无效')
return metadata
@staticmethod
def _validate_source(source: Any) -> None:
"""
校验来源记录使用当前项目名和规范的框架工程路径。
:param source: SDK 副本中的来源记录
:return: None
"""
if not isinstance(source, dict) or set(source) != {'project', 'directory'} or source['project'] != SDK_PROJECT:
raise ValueError('桥接 SDK 的来源记录无效')
directory = source['directory']
match = (
re.fullmatch(r'ruoyi-fastapi-frontend/([^/]+)/web/src/utils', directory)
if isinstance(directory, str)
else None
)
if match is None:
raise ValueError('桥接 SDK 的来源目录无效')
validate_frontend_framework(match.group(1))
@staticmethod
def _wire_versions(content: str) -> list[str]:
"""
@@ -253,6 +253,12 @@ class CliPluginRuntimeService:
lockfile_template = PluginDependencyLockfileTemplateBuilder.build(
discovered_plugin.manifest,
offline_dir=offline_dir or None,
frontend_root=runtime_environment.get_frontend_dir(),
frontend_framework=(
runtime_environment.get_frontend_framework()
if discovered_plugin.manifest.dependencies.frontend
else 'auto'
),
)
if resolved_output_path.exists() and not overwrite and not dry_run:
return PluginDependencyLockPayloadBuilder.build_exists_payload(plugin_id, resolved_output_path)
@@ -463,7 +469,7 @@ class CliPluginRuntimeService:
job: bool = True,
config: bool = True,
test: bool = True,
frontend_version: str = 'auto',
frontend_framework: str = 'auto',
dry_run: bool = False,
) -> dict[str, object]:
"""
@@ -478,7 +484,7 @@ class CliPluginRuntimeService:
:param job: 是否创建定时任务示例
:param config: 是否创建配置项示例
:param test: 是否创建测试样例
:param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3
:param frontend_framework: 前端框架标识,auto 表示自动识别;源码模板仅支持 vue2、vue3
:param dry_run: 是否仅预演
:return: 插件创建结果负载
"""
@@ -487,6 +493,7 @@ class CliPluginRuntimeService:
scaffold = PluginScaffoldBuilder(
Path(runtime_environment.get_backend_dir()),
frontend_root=Path(runtime_environment.get_frontend_dir()),
frontend_framework_resolver=runtime_environment.get_frontend_framework,
)
scaffold_plan = scaffold.build_plan(
plugin_id,
@@ -498,7 +505,7 @@ class CliPluginRuntimeService:
job=job,
config=config,
test=test,
frontend_version=frontend_version,
frontend_framework=frontend_framework,
)
if scaffold_plan['conflicts']:
return PluginScaffoldBuilder.build_conflict_payload(
@@ -8,6 +8,7 @@ from typing import Any
import yaml
from cli.exit_codes import RUNTIME_ERROR, SUCCESS
from plugins.core.frontend import PluginFrontendFrameworkResolver
from plugins.core.utils import validate_plugin_id_value
from utils.time_util import TimezoneUtil
@@ -223,6 +224,7 @@ class PluginDependencyLockfileTemplate:
:param npm_dev: npmDev 依赖锁定项
:param artifact_count: 已从离线制品反填的依赖项数量
:param warnings: 生成过程告警
:param frontend_framework: 锁定的宿主前端框架,无前端依赖分类时为 None
"""
plugin_id: str
@@ -233,6 +235,7 @@ class PluginDependencyLockfileTemplate:
npm_dev: list[dict[str, object]]
artifact_count: int = 0
warnings: list[str] = field(default_factory=list)
frontend_framework: str | None = None
@property
def entry_count(self) -> int:
@@ -256,6 +259,7 @@ class PluginDependencyLockfileTemplate:
'python': self.python,
'npm': self.npm,
'npmDev': self.npm_dev,
**({'frontendFramework': self.frontend_framework} if self.frontend_framework else {}),
}
def to_yaml(self) -> str:
@@ -275,16 +279,36 @@ class PluginDependencyLockfileTemplateBuilder:
"""
@classmethod
def build(cls, manifest: Any, *, offline_dir: Path | str | None = None) -> PluginDependencyLockfileTemplate:
def build(
cls,
manifest: Any,
*,
offline_dir: Path | str | None = None,
frontend_root: Path | str | None = None,
frontend_framework: str = 'auto',
) -> PluginDependencyLockfileTemplate:
"""
根据插件 manifest 构建锁文件模板。
:param manifest: 插件 manifest
:param offline_dir: 离线制品根目录
:param frontend_root: 宿主前端工程根目录,未指定时使用插件运行时目录
:param frontend_framework: 宿主显式框架选择,auto 时从运行时环境与工程目录解析
:return: 锁文件模板
"""
generated_at = TimezoneUtil.format_rfc3339(TimezoneUtil.utc_now())
dependencies = manifest.dependencies
resolved_frontend_framework = None
if dependencies.frontend:
from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT # noqa: PLC0415
frontend_root = frontend_root or PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_dir()
resolved_frontend_framework = (
PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_framework(frontend_root)
if frontend_framework == 'auto'
else PluginFrontendFrameworkResolver.resolve(Path(frontend_root), frontend_framework)
)
frontend_dependencies = dependencies.resolve_frontend(resolved_frontend_framework or 'vue3')
artifact_resolver = PluginDependencyOfflineArtifactResolver(offline_dir)
warnings: list[str] = []
artifact_count = 0
@@ -297,7 +321,7 @@ class PluginDependencyLockfileTemplateBuilder:
artifact_count += 1 if entry.get('resolvedVersion') else 0
if artifact_warning:
warnings.append(artifact_warning)
for requirement in dependencies.npm:
for requirement in frontend_dependencies.npm:
entry, artifact_warning = cls._build_npm_entry(
requirement,
kind='npm',
@@ -307,7 +331,7 @@ class PluginDependencyLockfileTemplateBuilder:
artifact_count += 1 if entry.get('resolvedVersion') else 0
if artifact_warning:
warnings.append(artifact_warning)
for requirement in dependencies.npm_dev:
for requirement in frontend_dependencies.npm_dev:
entry, artifact_warning = cls._build_npm_entry(
requirement,
kind='npmDev',
@@ -326,6 +350,7 @@ class PluginDependencyLockfileTemplateBuilder:
npm_dev=npm_dev_entries,
artifact_count=artifact_count,
warnings=warnings,
frontend_framework=resolved_frontend_framework,
)
@staticmethod
+1 -1
View File
@@ -22,7 +22,7 @@ class AppSettings(BaseSettings):
"""
app_env: str = 'dev'
app_name: str = 'RuoYi-FasAPI'
app_name: str = 'RuoYi-FastAPI'
app_root_path: str = '/dev-api'
app_host: str = '0.0.0.0'
app_port: int = 9099
+27 -4
View File
@@ -256,7 +256,7 @@ ruoyi dev test tests --keyword sanitize --maxfail=1 -q
```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 create demo --env=dev --template=crud-page --frontend-framework=vue2 --dry-run
ruoyi plugin check demo --env=dev
ruoyi plugin check-deps demo --env=dev
ruoyi plugin allowlist-example --env=dev --dry-run
@@ -271,9 +271,32 @@ 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` 分别声明,并使用按前端版本自动选择的测试覆盖两套清单。
`plugin create` 的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认值为 `auto`。显式选择 `vue2` 或 `vue3` 优先确定框架;`auto` 先采用标准目录 `ruoyi-fastapi-frontend/<framework>/{web,mobile}` 中的框架标识,独立工程再通过 `package.json` 依赖识别框架。标准目录标识不会被底层依赖覆盖;无法识别的独立工程会报错。未指定目标时,目录选择默认定位 `vue3/web`。显式传入 `--frontend-framework=vue2` 会选择 `vue2/web` 并生成 Vue2 模板,传入 `vue3` 则选择 Vue3 工程,脚手架结果使用 `frontendFramework` 记录框架标识。
`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` 策略。
当前 v1 源码前端模板仅提供 Vue2、Vue3 实现,`auto` 解析到其他框架时,生成源码页面会明确报错,提示模板尚未支持。v2 独立模板的 `--frontend-framework` 只允许默认值 `auto`。CLI 选项的固定取值不改变底层通用框架结构。
插件依赖检查、安装和测试使用当前选定的 Web 宿主。可通过 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2` 切换默认框架;未设置该变量时也可使用 `RUOYI_FRONTEND_FRAMEWORK`。`RUOYI_PLUGIN_FRONTEND_ROOT`/`RUOYI_FRONTEND_ROOT` 可直接指定 Web 工程,或指定前端聚合目录后按框架定位,旧版平铺目录仍受支持。此前按版本命名的选择变量和命令选项不再提供别名,已有脚本需同步更新。
各框架工程共用同一个后端插件清单。前端依赖分别完整声明到 `dependencies.frontend.<framework>` 的 `npm/npmDev` 中,分类键接受 `vue2`、`vue3`、`react` 等安全的小写框架标识;检查和安装只选择当前宿主的分类,不跨分类合并。未声明目标分类时,该宿主没有 npm 依赖;旧顶层 `dependencies.npm/npmDev` 会被清单校验拒绝,需迁移到对应分类。各工程分别安装前端依赖,后端插件只需安装、启用一次。依赖分类可扩展不代表对应框架的工程或源码模板已实现。
```powershell
# Windows PowerShell:检查 Vue2 Web 的 AI 插件依赖
$env:RUOYI_PLUGIN_FRONTEND_FRAMEWORK = 'vue2'
ruoyi plugin check-deps ai --env=dev
ruoyi plugin install-deps ai --env=dev --dry-run
```
```bash
# macOS / Linux
RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2 ruoyi plugin check-deps ai --env=dev
RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2 ruoyi plugin install-deps ai --env=dev --dry-run
```
`lock-deps` 会根据 `plugin.yaml` 的 Python 依赖和当前框架分类的前端依赖生成 `plugin.lock.yaml` 模板。锁文件产物保持顶层 `python/npm/npmDev` 列表,与插件清单的分类结构不同。默认模式不联网解析版本,也不生成 hash/integrity;如传入 `--offline-dir`,命令会从已有本地 wheel/tgz 反填 `resolvedVersion`、Python `hashes` 和 npm/npmDev `integrity`。该命令仍不会下载、安装或访问 registry,无法反填的项需要由人工审核或 CI 发布流水线补齐后,才能用于 `locked` 或 `offline` 策略。
清单声明 `dependencies.frontend` 时,锁模板记录当前宿主的 `frontendFramework`,只锁定该框架分类的前端依赖。请为各框架分别保存锁文件,例如切换到 Vue2、Vue3 后,使用 `--output-path plugins/ai/plugin.vue2.lock.yaml`、`--output-path plugins/ai/plugin.vue3.lock.yaml` 保存,再在 `install-deps` 中通过 `--lockfile` 传入对应文件;锁文件框架与宿主不一致时会被阻断。
包含旧框架字段 `frontendVersion` 的锁文件会被明确拒绝,不做静默迁移,应按目标框架重新生成并审核。既没有 `frontendFramework` 也没有旧框架字段的旧锁文件,保持原有依赖项校验方式;这不代表插件清单仍接受旧顶层 npm 字段。清单中的 `compatibility.frontendVersion` 仍是前端工程的语义版本约束,保留原名,与框架标识 `frontendFramework` 不同。
`install-deps` 是真实 Python/npm 依赖安装的唯一显式入口。文本 TTY 下可省略 `--yes`,CLI 会先输出依赖安装计划和策略判定,再询问是否执行;非 TTY、JSON 输出或 CI 场景应传 `--yes`,否则会由策略返回确认阻断。
@@ -307,7 +330,7 @@ ruoyi plugin sdk update plugin-projects/report_center --dry-run --output=json
ruoyi plugin sdk update plugin-projects/report_center
```
这些命令不接受 `--env`,也不读取宿主数据库配置或执行插件代码。来源默认为相邻前端目录,可用 `--frontend-root PATH` 或 `RUOYI_PLUGIN_FRONTEND_ROOT` 指定。`check` 仅在版本、来源记录及文件摘要与选定宿主一致(`current`)时返回 0;过期副本、本地修改或缺少记录返回非零,适合纳入构建检查。`protocolCompatible` 只比较桥协议,不表示业务功能已经验收。
这些命令不接受 `--env`,也不读取宿主数据库配置或执行插件代码。SDK 命令的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认值为 `auto`。来源默认是 `ruoyi-fastapi-frontend/vue3/web`;可用 `--frontend-framework=vue2` 选择 Vue2 Web,也可用 `--frontend-root PATH` 或 `RUOYI_PLUGIN_FRONTEND_ROOT` 指定工程。`auto` 跟随框架环境变量和显式根目录选择,所选 `ruoyi-fastapi-frontend/<framework>/web` 工程必须已提供对应 bridge SDK 文件。`check` 仅在版本、来源记录及文件摘要与选定宿主一致(`current`)时返回 0;过期副本、本地修改或缺少记录返回非零,适合纳入构建检查。`protocolCompatible` 只比较桥协议,不表示业务功能已经验收。
`update` 是显式写入命令,先备份再替换三个 SDK 文件。默认拒绝覆盖本地 SDK 修改或旧工程的未跟踪副本;需要纳管时先审阅 `update --force --dry-run`,再执行 `update --force`。备份位于工程 `.plugin-sdk-backups/`,业务代码保持原样。更新后应重建 bundle 并执行兼容测试;不会下载依赖、签名发布或改变运行中的插件。完整状态与摘要规则见开发手册第 16.1 节。
+105 -34
View File
@@ -23,16 +23,33 @@
```text
ruoyi-fastapi-backend/plugins/<plugin_id>/
ruoyi-fastapi-frontend/plugins/<plugin_id>/
ruoyi-fastapi-frontend/<framework>/web/plugins/<plugin_id>/
```
`<framework>` 是前端框架标识。当前仓库提供 `vue2`、`vue3` 工程,各框架的源码页面分别维护,共用一份后端代码与 `plugin.yaml`。移动端位于 `ruoyi-fastapi-frontend/<framework>/mobile`,不作为 Web 插件的宿主构建目录。目录和依赖分类可以扩展到 `react` 等框架标识,但当前没有 React 工程。
开发示例统一位于后端 `plugins/examples/`,按实现语言分类:`python/` 保存 Python 示例,`rust/` 保存 Rust 原生插件工程。
该目录不参与插件发现和启动代际计算。使用示例时,按各自 README 构建后部署到 `plugins/<plugin_id>/`,或通过签名制品发布流程安装。
运行时不会把前后端仓库名写死在各操作入口中。插件系统优先使用显式传入的目录,其次读取
`RUOYI_PLUGIN_BACKEND_ROOT`/`RUOYI_BACKEND_ROOT` 和
`RUOYI_PLUGIN_FRONTEND_ROOT`/`RUOYI_FRONTEND_ROOT`,再尝试从后端同级目录中识别前端工程;
最后才按后端目录名把 `backend` 推断为 `frontend`。非默认目录名的项目,应优先配置上述环境变量或在运行时注入目录。
插件系统优先使用显式传入的目录,其次读取 `RUOYI_PLUGIN_BACKEND_ROOT`/`RUOYI_BACKEND_ROOT` 和 `RUOYI_PLUGIN_FRONTEND_ROOT`/`RUOYI_FRONTEND_ROOT`。默认框架为 `vue3`,对应 `ruoyi-fastapi-frontend/vue3/web`;设置 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2` 可选择 `vue2/web`,未设置该变量时也可使用 `RUOYI_FRONTEND_FRAMEWORK`。前端根目录变量可以指向具体 Web 工程,也可以指向 `ruoyi-fastapi-frontend` 聚合目录,由框架标识继续定位。旧版包含 `package.json` 的平铺前端目录仍受支持。非默认目录名的项目应优先配置根目录变量。
脚手架和 SDK 命令的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认值为 `auto`。框架身份按以下顺序确定:显式选择 `vue2` 或 `vue3` 优先;`auto` 模式先读取标准目录 `ruoyi-fastapi-frontend/<framework>/{web,mobile}` 中的框架标识;独立工程再根据 `package.json` 依赖识别框架。标准目录标识不会被底层依赖覆盖。独立工程无法识别时会报错,不会退回 Vue3;未指定目标的目录选择仍默认定位 `vue3/web`。
CLI 选项的固定取值不改变底层通用框架目录与依赖分类结构。此前按版本命名的选择变量和命令选项不再提供别名,已有脚本需同步更新。
以下命令切换当前终端的宿主选择,后续依赖检查、安装及插件测试都针对该 Web 工程;不改变 HTTP 业务接口或插件生命周期状态。
```powershell
# Windows PowerShell
$env:RUOYI_PLUGIN_FRONTEND_FRAMEWORK = 'vue2'
```
```bash
# macOS / Linux
export RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2
```
切回 Vue3 时将变量值设为 `vue3`。每个 Web 工程需要单独安装前端依赖和构建,但后端插件只安装、启用一次。
后端插件必须包含 `plugin.yaml`。插件发现、安装、菜单、依赖、配置、迁移、种子数据和定时任务都以这个文件为入口。
@@ -54,14 +71,14 @@ cd ruoyi-fastapi-backend
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 测试)模板。通常无需传参;识别失败或需要覆盖时可显式指定:
脚手架的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认以 `auto` 识别目标框架。当前 v1 源码前端模板只提供 Vue 2(Element UI、Options API、CommonJS 测试)和 Vue 3(Element Plus、Composition API、ESM 测试)实现;`auto` 解析到其他框架时,生成源码页面会明确报错,提示该框架模板尚未支持。在聚合目录下,显式选择 `vue2` 或 `vue3` 还会选择对应的 `vue2/web` 或 `vue3/web` 输出目录:
```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
ruoyi plugin create demo --env=dev --template=crud-page --frontend-framework=vue2
ruoyi plugin create demo --env=dev --template=crud-page --frontend-framework=vue3
```
后端实现应在 Vue 2/3 项目间保持一致。`plugin.yaml` 通常只声明两个前端都使用的业务依赖;如果插件确实依赖不同的 Vue 绑定库或构建插件,允许各项目保留不同清单,但应分别提供 Vue 2/3 测试,并根据目标前端 `package.json` 自动选择执行。
脚手架结果中的 `frontendFramework` 记录所选前端框架。各框架工程共用后端实现和 `plugin.yaml`,前端依赖必须分别完整写入 `dependencies.frontend.<framework>` 的 `npm/npmDev`;多个框架都使用的包也需在各自分类中声明。检查和安装时根据目标 Web 工程选择对应分类,不跨分类合并。当前 Vue2、Vue3 页面分别使用 `vue2`、`vue3` 分类,并各自测试。字段规则见[依赖声明](#56-dependencies)。
常用模板:
@@ -222,9 +239,15 @@ permissions:
dependencies:
python:
- requests>=2.32.0
npm:
- dayjs>=1.11.0
npmDev: []
frontend:
vue2:
npm:
- dayjs>=1.11.0
npmDev: []
vue3:
npm:
- dayjs>=1.11.0
npmDev: []
plugins:
- id: ai
version: ">=0.1.0"
@@ -418,10 +441,36 @@ permissions:
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `python` | `string[]` | `[]` | Python 依赖声明,例如 `requests>=2.32.0`。 |
| `npm` | `string[]` | `[]` | 前端运行依赖声明,例如 `dayjs>=1.11.0`。 |
| `npmDev` | `string[]` | `[]` | 前端开发依赖声明。 |
| `frontend` | `object` | `{}` | 以安全的小写框架标识为键,完整声明各框架的前端依赖,如 `vue2`、`vue3`、`react`。 |
| `frontend.<framework>.npm` | `string[]` | `[]` | 对应框架的全部前端运行依赖,例如 `dayjs>=1.11.0`。 |
| `frontend.<framework>.npmDev` | `string[]` | `[]` | 对应框架的全部前端开发依赖。 |
| `plugins` | `object[]` | `[]` | 插件间依赖声明。 |
同一个后端插件的前端依赖按框架分类声明,以下是当前 Vue2、Vue3 实现的示例:
```yaml
dependencies:
frontend:
vue2:
npm:
- dayjs>=1.11.0
- markstream-vue2==0.0.50
npmDev: []
vue3:
npm:
- dayjs>=1.11.0
- markstream-vue==1.0.9-beta.2
- stream-diffs==0.0.2
npmDev:
- vite-plugin-monaco-editor-esm==2.0.2
```
`frontend` 的键不限于 `vue2`、`vue3`,也接受 `react` 等安全的小写框架标识。新增分类只声明该框架的依赖,不会自动生成对应前端工程或源码模板。
插件系统解析选定 Web 工程的框架,仅使用对应分类内的 `npm/npmDev`,不会读取其他框架的依赖。未声明目标框架分类时,该宿主没有 npm 依赖;未声明 `frontend` 时,插件没有宿主前端依赖。多个框架都需要的包必须在各自分类中分别列出,不设公共依赖层。
旧顶层 `dependencies.npm`、`dependencies.npmDev` 已移除,清单校验会拒绝,不会自动迁移或合并。升级已有插件时,请将原前端依赖完整迁入对应框架分类;同时支持多个框架的插件需分别维护完整列表。这里的框架标识与 `manifestVersion: 1/2` 无关。
`dependencies.plugins[]` 支持对象写法:
| 字段 | 类型 | 默认值 | 说明 |
@@ -444,13 +493,15 @@ dependencies:
| --- | --- | --- | --- |
| `backendVersion` | `string \| null` | `null` | 后端版本约束。 |
| `hostApiVersion` | `string` | `^1.0.0` | 仅 v2:宿主插件 SDK 版本约束,独立于应用版本;当前 Host API 为 `1.4.0`。bundle 至少使用 `^1.2.0`;请求 DTO、显式事务、字典及缓存服务使用 `^1.3.0`;按需配置读取使用 `^1.4.0`。 |
| `frontendVersion` | `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`。
`compatibility.frontendVersion` 约束前端工程的发行版本,与脚手架结果、依赖记录和锁文件中的 `frontendFramework` 不同;后者取 `vue2`、`vue3` 等框架标识,不能填入版本约束。
### 5.8 resources
| 字段 | 类型 | 默认值 | 说明 |
@@ -599,7 +650,7 @@ v2 新增或改变的字段如下,其余迁移、菜单、配置及插件依
`entrypoint`、Hook、健康检查都使用插件自身模块的完整路径;任务使用 `<module_path>.<callable_name>`。ASGI 插件不能同时声明 `hooks.onStartup/onShutdown`,启动和关闭统一写入 lifespan。资源路径使用 `/` 分隔的相对路径,不允许绝对路径、反斜杠、`.` 或 `..` 目录。
bundle 不因存在菜单而变为源码前端,`buildRequired` 必须为 `false`,构建依赖由插件自己的 `package.json` 管理,不写入清单的 `dependencies.npm/npmDev`。清单校验和静态预检不会导入插件代码;通过预检仍需进行实际加载、权限与生命周期测试。
bundle 不因存在菜单而变为源码前端,`buildRequired` 必须为 `false`,构建依赖由插件自己的 `package.json` 管理,不写入清单的 `dependencies.frontend`。清单校验和静态预检不会导入插件代码;通过预检仍需进行实际加载、权限与生命周期测试。
## 6. 后端开发约定
@@ -913,14 +964,14 @@ Rust 通过 `PluginHostContext`、`plugin_endpoint`、`plugin_lifespan` 和版
插件前端代码放在:
```text
ruoyi-fastapi-frontend/plugins/<plugin_id>/
ruoyi-fastapi-frontend/<framework>/web/plugins/<plugin_id>/
```
菜单组件路径和真实 Vue 文件的映射关系:
当前 Vue2、Vue3 实现中,菜单组件路径和真实 Vue 文件的映射关系如下,`<framework>` 分别替换为 `vue2` 或 `vue3`:
```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
plugin/demo/index -> ruoyi-fastapi-frontend/<framework>/web/plugins/demo/views/index.vue
plugin/demo/report/list -> ruoyi-fastapi-frontend/<framework>/web/plugins/demo/views/report/list.vue
```
只允许两类组件值:
@@ -946,9 +997,9 @@ UI GET/HEAD 使用普通 HTTPS,仍经过插件身份门禁;`/apps/<id>/api/.
HTML 注入有效 `uiBase`、`apiBase` 与 `<base>`。SPA 回退仅接受 UI GET/HEAD 中带 `Accept: text/html` 的无扩展名页面导航;缺失脚本、API、保留路径和非 HTML 请求不能返回首页。静态服务拒绝路径越界和链接逃逸,并限制为同源 iframe 嵌入。HTML 当前 `no-store`,其他静态资源 `no-cache`,不自动启用 immutable 缓存。
前端各环境已显式配置 `VITE_APP_PLUGIN_BASE`:development 为 `/dev-api`、production 为 `/prod-api`、docker 为 `/docker-api`、staging 为 `/stage-api`,与各环境的 `VITE_APP_BASE_API` 一致。开发、生产、Docker 分别对齐后端 `.env.dev`、`.env.prod`、`.env.dockermy`/`.env.dockerpg` 的 `APP_ROOT_PATH`;仓库未提供独立的后端 staging 环境文件,部署 staging 时需将后端 `APP_ROOT_PATH` 配为 `/stage-api`。以开发环境为例,插件使用同源 `/dev-api/apps/` 和 `/dev-api/plugin/runtime/`,Cookie Path 和会话响应也包含此前缀。
Vue3 Web 各环境已显式配置 `VITE_APP_PLUGIN_BASE`,Vue2 Web 使用 `VUE_APP_PLUGIN_BASE`:development 为 `/dev-api`、production 为 `/prod-api`、docker 为 `/docker-api`、staging 为 `/stage-api`,分别与各环境的 `VITE_APP_BASE_API`、`VUE_APP_BASE_API` 一致。开发、生产、Docker 分别对齐后端 `.env.dev`、`.env.prod`、`.env.dockermy`/`.env.dockerpg` 的 `APP_ROOT_PATH`;仓库未提供独立的后端 staging 环境文件,部署 staging 时需将后端 `APP_ROOT_PATH` 配为 `/stage-api`。以开发环境为例,插件使用同源 `/dev-api/apps/` 和 `/dev-api/plugin/runtime/`,Cookie Path 和会话响应也包含此前缀。
自定义部署时,宿主前端的 `VITE_APP_PLUGIN_BASE` 必须与后端 `APP_ROOT_PATH` 一致;只有后端前缀为空时,前端才留空并使用 `/apps/` 和 `/plugin/runtime/`。浏览器侧保留部署前缀,代理转发至通过 `app.py` 启动的 Uvicorn 时剥离此前缀一次,由 Uvicorn 补回 ASGI `root_path`;保留外部 Host,HTTPS 的原始 scheme 由可信代理配置传递。仓库 Vite 已按此规则改写插件路径;两份 Docker Nginx 配置也已提供 `/docker-api/apps/` 和 `/docker-api/plugin/runtime/` 专用代理,并在转发时剥离 `/docker-api`。生产、staging 或自定义反向代理须配套设置相同前缀、专用代理入口及转发路径,保留 WebSocket 升级和 SSE 非缓冲配置,并验证 Cookie Path、Origin、Secure 和加密 AAD。独立页面属于同源可信代码;iframe 提供布局和依赖隔离,不提供对恶意插件的安全隔离。
自定义部署时,宿主前端的 `VITE_APP_PLUGIN_BASE`(Vue3)或 `VUE_APP_PLUGIN_BASE`(Vue2)必须与后端 `APP_ROOT_PATH` 一致;只有后端前缀为空时,前端才留空并使用 `/apps/` 和 `/plugin/runtime/`。浏览器侧保留部署前缀,代理转发至通过 `app.py` 启动的 Uvicorn 时剥离此前缀一次,由 Uvicorn 补回 ASGI `root_path`;保留外部 Host,HTTPS 的原始 scheme 由可信代理配置传递。两个 Web 工程的开发代理已按此规则改写插件路径;各自的 Docker Nginx 配置也已提供 `/docker-api/apps/` 和 `/docker-api/plugin/runtime/` 专用代理,并在转发时剥离 `/docker-api`。生产、staging 或自定义反向代理须配套设置相同前缀、专用代理入口及转发路径,保留 WebSocket 升级和 SSE 非缓冲配置,并验证 Cookie Path、Origin、Secure 和加密 AAD。独立页面属于同源可信代码;iframe 提供布局和依赖隔离,不提供对恶意插件的安全隔离。
### 7.3 bundle 浏览器 SDK
@@ -1100,10 +1151,14 @@ async def summary(context: PluginRequestContext) -> dict[str, str]:
插件依赖声明在 `dependencies` 中:
- `python`:Python 包。
- `npm`:前端运行依赖。
- `npmDev`:前端开发依赖。
- `frontend.<framework>.npm`:对应框架 Web 工程的全部前端运行依赖。
- `frontend.<framework>.npmDev`:对应框架 Web 工程的全部前端开发依赖。
- `plugins`:插件间依赖。
未声明目标框架分类时,该宿主没有 npm 依赖。清单不再接受顶层 `dependencies.npm/npmDev`;迁移时需将依赖完整移入目标分类,同时支持多个框架的包需分别列出。
依赖检查、安装和锁文件生成均针对当前选定的 Web 宿主。默认是 `vue3/web`;验证 Vue2 时,按[基本模型](#1-基本模型)设置 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2`,或通过根目录变量选择具体工程,再运行相同命令。各框架工程不共享 `node_modules`。
检查和安装:
```bash
@@ -1120,6 +1175,20 @@ ruoyi plugin install-deps demo --env=dev --yes
`lock-deps` 默认生成锁文件模板,输出到 `plugins/<plugin_id>/plugin.lock.yaml`;如文件已存在,需要传 `--overwrite` 才会覆盖。默认模式不会联网解析真实版本,也不会写入 hash/integrity;如果传入 `--offline-dir`,命令会从已有本地 wheel/tgz 反填 `resolvedVersion`、Python `hashes` 和 npm/npmDev `integrity`。它仍不会下载、安装或访问 registry,未能反填的项发布前应由人工审核或 CI 流水线补齐。
清单声明 `dependencies.frontend` 时,锁模板只包含当前宿主分类的前端依赖,并记录 `frontendFramework`;安装策略会拒绝框架标识与宿主不一致的锁文件。锁文件产物仍使用顶层 `npm/npmDev` 列表,检查报告的依赖类型也仍为 `npm/npmDev`,它们与 `plugin.yaml` 清单格式不同。
包含旧框架字段 `frontendVersion` 的锁文件会被明确拒绝,不做静默迁移,应按目标框架重新生成并审核锁文件。既没有 `frontendFramework` 也没有旧框架字段的旧锁文件,仍沿用原有依赖项校验。这里的旧锁字段与仍受支持的 `compatibility.frontendVersion` 语义版本约束不同。
建议用 `--output-path` 分别维护各框架的锁文件,安装时用 `--lockfile` 选择。例如先切换到 Vue2,再执行:
```bash
ruoyi plugin lock-deps ai --env=dev --output-path plugins/ai/plugin.vue2.lock.yaml
# 发布流程补齐并审核锁文件中的 resolvedVersion、hash/integrity 后再安装
ruoyi plugin install-deps ai --env=dev --lockfile plugins/ai/plugin.vue2.lock.yaml --dry-run
```
Vue3 使用相同流程,将宿主框架和文件名改为 `vue3`。各框架分别维护宿主依赖锁,不要通过覆盖同一文件共享锁内容。
如果当前终端是交互式 TTY,且输出格式为 text,也可以不传 `--yes`:
```bash
@@ -1290,7 +1359,8 @@ ruoyi plugin test demo --env=dev
全栈插件还应执行前端构建检查:
```bash
cd ../ruoyi-fastapi-frontend
cd ../ruoyi-fastapi-frontend/vue3/web
# Vue2 工程改为:cd ../ruoyi-fastapi-frontend/vue2/web
npm run build:prod
```
@@ -1554,7 +1624,7 @@ ruoyi plugin create report_center --template rust-bundle --env dev --dry-run
ruoyi plugin create report_center --template rust-bundle --env dev
```
每个工程包含 v2 清单、受 `<id>:view` 权限保护的 `/api/info`、显式入口、健康检查、lifespan、构建与发布 README,以及 SDK 合约测试。Rust 模板固定 `ruoyi_plugin_<id>` Python 命名空间,包含锁定的 Cargo 依赖。bundle 复制当前宿主的 bridge SDK 及配套 `.d.ts`,使用相对资源基址和专用插件会话,不内嵌主登录 token。它与宿主 Vue 版本独立,v2 模板不接受 `--frontend-version`;bundle 也不能配合 `--backend-only`。
每个工程包含 v2 清单、受 `<id>:view` 权限保护的 `/api/info`、显式入口、健康检查、lifespan、构建与发布 README,以及 SDK 合约测试。Rust 模板固定 `ruoyi_plugin_<id>` Python 命名空间,包含锁定的 Cargo 依赖。bundle 复制当前宿主的 bridge SDK 及配套 `.d.ts`,使用相对资源基址和专用插件会话,不内嵌主登录 token。独立 bundle 与宿主前端框架解耦,v2 模板的 `--frontend-framework` 只允许默认值 `auto`;bundle 也不能配合 `--backend-only`。
bundle 安装 npm 依赖后,可运行 `npm --prefix plugin-projects/report_center/web run dev`,自动打开生成的 `/dev.html`。`dev.js` 提供 `/api/info` 的内存模拟、主题、延迟、故障、重载和退出,可按业务扩展;默认模板未实现文件或 SSE 业务接口。模拟配置仅在开发服务注入,生产构建不包含开发宿主。文件与 SSE 的完整示例见 `bundle_demo`。
@@ -1605,7 +1675,7 @@ ruoyi plugin sdk update plugin-projects/report_center
npm --prefix plugin-projects/report_center/web run build
```
默认来源为宿主相邻前端目录;独立检出时可传 `--frontend-root PATH`,或设置 `RUOYI_PLUGIN_FRONTEND_ROOT`。命令只静态读取工程清单和固定 SDK 文件,不导入插件、不读取宿主运行环境、不连接数据库或 registry。清单检查仅确认 YAML 可读、`manifestVersion: 2` 和 bundle 工程类型,不执行完整插件清单校验;完整检查与业务测试仍按第 13 节执行。
SDK 命令的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认值为 `auto`。默认来源为 `ruoyi-fastapi-frontend/vue3/web`;可用 `--frontend-framework=vue2` 选择 Vue2 Web,`auto` 则跟随框架环境变量和显式根目录选择。通用来源目录为 `ruoyi-fastapi-frontend/<framework>/web`,所选工程必须提供对应 bridge SDK 文件;指定框架不会生成这些文件。独立检出时可传 `--frontend-root PATH`,或设置 `RUOYI_PLUGIN_FRONTEND_ROOT`。命令只静态读取工程清单和固定 SDK 文件,不导入插件、不读取宿主运行环境、不连接数据库或 registry。清单检查仅确认 YAML 可读、`manifestVersion: 2` 和 bundle 工程类型,不执行完整插件清单校验;完整检查与业务测试仍按第 13 节执行。
| `check` 状态 | 含义与处理 |
| --- | --- |
@@ -1665,7 +1735,8 @@ python -m pytest tests/plugins/core/artifacts tests/plugins/core/deployment test
bundle 插件完成独立前端构建后,还应执行宿主桥接测试(在后端目录执行,三种平台通用):
```bash
npm --prefix ../ruoyi-fastapi-frontend run test:plugin
npm --prefix ../ruoyi-fastapi-frontend/vue2/web run test:plugin
npm --prefix ../ruoyi-fastapi-frontend/vue3/web run test:plugin
```
### 17.2 真实服务与多 worker 验收
@@ -1695,27 +1766,27 @@ npm --prefix ../ruoyi-fastapi-frontend run test:plugin
### 17.4 CI 覆盖
仓库的原生插件工作流配置 Windows/Linux、Python 3.10、3.11、3.12、3.13 构建测试矩阵,覆盖 v1/v2 脚手架、SDK 溯源与离线更新命令、原生扩展以及签名制品与维护发布回归。
统一入口为 [`ci.yml`](../../.github/workflows/ci.yml),完整分类和本地命令见[测试说明](../../ruoyi-fastapi-test/README.md)。[`ci-backend.yml`](../../.github/workflows/ci-backend.yml) 承担 v1/v2 脚手架、SDK 溯源、离线更新及 Windows CLI/路径回归;[`ci-native.yml`](../../.github/workflows/ci-native.yml) 保留 Windows/Linux × Python 3.10–3.13 的真实原生制品测试,Rust fmt/clippy 单独运行一次。
发布集成工作流在 Python 3.10、3.11、3.12、3.13 上使用临时 MySQL、PostgreSQL 和 Redis 服务验证多 worker 发布行为。工作流配置不代表已经执行成功;发布前应核对对应提交的实际 CI 结果,并完成目标平台的部署验收。
[`ci-integration.yml`](../../.github/workflows/ci-integration.yml) 使用临时 MySQL、PostgreSQL 和 Redis 服务验证多 worker 发布行为,PR 使用 Python 3.12,完整运行覆盖 Python 3.10–3.13。工作流配置不代表已经执行成功;发布前应核对对应提交的实际 CI 结果,并完成目标平台的部署验收。
`scripts/plugin_release_integration.py` 还检查双 worker 首次发布、升级和代码回滚后的固定目标收敛,无 worker 时单次断言不收敛,旧目标代际被拒绝、单 worker 故障立即失败,以及报告过期、缺员和遗留陈旧记录下的等待结果。足够的新 worker 全部就绪后,遗留陈旧记录不应造成永久等待;失败结果保留退出码和固定原因供流水线判断。脚本还针对两种数据库启动独立的完整 CLI 进程,验证标准输出可整体解析为一个 JSON 对象,并核对未就绪时退出 `50`、就绪后退出 `0`。
该工作流还实际构建 `task_demo` 的 bundle,并通过 `scripts/plugin_task_integration.py` 验证 Python 受控目录打包、签名、导入、维护准备、目标选择、旧结构数据保留以及当前业务 CRUD/读写分权。旧版本是仅承载 001 迁移的测试夹具;当前版本从真实签名制品加载。业务 HTTP 验收使用已认证上下文和 ASGI 适配器,不代替浏览器登录验收。运行真实发布集成脚本前须先按任务示例 README 构建 `web/dist`;本地 SQLite 交付回归使用最小 HTML 夹具,单独验证维护链路。
[`plugin-frontend.yml`](../../.github/workflows/plugin-frontend.yml) 在 Node.js 22 上执行 `npm run test:plugin`,相关源码、测试、SDK、构建配置及依赖变更会触发检查。仓库当前不提交 npm 锁文件,因此使用 `npm install --package-lock=false`,依赖解析仍遵循 `package.json` 的版本范围。
[`ci-frontend.yml`](../../.github/workflows/ci-frontend.yml) 在 Node.js 22 上按框架和 Web/Mobile 工程执行 `test:unit`、三时区 `test:contract` 及各目标构建;Web 单元入口包含插件、认证和任务测试,覆盖 `vue2`、`vue3`。当前两个 Web 工程不提交 npm 锁文件,因此使用 `npm install --package-lock=false`,依赖解析仍遵循各自 `package.json` 的版本范围。
[`plugin-network-smoke.yml`](../../.github/workflows/plugin-network-smoke.yml) 在 Linux、Python 3.12、Node.js 22 与 Chromium 上执行真实网络 smoke。链路为宿主 Vue `PluginFrame` → iframe SDK → 临时 HTTPS Vite 代理 → Uvicorn → 生产插件会话与门禁,使用 `/gateway` 部署前缀。覆盖未登录拒绝、限定路径的 Secure/HttpOnly/SameSite Cookie、JSON POST、CSRF/Origin 拒绝、SSE 增量接收与取消后游标恢复、SSE/WebSocket 在权限及主会话撤销后的关闭,以及 runtime drain 后连接和 lifespan 回收。工作流保留截图、Playwright trace、代理日志与 JUnit 结果。
`ci-integration.yml` 的 network 任务在 Linux、Python 3.12、Node.js 22 与 Chromium 上分别执行 Vue2/Vue3 真实网络 smoke。链路为宿主 `PluginFrame` → iframe SDK → 临时 HTTPS 代理 → Uvicorn → 生产插件会话与门禁,使用 `/gateway` 部署前缀。覆盖未登录拒绝、限定路径的 Secure/HttpOnly/SameSite Cookie、JSON POST、CSRF/Origin 拒绝、SSE 增量接收与取消后游标恢复、SSE/WebSocket 在权限及主会话撤销后的关闭,以及 runtime drain 后连接和 lifespan 回收。工作流保留截图、Playwright trace、代理日志与 JUnit 结果。
本地复现(在后端目录执行,三个平台通用):
```bash
python -m pip install pytest pytest-asyncio aiosqlite playwright
python -m playwright install chromium
npm --prefix ../ruoyi-fastapi-frontend install --no-audit --no-fund --package-lock=false
npm --prefix ../ruoyi-fastapi-frontend/vue3/web install --no-audit --no-fund --package-lock=false
python -c "import os,pytest; os.environ['RUOYI_PLUGIN_NETWORK_SMOKE']='1'; raise SystemExit(pytest.main(['tests/plugins/integration/test_network_smoke.py','-q']))"
```
常规 pytest 不设置 `RUOYI_PLUGIN_NETWORK_SMOKE=1` 时跳过此项,不要求安装浏览器。显式启用后,缺少浏览器或 Node 依赖会直接失败。可通过 `RUOYI_SMOKE_BROWSER_EXECUTABLE` 指定已有 Chromium 浏览器,通过 `RUOYI_SMOKE_OUTPUT` 指定诊断目录;默认输出在后端 `target/plugin-network-smoke/`。测试仅绑定 loopback 随机端口,临时证书不加入系统信任库,结束时回收浏览器和服务。
常规 Backend 分类使用 marker 排除此项;单独运行但不设置 `RUOYI_PLUGIN_NETWORK_SMOKE=1` 时跳过,不要求安装浏览器。显式启用后,缺少浏览器或 Node 依赖会直接失败。设置 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2` 可验证 Vue2(先安装对应 Web 依赖并设置 `NODE_OPTIONS=--openssl-legacy-provider`),默认是 Vue3。可通过 `RUOYI_SMOKE_BROWSER_EXECUTABLE` 指定已有 Chromium 浏览器,通过 `RUOYI_SMOKE_OUTPUT` 指定诊断目录;默认输出在后端 `target/plugin-network-smoke/<framework>/`。测试仅绑定 loopback 随机端口,临时证书不加入系统信任库,结束时回收浏览器和服务。
该 smoke 的账号查询、数据库会话、插件启用状态和 Redis 使用隔离夹具,传输加密为 off;它验证真实网络上的生产会话协议,不覆盖真实账号登录、Redis 故障、required 加密、生产证书信任或进程信号触发的停机。runtime drain 之后才让 Uvicorn 正常退出,SIGTERM、完整宿主 lifespan 及目标部署代理仍按 17.2 节另行验收。
@@ -1,6 +1,6 @@
# 统一认证中心开发者使用手册
更新日期:2026-10-01。适用于当前 Vue3、Vue2 两个仓库的统一认证中心;两者使用相同的后端协议和接入配置。
更新日期:2026-10-10。适用于本仓库 `ruoyi-fastapi-frontend/vue3/web`、`ruoyi-fastapi-frontend/vue2/web` 两个 Web 工程的统一认证中心;两者共用 `ruoyi-fastapi-backend`,使用相同的后端协议和接入配置。
本文面向平台管理员、业务应用开发者和资源服务开发者,按“启用服务 → 注册应用 → 登录 → 调用 API → 续期和退出”的顺序说明接入方法。本文的域名、客户端编号和凭据均为占位示例,使用时替换为实际值。
@@ -80,25 +80,64 @@ class ServerService:
)
# 磁盘信息
io = psutil.disk_partitions()
sys_files = []
for i in io:
try:
o = psutil.disk_usage(i.device)
disk_data = SysFiles(
dirName=i.device,
sysTypeName=i.fstype,
typeName='本地固定磁盘(' + i.mountpoint.replace('\\', '') + ')',
total=bytes2human(o.total),
used=bytes2human(o.used),
free=bytes2human(o.free),
usage=f'{psutil.disk_usage(i.device).percent}%',
)
sys_files.append(disk_data)
except Exception: # noqa: PERF203
# 忽略所有异常,跳过有问题的磁盘
continue
sys_files = ServerService._get_disk_info()
result = ServerMonitorModel(cpu=cpu, mem=mem, sys=sys, py=py, sysFiles=sys_files)
return result
@classmethod
def _get_disk_info(cls) -> list[SysFiles]:
"""
获取可访问的磁盘信息
:return: 磁盘信息列表
"""
sys_files = []
for partition in psutil.disk_partitions():
disk_info = cls._read_disk_info(partition.device, partition.mountpoint, partition.fstype)
if disk_info is not None:
sys_files.append(disk_info)
if sys_files:
return sys_files
# 默认分区列表会过滤overlay等容器文件系统,无可读分区时补充根文件系统
root_path = os.path.abspath(os.sep)
# 仅匹配根目录,避免展示proc、sysfs等虚拟挂载
root_partition = next(
(partition for partition in psutil.disk_partitions(all=True) if partition.mountpoint == root_path),
None,
)
disk_info = cls._read_disk_info(
root_partition.device if root_partition else root_path,
root_path,
root_partition.fstype if root_partition else '',
)
return [disk_info] if disk_info is not None else []
@staticmethod
def _read_disk_info(device: str, mountpoint: str, filesystem: str) -> SysFiles | None:
"""
读取指定磁盘的容量信息
:param device: 磁盘设备名称
:param mountpoint: 磁盘挂载路径
:param filesystem: 文件系统类型
:return: 磁盘容量信息,挂载路径不可访问时返回None
"""
try:
disk_usage = psutil.disk_usage(mountpoint)
except OSError:
# 跳过不存在或无权访问的挂载路径
return None
return SysFiles(
dirName=device,
sysTypeName=filesystem,
typeName='本地固定磁盘(' + mountpoint.replace('\\', '') + ')',
total=bytes2human(disk_usage.total),
used=bytes2human(disk_usage.used),
free=bytes2human(disk_usage.free),
usage=f'{disk_usage.percent}%',
)
@@ -9,7 +9,7 @@ from exceptions.exception import OidcInteractionException
from module_identity.dao.oauth_audit_dao import OAuthAuditDao
from module_identity.entity.do.oauth_audit_do import SysOAuthAuditLog
from module_identity.entity.vo.oauth_session_vo import AuditModel, AuditPageQueryModel
from utils.common_util import export_list2excel
from utils.excel_util import ExcelUtil
from utils.oidc_util import OidcUtil
@@ -270,8 +270,9 @@ class AuditService:
"""
rows = await OAuthAuditDao.list_admin_page(db, offset=0, limit=5000, **cls._admin_filters(query))
mapping = {field.alias or name: field.alias or name for name, field in AuditModel.model_fields.items()}
return export_list2excel([cls._admin_model(row) for row in rows])
return ExcelUtil.export_list2excel([cls._admin_model(row) for row in rows], mapping)
@classmethod
async def record_interaction_failure(cls, db: AsyncSession, event_type: str, **fields: Any) -> None:
+12 -2
View File
@@ -1,6 +1,6 @@
# AI 管理后端插件
AI 管理插件提供模型配置、供应商字典、会话对话和流式输出能力。插件保留原有接口前缀和权限标识,安装后会在系统菜单中生成「AI 管理」目录,以及「模型管理」「AI 对话」两个页面。
AI 管理插件提供模型配置、供应商字典、会话对话和流式输出能力。插件保留原有接口前缀和权限标识,安装后会在系统菜单中生成「AI 管理」目录,以及「模型管理」「AI 对话」两个页面。各前端框架共用此后端插件及同一份 `plugin.yaml`,页面目录约定为 `ruoyi-fastapi-frontend/<framework>/web/plugins/ai`;当前提供 Vue2、Vue3 页面,分别位于 `vue2/web/plugins/ai` 和 `vue3/web/plugins/ai`。
## 功能
@@ -93,7 +93,17 @@ ruoyi plugin disable ai --env=dev --yes
- `ollama`
- 以及其他供应商 SDK
插件启动期会检查已启用插件的 Python 依赖。缺失依赖时,CLI 启动流程会提示是否安装依赖;生产环境建议在发布阶段提前安装,避免运行期临时拉取依赖。
应用启动时只做默认启用插件的依赖门禁,不会自动安装或提示安装依赖。缺失依赖时,应在启动前显式执行 `ruoyi plugin install-deps ai --env=dev`;生产环境应在发布阶段提前准备依赖。
前端依赖按宿主框架完整声明,当前 AI 插件提供以下分类:
- `dependencies.frontend.vue2.npm`:Vue2 所需的全部运行依赖,包括 `markstream-vue2` 以及 Markdown、代码高亮、图表等依赖。
- `dependencies.frontend.vue3.npm`:Vue3 所需的全部运行依赖,包括 `markstream-vue`、`stream-diffs` 以及 Markdown、代码高亮、图表等依赖。
- `dependencies.frontend.vue3.npmDev`:Vue3 构建使用的 `vite-plugin-monaco-editor-esm`。
多个框架都使用的包也在各自分类中列出,不设公共依赖层。`dependencies.frontend` 接受 `react` 等安全的小写框架标识,但新增分类不会生成对应页面;当前 AI 插件仅提供 Vue2、Vue3 实现。旧顶层 `dependencies.npm/npmDev` 已移除,清单校验会拒绝;未声明目标框架分类时,该宿主没有 npm 依赖。
插件系统解析选定 Web 工程的框架,只检查、安装匹配框架分类中的依赖。默认选择 `vue3/web`;检查或安装 Vue2 依赖时,先在当前终端设置 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2`(PowerShell:`$env:RUOYI_PLUGIN_FRONTEND_FRAMEWORK = 'vue2'`;Bash:`export RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2`),再执行同样的插件命令。未设置该变量时也可使用 `RUOYI_FRONTEND_FRAMEWORK`,或用 `RUOYI_PLUGIN_FRONTEND_ROOT` 指向具体 Web 工程。各框架工程分别安装依赖和构建,后端插件仅需安装、启用一次。完整规则见[插件开发手册](../../docs/plugin_development.md#9-依赖管理)。
## 数据库
+25 -12
View File
@@ -86,18 +86,31 @@ dependencies:
- ollama==0.6.2
- openai==2.52.0
- portkey-ai==2.3.4
npm:
- '@antv/infographic^0.2.13'
- '@terrastruct/d2==0.1.33'
- katex>=0.16.27
- markstream-vue==1.0.9-beta.2
- mermaid>=11.15.0
- shiki^3.21.0
- stream-diffs==0.0.2
- stream-markdown==0.0.16
- stream-monaco==0.0.48
npmDev:
- vite-plugin-monaco-editor-esm==2.0.2
frontend:
vue2:
npm:
- '@antv/infographic^0.2.13'
- '@terrastruct/d2==0.1.33'
- katex>=0.16.27
- markstream-vue2==0.0.50
- mermaid>=11.15.0
- shiki^3.21.0
- stream-markdown==0.0.16
- stream-monaco==0.0.48
npmDev: []
vue3:
npm:
- '@antv/infographic^0.2.13'
- '@terrastruct/d2==0.1.33'
- katex>=0.16.27
- markstream-vue==1.0.9-beta.2
- mermaid>=11.15.0
- shiki^3.21.0
- stream-diffs==0.0.2
- stream-markdown==0.0.16
- stream-monaco==0.0.48
npmDev:
- vite-plugin-monaco-editor-esm==2.0.2
plugins: []
compatibility:
@@ -116,8 +116,7 @@ class PluginRuntimeCapabilityResolver:
manifest = discovered_plugin.manifest
has_frontend_resources = bool(
manifest.frontend.menus
or manifest.dependencies.npm
or manifest.dependencies.npm_dev
or manifest.dependencies.has_frontend_dependencies
or manifest.frontend.delivery.type == 'bundle'
)
frontend_build_required = manifest.frontend.delivery.type != 'bundle' and (
@@ -3,19 +3,23 @@ from pathlib import Path
from typing import Literal
from config.env import AppConfig
from plugins.core.frontend import (
PluginFrontendFrameworkResolver,
resolve_frontend_framework_request,
resolve_frontend_root,
)
PluginFrontendMode = Literal['dev', 'built']
PluginBackendRuntimeMode = Literal['dev', 'service', 'maintenance']
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 可执行文件等路径信息。
为插件运行时提供前后端工程目录、Python 可执行文件和运行模式。
"""
def __init__(
@@ -23,17 +27,20 @@ class PluginRuntimeEnvironmentService:
backend_root: Path | str | None = None,
frontend_root: Path | str | None = None,
python_executable: str | None = None,
frontend_framework: str = 'auto',
) -> None:
"""
初始化插件运行时环境服务。
:param backend_root: 后端项目根目录
:param frontend_root: 前端项目根目录
:param frontend_root: 前端项目根目录或统一前端目录
:param python_executable: Python 可执行文件路径
:param frontend_framework: 前端框架标识,auto 使用环境变量,未配置时默认 vue3
:return: None
"""
self.backend_root = self._resolve_backend_root(backend_root)
self.frontend_root = self._resolve_frontend_root(self.backend_root, frontend_root)
self.frontend_root = resolve_frontend_root(self.backend_root, frontend_root, frontend_framework)
self.frontend_framework = resolve_frontend_framework_request(self.frontend_root, frontend_framework)
self.python_executable = python_executable or 'python'
self.frontend_mode = self._get_frontend_mode()
self.backend_runtime_mode = self._get_backend_runtime_mode()
@@ -53,27 +60,6 @@ class PluginRuntimeEnvironmentService:
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:
"""
@@ -88,41 +74,6 @@ class PluginRuntimeEnvironmentService:
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:
"""
@@ -169,6 +120,20 @@ class PluginRuntimeEnvironmentService:
"""
return str(self.frontend_root)
def get_frontend_framework(self, frontend_root: Path | str | None = None) -> str:
"""
获取指定前端工程的框架标识,保留当前工程的显式选择。
其他工程按自身目录和依赖解析,不沿用当前工程的框架标识。
仅在需要前端依赖时解析,避免后端独立运行时要求存在前端文件。
:param frontend_root: 前端工程根目录,未指定时使用当前工程
:return: 前端框架标识
"""
root = Path(os.path.abspath(frontend_root)) if frontend_root is not None else self.frontend_root
framework = self.frontend_framework if root == self.frontend_root else 'auto'
return PluginFrontendFrameworkResolver.resolve(root, framework)
def get_frontend_plugins_dir(self) -> str:
"""
获取前端插件根目录。
@@ -0,0 +1,214 @@
import json
import os
import re
from pathlib import Path
FrontendFramework = str
FRONTEND_ROOT_ENV_NAMES = ('RUOYI_PLUGIN_FRONTEND_ROOT', 'RUOYI_FRONTEND_ROOT')
FRONTEND_FRAMEWORK_ENV_NAMES = ('RUOYI_PLUGIN_FRONTEND_FRAMEWORK', 'RUOYI_FRONTEND_FRAMEWORK')
FRONTEND_FRAMEWORK_PATTERN = re.compile(r'^[a-z][a-z0-9_-]*$')
def validate_frontend_framework(value: str) -> str:
"""
校验可用于依赖分类和工程目录的前端框架标识。
auto 仅用于请求自动解析,不能作为实际框架名称。
:param value: 待校验的前端框架标识
:return: 校验通过的前端框架标识
"""
if not isinstance(value, str) or value == 'auto' or FRONTEND_FRAMEWORK_PATTERN.fullmatch(value) is None:
raise ValueError('frontend_framework 必须以小写字母开头,且仅包含小写字母、数字、下划线或连字符,不能为 auto')
return value
def resolve_frontend_framework(frontend_framework: str = 'auto') -> FrontendFramework:
"""
解析前端工程使用的框架标识。
显式框架优先;auto 模式读取前端框架环境变量,未配置时默认使用 vue3。
:param frontend_framework: 前端框架标识,auto 表示自动选择
:return: 解析后的前端框架标识
"""
framework = (frontend_framework or 'auto').strip().lower()
if framework == 'auto':
framework = next(
(os.environ[name].strip().lower() for name in FRONTEND_FRAMEWORK_ENV_NAMES if os.getenv(name, '').strip()),
'vue3',
)
return validate_frontend_framework(framework)
def resolve_frontend_framework_request(frontend_root: Path | str, requested_framework: str = 'auto') -> str:
"""
保存前端工程的显式框架选择,供后续依赖、模板与 SDK 按需解析。
标准工程的 auto 保留目录框架身份;独立工程的 auto 使用已配置的框架
环境变量,无配置时继续保留 auto,交由包依赖识别。
:param frontend_root: 已选中的前端工程目录
:param requested_framework: 请求的框架标识,auto 表示自动识别
:return: 已规范化并校验的框架请求,可以是 auto
"""
root = Path(frontend_root)
framework = (requested_framework or 'auto').strip().lower()
standard_frontend_root = root.name in {'web', 'mobile'} and root.parent.parent.name == 'ruoyi-fastapi-frontend'
if framework == 'auto' and not standard_frontend_root:
framework = next(
(os.environ[name].strip().lower() for name in FRONTEND_FRAMEWORK_ENV_NAMES if os.getenv(name, '').strip()),
'auto',
)
return framework if framework == 'auto' else validate_frontend_framework(framework)
def select_frontend_root(frontend_root: Path | str, frontend_framework: str = 'auto') -> Path:
"""
从统一前端目录中选择 Web 工程,保留显式指定的独立工程目录。
:param frontend_root: 前端工程目录、框架目录或统一前端目录
:param frontend_framework: 前端框架标识,auto 表示自动选择
:return: 所选前端工程的绝对路径
"""
# 仅转换为绝对路径,保留符号链接信息供 SDK 后续执行路径安全检查。
root = Path(os.path.abspath(frontend_root))
framework = resolve_frontend_framework(frontend_framework)
automatic = (frontend_framework or 'auto').strip().lower() == 'auto'
if root.name == 'web' and root.parent.parent.name == 'ruoyi-fastapi-frontend':
validate_frontend_framework(root.parent.name)
# 显式框架选择同级工程,auto 模式保留当前指定的工程。
return root if automatic else root.parent.parent / framework / 'web'
if (root / 'package.json').is_file():
return root
if root.parent.name == 'ruoyi-fastapi-frontend':
validate_frontend_framework(root.name)
return root / 'web' if automatic else root.parent / framework / 'web'
if root.name == 'ruoyi-fastapi-frontend' or (
root.is_dir()
and any(
path.name != 'auto' and FRONTEND_FRAMEWORK_PATTERN.fullmatch(path.name) and (path / 'web').is_dir()
for path in root.iterdir()
)
):
return root / framework / 'web'
return root
def resolve_frontend_root(
backend_root: Path | str,
frontend_root: Path | str | None = None,
frontend_framework: str = 'auto',
) -> Path:
"""
解析前端工程根目录。
依次使用显式目录、环境变量和统一前端目录,并兼容同级的旧版独立前端工程。
未发现现存工程时,返回统一前端目录下所选框架的 Web 工程路径。
:param backend_root: 后端项目根目录
:param frontend_root: 显式指定的前端工程目录或统一前端目录
:param frontend_framework: 前端框架标识,auto 表示自动选择
:return: 解析后的前端工程根目录
"""
backend = Path(backend_root).resolve()
configured = frontend_root or next(
(os.environ[name].strip() for name in FRONTEND_ROOT_ENV_NAMES if os.getenv(name, '').strip()), None
)
if configured:
return select_frontend_root(configured, frontend_framework)
framework = resolve_frontend_framework(frontend_framework)
container = backend.parent / 'ruoyi-fastapi-frontend'
if container.is_dir():
return select_frontend_root(container, framework)
# 兼容重命名或单独检出的旧版前端工程。
if backend.parent.is_dir():
candidates = sorted(
path
for path in backend.parent.iterdir()
if path.is_dir() and path != backend and (path / 'package.json').is_file() and (path / 'plugins').is_dir()
)
if candidates:
return candidates[0]
return container / framework / 'web'
class PluginFrontendFrameworkResolver:
"""
前端框架解析器。
"""
AUTO = 'auto'
@classmethod
def resolve(cls, frontend_root: Path, requested_framework: str = AUTO) -> FrontendFramework:
"""
解析宿主依赖及脚手架应使用的前端框架。
显式框架优先;标准工程使用目录中的框架标识,独立工程从 package.json
识别底层框架。无法识别的独立工程要求显式指定。
:param frontend_root: 前端项目根目录
:param requested_framework: 前端框架标识,auto 表示自动识别
:return: 解析后的前端框架标识
"""
normalized_framework = (requested_framework or cls.AUTO).strip().lower()
if normalized_framework != cls.AUTO:
return validate_frontend_framework(normalized_framework)
# 目录标识决定标准工程的依赖分类,避免被底层 Vue 或 React 包覆盖。
if frontend_root.name in {'web', 'mobile'} and frontend_root.parent.parent.name == 'ruoyi-fastapi-frontend':
return validate_frontend_framework(frontend_root.parent.name)
package_json_path = frontend_root / 'package.json'
if package_json_path.is_file():
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'
if 'react' in dependencies:
return 'react'
raise ValueError(f'无法从 {package_json_path} 识别前端框架,请使用 --frontend-framework 显式指定')
@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) -> FrontendFramework | None:
"""
从 npm Vue 版本约束中提取主版本。
:param version_spec: Vue npm 版本约束
:return: Vue 版本,无法识别时返回 None
"""
if not isinstance(version_spec, str):
return None
match = re.search(r'(?<!\d)([23])(?:\.\d+)?', version_spec)
if match is None:
return None
return f'vue{match.group(1)}'
@@ -994,9 +994,11 @@ class PluginService:
"""
根据发现结果和数据库状态构建插件信息模型。
前端依赖摘要使用当前宿主前端框架的分类,并保留各框架分类供详情展示。
:param discovered_plugin: 已发现插件对象
:param backend_root: 后端插件根目录
:param frontend_root: 前端插件根目录
:param frontend_root: 当前宿主前端插件根目录,其父目录为前端工程根目录
:param existing_plugin: 数据库中已有插件对象
:return: 插件信息模型
"""
@@ -1015,6 +1017,14 @@ class PluginService:
status = current_status
frontend_path = frontend_root / manifest.frontend.plugin_id if frontend_root else None
frontend_menus = PluginMenuTree.flatten(manifest.frontend.menus)
frontend_framework = (
PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_framework(
frontend_root.parent if frontend_root else Path(PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_dir())
)
if manifest.dependencies.frontend
else None
)
frontend_dependencies = manifest.dependencies.resolve_frontend(frontend_framework or 'vue3')
return PluginModel(
pluginId=manifest.id,
@@ -1054,8 +1064,19 @@ class PluginService:
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,
'npm': frontend_dependencies.npm,
'npmDev': frontend_dependencies.npm_dev,
**(
{
'frontendFramework': frontend_framework,
'frontendProfiles': {
framework: profile.model_dump(by_alias=True)
for framework, profile in manifest.dependencies.frontend.items()
},
}
if frontend_framework
else {}
),
},
pluginDependencies=[dependency.model_dump(by_alias=True) for dependency in manifest.dependencies.plugins],
)
@@ -5,6 +5,7 @@ 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.frontend import validate_frontend_framework
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
@@ -314,18 +315,68 @@ class PluginDependencyManifest(BaseModel):
return value
class FrontendDependencyManifest(BaseModel):
"""
指定前端框架的宿主前端依赖声明。
"""
model_config = ConfigDict(populate_by_name=True, extra='forbid')
npm: list[str] = Field(default_factory=list, description='前端 npm 依赖声明')
npm_dev: list[str] = Field(default_factory=list, alias='npmDev', description='前端 npm 开发依赖声明')
class DependencyManifest(BaseModel):
"""
插件依赖声明。
前端依赖按宿主前端框架在 frontend 中分类声明,各分类独立维护完整依赖列表。
"""
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 开发依赖声明')
frontend: dict[str, FrontendDependencyManifest] = Field(
default_factory=dict, description='按宿主前端框架分类的完整前端依赖'
)
plugins: list[PluginDependencyManifest] = Field(default_factory=list, description='插件间依赖声明')
@field_validator('frontend')
@classmethod
def validate_frontend_frameworks(
cls, value: dict[str, FrontendDependencyManifest]
) -> dict[str, FrontendDependencyManifest]:
"""
校验前端依赖分类使用安全的小写框架标识。
:param value: 按宿主前端框架分类的依赖声明
:return: 校验后的前端依赖分类
"""
for framework in value:
validate_frontend_framework(framework)
return value
@property
def has_frontend_dependencies(self) -> bool:
"""
判断是否为任一前端框架声明宿主前端依赖。
:return: 是否存在前端依赖声明
"""
return any(item.npm or item.npm_dev for item in self.frontend.values())
def resolve_frontend(self, frontend_framework: str) -> FrontendDependencyManifest:
"""
获取指定前端框架的前端依赖声明副本。
返回独立副本,避免调用方修改原始清单;未声明的分类返回空依赖声明。
:param frontend_framework: 宿主前端框架标识,例如 vue2、vue3、react
:return: 指定前端框架的前端依赖声明副本
"""
profile = self.frontend.get(validate_frontend_framework(frontend_framework))
return profile.model_copy(deep=True) if profile is not None else FrontendDependencyManifest()
@field_validator('python')
@classmethod
def validate_python_requirements(cls, value: list[str]) -> list[str]:
@@ -1111,7 +1162,7 @@ class PluginManifest(BaseModel):
:return: None
"""
has_frontend_resources = bool(self.frontend.menus or self.dependencies.npm or self.dependencies.npm_dev)
has_frontend_resources = bool(self.frontend.menus or self.dependencies.has_frontend_dependencies)
if has_frontend_resources and self.frontend.delivery.type == 'none':
self.frontend.delivery.type = 'source'
if self.frontend.delivery.type == 'source':
@@ -231,7 +231,7 @@ class PluginManifestV2(PluginManifest):
raise ValueError('bundle 交付必须声明 frontend.bundle')
if self.frontend.delivery.build_required:
raise ValueError('bundle 不参与宿主构建,buildRequired 必须为 false')
if self.dependencies.npm or self.dependencies.npm_dev:
if self.dependencies.has_frontend_dependencies:
raise ValueError('bundle 的 npm 构建依赖由插件独立管理,不能安装到宿主')
def _validate_menu_components(self) -> None:
@@ -241,7 +241,9 @@ class PluginDependencyUseCase:
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(
policy_decision = DependencyInstallPolicyEvaluator(
resolved_policy_config, runtime_environment=self.dependencies.runtime_environment
).evaluate(
install_plan,
confirmed=confirmed,
)
@@ -345,7 +347,9 @@ class PluginDependencyUseCase:
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(
policy_decision = DependencyInstallPolicyEvaluator(
resolved_policy_config, runtime_environment=self.dependencies.runtime_environment
).evaluate(
install_plan,
confirmed=confirmed,
)
@@ -88,6 +88,7 @@ class PluginRuntimeService:
resolved_dependency_checker = dependency_checker or PluginDependencyChecker(
npm_inspector=NpmDependencyInspector(frontend_root=resolved_environment.get_frontend_dir()),
frontend_mode=resolved_environment.get_frontend_mode(),
runtime_environment=resolved_environment,
)
gateway_overrides = gateways or PluginRuntimeGatewayOverrides()
self._replace_dependencies(
@@ -159,6 +160,7 @@ class PluginRuntimeService:
frontend_root=self.dependencies.runtime_environment.get_frontend_dir(),
),
frontend_mode=self.dependencies.runtime_environment.get_frontend_mode(),
runtime_environment=self.dependencies.runtime_environment,
)
)
@@ -174,28 +174,26 @@ class PluginDocumentationBuilder:
@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:
if not dependencies.python and not dependencies.has_frontend_dependencies 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('- 无')
for framework, profile in dependencies.frontend.items():
lines.extend(['', f'### {framework} 宿主依赖'])
lines.extend(f'- npm: `{item}`' for item in profile.npm)
lines.extend(f'- npmDev: `{item}`' for item in profile.npm_dev)
if not profile.npm and not profile.npm_dev:
lines.append('- 无')
lines.extend(['', '### 插件'])
lines.extend(
f'- `{item.id}` {item.version or ""} {item.description or ""}'.rstrip() for item in dependencies.plugins
@@ -8,7 +8,7 @@ from typing import Literal
from packaging.requirements import InvalidRequirement
from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT
from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT, PluginRuntimeEnvironmentService
from plugins.core.manifest.schema import PluginManifest
from plugins.core.validation.python_requirements import PythonRequirementParser
from plugins.core.validation.versioning import PluginVersionComparator, PluginVersionConstraintMatcher
@@ -534,32 +534,47 @@ class PluginDependencyChecker:
python_inspector: PythonDependencyInspector | None = None,
npm_inspector: NpmDependencyInspector | None = None,
frontend_mode: Literal['dev', 'built'] = 'dev',
runtime_environment: PluginRuntimeEnvironmentService | None = None,
) -> None:
"""
初始化插件依赖检查器。
:param python_inspector: Python 依赖检查器
:param npm_inspector: npm 依赖检查器
:param npm_inspector: 当前宿主前端的 npm 依赖检查器
:param frontend_mode: 前端运行模式,已构建模式跳过 npm 依赖实际检查
:param runtime_environment: 保留当前宿主显式框架选择的运行时环境
:return: None
"""
self.runtime_environment = runtime_environment or PLUGIN_RUNTIME_ENVIRONMENT
self.python_inspector = python_inspector or PythonDependencyInspector()
self.npm_inspector = npm_inspector or NpmDependencyInspector()
self.npm_inspector = npm_inspector or NpmDependencyInspector(
frontend_root=self.runtime_environment.get_frontend_dir()
)
self.frontend_mode = frontend_mode
def check_manifest(self, manifest: PluginManifest) -> DependencyCheckResult:
"""
检查插件清单依赖。
根据当前宿主前端框架选择 frontend 中对应分类,只检查该分类声明的前端依赖。
:param manifest: 插件清单
:return: 插件依赖检查结果
"""
items = []
items.extend(self.python_inspector.check(manifest.dependencies.python))
frontend_framework = (
self.runtime_environment.get_frontend_framework(self.npm_inspector.frontend_root)
if manifest.dependencies.frontend
else 'vue3'
)
frontend_dependencies = manifest.dependencies.resolve_frontend(frontend_framework)
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))
items.extend(self.npm_inspector.skip(frontend_dependencies.npm))
items.extend(self.npm_inspector.skip(frontend_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))
items.extend(self.npm_inspector.check(frontend_dependencies.npm))
items.extend(self.npm_inspector.check(frontend_dependencies.npm_dev, dev=True))
return DependencyCheckResult(plugin_id=manifest.id, items=items)
@@ -10,6 +10,8 @@ import yaml
from packaging.requirements import InvalidRequirement
from pydantic import ValidationError
from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT, PluginRuntimeEnvironmentService
from plugins.core.frontend import validate_frontend_framework
from plugins.core.validation.dependencies import (
DependencyInstallPlan,
DependencyInstallPlanItem,
@@ -465,10 +467,15 @@ class DependencyLockEntry:
class DependencyLockfile:
"""
插件依赖锁文件。
:param path: 锁文件路径
:param entries: 按依赖类型和包名索引的锁定依赖
:param frontend_framework: 锁文件对应的宿主前端框架,未声明时不检查框架
"""
path: Path
entries: dict[tuple[DependencyKind, str], DependencyLockEntry]
frontend_framework: str | None = None
@classmethod
def load(cls, path: Path | str | None) -> 'DependencyLockfile | None':
@@ -477,6 +484,7 @@ class DependencyLockfile:
:param path: 锁文件路径
:return: 锁文件对象,缺失时返回 None
:raises ValueError: 锁文件使用旧框架字段或包含无效的前端框架标识
"""
if path is None:
return None
@@ -484,6 +492,15 @@ class DependencyLockfile:
if not lockfile_path.is_file():
return None
data = yaml.safe_load(lockfile_path.read_text(encoding='utf-8')) or {}
if not isinstance(data, dict):
raise ValueError('依赖锁文件顶层必须是对象')
if 'frontendVersion' in data:
raise ValueError('依赖锁文件 frontendVersion 已移除,请迁移为 frontendFramework 或重新生成锁文件')
frontend_framework = None
if 'frontendFramework' in data:
if not isinstance(data['frontendFramework'], str):
raise ValueError('依赖锁文件 frontendFramework 必须是小写框架标识')
frontend_framework = validate_frontend_framework(data['frontendFramework'])
entries: dict[tuple[DependencyKind, str], DependencyLockEntry] = {}
for kind in ('python', 'npm', 'npmDev'):
for raw_entry in data.get(kind, []) or []:
@@ -502,7 +519,7 @@ class DependencyLockfile:
)
entries[(kind, normalize_dependency_name(kind, name))] = entry
return cls(path=lockfile_path, entries=entries)
return cls(path=lockfile_path, entries=entries, frontend_framework=frontend_framework)
def get_entry(self, item: DependencyInstallPlanItem) -> DependencyLockEntry | None:
"""
@@ -728,14 +745,21 @@ class DependencyInstallPolicyEvaluator:
插件依赖安装策略判定器。
"""
def __init__(self, config: DependencyInstallPolicyConfig | None = None) -> None:
def __init__(
self,
config: DependencyInstallPolicyConfig | None = None,
*,
runtime_environment: PluginRuntimeEnvironmentService | None = None,
) -> None:
"""
初始化策略判定器。
:param config: 策略配置
:param runtime_environment: 保留当前宿主显式框架选择的运行时环境
:return: None
"""
self.config = config or DependencyInstallPolicyConfig.from_environment()
self.runtime_environment = runtime_environment or PLUGIN_RUNTIME_ENVIRONMENT
def evaluate(
self, install_plan: DependencyInstallPlan, *, confirmed: bool = False
@@ -977,16 +1001,16 @@ class DependencyInstallPolicyEvaluator:
return True
return version_satisfies_range(lock_entry.resolved_version, required_version)
@staticmethod
def _build_lockfile_extra_reasons(
self,
lockfile: DependencyLockfile | None,
install_plan: DependencyInstallPlan,
) -> list[str]:
"""
构建锁文件额外安装项阻断原因。
构建锁文件宿主框架及额外依赖的阻断原因。
:param lockfile: 锁文件
:param install_plan: 安装计划
:param install_plan: 安装计划,包含前端依赖的目标工作目录
:return: 阻断原因
"""
if lockfile is None:
@@ -996,6 +1020,18 @@ class DependencyInstallPolicyEvaluator:
for item in install_plan.items
}
reasons = []
if lockfile.frontend_framework:
frontend_roots = {item.workdir for item in install_plan.items if item.kind in {'npm', 'npmDev'}}
for frontend_root in sorted(frontend_roots):
try:
frontend_framework = self.runtime_environment.get_frontend_framework(Path(frontend_root))
except ValueError as exc:
reasons.append(str(exc))
continue
if frontend_framework != lockfile.frontend_framework:
reasons.append(
f'锁文件前端框架不匹配:lock={lockfile.frontend_framework} host={frontend_framework}'
)
for entry in lockfile.entries.values():
key = (entry.kind, normalize_dependency_name(entry.kind, entry.name), entry.requirement)
if key not in plan_keys:
@@ -185,14 +185,19 @@ class PluginManifestChecker:
"""
检查未声明版本约束的依赖。
检查 Python 依赖及 frontend 中所有前端框架分类,不依赖当前宿主选择。
:param manifest: 插件 manifest
:return: 未声明版本约束问题项列表
"""
issues = []
for dependency_kind, requirements in (
('python', manifest.dependencies.python),
('npm', manifest.dependencies.npm),
('npmDev', manifest.dependencies.npm_dev),
*[
(f'frontend.{framework}.{kind}', requirements)
for framework, profile in manifest.dependencies.frontend.items()
for kind, requirements in (('npm', profile.npm), ('npmDev', profile.npm_dev))
],
):
for index, requirement in enumerate(requirements):
if dependency_kind == 'python':
@@ -15,12 +15,12 @@ npm --prefix plugins/examples/python/bundle_demo/web install
npm --prefix plugins/examples/python/bundle_demo/web run build
```
首次安装会生成此示例自己的锁文件;独立维护插件时应保留锁文件,在 CI 中使用 `npm ci`。示例的 SDK 别名指向本仓库 `ruoyi-fastapi-frontend/src/utils/pluginBridge.js`。迁移到独立插件仓库时,将该模块及相邻的 `pluginBridge.d.ts` 一起复制为自己的版本化源码依赖,并调整 `vite.config.js` 中 `@ruoyi/plugin-bridge` 的别名。TypeScript 项目还应在自己的 `tsconfig.json` 中将同一别名映射到复制后的模块路径。
首次安装会生成此示例自己的锁文件;独立维护插件时应保留锁文件,在 CI 中使用 `npm ci`。示例的 SDK 别名指向本仓库 `ruoyi-fastapi-frontend/vue3/web/src/utils/pluginBridge.js`。迁移到独立插件仓库时,将该模块及相邻的 `pluginBridge.d.ts` 一起复制为自己的版本化源码依赖,并调整 `vite.config.js` 中 `@ruoyi/plugin-bridge` 的别名。TypeScript 项目还应在自己的 `tsconfig.json` 中将同一别名映射到复制后的模块路径。
如果本仓库宿主前端依赖已安装,也可复用其中的 Vite 可执行文件构建**同一个插件前端项目**:
```bash
node ../ruoyi-fastapi-frontend/node_modules/vite/bin/vite.js build plugins/examples/python/bundle_demo/web --config plugins/examples/python/bundle_demo/web/vite.config.js
node ../ruoyi-fastapi-frontend/vue3/web/node_modules/vite/bin/vite.js build plugins/examples/python/bundle_demo/web --config plugins/examples/python/bundle_demo/web/vite.config.js
```
两种方式均输出 `plugins/examples/python/bundle_demo/web/dist`。配置使用 `base: './'`、关闭 sourcemap;宿主交付 HTML 时注入有效 `<base>` 和运行基址,因此资源和页面深层链接不需写死域名或代理前缀。直接用 `file://` 打开 dist 不会建立宿主身份桥;真实账号交互应从管理平台的插件菜单进入。
@@ -233,7 +233,7 @@ ASGI 网关对已建立的 SSE/WebSocket 按 15 秒间隔复核登录、插件
```bash
python -m pytest tests/plugins/core/runtime/test_bundle.py tests/plugins/core/runtime/test_browser_session.py tests/plugins/core/runtime/test_bundle_transport.py tests/plugins/core/runtime/test_bundle_files.py tests/plugins/core/runtime/test_bundle_events.py tests/plugins/core/runtime/test_connections.py tests/plugins/core/runtime/test_connection_sessions.py tests/plugins/core/runtime/test_configuration.py tests/module_admin/service/test_login_plugin_routes.py -q
npm --prefix ../ruoyi-fastapi-frontend run test:plugin
npm --prefix ../ruoyi-fastapi-frontend/vue3/web run test:plugin
```
测试覆盖静态边界、Cookie/CSRF/退出、真实 RSA/AES 协议、文件大小、`root_path`、流连接复核及运行时关闭顺序;浏览器会话测试替换真实账号查询,配置读取测试使用隔离 SQLite 验证保存、解密及重启版本。实际角色配置、TLS/Nginx、多 worker 和 ASGI 服务器的优雅停机阶段仍需部署验收。网关的周期关闭与协作式取消不能强制终止忽略取消的 Python 代码,也不提供跨 worker 的即时撤销广播。
@@ -27,7 +27,7 @@ export default {
alias: {
'@ruoyi/plugin-bridge': fileURLToPath(
new URL(
'../../../../../../ruoyi-fastapi-frontend/src/utils/pluginBridge.js',
'../../../../../../ruoyi-fastapi-frontend/vue3/web/src/utils/pluginBridge.js',
import.meta.url
)
),
@@ -27,7 +27,7 @@ export default {
alias: {
'@ruoyi/plugin-bridge': fileURLToPath(
new URL(
'../../../../../../ruoyi-fastapi-frontend/src/utils/pluginBridge.js',
'../../../../../../ruoyi-fastapi-frontend/vue3/web/src/utils/pluginBridge.js',
import.meta.url
)
),
+7 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ruoyi-fastapi-backend-cli"
version = "1.10.0"
description = "RuoYi FastAPI backend internal CLI entrypoint"
description = "RuoYi-FastAPI backend internal CLI entrypoint"
requires-python = ">=3.10"
[project.scripts]
@@ -13,6 +13,12 @@ ruoyi = "cli.main:main"
[tool.pytest.ini_options]
pythonpath = ["."]
markers = [
"browser: requires a real browser and the frontend network test host",
"native: requires a prebuilt native plugin artifact",
"integration: requires explicitly enabled external database or cache services",
"contract: validates public CLI arguments, output, or cold-process behavior",
]
[tool.setuptools.packages.find]
include = ["cli*"]
@@ -0,0 +1,4 @@
pytest==9.1.1
pytest-asyncio==1.4.0
aiosqlite==0.22.1
PyYAML==6.0.3
@@ -2,12 +2,14 @@ import json
import os
import subprocess
import sys
from collections.abc import Callable
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any
import pytest
CLI_PROCESS_TIMEOUT_SECONDS = 60
@pytest.fixture
def backend_dir() -> Path:
@@ -16,13 +18,16 @@ def backend_dir() -> Path:
@pytest.fixture
def run_cli_command(backend_dir: Path) -> Callable[..., subprocess.CompletedProcess[str]]:
def _run_cli_command(*args: str) -> subprocess.CompletedProcess[str]:
def _run_cli_command(*args: str, env: Mapping[str, str] | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, '-m', 'cli.main', *args],
[sys.executable, '-X', 'utf8', '-m', 'cli.main', *args],
cwd=backend_dir,
env=env,
capture_output=True,
text=True,
encoding='utf-8',
check=False,
timeout=CLI_PROCESS_TIMEOUT_SECONDS,
)
return _run_cli_command
@@ -49,11 +54,13 @@ def run_cli_completion_command(
instruction: str = 'bash_complete',
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, '-m', 'cli.main'],
[sys.executable, '-X', 'utf8', '-m', 'cli.main'],
cwd=backend_dir,
capture_output=True,
text=True,
encoding='utf-8',
check=False,
timeout=CLI_PROCESS_TIMEOUT_SECONDS,
env={
**os.environ,
'COMP_WORDS': comp_words,
@@ -65,6 +72,29 @@ def run_cli_completion_command(
return _run_cli_completion_command
@pytest.fixture
def untracked_bundle_project(tmp_path: Path) -> Path:
"""
创建带未跟踪 SDK 的隔离 bundle 工程,供 CLI 检查与更新测试复用。
:param tmp_path: pytest 临时目录
:return: 不包含可执行业务插件的测试工程目录
"""
root = tmp_path / 'example'
vendor = root / 'web' / 'vendor'
vendor.mkdir(parents=True)
(root / 'plugin.yaml').write_text(
'manifestVersion: 2\nid: example\nname: Example\nversion: 1.0.0\n'
'backend:\n module: plugins.example\n entrypoint: plugins.example:create_plugin\n integration: asgi\n'
'frontend:\n delivery:\n type: bundle\n',
encoding='utf-8',
)
(root / '__init__.py').write_text('raise RuntimeError("must never import this project")\n', encoding='utf-8')
for name in ('pluginBridge.js', 'pluginBridge.d.ts'):
(vendor / name).write_text('old untracked SDK\n', encoding='utf-8')
return root
@pytest.fixture
def parse_json_stdout() -> Callable[[subprocess.CompletedProcess[str]], dict[str, Any]]:
def _parse_json_stdout(completed: subprocess.CompletedProcess[str]) -> dict[str, Any]:
@@ -4,6 +4,9 @@ import subprocess
import sys
from pathlib import Path
import pytest
pytestmark = pytest.mark.contract
BACKEND_DIR = Path(__file__).resolve().parents[3]
@@ -31,6 +34,7 @@ print(json.dumps({
capture_output=True,
text=True,
check=False,
timeout=30,
)
assert completed.returncode == 0, completed.stderr
@@ -78,6 +82,7 @@ print(json.dumps({
capture_output=True,
text=True,
check=False,
timeout=30,
)
assert completed.returncode == 0, completed.stderr
@@ -143,6 +148,7 @@ print(json.dumps({
capture_output=True,
text=True,
check=False,
timeout=30,
)
assert completed.returncode == 0, completed.stderr
@@ -204,6 +210,7 @@ print(json.dumps({
capture_output=True,
text=True,
check=False,
timeout=30,
)
assert completed.returncode == 0, completed.stderr
@@ -5,10 +5,13 @@ from collections.abc import Callable
from pathlib import Path
from textwrap import dedent
from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS
from cli.runtime.plugin.scaffold import PluginFrontendVersionResolver
import pytest
FRONTEND_ROOT = Path(__file__).resolve().parents[4] / 'ruoyi-fastapi-frontend'
from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, SUCCESS
from cli.runtime.plugin.scaffold import PluginFrontendFrameworkResolver
pytestmark = pytest.mark.contract
FRONTEND_ROOT = Path(__file__).resolve().parents[4] / 'ruoyi-fastapi-frontend' / 'vue3' / 'web'
def test_app_config_json_output_is_pure_json(
@@ -469,7 +472,7 @@ def test_plugin_create_dry_run_json_output_has_stable_contract(
assert payload['dryRun'] is True
assert payload['backend'] is True
assert payload['frontend'] is True
assert payload['frontendVersion'] == PluginFrontendVersionResolver.resolve(FRONTEND_ROOT)
assert payload['frontendFramework'] == PluginFrontendFrameworkResolver.resolve(FRONTEND_ROOT)
assert payload['migration'] is True
assert payload['seed'] is True
assert payload['job'] is True
@@ -508,7 +511,7 @@ def test_plugin_create_optional_parts_json_output_has_stable_contract(
assert payload['config'] is False
def test_plugin_create_frontend_version_override_json_output_has_stable_contract(
def test_plugin_create_frontend_framework_override_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict],
) -> None:
@@ -518,7 +521,7 @@ def test_plugin_create_frontend_version_override_json_output_has_stable_contract
'contract_vue2',
'--dry-run',
'--frontend-only',
'--frontend-version=vue2',
'--frontend-framework=vue2',
'--env=dev',
'--output=json',
)
@@ -527,10 +530,38 @@ def test_plugin_create_frontend_version_override_json_output_has_stable_contract
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 payload['frontendFramework'] == 'vue2'
assert FRONTEND_ROOT.parents[1] / 'vue2/web/plugins/contract_vue2' in [Path(path) for path in payload['targetDirs']]
assert 'slot="header"' in view_payload['content']
def test_plugin_create_rejects_framework_outside_fixed_choices(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
"""校验根命令在参数解析阶段拒绝不属于固定选项的 React 框架。"""
completed = run_cli_command(
'plugin',
'create',
'contract_react',
'--dry-run',
'--frontend-only',
'--frontend-framework=react',
'--env=dev',
'--output=json',
)
assert completed.returncode == ARGUMENT_ERROR
assert 'Invalid value' in completed.stderr
assert 'react' in completed.stderr
def test_plugin_create_rejects_removed_frontend_version_option(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
) -> None:
"""校验创建命令不再接受已移除的前端版本选项。"""
completed = run_cli_command('plugin', 'create', 'contract_old_option', '--dry-run', '--frontend-version=vue3')
assert completed.returncode != SUCCESS
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],
@@ -2,8 +2,12 @@ import subprocess
from collections.abc import Callable
from pathlib import Path
import pytest
from cli.exit_codes import SUCCESS
pytestmark = pytest.mark.contract
def test_root_help_shows_commands_without_completion_options(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
@@ -1,8 +1,12 @@
import subprocess
from collections.abc import Callable
import pytest
from cli.exit_codes import DATABASE_ERROR, REDIS_ERROR, RUNTIME_ERROR, SUCCESS
pytestmark = pytest.mark.contract
def test_config_doctor_json_output_has_stable_contract(
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
@@ -2,8 +2,11 @@ import subprocess
from collections.abc import Callable
from pathlib import Path
import pytest
from cli.exit_codes import DATABASE_ERROR, GUARD_REJECTED, REDIS_ERROR, RUNTIME_ERROR, SUCCESS
pytestmark = pytest.mark.contract
DB_HISTORY_TEST_LIMIT = 5
@@ -1,8 +1,11 @@
import subprocess
from collections.abc import Callable
import pytest
from cli.exit_codes import ARGUMENT_ERROR, DATABASE_ERROR, GUARD_REJECTED, RUNTIME_ERROR, SUCCESS
pytestmark = pytest.mark.contract
GEN_TEST_TABLE_NAME = 'demo_table'
GEN_CREATE_SQL = 'CREATE TABLE demo_cli_test (id bigint);'
MISSING_JOB_ID = 999999999
@@ -20,6 +20,7 @@ from cli.exit_codes import ARGUMENT_ERROR, GUARD_REJECTED
from cli.groups.plugin.artifact_controller import PluginArtifactCommandController
from cli.groups.plugin.commands.artifact import register_artifact_commands
pytestmark = pytest.mark.contract
BACKEND_ROOT = Path(__file__).resolve().parents[3]
DIGEST = 'a' * 64
GENERATION = 'b' * 32
@@ -257,7 +258,7 @@ def test_help_keeps_config_crypto_database_and_plugin_code_unloaded(help_argumen
f'print(json.dumps({{"exit":r.exit_code,"loaded":{{name:name in sys.modules for name in {observed!r}}}}}))'
)
result = subprocess.run(
[sys.executable, '-c', script], cwd=BACKEND_ROOT, capture_output=True, text=True, check=True
[sys.executable, '-c', script], cwd=BACKEND_ROOT, capture_output=True, text=True, check=True, timeout=30
)
payload = json.loads(result.stdout)
assert payload['exit'] == 0
@@ -4,9 +4,12 @@ import pytest
import typer
from typer.testing import CliRunner
from cli.exit_codes import ARGUMENT_ERROR, SUCCESS
from cli.groups.plugin.commands.developer import register_developer_commands
from cli.groups.plugin.options import PluginCreateCommandOptions
pytestmark = pytest.mark.contract
@pytest.mark.parametrize('template', ['python-asgi', 'python-bundle', 'rust-asgi', 'rust-bundle'])
def test_create_cli_forwards_v2_template_without_changing_default_options(template: str) -> None:
@@ -47,3 +50,46 @@ def test_create_cli_defaults_to_existing_v1_full_stack_template() -> None:
result = CliRunner().invoke(app, ['create', 'legacy_demo'])
assert result.exit_code == 0
assert recorded == [PluginCreateCommandOptions()]
@pytest.mark.parametrize('framework', ['auto', 'vue2', 'vue3'])
def test_create_cli_accepts_fixed_frontend_framework_choices(framework: str) -> None:
"""校验创建命令接受固定框架选项并将原始字符串传递给控制器。"""
recorded: list[PluginCreateCommandOptions] = []
class Controller:
@staticmethod
def create_plugin(plugin_id: str, env: str, output: str, options: PluginCreateCommandOptions) -> None:
recorded.append(options)
app = typer.Typer()
register_developer_commands(app, Controller)
result = CliRunner().invoke(app, ['create', 'demo', '--frontend-framework', framework])
assert result.exit_code == SUCCESS, result.output
assert recorded[0].frontend_framework == framework
@pytest.mark.parametrize('framework', ['react', 'custom_ui-1', '../react', 'Vue3'])
def test_create_cli_rejects_framework_before_resolving_controller(framework: str) -> None:
"""校验非法框架选项在加载控制器或运行时之前被拒绝。"""
def unavailable_controller() -> None:
"""禁止参数错误路径加载控制器。"""
raise AssertionError('不应加载控制器')
app = typer.Typer()
register_developer_commands(app, unavailable_controller)
result = CliRunner().invoke(app, ['create', 'demo', '--frontend-framework', framework])
assert result.exit_code == ARGUMENT_ERROR
assert 'Invalid value' in result.output
assert framework in result.output
assert 'auto' in result.output and 'vue2' in result.output and 'vue3' in result.output
def test_create_cli_help_displays_fixed_frontend_framework_choices() -> None:
"""校验创建命令帮助展示固定的三种框架选项。"""
app = typer.Typer()
register_developer_commands(app, lambda: None)
result = CliRunner().invoke(app, ['create', '--help'], terminal_width=160)
assert result.exit_code == SUCCESS
assert 'auto|vue2|vue3' in result.output
@@ -0,0 +1,130 @@
import json
import os
import subprocess
from collections.abc import Callable
from pathlib import Path
import pytest
import yaml
from cli.exit_codes import SUCCESS
pytestmark = pytest.mark.contract
SDK_FILES = ('pluginBridge.js', 'pluginBridge.d.ts', 'pluginBridge.sdk.json')
@pytest.mark.parametrize('framework', ['vue2', 'vue3'])
def test_cold_cli_preserves_frontend_framework_across_scaffold_sdk_and_dependency_lock(
tmp_path: Path,
backend_dir: Path,
untracked_bundle_project: Path,
run_cli_command: Callable[..., subprocess.CompletedProcess[str]],
framework: str,
) -> None:
"""
校验独立进程从框架环境变量启动后,模板、SDK 与依赖锁选择相同工程。
所有宿主文件位于临时目录;创建和锁定只预演,SDK 更新仅修改测试副本。
"""
backend = tmp_path / 'backend'
frontend_container = tmp_path / 'ruoyi-fastapi-frontend'
for host_framework in ('vue2', 'vue3'):
frontend = frontend_container / host_framework / 'web'
source = frontend / 'src' / 'utils'
source.mkdir(parents=True)
(frontend / 'package.json').write_text(
json.dumps({'dependencies': {'vue': f'^{host_framework[-1]}.0.0'}}), encoding='utf-8'
)
original_source = backend_dir.parent / 'ruoyi-fastapi-frontend' / host_framework / 'web' / 'src' / 'utils'
for name in SDK_FILES:
(source / name).write_bytes((original_source / name).read_bytes())
plugin = backend / 'plugins' / 'environment_demo'
plugin.mkdir(parents=True)
(plugin / 'plugin.yaml').write_text(
yaml.safe_dump(
{
'id': 'environment_demo',
'name': 'Environment Demo',
'version': '1.0.0',
'backend': {'module': 'plugins.environment_demo'},
'dependencies': {
'frontend': {
'vue2': {'npm': ['vue2-renderer==2.0.0']},
'vue3': {'npm': ['vue3-renderer==3.0.0']},
}
},
}
),
encoding='utf-8',
)
(plugin / '__init__.py').write_text('raise RuntimeError("must never import this plugin")\n', encoding='utf-8')
environment = {
key: value for key, value in os.environ.items() if key != 'APP_ENV' and not key.startswith(('RUOYI_', 'OIDC_'))
}
environment.update(
RUOYI_PLUGIN_BACKEND_ROOT=str(backend),
RUOYI_PLUGIN_FRONTEND_ROOT=str(frontend_container),
RUOYI_PLUGIN_FRONTEND_FRAMEWORK=framework,
RUOYI_FRONTEND_FRAMEWORK='vue3' if framework == 'vue2' else 'vue2',
LOG_FILE_ENABLED='false',
)
original_files = {path: path.read_bytes() for path in tmp_path.rglob('*') if path.is_file()}
created = run_cli_command(
'plugin',
'create',
'environment_scaffold',
'--frontend-framework',
'auto',
'--dry-run',
'--output',
'json',
env=environment,
)
assert created.returncode == SUCCESS, created.stdout + created.stderr
scaffold = json.loads(created.stdout)
selected_frontend = frontend_container / framework / 'web'
assert scaffold['frontendFramework'] == framework
assert str(backend / 'plugins' / 'environment_scaffold') in scaffold['targetDirs']
assert str(selected_frontend / 'plugins' / 'environment_scaffold') in scaffold['targetDirs']
assert all(Path(item['path']).is_relative_to(tmp_path) for item in scaffold['files'])
locked = run_cli_command(
'plugin', 'lock-deps', 'environment_demo', '--dry-run', '--output', 'json', env=environment
)
assert locked.returncode == SUCCESS, locked.stdout + locked.stderr
lock_payload = json.loads(locked.stdout)
lockfile = yaml.safe_load(lock_payload['lockfile'])
assert Path(lock_payload['outputFile']) == plugin / 'plugin.lock.yaml'
assert lockfile['frontendFramework'] == framework
assert [item['requirement'] for item in lockfile['npm']] == [f'{framework}-renderer=={framework[-1]}.0.0']
assert lock_payload['written'] is False
assert original_files == {path: path.read_bytes() for path in tmp_path.rglob('*') if path.is_file()}
updated = run_cli_command(
'plugin',
'sdk',
'update',
str(untracked_bundle_project),
'--frontend-framework',
'auto',
'--force',
'--output',
'json',
env=environment,
)
assert updated.returncode == SUCCESS, updated.stdout + updated.stderr
assert json.loads(updated.stdout)['updated'] is True
metadata = json.loads((untracked_bundle_project / 'web/vendor/pluginBridge.sdk.json').read_text(encoding='utf-8'))
assert metadata['source'] == {
'project': 'RuoYi-FastAPI',
'directory': f'ruoyi-fastapi-frontend/{framework}/web/src/utils',
}
host_files = {
path: content for path, content in original_files.items() if not path.is_relative_to(untracked_bundle_project)
}
assert host_files == {
path: path.read_bytes()
for path in tmp_path.rglob('*')
if path.is_file() and not path.is_relative_to(untracked_bundle_project)
}
@@ -4,6 +4,9 @@ import subprocess
import sys
from pathlib import Path
import pytest
pytestmark = pytest.mark.contract
BACKEND_ROOT = Path(__file__).resolve().parents[3]
@@ -20,6 +23,7 @@ def inspect_cold_import(module_name: str, observed_modules: tuple[str, ...]) ->
check=True,
capture_output=True,
text=True,
timeout=30,
)
return json.loads(completed.stdout)
@@ -132,6 +136,7 @@ print(json.dumps({'initial': initial, 'afterController': after_controller, 'afte
check=True,
capture_output=True,
text=True,
timeout=30,
)
observed = json.loads(completed.stdout)
@@ -11,8 +11,9 @@ from typer.testing import CliRunner
from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, SUCCESS
from cli.groups.plugin.commands.sdk import register_sdk_commands
pytestmark = pytest.mark.contract
BACKEND = Path(__file__).resolve().parents[3]
FRONTEND = BACKEND.parent / 'ruoyi-fastapi-frontend'
FRONTEND = BACKEND.parent / 'ruoyi-fastapi-frontend' / 'vue3' / 'web'
def sdk_app() -> typer.Typer:
@@ -27,26 +28,14 @@ def sdk_app() -> typer.Typer:
@pytest.fixture
def project(tmp_path: Path) -> Path:
def project(untracked_bundle_project: Path) -> Path:
"""
创建未跟踪 SDK 的旧 bundle 工程,避免读取业务插件。
:param tmp_path: 隔离测试目录
:param untracked_bundle_project: 共用的隔离 bundle 工程
:return: 工程根目录
"""
root = tmp_path / 'example'
vendor = root / 'web' / 'vendor'
vendor.mkdir(parents=True)
(root / 'plugin.yaml').write_text(
'manifestVersion: 2\nid: example\nname: Example\nversion: 1.0.0\n'
'backend:\n module: plugins.example\n entrypoint: plugins.example:create_plugin\n integration: asgi\n'
'frontend:\n delivery:\n type: bundle\n',
encoding='utf-8',
)
(root / '__init__.py').write_text('raise RuntimeError("must never import this project")\n', encoding='utf-8')
for name in ('pluginBridge.js', 'pluginBridge.d.ts'):
(vendor / name).write_text('old untracked SDK\n', encoding='utf-8')
return root
return untracked_bundle_project
def invoke_sdk(operation: str, project: Path, *options: str) -> tuple[int, dict]:
@@ -104,6 +93,87 @@ def test_sdk_cli_bad_source_is_json_and_nonzero(project: Path, tmp_path: Path) -
assert json.loads(result.stdout)['ok'] is False
@pytest.mark.parametrize(('framework', 'selected_framework'), [('auto', 'vue3'), ('vue2', 'vue2'), ('vue3', 'vue3')])
def test_sdk_cli_selects_supported_framework_from_frontend_container(
project: Path, framework: str, selected_framework: str
) -> None:
"""校验 SDK 更新和检查命令均接受三个固定框架选项。"""
result = CliRunner().invoke(
sdk_app(),
[
'sdk',
'update',
str(project),
'--frontend-root',
str(FRONTEND.parents[1]),
'--frontend-framework',
framework,
'--force',
'--output',
'json',
],
)
assert result.exit_code == SUCCESS, result.stdout
metadata = json.loads((project / 'web/vendor/pluginBridge.sdk.json').read_text(encoding='utf-8'))
assert metadata['source']['directory'] == f'ruoyi-fastapi-frontend/{selected_framework}/web/src/utils'
check_result = CliRunner().invoke(
sdk_app(),
[
'sdk',
'check',
str(project),
'--frontend-root',
str(FRONTEND.parents[1]),
'--frontend-framework',
framework,
'--output',
'json',
],
)
assert check_result.exit_code == SUCCESS, check_result.stdout
assert json.loads(check_result.stdout)['status'] == 'current'
@pytest.mark.parametrize('operation', ['check', 'update'])
@pytest.mark.parametrize('framework', ['react', 'custom_ui-1', '../react', 'Vue3'])
def test_sdk_cli_rejects_unsupported_framework_before_file_operations(
project: Path, operation: str, framework: str
) -> None:
"""校验 SDK 命令在参数解析阶段拒绝固定选项之外的框架且不修改工程。"""
before = {path.relative_to(project): path.read_bytes() for path in project.rglob('*') if path.is_file()}
result = CliRunner().invoke(
sdk_app(),
[
'sdk',
operation,
str(project),
'--frontend-framework',
framework,
'--output',
'json',
],
)
assert result.exit_code == ARGUMENT_ERROR
assert 'Invalid value' in result.output
assert framework in result.output
assert 'auto' in result.output and 'vue2' in result.output and 'vue3' in result.output
assert before == {path.relative_to(project): path.read_bytes() for path in project.rglob('*') if path.is_file()}
@pytest.mark.parametrize('operation', ['check', 'update'])
def test_sdk_cli_help_displays_fixed_frontend_framework_choices(operation: str) -> None:
"""校验两个 SDK 命令的帮助均展示固定框架选项。"""
result = CliRunner().invoke(sdk_app(), ['sdk', operation, '--help'], terminal_width=160)
assert result.exit_code == SUCCESS
assert 'auto|vue2|vue3' in result.output
def test_sdk_cli_rejects_removed_frontend_version_option(project: Path) -> None:
"""校验旧版本选项已移除,不保留兼容别名。"""
result = CliRunner().invoke(sdk_app(), ['sdk', 'check', str(project), '--frontend-version', 'vue3'])
assert result.exit_code == ARGUMENT_ERROR
def test_sdk_cli_invalid_output_is_argument_error(project: Path) -> None:
"""
验证 SDK 子命令复用 CLI 输出格式约束。
@@ -4,6 +4,7 @@ from subprocess import CompletedProcess
from cli.runtime.plugin.service import CliPluginRuntimeService
from plugins.core.environment import PluginRuntimeEnvironmentService
from plugins.core.frontend import PluginFrontendFrameworkResolver
from plugins.core.validation.dependencies import (
NpmDependencyInspector,
PluginDependencyChecker,
@@ -37,6 +38,10 @@ class FakeRuntimeEnvironment:
"""获取前端项目根目录。"""
return str(self.frontend_dir)
def get_frontend_framework(self, frontend_root: Path | None = None) -> str:
"""获取指定测试前端工程的框架标识。"""
return PluginFrontendFrameworkResolver.resolve(frontend_root or self.frontend_dir)
def get_frontend_plugins_dir(self) -> str:
"""获取前端插件根目录。"""
return str(self.frontend_dir / 'plugins')
@@ -15,8 +15,7 @@ def write_demo_dependency_manifest(backend_root: Path) -> None:
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': [],
'frontend': {'vue3': {'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')
@@ -43,6 +42,8 @@ def test_plugin_runtime_lock_dependencies_dry_run_returns_lockfile_template(tmp_
lockfile = yaml.safe_load(payload['lockfile'])
assert lockfile['plugin'] == 'demo'
assert lockfile['version'] == '0.1.0'
assert lockfile['frontendFramework'] == 'vue3'
assert 'frontendVersion' not in lockfile
assert lockfile['python'][0]['name'] == 'openai'
assert lockfile['python'][0]['requirement'] == 'openai>=2.0.0,<3.0.0'
assert lockfile['python'][0]['resolvedVersion'] == ''
@@ -1,7 +1,10 @@
import json
from pathlib import Path
import pytest
from cli.runtime.plugin.service import CliPluginRuntimeService
from plugins.core.environment import PluginRuntimeEnvironmentService
from .conftest import FakeRuntimeEnvironment, build_runtime
@@ -62,16 +65,21 @@ def test_plugin_runtime_create_plugin_dry_run_does_not_write_files(tmp_path: Pat
assert payload['template'] == 'full-stack'
assert payload['backend'] is True
assert payload['frontend'] is True
assert payload['frontendVersion'] == 'vue3'
assert payload['frontendFramework'] == '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 (
str(project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / '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()
assert not (
project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js'
).exists()
def test_plugin_runtime_create_plugin_rejects_unsafe_plugin_id(tmp_path: Path) -> None:
@@ -93,6 +101,7 @@ def test_plugin_runtime_create_plugin_uses_runtime_frontend_dir(tmp_path: Path)
backend_root = project_root / 'api-server'
frontend_root = project_root / 'web-client'
backend_root.mkdir(parents=True)
write_frontend_package(frontend_root, {'vue': '^3.5.26'})
payload = build_runtime(backend_root, frontend_root=frontend_root).create_plugin('demo')
@@ -115,7 +124,7 @@ def test_plugin_runtime_create_plugin_auto_detects_vue2_frontend(tmp_path: Path)
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 payload['frontendFramework'] == 'vue2'
assert '<script>' in view_content
assert 'export default {' in view_content
assert 'slot-scope="scope"' in view_content
@@ -137,7 +146,7 @@ def test_plugin_runtime_create_plugin_auto_detects_vue3_frontend(tmp_path: Path)
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'] == 'vue3'
assert payload['frontendFramework'] == 'vue3'
assert '<script setup' in view_content
assert '<template #default="scope">' in view_content
assert 'export default {' not in view_content
@@ -145,8 +154,8 @@ def test_plugin_runtime_create_plugin_auto_detects_vue3_frontend(tmp_path: Path)
assert "'../../../plugins/demo/views/index.vue'" in test_content
def test_plugin_runtime_create_plugin_supports_frontend_version_override(tmp_path: Path) -> None:
"""校验显式 frontend_version 会覆盖 package.json 自动识别结果。"""
def test_plugin_runtime_create_plugin_supports_frontend_framework_override(tmp_path: Path) -> None:
"""校验显式 frontend_framework 会覆盖 package.json 自动识别结果。"""
project_root = tmp_path / 'project'
backend_root = project_root / 'api-server'
frontend_root = project_root / 'web-client'
@@ -156,25 +165,75 @@ def test_plugin_runtime_create_plugin_supports_frontend_version_override(tmp_pat
payload = build_runtime(backend_root, frontend_root=frontend_root).create_plugin(
'demo',
template='crud-page',
frontend_version='vue2',
frontend_framework='vue2',
dry_run=True,
)
view_payload = next(file for file in payload['files'] if str(file['path']).endswith('/views/index.vue'))
assert payload['ok'] is True
assert payload['frontendVersion'] == 'vue2'
assert payload['frontendFramework'] == 'vue2'
assert 'export default {' in view_payload['content']
def test_plugin_runtime_create_plugin_rejects_unknown_frontend_version(tmp_path: Path) -> None:
"""校验未知的 frontend_version 会返回清晰错误。"""
def test_plugin_create_preserves_independent_host_framework_context(tmp_path: Path) -> None:
"""校验独立宿主的运行时框架选择会保留,创建命令显式参数仍可覆盖。"""
frontend = tmp_path / 'web-client'
write_frontend_package(frontend, {'vue': '^3.5.26'})
runtime = CliPluginRuntimeService(
runtime_environment=PluginRuntimeEnvironmentService(
backend_root=tmp_path / 'backend', frontend_root=frontend, frontend_framework='vue2'
)
)
payload = runtime.create_plugin('demo', dry_run=True)
override = runtime.create_plugin('demo', frontend_framework='vue3', dry_run=True)
assert payload['ok'] is True
assert payload['frontendFramework'] == 'vue2'
assert override['ok'] is True
assert override['frontendFramework'] == 'vue3'
def test_backend_only_scaffold_does_not_require_frontend_framework(tmp_path: Path) -> None:
"""校验仅后端模板不要求解析不存在的独立前端工程。"""
runtime = CliPluginRuntimeService(
runtime_environment=PluginRuntimeEnvironmentService(
backend_root=tmp_path / 'backend', frontend_root=tmp_path / 'missing-frontend'
)
)
payload = runtime.create_plugin('demo', frontend=False, dry_run=True)
assert payload['ok'] is True
assert payload['frontendFramework'] is None
@pytest.mark.parametrize('framework', ['vue4', 'react', 'custom_ui-1'])
def test_plugin_runtime_create_plugin_rejects_unsupported_frontend_template(tmp_path: Path, framework: str) -> None:
"""校验合法但没有源码模板的框架会返回清晰错误且不写文件。"""
backend_root = tmp_path / 'project' / 'api-server'
backend_root.mkdir(parents=True)
payload = build_runtime(backend_root).create_plugin('demo', frontend_version='vue4', dry_run=True)
payload = build_runtime(backend_root).create_plugin('demo', frontend_framework=framework)
assert payload['ok'] is False
assert 'frontend_version 仅支持 auto、vue2、vue3' in str(payload['error'])
assert f'暂不支持前端框架 {framework} 的源码模板' in str(payload['error'])
assert not (backend_root / 'plugins/demo').exists()
def test_plugin_scaffold_selects_vue2_workspace_and_leaves_vue3_untouched(tmp_path: Path) -> None:
"""校验显式选择 Vue 2 时,脚手架将页面与测试写入同一个 Vue 2 工程。"""
backend_root = tmp_path / 'ruoyi-fastapi-backend'
backend_root.mkdir()
container = tmp_path / 'ruoyi-fastapi-frontend'
write_frontend_package(container / 'vue2/web', {'vue': '^2.7.16'})
write_frontend_package(container / 'vue3/web', {'vue': '^3.5.26'})
payload = build_runtime(backend_root).create_plugin('demo', frontend_framework='vue2')
assert payload['ok'] is True
assert payload['frontendFramework'] == 'vue2'
assert (container / 'vue2/web/plugins/demo/views/index.vue').is_file()
assert (container / 'vue2/web/tests/plugins/demo/pluginView.test.js').is_file()
assert not (container / 'vue3/web/plugins/demo').exists()
def test_plugin_runtime_create_plugin_writes_backend_and_frontend_files(tmp_path: Path) -> None:
@@ -196,8 +255,12 @@ def test_plugin_runtime_create_plugin_writes_backend_and_frontend_files(tmp_path
assert (backend_root / 'plugins' / 'demo' / 'migrations' / '001_init.sql').is_file()
assert (backend_root / 'plugins' / 'demo' / 'seeds' / '001_seed.sql').is_file()
assert (backend_root / 'tests' / 'plugins' / 'demo' / 'test_ping.py').is_file()
assert (project_root / 'ruoyi-fastapi-frontend' / 'plugins' / 'demo' / 'views' / 'index.vue').is_file()
assert (project_root / 'ruoyi-fastapi-frontend' / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js').is_file()
assert (
project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'plugins' / 'demo' / 'views' / 'index.vue'
).is_file()
assert (
project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js'
).is_file()
def test_plugin_runtime_create_plugin_generates_checkable_template(tmp_path: Path) -> None:
@@ -221,8 +284,12 @@ def test_plugin_runtime_create_plugin_generates_checkable_template(tmp_path: Pat
assert (backend_root / 'plugins' / 'demo' / 'migrations' / '001_init.sql').is_file()
assert (backend_root / 'plugins' / 'demo' / 'seeds' / '001_seed.sql').is_file()
assert (backend_root / 'tests' / 'plugins' / 'demo' / 'test_ping.py').is_file()
assert (project_root / 'ruoyi-fastapi-frontend' / 'plugins' / 'demo' / 'views' / 'index.vue').is_file()
assert (project_root / 'ruoyi-fastapi-frontend' / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js').is_file()
assert (
project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'plugins' / 'demo' / 'views' / 'index.vue'
).is_file()
assert (
project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js'
).is_file()
def test_plugin_runtime_create_plugin_supports_optional_scaffold_parts(tmp_path: Path) -> None:
@@ -245,7 +312,7 @@ def test_plugin_runtime_create_plugin_supports_optional_scaffold_parts(tmp_path:
assert create_payload['ok'] is True
assert create_payload['frontend'] is False
assert create_payload['frontendVersion'] is None
assert create_payload['frontendFramework'] is None
assert create_payload['migration'] is False
assert create_payload['seed'] is False
assert create_payload['job'] is False
@@ -253,12 +320,12 @@ def test_plugin_runtime_create_plugin_supports_optional_scaffold_parts(tmp_path:
assert create_payload['test'] is False
assert check_payload['ok'] is True
assert check_payload['checks'][0]['structureErrors'] == []
assert not (project_root / 'ruoyi-fastapi-frontend' / 'plugins' / 'demo').exists()
assert not (project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'plugins' / 'demo').exists()
assert not (backend_root / 'plugins' / 'demo' / 'jobs.py').exists()
assert not (backend_root / 'plugins' / 'demo' / 'migrations' / '001_init.sql').exists()
assert not (backend_root / 'plugins' / 'demo' / 'seeds' / '001_seed.sql').exists()
assert not (backend_root / 'tests' / 'plugins' / 'demo' / 'test_ping.py').exists()
assert not (project_root / 'ruoyi-fastapi-frontend' / 'tests' / 'plugins' / 'demo').exists()
assert not (project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'tests' / 'plugins' / 'demo').exists()
def test_plugin_runtime_create_plugin_supports_minimal_template(tmp_path: Path) -> None:
@@ -275,14 +342,14 @@ def test_plugin_runtime_create_plugin_supports_minimal_template(tmp_path: Path)
assert create_payload['template'] == 'minimal'
assert create_payload['backend'] is True
assert create_payload['frontend'] is False
assert create_payload['frontendVersion'] is None
assert create_payload['frontendFramework'] is None
assert create_payload['migration'] is False
assert create_payload['seed'] is False
assert create_payload['job'] is False
assert create_payload['config'] is False
assert create_payload['test'] is True
assert check_payload['ok'] is True
assert not (project_root / 'ruoyi-fastapi-frontend' / 'plugins' / 'demo').exists()
assert not (project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'plugins' / 'demo').exists()
assert not (backend_root / 'plugins' / 'demo' / 'jobs.py').exists()
assert (backend_root / 'tests' / 'plugins' / 'demo' / 'test_ping.py').is_file()
@@ -301,14 +368,14 @@ def test_plugin_runtime_create_plugin_supports_scheduled_job_template(tmp_path:
assert create_payload['template'] == 'scheduled-job'
assert create_payload['backend'] is True
assert create_payload['frontend'] is False
assert create_payload['frontendVersion'] is None
assert create_payload['frontendFramework'] is None
assert create_payload['migration'] is False
assert create_payload['seed'] is False
assert create_payload['job'] is True
assert create_payload['config'] is False
assert check_payload['ok'] is True
assert (backend_root / 'plugins' / 'demo' / 'jobs.py').is_file()
assert not (project_root / 'ruoyi-fastapi-frontend' / 'plugins' / 'demo').exists()
assert not (project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'plugins' / 'demo').exists()
def test_plugin_runtime_create_plugin_supports_crud_page_template(tmp_path: Path) -> None:
@@ -334,8 +401,10 @@ def test_plugin_runtime_create_plugin_supports_crud_page_template(tmp_path: Path
assert (backend_root / 'tests' / 'plugins' / 'demo' / 'test_ping.py').read_text(encoding='utf-8').find(
'service_crud_flow'
) > -1
assert (project_root / 'ruoyi-fastapi-frontend' / 'plugins' / 'demo' / 'api' / 'demo.js').is_file()
assert (project_root / 'ruoyi-fastapi-frontend' / 'plugins' / 'demo' / 'views' / 'index.vue').is_file()
assert (project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'plugins' / 'demo' / 'api' / 'demo.js').is_file()
assert (
project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'plugins' / 'demo' / 'views' / 'index.vue'
).is_file()
def test_plugin_runtime_create_plugin_rejects_existing_target_dir(tmp_path: Path) -> None:
@@ -366,7 +435,7 @@ def test_plugin_runtime_create_plugin_rejects_existing_frontend_test_dir(tmp_pat
"""校验插件模板创建会拒绝覆盖已存在前端测试目录。"""
project_root = tmp_path / 'project'
backend_root = project_root / 'ruoyi-fastapi-backend'
frontend_test_root = project_root / 'ruoyi-fastapi-frontend' / 'tests' / 'plugins' / 'demo'
frontend_test_root = project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'tests' / 'plugins' / 'demo'
backend_root.mkdir(parents=True)
frontend_test_root.mkdir(parents=True)
@@ -10,14 +10,16 @@ from types import SimpleNamespace
import pytest
from cli.runtime.plugin.service import CliPluginRuntimeService
from plugins.core.discovery.scanner import PluginScanner
from plugins.core.environment import PluginRuntimeEnvironmentService
from plugins.core.manifest.schema import EXPLICIT_MANIFEST_VERSION
from plugins.core.validation.structure import PluginStructureChecker
from .conftest import build_runtime
BACKEND_ROOT = Path(__file__).resolve().parents[4]
FRONTEND_ROOT = BACKEND_ROOT.parent / 'ruoyi-fastapi-frontend'
FRONTEND_ROOT = BACKEND_ROOT.parent / 'ruoyi-fastapi-frontend' / 'vue3' / 'web'
PLUGIN_ID = 'invoice_ops_27'
TEMPLATES = ('python-asgi', 'python-bundle', 'rust-asgi', 'rust-bundle')
@@ -43,6 +45,7 @@ def run_builder(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
text=True,
encoding='utf-8',
check=False,
timeout=60,
)
@@ -87,7 +90,7 @@ def test_v2_templates_create_independent_projects_without_importing_or_installin
assert plugin.manifest.id == PLUGIN_ID
assert plugin.manifest.backend.asgi.mount_path == f'/apps/{PLUGIN_ID}'
assert payload['sourceDir'] == source.as_posix()
assert payload['frontendVersion'] is None
assert payload['frontendFramework'] is None
assert not (tmp_path / 'backend' / 'plugins').exists()
assert f'plugins.{PLUGIN_ID}' not in sys.modules
assert f'ruoyi_plugin_{PLUGIN_ID}' not in sys.modules
@@ -139,13 +142,48 @@ def test_bundle_templates_record_sdk_identity_and_source_hashes(tmp_path: Path,
assert sdk['bridgeVersion'] == 1
assert sdk['capabilities'] == {'files': 1, 'streams': 1}
assert sdk['hashAlgorithm'] == 'sha256-utf8-lf'
assert sdk['source'] == {'project': 'RuoYi-Vue3-FastAPI', 'directory': 'ruoyi-fastapi-frontend/src/utils'}
assert sdk['source'] == {'project': 'RuoYi-FastAPI', 'directory': 'ruoyi-fastapi-frontend/vue3/web/src/utils'}
assert set(sdk['files']) == {'pluginBridge.js', 'pluginBridge.d.ts'}
for name in ('pluginBridge.js', 'pluginBridge.d.ts'):
content = (vendor / name).read_text(encoding='utf-8')
assert sdk['files'][name] == hashlib.sha256(content.encode('utf-8')).hexdigest()
def test_bundle_template_preserves_independent_host_framework_with_auto_option(tmp_path: Path) -> None:
"""校验 v2 的 auto 选项沿用独立宿主框架生成 SDK 来源,而不触发显式选项限制。"""
frontend = tmp_path / 'independent-frontend'
source = frontend / 'src/utils'
source.mkdir(parents=True)
for name in ('pluginBridge.js', 'pluginBridge.d.ts', 'pluginBridge.sdk.json'):
(source / name).write_bytes((FRONTEND_ROOT / 'src/utils' / name).read_bytes())
runtime = CliPluginRuntimeService(
runtime_environment=PluginRuntimeEnvironmentService(
backend_root=tmp_path / 'backend', frontend_root=frontend, frontend_framework='custom_ui-1'
)
)
payload = runtime.create_plugin(PLUGIN_ID, template='python-bundle', dry_run=True)
assert payload['ok'] is True
assert payload['frontendFramework'] is None
metadata = next(item for item in payload['files'] if item['path'].endswith('/pluginBridge.sdk.json'))
assert json.loads(metadata['content'])['source']['directory'] == (
'ruoyi-fastapi-frontend/custom_ui-1/web/src/utils'
)
def test_api_template_does_not_resolve_unknown_independent_host_framework(tmp_path: Path) -> None:
"""校验仅 API 的 v2 模板不要求宿主前端文件或已知框架。"""
runtime = CliPluginRuntimeService(
runtime_environment=PluginRuntimeEnvironmentService(
backend_root=tmp_path / 'backend', frontend_root=tmp_path / 'missing-frontend'
)
)
payload = runtime.create_plugin(PLUGIN_ID, template='python-asgi', dry_run=True)
assert payload['ok'] is True
assert payload['frontendFramework'] is None
def test_bundle_sdk_source_hashes_are_stable_across_crlf_checkout(tmp_path: Path) -> None:
"""
验证使用 CRLF 的宿主源码副本生成工程后,SDK 溯源摘要仍与规范化内容一致。
@@ -153,6 +191,7 @@ def test_bundle_sdk_source_hashes_are_stable_across_crlf_checkout(tmp_path: Path
frontend = tmp_path / 'frontend'
sdk_source = frontend / 'src' / 'utils'
sdk_source.mkdir(parents=True)
(frontend / 'package.json').write_text('{"dependencies":{"vue":"^3.5.26"}}', encoding='utf-8')
for name in ('pluginBridge.js', 'pluginBridge.d.ts', 'pluginBridge.sdk.json'):
content = (FRONTEND_ROOT / 'src' / 'utils' / name).read_text(encoding='utf-8')
(sdk_source / name).write_bytes(content.replace('\n', '\r\n').encode('utf-8'))
@@ -256,7 +295,7 @@ def test_v2_python_templates_describe_explicit_release_files(tmp_path: Path, tem
[
('python-asgi', {'backend': False}, '--frontend-only'),
('rust-bundle', {'frontend': False}, '--backend-only'),
('python-bundle', {'frontend_version': 'vue2'}, '--frontend-version'),
('python-bundle', {'frontend_framework': 'vue2'}, '--frontend-framework'),
],
)
def test_v2_templates_reject_incompatible_legacy_options(
@@ -327,6 +366,7 @@ def test_generated_python_release_passes_structure_and_generated_contract_tests(
text=True,
encoding='utf-8',
check=False,
timeout=120,
)
assert tested.returncode == 0, tested.stdout + tested.stderr
again = run_builder(source, output)
@@ -26,6 +26,7 @@ def write_source(
"""
directory = frontend / 'src' / 'utils'
directory.mkdir(parents=True, exist_ok=True)
(frontend / 'package.json').write_text('{"dependencies":{"vue":"^3.5.26"}}', encoding='utf-8')
(directory / SDK_FILES[0]).write_text(
f"export const PLUGIN_BRIDGE_SDK_VERSION = '{version}'\nexport const PLUGIN_BRIDGE_VERSION = {wire}\n",
encoding='utf-8',
@@ -121,6 +122,56 @@ def test_build_vendor_records_normalized_hashes_and_versions(tmp_path: Path) ->
assert sdk.check(project)['status'] == 'current'
@pytest.mark.parametrize('frontend_framework', ['vue2', 'vue3', 'react', 'custom_ui-1'])
def test_sdk_workspace_records_selected_source(tmp_path: Path, frontend_framework: str) -> None:
"""校验 SDK 来源记录使用当前项目名及所选的 Web 工程。"""
sdk = write_source(tmp_path / 'ruoyi-fastapi-frontend' / frontend_framework / 'web')
metadata = json.loads(sdk.build_vendor_files()[SDK_METADATA])
assert metadata['source'] == {
'project': 'RuoYi-FastAPI',
'directory': f'ruoyi-fastapi-frontend/{frontend_framework}/web/src/utils',
}
project = write_project(tmp_path / 'project', sdk)
assert sdk.check(project)['status'] == 'current'
def test_sdk_preserves_independent_host_framework_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""校验离线 SDK 在独立工程中保留环境框架提示,标准目录保留自身身份。"""
monkeypatch.setenv('RUOYI_PLUGIN_FRONTEND_FRAMEWORK', 'custom_ui-1')
sdk = write_source(tmp_path / 'independent-frontend')
metadata = json.loads(sdk.build_vendor_files()[SDK_METADATA])
assert metadata['source']['directory'] == 'ruoyi-fastapi-frontend/custom_ui-1/web/src/utils'
standard_sdk = write_source(tmp_path / 'ruoyi-fastapi-frontend/react/web')
standard_metadata = json.loads(standard_sdk.build_vendor_files()[SDK_METADATA])
assert standard_metadata['source']['directory'] == 'ruoyi-fastapi-frontend/react/web/src/utils'
@pytest.mark.parametrize(
'source',
[
{'project': 'RuoYi-Vue3-FastAPI', 'directory': 'ruoyi-fastapi-frontend/vue3/web/src/utils'},
{'project': 'RuoYi-FastAPI', 'directory': 'ruoyi-fastapi-frontend/../web/src/utils'},
{'project': 'RuoYi-FastAPI', 'directory': 'ruoyi-fastapi-frontend/auto/web/src/utils'},
{'project': 'RuoYi-FastAPI', 'directory': 'ruoyi-fastapi-frontend/React/web/src/utils'},
{'project': 'RuoYi-FastAPI', 'directory': 'ruoyi-fastapi-frontend/react\\outside/web/src/utils'},
{'project': 'RuoYi-FastAPI', 'directory': 'ruoyi-fastapi-frontend/react/web/src/utils/extra'},
],
)
def test_sdk_rejects_invalid_vendor_source(tmp_path: Path, source: dict[str, str]) -> None:
"""校验旧项目名和非规范框架目录不能作为有效 SDK 来源。"""
sdk = write_source(tmp_path / 'frontend')
project = write_project(tmp_path / 'project', sdk)
metadata_path = project / 'web/vendor' / SDK_METADATA
metadata = json.loads(metadata_path.read_text(encoding='utf-8'))
metadata['source'] = source
metadata_path.write_text(json.dumps(metadata), encoding='utf-8')
report = sdk.check(project)
assert not report['ok']
assert report['status'] == 'invalid'
@pytest.mark.parametrize('target', ['pluginBridge.js', 'pluginBridge.d.ts', 'pluginBridge.sdk.json'])
def test_invalid_source_version_cannot_be_used(tmp_path: Path, target: str) -> None:
"""宿主源三份版本声明必须一致,强制模式不能绕过源错误。"""
@@ -94,7 +94,7 @@ def test_plugin_runtime_test_plugin_runs_backend_and_frontend_targets(tmp_path:
"""校验插件测试命令会聚合执行后端 pytest 和前端 node 测试。"""
project_root = tmp_path / 'project'
backend_root = project_root / 'ruoyi-fastapi-backend'
frontend_root = project_root / 'ruoyi-fastapi-frontend'
frontend_root = project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web'
backend_test_root = backend_root / 'tests' / 'plugins' / 'demo'
frontend_test_file = frontend_root / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js'
backend_test_root.mkdir(parents=True)
@@ -138,7 +138,7 @@ def test_plugin_runtime_test_plugin_can_run_frontend_build_acceptance(tmp_path:
"""校验插件测试命令可按需追加前端构建验收。"""
project_root = tmp_path / 'project'
backend_root = project_root / 'ruoyi-fastapi-backend'
frontend_root = project_root / 'ruoyi-fastapi-frontend'
frontend_root = project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web'
backend_test_root = backend_root / 'tests' / 'plugins' / 'demo'
backend_test_root.mkdir(parents=True)
frontend_root.mkdir(parents=True)
@@ -166,7 +166,7 @@ def test_plugin_runtime_test_plugin_reports_missing_test_dir(tmp_path: Path) ->
assert result['message'].startswith('插件测试目录不存在')
assert result['targets'] == [
str(backend_root / 'tests' / 'plugins' / 'demo'),
str(backend_root.parent / 'frontend' / 'tests' / 'plugins' / 'demo'),
str(backend_root.parent / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'tests' / 'plugins' / 'demo'),
]
@@ -1,7 +1,6 @@
import asyncio
import json
import os
import time
from concurrent.futures import ProcessPoolExecutor
from datetime import timedelta
from pathlib import Path
@@ -48,23 +47,33 @@ async def test_two_processes_atomically_enforce_logical_job_limit(runtime: Runti
assert sorted(row.status for row in rows) == ['rejected', 'running']
async def wait_for_process_file(path: Path) -> dict:
async def wait_for_process_file(path: Path, executor: TimedProcessPoolExecutor, timeout: float = 45) -> dict:
"""
等待真实进程进入业务函数
:param path: 进程启动标记文件路径
:param executor: 提供超时诊断的真实进程执行器
:param timeout: 冷启动最长等待秒数,不作为业务执行耗时断言
:return: 进程启动信息
"""
def wait() -> dict:
deadline = time.monotonic() + 15
while not path.exists():
if time.monotonic() > deadline:
raise TimeoutError('process did not enter the task')
time.sleep(0.02)
return json.loads(path.read_text(encoding='utf-8'))
return await asyncio.to_thread(wait)
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
last_error = None
while loop.time() < deadline:
try:
return json.loads(await asyncio.to_thread(path.read_text, encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError) as exc:
last_error = type(exc).__name__
await asyncio.sleep(0.02)
processes = getattr(executor._pool, '_processes', None) or {}
states = [
{'pid': process.pid, 'alive': process.is_alive(), 'exitcode': process.exitcode}
for process in processes.values()
]
raise TimeoutError(
f'process did not enter the task within {timeout}s; marker={path}; '
f'processes={states}; broken={getattr(executor._pool, "_broken", None)}; last_read={last_error}'
)
@pytest.mark.asyncio
@@ -75,14 +84,12 @@ async def test_running_process_blocks_new_executor_and_can_finish_after_delete(
) -> None:
started, release = tmp_path / 'started.json', tmp_path / 'release'
monkeypatch.setattr(task_module, 'runtime_process', held_process_job, raising=False)
runtime.scheduler.add_executor(
TimedProcessPoolExecutor(
1,
manage_executions=True,
pool_kwargs={'initializer': init_execution_process, 'initargs': (str(runtime.engine.url),)},
),
alias='processpool',
executor = TimedProcessPoolExecutor(
1,
manage_executions=True,
pool_kwargs={'initializer': init_execution_process, 'initargs': (str(runtime.engine.url),)},
)
runtime.scheduler.add_executor(executor, alias='processpool')
job = await runtime.save(
job_info(
invokeTarget='module_task.scheduler_test.runtime_process',
@@ -95,7 +102,7 @@ async def test_running_process_blocks_new_executor_and_can_finish_after_delete(
execution_id = response.result['executionId']
runtime.scheduler.resume()
try:
assert (await wait_for_process_file(started))['pid'] != os.getpid()
assert (await wait_for_process_file(started, executor))['pid'] != os.getpid()
assert (await runtime.execution(execution_id))['status'] == 'running'
async with runtime.sessions() as db:
await JobService.edit_job_services(
@@ -0,0 +1 @@
from tests.scheduler_helpers import runtime # noqa: F401
@@ -0,0 +1,169 @@
import os
from types import SimpleNamespace
from unittest.mock import Mock, call
import psutil
import pytest
from module_admin.service.server_service import ServerService
from utils.common_util import bytes2human
ROOT_PATH = os.path.abspath(os.sep)
@pytest.mark.asyncio
@pytest.mark.parametrize(
('devices', 'mountpoints', 'filesystem'),
[
(('/dev/vda1', '/dev/vdb1'), ('/', '/srv/data'), 'ext4'),
(('C:\\', 'D:\\'), ('C:\\', 'D:\\'), 'NTFS'),
],
ids=['linux', 'windows'],
)
async def test_server_disks_sample_each_mountpoint_once(
monkeypatch: pytest.MonkeyPatch, devices: tuple[str, str], mountpoints: tuple[str, str], filesystem: str
) -> None:
"""设备名与挂载点不同的 Linux 分区和 Windows 盘符均返回完整容量快照。"""
partitions = Mock(
return_value=[
SimpleNamespace(device=device, mountpoint=mountpoint, fstype=filesystem)
for device, mountpoint in zip(devices, mountpoints, strict=True)
]
)
usage = Mock(
side_effect=[
SimpleNamespace(total=1024**3, used=256 * 1024**2, free=768 * 1024**2, percent=25.0),
SimpleNamespace(total=2 * 1024**3, used=1024**3, free=1024**3, percent=50.0),
]
)
monkeypatch.setattr(psutil, 'disk_partitions', partitions)
monkeypatch.setattr(psutil, 'disk_usage', usage)
result = await ServerService.get_server_monitor_info()
partitions.assert_called_once_with()
assert usage.call_args_list == [call(mountpoint) for mountpoint in mountpoints]
assert [disk.model_dump(by_alias=True) for disk in result.sys_files] == [
{
'dirName': devices[0],
'sysTypeName': filesystem,
'typeName': '本地固定磁盘(' + mountpoints[0].replace('\\', '') + ')',
'total': '1.0GB',
'used': '256.0MB',
'free': '768.0MB',
'usage': '25.0%',
},
{
'dirName': devices[1],
'sysTypeName': filesystem,
'typeName': '本地固定磁盘(' + mountpoints[1].replace('\\', '') + ')',
'total': '2.0GB',
'used': '1.0GB',
'free': '1.0GB',
'usage': '50.0%',
},
]
def test_unreadable_mount_does_not_hide_other_disks(monkeypatch: pytest.MonkeyPatch) -> None:
"""失效挂载点单独跳过,后续可读磁盘仍保留。"""
partitions = Mock(
return_value=[
SimpleNamespace(device='/dev/removed', mountpoint='/removed', fstype='ext4'),
SimpleNamespace(device='/dev/data', mountpoint='/data', fstype='ext4'),
]
)
usage = Mock(
side_effect=[FileNotFoundError('unmounted'), SimpleNamespace(total=1024, used=256, free=768, percent=25.0)]
)
monkeypatch.setattr(psutil, 'disk_partitions', partitions)
monkeypatch.setattr(psutil, 'disk_usage', usage)
disks = ServerService._get_disk_info()
assert [disk.dir_name for disk in disks] == ['/dev/data']
assert usage.call_args_list == [call('/removed'), call('/data')]
partitions.assert_called_once_with()
@pytest.mark.parametrize(
'unreadable_physical_disk', [False, True], ids=['no-physical-disk', 'unreadable-physical-disk']
)
def test_container_root_filesystem_is_sampled_when_physical_disks_are_unavailable(
monkeypatch: pytest.MonkeyPatch, unreadable_physical_disk: bool
) -> None:
"""容器物理磁盘不可见时只回退到根 overlay,排除 proc 等虚拟挂载。"""
root = ROOT_PATH
physical = [SimpleNamespace(device='/dev/host', mountpoint='/host', fstype='ext4')]
partitions = Mock(
side_effect=[
physical if unreadable_physical_disk else [],
[
SimpleNamespace(device='proc', mountpoint='/proc', fstype='proc'),
SimpleNamespace(device='overlay', mountpoint=root, fstype='overlay'),
],
]
)
snapshot = SimpleNamespace(total=1024**3, used=256 * 1024**2, free=768 * 1024**2, percent=25.0)
usage = Mock(side_effect=[PermissionError('not exposed'), snapshot] if unreadable_physical_disk else [snapshot])
monkeypatch.setattr(psutil, 'disk_partitions', partitions)
monkeypatch.setattr(psutil, 'disk_usage', usage)
disks = ServerService._get_disk_info()
assert partitions.call_args_list == [call(), call(all=True)]
assert usage.call_args_list == ([call('/host')] if unreadable_physical_disk else []) + [call(root)]
assert len(disks) == 1
assert disks[0].dir_name == 'overlay'
assert disks[0].sys_type_name == 'overlay'
assert disks[0].total == '1.0GB'
assert disks[0].used == '256.0MB'
assert disks[0].free == '768.0MB'
assert disks[0].usage == '25.0%'
def test_missing_mount_metadata_still_reads_real_root_disk(monkeypatch: pytest.MonkeyPatch) -> None:
"""挂载清单为空时仍以真实 psutil 读取当前根目录,且不虚构文件系统类型。"""
root = ROOT_PATH
expected = psutil.disk_usage(root)
monkeypatch.setattr(psutil, 'disk_partitions', Mock(return_value=[]))
disks = ServerService._get_disk_info()
assert len(disks) == 1
assert disks[0].dir_name == root
assert disks[0].sys_type_name == ''
assert disks[0].total == bytes2human(expected.total)
assert disks[0].used
assert disks[0].free
assert disks[0].usage.endswith('%')
@pytest.mark.asyncio
async def test_server_monitor_retains_other_metrics_when_all_disks_are_unreadable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""分区和根目录都无读取权限时返回空列表,CPU 等其它监控仍可用。"""
root = ROOT_PATH
partition = SimpleNamespace(device='overlay', mountpoint=root, fstype='overlay')
partitions = Mock(side_effect=[[], [partition]])
usage = Mock(side_effect=PermissionError('filesystem not accessible'))
monkeypatch.setattr(psutil, 'disk_partitions', partitions)
monkeypatch.setattr(psutil, 'disk_usage', usage)
result = await ServerService.get_server_monitor_info()
assert result.sys_files == []
assert result.cpu.cpu_num > 0
assert result.mem.total
assert result.sys.os_name
assert result.py.version
usage.assert_called_once_with(root)
def test_unexpected_disk_sampling_errors_are_not_silently_hidden(monkeypatch: pytest.MonkeyPatch) -> None:
"""只处理文件系统访问错误,采样逻辑异常不能被伪装成无磁盘。"""
monkeypatch.setattr(psutil, 'disk_usage', Mock(side_effect=ValueError('invalid disk snapshot')))
with pytest.raises(ValueError, match='invalid disk snapshot'):
ServerService._read_disk_info('/dev/data', '/data', 'ext4')
@@ -1,11 +1,14 @@
from datetime import datetime, timedelta, timezone
from io import BytesIO
from types import SimpleNamespace
import pytest
import pytest_asyncio
from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from common.context import RequestContext
from config.database import Base
from exceptions.exception import OidcInteractionException
from module_identity.dao.oauth_audit_dao import OAuthAuditDao
@@ -154,7 +157,6 @@ async def test_audit_admin_reads_project_safe_models_and_export(monkeypatch: pyt
monkeypatch.setattr(OAuthAuditDao, 'list_admin_page', list_admin_page)
monkeypatch.setattr(OAuthAuditDao, 'count_admin', count_admin)
monkeypatch.setattr('module_identity.service.audit_service.export_list2excel', lambda values: b'xlsx')
query = AuditPageQueryModel(page_num=2, page_size=_ADMIN_PAGE_SIZE, client_id='client-1')
rows, total = await AuditService.list_admin_page(SimpleNamespace(), query)
@@ -163,11 +165,49 @@ async def test_audit_admin_reads_project_safe_models_and_export(monkeypatch: pyt
assert total == _ADMIN_TOTAL
assert rows[0]['auditId'] == _ADMIN_EVENT_ID
assert 'detail' not in rows[0]
assert exported == b'xlsx'
workbook = load_workbook(BytesIO(exported))
headers, values = list(workbook.active.values)
assert dict(zip(headers, values, strict=True))['auditId'] == _ADMIN_EVENT_ID
assert 'detail' not in headers
assert calls[0]['offset'] == _ADMIN_PAGE_SIZE and calls[0]['limit'] == _ADMIN_PAGE_SIZE
assert calls[1]['offset'] == 0 and calls[1]['limit'] == _EXPORT_LIMIT
@pytest.mark.asyncio
@pytest.mark.parametrize(
('timezone_name', 'expected_time'),
[
('Asia/Shanghai', datetime(2026, 8, 28, 18, 30, 0, 123000)),
('America/New_York', datetime(2026, 8, 28, 6, 30, 0, 123000)),
],
)
async def test_audit_export_serializes_stored_utc_in_request_timezone(
audit_session: AsyncSession, timezone_name: str, expected_time: datetime
) -> None:
"""真实查询和 Excel 写入链路必须转换 UTC 时刻并注明导出时区。"""
event = await AuditService.record(
audit_session,
'export_timezone',
'success',
create_time=datetime(2026, 8, 28, 10, 30, 0, 123000, tzinfo=timezone.utc),
detail={'private_note': 'not-for-export', 'access_token': 'hidden'},
)
await audit_session.commit()
token = RequestContext.set_current_timezone(timezone_name)
try:
exported = await AuditService.export_admin(audit_session, AuditPageQueryModel(event_type='export_timezone'))
finally:
RequestContext.reset_current_timezone(token)
workbook = load_workbook(BytesIO(exported))
headers, values = list(workbook.active.values)
exported_row = dict(zip(headers, values, strict=True))
assert exported_row['auditId'] == event.event_id
assert exported_row[f'createTime ({timezone_name})'] == expected_time
assert 'detail' not in headers
assert 'not-for-export' not in values
assert 'hidden' not in values
@pytest.mark.asyncio
async def test_interaction_failure_audit_rolls_back_and_wraps_failure(monkeypatch: pytest.MonkeyPatch) -> None:
"""交互失败审计保持双重回滚和统一异常包装语义。"""
@@ -569,6 +569,7 @@ async def test_restarted_process_loads_upgrade_then_rollback_without_downgrading
@pytest.mark.asyncio
@pytest.mark.native
@pytest.mark.skipif(not os.environ.get('RUOYI_NATIVE_PLUGIN_DIR'), reason='需要预先构建的真实 Rust 制品')
async def test_signed_native_artifact_loads_from_store_in_clean_process(deployment: SimpleNamespace) -> None:
"""验证干净进程从不可变存储实际加载签名原生制品。"""
@@ -2,6 +2,7 @@ import sys
from pathlib import Path
from plugins.core.environment import PluginRuntimeEnvironmentService
from plugins.core.frontend import PluginFrontendFrameworkResolver
class FakeRuntimeEnvironment:
@@ -30,6 +31,11 @@ class FakeRuntimeEnvironment:
"""获取前端项目根目录。"""
return str(self.frontend_dir)
def get_frontend_framework(self, frontend_root: Path | str | None = None) -> str:
"""获取测试用前端工程的框架标识。"""
root = Path(frontend_root) if frontend_root is not None else self.frontend_dir
return PluginFrontendFrameworkResolver.resolve(root)
def get_frontend_plugins_dir(self) -> str:
"""获取前端插件根目录。"""
return str(self.frontend_dir / 'plugins')
@@ -34,8 +34,10 @@ backend:
dependencies:
python:
- missing-python
npm:
- vue>=3.5.0
frontend:
vue3:
npm:
- vue>=3.5.0
""",
)
@@ -390,10 +392,12 @@ backend:
dependencies:
python:
- missing-python
npm:
- missing-npm
npmDev:
- missing-dev-npm
frontend:
vue3:
npm:
- missing-npm
npmDev:
- missing-dev-npm
""",
)
@@ -694,8 +698,10 @@ permissions:
dependencies:
python:
- missing-python
npm:
- missing-npm
frontend:
vue3:
npm:
- missing-npm
""",
)
create_frontend_view(backend_root, 'demo')
@@ -448,8 +448,10 @@ backend:
dependencies:
python:
- openai>=2.17.0
npm:
- vue>=3.5.0
frontend:
vue3:
npm:
- vue>=3.5.0
""",
)
@@ -350,8 +350,10 @@ permissions:
dependencies:
python:
- requests>=2.0.0
npm:
- lodash@^4.17.0
frontend:
vue3:
npm:
- lodash@^4.17.0
plugins:
- base>=1.0.0
config:

Some files were not shown because too many files have changed in this diff Show More