feat(sync): 聊天图片本地化(message media_hash+download_message_images+local-first rendering)
- Message 模型新增 media_hash 列 (sha256 FK -> Media.media_hash)
- Alembic 迁移 e5f6a7b8c9d0_message_media_hash (batch_alter_table, SQLite/MySQL 通用)
- app/scraper/media.py:download_message_images 批量下载器,镜像 download_call_recordings
- 候选: media_hash IS NULL AND channel IN ('wechat','whatsapp') AND type IN ('image','video','file') AND 任一 file_*_url LIKE 'http%'
- image 类型优先 file_th_url (缩略图,小快省),video/file 优先 file_url
- per-row try/except + per-run tried set,失败不挂循环
- sync_message_images orchestrator 入口 + ENTITY_MESSAGE_IMAGES 常量 (提交自身 SyncRun,镜像 call_recordings)
- scheduler: _run_message_images + ENTITY_FUNCS['message_images'] + 加入 daily all job (尾部)
- MessageOut schema 暴露 media_hash 字段
- MessageBubble.vue local-first: media_hash 优先 /api/media/{hash},否则回退 OSS file_url/file_th_url/file_hd_url
- types.ts: Message 接口添加 media_hash 字段
- 新增 test_message_images_downloader.py (6 tests, 镜像 test_recording_downloader)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,19 @@ const bestMediaUrl = computed<string | undefined>(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
/**
|
||||
* Local-first media src — when the backend has localized the media blob
|
||||
* (`message.media_hash` set by the `message_images` sync pass), render via
|
||||
* the local `/api/media/{hash}` endpoint instead of hitting yunke OSS.
|
||||
* Returns `undefined` when no localized copy exists yet; callers fall back
|
||||
* to `bestMediaUrl` / `imageFullUrl` (the OSS URLs) so a not-yet-localized
|
||||
* message still renders (degraded — talks to OSS).
|
||||
*/
|
||||
const localSrc = computed<string | undefined>(() => {
|
||||
const hash = props.message.media_hash
|
||||
return hash ? `/api/media/${hash}` : undefined
|
||||
})
|
||||
|
||||
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'tif', 'tiff'])
|
||||
const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'm4v', 'avi', 'mkv'])
|
||||
const AUDIO_EXTS = new Set(['mp3', 'amr', 'silk', 'm4a', 'aac', 'wav', 'ogg'])
|
||||
@@ -88,11 +101,24 @@ const mediaKind = computed<'image' | 'video' | 'audio' | 'file' | undefined>(()
|
||||
return 'file'
|
||||
})
|
||||
|
||||
/**
|
||||
* Source for the rendered image / video / audio / file link. Local-first:
|
||||
* when `media_hash` is present, serve the localized blob via
|
||||
* `/api/media/{hash}`; otherwise fall back to the best OSS URL on the row
|
||||
* (degraded — talks to yunke OSS directly). For images, click-to-zoom also
|
||||
* uses the local URL when available (the localized blob is what we have).
|
||||
*/
|
||||
const mediaSrc = computed<string | undefined>(() => localSrc.value || bestMediaUrl.value)
|
||||
|
||||
/**
|
||||
* Image preview src list — prefer the highest-fidelity http URL available so
|
||||
* click-to-zoom shows the best copy. Falls back to the displayed thumb URL.
|
||||
* When a localized copy exists, the preview is the local URL (the localized
|
||||
* blob is what we have on hand — typically the thumbnail, which is
|
||||
* sufficient for chat-timeline display).
|
||||
*/
|
||||
const imageFullUrl = computed<string | undefined>(() => {
|
||||
if (localSrc.value) return localSrc.value
|
||||
const m = props.message
|
||||
for (const u of [m.file_hd_url, m.file_url, m.file_th_url]) {
|
||||
if (u && HTTP_RE.test(u)) return u
|
||||
@@ -138,7 +164,7 @@ const typeTagLabel = computed(() => {
|
||||
<div v-if="mediaKind === 'image'" @click.stop>
|
||||
<el-image
|
||||
class="yk-msg__image"
|
||||
:src="bestMediaUrl"
|
||||
:src="mediaSrc"
|
||||
:preview-src-list="imageFullUrl ? [imageFullUrl] : []"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
@@ -150,7 +176,7 @@ const typeTagLabel = computed(() => {
|
||||
<div v-else-if="mediaKind === 'video'" @click.stop>
|
||||
<video
|
||||
class="yk-msg__video"
|
||||
:src="bestMediaUrl"
|
||||
:src="mediaSrc"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
@@ -159,7 +185,7 @@ const typeTagLabel = computed(() => {
|
||||
<!-- 1c. audio (http URL with audio extension) — native player + chip
|
||||
showing the duration when the message carries one. -->
|
||||
<div v-else-if="mediaKind === 'audio'" class="yk-msg__voice" @click.stop>
|
||||
<audio class="yk-msg__audio" :src="bestMediaUrl" controls preload="metadata" />
|
||||
<audio class="yk-msg__audio" :src="mediaSrc" controls preload="metadata" />
|
||||
<span v-if="message.duration" class="yk-msg__chip">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
{{ formatDuration(message.duration) }}
|
||||
@@ -170,7 +196,7 @@ const typeTagLabel = computed(() => {
|
||||
<div v-else-if="mediaKind === 'file'" @click.stop>
|
||||
<a
|
||||
class="yk-msg__chip yk-msg__chip--download"
|
||||
:href="bestMediaUrl"
|
||||
:href="mediaSrc"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user