mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(chat-media): 修复媒体缓存
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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="获取表情消息资源")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user