From eecb4f66e901117bc5273eaa1a342df6f459bce1 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Wed, 7 Oct 2026 17:22:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=A2=E9=98=85=E5=BC=8F=E8=BD=AF?= =?UTF-8?q?=E4=BB=B6=E6=8F=90=E4=BE=9B):=20=E4=B8=BB=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E4=BB=A3=E7=90=86=E6=8F=90=E4=BE=9B=E8=80=85?= =?UTF-8?q?=E5=AD=90=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=B5=84=E6=BA=90=EF=BC=8C?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E4=B8=8D=E5=86=8D=E7=9B=B4=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 SubscriptionRemoteAsset 缓存表(迁移 0054):内容落库,TTL 24h,过期回源按 SHA256 判断变更,回源失败降级用旧缓存 - 新增 GET /API/Subscription/RemoteAsset//(带 ETag/条件请求)与 ANY /API/Subscription/RemoteApi// 实时接口透传(不缓存、不转发 Cookie) - ListMySubscriptions 下发的 RemoteEntry 改写为主服务同源代理 URL;venom_ui API base 随之走 RemoteApi 透传 --- GVSDServiceServer/venom_ui/src/api.ts | 19 ++- gvsds/apps/gerp/apps/subscription/manager.py | 125 +++++++++++++++++- gvsds/apps/gerp/apps/subscription/models.py | 34 +++++ gvsds/apps/gerp/apps/subscription/urls.py | 5 + gvsds/apps/gerp/apps/subscription/views.py | 105 ++++++++++++++- .../0054_subscription_remote_asset.py | 37 ++++++ 6 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 gvsds/apps/gerp/migrations/0054_subscription_remote_asset.py diff --git a/GVSDServiceServer/venom_ui/src/api.ts b/GVSDServiceServer/venom_ui/src/api.ts index fa563079..17a394f4 100644 --- a/GVSDServiceServer/venom_ui/src/api.ts +++ b/GVSDServiceServer/venom_ui/src/api.ts @@ -1,10 +1,25 @@ /** Venom 包管理器 API 客户端 * - * 远端模块由 GVSDServiceServer 自身托管,故用 `import.meta.url` 推断服务地址, - * 无需宿主额外注入(跨源由子服务器的 CORS 中间件放行)。 + * 两种运行形态: + * 1) 主服务缓存代理(生产):模块 URL 形如 + * `https://gerp.gvsds.com/API/Subscription/RemoteAsset//venom/remote-entry.js` + * → 同源接口走主服务的透传端点 `.../RemoteApi//api/venom`, + * 浏览器完全不直连子服务器(无混合内容 / 无 CORS)。 + * 2) 直连子服务器(本地开发 / 未启用代理):由 `import.meta.url` 推断服务地址。 */ const API_BASE = (() => { + const proxied = /\/API\/Subscription\/RemoteAsset\/([^/]+)\//.exec(import.meta.url) + if (proxied) { + try { + return new URL( + `/API/Subscription/RemoteApi/${proxied[1]}/api/venom`, + import.meta.url, + ).toString() + } catch { + /* 落到直连分支 */ + } + } try { return new URL('/api/venom', import.meta.url).toString() } catch { diff --git a/gvsds/apps/gerp/apps/subscription/manager.py b/gvsds/apps/gerp/apps/subscription/manager.py index 00809e86..b261d64a 100644 --- a/gvsds/apps/gerp/apps/subscription/manager.py +++ b/gvsds/apps/gerp/apps/subscription/manager.py @@ -9,9 +9,14 @@ """ import datetime +import hashlib +import logging +import mimetypes import secrets import uuid +from urllib.parse import urlparse +import requests from django.db import transaction from gvsdsdk.auth.crypto import HashPassword, VerifyPassword @@ -25,8 +30,18 @@ from .models import ( SubscriptionGroup, SubscriptionGroupCompany, SubscriptionMember, + SubscriptionRemoteAsset, ) +#: 远端资源缓存有效期(秒)——过期后回源并按 SHA256 判断是否变更 +REMOTE_ASSET_TTL_SECONDS = 24 * 3600 +#: 主服务代理资源的 URL 前缀(缓存) +REMOTE_ASSET_URL_PREFIX = '/API/Subscription/RemoteAsset' +#: 主服务透传提供者实时接口的 URL 前缀(不缓存) +REMOTE_API_URL_PREFIX = '/API/Subscription/RemoteApi' + +logger = logging.getLogger(__name__) + def _ToBytes(value): """UUIDObj / bytes / uuid.UUID / str → BINARY(16) bytes;无法转换返回 None""" @@ -577,6 +592,113 @@ class SubscriptionManager: 'RemoteModule': group.RemoteModule, }, None + # ==================== 远端资源代理与缓存(主服务代子服务器出资源) ==================== + + @staticmethod + def EntryOrigin(RemoteEntry): + """从远端入口 URL 取 origin(scheme://netloc);非绝对 http(s) 返回 None""" + try: + parsed = urlparse(str(RemoteEntry or '').strip()) + except Exception: + return None + if parsed.scheme not in ('http', 'https') or not parsed.netloc: + return None + return f'{parsed.scheme}://{parsed.netloc}' + + @staticmethod + def ProxyEntryURL(group): + """把产品定制页远端入口改写为主服务的缓存代理 URL + + 相对/同源入口(非绝对 http(s))原样返回,无需代理。 + 供应商声明的绝对入口 → `/API/Subscription/RemoteAsset//`, + 浏览器只访问主服务,不再直连提供者子服务器。 + """ + entry = str(getattr(group, 'RemoteEntry', '') or '').strip() + if SubscriptionManager.EntryOrigin(entry) is None: + return entry + asset_path = urlparse(entry).path.lstrip('/') or 'index.js' + return f'{REMOTE_ASSET_URL_PREFIX}/{_ToStr(group.SubscriptionGroupUUID)}/{asset_path}' + + @staticmethod + def ProviderOrigin(GroupUUID): + """产品的提供者子服务器 origin(回源目标) + + Returns: + (origin|None, error|None) + """ + group = SubscriptionGroup.objects.filter(SubscriptionGroupUUID=_ToBytes(GroupUUID)).first() + if group is None: + return None, '订阅组不存在' + origin = SubscriptionManager.EntryOrigin(group.RemoteEntry) + if origin is None: + return None, '该产品未声明有效的子服务器远端入口' + return origin, None + + @staticmethod + def FetchRemoteAsset(GroupUUID, AssetPath): + """取远端资源:缓存内且未过期直接命中;否则回源并按 SHA256 判断是否变更 + + 回源失败且已有缓存时**降级返回旧缓存**(保证页面仍可用,不因提供者抖动白屏)。 + + Returns: + (dict|None, error|None) + dict: {'Content', 'ContentType', 'SHA256', 'Size', 'Hit', 'Changed'} + """ + group_bytes = _ToBytes(GroupUUID) + if group_bytes is None: + return None, '订阅组 UUID 非法' + asset_path = str(AssetPath or '').strip().lstrip('/') + if not asset_path or '..' in asset_path.split('/'): + return None, '非法的资源路径' + + origin, err = SubscriptionManager.ProviderOrigin(GroupUUID) + if err: + return None, err + + cached = SubscriptionRemoteAsset.objects.filter( + SubscriptionGroupUUID=group_bytes, AssetPath=asset_path).first() + now = datetime.datetime.utcnow() + if cached is not None and (now - cached.FetchedAt).total_seconds() < REMOTE_ASSET_TTL_SECONDS: + return SubscriptionManager._AssetPayload(cached, hit=True, changed=False), None + + upstream = f'{origin}/{asset_path}' + try: + resp = requests.get(upstream, timeout=15) + resp.raise_for_status() + content = resp.content + except Exception as exc: + if cached is not None: + logger.warning('远端资源回源失败,降级使用缓存: %s (%s)', upstream, exc) + return SubscriptionManager._AssetPayload(cached, hit=True, changed=False), None + logger.error('远端资源回源失败: %s (%s)', upstream, exc) + return None, f'回源失败:{exc}' + + digest = hashlib.sha256(content).hexdigest() + content_type = (resp.headers.get('Content-Type') or '').split(';')[0].strip() \ + or mimetypes.guess_type(asset_path)[0] or 'application/octet-stream' + changed = cached is None or cached.SHA256 != digest + if cached is None: + cached = SubscriptionRemoteAsset( + SubscriptionGroupUUID=group_bytes, AssetPath=asset_path) + cached.ContentType = content_type + cached.SHA256 = digest + cached.Size = len(content) + cached.Content = content + cached.FetchedAt = now + cached.save() + return SubscriptionManager._AssetPayload(cached, hit=False, changed=changed), None + + @staticmethod + def _AssetPayload(asset, hit, changed): + return { + 'Content': bytes(asset.Content), + 'ContentType': asset.ContentType or 'application/octet-stream', + 'SHA256': asset.SHA256 or '', + 'Size': asset.Size or 0, + 'Hit': hit, + 'Changed': changed, + } + @staticmethod def ListMySubscriptions(UserUUID, CompanyUUID=None): """我的订阅:当前用户作为订阅成员已订阅的产品(含定制页远端入口) @@ -630,7 +752,8 @@ class SubscriptionManager: 'GroupDesc': group.GroupDesc or '', 'GroupStatus': group.GroupStatus, 'Providers': bucket['Providers'], - 'RemoteEntry': group.RemoteEntry or '', + # 改写为主服务缓存代理 URL:浏览器只访问主服务(跳过子服务器直连) + 'RemoteEntry': SubscriptionManager.ProxyEntryURL(group), 'RemoteModule': group.RemoteModule or 'default', 'MyDeptNames': dept_names_by_group.get(gb, []), }) diff --git a/gvsds/apps/gerp/apps/subscription/models.py b/gvsds/apps/gerp/apps/subscription/models.py index c5c85374..e4aab6a5 100644 --- a/gvsds/apps/gerp/apps/subscription/models.py +++ b/gvsds/apps/gerp/apps/subscription/models.py @@ -174,3 +174,37 @@ class SubscriptionMember(QModel): def __str__(self): return f'{self.SubscriptionGroupUUID} → {self.UserRoleDepartmentUUID}' + + +class SubscriptionRemoteAsset(QModel): + """主服务缓存的「提供者子服务器资源文件」 + + 产品的定制页远端入口(SubscriptionGroup.RemoteEntry)位于提供者子服务器; + 为让浏览器只访问主服务(跳过子服务器直连、规避混合内容/CORS),主服务把该入口 + 及其同源相对资源代理到 `/API/Subscription/RemoteAsset//`。 + + 缓存策略:内容落库,TTL 24 小时;过期后回源,按内容 SHA256 判断是否有变更 + (变更则覆盖,未变更仅刷新 FetchedAt)。回源失败时降级使用已缓存内容。 + """ + + SubscriptionRemoteAssetUUID = UUIDField(primary_key=True, verbose_name='远端资源缓存UUID') + SubscriptionGroupUUID = UUIDField( + db_column='SubscriptionGroupUUID', db_index=True, verbose_name='订阅组UUID') + AssetPath = models.CharField(max_length=512, verbose_name='资源路径(相对子服务器根)') + ContentType = models.CharField(max_length=128, default='application/octet-stream', verbose_name='内容类型') + SHA256 = models.CharField(max_length=64, default='', db_index=True, verbose_name='内容SHA256') + Size = models.IntegerField(default=0, verbose_name='字节数') + Content = models.BinaryField(verbose_name='内容') + FetchedAt = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='最近回源时间') + + class Meta: + db_table = 'SubscriptionRemoteAsset' + app_label = 'gerp' + ordering = ['-FetchedAt'] + # 同一产品内,同一资源路径只缓存一份 + unique_together = (('SubscriptionGroupUUID', 'AssetPath'),) + verbose_name = '远端资源缓存' + verbose_name_plural = '远端资源缓存' + + def __str__(self): + return f'{self.SubscriptionGroupUUID} → {self.AssetPath}' diff --git a/gvsds/apps/gerp/apps/subscription/urls.py b/gvsds/apps/gerp/apps/subscription/urls.py index cabd6db1..c4e1e0fe 100644 --- a/gvsds/apps/gerp/apps/subscription/urls.py +++ b/gvsds/apps/gerp/apps/subscription/urls.py @@ -30,4 +30,9 @@ urlpatterns = [ path('Provider/Page/Save', views.SubscriptionProviderPageSaveView.as_view(), name='subscription_provider_page_save'), # 我的订阅(被订阅者视角,仅需登录) path('MySubscriptions', views.SubscriptionMyView.as_view(), name='subscription_my'), + # 远端资源代理:主服务缓存提供者子服务器的资源文件 / 透传其实时接口 + path('RemoteAsset//', + views.SubscriptionRemoteAssetView.as_view(), name='subscription_remote_asset'), + path('RemoteApi//', + views.SubscriptionRemoteApiView.as_view(), name='subscription_remote_api'), ] diff --git a/gvsds/apps/gerp/apps/subscription/views.py b/gvsds/apps/gerp/apps/subscription/views.py index 8294b361..b8d6f036 100644 --- a/gvsds/apps/gerp/apps/subscription/views.py +++ b/gvsds/apps/gerp/apps/subscription/views.py @@ -18,6 +18,8 @@ URL 路径最后一段作为 action 名称: GET /API/Subscription/Provider/Ping - 提供者子服务器鉴权连通性探测(Bearer 令牌) POST /API/Subscription/Provider/Page/Save - 提供者子服务器声明本产品定制页远端入口(Bearer 令牌) GET /API/Subscription/MySubscriptions - 我的订阅(被订阅者视角,仅需登录) + GET /API/Subscription/RemoteAsset// - 主服务代理并缓存提供者子服务器资源(24h / SHA256) + * /API/Subscription/RemoteApi// - 主服务透传提供者子服务器实时接口(不缓存) 权限:管理端 = 管理员(admin:super / admin:tenant / admin:company); Provider/* = 提供者子服务器(Bearer 订阅访问令牌,见 permissions.SubscriptionProviderAuthentication); @@ -31,8 +33,11 @@ URL 路径最后一段作为 action 名称: import logging +import requests +from django.http import HttpResponse from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from rest_framework.views import APIView from gvsdsdk.auth.permissions import IsAdminUser from gvsdsdk.model_utils import UUIDObj @@ -40,7 +45,7 @@ from gvsdsdk.model_utils import UUIDObj from gvsds.apps.gerp.app_manager import AppManager from gvsds.apps.gerp.views import ELTAuthenticatedAPIView -from .manager import SubscriptionManager +from .manager import REMOTE_ASSET_TTL_SECONDS, SubscriptionManager from .permissions import IsSubscriptionProvider, SubscriptionProviderAuthentication logger = logging.getLogger(__name__) @@ -415,3 +420,101 @@ class SubscriptionMyView(ELTAuthenticatedAPIView): CompanyUUID=getattr(request, 'current_company_uuid', None), ) return Response({'code': 0, 'data': {'list': rows, 'total': len(rows)}}) + + +# ==================== 远端资源代理(主服务代子服务器出资源) ==================== + +class SubscriptionRemoteAssetView(APIView): + """主服务代理 + 缓存提供者子服务器的资源文件 + + GET /API/Subscription/RemoteAsset// + + - 缓存:内容落库,TTL 24 小时;过期回源并按内容 SHA256 判断是否变更 + - 响应带 ETag(SHA256):浏览器条件请求命中即 304,不回源 + - 免登录:与子服务器原本的公开资源一致(页面渲染前浏览器无法携带业务鉴权) + """ + + authentication_classes = [] + permission_classes = [AllowAny] + + def get(self, request, group_uuid, asset_path): + payload, err = SubscriptionManager.FetchRemoteAsset(group_uuid, asset_path) + if err: + logger.warning('远端资源不可用: %s / %s (%s)', group_uuid, asset_path, err) + return HttpResponse( + err, status=502, content_type='text/plain; charset=utf-8') + + etag = f'"{payload["SHA256"]}"' + if payload['SHA256'] and request.headers.get('If-None-Match') == etag: + response = HttpResponse(status=304) + response['ETag'] = etag + return response + + response = HttpResponse(payload['Content'], content_type=payload['ContentType']) + response['ETag'] = etag + # 主服务侧缓存 24h(回源频率上限);浏览器侧 1h + ETag 条件请求, + # 便于提供者更新后 1 小时内可见,又不会频繁打到子服务器。 + response['Cache-Control'] = 'public, max-age=3600' + response['X-Remote-Cache'] = 'HIT' if payload['Hit'] else ('CHANGED' if payload['Changed'] else 'FRESH') + return response + + +class SubscriptionRemoteApiView(APIView): + """主服务透传提供者子服务器的实时接口(**不缓存**) + + ANY /API/Subscription/RemoteApi// + + 让定制页在「同源」下调用提供者接口:浏览器不再直连子服务器(无混合内容/CORS)。 + 仅回源到该产品声明的入口 origin(防 SSRF),且不转发浏览器 Cookie(防凭据外泄)。 + """ + + authentication_classes = [] + permission_classes = [AllowAny] + + def _Proxy(self, request, group_uuid, path): + origin, err = SubscriptionManager.ProviderOrigin(group_uuid) + if err: + return HttpResponse(err, status=502, content_type='text/plain; charset=utf-8') + asset_path = str(path or '').strip().lstrip('/') + if not asset_path or '..' in asset_path.split('/'): + return HttpResponse('非法的资源路径', status=400, content_type='text/plain; charset=utf-8') + + url = f'{origin}/{asset_path}' + if request.META.get('QUERY_STRING'): + url = f'{url}?{request.META["QUERY_STRING"]}' + headers = {} + if request.content_type: + headers['Content-Type'] = request.content_type + accept = request.headers.get('Accept') + if accept: + headers['Accept'] = accept + try: + upstream = requests.request( + request.method, url, data=request.body or None, headers=headers, timeout=20) + except Exception as exc: + logger.error('远端接口转发失败: %s (%s)', url, exc) + return HttpResponse( + f'转发失败:{exc}', status=502, content_type='text/plain; charset=utf-8') + + response = HttpResponse( + upstream.content, + status=upstream.status_code, + content_type=upstream.headers.get('Content-Type') or 'application/json; charset=utf-8', + ) + response['Cache-Control'] = 'no-store' + return response + + def get(self, request, group_uuid, path): + return self._Proxy(request, group_uuid, path) + + def post(self, request, group_uuid, path): + return self._Proxy(request, group_uuid, path) + + def put(self, request, group_uuid, path): + return self._Proxy(request, group_uuid, path) + + def patch(self, request, group_uuid, path): + return self._Proxy(request, group_uuid, path) + + def delete(self, request, group_uuid, path): + return self._Proxy(request, group_uuid, path) diff --git a/gvsds/apps/gerp/migrations/0054_subscription_remote_asset.py b/gvsds/apps/gerp/migrations/0054_subscription_remote_asset.py new file mode 100644 index 00000000..e6956fca --- /dev/null +++ b/gvsds/apps/gerp/migrations/0054_subscription_remote_asset.py @@ -0,0 +1,37 @@ +"""订阅式软件提供(subscription):主服务缓存的提供者子服务器资源文件 +""" +import datetime + +from django.db import migrations, models + +import gvsds.apps.core.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0053_subscription_group_remote_page'), + ] + + operations = [ + migrations.CreateModel( + name='SubscriptionRemoteAsset', + fields=[ + ('SubscriptionRemoteAssetUUID', gvsds.apps.core.models.UUIDField(primary_key=True, serialize=False, verbose_name='远端资源缓存UUID')), + ('SubscriptionGroupUUID', gvsds.apps.core.models.UUIDField(db_column='SubscriptionGroupUUID', db_index=True, verbose_name='订阅组UUID')), + ('AssetPath', models.CharField(max_length=512, verbose_name='资源路径(相对子服务器根)')), + ('ContentType', models.CharField(default='application/octet-stream', max_length=128, verbose_name='内容类型')), + ('SHA256', models.CharField(db_index=True, default='', max_length=64, verbose_name='内容SHA256')), + ('Size', models.IntegerField(default=0, verbose_name='字节数')), + ('Content', models.BinaryField(verbose_name='内容')), + ('FetchedAt', models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='最近回源时间')), + ], + options={ + 'db_table': 'SubscriptionRemoteAsset', + 'ordering': ['-FetchedAt'], + 'unique_together': {('SubscriptionGroupUUID', 'AssetPath')}, + 'verbose_name': '远端资源缓存', + 'verbose_name_plural': '远端资源缓存', + }, + ), + ]