From 7ae152af3323425155f5cdb2b5a87165331c90ad Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Mon, 30 Mar 2026 22:02:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat-media):=20=E4=BF=AE=E5=A4=8D=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E5=9B=BE=E7=89=87=E7=BC=93=E5=AD=98=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E5=B9=B6=E5=88=B7=E6=96=B0=E5=BD=93=E5=89=8D=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AA=92=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优先使用更高质量的微信图片资源并回写本地缓存 - 图片接口返回 no-store,避免浏览器继续命中旧缓存 - 页面恢复前台时刷新当前会话媒体资源,并补充相关测试 --- frontend/composables/chat/useChatMessages.js | 42 +++- frontend/lib/chat/message-normalizer.js | 12 +- frontend/pages/chat/[[username]].vue | 27 +++ src/wechat_decrypt_tool/routers/chat_media.py | 133 +++++++++-- tests/test_chat_media_image_cache_upgrade.py | 210 ++++++++++++++++++ 5 files changed, 406 insertions(+), 18 deletions(-) create mode 100644 tests/test_chat_media_image_cache_upgrade.py diff --git a/frontend/composables/chat/useChatMessages.js b/frontend/composables/chat/useChatMessages.js index 0991e6c..d1919b5 100644 --- a/frontend/composables/chat/useChatMessages.js +++ b/frontend/composables/chat/useChatMessages.js @@ -74,6 +74,7 @@ export const useChatMessages = ({ let highlightTimer = null const messageTypeFilter = ref('all') + const localMediaVersion = ref(0) const messageTypeFilterOptions = [ { value: 'all', label: '全部' }, { value: 'text', label: '文本' }, @@ -95,9 +96,39 @@ export const useChatMessages = ({ const normalizeMessage = createMessageNormalizer({ apiBase, getSelectedAccount: () => selectedAccount.value, - getSelectedContact: () => selectedContact.value + getSelectedContact: () => selectedContact.value, + getLocalMediaVersion: () => localMediaVersion.value }) + const bumpLocalMediaVersion = () => { + localMediaVersion.value = (localMediaVersion.value + 1) % 1000000000 + return localMediaVersion.value + } + + const renormalizeLoadedMessages = (username) => { + const key = String(username || '').trim() + if (!key) return + const existing = allMessages.value[key] + if (!Array.isArray(existing) || !existing.length) return + + const refreshed = dedupeMessagesById(existing.map((message) => { + const normalized = normalizeMessage(message) + return { + ...message, + ...normalized, + _emojiDownloading: !!message?._emojiDownloading, + _emojiDownloaded: typeof message?._emojiDownloaded === 'boolean' ? message._emojiDownloaded : normalized._emojiDownloaded, + _quoteImageError: false, + _quoteThumbError: false + } + })) + + allMessages.value = { + ...allMessages.value, + [key]: refreshed + } + } + const messages = computed(() => { if (!selectedContact.value) return [] return allMessages.value[selectedContact.value.username] || [] @@ -534,9 +565,17 @@ export const useChatMessages = ({ const refreshSelectedMessages = async () => { if (!selectedContact.value) return + bumpLocalMediaVersion() await loadMessages({ username: selectedContact.value.username, reset: true }) } + const refreshCurrentMessageMedia = async () => { + if (!selectedContact.value?.username) return + bumpLocalMediaVersion() + renormalizeLoadedMessages(selectedContact.value.username) + await nextTick() + } + const refreshRealtimeIncremental = async () => { if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return if (searchContext.value?.active || isLoadingMessages.value) return @@ -912,6 +951,7 @@ export const useChatMessages = ({ loadMessages, loadMoreMessages, refreshSelectedMessages, + refreshCurrentMessageMedia, refreshRealtimeIncremental, queueRealtimeRefresh, tryEnableRealtimeAuto, diff --git a/frontend/lib/chat/message-normalizer.js b/frontend/lib/chat/message-normalizer.js index 08a162f..aef313f 100644 --- a/frontend/lib/chat/message-normalizer.js +++ b/frontend/lib/chat/message-normalizer.js @@ -17,11 +17,12 @@ const buildAccountMediaUrl = (apiBase, path, parts) => { return `${apiBase}${path}?${parts.filter(Boolean).join('&')}` } -export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => { +export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact, getLocalMediaVersion }) => { return (msg) => { const account = String(getSelectedAccount?.() || '').trim() const contact = getSelectedContact?.() || null const username = String(contact?.username || '').trim() + const localMediaVersion = Number(getLocalMediaVersion?.() || 0) const isSent = !!msg.isSent const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '') const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null @@ -66,7 +67,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect `account=${encodeURIComponent(account)}`, msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '', msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '', - `username=${encodeURIComponent(username)}` + `username=${encodeURIComponent(username)}`, + localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : '' ]) })() @@ -86,7 +88,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect `account=${encodeURIComponent(account)}`, msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '', msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '', - `username=${encodeURIComponent(username)}` + `username=${encodeURIComponent(username)}`, + localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : '' ]) })() @@ -158,7 +161,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect return buildAccountMediaUrl(apiBase, '/chat/media/image', [ `account=${encodeURIComponent(account)}`, `server_id=${encodeURIComponent(quoteServerIdStr)}`, - username ? `username=${encodeURIComponent(username)}` : '' + username ? `username=${encodeURIComponent(username)}` : '', + localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : '' ]) })() diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index 09a0034..6ec51e6 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -216,6 +216,7 @@ const { loadMessages, loadMoreMessages, refreshSelectedMessages, + refreshCurrentMessageMedia, queueRealtimeRefresh, tryEnableRealtimeAuto, resetMessageState, @@ -568,6 +569,28 @@ const onGlobalKeyDown = (event) => { } } +let lastResumeMediaRefreshAt = 0 + +const maybeRefreshMediaOnResume = () => { + if (!process.client) return + if (!selectedContact.value?.username) return + if (searchContext.value?.active) return + + const now = Date.now() + if ((now - lastResumeMediaRefreshAt) < 1200) return + lastResumeMediaRefreshAt = now + void refreshCurrentMessageMedia() +} + +const onWindowFocus = () => { + maybeRefreshMediaOnResume() +} + +const onVisibilityChange = () => { + if (document.visibilityState !== 'visible') return + maybeRefreshMediaOnResume() +} + onMounted(async () => { if (!process.client) return @@ -585,6 +608,8 @@ onMounted(async () => { document.addEventListener('touchmove', onFloatingWindowMouseMove) document.addEventListener('touchend', onFloatingWindowMouseUp) document.addEventListener('touchcancel', onFloatingWindowMouseUp) + window.addEventListener('focus', onWindowFocus) + document.addEventListener('visibilitychange', onVisibilityChange) logChatBootstrap('loadContacts:start', { selectedAccount: selectedAccount.value @@ -635,6 +660,8 @@ onUnmounted(() => { document.removeEventListener('touchmove', onFloatingWindowMouseMove) document.removeEventListener('touchend', onFloatingWindowMouseUp) document.removeEventListener('touchcancel', onFloatingWindowMouseUp) + window.removeEventListener('focus', onWindowFocus) + document.removeEventListener('visibilitychange', onVisibilityChange) if (locateServerIdTimer) clearTimeout(locateServerIdTimer) locateServerIdTimer = null diff --git a/src/wechat_decrypt_tool/routers/chat_media.py b/src/wechat_decrypt_tool/routers/chat_media.py index 70b7290..cdab0a3 100644 --- a/src/wechat_decrypt_tool/routers/chat_media.py +++ b/src/wechat_decrypt_tool/routers/chat_media.py @@ -67,6 +67,87 @@ 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 + + +def _image_candidate_variant_rank(path: Path) -> int: + stem = str(path.stem or "").lower() + if stem.endswith(("_b", ".b")): + return 0 + if stem.endswith(("_h", ".h")): + return 1 + if stem.endswith(("_c", ".c")): + return 3 + if stem.endswith(("_t", ".t")): + return 4 + return 2 + + +def _image_candidate_stat(path: Optional[Path]) -> tuple[int, float]: + if not path: + return 0, 0.0 + try: + st = path.stat() + return int(st.st_size), float(st.st_mtime) + except Exception: + return 0, 0.0 + + +def _should_prefer_live_image_candidates( + *, + cached_path: Optional[Path], + live_candidates: list[Path], +) -> bool: + if not live_candidates: + return False + if not cached_path: + return True + + best_live = live_candidates[0] + live_rank = _image_candidate_variant_rank(best_live) + if live_rank < 2: + return True + + cache_size, cache_mtime = _image_candidate_stat(cached_path) + live_size, live_mtime = _image_candidate_stat(best_live) + if live_rank == 2 and live_size > cache_size: + return True + if live_rank == 2 and live_size >= cache_size and live_mtime > cache_mtime: + return True + return False + + +def _write_cached_chat_image(account_dir: Path, md5: str, data: bytes) -> None: + md5_norm = str(md5 or "").strip().lower() + if (not md5_norm) or (not data): + return + + ext = _detect_image_extension(data) + out_path = _get_decrypted_resource_path(account_dir, md5_norm, ext) + out_path.parent.mkdir(parents=True, exist_ok=True) + + for stale_ext in ("jpg", "png", "gif", "webp", "dat"): + stale_path = _get_decrypted_resource_path(account_dir, md5_norm, stale_ext) + if stale_path == out_path: + continue + try: + if stale_path.exists(): + stale_path.unlink() + except Exception: + pass + + try: + if out_path.exists() and out_path.read_bytes() == data: + return + except Exception: + pass + + out_path.write_bytes(data) + + def _resolve_avatar_remote_url(*, account_dir: Path, username: str) -> str: u = str(username or "").strip() if not u: @@ -1311,20 +1392,26 @@ async def get_chat_image( if md5_from_msg: md5 = md5_from_msg - # md5 模式:优先从解密资源目录读取(更快) + cached_path: Optional[Path] = None + cached_data = b"" + cached_media_type = "application/octet-stream" + + # md5 模式:优先检查解密资源目录;如果微信目录里已经有更高质量版本,会在后面自动升级。 if md5: decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower()) if decrypted_path: data = decrypted_path.read_bytes() media_type = _detect_image_media_type(data[:32]) if media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type): - return Response(content=data, media_type=media_type) + cached_path = decrypted_path + cached_data = data + cached_media_type = media_type # Corrupted cached file (e.g. wrong ext / partial data): remove and regenerate from source. - try: - if decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: + elif decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: + try: decrypted_path.unlink() - except Exception: - pass + except Exception: + pass # 回退:从微信数据目录实时定位并解密 wxid_dir = _resolve_account_wxid_dir(account_dir) @@ -1414,11 +1501,36 @@ async def get_chat_image( break if not p: + if cached_path: + return _build_uncached_media_response(cached_data, cached_media_type) raise HTTPException(status_code=404, detail="Image not found.") candidates.extend(_iter_media_source_candidates(p)) candidates = _order_media_candidates(candidates) + if cached_path: + try: + cached_key = str(cached_path.resolve()) + except Exception: + cached_key = str(cached_path) + + live_candidates: list[Path] = [] + seen_live: set[str] = set() + for candidate in candidates: + try: + key = str(candidate.resolve()) + except Exception: + key = str(candidate) + if key == cached_key or key in seen_live: + continue + seen_live.add(key) + live_candidates.append(candidate) + + if _should_prefer_live_image_candidates(cached_path=cached_path, live_candidates=live_candidates): + candidates = [*live_candidates, cached_path] + else: + candidates = [cached_path, *live_candidates] + logger.info(f"chat_image: md5={md5} file_id={file_id} candidates={len(candidates)} first={p}") data = b"" @@ -1443,19 +1555,14 @@ async def get_chat_image( # 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名 if md5 and media_type.startswith("image/"): try: - out_md5 = str(md5).lower() - ext = _detect_image_extension(data) - out_path = _get_decrypted_resource_path(account_dir, out_md5, ext) - out_path.parent.mkdir(parents=True, exist_ok=True) - if not out_path.exists(): - out_path.write_bytes(data) + _write_cached_chat_image(account_dir, str(md5), data) except Exception: pass logger.info( f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}" ) - return Response(content=data, media_type=media_type) + return _build_uncached_media_response(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 new file mode 100644 index 0000000..684290f --- /dev/null +++ b/tests/test_chat_media_image_cache_upgrade.py @@ -0,0 +1,210 @@ +import hashlib +import importlib +import json +import logging +import os +import sqlite3 +import sys +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +class TestChatMediaImageCacheUpgrade(unittest.TestCase): + def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None: + conn = sqlite3.connect(str(path)) + try: + conn.execute( + """ + CREATE TABLE contact ( + username TEXT, + remark TEXT, + nick_name TEXT, + alias TEXT, + local_type INTEGER, + verify_flag INTEGER, + big_head_url TEXT, + small_head_url TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE stranger ( + username TEXT, + remark TEXT, + nick_name TEXT, + alias TEXT, + local_type INTEGER, + verify_flag INTEGER, + big_head_url TEXT, + small_head_url TEXT + ) + """ + ) + conn.execute( + "INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (account, "", "我", "", 1, 0, "", ""), + ) + conn.execute( + "INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (username, "", "测试好友", "", 1, 0, "", ""), + ) + conn.commit() + finally: + conn.close() + + def _seed_session_db(self, path: Path, *, username: str) -> None: + conn = sqlite3.connect(str(path)) + try: + conn.execute( + """ + CREATE TABLE SessionTable ( + username TEXT, + is_hidden INTEGER, + sort_timestamp INTEGER + ) + """ + ) + conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", (username, 0, 1735689600)) + conn.commit() + finally: + conn.close() + + def _seed_source_info(self, account_dir: Path, *, wxid_dir: Path) -> None: + payload = { + "wxid_dir": str(wxid_dir), + "db_storage_path": "", + } + (account_dir / "_source.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + def _seed_cached_resource(self, account_dir: Path, *, md5: str, payload: bytes) -> Path: + resource_dir = account_dir / "resource" / md5[:2] + resource_dir.mkdir(parents=True, exist_ok=True) + target = resource_dir / f"{md5}.jpg" + target.write_bytes(payload) + return target + + def _seed_live_variant(self, wxid_dir: Path, *, username: str, md5: str, suffix: str, payload: bytes) -> Path: + chat_hash = hashlib.md5(username.encode("utf-8")).hexdigest() + target = wxid_dir / "msg" / "attach" / chat_hash / "2026-03" / "Img" / f"{md5}{suffix}.dat" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(payload) + return target + + def _build_client(self): + import wechat_decrypt_tool.logging_config as logging_config + import wechat_decrypt_tool.app_paths as app_paths + import wechat_decrypt_tool.media_helpers as media_helpers + import wechat_decrypt_tool.routers.chat_media as chat_media + + logging.shutdown() + importlib.reload(logging_config) + importlib.reload(app_paths) + importlib.reload(media_helpers) + importlib.reload(chat_media) + + app = FastAPI() + app.include_router(chat_media.router) + return TestClient(app) + + def test_live_high_variant_replaces_stale_cached_thumb(self): + with TemporaryDirectory() as td: + root = Path(td) + account = "wxid_test" + username = "wxid_friend" + md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + 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_thumb = b"\xff\xd8\xff\xd9" + live_original = b"\xff\xd8\xff\xe0" + (b"\x00" * 48) + b"\xff\xd9" + cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_thumb) + self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_h", payload=live_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() + resp = client.get( + "/api/chat/media/image", + params={"account": account, "md5": md5, "username": username}, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, live_original) + self.assertEqual(resp.headers.get("cache-control"), "no-store") + self.assertEqual(cache_path.read_bytes(), live_original) + 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 + + def test_cached_original_is_not_downgraded_by_live_thumb(self): + with TemporaryDirectory() as td: + root = Path(td) + account = "wxid_test" + username = "wxid_friend" + md5 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + 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"\x11" * 64) + b"\xff\xd9" + live_thumb = b"\xff\xd8\xff\xd9" + cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_original) + self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_t", payload=live_thumb) + + prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR") + client = None + try: + os.environ["WECHAT_TOOL_DATA_DIR"] = str(root) + client = self._build_client() + resp = client.get( + "/api/chat/media/image", + params={"account": account, "md5": md5, "username": username}, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.content, cached_original) + self.assertEqual(resp.headers.get("cache-control"), "no-store") + self.assertEqual(cache_path.read_bytes(), cached_original) + 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()