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:
1 parent
8beb280aad
commit
eecb4f66e9
6 files changed
+321
-4
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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': '远端资源缓存',
|
||||
},
|
||||
),
|
||||
]
|
||||
Reference in new issue
Block a user