diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index 4ab20f8..b4ac291 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -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() } diff --git a/src/wechat_decrypt_tool/routers/chat_media.py b/src/wechat_decrypt_tool/routers/chat_media.py index cdab0a3..1b6656b 100644 --- a/src/wechat_decrypt_tool/routers/chat_media.py +++ b/src/wechat_decrypt_tool/routers/chat_media.py @@ -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="获取表情消息资源") diff --git a/tests/test_chat_media_image_cache_upgrade.py b/tests/test_chat_media_image_cache_upgrade.py index 684290f..a601868 100644 --- a/tests/test_chat_media_image_cache_upgrade.py +++ b/tests/test_chat_media_image_cache_upgrade.py @@ -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()