From 7f81f1c9ee223d4d4be376b182963156b8020065 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:12:56 +0800 Subject: [PATCH 01/21] =?UTF-8?q?fix:=20=E5=85=B3=E9=97=AD=E5=85=A8?= =?UTF-8?q?=E9=83=A8=20API=20=E6=96=87=E6=A1=A3=E6=97=B6=E7=A6=81=E7=94=A8?= =?UTF-8?q?=E5=85=AC=E5=BC=80=20Schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_api_docs_switches.py | 21 +++++++++++++++++++ ruoyi-fastapi-backend/utils/server_util.py | 3 ++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 ruoyi-fastapi-backend/tests/test_api_docs_switches.py diff --git a/ruoyi-fastapi-backend/tests/test_api_docs_switches.py b/ruoyi-fastapi-backend/tests/test_api_docs_switches.py new file mode 100644 index 000000000..70e2391e2 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/test_api_docs_switches.py @@ -0,0 +1,21 @@ +import pytest +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from config.env import AppConfig +from utils.server_util import APIDocsUtil + + +@pytest.mark.parametrize('disable_swagger', [False, True]) +@pytest.mark.parametrize('disable_redoc', [False, True]) +def test_custom_schema_respects_docs_switches( + monkeypatch: pytest.MonkeyPatch, disable_swagger: bool, disable_redoc: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_disable_swagger', disable_swagger) + monkeypatch.setattr(AppConfig, 'app_disable_redoc', disable_redoc) + app = FastAPI(openapi_url=None) + APIDocsUtil.custom_api_docs_router(app) + response = TestClient(app).get('/openapi.json') + assert response.status_code == (404 if disable_swagger and disable_redoc else 200) + if response.status_code == status.HTTP_200_OK: + assert 'openapi' in response.json() diff --git a/ruoyi-fastapi-backend/utils/server_util.py b/ruoyi-fastapi-backend/utils/server_util.py index 5b2c04473..decb9e8ef 100644 --- a/ruoyi-fastapi-backend/utils/server_util.py +++ b/ruoyi-fastapi-backend/utils/server_util.py @@ -154,7 +154,8 @@ async def custom_swagger_ui_redirect(request: Request) -> HTMLResponse: return await cls._custom_swagger_ui_redirect(app, swagger_favicon_url) # 注册路由 - app.add_route(cls._OPENAPI_URL, custom_openapi, include_in_schema=False) + if not (AppConfig.app_disable_swagger and AppConfig.app_disable_redoc): + app.add_route(cls._OPENAPI_URL, custom_openapi, include_in_schema=False) cls._register_docs_routes(app, custom_swagger, custom_swagger_ui_redirect, custom_redoc) @classmethod From a71134d7f494fbc035a53f4449bb6d42a5d5843c Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:13:02 +0800 Subject: [PATCH 02/21] =?UTF-8?q?fix:=20=E5=8D=95=E7=8B=AC=E5=90=AF?= =?UTF-8?q?=E7=94=A8=E6=96=87=E6=A1=A3=E6=97=B6=E6=8F=90=E4=BE=9B=E4=BB=A3?= =?UTF-8?q?=E7=90=86=20OpenAPI=20Schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_api_docs_switches.py | 24 +++++++++++++++++++ ruoyi-fastapi-backend/utils/server_util.py | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/ruoyi-fastapi-backend/tests/test_api_docs_switches.py b/ruoyi-fastapi-backend/tests/test_api_docs_switches.py index 70e2391e2..70d2de244 100644 --- a/ruoyi-fastapi-backend/tests/test_api_docs_switches.py +++ b/ruoyi-fastapi-backend/tests/test_api_docs_switches.py @@ -19,3 +19,27 @@ def test_custom_schema_respects_docs_switches( assert response.status_code == (404 if disable_swagger and disable_redoc else 200) if response.status_code == status.HTTP_200_OK: assert 'openapi' in response.json() + + +@pytest.mark.parametrize('disable_swagger', [False, True]) +@pytest.mark.parametrize('disable_redoc', [False, True]) +def test_proxy_schema_and_enabled_ui_are_available( + monkeypatch: pytest.MonkeyPatch, disable_swagger: bool, disable_redoc: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_disable_swagger', disable_swagger) + monkeypatch.setattr(AppConfig, 'app_disable_redoc', disable_redoc) + app = FastAPI( + openapi_url=APIDocsUtil.proxy_openapi_url(), + docs_url=APIDocsUtil.proxy_docs_url(), + redoc_url=APIDocsUtil.proxy_redoc_url(), + ) + APIDocsUtil.custom_api_docs_router(app) + client = TestClient(app) + assert client.get('/proxy-openapi.json').status_code == (404 if disable_swagger and disable_redoc else 200) + for path, disabled in [('/proxy-docs', disable_swagger), ('/proxy-redoc', disable_redoc)]: + response = client.get(path) + assert response.status_code == status.HTTP_200_OK + if not disabled: + assert '/proxy-openapi.json' in response.text + else: + assert 'has been disabled' in response.text diff --git a/ruoyi-fastapi-backend/utils/server_util.py b/ruoyi-fastapi-backend/utils/server_util.py index decb9e8ef..cf99bc71f 100644 --- a/ruoyi-fastapi-backend/utils/server_util.py +++ b/ruoyi-fastapi-backend/utils/server_util.py @@ -38,11 +38,11 @@ class APIDocsUtil: DEFAULT_SWAGGER_FAVICON_URL = 'https://fastapi.tiangolo.com/img/favicon.png' @classmethod - def proxy_openapi_url(cls) -> str: + def proxy_openapi_url(cls) -> str | None: """ 代理OpenAPI文档URL """ - return cls._PROXY_OPENAPI_URL if not AppConfig.app_disable_swagger and not AppConfig.app_disable_redoc else None + return cls._PROXY_OPENAPI_URL if not (AppConfig.app_disable_swagger and AppConfig.app_disable_redoc) else None @classmethod def docs_url(cls) -> str: From 2e2f43057ffd0b8ef75a1a52803488601b60cebc Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:16:22 +0800 Subject: [PATCH 03/21] =?UTF-8?q?fix:=20CLI=20=E7=94=9F=E4=BA=A7=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E4=BD=BF=E7=94=A8=E5=AE=9E=E9=99=85=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E7=9A=84=E5=BA=94=E7=94=A8=E7=8E=AF=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-fastapi-backend/cli/context.py | 17 +++- .../cli/groups/plugin/artifact_controller.py | 4 +- ruoyi-fastapi-backend/cli/guards.py | 5 +- .../cli/root/test_effective_environment.py | 96 +++++++++++++++++++ .../cli/root/test_plugin_artifact_commands.py | 14 ++- .../cli/root/test_plugin_release_wait.py | 2 +- 6 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py diff --git a/ruoyi-fastapi-backend/cli/context.py b/ruoyi-fastapi-backend/cli/context.py index adbfab968..9fa861d42 100644 --- a/ruoyi-fastapi-backend/cli/context.py +++ b/ruoyi-fastapi-backend/cli/context.py @@ -11,7 +11,8 @@ class CliContext: """ CLI 运行上下文。 - env: 当前命令使用的环境名称 + env: 当前加载的配置文件选择名 + app_env: 已加载配置声明的实际应用环境 output: 结果输出格式 operator: 操作者标识,后续用于审计 allow_prod: 是否显式允许生产环境执行危险命令 @@ -25,6 +26,7 @@ class CliContext: allow_prod: bool = False yes: bool = False dry_run: bool = False + app_env: str | None = None class CliContextBuilder: @@ -53,8 +55,12 @@ def build( :param dry_run: 是否执行演练模式 :return: 构建完成的 CLI 上下文 """ + # 必须与实际资源配置使用同一来源,命令参数默认值不能覆盖已加载的环境。 + from config.env import AppConfig, get_config # noqa: PLC0415 - 保留 CLI 启动时的惰性配置加载 + return CliContext( - env=env, + env=get_config.run_env, + app_env=AppConfig.app_env, output=output, allow_prod=allow_prod, yes=yes, @@ -66,7 +72,12 @@ def build( EnvOption = Annotated[ str, - typer.Option('--env', help='运行环境名称', autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values), + typer.Option( + '--env', + envvar='APP_ENV', + help='运行环境配置名称', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values, + ), ] OutputOption = Annotated[Literal['text', 'json'], typer.Option('--output', help='输出格式,支持 text 或 json')] AllowProdOption = Annotated[bool, typer.Option('--allow-prod', help='允许在生产环境中执行危险命令')] diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py b/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py index 47aba0f43..a8d1895f9 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py @@ -165,9 +165,8 @@ def _context( yes: bool = False, dry_run: bool = False, ) -> CliContext: - # config.env 可能在应用日志策略时首次导入,因此必须先设置命令选择的环境。 """ - 先设置命令环境,再应用危险操作保护并解析操作者。 + 使用统一执行上下文应用危险操作保护并解析操作者。 :param env: 当前命令运行环境 :param output: 输出格式 @@ -177,7 +176,6 @@ def _context( :param dry_run: 是否仅预演而不写入发布状态 :return: 完成环境与操作者设置的 CLI 上下文 """ - os.environ['APP_ENV'] = env if command_name and not dry_run: ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, dry_run, command_name=command_name) else: diff --git a/ruoyi-fastapi-backend/cli/guards.py b/ruoyi-fastapi-backend/cli/guards.py index 2bad51f94..75cc1cbce 100644 --- a/ruoyi-fastapi-backend/cli/guards.py +++ b/ruoyi-fastapi-backend/cli/guards.py @@ -122,7 +122,8 @@ def confirm(self, ctx: CliContext, *, command_name: str) -> CommandResult | None try: confirmed = typer.confirm( - f'确认执行危险命令 `{command_name}` 吗? 当前环境:{ctx.env}{"(dry-run)" if ctx.dry_run else ""}', + f'确认执行危险命令 `{command_name}` 吗? 配置:{ctx.env},' + f'应用环境:{ctx.app_env or ctx.env}{"(dry-run)" if ctx.dry_run else ""}', default=False, ) except (click.Abort, EOFError, KeyboardInterrupt): @@ -173,7 +174,7 @@ def guard(self, ctx: CliContext, *, rule: DangerousCommandRule) -> CommandResult :return: 拒绝结果或 None """ command_name = rule.command_name - if ctx.env == 'prod' and not ctx.allow_prod: + if 'prod' in {ctx.env.strip().lower(), (ctx.app_env or '').strip().lower()} and not ctx.allow_prod: return self.result_builder.build_guard_reject_result( f'生产环境默认禁止直接执行危险命令:{command_name}', '如确认执行,请传入 --allow-prod;如需跳过确认,请同时传入 --yes', diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py b/ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py new file mode 100644 index 000000000..fa6f640bd --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py @@ -0,0 +1,96 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import typer +from typer.testing import CliRunner + +from cli.context import EnvOption + +BACKEND_DIR = Path(__file__).resolve().parents[3] + + +@pytest.mark.parametrize( + ('external_env', 'selected', 'file_env', 'expected_selector', 'expected_app', 'rejected'), + [ + (None, None, 'dev', 'dev', 'dev', False), + ('prod', None, 'dev', 'prod', 'prod', True), + (None, 'prod', 'prod', 'prod', 'prod', True), + (None, 'dockermy', 'prod', 'dockermy', 'prod', True), + (None, 'dockerpg', 'prod', 'dockerpg', 'prod', True), + ('prod', 'dev', 'dev', 'dev', 'prod', True), + ('dev', 'prod', 'prod', 'prod', 'dev', True), + ], +) +def test_guard_uses_loaded_environment_without_resource_access( + tmp_path: Path, + external_env: str | None, + selected: str | None, + file_env: str, + expected_selector: str, + expected_app: str, + rejected: bool, +) -> None: + (tmp_path / f'.env.{expected_selector}').write_text(f'APP_ENV={file_env}\n', encoding='utf-8') + script = """ +import json +from cli.context import CLI_CONTEXT_BUILDER +from cli.guards import DEFAULT_DANGEROUS_COMMAND_GUARD, DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY +ctx = CLI_CONTEXT_BUILDER.build('dev', 'json', False, True, False) +rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.require_rule('job run-once') +result = DEFAULT_DANGEROUS_COMMAND_GUARD.guard(ctx, rule=rule) +ctx.allow_prod = True +allowed = DEFAULT_DANGEROUS_COMMAND_GUARD.guard(ctx, rule=rule) +print(json.dumps({'env': ctx.env, 'appEnv': ctx.app_env, 'rejected': result is not None, 'allowed': allowed is None})) +""" + process_env = {key: value for key, value in os.environ.items() if key != 'APP_ENV' and not key.startswith('OIDC_')} + process_env['PYTHONPATH'] = str(BACKEND_DIR) + process_env['DB_DEFAULT_SOURCE'] = 'master' + process_env['DB_SOURCES'] = json.dumps( + { + 'master': { + 'db_type': 'mysql', + 'db_host': '127.0.0.1', + 'db_port': 1, + 'db_username': 'test', + 'db_password': 'test', + 'db_database': 'isolated', + } + } + ) + if external_env: + process_env['APP_ENV'] = external_env + completed = subprocess.run( + [sys.executable, '-c', script, *(['--env', selected] if selected else [])], + cwd=tmp_path, + env=process_env, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + 'env': expected_selector, + 'appEnv': expected_app, + 'rejected': rejected, + 'allowed': True, + } + + +def test_env_option_uses_external_default_and_preserves_explicit_choice(monkeypatch: pytest.MonkeyPatch) -> None: + app = typer.Typer() + selected = [] + + @app.command() + def run(env: EnvOption = 'dev') -> None: + selected.append(env) + + monkeypatch.setenv('APP_ENV', 'prod') + runner = CliRunner() + assert runner.invoke(app, []).exit_code == 0 + assert runner.invoke(app, ['--env', 'dockermy']).exit_code == 0 + assert selected == ['prod', 'dockermy'] diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py index 707dd7e1d..6218399a1 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py @@ -274,7 +274,6 @@ def __init__(self, events: list[str]) -> None: self.events = events def build_regular(self, env: str, output: str, allow_prod: bool, yes: bool, dry_run: bool) -> CliContext: - assert os.environ['APP_ENV'] == env self.events.append('context') return CliContext(env=env, output=output, allow_prod=allow_prod, yes=yes, dry_run=dry_run) @@ -310,11 +309,15 @@ def quiet_context_factory() -> CliContextFactory: ) -def test_production_mutations_are_guarded_before_loading_runtime() -> None: +def test_production_mutations_are_guarded_before_loading_runtime(monkeypatch: pytest.MonkeyPatch) -> None: """ 验证生产写入保护先于运行时加载执行。 """ + env_module = importlib.import_module('config.env') + monkeypatch.setattr(env_module.get_config, 'run_env', 'prod') + monkeypatch.setattr(env_module.AppConfig, 'app_env', 'prod') + def forbidden_config() -> None: pytest.fail('rejected CLI must not construct runtime') @@ -340,16 +343,17 @@ def forbidden_config() -> None: assert json.loads(result.stdout)['ok'] is False -def test_dry_run_is_readonly_and_environment_precedes_runtime() -> None: +def test_dry_run_is_readonly_and_environment_precedes_runtime(monkeypatch: pytest.MonkeyPatch) -> None: """ - 验证预演保持只读,且先设置环境再创建运行时。 + 验证预演保持只读,且不会用配置选择名覆盖外部应用环境。 """ + monkeypatch.setenv('APP_ENV', 'dev') events: list[str] = [] execution = RecordingExecution() def config() -> object: assert events == ['context'] - assert os.environ['APP_ENV'] == 'prod' + assert os.environ['APP_ENV'] == 'dev' events.append('config') return object() diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py index 96c5395d5..5b9be51fa 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py @@ -146,7 +146,7 @@ def test_wait_outputs_exactly_one_final_json_and_preserves_service_exit_semantic timeout_seconds=0.0, poll_interval_seconds=0.25, ) - assert os.environ['APP_ENV'] == 'prod' + assert os.environ['APP_ENV'] == 'dev' @pytest.mark.parametrize( From d1b46ba8bdb1ee18056ca532df90aa0acde1e83f Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:20:17 +0800 Subject: [PATCH 04/21] =?UTF-8?q?fix:=20=E6=97=A0=E6=9C=89=E6=95=88?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E6=97=B6=E9=BB=98=E8=AE=A4=E6=8B=92=E7=BB=9D?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/aspect/data_scope.py | 15 ++-- .../tests/common/test_data_scope.py | 70 +++++++++++++++++++ .../service/test_file_management.py | 2 +- 3 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 ruoyi-fastapi-backend/tests/common/test_data_scope.py diff --git a/ruoyi-fastapi-backend/common/aspect/data_scope.py b/ruoyi-fastapi-backend/common/aspect/data_scope.py index 80d83048b..0d3384101 100644 --- a/ruoyi-fastapi-backend/common/aspect/data_scope.py +++ b/ruoyi-fastapi-backend/common/aspect/data_scope.py @@ -1,5 +1,5 @@ from fastapi import Depends, Request, params -from sqlalchemy import ColumnElement, func, or_, select +from sqlalchemy import ColumnElement, false, func, or_, select, true from common.context import RequestContext from config.database import Base @@ -39,14 +39,15 @@ def __init__( def __call__(self, request: Request) -> ColumnElement: DependencyUtil.check_exclude_routes(request, err_msg='当前路由不在认证规则内,不可使用GetDataScope依赖项') current_user = RequestContext.get_current_user() + if current_user.user.admin: + return true() user_id = current_user.user.user_id dept_id = current_user.user.dept_id - custom_data_scope_role_id_list = [ - item.role_id for item in current_user.user.role if item.data_scope == self.DATA_SCOPE_CUSTOM - ] + roles = [role for role in (current_user.user.role or []) if role.status == '0' and role.del_flag == '0'] + custom_data_scope_role_id_list = [item.role_id for item in roles if item.data_scope == self.DATA_SCOPE_CUSTOM] param_sql_list = [] - for role in current_user.user.role: - if current_user.user.admin or role.data_scope == self.DATA_SCOPE_ALL: + for role in roles: + if role.data_scope == self.DATA_SCOPE_ALL: param_sql_list = [True] break if role.data_scope == self.DATA_SCOPE_CUSTOM: @@ -91,7 +92,7 @@ def __call__(self, request: Request) -> ColumnElement: else: param_sql_list.append(False) param_sql_list = list(dict.fromkeys(param_sql_list)) - param_sql = or_(*param_sql_list) + param_sql = or_(false(), *param_sql_list) return param_sql diff --git a/ruoyi-fastapi-backend/tests/common/test_data_scope.py b/ruoyi-fastapi-backend/tests/common/test_data_scope.py new file mode 100644 index 000000000..4d4e30b4c --- /dev/null +++ b/ruoyi-fastapi-backend/tests/common/test_data_scope.py @@ -0,0 +1,70 @@ +from types import SimpleNamespace + +import pytest +from fastapi import Request +from sqlalchemy import Column, Integer, MetaData, Table, create_engine, select +from sqlalchemy.orm import registry + +from common.aspect.data_scope import GetDataScope +from common.context import RequestContext +from module_admin.entity.vo.role_vo import RoleModel +from utils.dependency_util import DependencyUtil + + +@pytest.mark.parametrize( + ('roles', 'admin', 'expected'), + [ + ([], False, []), + ([('1', '1', '0')], False, []), + ([('1', '0', '2')], False, []), + ([(None, '0', '0')], False, []), + ([('5', '0', '0')], False, [1]), + ([('3', '0', '0')], False, [1, 2]), + ([('3', '0', '0'), ('5', '0', '0')], False, [1, 2]), + ([('5', '0', '0'), ('1', '1', '0')], False, [1]), + ([('1', '0', '0')], False, [1, 2, 3]), + ([], True, [1, 2, 3]), + ], +) +def test_data_scope_filters_real_query( + monkeypatch: pytest.MonkeyPatch, roles: list[tuple], admin: bool, expected: list[int] +) -> None: + metadata = MetaData() + table = Table('records', metadata, Column('user_id', Integer, primary_key=True), Column('dept_id', Integer)) + mapper = registry() + + class Record: + pass + + mapper.map_imperatively(Record, table) + user = SimpleNamespace( + user_id=1, + dept_id=10, + admin=admin, + role=[ + RoleModel(roleId=index + 2, dataScope=scope, status=status, delFlag=deleted) + for index, (scope, status, deleted) in enumerate(roles) + ], + ) + monkeypatch.setattr(RequestContext, 'get_current_user', lambda: SimpleNamespace(user=user)) + monkeypatch.setattr(DependencyUtil, 'check_exclude_routes', lambda *args, **kwargs: None) + condition = GetDataScope(Record)(Request({'type': 'http'})) + query = select(table.c.user_id).where(condition).order_by(table.c.user_id) + engine = create_engine('sqlite://') + metadata.create_all(engine) + try: + with engine.begin() as connection: + connection.execute( + table.insert(), + [ + {'user_id': 1, 'dept_id': 10}, + {'user_id': 2, 'dept_id': 10}, + {'user_id': 3, 'dept_id': 20}, + ], + ) + assert list(connection.scalars(query)) == expected + if not expected: + assert 'WHERE 0 = 1' in str(query.compile(engine)) + finally: + engine.dispose() + mapper.dispose() diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py index 17ad1944b..ded604c2b 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py @@ -75,7 +75,7 @@ def test_file_data_scope_does_not_match_unowned_files_when_user_has_no_departmen user_id=20, dept_id=None, admin=False, - role=[SimpleNamespace(role_id=2, data_scope=data_scope)], + role=[SimpleNamespace(role_id=2, data_scope=data_scope, status='0', del_flag='0')], ) ) From 8c8de2d2b2f21262dda5ba7acc96e547044e8b7b Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:26:53 +0800 Subject: [PATCH 05/21] =?UTF-8?q?fix:=20=E6=B8=85=E6=B4=97=E5=85=AC?= =?UTF-8?q?=E5=91=8A=E5=AF=8C=E6=96=87=E6=9C=AC=E5=B9=B6=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E5=85=AC=E5=91=8A=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../module_admin/entity/vo/notice_vo.py | 15 ++- .../module_admin/service/notice_service.py | 5 + ruoyi-fastapi-backend/requirements-pg.txt | 1 + ruoyi-fastapi-backend/requirements.txt | 1 + .../service/test_notice_html_security.py | 88 +++++++++++++++ .../utils/notice_html_util.py | 102 ++++++++++++++++++ ruoyi-fastapi-frontend/react/web/package.json | 3 +- .../components/HeaderNotice/DetailView.jsx | 4 +- .../react/web/src/utils/noticeHtml.js | 71 ++++++++++++ .../web/tests/security/notice-html.test.cjs | 44 ++++++++ ruoyi-fastapi-frontend/vue2/web/package.json | 1 + .../vue2/web/pnpm-lock.yaml | 3 + .../components/HeaderNotice/DetailView.vue | 6 +- .../vue2/web/src/utils/noticeHtml.js | 71 ++++++++++++ ruoyi-fastapi-frontend/vue3/web/package.json | 1 + .../vue3/web/pnpm-lock.yaml | 3 + .../components/HeaderNotice/DetailView.vue | 5 +- .../vue3/web/src/utils/noticeHtml.js | 71 ++++++++++++ 18 files changed, 489 insertions(+), 6 deletions(-) create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py create mode 100644 ruoyi-fastapi-backend/utils/notice_html_util.py create mode 100644 ruoyi-fastapi-frontend/react/web/src/utils/noticeHtml.js create mode 100644 ruoyi-fastapi-frontend/react/web/tests/security/notice-html.test.cjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/noticeHtml.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/noticeHtml.js diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py index 055ffb54f..1598a4265 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py @@ -1,11 +1,12 @@ from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Size, Xss from common.mixin import DateRangeQueryMixin from common.types import ApiUtcDateTime +from utils.notice_html_util import sanitize_notice_html class NoticeModel(BaseModel): @@ -26,6 +27,18 @@ class NoticeModel(BaseModel): update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') + @field_validator('notice_content') + @classmethod + def sanitize_content(cls, value: bytes | None) -> bytes | None: + """ + 清洗公告正文,同时保护 API 写入及历史详情读取。 + + :param value: 公告正文二进制内容 + :return: 清洗后的正文 + """ + cleaned = sanitize_notice_html(value) + return cleaned.encode('utf-8') if cleaned is not None else None + @Xss(field_name='notice_title', message='公告标题不能包含脚本字符') @NotBlank(field_name='notice_title', message='公告标题不能为空') @Size(field_name='notice_title', min_length=0, max_length=50, message='公告标题不能超过50个字符') diff --git a/ruoyi-fastapi-backend/module_admin/service/notice_service.py b/ruoyi-fastapi-backend/module_admin/service/notice_service.py index 0f09a92a4..52b662159 100644 --- a/ruoyi-fastapi-backend/module_admin/service/notice_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/notice_service.py @@ -15,6 +15,7 @@ NoticeTopResponseModel, ) from utils.common_util import CamelCaseUtil +from utils.notice_html_util import sanitize_notice_html class NoticeService: @@ -38,6 +39,10 @@ async def get_notice_list_services( """ notice_list_result = await NoticeDao.get_notice_list(query_db, query_object, is_page) + rows = notice_list_result.rows if isinstance(notice_list_result, PageModel) else notice_list_result + for row in rows: + if 'noticeContent' in row: + row['noticeContent'] = sanitize_notice_html(row['noticeContent']) return notice_list_result @classmethod diff --git a/ruoyi-fastapi-backend/requirements-pg.txt b/ruoyi-fastapi-backend/requirements-pg.txt index 384b38cbd..c512bc7e4 100644 --- a/ruoyi-fastapi-backend/requirements-pg.txt +++ b/ruoyi-fastapi-backend/requirements-pg.txt @@ -6,6 +6,7 @@ asyncpg==0.32.0 bcrypt==5.0.0 fastapi[all]==0.143.3 loguru==0.7.3 +nh3==0.3.7 openpyxl==3.1.5 packaging==26.3 pandas==2.3.3 diff --git a/ruoyi-fastapi-backend/requirements.txt b/ruoyi-fastapi-backend/requirements.txt index 4660e6e08..25c6600db 100644 --- a/ruoyi-fastapi-backend/requirements.txt +++ b/ruoyi-fastapi-backend/requirements.txt @@ -6,6 +6,7 @@ asyncmy==0.2.16 bcrypt==5.0.0 fastapi[all]==0.143.3 loguru==0.7.3 +nh3==0.3.7 openpyxl==3.1.5 packaging==26.3 pandas==2.3.3 diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py new file mode 100644 index 000000000..9f8b4e339 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py @@ -0,0 +1,88 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from module_admin.dao.notice_dao import NoticeDao +from module_admin.entity.do.notice_do import SysNotice +from module_admin.entity.vo.notice_vo import NoticeModel, NoticePageQueryModel +from module_admin.service.notice_service import NoticeService + +PAYLOAD = '

