WechatOnCloud/bridge/woc_bridge/routes/media.py
Kris 102b98adea refactor: 完成项目包结构重构与基础模块搭建
本次提交将woc-bridge项目重构为模块化包结构,按职责拆分多个子域:
1. 新增models层定义所有Pydantic数据模型与统一错误体系
2. 拆分db/ui/messaging/routes等业务域模块
3. 实现基础API路由:状态查询、截图、登录、媒体获取等
4. 重构tools脚本的模块导入路径
5. 补充版本号与能力清单定义
6. 完善全局配置与依赖管理

整体完成项目从单文件脚本到可维护的包结构迁移,为后续功能开发打下基础。
2026-07-08 23:25:58 +08:00

131 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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): 未找到联系人或无有效头像 URLHTTP 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-Typeimage/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): 未找到微信消息 DBHTTP 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)