fix(订阅式软件提供): 远端资源改 no-cache 条件校验,且 ETag 按 RFC7232 弱比较

- 浏览器侧 max-age=24h 会把旧产物挡住一天,提供者重建后被浏览器旧副本劫持;改为 no-cache + ETag:每次校验、未变 304,回源仍受主服务 24h TTL 限制

- 实测响应 ETag 被网关降为弱校验(W/ 前缀),严格相等匹配拿不到 304;改为归一化(去 W/ 与引号)后按弱比较命中,实测 304
This commit is contained in:
TermiNexus committed 2026-10-07 17:59:59 +08:00
1 parent f812413de5
commit 6165df5d78
1 file changed
+29 -6
+29 -6
View File
@@ -45,11 +45,34 @@ from gvsdsdk.model_utils import UUIDObj
from gvsds.apps.gerp.app_manager import AppManager
from gvsds.apps.gerp.views import ELTAuthenticatedAPIView
from .manager import REMOTE_ASSET_TTL_SECONDS, SubscriptionManager
from .manager import SubscriptionManager
from .permissions import IsSubscriptionProvider, SubscriptionProviderAuthentication
logger = logging.getLogger(__name__)
#: 远端资源下发给浏览器的缓存策略
# 「可存但每次校验」= 浏览器每次都带 If-None-Match 回主服务,内容未变即 304:
# - 提供者重建后立即生效(不会像 max-age=24h 那样被浏览器旧副本挡住)
# - 回源(打子服务器)仍受主服务 24h TTL 限制,负载不增加
_REMOTE_ASSET_CACHE_CONTROL = 'no-cache'
def _NormalizeEtag(value):
"""归一化 ETag:去掉弱校验前缀 W/ 与两侧引号(RFC 7232 弱比较口径)"""
return (value or '').strip().removeprefix('W/').strip().strip('"')
def _EtagMatches(HeaderValue, Etag):
"""If-None-Match 是否命中给定 ETag
RFC 7232:If-None-Match 用**弱比较**(忽略 W/ 前缀与引号);上游/网关可能把
我们的强 ETag 改成弱 ETag(实测响应是 W/"<sha256>"),故必须归一化后比较。
"""
if not HeaderValue:
return False
target = _NormalizeEtag(Etag)
return any(_NormalizeEtag(item) == target for item in HeaderValue.split(','))
def _ToBytes(value):
if value is None:
@@ -430,8 +453,8 @@ class SubscriptionRemoteAssetView(APIView):
GET /API/Subscription/RemoteAsset/<SubscriptionGroupUUID>/<AssetPath>
- 缓存:内容落库,TTL 24 小时;过期回源并按内容 SHA256 判断是否变更
- 浏览器侧同为 24h(`Cache-Control: public, max-age=86400`),并带 ETag(SHA256):
过期/硬刷新后走条件请求,内容未变即 304,不回源
- 下发策略 `no-cache` + ETag(SHA256):浏览器每次校验、未变即 304(不回源),
因此提供者重建后立即生效,而回源频率上限仍是 24h
- 免登录:与子服务器原本的公开资源一致(页面渲染前浏览器无法携带业务鉴权)
"""
@@ -446,15 +469,15 @@ class SubscriptionRemoteAssetView(APIView):
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:
if payload['SHA256'] and _EtagMatches(request.headers.get('If-None-Match'), etag):
response = HttpResponse(status=304)
response['ETag'] = etag
response['Cache-Control'] = _REMOTE_ASSET_CACHE_CONTROL
return response
response = HttpResponse(payload['Content'], content_type=payload['ContentType'])
response['ETag'] = etag
# 主服务侧与浏览器侧同为 24h(ETag 保留:硬刷新/过期后仍走条件请求,命中即 304)
response['Cache-Control'] = f'public, max-age={REMOTE_ASSET_TTL_SECONDS}'
response['Cache-Control'] = _REMOTE_ASSET_CACHE_CONTROL
response['X-Remote-Cache'] = 'HIT' if payload['Hit'] else ('CHANGED' if payload['Changed'] else 'FRESH')
return response