Compare commits

...

3 Commits

Author SHA1 Message Date
zhinianboke
a525675e73 优化结构 2025-08-07 23:04:38 +08:00
zhinianboke
5b2a991f41 支持指定商品默认回复 2025-08-07 22:50:02 +08:00
zhinianboke
46f7066519 新增订单管理等;优化程序支持系统消息回复 2025-08-07 17:40:35 +08:00
12 changed files with 3385 additions and 228 deletions

202
.gitignore vendored
View File

@ -104,4 +104,204 @@ keywords_sample.xlsx
*.zip *.zip
*.tar.gz *.tar.gz
*.rar *.rar
*.7z *.7z
# ==================== 新增忽略项 ====================
# Python 字节码和缓存
*.pyc
*.pyo
*.pyd
__pycache__/
*.py[cod]
*$py.class
# 分发/打包
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# 单元测试/覆盖率报告
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582
__pypackages__/
# Celery
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# ==================== 项目特定新增 ====================
# 环境变量文件
.env.example
.env.*.example
# 数据库文件
*.db-journal
*.db-wal
*.db-shm
# 临时文件和缓存
*.cache
.cache/
cache/
# 编辑器临时文件
*.swp
*.swo
*.tmp
*~
.#*
# 系统文件
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# 文档生成
docs/_build/
docs/build/
# 密钥和证书
*.key
*.pem
*.crt
*.cert
*.p12
*.pfx
# 配置文件备份
*.conf.bak
*.config.bak
# 运行时文件
*.pid
*.sock
# 调试文件
debug.log
*.debug
# 性能分析文件
*.prof
# 本地开发文件
local/
.local/
# Docker相关
.dockerignore.bak
docker-compose.override.yml
# 版本控制
.svn/
.hg/
.bzr/
# 包管理器
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
yarn.lock
# 前端构建
dist/
build/
.next/
.nuxt/
.vuepress/dist
# 移动端
*.apk
*.ipa
*.app
# 数据文件
*.csv.bak
*.json.bak
*.xml.bak
# 媒体文件缓存
*.mp4.cache
*.mp3.cache
*.wav.cache
*.avi.cache
# AI模型文件
*.model
*.weights
*.h5
*.pb
# 大文件
*.iso
*.dmg
*.img

View File

@ -25,12 +25,13 @@
### 🤖 智能回复系统 ### 🤖 智能回复系统
- **关键词匹配** - 支持精确关键词匹配回复 - **关键词匹配** - 支持精确关键词匹配回复
- **商品专用回复** - 支持为特定商品设置专用关键词回复 - **指定商品回复** - 支持为特定商品设置专门的回复内容,优先级最高
- **商品专用关键词** - 支持为特定商品设置专用关键词回复
- **通用关键词** - 支持全局通用关键词,适用于所有商品 - **通用关键词** - 支持全局通用关键词,适用于所有商品
- **批量导入导出** - 支持Excel格式的关键词批量导入导出 - **批量导入导出** - 支持Excel格式的关键词批量导入导出
- **AI智能回复** - 集成OpenAI API支持上下文理解 - **AI智能回复** - 集成OpenAI API支持上下文理解
- **变量替换** - 回复内容支持动态变量(用户名、商品信息等) - **变量替换** - 回复内容支持动态变量(用户名、商品信息、商品ID等)
- **优先级策略** - 商品专用关键词 > 通用关键词 > AI回复 - **优先级策略** - 指定商品回复 > 商品专用关键词 > 通用关键词 > 默认回复 > AI回复
### 🚚 自动发货功能 ### 🚚 自动发货功能
- **智能匹配** - 基于商品信息自动匹配发货规则 - **智能匹配** - 基于商品信息自动匹配发货规则
@ -41,8 +42,9 @@
- **防重复发货** - 智能防重复机制,避免重复发货 - **防重复发货** - 智能防重复机制,避免重复发货
- **多种发货方式** - 支持固定文字、批量数据、API调用、图片发货等方式 - **多种发货方式** - 支持固定文字、批量数据、API调用、图片发货等方式
- **图片发货** - 支持上传图片并自动发送给买家图片自动上传到CDN - **图片发货** - 支持上传图片并自动发送给买家图片自动上传到CDN
- **自动确认发货** - 检测到付款后自动调用闲鱼API确认发货 - **自动确认发货** - 检测到付款后自动调用闲鱼API确认发货,支持锁机制防并发
- **防重复确认** - 智能防重复确认机制避免重复API调用 - **防重复确认** - 智能防重复确认机制避免重复API调用
- **订单详情缓存** - 订单详情获取支持数据库缓存,大幅提升性能
- **发货统计** - 完整的发货记录和统计功能 - **发货统计** - 完整的发货记录和统计功能
### 🛍️ 商品管理 ### 🛍️ 商品管理
@ -358,51 +360,57 @@ python Start.py
## 📁 核心文件功能说明 ## 📁 核心文件功能说明
### 🚀 启动和核心模块 ### 🚀 核心启动模块
- **`Start.py`** - 项目启动入口初始化CookieManager和FastAPI服务管理多账号任务 - **`Start.py`** - 项目启动入口初始化CookieManager和FastAPI服务从数据库加载账号任务并启动后台API服务支持环境变量配置
- **`XianyuAutoAsync.py`** - 闲鱼WebSocket连接核心处理消息收发、自动回复、自动发货 - **`XianyuAutoAsync.py`** - 闲鱼WebSocket连接核心处理消息收发、自动回复、指定商品回复、自动发货、商品信息收集、AI回复
- **`reply_server.py`** - FastAPI Web服务器提供完整的管理界面和RESTful API接口 - **`reply_server.py`** - FastAPI Web服务器提供完整的管理界面和RESTful API接口支持多用户系统、JWT认证、权限管理
- **`cookie_manager.py`** - 多账号Cookie管理器负责账号任务的启动、停止和状态管理 - **`cookie_manager.py`** - 多账号Cookie管理器负责账号任务的启动、停止、状态管理和线程安全操作,支持数据库持久化
### 🗄️ 数据和配置管理 ### 🗄️ 数据和配置管理
- **`db_manager.py`** - SQLite数据库管理器支持多用户数据隔离、自动迁移、版本管理 - **`db_manager.py`** - SQLite数据库管理器支持多用户数据隔离、自动迁移、版本管理、完整的CRUD操作、邮箱验证、系统设置
- **`config.py`** - 全局配置文件管理器加载YAML配置和环境变量 - **`config.py`** - 全局配置文件管理器加载YAML配置和环境变量,提供配置项访问接口,支持动态配置更新
- **`global_config.yml`** - 全局配置文件包含WebSocket、API、自动回复等所有配置项 - **`global_config.yml`** - 全局配置文件包含WebSocket、API、自动回复、AI、通知等所有系统配置项
### 🤖 智能功能模块 ### 🤖 智能功能模块
- **`ai_reply_engine.py`** - AI智能回复引擎支持OpenAI、通义千问等多种AI模型 - **`ai_reply_engine.py`** - AI智能回复引擎支持OpenAI、通义千问等多种AI模型,意图识别、上下文管理、个性化回复
- **`secure_confirm_ultra.py`** - 自动确认发货模块,采用多层加密保护核心业务逻辑 - **`secure_confirm_ultra.py`** - 自动确认发货模块,采用多层加密保护调用闲鱼API确认发货状态支持锁机制防并发
- **`secure_freeshipping_ultra.py`** - 自动免拼发货模块,支持批量处理和异常恢复 - **`secure_freeshipping_ultra.py`** - 自动免拼发货模块,支持批量处理、异常恢复、智能匹配、规格识别
- **`file_log_collector.py`** - 实时日志收集器提供Web界面日志查看和管理 - **`file_log_collector.py`** - 实时日志收集器提供Web界面日志查看、搜索、过滤、下载和管理功能
### 🛠️ 工具模块 ### 🛠️ 工具模块 (`utils/`)
- **`utils/xianyu_utils.py`** - 闲鱼API工具函数包含加密解密、签名生成、数据解析 - **`xianyu_utils.py`** - 闲鱼API核心工具包含加密算法、签名生成、数据解析、Cookie处理、请求封装
- **`utils/message_utils.py`** - 消息格式化和处理工具,支持变量替换和模板渲染 - **`message_utils.py`** - 消息处理工具,格式化消息内容、变量替换、内容过滤、模板渲染、表情处理
- **`utils/ws_utils.py`** - WebSocket客户端封装提供连接管理和重连机制 - **`ws_utils.py`** - WebSocket客户端封装处理连接管理、心跳检测、重连机制、消息队列、异常恢复
- **`utils/item_search.py`** - 商品搜索功能基于Playwright获取真实闲鱼数据 - **`qr_login.py`** - 二维码登录功能生成登录二维码、状态检测、Cookie获取、验证、自动刷新
- **`utils/order_detail_fetcher.py`** - 订单详情获取工具,支持多规格商品信息解析 - **`item_search.py`** - 商品搜索功能基于Playwright获取真实闲鱼商品数据支持分页、过滤、排序
- **`utils/image_utils.py`** - 图片处理工具,支持压缩、格式转换、尺寸调整 - **`order_detail_fetcher.py`** - 订单详情获取工具解析订单信息、买家信息、SKU详情支持缓存优化、锁机制
- **`utils/image_uploader.py`** - 图片上传到CDN工具支持闲鱼图片服务器上传 - **`image_utils.py`** - 图片处理工具,支持压缩、格式转换、尺寸调整、水印添加、质量优化
- **`utils/qr_login.py`** - 二维码登录功能支持扫码获取Cookie - **`image_uploader.py`** - 图片上传工具支持多种CDN服务商、自动压缩、格式优化、批量上传
### 🌐 前端界面 ### 🌐 前端界面 (`static/`)
- **`static/index.html`** - 主管理界面,集成账号管理、系统监控、功能配置 - **`index.html`** - 主管理界面,包含账号管理、关键词管理、系统监控、实时状态显示
- **`static/login.html`** - 用户登录页面,支持图形验证码和记住登录状态 - **`login.html`** - 用户登录页面,支持图形验证码、记住登录状态、多重安全验证
- **`static/register.html`** - 用户注册页面,支持邮箱验证和实时验证 - **`register.html`** - 用户注册页面,支持邮箱验证码、实时验证、密码强度检测
- **`static/user_management.html`** - 用户管理页面,管理员专用功能 - **`user_management.html`** - 用户管理页面,管理员专用,用户增删改查、权限管理
- **`static/data_management.html`** - 数据管理页面支持Excel导入导出和批量操作 - **`data_management.html`** - 数据管理页面支持Excel导入导出、数据备份、批量操作
- **`static/log_management.html`** - 日志管理页面,实时日志查看和过滤 - **`log_management.html`** - 日志管理页面,实时日志查看、日志搜索过滤、日志下载
- **`static/item_search.html`** - 商品搜索页面,获取真实闲鱼商品数据 - **`item_search.html`** - 商品搜索页面,获取真实闲鱼商品数据,支持多条件搜索
- **`static/js/app.js`** - 主要JavaScript逻辑处理前端交互和API调用 - **`js/app.js`** - 主要JavaScript逻辑处理前端交互、API调用、实时更新
- **`static/css/style.css`** - 自定义样式文件,美化界面和响应式设计 - **`css/`** - 模块化样式文件,包含布局、组件、主题等分类样式,响应式设计
- **`xianyu_js_version_2.js`** - 闲鱼JavaScript工具库加密解密、数据处理、API封装
- **`lib/`** - 前端依赖库包含Bootstrap、jQuery、Chart.js等第三方库
### 🐳 部署配置 ### 🐳 部署配置
- **`Dockerfile`** - Docker镜像构建文件包含Python环境、Playwright浏览器等 - **`Dockerfile`** - Docker镜像构建文件包含Python环境、Playwright浏览器、系统依赖
- **`docker-compose.yml`** - Docker Compose配置支持一键部署和Nginx反向代理 - **`Dockerfile-cn`** - 中国镜像源版本,优化国内网络环境下的构建速度
- **`docker-deploy.sh`** - Docker部署管理脚本提供构建、启动、监控等功能 - **`docker-compose.yml`** - Docker Compose配置支持一键部署、Nginx反向代理、资源限制
- **`docker-compose-cn.yml`** - 中国镜像源版本,适配国内网络环境
- **`docker-deploy.sh`** - Docker部署管理脚本提供构建、启动、监控、日志查看等功能
- **`docker-deploy.bat`** - Windows版本部署脚本支持Windows环境一键部署
- **`entrypoint.sh`** - Docker容器启动脚本处理环境初始化和服务启动
- **`nginx/nginx.conf`** - Nginx反向代理配置支持负载均衡和SSL终端 - **`nginx/nginx.conf`** - Nginx反向代理配置支持负载均衡和SSL终端
- **`.env`** - 环境变量配置文件,包含所有可配置的系统参数 - **`.env`** - 环境变量配置文件,包含所有可配置的系统参数和敏感信息
- **`requirements.txt`** - Python依赖包列表精简版本无冗余依赖 - **`requirements.txt`** - Python依赖包列表精简版本无冗余依赖,按功能分类组织
## ⚙️ 配置说明 ## ⚙️ 配置说明

View File

