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_name: str | None = None
sender_head_url: str | None = None sender_head_url: str | None = None
timestamp: int | 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 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 timestamp: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # ms epoch
is_del: Mapped[bool | None] = mapped_column(Boolean, nullable=True) 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) version: Mapped[str | None] = mapped_column(String(64), nullable=True)
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
fetched_at: Mapped[dt.datetime] = _fetched_at() fetched_at: Mapped[dt.datetime] = _fetched_at()
+139 -1
View File
@@ -37,13 +37,14 @@ import httpx
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession 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.session.yunke_client import YunkeClient
from app.storage.object_store import ObjectStore from app.storage.object_store import ObjectStore
from app.storage.repos import upsert_media from app.storage.repos import upsert_media
__all__ = [ __all__ = [
"download_call_recordings", "download_call_recordings",
"download_message_images",
"download_message_media", "download_message_media",
"download_url", "download_url",
"download_voice_clip_audio", "download_voice_clip_audio",
@@ -374,3 +375,140 @@ async def download_voice_clip_audio(
localized += 1 localized += 1
await session.commit() await session.commit()
return attempted, localized 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.call import scrape_phone_calls, scrape_sms
from app.scraper.media import ( from app.scraper.media import (
download_call_recordings, download_call_recordings,
download_message_images,
download_message_media, download_message_media,
download_voice_clip_audio, download_voice_clip_audio,
) )
@@ -92,11 +93,13 @@ logger = logging.getLogger(__name__)
__all__ = [ __all__ = [
"ENTITY_CALL_RECORDINGS", "ENTITY_CALL_RECORDINGS",
"ENTITY_MESSAGE_IMAGES",
"ENTITY_VOICE_CLIP_AUDIO", "ENTITY_VOICE_CLIP_AUDIO",
"SyncResult", "SyncResult",
"sync_all_wechat", "sync_all_wechat",
"sync_call_recordings", "sync_call_recordings",
"sync_departments", "sync_departments",
"sync_message_images",
"sync_phone_calls", "sync_phone_calls",
"sync_sms", "sync_sms",
"sync_voice_clip_audio", "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 # object store (records a SyncRun with this entity_type; no SyncState row
# because the voice_clip table itself is the cursor). Mirrors call_recordings. # because the voice_clip table itself is the cursor). Mirrors call_recordings.
ENTITY_VOICE_CLIP_AUDIO = "voice_clip_audio" 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_WECHAT = "wechat"
CHANNEL_WHATSAPP = "whatsapp" 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 # Coordinator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+16
View File
@@ -57,6 +57,7 @@ from app.sync.orchestrator import (
sync_all_wechat, sync_all_wechat,
sync_call_recordings, sync_call_recordings,
sync_departments, sync_departments,
sync_message_images,
sync_phone_calls, sync_phone_calls,
sync_sms, sync_sms,
sync_voice_clip_audio, sync_voice_clip_audio,
@@ -218,6 +219,13 @@ async def _run_voice_clip_audio(
return [await sync_voice_clip_audio(session, client, store)] 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( async def _run_messages(
session: AsyncSession, client: YunkeClient, store: ObjectStore session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> list[SyncResult]: ) -> 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 # in the daily job means no later in-batch entity can be affected by the
# mid-batch commit boundary. # mid-batch commit boundary.
results.append(await sync_voice_clip_audio(session, client, store)) 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 return results
@@ -324,6 +339,7 @@ ENTITY_FUNCS: dict[
"voice_clips": _run_voice_clips, "voice_clips": _run_voice_clips,
"call_recordings": _run_call_recordings, "call_recordings": _run_call_recordings,
"voice_clip_audio": _run_voice_clip_audio, "voice_clip_audio": _run_voice_clip_audio,
"message_images": _run_message_images,
"messages": _run_messages, "messages": _run_messages,
"wa_messages": _run_wa_messages, "wa_messages": _run_wa_messages,
"all_wechat": _run_all_wechat, "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 sender_head_url?: string | null
/** ms-epoch */ /** ms-epoch */
timestamp?: number | null 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 is_del?: boolean | null
/** /**
* Verbatim upstream message record (camelCase keys: msgSvrId/keyId, type, * Verbatim upstream message record (camelCase keys: msgSvrId/keyId, type,
+30 -4
View File
@@ -59,6 +59,19 @@ const bestMediaUrl = computed<string | undefined>(() => {
return 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 IMAGE_EXTS = new Set(['jpg', 'jpeg', 'webp', 'png', 'gif', 'bmp', 'tif', 'tiff'])
const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'm4v', 'avi', 'mkv']) const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'm4v', 'avi', 'mkv'])
const AUDIO_EXTS = new Set(['mp3', 'amr', 'silk', 'm4a', 'aac', 'wav', 'ogg']) 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' 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 * 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. * 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>(() => { const imageFullUrl = computed<string | undefined>(() => {
if (localSrc.value) return localSrc.value
const m = props.message const m = props.message
for (const u of [m.file_hd_url, m.file_url, m.file_th_url]) { for (const u of [m.file_hd_url, m.file_url, m.file_th_url]) {
if (u && HTTP_RE.test(u)) return u if (u && HTTP_RE.test(u)) return u
@@ -138,7 +164,7 @@ const typeTagLabel = computed(() => {
<div v-if="mediaKind === 'image'" @click.stop> <div v-if="mediaKind === 'image'" @click.stop>
<el-image <el-image
class="yk-msg__image" class="yk-msg__image"
:src="bestMediaUrl" :src="mediaSrc"
:preview-src-list="imageFullUrl ? [imageFullUrl] : []" :preview-src-list="imageFullUrl ? [imageFullUrl] : []"
:preview-teleported="true" :preview-teleported="true"
fit="cover" fit="cover"
@@ -150,7 +176,7 @@ const typeTagLabel = computed(() => {
<div v-else-if="mediaKind === 'video'" @click.stop> <div v-else-if="mediaKind === 'video'" @click.stop>
<video <video
class="yk-msg__video" class="yk-msg__video"
:src="bestMediaUrl" :src="mediaSrc"
controls controls
preload="metadata" preload="metadata"
/> />
@@ -159,7 +185,7 @@ const typeTagLabel = computed(() => {
<!-- 1c. audio (http URL with audio extension) — native player + chip <!-- 1c. audio (http URL with audio extension) — native player + chip
showing the duration when the message carries one. --> showing the duration when the message carries one. -->
<div v-else-if="mediaKind === 'audio'" class="yk-msg__voice" @click.stop> <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"> <span v-if="message.duration" class="yk-msg__chip">
<el-icon><Microphone /></el-icon> <el-icon><Microphone /></el-icon>
{{ formatDuration(message.duration) }} {{ formatDuration(message.duration) }}
@@ -170,7 +196,7 @@ const typeTagLabel = computed(() => {
<div v-else-if="mediaKind === 'file'" @click.stop> <div v-else-if="mediaKind === 'file'" @click.stop>
<a <a
class="yk-msg__chip yk-msg__chip--download" class="yk-msg__chip yk-msg__chip--download"
:href="bestMediaUrl" :href="mediaSrc"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
> >