feat(chat-media): 修复媒体缓存

This commit is contained in:
2977094657
2026-04-08 00:21:20 +08:00
Unverified
parent 1064c5a248
commit 655a1fd7f5
3 changed files with 109 additions and 10 deletions
+25 -1
View File
@@ -606,16 +606,36 @@ const onGlobalKeyDown = (event) => {
}
}
const RESUME_MEDIA_REFRESH_MIN_INTERVAL_MS = 1200
const RESUME_MEDIA_REFRESH_MIN_HIDDEN_MS = 30 * 1000
let lastResumeMediaRefreshAt = 0
let lastPageHiddenAt = 0
const hasLoadedConversationMedia = () => {
const list = Array.isArray(messages.value) ? messages.value : []
return list.some((message) => {
return !!(
String(message?.imageUrl || '').trim()
|| String(message?.videoThumbUrl || '').trim()
|| String(message?.quoteImageUrl || '').trim()
)
})
}
const maybeRefreshMediaOnResume = () => {
if (!process.client) return
if (!selectedContact.value?.username) return
if (searchContext.value?.active) return
if (!hasLoadedConversationMedia()) return
const hiddenDuration = lastPageHiddenAt > 0 ? (Date.now() - lastPageHiddenAt) : 0
if (hiddenDuration < RESUME_MEDIA_REFRESH_MIN_HIDDEN_MS) return
const now = Date.now()
if ((now - lastResumeMediaRefreshAt) < 1200) return
if ((now - lastResumeMediaRefreshAt) < RESUME_MEDIA_REFRESH_MIN_INTERVAL_MS) return
lastResumeMediaRefreshAt = now
lastPageHiddenAt = 0
void refreshCurrentMessageMedia()
}
@@ -624,6 +644,10 @@ const onWindowFocus = () => {
}
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
lastPageHiddenAt = Date.now()
return
}
if (document.visibilityState !== 'visible') return
maybeRefreshMediaOnResume()
}
+25 -7
View File
@@ -12,7 +12,7 @@ from typing import Any, Optional
from urllib.parse import urlparse
import requests
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field
@@ -67,10 +67,27 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
def _build_uncached_media_response(data: bytes, media_type: str) -> Response:
resp = Response(content=data, media_type=media_type)
resp.headers["Cache-Control"] = "no-store"
return resp
CHAT_MEDIA_BROWSER_CACHE_SECONDS = 24 * 60 * 60
def _build_cached_media_response(request: Optional[Request], data: bytes, media_type: str) -> Response:
payload = bytes(data or b"")
etag = f'"{hashlib.sha1(payload).hexdigest()}"'
cache_control = f"private, max-age={CHAT_MEDIA_BROWSER_CACHE_SECONDS}"
headers = {
"Cache-Control": cache_control,
"ETag": etag,
}
try:
if_none_match = str(request.headers.get("if-none-match") or "").strip() if request else ""
except Exception:
if_none_match = ""
if if_none_match and if_none_match == etag:
return Response(status_code=304, headers=headers)
return Response(content=payload, media_type=media_type, headers=headers)
def _image_candidate_variant_rank(path: Path) -> int:
@@ -1363,6 +1380,7 @@ async def download_chat_emoji(req: EmojiDownloadRequest):
@router.get("/api/chat/media/image", summary="获取图片消息资源")
async def get_chat_image(
request: Request,
md5: Optional[str] = None,
file_id: Optional[str] = None,
server_id: Optional[int] = None,
@@ -1502,7 +1520,7 @@ async def get_chat_image(
if not p:
if cached_path:
return _build_uncached_media_response(cached_data, cached_media_type)
return _build_cached_media_response(request, cached_data, cached_media_type)
raise HTTPException(status_code=404, detail="Image not found.")
candidates.extend(_iter_media_source_candidates(p))
@@ -1562,7 +1580,7 @@ async def get_chat_image(
logger.info(
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
)
return _build_uncached_media_response(data, media_type)
return _build_cached_media_response(request, data, media_type)
@router.get("/api/chat/media/emoji", summary="获取表情消息资源")
+59 -2
View File
@@ -18,6 +18,10 @@ sys.path.insert(0, str(ROOT / "src"))
class TestChatMediaImageCacheUpgrade(unittest.TestCase):
def assert_cacheable_chat_image_response(self, resp) -> None:
self.assertEqual(resp.headers.get("cache-control"), "private, max-age=86400")
self.assertTrue(str(resp.headers.get("etag") or "").strip())
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
conn = sqlite3.connect(str(path))
try:
@@ -147,7 +151,7 @@ class TestChatMediaImageCacheUpgrade(unittest.TestCase):
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, live_original)
self.assertEqual(resp.headers.get("cache-control"), "no-store")
self.assert_cacheable_chat_image_response(resp)
self.assertEqual(cache_path.read_bytes(), live_original)
finally:
try:
@@ -192,7 +196,7 @@ class TestChatMediaImageCacheUpgrade(unittest.TestCase):
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, cached_original)
self.assertEqual(resp.headers.get("cache-control"), "no-store")
self.assert_cacheable_chat_image_response(resp)
self.assertEqual(cache_path.read_bytes(), cached_original)
finally:
try:
@@ -205,6 +209,59 @@ class TestChatMediaImageCacheUpgrade(unittest.TestCase):
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_chat_image_supports_etag_revalidation(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
md5 = "cccccccccccccccccccccccccccccccc"
account_dir = root / "output" / "databases" / account
wxid_dir = root / "wxid_source"
account_dir.mkdir(parents=True, exist_ok=True)
wxid_dir.mkdir(parents=True, exist_ok=True)
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
self._seed_session_db(account_dir / "session.db", username=username)
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
cached_original = b"\xff\xd8\xff\xe0" + (b"\x22" * 64) + b"\xff\xd9"
self._seed_cached_resource(account_dir, md5=md5, payload=cached_original)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
client = None
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
client = self._build_client()
first = client.get(
"/api/chat/media/image",
params={"account": account, "md5": md5, "username": username},
)
self.assertEqual(first.status_code, 200)
self.assertEqual(first.content, cached_original)
self.assert_cacheable_chat_image_response(first)
etag = str(first.headers.get("etag") or "").strip()
second = client.get(
"/api/chat/media/image",
params={"account": account, "md5": md5, "username": username},
headers={"If-None-Match": etag},
)
self.assertEqual(second.status_code, 304)
self.assertEqual(second.content, b"")
self.assertEqual(second.headers.get("etag"), etag)
self.assertEqual(second.headers.get("cache-control"), "private, max-age=86400")
finally:
try:
client.close()
except Exception:
pass
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
if __name__ == "__main__":
unittest.main()