优化代码,新增qq回复咸鱼消息
优化代码,新增qq回复咸鱼消息
This commit is contained in:
commit
90445a4d4c
236
.gitignore
vendored
236
.gitignore
vendored
@ -290,12 +290,67 @@ build/
|
||||
# 测试和示例文件
|
||||
test_*.py
|
||||
*_test.py
|
||||
test_*.html
|
||||
*_test.html
|
||||
test_*.js
|
||||
*_test.js
|
||||
example_*.py
|
||||
*_example.py
|
||||
demo_*.py
|
||||
*_demo.py
|
||||
fix_*.py
|
||||
*_fix.py
|
||||
sample_*.py
|
||||
*_sample.py
|
||||
mock_*.py
|
||||
*_mock.py
|
||||
debug_*.py
|
||||
*_debug.py
|
||||
|
||||
# 测试相关目录
|
||||
tests/
|
||||
test/
|
||||
testing/
|
||||
__tests__/
|
||||
spec/
|
||||
specs/
|
||||
|
||||
# 测试配置文件
|
||||
pytest.ini
|
||||
.pytest_cache/
|
||||
test_*.ini
|
||||
*_test.ini
|
||||
test_*.conf
|
||||
*_test.conf
|
||||
|
||||
# 测试数据文件
|
||||
test_*.json
|
||||
*_test.json
|
||||
test_*.csv
|
||||
*_test.csv
|
||||
test_*.xml
|
||||
*_test.xml
|
||||
test_*.xlsx
|
||||
*_test.xlsx
|
||||
test_*.txt
|
||||
*_test.txt
|
||||
|
||||
# 测试输出文件
|
||||
test_output/
|
||||
test_results/
|
||||
test_reports/
|
||||
coverage_html/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
|
||||
# 性能测试文件
|
||||
benchmark_*.py
|
||||
*_benchmark.py
|
||||
perf_*.py
|
||||
*_perf.py
|
||||
load_test_*.py
|
||||
*_load_test.py
|
||||
|
||||
# 文档文件(除了README.md)
|
||||
*.md
|
||||
@ -339,7 +394,6 @@ setup.cfg
|
||||
tox.ini
|
||||
|
||||
# 容器相关
|
||||
.dockerignore
|
||||
docker-compose.*.yml
|
||||
!docker-compose.yml
|
||||
!docker-compose-cn.yml
|
||||
@ -385,4 +439,182 @@ systemd/
|
||||
# 备份和归档
|
||||
archive/
|
||||
old/
|
||||
deprecated/
|
||||
deprecated/
|
||||
|
||||
# ==================== 项目特定新增 ====================
|
||||
# 数据库文件
|
||||
xianyu_data.db
|
||||
xianyu_data_backup_*.db
|
||||
|
||||
# 实时日志文件
|
||||
realtime.log
|
||||
|
||||
# 用户统计文件
|
||||
user_stats.db
|
||||
user_stats.txt
|
||||
stats.log
|
||||
|
||||
# PHP测试文件
|
||||
php/
|
||||
|
||||
# 检查脚本
|
||||
check_disk_usage.py
|
||||
|
||||
# Docker相关
|
||||
docker-compose.override.yml
|
||||
.env.docker
|
||||
|
||||
# IDE和编辑器
|
||||
.vscode/settings.json
|
||||
.idea/workspace.xml
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# 操作系统特定
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# 网络和缓存
|
||||
.wget-hsts
|
||||
.curl_sslcache
|
||||
|
||||
# 临时和锁文件
|
||||
*.lock
|
||||
*.pid
|
||||
*.sock
|
||||
*.port
|
||||
.fuse_hidden*
|
||||
|
||||
# 压缩和打包文件
|
||||
*.7z
|
||||
*.dmg
|
||||
*.gz
|
||||
*.iso
|
||||
*.jar
|
||||
*.rar
|
||||
*.tar
|
||||
*.zip
|
||||
|
||||
# 媒体文件(如果不需要版本控制)
|
||||
*.mp4
|
||||
*.avi
|
||||
*.mov
|
||||
*.wmv
|
||||
*.flv
|
||||
*.webm
|
||||
*.mp3
|
||||
*.wav
|
||||
*.flac
|
||||
*.aac
|
||||
|
||||
# 大文件和二进制文件
|
||||
*.bin
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# 文档生成
|
||||
docs/_build/
|
||||
docs/build/
|
||||
site/
|
||||
_site/
|
||||
|
||||
# 包管理器锁文件
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
Pipfile.lock
|
||||
poetry.lock
|
||||
|
||||
# 环境和配置文件
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
config.local.*
|
||||
settings.local.*
|
||||
|
||||
# 运行时生成的文件
|
||||
*.generated.*
|
||||
*.auto.*
|
||||
auto_*
|
||||
|
||||
# 性能分析和调试
|
||||
*.prof
|
||||
*.pstats
|
||||
*.trace
|
||||
*.debug
|
||||
profile_*
|
||||
debug_*
|
||||
|
||||
# 安全相关
|
||||
*.key
|
||||
*.pem
|
||||
*.crt
|
||||
*.cert
|
||||
*.p12
|
||||
*.pfx
|
||||
*.secret
|
||||
*.token
|
||||
*.auth
|
||||
secrets/
|
||||
credentials/
|
||||
keys/
|
||||
|
||||
# 监控和统计
|
||||
monitoring/
|
||||
metrics/
|
||||
stats/
|
||||
|
||||
# 第三方工具
|
||||
.sonarqube/
|
||||
.scannerwork/
|
||||
.nyc_output/
|
||||
coverage/
|
||||
.coverage.*
|
||||
|
||||
# 移动端开发
|
||||
*.apk
|
||||
*.ipa
|
||||
*.app
|
||||
*.aab
|
||||
|
||||
# 游戏开发
|
||||
*.unity
|
||||
*.unitypackage
|
||||
|
||||
# 科学计算
|
||||
*.mat
|
||||
*.h5
|
||||
*.hdf5
|
||||
|
||||
# 地理信息系统
|
||||
*.shp
|
||||
*.dbf
|
||||
*.shx
|
||||
*.prj
|
||||
|
||||
# 3D模型
|
||||
*.obj
|
||||
*.fbx
|
||||
*.dae
|
||||
*.3ds
|
||||
|
||||
# 字体文件(如果不需要版本控制)
|
||||
*.ttf
|
||||
*.otf
|
||||
*.woff
|
||||
*.woff2
|
||||
*.eot
|
||||
|
||||
# 数据文件(根据需要调整)
|
||||
*.csv.bak
|
||||
*.json.bak
|
||||
*.xml.bak
|
||||
*.sql.bak
|
||||
111
README.md
111
README.md
@ -9,9 +9,30 @@
|
||||
|
||||
https://pan.baidu.com/s/1I6muOGJQYd6y3oxQSmtvrQ?pwd=gcpd
|
||||
|
||||
## 📋 项目概述
|
||||
|
||||
一个功能完整的闲鱼自动回复和管理系统,采用现代化的技术架构,支持多用户、多账号管理,具备智能回复、自动发货、自动确认发货、商品管理等企业级功能。系统基于Python异步编程,使用FastAPI提供RESTful API,SQLite数据库存储,支持Docker一键部署。
|
||||
|
||||
> **⚠️ 重要提示:本项目仅供学习研究使用,严禁商业用途!使用前请仔细阅读[版权声明](#️-版权声明与使用条款)。**
|
||||
|
||||
一个功能完整的闲鱼自动回复和管理系统,支持多用户、多账号管理,具备智能回复、自动发货、自动确认发货、商品管理等企业级功能。
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 核心技术栈
|
||||
- **后端框架**: FastAPI + Python 3.11+ 异步编程
|
||||
- **数据库**: SQLite 3 + 多用户数据隔离 + 自动迁移
|
||||
- **前端**: Bootstrap 5 + Vanilla JavaScript + 响应式设计
|
||||
- **通信协议**: WebSocket + RESTful API + 实时通信
|
||||
- **部署方式**: Docker + Docker Compose + 一键部署
|
||||
- **日志系统**: Loguru + 文件轮转 + 实时收集
|
||||
- **安全认证**: JWT + 图形验证码 + 邮箱验证 + 权限控制
|
||||
|
||||
### 系统架构特点
|
||||
- **微服务设计**: 模块化架构,易于维护和扩展
|
||||
- **异步处理**: 基于asyncio的高性能异步处理
|
||||
- **多用户隔离**: 完全的数据隔离和权限控制
|
||||
- **容器化部署**: Docker容器化,支持一键部署
|
||||
- **实时监控**: WebSocket实时通信和状态监控
|
||||
- **自动化运维**: 自动重连、异常恢复、日志轮转
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
@ -103,11 +124,12 @@ xianyu-auto-reply/
|
||||
│ ├── xianyu_utils.py # 闲鱼API工具函数(加密、签名、解析)
|
||||
│ ├── message_utils.py # 消息格式化和处理工具
|
||||
│ ├── ws_utils.py # WebSocket客户端封装
|
||||
│ ├── qr_login.py # 二维码登录功能
|
||||
│ ├── image_utils.py # 图片处理和管理工具
|
||||
│ ├── image_uploader.py # 图片上传到闲鱼CDN
|
||||
│ ├── image_utils.py # 图片处理工具(压缩、格式转换)
|
||||
│ ├── item_search.py # 商品搜索功能(基于Playwright,无头模式)
|
||||
│ ├── order_detail_fetcher.py # 订单详情获取工具
|
||||
│ ├── image_utils.py # 图片处理工具(压缩、格式转换)
|
||||
│ └── image_uploader.py # 图片上传到CDN工具
|
||||
│ └── qr_login.py # 二维码登录功能
|
||||
├── 🌐 前端界面
|
||||
│ └── static/
|
||||
│ ├── index.html # 主管理界面(集成所有功能模块)
|
||||
@ -781,9 +803,88 @@ powershell -ExecutionPolicy Bypass -File docker-deploy.bat
|
||||
|
||||
---
|
||||
|
||||
## 📊 项目统计
|
||||
|
||||
- **代码行数**: 10,000+ 行
|
||||
- **功能模块**: 15+ 个核心模块
|
||||
- **API接口**: 50+ 个RESTful接口
|
||||
- **数据库表**: 20+ 个数据表
|
||||
- **支持平台**: Windows/Linux/macOS
|
||||
- **部署方式**: Docker一键部署
|
||||
- **开发周期**: 持续更新维护
|
||||
|
||||
## 🎯 项目优势
|
||||
|
||||
### 技术优势
|
||||
- ✅ **现代化架构**: 基于FastAPI + Python 3.11+异步编程
|
||||
- ✅ **容器化部署**: Docker + Docker Compose一键部署
|
||||
- ✅ **多用户系统**: 完整的用户注册、登录、权限管理
|
||||
- ✅ **数据隔离**: 每个用户的数据完全独立,安全可靠
|
||||
- ✅ **实时通信**: WebSocket实时消息处理和状态监控
|
||||
|
||||
### 功能优势
|
||||
- ✅ **智能回复**: 关键词匹配 + AI智能回复 + 优先级策略
|
||||
- ✅ **自动发货**: 多种发货方式,支持规格匹配和延时发货
|
||||
- ✅ **商品管理**: 自动收集商品信息,支持批量操作
|
||||
- ✅ **订单管理**: 订单详情获取,支持自动确认发货
|
||||
- ✅ **安全保护**: 多层加密,防重复机制,异常恢复
|
||||
|
||||
### 运维优势
|
||||
- ✅ **日志系统**: 完整的日志记录和实时查看
|
||||
- ✅ **监控告警**: 账号状态监控和异常告警
|
||||
- ✅ **数据备份**: 自动数据备份和恢复机制
|
||||
- ✅ **性能优化**: 异步处理,高并发支持
|
||||
- ✅ **易于维护**: 模块化设计,代码结构清晰
|
||||
- ✅ **使用统计**: 匿名使用统计,帮助改进产品
|
||||
|
||||
## 📊 用户统计说明
|
||||
|
||||
### 统计目的
|
||||
为了了解有多少人在使用这个系统,系统会发送匿名的用户统计信息。
|
||||
|
||||
### 收集的信息
|
||||
- **匿名ID**: 基于机器特征生成的唯一标识符(重启不变)
|
||||
- **操作系统**: 系统类型(如Windows、Linux)
|
||||
- **版本信息**: 软件版本号
|
||||
|
||||
### 隐私保护
|
||||
- ✅ **完全匿名**: 不收集任何个人身份信息
|
||||
- ✅ **数据安全**: 不收集账号、密码、关键词等敏感信息
|
||||
- ✅ **本地优先**: 所有业务数据仅存储在本地
|
||||
- ✅ **持久化ID**: Docker重建时ID不变(保存在数据库中)
|
||||
|
||||
### 查看统计信息
|
||||
|
||||
#### 方式1: Python统计服务器
|
||||
```bash
|
||||
# 部署Python统计服务器
|
||||
python simple_stats_server.py
|
||||
|
||||
# 访问统计服务器查看用户数量
|
||||
curl http://localhost:8081/stats
|
||||
```
|
||||
|
||||
#### 方式2: PHP统计服务器
|
||||
```bash
|
||||
# 将index.php部署到Web服务器(如Apache/Nginx)
|
||||
# 访问统计接口
|
||||
curl http://localhost/php/stats
|
||||
|
||||
# 测试统计功能
|
||||
python test_php_stats.py
|
||||
```
|
||||
|
||||
**PHP统计服务器特点**:
|
||||
- 数据保存在`user_stats.txt`文件中
|
||||
- 支持用户数据更新(anonymous_id作为key)
|
||||
- 自动生成统计摘要
|
||||
- 记录操作日志到`stats.log`
|
||||
|
||||
---
|
||||
|
||||
🎉 **开始使用闲鱼自动回复系统,让您的闲鱼店铺管理更加智能高效!**
|
||||
|
||||
**请记住:仅限学习使用,禁止商业用途!**
|
||||
**⚠️ 重要提醒:本项目仅供学习研究使用,严禁商业用途!**
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
7
Start.py
7
Start.py
@ -27,6 +27,7 @@ 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
|
||||
from usage_statistics import report_user_count
|
||||
|
||||
|
||||
def _start_api_server():
|
||||
@ -143,6 +144,12 @@ async def main():
|
||||
threading.Thread(target=_start_api_server, daemon=True).start()
|
||||
print("API 服务线程已启动")
|
||||
|
||||
# 上报用户统计
|
||||
try:
|
||||
await report_user_count()
|
||||
except Exception as e:
|
||||
logger.debug(f"上报用户统计失败: {e}")
|
||||
|
||||
# 阻塞保持运行
|
||||
print("主程序启动完成,保持运行...")
|
||||
await asyncio.Event().wait()
|
||||
|
||||
@ -37,6 +37,11 @@ class AutoReplyPauseManager:
|
||||
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
|
||||
@ -122,6 +127,10 @@ class XianyuLive:
|
||||
# 商品详情缓存(24小时有效)
|
||||
_item_detail_cache = {} # {item_id: {'detail': str, 'timestamp': float}}
|
||||
_item_detail_cache_lock = asyncio.Lock()
|
||||
|
||||
# 类级别的实例管理字典,用于API调用
|
||||
_instances = {} # {cookie_id: XianyuLive实例}
|
||||
_instances_lock = asyncio.Lock()
|
||||
|
||||
def _safe_str(self, e):
|
||||
"""安全地将异常转换为字符串"""
|
||||
@ -213,7 +222,41 @@ class XianyuLive:
|
||||
self.max_connection_failures = 5 # 最大连续失败次数
|
||||
self.last_successful_connection = 0 # 上次成功连接时间
|
||||
|
||||
# 注册实例到类级别字典(用于API调用)
|
||||
self._register_instance()
|
||||
|
||||
def _register_instance(self):
|
||||
"""注册当前实例到类级别字典"""
|
||||
try:
|
||||
# 使用同步方式注册,避免在__init__中使用async
|
||||
XianyuLive._instances[self.cookie_id] = self
|
||||
logger.debug(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.debug(f"【{self.cookie_id}】实例已从全局字典中注销")
|
||||
except Exception as e:
|
||||
logger.error(f"【{self.cookie_id}】注销实例失败: {self._safe_str(e)}")
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, cookie_id: str):
|
||||
"""获取指定cookie_id的XianyuLive实例"""
|
||||
return cls._instances.get(cookie_id)
|
||||
|
||||
@classmethod
|
||||
def get_all_instances(cls):
|
||||
"""获取所有活跃的XianyuLive实例"""
|
||||
return dict(cls._instances)
|
||||
|
||||
@classmethod
|
||||
def get_instance_count(cls):
|
||||
"""获取当前活跃实例数量"""
|
||||
return len(cls._instances)
|
||||
|
||||
def is_auto_confirm_enabled(self) -> bool:
|
||||
"""检查当前账号是否启用自动确认发货"""
|
||||
@ -4991,6 +5034,10 @@ class XianyuLive:
|
||||
self.cookie_refresh_task.cancel()
|
||||
await self.close_session() # 确保关闭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):
|
||||
"""获取商品信息,自动处理token失效的情况
|
||||
|
||||
|
||||
@ -414,7 +414,8 @@ class DBManager:
|
||||
('smtp_password', '', 'SMTP登录密码/授权码'),
|
||||
('smtp_from', '', '发件人显示名(留空则使用用户名)'),
|
||||
('smtp_use_tls', 'true', '是否启用TLS'),
|
||||
('smtp_use_ssl', 'false', '是否启用SSL')
|
||||
('smtp_use_ssl', 'false', '是否启用SSL'),
|
||||
('qq_reply_secret_key', 'xianyu_qq_reply_2024', 'QQ回复消息API秘钥')
|
||||
''')
|
||||
|
||||
# 检查并升级数据库
|
||||
@ -1210,7 +1211,7 @@ class DBManager:
|
||||
'user_id': result[2],
|
||||
'auto_confirm': bool(result[3]),
|
||||
'remark': result[4] or '',
|
||||
'pause_duration': result[5] if result[5] is not None else 10,
|
||||
'pause_duration': result[5] if result[5] is not None else 10, # 0是有效值,表示不暂停
|
||||
'created_at': result[6]
|
||||
}
|
||||
return None
|
||||
@ -1271,7 +1272,7 @@ class DBManager:
|
||||
self._execute_sql(cursor, "UPDATE cookies SET pause_duration = 10 WHERE id = ?", (cookie_id,))
|
||||
self.conn.commit()
|
||||
return 10
|
||||
return result[0] # 返回实际值,不使用or操作符
|
||||
return result[0] # 返回实际值,包括0(0表示不暂停)
|
||||
else:
|
||||
logger.warning(f"账号 {cookie_id} 未找到记录,使用默认值10分钟")
|
||||
return 10
|
||||
@ -1338,6 +1339,29 @@ class DBManager:
|
||||
try:
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# 检查是否与现有图片关键词冲突
|
||||
for keyword, reply, item_id in keywords:
|
||||
normalized_item_id = item_id if item_id and item_id.strip() else None
|
||||
|
||||
# 检查是否存在同名的图片关键词
|
||||
if normalized_item_id:
|
||||
# 有商品ID的情况:检查 (cookie_id, keyword, item_id) 是否存在图片关键词
|
||||
self._execute_sql(cursor,
|
||||
"SELECT type FROM keywords WHERE cookie_id = ? AND keyword = ? AND item_id = ? AND type = 'image'",
|
||||
(cookie_id, keyword, normalized_item_id))
|
||||
else:
|
||||
# 通用关键词的情况:检查 (cookie_id, keyword) 是否存在图片关键词
|
||||
self._execute_sql(cursor,
|
||||
"SELECT type FROM keywords WHERE cookie_id = ? AND keyword = ? AND (item_id IS NULL OR item_id = '') AND type = 'image'",
|
||||
(cookie_id, keyword))
|
||||
|
||||
if cursor.fetchone():
|
||||
# 存在同名图片关键词,抛出友好的错误信息
|
||||
item_desc = f"商品ID: {normalized_item_id}" if normalized_item_id else "通用关键词"
|
||||
error_msg = f"关键词 '{keyword}' ({item_desc}) 已存在(图片关键词),无法保存为文本关键词"
|
||||
logger.warning(f"文本关键词与图片关键词冲突: Cookie={cookie_id}, 关键词='{keyword}', {item_desc}")
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# 只删除该cookie_id的文本类型关键字,保留图片关键词
|
||||
self._execute_sql(cursor,
|
||||
"DELETE FROM keywords WHERE cookie_id = ? AND (type IS NULL OR type = 'text')",
|
||||
@ -1348,22 +1372,15 @@ class DBManager:
|
||||
# 标准化item_id:空字符串转为NULL
|
||||
normalized_item_id = item_id if item_id and item_id.strip() else None
|
||||
|
||||
try:
|
||||
self._execute_sql(cursor,
|
||||
"INSERT INTO keywords (cookie_id, keyword, reply, item_id, type) VALUES (?, ?, ?, ?, 'text')",
|
||||
(cookie_id, keyword, reply, normalized_item_id))
|
||||
except sqlite3.IntegrityError as ie:
|
||||
# 如果遇到唯一约束冲突,记录详细错误信息并回滚
|
||||
item_desc = f"商品ID: {normalized_item_id}" if normalized_item_id else "通用关键词"
|
||||
logger.error(f"关键词唯一约束冲突: Cookie={cookie_id}, 关键词='{keyword}', {item_desc}")
|
||||
self.conn.rollback()
|
||||
raise ie
|
||||
self._execute_sql(cursor,
|
||||
"INSERT INTO keywords (cookie_id, keyword, reply, item_id, type) VALUES (?, ?, ?, ?, 'text')",
|
||||
(cookie_id, keyword, reply, normalized_item_id))
|
||||
|
||||
self.conn.commit()
|
||||
logger.info(f"文本关键字保存成功: {cookie_id}, {len(keywords)}条,图片关键词已保留")
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
# 唯一约束冲突,重新抛出异常让上层处理
|
||||
except ValueError:
|
||||
# 重新抛出友好的错误信息
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"文本关键字保存失败: {e}")
|
||||
|
||||
227
reply_server.py
227
reply_server.py
@ -24,6 +24,7 @@ from ai_reply_engine import ai_reply_engine
|
||||
from utils.qr_login import qr_login_manager
|
||||
from utils.xianyu_utils import trans_cookies
|
||||
from utils.image_utils import image_manager
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# 关键字文件路径
|
||||
@ -848,6 +849,146 @@ async def register(request: RegisterRequest):
|
||||
)
|
||||
|
||||
|
||||
# ------------------------- 发送消息接口 -------------------------
|
||||
|
||||
# 固定的API秘钥(生产环境中应该从配置文件或环境变量读取)
|
||||
# 注意:现在从系统设置中读取QQ回复消息秘钥
|
||||
API_SECRET_KEY = "xianyu_api_secret_2024" # 保留作为后备
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
api_key: str
|
||||
cookie_id: str
|
||||
chat_id: str
|
||||
to_user_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class SendMessageResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
def verify_api_key(api_key: str) -> bool:
|
||||
"""验证API秘钥"""
|
||||
try:
|
||||
# 从系统设置中获取QQ回复消息秘钥
|
||||
from db_manager import db_manager
|
||||
qq_secret_key = db_manager.get_system_setting('qq_reply_secret_key')
|
||||
|
||||
# 如果系统设置中没有配置,使用默认值
|
||||
if not qq_secret_key:
|
||||
qq_secret_key = API_SECRET_KEY
|
||||
|
||||
return api_key == qq_secret_key
|
||||
except Exception as e:
|
||||
logger.error(f"验证API秘钥时发生异常: {e}")
|
||||
# 异常情况下使用默认秘钥验证
|
||||
return api_key == API_SECRET_KEY
|
||||
|
||||
|
||||
@app.post('/send-message', response_model=SendMessageResponse)
|
||||
async def send_message_api(request: SendMessageRequest):
|
||||
"""发送消息API接口(使用秘钥验证)"""
|
||||
try:
|
||||
# 清理所有参数中的换行符
|
||||
def clean_param(param_str):
|
||||
"""清理参数中的换行符"""
|
||||
if isinstance(param_str, str):
|
||||
return param_str.replace('\\n', '').replace('\n', '')
|
||||
return param_str
|
||||
|
||||
# 清理所有参数
|
||||
cleaned_api_key = clean_param(request.api_key)
|
||||
cleaned_cookie_id = clean_param(request.cookie_id)
|
||||
cleaned_chat_id = clean_param(request.chat_id)
|
||||
cleaned_to_user_id = clean_param(request.to_user_id)
|
||||
cleaned_message = clean_param(request.message)
|
||||
|
||||
# 验证API秘钥不能为空
|
||||
if not cleaned_api_key:
|
||||
logger.warning("API秘钥为空")
|
||||
return SendMessageResponse(
|
||||
success=False,
|
||||
message="API秘钥不能为空"
|
||||
)
|
||||
|
||||
# 特殊测试秘钥处理
|
||||
if cleaned_api_key == "zhinina_test_key":
|
||||
logger.info("使用测试秘钥,直接返回成功")
|
||||
return SendMessageResponse(
|
||||
success=True,
|
||||
message="接口验证成功"
|
||||
)
|
||||
|
||||
# 验证API秘钥
|
||||
if not verify_api_key(cleaned_api_key):
|
||||
logger.warning(f"API秘钥验证失败: {cleaned_api_key}")
|
||||
return SendMessageResponse(
|
||||
success=False,
|
||||
message="API秘钥验证失败"
|
||||
)
|
||||
|
||||
# 验证必需参数不能为空
|
||||
required_params = {
|
||||
'cookie_id': cleaned_cookie_id,
|
||||
'chat_id': cleaned_chat_id,
|
||||
'to_user_id': cleaned_to_user_id,
|
||||
'message': cleaned_message
|
||||
}
|
||||
|
||||
for param_name, param_value in required_params.items():
|
||||
if not param_value:
|
||||
logger.warning(f"必需参数 {param_name} 为空")
|
||||
return SendMessageResponse(
|
||||
success=False,
|
||||
message=f"参数 {param_name} 不能为空"
|
||||
)
|
||||
|
||||
# 直接获取XianyuLive实例,跳过cookie_manager检查
|
||||
from XianyuAutoAsync import XianyuLive
|
||||
live_instance = XianyuLive.get_instance(cleaned_cookie_id)
|
||||
|
||||
if not live_instance:
|
||||
logger.warning(f"账号实例不存在或未连接: {cleaned_cookie_id}")
|
||||
return SendMessageResponse(
|
||||
success=False,
|
||||
message="账号实例不存在或未连接,请检查账号状态"
|
||||
)
|
||||
|
||||
# 检查WebSocket连接状态
|
||||
if not live_instance.ws or live_instance.ws.closed:
|
||||
logger.warning(f"账号WebSocket连接已断开: {cleaned_cookie_id}")
|
||||
return SendMessageResponse(
|
||||
success=False,
|
||||
message="账号WebSocket连接已断开,请等待重连"
|
||||
)
|
||||
|
||||
# 发送消息(使用清理后的所有参数)
|
||||
await live_instance.send_msg(
|
||||
live_instance.ws,
|
||||
cleaned_chat_id,
|
||||
cleaned_to_user_id,
|
||||
cleaned_message
|
||||
)
|
||||
|
||||
logger.info(f"API成功发送消息: {cleaned_cookie_id} -> {cleaned_to_user_id}, 内容: {cleaned_message[:50]}{'...' if len(cleaned_message) > 50 else ''}")
|
||||
|
||||
return SendMessageResponse(
|
||||
success=True,
|
||||
message="消息发送成功"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# 使用清理后的参数记录日志
|
||||
cookie_id_for_log = clean_param(request.cookie_id) if 'clean_param' in locals() else request.cookie_id
|
||||
to_user_id_for_log = clean_param(request.to_user_id) if 'clean_param' in locals() else request.to_user_id
|
||||
logger.error(f"API发送消息异常: {cookie_id_for_log} -> {to_user_id_for_log}, 错误: {str(e)}")
|
||||
return SendMessageResponse(
|
||||
success=False,
|
||||
message=f"发送消息失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/xianyu/reply", response_model=ResponseModel)
|
||||
async def xianyu_reply(req: RequestModel):
|
||||
msg_template = match_reply(req.cookie_id, req.send_message)
|
||||
@ -928,6 +1069,11 @@ class MessageNotificationIn(BaseModel):
|
||||
|
||||
|
||||
class SystemSettingIn(BaseModel):
|
||||
value: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class SystemSettingCreateIn(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
description: Optional[str] = None
|
||||
@ -2027,9 +2173,9 @@ def update_cookie_pause_duration(cid: str, update_data: PauseDurationUpdate, cur
|
||||
if cid not in user_cookies:
|
||||
raise HTTPException(status_code=403, detail="无权限操作该Cookie")
|
||||
|
||||
# 验证暂停时间范围(1-60分钟)
|
||||
if not (1 <= update_data.pause_duration <= 60):
|
||||
raise HTTPException(status_code=400, detail="暂停时间必须在1-60分钟之间")
|
||||
# 验证暂停时间范围(0-60分钟,0表示不暂停)
|
||||
if not (0 <= update_data.pause_duration <= 60):
|
||||
raise HTTPException(status_code=400, detail="暂停时间必须在0-60分钟之间(0表示不暂停)")
|
||||
|
||||
# 更新暂停时间
|
||||
success = db_manager.update_cookie_pause_duration(cid, update_data.pause_duration)
|
||||
@ -2207,12 +2353,40 @@ def update_keywords_with_item_id(cid: str, body: KeywordWithItemIdIn, current_us
|
||||
raise HTTPException(status_code=500, detail="保存关键词失败")
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "UNIQUE constraint failed" in error_msg:
|
||||
# 解析具体的冲突信息
|
||||
if "keywords.cookie_id, keywords.keyword" in error_msg:
|
||||
raise HTTPException(status_code=400, detail="关键词重复!该关键词已存在(可能是图片关键词或文本关键词),请使用其他关键词")
|
||||
|
||||
# 检查是否是图片关键词冲突
|
||||
if "已存在(图片关键词)" in error_msg:
|
||||
# 直接使用数据库管理器提供的友好错误信息
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
elif "UNIQUE constraint failed" in error_msg or "唯一约束冲突" in error_msg:
|
||||
# 尝试从错误信息中提取具体的冲突关键词
|
||||
conflict_keyword = None
|
||||
conflict_type = None
|
||||
|
||||
# 检查是否是数据库管理器抛出的详细错误
|
||||
if "关键词唯一约束冲突" in error_msg:
|
||||
# 解析详细错误信息:关键词唯一约束冲突: Cookie=xxx, 关键词='xxx', 通用关键词/商品ID: xxx
|
||||
import re
|
||||
keyword_match = re.search(r"关键词='([^']+)'", error_msg)
|
||||
if keyword_match:
|
||||
conflict_keyword = keyword_match.group(1)
|
||||
|
||||
if "通用关键词" in error_msg:
|
||||
conflict_type = "通用关键词"
|
||||
elif "商品ID:" in error_msg:
|
||||
item_match = re.search(r"商品ID: ([^\s,]+)", error_msg)
|
||||
if item_match:
|
||||
conflict_type = f"商品关键词(商品ID: {item_match.group(1)})"
|
||||
|
||||
# 构造用户友好的错误信息
|
||||
if conflict_keyword and conflict_type:
|
||||
detail_msg = f'关键词 "{conflict_keyword}" ({conflict_type}) 已存在,请使用其他关键词或商品ID'
|
||||
elif "keywords.cookie_id, keywords.keyword" in error_msg:
|
||||
detail_msg = "关键词重复!该关键词已存在(可能是图片关键词或文本关键词),请使用其他关键词"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="关键词重复!请使用不同的关键词或商品ID组合")
|
||||
detail_msg = "关键词重复!请使用不同的关键词或商品ID组合"
|
||||
|
||||
raise HTTPException(status_code=400, detail=detail_msg)
|
||||
else:
|
||||
log_with_user('error', f"保存关键词时发生未知错误: {error_msg}", current_user)
|
||||
raise HTTPException(status_code=500, detail="保存关键词失败")
|
||||
@ -4327,6 +4501,43 @@ def update_item_multi_quantity_delivery(cookie_id: str, item_id: str, delivery_d
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ==================== 订单管理接口 ====================
|
||||
|
||||
@app.get('/api/orders')
|
||||
def get_user_orders(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""获取当前用户的订单信息"""
|
||||
try:
|
||||
from db_manager import db_manager
|
||||
|
||||
user_id = current_user['user_id']
|
||||
log_with_user('info', "查询用户订单信息", current_user)
|
||||
|
||||
# 获取用户的所有Cookie
|
||||
user_cookies = db_manager.get_all_cookies(user_id)
|
||||
|
||||
# 获取所有订单数据
|
||||
all_orders = []
|
||||
for cookie_id in user_cookies.keys():
|
||||
orders = db_manager.get_orders_by_cookie(cookie_id, limit=1000) # 增加限制数量
|
||||
# 为每个订单添加cookie_id信息
|
||||
for order in orders:
|
||||
order['cookie_id'] = cookie_id
|
||||
all_orders.append(order)
|
||||
|
||||
# 按创建时间倒序排列
|
||||
all_orders.sort(key=lambda x: x.get('created_at', ''), reverse=True)
|
||||
|
||||
log_with_user('info', f"用户订单查询成功,共 {len(all_orders)} 条记录", current_user)
|
||||
return {"success": True, "data": all_orders}
|
||||
|
||||
except Exception as e:
|
||||
log_with_user('error', f"查询用户订单失败: {str(e)}", current_user)
|
||||
raise HTTPException(status_code=500, detail=f"查询订单失败: {str(e)}")
|
||||
|
||||
|
||||
# 移除自动启动,由Start.py或手动启动
|
||||
# if __name__ == "__main__":
|
||||
# uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||
232
simple_stats_server.py
Normal file
232
simple_stats_server.py
Normal file
@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
简单的用户统计服务器
|
||||
只统计有多少人在使用闲鱼自动回复系统
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Any
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
import uvicorn
|
||||
from pathlib import Path
|
||||
|
||||
app = FastAPI(title="闲鱼自动回复系统用户统计", version="1.0.0")
|
||||
|
||||
# 数据库文件路径
|
||||
DB_PATH = Path(__file__).parent / "user_stats.db"
|
||||
|
||||
|
||||
class UserStats(BaseModel):
|
||||
"""用户统计数据模型"""
|
||||
anonymous_id: str
|
||||
timestamp: str
|
||||
project: str
|
||||
info: Dict[str, Any]
|
||||
|
||||
|
||||
def init_database():
|
||||
"""初始化数据库"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建用户统计表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS user_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
anonymous_id TEXT UNIQUE NOT NULL,
|
||||
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
os TEXT,
|
||||
version TEXT,
|
||||
total_reports INTEGER DEFAULT 1
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_anonymous_id ON user_stats(anonymous_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_last_seen ON user_stats(last_seen)')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_user_stats(data: UserStats):
|
||||
"""保存用户统计数据"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
info = data.info
|
||||
os_info = info.get('os', 'unknown')
|
||||
version = info.get('version', '2.2.0')
|
||||
|
||||
# 检查用户是否已存在
|
||||
cursor.execute('SELECT id, total_reports FROM user_stats WHERE anonymous_id = ?', (data.anonymous_id,))
|
||||
existing = cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# 更新现有用户的最后访问时间和报告次数
|
||||
cursor.execute('''
|
||||
UPDATE user_stats
|
||||
SET last_seen = CURRENT_TIMESTAMP,
|
||||
total_reports = total_reports + 1,
|
||||
os = ?,
|
||||
version = ?
|
||||
WHERE anonymous_id = ?
|
||||
''', (os_info, version, data.anonymous_id))
|
||||
else:
|
||||
# 插入新用户
|
||||
cursor.execute('''
|
||||
INSERT INTO user_stats (anonymous_id, os, version)
|
||||
VALUES (?, ?, ?)
|
||||
''', (data.anonymous_id, os_info, version))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"保存用户统计失败: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.post('/statistics')
|
||||
async def receive_user_stats(data: UserStats):
|
||||
"""接收用户统计数据"""
|
||||
try:
|
||||
success = save_user_stats(data)
|
||||
|
||||
if success:
|
||||
print(f"收到用户统计: {data.anonymous_id}")
|
||||
return {"status": "success", "message": "用户统计已收到"}
|
||||
else:
|
||||
return {"status": "error", "message": "保存统计数据失败"}
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理用户统计失败: {e}")
|
||||
return {"status": "error", "message": "处理统计数据失败"}
|
||||
|
||||
|
||||
@app.get('/stats')
|
||||
async def get_user_stats():
|
||||
"""获取用户统计摘要"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 总用户数
|
||||
cursor.execute('SELECT COUNT(*) FROM user_stats')
|
||||
total_users = cursor.fetchone()[0]
|
||||
|
||||
# 今日活跃用户
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*)
|
||||
FROM user_stats
|
||||
WHERE DATE(last_seen) = DATE('now')
|
||||
''')
|
||||
daily_active = cursor.fetchone()[0]
|
||||
|
||||
# 本周活跃用户
|
||||
cursor.execute('''
|
||||
SELECT COUNT(*)
|
||||
FROM user_stats
|
||||
WHERE DATE(last_seen) >= DATE('now', '-7 days')
|
||||
''')
|
||||
weekly_active = cursor.fetchone()[0]
|
||||
|
||||
# 操作系统分布
|
||||
cursor.execute('''
|
||||
SELECT os, COUNT(*) as count
|
||||
FROM user_stats
|
||||
GROUP BY os
|
||||
ORDER BY count DESC
|
||||
''')
|
||||
os_distribution = dict(cursor.fetchall())
|
||||
|
||||
# 版本分布
|
||||
cursor.execute('''
|
||||
SELECT version, COUNT(*) as count
|
||||
FROM user_stats
|
||||
GROUP BY version
|
||||
ORDER BY count DESC
|
||||
''')
|
||||
version_distribution = dict(cursor.fetchall())
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"daily_active_users": daily_active,
|
||||
"weekly_active_users": weekly_active,
|
||||
"os_distribution": os_distribution,
|
||||
"version_distribution": version_distribution,
|
||||
"last_updated": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"获取统计失败: {e}"}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get('/stats/recent')
|
||||
async def get_recent_users():
|
||||
"""获取最近活跃的用户(匿名)"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
SELECT anonymous_id, first_seen, last_seen, os, version, total_reports
|
||||
FROM user_stats
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT 20
|
||||
''')
|
||||
|
||||
records = cursor.fetchall()
|
||||
|
||||
return {
|
||||
"recent_users": [
|
||||
{
|
||||
"anonymous_id": record[0][:8] + "****", # 部分隐藏ID
|
||||
"first_seen": record[1],
|
||||
"last_seen": record[2],
|
||||
"os": record[3],
|
||||
"version": record[4],
|
||||
"total_reports": record[5]
|
||||
}
|
||||
for record in records
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"获取最近用户失败: {e}"}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get('/')
|
||||
async def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"message": "闲鱼自动回复系统用户统计服务器",
|
||||
"description": "只统计有多少人在使用这个系统",
|
||||
"endpoints": {
|
||||
"POST /statistics": "接收用户统计数据",
|
||||
"GET /stats": "获取用户统计摘要",
|
||||
"GET /stats/recent": "获取最近活跃用户"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 初始化数据库
|
||||
init_database()
|
||||
print("用户统计数据库初始化完成")
|
||||
|
||||
# 启动服务器
|
||||
print("启动用户统计服务器...")
|
||||
print("访问 http://localhost:8081/stats 查看统计信息")
|
||||
uvicorn.run(app, host="0.0.0.0", port=8081)
|
||||
@ -155,7 +155,11 @@
|
||||
</h2>
|
||||
<p class="text-muted mb-0">系统概览和统计信息</p>
|
||||
</div>
|
||||
<div class="version-info">
|
||||
<div class="version-info d-flex gap-2">
|
||||
<span class="badge bg-primary" id="projectUsers" style="cursor: pointer;" onclick="showProjectStats()" title="点击查看详细统计">
|
||||
<i class="bi bi-people me-1"></i>
|
||||
使用人数: <span id="totalUsers">加载中...</span>
|
||||
</span>
|
||||
<span class="badge bg-secondary" id="systemVersion">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
版本: <span id="versionNumber">加载中...</span>
|
||||
@ -319,7 +323,7 @@
|
||||
<i class="bi bi-question-circle ms-1"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="top"
|
||||
title="检测到手动发出消息后,自动回复暂停的时间长度(分钟)。如果在暂停期间再次手动发出消息,会重新开始计时。"></i>
|
||||
title="检测到手动发出消息后,自动回复暂停的时间长度(分钟)。设置为0表示不暂停。如果在暂停期间再次手动发出消息,会重新开始计时。"></i>
|
||||
</th>
|
||||
<th style="width: 18%">操作</th>
|
||||
</tr>
|
||||
@ -960,7 +964,7 @@
|
||||
<th>商品关键字</th>
|
||||
<th>匹配卡券</th>
|
||||
<th>卡券类型</th>
|
||||
<th>发货数量</th>
|
||||
<!-- <th>发货数量</th> 隐藏发货数量列 -->
|
||||
<th>状态</th>
|
||||
<th>已发货次数</th>
|
||||
<th>操作</th>
|
||||
@ -1579,6 +1583,94 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API安全设置 (仅管理员可见) -->
|
||||
<div id="api-security-settings" class="row mt-4" style="display: none;">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-shield-check me-2"></i>API安全设置
|
||||
<span class="badge bg-warning ms-2">管理员专用</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<!-- QQ回复消息秘钥 -->
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="qqReplySecretKey" class="form-label">
|
||||
<i class="bi bi-key me-1"></i>
|
||||
<strong>QQ回复消息API秘钥</strong>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="qqReplySecretKey"
|
||||
placeholder="请输入QQ回复消息API秘钥">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('qqReplySecretKey')">
|
||||
<i class="bi bi-eye" id="qqReplySecretKey-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
用于验证 <code>/send-message</code> API接口的访问权限,确保只有授权的应用可以发送消息
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 秘钥操作 -->
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-tools me-1"></i>
|
||||
<strong>秘钥管理</strong>
|
||||
</label>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-success" onclick="generateRandomSecretKey()">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>生成随机秘钥
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="updateQQReplySecretKey()">
|
||||
<i class="bi bi-check-circle me-1"></i>保存秘钥
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text mt-2">
|
||||
<i class="bi bi-exclamation-triangle me-1 text-warning"></i>
|
||||
修改秘钥后,所有使用该API的应用都需要更新配置
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态显示 -->
|
||||
<div id="qqReplySecretStatus" class="mt-3" style="display: none;">
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<span id="qqReplySecretStatusText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API使用说明 -->
|
||||
<div class="mt-4">
|
||||
<h6 class="mb-3">
|
||||
<i class="bi bi-book me-2"></i>API使用说明
|
||||
</h6>
|
||||
<div class="bg-light p-3 rounded">
|
||||
<p class="mb-2"><strong>接口地址:</strong> <code>POST /send-message</code></p>
|
||||
<p class="mb-2"><strong>请求参数:</strong></p>
|
||||
<pre class="mb-2"><code>{
|
||||
"api_key": "您的秘钥",
|
||||
"cookie_id": "账号ID",
|
||||
"chat_id": "聊天ID",
|
||||
"to_user_id": "目标用户ID",
|
||||
"message": "消息内容"
|
||||
}</code></pre>
|
||||
<p class="mb-0">
|
||||
<i class="bi bi-lightbulb me-1 text-warning"></i>
|
||||
<strong>提示:</strong>请妥善保管API秘钥,避免泄露给未授权的第三方
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册设置 (仅管理员可见) -->
|
||||
<div id="registration-settings" class="row mt-4" style="display: none;">
|
||||
<div class="col-md-6">
|
||||
@ -2706,11 +2798,8 @@
|
||||
<option value="">请选择卡券</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">发货数量</label>
|
||||
<input type="number" class="form-control" id="deliveryCount" value="1" min="1" max="10">
|
||||
<small class="form-text text-muted">每次发货的数量</small>
|
||||
</div>
|
||||
<!-- 隐藏发货数量字段,默认为1 -->
|
||||
<input type="hidden" id="deliveryCount" value="1">
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="ruleEnabled" checked>
|
||||
@ -2758,11 +2847,8 @@
|
||||
<option value="">请选择卡券</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">发货数量</label>
|
||||
<input type="number" class="form-control" id="editDeliveryCount" value="1" min="1" max="10">
|
||||
<small class="form-text text-muted">每次发货的数量</small>
|
||||
</div>
|
||||
<!-- 隐藏发货数量字段,默认为1 -->
|
||||
<input type="hidden" id="editDeliveryCount" value="1">
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="editRuleEnabled">
|
||||
|
||||
333
static/js/app.js
333
static/js/app.js
@ -233,7 +233,7 @@ async function loadDashboard() {
|
||||
async function loadOrdersCount() {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const response = await fetch('/admin/data/orders', {
|
||||
const response = await fetch('/api/orders', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
@ -702,7 +702,13 @@ async function addKeyword() {
|
||||
}
|
||||
|
||||
// 检查关键词是否已存在(考虑商品ID,检查所有类型的关键词)
|
||||
const allKeywords = keywordsData[currentCookieId] || [];
|
||||
// 在编辑模式下,需要排除正在编辑的关键词本身
|
||||
let allKeywords = keywordsData[currentCookieId] || [];
|
||||
if (isEditMode && typeof window.editingIndex !== 'undefined') {
|
||||
// 创建一个副本,排除正在编辑的关键词
|
||||
allKeywords = allKeywords.filter((item, index) => index !== window.editingIndex);
|
||||
}
|
||||
|
||||
const existingKeyword = allKeywords.find(item =>
|
||||
item.keyword === keyword &&
|
||||
(item.item_id || '') === (itemId || '')
|
||||
@ -1269,8 +1275,8 @@ async function loadCookies() {
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="pause-duration-cell" data-cookie-id="${cookie.id}">
|
||||
<span class="pause-duration-display" onclick="editPauseDuration('${cookie.id}', ${cookie.pause_duration || 10})" title="点击编辑暂停时间" style="cursor: pointer; color: #6c757d; font-size: 0.875rem;">
|
||||
<i class="bi bi-clock me-1"></i>${cookie.pause_duration || 10}分钟
|
||||
<span class="pause-duration-display" onclick="editPauseDuration('${cookie.id}', ${cookie.pause_duration !== undefined ? cookie.pause_duration : 10})" title="点击编辑暂停时间" style="cursor: pointer; color: #6c757d; font-size: 0.875rem;">
|
||||
<i class="bi bi-clock me-1"></i>${cookie.pause_duration === 0 ? '不暂停' : (cookie.pause_duration || 10) + '分钟'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
@ -1906,6 +1912,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// 加载系统版本号
|
||||
loadSystemVersion();
|
||||
// 启动项目使用人数定时刷新
|
||||
startProjectUsersRefresh();
|
||||
// 添加Cookie表单提交
|
||||
document.getElementById('addForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
@ -4219,9 +4227,8 @@ function renderDeliveryRulesList(rules) {
|
||||
</div>
|
||||
</td>
|
||||
<td>${cardTypeBadge}</td>
|
||||
<td>
|
||||
<span class="badge bg-info">${rule.delivery_count || 1}</span>
|
||||
</td>
|
||||
<!-- 隐藏发货数量列 -->
|
||||
<!-- <td><span class="badge bg-info">${rule.delivery_count || 1}</span></td> -->
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
<span class="badge bg-warning">${rule.delivery_times || 0}</span>
|
||||
@ -4330,7 +4337,7 @@ async function saveDeliveryRule() {
|
||||
try {
|
||||
const keyword = document.getElementById('productKeyword').value;
|
||||
const cardId = document.getElementById('selectedCard').value;
|
||||
const deliveryCount = document.getElementById('deliveryCount').value;
|
||||
const deliveryCount = document.getElementById('deliveryCount').value || 1;
|
||||
const enabled = document.getElementById('ruleEnabled').checked;
|
||||
const description = document.getElementById('ruleDescription').value;
|
||||
|
||||
@ -4787,7 +4794,7 @@ async function updateDeliveryRule() {
|
||||
const ruleId = document.getElementById('editRuleId').value;
|
||||
const keyword = document.getElementById('editProductKeyword').value;
|
||||
const cardId = document.getElementById('editSelectedCard').value;
|
||||
const deliveryCount = document.getElementById('editDeliveryCount').value;
|
||||
const deliveryCount = document.getElementById('editDeliveryCount').value || 1;
|
||||
const enabled = document.getElementById('editRuleEnabled').checked;
|
||||
const description = document.getElementById('editRuleDescription').value;
|
||||
|
||||
@ -7797,16 +7804,16 @@ function editPauseDuration(cookieId, currentDuration) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'form-control form-control-sm';
|
||||
input.value = currentDuration || 10;
|
||||
input.value = currentDuration !== undefined ? currentDuration : 10;
|
||||
input.placeholder = '请输入暂停时间...';
|
||||
input.style.fontSize = '0.875rem';
|
||||
input.min = 1;
|
||||
input.min = 0;
|
||||
input.max = 60;
|
||||
input.step = 1;
|
||||
|
||||
// 保存原始内容和原始值
|
||||
const originalContent = pauseCell.innerHTML;
|
||||
const originalValue = currentDuration || 10;
|
||||
const originalValue = currentDuration !== undefined ? currentDuration : 10;
|
||||
|
||||
// 标记是否已经进行了编辑
|
||||
let hasChanged = false;
|
||||
@ -7818,7 +7825,7 @@ function editPauseDuration(cookieId, currentDuration) {
|
||||
|
||||
// 监听输入变化
|
||||
input.addEventListener('input', () => {
|
||||
const newValue = parseInt(input.value) || 10;
|
||||
const newValue = input.value === '' ? 10 : parseInt(input.value);
|
||||
hasChanged = newValue !== originalValue;
|
||||
});
|
||||
|
||||
@ -7827,12 +7834,12 @@ function editPauseDuration(cookieId, currentDuration) {
|
||||
console.log('savePauseDuration called, isProcessing:', isProcessing, 'hasChanged:', hasChanged); // 调试信息
|
||||
if (isProcessing) return; // 防止重复调用
|
||||
|
||||
const newDuration = parseInt(input.value) || 10;
|
||||
const newDuration = input.value === '' ? 10 : parseInt(input.value);
|
||||
console.log('newDuration:', newDuration, 'originalValue:', originalValue); // 调试信息
|
||||
|
||||
// 验证范围
|
||||
if (newDuration < 1 || newDuration > 60) {
|
||||
showToast('暂停时间必须在1-60分钟之间', 'warning');
|
||||
if (isNaN(newDuration) || newDuration < 0 || newDuration > 60) {
|
||||
showToast('暂停时间必须在0-60分钟之间(0表示不暂停)', 'warning');
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
@ -7860,7 +7867,7 @@ function editPauseDuration(cookieId, currentDuration) {
|
||||
// 更新显示
|
||||
pauseCell.innerHTML = `
|
||||
<span class="pause-duration-display" onclick="editPauseDuration('${cookieId}', ${newDuration})" title="点击编辑暂停时间" style="cursor: pointer; color: #6c757d; font-size: 0.875rem;">
|
||||
<i class="bi bi-clock me-1"></i>${newDuration}分钟
|
||||
<i class="bi bi-clock me-1"></i>${newDuration === 0 ? '不暂停' : newDuration + '分钟'}
|
||||
</span>
|
||||
`;
|
||||
showToast('暂停时间更新成功', 'success');
|
||||
@ -7938,18 +7945,28 @@ async function loadSystemSettings() {
|
||||
|
||||
console.log('用户信息:', result, '是否管理员:', isAdmin);
|
||||
|
||||
// 显示/隐藏注册设置和外发配置(仅管理员可见)
|
||||
// 显示/隐藏管理员专用设置(仅管理员可见)
|
||||
const apiSecuritySettings = document.getElementById('api-security-settings');
|
||||
const registrationSettings = document.getElementById('registration-settings');
|
||||
const outgoingConfigs = document.getElementById('outgoing-configs');
|
||||
const backupManagement = document.getElementById('backup-management');
|
||||
|
||||
if (apiSecuritySettings) {
|
||||
apiSecuritySettings.style.display = isAdmin ? 'block' : 'none';
|
||||
}
|
||||
if (registrationSettings) {
|
||||
registrationSettings.style.display = isAdmin ? 'block' : 'none';
|
||||
}
|
||||
if (outgoingConfigs) {
|
||||
outgoingConfigs.style.display = isAdmin ? 'block' : 'none';
|
||||
}
|
||||
if (backupManagement) {
|
||||
backupManagement.style.display = isAdmin ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// 如果是管理员,加载注册设置、登录信息设置和外发配置
|
||||
// 如果是管理员,加载所有管理员设置
|
||||
if (isAdmin) {
|
||||
await loadAPISecuritySettings();
|
||||
await loadRegistrationSettings();
|
||||
await loadLoginInfoSettings();
|
||||
await loadOutgoingConfigs();
|
||||
@ -7965,6 +7982,115 @@ async function loadSystemSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载API安全设置
|
||||
async function loadAPISecuritySettings() {
|
||||
try {
|
||||
const response = await fetch('/system-settings', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const settings = await response.json();
|
||||
|
||||
// 加载QQ回复消息秘钥
|
||||
const qqReplySecretKey = settings.qq_reply_secret_key || '';
|
||||
const qqReplySecretKeyInput = document.getElementById('qqReplySecretKey');
|
||||
if (qqReplySecretKeyInput) {
|
||||
qqReplySecretKeyInput.value = qqReplySecretKey;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载API安全设置失败:', error);
|
||||
showToast('加载API安全设置失败', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换密码可见性
|
||||
function togglePasswordVisibility(inputId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = document.getElementById(inputId + '-icon');
|
||||
|
||||
if (input && icon) {
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成随机秘钥
|
||||
function generateRandomSecretKey() {
|
||||
// 生成32位随机字符串
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = 'xianyu_qq_';
|
||||
for (let i = 0; i < 24; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
|
||||
const qqReplySecretKeyInput = document.getElementById('qqReplySecretKey');
|
||||
if (qqReplySecretKeyInput) {
|
||||
qqReplySecretKeyInput.value = result;
|
||||
showToast('随机秘钥已生成', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新QQ回复消息秘钥
|
||||
async function updateQQReplySecretKey() {
|
||||
const qqReplySecretKey = document.getElementById('qqReplySecretKey').value.trim();
|
||||
|
||||
if (!qqReplySecretKey) {
|
||||
showToast('请输入QQ回复消息API秘钥', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (qqReplySecretKey.length < 8) {
|
||||
showToast('秘钥长度至少需要8位字符', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/system-settings/qq_reply_secret_key', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
value: qqReplySecretKey,
|
||||
description: 'QQ回复消息API秘钥'
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('QQ回复消息API秘钥更新成功', 'success');
|
||||
|
||||
// 显示状态信息
|
||||
const statusDiv = document.getElementById('qqReplySecretStatus');
|
||||
const statusText = document.getElementById('qqReplySecretStatusText');
|
||||
if (statusDiv && statusText) {
|
||||
statusText.textContent = `秘钥已更新,长度: ${qqReplySecretKey.length} 位`;
|
||||
statusDiv.style.display = 'block';
|
||||
|
||||
// 3秒后隐藏状态
|
||||
setTimeout(() => {
|
||||
statusDiv.style.display = 'none';
|
||||
}, 3000);
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
showToast(`更新失败: ${errorData.detail || '未知错误'}`, 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新QQ回复消息秘钥失败:', error);
|
||||
showToast('更新QQ回复消息秘钥失败', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// 加载外发配置
|
||||
async function loadOutgoingConfigs() {
|
||||
try {
|
||||
@ -8309,7 +8435,7 @@ async function loadOrderCookieFilter() {
|
||||
// 加载所有订单
|
||||
async function loadAllOrders() {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/admin/data/orders`, {
|
||||
const response = await fetch(`${apiBase}/api/orders`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
@ -8342,7 +8468,7 @@ async function loadOrdersByCookie() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/admin/data/orders`, {
|
||||
const response = await fetch(`${apiBase}/api/orders`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
@ -10009,6 +10135,171 @@ function exportSearchResults() {
|
||||
// 版本管理功能
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* 加载项目使用人数
|
||||
*/
|
||||
async function loadProjectUsers() {
|
||||
try {
|
||||
const response = await fetch('http://xianyu.zhinianblog.cn/?action=stats');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
console.error('获取项目使用人数失败:', result.error);
|
||||
document.getElementById('totalUsers').textContent = '获取失败';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalUsers = result.total_users || 0;
|
||||
document.getElementById('totalUsers').textContent = totalUsers;
|
||||
|
||||
// 如果用户数量大于0,可以添加一些视觉效果
|
||||
if (totalUsers > 0) {
|
||||
const usersElement = document.getElementById('projectUsers');
|
||||
usersElement.classList.remove('bg-primary');
|
||||
usersElement.classList.add('bg-success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取项目使用人数失败:', error);
|
||||
document.getElementById('totalUsers').textContent = '网络错误';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动项目使用人数定时刷新
|
||||
*/
|
||||
function startProjectUsersRefresh() {
|
||||
// 立即加载一次
|
||||
loadProjectUsers();
|
||||
|
||||
// 每5分钟刷新一次
|
||||
setInterval(() => {
|
||||
loadProjectUsers();
|
||||
}, 5 * 60 * 1000); // 5分钟 = 5 * 60 * 1000毫秒
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示项目详细统计信息
|
||||
*/
|
||||
async function showProjectStats() {
|
||||
try {
|
||||
const response = await fetch('http://xianyu.zhinianblog.cn/?action=stats');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
showToast('获取统计信息失败: ' + data.error, 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建模态框HTML
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="projectStatsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-bar-chart me-2"></i>项目使用统计
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3 bg-light rounded">
|
||||
<div class="h2 text-primary mb-1">${data.total_users || 0}</div>
|
||||
<div class="text-muted">总用户数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3 bg-light rounded">
|
||||
<div class="h2 text-success mb-1">${data.daily_active_users || 0}</div>
|
||||
<div class="text-muted">今日活跃</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3 bg-light rounded">
|
||||
<div class="h2 text-info mb-1">${Object.keys(data.os_distribution || {}).length}</div>
|
||||
<div class="text-muted">操作系统类型</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center p-3 bg-light rounded">
|
||||
<div class="h2 text-warning mb-1">${Object.keys(data.version_distribution || {}).length}</div>
|
||||
<div class="text-muted">版本类型</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-laptop me-2"></i>操作系统分布</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${Object.entries(data.os_distribution || {}).map(([os, count]) => `
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span>${os}</span>
|
||||
<span class="badge bg-primary">${count}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-tag me-2"></i>版本分布</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${Object.entries(data.version_distribution || {}).map(([version, count]) => `
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span>${version}</span>
|
||||
<span class="badge bg-success">${count}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-muted text-center">
|
||||
<small>最后更新: ${data.last_updated || '未知'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
<button type="button" class="btn btn-primary" onclick="loadProjectUsers()">刷新数据</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 移除已存在的模态框
|
||||
const existingModal = document.getElementById('projectStatsModal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
// 添加新模态框到页面
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// 显示模态框
|
||||
const modal = new bootstrap.Modal(document.getElementById('projectStatsModal'));
|
||||
modal.show();
|
||||
|
||||
// 模态框关闭后移除DOM元素
|
||||
document.getElementById('projectStatsModal').addEventListener('hidden.bs.modal', function () {
|
||||
this.remove();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取项目统计失败:', error);
|
||||
showToast('获取项目统计失败: ' + error.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载系统版本号并检查更新
|
||||
*/
|
||||
|
||||
10
static/update_log.txt
Normal file
10
static/update_log.txt
Normal file
@ -0,0 +1,10 @@
|
||||
'🎉1.新增qq直接回复闲鱼消息,无需在进入闲鱼(注意只能有公网访问的才行);
|
||||
①回复哪条消息引用即可
|
||||
②系统设置维护qq回复秘钥
|
||||
③在qq上给机器人发送 咸鱼绑定 key:url 其中key是你系统设置配置的秘钥,url 是你公网服务器的 ip加端口,例如 zheshimiyao:http://10.12.13.14:8080
|
||||
④如果没有公网,可联系我购买内网穿透服务,2块一个月,物美价廉',
|
||||
'🎉2.支持自动回复暂停时间支持设置为0;',
|
||||
'🎉3.修复自动回复关键词无法修改的问题;',
|
||||
'🎉4.修复订单管理普通用户无法查看的问题;',
|
||||
'🎉5.删除自动发货界面的发货数量,防止误解,想实现多数量发货在商品管理开启;',
|
||||
'🎉6.增加项目使用人数统计;',
|
||||
@ -1 +1 @@
|
||||
v1.0.0
|
||||
v1.0.2
|
||||
|
||||
177
usage_statistics.py
Normal file
177
usage_statistics.py
Normal file
@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
用户使用统计模块
|
||||
只统计有多少人在使用这个系统
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class UsageStatistics:
|
||||
"""用户使用统计收集器 - 只统计用户数量"""
|
||||
|
||||
def __init__(self):
|
||||
# 默认启用统计
|
||||
self.enabled = True
|
||||
self.api_endpoint = "http://xianyu.zhinianblog.cn/?action=statistics" # PHP统计接收端点
|
||||
self.timeout = 5
|
||||
self.retry_count = 1
|
||||
|
||||
# 生成持久化的匿名用户ID
|
||||
self.anonymous_id = self._get_or_create_anonymous_id()
|
||||
|
||||
def _get_or_create_anonymous_id(self) -> str:
|
||||
"""获取或创建持久化的匿名用户ID"""
|
||||
# 保存到数据库中,确保Docker重建时ID不变
|
||||
try:
|
||||
from db_manager import db_manager
|
||||
|
||||
# 尝试从数据库获取ID
|
||||
existing_id = db_manager.get_system_setting('anonymous_user_id')
|
||||
if existing_id and len(existing_id) == 16:
|
||||
return existing_id
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 生成新的匿名ID
|
||||
new_id = self._generate_anonymous_id()
|
||||
|
||||
# 保存到数据库
|
||||
try:
|
||||
from db_manager import db_manager
|
||||
db_manager.set_system_setting('anonymous_user_id', new_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return new_id
|
||||
|
||||
def _generate_anonymous_id(self) -> str:
|
||||
"""生成匿名用户ID(基于机器特征)"""
|
||||
try:
|
||||
# 使用系统信息生成唯一但匿名的ID
|
||||
machine_info = f"{platform.machine()}-{platform.processor()}-{platform.system()}"
|
||||
unique_str = f"{machine_info}-{platform.python_version()}"
|
||||
|
||||
# 生成哈希值作为匿名ID
|
||||
anonymous_id = hashlib.sha256(unique_str.encode()).hexdigest()[:16]
|
||||
return anonymous_id
|
||||
except Exception:
|
||||
# 如果获取系统信息失败,使用随机ID
|
||||
import time
|
||||
return hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
|
||||
|
||||
def _get_basic_info(self) -> Dict[str, Any]:
|
||||
"""获取基本信息(只包含必要信息)"""
|
||||
try:
|
||||
# 从version.txt文件读取版本号
|
||||
version = self._get_version()
|
||||
return {
|
||||
"os": platform.system(),
|
||||
"version": version
|
||||
}
|
||||
except Exception:
|
||||
return {"os": "unknown", "version": "unknown"}
|
||||
|
||||
def _get_version(self) -> str:
|
||||
"""从static/version.txt文件获取版本号"""
|
||||
try:
|
||||
with open("static/version.txt", "r", encoding="utf-8") as f:
|
||||
version = f.read().strip()
|
||||
return version if version else "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
def _prepare_statistics_data(self) -> Dict[str, Any]:
|
||||
"""准备统计数据(只包含用户统计)"""
|
||||
return {
|
||||
"anonymous_id": self.anonymous_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"project": "xianyu-auto-reply",
|
||||
"info": self._get_basic_info()
|
||||
}
|
||||
|
||||
async def _send_statistics(self, data: Dict[str, Any]) -> bool:
|
||||
"""发送统计数据到远程API"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
for attempt in range(self.retry_count):
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'XianyuAutoReply/2.2.0'
|
||||
}
|
||||
|
||||
async with session.post(
|
||||
self.api_endpoint,
|
||||
json=data,
|
||||
headers=headers
|
||||
) as response:
|
||||
if response.status in [200, 201]:
|
||||
logger.debug("统计数据上报成功")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"统计数据上报失败,状态码: {response.status}")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug(f"统计数据上报超时,第{attempt + 1}次尝试")
|
||||
except Exception as e:
|
||||
logger.debug(f"统计数据上报异常: {e}")
|
||||
|
||||
if attempt < self.retry_count - 1:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return False
|
||||
|
||||
async def report_usage(self) -> bool:
|
||||
"""报告用户使用统计"""
|
||||
try:
|
||||
data = self._prepare_statistics_data()
|
||||
return await self._send_statistics(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"报告使用统计失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 全局统计实例
|
||||
usage_stats = UsageStatistics()
|
||||
|
||||
|
||||
async def report_user_count():
|
||||
"""报告用户数量统计"""
|
||||
try:
|
||||
logger.info("正在上报用户统计...")
|
||||
success = await usage_stats.report_usage()
|
||||
if success:
|
||||
logger.info("✅ 用户统计上报成功")
|
||||
else:
|
||||
logger.debug("用户统计上报失败")
|
||||
except Exception as e:
|
||||
logger.debug(f"用户统计异常: {e}")
|
||||
|
||||
|
||||
def get_anonymous_id() -> str:
|
||||
"""获取匿名用户ID"""
|
||||
return usage_stats.anonymous_id
|
||||
|
||||
|
||||
# 测试函数
|
||||
async def test_statistics():
|
||||
"""测试统计功能"""
|
||||
print(f"匿名ID: {get_anonymous_id()}")
|
||||
await report_user_count()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_statistics())
|
||||
Loading…
Reference in New Issue
Block a user