修复后台聊天表情发送易卡住问题\n\n- 将后台表情发送改为异步提交,避免请求线程同步等待导致卡住\n- 增加按 md5 反查历史表情 total_length 的兜底逻辑\n- 为 SendEmoji 增加超时与详细日志,便于定位接口无响应问题

This commit is contained in:
liuwei
2026-04-27 11:21:11 +08:00
parent 7d2ad5b3d8
commit 19411d19c8
3 changed files with 125 additions and 17 deletions

View File

@@ -203,6 +203,73 @@ def _extract_emoji_meta(attachment_url: str, image_path: str):
return md5, total_length
def _parse_positive_int(value):
"""将任意输入尽量解析为正整数,失败时返回 0。
说明:
1. 前端可能传 total_length / totalLength / len类型也可能是字符串
2. 统一在这里收口,避免每个分支都重复写 try/except。
"""
try:
parsed = int(value)
except Exception:
return 0
return parsed if parsed > 0 else 0
def _get_emoji_asset_by_md5(message_storage, md5: str):
"""从消息存储中按 md5 反查表情原始记录。
说明:
1. 优先走 message_storage 自身方法,兼容未来把查询逻辑上移;
2. 若当前实例没有该方法,则回退到底层 message_db
3. 查不到时返回 None让上层决定是否报错。
"""
if not message_storage or not md5:
return None
if hasattr(message_storage, "get_emoji_asset_by_md5"):
return message_storage.get_emoji_asset_by_md5(md5)
message_db = getattr(message_storage, "message_db", None)
if message_db and hasattr(message_db, "get_emoji_asset_by_md5"):
return message_db.get_emoji_asset_by_md5(md5)
return None
def _resolve_emoji_send_meta(message_storage, md5: str, total_length: int):
"""补全发送表情所需的 md5 与 total_length。
说明:
1. wechat_ipad 的 SendEmoji 接口并不是只要 md5还必须带 TotalLen
2. 当前端只传了 md5 或长度为空时,这里尝试从历史消息里反查原始 XML
3. 返回值始终是“规整后的 md5 + total_length”方便发送分支直接使用。
"""
normalized_md5 = _safe_text(md5).strip().lower()
normalized_total_length = _parse_positive_int(total_length)
if not re.fullmatch(r"[0-9a-f]{16,64}", normalized_md5):
return "", 0
if normalized_total_length > 0:
return normalized_md5, normalized_total_length
asset = _get_emoji_asset_by_md5(message_storage, normalized_md5)
if not asset:
return normalized_md5, 0
resolved_md5, resolved_total_length = _extract_emoji_meta(
_safe_text(asset.get("attachment_url")),
_safe_text(asset.get("image_path"))
)
if resolved_md5 and resolved_md5 != normalized_md5:
# 历史数据如果出现大小写或异常值,以前端传入的 md5 为准,避免串表情。
logger.warning(f"表情参数回填命中 md5 不一致request_md5={normalized_md5}, record_md5={resolved_md5}")
return normalized_md5, _parse_positive_int(resolved_total_length)
def _normalize_recent_message(server, raw_message: dict, chat_type: str, target_wxid: str):
sender = _safe_text(raw_message.get("sender")).strip()
message_type = str(raw_message.get("message_type", ""))
@@ -830,20 +897,30 @@ def api_send_message():
elif msg_type == 'emoji':
if not isinstance(content, dict):
return jsonify({'success': False, 'message': '表情参数格式错误'})
md5 = _safe_text(content.get('md5')).strip().lower()
total_length = int(content.get('total_length') or 0)
if not re.fullmatch(r"[0-9a-f]{16,64}", md5) or total_length <= 0:
return jsonify({'success': False, 'message': '缺少表情 md5 或长度'})
try:
loop = get_or_create_loop()
future = asyncio.run_coroutine_threadsafe(server.client.send_emoji_message(wxid, md5, total_length), loop)
future.result(timeout=20)
except Exception as e:
logger.error(f"发送表情失败 md5={md5} len={total_length} wxid={wxid}: {e}")
return jsonify({'success': False, 'message': f'表情发送失败: {str(e)}'}), 500
# 表情发送必须同时具备 md5 和 total_length。
# 当前前端有时只拿得到 md5因此这里优先使用请求体里的长度
# 拿不到时再去历史消息表里反查,避免“参数明明看起来对,但接口还是发不出去”。
md5, total_length = _resolve_emoji_send_meta(
getattr(server, "message_storage", None),
content.get('md5'),
content.get('total_length') or content.get('totalLength') or content.get('len')
)
if not md5:
return jsonify({'success': False, 'message': '表情 md5 格式不正确'})
if total_length <= 0:
return jsonify({'success': False, 'message': '该表情缺少 total_length无法仅凭 md5 发送'})
# 表情发送改为和文本/图片一致的异步提交通道,避免 HTTP 请求线程
# 同步等待队列结果导致“高概率卡住”的体验问题。
logger.info(f"提交表情发送任务 wxid={wxid} md5={md5} total_length={total_length}")
send_message_in_thread(server.client.send_emoji_message, wxid, md5, total_length)
return jsonify({
'success': True,
'message': '表情发送成功'
'message': '表情消息已提交到 iPad 通道',
'data': {
'md5': md5,
'total_length': total_length
}
})
else:

