diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index 814213cc8..89b6cdb5d 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -1,6 +1,7 @@ import asyncio import mimetypes from typing import Annotated, Any, Dict, List, Optional +from urllib.parse import urlsplit import aiofiles from anyio import Path as AsyncPath @@ -155,7 +156,7 @@ def _is_plugin_auth_remote_file(plugin_id: str, filepath: str) -> bool: remote = provider.get("remote") or {} if str(remote.get("id") or "").lower() != normalized_plugin_id: continue - remote_path = str(remote.get("url") or "").lstrip("/") + remote_path = urlsplit(str(remote.get("url") or "")).path.lstrip("/") remote_path_lower = remote_path.lower() expected_prefix = f"plugin/file/{normalized_plugin_id}/" if not remote_path_lower.startswith(expected_prefix): @@ -756,7 +757,10 @@ async def plugin_static_file( return StreamingResponse( file_generator(), media_type=response_type, - headers={"Content-Disposition": f"inline; filename={plugin_file_path.name}"}, + headers={ + "Content-Disposition": f"inline; filename={plugin_file_path.name}", + "Cache-Control": "no-cache, must-revalidate", + }, ) except Exception as e: logger.error( diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py index 266bb3cb7..32a427870 100644 --- a/app/runtime/extensions/plugin/projection.py +++ b/app/runtime/extensions/plugin/projection.py @@ -2,6 +2,7 @@ import inspect from typing import Any, Callable, Dict, List, Mapping, Optional +from urllib.parse import urlencode from app.runtime.extensions.plugin.contracts import ( PluginDashboardError, @@ -177,13 +178,19 @@ class PluginProjection: """构造联邦远程入口描述,分身额外带出其源插件 ID。 分身与本体共享同一份前端产物,只有源插件名下才有产物目录;前端联邦加载器 - 拿不到源插件 ID 就只能按分身 ID 去取,必然落空。 + 拿不到源插件 ID 就只能按分身 ID 去取,必然落空。入口 URL 同时带上插件版本, + 让插件更新后得到新的浏览器与 Service Worker 缓存键。 """ if not self._remote_entry_factory: raise RuntimeError("插件联邦入口生成器尚未配置") + remote_url = self._remote_entry_factory(plugin_id, dist_path) + plugin_version = getattr(plugin, "plugin_version", None) + if plugin_version: + separator = "&" if "?" in remote_url else "?" + remote_url = f"{remote_url}{separator}{urlencode({'v': str(plugin_version)})}" remote: Dict[str, Any] = { "id": plugin_id, - "url": self._remote_entry_factory(plugin_id, dist_path), + "url": remote_url, "name": plugin.plugin_name, } source_plugin_id = getattr(plugin, "plugin_source_id", None) diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index 814ed3708..36d3ca34d 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -274,6 +274,34 @@ def test_plugin_static_file_requires_resource_token_by_default(monkeypatch): assert calls == ["resource-token"] +def test_plugin_auth_remote_accepts_versioned_entry_url(monkeypatch): + """认证远程入口带缓存版本参数时仍允许匿名读取。""" + calls = [] + + class FakePluginManager: + """返回带版本参数的认证远程入口桩。""" + + def get_plugin_auth_providers(self): + """返回认证远程入口列表。""" + return [{ + "remote": { + "id": "AuthPlugin", + "url": "/plugin/file/AuthPlugin/dist/remoteEntry.js?v=1.2.3", + } + }] + + monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: FakePluginManager()) + monkeypatch.setattr(plugin_endpoint, "verify_resource_token", lambda token: calls.append(token)) + + plugin_endpoint._verify_plugin_static_file_access( + plugin_id="AuthPlugin", + filepath="dist/remoteEntry.js", + resource_token=None, + ) + + assert calls == [] + + def test_plugin_auth_remote_files_allow_anonymous_bootstrap(monkeypatch): """插件登录认证远程组件需要允许登录前匿名加载。""" calls = [] diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 07fe1d00a..7c232e840 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -1229,6 +1229,7 @@ def test_virtual_instance_static_file_reads_from_source_directory(tmp_path, monk assert asyncio.run(read_body()) == b"export default 'shared'" assert response.media_type == "application/javascript" + assert response.headers["cache-control"] == "no-cache, must-revalidate" plugin_manager.get_plugin_source_id.assert_called_once_with("DemoPluginwork") diff --git a/tests/test_plugin_projection.py b/tests/test_plugin_projection.py index 66af525e4..cb7697604 100644 --- a/tests/test_plugin_projection.py +++ b/tests/test_plugin_projection.py @@ -217,6 +217,20 @@ def test_projection_builds_federation_and_auth_provider_entries(): }] +def test_projection_versions_federation_remote_entry_after_plugin_update(): + """插件版本变化时,联邦入口 URL 应生成新的缓存键。""" + plugin = _Plugin( + plugin_version="1.2.3+build 4", + get_render_mode=lambda: ("vue", "dist/assets"), + ) + projection = PluginProjection( + {"Demo": plugin}, + remote_entry_factory=lambda plugin_id, path: f"/{plugin_id}/{path}", + ) + + assert projection.remotes()[0]["url"] == "/Demo/dist/assets?v=1.2.3%2Bbuild+4" + + def test_projection_exposes_source_identity_for_virtual_frontend_instance(): """虚拟实例的联邦入口保留实例 URL,并补充共享源码身份。""" plugin = _Plugin(