fix(chat-media): 修复聊天图片缓存降级并刷新当前会话媒体

- 优先使用更高质量的微信图片资源并回写本地缓存
- 图片接口返回 no-store,避免浏览器继续命中旧缓存
- 页面恢复前台时刷新当前会话媒体资源,并补充相关测试
This commit is contained in:
2977094657
2026-03-30 22:02:09 +08:00
Unverified
parent 697e788318
commit 7ae152af33
5 changed files with 406 additions and 18 deletions
+41 -1
View File
@@ -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,
+8 -4
View File
@@ -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))}` : ''
])
})()
+27
View File
@@ -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
+120 -13
View File
@@ -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="获取表情消息资源")
@@ -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()