feat(chat-media): 修复媒体缓存

This commit is contained in:
2977094657
2026-04-08 00:21:20 +08:00
Unverified
parent 1064c5a248
commit 655a1fd7f5
3 changed files with 109 additions and 10 deletions
+25 -1
View File
@@ -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 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 = () => { const maybeRefreshMediaOnResume = () => {
if (!process.client) return if (!process.client) return
if (!selectedContact.value?.username) return if (!selectedContact.value?.username) return
if (searchContext.value?.active) 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() const now = Date.now()
if ((now - lastResumeMediaRefreshAt) < 1200) return if ((now - lastResumeMediaRefreshAt) < RESUME_MEDIA_REFRESH_MIN_INTERVAL_MS) return
lastResumeMediaRefreshAt = now lastResumeMediaRefreshAt = now
lastPageHiddenAt = 0
void refreshCurrentMessageMedia() void refreshCurrentMessageMedia()
} }
@@ -624,6 +644,10 @@ const onWindowFocus = () => {
} }
const onVisibilityChange = () => { const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
lastPageHiddenAt = Date.now()
return
}
if (document.visibilityState !== 'visible') return if (document.visibilityState !== 'visible') return
maybeRefreshMediaOnResume() maybeRefreshMediaOnResume()
} }
+25 -7
View File
@@ -12,7 +12,7 @@ from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -67,10 +67,27 @@ logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute) router = APIRouter(route_class=PathFixRoute)
def _build_uncached_media_response(data: bytes, media_type: str) -> Response: CHAT_MEDIA_BROWSER_CACHE_SECONDS = 24 * 60 * 60
resp = Response(content=data, media_type=media_type)
resp.headers["Cache-Control"] = "no-store"
return resp 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: 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="获取图片消息资源") @router.get("/api/chat/media/image", summary="获取图片消息资源")
async def get_chat_image( async def get_chat_image(
request: Request,
md5: Optional[str] = None, md5: Optional[str] = None,
file_id: Optional[str] = None, file_id: Optional[str] = None,
server_id: Optional[int] = None, server_id: Optional[int] = None,
@@ -1502,7 +1520,7 @@ async def get_chat_image(
if not p: if not p:
if cached_path: 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.") raise HTTPException(status_code=404, detail="Image not found.")
candidates.extend(_iter_media_source_candidates(p)) candidates.extend(_iter_media_source_candidates(p))
@@ -1562,7 +1580,7 @@ async def get_chat_image(
logger.info( logger.info(
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}" 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="获取表情消息资源") @router.get("/api/chat/media/emoji", summary="获取表情消息资源")
+59 -2
View File
@@ -18,6 +18,10 @@ sys.path.insert(0, str(ROOT / "src"))
class TestChatMediaImageCacheUpgrade(unittest.TestCase): 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: def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
conn = sqlite3.connect(str(path)) conn = sqlite3.connect(str(path))
try: try:
@@ -147,7 +151,7 @@ class TestChatMediaImageCacheUpgrade(unittest.TestCase):
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, live_original) 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) self.assertEqual(cache_path.read_bytes(), live_original)
finally: finally:
try: try:
@@ -192,7 +196,7 @@ class TestChatMediaImageCacheUpgrade(unittest.TestCase):
) )
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, cached_original) 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) self.assertEqual(cache_path.read_bytes(), cached_original)
finally: finally:
try: try:
@@ -205,6 +209,59 @@ class TestChatMediaImageCacheUpgrade(unittest.TestCase):
else: else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data 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__": if __name__ == "__main__":
unittest.main() unittest.main()