View File

@@ -645,6 +645,26 @@ class MessageStorageDB(BaseDBOperator):
"""
return self.execute_query(sql, (limit,)) or []
def get_emoji_asset_by_md5(self, md5: str) -> Optional[Dict]:
"""根据表情 md5 精确查找最近一条表情消息。
说明:
1. 后台聊天发送表情时,前端偶尔只能拿到 md5拿不到 total_length
2. wechat_ipad 的 SendEmoji 接口要求同时提供 Md5 和 TotalLen
3. 这里直接从历史消息表里按 md5 反查最近一条原始记录,给发送接口补全长度。
"""
sql = """
SELECT message_id, group_id, sender, timestamp, message_type, attachment_url, image_path
FROM messages
WHERE message_type IN ('47', '1048625', '1090519089')
AND attachment_url IS NOT NULL
AND attachment_url <> ''
AND attachment_url LIKE %s
ORDER BY timestamp DESC
LIMIT 1
"""
return self.execute_query(sql, (f'%md5="{md5}"%',), fetch_one=True)
def get_messages_by_date_range(self, group_id: str, start_date: str, end_date: str = None,
min_content_length: int = 6, max_results: int = 5000) -> List[Dict]:
"""按日期范围获取消息(支持按天总结)

View File

@@ -506,15 +506,26 @@ class MessageMixin(WechatAPIClientBase):
if not self.wxid:
raise UserLoggedOut("请先登录")
async with aiohttp.ClientSession() as session:
# 表情发送接口历史上最容易出现“接口长时间不返回,导致整个消息队列被拖住”的问题,
# 因此这里单独加总超时和更细的日志,方便区分“参数错误”和“接口无响应”两类故障。
timeout = aiohttp.ClientTimeout(total=20)
async with aiohttp.ClientSession(timeout=timeout) as session:
json_param = {"Wxid": self.wxid, "ToWxid": wxid, "Md5": md5, "TotalLen": total_length}
response = await session.post(f'http://{self.ip}:{self.port}/api/Msg/SendEmoji', json=json_param)
json_resp = await response.json()
try:
self.logging.info("开始发送表情消息: 对方wxid:{} md5:{} 总长度:{}", wxid, md5, total_length)
response = await session.post(f'http://{self.ip}:{self.port}/api/Msg/SendEmoji', json=json_param)
json_resp = await response.json(content_type=None)
except asyncio.TimeoutError as exc:
self.logging.error("发送表情消息超时: 对方wxid:{} md5:{} 总长度:{}", wxid, md5, total_length)
raise TimeoutError("SendEmoji 接口调用超时") from exc
if json_resp.get("Success"):
self.logging.info("发送表情消息: 对方wxid:{} md5:{} 总长度:{}", wxid, md5, total_length)
return json_resp.get("Data").get("emojiItem")
data = json_resp.get("Data") or {}
self.logging.info("发送表情消息成功: 对方wxid:{} md5:{} 总长度:{}", wxid, md5, total_length)
return data.get("emojiItem") or data.get("EmojiItem") or data
else:
self.logging.error("发送表情消息失败: 对方wxid:{} md5:{} 总长度:{} resp:{}",
wxid, md5, total_length, json_resp)
self.error_handler(json_resp)
async def send_card_message(self, wxid: str, card_wxid: str, card_nickname: str, card_alias: str = "") -> tuple[