优化代码,新增qq回复咸鱼消息

优化代码,新增qq回复咸鱼消息
This commit is contained in:
zhinianboke 2025-09-05 11:24:16 +08:00 committed by GitHub
commit 90445a4d4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1476 additions and 65 deletions

236
.gitignore vendored
View File

@ -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
View File

@ -9,9 +9,30 @@
https://pan.baidu.com/s/1I6muOGJQYd6y3oxQSmtvrQ?pwd=gcpd
## 📋 项目概述
一个功能完整的闲鱼自动回复和管理系统采用现代化的技术架构支持多用户、多账号管理具备智能回复、自动发货、自动确认发货、商品管理等企业级功能。系统基于Python异步编程使用FastAPI提供RESTful APISQLite数据库存储支持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

View File

@ -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()

View File

@ -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失效的情况

View File

@ -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] # 返回实际值,包括00表示不暂停
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}")

View File

@ -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
View 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)

View File

@ -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">

View File

@ -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
View 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.增加项目使用人数统计;',

View File

@ -1 +1 @@
v1.0.0
v1.0.2

177
usage_statistics.py Normal file
View 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())