正文重点

链接' + + +def assert_safe(content: str) -> None: + assert '重点' in content + assert 'src="/safe.png"' in content + assert 'onerror' not in content + assert ' None: + app = FastAPI() + + @app.post('/notice') + def notice(payload: NoticeModel) -> NoticeModel: + assert_safe(payload.notice_content.decode()) + return payload + + response = TestClient(app).post('/notice', json={'noticeTitle': '测试', 'noticeContent': PAYLOAD}) + assert_safe(response.json()['noticeContent']) + + +@pytest.mark.asyncio +async def test_legacy_notice_detail_and_list_are_sanitized(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + NoticeDao, + 'get_notice_detail_by_id', + AsyncMock( + return_value=SysNotice( + notice_id=2, + notice_title='历史公告', + notice_content=PAYLOAD.encode(), + ) + ), + ) + monkeypatch.setattr(NoticeDao, 'get_notice_list', AsyncMock(return_value=[{'noticeContent': PAYLOAD}])) + result = await NoticeService.notice_detail_services(AsyncMock(), 2) + assert_safe(result.notice_content.decode()) + rows = await NoticeService.get_notice_list_services(AsyncMock(), NoticePageQueryModel(), False) + assert_safe(rows[0]['noticeContent']) + + +@pytest.mark.parametrize('url', ['javascript:alert(1)', 'javascript:alert(1)', 'data:text/html,x', 'vbscript:x']) +def test_rejects_dangerous_urls_and_embedded_content(url: str) -> None: + result = NoticeModel(noticeContent=f'x') + content = result.notice_content.decode() + assert 'href=' not in content + assert 'src=' not in content + assert ' None: + content = '

