本次提交将woc-bridge项目重构为模块化包结构,按职责拆分多个子域: 1. 新增models层定义所有Pydantic数据模型与统一错误体系 2. 拆分db/ui/messaging/routes等业务域模块 3. 实现基础API路由:状态查询、截图、登录、媒体获取等 4. 重构tools脚本的模块导入路径 5. 补充版本号与能力清单定义 6. 完善全局配置与依赖管理 整体完成项目从单文件脚本到可维护的包结构迁移,为后续功能开发打下基础。
131 lines
4.7 KiB
Python
131 lines
4.7 KiB
Python
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)
|