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 @@
-
+
diff --git a/static/js/app.js b/static/js/app.js
index 650ff12..cdf9989 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1347,6 +1347,157 @@ function copyCookie(id, value) {
});
}
+// 刷新真实Cookie
+async function refreshRealCookie(cookieId) {
+ if (!cookieId) {
+ showToast('缺少账号ID', 'warning');
+ return;
+ }
+
+ // 获取当前cookie值
+ try {
+ const cookieDetails = await fetchJSON(`${apiBase}/cookies/details`);
+ const currentCookie = cookieDetails.find(c => c.id === cookieId);
+
+ if (!currentCookie || !currentCookie.value) {
+ showToast('未找到有效的Cookie信息', 'warning');
+ return;
+ }
+
+ // 确认操作
+ if (!confirm(`确定要刷新账号 "${cookieId}" 的真实Cookie吗?\n\n此操作将使用当前Cookie访问闲鱼IM界面获取最新的真实Cookie。`)) {
+ return;
+ }
+
+ // 显示加载状态
+ const button = event.target.closest('button');
+ const originalContent = button.innerHTML;
+ button.disabled = true;
+ button.innerHTML = '
';
+
+ // 调用刷新API
+ const response = await fetch(`${apiBase}/qr-login/refresh-cookies`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ qr_cookies: currentCookie.value,
+ cookie_id: cookieId
+ })
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ showToast(`账号 "${cookieId}" 真实Cookie刷新成功`, 'success');
+ // 刷新账号列表以显示更新后的cookie
+ loadCookies();
+ } else {
+ showToast(`真实Cookie刷新失败: ${result.message}`, 'danger');
+ }
+
+ } catch (error) {
+ console.error('刷新真实Cookie失败:', error);
+ showToast(`刷新真实Cookie失败: ${error.message || '未知错误'}`, 'danger');
+ } finally {
+ // 恢复按钮状态
+ const button = event.target.closest('button');
+ if (button) {
+ button.disabled = false;
+ button.innerHTML = '
';
+ }
+ }
+}
+
+// 显示冷却状态
+async function showCooldownStatus(cookieId) {
+ if (!cookieId) {
+ showToast('缺少账号ID', 'warning');
+ return;
+ }
+
+ try {
+ const response = await fetch(`${apiBase}/qr-login/cooldown-status/${cookieId}`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ const { remaining_time, cooldown_duration, is_in_cooldown, remaining_minutes, remaining_seconds } = result;
+
+ let statusMessage = `账号: ${cookieId}\n`;
+ statusMessage += `冷却时长: ${cooldown_duration / 60}分钟\n`;
+
+ if (is_in_cooldown) {
+ statusMessage += `冷却状态: 进行中\n`;
+ statusMessage += `剩余时间: ${remaining_minutes}分${remaining_seconds}秒\n\n`;
+ statusMessage += `在冷却期间,_refresh_cookies_via_browser 方法将被跳过。\n\n`;
+ statusMessage += `是否要重置冷却时间?`;
+
+ if (confirm(statusMessage)) {
+ await resetCooldownTime(cookieId);
+ }
+ } else {
+ statusMessage += `冷却状态: 无冷却\n`;
+ statusMessage += `可以正常执行 _refresh_cookies_via_browser 方法`;
+ alert(statusMessage);
+ }
+ } else {
+ showToast(`获取冷却状态失败: ${result.message}`, 'danger');
+ }
+
+ } catch (error) {
+ console.error('获取冷却状态失败:', error);
+ showToast(`获取冷却状态失败: ${error.message || '未知错误'}`, 'danger');
+ }
+}
+
+// 重置冷却时间
+async function resetCooldownTime(cookieId) {
+ if (!cookieId) {
+ showToast('缺少账号ID', 'warning');
+ return;
+ }
+
+ try {
+ const response = await fetch(`${apiBase}/qr-login/reset-cooldown/${cookieId}`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${authToken}`,
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ const result = await response.json();
+
+ if (result.success) {
+ const previousTime = result.previous_remaining_time || 0;
+ const previousMinutes = Math.floor(previousTime / 60);
+ const previousSeconds = previousTime % 60;
+
+ let message = `账号 "${cookieId}" 的扫码登录冷却时间已重置`;
+ if (previousTime > 0) {
+ message += `\n原剩余时间: ${previousMinutes}分${previousSeconds}秒`;
+ }
+
+ showToast(message, 'success');
+ } else {
+ showToast(`重置冷却时间失败: ${result.message}`, 'danger');
+ }
+
+ } catch (error) {
+ console.error('重置冷却时间失败:', error);
+ showToast(`重置冷却时间失败: ${error.message || '未知错误'}`, 'danger');
+ }
+}
+
// 删除Cookie
async function delCookie(id) {
if (!confirm(`确定要删除账号 "${id}" 吗?此操作不可恢复。`)) return;
@@ -1752,6 +1903,9 @@ document.addEventListener('DOMContentLoaded', async () => {
// 首先检查认证状态
const isAuthenticated = await checkAuth();
if (!isAuthenticated) return;
+
+ // 加载系统版本号
+ loadSystemVersion();
// 添加Cookie表单提交
document.getElementById('addForm').addEventListener('submit', async (e) => {
e.preventDefault();
@@ -2450,6 +2604,78 @@ const channelTypeConfigs = {
}
]
},
+ feishu: {
+ title: '飞书通知',
+ description: '请设置飞书机器人Webhook URL,支持自定义机器人和群机器人',
+ icon: 'bi-chat-square-text-fill',
+ color: 'warning',
+ fields: [
+ {
+ id: 'webhook_url',
+ label: '飞书机器人Webhook URL',
+ type: 'url',
+ placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/...',
+ required: true,
+ help: '飞书机器人的Webhook地址'
+ },
+ {
+ id: 'secret',
+ label: '签名密钥(可选)',
+ type: 'text',
+ placeholder: '输入签名密钥',
+ required: false,
+ help: '如果机器人开启了签名验证,请填写密钥'
+ }
+ ]
+ },
+ bark: {
+ title: 'Bark通知',
+ description: 'iOS推送通知服务,支持自建服务器和官方服务器',
+ icon: 'bi-phone-fill',
+ color: 'dark',
+ fields: [
+ {
+ id: 'device_key',
+ label: '设备密钥',
+ type: 'text',
+ placeholder: '输入Bark设备密钥',
+ required: true,
+ help: 'Bark应用中显示的设备密钥'
+ },
+ {
+ id: 'server_url',
+ label: '服务器地址(可选)',
+ type: 'url',
+ placeholder: 'https://api.day.app',
+ required: false,
+ help: '自建Bark服务器地址,留空使用官方服务器'
+ },
+ {
+ id: 'title',
+ label: '通知标题(可选)',
+ type: 'text',
+ placeholder: '闲鱼自动回复通知',
+ required: false,
+ help: '推送通知的标题'
+ },
+ {
+ id: 'sound',
+ label: '提示音(可选)',
+ type: 'text',
+ placeholder: 'default',
+ required: false,
+ help: '通知提示音,如:alarm, anticipate, bell等'
+ },
+ {
+ id: 'group',
+ label: '分组(可选)',
+ type: 'text',
+ placeholder: 'xianyu',
+ required: false,
+ help: '通知分组名称,用于归类消息'
+ }
+ ]
+ },
email: {
title: '邮件通知',
description: '通过SMTP服务器发送邮件通知,支持各种邮箱服务商',
@@ -2753,6 +2979,8 @@ function renderNotificationChannels(channels) {
let channelType = channel.type;
if (channelType === 'ding_talk') {
channelType = 'dingtalk'; // 兼容旧的类型名
+ } else if (channelType === 'lark') {
+ channelType = 'feishu'; // 兼容lark类型名
}
const typeConfig = channelTypeConfigs[channelType];
const typeDisplay = typeConfig ? typeConfig.title : channel.type;
@@ -2867,6 +3095,8 @@ async function editNotificationChannel(channelId) {
let channelType = channel.type;
if (channelType === 'ding_talk') {
channelType = 'dingtalk'; // 兼容旧的类型名
+ } else if (channelType === 'lark') {
+ channelType = 'feishu'; // 兼容lark类型名
}
const config = channelTypeConfigs[channelType];
@@ -2891,6 +3121,10 @@ async function editNotificationChannel(channelId) {
configData = { qq_number: channel.config };
} else if (channel.type === 'dingtalk' || channel.type === 'ding_talk') {
configData = { webhook_url: channel.config };
+ } else if (channel.type === 'feishu' || channel.type === 'lark') {
+ configData = { webhook_url: channel.config };
+ } else if (channel.type === 'bark') {
+ configData = { device_key: channel.config };
} else {
configData = { config: channel.config };
}
@@ -5883,7 +6117,12 @@ function updateBatchDeleteButton() {
// 格式化日期时间
function formatDateTime(dateString) {
if (!dateString) return '未知';
- const date = new Date(dateString);
+ // 如果是ISO格式,直接new Date
+ if (dateString.includes('T') && dateString.endsWith('Z')) {
+ return new Date(dateString).toLocaleString('zh-CN');
+ }
+ // 否则按原有逻辑(可选:补偿8小时)
+ const date = new Date(dateString.replace(' ', 'T') + 'Z');
return date.toLocaleString('zh-CN');
}
@@ -6927,6 +7166,16 @@ async function checkQRCodeStatus() {
clearQRCodeCheck();
showVerificationRequired(data);
break;
+ case 'processing':
+ document.getElementById('statusText').textContent = '正在处理中...';
+ // 继续轮询,不清理检查
+ break;
+ case 'already_processed':
+ document.getElementById('statusText').textContent = '登录已完成';
+ document.getElementById('statusSpinner').style.display = 'none';
+ clearQRCodeCheck();
+ showToast('该扫码会话已处理完成', 'info');
+ break;
}
}
} catch (error) {
@@ -6990,12 +7239,37 @@ function showVerificationRequired(data) {
// 处理扫码成功
function handleQRCodeSuccess(data) {
if (data.account_info) {
- const { account_id, is_new_account } = data.account_info;
+ const { account_id, is_new_account, real_cookie_refreshed, fallback_reason, cookie_length } = data.account_info;
+ // 构建成功消息
+ let successMessage = '';
if (is_new_account) {
- showToast(`新账号添加成功!账号ID: ${account_id}`, 'success');
+ successMessage = `新账号添加成功!账号ID: ${account_id}`;
} else {
- showToast(`账号Cookie已更新!账号ID: ${account_id}`, 'success');
+ successMessage = `账号Cookie已更新!账号ID: ${account_id}`;
+ }
+
+ // 添加cookie长度信息
+ if (cookie_length) {
+ successMessage += `\nCookie长度: ${cookie_length}`;
+ }
+
+ // 添加真实cookie获取状态信息
+ if (real_cookie_refreshed === true) {
+ successMessage += '\n✅ 真实Cookie获取并保存成功';
+ document.getElementById('statusText').textContent = '登录成功!真实Cookie已获取并保存';
+ showToast(successMessage, 'success');
+ } else if (real_cookie_refreshed === false) {
+ successMessage += '\n⚠️ 真实Cookie获取失败,已保存原始扫码Cookie';
+ if (fallback_reason) {
+ successMessage += `\n原因: ${fallback_reason}`;
+ }
+ document.getElementById('statusText').textContent = '登录成功,但使用原始Cookie';
+ showToast(successMessage, 'warning');
+ } else {
+ // 兼容旧版本,没有真实cookie刷新信息
+ document.getElementById('statusText').textContent = '登录成功!';
+ showToast(successMessage, 'success');
}
// 关闭模态框
@@ -7005,7 +7279,7 @@ function handleQRCodeSuccess(data) {
// 刷新账号列表
loadCookies();
- }, 2000);
+ }, 3000); // 延长显示时间以便用户看到详细信息
}
}
@@ -8625,23 +8899,6 @@ function updateBatchDeleteOrdersButton() {
}
}
-// 格式化日期时间
-function formatDateTime(dateString) {
- if (!dateString) return '未知时间';
-
- try {
- const date = new Date(dateString);
- return date.toLocaleString('zh-CN', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit'
- });
- } catch (error) {
- return dateString;
- }
-}
// 页面加载完成后初始化订单搜索功能
document.addEventListener('DOMContentLoaded', function() {
@@ -9747,3 +10004,165 @@ function exportSearchResults() {
showToast('导出搜索结果失败', 'danger');
}
}
+
+// ================================
+// 版本管理功能
+// ================================
+
+/**
+ * 加载系统版本号并检查更新
+ */
+async function loadSystemVersion() {
+ try {
+ // 从 version.txt 文件读取当前系统版本
+ let currentSystemVersion = 'v1.0.0'; // 默认版本
+
+ try {
+ const versionResponse = await fetch('/static/version.txt');
+ if (versionResponse.ok) {
+ currentSystemVersion = (await versionResponse.text()).trim();
+ }
+ } catch (e) {
+ console.warn('无法读取本地版本文件,使用默认版本');
+ }
+
+ // 显示当前版本
+ document.getElementById('versionNumber').textContent = currentSystemVersion;
+
+ // 获取远程版本并检查更新
+ const response = await fetch('http://xianyu.zhinianblog.cn/index.php?action=getVersion');
+ const result = await response.json();
+
+ if (result.error) {
+ console.error('获取版本号失败:', result.message);
+ return;
+ }
+
+ const remoteVersion = result.data;
+
+ // 检查是否有更新
+ if (remoteVersion !== currentSystemVersion) {
+ showUpdateAvailable(remoteVersion);
+ }
+
+ } catch (error) {
+ console.error('获取版本号失败:', error);
+ document.getElementById('versionNumber').textContent = '未知';
+ }
+}
+
+/**
+ * 显示有更新标签
+ */
+function showUpdateAvailable(newVersion) {
+ const versionContainer = document.querySelector('.version-info');
+
+ if (!versionContainer) {
+ return;
+ }
+
+ // 检查是否已经有更新标签
+ if (versionContainer.querySelector('.update-badge')) {
+ return;
+ }
+
+ // 创建更新标签
+ const updateBadge = document.createElement('span');
+ updateBadge.className = 'badge bg-warning ms-2 update-badge';
+ updateBadge.style.cursor = 'pointer';
+ updateBadge.innerHTML = '
有更新';
+ updateBadge.title = `新版本 ${newVersion} 可用,点击查看更新内容`;
+
+ // 点击事件
+ updateBadge.onclick = () => showUpdateInfo(newVersion);
+
+ // 添加到版本信息容器
+ versionContainer.appendChild(updateBadge);
+}
+
+/**
+ * 获取更新信息
+ */
+async function getUpdateInfo() {
+ try {
+ const response = await fetch('http://xianyu.zhinianblog.cn/index.php?action=getUpdateInfo');
+ const result = await response.json();
+
+ if (result.error) {
+ showToast('获取更新信息失败: ' + result.message, 'danger');
+ return null;
+ }
+
+ return result.data;
+
+ } catch (error) {
+ console.error('获取更新信息失败:', error);
+ showToast('获取更新信息失败', 'danger');
+ return null;
+ }
+}
+
+/**
+ * 显示更新信息(点击"有更新"标签时调用)
+ */
+async function showUpdateInfo(newVersion) {
+ const updateInfo = await getUpdateInfo();
+ if (!updateInfo) return;
+
+ let updateList = '';
+ if (updateInfo.updates && updateInfo.updates.length > 0) {
+ updateList = updateInfo.updates.map(item => `
${item}`).join('');
+ }
+
+ const modalHtml = `
+
+
+
+
+
+
+
+ 发现新版本!以下是最新版本的更新内容。
+
+
+
+
最新版本
+
${updateInfo.version}
+
+
+
发布日期
+
${updateInfo.releaseDate || '未知'}
+
+
+
+
更新内容
+ ${updateList ? `
` : '
暂无更新内容
'}
+
+
+
+
+
+ `;
+
+ // 移除已存在的模态框
+ const existingModal = document.getElementById('updateModal');
+ if (existingModal) {
+ existingModal.remove();
+ }
+
+ // 添加新的模态框
+ document.body.insertAdjacentHTML('beforeend', modalHtml);
+
+ // 显示模态框
+ const modal = new bootstrap.Modal(document.getElementById('updateModal'));
+ modal.show();
+}
+
+
diff --git a/static/version.txt b/static/version.txt
new file mode 100644
index 0000000..0ec25f7
--- /dev/null
+++ b/static/version.txt
@@ -0,0 +1 @@
+v1.0.0
diff --git a/static/wechat.png b/static/wechat.png
new file mode 100644
index 0000000..f7f78ae
Binary files /dev/null and b/static/wechat.png differ