feat(订阅式软件提供): 主服务缓存代理提供者子服务器资源,浏览器不再直连

- 新增 SubscriptionRemoteAsset 缓存表(迁移 0054):内容落库,TTL 24h,过期回源按 SHA256 判断变更,回源失败降级用旧缓存

- 新增 GET /API/Subscription/RemoteAsset/<GroupUUID>/<Path>(带 ETag/条件请求)与 ANY /API/Subscription/RemoteApi/<GroupUUID>/<Path> 实时接口透传(不缓存、不转发 Cookie)

- ListMySubscriptions 下发的 RemoteEntry 改写为主服务同源代理 URL;venom_ui API base 随之走 RemoteApi 透传
This commit is contained in:
TermiNexus committed 2026-10-07 17:22:48 +08:00
1 parent 8beb280aad
commit eecb4f66e9
6 files changed
+321 -4

No files matched your search

+17 -2
View File
@@ -1,10 +1,25 @@
/** Venom 包管理器 API 客户端
*
* 远端模块由 GVSDServiceServer 自身托管,故用 `import.meta.url` 推断服务地址,
* 无需宿主额外注入(跨源由子服务器的 CORS 中间件放行)。
* 两种运行形态:
* 1) 主服务缓存代理(生产):模块 URL 形如
* `https://gerp.gvsds.com/API/Subscription/RemoteAsset/<GroupUUID>/venom/remote-entry.js`
* → 同源接口走主服务的透传端点 `.../RemoteApi/<GroupUUID>/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 {
+124 -1
View File
@@ -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/<GroupUUID>/<EntryPath>`,
浏览器只访问主服务,不再直连提供者子服务器。
"""
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, []),
})
@@ -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/<GroupUUID>/<Path>`。
缓存策略:内容落库,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}'
@@ -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/<str:group_uuid>/<path:asset_path>',
views.SubscriptionRemoteAssetView.as_view(), name='subscription_remote_asset'),
path('RemoteApi/<str:group_uuid>/<path:path>',
views.SubscriptionRemoteApiView.as_view(), name='subscription_remote_api'),
]
+104 -1
View File
@@ -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/<GroupUUID>/<Path> - 主服务代理并缓存提供者子服务器资源(24h / SHA256)
* /API/Subscription/RemoteApi/<GroupUUID>/<Path> - 主服务透传提供者子服务器实时接口(不缓存)
权限:管理端 = 管理员(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/<SubscriptionGroupUUID>/<AssetPath>
- 缓存:内容落库,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/<SubscriptionGroupUUID>/<Path>
让定制页在「同源」下调用提供者接口:浏览器不再直连子服务器(无混合内容/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)
@@ -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': '远端资源缓存',
},
),
]