Files
TermiNexus 51d6e220ee perf(core): 构建产物静态资源改 72h 强缓存
- _serve_file 识别 Vite 内容 hash 文件名(-<hash>.<ext>,hash 段须含大写或数字)

- hash 产物: public, max-age=259200, immutable(72h,路径随版本变化不影响新版本生效)

- 非 hash 的传统资源保持 1h/24h,index.html 仍 no-cache
2026-09-29 11:32:35 +08:00

333 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import re
import mimetypes
from pathlib import Path
from django.http import HttpResponse, Http404, JsonResponse, HttpResponseRedirect
from django.views import View
from django.conf import settings
from gvsds.template_engine import render_template
TEMPLATE_DIR = Path(settings.BASE_DIR) / "TempLate"
#: 构建产物(Vite 输出 <name>-<hash>.<ext>)的强缓存时长:72 小时
#: 依据:文件名内含内容 hash,内容与名字一一对应;新版本会换新文件名,
#: 因此不会出现「改了代码但客户端还是旧文件」的问题(index.html 仍为 no-cache)。
HASHED_ASSET_MAX_AGE = 60 * 60 * 72
#: 识别构建产物名:-<8 位以上 hash>.<ext>,且 hash 段须含大写字母或数字
#: (避免误伤 gvs-design-system.css 这类全小写连字符的传统文件名)
HASHED_ASSET_RE = re.compile(r'-(?=[A-Za-z0-9_-]*[A-Z0-9])[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$')
MIME_TYPES = {
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject',
'.json': 'application/json',
'.xml': 'application/xml',
'.html': 'text/html',
'.htm': 'text/html',
'.txt': 'text/plain',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.mp4': 'video/mp4',
'.webp': 'image/webp',
'.map': 'application/json',
}
SUBDOMAIN_CONFIG = {
'www': {'dir': '', 'root_is_index': True},
'gerp': {'dir': 'GERP', 'root_is_index': True},
'ugm': {'dir': '', 'root_is_index': True},
'api': {'dir': None, 'root_is_index': False},
'mc': {'dir': 'MC', 'root_is_index': True},
'3crb': {'dir': 'CCCRB', 'root_is_index': True},
'ccgo': {'dir': 'CCGO', 'root_is_index': True},
'invite': {'dir': '', 'root_is_index': True},
'doc': {'dir': '', 'root_is_index': True},
'discuss': {'dir': '', 'root_is_index': True},
'community': {'dir': '', 'root_is_index': True},
'comm': {'dir': '', 'root_is_index': True},
't4': {'dir': None, 'root_is_index': False},
'verify': {'dir': 'Verify', 'root_is_index': True},
'verifox': {'dir': 'Verifox', 'root_is_index': True},
'nav': {'dir': 'nav', 'root_is_index': True},
'history': {'dir': 'history', 'root_is_index': True},
'cdn': {'dir': 'cdn', 'root_is_index': False, 'root_text': 'GVSDS CDN'},
'yc8z': {'dir': 'yc8z', 'root_is_index': True},
'omc': {'dir': 'OMC', 'root_is_index': True},
'sve': {'dir': 'SVE', 'root_is_index': True},
'tailwind': {'dir': 'tailwindcss', 'root_is_index': False, 'root_redirect': '/3.4.17'},
'nanjingmassacre': {'dir': 'NanjingMassacre', 'root_is_index': True},
'3c': {'dir': 'CreateECentralCourt', 'root_is_index': True},
'vu': {'dir': None, 'root_is_index': False},
'links': {'dir': None, 'root_is_index': False},
'unclear': {'dir': 'Unclear', 'root_is_index': True},
'old2': {'dir': 'Olds/2', 'root_is_index': True, 'base': 'Olds/2/_base'},
'old3': {'dir': 'Olds/3', 'root_is_index': True, 'base': 'Olds/3/_base'},
# Viper 语言官网(Vue 产物在 TempLate/Viper;venom 由 Main.py STATIC_SITES 直接静态托管,
# 此处登记仅为 dev server / 兜底一致)
'viper': {'dir': 'Viper', 'root_is_index': True},
'venom': {'dir': 'Venom', 'root_is_index': True},
}
def _get_subdomain(request):
return getattr(request, 'subdomain', 'www') or 'www'
def _get_subdomain_dir(subdomain):
config = SUBDOMAIN_CONFIG.get(subdomain, {})
return config.get('dir', '')
#: 专属目录子域未命中时,允许回退到站点根目录的路径前缀
#: (旧站复用根 /assets;GERP legacy 页面复用根 /pages)
ROOT_FALLBACK_PREFIXES = ('assets/', 'pages/')
def _resolve_file(subdomain, file_path):
"""解析子域请求对应的文件
专属目录子域(如 gerp → TempLate/GERP):
1. 先在自己的目录内查找,命中即返回;
2. 目录存在但未命中时,只允许 ROOT_FALLBACK_PREFIXES 前缀继续回退到站点根目录,
其余路径(尤其 index.html 等 HTML 入口)一律不回退——否则子域目录被清空的
瞬间(如旧的「先删后建」部署空窗期)就会命中根目录的 index.html,
把 gerp.gvsds.com 串台成 www(GVSite)首页;
3. 专属目录本身不存在(如 SUBDOMAIN_CONFIG 登记了但尚未落盘的 nav/history)
则沿用旧行为回退根目录,避免误伤这些别名子域。
"""
if '..' in file_path.split('/') or '..' in file_path.split(os.sep):
return None
subdomain_dir = _get_subdomain_dir(subdomain)
if subdomain_dir is None:
return None
if subdomain_dir:
site_dir = TEMPLATE_DIR / subdomain_dir
if site_dir.is_dir():
candidate = site_dir / file_path
try:
candidate.resolve().relative_to(TEMPLATE_DIR.resolve())
except ValueError:
return None
if candidate.is_file():
return candidate
# 目录已落盘:只放行共享前缀,避免子域 HTML 入口串到 www 站
if not file_path.lower().startswith(ROOT_FALLBACK_PREFIXES):
return None
candidate = TEMPLATE_DIR / file_path
try:
candidate.resolve().relative_to(TEMPLATE_DIR.resolve())
except ValueError:
return None
if candidate.is_file():
return candidate
return None
def _serve_file(full_path, subdomain=None):
ext = full_path.suffix.lower()
content_type = MIME_TYPES.get(ext, mimetypes.guess_type(str(full_path))[0] or 'application/octet-stream')
if ext in ('.html', '.htm'):
rel_path = full_path.relative_to(TEMPLATE_DIR)
template_name = str(rel_path).replace('\\', '/')
base = None
use_base = False
if subdomain:
config = SUBDOMAIN_CONFIG.get(subdomain, {})
base = config.get('base')
use_base = config.get('use_base', False)
if base:
response = render_template(request=None, template_name=template_name, UseBase='false', SubBase=base)
elif not use_base:
response = render_template(request=None, template_name=template_name, UseBase='false')
else:
response = render_template(request=None, template_name=template_name, UseBase='true')
# HTML 永不缓存:Vue SPA 的 index.html 引用了带 hash 的资源名,
# 若被浏览器/代理缓存,会引用已删除的旧资源导致 404、页面加载不全
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
return response
with open(full_path, 'rb') as f:
response = HttpResponse(f.read(), content_type=content_type)
if HASHED_ASSET_RE.search(full_path.name):
# Vite 构建产物(如 index-C_-1ws_N.js / style-BWB-O5Ko.css):
# 文件名含内容 hash,路径随版本变化,可长期强缓存 + immutable 免协商
response['Cache-Control'] = f'public, max-age={HASHED_ASSET_MAX_AGE}, immutable'
elif ext in ('.css', '.js', '.mjs', '.json', '.xml', '.txt', '.svg'):
# 非 hash 命名的传统静态资源(legacy 站点),改动后需较快生效
response['Cache-Control'] = 'public, max-age=3600'
elif ext in ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.mp4'):
response['Cache-Control'] = 'public, max-age=86400'
return response
# ============================================================================
# doc 子域 JSON API(页面由 GVSite SPA 渲染)
# /API/GetDoc/<uuid> → 文章 JSON
# /API/GetSerieDirectory/<uuid> → 系列目录 / 单文档标记 JSON
# ============================================================================
def _doc_api(file_path):
"""doc 子域 JSON API:API/GetDoc/<uuid> 与 API/GetSerieDirectory/<uuid>。"""
from gvsds.apps.doc.manager import DocManager
from gvsdsdk.model_utils import UUIDObj
parts = file_path.split('/')
if len(parts) < 3 or parts[0] != 'API':
return None
action = parts[1]
uuid_text = parts[2].strip('/')
try:
uuid_bytes = UUIDObj(uuid_text).bytes
except (ValueError, TypeError, AttributeError):
return JsonResponse({'error': 'invalid_uuid'}, status=404)
if action == 'GetDoc':
doc = DocManager.GetDocument(uuid_bytes)
if doc is None:
return JsonResponse({'error': 'not_found'}, status=404)
content = doc.DocContent or ''
doc_format = doc.DocFormat
# 数据兜底:内容实为完整 HTML 文档时按 html 渲染(marked 会把内嵌 </style> 拆坏,污染站点样式)
if doc_format == 'markdown' and content.lstrip().lower().startswith(('<!doctype', '<html')):
doc_format = 'html'
return JsonResponse({
'DocTitle': doc.DocTitle or '未命名文档',
'DocContent': content,
'DocFormat': doc_format,
'DocPermission': 1,
'DocDecrypt': True,
})
if action == 'GetSerieDirectory':
serie = DocManager.GetSerie(uuid_bytes)
if serie is not None:
docs = DocManager.ListDocumentsBySerie(uuid_bytes)
chapters = [
{
'id': UUIDObj(d.DocUUID).str.replace('-', ''),
'title': d.DocTitle or '未命名文档',
'sections': [],
}
for d in docs
]
return JsonResponse({
'title': serie.DocSerieName or '未命名系列',
'document': False,
'chapters': chapters,
})
# 非系列 UUID:单文档模式,返回所属系列的全部文章供侧边栏展示
doc = DocManager.GetDocument(uuid_bytes)
if doc is None:
return JsonResponse({'error': 'not_found'}, status=404)
chapters = []
if doc.DocSerieUUID:
serie_docs = DocManager.ListDocumentsBySerie(doc.DocSerieUUID)
chapters = [
{
'id': UUIDObj(d.DocUUID).str.replace('-', ''),
'title': d.DocTitle or '未命名文档',
'sections': [],
}
for d in serie_docs
]
return JsonResponse({
'title': doc.DocTitle or '未命名文档',
'document': True,
'id': UUIDObj(doc.DocUUID).str.replace('-', ''),
'chapters': chapters,
})
return None
class SubdomainView(View):
def get(self, request, file_path=''):
subdomain = _get_subdomain(request)
config = SUBDOMAIN_CONFIG.get(subdomain, {})
if config.get('dir') is None:
raise Http404
# doc 子域:动态阅读 JSON API(页面由 GVSite SPA 渲染,走下方通用静态/SPA fallback)
# doc.gvsds.com/API/GetDoc/<uuid> → 文章 JSON
# doc.gvsds.com/API/GetSerieDirectory/<uuid> → 目录 JSON
if subdomain == 'doc' and file_path and file_path.startswith('API/'):
api_resp = _doc_api(file_path)
if api_resp:
return api_resp
# ugm 子域:未登录则跳转登录页,已登录则跳转 gerp
if subdomain == 'ugm':
# JWT Cookie 认证:检查 AccessToken 或 RefreshToken
has_jwt = request.COOKIES.get('AccessToken') or request.COOKIES.get('RefreshToken')
if not has_jwt:
# 未登录 → 跳转 www 登录页
if file_path and file_path not in ('/', 'login.html', 'register.html'):
login_url = f"https://www.gvsds.com/login?redirect=https://gerp.gvsds.com/"
return HttpResponseRedirect(login_url)
if not file_path or file_path == '/':
return HttpResponseRedirect('https://www.gvsds.com/login')
else:
# 已登录 → 跳转 gerp
if not file_path or file_path == '/' or file_path == 'index.html':
return HttpResponseRedirect('https://gerp.gvsds.com/')
if not file_path or file_path == '/':
if config.get('root_redirect'):
return HttpResponseRedirect(config['root_redirect'])
if config.get('root_text') is not None:
return HttpResponse(config['root_text'])
if config.get('root_is_index'):
file_path = 'index.html'
else:
raise Http404
if file_path.endswith('/'):
file_path += 'index.html'
if subdomain == 'tailwind' and not file_path.endswith('.js') and not file_path.endswith('.json'):
js_path = _resolve_file(subdomain, file_path + '.js')
if js_path:
return _serve_file(js_path, subdomain)
resolved = _resolve_file(subdomain, file_path)
if resolved:
return _serve_file(resolved, subdomain)
# www/invite 子域:旧 .html 页面已被 Vue SPA 取代,去掉后缀重定向到 Vue 路由
# 兼容历史链接(如 /login.html、/gallery.html 等被外部页面/脚本引用的旧地址)
# 目录请求(/xxx/ 拼成的 /xxx/index.html)不走旧 .html 链接重定向,交给下方 SPA fallback
if subdomain in ('www', 'invite') and file_path.endswith('.html') and not file_path.endswith('/index.html'):
new_path = file_path[:-5] or '/'
redirect_url = new_path
if request.META.get('QUERY_STRING'):
redirect_url += '?' + request.META['QUERY_STRING']
return HttpResponseRedirect(redirect_url)
# SPA fallback:前端单页应用(如 GERP Vue)的客户端路由路径(无扩展名)
# 找不到对应文件时,回退到 index.html,由前端路由处理
# 目录请求(/xxx/ 被拼成 /xxx/index.html)同样回退 SPA,避免误入 Django 404
if config.get('root_is_index') and (
'.' not in os.path.basename(file_path) or file_path.endswith('/index.html')
):
index_resolved = _resolve_file(subdomain, 'index.html')
if index_resolved:
return _serve_file(index_resolved, subdomain)
raise Http404