Skip to content

Commit 9f03e87

Browse files
committed
feat(chat-media): 修复媒体缓存
1 parent 9a49abc commit 9f03e87

File tree

3 files changed

+109
-10
lines changed

3 files changed

+109
-10
lines changed

frontend/pages/chat/[[username]].vue

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,16 +606,36 @@ const onGlobalKeyDown = (event) => {
606606
}
607607
}
608608
609+
const RESUME_MEDIA_REFRESH_MIN_INTERVAL_MS = 1200
610+
const RESUME_MEDIA_REFRESH_MIN_HIDDEN_MS = 30 * 1000
611+
609612
let lastResumeMediaRefreshAt = 0
613+
let lastPageHiddenAt = 0
614+
615+
const hasLoadedConversationMedia = () => {
616+
const list = Array.isArray(messages.value) ? messages.value : []
617+
return list.some((message) => {
618+
return !!(
619+
String(message?.imageUrl || '').trim()
620+
|| String(message?.videoThumbUrl || '').trim()
621+
|| String(message?.quoteImageUrl || '').trim()
622+
)
623+
})
624+
}
610625
611626
const maybeRefreshMediaOnResume = () => {
612627
if (!process.client) return
613628
if (!selectedContact.value?.username) return
614629
if (searchContext.value?.active) return
630+
if (!hasLoadedConversationMedia()) return
631+
632+
const hiddenDuration = lastPageHiddenAt > 0 ? (Date.now() - lastPageHiddenAt) : 0
633+
if (hiddenDuration < RESUME_MEDIA_REFRESH_MIN_HIDDEN_MS) return
615634
616635
const now = Date.now()
617-
if ((now - lastResumeMediaRefreshAt) < 1200) return
636+
if ((now - lastResumeMediaRefreshAt) < RESUME_MEDIA_REFRESH_MIN_INTERVAL_MS) return
618637
lastResumeMediaRefreshAt = now
638+
lastPageHiddenAt = 0
619639
void refreshCurrentMessageMedia()
620640
}
621641
@@ -624,6 +644,10 @@ const onWindowFocus = () => {
624644
}
625645
626646
const onVisibilityChange = () => {
647+
if (document.visibilityState === 'hidden') {
648+
lastPageHiddenAt = Date.now()
649+
return
650+
}
627651
if (document.visibilityState !== 'visible') return
628652
maybeRefreshMediaOnResume()
629653
}

src/wechat_decrypt_tool/routers/chat_media.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from urllib.parse import urlparse
1313

1414
import requests
15-
from fastapi import APIRouter, HTTPException
15+
from fastapi import APIRouter, HTTPException, Request
1616
from fastapi.responses import FileResponse, Response
1717
from pydantic import BaseModel, Field
1818

@@ -67,10 +67,27 @@
6767
router = APIRouter(route_class=PathFixRoute)
6868

6969

70-
def _build_uncached_media_response(data: bytes, media_type: str) -> Response:
71-
resp = Response(content=data, media_type=media_type)
72-
resp.headers["Cache-Control"] = "no-store"
73-
return resp
70+
CHAT_MEDIA_BROWSER_CACHE_SECONDS = 24 * 60 * 60
71+
72+
73+
def _build_cached_media_response(request: Optional[Request], data: bytes, media_type: str) -> Response:
74+
payload = bytes(data or b"")
75+
etag = f'"{hashlib.sha1(payload).hexdigest()}"'
76+
cache_control = f"private, max-age={CHAT_MEDIA_BROWSER_CACHE_SECONDS}"
77+
headers = {
78+
"Cache-Control": cache_control,
79+
"ETag": etag,
80+
}
81+
82+
try:
83+
if_none_match = str(request.headers.get("if-none-match") or "").strip() if request else ""
84+
except Exception:
85+
if_none_match = ""
86+
87+
if if_none_match and if_none_match == etag:
88+
return Response(status_code=304, headers=headers)
89+
90+
return Response(content=payload, media_type=media_type, headers=headers)
7491

7592

7693
def _image_candidate_variant_rank(path: Path) -> int:
@@ -1363,6 +1380,7 @@ def _download_bytes() -> bytes:
13631380