标题

表格
' + result = NoticeModel(noticeContent=content).notice_content.decode() + assert '

标题

' in result and '正文' in result and 'colspan="2"' in result + assert 'notice_content' not in NoticeModel(status='1').model_dump(exclude_unset=True) + + +def test_quill_text_styles_are_preserved_without_layout_styles() -> None: + content = NoticeModel( + noticeContent='

正文

' + ).notice_content.decode() + assert 'ql-align-center' in content + assert 'color:red' in content and 'background-color:yellow' in content + assert 'position' not in content + + +def test_quill_video_is_sandboxed_and_cannot_embed_inline_script() -> None: + content = NoticeModel( + noticeContent='' + ).notice_content.decode() + assert 'src="https://video.example/embed/1"' in content + assert 'sandbox="allow-scripts allow-presentation"' in content + assert 'srcdoc' not in content and 'allow-same-origin' not in content + content = NoticeModel(noticeContent='正文').notice_content.decode() + assert 'expression' not in content diff --git a/ruoyi-fastapi-backend/utils/notice_html_util.py b/ruoyi-fastapi-backend/utils/notice_html_util.py new file mode 100644 index 000000000..a6b59a65c --- /dev/null +++ b/ruoyi-fastapi-backend/utils/notice_html_util.py @@ -0,0 +1,102 @@ +import re +from urllib.parse import urlsplit + +import nh3 + +# 公告只允许文档型富文本;仅保留文本样式,不接受表单或可执行内容,视频嵌入强制使用沙箱。 +NOTICE_TAGS = { + 'p', + 'br', + 'div', + 'span', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'strong', + 'b', + 'em', + 'i', + 'u', + 's', + 'strike', + 'sub', + 'sup', + 'blockquote', + 'pre', + 'code', + 'ul', + 'ol', + 'li', + 'hr', + 'table', + 'thead', + 'tbody', + 'tfoot', + 'tr', + 'th', + 'td', + 'a', + 'img', + 'iframe', +} +NOTICE_ATTRIBUTES = { + '*': {'class', 'title', 'style'}, + 'a': {'href'}, + 'img': {'src', 'alt', 'width', 'height'}, + 'iframe': {'src', 'width', 'height', 'frameborder', 'allowfullscreen'}, + 'th': {'colspan', 'rowspan'}, + 'td': {'colspan', 'rowspan'}, + 'ol': {'start'}, +} + + +def _filter_notice_attribute(tag: str, attribute: str, value: str) -> str | None: + """ + 限制富文本样式表达式和嵌入视频地址。 + + :param tag: 标签名称 + :param attribute: 属性名称 + :param value: 已解析的属性值 + :return: 允许的属性值,不安全时返回 None + """ + if attribute == 'style': + # 不允许 CSS 转义、动态表达式及外部资源,仅保留静态文本格式。 + normalized = re.sub(r'/\*.*?\*/', '', value, flags=re.DOTALL) + if re.search(r'(?:expression|url|var)\s*\(|[\\@]', normalized, re.IGNORECASE): + return None + if tag == 'iframe' and attribute == 'src': + try: + parsed = urlsplit(value) + if parsed.scheme.lower() not in {'http', 'https'} or not parsed.netloc: + return None + except ValueError: + return None + return value + + +def sanitize_notice_html(content: str | bytes | None) -> str | None: + """ + 按公告富文本白名单清洗写入内容及历史数据。 + + :param content: 原始公告正文,兼容数据库二进制内容 + :return: 清洗后的 HTML,未提供正文时返回 None + """ + if content is None: + return None + if isinstance(content, bytes): + content = content.decode('utf-8', errors='replace') + return nh3.clean( + content, + tags=NOTICE_TAGS, + attributes=NOTICE_ATTRIBUTES, + attribute_filter=_filter_notice_attribute, + set_tag_attribute_values={ + 'iframe': {'sandbox': 'allow-scripts allow-presentation', 'referrerpolicy': 'no-referrer'}, + }, + filter_style_properties={'color', 'background-color', 'font-size', 'font-family', 'text-align'}, + url_schemes={'http', 'https', 'mailto'}, + clean_content_tags={'script', 'style', 'object', 'embed', 'svg', 'math'}, + ) diff --git a/ruoyi-fastapi-frontend/react/web/package.json b/ruoyi-fastapi-frontend/react/web/package.json index 1ae21b348..5f4235241 100644 --- a/ruoyi-fastapi-frontend/react/web/package.json +++ b/ruoyi-fastapi-frontend/react/web/package.json @@ -14,7 +14,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "pnpm test:unit && pnpm test:contract", - "test:unit": "pnpm test:auth-center && pnpm test:plugin && pnpm test:job", + "test:unit": "pnpm test:auth-center && pnpm test:plugin && pnpm test:job && pnpm test:security", + "test:security": "node --test tests/security/*.test.cjs", "test:contract": "pnpm test:time", "test:job": "node --test tests/job/*.test.js", "test:plugin": "node --test tests/plugins/*.test.js && pnpm test:components tests/react/pluginFrame.test.jsx tests/react/plugin-status.test.jsx", diff --git a/ruoyi-fastapi-frontend/react/web/src/layout/components/HeaderNotice/DetailView.jsx b/ruoyi-fastapi-frontend/react/web/src/layout/components/HeaderNotice/DetailView.jsx index e44b71351..fbc89989c 100644 --- a/ruoyi-fastapi-frontend/react/web/src/layout/components/HeaderNotice/DetailView.jsx +++ b/ruoyi-fastapi-frontend/react/web/src/layout/components/HeaderNotice/DetailView.jsx @@ -2,7 +2,7 @@ import './style.css' import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons' import { useEffect, useState } from 'react' import { Drawer, Empty, Spin } from 'antd' -import DOMPurify from 'dompurify' +import { sanitizeNoticeHtml } from '@/utils/noticeHtml' import { getNotice } from '@/api/system/notice' import { parseTime } from '@/utils/ruoyi' export default function NoticeDetailView({ noticeId, onClose }) { @@ -69,7 +69,7 @@ export function NoticeArticle({ detail }) { {detail.noticeContent ? (
) : ( diff --git a/ruoyi-fastapi-frontend/react/web/src/utils/noticeHtml.js b/ruoyi-fastapi-frontend/react/web/src/utils/noticeHtml.js new file mode 100644 index 000000000..709a2c5f7 --- /dev/null +++ b/ruoyi-fastapi-frontend/react/web/src/utils/noticeHtml.js @@ -0,0 +1,71 @@ +import createDOMPurify from 'dompurify' + +const purifier = createDOMPurify(window) +purifier.addHook('uponSanitizeAttribute', (node, data) => { + if ( + node.tagName === 'IFRAME' && + data.attrName === 'src' && + !/^https?:\/\/[^/?#\s]+/i.test(data.attrValue) + ) { + data.keepAttr = false + } + if (data.attrName === 'href' || data.attrName === 'src') { + // 显式限制包括图片 data URI 在内的所有链接协议。 + const value = data.attrValue.replace(/[\u0000-\u0020\u007f-\u009f]/g, '') + const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i) + if (scheme && !['http', 'https', 'mailto'].includes(scheme[1].toLowerCase())) { + data.keepAttr = false + } + } +}) + +purifier.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'IFRAME') { + // 保留 Quill 视频能力,禁止嵌入页面访问父页面及同源存储。 + node.setAttribute('sandbox', 'allow-scripts allow-presentation') + node.setAttribute('referrerpolicy', 'no-referrer') + } + if (!node.hasAttribute('style')) return + // 保留编辑器使用的文本格式,剔除定位、背景资源等非正文样式。 + const properties = ['color', 'background-color', 'font-size', 'font-family', 'text-align'] + const styles = properties.map((property) => [property, node.style.getPropertyValue(property)]) + node.removeAttribute('style') + for (const [property, value] of styles) { + if (value) node.style.setProperty(property, value) + } +}) + +/** + * 清洗公告正文,与后端 notice_html_util 的富文本白名单保持一致。 + * @param {string} content 原始公告正文 + * @returns {string} 可安全渲染的公告正文 + */ +export function sanitizeNoticeHtml(content) { + return purifier.sanitize(content || '', { + ALLOWED_TAGS: + 'p br div span h1 h2 h3 h4 h5 h6 strong b em i u s strike sub sup blockquote pre code ul ol li hr table thead tbody tfoot tr th td a img iframe'.split( + ' ' + ), + ALLOWED_ATTR: [ + 'class', + 'title', + 'style', + 'href', + 'src', + 'alt', + 'width', + 'height', + 'colspan', + 'rowspan', + 'start', + 'rel', + 'sandbox', + 'referrerpolicy', + 'frameborder', + 'allowfullscreen', + ], + ALLOW_DATA_ATTR: false, + ALLOW_ARIA_ATTR: false, + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + }) +} diff --git a/ruoyi-fastapi-frontend/react/web/tests/security/notice-html.test.cjs b/ruoyi-fastapi-frontend/react/web/tests/security/notice-html.test.cjs new file mode 100644 index 000000000..bdff8d39a --- /dev/null +++ b/ruoyi-fastapi-frontend/react/web/tests/security/notice-html.test.cjs @@ -0,0 +1,44 @@ +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { resolve } = require('node:path') +const { test } = require('node:test') +const createDOMPurify = require('dompurify') +const { JSDOM } = require('jsdom') + +for (const framework of ['vue2', 'vue3', 'react']) { + test(`${framework} notice HTML removes active content and keeps rich text`, () => { + const source = readFileSync( + resolve(__dirname, `../../../../${framework}/web/src/utils/noticeHtml.js`), + 'utf8' + ) + .replace("import createDOMPurify from 'dompurify'", '') + .replace('export function', 'function') + const window = new JSDOM('').window + const sanitize = new Function( + 'createDOMPurify', + 'window', + `${source}; return sanitizeNoticeHtml` + )(createDOMPurify, window) + const clean = sanitize( + '

标题

重点

x' + ) + assert.match(clean, /重点<\/strong>/) + assert.match(clean, /src="\/safe.png"/) + assert.doesNotMatch(clean, /onerror|onload|正文

' + ) + assert.match(styled, /ql-align-center/) + assert.match(styled, /color: red/) + assert.match(styled, /background-color: yellow/) + assert.doesNotMatch(styled, /position|background-image/) + const video = sanitize( + '' + ) + assert.match(video, /src="https:\/\/video.example\/embed\/1"/) + assert.match(video, /sandbox="allow-scripts allow-presentation"/) + assert.doesNotMatch(video, /srcdoc|allow-same-origin/) + window.close() + }) +} diff --git a/ruoyi-fastapi-frontend/vue2/web/package.json b/ruoyi-fastapi-frontend/vue2/web/package.json index 84df7e1df..f99272920 100644 --- a/ruoyi-fastapi-frontend/vue2/web/package.json +++ b/ruoyi-fastapi-frontend/vue2/web/package.json @@ -43,6 +43,7 @@ "clipboard": "2.0.11", "core-js": "3.50.0", "dayjs": "1.11.23", + "dompurify": "3.4.17", "echarts": "5.6.0", "element-ui": "2.15.14", "file-saver": "2.0.5", diff --git a/ruoyi-fastapi-frontend/vue2/web/pnpm-lock.yaml b/ruoyi-fastapi-frontend/vue2/web/pnpm-lock.yaml index 2c8a7c0b8..af27042ad 100644 --- a/ruoyi-fastapi-frontend/vue2/web/pnpm-lock.yaml +++ b/ruoyi-fastapi-frontend/vue2/web/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: dayjs: specifier: 1.11.23 version: 1.11.23 + dompurify: + specifier: 3.4.17 + version: 3.4.17 echarts: specifier: 5.6.0 version: 5.6.0 diff --git a/ruoyi-fastapi-frontend/vue2/web/src/layout/components/HeaderNotice/DetailView.vue b/ruoyi-fastapi-frontend/vue2/web/src/layout/components/HeaderNotice/DetailView.vue index 86c1972c3..2c6673b70 100644 --- a/ruoyi-fastapi-frontend/vue2/web/src/layout/components/HeaderNotice/DetailView.vue +++ b/ruoyi-fastapi-frontend/vue2/web/src/layout/components/HeaderNotice/DetailView.vue @@ -71,7 +71,7 @@
import { getNotice } from '@/api/system/notice' +import { sanitizeNoticeHtml } from '@/utils/noticeHtml' export default { name: 'NoticeDetailView', @@ -98,6 +99,9 @@ export default { } }, computed: { + safeContent() { + return sanitizeNoticeHtml(this.detail && this.detail.noticeContent) + }, isStatusNormal() { const s = this.detail && this.detail.status return s === '0' || s === 0 diff --git a/ruoyi-fastapi-frontend/vue2/web/src/utils/noticeHtml.js b/ruoyi-fastapi-frontend/vue2/web/src/utils/noticeHtml.js new file mode 100644 index 000000000..709a2c5f7 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/web/src/utils/noticeHtml.js @@ -0,0 +1,71 @@ +import createDOMPurify from 'dompurify' + +const purifier = createDOMPurify(window) +purifier.addHook('uponSanitizeAttribute', (node, data) => { + if ( + node.tagName === 'IFRAME' && + data.attrName === 'src' && + !/^https?:\/\/[^/?#\s]+/i.test(data.attrValue) + ) { + data.keepAttr = false + } + if (data.attrName === 'href' || data.attrName === 'src') { + // 显式限制包括图片 data URI 在内的所有链接协议。 + const value = data.attrValue.replace(/[\u0000-\u0020\u007f-\u009f]/g, '') + const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i) + if (scheme && !['http', 'https', 'mailto'].includes(scheme[1].toLowerCase())) { + data.keepAttr = false + } + } +}) + +purifier.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'IFRAME') { + // 保留 Quill 视频能力,禁止嵌入页面访问父页面及同源存储。 + node.setAttribute('sandbox', 'allow-scripts allow-presentation') + node.setAttribute('referrerpolicy', 'no-referrer') + } + if (!node.hasAttribute('style')) return + // 保留编辑器使用的文本格式,剔除定位、背景资源等非正文样式。 + const properties = ['color', 'background-color', 'font-size', 'font-family', 'text-align'] + const styles = properties.map((property) => [property, node.style.getPropertyValue(property)]) + node.removeAttribute('style') + for (const [property, value] of styles) { + if (value) node.style.setProperty(property, value) + } +}) + +/** + * 清洗公告正文,与后端 notice_html_util 的富文本白名单保持一致。 + * @param {string} content 原始公告正文 + * @returns {string} 可安全渲染的公告正文 + */ +export function sanitizeNoticeHtml(content) { + return purifier.sanitize(content || '', { + ALLOWED_TAGS: + 'p br div span h1 h2 h3 h4 h5 h6 strong b em i u s strike sub sup blockquote pre code ul ol li hr table thead tbody tfoot tr th td a img iframe'.split( + ' ' + ), + ALLOWED_ATTR: [ + 'class', + 'title', + 'style', + 'href', + 'src', + 'alt', + 'width', + 'height', + 'colspan', + 'rowspan', + 'start', + 'rel', + 'sandbox', + 'referrerpolicy', + 'frameborder', + 'allowfullscreen', + ], + ALLOW_DATA_ATTR: false, + ALLOW_ARIA_ATTR: false, + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + }) +} diff --git a/ruoyi-fastapi-frontend/vue3/web/package.json b/ruoyi-fastapi-frontend/vue3/web/package.json index 315e809de..4bc6a489c 100644 --- a/ruoyi-fastapi-frontend/vue3/web/package.json +++ b/ruoyi-fastapi-frontend/vue3/web/package.json @@ -37,6 +37,7 @@ "axios": "1.20.0", "clipboard": "2.0.11", "dayjs": "1.11.23", + "dompurify": "3.4.17", "echarts": "6.1.0", "element-plus": "2.14.7", "file-saver": "2.0.5", diff --git a/ruoyi-fastapi-frontend/vue3/web/pnpm-lock.yaml b/ruoyi-fastapi-frontend/vue3/web/pnpm-lock.yaml index b0b2cdf33..be56e948b 100644 --- a/ruoyi-fastapi-frontend/vue3/web/pnpm-lock.yaml +++ b/ruoyi-fastapi-frontend/vue3/web/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: dayjs: specifier: 1.11.23 version: 1.11.23 + dompurify: + specifier: 3.4.17 + version: 3.4.17 echarts: specifier: 6.1.0 version: 6.1.0 diff --git a/ruoyi-fastapi-frontend/vue3/web/src/layout/components/HeaderNotice/DetailView.vue b/ruoyi-fastapi-frontend/vue3/web/src/layout/components/HeaderNotice/DetailView.vue index 6d8a8aecc..e68526861 100644 --- a/ruoyi-fastapi-frontend/vue3/web/src/layout/components/HeaderNotice/DetailView.vue +++ b/ruoyi-fastapi-frontend/vue3/web/src/layout/components/HeaderNotice/DetailView.vue @@ -71,7 +71,7 @@
import { getNotice } from '@/api/system/notice' +import { sanitizeNoticeHtml } from '@/utils/noticeHtml' const visible = ref(false) const loading = ref(false) const detail = ref(null) +const safeContent = computed(() => sanitizeNoticeHtml(detail.value?.noticeContent)) + const isStatusNormal = computed(() => { const status = detail.value && detail.value.status return status === '0' || status === 0 diff --git a/ruoyi-fastapi-frontend/vue3/web/src/utils/noticeHtml.js b/ruoyi-fastapi-frontend/vue3/web/src/utils/noticeHtml.js new file mode 100644 index 000000000..709a2c5f7 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue3/web/src/utils/noticeHtml.js @@ -0,0 +1,71 @@ +import createDOMPurify from 'dompurify' + +const purifier = createDOMPurify(window) +purifier.addHook('uponSanitizeAttribute', (node, data) => { + if ( + node.tagName === 'IFRAME' && + data.attrName === 'src' && + !/^https?:\/\/[^/?#\s]+/i.test(data.attrValue) + ) { + data.keepAttr = false + } + if (data.attrName === 'href' || data.attrName === 'src') { + // 显式限制包括图片 data URI 在内的所有链接协议。 + const value = data.attrValue.replace(/[\u0000-\u0020\u007f-\u009f]/g, '') + const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i) + if (scheme && !['http', 'https', 'mailto'].includes(scheme[1].toLowerCase())) { + data.keepAttr = false + } + } +}) + +purifier.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'IFRAME') { + // 保留 Quill 视频能力,禁止嵌入页面访问父页面及同源存储。 + node.setAttribute('sandbox', 'allow-scripts allow-presentation') + node.setAttribute('referrerpolicy', 'no-referrer') + } + if (!node.hasAttribute('style')) return + // 保留编辑器使用的文本格式,剔除定位、背景资源等非正文样式。 + const properties = ['color', 'background-color', 'font-size', 'font-family', 'text-align'] + const styles = properties.map((property) => [property, node.style.getPropertyValue(property)]) + node.removeAttribute('style') + for (const [property, value] of styles) { + if (value) node.style.setProperty(property, value) + } +}) + +/** + * 清洗公告正文,与后端 notice_html_util 的富文本白名单保持一致。 + * @param {string} content 原始公告正文 + * @returns {string} 可安全渲染的公告正文 + */ +export function sanitizeNoticeHtml(content) { + return purifier.sanitize(content || '', { + ALLOWED_TAGS: + 'p br div span h1 h2 h3 h4 h5 h6 strong b em i u s strike sub sup blockquote pre code ul ol li hr table thead tbody tfoot tr th td a img iframe'.split( + ' ' + ), + ALLOWED_ATTR: [ + 'class', + 'title', + 'style', + 'href', + 'src', + 'alt', + 'width', + 'height', + 'colspan', + 'rowspan', + 'start', + 'rel', + 'sandbox', + 'referrerpolicy', + 'frameborder', + 'allowfullscreen', + ], + ALLOW_DATA_ATTR: false, + ALLOW_ARIA_ATTR: false, + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + }) +} From 099505c400ad2ea4feda87eba2b47fb9f6538312 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:28:29 +0800 Subject: [PATCH 06/21] =?UTF-8?q?fix:=20=E6=9C=AA=E7=9F=A5=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E5=93=8D=E5=BA=94=E9=9A=90=E8=97=8F=E5=86=85=E9=83=A8?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=B9=B6=E6=8F=90=E4=BE=9B=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=A0=87=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/annotation/log_annotation.py | 4 +- ruoyi-fastapi-backend/exceptions/handle.py | 4 +- .../tests/common/test_exception_responses.py | 54 +++++++++++++++++++ ruoyi-fastapi-backend/utils/exception_util.py | 28 ++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 ruoyi-fastapi-backend/tests/common/test_exception_responses.py create mode 100644 ruoyi-fastapi-backend/utils/exception_util.py diff --git a/ruoyi-fastapi-backend/common/annotation/log_annotation.py b/ruoyi-fastapi-backend/common/annotation/log_annotation.py index 6353d0c57..47763aa4a 100644 --- a/ruoyi-fastapi-backend/common/annotation/log_annotation.py +++ b/ruoyi-fastapi-backend/common/annotation/log_annotation.py @@ -29,6 +29,7 @@ from module_admin.service.log_service import LogQueueService from utils.client_ip_util import ClientIPUtil from utils.dependency_util import DependencyUtil +from utils.exception_util import ExceptionUtil from utils.log_util import LogSanitizer, logger from utils.response_util import ResponseUtil from utils.time_util import TimezoneUtil @@ -205,8 +206,7 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: except FileRangeNotSatisfiableException: raise except Exception as e: - logger.exception(e) - result = ResponseUtil.error(msg=str(e)) + result = ExceptionUtil.handle_unexpected_exception(e) # 获取请求耗时 cost_time = float(time.perf_counter() - start_time) * 1000 # 判断请求是否来自api文档 diff --git a/ruoyi-fastapi-backend/exceptions/handle.py b/ruoyi-fastapi-backend/exceptions/handle.py index 996fb0d36..a20bd36d8 100644 --- a/ruoyi-fastapi-backend/exceptions/handle.py +++ b/ruoyi-fastapi-backend/exceptions/handle.py @@ -17,6 +17,7 @@ ServiceException, ServiceWarning, ) +from utils.exception_util import ExceptionUtil from utils.log_util import logger from utils.oidc_util import OidcUtil from utils.response_util import JSONResponse, ResponseUtil, jsonable_encoder @@ -124,8 +125,7 @@ async def http_exception_handler(request: Request, exc: HTTPException) -> Respon # 处理其他异常 @app.exception_handler(Exception) async def exception_handler(request: Request, exc: Exception) -> Response: - logger.exception(exc) - return ResponseUtil.error(msg=str(exc)) + return ExceptionUtil.handle_unexpected_exception(exc) def _build_oauth_redirect(exc: OAuthProtocolException) -> Response: diff --git a/ruoyi-fastapi-backend/tests/common/test_exception_responses.py b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py new file mode 100644 index 000000000..2551c7fc1 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py @@ -0,0 +1,54 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from common.annotation.log_annotation import Log +from common.constant import HttpStatusConstant +from common.context import RequestContext +from common.enums import BusinessType +from exceptions.handle import handle_exception +from module_admin.service.log_service import LogQueueService +from utils.dependency_util import DependencyUtil + + +@pytest.fixture +def log_context(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + monkeypatch.setattr(DependencyUtil, 'check_exclude_routes', lambda *args, **kwargs: None) + monkeypatch.setattr(Log, '_get_oper_location', AsyncMock(return_value='测试')) + monkeypatch.setattr( + RequestContext, + 'get_current_user', + lambda: SimpleNamespace( + user=SimpleNamespace(user_name='tester', dept=None), + ), + ) + monkeypatch.setattr(LogQueueService, 'enqueue_operation_log', AsyncMock()) + diagnostic_logger = MagicMock() + monkeypatch.setattr('utils.exception_util.logger', diagnostic_logger) + return diagnostic_logger + + +@pytest.mark.parametrize('decorated', [False, True]) +def test_unexpected_error_is_safe_and_correlates_with_server_log(log_context: MagicMock, decorated: bool) -> None: + app = FastAPI() + handle_exception(app) + error = RuntimeError('SELECT secret FROM users; password=private-marker /srv/private/config') + + async def endpoint(request: Request) -> None: + raise error + + app.get('/failure')(Log(title='测试', business_type=BusinessType.OTHER)(endpoint) if decorated else endpoint) + response = TestClient(app, raise_server_exceptions=False).get('/failure') + payload = response.json() + assert payload['code'] == HttpStatusConstant.ERROR + assert payload['msg'] == '服务暂时不可用,请稍后重试' + assert 'private-marker' not in response.text + assert 'SELECT' not in response.text + assert '/srv/private' not in response.text + error_id = payload['data']['errorId'] + assert response.headers['X-Error-Id'] == error_id + log_context.bind.assert_called_once_with(error_id=error_id) + log_context.bind.return_value.opt.assert_called_once_with(exception=error) diff --git a/ruoyi-fastapi-backend/utils/exception_util.py b/ruoyi-fastapi-backend/utils/exception_util.py new file mode 100644 index 000000000..2dc5b2137 --- /dev/null +++ b/ruoyi-fastapi-backend/utils/exception_util.py @@ -0,0 +1,28 @@ +from uuid import uuid4 + +from fastapi import Response + +from utils.log_util import logger +from utils.response_util import ResponseUtil + + +class ExceptionUtil: + """ + 未知异常的安全响应与诊断工具类。 + """ + + @staticmethod + def handle_unexpected_exception(exc: Exception) -> Response: + """ + 记录脱敏诊断并返回不包含内部异常信息的统一响应。 + + :param exc: 未知异常对象 + :return: 包含错误关联标识的安全响应 + """ + error_id = uuid4().hex + logger.bind(error_id=error_id).opt(exception=exc).error('请求处理失败,错误标识:{}', error_id) + return ResponseUtil.error( + msg='服务暂时不可用,请稍后重试', + data={'errorId': error_id}, + headers={'X-Error-Id': error_id}, + ) From 6e04800fea5d1986f3378decec415f0948ac3f8b Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:29:54 +0800 Subject: [PATCH 07/21] =?UTF-8?q?fix:=20=E6=93=8D=E4=BD=9C=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=BF=9D=E7=95=99=E8=AE=A4=E8=AF=81=E4=B8=8E=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E5=BC=82=E5=B8=B8=E7=9A=84=E5=93=8D=E5=BA=94=E8=AF=AD?= =?UTF-8?q?=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/annotation/log_annotation.py | 152 ++++++++++-------- ruoyi-fastapi-backend/exceptions/handle.py | 6 +- .../tests/common/test_exception_responses.py | 59 ++++++- 3 files changed, 150 insertions(+), 67 deletions(-) diff --git a/ruoyi-fastapi-backend/common/annotation/log_annotation.py b/ruoyi-fastapi-backend/common/annotation/log_annotation.py index 47763aa4a..a5db0caee 100644 --- a/ruoyi-fastapi-backend/common/annotation/log_annotation.py +++ b/ruoyi-fastapi-backend/common/annotation/log_annotation.py @@ -11,7 +11,10 @@ import httpx from async_lru import alru_cache from fastapi import Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, ORJSONResponse, UJSONResponse +from pydantic_validation_decorator import FieldValidationError +from starlette.exceptions import HTTPException from starlette.status import HTTP_200_OK from typing_extensions import ParamSpec from user_agents import parse @@ -20,8 +23,13 @@ from common.enums import BusinessType from config.env import AppConfig from exceptions.exception import ( + AuthException, FileRangeNotSatisfiableException, LoginException, + ModelValidatorException, + OAuthProtocolException, + OidcInteractionException, + PermissionException, ServiceException, ServiceWarning, ) @@ -194,78 +202,94 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: oper_time = TimezoneUtil.utc_now() # 此处在登录之前向原始函数传递一些登录信息,用于监测在线用户的相关信息 login_log = self._get_login_log(user_agent, oper_ip, oper_location, oper_time, kwargs) + raised_exception = None try: # 调用原始函数 result = await func(*args, **kwargs) - except (LoginException, ServiceWarning) as e: - logger.warning(e.message) - result = ResponseUtil.failure(data=e.data, msg=e.message) - except ServiceException as e: - logger.error(e.message) - result = ResponseUtil.error(data=e.data, msg=e.message) - except FileRangeNotSatisfiableException: - raise + except ( + AuthException, + PermissionException, + LoginException, + ServiceException, + ServiceWarning, + ModelValidatorException, + FieldValidationError, + FileRangeNotSatisfiableException, + HTTPException, + RequestValidationError, + OAuthProtocolException, + OidcInteractionException, + ) as e: + # 已知异常仅构造审计摘要,实际响应仍交由全局异常处理器生成。 + raised_exception = e + result = ResponseUtil.error(msg=getattr(e, 'message', None) or str(e)) except Exception as e: result = ExceptionUtil.handle_unexpected_exception(e) - # 获取请求耗时 - cost_time = float(time.perf_counter() - start_time) * 1000 - # 判断请求是否来自api文档 - request_from_swagger, request_from_redoc = self._is_request_from_swagger_or_redoc(request) - # 根据响应结果的类型使用不同的方法获取响应结果参数 - sanitized_result_dict = LogSanitizer.sanitize_data( - self._get_result_dict(result, request_from_swagger, request_from_redoc) - ) - json_result = self._build_log_text( - sanitized_result_dict, - self.response_log_mode, - self.response_include_fields, - self.response_exclude_fields, - payload_kind='response', - ) - # 日志表返回参数字段长度最大为2000,因此在此处判断长度 - json_result = self._limit_log_text(json_result, self._json_result_len, '返回参数过长') - # 根据响应结果获取响应状态及异常信息 - status, error_msg = self._get_status_and_error_msg(sanitized_result_dict) - # 根据日志类型向对应的日志表插入数据 - if self.log_type == 'login': - # 登录请求来自于api文档时不记录登录日志,其余情况则记录 - if request_from_swagger or request_from_redoc: - pass + try: + # 获取请求耗时 + cost_time = float(time.perf_counter() - start_time) * 1000 + # 判断请求是否来自api文档 + request_from_swagger, request_from_redoc = self._is_request_from_swagger_or_redoc(request) + # 根据响应结果的类型使用不同的方法获取响应结果参数 + sanitized_result_dict = LogSanitizer.sanitize_data( + self._get_result_dict(result, request_from_swagger, request_from_redoc) + ) + json_result = self._build_log_text( + sanitized_result_dict, + self.response_log_mode, + self.response_include_fields, + self.response_exclude_fields, + payload_kind='response', + ) + # 日志表返回参数字段长度最大为2000,因此在此处判断长度 + json_result = self._limit_log_text(json_result, self._json_result_len, '返回参数过长') + # 根据响应结果获取响应状态及异常信息 + status, error_msg = self._get_status_and_error_msg(sanitized_result_dict) + # 根据日志类型向对应的日志表插入数据 + if self.log_type == 'login': + # 登录请求来自于api文档时不记录登录日志,其余情况则记录 + if request_from_swagger or request_from_redoc: + pass + else: + user = kwargs.get('form_data') + login_log.update( + { + 'loginTime': oper_time, + 'userName': user.username, + 'status': str(status), + 'msg': sanitized_result_dict.get('msg') or '', + } + ) + + await LogQueueService.enqueue_login_log(request, LogininforModel(**login_log), func_path) else: - user = kwargs.get('form_data') - login_log.update( - { - 'loginTime': oper_time, - 'userName': user.username, - 'status': str(status), - 'msg': sanitized_result_dict.get('msg') or '', - } + current_user = RequestContext.get_current_user() + oper_name = current_user.user.user_name + dept_name = current_user.user.dept.dept_name if current_user.user.dept else None + operation_log = OperLogModel( + title=self.title, + businessType=self.business_type, + method=func_path, + requestMethod=request_method, + operatorType=operator_type, + operName=oper_name, + deptName=dept_name, + operUrl=oper_url, + operIp=oper_ip, + operLocation=oper_location, + operParam=oper_param, + jsonResult=json_result, + status=status, + errorMsg=error_msg, + operTime=oper_time, + costTime=int(cost_time), ) + await LogQueueService.enqueue_operation_log(request, operation_log, func_path) - await LogQueueService.enqueue_login_log(request, LogininforModel(**login_log), func_path) - else: - current_user = RequestContext.get_current_user() - oper_name = current_user.user.user_name - dept_name = current_user.user.dept.dept_name if current_user.user.dept else None - operation_log = OperLogModel( - title=self.title, - businessType=self.business_type, - method=func_path, - requestMethod=request_method, - operatorType=operator_type, - operName=oper_name, - deptName=dept_name, - operUrl=oper_url, - operIp=oper_ip, - operLocation=oper_location, - operParam=oper_param, - jsonResult=json_result, - status=status, - errorMsg=error_msg, - operTime=oper_time, - costTime=int(cost_time), - ) - await LogQueueService.enqueue_operation_log(request, operation_log, func_path) + finally: + # 即使审计写入失败,也不能覆盖原有认证或协议异常。 + if raised_exception is not None: + raise raised_exception return result diff --git a/ruoyi-fastapi-backend/exceptions/handle.py b/ruoyi-fastapi-backend/exceptions/handle.py index a20bd36d8..7bad197dc 100644 --- a/ruoyi-fastapi-backend/exceptions/handle.py +++ b/ruoyi-fastapi-backend/exceptions/handle.py @@ -1,10 +1,10 @@ from urllib.parse import urlsplit from fastapi import FastAPI, Request, Response, status -from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse as FastAPIJSONResponse from fastapi.responses import RedirectResponse from pydantic_validation_decorator import FieldValidationError +from starlette.exceptions import HTTPException from exceptions.exception import ( AuthException, @@ -119,7 +119,9 @@ async def file_range_not_satisfiable_exception_handler( @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException) -> Response: return JSONResponse( - content=jsonable_encoder({'code': exc.status_code, 'msg': exc.detail}), status_code=exc.status_code + content=jsonable_encoder({'code': exc.status_code, 'msg': exc.detail}), + status_code=exc.status_code, + headers=exc.headers, ) # 处理其他异常 diff --git a/ruoyi-fastapi-backend/tests/common/test_exception_responses.py b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py index 2551c7fc1..29cbcc2e9 100644 --- a/ruoyi-fastapi-backend/tests/common/test_exception_responses.py +++ b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py @@ -2,13 +2,22 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from fastapi import FastAPI, Request +from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient from common.annotation.log_annotation import Log from common.constant import HttpStatusConstant from common.context import RequestContext from common.enums import BusinessType +from exceptions.exception import ( + AuthException, + FileRangeNotSatisfiableException, + LoginException, + OAuthProtocolException, + OidcInteractionException, + PermissionException, + ServiceException, +) from exceptions.handle import handle_exception from module_admin.service.log_service import LogQueueService from utils.dependency_util import DependencyUtil @@ -52,3 +61,51 @@ async def endpoint(request: Request) -> None: assert response.headers['X-Error-Id'] == error_id log_context.bind.assert_called_once_with(error_id=error_id) log_context.bind.return_value.opt.assert_called_once_with(exception=error) + + +@pytest.mark.parametrize( + 'exception_kind', ['auth', 'permission', 'http', 'oauth', 'redirect', 'oidc', 'login', 'service', 'range'] +) +@pytest.mark.parametrize('audit_fails', [False, True]) +def test_logged_known_errors_preserve_global_response( + log_context: MagicMock, monkeypatch: pytest.MonkeyPatch, exception_kind: str, audit_fails: bool +) -> None: + exceptions = { + 'auth': AuthException(message='令牌失效'), + 'permission': PermissionException(message='无权限'), + 'http': HTTPException(401, '凭证无效', headers={'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store'}), + 'oauth': OAuthProtocolException('invalid_client'), + 'redirect': OAuthProtocolException( + 'access_denied', redirect_uri='https://client.example/callback', redirect_uri_verified=True, state='opaque' + ), + 'oidc': OidcInteractionException(interaction_id='expired'), + 'login': LoginException(message='登录失败'), + 'service': ServiceException(message='操作失败'), + 'range': FileRangeNotSatisfiableException(20), + } + app = FastAPI() + handle_exception(app) + + async def endpoint(request: Request) -> None: + raise exceptions[exception_kind] + + app.get('/plain')(endpoint) + app.get('/logged')(Log(title='测试', business_type=BusinessType.OTHER)(endpoint)) + if audit_fails: + monkeypatch.setattr( + LogQueueService, 'enqueue_operation_log', AsyncMock(side_effect=RuntimeError('queue unavailable')) + ) + client = TestClient(app, raise_server_exceptions=False) + plain = client.get('/plain', follow_redirects=False) + logged = client.get('/logged', follow_redirects=False) + assert logged.status_code == plain.status_code + for name in ['location', 'www-authenticate', 'cache-control', 'pragma', 'content-range']: + assert logged.headers.get(name) == plain.headers.get(name) + if plain.content: + expected, actual = plain.json(), logged.json() + expected.pop('time', None) + actual.pop('time', None) + assert actual == expected + else: + assert not logged.content + LogQueueService.enqueue_operation_log.assert_awaited_once() From faed7fb66ba885add33826278b26b1e6f5877c89 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:33:33 +0800 Subject: [PATCH 08/21] =?UTF-8?q?fix:=20=E7=A7=BB=E5=8A=A8=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E4=B8=8E=E4=B8=8A=E4=BC=A0=E4=BC=98=E5=85=88=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20HTTP=20=E9=94=99=E8=AF=AF=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vue2/mobile/src/utils/request.js | 5 +- .../vue2/mobile/src/utils/response.js | 24 +++++++ .../vue2/mobile/src/utils/upload.js | 5 +- .../vue3/mobile/src/utils/request.js | 5 +- .../vue3/mobile/src/utils/response.js | 24 +++++++ .../vue3/mobile/src/utils/upload.js | 5 +- .../frontend/mobile-behavior.mjs | 71 ++++++++++++++++--- .../frontend/mobile-harness.mjs | 40 +++++++++-- 8 files changed, 154 insertions(+), 25 deletions(-) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/utils/response.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/response.js diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js index 2c46c5177..0066b075a 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js @@ -2,7 +2,7 @@ import { getDisplayTimezone } from '@/utils/time' import store from '@/store' import config from '@/config' import { getToken } from '@/utils/auth' -import errorCode from '@/utils/errorCode' +import { getResponseStatus } from '@/utils/response' import { toast, showConfirm, tansParams } from '@/utils/common' import { decryptTransportErrorResponse, @@ -66,8 +66,7 @@ const request = async (config) => { return } - const code = res.data.code || 200 - const msg = errorCode[code] || res.data.msg || errorCode['default'] + const { code, msg } = getResponseStatus(res, res.data) if (code === 401) { showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { if (res.confirm) { diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/response.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/response.js new file mode 100644 index 000000000..31574a1fa --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/response.js @@ -0,0 +1,24 @@ +import errorCode from '@/utils/errorCode' + +/** + * 统一解析 HTTP 状态和业务码,HTTP 失败优先于响应体中的成功码。 + * @param {Object} response 平台原始响应 + * @param {Object} data 已解析或解密的业务响应 + * @returns {{code: number, msg: string}} 统一响应状态 + */ +export function getResponseStatus(response, data) { + const status = Number(response.statusCode ?? response.status ?? 200) + const code = status < 200 || status >= 300 ? status : Number(data?.code ?? 200) + const detail = data?.detail + const detailMessage = + typeof detail === 'string' + ? detail + : Array.isArray(detail) + ? detail + .map((item) => item.msg) + .filter(Boolean) + .join(';') + : '' + const msg = data?.msg || detailMessage || errorCode[code] || errorCode['default'] + return { code, msg } +} diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js index 50219b259..76b916b69 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js @@ -1,7 +1,7 @@ import store from '@/store' import config from '@/config' import { getToken } from '@/utils/auth' -import errorCode from '@/utils/errorCode' +import { getResponseStatus } from '@/utils/response' import { toast, showConfirm, tansParams } from '@/utils/common' let timeout = 10000 @@ -30,8 +30,7 @@ const upload = (config) => { formData: config.formData, success: (res) => { let result = JSON.parse(res.data) - const code = result.code || 200 - const msg = errorCode[code] || result.msg || errorCode['default'] + const { code, msg } = getResponseStatus(res, result) if (code === 200) { resolve(result) } else if (code == 401) { diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js index e1298e5be..2065a5fe8 100644 --- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js @@ -1,7 +1,7 @@ import config from '@/config' import { getToken } from '@/utils/auth' import { getDisplayTimezone } from '@/utils/time' -import errorCode from '@/utils/errorCode' +import { getResponseStatus } from '@/utils/response' import { useUserStore } from '@/store/modules/user' import { toast, showConfirm, tansParams } from '@/utils/common' import { @@ -57,8 +57,7 @@ const request = async (config) => { return } - const code = res.data.code || 200 - const msg = errorCode[code] || res.data.msg || errorCode['default'] + const { code, msg } = getResponseStatus(res, res.data) if (code === 401) { showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { if (res.confirm) { diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/response.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/response.js new file mode 100644 index 000000000..31574a1fa --- /dev/null +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/response.js @@ -0,0 +1,24 @@ +import errorCode from '@/utils/errorCode' + +/** + * 统一解析 HTTP 状态和业务码,HTTP 失败优先于响应体中的成功码。 + * @param {Object} response 平台原始响应 + * @param {Object} data 已解析或解密的业务响应 + * @returns {{code: number, msg: string}} 统一响应状态 + */ +export function getResponseStatus(response, data) { + const status = Number(response.statusCode ?? response.status ?? 200) + const code = status < 200 || status >= 300 ? status : Number(data?.code ?? 200) + const detail = data?.detail + const detailMessage = + typeof detail === 'string' + ? detail + : Array.isArray(detail) + ? detail + .map((item) => item.msg) + .filter(Boolean) + .join(';') + : '' + const msg = data?.msg || detailMessage || errorCode[code] || errorCode['default'] + return { code, msg } +} diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js index 46471afbf..01bc7c1c8 100644 --- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js @@ -1,7 +1,7 @@ import { useUserStore } from '@/store' import config from '@/config' import { getToken } from '@/utils/auth' -import errorCode from '@/utils/errorCode' +import { getResponseStatus } from '@/utils/response' import { toast, showConfirm, tansParams } from '@/utils/common' let timeout = 10000 @@ -30,8 +30,7 @@ export default function upload(config) { formData: config.formData, success: (res) => { let result = JSON.parse(res.data) - const code = result.code || 200 - const msg = errorCode[code] || result.msg || errorCode['default'] + const { code, msg } = getResponseStatus(res, result) if (code === 200) { resolve(result) } else if (code == 401) { diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs index eee5b793d..240f303f0 100644 --- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs +++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs @@ -3,8 +3,45 @@ import test from 'node:test' import { createMobileHarness, settle, snapshot } from './mobile-harness.mjs' export function testMobileBehavior(root, framework) { + test('HTTP failures reject requests and uploads even without a business error code', async () => { + const app = await createMobileHarness(root, framework, { + token: 'active-token', + confirm: false, + }) + for (const statusCode of [401, 422, 500]) { + for (const data of [{ detail: [{ msg: '字段无效' }] }, { code: 200 }]) { + app.responses.push({ statusCode, data }) + await assert.rejects( + app.request({ url: '/failure' }), + (error) => error.response.statusCode === statusCode + ) + app.uploadResponses.push({ statusCode, data: JSON.stringify(data) }) + await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' })) + } + } + app.responses.push({ + statusCode: 422, + data: { detail: [{ msg: '字段无效' }] }, + }) + await assert.rejects(app.request({ url: '/validation' }), /字段无效/) + assert.equal((await app.upload({ url: '/upload', filePath: '/tmp/example' })).code, 200) + }) + + test('decrypted HTTP failures retain their status and reject', async () => { + const app = await createMobileHarness(root, framework, { + decryptResponse: async (response) => ({ + ...response, + data: { detail: '解密后的错误' }, + }), + }) + app.responses.push({ statusCode: 500, data: { encrypted: true } }) + await assert.rejects(app.request({ url: '/encrypted' }), /解密后的错误/) + }) + test('requests use the current token and timezone while public requests omit credentials', async () => { - const app = await createMobileHarness(root, framework, { token: 'active-token' }) + const app = await createMobileHarness(root, framework, { + token: 'active-token', + }) app.time.setUserTimezone('America/New_York') assert.equal( (await app.request({ url: '/private', params: { name: 'a b', page: 2 } })).code, @@ -27,9 +64,14 @@ export function testMobileBehavior(root, framework) { }) test('confirmed expired sessions reject the request, clear account state and relaunch login', async () => { - const app = await createMobileHarness(root, framework, { token: 'expired-token' }) + const app = await createMobileHarness(root, framework, { + token: 'expired-token', + }) await app.getInfo() - app.responses.push({ statusCode: 200, data: { code: 401, msg: 'expired' } }) + app.responses.push({ + statusCode: 200, + data: { code: 401, msg: 'expired' }, + }) await assert.rejects( app.request({ url: '/private' }), (error) => error.response.data.code === 401 @@ -50,7 +92,10 @@ export function testMobileBehavior(root, framework) { token: 'expired-token', confirm: false, }) - app.responses.push({ statusCode: 200, data: { code: 401, msg: 'expired' } }) + app.responses.push({ + statusCode: 200, + data: { code: 401, msg: 'expired' }, + }) await assert.rejects(app.request({ url: '/private' })) await settle() assert.equal(app.auth.getToken(), 'expired-token') @@ -107,14 +152,19 @@ export function testMobileBehavior(root, framework) { }) test('cold start restores only signed-in timezones and discards invalid preferences', async () => { - const savedTimezone = { appTimezone: 'Asia/Shanghai', timeZone: 'America/New_York' } + const savedTimezone = { + appTimezone: 'Asia/Shanghai', + timeZone: 'America/New_York', + } const signedIn = await createMobileHarness(root, framework, { token: 'active-token', savedTimezone, }) assert.equal(signedIn.time.getUserTimezone(), 'America/New_York') assert.equal(signedIn.time.formatBusinessTime('2026-01-01T00:00:00Z'), '2025-12-31 19:00:00') - const signedOut = await createMobileHarness(root, framework, { savedTimezone }) + const signedOut = await createMobileHarness(root, framework, { + savedTimezone, + }) assert.equal(signedOut.time.getUserTimezone(), 'auto') const invalid = await createMobileHarness(root, framework, { token: 'active-token', @@ -125,8 +175,13 @@ export function testMobileBehavior(root, framework) { }) test('non-authentication failures stay rejected without clearing the account', async () => { - const app = await createMobileHarness(root, framework, { token: 'active-token' }) - app.responses.push({ statusCode: 429, data: { code: 429, msg: 'rate limited' } }) + const app = await createMobileHarness(root, framework, { + token: 'active-token', + }) + app.responses.push({ + statusCode: 429, + data: { code: 429, msg: 'rate limited' }, + }) await assert.rejects( app.request({ url: '/private' }), (error) => error.response.statusCode === 429 diff --git a/ruoyi-fastapi-test/frontend/mobile-harness.mjs b/ruoyi-fastapi-test/frontend/mobile-harness.mjs index 5cd42bf92..530676e16 100644 --- a/ruoyi-fastapi-test/frontend/mobile-harness.mjs +++ b/ruoyi-fastapi-test/frontend/mobile-harness.mjs @@ -13,15 +13,24 @@ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.strin export async function createMobileHarness( root, framework, - { token = '', savedTimezone, confirm = true } = {} + { token = '', savedTimezone, confirm = true, decryptResponse } = {} ) { const project = fileURLToPath(root) const nativeRequire = createRequire(new URL('package.json', root)) const values = new Map([['device-theme', 'dark']]) if (token) values.set('App-Token', token) if (savedTimezone) values.set('storage_data', { timezone: copy(savedTimezone) }) - const calls = { requests: [], modals: [], toasts: [], navigations: [], login: [], logout: [] } + const calls = { + requests: [], + uploads: [], + modals: [], + toasts: [], + navigations: [], + login: [], + logout: [], + } const responses = [] + const uploadResponses = [] const interceptors = new Map() const uni = { getStorageSync: (key) => copy(values.get(key)), @@ -34,9 +43,22 @@ export async function createMobileHarness( calls.modals.push(options.content) queueMicrotask(() => options.success({ confirm, cancel: !confirm })) }, + uploadFile(options) { + calls.uploads.push(copy(options)) + const response = uploadResponses.shift() || { + statusCode: 200, + data: '{"code":200}', + } + queueMicrotask(() => + response.failure ? options.fail(response.failure) : options.success(response) + ) + }, request(options) { calls.requests.push(copy(options)) - const response = responses.shift() || { statusCode: 200, data: { code: 200, data: 'ok' } } + const response = responses.shift() || { + statusCode: 200, + data: { code: 200, data: 'ok' }, + } queueMicrotask(() => response.failure ? options.fail(response.failure) : options.success(response) ) @@ -64,14 +86,19 @@ export async function createMobileHarness( }, getInfo: async () => ({ appTimezone: 'Asia/Shanghai', - user: { userId: 7, userName: 'tester', avatar: '', timeZone: 'America/New_York' }, + user: { + userId: 7, + userName: 'tester', + avatar: '', + timeZone: 'America/New_York', + }, roles: ['operator'], permissions: ['system:user:query'], }), }, '@/utils/transportCrypto': { encryptTransportRequest: async (config) => config, - decryptTransportResponse: async (response) => response, + decryptTransportResponse: decryptResponse || (async (response) => response), decryptTransportErrorResponse: async (error) => error, shouldRetryTransportWithFreshKey: () => false, invalidateTransportKeyMeta() {}, @@ -150,6 +177,7 @@ export async function createMobileHarness( const time = await load('@/utils/time') const auth = await load('@/utils/auth') const request = (await load('@/utils/request')).default + const upload = (await load('@/utils/upload')).default await load('@/permission') return { state, @@ -159,6 +187,8 @@ export async function createMobileHarness( time, auth, request, + upload, + uploadResponses, values, responses, calls, From a88cad318f11480752de2515115c018f7d9cd42d Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:35:22 +0800 Subject: [PATCH 09/21] =?UTF-8?q?fix:=20=E4=B8=8A=E4=BC=A0=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5=E5=92=8C=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E9=94=99=E8=AF=AF=E6=97=B6=E5=8F=AF=E9=9D=A0=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=20Promise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vue2/mobile/src/utils/upload.js | 52 ++++++++++------- .../vue3/mobile/src/utils/upload.js | 56 +++++++++++-------- .../frontend/mobile-behavior.mjs | 22 ++++++++ 3 files changed, 86 insertions(+), 44 deletions(-) diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js index 76b916b69..77f51c772 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js @@ -29,29 +29,37 @@ const upload = (config) => { header: config.header, formData: config.formData, success: (res) => { - let result = JSON.parse(res.data) - const { code, msg } = getResponseStatus(res, result) - if (code === 200) { - resolve(result) - } else if (code == 401) { - showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { - if (res.confirm) { - store.dispatch('LogOut').then((res) => { - uni.reLaunch({ url: '/pages/login/login' }) - }) - } - }) - reject('无效的会话,或者会话已过期,请重新登录。') - } else if (code === 500) { - toast(msg) - reject('500') - } else if (code !== 200) { - toast(msg) - reject(code) + try { + const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data + if (!result || typeof result !== 'object') { + throw new Error('上传响应格式不正确') + } + const { code, msg } = getResponseStatus(res, result) + if (code === 200) { + resolve(result) + } else if (code == 401) { + showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { + if (res.confirm) { + store.dispatch('LogOut').then((res) => { + uni.reLaunch({ url: '/pages/login/login' }) + }) + } + }) + reject('无效的会话,或者会话已过期,请重新登录。') + } else if (code === 500) { + toast(msg) + reject('500') + } else if (code !== 200) { + toast(msg) + reject(code) + } + } catch (error) { + toast('上传响应格式不正确') + reject(error) } }, fail: (error) => { - let { message } = error + let message = String(error?.message || error?.errMsg || '上传失败') if (message == 'Network Error') { message = '后端接口连接异常' } else if (message.includes('timeout')) { @@ -60,7 +68,9 @@ const upload = (config) => { message = '系统接口' + message.substr(message.length - 3) + '异常' } toast(message) - reject(error) + const uploadError = error instanceof Error ? error : new Error(message) + if (uploadError !== error) uploadError.cause = error + reject(uploadError) }, }) }) diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js index 01bc7c1c8..46ea048db 100644 --- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js @@ -29,31 +29,39 @@ export default function upload(config) { header: config.header, formData: config.formData, success: (res) => { - let result = JSON.parse(res.data) - const { code, msg } = getResponseStatus(res, result) - if (code === 200) { - resolve(result) - } else if (code == 401) { - showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { - if (res.confirm) { - useUserStore() - .logOut() - .then((res) => { - uni.reLaunch({ url: '/pages/login/login' }) - }) - } - }) - reject('无效的会话,或者会话已过期,请重新登录。') - } else if (code === 500) { - toast(msg) - reject('500') - } else if (code !== 200) { - toast(msg) - reject(code) + try { + const result = typeof res.data === 'string' ? JSON.parse(res.data) : res.data + if (!result || typeof result !== 'object') { + throw new Error('上传响应格式不正确') + } + const { code, msg } = getResponseStatus(res, result) + if (code === 200) { + resolve(result) + } else if (code == 401) { + showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { + if (res.confirm) { + useUserStore() + .logOut() + .then((res) => { + uni.reLaunch({ url: '/pages/login/login' }) + }) + } + }) + reject('无效的会话,或者会话已过期,请重新登录。') + } else if (code === 500) { + toast(msg) + reject('500') + } else if (code !== 200) { + toast(msg) + reject(code) + } + } catch (error) { + toast('上传响应格式不正确') + reject(error) } }, fail: (error) => { - let { message } = error + let message = String(error?.message || error?.errMsg || '上传失败') if (message == 'Network Error') { message = '后端接口连接异常' } else if (message.includes('timeout')) { @@ -62,7 +70,9 @@ export default function upload(config) { message = '系统接口' + message.substr(message.length - 3) + '异常' } toast(message) - reject(error) + const uploadError = error instanceof Error ? error : new Error(message) + if (uploadError !== error) uploadError.cause = error + reject(uploadError) }, }) }) diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs index 240f303f0..26d571e48 100644 --- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs +++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs @@ -38,6 +38,28 @@ export function testMobileBehavior(root, framework) { await assert.rejects(app.request({ url: '/encrypted' }), /解密后的错误/) }) + test('upload parse failures and platform errors always settle the promise', async () => { + const app = await createMobileHarness(root, framework) + for (const data of ['bad gateway', '', 'null']) { + app.uploadResponses.push({ statusCode: 502, data }) + await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' })) + } + for (const failure of [ + { errMsg: 'uploadFile:fail timeout' }, + { errMsg: 'uploadFile:fail network' }, + {}, + ]) { + app.uploadResponses.push({ failure }) + await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' }), (error) => + Boolean(error.message) + ) + } + assert.equal( + app.calls.toasts.some((entry) => entry.title === '系统接口请求超时'), + true + ) + }) + test('requests use the current token and timezone while public requests omit credentials', async () => { const app = await createMobileHarness(root, framework, { token: 'active-token', From b44fe77757ee1ddd8fba5c8a191331f6b6b346d8 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:35:31 +0800 Subject: [PATCH 10/21] =?UTF-8?q?fix:=20=E4=B8=8A=E4=BC=A0=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=A4=B1=E6=95=88=E5=90=8E=E8=B7=B3=E8=BD=AC=E5=88=B0?= =?UTF-8?q?=E5=B7=B2=E6=B3=A8=E5=86=8C=E7=9A=84=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js | 2 +- ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js | 2 +- ruoyi-fastapi-test/frontend/mobile-behavior.mjs | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js index 77f51c772..b3da170df 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js @@ -41,7 +41,7 @@ const upload = (config) => { showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { if (res.confirm) { store.dispatch('LogOut').then((res) => { - uni.reLaunch({ url: '/pages/login/login' }) + uni.reLaunch({ url: '/pages/login' }) }) } }) diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js index 46ea048db..235d898ad 100644 --- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js @@ -43,7 +43,7 @@ export default function upload(config) { useUserStore() .logOut() .then((res) => { - uni.reLaunch({ url: '/pages/login/login' }) + uni.reLaunch({ url: '/pages/login' }) }) } }) diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs index 26d571e48..be1a3e282 100644 --- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs +++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs @@ -60,6 +60,14 @@ export function testMobileBehavior(root, framework) { ) }) + test('expired upload sessions navigate to the registered login page', async () => { + const app = await createMobileHarness(root, framework, { token: 'expired-token' }) + app.uploadResponses.push({ statusCode: 200, data: '{"code":401}' }) + await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' })) + await settle() + assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }]) + }) + test('requests use the current token and timezone while public requests omit credentials', async () => { const app = await createMobileHarness(root, framework, { token: 'active-token', From 3be52bc4124d03a1b720f929805f30be218928c0 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:43:11 +0800 Subject: [PATCH 11/21] =?UTF-8?q?fix:=20=E8=BF=9C=E7=AB=AF=E9=80=80?= =?UTF-8?q?=E5=87=BA=E5=A4=B1=E8=B4=A5=E6=97=B6=E4=BB=8D=E6=B8=85=E7=90=86?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E6=9C=AC=E5=9C=B0=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vue2/mobile/src/store/modules/user.js | 34 +++++++++---------- .../vue3/mobile/src/store/modules/user.js | 34 +++++++++---------- .../frontend/mobile-behavior.mjs | 22 ++++++++++++ .../frontend/mobile-harness.mjs | 3 +- 4 files changed, 58 insertions(+), 35 deletions(-) diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js b/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js index dfdaf162d..205ee547a 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js @@ -110,23 +110,23 @@ const user = { }) }, - // 退出系统 - LogOut({ commit, state }) { - return new Promise((resolve, reject) => { - logout(state.token) - .then(() => { - commit('SET_TOKEN', '') - commit('SET_TIMEZONE', 'auto') - commit('SET_ROLES', []) - commit('SET_PERMISSIONS', []) - removeToken() - storage.clean() - resolve() - }) - .catch((error) => { - reject(error) - }) - }) + // 退出系统:远端撤销失败也必须清理本地会话,保证可以重新登录。 + async LogOut({ commit, state }) { + try { + await logout(state.token) + } catch { + // 远端异常由请求层提示,本地退出不依赖远端撤销结果。 + } finally { + commit('SET_TOKEN', '') + commit('SET_ID', '') + commit('SET_NAME', '') + commit('SET_AVATAR', '') + commit('SET_TIMEZONE', 'auto') + commit('SET_ROLES', []) + commit('SET_PERMISSIONS', []) + removeToken() + storage.clean() + } }, }, } diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/store/modules/user.js b/ruoyi-fastapi-frontend/vue3/mobile/src/store/modules/user.js index 17b4e8657..8ccc71af2 100644 --- a/ruoyi-fastapi-frontend/vue3/mobile/src/store/modules/user.js +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/store/modules/user.js @@ -116,23 +116,23 @@ export const useUserStore = defineStore('user', () => { }) } - // 退出系统 - const logOutAction = () => { - return new Promise((resolve, reject) => { - logout(token.value) - .then(() => { - SET_TOKEN('') - applyTimezone('auto') - SET_ROLES([]) - SET_PERMISSIONS([]) - removeToken() - storage.clean() - resolve() - }) - .catch((error) => { - reject(error) - }) - }) + // 退出系统:远端撤销失败也必须清理本地会话,保证可以重新登录。 + const logOutAction = async () => { + try { + await logout(token.value) + } catch { + // 远端异常由请求层提示,本地退出不依赖远端撤销结果。 + } finally { + SET_TOKEN('') + SET_ID('') + SET_NAME('') + SET_AVATAR('') + applyTimezone('auto') + SET_ROLES([]) + SET_PERMISSIONS([]) + removeToken() + storage.clean() + } } return { diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs index be1a3e282..db89abd6a 100644 --- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs +++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs @@ -68,6 +68,28 @@ export function testMobileBehavior(root, framework) { assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }]) }) + test('remote logout failures still clear all account state and allow login navigation', async () => { + for (const message of ['server error', 'timeout', 'Network Error']) { + const app = await createMobileHarness(root, framework, { + token: 'expired-token', + logoutError: new Error(message), + }) + await app.getInfo() + app.responses.push({ statusCode: 401, data: {} }) + await assert.rejects(app.request({ url: '/private' })) + await settle() + assert.equal(app.auth.getToken(), undefined) + assert.equal(app.state.token, '') + assert.equal(app.state.id, '') + assert.equal(app.state.name, '') + assert.equal(app.state.avatar, '') + assert.deepEqual(snapshot(app.state.roles), []) + assert.deepEqual(snapshot(app.state.permissions), []) + assert.equal(app.values.has('storage_data'), false) + assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }]) + } + }) + test('requests use the current token and timezone while public requests omit credentials', async () => { const app = await createMobileHarness(root, framework, { token: 'active-token', diff --git a/ruoyi-fastapi-test/frontend/mobile-harness.mjs b/ruoyi-fastapi-test/frontend/mobile-harness.mjs index 530676e16..569cd2fa1 100644 --- a/ruoyi-fastapi-test/frontend/mobile-harness.mjs +++ b/ruoyi-fastapi-test/frontend/mobile-harness.mjs @@ -13,7 +13,7 @@ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.strin export async function createMobileHarness( root, framework, - { token = '', savedTimezone, confirm = true, decryptResponse } = {} + { token = '', savedTimezone, confirm = true, decryptResponse, logoutError } = {} ) { const project = fileURLToPath(root) const nativeRequire = createRequire(new URL('package.json', root)) @@ -83,6 +83,7 @@ export async function createMobileHarness( }, logout: async (value) => { calls.logout.push(value) + if (logoutError) throw logoutError }, getInfo: async () => ({ appTimezone: 'Asia/Shanghai', From c9d7fb4ed036cd6879a1187fae830c9628ea917d Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:43:41 +0800 Subject: [PATCH 12/21] =?UTF-8?q?fix:=20=E8=AF=B7=E6=B1=82=E4=B8=8E?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=85=B1=E7=94=A8=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=A4=B1=E6=95=88=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vue2/mobile/src/utils/common.js | 1 + .../vue2/mobile/src/utils/request.js | 17 ++++----- .../vue2/mobile/src/utils/session.js | 29 +++++++++++++++ .../vue2/mobile/src/utils/upload.js | 17 ++++----- .../vue3/mobile/src/utils/common.js | 1 + .../vue3/mobile/src/utils/request.js | 19 ++++------ .../vue3/mobile/src/utils/session.js | 29 +++++++++++++++ .../vue3/mobile/src/utils/upload.js | 19 ++++------ .../frontend/mobile-behavior.mjs | 35 +++++++++++++++++++ .../frontend/mobile-harness.mjs | 14 ++++++-- 10 files changed, 131 insertions(+), 50 deletions(-) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/utils/session.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/session.js diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js index 194d1cb66..cb99fbf6b 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js @@ -23,6 +23,7 @@ export function showConfirm(content) { success: function (res) { resolve(res) }, + fail: reject, }) }) } diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js index 0066b075a..24203266f 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js @@ -1,9 +1,9 @@ +import { handleSessionExpired } from '@/utils/session' import { getDisplayTimezone } from '@/utils/time' -import store from '@/store' import config from '@/config' import { getToken } from '@/utils/auth' import { getResponseStatus } from '@/utils/response' -import { toast, showConfirm, tansParams } from '@/utils/common' +import { toast, tansParams } from '@/utils/common' import { decryptTransportErrorResponse, decryptTransportResponse, @@ -28,8 +28,9 @@ const request = async (config) => { config.header = config.header || {} config.headers = config.headers || {} config.header['X-Timezone'] = config.header['X-Timezone'] || getDisplayTimezone() - if (getToken() && !isToken) { - config.header['Authorization'] = 'Bearer ' + getToken() + const token = getToken() + if (token && !isToken) { + config.header['Authorization'] = 'Bearer ' + token } try { @@ -68,13 +69,7 @@ const request = async (config) => { const { code, msg } = getResponseStatus(res, res.data) if (code === 401) { - showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { - if (res.confirm) { - store.dispatch('LogOut').then(() => { - uni.reLaunch({ url: '/pages/login' }) - }) - } - }) + handleSessionExpired(isToken ? null : token) const error = new Error('无效的会话,或者会话已过期,请重新登录。') error.response = res reject(error) diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/session.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/session.js new file mode 100644 index 000000000..6dd1959b0 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/session.js @@ -0,0 +1,29 @@ +import store from '@/store' +import { getToken } from '@/utils/auth' +import { showConfirm } from '@/utils/common' + +let sessionExpiredPromise = null + +/** + * 合并普通请求与上传的会话失效处理,避免重复确认、退出和跳转。 + * @param {string} token 发起请求时使用的令牌 + * @returns {Promise} 是否已确认退出 + */ +export function handleSessionExpired(token) { + // 忽略旧会话的迟到响应,避免影响已经重新登录的账号。 + if (!token || token !== getToken()) return Promise.resolve(false) + if (sessionExpiredPromise) return sessionExpiredPromise + sessionExpiredPromise = Promise.resolve() + .then(() => showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?')) + .then(async (result) => { + if (!result.confirm || token !== getToken()) return false + await store.dispatch('LogOut') + uni.reLaunch({ url: '/pages/login' }) + return true + }) + .catch(() => false) + .finally(() => { + sessionExpiredPromise = null + }) + return sessionExpiredPromise +} diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js index b3da170df..e56e0a499 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js @@ -1,8 +1,8 @@ -import store from '@/store' +import { handleSessionExpired } from '@/utils/session' import config from '@/config' import { getToken } from '@/utils/auth' import { getResponseStatus } from '@/utils/response' -import { toast, showConfirm, tansParams } from '@/utils/common' +import { toast, tansParams } from '@/utils/common' let timeout = 10000 const baseUrl = config.baseUrl @@ -11,8 +11,9 @@ const upload = (config) => { // 是否需要设置 token const isToken = (config.headers || {}).isToken === false config.header = config.header || {} - if (getToken() && !isToken) { - config.header['Authorization'] = 'Bearer ' + getToken() + const token = getToken() + if (token && !isToken) { + config.header['Authorization'] = 'Bearer ' + token } // get请求映射params参数 if (config.params) { @@ -38,13 +39,7 @@ const upload = (config) => { if (code === 200) { resolve(result) } else if (code == 401) { - showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { - if (res.confirm) { - store.dispatch('LogOut').then((res) => { - uni.reLaunch({ url: '/pages/login' }) - }) - } - }) + handleSessionExpired(isToken ? null : token) reject('无效的会话,或者会话已过期,请重新登录。') } else if (code === 500) { toast(msg) diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/common.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/common.js index 194d1cb66..cb99fbf6b 100644 --- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/common.js +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/common.js @@ -23,6 +23,7 @@ export function showConfirm(content) { success: function (res) { resolve(res) }, + fail: reject, }) }) } diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js index 2065a5fe8..ff5c510f2 100644 --- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/request.js @@ -1,9 +1,9 @@ +import { handleSessionExpired } from '@/utils/session' import config from '@/config' import { getToken } from '@/utils/auth' import { getDisplayTimezone } from '@/utils/time' import { getResponseStatus } from '@/utils/response' -import { useUserStore } from '@/store/modules/user' -import { toast, showConfirm, tansParams } from '@/utils/common' +import { toast, tansParams } from '@/utils/common' import { decryptTransportErrorResponse, decryptTransportResponse, @@ -22,8 +22,9 @@ const request = async (config) => { config.header = config.header || {} config.headers = config.headers || {} config.header['X-Timezone'] = config.header['X-Timezone'] || getDisplayTimezone() - if (getToken() && !isToken) { - config.header['Authorization'] = 'Bearer ' + getToken() + const token = getToken() + if (token && !isToken) { + config.header['Authorization'] = 'Bearer ' + token } try { @@ -59,15 +60,7 @@ const request = async (config) => { const { code, msg } = getResponseStatus(res, res.data) if (code === 401) { - showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { - if (res.confirm) { - useUserStore() - .logOut() - .then(() => { - uni.reLaunch({ url: '/pages/login' }) - }) - } - }) + handleSessionExpired(isToken ? null : token) const error = new Error('无效的会话,或者会话已过期,请重新登录。') error.response = res reject(error) diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/session.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/session.js new file mode 100644 index 000000000..1c6344ba1 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/session.js @@ -0,0 +1,29 @@ +import { useUserStore } from '@/store/modules/user' +import { getToken } from '@/utils/auth' +import { showConfirm } from '@/utils/common' + +let sessionExpiredPromise = null + +/** + * 合并普通请求与上传的会话失效处理,避免重复确认、退出和跳转。 + * @param {string} token 发起请求时使用的令牌 + * @returns {Promise} 是否已确认退出 + */ +export function handleSessionExpired(token) { + // 忽略旧会话的迟到响应,避免影响已经重新登录的账号。 + if (!token || token !== getToken()) return Promise.resolve(false) + if (sessionExpiredPromise) return sessionExpiredPromise + sessionExpiredPromise = Promise.resolve() + .then(() => showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?')) + .then(async (result) => { + if (!result.confirm || token !== getToken()) return false + await useUserStore().logOut() + uni.reLaunch({ url: '/pages/login' }) + return true + }) + .catch(() => false) + .finally(() => { + sessionExpiredPromise = null + }) + return sessionExpiredPromise +} diff --git a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js index 235d898ad..db67d4c74 100644 --- a/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js +++ b/ruoyi-fastapi-frontend/vue3/mobile/src/utils/upload.js @@ -1,8 +1,8 @@ -import { useUserStore } from '@/store' +import { handleSessionExpired } from '@/utils/session' import config from '@/config' import { getToken } from '@/utils/auth' import { getResponseStatus } from '@/utils/response' -import { toast, showConfirm, tansParams } from '@/utils/common' +import { toast, tansParams } from '@/utils/common' let timeout = 10000 const baseUrl = config.baseUrl @@ -11,8 +11,9 @@ export default function upload(config) { // 是否需要设置 token const isToken = (config.headers || {}).isToken === false config.header = config.header || {} - if (getToken() && !isToken) { - config.header['Authorization'] = 'Bearer ' + getToken() + const token = getToken() + if (token && !isToken) { + config.header['Authorization'] = 'Bearer ' + token } // get请求映射params参数 if (config.params) { @@ -38,15 +39,7 @@ export default function upload(config) { if (code === 200) { resolve(result) } else if (code == 401) { - showConfirm('登录状态已过期,您可以继续留在该页面,或者重新登录?').then((res) => { - if (res.confirm) { - useUserStore() - .logOut() - .then((res) => { - uni.reLaunch({ url: '/pages/login' }) - }) - } - }) + handleSessionExpired(isToken ? null : token) reject('无效的会话,或者会话已过期,请重新登录。') } else if (code === 500) { toast(msg) diff --git a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs index db89abd6a..1483367fc 100644 --- a/ruoyi-fastapi-test/frontend/mobile-behavior.mjs +++ b/ruoyi-fastapi-test/frontend/mobile-behavior.mjs @@ -90,6 +90,41 @@ export function testMobileBehavior(root, framework) { } }) + test('concurrent request and upload failures share one session recovery', async () => { + const app = await createMobileHarness(root, framework, { token: 'expired-token' }) + app.responses.push(...Array.from({ length: 3 }, () => ({ statusCode: 401, data: {} }))) + app.uploadResponses.push(...Array.from({ length: 3 }, () => ({ statusCode: 401, data: '{}' }))) + await Promise.all( + Array.from({ length: 3 }, () => [ + assert.rejects(app.request({ url: '/private' })), + assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' })), + ]).flat() + ) + await settle() + assert.equal(app.calls.modals.length, 1) + assert.deepEqual(app.calls.logout, ['expired-token']) + assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }]) + }) + + test('cancelled or failed dialogs release session recovery for later requests', async () => { + for (const first of [{ confirm: false }, { failure: new Error('modal unavailable') }]) { + const app = await createMobileHarness(root, framework, { + token: 'expired-token', + confirmResponses: [first, { confirm: true }], + }) + app.responses.push({ statusCode: 401, data: {} }) + await assert.rejects(app.request({ url: '/private' })) + await settle() + assert.equal(app.auth.getToken(), 'expired-token') + app.uploadResponses.push({ statusCode: 401, data: '{}' }) + await assert.rejects(app.upload({ url: '/upload', filePath: '/tmp/example' })) + await settle() + assert.equal(app.calls.modals.length, 2) + assert.deepEqual(app.calls.logout, ['expired-token']) + assert.deepEqual(app.calls.navigations, [{ url: '/pages/login' }]) + } + }) + test('requests use the current token and timezone while public requests omit credentials', async () => { const app = await createMobileHarness(root, framework, { token: 'active-token', diff --git a/ruoyi-fastapi-test/frontend/mobile-harness.mjs b/ruoyi-fastapi-test/frontend/mobile-harness.mjs index 569cd2fa1..7a646f01f 100644 --- a/ruoyi-fastapi-test/frontend/mobile-harness.mjs +++ b/ruoyi-fastapi-test/frontend/mobile-harness.mjs @@ -13,7 +13,14 @@ const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.strin export async function createMobileHarness( root, framework, - { token = '', savedTimezone, confirm = true, decryptResponse, logoutError } = {} + { + token = '', + savedTimezone, + confirm = true, + decryptResponse, + logoutError, + confirmResponses = [], + } = {} ) { const project = fileURLToPath(root) const nativeRequire = createRequire(new URL('package.json', root)) @@ -41,7 +48,10 @@ export async function createMobileHarness( showToast: (options) => calls.toasts.push(copy(options)), showModal(options) { calls.modals.push(options.content) - queueMicrotask(() => options.success({ confirm, cancel: !confirm })) + const result = confirmResponses.shift() || { confirm, cancel: !confirm } + queueMicrotask(() => + result.failure ? options.fail(result.failure) : options.success(result) + ) }, uploadFile(options) { calls.uploads.push(copy(options)) From 754c0e3cef1d7649707eb50b4a2d42f61c8da53c Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:45:30 +0800 Subject: [PATCH 13/21] =?UTF-8?q?fix:=20CLI=20=E4=BF=9D=E7=95=99=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=89=A7=E8=A1=8C=E7=BC=96=E5=8F=B7=E4=B8=8E=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E5=90=8C=E6=AD=A5=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/runtime/job/service.py | 4 +- .../tests/cli/runtime/test_job.py | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/ruoyi-fastapi-backend/cli/runtime/job/service.py b/ruoyi-fastapi-backend/cli/runtime/job/service.py index 6f6c38e4b..705e46cd8 100644 --- a/ruoyi-fastapi-backend/cli/runtime/job/service.py +++ b/ruoyi-fastapi-backend/cli/runtime/job/service.py @@ -211,8 +211,10 @@ async def run_with_scheduler_context( 'operation': operation, 'operationLabel': operation_metadata['operationLabel'], 'targetStatus': status, - 'message': operation_metadata['successMessage'] if result.is_success else '定时任务操作执行失败', + 'message': result.message + or (operation_metadata['successMessage'] if result.is_success else '定时任务操作执行失败'), 'serviceMessage': result.message, + 'result': result.result, } async def run_job_once(self, job_id: int) -> dict[str, Any]: diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py b/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py index 6da16e82c..2f3b3c6f2 100644 --- a/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py +++ b/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py @@ -1,8 +1,14 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + import pytest +from cli.output import OutputRenderer from cli.runtime.job import JobRuntimeService from cli.runtime.job.gateway import JobInfrastructureGateway from cli.runtime.job.support import JobDomainSupport, JobSchedulerSupport +from common.vo import CrudResponseModel MISSING_JOB_ID = 99 @@ -172,3 +178,39 @@ def _fake_get_job_service() -> FakeJobService: assert payload['ok'] is False assert payload['jobId'] == MISSING_JOB_ID assert payload['message'] == f'定时任务不存在:{MISSING_JOB_ID}' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['run-once', 'pause', 'resume']) +async def test_job_operation_retains_execution_and_sync_results( + operation: str, capsys: pytest.CaptureFixture[str] +) -> None: + result = ( + {'executionId': 'execution-123', 'status': 'pending'} + if operation == 'run-once' + else {'saved': True, 'synced': False, 'jobs': [{'jobId': 7, 'syncStatus': 'pending'}]} + ) + response = CrudResponseModel(is_success=True, message='已提交,等待调度器处理', result=result) + job_service = SimpleNamespace( + execute_job_once_services=AsyncMock(return_value=response), + edit_job_services=AsyncMock(return_value=response), + ) + gateway = MagicMock() + gateway.get_async_session_local.return_value = MagicMock(return_value=AsyncMock()) + gateway.get_redis_util.return_value.create_redis_pool = AsyncMock() + gateway.get_scheduler_manager.return_value.init_system_scheduler = AsyncMock() + gateway.get_job_service.return_value = job_service + scheduler_support = SimpleNamespace(close_scheduler_context=AsyncMock()) + service = JobRuntimeService(infrastructure_gateway=gateway, scheduler_support=scheduler_support) + payload = await service.run_with_scheduler_context(operation, 7) + assert payload['result'] == result + assert payload['message'] == response.message + OutputRenderer().emit_output(payload, 'json') + assert json.loads(capsys.readouterr().out)['result'] == result + text = OutputRenderer().render_text_output(payload) + if operation == 'run-once': + assert 'execution-123' in text + assert 'pending' in text + else: + assert 'saved' in text and 'synced' in text + scheduler_support.close_scheduler_context.assert_awaited_once() From ab4219f108e7debb2fc515c324676086eb0ed153 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Wed, 21 Oct 2026 16:48:51 +0800 Subject: [PATCH 14/21] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=20WebView=20=E5=8D=8F=E8=AE=AE=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=B9=B6=E9=81=BF=E5=85=8D=E9=87=8D=E5=A4=8D=E8=A7=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/src/pages/common/webview/index.jsx | 5 ++-- .../react/mobile/src/utils/webview.js | 22 ++++++++++++++++++ .../react/mobile/tests/route-params.test.mjs | 18 +++++++++++++++ .../mobile/src/pages/common/webview/index.vue | 3 ++- .../vue2/mobile/src/utils/webview.js | 22 ++++++++++++++++++ .../mobile/src/pages/common/webview/index.vue | 3 ++- .../vue3/mobile/src/utils/webview.js | 22 ++++++++++++++++++ .../frontend/mobile-behavior.mjs | 23 +++++++++++++++++++ .../frontend/mobile-harness.mjs | 2 ++ 9 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/utils/webview.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/webview.js diff --git a/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx b/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx index 273fc47a1..8c83d7143 100644 --- a/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx +++ b/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx @@ -1,13 +1,14 @@ import React, { useState } from 'react' import Taro, { useLoad, useDidShow } from '@tarojs/taro' import { decodeRouteParam } from '@/utils/routeParams' +import { resolveWebviewUrl } from '@/utils/webview' import { WebView, View, Text } from '@tarojs/components' export default function Browser() { const [params, setParams] = useState({}) useLoad((options) => { setParams({ title: decodeRouteParam(options.title), - url: decodeRouteParam(options.url), + url: resolveWebviewUrl(options.url), }) }) useDidShow(() => { @@ -16,7 +17,7 @@ export default function Browser() { React.useEffect(() => { if (params.title) Taro.setNavigationBarTitle({ title: params.title }) }, [params.title]) - return /^https?:\/\//i.test(params.url || '') ? ( + return params.url ? ( ) : ( diff --git a/ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js b/ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js new file mode 100644 index 000000000..2e2419a6e --- /dev/null +++ b/ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js @@ -0,0 +1,22 @@ +/** + * 解析 WebView 路由地址,保留 HTTP(S) 外部网页能力。 + * 仅对整体编码的 URL 解码一次,避免改写已解码地址中的嵌套查询参数。 + * @param {string} value 路由传入的网页地址 + * @returns {string} 合法的 HTTP(S) 地址,无效时返回空字符串 + */ +export function resolveWebviewUrl(value) { + if (typeof value !== 'string') return '' + let url = value.trim() + if (/^https?%3a%2f%2f/i.test(url)) { + try { + url = decodeURIComponent(url) + } catch { + return '' + } + } + // 各移动平台的 URL API 支持不同,使用保守的协议与主机检查。 + if (!/^https?:\/\/[^/?#\s]+(?:[/?#]|$)/i.test(url) || /[\u0000-\u0020\u007f\\]/.test(url)) { + return '' + } + return url +} diff --git a/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs b/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs index 8c99b3aa5..0899578f6 100644 --- a/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs +++ b/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs @@ -17,3 +17,21 @@ test('missing and malformed query values do not crash the page', () => { assert.equal(decodeRouteParam('说明 100%'), '说明 100%') assert.equal(decodeRouteParam('%E4%B8'), '%E4%B8') }) + +test('webview keeps nested query encoding and rejects non-HTTP protocols', async () => { + const { resolveWebviewUrl } = await import('../src/utils/webview.js') + const url = + 'https://example.com/中文?q=100%25&next=https%3A%2F%2Fother.example%2F%3Fa%3D1%26b%3D2' + assert.equal(resolveWebviewUrl(url), url) + assert.equal(resolveWebviewUrl(encodeURIComponent(url)), url) + for (const invalid of [ + 'javascript:alert(1)', + 'data:text/html,x', + 'file:///private', + '//example.com', + 'https://', + '%E4%B8', + ]) { + assert.equal(resolveWebviewUrl(invalid), '') + } +}) diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue index 1e66ffa7c..cb5352c2c 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue @@ -8,6 +8,7 @@