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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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