diff --git a/.gitignore b/.gitignore index 9695d70..d542d2d 100644 --- a/.gitignore +++ b/.gitignore @@ -294,6 +294,8 @@ example_*.py *_example.py demo_*.py *_demo.py +fix_*.py +*_fix.py # 文档文件(除了README.md) *.md @@ -301,6 +303,7 @@ demo_*.py !CHANGELOG.md !CONTRIBUTING.md !LICENSE.md +!docs/*.md # 临时配置文件 *.local.yml diff --git a/Dockerfile b/Dockerfile index 870186a..822bf45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,11 +3,13 @@ FROM python:3.11-slim-bookworm # 设置标签信息 LABEL maintainer="zhinianboke" -LABEL version="2.1.0" +LABEL version="2.2.0" LABEL description="闲鱼自动回复系统 - 企业级多用户版本,支持自动发货和免拼发货" LABEL repository="https://github.com/zhinianboke/xianyu-auto-reply" LABEL license="仅供学习使用,禁止商业用途" LABEL author="zhinianboke" +LABEL build-date="" +LABEL vcs-ref="" # 设置工作目录 WORKDIR /app @@ -99,9 +101,10 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 -# 复制启动脚本 +# 复制启动脚本并设置权限 COPY entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh && \ + dos2unix /app/entrypoint.sh 2>/dev/null || true -# 启动命令 -CMD ["/app/entrypoint.sh"] \ No newline at end of file +# 启动命令(使用ENTRYPOINT确保脚本被执行) +ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index adbec11..5e60e3a 100644 --- a/README.md +++ b/README.md @@ -113,13 +113,8 @@ xianyu-auto-reply/ │ ├── index.html # 主管理界面(集成所有功能模块) │ ├── login.html # 用户登录页面 │ ├── register.html # 用户注册页面(邮箱验证) -│ ├── user_management.html # 用户管理页面(管理员功能) -│ ├── data_management.html # 数据管理页面(导入导出) -│ ├── log_management.html # 日志管理页面(实时日志查看) -│ ├── item_search.html # 商品搜索页面(独立版本) │ ├── js/ -│ │ ├── app.js # 主要JavaScript逻辑 -│ │ └── modules/ # 模块化JavaScript文件 +│ │ └── app.js # 主要JavaScript逻辑和所有功能模块 │ ├── css/ │ │ ├── variables.css # CSS变量定义 │ │ ├── layout.css # 布局样式 @@ -141,16 +136,23 @@ xianyu-auto-reply/ │ ├── wechat-group.png # 微信群二维码 │ └── qq-group.png # QQ群二维码 ├── 🐳 Docker部署 -│ ├── Dockerfile # Docker镜像构建文件 +│ ├── Dockerfile # Docker镜像构建文件(优化版) +│ ├── Dockerfile-cn # 国内优化版Docker镜像构建文件 │ ├── docker-compose.yml # Docker Compose一键部署配置 +│ ├── docker-compose-cn.yml # 国内优化版Docker Compose配置 │ ├── docker-deploy.sh # Docker部署管理脚本(Linux/macOS) │ ├── docker-deploy.bat # Docker部署管理脚本(Windows) -│ └── entrypoint.sh # Docker容器启动脚本 +│ ├── entrypoint.sh # Docker容器启动脚本 +│ └── .dockerignore # Docker构建忽略文件 +├── 🌐 Nginx配置 +│ └── nginx/ +│ ├── nginx.conf # Nginx反向代理配置 +│ └── ssl/ # SSL证书目录 ├── 📋 配置文件 │ ├── global_config.yml # 全局配置文件(WebSocket、API等) -│ ├── requirements.txt # Python依赖包列表(精简版) -│ ├── .gitignore # Git忽略文件配置 -│ └── README.md # 项目说明文档 +│ ├── requirements.txt # Python依赖包列表(精简版,无内置模块) +│ ├── .gitignore # Git忽略文件配置(完整版) +│ └── README.md # 项目说明文档(本文件) └── 📊 数据目录(运行时创建) ├── data/ # 数据目录(Docker挂载) │ └── xianyu_data.db # SQLite数据库文件 @@ -340,7 +342,7 @@ python Start.py - **多重安全验证** - 超级加密保护,防止误操作和数据泄露 - **批量处理能力** - 支持批量确认发货,提高处理效率 - **异常处理机制** - 完善的错误处理和重试机制,确保发货成功 -- **多渠道通知** - 支持QQ、钉钉、邮件等多种发货通知方式 +- **多渠道通知** - 支持QQ、钉钉、飞书、Bark、邮件等多种发货通知方式 ### 👥 多用户系统 - **用户注册登录** - 支持邮箱验证和图形验证码,安全可靠 @@ -364,10 +366,11 @@ python Start.py - **账号状态验证** - 自动检查cookies启用状态,确保搜索功能正常 ### 📱 通知系统 -- **多渠道支持** - QQ、钉钉、邮件、微信、Telegram等6种通知方式 +- **多渠道支持** - QQ、钉钉、飞书、Bark、邮件、微信、Telegram等8种通知方式 - **智能配置** - 可视化配置界面,支持复杂参数和加密设置 - **实时推送** - 重要事件实时通知,及时了解系统状态 - **通知模板** - 自定义通知内容和格式,个性化消息推送 +- **移动端支持** - Bark iOS推送,随时随地接收通知 ### 🔐 安全特性 - **Cookie安全管理** - 加密存储用户凭证,定期自动刷新 @@ -411,30 +414,27 @@ python Start.py - **`image_uploader.py`** - 图片上传工具,支持多种CDN服务商、自动压缩、格式优化、批量上传 ### 🌐 前端界面 (`static/`) -- **`index.html`** - 主管理界面,包含账号管理、关键词管理、系统监控、实时状态显示 +- **`index.html`** - 主管理界面,集成所有功能模块:账号管理、关键词管理、商品管理、发货管理、系统监控、用户管理等 - **`login.html`** - 用户登录页面,支持图形验证码、记住登录状态、多重安全验证 - **`register.html`** - 用户注册页面,支持邮箱验证码、实时验证、密码强度检测 -- **`user_management.html`** - 用户管理页面,管理员专用,用户增删改查、权限管理 -- **`data_management.html`** - 数据管理页面,支持Excel导入导出、数据备份、批量操作 -- **`log_management.html`** - 日志管理页面,实时日志查看、日志搜索过滤、日志下载 -- **`item_search.html`** - 商品搜索页面,获取真实闲鱼商品数据,支持多条件搜索 -- **`js/app.js`** - 主要JavaScript逻辑,处理前端交互、API调用、实时更新 -- **`css/`** - 模块化样式文件,包含布局、组件、主题等分类样式,响应式设计 +- **`js/app.js`** - 主要JavaScript逻辑,包含所有功能模块:前端交互、API调用、实时更新、数据管理、用户界面控制 +- **`css/`** - 模块化样式文件,包含布局、组件、主题等分类样式,响应式设计,支持明暗主题切换 - **`xianyu_js_version_2.js`** - 闲鱼JavaScript工具库,加密解密、数据处理、API封装 -- **`lib/`** - 前端依赖库,包含Bootstrap、jQuery、Chart.js等第三方库 +- **`lib/`** - 前端依赖库,包含Bootstrap 5、Bootstrap Icons等第三方库 +- **`uploads/images/`** - 图片上传目录,支持发货图片和其他媒体文件存储 ### 🐳 部署配置 -- **`Dockerfile`** - Docker镜像构建文件,包含Python环境、Playwright浏览器、系统依赖,支持无头模式运行 -- **`docker-compose.yml`** - Docker Compose配置,支持一键部署、环境变量配置、资源限制、健康检查 -- **`docker-deploy.sh`** - Docker部署管理脚本,提供构建、启动、监控、日志查看等功能(Linux/macOS) -- **`docker-deploy.bat`** - Windows版本部署脚本,支持Windows环境一键部署 -- **`entrypoint.sh`** - Docker容器启动脚本,处理环境初始化和服务启动 -- **`nginx/nginx.conf`** - Nginx反向代理配置,支持负载均衡、SSL终端、WebSocket代理 -- **`requirements.txt`** - Python依赖包列表,精简版本无冗余依赖,按功能分类组织,包含详细说明 -- **`.gitignore`** - Git忽略文件配置,完整覆盖Python、Docker、前端等开发文件 -- **`.dockerignore`** - Docker构建忽略文件,优化构建上下文大小和构建速度 -- **`Dockerfile-cn`** - 国内优化版Docker镜像构建文件,使用国内镜像源加速构建 -- **`docker-compose-cn.yml`** - 国内优化版Docker Compose配置文件 +- **`Dockerfile`** - Docker镜像构建文件,基于Python 3.11-slim,包含Playwright浏览器、系统依赖,支持无头模式运行,优化构建层级 +- **`Dockerfile-cn`** - 国内优化版Docker镜像构建文件,使用国内镜像源加速构建,适合国内网络环境 +- **`docker-compose.yml`** - Docker Compose配置,支持一键部署、完整环境变量配置、资源限制、健康检查、可选Nginx代理 +- **`docker-compose-cn.yml`** - 国内优化版Docker Compose配置文件,使用国内镜像源 +- **`docker-deploy.sh`** - Docker部署管理脚本,提供构建、启动、停止、重启、监控、日志查看等功能(Linux/macOS) +- **`docker-deploy.bat`** - Windows版本部署脚本,支持Windows环境一键部署和管理 +- **`entrypoint.sh`** - Docker容器启动脚本,处理环境初始化、目录创建、权限设置和服务启动 +- **`nginx/nginx.conf`** - Nginx反向代理配置,支持负载均衡、SSL终端、WebSocket代理、静态文件服务 +- **`requirements.txt`** - Python依赖包列表,精简版本无内置模块,按功能分类组织,包含详细版本说明和安装指南 +- **`.gitignore`** - Git忽略文件配置,完整覆盖Python、Docker、前端、测试、临时文件等,支持项目特定文件类型 +- **`.dockerignore`** - Docker构建忽略文件,优化构建上下文大小和构建速度,排除不必要的文件和目录 ## 🏗️ 详细技术架构 diff --git a/Start.py b/Start.py index fb29ce4..313adfb 100644 --- a/Start.py +++ b/Start.py @@ -6,6 +6,7 @@ """ import os +import sys import asyncio import threading import uvicorn @@ -13,6 +14,15 @@ from urllib.parse import urlparse from pathlib import Path from loguru import logger +# 修复Linux环境下的asyncio子进程问题 +if sys.platform.startswith('linux'): + try: + # 在程序启动时就设置正确的事件循环策略 + asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) + logger.debug("已设置事件循环策略以支持子进程") + except Exception as e: + logger.debug(f"设置事件循环策略失败: {e}") + from config import AUTO_REPLY, COOKIES_LIST import cookie_manager as cm from db_manager import db_manager diff --git a/XianyuAutoAsync.py b/XianyuAutoAsync.py index 796b10c..7c93570 100644 --- a/XianyuAutoAsync.py +++ b/XianyuAutoAsync.py @@ -195,6 +195,24 @@ class XianyuLive: # 启动定期清理过期暂停记录的任务 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_running = False # 防止重复执行Cookie刷新 + self.cookie_refresh_enabled = True # 是否启用Cookie刷新功能 + + # 扫码登录Cookie刷新标志 + self.last_qr_cookie_refresh_time = 0 # 记录上次扫码登录Cookie刷新时间 + self.qr_cookie_refresh_cooldown = 600 # 扫码登录Cookie刷新后的冷却时间:10分钟 + + + + # WebSocket连接监控 + self.connection_failures = 0 # 连续连接失败次数 + self.max_connection_failures = 5 # 最大连续失败次数 + self.last_successful_connection = 0 # 上次成功连接时间 + def is_auto_confirm_enabled(self) -> bool: @@ -642,18 +660,18 @@ class XianyuLive: # 发送成功通知 if len(delivery_contents) > 1: - await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, f"多数量发货成功,共发送 {len(delivery_contents)} 个卡券") + await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, f"多数量发货成功,共发送 {len(delivery_contents)} 个卡券", chat_id) else: - await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, "发货成功") + await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, "发货成功", chat_id) else: logger.warning(f'[{msg_time}] 【自动发货】未找到匹配的发货规则或获取发货内容失败') # 发送自动发货失败通知 - await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, "未找到匹配的发货规则或获取发货内容失败") + 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)}") + 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},自动发货处理完成') @@ -666,10 +684,13 @@ class XianyuLive: """刷新token""" try: logger.info(f"【{self.cookie_id}】开始刷新token...") + # 生成更精确的时间戳 + timestamp = str(int(time.time() * 1000)) + params = { 'jsv': '2.7.2', 'appKey': '34839810', - 't': str(int(time.time()) * 1000), + 't': timestamp, 'sign': '', 'v': '1.0', 'type': 'originaljson', @@ -678,7 +699,13 @@ class XianyuLive: '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 = { @@ -692,9 +719,25 @@ class XianyuLive: sign = generate_sign(params['t'], token, data_val) params['sign'] = sign - # 发送请求 - headers = DEFAULT_HEADERS.copy() - headers['cookie'] = self.cookies_str + # 发送请求 - 使用与浏览器完全一致的请求头 + 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 + } async with aiohttp.ClientSession() as session: async with session.post( @@ -735,13 +778,20 @@ class XianyuLive: return new_token logger.error(f"【{self.cookie_id}】Token刷新失败: {res_json}") - + + # 清空当前token,确保下次重试时重新获取 + self.current_token = None + # 发送Token刷新失败通知 await self.send_token_refresh_notification(f"Token刷新失败: {res_json}", "token_refresh_failed") return None except Exception as e: logger.error(f"Token刷新异常: {self._safe_str(e)}") + + # 清空当前token,确保下次重试时重新获取 + self.current_token = None + # 发送Token刷新异常通知 await self.send_token_refresh_notification(f"Token刷新异常: {str(e)}", "token_refresh_exception") return None @@ -1769,12 +1819,22 @@ class XianyuLive: except: return 0.0 - async def send_notification(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str = None): + 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 db_manager import db_manager import aiohttp + # 过滤系统默认消息,不发送通知 + system_messages = [ + '发来一条消息', + '发来一条新消息' + ] + + if send_message in system_messages: + logger.debug(f"📱 系统消息不发送通知: {send_message}") + return + logger.info(f"📱 开始发送消息通知 - 账号: {self.cookie_id}, 买家: {send_user_name}") # 获取当前账号的通知配置 @@ -1791,6 +1851,7 @@ class XianyuLive: 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" @@ -1819,6 +1880,12 @@ class XianyuLive: 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) @@ -1945,6 +2012,159 @@ class XianyuLive: 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(f"📱 飞书通知 - 开始处理配置数据: {config_data}") + + # 解析配置 + 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( + secret.encode('utf-8'), + string_to_sign.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 + from urllib.parse import quote + + logger.info(f"📱 Bark通知 - 开始处理配置数据: {config_data}") + + # 解析配置 + 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通知 - 设备密钥: {device_key[:10]}..." if device_key else "📱 Bark通知 - 设备密钥: 未设置") + 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): """发送邮件通知""" try: @@ -2097,7 +2317,7 @@ class XianyuLive: 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"): + async def send_token_refresh_notification(self, error_message: str, notification_type: str = "token_refresh", chat_id: str = None): """发送Token刷新异常通知(带防重复机制)""" try: # 检查是否是正常的令牌过期,这种情况不需要发送通知 @@ -2147,6 +2367,7 @@ class XianyuLive: notification_msg = f"""🔴 闲鱼账号Token刷新异常 账号ID: {self.cookie_id} +聊天ID: {chat_id or '未知'} 异常时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())} 异常信息: {error_message} @@ -2174,6 +2395,12 @@ class XianyuLive: 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) notification_sent = True @@ -2282,7 +2509,7 @@ class XianyuLive: return False - async def send_delivery_failure_notification(self, send_user_name: str, send_user_id: str, item_id: str, error_message: str): + 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 db_manager import db_manager @@ -2299,6 +2526,7 @@ class XianyuLive: 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"请及时处理!" @@ -3048,6 +3276,10 @@ class XianyuLive: break 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 asyncio.sleep(self.token_retry_interval) @@ -3132,6 +3364,7 @@ class XianyuLive: 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: @@ -3193,6 +3426,9 @@ class XianyuLive: async def heartbeat_loop(self, ws): """心跳循环""" + consecutive_failures = 0 + max_failures = 3 # 连续失败3次后停止心跳 + while True: try: # 检查账号是否启用 @@ -3201,11 +3437,26 @@ class XianyuLive: 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 asyncio.sleep(self.heartbeat_interval) + except Exception as e: - logger.error(f"心跳发送失败: {self._safe_str(e)}") - break + 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 + + # 失败后短暂等待再重试 + await asyncio.sleep(5) async def handle_heartbeat_response(self, message_data): """处理心跳响应""" @@ -3230,16 +3481,742 @@ class XianyuLive: # 清理过期的暂停记录 pause_manager.cleanup_expired_pauses() - + # 清理过期的锁(每5分钟清理一次,保留24小时内的锁) self.cleanup_expired_locks(max_age_hours=24) - + # 每5分钟清理一次 await asyncio.sleep(300) except Exception as e: logger.error(f"【{self.cookie_id}】清理任务失败: {self._safe_str(e)}") await asyncio.sleep(300) # 出错后也等待5分钟再重试 + + async def cookie_refresh_loop(self): + """Cookie刷新定时任务 - 每小时执行一次""" + while True: + try: + # 检查账号是否启用 + from 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.debug(f"【{self.cookie_id}】Cookie刷新功能已禁用,跳过执行") + await asyncio.sleep(300) # 5分钟后再检查 + continue + + current_time = time.time() + if current_time - self.last_cookie_refresh_time >= self.cookie_refresh_interval: + # 检查是否已有Cookie刷新任务在执行 + if self.cookie_refresh_running: + logger.debug(f"【{self.cookie_id}】Cookie刷新任务已在执行中,跳过本次触发") + else: + logger.info(f"【{self.cookie_id}】开始执行Cookie刷新任务...") + # 在独立的任务中执行Cookie刷新,避免阻塞主循环 + asyncio.create_task(self._execute_cookie_refresh(current_time)) + + # 每分钟检查一次是否需要执行 + await asyncio.sleep(60) + except Exception as e: + logger.error(f"【{self.cookie_id}】Cookie刷新循环失败: {self._safe_str(e)}") + await asyncio.sleep(60) # 出错后也等待1分钟再重试 + + async def _execute_cookie_refresh(self, current_time): + """独立执行Cookie刷新任务,避免阻塞主循环""" + + + # 设置运行状态,防止重复执行 + self.cookie_refresh_running = True + + 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.debug(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.debug(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刷新任务完成,心跳已恢复") + 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)) + + # 清除运行状态 + self.cookie_refresh_running = False + + + + 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)}") + + # Docker环境下修复asyncio子进程问题 + is_docker = os.getenv('DOCKER_ENV') or os.path.exists('/.dockerenv') + + if is_docker: + logger.debug(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.debug(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' + ]) + + # 使用无头浏览器 + browser = await playwright.chromium.launch( + headless=True, # 改回无头模式 + args=browser_args + ) + + # 创建浏览器上下文 + 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到浏览器") + + # 打印设置的扫码Cookie详情 + logger.info(f"【{target_cookie_id}】=== 设置到浏览器的扫码Cookie ===") + for i, cookie in enumerate(cookies, 1): + logger.info(f"【{target_cookie_id}】{i:2d}. {cookie['name']}: {cookie['value'][:50]}{'...' if len(cookie['value']) > 50 else ''}") + + # 创建页面 + page = await context.new_page() + + # 等待页面准备 + await asyncio.sleep(0.1) + + # 访问指定页面获取真实cookie + target_url = "https://www.goofish.com/im?spm=a21ybx.home.sidebar.1.4c053da6vYwnmf" + 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)} 个字段") + + # 打印完整的真实Cookie内容 + logger.info(f"【{target_cookie_id}】=== 完整真实Cookie内容 ===") + logger.info(f"【{target_cookie_id}】Cookie字符串长度: {len(real_cookies_str)}") + logger.info(f"【{target_cookie_id}】Cookie完整内容:") + logger.info(f"【{target_cookie_id}】{real_cookies_str}") + + # 打印所有Cookie字段的详细信息 + logger.info(f"【{target_cookie_id}】=== Cookie字段详细信息 ===") + for i, (name, value) in enumerate(real_cookies_dict.items(), 1): + # 对于长值,显示前后部分 + if len(value) > 50: + display_value = f"{value[:20]}...{value[-20:]}" + else: + display_value = value + logger.info(f"【{target_cookie_id}】{i:2d}. {name}: {display_value}") + + # 打印原始扫码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字段的完整详情 + important_cookies = ['_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'unb', 'uc1', 'uc3', 'uc4'] + logger.info(f"【{target_cookie_id}】=== 重要Cookie字段完整详情 ===") + for cookie_name in important_cookies: + if cookie_name in real_cookies_dict: + cookie_value = real_cookies_dict[cookie_name] + + # 标记是否发生了变化 + change_mark = " [已变化]" if cookie_name in changed_cookies else " [新增]" if cookie_name in new_cookies else " [无变化]" + + # 显示完整的cookie值 + logger.info(f"【{target_cookie_id}】{cookie_name}{change_mark}:") + logger.info(f"【{target_cookie_id}】 值: {cookie_value}") + logger.info(f"【{target_cookie_id}】 长度: {len(cookie_value)}") + + # 如果有对应的扫码cookie值,显示对比 + if cookie_name in qr_cookies_dict: + old_value = qr_cookies_dict[cookie_name] + if old_value != cookie_value: + logger.info(f"【{target_cookie_id}】 原值: {old_value}") + logger.info(f"【{target_cookie_id}】 原长度: {len(old_value)}") + logger.info(f"【{target_cookie_id}】 ---") + else: + logger.info(f"【{target_cookie_id}】{cookie_name}: [不存在]") + + # 保存真实Cookie到数据库 + from db_manager import db_manager + 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: + if browser: + await browser.close() + if playwright: + await playwright.stop() + except Exception as cleanup_e: + logger.warning(f"【{target_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): + """通过浏览器访问指定页面刷新Cookie""" + + + 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.debug(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.debug(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' + ]) + + # Cookie刷新模式使用无头浏览器 + browser = await playwright.chromium.launch( + headless=True, + args=browser_args + ) + + # 创建浏览器上下文 + 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?spm=a21ybx.home.sidebar.1.4c053da6vYwnmf" + 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() + + # 构造新的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无变化") + + # 打印完整的更新后Cookie(可选择性启用) + logger.info(f"【{self.cookie_id}】更新后的完整Cookie: {self.cookies_str}") + + # 打印主要的Cookie字段详情 + important_cookies = ['_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'unb', 'uc1', 'uc3', 'uc4'] + logger.info(f"【{self.cookie_id}】重要Cookie字段详情:") + for cookie_name in important_cookies: + if cookie_name in new_cookies_dict: + cookie_value = new_cookies_dict[cookie_name] + # 对于敏感信息,只显示前后几位 + if len(cookie_value) > 20: + display_value = f"{cookie_value[:8]}...{cookie_value[-8:]}" + else: + display_value = cookie_value + + # 标记是否发生了变化 + change_mark = " [已变化]" if cookie_name in changed_cookies else " [新增]" if cookie_name in new_cookies else "" + logger.info(f"【{self.cookie_id}】 {cookie_name}: {display_value}{change_mark}") + + # 更新数据库中的Cookie + await self.update_config_cookies() + + 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: + # 确保资源清理 + try: + if browser: + await browser.close() + if playwright: + await playwright.stop() + except Exception as cleanup_e: + logger.warning(f"【{self.cookie_id}】清理浏览器资源时出错: {self._safe_str(cleanup_e)}") + + async def send_msg_once(self, toid, item_id, text): headers = { "Cookie": self.cookies_str, @@ -3679,7 +4656,7 @@ class XianyuLive: # 🔔 立即发送消息通知(独立于自动回复功能) try: - await self.send_notification(send_user_name, send_user_id, send_message, item_id) + 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)}") @@ -3904,6 +4881,10 @@ class XianyuLive: logger.info(f"【{self.cookie_id}】WebSocket连接建立成功!") self.ws = websocket + # 更新连接状态 + self.connection_failures = 0 + self.last_successful_connection = time.time() + logger.info(f"【{self.cookie_id}】开始初始化WebSocket连接...") await self.init(websocket) logger.info(f"【{self.cookie_id}】WebSocket初始化完成!") @@ -3921,6 +4902,11 @@ class XianyuLive: logger.info(f"【{self.cookie_id}】启动暂停记录清理任务...") self.cleanup_task = asyncio.create_task(self.pause_cleanup_loop()) + # 启动Cookie刷新任务 + if not self.cookie_refresh_task: + logger.info(f"【{self.cookie_id}】启动Cookie刷新任务...") + self.cookie_refresh_task = asyncio.create_task(self.cookie_refresh_loop()) + logger.info(f"【{self.cookie_id}】开始监听WebSocket消息...") logger.info(f"【{self.cookie_id}】WebSocket连接状态正常,等待服务器消息...") logger.info(f"【{self.cookie_id}】准备进入消息循环...") @@ -3943,16 +4929,57 @@ class XianyuLive: continue except Exception as e: - logger.error(f"WebSocket连接异常: {self._safe_str(e)}") + error_msg = self._safe_str(e) + self.connection_failures += 1 + + logger.error(f"WebSocket连接异常 ({self.connection_failures}/{self.max_connection_failures}): {error_msg}") + + # 检查是否超过最大失败次数 + if self.connection_failures >= self.max_connection_failures: + logger.error(f"【{self.cookie_id}】连续连接失败{self.max_connection_failures}次,暂停重试30分钟") + await asyncio.sleep(1800) # 暂停30分钟 + self.connection_failures = 0 # 重置失败计数 + continue + + # 根据错误类型和失败次数决定处理策略 + if "no close frame received or sent" in error_msg: + logger.info(f"【{self.cookie_id}】检测到WebSocket连接意外断开,准备重新连接...") + retry_delay = min(3 * self.connection_failures, 15) # 递增重试间隔,最大15秒 + elif "Connection refused" in error_msg or "timeout" in error_msg.lower(): + logger.warning(f"【{self.cookie_id}】网络连接问题,延长重试间隔...") + retry_delay = min(10 * self.connection_failures, 60) # 递增重试间隔,最大60秒 + else: + logger.warning(f"【{self.cookie_id}】未知WebSocket错误,使用默认重试间隔...") + retry_delay = min(5 * self.connection_failures, 30) # 递增重试间隔,最大30秒 + + # 清空当前token,确保重新连接时会重新获取 + if self.current_token: + logger.info(f"【{self.cookie_id}】清空当前token,重新连接时将重新获取") + self.current_token = None + + # 取消所有任务并重置为None if self.heartbeat_task: self.heartbeat_task.cancel() + self.heartbeat_task = None if self.token_refresh_task: self.token_refresh_task.cancel() + self.token_refresh_task = None if self.cleanup_task: self.cleanup_task.cancel() - await asyncio.sleep(5) # 等待5秒后重试 + self.cleanup_task = None + if self.cookie_refresh_task: + self.cookie_refresh_task.cancel() + self.cookie_refresh_task = None + + logger.info(f"【{self.cookie_id}】等待 {retry_delay} 秒后重试连接...") + await asyncio.sleep(retry_delay) continue finally: + # 清空当前token + if self.current_token: + logger.info(f"【{self.cookie_id}】程序退出,清空当前token") + self.current_token = None + # 清理所有任务 if self.heartbeat_task: self.heartbeat_task.cancel() @@ -3960,6 +4987,8 @@ class XianyuLive: self.token_refresh_task.cancel() if self.cleanup_task: self.cleanup_task.cancel() + if self.cookie_refresh_task: + self.cookie_refresh_task.cancel() await self.close_session() # 确保关闭session async def get_item_list_info(self, page_number=1, page_size=20, retry_count=0): diff --git a/db_manager.py b/db_manager.py index f7cfcbc..f968481 100644 --- a/db_manager.py +++ b/db_manager.py @@ -354,7 +354,7 @@ class DBManager: CREATE TABLE IF NOT EXISTS notification_channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('qq')), + type TEXT NOT NULL CHECK (type IN ('qq','ding_talk','dingtalk','feishu','lark','bark','email','webhook','wechat','telegram')), config TEXT NOT NULL, enabled BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -567,6 +567,14 @@ class DBManager: self.upgrade_keywords_table_for_image_support(cursor) self.set_system_setting("db_version", "1.3", "数据库版本号") logger.info("数据库升级到版本1.3完成") + + + # 升级到版本1.4 - 添加关键词类型和图片URL字段 + if current_version < "1.4": + logger.info("开始升级数据库到版本1.4...") + self.upgrade_notification_channels_types(cursor) + self.set_system_setting("db_version", "1.4", "数据库版本号") + logger.info("数据库升级到版本1.4完成") # 迁移遗留数据(在所有版本升级完成后执行) self.migrate_legacy_data(cursor) @@ -803,13 +811,13 @@ class DBManager: existing_data = cursor.fetchall() logger.info(f"备份 {count} 条通知渠道数据") - # 创建新表,支持更多渠道类型 + # 创建新表,支持所有通知渠道类型 cursor.execute(''' CREATE TABLE notification_channels_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, user_id INTEGER NOT NULL, - type TEXT NOT NULL CHECK (type IN ('qq','ding_talk','dingtalk','email','webhook','wechat','telegram')), + type TEXT NOT NULL CHECK (type IN ('qq','ding_talk','dingtalk','feishu','lark','bark','email','webhook','wechat','telegram')), config TEXT NOT NULL, enabled BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -824,15 +832,18 @@ class DBManager: # 处理类型映射,支持更多渠道类型 old_type = row[3] if len(row) > 3 else 'qq' # type字段 - # 扩展的类型映射规则 + # 完整的类型映射规则,支持所有通知渠道 type_mapping = { 'ding_talk': 'dingtalk', # 统一为dingtalk 'dingtalk': 'dingtalk', 'qq': 'qq', - 'email': 'email', # 现在支持email - 'webhook': 'webhook', # 现在支持webhook - 'wechat': 'wechat', # 现在支持wechat - 'telegram': 'telegram' # 现在支持telegram + 'feishu': 'feishu', # 飞书通知 + 'lark': 'lark', # 飞书通知(英文名) + 'bark': 'bark', # Bark通知 + 'email': 'email', # 邮件通知 + 'webhook': 'webhook', # Webhook通知 + 'wechat': 'wechat', # 微信通知 + 'telegram': 'telegram' # Telegram通知 } new_type = type_mapping.get(old_type, 'qq') # 默认为qq @@ -863,6 +874,15 @@ class DBManager: cursor.execute("ALTER TABLE notification_channels_new RENAME TO notification_channels") logger.info("notification_channels表类型升级完成") + logger.info("✅ 现在支持以下所有通知渠道类型:") + logger.info(" - qq (QQ通知)") + logger.info(" - ding_talk/dingtalk (钉钉通知)") + logger.info(" - feishu/lark (飞书通知)") + logger.info(" - bark (Bark通知)") + logger.info(" - email (邮件通知)") + logger.info(" - webhook (Webhook通知)") + logger.info(" - wechat (微信通知)") + logger.info(" - telegram (Telegram通知)") return True except Exception as e: logger.error(f"升级notification_channels表类型失败: {e}") @@ -1190,7 +1210,7 @@ class DBManager: 'user_id': result[2], 'auto_confirm': bool(result[3]), 'remark': result[4] or '', - 'pause_duration': result[5] or 10, + 'pause_duration': result[5] if result[5] is not None else 10, 'created_at': result[6] } return None @@ -1245,11 +1265,19 @@ class DBManager: self._execute_sql(cursor, "SELECT pause_duration FROM cookies WHERE id = ?", (cookie_id,)) result = cursor.fetchone() if result: - return result[0] or 10 # 默认10分钟 - return 10 # 如果没有找到记录,返回默认值 + if result[0] is None: + logger.warning(f"账号 {cookie_id} 的pause_duration为NULL,使用默认值10分钟并修复数据库") + # 修复数据库中的NULL值 + self._execute_sql(cursor, "UPDATE cookies SET pause_duration = 10 WHERE id = ?", (cookie_id,)) + self.conn.commit() + return 10 + return result[0] # 返回实际值,不使用or操作符 + else: + logger.warning(f"账号 {cookie_id} 未找到记录,使用默认值10分钟") + return 10 except Exception as e: logger.error(f"获取账号自动回复暂停时间失败: {e}") - return 10 # 出错时返回默认值 + return 10 def get_auto_confirm(self, cookie_id: str) -> bool: """获取Cookie的自动确认发货设置""" diff --git a/entrypoint.sh b/entrypoint.sh index c472e48..a2867f6 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,15 +1,12 @@ #!/bin/bash -set -e -echo "🚀 启动闲鱼自动回复系统..." -echo "📊 数据库将在应用启动时自动初始化..." -echo "🎯 启动主应用..." +echo "Starting xianyu-auto-reply system..." -# 确保数据目录存在 +# Create necessary directories mkdir -p /app/data /app/logs /app/backups /app/static/uploads/images -# 设置目录权限 +# Set permissions chmod 777 /app/data /app/logs /app/backups /app/static/uploads /app/static/uploads/images -# 启动主应用 +# Start the application exec python Start.py diff --git a/global_config.yml b/global_config.yml index e3f9217..0ab588d 100644 --- a/global_config.yml +++ b/global_config.yml @@ -45,7 +45,7 @@ DEFAULT_HEADERS: 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 HEARTBEAT_INTERVAL: 15 -HEARTBEAT_TIMEOUT: 5 +HEARTBEAT_TIMEOUT: 30 LOG_CONFIG: compression: zip format: '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} @@ -58,7 +58,7 @@ MANUAL_MODE: timeout: 3600 toggle_keywords: [] MESSAGE_EXPIRE_TIME: 300000 -TOKEN_REFRESH_INTERVAL: 72000 # 从3600秒(1小时)增加到18000秒(20小时) +TOKEN_REFRESH_INTERVAL: 72000 # 从3600秒(1小时)增加到72000秒(20小时) TOKEN_RETRY_INTERVAL: 7200 # 从300秒(5分钟)增加到7200秒(2小时) WEBSOCKET_HEADERS: Accept-Encoding: gzip, deflate, br, zstd diff --git a/reply_server.py b/reply_server.py index 09160c2..238a230 100644 --- a/reply_server.py +++ b/reply_server.py @@ -14,6 +14,8 @@ import os import uvicorn import pandas as pd import io +import asyncio +from collections import defaultdict import cookie_manager from db_manager import db_manager @@ -36,9 +38,30 @@ TOKEN_EXPIRE_TIME = 24 * 60 * 60 # token过期时间:24小时 # HTTP Bearer认证 security = HTTPBearer(auto_error=False) +# 扫码登录检查锁 - 防止并发处理同一个session +qr_check_locks = defaultdict(lambda: asyncio.Lock()) +qr_check_processed = {} # 记录已处理的session: {session_id: {'processed': bool, 'timestamp': float}} + # 不再需要单独的密码初始化,由数据库初始化时处理 +def cleanup_qr_check_records(): + """清理过期的扫码检查记录""" + current_time = time.time() + expired_sessions = [] + + for session_id, record in qr_check_processed.items(): + # 清理超过1小时的记录 + if current_time - record['timestamp'] > 3600: + expired_sessions.append(session_id) + + for session_id in expired_sessions: + if session_id in qr_check_processed: + del qr_check_processed[session_id] + if session_id in qr_check_locks: + del qr_check_locks[session_id] + + def load_keywords() -> List[Tuple[str, str]]: """读取关键字→回复映射表 @@ -1038,26 +1061,57 @@ async def generate_qr_code(current_user: Dict[str, Any] = Depends(get_current_us async def check_qr_code_status(session_id: str, current_user: Dict[str, Any] = Depends(get_current_user)): """检查扫码登录状态""" try: - # 清理过期会话 - qr_login_manager.cleanup_expired_sessions() + # 清理过期记录 + cleanup_qr_check_records() - # 获取会话状态 - status_info = qr_login_manager.get_session_status(session_id) + # 检查是否已经处理过 + if session_id in qr_check_processed: + record = qr_check_processed[session_id] + if record['processed']: + log_with_user('debug', f"扫码登录session {session_id} 已处理过,直接返回", current_user) + # 返回简单的成功状态,避免重复处理 + return {'status': 'already_processed', 'message': '该会话已处理完成'} - if status_info['status'] == 'success': - # 登录成功,处理Cookie - cookies_info = qr_login_manager.get_session_cookies(session_id) - if cookies_info: - account_info = await process_qr_login_cookies( - cookies_info['cookies'], - cookies_info['unb'], - current_user - ) - status_info['account_info'] = account_info + # 获取该session的锁 + session_lock = qr_check_locks[session_id] - log_with_user('info', f"扫码登录成功处理完成: {session_id}, 账号: {account_info.get('account_id', 'unknown')}", current_user) + # 使用非阻塞方式尝试获取锁 + if session_lock.locked(): + log_with_user('debug', f"扫码登录session {session_id} 正在被其他请求处理,跳过", current_user) + return {'status': 'processing', 'message': '正在处理中,请稍候...'} - return status_info + async with session_lock: + # 再次检查是否已处理(双重检查) + if session_id in qr_check_processed and qr_check_processed[session_id]['processed']: + log_with_user('debug', f"扫码登录session {session_id} 在获取锁后发现已处理,直接返回", current_user) + return {'status': 'already_processed', 'message': '该会话已处理完成'} + + # 清理过期会话 + qr_login_manager.cleanup_expired_sessions() + + # 获取会话状态 + status_info = qr_login_manager.get_session_status(session_id) + + if status_info['status'] == 'success': + # 登录成功,处理Cookie(现在包含获取真实cookie的逻辑) + cookies_info = qr_login_manager.get_session_cookies(session_id) + if cookies_info: + account_info = await process_qr_login_cookies( + cookies_info['cookies'], + cookies_info['unb'], + current_user + ) + status_info['account_info'] = account_info + + log_with_user('info', f"扫码登录处理完成: {session_id}, 账号: {account_info.get('account_id', 'unknown')}", current_user) + + # 标记该session已处理 + qr_check_processed[session_id] = { + 'processed': True, + 'timestamp': time.time() + } + + return status_info except Exception as e: log_with_user('error', f"检查扫码登录状态异常: {str(e)}", current_user) @@ -1065,7 +1119,7 @@ async def check_qr_code_status(session_id: str, current_user: Dict[str, Any] = D async def process_qr_login_cookies(cookies: str, unb: str, current_user: Dict[str, Any]) -> Dict[str, Any]: - """处理扫码登录获取的Cookie""" + """处理扫码登录获取的Cookie - 先获取真实cookie再保存到数据库""" try: user_id = current_user['user_id'] @@ -1083,20 +1137,11 @@ async def process_qr_login_cookies(cookies: str, unb: str, current_user: Dict[st except: continue + # 确定账号ID if existing_account_id: - # 更新现有账号的Cookie - db_manager.save_cookie(existing_account_id, cookies, user_id) - - # 更新cookie_manager中的Cookie - if cookie_manager.manager: - cookie_manager.manager.update_cookie(existing_account_id, cookies) - - log_with_user('info', f"扫码登录更新现有账号Cookie: {existing_account_id}, UNB: {unb}", current_user) - - return { - 'account_id': existing_account_id, - 'is_new_account': False - } + account_id = existing_account_id + is_new_account = False + log_with_user('info', f"扫码登录找到现有账号: {account_id}, UNB: {unb}", current_user) else: # 创建新账号,使用unb作为账号ID account_id = unb @@ -1108,25 +1153,255 @@ async def process_qr_login_cookies(cookies: str, unb: str, current_user: Dict[st account_id = f"{original_account_id}_{counter}" counter += 1 - # 保存新账号 - db_manager.save_cookie(account_id, cookies, user_id) + is_new_account = True + log_with_user('info', f"扫码登录准备创建新账号: {account_id}, UNB: {unb}", current_user) - # 添加到cookie_manager - if cookie_manager.manager: - cookie_manager.manager.add_cookie(account_id, cookies) + # 第一步:使用扫码cookie获取真实cookie + log_with_user('info', f"开始使用扫码cookie获取真实cookie: {account_id}", current_user) - log_with_user('info', f"扫码登录创建新账号: {account_id}, UNB: {unb}", current_user) + try: + # 创建一个临时的XianyuLive实例来执行cookie刷新 + from XianyuAutoAsync import XianyuLive - return { - 'account_id': account_id, - 'is_new_account': True - } + # 使用扫码登录的cookie创建临时实例 + temp_instance = XianyuLive( + cookies_str=cookies, + cookie_id=account_id, + user_id=user_id + ) + + # 执行cookie刷新获取真实cookie + refresh_success = await temp_instance.refresh_cookies_from_qr_login( + qr_cookies_str=cookies, + cookie_id=account_id, + user_id=user_id + ) + + if refresh_success: + log_with_user('info', f"扫码登录真实cookie获取成功: {account_id}", current_user) + + # 从数据库获取刚刚保存的真实cookie + updated_cookie_info = db_manager.get_cookie_by_id(account_id) + if updated_cookie_info: + real_cookies = updated_cookie_info['cookies_str'] + log_with_user('info', f"已获取真实cookie,长度: {len(real_cookies)}", current_user) + + # 第二步:将真实cookie添加到cookie_manager(如果是新账号)或更新现有账号 + if cookie_manager.manager: + if is_new_account: + cookie_manager.manager.add_cookie(account_id, real_cookies) + log_with_user('info', f"已将真实cookie添加到cookie_manager: {account_id}", current_user) + else: + cookie_manager.manager.update_cookie(account_id, real_cookies) + log_with_user('info', f"已更新cookie_manager中的真实cookie: {account_id}", current_user) + + return { + 'account_id': account_id, + 'is_new_account': is_new_account, + 'real_cookie_refreshed': True, + 'cookie_length': len(real_cookies) + } + else: + log_with_user('error', f"无法从数据库获取真实cookie: {account_id}", current_user) + # 降级处理:使用原始扫码cookie + return await _fallback_save_qr_cookie(account_id, cookies, user_id, is_new_account, current_user, "无法从数据库获取真实cookie") + else: + log_with_user('warning', f"扫码登录真实cookie获取失败: {account_id}", current_user) + # 降级处理:使用原始扫码cookie + return await _fallback_save_qr_cookie(account_id, cookies, user_id, is_new_account, current_user, "真实cookie获取失败") + + except Exception as refresh_e: + log_with_user('error', f"扫码登录真实cookie获取异常: {str(refresh_e)}", current_user) + # 降级处理:使用原始扫码cookie + return await _fallback_save_qr_cookie(account_id, cookies, user_id, is_new_account, current_user, f"获取真实cookie异常: {str(refresh_e)}") except Exception as e: log_with_user('error', f"处理扫码登录Cookie失败: {str(e)}", current_user) raise e +async def _fallback_save_qr_cookie(account_id: str, cookies: str, user_id: int, is_new_account: bool, current_user: Dict[str, Any], error_reason: str) -> Dict[str, Any]: + """降级处理:当无法获取真实cookie时,保存原始扫码cookie""" + try: + log_with_user('warning', f"降级处理 - 保存原始扫码cookie: {account_id}, 原因: {error_reason}", current_user) + + # 保存原始扫码cookie到数据库 + if is_new_account: + db_manager.save_cookie(account_id, cookies, user_id) + log_with_user('info', f"降级处理 - 新账号原始cookie已保存: {account_id}", current_user) + else: + db_manager.save_cookie(account_id, cookies, user_id) + log_with_user('info', f"降级处理 - 现有账号原始cookie已更新: {account_id}", current_user) + + # 添加到或更新cookie_manager + if cookie_manager.manager: + if is_new_account: + cookie_manager.manager.add_cookie(account_id, cookies) + log_with_user('info', f"降级处理 - 已将原始cookie添加到cookie_manager: {account_id}", current_user) + else: + cookie_manager.manager.update_cookie(account_id, cookies) + log_with_user('info', f"降级处理 - 已更新cookie_manager中的原始cookie: {account_id}", current_user) + + return { + 'account_id': account_id, + 'is_new_account': is_new_account, + 'real_cookie_refreshed': False, + 'fallback_reason': error_reason, + 'cookie_length': len(cookies) + } + + except Exception as fallback_e: + log_with_user('error', f"降级处理失败: {str(fallback_e)}", current_user) + raise fallback_e + + +@app.post("/qr-login/refresh-cookies") +async def refresh_cookies_from_qr_login( + request: Dict[str, Any], + current_user: Dict[str, Any] = Depends(get_current_user) +): + """使用扫码登录获取的cookie访问指定界面获取真实cookie并存入数据库""" + try: + qr_cookies = request.get('qr_cookies') + cookie_id = request.get('cookie_id') + + if not qr_cookies: + return {'success': False, 'message': '缺少扫码登录cookie'} + + if not cookie_id: + return {'success': False, 'message': '缺少cookie_id'} + + log_with_user('info', f"开始使用扫码cookie刷新真实cookie: {cookie_id}", current_user) + + # 创建一个临时的XianyuLive实例来执行cookie刷新 + from XianyuAutoAsync import XianyuLive + + # 使用扫码登录的cookie创建临时实例 + temp_instance = XianyuLive( + cookies_str=qr_cookies, + cookie_id=cookie_id, + user_id=current_user['user_id'] + ) + + # 执行cookie刷新 + success = await temp_instance.refresh_cookies_from_qr_login( + qr_cookies_str=qr_cookies, + cookie_id=cookie_id, + user_id=current_user['user_id'] + ) + + if success: + log_with_user('info', f"扫码cookie刷新成功: {cookie_id}", current_user) + + # 如果cookie_manager存在,更新其中的cookie + if cookie_manager.manager: + # 从数据库获取更新后的cookie + updated_cookie_info = db_manager.get_cookie_by_id(cookie_id) + if updated_cookie_info: + cookie_manager.manager.update_cookie(cookie_id, updated_cookie_info['cookies_str']) + log_with_user('info', f"已更新cookie_manager中的cookie: {cookie_id}", current_user) + + return { + 'success': True, + 'message': '真实cookie获取并保存成功', + 'cookie_id': cookie_id + } + else: + log_with_user('error', f"扫码cookie刷新失败: {cookie_id}", current_user) + return {'success': False, 'message': '获取真实cookie失败'} + + except Exception as e: + log_with_user('error', f"扫码cookie刷新异常: {str(e)}", current_user) + return {'success': False, 'message': f'刷新cookie失败: {str(e)}'} + + +@app.post("/qr-login/reset-cooldown/{cookie_id}") +async def reset_qr_cookie_refresh_cooldown( + cookie_id: str, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """重置指定账号的扫码登录Cookie刷新冷却时间""" + try: + log_with_user('info', f"重置扫码登录Cookie刷新冷却时间: {cookie_id}", current_user) + + # 检查cookie是否存在 + cookie_info = db_manager.get_cookie_by_id(cookie_id) + if not cookie_info: + return {'success': False, 'message': '账号不存在'} + + # 如果cookie_manager中有对应的实例,直接重置 + if cookie_manager.manager and cookie_id in cookie_manager.manager.instances: + instance = cookie_manager.manager.instances[cookie_id] + remaining_time_before = instance.get_qr_cookie_refresh_remaining_time() + instance.reset_qr_cookie_refresh_flag() + + log_with_user('info', f"已重置账号 {cookie_id} 的扫码登录冷却时间,原剩余时间: {remaining_time_before}秒", current_user) + + return { + 'success': True, + 'message': '扫码登录Cookie刷新冷却时间已重置', + 'cookie_id': cookie_id, + 'previous_remaining_time': remaining_time_before + } + else: + # 如果没有活跃实例,返回成功(因为没有冷却时间需要重置) + log_with_user('info', f"账号 {cookie_id} 没有活跃实例,无需重置冷却时间", current_user) + return { + 'success': True, + 'message': '账号没有活跃实例,无需重置冷却时间', + 'cookie_id': cookie_id + } + + except Exception as e: + log_with_user('error', f"重置扫码登录冷却时间异常: {str(e)}", current_user) + return {'success': False, 'message': f'重置冷却时间失败: {str(e)}'} + + +@app.get("/qr-login/cooldown-status/{cookie_id}") +async def get_qr_cookie_refresh_cooldown_status( + cookie_id: str, + current_user: Dict[str, Any] = Depends(get_current_user) +): + """获取指定账号的扫码登录Cookie刷新冷却状态""" + try: + # 检查cookie是否存在 + cookie_info = db_manager.get_cookie_by_id(cookie_id) + if not cookie_info: + return {'success': False, 'message': '账号不存在'} + + # 如果cookie_manager中有对应的实例,获取冷却状态 + if cookie_manager.manager and cookie_id in cookie_manager.manager.instances: + instance = cookie_manager.manager.instances[cookie_id] + remaining_time = instance.get_qr_cookie_refresh_remaining_time() + cooldown_duration = instance.qr_cookie_refresh_cooldown + last_refresh_time = instance.last_qr_cookie_refresh_time + + return { + 'success': True, + 'cookie_id': cookie_id, + 'remaining_time': remaining_time, + 'cooldown_duration': cooldown_duration, + 'last_refresh_time': last_refresh_time, + 'is_in_cooldown': remaining_time > 0, + 'remaining_minutes': remaining_time // 60, + 'remaining_seconds': remaining_time % 60 + } + else: + return { + 'success': True, + 'cookie_id': cookie_id, + 'remaining_time': 0, + 'cooldown_duration': 600, # 默认10分钟 + 'last_refresh_time': 0, + 'is_in_cooldown': False, + 'message': '账号没有活跃实例' + } + + except Exception as e: + log_with_user('error', f"获取扫码登录冷却状态异常: {str(e)}", current_user) + return {'success': False, 'message': f'获取冷却状态失败: {str(e)}'} + + @app.put('/cookies/{cid}/status') def update_cookie_status(cid: str, status_data: CookieStatusIn, current_user: Dict[str, Any] = Depends(get_current_user)): """更新账号的启用/禁用状态""" diff --git a/requirements.txt b/requirements.txt index 0217286..72f2d28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -78,4 +78,5 @@ xlsxwriter>=3.1.0 # collections, itertools, functools, copy, pickle, gzip, zipfile, shutil # tempfile, io, csv, xml, html, http, socket, ssl, subprocess, signal # inspect, ast, enum, math, decimal, array, queue, contextlib, warnings -# typing, dataclasses, weakref, gc, platform, stat, glob, fnmatch \ No newline at end of file +# typing, dataclasses, weakref, gc, platform, stat, glob, fnmatch, mimetypes +# email, smtplib, imaplib, poplib, ftplib, telnetlib, configparser, argparse \ No newline at end of file diff --git a/static/css/components.css b/static/css/components.css index 2d286c0..10feca8 100644 --- a/static/css/components.css +++ b/static/css/components.css @@ -1,6 +1,16 @@ /* ================================ 通用卡片样式 - 适用于所有菜单的卡片 ================================ */ + +/* 旋转动画 */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.spin { + animation: spin 1s linear infinite; +} .card { background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); diff --git a/static/index.html b/static/index.html index b07bebb..cf0c6e3 100644 --- a/static/index.html +++ b/static/index.html @@ -147,11 +147,21 @@
-

- - 仪表盘 -

-

系统概览和统计信息

+
+
+

+ + 仪表盘 +

+

系统概览和统计信息

+
+
+ + + 版本: 加载中... + +
+
@@ -234,17 +244,17 @@
-
@@ -1064,6 +1074,40 @@
+
+
+
+
+ +
+
飞书通知
+

飞书机器人消息

+
+ +
+
+
+
+ +
+
+
+
+ +
+
Bark通知
+

iOS推送通知

+
+ +
+
+
+
+
@@ -2753,7 +2797,7 @@ - +