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:
浅念
2026-08-11 18:24:11 +08:00
parent af7f8d71c8
commit 5ce33232c1
9 changed files with 678 additions and 5 deletions
+5
View File
@@ -149,6 +149,11 @@ class MessageOut(BaseModel):
sender_name: str | None = None
sender_head_url: str | None = None
timestamp: int | None = None
# sha256 media_hash of the localized media blob (NULL until the
# message_images sync pass downloads it). When present, the frontend
# renders via ``/api/media/{media_hash}`` (local-first); when absent, it
# falls back to the OSS ``file_url`` / ``file_th_url`` / ``file_hd_url``.
media_hash: str | None = None
raw_json: dict[str, Any] | None = None
+8
View File
@@ -377,6 +377,14 @@ class Message(Base):
timestamp: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # ms epoch
is_del: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# sha256 media_hash of the localized media blob (FK -> Media.media_hash);
# NULL until sync_message_images downloads it. Populated by the
# message_images sync pass; rendering prefers /api/media/{this} over the
# OSS file_url / file_th_url / file_hd_url URLs. For image messages the
# localized blob is typically the thumbnail (file_th_url) — small + fast
# + sufficient for chat-timeline display.
media_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
version: Mapped[str | None] = mapped_column(String(64), nullable=True)
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
fetched_at: Mapped[dt.datetime] = _fetched_at()
+139 -1
View File
@@ -37,13 +37,14 @@ import httpx
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Media, PhoneCall, VoiceClip
from app.db.models import Media, Message, PhoneCall, VoiceClip
from app.session.yunke_client import YunkeClient
from app.storage.object_store import ObjectStore
from app.storage.repos import upsert_media
__all__ = [
"download_call_recordings",
"download_message_images",
"download_message_media",
"download_url",
"download_voice_clip_audio",
@@ -374,3 +375,140 @@ async def download_voice_clip_audio(
localized += 1
await session.commit()
return attempted, localized
def _pick_message_image_url(
*,
type_: str | None,
file_url: str | None,
file_th_url: str | None,
file_hd_url: str | None,
) -> str | None:
"""Pick the best downloadable http(s) URL for a message media row.
For ``image`` messages, prefer the thumbnail (``file_th_url``): thumbnails
are small, fast to fetch, and exactly what the chat timeline renders, so
localizing the thumbnail is sufficient for display and easy on disk + bandwidth.
Falls back to ``file_url`` then ``file_hd_url`` when no http(s) thumbnail is
available.
For every other media type (``video`` / ``file``), prefer ``file_url`` (the
original), then ``file_hd_url``, then ``file_th_url``.
Returns ``None`` when no candidate is an http(s) URL (e.g. type-3 opaque ids
or relative filenames). Only ``http://`` / ``https://`` URLs are downloadable.
"""
if type_ == "image":
order = (file_th_url, file_url, file_hd_url)
else:
order = (file_url, file_hd_url, file_th_url)
for url in order:
if _is_http_url(url):
return url # type: ignore[return-value]
return None
async def download_message_images(
client: YunkeClient,
store: ObjectStore,
session: AsyncSession,
*,
batch_size: int = 50,
) -> tuple[int, int]:
"""Download chat-message media (image / video / file) OSS URLs into the
local object store and link them via ``message.media_hash``.
Iterates ``message`` rows that carry an http(s) URL on any of
``file_url`` / ``file_th_url`` / ``file_hd_url``, have ``type`` in
('image', 'video', 'file'), are on the wechat / whatsapp channel, and do
NOT yet carry a ``media_hash``. For each: pick the best available URL
(image: thumbnail-first; other: original-first — see
:func:`_pick_message_image_url`), fetch bytes (:func:`download_url`),
store content-addressed (:meth:`ObjectStore.put_by_hash`, sha256 dedup),
upsert a ``Media`` row, and write ``media_hash`` back onto the message.
Returns ``(attempted, localized)``. Resilient: a per-row download/store
failure is logged and counted as attempted-but-not-localized; no exception
escapes. Idempotent within a run: a per-run ``tried`` set excludes already-
attempted msg_ids from later batches so a persistently-failing row is NOT
re-fetched infinitely (it stays ``media_hash IS NULL`` and is retried on
the NEXT run). Rows already carrying ``media_hash`` are skipped; identical
bytes are stored once.
Mirrors :func:`download_call_recordings` / :func:`download_voice_clip_audio`
exactly. The message ``type`` label filter restricts to media-bearing rows
so text / system / unknown messages are never selected.
"""
attempted = 0
localized = 0
tried: set[str] = set()
while True:
stmt = (
sa.select(
Message.msg_id,
Message.type,
Message.channel,
Message.file_url,
Message.file_th_url,
Message.file_hd_url,
)
.where(Message.media_hash.is_(None))
.where(Message.channel.in_(("wechat", "whatsapp")))
.where(Message.type.in_(("image", "video", "file")))
.where(
sa.or_(
Message.file_url.like("http%"),
Message.file_th_url.like("http%"),
Message.file_hd_url.like("http%"),
)
)
.limit(batch_size)
)
if tried:
stmt = stmt.where(Message.msg_id.not_in(tried))
rows = (await session.execute(stmt)).all()
if not rows:
break
for msg_id, type_, channel, file_url, file_th_url, file_hd_url in rows:
attempted += 1
tried.add(msg_id)
url = _pick_message_image_url(
type_=type_,
file_url=file_url,
file_th_url=file_th_url,
file_hd_url=file_hd_url,
)
if url is None:
# WHERE clause matched at least one http(s) URL, but the
# preferred slot for this type isn't http(s) — defensive, in
# practice the OR above guarantees _pick returns a value.
continue
try:
data = await download_url(client, url)
except (httpx.HTTPError, OSError) as exc:
logger.warning(
"message media download failed for %s: %s", msg_id, exc
)
continue
ext = _ext_from_url(url)
sha256_hex, path = await store.put_by_hash(data, ext=ext)
await upsert_media(
session,
media_hash=sha256_hex,
channel=channel or "wechat",
media_type=type_,
original_url=file_url,
original_th_url=file_th_url,
original_hd_url=file_hd_url,
local_path=str(path),
size_bytes=len(data),
mime_type=guess_mime_type(ext),
)
await session.execute(
sa.update(Message)
.where(Message.msg_id == msg_id)
.values(media_hash=sha256_hex)
)
localized += 1
await session.commit()
return attempted, localized
+63
View File
@@ -53,6 +53,7 @@ from app.db.models import Department, SyncRun, WechatAccount, WhatsappAccount
from app.scraper.call import scrape_phone_calls, scrape_sms
from app.scraper.media import (
download_call_recordings,
download_message_images,
download_message_media,
download_voice_clip_audio,
)
@@ -92,11 +93,13 @@ logger = logging.getLogger(__name__)
__all__ = [
"ENTITY_CALL_RECORDINGS",
"ENTITY_MESSAGE_IMAGES",
"ENTITY_VOICE_CLIP_AUDIO",
"SyncResult",
"sync_all_wechat",
"sync_call_recordings",
"sync_departments",
"sync_message_images",
"sync_phone_calls",
"sync_sms",
"sync_voice_clip_audio",
@@ -157,6 +160,12 @@ ENTITY_VOICE_CLIP = "voice_clip"
# object store (records a SyncRun with this entity_type; no SyncState row
# because the voice_clip table itself is the cursor). Mirrors call_recordings.
ENTITY_VOICE_CLIP_AUDIO = "voice_clip_audio"
# Message image / media localization pass: not a scrape — reads already-mirrored
# ``message`` rows whose ``file_url`` / ``file_th_url`` / ``file_hd_url`` point
# at OSS and have no ``media_hash`` yet, downloads each into the local object
# store (records a SyncRun with this entity_type; no SyncState row because the
# message table itself is the cursor). Mirrors call_recordings / voice_clip_audio.
ENTITY_MESSAGE_IMAGES = "message_images"
CHANNEL_WECHAT = "wechat"
CHANNEL_WHATSAPP = "whatsapp"
@@ -1152,6 +1161,60 @@ async def sync_voice_clip_audio(
)
async def sync_message_images(
session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> SyncResult:
"""Download localized copies of chat-message media for already-mirrored
messages.
Not a scrape: reads local ``message`` rows whose ``file_url`` /
``file_th_url`` / ``file_hd_url`` point at OSS, have ``type`` in
('image', 'video', 'file'), are on the wechat / whatsapp channel, and have
no ``media_hash`` yet; downloads each and links it. Records a ``SyncRun``
(``entity_type='message_images'``); ``fetched`` = messages needing
download, ``upserted`` = messages localized.
Mirrors :func:`sync_call_recordings` / :func:`sync_voice_clip_audio`
exactly (same commit semantics): commits its own ``SyncRun`` row because
:func:`download_message_images` commits per batch internally, so the run
bookkeeping must land in the same transaction boundary to stay consistent
with what was actually persisted.
Failure policy mirrors every other ``sync_*``: a downloader exception is
recorded as a ``status='failed'`` ``SyncRun`` and a failed
:class:`SyncResult` is returned (no re-raise), so a broken image pass does
NOT abort a multi-entity batch.
"""
run = await _start_run(session, ENTITY_MESSAGE_IMAGES)
try:
attempted, localized = await download_message_images(
client, store, session
)
await _finish_run(
session, run, status="success", fetched=attempted, upserted=localized
)
await session.commit()
return SyncResult(
entity=ENTITY_MESSAGE_IMAGES,
status="success",
fetched=attempted,
upserted=localized,
)
except Exception as exc:
logger.exception("sync_message_images failed")
await _finish_run(
session, run, status="failed", fetched=0, upserted=0, error=repr(exc)
)
await session.commit()
return SyncResult(
entity=ENTITY_MESSAGE_IMAGES,
status="failed",
fetched=0,
upserted=0,
error=repr(exc),
)
# ---------------------------------------------------------------------------
# Coordinator
# ---------------------------------------------------------------------------
+16
View File
@@ -57,6 +57,7 @@ from app.sync.orchestrator import (
sync_all_wechat,
sync_call_recordings,
sync_departments,
sync_message_images,
sync_phone_calls,
sync_sms,
sync_voice_clip_audio,
@@ -218,6 +219,13 @@ async def _run_voice_clip_audio(
return [await sync_voice_clip_audio(session, client, store)]
async def _run_message_images(
session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> list[SyncResult]:
"""Image / media localization pass for already-mirrored ``message`` rows."""
return [await sync_message_images(session, client, store)]
async def _run_messages(
session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> list[SyncResult]:
@@ -301,6 +309,13 @@ async def _run_all(
# in the daily job means no later in-batch entity can be affected by the
# mid-batch commit boundary.
results.append(await sync_voice_clip_audio(session, client, store))
# Message image / media localization: download OSS images / videos / files
# for already-mirrored ``message`` rows. Runs AFTER messages (synced
# earlier in this same daily job via _run_all_wechat + _run_wa_messages)
# so freshly-scraped rows are eligible. Commits internally (mirrors
# call_recordings / voice_clip_audio) — also placed at the tail of the
# daily job for the same mid-batch commit-boundary reason.
results.append(await sync_message_images(session, client, store))
return results
@@ -324,6 +339,7 @@ ENTITY_FUNCS: dict[
"voice_clips": _run_voice_clips,
"call_recordings": _run_call_recordings,
"voice_clip_audio": _run_voice_clip_audio,
"message_images": _run_message_images,
"messages": _run_messages,
"wa_messages": _run_wa_messages,
"all_wechat": _run_all_wechat,
@@ -0,0 +1,35 @@
"""message media_hash
Revision ID: e5f6a7b8c9d0
Revises: c4d5e6f7a8b9
Create Date: 2026-08-11 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'e5f6a7b8c9d0'
down_revision: str | Sequence[str] | None = 'c4d5e6f7a8b9'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# sha256 media_hash of the localized media blob (FK -> Media.media_hash).
# NULL until sync_message_images downloads it. batch_alter_table keeps
# this portable across SQLite (dev/test, table-recreate) and MySQL (prod,
# direct ALTER). Mirrors the voice_clip.media_hash + phone_call.record_media_hash
# migrations.
with op.batch_alter_table('message', schema=None) as batch_op:
batch_op.add_column(sa.Column('media_hash', sa.String(length=64), nullable=True))
def downgrade() -> None:
"""Downgrade schema."""
with op.batch_alter_table('message', schema=None) as batch_op:
batch_op.drop_column('media_hash')
@@ -0,0 +1,375 @@
"""Tests for :func:`app.scraper.media.download_message_images`.
Covers the message-media batch downloader that localizes chat-message
``file_url`` / ``file_th_url`` / ``file_hd_url`` OSS URLs into the local
object store, writes a ``Media`` row per blob, and links the message back via
``media_hash``.
Mirrors ``test_recording_downloader.py``:
* Happy path: one image message -> thumbnail (``file_th_url``) is downloaded,
stored, upserted, linked.
* Thumbnail preference: image with both ``file_url`` and ``file_th_url`` set
-> the thumbnail URL is fetched (not the original).
* Idempotent: a message already carrying ``media_hash`` is excluded from the
candidate set (``attempted == 0``).
* Resilient: a persistent 500 does NOT hang the loop (the per-run ``tried``
set excludes the failing msg_id from later batches) and returns
``(attempted=1, localized=0)`` with no exception escaping.
* Filter: a text message with an http URL (e.g. a documentation link) is
NOT selected (only ``type IN ('image','video','file')`` rows are eligible).
"""
from __future__ import annotations
import hashlib
import httpx
import respx
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.db.models import Media, Message
from app.scraper.media import download_message_images
from app.session.yunke_client import YunkeClient
from app.storage.object_store import ObjectStore
BASE = "https://crm2.yunkecn.com"
# Realistic OSS image URL shapes (yunke OSS uses ``wx.oss.yunkecn.com``; the
# downloader is URL-agnostic — any http(s) URL works through the mocked
# transport). Thumbnail + original + hd variants of the same asset.
OSS_THUMB = "https://wx.oss.yunkecn.com/wechat/img/abc_thumb.jpg"
OSS_FULL = "https://wx.oss.yunkecn.com/wechat/img/abc.jpg"
OSS_HD = "https://wx.oss.yunkecn.com/wechat/img/abc_hd.jpg"
USERID = "a" * 32
COMPANY = "8m0atd"
# ---------------------------------------------------------------------------
# Shared helpers (mirror tests/test_recording_downloader.py)
# ---------------------------------------------------------------------------
def _primed_client() -> YunkeClient:
"""A YunkeClient already in a logged-in state (login flow skipped)."""
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
async def _make_engine():
"""Create an in-memory async SQLite engine with all tables created."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine
# ---------------------------------------------------------------------------
# download_message_images
# ---------------------------------------------------------------------------
@respx.mock
async def test_download_message_images_localizes_and_links(tmp_path):
"""One image message with an OSS thumb url: downloaded, stored, linked.
Returns ``(1, 1)``; the message's ``media_hash`` is the sha256 of the
fetched bytes; a ``Media`` row exists with ``media_type='image'``,
``mime_type='image/jpeg'``, ``original_url == OSS_FULL``; the file exists
under ``tmp_path`` at the sha256-content-addressed ``.jpg`` path.
"""
store = ObjectStore(tmp_path)
engine = await _make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
blob = b"\xff\xd8\xff\xe0thumb-jpg-payload"
respx.get(OSS_THUMB).mock(return_value=httpx.Response(200, content=blob))
client = _primed_client()
try:
async with SessionLocal() as session:
session.add(
Message(
msg_id="m1",
channel="wechat",
type="image",
file_url=OSS_FULL,
file_th_url=OSS_THUMB,
file_hd_url=OSS_HD,
)
)
await session.commit()
async with SessionLocal() as session:
attempted, localized = await download_message_images(
client, store, session
)
await session.commit()
assert (attempted, localized) == (1, 1)
# File landed on disk at the sha256-content-addressed path with .jpg.
expected_sha = hashlib.sha256(blob).hexdigest()
expected_path = tmp_path / expected_sha[:2] / f"{expected_sha}.jpg"
assert expected_path.is_file()
assert expected_path.read_bytes() == blob
async with SessionLocal() as session:
msg = (
await session.execute(
sa.select(Message).where(Message.msg_id == "m1")
)
).scalar_one()
assert msg.media_hash == expected_sha
media = (
await session.execute(
sa.select(Media).where(Media.media_hash == expected_sha)
)
).scalar_one()
assert media.media_type == "image"
assert media.mime_type == "image/jpeg"
# All three OSS URLs are preserved on the Media row.
assert media.original_url == OSS_FULL
assert media.original_th_url == OSS_THUMB
assert media.original_hd_url == OSS_HD
assert media.local_path == str(expected_path)
assert media.size_bytes == len(blob)
finally:
await client.aclose()
await engine.dispose()
@respx.mock
async def test_image_prefers_thumbnail_url(tmp_path):
"""For ``type='image'`` rows, the THUMBNAIL URL is fetched first.
The row carries all three URLs (file_url / file_th_url / file_hd_url); the
downloader fetches ``file_th_url`` (smaller, sufficient for chat-timeline
display per spec). Verified by asserting only the thumb route was hit.
"""
store = ObjectStore(tmp_path)
engine = await _make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
thumb_blob = b"thumb-bytes"
full_route = respx.get(OSS_FULL).mock(return_value=httpx.Response(200, content=b"full"))
thumb_route = respx.get(OSS_THUMB).mock(return_value=httpx.Response(200, content=thumb_blob))
client = _primed_client()
try:
async with SessionLocal() as session:
session.add(
Message(
msg_id="m-pref",
channel="wechat",
type="image",
file_url=OSS_FULL,
file_th_url=OSS_THUMB,
)
)
await session.commit()
async with SessionLocal() as session:
attempted, localized = await download_message_images(
client, store, session
)
await session.commit()
assert (attempted, localized) == (1, 1)
assert thumb_route.called
assert not full_route.called
# media row is keyed by the THUMBNAIL's sha256.
expected_sha = hashlib.sha256(thumb_blob).hexdigest()
async with SessionLocal() as session:
msg = (
await session.execute(
sa.select(Message).where(Message.msg_id == "m-pref")
)
).scalar_one()
assert msg.media_hash == expected_sha
finally:
await client.aclose()
await engine.dispose()
@respx.mock
async def test_video_uses_file_url(tmp_path):
"""For ``type='video'`` rows, ``file_url`` (the original) is fetched.
The image-thumbnail preference does NOT apply to video; the original
``file_url`` is the right asset.
"""
store = ObjectStore(tmp_path)
engine = await _make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
blob = b"\x00\x00\x00 mp4-isom-payload"
video_url = "https://wx.oss.yunkecn.com/wechat/vid/abc.mp4"
respx.get(video_url).mock(return_value=httpx.Response(200, content=blob))
client = _primed_client()
try:
async with SessionLocal() as session:
session.add(
Message(
msg_id="m-vid",
channel="wechat",
type="video",
file_url=video_url,
)
)
await session.commit()
async with SessionLocal() as session:
attempted, localized = await download_message_images(
client, store, session
)
await session.commit()
assert (attempted, localized) == (1, 1)
expected_sha = hashlib.sha256(blob).hexdigest()
expected_path = tmp_path / expected_sha[:2] / f"{expected_sha}.mp4"
assert expected_path.is_file()
async with SessionLocal() as session:
msg = (
await session.execute(
sa.select(Message).where(Message.msg_id == "m-vid")
)
).scalar_one()
assert msg.media_hash == expected_sha
finally:
await client.aclose()
await engine.dispose()
@respx.mock
async def test_idempotent_skip(tmp_path):
"""A message already carrying ``media_hash`` is excluded -> attempted 0.
The WHERE clause filters ``media_hash IS NULL``, so a previously localized
message is never re-fetched.
"""
store = ObjectStore(tmp_path)
engine = await _make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
client = _primed_client()
try:
async with SessionLocal() as session:
session.add(
Message(
msg_id="m2",
channel="wechat",
type="image",
file_url=OSS_FULL,
file_th_url=OSS_THUMB,
media_hash="already",
)
)
await session.commit()
async with SessionLocal() as session:
attempted, localized = await download_message_images(
client, store, session
)
await session.commit()
assert (attempted, localized) == (0, 0)
# No network was touched: the candidate set was empty.
assert respx.calls.call_count == 0
finally:
await client.aclose()
await engine.dispose()
@respx.mock
async def test_resilient_on_failure_and_no_infinite_loop(tmp_path):
"""A persistent 500: no exception, loop TERMINATES, ``(1, 0)`` returned.
This is the key guard for the per-run ``tried`` set: without it, the
failing row would stay ``media_hash IS NULL`` and be re-selected by every
subsequent batch forever, hanging the run. The ``tried`` set excludes
the attempted msg_id from later batches, so the next SELECT returns an
empty page and the loop breaks.
"""
store = ObjectStore(tmp_path)
engine = await _make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
respx.get(OSS_THUMB).mock(return_value=httpx.Response(500))
client = _primed_client()
try:
async with SessionLocal() as session:
session.add(
Message(
msg_id="m3",
channel="wechat",
type="image",
file_th_url=OSS_THUMB,
)
)
await session.commit()
async with SessionLocal() as session:
attempted, localized = await download_message_images(
client, store, session
)
await session.commit()
assert (attempted, localized) == (1, 0)
# The failing message is left un-localized (retried on the NEXT run).
async with SessionLocal() as session:
msg = (
await session.execute(
sa.select(Message).where(Message.msg_id == "m3")
)
).scalar_one()
assert msg.media_hash is None
finally:
await client.aclose()
await engine.dispose()
@respx.mock
async def test_text_message_with_url_is_not_selected(tmp_path):
"""A text message whose text happens to be a URL is NOT selected.
The candidate filter requires ``type IN ('image','video','file')``; a
text message is skipped even if its columns somehow carry an http URL.
"""
store = ObjectStore(tmp_path)
engine = await _make_engine()
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
client = _primed_client()
try:
async with SessionLocal() as session:
session.add(
Message(
msg_id="m-text",
channel="wechat",
type="text",
text="see https://wx.oss.yunkecn.com/wechat/img/abc.jpg",
# Even with a file_url, type='text' disqualifies the row.
file_url=OSS_FULL,
)
)
await session.commit()
async with SessionLocal() as session:
attempted, localized = await download_message_images(
client, store, session
)
await session.commit()
assert (attempted, localized) == (0, 0)
assert respx.calls.call_count == 0
finally:
await client.aclose()
await engine.dispose()
+7
View File
@@ -282,6 +282,13 @@ export interface Message {
sender_head_url?: string | null
/** ms-epoch */
timestamp?: number | null
/**
* sha256 media_hash of the localized media blob (NULL until the
* message_images sync pass downloads it). When present, MessageBubble
* renders via `/api/media/${media_hash}` (local-first); when absent, it
* falls back to the OSS file_url / file_th_url / file_hd_url.
*/
media_hash?: string | null
is_del?: boolean | null
/**
* Verbatim upstream message record (camelCase keys: msgSvrId/keyId, type,
+30 -4
View File
@@ -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"
>