@ -142,7 +142,7 @@ class XianyuLive:
self.myid = self.cookies['unb'] self.myid = self.cookies['unb']
logger.info(f"{cookie_id}】用户ID: {self.myid}") logger.info(f"{cookie_id}】用户ID: {self.myid}")
self.device_id = generate_device_id(self.myid) self.device_id = generate_device_id(self.myid)
# 心跳相关配置 # 心跳相关配置
self.heartbeat_interval = HEARTBEAT_INTERVAL self.heartbeat_interval = HEARTBEAT_INTERVAL
self.heartbeat_timeout = HEARTBEAT_TIMEOUT self.heartbeat_timeout = HEARTBEAT_TIMEOUT
@ -150,7 +150,7 @@ class XianyuLive:
self.last_heartbeat_response = 0 self.last_heartbeat_response = 0
self.heartbeat_task = None self.heartbeat_task = None
self.ws = None self.ws = None
# Token刷新相关配置 # Token刷新相关配置
self.token_refresh_interval = TOKEN_REFRESH_INTERVAL self.token_refresh_interval = TOKEN_REFRESH_INTERVAL
self.token_retry_interval = TOKEN_RETRY_INTERVAL self.token_retry_interval = TOKEN_RETRY_INTERVAL
@ -238,16 +238,34 @@ class XianyuLive:
# 先查看消息的完整结构 # 先查看消息的完整结构
logger.debug(f"{self.cookie_id}】🔍 完整消息结构: {message}") logger.debug(f"{self.cookie_id}】🔍 完整消息结构: {message}")
# 检查message['1']的结构 # 检查message['1']的结构,处理可能是列表、字典或字符串的情况
message_1 = message.get('1', {}) message_1 = message.get('1', {})
logger.debug(f"{self.cookie_id}】🔍 message['1'] keys: {list(message_1.keys()) if message_1 else 'None'}") content_json_str = ''
# 检查message['1']['6']的结构 if isinstance(message_1, dict):
message_1_6 = message_1.get('6', {}) if message_1 else {} logger.debug(f"{self.cookie_id}】🔍 message['1'] 是字典keys: {list(message_1.keys())}")
logger.debug(f"{self.cookie_id}】🔍 message['1']['6'] keys: {list(message_1_6.keys()) if message_1_6 else 'None'}")
# 检查message['1']['6']的结构
message_1_6 = message_1.get('6', {})
if isinstance(message_1_6, dict):
logger.debug(f"{self.cookie_id}】🔍 message['1']['6'] 是字典keys: {list(message_1_6.keys())}")
# 方法1: 从button的targetUrl中提取orderId
content_json_str = message_1_6.get('3', {}).get('5', '') if isinstance(message_1_6.get('3', {}), dict) else ''
else:
logger.debug(f"{self.cookie_id}】🔍 message['1']['6'] 不是字典: {type(message_1_6)}")
elif isinstance(message_1, list):
logger.debug(f"{self.cookie_id}】🔍 message['1'] 是列表,长度: {len(message_1)}")
# 如果message['1']是列表,跳过这种提取方式
elif isinstance(message_1, str):
logger.debug(f"{self.cookie_id}】🔍 message['1'] 是字符串,长度: {len(message_1)}")
# 如果message['1']是字符串,跳过这种提取方式
else:
logger.debug(f"{self.cookie_id}】🔍 message['1'] 未知类型: {type(message_1)}")
# 其他类型,跳过这种提取方式
# 方法1: 从button的targetUrl中提取orderId
content_json_str = message.get('1', {}).get('6', {}).get('3', {}).get('5', '')
if content_json_str: if content_json_str:
try: try:
content_data = json.loads(content_json_str) content_data = json.loads(content_json_str)
@ -287,10 +305,40 @@ class XianyuLive:
except Exception as parse_e: except Exception as parse_e:
logger.debug(f"解析dynamicOperation JSON失败: {parse_e}") logger.debug(f"解析dynamicOperation JSON失败: {parse_e}")
# 方法3: 如果前面的方法都失败尝试在整个消息中搜索订单ID模式
if not order_id:
try:
# 将整个消息转换为字符串进行搜索
message_str = str(message)
# 搜索各种可能的订单ID模式
patterns = [
r'orderId[=:](\d{10,})', # orderId=123456789 或 orderId:123456789
r'order_detail\?id=(\d{10,})', # order_detail?id=123456789
r'"id"\s*:\s*"?(\d{10,})"?', # "id":"123456789" 或 "id":123456789
r'bizOrderId[=:](\d{10,})', # bizOrderId=123456789
]
for pattern in patterns:
matches = re.findall(pattern, message_str)
if matches:
# 取第一个匹配的订单ID
order_id = matches[0]
logger.info(f'{self.cookie_id}】✅ 从消息字符串中提取到订单ID: {order_id} (模式: {pattern})')
break
except Exception as search_e:
logger.debug(f"在消息字符串中搜索订单ID失败: {search_e}")
if order_id:
logger.info(f'{self.cookie_id}】🎯 最终提取到订单ID: {order_id}')
else:
logger.debug(f'{self.cookie_id}】❌ 未能从消息中提取到订单ID')
return order_id return order_id
except Exception as e: except Exception as e:
logger.error(f"提取订单ID失败: {self._safe_str(e)}") logger.error(f"{self.cookie_id}提取订单ID失败: {self._safe_str(e)}")
return None return None
async def _handle_auto_delivery(self, websocket, message: dict, send_user_name: str, send_user_id: str, async def _handle_auto_delivery(self, websocket, message: dict, send_user_name: str, send_user_id: str,
@ -321,7 +369,7 @@ class XianyuLive:
logger.info(f"{self.cookie_id}】准备自动发货: item_id={item_id}, item_title={item_title}") logger.info(f"{self.cookie_id}】准备自动发货: item_id={item_id}, item_title={item_title}")
# 调用自动发货方法(包含自动确认发货) # 调用自动发货方法(包含自动确认发货)
delivery_content = await self._auto_delivery(item_id, item_title, order_id) delivery_content = await self._auto_delivery(item_id, item_title, order_id, send_user_id)
if delivery_content: if delivery_content:
# 标记已发货(防重复)- 基于订单ID # 标记已发货(防重复)- 基于订单ID
@ -394,18 +442,18 @@ class XianyuLive:
data = { data = {
'data': data_val, 'data': data_val,
} }
# 获取token # 获取token
token = None token = None
token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else '' token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
sign = generate_sign(params['t'], token, data_val) sign = generate_sign(params['t'], token, data_val)
params['sign'] = sign params['sign'] = sign
# 发送请求 # 发送请求
headers = DEFAULT_HEADERS.copy() headers = DEFAULT_HEADERS.copy()
headers['cookie'] = self.cookies_str headers['cookie'] = self.cookies_str
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.post( async with session.post(
API_ENDPOINTS.get('token'), API_ENDPOINTS.get('token'),
@ -414,7 +462,7 @@ class XianyuLive:
headers=headers headers=headers
) as response: ) as response:
res_json = await response.json() res_json = await response.json()
# 检查并更新Cookie # 检查并更新Cookie
if 'set-cookie' in response.headers: if 'set-cookie' in response.headers:
new_cookies = {} new_cookies = {}
@ -422,7 +470,7 @@ class XianyuLive:
if '=' in cookie: if '=' in cookie:
name, value = cookie.split(';')[0].split('=', 1) name, value = cookie.split(';')[0].split('=', 1)
new_cookies[name.strip()] = value.strip() new_cookies[name.strip()] = value.strip()
# 更新cookies # 更新cookies
if new_cookies: if new_cookies:
self.cookies.update(new_cookies) self.cookies.update(new_cookies)
@ -431,7 +479,7 @@ class XianyuLive:
# 更新数据库中的Cookie # 更新数据库中的Cookie
await self.update_config_cookies() await self.update_config_cookies()
logger.debug("已更新Cookie到数据库") logger.debug("已更新Cookie到数据库")
if isinstance(res_json, dict): if isinstance(res_json, dict):
ret_value = res_json.get('ret', []) ret_value = res_json.get('ret', [])
# 检查ret是否包含成功信息 # 检查ret是否包含成功信息
@ -442,7 +490,7 @@ class XianyuLive:
self.last_token_refresh_time = time.time() self.last_token_refresh_time = time.time()
logger.info(f"{self.cookie_id}】Token刷新成功") logger.info(f"{self.cookie_id}】Token刷新成功")
return new_token return new_token
logger.error(f"{self.cookie_id}】Token刷新失败: {res_json}") logger.error(f"{self.cookie_id}】Token刷新失败: {res_json}")
# 发送Token刷新失败通知 # 发送Token刷新失败通知
await self.send_token_refresh_notification(f"Token刷新失败: {res_json}", "token_refresh_failed") await self.send_token_refresh_notification(f"Token刷新失败: {res_json}", "token_refresh_failed")
@ -822,7 +870,7 @@ class XianyuLive:
if '=' in cookie: if '=' in cookie:
name, value = cookie.split(';')[0].split('=', 1) name, value = cookie.split(';')[0].split('=', 1)
new_cookies[name.strip()] = value.strip() new_cookies[name.strip()] = value.strip()
# 更新cookies # 更新cookies
if new_cookies: if new_cookies:
self.cookies.update(new_cookies) self.cookies.update(new_cookies)
@ -839,7 +887,7 @@ class XianyuLive:
# 检查ret是否包含成功信息 # 检查ret是否包含成功信息
if not any('SUCCESS::调用成功' in ret for ret in ret_value): if not any('SUCCESS::调用成功' in ret for ret in ret_value):
logger.warning(f"商品信息API调用失败错误信息: {ret_value}") logger.warning(f"商品信息API调用失败错误信息: {ret_value}")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
return await self.get_item_info(item_id, retry_count + 1) return await self.get_item_info(item_id, retry_count + 1)
else: else:
@ -962,12 +1010,36 @@ class XianyuLive:
except Exception as e: except Exception as e:
logger.error(f"调试消息结构时发生错误: {self._safe_str(e)}") logger.error(f"调试消息结构时发生错误: {self._safe_str(e)}")
async def get_default_reply(self, send_user_name: str, send_user_id: str, send_message: str, chat_id: str = None) -> str: async def get_default_reply(self, send_user_name: str, send_user_id: str, send_message: str, chat_id: str, item_id: str = None) -> str:
"""获取默认回复内容,支持变量替换和只回复一次功能""" """获取默认回复内容,支持指定商品回复、变量替换和只回复一次功能"""
try: try:
from db_manager import db_manager from db_manager import db_manager
# 获取当前账号的默认回复设置 # 1. 优先检查指定商品回复
if item_id:
item_reply = db_manager.get_item_reply(self.cookie_id, item_id)
if item_reply and item_reply.get('reply_content'):
reply_content = item_reply['reply_content']
logger.info(f"{self.cookie_id}】使用指定商品回复: 商品ID={item_id}")
# 进行变量替换
try:
formatted_reply = reply_content.format(
send_user_name=send_user_name,
send_user_id=send_user_id,
send_message=send_message,
item_id=item_id
)
logger.info(f"{self.cookie_id}】指定商品回复内容: {formatted_reply}")
return formatted_reply
except Exception as format_error:
logger.error(f"指定商品回复变量替换失败: {self._safe_str(format_error)}")
# 如果变量替换失败,返回原始内容
return reply_content
else:
logger.debug(f"{self.cookie_id}】商品ID {item_id} 没有配置指定回复,使用默认回复")
# 2. 获取当前账号的默认回复设置
default_reply_settings = db_manager.get_default_reply(self.cookie_id) default_reply_settings = db_manager.get_default_reply(self.cookie_id)
if not default_reply_settings or not default_reply_settings.get('enabled', False): if not default_reply_settings or not default_reply_settings.get('enabled', False):
@ -982,9 +1054,9 @@ class XianyuLive:
return None return None
reply_content = default_reply_settings.get('reply_content', '') reply_content = default_reply_settings.get('reply_content', '')
if not reply_content: if not reply_content or (reply_content and reply_content.strip() == ''):
logger.warning(f"账号 {self.cookie_id} 默认回复内容为空") logger.info(f"账号 {self.cookie_id} 默认回复内容为空,不进行回复")
return None return "EMPTY_REPLY" # 返回特殊标记表示不回复
# 进行变量替换 # 进行变量替换
try: try:
@ -1039,7 +1111,12 @@ class XianyuLive:
# 图片类型关键词,发送图片 # 图片类型关键词,发送图片
return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message) return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message)
else: else:
# 文本类型关键词,进行变量替换 # 文本类型关键词,检查回复内容是否为空
if not reply or (reply and reply.strip() == ''):
logger.info(f"商品ID关键词 '{keyword}' 回复内容为空,不进行回复")
return "EMPTY_REPLY" # 返回特殊标记表示匹配到但不回复
# 进行变量替换
try: try:
formatted_reply = reply.format( formatted_reply = reply.format(
send_user_name=send_user_name, send_user_name=send_user_name,
@ -1069,7 +1146,12 @@ class XianyuLive:
# 图片类型关键词,发送图片 # 图片类型关键词,发送图片
return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message) return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message)
else: else:
# 文本类型关键词,进行变量替换 # 文本类型关键词,检查回复内容是否为空
if not reply or (reply and reply.strip() == ''):
logger.info(f"通用关键词 '{keyword}' 回复内容为空,不进行回复")
return "EMPTY_REPLY" # 返回特殊标记表示匹配到但不回复
# 进行变量替换
try: try:
formatted_reply = reply.format( formatted_reply = reply.format(
send_user_name=send_user_name, send_user_name=send_user_name,
@ -1254,14 +1336,17 @@ class XianyuLive:
from db_manager import db_manager from db_manager import db_manager
import aiohttp import aiohttp
logger.info(f"📱 开始发送消息通知 - 账号: {self.cookie_id}, 买家: {send_user_name}")
# 获取当前账号的通知配置 # 获取当前账号的通知配置
notifications = db_manager.get_account_notifications(self.cookie_id) notifications = db_manager.get_account_notifications(self.cookie_id)
if not notifications: if not notifications:
logger.debug(f"账号 {self.cookie_id} 未配置消息通知") logger.warning(f"📱 账号 {self.cookie_id} 未配置消息通知,跳过通知发送")
return return
logger.info(f"📱 找到 {len(notifications)} 个通知渠道配置")
# 构建通知消息 # 构建通知消息
notification_msg = f"🚨 接收消息通知\n\n" \ notification_msg = f"🚨 接收消息通知\n\n" \
f"账号: {self.cookie_id}\n" \ f"账号: {self.cookie_id}\n" \
@ -1271,38 +1356,54 @@ class XianyuLive:
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n" f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
# 发送通知到各个渠道 # 发送通知到各个渠道
for notification in notifications: for i, notification in enumerate(notifications, 1):
logger.info(f"📱 处理第 {i} 个通知渠道: {notification.get('channel_name', 'Unknown')}")
if not notification.get('enabled', True): if not notification.get('enabled', True):
logger.warning(f"📱 通知渠道 {notification.get('channel_name')} 已禁用,跳过")
continue continue
channel_type = notification.get('channel_type') channel_type = notification.get('channel_type')
channel_config = notification.get('channel_config') channel_config = notification.get('channel_config')
logger.info(f"📱 渠道类型: {channel_type}, 配置: {channel_config}")
try: try:
# 解析配置数据 # 解析配置数据
config_data = self._parse_notification_config(channel_config) config_data = self._parse_notification_config(channel_config)
logger.info(f"📱 解析后的配置数据: {config_data}")
match channel_type: match channel_type:
case 'qq': case 'qq':
logger.info(f"📱 开始发送QQ通知...")
await self._send_qq_notification(config_data, notification_msg) await self._send_qq_notification(config_data, notification_msg)
case 'ding_talk' | 'dingtalk': case 'ding_talk' | 'dingtalk':
logger.info(f"📱 开始发送钉钉通知...")
await self._send_dingtalk_notification(config_data, notification_msg) await self._send_dingtalk_notification(config_data, notification_msg)
case 'email': case 'email':
logger.info(f"📱 开始发送邮件通知...")
await self._send_email_notification(config_data, notification_msg) await self._send_email_notification(config_data, notification_msg)
case 'webhook': case 'webhook':
logger.info(f"📱 开始发送Webhook通知...")
await self._send_webhook_notification(config_data, notification_msg) await self._send_webhook_notification(config_data, notification_msg)
case 'wechat': case 'wechat':
logger.info(f"📱 开始发送微信通知...")
await self._send_wechat_notification(config_data, notification_msg) await self._send_wechat_notification(config_data, notification_msg)
case 'telegram': case 'telegram':
logger.info(f"📱 开始发送Telegram通知...")
await self._send_telegram_notification(config_data, notification_msg) await self._send_telegram_notification(config_data, notification_msg)
case _: case _:
logger.warning(f"不支持的通知渠道类型: {channel_type}") logger.warning(f"📱 不支持的通知渠道类型: {channel_type}")
except Exception as notify_error: except Exception as notify_error:
logger.error(f"发送通知失败 ({notification.get('channel_name', 'Unknown')}): {self._safe_str(notify_error)}") logger.error(f"📱 发送通知失败 ({notification.get('channel_name', 'Unknown')}): {self._safe_str(notify_error)}")
import traceback
logger.error(f"📱 详细错误信息: {traceback.format_exc()}")
except Exception as e: except Exception as e:
logger.error(f"处理消息通知失败: {self._safe_str(e)}") logger.error(f"📱 处理消息通知失败: {self._safe_str(e)}")
import traceback
logger.error(f"📱 详细错误信息: {traceback.format_exc()}")
def _parse_notification_config(self, config: str) -> dict: def _parse_notification_config(self, config: str) -> dict:
"""解析通知配置数据""" """解析通知配置数据"""
@ -1319,12 +1420,16 @@ class XianyuLive:
try: try:
import aiohttp import aiohttp
logger.info(f"📱 QQ通知 - 开始处理配置数据: {config_data}")
# 解析配置QQ号码 # 解析配置QQ号码
qq_number = config_data.get('qq_number') or config_data.get('config', '') qq_number = config_data.get('qq_number') or config_data.get('config', '')
qq_number = qq_number.strip() if qq_number else '' qq_number = qq_number.strip() if qq_number else ''
logger.info(f"📱 QQ通知 - 解析到QQ号码: {qq_number}")
if not qq_number: if not qq_number:
logger.warning("QQ通知配置为空") logger.warning("📱 QQ通知 - QQ号码配置为空,无法发送通知")
return return
# 构建请求URL # 构建请求URL
@ -1334,16 +1439,25 @@ class XianyuLive:
'msg': message 'msg': message
} }
logger.info(f"📱 QQ通知 - 请求URL: {api_url}")
logger.info(f"📱 QQ通知 - 请求参数: qq={qq_number}, msg长度={len(message)}")
# 发送GET请求 # 发送GET请求
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get(api_url, params=params, timeout=10) as response: async with session.get(api_url, params=params, timeout=10) as response:
response_text = await response.text()
logger.info(f"📱 QQ通知 - 响应状态: {response.status}")
logger.info(f"📱 QQ通知 - 响应内容: {response_text}")
if response.status == 200: if response.status == 200:
logger.info(f"QQ通知发送成功: {qq_number}") logger.info(f"📱 QQ通知发送成功: {qq_number}")
else: else:
logger.warning(f"QQ通知发送失败: {response.status}") logger.warning(f"📱 QQ通知发送失败: HTTP {response.status}, 响应: {response_text}")
except Exception as e: except Exception as e:
logger.error(f"发送QQ通知异常: {self._safe_str(e)}") logger.error(f"📱 发送QQ通知异常: {self._safe_str(e)}")
import traceback
logger.error(f"📱 QQ通知异常详情: {traceback.format_exc()}")
async def _send_dingtalk_notification(self, config_data: dict, message: str): async def _send_dingtalk_notification(self, config_data: dict, message: str):
"""发送钉钉通知""" """发送钉钉通知"""
@ -1788,25 +1902,37 @@ class XianyuLive:
except Exception as e: except Exception as e:
logger.error(f"发送自动发货通知异常: {self._safe_str(e)}") logger.error(f"发送自动发货通知异常: {self._safe_str(e)}")
async def auto_confirm(self, order_id, retry_count=0): async def auto_confirm(self, order_id, item_id=None, retry_count=0):
"""自动确认发货 - 使用加密模块不包含延时处理延时已在_auto_delivery中处理""" """自动确认发货 - 使用加密模块不包含延时处理延时已在_auto_delivery中处理"""
try: try:
logger.debug(f"{self.cookie_id}】开始确认发货订单ID: {order_id}") logger.debug(f"{self.cookie_id}】开始确认发货订单ID: {order_id}")
# 导入超级混淆加密模块 # 导入解密后的确认发货模块
from secure_confirm_ultra import SecureConfirm from secure_confirm_decrypted import SecureConfirm
# 创建加密确认实例 # 创建确认实例,传入主界面类实例
secure_confirm = SecureConfirm(self.session, self.cookies_str, self.cookie_id) secure_confirm = SecureConfirm(self.session, self.cookies_str, self.cookie_id, self)
# 传递必要的属性 # 传递必要的属性
secure_confirm.current_token = self.current_token secure_confirm.current_token = self.current_token
secure_confirm.last_token_refresh_time = self.last_token_refresh_time secure_confirm.last_token_refresh_time = self.last_token_refresh_time
secure_confirm.token_refresh_interval = self.token_refresh_interval secure_confirm.token_refresh_interval = self.token_refresh_interval
secure_confirm.refresh_token = self.refresh_token # 传递refresh_token方法
# 调用加密的确认方法 # 调用确认方法传入item_id用于token刷新
return await secure_confirm.auto_confirm(order_id, retry_count) result = await secure_confirm.auto_confirm(order_id, item_id, retry_count)
# 同步更新后的cookies和token
if secure_confirm.cookies_str != self.cookies_str:
self.cookies_str = secure_confirm.cookies_str
self.cookies = secure_confirm.cookies
logger.debug(f"{self.cookie_id}】已同步确认发货模块更新的cookies")
if secure_confirm.current_token != self.current_token:
self.current_token = secure_confirm.current_token
self.last_token_refresh_time = secure_confirm.last_token_refresh_time
logger.debug(f"{self.cookie_id}】已同步确认发货模块更新的token")
return result
except Exception as e: except Exception as e:
logger.error(f"{self.cookie_id}】加密确认模块调用失败: {self._safe_str(e)}") logger.error(f"{self.cookie_id}】加密确认模块调用失败: {self._safe_str(e)}")
@ -1836,20 +1962,26 @@ class XianyuLive:
logger.error(f"{self.cookie_id}】加密免拼发货模块调用失败: {self._safe_str(e)}") logger.error(f"{self.cookie_id}】加密免拼发货模块调用失败: {self._safe_str(e)}")
return {"error": f"加密免拼发货模块调用失败: {self._safe_str(e)}", "order_id": order_id} return {"error": f"加密免拼发货模块调用失败: {self._safe_str(e)}", "order_id": order_id}
async def fetch_order_detail_info(self, order_id: str): async def fetch_order_detail_info(self, order_id: str, item_id: str = None, buyer_id: str = None, debug_headless: bool = None):
"""获取订单详情信息""" """获取订单详情信息"""
try: try:
logger.info(f"{self.cookie_id}】开始获取订单详情: {order_id}") logger.info(f"{self.cookie_id}】开始获取订单详情: {order_id}")
# 导入订单详情获取器 # 导入订单详情获取器
from utils.order_detail_fetcher import fetch_order_detail_simple from utils.order_detail_fetcher import fetch_order_detail_simple
from db_manager import db_manager
# 获取当前账号的cookie字符串 # 获取当前账号的cookie字符串
cookie_string = self.cookies_str cookie_string = self.cookies_str
logger.debug(f"{self.cookie_id}】使用Cookie长度: {len(cookie_string) if cookie_string else 0}") logger.debug(f"{self.cookie_id}】使用Cookie长度: {len(cookie_string) if cookie_string else 0}")
# 异步获取订单详情使用当前账号的cookie和无头模式 # 确定是否使用有头模式(调试用)
result = await fetch_order_detail_simple(order_id, cookie_string, headless=True) headless_mode = True if debug_headless is None else debug_headless
if not headless_mode:
logger.info(f"{self.cookie_id}】🖥️ 启用有头模式进行调试")
# 异步获取订单详情使用当前账号的cookie
result = await fetch_order_detail_simple(order_id, cookie_string, headless=headless_mode)
if result: if result:
logger.info(f"{self.cookie_id}】订单详情获取成功: {order_id}") logger.info(f"{self.cookie_id}】订单详情获取成功: {order_id}")
@ -1858,6 +1990,8 @@ class XianyuLive:
# 获取解析后的规格信息 # 获取解析后的规格信息
spec_name = result.get('spec_name', '') spec_name = result.get('spec_name', '')
spec_value = result.get('spec_value', '') spec_value = result.get('spec_value', '')
quantity = result.get('quantity', '')
amount = result.get('amount', '')
if spec_name and spec_value: if spec_name and spec_value:
logger.info(f"{self.cookie_id}】📋 规格名称: {spec_name}") logger.info(f"{self.cookie_id}】📋 规格名称: {spec_name}")
@ -1867,6 +2001,29 @@ class XianyuLive:
logger.warning(f"{self.cookie_id}】未获取到有效的规格信息") logger.warning(f"{self.cookie_id}】未获取到有效的规格信息")
print(f"⚠️ 【{self.cookie_id}】订单 {order_id} 规格信息获取失败") print(f"⚠️ 【{self.cookie_id}】订单 {order_id} 规格信息获取失败")
# 插入或更新订单信息到数据库
try:
success = db_manager.insert_or_update_order(
order_id=order_id,
item_id=item_id,
buyer_id=buyer_id,
spec_name=spec_name,
spec_value=spec_value,
quantity=quantity,
amount=amount,
order_status='processed', # 已处理状态
cookie_id=self.cookie_id
)
if success:
logger.info(f"{self.cookie_id}】订单信息已保存到数据库: {order_id}")
print(f"💾 【{self.cookie_id}】订单 {order_id} 信息已保存到数据库")
else:
logger.warning(f"{self.cookie_id}】订单信息保存失败: {order_id}")
except Exception as db_e:
logger.error(f"{self.cookie_id}】保存订单信息到数据库失败: {self._safe_str(db_e)}")
return result return result
else: else:
logger.warning(f"{self.cookie_id}】订单详情获取失败: {order_id}") logger.warning(f"{self.cookie_id}】订单详情获取失败: {order_id}")
@ -1876,7 +2033,7 @@ class XianyuLive:
logger.error(f"{self.cookie_id}】获取订单详情异常: {self._safe_str(e)}") logger.error(f"{self.cookie_id}】获取订单详情异常: {self._safe_str(e)}")
return None return None
async def _auto_delivery(self, item_id: str, item_title: str = None, order_id: str = None): async def _auto_delivery(self, item_id: str, item_title: str = None, order_id: str = None, send_user_id: str = None):
"""自动发货功能 - 获取卡券规则,执行延时,确认发货,发送内容""" """自动发货功能 - 获取卡券规则,执行延时,确认发货,发送内容"""
try: try:
from db_manager import db_manager from db_manager import db_manager
@ -1987,7 +2144,7 @@ class XianyuLive:
if is_multi_spec and order_id: if is_multi_spec and order_id:
logger.info(f"检测到多规格商品,获取订单规格信息: {order_id}") logger.info(f"检测到多规格商品,获取订单规格信息: {order_id}")
try: try:
order_detail = await self.fetch_order_detail_info(order_id) order_detail = await self.fetch_order_detail_info(order_id, item_id, send_user_id)
if order_detail: if order_detail:
spec_name = order_detail.get('spec_name', '') spec_name = order_detail.get('spec_name', '')
spec_value = order_detail.get('spec_value', '') spec_value = order_detail.get('spec_value', '')
@ -2087,8 +2244,8 @@ class XianyuLive:
should_confirm = False should_confirm = False
if should_confirm: if should_confirm:
logger.info(f"开始自动确认发货: 订单ID={order_id}") logger.info(f"开始自动确认发货: 订单ID={order_id}, 商品ID={item_id}")
confirm_result = await self.auto_confirm(order_id) confirm_result = await self.auto_confirm(order_id, item_id)
if confirm_result.get('success'): if confirm_result.get('success'):
self.confirmed_orders[order_id] = current_time self.confirmed_orders[order_id] = current_time
logger.info(f"🎉 自动确认发货成功订单ID: {order_id}") logger.info(f"🎉 自动确认发货成功订单ID: {order_id}")
@ -2098,6 +2255,23 @@ class XianyuLive:
# 检查是否存在订单ID只有存在订单ID才处理发货内容 # 检查是否存在订单ID只有存在订单ID才处理发货内容
if order_id: if order_id:
# 保存订单基本信息到数据库(如果还没有详细信息)
try:
from db_manager import db_manager
existing_order = db_manager.get_order_by_id(order_id)
if not existing_order:
# 插入基本订单信息
db_manager.insert_or_update_order(
order_id=order_id,
item_id=item_id,
buyer_id=send_user_id,
order_status='processing', # 处理中状态
cookie_id=self.cookie_id
)
logger.info(f"保存基本订单信息到数据库: {order_id}")
except Exception as db_e:
logger.error(f"保存基本订单信息失败: {self._safe_str(db_e)}")
# 开始处理发货内容 # 开始处理发货内容
logger.info(f"开始处理发货内容,规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})") logger.info(f"开始处理发货内容,规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
@ -2384,7 +2558,7 @@ class XianyuLive:
else: else:
logger.info("由于刚刚尝试过token刷新跳过重复的初始化失败通知") logger.info("由于刚刚尝试过token刷新跳过重复的初始化失败通知")
raise Exception("Token获取失败") raise Exception("Token获取失败")
msg = { msg = {
"lwp": "/reg", "lwp": "/reg",
"headers": { "headers": {
@ -2572,8 +2746,8 @@ class XianyuLive:
"""判断是否为用户聊天消息""" """判断是否为用户聊天消息"""
try: try:
return ( return (
isinstance(message, dict) isinstance(message, dict)
and "1" in message and "1" in message
and isinstance(message["1"], dict) and isinstance(message["1"], dict)
and "10" in message["1"] and "10" in message["1"]
and isinstance(message["1"]["10"], dict) and isinstance(message["1"]["10"], dict)
@ -2621,7 +2795,7 @@ class XianyuLive:
api_config = AUTO_REPLY.get('api', {}) api_config = AUTO_REPLY.get('api', {})
timeout = aiohttp.ClientTimeout(total=api_config.get('timeout', 10)) timeout = aiohttp.ClientTimeout(total=api_config.get('timeout', 10))
payload = { payload = {
"cookie_id": self.cookie_id, "cookie_id": self.cookie_id,
"msg_time": msg_time, "msg_time": msg_time,
@ -2632,14 +2806,14 @@ class XianyuLive:
"send_message": send_message, "send_message": send_message,
"chat_id": chat_id "chat_id": chat_id
} }
async with self.session.post( async with self.session.post(
api_config.get('url', 'http://localhost:8080/xianyu/reply'), api_config.get('url', 'http://localhost:8080/xianyu/reply'),
json=payload, json=payload,
timeout=timeout timeout=timeout
) as response: ) as response:
result = await response.json() result = await response.json()
# 将code转换为字符串进行比较或者直接用数字比较 # 将code转换为字符串进行比较或者直接用数字比较
if str(result.get('code')) == '200' or result.get('code') == 200: if str(result.get('code')) == '200' or result.get('code') == 200:
send_msg = result.get('data', {}).get('send_msg') send_msg = result.get('data', {}).get('send_msg')
@ -2656,7 +2830,7 @@ class XianyuLive:
else: else:
logger.warning(f"API返回错误: {result.get('msg', '未知错误')}") logger.warning(f"API返回错误: {result.get('msg', '未知错误')}")
return None return None
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error("API调用超时") logger.error("API调用超时")
return None return None
@ -2699,7 +2873,7 @@ class XianyuLive:
# 获取并解密数据 # 获取并解密数据
sync_data = message_data["body"]["syncPushPackage"]["data"][0] sync_data = message_data["body"]["syncPushPackage"]["data"][0]
# 检查是否有必要的字段 # 检查是否有必要的字段
if "data" not in sync_data: if "data" not in sync_data:
logger.debug("同步包中无data字段") logger.debug("同步包中无data字段")
@ -2749,6 +2923,62 @@ class XianyuLive:
logger.debug(f"消息内容: {message}") logger.debug(f"消息内容: {message}")
return return
# 【优先处理】尝试获取订单ID并获取订单详情
order_id = None
try:
order_id = self._extract_order_id(message)
if order_id:
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 检测到订单ID: {order_id},开始获取订单详情')
# 立即获取订单详情信息
try:
# 先尝试提取用户ID和商品ID用于订单详情获取
temp_user_id = None
temp_item_id = None
# 提取用户ID
try:
message_1 = message.get("1")
if isinstance(message_1, str) and '@' in message_1:
temp_user_id = message_1.split('@')[0]
elif isinstance(message_1, dict):
# 从字典中提取用户ID
if "10" in message_1 and isinstance(message_1["10"], dict):
temp_user_id = message_1["10"].get("senderUserId", "unknown_user")
else:
temp_user_id = "unknown_user"
else:
temp_user_id = "unknown_user"
except:
temp_user_id = "unknown_user"
# 提取商品ID
try:
if "1" in message and isinstance(message["1"], dict) and "10" in message["1"] and isinstance(message["1"]["10"], dict):
url_info = message["1"]["10"].get("reminderUrl", "")
if isinstance(url_info, str) and "itemId=" in url_info:
temp_item_id = url_info.split("itemId=")[1].split("&")[0]
if not temp_item_id:
temp_item_id = self.extract_item_id_from_message(message)
except:
pass
# 调用订单详情获取方法
order_detail = await self.fetch_order_detail_info(order_id, temp_item_id, temp_user_id)
if order_detail:
logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 订单详情获取成功: {order_id}')
else:
logger.warning(f'[{msg_time}] 【{self.cookie_id}】⚠️ 订单详情获取失败: {order_id}')
except Exception as detail_e:
logger.error(f'[{msg_time}] 【{self.cookie_id}】❌ 获取订单详情异常: {self._safe_str(detail_e)}')
else:
logger.debug(f"{self.cookie_id}】未检测到订单ID")
except Exception as e:
logger.error(f"{self.cookie_id}】提取订单ID失败: {self._safe_str(e)}")
# 安全地获取用户ID # 安全地获取用户ID
user_id = None user_id = None
try: try:
@ -2756,8 +2986,11 @@ class XianyuLive:
if isinstance(message_1, str) and '@' in message_1: if isinstance(message_1, str) and '@' in message_1:
user_id = message_1.split('@')[0] user_id = message_1.split('@')[0]
elif isinstance(message_1, dict): elif isinstance(message_1, dict):
# 如果message['1']是字典尝试其他方式提取user_id # 如果message['1']是字典从message["1"]["10"]["senderUserId"]中提取user_id
user_id = "unknown_user" if "10" in message_1 and isinstance(message_1["10"], dict):
user_id = message_1["10"].get("senderUserId", "unknown_user")
else:
user_id = "unknown_user"
else: else:
user_id = "unknown_user" user_id = "unknown_user"
except Exception as e: except Exception as e:
@ -2856,6 +3089,11 @@ class XianyuLive:
else: else:
logger.info(f"[{msg_time}] 【收到】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {send_message}") logger.info(f"[{msg_time}] 【收到】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {send_message}")
# 🔔 立即发送消息通知(独立于自动回复功能)
try:
await self.send_notification(send_user_name, send_user_id, send_message, item_id)
except Exception as notify_error:
logger.error(f"📱 发送消息通知失败: {self._safe_str(notify_error)}")
@ -2875,7 +3113,7 @@ class XianyuLive:
# 构造用户URL # 构造用户URL
user_url = f'https://www.goofish.com/personal?userId={send_user_id}' user_url = f'https://www.goofish.com/personal?userId={send_user_id}'
reply = None reply = None
# 判断是否启用API回复 # 判断是否启用API回复
if AUTO_REPLY.get('api', {}).get('enabled', False): if AUTO_REPLY.get('api', {}).get('enabled', False):
@ -2885,7 +3123,7 @@ class XianyuLive:
) )
if not reply: if not reply:
logger.error(f"[{msg_time}] 【API调用失败】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {send_message}") logger.error(f"[{msg_time}] 【API调用失败】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {send_message}")
if send_message == '[我已拍下,待付款]': if send_message == '[我已拍下,待付款]':
logger.info(f'[{msg_time}] 【{self.cookie_id}】系统消息不处理') logger.info(f'[{msg_time}] 【{self.cookie_id}】系统消息不处理')
return return
@ -2978,7 +3216,11 @@ class XianyuLive:
if not reply: if not reply:
# 1. 首先尝试关键词匹配传入商品ID # 1. 首先尝试关键词匹配传入商品ID
reply = await self.get_keyword_reply(send_user_name, send_user_id, send_message, item_id) reply = await self.get_keyword_reply(send_user_name, send_user_id, send_message, item_id)
if reply: if reply == "EMPTY_REPLY":
# 匹配到关键词但回复内容为空,不进行任何回复
logger.info(f"[{msg_time}] 【{self.cookie_id}】匹配到空回复关键词,跳过自动回复")
return
elif reply:
reply_source = '关键词' # 标记为关键词回复 reply_source = '关键词' # 标记为关键词回复
else: else:
# 2. 关键词匹配失败如果AI开关打开尝试AI回复 # 2. 关键词匹配失败如果AI开关打开尝试AI回复
@ -2987,13 +3229,16 @@ class XianyuLive:
reply_source = 'AI' # 标记为AI回复 reply_source = 'AI' # 标记为AI回复
else: else:
# 3. 最后使用默认回复 # 3. 最后使用默认回复
reply = await self.get_default_reply(send_user_name, send_user_id, send_message, chat_id) reply = await self.get_default_reply(send_user_name, send_user_id, send_message, chat_id, item_id)
if reply == "EMPTY_REPLY":
# 默认回复内容为空,不进行任何回复
logger.info(f"[{msg_time}] 【{self.cookie_id}】默认回复内容为空,跳过自动回复")
return
reply_source = '默认' # 标记为默认回复 reply_source = '默认' # 标记为默认回复
# 注意这里只有商品ID没有标题和详情根据新的规则不保存到数据库 # 注意这里只有商品ID没有标题和详情根据新的规则不保存到数据库
# 商品信息会在其他有完整信息的地方保存(如发货规则匹配时) # 商品信息会在其他有完整信息的地方保存(如发货规则匹配时)
# 发送通知 # 消息通知已在收到消息时立即发送,此处不再重复发送
await self.send_notification(send_user_name, send_user_id, send_message, item_id)
# 如果有回复内容,发送消息 # 如果有回复内容,发送消息
if reply: if reply:
@ -3022,7 +3267,7 @@ class XianyuLive:
else: else:
msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】未找到匹配的回复规则,不回复") logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】未找到匹配的回复规则,不回复")
except Exception as e: except Exception as e:
logger.error(f"处理消息时发生错误: {self._safe_str(e)}") logger.error(f"处理消息时发生错误: {self._safe_str(e)}")
logger.debug(f"原始消息: {message_data}") logger.debug(f"原始消息: {message_data}")
@ -3194,7 +3439,7 @@ class XianyuLive:
if '=' in cookie: if '=' in cookie:
name, value = cookie.split(';')[0].split('=', 1) name, value = cookie.split(';')[0].split('=', 1)
new_cookies[name.strip()] = value.strip() new_cookies[name.strip()] = value.strip()
# 更新cookies # 更新cookies
if new_cookies: if new_cookies:
self.cookies.update(new_cookies) self.cookies.update(new_cookies)

View File

@ -212,6 +212,24 @@ class DBManager:
) )
''') ''')
# 创建订单表
cursor.execute('''
CREATE TABLE IF NOT EXISTS orders (
order_id TEXT PRIMARY KEY,
item_id TEXT,
buyer_id TEXT,
spec_name TEXT,
spec_value TEXT,
quantity TEXT,
amount TEXT,
order_status TEXT DEFAULT 'unknown',
cookie_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE
)
''')
# 检查并添加 user_id 列(用于数据库迁移) # 检查并添加 user_id 列(用于数据库迁移)
try: try:
self._execute_sql(cursor, "SELECT user_id FROM cards LIMIT 1") self._execute_sql(cursor, "SELECT user_id FROM cards LIMIT 1")
@ -297,6 +315,18 @@ class DBManager:
if "duplicate column name" not in str(e).lower(): if "duplicate column name" not in str(e).lower():
logger.warning(f"添加 reply_once 字段失败: {e}") logger.warning(f"添加 reply_once 字段失败: {e}")
# 创建指定商品回复表
cursor.execute('''
CREATE TABLE IF NOT EXISTS item_replay (
item_id TEXT NOT NULL PRIMARY KEY,
cookie_id TEXT NOT NULL,
reply_content TEXT NOT NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (item_id) REFERENCES cookies(id) ON DELETE CASCADE
)
''')
# 创建默认回复记录表记录已回复的chat_id # 创建默认回复记录表记录已回复的chat_id
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS default_reply_records ( CREATE TABLE IF NOT EXISTS default_reply_records (
@ -4017,6 +4047,138 @@ class DBManager:
logger.error(f"获取表数据失败: {table_name} - {e}") logger.error(f"获取表数据失败: {table_name} - {e}")
return [], [] return [], []
def insert_or_update_order(self, order_id: str, item_id: str = None, buyer_id: str = None,
spec_name: str = None, spec_value: str = None, quantity: str = None,
amount: str = None, order_status: str = None, cookie_id: str = None):
"""插入或更新订单信息"""
with self.lock:
try:
cursor = self.conn.cursor()
# 检查订单是否已存在
cursor.execute("SELECT order_id FROM orders WHERE order_id = ?", (order_id,))
existing = cursor.fetchone()
if existing:
# 更新现有订单
update_fields = []
update_values = []
if item_id is not None:
update_fields.append("item_id = ?")
update_values.append(item_id)
if buyer_id is not None:
update_fields.append("buyer_id = ?")
update_values.append(buyer_id)
if spec_name is not None:
update_fields.append("spec_name = ?")
update_values.append(spec_name)
if spec_value is not None:
update_fields.append("spec_value = ?")
update_values.append(spec_value)
if quantity is not None:
update_fields.append("quantity = ?")
update_values.append(quantity)
if amount is not None:
update_fields.append("amount = ?")
update_values.append(amount)
if order_status is not None:
update_fields.append("order_status = ?")
update_values.append(order_status)
if cookie_id is not None:
update_fields.append("cookie_id = ?")
update_values.append(cookie_id)
if update_fields:
update_fields.append("updated_at = CURRENT_TIMESTAMP")
update_values.append(order_id)
sql = f"UPDATE orders SET {', '.join(update_fields)} WHERE order_id = ?"
cursor.execute(sql, update_values)
logger.info(f"更新订单信息: {order_id}")
else:
# 插入新订单
cursor.execute('''
INSERT INTO orders (order_id, item_id, buyer_id, spec_name, spec_value,
quantity, amount, order_status, cookie_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (order_id, item_id, buyer_id, spec_name, spec_value,
quantity, amount, order_status or 'unknown', cookie_id))
logger.info(f"插入新订单: {order_id}")
self.conn.commit()
return True
except Exception as e:
logger.error(f"插入或更新订单失败: {order_id} - {e}")
self.conn.rollback()
return False
def get_order_by_id(self, order_id: str):
"""根据订单ID获取订单信息"""
with self.lock:
try:
cursor = self.conn.cursor()
cursor.execute('''
SELECT order_id, item_id, buyer_id, spec_name, spec_value,
quantity, amount, order_status, cookie_id, created_at, updated_at
FROM orders WHERE order_id = ?
''', (order_id,))
row = cursor.fetchone()
if row:
return {
'order_id': row[0],
'item_id': row[1],
'buyer_id': row[2],
'spec_name': row[3],
'spec_value': row[4],
'quantity': row[5],
'amount': row[6],
'order_status': row[7],
'cookie_id': row[8],
'created_at': row[9],
'updated_at': row[10]
}
return None
except Exception as e:
logger.error(f"获取订单信息失败: {order_id} - {e}")
return None
def get_orders_by_cookie(self, cookie_id: str, limit: int = 100):
"""根据Cookie ID获取订单列表"""
with self.lock:
try:
cursor = self.conn.cursor()
cursor.execute('''
SELECT order_id, item_id, buyer_id, spec_name, spec_value,
quantity, amount, order_status, created_at, updated_at
FROM orders WHERE cookie_id = ?
ORDER BY created_at DESC LIMIT ?
''', (cookie_id, limit))
orders = []
for row in cursor.fetchall():
orders.append({
'order_id': row[0],
'item_id': row[1],
'buyer_id': row[2],
'spec_name': row[3],
'spec_value': row[4],
'quantity': row[5],
'amount': row[6],
'order_status': row[7],
'created_at': row[8],
'updated_at': row[9]
})
return orders
except Exception as e:
logger.error(f"获取Cookie订单列表失败: {cookie_id} - {e}")
return []
def delete_table_record(self, table_name: str, record_id: str): def delete_table_record(self, table_name: str, record_id: str):
"""删除指定表的指定记录""" """删除指定表的指定记录"""
with self.lock: with self.lock:
@ -4036,7 +4198,8 @@ class DBManager:
'notification_channels': 'id', 'notification_channels': 'id',
'user_settings': 'id', 'user_settings': 'id',
'email_verifications': 'id', 'email_verifications': 'id',
'captcha_codes': 'id' 'captcha_codes': 'id',
'orders': 'order_id'
} }
primary_key = primary_key_map.get(table_name, 'id') primary_key = primary_key_map.get(table_name, 'id')
@ -4104,6 +4267,198 @@ class DBManager:
except Exception as e: except Exception as e:
logger.error(f"升级keywords表失败: {e}") logger.error(f"升级keywords表失败: {e}")
raise raise
def get_item_replay(self, item_id: str) -> Optional[Dict[str, Any]]:
"""
根据商品ID获取商品回复信息并返回统一格式
Args:
item_id (str): 商品ID
Returns:
Optional[Dict[str, Any]]: 商品回复信息字典统一格式找不到返回 None
"""
try:
with self.lock:
cursor = self.conn.cursor()
cursor.execute('''
SELECT reply_content FROM item_replay
WHERE item_id = ?
''', (item_id,))
row = cursor.fetchone()
if row:
(reply_content,) = row
return {
'reply_content': reply_content or ''
}
return None
except Exception as e:
logger.error(f"获取商品回复失败: {e}")
return None
def get_item_reply(self, cookie_id: str, item_id: str) -> Optional[Dict[str, Any]]:
"""
获取指定账号和商品的回复内容
Args:
cookie_id (str): 账号ID
item_id (str): 商品ID
Returns:
Dict: 包含回复内容的字典如果不存在返回None
"""
try:
with self.lock:
cursor = self.conn.cursor()
cursor.execute('''
SELECT reply_content, created_at, updated_at
FROM item_replay
WHERE cookie_id = ? AND item_id = ?
''', (cookie_id, item_id))
row = cursor.fetchone()
if row:
return {
'reply_content': row[0] or '',
'created_at': row[1],
'updated_at': row[2]
}
return None
except Exception as e:
logger.error(f"获取指定商品回复失败: {e}")
return None
def update_item_reply(self, cookie_id: str, item_id: str, reply_content: str) -> bool:
"""
更新指定cookie和item的回复内容及更新时间
Args:
cookie_id (str): 账号ID
item_id (str): 商品ID
reply_content (str): 回复内容
Returns:
bool: 更新成功返回True失败返回False
"""
try:
with self.lock:
cursor = self.conn.cursor()
cursor.execute('''
UPDATE item_replay
SET reply_content = ?, updated_at = CURRENT_TIMESTAMP
WHERE cookie_id = ? AND item_id = ?
''', (reply_content, cookie_id, item_id))
if cursor.rowcount == 0:
# 如果没更新到,说明该条记录不存在,可以考虑插入
cursor.execute('''
INSERT INTO item_replay (item_id, cookie_id, reply_content, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
''', (item_id, cookie_id, reply_content))
self.conn.commit()
return True
except Exception as e:
logger.error(f"更新商品回复失败: {e}")
return False
def get_itemReplays_by_cookie(self, cookie_id: str) -> List[Dict]:
"""获取指定Cookie的所有商品信息
Args:
cookie_id: Cookie ID
Returns:
List[Dict]: 商品信息列表
"""
try:
with self.lock:
cursor = self.conn.cursor()
cursor.execute('''
SELECT r.item_id, r.cookie_id, r.reply_content, r.created_at, r.updated_at, i.item_title, i.item_detail
FROM item_replay r
LEFT JOIN item_info i ON i.item_id = r.item_id
WHERE r.cookie_id = ?
ORDER BY r.updated_at DESC
''', (cookie_id,))
columns = [description[0] for description in cursor.description]
items = []
for row in cursor.fetchall():
item_info = dict(zip(columns, row))
items.append(item_info)
return items
except Exception as e:
logger.error(f"获取Cookie商品信息失败: {e}")
return []
def delete_item_reply(self, cookie_id: str, item_id: str) -> bool:
"""
删除指定 cookie_id item_id 的商品回复
Args:
cookie_id: Cookie ID
item_id: 商品ID
Returns:
bool: 删除成功返回 True失败返回 False
"""
try:
with self.lock:
cursor = self.conn.cursor()
cursor.execute('''
DELETE FROM item_replay
WHERE cookie_id = ? AND item_id = ?
''', (cookie_id, item_id))
self.conn.commit()
# 判断是否有删除行
return cursor.rowcount > 0
except Exception as e:
logger.error(f"删除商品回复失败: {e}")
return False
def batch_delete_item_replies(self, items: List[Dict[str, str]]) -> Dict[str, int]:
"""
批量删除商品回复
Args:
items: List[Dict] 每个字典包含 cookie_id item_id
Returns:
Dict[str, int]: 返回成功和失败的数量例如 {"success_count": 3, "failed_count": 1}
"""
success_count = 0
failed_count = 0
try:
with self.lock:
cursor = self.conn.cursor()
for item in items:
cookie_id = item.get('cookie_id')
item_id = item.get('item_id')
if not cookie_id or not item_id:
failed_count += 1
continue
cursor.execute('''
DELETE FROM item_replay
WHERE cookie_id = ? AND item_id = ?
''', (cookie_id, item_id))
if cursor.rowcount > 0:
success_count += 1
else:
failed_count += 1
self.conn.commit()
except Exception as e:
logger.error(f"批量删除商品回复失败: {e}")
# 整体失败则视为全部失败
return {"success_count": 0, "failed_count": len(items)}
return {"success_count": success_count, "failed_count": failed_count}
# 全局单例 # 全局单例

View File

@ -477,6 +477,9 @@ async def data_management_page():
return HTMLResponse('<h3>Data management page not found</h3>') return HTMLResponse('<h3>Data management page not found</h3>')
# 商品搜索页面路由 # 商品搜索页面路由
@app.get('/item_search.html', response_class=HTMLResponse) @app.get('/item_search.html', response_class=HTMLResponse)
async def item_search_page(): async def item_search_page():
@ -1893,8 +1896,8 @@ def update_keywords_with_item_id(cid: str, body: KeywordWithItemIdIn, current_us
reply = kw_data.get('reply', '').strip() reply = kw_data.get('reply', '').strip()
item_id = kw_data.get('item_id', '').strip() or None item_id = kw_data.get('item_id', '').strip() or None
if not keyword or not reply: if not keyword:
raise HTTPException(status_code=400, detail="关键词和回复内容不能为空") raise HTTPException(status_code=400, detail="关键词不能为空")
# 检查当前提交的关键词中是否有重复 # 检查当前提交的关键词中是否有重复
keyword_key = f"{keyword}|{item_id or ''}" keyword_key = f"{keyword}|{item_id or ''}"
@ -2105,8 +2108,8 @@ async def import_keywords(cid: str, file: UploadFile = File(...), current_user:
item_id = str(row['商品ID']).strip() if pd.notna(row['商品ID']) and str(row['商品ID']).strip() else None item_id = str(row['商品ID']).strip() if pd.notna(row['商品ID']) and str(row['商品ID']).strip() else None
reply = str(row['关键词内容']).strip() reply = str(row['关键词内容']).strip()
if not keyword or not reply: if not keyword:
continue # 跳过 continue # 跳过没有关键词的
# 检查是否重复 # 检查是否重复
key = f"{keyword}|{item_id or ''}" key = f"{keyword}|{item_id or ''}"
@ -3455,6 +3458,156 @@ def get_system_stats(admin_user: Dict[str, Any] = Depends(require_admin)):
log_with_user('error', f"获取系统统计信息失败: {str(e)}", admin_user) log_with_user('error', f"获取系统统计信息失败: {str(e)}", admin_user)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# ------------------------- 指定商品回复接口 -------------------------
@app.get("/itemReplays")
def get_all_items(current_user: Dict[str, Any] = Depends(get_current_user)):
"""获取当前用户的所有商品回复信息"""
try:
# 只返回当前用户的商品信息
user_id = current_user['user_id']
from db_manager import db_manager
user_cookies = db_manager.get_all_cookies(user_id)
all_items = []
for cookie_id in user_cookies.keys():
items = db_manager.get_itemReplays_by_cookie(cookie_id)
all_items.extend(items)
return {"items": all_items}
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取商品回复信息失败: {str(e)}")
@app.get("/itemReplays/cookie/{cookie_id}")
def get_items_by_cookie(cookie_id: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""获取指定Cookie的商品信息"""
try:
# 检查cookie是否属于当前用户
user_id = current_user['user_id']
from db_manager import db_manager
user_cookies = db_manager.get_all_cookies(user_id)
if cookie_id not in user_cookies:
raise HTTPException(status_code=403, detail="无权限访问该Cookie")
items = db_manager.get_itemReplays_by_cookie(cookie_id)
return {"items": items}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取商品信息失败: {str(e)}")
@app.put("/item-reply/{cookie_id}/{item_id}")
def update_item_reply(
cookie_id: str,
item_id: str,
data: dict,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
更新指定账号和商品的回复内容
"""
try:
user_id = current_user['user_id']
from db_manager import db_manager
# 验证cookie是否属于用户
user_cookies = db_manager.get_all_cookies(user_id)
if cookie_id not in user_cookies:
raise HTTPException(status_code=403, detail="无权限访问该Cookie")
reply_content = data.get("reply_content", "").strip()
if not reply_content:
raise HTTPException(status_code=400, detail="回复内容不能为空")
db_manager.update_item_reply(cookie_id=cookie_id, item_id=item_id, reply_content=reply_content)
return {"message": "商品回复更新成功"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"更新商品回复失败: {str(e)}")
@app.delete("/item-reply/{cookie_id}/{item_id}")
def delete_item_reply(cookie_id: str, item_id: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""
删除指定账号cookie_id和商品item_id的商品回复
"""
try:
user_id = current_user['user_id']
user_cookies = db_manager.get_all_cookies(user_id)
if cookie_id not in user_cookies:
raise HTTPException(status_code=403, detail="无权限访问该Cookie")
success = db_manager.delete_item_reply(cookie_id, item_id)
if not success:
raise HTTPException(status_code=404, detail="商品回复不存在")
return {"message": "商品回复删除成功"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除商品回复失败: {str(e)}")
class ItemToDelete(BaseModel):
cookie_id: str
item_id: str
class BatchDeleteRequest(BaseModel):
items: List[ItemToDelete]
@app.delete("/item-reply/batch")
async def batch_delete_item_reply(
req: BatchDeleteRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
批量删除商品回复
"""
user_id = current_user['user_id']
from db_manager import db_manager
# 先校验当前用户是否有权限删除每个cookie对应的回复
user_cookies = db_manager.get_all_cookies(user_id)
for item in req.items:
if item.cookie_id not in user_cookies:
raise HTTPException(status_code=403, detail=f"无权限访问Cookie {item.cookie_id}")
result = db_manager.batch_delete_item_replies([item.dict() for item in req.items])
return {
"success_count": result["success_count"],
"failed_count": result["failed_count"]
}
@app.get("/item-reply/{cookie_id}/{item_id}")
def get_item_reply(cookie_id: str, item_id: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""
获取指定账号cookie_id和商品item_id的商品回复内容
"""
try:
user_id = current_user['user_id']
# 校验cookie_id是否属于当前用户
user_cookies = db_manager.get_all_cookies(user_id)
if cookie_id not in user_cookies:
raise HTTPException(status_code=403, detail="无权限访问该Cookie")
# 获取指定商品回复
item_replies = db_manager.get_itemReplays_by_cookie(cookie_id)
# 找对应item_id的回复
item_reply = next((r for r in item_replies if r['item_id'] == item_id), None)
if item_reply is None:
raise HTTPException(status_code=404, detail="商品回复不存在")
return item_reply
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取商品回复失败: {str(e)}")
# ------------------------- 数据库备份和恢复接口 ------------------------- # ------------------------- 数据库备份和恢复接口 -------------------------
@ -3661,7 +3814,7 @@ def get_table_data(table_name: str, admin_user: Dict[str, Any] = Depends(require
'users', 'cookies', 'cookie_status', 'keywords', 'default_replies', 'default_reply_records', 'users', 'cookies', 'cookie_status', 'keywords', 'default_replies', 'default_reply_records',
'ai_reply_settings', 'ai_conversations', 'ai_item_cache', 'item_info', 'ai_reply_settings', 'ai_conversations', 'ai_item_cache', 'item_info',
'message_notifications', 'cards', 'delivery_rules', 'notification_channels', 'message_notifications', 'cards', 'delivery_rules', 'notification_channels',
'user_settings', 'system_settings', 'email_verifications', 'captcha_codes' 'user_settings', 'system_settings', 'email_verifications', 'captcha_codes', 'orders', "item_replay"
] ]
if table_name not in allowed_tables: if table_name not in allowed_tables:
@ -3698,7 +3851,7 @@ def delete_table_record(table_name: str, record_id: str, admin_user: Dict[str, A
'users', 'cookies', 'cookie_status', 'keywords', 'default_replies', 'default_reply_records', 'users', 'cookies', 'cookie_status', 'keywords', 'default_replies', 'default_reply_records',
'ai_reply_settings', 'ai_conversations', 'ai_item_cache', 'item_info', 'ai_reply_settings', 'ai_conversations', 'ai_item_cache', 'item_info',
'message_notifications', 'cards', 'delivery_rules', 'notification_channels', 'message_notifications', 'cards', 'delivery_rules', 'notification_channels',
'user_settings', 'system_settings', 'email_verifications', 'captcha_codes' 'user_settings', 'system_settings', 'email_verifications', 'captcha_codes', 'orders'
] ]
if table_name not in allowed_tables: if table_name not in allowed_tables:
@ -3738,7 +3891,7 @@ def clear_table_data(table_name: str, admin_user: Dict[str, Any] = Depends(requi
'cookies', 'cookie_status', 'keywords', 'default_replies', 'default_reply_records', 'cookies', 'cookie_status', 'keywords', 'default_replies', 'default_reply_records',
'ai_reply_settings', 'ai_conversations', 'ai_item_cache', 'item_info', 'ai_reply_settings', 'ai_conversations', 'ai_item_cache', 'item_info',
'message_notifications', 'cards', 'delivery_rules', 'notification_channels', 'message_notifications', 'cards', 'delivery_rules', 'notification_channels',
'user_settings', 'system_settings', 'email_verifications', 'captcha_codes' 'user_settings', 'system_settings', 'email_verifications', 'captcha_codes', 'orders', "item_replay"
] ]
# 不允许清空用户表 # 不允许清空用户表

View File

@ -57,10 +57,14 @@ openpyxl>=3.1.0
# ==================== 邮件发送 ==================== # ==================== 邮件发送 ====================
email-validator>=2.0.0 email-validator>=2.0.0
# ==================== 数据处理和验证 ====================
xlsxwriter>=3.1.0
# ==================== 说明 ==================== # ==================== 说明 ====================
# 以下模块是Python内置模块无需安装 # 以下模块是Python内置模块无需安装
# sqlite3, json, base64, hashlib, hmac, time, datetime, os, sys, re, urllib # sqlite3, json, base64, hashlib, hmac, time, datetime, os, sys, re, urllib
# asyncio, threading, pathlib, uuid, random, secrets, traceback, logging # asyncio, threading, pathlib, uuid, random, secrets, traceback, logging
# collections, itertools, functools, copy, pickle, gzip, zipfile, shutil # collections, itertools, functools, copy, pickle, gzip, zipfile, shutil
# tempfile, io, csv, xml, html, http, socket, ssl, subprocess, signal # tempfile, io, csv, xml, html, http, socket, ssl, subprocess, signal
# inspect, ast, enum, math, decimal, array, queue, contextlib, warnings # inspect, ast, enum, math, decimal, array, queue, contextlib, warnings
# typing, dataclasses, weakref, gc, platform, stat, glob, fnmatch

345
secure_confirm_decrypted.py Normal file
View File

@ -0,0 +1,345 @@
"""
自动确认发货模块 - 解密版本
这是secure_confirm_ultra.py的解密版本用于自动确认发货功能
"""
import asyncio
import json
import time
import aiohttp
from loguru import logger
from utils.xianyu_utils import generate_sign, trans_cookies
class SecureConfirm:
"""自动确认发货类"""
def __init__(self, session, cookies_str, cookie_id, main_instance=None):
"""
初始化确认发货实例
Args:
session: aiohttp会话对象
cookies_str: Cookie字符串
cookie_id: Cookie ID
main_instance: 主实例对象XianyuLive
"""
self.session = session
self.cookies_str = cookies_str
self.cookie_id = cookie_id
self.main_instance = main_instance
# 解析cookies
self.cookies = trans_cookies(cookies_str) if cookies_str else {}
# Token相关属性
self.current_token = None
self.last_token_refresh_time = 0
self.token_refresh_interval = 3600 # 1小时
def _safe_str(self, obj):
"""安全字符串转换"""
try:
return str(obj)
except:
return "无法转换的对象"
async def _get_real_item_id(self):
"""从数据库中获取一个真实的商品ID"""
try:
from db_manager import db_manager
# 获取该账号的商品列表
items = db_manager.get_items_by_cookie(self.cookie_id)
if items:
# 返回第一个商品的ID
item_id = items[0].get('item_id')
if item_id:
logger.debug(f"{self.cookie_id}】获取到真实商品ID: {item_id}")
return item_id
# 如果该账号没有商品尝试获取任意一个商品ID
all_items = db_manager.get_all_items()
if all_items:
item_id = all_items[0].get('item_id')
if item_id:
logger.debug(f"{self.cookie_id}】使用其他账号的商品ID: {item_id}")
return item_id
logger.warning(f"{self.cookie_id}】数据库中没有找到任何商品ID")
return None
except Exception as e:
logger.error(f"{self.cookie_id}】获取真实商品ID失败: {self._safe_str(e)}")
return None
async def refresh_token_by_detail_api(self, retry_count=0):
"""通过商品详情API刷新token - 参照get_item_info方法"""
if retry_count >= 3: # 最多重试2次
logger.error(f"{self.cookie_id}】通过详情API刷新token失败重试次数过多")
return False
try:
# 优先使用传入的item_id否则从数据库获取
real_item_id = None
if hasattr(self, '_current_item_id') and self._current_item_id:
real_item_id = self._current_item_id
logger.debug(f"{self.cookie_id}】使用传入的商品ID: {real_item_id}")
else:
# 从数据库中获取一个真实的商品ID来请求详情API
real_item_id = await self._get_real_item_id()
if not real_item_id:
logger.warning(f"{self.cookie_id}】无法获取真实商品ID使用默认ID")
real_item_id = "123456789" # 备用ID
params = {
'jsv': '2.7.2',
'appKey': '34839810',
't': str(int(time.time()) * 1000),
'sign': '',
'v': '1.0',
'type': 'originaljson',
'accountSite': 'xianyu',
'dataType': 'json',
'timeout': '20000',
'api': 'mtop.taobao.idle.pc.detail',
'sessionOption': 'AutoLoginOnly',
}
data_val = f'{{"itemId":"{real_item_id}"}}'
data = {
'data': data_val,
}
# 从当前cookies中获取token
token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
if token:
logger.debug(f"{self.cookie_id}】使用当前token刷新: {token}")
else:
logger.warning(f"{self.cookie_id}】当前cookies中没有找到token")
# 生成签名
sign = generate_sign(params['t'], token, data_val)
params['sign'] = sign
logger.info(f"{self.cookie_id}】通过详情API刷新token使用商品ID: {real_item_id}")
async with self.session.post(
'https://h5api.m.goofish.com/h5/mtop.taobao.idle.pc.detail/1.0/',
params=params,
data=data
) as response:
# 检查并更新Cookie
if 'set-cookie' in response.headers:
new_cookies = {}
for cookie in response.headers.getall('set-cookie', []):
if '=' in cookie:
name, value = cookie.split(';')[0].split('=', 1)
new_cookies[name.strip()] = value.strip()
# 更新cookies
if new_cookies:
self.cookies.update(new_cookies)
# 生成新的cookie字符串
self.cookies_str = '; '.join([f"{k}={v}" for k, v in self.cookies.items()])
# 更新数据库中的Cookie
await self._update_config_cookies()
logger.debug(f"{self.cookie_id}】已通过详情API更新Cookie到数据库")
# 获取新的token
new_token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
if new_token and new_token != token:
self.current_token = new_token
self.last_token_refresh_time = time.time()
logger.info(f"{self.cookie_id}】通过详情API成功刷新token: {new_token}")
return True
else:
logger.debug(f"{self.cookie_id}】详情API返回的token未变化")
# 检查响应状态
try:
res_json = await response.json()
if isinstance(res_json, dict):
ret_value = res_json.get('ret', [])
if any('SUCCESS::调用成功' in ret for ret in ret_value):
logger.debug(f"{self.cookie_id}】详情API调用成功")
return True
else:
logger.warning(f"{self.cookie_id}】详情API调用失败: {ret_value}")
if retry_count < 2:
await asyncio.sleep(0.5)
return await self.refresh_token_by_detail_api(retry_count + 1)
except:
logger.debug(f"{self.cookie_id}】详情API响应解析失败但可能已获取到新cookies")
return bool(self.current_token)
except Exception as e:
logger.error(f"{self.cookie_id}】通过详情API刷新token异常: {self._safe_str(e)}")
if retry_count < 2:
await asyncio.sleep(0.5)
return await self.refresh_token_by_detail_api(retry_count + 1)
return False
async def _update_config_cookies(self):
"""更新数据库中的Cookie配置"""
try:
from db_manager import db_manager
# 更新数据库中的cookies
db_manager.update_cookie_value(self.cookie_id, self.cookies_str)
logger.debug(f"{self.cookie_id}】已更新数据库中的Cookie")
except Exception as e:
logger.error(f"{self.cookie_id}】更新数据库Cookie失败: {self._safe_str(e)}")
async def refresh_token(self):
"""刷新token - 优先使用详情API失败时调用主界面类的方法"""
# 首先尝试通过详情API刷新token
success = await self.refresh_token_by_detail_api()
if success:
return self.current_token
# 如果详情API失败尝试调用主界面类的方法
if self.main_instance and hasattr(self.main_instance, 'refresh_token'):
try:
logger.debug(f"{self.cookie_id}】详情API刷新失败调用主界面类的refresh_token方法")
new_token = await self.main_instance.refresh_token()
if new_token:
self.current_token = new_token
self.last_token_refresh_time = time.time()
# 更新本地的cookies_str
self.cookies_str = self.main_instance.cookies_str
# 重新解析cookies
self.cookies = {}
if self.cookies_str:
for cookie in self.cookies_str.split(';'):
if '=' in cookie:
key, value = cookie.strip().split('=', 1)
self.cookies[key] = value
logger.debug(f"{self.cookie_id}】通过主界面类Token刷新成功已同步cookies")
return new_token
else:
logger.warning(f"{self.cookie_id}】主界面类Token刷新失败")
return None
except Exception as e:
logger.error(f"{self.cookie_id}】调用主界面类refresh_token失败: {self._safe_str(e)}")
return None
else:
logger.warning(f"{self.cookie_id}】主界面类实例不存在或没有refresh_token方法")
return None
async def auto_confirm(self, order_id, item_id=None, retry_count=0):
"""自动确认发货 - 使用真实商品ID刷新token"""
if retry_count >= 4: # 最多重试3次
logger.error("自动确认发货失败,重试次数过多")
return {"error": "自动确认发货失败,重试次数过多"}
# 保存item_id供Token刷新使用
if item_id:
self._current_item_id = item_id
logger.debug(f"{self.cookie_id}】设置当前商品ID: {item_id}")
# 如果是重试retry_count > 0强制刷新token
if retry_count > 0:
old_token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
logger.info(f"{self.cookie_id}】重试第{retry_count}强制刷新token... 当前_m_h5_tk: {old_token}")
await self.refresh_token()
new_token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
logger.info(f"{self.cookie_id}】重试刷新token完成新的_m_h5_tk: {new_token}")
else:
# 确保使用最新的token首次调用时的正常逻辑
if not self.current_token or (time.time() - self.last_token_refresh_time) >= self.token_refresh_interval:
old_token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
logger.info(f"{self.cookie_id}】Token过期或不存在刷新token... 当前_m_h5_tk: {old_token}")
await self.refresh_token()
new_token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
logger.info(f"{self.cookie_id}】Token刷新完成新的_m_h5_tk: {new_token}")
# 确保session已创建
if not self.session:
raise Exception("Session未创建")
params = {
'jsv': '2.7.2',
'appKey': '34839810',
't': str(int(time.time()) * 1000),
'sign': '',
'v': '1.0',
'type': 'originaljson',
'accountSite': 'xianyu',
'dataType': 'json',
'timeout': '20000',
'api': 'mtop.taobao.idle.logistic.consign.dummy',
'sessionOption': 'AutoLoginOnly',
}
data_val = '{"orderId":"' + order_id + '", "tradeText":"","picList":[],"newUnconsign":true}'
data = {
'data': data_val,
}
# 始终从最新的cookies中获取_m_h5_tk token刷新后cookies会被更新
token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
if token:
logger.info(f"使用cookies中的_m_h5_tk token: {token}")
else:
logger.warning("cookies中没有找到_m_h5_tk token")
sign = generate_sign(params['t'], token, data_val)
params['sign'] = sign
try:
logger.info(f"{self.cookie_id}】开始自动确认发货订单ID: {order_id}")
async with self.session.post(
'https://h5api.m.goofish.com/h5/mtop.taobao.idle.logistic.consign.dummy/1.0/',
params=params,
data=data
) as response:
res_json = await response.json()
# 检查并更新Cookie
if 'set-cookie' in response.headers:
new_cookies = {}
for cookie in response.headers.getall('set-cookie', []):
if '=' in cookie:
name, value = cookie.split(';')[0].split('=', 1)
new_cookies[name.strip()] = value.strip()
# 更新cookies
if new_cookies:
self.cookies.update(new_cookies)
# 生成新的cookie字符串
self.cookies_str = '; '.join([f"{k}={v}" for k, v in self.cookies.items()])
# 更新数据库中的Cookie
await self._update_config_cookies()
logger.debug("已更新Cookie到数据库")
logger.info(f"{self.cookie_id}】自动确认发货响应: {res_json}")
# 检查响应结果
if res_json.get('ret') and res_json['ret'][0] == 'SUCCESS::调用成功':
logger.info(f"{self.cookie_id}】✅ 自动确认发货成功订单ID: {order_id}")
return {"success": True, "order_id": order_id}
else:
error_msg = res_json.get('ret', ['未知错误'])[0] if res_json.get('ret') else '未知错误'
logger.warning(f"{self.cookie_id}】❌ 自动确认发货失败: {error_msg}")
# 如果是token相关错误进行重试
if 'token' in error_msg.lower() or 'sign' in error_msg.lower():
logger.info(f"{self.cookie_id}】检测到token错误准备重试...")
return await self.auto_confirm(order_id, item_id, retry_count + 1)
return {"error": error_msg, "order_id": order_id}
except Exception as e:
logger.error(f"{self.cookie_id}】自动确认发货API请求异常: {self._safe_str(e)}")
await asyncio.sleep(0.5)
# 网络异常也进行重试
if retry_count < 2:
logger.info(f"{self.cookie_id}】网络异常,准备重试...")
return await self.auto_confirm(order_id, item_id, retry_count + 1)
return {"error": f"网络异常: {self._safe_str(e)}", "order_id": order_id}

View File

@ -49,6 +49,11 @@
color: var(--warning-color); color: var(--warning-color);
} }
.stat-icon.info {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.stat-number { .stat-number {
font-size: 2rem; font-size: 2rem;
font-weight: 700; font-weight: 700;

View File

@ -98,6 +98,7 @@
<option value="cookie_status">cookie_status - Cookie状态表</option> <option value="cookie_status">cookie_status - Cookie状态表</option>
<option value="keywords">keywords - 关键字表</option> <option value="keywords">keywords - 关键字表</option>
<option value="default_replies">default_replies - 默认回复表</option> <option value="default_replies">default_replies - 默认回复表</option>
<option value="item_replay">item_replay - 指定商品回复表</option>
<option value="default_reply_records">default_reply_records - 默认回复记录表</option> <option value="default_reply_records">default_reply_records - 默认回复记录表</option>
<option value="ai_reply_settings">ai_reply_settings - AI回复设置表</option> <option value="ai_reply_settings">ai_reply_settings - AI回复设置表</option>
<option value="ai_conversations">ai_conversations - AI对话历史表</option> <option value="ai_conversations">ai_conversations - AI对话历史表</option>
@ -111,6 +112,7 @@
<option value="system_settings">system_settings - 系统设置表</option> <option value="system_settings">system_settings - 系统设置表</option>
<option value="email_verifications">email_verifications - 邮箱验证表</option> <option value="email_verifications">email_verifications - 邮箱验证表</option>
<option value="captcha_codes">captcha_codes - 验证码表</option> <option value="captcha_codes">captcha_codes - 验证码表</option>
<option value="orders">orders - 订单表</option>
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
@ -262,6 +264,7 @@
'cookies': 'Cookie账号表', 'cookies': 'Cookie账号表',
'cookie_status': 'Cookie状态表', 'cookie_status': 'Cookie状态表',
'keywords': '关键字表', 'keywords': '关键字表',
'item_replay': '指定商品回复表',
'default_replies': '默认回复表', 'default_replies': '默认回复表',
'default_reply_records': '默认回复记录表', 'default_reply_records': '默认回复记录表',
'ai_reply_settings': 'AI回复设置表', 'ai_reply_settings': 'AI回复设置表',
@ -275,7 +278,8 @@
'user_settings': '用户设置表', 'user_settings': '用户设置表',
'system_settings': '系统设置表', 'system_settings': '系统设置表',
'email_verifications': '邮箱验证表', 'email_verifications': '邮箱验证表',
'captcha_codes': '验证码表' 'captcha_codes': '验证码表',
'orders': '订单表'
}; };
// 页面加载完成后初始化 // 页面加载完成后初始化

View File

@ -42,12 +42,24 @@
商品管理 商品管理
</a> </a>
</div> </div>
<div class="nav-item">
<a href="#" class="nav-link" onclick="showSection('orders')">
<i class="bi bi-receipt-cutoff"></i>
订单管理
</a>
</div>
<div class="nav-item"> <div class="nav-item">
<a href="#" class="nav-link" onclick="showSection('auto-reply')"> <a href="#" class="nav-link" onclick="showSection('auto-reply')">
<i class="bi bi-chat-left-text"></i> <i class="bi bi-chat-left-text"></i>
自动回复 自动回复
</a> </a>
</div> </div>
<div class="nav-item">
<a href="#" class="nav-link" onclick="showSection('items-reply')">
<i class="bi bi-chat-left-text"></i>
指定商品回复
</a>
</div>
<div class="nav-item"> <div class="nav-item">
<a href="#" class="nav-link" onclick="showSection('cards')"> <a href="#" class="nav-link" onclick="showSection('cards')">
<i class="bi bi-credit-card"></i> <i class="bi bi-credit-card"></i>
@ -164,6 +176,13 @@
<div class="stat-number" id="activeAccounts">0</div> <div class="stat-number" id="activeAccounts">0</div>
<div class="stat-label">启用账号数</div> <div class="stat-label">启用账号数</div>
</div> </div>
<div class="stat-card">
<div class="stat-icon info">
<i class="bi bi-receipt-cutoff"></i>
</div>
<div class="stat-number" id="totalOrders">0</div>
<div class="stat-label">总订单数</div>
</div>
</div> </div>
<!-- 账号详情列表 --> <!-- 账号详情列表 -->
@ -384,6 +403,7 @@
<th style="width: 12%">商品ID</th> <th style="width: 12%">商品ID</th>
<th style="width: 18%">商品标题</th> <th style="width: 18%">商品标题</th>
<th style="width: 20%">商品详情</th> <th style="width: 20%">商品详情</th>
<th style="width: 20%">商品价格</th>
<th style="width: 8%">多规格</th> <th style="width: 8%">多规格</th>
<th style="width: 10%">更新时间</th> <th style="width: 10%">更新时间</th>
<th style="width: 15%">操作</th> <th style="width: 15%">操作</th>
@ -443,6 +463,271 @@
</div> </div>
</div> </div>
<!-- 指定商品自动管理内容 -->
<div id="items-reply-section" class="content-section">
<div class="content-header">
<h2 class="mb-0">
<i class="bi bi-box-seam me-2"></i>
商品回复管理
</h2>
<p class="text-muted mb-0">管理各账号的商品信息</p>
</div>
<div class="content-body">
<!-- Cookie筛选 -->
<div class="card mb-4">
<div class="card-body">
<div class="row align-items-end">
<div class="col-md-6">
<label for="itemCookieFilter" class="form-label">筛选账号</label>
<select class="form-select" id="itemReplayCookieFilter" onchange="loadItemsReplayByCookie()">
<option value="">所有账号</option>
</select>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-end align-items-end gap-2">
<!-- 页码输入 -->
<div class="d-flex align-items-center gap-2">
<label for="pageNumber" class="form-label mb-0 text-nowrap">页码:</label>
<input type="number" class="form-control" id="pageNumber" placeholder="页码" min="1" value="1" style="width: 80px;">
</div>
<div class="d-flex gap-2">
<button class="btn btn-success" onclick="showItemReplayEdit()">
添加商品回复
</button>
<button class="btn btn-primary" onclick="refreshItemReplayS()">
<i class="bi bi-arrow-clockwise me-1"></i>刷新
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 商品列表 -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">商品列表(自动发货根据商品标题和商品详情匹配关键字)</h5>
<button class="btn btn-sm btn-outline-danger" onclick="batchDeleteItemReplies()">
<i class="bi bi-trash"></i> 批量删除
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 5%">
<input type="checkbox" id="selectAllItemReplay" onchange="toggleSelectAll(this)">
</th>
<th style="width: 12%">账号ID</th>
<th style="width: 12%">商品ID</th>
<th style="width: 18%">商品标题</th>
<th style="width: 20%">商品内容</th>
<th style="width: 18%">回复内容</th>
<th style="width: 10%">更新时间</th>
<th style="width: 15%">操作</th>
</tr>
</thead>
<tbody id="itemReplaysTableBody">
<tr>
<td colspan="5" class="text-center text-muted">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 商品回复弹框 -->
<!-- 添加/编辑商品回复模态框 -->
<div class="modal fade" id="editItemReplyModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-chat-text me-2"></i><span id="itemReplayTitle">编辑商品回复</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editItemReplyForm">
<input type="hidden" id="editReplyCookieId">
<input type="hidden" id="editReplyItemId">
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">账号ID<span class="text-danger">*</span></label>
<select id="editReplyCookieIdSelect" class="form-select" onchange="onCookieChangeForReply()">
<option value="">请选择账号</option>
<!-- JS 动态填充账号选项 -->
</select>
</div>
<div class="col-md-6">
<label class="form-label">商品ID<span class="text-danger">*</span></label>
<select id="editReplyItemIdSelect" class="form-select">
<option value="">选择商品</option>
<!-- JS 动态填充商品选项 -->
</select>
</div>
</div>
<div class="mb-3">
<label for="editReplyContent" class="form-label">商品回复内容 <span class="text-danger">*</span></label>
<textarea class="form-control" id="editItemReplyContent" rows="10"
placeholder="请输入商品回复内容..."></textarea>
<div class="form-text">
<i class="bi bi-info-circle me-1"></i>
请输入商品的自动回复内容,用户购买后将收到该回复。
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveItemReply()">
<i class="bi bi-check-circle me-1"></i>保存
</button>
</div>
</div>
</div>
</div>
<!-- 订单管理内容 -->
<div id="orders-section" class="content-section">
<div class="content-header">
<h2 class="mb-0">
<i class="bi bi-receipt-cutoff me-2"></i>
订单管理
</h2>
<p class="text-muted mb-0">查看和管理所有订单信息</p>
</div>
<div class="content-body">
<!-- Cookie筛选器 -->
<div class="row mb-3">
<div class="col-md-4">
<label class="form-label">筛选账号</label>
<select class="form-select" id="orderCookieFilter" onchange="loadOrdersByCookie()">
<option value="">所有账号</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">订单状态</label>
<select class="form-select" id="orderStatusFilter" onchange="filterOrders()">
<option value="">所有状态</option>
<option value="processing">处理中</option>
<option value="processed">已处理</option>
<option value="completed">已完成</option>
<option value="unknown">未知</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">&nbsp;</label>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" onclick="refreshOrders()">
<i class="bi bi-arrow-clockwise"></i> 刷新
</button>
<button class="btn btn-outline-secondary" onclick="clearOrderFilters()">
<i class="bi bi-x-circle"></i> 清空筛选
</button>
</div>
</div>
</div>
<!-- 订单列表 -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<input type="text" class="form-control" id="orderSearchInput"
placeholder="搜索订单ID或商品ID..." style="width: 300px;">
<h5 class="mb-0">订单列表</h5>
</div>
<button class="btn btn-sm btn-outline-danger" onclick="batchDeleteOrders()" id="batchDeleteOrdersBtn" disabled>
<i class="bi bi-trash"></i> 批量删除
</button>
</div>
<div class="card-body">
<!-- 搜索统计信息 -->
<div id="orderSearchStats" class="text-muted small mb-2" style="display: none;">
<i class="bi bi-search me-1"></i>
<span id="orderSearchStatsText"></span>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 5%">
<input type="checkbox" id="selectAllOrders" onchange="toggleSelectAllOrders(this)">
</th>
<th style="width: 15%">订单ID</th>
<th style="width: 12%">商品ID</th>
<th style="width: 10%">买家ID</th>
<th style="width: 12%">规格信息</th>
<th style="width: 8%">数量</th>
<th style="width: 10%">金额</th>
<th style="width: 8%">状态</th>
<th style="width: 10%">账号ID</th>
<th style="width: 10%">操作</th>
</tr>
</thead>
<tbody id="ordersTableBody">
<tr>
<td colspan="10" class="text-center text-muted">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分页控件 -->
<div class="card-footer" id="ordersPagination">
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted small">
<span id="ordersPageInfo">显示第 1-10 条,共 0 条记录</span>
</div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm" id="ordersFirstPage" onclick="goToOrdersPage(1)" disabled>
<i class="bi bi-chevron-double-left"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="ordersPrevPage" onclick="goToOrdersPage(currentOrdersPage - 1)" disabled>
<i class="bi bi-chevron-left"></i>
</button>
</div>
<div class="d-flex align-items-center gap-2 mx-2">
<span class="text-muted small"></span>
<input type="number" class="form-control form-control-sm" id="ordersPageInput"
style="width: 60px;" min="1" value="1">
<span class="text-muted small">页,共 <span id="ordersTotalPages">0</span></span>
</div>
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm" id="ordersNextPage" onclick="goToOrdersPage(currentOrdersPage + 1)" disabled>
<i class="bi bi-chevron-right"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="ordersLastPage" onclick="goToOrdersPage(totalOrdersPages)" disabled>
<i class="bi bi-chevron-double-right"></i>
</button>
</div>
<select class="form-select form-select-sm ms-2" id="ordersPageSize" style="width: auto;">
<option value="10">10条/页</option>
<option value="20" selected>20条/页</option>
<option value="50">50条/页</option>
<option value="100">100条/页</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 自动回复内容 --> <!-- 自动回复内容 -->
<div id="auto-reply-section" class="content-section"> <div id="auto-reply-section" class="content-section">
<div class="content-header"> <div class="content-header">
@ -511,8 +796,8 @@
<input type="text" id="newKeyword" placeholder="例如:你好"> <input type="text" id="newKeyword" placeholder="例如:你好">
</div> </div>
<div class="input-field"> <div class="input-field">
<label>自动回复内容</label> <label>自动回复内容(可选)</label>
<input type="text" id="newReply" placeholder="例如:您好,欢迎咨询!有什么可以帮助您的吗?"> <input type="text" id="newReply" placeholder="例如:您好,欢迎咨询!留空表示不回复">
</div> </div>
<div class="input-field"> <div class="input-field">
<label>商品ID可选</label> <label>商品ID可选</label>
@ -535,7 +820,9 @@
<strong>支持变量:</strong> <strong>支持变量:</strong>
<code>{send_user_name}</code> 用户昵称, <code>{send_user_name}</code> 用户昵称,
<code>{send_user_id}</code> 用户ID <code>{send_user_id}</code> 用户ID
<code>{send_message}</code> 用户消息 <code>{send_message}</code> 用户消息<br>
<i class="bi bi-info-circle me-1"></i>
<strong>提示:</strong>回复内容留空时,匹配到关键词但不会自动回复,可用于屏蔽特定消息
</small> </small>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,10 @@ import os
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from playwright.async_api import async_playwright, Browser, BrowserContext, Page from playwright.async_api import async_playwright, Browser, BrowserContext, Page
from loguru import logger from loguru import logger
import re
import json
from threading import Lock
from collections import defaultdict
# 修复Docker环境中的asyncio事件循环策略问题 # 修复Docker环境中的asyncio事件循环策略问题
if sys.platform.startswith('linux') or os.getenv('DOCKER_ENV'): if sys.platform.startswith('linux') or os.getenv('DOCKER_ENV'):
@ -33,10 +37,14 @@ if os.getenv('DOCKER_ENV'):
class OrderDetailFetcher: class OrderDetailFetcher:
"""闲鱼订单详情获取器""" """闲鱼订单详情获取器"""
def __init__(self, cookie_string: str = None): # 类级别的锁字典为每个order_id维护一个锁
_order_locks = defaultdict(lambda: asyncio.Lock())
def __init__(self, cookie_string: str = None, headless: bool = True):
self.browser: Optional[Browser] = None self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None self.context: Optional[BrowserContext] = None
self.page: Optional[Page] = None self.page: Optional[Page] = None
self.headless = headless # 保存headless设置
# 请求头配置 # 请求头配置
self.headers = { self.headers = {
@ -58,11 +66,17 @@ class OrderDetailFetcher:
# Cookie配置 - 支持动态传入 # Cookie配置 - 支持动态传入
self.cookie = cookie_string self.cookie = cookie_string
async def init_browser(self, headless: bool = True): async def init_browser(self, headless: bool = None):
"""初始化浏览器""" """初始化浏览器"""
try: try:
# 如果没有传入headless参数使用实例的设置
if headless is None:
headless = self.headless
logger.info(f"开始初始化浏览器headless模式: {headless}")
playwright = await async_playwright().start() playwright = await async_playwright().start()
# 启动浏览器Docker环境优化 # 启动浏览器Docker环境优化
browser_args = [ browser_args = [
'--no-sandbox', '--no-sandbox',
@ -84,10 +98,13 @@ class OrderDetailFetcher:
'--hide-scrollbars', '--hide-scrollbars',
'--mute-audio', '--mute-audio',
'--no-default-browser-check', '--no-default-browser-check',
'--no-pings', '--no-pings'
'--single-process' # 在Docker中使用单进程模式
] ]
# 只在Docker环境中使用单进程模式
if os.getenv('DOCKER_ENV'):
browser_args.append('--single-process')
# 在Docker环境中添加额外参数 # 在Docker环境中添加额外参数
if os.getenv('DOCKER_ENV'): if os.getenv('DOCKER_ENV'):
browser_args.extend([ browser_args.extend([
@ -108,26 +125,38 @@ class OrderDetailFetcher:
'--use-mock-keychain' '--use-mock-keychain'
]) ])
logger.info(f"启动浏览器,参数: {browser_args}")
self.browser = await playwright.chromium.launch( self.browser = await playwright.chromium.launch(
headless=headless, headless=headless,
args=browser_args args=browser_args
) )
logger.info("浏览器启动成功,创建上下文...")
# 创建浏览器上下文 # 创建浏览器上下文
self.context = await self.browser.new_context( self.context = await self.browser.new_context(
viewport={'width': 1920, 'height': 1080}, viewport={'width': 1920, 'height': 1080},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' 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'
) )
logger.info("浏览器上下文创建成功设置HTTP头...")
# 设置额外的HTTP头 # 设置额外的HTTP头
await self.context.set_extra_http_headers(self.headers) await self.context.set_extra_http_headers(self.headers)
logger.info("创建页面...")
# 创建页面 # 创建页面
self.page = await self.context.new_page() self.page = await self.context.new_page()
logger.info("页面创建成功设置Cookie...")
# 设置Cookie # 设置Cookie
await self._set_cookies() await self._set_cookies()
# 等待一段时间确保浏览器完全初始化
await asyncio.sleep(1)
logger.info("浏览器初始化成功") logger.info("浏览器初始化成功")
return True return True
@ -159,63 +188,160 @@ class OrderDetailFetcher:
async def fetch_order_detail(self, order_id: str, timeout: int = 30) -> Optional[Dict[str, Any]]: async def fetch_order_detail(self, order_id: str, timeout: int = 30) -> Optional[Dict[str, Any]]:
""" """
获取订单详情 获取订单详情带锁机制和数据库缓存
Args: Args:
order_id: 订单ID order_id: 订单ID
timeout: 超时时间 timeout: 超时时间
Returns: Returns:
包含订单详情的字典失败时返回None 包含订单详情的字典失败时返回None
""" """
try: # 获取该订单ID的锁
if not self.page: order_lock = self._order_locks[order_id]
logger.error("浏览器未初始化")
async with order_lock:
logger.info(f"🔒 获取订单 {order_id} 的锁,开始处理...")
try:
# 首先查询数据库中是否已存在该订单(在初始化浏览器之前)
from db_manager import db_manager
existing_order = db_manager.get_order_by_id(order_id)
if existing_order:
# 检查金额字段是否有效不为空且不为0
amount = existing_order.get('amount', '')
amount_valid = False
if amount:
# 移除可能的货币符号和空格,检查是否为有效数字
amount_clean = str(amount).replace('¥', '').replace('', '').replace('$', '').strip()
try:
amount_value = float(amount_clean)
amount_valid = amount_value > 0
except (ValueError, TypeError):
amount_valid = False
if amount_valid:
logger.info(f"📋 订单 {order_id} 已存在于数据库中且金额有效({amount}),直接返回缓存数据")
print(f"✅ 订单 {order_id} 使用缓存数据,跳过浏览器获取")
# 构建返回格式,与浏览器获取的格式保持一致
result = {
'order_id': existing_order['order_id'],
'url': f"https://www.goofish.com/order-detail?orderId={order_id}&role=seller",
'title': f"订单详情 - {order_id}",
'sku_info': {
'spec_name': existing_order.get('spec_name', ''),
'spec_value': existing_order.get('spec_value', ''),
'quantity': existing_order.get('quantity', ''),
'amount': existing_order.get('amount', '')
},
'spec_name': existing_order.get('spec_name', ''),
'spec_value': existing_order.get('spec_value', ''),
'quantity': existing_order.get('quantity', ''),
'amount': existing_order.get('amount', ''),
'timestamp': time.time(),
'from_cache': True # 标记数据来源
}
return result
else:
logger.info(f"📋 订单 {order_id} 存在于数据库中但金额无效({amount}),需要重新获取")
print(f"⚠️ 订单 {order_id} 金额无效,重新获取详情...")
# 只有在数据库中没有有效数据时才初始化浏览器
logger.info(f"🌐 订单 {order_id} 需要浏览器获取,开始初始化浏览器...")
print(f"🔍 订单 {order_id} 开始浏览器获取详情...")
# 确保浏览器准备就绪
if not await self._ensure_browser_ready():
logger.error("浏览器初始化失败,无法获取订单详情")
return None
# 构建订单详情URL
url = f"https://www.goofish.com/order-detail?orderId={order_id}&role=seller"
logger.info(f"开始访问订单详情页面: {url}")
# 访问页面(带重试机制)
max_retries = 2
response = None
for retry in range(max_retries + 1):
try:
response = await self.page.goto(url, wait_until='networkidle', timeout=timeout * 1000)
if response and response.status == 200:
break
else:
logger.warning(f"页面访问失败,状态码: {response.status if response else 'None'},重试 {retry + 1}/{max_retries + 1}")
except Exception as e:
logger.warning(f"页面访问异常: {e},重试 {retry + 1}/{max_retries + 1}")
# 如果是浏览器连接问题,尝试重新初始化
if "Target page, context or browser has been closed" in str(e):
logger.info("检测到浏览器连接断开,尝试重新初始化...")
if await self._ensure_browser_ready():
logger.info("浏览器重新初始化成功,继续重试...")
continue
else:
logger.error("浏览器重新初始化失败")
return None
if retry == max_retries:
logger.error(f"页面访问最终失败: {e}")
return None
await asyncio.sleep(1) # 重试前等待1秒
if not response or response.status != 200:
logger.error(f"页面访问最终失败,状态码: {response.status if response else 'None'}")
return None
logger.info("页面加载成功,等待内容渲染...")
# 等待页面完全加载
try:
await self.page.wait_for_load_state('networkidle')
except Exception as e:
logger.warning(f"等待页面加载状态失败: {e}")
# 继续执行,不中断流程
# 额外等待确保动态内容加载完成
await asyncio.sleep(3)
# 获取并解析SKU信息
sku_info = await self._get_sku_content()
# 获取页面标题
try:
title = await self.page.title()
except Exception as e:
logger.warning(f"获取页面标题失败: {e}")
title = f"订单详情 - {order_id}"
result = {
'order_id': order_id,
'url': url,
'title': title,
'sku_info': sku_info, # 包含解析后的规格信息
'spec_name': sku_info.get('spec_name', '') if sku_info else '',
'spec_value': sku_info.get('spec_value', '') if sku_info else '',
'quantity': sku_info.get('quantity', '') if sku_info else '', # 数量
'amount': sku_info.get('amount', '') if sku_info else '', # 金额
'timestamp': time.time(),
'from_cache': False # 标记数据来源
}
logger.info(f"订单详情获取成功: {order_id}")
if sku_info:
logger.info(f"规格信息 - 名称: {result['spec_name']}, 值: {result['spec_value']}")
logger.info(f"数量: {result['quantity']}, 金额: {result['amount']}")
return result
except Exception as e:
logger.error(f"获取订单详情失败: {e}")
return None return None
# 构建订单详情URL
url = f"https://www.goofish.com/order-detail?orderId={order_id}&role=seller"
logger.info(f"开始访问订单详情页面: {url}")
# 访问页面
response = await self.page.goto(url, wait_until='networkidle', timeout=timeout * 1000)
if not response or response.status != 200:
logger.error(f"页面访问失败,状态码: {response.status if response else 'None'}")
return None
logger.info("页面加载成功,等待内容渲染...")
# 等待页面完全加载
await self.page.wait_for_load_state('networkidle')
# 额外等待确保动态内容加载完成
await asyncio.sleep(3)
# 获取并解析SKU信息
sku_info = await self._get_sku_content()
# 获取页面标题
title = await self.page.title()
result = {
'order_id': order_id,
'url': url,
'title': title,
'sku_info': sku_info, # 包含解析后的规格信息
'spec_name': sku_info.get('spec_name', '') if sku_info else '',
'spec_value': sku_info.get('spec_value', '') if sku_info else '',
'timestamp': time.time()
}
logger.info(f"订单详情获取成功: {order_id}")
if sku_info:
logger.info(f"规格信息 - 名称: {result['spec_name']}, 值: {result['spec_value']}")
return result
except Exception as e:
logger.error(f"获取订单详情失败: {e}")
return None
def _parse_sku_content(self, sku_content: str) -> Dict[str, str]: def _parse_sku_content(self, sku_content: str) -> Dict[str, str]:
""" """
@ -258,38 +384,121 @@ class OrderDetailFetcher:
return {} return {}
async def _get_sku_content(self) -> Optional[Dict[str, str]]: async def _get_sku_content(self) -> Optional[Dict[str, str]]:
"""获取并解析SKU内容""" """获取并解析SKU内容,包括规格、数量和金额"""
try: try:
# 等待SKU元素出现 # 检查浏览器状态
if not await self._check_browser_status():
logger.error("浏览器状态异常无法获取SKU内容")
return {}
result = {}
# 获取所有 sku--u_ddZval 元素
sku_selector = '.sku--u_ddZval' sku_selector = '.sku--u_ddZval'
sku_elements = await self.page.query_selector_all(sku_selector)
# 检查元素是否存在 logger.info(f"找到 {len(sku_elements)} 个 sku--u_ddZval 元素")
sku_element = await self.page.query_selector(sku_selector) print(f"🔍 找到 {len(sku_elements)} 个 sku--u_ddZval 元素")
if sku_element: # 获取金额信息
# 获取元素文本内容 amount_selector = '.boldNum--JgEOXfA3'
sku_content = await sku_element.text_content() amount_element = await self.page.query_selector(amount_selector)
if sku_content: amount = ''
sku_content = sku_content.strip() if amount_element:
logger.info(f"找到SKU原始内容: {sku_content}") amount_text = await amount_element.text_content()
print(f"🛍️ SKU原始内容: {sku_content}") if amount_text:
amount = amount_text.strip()
# 解析SKU内容 logger.info(f"找到金额: {amount}")
parsed_sku = self._parse_sku_content(sku_content) print(f"💰 金额: {amount}")
if parsed_sku: result['amount'] = amount
print(f"📋 规格名称: {parsed_sku['spec_name']}")
print(f"📝 规格值: {parsed_sku['spec_value']}")
return parsed_sku
else:
logger.warning("SKU内容解析失败")
return {}
else:
logger.warning("SKU元素内容为空")
return {}
else: else:
logger.warning("未找到SKU元素") logger.warning("未找到金额元素")
print("⚠️ 未找到金额信息")
# 尝试获取页面的所有class包含sku的元素 # 处理 sku--u_ddZval 元素
if len(sku_elements) == 2:
# 有两个元素:第一个是规格,第二个是数量
logger.info("检测到两个 sku--u_ddZval 元素,第一个为规格,第二个为数量")
print("📋 检测到两个元素:第一个为规格,第二个为数量")
# 处理规格(第一个元素)
spec_content = await sku_elements[0].text_content()
if spec_content:
spec_content = spec_content.strip()
logger.info(f"规格原始内容: {spec_content}")
print(f"🛍️ 规格原始内容: {spec_content}")
# 解析规格内容
parsed_spec = self._parse_sku_content(spec_content)
if parsed_spec:
result.update(parsed_spec)
print(f"📋 规格名称: {parsed_spec['spec_name']}")
print(f"📝 规格值: {parsed_spec['spec_value']}")
# 处理数量(第二个元素)
quantity_content = await sku_elements[1].text_content()
if quantity_content:
quantity_content = quantity_content.strip()
logger.info(f"数量原始内容: {quantity_content}")
print(f"📦 数量原始内容: {quantity_content}")
# 从数量内容中提取数量值(使用冒号分割,取后面的值)
if ':' in quantity_content:
quantity_value = quantity_content.split(':', 1)[1].strip()
result['quantity'] = quantity_value
logger.info(f"提取到数量: {quantity_value}")
print(f"🔢 数量: {quantity_value}")
else:
result['quantity'] = quantity_content
logger.info(f"数量内容无冒号,直接使用: {quantity_content}")
print(f"🔢 数量: {quantity_content}")
elif len(sku_elements) == 1:
# 只有一个元素:判断是否包含"数量"
logger.info("检测到一个 sku--u_ddZval 元素,判断是规格还是数量")
print("📋 检测到一个元素,判断是规格还是数量")
content = await sku_elements[0].text_content()
if content:
content = content.strip()
logger.info(f"元素原始内容: {content}")
print(f"🛍️ 元素原始内容: {content}")
if '数量' in content:
# 这是数量信息
logger.info("判断为数量信息")
print("📦 判断为数量信息")
if ':' in content:
quantity_value = content.split(':', 1)[1].strip()
result['quantity'] = quantity_value
logger.info(f"提取到数量: {quantity_value}")
print(f"🔢 数量: {quantity_value}")
else:
result['quantity'] = content
logger.info(f"数量内容无冒号,直接使用: {content}")
print(f"🔢 数量: {content}")
else:
# 这是规格信息
logger.info("判断为规格信息")
print("📋 判断为规格信息")
parsed_spec = self._parse_sku_content(content)
if parsed_spec:
result.update(parsed_spec)
print(f"📋 规格名称: {parsed_spec['spec_name']}")
print(f"📝 规格值: {parsed_spec['spec_value']}")
else:
logger.warning(f"未找到或找到异常数量的 sku--u_ddZval 元素: {len(sku_elements)}")
print(f"⚠️ 未找到或找到异常数量的元素: {len(sku_elements)}")
# 如果没有找到sku--u_ddZval元素设置默认数量为0
if len(sku_elements) == 0:
result['quantity'] = '0'
logger.info("未找到sku--u_ddZval元素数量默认设置为0")
print("📦 数量默认设置为: 0")
# 尝试获取页面的所有class包含sku的元素进行调试
all_sku_elements = await self.page.query_selector_all('[class*="sku"]') all_sku_elements = await self.page.query_selector_all('[class*="sku"]')
if all_sku_elements: if all_sku_elements:
logger.info(f"找到 {len(all_sku_elements)} 个包含'sku'的元素") logger.info(f"找到 {len(all_sku_elements)} 个包含'sku'的元素")
@ -298,12 +507,104 @@ class OrderDetailFetcher:
text_content = await element.text_content() text_content = await element.text_content()
logger.info(f"SKU元素 {i+1}: class='{class_name}', text='{text_content}'") logger.info(f"SKU元素 {i+1}: class='{class_name}', text='{text_content}'")
return {} # 确保数量字段存在如果不存在则设置为0
if 'quantity' not in result:
result['quantity'] = '0'
logger.info("未获取到数量信息默认设置为0")
print("📦 数量默认设置为: 0")
# 打印最终结果
if result:
logger.info(f"最终解析结果: {result}")
print("✅ 解析结果:")
for key, value in result.items():
print(f" {key}: {value}")
return result
else:
logger.warning("未能解析到任何有效信息")
print("❌ 未能解析到任何有效信息")
# 即使没有其他信息,也要返回默认数量
return {'quantity': '0'}
except Exception as e: except Exception as e:
logger.error(f"获取SKU内容失败: {e}") logger.error(f"获取SKU内容失败: {e}")
return {} return {}
async def _check_browser_status(self) -> bool:
"""检查浏览器状态是否正常"""
try:
if not self.browser or not self.context or not self.page:
logger.warning("浏览器组件不完整")
return False
# 检查浏览器是否已连接
if self.browser.is_connected():
# 尝试获取页面标题来验证页面是否可用
await self.page.title()
return True
else:
logger.warning("浏览器连接已断开")
return False
except Exception as e:
logger.warning(f"浏览器状态检查失败: {e}")
return False
async def _ensure_browser_ready(self) -> bool:
"""确保浏览器准备就绪,如果不可用则重新初始化"""
try:
if await self._check_browser_status():
return True
logger.info("浏览器状态异常,尝试重新初始化...")
# 先尝试关闭现有的浏览器实例
await self._force_close_browser()
# 重新初始化浏览器
await self.init_browser()
# 等待更长时间确保浏览器完全就绪
await asyncio.sleep(2)
# 再次检查状态
if await self._check_browser_status():
logger.info("浏览器重新初始化成功")
return True
else:
logger.error("浏览器重新初始化失败")
return False
except Exception as e:
logger.error(f"确保浏览器就绪失败: {e}")
return False
async def _force_close_browser(self):
"""强制关闭浏览器,忽略所有错误"""
try:
if self.page:
try:
await self.page.close()
except:
pass
self.page = None
if self.context:
try:
await self.context.close()
except:
pass
self.context = None
if self.browser:
try:
await self.browser.close()
except:
pass
self.browser = None
except Exception as e:
logger.debug(f"强制关闭浏览器过程中的异常(可忽略): {e}")
async def close(self): async def close(self):
"""关闭浏览器""" """关闭浏览器"""
try: try:
@ -316,6 +617,8 @@ class OrderDetailFetcher:
logger.info("浏览器已关闭") logger.info("浏览器已关闭")
except Exception as e: except Exception as e:
logger.error(f"关闭浏览器失败: {e}") logger.error(f"关闭浏览器失败: {e}")
# 如果正常关闭失败,尝试强制关闭
await self._force_close_browser()
async def __aenter__(self): async def __aenter__(self):
"""异步上下文管理器入口""" """异步上下文管理器入口"""
@ -330,7 +633,7 @@ class OrderDetailFetcher:
# 便捷函数 # 便捷函数
async def fetch_order_detail_simple(order_id: str, cookie_string: str = None, headless: bool = True) -> Optional[Dict[str, Any]]: async def fetch_order_detail_simple(order_id: str, cookie_string: str = None, headless: bool = True) -> Optional[Dict[str, Any]]:
""" """
简单的订单详情获取函数 简单的订单详情获取函数优化版先检查数据库再初始化浏览器
Args: Args:
order_id: 订单ID order_id: 订单ID
@ -338,9 +641,71 @@ async def fetch_order_detail_simple(order_id: str, cookie_string: str = None, he
headless: 是否无头模式 headless: 是否无头模式
Returns: Returns:
订单详情字典或None 订单详情字典包含以下字段
- order_id: 订单ID
- url: 订单详情页面URL
- title: 页面标题
- sku_info: 完整的SKU信息字典
- spec_name: 规格名称
- spec_value: 规格值
- quantity: 数量
- amount: 金额
- timestamp: 获取时间戳
失败时返回None
""" """
fetcher = OrderDetailFetcher(cookie_string) # 先检查数据库中是否有有效数据
try:
from db_manager import db_manager
existing_order = db_manager.get_order_by_id(order_id)
if existing_order:
# 检查金额字段是否有效
amount = existing_order.get('amount', '')
amount_valid = False
if amount:
amount_clean = str(amount).replace('¥', '').replace('', '').replace('$', '').strip()
try:
amount_value = float(amount_clean)
amount_valid = amount_value > 0
except (ValueError, TypeError):
amount_valid = False
if amount_valid:
logger.info(f"📋 订单 {order_id} 已存在于数据库中且金额有效({amount}),直接返回缓存数据")
print(f"✅ 订单 {order_id} 使用缓存数据,跳过浏览器获取")
# 构建返回格式
result = {
'order_id': existing_order['order_id'],
'url': f"https://www.goofish.com/order-detail?orderId={order_id}&role=seller",
'title': f"订单详情 - {order_id}",
'sku_info': {
'spec_name': existing_order.get('spec_name', ''),
'spec_value': existing_order.get('spec_value', ''),
'quantity': existing_order.get('quantity', ''),
'amount': existing_order.get('amount', '')
},
'spec_name': existing_order.get('spec_name', ''),
'spec_value': existing_order.get('spec_value', ''),
'quantity': existing_order.get('quantity', ''),
'amount': existing_order.get('amount', ''),
'order_status': existing_order.get('order_status', 'unknown'), # 添加订单状态
'timestamp': time.time(),
'from_cache': True
}
return result
else:
logger.info(f"📋 订单 {order_id} 存在于数据库中但金额无效({amount}),需要重新获取")
print(f"⚠️ 订单 {order_id} 金额无效,重新获取详情...")
except Exception as e:
logger.warning(f"检查数据库缓存失败: {e}")
# 数据库中没有有效数据,使用浏览器获取
logger.info(f"🌐 订单 {order_id} 需要浏览器获取,开始初始化浏览器...")
print(f"🔍 订单 {order_id} 开始浏览器获取详情...")
fetcher = OrderDetailFetcher(cookie_string, headless)
try: try:
if await fetcher.init_browser(headless=headless): if await fetcher.init_browser(headless=headless):
return await fetcher.fetch_order_detail(order_id) return await fetcher.fetch_order_detail(order_id)
@ -364,7 +729,10 @@ if __name__ == "__main__":
print(f"📋 订单ID: {result['order_id']}") print(f"📋 订单ID: {result['order_id']}")
print(f"🌐 URL: {result['url']}") print(f"🌐 URL: {result['url']}")
print(f"📄 页面标题: {result['title']}") print(f"📄 页面标题: {result['title']}")
print(f"🛍️ SKU内容: {result['sku_content']}") print(f"🛍️ 规格名称: {result.get('spec_name', '未获取到')}")
print(f"📝 规格值: {result.get('spec_value', '未获取到')}")
print(f"🔢 数量: {result.get('quantity', '未获取到')}")
print(f"💰 金额: {result.get('amount', '未获取到')}")
else: else:
print("❌ 订单详情获取失败") print("❌ 订单详情获取失败")