13641381
@router.get("/api/chat/media/image", summary="获取图片消息资源")
13651382
async def get_chat_image(
1383+
request: Request,
13661384
md5: Optional[str] = None,
13671385
file_id: Optional[str] = None,
13681386
server_id: Optional[int] = None,
@@ -1502,7 +1520,7 @@ async def get_chat_image(
15021520

15031521
if not p:
15041522
if cached_path:
1505-
return _build_uncached_media_response(cached_data, cached_media_type)
1523+
return _build_cached_media_response(request, cached_data, cached_media_type)
15061524
raise HTTPException(status_code=404, detail="Image not found.")
15071525

15081526
candidates.extend(_iter_media_source_candidates(p))
@@ -1562,7 +1580,7 @@ async def get_chat_image(
15621580
logger.info(
15631581
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
15641582
)
1565-
return _build_uncached_media_response(data, media_type)
1583+
return _build_cached_media_response(request, data, media_type)
15661584

15671585

15681586
@router.get("/api/chat/media/emoji", summary="获取表情消息资源")

tests/test_chat_media_image_cache_upgrade.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818

1919

2020
class TestChatMediaImageCacheUpgrade(unittest.TestCase):
21+
def assert_cacheable_chat_image_response(self, resp) -> None:
22+
self.assertEqual(resp.headers.get("cache-control"), "private, max-age=86400")
23+
self.assertTrue(str(resp.headers.get("etag") or "").strip())
24+
2125
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
2226
conn = sqlite3.connect(str(path))
2327
try:
@@ -147,7 +151,7 @@ def test_live_high_variant_replaces_stale_cached_thumb(self):
147151
)
148152
self.assertEqual(resp.status_code, 200)
149153
self.assertEqual(resp.content, live_original)
150-
self.assertEqual(resp.headers.get("cache-control"), "no-store")
154+
self.assert_cacheable_chat_image_response(resp)
151155
self.assertEqual(cache_path.read_bytes(), live_original)
152156
finally:
153157
try:
@@ -192,7 +196,7 @@ def test_cached_original_is_not_downgraded_by_live_thumb(self):
192196
)
193197
self.assertEqual(resp.status_code, 200)
194198
self.assertEqual(resp.content, cached_original)
195-
self.assertEqual(resp.headers.get("cache-control"), "no-store")
199+
self.assert_cacheable_chat_image_response(resp)
196200
self.assertEqual(cache_path.read_bytes(), cached_original)
197201
finally:
198202
try:
@@ -205,6 +209,59 @@ def test_cached_original_is_not_downgraded_by_live_thumb(self):
205209
else:
206210
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
207211

212+
def test_chat_image_supports_etag_revalidation(self):
213+
with TemporaryDirectory() as td:
214+
root = Path(td)
215+
account = "wxid_test"
216+
username = "wxid_friend"
217+
md5 = "cccccccccccccccccccccccccccccccc"
218+
219+
account_dir = root / "output" / "databases" / account
220+
wxid_dir = root / "wxid_source"
221+
account_dir.mkdir(parents=True, exist_ok=True)
222+
wxid_dir.mkdir(parents=True, exist_ok=True)
223+
224+
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
225+
self._seed_session_db(account_dir / "session.db", username=username)
226+
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
227+
228+
cached_original = b"\xff\xd8\xff\xe0" + (b"\x22" * 64) + b"\xff\xd9"
229+
self._seed_cached_resource(account_dir, md5=md5, payload=cached_original)
230+
231+
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
232+
client = None
233+
try:
234+
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
235+
client = self._build_client()
236+
first = client.get(
237+
"/api/chat/media/image",
238+
params={"account": account, "md5": md5, "username": username},
239+
)
240+
self.assertEqual(first.status_code, 200)
241+
self.assertEqual(first.content, cached_original)
242+
self.assert_cacheable_chat_image_response(first)
243+
244+
etag = str(first.headers.get("etag") or "").strip()
245+
second = client.get(
246+
"/api/chat/media/image",
247+
params={"account": account, "md5": md5, "username": username},
248+
headers={"If-None-Match": etag},
249+
)
250+
self.assertEqual(second.status_code, 304)
251+
self.assertEqual(second.content, b"")
252+
self.assertEqual(second.headers.get("etag"), etag)
253+
self.assertEqual(second.headers.get("cache-control"), "private, max-age=86400")
254+
finally:
255+
try:
256+
client.close()
257+
except Exception:
258+
pass
259+
logging.shutdown()
260+
if prev_data is None:
261+
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
262+
else:
263+
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
264+
208265

209266
if __name__ == "__main__":
210267
unittest.main()

0 commit comments

Comments
 (0)