Skip to content

Commit 4681167

Browse files
authored
Merge pull request #25 from zlxlabs/fix/static-asset-versioning
修复静态资源缓存顽疾:URL 加内容哈希版本指纹 + no-cache 响应头
2 parents 37df4e8 + b380762 commit 4681167

6 files changed

Lines changed: 218 additions & 6 deletions

File tree

‎src/video_transcript_api/api/app.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@
3232
from .services.transcription import process_llm_queue, process_task_queue
3333

3434

35+
class NoCacheStaticFiles(StaticFiles):
36+
"""StaticFiles 子类:所有 /static/ 响应统一附加 ``Cache-Control: no-cache``。
37+
38+
no-cache 语义为每次回源验证(ETag 命中仍返回 304、节省带宽),不是
39+
no-store。配合模板里的 ``?v={{ asset_v }}`` 内容指纹:版本令牌穿透
40+
中间代理/浏览器的旧缓存,no-cache 保证 HTML 与指纹未覆盖的资源也
41+
每次都回源验证,根治"部署后用户拿不到新静态文件"的缓存顽疾。
42+
"""
43+
44+
async def get_response(self, path: str, scope):
45+
response = await super().get_response(path, scope)
46+
response.headers["Cache-Control"] = "no-cache"
47+
return response
48+
49+
3550
def _repair_all_task_snapshots(audit_logger, cache_manager) -> int:
3651
"""Backfill all legacy terminal rows through bounded 500-row operations."""
3752
total = 0
@@ -298,7 +313,7 @@ async def lifespan(app: FastAPI):
298313

299314
static_dir = get_static_dir()
300315
if static_dir.exists():
301-
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
316+
app.mount("/static", NoCacheStaticFiles(directory=str(static_dir)), name="static")
302317

303318
# 调试中间件:记录 /view/ 请求的详细信息,用于排查外部 AI 工具的访问问题
304319
@app.middleware("http")

‎src/video_transcript_api/api/context.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import concurrent.futures
1010
import contextvars
1111
import datetime
12+
import hashlib
1213
import os
1314
import queue
1415
import re
@@ -1891,8 +1892,32 @@ def get_template_dir() -> Path:
18911892
return Path(__file__).resolve().parents[2] / "web" / "templates"
18921893

18931894

1895+
def compute_asset_version(static_dir: Path) -> str:
1896+
"""计算静态资源版本令牌:对目录下全部文件的相对路径与内容做合并
1897+
sha256,取前 8 位十六进制。
1898+
1899+
文件内容不变则令牌不变,不破坏既有缓存收益;任何文件增删或内容变化
1900+
都会改变令牌,使模板里的 ``?v={{ asset_v }}`` URL 随之变化,穿透
1901+
中间代理/浏览器持有的旧缓存。
1902+
"""
1903+
hasher = hashlib.sha256()
1904+
if static_dir.exists():
1905+
for path in sorted(static_dir.rglob("*")):
1906+
if not path.is_file():
1907+
continue
1908+
hasher.update(str(path.relative_to(static_dir)).encode("utf-8"))
1909+
hasher.update(b"\0")
1910+
hasher.update(path.read_bytes())
1911+
hasher.update(b"\0")
1912+
return hasher.hexdigest()[:8]
1913+
1914+
18941915
def get_templates() -> Jinja2Templates:
1895-
return Jinja2Templates(directory=str(get_template_dir()))
1916+
templates = Jinja2Templates(directory=str(get_template_dir()))
1917+
# 启动时(模块加载、进程内一次)计算资产版本令牌并注入模板全局上下文,
1918+
# 模板以 ``?v={{ asset_v }}`` 引用静态资源实现缓存指纹。
1919+
templates.env.globals["asset_v"] = compute_asset_version(get_static_dir())
1920+
return templates
18961921

18971922

18981923
def get_static_dir() -> Path:

‎src/web/templates/base.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1044,7 +1044,7 @@
10441044
</button>
10451045
<nav class="site-nav">
10461046
<a href="/add_task_by_web" class="site-nav-link">📝 提交任务</a>
1047-
<a href="/static/history.html" class="site-nav-link">📋 任务历史</a>
1047+
<a href="/static/history.html?v={{ asset_v }}" class="site-nav-link">📋 任务历史</a>
10481048
</nav>
10491049
<h1>{{ title or "视频转录查看器" }}</h1>
10501050
{% if author or url %}

