From 40a78f0032dec4e4269e3f07b9862e51ac677b6b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com>
Date: Wed, 15 Apr 2026 22:56:44 +0800
Subject: [PATCH] first commit
---
.dockerignore | 363 +
.gitattributes | 34 +
.gitignore | 814 +
Dockerfile | 131 +
Dockerfile-cn | 122 +
README.md | 505 +
SECURITY.md | 41 +
Start.py | 595 +
XianyuAutoAsync.py | 15323 ++++++++++++
ai_reply_engine.py | 628 +
announcement.json | 37 +
api_captcha_remote.py | 318 +
auto_updater.py | 1019 +
build-multi-arch.sh | 190 +
captcha_control.html | 571 +
config.py | 131 +
cookie_manager.py | 437 +
db_manager.py | 9013 +++++++
debug-xvfb.sh | 137 +
docker-compose-cn.yml | 117 +
docker-compose.yml | 117 +
docker-deploy.bat | 239 +
docker-deploy.sh | 388 +
entrypoint.sh | 169 +
file_log_collector.py | 257 +
generate_update_manifest.py | 412 +
global_config.yml | 82 +
nginx/nginx.conf | 123 +
order_event_hub.py | 81 +
order_status_handler.py | 1736 ++
release_precheck.py | 203 +
reply_server.py | 11013 +++++++++
requirements.txt | 95 +
secure_confirm_decrypted.py | 237 +
secure_freeshipping_decrypted.py | 180 +
secure_item_polish_ultra.py | 42 +
start.sh | 42 +
static/css/about.css | 69 +
static/css/accounts.css | 1375 ++
static/css/admin.css | 241 +
static/css/app.css | 13 +
static/css/components.css | 848 +
static/css/dashboard.css | 1985 ++
static/css/items.css | 52 +
static/css/keywords.css | 940 +
static/css/layout.css | 306 +
static/css/logs.css | 875 +
static/css/notifications.css | 83 +
static/css/variables.css | 550 +
static/favicon.png | Bin 0 -> 7996 bytes
static/index.html | 5032 ++++
static/js/app.js | 20248 ++++++++++++++++
.../lib/bootstrap-icons/bootstrap-icons.css | 2078 ++
.../fonts/bootstrap-icons.woff | Bin 0 -> 176196 bytes
.../fonts/bootstrap-icons.woff2 | Bin 0 -> 130764 bytes
static/lib/bootstrap/bootstrap.bundle.min.js | 7 +
static/lib/bootstrap/bootstrap.min.css | 6 +
static/login.html | 961 +
static/register.html | 570 +
static/update_log.txt | 149 +
static/userscripts/goofish-dark-mode.user.js | 421 +
static/version.txt | 1 +
static/xianyu_js_version_2.js | 568 +
stop.sh | 18 +
utils/captcha_remote_control.py | 368 +
utils/image_uploader.py | 257 +
utils/image_utils.py | 256 +
utils/item_search.py | 1635 ++
utils/notification_dispatcher.py | 585 +
utils/order_detail_fetcher.py | 2743 +++
utils/order_history_sync.py | 444 +
utils/qr_login.py | 755 +
utils/refresh_util.py | 2081 ++
utils/slider_patch.py | 2129 ++
utils/time_utils.py | 118 +
utils/xianyu_slider_stealth.py | 7754 ++++++
utils/xianyu_utils.py | 379 +
77 files changed, 102842 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .gitattributes
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 Dockerfile-cn
create mode 100644 README.md
create mode 100644 SECURITY.md
create mode 100644 Start.py
create mode 100644 XianyuAutoAsync.py
create mode 100644 ai_reply_engine.py
create mode 100644 announcement.json
create mode 100644 api_captcha_remote.py
create mode 100644 auto_updater.py
create mode 100644 build-multi-arch.sh
create mode 100644 captcha_control.html
create mode 100644 config.py
create mode 100644 cookie_manager.py
create mode 100644 db_manager.py
create mode 100644 debug-xvfb.sh
create mode 100644 docker-compose-cn.yml
create mode 100644 docker-compose.yml
create mode 100644 docker-deploy.bat
create mode 100644 docker-deploy.sh
create mode 100644 entrypoint.sh
create mode 100644 file_log_collector.py
create mode 100644 generate_update_manifest.py
create mode 100644 global_config.yml
create mode 100644 nginx/nginx.conf
create mode 100644 order_event_hub.py
create mode 100644 order_status_handler.py
create mode 100644 release_precheck.py
create mode 100644 reply_server.py
create mode 100644 requirements.txt
create mode 100644 secure_confirm_decrypted.py
create mode 100644 secure_freeshipping_decrypted.py
create mode 100644 secure_item_polish_ultra.py
create mode 100644 start.sh
create mode 100644 static/css/about.css
create mode 100644 static/css/accounts.css
create mode 100644 static/css/admin.css
create mode 100644 static/css/app.css
create mode 100644 static/css/components.css
create mode 100644 static/css/dashboard.css
create mode 100644 static/css/items.css
create mode 100644 static/css/keywords.css
create mode 100644 static/css/layout.css
create mode 100644 static/css/logs.css
create mode 100644 static/css/notifications.css
create mode 100644 static/css/variables.css
create mode 100644 static/favicon.png
create mode 100644 static/index.html
create mode 100644 static/js/app.js
create mode 100644 static/lib/bootstrap-icons/bootstrap-icons.css
create mode 100644 static/lib/bootstrap-icons/fonts/bootstrap-icons.woff
create mode 100644 static/lib/bootstrap-icons/fonts/bootstrap-icons.woff2
create mode 100644 static/lib/bootstrap/bootstrap.bundle.min.js
create mode 100644 static/lib/bootstrap/bootstrap.min.css
create mode 100644 static/login.html
create mode 100644 static/register.html
create mode 100644 static/update_log.txt
create mode 100644 static/userscripts/goofish-dark-mode.user.js
create mode 100644 static/version.txt
create mode 100644 static/xianyu_js_version_2.js
create mode 100644 stop.sh
create mode 100644 utils/captcha_remote_control.py
create mode 100644 utils/image_uploader.py
create mode 100644 utils/image_utils.py
create mode 100644 utils/item_search.py
create mode 100644 utils/notification_dispatcher.py
create mode 100644 utils/order_detail_fetcher.py
create mode 100644 utils/order_history_sync.py
create mode 100644 utils/qr_login.py
create mode 100644 utils/refresh_util.py
create mode 100644 utils/slider_patch.py
create mode 100644 utils/time_utils.py
create mode 100644 utils/xianyu_slider_stealth.py
create mode 100644 utils/xianyu_utils.py
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..c6af667
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,363 @@
+# Git相关
+.git
+.gitignore
+.gitattributes
+
+# Python相关
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# 虚拟环境
+venv/
+env/
+ENV/
+env.bak/
+venv.bak/
+
+# IDE相关
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# 操作系统相关
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# 日志文件
+logs/*.log
+*.log
+
+# 数据库文件(构建时不包含,运行时挂载)
+*.db
+*.sqlite
+*.sqlite3
+
+# 临时文件
+*.tmp
+*.temp
+temp/
+tmp/
+
+# 测试文件
+test_*.py
+*_test.py
+tests/
+
+# 文档
+*.md
+docs/
+
+# Docker相关(保留必要的构建文件)
+.dockerignore.bak
+docker-compose.override.yml
+
+# 配置文件(运行时挂载)
+# global_config.yml
+
+# 环境变量文件
+.env
+.env.*
+!.env.example
+*.local.yml
+*.dev.yml
+*.test.yml
+config.*.yml
+!global_config.yml
+
+# 前端相关
+node_modules/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+package-lock.json
+yarn.lock
+
+# 数据目录(运行时挂载)
+data/
+backups/
+logs/
+realtime.log
+
+# 上传文件
+static/uploads/*
+!static/uploads/.gitkeep
+!static/uploads/images/
+static/uploads/images/*
+!static/uploads/images/.gitkeep
+
+# Excel和数据文件
+*.xlsx
+*.xls
+*.csv
+keywords_*.xlsx
+export_*.csv
+export_*.json
+export_*.xlsx
+
+# 压缩文件
+*.zip
+*.tar.gz
+*.rar
+*.7z
+
+# 备份文件
+*.bak
+*.backup
+*.old
+
+# 密钥和证书
+*.key
+*.pem
+*.crt
+*.cert
+*.p12
+*.pfx
+ssl/
+secrets/
+credentials/
+
+# 运行时文件
+*.pid
+*.sock
+*.lock
+*.port
+
+# 性能分析文件
+*.prof
+*.profile
+*.pstats
+
+# 缓存文件
+.cache/
+cache/
+*.cache
+
+# 浏览器相关
+.playwright/
+playwright-report/
+test-results/
+
+# 示例和演示文件
+example_*.py
+*_example.py
+demo_*.py
+*_demo.py
+
+# AI模型文件
+*.model
+*.weights
+*.h5
+*.pb
+
+# 大文件
+*.iso
+*.dmg
+*.img
+
+# 监控和审计
+monitoring/
+*.access.log
+*.error.log
+*.audit.log
+
+# 本地开发文件
+local/
+.local/
+debug.log
+*.debug
+
+# ==================== 2025年新增规则 ====================
+
+# Nuitka编译临时文件
+nuitka-crash-report*.xml
+nuitka-onefile-warning.txt
+utils/*.build/
+utils/*.dist/
+*.build/
+*.dist/
+*.onefile-build/
+
+# 浏览器数据和缓存
+DrissionPage/
+.drission/
+selenium/
+webdriver/
+.playwright/.browsers/
+playwright-state/
+
+# 统计和监控数据
+user_stats.txt
+user_stats.db
+stats.log
+stats_*.log
+metrics/
+analytics/
+
+# 运行时生成的数据(这些在容器运行时挂载)
+xianyu_data.db
+xianyu_data.db.*
+xianyu_data_backup_*.db
+*.db-journal
+*.db-wal
+*.db-shm
+realtime.log
+
+# 用户上传的文件(运行时挂载)
+static/uploads/*
+!static/uploads/.gitkeep
+
+# 轨迹历史数据
+trajectory_history/*.json
+trajectory_history/*.trajectory
+
+# Python编译缓存(补充)
+__pycache__/
+*.pyc
+*.pyo
+*$py.class
+
+# 临时脚本和测试文件
+temp_*.py
+tmp_*.py
+quick_*.py
+fix_*.py
+test_*.py
+check_*.py
+debug_*.py
+*_test.py
+*_demo.py
+*_example.py
+
+# 数据导出文件
+export_*.xlsx
+export_*.csv
+export_*.json
+dump_*.sql
+backup_*.db
+
+# 开发环境文件
+.env
+.env.*
+.env.local
+.env.*.local
+*.local.yml
+*.dev.yml
+*.test.yml
+config.local.*
+
+# Git相关(补充)
+.git/
+.github/
+.gitignore
+.gitattributes
+.git-credentials
+
+# 编辑器和IDE配置
+.vscode/
+.idea/
+*.sublime-*
+*.code-workspace
+
+# 文档和说明(Dockerfile中不需要)
+*.md
+!README.md
+docs/
+documentation/
+
+# 压缩包和大文件
+*.zip
+*.tar.gz
+*.rar
+*.7z
+*.iso
+*.dmg
+
+# 媒体文件
+*.mp4
+*.avi
+*.mov
+*.mp3
+*.wav
+
+# AI模型文件
+*.model
+*.weights
+*.h5
+*.pb
+*.onnx
+
+# 性能分析和调试
+*.perf
+*.trace
+*.profile
+*.prof
+memory_*.dump
+cpu_*.profile
+
+# 容器相关
+docker-compose.override.yml
+.docker/
+docker-data/
+container_logs/
+
+# 密钥和证书(安全相关)
+*.key
+*.pem
+*.crt
+*.cert
+*.p12
+*.pfx
+secrets/
+credentials/
+keys/
+ssl/*.key
+ssl/*.pem
+
+# 备份文件
+*.bak
+*.backup
+*.old
+*~
+
+# 操作系统特定文件
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# Python虚拟环境
+venv/
+env/
+ENV/
+.venv/
+
+# 项目特定排除文件(已清理的测试文件和调试工具)
+# check_silent_mode.py - 已删除
+# order_status_handler.py - 保留,是核心订单处理模块
+php/
+# simple_stats_server.py - 保留,是可选的统计服务器
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..89fadd4
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,34 @@
+# 确保脚本文件使用Unix换行符(LF)
+*.sh text eol=lf
+entrypoint.sh text eol=lf
+
+# 确保Windows批处理文件使用Windows换行符(CRLF)
+*.bat text eol=crlf
+
+# 确保Python文件使用Unix换行符(LF)
+*.py text eol=lf
+
+# 确保前端文件使用Unix换行符(LF)
+*.js text eol=lf
+*.html text eol=lf
+*.css text eol=lf
+
+# 确保配置文件使用Unix换行符(LF)
+*.yml text eol=lf
+*.yaml text eol=lf
+*.json text eol=lf
+*.conf text eol=lf
+
+# 确保文档文件使用Unix换行符(LF)
+*.md text eol=lf
+*.txt text eol=lf
+
+# 二进制文件
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.db binary
+*.sqlite binary
+*.sqlite3 binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f5d90cd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,814 @@
+__pycache__
+*node_modules/*
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+# Python lib directories (but not static/lib)
+lib/
+!static/lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+MANIFEST
+*.manifest
+*.spec
+.cache
+*.log
+local_settings.py
+
+# Database files
+*.db
+*.sqlite
+*.sqlite3
+db.sqlite3
+__pypackages__/
+.venv
+
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Temporary files
+*.tmp
+*.temp
+temp/
+tmp/
+
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# IDE files
+.vscode/
+.idea/
+.claude/
+*.swp
+*.swo
+*~
+
+# Local environment files
+.env.local
+.env.*.local
+update_files.json
+
+# ==================== 项目特定文件 ====================
+# 日志文件
+logs/
+realtime.log
+
+# 数据目录
+data/
+backups/
+
+# Excel测试文件
+keywords_*.xlsx
+*.xls
+
+# 图片缓存
+*.png.cache
+*.jpg.cache
+
+# 上传的文件(保留目录结构,忽略文件内容)
+static/uploads/*
+!static/uploads/.gitkeep
+!static/uploads/images/
+static/uploads/images/*
+!static/uploads/images/.gitkeep
+
+# 配置文件(包含敏感信息)
+config.local.yml
+global_config.local.yml
+
+# 测试文件
+test_*.py
+*_test.py
+keywords_sample.xlsx
+
+# 备份文件
+*.bak
+*.backup
+*.old
+
+# 压缩文件
+*.zip
+*.tar.gz
+*.rar
+*.7z
+
+# ==================== Python开发相关 ====================
+# Python 字节码和缓存(补充)
+*.pyc
+*.pyo
+*.pyd
+*.py[cod]
+*$py.class
+
+# 分发/打包(补充)
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller(补充)
+
+# 单元测试/覆盖率报告
+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/
+
+# ==================== 项目特定新增 ====================
+
+# 数据库文件
+*.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/
+opencode.json
+
+# 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
+
+# 文档文件(除了README.md)
+*.md
+!README.md
+!CHANGELOG.md
+!CONTRIBUTING.md
+!SECURITY.md
+!LICENSE.md
+!docs/*.md
+
+# 临时配置文件
+*.local.yml
+*.dev.yml
+*.test.yml
+config.*.yml
+!global_config.yml
+
+# 开发工具配置
+.editorconfig
+.flake8
+.pylintrc
+pyproject.toml
+setup.cfg
+tox.ini
+
+# 数据导出文件
+export_*.csv
+export_*.json
+export_*.xlsx
+dump_*.sql
+
+# 临时下载文件
+downloads/
+temp_downloads/
+
+# 浏览器相关
+.playwright/
+playwright-report/
+test-results/
+playwright/.browsers/
+
+# 系统服务文件
+*.service
+*.timer
+systemd/
+
+# 备份和归档
+archive/
+old/
+deprecated/
+
+# 项目特定新增规则
+# 轨迹历史文件
+trajectory_history/*.json
+!trajectory_history/.gitkeep
+
+# 统计服务器相关(保留核心文件,只忽略数据文件)
+user_stats.txt
+stats.log
+
+# 加密相关文件(保留项目核心文件,只忽略临时解密文件)
+# *_decrypted.py # 注释掉,因为项目中有重要的解密文件
+# *_ultra.py # 注释掉,因为项目中有重要的ultra文件
+item_polish_module.py
+
+# 临时脚本文件
+temp_*.py
+quick_*.py
+fix_*.py
+
+# ==================== 项目特定数据库和日志 ====================
+# 数据库文件
+xianyu_data.db
+xianyu_data_backup_*.db
+user_stats.db
+
+# 实时日志文件
+realtime.log
+
+# PHP测试文件
+php/
+
+# 检查脚本
+check_disk_usage.py
+
+# 环境配置文件
+.env
+.env.*
+!.env.example
+.env.docker
+
+# === 滑块验证模块配置 ===
+# 允许跟踪源代码和二进制扩展模块
+!utils/xianyu_slider_stealth.py
+!utils/xianyu_slider_stealth*.pyd
+!utils/xianyu_slider_stealth*.so
+!utils/xianyu_slider_stealth.pyi
+
+# ==================== 新增项目特定规则 ====================
+
+# 用户数据和隐私文件
+user_data/
+personal_configs/
+*.personal.yml
+*.private.yml
+
+# 运行时生成的文件
+*.runtime
+*.session
+session_*
+runtime_*
+
+# 第三方服务配置
+*.service.yml
+*.webhook.yml
+external_configs/
+
+# 性能分析和调试文件
+*.perf
+*.trace
+memory_*.dump
+cpu_*.profile
+
+# 机器学习模型文件(如果有AI功能扩展)
+models/
+*.model
+*.weights
+*.checkpoint
+
+# 容器相关文件
+.docker/
+docker-data/
+container_logs/
+
+# 监控和统计文件
+metrics/
+analytics/
+*.metrics
+*.analytics
+
+# 自动生成的文档
+auto_docs/
+generated_docs/
+
+# 临时API文件
+api_temp/
+temp_api/
+
+# 插件和扩展
+plugins/
+extensions/
+addons/
+
+# 测试覆盖率报告
+coverage_html/
+.coverage.*
+coverage.xml
+
+# IDE和编辑器特定文件
+.vscode/settings.json
+.vscode/launch.json
+.idea/workspace.xml
+.idea/tasks.xml
+
+# 系统特定文件
+.DS_Store
+Thumbs.db
+desktop.ini
+*.lnk
+
+# 网络和代理配置
+proxy_configs/
+*.proxy
+network_configs/
+
+# ==================== 项目清理后新增规则 ====================
+
+# 临时文档和说明文件
+*功能说明.md
+*修改说明.md
+*分析报告.md
+*使用说明.md
+
+# 轨迹历史文件
+trajectory_history/
+*.trajectory
+
+# 实时日志文件
+realtime.log
+*.realtime
+
+# Nuitka编译报告
+nuitka-crash-report.xml
+*.crash-report.xml
+
+# 项目压缩包
+*.zip
+*.tar.gz
+*.rar
+*.7z
+
+# 临时数据库文件
+*.db-journal
+*.db-wal
+*.db-shm
+
+# ==================== 2025年新增规则 ====================
+
+# 编译相关临时文件
+*.build/
+*.dist/
+*.onefile-build/
+nuitka-crash-report*.xml
+nuitka-onefile-warning.txt
+
+# Nuitka 编译产物(保留在utils目录下的二进制模块)
+utils/*.build/
+utils/*.dist/
+!utils/xianyu_slider_stealth*.pyd
+!utils/xianyu_slider_stealth*.so
+
+# Python类型提示文件(Nuitka生成的)
+*.pyi
+!utils/xianyu_slider_stealth.pyi
+
+# DrissionPage浏览器缓存
+DrissionPage/
+.drission/
+
+# 其他浏览器数据
+selenium/
+webdriver/
+*.crdownload
+
+# 项目运行时生成的统计文件
+user_stats.txt
+user_stats.db
+stats.log
+stats_*.log
+
+# 临时Python脚本(不要提交临时脚本)
+temp_*.py
+tmp_*.py
+quick_*.py
+fix_*.py
+test_*.py
+check_*.py
+debug_*.py
+
+# 数据导出文件(避免提交用户数据)
+export_*.xlsx
+export_*.csv
+export_*.json
+dump_*.sql
+backup_*.db
+
+# 日志文件(更全面)
+*.log
+*.log.*
+logs/
+*.realtime
+realtime.log
+
+# 配置文件备份
+*.yml.bak
+*.yaml.bak
+*.json.bak
+*.conf.bak
+
+# 系统监控和性能分析
+*.perf
+*.trace
+*.profile
+memory_*.dump
+cpu_*.profile
+performance_*.txt
+
+# 容器相关(补充)
+.docker/
+docker-data/
+container_logs/
+docker-compose.override.yml
+
+# 数据目录(补充确保不提交用户数据)
+data/
+backups/
+trajectory_history/
+*.trajectory
+
+# 临时下载和缓存
+downloads/
+temp_downloads/
+.cache/
+cache/
+__cache__/
+
+# 开发环境配置(保护开发者隐私)
+.env
+.env.*
+!.env.example
+*.local.yml
+*.dev.yml
+*.test.yml
+*.personal.yml
+*.private.yml
+config.local.*
+config.dev.*
+
+# 加密密钥文件(确保安全)
+*.key
+*.pem
+*.crt
+*.cert
+*.p12
+*.pfx
+secrets/
+credentials/
+keys/
+
+# AI模型和大文件
+*.model
+*.weights
+*.h5
+*.pb
+*.onnx
+*.tflite
+models/
+checkpoints/
+
+# 媒体文件(避免提交大文件)
+*.mp4
+*.avi
+*.mov
+*.wmv
+*.flv
+*.mp3
+*.wav
+*.ogg
+
+# 项目特定排除(明确列出不需要跟踪的文件)
+# check_silent_mode.py - 已删除
+# order_status_handler.py - 是核心模块,需要跟踪
+
+# ==================== 2025年10月新增规则 ====================
+
+# 测试相关文件(已清理)
+test_slider_*.py
+auto_test_*.py
+auto_debug_*.py
+run_slider_test.py
+observe_*.py
+auto_optimize_*.py
+run_loop_test.ps1
+slider_token_test_*.json
+test_results_*.json
+test_output.txt
+
+# 临时文档和说明(已清理)
+README_SLIDER_TEST.md
+auto_optimize_log.txt
+最新镜像地址.txt
+
+# 轨迹历史文件(运行时生成)
+trajectory_history/*.json
+trajectory_history/*.trajectory
+!trajectory_history/.gitkeep
+
+# 静态文件上传目录(运行时上传)
+static/uploads/images/*
+!static/uploads/images/.gitkeep
+
+# 数据目录完整覆盖
+data/**
+!data/.gitkeep
+backups/**
+!backups/.gitkeep
+
+# 日志完整覆盖
+logs/**
+*.log
+*.log.*
+realtime.log
+
+# Python编译文件完整覆盖
+*.pyc
+*.pyo
+*.pyd
+__pycache__/**
+
+# 编辑器和IDE完整覆盖
+.vscode/**
+.idea/**
+*.swp
+*.swo
+*~
+
+# 临时文件完整覆盖
+*.tmp
+*.temp
+temp/**
+tmp/**
+
+# 数据库文件完整覆盖(运行时生成)
+*.db
+*.db-journal
+*.db-wal
+*.db-shm
+*.sqlite
+*.sqlite3
+
+# 浏览器数据完整覆盖
+.playwright/**
+.playwright-mcp/**
+DrissionPage/**
+.drission/**
+playwright-report/**
+test-results/**
+playwright/.browsers/**
+playwright-state/**
+
+# 统计数据完整覆盖
+user_stats.txt
+user_stats.db
+stats.log
+stats_*.log
+
+# Nuitka编译产物完整覆盖
+*.build/**
+*.dist/**
+*.onefile-build/**
+nuitka-crash-report*.xml
+nuitka-onefile-warning.txt
+utils/*.build/**
+utils/*.dist/**
+
+# 环境配置完整覆盖
+.env
+.env.*
+!.env.example
+*.local.yml
+*.dev.yml
+*.test.yml
+config.local.*
+config.dev.*
+
+# 密钥和证书完整覆盖
+*.key
+*.pem
+*.crt
+*.cert
+*.p12
+*.pfx
+secrets/**
+credentials/**
+keys/**
+
+# 媒体文件缓存
+*.mp4
+*.avi
+*.mov
+*.mp3
+*.wav
+
+# 压缩包和归档
+*.zip
+*.tar.gz
+*.rar
+*.7z
+
+# 备份文件
+*.bak
+*.backup
+*.old
+
+# Excel和CSV数据文件
+export_*.xlsx
+export_*.csv
+export_*.json
+keywords_*.xlsx
+
+# AI模型文件
+*.model
+*.weights
+*.h5
+*.pb
+*.onnx
+checkpoints/**
+
+# 性能分析文件
+*.perf
+*.trace
+*.profile
+memory_*.dump
+cpu_*.profile
+
+# 系统特定文件
+.DS_Store
+Thumbs.db
+desktop.ini
+*.lnk
+
+# Docker相关
+docker-compose.override.yml
+.docker/**
+docker-data/**
+container_logs/**
+# Browser data
+browser_data/
+qr_screenshots/
+update_backup/
+logs/
+.ace-tool/
+
+# 前端预览文件
+static/control-tower-preview.html
+
+# 本地保留的统计相关脚本
+usage_statistics.py
+simple_stats_server.py
+utils/message_utils.py
+utils/ws_utils.py
+secure_confirm_ultra.py
+secure_freeshipping_ultra.py
+test_php_stats.py
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b3eecf5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,131 @@
+# 使用Python 3.11作为基础镜像
+# 支持通过构建参数指定镜像源(解决多架构构建时的网络问题)
+# 使用方法:docker build --build-arg BASE_IMAGE=ccr.ccs.tencentyun.com/dockerp/library/python:3.11-slim-bookworm
+ARG BASE_IMAGE=python:3.11-slim-bookworm
+FROM ${BASE_IMAGE}
+
+# 设置标签信息
+LABEL maintainer="GuDong2003"
+LABEL version="2.1.0"
+LABEL description="闲鱼管理系统 - GuDong2003 维护版本,支持多用户、多账号与自动化管理"
+LABEL repository="https://github.com/GuDong2003/xianyu-auto-reply-fix"
+LABEL license="仅供学习与研究使用,禁止商业用途"
+LABEL author="GuDong2003"
+LABEL build-date=""
+LABEL vcs-ref=""
+
+# 设置工作目录
+WORKDIR /app
+
+# 设置环境变量
+ENV PYTHONUNBUFFERED=1
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV TZ=Asia/Shanghai
+ENV DOCKER_ENV=true
+ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+
+#更换中科大源
+RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources
+
+# 安装系统依赖(包括Playwright浏览器依赖)
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ # 基础工具
+ nodejs \
+ npm \
+ tzdata \
+ curl \
+ ca-certificates \
+ # 图像处理依赖
+ libjpeg-dev \
+ libpng-dev \
+ libfreetype6-dev \
+ fonts-dejavu-core \
+ fonts-liberation \
+ # Playwright浏览器依赖
+ libnss3 \
+ libnspr4 \
+ libatk-bridge2.0-0 \
+ libdrm2 \
+ libxkbcommon0 \
+ libxcomposite1 \
+ libxdamage1 \
+ libxrandr2 \
+ libgbm1 \
+ libxss1 \
+ libasound2 \
+ libatspi2.0-0 \
+ libgtk-3-0 \
+ libgdk-pixbuf2.0-0 \
+ libxcursor1 \
+ libxi6 \
+ libxrender1 \
+ libxext6 \
+ libx11-6 \
+ libxft2 \
+ libxinerama1 \
+ libxtst6 \
+ libappindicator3-1 \
+ libx11-xcb1 \
+ libxfixes3 \
+ xdg-utils \
+ chromium \
+ xvfb \
+ x11vnc \
+ fluxbox \
+ # OpenCV运行时依赖
+ libgl1 \
+ libglib2.0-0 \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/* \
+ && rm -rf /tmp/* \
+ && rm -rf /var/tmp/*
+
+# 设置时区
+RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
+
+# 验证Node.js安装并设置环境变量
+RUN node --version && npm --version
+ENV NODE_PATH=/usr/lib/node_modules
+
+# 复制requirements.txt并安装Python依赖
+COPY requirements.txt .
+RUN pip install --no-cache-dir --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple&& \
+ pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+
+# 复制项目文件
+COPY . .
+
+# 安装Playwright浏览器(必须在复制项目文件之后)
+RUN playwright install chromium && \
+ playwright install-deps chromium
+
+# 创建必要的目录并设置权限
+RUN mkdir -p /app/logs /app/data /app/backups /app/static/uploads/images && \
+ chmod 777 /app/logs /app/data /app/backups /app/static/uploads /app/static/uploads/images
+
+# 配置系统限制,防止core文件生成
+RUN echo "ulimit -c 0" >> /etc/profile
+
+# 注意: 为了简化权限问题,使用root用户运行
+# 在生产环境中,建议配置适当的用户映射
+
+# 暴露端口
+EXPOSE 8090
+
+# 健康检查
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:8090/health || exit 1
+
+# 复制启动脚本
+# 复制启动脚本和调试工具
+COPY entrypoint.sh /app/entrypoint.sh
+COPY debug-xvfb.sh /app/debug-xvfb.sh
+
+# 设置执行权限(使用多种方式确保权限正确)
+RUN chmod +x /app/entrypoint.sh /app/debug-xvfb.sh && \
+ chmod 755 /app/entrypoint.sh /app/debug-xvfb.sh && \
+ ls -la /app/entrypoint.sh /app/debug-xvfb.sh
+
+# 启动命令
+CMD ["/app/entrypoint.sh"]
diff --git a/Dockerfile-cn b/Dockerfile-cn
new file mode 100644
index 0000000..b28cad6
--- /dev/null
+++ b/Dockerfile-cn
@@ -0,0 +1,122 @@
+# 使用Python 3.11作为基础镜像
+# 支持通过构建参数指定镜像源(解决多架构构建时的网络问题)
+# 使用方法:docker build --build-arg BASE_IMAGE=ccr.ccs.tencentyun.com/dockerp/library/python:3.11-slim-bookworm
+ARG BASE_IMAGE=python:3.11-slim-bookworm
+FROM ${BASE_IMAGE}
+
+# 设置标签信息
+LABEL maintainer="GuDong2003"
+LABEL version="2.1.0"
+LABEL description="闲鱼管理系统 - GuDong2003 维护版本,支持多用户、多账号与自动化管理"
+LABEL repository="https://github.com/GuDong2003/xianyu-auto-reply-fix"
+LABEL license="仅供学习与研究使用,禁止商业用途"
+LABEL author="GuDong2003"
+LABEL build-date=""
+LABEL vcs-ref=""
+
+# 设置工作目录
+WORKDIR /app
+
+# 设置环境变量
+ENV PYTHONUNBUFFERED=1
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV TZ=Asia/Shanghai
+ENV DOCKER_ENV=true
+ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+
+#更换中科大源
+RUN sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list.d/debian.sources
+
+# 安装系统依赖(包括Playwright浏览器依赖)
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ # 基础工具
+ nodejs \
+ npm \
+ tzdata \
+ curl \
+ ca-certificates \
+ # 图像处理依赖
+ libjpeg-dev \
+ libpng-dev \
+ libfreetype6-dev \
+ fonts-dejavu-core \
+ fonts-liberation \
+ # Playwright浏览器依赖
+ libnss3 \
+ libnspr4 \
+ libatk-bridge2.0-0 \
+ libdrm2 \
+ libxkbcommon0 \
+ libxcomposite1 \
+ libxdamage1 \
+ libxrandr2 \
+ libgbm1 \
+ libxss1 \
+ libasound2 \
+ libatspi2.0-0 \
+ libgtk-3-0 \
+ libgdk-pixbuf2.0-0 \
+ libxcursor1 \
+ libxi6 \
+ libxrender1 \
+ libxext6 \
+ libx11-6 \
+ libxft2 \
+ libxinerama1 \
+ libxtst6 \
+ libappindicator3-1 \
+ libx11-xcb1 \
+ libxfixes3 \
+ xdg-utils \
+ chromium \
+ # OpenCV运行时依赖
+ libgl1 \
+ libglib2.0-0 \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/* \
+ && rm -rf /tmp/* \
+ && rm -rf /var/tmp/*
+
+# 设置时区
+RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
+
+# 验证Node.js安装并设置环境变量
+RUN node --version && npm --version
+ENV NODE_PATH=/usr/lib/node_modules
+
+# 复制requirements.txt并安装Python依赖
+COPY requirements.txt .
+RUN pip install --no-cache-dir --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple&& \
+ pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
+
+# 复制项目文件
+COPY . .
+
+# 安装Playwright浏览器(必须在复制项目文件之后)
+RUN playwright install chromium && \
+ playwright install-deps chromium
+
+# 创建必要的目录并设置权限
+RUN mkdir -p /app/logs /app/data /app/backups /app/static/uploads/images && \
+ chmod 777 /app/logs /app/data /app/backups /app/static/uploads /app/static/uploads/images
+
+# 配置系统限制,防止core文件生成
+RUN echo "ulimit -c 0" >> /etc/profile
+
+# 注意: 为了简化权限问题,使用root用户运行
+# 在生产环境中,建议配置适当的用户映射
+
+# 暴露端口
+EXPOSE 8090
+
+# 健康检查
+HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+ CMD curl -f http://localhost:8090/health || exit 1
+
+# 复制启动脚本
+COPY entrypoint.sh /app/entrypoint.sh
+RUN chmod +x /app/entrypoint.sh
+
+# 启动命令
+CMD ["/app/entrypoint.sh"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1c2aa06
--- /dev/null
+++ b/README.md
@@ -0,0 +1,505 @@
+# 🐟 闲鱼管理系统
+
+
+[](https://github.com/GuDong2003/xianyu-auto-reply-fix)
+[](#-快速开始)
+[](https://www.python.org/)
+[](#️-版权声明与使用条款)
+
+## 📋 项目概述
+
+一个功能完整的闲鱼管理系统,采用现代化的技术架构,支持多用户、多账号管理,具备智能回复、自动发货、自动确认发货、商品管理等企业级功能。系统基于Python异步编程,使用FastAPI提供RESTful API,SQLite数据库存储,支持Docker一键部署。
+
+> **⚠️ 重要提示:本项目仅供学习研究使用,严禁商业用途!使用前请仔细阅读[版权声明](#️-版权声明与使用条款)。**
+
+## 🏗️ 技术架构
+
+### 核心技术栈
+- **后端框架**: FastAPI + Uvicorn + Python 3.11+ 异步编程
+- **数据库**: SQLite 3 + 多用户数据隔离 + 自动迁移
+- **前端**: Bootstrap 5 + Vanilla JavaScript + Chart.js + 响应式设计
+- **通信协议**: WebSocket + SSE + RESTful API + 实时通信
+- **自动化能力**: Playwright + DrissionPage + 浏览器自动化
+- **部署方式**: Docker + Docker Compose + Nginx(可选)+ 一键部署
+- **日志系统**: Loguru + 文件轮转 + 实时收集
+- **安全认证**: Bearer Token + 图形验证码 + 邮箱验证 + 权限控制
+
+### 系统架构特点
+- **模块化架构**: 按账号、订单、发货、通知、日志等模块拆分,易于维护和扩展
+- **异步处理**: 基于 asyncio 的高性能异步处理
+- **多用户隔离**: 完整的数据隔离和权限控制
+- **容器化部署**: Docker 容器化部署,支持一键启动
+- **实时监控**: WebSocket + SSE 实时通信和状态监控
+- **稳定性保障**: 自动重连、异常恢复、自动迁移、日志轮转
+
+## ✨ 核心特性
+
+### 🔐 多用户系统
+- **用户注册登录** - 支持邮箱验证码注册、用户名/邮箱登录和图形验证码保护
+- **数据完全隔离** - 每个用户的数据独立存储,互不干扰
+- **权限管理** - 严格的用户权限控制和 Bearer Token 认证
+- **安全保护** - 防暴力破解、会话管理、安全日志
+- **日期校验** - 核心滑块验证模块包含日期有效性校验
+
+### 📱 多账号管理
+- **多账号支持** - 每个用户可管理多个闲鱼账号
+- **独立运行** - 每个账号独立启用、停用和刷新
+- **实时状态** - 账号连接状态和运行配置可实时查看
+- **账号维护** - 支持 Cookie、账密、备注等信息管理
+- **一键擦亮** - 支持在账号列表中一键批量擦亮当前在售商品
+- **定时擦亮** - 支持按账号配置每日执行时段与随机延迟,自动完成商品擦亮
+
+### 🤖 智能回复系统
+- **关键词匹配** - 支持通用关键词和商品专属关键词回复
+- **指定商品回复** - 支持为特定商品设置专门回复内容
+- **批量导入导出** - 支持 Excel 格式的关键词批量导入导出
+- **AI智能回复** - 支持上下文理解和多种兼容模型接口
+- **图片关键词** - 支持图片关键词和图片自动发送
+- **优先级策略** - 指定商品回复 > 商品专用关键词 > 通用关键词 > 默认回复 > AI回复
+
+### 🚚 自动发货功能
+- **智能匹配** - 基于商品信息自动匹配发货规则
+- **多规格支持** - 支持同一商品的不同规格自动匹配
+- **延时发货** - 支持设置发货延时时间
+- **多种触发** - 支持付款消息、小刀卡片等触发条件
+- **防重复处理** - 智能防重复发货和防重复确认
+- **多种发货方式** - 支持文字、批量数据、API、图片等发货方式
+- **发货统计** - 完整的发货记录和统计功能
+
+### 🛍️ 商品管理
+- **自动收集** - 消息触发时自动收集商品信息
+- **商品详情** - 支持获取、查看和编辑商品详情
+- **多规格配置** - 支持多规格商品配置和管理
+- **智能去重** - 自动去重,避免重复存储
+
+### 🧰 运营协同
+- **后台运营** - 提供订单管理、通知渠道、消息通知和在线客服等常用功能,便于统一处理日常运营事务
+
+### 📊 系统监控
+- **实时日志** - 完整的操作日志记录、查看和导出
+- **安全统计** - 支持登录封禁和锁定统计
+- **健康检查** - 服务状态健康检查
+- **系统统计** - 支持用户、账号、卡券等数据统计
+
+### 📁 数据管理
+- **Excel导入导出** - 支持关键词数据的 Excel 导入导出
+- **模板生成** - 自动生成包含示例数据的导入模板
+- **批量操作** - 支持批量添加、更新关键词数据
+- **数据验证** - 导入时自动验证数据格式和重复性
+- **多规格卡券管理** - 支持创建和管理多规格卡券
+- **发货规则管理** - 支持多规格发货规则的创建和管理
+- **数据备份恢复** - 支持用户备份恢复和管理员数据库备份
+- **容器化部署** - 支持 Docker / Docker Compose 持久化部署
+
+## 🚀 快速开始
+
+**⚡ 推荐方式**:使用仓库内置的 Docker Compose 配置从源码构建并启动,和当前项目配置保持一致。
+
+### 方式一:使用部署脚本(推荐)⭐
+
+
+Linux / macOS
+
+```bash
+# 1. 克隆项目
+git clone https://github.com/GuDong2003/xianyu-auto-reply-fix.git
+cd xianyu-auto-reply-fix
+
+# 2. 执行部署脚本
+chmod +x docker-deploy.sh
+./docker-deploy.sh
+```
+
+脚本会自动检查依赖、创建目录、构建镜像并启动服务。
+默认访问地址:
+- `docker-compose.yml`:`http://localhost:9000`
+- `docker-compose-cn.yml`:`http://localhost:8000`
+
+
+
+
+Windows
+
+```cmd
+:: 1. 克隆项目
+git clone https://github.com/GuDong2003/xianyu-auto-reply-fix.git
+cd xianyu-auto-reply-fix
+
+:: 2. 执行部署脚本
+docker-deploy.bat
+```
+
+默认访问地址:
+- `docker-compose.yml`:`http://localhost:9000`
+- `docker-compose-cn.yml`:`http://localhost:8000`
+
+
+
+### 方式二:手动使用 Docker Compose
+
+#### 默认配置
+```bash
+# 1. 克隆项目
+git clone https://github.com/GuDong2003/xianyu-auto-reply-fix.git
+cd xianyu-auto-reply-fix
+
+# 2. 启动服务
+docker compose up -d --build
+
+# 3. 访问系统
+# http://localhost:9000
+```
+
+#### 国内构建配置
+```bash
+# 1. 克隆项目
+git clone https://github.com/GuDong2003/xianyu-auto-reply-fix.git
+cd xianyu-auto-reply-fix
+
+# 2. 启动服务
+docker compose -f docker-compose-cn.yml up -d --build
+
+# 3. 访问系统
+# http://localhost:8000
+```
+
+### 方式三:本地运行
+
+```bash
+# 1. 克隆项目
+git clone https://github.com/GuDong2003/xianyu-auto-reply-fix.git
+cd xianyu-auto-reply-fix
+
+# 2. 创建虚拟环境
+python -m venv venv
+source venv/bin/activate # Linux/macOS
+# 或 venv\Scripts\activate # Windows
+
+# 3. 安装依赖
+pip install --upgrade pip
+pip install -r requirements.txt
+
+# 4. 安装 Playwright 浏览器
+playwright install chromium
+playwright install-deps chromium # 仅 Linux 需要
+
+# 5. 启动系统
+python Start.py
+
+# 6. 访问系统
+# http://localhost:8090
+```
+
+> 本地运行请确保已安装 Node.js,否则 `PyExecJS` 相关功能无法正常使用。
+
+### 📋 环境要求
+
+- **Python**: 3.11+
+- **Node.js**: 16+(用于 PyExecJS 执行 JavaScript)
+- **系统**: Windows / Linux / macOS
+- **架构**: x86_64 (amd64) / ARM64 (aarch64)
+- **Docker**: 20.10+(Docker 部署)
+- **Docker Compose**: 2.0+(Docker 部署)
+- **浏览器依赖**: Playwright Chromium(本地运行需要安装)
+- **资源建议**: 建议 2GB+ 内存,预留 10GB+ 存储空间
+
+### 🖥️ 多架构支持
+
+**支持的架构**:
+- ✅ **linux/amd64** - Intel / AMD 处理器
+- ✅ **linux/arm64** - ARM64 处理器
+
+**构建方式**:
+- ✅ 提供 `build-multi-arch.sh` 多架构构建脚本
+- ✅ 支持使用 Docker Buildx 构建 amd64 / arm64 镜像
+- ✅ Docker 部署和本地运行可在对应架构环境中使用
+
+**说明**:
+- 当前仓库未包含 GitHub Actions 自动构建配置
+- 镜像仓库地址请以实际发布情况为准
+
+### ⚙️ 环境变量配置(可选)
+
+系统实际会读取的环境变量主要包括:
+
+```bash
+# Web 服务
+API_HOST=0.0.0.0
+API_PORT=8090
+
+# 数据存储
+DB_PATH=/app/data/xianyu_data.db
+
+# SQL 日志
+SQL_LOG_ENABLED=true
+SQL_LOG_LEVEL=INFO
+
+# 敏感信息加密
+SECRET_ENCRYPTION_KEY=your-secret-key
+
+# 兼容旧单账号模式(可选)
+COOKIES_STR=your_cookie_string
+
+# Docker 图形模式(可选)
+USE_XVFB=true
+ENABLE_HEADFUL=true
+ENABLE_VNC=false
+DISPLAY=:99
+```
+
+> 其他运行参数(如 WebSocket、心跳、自动回复等)主要在 `global_config.yml` 和 Web 管理界面中配置。
+
+### 🔄 热更新发版
+
+当前仓库的 GitHub Actions 会在 `push` 到 `main` 后读取 `static/version.txt`。如果该版本对应的 Release 还不存在,则会自动生成 `update_files.json` 并创建同名 Release。
+
+自动纳入热更新清单的文件:
+- 任意目录下的 `.py` 文件
+- 任意目录下的 `.html` 文件
+- `static/` 目录下的静态资源,例如 `.js`、`.css`、`.txt`、`.json`、图片和字体文件
+- `static/` 目录下的前端源码文件,例如 `.ts`、`.tsx`、`.jsx`、`.vue`
+
+默认排除的内容:
+- 用户配置和运行时目录,例如 `global_config.yml`、`data/`、`logs/`、`browser_data/`、`update_backup/`、`venv/`
+- 发布和部署文件,例如 `.github/`、`Dockerfile*`、`docker-compose*.yml`、`nginx/`
+- 文档、脚本、数据库和缓存文件,例如 `.md`、`.sh`、`.sql`
+
+建议的发版步骤:
+1. 修改代码或新增需要热更新的文件
+2. 更新 `static/version.txt` 为新的版本号
+3. 执行 `python3 release_precheck.py`,检查是否存在删文件、改名、未跟踪文件或忘记升级版本号的情况
+4. 提交并 `push` 到 `main`
+5. 等待 Action 自动生成 Release 和 `update_files.json`
+
+热更新在覆盖和新增文件之外,还支持通过 manifest 的 `deleted_files` 清理旧文件。删除前会先备份原文件,再执行清理。
+
+
+
+### 🌐 访问系统
+
+部署完成后,您可以通过以下方式访问系统:
+
+- **Web管理界面**:
+ - Docker Compose 默认配置: http://localhost:9000
+ - Docker Compose 国内配置: http://localhost:8000
+ - 本地运行: http://localhost:8090
+- **默认管理员账号(首次初始化且未自定义密码时)**:
+ - 用户名:`admin`
+ - 密码:`admin123`
+- **API文档**:
+ - Docker Compose 默认配置: http://localhost:9000/docs
+ - Docker Compose 国内配置: http://localhost:8000/docs
+ - 本地运行: http://localhost:8090/docs
+- **健康检查**:
+ - Docker Compose 默认配置: http://localhost:9000/health
+ - Docker Compose 国内配置: http://localhost:8000/health
+ - 本地运行: http://localhost:8090/health
+
+> ⚠️ **安全提示**:首次登录后请立即修改默认密码!
+
+
+## 📋 系统使用
+
+### 1. 用户注册
+- 若系统已开启注册功能,可访问登录页或注册页(如本地运行使用 `http://localhost:8090/register.html`)
+- 填写用户信息,完成邮箱验证
+- 根据系统配置完成图形验证码校验
+
+### 2. 添加闲鱼账号
+- 登录系统后进入主界面
+- 进入账号管理,添加新账号
+- 输入账号 ID 和 Cookie 信息
+- 可按需配置用户名、密码、备注和显示浏览器
+- 保存后即可启用或刷新账号
+
+### 3. 使用商品擦亮
+- 在账号管理页可直接执行一键擦亮,批量处理当前账号在售商品
+- 可按账号配置每日定时擦亮任务,支持执行时段和随机延迟
+
+### 4. 配置自动回复
+- **关键词回复**:设置通用关键词和商品专属关键词
+- **AI回复**:配置兼容模型接口启用智能回复
+- **默认回复**:设置未匹配时的默认回复
+- **指定商品回复**:为特定商品设置专门回复内容
+
+### 5. 设置自动发货
+- 添加卡券和发货规则,配置商品与发货内容的匹配关系
+- 支持文本、批量数据、API、图片等发货方式
+- 系统检测到付款消息或小刀卡片后自动触发发货流程
+
+### 6. 安装闲鱼聊天暗色模式脚本(可选)
+本项目提供了一个油猴用户脚本,可为闲鱼官网聊天页面添加暗色模式支持。
+
+**安装步骤**:
+1. 安装油猴扩展(Tampermonkey)到浏览器
+2. 打开 `static/userscripts/goofish-dark-mode.user.js`
+3. 在油猴扩展中创建新脚本并粘贴保存
+4. 访问闲鱼聊天页面后即可生效
+
+**功能特性**:
+- 支持开启 / 关闭 / 跟随系统三种模式
+- 支持通过油猴菜单快速切换状态
+- 提供适配聊天界面的暗色风格
+
+## 🏗️ 系统架构
+
+```text
+┌─────────────────────────────────────────┐
+│ Web 界面 (FastAPI + Static) │
+│ 用户管理 + 功能界面 │
+└───────────────────┬─────────────────────┘
+ │
+┌───────────────────▼─────────────────────┐
+│ CookieManager │
+│ 多账号任务与状态管理 │
+└───────────────────┬─────────────────────┘
+ │
+┌───────────────────▼─────────────────────┐
+│ XianyuLive (多实例) │
+│ WebSocket 连接 + 消息处理 │
+└──────────────┬──────────────┬───────────┘
+ │ │
+┌──────────────▼───────┐ ┌────▼──────────────┐
+│ AIReplyEngine │ │ FileLogCollector │
+│ AI 回复与上下文 │ │ 实时日志与统计 │
+└──────────────┬───────┘ └────┬──────────────┘
+ │ │
+┌──────────────▼──────────────▼───────────┐
+│ SQLite 数据库 │
+│ 用户数据 + 商品信息 + 配置数据 │
+└─────────────────────────────────────────┘
+```
+
+## ⚙️ 配置说明
+
+### 全局配置文件
+`global_config.yml` 包含详细的系统配置,支持:
+- WebSocket连接参数
+- API接口配置
+- 自动回复设置
+- 商品管理配置
+- 日志配置等
+
+## 📊 监控和维护
+
+### 日志管理
+- **实时日志**:Web界面查看实时系统日志
+- **日志文件**:`logs/` 目录下的按日期分割的日志文件
+- **日志级别**:支持DEBUG、INFO、WARNING、ERROR级别
+
+## 🤝 贡献指南
+
+欢迎为项目做出贡献!您可以通过以下方式参与:
+
+### 📝 提交问题
+- 在 [GitHub Issues](https://github.com/GuDong2003/xianyu-auto-reply-fix/issues) 中报告Bug
+- 提出新功能建议和改进意见
+- 分享使用经验和最佳实践
+
+### 🔧 代码贡献
+- Fork 项目到您的GitHub账号
+- 创建功能分支:`git checkout -b feature/your-feature`
+- 提交更改:`git commit -am 'Add some feature'`
+- 推送分支:`git push origin feature/your-feature`
+- 提交 Pull Request
+
+
+## ❓ 常见问题
+
+### 1. 端口被占用
+- **Docker Compose**:修改 `docker-compose.yml` 或 `docker-compose-cn.yml` 中的端口映射
+- **本地运行**:修改 `API_PORT` 环境变量,或调整 `global_config.yml` 中的 `AUTO_REPLY.api.port`
+
+### 2. 数据库连接失败
+检查 `data/` 目录和数据库文件权限,确保应用有读写权限;如使用自定义路径,确认 `DB_PATH` 配置正确。
+
+### 3. WebSocket连接失败
+检查网络和防火墙设置,并确认闲鱼账号 Cookie 仍然有效。
+
+### 4. Shell脚本执行错误(Linux/macOS)
+如果遇到 `bad interpreter` 错误,说明脚本行结束符格式不正确:
+
+```bash
+sed -i 's/\r$//' docker-deploy.sh
+chmod +x docker-deploy.sh
+./docker-deploy.sh
+```
+
+或直接使用:
+
+```bash
+bash docker-deploy.sh
+```
+
+### 5. Docker容器启动失败
+如果遇到 `exec /app/entrypoint.sh: no such file or directory` 错误:
+
+```bash
+docker compose down
+docker compose build --no-cache
+docker compose up -d
+```
+
+### 6. Windows系统部署
+Windows 用户建议直接使用批处理脚本:
+
+```cmd
+docker-deploy.bat
+```
+
+## 📞 技术支持
+
+### 📧 联系方式
+- **问题反馈**:通过 [GitHub Issues](https://github.com/GuDong2003/xianyu-auto-reply-fix/issues) 提交
+- **功能建议**:通过 GitHub Issues 提出改进建议
+- **使用问题**:建议先查看本文档的“常见问题”部分
+
+## 🧸 特别鸣谢
+
+### 开源项目参考
+- **[xianyu-auto-reply](https://github.com/zhinianboke-new/xianyu-auto-reply)** - 提供了原始项目基础与二次开发起点
+- **[XianYuApis](https://github.com/cv-cat/XianYuApis)** - 提供了闲鱼 API 接口的技术参考
+- **[XianyuAutoAgent](https://github.com/shaxiu/XianyuAutoAgent)** - 提供了自动化处理的实现思路
+- **[myfish](https://github.com/Kaguya233qwq/myfish)** - 提供了扫码登录的实现思路
+
+### 开发者支持
+- **[syunnrai123](https://github.com/syunnrai123)** - 为当前项目的滑块处理方案提供思路与参考
+- **[Mangor2021](https://github.com/Mangor2021)** - 为项目开发与改进提供了实际贡献
+- **[82762294](https://github.com/82762294)** - 为项目开发与改进提供了实际贡献
+
+## ⚖️ 版权声明与使用条款
+
+### 📋 重要声明
+
+本项目基于原项目整理和修复,仅供学习与研究使用,请勿用于商业用途或任何违法违规场景。
+
+### 🚫 使用限制
+
+- **禁止商业使用** - 不得将本项目或其衍生内容用于商业用途
+- **禁止违法使用** - 不得将本项目用于任何违法违规活动
+- **禁止滥用服务** - 不得利用本项目进行骚扰、欺诈或其他不当行为
+
+### ✅ 使用说明
+
+- **保留来源信息** - 使用、修改或分发时请保留原项目来源说明
+- **标注修改内容** - 如基于本项目进行了修改,建议明确标注修改部分
+- **自行承担风险** - 使用者需自行承担部署、配置和运行风险
+- **遵守当地法规** - 使用者应确保实际用途符合当地法律法规和平台规则
+
+### 👤 项目来源
+
+- **原项目**:`zhinianboke-new/xianyu-auto-reply`
+- **当前仓库**:`GuDong2003/xianyu-auto-reply-fix`
+
+### ⚠️ 免责声明
+
+本项目按“现状”提供,不提供任何明示或暗示的保证;因使用本项目产生的风险、损失或责任,由使用者自行承担。
+
+## Star History
+
+
+
+
+
+
+
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..3a30ef4
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,41 @@
+# Security Policy
+
+## Supported Versions
+
+目前仅对最新稳定版本提供安全修复支持。
+
+| Version | Supported |
+| ------- | --------- |
+| v1.8.x | :white_check_mark: |
+| < v1.8 | :x: |
+
+## Reporting a Vulnerability
+
+如果你发现了可能影响本项目安全的问题,请不要通过公开 Issue 提交,也不要在公开页面直接披露漏洞细节。
+
+建议优先通过 GitHub 的 Private vulnerability reporting 功能私下提交;如果当前仓库尚未开启该功能,请先联系维护者确认合适的私密反馈方式后再继续披露细节。
+
+提交安全问题时,建议尽量提供以下信息:
+
+- 受影响的版本
+- 问题描述与影响范围
+- 复现步骤
+- 可能的利用条件
+- 脱敏后的日志、截图或请求示例
+
+请不要提交真实的敏感信息,例如:
+
+- Cookie
+- Access Token
+- 账号密码
+- 邮箱验证码
+- 服务器地址或数据库凭据
+
+收到报告后,我会尽量在 7 天内进行初步确认;如果问题被确认成立,会尽快安排修复,并在修复完成后说明受影响版本和处理结果。
+
+以下问题通常不属于安全漏洞范围,建议通过普通 Issue 反馈:
+
+- 平台风控、滑块、人脸验证、二维码验证
+- 账号掉线、Session 失效、Token 过期
+- 部署、配置或使用问题
+- 一般性的功能建议或兼容性问题
diff --git a/Start.py b/Start.py
new file mode 100644
index 0000000..fc4e300
--- /dev/null
+++ b/Start.py
@@ -0,0 +1,595 @@
+"""项目启动入口:
+
+1. 创建 CookieManager,按配置文件 / 环境变量初始化账号任务
+2. 在后台线程启动 FastAPI (reply_server) 提供管理与自动回复接口
+3. 主协程保持运行
+"""
+
+import os
+import sys
+import shutil
+from pathlib import Path
+
+# 设置标准输出编码为UTF-8(Windows兼容)
+def _setup_console_encoding():
+ """设置控制台编码为UTF-8,避免Windows GBK编码问题"""
+ if sys.platform == 'win32':
+ try:
+ # 方法1: 设置环境变量
+ os.environ['PYTHONIOENCODING'] = 'utf-8'
+
+ # 方法2: 尝试设置控制台代码页为UTF-8
+ try:
+ import ctypes
+ kernel32 = ctypes.windll.kernel32
+ kernel32.SetConsoleOutputCP(65001) # UTF-8代码页
+ except Exception:
+ pass
+
+ # 方法3: 重新包装stdout和stderr
+ try:
+ if hasattr(sys.stdout, 'buffer'):
+ import io
+ # 只在编码不是UTF-8时重新包装
+ if sys.stdout.encoding and sys.stdout.encoding.lower() not in ('utf-8', 'utf8'):
+ sys.stdout = io.TextIOWrapper(
+ sys.stdout.buffer,
+ encoding='utf-8',
+ errors='replace',
+ line_buffering=True
+ )
+ if sys.stderr.encoding and sys.stderr.encoding.lower() not in ('utf-8', 'utf8'):
+ sys.stderr = io.TextIOWrapper(
+ sys.stderr.buffer,
+ encoding='utf-8',
+ errors='replace',
+ line_buffering=True
+ )
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+# 在程序启动时设置编码
+_setup_console_encoding()
+
+# 定义ASCII安全字符(备用方案)
+_OK = '[OK]'
+_WARN = '[WARN]'
+_ERROR = '[ERROR]'
+_INFO = '[INFO]'
+
+# ==================== 在导入任何模块之前先迁移数据库 ====================
+def _migrate_database_files_early():
+ """在启动前检查并迁移数据库文件到data目录(使用print,因为logger还未初始化)"""
+ print("检查数据库文件位置...")
+
+ # 确保data目录存在
+ data_dir = Path("data")
+ if not data_dir.exists():
+ data_dir.mkdir(parents=True, exist_ok=True)
+ print(f"{_OK} 创建 data 目录")
+
+ # 定义需要迁移的文件
+ files_to_migrate = [
+ ("xianyu_data.db", "data/xianyu_data.db", "主数据库"),
+ ("user_stats.db", "data/user_stats.db", "统计数据库"),
+ ]
+
+ migrated_files = []
+
+ # 迁移主数据库和统计数据库
+ for old_path, new_path, description in files_to_migrate:
+ old_file = Path(old_path)
+ new_file = Path(new_path)
+
+ if old_file.exists():
+ if not new_file.exists():
+ # 新位置不存在,移动文件
+ try:
+ shutil.move(str(old_file), str(new_file))
+ print(f"{_OK} 迁移{description}: {old_path} -> {new_path}")
+ migrated_files.append(description)
+ except Exception as e:
+ print(f"{_WARN} 无法迁移{description}: {e}")
+ print(f" 尝试复制文件...")
+ try:
+ shutil.copy2(str(old_file), str(new_file))
+ print(f"{_OK} 已复制{description}到新位置")
+ print(f" 请在确认数据正常后手动删除: {old_path}")
+ migrated_files.append(f"{description}(已复制)")
+ except Exception as e2:
+ print(f"{_ERROR} 复制{description}失败: {e2}")
+ else:
+ # 新位置已存在,检查旧文件大小
+ try:
+ if old_file.stat().st_size > 0:
+ print(f"{_WARN} 发现旧{description}文件: {old_path}")
+ print(f" 新数据库位于: {new_path}")
+ print(f" 建议备份后删除旧文件")
+ except:
+ pass
+
+ # 迁移备份文件
+ backup_files = list(Path(".").glob("xianyu_data_backup_*.db"))
+ if backup_files:
+ print(f"发现 {len(backup_files)} 个备份文件")
+ backup_migrated = 0
+ for backup_file in backup_files:
+ new_backup_path = data_dir / backup_file.name
+ if not new_backup_path.exists():
+ try:
+ shutil.move(str(backup_file), str(new_backup_path))
+ print(f"{_OK} 迁移备份文件: {backup_file.name}")
+ backup_migrated += 1
+ except Exception as e:
+ print(f"{_WARN} 无法迁移备份文件 {backup_file.name}: {e}")
+
+ if backup_migrated > 0:
+ migrated_files.append(f"{backup_migrated}个备份文件")
+
+ # 输出迁移总结
+ if migrated_files:
+ print(f"{_OK} 数据库迁移完成,已迁移: {', '.join(migrated_files)}")
+ else:
+ print(f"{_OK} 数据库文件检查完成")
+
+ return True
+
+# 在导入 db_manager 之前先执行数据库迁移
+try:
+ _migrate_database_files_early()
+except Exception as e:
+ print(f"{_WARN} 数据库迁移检查失败: {e}")
+ # 继续启动,因为可能是首次运行
+
+# ==================== 检查并安装Playwright浏览器 ====================
+def _check_and_install_playwright():
+ """检查Playwright浏览器是否存在,如果不存在则自动安装"""
+ print("检查Playwright浏览器...")
+
+ # 检查是否安装了playwright模块
+ try:
+ import playwright
+ except ImportError:
+ print(f"{_WARN} Playwright模块未安装,跳过浏览器检查")
+ return False
+
+ # 检查Playwright浏览器是否存在
+ playwright_installed = False
+ possible_paths = []
+
+ # 如果是打包后的exe,优先检查exe同目录
+ if getattr(sys, 'frozen', False):
+ exe_dir = Path(sys.executable).parent
+ playwright_dir = exe_dir / 'playwright'
+ possible_paths.insert(0, playwright_dir) # 插入到最前面,优先检查
+
+ # 检查exe同目录的浏览器是否完整
+ if playwright_dir.exists():
+ chromium_dirs = list(playwright_dir.glob('chromium-*'))
+ if chromium_dirs:
+ chromium_dir = chromium_dirs[0]
+ chrome_exe = chromium_dir / 'chrome-win' / 'chrome.exe'
+ if chrome_exe.exists() and chrome_exe.stat().st_size > 0:
+ print(f"{_OK} 找到已提取的Playwright浏览器: {chrome_exe}")
+ print(f"{_INFO} 浏览器版本: {chromium_dir.name}")
+ # 清除可能存在的旧环境变量,使用实际存在的浏览器
+ if 'PLAYWRIGHT_BROWSERS_PATH' in os.environ:
+ old_path = os.environ['PLAYWRIGHT_BROWSERS_PATH']
+ if old_path != str(playwright_dir):
+ print(f"{_INFO} 清除旧的环境变量: {old_path}")
+ del os.environ['PLAYWRIGHT_BROWSERS_PATH']
+ # 确保环境变量已设置
+ os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(playwright_dir)
+ print(f"{_INFO} 已设置PLAYWRIGHT_BROWSERS_PATH: {playwright_dir}")
+ playwright_installed = True
+ return True
+
+ # Windows上的常见位置
+ if sys.platform == 'win32':
+ # 用户缓存目录
+ user_cache = Path.home() / '.cache' / 'ms-playwright'
+ possible_paths.append(user_cache)
+
+ # LocalAppData目录
+ local_appdata = os.getenv('LOCALAPPDATA')
+ if local_appdata:
+ possible_paths.append(Path(local_appdata) / 'ms-playwright')
+
+ # AppData目录
+ appdata = os.getenv('APPDATA')
+ if appdata:
+ possible_paths.append(Path(appdata) / 'ms-playwright')
+
+ # 检查是否存在chromium浏览器
+ for path in possible_paths:
+ if path.exists():
+ # 查找chromium目录
+ chromium_dirs = list(path.glob('chromium-*'))
+ if chromium_dirs:
+ for chromium_dir in chromium_dirs:
+ chrome_win = chromium_dir / 'chrome-win'
+ chrome_exe = chrome_win / 'chrome.exe'
+ if chrome_exe.exists():
+ print(f"{_OK} 找到Playwright浏览器: {chrome_exe}")
+ # 设置环境变量
+ os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(path)
+ playwright_installed = True
+ break
+ if playwright_installed:
+ break
+
+ # 如果没找到,尝试使用playwright命令检查
+ if not playwright_installed:
+ try:
+ from playwright.sync_api import sync_playwright
+ with sync_playwright() as p:
+ try:
+ browser = p.chromium.launch(headless=True)
+ browser.close()
+ print(f"{_OK} Playwright浏览器已安装(通过API检测)")
+ playwright_installed = True
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # 如果没找到,先尝试从临时目录提取(如果是打包的exe)
+ if not playwright_installed and getattr(sys, 'frozen', False):
+ try:
+ exe_dir = Path(sys.executable).parent
+ playwright_dir = exe_dir / 'playwright'
+
+ if hasattr(sys, '_MEIPASS'):
+ temp_dir = Path(sys._MEIPASS)
+ temp_playwright = temp_dir / 'playwright'
+
+ if temp_playwright.exists():
+ # 查找所有 chromium 相关目录(包括 chromium-* 和 chromium_headless_shell-*)
+ temp_chromium_dirs = list(temp_playwright.glob('chromium*'))
+ if temp_chromium_dirs:
+ print(f"{_INFO} 检测到打包的浏览器文件,正在提取...")
+ playwright_dir.mkdir(parents=True, exist_ok=True)
+ extracted_count = 0
+
+ for temp_chromium_dir in temp_chromium_dirs:
+ temp_chrome_win = temp_chromium_dir / 'chrome-win'
+
+ # 检查完整版或 headless_shell 版
+ temp_chrome_exe = temp_chrome_win / 'chrome.exe'
+ temp_headless_exe = temp_chrome_win / 'headless_shell.exe'
+
+ # 验证文件是否存在
+ is_valid = False
+ if temp_chromium_dir.name.startswith('chromium_headless_shell'):
+ is_valid = temp_headless_exe.exists() and temp_headless_exe.stat().st_size > 0
+ else:
+ is_valid = temp_chrome_exe.exists() and temp_chrome_exe.stat().st_size > 0
+
+ if is_valid:
+ target_chromium_dir = playwright_dir / temp_chromium_dir.name
+
+ if not target_chromium_dir.exists():
+ try:
+ shutil.copytree(temp_chromium_dir, target_chromium_dir, dirs_exist_ok=True)
+
+ # 验证提取的文件
+ if temp_chromium_dir.name.startswith('chromium_headless_shell'):
+ target_exe = target_chromium_dir / 'chrome-win' / 'headless_shell.exe'
+ else:
+ target_exe = target_chromium_dir / 'chrome-win' / 'chrome.exe'
+
+ if target_exe.exists() and target_exe.stat().st_size > 0:
+ print(f"{_OK} 浏览器文件提取成功: {target_exe}")
+ print(f"{_INFO} 浏览器版本: {temp_chromium_dir.name}")
+ extracted_count += 1
+ except Exception as e:
+ print(f"{_WARN} 提取 {temp_chromium_dir.name} 失败: {e}")
+
+ if extracted_count > 0:
+ # 清除可能存在的旧环境变量
+ if 'PLAYWRIGHT_BROWSERS_PATH' in os.environ:
+ old_path = os.environ['PLAYWRIGHT_BROWSERS_PATH']
+ print(f"{_INFO} 清除旧的环境变量: {old_path}")
+ del os.environ['PLAYWRIGHT_BROWSERS_PATH']
+ # 设置新的环境变量
+ os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(playwright_dir)
+ print(f"{_INFO} 已提取 {extracted_count} 个浏览器版本")
+ print(f"{_INFO} 已设置PLAYWRIGHT_BROWSERS_PATH: {playwright_dir}")
+ playwright_installed = True
+ return True
+ except Exception as e:
+ print(f"{_WARN} 提取浏览器文件时出错: {e}")
+
+ # 如果没找到,尝试安装
+ if not playwright_installed:
+ print(f"{_WARN} 未找到Playwright浏览器,正在自动安装...")
+ print(" 这可能需要几分钟时间,请耐心等待...")
+
+ try:
+ # 方法1: 尝试使用playwright的Python API安装(推荐,适用于打包后的exe)
+ try:
+ # 直接调用playwright的安装函数
+ from playwright._impl._driver import install_driver, install_browsers
+ print(" 正在安装Playwright驱动...")
+ install_driver()
+ print(" 正在安装Chromium浏览器...")
+ install_browsers(['chromium'])
+ print(f"{_OK} Playwright浏览器安装成功(通过API)")
+ playwright_installed = True
+ except ImportError:
+ # 如果API不可用,使用命令行方式
+ print(" 使用命令行方式安装...")
+ import subprocess
+
+ # 尝试使用playwright的安装命令
+ # 对于打包后的exe,playwright模块应该已经包含
+ creation_flags = 0
+ if sys.platform == 'win32' and hasattr(subprocess, 'CREATE_NO_WINDOW'):
+ creation_flags = subprocess.CREATE_NO_WINDOW
+
+ result = subprocess.run(
+ [sys.executable, '-m', 'playwright', 'install', 'chromium'],
+ capture_output=True,
+ text=True,
+ timeout=600, # 10分钟超时
+ creationflags=creation_flags
+ )
+
+ if result.returncode == 0:
+ print(f"{_OK} Playwright浏览器安装成功")
+ playwright_installed = True
+ else:
+ print(f"{_WARN} Playwright浏览器安装失败")
+ if result.stdout:
+ print(f" 输出: {result.stdout[-500:]}") # 只显示最后500字符
+ if result.stderr:
+ print(f" 错误: {result.stderr[-500:]}")
+ print(" 您可以稍后手动运行: playwright install chromium")
+ return False
+ except Exception as api_error:
+ # API安装失败,尝试命令行方式
+ print(f" API安装失败,尝试命令行方式: {api_error}")
+ import subprocess
+
+ creation_flags = 0
+ if sys.platform == 'win32' and hasattr(subprocess, 'CREATE_NO_WINDOW'):
+ creation_flags = subprocess.CREATE_NO_WINDOW
+
+ result = subprocess.run(
+ [sys.executable, '-m', 'playwright', 'install', 'chromium'],
+ capture_output=True,
+ text=True,
+ timeout=600,
+ creationflags=creation_flags
+ )
+
+ if result.returncode == 0:
+ print(f"{_OK} Playwright浏览器安装成功(通过命令行)")
+ playwright_installed = True
+ else:
+ print(f"{_WARN} Playwright浏览器安装失败")
+ if result.stdout:
+ print(f" 输出: {result.stdout[-500:]}")
+ if result.stderr:
+ print(f" 错误: {result.stderr[-500:]}")
+ print(" 您可以稍后手动运行: playwright install chromium")
+ return False
+ except ImportError:
+ # 如果playwright模块不可用,尝试使用subprocess
+ import subprocess
+ result = subprocess.run(
+ [sys.executable, '-m', 'playwright', 'install', 'chromium'],
+ capture_output=True,
+ text=True,
+ timeout=600,
+ creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' and hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
+ )
+
+ if result.returncode == 0:
+ print(f"{_OK} Playwright浏览器安装成功")
+ playwright_installed = True
+ else:
+ print(f"{_WARN} Playwright浏览器安装失败")
+ if result.stdout:
+ print(f" 输出: {result.stdout}")
+ if result.stderr:
+ print(f" 错误: {result.stderr}")
+ print(" 您可以稍后手动运行: playwright install chromium")
+ return False
+
+ except subprocess.TimeoutExpired:
+ print(f"{_WARN} Playwright浏览器安装超时(超过10分钟)")
+ print(" 您可以稍后手动运行: playwright install chromium")
+ return False
+ except Exception as e:
+ print(f"{_WARN} Playwright浏览器安装失败: {e}")
+ import traceback
+ print(f" 详细错误: {traceback.format_exc()}")
+ print(" 您可以稍后手动运行: playwright install chromium")
+ return False
+
+ return playwright_installed
+
+# 检查并安装Playwright浏览器
+try:
+ _check_and_install_playwright()
+except Exception as e:
+ print(f"{_WARN} Playwright浏览器检查失败: {e}")
+ print(" 程序将继续启动,但Playwright功能可能不可用")
+ # 继续启动,不影响主程序运行
+
+# ==================== 现在可以安全地导入其他模块 ====================
+import asyncio
+import threading
+import uvicorn
+from urllib.parse import urlparse
+from loguru import logger
+
+# 修复Linux环境下的asyncio子进程问题
+if sys.platform.startswith('linux'):
+ try:
+ # 在程序启动时就设置正确的事件循环策略
+ asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
+ logger.debug("已设置事件循环策略以支持子进程")
+ except Exception as e:
+ logger.debug(f"设置事件循环策略失败: {e}")
+
+from config import AUTO_REPLY, COOKIES_LIST
+import cookie_manager as cm
+from db_manager import db_manager
+from file_log_collector import setup_file_logging
+
+
+def _start_api_server():
+ """后台线程启动 FastAPI 服务"""
+ api_conf = AUTO_REPLY.get('api', {})
+
+ # 优先使用环境变量配置
+ host = os.getenv('API_HOST', '0.0.0.0') # 默认绑定所有接口
+ port = int(os.getenv('API_PORT', '8090')) # 默认端口8090
+
+ # 如果配置文件中有特定配置,则使用配置文件
+ if 'host' in api_conf:
+ host = api_conf['host']
+ if 'port' in api_conf:
+ port = api_conf['port']
+
+ # 兼容旧的URL配置方式
+ if 'url' in api_conf and 'host' not in api_conf and 'port' not in api_conf:
+ url = api_conf.get('url', 'http://0.0.0.0:8090/xianyu/reply')
+ parsed = urlparse(url)
+ if parsed.hostname and parsed.hostname != 'localhost':
+ host = parsed.hostname
+ port = parsed.port or 8090
+
+ logger.info(f"启动Web服务器: http://{host}:{port}")
+ # 在后台线程中创建独立事件循环并直接运行 server.serve()
+ import uvicorn
+ try:
+ config = uvicorn.Config("reply_server:app", host=host, port=port, log_level="info")
+ server = uvicorn.Server(config)
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ loop.run_until_complete(server.serve())
+ except Exception as e:
+ logger.error(f"uvicorn服务器启动失败: {e}")
+ try:
+ # 确保线程内事件循环被正确关闭
+ loop = asyncio.get_event_loop()
+ if loop.is_running():
+ loop.stop()
+ except Exception:
+ pass
+
+
+
+
+def load_keywords_file(path: str):
+ """从文件读取关键字 -> [(keyword, reply)]"""
+ kw_list = []
+ p = Path(path)
+ if not p.exists():
+ return kw_list
+ with p.open('r', encoding='utf-8') as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+ if '\t' in line:
+ k, r = line.split('\t', 1)
+ elif ' ' in line:
+ k, r = line.split(' ', 1)
+ elif ':' in line:
+ k, r = line.split(':', 1)
+ else:
+ continue
+ kw_list.append((k.strip(), r.strip()))
+ return kw_list
+
+
+async def main():
+ print("开始启动主程序...")
+
+ # 初始化文件日志收集器
+ print("初始化文件日志收集器...")
+ setup_file_logging()
+ logger.info("文件日志收集器已启动,开始收集实时日志")
+
+ loop = asyncio.get_running_loop()
+
+ # 创建 CookieManager 并在全局暴露
+ print("创建 CookieManager...")
+ cm.manager = cm.CookieManager(loop)
+ manager = cm.manager
+ print("CookieManager 创建完成")
+
+ # 1) 从数据库加载的 Cookie 已经在 CookieManager 初始化时完成
+ # 为每个启用的 Cookie 启动任务
+ for cid, val in manager.cookies.items():
+ # 检查账号是否启用
+ if not manager.get_cookie_status(cid):
+ logger.info(f"跳过禁用的 Cookie: {cid}")
+ continue
+
+ try:
+ # 直接启动任务,不重新保存到数据库
+ from db_manager import db_manager
+ logger.info(f"正在获取Cookie详细信息: {cid}")
+ cookie_info = db_manager.get_cookie_details(cid)
+ user_id = cookie_info.get('user_id') if cookie_info else None
+ logger.info(f"Cookie详细信息获取成功: {cid}, user_id: {user_id}")
+
+ logger.info(f"正在创建异步任务: {cid}")
+ task = loop.create_task(manager._run_xianyu(cid, val, user_id))
+ manager.tasks[cid] = task
+ logger.info(f"启动数据库中的 Cookie 任务: {cid} (用户ID: {user_id})")
+ logger.info(f"任务已添加到管理器,当前任务数: {len(manager.tasks)}")
+ except Exception as e:
+ logger.error(f"启动 Cookie 任务失败: {cid}, {e}")
+ import traceback
+ logger.error(f"详细错误信息: {traceback.format_exc()}")
+
+ # 2) 如果配置文件中有新的 Cookie,也加载它们
+ for entry in COOKIES_LIST:
+ cid = entry.get('id')
+ val = entry.get('value')
+ if not cid or not val or cid in manager.cookies:
+ continue
+
+ kw_file = entry.get('keywords_file')
+ kw_list = load_keywords_file(kw_file) if kw_file else None
+ manager.add_cookie(cid, val, kw_list)
+ logger.info(f"从配置文件加载 Cookie: {cid}")
+
+ # 3) 若老环境变量仍提供单账号 Cookie,则作为 default 账号
+ env_cookie = os.getenv('COOKIES_STR')
+ if env_cookie and 'default' not in manager.list_cookies():
+ manager.add_cookie('default', env_cookie)
+ logger.info("从环境变量加载 default Cookie")
+
+ # 启动 API 服务线程
+ print("启动 API 服务线程...")
+ threading.Thread(target=_start_api_server, daemon=True).start()
+ print("API 服务线程已启动")
+
+ # 阻塞保持运行
+ print("主程序启动完成,保持运行...")
+ await asyncio.Event().wait()
+
+
+if __name__ == '__main__':
+ # 避免使用被monkey patch的asyncio.run()
+ # 使用原生的事件循环管理方式
+ loop = asyncio.new_event_loop()
+ try:
+ asyncio.set_event_loop(loop)
+ loop.run_until_complete(main())
+ finally:
+ try:
+ loop.run_until_complete(loop.shutdown_asyncgens())
+ except Exception:
+ pass
+ asyncio.set_event_loop(None)
+ loop.close()
diff --git a/XianyuAutoAsync.py b/XianyuAutoAsync.py
new file mode 100644
index 0000000..dde62ff
--- /dev/null
+++ b/XianyuAutoAsync.py
@@ -0,0 +1,15323 @@
+import asyncio
+import json
+import re
+import time
+import base64
+import hashlib
+import os
+import random
+import secrets
+import threading
+from enum import Enum
+from urllib.parse import parse_qs, urlparse
+from loguru import logger
+import websockets
+from utils.xianyu_utils import (
+ decrypt, generate_mid, generate_uuid, trans_cookies,
+ generate_device_id, generate_sign
+)
+from config import (
+ WEBSOCKET_URL, HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT,
+ TOKEN_REFRESH_INTERVAL, TOKEN_RETRY_INTERVAL,
+ SESSION_KEEPALIVE_INTERVAL, SESSION_KEEPALIVE_RETRY_INTERVAL, COOKIES_STR,
+ LOG_CONFIG, AUTO_REPLY, DEFAULT_HEADERS, WEBSOCKET_HEADERS,
+ APP_CONFIG, API_ENDPOINTS, YIFAN_API
+)
+# from app.logging_config import setup_logging # 已移除,模块不存在
+import sys
+import aiohttp
+from collections import defaultdict, deque
+from typing import Any, Dict, Optional, Tuple
+from db_manager import db_manager
+from utils.notification_dispatcher import (
+ dispatch_account_notifications,
+ format_notification_template,
+ get_notification_template_text,
+ guess_verification_type,
+ render_notification_template,
+)
+
+
+DELIVERY_BATCH_MAX_UNITS = 10
+DELIVERY_BATCH_MAX_CHARS = 1200
+PROTECTED_SESSION_COOKIE_FIELDS = (
+ 'unb',
+ 'sgcookie',
+ 'cookie2',
+ '_m_h5_tk',
+ '_m_h5_tk_enc',
+ 't',
+ 'cna',
+ 'havana_lgc2_77',
+ '_tb_token_',
+)
+REQUIRED_SESSION_COOKIE_FIELDS = (
+ 'unb',
+ 'sgcookie',
+ 'cookie2',
+ '_m_h5_tk',
+ '_m_h5_tk_enc',
+ 't',
+ 'cna',
+)
+
+# 滑块验证补丁已废弃,使用集成的 Playwright 登录方法
+# 不再需要猴子补丁,所有功能已集成到 XianyuSliderStealth 类中
+
+
+# ============ Docker环境兼容工具 ============
+class _DummyChildWatcher:
+ """Docker环境下的虚拟子进程监视器"""
+ def __enter__(self): return self
+ def __exit__(self, *args): pass
+ def is_active(self): return True
+ def add_child_handler(self, *args, **kwargs): pass
+ def remove_child_handler(self, *args, **kwargs): pass
+ def attach_loop(self, *args, **kwargs): pass
+ def close(self): pass
+ def __del__(self): pass
+
+
+class _DockerEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
+ """Docker环境下的自定义事件循环策略"""
+ def get_child_watcher(self):
+ return _DummyChildWatcher()
+
+
+def _is_docker_env() -> bool:
+ """检测是否在Docker环境中运行"""
+ return bool(os.getenv('DOCKER_ENV') or os.path.exists('/.dockerenv'))
+
+
+async def _start_playwright_safe(cookie_id: str = "default"):
+ """安全启动Playwright,兼容Docker环境
+
+ Args:
+ cookie_id: 用于日志标识的账号ID
+
+ Returns:
+ playwright实例,失败返回None
+ """
+ from playwright.async_api import async_playwright
+
+ is_docker = _is_docker_env()
+ old_policy = None
+
+ if is_docker:
+ logger.warning(f"【{cookie_id}】检测到Docker环境,应用asyncio修复")
+ old_policy = asyncio.get_event_loop_policy()
+ asyncio.set_event_loop_policy(_DockerEventLoopPolicy())
+
+ try:
+ playwright = await asyncio.wait_for(
+ async_playwright().start(),
+ timeout=30.0
+ )
+ if is_docker:
+ logger.warning(f"【{cookie_id}】Docker环境下Playwright启动成功")
+ return playwright
+ except asyncio.TimeoutError:
+ logger.error(f"【{cookie_id}】Playwright启动超时")
+ return None
+ finally:
+ if old_policy:
+ asyncio.set_event_loop_policy(old_policy)
+
+
+class ConnectionState(Enum):
+ """WebSocket连接状态枚举"""
+ DISCONNECTED = "disconnected" # 未连接
+ CONNECTING = "connecting" # 连接中
+ CONNECTED = "connected" # 已连接
+ RECONNECTING = "reconnecting" # 重连中
+ FAILED = "failed" # 连接失败
+ CLOSED = "closed" # 已关闭
+
+
+class InitAuthError(Exception):
+ """WebSocket 已建立,但初始化鉴权失败。"""
+
+
+class AutoReplyPauseManager:
+ """自动回复暂停管理器"""
+ def __init__(self):
+ # 存储每个chat_id的暂停信息 {chat_id: pause_until_timestamp}
+ self.paused_chats = {}
+
+ def pause_chat(self, chat_id: str, cookie_id: str):
+ """暂停指定chat_id的自动回复,使用账号特定的暂停时间"""
+ # 获取账号特定的暂停时间
+ try:
+ from db_manager import db_manager
+ pause_minutes = db_manager.get_cookie_pause_duration(cookie_id)
+ except Exception as e:
+ logger.error(f"获取账号 {cookie_id} 暂停时间失败: {e},使用默认10分钟")
+ pause_minutes = 10
+
+ # 如果暂停时间为0,表示不暂停
+ if pause_minutes == 0:
+ logger.info(f"【{cookie_id}】检测到手动发出消息,但暂停时间设置为0,不暂停自动回复")
+ return
+
+ pause_duration_seconds = pause_minutes * 60
+ pause_until = time.time() + pause_duration_seconds
+ self.paused_chats[chat_id] = pause_until
+
+ # 计算暂停结束时间
+ end_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(pause_until))
+ logger.info(f"【{cookie_id}】检测到手动发出消息,chat_id {chat_id} 自动回复暂停{pause_minutes}分钟,恢复时间: {end_time}")
+
+ def is_chat_paused(self, chat_id: str) -> bool:
+ """检查指定chat_id是否处于暂停状态"""
+ if chat_id not in self.paused_chats:
+ return False
+
+ current_time = time.time()
+ pause_until = self.paused_chats[chat_id]
+
+ if current_time >= pause_until:
+ # 暂停时间已过,移除记录
+ del self.paused_chats[chat_id]
+ return False
+
+ return True
+
+ def get_remaining_pause_time(self, chat_id: str) -> int:
+ """获取指定chat_id剩余暂停时间(秒)"""
+ if chat_id not in self.paused_chats:
+ return 0
+
+ current_time = time.time()
+ pause_until = self.paused_chats[chat_id]
+ remaining = max(0, int(pause_until - current_time))
+
+ return remaining
+
+ def cleanup_expired_pauses(self):
+ """清理已过期的暂停记录"""
+ current_time = time.time()
+ expired_chats = [chat_id for chat_id, pause_until in self.paused_chats.items()
+ if current_time >= pause_until]
+
+ for chat_id in expired_chats:
+ del self.paused_chats[chat_id]
+
+
+# 全局暂停管理器实例
+pause_manager = AutoReplyPauseManager()
+
+def log_captcha_event(cookie_id: str, event_type: str, success: bool = None, details: str = ""):
+ """
+ 简单记录滑块验证事件到txt文件
+
+ Args:
+ cookie_id: 账号ID
+ event_type: 事件类型 (检测到/开始处理/成功/失败)
+ success: 是否成功 (None表示进行中)
+ details: 详细信息
+ """
+ try:
+ log_dir = 'logs'
+ os.makedirs(log_dir, exist_ok=True)
+ log_file = os.path.join(log_dir, 'captcha_verification.txt')
+
+ timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
+ status = "成功" if success is True else "失败" if success is False else "进行中"
+
+ log_entry = f"[{timestamp}] 【{cookie_id}】{event_type} - {status}"
+ if details:
+ log_entry += f" - {details}"
+ log_entry += "\n"
+
+ with open(log_file, 'a', encoding='utf-8') as f:
+ f.write(log_entry)
+
+ except Exception as e:
+ logger.error(f"记录滑块验证日志失败: {e}")
+
+# setup_logging(LOG_CONFIG) # 已移除,模块不存在
+
+class XianyuLive:
+ # 类级别的锁字典,为每个order_id维护一个锁(用于自动发货)
+ _order_locks = defaultdict(lambda: asyncio.Lock())
+ # 记录锁的最后使用时间,用于清理
+ _lock_usage_times = {}
+ # 记录锁的持有状态和释放时间 {lock_key: {'locked': bool, 'release_time': float, 'task': asyncio.Task}}
+ _lock_hold_info = {}
+
+ # 独立的锁字典,用于订单详情获取(不使用延迟锁机制)
+ _order_detail_locks = defaultdict(lambda: asyncio.Lock())
+ # 记录订单详情锁的使用时间
+ _order_detail_lock_times = {}
+
+ # 商品详情缓存(24小时有效)
+ _item_detail_cache = {} # {item_id: {'detail': str, 'timestamp': float, 'access_time': float}}
+ _item_detail_cache_lock = asyncio.Lock()
+ _item_detail_cache_max_size = 1000 # 最大缓存1000个商品
+ _item_detail_cache_ttl = 24 * 60 * 60 # 24小时TTL
+
+ # 类级别的实例管理字典,用于API调用
+ _instances = {} # {cookie_id: XianyuLive实例}
+ _instances_lock = asyncio.Lock()
+
+ # 类级别的密码登录时间记录,用于防止重复登录
+ _last_password_login_time = {} # {cookie_id: timestamp}
+ _password_login_cooldown = 60 # 密码登录冷却时间:60秒
+ _password_login_failure_backoff = {} # {cookie_id: {'until': float, 'reason': str, 'seconds': int}}
+
+ # 手动刷新状态:用于避免手动刷新与自动滑块/自动Cookie刷新互相踩踏
+ _manual_refresh_state = {} # {cookie_id: {'source': str, 'phase': str, 'started_at': float, 'previous_cookie_refresh_enabled': Optional[bool]}}
+ _manual_refresh_lock = threading.Lock()
+ _manual_refresh_handoff_ttl = 120 # 刷新交接恢复窗口(秒)
+
+ # 认证恢复锁:同一账号同一时刻只允许一条密码登录恢复链路执行
+ _auth_recovery_locks = {} # {cookie_id: {'owner': str, 'acquired_at': float, 'expires_at': float}}
+ _auth_recovery_lock = threading.Lock()
+ _auth_recovery_lock_ttl = 240
+
+ # 通用预热 token:用于手动刷新/恢复预检成功后的新实例首轮复用
+ _auth_prewarmed_tokens = {} # {cookie_id: {'token': str, 'timestamp': float, 'source': str}}
+ _auth_prewarmed_token_ttl = 180
+
+ # 初始化鉴权失败熔断:区分于 WebSocket 建链失败,避免重连风暴
+ _init_auth_failure_state = {} # {cookie_id: {'count': int, 'window_started_at': float, 'last_failure_at': float, 'last_reason': str, 'circuit_until': float}}
+ _init_auth_failure_lock = threading.Lock()
+ _init_auth_failure_window = 60
+ _init_auth_failure_threshold = 3
+ _init_auth_cooldown = 60
+
+ # 扫码登录token预热缓存,避免扫码成功后正式任务立即再次刷新token
+ _qr_prewarmed_tokens = {} # {cookie_id: {'token': str, 'timestamp': float}}
+ _qr_prewarmed_token_ttl = 180 # 秒
+
+ # 扫码登录后的短期缓冲状态:首轮 token 刷新命中风控时,先做浏览器侧稳定化再决定是否上滑块
+ _qr_login_grace_state = {} # {cookie_id: {'timestamp': float, 'captcha_buffer_used': bool, 'browser_stabilized': bool}}
+ _qr_login_grace_ttl = 180 # 秒
+
+ @classmethod
+ def _cleanup_auth_prewarmed_tokens(cls):
+ """清理过期的通用预热 token 缓存。"""
+ now = time.time()
+ expired_cookie_ids = [
+ cookie_id
+ for cookie_id, token_info in cls._auth_prewarmed_tokens.items()
+ if now - token_info.get('timestamp', 0) > cls._auth_prewarmed_token_ttl
+ ]
+ for cookie_id in expired_cookie_ids:
+ cls._auth_prewarmed_tokens.pop(cookie_id, None)
+
+ @classmethod
+ def cache_auth_prewarmed_token(cls, cookie_id: str, token: str, source: str = 'generic_auth'):
+ """缓存预检成功后的 token,供新实例首轮初始化复用。"""
+ if not cookie_id or not token:
+ return
+ cls._cleanup_auth_prewarmed_tokens()
+ cls._auth_prewarmed_tokens[cookie_id] = {
+ 'token': token,
+ 'timestamp': time.time(),
+ 'source': source,
+ }
+
+ @classmethod
+ def pop_auth_prewarmed_token(cls, cookie_id: str) -> Optional[Dict[str, Any]]:
+ """弹出通用预热 token,过期则忽略。"""
+ if not cookie_id:
+ return None
+ cls._cleanup_auth_prewarmed_tokens()
+ token_info = cls._auth_prewarmed_tokens.pop(cookie_id, None)
+ if not token_info:
+ return None
+ if time.time() - token_info.get('timestamp', 0) > cls._auth_prewarmed_token_ttl:
+ return None
+ return token_info
+
+ @classmethod
+ def clear_auth_prewarmed_token(cls, cookie_id: str):
+ if not cookie_id:
+ return
+ cls._auth_prewarmed_tokens.pop(cookie_id, None)
+
+ @classmethod
+ def _cleanup_qr_prewarmed_tokens(cls):
+ """清理过期的扫码预热token缓存"""
+ now = time.time()
+ expired_cookie_ids = [
+ cookie_id
+ for cookie_id, token_info in cls._qr_prewarmed_tokens.items()
+ if now - token_info.get('timestamp', 0) > cls._qr_prewarmed_token_ttl
+ ]
+ for cookie_id in expired_cookie_ids:
+ cls._qr_prewarmed_tokens.pop(cookie_id, None)
+
+ @classmethod
+ def cache_qr_prewarmed_token(cls, cookie_id: str, token: str):
+ """缓存扫码登录预热好的token,供后续正式实例直接复用"""
+ if not cookie_id or not token:
+ return
+ cls._cleanup_qr_prewarmed_tokens()
+ cls._qr_prewarmed_tokens[cookie_id] = {
+ 'token': token,
+ 'timestamp': time.time()
+ }
+
+ @classmethod
+ def pop_qr_prewarmed_token(cls, cookie_id: str) -> Optional[Dict[str, Any]]:
+ """弹出扫码登录预热token,过期则忽略"""
+ if not cookie_id:
+ return None
+ cls._cleanup_qr_prewarmed_tokens()
+ token_info = cls._qr_prewarmed_tokens.pop(cookie_id, None)
+ if not token_info:
+ return None
+ if time.time() - token_info.get('timestamp', 0) > cls._qr_prewarmed_token_ttl:
+ return None
+ return token_info
+
+ @classmethod
+ def clear_qr_prewarmed_token(cls, cookie_id: str):
+ """清理指定账号的扫码预热token缓存"""
+ if not cookie_id:
+ return
+ cls._qr_prewarmed_tokens.pop(cookie_id, None)
+
+ @classmethod
+ def _cleanup_manual_refresh_state(cls):
+ """清理过期的刷新交接恢复状态。"""
+ now = time.time()
+ expired_cookie_ids = []
+ with cls._manual_refresh_lock:
+ for cookie_id, state in cls._manual_refresh_state.items():
+ if state.get('phase') != 'handoff_recovery':
+ continue
+ expires_at = state.get('expires_at', 0)
+ if expires_at and now > expires_at:
+ expired_cookie_ids.append(cookie_id)
+
+ for cookie_id in expired_cookie_ids:
+ cls._manual_refresh_state.pop(cookie_id, None)
+
+ for cookie_id in expired_cookie_ids:
+ logger.warning(f"【{cookie_id}】刷新交接恢复状态已过期,自动清理")
+
+ @classmethod
+ def get_manual_refresh_state(cls, cookie_id: str) -> Optional[Dict[str, Any]]:
+ if not cookie_id:
+ return None
+ cls._cleanup_manual_refresh_state()
+ with cls._manual_refresh_lock:
+ state = cls._manual_refresh_state.get(cookie_id)
+ return dict(state) if state else None
+
+ @classmethod
+ def mark_manual_refresh_handoff(cls, cookie_id: str, source: str = 'manual_refresh_handoff', ttl: int = None) -> Dict[str, Any]:
+ """将手动刷新状态切换为交接恢复窗口,允许新实例做初始化恢复。"""
+ if not cookie_id:
+ return {'updated': False, 'reason': 'empty_cookie_id'}
+
+ live_instance = cls.get_instance(cookie_id)
+ previous_cookie_refresh_enabled = None
+ if live_instance is not None:
+ previous_cookie_refresh_enabled = live_instance.cookie_refresh_enabled
+
+ now = time.time()
+ expires_at = now + (ttl or cls._manual_refresh_handoff_ttl)
+ with cls._manual_refresh_lock:
+ state = cls._manual_refresh_state.get(cookie_id) or {}
+ state.update({
+ 'source': source,
+ 'phase': 'handoff_recovery',
+ 'started_at': state.get('started_at', now),
+ 'updated_at': now,
+ 'handoff_started_at': now,
+ 'expires_at': expires_at,
+ 'slider_failed_bypass_used': state.get('slider_failed_bypass_used', False),
+ 'previous_cookie_refresh_enabled': state.get('previous_cookie_refresh_enabled', previous_cookie_refresh_enabled),
+ })
+ cls._manual_refresh_state[cookie_id] = state
+
+ logger.warning(
+ f"【{cookie_id}】已进入刷新交接恢复窗口,允许新实例执行初始化恢复 (有效期 {int(expires_at - now)} 秒)"
+ )
+ return {'updated': True, 'phase': 'handoff_recovery', 'expires_at': expires_at}
+
+ @classmethod
+ def consume_manual_refresh_slider_failed_bypass(cls, cookie_id: str) -> bool:
+ if not cookie_id:
+ return False
+ cls._cleanup_manual_refresh_state()
+ with cls._manual_refresh_lock:
+ state = cls._manual_refresh_state.get(cookie_id)
+ if not state or state.get('phase') != 'handoff_recovery':
+ return False
+ if state.get('slider_failed_bypass_used'):
+ return False
+ state['slider_failed_bypass_used'] = True
+ state['updated_at'] = time.time()
+ return True
+
+ @classmethod
+ def _cleanup_auth_recovery_locks(cls):
+ now = time.time()
+ expired_cookie_ids = []
+ with cls._auth_recovery_lock:
+ for cookie_id, state in cls._auth_recovery_locks.items():
+ if now > state.get('expires_at', 0):
+ expired_cookie_ids.append(cookie_id)
+ for cookie_id in expired_cookie_ids:
+ cls._auth_recovery_locks.pop(cookie_id, None)
+
+ @classmethod
+ def acquire_auth_recovery_lock(cls, cookie_id: str, owner: str, ttl: int = None) -> Tuple[bool, Optional[Dict[str, Any]]]:
+ if not cookie_id or not owner:
+ return False, None
+ cls._cleanup_auth_recovery_locks()
+ now = time.time()
+ expires_at = now + (ttl or cls._auth_recovery_lock_ttl)
+ with cls._auth_recovery_lock:
+ existing = cls._auth_recovery_locks.get(cookie_id)
+ if existing and existing.get('owner') != owner and now <= existing.get('expires_at', 0):
+ return False, dict(existing)
+ cls._auth_recovery_locks[cookie_id] = {
+ 'owner': owner,
+ 'acquired_at': now,
+ 'expires_at': expires_at,
+ }
+ return True, None
+
+ @classmethod
+ def release_auth_recovery_lock(cls, cookie_id: str, owner: str = None):
+ if not cookie_id:
+ return
+ with cls._auth_recovery_lock:
+ existing = cls._auth_recovery_locks.get(cookie_id)
+ if not existing:
+ return
+ if owner and existing.get('owner') != owner:
+ return
+ cls._auth_recovery_locks.pop(cookie_id, None)
+
+ @classmethod
+ def get_init_auth_failure_state(cls, cookie_id: str) -> Optional[Dict[str, Any]]:
+ if not cookie_id:
+ return None
+ with cls._init_auth_failure_lock:
+ state = cls._init_auth_failure_state.get(cookie_id)
+ if not state:
+ return None
+ if state.get('circuit_until') and time.time() > state.get('circuit_until', 0):
+ state = {
+ 'count': 0,
+ 'window_started_at': 0,
+ 'last_failure_at': state.get('last_failure_at', 0),
+ 'last_reason': state.get('last_reason'),
+ 'circuit_until': 0,
+ }
+ cls._init_auth_failure_state[cookie_id] = state
+ return dict(state)
+
+ @classmethod
+ def record_init_auth_failure(cls, cookie_id: str, reason: str) -> Dict[str, Any]:
+ now = time.time()
+ with cls._init_auth_failure_lock:
+ state = cls._init_auth_failure_state.get(cookie_id) or {
+ 'count': 0,
+ 'window_started_at': now,
+ 'last_failure_at': 0,
+ 'last_reason': '',
+ 'circuit_until': 0,
+ }
+ window_started_at = state.get('window_started_at', 0)
+ if not window_started_at or (now - window_started_at) > cls._init_auth_failure_window:
+ state['count'] = 0
+ state['window_started_at'] = now
+ state['circuit_until'] = 0
+
+ state['count'] = int(state.get('count', 0)) + 1
+ state['last_failure_at'] = now
+ state['last_reason'] = str(reason or '')
+ if state['count'] >= cls._init_auth_failure_threshold:
+ state['circuit_until'] = now + cls._init_auth_cooldown
+
+ cls._init_auth_failure_state[cookie_id] = state
+ return dict(state)
+
+ @classmethod
+ def clear_init_auth_failure_state(cls, cookie_id: str):
+ if not cookie_id:
+ return
+ with cls._init_auth_failure_lock:
+ cls._init_auth_failure_state.pop(cookie_id, None)
+
+ @classmethod
+ def _cleanup_qr_login_grace_state(cls):
+ """清理过期的扫码登录缓冲状态"""
+ now = time.time()
+ expired_cookie_ids = [
+ cookie_id
+ for cookie_id, state in cls._qr_login_grace_state.items()
+ if now - state.get('timestamp', 0) > cls._qr_login_grace_ttl
+ ]
+ for cookie_id in expired_cookie_ids:
+ cls._qr_login_grace_state.pop(cookie_id, None)
+
+ @classmethod
+ def mark_qr_login_grace(cls, cookie_id: str, **extra_state):
+ """标记账号刚完成扫码登录,后续首轮 token 刷新可走更保守的缓冲分支"""
+ if not cookie_id:
+ return
+ cls._cleanup_qr_login_grace_state()
+ state = {
+ 'timestamp': time.time(),
+ 'captcha_buffer_used': False,
+ 'browser_stabilized': False,
+ }
+ state.update(extra_state)
+ cls._qr_login_grace_state[cookie_id] = state
+
+ @classmethod
+ def get_qr_login_grace(cls, cookie_id: str) -> Optional[Dict[str, Any]]:
+ """获取扫码登录缓冲状态,过期则自动忽略"""
+ if not cookie_id:
+ return None
+ cls._cleanup_qr_login_grace_state()
+ state = cls._qr_login_grace_state.get(cookie_id)
+ if not state:
+ return None
+ if time.time() - state.get('timestamp', 0) > cls._qr_login_grace_ttl:
+ cls._qr_login_grace_state.pop(cookie_id, None)
+ return None
+ return state
+
+ @classmethod
+ def update_qr_login_grace(cls, cookie_id: str, **updates):
+ """更新扫码登录缓冲状态"""
+ state = cls.get_qr_login_grace(cookie_id)
+ if not state:
+ return None
+ state.update(updates)
+ cls._qr_login_grace_state[cookie_id] = state
+ return state
+
+ @classmethod
+ def clear_qr_login_grace(cls, cookie_id: str):
+ """清理指定账号的扫码登录缓冲状态"""
+ if not cookie_id:
+ return
+ cls._qr_login_grace_state.pop(cookie_id, None)
+
+ @classmethod
+ def _cleanup_password_login_failure_backoff(cls):
+ """清理已过期的密码登录失败退避状态"""
+ now = time.time()
+ expired_cookie_ids = [
+ cookie_id
+ for cookie_id, state in cls._password_login_failure_backoff.items()
+ if now >= state.get('until', 0)
+ ]
+ for cookie_id in expired_cookie_ids:
+ cls._password_login_failure_backoff.pop(cookie_id, None)
+
+ @classmethod
+ def get_password_login_failure_backoff(cls, cookie_id: str) -> Optional[Dict[str, Any]]:
+ """获取当前账号的密码登录失败退避状态"""
+ if not cookie_id:
+ return None
+ cls._cleanup_password_login_failure_backoff()
+ return cls._password_login_failure_backoff.get(cookie_id)
+
+ @classmethod
+ def clear_password_login_failure_backoff(cls, cookie_id: str):
+ """清理指定账号的密码登录失败退避状态"""
+ if not cookie_id:
+ return
+ cls._password_login_failure_backoff.pop(cookie_id, None)
+
+ @classmethod
+ def set_password_login_failure_backoff(cls, cookie_id: str, reason: str, seconds: int):
+ """设置密码登录失败后的退避时间"""
+ if not cookie_id or seconds <= 0:
+ return
+ cls._password_login_failure_backoff[cookie_id] = {
+ 'until': time.time() + seconds,
+ 'reason': reason,
+ 'seconds': seconds,
+ 'created_at': time.time(),
+ }
+
+ @staticmethod
+ def classify_password_login_failure(error_message: str) -> Tuple[str, int]:
+ """按失败类型返回(原因标签, 退避秒数)"""
+ message = (error_message or "").lower()
+ if any(keyword in message for keyword in ["账号密码错误", "账密错误", "用户名或密码错误", "密码错误"]):
+ return "credentials", 1800
+ if any(keyword in message for keyword in ["前置滑块", "风控", "拦截", "框体错误", "点击框体重试"]):
+ return "risk_control", 900
+ if any(keyword in message for keyword in ["滑块验证失败", "未找到滑块容器"]):
+ return "slider_failed", 600
+ if any(
+ keyword in message for keyword in [
+ "未找到登录表单",
+ "未找到登录iframe",
+ "session过期且清理会话状态后未找到登录表单",
+ "session验证异常且清理会话状态后未找到登录表单",
+ ]
+ ):
+ return "login_form_missing", 90
+ if any(keyword in message for keyword in ["页面会话已失效", "target page, context or browser has been closed"]):
+ return "unknown", 180
+ if any(keyword in message for keyword in ["网络", "timeout", "cannot connect", "连接", "dns", "ssl"]):
+ return "network", 180
+ return "unknown", 300
+
+ def _safe_str(self, e):
+ """安全地将异常转换为字符串"""
+ try:
+ return str(e)
+ except Exception:
+ try:
+ return repr(e)
+ except Exception:
+ return "未知错误"
+
+ def _mask_secret_value(self, value: str, head: int = 6, tail: int = 4) -> str:
+ text = str(value or '')
+ if not text:
+ return ''
+ if len(text) <= head + tail:
+ return '***'
+ return f"{text[:head]}***{text[-tail:]}"
+
+ def _summarize_cookie_string(self, cookie_string: str) -> str:
+ cookie_string = str(cookie_string or '').strip()
+ if not cookie_string:
+ return 'empty-cookie'
+
+ segments = []
+ for part in cookie_string.split(';'):
+ part = part.strip()
+ if not part:
+ continue
+ if '=' in part:
+ key, value = part.split('=', 1)
+ segments.append(f"{key.strip()}={self._mask_secret_value(value.strip(), head=4, tail=2)}")
+ else:
+ segments.append(self._mask_secret_value(part, head=4, tail=2))
+
+ preview = '; '.join(segments[:6])
+ if len(segments) > 6:
+ preview += f"; ...(+{len(segments) - 6} fields)"
+ return preview
+
+ @staticmethod
+ def _new_risk_session_id(prefix: str = 'risk') -> str:
+ return f"{prefix}_{secrets.token_hex(8)}"
+
+ def _normalize_risk_trigger_scene(self, trigger_reason: str = None, default: str = 'unknown') -> str:
+ text = str(trigger_reason or '').strip()
+ if not text:
+ return default
+ lower_text = text.lower()
+ if 'token' in lower_text or 'session' in lower_text or '令牌' in text:
+ return 'token_refresh'
+ if 'password' in lower_text or '账密' in text or '登录' in text:
+ return 'password_login'
+ if 'cookie' in lower_text or '连接' in text or '失败' in text:
+ return 'auto_cookie_refresh'
+ return default
+
+ def _sanitize_verification_meta(self, verification_url: str = None) -> Dict[str, Any]:
+ text = str(verification_url or '').strip()
+ if not text:
+ return {}
+
+ try:
+ parsed = urlparse(text)
+ if not parsed.scheme and not parsed.netloc:
+ return {'verification_source': text[:120]}
+
+ meta: Dict[str, Any] = {
+ 'verification_host': parsed.netloc or None,
+ 'verification_path': parsed.path or None,
+ }
+ query = parse_qs(parsed.query or '')
+ x5secdata = query.get('x5secdata', [None])[0]
+ if x5secdata:
+ meta['verification_token_hash'] = hashlib.sha256(x5secdata.encode('utf-8')).hexdigest()[:16]
+ action = query.get('action', [None])[0]
+ if action:
+ meta['verification_action'] = action
+ step = query.get('x5step', [None])[0]
+ if step:
+ meta['verification_step'] = step
+ return {key: value for key, value in meta.items() if value is not None}
+ except Exception as e:
+ logger.debug(f"【{self.cookie_id}】解析验证链接失败: {self._safe_str(e)}")
+ return {'verification_source': text[:120]}
+
+ def _build_risk_event_meta(self, trigger_scene: str = None, verification_url: str = None, extra: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
+ payload: Dict[str, Any] = {}
+ if trigger_scene:
+ payload['trigger_scene'] = trigger_scene
+ payload.update(self._sanitize_verification_meta(verification_url))
+ if isinstance(extra, dict):
+ payload.update({key: value for key, value in extra.items() if value is not None})
+ return payload or None
+
+ def _create_risk_log(
+ self,
+ event_type: str,
+ event_description: str,
+ processing_status: str = 'processing',
+ processing_result: str = None,
+ error_message: str = None,
+ session_id: str = None,
+ trigger_scene: str = None,
+ result_code: str = None,
+ event_meta: Optional[Dict[str, Any]] = None,
+ duration_ms: Optional[int] = None,
+ ) -> Optional[int]:
+ try:
+ return db_manager.add_risk_control_log(
+ cookie_id=self.cookie_id,
+ event_type=event_type,
+ session_id=session_id,
+ trigger_scene=trigger_scene,
+ result_code=result_code,
+ event_description=event_description,
+ event_meta=event_meta,
+ processing_result=processing_result,
+ processing_status=processing_status,
+ error_message=error_message,
+ duration_ms=duration_ms,
+ )
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】记录风控日志失败: {self._safe_str(e)}")
+ return None
+
+ def _update_risk_log(
+ self,
+ log_id: Optional[int],
+ *,
+ event_description: str = None,
+ processing_status: str = None,
+ processing_result: str = None,
+ error_message: str = None,
+ session_id: str = None,
+ trigger_scene: str = None,
+ result_code: str = None,
+ event_meta: Optional[Dict[str, Any]] = None,
+ duration_ms: Optional[int] = None,
+ ) -> None:
+ if not log_id:
+ return
+ try:
+ db_manager.update_risk_control_log(
+ log_id=log_id,
+ event_description=event_description,
+ processing_status=processing_status,
+ processing_result=processing_result,
+ error_message=error_message,
+ session_id=session_id,
+ trigger_scene=trigger_scene,
+ result_code=result_code,
+ event_meta=event_meta,
+ duration_ms=duration_ms,
+ )
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】更新风控日志失败: {self._safe_str(e)}")
+
+ @staticmethod
+ def _extract_cookie_value(cookie_info: Optional[Dict[str, Any]]) -> str:
+ """兼容不同调用方返回字段名,提取cookie字符串"""
+ if not cookie_info:
+ return ''
+ return (
+ cookie_info.get('value')
+ or cookie_info.get('cookies_str')
+ or cookie_info.get('cookie_value')
+ or ''
+ )
+
+ def _load_proxy_config(self) -> dict:
+ """从数据库加载当前账号的代理配置"""
+ try:
+ proxy_config = db_manager.get_cookie_proxy_config(self.cookie_id)
+ return proxy_config
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】加载代理配置失败: {e},使用默认配置(无代理)")
+ return {
+ 'proxy_type': 'none',
+ 'proxy_host': '',
+ 'proxy_port': 0,
+ 'proxy_user': '',
+ 'proxy_pass': ''
+ }
+
+ def _get_proxy_url(self) -> str:
+ """根据代理配置生成代理URL
+
+ Returns:
+ 代理URL字符串,如果没有配置代理则返回None
+ """
+ if not self.proxy_config or self.proxy_config.get('proxy_type', 'none') == 'none':
+ return None
+
+ proxy_type = self.proxy_config.get('proxy_type', 'none')
+ proxy_host = self.proxy_config.get('proxy_host', '')
+ proxy_port = self.proxy_config.get('proxy_port', 0)
+ proxy_user = self.proxy_config.get('proxy_user', '')
+ proxy_pass = self.proxy_config.get('proxy_pass', '')
+
+ if not proxy_host or not proxy_port:
+ return None
+
+ # 构建代理URL
+ if proxy_user and proxy_pass:
+ # 带认证的代理
+ proxy_url = f"{proxy_type}://{proxy_user}:{proxy_pass}@{proxy_host}:{proxy_port}"
+ else:
+ # 无认证的代理
+ proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
+
+ return proxy_url
+
+ def _set_connection_state(self, new_state: ConnectionState, reason: str = ""):
+ """设置连接状态并记录日志"""
+ if self.connection_state != new_state:
+ old_state = self.connection_state
+ self.connection_state = new_state
+ self.last_state_change_time = time.time()
+
+ # 记录状态转换
+ state_msg = f"【{self.cookie_id}】连接状态: {old_state.value} → {new_state.value}"
+ if reason:
+ state_msg += f" ({reason})"
+
+ # 根据状态严重程度选择日志级别
+ if new_state == ConnectionState.FAILED:
+ logger.error(state_msg)
+ elif new_state == ConnectionState.RECONNECTING:
+ logger.warning(state_msg)
+ elif new_state == ConnectionState.CONNECTED:
+ logger.success(state_msg)
+ else:
+ logger.info(state_msg)
+
+ async def _interruptible_sleep(self, duration: float):
+ """可中断的sleep,将长时间sleep拆分成多个短时间sleep,以便及时响应取消信号
+
+ Args:
+ duration: 总睡眠时间(秒)
+ """
+ # 将长时间sleep拆分成多个1秒的短sleep,这样可以及时响应取消信号
+ chunk_size = 1.0 # 每次sleep 1秒
+ remaining = duration
+
+ while remaining > 0:
+ sleep_time = min(chunk_size, remaining)
+ try:
+ await asyncio.sleep(sleep_time)
+ remaining -= sleep_time
+ except asyncio.CancelledError:
+ # 如果收到取消信号,立即抛出
+ raise
+
+ def _reset_stream_activity_state(self, connected_at: Optional[float] = None):
+ """重置当前连接的消息流活性状态。"""
+ now = connected_at or time.time()
+ self.last_non_heartbeat_message_time = now
+ self.last_sync_package_time = 0
+ self.last_user_chat_time = 0
+ self.last_heartbeat_response = 0
+ self.last_sent_heartbeat_mid = None
+ self.pending_heartbeat_mids.clear()
+ self.last_stream_watchdog_reconnect_time = 0
+
+ def _mark_non_heartbeat_message(self, received_at: Optional[float] = None, *, is_sync_package: bool = False):
+ """记录最近一次非心跳业务包时间。"""
+ now = received_at or time.time()
+ self.last_non_heartbeat_message_time = now
+ if is_sync_package:
+ self.last_sync_package_time = now
+ if self.stream_watchdog_trigger_times:
+ self.stream_watchdog_trigger_times.clear()
+
+ async def _force_websocket_reconnect(self, reason: str) -> bool:
+ """主动关闭当前WebSocket,让主循环重新建立业务流连接。"""
+ ws = self.ws
+ if not ws:
+ logger.info(f"【{self.cookie_id}】{reason},但当前没有活跃的WebSocket连接")
+ return False
+
+ if getattr(ws, "closed", False):
+ logger.info(f"【{self.cookie_id}】{reason},但当前WebSocket已关闭,等待主循环重连")
+ return False
+
+ self._set_connection_state(ConnectionState.RECONNECTING, reason)
+ logger.warning(f"【{self.cookie_id}】{reason},主动关闭当前WebSocket触发重连")
+ try:
+ await asyncio.wait_for(ws.close(), timeout=2.0)
+ logger.warning(f"【{self.cookie_id}】当前WebSocket已关闭,主循环将使用最新状态重新连接")
+ return True
+ except asyncio.TimeoutError:
+ logger.warning(f"【{self.cookie_id}】主动关闭WebSocket超时,等待主循环自行回收连接")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】主动关闭WebSocket失败: {self._safe_str(e)}")
+ return False
+
+ def _record_message_stream_watchdog_trigger(self, occurred_at: Optional[float] = None) -> int:
+ """记录业务流看门狗触发次数,便于识别重复假在线。"""
+ now = occurred_at or time.time()
+ window_seconds = max(60, int(self.message_stream_notification_window or 0))
+ while self.stream_watchdog_trigger_times and now - self.stream_watchdog_trigger_times[0] > window_seconds:
+ self.stream_watchdog_trigger_times.popleft()
+ self.stream_watchdog_trigger_times.append(now)
+ return len(self.stream_watchdog_trigger_times)
+
+ async def _maybe_notify_message_stream_stale(self, occurred_at: float, connected_for: float, business_idle: float):
+ """仅在短时间重复触发时发送业务流假在线通知,避免单次波动刷屏。"""
+ trigger_count = self._record_message_stream_watchdog_trigger(occurred_at)
+ if trigger_count < 2:
+ return
+
+ window_minutes = max(1, int(self.message_stream_notification_window // 60))
+ sync_desc = (
+ f"最近同步包距今{(occurred_at - self.last_sync_package_time):.0f}秒"
+ if self.last_sync_package_time else
+ "当前连接尚未收到同步包"
+ )
+ user_chat_desc = (
+ f"最近真实买家消息距今{(occurred_at - self.last_user_chat_time):.0f}秒"
+ if self.last_user_chat_time else
+ "当前连接尚未收到真实买家消息"
+ )
+ notification_message = (
+ f"业务消息流疑似假在线,最近{window_minutes}分钟内已连续触发{trigger_count}次自动重连。"
+ f"已连接{connected_for:.0f}秒,最近非心跳业务包距今{business_idle:.0f}秒,"
+ f"{sync_desc},{user_chat_desc}"
+ )
+ await self.send_token_refresh_notification(notification_message, "message_stream_stale")
+
+ async def message_stream_watchdog_loop(self):
+ """检测“只有心跳、没有业务包”的假在线状态。"""
+ heartbeat_stale_timeout = max(self.heartbeat_timeout * 2, self.heartbeat_interval * 3)
+ try:
+ while True:
+ try:
+ from cookie_manager import manager as cookie_manager
+ if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
+ logger.info(f"【{self.cookie_id}】账号已禁用,停止业务流看门狗")
+ break
+
+ await self._interruptible_sleep(self.stream_watchdog_check_interval)
+
+ ws = self.ws
+ if not ws or getattr(ws, "closed", False):
+ continue
+
+ if not self.last_successful_connection:
+ continue
+
+ now = time.time()
+ connected_for = now - self.last_successful_connection
+ if connected_for < self.stream_watchdog_grace_period:
+ continue
+
+ if not self.last_heartbeat_response:
+ continue
+
+ heartbeat_age = now - self.last_heartbeat_response
+ if heartbeat_age > heartbeat_stale_timeout:
+ continue
+
+ last_business_at = self.last_non_heartbeat_message_time or self.last_successful_connection
+ business_idle = now - last_business_at
+ if business_idle < self.message_stream_watchdog_timeout:
+ continue
+
+ if (
+ self.last_stream_watchdog_reconnect_time
+ and now - self.last_stream_watchdog_reconnect_time < self.message_stream_watchdog_timeout / 2
+ ):
+ continue
+
+ self.last_stream_watchdog_reconnect_time = now
+ if self.last_sync_package_time:
+ sync_status = f"最近同步包距今{(now - self.last_sync_package_time):.0f}秒"
+ else:
+ sync_status = "当前连接尚未收到同步包"
+ if self.last_user_chat_time:
+ user_chat_status = f",最近真实买家消息距今{(now - self.last_user_chat_time):.0f}秒"
+ else:
+ user_chat_status = ",当前连接尚未收到真实买家消息"
+
+ logger.warning(
+ f"【{self.cookie_id}】检测到业务流疑似假在线: "
+ f"已连接{connected_for:.0f}秒,最近非心跳业务包距今{business_idle:.0f}秒,{sync_status}{user_chat_status}"
+ )
+ await self._force_websocket_reconnect("业务消息流长时间只有心跳,疑似假在线")
+ await self._maybe_notify_message_stream_stale(now, connected_for, business_idle)
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】业务流看门狗收到取消信号,准备退出")
+ raise
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】业务流看门狗异常: {self._safe_str(e)}")
+ await self._interruptible_sleep(30)
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】业务流看门狗已取消,正在退出...")
+ raise
+ finally:
+ logger.info(f"【{self.cookie_id}】业务流看门狗已退出")
+
+ def _reset_background_tasks(self):
+ """直接重置后台任务引用,不等待取消(用于快速重连)
+
+ 注意:只重置心跳任务,因为只有心跳任务依赖WebSocket连接。
+ 其他任务(会话保活、业务流看门狗、清理、Cookie刷新)不依赖WebSocket,可以继续运行。
+ """
+ logger.info(f"【{self.cookie_id}】准备重置后台任务引用(仅重置依赖WebSocket的任务)...")
+
+ # 只处理心跳任务(依赖WebSocket,需要重启)
+ if self.heartbeat_task:
+ status = "已完成" if self.heartbeat_task.done() else "运行中"
+ logger.info(f"【{self.cookie_id}】发现心跳任务(状态: {status}),需要重置(因为依赖WebSocket连接)")
+ # 尝试取消心跳任务(但不等待)
+ if not self.heartbeat_task.done():
+ try:
+ self.heartbeat_task.cancel()
+ logger.debug(f"【{self.cookie_id}】已发送取消信号给心跳任务(不等待响应)")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】取消心跳任务失败: {e}")
+ # 重置心跳任务引用
+ self.heartbeat_task = None
+ logger.info(f"【{self.cookie_id}】心跳任务引用已重置")
+ else:
+ logger.info(f"【{self.cookie_id}】没有心跳任务需要重置")
+
+ # 检查其他任务的状态(这些任务不依赖WebSocket,不需要重启)
+ other_tasks_status = []
+ if self.token_refresh_task:
+ status = "已完成" if self.token_refresh_task.done() else "运行中"
+ other_tasks_status.append(f"Token刷新任务({status})")
+ if self.cleanup_task:
+ status = "已完成" if self.cleanup_task.done() else "运行中"
+ other_tasks_status.append(f"清理任务({status})")
+ if self.cookie_refresh_task:
+ status = "已完成" if self.cookie_refresh_task.done() else "运行中"
+ other_tasks_status.append(f"Cookie刷新任务({status})")
+ if self.stream_watchdog_task:
+ status = "已完成" if self.stream_watchdog_task.done() else "运行中"
+ other_tasks_status.append(f"业务流看门狗({status})")
+
+ if other_tasks_status:
+ logger.info(f"【{self.cookie_id}】其他任务继续运行(不依赖WebSocket): {', '.join(other_tasks_status)}")
+ else:
+ logger.info(f"【{self.cookie_id}】没有其他任务在运行")
+
+ logger.info(f"【{self.cookie_id}】任务重置完成,可以立即创建新的心跳任务")
+
+ async def _cancel_background_tasks(self):
+ """取消并清理所有后台任务(保留此方法用于程序退出时的完整清理)"""
+ try:
+ tasks_to_cancel = []
+
+ # 收集所有需要取消的任务(只收集未完成的任务)
+ if self.heartbeat_task:
+ if not self.heartbeat_task.done():
+ tasks_to_cancel.append(("心跳任务", self.heartbeat_task))
+ else:
+ logger.debug(f"【{self.cookie_id}】心跳任务已完成,跳过")
+
+ if self.token_refresh_task:
+ if not self.token_refresh_task.done():
+ tasks_to_cancel.append(("Token刷新任务", self.token_refresh_task))
+ else:
+ logger.debug(f"【{self.cookie_id}】Token刷新任务已完成,跳过")
+
+ if self.cleanup_task:
+ if not self.cleanup_task.done():
+ tasks_to_cancel.append(("清理任务", self.cleanup_task))
+ else:
+ logger.debug(f"【{self.cookie_id}】清理任务已完成,跳过")
+
+ if self.cookie_refresh_task:
+ if not self.cookie_refresh_task.done():
+ tasks_to_cancel.append(("Cookie刷新任务", self.cookie_refresh_task))
+ else:
+ logger.debug(f"【{self.cookie_id}】Cookie刷新任务已完成,跳过")
+
+ if self.stream_watchdog_task:
+ if not self.stream_watchdog_task.done():
+ tasks_to_cancel.append(("业务流看门狗", self.stream_watchdog_task))
+ else:
+ logger.debug(f"【{self.cookie_id}】业务流看门狗已完成,跳过")
+
+ if not tasks_to_cancel:
+ logger.info(f"【{self.cookie_id}】没有后台任务需要取消(所有任务已完成或不存在)")
+ # 立即重置任务引用
+ self.heartbeat_task = None
+ self.token_refresh_task = None
+ self.cleanup_task = None
+ self.cookie_refresh_task = None
+ self.stream_watchdog_task = None
+ return
+
+ logger.info(f"【{self.cookie_id}】开始取消 {len(tasks_to_cancel)} 个未完成的后台任务...")
+
+ # 取消所有任务
+ for task_name, task in tasks_to_cancel:
+ try:
+ if task.done():
+ logger.info(f"【{self.cookie_id}】任务已完成,跳过取消: {task_name}")
+ else:
+ task.cancel()
+ logger.info(f"【{self.cookie_id}】已发送取消信号: {task_name}")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】取消任务失败 {task_name}: {e}")
+
+ # 等待所有任务完成取消,使用合理的超时时间
+ # 现在任务中已经添加了 await asyncio.sleep(0) 来让出控制权,应该能够响应取消信号
+ tasks = [task for _, task in tasks_to_cancel]
+ logger.info(f"【{self.cookie_id}】等待 {len(tasks)} 个任务响应取消信号...")
+
+ wait_timeout = 5.0 # 增加超时时间到5秒,给任务更多时间响应取消信号
+
+ start_time = time.time()
+ try:
+ # 只等待未完成的任务
+ pending_tasks_list = [task for task in tasks if not task.done()]
+
+ # 记录每个任务的状态
+ for task_name, task in tasks_to_cancel:
+ status = "已完成" if task.done() else "运行中"
+ logger.info(f"【{self.cookie_id}】任务状态: {task_name} - {status}")
+
+ if not pending_tasks_list:
+ logger.info(f"【{self.cookie_id}】所有任务已完成,无需等待")
+ else:
+ logger.info(f"【{self.cookie_id}】等待 {len(pending_tasks_list)} 个未完成任务响应(超时时间: {wait_timeout}秒)...")
+ try:
+ # 使用 wait 等待任务完成,设置超时
+ logger.debug(f"【{self.cookie_id}】开始调用 asyncio.wait()...")
+ done, pending = await asyncio.wait(
+ pending_tasks_list,
+ timeout=wait_timeout,
+ return_when=asyncio.ALL_COMPLETED
+ )
+ elapsed = time.time() - start_time
+ logger.info(f"【{self.cookie_id}】asyncio.wait() 返回,耗时 {elapsed:.3f}秒,已完成: {len(done)},未完成: {len(pending)}")
+
+ # 检查已完成的任务,并记录详细信息
+ for task_name, task in tasks_to_cancel:
+ if task in done:
+ try:
+ task.result()
+ logger.warning(f"【{self.cookie_id}】⚠️ 任务正常完成(非取消): {task_name}")
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】✅ 任务已成功取消: {task_name}")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】⚠️ 任务取消时出现异常 {task_name}: {e}")
+
+ if pending:
+ # 找出未完成的任务名称和详细信息
+ pending_names = []
+ for task_name, task in tasks_to_cancel:
+ if task in pending:
+ pending_names.append(task_name)
+ # 记录未完成任务的状态
+ if task.done():
+ try:
+ task.result()
+ logger.warning(f"【{self.cookie_id}】任务在等待期间完成: {task_name}")
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】任务在等待期间被取消: {task_name}")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】任务在等待期间异常 {task_name}: {e}")
+ else:
+ logger.warning(f"【{self.cookie_id}】任务仍未完成: {task_name} (done={task.done()})")
+
+ logger.warning(f"【{self.cookie_id}】等待超时 ({elapsed:.3f}秒),以下任务可能仍在运行: {', '.join(pending_names)}")
+
+ # 强制取消所有未完成的任务(再次尝试)
+ for task_name, task in tasks_to_cancel:
+ if task in pending and not task.done():
+ try:
+ task.cancel()
+ logger.warning(f"【{self.cookie_id}】强制取消任务: {task_name}")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】强制取消任务失败 {task_name}: {e}")
+
+ # 再等待一小段时间,看是否有任务响应
+ if pending:
+ try:
+ done2, pending2 = await asyncio.wait(pending, timeout=1.0, return_when=asyncio.ALL_COMPLETED)
+ for task_name, task in tasks_to_cancel:
+ if task in done2:
+ try:
+ task.result()
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】任务在二次等待期间被取消: {task_name}")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】任务在二次等待期间异常 {task_name}: {e}")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】二次等待任务时出错: {e}")
+
+ logger.warning(f"【{self.cookie_id}】强制继续重连流程,未完成的任务将在后台继续运行(但已标记为取消)")
+ else:
+ logger.info(f"【{self.cookie_id}】所有后台任务已取消 (耗时 {elapsed:.3f}秒)")
+
+ except Exception as e:
+ elapsed = time.time() - start_time
+ logger.warning(f"【{self.cookie_id}】等待任务时出错 (耗时 {elapsed:.3f}秒): {e}")
+ import traceback
+ logger.warning(f"【{self.cookie_id}】等待任务异常堆栈:\n{traceback.format_exc()}")
+
+ except Exception as e:
+ elapsed = time.time() - start_time
+ logger.error(f"【{self.cookie_id}】等待任务取消时出错 (耗时 {elapsed:.3f}秒): {e}")
+ import traceback
+ logger.error(f"【{self.cookie_id}】等待任务取消异常堆栈:\n{traceback.format_exc()}")
+
+ logger.info(f"【{self.cookie_id}】任务取消流程完成,继续重连流程")
+
+ # 最后检查一次所有任务的状态
+ for task_name, task in tasks_to_cancel:
+ if task and not task.done():
+ logger.warning(f"【{self.cookie_id}】⚠️ 任务取消流程完成后,任务仍未完成: {task_name} (done={task.done()})")
+ elif task and task.done():
+ logger.debug(f"【{self.cookie_id}】✅ 任务已完成: {task_name}")
+
+ finally:
+ # 使用 finally 确保无论发生什么情况都会重置任务引用
+ # 这样可以保证下次重连时所有任务都会被重新创建
+ self.heartbeat_task = None
+ self.token_refresh_task = None
+ self.cleanup_task = None
+ self.cookie_refresh_task = None
+ self.stream_watchdog_task = None
+ logger.info(f"【{self.cookie_id}】后台任务引用已全部重置")
+
+ def _calculate_retry_delay(self, error_msg: str) -> int:
+ """根据错误类型和失败次数计算重试延迟"""
+ # WebSocket意外断开 - 短延迟
+ if "no close frame received or sent" in error_msg:
+ return min(3 * self.connection_failures, 15)
+
+ # 网络连接问题 - 长延迟
+ elif "Connection refused" in error_msg or "timeout" in error_msg.lower():
+ return min(10 * self.connection_failures, 60)
+
+ # 其他未知错误 - 中等延迟
+ else:
+ return min(5 * self.connection_failures, 30)
+
+ def _cleanup_instance_caches(self):
+ """清理实例级别的缓存,防止内存泄漏"""
+ try:
+ current_time = time.time()
+ cleaned_total = 0
+
+ # 清理过期的通知记录(保留30分钟内的,从1小时优化)
+ max_notification_age = 1800 # 30分钟(从3600优化)
+ expired_notifications = [
+ key for key, last_time in self.last_notification_time.items()
+ if current_time - last_time > max_notification_age
+ ]
+ for key in expired_notifications:
+ del self.last_notification_time[key]
+ if expired_notifications:
+ cleaned_total += len(expired_notifications)
+ logger.warning(f"【{self.cookie_id}】清理了 {len(expired_notifications)} 个过期通知记录")
+
+ # 清理过期的发货记录(保留30分钟内的)
+ max_delivery_age = 1800 # 30分钟
+ expired_deliveries = [
+ order_id for order_id, last_time in self.last_delivery_time.items()
+ if current_time - last_time > max_delivery_age
+ ]
+ for order_id in expired_deliveries:
+ del self.last_delivery_time[order_id]
+ if expired_deliveries:
+ cleaned_total += len(expired_deliveries)
+ logger.warning(f"【{self.cookie_id}】清理了 {len(expired_deliveries)} 个过期发货记录")
+
+ # 清理过期的订单确认记录(保留30分钟内的)
+ max_confirm_age = 1800 # 30分钟
+ expired_confirms = [
+ order_id for order_id, last_time in self.confirmed_orders.items()
+ if current_time - last_time > max_confirm_age
+ ]
+ for order_id in expired_confirms:
+ del self.confirmed_orders[order_id]
+ if expired_confirms:
+ cleaned_total += len(expired_confirms)
+ logger.warning(f"【{self.cookie_id}】清理了 {len(expired_confirms)} 个过期订单确认记录")
+
+ # 只有实际清理了内容才记录总数日志
+ if cleaned_total > 0:
+ logger.info(f"【{self.cookie_id}】实例缓存清理完成,共清理 {cleaned_total} 条记录")
+ logger.warning(f"【{self.cookie_id}】当前缓存数量 - 通知: {len(self.last_notification_time)}, 发货: {len(self.last_delivery_time)}, 确认: {len(self.confirmed_orders)}")
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】清理实例缓存时出错: {self._safe_str(e)}")
+
+ async def _cleanup_playwright_cache(self):
+ """清理Playwright浏览器临时文件和缓存(Docker环境专用)"""
+ try:
+ import shutil
+ import glob
+
+ # 定义需要清理的临时目录路径
+ temp_paths = [
+ '/tmp/playwright-*', # Playwright临时会话
+ '/tmp/chromium-*', # Chromium临时文件
+ '/ms-playwright/chromium-*/Default/Cache', # 浏览器缓存
+ '/ms-playwright/chromium-*/Default/Code Cache', # 代码缓存
+ '/ms-playwright/chromium-*/Default/GPUCache', # GPU缓存
+ ]
+
+ total_cleaned = 0
+ total_size_mb = 0
+
+ for pattern in temp_paths:
+ try:
+ matching_paths = glob.glob(pattern)
+ for path in matching_paths:
+ try:
+ if os.path.exists(path):
+ # 计算大小
+ if os.path.isdir(path):
+ size = sum(
+ os.path.getsize(os.path.join(dirpath, filename))
+ for dirpath, _, filenames in os.walk(path)
+ for filename in filenames
+ )
+ shutil.rmtree(path, ignore_errors=True)
+ else:
+ size = os.path.getsize(path)
+ os.remove(path)
+
+ total_size_mb += size / (1024 * 1024)
+ total_cleaned += 1
+ except Exception as e:
+ logger.warning(f"清理路径 {path} 时出错: {e}")
+ except Exception as e:
+ logger.warning(f"匹配路径 {pattern} 时出错: {e}")
+
+ if total_cleaned > 0:
+ logger.info(f"【{self.cookie_id}】Playwright缓存清理完成: 删除了 {total_cleaned} 个文件/目录,释放 {total_size_mb:.2f} MB")
+ else:
+ logger.warning(f"【{self.cookie_id}】Playwright缓存清理: 没有需要清理的临时文件")
+
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】清理Playwright缓存时出错: {self._safe_str(e)}")
+
+ async def _cleanup_old_logs(self, retention_days: int = 7):
+ """清理过期的日志文件
+
+ Args:
+ retention_days: 保留的天数,默认7天
+
+ Returns:
+ 清理的文件数量
+ """
+ try:
+ import glob
+ from datetime import datetime, timedelta
+
+ logs_dir = "logs"
+ if not os.path.exists(logs_dir):
+ logger.warning(f"【{self.cookie_id}】日志目录不存在: {logs_dir}")
+ return 0
+
+ # 计算过期时间点
+ cutoff_time = datetime.now() - timedelta(days=retention_days)
+
+ # 查找所有日志文件(包括.log和.log.zip)
+ log_patterns = [
+ os.path.join(logs_dir, "xianyu_*.log"),
+ os.path.join(logs_dir, "xianyu_*.log.zip"),
+ os.path.join(logs_dir, "app_*.log"),
+ os.path.join(logs_dir, "app_*.log.zip"),
+ ]
+
+ total_cleaned = 0
+ total_size_mb = 0
+
+ for pattern in log_patterns:
+ log_files = glob.glob(pattern)
+ for log_file in log_files:
+ try:
+ # 获取文件修改时间
+ file_mtime = datetime.fromtimestamp(os.path.getmtime(log_file))
+
+ # 如果文件早于保留期限,则删除
+ if file_mtime < cutoff_time:
+ file_size = os.path.getsize(log_file)
+ os.remove(log_file)
+ total_size_mb += file_size / (1024 * 1024)
+ total_cleaned += 1
+ logger.debug(f"【{self.cookie_id}】删除过期日志文件: {log_file} (修改时间: {file_mtime})")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】删除日志文件失败 {log_file}: {self._safe_str(e)}")
+
+ if total_cleaned > 0:
+ logger.info(f"【{self.cookie_id}】日志清理完成: 删除了 {total_cleaned} 个日志文件,释放 {total_size_mb:.2f} MB (保留 {retention_days} 天内的日志)")
+ else:
+ logger.debug(f"【{self.cookie_id}】日志清理: 没有需要清理的过期日志文件 (保留 {retention_days} 天)")
+
+ return total_cleaned
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】清理日志文件时出错: {self._safe_str(e)}")
+ return 0
+
+ def __init__(self, cookies_str=None, cookie_id: str = "default", user_id: int = None, *, register_instance: bool = True):
+ """初始化闲鱼直播类"""
+ logger.info(f"【{cookie_id}】开始初始化XianyuLive...")
+
+ if not cookies_str:
+ cookies_str = COOKIES_STR
+ if not cookies_str:
+ raise ValueError("未提供cookies,请在global_config.yml中配置COOKIES_STR或通过参数传入")
+
+ logger.info(f"【{cookie_id}】解析cookies...")
+ self.cookies = trans_cookies(cookies_str)
+ logger.info(f"【{cookie_id}】cookies解析完成,包含字段: {list(self.cookies.keys())}")
+
+ self.cookie_id = cookie_id # 唯一账号标识
+ self.cookies_str = cookies_str # 保存原始cookie字符串
+ self.user_id = user_id # 保存用户ID,用于token刷新时保持正确的所有者关系
+ self.register_instance = bool(register_instance)
+ self.base_url = WEBSOCKET_URL
+
+ if 'unb' not in self.cookies:
+ raise ValueError(f"【{cookie_id}】Cookie中缺少必需的'unb'字段,当前字段: {list(self.cookies.keys())}")
+
+ self.myid = self.cookies['unb']
+ logger.info(f"【{cookie_id}】用户ID: {self.myid}")
+ self.device_id = generate_device_id(self.myid)
+
+ # 心跳相关配置
+ self.heartbeat_interval = HEARTBEAT_INTERVAL
+ self.heartbeat_timeout = HEARTBEAT_TIMEOUT
+ self.last_heartbeat_time = 0
+ self.last_heartbeat_response = 0
+ self.last_sent_heartbeat_mid = None
+ self.pending_heartbeat_mids = deque(maxlen=32)
+ self.heartbeat_task = None
+ self.ws = None
+ self.last_non_heartbeat_message_time = 0
+ self.last_sync_package_time = 0
+ self.last_user_chat_time = 0
+ self.last_stream_watchdog_reconnect_time = 0
+
+ # Token刷新相关配置
+ self.token_refresh_interval = TOKEN_REFRESH_INTERVAL
+ self.token_retry_interval = TOKEN_RETRY_INTERVAL
+ self.session_keepalive_interval = SESSION_KEEPALIVE_INTERVAL
+ self.session_keepalive_retry_interval = SESSION_KEEPALIVE_RETRY_INTERVAL
+ self.last_token_refresh_time = 0
+ self.last_session_keepalive_time = 0
+ self.current_token = None
+ self.token_refresh_task = None
+ self.last_token_refresh_status = None # Token刷新状态追踪
+ self.last_token_refresh_error_message = None # Token刷新失败详情,供通知文案分流
+ self.last_session_keepalive_status = None
+ self.last_session_keepalive_error_message = None
+ self.pending_slider_success_notice = None # 滑块成功后的延迟成功通知,避免会话未恢复时误报
+ self.connection_restart_flag = False # 连接重启标志
+ self.last_init_failure_reason = None
+ self.last_init_failure_type = None
+ self.init_auth_failures = 0
+ self.stream_watchdog_task = None
+ self.stream_watchdog_check_interval = max(self.heartbeat_interval, 15)
+ self.stream_watchdog_grace_period = max(self.heartbeat_interval * 4, 120)
+ self.message_stream_watchdog_timeout = max(self.session_keepalive_interval * 3, 1800)
+ self.stream_watchdog_trigger_times = deque(maxlen=8)
+ self.message_stream_notification_window = max(self.message_stream_watchdog_timeout * 2, 3600)
+ self.message_stream_notification_cooldown = max(self.message_stream_watchdog_timeout, 1800)
+
+ prewarmed_token_info = self.pop_auth_prewarmed_token(self.cookie_id)
+ if prewarmed_token_info:
+ self.current_token = prewarmed_token_info.get('token')
+ self.last_token_refresh_time = prewarmed_token_info.get('timestamp', time.time())
+ logger.info(
+ f"【{cookie_id}】已复用认证预热token,来源: {prewarmed_token_info.get('source') or 'unknown'}"
+ )
+
+ prewarmed_token_info = self.pop_qr_prewarmed_token(self.cookie_id)
+ if prewarmed_token_info and not self.current_token:
+ self.current_token = prewarmed_token_info.get('token')
+ self.last_token_refresh_time = prewarmed_token_info.get('timestamp', time.time())
+ logger.info(f"【{cookie_id}】已复用扫码预热token,跳过首次token刷新")
+
+ # 通知防重复机制
+ self.last_notification_time = {} # 记录每种通知类型的最后发送时间
+ self.notification_cooldown = 300 # 5分钟内不重复发送相同类型的通知
+ self.token_refresh_notification_cooldown = 18000 # Token刷新异常通知冷却时间:3小时
+ self.notification_lock = asyncio.Lock() # 通知防重复机制的异步锁
+ self.pending_notification_keys = set() # 记录发送中的通知,避免并发重复发送
+
+ # 自动发货防重复机制
+ self.last_delivery_time = {} # 记录每个商品的最后发货时间
+ self.delivery_cooldown = 600 # 10分钟内不重复发货
+
+ # 自动确认发货防重复机制
+ self.confirmed_orders = {} # 记录已确认发货的订单,防止重复确认
+ self.order_confirm_cooldown = 600 # 10分钟内不重复确认同一订单
+
+ # 自动发货已发送订单记录
+ self.delivery_sent_orders = set() # 记录已发货的订单ID,防止重复发货
+
+ self.session = None # 用于API调用的aiohttp session
+
+ # 代理配置 - 从数据库加载
+ self.proxy_config = self._load_proxy_config()
+ if self.proxy_config.get('proxy_type', 'none') != 'none':
+ logger.info(f"【{cookie_id}】已加载代理配置: {self.proxy_config['proxy_type']}://{self.proxy_config['proxy_host']}:{self.proxy_config['proxy_port']}")
+
+ # 启动定期清理过期暂停记录的任务
+ self.cleanup_task = None
+
+ # Cookie刷新定时任务
+ self.cookie_refresh_task = None
+ self.cookie_refresh_interval = 10800 # 3小时 = 10800秒
+ self.last_cookie_refresh_time = 0
+ self.cookie_refresh_lock = asyncio.Lock() # 使用Lock防止重复执行Cookie刷新
+ self.cookie_refresh_enabled = True # 是否启用Cookie刷新功能
+
+ # 扫码登录Cookie刷新标志
+ self.last_qr_cookie_refresh_time = 0 # 记录上次扫码登录Cookie刷新时间
+ self.qr_cookie_refresh_cooldown = 600 # 扫码登录Cookie刷新后的冷却时间:10分钟
+
+ # 消息接收标识 - 用于控制Cookie刷新
+ self.last_message_received_time = 0 # 记录上次收到消息的时间
+ self.message_cookie_refresh_cooldown = 300 # 收到消息后5分钟内不执行Cookie刷新
+
+ # 浏览器Cookie刷新成功标志
+ self.browser_cookie_refreshed = False # 标记_refresh_cookies_via_browser是否成功更新过数据库
+ self.restarted_in_browser_refresh = False # 刷新流程内部是否已触发重启(用于去重)
+
+
+ # 滑块验证相关
+ self.captcha_verification_count = 0 # 滑块验证次数计数器
+ self.max_captcha_verification_count = 3 # 最大滑块验证次数,防止无限递归
+ self.last_slider_success_at = 0.0
+ self.last_slider_success_cookie_length = 0
+ self.slider_success_reentry_window = 30
+ self.post_slider_token_retry_delay = (1.5, 3.0)
+ self.token_refresh_lock = asyncio.Lock() # 防止多个入口并发刷新 token
+
+ # WebSocket连接监控
+ self.connection_state = ConnectionState.DISCONNECTED # 连接状态
+ self.connection_failures = 0 # 连续连接失败次数
+ self.max_connection_failures = 5 # 最大连续失败次数
+ self.last_successful_connection = 0 # 上次成功连接时间
+ self.last_state_change_time = time.time() # 上次状态变化时间
+
+ # 后台任务追踪(用于清理未等待的任务)
+ self.background_tasks = set() # 追踪所有后台任务
+
+ # 消息处理并发控制(防止内存泄漏)
+ self.message_semaphore = asyncio.Semaphore(100) # 最多100个并发消息处理任务
+ self.active_message_tasks = 0 # 当前活跃的消息处理任务数
+
+ # ============ 高性能消息队列系统 ============
+ # 消息队列配置
+ self.message_queue_enabled = True # 是否启用消息队列系统
+ self.message_queue_max_size = 1000 # 消息队列最大容量
+ self.message_queue_workers = 5 # 消息处理工作协程数量
+ self.message_expire_seconds = 60 # 消息过期时间(秒),超过此时间的消息将被丢弃
+
+ # 消息优先级队列(使用优先级队列实现高优先级消息先处理)
+ # 优先级: 0=最高(心跳/ACK), 1=高(订单消息), 2=中(聊天消息), 3=低(其他)
+ self.message_queue = asyncio.PriorityQueue(maxsize=self.message_queue_max_size)
+ self.message_queue_counter = 0 # 用于保证FIFO顺序的计数器
+ self.message_queue_lock = asyncio.Lock()
+
+ # 工作协程管理
+ self.message_workers = [] # 工作协程列表
+ self.message_queue_running = False # 队列系统运行状态
+
+ # 队列监控统计
+ self.queue_stats = {
+ 'received': 0, # 收到的消息总数
+ 'processed': 0, # 处理的消息数
+ 'dropped_full': 0, # 因队列满而丢弃的消息数
+ 'dropped_expired': 0, # 因过期而丢弃的消息数
+ 'errors': 0, # 处理错误数
+ 'last_stats_time': time.time(), # 上次统计时间
+ }
+
+ # 亦凡卡劵账号充值确认流程状态管理
+ self.yifan_account_waiting = {} # 等待账号输入的订单: {chat_id: {buyer_id, rule, order_id, item_id, state, account, create_time}}
+ self.yifan_account_lock = asyncio.Lock() # 状态管理锁
+
+ # 消息防抖管理器:用于处理用户连续发送消息的情况
+ # {chat_id: {'task': asyncio.Task, 'last_message': dict, 'timer': float}}
+ self.message_debounce_tasks = {} # 存储每个chat_id的防抖任务
+ self._message_debounce_delay = 3 # 防抖延迟默认值(秒),实际值通过property从数据库动态读取
+ self.message_debounce_lock = asyncio.Lock() # 防抖任务管理的锁
+
+ # 消息去重机制:防止同一条消息被处理多次
+ self.processed_message_ids = {} # 存储已处理的消息ID和时间戳 {message_id: timestamp}
+ self.processed_message_ids_lock = asyncio.Lock() # 消息ID去重的锁
+ self.processed_message_ids_max_size = 10000 # 最大保存10000个消息ID,防止内存泄漏
+ self.message_expire_time = 3600 # 消息过期时间(秒),默认1小时后可以重复回复
+
+ # 订单详情补抓任务:详情首次超时时,后台再补抓一次,避免整单丢失
+ self.order_detail_retry_tasks = {}
+ self.order_detail_force_refresh_marks = {}
+ self.order_detail_force_refresh_cooldown = 5
+
+ # 初始化订单状态处理器
+ self._init_order_status_handler()
+
+ # 只有长期运行实例才进入全局实例表,避免临时实例污染运行态诊断
+ if self.register_instance:
+ self._register_instance()
+
+ @property
+ def message_debounce_delay(self):
+ """动态从数据库读取防抖延迟配置,修改后无需重启"""
+ try:
+ from db_manager import db_manager
+ val = db_manager.get_system_setting('message_debounce_delay')
+ return int(val) if val else self._message_debounce_delay
+ except Exception:
+ return self._message_debounce_delay
+
+ def _init_order_status_handler(self):
+ """初始化订单状态处理器"""
+ try:
+ # 直接导入订单状态处理器
+ from order_status_handler import order_status_handler
+ self.order_status_handler = order_status_handler
+ logger.info(f"【{self.cookie_id}】订单状态处理器已启用")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】初始化订单状态处理器失败: {self._safe_str(e)}")
+ self.order_status_handler = None
+
+ def _register_instance(self):
+ """注册当前实例到类级别字典"""
+ try:
+ # 使用同步方式注册,避免在__init__中使用async
+ XianyuLive._instances[self.cookie_id] = self
+ logger.warning(f"【{self.cookie_id}】实例已注册到全局字典")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】注册实例失败: {self._safe_str(e)}")
+
+ def _unregister_instance(self):
+ """从类级别字典中注销当前实例"""
+ try:
+ if self.cookie_id in XianyuLive._instances:
+ del XianyuLive._instances[self.cookie_id]
+ logger.warning(f"【{self.cookie_id}】实例已从全局字典中注销")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】注销实例失败: {self._safe_str(e)}")
+
+ @classmethod
+ def get_instance(cls, cookie_id: str):
+ """获取指定cookie_id的XianyuLive实例"""
+ return cls._instances.get(cookie_id)
+
+ @classmethod
+ def get_all_instances(cls):
+ """获取所有活跃的XianyuLive实例"""
+ return dict(cls._instances)
+
+ @classmethod
+ def get_instance_count(cls):
+ """获取当前活跃实例数量"""
+ return len(cls._instances)
+
+ @classmethod
+ def is_manual_refresh_active(cls, cookie_id: str, allow_handoff_recovery: bool = False) -> bool:
+ """检查指定账号是否处于手动刷新保护期。"""
+ if not cookie_id:
+ return False
+ state = cls.get_manual_refresh_state(cookie_id)
+ if not state:
+ return False
+ phase = state.get('phase') or 'manual_refresh'
+ if allow_handoff_recovery and phase == 'handoff_recovery':
+ return False
+ return True
+
+ @classmethod
+ def begin_manual_refresh(cls, cookie_id: str, source: str = "manual_refresh") -> Dict[str, Any]:
+ """标记账号进入手动刷新保护期,并暂停自动Cookie刷新"""
+ if not cookie_id:
+ return {"started": False, "already_active": False, "reason": "empty_cookie_id"}
+
+ live_instance = cls.get_instance(cookie_id)
+ previous_cookie_refresh_enabled = None
+ if live_instance is not None:
+ previous_cookie_refresh_enabled = live_instance.cookie_refresh_enabled
+
+ cls._cleanup_manual_refresh_state()
+ with cls._manual_refresh_lock:
+ existing = cls._manual_refresh_state.get(cookie_id)
+ if existing:
+ existing["source"] = source
+ existing["phase"] = 'manual_refresh'
+ existing["updated_at"] = time.time()
+ existing["expires_at"] = None
+ return {
+ "started": False,
+ "already_active": True,
+ "previous_cookie_refresh_enabled": existing.get("previous_cookie_refresh_enabled")
+ }
+
+ cls._manual_refresh_state[cookie_id] = {
+ "source": source,
+ "phase": 'manual_refresh',
+ "started_at": time.time(),
+ "updated_at": time.time(),
+ "expires_at": None,
+ "previous_cookie_refresh_enabled": previous_cookie_refresh_enabled,
+ }
+
+ if live_instance is not None and previous_cookie_refresh_enabled is not None:
+ live_instance.enable_cookie_refresh(False)
+ logger.warning(f"【{cookie_id}】已进入手动刷新保护期,暂停自动Cookie刷新")
+ else:
+ logger.warning(f"【{cookie_id}】已进入手动刷新保护期,当前无运行中的账号实例")
+
+ return {
+ "started": True,
+ "already_active": False,
+ "previous_cookie_refresh_enabled": previous_cookie_refresh_enabled
+ }
+
+ @classmethod
+ def end_manual_refresh(cls, cookie_id: str, source: str = "manual_refresh") -> bool:
+ """结束手动刷新保护期,并按原状态恢复自动Cookie刷新"""
+ if not cookie_id:
+ return False
+
+ cls._cleanup_manual_refresh_state()
+ with cls._manual_refresh_lock:
+ state = cls._manual_refresh_state.pop(cookie_id, None)
+
+ if state is None:
+ return False
+
+ live_instance = cls.get_instance(cookie_id)
+ previous_cookie_refresh_enabled = state.get("previous_cookie_refresh_enabled")
+ if live_instance is not None and previous_cookie_refresh_enabled is not None:
+ live_instance.enable_cookie_refresh(previous_cookie_refresh_enabled)
+ if previous_cookie_refresh_enabled:
+ # 手动刷新刚结束时,避免新实例立刻再触发一轮自动Cookie刷新。
+ live_instance.last_cookie_refresh_time = time.time()
+ logger.warning(
+ f"【{cookie_id}】手动刷新保护期已结束,恢复自动Cookie刷新: {previous_cookie_refresh_enabled}"
+ )
+ else:
+ logger.warning(f"【{cookie_id}】手动刷新保护期已结束,当前无运行中的账号实例可恢复")
+
+ logger.info(f"【{cookie_id}】结束手动刷新保护期,来源: {source}")
+ return True
+
+ def _create_tracked_task(self, coro):
+ """创建并追踪后台任务,确保异常不会被静默忽略"""
+ task = asyncio.create_task(coro)
+ self.background_tasks.add(task)
+ task.add_done_callback(self.background_tasks.discard)
+ return task
+
+ def _sanitize_buyer_nick(self, candidate: Any, *, source: str = "unknown",
+ message_meta: Dict[str, Any] = None, log_prefix: str = "") -> Optional[str]:
+ """过滤系统/营销文案,避免污染订单买家昵称。"""
+ if candidate is None:
+ return None
+
+ text = str(candidate).strip()
+ if not text or text in {"未知用户", "unknown", "unknown_user"}:
+ return None
+
+ invalid_exact_titles = {
+ "订单",
+ "全部",
+ "交易消息",
+ "等待你发货",
+ "你人真不错,送你闲鱼小红花",
+ "卖家人不错?送Ta闲鱼小红花",
+ "快给ta一个评价吧~",
+ }
+ if text in invalid_exact_titles:
+ logger.info(f"{log_prefix} 👤 忽略系统标题型买家昵称({source}): {text}")
+ return None
+
+ meta = message_meta if isinstance(message_meta, dict) else {}
+ related_notice_texts = []
+ for key in ("detailNotice", "reminderContent", "reminderNotice"):
+ value = str(meta.get(key, "")).strip()
+ if value:
+ related_notice_texts.append(value)
+
+ if text in related_notice_texts:
+ logger.info(f"{log_prefix} 👤 忽略通知文案型买家昵称({source}): {text}")
+ return None
+
+ reminder_title = str(meta.get("reminderTitle", "")).strip()
+ if source != "senderNick":
+ invalid_keywords = (
+ "小红花", "待付款", "待发货", "待刀成", "成功小刀", "闲鱼",
+ "交易", "收货", "退款", "评价", "发货", "付款", "拍下",
+ "确认", "关闭", "鼓励", "真不错", "全部", "订单",
+ )
+ if any(keyword in text for keyword in invalid_keywords):
+ logger.info(f"{log_prefix} 👤 忽略系统关键词型买家昵称({source}): {text}")
+ return None
+
+ if reminder_title == text and len(text) >= 10 and any(ch in text for ch in ",,。!??!::~~"):
+ logger.info(f"{log_prefix} 👤 忽略长句型买家昵称({source}): {text}")
+ return None
+
+ return text
+
+ def _resolve_delivery_log_buyer_nick(self, buyer_nick: Any = None, *, order_id: str = None,
+ buyer_id: str = None, log_prefix: str = "") -> Optional[str]:
+ """为发货日志优先选择可信的买家昵称,避免写入系统卡片标题。"""
+ from db_manager import db_manager
+
+ normalized_order_id = str(order_id).strip() if order_id else None
+ normalized_buyer_id = str(buyer_id).strip() if buyer_id else None
+
+ try:
+ if normalized_order_id:
+ order_info = db_manager.get_order_by_id(normalized_order_id)
+ if order_info:
+ order_cookie_id = str(order_info.get("cookie_id") or "").strip()
+ if not order_cookie_id or order_cookie_id == str(self.cookie_id).strip():
+ order_buyer_nick = self._sanitize_buyer_nick(
+ order_info.get("buyer_nick"),
+ source="delivery_log_order",
+ log_prefix=log_prefix,
+ )
+ if order_buyer_nick:
+ return order_buyer_nick
+
+ if not normalized_buyer_id:
+ normalized_buyer_id = str(order_info.get("buyer_id") or "").strip() or None
+
+ if normalized_buyer_id:
+ recent_order = db_manager.get_recent_order_by_buyer_id(
+ normalized_buyer_id,
+ cookie_id=self.cookie_id,
+ minutes=60,
+ )
+ if recent_order:
+ recent_buyer_nick = self._sanitize_buyer_nick(
+ recent_order.get("buyer_nick"),
+ source="delivery_log_recent_order",
+ log_prefix=log_prefix,
+ )
+ if recent_buyer_nick:
+ return recent_buyer_nick
+ except Exception as resolve_error:
+ logger.warning(f"{log_prefix} 发货日志买家昵称解析失败: {self._safe_str(resolve_error)}")
+
+ return self._sanitize_buyer_nick(
+ buyer_nick,
+ source="delivery_log_raw",
+ log_prefix=log_prefix,
+ )
+
+ def _lookup_delivery_order_by_sid(self, sid: str, *, minutes: int = 10,
+ log_prefix: str = "") -> Dict[str, Any]:
+ """根据 sid 查找简化发货对应订单,并区分是否已处理/已关闭。"""
+ normalized_sid = str(sid or "").strip()
+ if not normalized_sid:
+ return {"match_type": "missing", "order": None}
+
+ try:
+ pending_orders = db_manager.find_recent_orders_by_match_context(
+ sid=normalized_sid,
+ cookie_id=self.cookie_id,
+ statuses=[
+ "pending_ship",
+ "pending_delivery",
+ "partial_success",
+ "partial_pending_finalize",
+ ],
+ minutes=minutes,
+ limit=5,
+ )
+ except Exception as lookup_error:
+ logger.error(f"{log_prefix} sid兜底查单异常: {self._safe_str(lookup_error)}")
+ return {"match_type": "error", "order": None}
+
+ if pending_orders:
+ order = pending_orders[0]
+ logger.info(
+ f"{log_prefix} sid兜底命中待发货订单: sid={normalized_sid}, "
+ f"order_id={order.get('order_id')}, status={order.get('order_status') or 'unknown'}"
+ )
+ return {"match_type": "pending_ship", "order": order}
+
+ try:
+ recent_orders = db_manager.find_recent_orders_by_match_context(
+ sid=normalized_sid,
+ cookie_id=self.cookie_id,
+ statuses=[
+ "processing",
+ "pending_payment",
+ "shipped",
+ "completed",
+ "cancelled",
+ ],
+ minutes=minutes,
+ limit=5,
+ )
+ except Exception as lookup_error:
+ logger.error(f"{log_prefix} sid兜底查单异常: {self._safe_str(lookup_error)}")
+ return {"match_type": "error", "order": None}
+
+ if not recent_orders:
+ return {"match_type": "missing", "order": None}
+
+ order = recent_orders[0]
+ order_id = str(order.get("order_id") or "").strip()
+ order_status = str(order.get("order_status") or "").strip()
+ if order_status == "shipped":
+ if self._has_delivery_progress_evidence(order_id):
+ match_type = "already_processed"
+ else:
+ match_type = "suspicious_shipped"
+ logger.warning(
+ f"{log_prefix} sid兜底命中可疑已发货订单,检测到无真实发货进度,继续允许纠偏: "
+ f"sid={normalized_sid}, order_id={order_id}, status={order_status}"
+ )
+ elif order_status == "completed":
+ match_type = "already_processed"
+ elif order_status == "cancelled":
+ match_type = "cancelled"
+ elif order_status in {"processing", "pending_payment"}:
+ match_type = "not_ready"
+ else:
+ match_type = "other_status"
+
+ logger.info(
+ f"{log_prefix} sid兜底命中订单: sid={normalized_sid}, "
+ f"order_id={order.get('order_id')}, status={order_status or 'unknown'}, match_type={match_type}"
+ )
+ return {"match_type": match_type, "order": order}
+
+ async def _refresh_sid_lookup_if_needed(self, sid: str, sid_lookup: Dict[str, Any], *,
+ item_id: str = None, buyer_id: str = None,
+ minutes: int = 10, allow_bargain_ready: bool = False,
+ log_prefix: str = "") -> Dict[str, Any]:
+ """sid 命中未就绪订单时,强刷详情后再判定一次。"""
+ recent_order = (sid_lookup or {}).get('order')
+ match_type = (sid_lookup or {}).get('match_type', 'missing')
+
+ if not recent_order or match_type not in {'not_ready', 'other_status', 'suspicious_shipped'}:
+ return sid_lookup
+
+ order_id = str(recent_order.get('order_id') or '').strip()
+ if not order_id:
+ return sid_lookup
+
+ refresh_item_id = recent_order.get('item_id') or item_id
+ refresh_buyer_id = recent_order.get('buyer_id') or buyer_id
+ old_status = recent_order.get('order_status') or 'unknown'
+
+ logger.info(
+ f"{log_prefix} sid命中的订单状态未就绪,尝试强制刷新订单详情后重试: "
+ f"order_id={order_id}, status={old_status}"
+ )
+
+ if not self._reserve_order_detail_force_refresh(
+ order_id,
+ reason='sid_not_ready',
+ log_prefix=log_prefix,
+ ):
+ return sid_lookup
+
+ try:
+ await self.fetch_order_detail_info(
+ order_id,
+ refresh_item_id,
+ refresh_buyer_id,
+ sid=sid,
+ force_refresh=True
+ )
+ except Exception as refresh_error:
+ logger.warning(f"{log_prefix} sid未就绪订单强刷失败: {self._safe_str(refresh_error)}")
+ return sid_lookup
+
+ refreshed_lookup = self._lookup_delivery_order_by_sid(
+ sid,
+ minutes=minutes,
+ log_prefix=log_prefix
+ )
+ refreshed_order = refreshed_lookup.get('order') or {}
+
+ if (
+ allow_bargain_ready and
+ refreshed_lookup.get('match_type') == 'not_ready' and
+ refreshed_order and
+ str(refreshed_order.get('order_status') or '').strip() in {'processing', 'pending_payment'} and
+ self._has_bargain_success_evidence(refreshed_order)
+ ):
+ refreshed_lookup = dict(refreshed_lookup)
+ refreshed_lookup['match_type'] = 'bargain_ready'
+ logger.info(
+ f"{log_prefix} sid强刷后仍未进入待发货,但检测到小刀成功证据,"
+ f"改用小刀兜底发货: order_id={refreshed_order.get('order_id') or order_id}, "
+ f"status={refreshed_order.get('order_status') or 'unknown'}"
+ )
+
+ logger.info(
+ f"{log_prefix} sid强刷后重新判定: order_id={refreshed_order.get('order_id') or order_id}, "
+ f"status={refreshed_order.get('order_status') or 'unknown'}, "
+ f"match_type={refreshed_lookup.get('match_type', 'missing')}"
+ )
+ return refreshed_lookup
+
+ async def _ensure_item_owned_by_current_account(self, item_id: str, *,
+ log_prefix: str = "",
+ page_size: int = 50,
+ max_pages: int = 3) -> bool:
+ """优先查本地缓存,未命中时刷新在售商品列表进行归属校验。"""
+ if not item_id or item_id == "未知商品":
+ return False
+
+ existing_item = db_manager.get_item_info(self.cookie_id, item_id)
+ if existing_item:
+ return True
+
+ logger.info(f"{log_prefix} 商品 {item_id} 未命中本地缓存,刷新在售商品列表后重试归属校验")
+ try:
+ for page_number in range(1, max_pages + 1):
+ result = await self.get_item_list_info(page_number=page_number, page_size=page_size)
+ if not result.get("success"):
+ logger.warning(f"{log_prefix} 刷新在售商品列表失败,停止归属校验回退: page={page_number}, result={result}")
+ break
+
+ current_items = result.get("items", [])
+ if any(str(item.get("id", "")).strip() == str(item_id).strip() for item in current_items):
+ logger.info(f"{log_prefix} 商品 {item_id} 在第 {page_number} 页在售商品列表中命中,归属校验通过")
+ return True
+
+ if len(current_items) < page_size:
+ break
+ except Exception as e:
+ logger.error(f"{log_prefix} 刷新在售商品列表进行归属校验失败: {self._safe_str(e)}")
+
+ return bool(db_manager.get_item_info(self.cookie_id, item_id))
+
+ # 已知的无效 buyer_id 占位值
+ _INVALID_BUYER_IDS = {"unknown_user", "unknown", "", "None", "null", "0", "-", "-1"}
+
+ @classmethod
+ def _normalize_buyer_id_value(cls, buyer_id) -> Optional[str]:
+ if buyer_id is None:
+ return None
+ text = str(buyer_id).strip()
+ if not text:
+ return None
+ if text.endswith('@goofish'):
+ text = text.split('@')[0].strip()
+ return text or None
+
+ @staticmethod
+ def _is_trustworthy_buyer_id(buyer_id) -> bool:
+ """判断 buyer_id 是否可信,用于防串单校验。
+ 不可信的值(占位符等)不应参与一致性比对。"""
+ normalized_buyer_id = XianyuLive._normalize_buyer_id_value(buyer_id)
+ if not normalized_buyer_id:
+ return False
+ if normalized_buyer_id in XianyuLive._INVALID_BUYER_IDS:
+ return False
+ if normalized_buyer_id.isdigit() and len(normalized_buyer_id) <= 2:
+ return False
+ return True
+
+ def _extract_query_value_from_url(self, url_text: Any, key: str) -> Optional[str]:
+ text = str(url_text or '').strip()
+ if not text:
+ return None
+
+ try:
+ parsed = urlparse(text)
+ query = parse_qs(parsed.query or '')
+ value = query.get(key, [None])[0]
+ return self._normalize_buyer_id_value(value)
+ except Exception as e:
+ logger.debug(f"【{self.cookie_id}】解析链接参数失败: key={key}, error={self._safe_str(e)}")
+ return None
+
+ def _extract_buyer_id_from_message_meta(self, message_meta: dict, *, meta_label: str,
+ log_prefix: str = "") -> Tuple[Optional[str], Optional[str]]:
+ if not isinstance(message_meta, dict):
+ return None, None
+
+ biz_tag_dict = self._load_json_dict(message_meta.get('bizTag', ''))
+ candidates = [
+ ('reminderUrl.peerUserId', self._extract_query_value_from_url(message_meta.get('reminderUrl'), 'peerUserId')),
+ ('bizTag.senderId', self._normalize_buyer_id_value(biz_tag_dict.get('senderId') or biz_tag_dict.get('sender_id'))),
+ (f'{meta_label}.senderUserId', self._normalize_buyer_id_value(message_meta.get('senderUserId'))),
+ ]
+
+ low_trust_candidates = []
+ for source, candidate in candidates:
+ if not candidate:
+ continue
+ if self._is_trustworthy_buyer_id(candidate):
+ return candidate, source
+ low_trust_candidates.append(f'{source}={candidate}')
+
+ if low_trust_candidates:
+ logger.info(
+ f"{log_prefix} 👤 检测到低可信买家ID候选,已忽略: {', '.join(low_trust_candidates[:3])}"
+ )
+ return None, None
+
+ def _select_buyer_identity_for_order_write(self, order_id: str, *, incoming_buyer_id: Any = None,
+ incoming_buyer_nick: Any = None, existing_order: Dict[str, Any] = None,
+ buyer_id_source: str = None, buyer_nick_source: str = 'unknown',
+ log_prefix: str = '') -> Tuple[Optional[str], Optional[str], bool]:
+ incoming_buyer_id = self._normalize_buyer_id_value(incoming_buyer_id)
+ incoming_buyer_nick = self._sanitize_buyer_nick(
+ incoming_buyer_nick,
+ source=buyer_nick_source,
+ log_prefix=log_prefix,
+ )
+
+ existing_buyer_id = self._normalize_buyer_id_value((existing_order or {}).get('buyer_id'))
+ existing_buyer_nick = (existing_order or {}).get('buyer_nick')
+ existing_buyer_is_trustworthy = self._is_trustworthy_buyer_id(existing_buyer_id)
+ incoming_buyer_is_trustworthy = self._is_trustworthy_buyer_id(incoming_buyer_id)
+ source_label = buyer_id_source or 'unknown'
+
+ if incoming_buyer_id and incoming_buyer_id == self.myid:
+ if existing_order:
+ preserved_buyer_id = existing_buyer_id if existing_buyer_id and existing_buyer_id != self.myid else None
+ if existing_buyer_nick:
+ incoming_buyer_nick = existing_buyer_nick
+ logger.info(
+ f"{log_prefix} 订单 {order_id} 命中自己买家ID保护,继续刷新并保留已有买家信息: "
+ f"incoming_buyer_id={incoming_buyer_id}, preserved_buyer_id={preserved_buyer_id}"
+ )
+ return preserved_buyer_id, incoming_buyer_nick, False
+
+ logger.info(
+ f"{log_prefix} 跳过疑似买家订单 {order_id} 的首次写入,buyer_id={incoming_buyer_id} 等于自己的ID"
+ )
+ return None, incoming_buyer_nick, True
+
+ if existing_buyer_is_trustworthy:
+ if not incoming_buyer_id:
+ return existing_buyer_id, incoming_buyer_nick or existing_buyer_nick, False
+
+ if not incoming_buyer_is_trustworthy:
+ logger.info(
+ f"{log_prefix} 忽略低可信buyer_id覆盖,保留已有买家信息: "
+ f"order_id={order_id}, incoming_buyer_id={incoming_buyer_id}, "
+ f"incoming_source={source_label}, preserved_buyer_id={existing_buyer_id}"
+ )
+ return existing_buyer_id, incoming_buyer_nick or existing_buyer_nick, False
+
+ if incoming_buyer_id != existing_buyer_id:
+ logger.warning(
+ f"{log_prefix} 检测到买家ID冲突,保留已有可信买家信息: "
+ f"order_id={order_id}, incoming_buyer_id={incoming_buyer_id}, "
+ f"incoming_source={source_label}, preserved_buyer_id={existing_buyer_id}"
+ )
+ return existing_buyer_id, incoming_buyer_nick or existing_buyer_nick, False
+
+ return existing_buyer_id, incoming_buyer_nick or existing_buyer_nick, False
+
+ if incoming_buyer_is_trustworthy:
+ return incoming_buyer_id, incoming_buyer_nick or existing_buyer_nick, False
+
+ if incoming_buyer_id:
+ logger.info(
+ f"{log_prefix} 检测到低可信buyer_id,暂不写入订单: "
+ f"order_id={order_id}, incoming_buyer_id={incoming_buyer_id}, incoming_source={source_label}"
+ )
+
+ fallback_buyer_id = existing_buyer_id if existing_buyer_id and existing_buyer_id != self.myid else None
+ return fallback_buyer_id, incoming_buyer_nick or existing_buyer_nick, False
+
+ def _extract_order_message_context(self, message: dict, msg_id: str = None) -> Dict[str, Any]:
+ """从订单相关消息中提取买家、会话和商品信息。"""
+ buyer_id = None
+ buyer_id_source = None
+ buyer_nick = None
+ sid = ""
+ item_id = None
+ log_prefix = f"【{self.cookie_id}】[{msg_id}]" if msg_id else f"【{self.cookie_id}】"
+
+ try:
+ message_1 = message.get("1")
+ if isinstance(message_1, str):
+ # message['1'] 是字符串,可能是 sid(如 "56226853668@goofish")或消息ID(如 "4003914207496.PNM")
+ if '@' in message_1:
+ sid = message_1
+ else:
+ # PNM 等非 sid 格式,真正的 sid 在 message['2']
+ sid = message.get("2", "") or ""
+ buyer_id = None
+ # 尝试从 message['4'] 提取 buyer_id(PNM 等格式的 senderUserId 在这里)
+ message_4 = message.get("4")
+ if isinstance(message_4, dict):
+ buyer_id, buyer_id_source = self._extract_buyer_id_from_message_meta(
+ message_4,
+ meta_label='message[4]',
+ log_prefix=log_prefix,
+ )
+ buyer_nick = self._sanitize_buyer_nick(
+ message_4.get("senderNick"),
+ source="senderNick(msg4)",
+ message_meta=message_4,
+ log_prefix=log_prefix
+ )
+ if not buyer_nick:
+ reminder_title = message_4.get("reminderTitle", "")
+ buyer_nick = self._sanitize_buyer_nick(
+ reminder_title,
+ source="reminderTitle(msg4)",
+ message_meta=message_4,
+ log_prefix=log_prefix
+ )
+ if buyer_nick:
+ logger.info(f"{log_prefix} 👤 从message[4].reminderTitle提取到买家昵称: {buyer_nick}")
+ if buyer_nick:
+ logger.info(f"{log_prefix} 👤 从message[4]提取到买家昵称: {buyer_nick}")
+ logger.info(
+ f"{log_prefix} 📌 简化消息,sid: {sid},buyer_id: {buyer_id},"
+ f"buyer_id_source: {buyer_id_source or '-'}"
+ )
+ elif isinstance(message_1, dict):
+ if "10" in message_1 and isinstance(message_1["10"], dict):
+ message_10 = message_1["10"]
+ buyer_id, buyer_id_source = self._extract_buyer_id_from_message_meta(
+ message_10,
+ meta_label='message[1][10]',
+ log_prefix=log_prefix,
+ )
+ buyer_nick = self._sanitize_buyer_nick(
+ message_10.get("senderNick"),
+ source="senderNick",
+ message_meta=message_10,
+ log_prefix=log_prefix
+ )
+ if not buyer_nick:
+ reminder_title = message_10.get("reminderTitle", "")
+ buyer_nick = self._sanitize_buyer_nick(
+ reminder_title,
+ source="reminderTitle",
+ message_meta=message_10,
+ log_prefix=log_prefix
+ )
+ if buyer_nick:
+ logger.info(f"{log_prefix} 👤 从reminderTitle提取到买家昵称: {buyer_nick}")
+ if buyer_nick:
+ logger.info(f"{log_prefix} 👤 提取到买家昵称: {buyer_nick}")
+ sid = message_1.get("2", "")
+ if sid:
+ logger.info(f"{log_prefix} 📌 提取到sid: {sid}")
+ except Exception as context_e:
+ logger.warning(f"{log_prefix} 提取订单上下文失败: {self._safe_str(context_e)}")
+
+ try:
+ if "1" in message and isinstance(message["1"], dict) and "10" in message["1"] and isinstance(message["1"]["10"], dict):
+ url_info = message["1"]["10"].get("reminderUrl", "")
+ if isinstance(url_info, str) and "itemId=" in url_info:
+ item_id = url_info.split("itemId=")[1].split("&")[0]
+
+ # message['4'] 中也可能包含 reminderUrl(PNM 等格式)
+ if not item_id and "4" in message and isinstance(message["4"], dict):
+ url_info = message["4"].get("reminderUrl", "")
+ if isinstance(url_info, str) and "itemId=" in url_info:
+ item_id = url_info.split("itemId=")[1].split("&")[0]
+
+ if not item_id:
+ item_id = self.extract_item_id_from_message(message)
+ except Exception as item_e:
+ logger.warning(f"{log_prefix} 提取商品ID失败: {self._safe_str(item_e)}")
+
+ return {
+ 'buyer_id': buyer_id,
+ 'buyer_id_source': buyer_id_source,
+ 'buyer_nick': buyer_nick,
+ 'sid': sid,
+ 'item_id': item_id,
+ }
+
+ def _preload_basic_order_info(self, order_id: str, item_id: str = None, buyer_id: str = None,
+ sid: str = None, buyer_nick: str = None,
+ buyer_id_source: str = None) -> bool:
+ """在详情抓取前先落基础订单,避免详情超时导致整单丢失。"""
+ try:
+ existing_order = db_manager.get_order_by_id(order_id)
+ buyer_id_to_save, buyer_nick_to_save, should_skip_write = self._select_buyer_identity_for_order_write(
+ order_id,
+ incoming_buyer_id=buyer_id,
+ incoming_buyer_nick=buyer_nick,
+ existing_order=existing_order,
+ buyer_id_source=buyer_id_source,
+ buyer_nick_source="preload",
+ log_prefix=f"【{self.cookie_id}】",
+ )
+ if should_skip_write:
+ return False
+
+ success = db_manager.insert_or_update_order(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=buyer_id_to_save,
+ buyer_nick=buyer_nick_to_save,
+ sid=sid,
+ cookie_id=self.cookie_id,
+ order_status='processing' if not existing_order else None
+ )
+ if success:
+ action = "更新基础订单信息" if existing_order else "基础订单已预入库"
+ logger.info(
+ f"【{self.cookie_id}】{action}: order_id={order_id}, item_id={item_id}, "
+ f"buyer_id={buyer_id_to_save}, sid={sid or '-'}"
+ )
+ else:
+ logger.warning(f"【{self.cookie_id}】基础订单预入库失败: {order_id}")
+ return success
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】基础订单预入库异常: {self._safe_str(e)}")
+ return False
+
+ async def _retry_order_detail_after_delay(self, order_id: str, item_id: str = None, buyer_id: str = None,
+ sid: str = None, buyer_nick: str = None, delay_seconds: int = 30,
+ buyer_id_source: str = None):
+ """订单详情首次抓取失败后,后台延迟补抓一次。"""
+ current_task = asyncio.current_task()
+ try:
+ await asyncio.sleep(delay_seconds)
+ logger.info(f"【{self.cookie_id}】开始延迟补抓订单详情: order_id={order_id}, delay={delay_seconds}s")
+ result = await self.fetch_order_detail_info(
+ order_id,
+ item_id,
+ buyer_id,
+ sid=sid,
+ buyer_nick=buyer_nick,
+ buyer_id_source=buyer_id_source,
+ force_refresh=True
+ )
+ if result:
+ logger.info(f"【{self.cookie_id}】订单详情延迟补抓成功: {order_id}")
+ else:
+ logger.warning(f"【{self.cookie_id}】订单详情延迟补抓仍失败,保留基础订单: {order_id}")
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】订单详情延迟补抓任务已取消: {order_id}")
+ raise
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】订单详情延迟补抓异常: {order_id} - {self._safe_str(e)}")
+ finally:
+ existing_task = self.order_detail_retry_tasks.get(order_id)
+ if existing_task is current_task:
+ self.order_detail_retry_tasks.pop(order_id, None)
+
+ def _schedule_order_detail_retry(self, order_id: str, item_id: str = None, buyer_id: str = None,
+ sid: str = None, buyer_nick: str = None, delay_seconds: int = 30,
+ buyer_id_source: str = None):
+ """调度订单详情补抓任务,避免同一订单重复创建补抓。"""
+ existing_task = self.order_detail_retry_tasks.get(order_id)
+ if existing_task and not existing_task.done():
+ logger.info(f"【{self.cookie_id}】订单详情补抓任务已存在,跳过重复调度: {order_id}")
+ return
+
+ task = self._create_tracked_task(
+ self._retry_order_detail_after_delay(
+ order_id,
+ item_id=item_id,
+ buyer_id=buyer_id,
+ sid=sid,
+ buyer_nick=buyer_nick,
+ delay_seconds=delay_seconds,
+ buyer_id_source=buyer_id_source,
+ )
+ )
+ self.order_detail_retry_tasks[order_id] = task
+ logger.info(f"【{self.cookie_id}】已调度订单详情补抓任务: order_id={order_id}, delay={delay_seconds}s")
+
+ # ============ 高性能消息队列系统方法 ============
+
+ def _get_message_priority(self, message_data: dict) -> int:
+ """
+ 根据消息类型确定优先级
+
+ 优先级定义:
+ - 0: 最高优先级(心跳响应、ACK确认)- 立即处理
+ - 1: 高优先级(订单相关消息)- 优先处理
+ - 2: 中优先级(普通聊天消息)- 正常处理
+ - 3: 低优先级(系统通知、其他)- 延后处理
+
+ Returns:
+ int: 优先级值,越小优先级越高
+ """
+ try:
+ # 检查是否是心跳响应
+ if isinstance(message_data, dict):
+ # 心跳响应
+ if message_data.get("code") == 200 and "body" not in message_data:
+ return 0
+
+ # 检查消息体
+ body = message_data.get("body", {})
+
+ # 同步包消息需要进一步分析
+ if "syncPushPackage" in body:
+ try:
+ sync_data = body["syncPushPackage"].get("data", [])
+ if sync_data and isinstance(sync_data, list) and len(sync_data) > 0:
+ first_data = sync_data[0]
+ # 检查是否包含订单相关关键词
+ data_str = str(first_data).lower()
+ if any(kw in data_str for kw in ['orderid', 'order_id', 'bizorderid', 'paysucc', 'paid']):
+ return 1 # 订单消息 - 高优先级
+ if 'message' in data_str or 'chat' in data_str:
+ return 2 # 聊天消息 - 中优先级
+ except Exception:
+ pass
+
+ # ACK确认消息
+ if message_data.get("code") == 200:
+ return 0
+
+ return 3 # 默认低优先级
+ except Exception as e:
+ logger.debug(f"【{self.cookie_id}】解析消息优先级失败: {e}")
+ return 3
+
+ async def _enqueue_message(self, message_data: dict, websocket, msg_id: str = "unknown") -> bool:
+ """
+ 将消息放入优先级队列
+
+ Args:
+ message_data: 消息数据
+ websocket: WebSocket连接
+ msg_id: 消息ID
+
+ Returns:
+ bool: 是否成功入队
+ """
+ try:
+ # 获取消息优先级
+ priority = self._get_message_priority(message_data)
+
+ # 创建消息包装对象
+ async with self.message_queue_lock:
+ self.message_queue_counter += 1
+ counter = self.message_queue_counter
+
+ message_item = {
+ 'data': message_data,
+ 'websocket': websocket,
+ 'msg_id': msg_id,
+ 'enqueue_time': time.time(),
+ 'priority': priority,
+ }
+
+ # 尝试非阻塞入队
+ try:
+ self.message_queue.put_nowait((priority, counter, message_item))
+ self.queue_stats['received'] += 1
+
+ # 高优先级消息日志
+ if priority <= 1:
+ logger.info(f"【{self.cookie_id}】📥 高优先级消息入队 [P{priority}][ID:{msg_id}] 队列大小: {self.message_queue.qsize()}")
+ else:
+ logger.debug(f"【{self.cookie_id}】📥 消息入队 [P{priority}][ID:{msg_id}] 队列大小: {self.message_queue.qsize()}")
+
+ return True
+ except asyncio.QueueFull:
+ # 队列满时,尝试丢弃最低优先级的旧消息
+ self.queue_stats['dropped_full'] += 1
+ logger.warning(f"【{self.cookie_id}】⚠️ 消息队列已满({self.message_queue_max_size}),消息[ID:{msg_id}]被丢弃")
+ return False
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】消息入队失败: {self._safe_str(e)}")
+ return False
+
+ async def _message_worker(self, worker_id: int):
+ """
+ 消息处理工作协程
+
+ 从队列中取出消息并处理,支持并发处理多个消息
+
+ Args:
+ worker_id: 工作协程ID
+ """
+ logger.info(f"【{self.cookie_id}】🔧 消息处理工作协程 #{worker_id} 启动")
+
+ while self.message_queue_running:
+ try:
+ # 设置超时获取,避免无限等待
+ try:
+ priority, counter, message_item = await asyncio.wait_for(
+ self.message_queue.get(),
+ timeout=5.0
+ )
+ except asyncio.TimeoutError:
+ # 超时没有消息,继续循环
+ continue
+
+ # 检查消息是否过期
+ enqueue_time = message_item['enqueue_time']
+ age = time.time() - enqueue_time
+ if age > self.message_expire_seconds:
+ self.queue_stats['dropped_expired'] += 1
+ logger.warning(f"【{self.cookie_id}】⏰ 工作协程#{worker_id} 丢弃过期消息 [ID:{message_item['msg_id']}] 已等待{age:.1f}秒")
+ self.message_queue.task_done()
+ continue
+
+ # 处理消息
+ msg_id = message_item['msg_id']
+ try:
+ logger.debug(f"【{self.cookie_id}】🔄 工作协程#{worker_id} 开始处理消息 [P{priority}][ID:{msg_id}] 等待{age:.2f}秒")
+
+ # 使用信号量控制并发
+ async with self.message_semaphore:
+ self.active_message_tasks += 1
+ try:
+ await self.handle_message(
+ message_item['data'],
+ message_item['websocket'],
+ msg_id
+ )
+ self.queue_stats['processed'] += 1
+ finally:
+ self.active_message_tasks -= 1
+
+ logger.debug(f"【{self.cookie_id}】✅ 工作协程#{worker_id} 完成消息处理 [ID:{msg_id}]")
+
+ except Exception as e:
+ self.queue_stats['errors'] += 1
+ logger.error(f"【{self.cookie_id}】❌ 工作协程#{worker_id} 处理消息失败 [ID:{msg_id}]: {self._safe_str(e)}")
+ finally:
+ self.message_queue.task_done()
+
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】🛑 消息处理工作协程 #{worker_id} 被取消")
+ break
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】工作协程#{worker_id} 异常: {self._safe_str(e)}")
+ await asyncio.sleep(1) # 出错后短暂休息
+
+ logger.info(f"【{self.cookie_id}】🔧 消息处理工作协程 #{worker_id} 已停止")
+
+ async def _start_message_queue_workers(self):
+ """启动消息队列工作协程"""
+ if not self.message_queue_enabled:
+ logger.info(f"【{self.cookie_id}】消息队列系统已禁用,使用传统处理模式")
+ return
+
+ self.message_queue_running = True
+ self.message_workers = []
+
+ # 创建多个工作协程
+ for i in range(self.message_queue_workers):
+ worker_task = self._create_tracked_task(self._message_worker(i))
+ self.message_workers.append(worker_task)
+
+ # 启动队列监控任务
+ self._create_tracked_task(self._queue_stats_monitor())
+
+ logger.info(f"【{self.cookie_id}】🚀 消息队列系统已启动,{self.message_queue_workers}个工作协程")
+
+ async def _stop_message_queue_workers(self):
+ """停止消息队列工作协程"""
+ self.message_queue_running = False
+
+ # 取消所有工作协程
+ for worker_task in self.message_workers:
+ if not worker_task.done():
+ worker_task.cancel()
+
+ # 等待所有工作协程结束
+ if self.message_workers:
+ await asyncio.gather(*self.message_workers, return_exceptions=True)
+
+ self.message_workers = []
+ logger.info(f"【{self.cookie_id}】🛑 消息队列系统已停止")
+
+ async def _queue_stats_monitor(self):
+ """定期输出队列统计信息"""
+ while self.message_queue_running:
+ try:
+ await asyncio.sleep(60) # 每60秒输出一次统计
+
+ if not self.message_queue_running:
+ break
+
+ # 计算统计
+ stats = self.queue_stats
+ elapsed = time.time() - stats['last_stats_time']
+
+ if stats['received'] > 0:
+ process_rate = stats['processed'] / elapsed if elapsed > 0 else 0
+ drop_rate = (stats['dropped_full'] + stats['dropped_expired']) / stats['received'] * 100
+
+ logger.info(
+ f"【{self.cookie_id}】📊 消息队列统计 - "
+ f"队列大小: {self.message_queue.qsize()}/{self.message_queue_max_size} | "
+ f"收到: {stats['received']} | "
+ f"处理: {stats['processed']} | "
+ f"丢弃(满): {stats['dropped_full']} | "
+ f"丢弃(过期): {stats['dropped_expired']} | "
+ f"错误: {stats['errors']} | "
+ f"处理速率: {process_rate:.1f}/s | "
+ f"丢弃率: {drop_rate:.1f}%"
+ )
+
+ # 如果丢弃率过高,发出警告
+ if drop_rate > 10:
+ logger.warning(f"【{self.cookie_id}】⚠️ 消息丢弃率过高({drop_rate:.1f}%),建议增加工作协程数量或检查消息处理效率")
+
+ # 重置统计
+ stats['last_stats_time'] = time.time()
+
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】队列监控异常: {self._safe_str(e)}")
+
+ def is_auto_confirm_enabled(self) -> bool:
+ """检查当前账号是否启用自动确认发货"""
+ try:
+ from db_manager import db_manager
+ return db_manager.get_auto_confirm(self.cookie_id)
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】获取自动确认发货设置失败: {self._safe_str(e)}")
+ return True # 出错时默认启用
+
+ def is_auto_comment_enabled(self) -> bool:
+ """检查当前账号是否启用自动好评"""
+ try:
+ from db_manager import db_manager
+ return db_manager.get_auto_comment(self.cookie_id)
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】获取自动好评设置失败: {self._safe_str(e)}")
+ return False # 出错时默认禁用
+
+ async def handle_auto_comment(self, message: dict, msg_time: str, msg_id: str = ""):
+ """处理自动好评"""
+ try:
+ # 检查是否启用自动好评
+ if not self.is_auto_comment_enabled():
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 未启用自动好评,跳过')
+ return False
+
+ # 从消息中提取订单ID
+ order_id = self._extract_order_id_for_comment(message)
+ if not order_id:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 无法从评价消息中提取订单ID,跳过自动好评')
+ return False
+
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 检测到评价提醒,订单ID: {order_id}')
+
+ # 获取激活的好评模板
+ from db_manager import db_manager
+ template = db_manager.get_active_comment_template(self.cookie_id)
+ if not template:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 未设置激活的好评模板,跳过自动好评')
+ return False
+
+ comment_content = template.get('content', '')
+ if not comment_content:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 好评模板内容为空,跳过自动好评')
+ return False
+
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 使用模板"{template.get("name", "")}"进行好评: {comment_content[:50]}...')
+
+ # 调用好评接口
+ result = await self._call_comment_api(order_id, comment_content)
+
+ if result.get('success'):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ✅ 订单 {order_id} 自动好评成功')
+ return True
+ else:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ❌ 订单 {order_id} 自动好评失败: {result.get("message", "未知错误")}')
+ return False
+
+ except Exception as e:
+ logger.error(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 自动好评异常: {self._safe_str(e)}')
+ return False
+
+ def _extract_order_id_for_comment(self, message: dict) -> str:
+ """从评价提醒消息中提取订单ID"""
+ try:
+ order_id = self._extract_order_id(message)
+ if order_id:
+ logger.info(f'【{self.cookie_id}】评价提醒消息提取到订单ID: {order_id}')
+ return order_id
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】提取评价订单ID失败: {self._safe_str(e)}")
+ return None
+
+ async def _call_comment_api(self, order_id: str, comment: str) -> dict:
+ """调用好评接口"""
+ import aiohttp
+
+ try:
+ # 好评接口地址
+ comment_api_url = "http://119.29.64.68:8081/comment"
+
+ # 获取当前账号的cookie
+ cookie_str = self.cookies_str
+
+ payload = {
+ "cookie_str": cookie_str,
+ "order_id": order_id,
+ "comment": comment
+ }
+
+ headers = {
+ "accept": "application/json",
+ "Content-Type": "application/json"
+ }
+
+ async with aiohttp.ClientSession() as session:
+ async with session.post(comment_api_url, json=payload, headers=headers, timeout=30) as response:
+ if response.status == 200:
+ result = await response.json()
+ return {
+ "success": result.get("status") == "success",
+ "message": result.get("message", "好评成功")
+ }
+ else:
+ error_text = await response.text()
+ logger.error(f"【{self.cookie_id}】好评接口返回错误: status={response.status}, body={error_text}")
+ return {
+ "success": False,
+ "message": f"接口返回错误: {response.status}"
+ }
+
+ except asyncio.TimeoutError:
+ logger.error(f"【{self.cookie_id}】好评接口请求超时")
+ return {
+ "success": False,
+ "message": "请求超时"
+ }
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】调用好评接口异常: {self._safe_str(e)}")
+ return {
+ "success": False,
+ "message": str(e)
+ }
+
+ def can_auto_delivery(self, order_id: str) -> bool:
+ """检查是否可以进行自动发货(防重复发货)- 基于订单ID"""
+ if not order_id:
+ # 如果没有订单ID,则不进行冷却检查,允许发货
+ return True
+
+ current_time = time.time()
+ last_delivery = self.last_delivery_time.get(order_id, 0)
+
+ if current_time - last_delivery < self.delivery_cooldown:
+ logger.info(f"【{self.cookie_id}】订单 {order_id} 在冷却期内,跳过自动发货")
+ return False
+
+ return True
+
+ def mark_delivery_sent(self, order_id: str, context: str = "自动发货完成"):
+ """标记订单已发货"""
+ self.delivery_sent_orders.add(order_id)
+ self.last_delivery_time[order_id] = time.time()
+ logger.info(f"【{self.cookie_id}】订单 {order_id} 已标记为发货")
+
+ # 更新订单状态为已发货
+ logger.info(f"【{self.cookie_id}】检查自动发货订单状态处理器: handler_exists={self.order_status_handler is not None}")
+ if self.order_status_handler:
+ logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.handle_auto_delivery_order_status: {order_id}")
+ try:
+ success = self.order_status_handler.handle_auto_delivery_order_status(
+ order_id=order_id,
+ cookie_id=self.cookie_id,
+ context=context
+ )
+ logger.info(f"【{self.cookie_id}】订单状态处理器.handle_auto_delivery_order_status返回结果: {success}")
+ if success:
+ logger.info(f"【{self.cookie_id}】订单 {order_id} 状态已更新为已发货")
+ else:
+ logger.warning(f"【{self.cookie_id}】订单 {order_id} 状态更新为已发货失败")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】订单状态更新失败: {self._safe_str(e)}")
+ import traceback
+ logger.error(f"【{self.cookie_id}】详细错误信息: {traceback.format_exc()}")
+ else:
+ logger.warning(f"【{self.cookie_id}】订单状态处理器为None,跳过自动发货状态更新: {order_id}")
+
+ def _activate_delivery_lock(self, lock_key: str, delay_minutes: int = 10):
+ """在发货成功后激活订单延迟锁,避免重复发货。"""
+ if not lock_key:
+ return
+
+ existing_lock = self._lock_hold_info.get(lock_key)
+ if existing_lock and existing_lock.get('locked'):
+ return
+
+ self._lock_hold_info[lock_key] = {
+ 'locked': True,
+ 'lock_time': time.time(),
+ 'release_time': None,
+ 'task': None
+ }
+ delay_task = asyncio.create_task(self._delayed_lock_release(lock_key, delay_minutes=delay_minutes))
+ self._lock_hold_info[lock_key]['task'] = delay_task
+
+ def _record_delivery_log(self, order_id: str = None, item_id: str = None, buyer_id: str = None,
+ buyer_nick: str = None, status: str = 'failed', reason: str = None,
+ channel: str = 'auto', rule_meta: dict = None):
+ """记录真实发货事件日志(成功/失败)。"""
+ try:
+ from db_manager import db_manager
+ meta = rule_meta or {}
+ log_prefix = f"【{self.cookie_id}】"
+ resolved_buyer_nick = self._resolve_delivery_log_buyer_nick(
+ buyer_nick,
+ order_id=order_id,
+ buyer_id=buyer_id,
+ log_prefix=log_prefix,
+ )
+ normalized_status = str(status or 'failed').strip().lower()
+ if normalized_status not in {'success', 'failed', 'skipped'}:
+ normalized_status = 'failed'
+ db_manager.create_delivery_log(
+ user_id=self.user_id,
+ cookie_id=self.cookie_id,
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=buyer_id,
+ buyer_nick=resolved_buyer_nick,
+ rule_id=meta.get('rule_id'),
+ rule_keyword=meta.get('rule_keyword'),
+ card_type=meta.get('card_type'),
+ match_mode=meta.get('match_mode'),
+ channel=channel or 'auto',
+ status=normalized_status,
+ reason=self._format_delivery_log_reason(reason, meta)
+ )
+ except Exception as log_e:
+ logger.error(f"【{self.cookie_id}】记录发货日志失败: {self._safe_str(log_e)}")
+
+ def _format_delivery_log_reason(self, reason: str = None, rule_meta: dict = None) -> str:
+ """将规格模式上下文拼接到发货日志原因中,便于后续排查。"""
+ meta = rule_meta or {}
+ context_parts = []
+
+ order_spec_mode = meta.get('order_spec_mode')
+ rule_spec_mode = meta.get('rule_spec_mode')
+ item_config_mode = meta.get('item_config_mode')
+
+ if order_spec_mode:
+ context_parts.append(f"order_spec_mode={order_spec_mode}")
+ if rule_spec_mode:
+ context_parts.append(f"rule_spec_mode={rule_spec_mode}")
+ if item_config_mode:
+ context_parts.append(f"item_config_mode={item_config_mode}")
+
+ reason_text = (reason or '').strip()
+ if not context_parts:
+ return reason_text
+
+ if any(part.split('=')[0] + '=' in reason_text for part in context_parts):
+ return reason_text
+
+ if not reason_text:
+ reason_text = '未提供发货日志原因'
+
+ return f"{reason_text} [{', '.join(context_parts)}]"
+
+ async def _finalize_delivery_after_send(self, delivery_meta: dict = None, order_id: str = None,
+ item_id: str = None, skip_confirm: bool = False):
+ """在消息发送成功后提交发货副作用:消费卡密、更新计数、确认发货。"""
+ meta = delivery_meta or {}
+
+ if not meta.get('success'):
+ return {
+ 'success': False,
+ 'error': '发货元数据无效,无法提交副作用'
+ }
+
+ from db_manager import db_manager
+
+ consume_required = bool(meta.get('data_card_pending_consume'))
+ rule_id = meta.get('rule_id')
+ card_id = meta.get('card_id')
+ card_type = meta.get('card_type')
+ expected_line = meta.get('data_line')
+ reservation_id = meta.get('data_reservation_id')
+ reservation_already_finalized = False
+
+ if consume_required:
+ if reservation_id:
+ finalize_state = db_manager.finalize_batch_data_reservation(reservation_id)
+ if not finalize_state.get('success'):
+ return {
+ 'success': False,
+ 'error': '批量数据预占完成失败,已中止后续确认发货'
+ }
+ reservation_already_finalized = bool(finalize_state.get('already_finalized'))
+ elif not card_id or card_type != 'data':
+ return {
+ 'success': False,
+ 'error': '批量数据卡券元数据不完整,无法提交消费'
+ }
+ else:
+ consumed = db_manager.consume_specific_batch_data(card_id, expected_line)
+ if not consumed:
+ return {
+ 'success': False,
+ 'error': '批量数据消费失败,已中止后续确认发货'
+ }
+
+ if rule_id and not consume_required:
+ db_manager.increment_delivery_times(rule_id)
+
+ if order_id and not skip_confirm:
+ if not self.is_auto_confirm_enabled():
+ logger.info(f"自动确认发货已关闭,跳过订单 {order_id}")
+ else:
+ current_time = time.time()
+ should_confirm = True
+
+ if order_id in self.confirmed_orders:
+ last_confirm_time = self.confirmed_orders[order_id]
+ if current_time - last_confirm_time < self.order_confirm_cooldown:
+ logger.info(f"订单 {order_id} 已在 {self.order_confirm_cooldown} 秒内确认过,跳过重复确认")
+ should_confirm = False
+
+ if should_confirm:
+ logger.info(f"开始自动确认发货: 订单ID={order_id}, 商品ID={item_id}")
+ confirm_result = await self.auto_confirm(order_id, item_id)
+ if confirm_result.get('success'):
+ self.confirmed_orders[order_id] = current_time
+ logger.info(f"🎉 自动确认发货成功!订单ID: {order_id}")
+ else:
+ return {
+ 'success': False,
+ 'error': f"自动确认发货失败: {confirm_result.get('error', '未知错误')}"
+ }
+
+ if rule_id and consume_required and not reservation_already_finalized:
+ db_manager.increment_delivery_times(rule_id)
+
+ return {
+ 'success': True
+ }
+
+ def _mark_data_reservation_sent_if_needed(self, delivery_meta: dict = None) -> bool:
+ meta = delivery_meta or {}
+ reservation_id = meta.get('data_reservation_id')
+ if not reservation_id:
+ return True
+
+ from db_manager import db_manager
+ return db_manager.mark_batch_data_reservation_sent(reservation_id)
+
+ def _release_data_reservation_if_needed(self, delivery_meta: dict = None, error: str = None) -> bool:
+ meta = delivery_meta or {}
+ reservation_id = meta.get('data_reservation_id')
+ if not reservation_id:
+ return True
+
+ from db_manager import db_manager
+ return db_manager.release_batch_data_reservation(reservation_id, error=error)
+
+ def _get_pending_delivery_finalization_meta(self, order_id: str, delivery_unit_index: int = 1):
+ if not order_id:
+ return None
+
+ from db_manager import db_manager
+ state = db_manager.get_delivery_finalization_state(order_id, delivery_unit_index)
+ if not state or state.get('status') != 'sent':
+ return None
+
+ delivery_meta = state.get('delivery_meta') or {}
+ delivery_meta.setdefault('success', True)
+ delivery_meta.setdefault('delivery_unit_index', delivery_unit_index)
+ return delivery_meta
+
+ def _persist_delivery_finalization_state(self, order_id: str, item_id: str, buyer_id: str,
+ delivery_meta: dict = None, channel: str = 'auto',
+ status: str = 'sent', last_error: str = None) -> bool:
+ if not order_id:
+ return False
+
+ from db_manager import db_manager
+ meta = delivery_meta or {}
+ unit_index = int(meta.get('delivery_unit_index') or 1)
+ return db_manager.upsert_delivery_finalization_state(
+ order_id=order_id,
+ unit_index=unit_index,
+ cookie_id=self.cookie_id,
+ item_id=item_id,
+ buyer_id=buyer_id,
+ channel=channel,
+ status=status,
+ delivery_meta=meta,
+ last_error=last_error,
+ )
+
+ def _summarize_delivery_progress(self, order_id: str, expected_quantity: int = 1):
+ if not order_id:
+ return {
+ 'order_id': order_id,
+ 'expected_quantity': max(1, int(expected_quantity or 1)),
+ 'aggregate_status': 'pending_ship',
+ 'finalized_count': 0,
+ 'pending_finalize_count': 0,
+ 'remaining_count': max(1, int(expected_quantity or 1)),
+ 'finalized_unit_indexes': [],
+ 'pending_finalize_unit_indexes': [],
+ 'remaining_unit_indexes': list(range(1, max(1, int(expected_quantity or 1)) + 1)),
+ 'states': [],
+ }
+
+ from db_manager import db_manager
+ return db_manager.get_delivery_progress_summary(order_id, expected_quantity=expected_quantity)
+
+ def _resolve_external_order_status(self, current_status: str, incoming_status: str, source: str):
+ from db_manager import db_manager
+
+ merged_status = db_manager.resolve_external_order_status(current_status, incoming_status, source=source)
+ normalized_current = db_manager._normalize_order_status(current_status)
+
+ if merged_status and merged_status != normalized_current:
+ return merged_status
+ return None
+
+ def _normalize_order_amount_text(self, value: Any):
+ text = str(value or '').strip()
+ if not text:
+ return None
+ text = text.replace('¥', '').replace('¥', '').replace(',', '')
+ match = re.search(r'\d+(?:\.\d{1,2})?', text)
+ if not match:
+ return None
+ try:
+ return f"{float(match.group(0)):.2f}"
+ except (TypeError, ValueError):
+ return None
+
+ def _parse_order_amount_float(self, value: Any):
+ normalized = self._normalize_order_amount_text(value)
+ if normalized is None:
+ return None
+ try:
+ return float(normalized)
+ except (TypeError, ValueError):
+ return None
+
+ def _has_bargain_success_evidence(self, order: dict = None) -> bool:
+ order = order or {}
+ return bool(order.get('bargain_success_detected'))
+
+ def _mark_order_bargain_flow(self, order_id: str, item_id: str = None, buyer_id: str = None,
+ sid: str = None, *, apply_configured_price: bool = False,
+ success_detected=..., context: str = '') -> bool:
+ if not order_id:
+ return False
+
+ from db_manager import db_manager
+
+ existing_order = db_manager.get_order_by_id(order_id) or {}
+ effective_item_id = item_id or existing_order.get('item_id')
+ effective_buyer_id = buyer_id or existing_order.get('buyer_id')
+ effective_sid = sid or existing_order.get('sid')
+ amount_to_save = None
+
+ if apply_configured_price and effective_item_id:
+ item_config = db_manager.get_item_info(self.cookie_id, effective_item_id)
+ configured_amount = self._normalize_order_amount_text(item_config.get('item_price') if item_config else None)
+ configured_amount_value = self._parse_order_amount_float(configured_amount)
+ existing_amount_value = self._parse_order_amount_float(existing_order.get('amount'))
+ if configured_amount_value is not None and (
+ existing_amount_value is None or configured_amount_value < existing_amount_value - 0.009
+ ):
+ amount_to_save = configured_amount
+
+ success = db_manager.insert_or_update_order(
+ order_id=order_id,
+ item_id=effective_item_id,
+ buyer_id=effective_buyer_id,
+ sid=effective_sid,
+ amount=amount_to_save,
+ cookie_id=self.cookie_id,
+ bargain_flow_detected=True,
+ bargain_success_detected=success_detected,
+ )
+
+ if success:
+ logger.info(
+ f"【{self.cookie_id}】标记订单为小刀流程: order_id={order_id}, context={context or 'unknown'}, "
+ f"apply_configured_price={apply_configured_price}, amount_override={amount_to_save or ''}, "
+ f"success_detected={success_detected if success_detected is not ... else 'unchanged'}"
+ )
+ else:
+ logger.warning(
+ f"【{self.cookie_id}】标记订单小刀流程失败: order_id={order_id}, context={context or 'unknown'}"
+ )
+ return success
+
+ def _apply_bargain_amount_override(self, order_id: str, item_id: str, amount: Any, amount_source: str,
+ existing_order: dict = None, item_config: dict = None):
+ existing_order = existing_order or {}
+ if not existing_order.get('bargain_flow_detected'):
+ return amount, amount_source
+
+ configured_amount = self._normalize_order_amount_text(item_config.get('item_price') if item_config else None)
+ configured_amount_value = self._parse_order_amount_float(configured_amount)
+ if configured_amount_value is None:
+ return amount, amount_source
+
+ incoming_amount = self._normalize_order_amount_text(amount)
+ incoming_amount_value = self._parse_order_amount_float(incoming_amount)
+
+ if incoming_amount_value is None:
+ logger.warning(
+ f"【{self.cookie_id}】小刀订单缺少可信金额,回退为商品配置价: "
+ f"order_id={order_id}, item_id={item_id}, configured_amount={configured_amount}"
+ )
+ return configured_amount, 'bargain_item_price_locked'
+
+ if incoming_amount_value > configured_amount_value + 0.009:
+ logger.warning(
+ f"【{self.cookie_id}】检测到小刀订单仍返回原价,使用商品配置价覆盖: "
+ f"order_id={order_id}, item_id={item_id}, incoming_amount={incoming_amount}, "
+ f"configured_amount={configured_amount}, amount_source={amount_source}"
+ )
+ return configured_amount, 'bargain_item_price_locked'
+
+ return incoming_amount, amount_source
+
+ def _resolve_delivery_progress_order_status(self, current_status: str, aggregate_status: str):
+ from db_manager import db_manager
+
+ normalized_current = db_manager._normalize_order_status(current_status)
+ normalized_aggregate = db_manager._normalize_order_status(aggregate_status)
+
+ if not normalized_aggregate or normalized_aggregate == 'unknown':
+ return None
+
+ if not normalized_current or normalized_current == 'unknown':
+ return normalized_aggregate
+
+ if normalized_current in {'completed', 'refunding', 'cancelled'} and normalized_aggregate in {
+ 'pending_ship', 'partial_success', 'partial_pending_finalize', 'shipped'
+ }:
+ logger.warning(
+ f"【{self.cookie_id}】保留订单终态,忽略发货进度覆盖: current={normalized_current}, incoming={normalized_aggregate}"
+ )
+ return normalized_current
+
+ if normalized_current == 'shipped' and normalized_aggregate in {'pending_ship', 'partial_success', 'partial_pending_finalize'}:
+ logger.warning(
+ f"【{self.cookie_id}】保留已发货状态,忽略较低发货进度覆盖: current={normalized_current}, incoming={normalized_aggregate}"
+ )
+ return normalized_current
+
+ if normalized_current in {'partial_success', 'partial_pending_finalize'} and normalized_aggregate == 'pending_ship':
+ logger.warning(
+ f"【{self.cookie_id}】保留部分发货状态,忽略待发货覆盖: current={normalized_current}, incoming={normalized_aggregate}"
+ )
+ return normalized_current
+
+ return normalized_aggregate
+
+ def _sync_order_delivery_progress(self, order_id: str, cookie_id: str, expected_quantity: int = 1,
+ context: str = "自动发货进度同步"):
+ summary = self._summarize_delivery_progress(order_id, expected_quantity=expected_quantity)
+ aggregate_status = summary.get('aggregate_status') or 'pending_ship'
+ previous_status = None
+
+ try:
+ from db_manager import db_manager
+ current_order = db_manager.get_order_by_id(order_id) if order_id else None
+ previous_status = db_manager._normalize_order_status(current_order.get('order_status')) if current_order else None
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】读取订单旧状态失败: {self._safe_str(e)}")
+
+ logger.info(
+ f"【{self.cookie_id}】同步订单发货进度: order_id={order_id}, status={aggregate_status}, "
+ f"finalized={summary.get('finalized_count')}/{summary.get('expected_quantity')}, "
+ f"pending_finalize={summary.get('pending_finalize_count')}, remaining={summary.get('remaining_count')}"
+ )
+
+ status_to_write = self._resolve_delivery_progress_order_status(previous_status, aggregate_status)
+
+ if aggregate_status in {'shipped', 'partial_success', 'partial_pending_finalize'}:
+ self.delivery_sent_orders.add(order_id)
+ self.last_delivery_time[order_id] = time.time()
+
+ if self.order_status_handler and status_to_write == 'shipped' and previous_status != 'shipped':
+ try:
+ self.order_status_handler.handle_auto_delivery_order_status(
+ order_id=order_id,
+ cookie_id=cookie_id,
+ context=context
+ )
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】通过状态处理器同步已发货状态失败: {self._safe_str(e)}")
+
+ try:
+ from db_manager import db_manager
+ success = True
+ if status_to_write and status_to_write != previous_status:
+ success = db_manager.insert_or_update_order(order_id=order_id, order_status=status_to_write, cookie_id=cookie_id)
+
+ if success and status_to_write in {'partial_success', 'partial_pending_finalize'} and previous_status != status_to_write:
+ try:
+ from order_event_hub import publish_order_update_event
+ publish_order_update_event(order_id, source='delivery_progress_sync')
+ except Exception as publish_e:
+ logger.warning(
+ f"【{self.cookie_id}】发布部分发货实时事件失败: order_id={order_id}, error={self._safe_str(publish_e)}"
+ )
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】写入订单聚合发货状态失败: {self._safe_str(e)}")
+
+ return summary
+
+ async def _delayed_lock_release(self, lock_key: str, delay_minutes: int = 10):
+ """
+ 延迟释放锁的异步任务
+
+ Args:
+ lock_key: 锁的键
+ delay_minutes: 延迟时间(分钟),默认10分钟
+ """
+ try:
+ delay_seconds = delay_minutes * 60
+ logger.info(f"【{self.cookie_id}】订单锁 {lock_key} 将在 {delay_minutes} 分钟后释放")
+
+ # 等待指定时间
+ await asyncio.sleep(delay_seconds)
+
+ # 检查锁是否仍然存在且需要释放
+ if lock_key in self._lock_hold_info:
+ lock_info = self._lock_hold_info[lock_key]
+ if lock_info.get('locked', False):
+ # 释放锁
+ lock_info['locked'] = False
+ lock_info['release_time'] = time.time()
+ logger.info(f"【{self.cookie_id}】订单锁 {lock_key} 延迟释放完成")
+
+ # 清理锁信息(可选,也可以保留用于统计)
+ # del self._lock_hold_info[lock_key]
+
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】订单锁 {lock_key} 延迟释放任务被取消")
+ raise
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】订单锁 {lock_key} 延迟释放失败: {self._safe_str(e)}")
+
+ def is_lock_held(self, lock_key: str) -> bool:
+ """
+ 检查指定的锁是否仍在持有状态
+
+ Args:
+ lock_key: 锁的键
+
+ Returns:
+ bool: True表示锁仍在持有,False表示锁已释放或不存在
+ """
+ if lock_key not in self._lock_hold_info:
+ return False
+
+ lock_info = self._lock_hold_info[lock_key]
+ return lock_info.get('locked', False)
+
+ def cleanup_expired_locks(self, max_age_hours: int = 24):
+ """
+ 清理过期的锁(包括自动发货锁和订单详情锁)
+
+ Args:
+ max_age_hours: 锁的最大保留时间(小时),默认24小时
+ """
+ try:
+ current_time = time.time()
+ max_age_seconds = max_age_hours * 3600
+
+ # 清理自动发货锁
+ expired_delivery_locks = []
+ for order_id, last_used in self._lock_usage_times.items():
+ if current_time - last_used > max_age_seconds:
+ expired_delivery_locks.append(order_id)
+
+ # 清理过期的自动发货锁
+ for order_id in expired_delivery_locks:
+ if order_id in self._order_locks:
+ del self._order_locks[order_id]
+ if order_id in self._lock_usage_times:
+ del self._lock_usage_times[order_id]
+ # 清理锁持有信息
+ if order_id in self._lock_hold_info:
+ lock_info = self._lock_hold_info[order_id]
+ # 取消延迟释放任务
+ if 'task' in lock_info and lock_info['task']:
+ lock_info['task'].cancel()
+ del self._lock_hold_info[order_id]
+
+ # 清理订单详情锁
+ expired_detail_locks = []
+ for order_id, last_used in self._order_detail_lock_times.items():
+ if current_time - last_used > max_age_seconds:
+ expired_detail_locks.append(order_id)
+
+ # 清理过期的订单详情锁
+ for order_id in expired_detail_locks:
+ if order_id in self._order_detail_locks:
+ del self._order_detail_locks[order_id]
+ if order_id in self._order_detail_lock_times:
+ del self._order_detail_lock_times[order_id]
+
+ expired_refresh_marks = []
+ for order_id, refresh_info in self.order_detail_force_refresh_marks.items():
+ refresh_timestamp = refresh_info.get('timestamp', 0) if isinstance(refresh_info, dict) else 0
+ if current_time - refresh_timestamp > max_age_seconds:
+ expired_refresh_marks.append(order_id)
+
+ for order_id in expired_refresh_marks:
+ self.order_detail_force_refresh_marks.pop(order_id, None)
+
+ total_expired = len(expired_delivery_locks) + len(expired_detail_locks) + len(expired_refresh_marks)
+ if total_expired > 0:
+ logger.info(
+ f"【{self.cookie_id}】清理了 {total_expired} 个过期锁/标记 "
+ f"(发货锁: {len(expired_delivery_locks)}, 详情锁: {len(expired_detail_locks)}, 刷新标记: {len(expired_refresh_marks)})"
+ )
+ logger.warning(f"【{self.cookie_id}】当前锁数量 - 发货锁: {len(self._order_locks)}, 详情锁: {len(self._order_detail_locks)}")
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】清理过期锁时发生错误: {self._safe_str(e)}")
+
+ def _get_order_status_priority(self, status: str) -> int:
+ normalized_status = db_manager._normalize_order_status(status)
+ priority_map = {
+ 'unknown': 0,
+ 'processing': 10,
+ 'pending_payment': 15,
+ 'pending_ship': 20,
+ 'partial_success': 30,
+ 'partial_pending_finalize': 30,
+ 'shipped': 40,
+ 'completed': 50,
+ 'refunding': 60,
+ 'refund_cancelled': 65,
+ 'cancelled': 70,
+ }
+ return priority_map.get(normalized_status or 'unknown', 0)
+
+ def _has_delivery_progress_evidence(self, order_id: str) -> bool:
+ normalized_order_id = str(order_id or '').strip()
+ if not normalized_order_id:
+ return False
+
+ try:
+ summary = self._summarize_delivery_progress(normalized_order_id, expected_quantity=1) or {}
+ except Exception as summary_error:
+ logger.warning(
+ f"【{self.cookie_id}】读取订单发货进度失败,按已有发货证据处理: "
+ f"order_id={normalized_order_id}, error={self._safe_str(summary_error)}"
+ )
+ return True
+
+ state_count = int(summary.get('state_count') or 0)
+ finalized_count = int(summary.get('finalized_count') or 0)
+ pending_finalize_count = int(summary.get('pending_finalize_count') or 0)
+ return state_count > 0 or finalized_count > 0 or pending_finalize_count > 0
+
+ def _reserve_order_detail_force_refresh(self, order_id: str, *, reason: str,
+ log_prefix: str = "", cooldown_seconds: float = None) -> bool:
+ normalized_order_id = str(order_id or '').strip()
+ if not normalized_order_id:
+ return False
+
+ cooldown = float(cooldown_seconds or self.order_detail_force_refresh_cooldown or 0)
+ now = time.time()
+ existing = self.order_detail_force_refresh_marks.get(normalized_order_id) or {}
+ last_timestamp = existing.get('timestamp', 0)
+ elapsed = now - last_timestamp
+
+ if last_timestamp and cooldown > 0 and elapsed < cooldown:
+ logger.info(
+ f"{log_prefix} 订单详情强刷命中冷却,跳过重复刷新: "
+ f"order_id={normalized_order_id}, reason={reason}, "
+ f"last_reason={existing.get('reason', 'unknown')}, remaining={round(cooldown - elapsed, 2)}s"
+ )
+ return False
+
+ self.order_detail_force_refresh_marks[normalized_order_id] = {
+ 'timestamp': now,
+ 'reason': reason,
+ }
+ return True
+
+ def _should_force_refresh_after_status_signal(self, status_signal: str, current_status: str,
+ order_id: str = None) -> bool:
+ normalized_signal = db_manager._normalize_order_status(status_signal)
+ normalized_current = db_manager._normalize_order_status(current_status)
+
+ if not normalized_signal or normalized_signal == 'unknown':
+ return False
+
+ if normalized_signal == 'pending_ship':
+ if normalized_current == 'shipped' and not self._has_delivery_progress_evidence(order_id):
+ logger.warning(
+ f"【{self.cookie_id}】检测到可疑已发货状态,允许待发货信号继续强刷详情: "
+ f"order_id={order_id or 'unknown'}, current_status={normalized_current}, signal={normalized_signal}"
+ )
+ return True
+ return normalized_current in {None, '', 'unknown', 'processing', 'pending_payment'}
+
+ if normalized_signal == 'shipped':
+ return normalized_current in {None, '', 'unknown', 'processing', 'pending_payment', 'pending_ship'}
+
+ if normalized_signal in {'completed', 'cancelled', 'refunding', 'refund_cancelled'}:
+ if not normalized_current or normalized_current == 'unknown':
+ return True
+ return self._get_order_status_priority(normalized_signal) > self._get_order_status_priority(normalized_current)
+
+ return False
+
+ def _should_accept_order_detail_status_correction(self, current_status: str, incoming_status: str,
+ incoming_source: str, *, force_refresh: bool,
+ order_id: str = None) -> bool:
+ normalized_current = db_manager._normalize_order_status(current_status)
+ normalized_incoming = db_manager._normalize_order_status(incoming_status)
+ normalized_source = str(incoming_source or 'unknown').strip().lower()
+
+ if not force_refresh:
+ return False
+ if normalized_current != 'shipped' or normalized_incoming != 'pending_ship':
+ return False
+ if normalized_source not in {'selector', 'button'}:
+ return False
+ if self._has_delivery_progress_evidence(order_id):
+ return False
+ return True
+
+ def _should_reject_order_detail_status_update(self, current_status: str, incoming_status: str,
+ incoming_source: str, *, force_refresh: bool) -> bool:
+ normalized_current = db_manager._normalize_order_status(current_status)
+ normalized_incoming = db_manager._normalize_order_status(incoming_status)
+ normalized_source = str(incoming_source or 'unknown').strip().lower()
+
+ if normalized_incoming != 'completed' or normalized_source != 'body':
+ return False
+
+ if force_refresh and normalized_current in {'shipped', 'pending_ship', 'partial_success', 'partial_pending_finalize'}:
+ return True
+
+ return False
+
+ async def _maybe_force_refresh_order_detail_for_signal(self, order_id: str, *, item_id: str = None,
+ buyer_id: str = None, sid: str = None,
+ buyer_nick: str = None, status_signal: str = None,
+ reason: str = "status_signal",
+ delay_seconds: float = 0,
+ log_prefix: str = "") -> bool:
+ normalized_order_id = str(order_id or '').strip()
+ if not normalized_order_id:
+ return False
+
+ current_order = db_manager.get_order_by_id(normalized_order_id) or {}
+ current_status = current_order.get('order_status')
+ if not self._should_force_refresh_after_status_signal(status_signal, current_status, normalized_order_id):
+ logger.info(
+ f"{log_prefix} 当前订单状态无需为该信号强刷详情: order_id={normalized_order_id}, "
+ f"signal={status_signal or 'unknown'}, current_status={current_status or 'unknown'}"
+ )
+ return False
+
+ if not self._reserve_order_detail_force_refresh(
+ normalized_order_id,
+ reason=reason,
+ log_prefix=log_prefix,
+ ):
+ return False
+
+ if delay_seconds and delay_seconds > 0:
+ await asyncio.sleep(delay_seconds)
+
+ latest_order = db_manager.get_order_by_id(normalized_order_id) or {}
+ latest_status = latest_order.get('order_status')
+ if not self._should_force_refresh_after_status_signal(status_signal, latest_status, normalized_order_id):
+ logger.info(
+ f"{log_prefix} 延迟后订单状态已更新,无需再强刷详情: order_id={normalized_order_id}, "
+ f"signal={status_signal or 'unknown'}, current_status={latest_status or 'unknown'}"
+ )
+ return False
+
+ refresh_item_id = item_id or latest_order.get('item_id')
+ refresh_buyer_id = buyer_id or latest_order.get('buyer_id')
+ logger.info(
+ f"{log_prefix} 状态信号触发订单详情强刷: order_id={normalized_order_id}, "
+ f"signal={status_signal or 'unknown'}, current_status={latest_status or 'unknown'}, reason={reason}"
+ )
+
+ try:
+ await self.fetch_order_detail_info(
+ order_id=normalized_order_id,
+ item_id=refresh_item_id,
+ buyer_id=refresh_buyer_id,
+ sid=sid,
+ buyer_nick=buyer_nick,
+ force_refresh=True
+ )
+ return True
+ except Exception as refresh_error:
+ logger.error(
+ f"{log_prefix} 状态信号触发订单详情强刷失败: order_id={normalized_order_id}, "
+ f"reason={reason}, error={self._safe_str(refresh_error)}"
+ )
+ return False
+
+
+ def _load_json_dict(self, raw_value: Any) -> Dict[str, Any]:
+ """安全解析 JSON 对象。"""
+ if isinstance(raw_value, dict):
+ return raw_value
+ if not isinstance(raw_value, str) or not raw_value.strip():
+ return {}
+ try:
+ parsed = json.loads(raw_value)
+ except Exception:
+ return {}
+ return parsed if isinstance(parsed, dict) else {}
+
+ def _extract_message_card_payload(self, message_1: Any) -> Dict[str, Any]:
+ """提取消息卡片 JSON 载荷。"""
+ if not isinstance(message_1, dict):
+ return {}
+
+ try:
+ message_6 = message_1.get('6', {})
+ if not isinstance(message_6, dict):
+ return {}
+ message_6_3 = message_6.get('3', {})
+ if not isinstance(message_6_3, dict):
+ return {}
+ payload = message_6_3.get('5', '')
+ return self._load_json_dict(payload)
+ except Exception:
+ return {}
+
+ def _extract_message_button_text(self, message_1: Any) -> str:
+ """提取消息卡片按钮文本。"""
+ payload = self._extract_message_card_payload(message_1)
+ try:
+ return str(
+ payload.get('dxCard', {})
+ .get('item', {})
+ .get('main', {})
+ .get('exContent', {})
+ .get('button', {})
+ .get('text', '')
+ ).strip()
+ except Exception:
+ return ''
+
+ def _extract_message_card_title(self, message_1: Any) -> str:
+ """提取消息卡片标题。"""
+ payload = self._extract_message_card_payload(message_1)
+ try:
+ return str(
+ payload.get('dxCard', {})
+ .get('item', {})
+ .get('main', {})
+ .get('exContent', {})
+ .get('title', '')
+ ).strip()
+ except Exception:
+ return ''
+
+ def _classify_message_route(self, *, message: dict, message_1: dict, message_10: dict,
+ send_message: str) -> Dict[str, Any]:
+ """将消息路由到订单状态、系统提示、特殊流程或真人聊天。"""
+ message_direction = message_1.get('7', 0) if isinstance(message_1, dict) else 0
+ content_type = 0
+ try:
+ message_6 = message_1.get('6', {}) if isinstance(message_1, dict) else {}
+ if isinstance(message_6, dict):
+ message_6_3 = message_6.get('3', {})
+ if isinstance(message_6_3, dict):
+ content_type = message_6_3.get('4', 0)
+ except Exception:
+ content_type = 0
+
+ biz_tag_raw = str(message_10.get('bizTag', '') or '').strip()
+ biz_tag_dict = self._load_json_dict(biz_tag_raw)
+ ext_json_dict = self._load_json_dict(message_10.get('extJson', ''))
+ task_name = str(biz_tag_dict.get('taskName') or '').strip()
+ update_key = str(ext_json_dict.get('updateKey') or '').strip()
+ detail_notice = str(message_10.get('detailNotice', '') or '').strip()
+ reminder_content = str(message_10.get('reminderContent', '') or send_message or '').strip()
+ reminder_title = str(message_10.get('reminderTitle', '') or '').strip()
+ reminder_notice = str(message_10.get('reminderNotice', '') or '').strip()
+ red_reminder = ''
+ if isinstance(message, dict) and isinstance(message.get('3'), dict):
+ red_reminder = str(message.get('3', {}).get('redReminder', '') or '').strip()
+
+ button_text = self._extract_message_button_text(message_1)
+ card_title = self._extract_message_card_title(message_1)
+ session_type = str(message_10.get('sessionType', '1') or '1').strip()
+ is_group_message = session_type == '30'
+ is_system_biz = bool(task_name) or 'SECURITY' in biz_tag_raw or 'taskId' in biz_tag_raw
+ is_system_message = message_direction == 1 or content_type == 6 or is_system_biz
+
+ texts = []
+ for raw_text in (
+ send_message,
+ reminder_content,
+ detail_notice,
+ reminder_title,
+ reminder_notice,
+ red_reminder,
+ task_name,
+ update_key,
+ button_text,
+ card_title,
+ ):
+ normalized_text = str(raw_text or '').strip()
+ if normalized_text and normalized_text not in texts:
+ texts.append(normalized_text)
+
+ special_flow_messages = {
+ '[卡片消息]',
+ '快给ta一个评价吧~',
+ '快给ta一个评价吧~',
+ }
+ special_flow_titles = {
+ '我已小刀,待刀成',
+ '我已小刀,待刀成',
+ '我已成功小刀,待发货',
+ '我已成功小刀,待发货',
+ }
+
+ if send_message in special_flow_messages or card_title in special_flow_titles:
+ route = 'special_flow'
+ order_status_signal = None
+ else:
+ order_status_signal = None
+ closed_markers = (
+ '[你关闭了订单,钱款已原路退返]',
+ '交易关闭',
+ '订单关闭',
+ '钱款已原路退返',
+ )
+ refund_markers = (
+ '退款中',
+ '退款成功',
+ '退货退款',
+ '退款关闭',
+ )
+ completed_markers = (
+ '[买家确认收货,交易成功]',
+ '[你已确认收货,交易成功]',
+ '买家确认收货',
+ '交易成功',
+ )
+ shipped_markers = (
+ '[你已发货]',
+ '已发货',
+ '等待买家收货',
+ )
+ pending_ship_markers = (
+ '[我已付款,等待你发货]',
+ '[已付款,待发货]',
+ '我已付款,等待你发货',
+ '[记得及时发货]',
+ '等待你发货',
+ '待发货',
+ '去发货',
+ '付款完成待发货',
+ 'TRADE_PAID_DONE_SELLER',
+ )
+ pending_payment_markers = (
+ '[我已拍下,待付款]',
+ '买家已拍下,待付款',
+ '待付款',
+ '等待买家付款',
+ '已拍下_未付款',
+ )
+ system_notice_markers = (
+ '闲鱼小红花',
+ '温馨提醒',
+ '曝光卡',
+ '蚂蚁森林',
+ '能量可领',
+ '创建合约',
+ '假客服骗钱',
+ '订单即将自动确认收货',
+ '宝贝性价比如何,去表个态吧',
+ '发来一条消息',
+ '发来一条新消息',
+ '已送出小红花',
+ '已收下',
+ )
+
+ def _contains_any(markers) -> bool:
+ return any(marker and marker in text for text in texts for marker in markers)
+
+ if _contains_any(closed_markers):
+ order_status_signal = 'cancelled'
+ elif _contains_any(refund_markers):
+ order_status_signal = 'refunding'
+ elif _contains_any(completed_markers):
+ order_status_signal = 'completed'
+ elif _contains_any(shipped_markers):
+ order_status_signal = 'shipped'
+ elif _contains_any(pending_ship_markers):
+ order_status_signal = 'pending_ship'
+ elif _contains_any(pending_payment_markers):
+ order_status_signal = 'pending_payment'
+
+ if is_system_message and order_status_signal:
+ route = 'order_status'
+ elif _contains_any(system_notice_markers) and (is_system_message or message_direction != 2):
+ route = 'system_notice'
+ elif is_system_message:
+ route = 'system_notice'
+ else:
+ route = 'user_chat'
+
+ should_notify = False
+ if not is_group_message:
+ if route == 'user_chat':
+ should_notify = True
+ elif route == 'order_status' and order_status_signal in {'pending_ship', 'refunding', 'cancelled'}:
+ should_notify = True
+
+ return {
+ 'route': route,
+ 'order_status_signal': order_status_signal,
+ 'should_notify': should_notify,
+ 'allow_auto_reply': route == 'user_chat',
+ 'is_system_message': is_system_message,
+ 'is_group_message': is_group_message,
+ 'message_direction': message_direction,
+ 'content_type': content_type,
+ 'task_name': task_name,
+ 'button_text': button_text,
+ 'card_title': card_title,
+ 'texts': texts,
+ }
+
+ def _is_auto_delivery_trigger(self, message: str) -> bool:
+ """检查消息是否为自动发货触发关键字"""
+ # 定义所有自动发货触发关键字
+ auto_delivery_keywords = [
+ # 系统消息
+ '[我已付款,等待你发货]',
+ '[已付款,待发货]',
+ '我已付款,等待你发货',
+ '[记得及时发货]',
+ ]
+
+ # 检查消息是否包含任何触发关键字
+ for keyword in auto_delivery_keywords:
+ if keyword in message:
+ return True
+
+ return False
+
+ def _extract_order_id_from_update_key(self, raw_text: Any) -> Optional[str]:
+ normalized_text = str(raw_text or '').strip()
+ if not normalized_text:
+ return None
+
+ direct_match_found = False
+ direct_match = re.search(r'updateKey["\']?\s*[:=]\s*["\']([^"\']+)', normalized_text)
+ if direct_match:
+ direct_match_found = True
+ normalized_text = direct_match.group(1)
+
+ colon_parts = [part.strip().strip('"\'') for part in normalized_text.split(':')]
+ long_numeric_parts = [part for part in colon_parts if part.isdigit() and len(part) >= 16]
+ if long_numeric_parts:
+ return long_numeric_parts[0]
+
+ if direct_match_found:
+ generic_matches = re.findall(r'\d{16,}', normalized_text)
+ if generic_matches:
+ return generic_matches[0]
+ return None
+
+ def _extract_order_id_from_candidate_text(self, raw_text: Any, source: str = '') -> Optional[str]:
+ normalized_text = str(raw_text or '').strip()
+ if not normalized_text:
+ return None
+
+ patterns = [
+ r'orderId(?:=|:|%3[Dd]|\\u003[dD])\s*"?(\d{10,})',
+ r'bizOrderId["\']?\s*[:=]\s*"?(\d{10,})',
+ r'order[_-]?id["\']?\s*[:=]\s*"?(\d{10,})',
+ r'order[_-]?detail\?(?:[^\s#]*?&)?id=(\d{10,})',
+ r'order-detail\?(?:[^\s#]*?&)?orderId=(\d{10,})',
+ ]
+
+ for pattern in patterns:
+ match = re.search(pattern, normalized_text)
+ if match:
+ return match.group(1)
+
+ source_lower = source.lower()
+ text_lower = normalized_text.lower()
+ if (
+ 'updatekey' in source_lower
+ or 'updatekey' in text_lower
+ or ('trade_' in text_lower and ':' in normalized_text)
+ or ('buyer_confirm' in text_lower and ':' in normalized_text)
+ ):
+ return self._extract_order_id_from_update_key(normalized_text)
+
+ return None
+
+ def _collect_order_id_candidate_texts(self, data: Any, root: str = 'message'):
+ candidates = []
+ seen = set()
+
+ def add_candidate(source: str, value: Any):
+ if value is None:
+ return
+ normalized_text = str(value).strip()
+ if not normalized_text:
+ return
+ dedupe_key = (source, normalized_text)
+ if dedupe_key in seen:
+ return
+ seen.add(dedupe_key)
+ candidates.append((source, normalized_text))
+
+ if normalized_text[:1] in {'{', '['}:
+ try:
+ parsed_value = json.loads(normalized_text)
+ except Exception:
+ return
+ walk_value(parsed_value, f'{source}.json')
+
+ def walk_value(value: Any, source: str):
+ if isinstance(value, dict):
+ for key, nested_value in value.items():
+ nested_source = f'{source}.{key}'
+ if isinstance(nested_value, (dict, list)):
+ walk_value(nested_value, nested_source)
+ else:
+ add_candidate(nested_source, nested_value)
+ elif isinstance(value, list):
+ for index, nested_value in enumerate(value[:20]):
+ walk_value(nested_value, f'{source}[{index}]')
+ else:
+ add_candidate(source, value)
+
+ walk_value(data, root)
+ return candidates
+
+ def _extract_order_id(self, message: dict, raw_message_data: dict = None) -> str:
+ """从消息中提取订单ID
+
+ Args:
+ message: 解密后的消息内容
+ raw_message_data: 原始的WebSocket消息数据(用于在解密消息中找不到订单ID时进行搜索)
+ """
+ try:
+ # 先查看消息的完整结构
+ logger.warning(f"【{self.cookie_id}】🔍 完整消息结构: {message}")
+
+ for source, candidate_text in self._collect_order_id_candidate_texts(message, root='message'):
+ order_id = self._extract_order_id_from_candidate_text(candidate_text, source=source)
+ if order_id:
+ logger.info(f'【{self.cookie_id}】🎯 最终提取到订单ID: {order_id} (source={source})')
+ return order_id
+
+ if raw_message_data:
+ logger.info(f'【{self.cookie_id}】🔍 尝试从原始消息数据中搜索订单ID')
+ for source, candidate_text in self._collect_order_id_candidate_texts(raw_message_data, root='raw_message'):
+ order_id = self._extract_order_id_from_candidate_text(candidate_text, source=source)
+ if order_id:
+ logger.info(f'【{self.cookie_id}】🎯 从原始消息提取到订单ID: {order_id} (source={source})')
+ return order_id
+
+ try:
+ sync_data_list = raw_message_data.get("body", {}).get("syncPushPackage", {}).get("data", [])
+ for idx, sync_data_item in enumerate(sync_data_list[:20]):
+ if not isinstance(sync_data_item, dict) or "data" not in sync_data_item:
+ continue
+
+ item_data = sync_data_item.get("data")
+ if item_data is None:
+ continue
+
+ try:
+ decoded_data = base64.b64decode(item_data).decode("utf-8")
+ except Exception:
+ decoded_data = item_data
+
+ for source, candidate_text in self._collect_order_id_candidate_texts(decoded_data, root=f'raw_sync[{idx}]'):
+ order_id = self._extract_order_id_from_candidate_text(candidate_text, source=source)
+ if order_id:
+ logger.info(f'【{self.cookie_id}】🎯 从syncPushPackage.data提取到订单ID: {order_id} (source={source})')
+ return order_id
+ except Exception as multi_data_e:
+ logger.warning(f"遍历syncPushPackage.data时出错: {multi_data_e}")
+
+ 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 _handle_simple_message_auto_delivery(self, websocket, order_id: str, item_id: str,
+ user_id: str, chat_id: str, msg_time: str, msg_id: str):
+ """处理简化结构消息的自动发货逻辑
+
+ 专门用于处理简化结构的发货通知消息(message['1']是字符串的情况)
+ 发货确认统一在 _auto_delivery 内执行,避免重复确认导致漏发
+
+ Args:
+ websocket: WebSocket连接
+ order_id: 订单ID
+ item_id: 商品ID
+ user_id: 买家用户ID
+ chat_id: 聊天ID
+ msg_time: 消息时间
+ msg_id: 消息ID
+ """
+ try:
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 🚀 开始处理简化消息自动发货: order_id={order_id}, item_id={item_id}')
+
+ # 检查商品是否属于当前账号
+ if item_id and item_id != "未知商品":
+ try:
+ if not await self._ensure_item_owned_by_current_account(
+ item_id,
+ log_prefix=f'[{msg_time}] 【{self.cookie_id}】[{msg_id}]'
+ ):
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ❌ 商品 {item_id} 不属于当前账号,跳过自动发货')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='failed',
+ reason='商品不属于当前账号,跳过自动发货',
+ channel='auto'
+ )
+ return
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ✅ 商品 {item_id} 归属验证通过')
+ except Exception as e:
+ logger.error(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 检查商品归属失败: {self._safe_str(e)},跳过自动发货')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='failed',
+ reason=f'检查商品归属失败: {self._safe_str(e)}',
+ channel='auto'
+ )
+ return
+
+ # 检查订单是否已发货
+ if not self.can_auto_delivery(order_id):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 订单 {order_id} 在冷却期内,跳过发货')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='skipped',
+ reason='订单在冷却期内,跳过发货',
+ channel='auto'
+ )
+ return
+
+ # 检查延迟锁状态
+ lock_key = order_id
+ if self.is_lock_held(lock_key):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 🔒 订单 {lock_key} 延迟锁仍在持有状态,跳过发货')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='skipped',
+ reason='订单延迟锁持有中,跳过发货',
+ channel='auto'
+ )
+ return
+
+ # 获取订单锁
+ order_lock = self._order_locks[lock_key]
+ self._lock_usage_times[lock_key] = time.time()
+
+ async with order_lock:
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 获取订单锁成功: {lock_key}')
+
+ # 再次检查延迟锁和冷却状态
+ if self.is_lock_held(lock_key) or not self.can_auto_delivery(order_id):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 获取锁后检查发现订单已处理,跳过发货')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='skipped',
+ reason='获取锁后发现订单已处理,跳过发货',
+ channel='auto'
+ )
+ return
+
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 📤 开始执行自动发货内容发送(发送成功后再确认发货)')
+
+ # 获取商品标题
+ item_title = "待获取商品信息"
+
+ pending_finalize_meta = self._get_pending_delivery_finalization_meta(order_id, 1)
+ if pending_finalize_meta:
+ finalize_result = await self._finalize_delivery_after_send(
+ delivery_meta=pending_finalize_meta,
+ order_id=order_id,
+ item_id=item_id
+ )
+ if not finalize_result.get('success'):
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ delivery_meta=pending_finalize_meta,
+ channel='auto',
+ status='sent',
+ last_error=finalize_result.get('error') or '补完成 finalize 失败'
+ )
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='failed',
+ reason=finalize_result.get('error') or '检测到已发送记录,但补完成发货收尾失败',
+ channel='auto',
+ rule_meta=pending_finalize_meta
+ )
+ await self.send_delivery_failure_notification(
+ send_user_name="买家",
+ send_user_id=user_id,
+ item_id=item_id,
+ error_message=finalize_result.get('error') or '检测到已发送记录,但补完成发货收尾失败',
+ chat_id=chat_id
+ )
+ return
+
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ delivery_meta=pending_finalize_meta,
+ channel='auto',
+ status='finalized'
+ )
+ self._sync_order_delivery_progress(
+ order_id=order_id,
+ cookie_id=self.cookie_id,
+ expected_quantity=1,
+ context="自动发货补完成收尾成功"
+ )
+ self._activate_delivery_lock(lock_key, delay_minutes=10)
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='success',
+ reason='检测到发货消息已发送,本次补完成收尾成功',
+ channel='auto',
+ rule_meta=pending_finalize_meta
+ )
+ await self.send_delivery_failure_notification(
+ send_user_name="买家",
+ send_user_id=user_id,
+ item_id=item_id,
+ error_message="发货成功",
+ chat_id=chat_id
+ )
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ✅ 简化消息自动发货补完成收尾成功')
+ return
+
+ # 调用自动发货方法获取发货内容
+ delivery_result = await self._auto_delivery(
+ item_id, item_title, order_id, user_id, chat_id, include_meta=True
+ )
+ if isinstance(delivery_result, dict):
+ delivery_content = delivery_result.get('content')
+ delivery_error = delivery_result.get('error')
+ delivery_steps = delivery_result.get('delivery_steps') or []
+ delivery_rule_meta = {
+ 'rule_id': delivery_result.get('rule_id'),
+ 'rule_keyword': delivery_result.get('rule_keyword'),
+ 'card_type': delivery_result.get('card_type'),
+ 'match_mode': delivery_result.get('match_mode'),
+ 'order_spec_mode': delivery_result.get('order_spec_mode'),
+ 'rule_spec_mode': delivery_result.get('rule_spec_mode'),
+ 'item_config_mode': delivery_result.get('item_config_mode'),
+ 'card_id': delivery_result.get('card_id'),
+ 'card_description': delivery_result.get('card_description'),
+ 'data_card_pending_consume': delivery_result.get('data_card_pending_consume'),
+ 'data_line': delivery_result.get('data_line'),
+ 'data_reservation_id': delivery_result.get('data_reservation_id'),
+ 'data_reservation_status': delivery_result.get('data_reservation_status'),
+ 'delivery_unit_index': delivery_result.get('delivery_unit_index')
+ }
+ else:
+ delivery_content = delivery_result
+ delivery_error = None
+ delivery_steps = []
+ delivery_rule_meta = {}
+
+ if delivery_content:
+ delivery_rule_meta.setdefault('success', True)
+ if not delivery_steps:
+ delivery_steps = self._build_delivery_steps(
+ delivery_content,
+ delivery_rule_meta.get('card_description', '')
+ )
+
+ # 发送发货内容
+ user_url = f'https://www.goofish.com/personal?userId={user_id}'
+
+ try:
+ await self._send_delivery_steps(
+ websocket,
+ chat_id,
+ user_id,
+ delivery_steps,
+ user_url=user_url,
+ log_prefix=f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 自动发货'
+ )
+
+ if not self._mark_data_reservation_sent_if_needed(delivery_result if isinstance(delivery_result, dict) else delivery_rule_meta):
+ self._release_data_reservation_if_needed(
+ delivery_result if isinstance(delivery_result, dict) else delivery_rule_meta,
+ error='发送成功后标记预占已发送失败'
+ )
+ raise Exception('批量数据预占标记已发送失败')
+
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ delivery_meta=delivery_result if isinstance(delivery_result, dict) else delivery_rule_meta,
+ channel='auto',
+ status='sent'
+ )
+
+ finalize_result = await self._finalize_delivery_after_send(
+ delivery_meta=delivery_result if isinstance(delivery_result, dict) else delivery_rule_meta,
+ order_id=order_id,
+ item_id=item_id
+ )
+ if not finalize_result.get('success'):
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ delivery_meta=delivery_result if isinstance(delivery_result, dict) else delivery_rule_meta,
+ channel='auto',
+ status='sent',
+ last_error=finalize_result.get('error') or '发送成功但提交发货副作用失败'
+ )
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='failed',
+ reason=finalize_result.get('error') or '发送成功但提交发货副作用失败',
+ channel='auto',
+ rule_meta=delivery_rule_meta
+ )
+ await self.send_delivery_failure_notification(
+ send_user_name="买家",
+ send_user_id=user_id,
+ item_id=item_id,
+ error_message=finalize_result.get('error') or '发送成功但提交发货副作用失败',
+ chat_id=chat_id
+ )
+ return
+
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ delivery_meta=delivery_result if isinstance(delivery_result, dict) else delivery_rule_meta,
+ channel='auto',
+ status='finalized'
+ )
+
+ self._sync_order_delivery_progress(
+ order_id=order_id,
+ cookie_id=self.cookie_id,
+ expected_quantity=1,
+ context="自动发货发送成功"
+ )
+ self._activate_delivery_lock(lock_key, delay_minutes=10)
+
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='success',
+ reason='自动发货步骤发送成功',
+ channel='auto',
+ rule_meta=delivery_rule_meta
+ )
+ except Exception as send_e:
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='failed',
+ reason=f'自动发货消息发送失败: {self._safe_str(send_e)}',
+ channel='auto',
+ rule_meta=delivery_rule_meta
+ )
+ raise
+
+ # 发送成功通知
+ await self.send_delivery_failure_notification(
+ send_user_name="买家",
+ send_user_id=user_id,
+ item_id=item_id,
+ error_message="发货成功",
+ chat_id=chat_id
+ )
+
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ✅ 简化消息自动发货完成')
+ else:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ❌ 未找到匹配的发货规则或获取发货内容失败')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='failed',
+ reason=delivery_error or '未找到匹配的发货规则或获取发货内容失败',
+ channel='auto',
+ rule_meta=delivery_rule_meta
+ )
+ await self.send_delivery_failure_notification(
+ send_user_name="买家",
+ send_user_id=user_id,
+ item_id=item_id,
+ error_message="未找到匹配的发货规则或获取发货内容失败",
+ chat_id=chat_id
+ )
+
+ except Exception as e:
+ self._release_data_reservation_if_needed(
+ delivery_result if 'delivery_result' in locals() and isinstance(delivery_result, dict) else delivery_rule_meta if 'delivery_rule_meta' in locals() else None,
+ error=f'自动发货发送失败: {self._safe_str(e)}'
+ )
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=user_id,
+ status='failed',
+ reason=f'简化消息自动发货异常: {self._safe_str(e)}',
+ channel='auto'
+ )
+ logger.error(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 简化消息自动发货异常: {self._safe_str(e)}')
+ import traceback
+ logger.error(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 异常堆栈: {traceback.format_exc()}')
+
+ async def _handle_auto_delivery(self, websocket, message: dict, send_user_name: str, send_user_id: str,
+ item_id: str, chat_id: str, msg_time: str, message_data: dict = None):
+ """统一处理自动发货逻辑
+
+ Args:
+ message_data: 原始的WebSocket消息数据,用于提取订单ID时的备用搜索
+ """
+ try:
+ from db_manager import db_manager
+
+ # 检查商品是否属于当前cookies
+ if item_id and item_id != "未知商品":
+ try:
+ if not await self._ensure_item_owned_by_current_account(
+ item_id,
+ log_prefix=f'[{msg_time}] 【{self.cookie_id}】'
+ ):
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 商品 {item_id} 不属于当前账号,跳过自动发货')
+ self._record_delivery_log(
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason='商品不属于当前账号,跳过自动发货',
+ channel='auto'
+ )
+ return
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】✅ 商品 {item_id} 归属验证通过')
+ except Exception as e:
+ logger.error(f'[{msg_time}] 【{self.cookie_id}】检查商品归属失败: {self._safe_str(e)},跳过自动发货')
+ self._record_delivery_log(
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=f'检查商品归属失败: {self._safe_str(e)}',
+ channel='auto'
+ )
+ return
+
+ # 提取订单ID(传递原始消息数据以便在解密消息中找不到时进行备用搜索)
+ order_id = self._extract_order_id(message, message_data)
+
+ # 如果order_id不存在,尝试通过sid进行兜底查单
+ if not order_id:
+ fallback_sid = None
+ try:
+ message_1 = message.get('1', {}) if isinstance(message, dict) else {}
+ if isinstance(message_1, dict):
+ # 优先使用会话字段
+ fallback_sid = message_1.get('2', '')
+
+ # 备用:从reminderUrl里解析sid
+ if not fallback_sid:
+ message_10 = message_1.get('10', {})
+ if isinstance(message_10, dict):
+ reminder_url = message_10.get('reminderUrl', '') or ''
+ sid_match = re.search(r'[?&]sid=([^&]+)', reminder_url)
+ if sid_match:
+ fallback_sid = sid_match.group(1)
+ except Exception as sid_e:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】解析sid失败: {self._safe_str(sid_e)}')
+
+ if fallback_sid:
+ try:
+ log_prefix = f'[{msg_time}] 【{self.cookie_id}】'
+ sid_lookup_minutes = 5
+ sid_lookup = self._lookup_delivery_order_by_sid(
+ fallback_sid,
+ minutes=sid_lookup_minutes,
+ log_prefix=log_prefix
+ )
+ sid_lookup = await self._refresh_sid_lookup_if_needed(
+ fallback_sid,
+ sid_lookup,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ minutes=sid_lookup_minutes,
+ allow_bargain_ready=True,
+ log_prefix=log_prefix
+ )
+ except Exception as sid_query_e:
+ logger.error(f'[{msg_time}] 【{self.cookie_id}】sid兜底查单异常: {self._safe_str(sid_query_e)}')
+ sid_lookup = {'match_type': 'error', 'order': None}
+
+ recent_order = sid_lookup.get('order')
+ sid_match_type = sid_lookup.get('match_type', 'missing')
+
+ if recent_order and sid_match_type in {'pending_ship', 'bargain_ready'}:
+ fallback_order_id = recent_order.get('order_id')
+ fallback_item_id = recent_order.get('item_id')
+ fallback_buyer_id = recent_order.get('buyer_id')
+
+ # 防串单:买家不一致直接拒绝(仅当 DB 中的 buyer_id 可信时才校验)
+ if send_user_id and fallback_buyer_id and self._is_trustworthy_buyer_id(fallback_buyer_id) and str(send_user_id) != str(fallback_buyer_id):
+ logger.warning(
+ f'[{msg_time}] 【{self.cookie_id}】❌ sid兜底命中订单但买家不一致,已拒绝发货: '
+ f'send_user_id={send_user_id}, order_buyer_id={fallback_buyer_id}, sid={fallback_sid}'
+ )
+ return
+
+ # 防串单:商品不一致直接拒绝
+ if item_id and item_id != "未知商品" and fallback_item_id and str(item_id) != str(fallback_item_id):
+ logger.warning(
+ f'[{msg_time}] 【{self.cookie_id}】❌ sid兜底命中订单但商品不一致,已拒绝发货: '
+ f'message_item_id={item_id}, order_item_id={fallback_item_id}, sid={fallback_sid}'
+ )
+ return
+
+ order_id = fallback_order_id
+ if (not item_id or item_id == "未知商品") and fallback_item_id:
+ item_id = fallback_item_id
+
+ if sid_match_type == 'bargain_ready':
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】✅ 订单ID提取失败,但检测到小刀成功证据,'
+ f'使用sid兜底直接进入自动发货: sid={fallback_sid}, order_id={order_id}'
+ )
+
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】✅ 订单ID提取失败,已通过sid兜底定位订单: '
+ f'sid={fallback_sid}, order_id={order_id}, item_id={item_id}'
+ )
+ elif recent_order:
+ fallback_order_id = recent_order.get('order_id')
+ fallback_status = recent_order.get('order_status') or 'unknown'
+ if sid_match_type == 'already_processed':
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】ℹ️ 订单ID提取失败,但sid命中的订单已处理完成,跳过重复发货: '
+ f'sid={fallback_sid}, order_id={fallback_order_id}, status={fallback_status}'
+ )
+ elif sid_match_type == 'cancelled':
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】ℹ️ 订单ID提取失败,但sid命中的订单已关闭,跳过自动发货: '
+ f'sid={fallback_sid}, order_id={fallback_order_id}'
+ )
+ else:
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】ℹ️ 订单ID提取失败,但sid命中的订单当前状态不适合兜底发货,等待后续完整消息: '
+ f'sid={fallback_sid}, order_id={fallback_order_id}, status={fallback_status}'
+ )
+ return
+ else:
+ logger.warning(
+ f'[{msg_time}] 【{self.cookie_id}】❌ 未能提取到订单ID,sid兜底也未命中待发货订单,跳过自动发货 '
+ f'(sid={fallback_sid})'
+ )
+ self._record_delivery_log(
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=f'未能提取订单ID且sid未命中待发货订单: sid={fallback_sid}',
+ channel='auto'
+ )
+ return
+ else:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 未能提取到订单ID且无可用sid,跳过自动发货')
+ self._record_delivery_log(
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason='未能提取到订单ID且无可用sid,跳过自动发货',
+ channel='auto'
+ )
+ return
+
+ # 订单ID已提取,将在自动发货时进行确认发货处理
+ # 防串单:对直接提取/兜底后的订单进行一致性校验
+ try:
+ existing_order = db_manager.get_order_by_id(order_id)
+ except Exception as order_check_e:
+ logger.error(f'[{msg_time}] 【{self.cookie_id}】查询订单一致性校验失败: {self._safe_str(order_check_e)}')
+ existing_order = None
+
+ if existing_order:
+ existing_buyer_id = existing_order.get('buyer_id')
+ existing_item_id = existing_order.get('item_id')
+
+ if send_user_id and existing_buyer_id and self._is_trustworthy_buyer_id(existing_buyer_id) and str(send_user_id) != str(existing_buyer_id):
+ logger.warning(
+ f'[{msg_time}] 【{self.cookie_id}】❌ 订单与当前会话买家不一致,拒绝自动发货: '
+ f'order_id={order_id}, send_user_id={send_user_id}, order_buyer_id={existing_buyer_id}'
+ )
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason='订单与当前会话买家不一致,拒绝自动发货',
+ channel='auto'
+ )
+ return
+
+ if item_id and item_id != "未知商品" and existing_item_id and str(item_id) != str(existing_item_id):
+ logger.warning(
+ f'[{msg_time}] 【{self.cookie_id}】❌ 订单与当前会话商品不一致,拒绝自动发货: '
+ f'order_id={order_id}, message_item_id={item_id}, order_item_id={existing_item_id}'
+ )
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason='订单与当前会话商品不一致,拒绝自动发货',
+ channel='auto'
+ )
+ return
+
+ if (not item_id or item_id == "未知商品") and existing_item_id:
+ item_id = existing_item_id
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】订单一致性校验补全商品ID: {item_id}')
+
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】提取到订单ID: {order_id},将在自动发货时处理确认发货')
+
+ # 使用订单ID作为锁的键
+ lock_key = order_id
+
+ # 第一重检查:延迟锁状态(在获取锁之前检查,避免不必要的等待)
+ if self.is_lock_held(lock_key):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】🔒【提前检查】订单 {lock_key} 延迟锁仍在持有状态,跳过发货')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason='订单延迟锁持有中,跳过发货',
+ channel='auto'
+ )
+ return
+
+ # 第二重检查:基于时间的冷却机制
+ if not self.can_auto_delivery(order_id):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 在冷却期内,跳过发货')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason='订单在冷却期内,跳过发货',
+ channel='auto'
+ )
+ return
+
+ # 获取或创建该订单的锁
+ order_lock = self._order_locks[lock_key]
+
+ # 更新锁的使用时间
+ self._lock_usage_times[lock_key] = time.time()
+
+ # 使用异步锁防止同一订单的并发处理
+ async with order_lock:
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】获取订单锁成功: {lock_key},开始处理自动发货')
+
+ # 第三重检查:获取锁后再次检查延迟锁状态(双重检查,防止在等待锁期间状态发生变化)
+ if self.is_lock_held(lock_key):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】订单 {lock_key} 在获取锁后检查发现延迟锁仍持有,跳过发货')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason='获取锁后发现延迟锁仍持有,跳过发货',
+ channel='auto'
+ )
+ return
+
+ # 第四重检查:获取锁后再次检查冷却状态
+ if not self.can_auto_delivery(order_id):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】订单 {order_id} 在获取锁后检查发现仍在冷却期,跳过发货')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason='获取锁后发现订单仍在冷却期,跳过发货',
+ channel='auto'
+ )
+ return
+
+ # 构造用户URL
+ user_url = f'https://www.goofish.com/personal?userId={send_user_id}'
+
+ # 自动发货逻辑
+ try:
+ # 设置默认标题(将通过API获取真实商品信息)
+ item_title = "待获取商品信息"
+
+ logger.info(f"【{self.cookie_id}】准备自动发货: item_id={item_id}, item_title={item_title}")
+
+ # 检查是否需要多数量发货
+ from db_manager import db_manager
+ quantity_to_send = 1 # 默认发送1个
+
+ # 检查商品是否开启了多数量发货
+ multi_quantity_delivery = db_manager.get_item_multi_quantity_delivery_status(self.cookie_id, item_id)
+
+ if multi_quantity_delivery and order_id:
+ logger.info(f"商品 {item_id} 开启了多数量发货,获取订单详情...")
+ try:
+ # 使用现有方法获取订单详情
+ order_detail = await self.fetch_order_detail_info(order_id, item_id, send_user_id)
+ if order_detail and order_detail.get('quantity'):
+ try:
+ order_quantity = int(order_detail['quantity'])
+ if order_quantity > 1:
+ quantity_to_send = order_quantity
+ logger.info(f"从订单详情获取数量: {order_quantity},将发送 {quantity_to_send} 个卡券")
+ else:
+ logger.info(f"订单数量为 {order_quantity},发送单个卡券")
+ except (ValueError, TypeError):
+ logger.warning(f"订单数量格式无效: {order_detail.get('quantity')},发送单个卡券")
+ else:
+ logger.info(f"未获取到订单数量信息,发送单个卡券")
+ except Exception as e:
+ logger.error(f"获取订单详情失败: {self._safe_str(e)},发送单个卡券")
+ elif not multi_quantity_delivery:
+ logger.info(f"商品 {item_id} 未开启多数量发货,发送单个卡券")
+ else:
+ logger.info(f"无订单ID,发送单个卡券")
+
+ successful_send_count = 0
+ last_delivery_error = None
+ prepared_units = []
+
+ for i in range(quantity_to_send):
+ unit_index = i + 1
+ rule_meta = {}
+ try:
+ pending_finalize_meta = self._get_pending_delivery_finalization_meta(order_id, unit_index)
+ if pending_finalize_meta:
+ finalize_result = await self._finalize_delivery_after_send(
+ delivery_meta=pending_finalize_meta,
+ order_id=order_id,
+ item_id=item_id
+ )
+ if not finalize_result.get('success'):
+ last_delivery_error = finalize_result.get('error') or f"第 {unit_index} 个卡券补完成收尾失败"
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ delivery_meta=pending_finalize_meta,
+ channel='auto',
+ status='sent',
+ last_error=last_delivery_error
+ )
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=last_delivery_error,
+ channel='auto',
+ rule_meta=pending_finalize_meta
+ )
+ logger.error(last_delivery_error)
+ continue
+
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ delivery_meta=pending_finalize_meta,
+ channel='auto',
+ status='finalized'
+ )
+ successful_send_count += 1
+
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='success',
+ reason='检测到发货消息已发送,本次补完成收尾成功',
+ channel='auto',
+ rule_meta=pending_finalize_meta
+ )
+ continue
+
+ delivery_result = await self._auto_delivery(
+ item_id,
+ item_title,
+ order_id,
+ send_user_id,
+ chat_id,
+ send_user_name,
+ include_meta=True,
+ delivery_unit_index=unit_index
+ )
+
+ if isinstance(delivery_result, dict):
+ delivery_content = delivery_result.get('content')
+ delivery_error = delivery_result.get('error')
+ delivery_steps = delivery_result.get('delivery_steps') or []
+ rule_meta = {
+ 'success': True,
+ 'rule_id': delivery_result.get('rule_id'),
+ 'rule_keyword': delivery_result.get('rule_keyword'),
+ 'card_type': delivery_result.get('card_type'),
+ 'match_mode': delivery_result.get('match_mode'),
+ 'order_spec_mode': delivery_result.get('order_spec_mode'),
+ 'rule_spec_mode': delivery_result.get('rule_spec_mode'),
+ 'item_config_mode': delivery_result.get('item_config_mode'),
+ 'card_id': delivery_result.get('card_id'),
+ 'card_description': delivery_result.get('card_description'),
+ 'data_card_pending_consume': delivery_result.get('data_card_pending_consume'),
+ 'data_line': delivery_result.get('data_line'),
+ 'data_reservation_id': delivery_result.get('data_reservation_id'),
+ 'data_reservation_status': delivery_result.get('data_reservation_status'),
+ 'delivery_unit_index': delivery_result.get('delivery_unit_index')
+ }
+ else:
+ delivery_content = delivery_result
+ delivery_error = None
+ delivery_steps = []
+
+ if not delivery_content:
+ failure_reason = delivery_error or f"第 {unit_index}/{quantity_to_send} 个卡券内容获取失败"
+ last_delivery_error = failure_reason
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=failure_reason,
+ channel='auto',
+ rule_meta=rule_meta
+ )
+ logger.warning(failure_reason)
+ continue
+
+ if not delivery_steps:
+ delivery_steps = self._build_delivery_steps(delivery_content, rule_meta.get('card_description', ''))
+ if not delivery_steps:
+ failure_reason = f"第 {unit_index}/{quantity_to_send} 个卡券发货步骤构建失败"
+ last_delivery_error = failure_reason
+ self._release_data_reservation_if_needed(rule_meta, error=failure_reason)
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=failure_reason,
+ channel='auto',
+ rule_meta=rule_meta
+ )
+ logger.error(failure_reason)
+ continue
+
+ prepared_units.append({
+ 'unit_index': unit_index,
+ 'delivery_steps': delivery_steps,
+ 'rule_meta': rule_meta,
+ 'card_type': rule_meta.get('card_type'),
+ })
+
+ except Exception as e:
+ self._release_data_reservation_if_needed(rule_meta, error=f'准备发货失败: {self._safe_str(e)}')
+ last_delivery_error = f"准备第 {unit_index}/{quantity_to_send} 个卡券失败: {self._safe_str(e)}"
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=last_delivery_error,
+ channel='auto',
+ rule_meta=rule_meta
+ )
+ logger.error(last_delivery_error)
+
+ send_groups = self._build_delivery_send_groups(prepared_units, quantity_to_send)
+ total_send_groups = len(send_groups)
+
+ for group_index, send_group in enumerate(send_groups, start=1):
+ group_units = send_group.get('units') or []
+ if not group_units:
+ continue
+
+ first_unit = group_units[0]
+ single_unit_index = first_unit.get('unit_index') or 1
+ is_batched_text_group = send_group.get('mode') == 'batched_text'
+
+ if is_batched_text_group:
+ group_log_prefix = (
+ f'[{msg_time}] 多数量自动发货批次 {group_index}/{total_send_groups} '
+ f'({len(group_units)}个单元, {send_group.get("char_count", 0)}字)'
+ )
+ else:
+ group_log_prefix = f'[{msg_time}] 多数量自动发货 {single_unit_index}/{quantity_to_send}'
+
+ try:
+ await self._send_delivery_steps(
+ websocket,
+ chat_id,
+ send_user_id,
+ send_group.get('delivery_steps') or [],
+ user_url=user_url,
+ log_prefix=group_log_prefix
+ )
+ except Exception as e:
+ group_error = self._safe_str(e)
+ for prepared_unit in group_units:
+ unit_rule_meta = prepared_unit.get('rule_meta') or {}
+ unit_index = prepared_unit.get('unit_index') or 1
+ self._release_data_reservation_if_needed(
+ unit_rule_meta,
+ error=f'发送失败(unit={unit_index}): {group_error}'
+ )
+ last_delivery_error = f"发送第 {unit_index}/{quantity_to_send} 个卡券失败: {group_error}"
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=last_delivery_error,
+ channel='auto',
+ rule_meta=unit_rule_meta
+ )
+ logger.error(last_delivery_error)
+ continue
+
+ for prepared_unit in group_units:
+ unit_rule_meta = prepared_unit.get('rule_meta') or {}
+ unit_index = prepared_unit.get('unit_index') or 1
+ unit_delivery_steps = prepared_unit.get('delivery_steps') or []
+
+ try:
+ if not self._mark_data_reservation_sent_if_needed(unit_rule_meta):
+ self._release_data_reservation_if_needed(
+ unit_rule_meta,
+ error=f'发送成功后标记预占已发送失败(unit={unit_index})'
+ )
+ last_delivery_error = f'第 {unit_index} 个卡券发送成功后标记预占已发送失败'
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=last_delivery_error,
+ channel='auto',
+ rule_meta=unit_rule_meta
+ )
+ logger.error(last_delivery_error)
+ continue
+
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ delivery_meta=unit_rule_meta,
+ channel='auto',
+ status='sent'
+ )
+
+ finalize_result = await self._finalize_delivery_after_send(
+ delivery_meta=unit_rule_meta,
+ order_id=order_id,
+ item_id=item_id
+ )
+ if not finalize_result.get('success'):
+ last_delivery_error = finalize_result.get('error') or f"第 {unit_index} 条消息发送成功但提交发货副作用失败"
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ delivery_meta=unit_rule_meta,
+ channel='auto',
+ status='sent',
+ last_error=last_delivery_error
+ )
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=last_delivery_error,
+ channel='auto',
+ rule_meta=unit_rule_meta
+ )
+ logger.error(last_delivery_error)
+ continue
+
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ delivery_meta=unit_rule_meta,
+ channel='auto',
+ status='finalized'
+ )
+
+ successful_send_count += 1
+
+ has_image_step = any(step.get('type') == 'image' for step in unit_delivery_steps)
+ if has_image_step:
+ success_reason = '自动发货图片步骤发送成功'
+ elif is_batched_text_group and len(group_units) > 1:
+ success_reason = '自动发货文本批量合并发送成功'
+ else:
+ success_reason = '自动发货文本发送成功'
+
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='success',
+ reason=success_reason,
+ channel='auto',
+ rule_meta=unit_rule_meta
+ )
+ except Exception as unit_post_error:
+ last_delivery_error = f"第 {unit_index} 个卡券消息已发送,但发送后处理异常: {self._safe_str(unit_post_error)}"
+ self._persist_delivery_finalization_state(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ delivery_meta=unit_rule_meta,
+ channel='auto',
+ status='sent',
+ last_error=last_delivery_error
+ )
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=last_delivery_error,
+ channel='auto',
+ rule_meta=unit_rule_meta
+ )
+ logger.error(last_delivery_error)
+
+ if total_send_groups > 1 and group_index < total_send_groups:
+ await asyncio.sleep(1)
+
+ progress_summary = self._sync_order_delivery_progress(
+ order_id=order_id,
+ cookie_id=self.cookie_id,
+ expected_quantity=quantity_to_send,
+ context="自动发货进度同步"
+ ) if order_id else None
+
+ if progress_summary and progress_summary.get('aggregate_status') in {'partial_success', 'partial_pending_finalize', 'shipped'}:
+ self._activate_delivery_lock(lock_key, delay_minutes=10)
+
+ if successful_send_count > 0:
+ if progress_summary and quantity_to_send > 1:
+ aggregate_status = progress_summary.get('aggregate_status')
+ finalized_count = progress_summary.get('finalized_count', 0)
+ pending_finalize_count = progress_summary.get('pending_finalize_count', 0)
+ remaining_count = progress_summary.get('remaining_count', 0)
+
+ if aggregate_status == 'partial_pending_finalize':
+ notify_message = (
+ f"多数量发货部分完成,已完成 {finalized_count}/{quantity_to_send},"
+ f"待收尾 {pending_finalize_count},待补发 {remaining_count}"
+ )
+ elif aggregate_status == 'partial_success':
+ notify_message = (
+ f"多数量发货部分成功,已完成 {finalized_count}/{quantity_to_send},"
+ f"待补发 {remaining_count}"
+ )
+ else:
+ notify_message = f"多数量发货成功,共完成 {finalized_count}/{quantity_to_send} 个卡券"
+ await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, notify_message, chat_id)
+ else:
+ await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, "发货成功", chat_id)
+ else:
+ logger.warning(f'[{msg_time}] 【自动发货】未找到匹配的发货规则或获取发货内容失败')
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=last_delivery_error or "未找到匹配的发货规则或获取发货内容失败",
+ channel='auto'
+ )
+ await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, last_delivery_error or "未找到匹配的发货规则或获取发货内容失败", chat_id)
+
+ except Exception as e:
+ self._record_delivery_log(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=f"自动发货处理异常: {self._safe_str(e)}",
+ channel='auto'
+ )
+ logger.error(f"自动发货处理异常: {self._safe_str(e)}")
+ # 发送自动发货异常通知
+ await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, f"自动发货处理异常: {str(e)}", chat_id)
+
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】订单锁释放: {lock_key},自动发货处理完成')
+
+ except Exception as e:
+ self._record_delivery_log(
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ status='failed',
+ reason=f"统一自动发货处理异常: {self._safe_str(e)}",
+ channel='auto'
+ )
+ logger.error(f"统一自动发货处理异常: {self._safe_str(e)}")
+
+
+
+ def _reload_latest_cookies_from_db(self, reason: str = "") -> bool:
+ """从数据库重载当前账号最新 Cookie。"""
+ try:
+ from db_manager import db_manager
+
+ account_info = db_manager.get_cookie_details(self.cookie_id)
+ new_cookies_str = self._extract_cookie_value(account_info)
+ if new_cookies_str and new_cookies_str != self.cookies_str:
+ suffix = f" ({reason})" if reason else ""
+ logger.info(f"【{self.cookie_id}】检测到数据库中的cookie已更新,重新加载cookie{suffix}")
+ self._set_runtime_cookie_state(cookies_str=new_cookies_str, source=f"db_reload{suffix}")
+ logger.warning(f"【{self.cookie_id}】Cookie已从数据库重新加载")
+ return True
+ except Exception as reload_e:
+ logger.warning(f"【{self.cookie_id}】从数据库重新加载cookie失败,继续使用当前cookie: {self._safe_str(reload_e)}")
+ return False
+
+ def _serialize_cookies(self, cookies_dict: Optional[Dict[str, Any]] = None) -> str:
+ cookies = cookies_dict or self.cookies
+ return '; '.join([f"{k}={v}" for k, v in cookies.items() if k])
+
+ def _sync_session_cookie_header(self):
+ if self.session and not self.session.closed:
+ self.session.headers['cookie'] = self.cookies_str
+
+ def _set_runtime_cookie_state(
+ self,
+ cookies_str: Optional[str] = None,
+ cookies_dict: Optional[Dict[str, Any]] = None,
+ source: str = "runtime_update",
+ ) -> bool:
+ normalized_cookies = dict(cookies_dict or trans_cookies(cookies_str or ""))
+ if not normalized_cookies:
+ logger.warning(f"【{self.cookie_id}】忽略空Cookie更新: source={source}")
+ return False
+
+ previous_cookie_string = self.cookies_str
+ previous_unb = self.cookies.get('unb') if isinstance(self.cookies, dict) else None
+
+ self.cookies = normalized_cookies
+ self.cookies_str = self._serialize_cookies(normalized_cookies)
+
+ new_unb = self.cookies.get('unb')
+ if new_unb and new_unb != previous_unb:
+ logger.warning(f"【{self.cookie_id}】Cookie中的unb发生变化: {previous_unb} -> {new_unb} (source={source})")
+ self.myid = new_unb
+ self.device_id = generate_device_id(self.myid)
+
+ self._sync_session_cookie_header()
+ return self.cookies_str != previous_cookie_string
+
+ async def _persist_runtime_cookie_state(
+ self,
+ cookies_str: Optional[str] = None,
+ cookies_dict: Optional[Dict[str, Any]] = None,
+ source: str = "runtime_update",
+ ) -> bool:
+ changed = self._set_runtime_cookie_state(
+ cookies_str=cookies_str,
+ cookies_dict=cookies_dict,
+ source=source,
+ )
+ if changed:
+ await self.update_config_cookies()
+ return changed
+
+ def _extract_set_cookie_updates(self, response_headers) -> Dict[str, str]:
+ if not response_headers:
+ return {}
+
+ set_cookie_values = []
+ try:
+ if hasattr(response_headers, 'getall') and 'set-cookie' in response_headers:
+ set_cookie_values = response_headers.getall('set-cookie', [])
+ elif hasattr(response_headers, 'get_all'):
+ set_cookie_values = response_headers.get_all('set-cookie', [])
+ elif isinstance(response_headers, dict):
+ raw_value = response_headers.get('set-cookie') or response_headers.get('Set-Cookie')
+ if isinstance(raw_value, list):
+ set_cookie_values = raw_value
+ elif raw_value:
+ set_cookie_values = [raw_value]
+ except Exception:
+ set_cookie_values = []
+
+ updates = {}
+ for cookie in set_cookie_values:
+ if '=' not in cookie:
+ continue
+ name, value = cookie.split(';')[0].split('=', 1)
+ updates[name.strip()] = value.strip()
+ return updates
+
+ async def _apply_response_cookie_updates(self, response_headers, source: str) -> bool:
+ updates = self._extract_set_cookie_updates(response_headers)
+ if not updates:
+ return False
+
+ merged_cookies = dict(self.cookies)
+ merged_cookies.update(updates)
+ changed = await self._persist_runtime_cookie_state(
+ cookies_dict=merged_cookies,
+ source=source,
+ )
+ if changed:
+ logger.info(f"【{self.cookie_id}】已应用 {len(updates)} 个响应Cookie更新: source={source}")
+ return changed
+
+ def _build_websocket_headers(self) -> Dict[str, str]:
+ headers = WEBSOCKET_HEADERS.copy()
+ headers['Cookie'] = self.cookies_str
+ return headers
+
+ def _mark_slider_success_recovery(self, cookies_str: str = ""):
+ self.last_slider_success_at = time.time()
+ self.last_slider_success_cookie_length = len(cookies_str or "")
+
+ def _build_cookie_string_with_updates(self, base_cookie_string: str = None, updated_cookies: Optional[Dict[str, Any]] = None) -> str:
+ merged_cookies = trans_cookies(base_cookie_string or self.cookies_str)
+ for key, value in (updated_cookies or {}).items():
+ if key:
+ merged_cookies[str(key).strip()] = str(value)
+ return self._serialize_cookies(merged_cookies)
+
+ def _mark_pending_slider_success_notice(self, source: str = "token_refresh"):
+ self.pending_slider_success_notice = {
+ 'source': source,
+ 'timestamp': time.time(),
+ }
+
+ def _consume_pending_slider_success_notice(self, max_age_seconds: int = 180) -> Optional[Dict[str, Any]]:
+ notice = self.pending_slider_success_notice
+ self.pending_slider_success_notice = None
+ if not notice:
+ return None
+
+ notice_timestamp = float(notice.get('timestamp') or 0)
+ if notice_timestamp and (time.time() - notice_timestamp) <= max_age_seconds:
+ return notice
+
+ logger.info(f"【{self.cookie_id}】检测到过期的滑块成功待发送通知,已自动丢弃")
+ return None
+
+ def _clear_pending_slider_success_notice(self, reason: str = None):
+ if self.pending_slider_success_notice:
+ suffix = f" ({reason})" if reason else ""
+ logger.info(f"【{self.cookie_id}】已清理滑块成功待发送通知{suffix}")
+ self.pending_slider_success_notice = None
+
+ def _build_x5_cookie_snapshot(self, cookie_string: str = None, cookies_dict: dict = None) -> Dict[str, Dict[str, Any]]:
+ source_dict = cookies_dict if cookies_dict is not None else trans_cookies(cookie_string or self.cookies_str)
+ snapshot = {}
+ for key in ('x5sec', 'x5secdata'):
+ value = source_dict.get(key)
+ snapshot[key] = {
+ 'present': bool(value),
+ 'length': len(str(value)) if value else 0,
+ 'hash': hashlib.sha256(str(value).encode('utf-8')).hexdigest()[:12] if value else None,
+ }
+ return snapshot
+
+ def _log_x5_cookie_snapshot(self, label: str, cookie_string: str = None, cookies_dict: dict = None):
+ snapshot = self._build_x5_cookie_snapshot(cookie_string=cookie_string, cookies_dict=cookies_dict)
+ parts = []
+ for key, info in snapshot.items():
+ if info.get('present'):
+ parts.append(f"{key}=存在(len={info['length']}, sha={info['hash']})")
+ else:
+ parts.append(f"{key}=缺失")
+ logger.info(f"【{self.cookie_id}】{label}: {', '.join(parts)}")
+
+ @classmethod
+ def protected_merge_cookie_dicts(cls, existing_cookies_dict, incoming_cookies_dict):
+ """保护性合并 Cookie,避免不完整快照覆盖关键会话字段。"""
+ existing = dict(existing_cookies_dict or {})
+ incoming = dict(incoming_cookies_dict or {})
+ existing_count = len(existing)
+ incoming_count = len(incoming)
+ existing_unb = str(existing.get('unb') or '').strip()
+ incoming_unb = str(incoming.get('unb') or '').strip()
+ account_switched = bool(existing_unb and incoming_unb and existing_unb != incoming_unb)
+
+ if account_switched:
+ merged = incoming.copy()
+ else:
+ merged = existing.copy()
+ for key, value in incoming.items():
+ merged[key] = value
+
+ updated_fields = []
+ changed_fields = []
+ new_fields = []
+ for key, value in incoming.items():
+ old_value = existing.get(key)
+ if old_value is None:
+ updated_fields.append(f"{key}(新增)")
+ new_fields.append(key)
+ elif old_value != value:
+ updated_fields.append(key)
+ changed_fields.append(key)
+
+ would_remove_fields = [key for key in existing.keys() if key not in incoming]
+ if account_switched:
+ removed_fields = list(would_remove_fields)
+ preserved_fields = []
+ preserved_protected_fields = []
+ else:
+ removed_fields = []
+ preserved_fields = list(would_remove_fields)
+ preserved_protected_fields = [
+ key for key in would_remove_fields
+ if key in PROTECTED_SESSION_COOKIE_FIELDS and existing.get(key)
+ ]
+
+ missing_protected_fields = [
+ key for key in PROTECTED_SESSION_COOKIE_FIELDS
+ if not merged.get(key)
+ ]
+ missing_required_fields = [
+ key for key in REQUIRED_SESSION_COOKIE_FIELDS
+ if not merged.get(key)
+ ]
+ incoming_missing_protected_fields = [
+ key for key in PROTECTED_SESSION_COOKIE_FIELDS
+ if not incoming.get(key)
+ ]
+ incoming_missing_required_fields = [
+ key for key in REQUIRED_SESSION_COOKIE_FIELDS
+ if not incoming.get(key)
+ ]
+
+ return {
+ 'existing_cookies_dict': existing,
+ 'incoming_cookies_dict': incoming,
+ 'merged_cookies_dict': merged,
+ 'existing_count': existing_count,
+ 'incoming_count': incoming_count,
+ 'merged_count': len(merged),
+ 'updated_fields': updated_fields,
+ 'changed_fields': changed_fields,
+ 'new_fields': new_fields,
+ 'would_remove_fields': would_remove_fields,
+ 'removed_fields': removed_fields,
+ 'preserved_fields': preserved_fields,
+ 'preserved_protected_fields': preserved_protected_fields,
+ 'missing_protected_fields': missing_protected_fields,
+ 'missing_required_fields': missing_required_fields,
+ 'incoming_missing_protected_fields': incoming_missing_protected_fields,
+ 'incoming_missing_required_fields': incoming_missing_required_fields,
+ 'account_switched': account_switched,
+ }
+
+ def _merge_cookie_dicts(self, incoming_cookies_dict, existing_cookies_dict=None):
+ """兼容旧调用,返回保护性合并结果。"""
+ merge_result = self.protected_merge_cookie_dicts(
+ existing_cookies_dict if existing_cookies_dict is not None else trans_cookies(self.cookies_str),
+ incoming_cookies_dict,
+ )
+ return (
+ merge_result['existing_cookies_dict'],
+ merge_result['merged_cookies_dict'],
+ merge_result['updated_fields'],
+ merge_result['changed_fields'],
+ merge_result['new_fields'],
+ )
+
+ def _log_protected_merge_event(self, event_name: str, merge_result: Dict[str, Any]):
+ """输出受保护 Cookie 合并审计日志,便于定位快照覆盖问题。"""
+ if not merge_result:
+ return
+
+ protected_preserved_fields = merge_result.get('preserved_protected_fields') or []
+ would_remove_fields = merge_result.get('would_remove_fields') or []
+ logger.info(
+ f"【{self.cookie_id}】{event_name} "
+ f"incoming_count={merge_result.get('incoming_count', 0)} "
+ f"existing_count={merge_result.get('existing_count', 0)} "
+ f"merged_count={merge_result.get('merged_count', 0)} "
+ f"protected_preserved_fields={protected_preserved_fields} "
+ f"would_remove_fields={would_remove_fields} "
+ f"account_switched={merge_result.get('account_switched', False)}"
+ )
+
+ def _log_cookie_merge_summary(self, merged_cookies_dict, updated_fields, changed_fields, new_fields, context: str,
+ preserved_fields=None, preserved_protected_fields=None,
+ would_remove_fields=None, removed_fields=None,
+ missing_protected_fields=None, missing_required_fields=None,
+ incoming_missing_protected_fields=None, account_switched: bool = False):
+ """打印 Cookie 合并结果,重点关注会话关键字段。"""
+ context_prefix = f"{context}:" if context else ""
+ logger.info(f"【{self.cookie_id}】{context_prefix}合并后cookies包含 {len(merged_cookies_dict)} 个字段")
+
+ if updated_fields:
+ logger.info(f"【{self.cookie_id}】{context_prefix}更新的cookie字段: {', '.join(updated_fields)}")
+ else:
+ logger.info(f"【{self.cookie_id}】{context_prefix}没有cookie字段需要更新")
+
+ if account_switched:
+ logger.warning(f"【{self.cookie_id}】{context_prefix}检测到unb变化,按账号切换处理,不保留旧账号Cookie字段")
+
+ if preserved_protected_fields:
+ logger.warning(
+ f"【{self.cookie_id}】{context_prefix}保护性保留关键字段 ({len(preserved_protected_fields)}个): {', '.join(preserved_protected_fields)}"
+ )
+ if preserved_fields:
+ logger.info(
+ f"【{self.cookie_id}】{context_prefix}保留旧Cookie字段 ({len(preserved_fields)}个): {', '.join(preserved_fields)}"
+ )
+ if would_remove_fields:
+ logger.info(
+ f"【{self.cookie_id}】{context_prefix}浏览器快照未返回的旧字段 ({len(would_remove_fields)}个): {', '.join(would_remove_fields)}"
+ )
+ if removed_fields:
+ logger.warning(
+ f"【{self.cookie_id}】{context_prefix}实际移除旧字段 ({len(removed_fields)}个): {', '.join(removed_fields)}"
+ )
+ if incoming_missing_protected_fields:
+ logger.warning(
+ f"【{self.cookie_id}】{context_prefix}新快照缺失的关键字段 ({len(incoming_missing_protected_fields)}个): {', '.join(incoming_missing_protected_fields)}"
+ )
+ if missing_protected_fields:
+ logger.warning(
+ f"【{self.cookie_id}】{context_prefix}合并后仍缺失的受保护字段 ({len(missing_protected_fields)}个): {', '.join(missing_protected_fields)}"
+ )
+ if missing_required_fields:
+ logger.error(
+ f"【{self.cookie_id}】{context_prefix}合并后仍缺失的核心字段 ({len(missing_required_fields)}个): {', '.join(missing_required_fields)}"
+ )
+
+ important_keys = list(PROTECTED_SESSION_COOKIE_FIELDS) + ['x5sec', 'x5secdata']
+ logger.info(f"【{self.cookie_id}】{context_prefix}关键字段检查:")
+ for key in important_keys:
+ if key in merged_cookies_dict:
+ val = merged_cookies_dict[key]
+ marker = " [已变化]" if key in changed_fields else " [新增]" if key in new_fields else ""
+ logger.info(f"【{self.cookie_id}】 ✅ {key}: {'存在' if val else '为空'} (长度: {len(str(val)) if val else 0}){marker}")
+ else:
+ logger.info(f"【{self.cookie_id}】 ❌ {key}: 缺失")
+
+ def _has_recent_slider_success(self, window_seconds: int = None) -> bool:
+ if not self.last_slider_success_at:
+ return False
+ window = window_seconds or self.slider_success_reentry_window
+ return (time.time() - self.last_slider_success_at) <= window
+
+ async def preflight_token_after_manual_refresh(self) -> str:
+ """手动刷新成功后的 token 预检,确认新实例可直接完成初始化。
+
+ 🔧 增加重试机制:密码登录获取的 Cookie 可能需要短暂时间在服务端生效,
+ 首次 Token 刷新可能因 session 未就绪而失败,等待后重试可提高成功率。
+ """
+ logger.info(f"【{self.cookie_id}】开始执行手动刷新后的Token预检...")
+ self.last_message_received_time = 0
+
+ max_preflight_retries = 3
+ for attempt in range(1, max_preflight_retries + 1):
+ token = await self.refresh_token(allow_password_login_recovery=False)
+ if token:
+ self.cache_auth_prewarmed_token(self.cookie_id, token, source='manual_refresh_handoff')
+ logger.info(f"【{self.cookie_id}】手动刷新后的Token预检成功(第{attempt}次),已缓存预热token供新实例复用")
+ return token
+
+ if attempt < max_preflight_retries:
+ wait_secs = 2.0 * attempt
+ logger.warning(
+ f"【{self.cookie_id}】Token预检第{attempt}次失败(状态: {self.last_token_refresh_status}),"
+ f"等待{wait_secs:.0f}秒后重试(Cookie可能尚未在服务端生效)"
+ )
+ await asyncio.sleep(wait_secs)
+
+ raise InitAuthError(f"手动刷新后的Token预检失败,状态: {self.last_token_refresh_status or 'unknown'}")
+
+ async def refresh_token(self, captcha_retry_count: int = 0, allow_password_login_recovery: bool = True):
+ if self.token_refresh_lock.locked():
+ logger.info(f"【{self.cookie_id}】Token刷新已有执行中任务,等待当前流程完成后复用结果")
+
+ async with self.token_refresh_lock:
+ if (
+ captcha_retry_count == 0 and
+ self.current_token and
+ self.last_token_refresh_status == "success" and
+ (time.time() - self.last_token_refresh_time) < 15
+ ):
+ logger.info(f"【{self.cookie_id}】最近15秒内已有成功的Token刷新结果,直接复用当前Token")
+ return self.current_token
+ return await self._refresh_token_impl(
+ captcha_retry_count,
+ allow_password_login_recovery=allow_password_login_recovery,
+ )
+
+ def _is_auth_failure_ret(self, ret_value: Any) -> bool:
+ if isinstance(ret_value, str):
+ ret_text = ret_value
+ elif isinstance(ret_value, (list, tuple)):
+ ret_text = ' | '.join([str(item) for item in ret_value])
+ else:
+ ret_text = str(ret_value or '')
+
+ auth_keywords = (
+ '令牌过期',
+ 'session过期',
+ 'FAIL_SYS_USER_VALIDATE',
+ 'FAIL_SYS_TOKEN_EXPIRED',
+ 'FAIL_SYS_TOKEN_EXOIRED',
+ 'FAIL_SYS_SESSION_EXPIRED',
+ 'passport.goofish.com',
+ 'mini_login',
+ 'login',
+ )
+ ret_text_lower = ret_text.lower()
+ return any(keyword.lower() in ret_text_lower for keyword in auth_keywords)
+
+ async def keep_session_alive(self) -> bool:
+ """使用 loginuser.get 轻量维持网页登录态。"""
+ self.last_session_keepalive_status = "started"
+ self.last_session_keepalive_error_message = None
+
+ try:
+ if not self.session:
+ await self.create_session()
+
+ self._reload_latest_cookies_from_db("轻量保活前")
+
+ 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.idlemessage.pc.loginuser.get',
+ 'sessionOption': 'AutoLoginOnly',
+ 'spm_cnt': 'a21ybx.im.0.0',
+ 'spm_pre': 'a21ybx.item.want.1.12523da6waCtUp',
+ 'log_id': '12523da6waCtUp',
+ }
+ data_val = '{}'
+ data = {'data': data_val}
+
+ token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
+ params['sign'] = generate_sign(params['t'], token, data_val)
+
+ headers = DEFAULT_HEADERS.copy()
+ headers['content-type'] = 'application/x-www-form-urlencoded'
+ headers['cookie'] = self.cookies_str
+
+ request_kwargs = {}
+ if getattr(self, '_http_proxy_url', None):
+ request_kwargs['proxy'] = self._http_proxy_url
+
+ api_url = API_ENDPOINTS.get('login_user')
+ async with self.session.post(
+ api_url,
+ params=params,
+ data=data,
+ headers=headers,
+ **request_kwargs,
+ ) as response:
+ try:
+ res_json = await response.json(content_type=None)
+ except Exception:
+ response_text = await response.text()
+ self.last_session_keepalive_status = "response_parse_failed"
+ self.last_session_keepalive_error_message = response_text[:200]
+ logger.warning(f"【{self.cookie_id}】轻量保活响应解析失败: {response_text[:200]}")
+ return False
+
+ await self._apply_response_cookie_updates(response.headers, "session_keepalive")
+ ret_value = res_json.get('ret', [])
+ if any('SUCCESS::调用成功' in str(ret) for ret in ret_value):
+ self.last_session_keepalive_status = "success"
+ self.last_session_keepalive_error_message = None
+ self.last_session_keepalive_time = time.time()
+ logger.info(f"【{self.cookie_id}】轻量会话保活成功")
+ return True
+
+ error_message = ' | '.join([str(ret) for ret in ret_value]) or '未知错误'
+ self.last_session_keepalive_error_message = error_message
+ self.last_session_keepalive_status = "auth_failed" if self._is_auth_failure_ret(ret_value) else "api_failed"
+ logger.warning(f"【{self.cookie_id}】轻量会话保活失败: {error_message}")
+ return False
+
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
+ self.last_session_keepalive_status = "network_failed"
+ self.last_session_keepalive_error_message = self._safe_str(e)
+ logger.warning(f"【{self.cookie_id}】轻量会话保活网络异常: {self._safe_str(e)}")
+ return False
+ except Exception as e:
+ self.last_session_keepalive_status = "exception"
+ self.last_session_keepalive_error_message = self._safe_str(e)
+ logger.error(f"【{self.cookie_id}】轻量会话保活异常: {self._safe_str(e)}")
+ return False
+
+ async def _refresh_token_impl(self, captcha_retry_count: int = 0, post_slider_session_grace_used: bool = False,
+ allow_password_login_recovery: bool = True,
+ manual_refresh_browser_stabilization_used: bool = False,
+ post_slider_session_retry_count: int = 0):
+ """刷新token
+
+ Args:
+ captcha_retry_count: 滑块验证重试次数,用于防止无限递归
+ """
+ # 初始化通知发送标志,避免重复发送通知
+ notification_sent = False
+
+ try:
+ logger.info(f"【{self.cookie_id}】开始刷新token... (滑块验证重试次数: {captcha_retry_count})")
+ # 标记本次刷新状态
+ self.last_token_refresh_status = "started"
+ self.last_token_refresh_error_message = None
+ # 重置“刷新流程内已重启”标记,避免多次重启
+ self.restarted_in_browser_refresh = False
+
+ # 检查滑块验证重试次数,防止无限递归
+ if captcha_retry_count >= self.max_captcha_verification_count:
+ logger.error(f"【{self.cookie_id}】滑块验证重试次数已达上限 ({self.max_captcha_verification_count}),停止重试")
+ self.last_token_refresh_status = "captcha_max_retries_exceeded"
+ self._clear_pending_slider_success_notice("滑块重试次数达到上限")
+ await self.send_token_refresh_notification(
+ f"滑块验证重试次数已达上限,请手动处理",
+ "captcha_max_retries_exceeded"
+ )
+ notification_sent = True
+ return None
+
+ # 【消息接收检查】检查是否在消息接收后的冷却时间内,与 cookie_refresh_loop 保持一致
+ current_time = time.time()
+ time_since_last_message = current_time - self.last_message_received_time
+ if self.last_message_received_time > 0 and time_since_last_message < self.message_cookie_refresh_cooldown:
+ remaining_time = self.message_cookie_refresh_cooldown - time_since_last_message
+ remaining_minutes = int(remaining_time // 60)
+ remaining_seconds = int(remaining_time % 60)
+ logger.info(f"【{self.cookie_id}】收到消息后冷却中,放弃本次token刷新,还需等待 {remaining_minutes}分{remaining_seconds}秒")
+ # 标记为因冷却而跳过(正常情况)
+ self.last_token_refresh_status = "skipped_cooldown"
+ return None
+
+ # 【重要】在刷新token前,先从数据库重新加载最新的cookie
+ # 这样即使用户已经手动更新了cookie,代码也会使用最新的cookie
+ logger.info(f"【{self.cookie_id}】开始执行Cookie刷新任务...")
+ self._reload_latest_cookies_from_db("token刷新前")
+
+ # 生成更精确的时间戳
+ timestamp = str(int(time.time() * 1000))
+
+ params = {
+ 'jsv': '2.7.2',
+ 'appKey': '34839810',
+ 't': timestamp,
+ 'sign': '',
+ 'v': '1.0',
+ 'type': 'originaljson',
+ 'accountSite': 'xianyu',
+ 'dataType': 'json',
+ 'timeout': '20000',
+ 'api': 'mtop.taobao.idlemessage.pc.login.token',
+ 'sessionOption': 'AutoLoginOnly',
+ 'dangerouslySetWindvaneParams': '%5Bobject%20Object%5D',
+ 'smToken': 'token',
+ 'queryToken': 'sm',
+ 'sm': 'sm',
+ 'spm_cnt': 'a21ybx.im.0.0',
+ 'spm_pre': 'a21ybx.home.sidebar.1.4c053da6vYwnmf',
+ 'log_id': '4c053da6vYwnmf'
+ }
+ data_val = '{"appKey":"444e9908a51d1cb236a27862abc769c9","deviceId":"' + self.device_id + '"}'
+ data = {
+ 'data': data_val,
+ }
+
+ # 获取token
+ token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
+
+ sign = generate_sign(params['t'], token, data_val)
+ params['sign'] = sign
+
+ # 发送请求 - 使用与浏览器完全一致的请求头
+ headers = {
+ 'accept': 'application/json',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
+ 'cache-control': 'no-cache',
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'pragma': 'no-cache',
+ 'priority': 'u=1, i',
+ 'sec-ch-ua': '"Not;A=Brand";v="99", "Google Chrome";v="139", "Chromium";v="139"',
+ 'sec-ch-ua-mobile': '?0',
+ 'sec-ch-ua-platform': '"Windows"',
+ 'sec-fetch-dest': 'empty',
+ 'sec-fetch-mode': 'cors',
+ 'sec-fetch-site': 'same-site',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
+ 'referer': 'https://www.goofish.com/',
+ 'origin': 'https://www.goofish.com',
+ 'cookie': self.cookies_str
+ }
+
+ # 发送Token刷新请求
+ api_url = API_ENDPOINTS.get('token')
+ logger.info(f"【{self.cookie_id}】正在刷新Token... API: {api_url}")
+
+ # 详细调试信息(仅debug级别)
+ logger.debug(f"【{self.cookie_id}】Token刷新参数: timestamp={params['t']}, sign={sign[:16]}...")
+
+ async with aiohttp.ClientSession() as session:
+ async with session.post(
+ api_url,
+ params=params,
+ data=data,
+ headers=headers,
+ timeout=aiohttp.ClientTimeout(total=30)
+ ) as response:
+ res_json = await response.json()
+ # 简化日志输出
+ ret_info = res_json.get('ret', [])
+ logger.debug(f"【{self.cookie_id}】Token刷新响应: status={response.status}, ret={ret_info}")
+
+ response_set_cookies = self._extract_set_cookie_updates(response.headers)
+
+ transient_recovery_cookies_str = self.cookies_str
+ if response_set_cookies:
+ transient_recovery_cookies_str = self._build_cookie_string_with_updates(
+ self.cookies_str,
+ response_set_cookies
+ )
+ logger.info(
+ f"【{self.cookie_id}】Token预检响应携带 {len(response_set_cookies)} 个临时Cookie,"
+ f"仅用于本次恢复链路,不提前写入数据库"
+ )
+
+ if isinstance(res_json, dict):
+ ret_value = res_json.get('ret', [])
+ # 检查ret是否包含成功信息
+ if any('SUCCESS::调用成功' in ret for ret in ret_value):
+ if 'data' in res_json and 'accessToken' in res_json['data']:
+ if response_set_cookies:
+ await self._apply_response_cookie_updates(response.headers, "token_refresh")
+ logger.warning(f"【{self.cookie_id}】Token刷新成功后已更新Cookie到数据库")
+
+ new_token = res_json['data']['accessToken']
+ self.current_token = new_token
+ self.last_token_refresh_time = time.time()
+
+ # 【消息接收时间重置】Token刷新成功后重置消息接收标志,与 cookie_refresh_loop 保持一致
+ self.last_message_received_time = 0
+ logger.warning(f"【{self.cookie_id}】Token刷新成功,已重置消息接收时间标识")
+ self.clear_qr_login_grace(self.cookie_id)
+ self.clear_init_auth_failure_state(self.cookie_id)
+ self.last_init_failure_reason = None
+ self.last_init_failure_type = None
+ self.init_auth_failures = 0
+
+ logger.info(f"【{self.cookie_id}】Token刷新成功")
+ # 标记为成功
+ self.last_token_refresh_status = "success"
+ self.last_token_refresh_error_message = None
+ if self._consume_pending_slider_success_notice():
+ await self.send_token_refresh_notification(
+ "滑块验证通过,账号会话已恢复",
+ "slider_recovered_success"
+ )
+ return new_token
+
+ # 检查是否需要滑块验证
+ if self._need_captcha_verification(res_json):
+ qr_login_grace = self.get_qr_login_grace(self.cookie_id)
+ if qr_login_grace and not qr_login_grace.get('captcha_buffer_used'):
+ logger.warning(f"【{self.cookie_id}】扫码登录后的首轮Token刷新命中风控,先执行浏览器侧Cookie稳定化")
+ log_captcha_event(
+ self.cookie_id,
+ "扫码登录首轮Token刷新命中风控,先执行浏览器侧稳定化",
+ None,
+ f"触发场景: Token刷新, ret={res_json.get('ret', [])}"
+ )
+ self.update_qr_login_grace(
+ self.cookie_id,
+ captcha_buffer_used=True,
+ captcha_detected_at=time.time()
+ )
+ await asyncio.sleep(2)
+ stabilization_success = await self._refresh_cookies_via_browser_page(
+ transient_recovery_cookies_str,
+ restart_on_success=False
+ )
+ if stabilization_success:
+ self.update_qr_login_grace(
+ self.cookie_id,
+ browser_stabilized=True,
+ browser_stabilized_at=time.time()
+ )
+ logger.info(f"【{self.cookie_id}】浏览器侧Cookie稳定化完成,重新尝试Token刷新")
+ return await self._refresh_token_impl(
+ captcha_retry_count,
+ post_slider_session_grace_used=post_slider_session_grace_used,
+ allow_password_login_recovery=allow_password_login_recovery,
+ manual_refresh_browser_stabilization_used=manual_refresh_browser_stabilization_used,
+ post_slider_session_retry_count=post_slider_session_retry_count,
+ )
+ logger.warning(f"【{self.cookie_id}】浏览器侧Cookie稳定化未消除风控,继续进入滑块验证")
+
+ manual_refresh_state = self.get_manual_refresh_state(self.cookie_id)
+ is_manual_refresh_handoff = bool(
+ manual_refresh_state and manual_refresh_state.get('phase') == 'handoff_recovery'
+ )
+ if is_manual_refresh_handoff and not manual_refresh_browser_stabilization_used:
+ logger.warning(f"【{self.cookie_id}】手动刷新交接阶段首轮Token预检命中风控,先执行浏览器侧Cookie稳定化")
+ log_captcha_event(
+ self.cookie_id,
+ "手动刷新交接阶段首轮Token预检命中风控,先执行浏览器侧稳定化",
+ None,
+ f"触发场景: Token刷新, ret={res_json.get('ret', [])}"
+ )
+ before_x5_snapshot = self._build_x5_cookie_snapshot(cookie_string=transient_recovery_cookies_str)
+ self._log_x5_cookie_snapshot("手动刷新交接稳定化前的x5票据", cookie_string=transient_recovery_cookies_str)
+ self.last_token_refresh_status = "manual_refresh_browser_stabilizing"
+ stabilization_success = await self._refresh_cookies_via_browser_page(
+ transient_recovery_cookies_str,
+ restart_on_success=False
+ )
+ if stabilization_success:
+ self._reload_latest_cookies_from_db("手动刷新交接阶段浏览器稳定化")
+ after_x5_snapshot = self._build_x5_cookie_snapshot()
+ self._log_x5_cookie_snapshot("手动刷新交接稳定化后的x5票据")
+ changed_x5_fields = [
+ key for key in ('x5sec', 'x5secdata')
+ if before_x5_snapshot.get(key, {}).get('hash') != after_x5_snapshot.get(key, {}).get('hash')
+ ]
+ if changed_x5_fields:
+ logger.info(
+ f"【{self.cookie_id}】手动刷新交接阶段浏览器稳定化已更新x5票据: {', '.join(changed_x5_fields)}"
+ )
+ else:
+ logger.info(f"【{self.cookie_id}】手动刷新交接阶段浏览器稳定化未观察到x5票据变化,继续重试Token预检")
+ return await self._refresh_token_impl(
+ captcha_retry_count,
+ post_slider_session_grace_used=post_slider_session_grace_used,
+ allow_password_login_recovery=allow_password_login_recovery,
+ manual_refresh_browser_stabilization_used=True,
+ post_slider_session_retry_count=post_slider_session_retry_count,
+ )
+ logger.warning(f"【{self.cookie_id}】手动刷新交接阶段浏览器稳定化失败,继续进入滑块验证")
+
+ if self.is_manual_refresh_active(self.cookie_id, allow_handoff_recovery=True):
+ logger.warning(f"【{self.cookie_id}】检测到手动刷新进行中,跳过自动滑块处理")
+ log_captcha_event(
+ self.cookie_id,
+ "手动刷新进行中,跳过自动滑块处理",
+ None,
+ "触发场景: Token刷新"
+ )
+ self.last_token_refresh_status = "manual_refresh_active"
+ self._clear_pending_slider_success_notice("手动刷新进行中")
+ notification_sent = True
+ return None
+
+ logger.warning(f"【{self.cookie_id}】检测到需要滑块验证,开始处理...")
+
+ # 记录滑块验证检测到日志文件
+ verification_url = res_json.get('data', {}).get('url', 'Token刷新时检测')
+ log_captcha_event(self.cookie_id, "检测到滑块验证", None, f"触发场景: Token刷新, URL: {verification_url}")
+ captcha_trigger_scene = 'token_refresh'
+ captcha_session_id = self._new_risk_session_id('slider')
+ captcha_event_meta = self._build_risk_event_meta(
+ trigger_scene=captcha_trigger_scene,
+ verification_url=verification_url,
+ extra={'cookie_id': self.cookie_id}
+ )
+
+ # 添加风控日志记录
+ log_id = None
+ try:
+ log_id = self._create_risk_log(
+ event_type='slider_captcha',
+ session_id=captcha_session_id,
+ trigger_scene=captcha_trigger_scene,
+ result_code='slider_captcha_detected',
+ event_description='检测到滑块验证(Token刷新)',
+ processing_status='processing',
+ event_meta=captcha_event_meta,
+ )
+ if log_id:
+ logger.info(f"【{self.cookie_id}】风控日志记录成功,ID: {log_id}")
+ except Exception as log_e:
+ logger.error(f"【{self.cookie_id}】记录风控日志失败: {log_e}")
+
+ try:
+ # 尝试通过滑块验证获取新的cookies
+ captcha_start_time = time.time()
+ new_cookies_str = await self._handle_captcha_verification(res_json)
+ captcha_duration = time.time() - captcha_start_time
+
+ if new_cookies_str:
+ logger.info(f"【{self.cookie_id}】滑块验证成功,准备重启实例...")
+
+ # 更新风控日志为成功状态
+ if 'log_id' in locals() and log_id:
+ self._update_risk_log(
+ log_id,
+ session_id=captcha_session_id,
+ trigger_scene=captcha_trigger_scene,
+ result_code='slider_captcha_success',
+ processing_result='滑块验证成功,已获取新Cookie',
+ processing_status='success',
+ duration_ms=max(0, int(captcha_duration * 1000)),
+ event_meta=self._build_risk_event_meta(
+ trigger_scene=captcha_trigger_scene,
+ verification_url=verification_url,
+ extra={
+ 'cookie_id': self.cookie_id,
+ 'cookie_length': len(new_cookies_str),
+ },
+ ),
+ )
+
+ # 重启实例(cookies已在_handle_captcha_verification中更新到数据库)
+ # await self._restart_instance()
+
+ # 给浏览器回写票据与数据库落盘留一个稳定窗口,避免刚过块就立即重新命中Session过期
+ settle_delay = random.uniform(*self.post_slider_token_retry_delay)
+ logger.info(
+ f"【{self.cookie_id}】滑块成功后进入稳定窗口 {settle_delay:.2f}s,再重新尝试Token刷新"
+ )
+ await asyncio.sleep(settle_delay)
+ self._reload_latest_cookies_from_db("滑块成功后的稳定窗口")
+ log_captcha_event(
+ self.cookie_id,
+ "滑块成功后重新进入Token刷新",
+ None,
+ f"类型: token_reentry_after_slider_success, captcha_retry_count={captcha_retry_count + 1}"
+ )
+
+ # 重新尝试刷新token(递归调用,但有深度限制)
+ return await self._refresh_token_impl(
+ captcha_retry_count + 1,
+ post_slider_session_grace_used=False,
+ allow_password_login_recovery=allow_password_login_recovery,
+ manual_refresh_browser_stabilization_used=manual_refresh_browser_stabilization_used,
+ post_slider_session_retry_count=0,
+ )
+ else:
+ logger.error(f"【{self.cookie_id}】滑块验证失败")
+
+ # 更新风控日志为失败状态
+ if 'log_id' in locals() and log_id:
+ self._update_risk_log(
+ log_id,
+ session_id=captcha_session_id,
+ trigger_scene=captcha_trigger_scene,
+ result_code='slider_captcha_failed',
+ processing_result='滑块验证失败,未获取到新Cookie',
+ processing_status='failed',
+ error_message='未获取到新Cookie',
+ duration_ms=max(0, int(captcha_duration * 1000)),
+ event_meta=self._build_risk_event_meta(
+ trigger_scene=captcha_trigger_scene,
+ verification_url=verification_url,
+ extra={'cookie_id': self.cookie_id},
+ ),
+ )
+
+ # 标记已处理,避免后续再发送通用失败通知
+ notification_sent = True
+ except Exception as captcha_e:
+ logger.error(f"【{self.cookie_id}】滑块验证处理异常: {self._safe_str(captcha_e)}")
+ self._clear_pending_slider_success_notice("滑块验证处理异常")
+
+ # 更新风控日志为异常状态
+ captcha_duration = time.time() - captcha_start_time if 'captcha_start_time' in locals() else 0
+ if 'log_id' in locals() and log_id:
+ self._update_risk_log(
+ log_id,
+ session_id=captcha_session_id,
+ trigger_scene=captcha_trigger_scene,
+ result_code='slider_captcha_exception',
+ processing_result='滑块验证处理异常',
+ processing_status='failed',
+ error_message=str(captcha_e)[:200],
+ duration_ms=max(0, int(captcha_duration * 1000)),
+ event_meta=self._build_risk_event_meta(
+ trigger_scene=captcha_trigger_scene,
+ verification_url=verification_url,
+ extra={'cookie_id': self.cookie_id},
+ ),
+ )
+
+ # 标记已处理,避免后续再发送通用失败通知
+ notification_sent = True
+
+ # 检查是否包含"令牌过期"或"Session过期"
+ if isinstance(res_json, dict):
+ res_json_str = json.dumps(res_json, ensure_ascii=False, separators=(',', ':'))
+ if '令牌过期' in res_json_str or 'Session过期' in res_json_str:
+ # 记录令牌/Session过期到风控日志
+ token_expired_log_id = None
+ token_expired_session_id = self._new_risk_session_id('token')
+ token_expired_started_at = time.time()
+ token_trigger_scene = 'token_refresh'
+ expire_type = '令牌过期' if '令牌过期' in res_json_str else 'Session过期'
+ try:
+ from db_manager import db_manager
+ stale_count = db_manager.mark_stale_risk_control_logs_failed(timeout_minutes=15, cookie_id=self.cookie_id)
+ if stale_count > 0:
+ logger.warning(f"【{self.cookie_id}】检测到{stale_count}条超时processing风控日志,已自动标记failed")
+ token_expired_log_id = self._create_risk_log(
+ event_type='token_expired',
+ session_id=token_expired_session_id,
+ trigger_scene=token_trigger_scene,
+ result_code='token_expired_detected',
+ event_description=f"检测到{expire_type}",
+ processing_status='processing',
+ event_meta=self._build_risk_event_meta(
+ trigger_scene=token_trigger_scene,
+ extra={'expire_type': expire_type, 'cookie_id': self.cookie_id},
+ ),
+ )
+ except Exception as log_e:
+ logger.error(f"【{self.cookie_id}】记录风控日志失败: {log_e}")
+
+ # 调用统一的密码登录刷新方法
+ if self.is_manual_refresh_active(self.cookie_id, allow_handoff_recovery=True):
+ logger.warning(f"【{self.cookie_id}】检测到手动刷新进行中,跳过自动密码登录刷新")
+ if token_expired_log_id:
+ self._update_risk_log(
+ token_expired_log_id,
+ session_id=token_expired_session_id,
+ trigger_scene=token_trigger_scene,
+ result_code='manual_refresh_active',
+ processing_status='failed',
+ error_message='检测到手动刷新进行中,自动刷新已跳过',
+ duration_ms=max(0, int((time.time() - token_expired_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(
+ trigger_scene=token_trigger_scene,
+ extra={'cookie_id': self.cookie_id, 'expire_type': expire_type},
+ ),
+ )
+ self.last_token_refresh_status = "manual_refresh_active"
+ self._clear_pending_slider_success_notice("手动刷新进行中")
+ notification_sent = True
+ return None
+
+ recent_slider_success = self._has_recent_slider_success()
+ max_post_slider_session_retries = 2
+
+ if recent_slider_success and not post_slider_session_grace_used:
+ grace_delay = random.uniform(1.2, 2.2)
+ logger.warning(
+ f"【{self.cookie_id}】检测到最近 {self.slider_success_reentry_window}s 内刚通过滑块,"
+ f"先等待 {grace_delay:.2f}s 并重载Cookie后再试一次Token刷新"
+ )
+ log_captcha_event(
+ self.cookie_id,
+ "滑块成功后Session过期,优先重试Token刷新",
+ None,
+ f"类型: token_retry_after_recent_slider_success, expire_type={expire_type}"
+ )
+ await asyncio.sleep(grace_delay)
+ self._reload_latest_cookies_from_db("滑块成功后的Session过期缓冲")
+ return await self._refresh_token_impl(
+ captcha_retry_count,
+ post_slider_session_grace_used=True,
+ allow_password_login_recovery=allow_password_login_recovery,
+ manual_refresh_browser_stabilization_used=manual_refresh_browser_stabilization_used,
+ post_slider_session_retry_count=post_slider_session_retry_count,
+ )
+
+ if (
+ recent_slider_success and
+ not allow_password_login_recovery and
+ post_slider_session_retry_count < max_post_slider_session_retries
+ ):
+ settle_retry_attempt = post_slider_session_retry_count + 1
+ settle_delay = random.uniform(2.4, 3.6) + ((settle_retry_attempt - 1) * 0.8)
+ logger.warning(
+ f"【{self.cookie_id}】预检模式下滑块成功后仍返回{expire_type},"
+ f"执行第{settle_retry_attempt}/{max_post_slider_session_retries}次稳定重试,"
+ f"等待 {settle_delay:.2f}s 后再次尝试Token刷新"
+ )
+ log_captcha_event(
+ self.cookie_id,
+ "滑块成功后Session仍未稳定,继续重试Token刷新",
+ None,
+ f"类型: token_settle_retry_after_slider, expire_type={expire_type}, "
+ f"attempt={settle_retry_attempt}/{max_post_slider_session_retries}"
+ )
+ self.last_token_refresh_status = "post_slider_session_settling"
+ await asyncio.sleep(settle_delay)
+ self._reload_latest_cookies_from_db(
+ f"滑块成功后的第{settle_retry_attempt}次Session稳定重试"
+ )
+ return await self._refresh_token_impl(
+ captcha_retry_count,
+ post_slider_session_grace_used=True,
+ allow_password_login_recovery=allow_password_login_recovery,
+ manual_refresh_browser_stabilization_used=manual_refresh_browser_stabilization_used,
+ post_slider_session_retry_count=settle_retry_attempt,
+ )
+
+ refresh_success = False
+ if allow_password_login_recovery:
+ refresh_success = await self._try_password_login_refresh(
+ "令牌/Session过期",
+ risk_session_id=token_expired_session_id,
+ trigger_scene=token_trigger_scene,
+ ignore_slider_failed_backoff=recent_slider_success,
+ )
+ else:
+ self.last_token_refresh_status = (
+ "session_expired_after_slider"
+ if recent_slider_success else
+ "session_expired_preflight"
+ )
+ self.last_token_refresh_error_message = f"Token预检返回{expire_type}"
+ logger.warning(f"【{self.cookie_id}】当前为预检模式,跳过密码登录恢复,直接返回Token刷新失败")
+
+ if token_expired_log_id:
+ self._update_risk_log(
+ token_expired_log_id,
+ session_id=token_expired_session_id,
+ trigger_scene=token_trigger_scene,
+ result_code='token_refresh_recovered' if refresh_success else 'token_refresh_recovery_failed',
+ processing_status='success' if refresh_success else 'failed',
+ processing_result='令牌/Session过期触发自动刷新成功,已进入重试流程' if refresh_success else None,
+ error_message=None if refresh_success else '令牌/Session过期触发自动刷新失败',
+ duration_ms=max(0, int((time.time() - token_expired_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(
+ trigger_scene=token_trigger_scene,
+ extra={'cookie_id': self.cookie_id, 'expire_type': expire_type},
+ ),
+ )
+
+ if not refresh_success:
+ if allow_password_login_recovery:
+ self.last_token_refresh_status = "token_expired_recovery_failed"
+ self._clear_pending_slider_success_notice("恢复流程失败")
+ # 标记已发送通知,避免重复通知
+ notification_sent = True
+ # 返回None,让调用者知道刷新失败
+ return None
+ else:
+ # 刷新成功后,重新尝试获取token
+ return await self._refresh_token_impl(
+ captcha_retry_count,
+ post_slider_session_grace_used=False,
+ allow_password_login_recovery=allow_password_login_recovery,
+ manual_refresh_browser_stabilization_used=manual_refresh_browser_stabilization_used,
+ post_slider_session_retry_count=0,
+ )
+
+ # 刷新失败时继续执行原有的失败处理逻辑
+
+ if self.last_token_refresh_status in (None, "started"):
+ self.last_token_refresh_status = "token_refresh_failed"
+ self.last_token_refresh_error_message = json.dumps(res_json, ensure_ascii=False, separators=(',', ':'))
+ self._clear_pending_slider_success_notice("Token刷新最终失败")
+ logger.error(f"【{self.cookie_id}】Token刷新失败: {res_json}")
+
+ # 清空当前token,确保下次重试时重新获取
+ self.current_token = None
+
+ # 只有在没有发送过通知的情况下才发送Token刷新失败通知
+ # 并且WebSocket未连接时才发送(已连接说明只是暂时失败)
+ if not notification_sent:
+ # 检查WebSocket连接状态
+ is_ws_connected = (
+ self.connection_state == ConnectionState.CONNECTED and
+ self.ws and
+ not self.ws.closed
+ )
+
+ if is_ws_connected:
+ logger.info(f"【{self.cookie_id}】WebSocket连接正常,Token刷新失败可能是暂时的,跳过失败通知")
+ else:
+ logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送Token刷新失败通知")
+ await self.send_token_refresh_notification(f"Token刷新失败: {res_json}", "token_refresh_failed")
+ else:
+ logger.info(f"【{self.cookie_id}】已发送滑块验证相关通知,跳过Token刷新失败通知")
+ return None
+
+ except Exception as e:
+ self.last_token_refresh_status = "token_refresh_exception"
+ self.last_token_refresh_error_message = self._safe_str(e)
+ self._clear_pending_slider_success_notice("Token刷新异常")
+ logger.error(f"Token刷新异常: {self._safe_str(e)}")
+
+ # 清空当前token,确保下次重试时重新获取
+ self.current_token = None
+
+ # 只有在没有发送过通知的情况下才发送Token刷新异常通知
+ # 并且WebSocket未连接时才发送(已连接说明只是暂时失败)
+ if not notification_sent:
+ # 检查WebSocket连接状态
+ is_ws_connected = (
+ self.connection_state == ConnectionState.CONNECTED and
+ self.ws and
+ not self.ws.closed
+ )
+
+ if is_ws_connected:
+ logger.info(f"【{self.cookie_id}】WebSocket连接正常,Token刷新异常可能是暂时的,跳过失败通知")
+ else:
+ logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送Token刷新异常通知")
+ await self.send_token_refresh_notification(f"Token刷新异常: {str(e)}", "token_refresh_exception")
+ else:
+ logger.info(f"【{self.cookie_id}】已发送滑块验证相关通知,跳过Token刷新异常通知")
+ return None
+
+ def _need_captcha_verification(self, res_json: dict) -> bool:
+ """检查响应是否需要滑块验证"""
+ try:
+ if not isinstance(res_json, dict):
+ return False
+
+ # 记录res_json内容到日志文件
+ import json
+ res_json_str = json.dumps(res_json, ensure_ascii=False, separators=(',', ':'))
+ log_captcha_event(self.cookie_id, "检查滑块验证响应", None, f"res_json内容: {res_json_str}")
+
+ # 检查返回的错误信息
+ ret_value = res_json.get('ret', [])
+ if not ret_value:
+ return False
+
+ # 检查是否包含需要验证的关键词
+ captcha_keywords = [
+ 'FAIL_SYS_USER_VALIDATE', # 用户验证失败
+ 'RGV587_ERROR', # 风控错误
+ '哎哟喂,被挤爆啦', # 被挤爆了
+ '哎哟喂,被挤爆啦', # 被挤爆了(中文逗号)
+ '挤爆了', # 挤爆了
+ '请稍后重试', # 请稍后重试
+ 'punish?x5secdata', # 惩罚页面
+ 'captcha', # 验证码
+ ]
+
+ error_msg = str(ret_value[0]) if ret_value else ''
+
+ # 检查错误信息是否包含需要验证的关键词
+ for keyword in captcha_keywords:
+ if keyword in error_msg:
+ logger.info(f"【{self.cookie_id}】检测到需要滑块验证的关键词: {keyword}")
+ return True
+
+ # 检查data字段中是否包含验证URL
+ data = res_json.get('data', {})
+ if isinstance(data, dict) and 'url' in data:
+ url = data.get('url', '')
+ if 'punish' in url or 'captcha' in url or 'validate' in url:
+ logger.info(f"【{self.cookie_id}】检测到验证URL: {url}")
+ return True
+
+ return False
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】检查是否需要滑块验证时出错: {self._safe_str(e)}")
+ return False
+
+ async def _handle_captcha_verification(self, res_json: dict) -> str:
+ """处理滑块验证,返回新的cookies字符串"""
+ try:
+ logger.info(f"【{self.cookie_id}】开始处理滑块验证...")
+
+ if self.is_manual_refresh_active(self.cookie_id, allow_handoff_recovery=True):
+ logger.warning(f"【{self.cookie_id}】手动刷新进行中,取消自动滑块处理")
+ log_captcha_event(
+ self.cookie_id,
+ "手动刷新进行中,取消自动滑块处理",
+ None,
+ "自动滑块处理已跳过"
+ )
+ return None
+
+ # 获取验证URL
+ verification_url = None
+
+ # 从data字段获取URL
+ data = res_json.get('data', {})
+ if isinstance(data, dict) and 'url' in data:
+ verification_url = data.get('url')
+
+ # 如果没有找到URL,使用默认的验证页面
+ if not verification_url:
+ logger.info(f"【{self.cookie_id}】未找到验证URL,认为不需要滑块验证,返回正常")
+ return None
+
+ logger.info(f"【{self.cookie_id}】验证URL: {verification_url}")
+
+ # 使用滑块验证器(独立实例,解决并发冲突)
+ try:
+ # 使用集成的滑块验证方法(无需猴子补丁)
+ from utils.xianyu_slider_stealth import XianyuSliderStealth
+ logger.info(f"【{self.cookie_id}】XianyuSliderStealth导入成功,使用滑块验证")
+
+ # 创建独立的滑块验证实例(每个用户独立实例,避免并发冲突)
+ slider_stealth = XianyuSliderStealth(
+ # user_id=f"{self.cookie_id}_{int(time.time() * 1000)}", # 使用唯一ID避免冲突
+ user_id=f"{self.cookie_id}", # 使用唯一ID避免冲突
+ enable_learning=True, # 启用学习功能
+ headless=True # 使用有头模式(可视化浏览器)
+ )
+
+ # 直接使用异步方法执行滑块验证(避免 ThreadPoolExecutor 导致的 Playwright 初始化问题)
+ success, cookies = await slider_stealth.async_run(verification_url)
+
+ if success and cookies:
+ logger.info(f"【{self.cookie_id}】滑块验证成功,获取到新的cookies")
+
+ current_cookies_dict = trans_cookies(self.cookies_str)
+ x5sec_cookies = {}
+
+ # 筛选出x5相关的cookies(包括x5sec, x5step等)
+ for cookie_name, cookie_value in cookies.items():
+ cookie_name_lower = cookie_name.lower()
+ if cookie_name_lower.startswith('x5') or 'x5sec' in cookie_name_lower:
+ x5sec_cookies[cookie_name] = cookie_value
+
+ logger.info(f"【{self.cookie_id}】找到{len(x5sec_cookies)}个x5相关cookies: {list(x5sec_cookies.keys())}")
+
+ merge_result = self.protected_merge_cookie_dicts(current_cookies_dict, cookies)
+ updated_cookies = merge_result['merged_cookies_dict']
+ updated_fields = merge_result['updated_fields']
+ changed_fields = merge_result['changed_fields']
+ new_fields = merge_result['new_fields']
+ removed_fields = merge_result['removed_fields']
+ preserved_fields = merge_result['preserved_fields']
+ preserved_protected_fields = merge_result['preserved_protected_fields']
+ would_remove_fields = merge_result['would_remove_fields']
+ missing_protected_fields = merge_result['missing_protected_fields']
+ missing_required_fields = merge_result['missing_required_fields']
+ incoming_missing_protected_fields = merge_result['incoming_missing_protected_fields']
+ account_switched = merge_result['account_switched']
+ cookies_str = "; ".join([f"{k}={v}" for k, v in updated_cookies.items()])
+ qr_login_grace = self.get_qr_login_grace(self.cookie_id)
+ merge_event_name = "slider_post_qr_protected_merge" if qr_login_grace else "captcha_protected_merge"
+ self._log_protected_merge_event(merge_event_name, merge_result)
+
+ self._log_cookie_merge_summary(
+ updated_cookies,
+ updated_fields,
+ changed_fields,
+ new_fields,
+ context="滑块验证成功后Cookie合并",
+ preserved_fields=preserved_fields,
+ preserved_protected_fields=preserved_protected_fields,
+ would_remove_fields=would_remove_fields,
+ removed_fields=removed_fields,
+ missing_protected_fields=missing_protected_fields,
+ missing_required_fields=missing_required_fields,
+ incoming_missing_protected_fields=incoming_missing_protected_fields,
+ account_switched=account_switched,
+ )
+
+ if missing_required_fields:
+ logger.error(f"【{self.cookie_id}】滑块验证后的Cookie仍缺失核心字段,放弃写回数据库: {', '.join(missing_required_fields)}")
+ return None
+
+ # 自动更新数据库中的cookie
+ try:
+ # 备份原有cookies
+ old_cookies_str = self.cookies_str
+ old_cookies_dict = self.cookies.copy()
+
+ # 更新当前实例的cookies(使用合并后的cookies)
+ self._set_runtime_cookie_state(
+ cookies_str=cookies_str,
+ cookies_dict=updated_cookies,
+ source="slider_success",
+ )
+
+ # 更新数据库中的cookies
+ await self.update_config_cookies()
+ logger.info(f"【{self.cookie_id}】滑块验证成功后,数据库cookies已自动更新")
+ self._mark_slider_success_recovery(cookies_str)
+ self._mark_pending_slider_success_notice("token_refresh")
+ XianyuLive.clear_password_login_failure_backoff(self.cookie_id)
+ logger.info(f"【{self.cookie_id}】滑块验证成功后,已清理密码登录失败退避状态")
+
+ # 记录成功更新到日志文件,包含关键字段变化和x5相关cookie信息
+ x5sec_cookies_str = "; ".join([f"{k}={v}" for k, v in x5sec_cookies.items()]) if x5sec_cookies else "无"
+ log_captcha_event(self.cookie_id, "滑块验证成功并自动更新数据库", True,
+ f"原有{len(current_cookies_dict)}个cookie项, 浏览器快照{len(cookies)}个, 合并后{len(updated_cookies)}个, 变更字段{len(changed_fields)}个, 新增字段{len(new_fields)}个, 保护保留{len(preserved_protected_fields)}个, 实际移除{len(removed_fields)}个, x5 cookies: {x5sec_cookies_str}")
+
+ except Exception as update_e:
+ logger.error(f"【{self.cookie_id}】自动更新数据库cookies失败: {self._safe_str(update_e)}")
+
+ # 回滚cookies
+ self._set_runtime_cookie_state(
+ cookies_str=old_cookies_str,
+ cookies_dict=old_cookies_dict,
+ source="slider_success_rollback",
+ )
+
+ # 记录更新失败到日志文件,包含获取到的x5 cookies
+ x5sec_cookies_str = "; ".join([f"{k}={v}" for k, v in x5sec_cookies.items()]) if x5sec_cookies else "无"
+ log_captcha_event(self.cookie_id, "滑块验证成功但数据库更新失败", False,
+ f"更新异常: {self._safe_str(update_e)[:100]}, 变更字段{len(changed_fields)}个, 新增字段{len(new_fields)}个, 保护保留{len(preserved_protected_fields)}个, 获取到的x5 cookies: {x5sec_cookies_str}")
+
+ # 发送更新失败通知
+ await self.send_token_refresh_notification(
+ f"滑块验证成功但数据库更新失败: {self._safe_str(update_e)}",
+ "captcha_success_db_update_failed"
+ )
+
+ return None
+
+ return cookies_str
+ else:
+ logger.error(f"【{self.cookie_id}】滑块验证失败")
+
+ # 记录滑块验证失败到日志文件
+ log_captcha_event(self.cookie_id, "滑块验证失败", False,
+ f"XianyuSliderStealth执行失败, 环境: {'Docker' if os.getenv('DOCKER_ENV') else '本地'}")
+
+ # 发送通知(检查WebSocket连接状态)
+ # 只有在WebSocket未连接时才发送通知,已连接说明可能是暂时性问题
+ is_ws_connected = (
+ self.connection_state == ConnectionState.CONNECTED and
+ self.ws and
+ not self.ws.closed
+ )
+
+ if is_ws_connected:
+ logger.info(f"【{self.cookie_id}】WebSocket连接正常,滑块验证失败可能是暂时的,跳过通知")
+ else:
+ logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送滑块验证失败通知")
+ await self.send_token_refresh_notification(
+ f"滑块验证失败,需要手动处理。验证URL: {verification_url}",
+ "captcha_verification_failed"
+ )
+ return None
+
+ except ImportError as import_e:
+ logger.error(f"【{self.cookie_id}】XianyuSliderStealth导入失败: {import_e}")
+ logger.error(f"【{self.cookie_id}】请安装Playwright库: pip install playwright")
+
+ # 记录导入失败到日志文件
+ log_captcha_event(self.cookie_id, "XianyuSliderStealth导入失败", False,
+ f"Playwright未安装, 错误: {import_e}")
+
+ # 发送通知
+ await self.send_token_refresh_notification(
+ f"滑块验证功能不可用,请安装Playwright。验证URL: {verification_url}",
+ "captcha_dependency_missing"
+ )
+ return None
+
+ except Exception as stealth_e:
+ logger.error(f"【{self.cookie_id}】滑块验证异常: {self._safe_str(stealth_e)}")
+
+ # 记录异常到日志文件
+ log_captcha_event(self.cookie_id, "滑块验证异常", False,
+ f"执行异常, 错误: {self._safe_str(stealth_e)[:100]}")
+
+ # 发送通知(检查WebSocket连接状态)
+ # 只有在WebSocket未连接时才发送通知,已连接说明可能是暂时性问题
+ is_ws_connected = (
+ self.connection_state == ConnectionState.CONNECTED and
+ self.ws and
+ not self.ws.closed
+ )
+
+ if is_ws_connected:
+ logger.info(f"【{self.cookie_id}】WebSocket连接正常,滑块验证执行异常可能是暂时的,跳过通知")
+ else:
+ logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送滑块验证执行异常通知")
+ await self.send_token_refresh_notification(
+ f"滑块验证执行异常,需要手动处理。验证URL: {verification_url}",
+ "captcha_execution_error"
+ )
+ return None
+
+
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】处理滑块验证时出错: {self._safe_str(e)}")
+ return None
+
+ async def _update_cookies_and_restart(self, new_cookies_str: str):
+ """更新cookies并重启任务"""
+ try:
+ logger.info(f"【{self.cookie_id}】开始更新cookies并重启任务...")
+
+ # 验证新cookies的有效性
+ if not new_cookies_str or not new_cookies_str.strip():
+ logger.error(f"【{self.cookie_id}】新cookies为空,无法更新")
+ return False
+
+ # 解析新cookies,确保格式正确
+ try:
+ new_cookies_dict = trans_cookies(new_cookies_str)
+ if not new_cookies_dict:
+ logger.error(f"【{self.cookie_id}】新cookies解析失败,无法更新")
+ return False
+ logger.info(f"【{self.cookie_id}】新cookies解析成功,包含 {len(new_cookies_dict)} 个字段")
+ except Exception as parse_e:
+ logger.error(f"【{self.cookie_id}】新cookies解析异常: {self._safe_str(parse_e)}")
+ return False
+
+ # 合并cookies:保留原有cookies,只更新新获取到的字段
+ try:
+ merge_result = self.protected_merge_cookie_dicts(trans_cookies(self.cookies_str), new_cookies_dict)
+ merged_cookies_dict = merge_result['merged_cookies_dict']
+ updated_fields = merge_result['updated_fields']
+ changed_fields = merge_result['changed_fields']
+ new_fields = merge_result['new_fields']
+ self._log_protected_merge_event("password_refresh_protected_merge", merge_result)
+
+ self._log_cookie_merge_summary(
+ merged_cookies_dict,
+ updated_fields,
+ changed_fields,
+ new_fields,
+ context="密码登录刷新Cookie",
+ preserved_fields=merge_result['preserved_fields'],
+ preserved_protected_fields=merge_result['preserved_protected_fields'],
+ would_remove_fields=merge_result['would_remove_fields'],
+ removed_fields=merge_result['removed_fields'],
+ missing_protected_fields=merge_result['missing_protected_fields'],
+ missing_required_fields=merge_result['missing_required_fields'],
+ incoming_missing_protected_fields=merge_result['incoming_missing_protected_fields'],
+ account_switched=merge_result['account_switched'],
+ )
+
+ if merge_result['missing_required_fields']:
+ logger.error(
+ f"【{self.cookie_id}】密码登录刷新后的Cookie仍缺失核心字段,放弃写回并重启: {', '.join(merge_result['missing_required_fields'])}"
+ )
+ return False
+
+ # 使用合并后的cookies字符串
+ new_cookies_str = '; '.join([f"{k}={v}" for k, v in merged_cookies_dict.items()])
+ new_cookies_dict = merged_cookies_dict
+
+ except Exception as merge_e:
+ logger.error(f"【{self.cookie_id}】cookies合并异常: {self._safe_str(merge_e)}")
+ logger.warning(f"【{self.cookie_id}】将使用原始新cookies(不合并)")
+ # 如果合并失败,继续使用原始的new_cookies_str
+
+ # 备份原有cookies,以防更新失败需要回滚
+ old_cookies_str = self.cookies_str
+ old_cookies_dict = self.cookies.copy()
+
+ try:
+ # 更新当前实例的cookies
+ self._set_runtime_cookie_state(
+ cookies_str=new_cookies_str,
+ cookies_dict=new_cookies_dict,
+ source="password_login_refresh",
+ )
+
+ # 更新数据库中的cookies
+ await self.update_config_cookies()
+ logger.info(f"【{self.cookie_id}】数据库cookies更新成功")
+
+ # ⚠️ 在重启前完成所有需要的操作(如发送通知)
+ # 因为重启触发后2秒内任务会被取消,不能再执行任何async操作
+ logger.info(f"【{self.cookie_id}】cookies更新成功,准备重启任务...")
+
+ # 通过CookieManager重启任务
+ logger.info(f"【{self.cookie_id}】通过CookieManager触发重启...")
+ await self._restart_instance()
+
+ # ⚠️ _restart_instance() 已触发重启,当前任务即将被取消
+ # 立即返回,不执行任何后续代码(包括发送通知)
+ logger.info(f"【{self.cookie_id}】重启请求已触发,等待任务被取消...")
+ return True
+
+ except Exception as update_e:
+ logger.error(f"【{self.cookie_id}】更新cookies过程中出错,尝试回滚: {self._safe_str(update_e)}")
+
+ # 回滚cookies
+ try:
+ self._set_runtime_cookie_state(
+ cookies_str=old_cookies_str,
+ cookies_dict=old_cookies_dict,
+ source="password_login_refresh_rollback",
+ )
+ await self.update_config_cookies()
+ logger.info(f"【{self.cookie_id}】cookies已回滚到原始状态")
+ except Exception as rollback_e:
+ logger.error(f"【{self.cookie_id}】cookies回滚失败: {self._safe_str(rollback_e)}")
+
+ return False
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】更新cookies并重启任务时出错: {self._safe_str(e)}")
+ return False
+
+ async def update_config_cookies(self):
+ """更新数据库中的cookies(不会覆盖账号密码等其他字段)"""
+ try:
+ from db_manager import db_manager
+
+ # 更新数据库中的Cookie
+ if hasattr(self, 'cookie_id') and self.cookie_id:
+ try:
+ # 获取当前Cookie的用户ID,避免在刷新时改变所有者
+ current_user_id = None
+ if hasattr(self, 'user_id') and self.user_id:
+ current_user_id = self.user_id
+
+ # 使用 update_cookie_account_info 避免覆盖其他字段(如 username, password, pause_duration, remark 等)
+ # 这个方法会自动处理新账号和现有账号的情况,不会覆盖账号密码
+ success = db_manager.update_cookie_account_info(
+ self.cookie_id,
+ cookie_value=self.cookies_str,
+ user_id=current_user_id # 如果是新账号,需要提供user_id
+ )
+ if not success:
+ # 如果更新失败,记录错误但不使用 save_cookie(避免覆盖账号密码)
+ logger.warning(f"更新Cookie到数据库失败: {self.cookie_id},但不使用save_cookie避免覆盖账号密码")
+ else:
+ logger.warning(f"已更新Cookie到数据库: {self.cookie_id}")
+ except Exception as e:
+ logger.error(f"更新数据库Cookie失败: {self._safe_str(e)}")
+ # 发送数据库更新失败通知
+ await self.send_token_refresh_notification(f"数据库Cookie更新失败: {str(e)}", "db_update_failed")
+ else:
+ logger.warning("Cookie ID不存在,无法更新数据库")
+ # 发送Cookie ID缺失通知
+ await self.send_token_refresh_notification("Cookie ID不存在,无法更新数据库", "cookie_id_missing")
+
+ except Exception as e:
+ logger.error(f"更新Cookie失败: {self._safe_str(e)}")
+ # 发送Cookie更新失败通知
+ await self.send_token_refresh_notification(f"Cookie更新失败: {str(e)}", "cookie_update_failed")
+
+ async def _try_password_login_refresh(
+ self,
+ trigger_reason: str = "令牌/Session过期",
+ risk_session_id: Optional[str] = None,
+ trigger_scene: Optional[str] = None,
+ ignore_slider_failed_backoff: bool = False,
+ ):
+ """尝试通过密码登录刷新Cookie并重启实例
+
+ Args:
+ trigger_reason: 触发原因,用于日志记录
+
+ Returns:
+ bool: 是否成功刷新Cookie
+ """
+ logger.warning(f"【{self.cookie_id}】检测到{trigger_reason},准备刷新Cookie并重启实例...")
+ trigger_scene = trigger_scene or self._normalize_risk_trigger_scene(trigger_reason, default='auto_cookie_refresh')
+ risk_session_id = risk_session_id or self._new_risk_session_id('cookie')
+ risk_log_started_at = time.time()
+ base_event_meta = {'cookie_id': self.cookie_id, 'trigger_reason': trigger_reason}
+
+ # 记录到风控日志
+ refresh_risk_log_id = None
+ try:
+ stale_count = db_manager.mark_stale_risk_control_logs_failed(timeout_minutes=15, cookie_id=self.cookie_id)
+ if stale_count > 0:
+ logger.warning(f"【{self.cookie_id}】检测到{stale_count}条超时processing风控日志,已自动标记failed")
+ refresh_risk_log_id = self._create_risk_log(
+ event_type='cookie_refresh',
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='cookie_refresh_started',
+ event_description=f"{trigger_reason}触发Cookie刷新",
+ processing_status='processing',
+ event_meta=self._build_risk_event_meta(trigger_scene=trigger_scene, extra=base_event_meta),
+ )
+ except Exception as log_e:
+ logger.error(f"【{self.cookie_id}】记录风控日志失败: {log_e}")
+
+ if self.is_manual_refresh_active(self.cookie_id, allow_handoff_recovery=True):
+ logger.warning(f"【{self.cookie_id}】手动刷新进行中,跳过自动密码登录刷新")
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='manual_refresh_active',
+ processing_status='failed',
+ error_message='手动刷新进行中,自动密码登录刷新已跳过',
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(trigger_scene=trigger_scene, extra=base_event_meta),
+ )
+ return False
+
+ recovery_lock_owner = f"{self.cookie_id}:{trigger_scene or 'auto_cookie_refresh'}:{int(time.time() * 1000)}"
+ recovery_lock_acquired = False
+
+ # 检查是否在密码登录冷却期内,避免重复登录
+ current_time = time.time()
+ failure_backoff = XianyuLive.get_password_login_failure_backoff(self.cookie_id)
+ if failure_backoff:
+ remaining_time = failure_backoff.get('until', 0) - current_time
+ if remaining_time > 0:
+ backoff_reason = failure_backoff.get('reason', 'unknown')
+ if backoff_reason == 'slider_failed' and (
+ ignore_slider_failed_backoff or self.consume_manual_refresh_slider_failed_bypass(self.cookie_id)
+ ):
+ logger.warning(
+ f"【{self.cookie_id}】检测到最近刚通过滑块或处于刷新交接恢复窗口,忽略一次旧的 slider_failed 退避并继续尝试密码登录刷新"
+ )
+ XianyuLive.clear_password_login_failure_backoff(self.cookie_id)
+ failure_backoff = None
+ else:
+ logger.warning(
+ f"【{self.cookie_id}】密码登录失败退避中(原因: {backoff_reason}),还需等待 {remaining_time:.1f} 秒"
+ )
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='password_login_backoff',
+ processing_status='failed',
+ error_message=f"密码登录失败退避中,剩余{remaining_time:.1f}秒",
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(
+ trigger_scene=trigger_scene,
+ extra={**base_event_meta, 'backoff_reason': backoff_reason, 'backoff_seconds': failure_backoff.get('seconds')},
+ ),
+ )
+ return False
+
+ last_password_login = XianyuLive._last_password_login_time.get(self.cookie_id, 0)
+ time_since_last_login = current_time - last_password_login
+
+ if last_password_login > 0 and time_since_last_login < XianyuLive._password_login_cooldown:
+ remaining_time = XianyuLive._password_login_cooldown - time_since_last_login
+ logger.warning(f"【{self.cookie_id}】距离上次密码登录仅 {time_since_last_login:.1f} 秒,仍在冷却期内(还需等待 {remaining_time:.1f} 秒),跳过密码登录")
+ logger.warning(f"【{self.cookie_id}】提示:如果新Cookie仍然无效,请检查账号状态或手动更新Cookie")
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='password_login_cooldown',
+ processing_status='failed',
+ error_message=f"密码登录冷却期内,剩余{remaining_time:.1f}秒",
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(trigger_scene=trigger_scene, extra=base_event_meta),
+ )
+ return False
+
+ recovery_lock_acquired, existing_lock = XianyuLive.acquire_auth_recovery_lock(
+ self.cookie_id,
+ recovery_lock_owner,
+ )
+ if not recovery_lock_acquired:
+ existing_owner = (existing_lock or {}).get('owner', 'unknown')
+ logger.warning(f"【{self.cookie_id}】认证恢复流程已在执行中,跳过本次重复触发: owner={existing_owner}")
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='auth_recovery_in_progress',
+ processing_status='failed',
+ error_message='已有认证恢复流程执行中',
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(
+ trigger_scene=trigger_scene,
+ extra={**base_event_meta, 'active_owner': existing_owner},
+ ),
+ )
+ return False
+
+ # 记录到日志文件
+ log_captcha_event(self.cookie_id, f"{trigger_reason}触发Cookie刷新和实例重启", None,
+ f"检测到{trigger_reason},准备刷新Cookie并重启实例")
+
+ try:
+ # 从数据库获取账号登录信息
+ account_info = db_manager.get_cookie_details(self.cookie_id)
+
+ if not account_info:
+ logger.error(f"【{self.cookie_id}】无法获取账号信息")
+ self.last_token_refresh_error_message = "无法获取账号信息"
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='account_info_missing',
+ processing_status='failed',
+ error_message='无法获取账号信息',
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(trigger_scene=trigger_scene, extra=base_event_meta),
+ )
+ return False
+
+ # 【重要】先检查数据库中的cookie是否已经更新
+ # 如果用户已经手动更新了cookie,就不需要触发密码登录刷新
+ db_cookie_value = account_info.get('cookie_value', '')
+ if db_cookie_value and db_cookie_value != self.cookies_str:
+ logger.info(f"【{self.cookie_id}】检测到数据库中的cookie已更新,重新加载cookie")
+ self._set_runtime_cookie_state(cookies_str=db_cookie_value, source="db_cookie_reload_before_password_login")
+ logger.info(f"【{self.cookie_id}】Cookie已从数据库重新加载,跳过密码登录刷新")
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='cookie_already_updated',
+ processing_status='success',
+ processing_result='检测到数据库Cookie已更新,自动刷新流程跳过',
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(trigger_scene=trigger_scene, extra=base_event_meta),
+ )
+ return True
+
+ username = account_info.get('username', '')
+ password = account_info.get('password', '')
+ show_browser = account_info.get('show_browser', False)
+
+ # 检查是否配置了用户名和密码
+ if not username or not password:
+ logger.warning(f"【{self.cookie_id}】未配置用户名或密码,跳过密码登录刷新")
+ self.last_token_refresh_error_message = "未配置用户名或密码,无法自动刷新Cookie"
+ await self.send_token_refresh_notification(
+ f"检测到{trigger_reason},但未配置用户名或密码,无法自动刷新Cookie",
+ "no_credentials"
+ )
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='missing_credentials',
+ processing_status='failed',
+ error_message='未配置用户名或密码,无法自动刷新Cookie',
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(trigger_scene=trigger_scene, extra=base_event_meta),
+ )
+ return False
+
+ # 使用集成的 Playwright 登录方法(无需猴子补丁)
+ from utils.xianyu_slider_stealth import XianyuSliderStealth
+ browser_mode = "有头" if show_browser else "无头"
+ logger.info(f"【{self.cookie_id}】开始使用{browser_mode}浏览器进行密码登录刷新Cookie...")
+ logger.info(f"【{self.cookie_id}】使用账号: {username}")
+
+ # 创建一个通知回调包装函数,支持接收截图路径和验证链接
+ async def notification_callback_wrapper(message: str, screenshot_path: str = None, verification_url: str = None):
+ """通知回调包装函数,支持接收截图路径和验证链接"""
+ await self.send_token_refresh_notification(
+ error_message=message,
+ notification_type="token_refresh",
+ chat_id=None,
+ attachment_path=screenshot_path,
+ verification_url=verification_url
+ )
+
+ # 在单独的线程中运行同步的登录方法
+ import asyncio
+ slider = XianyuSliderStealth(user_id=self.cookie_id, enable_learning=True, headless=not show_browser)
+ slider.risk_session_id = risk_session_id
+ slider.risk_trigger_scene = trigger_scene
+ result = await asyncio.to_thread(
+ slider.login_with_password_playwright,
+ account=username,
+ password=password,
+ show_browser=show_browser,
+ notification_callback=notification_callback_wrapper,
+ force_clean_context=True,
+ )
+
+ if result:
+ logger.info(f"【{self.cookie_id}】密码登录成功,获取到Cookie")
+ logger.info(f"【{self.cookie_id}】Cookie内容: {result}")
+ XianyuLive.clear_password_login_failure_backoff(self.cookie_id)
+
+ # 打印密码登录获取的Cookie字段详情
+ logger.info(f"【{self.cookie_id}】========== 密码登录Cookie字段详情 ==========")
+ logger.info(f"【{self.cookie_id}】Cookie字段数: {len(result)}")
+ logger.info(f"【{self.cookie_id}】Cookie字段列表:")
+ for i, (key, value) in enumerate(result.items(), 1):
+ if len(str(value)) > 50:
+ logger.info(f"【{self.cookie_id}】 {i:2d}. {key}: {str(value)[:30]}...{str(value)[-20:]} (长度: {len(str(value))})")
+ else:
+ logger.info(f"【{self.cookie_id}】 {i:2d}. {key}: {value}")
+
+ # 检查关键字段
+ important_keys = ['unb', '_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'cna']
+ logger.info(f"【{self.cookie_id}】关键字段检查:")
+ for key in important_keys:
+ if key in result:
+ val = result[key]
+ logger.info(f"【{self.cookie_id}】 ✅ {key}: {'存在' if val else '为空'} (长度: {len(str(val)) if val else 0})")
+ else:
+ logger.info(f"【{self.cookie_id}】 ❌ {key}: 缺失")
+ logger.info(f"【{self.cookie_id}】==========================================")
+
+ # 将cookie字典转换为字符串格式
+ new_cookies_str = '; '.join([f"{k}={v}" for k, v in result.items()])
+ logger.info(f"【{self.cookie_id}】Cookie字符串摘要: {self._summarize_cookie_string(new_cookies_str)}")
+
+ # 记录密码登录时间,防止重复登录
+ XianyuLive._last_password_login_time[self.cookie_id] = time.time()
+ logger.warning(f"【{self.cookie_id}】已记录密码登录时间,冷却期 {XianyuLive._password_login_cooldown} 秒")
+
+ # ⚠️ 先发送通知,再更新cookies并重启任务
+ # 因为重启后当前任务会被取消,不能在重启后发送通知
+ try:
+ await self.send_token_refresh_notification(
+ f"账号密码登录成功,Cookie已获取,准备更新并重启",
+ "cookie_refresh_success"
+ )
+ except Exception as notify_e:
+ logger.warning(f"【{self.cookie_id}】发送通知失败: {self._safe_str(notify_e)}")
+
+ # 更新cookies并重启任务
+ update_success = await self._update_cookies_and_restart(new_cookies_str)
+
+ if update_success:
+ logger.info(f"【{self.cookie_id}】Cookie更新并重启任务成功")
+ # 更新风控日志状态为成功
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='cookie_refresh_success',
+ processing_status='success',
+ processing_result='密码登录刷新Cookie成功,实例已重启',
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(trigger_scene=trigger_scene, extra=base_event_meta),
+ )
+ return True
+ else:
+ logger.error(f"【{self.cookie_id}】Cookie更新失败")
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='cookie_save_failed',
+ processing_status='failed',
+ error_message='Cookie获取成功但更新到数据库失败',
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(trigger_scene=trigger_scene, extra=base_event_meta),
+ )
+ return False
+
+ else:
+ login_error = getattr(slider, 'last_login_error', '') or "密码登录失败,未获取到Cookie"
+ self.last_token_refresh_error_message = login_error
+ backoff_reason, backoff_seconds = XianyuLive.classify_password_login_failure(login_error)
+ XianyuLive.set_password_login_failure_backoff(self.cookie_id, backoff_reason, backoff_seconds)
+ logger.warning(f"【{self.cookie_id}】密码登录失败,未获取到Cookie: {login_error}")
+ logger.warning(f"【{self.cookie_id}】已进入失败退避期: {backoff_reason}, {backoff_seconds}秒")
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code=f'password_login_{backoff_reason}',
+ processing_status='failed',
+ error_message=login_error[:200],
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(
+ trigger_scene=trigger_scene,
+ extra={**base_event_meta, 'backoff_reason': backoff_reason, 'backoff_seconds': backoff_seconds},
+ ),
+ )
+ return False
+
+ except Exception as refresh_e:
+ self.last_token_refresh_error_message = self._safe_str(refresh_e)
+ backoff_reason, backoff_seconds = XianyuLive.classify_password_login_failure(str(refresh_e))
+ XianyuLive.set_password_login_failure_backoff(self.cookie_id, backoff_reason, backoff_seconds)
+ logger.error(f"【{self.cookie_id}】Cookie刷新或实例重启失败: {self._safe_str(refresh_e)}")
+ import traceback
+ logger.error(f"【{self.cookie_id}】详细堆栈:\n{traceback.format_exc()}")
+ if refresh_risk_log_id:
+ self._update_risk_log(
+ refresh_risk_log_id,
+ session_id=risk_session_id,
+ trigger_scene=trigger_scene,
+ result_code='cookie_refresh_exception',
+ processing_status='failed',
+ error_message=str(refresh_e)[:200],
+ duration_ms=max(0, int((time.time() - risk_log_started_at) * 1000)),
+ event_meta=self._build_risk_event_meta(trigger_scene=trigger_scene, extra=base_event_meta),
+ )
+ return False
+ finally:
+ if recovery_lock_acquired:
+ XianyuLive.release_auth_recovery_lock(self.cookie_id, recovery_lock_owner)
+
+ async def _verify_cookie_validity(self) -> dict:
+ """验证Cookie的有效性,通过实际调用API测试
+
+ Returns:
+ dict: {
+ 'valid': bool, # 总体是否有效
+ 'confirm_api': bool, # 确认发货API是否有效
+ 'image_api': bool, # 图片上传API是否有效
+ 'details': str # 详细信息
+ }
+ """
+ logger.info(f"【{self.cookie_id}】开始验证Cookie有效性(使用真实API调用)...")
+
+ result = {
+ 'valid': True,
+ 'confirm_api': None,
+ 'web_session_api': None,
+ 'image_api': None,
+ 'details': [],
+ 'inconclusive': False,
+ 'relogin_recommended': True,
+ }
+
+ # 1. 测试确认发货API - 使用测试订单ID实际调用
+ # try:
+ # logger.info(f"【{self.cookie_id}】测试确认发货API(使用测试数据实际调用)...")
+
+ # # 确保session存在
+ # if not self.session:
+ # import aiohttp
+ # connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
+ # timeout = aiohttp.ClientTimeout(total=30)
+ # self.session = aiohttp.ClientSession(connector=connector, timeout=timeout)
+
+ # # 创建临时的确认发货实例
+ # from secure_confirm_decrypted import SecureConfirm
+ # confirm_tester = SecureConfirm(
+ # session=self.session,
+ # cookies_str=self.cookies_str,
+ # cookie_id=self.cookie_id,
+ # main_instance=self
+ # )
+
+ # # 使用一个测试订单ID(不存在的订单ID)
+ # # 如果Cookie有效,应该返回"订单不存在"类的错误
+ # # 如果Cookie无效,会返回"Session过期"错误
+ # test_order_id = "999999999999999999" # 不存在的测试订单ID
+
+ # # 实际调用API (retry_count=3阻止重试,快速失败)
+ # response = await confirm_tester.auto_confirm(test_order_id, retry_count=3)
+
+ # # 分析响应
+ # if response and isinstance(response, dict):
+ # error_msg = str(response.get('error', ''))
+ # success = response.get('success', False)
+
+ # # 检查是否是Session过期错误
+ # if 'Session过期' in error_msg or 'SESSION_EXPIRED' in error_msg:
+ # logger.warning(f"【{self.cookie_id}】❌ 确认发货API验证失败: Session过期")
+ # result['confirm_api'] = False
+ # result['valid'] = False
+ # result['details'].append("确认发货API: Session过期")
+ # elif '令牌过期' in error_msg:
+ # logger.warning(f"【{self.cookie_id}】❌ 确认发货API验证失败: 令牌过期")
+ # result['confirm_api'] = False
+ # result['valid'] = False
+ # result['details'].append("确认发货API: 令牌过期")
+ # elif success:
+ # # 竟然成功了(不太可能,因为是测试订单ID)
+ # logger.info(f"【{self.cookie_id}】✅ 确认发货API验证通过: API调用成功")
+ # result['confirm_api'] = True
+ # result['details'].append("确认发货API: 通过验证")
+ # elif error_msg and len(error_msg) > 0:
+ # # 有其他错误信息(如订单不存在、重试次数过多等),说明Cookie是有效的
+ # logger.info(f"【{self.cookie_id}】✅ 确认发货API验证通过: Cookie有效(返回业务错误: {error_msg[:50]})")
+ # result['confirm_api'] = True
+ # result['details'].append(f"确认发货API: 通过验证")
+ # else:
+ # # 没有明确信息,保守认为可能有问题
+ # logger.warning(f"【{self.cookie_id}】⚠️ 确认发货API验证警告: 响应不明确")
+ # result['confirm_api'] = False
+ # result['valid'] = False
+ # result['details'].append("确认发货API: 响应不明确")
+ # else:
+ # # 没有响应,可能有问题
+ # logger.warning(f"【{self.cookie_id}】⚠️ 确认发货API验证警告: 无响应")
+ # result['confirm_api'] = False
+ # result['valid'] = False
+ # result['details'].append("确认发货API: 无响应")
+
+ # except Exception as e:
+ # error_str = self._safe_str(e)
+ # # 检查异常信息中是否包含Session过期
+ # if 'Session过期' in error_str or 'SESSION_EXPIRED' in error_str:
+ # logger.warning(f"【{self.cookie_id}】❌ 确认发货API验证失败: Session过期")
+ # result['confirm_api'] = False
+ # result['valid'] = False
+ # result['details'].append("确认发货API: Session过期")
+ # else:
+ # logger.error(f"【{self.cookie_id}】确认发货API验证异常: {error_str}")
+ # # 网络异常等问题,不一定是Cookie问题,暂时标记为通过
+ # result['confirm_api'] = True
+ # result['details'].append(f"确认发货API: 调用异常(可能非Cookie问题)")
+
+ # 2. 测试网页登录态 - 只读访问 IM 页面,检测是否被重定向到登录/验证页
+ try:
+ logger.info(f"【{self.cookie_id}】测试网页登录态(访问 IM 页面)...")
+
+ if not self.session:
+ connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
+ timeout = aiohttp.ClientTimeout(total=30)
+ self.session = aiohttp.ClientSession(connector=connector, timeout=timeout)
+
+ async with self.session.get(
+ 'https://www.goofish.com/im',
+ headers={
+ 'cookie': self.cookies_str,
+ 'Referer': 'https://www.goofish.com/',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ },
+ allow_redirects=True
+ ) as response:
+ final_url = str(response.url)
+ page_text = await response.text()
+
+ redirected_to_login = (
+ 'passport.goofish.com' in final_url or
+ 'mini_login' in final_url or
+ ('mini_login.htm' in page_text and 'alibaba-login-box' in page_text)
+ )
+
+ if redirected_to_login or response.status in (401, 403):
+ logger.warning(f"【{self.cookie_id}】❌ 网页登录态验证失败: 已进入登录/验证页 ({final_url})")
+ result['web_session_api'] = False
+ result['valid'] = False
+ result['details'].append("网页登录态: 已重定向到登录/验证页")
+ elif response.status >= 500:
+ logger.warning(f"【{self.cookie_id}】⚠️ 网页登录态验证遇到服务端异常: HTTP {response.status}")
+ result['web_session_api'] = None
+ result['inconclusive'] = True
+ if result['valid']:
+ result['relogin_recommended'] = False
+ result['details'].append(f"网页登录态: 服务端异常,结果不确定 (HTTP {response.status})")
+ elif response.status == 200:
+ logger.info(f"【{self.cookie_id}】✅ 网页登录态验证通过: {final_url}")
+ result['web_session_api'] = True
+ result['details'].append("网页登录态: 通过验证")
+ else:
+ logger.warning(f"【{self.cookie_id}】⚠️ 网页登录态验证结果不明确: HTTP {response.status}, URL={final_url}")
+ result['web_session_api'] = None
+ result['inconclusive'] = True
+ if result['valid']:
+ result['relogin_recommended'] = False
+ result['details'].append(f"网页登录态: 结果不明确 (HTTP {response.status})")
+
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
+ error_str = self._safe_str(e)
+ logger.warning(f"【{self.cookie_id}】⚠️ 网页登录态验证网络异常: {error_str}")
+ result['web_session_api'] = None
+ result['inconclusive'] = True
+ if result['valid']:
+ result['relogin_recommended'] = False
+ result['details'].append(f"网页登录态: 网络异常,结果不确定 ({error_str[:50]})")
+ except Exception as e:
+ error_str = self._safe_str(e)
+ logger.error(f"【{self.cookie_id}】网页登录态验证异常: {error_str}")
+ result['web_session_api'] = None
+ result['inconclusive'] = True
+ if result['valid']:
+ result['relogin_recommended'] = False
+ result['details'].append(f"网页登录态: 验证异常,结果不确定 - {error_str[:50]}")
+
+ # 3. 测试图片上传API - 创建测试图片并实际上传
+ try:
+ logger.info(f"【{self.cookie_id}】测试图片上传API(使用测试图片实际上传)...")
+
+ # 创建一个最小的测试图片(1x1像素的PNG)
+ import tempfile
+ import os
+ from PIL import Image
+
+ # 创建临时目录
+ temp_dir = tempfile.gettempdir()
+ test_image_path = os.path.join(temp_dir, f'cookie_test_{self.cookie_id}.png')
+
+ try:
+ # 创建1x1像素的白色图片
+ img = Image.new('RGB', (1, 1), color='white')
+ img.save(test_image_path, 'PNG')
+ logger.info(f"【{self.cookie_id}】已创建测试图片: {test_image_path}")
+
+ # 创建图片上传实例
+ from utils.image_uploader import ImageUploader
+ uploader = ImageUploader(cookies_str=self.cookies_str)
+
+ # 创建session
+ await uploader.create_session()
+
+ try:
+ upload_result = None
+ error_type = None
+ error_message = None
+
+ for attempt in range(2):
+ upload_result = await uploader.upload_image(test_image_path)
+ if upload_result:
+ break
+
+ error_type = getattr(uploader, 'last_error_type', None)
+ error_message = getattr(uploader, 'last_error_message', None) or "未知原因"
+ is_retryable_auth = error_type == 'auth' and error_message == '返回登录页面' and result['web_session_api'] is not False
+ if attempt == 0 and is_retryable_auth:
+ logger.warning(
+ f"【{self.cookie_id}】图片上传校验首次返回登录页,但网页登录态仍可访问,1.5秒后重试一次"
+ )
+ await asyncio.sleep(1.5)
+ continue
+ break
+ finally:
+ # 确保关闭session
+ await uploader.close_session()
+
+ # 分析上传结果
+ if upload_result:
+ # 上传成功,Cookie有效
+ logger.info(f"【{self.cookie_id}】✅ 图片上传API验证通过: 上传成功 ({upload_result[:50]}...)")
+ result['image_api'] = True
+ result['details'].append("图片上传API: 通过验证")
+ else:
+ error_type = getattr(uploader, 'last_error_type', None)
+ error_message = getattr(uploader, 'last_error_message', None) or "未知原因"
+ if error_type == 'network':
+ logger.warning(f"【{self.cookie_id}】⚠️ 图片上传API验证遇到网络异常,不判定为Cookie失效: {error_message}")
+ result['image_api'] = None
+ result['inconclusive'] = True
+ if result['valid']:
+ result['relogin_recommended'] = False
+ result['details'].append(f"图片上传API: 网络异常,结果不确定 ({error_message[:50]})")
+ elif error_type == 'http' and getattr(uploader, 'last_http_status', None) and uploader.last_http_status >= 500:
+ logger.warning(f"【{self.cookie_id}】⚠️ 图片上传API返回服务端异常,不判定为Cookie失效: HTTP {uploader.last_http_status}")
+ result['image_api'] = None
+ result['inconclusive'] = True
+ if result['valid']:
+ result['relogin_recommended'] = False
+ result['details'].append(f"图片上传API: 服务端异常,结果不确定 (HTTP {uploader.last_http_status})")
+ elif error_type == 'auth' and error_message == '返回登录页面':
+ logger.warning(
+ f"【{self.cookie_id}】❌ 图片上传接口返回登录页,按旧版严格策略判定Cookie失效"
+ )
+ result['image_api'] = False
+ result['valid'] = False
+ result['details'].append("图片上传API: 返回登录页面")
+ else:
+ # 明确认证/会话异常才视为Cookie失效
+ logger.warning(f"【{self.cookie_id}】❌ 图片上传API验证失败: {error_message}")
+ result['image_api'] = False
+ result['valid'] = False
+ result['details'].append(f"图片上传API: {error_message[:50]}")
+
+ finally:
+ # 清理测试图片
+ if os.path.exists(test_image_path):
+ try:
+ os.remove(test_image_path)
+ logger.debug(f"【{self.cookie_id}】已删除测试图片")
+ except Exception:
+ pass
+
+ except Exception as e:
+ error_str = self._safe_str(e)
+ logger.error(f"【{self.cookie_id}】图片上传API验证异常: {error_str}")
+ error_lower = error_str.lower()
+ auth_keywords = ['返回登录页面', 'session过期', '令牌过期', 'login', 'mini_login', 'passport.goofish.com']
+ if any(keyword.lower() in error_lower for keyword in auth_keywords):
+ result['image_api'] = False
+ result['valid'] = False
+ result['details'].append(f"图片上传API: 验证异常({error_str[:50]})")
+ else:
+ # 上传校验异常可能是网络或环境问题,不直接判定为Cookie失效
+ result['image_api'] = None
+ result['inconclusive'] = True
+ if result['valid']:
+ result['relogin_recommended'] = False
+ result['details'].append(f"图片上传API: 验证异常,结果不确定 - {error_str[:50]}")
+
+ if result['image_api'] is False:
+ result['valid'] = False
+ elif result['web_session_api'] is False and result['image_api'] is not True:
+ result['valid'] = False
+ elif result['web_session_api'] is False and result['image_api'] is True:
+ logger.warning(f"【{self.cookie_id}】❌ 网页登录态与图片上传校验结果不一致,按严格策略判定Cookie失效")
+ result['valid'] = False
+ result['details'].append("校验结果: 网页登录态与图片上传结果不一致")
+
+ # 汇总结果
+ if result['valid']:
+ if result['inconclusive']:
+ logger.warning(f"【{self.cookie_id}】⚠️ Cookie验证结果不确定: 未发现明确失效证据,但部分校验存在波动或结果矛盾")
+ else:
+ logger.info(f"【{self.cookie_id}】✅ Cookie验证通过: 所有关键API均可用")
+ else:
+ logger.warning(f"【{self.cookie_id}】❌ Cookie验证失败:")
+ for detail in result['details']:
+ logger.warning(f"【{self.cookie_id}】 - {detail}")
+
+ result['details'] = '; '.join(result['details'])
+ return result
+
+ async def _restart_instance(self):
+ """重启XianyuLive实例
+
+ ⚠️ 注意:此方法会触发当前任务被取消!
+ 调用此方法后,当前任务会立即被 CookieManager 取消,
+ 因此不要在此方法后执行任何重要操作。
+ """
+ try:
+ logger.info(f"【{self.cookie_id}】准备重启实例...")
+
+ # 导入CookieManager
+ from cookie_manager import manager as cookie_manager
+
+ if cookie_manager:
+ # 通过CookieManager重启实例
+ logger.info(f"【{self.cookie_id}】通过CookieManager重启实例...")
+
+ # ⚠️ 重要:不要等待重启完成!
+ # cookie_manager.update_cookie() 会立即取消当前任务
+ # 如果我们等待它完成,会导致 CancelledError 中断等待
+ # 正确的做法是:触发重启后立即返回,让任务自然退出
+
+ import threading
+
+ def trigger_restart():
+ """在后台线程中触发重启,不阻塞当前任务"""
+ try:
+ # 给当前任务足够时间完成清理和退出(避免竞态条件)
+ # 增加到2秒,确保任务有足够时间处理返回和清理
+ import time
+ time.sleep(2.0)
+
+ # save_to_db=False 因为 update_config_cookies 已经保存过了
+ cookie_manager.update_cookie(self.cookie_id, self.cookies_str, save_to_db=False)
+ logger.info(f"【{self.cookie_id}】实例重启请求已触发")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】触发实例重启失败: {e}")
+ import traceback
+ logger.error(f"【{self.cookie_id}】重启失败详情:\n{traceback.format_exc()}")
+
+ # 在后台线程中触发重启
+ restart_thread = threading.Thread(target=trigger_restart, daemon=True)
+ restart_thread.start()
+
+ logger.info(f"【{self.cookie_id}】实例重启已触发,当前任务即将退出...")
+ logger.warning(f"【{self.cookie_id}】注意:重启请求已发送,CookieManager将在2秒后取消当前任务并启动新实例")
+
+ else:
+ logger.warning(f"【{self.cookie_id}】CookieManager不可用,无法重启实例")
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】重启实例失败: {self._safe_str(e)}")
+ import traceback
+ logger.error(f"【{self.cookie_id}】重启失败堆栈:\n{traceback.format_exc()}")
+ # 发送重启失败通知
+ try:
+ await self.send_token_refresh_notification(f"实例重启失败: {str(e)}", "instance_restart_failed")
+ except Exception as notify_e:
+ logger.error(f"【{self.cookie_id}】发送重启失败通知时出错: {self._safe_str(notify_e)}")
+
+ async def save_item_info_to_db(self, item_id: str, item_detail: str = None, item_title: str = None):
+ """保存商品信息到数据库
+
+ Args:
+ item_id: 商品ID
+ item_detail: 商品详情内容(可以是任意格式的文本)
+ item_title: 商品标题
+ """
+ try:
+ # 跳过以 auto_ 开头的商品ID
+ if item_id and item_id.startswith('auto_'):
+ logger.warning(f"跳过保存自动生成的商品ID: {item_id}")
+ return
+
+ # 验证:如果只有商品ID,没有商品标题和商品详情,则不插入数据库
+ if not item_title and not item_detail:
+ logger.warning(f"跳过保存商品信息:缺少商品标题和详情 - {item_id}")
+ return
+
+ # 如果有商品标题但没有详情,也跳过(根据需求,需要同时有标题和详情)
+ if not item_title or not item_detail:
+ logger.warning(f"跳过保存商品信息:商品标题或详情不完整 - {item_id}")
+ return
+
+ from db_manager import db_manager
+
+ # 直接使用传入的详情内容
+ item_data = item_detail
+
+ # 保存到数据库
+ success = db_manager.save_item_info(self.cookie_id, item_id, item_data)
+ if success:
+ logger.info(f"商品信息已保存到数据库: {item_id}")
+ else:
+ logger.warning(f"保存商品信息到数据库失败: {item_id}")
+
+ except Exception as e:
+ logger.error(f"保存商品信息到数据库异常: {self._safe_str(e)}")
+
+ async def save_item_detail_only(self, item_id, item_detail):
+ """仅保存商品详情(不影响标题等基本信息)"""
+ try:
+ from db_manager import db_manager
+
+ # 使用专门的详情更新方法
+ success = db_manager.update_item_detail(self.cookie_id, item_id, item_detail)
+
+ if success:
+ logger.info(f"商品详情已更新: {item_id}")
+ else:
+ logger.warning(f"更新商品详情失败: {item_id}")
+
+ return success
+
+ except Exception as e:
+ logger.error(f"更新商品详情异常: {self._safe_str(e)}")
+ return False
+
+ async def fetch_item_detail_from_api(self, item_id: str, force_refresh: bool = False) -> str:
+ """获取商品详情(使用浏览器获取,支持24小时缓存)
+
+ Args:
+ item_id: 商品ID
+ force_refresh: 是否绕过缓存强制拉取最新详情
+
+ Returns:
+ str: 商品详情文本,获取失败返回空字符串
+ """
+ try:
+ # 检查是否启用自动获取功能
+ from config import config
+ auto_fetch_config = config.get('ITEM_DETAIL', {}).get('auto_fetch', {})
+
+ if not auto_fetch_config.get('enabled', True):
+ logger.warning(f"自动获取商品详情功能已禁用: {item_id}")
+ return ""
+
+ # 1. 首先检查缓存(24小时有效)
+ if not force_refresh:
+ async with self._item_detail_cache_lock:
+ if item_id in self._item_detail_cache:
+ cache_data = self._item_detail_cache[item_id]
+ cache_time = cache_data['timestamp']
+ current_time = time.time()
+
+ # 检查缓存是否在24小时内
+ if current_time - cache_time < self._item_detail_cache_ttl:
+ # 更新访问时间(用于LRU)
+ cache_data['access_time'] = current_time
+ logger.info(f"从缓存获取商品详情: {item_id}")
+ return cache_data['detail']
+ else:
+ # 缓存过期,删除
+ del self._item_detail_cache[item_id]
+ logger.warning(f"缓存已过期,删除: {item_id}")
+ else:
+ logger.info(f"强制刷新商品详情,跳过缓存: {item_id}")
+
+ # 2. 尝试使用浏览器获取商品详情
+ detail_from_browser = await self._fetch_item_detail_from_browser(item_id)
+ if detail_from_browser:
+ # 保存到缓存(带大小限制)
+ await self._add_to_item_cache(item_id, detail_from_browser)
+ logger.info(f"成功通过浏览器获取商品详情: {item_id}, 长度: {len(detail_from_browser)}")
+ return detail_from_browser
+
+ # 浏览器获取失败
+ logger.warning(f"浏览器获取商品详情失败: {item_id}")
+ return ""
+
+ except Exception as e:
+ logger.error(f"获取商品详情异常: {item_id}, 错误: {self._safe_str(e)}")
+ return ""
+
+ async def _add_to_item_cache(self, item_id: str, detail: str):
+ """添加商品详情到缓存,实现LRU策略和大小限制
+
+ Args:
+ item_id: 商品ID
+ detail: 商品详情
+ """
+ async with self._item_detail_cache_lock:
+ current_time = time.time()
+
+ # 检查缓存大小,如果超过限制则清理
+ if len(self._item_detail_cache) >= self._item_detail_cache_max_size:
+ # 使用LRU策略删除最久未访问的项
+ if self._item_detail_cache:
+ # 找到最久未访问的项
+ oldest_item = min(
+ self._item_detail_cache.items(),
+ key=lambda x: x[1].get('access_time', x[1]['timestamp'])
+ )
+ oldest_item_id = oldest_item[0]
+ del self._item_detail_cache[oldest_item_id]
+ logger.warning(f"缓存已满,删除最旧项: {oldest_item_id}")
+
+ # 添加新项到缓存
+ self._item_detail_cache[item_id] = {
+ 'detail': detail,
+ 'timestamp': current_time,
+ 'access_time': current_time
+ }
+ logger.warning(f"添加商品详情到缓存: {item_id}, 当前缓存大小: {len(self._item_detail_cache)}")
+
+ @classmethod
+ async def _cleanup_item_cache(cls):
+ """清理过期的商品详情缓存"""
+ try:
+ async with cls._item_detail_cache_lock:
+ # 在持有锁时也要能响应取消信号
+ await asyncio.sleep(0)
+
+ current_time = time.time()
+ expired_items = []
+
+ # 找出所有过期的项
+ for item_id, cache_data in cls._item_detail_cache.items():
+ # 在循环中也要能响应取消信号
+ await asyncio.sleep(0)
+ if current_time - cache_data['timestamp'] >= cls._item_detail_cache_ttl:
+ expired_items.append(item_id)
+
+ # 删除过期项
+ for item_id in expired_items:
+ await asyncio.sleep(0) # 让出控制权
+ del cls._item_detail_cache[item_id]
+
+ if expired_items:
+ logger.info(f"清理了 {len(expired_items)} 个过期的商品详情缓存")
+
+ return len(expired_items)
+ except asyncio.CancelledError:
+ # 如果被取消,确保锁能正确释放
+ raise
+
+ async def _fetch_item_detail_from_browser(self, item_id: str) -> str:
+ """使用浏览器获取商品详情"""
+ playwright = None
+ browser = None
+ try:
+ from playwright.async_api import async_playwright
+
+ logger.info(f"开始使用浏览器获取商品详情: {item_id}")
+
+ playwright = await async_playwright().start()
+
+ # 启动浏览器(参照order_detail_fetcher的配置)
+ browser_args = [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--disable-accelerated-2d-canvas',
+ '--no-first-run',
+ '--no-zygote',
+ '--disable-gpu',
+ '--disable-background-timer-throttling',
+ '--disable-backgrounding-occluded-windows',
+ '--disable-renderer-backgrounding',
+ '--disable-features=TranslateUI',
+ '--disable-ipc-flooding-protection',
+ '--disable-extensions',
+ '--disable-default-apps',
+ '--disable-sync',
+ '--disable-translate',
+ '--hide-scrollbars',
+ '--mute-audio',
+ '--no-default-browser-check',
+ '--no-pings'
+ ]
+
+ # 在Docker环境中添加额外参数
+ if os.getenv('DOCKER_ENV'):
+ browser_args.extend([
+ # '--single-process', # 注释掉,避免多用户并发时的进程冲突和资源泄漏
+ '--disable-background-networking',
+ '--disable-client-side-phishing-detection',
+ '--disable-hang-monitor',
+ '--disable-popup-blocking',
+ '--disable-prompt-on-repost',
+ '--disable-web-resources',
+ '--metrics-recording-only',
+ '--safebrowsing-disable-auto-update',
+ '--enable-automation',
+ '--password-store=basic',
+ '--use-mock-keychain'
+ ])
+
+ browser = await playwright.chromium.launch(
+ headless=True, # 移动模式使用无头模式
+ args=browser_args
+ )
+
+ # 创建移动设备浏览器上下文(模拟iPhone)
+ context = await browser.new_context(
+ viewport={'width': 375, 'height': 812}, # iPhone X/11/12 尺寸
+ user_agent='Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 AliApp(TB/11.15.0)',
+ device_scale_factor=3, # iPhone 的屏幕缩放比例
+ is_mobile=True,
+ has_touch=True
+ )
+
+ # 设置Cookie
+ cookies = []
+ for cookie_pair in self.cookies_str.split('; '):
+ if '=' in cookie_pair:
+ name, value = cookie_pair.split('=', 1)
+ cookies.append({
+ 'name': name.strip(),
+ 'value': value.strip(),
+ 'domain': '.goofish.com',
+ 'path': '/'
+ })
+
+ await context.add_cookies(cookies)
+ logger.info(f"已设置 {len(cookies)} 个Cookie(移动模式)")
+
+ # 创建页面
+ page = await context.new_page()
+
+ # 构造移动版商品详情页面URL
+ item_url = f"https://h5.m.goofish.com/item?id={item_id}"
+ logger.info(f"访问移动版商品页面: {item_url}")
+
+ # 访问页面
+ await page.goto(item_url, wait_until='networkidle', timeout=30000)
+
+ # 等待页面完全加载
+ await asyncio.sleep(2)
+
+ # 获取商品详情内容
+ detail_text = ""
+ try:
+ # 移动版页面选择器列表(按优先级排序)
+ selectors = [
+ '.detailDesc--descText--1FMDTCm', # 移动版商品详情主选择器
+ 'span.rax-text-v2.detailDesc--descText--1FMDTCm', # 完整选择器
+ '[class*="detailDesc--descText"]', # 匹配包含detailDesc--descText的类名
+ '[class*="descText"]', # 匹配包含descText的类名
+ '.desc--GaIUKUQY', # PC版选择器(备用)
+ '.detail-desc', # 常见的详情选择器
+ '.item-desc', # 商品描述
+ '[class*="desc"]', # 包含desc的类名
+ ]
+
+ for selector in selectors:
+ try:
+ # 尝试等待元素出现(短超时)
+ await page.wait_for_selector(selector, timeout=3000)
+ detail_element = await page.query_selector(selector)
+ if detail_element:
+ detail_text = await detail_element.inner_text()
+ if detail_text and len(detail_text.strip()) > 0:
+ logger.info(f"成功获取商品详情(选择器: {selector}): {item_id}, 长度: {len(detail_text)}")
+ return detail_text.strip()
+ except Exception as e:
+ logger.debug(f"选择器 {selector} 未找到: {self._safe_str(e)}")
+ continue
+
+ # 如果所有选择器都失败,尝试获取整个页面的文本内容
+ logger.warning(f"未找到特定详情元素,尝试获取整个页面内容: {item_id}")
+ body_text = await page.inner_text('body')
+ if body_text:
+ logger.info(f"获取到页面整体内容: {item_id}, 长度: {len(body_text)}")
+ return body_text.strip()
+ else:
+ logger.warning(f"未找到商品详情元素: {item_id}")
+
+ except Exception as e:
+ logger.warning(f"获取商品详情元素失败: {item_id}, 错误: {self._safe_str(e)}")
+
+ return ""
+
+ except Exception as e:
+ logger.error(f"浏览器获取商品详情异常: {item_id}, 错误: {self._safe_str(e)}")
+ return ""
+ finally:
+ # 确保资源被正确清理
+ try:
+ if browser:
+ await browser.close()
+ logger.warning(f"Browser已关闭: {item_id}")
+ except Exception as e:
+ logger.warning(f"关闭browser时出错: {self._safe_str(e)}")
+
+ try:
+ if playwright:
+ await playwright.stop()
+ logger.warning(f"Playwright已停止: {item_id}")
+ except Exception as e:
+ logger.warning(f"停止playwright时出错: {self._safe_str(e)}")
+
+
+ async def save_items_list_to_db(self, items_list, sync_item_details=False):
+ """批量保存商品列表信息到数据库(并发安全)
+
+ Args:
+ items_list: 从get_item_list_info获取的商品列表
+ sync_item_details: 是否同步已存在商品的最新详情
+ """
+ try:
+ from db_manager import db_manager
+
+ # 准备批量数据,区分新商品和需要更新的商品
+ batch_new_data = [] # 新商品,保存所有信息
+ batch_update_data = [] # 已有商品,只更新标题和价格
+ items_need_detail = [] # 需要获取或同步详情的商品列表
+
+ for item in items_list:
+ item_id = item.get('id')
+ if not item_id or item_id.startswith('auto_'):
+ continue
+
+ # 构造商品详情数据
+ item_detail = {
+ 'title': item.get('title', ''),
+ 'price': item.get('price', ''),
+ 'price_text': item.get('price_text', ''),
+ 'category_id': item.get('category_id', ''),
+ 'auction_type': item.get('auction_type', ''),
+ 'item_status': item.get('item_status', 0),
+ 'detail_url': item.get('detail_url', ''),
+ 'pic_info': item.get('pic_info', {}),
+ 'detail_params': item.get('detail_params', {}),
+ 'track_params': item.get('track_params', {}),
+ 'item_label_data': item.get('item_label_data', {}),
+ 'card_type': item.get('card_type', 0)
+ }
+
+ # 检查数据库中是否已有该商品
+ existing_item = db_manager.get_item_info(self.cookie_id, item_id)
+
+ if existing_item:
+ # 商品已存在,先更新标题和价格;商品详情按同步模式单独处理
+ batch_update_data.append({
+ 'cookie_id': self.cookie_id,
+ 'item_id': item_id,
+ 'item_title': item.get('title', ''),
+ 'item_price': item.get('price_text', ''),
+ 'item_category': str(item.get('category_id', ''))
+ })
+ if sync_item_details:
+ items_need_detail.append({
+ 'item_id': item_id,
+ 'item_title': item.get('title', '')
+ })
+ logger.debug(f"商品 {item_id} 已存在,将更新标题和价格")
+ else:
+ # 新商品,保存所有信息
+ batch_new_data.append({
+ 'cookie_id': self.cookie_id,
+ 'item_id': item_id,
+ 'item_title': item.get('title', ''),
+ 'item_description': '', # 暂时为空
+ 'item_category': str(item.get('category_id', '')),
+ 'item_price': item.get('price_text', ''),
+ 'item_detail': json.dumps(item_detail, ensure_ascii=False)
+ })
+
+ # 新商品需要获取详情
+ items_need_detail.append({
+ 'item_id': item_id,
+ 'item_title': item.get('title', '')
+ })
+ logger.debug(f"商品 {item_id} 是新商品,将保存完整信息")
+
+ saved_count = 0
+
+ # 保存新商品
+ if batch_new_data:
+ new_count = db_manager.batch_save_item_basic_info(batch_new_data)
+ logger.info(f"新增商品信息: {new_count}/{len(batch_new_data)} 个")
+ saved_count += new_count
+
+ # 更新已有商品的标题和价格
+ if batch_update_data:
+ update_count = db_manager.batch_update_item_title_price(batch_update_data)
+ logger.info(f"更新商品标题和价格: {update_count}/{len(batch_update_data)} 个")
+ saved_count += update_count
+
+ # 异步获取商品详情
+ if items_need_detail:
+ from config import config
+ auto_fetch_config = config.get('ITEM_DETAIL', {}).get('auto_fetch', {})
+
+ if auto_fetch_config.get('enabled', True):
+ action_text = '同步最新详情' if sync_item_details else '获取缺失详情'
+ logger.info(f"准备为 {len(items_need_detail)} 个商品{action_text}...")
+ detail_success_count = await self._fetch_item_details(
+ items_need_detail,
+ force_refresh=sync_item_details,
+ )
+ logger.info(f"成功为 {detail_success_count}/{len(items_need_detail)} 个商品{action_text}")
+ else:
+ logger.info(f"有 {len(items_need_detail)} 个商品需要获取详情,但自动获取功能已禁用")
+
+ return saved_count
+
+ except Exception as e:
+ logger.error(f"批量保存商品信息异常: {self._safe_str(e)}")
+ return 0
+
+ async def _fetch_item_details(self, items_need_detail, force_refresh=False):
+ """批量获取或同步商品详情
+
+ Args:
+ items_need_detail: 需要获取详情的商品列表
+ force_refresh: 是否绕过缓存强制拉取最新详情
+
+ Returns:
+ int: 成功获取详情的商品数量
+ """
+ success_count = 0
+
+ try:
+ from db_manager import db_manager
+ from config import config
+
+ # 从配置获取并发数量和延迟时间
+ auto_fetch_config = config.get('ITEM_DETAIL', {}).get('auto_fetch', {})
+ max_concurrent = auto_fetch_config.get('max_concurrent', 3)
+ retry_delay = auto_fetch_config.get('retry_delay', 0.5)
+
+ # 限制并发数量,避免对API服务器造成压力
+ semaphore = asyncio.Semaphore(max_concurrent)
+
+ async def fetch_single_item_detail(item_info):
+ async with semaphore:
+ try:
+ item_id = item_info['item_id']
+ item_title = item_info['item_title']
+
+ # 获取商品详情
+ item_detail_text = await self.fetch_item_detail_from_api(
+ item_id,
+ force_refresh=force_refresh,
+ )
+
+ if item_detail_text:
+ # 保存详情到数据库
+ success = await self.save_item_detail_only(item_id, item_detail_text)
+ if success:
+ logger.info(f"✅ 成功获取并保存商品详情: {item_id} - {item_title}")
+ return 1
+ else:
+ logger.warning(f"❌ 获取详情成功但保存失败: {item_id}")
+ else:
+ logger.warning(f"❌ 未能获取商品详情: {item_id} - {item_title}")
+
+ # 添加延迟,避免请求过于频繁
+ await asyncio.sleep(retry_delay)
+ return 0
+
+ except Exception as e:
+ logger.error(f"获取单个商品详情异常: {item_info.get('item_id', 'unknown')}, 错误: {self._safe_str(e)}")
+ return 0
+
+ # 并发获取所有商品详情
+ tasks = [fetch_single_item_detail(item_info) for item_info in items_need_detail]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+
+ # 统计成功数量
+ for result in results:
+ if isinstance(result, int):
+ success_count += result
+ elif isinstance(result, Exception):
+ logger.error(f"获取商品详情任务异常: {result}")
+
+ return success_count
+
+ except Exception as e:
+ logger.error(f"批量获取商品详情异常: {self._safe_str(e)}")
+ return success_count
+
+ async def get_item_info(self, item_id, retry_count=0):
+ """获取商品信息,自动处理token失效的情况"""
+ if retry_count >= 4: # 最多重试3次
+ logger.error("获取商品信息失败,重试次数过多")
+ return {"error": "获取商品信息失败,重试次数过多"}
+
+ # 确保session已创建
+ if not self.session:
+ await self.create_session()
+
+ params = {
+ 'jsv': '2.7.2',
+ 'appKey': '34839810',
+ 't': str(int(time.time()) * 1000),
+ 'sign': '',
+ 'v': '1.0',
+ 'type': 'originaljson',
+ 'accountSite': 'xianyu',
+ 'dataType': 'json',
+ 'timeout': '20000',
+ 'api': 'mtop.taobao.idle.pc.detail',
+ 'sessionOption': 'AutoLoginOnly',
+ 'spm_cnt': 'a21ybx.im.0.0',
+ }
+
+ data_val = '{"itemId":"' + item_id + '"}'
+ data = {
+ 'data': data_val,
+ }
+
+ # 始终从最新的cookies中获取_m_h5_tk token(刷新后cookies会被更新)
+ token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
+
+ if token:
+ logger.warning(f"使用cookies中的_m_h5_tk token: {self._mask_secret_value(token, head=6, tail=4)}")
+ else:
+ logger.warning("cookies中没有找到_m_h5_tk token")
+
+ from utils.xianyu_utils import generate_sign
+ sign = generate_sign(params['t'], token, data_val)
+ params['sign'] = sign
+
+ try:
+ async with self.session.post(
+ 'https://h5api.m.goofish.com/h5/mtop.taobao.idle.pc.detail/1.0/',
+ params=params,
+ data=data
+ ) as response:
+ res_json = await response.json()
+
+ if await self._apply_response_cookie_updates(response.headers, "item_detail"):
+ logger.warning("已更新Cookie到数据库")
+
+ logger.warning(f"商品信息获取成功: {res_json}")
+ # 检查返回状态
+ if isinstance(res_json, dict):
+ ret_value = res_json.get('ret', [])
+ # 检查ret是否包含成功信息
+ if not any('SUCCESS::调用成功' in ret for ret in ret_value):
+ logger.warning(f"商品信息API调用失败,错误信息: {ret_value}")
+
+ await asyncio.sleep(0.5)
+ return await self.get_item_info(item_id, retry_count + 1)
+ else:
+ logger.warning(f"商品信息获取成功: {item_id}")
+ return res_json
+ else:
+ logger.error(f"商品信息API返回格式异常: {res_json}")
+ return await self.get_item_info(item_id, retry_count + 1)
+
+ except Exception as e:
+ logger.error(f"商品信息API请求异常: {self._safe_str(e)}")
+ await asyncio.sleep(0.5)
+ return await self.get_item_info(item_id, retry_count + 1)
+
+ def extract_item_id_from_message(self, message):
+ """从消息中提取商品ID的辅助方法"""
+ try:
+ # 注意: message["1"] 是会话ID(chat_id/cid),格式如 "56226853668@goofish"
+ # 不能从中提取商品ID,否则会把chat_id误当作item_id
+
+ # 方法1: 从message["3"]中提取
+ message_3 = message.get('3', {})
+ if isinstance(message_3, dict):
+
+ # 从extension中提取
+ if 'extension' in message_3:
+ extension = message_3['extension']
+ if isinstance(extension, dict):
+ item_id = extension.get('itemId') or extension.get('item_id')
+ if item_id:
+ logger.info(f"从extension中提取商品ID: {item_id}")
+ return item_id
+
+ # 从bizData中提取
+ if 'bizData' in message_3:
+ biz_data = message_3['bizData']
+ if isinstance(biz_data, dict):
+ item_id = biz_data.get('itemId') or biz_data.get('item_id')
+ if item_id:
+ logger.info(f"从bizData中提取商品ID: {item_id}")
+ return item_id
+
+ # 从其他可能的字段中提取
+ for key, value in message_3.items():
+ if isinstance(value, dict):
+ item_id = value.get('itemId') or value.get('item_id')
+ if item_id:
+ logger.info(f"从{key}字段中提取商品ID: {item_id}")
+ return item_id
+
+ # 从消息内容中提取数字ID
+ content = message_3.get('content', '')
+ if isinstance(content, str) and content:
+ id_match = re.search(r'(\d{10,})', content)
+ if id_match:
+ logger.info(f"【{self.cookie_id}】从消息内容中提取商品ID: {id_match.group(1)}")
+ return id_match.group(1)
+
+ # 方法2: 遍历整个消息结构查找可能的商品ID
+ # 跳过的字段: "1" 是会话ID(chat_id/cid),不包含商品ID
+ # 跳过可能包含非商品ID的字段
+ skip_keys = {'1', 'tradeId', 'trade_id', 'bizId', 'biz_id', 'orderId', 'order_id',
+ 'userId', 'user_id', 'senderId', 'sender_id', 'receiverId', 'receiver_id',
+ 'chatId', 'chat_id', 'conversationId', 'conversation_id', 'msgId', 'msg_id'}
+
+ def find_item_id_recursive(obj, path=""):
+ if isinstance(obj, dict):
+ # 只查找明确命名为 itemId 的字段(不查找通用的 'id' 字段,避免误提取 tradeId 等)
+ for key in ['itemId', 'item_id']:
+ if key in obj and isinstance(obj[key], (str, int)):
+ value = str(obj[key])
+ if len(value) >= 10 and value.isdigit():
+ logger.info(f"从{path}.{key}中提取商品ID: {value}")
+ return value
+
+ # 递归查找(跳过chat_id和其他非商品ID字段)
+ for key, value in obj.items():
+ if key in skip_keys:
+ continue
+ result = find_item_id_recursive(value, f"{path}.{key}" if path else key)
+ if result:
+ return result
+
+ elif isinstance(obj, str):
+ # 跳过chat_id格式的字符串(如 "56226853668@goofish")
+ if '@goofish' in obj or '@xianyu' in obj:
+ return None
+ # 只从URL中提取itemId参数,不从普通字符串中提取数字(避免误提取)
+ if 'itemId=' in obj:
+ id_match = re.search(r'itemId=(\d{10,})', obj)
+ if id_match:
+ logger.info(f"从{path}的URL参数中提取商品ID: {id_match.group(1)}")
+ return id_match.group(1)
+
+ return None
+
+ result = find_item_id_recursive(message)
+ if result:
+ return result
+
+ logger.warning("所有方法都未能提取到商品ID")
+ return None
+
+ except Exception as e:
+ logger.error(f"提取商品ID失败: {self._safe_str(e)}")
+ return None
+
+ def debug_message_structure(self, message, context=""):
+ """调试消息结构的辅助方法"""
+ try:
+ logger.warning(f"[{context}] 消息结构调试:")
+ logger.warning(f" 消息类型: {type(message)}")
+
+ if isinstance(message, dict):
+ for key, value in message.items():
+ logger.warning(f" 键 '{key}': {type(value)} - {str(value)[:100]}...")
+
+ # 特别关注可能包含商品ID的字段
+ if key in ["1", "3"] and isinstance(value, dict):
+ logger.warning(f" 详细结构 '{key}':")
+ for sub_key, sub_value in value.items():
+ logger.warning(f" '{sub_key}': {type(sub_value)} - {str(sub_value)[:50]}...")
+ else:
+ logger.warning(f" 消息内容: {str(message)[:200]}...")
+
+ except Exception as e:
+ logger.error(f"调试消息结构时发生错误: {self._safe_str(e)}")
+
+ async def get_item_specific_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str = None) -> str:
+ """获取指定商品回复内容"""
+ if not item_id:
+ return None
+
+ try:
+ from db_manager import db_manager
+
+ item_reply = db_manager.get_item_reply(self.cookie_id, item_id)
+ if not item_reply or not item_reply.get('reply_content'):
+ return None
+
+ 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
+
+ except Exception as e:
+ logger.error(f"获取指定商品回复失败: {self._safe_str(e)}")
+ return None
+
+ 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:
+ from db_manager import db_manager
+
+ # 获取当前账号的默认回复设置
+ default_reply_settings = db_manager.get_default_reply(self.cookie_id)
+
+ if not default_reply_settings or not default_reply_settings.get('enabled', False):
+ logger.warning(f"账号 {self.cookie_id} 未启用默认回复")
+ return None
+
+ # 检查"只回复一次"功能
+ if default_reply_settings.get('reply_once', False) and chat_id:
+ # 检查是否已经回复过这个chat_id
+ if db_manager.has_default_reply_record(self.cookie_id, chat_id):
+ logger.info(f"【{self.cookie_id}】chat_id {chat_id} 已使用过默认回复,跳过(只回复一次)")
+ return "SKIP_REPLY"
+
+ reply_content = default_reply_settings.get('reply_content', '')
+ if not reply_content or (reply_content and reply_content.strip() == ''):
+ logger.info(f"账号 {self.cookie_id} 默认回复内容为空,不进行回复")
+ return "EMPTY_REPLY" # 返回特殊标记表示不回复
+
+ # 进行变量替换
+ try:
+ formatted_reply = reply_content.format(
+ send_user_name=send_user_name,
+ send_user_id=send_user_id,
+ send_message=send_message
+ )
+
+ # 如果开启了"只回复一次"功能,记录这次回复
+ if default_reply_settings.get('reply_once', False) and chat_id:
+ db_manager.add_default_reply_record(self.cookie_id, chat_id)
+ logger.info(f"【{self.cookie_id}】记录默认回复: chat_id={chat_id}")
+
+ logger.info(f"【{self.cookie_id}】使用默认回复: {formatted_reply}")
+ return formatted_reply
+ except Exception as format_error:
+ logger.error(f"默认回复变量替换失败: {self._safe_str(format_error)}")
+ # 如果变量替换失败,返回原始内容
+ return reply_content
+
+ except Exception as e:
+ logger.error(f"获取默认回复失败: {self._safe_str(e)}")
+ return None
+
+ async def get_keyword_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str = None) -> str:
+ """获取关键词匹配回复(支持商品ID优先匹配和图片类型)"""
+ try:
+ from db_manager import db_manager
+
+ # 获取当前账号的关键词列表(包含类型信息)
+ keywords = db_manager.get_keywords_with_type(self.cookie_id)
+
+ if not keywords:
+ logger.warning(f"账号 {self.cookie_id} 没有配置关键词")
+ return None
+
+ # 1. 如果有商品ID,优先匹配该商品ID对应的关键词
+ if item_id:
+ for keyword_data in keywords:
+ keyword = keyword_data['keyword']
+ reply = keyword_data['reply']
+ keyword_item_id = keyword_data['item_id']
+ keyword_type = keyword_data.get('type', 'text')
+ image_url = keyword_data.get('image_url')
+
+ if keyword_item_id == item_id and keyword.lower() in send_message.lower():
+ logger.info(f"商品ID关键词匹配成功: 商品{item_id} '{keyword}' (类型: {keyword_type})")
+
+ # 根据关键词类型处理
+ if keyword_type == 'image' and image_url:
+ # 图片类型关键词,发送图片
+ return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message)
+ else:
+ # 文本类型关键词,检查回复内容是否为空
+ if not reply or (reply and reply.strip() == ''):
+ logger.info(f"商品ID关键词 '{keyword}' 回复内容为空,不进行回复")
+ return "EMPTY_REPLY" # 返回特殊标记表示匹配到但不回复
+
+ # 进行变量替换
+ try:
+ formatted_reply = reply.format(
+ send_user_name=send_user_name,
+ send_user_id=send_user_id,
+ send_message=send_message
+ )
+ logger.info(f"商品ID文本关键词回复: {formatted_reply}")
+ return formatted_reply
+ except Exception as format_error:
+ logger.error(f"关键词回复变量替换失败: {self._safe_str(format_error)}")
+ # 如果变量替换失败,返回原始内容
+ return reply
+
+ # 2. 如果商品ID匹配失败或没有商品ID,匹配没有商品ID的通用关键词
+ for keyword_data in keywords:
+ keyword = keyword_data['keyword']
+ reply = keyword_data['reply']
+ keyword_item_id = keyword_data['item_id']
+ keyword_type = keyword_data.get('type', 'text')
+ image_url = keyword_data.get('image_url')
+
+ if not keyword_item_id and keyword.lower() in send_message.lower():
+ logger.info(f"通用关键词匹配成功: '{keyword}' (类型: {keyword_type})")
+
+ # 根据关键词类型处理
+ if keyword_type == 'image' and image_url:
+ # 图片类型关键词,发送图片
+ return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message)
+ else:
+ # 文本类型关键词,检查回复内容是否为空
+ if not reply or (reply and reply.strip() == ''):
+ logger.info(f"通用关键词 '{keyword}' 回复内容为空,不进行回复")
+ return "EMPTY_REPLY" # 返回特殊标记表示匹配到但不回复
+
+ # 进行变量替换
+ try:
+ formatted_reply = reply.format(
+ send_user_name=send_user_name,
+ send_user_id=send_user_id,
+ send_message=send_message
+ )
+ logger.info(f"通用文本关键词回复: {formatted_reply}")
+ return formatted_reply
+ except Exception as format_error:
+ logger.error(f"关键词回复变量替换失败: {self._safe_str(format_error)}")
+ # 如果变量替换失败,返回原始内容
+ return reply
+
+ logger.warning(f"未找到匹配的关键词: {send_message}")
+ return None
+
+ except Exception as e:
+ logger.error(f"获取关键词回复失败: {self._safe_str(e)}")
+ return None
+
+ async def _handle_image_keyword(self, keyword: str, image_url: str, send_user_name: str, send_user_id: str, send_message: str) -> str:
+ """处理图片类型关键词"""
+ try:
+ # 检查图片URL类型
+ if self._is_cdn_url(image_url):
+ # 已经是CDN链接,直接使用
+ logger.info(f"使用已有的CDN图片链接: {image_url}")
+ return f"__IMAGE_SEND__{image_url}"
+
+ elif image_url.startswith('/static/uploads/') or image_url.startswith('static/uploads/'):
+ # 本地图片,需要上传到闲鱼CDN
+ local_image_path = image_url.replace('/static/uploads/', 'static/uploads/')
+ if os.path.exists(local_image_path):
+ logger.info(f"准备上传本地图片到闲鱼CDN: {local_image_path}")
+
+ # 使用图片上传器上传到闲鱼CDN
+ from utils.image_uploader import ImageUploader
+ uploader = ImageUploader(self.cookies_str)
+
+ async with uploader:
+ cdn_url = await uploader.upload_image(local_image_path)
+ if cdn_url:
+ logger.info(f"图片上传成功,CDN URL: {cdn_url}")
+ # 更新数据库中的图片URL为CDN URL
+ await self._update_keyword_image_url(keyword, cdn_url)
+ image_url = cdn_url
+ else:
+ logger.error(f"图片上传失败: {local_image_path}")
+ logger.error(f"❌ Cookie可能已失效!请检查配置并更新Cookie")
+ return f"抱歉,图片发送失败(Cookie可能已失效,请检查日志)"
+ else:
+ logger.error(f"本地图片文件不存在: {local_image_path}")
+ return f"抱歉,图片文件不存在。"
+
+ else:
+ # 其他类型的URL(可能是外部链接),直接使用
+ logger.info(f"使用外部图片链接: {image_url}")
+
+ # 发送图片(这里返回特殊标记,在调用处处理实际发送)
+ return f"__IMAGE_SEND__{image_url}"
+
+ except Exception as e:
+ logger.error(f"处理图片关键词失败: {e}")
+ return f"抱歉,图片发送失败: {str(e)}"
+
+ def _is_cdn_url(self, url: str) -> bool:
+ """检查URL是否是闲鱼CDN链接"""
+ if not url:
+ return False
+
+ # 闲鱼CDN域名列表
+ cdn_domains = [
+ 'gw.alicdn.com',
+ 'img.alicdn.com',
+ 'cloud.goofish.com',
+ 'goofish.com',
+ 'taobaocdn.com',
+ 'tbcdn.cn',
+ 'aliimg.com'
+ ]
+
+ # 检查是否包含CDN域名
+ url_lower = url.lower()
+ for domain in cdn_domains:
+ if domain in url_lower:
+ return True
+
+ # 检查是否是HTTPS链接且包含图片特征
+ if url_lower.startswith('https://') and any(ext in url_lower for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
+ return True
+
+ return False
+
+ async def _update_keyword_image_url(self, keyword: str, new_image_url: str):
+ """更新关键词的图片URL"""
+ try:
+ from db_manager import db_manager
+ success = db_manager.update_keyword_image_url(self.cookie_id, keyword, new_image_url)
+ if success:
+ logger.info(f"图片URL已更新: {keyword} -> {new_image_url}")
+ else:
+ logger.warning(f"图片URL更新失败: {keyword}")
+ except Exception as e:
+ logger.error(f"更新关键词图片URL失败: {e}")
+
+ async def _update_card_image_url(self, card_id: int, new_image_url: str):
+ """更新卡券的图片URL"""
+ try:
+ from db_manager import db_manager
+ success = db_manager.update_card_image_url(card_id, new_image_url)
+ if success:
+ logger.info(f"卡券图片URL已更新: 卡券ID={card_id} -> {new_image_url}")
+ else:
+ logger.warning(f"卡券图片URL更新失败: 卡券ID={card_id}")
+ except Exception as e:
+ logger.error(f"更新卡券图片URL失败: {e}")
+
+ async def get_ai_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str, chat_id: str):
+ """获取AI回复"""
+ try:
+ from ai_reply_engine import ai_reply_engine
+
+ # 检查是否启用AI回复
+ if not ai_reply_engine.is_ai_enabled(self.cookie_id):
+ logger.warning(f"账号 {self.cookie_id} 未启用AI回复")
+ return None
+
+ # 从数据库获取商品信息
+ from db_manager import db_manager
+ item_info_raw = db_manager.get_item_info(self.cookie_id, item_id)
+
+ if not item_info_raw:
+ logger.warning(f"数据库中无商品信息: {item_id}")
+ # 使用默认商品信息
+ item_info = {
+ 'title': '商品信息获取失败',
+ 'price': 0,
+ 'desc': '暂无商品描述'
+ }
+ else:
+ # 解析数据库中的商品信息
+ item_info = {
+ 'title': item_info_raw.get('item_title', '未知商品'),
+ 'price': self._parse_price(item_info_raw.get('item_price', '0')),
+ 'desc': item_info_raw.get('item_detail', '暂无商品描述')
+ }
+
+ # 生成AI回复
+ # 由于外部已实现防抖机制,跳过内部等待(skip_wait=True)
+ reply = ai_reply_engine.generate_reply(
+ message=send_message,
+ item_info=item_info,
+ chat_id=chat_id,
+ cookie_id=self.cookie_id,
+ user_id=send_user_id,
+ item_id=item_id,
+ skip_wait=True # 跳过内部等待,因为外部已实现防抖
+ )
+
+ if reply:
+ logger.info(f"【{self.cookie_id}】AI回复生成成功: {reply}")
+ return reply
+ else:
+ logger.warning(f"AI回复生成失败")
+ return None
+
+ except Exception as e:
+ logger.error(f"获取AI回复失败: {self._safe_str(e)}")
+ return None
+
+ def _parse_price(self, price_str: str) -> float:
+ """解析价格字符串为数字"""
+ try:
+ if not price_str:
+ return 0.0
+ # 移除非数字字符,保留小数点
+ price_clean = re.sub(r'[^\d.]', '', str(price_str))
+ return float(price_clean) if price_clean else 0.0
+ except Exception:
+ return 0.0
+
+ def _get_notification_template(self, template_type: str) -> str:
+ """获取通知模板,如果没有自定义模板则返回默认模板"""
+ return get_notification_template_text(template_type)
+
+ def _format_template(self, template: str, **kwargs) -> str:
+ """格式化模板,将变量替换为实际值"""
+ return format_notification_template(template, **kwargs)
+
+ async def send_notification(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str = None, chat_id: str = None):
+ """发送消息通知"""
+ try:
+ import hashlib
+
+ # 过滤系统默认消息,不发送通知
+ system_messages = [
+ '发来一条消息',
+ '发来一条新消息'
+ ]
+
+ if send_message in system_messages:
+ logger.warning(f"📱 系统消息不发送通知: {send_message}")
+ return
+
+ # 生成通知的唯一标识(基于消息内容、chat_id、send_user_id)
+ # 用于防重复发送
+ notification_key = f"{chat_id or 'unknown'}_{send_user_id}_{send_message}"
+ notification_hash = hashlib.md5(notification_key.encode('utf-8')).hexdigest()
+ reservation_key = f"msg:{notification_hash}"
+
+ # 使用异步锁保护防重复检查,确保并发安全
+ async with self.notification_lock:
+ # 检查是否在冷却时间内已发送过相同的通知
+ current_time = time.time()
+ if notification_hash in self.last_notification_time:
+ time_since_last = current_time - self.last_notification_time[notification_hash]
+ if time_since_last < self.notification_cooldown:
+ remaining_seconds = int(self.notification_cooldown - time_since_last)
+ logger.warning(f"📱 通知在冷却期内(剩余 {remaining_seconds} 秒),跳过重复发送 - 账号: {self.cookie_id}, 买家: {send_user_name}, 消息: {send_message[:30]}...")
+ return
+ if reservation_key in self.pending_notification_keys:
+ logger.warning(f"📱 相同消息通知正在发送中,跳过重复发送 - 账号: {self.cookie_id}, 买家: {send_user_name}")
+ return
+ self.pending_notification_keys.add(reservation_key)
+
+ try:
+ logger.info(f"📱 开始发送消息通知 - 账号: {self.cookie_id}, 买家: {send_user_name}")
+
+ notification_msg = render_notification_template(
+ 'message',
+ account_id=self.cookie_id,
+ buyer_name=send_user_name,
+ buyer_id=send_user_id,
+ item_id=item_id or '未知',
+ chat_id=chat_id or '未知',
+ message=send_message,
+ time=time.strftime('%Y-%m-%d %H:%M:%S')
+ )
+
+ notification_sent = await dispatch_account_notifications(
+ self.cookie_id,
+ notification_msg,
+ title='接收消息通知',
+ notification_type='message',
+ )
+
+ if not notification_sent:
+ logger.warning(f"📱 消息通知未发送成功,不进入冷却 - 账号: {self.cookie_id}, 买家: {send_user_name}")
+ return
+
+ async with self.notification_lock:
+ sent_time = time.time()
+ self.last_notification_time[notification_hash] = sent_time
+ expired_keys = [
+ key for key, timestamp in self.last_notification_time.items()
+ if sent_time - timestamp > 3600
+ ]
+ for key in expired_keys:
+ del self.last_notification_time[key]
+ finally:
+ async with self.notification_lock:
+ self.pending_notification_keys.discard(reservation_key)
+
+ except Exception as e:
+ logger.error(f"📱 处理消息通知失败: {self._safe_str(e)}")
+ import traceback
+ logger.error(f"📱 详细错误信息: {traceback.format_exc()}")
+
+ def _parse_notification_config(self, config: str) -> dict:
+ """解析通知配置数据"""
+ try:
+ import json
+ # 尝试解析JSON格式的配置
+ return json.loads(config)
+ except (json.JSONDecodeError, TypeError):
+ # 兼容旧格式(直接字符串)
+ return {"config": config}
+
+ async def _send_qq_notification(self, config_data: dict, message: str):
+ """发送QQ通知"""
+ try:
+ import aiohttp
+
+ logger.info(f"📱 QQ通知 - 开始处理配置数据: {config_data}")
+
+ # 解析配置(QQ号码)
+ qq_number = config_data.get('qq_number') or config_data.get('config', '')
+ qq_number = qq_number.strip() if qq_number else ''
+
+ logger.info(f"📱 QQ通知 - 解析到QQ号码: {qq_number}")
+
+ if not qq_number:
+ logger.warning("📱 QQ通知 - QQ号码配置为空,无法发送通知")
+ return False
+
+ # 构建请求URL
+ api_url = "http://36.111.68.231:3000/sendPrivateMsg"
+ params = {
+ 'qq': qq_number,
+ 'msg': message
+ }
+
+ logger.info(f"📱 QQ通知 - 请求URL: {api_url}")
+ logger.info(f"📱 QQ通知 - 请求参数: qq={qq_number}, msg长度={len(message)}")
+
+ # 发送GET请求
+ async with aiohttp.ClientSession() as session:
+ async with session.get(api_url, params=params, timeout=10) as response:
+ response_text = await response.text()
+ logger.info(f"📱 QQ通知 - 响应状态: {response.status}")
+
+ # 需求:502 视为成功,且不打印返回内容
+ if response.status == 502:
+ logger.info(f"📱 QQ通知发送成功: {qq_number} (状态码: {response.status})")
+ return True
+ elif response.status == 200:
+ logger.info(f"📱 QQ通知发送成功: {qq_number} (状态码: {response.status})")
+ logger.warning(f"📱 QQ通知 - 响应内容: {response_text}")
+ return True
+ else:
+ logger.warning(f"📱 QQ通知发送失败: HTTP {response.status}")
+ logger.warning(f"📱 QQ通知 - 响应内容: {response_text}")
+ return False
+
+ except Exception as e:
+ logger.error(f"📱 发送QQ通知异常: {self._safe_str(e)}")
+ import traceback
+ logger.error(f"📱 QQ通知异常详情: {traceback.format_exc()}")
+ return False
+
+ async def _send_dingtalk_notification(self, config_data: dict, message: str):
+ """发送钉钉通知"""
+ try:
+ import aiohttp
+ import json
+ import hmac
+ import hashlib
+ import base64
+ import time
+
+ # 解析配置
+ webhook_url = config_data.get('webhook_url') or config_data.get('config', '')
+ secret = config_data.get('secret', '')
+
+ webhook_url = webhook_url.strip() if webhook_url else ''
+ if not webhook_url:
+ logger.warning("钉钉通知配置为空")
+ return False
+
+ # 如果有加签密钥,生成签名
+ if secret:
+ timestamp = str(round(time.time() * 1000))
+ secret_enc = secret.encode('utf-8')
+ string_to_sign = f'{timestamp}\n{secret}'
+ string_to_sign_enc = string_to_sign.encode('utf-8')
+ hmac_code = hmac.new(secret_enc, string_to_sign_enc, digestmod=hashlib.sha256).digest()
+ sign = base64.b64encode(hmac_code).decode('utf-8')
+ webhook_url += f'×tamp={timestamp}&sign={sign}'
+
+ data = {
+ "msgtype": "markdown",
+ "markdown": {
+ "title": "闲鱼管理系统通知",
+ "text": message
+ }
+ }
+
+ async with aiohttp.ClientSession() as session:
+ async with session.post(webhook_url, json=data, timeout=10) as response:
+ if response.status == 200:
+ logger.info(f"钉钉通知发送成功")
+ return True
+ else:
+ logger.warning(f"钉钉通知发送失败: {response.status}")
+ return False
+
+ except Exception as e:
+ logger.error(f"发送钉钉通知异常: {self._safe_str(e)}")
+ return False
+
+ async def _send_feishu_notification(self, config_data: dict, message: str):
+ """发送飞书通知"""
+ try:
+ import aiohttp
+ import json
+ import hmac
+ import hashlib
+ import base64
+
+ logger.info(f"📱 飞书通知 - 开始处理配置数据: {config_data}")
+
+ # 解析配置
+ webhook_url = config_data.get('webhook_url', '')
+ secret = config_data.get('secret', '')
+
+ logger.info(f"📱 飞书通知 - Webhook URL: {webhook_url[:50]}...")
+ logger.info(f"📱 飞书通知 - 是否有签名密钥: {'是' if secret else '否'}")
+
+ if not webhook_url:
+ logger.warning("📱 飞书通知 - Webhook URL配置为空,无法发送通知")
+ return False
+
+ # 如果有加签密钥,生成签名
+ timestamp = str(int(time.time()))
+ sign = ""
+
+ if secret:
+ string_to_sign = f'{timestamp}\n{secret}'
+ hmac_code = hmac.new(
+ string_to_sign.encode('utf-8'),
+ ''.encode('utf-8'),
+ digestmod=hashlib.sha256
+ ).digest()
+ sign = base64.b64encode(hmac_code).decode('utf-8')
+ logger.info(f"📱 飞书通知 - 已生成签名")
+
+ # 构建请求数据
+ data = {
+ "msg_type": "text",
+ "content": {
+ "text": message
+ },
+ "timestamp": timestamp
+ }
+
+ # 如果有签名,添加到请求数据中
+ if sign:
+ data["sign"] = sign
+
+ logger.info(f"📱 飞书通知 - 请求数据构建完成")
+
+ # 发送POST请求
+ async with aiohttp.ClientSession() as session:
+ async with session.post(webhook_url, json=data, timeout=10) as response:
+ response_text = await response.text()
+ logger.info(f"📱 飞书通知 - 响应状态: {response.status}")
+ logger.info(f"📱 飞书通知 - 响应内容: {response_text}")
+
+ if response.status == 200:
+ try:
+ response_json = json.loads(response_text)
+ if response_json.get('code') == 0:
+ logger.info(f"📱 飞书通知发送成功")
+ return True
+ else:
+ logger.warning(f"📱 飞书通知发送失败: {response_json.get('msg', '未知错误')}")
+ return False
+ except json.JSONDecodeError:
+ logger.info(f"📱 飞书通知发送成功(响应格式异常)")
+ return True
+ else:
+ logger.warning(f"📱 飞书通知发送失败: HTTP {response.status}, 响应: {response_text}")
+ return False
+
+ except Exception as e:
+ logger.error(f"📱 发送飞书通知异常: {self._safe_str(e)}")
+ import traceback
+ logger.error(f"📱 飞书通知异常详情: {traceback.format_exc()}")
+ return False
+
+ async def _send_bark_notification(self, config_data: dict, message: str):
+ """发送Bark通知"""
+ try:
+ import aiohttp
+ import json
+ from urllib.parse import quote
+
+ logger.info(f"📱 Bark通知 - 开始处理配置数据: {config_data}")
+
+ # 解析配置
+ server_url = config_data.get('server_url', 'https://api.day.app').rstrip('/')
+ device_key = config_data.get('device_key', '')
+ title = config_data.get('title', '闲鱼管理系统通知')
+ sound = config_data.get('sound', 'default')
+ icon = config_data.get('icon', '')
+ group = config_data.get('group', 'xianyu')
+ url = config_data.get('url', '')
+
+ logger.info(f"📱 Bark通知 - 服务器: {server_url}")
+ logger.info(f"📱 Bark通知 - 设备密钥: {device_key[:10]}..." if device_key else "📱 Bark通知 - 设备密钥: 未设置")
+ logger.info(f"📱 Bark通知 - 标题: {title}")
+
+ if not device_key:
+ logger.warning("📱 Bark通知 - 设备密钥配置为空,无法发送通知")
+ return False
+
+ # 构建请求URL和数据
+ # Bark支持两种方式:URL路径方式和POST JSON方式
+ # 这里使用POST JSON方式,更灵活且支持更多参数
+
+ api_url = f"{server_url}/push"
+
+ # 构建请求数据
+ data = {
+ "device_key": device_key,
+ "title": title,
+ "body": message,
+ "sound": sound,
+ "group": group
+ }
+
+ # 可选参数
+ if icon:
+ data["icon"] = icon
+ if url:
+ data["url"] = url
+
+ logger.info(f"📱 Bark通知 - API地址: {api_url}")
+ logger.info(f"📱 Bark通知 - 请求数据构建完成")
+
+ # 发送POST请求
+ async with aiohttp.ClientSession() as session:
+ async with session.post(api_url, json=data, timeout=10) as response:
+ response_text = await response.text()
+ logger.info(f"📱 Bark通知 - 响应状态: {response.status}")
+ logger.info(f"📱 Bark通知 - 响应内容: {response_text}")
+
+ if response.status == 200:
+ try:
+ response_json = json.loads(response_text)
+ if response_json.get('code') == 200:
+ logger.info(f"📱 Bark通知发送成功")
+ return True
+ else:
+ logger.warning(f"📱 Bark通知发送失败: {response_json.get('message', '未知错误')}")
+ return False
+ except json.JSONDecodeError:
+ # 某些Bark服务器可能返回纯文本
+ if 'success' in response_text.lower() or 'ok' in response_text.lower():
+ logger.info(f"📱 Bark通知发送成功")
+ return True
+ else:
+ logger.warning(f"📱 Bark通知响应格式异常: {response_text}")
+ return False
+ else:
+ logger.warning(f"📱 Bark通知发送失败: HTTP {response.status}, 响应: {response_text}")
+ return False
+
+ except Exception as e:
+ logger.error(f"📱 发送Bark通知异常: {self._safe_str(e)}")
+ import traceback
+ logger.error(f"📱 Bark通知异常详情: {traceback.format_exc()}")
+ return False
+
+ async def _send_email_notification(self, config_data: dict, message: str, attachment_path: str = None):
+ """发送邮件通知(支持附件)
+
+ Args:
+ config_data: 邮件配置
+ message: 邮件正文
+ attachment_path: 附件文件路径(可选)
+ """
+ try:
+ import smtplib
+ from email.mime.text import MIMEText
+ from email.mime.multipart import MIMEMultipart
+ from email.mime.image import MIMEImage
+ import os
+
+ # 解析配置
+ smtp_server = config_data.get('smtp_server', '')
+ smtp_port = int(config_data.get('smtp_port', 587))
+ email_user = config_data.get('email_user', '')
+ email_password = config_data.get('email_password', '')
+ recipient_email = config_data.get('recipient_email', '')
+ smtp_use_tls = config_data.get('smtp_use_tls', smtp_port == 587) # 修复:添加变量定义
+
+ if not all([smtp_server, email_user, email_password, recipient_email]):
+ logger.warning("邮件通知配置不完整")
+ return False
+
+ # 创建邮件
+ msg = MIMEMultipart()
+ msg['From'] = email_user
+ msg['To'] = recipient_email
+ msg['Subject'] = "闲鱼管理系统通知"
+
+ # 添加邮件正文
+ msg.attach(MIMEText(message, 'plain', 'utf-8'))
+
+ # 添加附件(如果有)
+ if attachment_path and os.path.exists(attachment_path):
+ try:
+ with open(attachment_path, 'rb') as f:
+ img_data = f.read()
+
+ # 根据文件扩展名判断MIME类型
+ filename = os.path.basename(attachment_path)
+ if attachment_path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif')):
+ img = MIMEImage(img_data)
+ img.add_header('Content-Disposition', 'attachment', filename=filename)
+ msg.attach(img)
+ logger.info(f"已添加图片附件: {filename}")
+ else:
+ from email.mime.application import MIMEApplication
+ attach = MIMEApplication(img_data)
+ attach.add_header('Content-Disposition', 'attachment', filename=filename)
+ msg.attach(attach)
+ logger.info(f"已添加附件: {filename}")
+ except Exception as attach_error:
+ logger.error(f"添加邮件附件失败: {self._safe_str(attach_error)}")
+
+ # 发送邮件
+ server = None
+ try:
+ if smtp_port == 465:
+ # 使用SSL连接(端口465)
+ server = smtplib.SMTP_SSL(smtp_server, smtp_port, timeout=30)
+ else:
+ # 使用普通连接,然后升级到TLS(端口587)
+ server = smtplib.SMTP(smtp_server, smtp_port, timeout=30)
+ if smtp_use_tls:
+ server.starttls()
+
+ # 尝试登录
+ try:
+ server.login(email_user, email_password)
+ except smtplib.SMTPAuthenticationError as auth_error:
+ error_code = auth_error.smtp_code if hasattr(auth_error, 'smtp_code') else None
+ error_msg = str(auth_error)
+
+ # 提供详细的错误提示
+ logger.error(f"邮件SMTP认证失败 (错误码: {error_code})")
+ logger.error(f"邮箱地址: {email_user}")
+ logger.error(f"SMTP服务器: {smtp_server}:{smtp_port}")
+ logger.error(f"错误详情: {error_msg}")
+
+ # 根据常见错误提供解决建议
+ suggestions = []
+ if 'qq.com' in email_user.lower() or 'qq' in smtp_server.lower():
+ suggestions.append("QQ邮箱需要使用授权码而不是登录密码")
+ suggestions.append("请到QQ邮箱设置 -> 账户 -> 开启SMTP服务 -> 生成授权码")
+ elif 'gmail.com' in email_user.lower() or 'gmail' in smtp_server.lower():
+ suggestions.append("Gmail需要使用应用专用密码")
+ suggestions.append("请到Google账户 -> 安全性 -> 两步验证 -> 应用专用密码")
+ suggestions.append("或启用'允许不够安全的应用访问'(不推荐)")
+ elif '163.com' in email_user.lower() or '126.com' in email_user.lower() or 'yeah.net' in email_user.lower():
+ suggestions.append("网易邮箱需要使用授权码")
+ suggestions.append("请到邮箱设置 -> POP3/SMTP/IMAP -> 开启SMTP服务 -> 生成授权码")
+ else:
+ suggestions.append("请检查邮箱密码/授权码是否正确")
+ suggestions.append("某些邮箱服务商需要使用授权码而不是登录密码")
+ suggestions.append("请查看邮箱服务商的SMTP设置说明")
+
+ if suggestions:
+ logger.error("解决建议:")
+ for i, suggestion in enumerate(suggestions, 1):
+ logger.error(f" {i}. {suggestion}")
+
+ raise # 重新抛出异常
+
+ server.send_message(msg)
+ logger.info(f"邮件通知发送成功: {recipient_email}")
+ return True
+
+ finally:
+ # 确保关闭连接
+ if server:
+ try:
+ server.quit()
+ except Exception:
+ try:
+ server.close()
+ except Exception:
+ pass
+
+ except smtplib.SMTPAuthenticationError:
+ # 认证错误已在上面处理,这里不再重复记录
+ return False
+ except smtplib.SMTPException as smtp_error:
+ logger.error(f"SMTP协议错误: {self._safe_str(smtp_error)}")
+ logger.error(f"SMTP服务器: {smtp_server}:{smtp_port}")
+ logger.error(f"请检查SMTP服务器地址和端口配置是否正确")
+ return False
+ except Exception as e:
+ logger.error(f"发送邮件通知异常: {self._safe_str(e)}")
+ import traceback
+ logger.error(f"邮件发送详细错误: {traceback.format_exc()}")
+ return False
+
+ async def _send_webhook_notification(self, config_data: dict, message: str):
+ """发送Webhook通知"""
+ try:
+ import aiohttp
+ import json
+
+ # 解析配置
+ webhook_url = config_data.get('webhook_url', '')
+ http_method = config_data.get('http_method', 'POST').upper()
+ headers_str = config_data.get('headers', '{}')
+
+ if not webhook_url:
+ logger.warning("Webhook通知配置为空")
+ return False
+
+ # 解析自定义请求头
+ try:
+ custom_headers = json.loads(headers_str) if headers_str else {}
+ except json.JSONDecodeError:
+ custom_headers = {}
+
+ # 设置默认请求头
+ headers = {'Content-Type': 'application/json'}
+ headers.update(custom_headers)
+
+ # 构建请求数据
+ data = {
+ 'message': message,
+ 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
+ 'source': 'xianyu-auto-reply'
+ }
+
+ async with aiohttp.ClientSession() as session:
+ if http_method == 'POST':
+ async with session.post(webhook_url, json=data, headers=headers, timeout=10) as response:
+ if response.status == 200:
+ logger.info(f"Webhook通知发送成功")
+ return True
+ else:
+ logger.warning(f"Webhook通知发送失败: {response.status}")
+ return False
+ elif http_method == 'PUT':
+ async with session.put(webhook_url, json=data, headers=headers, timeout=10) as response:
+ if response.status == 200:
+ logger.info(f"Webhook通知发送成功")
+ return True
+ else:
+ logger.warning(f"Webhook通知发送失败: {response.status}")
+ return False
+ else:
+ logger.warning(f"不支持的HTTP方法: {http_method}")
+ return False
+
+ except Exception as e:
+ logger.error(f"发送Webhook通知异常: {self._safe_str(e)}")
+ return False
+
+ async def _send_wechat_notification(self, config_data: dict, message: str):
+ """发送微信通知"""
+ try:
+ import aiohttp
+ import json
+
+ # 解析配置
+ webhook_url = config_data.get('webhook_url', '')
+
+ if not webhook_url:
+ logger.warning("微信通知配置为空")
+ return False
+
+ data = {
+ "msgtype": "text",
+ "text": {
+ "content": message
+ }
+ }
+
+ async with aiohttp.ClientSession() as session:
+ async with session.post(webhook_url, json=data, timeout=10) as response:
+ if response.status == 200:
+ logger.info(f"微信通知发送成功")
+ return True
+ else:
+ logger.warning(f"微信通知发送失败: {response.status}")
+ return False
+
+ except Exception as e:
+ logger.error(f"发送微信通知异常: {self._safe_str(e)}")
+ return False
+
+ async def _send_telegram_notification(self, config_data: dict, message: str):
+ """发送Telegram通知"""
+ try:
+ import aiohttp
+
+ # 解析配置
+ bot_token = config_data.get('bot_token', '')
+ chat_id = config_data.get('chat_id', '')
+
+ if not all([bot_token, chat_id]):
+ logger.warning("Telegram通知配置不完整")
+ return False
+
+ # 构建API URL
+ api_url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+
+ data = {
+ 'chat_id': chat_id,
+ 'text': message,
+ 'parse_mode': 'HTML'
+ }
+
+ async with aiohttp.ClientSession() as session:
+ async with session.post(api_url, json=data, timeout=10) as response:
+ if response.status == 200:
+ logger.info(f"Telegram通知发送成功")
+ return True
+ else:
+ logger.warning(f"Telegram通知发送失败: {response.status}")
+ return False
+
+ except Exception as e:
+ logger.error(f"发送Telegram通知异常: {self._safe_str(e)}")
+ return False
+
+ async def send_token_refresh_notification(self, error_message: str, notification_type: str = "token_refresh", chat_id: str = None, attachment_path: str = None, verification_url: str = None):
+ """发送Token刷新异常通知(带防重复机制,支持附件)
+
+ Args:
+ error_message: 错误消息
+ notification_type: 通知类型
+ chat_id: 聊天ID(可选)
+ attachment_path: 附件路径(可选,用于发送截图)
+ """
+ try:
+ # 检查是否是正常的令牌过期,这种情况不需要发送通知
+ if notification_type != "token_scheduled_refresh_failed" and self._is_normal_token_expiry(error_message):
+ logger.warning(f"检测到正常的令牌过期,跳过通知: {error_message}")
+ return
+
+ notification_key = f"token:{notification_type}"
+
+ # 为Token刷新异常通知使用特殊的3小时冷却时间
+ # 基于错误消息内容判断是否为Token相关异常
+ if notification_type == "message_stream_stale":
+ cooldown_time = self.message_stream_notification_cooldown
+ cooldown_desc = f"{max(1, int(cooldown_time // 60))}分钟"
+ elif self._is_token_related_error(error_message):
+ cooldown_time = self.token_refresh_notification_cooldown
+ cooldown_desc = "3小时"
+ else:
+ cooldown_time = self.notification_cooldown
+ cooldown_desc = f"{self.notification_cooldown // 60}分钟"
+
+ async with self.notification_lock:
+ current_time = time.time()
+ last_time = self.last_notification_time.get(notification_key, 0)
+ if notification_key in self.pending_notification_keys:
+ logger.warning(f"Token刷新通知正在发送中,跳过重复发送: {notification_type}")
+ return
+ if current_time - last_time < cooldown_time:
+ remaining_time = cooldown_time - (current_time - last_time)
+ remaining_hours = int(remaining_time // 3600)
+ remaining_minutes = int((remaining_time % 3600) // 60)
+ remaining_seconds = int(remaining_time % 60)
+
+ if remaining_hours > 0:
+ time_desc = f"{remaining_hours}小时{remaining_minutes}分钟"
+ elif remaining_minutes > 0:
+ time_desc = f"{remaining_minutes}分钟{remaining_seconds}秒"
+ else:
+ time_desc = f"{remaining_seconds}秒"
+
+ logger.warning(f"Token刷新通知在冷却期内,跳过发送: {notification_type} (还需等待 {time_desc})")
+ return
+ self.pending_notification_keys.add(notification_key)
+
+ # 构造通知消息(使用模板)
+ if notification_type in ("slider_success", "slider_recovered_success"):
+ slider_status_text = (
+ "账号会话已恢复"
+ if notification_type == "slider_recovered_success"
+ else "cookies已自动更新到数据库"
+ )
+ notification_msg = render_notification_template(
+ 'slider_success',
+ account_id=self.cookie_id,
+ time=time.strftime('%Y-%m-%d %H:%M:%S'),
+ status_text=slider_status_text
+ )
+ elif "密码登录成功" in error_message or notification_type == "password_login_success":
+ notification_msg = render_notification_template(
+ 'password_login_success',
+ account_id=self.cookie_id,
+ time=time.strftime('%Y-%m-%d %H:%M:%S'),
+ cookie_count='已获取'
+ )
+ elif "刷新Cookie成功" in error_message or notification_type == "cookie_refresh_success":
+ notification_msg = render_notification_template(
+ 'cookie_refresh_success',
+ account_id=self.cookie_id,
+ time=time.strftime('%Y-%m-%d %H:%M:%S'),
+ cookie_count='已获取'
+ )
+ elif "人脸验证" in error_message or "短信验证" in error_message or "二维码验证" in error_message or "身份验证" in error_message or (verification_url and "passport" in verification_url):
+ notification_msg = render_notification_template(
+ 'face_verify',
+ account_id=self.cookie_id,
+ time=time.strftime('%Y-%m-%d %H:%M:%S'),
+ verification_url=verification_url or '无',
+ verification_type=guess_verification_type(error_message, verification_url)
+ )
+ elif verification_url:
+ notification_msg = render_notification_template(
+ 'token_refresh',
+ account_id=self.cookie_id,
+ time=time.strftime('%Y-%m-%d %H:%M:%S'),
+ error_message=error_message,
+ verification_url=verification_url
+ )
+ else:
+ notification_msg = render_notification_template(
+ 'token_refresh',
+ account_id=self.cookie_id,
+ time=time.strftime('%Y-%m-%d %H:%M:%S'),
+ error_message=error_message,
+ verification_url='无'
+ )
+
+ logger.info(f"准备发送Token刷新异常通知: {self.cookie_id}")
+
+ notification_sent = await dispatch_account_notifications(
+ self.cookie_id,
+ notification_msg,
+ title='闲鱼管理系统通知',
+ notification_type=notification_type,
+ attachment_path=attachment_path,
+ )
+
+ # 如果成功发送了通知,更新最后发送时间
+ if notification_sent:
+ current_time = time.time()
+ async with self.notification_lock:
+ self.last_notification_time[notification_key] = current_time
+
+ # 根据错误消息内容使用不同的冷却时间
+ if notification_type == "message_stream_stale":
+ next_send_time = current_time + self.message_stream_notification_cooldown
+ cooldown_desc = f"{max(1, int(self.message_stream_notification_cooldown // 60))}分钟"
+ elif self._is_token_related_error(error_message):
+ next_send_time = current_time + self.token_refresh_notification_cooldown
+ cooldown_desc = "3小时"
+ else:
+ next_send_time = current_time + self.notification_cooldown
+ cooldown_desc = f"{self.notification_cooldown // 60}分钟"
+
+ next_send_time_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_send_time))
+ logger.info(f"Token刷新通知已发送,下次可发送时间: {next_send_time_str} (冷却时间: {cooldown_desc})")
+ else:
+ logger.warning(f"【{self.cookie_id}】Token刷新通知未发送成功,不进入冷却: {notification_type}")
+
+ except Exception as e:
+ logger.error(f"处理Token刷新通知失败: {self._safe_str(e)}")
+ finally:
+ async with self.notification_lock:
+ self.pending_notification_keys.discard(f"token:{notification_type}")
+
+ def _is_normal_token_expiry(self, error_message: str) -> bool:
+ """检查是否是正常的令牌过期或其他不需要通知的情况"""
+ # 不需要发送通知的关键词
+ no_notification_keywords = [
+ # 正常的令牌过期
+ 'FAIL_SYS_TOKEN_EXOIRED::令牌过期',
+ 'FAIL_SYS_TOKEN_EXPIRED::令牌过期',
+ 'FAIL_SYS_TOKEN_EXOIRED',
+ 'FAIL_SYS_TOKEN_EXPIRED',
+ '令牌过期',
+ # Session过期(正常情况)
+ 'FAIL_SYS_SESSION_EXPIRED::Session过期',
+ 'FAIL_SYS_SESSION_EXPIRED',
+ 'Session过期',
+ # Token定时刷新失败(会自动重试)
+ 'Token定时刷新失败,将自动重试',
+ 'Token定时刷新失败'
+ ]
+
+ # 检查错误消息是否包含不需要通知的关键词
+ for keyword in no_notification_keywords:
+ if keyword in error_message:
+ return True
+
+ return False
+
+ def _is_token_related_error(self, error_message: str) -> bool:
+ """检查是否是Token相关的错误,需要使用3小时冷却时间"""
+ # Token相关错误的关键词
+ token_error_keywords = [
+ # Token刷新失败相关
+ 'Token刷新失败',
+ 'Token刷新异常',
+ 'token刷新失败',
+ 'token刷新异常',
+ 'TOKEN刷新失败',
+ 'TOKEN刷新异常',
+ # 具体的Token错误信息
+ 'FAIL_SYS_USER_VALIDATE',
+ 'RGV587_ERROR',
+ '哎哟喂,被挤爆啦',
+ '请稍后重试',
+ 'punish?x5secdata',
+ 'captcha',
+ # Token获取失败
+ '无法获取有效token',
+ '无法获取有效Token',
+ 'Token获取失败',
+ 'token获取失败',
+ 'TOKEN获取失败',
+ # Token定时刷新失败
+ 'Token定时刷新失败',
+ 'token定时刷新失败',
+ 'TOKEN定时刷新失败',
+ # 初始化Token失败
+ '初始化时无法获取有效Token',
+ '初始化时无法获取有效token',
+ # 其他Token相关错误
+ 'accessToken',
+ 'access_token',
+ '_m_h5_tk',
+ 'mtop.taobao.idlemessage.pc.login.token'
+ ]
+
+ # 检查错误消息是否包含Token相关的关键词
+ error_message_lower = error_message.lower()
+ for keyword in token_error_keywords:
+ if keyword.lower() in error_message_lower:
+ return True
+
+ return False
+
+ def _build_scheduled_token_refresh_error_message(self, last_refresh_status: str) -> str:
+ """为定时Token刷新失败选择更准确的通知文案。"""
+ if last_refresh_status in {"session_expired_after_slider", "session_expired_preflight"}:
+ return "Session已过期,系统自动恢复失败,请重新登录"
+
+ if last_refresh_status == "token_expired_recovery_failed":
+ detail = (self.last_token_refresh_error_message or "").lower()
+ if "session过期" in detail or "页面会话已失效" in detail:
+ return "Session已过期,系统自动恢复失败,请重新登录"
+
+ return "Token定时刷新失败,将自动重试"
+
+ async def send_delivery_failure_notification(self, send_user_name: str, send_user_id: str, item_id: str, error_message: str, chat_id: str = None):
+ """发送自动发货失败通知"""
+ try:
+ notification_message = render_notification_template(
+ 'delivery',
+ account_id=self.cookie_id,
+ buyer_name=send_user_name,
+ buyer_id=send_user_id,
+ item_id=item_id,
+ chat_id=chat_id or '未知',
+ result=error_message,
+ time=time.strftime('%Y-%m-%d %H:%M:%S')
+ )
+
+ notification_sent = await dispatch_account_notifications(
+ self.cookie_id,
+ notification_message,
+ title='自动发货通知',
+ notification_type='delivery',
+ )
+ if not notification_sent:
+ logger.warning(f"【{self.cookie_id}】自动发货通知未发送成功")
+
+ except Exception as e:
+ logger.error(f"发送自动发货通知异常: {self._safe_str(e)}")
+
+ async def auto_confirm(self, order_id, item_id=None, retry_count=0):
+ """自动确认发货 - 使用加密模块,不包含延时处理(延时已在_auto_delivery中处理)"""
+ try:
+ logger.warning(f"【{self.cookie_id}】开始确认发货,订单ID: {order_id}")
+
+ # 导入解密后的确认发货模块
+ from secure_confirm_decrypted import SecureConfirm
+
+ # 创建确认实例,传入主界面类实例
+ secure_confirm = SecureConfirm(self.session, self.cookies_str, self.cookie_id, self)
+
+ # 传递必要的属性
+ secure_confirm.current_token = self.current_token
+ secure_confirm.last_token_refresh_time = self.last_token_refresh_time
+ secure_confirm.token_refresh_interval = self.token_refresh_interval
+
+ # 调用确认方法,传入item_id用于token刷新
+ result = await secure_confirm.auto_confirm(order_id, item_id, retry_count)
+
+ # 同步更新后的cookies和token
+ if secure_confirm.cookies_str != self.cookies_str:
+ self._set_runtime_cookie_state(
+ cookies_str=secure_confirm.cookies_str,
+ cookies_dict=secure_confirm.cookies,
+ source="secure_confirm_sync",
+ )
+ logger.warning(f"【{self.cookie_id}】已同步确认发货模块更新的cookies")
+
+ if secure_confirm.current_token != self.current_token:
+ self.current_token = secure_confirm.current_token
+ self.last_token_refresh_time = secure_confirm.last_token_refresh_time
+ logger.warning(f"【{self.cookie_id}】已同步确认发货模块更新的token")
+
+ return result
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】加密确认模块调用失败: {self._safe_str(e)}")
+ return {"error": f"加密确认模块调用失败: {self._safe_str(e)}", "order_id": order_id}
+
+ async def auto_freeshipping(self, order_id, item_id, buyer_id, retry_count=0):
+ """自动免拼发货 - 使用解密模块"""
+ try:
+ logger.warning(f"【{self.cookie_id}】开始免拼发货,订单ID: {order_id}")
+
+ # 导入解密后的免拼发货模块
+ from secure_freeshipping_decrypted import SecureFreeshipping
+
+ # 创建免拼发货实例
+ secure_freeshipping = SecureFreeshipping(self.session, self.cookies_str, self.cookie_id)
+
+ # 传递必要的属性
+ secure_freeshipping.current_token = self.current_token
+ secure_freeshipping.last_token_refresh_time = self.last_token_refresh_time
+ secure_freeshipping.token_refresh_interval = self.token_refresh_interval
+
+ # 调用免拼发货方法
+ result = await secure_freeshipping.auto_freeshipping(order_id, item_id, buyer_id, retry_count)
+
+ if secure_freeshipping.cookies_str != self.cookies_str:
+ self._set_runtime_cookie_state(
+ cookies_str=secure_freeshipping.cookies_str,
+ cookies_dict=secure_freeshipping.cookies,
+ source="secure_freeshipping_sync",
+ )
+ logger.warning(f"【{self.cookie_id}】已同步免拼发货模块更新的cookies")
+
+ if secure_freeshipping.current_token != self.current_token:
+ self.current_token = secure_freeshipping.current_token
+ self.last_token_refresh_time = secure_freeshipping.last_token_refresh_time
+ logger.warning(f"【{self.cookie_id}】已同步免拼发货模块更新的token")
+
+ return result
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】免拼发货模块调用失败: {self._safe_str(e)}")
+ return {"error": f"免拼发货模块调用失败: {self._safe_str(e)}", "order_id": order_id}
+
+ async def fetch_order_detail_info(self, order_id: str, item_id: str = None, buyer_id: str = None, debug_headless: bool = None, sid: str = None, force_refresh: bool = False, buyer_nick: str = None, buyer_id_source: str = None):
+ """获取订单详情信息(使用独立的锁机制,不受延迟锁影响)
+
+ Args:
+ order_id: 订单ID
+ item_id: 商品ID
+ buyer_id: 买家ID
+ debug_headless: 是否使用有头模式调试
+ sid: 会话ID(如 56226853668@goofish),用于简化消息匹配订单
+ force_refresh: 是否强制刷新(跳过缓存直接从闲鱼获取)
+ buyer_nick: 买家昵称(从下单消息中提取)
+ """
+ # 使用独立的订单详情锁,不与自动发货锁冲突
+ order_detail_lock = self._order_detail_locks[order_id]
+
+ # 如果锁绑定了不同的事件循环(如从 Web API 调用),创建新锁
+ try:
+ current_loop = asyncio.get_running_loop()
+ lock_loop = getattr(order_detail_lock, '_loop', None)
+ if lock_loop is not None and lock_loop is not current_loop:
+ order_detail_lock = asyncio.Lock()
+ self._order_detail_locks[order_id] = order_detail_lock
+ logger.info(f"【{self.cookie_id}】订单详情锁 {order_id} 事件循环不匹配,已重建")
+ except RuntimeError:
+ pass
+
+ # 记录订单详情锁的使用时间
+ self._order_detail_lock_times[order_id] = time.time()
+
+ async with order_detail_lock:
+ logger.info(f"🔍 【{self.cookie_id}】获取订单详情锁 {order_id},开始处理...")
+
+ try:
+ logger.info(f"【{self.cookie_id}】开始获取订单详情: {order_id}, sid={sid}")
+
+ # 导入订单详情获取器
+ from utils.order_detail_fetcher import fetch_order_detail_simple
+ from db_manager import db_manager
+
+ # 获取当前账号的cookie字符串
+ cookie_string = self.cookies_str
+ logger.warning(f"【{self.cookie_id}】使用Cookie长度: {len(cookie_string) if cookie_string else 0}")
+
+ # 确定是否使用有头模式(调试用)
+ 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,
+ force_refresh=force_refresh,
+ cookie_id_for_log=self.cookie_id
+ )
+
+ if result:
+ retry_task = self.order_detail_retry_tasks.get(order_id)
+ current_task = asyncio.current_task()
+ if retry_task and retry_task is not current_task and not retry_task.done():
+ retry_task.cancel()
+ self.order_detail_retry_tasks.pop(order_id, None)
+ logger.info(f"【{self.cookie_id}】订单详情已成功获取,取消待执行的补抓任务: {order_id}")
+
+ logger.info(f"【{self.cookie_id}】订单详情获取成功: {order_id}")
+ logger.info(f"【{self.cookie_id}】页面标题: {result.get('title', '未知')}")
+
+ def _normalize_optional_text(value):
+ if value is None:
+ return None
+ text = str(value).strip()
+ return text if text else None
+
+ def _normalize_amount_text(value):
+ text = _normalize_optional_text(value)
+ if not text:
+ return None
+ # 避免将无数字的异常文本写入金额字段
+ if not re.search(r'\d', text):
+ return None
+ return text
+
+ def _parse_amount_float(value):
+ text = _normalize_amount_text(value)
+ if not text:
+ return None
+ try:
+ return float(text)
+ except (TypeError, ValueError):
+ return None
+
+ # 获取解析后的规格信息
+ spec_parse_mode = str(result.get('spec_parse_mode') or '').strip() or 'no_spec'
+ spec_name = _normalize_optional_text(result.get('spec_name'))
+ spec_value = _normalize_optional_text(result.get('spec_value'))
+ spec_name_2 = _normalize_optional_text(result.get('spec_name_2'))
+ spec_value_2 = _normalize_optional_text(result.get('spec_value_2'))
+ quantity = _normalize_optional_text(result.get('quantity'))
+ amount = _normalize_amount_text(result.get('amount'))
+ amount_source = _normalize_optional_text(result.get('amount_source')) or 'unknown'
+ platform_created_at = _normalize_optional_text(result.get('platform_created_at'))
+ platform_paid_at = _normalize_optional_text(result.get('platform_paid_at'))
+ platform_completed_at = _normalize_optional_text(result.get('platform_completed_at'))
+ item_config = db_manager.get_item_info(self.cookie_id, item_id) if item_id else None
+ item_config_multi_spec = bool(item_config and item_config.get('is_multi_spec'))
+ item_config_detail = _normalize_optional_text(item_config.get('item_detail')) if item_config else None
+ is_coin_deduction_item = bool(item_config_detail and '闲鱼币抵扣' in item_config_detail)
+ configured_item_amount = _normalize_amount_text(item_config.get('item_price')) if item_config else None
+ configured_item_amount_value = _parse_amount_float(configured_item_amount)
+
+ if item_config is not None and not item_config_multi_spec and any(
+ [spec_name, spec_value, spec_name_2, spec_value_2]
+ ):
+ logger.warning(
+ f"【{self.cookie_id}】商品配置为无规格,刷新订单详情时忽略解析到的规格信息: "
+ f"order_id={order_id}, item_id={item_id}, "
+ f"spec={spec_name or ''}:{spec_value or ''}, spec2={spec_name_2 or ''}:{spec_value_2 or ''}"
+ )
+ spec_name = None
+ spec_value = None
+ spec_name_2 = None
+ spec_value_2 = None
+
+ if spec_parse_mode == 'one_spec' and spec_name and spec_value and not (spec_name_2 or spec_value_2):
+ spec_name_2 = ''
+ spec_value_2 = ''
+ logger.info(
+ f"【{self.cookie_id}】订单详情明确解析为单规格,允许清空历史残留的第二规格字段: "
+ f"order_id={order_id}, item_id={item_id}, spec={spec_name}:{spec_value}"
+ )
+
+ # 获取订单状态(从闲鱼页面解析)
+ raw_order_status = _normalize_optional_text(result.get('order_status'))
+ order_status_source = _normalize_optional_text(result.get('order_status_source')) or 'unknown'
+ # unknown 视为解析失败,不覆盖已有状态
+ order_status = raw_order_status if raw_order_status and raw_order_status.lower() != 'unknown' else None
+ if order_status:
+ logger.info(f"【{self.cookie_id}】📊 订单状态: {order_status} (source={order_status_source})")
+ elif raw_order_status and raw_order_status.lower() == 'unknown':
+ logger.warning(f"【{self.cookie_id}】订单状态解析为unknown,跳过状态字段写库")
+
+ if spec_name and spec_value:
+ logger.info(f"【{self.cookie_id}】📋 规格名称: {spec_name}")
+ logger.info(f"【{self.cookie_id}】📝 规格值: {spec_value}")
+ if spec_name_2 and spec_value_2:
+ logger.info(f"【{self.cookie_id}】📋 规格2名称: {spec_name_2}")
+ logger.info(f"【{self.cookie_id}】📝 规格2值: {spec_value_2}")
+ print(f"🛍️ 【{self.cookie_id}】订单 {order_id} 规格信息: {spec_name} -> {spec_value}, {spec_name_2} -> {spec_value_2}")
+ else:
+ print(f"🛍️ 【{self.cookie_id}】订单 {order_id} 规格信息: {spec_name} -> {spec_value}")
+ else:
+ logger.warning(f"【{self.cookie_id}】未获取到有效的规格信息")
+ print(f"⚠️ 【{self.cookie_id}】订单 {order_id} 规格信息获取失败")
+
+ if amount:
+ logger.info(f"【{self.cookie_id}】💰 订单金额: {amount} (source={amount_source})")
+
+ # 插入或更新订单信息到数据库
+ try:
+ # 对于系统消息误识别出的“自己是买家”场景,保留已有买家信息并继续刷新订单字段
+ existing_order = db_manager.get_order_by_id(order_id)
+ current_order_status = existing_order.get('order_status') if existing_order else None
+ existing_amount = existing_order.get('amount') if existing_order else None
+ existing_amount_value = _parse_amount_float(existing_amount)
+ amount, amount_source = self._apply_bargain_amount_override(
+ order_id,
+ item_id,
+ amount,
+ amount_source,
+ existing_order=existing_order,
+ item_config=item_config,
+ )
+ incoming_amount_value = _parse_amount_float(amount)
+ has_valid_spec = bool(spec_name and spec_value)
+ low_confidence_amount_sources = {
+ 'selector_direct',
+ 'selector_currency',
+ 'text_currency',
+ 'unknown',
+ }
+
+ if (
+ is_coin_deduction_item and existing_amount_value is not None and incoming_amount_value is not None and
+ configured_item_amount_value is not None and existing_amount_value + 0.009 < configured_item_amount_value and
+ abs(incoming_amount_value - configured_item_amount_value) <= 0.009
+ ):
+ logger.warning(
+ f"【{self.cookie_id}】闲鱼币抵扣订单返回原价,保留已有实付金额: "
+ f"order_id={order_id}, existing_amount={existing_amount}, incoming_amount={amount}, "
+ f"configured_amount={configured_item_amount}, amount_source={amount_source}"
+ )
+ amount = _normalize_amount_text(existing_amount)
+ amount_source = 'coin_deduction_preserved_existing'
+ incoming_amount_value = _parse_amount_float(amount)
+
+ if amount and amount_source in low_confidence_amount_sources and not has_valid_spec and not order_status:
+ if existing_amount_value is not None:
+ logger.warning(
+ f"【{self.cookie_id}】订单详情返回低置信度金额,保留已有金额: "
+ f"order_id={order_id}, existing_amount={existing_amount}, incoming_amount={amount}, "
+ f"amount_source={amount_source}"
+ )
+ amount = _normalize_amount_text(existing_amount)
+ amount_source = 'preserved_existing'
+ else:
+ logger.warning(
+ f"【{self.cookie_id}】订单详情返回低置信度金额,且缺少规格/状态佐证,跳过写库: "
+ f"order_id={order_id}, incoming_amount={amount}, amount_source={amount_source}"
+ )
+ amount = None
+
+ elif (
+ amount and existing_amount_value is not None and incoming_amount_value is not None and
+ abs(existing_amount_value - incoming_amount_value) > 0.009 and
+ not has_valid_spec and not order_status and
+ amount_source not in {'selector_keyword_high', 'selector_keyword_low', 'text_keyword_high', 'text_keyword_low', 'cache'}
+ ):
+ logger.warning(
+ f"【{self.cookie_id}】订单详情金额跳变且缺少规格/状态佐证,保留已有金额: "
+ f"order_id={order_id}, existing_amount={existing_amount}, incoming_amount={amount}, "
+ f"amount_source={amount_source}"
+ )
+ amount = _normalize_amount_text(existing_amount)
+ amount_source = 'preserved_existing'
+
+ if self._should_reject_order_detail_status_update(
+ current_status=current_order_status,
+ incoming_status=order_status,
+ incoming_source=order_status_source,
+ force_refresh=force_refresh,
+ ):
+ logger.warning(
+ f"【{self.cookie_id}】强制刷新结果仅来自正文,拒绝将订单状态更新为completed: "
+ f"order_id={order_id}, current={current_order_status}, incoming={order_status}, "
+ f"source={order_status_source}"
+ )
+ order_status = None
+
+ normalized_current_order_status = db_manager._normalize_order_status(current_order_status)
+ normalized_incoming_order_status = db_manager._normalize_order_status(order_status)
+ if self._should_accept_order_detail_status_correction(
+ current_order_status,
+ order_status,
+ order_status_source,
+ force_refresh=force_refresh,
+ order_id=order_id,
+ ):
+ order_status_to_save = normalized_incoming_order_status
+ logger.warning(
+ f"【{self.cookie_id}】检测到可疑已发货状态,允许强刷后的结构化待发货结果纠偏: "
+ f"order_id={order_id}, current={current_order_status}, incoming={order_status}, "
+ f"source={order_status_source}"
+ )
+ else:
+ order_status_to_save = self._resolve_external_order_status(
+ current_order_status,
+ order_status,
+ source='order_detail_refresh'
+ )
+
+ if (
+ order_status and existing_order and order_status_to_save is None and
+ normalized_current_order_status != normalized_incoming_order_status
+ ):
+ logger.info(
+ f"【{self.cookie_id}】保留订单现有状态,跳过详情页覆盖: "
+ f"order_id={order_id}, current={current_order_status}, incoming={order_status}"
+ )
+
+ buyer_id_to_save, buyer_nick_to_save, should_skip_write = self._select_buyer_identity_for_order_write(
+ order_id,
+ incoming_buyer_id=buyer_id,
+ incoming_buyer_nick=buyer_nick,
+ existing_order=existing_order,
+ buyer_id_source=buyer_id_source,
+ buyer_nick_source="order_detail",
+ log_prefix=f"【{self.cookie_id}】",
+ )
+ if should_skip_write:
+ return result
+
+ # 检查cookie_id是否在cookies表中存在
+ cookie_info = db_manager.get_cookie_by_id(self.cookie_id)
+ if not cookie_info:
+ logger.warning(f"Cookie ID {self.cookie_id} 不存在于cookies表中,丢弃订单 {order_id}")
+ else:
+ # 先保存订单基本信息(包含sid和buyer_nick用于简化消息匹配)
+ success = db_manager.insert_or_update_order(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=buyer_id_to_save,
+ buyer_nick=buyer_nick_to_save, # 传递买家昵称
+ sid=sid,
+ spec_name=spec_name,
+ spec_value=spec_value,
+ spec_name_2=spec_name_2,
+ spec_value_2=spec_value_2,
+ quantity=quantity,
+ amount=amount,
+ cookie_id=self.cookie_id,
+ order_status=order_status_to_save, # 外部详情状态仅在不会回退内部状态时写库
+ platform_created_at=platform_created_at,
+ platform_paid_at=platform_paid_at,
+ platform_completed_at=platform_completed_at
+ )
+
+ # 使用订单状态处理器设置状态
+ logger.info(f"【{self.cookie_id}】检查订单状态处理器调用条件: success={success}, handler_exists={self.order_status_handler is not None}")
+ if success and self.order_status_handler:
+ logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.handle_order_detail_fetched_status: {order_id}")
+ try:
+ handler_result = self.order_status_handler.handle_order_detail_fetched_status(
+ order_id=order_id,
+ cookie_id=self.cookie_id,
+ context="订单详情已拉取"
+ )
+ logger.info(f"【{self.cookie_id}】订单状态处理器.handle_order_detail_fetched_status返回结果: {handler_result}")
+
+ # 处理待处理队列
+ logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.on_order_details_fetched: {order_id}")
+ self.order_status_handler.on_order_details_fetched(order_id)
+ logger.info(f"【{self.cookie_id}】订单状态处理器.on_order_details_fetched调用成功: {order_id}")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】订单状态处理器调用失败: {self._safe_str(e)}")
+ import traceback
+ logger.error(f"【{self.cookie_id}】详细错误信息: {traceback.format_exc()}")
+ else:
+ logger.warning(f"【{self.cookie_id}】订单状态处理器调用条件不满足: success={success}, handler_exists={self.order_status_handler is not None}")
+
+ if success:
+ logger.info(f"【{self.cookie_id}】订单信息已保存到数据库: {order_id}")
+ print(f"💾 【{self.cookie_id}】订单 {order_id} 信息已保存到数据库")
+ else:
+ logger.warning(f"【{self.cookie_id}】订单信息保存失败: {order_id}")
+
+ except Exception as db_e:
+ logger.error(f"【{self.cookie_id}】保存订单信息到数据库失败: {self._safe_str(db_e)}")
+
+ return result
+ else:
+ logger.warning(f"【{self.cookie_id}】订单详情获取失败: {order_id}")
+ return None
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】获取订单详情异常: {self._safe_str(e)}")
+ return None
+
+ async def _auto_delivery(self, item_id: str, item_title: str = None, order_id: str = None, send_user_id: str = None,
+ chat_id: str = None, send_user_name: str = None, include_meta: bool = False,
+ data_preview_index: int = 0, delivery_unit_index: int = 1):
+ """自动发货功能 - 匹配规则并准备发货内容,不直接提交副作用。"""
+ try:
+ matched_rule_context = None
+ match_mode_context = None
+
+ def build_result(success: bool, content: str = None, error: str = None, matched_rule: dict = None,
+ match_mode_value: str = None, delivery_steps_value: list = None):
+ order_spec_mode_value = 'no_spec'
+ item_config_mode_value = 'no_spec'
+ rule_spec_mode_value = None
+
+ try:
+ order_spec_mode_value = _get_order_spec_mode()
+ except Exception:
+ pass
+
+ try:
+ rule_spec_mode_value = _get_rule_spec_mode(matched_rule) if matched_rule else None
+ except Exception:
+ pass
+
+ try:
+ item_config_mode_value = 'spec_enabled' if item_config_multi_spec else 'no_spec'
+ except Exception:
+ pass
+
+ if include_meta:
+ return {
+ "success": bool(success),
+ "content": content if success else None,
+ "error": error if not success else None,
+ "rule_id": matched_rule.get('id') if matched_rule else None,
+ "rule_keyword": matched_rule.get('keyword') if matched_rule else None,
+ "card_type": matched_rule.get('card_type') if matched_rule else None,
+ "match_mode": match_mode_value,
+ "order_spec_mode": order_spec_mode_value,
+ "rule_spec_mode": rule_spec_mode_value,
+ "item_config_mode": item_config_mode_value,
+ "card_id": matched_rule.get('card_id') if matched_rule else None,
+ "card_description": matched_rule.get('card_description') if matched_rule else None,
+ "delivery_steps": delivery_steps_value or [],
+ "data_card_pending_consume": False,
+ "data_line": None,
+ "data_reservation_id": None,
+ "data_reservation_status": None,
+ "delivery_unit_index": delivery_unit_index
+ }
+ return content if success else None
+
+ from db_manager import db_manager
+
+ logger.info(f"开始自动发货检查: 商品ID={item_id}")
+
+ # 获取商品详细信息
+ item_info = None
+ search_text = item_title # 默认使用传入的标题
+
+ if item_id and item_id != "未知商品":
+ # 直接从数据库获取商品信息(发货时不再调用API)
+ try:
+ logger.info(f"从数据库获取商品信息: {item_id}")
+ db_item_info = db_manager.get_item_info(self.cookie_id, item_id)
+ if db_item_info:
+ item_info = db_item_info
+ # 拼接商品标题和详情作为搜索文本
+ item_title_db = db_item_info.get('item_title', '') or ''
+ item_detail_db = db_item_info.get('item_detail', '') or ''
+
+ # 如果数据库中没有详情,尝试自动获取
+ if not item_detail_db.strip():
+ from config import config
+ auto_fetch_config = config.get('ITEM_DETAIL', {}).get('auto_fetch', {})
+
+ if auto_fetch_config.get('enabled', True):
+ logger.info(f"数据库中商品详情为空,尝试自动获取: {item_id}")
+ try:
+ fetched_detail = await self.fetch_item_detail_from_api(item_id)
+ if fetched_detail:
+ # 保存获取到的详情
+ await self.save_item_detail_only(item_id, fetched_detail)
+ item_detail_db = fetched_detail
+ logger.info(f"成功获取并保存商品详情: {item_id}")
+ else:
+ logger.warning(f"未能获取到商品详情: {item_id}")
+ except Exception as api_e:
+ logger.warning(f"获取商品详情失败: {item_id}, 错误: {self._safe_str(api_e)}")
+ else:
+ logger.warning(f"自动获取商品详情功能已禁用,跳过: {item_id}")
+
+ # 组合搜索文本:商品标题 + 商品详情
+ search_parts = []
+ if item_title_db.strip():
+ search_parts.append(item_title_db.strip())
+ if item_detail_db.strip():
+ search_parts.append(item_detail_db.strip())
+
+ if search_parts:
+ search_text = ' '.join(search_parts)
+ logger.info(f"使用数据库商品标题+详情作为搜索文本: 标题='{item_title_db}', 详情长度={len(item_detail_db)}")
+ logger.warning(f"完整搜索文本: {search_text[:200]}...")
+ else:
+ logger.warning(f"数据库中商品标题和详情都为空: {item_id}")
+ search_text = item_title or item_id
+ else:
+ logger.warning(f"数据库中未找到商品信息: {item_id}")
+ search_text = item_title or item_id
+
+ except Exception as db_e:
+ logger.warning(f"从数据库获取商品信息失败: {self._safe_str(db_e)}")
+ search_text = item_title or item_id
+
+ if not search_text:
+ search_text = item_id or "未知商品"
+
+ logger.info(f"使用搜索文本匹配发货规则: {search_text[:100]}...")
+
+ item_config_multi_spec = db_manager.get_item_multi_spec_status(self.cookie_id, item_id)
+ spec_name = ''
+ spec_value = ''
+ spec_name_2 = ''
+ spec_value_2 = ''
+
+ def _apply_spec_from_order_detail(order_detail_data) -> bool:
+ nonlocal spec_name, spec_value, spec_name_2, spec_value_2
+ if not order_detail_data or not isinstance(order_detail_data, dict):
+ return False
+ spec_name = (order_detail_data.get('spec_name') or '').strip()
+ spec_value = (order_detail_data.get('spec_value') or '').strip()
+ spec_name_2 = (order_detail_data.get('spec_name_2') or '').strip()
+ spec_value_2 = (order_detail_data.get('spec_value_2') or '').strip()
+ return bool(spec_name and spec_value)
+
+ def _get_order_spec_mode() -> str:
+ has_first_spec = bool(spec_name and spec_value)
+ has_second_spec = bool(spec_name_2 and spec_value_2)
+
+ if has_first_spec and has_second_spec:
+ return 'two_spec'
+ if has_first_spec:
+ return 'one_spec'
+ return 'no_spec'
+
+ def _get_rule_spec_mode(rule: dict) -> str:
+ if not rule:
+ return 'no_spec'
+
+ rule_spec_name = (rule.get('spec_name') or '').strip()
+ rule_spec_value = (rule.get('spec_value') or '').strip()
+ rule_spec_name_2 = (rule.get('spec_name_2') or '').strip()
+ rule_spec_value_2 = (rule.get('spec_value_2') or '').strip()
+
+ if rule_spec_name and rule_spec_value and rule_spec_name_2 and rule_spec_value_2:
+ return 'two_spec'
+ if rule_spec_name and rule_spec_value:
+ return 'one_spec'
+ return 'no_spec'
+
+ # 只要有订单ID就尝试拉取订单详情;规格商品缺失规格时自动重试,提升精确命中率
+ if order_id:
+ logger.info(f"检测到订单ID,获取订单详情用于规则匹配: {order_id}")
+ max_detail_attempts = 3 if item_config_multi_spec else 1
+ for attempt in range(1, max_detail_attempts + 1):
+ try:
+ force_refresh = attempt > 1
+ if force_refresh:
+ logger.info(f"订单规格信息缺失,开始强刷重试 ({attempt}/{max_detail_attempts}): {order_id}")
+
+ order_detail = await self.fetch_order_detail_info(
+ order_id,
+ item_id,
+ send_user_id,
+ force_refresh=force_refresh
+ )
+
+ if _apply_spec_from_order_detail(order_detail):
+ logger.info(f"获取到规格信息: {spec_name} = {spec_value}")
+ if spec_name_2 and spec_value_2:
+ logger.info(f"获取到规格2信息: {spec_name_2} = {spec_value_2}")
+ break
+
+ if item_config_multi_spec:
+ logger.warning(
+ f"订单详情已获取但未解析到有效规格信息 (尝试 {attempt}/{max_detail_attempts})"
+ )
+ else:
+ logger.info("无规格商品未解析到规格信息,按普通规则继续")
+ except Exception as e:
+ logger.error(
+ f"获取订单详情失败 (尝试 {attempt}/{max_detail_attempts}): {self._safe_str(e)}"
+ )
+
+ if attempt < max_detail_attempts:
+ await asyncio.sleep(0.6)
+
+ if _get_order_spec_mode() == 'no_spec':
+ try:
+ cached_order = db_manager.get_order_by_id(order_id)
+ if cached_order and _apply_spec_from_order_detail(cached_order):
+ logger.warning(
+ f"订单 {order_id} 从数据库缓存恢复规格成功: "
+ f"{spec_name}:{spec_value}"
+ )
+ except Exception as cache_e:
+ logger.warning(f"订单缓存规格恢复失败: {self._safe_str(cache_e)}")
+ else:
+ logger.warning("当前无订单ID,跳过订单详情拉取,将仅基于商品文本匹配规则")
+
+ order_spec_mode = _get_order_spec_mode()
+ item_config_mode = 'spec_enabled' if item_config_multi_spec else 'no_spec'
+
+ if order_spec_mode != 'no_spec' and item_info is not None and not item_config_multi_spec:
+ logger.warning(
+ f"商品已配置为无规格,忽略订单解析到的规格并按普通规则匹配: "
+ f"order_spec_mode={order_spec_mode}, item_id={item_id or 'unknown'}, "
+ f"order_id={order_id or 'unknown'}, spec={spec_name}:{spec_value}"
+ )
+ spec_name = ''
+ spec_value = ''
+ spec_name_2 = ''
+ spec_value_2 = ''
+ order_spec_mode = _get_order_spec_mode()
+ elif order_spec_mode == 'no_spec' and item_config_multi_spec:
+ block_reason = (
+ f"商品已开启规格匹配,但订单未解析到有效规格信息,已阻断自动发货: "
+ f"order_id={order_id or 'unknown'}, item_id={item_id or 'unknown'}"
+ )
+ logger.error(block_reason)
+ return build_result(False, error=block_reason, match_mode_value='blocked_no_spec_parsed')
+
+ logger.info(
+ f"规格模式判定完成: order_spec_mode={order_spec_mode}, "
+ f"item_config_mode={item_config_mode}"
+ )
+
+ delivery_rules = []
+ if order_spec_mode == 'two_spec':
+ match_mode = 'two_spec_exact'
+ match_mode_context = match_mode
+ logger.info(
+ f"尝试精确匹配两组规格发货规则: {search_text[:50]}... "
+ f"[{spec_name}:{spec_value}, {spec_name_2}:{spec_value_2}]"
+ )
+ delivery_rules = db_manager.get_delivery_rules_by_keyword_and_spec(
+ search_text,
+ spec_name,
+ spec_value,
+ spec_name_2,
+ spec_value_2,
+ user_id=self.user_id,
+ expected_mode='two_spec'
+ )
+ if not delivery_rules:
+ error_message = "两组规格订单未找到匹配的发货规则"
+ logger.warning(f"{error_message}: {search_text[:50]}...")
+ return build_result(False, error=error_message, match_mode_value='blocked_no_rule')
+ elif order_spec_mode == 'one_spec':
+ match_mode = 'one_spec_exact'
+ match_mode_context = match_mode
+ logger.info(
+ f"尝试精确匹配一组规格发货规则: {search_text[:50]}... "
+ f"[{spec_name}:{spec_value}]"
+ )
+ delivery_rules = db_manager.get_delivery_rules_by_keyword_and_spec(
+ search_text,
+ spec_name,
+ spec_value,
+ spec_name_2,
+ spec_value_2,
+ user_id=self.user_id,
+ expected_mode='one_spec'
+ )
+ if not delivery_rules:
+ logger.warning(
+ f"一组规格订单未找到精确规格规则,尝试降级匹配普通发货规则: {search_text[:50]}..."
+ )
+ fallback_rules = db_manager.get_delivery_rules_by_keyword(
+ search_text,
+ user_id=self.user_id,
+ only_non_multi_spec=True
+ )
+ if not fallback_rules:
+ error_message = "一组规格订单未找到匹配的发货规则"
+ logger.warning(f"{error_message}: {search_text[:50]}...")
+ return build_result(False, error=error_message, match_mode_value='blocked_no_rule')
+ if len(fallback_rules) != 1:
+ block_reason = (
+ f"一组规格订单精确匹配失败后,普通规则兜底匹配到{len(fallback_rules)}条,"
+ f"已阻断自动发货以避免错发: order_id={order_id or 'unknown'}, "
+ f"item_id={item_id or 'unknown'}"
+ )
+ logger.error(block_reason)
+ return build_result(False, error=block_reason, match_mode_value='blocked_multiple_no_spec_rules')
+ delivery_rules = fallback_rules
+ match_mode = 'one_spec_fallback_no_spec'
+ match_mode_context = match_mode
+ logger.warning(
+ f"一组规格订单已降级命中唯一普通规则: order_id={order_id or 'unknown'}, "
+ f"item_id={item_id or 'unknown'}, rule_id={delivery_rules[0].get('id')}"
+ )
+ else:
+ match_mode = 'no_spec_match'
+ match_mode_context = match_mode
+ logger.info(f"无规格订单,尝试匹配普通发货规则: {search_text[:50]}...")
+ delivery_rules = db_manager.get_delivery_rules_by_keyword(
+ search_text,
+ user_id=self.user_id,
+ only_non_multi_spec=True
+ )
+ if not delivery_rules:
+ error_message = "无规格订单未找到匹配的普通发货规则"
+ logger.warning(f"{error_message}: {search_text[:50]}...")
+ return build_result(False, error=error_message, match_mode_value='blocked_no_rule')
+ if len(delivery_rules) != 1:
+ block_reason = (
+ f"无规格订单匹配到{len(delivery_rules)}条普通规则,已阻断自动发货以避免错发: "
+ f"order_id={order_id or 'unknown'}, item_id={item_id or 'unknown'}"
+ )
+ logger.error(block_reason)
+ return build_result(False, error=block_reason, match_mode_value='blocked_multiple_no_spec_rules')
+
+ # 使用第一个匹配的规则(按关键字长度降序排列,优先匹配更精确的规则)
+ rule = delivery_rules[0]
+ matched_rule_context = rule
+ rule_spec_mode = _get_rule_spec_mode(rule)
+
+ logger.info(
+ f"规则模式判定完成: order_spec_mode={order_spec_mode}, rule_spec_mode={rule_spec_mode}, "
+ f"match_mode={match_mode}, rule_id={rule.get('id')}"
+ )
+
+ allow_one_spec_fallback = (
+ match_mode == 'one_spec_fallback_no_spec'
+ and order_spec_mode == 'one_spec'
+ and rule_spec_mode == 'no_spec'
+ )
+
+ if rule_spec_mode != order_spec_mode and not allow_one_spec_fallback:
+ block_reason = (
+ f"订单规格模式与命中规则模式不一致,已阻断自动发货: "
+ f"order_spec_mode={order_spec_mode}, rule_spec_mode={rule_spec_mode}, "
+ f"order_id={order_id or 'unknown'}, item_id={item_id or 'unknown'}, rule_id={rule.get('id')}"
+ )
+ logger.error(block_reason)
+ return build_result(False, error=block_reason, matched_rule=rule, match_mode_value='blocked_rule_mode_mismatch')
+
+ # 注释掉自动发货时的商品信息保存逻辑,避免重复保存导致item_detail字段内容累积
+ # 商品信息应该在商品列表获取、订单详情获取等其他环节已经保存过了
+ # 保存商品信息到数据库(需要有商品标题才保存)
+ # # 尝试获取商品标题
+ # item_title_for_save = None
+ # try:
+ # from db_manager import db_manager
+ # db_item_info = db_manager.get_item_info(self.cookie_id, item_id)
+ # if db_item_info:
+ # item_title_for_save = db_item_info.get('item_title', '').strip()
+ # except:
+ # pass
+ #
+ # # 如果有商品标题,则保存商品信息
+ # if item_title_for_save:
+ # await self.save_item_info_to_db(item_id, search_text, item_title_for_save)
+ # else:
+ # logger.warning(f"跳过保存商品信息:缺少商品标题 - {item_id}")
+
+ # 详细的匹配结果日志
+ if order_spec_mode == 'two_spec':
+ rule_spec_info = f"{rule['spec_name']}:{rule['spec_value']}, {rule['spec_name_2']}:{rule['spec_value_2']}"
+ order_spec_info = f"{spec_name}:{spec_value}, {spec_name_2}:{spec_value_2}"
+ logger.info(f"🎯 精确匹配两组规格发货规则: {rule['keyword']} -> {rule['card_name']} [{rule_spec_info}]")
+ logger.info(f"📋 订单规格: {order_spec_info} ✅ 匹配卡券规格: {rule_spec_info}")
+ elif match_mode == 'one_spec_fallback_no_spec':
+ order_spec_info = f"{spec_name}:{spec_value}"
+ logger.warning(
+ f"⚠️ 单规格订单降级匹配普通发货规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})"
+ )
+ logger.warning(f"📋 订单规格: {order_spec_info},精确规格未命中,已降级到普通规则")
+ elif order_spec_mode == 'one_spec':
+ rule_spec_info = f"{rule['spec_name']}:{rule['spec_value']}"
+ order_spec_info = f"{spec_name}:{spec_value}"
+ logger.info(f"🎯 精确匹配一组规格发货规则: {rule['keyword']} -> {rule['card_name']} [{rule_spec_info}]")
+ logger.info(f"📋 订单规格: {order_spec_info} ✅ 匹配卡券规格: {rule_spec_info}")
+ else:
+ logger.info(f"✅ 匹配无规格发货规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
+
+ # 获取延时设置
+ delay_seconds = rule.get('card_delay_seconds', 0)
+
+ # 执行延时(只准备内容,不执行确认发货)
+ if delay_seconds and delay_seconds > 0:
+ logger.info(f"检测到发货延时设置: {delay_seconds}秒,开始延时...")
+ await asyncio.sleep(delay_seconds)
+ logger.info(f"延时完成")
+
+ # 检查是否存在订单ID,只有存在订单ID才处理发货内容
+ if order_id:
+ # 保存订单基本信息到数据库(如果还没有详细信息)
+ try:
+ from db_manager import db_manager
+
+ # 过滤掉买家订单(如果send_user_id是自己,说明是自己购买的订单)
+ if send_user_id and send_user_id == self.myid:
+ logger.info(f"【{self.cookie_id}】跳过买家订单 {order_id},buyer_id={send_user_id} 等于自己的ID")
+ # 不保存买家订单,但继续返回发货内容(如果有的话)
+ else:
+ # 检查cookie_id是否在cookies表中存在
+ cookie_info = db_manager.get_cookie_by_id(self.cookie_id)
+ if not cookie_info:
+ logger.warning(f"Cookie ID {self.cookie_id} 不存在于cookies表中,丢弃订单 {order_id}")
+ else:
+ existing_order = db_manager.get_order_by_id(order_id)
+ if not existing_order:
+ # 插入基本订单信息
+ success = db_manager.insert_or_update_order(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ buyer_nick=send_user_name,
+ cookie_id=self.cookie_id
+ )
+
+ # 使用订单状态处理器设置状态
+ if success and self.order_status_handler:
+ try:
+ self.order_status_handler.handle_order_basic_info_status(
+ order_id=order_id,
+ cookie_id=self.cookie_id,
+ context="自动发货-基本信息"
+ )
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】订单状态处理器调用失败: {self._safe_str(e)}")
+
+ if success:
+ logger.info(f"保存基本订单信息到数据库: {order_id}")
+ except Exception as db_e:
+ logger.error(f"保存基本订单信息失败: {self._safe_str(db_e)}")
+
+ # 开始处理发货内容
+ logger.info(f"开始处理发货内容,规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
+
+ delivery_content = None
+ data_line = None
+ data_reservation = None
+
+ # 根据卡券类型处理发货内容
+ if rule['card_type'] == 'api':
+ # API类型:调用API获取内容,传入订单和商品信息用于动态参数替换
+ delivery_content = await self._get_api_card_content(rule, order_id, item_id, send_user_id, spec_name, spec_value)
+
+ elif rule['card_type'] == 'yifan_api':
+ # 亦凡卡劵API类型:调用亦凡API获取内容
+ delivery_content = await self._get_yifan_api_card_content(rule, order_id, item_id, send_user_id, chat_id)
+
+ elif rule['card_type'] == 'text':
+ # 固定文字类型:直接使用文字内容
+ delivery_content = rule['text_content']
+
+ elif rule['card_type'] == 'data':
+ # 批量数据类型:先原子预占,再发送,避免并发订单拿到同一条卡密
+ data_reservation = db_manager.reserve_batch_data(
+ card_id=rule['card_id'],
+ order_id=order_id,
+ unit_index=delivery_unit_index,
+ cookie_id=self.cookie_id,
+ buyer_id=send_user_id,
+ )
+ if data_reservation:
+ data_line = data_reservation.get('reserved_content')
+ delivery_content = data_line
+ else:
+ delivery_content = None
+
+ elif rule['card_type'] == 'image':
+ # 图片类型:返回图片发送标记,包含卡券ID
+ image_url = rule.get('image_url')
+ if image_url:
+ delivery_content = f"__IMAGE_SEND__{rule['card_id']}|{image_url}"
+ logger.info(f"准备发送图片: {image_url} (卡券ID: {rule['card_id']})")
+ else:
+ logger.error(f"图片卡券缺少图片URL: 卡券ID={rule['card_id']}")
+ delivery_content = None
+
+ if delivery_content:
+ delivery_steps = self._build_delivery_steps(delivery_content, rule.get('card_description', ''))
+ if not delivery_steps:
+ logger.warning(f"发货步骤构建失败: 规则ID={rule['id']}")
+ return build_result(False, error=f"发货步骤构建失败: 规则ID={rule['id']}", matched_rule=rule, match_mode_value=match_mode)
+
+ if len(delivery_steps) == 1 and delivery_steps[0].get('type') == 'text':
+ final_content = delivery_steps[0].get('content') or ''
+ else:
+ final_content = delivery_content
+
+ logger.info(f"自动发货内容准备成功: 规则ID={rule['id']}, 步骤数={len(delivery_steps)}")
+
+ result = build_result(
+ True,
+ content=final_content,
+ matched_rule=rule,
+ match_mode_value=match_mode,
+ delivery_steps_value=delivery_steps
+ )
+ if include_meta and isinstance(result, dict):
+ result['card_id'] = rule.get('card_id')
+ result['data_card_pending_consume'] = bool(rule['card_type'] == 'data')
+ result['data_line'] = data_line
+ result['data_reservation_id'] = data_reservation.get('id') if data_reservation else None
+ result['data_reservation_status'] = data_reservation.get('status') if data_reservation else None
+ result['delivery_unit_index'] = delivery_unit_index
+ return result
+ else:
+ logger.warning(f"获取发货内容失败: 规则ID={rule['id']}")
+ return build_result(False, error=f"获取发货内容失败: 规则ID={rule['id']}", matched_rule=rule, match_mode_value=match_mode)
+ else:
+ # 没有订单ID,记录日志但不处理发货内容
+ logger.info(f"⚠️ 未检测到订单ID,跳过发货内容处理。规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
+ return build_result(False, error="未检测到订单ID,跳过发货内容处理", matched_rule=rule, match_mode_value=match_mode)
+
+ except Exception as e:
+ error_text = self._safe_str(e)
+ if matched_rule_context:
+ rule_label = matched_rule_context.get('keyword') or f"规则ID={matched_rule_context.get('id')}"
+ card_type = matched_rule_context.get('card_type') or 'unknown'
+ error_message = f"规则已命中({rule_label}),但{card_type}发货处理异常: {error_text}"
+ else:
+ error_message = f"自动发货异常: {error_text}"
+ logger.error(error_message)
+ return build_result(
+ False,
+ error=error_message,
+ matched_rule=matched_rule_context,
+ match_mode_value=match_mode_context
+ )
+
+
+
+ def _process_delivery_content_with_description(self, delivery_content: str, card_description: str) -> str:
+ """处理发货内容和备注信息,实现变量替换"""
+ try:
+ # 如果没有备注信息,直接返回发货内容
+ if not card_description or not card_description.strip():
+ return delivery_content
+
+ # 替换备注中的变量
+ processed_description = card_description.replace('{DELIVERY_CONTENT}', delivery_content)
+
+ # 如果备注中包含变量替换,返回处理后的备注
+ if '{DELIVERY_CONTENT}' in card_description:
+ return processed_description
+ else:
+ # 如果备注中没有变量,将备注和发货内容组合
+ return f"{processed_description}\n\n{delivery_content}"
+
+ except Exception as e:
+ logger.error(f"处理备注信息失败: {e}")
+ # 出错时返回原始发货内容
+ return delivery_content
+
+ def _build_delivery_steps(self, delivery_content: str, card_description: str):
+ """构建发货步骤,确保图片卡券和备注按正确顺序发送。"""
+ try:
+ raw_content = delivery_content if isinstance(delivery_content, str) else str(delivery_content or '')
+ description = (card_description or '').strip()
+ steps = []
+
+ if raw_content and not raw_content.startswith("__IMAGE_SEND__"):
+ final_text = self._process_delivery_content_with_description(raw_content, description)
+ return [{'type': 'text', 'content': final_text}] if final_text else []
+
+ def append_text_step(text: str):
+ text = (text or '').strip()
+ if text:
+ steps.append({'type': 'text', 'content': text})
+
+ def append_payload_step(payload: str):
+ payload = (payload or '').strip()
+ if payload:
+ if payload.startswith("__IMAGE_SEND__"):
+ steps.append({'type': 'image', 'content': payload})
+ else:
+ steps.append({'type': 'text', 'content': payload})
+
+ if not description:
+ append_payload_step(raw_content)
+ return steps
+
+ if '{DELIVERY_CONTENT}' in description:
+ placeholder = '{DELIVERY_CONTENT}'
+ segments = description.split(placeholder)
+ for index, segment in enumerate(segments):
+ append_text_step(segment)
+ if index < len(segments) - 1:
+ append_payload_step(raw_content)
+ return steps
+
+ append_text_step(description)
+ append_payload_step(raw_content)
+ return steps
+ except Exception as e:
+ logger.error(f"构建发货步骤失败: {e}")
+ fallback_content = delivery_content if isinstance(delivery_content, str) else str(delivery_content or '')
+ if fallback_content:
+ return [{'type': 'image' if fallback_content.startswith("__IMAGE_SEND__") else 'text', 'content': fallback_content}]
+ return []
+
+ def _can_batch_text_delivery(self, delivery_steps, card_type: str = None) -> bool:
+ """仅将 text/data/api 的单条纯文本步骤纳入批量合并发送。"""
+ normalized_card_type = str(card_type or '').strip().lower()
+ if normalized_card_type not in {'text', 'data', 'api'}:
+ return False
+
+ steps = delivery_steps or []
+ if len(steps) != 1:
+ return False
+
+ step = steps[0] or {}
+ if step.get('type') != 'text':
+ return False
+
+ return bool((step.get('content') or '').strip())
+
+ def _format_delivery_unit_text(self, text: str, unit_index: int, total_units: int) -> str:
+ """为批量发货文本添加全局连续序号。"""
+ safe_total_units = max(1, int(total_units or 1))
+ safe_unit_index = max(1, int(unit_index or 1))
+ prefix = f"【{safe_unit_index}/{safe_total_units}】"
+ content = (text or '').strip()
+ return f"{prefix}{content}" if content else prefix
+
+ def _apply_delivery_unit_numbering(self, delivery_steps, unit_index: int, total_units: int, card_type: str = None):
+ """为多数量订单中的 text/data/api 步骤补充序号。"""
+ if max(1, int(total_units or 1)) <= 1:
+ return delivery_steps or []
+
+ normalized_card_type = str(card_type or '').strip().lower()
+ if normalized_card_type not in {'text', 'data', 'api'}:
+ return delivery_steps or []
+
+ steps = [dict(step or {}) for step in (delivery_steps or [])]
+ prefix = f"【{max(1, int(unit_index or 1))}/{max(1, int(total_units or 1))}】"
+
+ for step in steps:
+ if step.get('type') == 'text':
+ step['content'] = f"{prefix}{(step.get('content') or '').strip()}"
+ return steps
+
+ return [{'type': 'text', 'content': prefix}] + steps
+
+ def _build_delivery_send_groups(self, prepared_units, total_units: int,
+ max_units_per_message: int = DELIVERY_BATCH_MAX_UNITS,
+ max_chars_per_message: int = DELIVERY_BATCH_MAX_CHARS):
+ """按数量和字符数双阈值生成发货发送批次。"""
+ if max(1, int(total_units or 1)) <= 1:
+ return [{
+ 'mode': 'single',
+ 'units': [prepared_unit],
+ 'delivery_steps': prepared_unit.get('delivery_steps') or [],
+ 'unit_count': 1,
+ 'char_count': 0,
+ } for prepared_unit in sorted(prepared_units or [], key=lambda unit: int(unit.get('unit_index') or 0))]
+
+ groups = []
+ current_batch_units = []
+ current_batch_chars = 0
+
+ def flush_current_batch():
+ nonlocal current_batch_units, current_batch_chars
+ if not current_batch_units:
+ return
+
+ batched_text = '\n\n'.join(unit['batched_text'] for unit in current_batch_units)
+ groups.append({
+ 'mode': 'batched_text',
+ 'units': list(current_batch_units),
+ 'delivery_steps': [{'type': 'text', 'content': batched_text}],
+ 'unit_count': len(current_batch_units),
+ 'char_count': len(batched_text),
+ })
+ current_batch_units = []
+ current_batch_chars = 0
+
+ for prepared_unit in sorted(prepared_units or [], key=lambda unit: int(unit.get('unit_index') or 0)):
+ delivery_steps = prepared_unit.get('delivery_steps') or []
+ rule_meta = prepared_unit.get('rule_meta') or {}
+ card_type = prepared_unit.get('card_type') or rule_meta.get('card_type')
+
+ if not self._can_batch_text_delivery(delivery_steps, card_type):
+ flush_current_batch()
+ numbered_steps = self._apply_delivery_unit_numbering(
+ delivery_steps,
+ prepared_unit.get('unit_index') or 1,
+ total_units,
+ card_type,
+ )
+ groups.append({
+ 'mode': 'single',
+ 'units': [prepared_unit],
+ 'delivery_steps': numbered_steps,
+ 'unit_count': 1,
+ 'char_count': 0,
+ })
+ continue
+
+ numbered_text = self._format_delivery_unit_text(
+ delivery_steps[0].get('content') or '',
+ prepared_unit.get('unit_index') or 1,
+ total_units,
+ )
+
+ if len(numbered_text) > max_chars_per_message:
+ flush_current_batch()
+ logger.warning(
+ f"【{self.cookie_id}】发货单元 {prepared_unit.get('unit_index')} 文本长度 {len(numbered_text)} 超过批量阈值 {max_chars_per_message},回退为单条发送"
+ )
+ groups.append({
+ 'mode': 'single',
+ 'units': [prepared_unit],
+ 'delivery_steps': [{'type': 'text', 'content': numbered_text}],
+ 'unit_count': 1,
+ 'char_count': len(numbered_text),
+ })
+ continue
+
+ separator_chars = 2 if current_batch_units else 0
+ exceeds_unit_limit = len(current_batch_units) >= max_units_per_message
+ exceeds_char_limit = current_batch_units and (
+ current_batch_chars + separator_chars + len(numbered_text) > max_chars_per_message
+ )
+
+ if exceeds_unit_limit or exceeds_char_limit:
+ flush_current_batch()
+
+ prepared_unit_with_text = dict(prepared_unit)
+ prepared_unit_with_text['batched_text'] = numbered_text
+ current_batch_units.append(prepared_unit_with_text)
+ current_batch_chars += (2 if len(current_batch_units) > 1 else 0) + len(numbered_text)
+
+ flush_current_batch()
+ return groups
+
+ async def _send_delivery_steps(self, websocket, chat_id: str, user_id: str, delivery_steps, user_url: str = None,
+ log_prefix: str = "自动发货", card_id: int = None):
+ """按顺序发送发货步骤,支持文本与图片混排。"""
+ steps = delivery_steps or []
+ if not steps:
+ raise ValueError("发货步骤为空")
+
+ total_steps = len(steps)
+ user_url = user_url or f'https://www.goofish.com/personal?userId={user_id}'
+
+ for index, step in enumerate(steps, start=1):
+ step_type = step.get('type')
+ step_content = step.get('content') or ''
+
+ if step_type == 'image':
+ image_data = step_content.replace("__IMAGE_SEND__", "", 1)
+ image_card_id = card_id
+ image_url = image_data
+ if "|" in image_data:
+ card_id_str, image_url = image_data.split("|", 1)
+ try:
+ image_card_id = int(card_id_str)
+ except ValueError:
+ logger.error(f"无效的卡券ID: {card_id_str}")
+ image_card_id = card_id
+
+ await self.send_image_msg(websocket, chat_id, user_id, image_url, card_id=image_card_id)
+ logger.info(
+ f"【{log_prefix}】步骤 {index}/{total_steps} 已向 {user_url} 发送图片: {image_url}"
+ )
+ else:
+ await self.send_msg(websocket, chat_id, user_id, step_content)
+ logger.info(
+ f"【{log_prefix}】步骤 {index}/{total_steps} 已向 {user_url} 发送文本内容"
+ )
+
+ if total_steps > 1 and index < total_steps:
+ await asyncio.sleep(0.3)
+
+ async def _get_api_card_content(self, rule, order_id=None, item_id=None, buyer_id=None, spec_name=None, spec_value=None, retry_count=0):
+ """调用API获取卡券内容,支持动态参数替换和重试机制"""
+ max_retries = 4
+
+ if retry_count >= max_retries:
+ logger.error(f"API调用失败,已达到最大重试次数({max_retries})")
+ return None
+
+ try:
+ import aiohttp
+ import json
+
+ api_config = rule.get('api_config')
+ if not api_config:
+ logger.error(f"API配置为空,规则ID: {rule.get('id')}, 卡券名称: {rule.get('card_name')}")
+ logger.warning(f"规则详情: {rule}")
+ return None
+
+ # 解析API配置
+ if isinstance(api_config, str):
+ api_config = json.loads(api_config)
+
+ url = api_config.get('url')
+ method = api_config.get('method', 'GET').upper()
+ timeout = api_config.get('timeout', 10)
+ headers = api_config.get('headers', '{}')
+ params = api_config.get('params', '{}')
+
+ # 解析headers和params
+ if isinstance(headers, str):
+ headers = json.loads(headers)
+ if isinstance(params, str):
+ params = json.loads(params)
+
+ # 如果是POST请求且有动态参数,进行参数替换
+ if method == 'POST' and params:
+ params = await self._replace_api_dynamic_params(params, order_id, item_id, buyer_id, spec_name, spec_value)
+
+ retry_info = f" (重试 {retry_count + 1}/{max_retries})" if retry_count > 0 else ""
+ logger.info(f"调用API获取卡券: {method} {url}{retry_info}")
+ if method == 'POST' and params:
+ logger.warning(f"POST请求参数: {json.dumps(params, ensure_ascii=False)}")
+
+ # 确保session存在
+ if not self.session:
+ await self.create_session()
+
+ # 发起HTTP请求
+ timeout_obj = aiohttp.ClientTimeout(total=timeout)
+
+ if method == 'GET':
+ async with self.session.get(url, headers=headers, params=params, timeout=timeout_obj) as response:
+ status_code = response.status
+ response_text = await response.text()
+ elif method == 'POST':
+ async with self.session.post(url, headers=headers, json=params, timeout=timeout_obj) as response:
+ status_code = response.status
+ response_text = await response.text()
+ else:
+ logger.error(f"不支持的HTTP方法: {method}")
+ return None
+
+ if status_code == 200:
+ # 尝试解析JSON响应,如果失败则使用原始文本
+ try:
+ result = json.loads(response_text)
+ # 如果返回的是对象,尝试提取常见的内容字段
+ if isinstance(result, dict):
+ content = result.get('data') or result.get('content') or result.get('card') or str(result)
+ else:
+ content = str(result)
+ except Exception:
+ content = response_text
+
+ logger.info(f"API调用成功,返回内容长度: {len(content)}")
+ return content
+ else:
+ logger.warning(f"API调用失败: {status_code} - {response_text[:200]}...")
+
+ # 如果是服务器错误(5xx)或请求超时,进行重试
+ if status_code >= 500 or status_code == 408:
+ if retry_count < max_retries - 1:
+ wait_time = (retry_count + 1) * 2 # 递增等待时间: 2s, 4s, 6s
+ logger.info(f"等待 {wait_time} 秒后重试...")
+ await asyncio.sleep(wait_time)
+ return await self._get_api_card_content(rule, order_id, item_id, buyer_id, spec_name, spec_value, retry_count + 1)
+
+ return None
+
+ except (asyncio.TimeoutError, aiohttp.ClientError) as e:
+ logger.warning(f"API调用网络异常: {self._safe_str(e)}")
+
+ # 网络异常也进行重试
+ if retry_count < max_retries - 1:
+ wait_time = (retry_count + 1) * 2 # 递增等待时间
+ logger.info(f"等待 {wait_time} 秒后重试...")
+ await asyncio.sleep(wait_time)
+ return await self._get_api_card_content(rule, order_id, item_id, buyer_id, spec_name, spec_value, retry_count + 1)
+ else:
+ logger.error(f"API调用网络异常,已达到最大重试次数: {self._safe_str(e)}")
+ return None
+
+ except Exception as e:
+ logger.error(f"API调用异常: {self._safe_str(e)}")
+ return None
+
+ async def _get_yifan_api_card_content(self, rule, order_id=None, item_id=None, buyer_id=None, chat_id=None):
+ """调用亦凡卡劵API获取内容"""
+ try:
+ import hashlib
+ import time
+ import aiohttp
+ import json
+ from urllib.parse import urlencode
+
+ # 获取API配置(存储在api_config字段中)
+ api_config = rule.get('api_config')
+ if not api_config:
+ logger.error(f"亦凡API配置为空,规则ID: {rule.get('id')}, 卡券名称: {rule.get('card_name')}")
+ return None
+
+ # 解析API配置
+ if isinstance(api_config, str):
+ api_config = json.loads(api_config)
+
+ # 亦凡API配置直接存储在api_config字段中
+ user_id = api_config.get('user_id')
+ user_key = api_config.get('user_key')
+ goods_id = api_config.get('goods_id')
+ # 回调地址:优先使用卡券配置中的,如果没有则从全局配置读取,最后使用默认地址
+ callback_url = (api_config.get('callback_url') or '').strip() or (YIFAN_API.get('callback_url') or '').strip() or 'http://116.196.116.76/yifan.php'
+ require_account = api_config.get('require_account', False)
+
+ if not user_id or not user_key or not goods_id:
+ logger.error(f"亦凡API配置不完整,规则ID: {rule.get('id')}")
+ return None
+
+ # 如果需要充值账号,先进行账号询问和确认流程
+ recharge_account = None
+ if require_account:
+ logger.info(f"亦凡API需要充值账号,开始询问流程")
+ recharge_account = await self._ask_for_recharge_account(chat_id, buyer_id, rule, order_id, item_id)
+ if recharge_account == "__WAITING_ACCOUNT__":
+ # 已设置等待状态,暂时中断发货流程
+ logger.info(f"已设置等待账号输入状态,暂停发货流程")
+ return None
+ elif not recharge_account:
+ logger.error(f"获取充值账号失败,取消发货")
+ return None
+ logger.info(f"获取到充值账号: {recharge_account}")
+
+ # 构建API请求参数(所有值都转换为字符串,避免空格问题)
+ timestamp = str(int(time.time()))
+ params = {
+ 'userid': str(user_id),
+ 'timestamp': timestamp,
+ 'goodsid': str(goods_id),
+ 'buynum': '1',
+ }
+
+ # 如果有回调地址,添加到参数中(签名之前添加)
+ if callback_url and callback_url.strip():
+ params['callbackurl'] = str(callback_url).strip()
+
+ # 如果有充值账号,添加到参数中
+ if recharge_account:
+ params['attach'] = str(recharge_account).strip()
+
+ # 生成签名(确保参数值没有空格)
+ # 1. 按照key的ascii码从小到大排序
+ # 2. 空值不参与签名
+ # 3. 使用QueryString格式拼接
+ # 4. 尾部追加商户KEY
+ # 5. MD5后转成32位小写
+ sign_params = {k: str(v).strip() for k, v in params.items() if v is not None and str(v).strip() != ''}
+ sorted_keys = sorted(sign_params.keys())
+ sign_string = '&'.join([f"{key}={sign_params[key]}" for key in sorted_keys])
+ sign_string += user_key
+
+ logger.info(f"亦凡API签名字符串: {sign_string}")
+
+ sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().lower()
+ params['sign'] = sign
+
+ logger.info(f"调用亦凡API: 商户ID={user_id}, 商品ID={goods_id}, 充值账号={recharge_account}, 回调URL={callback_url if callback_url else '无'}")
+
+ # 确保session存在
+ if not self.session:
+ await self.create_session()
+
+ # 发起API请求(使用data而不是json,发送form格式)
+ api_url = "http://price.78shuk.top/dockapiv3/order/create"
+
+ timeout_obj = aiohttp.ClientTimeout(total=30)
+ async with self.session.post(api_url, data=params, timeout=timeout_obj) as response:
+ status_code = response.status
+ response_text = await response.text()
+
+ logger.info(f"亦凡API返回状态码: {status_code}, 响应: {response_text}")
+
+ if status_code == 200:
+ try:
+ result = json.loads(response_text)
+ # 根据亦凡API的返回格式处理:code为1表示成功
+ if result.get('code') == 1:
+ # 提取订单信息
+ data = result.get('data', {})
+ order_no = data.get('orderno', '')
+ us_order_no = data.get('usorderno', '')
+
+ # 构建成功消息
+ success_msg = f"✅ 自动发货订单已提交成功\n\n"
+ success_msg += f"📋 订单信息:\n"
+ success_msg += f"平台订单号: {order_no}\n"
+ if us_order_no:
+ success_msg += f"商家订单号: {us_order_no}\n"
+
+ # 添加查询地址(从全局配置读取)
+ query_url = YIFAN_API.get('query_url', 'http://116.196.116.76/yifan.php')
+ success_msg += f"\n🔍 查询卡密:\n"
+ success_msg += f"{query_url}\n"
+ success_msg += f"(输入订单号查询)\n"
+
+ # 添加提示信息
+ success_msg += f"\n⏰ 温馨提示:\n"
+ success_msg += f"订单处理需要一定时间,请耐心等待。\n"
+ success_msg += f"如果1小时后仍未看到卡密信息,\n"
+ success_msg += f"请联系客服处理。"
+
+ logger.info(f"亦凡API调用成功: order_no={order_no}")
+
+ # 将亦凡订单号记录到数据库(用于后续回调匹配)
+ if order_id and order_no:
+ try:
+ from db_manager import db_manager
+ # 更新订单的亦凡订单号和chat_id
+ db_manager.update_order_yifan_status(
+ order_id=order_id,
+ yifan_orderno=order_no,
+ delivery_status='processing'
+ )
+ if chat_id:
+ db_manager.update_order_chat_id(order_id, chat_id)
+ logger.info(f"已记录亦凡订单信息: order_id={order_id}, yifan_orderno={order_no}")
+ except Exception as e:
+ logger.error(f"记录亦凡订单信息失败: {e}")
+
+ return success_msg
+ else:
+ # code不为1,下单失败,需要通知用户
+ error_msg = result.get('msg', '未知错误')
+ logger.error(f"亦凡API调用失败: code={result.get('code')}, msg={error_msg}")
+
+ # 发送通知给用户
+ if chat_id and buyer_id:
+ from db_manager import db_manager
+ notification_msg = f"❌ 自动发货失败\n错误信息: {error_msg}\n请联系客服处理"
+ await self.send_notification("系统", buyer_id, notification_msg, item_id or "unknown", chat_id)
+
+ return None
+ except Exception as e:
+ logger.error(f"解析亦凡API返回失败: {self._safe_str(e)}")
+ return None
+ else:
+ logger.error(f"亦凡API调用失败: HTTP {status_code} - {response_text[:200]}")
+ return None
+
+ except Exception as e:
+ logger.error(f"亦凡API调用异常: {self._safe_str(e)}")
+ return None
+
+ async def _call_yifan_api_with_account(self, rule, account, order_id=None, item_id=None, buyer_id=None, chat_id=None):
+ """使用确认的账号调用亦凡API"""
+ try:
+ import hashlib
+ import time
+ import aiohttp
+ import json
+
+ # 获取API配置
+ api_config = rule.get('api_config')
+ if not api_config:
+ logger.error(f"亦凡API配置为空")
+ return None
+
+ # 解析API配置
+ if isinstance(api_config, str):
+ api_config = json.loads(api_config)
+
+ # 亦凡API配置直接存储在api_config字段中
+ user_id = api_config.get('user_id')
+ user_key = api_config.get('user_key')
+ goods_id = api_config.get('goods_id')
+ callback_url = api_config.get('callback_url', '')
+
+ if not user_id or not user_key or not goods_id:
+ logger.error(f"亦凡API配置不完整")
+ return None
+
+ # 构建API请求参数(所有值都转换为字符串,避免空格问题)
+ timestamp = str(int(time.time()))
+ params = {
+ 'userid': str(user_id),
+ 'timestamp': timestamp,
+ 'goodsid': str(goods_id),
+ 'buynum': '1',
+ 'attach': str(account).strip() # 充值账号,去除首尾空格
+ }
+
+ # 如果有回调地址,添加到参数中(签名之前添加)
+ if callback_url and callback_url.strip():
+ params['callbackurl'] = str(callback_url).strip()
+
+ # 生成签名(确保参数值没有空格)
+ sign_params = {k: str(v).strip() for k, v in params.items() if v is not None and str(v).strip() != ''}
+ sorted_keys = sorted(sign_params.keys())
+ sign_string = '&'.join([f"{key}={sign_params[key]}" for key in sorted_keys])
+ sign_string += user_key
+
+ logger.info(f"亦凡API签名字符串: {sign_string}")
+
+ sign = hashlib.md5(sign_string.encode('utf-8')).hexdigest().lower()
+ params['sign'] = sign
+
+ logger.info(f"调用亦凡API: 商户ID={user_id}, 商品ID={goods_id}, 充值账号={account}, 回调URL={callback_url if callback_url else '无'}")
+
+ # 确保session存在
+ if not self.session:
+ await self.create_session()
+
+ # 发起API请求(使用data而不是json,发送form格式)
+ api_url = "http://price.78shuk.top/dockapiv3/order/create"
+
+ timeout_obj = aiohttp.ClientTimeout(total=30)
+ async with self.session.post(api_url, data=params, timeout=timeout_obj) as response:
+ status_code = response.status
+ response_text = await response.text()
+
+ logger.info(f"亦凡API返回状态码: {status_code}, 响应: {response_text}")
+
+ if status_code == 200:
+ try:
+ result = json.loads(response_text)
+ if result.get('code') == 1:
+ # 下单成功
+ data = result.get('data', {})
+ order_no = data.get('orderno', '')
+ us_order_no = data.get('usorderno', '')
+
+ success_msg = f"✅ 下单成功\n"
+ success_msg += f"订单号: {order_no}\n"
+ if us_order_no:
+ success_msg += f"用户订单号: {us_order_no}\n"
+ success_msg += f"充值账号: {account}\n"
+ success_msg += f"返回信息: {result.get('msg', '提交成功')}\n"
+ success_msg += f"有任何问题,请及时联系客服处理。"
+
+ logger.info(f"亦凡API调用成功: {success_msg}")
+ return success_msg
+ else:
+ # 下单失败
+ error_msg = result.get('msg', '未知错误')
+ logger.error(f"亦凡API调用失败: code={result.get('code')}, msg={error_msg}")
+
+ # 发送通知给用户
+ if chat_id and buyer_id:
+ from db_manager import db_manager
+ notification_msg = f"❌ 自动发货失败\n错误信息: {error_msg}\n请联系客服处理"
+ await self.send_notification("系统", buyer_id, notification_msg, item_id or "unknown", chat_id)
+
+ return None
+ except Exception as e:
+ logger.error(f"解析亦凡API返回失败: {self._safe_str(e)}")
+ return None
+ else:
+ logger.error(f"亦凡API调用失败: HTTP {status_code} - {response_text[:200]}")
+ return None
+
+ except Exception as e:
+ logger.error(f"亦凡API调用异常: {self._safe_str(e)}")
+ return None
+
+ async def _ask_for_recharge_account(self, chat_id, buyer_id, rule, order_id=None, item_id=None):
+ """询问客户充值账号并设置等待状态(不阻塞)"""
+ try:
+ async with self.yifan_account_lock:
+ # 设置等待状态
+ self.yifan_account_waiting[chat_id] = {
+ 'buyer_id': buyer_id,
+ 'rule': rule,
+ 'order_id': order_id,
+ 'item_id': item_id,
+ 'state': 'waiting_account', # waiting_account 或 waiting_confirm
+ 'account': None,
+ 'create_time': time.time(),
+ 'retry_count': 0
+ }
+
+ # 发送询问消息
+ ask_message = "请单独发送您的充值账号,不要有任何其他的文字。如果因为您输错的原因导致错误下单,概不退款。"
+ await self.send_msg(self.ws, chat_id, buyer_id, ask_message)
+ logger.info(f"已发送充值账号询问消息,等待用户回复")
+
+ # 返回特殊标记,表示需要等待用户输入
+ return "__WAITING_ACCOUNT__"
+
+ except Exception as e:
+ logger.error(f"询问充值账号异常: {self._safe_str(e)}")
+ return None
+
+ async def _replace_api_dynamic_params(self, params, order_id=None, item_id=None, buyer_id=None, spec_name=None, spec_value=None):
+ """替换API请求参数中的动态参数"""
+ try:
+ if not params or not isinstance(params, dict):
+ return params
+
+ # 获取订单和商品信息
+ order_info = None
+ item_info = None
+
+ # 如果有订单ID,获取订单信息
+ if order_id:
+ try:
+ from db_manager import db_manager
+ # 尝试从数据库获取订单信息
+ order_info = db_manager.get_order_by_id(order_id)
+ if not order_info:
+ # 如果数据库中没有,尝试通过API获取
+ order_detail = await self.fetch_order_detail_info(order_id, item_id, buyer_id)
+ if order_detail:
+ order_info = order_detail
+ logger.warning(f"通过API获取到订单信息: {order_id}")
+ else:
+ logger.warning(f"无法获取订单信息: {order_id}")
+ else:
+ logger.warning(f"从数据库获取到订单信息: {order_id}")
+ except Exception as e:
+ logger.warning(f"获取订单信息失败: {self._safe_str(e)}")
+
+ # 如果有商品ID,获取商品信息
+ if item_id:
+ try:
+ from db_manager import db_manager
+ item_info = db_manager.get_item_info(self.cookie_id, item_id)
+ if item_info:
+ logger.warning(f"从数据库获取到商品信息: {item_id}")
+ else:
+ logger.warning(f"无法获取商品信息: {item_id}")
+ except Exception as e:
+ logger.warning(f"获取商品信息失败: {self._safe_str(e)}")
+
+ # 构建参数映射
+ param_mapping = {
+ 'order_id': order_id or '',
+ 'item_id': item_id or '',
+ 'buyer_id': buyer_id or '',
+ 'cookie_id': self.cookie_id or '',
+ 'spec_name': spec_name or '',
+ 'spec_value': spec_value or '',
+ 'timestamp': str(int(time.time())),
+ }
+
+ # 从订单信息中提取参数
+ if order_info:
+ param_mapping.update({
+ 'order_amount': str(order_info.get('amount', '')),
+ 'order_quantity': str(order_info.get('quantity', '')),
+ })
+
+ # 从商品信息中提取参数
+ if item_info:
+ # 处理商品详情,如果是JSON字符串则提取detail字段
+ item_detail = item_info.get('item_detail', '')
+ if item_detail:
+ try:
+ # 尝试解析JSON
+ import json
+ detail_data = json.loads(item_detail)
+ if isinstance(detail_data, dict) and 'detail' in detail_data:
+ item_detail = detail_data['detail']
+ except (json.JSONDecodeError, TypeError):
+ # 如果不是JSON或解析失败,使用原始字符串
+ pass
+
+ param_mapping.update({
+ 'item_detail': item_detail,
+ })
+
+ # 递归替换参数
+ replaced_params = self._recursive_replace_params(params, param_mapping)
+
+ # 记录替换的参数
+ replaced_keys = []
+ for key, value in replaced_params.items():
+ if isinstance(value, str) and '{' in str(params.get(key, '')):
+ replaced_keys.append(key)
+
+ if replaced_keys:
+ logger.info(f"API动态参数替换完成,替换的参数: {replaced_keys}")
+ logger.warning(f"参数映射: {param_mapping}")
+
+ return replaced_params
+
+ except Exception as e:
+ logger.error(f"替换API动态参数失败: {self._safe_str(e)}")
+ return params
+
+ def _recursive_replace_params(self, obj, param_mapping):
+ """递归替换参数中的占位符"""
+ if isinstance(obj, dict):
+ result = {}
+ for key, value in obj.items():
+ result[key] = self._recursive_replace_params(value, param_mapping)
+ return result
+ elif isinstance(obj, list):
+ return [self._recursive_replace_params(item, param_mapping) for item in obj]
+ elif isinstance(obj, str):
+ # 替换字符串中的占位符
+ result = obj
+ for param_key, param_value in param_mapping.items():
+ placeholder = f"{{{param_key}}}"
+ if placeholder in result:
+ result = result.replace(placeholder, str(param_value))
+ return result
+ else:
+ return obj
+
+ async def token_refresh_loop(self):
+ """会话保活循环。轻量保活优先,重型恢复兜底。"""
+ try:
+ while True:
+ try:
+ # 检查账号是否启用
+ from cookie_manager import manager as cookie_manager
+ if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
+ logger.info(f"【{self.cookie_id}】账号已禁用,停止Token刷新循环")
+ break
+
+ current_time = time.time()
+ if current_time - self.last_session_keepalive_time >= self.session_keepalive_interval:
+ logger.info(f"【{self.cookie_id}】开始执行轻量会话保活...")
+ keepalive_ok = await self.keep_session_alive()
+ if keepalive_ok:
+ await self._interruptible_sleep(60)
+ continue
+
+ keepalive_status = getattr(self, 'last_session_keepalive_status', None)
+ if keepalive_status == "auth_failed":
+ logger.warning(f"【{self.cookie_id}】轻量保活鉴权失败,尝试执行重型Token恢复流程")
+ new_token = await self.refresh_token()
+ if new_token:
+ self.last_session_keepalive_time = time.time()
+ logger.info(f"【{self.cookie_id}】重型Token恢复成功,主动关闭旧WebSocket以使用新Token重连")
+ await self._force_websocket_reconnect("重型Token恢复成功,准备使用新Token重连")
+ break
+
+ last_refresh_status = getattr(self, 'last_token_refresh_status', None)
+ benign_refresh_statuses = ("skipped_cooldown", "restarted_after_cookie_refresh")
+ if last_refresh_status not in benign_refresh_statuses:
+ scheduled_error_message = self._build_scheduled_token_refresh_error_message(last_refresh_status)
+ await self.send_token_refresh_notification(
+ scheduled_error_message,
+ "token_scheduled_refresh_failed"
+ )
+ logger.warning(
+ f"【{self.cookie_id}】重型Token恢复失败(status={last_refresh_status}),"
+ f"{self.token_retry_interval} 秒后重试"
+ )
+ await self._interruptible_sleep(self.token_retry_interval)
+ else:
+ logger.warning(
+ f"【{self.cookie_id}】轻量保活失败(status={keepalive_status}),"
+ f"{self.session_keepalive_retry_interval} 秒后重试"
+ )
+ await self._interruptible_sleep(self.session_keepalive_retry_interval)
+ continue
+ await self._interruptible_sleep(60)
+ except asyncio.CancelledError:
+ # 收到取消信号,立即退出循环
+ logger.info(f"【{self.cookie_id}】Token刷新循环收到取消信号,准备退出")
+ raise
+ except Exception as e:
+ logger.error(f"Token刷新循环出错: {self._safe_str(e)}")
+ # 出错后也等待1分钟再重试,使用可中断的sleep
+ try:
+ await self._interruptible_sleep(60)
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】Token刷新循环在重试等待时收到取消信号,准备退出")
+ raise
+ except asyncio.CancelledError:
+ # 确保CancelledError被正确传播
+ logger.info(f"【{self.cookie_id}】Token刷新循环已取消,正在退出...")
+ raise
+ finally:
+ # 确保任务能正常结束
+ logger.info(f"【{self.cookie_id}】Token刷新循环已退出")
+
+ async def create_chat(self, ws, toid, item_id='891198795482'):
+ msg = {
+ "lwp": "/r/SingleChatConversation/create",
+ "headers": {
+ "mid": generate_mid()
+ },
+ "body": [
+ {
+ "pairFirst": f"{toid}@goofish",
+ "pairSecond": f"{self.myid}@goofish",
+ "bizType": "1",
+ "extension": {
+ "itemId": item_id
+ },
+ "ctx": {
+ "appVersion": "1.0",
+ "platform": "web"
+ }
+ }
+ ]
+ }
+ await ws.send(json.dumps(msg))
+
+ async def send_msg(self, ws, cid, toid, text):
+ text = {
+ "contentType": 1,
+ "text": {
+ "text": text
+ }
+ }
+ text_base64 = str(base64.b64encode(json.dumps(text).encode('utf-8')), 'utf-8')
+ msg = {
+ "lwp": "/r/MessageSend/sendByReceiverScope",
+ "headers": {
+ "mid": generate_mid()
+ },
+ "body": [
+ {
+ "uuid": generate_uuid(),
+ "cid": f"{cid}@goofish",
+ "conversationType": 1,
+ "content": {
+ "contentType": 101,
+ "custom": {
+ "type": 1,
+ "data": text_base64
+ }
+ },
+ "redPointPolicy": 0,
+ "extension": {
+ "extJson": "{}"
+ },
+ "ctx": {
+ "appVersion": "1.0",
+ "platform": "web"
+ },
+ "mtags": {},
+ "msgReadStatusSetting": 1
+ },
+ {
+ "actualReceivers": [
+ f"{toid}@goofish",
+ f"{self.myid}@goofish"
+ ]
+ }
+ ]
+ }
+ await ws.send(json.dumps(msg))
+
+ async def init(self, ws):
+ # 如果没有token或者token过期,获取新token
+ token_refresh_attempted = False
+ if not self.current_token or (time.time() - self.last_token_refresh_time) >= self.token_refresh_interval:
+ logger.info(f"【{self.cookie_id}】获取初始token...")
+ token_refresh_attempted = True
+
+ await self.refresh_token()
+
+ if not self.current_token:
+ self.last_init_failure_type = 'init_auth_failed'
+ self.last_init_failure_reason = self.last_token_refresh_status or 'token_missing_after_refresh'
+ logger.error(f"【{self.cookie_id}】无法获取有效token,初始化鉴权失败")
+ # 只有在没有尝试刷新token的情况下才发送通知,避免与refresh_token中的通知重复
+ if not token_refresh_attempted:
+ await self.send_token_refresh_notification("初始化时无法获取有效Token", "token_init_failed")
+ else:
+ logger.info(f"【{self.cookie_id}】由于刚刚尝试过token刷新,跳过重复的初始化失败通知")
+ raise InitAuthError(f"Token获取失败(status={self.last_init_failure_reason})")
+
+ self.last_init_failure_type = None
+ self.last_init_failure_reason = None
+ self.clear_init_auth_failure_state(self.cookie_id)
+ self.init_auth_failures = 0
+
+ msg = {
+ "lwp": "/reg",
+ "headers": {
+ "cache-header": "app-key token ua wv",
+ "app-key": APP_CONFIG.get('app_key'),
+ "token": self.current_token,
+ "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 DingTalk(2.1.5) OS(Windows/10) Browser(Chrome/133.0.0.0) DingWeb/2.1.5 IMPaaS DingWeb/2.1.5",
+ "dt": "j",
+ "wv": "im:3,au:3,sy:6",
+ "sync": "0,0;0;0;",
+ "did": self.device_id,
+ "mid": generate_mid()
+ }
+ }
+ await ws.send(json.dumps(msg))
+ await asyncio.sleep(1)
+ current_time = int(time.time() * 1000)
+ msg = {
+ "lwp": "/r/SyncStatus/ackDiff",
+ "headers": {"mid": generate_mid()},
+ "body": [
+ {
+ "pipeline": "sync",
+ "tooLong2Tag": "PNM,1",
+ "channel": "sync",
+ "topic": "sync",
+ "highPts": 0,
+ "pts": current_time * 1000,
+ "seq": 0,
+ "timestamp": current_time
+ }
+ ]
+ }
+ await ws.send(json.dumps(msg))
+ logger.info(f'【{self.cookie_id}】连接注册完成')
+
+ async def list_all_conversations(self, cid: str, page_size: int = 20):
+ """拉取指定会话的历史消息。"""
+ headers = self._build_websocket_headers()
+ async with await self._create_websocket_connection(headers) as websocket:
+ await self.init(websocket)
+ send_mid = generate_mid()
+ request_msg = {
+ "lwp": "/r/MessageManager/listUserMessages",
+ "headers": {
+ "mid": send_mid
+ },
+ "body": [
+ f"{cid}@goofish",
+ False,
+ 9007199254740991,
+ page_size,
+ False
+ ]
+ }
+ history_messages = []
+
+ async for raw_message in websocket:
+ try:
+ message = json.loads(raw_message)
+ except Exception:
+ continue
+
+ try:
+ ack = {
+ "code": 200,
+ "headers": {
+ "mid": message.get("headers", {}).get("mid", generate_mid()),
+ "sid": message.get("headers", {}).get("sid", ""),
+ }
+ }
+ if 'app-key' in message.get("headers", {}):
+ ack["headers"]["app-key"] = message["headers"]["app-key"]
+ if 'ua' in message.get("headers", {}):
+ ack["headers"]["ua"] = message["headers"]["ua"]
+ if 'dt' in message.get("headers", {}):
+ ack["headers"]["dt"] = message["headers"]["dt"]
+ await websocket.send(json.dumps(ack))
+ except Exception:
+ pass
+
+ try:
+ if message.get('lwp') == "/s/vulcan":
+ await websocket.send(json.dumps(request_msg))
+ continue
+
+ recv_mid = message.get("headers", {}).get("mid", "")
+ if recv_mid != send_mid:
+ continue
+
+ body = message.get("body", {})
+ has_more = body.get("hasMore") == 1
+ next_cursor = body.get("nextCursor")
+ for user_message in body.get("userMessageModels", []):
+ extension = user_message.get("message", {}).get("extension", {})
+ custom_content = user_message.get("message", {}).get("content", {}).get("custom", {})
+ send_message_base64 = custom_content.get("data", "")
+ parsed_message = None
+ if send_message_base64:
+ try:
+ parsed_message = json.loads(base64.b64decode(send_message_base64).decode('utf-8'))
+ except Exception:
+ parsed_message = {"raw": send_message_base64}
+
+ history_messages.insert(0, {
+ "send_user_id": extension.get("senderUserId", ""),
+ "send_user_name": extension.get("reminderTitle", ""),
+ "message": parsed_message,
+ })
+
+ if has_more:
+ send_mid = generate_mid()
+ request_msg["headers"]["mid"] = send_mid
+ request_msg["body"][2] = next_cursor
+ await websocket.send(json.dumps(request_msg))
+ else:
+ return history_messages
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】拉取历史消息时发生异常: {self._safe_str(e)}")
+ return history_messages
+
+ return []
+
+ async def send_heartbeat(self, ws):
+ """发送心跳包"""
+ # 检查WebSocket连接状态,如果已关闭则不发送
+ if ws.closed:
+ raise ConnectionError("WebSocket连接已关闭,无法发送心跳")
+
+ heartbeat_mid = generate_mid()
+ msg = {
+ "lwp": "/!",
+ "headers": {
+ "mid": heartbeat_mid
+ }
+ }
+ # 添加超时保护,避免在WebSocket关闭时阻塞
+ try:
+ self.last_sent_heartbeat_mid = heartbeat_mid
+ self.pending_heartbeat_mids.append(heartbeat_mid)
+ await asyncio.wait_for(ws.send(json.dumps(msg)), timeout=2.0)
+ self.last_heartbeat_time = time.time()
+ logger.warning(f"【{self.cookie_id}】心跳包已发送 [ID:{heartbeat_mid}]")
+ except asyncio.TimeoutError:
+ raise ConnectionError("心跳发送超时,WebSocket可能已断开")
+ except asyncio.CancelledError:
+ # 如果被取消,立即重新抛出,不执行后续操作
+ raise
+
+ async def heartbeat_loop(self, ws):
+ """心跳循环"""
+ consecutive_failures = 0
+ max_failures = 3 # 连续失败3次后停止心跳
+
+ try:
+ while True:
+ try:
+ # 检查账号是否启用
+ from cookie_manager import manager as cookie_manager
+ if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
+ logger.info(f"【{self.cookie_id}】账号已禁用,停止心跳循环")
+ break
+
+ # 检查WebSocket连接状态
+ if ws.closed:
+ logger.warning(f"【{self.cookie_id}】WebSocket连接已关闭,停止心跳循环")
+ break
+
+ await self.send_heartbeat(ws)
+ consecutive_failures = 0 # 重置失败计数
+
+ await self._interruptible_sleep(self.heartbeat_interval)
+
+ except asyncio.CancelledError:
+ # 收到取消信号,立即退出循环
+ logger.info(f"【{self.cookie_id}】心跳循环收到取消信号,准备退出")
+ raise # 重新抛出,让任务正常结束
+ except Exception as e:
+ consecutive_failures += 1
+ logger.error(f"心跳发送失败 ({consecutive_failures}/{max_failures}): {self._safe_str(e)}")
+
+ if consecutive_failures >= max_failures:
+ logger.error(f"【{self.cookie_id}】心跳连续失败{max_failures}次,停止心跳循环")
+ break
+
+ # 失败后短暂等待再重试,使用可中断的sleep
+ try:
+ await self._interruptible_sleep(5)
+ except asyncio.CancelledError:
+ # 在等待重试时收到取消信号,立即退出
+ logger.info(f"【{self.cookie_id}】心跳循环在重试等待时收到取消信号,准备退出")
+ raise
+ except asyncio.CancelledError:
+ # 确保CancelledError被正确传播
+ logger.info(f"【{self.cookie_id}】心跳循环已取消,正在退出...")
+ raise
+ finally:
+ # 确保任务能正常结束
+ logger.info(f"【{self.cookie_id}】心跳循环已退出")
+
+ async def handle_heartbeat_response(self, message_data):
+ """处理心跳响应"""
+ try:
+ if not isinstance(message_data, dict):
+ return False
+
+ if message_data.get("code") != 200:
+ return False
+
+ if self.is_sync_package(message_data):
+ return False
+
+ headers = message_data.get("headers")
+ if not isinstance(headers, dict):
+ return False
+
+ response_mid = str(headers.get("mid") or "")
+ if not response_mid or response_mid not in self.pending_heartbeat_mids:
+ return False
+
+ self.last_heartbeat_response = time.time()
+ try:
+ self.pending_heartbeat_mids.remove(response_mid)
+ except ValueError:
+ pass
+ logger.warning(f"【{self.cookie_id}】心跳响应正常 [ID:{response_mid}]")
+ return True
+ except Exception as e:
+ logger.error(f"处理心跳响应出错: {self._safe_str(e)}")
+ return False
+
+ async def pause_cleanup_loop(self):
+ """定期清理过期的暂停记录、锁和缓存"""
+ try:
+ while True:
+ try:
+ # 检查账号是否启用
+ from cookie_manager import manager as cookie_manager
+ if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
+ logger.info(f"【{self.cookie_id}】账号已禁用,停止清理循环")
+ break
+
+ # 清理过期的暂停记录
+ pause_manager.cleanup_expired_pauses()
+ await asyncio.sleep(0) # 让出控制权,允许检查取消信号
+
+ # 清理过期的锁(每5分钟清理一次,保留24小时内的锁)
+ self.cleanup_expired_locks(max_age_hours=24)
+ await asyncio.sleep(0) # 让出控制权,允许检查取消信号
+
+ # 清理过期的商品详情缓存
+ try:
+ cleaned_count = await self._cleanup_item_cache()
+ if cleaned_count > 0:
+ logger.info(f"【{self.cookie_id}】清理了 {cleaned_count} 个过期的商品详情缓存")
+ except asyncio.CancelledError:
+ raise
+ except Exception as cache_clean_e:
+ logger.warning(f"【{self.cookie_id}】清理商品详情缓存时出错: {cache_clean_e}")
+
+ # 清理过期的通知、发货和订单确认记录(防止内存泄漏)
+ self._cleanup_instance_caches()
+ await asyncio.sleep(0) # 让出控制权,允许检查取消信号
+
+ # 清理QR登录过期会话(每5分钟检查一次)
+ try:
+ from utils.qr_login import qr_login_manager
+ qr_login_manager.cleanup_expired_sessions()
+ await asyncio.sleep(0) # 让出控制权,允许检查取消信号
+ except asyncio.CancelledError:
+ raise
+ except Exception as qr_clean_e:
+ logger.warning(f"【{self.cookie_id}】清理QR登录会话时出错: {qr_clean_e}")
+
+ # 清理Playwright浏览器临时文件和缓存(每5分钟检查一次)
+ try:
+ await self._cleanup_playwright_cache()
+ except asyncio.CancelledError:
+ raise
+ except Exception as pw_clean_e:
+ logger.warning(f"【{self.cookie_id}】清理Playwright缓存时出错: {pw_clean_e}")
+
+ # 清理过期的日志文件(每5分钟检查一次,保留7天)
+ try:
+ cleaned_logs = await self._cleanup_old_logs(retention_days=7)
+ await asyncio.sleep(0) # 让出控制权,允许检查取消信号
+ except asyncio.CancelledError:
+ raise
+ except Exception as log_clean_e:
+ logger.warning(f"【{self.cookie_id}】清理日志文件时出错: {log_clean_e}")
+
+ # 清理超时仍处于processing的风控日志(每10分钟一次)
+ # 为避免所有实例同时执行,只让第一个实例执行
+ try:
+ if hasattr(self.__class__, '_last_risk_log_cleanup_time'):
+ last_risk_cleanup = self.__class__._last_risk_log_cleanup_time
+ else:
+ self.__class__._last_risk_log_cleanup_time = 0
+ last_risk_cleanup = 0
+
+ current_time = time.time()
+ if current_time - last_risk_cleanup > 600:
+ try:
+ cleaned_count = await asyncio.to_thread(
+ db_manager.mark_stale_risk_control_logs_failed,
+ timeout_minutes=15
+ )
+ if cleaned_count > 0:
+ logger.warning(f"【{self.cookie_id}】风控日志超时兜底清理完成,自动关闭 {cleaned_count} 条processing记录")
+ self.__class__._last_risk_log_cleanup_time = current_time
+ except asyncio.CancelledError:
+ logger.warning(f"【{self.cookie_id}】风控日志超时兜底清理被取消")
+ raise
+ except asyncio.CancelledError:
+ raise
+ except Exception as risk_clean_e:
+ logger.error(f"【{self.cookie_id}】清理超时风控日志时出错: {risk_clean_e}")
+
+ # 清理数据库历史数据(每天一次,保留90天数据)
+ # 为避免所有实例同时执行,只让第一个实例执行
+ try:
+ if hasattr(self.__class__, '_last_db_cleanup_time'):
+ last_cleanup = self.__class__._last_db_cleanup_time
+ else:
+ self.__class__._last_db_cleanup_time = 0
+ last_cleanup = 0
+
+ current_time = time.time()
+ # 每24小时清理一次
+ if current_time - last_cleanup > 86400:
+ logger.info(f"【{self.cookie_id}】开始执行数据库历史数据清理...")
+ # 数据库清理可能很耗时,使用线程池执行,避免阻塞事件循环
+ # 这样即使清理操作很慢,也能响应取消信号
+ try:
+ stats = await asyncio.to_thread(db_manager.cleanup_old_data, days=90)
+ if 'error' not in stats:
+ logger.info(f"【{self.cookie_id}】数据库清理完成: {stats}")
+ self.__class__._last_db_cleanup_time = current_time
+ else:
+ logger.error(f"【{self.cookie_id}】数据库清理失败: {stats['error']}")
+ except asyncio.CancelledError:
+ logger.warning(f"【{self.cookie_id}】数据库清理被取消")
+ raise
+ except asyncio.CancelledError:
+ raise # 重新抛出取消信号
+ except Exception as db_clean_e:
+ logger.error(f"【{self.cookie_id}】清理数据库历史数据时出错: {db_clean_e}")
+
+ # 每5分钟清理一次
+ await self._interruptible_sleep(300)
+ except asyncio.CancelledError:
+ # 收到取消信号,立即退出循环
+ logger.info(f"【{self.cookie_id}】清理循环收到取消信号,准备退出")
+ raise
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】清理任务失败: {self._safe_str(e)}")
+ # 出错后也等待5分钟再重试,使用可中断的sleep
+ try:
+ await self._interruptible_sleep(300)
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】清理循环在重试等待时收到取消信号,准备退出")
+ raise
+ except asyncio.CancelledError:
+ # 确保CancelledError被正确传播
+ logger.info(f"【{self.cookie_id}】清理循环已取消,正在退出...")
+ raise
+ finally:
+ # 确保任务能正常结束
+ logger.info(f"【{self.cookie_id}】清理循环已退出")
+
+
+ async def cookie_refresh_loop(self):
+ """Cookie刷新定时任务 - 每小时执行一次"""
+ try:
+ while True:
+ try:
+ # 检查账号是否启用
+ from cookie_manager import manager as cookie_manager
+ if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
+ logger.info(f"【{self.cookie_id}】账号已禁用,停止Cookie刷新循环")
+ break
+
+ # 检查Cookie刷新功能是否启用
+ if not self.cookie_refresh_enabled:
+ logger.warning(f"【{self.cookie_id}】Cookie刷新功能已禁用,跳过执行")
+ await self._interruptible_sleep(300) # 5分钟后再检查
+ continue
+
+ if self.is_manual_refresh_active(self.cookie_id):
+ logger.warning(f"【{self.cookie_id}】手动刷新进行中,跳过自动Cookie刷新")
+ await self._interruptible_sleep(60)
+ continue
+
+ current_time = time.time()
+ if current_time - self.last_cookie_refresh_time >= self.cookie_refresh_interval:
+ # 检查是否在消息接收后的冷却时间内
+ time_since_last_message = current_time - self.last_message_received_time
+ if time_since_last_message < self.message_cookie_refresh_cooldown:
+ remaining_time = self.message_cookie_refresh_cooldown - time_since_last_message
+ remaining_minutes = int(remaining_time // 60)
+ remaining_seconds = int(remaining_time % 60)
+ logger.warning(f"【{self.cookie_id}】收到消息后冷却中,还需等待 {remaining_minutes}分{remaining_seconds}秒 才能执行Cookie刷新")
+ # 检查是否已有Cookie刷新任务在执行
+ elif self.cookie_refresh_lock.locked():
+ logger.warning(f"【{self.cookie_id}】Cookie刷新任务已在执行中,跳过本次触发")
+ else:
+ logger.info(f"【{self.cookie_id}】开始执行Cookie刷新任务...")
+ # 在独立的任务中执行Cookie刷新,避免阻塞主循环
+ asyncio.create_task(self._execute_cookie_refresh(current_time))
+
+ # 每分钟检查一次是否需要执行
+ await self._interruptible_sleep(60)
+ except asyncio.CancelledError:
+ # 收到取消信号,立即退出循环
+ logger.info(f"【{self.cookie_id}】Cookie刷新循环收到取消信号,准备退出")
+ raise
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】Cookie刷新循环失败: {self._safe_str(e)}")
+ # 出错后也等待1分钟再重试,使用可中断的sleep
+ try:
+ await self._interruptible_sleep(60)
+ except asyncio.CancelledError:
+ logger.info(f"【{self.cookie_id}】Cookie刷新循环在重试等待时收到取消信号,准备退出")
+ raise
+ except asyncio.CancelledError:
+ # 确保CancelledError被正确传播
+ logger.info(f"【{self.cookie_id}】Cookie刷新循环已取消,正在退出...")
+ raise
+ finally:
+ # 确保任务能正常结束
+ logger.info(f"【{self.cookie_id}】Cookie刷新循环已退出")
+
+ async def _execute_cookie_refresh(self, current_time):
+ """独立执行Cookie刷新任务,避免阻塞主循环"""
+
+ # 使用Lock确保原子性,防止重复执行
+ async with self.cookie_refresh_lock:
+ try:
+ clear_message_received_flag = False
+ if self.is_manual_refresh_active(self.cookie_id):
+ logger.warning(f"【{self.cookie_id}】手动刷新进行中,取消当前自动Cookie刷新任务")
+ return
+
+ logger.info(f"【{self.cookie_id}】开始Cookie刷新任务,暂时暂停心跳以避免连接冲突...")
+
+ # 暂时暂停心跳任务,避免与浏览器操作冲突
+ heartbeat_was_running = False
+ if self.heartbeat_task and not self.heartbeat_task.done():
+ heartbeat_was_running = True
+ self.heartbeat_task.cancel()
+ logger.warning(f"【{self.cookie_id}】已暂停心跳任务")
+
+ # 为整个Cookie刷新任务添加超时保护(3分钟,缩短时间减少影响)
+ success = await asyncio.wait_for(
+ self._refresh_cookies_via_browser(),
+ timeout=180.0 # 3分钟超时,减少对WebSocket的影响
+ )
+
+ # 重新启动心跳任务
+ if heartbeat_was_running and self.ws and not self.ws.closed:
+ logger.warning(f"【{self.cookie_id}】重新启动心跳任务")
+ self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(self.ws))
+
+ if success:
+ self.last_cookie_refresh_time = current_time
+ logger.info(f"【{self.cookie_id}】Cookie刷新任务完成,心跳已恢复")
+
+ # 刷新成功后,验证Cookie有效性
+ logger.info(f"【{self.cookie_id}】开始验证刷新后的Cookie有效性...")
+ try:
+ validation_result = await self._verify_cookie_validity()
+
+ if not validation_result['valid']:
+ logger.warning(f"【{self.cookie_id}】❌ Cookie验证失败: {validation_result['details']}")
+ if validation_result.get('relogin_recommended', True):
+ logger.warning(f"【{self.cookie_id}】检测到Cookie可能无法用于关键API,尝试通过密码登录重新获取...")
+
+ # 触发密码登录刷新
+ password_refresh_success = await self._try_password_login_refresh("Cookie验证失败(关键API不可用)")
+
+ if password_refresh_success:
+ logger.info(f"【{self.cookie_id}】✅ 密码登录刷新成功,Cookie已更新")
+ clear_message_received_flag = True
+ else:
+ logger.warning(f"【{self.cookie_id}】⚠️ 密码登录刷新失败,Cookie可能仍然无效")
+ # 发送通知
+ await self.send_token_refresh_notification(
+ f"Cookie验证失败且密码登录刷新也失败\n验证详情: {validation_result['details']}",
+ "cookie_validation_failed"
+ )
+ else:
+ logger.warning(f"【{self.cookie_id}】Cookie验证失败,但当前错误更像网络/环境问题,跳过密码登录刷新")
+ else:
+ if validation_result.get('inconclusive'):
+ logger.warning(f"【{self.cookie_id}】⚠️ Cookie验证结果不确定,保留当前消息冷却标志,等待后续保活再次确认: {validation_result['details']}")
+ else:
+ logger.info(f"【{self.cookie_id}】✅ Cookie验证通过: {validation_result['details']}")
+ clear_message_received_flag = True
+
+ except Exception as verify_e:
+ logger.error(f"【{self.cookie_id}】Cookie验证过程异常: {self._safe_str(verify_e)}")
+ import traceback
+ logger.error(f"【{self.cookie_id}】详细堆栈:\n{traceback.format_exc()}")
+ else:
+ logger.warning(f"【{self.cookie_id}】Cookie刷新任务失败")
+ # 即使失败也要更新时间,避免频繁重试
+ self.last_cookie_refresh_time = current_time
+
+ except asyncio.TimeoutError:
+ # 超时也要更新时间,避免频繁重试
+ self.last_cookie_refresh_time = current_time
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】执行Cookie刷新任务异常: {self._safe_str(e)}")
+ # 异常也要更新时间,避免频繁重试
+ self.last_cookie_refresh_time = current_time
+ finally:
+ # 确保心跳任务恢复(如果WebSocket仍然连接)
+ if (self.ws and not self.ws.closed and
+ (not self.heartbeat_task or self.heartbeat_task.done())):
+ logger.info(f"【{self.cookie_id}】Cookie刷新完成,心跳任务正常运行")
+ self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(self.ws))
+
+ if clear_message_received_flag:
+ # 仅在刷新链路确认恢复可用后,才清空消息接收标志。
+ self.last_message_received_time = 0
+ logger.warning(f"【{self.cookie_id}】Cookie刷新完成,已清空消息接收标志")
+ else:
+ logger.warning(f"【{self.cookie_id}】Cookie刷新未确认恢复可用,保留消息接收标志")
+
+
+
+ def enable_cookie_refresh(self, enabled: bool = True):
+ """启用或禁用Cookie刷新功能"""
+ self.cookie_refresh_enabled = enabled
+ status = "启用" if enabled else "禁用"
+ logger.info(f"【{self.cookie_id}】Cookie刷新功能已{status}")
+
+
+ async def refresh_cookies_from_qr_login(self, qr_cookies_str: str, cookie_id: str = None, user_id: int = None):
+ """使用扫码登录获取的cookie访问指定界面获取真实cookie并存入数据库
+
+ Args:
+ qr_cookies_str: 扫码登录获取的cookie字符串
+ cookie_id: 可选的cookie ID,如果不提供则使用当前实例的cookie_id
+ user_id: 可选的用户ID,如果不提供则使用当前实例的user_id
+
+ Returns:
+ bool: 成功返回True,失败返回False
+ """
+ playwright = None
+ browser = None
+ target_cookie_id = cookie_id or self.cookie_id
+ target_user_id = user_id or self.user_id
+
+ try:
+ import asyncio
+ from playwright.async_api import async_playwright
+ from utils.xianyu_utils import trans_cookies
+
+ logger.info(f"【{target_cookie_id}】开始使用扫码登录cookie获取真实cookie...")
+ logger.info(f"【{target_cookie_id}】扫码cookie长度: {len(qr_cookies_str)}")
+
+ # 解析扫码登录的cookie
+ qr_cookies_dict = trans_cookies(qr_cookies_str)
+ logger.info(f"【{target_cookie_id}】扫码cookie字段数: {len(qr_cookies_dict)}")
+
+ # 使用统一的Playwright启动方法
+ playwright = await _start_playwright_safe(target_cookie_id)
+ if not playwright:
+ return False
+
+ # 启动浏览器(参照商品搜索的配置)
+ browser_args = [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--disable-accelerated-2d-canvas',
+ '--no-first-run',
+ '--no-zygote',
+ '--disable-gpu',
+ '--disable-background-timer-throttling',
+ '--disable-backgrounding-occluded-windows',
+ '--disable-renderer-backgrounding',
+ '--disable-features=TranslateUI',
+ '--disable-ipc-flooding-protection',
+ '--disable-extensions',
+ '--disable-default-apps',
+ '--disable-sync',
+ '--disable-translate',
+ '--hide-scrollbars',
+ '--mute-audio',
+ '--no-default-browser-check',
+ '--no-pings'
+ ]
+
+ # 在Docker环境中添加额外参数
+ if os.getenv('DOCKER_ENV'):
+ browser_args.extend([
+ # '--single-process', # 注释掉,避免多用户并发时的进程冲突和资源泄漏
+ '--disable-background-networking',
+ '--disable-client-side-phishing-detection',
+ '--disable-hang-monitor',
+ '--disable-popup-blocking',
+ '--disable-prompt-on-repost',
+ '--disable-web-resources',
+ '--metrics-recording-only',
+ '--safebrowsing-disable-auto-update',
+ '--enable-automation',
+ '--password-store=basic',
+ '--use-mock-keychain'
+ ])
+
+ # 使用无头浏览器
+ browser = await playwright.chromium.launch(
+ headless=True, # 改回无头模式
+ args=browser_args
+ )
+
+ # 创建浏览器上下文
+ context_options = {
+ 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
+ }
+
+ # 使用标准窗口大小
+ context_options['viewport'] = {'width': 1920, 'height': 1080}
+
+ context = await browser.new_context(**context_options)
+
+ # 设置扫码登录获取的Cookie
+ cookies = []
+ for cookie_pair in qr_cookies_str.split('; '):
+ if '=' in cookie_pair:
+ name, value = cookie_pair.split('=', 1)
+ cookies.append({
+ 'name': name.strip(),
+ 'value': value.strip(),
+ 'domain': '.goofish.com',
+ 'path': '/'
+ })
+
+ await context.add_cookies(cookies)
+ logger.info(f"【{target_cookie_id}】已设置 {len(cookies)} 个扫码Cookie到浏览器")
+
+ # 打印设置的扫码Cookie详情
+ logger.info(f"【{target_cookie_id}】=== 设置到浏览器的扫码Cookie ===")
+ for i, cookie in enumerate(cookies, 1):
+ logger.info(f"【{target_cookie_id}】{i:2d}. {cookie['name']}: {cookie['value'][:50]}{'...' if len(cookie['value']) > 50 else ''}")
+
+ # 创建页面
+ page = await context.new_page()
+
+ # 等待页面准备
+ await asyncio.sleep(0.1)
+
+ # 访问指定页面获取真实cookie
+ target_url = "https://www.goofish.com/im"
+ logger.info(f"【{target_cookie_id}】访问页面获取真实cookie: {target_url}")
+
+ # 使用更灵活的页面访问策略
+ try:
+ # 首先尝试较短超时
+ await page.goto(target_url, wait_until='domcontentloaded', timeout=15000)
+ logger.info(f"【{target_cookie_id}】页面访问成功")
+ except Exception as e:
+ if 'timeout' in str(e).lower():
+ logger.warning(f"【{target_cookie_id}】页面访问超时,尝试降级策略...")
+ try:
+ # 降级策略:只等待基本加载
+ await page.goto(target_url, wait_until='load', timeout=20000)
+ logger.info(f"【{target_cookie_id}】页面访问成功(降级策略)")
+ except Exception as e2:
+ logger.warning(f"【{target_cookie_id}】降级策略也失败,尝试最基本访问...")
+ # 最后尝试:不等待任何加载完成
+ await page.goto(target_url, timeout=25000)
+ logger.info(f"【{target_cookie_id}】页面访问成功(最基本策略)")
+ else:
+ raise e
+
+ # 等待页面完全加载并获取真实cookie
+ logger.info(f"【{target_cookie_id}】页面加载完成,等待获取真实cookie...")
+ await asyncio.sleep(2)
+
+ # 执行一次刷新以确保获取最新的cookie
+ logger.info(f"【{target_cookie_id}】执行页面刷新获取最新cookie...")
+ try:
+ await page.reload(wait_until='domcontentloaded', timeout=12000)
+ logger.info(f"【{target_cookie_id}】页面刷新成功")
+ except Exception as e:
+ if 'timeout' in str(e).lower():
+ logger.warning(f"【{target_cookie_id}】页面刷新超时,使用降级策略...")
+ await page.reload(wait_until='load', timeout=15000)
+ logger.info(f"【{target_cookie_id}】页面刷新成功(降级策略)")
+ else:
+ raise e
+ await asyncio.sleep(1)
+
+ # 获取更新后的真实Cookie
+ logger.info(f"【{target_cookie_id}】获取真实Cookie...")
+ updated_cookies = await context.cookies()
+
+ # 构造新的Cookie字典
+ real_cookies_dict = {}
+ for cookie in updated_cookies:
+ real_cookies_dict[cookie['name']] = cookie['value']
+
+ # 现有账号不要直接整包覆盖旧Cookie,保留扫码前已经存在但本次页面未返回的字段
+ from db_manager import db_manager
+ existing_cookie = db_manager.get_cookie_details(target_cookie_id)
+ existing_cookie_value = self._extract_cookie_value(existing_cookie)
+ existing_cookies_dict = {}
+ if existing_cookie_value:
+ try:
+ existing_cookies_dict = trans_cookies(existing_cookie_value) or {}
+ except Exception as merge_e:
+ logger.warning(f"【{target_cookie_id}】解析现有账号Cookie失败,按空基线继续: {self._safe_str(merge_e)}")
+
+ merge_result = self.protected_merge_cookie_dicts(existing_cookies_dict, real_cookies_dict)
+ real_cookies_dict = merge_result['merged_cookies_dict']
+ if target_cookie_id == self.cookie_id:
+ self._log_protected_merge_event("qr_login_protected_merge", merge_result)
+ else:
+ logger.info(
+ f"【{target_cookie_id}】qr_login_protected_merge "
+ f"incoming_count={merge_result.get('incoming_count', 0)} "
+ f"existing_count={merge_result.get('existing_count', 0)} "
+ f"merged_count={merge_result.get('merged_count', 0)} "
+ f"protected_preserved_fields={merge_result.get('preserved_protected_fields') or []} "
+ f"would_remove_fields={merge_result.get('would_remove_fields') or []} "
+ f"account_switched={merge_result.get('account_switched', False)}"
+ )
+ if merge_result['updated_fields']:
+ logger.info(f"【{target_cookie_id}】扫码登录合并更新Cookie字段: {', '.join(merge_result['updated_fields'])}")
+ if merge_result['preserved_fields']:
+ logger.info(f"【{target_cookie_id}】扫码登录保留现有Cookie字段 ({len(merge_result['preserved_fields'])}个): {', '.join(merge_result['preserved_fields'])}")
+ if merge_result['preserved_protected_fields']:
+ logger.warning(f"【{target_cookie_id}】扫码登录保护性保留关键字段: {', '.join(merge_result['preserved_protected_fields'])}")
+ if merge_result['account_switched']:
+ logger.warning(f"【{target_cookie_id}】扫码登录检测到unb变化,按账号切换处理,不保留旧账号Cookie字段")
+
+ missing_required_fields = merge_result['missing_required_fields']
+ if missing_required_fields:
+ logger.error(f"【{target_cookie_id}】扫码登录真实Cookie仍缺失核心字段,放弃保存: {', '.join(missing_required_fields)}")
+ return False
+
+ # 生成真实cookie字符串
+ real_cookies_str = '; '.join([f"{k}={v}" for k, v in real_cookies_dict.items()])
+
+ logger.info(f"【{target_cookie_id}】真实Cookie已获取,包含 {len(real_cookies_dict)} 个字段")
+
+ # 打印扫码登录获取的真实Cookie字段详情
+ logger.info(f"【{target_cookie_id}】========== 扫码登录真实Cookie字段详情 ==========")
+ logger.info(f"【{target_cookie_id}】Cookie字段数: {len(real_cookies_dict)}")
+ logger.info(f"【{target_cookie_id}】Cookie字段列表:")
+ for i, (key, value) in enumerate(real_cookies_dict.items(), 1):
+ if len(str(value)) > 50:
+ logger.info(f"【{target_cookie_id}】 {i:2d}. {key}: {str(value)[:30]}...{str(value)[-20:]} (长度: {len(str(value))})")
+ else:
+ logger.info(f"【{target_cookie_id}】 {i:2d}. {key}: {value}")
+
+ # 检查关键字段
+ important_keys = ['unb', '_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'cna']
+ logger.info(f"【{target_cookie_id}】关键字段检查:")
+ for key in important_keys:
+ if key in real_cookies_dict:
+ val = real_cookies_dict[key]
+ logger.info(f"【{target_cookie_id}】 ✅ {key}: {'存在' if val else '为空'} (长度: {len(str(val)) if val else 0})")
+ else:
+ logger.info(f"【{target_cookie_id}】 ❌ {key}: 缺失")
+ logger.info(f"【{target_cookie_id}】==========================================")
+
+ # 打印完整的真实Cookie内容
+ logger.info(f"【{target_cookie_id}】=== 真实Cookie摘要 ===")
+ logger.info(f"【{target_cookie_id}】Cookie字符串长度: {len(real_cookies_str)}")
+ logger.info(f"【{target_cookie_id}】Cookie摘要: {self._summarize_cookie_string(real_cookies_str)}")
+
+ # 打印所有Cookie字段的详细信息
+ logger.info(f"【{target_cookie_id}】=== Cookie字段详细信息 ===")
+ for i, (name, value) in enumerate(real_cookies_dict.items(), 1):
+ # 对于长值,显示前后部分
+ if len(value) > 50:
+ display_value = f"{value[:20]}...{value[-20:]}"
+ else:
+ display_value = value
+ logger.info(f"【{target_cookie_id}】{i:2d}. {name}: {display_value}")
+
+ # 打印原始扫码Cookie对比
+ logger.info(f"【{target_cookie_id}】=== 扫码Cookie对比 ===")
+ logger.info(f"【{target_cookie_id}】扫码Cookie长度: {len(qr_cookies_str)}")
+ logger.info(f"【{target_cookie_id}】扫码Cookie字段数: {len(qr_cookies_dict)}")
+ logger.info(f"【{target_cookie_id}】真实Cookie长度: {len(real_cookies_str)}")
+ logger.info(f"【{target_cookie_id}】真实Cookie字段数: {len(real_cookies_dict)}")
+ logger.info(f"【{target_cookie_id}】长度增加: {len(real_cookies_str) - len(qr_cookies_str)} 字符")
+ logger.info(f"【{target_cookie_id}】字段增加: {len(real_cookies_dict) - len(qr_cookies_dict)} 个")
+
+ # 检查Cookie变化
+ changed_cookies = []
+ new_cookies = []
+ for name, new_value in real_cookies_dict.items():
+ old_value = qr_cookies_dict.get(name)
+ if old_value is None:
+ new_cookies.append(name)
+ elif old_value != new_value:
+ changed_cookies.append(name)
+
+ # 显示Cookie变化统计
+ if changed_cookies:
+ logger.info(f"【{target_cookie_id}】发生变化的Cookie字段 ({len(changed_cookies)}个): {', '.join(changed_cookies)}")
+ if new_cookies:
+ logger.info(f"【{target_cookie_id}】新增的Cookie字段 ({len(new_cookies)}个): {', '.join(new_cookies)}")
+ if not changed_cookies and not new_cookies:
+ logger.info(f"【{target_cookie_id}】Cookie无变化")
+
+ # 打印重要Cookie字段的完整详情
+ important_cookies = ['_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'unb', 'uc1', 'uc3', 'uc4']
+ logger.info(f"【{target_cookie_id}】=== 重要Cookie字段完整详情 ===")
+ for cookie_name in important_cookies:
+ if cookie_name in real_cookies_dict:
+ cookie_value = real_cookies_dict[cookie_name]
+
+ # 标记是否发生了变化
+ change_mark = " [已变化]" if cookie_name in changed_cookies else " [新增]" if cookie_name in new_cookies else " [无变化]"
+
+ # 显示完整的cookie值
+ logger.info(f"【{target_cookie_id}】{cookie_name}{change_mark}:")
+ logger.info(f"【{target_cookie_id}】 值: {self._mask_secret_value(cookie_value, head=8, tail=6)}")
+ logger.info(f"【{target_cookie_id}】 长度: {len(cookie_value)}")
+
+ # 如果有对应的扫码cookie值,显示对比
+ if cookie_name in qr_cookies_dict:
+ old_value = qr_cookies_dict[cookie_name]
+ if old_value != cookie_value:
+ logger.info(f"【{target_cookie_id}】 原值: {self._mask_secret_value(old_value, head=8, tail=6)}")
+ logger.info(f"【{target_cookie_id}】 原长度: {len(old_value)}")
+ logger.info(f"【{target_cookie_id}】 ---")
+ else:
+ logger.info(f"【{target_cookie_id}】{cookie_name}: [不存在]")
+
+ # 保存真实Cookie到数据库
+ # 检查是否为新账号
+ existing_cookie = db_manager.get_cookie_details(target_cookie_id)
+ if existing_cookie:
+ # 现有账号,使用 update_cookie_account_info 避免覆盖其他字段(如 pause_duration, remark 等)
+ success = db_manager.update_cookie_account_info(target_cookie_id, cookie_value=real_cookies_str)
+ else:
+ # 新账号,使用 save_cookie
+ success = db_manager.save_cookie(target_cookie_id, real_cookies_str, target_user_id)
+
+ if success:
+ logger.info(f"【{target_cookie_id}】真实Cookie已成功保存到数据库")
+
+ # 如果当前实例的cookie_id匹配,更新实例的cookie信息
+ if target_cookie_id == self.cookie_id:
+ self._set_runtime_cookie_state(
+ cookies_str=real_cookies_str,
+ cookies_dict=real_cookies_dict,
+ source="qr_login_refresh",
+ )
+ logger.info(f"【{target_cookie_id}】已更新当前实例的Cookie信息")
+
+ # 更新扫码登录Cookie刷新时间标志
+ self.last_qr_cookie_refresh_time = time.time()
+ logger.info(f"【{target_cookie_id}】已更新扫码登录Cookie刷新时间标志,_refresh_cookies_via_browser将等待{self.qr_cookie_refresh_cooldown//60}分钟后执行")
+
+ return True
+ else:
+ logger.error(f"【{target_cookie_id}】保存真实Cookie到数据库失败")
+ return False
+
+ except Exception as e:
+ logger.error(f"【{target_cookie_id}】使用扫码cookie获取真实cookie失败: {self._safe_str(e)}")
+ return False
+ finally:
+ # 确保资源清理
+ try:
+ # 先关闭浏览器,再关闭Playwright(顺序很重要)
+ if browser:
+ try:
+ await asyncio.wait_for(browser.close(), timeout=5.0)
+ logger.warning(f"【{target_cookie_id}】浏览器关闭完成")
+ except asyncio.TimeoutError:
+ logger.warning(f"【{target_cookie_id}】浏览器关闭超时(5秒),资源可能未完全释放")
+ # 尝试取消浏览器相关的任务
+ try:
+ if hasattr(browser, '_connection'):
+ browser._connection = None
+ except Exception:
+ pass
+ except Exception as e:
+ logger.warning(f"【{target_cookie_id}】关闭浏览器时出错: {self._safe_str(e)}")
+
+ # Playwright关闭:使用更短的超时,超时后立即放弃
+ if playwright:
+ try:
+ logger.warning(f"【{target_cookie_id}】正在关闭Playwright...")
+ await asyncio.wait_for(playwright.stop(), timeout=2.0)
+ logger.warning(f"【{target_cookie_id}】Playwright关闭完成")
+ except asyncio.TimeoutError:
+ logger.warning(f"【{target_cookie_id}】Playwright关闭超时(2秒),进程可能仍在运行")
+ logger.warning(f"【{target_cookie_id}】提示:如果后续Playwright启动失败,可能需要手动清理残留进程")
+ # 尝试清理Playwright的内部状态
+ try:
+ # 取消可能正在运行的Playwright任务
+ if hasattr(playwright, '_transport'):
+ playwright._transport = None
+ except Exception:
+ pass
+ except Exception as e:
+ logger.warning(f"【{target_cookie_id}】关闭Playwright时出错: {self._safe_str(e)}")
+ except Exception as cleanup_e:
+ logger.warning(f"【{target_cookie_id}】清理浏览器资源时出错: {self._safe_str(cleanup_e)}")
+
+ async def _refresh_cookies_via_browser_page(self, current_cookies_str: str, restart_on_success: bool = True):
+ """使用当前cookie访问指定页面获取真实cookie并更新
+
+ 这是令牌过期时的备用刷新方案,类似于refresh_cookies_from_qr_login,
+ 但使用当前的cookie而不是扫码登录的cookie。
+
+ Args:
+ current_cookies_str: 当前的cookie字符串
+ restart_on_success: 成功后是否立即重启任务。扫码登录后的首轮缓冲只需要稳定 Cookie,不应直接重启。
+
+ Returns:
+ bool: 成功返回True,失败返回False
+ """
+ playwright = None
+ browser = None
+
+ try:
+ import asyncio
+ from playwright.async_api import async_playwright
+ from utils.xianyu_utils import trans_cookies
+
+ logger.info(f"【{self.cookie_id}】开始使用当前cookie访问指定页面获取真实cookie...")
+ logger.info(f"【{self.cookie_id}】当前cookie长度: {len(current_cookies_str)}")
+
+ # 解析当前的cookie
+ current_cookies_dict = trans_cookies(current_cookies_str)
+ logger.info(f"【{self.cookie_id}】当前cookie字段数: {len(current_cookies_dict)}")
+
+ # 使用统一的Playwright启动方法
+ playwright = await _start_playwright_safe(self.cookie_id)
+ if not playwright:
+ return False
+
+ # 启动浏览器(参照商品搜索的配置)
+ browser_args = [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--disable-accelerated-2d-canvas',
+ '--no-first-run',
+ '--no-zygote',
+ '--disable-gpu',
+ '--disable-background-timer-throttling',
+ '--disable-backgrounding-occluded-windows',
+ '--disable-renderer-backgrounding',
+ '--disable-features=TranslateUI',
+ '--disable-ipc-flooding-protection',
+ '--disable-extensions',
+ '--disable-default-apps',
+ '--disable-sync',
+ '--disable-translate',
+ '--hide-scrollbars',
+ '--mute-audio',
+ '--no-default-browser-check',
+ '--no-pings'
+ ]
+
+ # 在Docker环境中添加额外参数
+ if os.getenv('DOCKER_ENV'):
+ browser_args.extend([
+ '--disable-background-networking',
+ '--disable-client-side-phishing-detection',
+ '--disable-hang-monitor',
+ '--disable-popup-blocking',
+ '--disable-prompt-on-repost',
+ '--disable-web-resources',
+ '--metrics-recording-only',
+ '--safebrowsing-disable-auto-update',
+ '--enable-automation',
+ '--password-store=basic',
+ '--use-mock-keychain'
+ ])
+
+ # 使用无头浏览器
+ browser = await playwright.chromium.launch(
+ headless=True,
+ args=browser_args
+ )
+
+ # 创建浏览器上下文
+ context_options = {
+ 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
+ }
+
+ # 使用标准窗口大小
+ context_options['viewport'] = {'width': 1920, 'height': 1080}
+
+ context = await browser.new_context(**context_options)
+
+ # 设置当前的Cookie
+ cookies = []
+ for cookie_pair in current_cookies_str.split('; '):
+ if '=' in cookie_pair:
+ name, value = cookie_pair.split('=', 1)
+ cookies.append({
+ 'name': name.strip(),
+ 'value': value.strip(),
+ 'domain': '.goofish.com',
+ 'path': '/'
+ })
+
+ await context.add_cookies(cookies)
+ logger.info(f"【{self.cookie_id}】已设置 {len(cookies)} 个当前Cookie到浏览器")
+
+ # 创建页面
+ page = await context.new_page()
+
+ # 等待页面准备
+ await asyncio.sleep(0.1)
+
+ # 访问指定页面获取真实cookie
+ target_url = "https://www.goofish.com/im"
+ logger.info(f"【{self.cookie_id}】访问页面获取真实cookie: {target_url}")
+
+ # 使用更灵活的页面访问策略
+ try:
+ # 首先尝试较短超时
+ await page.goto(target_url, wait_until='domcontentloaded', timeout=15000)
+ logger.info(f"【{self.cookie_id}】页面访问成功")
+ except Exception as e:
+ if 'timeout' in str(e).lower():
+ logger.warning(f"【{self.cookie_id}】页面访问超时,尝试降级策略...")
+ try:
+ # 降级策略:只等待基本加载
+ await page.goto(target_url, wait_until='load', timeout=20000)
+ logger.info(f"【{self.cookie_id}】页面访问成功(降级策略)")
+ except Exception as e2:
+ logger.warning(f"【{self.cookie_id}】降级策略也失败,尝试最基本访问...")
+ # 最后尝试:不等待任何加载完成
+ await page.goto(target_url, timeout=25000)
+ logger.info(f"【{self.cookie_id}】页面访问成功(最基本策略)")
+ else:
+ raise e
+
+ # 等待页面完全加载并获取真实cookie
+ logger.info(f"【{self.cookie_id}】页面加载完成,等待获取真实cookie...")
+ await asyncio.sleep(2)
+
+ # 执行一次刷新以确保获取最新的cookie
+ logger.info(f"【{self.cookie_id}】执行页面刷新获取最新cookie...")
+ try:
+ await page.reload(wait_until='domcontentloaded', timeout=12000)
+ logger.info(f"【{self.cookie_id}】页面刷新成功")
+ except Exception as e:
+ if 'timeout' in str(e).lower():
+ logger.warning(f"【{self.cookie_id}】页面刷新超时,使用降级策略...")
+ await page.reload(wait_until='load', timeout=15000)
+ logger.info(f"【{self.cookie_id}】页面刷新成功(降级策略)")
+ else:
+ raise e
+ await asyncio.sleep(1)
+
+ # 获取更新后的真实Cookie
+ logger.info(f"【{self.cookie_id}】获取真实Cookie...")
+ updated_cookies = await context.cookies()
+
+ # 构造新的Cookie字典
+ real_cookies_dict = {}
+ for cookie in updated_cookies:
+ real_cookies_dict[cookie['name']] = cookie['value']
+
+ merge_result = self.protected_merge_cookie_dicts(current_cookies_dict, real_cookies_dict)
+ real_cookies_dict = merge_result['merged_cookies_dict']
+ self._log_protected_merge_event("browser_stabilization_protected_merge", merge_result)
+
+ # 生成真实cookie字符串
+ real_cookies_str = '; '.join([f"{k}={v}" for k, v in real_cookies_dict.items()])
+
+ logger.info(f"【{self.cookie_id}】真实Cookie已获取,包含 {len(real_cookies_dict)} 个字段")
+ logger.info(f"【{self.cookie_id}】真实Cookie摘要: {self._summarize_cookie_string(real_cookies_str)}")
+
+ self._log_cookie_merge_summary(
+ real_cookies_dict,
+ merge_result['updated_fields'],
+ merge_result['changed_fields'],
+ merge_result['new_fields'],
+ context="浏览器稳定化Cookie",
+ preserved_fields=merge_result['preserved_fields'],
+ preserved_protected_fields=merge_result['preserved_protected_fields'],
+ would_remove_fields=merge_result['would_remove_fields'],
+ removed_fields=merge_result['removed_fields'],
+ missing_protected_fields=merge_result['missing_protected_fields'],
+ missing_required_fields=merge_result['missing_required_fields'],
+ incoming_missing_protected_fields=merge_result['incoming_missing_protected_fields'],
+ account_switched=merge_result['account_switched'],
+ )
+
+ if merge_result['missing_required_fields']:
+ logger.error(f"【{self.cookie_id}】浏览器稳定化后的Cookie仍缺失核心字段,放弃写回数据库: {', '.join(merge_result['missing_required_fields'])}")
+ return False
+
+ # 检查Cookie是否有有效更新
+ changed_cookies = []
+ new_cookies = []
+ for name, new_value in real_cookies_dict.items():
+ old_value = current_cookies_dict.get(name)
+ if old_value is None:
+ new_cookies.append(name)
+ elif old_value != new_value:
+ changed_cookies.append(name)
+
+ if not changed_cookies and not new_cookies:
+ if restart_on_success:
+ logger.warning(f"【{self.cookie_id}】Cookie无变化,可能当前cookie已失效")
+ return False
+ logger.info(f"【{self.cookie_id}】Cookie字段无变化,但浏览器稳定化访问已完成")
+
+ logger.info(f"【{self.cookie_id}】发生变化的Cookie字段 ({len(changed_cookies)}个): {', '.join(changed_cookies[:10])}")
+ if new_cookies:
+ logger.info(f"【{self.cookie_id}】新增的Cookie字段 ({len(new_cookies)}个): {', '.join(new_cookies[:10])}")
+
+ if restart_on_success:
+ # 更新Cookie并重启任务
+ logger.info(f"【{self.cookie_id}】开始更新Cookie并重启任务...")
+ update_success = await self._update_cookies_and_restart(real_cookies_str)
+
+ if update_success:
+ logger.info(f"【{self.cookie_id}】通过访问指定页面成功更新Cookie并重启任务")
+ return True
+ else:
+ logger.error(f"【{self.cookie_id}】更新Cookie或重启任务失败")
+ return False
+
+ old_cookies_str = self.cookies_str
+ old_cookies_dict = self.cookies.copy()
+ try:
+ self._set_runtime_cookie_state(
+ cookies_str=real_cookies_str,
+ cookies_dict=real_cookies_dict,
+ source="stabilize_cookie_snapshot",
+ )
+ await self.update_config_cookies()
+ logger.info(f"【{self.cookie_id}】通过访问指定页面成功稳定当前Cookie(不重启任务)")
+ return True
+ except Exception as update_e:
+ self._set_runtime_cookie_state(
+ cookies_str=old_cookies_str,
+ cookies_dict=old_cookies_dict,
+ source="stabilize_cookie_snapshot_rollback",
+ )
+ logger.error(f"【{self.cookie_id}】稳定Cookie时更新数据库失败: {self._safe_str(update_e)}")
+ return False
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】使用当前cookie访问指定页面获取真实cookie失败: {self._safe_str(e)}")
+ return False
+ finally:
+ # 确保资源清理
+ try:
+ # 先关闭浏览器,再关闭Playwright(顺序很重要)
+ if browser:
+ try:
+ await asyncio.wait_for(browser.close(), timeout=5.0)
+ logger.warning(f"【{self.cookie_id}】浏览器关闭完成")
+ except asyncio.TimeoutError:
+ logger.warning(f"【{self.cookie_id}】浏览器关闭超时(5秒),资源可能未完全释放")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】关闭浏览器时出错: {self._safe_str(e)}")
+
+ # Playwright关闭:使用更短的超时,超时后立即放弃
+ if playwright:
+ try:
+ logger.warning(f"【{self.cookie_id}】正在关闭Playwright...")
+ await asyncio.wait_for(playwright.stop(), timeout=2.0)
+ logger.warning(f"【{self.cookie_id}】Playwright关闭完成")
+ except asyncio.TimeoutError:
+ logger.warning(f"【{self.cookie_id}】Playwright关闭超时(2秒),进程可能仍在运行")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】关闭Playwright时出错: {self._safe_str(e)}")
+ except Exception as cleanup_e:
+ logger.warning(f"【{self.cookie_id}】清理浏览器资源时出错: {self._safe_str(cleanup_e)}")
+
+ def reset_qr_cookie_refresh_flag(self):
+ """重置扫码登录Cookie刷新标志,允许立即执行_refresh_cookies_via_browser"""
+ self.last_qr_cookie_refresh_time = 0
+ logger.info(f"【{self.cookie_id}】已重置扫码登录Cookie刷新标志")
+
+ def get_qr_cookie_refresh_remaining_time(self) -> int:
+ """获取扫码登录Cookie刷新剩余冷却时间(秒)"""
+ current_time = time.time()
+ time_since_qr_refresh = current_time - self.last_qr_cookie_refresh_time
+ remaining_time = max(0, self.qr_cookie_refresh_cooldown - time_since_qr_refresh)
+ return int(remaining_time)
+
+ async def _refresh_cookies_via_browser(self, triggered_by_refresh_token: bool = False):
+ """通过浏览器访问指定页面刷新Cookie
+
+ Args:
+ triggered_by_refresh_token: 是否由refresh_token方法触发,如果是True则设置browser_cookie_refreshed标志
+ """
+
+
+ playwright = None
+ browser = None
+ try:
+ import asyncio
+ from playwright.async_api import async_playwright
+
+ # 检查是否需要等待扫码登录Cookie刷新的冷却时间
+ current_time = time.time()
+ time_since_qr_refresh = current_time - self.last_qr_cookie_refresh_time
+
+ if time_since_qr_refresh < self.qr_cookie_refresh_cooldown:
+ remaining_time = self.qr_cookie_refresh_cooldown - time_since_qr_refresh
+ remaining_minutes = int(remaining_time // 60)
+ remaining_seconds = int(remaining_time % 60)
+
+ logger.info(f"【{self.cookie_id}】扫码登录Cookie刷新冷却中,还需等待 {remaining_minutes}分{remaining_seconds}秒")
+ logger.info(f"【{self.cookie_id}】跳过本次浏览器Cookie刷新")
+ return False
+
+ logger.info(f"【{self.cookie_id}】开始通过浏览器刷新Cookie...")
+ logger.info(f"【{self.cookie_id}】刷新前Cookie长度: {len(self.cookies_str)}")
+ logger.info(f"【{self.cookie_id}】刷新前Cookie字段数: {len(self.cookies)}")
+
+ # 使用统一的Playwright启动方法
+ playwright = await _start_playwright_safe(self.cookie_id)
+ if not playwright:
+ return False
+
+ # 启动浏览器(参照商品搜索的配置)
+ browser_args = [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--disable-accelerated-2d-canvas',
+ '--no-first-run',
+ '--no-zygote',
+ '--disable-gpu',
+ '--disable-background-timer-throttling',
+ '--disable-backgrounding-occluded-windows',
+ '--disable-renderer-backgrounding',
+ '--disable-features=TranslateUI',
+ '--disable-ipc-flooding-protection',
+ '--disable-extensions',
+ '--disable-default-apps',
+ '--disable-sync',
+ '--disable-translate',
+ '--hide-scrollbars',
+ '--mute-audio',
+ '--no-default-browser-check',
+ '--no-pings'
+ ]
+
+ # 在Docker环境中添加额外参数
+ if os.getenv('DOCKER_ENV'):
+ browser_args.extend([
+ # '--single-process', # 注释掉,避免多用户并发时的进程冲突和资源泄漏
+ '--disable-background-networking',
+ '--disable-client-side-phishing-detection',
+ '--disable-hang-monitor',
+ '--disable-popup-blocking',
+ '--disable-prompt-on-repost',
+ '--disable-web-resources',
+ '--metrics-recording-only',
+ '--safebrowsing-disable-auto-update',
+ '--enable-automation',
+ '--password-store=basic',
+ '--use-mock-keychain'
+ ])
+
+ # Cookie刷新模式使用无头浏览器
+ browser = await playwright.chromium.launch(
+ headless=True,
+ args=browser_args
+ )
+
+ # 创建浏览器上下文
+ context_options = {
+ 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'
+ }
+
+ # 使用标准窗口大小
+ context_options['viewport'] = {'width': 1920, 'height': 1080}
+
+ context = await browser.new_context(**context_options)
+
+ # 设置当前Cookie
+ cookies = []
+ for cookie_pair in self.cookies_str.split('; '):
+ if '=' in cookie_pair:
+ name, value = cookie_pair.split('=', 1)
+ cookies.append({
+ 'name': name.strip(),
+ 'value': value.strip(),
+ 'domain': '.goofish.com',
+ 'path': '/'
+ })
+
+ await context.add_cookies(cookies)
+ logger.info(f"【{self.cookie_id}】已设置 {len(cookies)} 个Cookie到浏览器")
+
+ # 创建页面
+ page = await context.new_page()
+
+ # 等待页面准备
+ await asyncio.sleep(0.1)
+
+ # 访问指定页面
+ target_url = "https://www.goofish.com/im"
+ logger.info(f"【{self.cookie_id}】访问页面: {target_url}")
+
+ # 使用更灵活的页面访问策略
+ try:
+ # 首先尝试较短超时
+ await page.goto(target_url, wait_until='domcontentloaded', timeout=15000)
+ logger.info(f"【{self.cookie_id}】页面访问成功")
+ except Exception as e:
+ if 'timeout' in str(e).lower():
+ logger.warning(f"【{self.cookie_id}】页面访问超时,尝试降级策略...")
+ try:
+ # 降级策略:只等待基本加载
+ await page.goto(target_url, wait_until='load', timeout=20000)
+ logger.info(f"【{self.cookie_id}】页面访问成功(降级策略)")
+ except Exception as e2:
+ logger.warning(f"【{self.cookie_id}】降级策略也失败,尝试最基本访问...")
+ # 最后尝试:不等待任何加载完成
+ await page.goto(target_url, timeout=25000)
+ logger.info(f"【{self.cookie_id}】页面访问成功(最基本策略)")
+ else:
+ raise e
+
+ # Cookie刷新模式:执行两次刷新
+ logger.info(f"【{self.cookie_id}】页面加载完成,开始刷新...")
+ await asyncio.sleep(1)
+
+ # 第一次刷新 - 带重试机制
+ logger.info(f"【{self.cookie_id}】执行第一次刷新...")
+ try:
+ await page.reload(wait_until='domcontentloaded', timeout=12000)
+ logger.info(f"【{self.cookie_id}】第一次刷新成功")
+ except Exception as e:
+ if 'timeout' in str(e).lower():
+ logger.warning(f"【{self.cookie_id}】第一次刷新超时,使用降级策略...")
+ await page.reload(wait_until='load', timeout=15000)
+ logger.info(f"【{self.cookie_id}】第一次刷新成功(降级策略)")
+ else:
+ raise e
+ await asyncio.sleep(1)
+
+ # 第二次刷新 - 带重试机制
+ logger.info(f"【{self.cookie_id}】执行第二次刷新...")
+ try:
+ await page.reload(wait_until='domcontentloaded', timeout=12000)
+ logger.info(f"【{self.cookie_id}】第二次刷新成功")
+ except Exception as e:
+ if 'timeout' in str(e).lower():
+ logger.warning(f"【{self.cookie_id}】第二次刷新超时,使用降级策略...")
+ await page.reload(wait_until='load', timeout=15000)
+ logger.info(f"【{self.cookie_id}】第二次刷新成功(降级策略)")
+ else:
+ raise e
+ await asyncio.sleep(1)
+
+ # Cookie刷新模式:正常更新Cookie
+ logger.info(f"【{self.cookie_id}】获取更新后的Cookie...")
+ updated_cookies = await context.cookies()
+
+ # 获取并打印当前页面标题
+ page_title = await page.title()
+ logger.info(f"【{self.cookie_id}】当前页面标题: {page_title}")
+
+ # 构造新的Cookie字典
+ new_cookies_dict = {}
+ for cookie in updated_cookies:
+ new_cookies_dict[cookie['name']] = cookie['value']
+
+ # 检查Cookie变化
+ changed_cookies = []
+ new_cookies = []
+ for name, new_value in new_cookies_dict.items():
+ old_value = self.cookies.get(name)
+ if old_value is None:
+ new_cookies.append(name)
+ elif old_value != new_value:
+ changed_cookies.append(name)
+
+ merge_result = self.protected_merge_cookie_dicts(self.cookies, new_cookies_dict)
+ merged_cookies_dict = merge_result['merged_cookies_dict']
+ self._log_protected_merge_event("browser_refresh_protected_merge", merge_result)
+
+ self._log_cookie_merge_summary(
+ merged_cookies_dict,
+ merge_result['updated_fields'],
+ merge_result['changed_fields'],
+ merge_result['new_fields'],
+ context="浏览器刷新Cookie",
+ preserved_fields=merge_result['preserved_fields'],
+ preserved_protected_fields=merge_result['preserved_protected_fields'],
+ would_remove_fields=merge_result['would_remove_fields'],
+ removed_fields=merge_result['removed_fields'],
+ missing_protected_fields=merge_result['missing_protected_fields'],
+ missing_required_fields=merge_result['missing_required_fields'],
+ incoming_missing_protected_fields=merge_result['incoming_missing_protected_fields'],
+ account_switched=merge_result['account_switched'],
+ )
+
+ if merge_result['missing_required_fields']:
+ logger.error(
+ f"【{self.cookie_id}】浏览器刷新后的Cookie仍缺失核心字段,放弃覆盖当前Cookie: {', '.join(merge_result['missing_required_fields'])}"
+ )
+ return False
+
+ # 更新self.cookies和cookies_str
+ self._set_runtime_cookie_state(
+ cookies_dict=merged_cookies_dict,
+ source="browser_cookie_refresh",
+ )
+
+ logger.info(f"【{self.cookie_id}】Cookie已更新,包含 {len(new_cookies_dict)} 个字段")
+
+ # 显示Cookie变化统计
+ if changed_cookies:
+ logger.info(f"【{self.cookie_id}】发生变化的Cookie字段 ({len(changed_cookies)}个): {', '.join(changed_cookies)}")
+ if new_cookies:
+ logger.info(f"【{self.cookie_id}】新增的Cookie字段 ({len(new_cookies)}个): {', '.join(new_cookies)}")
+ if not changed_cookies and not new_cookies:
+ logger.info(f"【{self.cookie_id}】Cookie无变化")
+
+ # 打印完整的更新后Cookie(可选择性启用)
+ logger.info(f"【{self.cookie_id}】更新后的Cookie摘要: {self._summarize_cookie_string(self.cookies_str)}")
+
+ # 打印主要的Cookie字段详情
+ important_cookies = ['_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'unb', 'uc1', 'uc3', 'uc4']
+ logger.info(f"【{self.cookie_id}】重要Cookie字段详情:")
+ for cookie_name in important_cookies:
+ if cookie_name in new_cookies_dict:
+ cookie_value = new_cookies_dict[cookie_name]
+ # 对于敏感信息,只显示前后几位
+ if len(cookie_value) > 20:
+ display_value = f"{cookie_value[:8]}...{cookie_value[-8:]}"
+ else:
+ display_value = cookie_value
+
+ # 标记是否发生了变化
+ change_mark = " [已变化]" if cookie_name in changed_cookies else " [新增]" if cookie_name in new_cookies else ""
+ logger.info(f"【{self.cookie_id}】 {cookie_name}: {display_value}{change_mark}")
+
+ # 更新数据库中的Cookie
+ await self.update_config_cookies()
+
+ # 只有当由refresh_token触发时才设置浏览器Cookie刷新成功标志
+ if triggered_by_refresh_token:
+ self.browser_cookie_refreshed = True
+ logger.info(f"【{self.cookie_id}】由refresh_token触发,浏览器Cookie刷新成功标志已设置为True")
+
+ # 兜底:直接在此处触发实例重启,避免外层协程在返回后被取消导致未重启
+ try:
+ # 标记"刷新流程内已触发重启",供外层去重
+ self.restarted_in_browser_refresh = True
+
+ logger.info(f"【{self.cookie_id}】Cookie刷新成功,准备重启实例...(via _refresh_cookies_via_browser)")
+ await self._restart_instance()
+
+ # ⚠️ _restart_instance() 已触发重启,当前任务即将被取消
+ # 不要等待或执行耗时操作
+ logger.info(f"【{self.cookie_id}】重启请求已触发(via _refresh_cookies_via_browser)")
+
+ # 标记重启标志(无需主动关闭WS,重启由管理器处理)
+ self.connection_restart_flag = True
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】兜底重启失败: {self._safe_str(e)}")
+ else:
+ logger.info(f"【{self.cookie_id}】由定时任务触发,不设置浏览器Cookie刷新成功标志")
+
+ logger.info(f"【{self.cookie_id}】Cookie刷新完成")
+ return True
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】通过浏览器刷新Cookie失败: {self._safe_str(e)}")
+ return False
+ finally:
+ # 异步关闭浏览器:创建清理任务并等待完成,确保资源正确释放
+ close_task = None
+ try:
+ if browser or playwright:
+ # 创建关闭任务
+ close_task = asyncio.create_task(
+ self._async_close_browser(browser, playwright)
+ )
+ logger.info(f"【{self.cookie_id}】浏览器异步关闭任务已启动")
+
+ # 等待关闭任务完成,但设置超时避免阻塞太久
+ try:
+ await asyncio.wait_for(close_task, timeout=15.0)
+ logger.info(f"【{self.cookie_id}】浏览器关闭任务已完成")
+ except asyncio.TimeoutError:
+ logger.warning(f"【{self.cookie_id}】浏览器关闭任务超时(15秒),强制继续")
+ # 取消任务,避免资源泄漏
+ if not close_task.done():
+ close_task.cancel()
+ try:
+ await close_task
+ except (asyncio.CancelledError, Exception):
+ pass
+ except Exception as wait_e:
+ logger.warning(f"【{self.cookie_id}】等待浏览器关闭任务时出错: {self._safe_str(wait_e)}")
+ # 确保任务被取消
+ if close_task and not close_task.done():
+ close_task.cancel()
+ try:
+ await close_task
+ except (asyncio.CancelledError, Exception):
+ pass
+ except Exception as cleanup_e:
+ logger.warning(f"【{self.cookie_id}】创建浏览器关闭任务时出错: {self._safe_str(cleanup_e)}")
+ # 如果创建任务失败,尝试直接关闭
+ if browser or playwright:
+ try:
+ await self._force_close_resources(browser, playwright)
+ except Exception:
+ pass
+
+ async def _async_close_browser(self, browser, playwright):
+ """异步关闭:正常关闭,超时后强制关闭"""
+ try:
+ logger.info(f"【{self.cookie_id}】开始异步关闭浏览器...") # 改为info级别
+
+ # 正常关闭,设置超时
+ await asyncio.wait_for(
+ self._normal_close_resources(browser, playwright),
+ timeout=10.0
+ )
+ logger.info(f"【{self.cookie_id}】浏览器正常关闭完成") # 改为info级别
+
+ except asyncio.TimeoutError:
+ logger.warning(f"【{self.cookie_id}】正常关闭超时,开始强制关闭...")
+ await self._force_close_resources(browser, playwright)
+
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】异步关闭时出错,强制关闭: {self._safe_str(e)}")
+ await self._force_close_resources(browser, playwright)
+
+ async def _normal_close_resources(self, browser, playwright):
+ """正常关闭资源:浏览器+Playwright短超时关闭"""
+ try:
+ # 先关闭浏览器,再关闭Playwright
+ if browser:
+ try:
+ # 关闭浏览器,设置超时
+ await asyncio.wait_for(browser.close(), timeout=5.0)
+ logger.info(f"【{self.cookie_id}】浏览器关闭完成")
+ except asyncio.TimeoutError:
+ logger.warning(f"【{self.cookie_id}】浏览器关闭超时,尝试强制关闭")
+ try:
+ # 尝试强制关闭
+ if hasattr(browser, '_connection'):
+ browser._connection.dispose()
+ except Exception:
+ pass
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】关闭浏览器时出错: {e}")
+
+ # 关闭Playwright:使用短超时,如果超时就放弃
+ if playwright:
+ try:
+ logger.info(f"【{self.cookie_id}】正在关闭Playwright...")
+ # 增加超时时间,确保Playwright有足够时间清理资源
+ await asyncio.wait_for(playwright.stop(), timeout=5.0)
+ logger.info(f"【{self.cookie_id}】Playwright关闭完成")
+ except asyncio.TimeoutError:
+ logger.warning(f"【{self.cookie_id}】Playwright关闭超时,将自动清理")
+ # 尝试强制清理Playwright的内部连接
+ try:
+ if hasattr(playwright, '_connection'):
+ playwright._connection.dispose()
+ except Exception:
+ pass
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】关闭Playwright时出错: {e}")
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】正常关闭时出现异常: {e}")
+ raise
+
+
+ async def _force_close_resources(self, browser, playwright):
+ """强制关闭资源:强制关闭浏览器+Playwright超时等待"""
+ try:
+ logger.warning(f"【{self.cookie_id}】开始强制关闭资源...")
+
+ # 强制关闭浏览器+Playwright,设置短超时
+ force_tasks = []
+ if browser:
+ force_tasks.append(asyncio.wait_for(browser.close(), timeout=3.0))
+ if playwright:
+ force_tasks.append(asyncio.wait_for(playwright.stop(), timeout=3.0))
+
+ if force_tasks:
+ # 使用gather执行,所有失败都会被忽略
+ results = await asyncio.gather(*force_tasks, return_exceptions=True)
+
+ # 检查是否有超时或异常,尝试强制清理
+ for i, result in enumerate(results):
+ if isinstance(result, (asyncio.TimeoutError, Exception)):
+ resource_name = "浏览器" if i == 0 and browser else "Playwright"
+ logger.warning(f"【{self.cookie_id}】{resource_name}强制关闭失败,尝试直接清理连接")
+ try:
+ if i == 0 and browser and hasattr(browser, '_connection'):
+ browser._connection.dispose()
+ elif playwright and hasattr(playwright, '_connection'):
+ playwright._connection.dispose()
+ except Exception:
+ pass
+
+ logger.info(f"【{self.cookie_id}】强制关闭完成")
+ else:
+ logger.info(f"【{self.cookie_id}】没有需要强制关闭的资源")
+
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】强制关闭时出现异常(已忽略): {e}")
+
+ async def send_msg_once(self, toid, item_id, text):
+ """单次发送消息(创建新的WebSocket连接)"""
+ headers = self._build_websocket_headers()
+
+ logger.info(f"【{self.cookie_id}】开始单次发送消息: toid={toid}, item_id={item_id}")
+
+ # 兼容不同版本的websockets库
+ try:
+ async with websockets.connect(
+ self.base_url,
+ extra_headers=headers,
+ close_timeout=5 # 添加关闭超时
+ ) as websocket:
+ result = await self._handle_websocket_connection(websocket, toid, item_id, text)
+ if result:
+ logger.info(f"【{self.cookie_id}】单次发送消息成功")
+ else:
+ raise Exception("消息发送失败")
+ except TypeError as e:
+ # 安全地检查异常信息
+ error_msg = self._safe_str(e)
+
+ if "extra_headers" in error_msg:
+ logger.warning("websockets库不支持extra_headers参数,使用兼容模式")
+ # 使用兼容模式
+ async with websockets.connect(
+ self.base_url,
+ additional_headers=headers,
+ close_timeout=5
+ ) as websocket:
+ result = await self._handle_websocket_connection(websocket, toid, item_id, text)
+ if result:
+ logger.info(f"【{self.cookie_id}】单次发送消息成功(兼容模式)")
+ else:
+ raise Exception("消息发送失败")
+ else:
+ raise
+ except websockets.exceptions.ConnectionClosedError as e:
+ logger.warning(f"【{self.cookie_id}】WebSocket连接关闭: {self._safe_str(e)}")
+ # 连接关闭但消息可能已发送,不抛出异常
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】单次发送消息异常: {self._safe_str(e)}")
+ raise
+
+ async def send_delivery_steps_once(self, toid, item_id, delivery_steps):
+ """单次发送发货步骤(创建新的WebSocket连接)。"""
+ headers = self._build_websocket_headers()
+
+ logger.info(f"【{self.cookie_id}】开始单次发送发货步骤: toid={toid}, item_id={item_id}, steps={len(delivery_steps or [])}")
+
+ try:
+ async with websockets.connect(
+ self.base_url,
+ extra_headers=headers,
+ close_timeout=5
+ ) as websocket:
+ result = await self._handle_websocket_connection_steps(websocket, toid, item_id, delivery_steps)
+ if result:
+ logger.info(f"【{self.cookie_id}】单次发送发货步骤成功")
+ else:
+ raise Exception("发货步骤发送失败")
+ except TypeError as e:
+ error_msg = self._safe_str(e)
+
+ if "extra_headers" in error_msg:
+ logger.warning("websockets库不支持extra_headers参数,使用兼容模式发送发货步骤")
+ async with websockets.connect(
+ self.base_url,
+ additional_headers=headers,
+ close_timeout=5
+ ) as websocket:
+ result = await self._handle_websocket_connection_steps(websocket, toid, item_id, delivery_steps)
+ if result:
+ logger.info(f"【{self.cookie_id}】单次发送发货步骤成功(兼容模式)")
+ else:
+ raise Exception("发货步骤发送失败")
+ else:
+ raise
+ except websockets.exceptions.ConnectionClosedError as e:
+ logger.warning(f"【{self.cookie_id}】WebSocket连接关闭: {self._safe_str(e)}")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】单次发送发货步骤异常: {self._safe_str(e)}")
+ raise
+
+ async def _handle_websocket_connection_steps(self, websocket, toid, item_id, delivery_steps):
+ """处理WebSocket连接的发货步骤发送逻辑。"""
+ try:
+ await self.init(websocket)
+ await self.create_chat(websocket, toid, item_id)
+
+ timeout = 30
+ start_time = time.time()
+
+ async for message in websocket:
+ try:
+ if time.time() - start_time > timeout:
+ logger.warning(f"【{self.cookie_id}】WebSocket消息等待超时")
+ break
+
+ logger.info(f"【{self.cookie_id}】message: {message}")
+ message = json.loads(message)
+ cid = message["body"]["singleChatConversation"]["cid"]
+ cid = cid.split('@')[0]
+ await self._send_delivery_steps(
+ websocket,
+ cid,
+ toid,
+ delivery_steps,
+ log_prefix="单次手动发货"
+ )
+ logger.info(f'【{self.cookie_id}】send delivery steps success')
+ return True
+ except KeyError:
+ continue
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】处理消息异常: {self._safe_str(e)}")
+ continue
+
+ logger.warning(f"【{self.cookie_id}】WebSocket连接关闭,未能发送发货步骤")
+ return False
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】WebSocket发货步骤处理异常: {self._safe_str(e)}")
+ return False
+
+ async def _create_websocket_connection(self, headers):
+ """创建WebSocket连接,兼容不同版本的websockets库,支持代理配置"""
+ import websockets
+
+ # 获取websockets版本用于调试
+ websockets_version = getattr(websockets, '__version__', '未知')
+ logger.info(f"【{self.cookie_id}】websockets库版本: {websockets_version}")
+
+ # 检查是否需要使用代理
+ proxy_url = self._get_proxy_url()
+ proxy_sock = None
+
+ if proxy_url:
+ proxy_type = self.proxy_config.get('proxy_type', 'none')
+ logger.info(f"【{self.cookie_id}】WebSocket将通过代理连接: {proxy_type}://{self.proxy_config.get('proxy_host')}:{self.proxy_config.get('proxy_port')}")
+
+ try:
+ from python_socks.async_.asyncio.v2 import Proxy
+ from python_socks import ProxyType as SocksProxyType
+ import ssl
+
+ # 确定代理类型
+ if proxy_type == 'socks5':
+ socks_type = SocksProxyType.SOCKS5
+ elif proxy_type == 'socks4':
+ socks_type = SocksProxyType.SOCKS4
+ elif proxy_type in ['http', 'https']:
+ socks_type = SocksProxyType.HTTP
+ else:
+ socks_type = None
+
+ if socks_type:
+ # 解析WebSocket URL获取目标主机和端口
+ import urllib.parse
+ parsed_url = urllib.parse.urlparse(self.base_url)
+ dest_host = parsed_url.hostname
+ dest_port = parsed_url.port or (443 if parsed_url.scheme == 'wss' else 80)
+
+ # 创建代理连接
+ proxy = Proxy(
+ proxy_type=socks_type,
+ host=self.proxy_config.get('proxy_host'),
+ port=self.proxy_config.get('proxy_port'),
+ username=self.proxy_config.get('proxy_user') or None,
+ password=self.proxy_config.get('proxy_pass') or None
+ )
+
+ # 通过代理连接到目标服务器
+ proxy_sock = await proxy.connect(
+ dest_host=dest_host,
+ dest_port=dest_port
+ )
+
+ # 如果是wss,需要升级为SSL
+ if parsed_url.scheme == 'wss':
+ ssl_context = ssl.create_default_context()
+ proxy_sock = ssl_context.wrap_socket(
+ proxy_sock,
+ server_hostname=dest_host
+ )
+
+ logger.info(f"【{self.cookie_id}】代理连接建立成功")
+
+ except ImportError as e:
+ logger.warning(f"【{self.cookie_id}】代理连接需要安装 python-socks: pip install python-socks[asyncio]")
+ logger.warning(f"【{self.cookie_id}】将尝试不使用代理进行WebSocket连接")
+ proxy_sock = None
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】通过代理建立连接失败: {self._safe_str(e)}")
+ logger.warning(f"【{self.cookie_id}】将尝试不使用代理进行WebSocket连接")
+ proxy_sock = None
+
+ try:
+ # 尝试使用extra_headers参数
+ connect_kwargs = {
+ 'extra_headers': headers
+ }
+ if proxy_sock:
+ connect_kwargs['sock'] = proxy_sock
+
+ return websockets.connect(
+ self.base_url,
+ **connect_kwargs
+ )
+ except Exception as e:
+ # 捕获所有异常类型,不仅仅是TypeError
+ error_msg = self._safe_str(e)
+ logger.warning(f"【{self.cookie_id}】extra_headers参数失败: {error_msg}")
+
+ if "extra_headers" in error_msg or "unexpected keyword argument" in error_msg:
+ logger.warning(f"【{self.cookie_id}】websockets库不支持extra_headers参数,尝试additional_headers")
+ # 使用additional_headers参数(较新版本)
+ try:
+ connect_kwargs = {
+ 'additional_headers': headers
+ }
+ if proxy_sock:
+ connect_kwargs['sock'] = proxy_sock
+
+ return websockets.connect(
+ self.base_url,
+ **connect_kwargs
+ )
+ except Exception as e2:
+ error_msg2 = self._safe_str(e2)
+ logger.warning(f"【{self.cookie_id}】additional_headers参数失败: {error_msg2}")
+
+ if "additional_headers" in error_msg2 or "unexpected keyword argument" in error_msg2:
+ raise RuntimeError(
+ f"当前websockets库不支持header参数,无法安全建立鉴权连接: {error_msg2}"
+ )
+ else:
+ raise e2
+ else:
+ raise e
+
+ async def _handle_websocket_connection(self, websocket, toid, item_id, text):
+ """处理WebSocket连接的具体逻辑"""
+ try:
+ await self.init(websocket)
+ await self.create_chat(websocket, toid, item_id)
+
+ # 添加超时处理,最多等待30秒
+ timeout = 30
+ start_time = time.time()
+
+ async for message in websocket:
+ try:
+ # 检查是否超时
+ if time.time() - start_time > timeout:
+ logger.warning(f"【{self.cookie_id}】WebSocket消息等待超时")
+ break
+
+ logger.info(f"【{self.cookie_id}】message: {message}")
+ message = json.loads(message)
+ cid = message["body"]["singleChatConversation"]["cid"]
+ cid = cid.split('@')[0]
+ await self.send_msg(websocket, cid, toid, text)
+ logger.info(f'【{self.cookie_id}】send message success')
+ return True
+ except KeyError:
+ # 消息格式不符合预期,继续等待
+ continue
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】处理消息异常: {self._safe_str(e)}")
+ continue
+
+ logger.warning(f"【{self.cookie_id}】WebSocket连接关闭,未能发送消息")
+ return False
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】WebSocket连接处理异常: {self._safe_str(e)}")
+ return False
+
+ def is_chat_message(self, message):
+ """判断是否为用户聊天消息"""
+ try:
+ return (
+ isinstance(message, dict)
+ and "1" in message
+ and isinstance(message["1"], dict)
+ and "10" in message["1"]
+ and isinstance(message["1"]["10"], dict)
+ and "reminderContent" in message["1"]["10"]
+ )
+ except Exception:
+ return False
+
+ def is_sync_package(self, message_data):
+ """判断是否为同步包消息"""
+ try:
+ return (
+ isinstance(message_data, dict)
+ and "body" in message_data
+ and "syncPushPackage" in message_data["body"]
+ and "data" in message_data["body"]["syncPushPackage"]
+ and len(message_data["body"]["syncPushPackage"]["data"]) > 0
+ )
+ except Exception:
+ return False
+
+ async def create_session(self):
+ """创建aiohttp session,支持代理配置"""
+ if not self.session:
+ # 创建带有cookies和headers的session
+ headers = DEFAULT_HEADERS.copy()
+
+ proxy_url = self._get_proxy_url()
+ connector = None
+
+ if proxy_url:
+ proxy_type = self.proxy_config.get('proxy_type', 'none')
+ logger.info(f"【{self.cookie_id}】创建带代理的Session: {proxy_type}://{self.proxy_config.get('proxy_host')}:{self.proxy_config.get('proxy_port')}")
+
+ if proxy_type == 'socks5':
+ # SOCKS5 代理使用 aiohttp_socks
+ try:
+ from aiohttp_socks import ProxyConnector, ProxyType
+ connector = ProxyConnector(
+ proxy_type=ProxyType.SOCKS5,
+ host=self.proxy_config.get('proxy_host'),
+ port=self.proxy_config.get('proxy_port'),
+ username=self.proxy_config.get('proxy_user') or None,
+ password=self.proxy_config.get('proxy_pass') or None,
+ rdns=True # 使用代理服务器解析DNS
+ )
+ except ImportError:
+ logger.error(f"【{self.cookie_id}】SOCKS5代理需要安装 aiohttp-socks: pip install aiohttp-socks")
+ connector = None
+ else:
+ # HTTP/HTTPS 代理使用 aiohttp 内置支持(通过 trust_env 或在请求时指定)
+ # 注意:aiohttp 的 TCPConnector 不直接支持 proxy 参数
+ # 代理将在每次请求时通过 proxy 参数指定
+ connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
+ else:
+ connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
+
+ self.session = aiohttp.ClientSession(
+ headers=headers,
+ timeout=aiohttp.ClientTimeout(total=30),
+ connector=connector
+ )
+ self._sync_session_cookie_header()
+
+ # 保存代理URL供后续请求使用(HTTP/HTTPS代理)
+ self._http_proxy_url = proxy_url if proxy_url and self.proxy_config.get('proxy_type') in ['http', 'https'] else None
+
+ async def close_session(self):
+ """关闭aiohttp session"""
+ if self.session:
+ await self.session.close()
+ self.session = None
+
+ async def get_api_reply(self, msg_time, user_url, send_user_id, send_user_name, item_id, send_message, chat_id):
+ """调用API获取回复消息"""
+ try:
+ if not self.session:
+ await self.create_session()
+
+ api_config = AUTO_REPLY.get('api', {})
+ timeout = aiohttp.ClientTimeout(total=api_config.get('timeout', 10))
+
+ payload = {
+ "cookie_id": self.cookie_id,
+ "msg_time": msg_time,
+ "user_url": user_url,
+ "send_user_id": send_user_id,
+ "send_user_name": send_user_name,
+ "item_id": item_id,
+ "send_message": send_message,
+ "chat_id": chat_id
+ }
+
+ async with self.session.post(
+ api_config.get('url', 'http://localhost:8080/xianyu/reply'),
+ json=payload,
+ timeout=timeout
+ ) as response:
+ result = await response.json()
+
+ # 将code转换为字符串进行比较,或者直接用数字比较
+ if str(result.get('code')) == '200' or result.get('code') == 200:
+ send_msg = result.get('data', {}).get('send_msg')
+ if send_msg:
+ # 格式化消息中的占位符
+ return send_msg.format(
+ send_user_id=payload['send_user_id'],
+ send_user_name=payload['send_user_name'],
+ send_message=payload['send_message']
+ )
+ else:
+ logger.warning("API返回成功但无回复消息")
+ return None
+ else:
+ logger.warning(f"API返回错误: {result.get('msg', '未知错误')}")
+ return None
+
+ except asyncio.TimeoutError:
+ logger.error("API调用超时")
+ return None
+ except Exception as e:
+ logger.error(f"调用API出错: {self._safe_str(e)}")
+ return None
+
+ async def _handle_message_with_semaphore(self, message_data, websocket, msg_id="unknown"):
+ """带信号量的消息处理包装器,防止并发任务过多"""
+ async with self.message_semaphore:
+ self.active_message_tasks += 1
+ try:
+ await self.handle_message(message_data, websocket, msg_id)
+ finally:
+ self.active_message_tasks -= 1
+ # 定期记录活跃任务数(每100个任务记录一次)
+ if self.active_message_tasks % 100 == 0 and self.active_message_tasks > 0:
+ logger.info(f"【{self.cookie_id}】当前活跃消息处理任务数: {self.active_message_tasks}")
+
+ def _extract_message_id(self, message_data: dict) -> str:
+ """
+ 从消息数据中提取消息ID,用于去重
+
+ Args:
+ message_data: 原始消息数据
+
+ Returns:
+ 消息ID字符串,如果无法提取则返回None
+ """
+ try:
+ # 尝试从 message['1']['10']['bizTag'] 中提取 messageId
+ if isinstance(message_data, dict) and "1" in message_data:
+ message_1 = message_data.get("1")
+ if isinstance(message_1, dict) and "10" in message_1:
+ message_10 = message_1.get("10")
+ if isinstance(message_10, dict) and "bizTag" in message_10:
+ biz_tag = message_10.get("bizTag", "")
+ if isinstance(biz_tag, str):
+ # bizTag 是 JSON 字符串,格式如: '{"sourceId":"S:1","messageId":"984f323c719d4cd0a7b993a0769a33b6"}'
+ try:
+ import json
+ biz_tag_dict = json.loads(biz_tag)
+ if isinstance(biz_tag_dict, dict) and "messageId" in biz_tag_dict:
+ return biz_tag_dict.get("messageId")
+ except (json.JSONDecodeError, TypeError):
+ pass
+
+ # 如果 bizTag 解析失败,尝试从 extJson 中提取
+ if "extJson" in message_10:
+ ext_json = message_10.get("extJson", "")
+ if isinstance(ext_json, str):
+ try:
+ import json
+ ext_json_dict = json.loads(ext_json)
+ if isinstance(ext_json_dict, dict) and "messageId" in ext_json_dict:
+ return ext_json_dict.get("messageId")
+ except (json.JSONDecodeError, TypeError):
+ pass
+ except Exception as e:
+ logger.debug(f"【{self.cookie_id}】提取消息ID失败: {self._safe_str(e)}")
+
+ return None
+
+ async def _schedule_debounced_reply(self, chat_id: str, message_data: dict, websocket,
+ send_user_name: str, send_user_id: str, send_message: str,
+ item_id: str, msg_time: str):
+ """
+ 调度防抖回复:如果用户连续发送消息,等待用户停止发送后再回复最后一条消息
+
+ Args:
+ chat_id: 聊天ID
+ message_data: 原始消息数据
+ websocket: WebSocket连接
+ send_user_name: 发送者用户名
+ send_user_id: 发送者用户ID
+ send_message: 消息内容
+ item_id: 商品ID
+ msg_time: 消息时间
+ """
+ # 提取消息ID并检查是否已处理
+ message_id = self._extract_message_id(message_data)
+ # 如果没有 messageId,使用备用标识(chat_id + send_message + 时间戳)
+ if not message_id:
+ try:
+ # 尝试从消息数据中提取时间戳
+ create_time = 0
+ if isinstance(message_data, dict) and "1" in message_data:
+ message_1 = message_data.get("1")
+ if isinstance(message_1, dict):
+ create_time = message_1.get("5", 0)
+ # 使用组合键作为备用标识
+ message_id = f"{chat_id}_{send_message}_{create_time}"
+ except Exception:
+ # 如果提取失败,使用当前时间戳
+ message_id = f"{chat_id}_{send_message}_{int(time.time() * 1000)}"
+
+ async with self.processed_message_ids_lock:
+ current_time = time.time()
+
+ # 检查消息是否已处理且未过期
+ if message_id in self.processed_message_ids:
+ last_process_time = self.processed_message_ids[message_id]
+ time_elapsed = current_time - last_process_time
+
+ # 如果消息处理时间未超过1小时,跳过
+ if time_elapsed < self.message_expire_time:
+ remaining_time = int(self.message_expire_time - time_elapsed)
+ logger.warning(f"【{self.cookie_id}】消息ID {message_id[:50]}... 已处理过,距离可重复回复还需 {remaining_time} 秒")
+ return
+ else:
+ # 超过1小时,可以重新处理
+ logger.info(f"【{self.cookie_id}】消息ID {message_id[:50]}... 已超过 {int(time_elapsed/60)} 分钟,允许重新回复")
+
+ # 标记消息ID为已处理(更新或添加时间戳)
+ self.processed_message_ids[message_id] = current_time
+
+ # 定期清理过期的消息ID
+ if len(self.processed_message_ids) > self.processed_message_ids_max_size:
+ # 清理超过1小时的旧记录
+ expired_ids = [
+ msg_id for msg_id, timestamp in self.processed_message_ids.items()
+ if current_time - timestamp > self.message_expire_time
+ ]
+
+ for msg_id in expired_ids:
+ del self.processed_message_ids[msg_id]
+
+ logger.info(f"【{self.cookie_id}】已清理 {len(expired_ids)} 个过期消息ID")
+
+ # 如果清理后仍然过大,删除最旧的一半
+ if len(self.processed_message_ids) > self.processed_message_ids_max_size:
+ sorted_ids = sorted(self.processed_message_ids.items(), key=lambda x: x[1])
+ remove_count = len(sorted_ids) // 2
+ for msg_id, _ in sorted_ids[:remove_count]:
+ del self.processed_message_ids[msg_id]
+ logger.info(f"【{self.cookie_id}】消息ID去重字典过大,已清理 {remove_count} 个最旧记录")
+
+ async with self.message_debounce_lock:
+ # 如果该chat_id已有防抖任务,取消它
+ if chat_id in self.message_debounce_tasks:
+ old_task = self.message_debounce_tasks[chat_id].get('task')
+ if old_task and not old_task.done():
+ old_task.cancel()
+ logger.warning(f"【{self.cookie_id}】取消chat_id {chat_id} 的旧防抖任务")
+
+ # 更新最后一条消息信息
+ current_timer = time.time()
+ self.message_debounce_tasks[chat_id] = {
+ 'last_message': {
+ 'message_data': message_data,
+ 'websocket': websocket,
+ 'send_user_name': send_user_name,
+ 'send_user_id': send_user_id,
+ 'send_message': send_message,
+ 'item_id': item_id,
+ 'msg_time': msg_time
+ },
+ 'timer': current_timer
+ }
+
+ # 创建新的防抖任务
+ async def debounce_task():
+ saved_timer = current_timer # 保存创建任务时的时间戳
+ try:
+ # 等待防抖延迟时间
+ await asyncio.sleep(self.message_debounce_delay)
+
+ # 检查是否仍然是最新的消息(防止在等待期间有新消息)
+ async with self.message_debounce_lock:
+ if chat_id not in self.message_debounce_tasks:
+ return
+
+ debounce_info = self.message_debounce_tasks[chat_id]
+ # 检查时间戳是否匹配(确保这是最新的消息)
+ if saved_timer != debounce_info['timer']:
+ logger.warning(f"【{self.cookie_id}】chat_id {chat_id} 在防抖期间有新消息,跳过旧消息处理")
+ return
+
+ # 获取最后一条消息
+ last_msg = debounce_info['last_message']
+
+ # 从防抖任务中移除
+ del self.message_debounce_tasks[chat_id]
+
+ # 处理最后一条消息
+ logger.info(f"【{self.cookie_id}】防抖延迟结束,开始处理chat_id {chat_id} 的最后一条消息: {last_msg['send_message'][:30]}...")
+ await self._process_chat_message_reply(
+ last_msg['message_data'],
+ last_msg['websocket'],
+ last_msg['send_user_name'],
+ last_msg['send_user_id'],
+ last_msg['send_message'],
+ last_msg['item_id'],
+ chat_id,
+ last_msg['msg_time']
+ )
+
+ except asyncio.CancelledError:
+ logger.warning(f"【{self.cookie_id}】chat_id {chat_id} 的防抖任务被取消")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】处理防抖回复时发生错误: {self._safe_str(e)}")
+ # 确保从防抖任务中移除
+ async with self.message_debounce_lock:
+ if chat_id in self.message_debounce_tasks:
+ del self.message_debounce_tasks[chat_id]
+
+ task = self._create_tracked_task(debounce_task())
+ self.message_debounce_tasks[chat_id]['task'] = task
+ logger.warning(f"【{self.cookie_id}】为chat_id {chat_id} 创建防抖任务,延迟 {self.message_debounce_delay} 秒")
+
+ async def _process_chat_message_reply(self, message_data: dict, websocket, send_user_name: str,
+ send_user_id: str, send_message: str, item_id: str,
+ chat_id: str, msg_time: str):
+ """
+ 处理聊天消息的回复逻辑(从handle_message中提取出来的核心回复逻辑)
+
+ Args:
+ message_data: 原始消息数据
+ websocket: WebSocket连接
+ send_user_name: 发送者用户名
+ send_user_id: 发送者用户ID
+ send_message: 消息内容
+ item_id: 商品ID
+ chat_id: 聊天ID
+ msg_time: 消息时间
+ """
+ try:
+ # 自动回复消息
+ if not AUTO_REPLY.get('enabled', True):
+ logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】自动回复已禁用")
+ return
+
+ # 检查该chat_id是否处于暂停状态
+ if pause_manager.is_chat_paused(chat_id):
+ remaining_time = pause_manager.get_remaining_pause_time(chat_id)
+ remaining_minutes = remaining_time // 60
+ remaining_seconds = remaining_time % 60
+ logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】chat_id {chat_id} 自动回复已暂停,剩余时间: {remaining_minutes}分{remaining_seconds}秒")
+ return
+
+ reply = None
+ reply_source = None
+
+ # 按 README 定义的优先级处理:
+ # 指定商品回复 > 商品专用关键词 > 通用关键词 > 默认回复 > AI回复
+ reply = await self.get_item_specific_reply(send_user_name, send_user_id, send_message, item_id)
+ if reply:
+ reply_source = '指定商品'
+ else:
+ # 1. 尝试关键词匹配(内部已区分商品专用关键词和通用关键词)
+ reply = await self.get_keyword_reply(send_user_name, send_user_id, send_message, item_id)
+ if reply == "EMPTY_REPLY":
+ # 匹配到关键词但回复内容为空,不进行任何回复
+ logger.info(f"[{msg_time}] 【{self.cookie_id}】匹配到空回复关键词,跳过自动回复")
+ return
+ elif reply:
+ reply_source = '关键词' # 标记为关键词回复
+ else:
+ # 2. 关键词匹配失败后,使用默认回复兜底
+ 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
+ elif reply == "SKIP_REPLY":
+ logger.info(f"[{msg_time}] 【{self.cookie_id}】默认回复已命中过当前会话,跳过自动回复")
+ return
+ elif reply:
+ reply_source = '默认'
+ else:
+ # 3. 最后尝试AI回复
+ reply = await self.get_ai_reply(send_user_name, send_user_id, send_message, item_id, chat_id)
+ if reply:
+ reply_source = 'AI'
+
+ # 注意:这里只有商品ID,没有标题和详情,根据新的规则不保存到数据库
+ # 商品信息会在其他有完整信息的地方保存(如发货规则匹配时)
+ # 消息通知已在收到消息时立即发送,此处不再重复发送
+
+ # 如果有回复内容,发送消息
+ if reply:
+ # 检查是否是图片发送标记
+ if reply.startswith("__IMAGE_SEND__"):
+ # 提取图片URL(关键词回复不包含卡券ID)
+ image_url = reply.replace("__IMAGE_SEND__", "")
+ # 发送图片消息
+ try:
+ await self.send_image_msg(websocket, chat_id, send_user_id, image_url)
+ # 记录发出的图片消息
+ msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ logger.info(f"[{msg_time}] 【{reply_source}图片发出】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): 图片 {image_url}")
+ except Exception as e:
+ # 图片发送失败,发送错误提示
+ logger.error(f"图片发送失败: {self._safe_str(e)}")
+ await self.send_msg(websocket, chat_id, send_user_id, "抱歉,图片发送失败,请稍后重试。")
+ msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ logger.error(f"[{msg_time}] 【{reply_source}图片发送失败】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id})")
+ else:
+ # 普通文本消息
+ await self.send_msg(websocket, chat_id, send_user_id, reply)
+ # 记录发出的消息
+ msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ logger.info(f"[{msg_time}] 【{reply_source}发出】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {reply}")
+ else:
+ msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】未找到匹配的回复规则,不回复")
+ except Exception as e:
+ logger.error(f"处理聊天消息回复时发生错误: {self._safe_str(e)}")
+
+ async def handle_message(self, message_data, websocket, msg_id="unknown"):
+ """处理所有类型的消息"""
+ # 获取消息大小用于追踪
+ msg_size = len(json.dumps(message_data)) if message_data else 0
+ logger.info(f"【{self.cookie_id}】[{msg_id}] 🚀 开始处理消息 ({msg_size}字节)")
+
+ try:
+ # 检查账号是否启用
+ from cookie_manager import manager as cookie_manager
+ if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] ⏹️ 账号已禁用,消息处理结束")
+ return
+
+ # 发送确认消息
+ try:
+ message = message_data
+ ack = {
+ "code": 200,
+ "headers": {
+ "mid": message["headers"]["mid"] if "mid" in message["headers"] else generate_mid(),
+ "sid": message["headers"]["sid"] if "sid" in message["headers"] else '',
+ }
+ }
+ if 'app-key' in message["headers"]:
+ ack["headers"]["app-key"] = message["headers"]["app-key"]
+ if 'ua' in message["headers"]:
+ ack["headers"]["ua"] = message["headers"]["ua"]
+ if 'dt' in message["headers"]:
+ ack["headers"]["dt"] = message["headers"]["dt"]
+ await websocket.send(json.dumps(ack))
+ except Exception as e:
+ logger.debug(f"【{self.cookie_id}】[{msg_id}] 发送ACK失败: {e}")
+
+ # 如果不是同步包消息,直接返回
+ if not self.is_sync_package(message_data):
+ logger.debug(f"【{self.cookie_id}】[{msg_id}] ⏹️ 非同步包消息,处理结束")
+ return
+
+ # 获取并解密数据
+ sync_data = message_data["body"]["syncPushPackage"]["data"][0]
+
+ # 检查是否有必要的字段
+ if "data" not in sync_data:
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] ⚠️ 同步包中无data字段,消息内容: {sync_data}")
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] ⏹️ 消息处理结束(缺少data字段)")
+ return
+
+ # 解密数据
+ message = None
+ try:
+ data = sync_data["data"]
+ logger.debug(f"【{self.cookie_id}】[{msg_id}] 开始解密同步包数据...")
+ try:
+ data = base64.b64decode(data).decode("utf-8")
+ parsed_data = json.loads(data)
+ # 处理未加密的消息(如系统提示等)
+ msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ if isinstance(parsed_data, dict) and 'chatType' in parsed_data:
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] ⚠️ 检测到chatType消息,完整内容: {parsed_data}")
+ if 'operation' in parsed_data and 'content' in parsed_data['operation']:
+ content = parsed_data['operation']['content']
+ if 'sessionArouse' in content:
+ # 处理系统引导消息
+ logger.info(f"[{msg_time}] 【{self.cookie_id}】[{msg_id}] 【系统】小闲鱼智能提示:")
+ if 'arouseChatScriptInfo' in content['sessionArouse']:
+ for qa in content['sessionArouse']['arouseChatScriptInfo']:
+ logger.info(f" - {qa['chatScrip']}")
+ logger.info(f"[{msg_time}] 【{self.cookie_id}】[{msg_id}] ⏹️ 系统引导消息处理完成")
+ return
+ elif 'contentType' in content:
+ # 其他类型的未加密消息
+ logger.warning(f"[{msg_time}] 【{self.cookie_id}】[{msg_id}] 【系统】其他类型消息: {content}")
+ # ⚠️ 修复:不能直接return,应该继续处理这条消息
+ # 因为付款消息可能也包含chatType字段
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] ⚠️ chatType消息但不是引导消息,继续处理...")
+ message = parsed_data
+ else:
+ # 如果不是系统消息,将解析的数据作为message
+ logger.debug(f"【{self.cookie_id}】[{msg_id}] 解密成功,正常消息")
+ message = parsed_data
+ except Exception as e:
+ # 如果JSON解析失败,尝试解密
+ logger.debug(f"【{self.cookie_id}】[{msg_id}] JSON解析失败,尝试解密...")
+ decrypted_data = decrypt(data)
+ message = json.loads(decrypted_data)
+ logger.debug(f"【{self.cookie_id}】[{msg_id}] 解密成功")
+ except Exception as e:
+ # ⚠️ 关键:对于解密失败的大消息,记录完整信息
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ❌ 消息解密失败: {self._safe_str(e)}")
+ if msg_size > 3000:
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ⚠️⚠️⚠️ 大消息({msg_size}字节)解密失败,完整sync_data: {sync_data}")
+ # 尝试记录base64数据的前后部分
+ try:
+ raw_data = sync_data.get("data", "")
+ logger.error(f"【{self.cookie_id}】[{msg_id}] Base64数据长度: {len(raw_data)}")
+ logger.error(f"【{self.cookie_id}】[{msg_id}] Base64前100字符: {raw_data[:100]}")
+ logger.error(f"【{self.cookie_id}】[{msg_id}] Base64后100字符: {raw_data[-100:]}")
+ except Exception:
+ pass
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ⏹️ 消息处理结束(解密失败)")
+ return
+
+ # 确保message不为空
+ if message is None:
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ❌ 消息解析后为空")
+ if msg_size > 3000:
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ⚠️⚠️⚠️ 大消息({msg_size}字节)解析后为空!")
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ⏹️ 消息处理结束(解析后为空)")
+ return
+
+ # 确保message是字典类型
+ if not isinstance(message, dict):
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ❌ 消息格式错误,期望字典但得到: {type(message)}")
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] 消息内容: {message}")
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ⏹️ 消息处理结束(格式错误)")
+ return
+
+ # 【消息接收标识】记录收到消息的时间,用于控制Cookie刷新
+ self.last_message_received_time = time.time()
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] ✅ 开始处理消息")
+
+ # 【优先处理】尝试获取订单ID并获取订单详情
+ order_id = None
+ try:
+ logger.info(f"【{self.cookie_id}】[{msg_id}] 🔍 开始提取订单ID,消息类型: {type(message)}")
+ order_id = self._extract_order_id(message, message_data)
+ if order_id:
+ msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ✅ 检测到订单ID: {order_id},开始获取订单详情')
+
+ order_context = self._extract_order_message_context(message, msg_id=msg_id)
+ temp_user_id = order_context.get('buyer_id')
+ temp_user_id_source = order_context.get('buyer_id_source')
+ temp_item_id = order_context.get('item_id')
+ temp_sid = order_context.get('sid')
+ temp_buyer_nick = order_context.get('buyer_nick')
+
+ # 通知订单状态处理器订单ID已提取
+ if self.order_status_handler:
+ logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.on_order_id_extracted: {order_id}")
+ try:
+ self.order_status_handler.on_order_id_extracted(
+ order_id,
+ self.cookie_id,
+ message,
+ match_context={
+ 'sid': temp_sid,
+ 'buyer_id': temp_user_id,
+ 'item_id': temp_item_id,
+ }
+ )
+ logger.info(f"【{self.cookie_id}】订单状态处理器.on_order_id_extracted调用成功: {order_id}")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】通知订单状态处理器订单ID提取失败: {self._safe_str(e)}")
+ import traceback
+ logger.error(f"【{self.cookie_id}】详细错误信息: {traceback.format_exc()}")
+ else:
+ logger.warning(f"【{self.cookie_id}】订单状态处理器为None,跳过订单ID提取通知: {order_id}")
+
+ basic_order_saved = self._preload_basic_order_info(
+ order_id,
+ item_id=temp_item_id,
+ buyer_id=temp_user_id,
+ sid=temp_sid,
+ buyer_nick=temp_buyer_nick,
+ buyer_id_source=temp_user_id_source,
+ )
+
+ # 立即获取订单详情信息
+ try:
+ # 调用订单详情获取方法(传入sid和buyer_nick用于保存到数据库)
+ order_detail = await self.fetch_order_detail_info(
+ order_id,
+ temp_item_id,
+ temp_user_id,
+ sid=temp_sid,
+ buyer_nick=temp_buyer_nick,
+ buyer_id_source=temp_user_id_source,
+ )
+ if order_detail:
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 订单详情获取成功: {order_id}')
+ else:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】⚠️ 订单详情获取失败: {order_id}')
+ if basic_order_saved:
+ self._schedule_order_detail_retry(
+ order_id,
+ item_id=temp_item_id,
+ buyer_id=temp_user_id,
+ sid=temp_sid,
+ buyer_nick=temp_buyer_nick,
+ delay_seconds=30,
+ buyer_id_source=temp_user_id_source,
+ )
+
+ except Exception as detail_e:
+ logger.error(f'[{msg_time}] 【{self.cookie_id}】❌ 获取订单详情异常: {self._safe_str(detail_e)}')
+ if basic_order_saved:
+ self._schedule_order_detail_retry(
+ order_id,
+ item_id=temp_item_id,
+ buyer_id=temp_user_id,
+ sid=temp_sid,
+ buyer_nick=temp_buyer_nick,
+ delay_seconds=30,
+ buyer_id_source=temp_user_id_source,
+ )
+ else:
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] 未检测到订单ID")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】[{msg_id}] 提取订单ID失败: {self._safe_str(e)}")
+
+ # 安全地获取用户ID
+ user_id = None
+ try:
+ message_1 = message.get("1")
+ if isinstance(message_1, str):
+ # message['1'] 是字符串(sid 或 PNM 等),尝试从 message['4'] 提取 buyer_id
+ message_4 = message.get("4")
+ if isinstance(message_4, dict):
+ user_id = message_4.get("senderUserId") or None
+ elif isinstance(message_1, dict):
+ # 如果message['1']是字典,从message["1"]["10"]["senderUserId"]中提取user_id
+ if "10" in message_1 and isinstance(message_1["10"], dict):
+ user_id = message_1["10"].get("senderUserId") or None
+ else:
+ user_id = None
+ else:
+ user_id = None
+ except Exception as e:
+ logger.warning(f"提取用户ID失败: {self._safe_str(e)}")
+ user_id = None
+
+
+
+ # 安全地提取商品ID
+ item_id = None
+ try:
+ if "1" in message and isinstance(message["1"], dict) and "10" in message["1"] and isinstance(message["1"]["10"], dict):
+ url_info = message["1"]["10"].get("reminderUrl", "")
+ if isinstance(url_info, str) and "itemId=" in url_info:
+ item_id = url_info.split("itemId=")[1].split("&")[0]
+
+ # 如果没有提取到,使用辅助方法
+ if not item_id:
+ item_id = self.extract_item_id_from_message(message)
+
+ if not item_id:
+ item_id = f"auto_{user_id}_{int(time.time())}"
+ logger.warning(f"无法提取商品ID,使用默认值: {item_id}")
+
+ except Exception as e:
+ logger.error(f"提取商品ID时发生错误: {self._safe_str(e)}")
+ item_id = f"auto_{user_id}_{int(time.time())}"
+ # 处理订单状态消息
+ try:
+ logger.info(f"【{self.cookie_id}】[{msg_id}] 消息内容: {message}")
+ msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
+
+ # 安全地检查订单状态
+ red_reminder = None
+ if isinstance(message, dict) and "3" in message and isinstance(message["3"], dict):
+ red_reminder = message["3"].get("redReminder")
+
+ if red_reminder == '等待买家付款':
+ user_url = f'https://www.goofish.com/personal?userId={user_id}'
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 【系统】等待买家 {user_url} 付款')
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(等待买家付款)")
+ return
+ elif red_reminder == '交易关闭':
+ user_url = f'https://www.goofish.com/personal?userId={user_id}'
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 【系统】买家 {user_url} 交易关闭')
+
+ # 【修复】更新订单状态到数据库
+ if self.order_status_handler:
+ try:
+ self.order_status_handler.handle_red_reminder_order_status(
+ red_reminder=red_reminder,
+ message=message,
+ user_id=user_id,
+ cookie_id=self.cookie_id,
+ msg_time=msg_time,
+ match_context={
+ 'sid': None,
+ 'buyer_id': user_id,
+ 'item_id': item_id,
+ }
+ )
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】更新交易关闭订单状态失败: {self._safe_str(e)}")
+
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(交易关闭)")
+ return
+ elif red_reminder == '等待卖家发货':
+ user_url = f'https://www.goofish.com/personal?userId={user_id}'
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 【系统】交易成功 {user_url} 等待卖家发货')
+
+ # 【关键修复】对于简化结构的消息(message['1']是字符串),根据sid查找订单信息后触发自动发货
+ # 简化消息结构: {'1': '56226853668@goofish', '2': 1, '3': {'redReminder': '等待卖家发货', ...}}
+ # message['1'] 就是 sid(会话ID)
+ # 【优化】只使用简化消息触发自动发货,完整付款消息已注释
+ if isinstance(message.get('1'), str):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 🔔 检测到简化结构的发货通知消息,延迟处理')
+ await asyncio.sleep(30)
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 🔔 延迟30秒后处理简化发货')
+ # 检查是否启用自动确认发货
+ if self.is_auto_confirm_enabled():
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ✅ 自动确认发货已启用,开始处理')
+
+ # 从简化消息中提取sid(会话ID),如 "56226853668@goofish"
+ simple_sid = message.get('1', '')
+ # 提取纯数字部分作为session_id_str
+ session_id_str = simple_sid.split('@')[0] if '@' in str(simple_sid) else simple_sid
+
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 🔍 简化消息解析: sid={simple_sid}, session_id={session_id_str}')
+
+ log_prefix = f'[{msg_time}] 【{self.cookie_id}】[{msg_id}]'
+ sid_lookup_minutes = 5
+ sid_lookup = self._lookup_delivery_order_by_sid(
+ simple_sid,
+ minutes=sid_lookup_minutes,
+ log_prefix=log_prefix
+ )
+ sid_lookup = await self._refresh_sid_lookup_if_needed(
+ simple_sid,
+ sid_lookup,
+ item_id=item_id,
+ buyer_id=user_id,
+ minutes=sid_lookup_minutes,
+ allow_bargain_ready=True,
+ log_prefix=log_prefix
+ )
+ recent_order = sid_lookup.get('order')
+ sid_match_type = sid_lookup.get('match_type', 'missing')
+
+ if recent_order and sid_match_type in {'pending_ship', 'bargain_ready'}:
+ order_id = recent_order.get('order_id')
+ real_item_id = recent_order.get('item_id')
+ simple_user_id = recent_order.get('buyer_id', user_id) # 从订单中获取buyer_id
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ✅ 通过sid从数据库找到订单: order_id={order_id}, item_id={real_item_id}, buyer_id={simple_user_id}')
+
+ if sid_match_type == 'bargain_ready':
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ✅ 小刀订单缺少完整待发货卡片,'
+ f'使用sid+小刀成功证据兜底进入自动发货: order_id={order_id}'
+ )
+
+ # 【防重复检查】先检查该订单是否已经在冷却期内(说明完整消息已经处理过)
+ if not self.can_auto_delivery(order_id):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 🔒 订单 {order_id} 已在冷却期内(可能完整消息已处理),跳过简化消息发货')
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(订单已处理)")
+ return
+
+ # 【防重复检查】检查延迟锁状态
+ if self.is_lock_held(order_id):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 🔒 订单 {order_id} 延迟锁已被持有(可能完整消息正在处理),跳过简化消息发货')
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(订单正在处理)")
+ return
+
+ # 使用正确的商品ID和订单ID调用自动发货
+ simple_chat_id = session_id_str # 使用会话ID作为chat_id
+
+ # 调用自动发货处理(使用简化消息专用方法)
+ await self._handle_simple_message_auto_delivery(
+ websocket=websocket,
+ order_id=order_id,
+ item_id=real_item_id,
+ user_id=simple_user_id,
+ chat_id=simple_chat_id,
+ msg_time=msg_time,
+ msg_id=msg_id
+ )
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(简化消息自动发货)")
+ return
+ elif recent_order:
+ order_id = recent_order.get('order_id')
+ order_status = recent_order.get('order_status') or 'unknown'
+ if sid_match_type == 'already_processed':
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ℹ️ sid命中的订单已处理完成,跳过重复发货: '
+ f'order_id={order_id}, status={order_status}'
+ )
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(订单已处理)")
+ elif sid_match_type == 'cancelled':
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ℹ️ sid命中的订单已关闭,跳过自动发货: '
+ f'order_id={order_id}'
+ )
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(订单已关闭)")
+ else:
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ℹ️ sid命中的订单当前状态不适合简化消息兜底发货,等待后续完整消息: '
+ f'order_id={order_id}, status={order_status}'
+ )
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(订单状态未就绪)")
+ return
+ else:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ❌ 未找到sid {simple_sid} 的最近订单,跳过自动发货')
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(未找到订单)")
+ return
+ else:
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ⚠️ 未启用自动确认发货,跳过')
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(未启用自动发货)")
+ return
+ # 如果不是简化结构,继续走正常流程
+ except Exception:
+ pass
+
+ # 判断是否为聊天消息
+ if not self.is_chat_message(message):
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] ⏹️ 非聊天消息,处理结束")
+ return
+
+ # 处理聊天消息
+ try:
+ # 安全地提取聊天消息信息
+ if not (isinstance(message, dict) and "1" in message and isinstance(message["1"], dict)):
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ❌ 消息格式错误:缺少必要的字段结构")
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(格式错误)")
+ return
+
+ message_1 = message["1"]
+ if not isinstance(message_1.get("10"), dict):
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ❌ 消息格式错误:缺少消息详情字段")
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(缺少详情字段)")
+ return
+
+ create_time = int(message_1.get("5", 0))
+ message_10 = message_1["10"]
+ send_user_name = message_10.get("senderNick", message_10.get("reminderTitle", "未知用户"))
+ send_user_id = message_10.get("senderUserId", "unknown")
+ send_message = message_10.get("reminderContent", "")
+
+ chat_id_raw = message_1.get("2", "")
+ chat_id = chat_id_raw.split('@')[0] if '@' in str(chat_id_raw) else str(chat_id_raw)
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ❌ 提取聊天消息信息失败: {self._safe_str(e)}")
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(提取信息失败)")
+ return
+
+ # 格式化消息时间
+ msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(create_time/1000))
+
+
+
+ message_route_info = self._classify_message_route(
+ message=message,
+ message_1=message_1,
+ message_10=message_10,
+ send_message=send_message,
+ )
+ message_route = message_route_info.get('route', 'user_chat')
+ order_status_signal = message_route_info.get('order_status_signal')
+ should_notify_message = bool(message_route_info.get('should_notify'))
+ allow_auto_reply = bool(message_route_info.get('allow_auto_reply'))
+ is_system_message = bool(message_route_info.get('is_system_message'))
+ is_group_message = bool(message_route_info.get('is_group_message'))
+ message_direction = message_route_info.get('message_direction', 0)
+ content_type = message_route_info.get('content_type', 0)
+ card_title = str(message_route_info.get('card_title') or '').strip()
+ special_flow_card_titles = {
+ '我已小刀,待刀成',
+ '我已小刀,待刀成',
+ '我已成功小刀,待发货',
+ '我已成功小刀,待发货',
+ }
+
+ logger.info(
+ f"【{self.cookie_id}】[{msg_id}] 消息分类: route={message_route}, "
+ f"status_signal={order_status_signal or 'none'}, notify={should_notify_message}, "
+ f"auto_reply={allow_auto_reply}, system={is_system_message}, "
+ f"direction={message_direction}, contentType={content_type}"
+ )
+
+ if send_user_id == self.myid and not is_system_message:
+ logger.info(f"[{msg_time}] 【{self.cookie_id}】[{msg_id}] 【手动发出】 商品({item_id}): {send_message}")
+
+ # 暂停该chat_id的自动回复10分钟
+ pause_manager.pause_chat(chat_id, self.cookie_id)
+
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(手动发出消息)")
+ return
+ elif send_user_id == self.myid and is_system_message:
+ logger.info(
+ f"[{msg_time}] 【{self.cookie_id}】[{msg_id}] 检测到系统消息(sender=自己ID),继续执行状态处理 "
+ f"(direction={message_direction}, contentType={content_type})"
+ )
+ else:
+ logger.info(f"[{msg_time}] 【收到】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {send_message}")
+ if message_route == 'user_chat':
+ self.last_user_chat_time = time.time()
+
+ # 【优先处理】检查是否正在等待亦凡卡劵账号输入
+ async with self.yifan_account_lock:
+ if chat_id in self.yifan_account_waiting:
+ waiting_info = self.yifan_account_waiting[chat_id]
+
+ # 检查超时(30分钟)
+ if time.time() - waiting_info['create_time'] > 1800:
+ logger.warning(f"账号输入等待超时,清除等待状态")
+ del self.yifan_account_waiting[chat_id]
+ elif waiting_info['buyer_id'] == send_user_id:
+ # 检查是否为客户真实消息(过滤系统消息)
+ # 真实客户消息: message['1']['7'] = 2, contentType = 1
+ # 系统消息: message['1']['7'] = 1, contentType = 6 (textCard)
+ message_1 = message.get('1', {})
+ message_direction = message_1.get('7', 0) if isinstance(message_1, dict) else 0
+
+ # 获取contentType
+ content_type = 0
+ try:
+ message_6 = message_1.get('6', {})
+ if isinstance(message_6, dict):
+ message_6_3 = message_6.get('3', {})
+ if isinstance(message_6_3, dict):
+ content_type = message_6_3.get('4', 0)
+ except Exception:
+ pass
+
+ # 检查bizTag是否包含系统消息标识
+ is_system_msg = False
+ try:
+ message_10 = message_1.get('10', {})
+ if isinstance(message_10, dict):
+ biz_tag = message_10.get('bizTag', '')
+ if biz_tag and ('SECURITY' in biz_tag or 'taskName' in biz_tag or 'taskId' in biz_tag):
+ is_system_msg = True
+ except Exception:
+ pass
+
+ # 过滤非真实客户消息:
+ # 1. message['1']['7'] != 2 表示不是接收的消息
+ # 2. contentType = 6 表示系统卡片消息
+ # 3. bizTag包含系统标识
+ if message_direction != 2 or content_type == 6 or is_system_msg:
+ logger.info(f"【{self.cookie_id}】[{msg_id}] 收到系统消息,跳过账号确认处理(direction={message_direction}, contentType={content_type}, isSystem={is_system_msg})")
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(系统消息)")
+ return
+
+ # 是同一个用户的真实回复
+ if waiting_info['state'] == 'waiting_account':
+ # 等待账号输入阶段
+ account = send_message.strip()
+ if account:
+ # 保存账号并发送确认消息
+ waiting_info['account'] = account
+ waiting_info['state'] = 'waiting_confirm'
+
+ confirm_msg = f"{account}\n这是您要充值的账号,请回答\"是\",进行确认下单,如果账号不对,请重新输入正确的账号,如果因为您账号输错,导致错误下单,概不退款。"
+ await self.send_msg(self.ws, chat_id, send_user_id, confirm_msg)
+ logger.info(f"【{self.cookie_id}】[{msg_id}] 已保存充值账号: {account},等待用户确认")
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(等待账号确认)")
+ return # 处理完毕,不再继续其他流程
+
+ elif waiting_info['state'] == 'waiting_confirm':
+ # 等待确认阶段
+ user_reply = send_message.strip()
+
+ if user_reply == '是':
+ # 用户确认,继续发货流程
+ logger.info(f"用户确认账号,继续亦凡API发货流程")
+ account = waiting_info['account']
+ rule = waiting_info['rule']
+ order_id_saved = waiting_info.get('order_id')
+ item_id_saved = waiting_info.get('item_id')
+
+ # 清除等待状态
+ del self.yifan_account_waiting[chat_id]
+
+ # 继续执行亦凡API调用(带账号)
+ try:
+ # 直接调用亦凡API下单
+ delivery_content = await self._call_yifan_api_with_account(
+ rule, account, order_id_saved, item_id_saved, send_user_id, chat_id
+ )
+
+ if delivery_content:
+ delivery_steps = self._build_delivery_steps(
+ delivery_content,
+ rule.get('card_description', '')
+ )
+ await self._send_delivery_steps(
+ self.ws,
+ chat_id,
+ send_user_id,
+ delivery_steps,
+ log_prefix=f"亦凡账号确认发货 order_id={order_id_saved or 'unknown'}"
+ )
+
+ finalize_result = await self._finalize_delivery_after_send(
+ delivery_meta={
+ 'success': True,
+ 'rule_id': rule.get('id'),
+ 'card_id': rule.get('card_id'),
+ 'card_type': rule.get('card_type'),
+ 'order_spec_mode': None,
+ 'rule_spec_mode': None,
+ 'item_config_mode': None,
+ 'data_card_pending_consume': False,
+ 'data_line': None
+ },
+ order_id=order_id_saved,
+ item_id=item_id_saved
+ )
+ if not finalize_result.get('success'):
+ self._record_delivery_log(
+ order_id=order_id_saved,
+ item_id=item_id_saved,
+ buyer_id=send_user_id,
+ status='failed',
+ reason=finalize_result.get('error') or '亦凡账号确认发货发送成功但提交副作用失败',
+ channel='auto',
+ rule_meta={
+ 'rule_id': rule.get('id'),
+ 'rule_keyword': rule.get('keyword'),
+ 'card_type': rule.get('card_type')
+ }
+ )
+ await self.send_msg(self.ws, chat_id, send_user_id, "发货消息已发送,但确认发货失败,请稍后刷新订单状态。")
+ logger.error(f"亦凡API自动发货副作用提交失败: {finalize_result.get('error')}")
+ return
+
+ if order_id_saved:
+ self.mark_delivery_sent(order_id_saved, context="亦凡账号确认发货发送成功")
+ self._activate_delivery_lock(order_id_saved, delay_minutes=10)
+
+ self._record_delivery_log(
+ order_id=order_id_saved,
+ item_id=item_id_saved,
+ buyer_id=send_user_id,
+ status='success',
+ reason='亦凡账号确认发货发送成功',
+ channel='auto',
+ rule_meta={
+ 'rule_id': rule.get('id'),
+ 'rule_keyword': rule.get('keyword'),
+ 'card_type': rule.get('card_type')
+ }
+ )
+ logger.info(f"亦凡API自动发货成功")
+ else:
+ # 发货失败通知
+ await self.send_msg(self.ws, chat_id, send_user_id, "抱歉,自动发货失败,请联系客服处理。")
+ except Exception as e:
+ logger.error(f"亦凡API发货异常: {self._safe_str(e)}")
+ await self.send_msg(self.ws, chat_id, send_user_id, "系统异常,请联系客服处理。")
+
+ return # 处理完毕
+
+ else:
+ # 用户输入的不是"是",认为是重新输入账号
+ new_account = user_reply
+ if new_account:
+ waiting_info['account'] = new_account
+ waiting_info['retry_count'] += 1
+
+ # 检查重试次数
+ if waiting_info['retry_count'] >= 5:
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] 账号确认重试次数过多,取消发货")
+ del self.yifan_account_waiting[chat_id]
+ await self.send_msg(self.ws, chat_id, send_user_id, "账号确认失败次数过多,已取消发货,请重新下单。")
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(重试次数过多)")
+ return
+
+ confirm_msg = f"{new_account}\n这是您要充值的账号,请回答\"是\",进行确认下单,如果账号不对,请重新输入正确的账号,如果因为您账号输错,导致错误下单,概不退款。"
+ await self.send_msg(self.ws, chat_id, send_user_id, confirm_msg)
+ logger.info(f"【{self.cookie_id}】[{msg_id}] 用户重新输入账号: {new_account},再次等待确认")
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(等待账号重新确认)")
+ return
+
+ try:
+ if is_group_message:
+ logger.info(f"📱 检测到群组消息(sessionType=30),跳过消息通知")
+ elif should_notify_message:
+ await self.send_notification(send_user_name, send_user_id, send_message, item_id, chat_id)
+ else:
+ logger.info(
+ f"📱 当前消息不发送通知: route={message_route}, "
+ f"status_signal={order_status_signal or 'none'}, message={send_message}"
+ )
+ except Exception as notify_error:
+ logger.error(f"📱 发送消息通知失败: {self._safe_str(notify_error)}")
+
+
+
+
+ # 【优先处理】使用订单状态处理器处理系统消息
+ if self.order_status_handler:
+ try:
+ # 处理系统消息的订单状态更新
+ try:
+ handled = self.order_status_handler.handle_system_message(
+ message=message,
+ send_message=send_message,
+ cookie_id=self.cookie_id,
+ msg_time=msg_time,
+ match_context={
+ 'sid': message_1.get('2', '') if isinstance(message_1, dict) else None,
+ 'buyer_id': send_user_id,
+ 'item_id': item_id,
+ }
+ )
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】处理系统消息失败: {self._safe_str(e)}")
+ handled = False
+
+ # 处理红色提醒消息
+ if not handled:
+ try:
+ if isinstance(message, dict) and "3" in message and isinstance(message["3"], dict):
+ red_reminder = message["3"].get("redReminder")
+ user_id = message["3"].get("userId", "unknown")
+
+ if red_reminder:
+ try:
+ self.order_status_handler.handle_red_reminder_message(
+ message=message,
+ red_reminder=red_reminder,
+ user_id=user_id,
+ cookie_id=self.cookie_id,
+ msg_time=msg_time,
+ match_context={
+ 'sid': message_1.get('2', '') if isinstance(message_1, dict) else None,
+ 'buyer_id': send_user_id,
+ 'item_id': item_id,
+ }
+ )
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】处理红色提醒消息失败: {self._safe_str(e)}")
+ except Exception as red_e:
+ logger.warning(f"处理红色提醒消息失败: {self._safe_str(red_e)}")
+
+ except Exception as e:
+ logger.error(f"订单状态处理失败: {self._safe_str(e)}")
+
+ # 关键状态消息到达时,按需补刷一次订单详情,避免缓存把状态留在旧值
+ if order_id and order_status_signal in {'pending_ship', 'shipped', 'completed', 'cancelled', 'refunding'}:
+ try:
+ refresh_sid = ''
+ if isinstance(message_1, dict):
+ refresh_sid = message_1.get("2", "")
+
+ await self._maybe_force_refresh_order_detail_for_signal(
+ order_id=order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ sid=refresh_sid,
+ buyer_nick=send_user_name,
+ status_signal=order_status_signal,
+ reason=f'message_signal_{order_status_signal}',
+ delay_seconds=1 if order_status_signal == 'pending_ship' else 0,
+ log_prefix=f"【{self.cookie_id}】[{msg_id}]"
+ )
+ except Exception as refresh_e:
+ logger.error(
+ f"【{self.cookie_id}】[{msg_id}] 状态消息触发订单详情补刷失败: {self._safe_str(refresh_e)}"
+ )
+
+ # 【优先处理】检查系统消息和自动发货触发消息(不受人工接入暂停影响)
+ fallback_ignore_keywords = [
+ '不想宝贝被砍价',
+ 'AI正在帮你回复',
+ '发来一条',
+ '小心假客服骗钱',
+ '蚂蚁森林能量',
+ '恭喜你拿到曝光卡',
+ '订单即将自动确认收货',
+ '温馨提醒:商品信息近期有过变更',
+ ]
+ if send_message == '[我已拍下,待付款]':
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 系统消息不处理')
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(系统消息:待付款)")
+ return
+ elif send_message == '[你关闭了订单,钱款已原路退返]':
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 系统消息不处理')
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(系统消息:订单关闭)")
+ return
+ elif send_message in [
+ '快给ta一个评价吧~',
+ '快给ta一个评价吧~',
+ ]:
+ # 检测到评价提醒消息,尝试自动好评
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 🌟 检测到评价提醒消息: {send_message}')
+ await self.handle_auto_comment(message, msg_time, msg_id)
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(评价提醒消息)")
+ return
+ elif message_route == 'system_notice' or any(keyword in send_message for keyword in fallback_ignore_keywords):
+ logger.info(
+ f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ⏹️ 系统提示消息不处理: '
+ f'route={message_route}, message={send_message}'
+ )
+ return
+ # 简化消息通过 sid 查找订单,更可靠
+ elif message_route == 'order_status' and self._is_auto_delivery_trigger(send_message):
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 检测到自动发货触发消息: {send_message}')
+
+ # 只允许系统消息触发自动发货,防止买家手动输入关键字触发
+ if not is_system_message:
+ logger.warning(
+ f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ⚠️ 自动发货关键字来自非系统消息,已忽略 '
+ f'(direction={message_direction}, contentType={content_type})'
+ )
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(非系统触发)")
+ return
+
+ # 检查是否启用自动确认发货
+ if not self.is_auto_confirm_enabled():
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] 未启用自动确认发货,跳过自动发货')
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(未启用自动发货)")
+ return
+ # 使用统一的自动发货处理方法(传递message_data以便提取订单ID)
+ await self._handle_auto_delivery(websocket, message, send_user_name, send_user_id,
+ item_id, chat_id, msg_time, message_data)
+ logger.info(f"【{self.cookie_id}】[{msg_id}] ⏹️ 处理结束(自动发货完成)")
+ return
+ # 【重要】检查小刀流程卡片消息 - 即使在人工接入暂停期间也要处理
+ elif send_message == '[卡片消息]' or card_title in special_flow_card_titles:
+ # 检查是否为小刀相关卡片消息
+ try:
+ # 从消息中提取卡片内容
+ card_title = card_title or None
+ card_message_1 = message.get("1", {}) if isinstance(message, dict) else {}
+ if not card_title and isinstance(card_message_1, dict):
+ if "6" in card_message_1 and isinstance(card_message_1["6"], dict):
+ message_6 = card_message_1["6"]
+ if "3" in message_6 and isinstance(message_6["3"], dict):
+ message_6_3 = message_6["3"]
+ if "5" in message_6_3:
+ # 解析JSON内容
+ try:
+ card_content = json.loads(message_6_3["5"])
+ if "dxCard" in card_content and "item" in card_content["dxCard"]:
+ card_item = card_content["dxCard"]["item"]
+ if "main" in card_item and "exContent" in card_item["main"]:
+ ex_content = card_item["main"]["exContent"]
+ card_title = ex_content.get("title", "")
+ except (json.JSONDecodeError, KeyError) as e:
+ logger.warning(f"解析卡片消息失败: {e}")
+
+ # 卡片流程仅接受系统消息,避免伪造卡片触发
+ card_message_direction = card_message_1.get('7', 0) if isinstance(card_message_1, dict) else 0
+ card_content_type = 0
+ card_is_system_biz = False
+ try:
+ card_message_6 = card_message_1.get('6', {}) if isinstance(card_message_1, dict) else {}
+ if isinstance(card_message_6, dict):
+ card_message_6_3 = card_message_6.get('3', {})
+ if isinstance(card_message_6_3, dict):
+ card_content_type = card_message_6_3.get('4', 0)
+ except Exception:
+ pass
+
+ try:
+ card_message_10 = card_message_1.get('10', {}) if isinstance(card_message_1, dict) else {}
+ if isinstance(card_message_10, dict):
+ biz_tag = card_message_10.get('bizTag', '')
+ if biz_tag and ('SECURITY' in biz_tag or 'taskName' in biz_tag or 'taskId' in biz_tag):
+ card_is_system_biz = True
+ except Exception:
+ pass
+
+ is_system_card_message = card_message_direction == 1 or card_content_type == 6 or card_is_system_biz
+ if not is_system_card_message:
+ logger.warning(
+ f'[{msg_time}] 【{self.cookie_id}】[{msg_id}] ⚠️ 非系统卡片消息,忽略小刀流程 '
+ f'(direction={card_message_direction}, contentType={card_content_type}, isSystemBiz={card_is_system_biz})'
+ )
+ return
+
+ waiting_bargain_titles = {"我已小刀,待刀成", "我已小刀,待刀成"}
+ ready_to_ship_titles = {"我已成功小刀,待发货", "我已成功小刀,待发货"}
+
+ # 第一阶段:待刀成,仅执行免拼,不直接发货
+ if card_title in waiting_bargain_titles:
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】【系统】检测到"{card_title}",执行免拼流程')
+
+ # 检查是否启用自动确认发货
+ if not self.is_auto_confirm_enabled():
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】未启用自动确认发货,跳过自动小刀和自动发货')
+ return
+
+ # 检查商品是否属于当前cookies
+ if item_id and item_id != "未知商品":
+ try:
+ if not await self._ensure_item_owned_by_current_account(
+ item_id,
+ log_prefix=f'[{msg_time}] 【{self.cookie_id}】'
+ ):
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 商品 {item_id} 不属于当前账号,跳过免拼发货')
+ return
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】✅ 商品 {item_id} 归属验证通过')
+ except Exception as e:
+ logger.error(f'[{msg_time}] 【{self.cookie_id}】检查商品归属失败: {self._safe_str(e)},跳过免拼发货')
+ return
+
+ # 提取订单ID(传递原始消息数据以便在解密消息中找不到时进行备用搜索)
+ order_id = self._extract_order_id(message, message_data)
+ if not order_id:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 未能提取到订单ID,无法执行免拼发货')
+ return
+
+ self._mark_order_bargain_flow(
+ order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ context=card_title or 'waiting_bargain',
+ )
+
+ # 延迟2秒后执行免拼发货
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】延迟2秒后执行免拼发货...')
+ await asyncio.sleep(2)
+ # 调用自动免拼发货方法
+ result = await self.auto_freeshipping(order_id, item_id, send_user_id)
+ if result.get('success'):
+ self._mark_order_bargain_flow(
+ order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ apply_configured_price=True,
+ success_detected=True,
+ context=f'{card_title or "waiting_bargain"}_success',
+ )
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 自动免拼发货成功')
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】⏳ 已完成免拼,等待"我已成功小刀,待发货"卡片后再自动发货')
+ return
+ else:
+ logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 自动免拼发货失败: {result.get("error", "未知错误")}')
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】⏹️ 免拼失败,不执行自动发货')
+ return
+
+ # 第二阶段:成功小刀待发货,触发自动发货
+ elif card_title in ready_to_ship_titles:
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】【系统】检测到"{card_title}",开始自动发货')
+
+ order_id = self._extract_order_id(message, message_data)
+ if order_id:
+ self._mark_order_bargain_flow(
+ order_id,
+ item_id=item_id,
+ buyer_id=send_user_id,
+ apply_configured_price=True,
+ success_detected=True,
+ context=card_title,
+ )
+
+ # 检查是否启用自动确认发货
+ if not self.is_auto_confirm_enabled():
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】未启用自动确认发货,跳过自动发货')
+ return
+
+ await self._handle_auto_delivery(
+ websocket, message, send_user_name, send_user_id,
+ item_id, chat_id, msg_time, message_data
+ )
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】⏹️ 小刀成功待发货卡片处理完成')
+ return
+ else:
+ logger.info(f'[{msg_time}] 【{self.cookie_id}】收到卡片消息,标题: {card_title or "未知"}')
+ # 如果不是目标卡片消息,继续正常处理流程(会受到暂停影响)
+
+ except Exception as e:
+ logger.error(f"处理卡片消息异常: {self._safe_str(e)}")
+ # 如果处理异常,继续正常处理流程(会受到暂停影响)
+
+ # 自动更新买家昵称(补全历史订单的昵称信息)
+ # 需要过滤掉系统提示文本,避免将"买家已拍下,待付款"等写入昵称
+ if send_user_id and send_user_name:
+ valid_buyer_nick = self._sanitize_buyer_nick(
+ send_user_name,
+ source="message_sender",
+ message_meta=message_10 if isinstance(message_10, dict) else None,
+ log_prefix=f"【{self.cookie_id}】[{msg_id}]"
+ )
+ if valid_buyer_nick:
+ try:
+ from db_manager import db_manager
+ db_manager.update_buyer_nick_by_buyer_id(send_user_id, valid_buyer_nick, self.cookie_id)
+ except Exception as e:
+ logger.debug(f"更新买家昵称失败: {self._safe_str(e)}")
+
+ if not allow_auto_reply:
+ logger.info(
+ f"【{self.cookie_id}】[{msg_id}] ⏹️ 当前消息不进入自动回复链: "
+ f"route={message_route}, status_signal={order_status_signal or 'none'}"
+ )
+ return
+
+ # 使用防抖机制处理聊天消息回复
+ # 如果用户连续发送消息,等待用户停止发送后再回复最后一条消息
+ await self._schedule_debounced_reply(
+ chat_id=chat_id,
+ message_data=message_data,
+ websocket=websocket,
+ send_user_name=send_user_name,
+ send_user_id=send_user_id,
+ send_message=send_message,
+ item_id=item_id,
+ msg_time=msg_time
+ )
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ❌ 处理消息时发生异常: {self._safe_str(e)}")
+ if msg_size > 3000:
+ logger.error(f"【{self.cookie_id}】[{msg_id}] ⚠️⚠️⚠️ 大消息({msg_size}字节)处理异常!")
+ logger.warning(f"【{self.cookie_id}】[{msg_id}] 原始消息: {message_data}")
+ import traceback
+ logger.error(f"【{self.cookie_id}】[{msg_id}] 异常堆栈: {traceback.format_exc()}")
+ finally:
+ # 确保每条消息都有明确的处理结束标记
+ logger.info(f"【{self.cookie_id}】[{msg_id}] 🏁 消息处理完成 ({msg_size}字节)")
+
+ async def main(self):
+ """主程序入口"""
+ try:
+ logger.info(f"【{self.cookie_id}】开始启动XianyuLive主程序...")
+ await self.create_session() # 创建session
+ logger.info(f"【{self.cookie_id}】Session创建完成,开始WebSocket连接循环...")
+
+ while True:
+ try:
+ # 检查账号是否启用
+ from cookie_manager import manager as cookie_manager
+ if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
+ logger.info(f"【{self.cookie_id}】账号已禁用,停止主循环")
+ break
+
+ init_auth_state = self.get_init_auth_failure_state(self.cookie_id) or {}
+ circuit_until = init_auth_state.get('circuit_until', 0)
+ if circuit_until and time.time() < circuit_until:
+ remaining_seconds = max(1, int(circuit_until - time.time()))
+ self._set_connection_state(ConnectionState.RECONNECTING, f"初始化鉴权冷静期剩余{remaining_seconds}秒")
+ logger.warning(
+ f"【{self.cookie_id}】初始化鉴权失败熔断中,暂停发起新的WebSocket连接,剩余 {remaining_seconds} 秒"
+ )
+ await self._interruptible_sleep(remaining_seconds)
+ continue
+
+ headers = self._build_websocket_headers()
+
+ # 更新连接状态为连接中
+ self._set_connection_state(ConnectionState.CONNECTING, "准备建立WebSocket连接")
+ logger.info(f"【{self.cookie_id}】WebSocket目标地址: {self.base_url}")
+
+ # 兼容不同版本的websockets库
+ async with await self._create_websocket_connection(headers) as websocket:
+ self.ws = websocket
+ logger.info(f"【{self.cookie_id}】WebSocket连接建立成功,开始初始化...")
+
+ try:
+ # 开始初始化
+ await self.init(websocket)
+ logger.info(f"【{self.cookie_id}】WebSocket初始化完成!")
+
+ # 初始化完成后才设置为已连接状态
+ self._set_connection_state(ConnectionState.CONNECTED, "初始化完成,连接就绪")
+ self.connection_failures = 0
+ self.last_successful_connection = time.time()
+ self._reset_stream_activity_state(self.last_successful_connection)
+
+ # 记录后台任务启动前的状态
+ logger.warning(f"【{self.cookie_id}】准备启动后台任务 - 当前状态: heartbeat={self.heartbeat_task}, token_refresh={self.token_refresh_task}, cleanup={self.cleanup_task}, cookie_refresh={self.cookie_refresh_task}, stream_watchdog={self.stream_watchdog_task}")
+
+ # 如果存在心跳任务引用,先清理(心跳任务依赖WebSocket,必须重启)
+ if self.heartbeat_task:
+ logger.warning(f"【{self.cookie_id}】检测到旧心跳任务引用,先清理...")
+ self._reset_background_tasks()
+
+ # 启动心跳任务(依赖WebSocket,每次重连都需要重启)
+ logger.info(f"【{self.cookie_id}】启动心跳任务...")
+ self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(websocket))
+
+ # 启动其他后台任务(不依赖WebSocket,只在首次连接时启动)
+ tasks_started = []
+
+ if not self.token_refresh_task or self.token_refresh_task.done():
+ logger.info(f"【{self.cookie_id}】启动会话保活任务...")
+ self.token_refresh_task = asyncio.create_task(self.token_refresh_loop())
+ tasks_started.append("会话保活")
+ else:
+ logger.info(f"【{self.cookie_id}】Token刷新任务已在运行,跳过启动")
+
+ if not self.cleanup_task or self.cleanup_task.done():
+ logger.info(f"【{self.cookie_id}】启动暂停记录清理任务...")
+ self.cleanup_task = asyncio.create_task(self.pause_cleanup_loop())
+ tasks_started.append("暂停清理")
+ else:
+ logger.info(f"【{self.cookie_id}】暂停记录清理任务已在运行,跳过启动")
+
+ if not self.cookie_refresh_task or self.cookie_refresh_task.done():
+ logger.info(f"【{self.cookie_id}】启动Cookie刷新任务...")
+ self.cookie_refresh_task = asyncio.create_task(self.cookie_refresh_loop())
+ tasks_started.append("Cookie刷新")
+ else:
+ logger.info(f"【{self.cookie_id}】Cookie刷新任务已在运行,跳过启动")
+
+ if not self.stream_watchdog_task or self.stream_watchdog_task.done():
+ logger.info(f"【{self.cookie_id}】启动业务流看门狗任务...")
+ self.stream_watchdog_task = asyncio.create_task(self.message_stream_watchdog_loop())
+ tasks_started.append("业务流看门狗")
+ else:
+ logger.info(f"【{self.cookie_id}】业务流看门狗任务已在运行,跳过启动")
+
+ # 启动消息队列工作协程(高性能消息处理)
+ if self.message_queue_enabled:
+ await self._start_message_queue_workers()
+ tasks_started.append("消息队列")
+
+ # 记录所有后台任务状态
+ if tasks_started:
+ logger.info(f"【{self.cookie_id}】✅ 新启动的任务: {', '.join(tasks_started)}")
+ logger.info(f"【{self.cookie_id}】✅ 所有后台任务状态: 心跳(已启动), 会话保活({'运行中' if self.token_refresh_task and not self.token_refresh_task.done() else '已启动'}), 暂停清理({'运行中' if self.cleanup_task and not self.cleanup_task.done() else '已启动'}), Cookie刷新({'运行中' if self.cookie_refresh_task and not self.cookie_refresh_task.done() else '已启动'}), 业务流看门狗({'运行中' if self.stream_watchdog_task and not self.stream_watchdog_task.done() else '已启动'})")
+
+ logger.info(f"【{self.cookie_id}】开始监听WebSocket消息...")
+ logger.info(f"【{self.cookie_id}】WebSocket连接状态正常,等待服务器消息...")
+ logger.info(f"【{self.cookie_id}】准备进入消息循环...")
+
+ async for message in websocket:
+ try:
+ message_data = json.loads(message)
+
+ # 提取消息标识用于日志追踪(防止异步处理导致日志混乱)
+ msg_id = "unknown"
+ msg_preview = ""
+ try:
+ # 尝试从headers中提取mid
+ if isinstance(message_data, dict) and "headers" in message_data:
+ msg_id = message_data["headers"].get("mid", "unknown")
+ # 尝试提取消息预览(用于区分不同类型的消息)
+ if isinstance(message_data, dict) and "body" in message_data:
+ if "syncPushPackage" in message_data["body"]:
+ msg_preview = "[同步包]"
+ elif "ack" in str(message_data["body"]).lower():
+ msg_preview = "[确认]"
+ except Exception:
+ pass
+
+ logger.info(f"【{self.cookie_id}】📨 收到消息 [ID:{msg_id}] {msg_preview} {len(message) if message else 0}字节")
+
+ # 处理心跳响应(高优先级,直接处理)
+ if await self.handle_heartbeat_response(message_data):
+ continue
+
+ is_sync_package = self.is_sync_package(message_data)
+ self._mark_non_heartbeat_message(time.time(), is_sync_package=is_sync_package)
+
+ # 处理其他消息
+ # 使用高性能消息队列系统处理消息,解决消息阻塞问题
+ if self.message_queue_enabled and self.message_queue_running:
+ # 消息队列模式:快速入队,由工作协程异步处理
+ await self._enqueue_message(message_data, websocket, msg_id)
+ else:
+ # 传统模式:使用追踪的异步任务处理消息
+ self._create_tracked_task(self._handle_message_with_semaphore(message_data, websocket, msg_id))
+
+ except Exception as e:
+ logger.error(f"处理消息出错: {self._safe_str(e)}")
+ continue
+ finally:
+ # 停止消息队列工作协程
+ if self.message_queue_enabled and self.message_queue_running:
+ logger.info(f"【{self.cookie_id}】正在停止消息队列工作协程...")
+ await self._stop_message_queue_workers()
+
+ # 确保在退出 async with 块时清理 WebSocket 引用
+ # 注意:async with 会自动关闭 WebSocket,但我们需要清理引用
+ if self.ws == websocket:
+ self.ws = None
+ logger.info(f"【{self.cookie_id}】WebSocket连接已退出,引用已清理")
+
+ except InitAuthError as e:
+ error_msg = self._safe_str(e)
+ self.current_token = None
+ self.connection_failures = 0
+ init_auth_state = self.record_init_auth_failure(self.cookie_id, error_msg)
+ self.init_auth_failures = int(init_auth_state.get('count', 0))
+ self._set_connection_state(ConnectionState.RECONNECTING, f"初始化鉴权失败第{self.init_auth_failures}次")
+ logger.error(f"【{self.cookie_id}】初始化鉴权失败 ({self.init_auth_failures}/{self._init_auth_failure_threshold})")
+ logger.error(f"【{self.cookie_id}】初始化失败原因: {error_msg}")
+
+ retry_delay = self._calculate_retry_delay(error_msg)
+ circuit_until = init_auth_state.get('circuit_until', 0)
+ if circuit_until and time.time() < circuit_until:
+ circuit_wait = max(1, int(circuit_until - time.time()))
+ retry_delay = max(retry_delay, circuit_wait)
+ logger.warning(
+ f"【{self.cookie_id}】初始化鉴权失败已达到阈值,进入冷静期 {circuit_wait} 秒后再重试"
+ )
+ else:
+ logger.warning(f"【{self.cookie_id}】将在 {retry_delay} 秒后重试初始化鉴权...")
+
+ self._reset_background_tasks()
+ await self._interruptible_sleep(retry_delay)
+ logger.info(f"【{self.cookie_id}】初始化鉴权重试等待完成,准备重新建立连接...")
+ continue
+
+ except Exception as e:
+ error_msg = self._safe_str(e)
+ import traceback
+ error_type = type(e).__name__
+
+ # 检查是否是 ConnectionClosedError(正常的连接关闭)
+ is_connection_closed = (
+ 'ConnectionClosedError' in error_type or
+ 'ConnectionClosed' in error_type or
+ 'no close frame received or sent' in error_msg or
+ 'IncompleteReadError' in error_type
+ )
+
+ # 对于连接关闭错误,使用警告级别而不是错误级别
+ if is_connection_closed:
+ logger.warning(f"【{self.cookie_id}】WebSocket连接已关闭 ({self.connection_failures + 1}/{self.max_connection_failures})")
+ logger.warning(f"【{self.cookie_id}】关闭原因: {error_msg}")
+ else:
+ self.connection_failures += 1
+ # 更新连接状态为重连中
+ self._set_connection_state(ConnectionState.RECONNECTING, f"第{self.connection_failures}次失败")
+ logger.error(f"【{self.cookie_id}】WebSocket连接异常 ({self.connection_failures}/{self.max_connection_failures})")
+ logger.error(f"【{self.cookie_id}】异常类型: {error_type}")
+ logger.error(f"【{self.cookie_id}】异常信息: {error_msg}")
+ logger.warning(f"【{self.cookie_id}】异常堆栈:\n{traceback.format_exc()}")
+
+ # 确保清理 WebSocket 引用
+ if self.ws:
+ try:
+ # 检查 WebSocket 是否仍然打开
+ if hasattr(self.ws, 'close_code') and self.ws.close_code is None:
+ # WebSocket 可能仍然打开,尝试关闭
+ try:
+ await asyncio.wait_for(self.ws.close(), timeout=2.0)
+ except (asyncio.TimeoutError, Exception):
+ pass
+ except Exception:
+ pass
+ finally:
+ self.ws = None
+ logger.info(f"【{self.cookie_id}】WebSocket引用已清理")
+
+ # 对于连接关闭错误,也增加失败计数
+ if is_connection_closed:
+ self.connection_failures += 1
+ # 更新连接状态为重连中
+ self._set_connection_state(ConnectionState.RECONNECTING, f"连接关闭,第{self.connection_failures}次重连")
+
+ # 检查是否超过最大失败次数
+ if self.connection_failures >= self.max_connection_failures:
+ self._set_connection_state(ConnectionState.FAILED, f"连续失败{self.max_connection_failures}次")
+ logger.warning(f"【{self.cookie_id}】连续失败{self.max_connection_failures}次,尝试通过密码登录刷新Cookie...")
+
+ try:
+ # 调用统一的密码登录刷新方法
+ refresh_success = await self._try_password_login_refresh(
+ f"连续失败{self.max_connection_failures}次",
+ ignore_slider_failed_backoff=self._has_recent_slider_success(),
+ )
+
+ if refresh_success:
+ logger.info(f"【{self.cookie_id}】✅ 密码登录刷新成功,将重置失败计数并继续重连")
+ # 重置失败计数,因为已经刷新了Cookie
+ self.connection_failures = 0
+ # 更新连接状态
+ self._set_connection_state(ConnectionState.RECONNECTING, "Cookie已刷新,准备重连")
+ # 短暂等待后继续重连循环
+ await asyncio.sleep(2)
+ continue
+ else:
+ logger.warning(f"【{self.cookie_id}】❌ 密码登录刷新失败,将重启实例...")
+ except Exception as refresh_e:
+ logger.error(f"【{self.cookie_id}】密码登录刷新过程异常: {self._safe_str(refresh_e)}")
+ logger.warning(f"【{self.cookie_id}】将重启实例...")
+
+ # 如果密码登录刷新失败或异常,则重启实例
+ logger.error(f"【{self.cookie_id}】准备重启实例...")
+ self.connection_failures = 0 # 重置失败计数
+
+ # 先清理后台任务,避免与重启过程冲突
+ logger.info(f"【{self.cookie_id}】重启前先清理后台任务...")
+ try:
+ await asyncio.wait_for(
+ self._cancel_background_tasks(),
+ timeout=8.0 # 给足够时间让任务响应
+ )
+ logger.info(f"【{self.cookie_id}】后台任务已清理完成")
+ except asyncio.TimeoutError:
+ logger.warning(f"【{self.cookie_id}】后台任务清理超时,强制继续重启")
+ except Exception as cleanup_e:
+ logger.error(f"【{self.cookie_id}】后台任务清理失败: {self._safe_str(cleanup_e)}")
+
+ # 触发重启(不等待完成)
+ await self._restart_instance()
+
+ # ⚠️ 重要:_restart_instance() 已触发重启,2秒后当前任务会被取消
+ # 不要在这里等待或执行其他操作,让任务自然退出
+ logger.info(f"【{self.cookie_id}】重启请求已触发,主程序即将退出,新实例将自动启动")
+ return # 退出当前连接循环,等待被取消
+
+ # 计算重试延迟
+ retry_delay = self._calculate_retry_delay(error_msg)
+ logger.warning(f"【{self.cookie_id}】将在 {retry_delay} 秒后重试连接...")
+
+ try:
+ # 清空当前token,确保重新连接时会重新获取
+ if self.current_token:
+ logger.warning(f"【{self.cookie_id}】清空当前token,重新连接时将重新获取")
+ self.current_token = None
+
+ # 直接重置任务引用,不等待取消(快速重连方案)
+ # 这样可以避免等待任务取消导致的阻塞问题
+ logger.info(f"【{self.cookie_id}】准备重置后台任务引用(快速重连模式)...")
+ self._reset_background_tasks()
+ logger.info(f"【{self.cookie_id}】后台任务引用已重置,可以立即重连")
+
+ # 等待后重试 - 使用可中断的sleep,并定期输出日志证明进程还活着
+ logger.info(f"【{self.cookie_id}】开始等待 {retry_delay} 秒...")
+ # 强制刷新日志缓冲区,确保日志被写入
+ try:
+ sys.stdout.flush()
+ except Exception:
+ pass
+
+ # 使用可中断的sleep,每5秒输出一次心跳日志
+ chunk_size = 5.0 # 每5秒输出一次日志
+ remaining = retry_delay
+ start_time = time.time()
+
+ while remaining > 0:
+ sleep_time = min(chunk_size, remaining)
+ try:
+ await asyncio.sleep(sleep_time)
+ remaining -= sleep_time
+ elapsed = time.time() - start_time
+ if remaining > 0:
+ logger.info(f"【{self.cookie_id}】等待中... 已等待 {elapsed:.1f} 秒,剩余 {remaining:.1f} 秒")
+ # 定期刷新日志
+ try:
+ sys.stdout.flush()
+ except Exception:
+ pass
+ except asyncio.CancelledError:
+ logger.warning(f"【{self.cookie_id}】等待期间收到取消信号")
+ raise
+ except Exception as sleep_error:
+ logger.error(f"【{self.cookie_id}】等待期间发生异常: {self._safe_str(sleep_error)}")
+ logger.warning(f"【{self.cookie_id}】等待异常堆栈:\n{traceback.format_exc()}")
+ # 即使出错也继续等待剩余时间
+ if remaining > 0:
+ await asyncio.sleep(remaining)
+ break
+
+ logger.info(f"【{self.cookie_id}】等待完成(总耗时 {time.time() - start_time:.1f} 秒),准备重新连接...")
+ # 再次强制刷新日志
+ try:
+ sys.stdout.flush()
+ except Exception:
+ pass
+
+ except Exception as cleanup_error:
+ logger.error(f"【{self.cookie_id}】清理过程出错: {self._safe_str(cleanup_error)}")
+ logger.warning(f"【{self.cookie_id}】清理异常堆栈:\n{traceback.format_exc()}")
+ # 即使清理失败,也要重置任务引用并等待后重试
+ self.heartbeat_task = None
+ self.token_refresh_task = None
+ self.cleanup_task = None
+ self.cookie_refresh_task = None
+ self.stream_watchdog_task = None
+ logger.warning(f"【{self.cookie_id}】清理失败,已强制重置所有任务引用")
+ # 使用可中断的sleep,并定期输出日志
+ logger.info(f"【{self.cookie_id}】清理失败后开始等待 {retry_delay} 秒...")
+ chunk_size = 5.0
+ remaining = retry_delay
+ start_time = time.time()
+
+ while remaining > 0:
+ sleep_time = min(chunk_size, remaining)
+ try:
+ await asyncio.sleep(sleep_time)
+ remaining -= sleep_time
+ if remaining > 0:
+ logger.info(f"【{self.cookie_id}】清理失败后等待中... 剩余 {remaining:.1f} 秒")
+ except asyncio.CancelledError:
+ logger.warning(f"【{self.cookie_id}】清理失败后等待期间收到取消信号")
+ raise
+ except Exception as sleep_error:
+ logger.error(f"【{self.cookie_id}】清理失败后等待期间发生异常: {self._safe_str(sleep_error)}")
+ if remaining > 0:
+ await asyncio.sleep(remaining)
+ break
+
+ logger.info(f"【{self.cookie_id}】清理失败后等待完成(总耗时 {time.time() - start_time:.1f} 秒)")
+
+ # 继续下一次循环
+ logger.info(f"【{self.cookie_id}】开始新一轮WebSocket连接尝试...")
+ continue
+ finally:
+ # 更新连接状态为已关闭
+ self._set_connection_state(ConnectionState.CLOSED, "程序退出")
+
+ # 清空当前token
+ if self.current_token:
+ logger.info(f"【{self.cookie_id}】程序退出,清空当前token")
+ self.current_token = None
+
+ # 检查是否还有未取消的后台任务,如果有才执行清理
+ has_pending_tasks = any([
+ self.heartbeat_task and not self.heartbeat_task.done(),
+ self.token_refresh_task and not self.token_refresh_task.done(),
+ self.cleanup_task and not self.cleanup_task.done(),
+ self.cookie_refresh_task and not self.cookie_refresh_task.done(),
+ self.stream_watchdog_task and not self.stream_watchdog_task.done()
+ ])
+
+ if has_pending_tasks:
+ logger.info(f"【{self.cookie_id}】检测到未完成的后台任务,执行清理...")
+ # 使用统一的任务清理方法,添加超时保护
+ try:
+ await asyncio.wait_for(
+ self._cancel_background_tasks(),
+ timeout=10.0
+ )
+ except asyncio.TimeoutError:
+ logger.error(f"【{self.cookie_id}】程序退出时任务取消超时,强制继续")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】程序退出时任务取消失败: {self._safe_str(e)}")
+ finally:
+ # 确保任务引用被重置
+ self.heartbeat_task = None
+ self.token_refresh_task = None
+ self.cleanup_task = None
+ self.cookie_refresh_task = None
+ self.stream_watchdog_task = None
+ else:
+ logger.info(f"【{self.cookie_id}】所有后台任务已清理完成,跳过重复清理")
+ # 确保任务引用被重置
+ self.heartbeat_task = None
+ self.token_refresh_task = None
+ self.cleanup_task = None
+ self.cookie_refresh_task = None
+ self.stream_watchdog_task = None
+
+ # 清理所有后台任务
+ if self.background_tasks:
+ logger.info(f"【{self.cookie_id}】等待 {len(self.background_tasks)} 个后台任务完成...")
+ try:
+ await asyncio.wait_for(
+ asyncio.gather(*self.background_tasks, return_exceptions=True),
+ timeout=10.0 # 10秒超时
+ )
+ except asyncio.TimeoutError:
+ logger.warning(f"【{self.cookie_id}】后台任务清理超时,强制继续")
+
+ # 确保关闭session
+ await self.close_session()
+
+ # 从全局实例字典中注销当前实例
+ self._unregister_instance()
+ logger.info(f"【{self.cookie_id}】XianyuLive主程序已完全退出")
+
+ async def get_item_list_info(self, page_number=1, page_size=20, retry_count=0, sync_item_details=False):
+ """获取商品信息,自动处理token失效的情况
+
+ Args:
+ page_number (int): 页码,从1开始
+ page_size (int): 每页数量,默认20
+ retry_count (int): 重试次数,内部使用
+ sync_item_details (bool): 是否同步已存在商品的最新详情
+ """
+ if retry_count >= 4: # 最多重试3次
+ logger.error("获取商品信息失败,重试次数过多")
+ return {"error": "获取商品信息失败,重试次数过多"}
+
+ # 确保session已创建
+ if not self.session:
+ await self.create_session()
+
+ params = {
+ 'jsv': '2.7.2',
+ 'appKey': '34839810',
+ 't': str(int(time.time()) * 1000),
+ 'sign': '',
+ 'v': '1.0',
+ 'type': 'originaljson',
+ 'accountSite': 'xianyu',
+ 'dataType': 'json',
+ 'timeout': '20000',
+ 'api': 'mtop.idle.web.xyh.item.list',
+ 'sessionOption': 'AutoLoginOnly',
+ 'spm_cnt': 'a21ybx.im.0.0',
+ 'spm_pre': 'a21ybx.collection.menu.1.272b5141NafCNK'
+ }
+
+ data = {
+ 'needGroupInfo': False,
+ 'pageNumber': page_number,
+ 'pageSize': page_size,
+ 'groupName': '在售',
+ 'groupId': '58877261',
+ 'defaultGroup': True,
+ "userId": self.myid
+ }
+
+ # 始终从最新的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 ''
+
+ logger.warning(f"准备获取商品列表,token: {token}")
+ if token:
+ logger.warning(f"使用cookies中的_m_h5_tk token: {self._mask_secret_value(token, head=6, tail=4)}")
+ else:
+ logger.warning("cookies中没有找到_m_h5_tk token")
+
+ # 生成签名
+ data_val = json.dumps(data, separators=(',', ':'))
+ sign = generate_sign(params['t'], token, data_val)
+ params['sign'] = sign
+
+ try:
+ async with self.session.post(
+ 'https://h5api.m.goofish.com/h5/mtop.idle.web.xyh.item.list/1.0/',
+ params=params,
+ data={'data': data_val}
+ ) as response:
+ res_json = await response.json()
+
+ if await self._apply_response_cookie_updates(response.headers, "item_list"):
+ logger.warning("已更新Cookie到数据库")
+
+ logger.info(f"商品信息获取响应: {res_json}")
+
+ # 检查响应是否成功
+ if res_json.get('ret') and res_json['ret'][0] == 'SUCCESS::调用成功':
+ items_data = res_json.get('data', {})
+ # 从cardList中提取商品信息
+ card_list = items_data.get('cardList', [])
+
+ # 解析cardList中的商品信息
+ items_list = []
+ for card in card_list:
+ card_data = card.get('cardData', {})
+ if card_data:
+ # 提取商品基本信息
+ item_info = {
+ 'id': card_data.get('id', ''),
+ 'title': card_data.get('title', ''),
+ 'price': card_data.get('priceInfo', {}).get('price', ''),
+ 'price_text': card_data.get('priceInfo', {}).get('preText', '') + card_data.get('priceInfo', {}).get('price', ''),
+ 'category_id': card_data.get('categoryId', ''),
+ 'auction_type': card_data.get('auctionType', ''),
+ 'item_status': card_data.get('itemStatus', 0),
+ 'detail_url': card_data.get('detailUrl', ''),
+ 'pic_info': card_data.get('picInfo', {}),
+ 'detail_params': card_data.get('detailParams', {}),
+ 'track_params': card_data.get('trackParams', {}),
+ 'item_label_data': card_data.get('itemLabelDataVO', {}),
+ 'card_type': card.get('cardType', 0)
+ }
+ items_list.append(item_info)
+
+ logger.info(f"成功获取到 {len(items_list)} 个商品")
+
+ # 打印商品详细信息到控制台
+ print("\n" + "="*80)
+ print(f"📦 账号 {self.myid} 的商品列表 (第{page_number}页,{len(items_list)} 个商品)")
+ print("="*80)
+
+ for i, item in enumerate(items_list, 1):
+ print(f"\n🔸 商品 {i}:")
+ print(f" 商品ID: {item.get('id', 'N/A')}")
+ print(f" 商品标题: {item.get('title', 'N/A')}")
+ print(f" 价格: {item.get('price_text', 'N/A')}")
+ print(f" 分类ID: {item.get('category_id', 'N/A')}")
+ print(f" 商品状态: {item.get('item_status', 'N/A')}")
+ print(f" 拍卖类型: {item.get('auction_type', 'N/A')}")
+ print(f" 详情链接: {item.get('detail_url', 'N/A')}")
+ if item.get('pic_info'):
+ pic_info = item['pic_info']
+ print(f" 图片信息: {pic_info.get('width', 'N/A')}x{pic_info.get('height', 'N/A')}")
+ print(f" 图片链接: {pic_info.get('picUrl', 'N/A')}")
+ print(f" 完整信息: {json.dumps(item, ensure_ascii=False, indent=2)}")
+
+ print("\n" + "="*80)
+ print("✅ 商品列表获取完成")
+ print("="*80)
+
+ # 自动保存商品信息到数据库
+ if items_list:
+ saved_count = await self.save_items_list_to_db(
+ items_list,
+ sync_item_details=sync_item_details,
+ )
+ logger.info(f"已将 {saved_count} 个商品信息保存到数据库")
+
+ return {
+ "success": True,
+ "page_number": page_number,
+ "page_size": page_size,
+ "current_count": len(items_list),
+ "items": items_list,
+ "saved_count": saved_count if items_list else 0,
+ "raw_data": items_data # 保留原始数据以备调试
+ }
+ else:
+ # 检查是否是token失效
+ error_msg = res_json.get('ret', [''])[0] if res_json.get('ret') else ''
+ if 'FAIL_SYS_TOKEN_EXOIRED' in error_msg or 'token' in error_msg.lower():
+ logger.warning(f"Token失效,准备重试: {error_msg}")
+ await asyncio.sleep(0.5)
+ return await self.get_item_list_info(
+ page_number,
+ page_size,
+ retry_count + 1,
+ sync_item_details=sync_item_details,
+ )
+ else:
+ logger.error(f"获取商品信息失败: {res_json}")
+ return {"error": f"获取商品信息失败: {error_msg}"}
+
+ except Exception as e:
+ logger.error(f"商品信息API请求异常: {self._safe_str(e)}")
+ await asyncio.sleep(0.5)
+ return await self.get_item_list_info(
+ page_number,
+ page_size,
+ retry_count + 1,
+ sync_item_details=sync_item_details,
+ )
+
+ async def get_all_items(self, page_size=20, max_pages=None, sync_item_details=False):
+ """获取所有商品信息(自动分页)
+
+ Args:
+ page_size (int): 每页数量,默认20
+ max_pages (int): 最大页数限制,None表示无限制
+ sync_item_details (bool): 是否同步已存在商品的最新详情
+
+ Returns:
+ dict: 包含所有商品信息的字典
+ """
+ all_items = []
+ page_number = 1
+ total_saved = 0
+
+ logger.info(f"开始获取所有商品信息,每页{page_size}条")
+
+ while True:
+ if max_pages and page_number > max_pages:
+ logger.info(f"达到最大页数限制 {max_pages},停止获取")
+ break
+
+ logger.info(f"正在获取第 {page_number} 页...")
+ result = await self.get_item_list_info(
+ page_number,
+ page_size,
+ sync_item_details=sync_item_details,
+ )
+
+ if not result.get("success"):
+ logger.error(f"获取第 {page_number} 页失败: {result}")
+ break
+
+ current_items = result.get("items", [])
+ if not current_items:
+ logger.info(f"第 {page_number} 页没有数据,获取完成")
+ break
+
+ all_items.extend(current_items)
+ total_saved += result.get("saved_count", 0)
+
+ logger.info(f"第 {page_number} 页获取到 {len(current_items)} 个商品")
+
+ # 如果当前页商品数量少于页面大小,说明已经是最后一页
+ if len(current_items) < page_size:
+ logger.info(f"第 {page_number} 页商品数量({len(current_items)})少于页面大小({page_size}),获取完成")
+ break
+
+ page_number += 1
+
+ # 添加延迟避免请求过快
+ await asyncio.sleep(1)
+
+ logger.info(f"所有商品获取完成,共 {len(all_items)} 个商品,保存了 {total_saved} 个")
+
+ return {
+ "success": True,
+ "total_pages": page_number,
+ "total_count": len(all_items),
+ "total_saved": total_saved,
+ "items": all_items
+ }
+
+ def _get_item_polish_module(self):
+ if os.getenv('ITEM_POLISH_IMPL', '').strip().lower() == 'plain':
+ from item_polish_module import ItemPolishModule
+ else:
+ from secure_item_polish_ultra import ItemPolishModule
+
+ return ItemPolishModule(self)
+
+ async def polish_item(self, item_id, retry_count=0):
+ """擦亮单个商品。"""
+ return await self._get_item_polish_module().polish_item(item_id, retry_count)
+
+ async def _polish_item_backup(self, item_id):
+ """使用备用API擦亮商品。"""
+ return await self._get_item_polish_module()._polish_item_backup(item_id)
+
+ async def polish_all_items(self):
+ """擦亮所有在售商品。"""
+ return await self._get_item_polish_module().polish_all_items()
+
+ async def send_image_msg(self, ws, cid, toid, image_url, width=800, height=600, card_id=None):
+ """发送图片消息"""
+ try:
+ # 检查图片URL是否需要上传到CDN
+ original_url = image_url
+
+ if self._is_cdn_url(image_url):
+ # 已经是CDN链接,直接使用
+ logger.info(f"【{self.cookie_id}】使用已有的CDN图片链接: {image_url}")
+ elif image_url.startswith('/static/uploads/') or image_url.startswith('static/uploads/'):
+ # 本地图片,需要上传到闲鱼CDN
+ local_image_path = image_url.replace('/static/uploads/', 'static/uploads/')
+ if os.path.exists(local_image_path):
+ logger.info(f"【{self.cookie_id}】准备上传本地图片到闲鱼CDN: {local_image_path}")
+
+ # 使用图片上传器上传到闲鱼CDN
+ from utils.image_uploader import ImageUploader
+ uploader = ImageUploader(self.cookies_str)
+
+ async with uploader:
+ cdn_url = await uploader.upload_image(local_image_path)
+ if cdn_url:
+ logger.info(f"【{self.cookie_id}】图片上传成功,CDN URL: {cdn_url}")
+ image_url = cdn_url
+
+ # 如果是卡券图片,更新数据库中的图片URL
+ if card_id is not None:
+ await self._update_card_image_url(card_id, cdn_url)
+
+ # 获取实际图片尺寸
+ from utils.image_utils import image_manager
+ try:
+ actual_width, actual_height = image_manager.get_image_size(local_image_path)
+ if actual_width and actual_height:
+ width, height = actual_width, actual_height
+ logger.info(f"【{self.cookie_id}】获取到实际图片尺寸: {width}x{height}")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】获取图片尺寸失败,使用默认尺寸: {e}")
+ else:
+ logger.error(f"【{self.cookie_id}】图片上传失败: {local_image_path}")
+ logger.error(f"【{self.cookie_id}】❌ Cookie可能已失效!请检查配置并更新Cookie")
+ raise Exception(f"图片上传失败(Cookie可能已失效): {local_image_path}")
+ else:
+ logger.error(f"【{self.cookie_id}】本地图片文件不存在: {local_image_path}")
+ raise Exception(f"本地图片文件不存在: {local_image_path}")
+ else:
+ logger.warning(f"【{self.cookie_id}】未知的图片URL格式: {image_url}")
+
+ # 记录详细的图片信息
+ logger.info(f"【{self.cookie_id}】准备发送图片消息:")
+ logger.info(f" - 原始URL: {original_url}")
+ logger.info(f" - CDN URL: {image_url}")
+ logger.info(f" - 图片尺寸: {width}x{height}")
+ logger.info(f" - 聊天ID: {cid}")
+ logger.info(f" - 接收者ID: {toid}")
+
+ # 构造图片消息内容 - 使用正确的闲鱼格式
+ image_content = {
+ "contentType": 2, # 图片消息类型
+ "image": {
+ "pics": [
+ {
+ "height": int(height),
+ "type": 0,
+ "url": image_url,
+ "width": int(width)
+ }
+ ]
+ }
+ }
+
+ # Base64编码
+ content_json = json.dumps(image_content, ensure_ascii=False)
+ content_base64 = str(base64.b64encode(content_json.encode('utf-8')), 'utf-8')
+
+ logger.info(f"【{self.cookie_id}】图片内容JSON: {content_json}")
+ logger.info(f"【{self.cookie_id}】Base64编码长度: {len(content_base64)}")
+
+ # 构造WebSocket消息(完全参考send_msg的格式)
+ msg = {
+ "lwp": "/r/MessageSend/sendByReceiverScope",
+ "headers": {
+ "mid": generate_mid()
+ },
+ "body": [
+ {
+ "uuid": generate_uuid(),
+ "cid": f"{cid}@goofish",
+ "conversationType": 1,
+ "content": {
+ "contentType": 101,
+ "custom": {
+ "type": 1,
+ "data": content_base64
+ }
+ },
+ "redPointPolicy": 0,
+ "extension": {
+ "extJson": "{}"
+ },
+ "ctx": {
+ "appVersion": "1.0",
+ "platform": "web"
+ },
+ "mtags": {},
+ "msgReadStatusSetting": 1
+ },
+ {
+ "actualReceivers": [
+ f"{toid}@goofish",
+ f"{self.myid}@goofish"
+ ]
+ }
+ ]
+ }
+
+ await ws.send(json.dumps(msg))
+ logger.info(f"【{self.cookie_id}】图片消息发送成功: {image_url}")
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】发送图片消息失败: {self._safe_str(e)}")
+ raise
+
+ async def send_image_from_file(self, ws, cid, toid, image_path):
+ """从本地文件发送图片"""
+ try:
+ # 上传图片到闲鱼CDN
+ logger.info(f"【{self.cookie_id}】开始上传图片: {image_path}")
+
+ from utils.image_uploader import ImageUploader
+ uploader = ImageUploader(self.cookies_str)
+
+ async with uploader:
+ image_url = await uploader.upload_image(image_path)
+
+ if image_url:
+ # 获取图片信息
+ from utils.image_utils import image_manager
+ try:
+ from PIL import Image
+ with Image.open(image_path) as img:
+ width, height = img.size
+ except Exception as e:
+ logger.warning(f"无法获取图片尺寸,使用默认值: {e}")
+ width, height = 800, 600
+
+ # 发送图片消息
+ await self.send_image_msg(ws, cid, toid, image_url, width, height)
+ logger.info(f"【{self.cookie_id}】图片发送完成: {image_path} -> {image_url}")
+ return True
+ else:
+ logger.error(f"【{self.cookie_id}】图片上传失败: {image_path}")
+ logger.error(f"【{self.cookie_id}】❌ Cookie可能已失效!请检查配置并更新Cookie")
+ return False
+
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】从文件发送图片失败: {self._safe_str(e)}")
+ return False
+
+if __name__ == '__main__':
+ cookies_str = os.getenv('COOKIES_STR')
+ xianyuLive = XianyuLive(cookies_str)
+ asyncio.run(xianyuLive.main())
diff --git a/ai_reply_engine.py b/ai_reply_engine.py
new file mode 100644
index 0000000..5ee8651
--- /dev/null
+++ b/ai_reply_engine.py
@@ -0,0 +1,628 @@
+"""
+AI回复引擎模块 - 统一意图识别与回复生成
+
+【重构版本】
+- 将意图判断和回复生成合并为一次AI调用
+- AI根据完整上下文自行判断意图并生成回复
+- 避免关键词误判导致的不当回复
+- 支持多种API类型:OpenAI / OpenAI Responses / Gemini / Anthropic / Azure OpenAI / Ollama / DashScope
+"""
+
+import json
+import time
+import requests
+import threading
+from typing import List, Dict, Optional
+from loguru import logger
+from db_manager import db_manager
+
+
+class AIReplyEngine:
+ """AI回复引擎 - 统一意图识别与回复生成"""
+
+ def __init__(self):
+ self._init_default_prompts()
+ # 用于控制同一chat_id消息的串行处理
+ self._chat_locks = {}
+ self._chat_locks_lock = threading.Lock()
+
+ def _init_default_prompts(self):
+ """初始化默认提示词(用于构建统一提示词)"""
+ self.default_prompts = {
+ 'price': '''【议价场景】
+策略:根据议价次数递减优惠
+- 第1次:可小幅优惠,表达诚意
+- 第2次:中等优惠,强调已是优惠价
+- 第3次及以后:最大优惠或坚持底线
+语气友好但坚定,突出商品价值和优势。''',
+
+ 'tech': '''【技术/产品问题】
+基于商品信息回答,不要自行发挥。
+如果问题超出商品信息范围,回复:"等等,这个我需要看一看"''',
+
+ 'default': '''【一般咨询】
+基于商品信息回答物流、售后等问题。
+如果问题超出商品信息范围,回复:"等等,这个我需要看一看"
+如果客户明确询问退款,回复:"虚拟产品,一旦发出是不可以退款的"'''
+ }
+
+ def _resolve_api_type(self, settings: dict) -> str:
+ """根据设置解析实际的API类型(支持显式设置和自动检测)"""
+ api_type = (settings.get('api_type') or '').strip()
+ if api_type:
+ return api_type
+
+ # 向后兼容:自动检测
+ if self._is_dashscope_app_api(settings):
+ return 'dashscope'
+ if self._is_gemini_api(settings):
+ return 'gemini'
+ return 'openai'
+
+ def _is_dashscope_app_api(self, settings: dict) -> bool:
+ """判断是否为DashScope应用API(/apps/模式)"""
+ base_url = (settings.get('base_url') or '').strip().rstrip('/')
+ return 'dashscope.aliyuncs.com' in base_url and '/apps/' in base_url
+
+ def _is_gemini_api(self, settings: dict) -> bool:
+ """判断是否为Gemini API"""
+ model_name = settings.get('model_name', '').lower()
+ return 'gemini' in model_name
+
+ def _build_unified_system_prompt(self, custom_prompts: dict, settings: dict) -> str:
+ """
+ 构建统一的系统提示词
+ 将意图判断和回复生成整合到一个提示词中
+ """
+ # 获取各场景的指导(优先使用用户自定义)
+ price_guide = custom_prompts.get('price', self.default_prompts['price'])
+ tech_guide = custom_prompts.get('tech', self.default_prompts['tech'])
+ default_guide = custom_prompts.get('default', self.default_prompts['default'])
+
+ # 获取议价设置
+ max_bargain_rounds = settings.get('max_bargain_rounds', 3)
+ max_discount_percent = settings.get('max_discount_percent', 10)
+ max_discount_amount = settings.get('max_discount_amount', 100)
+
+ unified_prompt = f"""你是一位专业的电商客服AI助手。请根据用户消息和上下文,直接生成合适的回复。
+
+## 核心原则
+1. **准确理解意图**:只根据用户实际说的内容判断,不要过度解读
+2. **不要主动提及敏感话题**:用户没提到的(如退款、砍价)不要主动提
+3. **基于商品信息回答**:只回答商品信息中有的内容
+4. **避免重复**:结合对话历史,不要重复之前说过的话
+5. **语言简洁友好**:回复要自然、简短,尽量别超过20个字
+
+## 场景处理指南
+
+### 当用户明确要求降价/优惠/砍价时
+{price_guide}
+- 议价限制:最多{max_bargain_rounds}轮,最大优惠{max_discount_percent}%或{max_discount_amount}元
+
+### 当用户询问产品技术/功能/使用问题时
+{tech_guide}
+
+### 其他一般咨询(物流、售后、商品介绍等)
+{default_guide}
+
+## 特别注意
+- 用户只是问价格≠用户在砍价,正常回答价格即可
+- 用户咨询售后≠用户要退款,正常解答即可
+- 如果用户的问题超过你的回答范围,比如发图片,可以说"等等,这个问题我需要看看",不要自己回答
+
+请直接输出回复内容,不要输出分析过程。"""
+
+ return unified_prompt
+
+ def _call_dashscope_api(self, settings: dict, messages: list, max_tokens: int = 100, temperature: float = 0.7) -> str:
+ """调用DashScope API"""
+ base_url = settings['base_url']
+ if '/apps/' in base_url:
+ app_id = base_url.split('/apps/')[-1].split('/')[0]
+ else:
+ raise ValueError("DashScope API URL中未找到app_id")
+
+ url = f"https://dashscope.aliyuncs.com/api/v1/apps/{app_id}/completion"
+
+ system_content = ""
+ user_content = ""
+ for msg in messages:
+ if msg['role'] == 'system':
+ system_content = msg['content']
+ elif msg['role'] == 'user':
+ user_content = msg['content'] # 假设 user prompt 已在 generate_reply 中构建好
+
+ if system_content and user_content:
+ prompt = f"{system_content}\n\n用户问题:{user_content}\n\n请直接回答用户的问题:"
+ elif user_content:
+ prompt = user_content
+ else:
+ prompt = "\n".join([f"{msg['role']}: {msg['content']}" for msg in messages])
+
+ data = {
+ "input": {"prompt": prompt},
+ "parameters": {"max_tokens": max_tokens, "temperature": temperature},
+ "debug": {}
+ }
+ headers = {
+ "Authorization": f"Bearer {settings['api_key']}",
+ "Content-Type": "application/json"
+ }
+
+ logger.info(f"DashScope API请求: {url}")
+ logger.info(f"发送的prompt: {prompt[:100]}...") # 避免 prompt 过长
+ logger.debug(f"请求数据: {json.dumps(data, ensure_ascii=False)}")
+
+ response = requests.post(url, headers=headers, json=data, timeout=30)
+
+ if response.status_code != 200:
+ logger.error(f"DashScope API请求失败: {response.status_code} - {response.text}")
+ raise Exception(f"DashScope API请求失败: {response.status_code} - {response.text}")
+
+ result = response.json()
+ logger.debug(f"DashScope API响应: {json.dumps(result, ensure_ascii=False)}")
+
+ if 'output' in result and 'text' in result['output']:
+ return result['output']['text'].strip()
+ else:
+ raise Exception(f"DashScope API响应格式错误: {result}")
+
+ def _call_gemini_api(self, settings: dict, messages: list, max_tokens: int = 100, temperature: float = 0.7) -> str:
+ """
+ 调用Google Gemini REST API (v1beta)
+ """
+ api_key = settings['api_key']
+ model_name = settings['model_name']
+
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={api_key}"
+
+ headers = {"Content-Type": "application/json"}
+
+ # --- 转换消息格式 (修复 P1-3: 增强健壮性) ---
+ system_instruction = ""
+ user_content_parts = []
+
+ # 遍历消息,找到 system 和所有的 user parts
+ for msg in messages:
+ if msg['role'] == 'system':
+ system_instruction = msg['content']
+ elif msg['role'] == 'user':
+ # 我们只关心 user content
+ user_content_parts.append(msg['content'])
+
+ # 将所有 user parts 合并为最后的 user_content
+ # 在我们的使用场景中 (generate_reply),只会有一个 user part,但这样更安全
+ user_content = "\n".join(user_content_parts)
+
+ if not user_content:
+ logger.warning(f"Gemini API 调用: 未在消息中找到 'user' 角色内容。Messages: {messages}")
+ raise ValueError("未在消息中找到用户内容 (user content)")
+ # --- 消息格式转换结束 ---
+
+ payload = {
+ "contents": [
+ {
+ "role": "user",
+ "parts": [{"text": user_content}]
+ }
+ ],
+ "generationConfig": {
+ "temperature": temperature,
+ "maxOutputTokens": max_tokens
+ }
+ }
+
+ if system_instruction:
+ payload["systemInstruction"] = {
+ "parts": [{"text": system_instruction}]
+ }
+
+ logger.info(f"Calling Gemini REST API: {url.split('?')[0]}")
+ logger.debug(f"Gemini Payload: {json.dumps(payload, ensure_ascii=False)}")
+
+ response = requests.post(url, headers=headers, json=payload, timeout=30)
+
+ if response.status_code != 200:
+ logger.error(f"Gemini API 请求失败: {response.status_code} - {response.text}")
+ raise Exception(f"Gemini API 请求失败: {response.status_code} - {response.text}")
+
+ result = response.json()
+ logger.debug(f"Gemini API 响应: {json.dumps(result, ensure_ascii=False)}")
+
+ try:
+ reply_text = result['candidates'][0]['content']['parts'][0]['text']
+ return reply_text.strip()
+ except (KeyError, IndexError, TypeError) as e:
+ logger.error(f"Gemini API 响应格式错误: {result} - {e}")
+ raise Exception(f"Gemini API 响应格式错误: {result}")
+
+ def _call_openai_chat_api(self, settings: dict, messages: list, max_tokens: int = 100, temperature: float = 0.7) -> str:
+ """调用OpenAI Chat Completions API(兼容OpenAI / Ollama / 其他兼容服务)"""
+ base_url = settings['base_url'].rstrip('/')
+ if not base_url.endswith('/v1'):
+ base_url = base_url + '/v1'
+ url = f"{base_url}/chat/completions"
+
+ headers = {"Content-Type": "application/json"}
+ api_key = settings.get('api_key', '')
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+
+ data = {
+ "model": settings['model_name'],
+ "messages": messages,
+ "max_tokens": max_tokens,
+ "temperature": temperature
+ }
+
+ logger.info(f"OpenAI Chat API请求: {url}")
+ response = requests.post(url, headers=headers, json=data, timeout=30)
+
+ if response.status_code != 200:
+ logger.error(f"OpenAI Chat API请求失败: {response.status_code} - {response.text}")
+ raise Exception(f"OpenAI Chat API请求失败: {response.status_code} - {response.text}")
+
+ result = response.json()
+ return result['choices'][0]['message']['content'].strip()
+
+ def _call_openai_responses_api(self, settings: dict, messages: list, max_tokens: int = 100, temperature: float = 0.7) -> str:
+ """调用OpenAI Responses API"""
+ base_url = settings['base_url'].rstrip('/')
+ if not base_url.endswith('/v1'):
+ base_url = base_url + '/v1'
+ url = f"{base_url}/responses"
+
+ headers = {
+ "Authorization": f"Bearer {settings['api_key']}",
+ "Content-Type": "application/json"
+ }
+ data = {
+ "model": settings['model_name'],
+ "input": messages,
+ "max_output_tokens": max_tokens,
+ "temperature": temperature
+ }
+
+ logger.info(f"OpenAI Responses API请求: {url}")
+ response = requests.post(url, headers=headers, json=data, timeout=30)
+
+ if response.status_code != 200:
+ logger.error(f"OpenAI Responses API请求失败: {response.status_code} - {response.text}")
+ raise Exception(f"OpenAI Responses API请求失败: {response.status_code} - {response.text}")
+
+ result = response.json()
+ # Responses API 返回 output_text 字段
+ if 'output_text' in result:
+ return result['output_text'].strip()
+ # 兼容解析 output 数组
+ for item in result.get('output', []):
+ if item.get('type') == 'message':
+ for content in item.get('content', []):
+ if content.get('type') == 'output_text':
+ return content['text'].strip()
+ raise Exception(f"OpenAI Responses API响应格式错误: {result}")
+
+ def _call_anthropic_api(self, settings: dict, messages: list, max_tokens: int = 100, temperature: float = 0.7) -> str:
+ """调用Anthropic Claude Messages API"""
+ base_url = settings['base_url'].rstrip('/')
+ if base_url.endswith('/v1'):
+ url = f"{base_url}/messages"
+ else:
+ url = f"{base_url}/v1/messages"
+
+ headers = {
+ "x-api-key": settings['api_key'],
+ "anthropic-version": "2023-06-01",
+ "Content-Type": "application/json"
+ }
+
+ # Anthropic 格式:system 单独提取,messages 只包含 user/assistant
+ system_content = ""
+ api_messages = []
+ for msg in messages:
+ if msg['role'] == 'system':
+ system_content = msg['content']
+ else:
+ api_messages.append({"role": msg['role'], "content": msg['content']})
+
+ data = {
+ "model": settings['model_name'],
+ "max_tokens": max_tokens,
+ "temperature": temperature,
+ "messages": api_messages
+ }
+ if system_content:
+ data["system"] = system_content
+
+ logger.info(f"Anthropic API请求: {url}")
+ response = requests.post(url, headers=headers, json=data, timeout=30)
+
+ if response.status_code != 200:
+ logger.error(f"Anthropic API请求失败: {response.status_code} - {response.text}")
+ raise Exception(f"Anthropic API请求失败: {response.status_code} - {response.text}")
+
+ result = response.json()
+ return result['content'][0]['text'].strip()
+
+ def _call_azure_openai_api(self, settings: dict, messages: list, max_tokens: int = 100, temperature: float = 0.7) -> str:
+ """调用Azure OpenAI API"""
+ base_url = settings['base_url'].rstrip('/')
+ # Azure URL 格式: https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version=xxx
+ # 用户应在 base_url 中填入完整的 deployment URL
+ if '/chat/completions' in base_url:
+ url = base_url
+ else:
+ url = f"{base_url}/chat/completions"
+
+ if 'api-version' not in url:
+ separator = '&' if '?' in url else '?'
+ url = f"{url}{separator}api-version=2024-02-01"
+
+ headers = {
+ "api-key": settings['api_key'],
+ "Content-Type": "application/json"
+ }
+ data = {
+ "messages": messages,
+ "max_tokens": max_tokens,
+ "temperature": temperature
+ }
+
+ logger.info(f"Azure OpenAI API请求: {url.split('?')[0]}")
+ response = requests.post(url, headers=headers, json=data, timeout=30)
+
+ if response.status_code != 200:
+ logger.error(f"Azure OpenAI API请求失败: {response.status_code} - {response.text}")
+ raise Exception(f"Azure OpenAI API请求失败: {response.status_code} - {response.text}")
+
+ result = response.json()
+ return result['choices'][0]['message']['content'].strip()
+
+ def is_ai_enabled(self, cookie_id: str) -> bool:
+ """检查指定账号是否启用AI回复"""
+ settings = db_manager.get_ai_reply_settings(cookie_id)
+ return settings['ai_enabled']
+
+ def _get_chat_lock(self, chat_id: str) -> threading.Lock:
+ """获取指定chat_id的锁,如果不存在则创建"""
+ with self._chat_locks_lock:
+ if chat_id not in self._chat_locks:
+ self._chat_locks[chat_id] = threading.Lock()
+ return self._chat_locks[chat_id]
+
+ def generate_reply(self, message: str, item_info: dict, chat_id: str,
+ cookie_id: str, user_id: str, item_id: str,
+ skip_wait: bool = False) -> Optional[str]:
+ """
+ 生成AI回复 - 统一意图识别与回复生成
+ AI会自动判断用户意图并生成合适的回复,避免关键词误判
+ """
+ if not self.is_ai_enabled(cookie_id):
+ return None
+
+ try:
+ # 先保存用户消息到数据库(意图暂时设为None,后续可根据需要更新)
+ message_created_at = self.save_conversation(
+ chat_id, cookie_id, user_id, item_id, "user", message, intent=None
+ )
+
+ # 消息去抖处理
+ if not skip_wait:
+ logger.info(f"【{cookie_id}】消息已保存,等待10秒收集后续消息: {message[:20]}...")
+ time.sleep(10)
+ else:
+ logger.info(f"【{cookie_id}】消息已保存(外部防抖已启用): {message[:20]}...")
+
+ # 获取该chat_id的锁,确保同一对话的消息串行处理
+ chat_lock = self._get_chat_lock(chat_id)
+
+ with chat_lock:
+ # 检查是否有更新的消息
+ query_seconds = 6 if skip_wait else 25
+ recent_messages = self._get_recent_user_messages(chat_id, cookie_id, seconds=query_seconds)
+
+ if recent_messages and len(recent_messages) > 0:
+ latest_message = recent_messages[-1]
+ if message_created_at != latest_message['created_at']:
+ logger.info(f"【{cookie_id}】检测到更新消息,跳过当前消息")
+ return None
+
+ # 1. 获取AI设置
+ settings = db_manager.get_ai_reply_settings(cookie_id)
+ custom_prompts = json.loads(settings['custom_prompts']) if settings['custom_prompts'] else {}
+
+ # 2. 获取对话历史
+ context = self.get_conversation_context(chat_id, cookie_id)
+
+ # 3. 获取对话轮数和议价设置(供AI参考)
+ conversation_rounds = self.get_conversation_rounds(chat_id, cookie_id)
+ max_bargain_rounds = settings.get('max_bargain_rounds', 3)
+ max_discount_percent = settings.get('max_discount_percent', 10)
+ max_discount_amount = settings.get('max_discount_amount', 100)
+
+ # 4. 构建统一的系统提示词(整合意图判断和回复生成)
+ system_prompt = self._build_unified_system_prompt(custom_prompts, settings)
+
+ # 5. 构建商品信息
+ item_desc = f"商品标题: {item_info.get('title', '未知')}\n"
+ item_desc += f"商品价格: {item_info.get('price', '未知')}元\n"
+ item_desc += f"商品描述: {item_info.get('desc', '无')}"
+
+ # 6. 构建对话历史字符串
+ context_str = ""
+ if context:
+ context_str = "\n".join([
+ f"{'客户' if msg['role'] == 'user' else '客服'}: {msg['content']}"
+ for msg in context[-10:]
+ ])
+
+ # 7. 构建用户消息(包含所有上下文)
+ user_prompt = f"""## 商品信息
+{item_desc}
+
+## 对话历史
+{context_str if context_str else '(新对话,暂无历史)'}
+
+## 对话状态
+- 当前对话轮数:第{conversation_rounds + 1}轮
+- 议价限制:最多{max_bargain_rounds}轮议价后需坚持底价
+- 最大可优惠:{max_discount_percent}%或{max_discount_amount}元
+
+## 当前用户消息
+{message}
+
+请根据以上信息,直接回复用户:"""
+
+ # 8. 构建消息列表
+ messages = [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt}
+ ]
+
+ # 9. 根据API类型调用对应的AI接口
+ reply = None
+ api_type = self._resolve_api_type(settings)
+ logger.info(f"使用 {api_type} API生成回复")
+
+ if api_type == 'dashscope':
+ # DashScope有两种模式:
+ # 1) /apps/{app_id} 应用模式 -> 走百炼应用API
+ # 2) compatible-mode/v1 兼容模式 -> 走OpenAI Chat Completions
+ if self._is_dashscope_app_api(settings):
+ reply = self._call_dashscope_api(settings, messages, max_tokens=150, temperature=0.7)
+ else:
+ logger.info("DashScope检测为兼容模式(非/apps/),改走OpenAI兼容Chat API")
+ reply = self._call_openai_chat_api(settings, messages, max_tokens=150, temperature=0.7)
+ elif api_type == 'gemini':
+ reply = self._call_gemini_api(settings, messages, max_tokens=150, temperature=0.7)
+ elif api_type == 'openai_responses':
+ reply = self._call_openai_responses_api(settings, messages, max_tokens=150, temperature=0.7)
+ elif api_type == 'anthropic':
+ reply = self._call_anthropic_api(settings, messages, max_tokens=150, temperature=0.7)
+ elif api_type == 'azure_openai':
+ reply = self._call_azure_openai_api(settings, messages, max_tokens=150, temperature=0.7)
+ else:
+ # openai / ollama / 空值 均走 chat/completions
+ reply = self._call_openai_chat_api(settings, messages, max_tokens=150, temperature=0.7)
+
+ # 10. 保存AI回复到对话记录
+ self.save_conversation(chat_id, cookie_id, user_id, item_id, "assistant", reply, intent=None)
+
+ logger.info(f"AI回复生成成功 (账号: {cookie_id}): {reply}")
+ return reply
+
+ except Exception as e:
+ logger.error(f"AI回复生成失败 {cookie_id}: {e}")
+ if hasattr(e, 'response') and hasattr(e.response, 'url'):
+ logger.error(f"请求URL: {e.response.url}")
+ if hasattr(e, 'request') and hasattr(e.request, 'url'):
+ logger.error(f"请求URL: {e.request.url}")
+ return None
+
+ async def generate_reply_async(self, message: str, item_info: dict, chat_id: str,
+ cookie_id: str, user_id: str, item_id: str,
+ skip_wait: bool = False) -> Optional[str]:
+ """
+ 异步包装器:在独立线程池中执行同步的 `generate_reply`,并返回结果。
+ 这样可以在异步代码中直接 await,而不阻塞事件循环。
+ """
+ try:
+ import asyncio as _asyncio
+ return await _asyncio.to_thread(self.generate_reply, message, item_info, chat_id, cookie_id, user_id, item_id, skip_wait)
+ except Exception as e:
+ logger.error(f"异步生成回复失败: {e}")
+ return None
+
+ def get_conversation_context(self, chat_id: str, cookie_id: str, limit: int = 20) -> List[Dict]:
+ """获取对话上下文"""
+ try:
+ with db_manager.lock:
+ cursor = db_manager.conn.cursor()
+ cursor.execute('''
+ SELECT role, content FROM ai_conversations
+ WHERE chat_id = ? AND cookie_id = ?
+ ORDER BY created_at DESC LIMIT ?
+ ''', (chat_id, cookie_id, limit))
+
+ results = cursor.fetchall()
+ context = [{"role": row[0], "content": row[1]} for row in reversed(results)]
+ return context
+ except Exception as e:
+ logger.error(f"获取对话上下文失败: {e}")
+ return []
+
+ def save_conversation(self, chat_id: str, cookie_id: str, user_id: str,
+ item_id: str, role: str, content: str, intent: str = None) -> Optional[str]:
+ """保存对话记录,返回创建时间"""
+ try:
+ with db_manager.lock:
+ cursor = db_manager.conn.cursor()
+ cursor.execute('''
+ INSERT INTO ai_conversations
+ (cookie_id, chat_id, user_id, item_id, role, content, intent)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ''', (cookie_id, chat_id, user_id, item_id, role, content, intent))
+ db_manager.conn.commit()
+
+ # 获取刚插入记录的created_at
+ cursor.execute('''
+ SELECT created_at FROM ai_conversations
+ WHERE rowid = last_insert_rowid()
+ ''')
+ result = cursor.fetchone()
+ return result[0] if result else None
+ except Exception as e:
+ logger.error(f"保存对话记录失败: {e}")
+ return None
+ def get_conversation_rounds(self, chat_id: str, cookie_id: str) -> int:
+ """获取对话轮数(用户消息数量)"""
+ try:
+ with db_manager.lock:
+ cursor = db_manager.conn.cursor()
+ cursor.execute('''
+ SELECT COUNT(*) FROM ai_conversations
+ WHERE chat_id = ? AND cookie_id = ? AND role = 'user'
+ ''', (chat_id, cookie_id))
+
+ result = cursor.fetchone()
+ return result[0] if result else 0
+ except Exception as e:
+ logger.error(f"获取对话轮数失败: {e}")
+ return 0
+
+ def _get_recent_user_messages(self, chat_id: str, cookie_id: str, seconds: int = 2) -> List[Dict]:
+ """获取最近seconds秒内的所有用户消息(包含内容和时间戳)"""
+ try:
+ with db_manager.lock:
+ cursor = db_manager.conn.cursor()
+ # 先查询所有该chat的user消息,用于调试
+ cursor.execute('''
+ SELECT content, created_at,
+ julianday('now') - julianday(created_at) as time_diff_days,
+ (julianday('now') - julianday(created_at)) * 86400.0 as time_diff_seconds
+ FROM ai_conversations
+ WHERE chat_id = ? AND cookie_id = ? AND role = 'user'
+ ORDER BY created_at DESC LIMIT 10
+ ''', (chat_id, cookie_id))
+
+ all_messages = cursor.fetchall()
+ logger.info(f"【调试】chat_id={chat_id} 最近10条user消息: {[(msg[0][:10], msg[1], f'{msg[3]:.2f}秒前') for msg in all_messages]}")
+
+ # 正式查询
+ cursor.execute('''
+ SELECT content, created_at FROM ai_conversations
+ WHERE chat_id = ? AND cookie_id = ? AND role = 'user'
+ AND julianday('now') - julianday(created_at) < (? / 86400.0)
+ ORDER BY created_at ASC
+ ''', (chat_id, cookie_id, seconds))
+
+ results = cursor.fetchall()
+ return [{"content": row[0], "created_at": row[1]} for row in results]
+ except Exception as e:
+ logger.error(f"获取最近用户消息列表失败: {e}")
+ return []
+
+
+
+# 全局AI回复引擎实例
+ai_reply_engine = AIReplyEngine()
diff --git a/announcement.json b/announcement.json
new file mode 100644
index 0000000..85ef509
--- /dev/null
+++ b/announcement.json
@@ -0,0 +1,37 @@
+{
+ "announcements": [
+ {
+ "id": "2026-04-08-issue-feedback",
+ "enabled": true,
+ "level": "info",
+ "title": "问题反馈",
+ "message": "如有问题请 issue 反馈",
+ "dismissible": false,
+ "published_at": "2026-04-08T11:00:00+08:00",
+ "start_at": "2026-04-08T11:00:00+08:00",
+ "end_at": ""
+ },
+ {
+ "id": "2026-04-08-dashboard-announcement",
+ "enabled": true,
+ "level": "info",
+ "title": "系统公告",
+ "message": "这是第一条公告",
+ "dismissible": true,
+ "published_at": "2026-04-08T10:30:00+08:00",
+ "start_at": "2026-04-08T10:30:00+08:00",
+ "end_at": "2026-04-08T10:59:59+08:00"
+ },
+ {
+ "id": "2026-04-07-dashboard-history-test",
+ "enabled": true,
+ "level": "warning",
+ "title": "历史公告测试",
+ "message": "历史公告测试",
+ "dismissible": true,
+ "published_at": "2026-04-07T10:00:00+08:00",
+ "start_at": "2026-04-07T10:00:00+08:00",
+ "end_at": "2026-04-07T23:59:59+08:00"
+ }
+ ]
+}
diff --git a/api_captcha_remote.py b/api_captcha_remote.py
new file mode 100644
index 0000000..fa8a0be
--- /dev/null
+++ b/api_captcha_remote.py
@@ -0,0 +1,318 @@
+"""
+刮刮乐远程控制 API 路由
+提供 WebSocket 和 HTTP 接口用于远程操作滑块验证
+"""
+
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException
+from fastapi.responses import HTMLResponse, FileResponse
+from pydantic import BaseModel
+from typing import Optional, List
+import asyncio
+import os
+from loguru import logger
+
+from utils.captcha_remote_control import captcha_controller
+
+
+# 创建路由器
+router = APIRouter(prefix="/api/captcha", tags=["captcha"])
+
+
+class MouseEvent(BaseModel):
+ """鼠标事件模型"""
+ session_id: str
+ event_type: str # down, move, up
+ x: int
+ y: int
+
+
+class SessionCheckRequest(BaseModel):
+ """会话检查请求"""
+ session_id: str
+
+
+# =============================================================================
+# WebSocket 端点 - 实时通信
+# =============================================================================
+
+@router.websocket("/ws/{session_id}")
+async def websocket_endpoint(websocket: WebSocket, session_id: str):
+ """
+ WebSocket 连接用于实时传输截图和接收鼠标事件
+ """
+ await websocket.accept()
+ logger.info(f"🔌 WebSocket 连接建立: {session_id}")
+
+ # 注册 WebSocket 连接
+ captcha_controller.websocket_connections[session_id] = websocket
+
+ try:
+ # 发送初始会话信息
+ if session_id in captcha_controller.active_sessions:
+ session_data = captcha_controller.active_sessions[session_id]
+ await websocket.send_json({
+ 'type': 'session_info',
+ 'screenshot': session_data['screenshot'],
+ 'captcha_info': session_data['captcha_info'],
+ 'viewport': session_data['viewport']
+ })
+
+ # 不启动自动刷新,改为只在操作时更新(极速优化)
+ # refresh_task = asyncio.create_task(
+ # captcha_controller.auto_refresh_screenshot(session_id, interval=1.5)
+ # )
+ else:
+ await websocket.send_json({
+ 'type': 'error',
+ 'message': '会话不存在'
+ })
+ await websocket.close()
+ return
+
+ # 持续接收客户端消息
+ while True:
+ data = await websocket.receive_json()
+ msg_type = data.get('type')
+
+ if msg_type == 'mouse_event':
+ # 处理鼠标事件
+ event_type = data.get('event_type')
+ x = data.get('x')
+ y = data.get('y')
+
+ success = await captcha_controller.handle_mouse_event(
+ session_id, event_type, x, y
+ )
+
+ if success:
+ # 只在鼠标释放后才检查完成状态
+ if event_type == 'up':
+ # 等待页面更新(给验证码一些反应时间)
+ await asyncio.sleep(1.0)
+
+ # 多次确认滑块确实消失
+ completed = await captcha_controller.check_completion(session_id)
+
+ if completed:
+ # 再次确认(避免误判)
+ await asyncio.sleep(0.5)
+ completed = await captcha_controller.check_completion(session_id)
+
+ if completed:
+ await websocket.send_json({
+ 'type': 'completed',
+ 'message': '验证成功!'
+ })
+ logger.success(f"✅ 验证完成: {session_id}")
+ break
+ else:
+ # 更新截图显示验证结果
+ screenshot = await captcha_controller.update_screenshot(session_id)
+ if screenshot:
+ await websocket.send_json({
+ 'type': 'screenshot_update',
+ 'screenshot': screenshot
+ })
+ else:
+ # 按下或移动时,实时更新截图(截取整个验证码容器)
+ if event_type in ['down', 'move']:
+ # 截取整个验证码容器,降低质量换取速度
+ screenshot = await captcha_controller.update_screenshot(session_id, quality=30)
+ if screenshot:
+ await websocket.send_json({
+ 'type': 'screenshot_update',
+ 'screenshot': screenshot
+ })
+
+ elif msg_type == 'check_completion':
+ # 手动检查完成状态
+ completed = await captcha_controller.check_completion(session_id)
+ await websocket.send_json({
+ 'type': 'completion_status',
+ 'completed': completed
+ })
+
+ if completed:
+ break
+
+ elif msg_type == 'ping':
+ # 心跳
+ await websocket.send_json({'type': 'pong'})
+
+ except WebSocketDisconnect:
+ logger.info(f"🔌 WebSocket 连接断开: {session_id}")
+
+ except Exception as e:
+ logger.error(f"❌ WebSocket 错误: {e}")
+ import traceback
+ logger.error(traceback.format_exc())
+
+ finally:
+ # 清理
+ if session_id in captcha_controller.websocket_connections:
+ del captcha_controller.websocket_connections[session_id]
+
+ logger.info(f"🔒 WebSocket 会话结束: {session_id}")
+
+
+# =============================================================================
+# HTTP 端点 - REST API
+# =============================================================================
+
+@router.get("/sessions")
+async def get_active_sessions():
+ """获取所有活跃的验证会话"""
+ sessions = []
+ for session_id, data in captcha_controller.active_sessions.items():
+ sessions.append({
+ 'session_id': session_id,
+ 'completed': data.get('completed', False),
+ 'has_websocket': session_id in captcha_controller.websocket_connections
+ })
+
+ return {
+ 'count': len(sessions),
+ 'sessions': sessions
+ }
+
+
+@router.get("/session/{session_id}")
+async def get_session_info(session_id: str):
+ """获取指定会话的信息"""
+ if session_id not in captcha_controller.active_sessions:
+ raise HTTPException(status_code=404, detail="会话不存在")
+
+ session_data = captcha_controller.active_sessions[session_id]
+
+ return {
+ 'session_id': session_id,
+ 'screenshot': session_data['screenshot'],
+ 'captcha_info': session_data['captcha_info'],
+ 'viewport': session_data['viewport'],
+ 'completed': session_data.get('completed', False)
+ }
+
+
+@router.get("/screenshot/{session_id}")
+async def get_screenshot(session_id: str):
+ """获取最新截图"""
+ screenshot = await captcha_controller.update_screenshot(session_id)
+
+ if not screenshot:
+ raise HTTPException(status_code=404, detail="无法获取截图")
+
+ return {'screenshot': screenshot}
+
+
+@router.post("/mouse_event")
+async def handle_mouse_event(event: MouseEvent):
+ """处理鼠标事件(HTTP方式,不推荐,建议使用WebSocket)"""
+ success = await captcha_controller.handle_mouse_event(
+ event.session_id,
+ event.event_type,
+ event.x,
+ event.y
+ )
+
+ if not success:
+ raise HTTPException(status_code=400, detail="处理失败")
+
+ # 检查是否完成
+ completed = await captcha_controller.check_completion(event.session_id)
+
+ return {
+ 'success': True,
+ 'completed': completed
+ }
+
+
+@router.post("/check_completion")
+async def check_completion(request: SessionCheckRequest):
+ """检查验证是否完成"""
+ completed = await captcha_controller.check_completion(request.session_id)
+
+ return {
+ 'session_id': request.session_id,
+ 'completed': completed
+ }
+
+
+@router.delete("/session/{session_id}")
+async def close_session(session_id: str):
+ """关闭会话"""
+ await captcha_controller.close_session(session_id)
+ return {'success': True}
+
+
+# =============================================================================
+# 前端页面
+# =============================================================================
+
+@router.get("/status/{session_id}")
+async def get_captcha_status(session_id: str):
+ """
+ 获取验证状态
+ 用于前端轮询检查验证是否完成
+ """
+ try:
+ is_completed = captcha_controller.is_completed(session_id)
+ session_exists = captcha_controller.session_exists(session_id)
+
+ return {
+ "success": True,
+ "completed": is_completed,
+ "session_exists": session_exists,
+ "session_id": session_id
+ }
+ except Exception as e:
+ logger.error(f"获取验证状态失败: {e}")
+ return {
+ "success": False,
+ "completed": False,
+ "session_exists": False,
+ "session_id": session_id,
+ "error": str(e)
+ }
+
+
+@router.get("/control", response_class=HTMLResponse)
+async def captcha_control_page():
+ """返回滑块控制页面"""
+ html_file = "captcha_control.html"
+
+ if os.path.exists(html_file):
+ return FileResponse(html_file, media_type="text/html")
+ else:
+ # 返回简单的提示页面
+ return HTMLResponse(content="""
+
+
+
+ 验证码控制面板
+
+
+ 验证码控制面板
+ 前端页面文件 captcha_control.html 不存在
+ 请查看文档了解如何创建前端页面
+
+
+ """)
+
+
+@router.get("/control/{session_id}", response_class=HTMLResponse)
+async def captcha_control_page_with_session(session_id: str):
+ """返回带会话ID的滑块控制页面"""
+ html_file = "captcha_control.html"
+
+ if os.path.exists(html_file):
+ with open(html_file, 'r', encoding='utf-8') as f:
+ html_content = f.read()
+ # 注入会话ID
+ html_content = html_content.replace(
+ '
+
+
+
+
+
+
+
+
+
+
+
+
📋 使用说明
+
+ - 输入会话ID并点击"连接"按钮
+ - 等待页面截图加载
+ - 使用鼠标在滑块按钮上按下并拖动
+ - 释放鼠标完成验证
+
+
+
+
+
+
+
+
+
+
+
+',
+ f''
+ )
+ return HTMLResponse(content=html_content)
+ else:
+ raise HTTPException(status_code=404, detail="前端页面不存在")
+
diff --git a/auto_updater.py b/auto_updater.py
new file mode 100644
index 0000000..fa2fd88
--- /dev/null
+++ b/auto_updater.py
@@ -0,0 +1,1019 @@
+"""
+自动热更新模块
+
+支持在不重新下载整个exe/容器的情况下,自动更新少量修改的文件。
+
+更新机制:
+1. 从远程服务器获取更新清单(包含文件列表、版本、MD5哈希)
+2. 比较本地文件与远程文件的哈希值
+3. 只下载有变化的文件
+4. 备份旧文件,下载新文件
+5. 需要时重启应用
+
+支持更新的文件类型:
+- Python 源文件 (.py)
+- 前端文件 (.js, .ts, .tsx, .jsx, .vue, .css, .html, .txt, .json)
+- 配置文件 (.yml, .json)
+- 静态资源(图片、字体等)
+"""
+
+import os
+import sys
+import json
+import hashlib
+import shutil
+import tempfile
+import asyncio
+import aiohttp
+from pathlib import Path
+from datetime import datetime
+from typing import Dict, List, Optional, Tuple, Any
+from loguru import logger
+from dataclasses import dataclass, asdict
+from enum import Enum
+
+
+class UpdateStatus(Enum):
+ """更新状态"""
+ IDLE = "idle" # 空闲
+ CHECKING = "checking" # 检查中
+ DOWNLOADING = "downloading" # 下载中
+ INSTALLING = "installing" # 安装中
+ COMPLETED = "completed" # 完成
+ FAILED = "failed" # 失败
+ RESTART_REQUIRED = "restart_required" # 需要重启
+
+
+@dataclass
+class FileUpdate:
+ """文件更新信息"""
+ path: str # 相对路径
+ md5: str # MD5哈希
+ size: int # 文件大小
+ download_url: str # 下载URL
+ version: str # 文件版本
+ requires_restart: bool # 是否需要重启
+ description: str = "" # 更新说明
+
+
+@dataclass
+class DeletedFile:
+ """待删除文件信息"""
+ path: str
+ requires_restart: bool
+ description: str = ""
+
+
+@dataclass
+class UpdateManifest:
+ """更新清单"""
+ version: str # 版本号
+ release_date: str # 发布日期
+ description: str # 版本说明
+ files: List[FileUpdate] # 文件列表
+ deleted_files: List[DeletedFile] = None # 待删除文件列表
+ min_version: str = "" # 最低兼容版本
+ changelog: List[str] = None # 更新日志
+
+
+@dataclass
+class UpdateProgress:
+ """更新进度"""
+ status: UpdateStatus
+ current_file: str = ""
+ current_index: int = 0
+ total_files: int = 0
+ downloaded_bytes: int = 0
+ total_bytes: int = 0
+ message: str = ""
+ error: str = ""
+
+
+class AutoUpdater:
+ """自动更新器"""
+
+ # 默认 GitHub 更新源配置
+ DEFAULT_GITHUB_API_BASE = "https://api.github.com"
+ DEFAULT_GITHUB_RAW_BASE = "https://raw.githubusercontent.com"
+ DEFAULT_GITHUB_OWNER = "GuDong2003"
+ DEFAULT_GITHUB_REPO = "xianyu-auto-reply-fix"
+
+ # 可热更新的静态文件类型(通常不需要重启)
+ HOT_UPDATABLE_EXTENSIONS = {
+ '.css', '.eot', '.gif', '.html', '.ico', '.jpeg', '.jpg',
+ '.js', '.json', '.jsx', '.map', '.otf', '.png', '.svg',
+ '.ts', '.tsx', '.ttf', '.vue',
+ '.txt', '.webp', '.woff', '.woff2', '.yml', '.yaml',
+ }
+
+ # 需要重启的文件类型
+ RESTART_REQUIRED_EXTENSIONS = {'.py', '.pyd', '.so', '.dll', '.exe'}
+
+ # 不允许更新的文件/目录
+ EXCLUDED_PATHS = {
+ 'data/',
+ 'logs/',
+ 'browser_data/',
+ 'uploads/',
+ '__pycache__/',
+ '.git/',
+ '.github/',
+ '.claude/',
+ '.ace-tool/',
+ '.pytest_cache/',
+ 'build/',
+ 'nginx/',
+ 'dist/',
+ 'node_modules/',
+ 'qr_screenshots/',
+ 'trajectory_history/',
+ 'update_backup/',
+ 'venv/',
+ 'global_config.yml', # 用户配置文件不更新
+ 'update_files.json',
+ 'Dockerfile',
+ 'Dockerfile-cn',
+ 'docker-compose.yml',
+ 'docker-compose-cn.yml',
+ }
+
+ def __init__(self,
+ app_dir: Optional[str] = None,
+ update_server: Optional[str] = None,
+ github_owner: Optional[str] = None,
+ github_repo: Optional[str] = None,
+ github_token: Optional[str] = None,
+ current_version: str = "1.0.0"):
+ """
+ 初始化更新器
+
+ Args:
+ app_dir: 应用目录,默认为当前工作目录
+ update_server: 兼容旧参数,保留但不再作为默认更新源
+ github_owner: GitHub 仓库 owner
+ github_repo: GitHub 仓库 repo
+ github_token: GitHub API Token(可选,用于提升限额)
+ current_version: 当前版本号
+ """
+ self.app_dir = Path(app_dir) if app_dir else Path.cwd()
+ self.github_owner = github_owner or os.getenv("UPDATE_GITHUB_OWNER", self.DEFAULT_GITHUB_OWNER)
+ self.github_repo = github_repo or os.getenv("UPDATE_GITHUB_REPO", self.DEFAULT_GITHUB_REPO)
+ self.github_token = (github_token or os.getenv("UPDATE_GITHUB_TOKEN", "")).strip()
+ self.update_server = update_server or f"{self.github_owner}/{self.github_repo}"
+ self.current_version = current_version
+ self.backup_dir = self.app_dir / "update_backup"
+
+ # 更新状态
+ self.progress = UpdateProgress(status=UpdateStatus.IDLE)
+ self._update_callbacks: List[callable] = []
+
+ # 确保备份目录存在
+ self.backup_dir.mkdir(parents=True, exist_ok=True)
+
+ logger.info(
+ "自动更新器初始化: "
+ f"app_dir={self.app_dir}, repo={self.github_owner}/{self.github_repo}, version={self.current_version}"
+ )
+
+ def add_progress_callback(self, callback: callable):
+ """添加进度回调"""
+ self._update_callbacks.append(callback)
+
+ def _notify_progress(self):
+ """通知进度更新"""
+ for callback in self._update_callbacks:
+ try:
+ callback(self.progress)
+ except Exception as e:
+ logger.error(f"进度回调执行失败: {e}")
+
+ def _update_progress(self, **kwargs):
+ """更新进度"""
+ for key, value in kwargs.items():
+ if hasattr(self.progress, key):
+ setattr(self.progress, key, value)
+ self._notify_progress()
+
+ def _calculate_file_md5(self, file_path: Path) -> str:
+ """计算文件MD5"""
+ if not file_path.exists():
+ return ""
+
+ md5_hash = hashlib.md5()
+ with open(file_path, "rb") as f:
+ for chunk in iter(lambda: f.read(8192), b""):
+ md5_hash.update(chunk)
+ return md5_hash.hexdigest()
+
+ def _is_excluded(self, path: str) -> bool:
+ """检查路径是否被排除"""
+ path_lower = path.lower().replace('\\', '/')
+ for excluded in self.EXCLUDED_PATHS:
+ if path_lower.startswith(excluded.lower()) or excluded.lower() in path_lower:
+ return True
+ return False
+
+ def _needs_restart(self, file_path: str) -> bool:
+ """检查文件更新是否需要重启"""
+ ext = Path(file_path).suffix.lower()
+ return ext in self.RESTART_REQUIRED_EXTENSIONS
+
+ def refresh_current_version(self) -> str:
+ """从本地版本文件刷新当前版本号,避免长生命周期进程读到旧版本"""
+ version = self.current_version or "1.0.0"
+ version_file = self.app_dir / "static" / "version.txt"
+
+ try:
+ if version_file.exists():
+ file_version = version_file.read_text(encoding="utf-8").strip()
+ if file_version:
+ version = file_version
+ except Exception as e:
+ logger.warning(f"读取本地版本文件失败,继续使用缓存版本: {e}")
+ return self.current_version
+
+ if version != self.current_version:
+ logger.info(f"检测到本地版本变更: {self.current_version} -> {version}")
+ self.current_version = version
+
+ return self.current_version
+
+ def _build_request_headers(self, accept_json: bool = True) -> Dict[str, str]:
+ """构建 GitHub 请求头"""
+ headers = {
+ "User-Agent": f"XianyuAutoReplyUpdater/{self.current_version}",
+ }
+ if accept_json:
+ headers["Accept"] = "application/vnd.github+json"
+ if self.github_token:
+ headers["Authorization"] = f"Bearer {self.github_token}"
+ return headers
+
+ def _build_latest_release_url(self) -> str:
+ """构建 GitHub 最新 release API 地址"""
+ return (
+ f"{self.DEFAULT_GITHUB_API_BASE}/repos/"
+ f"{self.github_owner}/{self.github_repo}/releases/latest"
+ )
+
+ def _build_raw_file_url(self, tag: str, relative_path: str) -> str:
+ """构建 GitHub raw 文件地址"""
+ relative_path = relative_path.replace("\\", "/").lstrip("/")
+ return (
+ f"{self.DEFAULT_GITHUB_RAW_BASE}/"
+ f"{self.github_owner}/{self.github_repo}/{tag}/{relative_path}"
+ )
+
+ def _extract_changelog(self, release_data: Dict[str, Any]) -> List[str]:
+ """从 release body 中提取简易更新日志"""
+ body = (release_data.get("body") or "").strip()
+ if not body:
+ return []
+
+ changelog = []
+ for raw_line in body.splitlines():
+ line = raw_line.strip()
+ if not line:
+ continue
+ if line.startswith(("- ", "* ", "+ ")):
+ line = line[2:].strip()
+ changelog.append(line)
+ return changelog
+
+ def _find_asset_download_url(self, release_data: Dict[str, Any], asset_name: str) -> Optional[str]:
+ """查找指定 release asset 的浏览器下载地址"""
+ for asset in release_data.get("assets", []) or []:
+ if asset.get("name") == asset_name:
+ return asset.get("browser_download_url")
+ return None
+
+ async def check_for_updates(self) -> Optional[UpdateManifest]:
+ """
+ 检查是否有可用更新
+
+ Returns:
+ UpdateManifest: 更新清单,如果没有更新则返回None
+ """
+ self.refresh_current_version()
+ self._update_progress(status=UpdateStatus.CHECKING, message="正在检查更新...")
+
+ try:
+ async with aiohttp.ClientSession() as session:
+ release_url = self._build_latest_release_url()
+ async with session.get(
+ release_url,
+ headers=self._build_request_headers(),
+ timeout=aiohttp.ClientTimeout(total=30)
+ ) as response:
+ if response.status != 200:
+ logger.warning(f"获取 GitHub Release 失败: HTTP {response.status}")
+ self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
+ return None
+
+ release_data = await response.json()
+
+ release_tag = (release_data.get("tag_name") or "").strip()
+ if not release_tag:
+ logger.warning("GitHub Release 缺少 tag_name,无法检查更新")
+ self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
+ return None
+
+ manifest_url = self._find_asset_download_url(release_data, "update_files.json")
+ if not manifest_url:
+ manifest_url = self._build_raw_file_url(release_tag, "update_files.json")
+
+ async with session.get(
+ manifest_url,
+ headers=self._build_request_headers(accept_json=False),
+ timeout=aiohttp.ClientTimeout(total=30)
+ ) as response:
+ if response.status != 200:
+ logger.warning(f"获取更新清单失败: HTTP {response.status}")
+ self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
+ return None
+
+ manifest_text = await response.text()
+ try:
+ manifest_data = json.loads(manifest_text)
+ except json.JSONDecodeError as exc:
+ logger.warning(f"解析更新清单失败: {exc}")
+ self._update_progress(status=UpdateStatus.IDLE, message="检查更新失败")
+ return None
+
+ manifest_version = manifest_data.get("version") or release_tag
+ changelog = manifest_data.get("changelog") or self._extract_changelog(release_data)
+
+ files = []
+ for file_info in manifest_data.get("files", []):
+ file_path = file_info["path"].replace("\\", "/")
+ files.append(FileUpdate(
+ path=file_path,
+ md5=file_info.get("md5", ""),
+ size=file_info.get("size", 0),
+ download_url=self._build_raw_file_url(release_tag, file_path),
+ version=file_info.get("version", manifest_version),
+ requires_restart=file_info.get("requires_restart", self._needs_restart(file_path)),
+ description=file_info.get("description", "")
+ ))
+
+ deleted_files = []
+ for file_info in manifest_data.get("deleted_files", []):
+ file_path = file_info["path"].replace("\\", "/")
+ deleted_files.append(DeletedFile(
+ path=file_path,
+ requires_restart=file_info.get("requires_restart", self._needs_restart(file_path)),
+ description=file_info.get("description", "")
+ ))
+
+ manifest = UpdateManifest(
+ version=manifest_version,
+ release_date=manifest_data.get("release_date") or release_data.get("published_at", ""),
+ description=manifest_data.get("description") or release_data.get("name") or f"版本 {manifest_version} 更新",
+ files=files,
+ deleted_files=deleted_files,
+ min_version=manifest_data.get("min_version", ""),
+ changelog=changelog
+ )
+
+ logger.info(
+ f"发现发布版本: {manifest.version}, 共 {len(files)} 个文件可用于比对更新, "
+ f"{len(deleted_files)} 个文件待删除"
+ )
+ self._update_progress(status=UpdateStatus.IDLE, message=f"已获取版本 {manifest.version} 的更新清单")
+
+ return manifest
+
+ except asyncio.TimeoutError:
+ logger.error("检查更新超时")
+ self._update_progress(status=UpdateStatus.FAILED, error="检查更新超时")
+ return None
+ except Exception as e:
+ logger.error(f"检查更新失败: {e}")
+ self._update_progress(status=UpdateStatus.FAILED, error=str(e))
+ return None
+
+ async def get_files_to_update(self, manifest: UpdateManifest) -> List[FileUpdate]:
+ """
+ 获取需要更新的文件列表(排除已是最新的文件)
+
+ Args:
+ manifest: 更新清单
+
+ Returns:
+ 需要更新的文件列表
+ """
+ files_to_update = []
+ excluded_count = 0
+ up_to_date_count = 0
+
+ for file_update in manifest.files:
+ # 跳过被排除的文件
+ if self._is_excluded(file_update.path):
+ logger.debug(f"跳过排除的文件: {file_update.path}")
+ excluded_count += 1
+ continue
+
+ local_path = self.app_dir / file_update.path
+
+ # 如果服务端没有提供MD5,则始终更新该文件
+ if not file_update.md5 or not file_update.md5.strip():
+ files_to_update.append(file_update)
+ logger.debug(f"需要更新(无MD5校验): {file_update.path}")
+ continue
+
+ local_md5 = self._calculate_file_md5(local_path)
+
+ # 如果本地文件不存在或MD5不匹配,则需要更新
+ if local_md5 != file_update.md5:
+ files_to_update.append(file_update)
+ logger.debug(f"需要更新: {file_update.path} (本地MD5: {local_md5}, 远程MD5: {file_update.md5})")
+ else:
+ up_to_date_count += 1
+
+ logger.info(
+ f"更新检查完成: 共 {len(manifest.files)} 个文件, 需要更新 {len(files_to_update)} 个, "
+ f"已是最新 {up_to_date_count} 个, 排除 {excluded_count} 个"
+ )
+
+ return files_to_update
+
+ async def get_files_to_delete(self, manifest: UpdateManifest) -> List[DeletedFile]:
+ """获取本地实际存在且允许删除的旧文件列表"""
+ files_to_delete = []
+
+ for deleted_file in manifest.deleted_files or []:
+ if self._is_excluded(deleted_file.path):
+ logger.debug(f"跳过排除的删除路径: {deleted_file.path}")
+ continue
+
+ local_path = self.app_dir / deleted_file.path
+ if local_path.exists() and local_path.is_file():
+ files_to_delete.append(deleted_file)
+ logger.debug(f"需要删除旧文件: {deleted_file.path}")
+
+ return files_to_delete
+
+ # 非关键文件,MD5校验失败时可以继续更新(仅警告不报错)
+ NON_CRITICAL_FILES = {'version.txt', 'update_log.txt', 'changelog.txt'}
+
+ async def download_file(self, file_update: FileUpdate, session: aiohttp.ClientSession) -> Optional[bytes]:
+ """
+ 下载单个文件
+
+ Args:
+ file_update: 文件更新信息
+ session: aiohttp会话
+
+ Returns:
+ 文件内容,失败返回None
+ """
+ try:
+ async with session.get(file_update.download_url, timeout=aiohttp.ClientTimeout(total=60)) as response:
+ if response.status != 200:
+ logger.error(f"下载文件失败: {file_update.path}, HTTP {response.status}")
+ return None
+
+ content = await response.read()
+
+ # 验证MD5(如果服务端提供了MD5值)
+ if file_update.md5 and file_update.md5.strip():
+ downloaded_md5 = hashlib.md5(content).hexdigest()
+ if downloaded_md5 != file_update.md5:
+ # 检查是否为非关键文件
+ file_name = Path(file_update.path).name
+ if file_name in self.NON_CRITICAL_FILES:
+ logger.warning(f"非关键文件MD5不匹配(忽略): {file_update.path}, 期望: {file_update.md5}, 实际: {downloaded_md5}")
+ # 非关键文件,继续更新
+ else:
+ logger.error(f"文件MD5校验失败: {file_update.path}, 期望: {file_update.md5}, 实际: {downloaded_md5}")
+ return None
+ else:
+ logger.debug(f"文件MD5校验通过: {file_update.path}")
+ else:
+ logger.debug(f"跳过MD5校验(服务端未提供): {file_update.path}")
+
+ return content
+
+ except Exception as e:
+ logger.error(f"下载文件异常: {file_update.path}, {e}")
+ return None
+
+ def _backup_file(self, file_path: Path) -> bool:
+ """
+ 备份文件
+
+ Args:
+ file_path: 要备份的文件路径
+
+ Returns:
+ 是否成功
+ """
+ if not file_path.exists():
+ return True
+
+ try:
+ relative_path = file_path.relative_to(self.app_dir)
+ backup_path = self.backup_dir / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}" / relative_path
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(file_path, backup_path)
+ logger.debug(f"备份文件: {file_path} -> {backup_path}")
+ return True
+ except Exception as e:
+ logger.error(f"备份文件失败: {file_path}, {e}")
+ return False
+
+ def _cleanup_empty_parent_dirs(self, directory: Path):
+ """清理因删除文件产生的空目录"""
+ current = directory
+
+ while current != self.app_dir and current != current.parent:
+ try:
+ current.rmdir()
+ except OSError:
+ break
+
+ current = current.parent
+
+ async def apply_updates(self, files_to_update: List[FileUpdate]) -> Tuple[bool, List[str], bool]:
+ """
+ 应用更新
+
+ Args:
+ files_to_update: 需要更新的文件列表
+
+ Returns:
+ (是否成功, 更新的文件列表, 是否需要重启)
+ """
+ if not files_to_update:
+ return True, [], False
+
+ updated_files = []
+ needs_restart = False
+ total_size = sum(f.size for f in files_to_update)
+ downloaded_size = 0
+
+ self._update_progress(
+ status=UpdateStatus.DOWNLOADING,
+ total_files=len(files_to_update),
+ total_bytes=total_size,
+ message=f"正在下载 {len(files_to_update)} 个文件..."
+ )
+
+ async with aiohttp.ClientSession() as session:
+ for index, file_update in enumerate(files_to_update):
+ self._update_progress(
+ current_file=file_update.path,
+ current_index=index + 1,
+ message=f"正在下载: {file_update.path}"
+ )
+
+ # 下载文件
+ content = await self.download_file(file_update, session)
+ if content is None:
+ self._update_progress(
+ status=UpdateStatus.FAILED,
+ error=f"下载文件失败: {file_update.path}"
+ )
+ return False, updated_files, needs_restart
+
+ downloaded_size += len(content)
+ self._update_progress(downloaded_bytes=downloaded_size)
+
+ # 备份并安装
+ local_path = self.app_dir / file_update.path
+
+ # 备份旧文件
+ if not self._backup_file(local_path):
+ logger.warning(f"备份失败,继续更新: {file_update.path}")
+
+ # 确保目录存在
+ local_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # 写入新文件
+ try:
+ self._update_progress(
+ status=UpdateStatus.INSTALLING,
+ message=f"正在安装: {file_update.path}"
+ )
+
+ with open(local_path, 'wb') as f:
+ f.write(content)
+
+ updated_files.append(file_update.path)
+
+ if file_update.requires_restart:
+ needs_restart = True
+
+ logger.info(f"更新文件成功: {file_update.path}")
+
+ except Exception as e:
+ logger.error(f"写入文件失败: {file_update.path}, {e}")
+ self._update_progress(
+ status=UpdateStatus.FAILED,
+ error=f"安装文件失败: {file_update.path}"
+ )
+ return False, updated_files, needs_restart
+
+ # 更新完成
+ if needs_restart:
+ self._update_progress(
+ status=UpdateStatus.RESTART_REQUIRED,
+ message=f"更新完成,共更新 {len(updated_files)} 个文件,需要重启应用"
+ )
+ else:
+ self._update_progress(
+ status=UpdateStatus.COMPLETED,
+ message=f"更新完成,共更新 {len(updated_files)} 个文件"
+ )
+
+ return True, updated_files, needs_restart
+
+ async def apply_deletions(self, files_to_delete: List[DeletedFile]) -> Tuple[bool, List[str], bool]:
+ """
+ 删除 manifest 中声明的旧文件
+
+ Args:
+ files_to_delete: 待删除文件列表
+
+ Returns:
+ (是否成功, 删除的文件列表, 是否需要重启)
+ """
+ if not files_to_delete:
+ return True, [], False
+
+ deleted_paths = []
+ needs_restart = False
+
+ self._update_progress(
+ status=UpdateStatus.INSTALLING,
+ total_files=len(files_to_delete),
+ message=f"正在清理 {len(files_to_delete)} 个旧文件..."
+ )
+
+ for index, deleted_file in enumerate(files_to_delete):
+ self._update_progress(
+ current_file=deleted_file.path,
+ current_index=index + 1,
+ message=f"正在删除旧文件: {deleted_file.path}"
+ )
+
+ local_path = self.app_dir / deleted_file.path
+ if not local_path.exists():
+ continue
+
+ if not self._backup_file(local_path):
+ logger.warning(f"删除前备份失败,继续删除: {deleted_file.path}")
+
+ try:
+ local_path.unlink()
+ deleted_paths.append(deleted_file.path)
+
+ if deleted_file.requires_restart:
+ needs_restart = True
+
+ self._cleanup_empty_parent_dirs(local_path.parent)
+ logger.info(f"删除旧文件成功: {deleted_file.path}")
+ except Exception as e:
+ logger.error(f"删除旧文件失败: {deleted_file.path}, {e}")
+ self._update_progress(
+ status=UpdateStatus.FAILED,
+ error=f"删除旧文件失败: {deleted_file.path}"
+ )
+ return False, deleted_paths, needs_restart
+
+ return True, deleted_paths, needs_restart
+
+ async def perform_update(self, manifest: Optional[UpdateManifest] = None) -> Dict[str, Any]:
+ """
+ 执行完整的更新流程
+
+ Args:
+ manifest: 更新清单,如果为None则自动检查
+
+ Returns:
+ 更新结果
+ """
+ result = {
+ "success": False,
+ "message": "",
+ "updated_files": [],
+ "deleted_files": [],
+ "needs_restart": False,
+ "new_version": ""
+ }
+
+ try:
+ self.refresh_current_version()
+ # 检查更新
+ if manifest is None:
+ manifest = await self.check_for_updates()
+
+ if manifest is None:
+ result["message"] = "没有可用更新"
+ result["success"] = True
+ return result
+
+ result["new_version"] = manifest.version
+
+ # 获取需要更新的文件
+ files_to_update = await self.get_files_to_update(manifest)
+ files_to_delete = await self.get_files_to_delete(manifest)
+
+ if not files_to_update and not files_to_delete:
+ result["message"] = "所有文件已是最新"
+ result["success"] = True
+ return result
+
+ logger.info(
+ f"开始更新到版本 {manifest.version}: {len(files_to_update)} 个文件更新, "
+ f"{len(files_to_delete)} 个文件待删除"
+ )
+
+ # 应用更新
+ success, updated_files, needs_restart = await self.apply_updates(files_to_update)
+ deleted_files: List[str] = []
+
+ if success:
+ success, deleted_files, delete_restart = await self.apply_deletions(files_to_delete)
+ needs_restart = needs_restart or delete_restart
+
+ result["success"] = success
+ result["updated_files"] = updated_files
+ result["deleted_files"] = deleted_files
+ result["needs_restart"] = needs_restart
+
+ if success:
+ message_parts = []
+ if updated_files:
+ message_parts.append(f"更新 {len(updated_files)} 个文件")
+ if deleted_files:
+ message_parts.append(f"删除 {len(deleted_files)} 个旧文件")
+
+ result["message"] = f"成功{','.join(message_parts) if message_parts else '处理变更'}到版本 {manifest.version}"
+ if needs_restart:
+ result["message"] += ",需要重启应用生效"
+
+ # 更新成功后,保存文件哈希清单(用于以后对比)
+ self.save_file_hashes(manifest.version, updated_files, deleted_files)
+ else:
+ result["message"] = "更新过程中出现错误"
+
+ return result
+
+ except Exception as e:
+ logger.error(f"更新失败: {e}")
+ result["message"] = f"更新失败: {str(e)}"
+ self._update_progress(status=UpdateStatus.FAILED, error=str(e))
+ return result
+
+ def get_local_file_hashes(self, file_patterns: List[str] = None) -> Dict[str, str]:
+ """
+ 获取本地文件的MD5哈希值
+
+ Args:
+ file_patterns: 文件模式列表,默认为常见的可更新文件
+
+ Returns:
+ {文件路径: MD5哈希}
+ """
+ if file_patterns is None:
+ try:
+ from generate_update_manifest import collect_updatable_files
+
+ file_hashes = {}
+ for relative_path in collect_updatable_files(self.app_dir):
+ if self._is_excluded(relative_path):
+ continue
+
+ file_path = self.app_dir / relative_path
+ file_hashes[relative_path] = self._calculate_file_md5(file_path)
+
+ return file_hashes
+ except Exception as e:
+ logger.debug(f"使用 manifest 扫描规则获取本地哈希失败,回退到后缀模式: {e}")
+
+ file_patterns = [
+ '*.py', '*.js', '*.ts', '*.tsx', '*.jsx', '*.vue',
+ '*.css', '*.html', '*.txt', '*.json', '*.map',
+ '*.yml', '*.yaml', '*.png', '*.jpg', '*.jpeg',
+ '*.gif', '*.svg', '*.ico', '*.webp', '*.woff',
+ '*.woff2', '*.eot', '*.otf', '*.ttf',
+ ]
+
+ file_hashes = {}
+
+ for pattern in file_patterns:
+ for file_path in self.app_dir.rglob(pattern):
+ try:
+ relative_path = str(file_path.relative_to(self.app_dir)).replace('\\', '/')
+
+ # 跳过排除的路径
+ if self._is_excluded(relative_path):
+ continue
+
+ file_hashes[relative_path] = self._calculate_file_md5(file_path)
+ except Exception as e:
+ logger.debug(f"计算文件哈希失败: {file_path}, {e}")
+
+ return file_hashes
+
+ def cleanup_old_backups(self, keep_days: int = 7):
+ """
+ 清理旧的备份文件
+
+ Args:
+ keep_days: 保留天数
+ """
+ try:
+ cutoff_time = datetime.now().timestamp() - (keep_days * 24 * 60 * 60)
+
+ for backup_dir in self.backup_dir.iterdir():
+ if backup_dir.is_dir():
+ if backup_dir.stat().st_mtime < cutoff_time:
+ shutil.rmtree(backup_dir)
+ logger.info(f"清理旧备份: {backup_dir}")
+
+ except Exception as e:
+ logger.error(f"清理备份失败: {e}")
+
+ def save_file_hashes(
+ self,
+ version: str,
+ updated_files: List[str] = None,
+ deleted_files: List[str] = None,
+ ):
+ """
+ 保存文件哈希清单到本地
+
+ 更新完成后调用此方法,记录所有文件的MD5哈希值,
+ 方便以后对比哪些文件发生了变化。
+
+ Args:
+ version: 当前版本号
+ updated_files: 本次更新的文件列表(可选)
+ """
+ try:
+ hash_file = self.app_dir / "data" / "file_hashes.json"
+ hash_file.parent.mkdir(parents=True, exist_ok=True)
+
+ # 获取所有可更新文件的哈希
+ all_hashes = self.get_local_file_hashes()
+
+ # 构建哈希清单
+ manifest = {
+ "version": version,
+ "updated_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
+ "total_files": len(all_hashes),
+ "files": all_hashes
+ }
+
+ # 如果有本次更新的文件列表,单独记录
+ if updated_files:
+ manifest["last_updated_files"] = updated_files
+ manifest["last_updated_count"] = len(updated_files)
+ if deleted_files:
+ manifest["last_deleted_files"] = deleted_files
+ manifest["last_deleted_count"] = len(deleted_files)
+
+ # 保存到文件
+ with open(hash_file, 'w', encoding='utf-8') as f:
+ json.dump(manifest, f, ensure_ascii=False, indent=2)
+
+ logger.info(f"已保存文件哈希清单: {hash_file}, 共 {len(all_hashes)} 个文件")
+
+ except Exception as e:
+ logger.error(f"保存文件哈希清单失败: {e}")
+
+ def load_file_hashes(self) -> Optional[Dict[str, Any]]:
+ """
+ 加载本地保存的文件哈希清单
+
+ Returns:
+ 哈希清单字典,如果不存在则返回None
+ """
+ try:
+ hash_file = self.app_dir / "data" / "file_hashes.json"
+
+ if not hash_file.exists():
+ return None
+
+ with open(hash_file, 'r', encoding='utf-8') as f:
+ return json.load(f)
+
+ except Exception as e:
+ logger.error(f"加载文件哈希清单失败: {e}")
+ return None
+
+ def compare_file_hashes(self) -> Dict[str, Any]:
+ """
+ 比较当前文件与保存的哈希清单
+
+ Returns:
+ 比较结果,包含变化的文件列表
+ """
+ result = {
+ "has_changes": False,
+ "saved_version": None,
+ "changed_files": [],
+ "new_files": [],
+ "deleted_files": [],
+ "unchanged_files": []
+ }
+
+ try:
+ saved_manifest = self.load_file_hashes()
+
+ if saved_manifest is None:
+ result["message"] = "没有保存的哈希清单,无法比较"
+ return result
+
+ result["saved_version"] = saved_manifest.get("version")
+ saved_hashes = saved_manifest.get("files", {})
+
+ # 获取当前文件哈希
+ current_hashes = self.get_local_file_hashes()
+
+ # 比较文件
+ all_files = set(saved_hashes.keys()) | set(current_hashes.keys())
+
+ for file_path in all_files:
+ saved_md5 = saved_hashes.get(file_path)
+ current_md5 = current_hashes.get(file_path)
+
+ if saved_md5 is None:
+ # 新增的文件
+ result["new_files"].append(file_path)
+ elif current_md5 is None:
+ # 删除的文件
+ result["deleted_files"].append(file_path)
+ elif saved_md5 != current_md5:
+ # 修改的文件
+ result["changed_files"].append({
+ "path": file_path,
+ "old_md5": saved_md5,
+ "new_md5": current_md5
+ })
+ else:
+ # 未变化的文件
+ result["unchanged_files"].append(file_path)
+
+ result["has_changes"] = bool(result["changed_files"] or result["new_files"] or result["deleted_files"])
+ result["message"] = f"比较完成: {len(result['changed_files'])} 个文件修改, {len(result['new_files'])} 个新增, {len(result['deleted_files'])} 个删除"
+
+ except Exception as e:
+ logger.error(f"比较文件哈希失败: {e}")
+ result["message"] = f"比较失败: {str(e)}"
+
+ return result
+
+
+# 全局更新器实例
+_updater: Optional[AutoUpdater] = None
+
+
+def get_updater() -> AutoUpdater:
+ """获取全局更新器实例"""
+ global _updater
+ if _updater is None:
+ # 尝试从版本文件读取当前版本
+ version = "1.0.0"
+ try:
+ version_file = Path(__file__).parent / "static" / "version.txt"
+ if version_file.exists():
+ version = version_file.read_text().strip()
+ except:
+ pass
+
+ _updater = AutoUpdater(current_version=version)
+
+ return _updater
+
+
+def init_updater(app_dir: str = None, update_server: str = None, current_version: str = None) -> AutoUpdater:
+ """
+ 初始化全局更新器
+
+ Args:
+ app_dir: 应用目录
+ update_server: 更新服务器地址
+ current_version: 当前版本号
+ """
+ global _updater
+
+ if current_version is None:
+ try:
+ version_file = Path(app_dir or ".") / "static" / "version.txt"
+ if version_file.exists():
+ current_version = version_file.read_text().strip()
+ else:
+ current_version = "1.0.0"
+ except:
+ current_version = "1.0.0"
+
+ _updater = AutoUpdater(
+ app_dir=app_dir,
+ update_server=update_server,
+ current_version=current_version
+ )
+
+ return _updater
diff --git a/build-multi-arch.sh b/build-multi-arch.sh
new file mode 100644
index 0000000..40d98b9
--- /dev/null
+++ b/build-multi-arch.sh
@@ -0,0 +1,190 @@
+#!/bin/bash
+# 多架构 Docker 镜像构建脚本 (AMD64 + ARM64)
+# 直接使用当前目录构建,无需加密
+#
+# 用法:
+# ./build-multi-arch.sh
+
+set -e
+
+echo "========================================"
+echo " 多架构 Docker 镜像构建脚本"
+echo "========================================"
+echo
+
+# 设置镜像标签(可通过环境变量覆盖)
+IMAGE_NAME="${IMAGE_NAME:-xianyu-auto-reply-fix}"
+IMAGE_TAG="${IMAGE_TAG:-latest}"
+DOCKERFILE="${DOCKERFILE:-Dockerfile-cn}"
+
+# ==================== 自动化配置 ====================
+PUSH_IMAGE="${PUSH_IMAGE:-n}"
+REGISTRY="${REGISTRY:-}"
+
+if [ "$PUSH_IMAGE" = "y" ]; then
+ if [ -z "$REGISTRY" ]; then
+ echo "[错误] PUSH_IMAGE=y 时必须通过环境变量 REGISTRY 指定镜像仓库前缀"
+ echo "示例: REGISTRY=ghcr.io/your-name PUSH_IMAGE=y ./build-multi-arch.sh"
+ exit 1
+ fi
+ FULL_IMAGE_NAME="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
+ OUTPUT_FLAG="--push"
+ PUSH_STATUS="是"
+else
+ FULL_IMAGE_NAME="${IMAGE_NAME}:${IMAGE_TAG}"
+ OUTPUT_FLAG="--load"
+ PUSH_STATUS="否(仅加载到本地)"
+fi
+
+echo "配置信息:"
+echo " - 镜像名: $FULL_IMAGE_NAME"
+echo " - Dockerfile: $DOCKERFILE"
+echo " - 推送到仓库: $PUSH_STATUS"
+echo
+
+# 显示当前构建目录
+echo "========================================"
+echo "构建目录: $(pwd)"
+echo "========================================"
+echo "Python 文件:"
+ls -la *.py 2>/dev/null | head -5 || echo "(无 Python 文件)"
+echo
+
+echo "========================================"
+echo "步骤 1: 检查 Docker 服务"
+echo "========================================"
+if ! docker ps >/dev/null 2>&1; then
+ echo "[错误] Docker 服务未运行,请先启动 Docker"
+ exit 1
+fi
+echo "[✓] Docker 服务正常运行"
+
+echo
+echo "========================================"
+echo "步骤 2: 基础镜像源配置"
+echo "========================================"
+BASE_IMAGE="docker.1panel.live/library/python:3.11-slim-bookworm"
+BASE_IMAGE_ARG="--build-arg BASE_IMAGE=$BASE_IMAGE"
+echo "基础镜像: $BASE_IMAGE"
+echo
+
+echo "========================================"
+echo "步骤 3: 安装 QEMU 模拟器(支持 ARM64)"
+echo "========================================"
+echo "检查 QEMU 是否已安装..."
+if docker run --rm --privileged tonistiigi/binfmt --version >/dev/null 2>&1; then
+ echo "安装/更新 QEMU 模拟器..."
+ docker run --rm --privileged tonistiigi/binfmt --install all
+ if [ $? -eq 0 ]; then
+ echo "[✓] QEMU 模拟器安装成功"
+ else
+ echo "[⚠] QEMU 模拟器安装失败,继续尝试构建"
+ fi
+else
+ echo "[⚠] 无法安装 QEMU 模拟器,ARM64 构建可能需要其他方式"
+fi
+
+echo
+echo "========================================"
+echo "步骤 4: 检查并创建 buildx builder"
+echo "========================================"
+if ! docker buildx inspect multiarch-builder >/dev/null 2>&1; then
+ echo "创建新的 buildx builder..."
+ docker buildx create --name multiarch-builder --driver docker-container --use --bootstrap --driver-opt network=host
+ if [ $? -ne 0 ]; then
+ echo "尝试使用默认 driver..."
+ docker buildx create --name multiarch-builder --use --bootstrap
+ if [ $? -ne 0 ]; then
+ echo "[错误] 创建 buildx builder 失败"
+ exit 1
+ fi
+ fi
+ echo "[✓] buildx builder 创建成功"
+else
+ echo "使用现有的 buildx builder"
+ docker buildx use multiarch-builder
+ docker buildx inspect --bootstrap >/dev/null 2>&1
+ echo "[✓] buildx builder 已就绪"
+fi
+
+echo
+echo "========================================"
+echo "步骤 5: 查看支持的平台"
+echo "========================================"
+PLATFORMS=$(docker buildx inspect --bootstrap | grep "Platforms:" | sed 's/Platforms://' | xargs)
+echo "支持的平台: $PLATFORMS"
+
+# 检查是否支持 ARM64
+if echo "$PLATFORMS" | grep -q "linux/arm64"; then
+ echo "[✓] 检测到 ARM64 支持"
+ SUPPORT_ARM64=true
+ PLATFORMS="linux/amd64,linux/arm64"
+else
+ echo "[⚠] 未检测到 ARM64 支持,只构建 AMD64"
+ SUPPORT_ARM64=false
+ PLATFORMS="linux/amd64"
+fi
+echo
+
+LOCAL_PLATFORM="linux/amd64"
+case "$(uname -m)" in
+ x86_64|amd64)
+ LOCAL_PLATFORM="linux/amd64"
+ ;;
+ arm64|aarch64)
+ LOCAL_PLATFORM="linux/arm64"
+ ;;
+esac
+
+if [ "$PUSH_IMAGE" = "y" ]; then
+ TARGET_PLATFORMS="$PLATFORMS"
+else
+ TARGET_PLATFORMS="$LOCAL_PLATFORM"
+fi
+
+echo "========================================"
+echo "步骤 6: 开始构建镜像"
+echo "========================================"
+echo "镜像名称: $FULL_IMAGE_NAME"
+echo "Dockerfile: $DOCKERFILE"
+echo "平台: $TARGET_PLATFORMS"
+echo
+
+if [ "$SUPPORT_ARM64" = "true" ]; then
+ echo "[提示] ARM64 构建使用 QEMU 模拟,速度较慢,请耐心等待..."
+fi
+
+if [ "$PUSH_IMAGE" != "y" ]; then
+ echo "[提示] 未启用推送,当前仅构建并加载本机架构镜像"
+fi
+
+docker buildx build --platform "$TARGET_PLATFORMS" -t "$FULL_IMAGE_NAME" -f "$DOCKERFILE" . $OUTPUT_FLAG $BASE_IMAGE_ARG
+if [ $? -ne 0 ]; then
+ echo ""
+ echo "[错误] 构建失败"
+ exit 1
+fi
+
+echo
+echo "========================================"
+echo "✓ 构建完成!"
+echo "========================================"
+if [ "$PUSH_IMAGE" = "y" ]; then
+ echo "镜像已推送到: $FULL_IMAGE_NAME"
+else
+ echo "镜像已加载到本地: $FULL_IMAGE_NAME"
+fi
+echo
+echo "使用方法:"
+if [ "$PUSH_IMAGE" = "y" ]; then
+ echo " docker pull $FULL_IMAGE_NAME"
+fi
+echo " docker run -d -p 8090:8090 --name xianyu-auto-reply-fix $FULL_IMAGE_NAME"
+echo
+echo "验证多架构镜像:"
+if [ "$PUSH_IMAGE" = "y" ]; then
+ echo " docker buildx imagetools inspect $FULL_IMAGE_NAME"
+else
+ echo " docker image inspect $FULL_IMAGE_NAME"
+fi
+echo
diff --git a/captcha_control.html b/captcha_control.html
new file mode 100644
index 0000000..e9a4a7b
--- /dev/null
+++ b/captcha_control.html
@@ -0,0 +1,571 @@
+
+
+