WechatOnCloud/bridge/woc_bridge/routes/moments.py
Kris c45282f094 feat: 新增好友自动通过、UI自动化能力与多分辨率适配
- 新增登录状态守卫后台任务
- 新增好友申请自动通过规则引擎
- 新增多分辨率UI配置与模板资源
- 新增消息拉取复合游标支持
- 优化发送队列与UI自动化逻辑
- 新增批量发送日志与错误处理
- 优化Docker镜像构建与ptrace初始化
- 新增联系人名称缓存预热
2026-07-17 18:10:31 +08:00

717 lines
27 KiB
Python
Raw Permalink 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 os
import time
from typing import Optional
from fastapi import APIRouter
from woc_bridge.config import _require_send_queue, _require_xdotool, _require_db_reader
from woc_bridge.db.coordinator import with_db_retry
from woc_bridge.models import (
BridgeError,
MomentPublishRequest,
MomentPublishResponse,
MomentShareArticleRequest,
MomentShareArticleResponse,
MomentsTimelineResponse,
MomentLikeRequest,
MomentLikeResponse,
MomentCommentRequest,
MomentCommentResponse,
MomentDeleteRequest,
MomentDeleteResponse,
MomentPublishImageRequest,
MomentPublishImageResponse,
)
logger = logging.getLogger("woc-bridge")
router = APIRouter()
# 路径白名单:仅允许容器内安全目录的图片(用于 publish_image 防路径穿越)
_SAFE_IMAGE_DIRS = (
"/config/",
"/tmp/",
"/data/",
)
# ---------------------------------------------------------------------------
# 路由GET /api/moments/timeline
# ---------------------------------------------------------------------------
@router.get("/api/moments/timeline", response_model=MomentsTimelineResponse)
@with_db_retry
async def get_moments_timeline(
cursor: int = 0,
limit: int = 20,
) -> MomentsTimelineResponse:
"""读取朋友圈时间线。
WeChat 4.x 朋友圈 schema 未公开,本接口做容错探测:
1. 探测 moment/sns DB 文件
2. 探测朋友圈表名与列名
3. 按 create_time 降序返回
因 schema 不确定,响应中含 status 字段标识探测结果:
- ok正常读取
- db_not_found未找到朋友圈 DB可能该账号从未发过/看过朋友圈)
- table_not_found / no_columns / no_time_columnDB 存在但结构不匹配
- db_encryptedDB 加密且无法解密
- query_error查询异常
Args:
cursor: 游标,首次传 0取 create_time < cursor 的更旧朋友圈
limit: 最多返回条数1~50默认 20
Returns:
MomentsTimelineResponse含 moments / next_cursor / has_more / status
Raises:
BridgeError(INVALID_PARAMS): limit 越界HTTP 400
BridgeError(DB_ENCRYPTED): DB 加密且 key 提取失败HTTP 503
由 with_db_retry 触发重试)
Notes:
- 不抛 DB_NOT_FOUND朋友圈 DB 不存在属正常情况(新账号),
通过 status=db_not_found 告知客户端
- content 仅为文字内容;图片/视频等媒体字段暂未解析
- author_wxid 可能为空DB 中无对应列或值为空)
"""
if limit < 1 or limit > 50:
raise BridgeError(
code="INVALID_PARAMS",
message=f"limit 必须在 1~50 之间,收到 {limit}",
)
if cursor < 0:
raise BridgeError(
code="INVALID_PARAMS",
message=f"cursor 必须 >= 0收到 {cursor}",
)
db_reader = _require_db_reader()
result = await asyncio.to_thread(
db_reader.get_moments_timeline, cursor, limit
)
moment_count = len(result.get("moments", []))
logger.info(
"moments/timeline: cursor=%d limit=%d → 返回 %d 条, status=%s",
cursor, limit, moment_count, result.get("status"),
)
return MomentsTimelineResponse(**result)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/publish
# ---------------------------------------------------------------------------
@router.post("/api/moments/publish", response_model=MomentPublishResponse)
async def publish_moment(req: MomentPublishRequest) -> MomentPublishResponse:
"""发表纯文字朋友圈。
通过 xdotool 模拟 UI 操作发表朋友圈:激活窗口 → 点击朋友圈入口 →
点相机 → 选发表文字 → 粘贴内容 → 发表 → 返回主界面。经 send_queue
串行执行,避免与发消息等 UI 操作竞态。
Args:
req: MomentPublishRequestcontent 朋友圈文字内容)
Returns:
MomentPublishResponsesuccess=true / local_moment_id
/ placeholder=false真实执行 UI 发表操作)
Raises:
BridgeError(INVALID_PARAMS): content 为空HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND): 微信窗口未找到HTTP 503
BridgeError(SEND_FAILED): UI 操作失败或超时HTTP 500
Notes:
- 不走 with_db_retry发朋友圈是纯 UI 操作,不读 DB
- 经 send_queue 串行执行:朋友圈与发消息共享同一 X 会话,
必须串行避免窗口焦点竞态
- UI 路径(朋友圈入口/相机/发表按钮坐标)基于微信 4.0 Linux 默认
布局估算,需在目标分辨率实测调优
- local_moment_id 由 bridge 本地生成,不对应微信原生朋友圈 ID
仅用于客户端幂等去重
"""
xdotool = _require_xdotool()
send_queue = _require_send_queue()
t_total = time.perf_counter()
# 校验请求体
if not req.content:
logger.warning("moments/publish: 参数校验失败 — content 为空")
raise BridgeError(code="INVALID_PARAMS", message="content 不能为空")
logger.info("moments/publish: 收到请求 content_len=%d", len(req.content))
# 检查登录态
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/publish: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/publish: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法发表朋友圈",
)
# 经发送队列串行执行(避免与发消息 UI 竞态)
logger.info("moments/publish: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
local_moment_id = await send_queue.enqueue(
lambda: xdotool.publish_moment(req.content)
)
logger.info(
"moments/publish: 队列执行完成 → local_moment_id=%s (%.0fms)",
local_moment_id, (time.perf_counter() - t0) * 1000,
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"发表朋友圈失败: {e}",
)
logger.info(
"moments/publish: content_len=%d → 成功 local_moment_id=%s (总耗时 %.0fms)",
len(req.content), local_moment_id,
(time.perf_counter() - t_total) * 1000,
)
return MomentPublishResponse(
success=True,
local_moment_id=local_moment_id,
placeholder=False,
error=None,
)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/share_article
# ---------------------------------------------------------------------------
@router.post("/api/moments/share_article", response_model=MomentShareArticleResponse)
async def share_article_moment(req: MomentShareArticleRequest) -> MomentShareArticleResponse:
"""分享公众号文章到朋友圈。
通过搜索公众号名称进入会话,点击第 N 篇推送文章卡片打开文章页,
再经「分享到朋友圈」UI 路径生成带卡片预览的朋友圈。经 send_queue
串行执行,避免与发消息等 UI 操作竞态。
Args:
req: MomentShareArticleRequestpublic_account + article_index + 可选 comment
Returns:
MomentShareArticleResponsesuccess=true / local_moment_id
/ placeholder=false真实执行 UI 分享操作)
Raises:
BridgeError(INVALID_PARAMS): public_account 为空或 article_index < 1HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 当前未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND): 微信窗口未找到HTTP 503
BridgeError(SEND_FAILED): UI 操作失败或超时HTTP 500
Notes:
- 不走 with_db_retry分享是纯 UI 操作,不读 DB
- 经 send_queue 串行执行:与发消息共享同一 X 会话,必须串行避免
窗口焦点竞态
- UI 路径(文章卡片位置、「⋯」菜单、分享弹窗坐标)基于微信 4.0
Linux 默认布局估算,需在目标分辨率实测调优
- article_index=1 表示公众号会话顶部最新一篇推送文章
- local_moment_id 由 bridge 本地生成,不对应微信原生朋友圈 ID
仅用于客户端幂等去重
"""
xdotool = _require_xdotool()
send_queue = _require_send_queue()
t_total = time.perf_counter()
# 校验请求体
if not req.public_account:
logger.warning("moments/share_article: 参数校验失败 — public_account 为空")
raise BridgeError(code="INVALID_PARAMS", message="public_account 不能为空")
if req.article_index < 1:
logger.warning(
"moments/share_article: 参数校验失败 — article_index=%d (public_account=%s)",
req.article_index, req.public_account,
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"article_index 必须 >= 1收到 {req.article_index}",
)
logger.info(
"moments/share_article: 收到请求 public_account=%s index=%d comment_len=%d",
req.public_account, req.article_index,
len(req.comment) if req.comment else 0,
)
# 检查登录态
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/share_article: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/share_article: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法分享文章",
)
# 经发送队列串行执行(避免与发消息 UI 竞态)
logger.info("moments/share_article: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
local_moment_id = await send_queue.enqueue(
lambda: xdotool.share_article_moment(
req.public_account,
article_index=req.article_index,
comment=req.comment,
)
)
logger.info(
"moments/share_article: 队列执行完成 → local_moment_id=%s (%.0fms)",
local_moment_id, (time.perf_counter() - t0) * 1000,
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"分享文章失败: {e}",
)
logger.info(
"moments/share_article: public_account=%s index=%d comment_len=%d → 成功 local_moment_id=%s (总耗时 %.0fms)",
req.public_account, req.article_index,
len(req.comment) if req.comment else 0,
local_moment_id,
(time.perf_counter() - t_total) * 1000,
)
return MomentShareArticleResponse(
success=True,
local_moment_id=local_moment_id,
placeholder=False,
error=None,
)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/like (experimental)
# ---------------------------------------------------------------------------
@router.post("/api/moments/like", response_model=MomentLikeResponse)
async def moment_like(req: MomentLikeRequest) -> MomentLikeResponse:
"""给朋友圈点赞experimental
自动进入朋友圈页面后执行点赞操作。
Args:
req: MomentLikeRequestmoment_index1=最新)
Returns:
MomentLikeResponse
Raises:
BridgeError(INVALID_PARAMS): moment_index < 1HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
Notes:
- experimental朋友圈"赞/评论"按钮坐标为估算值
- 自动进入朋友圈页面,无需调用方预先进入
"""
t_total = time.perf_counter()
if req.moment_index < 1:
logger.warning(
"moments/like: 参数校验失败 — moment_index=%d",
req.moment_index,
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {req.moment_index}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
logger.info("moments/like: 收到请求 index=%d", req.moment_index)
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/like: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/like: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法点赞",
)
# P0 修复post_verify 与 UI 操作一起在 send_queue 内串行执行,
# 避免 after 截图被下一个 UI 操作污染send_queue 积压时 post_verify
# 的 sleep 期间会有新 UI 操作开始,导致 after 截图失真)。
async def _like_and_verify() -> bool:
await xdotool.moment_like(req.moment_index)
# Task 11: post_verify 截图 absdiff 校验点赞是否生效
try:
return await xdotool._moment.post_verify_moment_like(
None, req.moment_index
)
except Exception as exc:
logger.warning(
"moments/like: post_verify 异常 (index=%d): %s",
req.moment_index, exc,
)
return False
verified: bool | None = None
logger.info("moments/like: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
verified = await send_queue.enqueue(_like_and_verify)
logger.info(
"moments/like: 队列执行完成 → verified=%s (%.0fms)",
verified, (time.perf_counter() - t0) * 1000,
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"点赞失败: {e}",
)
logger.info(
"moments/like: index=%d → UI 操作完成 (总耗时 %.0fms)",
req.moment_index, (time.perf_counter() - t_total) * 1000,
)
return MomentLikeResponse(success=True, error=None, verified=verified)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/comment (experimental)
# ---------------------------------------------------------------------------
@router.post("/api/moments/comment", response_model=MomentCommentResponse)
async def moment_comment(req: MomentCommentRequest) -> MomentCommentResponse:
"""评论朋友圈experimental
自动进入朋友圈页面后执行评论操作。
Args:
req: MomentCommentRequest
Returns:
MomentCommentResponse
Raises:
BridgeError(INVALID_PARAMS): comment 为空或 moment_index < 1HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
Notes:
- experimental坐标为估算值
- 自动进入朋友圈页面,无需调用方预先进入
"""
t_total = time.perf_counter()
if not req.comment:
logger.warning("moments/comment: 参数校验失败 — comment 为空")
raise BridgeError(code="INVALID_PARAMS", message="comment 不能为空")
if req.moment_index < 1:
logger.warning(
"moments/comment: 参数校验失败 — moment_index=%d (comment_len=%d)",
req.moment_index, len(req.comment),
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {req.moment_index}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
db_reader = _require_db_reader()
logger.info(
"moments/comment: 收到请求 index=%d comment_len=%d",
req.moment_index, len(req.comment),
)
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/comment: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/comment: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法评论",
)
# P0 修复post_verify 与 UI 操作一起在 send_queue 内串行执行,
# 避免 after 截图被下一个 UI 操作污染(与 moment_like 同理)。
async def _comment_and_verify() -> bool:
await xdotool.moment_comment(req.comment, req.moment_index)
# Task 11: post_verify 校验评论是否写入 DBMomentsComment 表存在时)
# 或截图 absdiff 兜底(表不存在时)
try:
return await xdotool._moment.post_verify_moment_comment(
db_reader, req.comment, req.moment_index
)
except Exception as exc:
logger.warning(
"moments/comment: post_verify 异常 (index=%d): %s",
req.moment_index, exc,
)
return False
verified: bool | None = None
logger.info("moments/comment: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
verified = await send_queue.enqueue(_comment_and_verify)
logger.info(
"moments/comment: 队列执行完成 → verified=%s (%.0fms)",
verified, (time.perf_counter() - t0) * 1000,
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"评论失败: {e}",
)
logger.info(
"moments/comment: index=%d comment_len=%d → UI 操作完成 (总耗时 %.0fms)",
req.moment_index, len(req.comment),
(time.perf_counter() - t_total) * 1000,
)
return MomentCommentResponse(success=True, error=None, verified=verified)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/delete (experimental)
# ---------------------------------------------------------------------------
@router.post("/api/moments/delete", response_model=MomentDeleteResponse)
async def moment_delete(req: MomentDeleteRequest) -> MomentDeleteResponse:
"""删除自己的朋友圈experimental
自动进入朋友圈页面后执行删除操作,避免在聊天界面误删消息。
moment_index 应指向自己发的朋友圈。
Args:
req: MomentDeleteRequest
Returns:
MomentDeleteResponse
Raises:
BridgeError(INVALID_PARAMS): moment_index < 1HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
Notes:
- experimental长按位置与菜单项坐标为估算值
- 自动进入朋友圈页面,避免在聊天界面误删消息
- 只能删除自己发的朋友圈;长按别人的朋友圈不会出现"删除"
"""
t_total = time.perf_counter()
if req.moment_index < 1:
logger.warning(
"moments/delete: 参数校验失败 — moment_index=%d",
req.moment_index,
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"moment_index 必须 >= 1收到 {req.moment_index}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
db_reader = _require_db_reader()
logger.info("moments/delete: 收到请求 index=%d", req.moment_index)
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/delete: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/delete: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法删除朋友圈",
)
# P0 修复:删除前捕获朋友圈数量基线,供 post_verify 比对。
# 原实现 post_verify 只查 status=ok 就返回 True完全未校验删除生效。
baseline_count: Optional[int] = None
t0 = time.perf_counter()
try:
result = await asyncio.to_thread(db_reader.get_moments_timeline, 0, 50)
if result.get("status") == "ok":
baseline_count = len(result.get("moments", []))
logger.info(
"moments/delete: baseline_count 捕获成功 → %d (%.0fms)",
baseline_count, (time.perf_counter() - t0) * 1000,
)
else:
logger.warning(
"moments/delete: baseline_count 捕获失败status=%s (%.0fms)",
result.get("status"), (time.perf_counter() - t0) * 1000,
)
except Exception as exc:
logger.warning(
"moments/delete: baseline_count 捕获异常: %s (%.0fms)",
exc, (time.perf_counter() - t0) * 1000,
)
logger.info("moments/delete: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
await send_queue.enqueue(lambda: xdotool.moment_delete(req.moment_index))
logger.info(
"moments/delete: 队列执行完成 (%.0fms)",
(time.perf_counter() - t0) * 1000,
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"删除朋友圈失败: {e}",
)
logger.info(
"moments/delete: index=%d → UI 操作完成 (总耗时 %.0fms)",
req.moment_index, (time.perf_counter() - t_total) * 1000,
)
# Task 11: post_verify 校验 MomentsPost 记录是否已删除
verified: bool | None = None
try:
verified = await xdotool._moment.post_verify_moment_delete(
db_reader, req.moment_index, baseline_count=baseline_count
)
except Exception as exc:
logger.warning(
"moments/delete: post_verify 异常 (index=%d): %s",
req.moment_index, exc,
)
verified = False
return MomentDeleteResponse(success=True, error=None, verified=verified)
# ---------------------------------------------------------------------------
# 路由POST /api/moments/publish_image (experimental)
# ---------------------------------------------------------------------------
@router.post("/api/moments/publish_image", response_model=MomentPublishImageResponse)
async def publish_moment_with_image(
req: MomentPublishImageRequest,
) -> MomentPublishImageResponse:
"""发表带图片的朋友圈experimental
UI 自动化路径:进入朋友圈 → 点相机 → 选"发表图片" → 文件选择器
粘贴图片路径 → 可选输入文字 → 点"发表" → 返回主界面。
Args:
req: MomentPublishImageRequest
Returns:
MomentPublishImageResponse
Raises:
BridgeError(INVALID_PARAMS): image_path 为空或文件不存在HTTP 400
BridgeError(WECHAT_NOT_LOGGED_IN): 未登录HTTP 401
BridgeError(WINDOW_NOT_FOUND / SEND_FAILED)
Notes:
- experimental相机按钮、文件选择器、发表按钮坐标均为估算值
- image_path 必须是容器内可访问的绝对路径
"""
t_total = time.perf_counter()
if not req.image_path:
logger.warning("moments/publish_image: 参数校验失败 — image_path 为空")
raise BridgeError(code="INVALID_PARAMS", message="image_path 不能为空")
# 路径白名单:仅允许容器内安全目录的图片(防止 /etc/shadow 等敏感文件被读取)
_real = os.path.realpath(req.image_path)
if not _real.startswith(_SAFE_IMAGE_DIRS):
logger.warning(
"moments/publish_image: 路径校验失败 — image_path=%s 不在安全目录 %s",
_real, _SAFE_IMAGE_DIRS,
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"image_path 必须位于安全目录 {_SAFE_IMAGE_DIRS} 内,收到 {_real}",
)
if not os.path.isfile(req.image_path) or not os.access(req.image_path, os.R_OK):
logger.warning(
"moments/publish_image: 路径校验失败 — 图片不存在或不可读 image_path=%s",
req.image_path,
)
raise BridgeError(
code="INVALID_PARAMS",
message=f"图片不存在或不可读: {req.image_path}",
)
xdotool = _require_xdotool()
send_queue = _require_send_queue()
logger.info(
"moments/publish_image: 收到请求 image=%s content_len=%d",
req.image_path, len(req.content) if req.content else 0,
)
t0 = time.perf_counter()
login_state = await xdotool.detect_login_state()
logger.info(
"moments/publish_image: 登录态检测 → %s (%.0fms)",
login_state, (time.perf_counter() - t0) * 1000,
)
if login_state != "logged_in":
logger.warning("moments/publish_image: 拒绝,登录态=%s", login_state)
raise BridgeError(
code="WECHAT_NOT_LOGGED_IN",
message=f"当前登录态为 {login_state},无法发表朋友圈",
)
logger.info("moments/publish_image: 入队 send_queue (pending=%d)", send_queue.pending_count())
try:
t0 = time.perf_counter()
local_moment_id = await send_queue.enqueue(
lambda: xdotool.publish_moment_with_image(req.image_path, req.content)
)
logger.info(
"moments/publish_image: 队列执行完成 → local_moment_id=%s (%.0fms)",
local_moment_id, (time.perf_counter() - t0) * 1000,
)
except BridgeError:
raise
except Exception as e:
raise BridgeError(
code="SEND_FAILED",
message=f"发表图片朋友圈失败: {e}",
)
logger.info(
"moments/publish_image: image=%s content_len=%d → 成功 local_moment_id=%s (总耗时 %.0fms)",
req.image_path, len(req.content) if req.content else 0, local_moment_id,
(time.perf_counter() - t_total) * 1000,
)
return MomentPublishImageResponse(
success=True,
local_moment_id=local_moment_id,
error=None,
)