‎src/web/templates/transcript.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
{% block og_description %}{{ title or "视频转录" }} 的转录校对结果{% endblock %}
77

88
{% block extra_css %}
9-
<link rel="stylesheet" href="/static/css/floating-toc.css">
9+
<link rel="stylesheet" href="/static/css/floating-toc.css?v={{ asset_v }}">
1010
<style>
1111
/* Section header with inline action */
1212
.section-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
@@ -274,7 +274,7 @@ <h3>重新校对</h3>
274274
{% endblock %}
275275

276276
{% block extra_js %}
277-
<script src="/static/js/floating-toc.js"></script>
277+
<script src="/static/js/floating-toc.js?v={{ asset_v }}"></script>
278278
{% if view_token %}
279279
<script>
280280
(function() {
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Unit tests for static asset cache-busting.
2+
3+
Two mechanisms work together:
4+
- a content-hash version token (``asset_v``, sha256 of every file under
5+
``src/web/static`` truncated to 8 hex chars) injected into the Jinja
6+
template globals, so rendered pages reference ``/static/...?v=<token>``
7+
and any static file change busts intermediary/browser caches;
8+
- ``Cache-Control: no-cache`` on every ``/static/`` response, so caches
9+
always revalidate (ETag still yields 304 and saves bandwidth -- this is
10+
revalidation, not no-store).
11+
12+
All console output must be pure English (no emoji, no Chinese), per
13+
project testing conventions.
14+
"""
15+
16+
from pathlib import Path
17+
18+
import pytest
19+
from fastapi.testclient import TestClient
20+
21+
from video_transcript_api.api.context import (
22+
compute_asset_version,
23+
get_static_dir,
24+
get_templates,
25+
)
26+
27+
28+
def _write(path: Path, content: str) -> None:
29+
path.parent.mkdir(parents=True, exist_ok=True)
30+
path.write_text(content, encoding="utf-8")
31+
32+
33+
def _minimal_llm_config() -> dict:
34+
"""Smallest llm section satisfying LLMConfig.from_dict's hard keys;
35+
base_url points at a closed local port so nothing reaches the network."""
36+
return {
37+
"api_key": "test-llm-key",
38+
"base_url": "http://127.0.0.1:1/v1",
39+
"calibrate_model": "test-calibrate-model",
40+
"summary_model": "test-summary-model",
41+
}
42+
43+
44+
def _minimal_config(tmp_path: Path) -> dict:
45+
return {
46+
"api": {"host": "127.0.0.1", "port": 8000, "auth_token": "test-token"},
47+
"concurrent": {"max_workers": 1, "queue_size": 2, "llm_max_workers": 1},
48+
"storage": {
49+
"cache_dir": str(tmp_path / "cache"),
50+
"workspace_dir": str(tmp_path / "workspace"),
51+
"temp_dir": str(tmp_path / "temp"),
52+
"audit_db": str(tmp_path / "audit.db"),
53+
},
54+
"web": {"base_url": "http://localhost:8000"},
55+
"llm": _minimal_llm_config(),
56+
"log": {"file": str(tmp_path / "app.log")},
57+
}
58+
59+
60+
def _render_transcript(**overrides) -> str:
61+
"""Render transcript.html through the app's own Jinja2Templates so the
62+
asset_v global injected by get_templates() is in scope."""
63+
ctx = {
64+
"title": "Sample Video Title",
65+
"author": "Sample Author",
66+
"url": "https://example.com/video/123",
67+
"created_at_display": "2026-07-11 10:00",
68+
"platform": "youtube",
69+
"summary_html": "<p>Summary body.</p>",
70+
"calibrated_html": "<p>Calibrated body.</p>",
71+
"use_speaker_recognition": False,
72+
"view_token": "test-view-token-123",
73+
"stats": {
74+
"original_length": 100,
75+
"calibrated_length": 90,
76+
"summary_length": 50,
77+
},
78+
"llm_config": None,
79+
}
80+
ctx.update(overrides)
81+
return get_templates().env.get_template("transcript.html").render(**ctx)
82+
83+
84+
class TestComputeAssetVersion:
85+
def test_token_is_short_lowercase_hex(self, tmp_path):
86+
_write(tmp_path / "js" / "app.js", "console.log(1)")
87+
token = compute_asset_version(tmp_path)
88+
assert len(token) == 8
89+
assert all(c in "0123456789abcdef" for c in token)
90+
91+
def test_token_stable_when_content_unchanged(self, tmp_path):
92+
"""Same files -> same token, so unchanged assets keep their cache."""
93+
_write(tmp_path / "js" / "app.js", "alpha")
94+
_write(tmp_path / "css" / "site.css", "body {}")
95+
assert compute_asset_version(tmp_path) == compute_asset_version(tmp_path)
96+
97+
def test_token_changes_when_file_content_changes(self, tmp_path):
98+
_write(tmp_path / "js" / "app.js", "alpha")
99+
before = compute_asset_version(tmp_path)
100+
_write(tmp_path / "js" / "app.js", "alpha-modified")
101+
assert compute_asset_version(tmp_path) != before
102+
103+
def test_token_changes_when_file_added(self, tmp_path):
104+
_write(tmp_path / "js" / "app.js", "alpha")
105+
before = compute_asset_version(tmp_path)
106+
_write(tmp_path / "js" / "extra.js", "beta")
107+
assert compute_asset_version(tmp_path) != before
108+
109+
def test_missing_directory_yields_stable_hex_token(self, tmp_path):
110+
token = compute_asset_version(tmp_path / "does-not-exist")
111+
assert len(token) == 8
112+
assert token == compute_asset_version(tmp_path / "does-not-exist")
113+
114+
115+
class TestTemplateAssetVersioning:
116+
def test_templates_env_exposes_asset_v_global(self):
117+
token = get_templates().env.globals.get("asset_v")
118+
assert isinstance(token, str)
119+
assert token == compute_asset_version(get_static_dir())
120+
121+
def test_transcript_page_versions_floating_toc_assets(self):
122+
html = _render_transcript()
123+
token = compute_asset_version(get_static_dir())
124+
assert f'href="/static/css/floating-toc.css?v={token}"' in html
125+
assert f'src="/static/js/floating-toc.js?v={token}"' in html
126+
127+
def test_transcript_page_has_no_unversioned_static_asset_refs(self):
128+
"""Every /static/ css/js reference in the rendered page must carry
129+
the version query parameter."""
130+
html = _render_transcript()
131+
assert 'href="/static/css/floating-toc.css"' not in html
132+
assert 'src="/static/js/floating-toc.js"' not in html
133+
134+
def test_base_template_versions_history_page_link(self):
135+
"""history.html suffers the same intermediary-cache disease; its
136+
link is versioned with the same token."""
137+
html = _render_transcript()
138+
assert '/static/history.html?v=' in html
139+
140+
141+
class TestStaticNoCacheHeader:
142+
@pytest.fixture
143+
def client(self, tmp_path):
144+
from video_transcript_api.api.app import create_app
145+
146+
config = _minimal_config(tmp_path)
147+
app = create_app(config_loader=lambda: config, start_background=False)
148+
with TestClient(app) as test_client:
149+
yield test_client
150+
151+
def test_static_js_response_has_no_cache_header(self, client):
152+
resp = client.get("/static/js/floating-toc.js")
153+
assert resp.status_code == 200
154+
assert resp.headers["Cache-Control"] == "no-cache"
155+
156+
def test_static_css_response_has_no_cache_header(self, client):
157+
resp = client.get("/static/css/floating-toc.css")
158+
assert resp.status_code == 200
159+
assert resp.headers["Cache-Control"] == "no-cache"
160+
161+
def test_static_304_response_keeps_no_cache_header(self, client):
162+
"""ETag revalidation must still save bandwidth (304) and the 304
163+
response itself must carry no-cache so caches keep revalidating."""
164+
first = client.get("/static/js/floating-toc.js")
165+
etag = first.headers.get("ETag")
166+
assert etag
167+
second = client.get(
168+
"/static/js/floating-toc.js", headers={"If-None-Match": etag}
169+
)
170+
assert second.status_code == 304
171+
assert second.headers["Cache-Control"] == "no-cache"

‎tests/unit/web/test_frontend_nav.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ def test_contains_submit_task_link(self):
9595

9696
def test_contains_history_page_link(self):
9797
html = self._render()
98-
assert 'href="/static/history.html"' in html
98+
# Versioned with the asset_v cache-busting token (?v=...).
99+
assert 'href="/static/history.html?v=' in html
99100

100101
def test_nav_links_live_inside_site_nav_container(self):
101102
html = self._render()

0 commit comments

Comments
 (0)