from __future__ import annotations import asyncio import logging import mimetypes import urllib.error import urllib.request from typing import Optional from fastapi import APIRouter from fastapi.responses import Response from woc_bridge.config import _require_db_reader from woc_bridge.db.coordinator import _check_db_readable, with_db_retry from woc_bridge.models import BridgeError logger = logging.getLogger("woc-bridge") router = APIRouter() # --------------------------------------------------------------------------- # 路由:GET /api/media/avatar/{wxid} # --------------------------------------------------------------------------- # 注意:此路由必须在 /api/media/{msg_id} 之前声明,否则会被 {msg_id} # 匹配到(FastAPI 按声明顺序匹配,先匹配先得)。 @router.get("/api/media/avatar/{wxid}") @with_db_retry async def get_avatar(wxid: str) -> Response: """获取联系人头像。 最小实现:优先从 contact.db 读取 big_head_url / small_head_url / avatar_url, 若存在 HTTP(S) 链接则拉取并透传二进制;否则返回 MEDIA_NOT_FOUND。 本地 .dat 头像缓存解密仍留给后续 W-04 完善。 Args: wxid: 路径参数,联系人 wxid Returns: Response:头像二进制内容 + Content-Type Raises: BridgeError(MEDIA_NOT_FOUND): 未找到联系人或无有效头像 URL(HTTP 404) BridgeError(DB_ENCRYPTED): DB 已加密,无法读取联系人表(HTTP 503) Notes: - 路由声明顺序敏感:必须在 /api/media/{msg_id} 之前,否则 FastAPI 会把 "avatar" 当作 msg_id 匹配 """ db_reader = _require_db_reader() # DB 不可读时统一返回 503,行为与 /api/media/{msg_id} 一致 await _check_db_readable() contact = await asyncio.to_thread(db_reader.get_contact_detail, wxid) url: Optional[str] = None if contact: url = ( contact.get("big_head_url") or contact.get("small_head_url") or contact.get("avatar_url") ) if not url or not url.startswith(("http://", "https://")): raise BridgeError( code="MEDIA_NOT_FOUND", message=f"未找到联系人头像 URL: {wxid}", ) try: data = await asyncio.to_thread(_fetch_avatar_url, url) except Exception as e: logger.warning("avatar: wxid=%s url=%s 拉取失败: %s", wxid, url, e) raise BridgeError( code="MEDIA_NOT_FOUND", message=f"头像下载失败: {wxid}", ) mime = mimetypes.guess_type(url)[0] or "image/jpeg" logger.info("avatar: wxid=%s url=%s → %d bytes (%s)", wxid, url, len(data), mime) return Response(content=data, media_type=mime) def _fetch_avatar_url(url: str) -> bytes: """同步拉取头像 URL 二进制内容。""" with urllib.request.urlopen(url, timeout=15) as resp: return resp.read() # --------------------------------------------------------------------------- # 路由:GET /api/media/{msg_id} # --------------------------------------------------------------------------- @router.get("/api/media/{msg_id}") @with_db_retry async def get_media(msg_id: str) -> Response: """下载消息媒体文件(图片/语音/视频/文件)。 Args: msg_id: 路径参数,消息 ID(来自 /api/messages/since 返回的 msg_id) Returns: Response:二进制内容 + Content-Type(image/jpeg / audio/amr / video/mp4 / application/octet-stream 等) Raises: BridgeError(MEDIA_NOT_FOUND): 媒体文件未找到或解析失败(HTTP 404) BridgeError(DB_ENCRYPTED): DB 已加密,无法查 media 表(HTTP 503) BridgeError(DB_NOT_FOUND): 未找到微信消息 DB(HTTP 500) Notes: - 前置调 _check_db_readable(),DB 加密时直接返回 503 DB_ENCRYPTED, 不再走 sqlite3.DatabaseError 被兜底为 500 的旧路径 - DbReader.get_media_bytes 内部:DB 查 msg_id → 找对应 .dat 文件 → XOR 解密 → 返回 (data, mime) - 大文件可能阻塞较久,建议调用方设置合理的 timeout """ db_reader = _require_db_reader() # 前置 DB 可达性检查:加密时直接返回 503,避免 sqlite3 异常被兜底 500 await _check_db_readable() result = await asyncio.to_thread(db_reader.get_media_bytes, msg_id) if result is None: logger.info("media: msg_id=%s → 未找到", msg_id) raise BridgeError( code="MEDIA_NOT_FOUND", message=f"未找到媒体文件: {msg_id}", ) data, mime = result logger.info("media: msg_id=%s → %d bytes (%s)", msg_id, len(data), mime) return Response(content=data, media_type=mime)