mirror of
https://github.com/23Star/xianyu-super-butler.git
synced 2026-09-19 02:17:49 +08:00
11478 lines
569 KiB
Python
11478 lines
569 KiB
Python
import asyncio
|
||
import json
|
||
import re
|
||
import time
|
||
import base64
|
||
import os
|
||
import random
|
||
from enum import Enum
|
||
from loguru import logger
|
||
from utils import browser_limit
|
||
import websockets
|
||
from utils.xianyu_utils import (
|
||
decrypt, generate_mid, generate_uuid, trans_cookies,
|
||
generate_device_id, generate_sign, CAPTCHA_CHALLENGE_COOKIES
|
||
)
|
||
from app.config import (
|
||
WEBSOCKET_URL, HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT,
|
||
TOKEN_REFRESH_INTERVAL, TOKEN_RETRY_INTERVAL, COOKIES_STR,
|
||
LOG_CONFIG, AUTO_REPLY, DEFAULT_HEADERS, WEBSOCKET_HEADERS,
|
||
APP_CONFIG, API_ENDPOINTS
|
||
)
|
||
from app.config import config as cfg # 导入config实例(不是模块),使用别名避免冲突
|
||
import sys
|
||
import aiohttp
|
||
from collections import defaultdict
|
||
from app.db_manager import db_manager
|
||
from app.specification import combine_legacy_specification
|
||
from utils.log_sanitizer import redact_log_record, redact_sensitive_text
|
||
|
||
# 滑块验证补丁已废弃,使用集成的 Playwright 登录方法
|
||
# 不再需要猴子补丁,所有功能已集成到 XianyuSliderStealth 类中
|
||
|
||
class ConnectionState(Enum):
|
||
"""WebSocket连接状态枚举"""
|
||
DISCONNECTED = "disconnected" # 未连接
|
||
CONNECTING = "connecting" # 连接中
|
||
CONNECTED = "connected" # 已连接
|
||
RECONNECTING = "reconnecting" # 重连中
|
||
FAILED = "failed" # 连接失败
|
||
CLOSED = "closed" # 已关闭
|
||
|
||
|
||
class ItemListTransientError(Exception):
|
||
"""商品列表接口的临时故障(网关 5xx、响应不是 JSON 等)。
|
||
|
||
与业务错误区分开:这类故障重试就可能好,不该被写成“未返回在售分组”
|
||
之类的数据结论,否则用户会以为是账号或商品的问题。
|
||
"""
|
||
|
||
|
||
class AutoReplyPauseManager:
|
||
"""自动回复暂停管理器(人工接入后暂停该会话的自动回复)。
|
||
|
||
键必须是 (cookie_id, chat_id) 而不是单独的 chat_id。这是个全局单例,
|
||
所有账号共用;chat_id 标识的是一个会话,当用户自己的两个账号正好是同一个
|
||
会话的两端时(测试自动回复时最常见的做法),两边拿到的是同一个 chat_id。
|
||
于是 A 账号手动发一条消息,就会把 B 账号对这个会话的自动回复一起停掉,
|
||
表现为「关键词明明配好了却不回」,而唯一线索只有一行 info 日志。
|
||
"""
|
||
def __init__(self):
|
||
# {(cookie_id, chat_id): pause_until_timestamp}
|
||
self.paused_chats = {}
|
||
|
||
@staticmethod
|
||
def _key(cookie_id: str, chat_id: str):
|
||
return (str(cookie_id), str(chat_id))
|
||
|
||
def pause_chat(self, chat_id: str, cookie_id: str):
|
||
"""暂停指定账号下该 chat_id 的自动回复,使用账号特定的暂停时间"""
|
||
# 获取账号特定的暂停时间
|
||
try:
|
||
from app.db_manager import db_manager
|
||
pause_minutes = db_manager.get_cookie_pause_duration(cookie_id)
|
||
except Exception as e:
|
||
logger.error(f"获取账号 {cookie_id} 暂停时间失败: {e},使用默认10分钟")
|
||
pause_minutes = 10
|
||
|
||
# 如果暂停时间为0,表示不暂停
|
||
if pause_minutes == 0:
|
||
logger.info(f"【{cookie_id}】检测到手动发出消息,但暂停时间设置为0,不暂停自动回复")
|
||
return
|
||
|
||
pause_duration_seconds = pause_minutes * 60
|
||
pause_until = time.time() + pause_duration_seconds
|
||
self.paused_chats[self._key(cookie_id, chat_id)] = pause_until
|
||
|
||
# 计算暂停结束时间
|
||
end_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(pause_until))
|
||
logger.info(f"【{cookie_id}】检测到手动发出消息,chat_id {chat_id} 自动回复暂停{pause_minutes}分钟,恢复时间: {end_time}")
|
||
|
||
def is_chat_paused(self, chat_id: str, cookie_id: str) -> bool:
|
||
"""检查指定账号下该 chat_id 是否处于暂停状态"""
|
||
key = self._key(cookie_id, chat_id)
|
||
pause_until = self.paused_chats.get(key)
|
||
if pause_until is None:
|
||
return False
|
||
|
||
if time.time() >= pause_until:
|
||
# 暂停时间已过,移除记录
|
||
del self.paused_chats[key]
|
||
return False
|
||
|
||
return True
|
||
|
||
def get_remaining_pause_time(self, chat_id: str, cookie_id: str) -> int:
|
||
"""获取指定账号下该 chat_id 的剩余暂停时间(秒)"""
|
||
pause_until = self.paused_chats.get(self._key(cookie_id, chat_id))
|
||
if pause_until is None:
|
||
return 0
|
||
|
||
return max(0, int(pause_until - time.time()))
|
||
|
||
def cleanup_expired_pauses(self):
|
||
"""清理已过期的暂停记录"""
|
||
current_time = time.time()
|
||
expired = [key for key, pause_until in self.paused_chats.items()
|
||
if current_time >= pause_until]
|
||
|
||
for key in expired:
|
||
del self.paused_chats[key]
|
||
|
||
|
||
# 全局暂停管理器实例
|
||
pause_manager = AutoReplyPauseManager()
|
||
|
||
def log_captcha_event(cookie_id: str, event_type: str, success: bool = None, details: str = ""):
|
||
"""
|
||
简单记录滑块验证事件到txt文件
|
||
|
||
Args:
|
||
cookie_id: 账号ID
|
||
event_type: 事件类型 (检测到/开始处理/成功/失败)
|
||
success: 是否成功 (None表示进行中)
|
||
details: 详细信息
|
||
"""
|
||
try:
|
||
log_dir = 'logs'
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
log_file = os.path.join(log_dir, 'captcha_verification.txt')
|
||
|
||
timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
|
||
status = "成功" if success is True else "失败" if success is False else "进行中"
|
||
|
||
log_entry = f"[{timestamp}] 【{cookie_id}】{event_type} - {status}"
|
||
if details:
|
||
log_entry += f" - {redact_sensitive_text(details)}"
|
||
log_entry += "\n"
|
||
|
||
with open(log_file, 'a', encoding='utf-8') as f:
|
||
f.write(log_entry)
|
||
|
||
except Exception as e:
|
||
logger.error(f"记录滑块验证日志失败: {e}")
|
||
|
||
# 日志配置
|
||
log_dir = 'logs'
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
log_path = os.path.join(log_dir, f"xianyu_{time.strftime('%Y-%m-%d')}.log")
|
||
logger.remove()
|
||
logger.configure(patcher=redact_log_record)
|
||
logger.add(
|
||
log_path,
|
||
rotation=LOG_CONFIG.get('rotation', '1 day'),
|
||
retention=LOG_CONFIG.get('retention', '7 days'),
|
||
compression=LOG_CONFIG.get('compression', 'zip'),
|
||
level=LOG_CONFIG.get('level', 'DEBUG'),
|
||
format=LOG_CONFIG.get('format', '<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>'),
|
||
encoding='utf-8',
|
||
enqueue=True
|
||
)
|
||
logger.add(
|
||
sys.stdout,
|
||
level=LOG_CONFIG.get('level', 'DEBUG'),
|
||
format=LOG_CONFIG.get('format', '<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>'),
|
||
enqueue=True
|
||
)
|
||
|
||
class XianyuLive:
|
||
# 类级别的锁字典,为每个order_id维护一个锁(用于自动发货)
|
||
_order_locks = defaultdict(lambda: asyncio.Lock())
|
||
# 记录锁的最后使用时间,用于清理
|
||
_lock_usage_times = {}
|
||
# 记录锁的持有状态和释放时间 {lock_key: {'locked': bool, 'release_time': float, 'task': asyncio.Task}}
|
||
_lock_hold_info = {}
|
||
|
||
# 独立的锁字典,用于订单详情获取(不使用延迟锁机制)
|
||
_order_detail_locks = defaultdict(lambda: asyncio.Lock())
|
||
# 记录订单详情锁的使用时间
|
||
_order_detail_lock_times = {}
|
||
|
||
# 商品详情缓存(24小时有效)
|
||
_item_detail_cache = {} # {item_id: {'detail': str, 'timestamp': float, 'access_time': float}}
|
||
_item_detail_cache_lock = asyncio.Lock()
|
||
_item_detail_cache_max_size = 1000 # 最大缓存1000个商品
|
||
_item_detail_cache_ttl = 24 * 60 * 60 # 24小时TTL
|
||
|
||
# 类级别的实例管理字典,用于API调用
|
||
_instances = {} # {cookie_id: XianyuLive实例}
|
||
_instances_lock = asyncio.Lock()
|
||
|
||
# 类级别的密码登录时间记录,用于防止重复登录
|
||
_last_password_login_time = {} # {cookie_id: timestamp}
|
||
_password_login_cooldown = 60 # 密码登录冷却时间:60秒
|
||
|
||
def _safe_str(self, e):
|
||
"""安全地将异常转换为字符串"""
|
||
try:
|
||
return str(e)
|
||
except:
|
||
try:
|
||
return repr(e)
|
||
except:
|
||
return "未知错误"
|
||
|
||
def _set_connection_state(self, new_state: ConnectionState, reason: str = ""):
|
||
"""设置连接状态并记录日志"""
|
||
if self.connection_state != new_state:
|
||
old_state = self.connection_state
|
||
self.connection_state = new_state
|
||
self.last_state_change_time = time.time()
|
||
|
||
# 记录状态转换
|
||
state_msg = f"【{self.cookie_id}】连接状态: {old_state.value} → {new_state.value}"
|
||
if reason:
|
||
state_msg += f" ({reason})"
|
||
|
||
# 根据状态严重程度选择日志级别
|
||
if new_state == ConnectionState.FAILED:
|
||
logger.error(state_msg)
|
||
elif new_state == ConnectionState.RECONNECTING:
|
||
logger.warning(state_msg)
|
||
elif new_state == ConnectionState.CONNECTED:
|
||
logger.success(state_msg)
|
||
else:
|
||
logger.info(state_msg)
|
||
|
||
async def _interruptible_sleep(self, duration: float):
|
||
"""可中断的sleep,将长时间sleep拆分成多个短时间sleep,以便及时响应取消信号
|
||
|
||
Args:
|
||
duration: 总睡眠时间(秒)
|
||
"""
|
||
# 将长时间sleep拆分成多个1秒的短sleep,这样可以及时响应取消信号
|
||
chunk_size = 1.0 # 每次sleep 1秒
|
||
remaining = duration
|
||
|
||
while remaining > 0:
|
||
sleep_time = min(chunk_size, remaining)
|
||
try:
|
||
await asyncio.sleep(sleep_time)
|
||
remaining -= sleep_time
|
||
except asyncio.CancelledError:
|
||
# 如果收到取消信号,立即抛出
|
||
raise
|
||
|
||
def _reset_background_tasks(self):
|
||
"""直接重置后台任务引用,不等待取消(用于快速重连)
|
||
|
||
注意:只重置心跳任务,因为只有心跳任务依赖WebSocket连接。
|
||
其他任务(Token刷新、清理、Cookie刷新)不依赖WebSocket,可以继续运行。
|
||
"""
|
||
logger.info(f"【{self.cookie_id}】准备重置后台任务引用(仅重置依赖WebSocket的任务)...")
|
||
|
||
# 只处理心跳任务(依赖WebSocket,需要重启)
|
||
if self.heartbeat_task:
|
||
status = "已完成" if self.heartbeat_task.done() else "运行中"
|
||
logger.info(f"【{self.cookie_id}】发现心跳任务(状态: {status}),需要重置(因为依赖WebSocket连接)")
|
||
# 尝试取消心跳任务(但不等待)
|
||
if not self.heartbeat_task.done():
|
||
try:
|
||
self.heartbeat_task.cancel()
|
||
logger.debug(f"【{self.cookie_id}】已发送取消信号给心跳任务(不等待响应)")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】取消心跳任务失败: {e}")
|
||
# 重置心跳任务引用
|
||
self.heartbeat_task = None
|
||
logger.info(f"【{self.cookie_id}】心跳任务引用已重置")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】没有心跳任务需要重置")
|
||
|
||
# 检查其他任务的状态(这些任务不依赖WebSocket,不需要重启)
|
||
other_tasks_status = []
|
||
if self.token_refresh_task:
|
||
status = "已完成" if self.token_refresh_task.done() else "运行中"
|
||
other_tasks_status.append(f"Token刷新任务({status})")
|
||
if self.cleanup_task:
|
||
status = "已完成" if self.cleanup_task.done() else "运行中"
|
||
other_tasks_status.append(f"清理任务({status})")
|
||
if self.cookie_refresh_task:
|
||
status = "已完成" if self.cookie_refresh_task.done() else "运行中"
|
||
other_tasks_status.append(f"Cookie刷新任务({status})")
|
||
if self.item_sync_task:
|
||
status = "已完成" if self.item_sync_task.done() else "运行中"
|
||
other_tasks_status.append(f"商品同步任务({status})")
|
||
if self.order_sync_task:
|
||
status = "已完成" if self.order_sync_task.done() else "运行中"
|
||
other_tasks_status.append(f"订单同步任务({status})")
|
||
if self.item_polish_task:
|
||
status = "已完成" if self.item_polish_task.done() else "运行中"
|
||
other_tasks_status.append(f"商品擦亮任务({status})")
|
||
if self.delivery_timeout_task:
|
||
status = "已完成" if self.delivery_timeout_task.done() else "运行中"
|
||
other_tasks_status.append(f"发货超时检查({status})")
|
||
if self.buyer_interaction_task:
|
||
status = "已完成" if self.buyer_interaction_task.done() else "运行中"
|
||
other_tasks_status.append(f"买家互动任务({status})")
|
||
|
||
if other_tasks_status:
|
||
logger.info(f"【{self.cookie_id}】其他任务继续运行(不依赖WebSocket): {', '.join(other_tasks_status)}")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】没有其他任务在运行")
|
||
|
||
logger.info(f"【{self.cookie_id}】任务重置完成,可以立即创建新的心跳任务")
|
||
|
||
async def _cancel_background_tasks(self):
|
||
"""取消并清理所有后台任务(保留此方法用于程序退出时的完整清理)"""
|
||
try:
|
||
tasks_to_cancel = []
|
||
|
||
# 收集所有需要取消的任务(只收集未完成的任务)
|
||
if self.heartbeat_task:
|
||
if not self.heartbeat_task.done():
|
||
tasks_to_cancel.append(("心跳任务", self.heartbeat_task))
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】心跳任务已完成,跳过")
|
||
|
||
if self.token_refresh_task:
|
||
if not self.token_refresh_task.done():
|
||
tasks_to_cancel.append(("Token刷新任务", self.token_refresh_task))
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】Token刷新任务已完成,跳过")
|
||
|
||
if self.cleanup_task:
|
||
if not self.cleanup_task.done():
|
||
tasks_to_cancel.append(("清理任务", self.cleanup_task))
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】清理任务已完成,跳过")
|
||
|
||
if self.cookie_refresh_task:
|
||
if not self.cookie_refresh_task.done():
|
||
tasks_to_cancel.append(("Cookie刷新任务", self.cookie_refresh_task))
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】Cookie刷新任务已完成,跳过")
|
||
|
||
if self.item_sync_task:
|
||
if not self.item_sync_task.done():
|
||
tasks_to_cancel.append(("商品同步任务", self.item_sync_task))
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】商品同步任务已完成,跳过")
|
||
|
||
if self.order_sync_task:
|
||
if not self.order_sync_task.done():
|
||
tasks_to_cancel.append(("订单同步任务", self.order_sync_task))
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】订单同步任务已完成,跳过")
|
||
|
||
if self.item_polish_task:
|
||
if not self.item_polish_task.done():
|
||
tasks_to_cancel.append(("商品擦亮任务", self.item_polish_task))
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】商品擦亮任务已完成,跳过")
|
||
|
||
if self.delivery_timeout_task:
|
||
if not self.delivery_timeout_task.done():
|
||
tasks_to_cancel.append(("发货超时检查", self.delivery_timeout_task))
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】发货超时检查已完成,跳过")
|
||
|
||
if self.buyer_interaction_task:
|
||
if not self.buyer_interaction_task.done():
|
||
tasks_to_cancel.append(("买家互动任务", self.buyer_interaction_task))
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】买家互动任务已完成,跳过")
|
||
|
||
if not tasks_to_cancel:
|
||
logger.info(f"【{self.cookie_id}】没有后台任务需要取消(所有任务已完成或不存在)")
|
||
# 立即重置任务引用
|
||
self.heartbeat_task = None
|
||
self.token_refresh_task = None
|
||
self.cleanup_task = None
|
||
self.cookie_refresh_task = None
|
||
self.item_sync_task = None
|
||
self.order_sync_task = None
|
||
self.item_polish_task = None
|
||
self.delivery_timeout_task = None
|
||
self.buyer_interaction_task = None
|
||
return
|
||
|
||
logger.info(f"【{self.cookie_id}】开始取消 {len(tasks_to_cancel)} 个未完成的后台任务...")
|
||
|
||
# 取消所有任务
|
||
for task_name, task in tasks_to_cancel:
|
||
try:
|
||
if task.done():
|
||
logger.info(f"【{self.cookie_id}】任务已完成,跳过取消: {task_name}")
|
||
else:
|
||
task.cancel()
|
||
logger.info(f"【{self.cookie_id}】已发送取消信号: {task_name}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】取消任务失败 {task_name}: {e}")
|
||
|
||
# 等待所有任务完成取消,使用合理的超时时间
|
||
# 现在任务中已经添加了 await asyncio.sleep(0) 来让出控制权,应该能够响应取消信号
|
||
tasks = [task for _, task in tasks_to_cancel]
|
||
logger.info(f"【{self.cookie_id}】等待 {len(tasks)} 个任务响应取消信号...")
|
||
|
||
wait_timeout = 5.0 # 增加超时时间到5秒,给任务更多时间响应取消信号
|
||
|
||
start_time = time.time()
|
||
try:
|
||
# 只等待未完成的任务
|
||
pending_tasks_list = [task for task in tasks if not task.done()]
|
||
|
||
# 记录每个任务的状态
|
||
for task_name, task in tasks_to_cancel:
|
||
status = "已完成" if task.done() else "运行中"
|
||
logger.info(f"【{self.cookie_id}】任务状态: {task_name} - {status}")
|
||
|
||
if not pending_tasks_list:
|
||
logger.info(f"【{self.cookie_id}】所有任务已完成,无需等待")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】等待 {len(pending_tasks_list)} 个未完成任务响应(超时时间: {wait_timeout}秒)...")
|
||
try:
|
||
# 使用 wait 等待任务完成,设置超时
|
||
logger.debug(f"【{self.cookie_id}】开始调用 asyncio.wait()...")
|
||
done, pending = await asyncio.wait(
|
||
pending_tasks_list,
|
||
timeout=wait_timeout,
|
||
return_when=asyncio.ALL_COMPLETED
|
||
)
|
||
elapsed = time.time() - start_time
|
||
logger.info(f"【{self.cookie_id}】asyncio.wait() 返回,耗时 {elapsed:.3f}秒,已完成: {len(done)},未完成: {len(pending)}")
|
||
|
||
# 检查已完成的任务,并记录详细信息
|
||
for task_name, task in tasks_to_cancel:
|
||
if task in done:
|
||
try:
|
||
task.result()
|
||
logger.warning(f"【{self.cookie_id}】⚠️ 任务正常完成(非取消): {task_name}")
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】✅ 任务已成功取消: {task_name}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】⚠️ 任务取消时出现异常 {task_name}: {e}")
|
||
|
||
if pending:
|
||
# 找出未完成的任务名称和详细信息
|
||
pending_names = []
|
||
for task_name, task in tasks_to_cancel:
|
||
if task in pending:
|
||
pending_names.append(task_name)
|
||
# 记录未完成任务的状态
|
||
if task.done():
|
||
try:
|
||
task.result()
|
||
logger.warning(f"【{self.cookie_id}】任务在等待期间完成: {task_name}")
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】任务在等待期间被取消: {task_name}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】任务在等待期间异常 {task_name}: {e}")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】任务仍未完成: {task_name} (done={task.done()})")
|
||
|
||
logger.warning(f"【{self.cookie_id}】等待超时 ({elapsed:.3f}秒),以下任务可能仍在运行: {', '.join(pending_names)}")
|
||
|
||
# 强制取消所有未完成的任务(再次尝试)
|
||
for task_name, task in tasks_to_cancel:
|
||
if task in pending and not task.done():
|
||
try:
|
||
task.cancel()
|
||
logger.warning(f"【{self.cookie_id}】强制取消任务: {task_name}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】强制取消任务失败 {task_name}: {e}")
|
||
|
||
# 再等待一小段时间,看是否有任务响应
|
||
if pending:
|
||
try:
|
||
done2, pending2 = await asyncio.wait(pending, timeout=1.0, return_when=asyncio.ALL_COMPLETED)
|
||
for task_name, task in tasks_to_cancel:
|
||
if task in done2:
|
||
try:
|
||
task.result()
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】任务在二次等待期间被取消: {task_name}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】任务在二次等待期间异常 {task_name}: {e}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】二次等待任务时出错: {e}")
|
||
|
||
logger.warning(f"【{self.cookie_id}】强制继续重连流程,未完成的任务将在后台继续运行(但已标记为取消)")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】所有后台任务已取消 (耗时 {elapsed:.3f}秒)")
|
||
|
||
except Exception as e:
|
||
elapsed = time.time() - start_time
|
||
logger.warning(f"【{self.cookie_id}】等待任务时出错 (耗时 {elapsed:.3f}秒): {e}")
|
||
import traceback
|
||
logger.warning(f"【{self.cookie_id}】等待任务异常堆栈:\n{traceback.format_exc()}")
|
||
|
||
except Exception as e:
|
||
elapsed = time.time() - start_time
|
||
logger.error(f"【{self.cookie_id}】等待任务取消时出错 (耗时 {elapsed:.3f}秒): {e}")
|
||
import traceback
|
||
logger.error(f"【{self.cookie_id}】等待任务取消异常堆栈:\n{traceback.format_exc()}")
|
||
|
||
logger.info(f"【{self.cookie_id}】任务取消流程完成,继续重连流程")
|
||
|
||
# 最后检查一次所有任务的状态
|
||
for task_name, task in tasks_to_cancel:
|
||
if task and not task.done():
|
||
logger.warning(f"【{self.cookie_id}】⚠️ 任务取消流程完成后,任务仍未完成: {task_name} (done={task.done()})")
|
||
elif task and task.done():
|
||
logger.debug(f"【{self.cookie_id}】✅ 任务已完成: {task_name}")
|
||
|
||
finally:
|
||
# 使用 finally 确保无论发生什么情况都会重置任务引用
|
||
# 这样可以保证下次重连时所有任务都会被重新创建
|
||
self.heartbeat_task = None
|
||
self.token_refresh_task = None
|
||
self.cleanup_task = None
|
||
self.cookie_refresh_task = None
|
||
self.item_sync_task = None
|
||
self.order_sync_task = None
|
||
self.item_polish_task = None
|
||
self.delivery_timeout_task = None
|
||
self.buyer_interaction_task = None
|
||
logger.info(f"【{self.cookie_id}】后台任务引用已全部重置")
|
||
|
||
# 平台风控/人机验证的特征串。命中后必须大幅退避 —— 继续高频重试只会
|
||
# 让风控持续时间更长,实测无退避时会形成每分钟数百次请求的重试风暴。
|
||
RISK_CONTROL_MARKERS = (
|
||
'RGV587_ERROR',
|
||
'FAIL_SYS_USER_VALIDATE',
|
||
'哎哟喂',
|
||
'被挤爆',
|
||
'FAIL_SYS_FLOW_LIMIT',
|
||
'请稍后重试',
|
||
)
|
||
|
||
@classmethod
|
||
def is_risk_control_error(cls, message) -> bool:
|
||
"""判断错误是否来自平台风控或限流。"""
|
||
text = str(message or '')
|
||
return any(marker in text for marker in cls.RISK_CONTROL_MARKERS)
|
||
|
||
def _calculate_retry_delay(self, error_msg: str) -> int:
|
||
"""根据错误类型和失败次数计算重试延迟"""
|
||
# 平台风控 - 指数退避,最长 30 分钟
|
||
if self.is_risk_control_error(error_msg):
|
||
delay = min(60 * (2 ** max(0, self.connection_failures - 1)), 1800)
|
||
logger.warning(
|
||
f"【{self.cookie_id}】检测到平台风控,退避 {delay} 秒后重试"
|
||
f"(第 {self.connection_failures} 次)"
|
||
)
|
||
return delay
|
||
|
||
# WebSocket意外断开 - 短延迟
|
||
if "no close frame received or sent" in error_msg:
|
||
return min(3 * self.connection_failures, 15)
|
||
|
||
# 网络连接问题 - 长延迟
|
||
elif "Connection refused" in error_msg or "timeout" in error_msg.lower():
|
||
return min(10 * self.connection_failures, 60)
|
||
|
||
# 其他未知错误 - 中等延迟
|
||
else:
|
||
return min(5 * self.connection_failures, 30)
|
||
|
||
def _cleanup_instance_caches(self):
|
||
"""清理实例级别的缓存,防止内存泄漏"""
|
||
try:
|
||
current_time = time.time()
|
||
cleaned_total = 0
|
||
|
||
# 清理过期的通知记录(保留30分钟内的,从1小时优化)
|
||
max_notification_age = 1800 # 30分钟(从3600优化)
|
||
expired_notifications = [
|
||
key for key, last_time in self.last_notification_time.items()
|
||
if current_time - last_time > max_notification_age
|
||
]
|
||
for key in expired_notifications:
|
||
del self.last_notification_time[key]
|
||
if expired_notifications:
|
||
cleaned_total += len(expired_notifications)
|
||
logger.warning(f"【{self.cookie_id}】清理了 {len(expired_notifications)} 个过期通知记录")
|
||
|
||
# 清理过期的发货记录(保留30分钟内的)
|
||
max_delivery_age = 1800 # 30分钟
|
||
expired_deliveries = [
|
||
order_id for order_id, last_time in self.last_delivery_time.items()
|
||
if current_time - last_time > max_delivery_age
|
||
]
|
||
for order_id in expired_deliveries:
|
||
del self.last_delivery_time[order_id]
|
||
if expired_deliveries:
|
||
cleaned_total += len(expired_deliveries)
|
||
logger.warning(f"【{self.cookie_id}】清理了 {len(expired_deliveries)} 个过期发货记录")
|
||
|
||
# 清理过期的订单确认记录(保留30分钟内的)
|
||
max_confirm_age = 1800 # 30分钟
|
||
expired_confirms = [
|
||
order_id for order_id, last_time in self.confirmed_orders.items()
|
||
if current_time - last_time > max_confirm_age
|
||
]
|
||
for order_id in expired_confirms:
|
||
del self.confirmed_orders[order_id]
|
||
if expired_confirms:
|
||
cleaned_total += len(expired_confirms)
|
||
logger.warning(f"【{self.cookie_id}】清理了 {len(expired_confirms)} 个过期订单确认记录")
|
||
|
||
# 只有实际清理了内容才记录总数日志
|
||
if cleaned_total > 0:
|
||
logger.info(f"【{self.cookie_id}】实例缓存清理完成,共清理 {cleaned_total} 条记录")
|
||
logger.warning(f"【{self.cookie_id}】当前缓存数量 - 通知: {len(self.last_notification_time)}, 发货: {len(self.last_delivery_time)}, 确认: {len(self.confirmed_orders)}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】清理实例缓存时出错: {self._safe_str(e)}")
|
||
|
||
async def _cleanup_playwright_cache(self):
|
||
"""清理Playwright浏览器临时文件和缓存(Docker环境专用)"""
|
||
try:
|
||
import shutil
|
||
import glob
|
||
|
||
# 定义需要清理的临时目录路径
|
||
temp_paths = [
|
||
'/tmp/playwright-*', # Playwright临时会话
|
||
'/tmp/chromium-*', # Chromium临时文件
|
||
'/ms-playwright/chromium-*/Default/Cache', # 浏览器缓存
|
||
'/ms-playwright/chromium-*/Default/Code Cache', # 代码缓存
|
||
'/ms-playwright/chromium-*/Default/GPUCache', # GPU缓存
|
||
]
|
||
|
||
total_cleaned = 0
|
||
total_size_mb = 0
|
||
|
||
for pattern in temp_paths:
|
||
try:
|
||
matching_paths = glob.glob(pattern)
|
||
for path in matching_paths:
|
||
try:
|
||
if os.path.exists(path):
|
||
# 计算大小
|
||
if os.path.isdir(path):
|
||
size = sum(
|
||
os.path.getsize(os.path.join(dirpath, filename))
|
||
for dirpath, _, filenames in os.walk(path)
|
||
for filename in filenames
|
||
)
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
else:
|
||
size = os.path.getsize(path)
|
||
os.remove(path)
|
||
|
||
total_size_mb += size / (1024 * 1024)
|
||
total_cleaned += 1
|
||
except Exception as e:
|
||
logger.warning(f"清理路径 {path} 时出错: {e}")
|
||
except Exception as e:
|
||
logger.warning(f"匹配路径 {pattern} 时出错: {e}")
|
||
|
||
if total_cleaned > 0:
|
||
logger.info(f"【{self.cookie_id}】Playwright缓存清理完成: 删除了 {total_cleaned} 个文件/目录,释放 {total_size_mb:.2f} MB")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】Playwright缓存清理: 没有需要清理的临时文件")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】清理Playwright缓存时出错: {self._safe_str(e)}")
|
||
|
||
async def _cleanup_old_logs(self, retention_days: int = 7):
|
||
"""清理过期的日志文件
|
||
|
||
Args:
|
||
retention_days: 保留的天数,默认7天
|
||
|
||
Returns:
|
||
清理的文件数量
|
||
"""
|
||
try:
|
||
import glob
|
||
from datetime import datetime, timedelta
|
||
|
||
logs_dir = "logs"
|
||
if not os.path.exists(logs_dir):
|
||
logger.warning(f"【{self.cookie_id}】日志目录不存在: {logs_dir}")
|
||
return 0
|
||
|
||
# 计算过期时间点
|
||
cutoff_time = datetime.now() - timedelta(days=retention_days)
|
||
|
||
# 查找所有日志文件(包括.log和.log.zip)
|
||
log_patterns = [
|
||
os.path.join(logs_dir, "xianyu_*.log"),
|
||
os.path.join(logs_dir, "xianyu_*.log.zip"),
|
||
os.path.join(logs_dir, "app_*.log"),
|
||
os.path.join(logs_dir, "app_*.log.zip"),
|
||
]
|
||
|
||
total_cleaned = 0
|
||
total_size_mb = 0
|
||
|
||
for pattern in log_patterns:
|
||
log_files = glob.glob(pattern)
|
||
for log_file in log_files:
|
||
try:
|
||
# 获取文件修改时间
|
||
file_mtime = datetime.fromtimestamp(os.path.getmtime(log_file))
|
||
|
||
# 如果文件早于保留期限,则删除
|
||
if file_mtime < cutoff_time:
|
||
file_size = os.path.getsize(log_file)
|
||
os.remove(log_file)
|
||
total_size_mb += file_size / (1024 * 1024)
|
||
total_cleaned += 1
|
||
logger.debug(f"【{self.cookie_id}】删除过期日志文件: {log_file} (修改时间: {file_mtime})")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】删除日志文件失败 {log_file}: {self._safe_str(e)}")
|
||
|
||
if total_cleaned > 0:
|
||
logger.info(f"【{self.cookie_id}】日志清理完成: 删除了 {total_cleaned} 个日志文件,释放 {total_size_mb:.2f} MB (保留 {retention_days} 天内的日志)")
|
||
else:
|
||
logger.debug(f"【{self.cookie_id}】日志清理: 没有需要清理的过期日志文件 (保留 {retention_days} 天)")
|
||
|
||
return total_cleaned
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】清理日志文件时出错: {self._safe_str(e)}")
|
||
return 0
|
||
|
||
def __init__(self, cookies_str=None, cookie_id: str = "default", user_id: int = None):
|
||
"""初始化闲鱼直播类"""
|
||
logger.info(f"【{cookie_id}】开始初始化XianyuLive...")
|
||
|
||
if not cookies_str:
|
||
cookies_str = COOKIES_STR
|
||
if not cookies_str:
|
||
raise ValueError("未提供cookies,请在global_config.yml中配置COOKIES_STR或通过参数传入")
|
||
|
||
logger.info(f"【{cookie_id}】解析cookies...")
|
||
self.cookies = trans_cookies(cookies_str)
|
||
logger.info(f"【{cookie_id}】cookies解析完成,包含字段: {list(self.cookies.keys())}")
|
||
|
||
self.cookie_id = cookie_id # 唯一账号标识
|
||
self.cookies_str = cookies_str # 保存原始cookie字符串
|
||
self.user_id = user_id # 保存用户ID,用于token刷新时保持正确的所有者关系
|
||
self.base_url = WEBSOCKET_URL
|
||
|
||
if 'unb' not in self.cookies:
|
||
raise ValueError(f"【{cookie_id}】Cookie中缺少必需的'unb'字段,当前字段: {list(self.cookies.keys())}")
|
||
|
||
self.myid = self.cookies['unb']
|
||
logger.info(f"【{cookie_id}】用户ID: {self.myid}")
|
||
self.device_id = generate_device_id(self.myid)
|
||
|
||
# 心跳相关配置
|
||
self.heartbeat_interval = HEARTBEAT_INTERVAL
|
||
self.heartbeat_timeout = HEARTBEAT_TIMEOUT
|
||
self.last_heartbeat_time = 0
|
||
self.last_heartbeat_response = 0
|
||
self.heartbeat_task = None
|
||
self.ws = None
|
||
self._im_pending = {}
|
||
self._im_request_lock = asyncio.Lock()
|
||
|
||
# Token刷新相关配置
|
||
self.token_refresh_interval = TOKEN_REFRESH_INTERVAL
|
||
self.token_retry_interval = TOKEN_RETRY_INTERVAL
|
||
self.last_token_refresh_time = 0
|
||
self.current_token = None
|
||
self.token_refresh_task = None
|
||
self.connection_restart_flag = False # 连接重启标志
|
||
|
||
# 通知防重复机制
|
||
self.last_notification_time = {} # 记录每种通知类型的最后发送时间
|
||
self.notification_cooldown = 300 # 5分钟内不重复发送相同类型的通知
|
||
self.token_refresh_notification_cooldown = 18000 # Token刷新异常通知冷却时间:3小时
|
||
self.notification_lock = asyncio.Lock() # 通知防重复机制的异步锁
|
||
|
||
# 自动发货防重复机制
|
||
self.last_delivery_time = {} # 记录每个商品的最后发货时间
|
||
self.delivery_cooldown = 600 # 10分钟内不重复发货
|
||
|
||
# 自动确认发货防重复机制
|
||
self.confirmed_orders = {} # 记录已确认发货的订单,防止重复确认
|
||
self.order_confirm_cooldown = 600 # 10分钟内不重复确认同一订单
|
||
|
||
# 自动发货已发送订单记录
|
||
self.delivery_sent_orders = set() # 记录已发货的订单ID,防止重复发货
|
||
self.delivery_blocked_orders = set() # 部分发货或内容已消耗的订单,阻止自动重试造成重复发送
|
||
|
||
# 交易卡片落库前暂存卖家端接口返回的真实成交数据(金额、数量、收货信息)
|
||
self._pending_order_real_values = {}
|
||
|
||
self.session = None # 用于API调用的aiohttp session
|
||
|
||
# 启动定期清理过期暂停记录的任务
|
||
self.cleanup_task = None
|
||
|
||
# Cookie刷新定时任务
|
||
self.cookie_refresh_task = None
|
||
self.cookie_refresh_interval = 1200 # 1小时 = 3600秒
|
||
self.last_cookie_refresh_time = 0
|
||
self.cookie_refresh_lock = asyncio.Lock() # 使用Lock防止重复执行Cookie刷新
|
||
self.cookie_refresh_enabled = True # 是否启用Cookie刷新功能
|
||
|
||
# 商品同步定时任务
|
||
self.item_sync_task = None
|
||
self.item_sync_enabled = cfg.get('ITEM_SYNC', {}).get('enabled', True)
|
||
self.item_sync_interval = cfg.get('ITEM_SYNC', {}).get('interval', 3600) # 默认1小时
|
||
self.item_sync_max_pages = cfg.get('ITEM_SYNC', {}).get('max_pages', 5)
|
||
self.last_item_sync_time = 0
|
||
self.item_sync_lock = asyncio.Lock() # 使用Lock防止重复执行商品同步
|
||
|
||
# 订单同步定时任务:从卖家端接口拉全量,补齐监听离线期间的订单
|
||
self.order_sync_task = None
|
||
self.last_order_sync_time = 0
|
||
|
||
# 商品擦亮定时任务:重新获取搜索曝光,默认关闭
|
||
self.item_polish_task = None
|
||
self.last_polish_time = 0
|
||
|
||
# 发货超时告警:记录已提醒过的订单,避免重复推送
|
||
self.delivery_timeout_task = None
|
||
self._delivery_timeout_alerted = set()
|
||
|
||
# 账号资料只在连接成功后同步一次,避免重连时反复请求
|
||
self._profile_synced = False
|
||
|
||
# 买家互动(评价/求花):记录已处理订单,两者都默认关闭
|
||
self.buyer_interaction_task = None
|
||
self._auto_rated_orders = set()
|
||
self._auto_flowered_orders = set()
|
||
# 已发过确认收货致谢的会话/订单,避免同一笔交易的多条系统消息各发一次
|
||
self._thanked_receipts = set()
|
||
# 买家互动即时触发的去重标记
|
||
self._buyer_interaction_triggering = False
|
||
|
||
# 扫码登录Cookie刷新标志
|
||
self.last_qr_cookie_refresh_time = 0 # 记录上次扫码登录Cookie刷新时间
|
||
self.qr_cookie_refresh_cooldown = 600 # 扫码登录Cookie刷新后的冷却时间:10分钟
|
||
|
||
# 消息接收标识 - 用于控制Cookie刷新
|
||
self.last_message_received_time = 0 # 记录上次收到消息的时间
|
||
self.message_cookie_refresh_cooldown = 300 # 收到消息后5分钟内不执行Cookie刷新
|
||
|
||
# 浏览器Cookie刷新成功标志
|
||
self.browser_cookie_refreshed = False # 标记_refresh_cookies_via_browser是否成功更新过数据库
|
||
self.restarted_in_browser_refresh = False # 刷新流程内部是否已触发重启(用于去重)
|
||
|
||
|
||
# 滑块验证相关
|
||
self.captcha_verification_count = 0 # 滑块验证次数计数器
|
||
self.max_captcha_verification_count = 3 # 最大滑块验证次数,防止无限递归
|
||
self.manual_captcha_in_progress = False # 人工滑块验证进行中标志,暂停自动验证避免争抢浏览器槽位
|
||
|
||
# WebSocket连接监控
|
||
self.connection_state = ConnectionState.DISCONNECTED # 连接状态
|
||
self.connection_failures = 0 # 连续连接失败次数
|
||
self.max_connection_failures = 5 # 最大连续失败次数
|
||
self.last_successful_connection = 0 # 上次成功连接时间
|
||
self.last_state_change_time = time.time() # 上次状态变化时间
|
||
# 登录态已过期且无法自动续期时置位。与"风控中"要分开:风控能等能过验证,
|
||
# 会话过期只能重新扫码,界面必须给出不同的指引。
|
||
self.needs_relogin = False
|
||
self.relogin_reason = ''
|
||
|
||
# 后台任务追踪(用于清理未等待的任务)
|
||
self.background_tasks = set() # 追踪所有后台任务
|
||
|
||
# 消息处理并发控制(防止内存泄漏)
|
||
self.message_semaphore = asyncio.Semaphore(100) # 最多100个并发消息处理任务
|
||
self.active_message_tasks = 0 # 当前活跃的消息处理任务数
|
||
|
||
# 消息防抖管理器:用于处理用户连续发送消息的情况
|
||
# {chat_id: {'task': asyncio.Task, 'last_message': dict, 'timer': float}}
|
||
self.message_debounce_tasks = {} # 存储每个chat_id的防抖任务
|
||
self.message_debounce_delay = 1 # 防抖延迟时间(秒):用户停止发送消息1秒后才回复
|
||
self.message_debounce_lock = asyncio.Lock() # 防抖任务管理的锁
|
||
|
||
# 消息去重机制:防止同一条消息被处理多次
|
||
self.processed_message_ids = {} # 存储已处理的消息ID和时间戳 {message_id: timestamp}
|
||
self.processed_message_ids_lock = asyncio.Lock() # 消息ID去重的锁
|
||
self.processed_message_ids_max_size = 10000 # 最大保存10000个消息ID,防止内存泄漏
|
||
self.message_expire_time = 3600 # 消息过期时间(秒),默认1小时后可以重复回复
|
||
|
||
# 初始化订单状态处理器
|
||
self._init_order_status_handler()
|
||
|
||
# 注册实例到类级别字典(用于API调用)
|
||
self._register_instance()
|
||
|
||
def _init_order_status_handler(self):
|
||
"""初始化订单状态处理器"""
|
||
try:
|
||
# 直接导入订单状态处理器
|
||
from app.order_status_handler import order_status_handler
|
||
self.order_status_handler = order_status_handler
|
||
logger.info(f"【{self.cookie_id}】订单状态处理器已启用")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】初始化订单状态处理器失败: {self._safe_str(e)}")
|
||
self.order_status_handler = None
|
||
|
||
def _register_instance(self):
|
||
"""注册当前实例到类级别字典"""
|
||
try:
|
||
# 使用同步方式注册,避免在__init__中使用async
|
||
XianyuLive._instances[self.cookie_id] = self
|
||
logger.warning(f"【{self.cookie_id}】实例已注册到全局字典")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】注册实例失败: {self._safe_str(e)}")
|
||
|
||
def _unregister_instance(self):
|
||
"""从类级别字典中注销当前实例"""
|
||
try:
|
||
# 重连时同一账号会先建新实例再回收旧实例,若无条件删除,
|
||
# 旧实例的清理会把仍在运行的新实例一并抹掉 —— 表现为账号心跳正常、
|
||
# 界面显示「监听中」,完整发货却报「该账号未在线运行」。
|
||
# 因此只在注册表里存的确实是自己时才删除。
|
||
if XianyuLive._instances.get(self.cookie_id) is self:
|
||
del XianyuLive._instances[self.cookie_id]
|
||
logger.warning(f"【{self.cookie_id}】实例已从全局字典中注销")
|
||
elif self.cookie_id in XianyuLive._instances:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】注册表中已是更新的实例,跳过注销避免误删"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】注销实例失败: {self._safe_str(e)}")
|
||
|
||
@classmethod
|
||
def get_instance(cls, cookie_id: str):
|
||
"""获取指定cookie_id的XianyuLive实例"""
|
||
return cls._instances.get(cookie_id)
|
||
|
||
@classmethod
|
||
def get_all_instances(cls):
|
||
"""获取所有活跃的XianyuLive实例"""
|
||
return dict(cls._instances)
|
||
|
||
@classmethod
|
||
def get_instance_count(cls):
|
||
"""获取当前活跃实例数量"""
|
||
return len(cls._instances)
|
||
|
||
def _create_tracked_task(self, coro):
|
||
"""创建并追踪后台任务,确保异常不会被静默忽略"""
|
||
task = asyncio.create_task(coro)
|
||
self.background_tasks.add(task)
|
||
task.add_done_callback(self.background_tasks.discard)
|
||
return task
|
||
|
||
def is_auto_confirm_enabled(self) -> bool:
|
||
"""检查当前账号是否启用自动确认发货"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
return db_manager.get_auto_confirm(self.cookie_id)
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】获取自动确认发货设置失败: {self._safe_str(e)}")
|
||
return True # 出错时默认启用
|
||
|
||
async def _merge_mtop_response_cookies(self, response, source: str) -> None:
|
||
"""合并 mtop 响应中的 Cookie,并同步到当前会话和数据库。"""
|
||
if 'set-cookie' not in response.headers:
|
||
return
|
||
|
||
new_cookies = {}
|
||
for cookie in response.headers.getall('set-cookie', []):
|
||
if '=' not in cookie:
|
||
continue
|
||
name, value = cookie.split(';', 1)[0].split('=', 1)
|
||
new_cookies[name.strip()] = value.strip()
|
||
|
||
if not new_cookies:
|
||
return
|
||
|
||
self.cookies.update(new_cookies)
|
||
self.cookies_str = '; '.join(f"{key}={value}" for key, value in self.cookies.items())
|
||
if self.session and not self.session.closed:
|
||
self.session.headers['cookie'] = self.cookies_str
|
||
await self.update_config_cookies()
|
||
logger.info(f"【{self.cookie_id}】{source}响应更新了 {len(new_cookies)} 个 Cookie 字段")
|
||
|
||
async def fetch_buyer_rating_count(self, buyer_id: str, retry_count: int = 0) -> int:
|
||
"""获取买家被评价总数;失败返回 -1,由规则引擎按放行处理。"""
|
||
max_retry = 3
|
||
if not buyer_id or not self.cookies_str:
|
||
logger.warning(f"【{self.cookie_id}】买家信用查询缺少 buyer_id 或 Cookie,跳过")
|
||
return -1
|
||
|
||
data_payload = {
|
||
"rateType": 0,
|
||
"ratedUid": str(buyer_id),
|
||
"raterType": 0,
|
||
"rowsPerPage": 20,
|
||
"pageNumber": 1,
|
||
"foldFlag": 0,
|
||
"fishAdCode": "330110",
|
||
"extraTag": "",
|
||
}
|
||
data_val = json.dumps(data_payload, separators=(',', ':'), ensure_ascii=False)
|
||
timestamp = str(int(time.time() * 1000))
|
||
cookies = trans_cookies(self.cookies_str)
|
||
token_value = cookies.get('_m_h5_tk', '')
|
||
token = token_value.split('_')[0] if token_value else ''
|
||
params = {
|
||
'jsv': '2.7.2',
|
||
'appKey': '34839810',
|
||
't': timestamp,
|
||
'sign': generate_sign(timestamp, token, data_val),
|
||
'v': '1.0',
|
||
'type': 'originaljson',
|
||
'accountSite': 'xianyu',
|
||
'dataType': 'json',
|
||
'timeout': '20000',
|
||
'api': 'mtop.idle.web.trade.rate.list',
|
||
'sessionOption': 'AutoLoginOnly',
|
||
'spm_cnt': 'a21ybx.personal.0.0',
|
||
}
|
||
headers = {
|
||
'accept': 'application/json',
|
||
'content-type': 'application/x-www-form-urlencoded',
|
||
'origin': 'https://www.goofish.com',
|
||
'referer': 'https://www.goofish.com/',
|
||
'user-agent': (
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||
'AppleWebKit/537.36 Chrome/138.0.0.0 Safari/537.36'
|
||
),
|
||
'cookie': self.cookies_str.replace('\n', '').replace('\r', ''),
|
||
}
|
||
|
||
try:
|
||
async with self.session.post(
|
||
'https://h5api.m.goofish.com/h5/mtop.idle.web.trade.rate.list/1.0/',
|
||
params=params,
|
||
data={'data': data_val},
|
||
headers=headers,
|
||
timeout=aiohttp.ClientTimeout(total=20),
|
||
) as response:
|
||
result = await response.json()
|
||
await self._merge_mtop_response_cookies(response, "买家信用查询")
|
||
|
||
ret_list = result.get('ret', []) if isinstance(result, dict) else []
|
||
if any('SUCCESS' in value for value in ret_list):
|
||
total_count = (result.get('data') or {}).get('totalCount')
|
||
try:
|
||
count = int(total_count)
|
||
logger.info(
|
||
f"【{self.cookie_id}】买家信用查询成功: buyer_id={buyer_id}, total_count={count}"
|
||
)
|
||
return count
|
||
except (TypeError, ValueError):
|
||
logger.warning(
|
||
f"【{self.cookie_id}】买家信用查询缺少有效 totalCount: buyer_id={buyer_id}"
|
||
)
|
||
else:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】买家信用查询失败: buyer_id={buyer_id}, ret={ret_list}"
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】买家信用查询异常(第 {retry_count + 1} 次): "
|
||
f"{self._safe_str(exc)}"
|
||
)
|
||
|
||
if retry_count < max_retry - 1:
|
||
await asyncio.sleep(0.5)
|
||
return await self.fetch_buyer_rating_count(buyer_id, retry_count + 1)
|
||
return -1
|
||
|
||
async def close_order_by_seller(self, order_id: str, retry_count: int = 0) -> bool:
|
||
"""命中发货保护后,由卖家主动关闭闲鱼订单。"""
|
||
max_retry = 3
|
||
if not order_id or not self.cookies_str:
|
||
logger.warning(f"【{self.cookie_id}】关闭订单缺少 order_id 或 Cookie,跳过")
|
||
return False
|
||
|
||
data_payload = {
|
||
"tid": str(order_id),
|
||
"bizOrderId": str(order_id),
|
||
# 卖家版关单理由与买家版取值完全不同,可选值为:
|
||
# 不想卖了 / 宝贝已出售 / 买家联系不上 / 与买家协商一致 / 其他原因
|
||
"closeReason": "宝贝已出售",
|
||
}
|
||
data_val = json.dumps(data_payload, separators=(',', ':'), ensure_ascii=False)
|
||
timestamp = str(int(time.time() * 1000))
|
||
cookies = trans_cookies(self.cookies_str)
|
||
token_value = cookies.get('_m_h5_tk', '')
|
||
token = token_value.split('_')[0] if token_value else ''
|
||
params = {
|
||
'jsv': '2.7.2',
|
||
'appKey': '34839810',
|
||
't': timestamp,
|
||
'sign': generate_sign(timestamp, token, data_val),
|
||
'v': '2.0',
|
||
'type': 'originaljson',
|
||
'accountSite': 'xianyu',
|
||
'dataType': 'json',
|
||
'timeout': '20000',
|
||
'api': 'mtop.taobao.idle.trade.merchant.close.by.seller',
|
||
'sessionOption': 'AutoLoginOnly',
|
||
'spm_cnt': 'a21107h.44911108.0.0',
|
||
}
|
||
headers = {
|
||
'accept': 'application/json',
|
||
'content-type': 'application/x-www-form-urlencoded',
|
||
'origin': 'https://seller.goofish.com',
|
||
'referer': 'https://seller.goofish.com/',
|
||
'user-agent': (
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||
'AppleWebKit/537.36 Chrome/138.0.0.0 Safari/537.36'
|
||
),
|
||
'cookie': self.cookies_str.replace('\n', '').replace('\r', ''),
|
||
}
|
||
|
||
try:
|
||
async with self.session.post(
|
||
'https://h5api.m.goofish.com/h5/mtop.taobao.idle.trade.merchant.close.by.seller/2.0/',
|
||
params=params,
|
||
data={'data': data_val},
|
||
headers=headers,
|
||
timeout=aiohttp.ClientTimeout(total=20),
|
||
) as response:
|
||
result = await response.json()
|
||
await self._merge_mtop_response_cookies(response, "关闭订单")
|
||
|
||
ret_list = result.get('ret', []) if isinstance(result, dict) else []
|
||
if any('SUCCESS' in value for value in ret_list):
|
||
logger.warning(f"【{self.cookie_id}】订单 {order_id} 已由卖家主动关闭")
|
||
return True
|
||
logger.warning(
|
||
f"【{self.cookie_id}】关闭订单失败(第 {retry_count + 1} 次): "
|
||
f"order_id={order_id}, ret={ret_list}"
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】关闭订单异常(第 {retry_count + 1} 次): "
|
||
f"order_id={order_id}, error={self._safe_str(exc)}"
|
||
)
|
||
|
||
if retry_count < max_retry - 1:
|
||
await asyncio.sleep(0.5)
|
||
return await self.close_order_by_seller(order_id, retry_count + 1)
|
||
return False
|
||
|
||
async def apply_delivery_block_rules(
|
||
self,
|
||
order_id: str,
|
||
buyer_id: str,
|
||
item_id: str = None,
|
||
chat_id: str = '',
|
||
websocket=None,
|
||
owner_id: int = None,
|
||
service=None,
|
||
) -> dict:
|
||
"""执行全部发货保护,并返回 allow、block 或 card_only。"""
|
||
from app.services.delivery_block_rules import (
|
||
DeliveryBlockRuleService,
|
||
resolve_delivery_action,
|
||
)
|
||
|
||
rule_service = service or DeliveryBlockRuleService(db_manager)
|
||
buyer_rating_count = None
|
||
try:
|
||
credit_rule = next(
|
||
(
|
||
rule for rule in rule_service.list_rules(self.cookie_id)
|
||
if rule['rule_code'] == 'buyer_credit_zero'
|
||
),
|
||
None,
|
||
)
|
||
if (
|
||
credit_rule
|
||
and credit_rule['enabled']
|
||
and (not item_id or str(item_id) not in set(credit_rule['excluded_item_ids']))
|
||
):
|
||
buyer_rating_count = await self.fetch_buyer_rating_count(buyer_id)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】准备买家信用规则失败,按接口异常放行该规则: "
|
||
f"{self._safe_str(exc)}"
|
||
)
|
||
|
||
result = rule_service.evaluate(
|
||
account_id=self.cookie_id,
|
||
order_id=order_id,
|
||
buyer_id=buyer_id,
|
||
item_id=item_id,
|
||
owner_id=owner_id,
|
||
buyer_rating_count=buyer_rating_count,
|
||
)
|
||
if not result['hit']:
|
||
return {**result, 'action': 'allow', 'order_closed': False}
|
||
|
||
block_message = result.get('block_reason') or ''
|
||
active_websocket = websocket or self.ws
|
||
if block_message and chat_id and active_websocket:
|
||
try:
|
||
await self.send_msg(active_websocket, chat_id, buyer_id, block_message)
|
||
except Exception as exc:
|
||
logger.error(
|
||
f"【{self.cookie_id}】订单 {order_id} 已拦截,但发送买家提示失败: "
|
||
f"{self._safe_str(exc)}"
|
||
)
|
||
|
||
order_closed = False
|
||
if result.get('auto_close_order'):
|
||
order_closed = await self.close_order_by_seller(order_id)
|
||
|
||
action = resolve_delivery_action(result, order_closed)
|
||
if action == 'block':
|
||
self.delivery_blocked_orders.add(order_id)
|
||
self.last_delivery_time[order_id] = time.time()
|
||
|
||
logger.warning(
|
||
f"【{self.cookie_id}】订单 {order_id} 命中发货保护: "
|
||
f"rule={result.get('rule_code')}, action={action}, "
|
||
f"order_closed={order_closed}, reason={result.get('reason')}"
|
||
)
|
||
return {**result, 'action': action, 'order_closed': order_closed}
|
||
|
||
|
||
|
||
def can_auto_delivery(self, order_id: str) -> bool:
|
||
"""检查是否可以进行自动发货(防重复发货)- 基于订单ID"""
|
||
if not order_id:
|
||
# 如果没有订单ID,则不进行冷却检查,允许发货
|
||
return True
|
||
|
||
if order_id in self.delivery_sent_orders:
|
||
logger.info(f"【{self.cookie_id}】订单 {order_id} 已完成自动发货,跳过重复发货")
|
||
return False
|
||
|
||
if order_id in self.delivery_blocked_orders:
|
||
logger.warning(f"【{self.cookie_id}】订单 {order_id} 存在部分发货或内容消耗异常,需要人工处理,跳过自动重试")
|
||
return False
|
||
|
||
current_time = time.time()
|
||
last_delivery = self.last_delivery_time.get(order_id, 0)
|
||
|
||
if current_time - last_delivery < self.delivery_cooldown:
|
||
logger.info(f"【{self.cookie_id}】订单 {order_id} 在冷却期内,跳过自动发货")
|
||
return False
|
||
|
||
return True
|
||
|
||
def mark_delivery_sent(self, order_id: str, update_order_status: bool = True):
|
||
"""标记订单已发货"""
|
||
self.delivery_sent_orders.add(order_id)
|
||
self.last_delivery_time[order_id] = time.time()
|
||
logger.info(f"【{self.cookie_id}】订单 {order_id} 已标记为发货")
|
||
|
||
if not update_order_status:
|
||
logger.warning(f"【{self.cookie_id}】订单 {order_id} 的卡券已发送,但闲鱼发货状态未确认")
|
||
return
|
||
|
||
# 更新订单状态为已发货
|
||
logger.info(f"【{self.cookie_id}】检查自动发货订单状态处理器: handler_exists={self.order_status_handler is not None}")
|
||
if self.order_status_handler:
|
||
logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.handle_auto_delivery_order_status: {order_id}")
|
||
try:
|
||
success = self.order_status_handler.handle_auto_delivery_order_status(
|
||
order_id=order_id,
|
||
cookie_id=self.cookie_id,
|
||
context="自动发货完成"
|
||
)
|
||
logger.info(f"【{self.cookie_id}】订单状态处理器.handle_auto_delivery_order_status返回结果: {success}")
|
||
if success:
|
||
logger.info(f"【{self.cookie_id}】订单 {order_id} 状态已更新为已发货")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】订单 {order_id} 状态更新为已发货失败")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】订单状态更新失败: {self._safe_str(e)}")
|
||
import traceback
|
||
logger.error(f"【{self.cookie_id}】详细错误信息: {traceback.format_exc()}")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】订单状态处理器为None,跳过自动发货状态更新: {order_id}")
|
||
|
||
async def _delayed_lock_release(self, lock_key: str, delay_minutes: int = 10):
|
||
"""
|
||
延迟释放锁的异步任务
|
||
|
||
Args:
|
||
lock_key: 锁的键
|
||
delay_minutes: 延迟时间(分钟),默认10分钟
|
||
"""
|
||
try:
|
||
delay_seconds = delay_minutes * 60
|
||
logger.info(f"【{self.cookie_id}】订单锁 {lock_key} 将在 {delay_minutes} 分钟后释放")
|
||
|
||
# 等待指定时间
|
||
await asyncio.sleep(delay_seconds)
|
||
|
||
# 检查锁是否仍然存在且需要释放
|
||
if lock_key in self._lock_hold_info:
|
||
lock_info = self._lock_hold_info[lock_key]
|
||
if lock_info.get('locked', False):
|
||
# 释放锁
|
||
lock_info['locked'] = False
|
||
lock_info['release_time'] = time.time()
|
||
logger.info(f"【{self.cookie_id}】订单锁 {lock_key} 延迟释放完成")
|
||
|
||
# 清理锁信息(可选,也可以保留用于统计)
|
||
# del self._lock_hold_info[lock_key]
|
||
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】订单锁 {lock_key} 延迟释放任务被取消")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】订单锁 {lock_key} 延迟释放失败: {self._safe_str(e)}")
|
||
|
||
def is_lock_held(self, lock_key: str) -> bool:
|
||
"""
|
||
检查指定的锁是否仍在持有状态
|
||
|
||
Args:
|
||
lock_key: 锁的键
|
||
|
||
Returns:
|
||
bool: True表示锁仍在持有,False表示锁已释放或不存在
|
||
"""
|
||
if lock_key not in self._lock_hold_info:
|
||
return False
|
||
|
||
lock_info = self._lock_hold_info[lock_key]
|
||
return lock_info.get('locked', False)
|
||
|
||
def cleanup_expired_locks(self, max_age_hours: int = 24):
|
||
"""
|
||
清理过期的锁(包括自动发货锁和订单详情锁)
|
||
|
||
Args:
|
||
max_age_hours: 锁的最大保留时间(小时),默认24小时
|
||
"""
|
||
try:
|
||
current_time = time.time()
|
||
max_age_seconds = max_age_hours * 3600
|
||
|
||
# 清理自动发货锁
|
||
expired_delivery_locks = []
|
||
for order_id, last_used in self._lock_usage_times.items():
|
||
if current_time - last_used > max_age_seconds:
|
||
expired_delivery_locks.append(order_id)
|
||
|
||
# 清理过期的自动发货锁
|
||
for order_id in expired_delivery_locks:
|
||
if order_id in self._order_locks:
|
||
del self._order_locks[order_id]
|
||
if order_id in self._lock_usage_times:
|
||
del self._lock_usage_times[order_id]
|
||
# 清理锁持有信息
|
||
if order_id in self._lock_hold_info:
|
||
lock_info = self._lock_hold_info[order_id]
|
||
# 取消延迟释放任务
|
||
if 'task' in lock_info and lock_info['task']:
|
||
lock_info['task'].cancel()
|
||
del self._lock_hold_info[order_id]
|
||
|
||
# 清理订单详情锁
|
||
expired_detail_locks = []
|
||
for order_id, last_used in self._order_detail_lock_times.items():
|
||
if current_time - last_used > max_age_seconds:
|
||
expired_detail_locks.append(order_id)
|
||
|
||
# 清理过期的订单详情锁
|
||
for order_id in expired_detail_locks:
|
||
if order_id in self._order_detail_locks:
|
||
del self._order_detail_locks[order_id]
|
||
if order_id in self._order_detail_lock_times:
|
||
del self._order_detail_lock_times[order_id]
|
||
|
||
total_expired = len(expired_delivery_locks) + len(expired_detail_locks)
|
||
if total_expired > 0:
|
||
logger.info(f"【{self.cookie_id}】清理了 {total_expired} 个过期锁 (发货锁: {len(expired_delivery_locks)}, 详情锁: {len(expired_detail_locks)})")
|
||
logger.warning(f"【{self.cookie_id}】当前锁数量 - 发货锁: {len(self._order_locks)}, 详情锁: {len(self._order_detail_locks)}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】清理过期锁时发生错误: {self._safe_str(e)}")
|
||
|
||
|
||
|
||
def _is_auto_delivery_trigger(self, message: str) -> bool:
|
||
"""检查消息是否为自动发货触发关键字"""
|
||
auto_delivery_messages = {
|
||
'[我已付款,等待你发货]',
|
||
'[已付款,待发货]',
|
||
'[记得及时发货]',
|
||
}
|
||
return isinstance(message, str) and message.strip() in auto_delivery_messages
|
||
|
||
def _is_system_or_order_event(self, message: str) -> bool:
|
||
"""统一识别需要绕过普通自动回复链路的系统和订单事件。"""
|
||
try:
|
||
from app.ai_reply_engine import AIReplyEngine
|
||
return AIReplyEngine.is_system_or_order_event(message)
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】系统事件识别失败: {self._safe_str(e)}")
|
||
return False
|
||
|
||
def _extract_order_id(self, message: dict) -> str:
|
||
"""从消息中提取订单ID"""
|
||
try:
|
||
order_id = None
|
||
|
||
logger.debug(
|
||
f"【{self.cookie_id}】消息结构: type={type(message).__name__}, "
|
||
f"keys={list(message.keys()) if isinstance(message, dict) else []}"
|
||
)
|
||
|
||
# 检查message['1']的结构,处理可能是列表、字典或字符串的情况
|
||
message_1 = message.get('1', {})
|
||
content_json_str = ''
|
||
|
||
if isinstance(message_1, dict):
|
||
logger.warning(f"【{self.cookie_id}】🔍 message['1'] 是字典,keys: {list(message_1.keys())}")
|
||
|
||
# 检查message['1']['6']的结构
|
||
message_1_6 = message_1.get('6', {})
|
||
if isinstance(message_1_6, dict):
|
||
logger.warning(f"【{self.cookie_id}】🔍 message['1']['6'] 是字典,keys: {list(message_1_6.keys())}")
|
||
# 方法1: 从button的targetUrl中提取orderId
|
||
content_json_str = message_1_6.get('3', {}).get('5', '') if isinstance(message_1_6.get('3', {}), dict) else ''
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】🔍 message['1']['6'] 不是字典: {type(message_1_6)}")
|
||
|
||
elif isinstance(message_1, list):
|
||
logger.warning(f"【{self.cookie_id}】🔍 message['1'] 是列表,长度: {len(message_1)}")
|
||
# 如果message['1']是列表,跳过这种提取方式
|
||
|
||
elif isinstance(message_1, str):
|
||
logger.warning(f"【{self.cookie_id}】🔍 message['1'] 是字符串,长度: {len(message_1)}")
|
||
# 如果message['1']是字符串,跳过这种提取方式
|
||
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】🔍 message['1'] 未知类型: {type(message_1)}")
|
||
# 其他类型,跳过这种提取方式
|
||
|
||
if content_json_str:
|
||
try:
|
||
content_data = json.loads(content_json_str)
|
||
|
||
# 方法1a: 从button的targetUrl中提取orderId
|
||
target_url = content_data.get('dxCard', {}).get('item', {}).get('main', {}).get('exContent', {}).get('button', {}).get('targetUrl', '')
|
||
if target_url:
|
||
# 从URL中提取orderId参数
|
||
order_match = re.search(r'orderId=(\d+)', target_url)
|
||
if order_match:
|
||
order_id = order_match.group(1)
|
||
logger.info(f'【{self.cookie_id}】✅ 从button提取到订单ID: {order_id}')
|
||
|
||
# 方法1b: 从main的targetUrl中提取order_detail的id
|
||
if not order_id:
|
||
main_target_url = content_data.get('dxCard', {}).get('item', {}).get('main', {}).get('targetUrl', '')
|
||
if main_target_url:
|
||
order_match = re.search(r'order_detail\?id=(\d+)', main_target_url)
|
||
if order_match:
|
||
order_id = order_match.group(1)
|
||
logger.info(f'【{self.cookie_id}】✅ 从main targetUrl提取到订单ID: {order_id}')
|
||
|
||
except Exception as parse_e:
|
||
logger.warning(f"解析内容JSON失败: {parse_e}")
|
||
|
||
# 方法2: 从dynamicOperation中的order_detail URL提取orderId
|
||
if not order_id and content_json_str:
|
||
try:
|
||
content_data = json.loads(content_json_str)
|
||
dynamic_target_url = content_data.get('dynamicOperation', {}).get('changeContent', {}).get('dxCard', {}).get('item', {}).get('main', {}).get('exContent', {}).get('button', {}).get('targetUrl', '')
|
||
if dynamic_target_url:
|
||
# 从order_detail URL中提取id参数
|
||
order_match = re.search(r'order_detail\?id=(\d+)', dynamic_target_url)
|
||
if order_match:
|
||
order_id = order_match.group(1)
|
||
logger.info(f'【{self.cookie_id}】✅ 从order_detail提取到订单ID: {order_id}')
|
||
except Exception as parse_e:
|
||
logger.warning(f"解析dynamicOperation JSON失败: {parse_e}")
|
||
|
||
# 方法2.5: 从 message['3'] 中直接提取 orderId
|
||
if not order_id:
|
||
message_3 = message.get('3')
|
||
if isinstance(message_3, dict):
|
||
direct_order_id = message_3.get('orderId')
|
||
if direct_order_id and str(direct_order_id).isdigit():
|
||
order_id = str(direct_order_id)
|
||
logger.info(f'【{self.cookie_id}】✅ 从message[3].orderId提取到订单ID: {order_id}')
|
||
|
||
# 方法3: 如果前面的方法都失败,尝试在整个消息中搜索订单ID模式
|
||
if not order_id:
|
||
try:
|
||
# 将整个消息转换为字符串进行搜索
|
||
message_str = str(message)
|
||
|
||
# 搜索各种可能的订单ID模式
|
||
patterns = [
|
||
r'orderId["\']?\s*[:=]\s*["\']?(\d{10,})', # orderId: '123' 或 orderId=123
|
||
r'order_detail\?id=(\d{10,})', # order_detail?id=123456789
|
||
r'"id"\s*:\s*"?(\d{10,})"?', # "id":"123456789" 或 "id":123456789
|
||
r'bizOrderId["\']?\s*[:=]\s*["\']?(\d{10,})', # bizOrderId=123456789
|
||
]
|
||
|
||
for pattern in patterns:
|
||
matches = re.findall(pattern, message_str)
|
||
if matches:
|
||
# 取第一个匹配的订单ID
|
||
order_id = matches[0]
|
||
logger.info(f'【{self.cookie_id}】✅ 从消息字符串中提取到订单ID: {order_id} (模式: {pattern})')
|
||
break
|
||
|
||
except Exception as search_e:
|
||
logger.warning(f"在消息字符串中搜索订单ID失败: {search_e}")
|
||
|
||
if order_id:
|
||
logger.info(f'【{self.cookie_id}】🎯 最终提取到订单ID: {order_id}')
|
||
else:
|
||
logger.warning(f'【{self.cookie_id}】❌ 未能从消息中提取到订单ID')
|
||
|
||
return order_id
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】提取订单ID失败: {self._safe_str(e)}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def _extract_order_event_status(message: dict) -> str:
|
||
"""从交易卡片中识别可安全落库的订单状态。"""
|
||
if not isinstance(message, dict):
|
||
return None
|
||
|
||
message_1 = message.get("1")
|
||
if not isinstance(message_1, dict):
|
||
return None
|
||
|
||
message_detail = message_1.get("10")
|
||
event_text = message_detail.get("reminderContent", "") if isinstance(message_detail, dict) else ""
|
||
if not event_text:
|
||
content = message_1.get("6")
|
||
content_detail = content.get("3") if isinstance(content, dict) else None
|
||
event_text = content_detail.get("2", "") if isinstance(content_detail, dict) else ""
|
||
|
||
return {
|
||
"[我已拍下,待付款]": "processing",
|
||
"[我已付款,等待你发货]": "pending_ship",
|
||
"[买家已付款]": "pending_ship",
|
||
"[付款完成]": "pending_ship",
|
||
"[已付款,待发货]": "pending_ship",
|
||
"[你已发货]": "shipped",
|
||
"[你已发货,请等待买家确认收货]": "shipped",
|
||
"[买家确认收货,交易成功]": "completed",
|
||
"[你已确认收货,交易成功]": "completed",
|
||
"[退款成功,钱款已原路退返]": "cancelled",
|
||
"[你关闭了订单,钱款已原路退返]": "cancelled",
|
||
}.get(str(event_text).strip())
|
||
|
||
def _save_order_event_snapshot(
|
||
self,
|
||
order_id: str,
|
||
message: dict,
|
||
item_id: str = None,
|
||
buyer_id: str = None,
|
||
) -> bool:
|
||
"""订单详情暂不可用时,先保存交易卡片中可信的基础信息。"""
|
||
order_status = self._extract_order_event_status(message)
|
||
if not order_id or not order_status:
|
||
return False
|
||
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
message_1 = message.get("1") if isinstance(message, dict) else None
|
||
message_1 = message_1 if isinstance(message_1, dict) else {}
|
||
chat_id_raw = message_1.get("2", "")
|
||
chat_id = str(chat_id_raw).split("@")[0] if chat_id_raw else ""
|
||
|
||
created_at = None
|
||
create_time = message_1.get("5")
|
||
if create_time:
|
||
try:
|
||
created_at = time.strftime(
|
||
"%Y-%m-%d %H:%M:%S",
|
||
time.localtime(int(create_time) / 1000),
|
||
)
|
||
except (TypeError, ValueError, OSError):
|
||
created_at = None
|
||
|
||
# 金额和数量必须来自卖家端接口的真实成交数据。
|
||
# 旧实现取商品挂牌价并把数量固定为 1,多件和议价订单会算错,
|
||
# 这里只在接口不可用时保留状态,不再伪造金额。
|
||
amount = None
|
||
buy_num = None
|
||
auction_price = None
|
||
confirm_fee = None
|
||
refund_fee = None
|
||
post_fee = None
|
||
receiver_name = None
|
||
receiver_phone = None
|
||
receiver_address = None
|
||
real_values = self._pending_order_real_values.pop(order_id, None)
|
||
if real_values:
|
||
amount = real_values.get("amount") or None
|
||
buy_num = real_values.get("buy_num")
|
||
auction_price = real_values.get("auction_price") or None
|
||
confirm_fee = real_values.get("confirm_fee") or None
|
||
refund_fee = real_values.get("refund_fee") or None
|
||
post_fee = real_values.get("post_fee") or None
|
||
receiver_name = real_values.get("receiver_name") or None
|
||
receiver_phone = real_values.get("receiver_phone") or None
|
||
receiver_address = real_values.get("receiver_address") or None
|
||
|
||
existing_order = db_manager.get_order_by_id(order_id)
|
||
snapshot_item_id = item_id
|
||
snapshot_buyer_id = buyer_id
|
||
snapshot_quantity = str(buy_num) if buy_num else None
|
||
snapshot_created_at = created_at
|
||
if existing_order:
|
||
snapshot_item_id = item_id if not existing_order.get("item_id") else None
|
||
snapshot_buyer_id = buyer_id if not existing_order.get("buyer_id") else None
|
||
snapshot_created_at = created_at if not existing_order.get("created_at") else None
|
||
|
||
saved = db_manager.insert_or_update_order(
|
||
order_id=order_id,
|
||
item_id=snapshot_item_id,
|
||
buyer_id=snapshot_buyer_id,
|
||
quantity=snapshot_quantity,
|
||
amount=amount,
|
||
order_status=order_status,
|
||
cookie_id=self.cookie_id,
|
||
created_at=snapshot_created_at,
|
||
chat_id=chat_id,
|
||
buy_num=buy_num,
|
||
auction_price=auction_price,
|
||
confirm_fee=confirm_fee,
|
||
refund_fee=refund_fee,
|
||
post_fee=post_fee,
|
||
receiver_name=receiver_name,
|
||
receiver_phone=receiver_phone,
|
||
receiver_address=receiver_address,
|
||
)
|
||
if saved:
|
||
source = "卖家端接口" if real_values else "交易卡片"
|
||
logger.info(
|
||
f"【{self.cookie_id}】已保存订单快照(金额来源: {source}): "
|
||
f"order_id={order_id}, item_id={item_id}, buyer_id={buyer_id}, status={order_status}"
|
||
)
|
||
return saved
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】保存订单事件快照失败 {order_id}: {self._safe_str(e)}")
|
||
return False
|
||
|
||
async def fetch_order_real_values(self, order_id: str) -> dict:
|
||
"""通过卖家端 sold.get 获取订单的真实成交数据。
|
||
|
||
取代按商品挂牌价推算金额的做法;失败时返回空字典,由调用方决定降级策略。
|
||
"""
|
||
if not order_id or not self.cookies_str:
|
||
return {}
|
||
|
||
try:
|
||
from utils.xianyu_seller_api import (
|
||
XianyuSellerAPI,
|
||
SellerApiError,
|
||
parse_sold_order,
|
||
)
|
||
|
||
api = XianyuSellerAPI(self.cookie_id, self.cookies_str)
|
||
try:
|
||
batch = await api.get_sold_orders(order_ids=str(order_id), rows_per_page=10)
|
||
# 卖家端会下发新的签名令牌,同步回本实例避免后续请求失效
|
||
if api.cookies_str and api.cookies_str != self.cookies_str:
|
||
self.cookies_str = api.cookies_str
|
||
finally:
|
||
await api.close()
|
||
|
||
for item in batch.get("items") or []:
|
||
parsed = parse_sold_order(item)
|
||
if parsed.get("order_id") == str(order_id):
|
||
return parsed
|
||
|
||
logger.warning(f"【{self.cookie_id}】卖家端未返回订单 {order_id} 的成交数据")
|
||
return {}
|
||
except SellerApiError as exc:
|
||
logger.warning(f"【{self.cookie_id}】获取订单真实成交数据失败 {order_id}: {exc}")
|
||
return {}
|
||
except Exception as e:
|
||
logger.error(
|
||
f"【{self.cookie_id}】获取订单真实成交数据异常 {order_id}: {self._safe_str(e)}"
|
||
)
|
||
return {}
|
||
|
||
async def sync_sold_orders(self, days: int = 7, query_code: str = "ALL") -> dict:
|
||
"""全量同步卖出订单,作为消息驱动的兜底对账。
|
||
|
||
不按时间筛选 —— 卖家端接口本身只保留近期订单,加时间条件会把边界订单切掉。
|
||
``days`` 和 ``query_code`` 仅为兼容旧调用保留,实际不再使用。
|
||
|
||
Returns:
|
||
``{"total": int, "saved": int, "failed": int, ...}``
|
||
"""
|
||
if not self.cookies_str:
|
||
return {"total": 0, "saved": 0, "failed": 0}
|
||
|
||
from utils.seller_order_sync import sync_account_orders
|
||
|
||
result = await sync_account_orders(self.cookie_id, self.cookies_str)
|
||
|
||
# 同步接口下发的新令牌,避免后续请求签名失效
|
||
new_cookies = result.get("cookies_str")
|
||
if new_cookies and new_cookies != self.cookies_str:
|
||
self.cookies_str = new_cookies
|
||
|
||
return result
|
||
|
||
async def _handle_auto_delivery(self, websocket, message: dict, send_user_name: str, send_user_id: str,
|
||
item_id: str, chat_id: str, msg_time: str):
|
||
"""统一处理自动发货逻辑"""
|
||
try:
|
||
# 检查商品是否属于当前cookies
|
||
if item_id and item_id != "未知商品":
|
||
try:
|
||
from app.db_manager import db_manager
|
||
item_info = db_manager.get_item_info(self.cookie_id, item_id)
|
||
if not item_info:
|
||
logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 商品 {item_id} 不属于当前账号,跳过自动发货')
|
||
return
|
||
logger.warning(f'[{msg_time}] 【{self.cookie_id}】✅ 商品 {item_id} 归属验证通过')
|
||
except Exception as e:
|
||
logger.error(f'[{msg_time}] 【{self.cookie_id}】检查商品归属失败: {self._safe_str(e)},跳过自动发货')
|
||
return
|
||
|
||
# 提取订单ID
|
||
order_id = self._extract_order_id(message)
|
||
|
||
# 如果order_id不存在,直接返回
|
||
if not order_id:
|
||
logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 未能提取到订单ID,跳过自动发货')
|
||
return
|
||
|
||
# 发货前必须确认订单归属和已付款状态,避免伪造文本或旧消息误触发。
|
||
from app.db_manager import db_manager
|
||
current_order = db_manager.get_order_by_id(order_id)
|
||
if current_order:
|
||
if str(current_order.get('cookie_id') or '') != str(self.cookie_id):
|
||
logger.error(f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 不属于当前账号,拒绝自动发货')
|
||
return
|
||
if current_order.get('item_id') and str(current_order.get('item_id')) != str(item_id):
|
||
logger.error(f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 商品归属不一致,拒绝自动发货')
|
||
return
|
||
if current_order.get('buyer_id') and str(current_order.get('buyer_id')) != str(send_user_id):
|
||
logger.error(f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 买家归属不一致,拒绝自动发货')
|
||
return
|
||
|
||
order_status = (current_order or {}).get('order_status')
|
||
order_detail = None
|
||
if order_status != 'pending_ship':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 本地状态为 {order_status or "未记录"},拉取详情确认付款状态')
|
||
order_detail = await self.fetch_order_detail_info(order_id, item_id, send_user_id)
|
||
if order_detail:
|
||
order_status = order_detail.get('order_status')
|
||
if not order_status or order_status == 'unknown':
|
||
refreshed_order = db_manager.get_order_by_id(order_id)
|
||
order_status = (refreshed_order or {}).get('order_status')
|
||
|
||
if order_status != 'pending_ship':
|
||
logger.warning(
|
||
f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 未确认处于待发货状态'
|
||
f'(当前: {order_status or "unknown"}),跳过自动发货'
|
||
)
|
||
return
|
||
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 已确认付款并等待发货')
|
||
|
||
# 使用订单ID作为锁的键
|
||
lock_key = order_id
|
||
|
||
# 第一重检查:延迟锁状态(在获取锁之前检查,避免不必要的等待)
|
||
if self.is_lock_held(lock_key):
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】🔒【提前检查】订单 {lock_key} 延迟锁仍在持有状态,跳过发货')
|
||
return
|
||
|
||
# 第二重检查:基于时间的冷却机制
|
||
if not self.can_auto_delivery(order_id):
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 在冷却期内,跳过发货')
|
||
return
|
||
|
||
# 获取或创建该订单的锁
|
||
order_lock = self._order_locks[lock_key]
|
||
|
||
# 更新锁的使用时间
|
||
self._lock_usage_times[lock_key] = time.time()
|
||
|
||
# 使用异步锁防止同一订单的并发处理
|
||
async with order_lock:
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】获取订单锁成功: {lock_key},开始处理自动发货')
|
||
|
||
# 第三重检查:获取锁后再次检查延迟锁状态(双重检查,防止在等待锁期间状态发生变化)
|
||
if self.is_lock_held(lock_key):
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】订单 {lock_key} 在获取锁后检查发现延迟锁仍持有,跳过发货')
|
||
return
|
||
|
||
# 第四重检查:获取锁后再次检查冷却状态
|
||
if not self.can_auto_delivery(order_id):
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 在获取锁后检查发现仍在冷却期,跳过发货')
|
||
return
|
||
|
||
protection_result = await self.apply_delivery_block_rules(
|
||
order_id=order_id,
|
||
buyer_id=send_user_id,
|
||
item_id=item_id,
|
||
chat_id=chat_id,
|
||
websocket=websocket,
|
||
)
|
||
if protection_result["action"] == "block":
|
||
logger.warning(
|
||
f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 已停止自动发货: '
|
||
f'{protection_result["reason"]}'
|
||
)
|
||
return
|
||
card_only_delivery = protection_result["action"] == "card_only"
|
||
|
||
# 构造用户URL
|
||
user_url = f'https://www.goofish.com/personal?userId={send_user_id}'
|
||
|
||
# 自动发货逻辑
|
||
try:
|
||
# 设置默认标题(将通过API获取真实商品信息)
|
||
item_title = "待获取商品信息"
|
||
|
||
logger.info(f"【{self.cookie_id}】准备自动发货: item_id={item_id}, item_title={item_title}")
|
||
|
||
# 检查是否需要多数量发货
|
||
quantity_to_send = 1 # 默认发送1个
|
||
|
||
# 检查商品是否开启了多数量发货
|
||
multi_quantity_delivery = db_manager.get_item_multi_quantity_delivery_status(self.cookie_id, item_id)
|
||
|
||
if multi_quantity_delivery and order_id:
|
||
logger.info(f"商品 {item_id} 开启了多数量发货,获取订单详情...")
|
||
try:
|
||
# 付款校验阶段可能已经拉取过详情,优先复用。
|
||
if order_detail is None:
|
||
order_detail = await self.fetch_order_detail_info(order_id, item_id, send_user_id)
|
||
if order_detail and order_detail.get('quantity'):
|
||
try:
|
||
order_quantity = int(order_detail['quantity'])
|
||
if order_quantity > 1:
|
||
quantity_to_send = order_quantity
|
||
logger.info(f"从订单详情获取数量: {order_quantity},将发送 {quantity_to_send} 个卡券")
|
||
else:
|
||
logger.info(f"订单数量为 {order_quantity},发送单个卡券")
|
||
except (ValueError, TypeError):
|
||
logger.warning(f"订单数量格式无效: {order_detail.get('quantity')},发送单个卡券")
|
||
else:
|
||
logger.info(f"未获取到订单数量信息,发送单个卡券")
|
||
except Exception as e:
|
||
logger.error(f"获取订单详情失败: {self._safe_str(e)},发送单个卡券")
|
||
elif not multi_quantity_delivery:
|
||
logger.info(f"商品 {item_id} 未开启多数量发货,发送单个卡券")
|
||
else:
|
||
logger.info(f"无订单ID,发送单个卡券")
|
||
|
||
# 先解析一次商品规格与库存绑定,再按订单数量和每件发货份数取内容。
|
||
delivery_contents = []
|
||
delivery_context = {}
|
||
|
||
try:
|
||
first_content = await self._auto_delivery(
|
||
item_id,
|
||
item_title,
|
||
order_id,
|
||
send_user_id,
|
||
order_detail=order_detail,
|
||
delivery_context=delivery_context,
|
||
requested_item_quantity=quantity_to_send,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"第 1 个卡券获取异常: {self._safe_str(e)}")
|
||
first_content = None
|
||
|
||
if first_content:
|
||
per_item_delivery_count = max(
|
||
1,
|
||
int(delivery_context.get("delivery_count") or 1),
|
||
)
|
||
quantity_to_send *= per_item_delivery_count
|
||
delivery_contents.append(first_content)
|
||
logger.info(
|
||
f"自动发货数量已确定: 订单商品数量系数="
|
||
f"{max(1, quantity_to_send // per_item_delivery_count)}, "
|
||
f"每件发货={per_item_delivery_count},合计={quantity_to_send}"
|
||
)
|
||
else:
|
||
logger.warning("第 1 个卡券内容获取失败,停止本次自动发货")
|
||
|
||
for i in range(1, quantity_to_send):
|
||
if not delivery_contents:
|
||
break
|
||
try:
|
||
# 每次调用都可能获取不同的内容(API卡券、批量数据等)
|
||
delivery_content = await self._auto_delivery(
|
||
item_id,
|
||
item_title,
|
||
order_id,
|
||
send_user_id,
|
||
order_detail=order_detail,
|
||
delivery_context=delivery_context,
|
||
)
|
||
if delivery_content:
|
||
delivery_contents.append(delivery_content)
|
||
if quantity_to_send > 1:
|
||
logger.info(f"第 {i+1}/{quantity_to_send} 个卡券内容获取成功")
|
||
else:
|
||
logger.warning(f"第 {i+1}/{quantity_to_send} 个卡券内容获取失败")
|
||
except Exception as e:
|
||
logger.error(f"第 {i+1}/{quantity_to_send} 个卡券获取异常: {self._safe_str(e)}")
|
||
|
||
if delivery_contents:
|
||
# 标记锁为持有状态,并启动延迟释放任务
|
||
self._lock_hold_info[lock_key] = {
|
||
'locked': True,
|
||
'lock_time': time.time(),
|
||
'release_time': None,
|
||
'task': None
|
||
}
|
||
|
||
# 启动延迟释放锁的异步任务(10分钟后释放)
|
||
delay_task = asyncio.create_task(self._delayed_lock_release(lock_key, delay_minutes=10))
|
||
self._lock_hold_info[lock_key]['task'] = delay_task
|
||
|
||
# 发送所有获取到的发货内容
|
||
sent_count = 0
|
||
send_errors = []
|
||
for i, delivery_content in enumerate(delivery_contents):
|
||
try:
|
||
# 检查是否是图片发送标记
|
||
if delivery_content.startswith("__IMAGE_SEND__"):
|
||
# 提取卡券ID和图片URL
|
||
image_data = delivery_content.replace("__IMAGE_SEND__", "")
|
||
if "|" in image_data:
|
||
card_id_str, image_url = image_data.split("|", 1)
|
||
try:
|
||
card_id = int(card_id_str)
|
||
except ValueError:
|
||
logger.error(f"无效的卡券ID: {card_id_str}")
|
||
card_id = None
|
||
else:
|
||
# 兼容旧格式(没有卡券ID)
|
||
card_id = None
|
||
image_url = image_data
|
||
|
||
# 发送图片消息
|
||
await self.send_image_msg(websocket, chat_id, send_user_id, image_url, card_id=card_id)
|
||
if len(delivery_contents) > 1:
|
||
logger.info(f'[{msg_time}] 【多数量自动发货图片】第 {i+1}/{len(delivery_contents)} 张已向 {user_url} 发送图片: {image_url}')
|
||
else:
|
||
logger.info(f'[{msg_time}] 【自动发货图片】已向 {user_url} 发送图片: {image_url}')
|
||
|
||
# 多数量发货时,消息间隔1秒
|
||
if len(delivery_contents) > 1 and i < len(delivery_contents) - 1:
|
||
await asyncio.sleep(1)
|
||
|
||
else:
|
||
# 普通文本发货内容
|
||
await self.send_msg(websocket, chat_id, send_user_id, delivery_content)
|
||
if len(delivery_contents) > 1:
|
||
logger.info(f'[{msg_time}] 【多数量自动发货】第 {i+1}/{len(delivery_contents)} 条已向 {user_url} 发送发货内容')
|
||
else:
|
||
logger.info(f'[{msg_time}] 【自动发货】已向 {user_url} 发送发货内容')
|
||
|
||
# 多数量发货时,消息间隔1秒
|
||
if len(delivery_contents) > 1 and i < len(delivery_contents) - 1:
|
||
await asyncio.sleep(1)
|
||
|
||
sent_count += 1
|
||
except Exception as e:
|
||
error_message = self._safe_str(e)
|
||
send_errors.append(f"第{i + 1}条: {error_message}")
|
||
logger.error(f"发送第 {i+1} 条消息失败: {error_message}")
|
||
|
||
acquired_all = len(delivery_contents) == quantity_to_send
|
||
sent_all = sent_count == len(delivery_contents)
|
||
|
||
if acquired_all and sent_all:
|
||
confirm_required = self.is_auto_confirm_enabled() and not card_only_delivery
|
||
platform_confirmed = False
|
||
confirm_error = None
|
||
|
||
if confirm_required:
|
||
try:
|
||
confirm_result = await self.auto_confirm(order_id, item_id)
|
||
platform_confirmed = bool(confirm_result and confirm_result.get('success'))
|
||
if platform_confirmed:
|
||
self.confirmed_orders[order_id] = time.time()
|
||
else:
|
||
confirm_error = (confirm_result or {}).get('error', '未知错误')
|
||
except Exception as confirm_exception:
|
||
confirm_error = self._safe_str(confirm_exception)
|
||
|
||
# 卡券全部发送后才记录系统已发货;订单状态只在闲鱼确认成功后推进。
|
||
self.mark_delivery_sent(order_id, update_order_status=platform_confirmed)
|
||
try:
|
||
db_manager.insert_or_update_order(
|
||
order_id=order_id,
|
||
system_shipped=True,
|
||
chat_id=chat_id
|
||
)
|
||
logger.info(f'【{self.cookie_id}】订单 {order_id} 已标记为系统已发货 (system_shipped=1)')
|
||
except Exception as db_e:
|
||
logger.error(f'【{self.cookie_id}】更新订单system_shipped状态失败: {self._safe_str(db_e)}')
|
||
|
||
if card_only_delivery:
|
||
await self.send_delivery_failure_notification(
|
||
send_user_name,
|
||
send_user_id,
|
||
item_id,
|
||
f"订单命中“{protection_result['rule_name']}”并已关闭,仅发送卡券成功",
|
||
chat_id
|
||
)
|
||
elif confirm_required and not platform_confirmed:
|
||
await self.send_delivery_failure_notification(
|
||
send_user_name,
|
||
send_user_id,
|
||
item_id,
|
||
f"卡券已全部发送,但闲鱼确认发货失败,请手动确认:{confirm_error}",
|
||
chat_id
|
||
)
|
||
elif len(delivery_contents) > 1:
|
||
await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, f"多数量发货成功,共发送 {sent_count} 个卡券", chat_id)
|
||
else:
|
||
await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, "发货成功", chat_id)
|
||
else:
|
||
# 内容可能已从批量卡池取出,自动重试可能重复消耗或重复发送,转人工处理。
|
||
self.delivery_blocked_orders.add(order_id)
|
||
self.last_delivery_time[order_id] = time.time()
|
||
failure_detail = (
|
||
f"应发 {quantity_to_send} 个,获取 {len(delivery_contents)} 个,"
|
||
f"成功发送 {sent_count} 个"
|
||
)
|
||
if send_errors:
|
||
failure_detail += f";发送错误:{';'.join(send_errors)}"
|
||
logger.error(f"【{self.cookie_id}】订单 {order_id} 自动发货未完整完成:{failure_detail}")
|
||
await self.send_delivery_failure_notification(
|
||
send_user_name,
|
||
send_user_id,
|
||
item_id,
|
||
f"自动发货未完整完成({failure_detail}),已停止自动重试,请人工核对",
|
||
chat_id
|
||
)
|
||
else:
|
||
logger.warning(f'[{msg_time}] 【自动发货】未找到匹配的发货规则或获取发货内容失败')
|
||
# 发送自动发货失败通知
|
||
await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, "未找到匹配的发货规则或获取发货内容失败", chat_id)
|
||
|
||
except Exception as e:
|
||
logger.error(f"自动发货处理异常: {self._safe_str(e)}")
|
||
# 发送自动发货异常通知
|
||
await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, f"自动发货处理异常: {str(e)}", chat_id)
|
||
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】订单锁释放: {lock_key},自动发货处理完成')
|
||
|
||
except Exception as e:
|
||
logger.error(f"统一自动发货处理异常: {self._safe_str(e)}")
|
||
|
||
|
||
|
||
async def refresh_token(self, captcha_retry_count: int = 0):
|
||
"""刷新token
|
||
|
||
Args:
|
||
captcha_retry_count: 滑块验证重试次数,用于防止无限递归
|
||
"""
|
||
# 初始化通知发送标志,避免重复发送通知
|
||
notification_sent = False
|
||
# 本轮是否已经记过一次风控熔断。滑块失败分支和后面基于 ret 的通用分支
|
||
# 都会 trip(),同一次失败记两次会让冷却阶梯跳级、账号被多锁一倍时间。
|
||
already_tripped = False
|
||
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】开始刷新token... (滑块验证重试次数: {captcha_retry_count})")
|
||
# 标记本次刷新状态
|
||
self.last_token_refresh_status = "started"
|
||
# 重置“刷新流程内已重启”标记,避免多次重启
|
||
self.restarted_in_browser_refresh = False
|
||
|
||
# 风控冷却期内不再尝试刷新 —— 持续请求会让风控一直不解除
|
||
from utils import risk_control
|
||
guard = risk_control.registry.get(self.cookie_id)
|
||
if guard.is_blocked:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】处于风控冷却期(剩余 {guard.remaining_seconds} 秒),"
|
||
f"跳过本次 Token 刷新"
|
||
)
|
||
self.last_token_refresh_status = "risk_control_blocked"
|
||
return None
|
||
|
||
# 人工验证进行中时跳过自动 Token 刷新 —— 自动滑块和人工验证共用
|
||
# browser_limit 全局信号量(默认 3 槽位),自动滑块占满槽位后人工验证
|
||
# 会在 launch_browser 里等 300 秒超时,前端弹窗卡在白屏 loading。
|
||
if getattr(self, 'manual_captcha_in_progress', False):
|
||
logger.info(f"【{self.cookie_id}】人工滑块验证进行中,跳过本次自动 Token 刷新")
|
||
self.last_token_refresh_status = "skipped_manual_captcha"
|
||
return None
|
||
|
||
# 检查滑块验证重试次数,防止无限递归
|
||
if captcha_retry_count >= self.max_captcha_verification_count:
|
||
logger.error(f"【{self.cookie_id}】滑块验证重试次数已达上限 ({self.max_captcha_verification_count}),停止重试")
|
||
await self.send_token_refresh_notification(
|
||
f"滑块验证重试次数已达上限,请手动处理",
|
||
"captcha_max_retries_exceeded"
|
||
)
|
||
notification_sent = True
|
||
return None
|
||
|
||
# 【消息接收检查】检查是否在消息接收后的冷却时间内,与 cookie_refresh_loop 保持一致
|
||
current_time = time.time()
|
||
time_since_last_message = current_time - self.last_message_received_time
|
||
if self.last_message_received_time > 0 and time_since_last_message < self.message_cookie_refresh_cooldown:
|
||
remaining_time = self.message_cookie_refresh_cooldown - time_since_last_message
|
||
remaining_minutes = int(remaining_time // 60)
|
||
remaining_seconds = int(remaining_time % 60)
|
||
logger.info(f"【{self.cookie_id}】收到消息后冷却中,放弃本次token刷新,还需等待 {remaining_minutes}分{remaining_seconds}秒")
|
||
# 标记为因冷却而跳过(正常情况)
|
||
self.last_token_refresh_status = "skipped_cooldown"
|
||
return None
|
||
|
||
# 【重要】在刷新token前,先从数据库重新加载最新的cookie
|
||
# 这样即使用户已经手动更新了cookie,代码也会使用最新的cookie
|
||
logger.info(f"【{self.cookie_id}】开始执行Cookie刷新任务...")
|
||
# await self._execute_cookie_refresh(time.time())
|
||
try:
|
||
from app.db_manager import db_manager
|
||
# 必须走线程池。db_manager 是同步 SQLite 且带全局锁,直接在事件
|
||
# 循环里调用会把整个循环卡住 —— 此时 HTTP 线程通过
|
||
# run_coroutine_threadsafe 提交的删除、更新等操作永远得不到调度,
|
||
# 表现为「点删除账号没反应,最后报超时」。
|
||
account_info = await asyncio.to_thread(
|
||
db_manager.get_cookie_details, self.cookie_id
|
||
)
|
||
if account_info and account_info.get('cookie_value'):
|
||
new_cookies_str = account_info.get('cookie_value')
|
||
if new_cookies_str != self.cookies_str:
|
||
logger.info(f"【{self.cookie_id}】检测到数据库中的cookie已更新,重新加载cookie")
|
||
self.cookies_str = new_cookies_str
|
||
# 更新cookies字典
|
||
self.cookies = trans_cookies(self.cookies_str)
|
||
logger.warning(f"【{self.cookie_id}】Cookie已从数据库重新加载")
|
||
except Exception as reload_e:
|
||
logger.warning(f"【{self.cookie_id}】从数据库重新加载cookie失败,继续使用当前cookie: {self._safe_str(reload_e)}")
|
||
|
||
# 生成更精确的时间戳
|
||
timestamp = str(int(time.time() * 1000))
|
||
|
||
params = {
|
||
'jsv': '2.7.2',
|
||
'appKey': '34839810',
|
||
't': timestamp,
|
||
'sign': '',
|
||
'v': '1.0',
|
||
'type': 'originaljson',
|
||
'accountSite': 'xianyu',
|
||
'dataType': 'json',
|
||
'timeout': '20000',
|
||
'api': 'mtop.taobao.idlemessage.pc.login.token',
|
||
'sessionOption': 'AutoLoginOnly',
|
||
'dangerouslySetWindvaneParams': '%5Bobject%20Object%5D',
|
||
'smToken': 'token',
|
||
'queryToken': 'sm',
|
||
'sm': 'sm',
|
||
'spm_cnt': 'a21ybx.im.0.0',
|
||
'spm_pre': 'a21ybx.home.sidebar.1.4c053da6vYwnmf',
|
||
'log_id': '4c053da6vYwnmf'
|
||
}
|
||
data_val = '{"appKey":"444e9908a51d1cb236a27862abc769c9","deviceId":"' + self.device_id + '"}'
|
||
data = {
|
||
'data': data_val,
|
||
}
|
||
|
||
# 获取token
|
||
token = None
|
||
token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
|
||
|
||
sign = generate_sign(params['t'], token, data_val)
|
||
params['sign'] = sign
|
||
|
||
# 发送请求 - 使用与浏览器完全一致的请求头
|
||
headers = {
|
||
'accept': 'application/json',
|
||
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||
'cache-control': 'no-cache',
|
||
'content-type': 'application/x-www-form-urlencoded',
|
||
'pragma': 'no-cache',
|
||
'priority': 'u=1, i',
|
||
'sec-ch-ua': '"Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"',
|
||
'sec-ch-ua-mobile': '?0',
|
||
'sec-ch-ua-platform': '"Windows"',
|
||
'sec-fetch-dest': 'empty',
|
||
'sec-fetch-mode': 'cors',
|
||
'sec-fetch-site': 'same-site',
|
||
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
|
||
'referer': 'https://www.goofish.com/',
|
||
'origin': 'https://www.goofish.com',
|
||
'cookie': self.cookies_str
|
||
}
|
||
|
||
api_url = API_ENDPOINTS.get('token')
|
||
cookie_dict = trans_cookies(self.cookies_str)
|
||
logger.info(
|
||
f"【{self.cookie_id}】Token刷新请求: endpoint={api_url}, method=POST, "
|
||
f"cookie_fields={len(cookie_dict)}, cookie_length={len(self.cookies_str)}, "
|
||
f"token_present={bool(token)}, token_length={len(token)}, "
|
||
f"payload_length={len(data_val)}"
|
||
)
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(
|
||
api_url,
|
||
params=params,
|
||
data=data,
|
||
headers=headers,
|
||
timeout=aiohttp.ClientTimeout(total=30)
|
||
) as response:
|
||
logger.info(
|
||
f"【{self.cookie_id}】Token刷新响应: status={response.status}, "
|
||
f"content_type={response.headers.get('content-type', 'unknown')}"
|
||
)
|
||
|
||
res_json = await response.json()
|
||
response_keys = sorted(res_json.keys()) if isinstance(res_json, dict) else []
|
||
response_data = res_json.get("data") if isinstance(res_json, dict) else None
|
||
data_keys = sorted(response_data.keys()) if isinstance(response_data, dict) else []
|
||
# ret 是闲鱼说明失败原因的唯一字段(TOKEN 过期、未登录、风控拦截
|
||
# 各有不同取值)。原先只记结构不记 ret,日志里只能看到
|
||
# 「data_keys=[] / Token获取失败」,等于把唯一的线索丢了。
|
||
response_ret = res_json.get("ret") if isinstance(res_json, dict) else None
|
||
logger.info(
|
||
f"【{self.cookie_id}】Token刷新响应结构: "
|
||
f"type={type(res_json).__name__}, keys={response_keys}, "
|
||
f"data_keys={data_keys}, ret={response_ret}, "
|
||
f"has_access_token={isinstance(response_data, dict) and bool(response_data.get('accessToken'))}"
|
||
)
|
||
logger.info(f"【{self.cookie_id}】================================")
|
||
|
||
# 检查并更新Cookie
|
||
if 'set-cookie' in response.headers:
|
||
new_cookies = {}
|
||
for cookie in response.headers.getall('set-cookie', []):
|
||
if '=' in cookie:
|
||
name, value = cookie.split(';')[0].split('=', 1)
|
||
new_cookies[name.strip()] = value.strip()
|
||
|
||
# 更新cookies
|
||
if new_cookies:
|
||
self.cookies.update(new_cookies)
|
||
# 生成新的cookie字符串
|
||
self.cookies_str = '; '.join([f"{k}={v}" for k, v in self.cookies.items()])
|
||
# 更新数据库中的Cookie
|
||
await self.update_config_cookies()
|
||
logger.warning("已更新Cookie到数据库")
|
||
|
||
if isinstance(res_json, dict):
|
||
ret_value = res_json.get('ret', [])
|
||
# 检查ret是否包含成功信息
|
||
if any('SUCCESS::调用成功' in ret for ret in ret_value):
|
||
if 'data' in res_json and 'accessToken' in res_json['data']:
|
||
new_token = res_json['data']['accessToken']
|
||
self.current_token = new_token
|
||
self.last_token_refresh_time = time.time()
|
||
|
||
# 【消息接收时间重置】Token刷新成功后重置消息接收标志,与 cookie_refresh_loop 保持一致
|
||
self.last_message_received_time = 0
|
||
logger.warning(f"【{self.cookie_id}】Token刷新成功,已重置消息接收时间标识")
|
||
|
||
logger.info(f"【{self.cookie_id}】Token刷新成功")
|
||
# 标记为成功
|
||
self.last_token_refresh_status = "success"
|
||
# 拿到有效 token 就说明登录态是活的,必须清掉
|
||
# 「需重新扫码」终态标记。它曾经只置不清,于是账号
|
||
# 明明已经连上、订单和消息都在同步,界面还一直挂着
|
||
# 「需重新扫码」,把用户引去做一次没必要的扫码。
|
||
if self.needs_relogin:
|
||
logger.info(
|
||
f"【{self.cookie_id}】登录态已恢复,"
|
||
f"清除「需重新扫码」标记"
|
||
)
|
||
self.needs_relogin = False
|
||
self.relogin_reason = ''
|
||
risk_control.registry.get(self.cookie_id).reset()
|
||
return new_token
|
||
|
||
# 检查是否需要滑块验证
|
||
if self._need_captcha_verification(res_json):
|
||
logger.warning(f"【{self.cookie_id}】检测到需要滑块验证,开始处理...")
|
||
|
||
# 记录滑块验证检测到日志文件
|
||
verification_url = res_json.get('data', {}).get('url', 'Token刷新时检测')
|
||
log_captcha_event(self.cookie_id, "检测到滑块验证", None, f"触发场景: Token刷新, URL: {verification_url}")
|
||
|
||
# 添加风控日志记录
|
||
log_id = None
|
||
try:
|
||
from app.db_manager import db_manager
|
||
success = db_manager.add_risk_control_log(
|
||
cookie_id=self.cookie_id,
|
||
event_type='slider_captcha',
|
||
event_description=f"检测到需要滑块验证,触发场景: Token刷新, URL: {verification_url}",
|
||
processing_status='processing'
|
||
)
|
||
if success:
|
||
# 获取刚插入的记录ID(简单方式,实际应该返回ID)
|
||
logs = db_manager.get_risk_control_logs(cookie_id=self.cookie_id, limit=1)
|
||
if logs:
|
||
log_id = logs[0].get('id')
|
||
logger.info(f"【{self.cookie_id}】风控日志记录成功,ID: {log_id}")
|
||
except Exception as log_e:
|
||
logger.error(f"【{self.cookie_id}】记录风控日志失败: {log_e}")
|
||
|
||
try:
|
||
# 尝试通过滑块验证获取新的cookies
|
||
captcha_start_time = time.time()
|
||
new_cookies_str = await self._handle_captcha_verification(res_json)
|
||
captcha_duration = time.time() - captcha_start_time
|
||
|
||
if new_cookies_str:
|
||
logger.info(f"【{self.cookie_id}】滑块验证成功,准备重启实例...")
|
||
|
||
# 更新风控日志为成功状态
|
||
if 'log_id' in locals() and log_id:
|
||
try:
|
||
from app.db_manager import db_manager
|
||
db_manager.update_risk_control_log(
|
||
log_id=log_id,
|
||
processing_result=f"滑块验证成功,耗时: {captcha_duration:.2f}秒, cookies长度: {len(new_cookies_str)}",
|
||
processing_status='success'
|
||
)
|
||
except Exception as update_e:
|
||
logger.error(f"【{self.cookie_id}】更新风控日志失败: {update_e}")
|
||
|
||
# 重启实例(cookies已在_handle_captcha_verification中更新到数据库)
|
||
# await self._restart_instance()
|
||
|
||
# 重新尝试刷新token(递归调用,但有深度限制)
|
||
return await self.refresh_token(captcha_retry_count + 1)
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】滑块验证失败")
|
||
|
||
# 自动验证失败后立即熔断。实测滑块虽被拖到目标位置,
|
||
# 服务端仍判定失败(行为特征识别),继续自动重试不会成功,
|
||
# 只会让风控持续更久 —— 此时应转人工处理。
|
||
risk_control.registry.get(self.cookie_id).trip(
|
||
"滑块自动验证失败,需人工处理"
|
||
)
|
||
# 本轮已经熔断过,后面基于 ret 的通用风控分支不要再记一次:
|
||
# 同一次失败连续 trip 两次会让冷却阶梯跳级
|
||
# (300 秒直接跳到 600 秒),账号被多锁一倍时间。
|
||
already_tripped = True
|
||
|
||
# 更新风控日志为失败状态
|
||
if 'log_id' in locals() and log_id:
|
||
try:
|
||
from app.db_manager import db_manager
|
||
db_manager.update_risk_control_log(
|
||
log_id=log_id,
|
||
processing_result=f"滑块验证失败,耗时: {captcha_duration:.2f}秒, 原因: 未获取到新cookies",
|
||
processing_status='failed'
|
||
)
|
||
except Exception as update_e:
|
||
logger.error(f"【{self.cookie_id}】更新风控日志失败: {update_e}")
|
||
|
||
# 标记已发送通知(通知已在_handle_captcha_verification中发送)
|
||
notification_sent = True
|
||
|
||
# 自动验证已无望,给出可操作的人工处理指引。
|
||
# 滑块被拖到目标位置仍被判失败时,重试再多次也不会通过。
|
||
try:
|
||
await self.send_token_refresh_notification(
|
||
"滑块自动验证失败,需要人工处理\n\n"
|
||
"处理方式(任选其一):\n"
|
||
"1. 在账号管理页重新扫码登录(最直接)\n"
|
||
"2. 用浏览器登录 www.goofish.com 手动完成验证后更新 Cookie\n\n"
|
||
"系统已暂停该账号的自动请求,避免风控加重。",
|
||
"captcha_manual_required",
|
||
)
|
||
except Exception as notify_error:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】发送人工处理通知失败: "
|
||
f"{self._safe_str(notify_error)}"
|
||
)
|
||
except Exception as captcha_e:
|
||
logger.error(f"【{self.cookie_id}】滑块验证处理异常: {self._safe_str(captcha_e)}")
|
||
|
||
# 更新风控日志为异常状态
|
||
captcha_duration = time.time() - captcha_start_time if 'captcha_start_time' in locals() else 0
|
||
if 'log_id' in locals() and log_id:
|
||
try:
|
||
from app.db_manager import db_manager
|
||
db_manager.update_risk_control_log(
|
||
log_id=log_id,
|
||
processing_result=f"滑块验证处理异常,耗时: {captcha_duration:.2f}秒",
|
||
processing_status='failed',
|
||
error_message=str(captcha_e)
|
||
)
|
||
except Exception as update_e:
|
||
logger.error(f"【{self.cookie_id}】更新风控日志失败: {update_e}")
|
||
|
||
# 标记已发送通知(通知已在_handle_captcha_verification中发送)
|
||
notification_sent = True
|
||
|
||
# 「令牌过期」和「Session过期」必须分开处理 —— 它们不是一回事:
|
||
#
|
||
# FAIL_SYS_TOKEN_EXOIRED::令牌过期
|
||
# mtop 的签名令牌 _m_h5_tk 过时了。失败响应本身就会
|
||
# set-cookie 下发新令牌,重签一次即可,属可恢复。
|
||
# FAIL_SYS_SESSION_EXPIRED::Session过期
|
||
# 登录会话(cookie2 / unb)真的死了,滑块和等待都救不回来,
|
||
# 只能重新扫码。
|
||
#
|
||
# 原来一个 if 把两者一起打上「需重新扫码」终态,于是仅仅令牌
|
||
# 过期也会让界面提示重扫;而实测紧接着的下一次刷新就返回
|
||
# SUCCESS,账号完全正常。
|
||
if isinstance(res_json, dict):
|
||
res_json_str = json.dumps(res_json, ensure_ascii=False, separators=(',', ':'))
|
||
session_expired = 'Session过期' in res_json_str
|
||
token_expired = '令牌过期' in res_json_str
|
||
|
||
if token_expired and not session_expired:
|
||
# 令牌已随本次响应更新,直接带新令牌重试。必须递增计数:
|
||
# 递归上限由 refresh_token 开头的
|
||
# max_captcha_verification_count 统一把关。
|
||
logger.warning(
|
||
f"【{self.cookie_id}】签名令牌过期(可恢复),"
|
||
f"带新令牌重试第 {captcha_retry_count + 1} 次"
|
||
)
|
||
return await self.refresh_token(captcha_retry_count + 1)
|
||
|
||
if session_expired:
|
||
# 调用统一的密码登录刷新方法
|
||
refresh_success = await self._try_password_login_refresh("Session过期")
|
||
|
||
if not refresh_success:
|
||
# 会话过期和风控是两回事:滑块过了也救不回来,只能重新登录。
|
||
# 打上终态标记,让界面能明确提示"请重新扫码",
|
||
# 否则用户只看到「连接中/重连」,会一直等一个不会好的状态。
|
||
self.needs_relogin = True
|
||
self.relogin_reason = '闲鱼登录态已过期,请重新扫码登录'
|
||
logger.error(
|
||
f"【{self.cookie_id}】登录态已过期且无法自动续期"
|
||
f"(未配置账号密码或密码登录失败),需要重新扫码登录"
|
||
)
|
||
# 标记已发送通知,避免重复通知
|
||
notification_sent = True
|
||
# 返回None,让调用者知道刷新失败
|
||
return None
|
||
else:
|
||
self.needs_relogin = False
|
||
self.relogin_reason = ''
|
||
# 刷新成功后重新获取 token。必须递增计数,否则上限判断
|
||
# 永远不成立,会形成无限递归重试并持续加剧平台风控。
|
||
return await self.refresh_token(captcha_retry_count + 1)
|
||
|
||
ret_value = res_json.get('ret', []) if isinstance(res_json, dict) else []
|
||
logger.error(
|
||
f"【{self.cookie_id}】Token刷新失败: status={response.status}, "
|
||
f"ret={ret_value[:3]}, response_type={type(res_json).__name__}"
|
||
)
|
||
|
||
# 平台风控:立刻熔断,避免重试风暴反复触发验证
|
||
if risk_control.is_risk_control_error(json.dumps(ret_value, ensure_ascii=False)):
|
||
if not already_tripped:
|
||
guard.trip(str(ret_value[:2]))
|
||
self.last_token_refresh_status = "risk_control"
|
||
return None
|
||
|
||
# 清空当前token,确保下次重试时重新获取
|
||
self.current_token = None
|
||
|
||
# 只有在没有发送过通知的情况下才发送Token刷新失败通知
|
||
# 并且WebSocket未连接时才发送(已连接说明只是暂时失败)
|
||
if not notification_sent:
|
||
# 检查WebSocket连接状态
|
||
is_ws_connected = (
|
||
self.connection_state == ConnectionState.CONNECTED and
|
||
self.ws and
|
||
not self.ws.closed
|
||
)
|
||
|
||
if is_ws_connected:
|
||
logger.info(f"【{self.cookie_id}】WebSocket连接正常,Token刷新失败可能是暂时的,跳过失败通知")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送Token刷新失败通知")
|
||
await self.send_token_refresh_notification(
|
||
f"Token刷新失败: {ret_value[:3]}",
|
||
"token_refresh_failed",
|
||
)
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】已发送滑块验证相关通知,跳过Token刷新失败通知")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"Token刷新异常: {self._safe_str(e)}")
|
||
|
||
# 清空当前token,确保下次重试时重新获取
|
||
self.current_token = None
|
||
|
||
# 只有在没有发送过通知的情况下才发送Token刷新异常通知
|
||
# 并且WebSocket未连接时才发送(已连接说明只是暂时失败)
|
||
if not notification_sent:
|
||
# 检查WebSocket连接状态
|
||
is_ws_connected = (
|
||
self.connection_state == ConnectionState.CONNECTED and
|
||
self.ws and
|
||
not self.ws.closed
|
||
)
|
||
|
||
if is_ws_connected:
|
||
logger.info(f"【{self.cookie_id}】WebSocket连接正常,Token刷新异常可能是暂时的,跳过失败通知")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送Token刷新异常通知")
|
||
await self.send_token_refresh_notification(f"Token刷新异常: {str(e)}", "token_refresh_exception")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】已发送滑块验证相关通知,跳过Token刷新异常通知")
|
||
return None
|
||
|
||
def _need_captcha_verification(self, res_json: dict) -> bool:
|
||
"""检查响应是否需要滑块验证"""
|
||
try:
|
||
if not isinstance(res_json, dict):
|
||
return False
|
||
|
||
log_captcha_event(
|
||
self.cookie_id,
|
||
"检查滑块验证响应",
|
||
None,
|
||
f"响应字段: {sorted(res_json.keys())}",
|
||
)
|
||
|
||
# 检查返回的错误信息
|
||
ret_value = res_json.get('ret', [])
|
||
if not ret_value:
|
||
return False
|
||
|
||
# 检查是否包含需要验证的关键词
|
||
captcha_keywords = [
|
||
'FAIL_SYS_USER_VALIDATE', # 用户验证失败
|
||
'RGV587_ERROR', # 风控错误
|
||
'哎哟喂,被挤爆啦', # 被挤爆了
|
||
'哎哟喂,被挤爆啦', # 被挤爆了(中文逗号)
|
||
'挤爆了', # 挤爆了
|
||
'请稍后重试', # 请稍后重试
|
||
'punish?x5secdata', # 惩罚页面
|
||
'captcha', # 验证码
|
||
]
|
||
|
||
error_msg = str(ret_value[0]) if ret_value else ''
|
||
|
||
# 检查错误信息是否包含需要验证的关键词
|
||
for keyword in captcha_keywords:
|
||
if keyword in error_msg:
|
||
logger.info(f"【{self.cookie_id}】检测到需要滑块验证的关键词: {keyword}")
|
||
return True
|
||
|
||
# 检查data字段中是否包含验证URL
|
||
data = res_json.get('data', {})
|
||
if isinstance(data, dict) and 'url' in data:
|
||
url = data.get('url', '')
|
||
if 'punish' in url or 'captcha' in url or 'validate' in url:
|
||
logger.info(f"【{self.cookie_id}】检测到验证URL: {url}")
|
||
return True
|
||
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】检查是否需要滑块验证时出错: {self._safe_str(e)}")
|
||
return False
|
||
|
||
async def _handle_captcha_verification(self, res_json: dict) -> str:
|
||
"""处理滑块验证,返回新的cookies字符串"""
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】开始处理滑块验证...")
|
||
|
||
# 获取验证URL
|
||
verification_url = None
|
||
|
||
# 从data字段获取URL
|
||
data = res_json.get('data', {})
|
||
if isinstance(data, dict) and 'url' in data:
|
||
verification_url = data.get('url')
|
||
|
||
# 如果没有找到URL,使用默认的验证页面
|
||
if not verification_url:
|
||
logger.info(f"【{self.cookie_id}】未找到验证URL,认为不需要滑块验证,返回正常")
|
||
return None
|
||
|
||
logger.info(f"【{self.cookie_id}】验证URL: {verification_url}")
|
||
|
||
# 使用滑块验证器(独立实例,解决并发冲突)
|
||
try:
|
||
# 使用集成的滑块验证方法(无需猴子补丁)
|
||
from utils.xianyu_slider_stealth import XianyuSliderStealth
|
||
logger.info(f"【{self.cookie_id}】XianyuSliderStealth导入成功,使用滑块验证")
|
||
|
||
# 创建独立的滑块验证实例(每个用户独立实例,避免并发冲突)
|
||
# headless 必须为 False:实测同一账号、同一轨迹下,
|
||
# 无头 0/2 通过,有头 1/3 通过且平台确认解除风控。
|
||
# Chrome 无头的 WebGL 渲染器、字体列表、navigator.plugins、
|
||
# 屏幕参数与有头差异巨大,会被阿里 nc 直接识破。
|
||
# 服务器无显示器时用 Xvfb 提供虚拟显示:
|
||
# xvfb-run -a --server-args="-screen 0 1920x1080x24" python Start.py
|
||
slider_headless = os.getenv('SLIDER_HEADLESS', 'false').lower() == 'true'
|
||
slider_stealth = XianyuSliderStealth(
|
||
user_id=f"{self.cookie_id}",
|
||
enable_learning=True, # 启用学习功能
|
||
headless=slider_headless
|
||
)
|
||
|
||
# 在线程池中执行滑块验证
|
||
import asyncio
|
||
import concurrent.futures
|
||
|
||
loop = asyncio.get_event_loop()
|
||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||
# 执行滑块验证。必须把账号 Cookie 一并传入 ——
|
||
# 惩罚页的 x5secdata 绑定账号会话,不带 Cookie 时即使滑块拖过,
|
||
# 回收到的也只是空浏览器凭证,风控不会解除。
|
||
success, cookies = await loop.run_in_executor(
|
||
executor,
|
||
slider_stealth.run,
|
||
verification_url,
|
||
self.cookies_str
|
||
)
|
||
|
||
if success and cookies:
|
||
# 边界防御:只有 x5sec 才是通行凭证。x5secdata / x5sectag 是挑战
|
||
# 标记,必然存在,不能拿它们当验证通过的证据 —— 否则会把一堆
|
||
# 挑战 cookie 写回账号,token 刷新永远 FAIL_SYS_USER_VALIDATE。
|
||
if 'x5sec' not in {k.lower() for k in cookies}:
|
||
logger.error(
|
||
f"【{self.cookie_id}】滑块返回的 cookie 中没有 x5sec,"
|
||
f"视觉通过但服务端未放行,按失败处理。"
|
||
f"已有key: {list(cookies.keys())}"
|
||
)
|
||
success = False
|
||
cookies = None
|
||
|
||
if success and cookies:
|
||
logger.info(f"【{self.cookie_id}】滑块验证成功,获取到新的cookies")
|
||
|
||
# 只提取x5sec相关的cookie值进行更新
|
||
updated_cookies = self.cookies.copy() # 复制现有cookies
|
||
new_cookie_count = 0
|
||
updated_cookie_count = 0
|
||
x5sec_cookies = {}
|
||
|
||
# 筛选出x5相关的cookies(包括x5sec, x5step等)
|
||
for cookie_name, cookie_value in cookies.items():
|
||
cookie_name_lower = cookie_name.lower()
|
||
if cookie_name_lower.startswith('x5') or 'x5sec' in cookie_name_lower:
|
||
x5sec_cookies[cookie_name] = cookie_value
|
||
|
||
logger.info(f"【{self.cookie_id}】找到{len(x5sec_cookies)}个x5相关cookies: {list(x5sec_cookies.keys())}")
|
||
|
||
# 只更新x5相关的cookies
|
||
for cookie_name, cookie_value in x5sec_cookies.items():
|
||
if cookie_name in updated_cookies:
|
||
if updated_cookies[cookie_name] != cookie_value:
|
||
logger.warning(f"【{self.cookie_id}】更新x5 cookie: {cookie_name}")
|
||
updated_cookies[cookie_name] = cookie_value
|
||
updated_cookie_count += 1
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】x5 cookie值未变: {cookie_name}")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】新增x5 cookie: {cookie_name}")
|
||
updated_cookies[cookie_name] = cookie_value
|
||
new_cookie_count += 1
|
||
|
||
# 拿到 x5sec 就必须清掉挑战标记。x5secdata / x5sectag 表示"这个请求
|
||
# 还有一道未完成的人机验证",而 x5sec 才是通过凭证。
|
||
# 原来只做新增和覆盖、从不删除,于是滑块过了以后 Cookie 里
|
||
# x5sec 和旧的 x5secdata 同时存在 —— 闲鱼据此认为挑战仍未完成,
|
||
# 继续返回 FAIL_SYS_USER_VALIDATE,表现为"滑块过了却一直用不了"。
|
||
if 'x5sec' in {k.lower() for k in x5sec_cookies}:
|
||
for stale in CAPTCHA_CHALLENGE_COOKIES:
|
||
for name in [k for k in updated_cookies if k.lower() == stale]:
|
||
# 本次滑块响应又下发了同名值时以新值为准,不要删
|
||
if name not in x5sec_cookies:
|
||
updated_cookies.pop(name, None)
|
||
logger.warning(
|
||
f"【{self.cookie_id}】已清除过期的验证挑战标记: {name}"
|
||
)
|
||
|
||
# 将合并后的cookies字典转换为字符串格式
|
||
cookies_str = "; ".join([f"{k}={v}" for k, v in updated_cookies.items()])
|
||
|
||
logger.info(f"【{self.cookie_id}】x5 Cookie更新完成: 新增{new_cookie_count}个, 更新{updated_cookie_count}个, 总计{len(updated_cookies)}个")
|
||
|
||
# 自动更新数据库中的cookie
|
||
try:
|
||
# 备份原有cookies
|
||
old_cookies_str = self.cookies_str
|
||
old_cookies_dict = self.cookies.copy()
|
||
|
||
# 更新当前实例的cookies(使用合并后的cookies)
|
||
self.cookies_str = cookies_str
|
||
self.cookies = updated_cookies
|
||
|
||
# 更新数据库中的cookies
|
||
await self.update_config_cookies()
|
||
logger.info(f"【{self.cookie_id}】滑块验证成功后,数据库cookies已自动更新")
|
||
|
||
|
||
log_captcha_event(self.cookie_id, "滑块验证成功并自动更新数据库", True,
|
||
f"cookies长度: {len(cookies_str)}, 新增{new_cookie_count}个x5, 更新{updated_cookie_count}个x5, 总计{len(updated_cookies)}个cookie项, x5字段: {sorted(x5sec_cookies.keys())}")
|
||
|
||
# 发送成功通知
|
||
await self.send_token_refresh_notification(
|
||
f"滑块验证成功,cookies已自动更新到数据库",
|
||
"captcha_success_auto_update"
|
||
)
|
||
|
||
except Exception as update_e:
|
||
logger.error(f"【{self.cookie_id}】自动更新数据库cookies失败: {self._safe_str(update_e)}")
|
||
|
||
# 回滚cookies
|
||
self.cookies_str = old_cookies_str
|
||
self.cookies = old_cookies_dict
|
||
|
||
log_captcha_event(self.cookie_id, "滑块验证成功但数据库更新失败", False,
|
||
f"更新异常: {self._safe_str(update_e)[:100]}, x5字段: {sorted(x5sec_cookies.keys())}")
|
||
|
||
# 发送更新失败通知
|
||
await self.send_token_refresh_notification(
|
||
f"滑块验证成功但数据库更新失败: {self._safe_str(update_e)}",
|
||
"captcha_success_db_update_failed"
|
||
)
|
||
|
||
return cookies_str
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】滑块验证失败")
|
||
|
||
# 记录滑块验证失败到日志文件
|
||
log_captcha_event(self.cookie_id, "滑块验证失败", False,
|
||
f"XianyuSliderStealth执行失败, 环境: {'Docker' if os.getenv('DOCKER_ENV') else '本地'}")
|
||
|
||
# 发送通知(检查WebSocket连接状态)
|
||
# 只有在WebSocket未连接时才发送通知,已连接说明可能是暂时性问题
|
||
is_ws_connected = (
|
||
self.connection_state == ConnectionState.CONNECTED and
|
||
self.ws and
|
||
not self.ws.closed
|
||
)
|
||
|
||
if is_ws_connected:
|
||
logger.info(f"【{self.cookie_id}】WebSocket连接正常,滑块验证失败可能是暂时的,跳过通知")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送滑块验证失败通知")
|
||
await self.send_token_refresh_notification(
|
||
f"滑块验证失败,需要手动处理。验证URL: {verification_url}",
|
||
"captcha_verification_failed"
|
||
)
|
||
return None
|
||
|
||
except ImportError as import_e:
|
||
logger.error(f"【{self.cookie_id}】XianyuSliderStealth导入失败: {import_e}")
|
||
logger.error(f"【{self.cookie_id}】请安装Playwright库: pip install playwright")
|
||
|
||
# 记录导入失败到日志文件
|
||
log_captcha_event(self.cookie_id, "XianyuSliderStealth导入失败", False,
|
||
f"Playwright未安装, 错误: {import_e}")
|
||
|
||
# 发送通知
|
||
await self.send_token_refresh_notification(
|
||
f"滑块验证功能不可用,请安装Playwright。验证URL: {verification_url}",
|
||
"captcha_dependency_missing"
|
||
)
|
||
return None
|
||
|
||
except Exception as stealth_e:
|
||
logger.error(f"【{self.cookie_id}】滑块验证异常: {self._safe_str(stealth_e)}")
|
||
|
||
# 记录异常到日志文件
|
||
log_captcha_event(self.cookie_id, "滑块验证异常", False,
|
||
f"执行异常, 错误: {self._safe_str(stealth_e)[:100]}")
|
||
|
||
# 发送通知(检查WebSocket连接状态)
|
||
# 只有在WebSocket未连接时才发送通知,已连接说明可能是暂时性问题
|
||
is_ws_connected = (
|
||
self.connection_state == ConnectionState.CONNECTED and
|
||
self.ws and
|
||
not self.ws.closed
|
||
)
|
||
|
||
if is_ws_connected:
|
||
logger.info(f"【{self.cookie_id}】WebSocket连接正常,滑块验证执行异常可能是暂时的,跳过通知")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送滑块验证执行异常通知")
|
||
await self.send_token_refresh_notification(
|
||
f"滑块验证执行异常,需要手动处理。验证URL: {verification_url}",
|
||
"captcha_execution_error"
|
||
)
|
||
return None
|
||
|
||
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】处理滑块验证时出错: {self._safe_str(e)}")
|
||
return None
|
||
|
||
async def _update_cookies_and_restart(self, new_cookies_str: str):
|
||
"""更新cookies并重启任务"""
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】开始更新cookies并重启任务...")
|
||
|
||
# 验证新cookies的有效性
|
||
if not new_cookies_str or not new_cookies_str.strip():
|
||
logger.error(f"【{self.cookie_id}】新cookies为空,无法更新")
|
||
return False
|
||
|
||
# 解析新cookies,确保格式正确
|
||
try:
|
||
new_cookies_dict = trans_cookies(new_cookies_str)
|
||
if not new_cookies_dict:
|
||
logger.error(f"【{self.cookie_id}】新cookies解析失败,无法更新")
|
||
return False
|
||
logger.info(f"【{self.cookie_id}】新cookies解析成功,包含 {len(new_cookies_dict)} 个字段")
|
||
except Exception as parse_e:
|
||
logger.error(f"【{self.cookie_id}】新cookies解析异常: {self._safe_str(parse_e)}")
|
||
return False
|
||
|
||
# 合并cookies:保留原有cookies,只更新新获取到的字段
|
||
try:
|
||
# 获取当前的cookies字典
|
||
current_cookies_dict = trans_cookies(self.cookies_str)
|
||
logger.info(f"【{self.cookie_id}】当前cookies包含 {len(current_cookies_dict)} 个字段")
|
||
|
||
# 合并cookies:新cookies覆盖旧cookies中的相同字段
|
||
merged_cookies_dict = current_cookies_dict.copy()
|
||
updated_fields = []
|
||
|
||
for key, value in new_cookies_dict.items():
|
||
if key in merged_cookies_dict:
|
||
if merged_cookies_dict[key] != value:
|
||
merged_cookies_dict[key] = value
|
||
updated_fields.append(key)
|
||
else:
|
||
merged_cookies_dict[key] = value
|
||
updated_fields.append(f"{key}(新增)")
|
||
|
||
if updated_fields:
|
||
logger.info(f"【{self.cookie_id}】更新的cookie字段: {', '.join(updated_fields)}")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】没有cookie字段需要更新")
|
||
|
||
# 重新组装cookies字符串
|
||
merged_cookies_str = '; '.join([f"{k}={v}" for k, v in merged_cookies_dict.items()])
|
||
logger.info(f"【{self.cookie_id}】合并后cookies包含 {len(merged_cookies_dict)} 个字段")
|
||
|
||
# 打印合并后的Cookie字段详情
|
||
logger.info(f"【{self.cookie_id}】========== 合并后Cookie字段详情 ==========")
|
||
logger.info(f"【{self.cookie_id}】Cookie字段数: {len(merged_cookies_dict)}")
|
||
logger.info(f"【{self.cookie_id}】Cookie字段列表:")
|
||
for i, (key, value) in enumerate(merged_cookies_dict.items(), 1):
|
||
if len(str(value)) > 50:
|
||
logger.info(f"【{self.cookie_id}】 {i:2d}. {key}: {str(value)[:30]}...{str(value)[-20:]} (长度: {len(str(value))})")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】 {i:2d}. {key}: {value}")
|
||
|
||
# 检查关键字段
|
||
important_keys = ['unb', '_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'cna']
|
||
logger.info(f"【{self.cookie_id}】关键字段检查:")
|
||
for key in important_keys:
|
||
if key in merged_cookies_dict:
|
||
val = merged_cookies_dict[key]
|
||
logger.info(f"【{self.cookie_id}】 ✅ {key}: {'存在' if val else '为空'} (长度: {len(str(val)) if val else 0})")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】 ❌ {key}: 缺失")
|
||
logger.info(f"【{self.cookie_id}】==========================================")
|
||
|
||
# 使用合并后的cookies字符串
|
||
new_cookies_str = merged_cookies_str
|
||
new_cookies_dict = merged_cookies_dict
|
||
|
||
except Exception as merge_e:
|
||
logger.error(f"【{self.cookie_id}】cookies合并异常: {self._safe_str(merge_e)}")
|
||
logger.warning(f"【{self.cookie_id}】将使用原始新cookies(不合并)")
|
||
# 如果合并失败,继续使用原始的new_cookies_str
|
||
|
||
# 备份原有cookies,以防更新失败需要回滚
|
||
old_cookies_str = self.cookies_str
|
||
old_cookies_dict = self.cookies.copy()
|
||
|
||
try:
|
||
# 更新当前实例的cookies
|
||
self.cookies_str = new_cookies_str
|
||
self.cookies = new_cookies_dict
|
||
|
||
# 更新数据库中的cookies
|
||
await self.update_config_cookies()
|
||
logger.info(f"【{self.cookie_id}】数据库cookies更新成功")
|
||
|
||
# 通过CookieManager重启任务
|
||
logger.info(f"【{self.cookie_id}】通过CookieManager重启任务...")
|
||
await self._restart_instance()
|
||
|
||
# ⚠️ _restart_instance() 已触发重启,当前任务即将被取消
|
||
# 立即返回,不执行后续代码
|
||
logger.info(f"【{self.cookie_id}】cookies更新成功,重启请求已触发")
|
||
return True
|
||
|
||
except Exception as update_e:
|
||
logger.error(f"【{self.cookie_id}】更新cookies过程中出错,尝试回滚: {self._safe_str(update_e)}")
|
||
|
||
# 回滚cookies
|
||
try:
|
||
self.cookies_str = old_cookies_str
|
||
self.cookies = old_cookies_dict
|
||
await self.update_config_cookies()
|
||
logger.info(f"【{self.cookie_id}】cookies已回滚到原始状态")
|
||
except Exception as rollback_e:
|
||
logger.error(f"【{self.cookie_id}】cookies回滚失败: {self._safe_str(rollback_e)}")
|
||
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】更新cookies并重启任务时出错: {self._safe_str(e)}")
|
||
return False
|
||
|
||
async def update_config_cookies(self):
|
||
"""更新数据库中的cookies(不会覆盖账号密码等其他字段)"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
# 更新数据库中的Cookie
|
||
if hasattr(self, 'cookie_id') and self.cookie_id:
|
||
try:
|
||
# 获取当前Cookie的用户ID,避免在刷新时改变所有者
|
||
current_user_id = None
|
||
if hasattr(self, 'user_id') and self.user_id:
|
||
current_user_id = self.user_id
|
||
|
||
# 使用 update_cookie_account_info 避免覆盖其他字段(如 username, password, pause_duration, remark 等)
|
||
# 这个方法会自动处理新账号和现有账号的情况,不会覆盖账号密码
|
||
success = db_manager.update_cookie_account_info(
|
||
self.cookie_id,
|
||
cookie_value=self.cookies_str,
|
||
user_id=current_user_id # 如果是新账号,需要提供user_id
|
||
)
|
||
if not success:
|
||
# 如果更新失败,记录错误但不使用 save_cookie(避免覆盖账号密码)
|
||
logger.warning(f"更新Cookie到数据库失败: {self.cookie_id},但不使用save_cookie避免覆盖账号密码")
|
||
else:
|
||
logger.warning(f"已更新Cookie到数据库: {self.cookie_id}")
|
||
except Exception as e:
|
||
logger.error(f"更新数据库Cookie失败: {self._safe_str(e)}")
|
||
# 发送数据库更新失败通知
|
||
await self.send_token_refresh_notification(f"数据库Cookie更新失败: {str(e)}", "db_update_failed")
|
||
else:
|
||
logger.warning("Cookie ID不存在,无法更新数据库")
|
||
# 发送Cookie ID缺失通知
|
||
await self.send_token_refresh_notification("Cookie ID不存在,无法更新数据库", "cookie_id_missing")
|
||
|
||
except Exception as e:
|
||
logger.error(f"更新Cookie失败: {self._safe_str(e)}")
|
||
# 发送Cookie更新失败通知
|
||
await self.send_token_refresh_notification(f"Cookie更新失败: {str(e)}", "cookie_update_failed")
|
||
|
||
async def _try_password_login_refresh(self, trigger_reason: str = "令牌/Session过期"):
|
||
"""尝试通过密码登录刷新Cookie并重启实例
|
||
|
||
Args:
|
||
trigger_reason: 触发原因,用于日志记录
|
||
|
||
Returns:
|
||
bool: 是否成功刷新Cookie
|
||
"""
|
||
logger.warning(f"【{self.cookie_id}】检测到{trigger_reason},准备刷新Cookie并重启实例...")
|
||
|
||
# 检查是否在密码登录冷却期内,避免重复登录
|
||
current_time = time.time()
|
||
last_password_login = XianyuLive._last_password_login_time.get(self.cookie_id, 0)
|
||
time_since_last_login = current_time - last_password_login
|
||
|
||
if last_password_login > 0 and time_since_last_login < XianyuLive._password_login_cooldown:
|
||
remaining_time = XianyuLive._password_login_cooldown - time_since_last_login
|
||
logger.warning(f"【{self.cookie_id}】距离上次密码登录仅 {time_since_last_login:.1f} 秒,仍在冷却期内(还需等待 {remaining_time:.1f} 秒),跳过密码登录")
|
||
logger.warning(f"【{self.cookie_id}】提示:如果新Cookie仍然无效,请检查账号状态或手动更新Cookie")
|
||
return False
|
||
|
||
# 记录到日志文件
|
||
log_captcha_event(self.cookie_id, f"{trigger_reason}触发Cookie刷新和实例重启", None,
|
||
f"检测到{trigger_reason},准备刷新Cookie并重启实例")
|
||
|
||
try:
|
||
# 从数据库获取账号登录信息
|
||
from app.db_manager import db_manager
|
||
account_info = db_manager.get_cookie_details(self.cookie_id)
|
||
|
||
if not account_info:
|
||
logger.error(f"【{self.cookie_id}】无法获取账号信息")
|
||
return False
|
||
|
||
# 【重要】先检查数据库中的cookie是否已经更新
|
||
# 如果用户已经手动更新了cookie,就不需要触发密码登录刷新
|
||
db_cookie_value = account_info.get('cookie_value', '')
|
||
if db_cookie_value and db_cookie_value != self.cookies_str:
|
||
logger.info(f"【{self.cookie_id}】检测到数据库中的cookie已更新,重新加载cookie")
|
||
self.cookies_str = db_cookie_value
|
||
self.cookies = trans_cookies(self.cookies_str)
|
||
logger.info(f"【{self.cookie_id}】Cookie已从数据库重新加载,跳过密码登录刷新")
|
||
return True
|
||
|
||
username = account_info.get('username', '')
|
||
password = account_info.get('password', '')
|
||
show_browser = account_info.get('show_browser', False)
|
||
|
||
# 检查是否配置了用户名和密码
|
||
if not username or not password:
|
||
logger.warning(f"【{self.cookie_id}】未配置用户名或密码,跳过密码登录刷新")
|
||
await self.send_token_refresh_notification(
|
||
f"检测到{trigger_reason},但未配置用户名或密码,无法自动刷新Cookie",
|
||
"no_credentials"
|
||
)
|
||
return False
|
||
|
||
# 使用集成的 Playwright 登录方法(无需猴子补丁)
|
||
from utils.xianyu_slider_stealth import XianyuSliderStealth
|
||
browser_mode = "有头" if show_browser else "无头"
|
||
logger.info(f"【{self.cookie_id}】开始使用{browser_mode}浏览器进行密码登录刷新Cookie...")
|
||
logger.info(f"【{self.cookie_id}】使用账号: {username}")
|
||
|
||
# 创建一个通知回调包装函数,支持接收截图路径和验证链接
|
||
async def notification_callback_wrapper(message: str, screenshot_path: str = None, verification_url: str = None):
|
||
"""通知回调包装函数,支持接收截图路径和验证链接"""
|
||
await self.send_token_refresh_notification(
|
||
error_message=message,
|
||
notification_type="token_refresh",
|
||
chat_id=None,
|
||
attachment_path=screenshot_path,
|
||
verification_url=verification_url
|
||
)
|
||
|
||
# 在单独的线程中运行同步的登录方法
|
||
import asyncio
|
||
slider = XianyuSliderStealth(user_id=self.cookie_id, enable_learning=False, headless=not show_browser)
|
||
result = await asyncio.to_thread(
|
||
slider.login_with_password_playwright,
|
||
account=username,
|
||
password=password,
|
||
show_browser=show_browser,
|
||
notification_callback=notification_callback_wrapper
|
||
)
|
||
|
||
if result:
|
||
logger.info(f"【{self.cookie_id}】密码登录成功,获取到Cookie")
|
||
logger.info(
|
||
f"【{self.cookie_id}】Cookie读取完成: "
|
||
f"fields={len(result)}, names={sorted(result.keys())}"
|
||
)
|
||
|
||
# 打印密码登录获取的Cookie字段详情
|
||
logger.info(f"【{self.cookie_id}】========== 密码登录Cookie字段详情 ==========")
|
||
logger.info(f"【{self.cookie_id}】Cookie字段数: {len(result)}")
|
||
logger.info(f"【{self.cookie_id}】Cookie字段列表:")
|
||
for i, (key, value) in enumerate(result.items(), 1):
|
||
if len(str(value)) > 50:
|
||
logger.info(f"【{self.cookie_id}】 {i:2d}. {key}: {str(value)[:30]}...{str(value)[-20:]} (长度: {len(str(value))})")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】 {i:2d}. {key}: {value}")
|
||
|
||
# 检查关键字段
|
||
important_keys = ['unb', '_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'cna']
|
||
logger.info(f"【{self.cookie_id}】关键字段检查:")
|
||
for key in important_keys:
|
||
if key in result:
|
||
val = result[key]
|
||
logger.info(f"【{self.cookie_id}】 ✅ {key}: {'存在' if val else '为空'} (长度: {len(str(val)) if val else 0})")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】 ❌ {key}: 缺失")
|
||
logger.info(f"【{self.cookie_id}】==========================================")
|
||
|
||
# 将cookie字典转换为字符串格式
|
||
new_cookies_str = '; '.join([f"{k}={v}" for k, v in result.items()])
|
||
logger.info(
|
||
f"【{self.cookie_id}】密码登录Cookie已获取: "
|
||
f"字段数={len(result)}, 字符串长度={len(new_cookies_str)}"
|
||
)
|
||
|
||
# 记录密码登录时间,防止重复登录
|
||
XianyuLive._last_password_login_time[self.cookie_id] = time.time()
|
||
logger.warning(f"【{self.cookie_id}】已记录密码登录时间,冷却期 {XianyuLive._password_login_cooldown} 秒")
|
||
|
||
# 更新cookies并重启任务
|
||
update_success = await self._update_cookies_and_restart(new_cookies_str)
|
||
|
||
if update_success:
|
||
logger.info(f"【{self.cookie_id}】Cookie更新并重启任务成功")
|
||
# 发送账号密码登录成功通知
|
||
await self.send_token_refresh_notification(
|
||
f"账号密码登录成功,Cookie已更新,任务已重启",
|
||
"password_login_success"
|
||
)
|
||
return True
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】Cookie更新失败")
|
||
return False
|
||
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】密码登录失败,未获取到Cookie")
|
||
return False
|
||
|
||
except Exception as refresh_e:
|
||
logger.error(f"【{self.cookie_id}】Cookie刷新或实例重启失败: {self._safe_str(refresh_e)}")
|
||
import traceback
|
||
logger.error(f"【{self.cookie_id}】详细堆栈:\n{traceback.format_exc()}")
|
||
return False
|
||
|
||
async def _verify_cookie_validity(self) -> dict:
|
||
"""验证Cookie的有效性,通过实际调用API测试
|
||
|
||
Returns:
|
||
dict: {
|
||
'valid': bool, # 总体是否有效
|
||
'confirm_api': bool, # 确认发货API是否有效
|
||
'image_api': bool, # 图片上传API是否有效
|
||
'details': str # 详细信息
|
||
}
|
||
"""
|
||
logger.info(f"【{self.cookie_id}】开始验证Cookie有效性(使用真实API调用)...")
|
||
|
||
result = {
|
||
'valid': True,
|
||
'confirm_api': None,
|
||
'image_api': None,
|
||
'details': []
|
||
}
|
||
|
||
# 1. 测试确认发货API - 使用测试订单ID实际调用
|
||
# try:
|
||
# logger.info(f"【{self.cookie_id}】测试确认发货API(使用测试数据实际调用)...")
|
||
|
||
# # 确保session存在
|
||
# if not self.session:
|
||
# import aiohttp
|
||
# connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
|
||
# timeout = aiohttp.ClientTimeout(total=30)
|
||
# self.session = aiohttp.ClientSession(connector=connector, timeout=timeout)
|
||
|
||
# # 创建临时的确认发货实例
|
||
# from app.secure_confirm import SecureConfirm
|
||
# confirm_tester = SecureConfirm(
|
||
# session=self.session,
|
||
# cookies_str=self.cookies_str,
|
||
# cookie_id=self.cookie_id,
|
||
# main_instance=self
|
||
# )
|
||
|
||
# # 使用一个测试订单ID(不存在的订单ID)
|
||
# # 如果Cookie有效,应该返回"订单不存在"类的错误
|
||
# # 如果Cookie无效,会返回"Session过期"错误
|
||
# test_order_id = "999999999999999999" # 不存在的测试订单ID
|
||
|
||
# # 实际调用API (retry_count=3阻止重试,快速失败)
|
||
# response = await confirm_tester.auto_confirm(test_order_id, retry_count=3)
|
||
|
||
# # 分析响应
|
||
# if response and isinstance(response, dict):
|
||
# error_msg = str(response.get('error', ''))
|
||
# success = response.get('success', False)
|
||
|
||
# # 检查是否是Session过期错误
|
||
# if 'Session过期' in error_msg or 'SESSION_EXPIRED' in error_msg:
|
||
# logger.warning(f"【{self.cookie_id}】❌ 确认发货API验证失败: Session过期")
|
||
# result['confirm_api'] = False
|
||
# result['valid'] = False
|
||
# result['details'].append("确认发货API: Session过期")
|
||
# elif '令牌过期' in error_msg:
|
||
# logger.warning(f"【{self.cookie_id}】❌ 确认发货API验证失败: 令牌过期")
|
||
# result['confirm_api'] = False
|
||
# result['valid'] = False
|
||
# result['details'].append("确认发货API: 令牌过期")
|
||
# elif success:
|
||
# # 竟然成功了(不太可能,因为是测试订单ID)
|
||
# logger.info(f"【{self.cookie_id}】✅ 确认发货API验证通过: API调用成功")
|
||
# result['confirm_api'] = True
|
||
# result['details'].append("确认发货API: 通过验证")
|
||
# elif error_msg and len(error_msg) > 0:
|
||
# # 有其他错误信息(如订单不存在、重试次数过多等),说明Cookie是有效的
|
||
# logger.info(f"【{self.cookie_id}】✅ 确认发货API验证通过: Cookie有效(返回业务错误: {error_msg[:50]})")
|
||
# result['confirm_api'] = True
|
||
# result['details'].append(f"确认发货API: 通过验证")
|
||
# else:
|
||
# # 没有明确信息,保守认为可能有问题
|
||
# logger.warning(f"【{self.cookie_id}】⚠️ 确认发货API验证警告: 响应不明确")
|
||
# result['confirm_api'] = False
|
||
# result['valid'] = False
|
||
# result['details'].append("确认发货API: 响应不明确")
|
||
# else:
|
||
# # 没有响应,可能有问题
|
||
# logger.warning(f"【{self.cookie_id}】⚠️ 确认发货API验证警告: 无响应")
|
||
# result['confirm_api'] = False
|
||
# result['valid'] = False
|
||
# result['details'].append("确认发货API: 无响应")
|
||
|
||
# except Exception as e:
|
||
# error_str = self._safe_str(e)
|
||
# # 检查异常信息中是否包含Session过期
|
||
# if 'Session过期' in error_str or 'SESSION_EXPIRED' in error_str:
|
||
# logger.warning(f"【{self.cookie_id}】❌ 确认发货API验证失败: Session过期")
|
||
# result['confirm_api'] = False
|
||
# result['valid'] = False
|
||
# result['details'].append("确认发货API: Session过期")
|
||
# else:
|
||
# logger.error(f"【{self.cookie_id}】确认发货API验证异常: {error_str}")
|
||
# # 网络异常等问题,不一定是Cookie问题,暂时标记为通过
|
||
# result['confirm_api'] = True
|
||
# result['details'].append(f"确认发货API: 调用异常(可能非Cookie问题)")
|
||
|
||
# 2. 测试图片上传API - 创建测试图片并实际上传
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】测试图片上传API(使用测试图片实际上传)...")
|
||
|
||
# 创建一个最小的测试图片(1x1像素的PNG)
|
||
import tempfile
|
||
import os
|
||
from PIL import Image
|
||
|
||
# 创建临时目录
|
||
temp_dir = tempfile.gettempdir()
|
||
test_image_path = os.path.join(temp_dir, f'cookie_test_{self.cookie_id}.png')
|
||
|
||
try:
|
||
# 创建1x1像素的白色图片
|
||
img = Image.new('RGB', (1, 1), color='white')
|
||
img.save(test_image_path, 'PNG')
|
||
logger.info(f"【{self.cookie_id}】已创建测试图片: {test_image_path}")
|
||
|
||
# 创建图片上传实例
|
||
from utils.image_uploader import ImageUploader
|
||
uploader = ImageUploader(cookies_str=self.cookies_str)
|
||
|
||
# 创建session
|
||
await uploader.create_session()
|
||
|
||
try:
|
||
# 实际上传测试图片
|
||
upload_result = await uploader.upload_image(test_image_path)
|
||
finally:
|
||
# 确保关闭session
|
||
await uploader.close_session()
|
||
|
||
# 分析上传结果
|
||
if upload_result:
|
||
# 上传成功,Cookie有效
|
||
logger.info(f"【{self.cookie_id}】✅ 图片上传API验证通过: 上传成功 ({upload_result[:50]}...)")
|
||
result['image_api'] = True
|
||
result['details'].append("图片上传API: 通过验证")
|
||
else:
|
||
# 上传失败,需要进一步判断原因
|
||
# 如果是Cookie失效,通常会返回HTML登录页面
|
||
logger.warning(f"【{self.cookie_id}】❌ 图片上传API验证失败: 上传失败(可能是Cookie失效)")
|
||
result['image_api'] = False
|
||
result['valid'] = False
|
||
result['details'].append("图片上传API: 上传失败,可能Cookie已失效")
|
||
|
||
finally:
|
||
# 清理测试图片
|
||
if os.path.exists(test_image_path):
|
||
try:
|
||
os.remove(test_image_path)
|
||
logger.debug(f"【{self.cookie_id}】已删除测试图片")
|
||
except:
|
||
pass
|
||
|
||
except Exception as e:
|
||
error_str = self._safe_str(e)
|
||
logger.error(f"【{self.cookie_id}】图片上传API验证异常: {error_str}")
|
||
# 图片上传异常,标记为失败
|
||
result['image_api'] = False
|
||
result['valid'] = False
|
||
result['details'].append(f"图片上传API: 验证异常 - {error_str[:50]}")
|
||
|
||
# 汇总结果
|
||
if result['valid']:
|
||
logger.info(f"【{self.cookie_id}】✅ Cookie验证通过: 所有关键API均可用")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】❌ Cookie验证失败:")
|
||
for detail in result['details']:
|
||
logger.warning(f"【{self.cookie_id}】 - {detail}")
|
||
|
||
result['details'] = '; '.join(result['details'])
|
||
return result
|
||
|
||
async def _restart_instance(self):
|
||
"""重启XianyuLive实例
|
||
|
||
⚠️ 注意:此方法会触发当前任务被取消!
|
||
调用此方法后,当前任务会立即被 CookieManager 取消,
|
||
因此不要在此方法后执行任何重要操作。
|
||
"""
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】准备重启实例...")
|
||
|
||
# 导入CookieManager
|
||
from app.cookie_manager import manager as cookie_manager
|
||
|
||
if cookie_manager:
|
||
# 通过CookieManager重启实例
|
||
logger.info(f"【{self.cookie_id}】通过CookieManager重启实例...")
|
||
|
||
# ⚠️ 重要:不要等待重启完成!
|
||
# cookie_manager.update_cookie() 会立即取消当前任务
|
||
# 如果我们等待它完成,会导致 CancelledError 中断等待
|
||
# 正确的做法是:触发重启后立即返回,让任务自然退出
|
||
|
||
import threading
|
||
|
||
def trigger_restart():
|
||
"""在后台线程中触发重启,不阻塞当前任务"""
|
||
try:
|
||
# 给当前任务一点时间完成清理(避免竞态条件)
|
||
import time
|
||
time.sleep(0.5)
|
||
|
||
# save_to_db=False 因为 update_config_cookies 已经保存过了
|
||
cookie_manager.update_cookie(self.cookie_id, self.cookies_str, save_to_db=False)
|
||
logger.info(f"【{self.cookie_id}】实例重启请求已触发")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】触发实例重启失败: {e}")
|
||
import traceback
|
||
logger.error(f"【{self.cookie_id}】重启失败详情:\n{traceback.format_exc()}")
|
||
|
||
# 在后台线程中触发重启
|
||
restart_thread = threading.Thread(target=trigger_restart, daemon=True)
|
||
restart_thread.start()
|
||
|
||
logger.info(f"【{self.cookie_id}】实例重启已触发,当前任务即将退出...")
|
||
logger.warning(f"【{self.cookie_id}】注意:重启请求已发送,CookieManager将在0.5秒后取消当前任务并启动新实例")
|
||
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】CookieManager不可用,无法重启实例")
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】重启实例失败: {self._safe_str(e)}")
|
||
import traceback
|
||
logger.error(f"【{self.cookie_id}】重启失败堆栈:\n{traceback.format_exc()}")
|
||
# 发送重启失败通知
|
||
try:
|
||
await self.send_token_refresh_notification(f"实例重启失败: {str(e)}", "instance_restart_failed")
|
||
except Exception as notify_e:
|
||
logger.error(f"【{self.cookie_id}】发送重启失败通知时出错: {self._safe_str(notify_e)}")
|
||
|
||
async def save_item_info_to_db(self, item_id: str, item_detail: str = None, item_title: str = None):
|
||
"""保存商品信息到数据库
|
||
|
||
Args:
|
||
item_id: 商品ID
|
||
item_detail: 商品详情内容(可以是任意格式的文本)
|
||
item_title: 商品标题
|
||
"""
|
||
try:
|
||
# 跳过以 auto_ 开头的商品ID
|
||
if item_id and item_id.startswith('auto_'):
|
||
logger.warning(f"跳过保存自动生成的商品ID: {item_id}")
|
||
return
|
||
|
||
# 验证:如果只有商品ID,没有商品标题和商品详情,则不插入数据库
|
||
if not item_title and not item_detail:
|
||
logger.warning(f"跳过保存商品信息:缺少商品标题和详情 - {item_id}")
|
||
return
|
||
|
||
# 如果有商品标题但没有详情,也跳过(根据需求,需要同时有标题和详情)
|
||
if not item_title or not item_detail:
|
||
logger.warning(f"跳过保存商品信息:商品标题或详情不完整 - {item_id}")
|
||
return
|
||
|
||
from app.db_manager import db_manager
|
||
|
||
# 直接使用传入的详情内容
|
||
item_data = item_detail
|
||
|
||
# 保存到数据库
|
||
success = db_manager.save_item_info(self.cookie_id, item_id, item_data)
|
||
if success:
|
||
logger.info(f"商品信息已保存到数据库: {item_id}")
|
||
else:
|
||
logger.warning(f"保存商品信息到数据库失败: {item_id}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"保存商品信息到数据库异常: {self._safe_str(e)}")
|
||
|
||
async def save_item_detail_only(self, item_id, item_detail):
|
||
"""仅保存商品详情(不影响标题等基本信息)"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
# 使用专门的详情更新方法
|
||
success = db_manager.update_item_detail(self.cookie_id, item_id, item_detail)
|
||
|
||
if success:
|
||
logger.info(f"商品详情已更新: {item_id}")
|
||
else:
|
||
logger.warning(f"更新商品详情失败: {item_id}")
|
||
|
||
return success
|
||
|
||
except Exception as e:
|
||
logger.error(f"更新商品详情异常: {self._safe_str(e)}")
|
||
return False
|
||
|
||
async def fetch_item_detail_from_api(self, item_id: str) -> str:
|
||
"""获取商品详情(使用浏览器获取,支持24小时缓存)
|
||
|
||
Args:
|
||
item_id: 商品ID
|
||
|
||
Returns:
|
||
str: 商品详情文本,获取失败返回空字符串
|
||
"""
|
||
try:
|
||
# 检查是否启用自动获取功能
|
||
from app.config import config
|
||
auto_fetch_config = config.get('ITEM_DETAIL', {}).get('auto_fetch', {})
|
||
|
||
if not auto_fetch_config.get('enabled', True):
|
||
logger.warning(f"自动获取商品详情功能已禁用: {item_id}")
|
||
return ""
|
||
|
||
# 1. 首先检查缓存(24小时有效)
|
||
async with self._item_detail_cache_lock:
|
||
if item_id in self._item_detail_cache:
|
||
cache_data = self._item_detail_cache[item_id]
|
||
cache_time = cache_data['timestamp']
|
||
current_time = time.time()
|
||
|
||
# 检查缓存是否在24小时内
|
||
if current_time - cache_time < self._item_detail_cache_ttl:
|
||
# 更新访问时间(用于LRU)
|
||
cache_data['access_time'] = current_time
|
||
logger.info(f"从缓存获取商品详情: {item_id}")
|
||
return cache_data['detail']
|
||
else:
|
||
# 缓存过期,删除
|
||
del self._item_detail_cache[item_id]
|
||
logger.warning(f"缓存已过期,删除: {item_id}")
|
||
|
||
# 2. 尝试使用浏览器获取商品详情
|
||
detail_from_browser = await self._fetch_item_detail_from_browser(item_id)
|
||
if detail_from_browser:
|
||
# 保存到缓存(带大小限制)
|
||
await self._add_to_item_cache(item_id, detail_from_browser)
|
||
logger.info(f"成功通过浏览器获取商品详情: {item_id}, 长度: {len(detail_from_browser)}")
|
||
return detail_from_browser
|
||
|
||
# 浏览器获取失败
|
||
logger.warning(f"浏览器获取商品详情失败: {item_id}")
|
||
return ""
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取商品详情异常: {item_id}, 错误: {self._safe_str(e)}")
|
||
return ""
|
||
|
||
async def _add_to_item_cache(self, item_id: str, detail: str):
|
||
"""添加商品详情到缓存,实现LRU策略和大小限制
|
||
|
||
Args:
|
||
item_id: 商品ID
|
||
detail: 商品详情
|
||
"""
|
||
async with self._item_detail_cache_lock:
|
||
current_time = time.time()
|
||
|
||
# 检查缓存大小,如果超过限制则清理
|
||
if len(self._item_detail_cache) >= self._item_detail_cache_max_size:
|
||
# 使用LRU策略删除最久未访问的项
|
||
if self._item_detail_cache:
|
||
# 找到最久未访问的项
|
||
oldest_item = min(
|
||
self._item_detail_cache.items(),
|
||
key=lambda x: x[1].get('access_time', x[1]['timestamp'])
|
||
)
|
||
oldest_item_id = oldest_item[0]
|
||
del self._item_detail_cache[oldest_item_id]
|
||
logger.warning(f"缓存已满,删除最旧项: {oldest_item_id}")
|
||
|
||
# 添加新项到缓存
|
||
self._item_detail_cache[item_id] = {
|
||
'detail': detail,
|
||
'timestamp': current_time,
|
||
'access_time': current_time
|
||
}
|
||
logger.warning(f"添加商品详情到缓存: {item_id}, 当前缓存大小: {len(self._item_detail_cache)}")
|
||
|
||
@classmethod
|
||
async def _cleanup_item_cache(cls):
|
||
"""清理过期的商品详情缓存"""
|
||
try:
|
||
async with cls._item_detail_cache_lock:
|
||
# 在持有锁时也要能响应取消信号
|
||
await asyncio.sleep(0)
|
||
|
||
current_time = time.time()
|
||
expired_items = []
|
||
|
||
# 找出所有过期的项
|
||
for item_id, cache_data in cls._item_detail_cache.items():
|
||
# 在循环中也要能响应取消信号
|
||
await asyncio.sleep(0)
|
||
if current_time - cache_data['timestamp'] >= cls._item_detail_cache_ttl:
|
||
expired_items.append(item_id)
|
||
|
||
# 删除过期项
|
||
for item_id in expired_items:
|
||
await asyncio.sleep(0) # 让出控制权
|
||
del cls._item_detail_cache[item_id]
|
||
|
||
if expired_items:
|
||
logger.info(f"清理了 {len(expired_items)} 个过期的商品详情缓存")
|
||
|
||
return len(expired_items)
|
||
except asyncio.CancelledError:
|
||
# 如果被取消,确保锁能正确释放
|
||
raise
|
||
|
||
def _get_playwright_launch_options(self, playwright, browser_args, purpose: str) -> dict:
|
||
"""构造浏览器启动参数,Playwright浏览器缺失时回退到系统Chrome/Edge。"""
|
||
launch_options = {
|
||
'headless': True,
|
||
'args': browser_args,
|
||
# 显式指定完整版 Chromium。headless=True 时 Playwright 会优先找
|
||
# chromium_headless_shell-*,那是与 chromium-* 分开下载的另一份文件;
|
||
# 下载中断时常常只装上其中一个,于是出现「自检说浏览器已安装、
|
||
# 真正启动却报 Executable doesn't exist」。指定 channel 后只依赖
|
||
# 完整版,与下方 executable_path 的检查也就对得上了。
|
||
'channel': 'chromium',
|
||
}
|
||
bundled_executable = playwright.chromium.executable_path
|
||
if os.path.exists(bundled_executable):
|
||
logger.info(f"【{self.cookie_id}】{purpose}使用Playwright Chromium")
|
||
return launch_options
|
||
|
||
system_browser_candidates = [
|
||
("Chrome", r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
|
||
("Chrome", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"),
|
||
("Edge", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"),
|
||
("Edge", r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
|
||
]
|
||
system_browser = next(
|
||
(
|
||
(name, path)
|
||
for name, path in system_browser_candidates
|
||
if os.path.exists(path)
|
||
),
|
||
None
|
||
)
|
||
if not system_browser:
|
||
raise RuntimeError(
|
||
f"{purpose}未找到可用浏览器;Playwright预期路径不存在,"
|
||
"系统Chrome/Edge也未找到"
|
||
)
|
||
|
||
browser_name, browser_path = system_browser
|
||
launch_options['executable_path'] = browser_path
|
||
# 指定了自定义可执行文件就不能再带 channel,两者互斥
|
||
launch_options.pop('channel', None)
|
||
logger.warning(
|
||
f"【{self.cookie_id}】{purpose}的Playwright Chromium未安装,"
|
||
f"回退使用系统{browser_name}: {browser_path}"
|
||
)
|
||
return launch_options
|
||
|
||
@staticmethod
|
||
def _parse_profile_count(value):
|
||
"""将“116”或“1.2万”转换为整数。"""
|
||
text = str(value or '').strip().replace(',', '')
|
||
if not text:
|
||
return None
|
||
multiplier = 10000 if text.endswith('万') else 1
|
||
if multiplier > 1:
|
||
text = text[:-1]
|
||
try:
|
||
return int(float(text) * multiplier)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
@classmethod
|
||
def _parse_account_profile_snapshot(cls, snapshot, account_id=''):
|
||
"""从个人页的语义文本和图片元数据中提取公开账号资料。"""
|
||
if not isinstance(snapshot, dict):
|
||
return {}
|
||
|
||
lines = [
|
||
re.sub(r'\s+', ' ', str(line or '')).strip()
|
||
for line in snapshot.get('body_lines', [])
|
||
]
|
||
lines = [line for line in lines if line]
|
||
title = re.sub(r'\s+', ' ', str(snapshot.get('title') or '')).strip()
|
||
nickname = re.sub(r'[_\-\s]*闲鱼\s*$', '', title).strip()
|
||
if not nickname or nickname in ('闲鱼', 'Goofish'):
|
||
nickname = next(
|
||
(
|
||
line for line in lines
|
||
if line not in ('订单', '我的闲鱼') and len(line) <= 40
|
||
),
|
||
'',
|
||
)
|
||
|
||
followers = None
|
||
following = None
|
||
follower_index = None
|
||
following_index = None
|
||
# 闲鱼个人页的粉丝/关注在不同版本里排布不一:
|
||
# 「123 粉丝」/「粉丝 123」/「粉丝」与「123」分处相邻两行。
|
||
# 原来只用 fullmatch(r'([\d,.]+万?)\s*粉丝') 要求整行严格等于「123 粉丝」,
|
||
# 其余排布一律匹配不到,导致 followers 永远是 None。
|
||
count_pattern = r'[\d,.]+(?:\.\d+)?万?'
|
||
for index, line in enumerate(lines):
|
||
for label, is_follower in (('粉丝', True), ('关注', False)):
|
||
if label not in line:
|
||
continue
|
||
target = followers if is_follower else following
|
||
if target is not None:
|
||
continue
|
||
|
||
# 同一行内取数字,兼容「123 粉丝」和「粉丝 123」
|
||
m = re.search(rf'({count_pattern})\s*{label}', line) \
|
||
or re.search(rf'{label}\s*({count_pattern})', line)
|
||
value = cls._parse_profile_count(m.group(1)) if m else None
|
||
|
||
# 标签独占一行时,数字通常在相邻行
|
||
if value is None and line.strip() == label:
|
||
for neighbor in (index + 1, index - 1):
|
||
if 0 <= neighbor < len(lines):
|
||
nm = re.fullmatch(count_pattern, lines[neighbor].strip())
|
||
if nm:
|
||
value = cls._parse_profile_count(nm.group(0))
|
||
break
|
||
|
||
if value is not None:
|
||
if is_follower:
|
||
followers, follower_index = value, index
|
||
else:
|
||
following, following_index = value, index
|
||
|
||
location = ''
|
||
if follower_index is not None and follower_index > 0:
|
||
candidate = lines[follower_index - 1]
|
||
if candidate != nickname and len(candidate) <= 40:
|
||
location = candidate
|
||
|
||
bio = ''
|
||
if following_index is not None:
|
||
stop_texts = {'编辑资料', '宝贝', '信用及评价', '综合', '在售', '已售出'}
|
||
for candidate in lines[following_index + 1:following_index + 5]:
|
||
if candidate in stop_texts:
|
||
break
|
||
if candidate != nickname and len(candidate) <= 160:
|
||
bio = candidate
|
||
break
|
||
|
||
avatar_url = ''
|
||
avatar_candidates = []
|
||
for image in snapshot.get('images', []):
|
||
if not isinstance(image, dict):
|
||
continue
|
||
src = str(image.get('src') or '').strip()
|
||
if src.startswith('//'):
|
||
src = f'https:{src}'
|
||
if not src.startswith(('http://', 'https://')):
|
||
continue
|
||
lower_src = src.lower()
|
||
if 'mtopupload' not in lower_src and 'avatar' not in str(
|
||
image.get('class_name') or ''
|
||
).lower():
|
||
continue
|
||
width = int(image.get('natural_width') or 0)
|
||
height = int(image.get('natural_height') or 0)
|
||
score = width * height
|
||
if account_id and account_id in src:
|
||
score += 100000
|
||
if 'mtopupload' in lower_src:
|
||
score += 50000
|
||
avatar_candidates.append((score, src))
|
||
if avatar_candidates:
|
||
avatar_url = max(avatar_candidates, key=lambda item: item[0])[1]
|
||
|
||
profile = {
|
||
'nickname': nickname,
|
||
'avatar_url': avatar_url,
|
||
'location': location,
|
||
'bio': bio,
|
||
'followers': followers,
|
||
'following': following,
|
||
}
|
||
return {
|
||
key: value
|
||
for key, value in profile.items()
|
||
if value not in (None, '')
|
||
}
|
||
|
||
async def _sync_account_profile(self):
|
||
"""连接成功后把账号昵称和头像同步到数据库。
|
||
|
||
资料走接口直连,不受风控熔断影响时才执行;失败不影响主流程,
|
||
下次重连会再试。
|
||
"""
|
||
try:
|
||
from utils import risk_control
|
||
|
||
if risk_control.registry.get(self.cookie_id).is_blocked:
|
||
logger.debug(f"【{self.cookie_id}】风控冷却中,跳过账号资料同步")
|
||
self._profile_synced = False
|
||
return
|
||
|
||
result = await self.fetch_account_profile()
|
||
if not result.get('success'):
|
||
self._profile_synced = False
|
||
return
|
||
|
||
profile = result.get('profile') or {}
|
||
if not profile.get('nickname') and not profile.get('avatar_url'):
|
||
self._profile_synced = False
|
||
return
|
||
|
||
from app.db_manager import db_manager
|
||
|
||
db_manager.update_cookie_profile(self.cookie_id, profile)
|
||
logger.info(
|
||
f"【{self.cookie_id}】账号资料已自动同步: {profile.get('nickname')}"
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
self._profile_synced = False
|
||
logger.warning(
|
||
f"【{self.cookie_id}】账号资料自动同步失败: {self._safe_str(exc)}"
|
||
)
|
||
|
||
async def fetch_account_profile(self):
|
||
"""获取账号公开资料。
|
||
|
||
优先走接口直连:``mtop.idle.web.user.page.head`` 直接返回结构化的昵称和
|
||
头像,不依赖浏览器。Playwright 抓页面留作兜底 —— 浏览器版本不匹配或未安装
|
||
时那条路会整个失效,而资料为空会让账号列表看起来像没登录成功。
|
||
"""
|
||
try:
|
||
from utils.xianyu_seller_api import (
|
||
XianyuSellerAPI,
|
||
SellerApiError,
|
||
parse_user_profile,
|
||
)
|
||
|
||
api = XianyuSellerAPI(self.cookie_id, self.cookies_str)
|
||
try:
|
||
profile = parse_user_profile(await api.get_user_profile())
|
||
if api.cookies_str and api.cookies_str != self.cookies_str:
|
||
self.cookies_str = api.cookies_str
|
||
finally:
|
||
await api.close()
|
||
|
||
if profile.get('nickname') or profile.get('avatar_url'):
|
||
logger.info(
|
||
f"【{self.cookie_id}】账号资料获取成功(接口直连): "
|
||
f"{profile.get('nickname')}"
|
||
)
|
||
return {'success': True, 'profile': profile}
|
||
logger.info(f"【{self.cookie_id}】接口未返回资料,回退浏览器抓取")
|
||
except SellerApiError as exc:
|
||
logger.info(f"【{self.cookie_id}】资料接口调用失败,回退浏览器抓取: {exc}")
|
||
except Exception as exc:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】资料接口异常,回退浏览器抓取: {self._safe_str(exc)}"
|
||
)
|
||
|
||
return await self._fetch_account_profile_by_browser()
|
||
|
||
async def _fetch_account_profile_by_browser(self):
|
||
"""使用当前账号Cookie只读抓取闲鱼个人页公开资料。"""
|
||
playwright = None
|
||
browser = None
|
||
context = None
|
||
started_at = time.perf_counter()
|
||
try:
|
||
from playwright.async_api import async_playwright
|
||
|
||
playwright = await asyncio.wait_for(async_playwright().start(), timeout=30)
|
||
browser_args = [
|
||
'--no-sandbox',
|
||
'--disable-setuid-sandbox',
|
||
'--disable-dev-shm-usage',
|
||
'--disable-gpu',
|
||
'--disable-background-timer-throttling',
|
||
'--disable-backgrounding-occluded-windows',
|
||
'--disable-renderer-backgrounding',
|
||
'--disable-extensions',
|
||
'--disable-default-apps',
|
||
'--disable-sync',
|
||
'--disable-translate',
|
||
'--hide-scrollbars',
|
||
'--mute-audio',
|
||
'--no-default-browser-check',
|
||
'--no-pings',
|
||
]
|
||
launch_options = self._get_playwright_launch_options(
|
||
playwright,
|
||
browser_args,
|
||
"账号资料抓取",
|
||
)
|
||
browser = await browser_limit.launch_browser(playwright, launch_options, "账号资料抓取")
|
||
context = await browser.new_context(
|
||
viewport={'width': 1440, 'height': 900},
|
||
user_agent=(
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
||
'Chrome/138.0.0.0 Safari/537.36'
|
||
),
|
||
)
|
||
|
||
browser_cookies = []
|
||
for cookie_pair in self.cookies_str.split(';'):
|
||
cookie_pair = cookie_pair.strip()
|
||
if '=' not in cookie_pair:
|
||
continue
|
||
name, value = cookie_pair.split('=', 1)
|
||
browser_cookies.append({
|
||
'name': name.strip(),
|
||
'value': value.strip(),
|
||
'domain': '.goofish.com',
|
||
'path': '/',
|
||
})
|
||
await context.add_cookies(browser_cookies)
|
||
|
||
page = await context.new_page()
|
||
await page.goto(
|
||
f'https://www.goofish.com/personal?userId={self.myid}',
|
||
wait_until='domcontentloaded',
|
||
timeout=30000,
|
||
)
|
||
try:
|
||
await page.wait_for_function(
|
||
"() => document.body && document.body.innerText.includes('粉丝')",
|
||
timeout=10000,
|
||
)
|
||
except Exception:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】账号资料页未在等待时间内出现粉丝信息,继续解析已加载内容"
|
||
)
|
||
|
||
snapshot = await page.evaluate(
|
||
"""() => ({
|
||
title: document.title || '',
|
||
body_lines: (document.body?.innerText || '')
|
||
.split(/\\r?\\n/)
|
||
.map(line => line.trim())
|
||
.filter(Boolean),
|
||
images: Array.from(document.images).map(image => ({
|
||
src: image.currentSrc || image.src || '',
|
||
class_name: String(image.className || ''),
|
||
natural_width: image.naturalWidth || 0,
|
||
natural_height: image.naturalHeight || 0
|
||
}))
|
||
})"""
|
||
)
|
||
profile = self._parse_account_profile_snapshot(snapshot, self.myid)
|
||
if not profile.get('nickname') and not profile.get('avatar_url'):
|
||
raise RuntimeError("个人页未返回可识别的账号资料")
|
||
|
||
logger.info(
|
||
f"【{self.cookie_id}】账号资料抓取完成,"
|
||
f"字段={sorted(profile.keys())},"
|
||
f"耗时={time.perf_counter() - started_at:.2f}s"
|
||
)
|
||
return {'success': True, 'profile': profile}
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】账号资料抓取失败,"
|
||
f"异常类型={type(exc).__name__},"
|
||
f"耗时={time.perf_counter() - started_at:.2f}s"
|
||
)
|
||
return {
|
||
'success': False,
|
||
'error': '未能从闲鱼个人页获取账号资料',
|
||
'error_type': type(exc).__name__,
|
||
}
|
||
finally:
|
||
if context:
|
||
try:
|
||
await context.close()
|
||
except Exception:
|
||
pass
|
||
if browser:
|
||
try:
|
||
await browser.close()
|
||
except Exception:
|
||
pass
|
||
if playwright:
|
||
try:
|
||
await playwright.stop()
|
||
except Exception:
|
||
pass
|
||
|
||
async def _fetch_item_detail_from_browser(self, item_id: str) -> str:
|
||
"""使用浏览器获取商品详情"""
|
||
playwright = None
|
||
browser = None
|
||
try:
|
||
from playwright.async_api import async_playwright
|
||
|
||
logger.info(f"开始使用浏览器获取商品详情: {item_id}")
|
||
|
||
playwright = await async_playwright().start()
|
||
|
||
# 启动浏览器(参照order_detail_fetcher的配置)
|
||
browser_args = [
|
||
'--no-sandbox',
|
||
'--disable-setuid-sandbox',
|
||
'--disable-dev-shm-usage',
|
||
'--disable-accelerated-2d-canvas',
|
||
'--no-first-run',
|
||
'--no-zygote',
|
||
'--disable-gpu',
|
||
'--disable-background-timer-throttling',
|
||
'--disable-backgrounding-occluded-windows',
|
||
'--disable-renderer-backgrounding',
|
||
'--disable-features=TranslateUI',
|
||
'--disable-ipc-flooding-protection',
|
||
'--disable-extensions',
|
||
'--disable-default-apps',
|
||
'--disable-sync',
|
||
'--disable-translate',
|
||
'--hide-scrollbars',
|
||
'--mute-audio',
|
||
'--no-default-browser-check',
|
||
'--no-pings'
|
||
]
|
||
|
||
# 在Docker环境中添加额外参数
|
||
if os.getenv('DOCKER_ENV'):
|
||
browser_args.extend([
|
||
# '--single-process', # 注释掉,避免多用户并发时的进程冲突和资源泄漏
|
||
'--disable-background-networking',
|
||
'--disable-client-side-phishing-detection',
|
||
'--disable-hang-monitor',
|
||
'--disable-popup-blocking',
|
||
'--disable-prompt-on-repost',
|
||
'--disable-web-resources',
|
||
'--metrics-recording-only',
|
||
'--safebrowsing-disable-auto-update',
|
||
'--enable-automation',
|
||
'--password-store=basic',
|
||
'--use-mock-keychain'
|
||
])
|
||
|
||
launch_options = self._get_playwright_launch_options(
|
||
playwright,
|
||
browser_args,
|
||
"商品详情获取"
|
||
)
|
||
browser = await browser_limit.launch_browser(playwright, launch_options, "商品详情获取")
|
||
|
||
# 创建浏览器上下文
|
||
context = await browser.new_context(
|
||
viewport={'width': 1920, 'height': 1080},
|
||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
|
||
)
|
||
|
||
# 设置Cookie
|
||
cookies = []
|
||
for cookie_pair in self.cookies_str.split('; '):
|
||
if '=' in cookie_pair:
|
||
name, value = cookie_pair.split('=', 1)
|
||
cookies.append({
|
||
'name': name.strip(),
|
||
'value': value.strip(),
|
||
'domain': '.goofish.com',
|
||
'path': '/'
|
||
})
|
||
|
||
await context.add_cookies(cookies)
|
||
logger.warning(f"已设置 {len(cookies)} 个Cookie")
|
||
|
||
# 创建页面
|
||
page = await context.new_page()
|
||
|
||
# 构造商品详情页面URL
|
||
item_url = f"https://www.goofish.com/item?id={item_id}"
|
||
logger.info(f"访问商品页面: {item_url}")
|
||
|
||
# 访问页面
|
||
await page.goto(item_url, wait_until='networkidle', timeout=30000)
|
||
|
||
# 等待页面完全加载
|
||
await asyncio.sleep(3)
|
||
|
||
# 获取商品详情内容
|
||
detail_text = ""
|
||
try:
|
||
# 等待目标元素出现
|
||
await page.wait_for_selector('.desc--GaIUKUQY', timeout=10000)
|
||
|
||
# 获取商品详情文本
|
||
detail_element = await page.query_selector('.desc--GaIUKUQY')
|
||
if detail_element:
|
||
detail_text = await detail_element.inner_text()
|
||
logger.info(f"成功获取商品详情: {item_id}, 长度: {len(detail_text)}")
|
||
return detail_text.strip()
|
||
else:
|
||
logger.warning(f"未找到商品详情元素: {item_id}")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"获取商品详情元素失败: {item_id}, 错误: {self._safe_str(e)}")
|
||
|
||
return ""
|
||
|
||
except Exception as e:
|
||
logger.error(f"浏览器获取商品详情异常: {item_id}, 错误: {self._safe_str(e)}")
|
||
return ""
|
||
finally:
|
||
# 确保资源被正确清理
|
||
try:
|
||
if browser:
|
||
await browser.close()
|
||
logger.warning(f"Browser已关闭: {item_id}")
|
||
except Exception as e:
|
||
logger.warning(f"关闭browser时出错: {self._safe_str(e)}")
|
||
|
||
try:
|
||
if playwright:
|
||
await playwright.stop()
|
||
logger.warning(f"Playwright已停止: {item_id}")
|
||
except Exception as e:
|
||
logger.warning(f"停止playwright时出错: {self._safe_str(e)}")
|
||
|
||
|
||
async def save_items_list_to_db(self, items_list):
|
||
"""批量保存商品列表信息到数据库(并发安全)
|
||
|
||
Args:
|
||
items_list: 从get_item_list_info获取的商品列表
|
||
"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
# 准备批量数据
|
||
batch_data = []
|
||
items_need_detail = [] # 需要获取详情的商品列表
|
||
|
||
for item in items_list:
|
||
item_id = item.get('id')
|
||
if not item_id or item_id.startswith('auto_'):
|
||
continue
|
||
|
||
# 构造商品详情数据
|
||
item_detail = {
|
||
'title': item.get('title', ''),
|
||
'price': item.get('price', ''),
|
||
'price_text': item.get('price_text', ''),
|
||
'category_id': item.get('category_id', ''),
|
||
'auction_type': item.get('auction_type', ''),
|
||
'item_status': item.get('item_status', 0),
|
||
'detail_url': item.get('detail_url', ''),
|
||
'web_url': item.get('web_url', ''), # Web可访问URL
|
||
'pic_info': item.get('pic_info', {}),
|
||
'detail_params': item.get('detail_params', {}),
|
||
'track_params': item.get('track_params', {}),
|
||
'item_label_data': item.get('item_label_data', {}),
|
||
'card_type': item.get('card_type', 0)
|
||
}
|
||
|
||
# 检查数据库中是否已有详情
|
||
existing_item = db_manager.get_item_info(self.cookie_id, item_id)
|
||
has_detail = existing_item and existing_item.get('item_detail') and existing_item['item_detail'].strip()
|
||
|
||
batch_data.append({
|
||
'cookie_id': self.cookie_id,
|
||
'item_id': item_id,
|
||
'item_title': item.get('title', ''),
|
||
'item_description': '', # 暂时为空
|
||
'item_category': str(item.get('category_id', '')),
|
||
'item_price': item.get('price_text', ''),
|
||
'item_image': item.get('item_image', ''),
|
||
'item_detail': json.dumps(item_detail, ensure_ascii=False)
|
||
})
|
||
|
||
# 如果没有详情,添加到需要获取详情的列表
|
||
if not has_detail:
|
||
items_need_detail.append({
|
||
'item_id': item_id,
|
||
'item_title': item.get('title', '')
|
||
})
|
||
|
||
if not batch_data:
|
||
logger.info("没有有效的商品数据需要保存")
|
||
return 0
|
||
|
||
# 使用批量保存方法(并发安全)
|
||
saved_count = db_manager.batch_save_item_basic_info(batch_data)
|
||
logger.info(f"批量保存商品信息完成: {saved_count}/{len(batch_data)} 个商品")
|
||
|
||
# 异步获取缺失的商品详情
|
||
if items_need_detail:
|
||
from app.config import config
|
||
auto_fetch_config = config.get('ITEM_DETAIL', {}).get('auto_fetch', {})
|
||
|
||
if auto_fetch_config.get('enabled', True):
|
||
logger.info(f"发现 {len(items_need_detail)} 个商品缺少详情,开始获取...")
|
||
detail_success_count = await self._fetch_missing_item_details(items_need_detail)
|
||
logger.info(f"成功获取 {detail_success_count}/{len(items_need_detail)} 个商品的详情")
|
||
else:
|
||
logger.info(f"发现 {len(items_need_detail)} 个商品缺少详情,但自动获取功能已禁用")
|
||
|
||
return saved_count
|
||
|
||
except Exception as e:
|
||
logger.error(f"批量保存商品信息异常: {self._safe_str(e)}")
|
||
return 0
|
||
|
||
async def _fetch_missing_item_details(self, items_need_detail):
|
||
"""批量获取缺失的商品详情
|
||
|
||
Args:
|
||
items_need_detail: 需要获取详情的商品列表
|
||
|
||
Returns:
|
||
int: 成功获取详情的商品数量
|
||
"""
|
||
success_count = 0
|
||
|
||
try:
|
||
from app.config import config
|
||
|
||
# 从配置获取并发数量和延迟时间
|
||
auto_fetch_config = config.get('ITEM_DETAIL', {}).get('auto_fetch', {})
|
||
max_concurrent = auto_fetch_config.get('max_concurrent', 3)
|
||
retry_delay = auto_fetch_config.get('retry_delay', 0.5)
|
||
|
||
# 限制并发数量,避免对API服务器造成压力
|
||
semaphore = asyncio.Semaphore(max_concurrent)
|
||
|
||
async def fetch_single_item_detail(item_info):
|
||
async with semaphore:
|
||
try:
|
||
item_id = item_info['item_id']
|
||
item_title = item_info['item_title']
|
||
|
||
# 获取商品详情
|
||
item_detail_text = await self.fetch_item_detail_from_api(item_id)
|
||
|
||
if item_detail_text:
|
||
# 保存详情到数据库
|
||
success = await self.save_item_detail_only(item_id, item_detail_text)
|
||
if success:
|
||
logger.info(f"✅ 成功获取并保存商品详情: {item_id} - {item_title}")
|
||
return 1
|
||
else:
|
||
logger.warning(f"❌ 获取详情成功但保存失败: {item_id}")
|
||
else:
|
||
logger.warning(f"❌ 未能获取商品详情: {item_id} - {item_title}")
|
||
|
||
# 添加延迟,避免请求过于频繁
|
||
await asyncio.sleep(retry_delay)
|
||
return 0
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取单个商品详情异常: {item_info.get('item_id', 'unknown')}, 错误: {self._safe_str(e)}")
|
||
return 0
|
||
|
||
# 并发获取所有商品详情
|
||
tasks = [fetch_single_item_detail(item_info) for item_info in items_need_detail]
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
# 统计成功数量
|
||
for result in results:
|
||
if isinstance(result, int):
|
||
success_count += result
|
||
elif isinstance(result, Exception):
|
||
logger.error(f"获取商品详情任务异常: {result}")
|
||
|
||
return success_count
|
||
|
||
except Exception as e:
|
||
logger.error(f"批量获取商品详情异常: {self._safe_str(e)}")
|
||
return success_count
|
||
|
||
async def get_item_info(self, item_id, retry_count=0):
|
||
"""获取商品信息,自动处理token失效的情况"""
|
||
if retry_count >= 4: # 最多重试3次
|
||
logger.error("获取商品信息失败,重试次数过多")
|
||
return {"error": "获取商品信息失败,重试次数过多"}
|
||
|
||
# 确保session已创建
|
||
if not self.session:
|
||
await self.create_session()
|
||
|
||
params = {
|
||
'jsv': '2.7.2',
|
||
'appKey': '34839810',
|
||
't': str(int(time.time() * 1000)),
|
||
'sign': '',
|
||
'v': '1.0',
|
||
'type': 'originaljson',
|
||
'accountSite': 'xianyu',
|
||
'dataType': 'json',
|
||
'timeout': '20000',
|
||
'api': 'mtop.taobao.idle.pc.detail',
|
||
'sessionOption': 'AutoLoginOnly',
|
||
'spm_cnt': 'a21ybx.im.0.0',
|
||
}
|
||
|
||
data_val = '{"itemId":"' + item_id + '"}'
|
||
data = {
|
||
'data': data_val,
|
||
}
|
||
|
||
# 始终从最新的cookies中获取_m_h5_tk token(刷新后cookies会被更新)
|
||
token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
|
||
|
||
if token:
|
||
logger.info("获取商品详情,_m_h5_tk字段存在")
|
||
else:
|
||
logger.warning("cookies中没有找到_m_h5_tk token")
|
||
|
||
from utils.xianyu_utils import generate_sign
|
||
sign = generate_sign(params['t'], token, data_val)
|
||
params['sign'] = sign
|
||
|
||
try:
|
||
async with self.session.post(
|
||
'https://h5api.m.goofish.com/h5/mtop.taobao.idle.pc.detail/1.0/',
|
||
params=params,
|
||
data=data
|
||
) as response:
|
||
res_json = await response.json()
|
||
|
||
# 检查并更新Cookie
|
||
if 'set-cookie' in response.headers:
|
||
new_cookies = {}
|
||
for cookie in response.headers.getall('set-cookie', []):
|
||
if '=' in cookie:
|
||
name, value = cookie.split(';')[0].split('=', 1)
|
||
new_cookies[name.strip()] = value.strip()
|
||
|
||
# 更新cookies
|
||
if new_cookies:
|
||
self.cookies.update(new_cookies)
|
||
# 生成新的cookie字符串
|
||
self.cookies_str = '; '.join([f"{k}={v}" for k, v in self.cookies.items()])
|
||
# 更新数据库中的Cookie
|
||
await self.update_config_cookies()
|
||
logger.warning("已更新Cookie到数据库")
|
||
|
||
logger.warning(f"商品信息获取成功: {res_json}")
|
||
# 检查返回状态
|
||
if isinstance(res_json, dict):
|
||
ret_value = res_json.get('ret', [])
|
||
# 检查ret是否包含成功信息
|
||
if not any('SUCCESS::调用成功' in ret for ret in ret_value):
|
||
logger.warning(f"商品信息API调用失败,错误信息: {ret_value}")
|
||
|
||
await asyncio.sleep(0.5)
|
||
return await self.get_item_info(item_id, retry_count + 1)
|
||
else:
|
||
logger.warning(f"商品信息获取成功: {item_id}")
|
||
return res_json
|
||
else:
|
||
logger.error(f"商品信息API返回格式异常: {res_json}")
|
||
return await self.get_item_info(item_id, retry_count + 1)
|
||
|
||
except Exception as e:
|
||
logger.error(f"商品信息API请求异常: {self._safe_str(e)}")
|
||
await asyncio.sleep(0.5)
|
||
return await self.get_item_info(item_id, retry_count + 1)
|
||
|
||
def extract_item_id_from_message(self, message):
|
||
"""从消息中提取商品ID的辅助方法"""
|
||
try:
|
||
# 方法1: 从message["1"]中提取(如果是字符串格式)
|
||
message_1 = message.get('1')
|
||
if isinstance(message_1, str):
|
||
# 尝试从字符串中提取数字ID
|
||
id_match = re.search(r'(\d{10,})', message_1)
|
||
if id_match:
|
||
logger.info(f"从message[1]字符串中提取商品ID: {id_match.group(1)}")
|
||
return id_match.group(1)
|
||
|
||
# 方法2: 从message["3"]中提取
|
||
message_3 = message.get('3', {})
|
||
if isinstance(message_3, dict):
|
||
|
||
# 从extension中提取
|
||
if 'extension' in message_3:
|
||
extension = message_3['extension']
|
||
if isinstance(extension, dict):
|
||
item_id = extension.get('itemId') or extension.get('item_id')
|
||
if item_id:
|
||
logger.info(f"从extension中提取商品ID: {item_id}")
|
||
return item_id
|
||
|
||
# 从bizData中提取
|
||
if 'bizData' in message_3:
|
||
biz_data = message_3['bizData']
|
||
if isinstance(biz_data, dict):
|
||
item_id = biz_data.get('itemId') or biz_data.get('item_id')
|
||
if item_id:
|
||
logger.info(f"从bizData中提取商品ID: {item_id}")
|
||
return item_id
|
||
|
||
# 从其他可能的字段中提取
|
||
for key, value in message_3.items():
|
||
if isinstance(value, dict):
|
||
item_id = value.get('itemId') or value.get('item_id')
|
||
if item_id:
|
||
logger.info(f"从{key}字段中提取商品ID: {item_id}")
|
||
return item_id
|
||
|
||
# 从消息内容中提取数字ID
|
||
content = message_3.get('content', '')
|
||
if isinstance(content, str) and content:
|
||
id_match = re.search(r'(\d{10,})', content)
|
||
if id_match:
|
||
logger.info(f"【{self.cookie_id}】从消息内容中提取商品ID: {id_match.group(1)}")
|
||
return id_match.group(1)
|
||
|
||
# 方法3: 遍历整个消息结构查找可能的商品ID
|
||
def find_item_id_recursive(obj, path=""):
|
||
if isinstance(obj, dict):
|
||
# 直接查找itemId字段
|
||
for key in ['itemId', 'item_id', 'id']:
|
||
if key in obj and isinstance(obj[key], (str, int)):
|
||
value = str(obj[key])
|
||
if len(value) >= 10 and value.isdigit():
|
||
logger.info(f"从{path}.{key}中提取商品ID: {value}")
|
||
return value
|
||
|
||
# 递归查找
|
||
for key, value in obj.items():
|
||
result = find_item_id_recursive(value, f"{path}.{key}" if path else key)
|
||
if result:
|
||
return result
|
||
|
||
elif isinstance(obj, str):
|
||
# 从字符串中提取可能的商品ID
|
||
id_match = re.search(r'(\d{10,})', obj)
|
||
if id_match:
|
||
logger.info(f"从{path}字符串中提取商品ID: {id_match.group(1)}")
|
||
return id_match.group(1)
|
||
|
||
return None
|
||
|
||
result = find_item_id_recursive(message)
|
||
if result:
|
||
return result
|
||
|
||
logger.warning("所有方法都未能提取到商品ID")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"提取商品ID失败: {self._safe_str(e)}")
|
||
return None
|
||
|
||
def debug_message_structure(self, message, context=""):
|
||
"""调试消息结构的辅助方法"""
|
||
try:
|
||
logger.warning(f"[{context}] 消息结构调试:")
|
||
logger.warning(f" 消息类型: {type(message)}")
|
||
|
||
if isinstance(message, dict):
|
||
for key, value in message.items():
|
||
logger.warning(f" 键 '{key}': {type(value)} - {str(value)[:100]}...")
|
||
|
||
# 特别关注可能包含商品ID的字段
|
||
if key in ["1", "3"] and isinstance(value, dict):
|
||
logger.warning(f" 详细结构 '{key}':")
|
||
for sub_key, sub_value in value.items():
|
||
logger.warning(f" '{sub_key}': {type(sub_value)} - {str(sub_value)[:50]}...")
|
||
else:
|
||
logger.warning(f" 消息内容: {str(message)[:200]}...")
|
||
|
||
except Exception as e:
|
||
logger.error(f"调试消息结构时发生错误: {self._safe_str(e)}")
|
||
|
||
async def get_default_reply(self, send_user_name: str, send_user_id: str, send_message: str, chat_id: str, item_id: str = None) -> dict:
|
||
"""获取默认回复内容,支持指定商品回复、变量替换、只回复一次功能和图片发送
|
||
|
||
Returns:
|
||
dict: 包含 'text' (文字回复) 和 'image_url' (图片URL,可选) 的字典
|
||
或 None (无回复)
|
||
或 "EMPTY_REPLY" (空回复标记)
|
||
"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
# 1. 优先检查指定商品回复
|
||
if item_id:
|
||
item_reply = db_manager.get_item_reply(self.cookie_id, item_id)
|
||
if item_reply and item_reply.get('reply_content'):
|
||
reply_content = item_reply['reply_content']
|
||
logger.info(f"【{self.cookie_id}】使用指定商品回复: 商品ID={item_id}")
|
||
|
||
# 进行变量替换
|
||
try:
|
||
formatted_reply = reply_content.format(
|
||
send_user_name=send_user_name,
|
||
send_user_id=send_user_id,
|
||
send_message=send_message,
|
||
item_id=item_id
|
||
)
|
||
logger.info(f"【{self.cookie_id}】指定商品回复内容: {formatted_reply}")
|
||
return {'text': formatted_reply, 'image_url': None}
|
||
except Exception as format_error:
|
||
logger.error(f"指定商品回复变量替换失败: {self._safe_str(format_error)}")
|
||
# 如果变量替换失败,返回原始内容
|
||
return {'text': reply_content, 'image_url': None}
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】商品ID {item_id} 没有配置指定回复,使用默认回复")
|
||
|
||
# 2. 获取当前账号的默认回复设置
|
||
default_reply_settings = db_manager.get_default_reply(self.cookie_id)
|
||
|
||
if not default_reply_settings or not default_reply_settings.get('enabled', False):
|
||
logger.warning(f"账号 {self.cookie_id} 未启用默认回复")
|
||
return None
|
||
|
||
# 检查"只回复一次"功能
|
||
if default_reply_settings.get('reply_once', False) and chat_id:
|
||
# 检查是否已经回复过这个chat_id
|
||
if db_manager.has_default_reply_record(self.cookie_id, chat_id):
|
||
logger.info(f"【{self.cookie_id}】chat_id {chat_id} 已使用过默认回复,跳过(只回复一次)")
|
||
return None
|
||
|
||
reply_content = default_reply_settings.get('reply_content', '')
|
||
reply_image_url = default_reply_settings.get('reply_image_url', '')
|
||
|
||
# 如果文字和图片都为空,返回空回复标记
|
||
if (not reply_content or reply_content.strip() == '') and (not reply_image_url or reply_image_url.strip() == ''):
|
||
logger.info(f"账号 {self.cookie_id} 默认回复内容和图片都为空,不进行回复")
|
||
return "EMPTY_REPLY" # 返回特殊标记表示不回复
|
||
|
||
# 进行变量替换
|
||
try:
|
||
# 获取当前商品是否有设置自动回复
|
||
item_replay = db_manager.get_item_replay(item_id)
|
||
|
||
formatted_reply = reply_content.format(
|
||
send_user_name=send_user_name,
|
||
send_user_id=send_user_id,
|
||
send_message=send_message
|
||
) if reply_content else ''
|
||
|
||
if item_replay:
|
||
formatted_reply = item_replay.get('reply_content', '')
|
||
|
||
# 如果开启了"只回复一次"功能,记录这次回复
|
||
if default_reply_settings.get('reply_once', False) and chat_id:
|
||
db_manager.add_default_reply_record(self.cookie_id, chat_id)
|
||
logger.info(f"【{self.cookie_id}】记录默认回复: chat_id={chat_id}")
|
||
|
||
logger.info(f"【{self.cookie_id}】使用默认回复: 文字={formatted_reply}, 图片={reply_image_url}")
|
||
return {'text': formatted_reply, 'image_url': reply_image_url if reply_image_url and reply_image_url.strip() else None}
|
||
except Exception as format_error:
|
||
logger.error(f"默认回复变量替换失败: {self._safe_str(format_error)}")
|
||
# 如果变量替换失败,返回原始内容
|
||
return {'text': reply_content, 'image_url': reply_image_url if reply_image_url and reply_image_url.strip() else None}
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取默认回复失败: {self._safe_str(e)}")
|
||
return None
|
||
|
||
async def get_keyword_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str = None) -> str:
|
||
"""获取关键词匹配回复(支持商品ID优先匹配和图片类型)"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
# 获取当前账号的关键词列表(包含类型信息)
|
||
keywords = db_manager.get_keywords_with_type(self.cookie_id)
|
||
|
||
if not keywords:
|
||
logger.warning(f"账号 {self.cookie_id} 没有配置关键词")
|
||
return None
|
||
|
||
# 1. 如果有商品ID,优先匹配该商品ID对应的关键词
|
||
if item_id:
|
||
for keyword_data in keywords:
|
||
keyword = keyword_data['keyword']
|
||
reply = keyword_data['reply']
|
||
keyword_item_id = keyword_data['item_id']
|
||
keyword_type = keyword_data.get('type', 'text')
|
||
image_url = keyword_data.get('image_url')
|
||
|
||
if keyword_item_id == item_id and keyword.lower() in send_message.lower():
|
||
logger.info(f"商品ID关键词匹配成功: 商品{item_id} '{keyword}' (类型: {keyword_type})")
|
||
|
||
# 根据关键词类型处理
|
||
if keyword_type == 'image' and image_url:
|
||
# 图片类型关键词,发送图片
|
||
return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message)
|
||
else:
|
||
# 文本类型关键词,检查回复内容是否为空
|
||
if not reply or (reply and reply.strip() == ''):
|
||
logger.info(f"商品ID关键词 '{keyword}' 回复内容为空,不进行回复")
|
||
return "EMPTY_REPLY" # 返回特殊标记表示匹配到但不回复
|
||
|
||
# 进行变量替换
|
||
try:
|
||
formatted_reply = reply.format(
|
||
send_user_name=send_user_name,
|
||
send_user_id=send_user_id,
|
||
send_message=send_message
|
||
)
|
||
logger.info(f"商品ID文本关键词回复: {formatted_reply}")
|
||
return formatted_reply
|
||
except Exception as format_error:
|
||
logger.error(f"关键词回复变量替换失败: {self._safe_str(format_error)}")
|
||
# 如果变量替换失败,返回原始内容
|
||
return reply
|
||
|
||
# 2. 如果商品ID匹配失败或没有商品ID,匹配没有商品ID的通用关键词
|
||
for keyword_data in keywords:
|
||
keyword = keyword_data['keyword']
|
||
reply = keyword_data['reply']
|
||
keyword_item_id = keyword_data['item_id']
|
||
keyword_type = keyword_data.get('type', 'text')
|
||
image_url = keyword_data.get('image_url')
|
||
|
||
if not keyword_item_id and keyword.lower() in send_message.lower():
|
||
logger.info(f"通用关键词匹配成功: '{keyword}' (类型: {keyword_type})")
|
||
|
||
# 根据关键词类型处理
|
||
if keyword_type == 'image' and image_url:
|
||
# 图片类型关键词,发送图片
|
||
return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message)
|
||
else:
|
||
# 文本类型关键词,检查回复内容是否为空
|
||
if not reply or (reply and reply.strip() == ''):
|
||
logger.info(f"通用关键词 '{keyword}' 回复内容为空,不进行回复")
|
||
return "EMPTY_REPLY" # 返回特殊标记表示匹配到但不回复
|
||
|
||
# 进行变量替换
|
||
try:
|
||
formatted_reply = reply.format(
|
||
send_user_name=send_user_name,
|
||
send_user_id=send_user_id,
|
||
send_message=send_message
|
||
)
|
||
logger.info(f"通用文本关键词回复: {formatted_reply}")
|
||
return formatted_reply
|
||
except Exception as format_error:
|
||
logger.error(f"关键词回复变量替换失败: {self._safe_str(format_error)}")
|
||
# 如果变量替换失败,返回原始内容
|
||
return reply
|
||
|
||
logger.warning(f"未找到匹配的关键词: {send_message}")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取关键词回复失败: {self._safe_str(e)}")
|
||
return None
|
||
|
||
async def _handle_image_keyword(self, keyword: str, image_url: str, send_user_name: str, send_user_id: str, send_message: str) -> str:
|
||
"""处理图片类型关键词"""
|
||
try:
|
||
# 检查图片URL类型
|
||
if self._is_cdn_url(image_url):
|
||
# 已经是CDN链接,直接使用
|
||
logger.info(f"使用已有的CDN图片链接: {image_url}")
|
||
return f"__IMAGE_SEND__{image_url}"
|
||
|
||
elif image_url.startswith('/static/uploads/') or image_url.startswith('static/uploads/'):
|
||
# 本地图片,需要上传到闲鱼CDN
|
||
local_image_path = image_url.replace('/static/uploads/', 'static/uploads/')
|
||
if os.path.exists(local_image_path):
|
||
logger.info(f"准备上传本地图片到闲鱼CDN: {local_image_path}")
|
||
|
||
# 使用图片上传器上传到闲鱼CDN
|
||
from utils.image_uploader import ImageUploader
|
||
uploader = ImageUploader(self.cookies_str)
|
||
|
||
async with uploader:
|
||
cdn_url = await uploader.upload_image(local_image_path)
|
||
if cdn_url:
|
||
logger.info(f"图片上传成功,CDN URL: {cdn_url}")
|
||
# 更新数据库中的图片URL为CDN URL
|
||
await self._update_keyword_image_url(keyword, cdn_url)
|
||
image_url = cdn_url
|
||
else:
|
||
logger.error(f"图片上传失败: {local_image_path}")
|
||
logger.error(f"❌ Cookie可能已失效!请检查配置并更新Cookie")
|
||
return f"抱歉,图片发送失败(Cookie可能已失效,请检查日志)"
|
||
else:
|
||
logger.error(f"本地图片文件不存在: {local_image_path}")
|
||
return f"抱歉,图片文件不存在。"
|
||
|
||
else:
|
||
# 其他类型的URL(可能是外部链接),直接使用
|
||
logger.info(f"使用外部图片链接: {image_url}")
|
||
|
||
# 发送图片(这里返回特殊标记,在调用处处理实际发送)
|
||
return f"__IMAGE_SEND__{image_url}"
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理图片关键词失败: {e}")
|
||
return f"抱歉,图片发送失败: {str(e)}"
|
||
|
||
def _is_cdn_url(self, url: str) -> bool:
|
||
"""检查URL是否是闲鱼CDN链接"""
|
||
if not url:
|
||
return False
|
||
|
||
# 闲鱼CDN域名列表
|
||
cdn_domains = [
|
||
'gw.alicdn.com',
|
||
'img.alicdn.com',
|
||
'cloud.goofish.com',
|
||
'goofish.com',
|
||
'taobaocdn.com',
|
||
'tbcdn.cn',
|
||
'aliimg.com'
|
||
]
|
||
|
||
# 检查是否包含CDN域名
|
||
url_lower = url.lower()
|
||
for domain in cdn_domains:
|
||
if domain in url_lower:
|
||
return True
|
||
|
||
# 检查是否是HTTPS链接且包含图片特征
|
||
if url_lower.startswith('https://') and any(ext in url_lower for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
|
||
return True
|
||
|
||
return False
|
||
|
||
async def _get_image_size_from_url(self, image_url: str) -> tuple:
|
||
"""从URL获取图片尺寸
|
||
|
||
Args:
|
||
image_url: 图片URL
|
||
|
||
Returns:
|
||
(width, height) 元组,失败返回 (None, None)
|
||
"""
|
||
import aiohttp
|
||
from io import BytesIO
|
||
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】开始从URL获取图片尺寸: {image_url[:80]}...")
|
||
|
||
# 不接受AVIF格式(PIL默认不支持),让CDN返回WEBP/JPEG等格式
|
||
headers = {
|
||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||
'Accept': 'image/jpeg,image/png,image/gif,image/webp,*/*;q=0.8',
|
||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||
'Referer': 'https://www.goofish.com/',
|
||
}
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(image_url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as response:
|
||
if response.status == 200:
|
||
image_data = await response.read()
|
||
from PIL import Image
|
||
with Image.open(BytesIO(image_data)) as img:
|
||
width, height = img.size
|
||
logger.info(f"【{self.cookie_id}】解析图片尺寸成功: {width}x{height}")
|
||
return (width, height)
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】下载图片失败,HTTP状态码: {response.status}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】从URL获取图片尺寸失败: {e}")
|
||
|
||
return (None, None)
|
||
|
||
async def _update_keyword_image_url(self, keyword: str, new_image_url: str):
|
||
"""更新关键词的图片URL"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
success = db_manager.update_keyword_image_url(self.cookie_id, keyword, new_image_url)
|
||
if success:
|
||
logger.info(f"图片URL已更新: {keyword} -> {new_image_url}")
|
||
else:
|
||
logger.warning(f"图片URL更新失败: {keyword}")
|
||
except Exception as e:
|
||
logger.error(f"更新关键词图片URL失败: {e}")
|
||
|
||
async def _update_card_image_url(self, card_id: int, new_image_url: str):
|
||
"""更新卡券的图片URL"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
success = db_manager.update_card_image_url(card_id, new_image_url)
|
||
if success:
|
||
logger.info(f"卡券图片URL已更新: 卡券ID={card_id} -> {new_image_url}")
|
||
else:
|
||
logger.warning(f"卡券图片URL更新失败: 卡券ID={card_id}")
|
||
except Exception as e:
|
||
logger.error(f"更新卡券图片URL失败: {e}")
|
||
|
||
async def _update_default_reply_image_url(self, new_image_url: str):
|
||
"""更新默认回复的图片URL为CDN URL"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
success = db_manager.update_default_reply_image_url(self.cookie_id, new_image_url)
|
||
if success:
|
||
logger.info(f"【{self.cookie_id}】默认回复图片URL已更新: {new_image_url}")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】默认回复图片URL更新失败")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】更新默认回复图片URL失败: {e}")
|
||
|
||
async def get_ai_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str, chat_id: str):
|
||
"""获取AI回复"""
|
||
try:
|
||
from app.ai_reply_engine import ai_reply_engine
|
||
|
||
# 检查是否启用AI回复
|
||
if not ai_reply_engine.is_ai_enabled(self.cookie_id):
|
||
logger.warning(f"账号 {self.cookie_id} 未启用AI回复")
|
||
return None
|
||
|
||
# 从数据库获取商品信息
|
||
from app.db_manager import db_manager
|
||
item_info_raw = db_manager.get_item_info(self.cookie_id, item_id)
|
||
|
||
if not item_info_raw:
|
||
logger.warning(f"数据库中无商品信息: {item_id}")
|
||
# 使用默认商品信息
|
||
item_info = {
|
||
'title': '商品信息获取失败',
|
||
'price': 0,
|
||
'desc': '暂无商品描述'
|
||
}
|
||
else:
|
||
# 解析数据库中的商品信息
|
||
item_info = {
|
||
'title': item_info_raw.get('item_title', '未知商品'),
|
||
'price': self._parse_price(item_info_raw.get('item_price', '0')),
|
||
'desc': item_info_raw.get('item_detail', '暂无商品描述')
|
||
}
|
||
|
||
# 生成AI回复
|
||
# 由于外部已实现防抖机制,跳过内部等待(skip_wait=True)
|
||
reply = await ai_reply_engine.generate_reply_async(
|
||
message=send_message,
|
||
item_info=item_info,
|
||
chat_id=chat_id,
|
||
cookie_id=self.cookie_id,
|
||
user_id=send_user_id,
|
||
item_id=item_id,
|
||
skip_wait=True # 跳过内部等待,因为外部已实现防抖
|
||
)
|
||
|
||
if reply:
|
||
logger.info(f"【{self.cookie_id}】AI回复生成成功,字符数={len(reply)}")
|
||
return reply
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】AI回复未生成,将继续尝试默认回复")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取AI回复失败: {self._safe_str(e)}")
|
||
return None
|
||
|
||
def _parse_price(self, price_str: str) -> float:
|
||
"""解析价格字符串为数字"""
|
||
try:
|
||
if not price_str:
|
||
return 0.0
|
||
# 移除非数字字符,保留小数点
|
||
price_clean = re.sub(r'[^\d.]', '', str(price_str))
|
||
return float(price_clean) if price_clean else 0.0
|
||
except:
|
||
return 0.0
|
||
|
||
async def send_system_notification(self, message: str) -> int:
|
||
"""把系统级消息推送到该账号绑定的全部通知渠道。
|
||
|
||
与 :meth:`send_notification` 的区别是不依赖买家消息上下文,
|
||
供发货超时告警这类主动通知使用。
|
||
|
||
Returns:
|
||
成功发送的渠道数。
|
||
"""
|
||
if not message:
|
||
return 0
|
||
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
notifications = db_manager.get_account_notifications(self.cookie_id) or []
|
||
except Exception as e:
|
||
logger.error(f"📱 读取通知渠道失败: {self._safe_str(e)}")
|
||
return 0
|
||
|
||
sent = 0
|
||
for notification in notifications:
|
||
if not notification.get('enabled', True):
|
||
continue
|
||
|
||
channel_type = notification.get('channel_type')
|
||
try:
|
||
config_data = self._parse_notification_config(
|
||
notification.get('channel_config')
|
||
)
|
||
match channel_type:
|
||
case 'ding_talk' | 'dingtalk':
|
||
await self._send_dingtalk_notification(config_data, message)
|
||
case 'feishu' | 'lark':
|
||
await self._send_feishu_notification(config_data, message)
|
||
case 'bark':
|
||
await self._send_bark_notification(config_data, message)
|
||
case 'email':
|
||
await self._send_email_notification(config_data, message)
|
||
case 'webhook':
|
||
await self._send_webhook_notification(config_data, message)
|
||
case 'wechat':
|
||
await self._send_wechat_notification(config_data, message)
|
||
case 'telegram':
|
||
await self._send_telegram_notification(config_data, message)
|
||
case _:
|
||
logger.warning(f"📱 不支持的通知渠道类型: {channel_type}")
|
||
continue
|
||
sent += 1
|
||
except Exception as notify_error:
|
||
logger.error(
|
||
f"📱 发送系统通知失败 ({notification.get('channel_name', 'Unknown')}): "
|
||
f"{self._safe_str(notify_error)}"
|
||
)
|
||
|
||
return sent
|
||
|
||
async def send_notification(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str = None, chat_id: str = None):
|
||
"""发送消息通知"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
import hashlib
|
||
|
||
# 过滤系统默认消息,不发送通知
|
||
system_messages = [
|
||
'发来一条消息',
|
||
'发来一条新消息'
|
||
]
|
||
|
||
if send_message in system_messages:
|
||
logger.warning(f"📱 系统消息不发送通知: {send_message}")
|
||
return
|
||
|
||
# 生成通知的唯一标识(基于消息内容、chat_id、send_user_id)
|
||
# 用于防重复发送
|
||
notification_key = f"{chat_id or 'unknown'}_{send_user_id}_{send_message}"
|
||
notification_hash = hashlib.md5(notification_key.encode('utf-8')).hexdigest()
|
||
|
||
# 使用异步锁保护防重复检查,确保并发安全
|
||
async with self.notification_lock:
|
||
# 检查是否在冷却时间内已发送过相同的通知
|
||
current_time = time.time()
|
||
if notification_hash in self.last_notification_time:
|
||
time_since_last = current_time - self.last_notification_time[notification_hash]
|
||
if time_since_last < self.notification_cooldown:
|
||
remaining_seconds = int(self.notification_cooldown - time_since_last)
|
||
logger.warning(f"📱 通知在冷却期内(剩余 {remaining_seconds} 秒),跳过重复发送 - 账号: {self.cookie_id}, 买家: {send_user_name}, 消息: {send_message[:30]}...")
|
||
return
|
||
|
||
# 更新通知发送时间
|
||
self.last_notification_time[notification_hash] = current_time
|
||
|
||
# 清理过期的通知记录(超过1小时的记录)
|
||
expired_keys = [
|
||
key for key, timestamp in self.last_notification_time.items()
|
||
if current_time - timestamp > 3600 # 1小时
|
||
]
|
||
for key in expired_keys:
|
||
del self.last_notification_time[key]
|
||
|
||
logger.info(f"📱 开始发送消息通知 - 账号: {self.cookie_id}, 买家: {send_user_name}")
|
||
|
||
# 获取当前账号的通知配置
|
||
notifications = db_manager.get_account_notifications(self.cookie_id)
|
||
|
||
if not notifications:
|
||
logger.warning(f"📱 账号 {self.cookie_id} 未配置消息通知,跳过通知发送")
|
||
return
|
||
|
||
logger.info(f"📱 找到 {len(notifications)} 个通知渠道配置")
|
||
|
||
# 构建通知消息
|
||
notification_msg = f"🚨 接收消息通知\n\n" \
|
||
f"账号: {self.cookie_id}\n" \
|
||
f"买家: {send_user_name} (ID: {send_user_id})\n" \
|
||
f"商品ID: {item_id or '未知'}\n" \
|
||
f"聊天ID: {chat_id or '未知'}\n" \
|
||
f"消息内容: {send_message}\n" \
|
||
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
|
||
|
||
# 发送通知到各个渠道
|
||
for i, notification in enumerate(notifications, 1):
|
||
logger.info(f"📱 处理第 {i} 个通知渠道: {notification.get('channel_name', 'Unknown')}")
|
||
|
||
if not notification.get('enabled', True):
|
||
logger.warning(f"📱 通知渠道 {notification.get('channel_name')} 已禁用,跳过")
|
||
continue
|
||
|
||
channel_type = notification.get('channel_type')
|
||
channel_config = notification.get('channel_config')
|
||
|
||
logger.info(f"📱 渠道类型: {channel_type}")
|
||
|
||
try:
|
||
# 解析配置数据
|
||
config_data = self._parse_notification_config(channel_config)
|
||
match channel_type:
|
||
case 'ding_talk' | 'dingtalk':
|
||
logger.info(f"📱 开始发送钉钉通知...")
|
||
await self._send_dingtalk_notification(config_data, notification_msg)
|
||
case 'feishu' | 'lark':
|
||
logger.info(f"📱 开始发送飞书通知...")
|
||
await self._send_feishu_notification(config_data, notification_msg)
|
||
case 'bark':
|
||
logger.info(f"📱 开始发送Bark通知...")
|
||
await self._send_bark_notification(config_data, notification_msg)
|
||
case 'email':
|
||
logger.info(f"📱 开始发送邮件通知...")
|
||
await self._send_email_notification(config_data, notification_msg)
|
||
case 'webhook':
|
||
logger.info(f"📱 开始发送Webhook通知...")
|
||
await self._send_webhook_notification(config_data, notification_msg)
|
||
case 'wechat':
|
||
logger.info(f"📱 开始发送微信通知...")
|
||
await self._send_wechat_notification(config_data, notification_msg)
|
||
case 'telegram':
|
||
logger.info(f"📱 开始发送Telegram通知...")
|
||
await self._send_telegram_notification(config_data, notification_msg)
|
||
case _:
|
||
logger.warning(f"📱 不支持的通知渠道类型: {channel_type}")
|
||
|
||
except Exception as notify_error:
|
||
logger.error(f"📱 发送通知失败 ({notification.get('channel_name', 'Unknown')}): {self._safe_str(notify_error)}")
|
||
import traceback
|
||
logger.error(f"📱 详细错误信息: {traceback.format_exc()}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"📱 处理消息通知失败: {self._safe_str(e)}")
|
||
import traceback
|
||
logger.error(f"📱 详细错误信息: {traceback.format_exc()}")
|
||
|
||
def _parse_notification_config(self, config: str) -> dict:
|
||
"""解析通知配置数据"""
|
||
try:
|
||
import json
|
||
# 尝试解析JSON格式的配置
|
||
return json.loads(config)
|
||
except (json.JSONDecodeError, TypeError):
|
||
# 兼容旧格式(直接字符串)
|
||
return {"config": config}
|
||
|
||
async def _send_dingtalk_notification(self, config_data: dict, message: str):
|
||
"""发送钉钉通知"""
|
||
try:
|
||
import aiohttp
|
||
import hmac
|
||
import hashlib
|
||
import base64
|
||
import time
|
||
|
||
# 解析配置
|
||
webhook_url = config_data.get('webhook_url') or config_data.get('config', '')
|
||
secret = config_data.get('secret', '')
|
||
|
||
webhook_url = webhook_url.strip() if webhook_url else ''
|
||
if not webhook_url:
|
||
logger.warning("钉钉通知配置为空")
|
||
return
|
||
|
||
# 如果有加签密钥,生成签名
|
||
if secret:
|
||
timestamp = str(round(time.time() * 1000))
|
||
secret_enc = secret.encode('utf-8')
|
||
string_to_sign = f'{timestamp}\n{secret}'
|
||
string_to_sign_enc = string_to_sign.encode('utf-8')
|
||
hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest()
|
||
sign = base64.b64encode(hmac_code).decode('utf-8')
|
||
webhook_url += f'×tamp={timestamp}&sign={sign}'
|
||
|
||
data = {
|
||
"msgtype": "markdown",
|
||
"markdown": {
|
||
"title": "闲鱼自动回复通知",
|
||
"text": message
|
||
}
|
||
}
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(webhook_url, json=data, timeout=10) as response:
|
||
if response.status == 200:
|
||
logger.info(f"钉钉通知发送成功")
|
||
else:
|
||
logger.warning(f"钉钉通知发送失败: {response.status}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"发送钉钉通知异常: {self._safe_str(e)}")
|
||
|
||
async def _send_feishu_notification(self, config_data: dict, message: str):
|
||
"""发送飞书通知"""
|
||
try:
|
||
import aiohttp
|
||
import json
|
||
import hmac
|
||
import hashlib
|
||
import base64
|
||
|
||
logger.info("📱 飞书通知 - 开始处理")
|
||
|
||
# 解析配置
|
||
webhook_url = config_data.get('webhook_url', '')
|
||
secret = config_data.get('secret', '')
|
||
|
||
logger.info(f"📱 飞书通知 - Webhook URL: {webhook_url[:50]}...")
|
||
logger.info(f"📱 飞书通知 - 是否有签名密钥: {'是' if secret else '否'}")
|
||
|
||
if not webhook_url:
|
||
logger.warning("📱 飞书通知 - Webhook URL配置为空,无法发送通知")
|
||
return
|
||
|
||
# 如果有加签密钥,生成签名
|
||
timestamp = str(int(time.time()))
|
||
sign = ""
|
||
|
||
if secret:
|
||
string_to_sign = f'{timestamp}\n{secret}'
|
||
hmac_code = hmac.new(
|
||
string_to_sign.encode('utf-8'),
|
||
''.encode('utf-8'),
|
||
digestmod=hashlib.sha256
|
||
).digest()
|
||
sign = base64.b64encode(hmac_code).decode('utf-8')
|
||
logger.info(f"📱 飞书通知 - 已生成签名")
|
||
|
||
# 构建请求数据
|
||
data = {
|
||
"msg_type": "text",
|
||
"content": {
|
||
"text": message
|
||
},
|
||
"timestamp": timestamp
|
||
}
|
||
|
||
# 如果有签名,添加到请求数据中
|
||
if sign:
|
||
data["sign"] = sign
|
||
|
||
logger.info(f"📱 飞书通知 - 请求数据构建完成")
|
||
|
||
# 发送POST请求
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(webhook_url, json=data, timeout=10) as response:
|
||
response_text = await response.text()
|
||
logger.info(f"📱 飞书通知 - 响应状态: {response.status}")
|
||
logger.info(f"📱 飞书通知 - 响应内容: {response_text}")
|
||
|
||
if response.status == 200:
|
||
try:
|
||
response_json = json.loads(response_text)
|
||
if response_json.get('code') == 0:
|
||
logger.info(f"📱 飞书通知发送成功")
|
||
else:
|
||
logger.warning(f"📱 飞书通知发送失败: {response_json.get('msg', '未知错误')}")
|
||
except json.JSONDecodeError:
|
||
logger.info(f"📱 飞书通知发送成功(响应格式异常)")
|
||
else:
|
||
logger.warning(f"📱 飞书通知发送失败: HTTP {response.status}, 响应: {response_text}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"📱 发送飞书通知异常: {self._safe_str(e)}")
|
||
import traceback
|
||
logger.error(f"📱 飞书通知异常详情: {traceback.format_exc()}")
|
||
|
||
async def _send_bark_notification(self, config_data: dict, message: str):
|
||
"""发送Bark通知"""
|
||
try:
|
||
import aiohttp
|
||
import json
|
||
|
||
logger.info("📱 Bark通知 - 开始处理")
|
||
|
||
# 解析配置
|
||
server_url = config_data.get('server_url', 'https://api.day.app').rstrip('/')
|
||
device_key = config_data.get('device_key', '')
|
||
title = config_data.get('title', '闲鱼自动回复通知')
|
||
sound = config_data.get('sound', 'default')
|
||
icon = config_data.get('icon', '')
|
||
group = config_data.get('group', 'xianyu')
|
||
url = config_data.get('url', '')
|
||
|
||
logger.info(f"📱 Bark通知 - 服务器: {server_url}")
|
||
logger.info(f"📱 Bark通知 - 设备密钥: {'已配置' if device_key else '未设置'}")
|
||
logger.info(f"📱 Bark通知 - 标题: {title}")
|
||
|
||
if not device_key:
|
||
logger.warning("📱 Bark通知 - 设备密钥配置为空,无法发送通知")
|
||
return
|
||
|
||
# 构建请求URL和数据
|
||
# Bark支持两种方式:URL路径方式和POST JSON方式
|
||
# 这里使用POST JSON方式,更灵活且支持更多参数
|
||
|
||
api_url = f"{server_url}/push"
|
||
|
||
# 构建请求数据
|
||
data = {
|
||
"device_key": device_key,
|
||
"title": title,
|
||
"body": message,
|
||
"sound": sound,
|
||
"group": group
|
||
}
|
||
|
||
# 可选参数
|
||
if icon:
|
||
data["icon"] = icon
|
||
if url:
|
||
data["url"] = url
|
||
|
||
logger.info(f"📱 Bark通知 - API地址: {api_url}")
|
||
logger.info(f"📱 Bark通知 - 请求数据构建完成")
|
||
|
||
# 发送POST请求
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(api_url, json=data, timeout=10) as response:
|
||
response_text = await response.text()
|
||
logger.info(f"📱 Bark通知 - 响应状态: {response.status}")
|
||
logger.info(f"📱 Bark通知 - 响应内容: {response_text}")
|
||
|
||
if response.status == 200:
|
||
try:
|
||
response_json = json.loads(response_text)
|
||
if response_json.get('code') == 200:
|
||
logger.info(f"📱 Bark通知发送成功")
|
||
else:
|
||
logger.warning(f"📱 Bark通知发送失败: {response_json.get('message', '未知错误')}")
|
||
except json.JSONDecodeError:
|
||
# 某些Bark服务器可能返回纯文本
|
||
if 'success' in response_text.lower() or 'ok' in response_text.lower():
|
||
logger.info(f"📱 Bark通知发送成功")
|
||
else:
|
||
logger.warning(f"📱 Bark通知响应格式异常: {response_text}")
|
||
else:
|
||
logger.warning(f"📱 Bark通知发送失败: HTTP {response.status}, 响应: {response_text}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"📱 发送Bark通知异常: {self._safe_str(e)}")
|
||
import traceback
|
||
logger.error(f"📱 Bark通知异常详情: {traceback.format_exc()}")
|
||
|
||
async def _send_email_notification(self, config_data: dict, message: str, attachment_path: str = None):
|
||
"""发送邮件通知(支持附件)
|
||
|
||
Args:
|
||
config_data: 邮件配置
|
||
message: 邮件正文
|
||
attachment_path: 附件文件路径(可选)
|
||
"""
|
||
try:
|
||
import smtplib
|
||
from email.mime.text import MIMEText
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.image import MIMEImage
|
||
import os
|
||
|
||
# 解析配置
|
||
smtp_server = config_data.get('smtp_server', '')
|
||
smtp_port = int(config_data.get('smtp_port', 587))
|
||
email_user = config_data.get('email_user', '')
|
||
email_password = config_data.get('email_password', '')
|
||
recipient_email = config_data.get('recipient_email', '')
|
||
smtp_use_tls = config_data.get('smtp_use_tls', smtp_port == 587) # 修复:添加变量定义
|
||
|
||
if not all([smtp_server, email_user, email_password, recipient_email]):
|
||
logger.warning("邮件通知配置不完整")
|
||
return
|
||
|
||
# 创建邮件
|
||
msg = MIMEMultipart()
|
||
msg['From'] = email_user
|
||
msg['To'] = recipient_email
|
||
msg['Subject'] = "闲鱼自动回复通知"
|
||
|
||
# 添加邮件正文
|
||
msg.attach(MIMEText(message, 'plain', 'utf-8'))
|
||
|
||
# 添加附件(如果有)
|
||
if attachment_path and os.path.exists(attachment_path):
|
||
try:
|
||
with open(attachment_path, 'rb') as f:
|
||
img_data = f.read()
|
||
|
||
# 根据文件扩展名判断MIME类型
|
||
filename = os.path.basename(attachment_path)
|
||
if attachment_path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif')):
|
||
img = MIMEImage(img_data)
|
||
img.add_header('Content-Disposition', 'attachment', filename=filename)
|
||
msg.attach(img)
|
||
logger.info(f"已添加图片附件: {filename}")
|
||
else:
|
||
from email.mime.application import MIMEApplication
|
||
attach = MIMEApplication(img_data)
|
||
attach.add_header('Content-Disposition', 'attachment', filename=filename)
|
||
msg.attach(attach)
|
||
logger.info(f"已添加附件: {filename}")
|
||
except Exception as attach_error:
|
||
logger.error(f"添加邮件附件失败: {self._safe_str(attach_error)}")
|
||
|
||
# 发送邮件
|
||
server = None
|
||
try:
|
||
if smtp_port == 465:
|
||
# 使用SSL连接(端口465)
|
||
server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
|
||
else:
|
||
# 使用普通连接,然后升级到TLS(端口587)
|
||
server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
|
||
if smtp_use_tls:
|
||
server.starttls()
|
||
|
||
# 尝试登录
|
||
try:
|
||
server.login(email_user, email_password)
|
||
except smtplib.SMTPAuthenticationError as auth_error:
|
||
error_code = auth_error.smtp_code if hasattr(auth_error, 'smtp_code') else None
|
||
error_msg = str(auth_error)
|
||
|
||
# 提供详细的错误提示
|
||
logger.error(f"邮件SMTP认证失败 (错误码: {error_code})")
|
||
logger.error(f"邮箱地址: {email_user}")
|
||
logger.error(f"SMTP服务器: {smtp_server}:{smtp_port}")
|
||
logger.error(f"错误详情: {error_msg}")
|
||
|
||
# 根据常见错误提供解决建议
|
||
suggestions = []
|
||
if 'qq.com' in email_user.lower() or 'qq' in smtp_server.lower():
|
||
suggestions.append("QQ邮箱需要使用授权码而不是登录密码")
|
||
suggestions.append("请到QQ邮箱设置 -> 账户 -> 开启SMTP服务 -> 生成授权码")
|
||
elif 'gmail.com' in email_user.lower() or 'gmail' in smtp_server.lower():
|
||
suggestions.append("Gmail需要使用应用专用密码")
|
||
suggestions.append("请到Google账户 -> 安全性 -> 两步验证 -> 应用专用密码")
|
||
suggestions.append("或启用'允许不够安全的应用访问'(不推荐)")
|
||
elif '163.com' in email_user.lower() or '126.com' in email_user.lower() or 'yeah.net' in email_user.lower():
|
||
suggestions.append("网易邮箱需要使用授权码")
|
||
suggestions.append("请到邮箱设置 -> POP3/SMTP/IMAP -> 开启SMTP服务 -> 生成授权码")
|
||
else:
|
||
suggestions.append("请检查邮箱密码/授权码是否正确")
|
||
suggestions.append("某些邮箱服务商需要使用授权码而不是登录密码")
|
||
suggestions.append("请查看邮箱服务商的SMTP设置说明")
|
||
|
||
if suggestions:
|
||
logger.error("解决建议:")
|
||
for i, suggestion in enumerate(suggestions, 1):
|
||
logger.error(f" {i}. {suggestion}")
|
||
|
||
raise # 重新抛出异常
|
||
|
||
server.send_message(msg)
|
||
logger.info(f"邮件通知发送成功: {recipient_email}")
|
||
|
||
finally:
|
||
# 确保关闭连接
|
||
if server:
|
||
try:
|
||
server.quit()
|
||
except:
|
||
try:
|
||
server.close()
|
||
except:
|
||
pass
|
||
|
||
except smtplib.SMTPAuthenticationError:
|
||
# 认证错误已在上面处理,这里不再重复记录
|
||
pass
|
||
except smtplib.SMTPException as smtp_error:
|
||
logger.error(f"SMTP协议错误: {self._safe_str(smtp_error)}")
|
||
logger.error(f"SMTP服务器: {smtp_server}:{smtp_port}")
|
||
logger.error(f"请检查SMTP服务器地址和端口配置是否正确")
|
||
except Exception as e:
|
||
logger.error(f"发送邮件通知异常: {self._safe_str(e)}")
|
||
import traceback
|
||
logger.error(f"邮件发送详细错误: {traceback.format_exc()}")
|
||
|
||
async def _send_webhook_notification(self, config_data: dict, message: str):
|
||
"""发送Webhook通知"""
|
||
try:
|
||
import aiohttp
|
||
import json
|
||
|
||
# 解析配置
|
||
webhook_url = config_data.get('webhook_url', '')
|
||
http_method = config_data.get('http_method', 'POST').upper()
|
||
headers_str = config_data.get('headers', '{}')
|
||
|
||
if not webhook_url:
|
||
logger.warning("Webhook通知配置为空")
|
||
return
|
||
|
||
# 解析自定义请求头
|
||
try:
|
||
custom_headers = json.loads(headers_str) if headers_str else {}
|
||
except json.JSONDecodeError:
|
||
custom_headers = {}
|
||
|
||
# 设置默认请求头
|
||
headers = {'Content-Type': 'application/json'}
|
||
headers.update(custom_headers)
|
||
|
||
# 构建请求数据
|
||
data = {
|
||
'message': message,
|
||
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||
'source': 'xianyu-auto-reply'
|
||
}
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
if http_method == 'POST':
|
||
async with session.post(webhook_url, json=data, headers=headers, timeout=10) as response:
|
||
if response.status == 200:
|
||
logger.info(f"Webhook通知发送成功")
|
||
else:
|
||
logger.warning(f"Webhook通知发送失败: {response.status}")
|
||
elif http_method == 'PUT':
|
||
async with session.put(webhook_url, json=data, headers=headers, timeout=10) as response:
|
||
if response.status == 200:
|
||
logger.info(f"Webhook通知发送成功")
|
||
else:
|
||
logger.warning(f"Webhook通知发送失败: {response.status}")
|
||
else:
|
||
logger.warning(f"不支持的HTTP方法: {http_method}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"发送Webhook通知异常: {self._safe_str(e)}")
|
||
|
||
async def _send_wechat_notification(self, config_data: dict, message: str):
|
||
"""发送微信通知"""
|
||
try:
|
||
import aiohttp
|
||
|
||
# 解析配置
|
||
webhook_url = config_data.get('webhook_url', '')
|
||
|
||
if not webhook_url:
|
||
logger.warning("微信通知配置为空")
|
||
return
|
||
|
||
data = {
|
||
"msgtype": "text",
|
||
"text": {
|
||
"content": message
|
||
}
|
||
}
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(webhook_url, json=data, timeout=10) as response:
|
||
if response.status == 200:
|
||
logger.info(f"微信通知发送成功")
|
||
else:
|
||
logger.warning(f"微信通知发送失败: {response.status}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"发送微信通知异常: {self._safe_str(e)}")
|
||
|
||
async def _send_telegram_notification(self, config_data: dict, message: str):
|
||
"""发送Telegram通知"""
|
||
try:
|
||
import aiohttp
|
||
|
||
# 解析配置
|
||
bot_token = config_data.get('bot_token', '')
|
||
chat_id = config_data.get('chat_id', '')
|
||
|
||
if not all([bot_token, chat_id]):
|
||
logger.warning("Telegram通知配置不完整")
|
||
return
|
||
|
||
# 构建API URL
|
||
api_url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||
|
||
data = {
|
||
'chat_id': chat_id,
|
||
'text': message,
|
||
'parse_mode': 'HTML'
|
||
}
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(api_url, json=data, timeout=10) as response:
|
||
if response.status == 200:
|
||
logger.info(f"Telegram通知发送成功")
|
||
else:
|
||
logger.warning(f"Telegram通知发送失败: {response.status}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"发送Telegram通知异常: {self._safe_str(e)}")
|
||
|
||
async def send_token_refresh_notification(self, error_message: str, notification_type: str = "token_refresh", chat_id: str = None, attachment_path: str = None, verification_url: str = None):
|
||
"""发送Token刷新异常通知(带防重复机制,支持附件)
|
||
|
||
Args:
|
||
error_message: 错误消息
|
||
notification_type: 通知类型
|
||
chat_id: 聊天ID(可选)
|
||
attachment_path: 附件路径(可选,用于发送截图)
|
||
"""
|
||
try:
|
||
# 检查是否是正常的令牌过期,这种情况不需要发送通知
|
||
if self._is_normal_token_expiry(error_message):
|
||
logger.warning(f"检测到正常的令牌过期,跳过通知: {error_message}")
|
||
return
|
||
|
||
# 检查是否在冷却期内
|
||
current_time = time.time()
|
||
last_time = self.last_notification_time.get(notification_type, 0)
|
||
|
||
# 为Token刷新异常通知使用特殊的3小时冷却时间
|
||
# 基于错误消息内容判断是否为Token相关异常
|
||
if self._is_token_related_error(error_message):
|
||
cooldown_time = self.token_refresh_notification_cooldown
|
||
cooldown_desc = "3小时"
|
||
else:
|
||
cooldown_time = self.notification_cooldown
|
||
cooldown_desc = f"{self.notification_cooldown // 60}分钟"
|
||
|
||
if current_time - last_time < cooldown_time:
|
||
remaining_time = cooldown_time - (current_time - last_time)
|
||
remaining_hours = int(remaining_time // 3600)
|
||
remaining_minutes = int((remaining_time % 3600) // 60)
|
||
remaining_seconds = int(remaining_time % 60)
|
||
|
||
if remaining_hours > 0:
|
||
time_desc = f"{remaining_hours}小时{remaining_minutes}分钟"
|
||
elif remaining_minutes > 0:
|
||
time_desc = f"{remaining_minutes}分钟{remaining_seconds}秒"
|
||
else:
|
||
time_desc = f"{remaining_seconds}秒"
|
||
|
||
logger.warning(f"Token刷新通知在冷却期内,跳过发送: {notification_type} (还需等待 {time_desc})")
|
||
return
|
||
|
||
from app.db_manager import db_manager
|
||
|
||
# 获取当前账号的通知配置
|
||
notifications = db_manager.get_account_notifications(self.cookie_id)
|
||
|
||
if not notifications:
|
||
logger.warning("未配置消息通知,跳过Token刷新通知")
|
||
return
|
||
|
||
# 构造通知消息
|
||
# 判断异常信息中是否包含"滑块验证成功"
|
||
if "滑块验证成功" in error_message:
|
||
notification_msg = f"{error_message}\n\n" \
|
||
f"账号: {self.cookie_id}\n" \
|
||
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||
elif verification_url:
|
||
# 如果有验证链接,添加到消息中
|
||
notification_msg = f"{error_message}\n\n" \
|
||
f"账号: {self.cookie_id}\n" \
|
||
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n" \
|
||
f"验证链接: {verification_url}\n"
|
||
else:
|
||
notification_msg = f"Token刷新异常\n\n" \
|
||
f"账号ID: {self.cookie_id}\n" \
|
||
f"异常时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}\n" \
|
||
f"异常信息: {error_message}\n\n" \
|
||
f"请检查账号Cookie是否过期,如有需要请及时更新Cookie配置。\n"
|
||
|
||
logger.info(f"准备发送Token刷新异常通知: {self.cookie_id}")
|
||
|
||
# 发送通知到各个渠道
|
||
notification_sent = False
|
||
for notification in notifications:
|
||
if not notification.get('enabled', True):
|
||
continue
|
||
|
||
channel_type = notification.get('channel_type')
|
||
channel_config = notification.get('channel_config')
|
||
|
||
try:
|
||
# 解析配置数据
|
||
config_data = self._parse_notification_config(channel_config)
|
||
|
||
match channel_type:
|
||
case 'ding_talk' | 'dingtalk':
|
||
await self._send_dingtalk_notification(config_data, notification_msg)
|
||
notification_sent = True
|
||
case 'feishu' | 'lark':
|
||
await self._send_feishu_notification(config_data, notification_msg)
|
||
notification_sent = True
|
||
case 'bark':
|
||
await self._send_bark_notification(config_data, notification_msg)
|
||
notification_sent = True
|
||
case 'email':
|
||
# 邮件支持附件
|
||
await self._send_email_notification(config_data, notification_msg, attachment_path)
|
||
notification_sent = True
|
||
case 'webhook':
|
||
await self._send_webhook_notification(config_data, notification_msg)
|
||
notification_sent = True
|
||
case 'wechat':
|
||
await self._send_wechat_notification(config_data, notification_msg)
|
||
notification_sent = True
|
||
case 'telegram':
|
||
await self._send_telegram_notification(config_data, notification_msg)
|
||
notification_sent = True
|
||
case _:
|
||
logger.warning(f"不支持的通知渠道类型: {channel_type}")
|
||
|
||
except Exception as notify_error:
|
||
logger.error(f"发送Token刷新通知失败 ({notification.get('channel_name', 'Unknown')}): {self._safe_str(notify_error)}")
|
||
|
||
# 如果成功发送了通知,更新最后发送时间
|
||
if notification_sent:
|
||
self.last_notification_time[notification_type] = current_time
|
||
|
||
# 根据错误消息内容使用不同的冷却时间
|
||
if self._is_token_related_error(error_message):
|
||
next_send_time = current_time + self.token_refresh_notification_cooldown
|
||
cooldown_desc = "3小时"
|
||
else:
|
||
next_send_time = current_time + self.notification_cooldown
|
||
cooldown_desc = f"{self.notification_cooldown // 60}分钟"
|
||
|
||
next_send_time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_send_time))
|
||
logger.info(f"Token刷新通知已发送,下次可发送时间: {next_send_time_str} (冷却时间: {cooldown_desc})")
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理Token刷新通知失败: {self._safe_str(e)}")
|
||
|
||
def _is_normal_token_expiry(self, error_message: str) -> bool:
|
||
"""检查是否是正常的令牌过期或其他不需要通知的情况"""
|
||
# 不需要发送通知的关键词
|
||
no_notification_keywords = [
|
||
# 正常的令牌过期
|
||
'FAIL_SYS_TOKEN_EXOIRED::令牌过期',
|
||
'FAIL_SYS_TOKEN_EXPIRED::令牌过期',
|
||
'FAIL_SYS_TOKEN_EXOIRED',
|
||
'FAIL_SYS_TOKEN_EXPIRED',
|
||
'令牌过期',
|
||
# Session过期(正常情况)
|
||
'FAIL_SYS_SESSION_EXPIRED::Session过期',
|
||
'FAIL_SYS_SESSION_EXPIRED',
|
||
'Session过期',
|
||
# Token定时刷新失败(会自动重试)
|
||
'Token定时刷新失败,将自动重试',
|
||
'Token定时刷新失败'
|
||
]
|
||
|
||
# 检查错误消息是否包含不需要通知的关键词
|
||
for keyword in no_notification_keywords:
|
||
if keyword in error_message:
|
||
return True
|
||
|
||
return False
|
||
|
||
def _is_token_related_error(self, error_message: str) -> bool:
|
||
"""检查是否是Token相关的错误,需要使用3小时冷却时间"""
|
||
# Token相关错误的关键词
|
||
token_error_keywords = [
|
||
# Token刷新失败相关
|
||
'Token刷新失败',
|
||
'Token刷新异常',
|
||
'token刷新失败',
|
||
'token刷新异常',
|
||
'TOKEN刷新失败',
|
||
'TOKEN刷新异常',
|
||
# 具体的Token错误信息
|
||
'FAIL_SYS_USER_VALIDATE',
|
||
'RGV587_ERROR',
|
||
'哎哟喂,被挤爆啦',
|
||
'请稍后重试',
|
||
'punish?x5secdata',
|
||
'captcha',
|
||
# Token获取失败
|
||
'无法获取有效token',
|
||
'无法获取有效Token',
|
||
'Token获取失败',
|
||
'token获取失败',
|
||
'TOKEN获取失败',
|
||
# Token定时刷新失败
|
||
'Token定时刷新失败',
|
||
'token定时刷新失败',
|
||
'TOKEN定时刷新失败',
|
||
# 初始化Token失败
|
||
'初始化时无法获取有效Token',
|
||
'初始化时无法获取有效token',
|
||
# 其他Token相关错误
|
||
'accessToken',
|
||
'access_token',
|
||
'_m_h5_tk',
|
||
'mtop.taobao.idlemessage.pc.login.token'
|
||
]
|
||
|
||
# 检查错误消息是否包含Token相关的关键词
|
||
error_message_lower = error_message.lower()
|
||
for keyword in token_error_keywords:
|
||
if keyword.lower() in error_message_lower:
|
||
return True
|
||
|
||
return False
|
||
|
||
async def send_delivery_failure_notification(self, send_user_name: str, send_user_id: str, item_id: str, error_message: str, chat_id: str = None):
|
||
"""发送自动发货失败通知"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
# 获取当前账号的通知配置
|
||
notifications = db_manager.get_account_notifications(self.cookie_id)
|
||
|
||
if not notifications:
|
||
logger.warning("未配置消息通知,跳过自动发货通知")
|
||
return
|
||
|
||
# 构造通知消息
|
||
notification_message = f"🚨 自动发货通知\n\n" \
|
||
f"账号: {self.cookie_id}\n" \
|
||
f"买家: {send_user_name} (ID: {send_user_id})\n" \
|
||
f"商品ID: {item_id}\n" \
|
||
f"聊天ID: {chat_id or '未知'}\n" \
|
||
f"结果: {error_message}\n" \
|
||
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n" \
|
||
f"请及时处理!"
|
||
|
||
# 发送通知到所有已启用的通知渠道
|
||
for notification in notifications:
|
||
if notification.get('enabled', False):
|
||
channel_type = notification.get('channel_type', 'qq')
|
||
channel_config = notification.get('channel_config', '')
|
||
|
||
try:
|
||
# 解析配置数据
|
||
config_data = self._parse_notification_config(channel_config)
|
||
|
||
match channel_type:
|
||
case 'ding_talk' | 'dingtalk':
|
||
await self._send_dingtalk_notification(config_data, notification_message)
|
||
logger.info(f"已发送自动发货通知到钉钉")
|
||
case 'email':
|
||
await self._send_email_notification(config_data, notification_message)
|
||
logger.info(f"已发送自动发货通知到邮箱")
|
||
case 'webhook':
|
||
await self._send_webhook_notification(config_data, notification_message)
|
||
logger.info(f"已发送自动发货通知到Webhook")
|
||
case 'wechat':
|
||
await self._send_wechat_notification(config_data, notification_message)
|
||
logger.info(f"已发送自动发货通知到微信")
|
||
case 'telegram':
|
||
await self._send_telegram_notification(config_data, notification_message)
|
||
logger.info(f"已发送自动发货通知到Telegram")
|
||
case 'bark':
|
||
await self._send_bark_notification(config_data, notification_message)
|
||
logger.info(f"已发送自动发货通知到Bark")
|
||
case 'feishu' | 'lark':
|
||
await self._send_feishu_notification(config_data, notification_message)
|
||
logger.info(f"已发送自动发货通知到飞书")
|
||
case _:
|
||
logger.warning(f"不支持的通知渠道类型: {channel_type}")
|
||
|
||
except Exception as notify_error:
|
||
logger.error(f"发送自动发货通知失败: {self._safe_str(notify_error)}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"发送自动发货通知异常: {self._safe_str(e)}")
|
||
|
||
async def auto_confirm(self, order_id, item_id=None, retry_count=0):
|
||
"""自动确认发货 - 使用加密模块,不包含延时处理(延时已在_auto_delivery中处理)"""
|
||
temp_session = None
|
||
try:
|
||
logger.warning(f"【{self.cookie_id}】开始确认发货,订单ID: {order_id}")
|
||
|
||
# 导入解密后的确认发货模块
|
||
from app.secure_confirm import SecureConfirm
|
||
|
||
# self.session 是账号监听循环里创建的,绑定着那个事件循环。
|
||
# 后台「完整发货」由 FastAPI 的循环发起,跨循环复用会抛
|
||
# "Timeout context manager should be used inside a task",
|
||
# 表现为卡券已发出但闲鱼订单状态没变。
|
||
# 因此这里检测循环归属,不一致时用当前循环临时建一个 session。
|
||
session = self.session
|
||
running_loop = asyncio.get_running_loop()
|
||
session_loop = getattr(self.session, '_loop', None) if self.session else None
|
||
if self.session is None or (session_loop is not None and session_loop is not running_loop):
|
||
headers = DEFAULT_HEADERS.copy()
|
||
headers['cookie'] = self.cookies_str
|
||
temp_session = aiohttp.ClientSession(
|
||
headers=headers,
|
||
timeout=aiohttp.ClientTimeout(total=30),
|
||
)
|
||
session = temp_session
|
||
logger.warning(
|
||
f"【{self.cookie_id}】确认发货跨事件循环,已改用临时 session"
|
||
)
|
||
|
||
# 创建确认实例,传入主界面类实例
|
||
secure_confirm = SecureConfirm(session, self.cookies_str, self.cookie_id, self)
|
||
|
||
# 传递必要的属性
|
||
secure_confirm.current_token = self.current_token
|
||
secure_confirm.last_token_refresh_time = self.last_token_refresh_time
|
||
secure_confirm.token_refresh_interval = self.token_refresh_interval
|
||
|
||
# 调用确认方法,传入item_id用于token刷新
|
||
result = await secure_confirm.auto_confirm(order_id, item_id, retry_count)
|
||
|
||
# 同步更新后的cookies和token
|
||
if secure_confirm.cookies_str != self.cookies_str:
|
||
self.cookies_str = secure_confirm.cookies_str
|
||
self.cookies = secure_confirm.cookies
|
||
logger.warning(f"【{self.cookie_id}】已同步确认发货模块更新的cookies")
|
||
|
||
if secure_confirm.current_token != self.current_token:
|
||
self.current_token = secure_confirm.current_token
|
||
self.last_token_refresh_time = secure_confirm.last_token_refresh_time
|
||
logger.warning(f"【{self.cookie_id}】已同步确认发货模块更新的token")
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】加密确认模块调用失败: {self._safe_str(e)}")
|
||
return {"error": f"加密确认模块调用失败: {self._safe_str(e)}", "order_id": order_id}
|
||
finally:
|
||
if temp_session is not None:
|
||
try:
|
||
await temp_session.close()
|
||
except Exception:
|
||
pass
|
||
|
||
async def auto_freeshipping(self, order_id, item_id, buyer_id, retry_count=0):
|
||
"""自动免拼发货 - 使用解密模块"""
|
||
try:
|
||
logger.warning(f"【{self.cookie_id}】开始免拼发货,订单ID: {order_id}")
|
||
|
||
# 导入解密后的免拼发货模块
|
||
from app.secure_freeshipping import SecureFreeshipping
|
||
|
||
# 创建免拼发货实例
|
||
secure_freeshipping = SecureFreeshipping(self.session, self.cookies_str, self.cookie_id)
|
||
|
||
# 传递必要的属性
|
||
secure_freeshipping.current_token = self.current_token
|
||
secure_freeshipping.last_token_refresh_time = self.last_token_refresh_time
|
||
secure_freeshipping.token_refresh_interval = self.token_refresh_interval
|
||
|
||
# 调用免拼发货方法
|
||
return await secure_freeshipping.auto_freeshipping(order_id, item_id, buyer_id, retry_count)
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】免拼发货模块调用失败: {self._safe_str(e)}")
|
||
return {"error": f"免拼发货模块调用失败: {self._safe_str(e)}", "order_id": order_id}
|
||
|
||
async def fetch_order_detail_info(self, order_id: str, item_id: str = None, buyer_id: str = None, debug_headless: bool = None):
|
||
"""获取订单详情信息(使用独立的锁机制,不受延迟锁影响)"""
|
||
# 使用独立的订单详情锁,不与自动发货锁冲突
|
||
order_detail_lock = self._order_detail_locks[order_id]
|
||
|
||
# 记录订单详情锁的使用时间
|
||
self._order_detail_lock_times[order_id] = time.time()
|
||
|
||
async with order_detail_lock:
|
||
logger.info(f"🔍 【{self.cookie_id}】获取订单详情锁 {order_id},开始处理...")
|
||
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】开始获取订单详情: {order_id}")
|
||
|
||
# 导入订单详情获取器
|
||
from utils.order_detail_fetcher import fetch_order_detail_simple
|
||
from app.db_manager import db_manager
|
||
|
||
# 获取当前账号的cookie字符串
|
||
cookie_string = self.cookies_str
|
||
logger.warning(f"【{self.cookie_id}】使用Cookie长度: {len(cookie_string) if cookie_string else 0}")
|
||
|
||
# 优先走卖家端接口直连:一次两个 HTTP 请求即可拿到成交额、规格和
|
||
# 收货信息,无需启动浏览器。缺规格时说明接口没覆盖,再回退抓页面。
|
||
result = None
|
||
try:
|
||
from utils.seller_order_sync import fetch_order_detail_direct
|
||
|
||
direct = await fetch_order_detail_direct(
|
||
self.cookie_id, cookie_string, order_id
|
||
)
|
||
if direct and direct.get('spec_value'):
|
||
result = direct
|
||
logger.info(f"【{self.cookie_id}】订单详情已通过卖家端接口获取: {order_id}")
|
||
elif direct:
|
||
logger.info(
|
||
f"【{self.cookie_id}】卖家端接口未返回规格,回退浏览器抓取: {order_id}"
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】卖家端接口获取订单详情失败,回退浏览器: "
|
||
f"{self._safe_str(exc)}"
|
||
)
|
||
|
||
if not result:
|
||
# 确定是否使用有头模式(调试用)
|
||
headless_mode = True if debug_headless is None else debug_headless
|
||
if not headless_mode:
|
||
logger.info(f"【{self.cookie_id}】🖥️ 启用有头模式进行调试")
|
||
|
||
# 异步获取订单详情(使用当前账号的cookie)
|
||
result = await fetch_order_detail_simple(order_id, cookie_string, headless=headless_mode)
|
||
|
||
if result:
|
||
logger.info(f"【{self.cookie_id}】订单详情获取成功: {order_id}")
|
||
logger.info(f"【{self.cookie_id}】页面标题: {result.get('title', '未知')}")
|
||
|
||
# 获取解析后的规格信息
|
||
spec_name = result.get('spec_name', '')
|
||
spec_value = result.get('spec_value', '')
|
||
quantity = result.get('quantity', '')
|
||
amount = result.get('amount', '')
|
||
|
||
# 获取订单时间和收货人信息
|
||
order_time = result.get('order_time', None)
|
||
receiver_name = result.get('receiver_name', None)
|
||
receiver_phone = result.get('receiver_phone', None)
|
||
receiver_address = result.get('receiver_address', None)
|
||
|
||
if spec_name and spec_value:
|
||
logger.info(f"【{self.cookie_id}】📋 规格名称: {spec_name}")
|
||
logger.info(f"【{self.cookie_id}】📝 规格值: {spec_value}")
|
||
print(f"🛍️ 【{self.cookie_id}】订单 {order_id} 规格信息: {spec_name} -> {spec_value}")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】未获取到有效的规格信息")
|
||
print(f"⚠️ 【{self.cookie_id}】订单 {order_id} 规格信息获取失败")
|
||
|
||
# 记录订单时间和收货人信息
|
||
if order_time:
|
||
logger.info(f"【{self.cookie_id}】⏰ 订单时间: {order_time}")
|
||
if receiver_name:
|
||
logger.info(f"【{self.cookie_id}】👤 收货人: {receiver_name}")
|
||
if receiver_phone:
|
||
logger.info(f"【{self.cookie_id}】📱 手机号: {receiver_phone}")
|
||
if receiver_address:
|
||
logger.info(f"【{self.cookie_id}】📍 收货地址: {receiver_address}")
|
||
|
||
# 插入或更新订单信息到数据库
|
||
try:
|
||
# 检查cookie_id是否在cookies表中存在
|
||
cookie_info = db_manager.get_cookie_by_id(self.cookie_id)
|
||
if not cookie_info:
|
||
logger.warning(f"Cookie ID {self.cookie_id} 不存在于cookies表中,丢弃订单 {order_id}")
|
||
else:
|
||
# 先保存订单基本信息(包含时间和收货人信息)
|
||
success = db_manager.insert_or_update_order(
|
||
order_id=order_id,
|
||
item_id=item_id,
|
||
buyer_id=buyer_id,
|
||
spec_name=spec_name,
|
||
spec_value=spec_value,
|
||
quantity=quantity,
|
||
amount=amount,
|
||
order_status=result.get('order_status'), # 添加订单状态
|
||
cookie_id=self.cookie_id,
|
||
created_at=order_time,
|
||
receiver_name=receiver_name,
|
||
receiver_phone=receiver_phone,
|
||
receiver_address=receiver_address,
|
||
# 卖家端接口返回的成交明细,浏览器抓取时这些字段为空
|
||
buy_num=result.get('buy_num'),
|
||
auction_price=result.get('auction_price') or None,
|
||
confirm_fee=result.get('confirm_fee') or None,
|
||
refund_fee=result.get('refund_fee') or None,
|
||
post_fee=result.get('post_fee') or None
|
||
)
|
||
|
||
# 使用订单状态处理器设置状态
|
||
logger.info(f"【{self.cookie_id}】检查订单状态处理器调用条件: success={success}, handler_exists={self.order_status_handler is not None}")
|
||
if success and self.order_status_handler:
|
||
logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.handle_order_detail_fetched_status: {order_id}")
|
||
try:
|
||
handler_result = self.order_status_handler.handle_order_detail_fetched_status(
|
||
order_id=order_id,
|
||
cookie_id=self.cookie_id,
|
||
context="订单详情已拉取"
|
||
)
|
||
logger.info(f"【{self.cookie_id}】订单状态处理器.handle_order_detail_fetched_status返回结果: {handler_result}")
|
||
|
||
# 处理待处理队列
|
||
logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.on_order_details_fetched: {order_id}")
|
||
self.order_status_handler.on_order_details_fetched(order_id)
|
||
logger.info(f"【{self.cookie_id}】订单状态处理器.on_order_details_fetched调用成功: {order_id}")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】订单状态处理器调用失败: {self._safe_str(e)}")
|
||
import traceback
|
||
logger.error(f"【{self.cookie_id}】详细错误信息: {traceback.format_exc()}")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】订单状态处理器调用条件不满足: success={success}, handler_exists={self.order_status_handler is not None}")
|
||
|
||
if success:
|
||
logger.info(f"【{self.cookie_id}】订单信息已保存到数据库: {order_id}")
|
||
print(f"💾 【{self.cookie_id}】订单 {order_id} 信息已保存到数据库")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】订单信息保存失败: {order_id}")
|
||
|
||
except Exception as db_e:
|
||
logger.error(f"【{self.cookie_id}】保存订单信息到数据库失败: {self._safe_str(db_e)}")
|
||
|
||
return result
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】订单详情获取失败: {order_id}")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】获取订单详情异常: {self._safe_str(e)}")
|
||
return None
|
||
|
||
async def _auto_delivery(
|
||
self,
|
||
item_id: str,
|
||
item_title: str = None,
|
||
order_id: str = None,
|
||
send_user_id: str = None,
|
||
*,
|
||
order_detail: dict = None,
|
||
delivery_context: dict = None,
|
||
requested_item_quantity: int = 1,
|
||
):
|
||
"""匹配并取得发货内容;批量库存会在首次调用时按整单需求原子扣减。"""
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
logger.info(f"开始自动发货检查: 商品ID={item_id}")
|
||
|
||
# 获取商品详细信息
|
||
item_info = None
|
||
search_text = item_title # 默认使用传入的标题
|
||
|
||
if item_id and item_id != "未知商品":
|
||
# 直接从数据库获取商品信息(发货时不再调用API)
|
||
try:
|
||
logger.info(f"从数据库获取商品信息: {item_id}")
|
||
db_item_info = db_manager.get_item_info(self.cookie_id, item_id)
|
||
if db_item_info:
|
||
# 拼接商品标题和详情作为搜索文本
|
||
item_title_db = db_item_info.get('item_title', '') or ''
|
||
item_detail_db = db_item_info.get('item_detail', '') or ''
|
||
|
||
# 如果数据库中没有详情,尝试自动获取
|
||
if not item_detail_db.strip():
|
||
from app.config import config
|
||
auto_fetch_config = config.get('ITEM_DETAIL', {}).get('auto_fetch', {})
|
||
|
||
if auto_fetch_config.get('enabled', True):
|
||
logger.info(f"数据库中商品详情为空,尝试自动获取: {item_id}")
|
||
try:
|
||
fetched_detail = await self.fetch_item_detail_from_api(item_id)
|
||
if fetched_detail:
|
||
# 保存获取到的详情
|
||
await self.save_item_detail_only(item_id, fetched_detail)
|
||
item_detail_db = fetched_detail
|
||
logger.info(f"成功获取并保存商品详情: {item_id}")
|
||
else:
|
||
logger.warning(f"未能获取到商品详情: {item_id}")
|
||
except Exception as api_e:
|
||
logger.warning(f"获取商品详情失败: {item_id}, 错误: {self._safe_str(api_e)}")
|
||
else:
|
||
logger.warning(f"自动获取商品详情功能已禁用,跳过: {item_id}")
|
||
|
||
# 组合搜索文本:商品标题 + 商品详情
|
||
search_parts = []
|
||
if item_title_db.strip():
|
||
search_parts.append(item_title_db.strip())
|
||
if item_detail_db.strip():
|
||
search_parts.append(item_detail_db.strip())
|
||
if item_id and item_id != "未知商品":
|
||
search_parts.append(str(item_id).strip())
|
||
|
||
if search_parts:
|
||
search_text = ' '.join(search_parts)
|
||
logger.info(f"使用数据库商品标题+详情+商品ID作为搜索文本: 标题='{item_title_db}', 详情长度={len(item_detail_db)}")
|
||
logger.warning(f"完整搜索文本: {search_text[:200]}...")
|
||
else:
|
||
logger.warning(f"数据库中商品标题和详情都为空: {item_id}")
|
||
search_text = item_title or item_id
|
||
else:
|
||
logger.warning(f"数据库中未找到商品信息: {item_id}")
|
||
search_text = item_title or item_id
|
||
|
||
except Exception as db_e:
|
||
logger.warning(f"从数据库获取商品信息失败: {self._safe_str(db_e)}")
|
||
search_text = item_title or item_id
|
||
|
||
if not search_text:
|
||
search_text = item_id or "未知商品"
|
||
|
||
logger.info(f"使用搜索文本匹配发货规则: {search_text[:100]}...")
|
||
|
||
spec_name = None
|
||
spec_value = None
|
||
spec_text = ""
|
||
spec_payload = {}
|
||
platform_sku_id = ""
|
||
rule = delivery_context.get("resolved_rule") if delivery_context else None
|
||
|
||
if rule:
|
||
spec_name = delivery_context.get("spec_name")
|
||
spec_value = delivery_context.get("spec_value")
|
||
spec_text = delivery_context.get("spec_text") or ""
|
||
spec_payload = delivery_context.get("spec_payload") or {}
|
||
platform_sku_id = delivery_context.get("platform_sku_id") or ""
|
||
else:
|
||
delivery_config = db_manager.get_item_delivery_config(
|
||
self.cookie_id,
|
||
item_id,
|
||
)
|
||
if delivery_config:
|
||
if delivery_config.get("is_multi_spec"):
|
||
logger.info(f"检测到新版多规格商品配置,解析订单规格: {order_id}")
|
||
try:
|
||
if order_detail is None and order_id:
|
||
order_detail = await self.fetch_order_detail_info(
|
||
order_id,
|
||
item_id,
|
||
send_user_id,
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f"获取订单规格信息失败: {self._safe_str(e)},"
|
||
"为避免错发已停止自动发货"
|
||
)
|
||
return None
|
||
|
||
if isinstance(order_detail, dict):
|
||
sku_info = order_detail.get("sku_info") or {}
|
||
spec_payload = (
|
||
order_detail.get("spec_payload")
|
||
or sku_info.get("spec_payload")
|
||
or {}
|
||
)
|
||
spec_text = (
|
||
order_detail.get("spec_text")
|
||
or sku_info.get("spec_text")
|
||
or combine_legacy_specification(
|
||
order_detail.get("spec_name"),
|
||
order_detail.get("spec_value"),
|
||
)
|
||
)
|
||
platform_sku_id = str(
|
||
order_detail.get("platform_sku_id")
|
||
or sku_info.get("platform_sku_id")
|
||
or ""
|
||
).strip()
|
||
spec_name = order_detail.get("spec_name") or sku_info.get("spec_name")
|
||
spec_value = order_detail.get("spec_value") or sku_info.get("spec_value")
|
||
|
||
rule = db_manager.resolve_item_delivery_binding(
|
||
self.cookie_id,
|
||
item_id,
|
||
spec_text=spec_text,
|
||
spec_payload=spec_payload,
|
||
platform_sku_id=platform_sku_id,
|
||
)
|
||
if not rule.get("matched"):
|
||
reason_messages = {
|
||
"config_disabled": "商品自动发货已暂停",
|
||
"missing_specification": "订单未获取到规格",
|
||
"unknown_specification": "订单规格未绑定库存",
|
||
"conflicting_specification": "规格配置冲突",
|
||
"variant_disabled": "对应商品规格已停用",
|
||
"binding_disabled": "对应规格的发货绑定已停用",
|
||
"card_disabled": "对应规格绑定的卡密已停用",
|
||
}
|
||
reason = rule.get("reason") or "unknown"
|
||
logger.warning(
|
||
f"❌ 新版商品发货配置未通过: 商品={item_id}, "
|
||
f"原因={reason_messages.get(reason, reason)}, "
|
||
f"规格={spec_text or spec_payload or '空'}。"
|
||
"不会回退到普通卡密。"
|
||
)
|
||
return None
|
||
logger.info(
|
||
f"✅ 精确匹配商品规格库存: "
|
||
f"{rule.get('variant_name')} -> {rule.get('card_name')}"
|
||
)
|
||
else:
|
||
# 仅未迁移的商品兼容旧发货规则;存在新版配置时禁止回退。
|
||
is_multi_spec = db_manager.get_item_multi_spec_status(
|
||
self.cookie_id,
|
||
item_id,
|
||
)
|
||
if is_multi_spec and order_id:
|
||
logger.info(f"检测到旧版多规格商品,获取订单规格信息: {order_id}")
|
||
try:
|
||
if order_detail is None:
|
||
order_detail = await self.fetch_order_detail_info(
|
||
order_id,
|
||
item_id,
|
||
send_user_id,
|
||
)
|
||
if order_detail and isinstance(order_detail, dict):
|
||
spec_name = order_detail.get("spec_name", "")
|
||
spec_value = order_detail.get("spec_value", "")
|
||
else:
|
||
logger.warning("旧版多规格订单详情获取失败,停止自动发货")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(
|
||
f"获取旧版订单规格失败: {self._safe_str(e)},停止自动发货"
|
||
)
|
||
return None
|
||
|
||
if is_multi_spec and not (spec_name and spec_value):
|
||
# 区分两种失败:页面没渲染出来(技术故障,可重试)
|
||
# 与订单确实没有规格(配置问题)。原来一律静默跳过,
|
||
# 加上通知没配置,卖家对发货失败完全无感知。
|
||
page_empty = bool(
|
||
isinstance(order_detail, dict) and order_detail.get('page_empty')
|
||
)
|
||
if page_empty:
|
||
reason = (
|
||
f"订单详情页未渲染(DOM 节点 "
|
||
f"{order_detail.get('dom_node_count')} 个),未能读取规格"
|
||
)
|
||
else:
|
||
reason = "订单详情中没有规格信息"
|
||
logger.error(f"❌ 旧版多规格商品无法发货:{reason},订单 {order_id}")
|
||
try:
|
||
await self.send_delivery_failure_notification(
|
||
send_user_name=str(send_user_id or '买家'),
|
||
send_user_id=str(send_user_id or ''),
|
||
item_id=str(item_id or ''),
|
||
error_message=f"订单 {order_id}:{reason}",
|
||
)
|
||
except Exception as notify_err:
|
||
logger.debug(f"发送发货失败通知出错: {self._safe_str(notify_err)}")
|
||
return None
|
||
|
||
delivery_rules = db_manager.get_delivery_rules_for_item(
|
||
search_text,
|
||
self.cookie_id,
|
||
item_id,
|
||
spec_name=spec_name if is_multi_spec else None,
|
||
spec_value=spec_value if is_multi_spec else None,
|
||
)
|
||
if not delivery_rules:
|
||
logger.warning(f"❌ 商品未找到匹配的发货规则,跳过自动发货: {item_id}")
|
||
return None
|
||
if len(delivery_rules) > 1:
|
||
rule_names = [
|
||
(
|
||
f"{candidate['card_name']}"
|
||
f"({candidate.get('spec_name', '')}:"
|
||
f"{candidate.get('spec_value', '')})"
|
||
)
|
||
if candidate.get("is_multi_spec")
|
||
else candidate["card_name"]
|
||
for candidate in delivery_rules
|
||
]
|
||
logger.warning(
|
||
f"❌ 匹配到多个旧版发货规则({len(delivery_rules)}个),"
|
||
f"无法确定使用哪个: {', '.join(rule_names)}"
|
||
)
|
||
return None
|
||
rule = delivery_rules[0]
|
||
logger.info(
|
||
f"✅ 唯一匹配旧版发货规则: "
|
||
f"{rule['keyword']} -> {rule['card_name']} ({rule['card_type']})"
|
||
)
|
||
|
||
if delivery_context is not None:
|
||
delivery_context.update({
|
||
"resolved_rule": rule,
|
||
"delivery_count": max(1, int(rule.get("delivery_count") or 1)),
|
||
"spec_name": spec_name,
|
||
"spec_value": spec_value,
|
||
"spec_text": spec_text,
|
||
"spec_payload": spec_payload,
|
||
"platform_sku_id": platform_sku_id,
|
||
})
|
||
|
||
# 保存商品信息到数据库(需要有商品标题才保存)
|
||
# 尝试获取商品标题
|
||
item_title_for_save = None
|
||
try:
|
||
from app.db_manager import db_manager
|
||
db_item_info = db_manager.get_item_info(self.cookie_id, item_id)
|
||
if db_item_info:
|
||
item_title_for_save = db_item_info.get('item_title', '').strip()
|
||
except:
|
||
pass
|
||
|
||
# 如果有商品标题,则保存商品信息
|
||
if item_title_for_save:
|
||
await self.save_item_info_to_db(item_id, search_text, item_title_for_save)
|
||
else:
|
||
logger.warning(f"跳过保存商品信息:缺少商品标题 - {item_id}")
|
||
|
||
# 详细的匹配结果日志
|
||
if rule.get('is_multi_spec'):
|
||
if spec_name and spec_value:
|
||
logger.info(f"🎯 精确匹配多规格发货规则: {rule['keyword']} -> {rule['card_name']} [{rule['spec_name']}:{rule['spec_value']}]")
|
||
logger.info(f"📋 订单规格: {spec_name}:{spec_value} ✅ 匹配卡券规格: {rule['spec_name']}:{rule['spec_value']}")
|
||
else:
|
||
logger.info(f"⚠️ 使用多规格发货规则但无订单规格信息: {rule['keyword']} -> {rule['card_name']} [{rule['spec_name']}:{rule['spec_value']}]")
|
||
else:
|
||
if spec_name and spec_value:
|
||
logger.info(f"🔄 兜底匹配普通发货规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
|
||
logger.info(f"📋 订单规格: {spec_name}:{spec_value} ➡️ 使用普通卡券兜底")
|
||
else:
|
||
logger.info(f"✅ 匹配普通发货规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
|
||
|
||
# 获取延时设置
|
||
delay_seconds = rule.get('card_delay_seconds', 0)
|
||
|
||
# 执行延时(不管是否确认发货,只要有延时设置就执行)
|
||
if delay_seconds and delay_seconds > 0:
|
||
logger.info(f"检测到发货延时设置: {delay_seconds}秒,开始延时...")
|
||
await asyncio.sleep(delay_seconds)
|
||
logger.info(f"延时完成")
|
||
|
||
# 检查是否存在订单ID,只有存在订单ID才处理发货内容
|
||
if order_id:
|
||
# 保存订单基本信息到数据库(如果还没有详细信息)
|
||
try:
|
||
from app.db_manager import db_manager
|
||
|
||
# 检查cookie_id是否在cookies表中存在
|
||
cookie_info = db_manager.get_cookie_by_id(self.cookie_id)
|
||
if not cookie_info:
|
||
logger.warning(f"Cookie ID {self.cookie_id} 不存在于cookies表中,丢弃订单 {order_id}")
|
||
else:
|
||
existing_order = db_manager.get_order_by_id(order_id)
|
||
if not existing_order:
|
||
# 插入基本订单信息
|
||
success = db_manager.insert_or_update_order(
|
||
order_id=order_id,
|
||
item_id=item_id,
|
||
buyer_id=send_user_id,
|
||
cookie_id=self.cookie_id
|
||
)
|
||
|
||
# 使用订单状态处理器设置状态
|
||
if success and self.order_status_handler:
|
||
try:
|
||
self.order_status_handler.handle_order_basic_info_status(
|
||
order_id=order_id,
|
||
cookie_id=self.cookie_id,
|
||
context="自动发货-基本信息"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】订单状态处理器调用失败: {self._safe_str(e)}")
|
||
|
||
if success:
|
||
logger.info(f"保存基本订单信息到数据库: {order_id}")
|
||
except Exception as db_e:
|
||
logger.error(f"保存基本订单信息失败: {self._safe_str(db_e)}")
|
||
|
||
# 开始处理发货内容
|
||
logger.info(f"开始处理发货内容,规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
|
||
|
||
delivery_content = None
|
||
|
||
# 根据卡券类型处理发货内容
|
||
if rule['card_type'] == 'api':
|
||
# API类型:调用API获取内容,传入订单和商品信息用于动态参数替换
|
||
delivery_content = await self._get_api_card_content(rule, order_id, item_id, send_user_id, spec_name, spec_value)
|
||
|
||
elif rule['card_type'] == 'text':
|
||
# 固定文字类型:直接使用文字内容
|
||
delivery_content = rule['text_content']
|
||
|
||
elif rule['card_type'] == 'data':
|
||
# 首次调用按“订单数量 * 每件发货数”一次性扣减,后续只读取内存队列。
|
||
if delivery_context is not None:
|
||
batch_queue = delivery_context.get("batch_data_queue")
|
||
if batch_queue is None:
|
||
try:
|
||
item_quantity = max(1, int(requested_item_quantity or 1))
|
||
except (TypeError, ValueError):
|
||
item_quantity = 1
|
||
delivery_count = max(
|
||
1,
|
||
int(delivery_context.get("delivery_count") or 1),
|
||
)
|
||
required_count = item_quantity * delivery_count
|
||
batch_queue = db_manager.consume_batch_data_batch(
|
||
rule['card_id'],
|
||
required_count,
|
||
)
|
||
if not batch_queue:
|
||
logger.warning(
|
||
f"批量库存不足,整单未扣减: 卡券ID={rule['card_id']}, "
|
||
f"需要={required_count}条"
|
||
)
|
||
return None
|
||
delivery_context["batch_data_queue"] = batch_queue
|
||
delivery_context["batch_data_required_count"] = required_count
|
||
|
||
if not batch_queue:
|
||
logger.error(
|
||
f"批量库存内存队列已耗尽: 卡券ID={rule['card_id']}"
|
||
)
|
||
return None
|
||
delivery_content = batch_queue.pop(0)
|
||
else:
|
||
delivery_content = db_manager.consume_batch_data(rule['card_id'])
|
||
|
||
elif rule['card_type'] == 'image':
|
||
# 图片类型:返回图片发送标记,包含卡券ID
|
||
image_url = rule.get('image_url')
|
||
if image_url:
|
||
delivery_content = f"__IMAGE_SEND__{rule['card_id']}|{image_url}"
|
||
logger.info(f"准备发送图片: {image_url} (卡券ID: {rule['card_id']})")
|
||
else:
|
||
logger.error(f"图片卡券缺少图片URL: 卡券ID={rule['card_id']}")
|
||
delivery_content = None
|
||
|
||
if delivery_content:
|
||
# 处理备注信息和变量替换
|
||
final_content = self._process_delivery_content_with_description(delivery_content, rule.get('card_description', ''))
|
||
|
||
# 增加对应规则或商品规格绑定的发货次数统计。
|
||
if rule.get("rule_kind") == "variant_binding":
|
||
db_manager.increment_variant_binding_delivery_times(
|
||
rule["variant_binding_id"]
|
||
)
|
||
else:
|
||
db_manager.increment_delivery_times(rule['id'])
|
||
logger.info(f"自动发货成功: 规则ID={rule['id']}, 内容长度={len(final_content)}")
|
||
return final_content
|
||
else:
|
||
logger.warning(f"获取发货内容失败: 规则ID={rule['id']}")
|
||
return None
|
||
else:
|
||
# 没有订单ID,记录日志但不处理发货内容
|
||
logger.info(f"⚠️ 未检测到订单ID,跳过发货内容处理。规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"自动发货失败: {self._safe_str(e)}")
|
||
return None
|
||
|
||
|
||
|
||
def _process_delivery_content_with_description(self, delivery_content: str, card_description: str) -> str:
|
||
"""处理发货内容和备注信息,实现变量替换"""
|
||
try:
|
||
# 如果是图片发送标记,不进行备注处理,直接返回
|
||
if delivery_content.startswith("__IMAGE_SEND__"):
|
||
return delivery_content
|
||
|
||
# 如果没有备注信息,直接返回发货内容
|
||
if not card_description or not card_description.strip():
|
||
return delivery_content
|
||
|
||
# 替换备注中的变量
|
||
processed_description = card_description.replace('{DELIVERY_CONTENT}', delivery_content)
|
||
|
||
# 如果备注中包含变量替换,返回处理后的备注
|
||
if '{DELIVERY_CONTENT}' in card_description:
|
||
return processed_description
|
||
else:
|
||
# 如果备注中没有变量,将备注和发货内容组合
|
||
return f"{processed_description}\n\n{delivery_content}"
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理备注信息失败: {e}")
|
||
# 出错时返回原始发货内容
|
||
return delivery_content
|
||
|
||
async def _get_api_card_content(self, rule, order_id=None, item_id=None, buyer_id=None, spec_name=None, spec_value=None, retry_count=0):
|
||
"""调用API获取卡券内容,支持动态参数替换和重试机制"""
|
||
max_retries = 4
|
||
|
||
if retry_count >= max_retries:
|
||
logger.error(f"API调用失败,已达到最大重试次数({max_retries})")
|
||
return None
|
||
|
||
try:
|
||
import aiohttp
|
||
import json
|
||
|
||
api_config = rule.get('api_config')
|
||
if not api_config:
|
||
logger.error(f"API配置为空,规则ID: {rule.get('id')}, 卡券名称: {rule.get('card_name')}")
|
||
logger.warning(f"规则详情: {rule}")
|
||
return None
|
||
|
||
# 解析API配置
|
||
if isinstance(api_config, str):
|
||
api_config = json.loads(api_config)
|
||
|
||
url = api_config.get('url')
|
||
method = api_config.get('method', 'GET').upper()
|
||
timeout = api_config.get('timeout', 10)
|
||
headers = api_config.get('headers', '{}')
|
||
params = api_config.get('params', '{}')
|
||
|
||
# 解析headers和params
|
||
if isinstance(headers, str):
|
||
headers = json.loads(headers)
|
||
if isinstance(params, str):
|
||
params = json.loads(params)
|
||
|
||
# 如果是POST请求且有动态参数,进行参数替换
|
||
if method == 'POST' and params:
|
||
params = await self._replace_api_dynamic_params(params, order_id, item_id, buyer_id, spec_name, spec_value)
|
||
|
||
retry_info = f" (重试 {retry_count + 1}/{max_retries})" if retry_count > 0 else ""
|
||
logger.info(f"调用API获取卡券: {method} {url}{retry_info}")
|
||
if method == 'POST' and params:
|
||
logger.warning(f"POST请求参数: {json.dumps(params, ensure_ascii=False)}")
|
||
|
||
# 确保session存在
|
||
if not self.session:
|
||
await self.create_session()
|
||
|
||
# 发起HTTP请求
|
||
timeout_obj = aiohttp.ClientTimeout(total=timeout)
|
||
|
||
if method == 'GET':
|
||
async with self.session.get(url, headers=headers, params=params, timeout=timeout_obj) as response:
|
||
status_code = response.status
|
||
response_text = await response.text()
|
||
elif method == 'POST':
|
||
async with self.session.post(url, headers=headers, json=params, timeout=timeout_obj) as response:
|
||
status_code = response.status
|
||
response_text = await response.text()
|
||
else:
|
||
logger.error(f"不支持的HTTP方法: {method}")
|
||
return None
|
||
|
||
if status_code == 200:
|
||
# 尝试解析JSON响应,如果失败则使用原始文本
|
||
try:
|
||
result = json.loads(response_text)
|
||
# 如果返回的是对象,尝试提取常见的内容字段
|
||
if isinstance(result, dict):
|
||
content = result.get('data') or result.get('content') or result.get('card') or str(result)
|
||
else:
|
||
content = str(result)
|
||
except:
|
||
content = response_text
|
||
|
||
logger.info(f"API调用成功,返回内容长度: {len(content)}")
|
||
return content
|
||
else:
|
||
logger.warning(f"API调用失败: {status_code} - {response_text[:200]}...")
|
||
|
||
# 如果是服务器错误(5xx)或请求超时,进行重试
|
||
if status_code >= 500 or status_code == 408:
|
||
if retry_count < max_retries - 1:
|
||
wait_time = (retry_count + 1) * 2 # 递增等待时间: 2s, 4s, 6s
|
||
logger.info(f"等待 {wait_time} 秒后重试...")
|
||
await asyncio.sleep(wait_time)
|
||
return await self._get_api_card_content(rule, order_id, item_id, buyer_id, spec_name, spec_value, retry_count + 1)
|
||
|
||
return None
|
||
|
||
except (aiohttp.ClientTimeout, aiohttp.ClientError) as e:
|
||
logger.warning(f"API调用网络异常: {self._safe_str(e)}")
|
||
|
||
# 网络异常也进行重试
|
||
if retry_count < max_retries - 1:
|
||
wait_time = (retry_count + 1) * 2 # 递增等待时间
|
||
logger.info(f"等待 {wait_time} 秒后重试...")
|
||
await asyncio.sleep(wait_time)
|
||
return await self._get_api_card_content(rule, order_id, item_id, buyer_id, spec_name, spec_value, retry_count + 1)
|
||
else:
|
||
logger.error(f"API调用网络异常,已达到最大重试次数: {self._safe_str(e)}")
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"API调用异常: {self._safe_str(e)}")
|
||
return None
|
||
|
||
async def _replace_api_dynamic_params(self, params, order_id=None, item_id=None, buyer_id=None, spec_name=None, spec_value=None):
|
||
"""替换API请求参数中的动态参数"""
|
||
try:
|
||
if not params or not isinstance(params, dict):
|
||
return params
|
||
|
||
# 获取订单和商品信息
|
||
order_info = None
|
||
item_info = None
|
||
|
||
# 如果有订单ID,获取订单信息
|
||
if order_id:
|
||
try:
|
||
from app.db_manager import db_manager
|
||
# 尝试从数据库获取订单信息
|
||
order_info = db_manager.get_order_by_id(order_id)
|
||
if not order_info:
|
||
# 如果数据库中没有,尝试通过API获取
|
||
order_detail = await self.fetch_order_detail_info(order_id, item_id, buyer_id)
|
||
if order_detail:
|
||
order_info = order_detail
|
||
logger.warning(f"通过API获取到订单信息: {order_id}")
|
||
else:
|
||
logger.warning(f"无法获取订单信息: {order_id}")
|
||
else:
|
||
logger.warning(f"从数据库获取到订单信息: {order_id}")
|
||
except Exception as e:
|
||
logger.warning(f"获取订单信息失败: {self._safe_str(e)}")
|
||
|
||
# 如果有商品ID,获取商品信息
|
||
if item_id:
|
||
try:
|
||
from app.db_manager import db_manager
|
||
item_info = db_manager.get_item_info(self.cookie_id, item_id)
|
||
if item_info:
|
||
logger.warning(f"从数据库获取到商品信息: {item_id}")
|
||
else:
|
||
logger.warning(f"无法获取商品信息: {item_id}")
|
||
except Exception as e:
|
||
logger.warning(f"获取商品信息失败: {self._safe_str(e)}")
|
||
|
||
# 构建参数映射
|
||
param_mapping = {
|
||
'order_id': order_id or '',
|
||
'item_id': item_id or '',
|
||
'buyer_id': buyer_id or '',
|
||
'cookie_id': self.cookie_id or '',
|
||
'spec_name': spec_name or '',
|
||
'spec_value': spec_value or '',
|
||
}
|
||
|
||
# 从订单信息中提取参数
|
||
if order_info:
|
||
param_mapping.update({
|
||
'order_amount': str(order_info.get('amount', '')),
|
||
'order_quantity': str(order_info.get('quantity', '')),
|
||
})
|
||
|
||
# 从商品信息中提取参数
|
||
if item_info:
|
||
# 处理商品详情,如果是JSON字符串则提取detail字段
|
||
item_detail = item_info.get('item_detail', '')
|
||
if item_detail:
|
||
try:
|
||
# 尝试解析JSON
|
||
import json
|
||
detail_data = json.loads(item_detail)
|
||
if isinstance(detail_data, dict) and 'detail' in detail_data:
|
||
item_detail = detail_data['detail']
|
||
except (json.JSONDecodeError, TypeError):
|
||
# 如果不是JSON或解析失败,使用原始字符串
|
||
pass
|
||
|
||
param_mapping.update({
|
||
'item_detail': item_detail,
|
||
})
|
||
|
||
# 递归替换参数
|
||
replaced_params = self._recursive_replace_params(params, param_mapping)
|
||
|
||
# 记录替换的参数
|
||
replaced_keys = []
|
||
for key, value in replaced_params.items():
|
||
if isinstance(value, str) and '{' in str(params.get(key, '')):
|
||
replaced_keys.append(key)
|
||
|
||
if replaced_keys:
|
||
logger.info(f"API动态参数替换完成,替换的参数: {replaced_keys}")
|
||
logger.warning(f"参数映射: {param_mapping}")
|
||
|
||
return replaced_params
|
||
|
||
except Exception as e:
|
||
logger.error(f"替换API动态参数失败: {self._safe_str(e)}")
|
||
return params
|
||
|
||
def _recursive_replace_params(self, obj, param_mapping):
|
||
"""递归替换参数中的占位符"""
|
||
if isinstance(obj, dict):
|
||
result = {}
|
||
for key, value in obj.items():
|
||
result[key] = self._recursive_replace_params(value, param_mapping)
|
||
return result
|
||
elif isinstance(obj, list):
|
||
return [self._recursive_replace_params(item, param_mapping) for item in obj]
|
||
elif isinstance(obj, str):
|
||
# 替换字符串中的占位符
|
||
result = obj
|
||
for param_key, param_value in param_mapping.items():
|
||
placeholder = f"{{{param_key}}}"
|
||
if placeholder in result:
|
||
result = result.replace(placeholder, str(param_value))
|
||
return result
|
||
else:
|
||
return obj
|
||
|
||
async def token_refresh_loop(self):
|
||
"""Token刷新循环"""
|
||
try:
|
||
while True:
|
||
try:
|
||
# 检查账号是否启用
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止Token刷新循环")
|
||
break
|
||
|
||
current_time = time.time()
|
||
if current_time - self.last_token_refresh_time >= self.token_refresh_interval:
|
||
logger.info("Token即将过期,准备刷新...")
|
||
new_token = await self.refresh_token()
|
||
if new_token:
|
||
logger.info(f"【{self.cookie_id}】Token刷新成功,将关闭WebSocket以使用新Token重连")
|
||
|
||
# Token刷新成功后,需要关闭WebSocket连接,让它用新Token重新连接
|
||
# 原因:WebSocket连接建立时使用的是旧Token,新Token需要重新建立连接才能生效
|
||
# 注意:只关闭WebSocket,不重启整个实例(后台任务继续运行)
|
||
|
||
# 关闭当前WebSocket连接
|
||
if self.ws and not self.ws.closed:
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】关闭当前WebSocket连接以使用新Token重连...")
|
||
await self.ws.close()
|
||
logger.info(f"【{self.cookie_id}】WebSocket连接已关闭,将自动重连")
|
||
except Exception as close_e:
|
||
logger.warning(f"【{self.cookie_id}】关闭WebSocket时出错: {self._safe_str(close_e)}")
|
||
|
||
# 退出Token刷新循环,让main循环重新建立连接
|
||
# 后台任务(心跳、清理等)继续运行
|
||
logger.info(f"【{self.cookie_id}】Token刷新完成,WebSocket将使用新Token重新连接")
|
||
break
|
||
else:
|
||
# 根据上一次刷新状态决定日志级别(冷却/已重启为正常情况)
|
||
if getattr(self, 'last_token_refresh_status', None) in ("skipped_cooldown", "restarted_after_cookie_refresh"):
|
||
logger.info(f"【{self.cookie_id}】Token刷新未执行或已重启(正常),将在{self.token_retry_interval // 60}分钟后重试")
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】Token刷新失败,将在{self.token_retry_interval // 60}分钟后重试")
|
||
|
||
# 清空当前token,确保下次重试时重新获取
|
||
self.current_token = None
|
||
|
||
# 发送Token刷新失败通知
|
||
await self.send_token_refresh_notification("Token定时刷新失败,将自动重试", "token_scheduled_refresh_failed")
|
||
await self._interruptible_sleep(self.token_retry_interval)
|
||
continue
|
||
await self._interruptible_sleep(60)
|
||
except asyncio.CancelledError:
|
||
# 收到取消信号,立即退出循环
|
||
logger.info(f"【{self.cookie_id}】Token刷新循环收到取消信号,准备退出")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Token刷新循环出错: {self._safe_str(e)}")
|
||
# 出错后也等待1分钟再重试,使用可中断的sleep
|
||
try:
|
||
await self._interruptible_sleep(60)
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】Token刷新循环在重试等待时收到取消信号,准备退出")
|
||
raise
|
||
except asyncio.CancelledError:
|
||
# 确保CancelledError被正确传播
|
||
logger.info(f"【{self.cookie_id}】Token刷新循环已取消,正在退出...")
|
||
raise
|
||
finally:
|
||
# 确保任务能正常结束
|
||
logger.info(f"【{self.cookie_id}】Token刷新循环已退出")
|
||
|
||
async def create_chat(self, ws, toid, item_id='891198795482'):
|
||
msg = {
|
||
"lwp": "/r/SingleChatConversation/create",
|
||
"headers": {
|
||
"mid": generate_mid()
|
||
},
|
||
"body": [
|
||
{
|
||
"pairFirst": f"{toid}@goofish",
|
||
"pairSecond": f"{self.myid}@goofish",
|
||
"bizType": "1",
|
||
"extension": {
|
||
"itemId": item_id
|
||
},
|
||
"ctx": {
|
||
"appVersion": "1.0",
|
||
"platform": "web"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
await ws.send(json.dumps(msg))
|
||
|
||
async def send_msg(self, ws, cid, toid, text):
|
||
text = {
|
||
"contentType": 1,
|
||
"text": {
|
||
"text": text
|
||
}
|
||
}
|
||
text_base64 = str(base64.b64encode(json.dumps(text).encode('utf-8')), 'utf-8')
|
||
msg = {
|
||
"lwp": "/r/MessageSend/sendByReceiverScope",
|
||
"headers": {
|
||
"mid": generate_mid()
|
||
},
|
||
"body": [
|
||
{
|
||
"uuid": generate_uuid(),
|
||
"cid": f"{cid}@goofish",
|
||
"conversationType": 1,
|
||
"content": {
|
||
"contentType": 101,
|
||
"custom": {
|
||
"type": 1,
|
||
"data": text_base64
|
||
}
|
||
},
|
||
"redPointPolicy": 0,
|
||
"extension": {
|
||
"extJson": "{}"
|
||
},
|
||
"ctx": {
|
||
"appVersion": "1.0",
|
||
"platform": "web"
|
||
},
|
||
"mtags": {},
|
||
"msgReadStatusSetting": 1
|
||
},
|
||
{
|
||
"actualReceivers": [
|
||
f"{toid}@goofish",
|
||
f"{self.myid}@goofish"
|
||
]
|
||
}
|
||
]
|
||
}
|
||
await ws.send(json.dumps(msg))
|
||
|
||
def _resolve_im_response(self, message_data):
|
||
"""Resolve a pending IM request when the server echoes its mid."""
|
||
if not isinstance(message_data, dict):
|
||
return False
|
||
headers = message_data.get("headers")
|
||
if not isinstance(headers, dict):
|
||
return False
|
||
mid = str(headers.get("mid") or "")
|
||
if not mid:
|
||
return False
|
||
future = self._im_pending.pop(mid, None)
|
||
if future is None or future.done():
|
||
return False
|
||
future.set_result(message_data)
|
||
return True
|
||
|
||
def _fail_pending_im_requests(self, reason):
|
||
pending = list(self._im_pending.values())
|
||
self._im_pending.clear()
|
||
for future in pending:
|
||
if not future.done():
|
||
future.set_exception(ConnectionError(reason))
|
||
|
||
async def _send_im_request(self, lwp, body, timeout=15):
|
||
websocket = self.ws
|
||
if websocket is None:
|
||
# 风控冷却期内连接建不起来,给出可操作的提示而不是笼统的"未连接"
|
||
from utils import risk_control
|
||
|
||
guard = risk_control.registry.get(self.cookie_id)
|
||
if guard.is_blocked:
|
||
minutes = max(1, guard.remaining_seconds // 60)
|
||
raise ConnectionError(
|
||
f"闲鱼要求人机验证,账号暂停请求中(约 {minutes} 分钟后自动重试)"
|
||
)
|
||
raise ConnectionError("账号尚未连接闲鱼消息服务")
|
||
|
||
closed = getattr(websocket, "closed", False)
|
||
if closed:
|
||
raise ConnectionError("账号闲鱼消息连接已断开")
|
||
|
||
mid = generate_mid()
|
||
future = asyncio.get_running_loop().create_future()
|
||
async with self._im_request_lock:
|
||
self._im_pending[mid] = future
|
||
try:
|
||
await websocket.send(json.dumps({
|
||
"lwp": lwp,
|
||
"headers": {"mid": mid},
|
||
"body": body,
|
||
}))
|
||
except Exception:
|
||
self._im_pending.pop(mid, None)
|
||
raise
|
||
|
||
try:
|
||
return await asyncio.wait_for(future, timeout=timeout)
|
||
except asyncio.TimeoutError as exc:
|
||
self._im_pending.pop(mid, None)
|
||
raise TimeoutError(f"闲鱼消息服务响应超时: {lwp}") from exc
|
||
finally:
|
||
self._im_pending.pop(mid, None)
|
||
|
||
async def get_im_conversations(self, start_timestamp=None, limit=20):
|
||
if start_timestamp is None:
|
||
start_timestamp = 9007199254740991
|
||
response = await self._send_im_request(
|
||
"/r/Conversation/listNewestPagination",
|
||
[int(start_timestamp), max(1, min(int(limit), 100))],
|
||
)
|
||
return response.get("body", {}) if isinstance(response, dict) else {}
|
||
|
||
async def get_im_messages(self, cid, start_timestamp=None, limit=20):
|
||
if start_timestamp is None:
|
||
start_timestamp = 9007199254740991
|
||
full_cid = cid if "@goofish" in cid else f"{cid}@goofish"
|
||
response = await self._send_im_request(
|
||
"/r/MessageManager/listUserMessages",
|
||
[full_cid, False, int(start_timestamp), max(1, min(int(limit), 100)), False],
|
||
)
|
||
return response.get("body", {}) if isinstance(response, dict) else {}
|
||
|
||
async def send_im_text(self, cid, toid, text):
|
||
text = str(text or "").strip()
|
||
if not text:
|
||
raise ValueError("消息内容不能为空")
|
||
if len(text) > 2000:
|
||
raise ValueError("消息内容不能超过 2000 个字符")
|
||
|
||
full_cid = cid if "@goofish" in cid else f"{cid}@goofish"
|
||
full_toid = toid if "@goofish" in toid else f"{toid}@goofish"
|
||
payload = {
|
||
"contentType": 1,
|
||
"text": {"text": text},
|
||
}
|
||
text_base64 = base64.b64encode(
|
||
json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
).decode("utf-8")
|
||
response = await self._send_im_request(
|
||
"/r/MessageSend/sendByReceiverScope",
|
||
[
|
||
{
|
||
"uuid": generate_uuid(),
|
||
"cid": full_cid,
|
||
"conversationType": 1,
|
||
"content": {
|
||
"contentType": 101,
|
||
"custom": {"type": 1, "data": text_base64},
|
||
},
|
||
"redPointPolicy": 0,
|
||
"extension": {"extJson": "{}"},
|
||
"ctx": {"appVersion": "1.0", "platform": "web"},
|
||
"mtags": {},
|
||
"msgReadStatusSetting": 1,
|
||
},
|
||
{
|
||
"actualReceivers": [
|
||
full_toid,
|
||
f"{self.myid}@goofish",
|
||
],
|
||
},
|
||
],
|
||
)
|
||
body = response.get("body", {}) if isinstance(response, dict) else {}
|
||
if isinstance(body, dict) and (body.get("reason") or body.get("code")):
|
||
reason = body.get("developerMessage") or body.get("reason") or body.get("code")
|
||
raise RuntimeError(str(reason))
|
||
return response
|
||
|
||
async def init(self, ws):
|
||
# 如果没有token或者token过期,获取新token
|
||
token_refresh_attempted = False
|
||
if not self.current_token or (time.time() - self.last_token_refresh_time) >= self.token_refresh_interval:
|
||
logger.info(f"【{self.cookie_id}】获取初始token...")
|
||
token_refresh_attempted = True
|
||
|
||
await self.refresh_token()
|
||
|
||
if not self.current_token:
|
||
logger.error("无法获取有效token,初始化失败")
|
||
# 只有在没有尝试刷新token的情况下才发送通知,避免与refresh_token中的通知重复
|
||
if not token_refresh_attempted:
|
||
await self.send_token_refresh_notification("初始化时无法获取有效Token", "token_init_failed")
|
||
else:
|
||
logger.info("由于刚刚尝试过token刷新,跳过重复的初始化失败通知")
|
||
raise Exception("Token获取失败")
|
||
|
||
msg = {
|
||
"lwp": "/reg",
|
||
"headers": {
|
||
"cache-header": "app-key token ua wv",
|
||
"app-key": APP_CONFIG.get('app_key'),
|
||
"token": self.current_token,
|
||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 DingTalk(2.1.5) OS(Windows/10) Browser(Chrome/133.0.0.0) DingWeb/2.1.5 IMPaaS DingWeb/2.1.5",
|
||
"dt": "j",
|
||
"wv": "im:3,au:3,sy:6",
|
||
"sync": "0,0;0;0;",
|
||
"did": self.device_id,
|
||
"mid": generate_mid()
|
||
}
|
||
}
|
||
await ws.send(json.dumps(msg))
|
||
await asyncio.sleep(1)
|
||
current_time = int(time.time() * 1000)
|
||
msg = {
|
||
"lwp": "/r/SyncStatus/ackDiff",
|
||
"headers": {"mid": generate_mid()},
|
||
"body": [
|
||
{
|
||
"pipeline": "sync",
|
||
"tooLong2Tag": "PNM,1",
|
||
"channel": "sync",
|
||
"topic": "sync",
|
||
"highPts": 0,
|
||
"pts": current_time * 1000,
|
||
"seq": 0,
|
||
"timestamp": current_time
|
||
}
|
||
]
|
||
}
|
||
await ws.send(json.dumps(msg))
|
||
logger.info(f'【{self.cookie_id}】连接注册完成')
|
||
|
||
async def send_heartbeat(self, ws):
|
||
"""发送心跳包"""
|
||
# 检查WebSocket连接状态,如果已关闭则不发送
|
||
if ws.closed:
|
||
raise ConnectionError("WebSocket连接已关闭,无法发送心跳")
|
||
|
||
msg = {
|
||
"lwp": "/!",
|
||
"headers": {
|
||
"mid": generate_mid()
|
||
}
|
||
}
|
||
# 添加超时保护,避免在WebSocket关闭时阻塞
|
||
try:
|
||
await asyncio.wait_for(ws.send(json.dumps(msg)), timeout=2.0)
|
||
self.last_heartbeat_time = time.time()
|
||
logger.warning(f"【{self.cookie_id}】心跳包已发送")
|
||
except asyncio.TimeoutError:
|
||
raise ConnectionError("心跳发送超时,WebSocket可能已断开")
|
||
except asyncio.CancelledError:
|
||
# 如果被取消,立即重新抛出,不执行后续操作
|
||
raise
|
||
|
||
async def heartbeat_loop(self, ws):
|
||
"""心跳循环"""
|
||
consecutive_failures = 0
|
||
max_failures = 3 # 连续失败3次后停止心跳
|
||
|
||
try:
|
||
while True:
|
||
try:
|
||
# 检查账号是否启用
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止心跳循环")
|
||
break
|
||
|
||
# 检查WebSocket连接状态
|
||
if ws.closed:
|
||
logger.warning(f"【{self.cookie_id}】WebSocket连接已关闭,停止心跳循环")
|
||
break
|
||
|
||
await self.send_heartbeat(ws)
|
||
consecutive_failures = 0 # 重置失败计数
|
||
|
||
await self._interruptible_sleep(self.heartbeat_interval)
|
||
|
||
except asyncio.CancelledError:
|
||
# 收到取消信号,立即退出循环
|
||
logger.info(f"【{self.cookie_id}】心跳循环收到取消信号,准备退出")
|
||
raise # 重新抛出,让任务正常结束
|
||
except Exception as e:
|
||
consecutive_failures += 1
|
||
logger.error(f"心跳发送失败 ({consecutive_failures}/{max_failures}): {self._safe_str(e)}")
|
||
|
||
if consecutive_failures >= max_failures:
|
||
logger.error(f"【{self.cookie_id}】心跳连续失败{max_failures}次,停止心跳循环")
|
||
break
|
||
|
||
# 失败后短暂等待再重试,使用可中断的sleep
|
||
try:
|
||
await self._interruptible_sleep(5)
|
||
except asyncio.CancelledError:
|
||
# 在等待重试时收到取消信号,立即退出
|
||
logger.info(f"【{self.cookie_id}】心跳循环在重试等待时收到取消信号,准备退出")
|
||
raise
|
||
except asyncio.CancelledError:
|
||
# 确保CancelledError被正确传播
|
||
logger.info(f"【{self.cookie_id}】心跳循环已取消,正在退出...")
|
||
raise
|
||
finally:
|
||
# 确保任务能正常结束
|
||
logger.info(f"【{self.cookie_id}】心跳循环已退出")
|
||
|
||
async def handle_heartbeat_response(self, message_data):
|
||
"""处理心跳响应"""
|
||
try:
|
||
if message_data.get("code") == 200:
|
||
self.last_heartbeat_response = time.time()
|
||
logger.warning("心跳响应正常")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"处理心跳响应出错: {self._safe_str(e)}")
|
||
return False
|
||
|
||
async def pause_cleanup_loop(self):
|
||
"""定期清理过期的暂停记录、锁和缓存"""
|
||
try:
|
||
while True:
|
||
try:
|
||
# 检查账号是否启用
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止清理循环")
|
||
break
|
||
|
||
# 清理过期的暂停记录
|
||
pause_manager.cleanup_expired_pauses()
|
||
await asyncio.sleep(0) # 让出控制权,允许检查取消信号
|
||
|
||
# 清理过期的锁(每5分钟清理一次,保留24小时内的锁)
|
||
self.cleanup_expired_locks(max_age_hours=24)
|
||
await asyncio.sleep(0) # 让出控制权,允许检查取消信号
|
||
|
||
# 清理过期的商品详情缓存
|
||
try:
|
||
cleaned_count = await self._cleanup_item_cache()
|
||
if cleaned_count > 0:
|
||
logger.info(f"【{self.cookie_id}】清理了 {cleaned_count} 个过期的商品详情缓存")
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as cache_clean_e:
|
||
logger.warning(f"【{self.cookie_id}】清理商品详情缓存时出错: {cache_clean_e}")
|
||
|
||
# 清理过期的通知、发货和订单确认记录(防止内存泄漏)
|
||
self._cleanup_instance_caches()
|
||
await asyncio.sleep(0) # 让出控制权,允许检查取消信号
|
||
|
||
# 清理QR登录过期会话(每5分钟检查一次)
|
||
try:
|
||
from utils.qr_login import qr_login_manager
|
||
qr_login_manager.cleanup_expired_sessions()
|
||
await asyncio.sleep(0) # 让出控制权,允许检查取消信号
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as qr_clean_e:
|
||
logger.warning(f"【{self.cookie_id}】清理QR登录会话时出错: {qr_clean_e}")
|
||
|
||
# 清理Playwright浏览器临时文件和缓存(每5分钟检查一次)
|
||
try:
|
||
await self._cleanup_playwright_cache()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as pw_clean_e:
|
||
logger.warning(f"【{self.cookie_id}】清理Playwright缓存时出错: {pw_clean_e}")
|
||
|
||
# 清理过期的日志文件(每5分钟检查一次,保留7天)
|
||
try:
|
||
cleaned_logs = await self._cleanup_old_logs(retention_days=7)
|
||
await asyncio.sleep(0) # 让出控制权,允许检查取消信号
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as log_clean_e:
|
||
logger.warning(f"【{self.cookie_id}】清理日志文件时出错: {log_clean_e}")
|
||
|
||
# 清理数据库历史数据(每天一次,保留90天数据)
|
||
# 为避免所有实例同时执行,只让第一个实例执行
|
||
try:
|
||
if hasattr(self.__class__, '_last_db_cleanup_time'):
|
||
last_cleanup = self.__class__._last_db_cleanup_time
|
||
else:
|
||
self.__class__._last_db_cleanup_time = 0
|
||
last_cleanup = 0
|
||
|
||
current_time = time.time()
|
||
# 每24小时清理一次
|
||
if current_time - last_cleanup > 86400:
|
||
logger.info(f"【{self.cookie_id}】开始执行数据库历史数据清理...")
|
||
# 数据库清理可能很耗时,使用线程池执行,避免阻塞事件循环
|
||
# 这样即使清理操作很慢,也能响应取消信号
|
||
try:
|
||
stats = await asyncio.to_thread(db_manager.cleanup_old_data, days=90)
|
||
if 'error' not in stats:
|
||
logger.info(f"【{self.cookie_id}】数据库清理完成: {stats}")
|
||
self.__class__._last_db_cleanup_time = current_time
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】数据库清理失败: {stats['error']}")
|
||
except asyncio.CancelledError:
|
||
logger.warning(f"【{self.cookie_id}】数据库清理被取消")
|
||
raise
|
||
except asyncio.CancelledError:
|
||
raise # 重新抛出取消信号
|
||
except Exception as db_clean_e:
|
||
logger.error(f"【{self.cookie_id}】清理数据库历史数据时出错: {db_clean_e}")
|
||
|
||
# 每5分钟清理一次
|
||
await self._interruptible_sleep(300)
|
||
except asyncio.CancelledError:
|
||
# 收到取消信号,立即退出循环
|
||
logger.info(f"【{self.cookie_id}】清理循环收到取消信号,准备退出")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】清理任务失败: {self._safe_str(e)}")
|
||
# 出错后也等待5分钟再重试,使用可中断的sleep
|
||
try:
|
||
await self._interruptible_sleep(300)
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】清理循环在重试等待时收到取消信号,准备退出")
|
||
raise
|
||
except asyncio.CancelledError:
|
||
# 确保CancelledError被正确传播
|
||
logger.info(f"【{self.cookie_id}】清理循环已取消,正在退出...")
|
||
raise
|
||
finally:
|
||
# 确保任务能正常结束
|
||
logger.info(f"【{self.cookie_id}】清理循环已退出")
|
||
|
||
|
||
async def item_sync_loop(self):
|
||
"""商品同步定时任务 - 按配置间隔定时同步商品信息
|
||
|
||
支持动态配置更新:每次循环时从数据库读取最新配置
|
||
"""
|
||
try:
|
||
while True:
|
||
try:
|
||
# 检查账号是否启用
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止商品同步循环")
|
||
break
|
||
|
||
# 从数据库读取最新配置(支持动态更新)
|
||
from app.db_manager import db_manager
|
||
item_sync_enabled_str = db_manager.get_system_setting('item_sync_enabled')
|
||
item_sync_interval_str = db_manager.get_system_setting('item_sync_interval')
|
||
item_sync_max_pages_str = db_manager.get_system_setting('item_sync_max_pages')
|
||
|
||
# 使用数据库配置,如果不存在则使用实例变量(从global_config.yml读取的默认值)
|
||
item_sync_enabled = item_sync_enabled_str == 'true' if item_sync_enabled_str is not None else self.item_sync_enabled
|
||
item_sync_interval = int(item_sync_interval_str) if item_sync_interval_str is not None else self.item_sync_interval
|
||
item_sync_max_pages = int(item_sync_max_pages_str) if item_sync_max_pages_str is not None else self.item_sync_max_pages
|
||
|
||
# 检查是否启用了商品同步功能
|
||
if not item_sync_enabled:
|
||
await self._interruptible_sleep(60) # 未启用时每分钟检查一次
|
||
continue
|
||
|
||
# 检查距离上次同步的时间
|
||
current_time = time.time()
|
||
if current_time - self.last_item_sync_time < item_sync_interval:
|
||
# 未到达同步时间,等待
|
||
wait_time = min(60, item_sync_interval - (current_time - self.last_item_sync_time))
|
||
await self._interruptible_sleep(wait_time)
|
||
continue
|
||
|
||
# 使用Lock防止重复执行
|
||
if self.item_sync_lock.locked():
|
||
logger.info(f"【{self.cookie_id}】商品同步任务正在进行中,跳过本次执行")
|
||
await self._interruptible_sleep(60)
|
||
continue
|
||
|
||
# 执行商品同步
|
||
async with self.item_sync_lock:
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】🔄 开始定时同步商品信息...")
|
||
result = await self.get_all_items(page_size=20, max_pages=item_sync_max_pages)
|
||
|
||
if result.get('success'):
|
||
total_count = result.get('total_count', 0)
|
||
saved_count = result.get('total_saved', 0)
|
||
self.last_item_sync_time = current_time
|
||
if result.get('confirmed_empty'):
|
||
account_id = result.get('account_id') or self.myid
|
||
group_name = result.get('group_name', '在售')
|
||
logger.info(
|
||
f"【{self.cookie_id}】商品同步完成: 闲鱼接口确认账号 "
|
||
f"{account_id} 的“{group_name}”分组当前为 0 件"
|
||
)
|
||
else:
|
||
logger.info(
|
||
f"【{self.cookie_id}】✅ 商品同步完成: 共 {total_count} 件商品,"
|
||
f"保存/更新 {saved_count} 件"
|
||
)
|
||
else:
|
||
error_msg = result.get('error', '未知错误')
|
||
logger.warning(f"【{self.cookie_id}】❌ 商品同步失败: {error_msg}")
|
||
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】商品同步被取消")
|
||
raise
|
||
except Exception as sync_error:
|
||
logger.error(f"【{self.cookie_id}】商品同步异常: {self._safe_str(sync_error)}")
|
||
|
||
# 等待下次同步时间
|
||
await self._interruptible_sleep(item_sync_interval)
|
||
|
||
except asyncio.CancelledError:
|
||
# 收到取消信号,立即退出循环
|
||
logger.info(f"【{self.cookie_id}】商品同步循环收到取消信号,准备退出")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】商品同步任务失败: {self._safe_str(e)}")
|
||
# 出错后等待1分钟再重试
|
||
try:
|
||
await self._interruptible_sleep(60)
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】商品同步循环在重试等待时收到取消信号,准备退出")
|
||
raise
|
||
except asyncio.CancelledError:
|
||
# 确保CancelledError被正确传播
|
||
logger.info(f"【{self.cookie_id}】商品同步循环已取消,正在退出...")
|
||
raise
|
||
finally:
|
||
# 确保任务能正常结束
|
||
logger.info(f"【{self.cookie_id}】商品同步循环已退出")
|
||
|
||
|
||
async def order_sync_loop(self):
|
||
"""订单同步定时任务。
|
||
|
||
消息驱动只能捕获监听在线期间的订单事件,离线时段产生的订单会完全丢失,
|
||
这里定期从卖家端接口拉全量做对账。间隔可在系统设置里调整。
|
||
"""
|
||
# 启动错峰:多账号多任务同时发请求容易触发平台风控
|
||
await self._interruptible_sleep(random.uniform(0, 120))
|
||
try:
|
||
while True:
|
||
try:
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止订单同步循环")
|
||
break
|
||
|
||
from app.db_manager import db_manager
|
||
enabled_str = db_manager.get_system_setting('order_sync_enabled')
|
||
interval_str = db_manager.get_system_setting('order_sync_interval')
|
||
|
||
# 默认开启,间隔 30 分钟
|
||
enabled = enabled_str != 'false'
|
||
try:
|
||
interval = int(interval_str) if interval_str else 7200
|
||
except (TypeError, ValueError):
|
||
interval = 7200
|
||
# 订单同步会翻多页,是请求量最大的任务,最短 30 分钟
|
||
interval = max(1800, interval)
|
||
|
||
if not enabled:
|
||
await self._interruptible_sleep(60)
|
||
continue
|
||
|
||
current_time = time.time()
|
||
if current_time - self.last_order_sync_time < interval:
|
||
wait_time = min(60, interval - (current_time - self.last_order_sync_time))
|
||
await self._interruptible_sleep(wait_time)
|
||
continue
|
||
|
||
if not self.cookies_str:
|
||
await self._interruptible_sleep(60)
|
||
continue
|
||
|
||
# 风控冷却期内不发任何主动请求
|
||
from utils import risk_control
|
||
guard = risk_control.registry.get(self.cookie_id)
|
||
if guard.is_blocked:
|
||
wait = min(guard.remaining_seconds + 5, 300)
|
||
logger.debug(
|
||
f"【{self.cookie_id}】风控冷却中,{wait} 秒后再检查"
|
||
)
|
||
await self._interruptible_sleep(wait)
|
||
continue
|
||
|
||
try:
|
||
from utils.seller_order_sync import sync_account_orders
|
||
|
||
result = await sync_account_orders(self.cookie_id, self.cookies_str)
|
||
self.last_order_sync_time = current_time
|
||
|
||
new_cookies = result.get('cookies_str')
|
||
if new_cookies and new_cookies != self.cookies_str:
|
||
self.cookies_str = new_cookies
|
||
|
||
logger.info(
|
||
f"【{self.cookie_id}】定时订单同步完成: 共 {result['total']} 单,"
|
||
f"成功 {result['saved']},失败 {result['failed']}"
|
||
)
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】订单同步被取消")
|
||
raise
|
||
except Exception as sync_error:
|
||
logger.error(
|
||
f"【{self.cookie_id}】订单同步异常: {self._safe_str(sync_error)}"
|
||
)
|
||
|
||
await self._interruptible_sleep(interval)
|
||
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】订单同步循环收到取消信号,准备退出")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】订单同步任务失败: {self._safe_str(e)}")
|
||
try:
|
||
await self._interruptible_sleep(60)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】订单同步循环已取消,正在退出...")
|
||
raise
|
||
finally:
|
||
logger.info(f"【{self.cookie_id}】订单同步循环已退出")
|
||
|
||
|
||
async def item_polish_loop(self):
|
||
"""商品擦亮定时任务。
|
||
|
||
擦亮会把商品重新推到搜索和推荐前列,是平台提供的免费曝光手段。
|
||
平台对每个商品每天的擦亮次数有限制,超出会返回业务错误但不影响流程。
|
||
默认关闭,需要在设置里开启。
|
||
"""
|
||
# 启动错峰:多账号多任务同时发请求容易触发平台风控
|
||
await self._interruptible_sleep(random.uniform(0, 180))
|
||
try:
|
||
while True:
|
||
try:
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止商品擦亮循环")
|
||
break
|
||
|
||
from app.db_manager import db_manager
|
||
enabled_str = db_manager.get_system_setting('auto_polish_enabled')
|
||
interval_str = db_manager.get_system_setting('auto_polish_interval')
|
||
|
||
# 默认关闭:擦亮是对外动作,由用户显式开启
|
||
enabled = str(enabled_str or '').strip().lower() in ('1', 'true', 'yes')
|
||
try:
|
||
interval = int(interval_str) if interval_str else 21600
|
||
except (TypeError, ValueError):
|
||
interval = 21600
|
||
# 擦亮太频繁没有意义,最短 1 小时
|
||
interval = max(3600, interval)
|
||
|
||
if not enabled:
|
||
await self._interruptible_sleep(120)
|
||
continue
|
||
|
||
current_time = time.time()
|
||
if current_time - self.last_polish_time < interval:
|
||
wait_time = min(120, interval - (current_time - self.last_polish_time))
|
||
await self._interruptible_sleep(wait_time)
|
||
continue
|
||
|
||
if not self.cookies_str:
|
||
await self._interruptible_sleep(120)
|
||
continue
|
||
|
||
# 风控冷却期内不发任何主动请求
|
||
from utils import risk_control
|
||
guard = risk_control.registry.get(self.cookie_id)
|
||
if guard.is_blocked:
|
||
wait = min(guard.remaining_seconds + 5, 300)
|
||
logger.debug(
|
||
f"【{self.cookie_id}】风控冷却中,{wait} 秒后再检查"
|
||
)
|
||
await self._interruptible_sleep(wait)
|
||
continue
|
||
|
||
try:
|
||
from utils.item_polish import polish_account_items
|
||
|
||
result = await polish_account_items(self.cookie_id, self.cookies_str)
|
||
self.last_polish_time = current_time
|
||
|
||
new_cookies = result.get('cookies_str')
|
||
if new_cookies and new_cookies != self.cookies_str:
|
||
self.cookies_str = new_cookies
|
||
|
||
if result['total']:
|
||
logger.info(
|
||
f"【{self.cookie_id}】定时擦亮完成: 共 {result['total']} 个商品,"
|
||
f"成功 {result['success']},失败 {result['failed']}"
|
||
)
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】商品擦亮被取消")
|
||
raise
|
||
except Exception as polish_error:
|
||
logger.error(
|
||
f"【{self.cookie_id}】商品擦亮异常: {self._safe_str(polish_error)}"
|
||
)
|
||
|
||
await self._interruptible_sleep(interval)
|
||
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】商品擦亮循环收到取消信号,准备退出")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】商品擦亮任务失败: {self._safe_str(e)}")
|
||
try:
|
||
await self._interruptible_sleep(120)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】商品擦亮循环已取消,正在退出...")
|
||
raise
|
||
finally:
|
||
logger.info(f"【{self.cookie_id}】商品擦亮循环已退出")
|
||
|
||
|
||
async def delivery_timeout_loop(self):
|
||
"""发货超时告警。
|
||
|
||
闲鱼对超时未发货有处罚。卖家端订单接口提供了两个预警状态:
|
||
``NOT_SHIP_ABOUT_TO_EXPIRE``(即将超时)和 ``NOT_SHIP_EXPIRED``(已超时),
|
||
这里定期检查并推送到通知渠道。同一订单同一状态只提醒一次。
|
||
"""
|
||
# 启动错峰:多账号多任务同时发请求容易触发平台风控
|
||
await self._interruptible_sleep(random.uniform(0, 90))
|
||
try:
|
||
while True:
|
||
try:
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止发货超时检查")
|
||
break
|
||
|
||
from app.db_manager import db_manager
|
||
enabled_str = db_manager.get_system_setting('delivery_timeout_alert_enabled')
|
||
interval_str = db_manager.get_system_setting('delivery_timeout_interval')
|
||
|
||
# 默认开启:超时会被平台处罚,属于必要提醒
|
||
enabled = str(enabled_str or '').strip().lower() != 'false'
|
||
try:
|
||
interval = int(interval_str) if interval_str else 3600
|
||
except (TypeError, ValueError):
|
||
interval = 3600
|
||
interval = max(900, interval)
|
||
|
||
if not enabled or not self.cookies_str:
|
||
await self._interruptible_sleep(120)
|
||
continue
|
||
|
||
# 风控冷却期内不发任何主动请求
|
||
from utils import risk_control
|
||
guard = risk_control.registry.get(self.cookie_id)
|
||
if guard.is_blocked:
|
||
wait = min(guard.remaining_seconds + 5, 300)
|
||
logger.debug(
|
||
f"【{self.cookie_id}】风控冷却中,{wait} 秒后再检查"
|
||
)
|
||
await self._interruptible_sleep(wait)
|
||
continue
|
||
|
||
try:
|
||
await self._check_delivery_timeout()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as check_error:
|
||
logger.error(
|
||
f"【{self.cookie_id}】发货超时检查异常: {self._safe_str(check_error)}"
|
||
)
|
||
|
||
await self._interruptible_sleep(interval)
|
||
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】发货超时检查收到取消信号,准备退出")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】发货超时检查失败: {self._safe_str(e)}")
|
||
try:
|
||
await self._interruptible_sleep(120)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】发货超时检查已取消,正在退出...")
|
||
raise
|
||
finally:
|
||
logger.info(f"【{self.cookie_id}】发货超时检查已退出")
|
||
|
||
async def _check_delivery_timeout(self) -> int:
|
||
"""查询即将超时和已超时的待发货订单并推送告警。
|
||
|
||
Returns:
|
||
本次新提醒的订单数。
|
||
"""
|
||
from utils.xianyu_seller_api import (
|
||
XianyuSellerAPI,
|
||
SellerApiError,
|
||
parse_sold_order,
|
||
)
|
||
|
||
alerts = []
|
||
api = XianyuSellerAPI(self.cookie_id, self.cookies_str)
|
||
try:
|
||
for query_code, label in (
|
||
("NOT_SHIP_ABOUT_TO_EXPIRE", "即将超时"),
|
||
("NOT_SHIP_EXPIRED", "已超时"),
|
||
):
|
||
try:
|
||
batch = await api.get_sold_orders(
|
||
query_code=query_code, rows_per_page=50
|
||
)
|
||
except SellerApiError as exc:
|
||
logger.debug(f"【{self.cookie_id}】查询 {query_code} 失败: {exc}")
|
||
continue
|
||
|
||
for item in batch.get("items") or []:
|
||
parsed = parse_sold_order(item)
|
||
order_id = parsed.get("order_id")
|
||
if not order_id:
|
||
continue
|
||
# 同一订单的同一告警级别只提醒一次,避免重复轰炸
|
||
alert_key = f"{order_id}:{query_code}"
|
||
if alert_key in self._delivery_timeout_alerted:
|
||
continue
|
||
self._delivery_timeout_alerted.add(alert_key)
|
||
alerts.append((label, parsed))
|
||
|
||
if self.cookies_str != api.cookies_str and api.cookies_str:
|
||
self.cookies_str = api.cookies_str
|
||
finally:
|
||
await api.close()
|
||
|
||
if not alerts:
|
||
return 0
|
||
|
||
lines = [f"⚠️ 发货超时提醒(账号 {self.cookie_id})", ""]
|
||
for label, parsed in alerts:
|
||
lines.append(
|
||
f"[{label}] 订单 {parsed['order_id']}\n"
|
||
f" 商品: {parsed.get('item_title') or '未知'}\n"
|
||
f" 金额: {parsed.get('amount') or '未知'}\n"
|
||
f" 下单: {parsed.get('created_at') or '未知'}"
|
||
)
|
||
lines.append("")
|
||
lines.append("请尽快处理,超时未发货会被平台处罚。")
|
||
|
||
message = "\n".join(lines)
|
||
sent = await self.send_system_notification(message)
|
||
logger.warning(
|
||
f"【{self.cookie_id}】发货超时告警: {len(alerts)} 个订单,已推送 {sent} 个渠道"
|
||
)
|
||
return len(alerts)
|
||
|
||
|
||
async def buyer_interaction_loop(self):
|
||
"""自动评价与求小红花。
|
||
|
||
两者都会对买家产生实际动作(评价不可撤销、求花会发消息),因此默认关闭,
|
||
需要在设置里显式开启。同一订单只处理一次。
|
||
"""
|
||
# 启动错峰:多账号多任务同时发请求容易触发平台风控
|
||
await self._interruptible_sleep(random.uniform(0, 240))
|
||
try:
|
||
while True:
|
||
try:
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止买家互动任务")
|
||
break
|
||
|
||
from app.db_manager import db_manager
|
||
# 开关按账号存:不同账号经营策略不同,全局开关意味着一开就是
|
||
# 所有账号一起开,用户没法只对部分账号启用。
|
||
interaction = db_manager.get_buyer_interaction_settings(self.cookie_id)
|
||
rate_on = interaction['auto_rate_enabled']
|
||
flower_on = interaction['auto_flower_enabled']
|
||
interval_str = db_manager.get_system_setting('buyer_interaction_interval')
|
||
try:
|
||
interval = int(interval_str) if interval_str else 7200
|
||
except (TypeError, ValueError):
|
||
interval = 7200
|
||
# 下限从 30 分钟放宽到 5 分钟:确认收货现在由消息事件即时触发,
|
||
# 轮询退化成兜底(漏收消息、服务重启期间完成的订单)。但仍要有
|
||
# 下限 —— 这条循环每轮都拉一次卖出订单列表,太频繁会招来风控。
|
||
interval = min(max(300, interval), 86400)
|
||
|
||
if (not rate_on and not flower_on) or not self.cookies_str:
|
||
await self._interruptible_sleep(180)
|
||
continue
|
||
|
||
# 风控冷却期内不发任何主动请求
|
||
from utils import risk_control
|
||
guard = risk_control.registry.get(self.cookie_id)
|
||
if guard.is_blocked:
|
||
wait = min(guard.remaining_seconds + 5, 300)
|
||
logger.debug(
|
||
f"【{self.cookie_id}】风控冷却中,{wait} 秒后再检查"
|
||
)
|
||
await self._interruptible_sleep(wait)
|
||
continue
|
||
|
||
try:
|
||
await self._run_buyer_interactions(rate_on, flower_on)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as run_error:
|
||
logger.error(
|
||
f"【{self.cookie_id}】买家互动执行异常: {self._safe_str(run_error)}"
|
||
)
|
||
|
||
await self._interruptible_sleep(interval)
|
||
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】买家互动任务收到取消信号,准备退出")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】买家互动任务失败: {self._safe_str(e)}")
|
||
try:
|
||
await self._interruptible_sleep(180)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】买家互动任务已取消,正在退出...")
|
||
raise
|
||
finally:
|
||
logger.info(f"【{self.cookie_id}】买家互动任务已退出")
|
||
|
||
# 确认收货致谢的默认文案。留空则不发。
|
||
DEFAULT_THANKS_TEMPLATE = '亲,感谢支持!有任何问题随时找我~'
|
||
|
||
async def send_post_receipt_thanks(self, websocket, chat_id, to_user_id, order_id=None):
|
||
"""确认收货后给买家发一条致谢文本。
|
||
|
||
和评价/求花不同,这条只是发消息,不依赖卖出订单接口的状态流转,所以直接
|
||
在收到「交易成功」消息时就地发出 —— 此时 chat_id 和买家 ID 都在手上,
|
||
不必再查订单。
|
||
|
||
同一个会话只发一次:确认收货往往伴随多条系统消息(交易成功、评价提醒、
|
||
小红花提醒),逐条发会连着骚扰买家。
|
||
"""
|
||
from app.db_manager import db_manager
|
||
|
||
if not db_manager.get_buyer_interaction_settings(
|
||
self.cookie_id
|
||
)['auto_thanks_enabled']:
|
||
return False
|
||
|
||
key = str(order_id or chat_id)
|
||
if key in self._thanked_receipts:
|
||
logger.debug(f"【{self.cookie_id}】{key} 已发过确认收货致谢,跳过")
|
||
return False
|
||
|
||
template = (
|
||
db_manager.get_system_setting('auto_thanks_template')
|
||
or self.DEFAULT_THANKS_TEMPLATE
|
||
).strip()
|
||
if not template:
|
||
return False
|
||
|
||
# 先登记再发送:发送失败也不重试,避免异常时反复打扰买家
|
||
self._thanked_receipts.add(key)
|
||
try:
|
||
await self.send_msg(websocket, chat_id, to_user_id, template)
|
||
logger.info(f"【{self.cookie_id}】确认收货致谢已发送: chat_id={chat_id}")
|
||
return True
|
||
except Exception as exc:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】确认收货致谢发送失败: {self._safe_str(exc)}"
|
||
)
|
||
return False
|
||
|
||
# 交易成功后触发前的等待秒数。闲鱼推送「交易成功」消息时,卖出订单接口未必
|
||
# 已经把该单切到 TRADE_SUCCESS,立刻去查会查不到;等一小会儿再查。
|
||
BUYER_INTERACTION_TRIGGER_DELAY = float(
|
||
os.getenv('BUYER_INTERACTION_TRIGGER_DELAY', '20')
|
||
)
|
||
|
||
async def trigger_buyer_interactions_now(self, reason: str = '交易成功'):
|
||
"""收到交易成功消息后立即执行一次买家互动,不必等下一轮轮询。
|
||
|
||
轮询间隔最短也有几十分钟,买家确认收货后要等很久才评价/求花,时机上
|
||
已经偏晚。这里由消息事件驱动,做成「尽快执行一次」。
|
||
|
||
同一时刻只允许一个触发在跑:确认收货往往伴随多条系统消息(交易成功、
|
||
评价提醒、小红花提醒),每条都触发一次会连着打同一个接口。
|
||
"""
|
||
if getattr(self, '_buyer_interaction_triggering', False):
|
||
logger.debug(f"【{self.cookie_id}】买家互动已在触发中,忽略重复的「{reason}」")
|
||
return
|
||
|
||
from app.db_manager import db_manager
|
||
interaction = db_manager.get_buyer_interaction_settings(self.cookie_id)
|
||
if not interaction['auto_rate_enabled'] and not interaction['auto_flower_enabled']:
|
||
return
|
||
if not self.cookies_str:
|
||
return
|
||
|
||
self._buyer_interaction_triggering = True
|
||
|
||
async def run():
|
||
try:
|
||
await asyncio.sleep(self.BUYER_INTERACTION_TRIGGER_DELAY)
|
||
|
||
from utils import risk_control
|
||
guard = risk_control.registry.get(self.cookie_id)
|
||
if guard.is_blocked:
|
||
logger.info(
|
||
f"【{self.cookie_id}】{reason}触发买家互动,但正处风控冷却,"
|
||
f"交由定时轮询稍后处理"
|
||
)
|
||
return
|
||
|
||
logger.info(f"【{self.cookie_id}】{reason},立即执行买家互动")
|
||
result = await self._run_buyer_interactions(
|
||
interaction['auto_rate_enabled'], interaction['auto_flower_enabled']
|
||
)
|
||
if not result.get('rated') and not result.get('flowered'):
|
||
logger.info(
|
||
f"【{self.cookie_id}】{reason}触发未产生动作"
|
||
f"(可能该单已评价过,或接口尚未返回可求花状态)"
|
||
)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.error(
|
||
f"【{self.cookie_id}】{reason}触发买家互动失败: {self._safe_str(exc)}"
|
||
)
|
||
finally:
|
||
self._buyer_interaction_triggering = False
|
||
|
||
asyncio.create_task(run())
|
||
|
||
async def _run_buyer_interactions(self, rate_on: bool, flower_on: bool) -> dict:
|
||
"""对已完结订单执行评价和求花。
|
||
|
||
判定依据来自订单接口:``sellerRateStatus`` 为 4 表示卖家已评价,
|
||
``REQUIRE_FLOWER`` 出现在可执行动作里才说明该单能求花。
|
||
"""
|
||
from app.db_manager import db_manager
|
||
from utils.xianyu_seller_api import (
|
||
XianyuSellerAPI,
|
||
SellerApiError,
|
||
parse_sold_order,
|
||
)
|
||
|
||
rate_content = db_manager.get_system_setting('auto_rate_content') or '感谢惠顾,欢迎下次光临!'
|
||
rated = 0
|
||
flowered = 0
|
||
|
||
api = XianyuSellerAPI(self.cookie_id, self.cookies_str)
|
||
try:
|
||
try:
|
||
batch = await api.get_sold_orders(
|
||
query_code='TRADE_SUCCESS', rows_per_page=50
|
||
)
|
||
except SellerApiError as exc:
|
||
logger.debug(f"【{self.cookie_id}】查询已完结订单失败: {exc}")
|
||
return {'rated': 0, 'flowered': 0}
|
||
|
||
for item in batch.get('items') or []:
|
||
parsed = parse_sold_order(item)
|
||
order_id = parsed.get('order_id')
|
||
if not order_id:
|
||
continue
|
||
actions = parsed.get('trade_actions') or []
|
||
|
||
if rate_on and order_id not in self._auto_rated_orders:
|
||
# sellerRateStatus 为 4 表示已评价过
|
||
if parsed.get('seller_rate_status') != 4:
|
||
try:
|
||
result = await api.create_rate([order_id], feedback=rate_content, rate=1)
|
||
if result.get('success'):
|
||
rated += 1
|
||
logger.info(f"【{self.cookie_id}】订单 {order_id} 已自动评价")
|
||
except SellerApiError as exc:
|
||
logger.warning(f"【{self.cookie_id}】订单 {order_id} 评价失败: {exc}")
|
||
self._auto_rated_orders.add(order_id)
|
||
|
||
if flower_on and order_id not in self._auto_flowered_orders:
|
||
if 'REQUIRE_FLOWER' in actions:
|
||
try:
|
||
await api.require_flower(order_id)
|
||
flowered += 1
|
||
logger.info(f"【{self.cookie_id}】订单 {order_id} 已发送求花")
|
||
except SellerApiError as exc:
|
||
logger.warning(f"【{self.cookie_id}】订单 {order_id} 求花失败: {exc}")
|
||
self._auto_flowered_orders.add(order_id)
|
||
|
||
if api.cookies_str and api.cookies_str != self.cookies_str:
|
||
self.cookies_str = api.cookies_str
|
||
finally:
|
||
await api.close()
|
||
|
||
if rated or flowered:
|
||
logger.info(
|
||
f"【{self.cookie_id}】买家互动完成: 评价 {rated} 单,求花 {flowered} 单"
|
||
)
|
||
return {'rated': rated, 'flowered': flowered}
|
||
|
||
|
||
async def cookie_refresh_loop(self):
|
||
"""Cookie刷新定时任务 - 每小时执行一次"""
|
||
try:
|
||
while True:
|
||
try:
|
||
# 检查账号是否启用
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止Cookie刷新循环")
|
||
break
|
||
|
||
# 检查Cookie刷新功能是否启用
|
||
if not self.cookie_refresh_enabled:
|
||
logger.warning(f"【{self.cookie_id}】Cookie刷新功能已禁用,跳过执行")
|
||
await self._interruptible_sleep(300) # 5分钟后再检查
|
||
continue
|
||
|
||
current_time = time.time()
|
||
if current_time - self.last_cookie_refresh_time >= self.cookie_refresh_interval:
|
||
# 检查是否在消息接收后的冷却时间内
|
||
time_since_last_message = current_time - self.last_message_received_time
|
||
if time_since_last_message < self.message_cookie_refresh_cooldown:
|
||
remaining_time = self.message_cookie_refresh_cooldown - time_since_last_message
|
||
remaining_minutes = int(remaining_time // 60)
|
||
remaining_seconds = int(remaining_time % 60)
|
||
logger.warning(f"【{self.cookie_id}】收到消息后冷却中,还需等待 {remaining_minutes}分{remaining_seconds}秒 才能执行Cookie刷新")
|
||
# 检查是否已有Cookie刷新任务在执行
|
||
elif self.cookie_refresh_lock.locked():
|
||
logger.warning(f"【{self.cookie_id}】Cookie刷新任务已在执行中,跳过本次触发")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】开始执行Cookie刷新任务...")
|
||
# 在独立的任务中执行Cookie刷新,避免阻塞主循环
|
||
asyncio.create_task(self._execute_cookie_refresh(current_time))
|
||
|
||
# 每分钟检查一次是否需要执行
|
||
await self._interruptible_sleep(60)
|
||
except asyncio.CancelledError:
|
||
# 收到取消信号,立即退出循环
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新循环收到取消信号,准备退出")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】Cookie刷新循环失败: {self._safe_str(e)}")
|
||
# 出错后也等待1分钟再重试,使用可中断的sleep
|
||
try:
|
||
await self._interruptible_sleep(60)
|
||
except asyncio.CancelledError:
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新循环在重试等待时收到取消信号,准备退出")
|
||
raise
|
||
except asyncio.CancelledError:
|
||
# 确保CancelledError被正确传播
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新循环已取消,正在退出...")
|
||
raise
|
||
finally:
|
||
# 确保任务能正常结束
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新循环已退出")
|
||
|
||
async def _execute_cookie_refresh(self, current_time):
|
||
"""独立执行Cookie刷新任务,避免阻塞主循环"""
|
||
|
||
# 使用Lock确保原子性,防止重复执行
|
||
async with self.cookie_refresh_lock:
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】开始Cookie刷新任务,暂时暂停心跳以避免连接冲突...")
|
||
|
||
# 暂时暂停心跳任务,避免与浏览器操作冲突
|
||
heartbeat_was_running = False
|
||
if self.heartbeat_task and not self.heartbeat_task.done():
|
||
heartbeat_was_running = True
|
||
self.heartbeat_task.cancel()
|
||
logger.warning(f"【{self.cookie_id}】已暂停心跳任务")
|
||
|
||
# 为整个Cookie刷新任务添加超时保护(3分钟,缩短时间减少影响)
|
||
success = await asyncio.wait_for(
|
||
self._refresh_cookies_via_browser(),
|
||
timeout=180.0 # 3分钟超时,减少对WebSocket的影响
|
||
)
|
||
|
||
# 重新启动心跳任务
|
||
if heartbeat_was_running and self.ws and not self.ws.closed:
|
||
logger.warning(f"【{self.cookie_id}】重新启动心跳任务")
|
||
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(self.ws))
|
||
|
||
if success:
|
||
self.last_cookie_refresh_time = current_time
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新任务完成,心跳已恢复")
|
||
|
||
# 刷新成功后,验证Cookie有效性
|
||
logger.info(f"【{self.cookie_id}】开始验证刷新后的Cookie有效性...")
|
||
try:
|
||
validation_result = await self._verify_cookie_validity()
|
||
|
||
if not validation_result['valid']:
|
||
logger.warning(f"【{self.cookie_id}】❌ Cookie验证失败: {validation_result['details']}")
|
||
logger.warning(f"【{self.cookie_id}】检测到Cookie可能无法用于关键API,尝试通过密码登录重新获取...")
|
||
|
||
# 触发密码登录刷新
|
||
password_refresh_success = await self._try_password_login_refresh("Cookie验证失败(关键API不可用)")
|
||
|
||
if password_refresh_success:
|
||
logger.info(f"【{self.cookie_id}】✅ 密码登录刷新成功,Cookie已更新")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】⚠️ 密码登录刷新失败,Cookie可能仍然无效")
|
||
# 发送通知
|
||
await self.send_token_refresh_notification(
|
||
f"Cookie验证失败且密码登录刷新也失败\n验证详情: {validation_result['details']}",
|
||
"cookie_validation_failed"
|
||
)
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】✅ Cookie验证通过: {validation_result['details']}")
|
||
|
||
except Exception as verify_e:
|
||
logger.error(f"【{self.cookie_id}】Cookie验证过程异常: {self._safe_str(verify_e)}")
|
||
import traceback
|
||
logger.error(f"【{self.cookie_id}】详细堆栈:\n{traceback.format_exc()}")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】Cookie刷新任务失败")
|
||
# 即使失败也要更新时间,避免频繁重试
|
||
self.last_cookie_refresh_time = current_time
|
||
|
||
except asyncio.TimeoutError:
|
||
# 超时也要更新时间,避免频繁重试
|
||
self.last_cookie_refresh_time = current_time
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】执行Cookie刷新任务异常: {self._safe_str(e)}")
|
||
# 异常也要更新时间,避免频繁重试
|
||
self.last_cookie_refresh_time = current_time
|
||
finally:
|
||
# 确保心跳任务恢复(如果WebSocket仍然连接)
|
||
if (self.ws and not self.ws.closed and
|
||
(not self.heartbeat_task or self.heartbeat_task.done())):
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新完成,心跳任务正常运行")
|
||
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(self.ws))
|
||
|
||
# 清空消息接收标志,允许下次正常执行Cookie刷新
|
||
self.last_message_received_time = 0
|
||
logger.warning(f"【{self.cookie_id}】Cookie刷新完成,已清空消息接收标志")
|
||
|
||
|
||
|
||
def enable_cookie_refresh(self, enabled: bool = True):
|
||
"""启用或禁用Cookie刷新功能"""
|
||
self.cookie_refresh_enabled = enabled
|
||
status = "启用" if enabled else "禁用"
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新功能已{status}")
|
||
|
||
|
||
async def refresh_cookies_from_qr_login(self, qr_cookies_str: str, cookie_id: str = None, user_id: int = None):
|
||
"""使用扫码登录获取的cookie访问指定界面获取真实cookie并存入数据库
|
||
|
||
Args:
|
||
qr_cookies_str: 扫码登录获取的cookie字符串
|
||
cookie_id: 可选的cookie ID,如果不提供则使用当前实例的cookie_id
|
||
user_id: 可选的用户ID,如果不提供则使用当前实例的user_id
|
||
|
||
Returns:
|
||
bool: 成功返回True,失败返回False
|
||
"""
|
||
playwright = None
|
||
browser = None
|
||
target_cookie_id = cookie_id or self.cookie_id
|
||
target_user_id = user_id or self.user_id
|
||
|
||
try:
|
||
import asyncio
|
||
from playwright.async_api import async_playwright
|
||
from utils.xianyu_utils import trans_cookies
|
||
|
||
logger.info(f"【{target_cookie_id}】开始使用扫码登录cookie获取真实cookie...")
|
||
logger.info(f"【{target_cookie_id}】扫码cookie长度: {len(qr_cookies_str)}")
|
||
|
||
# 解析扫码登录的cookie
|
||
qr_cookies_dict = trans_cookies(qr_cookies_str)
|
||
logger.info(
|
||
f"【{target_cookie_id}】扫码cookie字段数: {len(qr_cookies_dict)}, "
|
||
f"字段: {sorted(qr_cookies_dict.keys())}"
|
||
)
|
||
|
||
# Docker环境下修复asyncio子进程问题
|
||
is_docker = os.getenv('DOCKER_ENV') or os.path.exists('/.dockerenv')
|
||
|
||
if is_docker:
|
||
logger.warning(f"【{target_cookie_id}】检测到Docker环境,应用asyncio修复")
|
||
|
||
# 创建一个完整的虚拟子进程监视器
|
||
class DummyChildWatcher:
|
||
def __enter__(self):
|
||
return self
|
||
def __exit__(self, *args):
|
||
pass
|
||
def is_active(self):
|
||
return True
|
||
def add_child_handler(self, *args, **kwargs):
|
||
pass
|
||
def remove_child_handler(self, *args, **kwargs):
|
||
pass
|
||
def attach_loop(self, *args, **kwargs):
|
||
pass
|
||
def close(self):
|
||
pass
|
||
def __del__(self):
|
||
pass
|
||
|
||
# 创建自定义事件循环策略
|
||
class DockerEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
|
||
def get_child_watcher(self):
|
||
return DummyChildWatcher()
|
||
|
||
# 临时设置策略
|
||
old_policy = asyncio.get_event_loop_policy()
|
||
asyncio.set_event_loop_policy(DockerEventLoopPolicy())
|
||
|
||
try:
|
||
# 添加超时机制,避免无限等待
|
||
playwright = await asyncio.wait_for(
|
||
async_playwright().start(),
|
||
timeout=30.0 # 30秒超时
|
||
)
|
||
logger.warning(f"【{target_cookie_id}】Docker环境下Playwright启动成功")
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"【{target_cookie_id}】Docker环境下Playwright启动超时")
|
||
return False
|
||
finally:
|
||
# 恢复原策略
|
||
asyncio.set_event_loop_policy(old_policy)
|
||
else:
|
||
# 非Docker环境,正常启动(也添加超时保护)
|
||
try:
|
||
playwright = await asyncio.wait_for(
|
||
async_playwright().start(),
|
||
timeout=30.0 # 30秒超时
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"【{target_cookie_id}】Playwright启动超时")
|
||
return False
|
||
|
||
# 启动浏览器(参照商品搜索的配置)
|
||
browser_args = [
|
||
'--no-sandbox',
|
||
'--disable-setuid-sandbox',
|
||
'--disable-dev-shm-usage',
|
||
'--disable-accelerated-2d-canvas',
|
||
'--no-first-run',
|
||
'--no-zygote',
|
||
'--disable-gpu',
|
||
'--disable-background-timer-throttling',
|
||
'--disable-backgrounding-occluded-windows',
|
||
'--disable-renderer-backgrounding',
|
||
'--disable-features=TranslateUI',
|
||
'--disable-ipc-flooding-protection',
|
||
'--disable-extensions',
|
||
'--disable-default-apps',
|
||
'--disable-sync',
|
||
'--disable-translate',
|
||
'--hide-scrollbars',
|
||
'--mute-audio',
|
||
'--no-default-browser-check',
|
||
'--no-pings'
|
||
]
|
||
|
||
# 在Docker环境中添加额外参数
|
||
if os.getenv('DOCKER_ENV'):
|
||
browser_args.extend([
|
||
# '--single-process', # 注释掉,避免多用户并发时的进程冲突和资源泄漏
|
||
'--disable-background-networking',
|
||
'--disable-client-side-phishing-detection',
|
||
'--disable-hang-monitor',
|
||
'--disable-popup-blocking',
|
||
'--disable-prompt-on-repost',
|
||
'--disable-web-resources',
|
||
'--metrics-recording-only',
|
||
'--safebrowsing-disable-auto-update',
|
||
'--enable-automation',
|
||
'--password-store=basic',
|
||
'--use-mock-keychain'
|
||
])
|
||
|
||
# 优先使用Playwright自带Chromium;未安装时回退到系统Chrome/Edge。
|
||
bundled_executable = playwright.chromium.executable_path
|
||
system_browser_candidates = [
|
||
("Chrome", r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
|
||
("Chrome", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"),
|
||
("Edge", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"),
|
||
("Edge", r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
|
||
]
|
||
launch_options = {
|
||
'headless': True,
|
||
'args': browser_args,
|
||
}
|
||
|
||
if os.path.exists(bundled_executable):
|
||
logger.info(
|
||
f"【{target_cookie_id}】使用Playwright Chromium: {bundled_executable}"
|
||
)
|
||
else:
|
||
system_browser = next(
|
||
(
|
||
(name, path)
|
||
for name, path in system_browser_candidates
|
||
if os.path.exists(path)
|
||
),
|
||
None
|
||
)
|
||
if not system_browser:
|
||
logger.error(
|
||
f"【{target_cookie_id}】未找到可用浏览器。"
|
||
f"Playwright预期路径不存在: {bundled_executable};"
|
||
"系统Chrome/Edge也未找到。请执行: python -m playwright install chromium"
|
||
)
|
||
return False
|
||
|
||
browser_name, browser_path = system_browser
|
||
launch_options['executable_path'] = browser_path
|
||
logger.warning(
|
||
f"【{target_cookie_id}】Playwright Chromium未安装,"
|
||
f"回退使用系统{browser_name}: {browser_path}"
|
||
)
|
||
|
||
logger.info(f"【{target_cookie_id}】正在启动无头浏览器")
|
||
browser = await browser_limit.launch_browser(playwright, launch_options, "扫码登录")
|
||
logger.info(f"【{target_cookie_id}】无头浏览器启动成功")
|
||
|
||
# 创建浏览器上下文
|
||
context_options = {
|
||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
|
||
}
|
||
|
||
# 使用标准窗口大小
|
||
context_options['viewport'] = {'width': 1920, 'height': 1080}
|
||
|
||
context = await browser.new_context(**context_options)
|
||
|
||
# 设置扫码登录获取的Cookie
|
||
cookies = []
|
||
for cookie_pair in qr_cookies_str.split('; '):
|
||
if '=' in cookie_pair:
|
||
name, value = cookie_pair.split('=', 1)
|
||
cookies.append({
|
||
'name': name.strip(),
|
||
'value': value.strip(),
|
||
'domain': '.goofish.com',
|
||
'path': '/'
|
||
})
|
||
|
||
await context.add_cookies(cookies)
|
||
logger.info(
|
||
f"【{target_cookie_id}】已设置 {len(cookies)} 个扫码Cookie到浏览器,"
|
||
f"字段: {sorted(cookie['name'] for cookie in cookies)}"
|
||
)
|
||
|
||
# 创建页面
|
||
page = await context.new_page()
|
||
|
||
# 等待页面准备
|
||
await asyncio.sleep(0.1)
|
||
|
||
# 访问指定页面获取真实cookie
|
||
target_url = "https://www.goofish.com/im"
|
||
logger.info(f"【{target_cookie_id}】访问页面获取真实cookie: {target_url}")
|
||
|
||
# 使用更灵活的页面访问策略
|
||
try:
|
||
# 首先尝试较短超时
|
||
await page.goto(target_url, wait_until='domcontentloaded', timeout=15000)
|
||
logger.info(f"【{target_cookie_id}】页面访问成功")
|
||
except Exception as e:
|
||
if 'timeout' in str(e).lower():
|
||
logger.warning(f"【{target_cookie_id}】页面访问超时,尝试降级策略...")
|
||
try:
|
||
# 降级策略:只等待基本加载
|
||
await page.goto(target_url, wait_until='load', timeout=20000)
|
||
logger.info(f"【{target_cookie_id}】页面访问成功(降级策略)")
|
||
except Exception as e2:
|
||
logger.warning(f"【{target_cookie_id}】降级策略也失败,尝试最基本访问...")
|
||
# 最后尝试:不等待任何加载完成
|
||
await page.goto(target_url, timeout=25000)
|
||
logger.info(f"【{target_cookie_id}】页面访问成功(最基本策略)")
|
||
else:
|
||
raise e
|
||
|
||
# 等待页面完全加载并获取真实cookie
|
||
logger.info(f"【{target_cookie_id}】页面加载完成,等待获取真实cookie...")
|
||
await asyncio.sleep(2)
|
||
|
||
# 执行一次刷新以确保获取最新的cookie
|
||
logger.info(f"【{target_cookie_id}】执行页面刷新获取最新cookie...")
|
||
try:
|
||
await page.reload(wait_until='domcontentloaded', timeout=12000)
|
||
logger.info(f"【{target_cookie_id}】页面刷新成功")
|
||
except Exception as e:
|
||
if 'timeout' in str(e).lower():
|
||
logger.warning(f"【{target_cookie_id}】页面刷新超时,使用降级策略...")
|
||
await page.reload(wait_until='load', timeout=15000)
|
||
logger.info(f"【{target_cookie_id}】页面刷新成功(降级策略)")
|
||
else:
|
||
raise e
|
||
await asyncio.sleep(1)
|
||
|
||
# 获取更新后的真实Cookie
|
||
logger.info(f"【{target_cookie_id}】获取真实Cookie...")
|
||
updated_cookies = await context.cookies()
|
||
|
||
# 构造新的Cookie字典
|
||
real_cookies_dict = {}
|
||
for cookie in updated_cookies:
|
||
real_cookies_dict[cookie['name']] = cookie['value']
|
||
|
||
# 生成真实cookie字符串
|
||
real_cookies_str = '; '.join([f"{k}={v}" for k, v in real_cookies_dict.items()])
|
||
|
||
logger.info(f"【{target_cookie_id}】真实Cookie已获取,包含 {len(real_cookies_dict)} 个字段")
|
||
|
||
# 检查关键字段
|
||
important_keys = ['unb', '_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'cna']
|
||
key_status = []
|
||
for key in important_keys:
|
||
if key in real_cookies_dict:
|
||
val = real_cookies_dict[key]
|
||
key_status.append(f"{key}=存在({len(str(val)) if val else 0})")
|
||
else:
|
||
key_status.append(f"{key}=缺失")
|
||
logger.info(
|
||
f"【{target_cookie_id}】真实Cookie摘要: 长度={len(real_cookies_str)}, "
|
||
f"字段={sorted(real_cookies_dict.keys())}, 关键字段=[{', '.join(key_status)}]"
|
||
)
|
||
|
||
# 打印原始扫码Cookie对比
|
||
logger.info(f"【{target_cookie_id}】=== 扫码Cookie对比 ===")
|
||
logger.info(f"【{target_cookie_id}】扫码Cookie长度: {len(qr_cookies_str)}")
|
||
logger.info(f"【{target_cookie_id}】扫码Cookie字段数: {len(qr_cookies_dict)}")
|
||
logger.info(f"【{target_cookie_id}】真实Cookie长度: {len(real_cookies_str)}")
|
||
logger.info(f"【{target_cookie_id}】真实Cookie字段数: {len(real_cookies_dict)}")
|
||
logger.info(f"【{target_cookie_id}】长度增加: {len(real_cookies_str) - len(qr_cookies_str)} 字符")
|
||
logger.info(f"【{target_cookie_id}】字段增加: {len(real_cookies_dict) - len(qr_cookies_dict)} 个")
|
||
|
||
# 检查Cookie变化
|
||
changed_cookies = []
|
||
new_cookies = []
|
||
for name, new_value in real_cookies_dict.items():
|
||
old_value = qr_cookies_dict.get(name)
|
||
if old_value is None:
|
||
new_cookies.append(name)
|
||
elif old_value != new_value:
|
||
changed_cookies.append(name)
|
||
|
||
# 显示Cookie变化统计
|
||
if changed_cookies:
|
||
logger.info(f"【{target_cookie_id}】发生变化的Cookie字段 ({len(changed_cookies)}个): {', '.join(changed_cookies)}")
|
||
if new_cookies:
|
||
logger.info(f"【{target_cookie_id}】新增的Cookie字段 ({len(new_cookies)}个): {', '.join(new_cookies)}")
|
||
if not changed_cookies and not new_cookies:
|
||
logger.info(f"【{target_cookie_id}】Cookie无变化")
|
||
|
||
# 保存真实Cookie到数据库
|
||
from app.db_manager import db_manager
|
||
|
||
# 检查是否为新账号
|
||
existing_cookie = db_manager.get_cookie_details(target_cookie_id)
|
||
if existing_cookie:
|
||
# 现有账号,使用 update_cookie_account_info 避免覆盖其他字段(如 pause_duration, remark 等)
|
||
success = db_manager.update_cookie_account_info(target_cookie_id, cookie_value=real_cookies_str)
|
||
else:
|
||
# 新账号,使用 save_cookie
|
||
success = db_manager.save_cookie(target_cookie_id, real_cookies_str, target_user_id)
|
||
|
||
if success:
|
||
logger.info(f"【{target_cookie_id}】真实Cookie已成功保存到数据库")
|
||
|
||
# 如果当前实例的cookie_id匹配,更新实例的cookie信息
|
||
if target_cookie_id == self.cookie_id:
|
||
self.cookies = real_cookies_dict
|
||
self.cookies_str = real_cookies_str
|
||
logger.info(f"【{target_cookie_id}】已更新当前实例的Cookie信息")
|
||
|
||
# 更新扫码登录Cookie刷新时间标志
|
||
self.last_qr_cookie_refresh_time = time.time()
|
||
logger.info(f"【{target_cookie_id}】已更新扫码登录Cookie刷新时间标志,_refresh_cookies_via_browser将等待{self.qr_cookie_refresh_cooldown//60}分钟后执行")
|
||
|
||
return True
|
||
else:
|
||
logger.error(f"【{target_cookie_id}】保存真实Cookie到数据库失败")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{target_cookie_id}】使用扫码cookie获取真实cookie失败: {self._safe_str(e)}")
|
||
return False
|
||
finally:
|
||
# 确保资源清理
|
||
try:
|
||
# 先关闭浏览器,再关闭Playwright(顺序很重要)
|
||
if browser:
|
||
try:
|
||
await asyncio.wait_for(browser.close(), timeout=5.0)
|
||
logger.warning(f"【{target_cookie_id}】浏览器关闭完成")
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{target_cookie_id}】浏览器关闭超时(5秒),资源可能未完全释放")
|
||
# 尝试取消浏览器相关的任务
|
||
try:
|
||
if hasattr(browser, '_connection'):
|
||
browser._connection = None
|
||
except:
|
||
pass
|
||
except Exception as e:
|
||
logger.warning(f"【{target_cookie_id}】关闭浏览器时出错: {self._safe_str(e)}")
|
||
|
||
# Playwright关闭:使用更短的超时,超时后立即放弃
|
||
if playwright:
|
||
try:
|
||
logger.warning(f"【{target_cookie_id}】正在关闭Playwright...")
|
||
await asyncio.wait_for(playwright.stop(), timeout=2.0)
|
||
logger.warning(f"【{target_cookie_id}】Playwright关闭完成")
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{target_cookie_id}】Playwright关闭超时(2秒),进程可能仍在运行")
|
||
logger.warning(f"【{target_cookie_id}】提示:如果后续Playwright启动失败,可能需要手动清理残留进程")
|
||
# 尝试清理Playwright的内部状态
|
||
try:
|
||
# 取消可能正在运行的Playwright任务
|
||
if hasattr(playwright, '_transport'):
|
||
playwright._transport = None
|
||
except:
|
||
pass
|
||
except Exception as e:
|
||
logger.warning(f"【{target_cookie_id}】关闭Playwright时出错: {self._safe_str(e)}")
|
||
except Exception as cleanup_e:
|
||
logger.warning(f"【{target_cookie_id}】清理浏览器资源时出错: {self._safe_str(cleanup_e)}")
|
||
|
||
async def _refresh_cookies_via_browser_page(self, current_cookies_str: str):
|
||
"""使用当前cookie访问指定页面获取真实cookie并更新
|
||
|
||
这是令牌过期时的备用刷新方案,类似于refresh_cookies_from_qr_login,
|
||
但使用当前的cookie而不是扫码登录的cookie。
|
||
|
||
Args:
|
||
current_cookies_str: 当前的cookie字符串
|
||
|
||
Returns:
|
||
bool: 成功返回True,失败返回False
|
||
"""
|
||
playwright = None
|
||
browser = None
|
||
|
||
try:
|
||
import asyncio
|
||
from playwright.async_api import async_playwright
|
||
from utils.xianyu_utils import trans_cookies
|
||
|
||
logger.info(f"【{self.cookie_id}】开始使用当前cookie访问指定页面获取真实cookie...")
|
||
logger.info(f"【{self.cookie_id}】当前cookie长度: {len(current_cookies_str)}")
|
||
|
||
# 解析当前的cookie
|
||
current_cookies_dict = trans_cookies(current_cookies_str)
|
||
logger.info(f"【{self.cookie_id}】当前cookie字段数: {len(current_cookies_dict)}")
|
||
|
||
# Docker环境下修复asyncio子进程问题
|
||
is_docker = os.getenv('DOCKER_ENV') or os.path.exists('/.dockerenv')
|
||
|
||
if is_docker:
|
||
logger.warning(f"【{self.cookie_id}】检测到Docker环境,应用asyncio修复")
|
||
|
||
# 创建一个完整的虚拟子进程监视器
|
||
class DummyChildWatcher:
|
||
def __enter__(self):
|
||
return self
|
||
def __exit__(self, *args):
|
||
pass
|
||
def is_active(self):
|
||
return True
|
||
def add_child_handler(self, *args, **kwargs):
|
||
pass
|
||
def remove_child_handler(self, *args, **kwargs):
|
||
pass
|
||
def attach_loop(self, *args, **kwargs):
|
||
pass
|
||
def close(self):
|
||
pass
|
||
def __del__(self):
|
||
pass
|
||
|
||
# 创建自定义事件循环策略
|
||
class DockerEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
|
||
def get_child_watcher(self):
|
||
return DummyChildWatcher()
|
||
|
||
# 临时设置策略
|
||
old_policy = asyncio.get_event_loop_policy()
|
||
asyncio.set_event_loop_policy(DockerEventLoopPolicy())
|
||
|
||
try:
|
||
# 添加超时机制,避免无限等待
|
||
playwright = await asyncio.wait_for(
|
||
async_playwright().start(),
|
||
timeout=30.0 # 30秒超时
|
||
)
|
||
logger.warning(f"【{self.cookie_id}】Docker环境下Playwright启动成功")
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"【{self.cookie_id}】Docker环境下Playwright启动超时")
|
||
return False
|
||
finally:
|
||
# 恢复原策略
|
||
asyncio.set_event_loop_policy(old_policy)
|
||
else:
|
||
# 非Docker环境,正常启动(也添加超时保护)
|
||
try:
|
||
playwright = await asyncio.wait_for(
|
||
async_playwright().start(),
|
||
timeout=30.0 # 30秒超时
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"【{self.cookie_id}】Playwright启动超时")
|
||
return False
|
||
|
||
# 启动浏览器(参照商品搜索的配置)
|
||
browser_args = [
|
||
'--no-sandbox',
|
||
'--disable-setuid-sandbox',
|
||
'--disable-dev-shm-usage',
|
||
'--disable-accelerated-2d-canvas',
|
||
'--no-first-run',
|
||
'--no-zygote',
|
||
'--disable-gpu',
|
||
'--disable-background-timer-throttling',
|
||
'--disable-backgrounding-occluded-windows',
|
||
'--disable-renderer-backgrounding',
|
||
'--disable-features=TranslateUI',
|
||
'--disable-ipc-flooding-protection',
|
||
'--disable-extensions',
|
||
'--disable-default-apps',
|
||
'--disable-sync',
|
||
'--disable-translate',
|
||
'--hide-scrollbars',
|
||
'--mute-audio',
|
||
'--no-default-browser-check',
|
||
'--no-pings'
|
||
]
|
||
|
||
# 在Docker环境中添加额外参数
|
||
if os.getenv('DOCKER_ENV'):
|
||
browser_args.extend([
|
||
'--disable-background-networking',
|
||
'--disable-client-side-phishing-detection',
|
||
'--disable-hang-monitor',
|
||
'--disable-popup-blocking',
|
||
'--disable-prompt-on-repost',
|
||
'--disable-web-resources',
|
||
'--metrics-recording-only',
|
||
'--safebrowsing-disable-auto-update',
|
||
'--enable-automation',
|
||
'--password-store=basic',
|
||
'--use-mock-keychain'
|
||
])
|
||
|
||
launch_options = self._get_playwright_launch_options(
|
||
playwright,
|
||
browser_args,
|
||
"浏览器页面刷新"
|
||
)
|
||
browser = await browser_limit.launch_browser(playwright, launch_options, "浏览器页面刷新")
|
||
|
||
# 创建浏览器上下文
|
||
context_options = {
|
||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
|
||
}
|
||
|
||
# 使用标准窗口大小
|
||
context_options['viewport'] = {'width': 1920, 'height': 1080}
|
||
|
||
context = await browser.new_context(**context_options)
|
||
|
||
# 设置当前的Cookie
|
||
cookies = []
|
||
for cookie_pair in current_cookies_str.split('; '):
|
||
if '=' in cookie_pair:
|
||
name, value = cookie_pair.split('=', 1)
|
||
cookies.append({
|
||
'name': name.strip(),
|
||
'value': value.strip(),
|
||
'domain': '.goofish.com',
|
||
'path': '/'
|
||
})
|
||
|
||
await context.add_cookies(cookies)
|
||
logger.info(f"【{self.cookie_id}】已设置 {len(cookies)} 个当前Cookie到浏览器")
|
||
|
||
# 创建页面
|
||
page = await context.new_page()
|
||
|
||
# 等待页面准备
|
||
await asyncio.sleep(0.1)
|
||
|
||
# 访问指定页面获取真实cookie
|
||
target_url = "https://www.goofish.com/im"
|
||
logger.info(f"【{self.cookie_id}】访问页面获取真实cookie: {target_url}")
|
||
|
||
# 使用更灵活的页面访问策略
|
||
try:
|
||
# 首先尝试较短超时
|
||
await page.goto(target_url, wait_until='domcontentloaded', timeout=15000)
|
||
logger.info(f"【{self.cookie_id}】页面访问成功")
|
||
except Exception as e:
|
||
if 'timeout' in str(e).lower():
|
||
logger.warning(f"【{self.cookie_id}】页面访问超时,尝试降级策略...")
|
||
try:
|
||
# 降级策略:只等待基本加载
|
||
await page.goto(target_url, wait_until='load', timeout=20000)
|
||
logger.info(f"【{self.cookie_id}】页面访问成功(降级策略)")
|
||
except Exception as e2:
|
||
logger.warning(f"【{self.cookie_id}】降级策略也失败,尝试最基本访问...")
|
||
# 最后尝试:不等待任何加载完成
|
||
await page.goto(target_url, timeout=25000)
|
||
logger.info(f"【{self.cookie_id}】页面访问成功(最基本策略)")
|
||
else:
|
||
raise e
|
||
|
||
# 等待页面完全加载并获取真实cookie
|
||
logger.info(f"【{self.cookie_id}】页面加载完成,等待获取真实cookie...")
|
||
await asyncio.sleep(2)
|
||
|
||
# 执行一次刷新以确保获取最新的cookie
|
||
logger.info(f"【{self.cookie_id}】执行页面刷新获取最新cookie...")
|
||
try:
|
||
await page.reload(wait_until='domcontentloaded', timeout=12000)
|
||
logger.info(f"【{self.cookie_id}】页面刷新成功")
|
||
except Exception as e:
|
||
if 'timeout' in str(e).lower():
|
||
logger.warning(f"【{self.cookie_id}】页面刷新超时,使用降级策略...")
|
||
await page.reload(wait_until='load', timeout=15000)
|
||
logger.info(f"【{self.cookie_id}】页面刷新成功(降级策略)")
|
||
else:
|
||
raise e
|
||
await asyncio.sleep(1)
|
||
|
||
# 获取更新后的真实Cookie
|
||
logger.info(f"【{self.cookie_id}】获取真实Cookie...")
|
||
updated_cookies = await context.cookies()
|
||
|
||
# 构造新的Cookie字典
|
||
real_cookies_dict = {}
|
||
for cookie in updated_cookies:
|
||
real_cookies_dict[cookie['name']] = cookie['value']
|
||
|
||
# 生成真实cookie字符串
|
||
real_cookies_str = '; '.join([f"{k}={v}" for k, v in real_cookies_dict.items()])
|
||
|
||
logger.info(f"【{self.cookie_id}】真实Cookie已获取,包含 {len(real_cookies_dict)} 个字段")
|
||
logger.info(
|
||
f"【{self.cookie_id}】真实Cookie采集完成: "
|
||
f"fields={len(real_cookies_dict)}, length={len(real_cookies_str)}"
|
||
)
|
||
# 检查关键字段
|
||
important_keys = ['unb', '_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'cna']
|
||
logger.info(f"【{self.cookie_id}】关键字段检查:")
|
||
for key in important_keys:
|
||
if key in real_cookies_dict:
|
||
val = real_cookies_dict[key]
|
||
logger.info(f"【{self.cookie_id}】 ✅ {key}: {'存在' if val else '为空'} (长度: {len(str(val)) if val else 0})")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】 ❌ {key}: 缺失")
|
||
|
||
# 检查Cookie是否有有效更新
|
||
changed_cookies = []
|
||
new_cookies = []
|
||
for name, new_value in real_cookies_dict.items():
|
||
old_value = current_cookies_dict.get(name)
|
||
if old_value is None:
|
||
new_cookies.append(name)
|
||
elif old_value != new_value:
|
||
changed_cookies.append(name)
|
||
|
||
if not changed_cookies and not new_cookies:
|
||
logger.warning(f"【{self.cookie_id}】Cookie无变化,可能当前cookie已失效")
|
||
return False
|
||
|
||
logger.info(f"【{self.cookie_id}】发生变化的Cookie字段 ({len(changed_cookies)}个): {', '.join(changed_cookies[:10])}")
|
||
if new_cookies:
|
||
logger.info(f"【{self.cookie_id}】新增的Cookie字段 ({len(new_cookies)}个): {', '.join(new_cookies[:10])}")
|
||
|
||
# 更新Cookie并重启任务
|
||
logger.info(f"【{self.cookie_id}】开始更新Cookie并重启任务...")
|
||
update_success = await self._update_cookies_and_restart(real_cookies_str)
|
||
|
||
if update_success:
|
||
logger.info(f"【{self.cookie_id}】通过访问指定页面成功更新Cookie并重启任务")
|
||
return True
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】更新Cookie或重启任务失败")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】使用当前cookie访问指定页面获取真实cookie失败: {self._safe_str(e)}")
|
||
return False
|
||
finally:
|
||
# 确保资源清理
|
||
try:
|
||
# 先关闭浏览器,再关闭Playwright(顺序很重要)
|
||
if browser:
|
||
try:
|
||
await asyncio.wait_for(browser.close(), timeout=5.0)
|
||
logger.warning(f"【{self.cookie_id}】浏览器关闭完成")
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{self.cookie_id}】浏览器关闭超时(5秒),资源可能未完全释放")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】关闭浏览器时出错: {self._safe_str(e)}")
|
||
|
||
# Playwright关闭:使用更短的超时,超时后立即放弃
|
||
if playwright:
|
||
try:
|
||
logger.warning(f"【{self.cookie_id}】正在关闭Playwright...")
|
||
await asyncio.wait_for(playwright.stop(), timeout=2.0)
|
||
logger.warning(f"【{self.cookie_id}】Playwright关闭完成")
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{self.cookie_id}】Playwright关闭超时(2秒),进程可能仍在运行")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】关闭Playwright时出错: {self._safe_str(e)}")
|
||
except Exception as cleanup_e:
|
||
logger.warning(f"【{self.cookie_id}】清理浏览器资源时出错: {self._safe_str(cleanup_e)}")
|
||
|
||
def reset_qr_cookie_refresh_flag(self):
|
||
"""重置扫码登录Cookie刷新标志,允许立即执行_refresh_cookies_via_browser"""
|
||
self.last_qr_cookie_refresh_time = 0
|
||
logger.info(f"【{self.cookie_id}】已重置扫码登录Cookie刷新标志")
|
||
|
||
def get_qr_cookie_refresh_remaining_time(self) -> int:
|
||
"""获取扫码登录Cookie刷新剩余冷却时间(秒)"""
|
||
current_time = time.time()
|
||
time_since_qr_refresh = current_time - self.last_qr_cookie_refresh_time
|
||
remaining_time = max(0, self.qr_cookie_refresh_cooldown - time_since_qr_refresh)
|
||
return int(remaining_time)
|
||
|
||
async def _refresh_cookies_via_browser(self, triggered_by_refresh_token: bool = False):
|
||
"""通过浏览器访问指定页面刷新Cookie
|
||
|
||
Args:
|
||
triggered_by_refresh_token: 是否由refresh_token方法触发,如果是True则设置browser_cookie_refreshed标志
|
||
"""
|
||
|
||
|
||
playwright = None
|
||
browser = None
|
||
try:
|
||
import asyncio
|
||
from playwright.async_api import async_playwright
|
||
|
||
# 检查是否需要等待扫码登录Cookie刷新的冷却时间
|
||
current_time = time.time()
|
||
time_since_qr_refresh = current_time - self.last_qr_cookie_refresh_time
|
||
|
||
if time_since_qr_refresh < self.qr_cookie_refresh_cooldown:
|
||
remaining_time = self.qr_cookie_refresh_cooldown - time_since_qr_refresh
|
||
remaining_minutes = int(remaining_time // 60)
|
||
remaining_seconds = int(remaining_time % 60)
|
||
|
||
logger.info(f"【{self.cookie_id}】扫码登录Cookie刷新冷却中,还需等待 {remaining_minutes}分{remaining_seconds}秒")
|
||
logger.info(f"【{self.cookie_id}】跳过本次浏览器Cookie刷新")
|
||
return False
|
||
|
||
logger.info(f"【{self.cookie_id}】开始通过浏览器刷新Cookie...")
|
||
logger.info(f"【{self.cookie_id}】刷新前Cookie长度: {len(self.cookies_str)}")
|
||
logger.info(f"【{self.cookie_id}】刷新前Cookie字段数: {len(self.cookies)}")
|
||
|
||
# Docker环境下修复asyncio子进程问题
|
||
is_docker = os.getenv('DOCKER_ENV') or os.path.exists('/.dockerenv')
|
||
|
||
if is_docker:
|
||
logger.warning(f"【{self.cookie_id}】检测到Docker环境,应用asyncio修复")
|
||
|
||
# 创建一个完整的虚拟子进程监视器
|
||
class DummyChildWatcher:
|
||
def __enter__(self):
|
||
return self
|
||
def __exit__(self, *args):
|
||
pass
|
||
def is_active(self):
|
||
return True
|
||
def add_child_handler(self, *args, **kwargs):
|
||
pass
|
||
def remove_child_handler(self, *args, **kwargs):
|
||
pass
|
||
def attach_loop(self, *args, **kwargs):
|
||
pass
|
||
def close(self):
|
||
pass
|
||
def __del__(self):
|
||
pass
|
||
|
||
# 创建自定义事件循环策略
|
||
class DockerEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
|
||
def get_child_watcher(self):
|
||
return DummyChildWatcher()
|
||
|
||
# 临时设置策略
|
||
old_policy = asyncio.get_event_loop_policy()
|
||
asyncio.set_event_loop_policy(DockerEventLoopPolicy())
|
||
|
||
try:
|
||
# 添加超时机制,避免无限等待
|
||
playwright = await asyncio.wait_for(
|
||
async_playwright().start(),
|
||
timeout=30.0 # 30秒超时
|
||
)
|
||
logger.warning(f"【{self.cookie_id}】Docker环境下Playwright启动成功")
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"【{self.cookie_id}】Docker环境下Playwright启动超时")
|
||
return False
|
||
finally:
|
||
# 恢复原策略
|
||
asyncio.set_event_loop_policy(old_policy)
|
||
else:
|
||
# 非Docker环境,正常启动(也添加超时保护)
|
||
try:
|
||
playwright = await asyncio.wait_for(
|
||
async_playwright().start(),
|
||
timeout=30.0 # 30秒超时
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"【{self.cookie_id}】Playwright启动超时")
|
||
return False
|
||
|
||
# 启动浏览器(参照商品搜索的配置)
|
||
browser_args = [
|
||
'--no-sandbox',
|
||
'--disable-setuid-sandbox',
|
||
'--disable-dev-shm-usage',
|
||
'--disable-accelerated-2d-canvas',
|
||
'--no-first-run',
|
||
'--no-zygote',
|
||
'--disable-gpu',
|
||
'--disable-background-timer-throttling',
|
||
'--disable-backgrounding-occluded-windows',
|
||
'--disable-renderer-backgrounding',
|
||
'--disable-features=TranslateUI',
|
||
'--disable-ipc-flooding-protection',
|
||
'--disable-extensions',
|
||
'--disable-default-apps',
|
||
'--disable-sync',
|
||
'--disable-translate',
|
||
'--hide-scrollbars',
|
||
'--mute-audio',
|
||
'--no-default-browser-check',
|
||
'--no-pings'
|
||
]
|
||
|
||
# 在Docker环境中添加额外参数
|
||
if os.getenv('DOCKER_ENV'):
|
||
browser_args.extend([
|
||
# '--single-process', # 注释掉,避免多用户并发时的进程冲突和资源泄漏
|
||
'--disable-background-networking',
|
||
'--disable-client-side-phishing-detection',
|
||
'--disable-hang-monitor',
|
||
'--disable-popup-blocking',
|
||
'--disable-prompt-on-repost',
|
||
'--disable-web-resources',
|
||
'--metrics-recording-only',
|
||
'--safebrowsing-disable-auto-update',
|
||
'--enable-automation',
|
||
'--password-store=basic',
|
||
'--use-mock-keychain'
|
||
])
|
||
|
||
launch_options = self._get_playwright_launch_options(
|
||
playwright,
|
||
browser_args,
|
||
"定时Cookie刷新"
|
||
)
|
||
browser = await browser_limit.launch_browser(playwright, launch_options, "定时Cookie刷新")
|
||
|
||
# 创建浏览器上下文
|
||
context_options = {
|
||
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
|
||
}
|
||
|
||
# 使用标准窗口大小
|
||
context_options['viewport'] = {'width': 1920, 'height': 1080}
|
||
|
||
context = await browser.new_context(**context_options)
|
||
|
||
# 设置当前Cookie
|
||
cookies = []
|
||
for cookie_pair in self.cookies_str.split('; '):
|
||
if '=' in cookie_pair:
|
||
name, value = cookie_pair.split('=', 1)
|
||
cookies.append({
|
||
'name': name.strip(),
|
||
'value': value.strip(),
|
||
'domain': '.goofish.com',
|
||
'path': '/'
|
||
})
|
||
|
||
await context.add_cookies(cookies)
|
||
logger.info(f"【{self.cookie_id}】已设置 {len(cookies)} 个Cookie到浏览器")
|
||
|
||
# 创建页面
|
||
page = await context.new_page()
|
||
|
||
# 等待页面准备
|
||
await asyncio.sleep(0.1)
|
||
|
||
# 访问指定页面
|
||
target_url = "https://www.goofish.com/im"
|
||
logger.info(f"【{self.cookie_id}】访问页面: {target_url}")
|
||
|
||
# 使用更灵活的页面访问策略
|
||
try:
|
||
# 首先尝试较短超时
|
||
await page.goto(target_url, wait_until='domcontentloaded', timeout=15000)
|
||
logger.info(f"【{self.cookie_id}】页面访问成功")
|
||
except Exception as e:
|
||
if 'timeout' in str(e).lower():
|
||
logger.warning(f"【{self.cookie_id}】页面访问超时,尝试降级策略...")
|
||
try:
|
||
# 降级策略:只等待基本加载
|
||
await page.goto(target_url, wait_until='load', timeout=20000)
|
||
logger.info(f"【{self.cookie_id}】页面访问成功(降级策略)")
|
||
except Exception as e2:
|
||
logger.warning(f"【{self.cookie_id}】降级策略也失败,尝试最基本访问...")
|
||
# 最后尝试:不等待任何加载完成
|
||
await page.goto(target_url, timeout=25000)
|
||
logger.info(f"【{self.cookie_id}】页面访问成功(最基本策略)")
|
||
else:
|
||
raise e
|
||
|
||
# Cookie刷新模式:执行两次刷新
|
||
logger.info(f"【{self.cookie_id}】页面加载完成,开始刷新...")
|
||
await asyncio.sleep(1)
|
||
|
||
# 第一次刷新 - 带重试机制
|
||
logger.info(f"【{self.cookie_id}】执行第一次刷新...")
|
||
try:
|
||
await page.reload(wait_until='domcontentloaded', timeout=12000)
|
||
logger.info(f"【{self.cookie_id}】第一次刷新成功")
|
||
except Exception as e:
|
||
if 'timeout' in str(e).lower():
|
||
logger.warning(f"【{self.cookie_id}】第一次刷新超时,使用降级策略...")
|
||
await page.reload(wait_until='load', timeout=15000)
|
||
logger.info(f"【{self.cookie_id}】第一次刷新成功(降级策略)")
|
||
else:
|
||
raise e
|
||
await asyncio.sleep(1)
|
||
|
||
# 第二次刷新 - 带重试机制
|
||
logger.info(f"【{self.cookie_id}】执行第二次刷新...")
|
||
try:
|
||
await page.reload(wait_until='domcontentloaded', timeout=12000)
|
||
logger.info(f"【{self.cookie_id}】第二次刷新成功")
|
||
except Exception as e:
|
||
if 'timeout' in str(e).lower():
|
||
logger.warning(f"【{self.cookie_id}】第二次刷新超时,使用降级策略...")
|
||
await page.reload(wait_until='load', timeout=15000)
|
||
logger.info(f"【{self.cookie_id}】第二次刷新成功(降级策略)")
|
||
else:
|
||
raise e
|
||
await asyncio.sleep(1)
|
||
|
||
# Cookie刷新模式:正常更新Cookie
|
||
logger.info(f"【{self.cookie_id}】获取更新后的Cookie...")
|
||
updated_cookies = await context.cookies()
|
||
|
||
# 获取并打印当前页面标题
|
||
page_title = await page.title()
|
||
logger.info(f"【{self.cookie_id}】当前页面标题: {page_title}")
|
||
|
||
# 构造新的Cookie字典
|
||
new_cookies_dict = {}
|
||
for cookie in updated_cookies:
|
||
new_cookies_dict[cookie['name']] = cookie['value']
|
||
|
||
# 检查Cookie变化
|
||
changed_cookies = []
|
||
new_cookies = []
|
||
for name, new_value in new_cookies_dict.items():
|
||
old_value = self.cookies.get(name)
|
||
if old_value is None:
|
||
new_cookies.append(name)
|
||
elif old_value != new_value:
|
||
changed_cookies.append(name)
|
||
|
||
# 更新self.cookies和cookies_str
|
||
self.cookies.update(new_cookies_dict)
|
||
self.cookies_str = '; '.join([f"{k}={v}" for k, v in self.cookies.items()])
|
||
|
||
logger.info(f"【{self.cookie_id}】Cookie已更新,包含 {len(new_cookies_dict)} 个字段")
|
||
|
||
# 显示Cookie变化统计
|
||
if changed_cookies:
|
||
logger.info(f"【{self.cookie_id}】发生变化的Cookie字段 ({len(changed_cookies)}个): {', '.join(changed_cookies)}")
|
||
if new_cookies:
|
||
logger.info(f"【{self.cookie_id}】新增的Cookie字段 ({len(new_cookies)}个): {', '.join(new_cookies)}")
|
||
if not changed_cookies and not new_cookies:
|
||
logger.info(f"【{self.cookie_id}】Cookie无变化")
|
||
|
||
logger.info(
|
||
f"【{self.cookie_id}】更新后Cookie字段名: "
|
||
f"{', '.join(sorted(new_cookies_dict.keys()))}"
|
||
)
|
||
|
||
# 只记录关键字段是否存在,不记录任何Cookie值或片段。
|
||
important_cookies = ['_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'unb', 'uc1', 'uc3', 'uc4']
|
||
present_important_cookies = [
|
||
cookie_name
|
||
for cookie_name in important_cookies
|
||
if cookie_name in new_cookies_dict
|
||
]
|
||
logger.info(
|
||
f"【{self.cookie_id}】关键Cookie字段检查: "
|
||
f"{', '.join(present_important_cookies) or '无'}"
|
||
)
|
||
|
||
# 更新数据库中的Cookie
|
||
await self.update_config_cookies()
|
||
|
||
# 只有当由refresh_token触发时才设置浏览器Cookie刷新成功标志
|
||
if triggered_by_refresh_token:
|
||
self.browser_cookie_refreshed = True
|
||
logger.info(f"【{self.cookie_id}】由refresh_token触发,浏览器Cookie刷新成功标志已设置为True")
|
||
|
||
# 兜底:直接在此处触发实例重启,避免外层协程在返回后被取消导致未重启
|
||
try:
|
||
# 标记"刷新流程内已触发重启",供外层去重
|
||
self.restarted_in_browser_refresh = True
|
||
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新成功,准备重启实例...(via _refresh_cookies_via_browser)")
|
||
await self._restart_instance()
|
||
|
||
# ⚠️ _restart_instance() 已触发重启,当前任务即将被取消
|
||
# 不要等待或执行耗时操作
|
||
logger.info(f"【{self.cookie_id}】重启请求已触发(via _refresh_cookies_via_browser)")
|
||
|
||
# 标记重启标志(无需主动关闭WS,重启由管理器处理)
|
||
self.connection_restart_flag = True
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】兜底重启失败: {self._safe_str(e)}")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】由定时任务触发,不设置浏览器Cookie刷新成功标志")
|
||
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新完成")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】通过浏览器刷新Cookie失败: {self._safe_str(e)}")
|
||
return False
|
||
finally:
|
||
# 异步关闭浏览器:创建清理任务并等待完成,确保资源正确释放
|
||
close_task = None
|
||
try:
|
||
if browser or playwright:
|
||
# 创建关闭任务
|
||
close_task = asyncio.create_task(
|
||
self._async_close_browser(browser, playwright)
|
||
)
|
||
logger.info(f"【{self.cookie_id}】浏览器异步关闭任务已启动")
|
||
|
||
# 等待关闭任务完成,但设置超时避免阻塞太久
|
||
try:
|
||
await asyncio.wait_for(close_task, timeout=15.0)
|
||
logger.info(f"【{self.cookie_id}】浏览器关闭任务已完成")
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{self.cookie_id}】浏览器关闭任务超时(15秒),强制继续")
|
||
# 取消任务,避免资源泄漏
|
||
if not close_task.done():
|
||
close_task.cancel()
|
||
try:
|
||
await close_task
|
||
except (asyncio.CancelledError, Exception):
|
||
pass
|
||
except Exception as wait_e:
|
||
logger.warning(f"【{self.cookie_id}】等待浏览器关闭任务时出错: {self._safe_str(wait_e)}")
|
||
# 确保任务被取消
|
||
if close_task and not close_task.done():
|
||
close_task.cancel()
|
||
try:
|
||
await close_task
|
||
except (asyncio.CancelledError, Exception):
|
||
pass
|
||
except Exception as cleanup_e:
|
||
logger.warning(f"【{self.cookie_id}】创建浏览器关闭任务时出错: {self._safe_str(cleanup_e)}")
|
||
# 如果创建任务失败,尝试直接关闭
|
||
if browser or playwright:
|
||
try:
|
||
await self._force_close_resources(browser, playwright)
|
||
except Exception:
|
||
pass
|
||
|
||
async def _async_close_browser(self, browser, playwright):
|
||
"""异步关闭:正常关闭,超时后强制关闭"""
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】开始异步关闭浏览器...") # 改为info级别
|
||
|
||
# 正常关闭,设置超时
|
||
await asyncio.wait_for(
|
||
self._normal_close_resources(browser, playwright),
|
||
timeout=10.0
|
||
)
|
||
logger.info(f"【{self.cookie_id}】浏览器正常关闭完成") # 改为info级别
|
||
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{self.cookie_id}】正常关闭超时,开始强制关闭...")
|
||
await self._force_close_resources(browser, playwright)
|
||
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】异步关闭时出错,强制关闭: {self._safe_str(e)}")
|
||
await self._force_close_resources(browser, playwright)
|
||
|
||
async def _normal_close_resources(self, browser, playwright):
|
||
"""正常关闭资源:浏览器+Playwright短超时关闭"""
|
||
try:
|
||
# 先关闭浏览器,再关闭Playwright
|
||
if browser:
|
||
try:
|
||
# 关闭浏览器,设置超时
|
||
await asyncio.wait_for(browser.close(), timeout=5.0)
|
||
logger.info(f"【{self.cookie_id}】浏览器关闭完成")
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{self.cookie_id}】浏览器关闭超时,尝试强制关闭")
|
||
try:
|
||
# 尝试强制关闭
|
||
if hasattr(browser, '_connection'):
|
||
browser._connection.dispose()
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】关闭浏览器时出错: {e}")
|
||
|
||
# 关闭Playwright:使用短超时,如果超时就放弃
|
||
if playwright:
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】正在关闭Playwright...")
|
||
# 增加超时时间,确保Playwright有足够时间清理资源
|
||
await asyncio.wait_for(playwright.stop(), timeout=5.0)
|
||
logger.info(f"【{self.cookie_id}】Playwright关闭完成")
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{self.cookie_id}】Playwright关闭超时,将自动清理")
|
||
# 尝试强制清理Playwright的内部连接
|
||
try:
|
||
if hasattr(playwright, '_connection'):
|
||
playwright._connection.dispose()
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】关闭Playwright时出错: {e}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】正常关闭时出现异常: {e}")
|
||
raise
|
||
|
||
|
||
async def _force_close_resources(self, browser, playwright):
|
||
"""强制关闭资源:强制关闭浏览器+Playwright超时等待"""
|
||
try:
|
||
logger.warning(f"【{self.cookie_id}】开始强制关闭资源...")
|
||
|
||
# 强制关闭浏览器+Playwright,设置短超时
|
||
force_tasks = []
|
||
if browser:
|
||
force_tasks.append(asyncio.wait_for(browser.close(), timeout=3.0))
|
||
if playwright:
|
||
force_tasks.append(asyncio.wait_for(playwright.stop(), timeout=3.0))
|
||
|
||
if force_tasks:
|
||
# 使用gather执行,所有失败都会被忽略
|
||
results = await asyncio.gather(*force_tasks, return_exceptions=True)
|
||
|
||
# 检查是否有超时或异常,尝试强制清理
|
||
for i, result in enumerate(results):
|
||
if isinstance(result, (asyncio.TimeoutError, Exception)):
|
||
resource_name = "浏览器" if i == 0 and browser else "Playwright"
|
||
logger.warning(f"【{self.cookie_id}】{resource_name}强制关闭失败,尝试直接清理连接")
|
||
try:
|
||
if i == 0 and browser and hasattr(browser, '_connection'):
|
||
browser._connection.dispose()
|
||
elif playwright and hasattr(playwright, '_connection'):
|
||
playwright._connection.dispose()
|
||
except Exception:
|
||
pass
|
||
|
||
logger.info(f"【{self.cookie_id}】强制关闭完成")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】没有需要强制关闭的资源")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】强制关闭时出现异常(已忽略): {e}")
|
||
|
||
async def send_msg_once(self, toid, item_id, text):
|
||
headers = {
|
||
"Cookie": self.cookies_str,
|
||
"Host": "wss-goofish.dingtalk.com",
|
||
"Connection": "Upgrade",
|
||
"Pragma": "no-cache",
|
||
"Cache-Control": "no-cache",
|
||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
||
"Origin": "https://www.goofish.com",
|
||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||
}
|
||
# 兼容不同版本的websockets库
|
||
try:
|
||
async with websockets.connect(
|
||
self.base_url,
|
||
extra_headers=headers
|
||
) as websocket:
|
||
await self._handle_websocket_connection(websocket, toid, item_id, text)
|
||
except TypeError as e:
|
||
# 安全地检查异常信息
|
||
error_msg = self._safe_str(e)
|
||
|
||
if "extra_headers" in error_msg:
|
||
logger.warning("websockets库不支持extra_headers参数,使用兼容模式")
|
||
# 使用兼容模式,通过subprotocols传递部分头信息
|
||
async with websockets.connect(
|
||
self.base_url,
|
||
additional_headers=headers
|
||
) as websocket:
|
||
await self._handle_websocket_connection(websocket, toid, item_id, text)
|
||
else:
|
||
raise
|
||
|
||
async def _create_websocket_connection(self, headers):
|
||
"""创建WebSocket连接,兼容不同版本的websockets库"""
|
||
import websockets
|
||
|
||
# 获取websockets版本用于调试
|
||
websockets_version = getattr(websockets, '__version__', '未知')
|
||
logger.warning(f"websockets库版本: {websockets_version}")
|
||
|
||
try:
|
||
# 尝试使用extra_headers参数
|
||
return websockets.connect(
|
||
self.base_url,
|
||
extra_headers=headers
|
||
)
|
||
except Exception as e:
|
||
# 捕获所有异常类型,不仅仅是TypeError
|
||
error_msg = self._safe_str(e)
|
||
logger.warning(f"extra_headers参数失败: {error_msg}")
|
||
|
||
if "extra_headers" in error_msg or "unexpected keyword argument" in error_msg:
|
||
logger.warning("websockets库不支持extra_headers参数,尝试additional_headers")
|
||
# 使用additional_headers参数(较新版本)
|
||
try:
|
||
return websockets.connect(
|
||
self.base_url,
|
||
additional_headers=headers
|
||
)
|
||
except Exception as e2:
|
||
error_msg2 = self._safe_str(e2)
|
||
logger.warning(f"additional_headers参数失败: {error_msg2}")
|
||
|
||
if "additional_headers" in error_msg2 or "unexpected keyword argument" in error_msg2:
|
||
# 如果都不支持,则不传递headers
|
||
logger.warning("websockets库不支持headers参数,使用基础连接模式")
|
||
return websockets.connect(self.base_url)
|
||
else:
|
||
raise e2
|
||
else:
|
||
raise e
|
||
|
||
async def _handle_websocket_connection(self, websocket, toid, item_id, text):
|
||
"""处理WebSocket连接的具体逻辑"""
|
||
await self.init(websocket)
|
||
await self.create_chat(websocket, toid, item_id)
|
||
async for message in websocket:
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】message: {message}")
|
||
message = json.loads(message)
|
||
cid = message["body"]["singleChatConversation"]["cid"]
|
||
cid = cid.split('@')[0]
|
||
await self.send_msg(websocket, cid, toid, text)
|
||
logger.info(f'【{self.cookie_id}】send message')
|
||
return
|
||
except Exception as e:
|
||
pass
|
||
|
||
def is_chat_message(self, message):
|
||
"""判断是否为用户聊天消息"""
|
||
try:
|
||
return (
|
||
isinstance(message, dict)
|
||
and "1" in message
|
||
and isinstance(message["1"], dict)
|
||
and "10" in message["1"]
|
||
and isinstance(message["1"]["10"], dict)
|
||
and "reminderContent" in message["1"]["10"]
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
def is_sync_package(self, message_data):
|
||
"""判断是否为同步包消息"""
|
||
try:
|
||
return (
|
||
isinstance(message_data, dict)
|
||
and "body" in message_data
|
||
and "syncPushPackage" in message_data["body"]
|
||
and "data" in message_data["body"]["syncPushPackage"]
|
||
and len(message_data["body"]["syncPushPackage"]["data"]) > 0
|
||
)
|
||
except Exception:
|
||
return False
|
||
|
||
async def create_session(self):
|
||
"""创建aiohttp session"""
|
||
if not self.session:
|
||
# 创建带有cookies和headers的session
|
||
headers = DEFAULT_HEADERS.copy()
|
||
headers['cookie'] = self.cookies_str
|
||
|
||
self.session = aiohttp.ClientSession(
|
||
headers=headers,
|
||
timeout=aiohttp.ClientTimeout(total=30)
|
||
)
|
||
|
||
async def close_session(self):
|
||
"""关闭aiohttp session"""
|
||
if self.session:
|
||
await self.session.close()
|
||
self.session = None
|
||
|
||
async def get_api_reply(self, msg_time, user_url, send_user_id, send_user_name, item_id, send_message, chat_id):
|
||
"""调用API获取回复消息"""
|
||
try:
|
||
if not self.session:
|
||
await self.create_session()
|
||
|
||
api_config = AUTO_REPLY.get('api', {})
|
||
timeout = aiohttp.ClientTimeout(total=api_config.get('timeout', 10))
|
||
|
||
payload = {
|
||
"cookie_id": self.cookie_id,
|
||
"msg_time": msg_time,
|
||
"user_url": user_url,
|
||
"send_user_id": send_user_id,
|
||
"send_user_name": send_user_name,
|
||
"item_id": item_id,
|
||
"send_message": send_message,
|
||
"chat_id": chat_id
|
||
}
|
||
|
||
async with self.session.post(
|
||
api_config.get('url', 'http://localhost:8080/xianyu/reply'),
|
||
json=payload,
|
||
timeout=timeout
|
||
) as response:
|
||
result = await response.json()
|
||
|
||
# 将code转换为字符串进行比较,或者直接用数字比较
|
||
if str(result.get('code')) == '200' or result.get('code') == 200:
|
||
send_msg = result.get('data', {}).get('send_msg')
|
||
if send_msg:
|
||
# 格式化消息中的占位符
|
||
return send_msg.format(
|
||
send_user_id=payload['send_user_id'],
|
||
send_user_name=payload['send_user_name'],
|
||
send_message=payload['send_message']
|
||
)
|
||
else:
|
||
logger.warning("API返回成功但无回复消息")
|
||
return None
|
||
else:
|
||
logger.warning(f"API返回错误: {result.get('msg', '未知错误')}")
|
||
return None
|
||
|
||
except asyncio.TimeoutError:
|
||
logger.error("API调用超时")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"调用API出错: {self._safe_str(e)}")
|
||
return None
|
||
|
||
async def _handle_message_with_semaphore(self, message_data, websocket):
|
||
"""带信号量的消息处理包装器,防止并发任务过多"""
|
||
async with self.message_semaphore:
|
||
self.active_message_tasks += 1
|
||
try:
|
||
await self.handle_message(message_data, websocket)
|
||
finally:
|
||
self.active_message_tasks -= 1
|
||
# 定期记录活跃任务数(每100个任务记录一次)
|
||
if self.active_message_tasks % 100 == 0 and self.active_message_tasks > 0:
|
||
logger.info(f"【{self.cookie_id}】当前活跃消息处理任务数: {self.active_message_tasks}")
|
||
|
||
def _extract_message_id(self, message_data: dict) -> str:
|
||
"""
|
||
从消息数据中提取消息ID,用于去重
|
||
|
||
Args:
|
||
message_data: 原始消息数据
|
||
|
||
Returns:
|
||
消息ID字符串,如果无法提取则返回None
|
||
"""
|
||
try:
|
||
# 尝试从 message['1']['10']['bizTag'] 中提取 messageId
|
||
if isinstance(message_data, dict) and "1" in message_data:
|
||
message_1 = message_data.get("1")
|
||
if isinstance(message_1, dict) and "10" in message_1:
|
||
message_10 = message_1.get("10")
|
||
if isinstance(message_10, dict) and "bizTag" in message_10:
|
||
biz_tag = message_10.get("bizTag", "")
|
||
if isinstance(biz_tag, str):
|
||
# bizTag 是 JSON 字符串,格式如: '{"sourceId":"S:1","messageId":"984f323c719d4cd0a7b993a0769a33b6"}'
|
||
try:
|
||
import json
|
||
biz_tag_dict = json.loads(biz_tag)
|
||
if isinstance(biz_tag_dict, dict) and "messageId" in biz_tag_dict:
|
||
return biz_tag_dict.get("messageId")
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
|
||
# 如果 bizTag 解析失败,尝试从 extJson 中提取
|
||
if "extJson" in message_10:
|
||
ext_json = message_10.get("extJson", "")
|
||
if isinstance(ext_json, str):
|
||
try:
|
||
import json
|
||
ext_json_dict = json.loads(ext_json)
|
||
if isinstance(ext_json_dict, dict) and "messageId" in ext_json_dict:
|
||
return ext_json_dict.get("messageId")
|
||
except (json.JSONDecodeError, TypeError):
|
||
pass
|
||
except Exception as e:
|
||
logger.debug(f"【{self.cookie_id}】提取消息ID失败: {self._safe_str(e)}")
|
||
|
||
return None
|
||
|
||
def _add_reply_decision_log(self, message_data: dict, **fields):
|
||
try:
|
||
from app.db_manager import db_manager
|
||
return db_manager.add_auto_reply_log(
|
||
cookie_id=self.cookie_id,
|
||
source_message_id=self._extract_message_id(message_data),
|
||
**fields,
|
||
)
|
||
except Exception as error:
|
||
logger.error(f"【{self.cookie_id}】记录自动回复决策失败: {self._safe_str(error)}")
|
||
return None
|
||
|
||
def _update_reply_decision_log(self, log_id, **fields):
|
||
if not log_id:
|
||
return
|
||
try:
|
||
from app.db_manager import db_manager
|
||
db_manager.update_auto_reply_log(log_id, **fields)
|
||
except Exception as error:
|
||
logger.error(f"【{self.cookie_id}】更新自动回复决策失败: {self._safe_str(error)}")
|
||
|
||
def _find_reply_keyword(self, message: str, item_id: str = None):
|
||
try:
|
||
from app.db_manager import db_manager
|
||
keywords = db_manager.get_keywords_with_type(self.cookie_id) or []
|
||
normalized_message = (message or "").casefold()
|
||
for item_only in (True, False):
|
||
for keyword_data in keywords:
|
||
keyword_item_id = keyword_data.get("item_id")
|
||
if item_only and keyword_item_id != item_id:
|
||
continue
|
||
if not item_only and keyword_item_id:
|
||
continue
|
||
keyword = (keyword_data.get("keyword") or "").strip()
|
||
if keyword and keyword.casefold() in normalized_message:
|
||
return keyword
|
||
except Exception as error:
|
||
logger.debug(f"【{self.cookie_id}】读取命中关键词失败: {self._safe_str(error)}")
|
||
return None
|
||
|
||
async def _schedule_debounced_reply(self, chat_id: str, message_data: dict, websocket,
|
||
send_user_name: str, send_user_id: str, send_message: str,
|
||
item_id: str, msg_time: str):
|
||
"""
|
||
调度防抖回复:如果用户连续发送消息,等待用户停止发送后再回复最后一条消息
|
||
|
||
Args:
|
||
chat_id: 聊天ID
|
||
message_data: 原始消息数据
|
||
websocket: WebSocket连接
|
||
send_user_name: 发送者用户名
|
||
send_user_id: 发送者用户ID
|
||
send_message: 消息内容
|
||
item_id: 商品ID
|
||
msg_time: 消息时间
|
||
"""
|
||
# 提取消息ID并检查是否已处理
|
||
message_id = self._extract_message_id(message_data)
|
||
# 如果没有 messageId,使用备用标识(chat_id + send_message + 时间戳)
|
||
if not message_id:
|
||
try:
|
||
# 尝试从消息数据中提取时间戳
|
||
create_time = 0
|
||
if isinstance(message_data, dict) and "1" in message_data:
|
||
message_1 = message_data.get("1")
|
||
if isinstance(message_1, dict):
|
||
create_time = message_1.get("5", 0)
|
||
# 使用组合键作为备用标识
|
||
message_id = f"{chat_id}_{send_message}_{create_time}"
|
||
except Exception:
|
||
# 如果提取失败,使用当前时间戳
|
||
message_id = f"{chat_id}_{send_message}_{int(time.time() * 1000)}"
|
||
|
||
async with self.processed_message_ids_lock:
|
||
current_time = time.time()
|
||
|
||
# 检查消息是否已处理且未过期
|
||
if message_id in self.processed_message_ids:
|
||
last_process_time = self.processed_message_ids[message_id]
|
||
time_elapsed = current_time - last_process_time
|
||
|
||
# 如果消息处理时间未超过1小时,跳过
|
||
if time_elapsed < self.message_expire_time:
|
||
remaining_time = int(self.message_expire_time - time_elapsed)
|
||
logger.warning(f"【{self.cookie_id}】消息ID {message_id[:50]}... 已处理过,距离可重复回复还需 {remaining_time} 秒")
|
||
return
|
||
else:
|
||
# 超过1小时,可以重新处理
|
||
logger.info(f"【{self.cookie_id}】消息ID {message_id[:50]}... 已超过 {int(time_elapsed/60)} 分钟,允许重新回复")
|
||
|
||
# 标记消息ID为已处理(更新或添加时间戳)
|
||
self.processed_message_ids[message_id] = current_time
|
||
|
||
# 定期清理过期的消息ID
|
||
if len(self.processed_message_ids) > self.processed_message_ids_max_size:
|
||
# 清理超过1小时的旧记录
|
||
expired_ids = [
|
||
msg_id for msg_id, timestamp in self.processed_message_ids.items()
|
||
if current_time - timestamp > self.message_expire_time
|
||
]
|
||
|
||
for msg_id in expired_ids:
|
||
del self.processed_message_ids[msg_id]
|
||
|
||
logger.info(f"【{self.cookie_id}】已清理 {len(expired_ids)} 个过期消息ID")
|
||
|
||
# 如果清理后仍然过大,删除最旧的一半
|
||
if len(self.processed_message_ids) > self.processed_message_ids_max_size:
|
||
sorted_ids = sorted(self.processed_message_ids.items(), key=lambda x: x[1])
|
||
remove_count = len(sorted_ids) // 2
|
||
for msg_id, _ in sorted_ids[:remove_count]:
|
||
del self.processed_message_ids[msg_id]
|
||
logger.info(f"【{self.cookie_id}】消息ID去重字典过大,已清理 {remove_count} 个最旧记录")
|
||
|
||
async with self.message_debounce_lock:
|
||
# 如果该chat_id已有防抖任务,取消它
|
||
if chat_id in self.message_debounce_tasks:
|
||
old_task = self.message_debounce_tasks[chat_id].get('task')
|
||
if old_task and not old_task.done():
|
||
old_task.cancel()
|
||
logger.warning(f"【{self.cookie_id}】取消chat_id {chat_id} 的旧防抖任务")
|
||
|
||
# 更新最后一条消息信息
|
||
current_timer = time.time()
|
||
self.message_debounce_tasks[chat_id] = {
|
||
'last_message': {
|
||
'message_data': message_data,
|
||
'websocket': websocket,
|
||
'send_user_name': send_user_name,
|
||
'send_user_id': send_user_id,
|
||
'send_message': send_message,
|
||
'item_id': item_id,
|
||
'msg_time': msg_time
|
||
},
|
||
'timer': current_timer
|
||
}
|
||
|
||
# 创建新的防抖任务
|
||
async def debounce_task():
|
||
saved_timer = current_timer # 保存创建任务时的时间戳
|
||
try:
|
||
# 等待防抖延迟时间
|
||
await asyncio.sleep(self.message_debounce_delay)
|
||
|
||
# 检查是否仍然是最新的消息(防止在等待期间有新消息)
|
||
async with self.message_debounce_lock:
|
||
if chat_id not in self.message_debounce_tasks:
|
||
return
|
||
|
||
debounce_info = self.message_debounce_tasks[chat_id]
|
||
# 检查时间戳是否匹配(确保这是最新的消息)
|
||
if saved_timer != debounce_info['timer']:
|
||
logger.warning(f"【{self.cookie_id}】chat_id {chat_id} 在防抖期间有新消息,跳过旧消息处理")
|
||
return
|
||
|
||
# 获取最后一条消息
|
||
last_msg = debounce_info['last_message']
|
||
|
||
# 从防抖任务中移除
|
||
del self.message_debounce_tasks[chat_id]
|
||
|
||
# 处理最后一条消息
|
||
logger.info(f"【{self.cookie_id}】防抖延迟结束,开始处理chat_id {chat_id} 的最后一条消息: {last_msg['send_message'][:30]}...")
|
||
await self._process_chat_message_reply(
|
||
last_msg['message_data'],
|
||
last_msg['websocket'],
|
||
last_msg['send_user_name'],
|
||
last_msg['send_user_id'],
|
||
last_msg['send_message'],
|
||
last_msg['item_id'],
|
||
chat_id,
|
||
last_msg['msg_time']
|
||
)
|
||
|
||
except asyncio.CancelledError:
|
||
logger.warning(f"【{self.cookie_id}】chat_id {chat_id} 的防抖任务被取消")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】处理防抖回复时发生错误: {self._safe_str(e)}")
|
||
# 确保从防抖任务中移除
|
||
async with self.message_debounce_lock:
|
||
if chat_id in self.message_debounce_tasks:
|
||
del self.message_debounce_tasks[chat_id]
|
||
|
||
task = self._create_tracked_task(debounce_task())
|
||
self.message_debounce_tasks[chat_id]['task'] = task
|
||
logger.warning(f"【{self.cookie_id}】为chat_id {chat_id} 创建防抖任务,延迟 {self.message_debounce_delay} 秒")
|
||
|
||
async def _process_chat_message_reply(self, message_data: dict, websocket, send_user_name: str,
|
||
send_user_id: str, send_message: str, item_id: str,
|
||
chat_id: str, msg_time: str):
|
||
"""
|
||
处理聊天消息的回复逻辑(从handle_message中提取出来的核心回复逻辑)
|
||
|
||
Args:
|
||
message_data: 原始消息数据
|
||
websocket: WebSocket连接
|
||
send_user_name: 发送者用户名
|
||
send_user_id: 发送者用户ID
|
||
send_message: 消息内容
|
||
item_id: 商品ID
|
||
chat_id: 聊天ID
|
||
msg_time: 消息时间
|
||
"""
|
||
log_id = None
|
||
reply_send_failed = False
|
||
log_context = {
|
||
"chat_id": chat_id,
|
||
"item_id": item_id,
|
||
"sender_user_id": send_user_id,
|
||
"sender_user_name": send_user_name,
|
||
"source_message": send_message,
|
||
}
|
||
try:
|
||
|
||
from app.db_manager import db_manager
|
||
matched_filter = db_manager.matches_message_filter(
|
||
self.cookie_id, send_message, "skip_reply"
|
||
)
|
||
if matched_filter:
|
||
self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="skipped",
|
||
decision_reason="skip_reply_filter",
|
||
reply_strategy="none",
|
||
matched_keyword=matched_filter,
|
||
send_status="unknown",
|
||
)
|
||
logger.info(
|
||
f"[{msg_time}] 【{self.cookie_id}】消息命中过滤规则“{matched_filter}”,跳过自动回复"
|
||
)
|
||
return
|
||
|
||
# 自动回复消息
|
||
if not AUTO_REPLY.get('enabled', True):
|
||
self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="skipped",
|
||
decision_reason="auto_reply_disabled",
|
||
reply_strategy="none",
|
||
send_status="unknown",
|
||
)
|
||
logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】自动回复已禁用")
|
||
return
|
||
|
||
# 检查该chat_id是否处于暂停状态
|
||
if pause_manager.is_chat_paused(chat_id, self.cookie_id):
|
||
remaining_time = pause_manager.get_remaining_pause_time(chat_id, self.cookie_id)
|
||
remaining_minutes = remaining_time // 60
|
||
remaining_seconds = remaining_time % 60
|
||
self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="skipped",
|
||
decision_reason="chat_paused",
|
||
reply_strategy="none",
|
||
send_status="unknown",
|
||
)
|
||
logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】chat_id {chat_id} 自动回复已暂停,剩余时间: {remaining_minutes}分{remaining_seconds}秒")
|
||
return
|
||
|
||
# 构造用户URL
|
||
user_url = f'https://www.goofish.com/personal?userId={send_user_id}'
|
||
|
||
reply = None
|
||
reply_strategy = "none"
|
||
matched_keyword = None
|
||
# 判断是否启用API回复
|
||
if AUTO_REPLY.get('api', {}).get('enabled', False):
|
||
reply = await self.get_api_reply(
|
||
msg_time, user_url, send_user_id, send_user_name,
|
||
item_id, send_message, chat_id
|
||
)
|
||
if reply:
|
||
reply_strategy = "api"
|
||
if not reply:
|
||
logger.error(f"[{msg_time}] 【API调用失败】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {send_message}")
|
||
|
||
# 记录回复来源
|
||
reply_source = 'API' # 默认假设是API回复
|
||
|
||
# 如果API回复失败或未启用API,按新的优先级顺序处理
|
||
if not reply:
|
||
# 1. 首先尝试关键词匹配(传入商品ID)
|
||
reply = await self.get_keyword_reply(send_user_name, send_user_id, send_message, item_id)
|
||
if reply == "EMPTY_REPLY":
|
||
# 匹配到关键词但回复内容为空,不进行任何回复
|
||
matched_keyword = self._find_reply_keyword(send_message, item_id)
|
||
self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="skipped",
|
||
decision_reason="empty_reply",
|
||
reply_strategy="keyword",
|
||
matched_keyword=matched_keyword,
|
||
send_status="unknown",
|
||
)
|
||
logger.info(f"[{msg_time}] 【{self.cookie_id}】匹配到空回复关键词,跳过自动回复")
|
||
return
|
||
elif reply:
|
||
reply_source = '关键词' # 标记为关键词回复
|
||
reply_strategy = "keyword"
|
||
matched_keyword = self._find_reply_keyword(send_message, item_id)
|
||
else:
|
||
# 2. 关键词匹配失败,如果AI开关打开,尝试AI回复
|
||
reply = await self.get_ai_reply(send_user_name, send_user_id, send_message, item_id, chat_id)
|
||
if reply:
|
||
reply_source = 'AI' # 标记为AI回复
|
||
reply_strategy = "ai"
|
||
else:
|
||
# 3. 最后使用默认回复
|
||
default_reply_result = await self.get_default_reply(send_user_name, send_user_id, send_message, chat_id, item_id)
|
||
if default_reply_result == "EMPTY_REPLY":
|
||
# 默认回复内容为空,不进行任何回复
|
||
self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="skipped",
|
||
decision_reason="empty_reply",
|
||
reply_strategy="default",
|
||
send_status="unknown",
|
||
)
|
||
logger.info(f"[{msg_time}] 【{self.cookie_id}】默认回复内容为空,跳过自动回复")
|
||
return
|
||
|
||
# 处理默认回复(可能包含图片和文字)
|
||
if default_reply_result and isinstance(default_reply_result, dict):
|
||
reply_source = '默认' # 标记为默认回复
|
||
reply_strategy = "default"
|
||
default_image_url = default_reply_result.get('image_url')
|
||
default_text = default_reply_result.get('text')
|
||
|
||
# 如果存在图片,先发送图片
|
||
if default_image_url:
|
||
try:
|
||
# 处理图片URL(上传到CDN如果需要)
|
||
final_image_url = default_image_url
|
||
image_width, image_height = 800, 600 # 默认尺寸
|
||
|
||
if self._is_cdn_url(default_image_url):
|
||
# 已经是CDN链接,获取真实尺寸
|
||
logger.info(f"【{self.cookie_id}】默认回复使用CDN图片: {default_image_url}")
|
||
width, height = await self._get_image_size_from_url(default_image_url)
|
||
if width and height:
|
||
image_width, image_height = width, height
|
||
elif default_image_url.startswith('/static/uploads/') or default_image_url.startswith('static/uploads/'):
|
||
# 本地图片,需要上传到闲鱼CDN
|
||
local_image_path = default_image_url.replace('/static/uploads/', 'static/uploads/')
|
||
if os.path.exists(local_image_path):
|
||
logger.info(f"【{self.cookie_id}】准备上传默认回复本地图片到闲鱼CDN: {local_image_path}")
|
||
|
||
from utils.image_uploader import ImageUploader
|
||
uploader = ImageUploader(self.cookies_str)
|
||
|
||
async with uploader:
|
||
cdn_url = await uploader.upload_image(local_image_path)
|
||
if cdn_url:
|
||
logger.info(f"【{self.cookie_id}】默认回复图片上传成功,CDN URL: {cdn_url}")
|
||
final_image_url = cdn_url
|
||
|
||
# 更新数据库中的图片URL为CDN URL
|
||
await self._update_default_reply_image_url(cdn_url)
|
||
|
||
# 获取实际图片尺寸
|
||
from utils.image_utils import image_manager
|
||
try:
|
||
actual_width, actual_height = image_manager.get_image_size(local_image_path)
|
||
if actual_width and actual_height:
|
||
image_width, image_height = actual_width, actual_height
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】获取图片尺寸失败,使用默认尺寸: {e}")
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】默认回复图片上传失败: {local_image_path}")
|
||
final_image_url = None
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】默认回复本地图片文件不存在: {local_image_path}")
|
||
final_image_url = None
|
||
else:
|
||
# 其他类型的URL,获取真实尺寸
|
||
width, height = await self._get_image_size_from_url(default_image_url)
|
||
if width and height:
|
||
image_width, image_height = width, height
|
||
|
||
# 发送图片
|
||
if final_image_url:
|
||
log_id = self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="success",
|
||
decision_reason="reply_selected",
|
||
reply_strategy=reply_strategy,
|
||
reply_text=default_text or final_image_url,
|
||
send_status="unknown",
|
||
)
|
||
await self.send_image_msg(websocket, chat_id, send_user_id, final_image_url, image_width, image_height)
|
||
self._update_reply_decision_log(
|
||
log_id,
|
||
decision_reason="reply_sent",
|
||
send_status="success",
|
||
)
|
||
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||
logger.info(f"[{msg_time}] 【{reply_source}图片发出】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): 图片 {final_image_url}")
|
||
else:
|
||
reply_send_failed = True
|
||
log_id = self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="failed",
|
||
decision_reason="send_failed",
|
||
reply_strategy=reply_strategy,
|
||
reply_text=default_text or default_image_url,
|
||
error_message="默认回复图片不可用或上传失败",
|
||
send_status="failed",
|
||
)
|
||
except Exception as e:
|
||
reply_send_failed = True
|
||
self._update_reply_decision_log(
|
||
log_id,
|
||
process_status="failed",
|
||
decision_reason="send_failed",
|
||
error_message=self._safe_str(e),
|
||
send_status="failed",
|
||
)
|
||
logger.error(f"【{self.cookie_id}】默认回复图片发送失败: {self._safe_str(e)}")
|
||
|
||
# 然后发送文字(如果有)
|
||
if default_text and default_text.strip():
|
||
reply = default_text
|
||
else:
|
||
# 只有图片没有文字,已经发送完毕
|
||
if default_image_url:
|
||
return
|
||
reply = None
|
||
else:
|
||
reply = None
|
||
|
||
# 注意:这里只有商品ID,没有标题和详情,根据新的规则不保存到数据库
|
||
# 商品信息会在其他有完整信息的地方保存(如发货规则匹配时)
|
||
# 消息通知已在收到消息时立即发送,此处不再重复发送
|
||
|
||
# 如果有回复内容,发送消息
|
||
if reply:
|
||
if not log_id:
|
||
log_id = self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="success",
|
||
decision_reason="reply_selected",
|
||
reply_strategy=reply_strategy,
|
||
matched_keyword=matched_keyword,
|
||
reply_text=reply,
|
||
send_status="unknown",
|
||
)
|
||
# 检查是否是图片发送标记
|
||
if reply.startswith("__IMAGE_SEND__"):
|
||
# 提取图片URL(关键词回复不包含卡券ID)
|
||
image_url = reply.replace("__IMAGE_SEND__", "")
|
||
# 发送图片消息
|
||
try:
|
||
await self.send_image_msg(websocket, chat_id, send_user_id, image_url)
|
||
self._update_reply_decision_log(
|
||
log_id,
|
||
decision_reason="reply_sent",
|
||
send_status="success",
|
||
)
|
||
# 记录发出的图片消息
|
||
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||
logger.info(f"[{msg_time}] 【{reply_source}图片发出】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): 图片 {image_url}")
|
||
except Exception as e:
|
||
# 图片发送失败,发送错误提示
|
||
self._update_reply_decision_log(
|
||
log_id,
|
||
process_status="failed",
|
||
decision_reason="send_failed",
|
||
error_message=self._safe_str(e),
|
||
send_status="failed",
|
||
)
|
||
logger.error(f"图片发送失败: {self._safe_str(e)}")
|
||
await self.send_msg(websocket, chat_id, send_user_id, "抱歉,图片发送失败,请稍后重试。")
|
||
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||
logger.error(f"[{msg_time}] 【{reply_source}图片发送失败】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id})")
|
||
else:
|
||
# 普通文本消息
|
||
await self.send_msg(websocket, chat_id, send_user_id, reply)
|
||
if not reply_send_failed:
|
||
self._update_reply_decision_log(
|
||
log_id,
|
||
decision_reason="reply_sent",
|
||
send_status="success",
|
||
)
|
||
# 记录发出的消息
|
||
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||
logger.info(f"[{msg_time}] 【{reply_source}发出】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {reply}")
|
||
else:
|
||
self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="skipped",
|
||
decision_reason="no_rule_matched",
|
||
reply_strategy="none",
|
||
send_status="unknown",
|
||
)
|
||
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||
logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】未找到匹配的回复规则,不回复")
|
||
except Exception as e:
|
||
error_message = self._safe_str(e)
|
||
if log_id:
|
||
self._update_reply_decision_log(
|
||
log_id,
|
||
process_status="failed",
|
||
decision_reason="failed",
|
||
error_message=error_message,
|
||
send_status="failed",
|
||
)
|
||
else:
|
||
self._add_reply_decision_log(
|
||
message_data,
|
||
**log_context,
|
||
process_status="failed",
|
||
decision_reason="failed",
|
||
reply_strategy="none",
|
||
error_message=error_message,
|
||
send_status="failed",
|
||
)
|
||
logger.error(f"处理聊天消息回复时发生错误: {self._safe_str(e)}")
|
||
|
||
async def handle_message(self, message_data, websocket):
|
||
"""处理所有类型的消息"""
|
||
try:
|
||
# 检查账号是否启用
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.warning(f"【{self.cookie_id}】账号已禁用,跳过消息处理")
|
||
return
|
||
|
||
# 发送确认消息
|
||
try:
|
||
message = message_data
|
||
ack = {
|
||
"code": 200,
|
||
"headers": {
|
||
"mid": message["headers"]["mid"] if "mid" in message["headers"] else generate_mid(),
|
||
"sid": message["headers"]["sid"] if "sid" in message["headers"] else '',
|
||
}
|
||
}
|
||
if 'app-key' in message["headers"]:
|
||
ack["headers"]["app-key"] = message["headers"]["app-key"]
|
||
if 'ua' in message["headers"]:
|
||
ack["headers"]["ua"] = message["headers"]["ua"]
|
||
if 'dt' in message["headers"]:
|
||
ack["headers"]["dt"] = message["headers"]["dt"]
|
||
await websocket.send(json.dumps(ack))
|
||
except Exception as e:
|
||
pass
|
||
|
||
# 如果不是同步包消息,直接返回
|
||
if not self.is_sync_package(message_data):
|
||
# 添加调试日志,记录非同步包消息
|
||
logger.debug(f"【{self.cookie_id}】非同步包消息,跳过处理")
|
||
return
|
||
|
||
# 获取并解密数据
|
||
sync_data = message_data["body"]["syncPushPackage"]["data"][0]
|
||
|
||
# 检查是否有必要的字段
|
||
if "data" not in sync_data:
|
||
logger.warning("同步包中无data字段")
|
||
return
|
||
|
||
# 解密数据
|
||
message = None
|
||
try:
|
||
data = sync_data["data"]
|
||
try:
|
||
data = base64.b64decode(data).decode("utf-8")
|
||
parsed_data = json.loads(data)
|
||
# 处理未加密的消息(如系统提示等)
|
||
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||
if isinstance(parsed_data, dict) and 'chatType' in parsed_data:
|
||
if 'operation' in parsed_data and 'content' in parsed_data['operation']:
|
||
content = parsed_data['operation']['content']
|
||
if 'sessionArouse' in content:
|
||
# 处理系统引导消息
|
||
logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】小闲鱼智能提示:")
|
||
if 'arouseChatScriptInfo' in content['sessionArouse']:
|
||
for qa in content['sessionArouse']['arouseChatScriptInfo']:
|
||
logger.info(f" - {qa['chatScrip']}")
|
||
elif 'contentType' in content:
|
||
# 其他类型的未加密消息
|
||
logger.warning(f"[{msg_time}] 【{self.cookie_id}】【系统】其他类型消息: {content}")
|
||
return
|
||
else:
|
||
# 如果不是系统消息,将解析的数据作为message
|
||
message = parsed_data
|
||
except Exception as e:
|
||
# 如果JSON解析失败,尝试解密
|
||
decrypted_data = decrypt(data)
|
||
message = json.loads(decrypted_data)
|
||
except Exception as e:
|
||
logger.error(f"消息解密失败: {self._safe_str(e)}")
|
||
return
|
||
|
||
# 确保message不为空
|
||
if message is None:
|
||
logger.error("消息解析后为空")
|
||
return
|
||
|
||
# 确保message是字典类型
|
||
if not isinstance(message, dict):
|
||
logger.error(f"消息格式错误,期望字典但得到: {type(message)}")
|
||
logger.warning(f"消息内容: {message}")
|
||
return
|
||
|
||
# 【消息接收标识】记录收到消息的时间,用于控制Cookie刷新
|
||
self.last_message_received_time = time.time()
|
||
logger.warning(f"【{self.cookie_id}】收到消息,更新消息接收时间标识")
|
||
|
||
# 【优先处理】尝试获取订单ID并获取订单详情
|
||
order_id = None
|
||
try:
|
||
order_id = self._extract_order_id(message)
|
||
if order_id:
|
||
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 检测到订单ID: {order_id},开始获取订单详情')
|
||
|
||
# 通知订单状态处理器订单ID已提取
|
||
if self.order_status_handler:
|
||
logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.on_order_id_extracted: {order_id}")
|
||
try:
|
||
self.order_status_handler.on_order_id_extracted(order_id, self.cookie_id, message)
|
||
logger.info(f"【{self.cookie_id}】订单状态处理器.on_order_id_extracted调用成功: {order_id}")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】通知订单状态处理器订单ID提取失败: {self._safe_str(e)}")
|
||
import traceback
|
||
logger.error(f"【{self.cookie_id}】详细错误信息: {traceback.format_exc()}")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】订单状态处理器为None,跳过订单ID提取通知: {order_id}")
|
||
|
||
# 立即获取订单详情信息
|
||
try:
|
||
# 先尝试提取用户ID和商品ID用于订单详情获取
|
||
temp_user_id = None
|
||
temp_item_id = None
|
||
|
||
# 提取用户ID
|
||
try:
|
||
message_1 = message.get("1")
|
||
if isinstance(message_1, str) and '@' in message_1:
|
||
temp_user_id = message_1.split('@')[0]
|
||
elif isinstance(message_1, dict):
|
||
# 从字典中提取用户ID
|
||
if "10" in message_1 and isinstance(message_1["10"], dict):
|
||
temp_user_id = message_1["10"].get("senderUserId", "unknown_user")
|
||
else:
|
||
temp_user_id = "unknown_user"
|
||
else:
|
||
temp_user_id = "unknown_user"
|
||
except:
|
||
temp_user_id = "unknown_user"
|
||
|
||
# 提取商品ID
|
||
try:
|
||
if "1" in message and isinstance(message["1"], dict) and "10" in message["1"] and isinstance(message["1"]["10"], dict):
|
||
url_info = message["1"]["10"].get("reminderUrl", "")
|
||
if isinstance(url_info, str) and "itemId=" in url_info:
|
||
temp_item_id = url_info.split("itemId=")[1].split("&")[0]
|
||
|
||
if not temp_item_id:
|
||
temp_item_id = self.extract_item_id_from_message(message)
|
||
except:
|
||
pass
|
||
|
||
# 交易卡片只提供订单号、商品、买家和状态,金额与数量必须走卖家端接口。
|
||
# 先拉真实成交数据暂存,再落库;接口失败时仅保留状态,不伪造金额。
|
||
real_values = await self.fetch_order_real_values(order_id)
|
||
if real_values:
|
||
self._pending_order_real_values[order_id] = real_values
|
||
|
||
self._save_order_event_snapshot(
|
||
order_id=order_id,
|
||
message=message,
|
||
item_id=temp_item_id,
|
||
buyer_id=temp_user_id,
|
||
)
|
||
|
||
# 检查是否已经在获取该订单详情
|
||
order_detail_lock = self._order_detail_locks[order_id]
|
||
if order_detail_lock.locked():
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】🔒 订单 {order_id} 详情正在被其他任务获取,跳过重复请求')
|
||
else:
|
||
# 调用订单详情获取方法
|
||
order_detail = await self.fetch_order_detail_info(order_id, temp_item_id, temp_user_id)
|
||
if order_detail:
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 订单详情获取成功: {order_id}')
|
||
else:
|
||
logger.warning(f'[{msg_time}] 【{self.cookie_id}】⚠️ 订单详情获取失败: {order_id}')
|
||
|
||
except Exception as detail_e:
|
||
logger.error(f'[{msg_time}] 【{self.cookie_id}】❌ 获取订单详情异常: {self._safe_str(detail_e)}')
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】未检测到订单ID")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】提取订单ID失败: {self._safe_str(e)}")
|
||
|
||
# 安全地获取用户ID
|
||
user_id = None
|
||
try:
|
||
message_1 = message.get("1")
|
||
if isinstance(message_1, str) and '@' in message_1:
|
||
user_id = message_1.split('@')[0]
|
||
elif isinstance(message_1, dict):
|
||
# 如果message['1']是字典,从message["1"]["10"]["senderUserId"]中提取user_id
|
||
if "10" in message_1 and isinstance(message_1["10"], dict):
|
||
user_id = message_1["10"].get("senderUserId", "unknown_user")
|
||
else:
|
||
user_id = "unknown_user"
|
||
else:
|
||
user_id = "unknown_user"
|
||
except Exception as e:
|
||
logger.warning(f"提取用户ID失败: {self._safe_str(e)}")
|
||
user_id = "unknown_user"
|
||
|
||
|
||
|
||
# 安全地提取商品ID
|
||
item_id = None
|
||
try:
|
||
if "1" in message and isinstance(message["1"], dict) and "10" in message["1"] and isinstance(message["1"]["10"], dict):
|
||
url_info = message["1"]["10"].get("reminderUrl", "")
|
||
if isinstance(url_info, str) and "itemId=" in url_info:
|
||
item_id = url_info.split("itemId=")[1].split("&")[0]
|
||
|
||
# 如果没有提取到,使用辅助方法
|
||
if not item_id:
|
||
item_id = self.extract_item_id_from_message(message)
|
||
|
||
if not item_id:
|
||
item_id = f"auto_{user_id}_{int(time.time())}"
|
||
logger.warning(f"无法提取商品ID,使用默认值: {item_id}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"提取商品ID时发生错误: {self._safe_str(e)}")
|
||
item_id = f"auto_{user_id}_{int(time.time())}"
|
||
# 处理订单状态消息
|
||
try:
|
||
logger.info(message)
|
||
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||
|
||
# 安全地检查订单状态
|
||
red_reminder = None
|
||
if isinstance(message, dict) and "3" in message and isinstance(message["3"], dict):
|
||
red_reminder = message["3"].get("redReminder")
|
||
|
||
if red_reminder == '等待买家付款':
|
||
user_url = f'https://www.goofish.com/personal?userId={user_id}'
|
||
logger.info(f'[{msg_time}] 【系统】等待买家 {user_url} 付款')
|
||
return
|
||
elif red_reminder == '交易关闭':
|
||
user_url = f'https://www.goofish.com/personal?userId={user_id}'
|
||
logger.info(f'[{msg_time}] 【系统】买家 {user_url} 交易关闭')
|
||
return
|
||
elif red_reminder == '等待卖家发货':
|
||
user_url = f'https://www.goofish.com/personal?userId={user_id}'
|
||
logger.info(f'[{msg_time}] 【系统】交易成功 {user_url} 等待卖家发货')
|
||
# return
|
||
except:
|
||
pass
|
||
|
||
# 判断是否为聊天消息
|
||
if not self.is_chat_message(message):
|
||
logger.warning("非聊天消息")
|
||
return
|
||
|
||
# 处理聊天消息
|
||
try:
|
||
# 安全地提取聊天消息信息
|
||
if not (isinstance(message, dict) and "1" in message and isinstance(message["1"], dict)):
|
||
logger.error("消息格式错误:缺少必要的字段结构")
|
||
return
|
||
|
||
message_1 = message["1"]
|
||
if not isinstance(message_1.get("10"), dict):
|
||
logger.error("消息格式错误:缺少消息详情字段")
|
||
return
|
||
|
||
create_time = int(message_1.get("5", 0))
|
||
message_10 = message_1["10"]
|
||
send_user_name = message_10.get("senderNick", message_10.get("reminderTitle", "未知用户"))
|
||
send_user_id = message_10.get("senderUserId", "unknown")
|
||
send_message = message_10.get("reminderContent", "")
|
||
|
||
chat_id_raw = message_1.get("2", "")
|
||
chat_id = chat_id_raw.split('@')[0] if '@' in str(chat_id_raw) else str(chat_id_raw)
|
||
|
||
except Exception as e:
|
||
logger.error(f"提取聊天消息信息失败: {self._safe_str(e)}")
|
||
return
|
||
|
||
# 格式化消息时间
|
||
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(create_time/1000))
|
||
|
||
|
||
|
||
# 判断消息方向
|
||
if send_user_id == self.myid:
|
||
logger.info(f"[{msg_time}] 【手动发出】 商品({item_id}): {send_message}")
|
||
|
||
# 暂停该chat_id的自动回复10分钟
|
||
pause_manager.pause_chat(chat_id, self.cookie_id)
|
||
|
||
return
|
||
else:
|
||
logger.info(f"[{msg_time}] 【收到】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {send_message}")
|
||
|
||
# 🔔 立即发送消息通知(独立于自动回复功能)
|
||
# 检查是否为群组消息,如果是群组消息则跳过通知
|
||
try:
|
||
session_type = message_10.get("sessionType", "1") # 默认为个人消息类型
|
||
if session_type == "30":
|
||
logger.info(f"📱 检测到群组消息(sessionType=30),跳过消息通知")
|
||
else:
|
||
from app.db_manager import db_manager
|
||
matched_filter = db_manager.matches_message_filter(
|
||
self.cookie_id, send_message, "skip_notify"
|
||
)
|
||
if matched_filter:
|
||
logger.info(
|
||
f"📱 消息命中过滤规则“{matched_filter}”,跳过外部通知"
|
||
)
|
||
else:
|
||
# 只对个人消息发送通知
|
||
await self.send_notification(send_user_name, send_user_id, send_message, item_id, chat_id)
|
||
except Exception as notify_error:
|
||
logger.error(f"📱 发送消息通知失败: {self._safe_str(notify_error)}")
|
||
|
||
|
||
|
||
|
||
# 【优先处理】使用订单状态处理器处理系统消息
|
||
if self.order_status_handler:
|
||
try:
|
||
# 处理系统消息的订单状态更新
|
||
try:
|
||
handled = self.order_status_handler.handle_system_message(
|
||
message=message,
|
||
send_message=send_message,
|
||
cookie_id=self.cookie_id,
|
||
msg_time=msg_time
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】处理系统消息失败: {self._safe_str(e)}")
|
||
handled = False
|
||
|
||
# 处理红色提醒消息
|
||
if not handled:
|
||
try:
|
||
if isinstance(message, dict) and "3" in message and isinstance(message["3"], dict):
|
||
red_reminder = message["3"].get("redReminder")
|
||
user_id = message["3"].get("userId", "unknown")
|
||
|
||
if red_reminder:
|
||
try:
|
||
self.order_status_handler.handle_red_reminder_message(
|
||
message=message,
|
||
red_reminder=red_reminder,
|
||
user_id=user_id,
|
||
cookie_id=self.cookie_id,
|
||
msg_time=msg_time
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】处理红色提醒消息失败: {self._safe_str(e)}")
|
||
except Exception as red_e:
|
||
logger.warning(f"处理红色提醒消息失败: {self._safe_str(red_e)}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"订单状态处理失败: {self._safe_str(e)}")
|
||
|
||
# 【优先处理】检查系统消息和自动发货触发消息(不受人工接入暂停影响)
|
||
if self._is_auto_delivery_trigger(send_message):
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】检测到自动发货触发消息,进入订单校验')
|
||
await self._handle_auto_delivery(
|
||
websocket, message, send_user_name, send_user_id,
|
||
item_id, chat_id, msg_time
|
||
)
|
||
return
|
||
if self._is_system_or_order_event(send_message):
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】系统或订单事件绕过普通自动回复')
|
||
return
|
||
if send_message == '[我已拍下,待付款]':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】系统消息不处理')
|
||
return
|
||
elif send_message == '[你关闭了订单,钱款已原路退返]':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】系统消息不处理')
|
||
return
|
||
elif send_message == '[不想宝贝被砍价?设置不砍价回复 ]':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】系统提示信息不处理')
|
||
return
|
||
elif send_message == 'AI正在帮你回复消息,不错过每笔订单':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】系统提示信息不处理')
|
||
return
|
||
elif send_message == '发来一条消息':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】系统通知消息不处理')
|
||
return
|
||
elif send_message == '发来一条新消息':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】系统通知消息不处理')
|
||
return
|
||
elif send_message == '[买家确认收货,交易成功]':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】交易完成,触发买家互动')
|
||
await self.send_post_receipt_thanks(websocket, chat_id, send_user_id)
|
||
await self.trigger_buyer_interactions_now('买家确认收货')
|
||
return
|
||
elif send_message == '快给ta一个评价吧~' or send_message == '快给ta一个评价吧~':
|
||
# 闲鱼只在交易完成后才推这条提醒,是个可靠的补充信号:
|
||
# 万一「交易成功」那条消息漏收,靠它也能触发。触发本身有去重。
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】收到评价提醒,触发买家互动')
|
||
await self.send_post_receipt_thanks(websocket, chat_id, send_user_id)
|
||
await self.trigger_buyer_interactions_now('评价提醒')
|
||
return
|
||
elif send_message == '卖家人不错?送Ta闲鱼小红花':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】小红花提醒消息不处理')
|
||
return
|
||
elif send_message == '[你已确认收货,交易成功]':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】确认收货,触发买家互动')
|
||
await self.send_post_receipt_thanks(websocket, chat_id, send_user_id)
|
||
await self.trigger_buyer_interactions_now('确认收货')
|
||
return
|
||
elif send_message == '[你已发货]':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】发货确认消息不处理')
|
||
return
|
||
elif send_message == '已发货':
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】发货确认消息不处理')
|
||
return
|
||
# 【重要】检查是否为自动发货触发消息 - 即使在人工接入暂停期间也要处理
|
||
elif self._is_auto_delivery_trigger(send_message):
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】检测到自动发货触发消息,即使在暂停期间也继续处理: {send_message}')
|
||
# 使用统一的自动发货处理方法
|
||
await self._handle_auto_delivery(websocket, message, send_user_name, send_user_id,
|
||
item_id, chat_id, msg_time)
|
||
return
|
||
# 【重要】检查是否为"我已小刀,待刀成"卡片消息 - 即使在人工接入暂停期间也要处理
|
||
elif send_message == '[卡片消息]':
|
||
# 检查是否为"我已小刀,待刀成"的卡片消息
|
||
try:
|
||
# 从消息中提取卡片内容
|
||
card_title = None
|
||
if isinstance(message, dict) and "1" in message and isinstance(message["1"], dict):
|
||
message_1 = message["1"]
|
||
if "6" in message_1 and isinstance(message_1["6"], dict):
|
||
message_6 = message_1["6"]
|
||
if "3" in message_6 and isinstance(message_6["3"], dict):
|
||
message_6_3 = message_6["3"]
|
||
if "5" in message_6_3:
|
||
# 解析JSON内容
|
||
try:
|
||
card_content = json.loads(message_6_3["5"])
|
||
if "dxCard" in card_content and "item" in card_content["dxCard"]:
|
||
card_item = card_content["dxCard"]["item"]
|
||
if "main" in card_item and "exContent" in card_item["main"]:
|
||
ex_content = card_item["main"]["exContent"]
|
||
card_title = ex_content.get("title", "")
|
||
except (json.JSONDecodeError, KeyError) as e:
|
||
logger.warning(f"解析卡片消息失败: {e}")
|
||
|
||
# 检查是否为"我已小刀,待刀成"
|
||
if card_title == "我已小刀,待刀成":
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】【系统】检测到"我已小刀,待刀成",即使在暂停期间也继续处理')
|
||
|
||
# 检查商品是否属于当前cookies
|
||
if item_id and item_id != "未知商品":
|
||
try:
|
||
from app.db_manager import db_manager
|
||
item_info = db_manager.get_item_info(self.cookie_id, item_id)
|
||
if not item_info:
|
||
logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 商品 {item_id} 不属于当前账号,跳过免拼发货')
|
||
return
|
||
logger.warning(f'[{msg_time}] 【{self.cookie_id}】✅ 商品 {item_id} 归属验证通过')
|
||
except Exception as e:
|
||
logger.error(f'[{msg_time}] 【{self.cookie_id}】检查商品归属失败: {self._safe_str(e)},跳过免拼发货')
|
||
return
|
||
|
||
# 提取订单ID
|
||
order_id = self._extract_order_id(message)
|
||
if not order_id:
|
||
logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 未能提取到订单ID,无法执行免拼发货')
|
||
return
|
||
|
||
# 更新订单的is_bargain字段为True(标记为小刀订单)
|
||
try:
|
||
from app.db_manager import db_manager
|
||
db_manager.insert_or_update_order(
|
||
order_id=order_id,
|
||
item_id=item_id,
|
||
buyer_id=send_user_id,
|
||
cookie_id=self.cookie_id,
|
||
is_bargain=True,
|
||
chat_id=chat_id
|
||
)
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 订单 {order_id} 已标记为小刀订单')
|
||
except Exception as e:
|
||
logger.error(f'[{msg_time}] 【{self.cookie_id}】标记小刀订单失败: {self._safe_str(e)}')
|
||
|
||
# 延迟2秒后执行免拼发货
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】延迟2秒后执行免拼发货...')
|
||
await asyncio.sleep(2)
|
||
# 调用自动免拼发货方法
|
||
result = await self.auto_freeshipping(order_id, item_id, send_user_id)
|
||
if result.get('success'):
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 自动免拼发货成功')
|
||
else:
|
||
logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 自动免拼发货失败: {result.get("error", "未知错误")}')
|
||
await self._handle_auto_delivery(websocket, message, send_user_name, send_user_id,
|
||
item_id, chat_id, msg_time)
|
||
return
|
||
else:
|
||
logger.info(f'[{msg_time}] 【{self.cookie_id}】收到卡片消息,标题: {card_title or "未知"}')
|
||
# 如果不是目标卡片消息,继续正常处理流程(会受到暂停影响)
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理卡片消息异常: {self._safe_str(e)}")
|
||
# 如果处理异常,继续正常处理流程(会受到暂停影响)
|
||
|
||
# 使用防抖机制处理聊天消息回复
|
||
# 如果用户连续发送消息,等待用户停止发送后再回复最后一条消息
|
||
await self._schedule_debounced_reply(
|
||
chat_id=chat_id,
|
||
message_data=message_data,
|
||
websocket=websocket,
|
||
send_user_name=send_user_name,
|
||
send_user_id=send_user_id,
|
||
send_message=send_message,
|
||
item_id=item_id,
|
||
msg_time=msg_time
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理消息时发生错误: {self._safe_str(e)}")
|
||
logger.warning(f"原始消息: {message_data}")
|
||
|
||
async def main(self):
|
||
"""主程序入口"""
|
||
try:
|
||
logger.info(f"【{self.cookie_id}】开始启动XianyuLive主程序...")
|
||
await self.create_session() # 创建session
|
||
logger.info(f"【{self.cookie_id}】Session创建完成,开始WebSocket连接循环...")
|
||
|
||
while True:
|
||
try:
|
||
# 检查账号是否启用
|
||
from app.cookie_manager import manager as cookie_manager
|
||
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
|
||
logger.info(f"【{self.cookie_id}】账号已禁用,停止主循环")
|
||
break
|
||
|
||
headers = WEBSOCKET_HEADERS.copy()
|
||
headers['Cookie'] = self.cookies_str
|
||
|
||
# 更新连接状态为连接中
|
||
self._set_connection_state(ConnectionState.CONNECTING, "准备建立WebSocket连接")
|
||
logger.info(f"【{self.cookie_id}】WebSocket目标地址: {self.base_url}")
|
||
|
||
# 兼容不同版本的websockets库
|
||
async with await self._create_websocket_connection(headers) as websocket:
|
||
self.ws = websocket
|
||
logger.info(f"【{self.cookie_id}】WebSocket连接建立成功,开始初始化...")
|
||
|
||
try:
|
||
# 开始初始化
|
||
await self.init(websocket)
|
||
logger.info(f"【{self.cookie_id}】WebSocket初始化完成!")
|
||
|
||
# 初始化完成后才设置为已连接状态
|
||
self._set_connection_state(ConnectionState.CONNECTED, "初始化完成,连接就绪")
|
||
self.connection_failures = 0
|
||
self.last_successful_connection = time.time()
|
||
|
||
# 记录后台任务启动前的状态
|
||
logger.warning(f"【{self.cookie_id}】准备启动后台任务 - 当前状态: heartbeat={self.heartbeat_task}, token_refresh={self.token_refresh_task}, cleanup={self.cleanup_task}, cookie_refresh={self.cookie_refresh_task}")
|
||
|
||
# 如果存在心跳任务引用,先清理(心跳任务依赖WebSocket,必须重启)
|
||
if self.heartbeat_task:
|
||
logger.warning(f"【{self.cookie_id}】检测到旧心跳任务引用,先清理...")
|
||
self._reset_background_tasks()
|
||
|
||
# 启动心跳任务(依赖WebSocket,每次重连都需要重启)
|
||
logger.info(f"【{self.cookie_id}】启动心跳任务...")
|
||
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(websocket))
|
||
|
||
# 启动其他后台任务(不依赖WebSocket,只在首次连接时启动)
|
||
tasks_started = []
|
||
|
||
if not self.token_refresh_task or self.token_refresh_task.done():
|
||
logger.info(f"【{self.cookie_id}】启动Token刷新任务...")
|
||
self.token_refresh_task = asyncio.create_task(self.token_refresh_loop())
|
||
tasks_started.append("Token刷新")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】Token刷新任务已在运行,跳过启动")
|
||
|
||
if not self.cleanup_task or self.cleanup_task.done():
|
||
logger.info(f"【{self.cookie_id}】启动暂停记录清理任务...")
|
||
self.cleanup_task = asyncio.create_task(self.pause_cleanup_loop())
|
||
tasks_started.append("暂停清理")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】暂停记录清理任务已在运行,跳过启动")
|
||
|
||
if not self.cookie_refresh_task or self.cookie_refresh_task.done():
|
||
logger.info(f"【{self.cookie_id}】启动Cookie刷新任务...")
|
||
self.cookie_refresh_task = asyncio.create_task(self.cookie_refresh_loop())
|
||
tasks_started.append("Cookie刷新")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】Cookie刷新任务已在运行,跳过启动")
|
||
|
||
# 启动商品同步任务
|
||
if self.item_sync_enabled:
|
||
if not self.item_sync_task or self.item_sync_task.done():
|
||
logger.info(f"【{self.cookie_id}】启动商品同步任务(间隔: {self.item_sync_interval}秒)...")
|
||
self.item_sync_task = asyncio.create_task(self.item_sync_loop())
|
||
tasks_started.append("商品同步")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】商品同步任务已在运行,跳过启动")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】商品同步功能未启用")
|
||
|
||
# 启动订单同步任务:补齐监听离线期间产生的订单
|
||
if not self.order_sync_task or self.order_sync_task.done():
|
||
logger.info(f"【{self.cookie_id}】启动订单同步任务...")
|
||
self.order_sync_task = asyncio.create_task(self.order_sync_loop())
|
||
tasks_started.append("订单同步")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】订单同步任务已在运行,跳过启动")
|
||
|
||
# 连接成功后补齐账号资料。此前只有手动点"刷新"才会拉,
|
||
# 新登录的账号在列表里没有昵称和头像,看着像没登录成功。
|
||
if not self._profile_synced:
|
||
self._profile_synced = True
|
||
self._create_tracked_task(self._sync_account_profile())
|
||
|
||
# 启动商品擦亮任务(是否真正执行由设置开关决定)
|
||
if not self.item_polish_task or self.item_polish_task.done():
|
||
logger.info(f"【{self.cookie_id}】启动商品擦亮任务...")
|
||
self.item_polish_task = asyncio.create_task(self.item_polish_loop())
|
||
tasks_started.append("商品擦亮")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】商品擦亮任务已在运行,跳过启动")
|
||
|
||
# 启动发货超时检查:超时未发货会被平台处罚
|
||
if not self.delivery_timeout_task or self.delivery_timeout_task.done():
|
||
logger.info(f"【{self.cookie_id}】启动发货超时检查...")
|
||
self.delivery_timeout_task = asyncio.create_task(self.delivery_timeout_loop())
|
||
tasks_started.append("发货超时检查")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】发货超时检查已在运行,跳过启动")
|
||
|
||
# 启动买家互动任务(评价/求花,是否执行由开关决定)
|
||
if not self.buyer_interaction_task or self.buyer_interaction_task.done():
|
||
logger.info(f"【{self.cookie_id}】启动买家互动任务...")
|
||
self.buyer_interaction_task = asyncio.create_task(self.buyer_interaction_loop())
|
||
tasks_started.append("买家互动")
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】买家互动任务已在运行,跳过启动")
|
||
|
||
# 记录所有后台任务状态
|
||
if tasks_started:
|
||
logger.info(f"【{self.cookie_id}】✅ 新启动的任务: {', '.join(tasks_started)}")
|
||
item_sync_status = '运行中' if self.item_sync_task and not self.item_sync_task.done() else '已启动' if self.item_sync_enabled else '未启用'
|
||
order_sync_status = '运行中' if self.order_sync_task and not self.order_sync_task.done() else '已启动'
|
||
logger.info(f"【{self.cookie_id}】✅ 所有后台任务状态: 心跳(已启动), Token刷新({'运行中' if self.token_refresh_task and not self.token_refresh_task.done() else '已启动'}), 暂停清理({'运行中' if self.cleanup_task and not self.cleanup_task.done() else '已启动'}), Cookie刷新({'运行中' if self.cookie_refresh_task and not self.cookie_refresh_task.done() else '已启动'}), 商品同步({item_sync_status}), 订单同步({order_sync_status})")
|
||
|
||
logger.info(f"【{self.cookie_id}】开始监听WebSocket消息...")
|
||
logger.info(f"【{self.cookie_id}】WebSocket连接状态正常,等待服务器消息...")
|
||
logger.info(f"【{self.cookie_id}】准备进入消息循环...")
|
||
|
||
async for message in websocket:
|
||
logger.info(f"【{self.cookie_id}】收到WebSocket消息: {len(message) if message else 0} 字节")
|
||
try:
|
||
message_data = json.loads(message)
|
||
|
||
# API requests and push messages share this WebSocket.
|
||
# Route request responses by mid before heartbeat/push handling.
|
||
if self._resolve_im_response(message_data):
|
||
continue
|
||
|
||
# 处理心跳响应
|
||
if await self.handle_heartbeat_response(message_data):
|
||
continue
|
||
|
||
# 处理其他消息
|
||
# 使用追踪的异步任务处理消息,防止阻塞后续消息接收
|
||
# 并通过信号量控制并发数量,防止内存泄漏
|
||
self._create_tracked_task(self._handle_message_with_semaphore(message_data, websocket))
|
||
|
||
except Exception as e:
|
||
logger.error(f"处理消息出错: {self._safe_str(e)}")
|
||
continue
|
||
finally:
|
||
# 确保在退出 async with 块时清理 WebSocket 引用
|
||
# 注意:async with 会自动关闭 WebSocket,但我们需要清理引用
|
||
if self.ws == websocket:
|
||
self.ws = None
|
||
self._fail_pending_im_requests("闲鱼消息连接已断开")
|
||
logger.info(f"【{self.cookie_id}】WebSocket连接已退出,引用已清理")
|
||
|
||
except Exception as e:
|
||
error_msg = self._safe_str(e)
|
||
import traceback
|
||
error_type = type(e).__name__
|
||
|
||
# 检查是否是 ConnectionClosedError(正常的连接关闭)
|
||
is_connection_closed = (
|
||
'ConnectionClosedError' in error_type or
|
||
'ConnectionClosed' in error_type or
|
||
'no close frame received or sent' in error_msg or
|
||
'IncompleteReadError' in error_type
|
||
)
|
||
|
||
# 对于连接关闭错误,使用警告级别而不是错误级别
|
||
if is_connection_closed:
|
||
logger.warning(f"【{self.cookie_id}】WebSocket连接已关闭 ({self.connection_failures + 1}/{self.max_connection_failures})")
|
||
logger.warning(f"【{self.cookie_id}】关闭原因: {error_msg}")
|
||
else:
|
||
self.connection_failures += 1
|
||
# 更新连接状态为重连中
|
||
self._set_connection_state(ConnectionState.RECONNECTING, f"第{self.connection_failures}次失败")
|
||
logger.error(f"【{self.cookie_id}】WebSocket连接异常 ({self.connection_failures}/{self.max_connection_failures})")
|
||
logger.error(f"【{self.cookie_id}】异常类型: {error_type}")
|
||
logger.error(f"【{self.cookie_id}】异常信息: {error_msg}")
|
||
logger.warning(f"【{self.cookie_id}】异常堆栈:\n{traceback.format_exc()}")
|
||
|
||
# 确保清理 WebSocket 引用
|
||
if self.ws:
|
||
try:
|
||
# 检查 WebSocket 是否仍然打开
|
||
if hasattr(self.ws, 'close_code') and self.ws.close_code is None:
|
||
# WebSocket 可能仍然打开,尝试关闭
|
||
try:
|
||
await asyncio.wait_for(self.ws.close(), timeout=2.0)
|
||
except (asyncio.TimeoutError, Exception):
|
||
pass
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
self.ws = None
|
||
logger.info(f"【{self.cookie_id}】WebSocket引用已清理")
|
||
|
||
# 对于连接关闭错误,也增加失败计数
|
||
if is_connection_closed:
|
||
self.connection_failures += 1
|
||
# 更新连接状态为重连中
|
||
self._set_connection_state(ConnectionState.RECONNECTING, f"连接关闭,第{self.connection_failures}次重连")
|
||
|
||
# 检查是否超过最大失败次数
|
||
if self.connection_failures >= self.max_connection_failures:
|
||
self._set_connection_state(ConnectionState.FAILED, f"连续失败{self.max_connection_failures}次")
|
||
logger.warning(f"【{self.cookie_id}】连续失败{self.max_connection_failures}次,尝试通过密码登录刷新Cookie...")
|
||
|
||
try:
|
||
# 调用统一的密码登录刷新方法
|
||
refresh_success = await self._try_password_login_refresh(f"连续失败{self.max_connection_failures}次")
|
||
|
||
if refresh_success:
|
||
logger.info(f"【{self.cookie_id}】✅ 密码登录刷新成功,将重置失败计数并继续重连")
|
||
# 重置失败计数,因为已经刷新了Cookie
|
||
self.connection_failures = 0
|
||
# 更新连接状态
|
||
self._set_connection_state(ConnectionState.RECONNECTING, "Cookie已刷新,准备重连")
|
||
# 短暂等待后继续重连循环
|
||
await asyncio.sleep(2)
|
||
continue
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】❌ 密码登录刷新失败,将重启实例...")
|
||
except Exception as refresh_e:
|
||
logger.error(f"【{self.cookie_id}】密码登录刷新过程异常: {self._safe_str(refresh_e)}")
|
||
logger.warning(f"【{self.cookie_id}】将重启实例...")
|
||
|
||
# 如果密码登录刷新失败或异常,则重启实例
|
||
logger.error(f"【{self.cookie_id}】准备重启实例...")
|
||
self.connection_failures = 0 # 重置失败计数
|
||
|
||
# 先清理后台任务,避免与重启过程冲突
|
||
logger.info(f"【{self.cookie_id}】重启前先清理后台任务...")
|
||
try:
|
||
await asyncio.wait_for(
|
||
self._cancel_background_tasks(),
|
||
timeout=8.0 # 给足够时间让任务响应
|
||
)
|
||
logger.info(f"【{self.cookie_id}】后台任务已清理完成")
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{self.cookie_id}】后台任务清理超时,强制继续重启")
|
||
except Exception as cleanup_e:
|
||
logger.error(f"【{self.cookie_id}】后台任务清理失败: {self._safe_str(cleanup_e)}")
|
||
|
||
# 触发重启(不等待完成)
|
||
await self._restart_instance()
|
||
|
||
# ⚠️ 重要:_restart_instance() 已触发重启,0.5秒后当前任务会被取消
|
||
# 不要在这里等待或执行其他操作,让任务自然退出
|
||
logger.info(f"【{self.cookie_id}】重启请求已触发,主程序即将退出,新实例将自动启动")
|
||
return # 退出当前连接循环,等待被取消
|
||
|
||
# 计算重试延迟
|
||
# 风控期间的失败通常报"Token获取失败",错误文本里不含风控特征,
|
||
# 只看 error_msg 会退避不足(20 秒一次),因此同时查熔断状态。
|
||
from utils import risk_control
|
||
|
||
guard = risk_control.registry.get(self.cookie_id)
|
||
if guard.is_blocked:
|
||
retry_delay = max(guard.remaining_seconds + 5, 60)
|
||
logger.warning(
|
||
f"【{self.cookie_id}】风控冷却中,将在 {retry_delay} 秒后重试连接"
|
||
f"(冷却剩余 {guard.remaining_seconds} 秒)"
|
||
)
|
||
else:
|
||
retry_delay = self._calculate_retry_delay(error_msg)
|
||
logger.warning(f"【{self.cookie_id}】将在 {retry_delay} 秒后重试连接...")
|
||
|
||
try:
|
||
# 清空当前token,确保重新连接时会重新获取
|
||
if self.current_token:
|
||
logger.warning(f"【{self.cookie_id}】清空当前token,重新连接时将重新获取")
|
||
self.current_token = None
|
||
|
||
# 直接重置任务引用,不等待取消(快速重连方案)
|
||
# 这样可以避免等待任务取消导致的阻塞问题
|
||
logger.info(f"【{self.cookie_id}】准备重置后台任务引用(快速重连模式)...")
|
||
self._reset_background_tasks()
|
||
logger.info(f"【{self.cookie_id}】后台任务引用已重置,可以立即重连")
|
||
|
||
# 等待后重试 - 使用可中断的sleep,并定期输出日志证明进程还活着
|
||
logger.info(f"【{self.cookie_id}】开始等待 {retry_delay} 秒...")
|
||
# 强制刷新日志缓冲区,确保日志被写入
|
||
try:
|
||
sys.stdout.flush()
|
||
except:
|
||
pass
|
||
|
||
# 使用可中断的sleep,每5秒输出一次心跳日志
|
||
chunk_size = 5.0 # 每5秒输出一次日志
|
||
remaining = retry_delay
|
||
start_time = time.time()
|
||
|
||
while remaining > 0:
|
||
sleep_time = min(chunk_size, remaining)
|
||
try:
|
||
await asyncio.sleep(sleep_time)
|
||
remaining -= sleep_time
|
||
elapsed = time.time() - start_time
|
||
if remaining > 0:
|
||
logger.info(f"【{self.cookie_id}】等待中... 已等待 {elapsed:.1f} 秒,剩余 {remaining:.1f} 秒")
|
||
# 定期刷新日志
|
||
try:
|
||
sys.stdout.flush()
|
||
except:
|
||
pass
|
||
except asyncio.CancelledError:
|
||
logger.warning(f"【{self.cookie_id}】等待期间收到取消信号")
|
||
raise
|
||
except Exception as sleep_error:
|
||
logger.error(f"【{self.cookie_id}】等待期间发生异常: {self._safe_str(sleep_error)}")
|
||
logger.warning(f"【{self.cookie_id}】等待异常堆栈:\n{traceback.format_exc()}")
|
||
# 即使出错也继续等待剩余时间
|
||
if remaining > 0:
|
||
await asyncio.sleep(remaining)
|
||
break
|
||
|
||
logger.info(f"【{self.cookie_id}】等待完成(总耗时 {time.time() - start_time:.1f} 秒),准备重新连接...")
|
||
# 再次强制刷新日志
|
||
try:
|
||
sys.stdout.flush()
|
||
except:
|
||
pass
|
||
|
||
except Exception as cleanup_error:
|
||
logger.error(f"【{self.cookie_id}】清理过程出错: {self._safe_str(cleanup_error)}")
|
||
logger.warning(f"【{self.cookie_id}】清理异常堆栈:\n{traceback.format_exc()}")
|
||
# 即使清理失败,也要重置任务引用并等待后重试
|
||
self.heartbeat_task = None
|
||
self.token_refresh_task = None
|
||
self.cleanup_task = None
|
||
self.cookie_refresh_task = None
|
||
logger.warning(f"【{self.cookie_id}】清理失败,已强制重置所有任务引用")
|
||
# 使用可中断的sleep,并定期输出日志
|
||
logger.info(f"【{self.cookie_id}】清理失败后开始等待 {retry_delay} 秒...")
|
||
chunk_size = 5.0
|
||
remaining = retry_delay
|
||
start_time = time.time()
|
||
|
||
while remaining > 0:
|
||
sleep_time = min(chunk_size, remaining)
|
||
try:
|
||
await asyncio.sleep(sleep_time)
|
||
remaining -= sleep_time
|
||
if remaining > 0:
|
||
logger.info(f"【{self.cookie_id}】清理失败后等待中... 剩余 {remaining:.1f} 秒")
|
||
except asyncio.CancelledError:
|
||
logger.warning(f"【{self.cookie_id}】清理失败后等待期间收到取消信号")
|
||
raise
|
||
except Exception as sleep_error:
|
||
logger.error(f"【{self.cookie_id}】清理失败后等待期间发生异常: {self._safe_str(sleep_error)}")
|
||
if remaining > 0:
|
||
await asyncio.sleep(remaining)
|
||
break
|
||
|
||
logger.info(f"【{self.cookie_id}】清理失败后等待完成(总耗时 {time.time() - start_time:.1f} 秒)")
|
||
|
||
# 继续下一次循环
|
||
logger.info(f"【{self.cookie_id}】开始新一轮WebSocket连接尝试...")
|
||
continue
|
||
finally:
|
||
# 更新连接状态为已关闭
|
||
self._set_connection_state(ConnectionState.CLOSED, "程序退出")
|
||
|
||
# 清空当前token
|
||
if self.current_token:
|
||
logger.info(f"【{self.cookie_id}】程序退出,清空当前token")
|
||
self.current_token = None
|
||
|
||
# 检查是否还有未取消的后台任务,如果有才执行清理
|
||
has_pending_tasks = any([
|
||
self.heartbeat_task and not self.heartbeat_task.done(),
|
||
self.token_refresh_task and not self.token_refresh_task.done(),
|
||
self.cleanup_task and not self.cleanup_task.done(),
|
||
self.cookie_refresh_task and not self.cookie_refresh_task.done()
|
||
])
|
||
|
||
if has_pending_tasks:
|
||
logger.info(f"【{self.cookie_id}】检测到未完成的后台任务,执行清理...")
|
||
# 使用统一的任务清理方法,添加超时保护
|
||
try:
|
||
await asyncio.wait_for(
|
||
self._cancel_background_tasks(),
|
||
timeout=10.0
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.error(f"【{self.cookie_id}】程序退出时任务取消超时,强制继续")
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】程序退出时任务取消失败: {self._safe_str(e)}")
|
||
finally:
|
||
# 确保任务引用被重置
|
||
self.heartbeat_task = None
|
||
self.token_refresh_task = None
|
||
self.cleanup_task = None
|
||
self.cookie_refresh_task = None
|
||
else:
|
||
logger.info(f"【{self.cookie_id}】所有后台任务已清理完成,跳过重复清理")
|
||
# 确保任务引用被重置
|
||
self.heartbeat_task = None
|
||
self.token_refresh_task = None
|
||
self.cleanup_task = None
|
||
self.cookie_refresh_task = None
|
||
|
||
# 清理所有后台任务
|
||
if self.background_tasks:
|
||
logger.info(f"【{self.cookie_id}】等待 {len(self.background_tasks)} 个后台任务完成...")
|
||
try:
|
||
await asyncio.wait_for(
|
||
asyncio.gather(*self.background_tasks, return_exceptions=True),
|
||
timeout=10.0 # 10秒超时
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.warning(f"【{self.cookie_id}】后台任务清理超时,强制继续")
|
||
|
||
# 确保关闭session
|
||
await self.close_session()
|
||
|
||
# 从全局实例字典中注销当前实例
|
||
self._unregister_instance()
|
||
logger.info(f"【{self.cookie_id}】XianyuLive主程序已完全退出")
|
||
|
||
@staticmethod
|
||
def _select_item_group(groups):
|
||
"""优先选择当前账号的“在售”分组。"""
|
||
if not isinstance(groups, list):
|
||
return None
|
||
for group in groups:
|
||
if isinstance(group, dict) and group.get('groupName') == '在售':
|
||
return group
|
||
for group in groups:
|
||
conditions = group.get('searchCondition') if isinstance(group, dict) else None
|
||
if isinstance(conditions, list) and any(
|
||
str(condition.get('status')) == '0'
|
||
for condition in conditions
|
||
if isinstance(condition, dict)
|
||
):
|
||
return group
|
||
return None
|
||
|
||
@staticmethod
|
||
def _extract_item_image(*sources):
|
||
"""从不同版本的商品卡片结构中选出最可信的主图。"""
|
||
candidates = []
|
||
image_keys = ('image', 'pic', 'cover', 'thumb', 'poster')
|
||
|
||
def visit(node, key_hint='', depth=0):
|
||
if depth > 8:
|
||
return
|
||
if isinstance(node, str):
|
||
value = node.strip()
|
||
lower_value = value.lower()
|
||
lower_key = key_hint.lower()
|
||
is_url = value.startswith(('http://', 'https://', '//'))
|
||
looks_like_image = any(
|
||
marker in lower_value
|
||
for marker in ('.jpg', '.jpeg', '.png', '.webp', '.avif', 'alicdn.com')
|
||
)
|
||
if is_url and looks_like_image:
|
||
score = 0
|
||
if any(marker in lower_key for marker in image_keys):
|
||
score += 40
|
||
if any(marker in lower_key for marker in ('main', 'cover', 'item')):
|
||
score += 40
|
||
if 'xy_item' in lower_value:
|
||
score += 100
|
||
if 'bao/uploaded' in lower_value:
|
||
score += 20
|
||
if any(marker in lower_key for marker in ('avatar', 'head', 'user', 'seller')):
|
||
score -= 100
|
||
candidates.append((score, value))
|
||
return
|
||
if isinstance(node, list):
|
||
for child in node:
|
||
visit(child, key_hint, depth + 1)
|
||
return
|
||
if not isinstance(node, dict):
|
||
return
|
||
|
||
priority_keys = (
|
||
'mainPic', 'mainImage', 'coverImage', 'itemImage', 'picUrl',
|
||
'imageUrl', 'picInfo', 'image', 'pic', 'cover', 'url', 'src'
|
||
)
|
||
visited = set()
|
||
for key in priority_keys:
|
||
if key in node:
|
||
visited.add(key)
|
||
visit(node[key], key, depth + 1)
|
||
for key, value in node.items():
|
||
if key not in visited and isinstance(value, (dict, list)):
|
||
visit(value, key, depth + 1)
|
||
|
||
for source in sources:
|
||
visit(source)
|
||
if not candidates:
|
||
return ''
|
||
return max(enumerate(candidates), key=lambda entry: (entry[1][0], -entry[0]))[1][1]
|
||
|
||
@staticmethod
|
||
def _normalize_item_card(card):
|
||
"""兼容旧cardList和新版itemTopicList中的商品对象。"""
|
||
if not isinstance(card, dict):
|
||
return None
|
||
card_data = card.get('cardData') if isinstance(card.get('cardData'), dict) else card
|
||
for key in ('itemInfo', 'itemData', 'item'):
|
||
if isinstance(card_data.get(key), dict):
|
||
card_data = card_data[key]
|
||
break
|
||
|
||
detail_params = card_data.get('detailParams', {})
|
||
if not isinstance(detail_params, dict):
|
||
detail_params = {}
|
||
item_id = (
|
||
detail_params.get('itemId')
|
||
or card_data.get('itemId')
|
||
or card_data.get('auctionId')
|
||
or card_data.get('id')
|
||
)
|
||
if not item_id:
|
||
return None
|
||
|
||
price_info = card_data.get('priceInfo', {})
|
||
if not isinstance(price_info, dict):
|
||
price_info = {}
|
||
price = price_info.get('price', card_data.get('price', ''))
|
||
pic_info = card_data.get('picInfo', card.get('picInfo', {}))
|
||
if not isinstance(pic_info, (dict, list, str)):
|
||
pic_info = {}
|
||
item_image = XianyuLive._extract_item_image(pic_info, card_data, card)
|
||
|
||
return {
|
||
'id': str(item_id),
|
||
'title': card_data.get('title') or card_data.get('itemTitle') or card_data.get('name') or '',
|
||
'price': price,
|
||
'price_text': f"{price_info.get('preText', '')}{price}" if price_info else str(price or ''),
|
||
'category_id': card_data.get('categoryId', ''),
|
||
'auction_type': card_data.get('auctionType', ''),
|
||
'item_status': card_data.get('itemStatus', 0),
|
||
'detail_url': card_data.get('detailUrl', ''),
|
||
'web_url': f'https://www.goofish.com/item?id={item_id}',
|
||
'pic_info': pic_info,
|
||
'item_image': item_image,
|
||
'detail_params': detail_params,
|
||
'track_params': card_data.get('trackParams', {}),
|
||
'item_label_data': card_data.get('itemLabelDataVO', {}),
|
||
'card_type': card.get('cardType', 0)
|
||
}
|
||
|
||
@classmethod
|
||
def _extract_items_from_response(cls, items_data):
|
||
"""从新旧商品列表结构中递归提取商品,并按ID去重。"""
|
||
candidates = list(items_data.get('cardList', []) or [])
|
||
|
||
def visit(node):
|
||
if isinstance(node, list):
|
||
for child in node:
|
||
visit(child)
|
||
return
|
||
if not isinstance(node, dict):
|
||
return
|
||
if isinstance(node.get('cardData'), dict):
|
||
candidates.append(node)
|
||
return
|
||
has_item_id = any(key in node for key in ('itemId', 'auctionId'))
|
||
has_item_shape = any(
|
||
key in node
|
||
for key in ('title', 'itemTitle', 'priceInfo', 'detailParams', 'itemInfo', 'itemData')
|
||
)
|
||
if has_item_id and has_item_shape:
|
||
candidates.append(node)
|
||
return
|
||
for child in node.values():
|
||
if isinstance(child, (dict, list)):
|
||
visit(child)
|
||
|
||
visit(items_data.get('itemTopicList', []) or [])
|
||
items = []
|
||
seen_ids = set()
|
||
for candidate in candidates:
|
||
item = cls._normalize_item_card(candidate)
|
||
if item and item['id'] not in seen_ids:
|
||
seen_ids.add(item['id'])
|
||
items.append(item)
|
||
return items
|
||
|
||
async def get_item_list_info(self, page_number=1, page_size=20, retry_count=0):
|
||
"""动态发现当前账号的在售分组并获取商品列表。"""
|
||
if retry_count >= 4:
|
||
return {'error': '获取商品信息失败,重试次数过多'}
|
||
if not self.session:
|
||
await self.create_session()
|
||
|
||
def empty_result(group_name='在售', confirmed=False, response_fields=None):
|
||
"""一件商品都没有,但这次同步是成功的。
|
||
|
||
这里必须带上 success 和调用方要读的全部字段。只有正常路径设了
|
||
`'success': True`,早期返回如果只给 {'items': []},get_all_items 会按
|
||
`not result.get('success')` 判成失败并抛 502 —— 表现为新加的空账号
|
||
同步时只弹一句「Request failed with status code 502」。
|
||
|
||
confirmed 表示「确认这个账号真的没有商品」。它会让上层把库里已有商品
|
||
全标成已下架,所以只有在闲鱼明确回了分组、且分组商品数都是 0 时才置
|
||
True;分组列表整个为空这种可疑情况一律留 False。
|
||
"""
|
||
return {
|
||
'success': True,
|
||
'page_number': page_number,
|
||
'page_size': page_size,
|
||
'current_count': 0,
|
||
'total_count': 0,
|
||
'api_total_count': 0,
|
||
'group_declared_count': 0,
|
||
'count_reconciled': False,
|
||
'items': [],
|
||
'saved_count': 0,
|
||
'next_page': False,
|
||
'confirmed_empty': confirmed,
|
||
'group_name': group_name,
|
||
'group_id': None,
|
||
'account_id': self.myid,
|
||
'response_fields': response_fields or [],
|
||
}
|
||
|
||
async def request_item_api(data):
|
||
params = {
|
||
'jsv': '2.7.2',
|
||
'appKey': '34839810',
|
||
't': str(int(time.time() * 1000)),
|
||
'sign': '',
|
||
'v': '1.0',
|
||
'type': 'originaljson',
|
||
'accountSite': 'xianyu',
|
||
'dataType': 'json',
|
||
'timeout': '20000',
|
||
'api': 'mtop.idle.web.xyh.item.list',
|
||
'sessionOption': 'AutoLoginOnly',
|
||
'spm_cnt': 'a21ybx.personal.0.0'
|
||
}
|
||
cookie_dict = trans_cookies(self.cookies_str)
|
||
token_value = cookie_dict.get('_m_h5_tk', '')
|
||
token = token_value.split('_')[0] if token_value else ''
|
||
if not token:
|
||
logger.warning(f"【{self.cookie_id}】商品同步Cookie缺少_m_h5_tk字段")
|
||
|
||
data_val = json.dumps(data, separators=(',', ':'))
|
||
params['sign'] = generate_sign(params['t'], token, data_val)
|
||
async with self.session.post(
|
||
'https://h5api.m.goofish.com/h5/mtop.idle.web.xyh.item.list/1.0/',
|
||
params=params,
|
||
data={'data': data_val}
|
||
) as response:
|
||
# 网关故障要当场认出来。502/503 返回的是 HTML 错误页,
|
||
# 直接 response.json() 要么抛解析异常、要么拿到不含 ret 的结构,
|
||
# 最后被当成业务问题报成“未返回在售分组”,把临时抖动说成数据异常。
|
||
if response.status >= 500:
|
||
raise ItemListTransientError(
|
||
f"闲鱼接口返回 HTTP {response.status}"
|
||
)
|
||
try:
|
||
res_json = await response.json(content_type=None)
|
||
except Exception as parse_error:
|
||
body = (await response.text())[:200]
|
||
raise ItemListTransientError(
|
||
f"闲鱼接口响应无法解析(HTTP {response.status}): {parse_error};响应片段: {body}"
|
||
)
|
||
if not isinstance(res_json, dict):
|
||
raise ItemListTransientError(
|
||
f"闲鱼接口响应结构异常(HTTP {response.status})"
|
||
)
|
||
if 'set-cookie' in response.headers:
|
||
new_cookies = {}
|
||
for cookie in response.headers.getall('set-cookie', []):
|
||
if '=' in cookie:
|
||
name, value = cookie.split(';')[0].split('=', 1)
|
||
new_cookies[name.strip()] = value.strip()
|
||
if new_cookies:
|
||
self.cookies.update(new_cookies)
|
||
self.cookies_str = '; '.join(f"{key}={value}" for key, value in self.cookies.items())
|
||
self.session.headers['cookie'] = self.cookies_str
|
||
await self.update_config_cookies()
|
||
logger.info(
|
||
f"【{self.cookie_id}】商品同步响应更新了"
|
||
f"{len(new_cookies)}个Cookie字段"
|
||
)
|
||
return res_json
|
||
|
||
try:
|
||
item_group = getattr(self, '_item_list_group', None)
|
||
if page_number == 1 or not item_group:
|
||
discovery_response = await request_item_api({
|
||
'needGroupInfo': True,
|
||
'pageNumber': 1,
|
||
'pageSize': page_size,
|
||
'userId': self.myid
|
||
})
|
||
discovery_ret = discovery_response.get('ret', [])
|
||
if not discovery_ret or not str(discovery_ret[0]).startswith('SUCCESS::'):
|
||
error_msg = discovery_ret[0] if discovery_ret else '未知错误'
|
||
if 'TOKEN' in str(error_msg).upper():
|
||
await asyncio.sleep(0.5)
|
||
return await self.get_item_list_info(page_number, page_size, retry_count + 1)
|
||
return {'error': f"商品分组发现失败: {error_msg}"}
|
||
|
||
# 用 `or []` 兜住显式 None:闲鱼对无分组账号有时省略这个键
|
||
# (get 拿到默认的 []),有时又明确回 null(get 拿到 None)。
|
||
# 后者会让下面的遍历直接抛 TypeError,被外层吞成一句同步失败。
|
||
groups = discovery_response.get('data', {}).get('itemGroupList') or []
|
||
group_summary = [
|
||
{
|
||
'name': group.get('groupName'),
|
||
'id': group.get('groupId'),
|
||
'count': group.get('itemNumber')
|
||
}
|
||
for group in groups
|
||
if isinstance(group, dict)
|
||
]
|
||
logger.info(
|
||
f"【{self.cookie_id}】商品分组发现: account={self.myid}, "
|
||
f"groups={group_summary}"
|
||
)
|
||
item_group = self._select_item_group(groups)
|
||
if not item_group:
|
||
# 没匹配到“在售”不代表拿不到商品:闲鱼可能改了分组文案,
|
||
# 账号也可能只有自定义分组。这里退回“按商品数最多的分组”,
|
||
# 真的一件商品都没有才如实返回空列表,而不是报成接口异常。
|
||
fallback_group = None
|
||
for group in groups:
|
||
if not isinstance(group, dict):
|
||
continue
|
||
if int(group.get('itemNumber') or 0) <= 0:
|
||
continue
|
||
if fallback_group is None or int(group.get('itemNumber') or 0) > int(
|
||
fallback_group.get('itemNumber') or 0
|
||
):
|
||
fallback_group = group
|
||
|
||
if fallback_group:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】未找到“在售”分组,改用"
|
||
f"“{fallback_group.get('groupName')}”"
|
||
f"({fallback_group.get('itemNumber')} 件)继续同步"
|
||
)
|
||
item_group = fallback_group
|
||
elif groups:
|
||
logger.info(
|
||
f"【{self.cookie_id}】账号所有分组均为 0 件商品,按空列表处理"
|
||
)
|
||
return empty_result(confirmed=True)
|
||
else:
|
||
# 分组列表为空,但发现请求本身返回了 SUCCESS —— 登录态是好的。
|
||
# 关键在于:这次请求的响应里往往已经带着商品(cardList),
|
||
# 只是没有分组信息。实测某账号 itemGroupList=None、
|
||
# totalCount=0,而 cardList 里就有它的 4 件商品。
|
||
#
|
||
# 原先这里直接报错,接口以 502 返回,用户只看到一句
|
||
# 「Request failed with status code 502」,而商品明明已经
|
||
# 在手里。所以先尝试直接解析这次响应,解析不到才按空处理。
|
||
discovery_data = discovery_response.get('data', {}) or {}
|
||
discovered_items = self._extract_items_from_response(discovery_data)
|
||
if discovered_items:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】账号无商品分组,改用分组发现响应里的"
|
||
f"商品列表:解析到 {len(discovered_items)} 件"
|
||
)
|
||
saved = await self.save_items_list_to_db(discovered_items)
|
||
return {
|
||
'success': True,
|
||
'page_number': page_number,
|
||
'page_size': page_size,
|
||
'current_count': len(discovered_items),
|
||
'total_count': len(discovered_items),
|
||
'api_total_count': int(discovery_data.get('totalCount') or 0),
|
||
'group_declared_count': 0,
|
||
'count_reconciled': int(discovery_data.get('totalCount') or 0)
|
||
!= len(discovered_items),
|
||
'items': discovered_items,
|
||
'saved_count': saved,
|
||
'next_page': bool(discovery_data.get('nextPage')),
|
||
'confirmed_empty': False,
|
||
'group_name': '全部',
|
||
'group_id': None,
|
||
'account_id': self.myid,
|
||
'response_fields': sorted(discovery_data.keys()),
|
||
}
|
||
|
||
# 连商品也解析不到:可能真的没上架,也可能是接口抖动。
|
||
# 按空列表处理但不置 confirmed_empty —— 后者会让上层把库里
|
||
# 已有商品全标成已下架,不能凭一次可疑的空响应就清空状态。
|
||
logger.info(
|
||
f"【{self.cookie_id}】闲鱼未返回商品分组,响应里也没有商品,"
|
||
f"按空列表处理(字段: {sorted(discovery_data.keys())})"
|
||
)
|
||
return empty_result(
|
||
confirmed=False,
|
||
response_fields=sorted(discovery_data.keys()),
|
||
)
|
||
self._item_list_group = item_group
|
||
|
||
group_id = item_group.get('groupId')
|
||
group_name = item_group.get('groupName', '在售')
|
||
res_json = await request_item_api({
|
||
'needGroupInfo': False,
|
||
'pageNumber': page_number,
|
||
'pageSize': page_size,
|
||
'groupName': group_name,
|
||
'groupId': str(group_id),
|
||
'defaultGroup': bool(item_group.get('defaultGroup', True)),
|
||
'userId': self.myid
|
||
})
|
||
response_ret = res_json.get('ret', [])
|
||
if not response_ret or not str(response_ret[0]).startswith('SUCCESS::'):
|
||
error_msg = response_ret[0] if response_ret else '未知错误'
|
||
if 'TOKEN' in str(error_msg).upper():
|
||
await asyncio.sleep(0.5)
|
||
return await self.get_item_list_info(page_number, page_size, retry_count + 1)
|
||
return {'error': f"获取商品信息失败: {error_msg}"}
|
||
|
||
items_data = res_json.get('data', {})
|
||
items_list = self._extract_items_from_response(items_data)
|
||
total_count = int(items_data.get('totalCount') or 0)
|
||
declared_count = int(item_group.get('itemNumber') or 0)
|
||
effective_total_count = max(total_count, declared_count, len(items_list))
|
||
response_fields = sorted(items_data.keys())
|
||
logger.info(
|
||
f"【{self.cookie_id}】商品列表响应: account={self.myid}, "
|
||
f"group={group_name}({group_id}), page={page_number}, "
|
||
f"fields={response_fields}, totalCount={total_count}, "
|
||
f"groupItemNumber={declared_count}, parsed={len(items_list)}, "
|
||
f"effectiveTotal={effective_total_count}, "
|
||
f"nextPage={bool(items_data.get('nextPage'))}"
|
||
)
|
||
|
||
if total_count > 0 and not items_list:
|
||
return {
|
||
'error': f"闲鱼接口显示“{group_name}”有 {total_count} 件商品,但当前响应结构无法解析",
|
||
'response_fields': response_fields
|
||
}
|
||
if total_count == 0 and declared_count > 0 and not items_list:
|
||
return {
|
||
'error': f"闲鱼分组显示“{group_name}”有 {declared_count} 件商品,但列表接口返回0件,请稍后重试",
|
||
'response_fields': response_fields
|
||
}
|
||
if items_list and total_count == 0:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】闲鱼商品列表 totalCount=0,"
|
||
f"但实际解析到 {len(items_list)} 件,按商品数组继续同步"
|
||
)
|
||
|
||
saved_count = 0
|
||
if items_list:
|
||
saved_count = await self.save_items_list_to_db(items_list)
|
||
|
||
return {
|
||
'success': True,
|
||
'page_number': page_number,
|
||
'page_size': page_size,
|
||
'current_count': len(items_list),
|
||
'total_count': effective_total_count,
|
||
'api_total_count': total_count,
|
||
'group_declared_count': declared_count,
|
||
'count_reconciled': effective_total_count != total_count,
|
||
'items': items_list,
|
||
'saved_count': saved_count,
|
||
'next_page': bool(items_data.get('nextPage')),
|
||
'confirmed_empty': not items_list and total_count == 0 and declared_count == 0,
|
||
'group_name': group_name,
|
||
'group_id': group_id,
|
||
'account_id': self.myid,
|
||
'response_fields': response_fields
|
||
}
|
||
except ItemListTransientError as e:
|
||
# 网关抖动:退避重试,比固定 0.5 秒更有机会等到恢复。
|
||
# 重试用尽后如实说明是接口临时故障,不要让用户去查账号和商品。
|
||
if retry_count + 1 >= 4:
|
||
logger.error(f"【{self.cookie_id}】商品列表接口持续异常: {self._safe_str(e)}")
|
||
return {'error': f"闲鱼接口暂时不可用({self._safe_str(e)}),请稍后重试"}
|
||
backoff = min(2 ** retry_count, 8)
|
||
logger.warning(
|
||
f"【{self.cookie_id}】商品列表接口异常,{backoff}秒后重试"
|
||
f"(第{retry_count + 1}次): {self._safe_str(e)}"
|
||
)
|
||
await asyncio.sleep(backoff)
|
||
return await self.get_item_list_info(page_number, page_size, retry_count + 1)
|
||
except Exception as e:
|
||
logger.error(f"商品信息API请求异常: {self._safe_str(e)}")
|
||
await asyncio.sleep(0.5)
|
||
return await self.get_item_list_info(page_number, page_size, retry_count + 1)
|
||
|
||
async def get_all_items(self, page_size=20, max_pages=None):
|
||
"""获取所有在售商品信息(自动分页)。"""
|
||
all_items = []
|
||
page_number = 1
|
||
pages_fetched = 0
|
||
total_saved = 0
|
||
confirmed_empty = False
|
||
group_name = '在售'
|
||
api_total_count = 0
|
||
group_declared_count = 0
|
||
count_reconciled = False
|
||
|
||
logger.info(f"开始获取所有商品信息,每页{page_size}条")
|
||
while True:
|
||
if max_pages and page_number > max_pages:
|
||
logger.info(f"达到最大页数限制 {max_pages},停止获取")
|
||
break
|
||
|
||
result = await self.get_item_list_info(page_number, page_size)
|
||
if not result.get('success'):
|
||
logger.error(f"获取第 {page_number} 页失败: {result}")
|
||
return {
|
||
'success': False,
|
||
'error': result.get('error', '商品同步失败'),
|
||
'details': result
|
||
}
|
||
|
||
pages_fetched += 1
|
||
current_items = result.get('items', [])
|
||
confirmed_empty = result.get('confirmed_empty', False)
|
||
group_name = result.get('group_name', group_name)
|
||
api_total_count = max(api_total_count, result.get('api_total_count', 0))
|
||
group_declared_count = max(
|
||
group_declared_count,
|
||
result.get('group_declared_count', 0)
|
||
)
|
||
count_reconciled = count_reconciled or result.get('count_reconciled', False)
|
||
if not current_items:
|
||
break
|
||
|
||
all_items.extend(current_items)
|
||
total_saved += result.get('saved_count', 0)
|
||
if not result.get('next_page', len(current_items) >= page_size):
|
||
break
|
||
|
||
page_number += 1
|
||
await asyncio.sleep(1)
|
||
|
||
logger.info(
|
||
f"【{self.cookie_id}】商品获取完成: account={self.myid}, "
|
||
f"group={group_name}, total={len(all_items)}, saved={total_saved}, "
|
||
f"confirmed_empty={confirmed_empty}"
|
||
)
|
||
|
||
# 校准上下架状态。同步原先只做 upsert,接口不再返回的商品会永久留在
|
||
# 列表里,和在售的长得一样 —— 用户既分不清也筛不掉。
|
||
#
|
||
# 只在「完整且成功」的同步后才校准,否则会把在售商品误标成下架:
|
||
# - 被 max_pages 截断时,后面几页的商品根本没被拉取;
|
||
# - 一件都没返回时,除非接口明确确认为空,否则更可能是接口抖动。
|
||
off_shelf_count = 0
|
||
truncated = bool(max_pages and pages_fetched >= max_pages)
|
||
if truncated:
|
||
logger.info(f"【{self.cookie_id}】同步被 max_pages 截断,跳过上下架校准")
|
||
elif not all_items and not confirmed_empty:
|
||
logger.warning(
|
||
f"【{self.cookie_id}】接口未返回任何商品且未确认为空,"
|
||
f"跳过上下架校准以免误标"
|
||
)
|
||
else:
|
||
from app.db_manager import db_manager
|
||
stats = db_manager.reconcile_item_listing_status(
|
||
self.cookie_id, [item.get('id') for item in all_items]
|
||
)
|
||
off_shelf_count = stats.get('off_shelf', 0)
|
||
|
||
return {
|
||
'success': True,
|
||
'total_pages': pages_fetched,
|
||
'total_count': len(all_items),
|
||
'total_saved': total_saved,
|
||
'items': all_items,
|
||
'api_total_count': api_total_count,
|
||
'group_declared_count': group_declared_count,
|
||
'parsed_count': len(all_items),
|
||
'count_reconciled': count_reconciled,
|
||
'confirmed_empty': confirmed_empty,
|
||
'group_name': group_name,
|
||
'account_id': self.myid,
|
||
'off_shelf_count': off_shelf_count
|
||
}
|
||
|
||
async def send_image_msg(self, ws, cid, toid, image_url, width=800, height=600, card_id=None):
|
||
"""发送图片消息"""
|
||
try:
|
||
# 检查图片URL是否需要上传到CDN
|
||
original_url = image_url
|
||
|
||
if self._is_cdn_url(image_url):
|
||
# 已经是CDN链接,直接使用
|
||
logger.info(f"【{self.cookie_id}】使用已有的CDN图片链接: {image_url}")
|
||
elif image_url.startswith('/static/uploads/') or image_url.startswith('static/uploads/'):
|
||
# 本地图片,需要上传到闲鱼CDN
|
||
local_image_path = image_url.replace('/static/uploads/', 'static/uploads/')
|
||
if os.path.exists(local_image_path):
|
||
logger.info(f"【{self.cookie_id}】准备上传本地图片到闲鱼CDN: {local_image_path}")
|
||
|
||
# 使用图片上传器上传到闲鱼CDN
|
||
from utils.image_uploader import ImageUploader
|
||
uploader = ImageUploader(self.cookies_str)
|
||
|
||
async with uploader:
|
||
cdn_url = await uploader.upload_image(local_image_path)
|
||
if cdn_url:
|
||
logger.info(f"【{self.cookie_id}】图片上传成功,CDN URL: {cdn_url}")
|
||
image_url = cdn_url
|
||
|
||
# 如果是卡券图片,更新数据库中的图片URL
|
||
if card_id is not None:
|
||
await self._update_card_image_url(card_id, cdn_url)
|
||
|
||
# 获取实际图片尺寸
|
||
from utils.image_utils import image_manager
|
||
try:
|
||
actual_width, actual_height = image_manager.get_image_size(local_image_path)
|
||
if actual_width and actual_height:
|
||
width, height = actual_width, actual_height
|
||
logger.info(f"【{self.cookie_id}】获取到实际图片尺寸: {width}x{height}")
|
||
except Exception as e:
|
||
logger.warning(f"【{self.cookie_id}】获取图片尺寸失败,使用默认尺寸: {e}")
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】图片上传失败: {local_image_path}")
|
||
logger.error(f"【{self.cookie_id}】❌ Cookie可能已失效!请检查配置并更新Cookie")
|
||
raise Exception(f"图片上传失败(Cookie可能已失效): {local_image_path}")
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】本地图片文件不存在: {local_image_path}")
|
||
raise Exception(f"本地图片文件不存在: {local_image_path}")
|
||
else:
|
||
logger.warning(f"【{self.cookie_id}】未知的图片URL格式: {image_url}")
|
||
|
||
# 记录详细的图片信息
|
||
logger.info(f"【{self.cookie_id}】准备发送图片消息:")
|
||
logger.info(f" - 原始URL: {original_url}")
|
||
logger.info(f" - CDN URL: {image_url}")
|
||
logger.info(f" - 图片尺寸: {width}x{height}")
|
||
logger.info(f" - 聊天ID: {cid}")
|
||
logger.info(f" - 接收者ID: {toid}")
|
||
|
||
# 构造图片消息内容 - 使用正确的闲鱼格式
|
||
image_content = {
|
||
"contentType": 2, # 图片消息类型
|
||
"image": {
|
||
"pics": [
|
||
{
|
||
"height": int(height),
|
||
"type": 0,
|
||
"url": image_url,
|
||
"width": int(width)
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
# Base64编码
|
||
content_json = json.dumps(image_content, ensure_ascii=False)
|
||
content_base64 = str(base64.b64encode(content_json.encode('utf-8')), 'utf-8')
|
||
|
||
logger.info(f"【{self.cookie_id}】图片内容JSON: {content_json}")
|
||
logger.info(f"【{self.cookie_id}】Base64编码长度: {len(content_base64)}")
|
||
|
||
# 构造WebSocket消息(完全参考send_msg的格式)
|
||
msg = {
|
||
"lwp": "/r/MessageSend/sendByReceiverScope",
|
||
"headers": {
|
||
"mid": generate_mid()
|
||
},
|
||
"body": [
|
||
{
|
||
"uuid": generate_uuid(),
|
||
"cid": f"{cid}@goofish",
|
||
"conversationType": 1,
|
||
"content": {
|
||
"contentType": 101,
|
||
"custom": {
|
||
"type": 1,
|
||
"data": content_base64
|
||
}
|
||
},
|
||
"redPointPolicy": 0,
|
||
"extension": {
|
||
"extJson": "{}"
|
||
},
|
||
"ctx": {
|
||
"appVersion": "1.0",
|
||
"platform": "web"
|
||
},
|
||
"mtags": {},
|
||
"msgReadStatusSetting": 1
|
||
},
|
||
{
|
||
"actualReceivers": [
|
||
f"{toid}@goofish",
|
||
f"{self.myid}@goofish"
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
await ws.send(json.dumps(msg))
|
||
logger.info(f"【{self.cookie_id}】图片消息发送成功: {image_url}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】发送图片消息失败: {self._safe_str(e)}")
|
||
raise
|
||
|
||
async def send_image_from_file(self, ws, cid, toid, image_path):
|
||
"""从本地文件发送图片"""
|
||
try:
|
||
# 上传图片到闲鱼CDN
|
||
logger.info(f"【{self.cookie_id}】开始上传图片: {image_path}")
|
||
|
||
from utils.image_uploader import ImageUploader
|
||
uploader = ImageUploader(self.cookies_str)
|
||
|
||
async with uploader:
|
||
image_url = await uploader.upload_image(image_path)
|
||
|
||
if image_url:
|
||
# 获取图片信息
|
||
try:
|
||
from PIL import Image
|
||
with Image.open(image_path) as img:
|
||
width, height = img.size
|
||
except Exception as e:
|
||
logger.warning(f"无法获取图片尺寸,使用默认值: {e}")
|
||
width, height = 800, 600
|
||
|
||
# 发送图片消息
|
||
await self.send_image_msg(ws, cid, toid, image_url, width, height)
|
||
logger.info(f"【{self.cookie_id}】图片发送完成: {image_path} -> {image_url}")
|
||
return True
|
||
else:
|
||
logger.error(f"【{self.cookie_id}】图片上传失败: {image_path}")
|
||
logger.error(f"【{self.cookie_id}】❌ Cookie可能已失效!请检查配置并更新Cookie")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"【{self.cookie_id}】从文件发送图片失败: {self._safe_str(e)}")
|
||
return False
|
||
|
||
if __name__ == '__main__':
|
||
cookies_str = os.getenv('COOKIES_STR')
|
||
xianyuLive = XianyuLive(cookies_str)
|
||
asyncio.run(xianyuLive.main())
|