Skip to content

Commit dc112e7

Browse files
committed
fix(chat-media): 修复聊天图片缓存降级并刷新当前会话媒体
- 优先使用更高质量的微信图片资源并回写本地缓存 - 图片接口返回 no-store,避免浏览器继续命中旧缓存 - 页面恢复前台时刷新当前会话媒体资源,并补充相关测试
1 parent 18af15b commit dc112e7

File tree

5 files changed

+406
-18
lines changed

5 files changed

+406
-18
lines changed

frontend/composables/chat/useChatMessages.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export const useChatMessages = ({
7474
let highlightTimer = null
7575

7676
const messageTypeFilter = ref('all')
77+
const localMediaVersion = ref(0)
7778
const messageTypeFilterOptions = [
7879
{ value: 'all', label: '全部' },
7980
{ value: 'text', label: '文本' },
@@ -95,9 +96,39 @@ export const useChatMessages = ({
9596
const normalizeMessage = createMessageNormalizer({
9697
apiBase,
9798
getSelectedAccount: () => selectedAccount.value,
98-
getSelectedContact: () => selectedContact.value
99+
getSelectedContact: () => selectedContact.value,
100+
getLocalMediaVersion: () => localMediaVersion.value
99101
})
100102

103+
const bumpLocalMediaVersion = () => {
104+
localMediaVersion.value = (localMediaVersion.value + 1) % 1000000000
105+
return localMediaVersion.value
106+
}
107+
108+
const renormalizeLoadedMessages = (username) => {
109+
const key = String(username || '').trim()
110+
if (!key) return
111+
const existing = allMessages.value[key]
112+
if (!Array.isArray(existing) || !existing.length) return
113+
114+
const refreshed = dedupeMessagesById(existing.map((message) => {
115+
const normalized = normalizeMessage(message)
116+
return {
117+
...message,
118+
...normalized,
119+
_emojiDownloading: !!message?._emojiDownloading,
120+
_emojiDownloaded: typeof message?._emojiDownloaded === 'boolean' ? message._emojiDownloaded : normalized._emojiDownloaded,
121+
_quoteImageError: false,
122+
_quoteThumbError: false
123+
}
124+
}))
125+
126+
allMessages.value = {
127+
...allMessages.value,
128+
[key]: refreshed
129+
}
130+
}
131+
101132
const messages = computed(() => {
102133
if (!selectedContact.value) return []
103134
return allMessages.value[selectedContact.value.username] || []
@@ -534,9 +565,17 @@ export const useChatMessages = ({
534565

535566
const refreshSelectedMessages = async () => {
536567
if (!selectedContact.value) return
568+
bumpLocalMediaVersion()
537569
await loadMessages({ username: selectedContact.value.username, reset: true })
538570
}
539571

572+
const refreshCurrentMessageMedia = async () => {
573+
if (!selectedContact.value?.username) return
574+
bumpLocalMediaVersion()
575+
renormalizeLoadedMessages(selectedContact.value.username)
576+
await nextTick()
577+
}
578+
540579
const refreshRealtimeIncremental = async () => {
541580
if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return
542581
if (searchContext.value?.active || isLoadingMessages.value) return
@@ -912,6 +951,7 @@ export const useChatMessages = ({
912951
loadMessages,
913952
loadMoreMessages,
914953
refreshSelectedMessages,
954+
refreshCurrentMessageMedia,
915955
refreshRealtimeIncremental,
916956
queueRealtimeRefresh,
917957
tryEnableRealtimeAuto,

frontend/lib/chat/message-normalizer.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ const buildAccountMediaUrl = (apiBase, path, parts) => {
1717
return `${apiBase}${path}?${parts.filter(Boolean).join('&')}`
1818
}
1919

20-
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
20+
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact, getLocalMediaVersion }) => {
2121
return (msg) => {
2222
const account = String(getSelectedAccount?.() || '').trim()
2323
const contact = getSelectedContact?.() || null
2424
const username = String(contact?.username || '').trim()
25+
const localMediaVersion = Number(getLocalMediaVersion?.() || 0)
2526
const isSent = !!msg.isSent
2627
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '')
2728
const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null
@@ -66,7 +67,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
6667
`account=${encodeURIComponent(account)}`,
6768
msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '',
6869
msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '',
69-
`username=${encodeURIComponent(username)}`
70+
`username=${encodeURIComponent(username)}`,
71+
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
7072
])
7173
})()
7274

@@ -86,7 +88,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
8688
`account=${encodeURIComponent(account)}`,
8789
msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '',
8890
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
89-
`username=${encodeURIComponent(username)}`
91+
`username=${encodeURIComponent(username)}`,
92+
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
9093
])
9194
})()
9295

@@ -158,7 +161,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
158161
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
159162
`account=${encodeURIComponent(account)}`,
160163
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
161-
username ? `username=${encodeURIComponent(username)}` : ''
164+
username ? `username=${encodeURIComponent(username)}` : '',
165+
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
162166
])
163167
})()
164168

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ const {
216216
loadMessages,
217217
loadMoreMessages,
218218
refreshSelectedMessages,
219+
refreshCurrentMessageMedia,
219220
queueRealtimeRefresh,
220221
tryEnableRealtimeAuto,
221222
resetMessageState,
@@ -568,6 +569,28 @@ const onGlobalKeyDown = (event) => {
568569
}
569570
}
570571
572+
let lastResumeMediaRefreshAt = 0
573+
574+
const maybeRefreshMediaOnResume = () => {
575+
if (!process.client) return
576+
if (!selectedContact.value?.username) return
577+
if (searchContext.value?.active) return
578+
579+
const now = Date.now()
580+
if ((now - lastResumeMediaRefreshAt) < 1200) return
581+
lastResumeMediaRefreshAt = now
582+
void refreshCurrentMessageMedia()
583+
}
584+
585+
const onWindowFocus = () => {
586+
maybeRefreshMediaOnResume()
587+
}
588+
589+
const onVisibilityChange = () => {
590+
if (document.visibilityState !== 'visible') return
591+
maybeRefreshMediaOnResume()
592+
}
593+
571594
onMounted(async () => {
572595
if (!process.client) return
573596
@@ -585,6 +608,8 @@ onMounted(async () => {
585608
document.addEventListener('touchmove', onFloatingWindowMouseMove)
586609
document.addEventListener('touchend', onFloatingWindowMouseUp)
587610
document.addEventListener('touchcancel', onFloatingWindowMouseUp)
611+
window.addEventListener('focus', onWindowFocus)
612+
document.addEventListener('visibilitychange', onVisibilityChange)
588613
589614
logChatBootstrap('loadContacts:start', {
590615
selectedAccount: selectedAccount.value
@@ -635,6 +660,8 @@ onUnmounted(() => {
635660
document.removeEventListener('touchmove', onFloatingWindowMouseMove)
636661
document.removeEventListener('touchend', onFloatingWindowMouseUp)
637662
document.removeEventListener('touchcancel', onFloatingWindowMouseUp)
663+
window.removeEventListener('focus', onWindowFocus)
664+
document.removeEventListener('visibilitychange', onVisibilityChange)
638665
639666
if (locateServerIdTimer) clearTimeout(locateServerIdTimer)
640667
locateServerIdTimer = null

src/wechat_decrypt_tool/routers/chat_media.py

Lines changed: 120 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,87 @@
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
74+
75+
76+
def _image_candidate_variant_rank(path: Path) -> int:
77+
stem = str(path.stem or "").lower()
78+
if stem.endswith(("_b", ".b")):
79+
return 0
80+
if stem.endswith(("_h", ".h")):
81+
return 1
82+
if stem.endswith(("_c", ".c")):
83+
return 3
84+
if stem.endswith(("_t", ".t")):
85+
return 4
86+
return 2
87+
88+
89+
def _image_candidate_stat(path: Optional[Path]) -> tuple[int, float]:
90+
if not path:
91+
return 0, 0.0
92+
try:
93+
st = path.stat()
94+
return int(st.st_size), float(st.st_mtime)
95+
except Exception:
96+
return 0, 0.0
97+
98+
99+
def _should_prefer_live_image_candidates(
100+
*,
101+
cached_path: Optional[Path],
102+
live_candidates: list[Path],
103+
) -> bool:
104+
if not live_candidates:
105+
return False
106+
if not cached_path:
107+
return True
108+
109+
best_live = live_candidates[0]
110+
live_rank = _image_candidate_variant_rank(best_live)
111+
if live_rank < 2:
112+
return True
113+
114+
cache_size, cache_mtime = _image_candidate_stat(cached_path)
115+
live_size, live_mtime = _image_candidate_stat(best_live)
116+
if live_rank == 2 and live_size > cache_size:
117+
return True
118+
if live_rank == 2 and live_size >= cache_size and live_mtime > cache_mtime:
119+
return True
120+
return False
121+
122+
123+
def _write_cached_chat_image(account_dir: Path, md5: str, data: bytes) -> None:
124+
md5_norm = str(md5 or "").strip().lower()
125+
if (not md5_norm) or (not data):
126+
return
127+
128+
ext = _detect_image_extension(data)
129+
out_path = _get_decrypted_resource_path(account_dir, md5_norm, ext)
130+
out_path.parent.mkdir(parents=True, exist_ok=True)
131+
132+
for stale_ext in ("jpg", "png", "gif", "webp", "dat"):
133+
stale_path = _get_decrypted_resource_path(account_dir, md5_norm, stale_ext)
134+
if stale_path == out_path:
135+
continue
136+
try:
137+
if stale_path.exists():
138+
stale_path.unlink()
139+
except Exception:
140+
pass
141+
142+
try:
143+
if out_path.exists() and out_path.read_bytes() == data:
144+
return
145+
except Exception:
146+
pass
147+
148+
out_path.write_bytes(data)
149+
150+
70151
def _resolve_avatar_remote_url(*, account_dir: Path, username: str) -> str:
71152
u = str(username or "").strip()
72153
if not u:
@@ -1311,20 +1392,26 @@ async def get_chat_image(
13111392
if md5_from_msg:
13121393
md5 = md5_from_msg
13131394

1314-
# md5 模式:优先从解密资源目录读取(更快)
1395+
cached_path: Optional[Path] = None
1396+
cached_data = b""
1397+
cached_media_type = "application/octet-stream"
1398+
1399+
# md5 模式:优先检查解密资源目录;如果微信目录里已经有更高质量版本,会在后面自动升级。
13151400
if md5:
13161401
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
13171402
if decrypted_path:
13181403
data = decrypted_path.read_bytes()
13191404
media_type = _detect_image_media_type(data[:32])
13201405
if media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type):
1321-
return Response(content=data, media_type=media_type)
1406+
cached_path = decrypted_path
1407+
cached_data = data
1408+
cached_media_type = media_type
13221409
# Corrupted cached file (e.g. wrong ext / partial data): remove and regenerate from source.
1323-
try:
1324-
if decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
1410+
elif decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
1411+
try:
13251412
decrypted_path.unlink()
1326-
except Exception:
1327-
pass
1413+
except Exception:
1414+
pass
13281415

13291416
# 回退:从微信数据目录实时定位并解密
13301417
wxid_dir = _resolve_account_wxid_dir(account_dir)
@@ -1414,11 +1501,36 @@ async def get_chat_image(
14141501
break
14151502

14161503
if not p:
1504+
if cached_path:
1505+
return _build_uncached_media_response(cached_data, cached_media_type)
14171506
raise HTTPException(status_code=404, detail="Image not found.")
14181507

14191508
candidates.extend(_iter_media_source_candidates(p))
14201509
candidates = _order_media_candidates(candidates)
14211510

1511+
if cached_path:
1512+
try:
1513+
cached_key = str(cached_path.resolve())
1514+
except Exception:
1515+
cached_key = str(cached_path)
1516+
1517+
live_candidates: list[Path] = []
1518+
seen_live: set[str] = set()
1519+
for candidate in candidates:
1520+
try:
1521+
key = str(candidate.resolve())
1522+
except Exception:
1523+
key = str(candidate)
1524+
if key == cached_key or key in seen_live:
1525+
continue
1526+
seen_live.add(key)
1527+
live_candidates.append(candidate)
1528+
1529+
if _should_prefer_live_image_candidates(cached_path=cached_path, live_candidates=live_candidates):
1530+
candidates = [*live_candidates, cached_path]
1531+
else:
1532+
candidates = [cached_path, *live_candidates]
1533+
14221534
logger.info(f"chat_image: md5={md5} file_id={file_id} candidates={len(candidates)} first={p}")
14231535

14241536
data = b""
@@ -1443,19 +1555,14 @@ async def get_chat_image(
14431555
# 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名
14441556
if md5 and media_type.startswith("image/"):
14451557
try:
1446-
out_md5 = str(md5).lower()
1447-
ext = _detect_image_extension(data)
1448-
out_path = _get_decrypted_resource_path(account_dir, out_md5, ext)
1449-
out_path.parent.mkdir(parents=True, exist_ok=True)
1450-
if not out_path.exists():
1451-
out_path.write_bytes(data)
1558+
_write_cached_chat_image(account_dir, str(md5), data)
14521559
except Exception:
14531560
pass
14541561

14551562
logger.info(
14561563
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
14571564
)
1458-
return Response(content=data, media_type=media_type)
1565+
return _build_uncached_media_response(data, media_type)
14591566

14601567

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

0 commit comments

Comments
 (0)