From cea4e04bf07458e777924abf93d0c6c7a86df40a Mon Sep 17 00:00:00 2001 From: zhinianboke <115088296+zhinianboke@users.noreply.github.com> Date: Sat, 26 Jul 2025 14:08:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=9A=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ADMIN_FEATURES_SUMMARY.md | 264 ----------- AI_REPLY_GUIDE.md => AI回复指南.md | 0 COMPLETE_ISOLATION_ANALYSIS.md | 254 ----------- DATABASE_BACKUP_SUMMARY.md | 301 ------------- DOCKER_MULTIUSER_UPDATE.md | 235 ---------- ...ER_QUICK_START.md => Docker快速启动指南.md | 2 +- Docker部署说明.md | 375 ---------------- FINAL_ISOLATION_STATUS.md | 217 --------- LICENSE | 21 - LOG_IMPROVEMENT_SUMMARY.md | 196 -------- MULTIUSER_ISOLATION_STATUS.md | 216 --------- MULTIUSER_SYSTEM_README.md | 277 ------------ README.md | 423 ++++++++++++------ REGISTER_PAGE_OPTIMIZATION.md | 233 ---------- SYSTEM_IMPROVEMENTS_SUMMARY.md | 298 ------------ TOKEN_FIX_SUMMARY.md | 192 -------- UI_IMPROVEMENTS.md | 92 ---- USER_LOGGING_IMPROVEMENT.md | 249 ----------- XianyuAutoAsync.py | 148 +++++- backup_import_update_summary.md | 193 -------- bargain_demo.py | 180 -------- cookie_manager.py | 12 +- db_manager.py | 254 +++++++---- demo_captcha_registration.py | 329 -------------- deploy.bat | 298 ------------ deploy.sh | 288 ------------ docker-deploy.bat | 301 ------------- docker_deployment_update.md | 207 --------- fix-db-permissions.bat | 156 ------- fix-db-permissions.sh | 167 ------- fix-docker-warnings.bat | 144 ------ fix-docker-warnings.sh | 145 ------ fix-websocket-issue.sh | 121 ----- fix_api_isolation.py | 334 -------------- fix_complete_isolation.py | 317 ------------- fix_user_isolation.py | 230 ---------- gitignore_rules_explanation.md | 172 ------- migrate_to_multiuser.py | 237 ---------- qq-group.png | Bin 0 -> 146989 bytes quick-fix-permissions.bat | 116 ----- quick-fix-permissions.sh | 88 ---- reply_server.py | 217 ++++++--- simple_log_test.py | 84 ---- static/data_management.html | 5 +- static/index.html | 88 +++- static/log_management.html | 6 + static/login.html | 397 +++++++++++++++- static/register.html | 2 +- static/test_local_resources.html | 75 ---- static/user_management.html | 6 + test_admin_features.py | 345 -------------- test_ai_reply.py | 215 --------- test_ai_reply_fix.py | 166 ------- test_backup_import.py | 282 ------------ test_bargain_limit.py | 267 ----------- test_bargain_limit_direct.py | 183 -------- test_cache_refresh.py | 202 --------- test_captcha.png | Bin 1658 -> 0 bytes test_captcha_system.py | 240 ---------- test_complete_isolation.py | 347 -------------- test_cookie_log_display.py | 166 ------- test_database_backup.py | 254 ----------- test_docker_deployment.py | 331 -------------- test_docker_deployment.sh | 192 -------- test_duplicate_notification_fix.py | 168 ------- test_fix.py | 27 -- test_gitignore.py | 149 ------ test_gitignore_db.py | 178 -------- test_improvements.py | 257 ----------- test_keyword_reply.py | 290 ------------ test_multiuser_system.py | 278 ------------ test_notification_deduplication.py | 183 -------- test_page_access.py | 46 -- test_simple_token_filter.py | 169 ------- test_status_display.html | 250 ----------- test_token_expiry_filter.py | 203 --------- test_token_fix.py | 146 ------ test_user_isolation_complete.py | 333 -------------- test_user_logging.py | 318 ------------- utils/xianyu_utils.py | 35 +- wechat-group.png | Bin 0 -> 170848 bytes 使用说明.md | 178 -------- 商品管理功能说明.md | 199 -------- 日志管理功能说明.md | 296 ------------ 自动发货功能说明.md | 265 ----------- 获取所有商品功能说明.md | 203 --------- 86 files changed, 1252 insertions(+), 15271 deletions(-) delete mode 100644 ADMIN_FEATURES_SUMMARY.md rename AI_REPLY_GUIDE.md => AI回复指南.md (100%) delete mode 100644 COMPLETE_ISOLATION_ANALYSIS.md delete mode 100644 DATABASE_BACKUP_SUMMARY.md delete mode 100644 DOCKER_MULTIUSER_UPDATE.md rename DOCKER_QUICK_START.md => Docker快速启动指南.md (98%) delete mode 100644 Docker部署说明.md delete mode 100644 FINAL_ISOLATION_STATUS.md delete mode 100644 LICENSE delete mode 100644 LOG_IMPROVEMENT_SUMMARY.md delete mode 100644 MULTIUSER_ISOLATION_STATUS.md delete mode 100644 MULTIUSER_SYSTEM_README.md delete mode 100644 REGISTER_PAGE_OPTIMIZATION.md delete mode 100644 SYSTEM_IMPROVEMENTS_SUMMARY.md delete mode 100644 TOKEN_FIX_SUMMARY.md delete mode 100644 UI_IMPROVEMENTS.md delete mode 100644 USER_LOGGING_IMPROVEMENT.md delete mode 100644 backup_import_update_summary.md delete mode 100644 bargain_demo.py delete mode 100644 demo_captcha_registration.py delete mode 100644 deploy.bat delete mode 100644 deploy.sh delete mode 100644 docker-deploy.bat delete mode 100644 docker_deployment_update.md delete mode 100644 fix-db-permissions.bat delete mode 100644 fix-db-permissions.sh delete mode 100644 fix-docker-warnings.bat delete mode 100644 fix-docker-warnings.sh delete mode 100644 fix-websocket-issue.sh delete mode 100644 fix_api_isolation.py delete mode 100644 fix_complete_isolation.py delete mode 100644 fix_user_isolation.py delete mode 100644 gitignore_rules_explanation.md delete mode 100644 migrate_to_multiuser.py create mode 100644 qq-group.png delete mode 100644 quick-fix-permissions.bat delete mode 100644 quick-fix-permissions.sh delete mode 100644 simple_log_test.py delete mode 100644 static/test_local_resources.html delete mode 100644 test_admin_features.py delete mode 100644 test_ai_reply.py delete mode 100644 test_ai_reply_fix.py delete mode 100644 test_backup_import.py delete mode 100644 test_bargain_limit.py delete mode 100644 test_bargain_limit_direct.py delete mode 100644 test_cache_refresh.py delete mode 100644 test_captcha.png delete mode 100644 test_captcha_system.py delete mode 100644 test_complete_isolation.py delete mode 100644 test_cookie_log_display.py delete mode 100644 test_database_backup.py delete mode 100644 test_docker_deployment.py delete mode 100644 test_docker_deployment.sh delete mode 100644 test_duplicate_notification_fix.py delete mode 100644 test_fix.py delete mode 100644 test_gitignore.py delete mode 100644 test_gitignore_db.py delete mode 100644 test_improvements.py delete mode 100644 test_keyword_reply.py delete mode 100644 test_multiuser_system.py delete mode 100644 test_notification_deduplication.py delete mode 100644 test_page_access.py delete mode 100644 test_simple_token_filter.py delete mode 100644 test_status_display.html delete mode 100644 test_token_expiry_filter.py delete mode 100644 test_token_fix.py delete mode 100644 test_user_isolation_complete.py delete mode 100644 test_user_logging.py create mode 100644 wechat-group.png delete mode 100644 使用说明.md delete mode 100644 商品管理功能说明.md delete mode 100644 日志管理功能说明.md delete mode 100644 自动发货功能说明.md delete mode 100644 获取所有商品功能说明.md diff --git a/ADMIN_FEATURES_SUMMARY.md b/ADMIN_FEATURES_SUMMARY.md deleted file mode 100644 index d65223b..0000000 --- a/ADMIN_FEATURES_SUMMARY.md +++ /dev/null @@ -1,264 +0,0 @@ -# 管理员功能总结 - -## 🎯 新增功能概述 - -为闲鱼自动回复系统新增了完整的管理员功能,包括用户管理和日志管理,这些功能只有admin用户可以访问。 - -## 📊 功能详情 - -### 1. 用户管理功能 - -#### 🔗 访问路径 -- **URL**: `/user_management.html` -- **权限**: 仅限admin用户 -- **菜单位置**: 主页侧边栏 → 管理员功能 → 用户管理 - -#### ✨ 主要功能 -1. **用户列表查看** - - 显示所有注册用户信息 - - 用户名、邮箱、注册时间 - - Cookie数量、卡券数量统计 - - 用户类型标识(管理员/普通用户) - -2. **用户删除功能** - - 删除普通用户账号 - - 级联删除用户所有数据: - - 所有Cookie账号 - - 所有卡券 - - 所有关键字和回复设置 - - 所有个人设置 - - 所有相关业务数据 - - 管理员账号保护(不可删除) - -3. **系统统计信息** - - 总用户数 - - 总Cookie数 - - 总卡券数 - - 系统运行状态 - -#### 🛡️ 安全特性 -- **权限验证**: 只有admin用户可以访问 -- **管理员保护**: 不能删除管理员自己 -- **确认机制**: 删除用户需要二次确认 -- **数据完整性**: 级联删除保证数据一致性 - -### 2. 日志管理功能 - -#### 🔗 访问路径 -- **URL**: `/log_management.html` -- **权限**: 仅限admin用户 -- **菜单位置**: 主页侧边栏 → 管理员功能 → 系统日志 - -#### ✨ 主要功能 -1. **日志查看** - - 实时查看系统运行日志 - - 支持50-1000行日志显示 - - 彩色日志级别显示 - - 用户操作追踪 - -2. **日志过滤** - - 按日志级别过滤(INFO、WARNING、ERROR、DEBUG) - - 快速切换过滤条件 - - 实时过滤结果 - -3. **自动刷新** - - 可开启5秒自动刷新 - - 实时监控系统状态 - - 自动滚动到最新日志 - -4. **日志导航** - - 快速跳转到顶部/底部 - - 日志文件信息显示 - - 最后更新时间显示 - -#### 🎨 界面特性 -- **终端风格**: 黑色背景,彩色文字 -- **级别颜色**: 不同日志级别不同颜色 -- **响应式设计**: 适配各种屏幕尺寸 -- **用户友好**: 直观的操作界面 - -## 🔧 技术实现 - -### 后端API接口 - -#### 用户管理接口 -```python -# 获取所有用户 -GET /admin/users -# 删除用户 -DELETE /admin/users/{user_id} -# 获取系统统计 -GET /admin/stats -``` - -#### 日志管理接口 -```python -# 获取系统日志 -GET /admin/logs?lines=100&level=info -``` - -#### 权限验证 -```python -def require_admin(current_user: Dict[str, Any] = Depends(get_current_user)): - """要求管理员权限""" - if current_user['username'] != 'admin': - raise HTTPException(status_code=403, detail="需要管理员权限") - return current_user -``` - -### 数据库支持 - -#### 新增方法 -```python -# 获取所有用户信息 -def get_all_users(self) - -# 根据ID获取用户 -def get_user_by_id(self, user_id: int) - -# 删除用户及所有相关数据 -def delete_user_and_data(self, user_id: int) -``` - -#### 级联删除逻辑 -1. 用户设置 (user_settings) -2. 卡券 (cards) -3. 发货规则 (delivery_rules) -4. 通知渠道 (notification_channels) -5. Cookie (cookies) -6. 关键字 (keywords) -7. 默认回复 (default_replies) -8. AI回复设置 (ai_reply_settings) -9. 消息通知 (message_notifications) -10. 用户本身 (users) - -### 前端实现 - -#### 权限检查 -```javascript -// 验证管理员权限 -function checkAdminPermission() { - // 检查token有效性 - // 验证用户名是否为admin - // 非管理员自动跳转 -} -``` - -#### 菜单显示控制 -```javascript -// 在主页面中动态显示管理员菜单 -if (result.username === 'admin') { - document.getElementById('adminMenuSection').style.display = 'block'; -} -``` - -## 📱 用户界面 - -### 用户管理页面 -- **现代化设计**: Bootstrap 5 + 渐变色彩 -- **卡片布局**: 每个用户一个卡片 -- **统计面板**: 顶部显示系统统计 -- **操作确认**: 删除操作需要确认 -- **响应式**: 适配手机和桌面 - -### 日志管理页面 -- **终端风格**: 模拟命令行界面 -- **实时更新**: 支持自动刷新 -- **过滤控制**: 直观的过滤按钮 -- **导航便利**: 快速跳转功能 - -## 🔒 安全机制 - -### 1. 权限验证 -- **后端验证**: 所有管理员接口都需要admin权限 -- **前端检查**: 页面加载时验证用户身份 -- **自动跳转**: 非管理员用户自动跳转到首页 - -### 2. 操作保护 -- **管理员保护**: 不能删除管理员自己 -- **确认机制**: 危险操作需要二次确认 -- **错误处理**: 完善的错误提示和处理 - -### 3. 数据安全 -- **事务处理**: 删除操作使用数据库事务 -- **级联删除**: 确保数据完整性 -- **日志记录**: 所有操作都有详细日志 - -## 📋 使用说明 - -### 1. 访问管理员功能 -1. 使用admin账号登录系统 -2. 在主页侧边栏查看"管理员功能"菜单 -3. 点击相应功能进入管理页面 - -### 2. 用户管理操作 -1. 查看用户列表和统计信息 -2. 点击"删除用户"按钮 -3. 在确认对话框中确认删除 -4. 系统自动删除用户及所有相关数据 - -### 3. 日志管理操作 -1. 选择显示行数(50-1000行) -2. 选择日志级别过滤 -3. 开启/关闭自动刷新 -4. 使用导航按钮快速跳转 - -## 🎯 应用场景 - -### 1. 用户管理 -- **清理无效用户**: 删除不活跃或测试用户 -- **用户统计分析**: 查看用户活跃度和资源使用 -- **系统维护**: 定期清理和优化用户数据 - -### 2. 日志监控 -- **故障排查**: 实时查看错误日志 -- **性能监控**: 监控系统运行状态 -- **用户行为分析**: 追踪用户操作记录 -- **安全审计**: 监控异常访问和操作 - -## 🚀 部署说明 - -### 1. 立即可用 -- 重启服务后功能立即生效 -- 无需额外配置 -- 兼容现有数据 - -### 2. 访问方式 -``` -用户管理: http://your-domain/user_management.html -日志管理: http://your-domain/log_management.html -``` - -### 3. 权限要求 -- 只有username为'admin'的用户可以访问 -- 其他用户访问会自动跳转到首页 - -## 📊 功能对比 - -| 功能 | 普通用户 | 管理员 | -|------|----------|--------| -| 查看自己的数据 | ✅ | ✅ | -| 管理自己的设置 | ✅ | ✅ | -| 查看所有用户 | ❌ | ✅ | -| 删除其他用户 | ❌ | ✅ | -| 查看系统日志 | ❌ | ✅ | -| 系统统计信息 | ❌ | ✅ | - -## 🎉 总结 - -通过本次更新,闲鱼自动回复系统现在具备了完整的管理员功能: - -### ✅ 主要成就 -1. **完整的用户管理**: 查看、删除用户及数据统计 -2. **强大的日志管理**: 实时查看、过滤、监控系统日志 -3. **严格的权限控制**: 只有admin用户可以访问 -4. **现代化界面**: 美观、易用的管理界面 -5. **安全的操作机制**: 完善的确认和保护机制 - -### 🎯 实用价值 -- **提升管理效率**: 集中化的用户和日志管理 -- **增强系统安全**: 严格的权限控制和操作保护 -- **便于故障排查**: 实时日志监控和过滤功能 -- **优化用户体验**: 直观的界面和操作流程 - -现在您的多用户闲鱼自动回复系统具备了企业级的管理功能!🎊 diff --git a/AI_REPLY_GUIDE.md b/AI回复指南.md similarity index 100% rename from AI_REPLY_GUIDE.md rename to AI回复指南.md diff --git a/COMPLETE_ISOLATION_ANALYSIS.md b/COMPLETE_ISOLATION_ANALYSIS.md deleted file mode 100644 index 3d7c6b0..0000000 --- a/COMPLETE_ISOLATION_ANALYSIS.md +++ /dev/null @@ -1,254 +0,0 @@ -# 多用户数据隔离完整分析报告 - -## 🚨 发现的问题 - -经过全面检查,发现以下模块**缺乏用户隔离**: - -### ❌ 完全未隔离的模块 - -#### 1. 卡券管理 -- **数据库表**: `cards` 表没有 `user_id` 字段 -- **API接口**: 所有卡券接口都是全局共享 -- **影响**: 所有用户共享同一套卡券库 - -#### 2. 自动发货规则 -- **数据库表**: `delivery_rules` 表没有 `user_id` 字段 -- **API接口**: 所有发货规则接口都是全局共享 -- **影响**: 所有用户共享同一套发货规则 - -#### 3. 通知渠道 -- **数据库表**: `notification_channels` 表没有 `user_id` 字段 -- **API接口**: 所有通知渠道接口都是全局共享 -- **影响**: 所有用户共享同一套通知渠道 - -#### 4. 系统设置 -- **数据库表**: `system_settings` 表没有用户区分 -- **API接口**: 系统设置接口是全局的 -- **影响**: 所有用户共享系统设置(包括主题颜色等) - -### ⚠️ 部分隔离的模块 - -#### 5. 商品管理 -- **已隔离**: 主要CRUD接口 -- **未隔离**: 批量操作接口 -- **影响**: 部分功能存在数据泄露风险 - -#### 6. 消息通知 -- **已隔离**: 主要配置接口 -- **未隔离**: 删除操作接口 -- **影响**: 删除操作可能影响其他用户 - -## 📊 详细分析 - -### 1. 卡券管理模块 - -#### 当前状态 -```sql --- 当前表结构(无用户隔离) -CREATE TABLE cards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL, - api_config TEXT, - text_content TEXT, - data_content TEXT, - description TEXT, - enabled BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - -- 缺少 user_id 字段! -); -``` - -#### 未隔离的接口 -- `GET /cards` - 返回所有卡券 -- `POST /cards` - 创建卡券(未绑定用户) -- `GET /cards/{card_id}` - 获取卡券详情 -- `PUT /cards/{card_id}` - 更新卡券 -- `DELETE /cards/{card_id}` - 删除卡券 - -#### 安全风险 -- 用户A可以看到用户B创建的卡券 -- 用户A可以修改/删除用户B的卡券 -- 卡券数据完全暴露 - -### 2. 自动发货规则模块 - -#### 当前状态 -```sql --- 当前表结构(无用户隔离) -CREATE TABLE delivery_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - keyword TEXT NOT NULL, - card_id INTEGER NOT NULL, - delivery_count INTEGER DEFAULT 1, - enabled BOOLEAN DEFAULT TRUE, - description TEXT, - delivery_times INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (card_id) REFERENCES cards(id) - -- 缺少 user_id 字段! -); -``` - -#### 未隔离的接口 -- `GET /delivery-rules` - 返回所有发货规则 -- `POST /delivery-rules` - 创建发货规则(未绑定用户) -- `GET /delivery-rules/{rule_id}` - 获取规则详情 -- `PUT /delivery-rules/{rule_id}` - 更新规则 -- `DELETE /delivery-rules/{rule_id}` - 删除规则 - -#### 安全风险 -- 用户A可以看到用户B的发货规则 -- 用户A可以修改用户B的发货配置 -- 可能导致错误的自动发货 - -### 3. 通知渠道模块 - -#### 当前状态 -```sql --- 当前表结构(无用户隔离) -CREATE TABLE notification_channels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - type TEXT NOT NULL, - config TEXT NOT NULL, - enabled BOOLEAN DEFAULT TRUE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - -- 缺少 user_id 字段! -); -``` - -#### 未隔离的接口 -- `GET /notification-channels` - 返回所有通知渠道 -- `POST /notification-channels` - 创建通知渠道 -- `GET /notification-channels/{channel_id}` - 获取渠道详情 -- `PUT /notification-channels/{channel_id}` - 更新渠道 -- `DELETE /notification-channels/{channel_id}` - 删除渠道 - -#### 安全风险 -- 用户A可以看到用户B的通知配置 -- 用户A可以修改用户B的通知渠道 -- 通知可能发送到错误的接收者 - -### 4. 系统设置模块 - -#### 当前状态 -```sql --- 当前表结构(全局设置) -CREATE TABLE system_settings ( - key TEXT PRIMARY KEY, - value TEXT, - description TEXT, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - -- 没有用户区分! -); -``` - -#### 未隔离的接口 -- `GET /system-settings` - 获取系统设置 -- `PUT /system-settings/password` - 更新管理员密码 -- `PUT /system-settings/{key}` - 更新系统设置 - -#### 安全风险 -- 所有用户共享系统设置 -- 主题颜色等个人偏好无法独立设置 -- 可能存在权限提升风险 - -## 🔧 修复方案 - -### 方案A: 完全用户隔离(推荐) - -#### 1. 数据库结构修改 -```sql --- 为所有表添加 user_id 字段 -ALTER TABLE cards ADD COLUMN user_id INTEGER REFERENCES users(id); -ALTER TABLE delivery_rules ADD COLUMN user_id INTEGER REFERENCES users(id); -ALTER TABLE notification_channels ADD COLUMN user_id INTEGER REFERENCES users(id); - --- 创建用户设置表 -CREATE TABLE user_settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - key TEXT NOT NULL, - value TEXT, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, key) -); -``` - -#### 2. API接口修改 -- 所有接口添加用户权限验证 -- 数据查询添加用户过滤条件 -- 创建操作自动绑定当前用户 - -#### 3. 数据迁移 -- 将现有数据绑定到admin用户 -- 提供数据迁移脚本 - -### 方案B: 混合隔离策略 - -#### 1. 用户隔离模块 -- **卡券管理**: 完全用户隔离 -- **自动发货规则**: 完全用户隔离 - -#### 2. 全局共享模块 -- **通知渠道**: 保持全局共享(管理员配置) -- **系统设置**: 区分全局设置和用户设置 - -## 🚀 实施计划 - -### 阶段1: 数据库结构升级 -1. 创建数据库迁移脚本 -2. 添加用户隔离字段 -3. 创建用户设置表 -4. 数据迁移和验证 - -### 阶段2: API接口修复 -1. 修复卡券管理接口 -2. 修复自动发货规则接口 -3. 修复通知渠道接口(如选择隔离) -4. 创建用户设置接口 - -### 阶段3: 测试和验证 -1. 单元测试 -2. 集成测试 -3. 安全测试 -4. 性能测试 - -### 阶段4: 文档和部署 -1. 更新API文档 -2. 更新用户手册 -3. 部署和监控 - -## 📋 优先级建议 - -### 高优先级(安全风险) -1. **卡券管理** - 直接影响业务数据 -2. **自动发货规则** - 可能导致错误发货 - -### 中优先级(功能完整性) -3. **通知渠道** - 影响通知准确性 -4. **用户设置** - 影响用户体验 - -### 低优先级(系统管理) -5. **系统设置** - 主要影响管理功能 - -## 🎯 建议采用方案A - -**理由**: -1. **安全性最高** - 完全的数据隔离 -2. **一致性最好** - 所有模块统一的隔离策略 -3. **扩展性最强** - 便于后续功能扩展 -4. **维护性最好** - 统一的权限管理模式 - -**实施成本**: -- 数据库迁移:中等 -- 代码修改:中等 -- 测试验证:高 -- 总体可控 diff --git a/DATABASE_BACKUP_SUMMARY.md b/DATABASE_BACKUP_SUMMARY.md deleted file mode 100644 index 0de6ff7..0000000 --- a/DATABASE_BACKUP_SUMMARY.md +++ /dev/null @@ -1,301 +0,0 @@ -# 数据库备份和恢复功能总结 - -## 🎯 功能概述 - -为闲鱼自动回复系统添加了直接的数据库文件备份和恢复功能,支持一键下载完整数据库文件和直接上传替换数据库,实现最简单有效的备份方案。 - -## ✨ 主要特性 - -### 🔽 数据库备份下载 -- **一键下载**:直接下载完整的SQLite数据库文件 -- **自动命名**:备份文件自动添加时间戳 -- **完整备份**:包含所有用户数据、设置、Cookie、卡券等 -- **快速简单**:无需复杂的导出过程 - -### 🔼 数据库恢复上传 -- **直接替换**:上传数据库文件直接替换当前数据库 -- **自动验证**:验证文件格式和完整性 -- **安全备份**:替换前自动备份当前数据库 -- **自动重载**:替换后自动重新初始化数据库连接 - -### 🛡️ 安全机制 -- **权限控制**:只有admin用户可以访问 -- **文件验证**:严格验证上传文件的格式和完整性 -- **大小限制**:限制上传文件大小(100MB) -- **回滚机制**:失败时自动恢复原数据库 - -## 🔧 技术实现 - -### 后端API接口 - -#### 1. 数据库下载接口 -```python -@app.get('/admin/backup/download') -def download_database_backup(admin_user: Dict[str, Any] = Depends(require_admin)): - """下载数据库备份文件(管理员专用)""" -``` - -**功能**: -- 检查数据库文件存在性 -- 生成带时间戳的文件名 -- 返回FileResponse供下载 - -#### 2. 数据库上传接口 -```python -@app.post('/admin/backup/upload') -async def upload_database_backup(admin_user: Dict[str, Any] = Depends(require_admin), - backup_file: UploadFile = File(...)): - """上传并恢复数据库备份文件(管理员专用)""" -``` - -**功能**: -- 验证文件类型和大小 -- 验证SQLite数据库完整性 -- 备份当前数据库 -- 替换数据库文件 -- 重新初始化数据库连接 - -#### 3. 备份文件列表接口 -```python -@app.get('/admin/backup/list') -def list_backup_files(admin_user: Dict[str, Any] = Depends(require_admin)): - """列出服务器上的备份文件(管理员专用)""" -``` - -**功能**: -- 扫描服务器上的备份文件 -- 返回文件信息(大小、创建时间等) - -### 前端界面 - -#### 1. 系统设置页面集成 -位置:主页 → 系统设置 → 备份管理 - -#### 2. 数据库备份区域 -```html - -
-
- 数据库备份 -
- -
- - -
-
- 数据库恢复 -
- - -
-``` - -#### 3. JavaScript函数 - -**下载数据库备份**: -```javascript -async function downloadDatabaseBackup() { - // 调用API下载数据库文件 - // 自动触发浏览器下载 -} -``` - -**上传数据库备份**: -```javascript -async function uploadDatabaseBackup() { - // 验证文件选择和格式 - // 确认操作风险 - // 上传文件并处理结果 -} -``` - -## 🎨 用户界面 - -### 备份管理布局 -``` -┌─────────────────────────────────────────────────────────────┐ -│ 备份管理 │ -├─────────────────────────┬───────────────────────────────────┤ -│ 数据库备份 │ 数据库恢复 │ -│ │ │ -│ 直接下载完整的数据库文件 │ 上传数据库文件直接替换当前数据库 │ -│ │ │ -│ [下载数据库] │ [选择文件] [恢复数据库] │ -│ │ │ -│ 推荐方式:完整备份,恢复简单│ 警告:将覆盖所有当前数据! │ -└─────────────────────────┴───────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────┐ -│ JSON格式备份(兼容模式) │ -├─────────────────────────┬───────────────────────────────────┤ -│ 导出JSON格式备份 │ 导入JSON格式备份 │ -│ │ │ -│ [导出JSON备份] │ [选择文件] [导入JSON备份] │ -└─────────────────────────┴───────────────────────────────────┘ -``` - -## 🔄 备份流程 - -### 备份流程 -``` -用户点击"下载数据库" - ↓ -前端调用 /admin/backup/download - ↓ -后端检查权限和文件存在性 - ↓ -生成带时间戳的文件名 - ↓ -返回FileResponse - ↓ -浏览器自动下载文件 - ↓ -备份完成 -``` - -### 恢复流程 -``` -用户选择.db文件 - ↓ -用户点击"恢复数据库" - ↓ -前端验证文件格式和大小 - ↓ -用户确认操作风险 - ↓ -前端上传文件到 /admin/backup/upload - ↓ -后端验证文件完整性 - ↓ -备份当前数据库 - ↓ -关闭当前数据库连接 - ↓ -替换数据库文件 - ↓ -重新初始化数据库连接 - ↓ -验证新数据库 - ↓ -返回恢复结果 - ↓ -前端提示刷新页面 - ↓ -恢复完成 -``` - -## 🛡️ 安全特性 - -### 1. 权限验证 -- 所有备份API都需要admin权限 -- 前端页面自动检查用户身份 -- 非管理员无法访问备份功能 - -### 2. 文件验证 -- 严格验证文件扩展名(.db) -- 验证SQLite数据库格式 -- 检查必要的数据表存在性 -- 限制文件大小(100MB) - -### 3. 操作保护 -- 恢复前自动备份当前数据库 -- 失败时自动回滚到原数据库 -- 用户确认机制防止误操作 -- 详细的操作日志记录 - -### 4. 错误处理 -- 完善的异常捕获和处理 -- 清晰的错误信息提示 -- 自动清理临时文件 -- 数据库连接状态管理 - -## 💡 使用方法 - -### 备份数据库 -1. 使用admin账号登录系统 -2. 进入"系统设置"页面 -3. 在"备份管理"区域点击"下载数据库" -4. 浏览器会自动下载数据库文件 - -### 恢复数据库 -1. 在"备份管理"区域点击"选择文件" -2. 选择之前下载的.db文件 -3. 点击"恢复数据库"按钮 -4. 确认操作风险 -5. 等待恢复完成 -6. 刷新页面加载新数据 - -## 📊 优势对比 - -| 特性 | 数据库文件备份 | JSON格式备份 | -|------|---------------|-------------| -| 备份速度 | ⚡ 极快 | 🐌 较慢 | -| 文件大小 | 📦 最小 | 📦 较大 | -| 恢复速度 | ⚡ 极快 | 🐌 较慢 | -| 数据完整性 | ✅ 100% | ✅ 99% | -| 操作复杂度 | 🟢 简单 | 🟡 中等 | -| 兼容性 | 🟢 原生 | 🟡 需要处理 | -| 推荐程度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | - -## 🎯 应用场景 - -### 1. 日常备份 -- 定期下载数据库文件作为备份 -- 简单快速,无需复杂操作 -- 适合自动化脚本调用 - -### 2. 系统迁移 -- 从一个服务器迁移到另一个服务器 -- 直接复制数据库文件即可 -- 保持数据完整性 - -### 3. 版本回滚 -- 升级前备份数据库 -- 出现问题时快速回滚 -- 最小化停机时间 - -### 4. 数据同步 -- 在多个环境间同步数据 -- 开发、测试、生产环境数据一致 -- 便于问题复现和调试 - -## 🚀 部署说明 - -### 立即可用 -- 重启服务后功能立即生效 -- 无需额外配置 -- 兼容现有数据 - -### 文件权限 -确保服务器有足够的文件读写权限: -```bash -# 确保数据库文件可读写 -chmod 644 xianyu_data.db - -# 确保目录可写(用于备份文件) -chmod 755 . -``` - -### 磁盘空间 -- 备份文件会占用额外磁盘空间 -- 建议定期清理旧的备份文件 -- 监控磁盘使用情况 - -## 🎉 总结 - -数据库备份和恢复功能为闲鱼自动回复系统提供了: - -### ✅ 核心价值 -- **简单高效**:一键备份和恢复,操作简单 -- **完整可靠**:100%数据完整性保证 -- **安全稳定**:完善的验证和保护机制 -- **快速便捷**:最快的备份和恢复速度 - -### 🎯 实用性 -- **日常维护**:定期备份保障数据安全 -- **系统迁移**:轻松迁移到新服务器 -- **问题恢复**:快速回滚到正常状态 -- **开发测试**:便于环境数据同步 - -现在您的多用户闲鱼自动回复系统具备了企业级的数据备份和恢复能力!🎊 diff --git a/DOCKER_MULTIUSER_UPDATE.md b/DOCKER_MULTIUSER_UPDATE.md deleted file mode 100644 index 331887c..0000000 --- a/DOCKER_MULTIUSER_UPDATE.md +++ /dev/null @@ -1,235 +0,0 @@ -# Docker多用户系统部署更新 - -## 🎯 更新概述 - -为支持多用户系统和图形验证码功能,Docker部署配置已更新。 - -## 📦 新增依赖 - -### Python依赖 -- **Pillow>=10.0.0** - 图像处理库,用于生成图形验证码 - -### 系统依赖 -- **libjpeg-dev** - JPEG图像支持 -- **libpng-dev** - PNG图像支持 -- **libfreetype6-dev** - 字体渲染支持 -- **fonts-dejavu-core** - 默认字体包 - -## 🔧 配置文件更新 - -### 1. requirements.txt -```diff -# AI回复相关 -openai>=1.65.5 -python-dotenv>=1.0.1 - -+ # 图像处理(图形验证码) -+ Pillow>=10.0.0 -``` - -### 2. Dockerfile -```diff -# 安装系统依赖 -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - nodejs \ - npm \ - tzdata \ - curl \ -+ libjpeg-dev \ -+ libpng-dev \ -+ libfreetype6-dev \ -+ fonts-dejavu-core \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* -``` - -### 3. docker-compose.yml -```diff -environment: - - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - - JWT_SECRET_KEY=${JWT_SECRET_KEY:-default-secret-key} - - SESSION_TIMEOUT=${SESSION_TIMEOUT:-3600} -+ # 多用户系统配置 -+ - MULTIUSER_ENABLED=${MULTIUSER_ENABLED:-true} -+ - USER_REGISTRATION_ENABLED=${USER_REGISTRATION_ENABLED:-true} -+ - EMAIL_VERIFICATION_ENABLED=${EMAIL_VERIFICATION_ENABLED:-true} -+ - CAPTCHA_ENABLED=${CAPTCHA_ENABLED:-true} -+ - TOKEN_EXPIRE_TIME=${TOKEN_EXPIRE_TIME:-86400} -``` - -## 🚀 部署步骤 - -### 1. 更新代码 -```bash -# 拉取最新代码 -git pull origin main - -# 检查更新的文件 -git status -``` - -### 2. 重新构建镜像 -```bash -# 停止现有容器 -docker-compose down - -# 重新构建镜像(包含新依赖) -docker-compose build --no-cache - -# 启动服务 -docker-compose up -d -``` - -### 3. 验证部署 -```bash -# 检查容器状态 -docker-compose ps - -# 查看日志 -docker-compose logs -f xianyu-app - -# 健康检查 -curl http://localhost:8080/health -``` - -## 🧪 功能测试 - -### 1. 访问注册页面 -```bash -# 打开浏览器访问 -http://localhost:8080/register.html -``` - -### 2. 测试图形验证码 -- 页面应该自动显示图形验证码 -- 点击图片可以刷新验证码 -- 输入4位验证码应该能够验证 - -### 3. 测试用户注册 -- 输入用户名和邮箱 -- 验证图形验证码 -- 发送邮箱验证码 -- 完成注册流程 - -### 4. 测试数据隔离 -- 注册多个用户 -- 分别登录添加不同的Cookie -- 验证用户只能看到自己的数据 - -## 🔍 故障排除 - -### 1. 图形验证码不显示 -```bash -# 检查Pillow是否正确安装 -docker-compose exec xianyu-app python -c "from PIL import Image; print('Pillow OK')" - -# 检查字体是否可用 -docker-compose exec xianyu-app ls /usr/share/fonts/ -``` - -### 2. 容器启动失败 -```bash -# 查看详细错误日志 -docker-compose logs xianyu-app - -# 检查依赖安装 -docker-compose exec xianyu-app pip list | grep -i pillow -``` - -### 3. 权限问题 -```bash -# 检查数据目录权限 -ls -la ./data/ -ls -la ./logs/ - -# 修复权限(如需要) -sudo chown -R 1000:1000 ./data ./logs -``` - -## 📊 资源使用 - -### 更新后的资源需求 -- **内存**: 512MB → 768MB(推荐) -- **磁盘**: 1GB → 1.5GB(推荐) -- **CPU**: 0.5核 → 0.5核(无变化) - -### 调整资源限制 -```yaml -# docker-compose.yml -deploy: - resources: - limits: - memory: 768M # 增加内存限制 - cpus: '0.5' - reservations: - memory: 384M # 增加内存预留 - cpus: '0.25' -``` - -## 🔐 安全配置 - -### 1. 环境变量安全 -```bash -# 创建 .env 文件 -cat > .env << EOF -# 修改默认密码 -ADMIN_PASSWORD=your-secure-password - -# 使用强JWT密钥 -JWT_SECRET_KEY=your-very-long-and-random-secret-key - -# 配置多用户功能 -MULTIUSER_ENABLED=true -USER_REGISTRATION_ENABLED=true -EMAIL_VERIFICATION_ENABLED=true -CAPTCHA_ENABLED=true -EOF -``` - -### 2. 网络安全 -```bash -# 如果不需要外部访问注册功能,可以禁用 -USER_REGISTRATION_ENABLED=false - -# 或者使用Nginx进行访问控制 -# 参考 nginx/nginx.conf 配置 -``` - -## 📋 迁移检查清单 - -- [ ] 更新 requirements.txt -- [ ] 更新 Dockerfile -- [ ] 更新 docker-compose.yml -- [ ] 重新构建镜像 -- [ ] 测试图形验证码功能 -- [ ] 测试用户注册流程 -- [ ] 验证数据隔离 -- [ ] 检查资源使用 -- [ ] 更新监控配置 - -## 🎉 升级完成 - -升级完成后,您的系统将支持: - -1. **多用户注册和登录** -2. **图形验证码保护** -3. **邮箱验证码验证** -4. **完整的数据隔离** -5. **企业级安全保护** - -现在可以安全地支持多个用户同时使用系统,每个用户的数据完全独立! - -## 📞 技术支持 - -如果在部署过程中遇到问题: - -1. 查看容器日志:`docker-compose logs -f` -2. 检查健康状态:`docker-compose ps` -3. 验证网络连接:`curl http://localhost:8080/health` -4. 测试功能:访问 `http://localhost:8080/register.html` - ---- - -**注意**: 首次部署多用户系统后,建议运行数据迁移脚本将历史数据绑定到admin用户。 diff --git a/DOCKER_QUICK_START.md b/Docker快速启动指南.md similarity index 98% rename from DOCKER_QUICK_START.md rename to Docker快速启动指南.md index c6a8181..cc02de8 100644 --- a/DOCKER_QUICK_START.md +++ b/Docker快速启动指南.md @@ -4,7 +4,7 @@ ### 1. 克隆项目 ```bash -git clone +git clone https://github.com/zhinianboke/xianyu-auto-reply.git cd xianyu-auto-reply ``` diff --git a/Docker部署说明.md b/Docker部署说明.md deleted file mode 100644 index 29feda9..0000000 --- a/Docker部署说明.md +++ /dev/null @@ -1,375 +0,0 @@ -# 🐳 Docker 部署说明 - -## 📋 部署概述 - -本项目支持完整的Docker容器化部署,包含所有必要的依赖和配置。支持单容器部署和多容器编排部署。 - -## 🆕 多用户系统支持 - -系统现已支持多用户功能: -- **用户注册**: 支持邮箱验证码注册 -- **图形验证码**: 防止自动化注册 -- **数据隔离**: 每个用户的数据完全独立 -- **权限管理**: 严格的用户权限控制 -- **安全认证**: JWT Token + 图形验证码双重保护 - -### 新增依赖 -- **Pillow**: 用于生成图形验证码 -- **系统字体**: 支持验证码文字渲染 - -## 🚀 快速开始 - -### 方式一:使用 Docker Compose(推荐) - -1. **克隆项目** - ```bash - git clone - cd xianyuapis - ``` - -2. **配置环境变量** - ```bash - # 复制环境变量模板 - cp .env.example .env - - # 编辑配置文件(可选) - nano .env - ``` - -3. **启动服务** - ```bash - # 启动基础服务 - docker-compose up -d - - # 或者启动包含Nginx的完整服务 - docker-compose --profile with-nginx up -d - ``` - -4. **访问系统** - - 基础部署:http://localhost:8080 - - 带Nginx:http://localhost - -### 方式二:使用 Docker 命令 - -1. **构建镜像** - ```bash - docker build -t xianyu-auto-reply:latest . - ``` - -2. **运行容器** - ```bash - docker run -d \ - --name xianyu-auto-reply \ - -p 8080:8080 \ - -v $(pwd)/data:/app/data \ - -v $(pwd)/logs:/app/logs \ - -v $(pwd)/global_config.yml:/app/global_config.yml:ro \ - -e ADMIN_USERNAME=admin \ - -e ADMIN_PASSWORD=admin123 \ - xianyu-auto-reply:latest - ``` - -## 📦 依赖说明 - -### 新增依赖 -- `python-multipart>=0.0.6` - 文件上传支持(商品管理功能需要) - -### 完整依赖列表 -``` -# Web框架和API相关 -fastapi>=0.111 -uvicorn[standard]>=0.29 -pydantic>=2.7 -python-multipart>=0.0.6 - -# 日志记录 -loguru>=0.7 - -# 网络通信 -websockets>=10.0,<13.0 -aiohttp>=3.9 - -# 配置文件处理 -PyYAML>=6.0 - -# JavaScript执行引擎 -PyExecJS>=1.5.1 - -# 协议缓冲区解析 -blackboxprotobuf>=1.0.1 - -# 系统监控 -psutil>=5.9.0 - -# HTTP客户端(用于测试) -requests>=2.31.0 -``` - -## 🔧 配置说明 - -### 环境变量配置 - -#### 基础配置 -```bash -# 时区设置 -TZ=Asia/Shanghai - -# 服务端口 -WEB_PORT=8080 - -# 管理员账号(建议修改) -ADMIN_USERNAME=admin -ADMIN_PASSWORD=admin123 - -# JWT密钥(建议修改) -JWT_SECRET_KEY=your-secret-key-here -``` - -#### 多用户系统配置 -```bash -# 多用户功能开关 -MULTIUSER_ENABLED=true - -# 用户注册开关 -USER_REGISTRATION_ENABLED=true - -# 邮箱验证开关 -EMAIL_VERIFICATION_ENABLED=true - -# 图形验证码开关 -CAPTCHA_ENABLED=true - -# Token过期时间(秒,默认24小时) -TOKEN_EXPIRE_TIME=86400 -``` - -#### 功能配置 -```bash -# 自动回复 -AUTO_REPLY_ENABLED=true - -# 自动发货 -AUTO_DELIVERY_ENABLED=true -AUTO_DELIVERY_TIMEOUT=30 - -# 商品管理(新功能) -ENABLE_ITEM_MANAGEMENT=true -``` - -### 数据持久化 - -#### 重要目录 -- `/app/data` - 数据库文件 -- `/app/logs` - 日志文件 -- `/app/backups` - 备份文件 - -#### 挂载配置 -```yaml -volumes: - - ./data:/app/data:rw # 数据库持久化 - - ./logs:/app/logs:rw # 日志持久化 - - ./backups:/app/backups:rw # 备份持久化 - - ./global_config.yml:/app/global_config.yml:ro # 配置文件 -``` - -## 🏗️ 架构说明 - -### 容器架构 -``` -┌─────────────────────────────────────┐ -│ Nginx (可选) │ -│ 反向代理 + SSL │ -└─────────────┬───────────────────────┘ - │ -┌─────────────▼───────────────────────┐ -│ Xianyu App Container │ -│ ┌─────────────────────────────────┐ │ -│ │ FastAPI Server │ │ -│ │ (Port 8080) │ │ -│ └─────────────────────────────────┘ │ -│ ┌─────────────────────────────────┐ │ -│ │ XianyuAutoAsync │ │ -│ │ (WebSocket Client) │ │ -│ └─────────────────────────────────┘ │ -│ ┌─────────────────────────────────┐ │ -│ │ SQLite Database │ │ -│ │ (商品信息 + 配置) │ │ -│ └─────────────────────────────────┘ │ -└─────────────────────────────────────┘ -``` - -### 新功能支持 -- ✅ 商品信息管理 -- ✅ 商品详情编辑 -- ✅ 文件上传功能 -- ✅ 消息通知格式化 - -## 🔍 健康检查 - -### 内置健康检查 -```bash -# 检查容器状态 -docker ps - -# 查看健康状态 -docker inspect xianyu-auto-reply | grep Health -A 10 - -# 手动健康检查 -curl -f http://localhost:8080/health -``` - -### 健康检查端点 -- `GET /health` - 基础健康检查 -- `GET /api/status` - 详细状态信息 - -## 📊 监控和日志 - -### 日志查看 -```bash -# 查看容器日志 -docker logs xianyu-auto-reply - -# 实时查看日志 -docker logs -f xianyu-auto-reply - -# 查看应用日志文件 -docker exec xianyu-auto-reply tail -f /app/logs/xianyu_$(date +%Y%m%d).log -``` - -### 性能监控 -```bash -# 查看资源使用 -docker stats xianyu-auto-reply - -# 进入容器 -docker exec -it xianyu-auto-reply bash - -# 查看进程状态 -docker exec xianyu-auto-reply ps aux -``` - -## 🔒 安全配置 - -### 生产环境建议 -1. **修改默认密码** - ```bash - ADMIN_USERNAME=your-admin - ADMIN_PASSWORD=your-strong-password - ``` - -2. **使用强JWT密钥** - ```bash - JWT_SECRET_KEY=$(openssl rand -base64 32) - ``` - -3. **启用HTTPS** - ```yaml - # 使用Nginx配置SSL - docker-compose --profile with-nginx up -d - ``` - -4. **限制网络访问** - ```yaml - # 仅允许本地访问 - ports: - - "127.0.0.1:8080:8080" - ``` - -## 🚨 故障排除 - -### 常见问题 - -1. **容器启动失败** - ```bash - # 查看详细错误 - docker logs xianyu-auto-reply - - # 检查端口占用 - netstat -tlnp | grep 8080 - ``` - -2. **数据库初始化失败** - ```bash - # 数据库会在应用启动时自动初始化 - # 如果需要重新初始化,可以删除数据库文件后重启容器 - docker exec xianyu-auto-reply rm -f /app/data/xianyu_data.db - docker restart xianyu-auto-reply - ``` - -3. **权限问题** - ```bash - # 修复目录权限 - sudo chown -R 1000:1000 ./data ./logs ./backups - ``` - -4. **依赖安装失败** - ```bash - # 重新构建镜像 - docker-compose build --no-cache - ``` - -### 调试模式 -```bash -# 启用调试模式 -docker-compose -f docker-compose.yml -f docker-compose.debug.yml up -d - -# 或设置环境变量 -docker run -e DEBUG=true -e LOG_LEVEL=DEBUG ... -``` - -## 🔄 更新部署 - -### 更新步骤 -1. **停止服务** - ```bash - docker-compose down - ``` - -2. **拉取最新代码** - ```bash - git pull origin main - ``` - -3. **重新构建** - ```bash - docker-compose build --no-cache - ``` - -4. **启动服务** - ```bash - docker-compose up -d - ``` - -### 数据备份 -```bash -# 备份数据库 -docker exec xianyu-auto-reply cp /app/data/xianyu_data.db /app/backups/ - -# 备份配置 -cp .env .env.backup -cp global_config.yml global_config.yml.backup -``` - -## 📈 性能优化 - -### 资源限制 -```yaml -deploy: - resources: - limits: - memory: 512M # 内存限制 - cpus: '0.5' # CPU限制 - reservations: - memory: 256M # 内存预留 - cpus: '0.25' # CPU预留 -``` - -### 优化建议 -1. **调整内存限制**:根据实际使用情况调整 -2. **使用SSD存储**:提高数据库性能 -3. **配置日志轮转**:避免日志文件过大 -4. **定期清理**:清理旧的备份文件 - ---- - -🎉 **Docker部署配置完善,支持所有新功能!** diff --git a/FINAL_ISOLATION_STATUS.md b/FINAL_ISOLATION_STATUS.md deleted file mode 100644 index b8b8fb0..0000000 --- a/FINAL_ISOLATION_STATUS.md +++ /dev/null @@ -1,217 +0,0 @@ -# 多用户数据隔离最终状态报告 - -## 🎯 总体状态 - -**当前进度**: 核心功能已完成用户隔离,部分功能需要策略确认 - -**数据库状态**: ✅ 已完成数据库结构升级和数据迁移 - -**API状态**: ✅ 核心接口已修复,部分接口待完善 - -## 📊 详细隔离状态 - -### ✅ 已完全隔离的模块 - -#### 1. 账号管理 (Cookie管理) -- ✅ **数据库**: cookies表已添加user_id字段 -- ✅ **API接口**: 所有Cookie相关接口已实现用户隔离 -- ✅ **权限验证**: 跨用户访问被严格禁止 -- ✅ **数据迁移**: 历史数据已绑定到admin用户 - -#### 2. 自动回复管理 -- ✅ **关键字管理**: 完全隔离 -- ✅ **默认回复设置**: 完全隔离 -- ✅ **权限验证**: 只能操作自己的回复规则 - -#### 3. 商品管理 -- ✅ **主要CRUD接口**: 已实现用户隔离 -- ✅ **权限验证**: Cookie所有权验证 -- ⚠️ **批量操作**: 部分接口需要进一步修复 - -#### 4. AI回复设置 -- ✅ **配置管理**: 完全隔离 -- ✅ **权限验证**: 只能配置自己的AI回复 - -#### 5. 消息通知 -- ✅ **配置管理**: 主要接口已隔离 -- ⚠️ **删除操作**: 部分接口需要修复 - -#### 6. 卡券管理 (新增隔离) -- ✅ **数据库**: cards表已添加user_id字段 -- ✅ **API接口**: 主要接口已实现用户隔离 -- ✅ **权限验证**: 跨用户访问被禁止 -- ✅ **数据迁移**: 历史数据已绑定到admin用户 - -#### 7. 用户设置 (新增功能) -- ✅ **数据库**: 新建user_settings表 -- ✅ **API接口**: 完整的用户设置管理 -- ✅ **主题颜色**: 每个用户独立的主题设置 -- ✅ **个人偏好**: 支持各种用户个性化设置 - -### ❓ 需要策略确认的模块 - -#### 1. 自动发货规则 -- **数据库**: ✅ delivery_rules表已添加user_id字段 -- **API接口**: ❌ 仍使用旧认证方式 -- **建议**: 实现用户隔离(每个用户独立的发货规则) - -#### 2. 通知渠道 -- **数据库**: ✅ notification_channels表已添加user_id字段 -- **API接口**: ❌ 仍使用旧认证方式 -- **策略选择**: - - 选项A: 实现用户隔离(每个用户独立配置) - - 选项B: 保持全局共享(管理员统一配置) - -#### 3. 系统设置 -- **当前状态**: 全局共享 -- **策略选择**: - - 全局设置: 保持共享(如系统配置) - - 用户设置: 已实现隔离(如主题颜色) - -## 🔧 已完成的修复 - -### 数据库结构升级 -```sql --- 为相关表添加用户隔离字段 -ALTER TABLE cards ADD COLUMN user_id INTEGER REFERENCES users(id); -ALTER TABLE delivery_rules ADD COLUMN user_id INTEGER REFERENCES users(id); -ALTER TABLE notification_channels ADD COLUMN user_id INTEGER REFERENCES users(id); - --- 创建用户设置表 -CREATE TABLE user_settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - key TEXT NOT NULL, - value TEXT, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, key) -); -``` - -### API接口修复 -- ✅ 卡券管理接口: 从`require_auth`升级到`get_current_user` -- ✅ 用户设置接口: 新增完整的用户设置管理 -- ✅ 数据库方法: 支持用户隔离的查询和操作 - -### 数据迁移 -- ✅ 历史卡券数据绑定到admin用户 -- ✅ 历史发货规则数据绑定到admin用户 -- ✅ 历史通知渠道数据绑定到admin用户 -- ✅ 为现有用户创建默认设置 - -## 📋 待修复的接口 - -### 高优先级(安全风险) -1. **自动发货规则接口** (5个接口) - - `GET /delivery-rules` - - `POST /delivery-rules` - - `GET /delivery-rules/{rule_id}` - - `PUT /delivery-rules/{rule_id}` - - `DELETE /delivery-rules/{rule_id}` - -2. **卡券管理剩余接口** (2个接口) - - `PUT /cards/{card_id}` - - `DELETE /cards/{card_id}` - -### 中优先级(功能完整性) -3. **通知渠道接口** (6个接口) - 需要策略确认 - - `GET /notification-channels` - - `POST /notification-channels` - - `GET /notification-channels/{channel_id}` - - `PUT /notification-channels/{channel_id}` - - `DELETE /notification-channels/{channel_id}` - -4. **消息通知删除接口** (2个接口) - - `DELETE /message-notifications/account/{cid}` - - `DELETE /message-notifications/{notification_id}` - -5. **商品管理批量接口** (3个接口) - - `DELETE /items/batch` - - `POST /items/get-all-from-account` - - `POST /items/get-by-page` - -## 🧪 测试验证 - -### 已通过的测试 -- ✅ 用户注册和登录 -- ✅ Cookie数据隔离 -- ✅ 卡券管理隔离 -- ✅ 用户设置隔离 -- ✅ 跨用户访问拒绝 - -### 测试脚本 -- `test_complete_isolation.py` - 完整的隔离测试 -- `fix_complete_isolation.py` - 数据库修复脚本 - -## 🎯 建议的隔离策略 - -### 完全用户隔离(推荐) -- **自动发货规则**: 每个用户独立的发货规则 -- **卡券管理**: 每个用户独立的卡券库 -- **用户设置**: 每个用户独立的个性化设置 - -### 混合策略(可选) -- **通知渠道**: 管理员统一配置,用户选择使用 -- **系统设置**: 区分全局设置和用户设置 - -## 🚀 下一步行动 - -### 立即执行(高优先级) -1. **修复自动发货规则接口** - ```bash - # 需要修复的接口模式 - @app.get("/delivery-rules") - def get_delivery_rules(current_user: Dict[str, Any] = Depends(get_current_user)): - # 添加用户权限验证 - ``` - -2. **修复卡券管理剩余接口** - ```bash - # 需要添加用户权限验证 - if card.user_id != current_user['user_id']: - raise HTTPException(status_code=403, detail="无权限操作") - ``` - -### 策略确认(中优先级) -3. **确认通知渠道策略** - - 与产品团队确认是否需要用户隔离 - - 如需隔离,按照卡券管理模式修复 - -4. **完善商品管理** - - 修复批量操作接口的用户权限验证 - -### 测试和部署(低优先级) -5. **完整测试** - - 运行所有隔离测试 - - 验证数据完整性 - -6. **文档更新** - - 更新API文档 - - 更新用户手册 - -## 📊 当前隔离覆盖率 - -- **已隔离模块**: 6/8 (75%) -- **已修复接口**: 约70% -- **数据库隔离**: 100% -- **核心功能隔离**: 100% - -## 🎉 总结 - -多用户数据隔离项目已基本完成,核心功能已实现完全隔离: - -### 主要成就 -- ✅ **数据库层面**: 完整的用户隔离支持 -- ✅ **核心业务**: Cookie、回复、商品、AI设置完全隔离 -- ✅ **新增功能**: 卡券管理和用户设置支持 -- ✅ **安全保障**: 跨用户访问被严格禁止 - -### 待完善项目 -- ⚠️ **自动发货规则**: 需要修复API接口 -- ❓ **通知渠道**: 需要确认隔离策略 -- 🔧 **批量操作**: 需要完善权限验证 - -**总体评估**: 系统已具备企业级的多用户数据隔离能力,可以安全地支持多个用户同时使用,剩余工作主要是完善和策略确认。 diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 4501f64..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 肥极喵 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LOG_IMPROVEMENT_SUMMARY.md b/LOG_IMPROVEMENT_SUMMARY.md deleted file mode 100644 index 612ec8b..0000000 --- a/LOG_IMPROVEMENT_SUMMARY.md +++ /dev/null @@ -1,196 +0,0 @@ -# 日志显示改进总结 - -## 🎯 改进目标 - -在多用户系统中,原有的日志无法区分不同用户的操作,导致调试和监控困难。本次改进为所有重要日志添加了Cookie ID标识。 - -## 📊 改进对比 - -### 改进前的日志格式 -``` -2025-07-25 14:23:47.770 | INFO | XianyuAutoAsync:init:1360 - 获取初始token... -2025-07-25 14:23:47.771 | INFO | XianyuAutoAsync:refresh_token:134 - 开始刷新token... -2025-07-25 14:23:48.269 | INFO | XianyuAutoAsync:refresh_token:200 - Token刷新成功 -2025-07-25 14:23:49.286 | INFO | XianyuAutoAsync:init:1407 - 连接注册完成 -2025-07-25 14:23:49.288 | INFO | XianyuAutoAsync:handle_message:1663 - [2025-07-25 14:23:49] 【系统】小闲鱼智能提示: -``` - -### 改进后的日志格式 -``` -2025-07-25 14:23:47.770 | INFO | XianyuAutoAsync:init:1360 - 【user1_cookie】获取初始token... -2025-07-25 14:23:47.771 | INFO | XianyuAutoAsync:refresh_token:134 - 【user1_cookie】开始刷新token... -2025-07-25 14:23:48.269 | INFO | XianyuAutoAsync:refresh_token:200 - 【user1_cookie】Token刷新成功 -2025-07-25 14:23:49.286 | INFO | XianyuAutoAsync:init:1407 - 【user1_cookie】连接注册完成 -2025-07-25 14:23:49.288 | INFO | XianyuAutoAsync:handle_message:1663 - [2025-07-25 14:23:49] 【user1_cookie】【系统】小闲鱼智能提示: -``` - -## 🔧 修改的日志类型 - -### 1. Token管理相关 -- ✅ `【{cookie_id}】开始刷新token...` -- ✅ `【{cookie_id}】Token刷新成功` -- ✅ `【{cookie_id}】Token刷新失败: {error}` -- ✅ `【{cookie_id}】获取初始token...` -- ✅ `【{cookie_id}】Token刷新成功,准备重新建立连接...` - -### 2. 连接管理相关 -- ✅ `【{cookie_id}】连接注册完成` -- ✅ `【{cookie_id}】message: {message}` -- ✅ `【{cookie_id}】send message` - -### 3. 系统消息相关 -- ✅ `[{time}] 【{cookie_id}】【系统】小闲鱼智能提示:` -- ✅ `[{time}] 【{cookie_id}】【系统】其他类型消息: {content}` -- ✅ `[{time}] 【{cookie_id}】系统消息不处理` -- ✅ `[{time}] 【{cookie_id}】【系统】买家已付款,准备自动发货` -- ✅ `[{time}] 【{cookie_id}】【系统】自动回复已禁用` -- ✅ `[{time}] 【{cookie_id}】【系统】未找到匹配的回复规则,不回复` - -### 4. 商品和发货相关 -- ✅ `【{cookie_id}】从消息内容中提取商品ID: {item_id}` -- ✅ `【{cookie_id}】准备自动发货: item_id={item_id}, item_title={title}` - -### 5. 回复生成相关 -- ✅ `【{cookie_id}】使用默认回复: {reply}` -- ✅ `【{cookie_id}】AI回复生成成功: {reply}` - -## 📁 修改的文件 - -### XianyuAutoAsync.py -- **修改行数**: 约20处日志输出 -- **影响范围**: 所有核心功能的日志 -- **修改方式**: 在日志消息前添加 `【{self.cookie_id}】` 标识 - -## 🎯 改进效果 - -### 1. 问题定位能力 -- **改进前**: 无法区分不同用户的操作,调试困难 -- **改进后**: 一眼就能看出是哪个用户的操作 - -### 2. 监控分析能力 -- **改进前**: 无法按用户统计操作情况 -- **改进后**: 可以轻松按用户过滤和统计 - -### 3. 运维管理能力 -- **改进前**: 多用户问题排查复杂 -- **改进后**: 快速定位特定用户的问题 - -## 💡 日志分析技巧 - -### 1. 按用户过滤日志 -```bash -# 查看特定用户的所有操作 -grep '【user1_cookie】' logs/xianyu_2025-07-25.log - -# 查看特定用户的错误日志 -grep 'ERROR.*【user1_cookie】' logs/xianyu_2025-07-25.log -``` - -### 2. 监控Token状态 -```bash -# 查看所有用户的Token刷新情况 -grep '【.*】.*Token' logs/xianyu_2025-07-25.log - -# 查看Token刷新失败的情况 -grep '【.*】.*Token刷新失败' logs/xianyu_2025-07-25.log -``` - -### 3. 统计用户活跃度 -```bash -# 统计各用户的操作次数 -grep -o '【[^】]*】' logs/xianyu_2025-07-25.log | sort | uniq -c - -# 查看最活跃的用户 -grep -o '【[^】]*】' logs/xianyu_2025-07-25.log | sort | uniq -c | sort -nr -``` - -### 4. 监控系统消息 -```bash -# 查看所有系统级别的消息 -grep '【系统】' logs/xianyu_2025-07-25.log - -# 查看自动发货相关的消息 -grep '准备自动发货' logs/xianyu_2025-07-25.log -``` - -### 5. 分析回复情况 -```bash -# 查看AI回复的使用情况 -grep 'AI回复生成成功' logs/xianyu_2025-07-25.log - -# 查看默认回复的使用情况 -grep '使用默认回复' logs/xianyu_2025-07-25.log -``` - -## 🔍 实时监控命令 - -### 1. 实时查看特定用户的日志 -```bash -tail -f logs/xianyu_2025-07-25.log | grep '【user1_cookie】' -``` - -### 2. 实时监控所有错误 -```bash -tail -f logs/xianyu_2025-07-25.log | grep 'ERROR.*【.*】' -``` - -### 3. 实时监控Token刷新 -```bash -tail -f logs/xianyu_2025-07-25.log | grep '【.*】.*Token' -``` - -## 📈 监控仪表板建议 - -基于新的日志格式,可以构建以下监控指标: - -### 1. 用户活跃度指标 -- 每个用户的操作频率 -- 用户在线时长统计 -- 用户操作成功率 - -### 2. 系统健康指标 -- Token刷新成功率(按用户) -- 连接稳定性(按用户) -- 错误发生频率(按用户) - -### 3. 业务指标 -- 自动回复使用率(按用户) -- AI回复成功率(按用户) -- 自动发货成功率(按用户) - -## 🚀 部署建议 - -### 1. 重启服务 -```bash -# 停止当前服务 -docker-compose down - -# 重新启动服务 -docker-compose up -d - -# 查看新的日志格式 -docker-compose logs -f -``` - -### 2. 日志轮转配置 -确保日志轮转配置能够处理增加的日志内容: -```yaml -# loguru配置示例 -rotation: "100 MB" -retention: "7 days" -compression: "zip" -``` - -### 3. 监控工具配置 -如果使用ELK、Grafana等监控工具,需要更新日志解析规则以识别新的Cookie ID字段。 - -## 🎉 总结 - -通过本次改进,多用户系统的日志现在具备了: - -- ✅ **清晰的用户标识**: 每条日志都能明确标识操作用户 -- ✅ **高效的问题定位**: 快速定位特定用户的问题 -- ✅ **精准的监控分析**: 支持按用户维度的监控和分析 -- ✅ **便捷的运维管理**: 简化多用户环境的运维工作 - -这为多用户系统的稳定运行和高效管理奠定了坚实的基础! diff --git a/MULTIUSER_ISOLATION_STATUS.md b/MULTIUSER_ISOLATION_STATUS.md deleted file mode 100644 index d5eaeb9..0000000 --- a/MULTIUSER_ISOLATION_STATUS.md +++ /dev/null @@ -1,216 +0,0 @@ -# 多用户数据隔离状态报告 - -## 🎯 总体状态 - -**当前进度**: 核心功能已实现用户隔离,部分管理功能待完善 - -**测试结果**: ✅ 核心数据隔离测试全部通过 - -## 📊 功能模块隔离状态 - -### ✅ 已完成隔离的模块 - -#### 1. 账号管理 (Cookie管理) -- ✅ 获取Cookie列表 - 只显示当前用户的Cookie -- ✅ 添加Cookie - 自动绑定到当前用户 -- ✅ 更新Cookie - 权限验证 -- ✅ 删除Cookie - 权限验证 -- ✅ Cookie状态管理 - 权限验证 - -#### 2. 自动回复管理 -- ✅ 关键字管理 - 完全隔离 -- ✅ 默认回复设置 - 完全隔离 -- ✅ 获取所有默认回复 - 只返回当前用户数据 - -#### 3. 商品管理 (部分完成) -- ✅ 获取所有商品 - 只返回当前用户商品 -- ✅ 按Cookie获取商品 - 权限验证 -- ✅ 获取商品详情 - 权限验证 -- ✅ 更新商品详情 - 权限验证 -- ✅ 删除商品信息 - 权限验证 - -#### 4. AI回复设置 -- ✅ 获取AI回复设置 - 权限验证 -- ✅ 更新AI回复设置 - 权限验证 -- ✅ 获取所有AI回复设置 - 只返回当前用户数据 - -#### 5. 消息通知 (部分完成) -- ✅ 获取所有消息通知 - 只返回当前用户数据 -- ✅ 获取账号消息通知 - 权限验证 -- ✅ 设置消息通知 - 权限验证 - -#### 6. 数据备份 -- ✅ 导出备份 - 只导出当前用户数据 -- ✅ 导入备份 - 只导入到当前用户 - -### ❓ 需要确认隔离策略的模块 - -#### 1. 卡券管理 -**当前状态**: 未隔离(全局共享) -**建议**: -- 选项A: 保持全局共享(所有用户共用卡券库) -- 选项B: 实现用户隔离(每个用户独立的卡券) - -#### 2. 通知渠道 -**当前状态**: 未隔离(全局共享) -**建议**: -- 选项A: 保持全局共享(管理员统一配置) -- 选项B: 实现用户隔离(每个用户独立配置) - -#### 3. 系统设置 -**当前状态**: 部分隔离 -**建议**: -- 全局设置: 保持共享(如系统配置) -- 用户设置: 实现隔离(如个人偏好) - -### ❌ 待修复的接口 - -#### 商品管理剩余接口 -- `batch_delete_items` - 批量删除商品 -- `get_all_items_from_account` - 从账号获取所有商品 -- `get_items_by_page` - 分页获取商品 - -#### 消息通知剩余接口 -- `delete_account_notifications` - 删除账号通知 -- `delete_message_notification` - 删除消息通知 - -#### 卡券管理接口 (如需隔离) -- `get_cards` - 获取卡券列表 -- `create_card` - 创建卡券 -- `get_card` - 获取卡券详情 -- `update_card` - 更新卡券 -- `delete_card` - 删除卡券 - -#### 自动发货接口 (如需隔离) -- `get_delivery_rules` - 获取发货规则 -- `create_delivery_rule` - 创建发货规则 -- `get_delivery_rule` - 获取发货规则详情 -- `update_delivery_rule` - 更新发货规则 -- `delete_delivery_rule` - 删除发货规则 - -#### 通知渠道接口 (如需隔离) -- `get_notification_channels` - 获取通知渠道 -- `create_notification_channel` - 创建通知渠道 -- `get_notification_channel` - 获取通知渠道详情 -- `update_notification_channel` - 更新通知渠道 -- `delete_notification_channel` - 删除通知渠道 - -## 🧪 测试结果 - -### ✅ 通过的测试 - -1. **用户注册和登录** - - 图形验证码生成和验证 - - 邮箱验证码发送和验证 - - 用户注册流程 - - 用户登录认证 - -2. **数据隔离** - - Cookie数据完全隔离 - - 用户只能看到自己的数据 - - 跨用户访问被正确拒绝 - -3. **权限验证** - - API层面权限检查 - - 403错误正确返回 - - 用户身份验证 - -### 📊 测试统计 - -- **已修复接口**: 25个 (使用新认证方式) -- **待修复接口**: 28个 (仍使用旧认证方式) -- **权限检查接口**: 23个 (包含用户权限验证) - -## 🔒 安全特性 - -### ✅ 已实现的安全特性 - -1. **用户认证** - - JWT Token认证 - - 图形验证码防护 - - 邮箱验证码验证 - -2. **数据隔离** - - 数据库层面用户绑定 - - API层面权限验证 - - 跨用户访问拒绝 - -3. **权限控制** - - 基于用户ID的数据过滤 - - Cookie所有权验证 - - 操作权限检查 - -### 🛡️ 安全机制 - -```python -# 标准的用户权限检查模式 -def api_function(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)): - # 1. 获取当前用户ID - user_id = current_user['user_id'] - - # 2. 获取用户的Cookie列表 - user_cookies = db_manager.get_all_cookies(user_id) - - # 3. 验证Cookie所有权 - if cid not in user_cookies: - raise HTTPException(status_code=403, detail="无权限访问该Cookie") - - # 4. 执行业务逻辑 - # ... -``` - -## 📋 建议的隔离策略 - -### 核心业务数据 (必须隔离) -- ✅ Cookie/账号数据 -- ✅ 商品信息 -- ✅ 关键字和回复 -- ✅ AI回复设置 -- ✅ 消息通知配置 - -### 配置数据 (建议策略) -- **卡券管理**: 建议保持全局共享 -- **通知渠道**: 建议保持全局共享(管理员配置) -- **发货规则**: 建议实现用户隔离 -- **系统设置**: 区分全局设置和用户设置 - -### 管理功能 (特殊处理) -- **系统监控**: 管理员专用 -- **用户管理**: 管理员专用 -- **系统配置**: 管理员专用 - -## 🚀 下一步行动计划 - -### 优先级1: 完成核心功能隔离 -1. 修复剩余的商品管理接口 -2. 修复剩余的消息通知接口 -3. 完善AI回复相关接口 - -### 优先级2: 确认隔离策略 -1. 与产品团队确认卡券管理策略 -2. 确认通知渠道管理策略 -3. 确认自动发货规则策略 - -### 优先级3: 完善管理功能 -1. 实现管理员用户管理界面 -2. 添加用户数据统计功能 -3. 完善系统监控功能 - -### 优先级4: 测试和文档 -1. 编写完整的API测试用例 -2. 更新API文档 -3. 编写用户使用指南 - -## 🎉 总结 - -**当前状态**: 多用户系统的核心功能已经实现,数据隔离测试全部通过。 - -**主要成就**: -- ✅ 用户注册和认证系统完整 -- ✅ 核心业务数据完全隔离 -- ✅ 安全权限验证机制完善 -- ✅ 数据库层面支持多用户 - -**待完善项目**: 主要是一些管理功能和配置功能的隔离策略确认。 - -**安全性**: 系统已具备企业级的多用户数据隔离能力,可以安全地支持多个用户同时使用。 diff --git a/MULTIUSER_SYSTEM_README.md b/MULTIUSER_SYSTEM_README.md deleted file mode 100644 index f7bbdbb..0000000 --- a/MULTIUSER_SYSTEM_README.md +++ /dev/null @@ -1,277 +0,0 @@ -# 多用户系统升级指南 - -## 🎯 功能概述 - -本次升级将闲鱼自动回复系统从单用户模式升级为多用户模式,实现以下功能: - -### ✨ 新增功能 - -1. **用户注册系统** - - 邮箱注册,支持验证码验证 - - 用户名唯一性检查 - - 密码安全存储(SHA256哈希) - -2. **数据隔离** - - 每个用户只能看到自己的数据 - - Cookie、关键字、备份等完全隔离 - - 历史数据自动绑定到admin用户 - -3. **邮箱验证** - - 集成邮件发送API - - 6位数字验证码 - - 10分钟有效期 - - 防重复使用 - -## 🚀 升级步骤 - -### 1. 备份数据 -```bash -# 备份数据库文件 -cp xianyu_data.db xianyu_data.db.backup -``` - -### 2. 运行迁移脚本 -```bash -# 迁移历史数据到admin用户 -python migrate_to_multiuser.py - -# 检查迁移状态 -python migrate_to_multiuser.py check -``` - -### 3. 重启应用 -```bash -# 重启应用程序 -python Start.py -``` - -### 4. 验证功能 -```bash -# 运行功能测试 -python test_multiuser_system.py -``` - -## 📋 API变更 - -### 新增接口 - -| 接口 | 方法 | 说明 | -|------|------|------| -| `/register` | POST | 用户注册 | -| `/send-verification-code` | POST | 发送验证码 | -| `/verify` | GET | 验证token(返回用户信息) | - -### 修改的接口 - -所有需要认证的接口现在都支持用户隔离: - -- `/cookies` - 只返回当前用户的cookies -- `/cookies/details` - 只返回当前用户的cookie详情 -- `/backup/export` - 只导出当前用户的数据 -- `/backup/import` - 只导入到当前用户 - -## 🔐 认证系统 - -### Token格式变更 -```javascript -// 旧格式 -SESSION_TOKENS[token] = timestamp - -// 新格式 -SESSION_TOKENS[token] = { - user_id: 1, - username: 'admin', - timestamp: 1234567890 -} -``` - -### 登录响应变更 -```json -{ - "success": true, - "token": "abc123...", - "message": "登录成功", - "user_id": 1 -} -``` - -## 🗄️ 数据库变更 - -### 新增表 - -1. **users** - 用户表 - ```sql - CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - is_active BOOLEAN DEFAULT TRUE, - created_at REAL NOT NULL, - updated_at REAL NOT NULL - ); - ``` - -2. **email_verifications** - 邮箱验证码表 - ```sql - CREATE TABLE email_verifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL, - code TEXT NOT NULL, - expires_at REAL NOT NULL, - used BOOLEAN DEFAULT FALSE, - created_at REAL DEFAULT (strftime('%s', 'now')) - ); - ``` - -### 修改的表 - -1. **cookies** - 添加user_id字段 - ```sql - ALTER TABLE cookies ADD COLUMN user_id INTEGER; - ``` - -## 🎨 前端变更 - -### 新增页面 - -1. **注册页面** (`/register.html`) - - 用户名、邮箱、密码输入 - - 邮箱验证码发送和验证 - - 表单验证和错误提示 - - 响应式设计 - -### 修改的页面 - -1. **登录页面** (`/login.html`) - - 添加注册链接 - - 保持向后兼容 - -## 📧 邮件系统 - -### 邮件API配置 -``` -API地址: https://dy.zhinianboke.com/api/emailSend -参数: -- subject: 邮件主题 -- receiveUser: 收件人邮箱 -- sendHtml: 邮件内容(HTML格式) -``` - -### 邮件模板 -- 响应式HTML设计 -- 品牌化样式 -- 验证码突出显示 -- 安全提醒信息 - -## 🔒 安全特性 - -1. **密码安全** - - SHA256哈希存储 - - 不可逆加密 - -2. **验证码安全** - - 6位随机数字 - - 10分钟有效期 - - 一次性使用 - - 防暴力破解 - -3. **数据隔离** - - 用户级别完全隔离 - - API层面权限检查 - - 数据库层面用户绑定 - -## 🧪 测试指南 - -### 功能测试 -```bash -# 运行完整测试套件 -python test_multiuser_system.py -``` - -### 手动测试步骤 - -1. **注册测试** - - 访问 `/register.html` - - 输入用户信息 - - 验证邮箱验证码 - - 完成注册 - -2. **登录测试** - - 使用新注册的账号登录 - - 验证只能看到自己的数据 - -3. **数据隔离测试** - - 创建多个用户账号 - - 分别添加不同的cookies - - 验证数据完全隔离 - -## 🐛 故障排除 - -### 常见问题 - -1. **迁移失败** - ```bash - # 检查数据库文件权限 - ls -la xianyu_data.db - - # 检查迁移状态 - python migrate_to_multiuser.py check - ``` - -2. **邮件发送失败** - - 检查网络连接 - - 验证邮箱地址格式 - - 查看应用日志 - -3. **用户无法登录** - - 检查用户名和密码 - - 确认用户状态为激活 - - 查看认证日志 - -### 回滚方案 - -如果升级出现问题,可以回滚到单用户模式: - -1. 恢复数据库备份 - ```bash - cp xianyu_data.db.backup xianyu_data.db - ``` - -2. 使用旧版本代码 -3. 重启应用 - -## 📈 性能影响 - -- **数据库查询**: 增加了user_id过滤条件,对性能影响微小 -- **内存使用**: CookieManager仍加载所有数据,API层面进行过滤 -- **响应时间**: 增加了用户验证步骤,延迟增加<10ms - -## 🔮 未来规划 - -1. **用户管理** - - 管理员用户管理界面 - - 用户权限控制 - - 用户状态管理 - -2. **高级功能** - - 用户组和权限 - - 数据共享机制 - - 审计日志 - -3. **性能优化** - - 用户级别的CookieManager - - 数据库索引优化 - - 缓存策略 - -## 📞 技术支持 - -如有问题,请: -1. 查看应用日志 -2. 运行测试脚本诊断 -3. 检查数据库状态 -4. 联系技术支持 - ---- - -**升级完成后,您的闲鱼自动回复系统将支持多用户使用,每个用户的数据完全隔离,提供更好的安全性和可扩展性!** 🎉 diff --git a/README.md b/README.md index ce4260f..f16ea20 100644 --- a/README.md +++ b/README.md @@ -1,173 +1,332 @@ -# 🐟 XianYuAutoDeliveryX - 闲鱼虚拟商品商自动发货&聊天对接大模型 +# 🐟 闲鱼自动回复系统 -[![Python Version](https://img.shields.io/badge/python-3.7%2B-blue)](https://www.python.org/) -[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![GitHub](https://img.shields.io/badge/GitHub-zhinianboke%2Fxianyu--auto--reply-blue?logo=github)](https://github.com/zhinianboke/xianyu-auto-reply) +[![Docker](https://img.shields.io/badge/Docker-一键部署-blue?logo=docker)](https://github.com/zhinianboke/xianyu-auto-reply#-快速开始) -**✨ 基于闲鱼API的自动发货系统,支持虚拟商品商品聊天窗口自动发货、消息自动回复等功能。** -**⚠️ 注意:本项目仅供学习交流使用,请勿用于商业用途。** +一个功能完整的闲鱼自动回复和管理系统,支持多用户、多账号管理,具备智能回复、自动发货、商品管理等企业级功能。 -## 🌟 核心特性 +## ✨ 核心特性 -- 🔐 **用户认证系统** - 安全的登录认证,保护管理界面 -- 👥 **多账号管理** - 支持同时管理多个闲鱼账号 -- 🎯 **智能关键词回复** - 每个账号独立的关键词回复设置 -- 💾 **数据持久化** - SQLite数据库存储账号和关键词数据 -- 🌐 **美观Web界面** - 响应式设计,操作简单直观 -- 📡 **API接口** - 完整的RESTful API支持 -- 🔄 **实时消息处理** - 基于WebSocket的实时消息监控 -- 📊 **订单状态监控** - 实时跟踪订单状态变化 -- 📝 **完善的日志系统** - 详细的操作日志记录 +### 🔐 多用户系统 +- **用户注册登录** - 支持邮箱验证码注册,图形验证码保护 +- **数据完全隔离** - 每个用户的数据独立存储,互不干扰 +- **权限管理** - 严格的用户权限控制和JWT认证 +- **安全保护** - 防暴力破解、会话管理、安全日志 -## 🛠️ 快速开始 +### 📱 多账号管理 +- **无限账号支持** - 每个用户可管理多个闲鱼账号 +- **独立运行** - 每个账号独立监控,互不影响 +- **实时状态** - 账号连接状态实时监控 +- **批量操作** - 支持批量启动、停止账号任务 -### ⛳ 运行环境 -- Python 3.7+ +### 🤖 智能回复系统 +- **关键词匹配** - 支持精确关键词匹配回复 +- **AI智能回复** - 集成OpenAI API,支持上下文理解 +- **变量替换** - 回复内容支持动态变量(用户名、商品信息等) +- **优先级策略** - 关键词回复优先,AI回复兜底 + +### 🚚 自动发货功能 +- **智能匹配** - 基于商品信息自动匹配发货规则 +- **多种触发** - 支持付款消息、小刀消息等多种触发条件 +- **防重复发货** - 智能防重复机制,避免重复发货 +- **卡密发货** - 支持文本内容和卡密文件发货 + +### 🛍️ 商品管理 +- **自动收集** - 消息触发时自动收集商品信息 +- **API获取** - 通过闲鱼API获取完整商品详情 +- **批量管理** - 支持批量查看、编辑商品信息 +- **智能去重** - 自动去重,避免重复存储 + +### 📊 系统监控 +- **实时日志** - 完整的操作日志记录和查看 +- **性能监控** - 系统资源使用情况监控 +- **健康检查** - 服务状态健康检查 +- **数据备份** - 自动数据备份和恢复 + +## 🚀 快速开始 + +### 方式一:Docker 一键部署(最简单) -### 🎯 安装依赖 ```bash +# 创建数据目录 +mkdir -p xianyu-auto-reply + +# 一键启动容器 +docker run -d \ + -p 8080:8080 \ + -v $PWD/xianyu-auto-reply/:/app/data/ \ + --name xianyu-auto-reply \ + --privileged=true \ + registry.cn-shanghai.aliyuncs.com/zhinian-software/xianyu-auto-reply:1.0 + +# 访问系统 +# http://localhost:8080 +``` + +### 方式二:Docker Compose 部署(推荐) + +```bash +# 1. 克隆项目 +git clone https://github.com/zhinianboke/xianyu-auto-reply.git +cd xianyu-auto-reply + +# 2. 一键部署 +./docker-deploy.sh + +# 3. 访问系统 +# http://localhost:8080 +``` + +### 方式三:本地部署 + +```bash +# 1. 克隆项目 +git clone https://github.com/zhinianboke/xianyu-auto-reply.git +cd xianyu-auto-reply + +# 2. 安装依赖 pip install -r requirements.txt -``` -### 🎨 配置说明 -1. 在 `global_config.yml` 中配置基本参数 -2. 系统支持多账号管理,可通过Web界面添加多个闲鱼账号Cookie - -### 🚀 运行项目 -```bash +# 3. 启动系统 python Start.py + +# 4. 访问系统 +# http://localhost:8080 ``` -### 🔐 登录系统 -1. 启动后访问 `http://localhost:8080` -2. 默认登录账号: - - 用户名:`admin` - - 密码:`admin123` -3. 登录后可进入管理界面进行操作 +### 🐳 Docker 部署说明 + +#### 一键部署特点 +- **无需配置** - 使用预构建镜像,开箱即用 +- **数据持久化** - 自动挂载数据目录,数据不丢失 +- **快速启动** - 30秒内完成部署 +- **生产就绪** - 包含所有依赖和优化配置 + +#### 容器管理命令 +```bash +# 查看容器状态 +docker ps + +# 查看容器日志 +docker logs -f xianyu-auto-reply + +# 停止容器 +docker stop xianyu-auto-reply + +# 重启容器 +docker restart xianyu-auto-reply + +# 删除容器 +docker rm -f xianyu-auto-reply +``` + +## 📋 系统使用 + +### 1. 用户注册 +- 访问 `http://localhost:8080/register.html` +- 填写用户信息,完成邮箱验证 +- 输入图形验证码完成注册 + +### 2. 添加闲鱼账号 +- 登录系统后进入主界面 +- 点击"添加新账号" +- 输入账号ID和完整的Cookie值 +- 系统自动启动账号监控任务 + +### 3. 配置自动回复 +- **关键词回复**:设置关键词和对应回复内容 +- **AI回复**:配置OpenAI API密钥启用智能回复 +- **默认回复**:设置未匹配时的默认回复 + +### 4. 设置自动发货 +- 添加发货规则,设置商品关键词和发货内容 +- 支持文本内容和卡密文件两种发货方式 +- 系统检测到付款消息时自动发货 + +## 🏗️ 系统架构 + +``` +┌─────────────────────────────────────┐ +│ Web界面 (FastAPI) │ +│ 用户管理 + 功能界面 │ +└─────────────┬───────────────────────┘ + │ +┌─────────────▼───────────────────────┐ +│ CookieManager │ +│ 多账号任务管理 │ +└─────────────┬───────────────────────┘ + │ +┌─────────────▼───────────────────────┐ +│ XianyuLive (多实例) │ +│ WebSocket连接 + 消息处理 │ +└─────────────┬───────────────────────┘ + │ +┌─────────────▼───────────────────────┐ +│ SQLite数据库 │ +│ 用户数据 + 商品信息 + 配置数据 │ +└─────────────────────────────────────┘ +``` ## 📁 项目结构 + ``` -├── Start.py # 项目启动入口 -├── XianyuAutoAsync.py # 核心业务逻辑 -├── config.py # 配置管理 -├── cookie_manager.py # Cookie管理器 -├── db_manager.py # 数据库管理 -├── reply_server.py # FastAPI服务器 -├── utils/ # 工具函数目录 -│ ├── xianyu_utils.py # 闲鱼相关工具 -│ ├── message_utils.py # 消息处理工具 -│ └── ws_utils.py # WebSocket工具 -├── static/ # 静态资源 -│ ├── index.html # 管理界面 -│ └── login.html # 登录页面 -├── logs/ # 日志文件 -├── global_config.yml # 全局配置文件 -├── xianyu_data.db # SQLite数据库 -└── requirements.txt # Python依赖 +xianyu-auto-reply/ +├── Start.py # 主启动文件 +├── XianyuAutoAsync.py # 闲鱼WebSocket客户端核心 +├── reply_server.py # FastAPI Web服务器 +├── db_manager.py # 数据库管理模块 +├── cookie_manager.py # Cookie和任务管理 +├── ai_reply_engine.py # AI回复引擎 +├── config.py # 配置管理 +├── file_log_collector.py # 日志收集器 +├── global_config.yml # 全局配置文件 +├── requirements.txt # Python依赖 +├── docker-compose.yml # Docker编排配置 +├── Dockerfile # Docker镜像构建 +├── static/ # 前端静态文件 +│ ├── index.html # 主界面 +│ ├── login.html # 登录页面 +│ ├── register.html # 注册页面 +│ └── ... # 其他页面和资源 +├── logs/ # 日志文件目录 +├── data/ # 数据库文件目录 +└── backups/ # 备份文件目录 ``` -## 🎯 主要功能 +## ⚙️ 配置说明 -### 1. 用户认证系统 -- 安全的登录认证机制 -- Session token管理 -- 自动登录状态检查 -- 登出功能 +### 环境变量配置 +系统支持通过环境变量或 `.env` 文件进行配置: -### 2. 多账号管理 -- 支持添加多个闲鱼账号 -- 每个账号独立管理 -- Cookie安全存储 -- 账号状态监控 +```bash +# 基础配置 +WEB_PORT=8080 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 +JWT_SECRET_KEY=your-secret-key -### 3. 智能关键词回复 -- 每个账号独立的关键词设置 -- 支持变量替换:`{send_user_name}`, `{send_user_id}`, `{send_message}` -- 实时关键词匹配 -- 默认回复机制 +# 多用户系统 +MULTIUSER_ENABLED=true +USER_REGISTRATION_ENABLED=true +EMAIL_VERIFICATION_ENABLED=true +CAPTCHA_ENABLED=true -### 4. Web管理界面 -- 响应式设计,支持移动端 -- 直观的操作界面 -- 实时数据更新 -- 操作反馈提示 +# AI回复配置 +AI_REPLY_ENABLED=false +DEFAULT_AI_MODEL=qwen-plus +DEFAULT_AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 -## 🔌 API 接口说明 - -### 智能回复接口 -`POST http://localhost:8080/xianyu/reply` - -#### 接口说明 -你需要实现这个接口,本项目会调用这个接口获取自动回复的内容并发送给客户 -不实现这个接口也没关系,系统会默认回复,你也可以配置默认回复的内容 -用于处理闲鱼消息的自动回复,支持对接大语言模型进行智能回复。 - -**通过这个接口可以检测到用户是否已付款,然后回复虚拟资料内容即可** -#### 请求参数 -```json -{ - "msg_time": "消息时间", - "user_url": "用户主页URL", - "send_user_id": "发送者ID", - "send_user_name": "发送者昵称", - "item_id": "商品ID", - "send_message": "发送的消息内容", - "chat_id": "会话ID" -} +# 自动发货配置 +AUTO_DELIVERY_ENABLED=true +AUTO_DELIVERY_TIMEOUT=30 ``` -#### 响应格式 -```json -{ - "code": 200, - "data": { - "send_msg": "回复的消息内容" - } -} +### 全局配置文件 +`global_config.yml` 包含详细的系统配置,支持: +- WebSocket连接参数 +- API接口配置 +- 自动回复设置 +- 商品管理配置 +- 日志配置等 + +## 🔧 高级功能 + +### AI回复配置 +1. 在用户设置中配置OpenAI API密钥 +2. 选择AI模型(支持GPT-3.5、GPT-4、通义千问等) +3. 设置回复策略和提示词 +4. 启用AI回复功能 + +### 自动发货规则 +1. 进入发货管理页面 +2. 添加发货规则,设置商品关键词 +3. 上传卡密文件或输入发货内容 +4. 系统自动匹配商品并发货 + +### 商品信息管理 +1. 系统自动收集消息中的商品信息 +2. 通过API获取完整商品详情 +3. 支持手动编辑商品信息 +4. 为自动发货提供准确的商品数据 + +## 📊 监控和维护 + +### 日志管理 +- **实时日志**:Web界面查看实时系统日志 +- **日志文件**:`logs/` 目录下的按日期分割的日志文件 +- **日志级别**:支持DEBUG、INFO、WARNING、ERROR级别 + +### 数据备份 +```bash +# 手动备份 +./docker-deploy.sh backup + +# 查看备份 +ls backups/ ``` -#### 配置示例 -```yaml -AUTO_REPLY: - api: - enabled: true # 是否启用API回复 - timeout: 10 # 超时时间(秒) - url: http://localhost:8080/xianyu/reply +### 健康检查 +```bash +# 检查服务状态 +curl http://localhost:8080/health + +# 查看系统状态 +./docker-deploy.sh status ``` -#### 使用场景 -- 当收到买家消息时,系统会自动调用此接口 -- 支持接入 ChatGPT、文心一言等大语言模型 -- 支持自定义回复规则和模板 -- 支持消息变量替换(如 `{send_user_name}`) +## 🔒 安全特性 -#### 注意事项 -- 接口需要返回正确的状态码(200)和消息内容 -- 建议实现错误重试机制 -- 注意处理超时情况(默认10秒) -- 可以根据需要扩展更多的参数和功能 +- **JWT认证**:安全的用户认证机制 +- **图形验证码**:防止自动化攻击 +- **邮箱验证**:确保用户邮箱真实性 +- **数据隔离**:用户数据完全隔离 +- **会话管理**:安全的会话超时机制 +- **操作日志**:完整的用户操作记录 -## 🗝️ 注意事项 -- 请确保闲鱼账号已登录并获取有效的 Cookie -- 建议在正式环境使用前先在测试环境验证 -- 定期检查日志文件,及时处理异常情况 -- 使用大模型时注意 API 调用频率和成本控制 +## 🤝 贡献指南 -## 📝 效果 +欢迎为项目做出贡献!您可以通过以下方式参与: +### 📝 提交问题 +- 在 [GitHub Issues](https://github.com/zhinianboke/xianyu-auto-reply/issues) 中报告Bug +- 提出新功能建议和改进意见 +- 分享使用经验和最佳实践 -![image-20250611004531745](https://typeropic.oss-cn-beijing.aliyuncs.com/cp/image-20250611004531745.png) +### 🔧 代码贡献 +- Fork 项目到您的GitHub账号 +- 创建功能分支:`git checkout -b feature/your-feature` +- 提交更改:`git commit -am 'Add some feature'` +- 推送分支:`git push origin feature/your-feature` +- 提交 Pull Request -![image-20250611004549662](https://typeropic.oss-cn-beijing.aliyuncs.com/cp/image-20250611004549662.png) +### 📖 文档贡献 +- 改进现有文档 +- 添加使用示例 +- 翻译文档到其他语言 -## 🧸特别鸣谢 +## 📞 技术支持 -本项目参考了以下开源项目: https://github.com/cv-cat/XianYuApis +### 🔧 故障排除 +如遇问题,请: +1. 查看日志:`docker-compose logs -f` +2. 检查状态:`./docker-deploy.sh status` +3. 健康检查:`curl http://localhost:8080/health` -感谢[@CVcat](https://github.com/cv-cat)的技术支持 +### 💬 交流群组 -## 📞 联系方式 -如有问题或建议,欢迎提交 Issue 或 Pull Request。 +欢迎加入我们的技术交流群,获取实时帮助和最新更新: -## 技术交流 +#### 微信交流群 +微信群二维码 -![image-20250611004141387](https://typeropic.oss-cn-beijing.aliyuncs.com/cp/image-20250611004141387.png) +#### QQ交流群 +QQ群二维码 + +### 📧 联系方式 +- **技术支持**:遇到问题可在群内咨询 +- **功能建议**:欢迎提出改进建议 +- **Bug反馈**:发现问题请及时反馈 + +--- + +🎉 **开始使用闲鱼自动回复系统,让您的闲鱼店铺管理更加智能高效!** diff --git a/REGISTER_PAGE_OPTIMIZATION.md b/REGISTER_PAGE_OPTIMIZATION.md deleted file mode 100644 index cba7408..0000000 --- a/REGISTER_PAGE_OPTIMIZATION.md +++ /dev/null @@ -1,233 +0,0 @@ -# 注册页面布局优化 - -## 🎯 优化目标 - -将注册页面优化为一屏显示,消除垂直滚动条,提升用户体验。 - -## 📊 优化前后对比 - -### 优化前的问题 -- ❌ 页面过长,需要垂直滚动 -- ❌ 间距过大,浪费屏幕空间 -- ❌ 字体和元素尺寸偏大 -- ❌ 表单提示文字占用过多空间 - -### 优化后的改进 -- ✅ 整个页面在一屏内显示完整 -- ✅ 紧凑而美观的布局 -- ✅ 适当的间距和字体大小 -- ✅ 简化的提示文字 - -## 🔧 具体优化措施 - -### 1. 容器和布局优化 - -**优化前:** -```css -.register-container { - max-width: 450px; - padding: 2rem; -} -.register-header { - padding: 2rem; -} -.register-body { - padding: 2rem; -} -``` - -**优化后:** -```css -.register-container { - max-width: 420px; - max-height: 95vh; - overflow-y: auto; -} -.register-header { - padding: 1.2rem; -} -.register-body { - padding: 1.2rem; -} -``` - -### 2. 表单元素优化 - -**优化前:** -```css -.form-control { - padding: 12px 15px; - border: 2px solid #e9ecef; - border-radius: 10px; -} -.mb-3 { - margin-bottom: 1rem; -} -``` - -**优化后:** -```css -.form-control { - padding: 8px 12px; - border: 1px solid #e9ecef; - border-radius: 8px; - font-size: 0.9rem; -} -.mb-3 { - margin-bottom: 0.8rem !important; -} -``` - -### 3. 文字和标签优化 - -**优化前:** -- 详细的表单提示文字 -- 较大的字体尺寸 -- 较多的说明文本 - -**优化后:** -- 简化的占位符文字 -- 适中的字体尺寸 -- 精简的说明文本 - -```css -.form-label { - font-size: 0.85rem; - margin-bottom: 0.3rem; -} -.form-text { - font-size: 0.75rem; - margin-top: 0.2rem; -} -``` - -### 4. 按钮和交互元素优化 - -**优化前:** -```css -.btn-register { - padding: 12px; - border-radius: 10px; -} -.btn-code { - border-radius: 10px; -} -``` - -**优化后:** -```css -.btn-register { - padding: 10px; - border-radius: 8px; - font-size: 0.9rem; -} -.btn-code { - padding: 8px 12px; - border-radius: 8px; - font-size: 0.85rem; -} -``` - -### 5. 图形验证码优化 - -**优化前:** -- 验证码图片高度 38px -- 较大的间距 - -**优化后:** -- 验证码图片高度 32px -- 紧凑的布局 -- 使用 `g-2` 类减少列间距 - -```html -
-
- -
-
- -
-
-``` - -### 6. 响应式优化 - -添加了针对小屏幕的特殊优化: - -```css -@media (max-height: 700px) { - .register-header { padding: 1rem; } - .mb-3 { margin-bottom: 0.6rem !important; } - .form-control { padding: 6px 10px; } -} - -@media (max-width: 480px) { - .register-container { margin: 5px; } - .row.g-2 > * { padding: 0.25rem; } -} -``` - -## 📱 用户体验提升 - -### 视觉效果 -- **更紧凑**:整个表单在一屏内完整显示 -- **更清晰**:减少了视觉噪音,重点突出 -- **更现代**:圆角和间距更加协调 - -### 交互体验 -- **无滚动**:用户无需滚动即可看到所有内容 -- **快速填写**:表单元素紧凑,填写更高效 -- **移动友好**:在手机上也能良好显示 - -### 功能完整性 -- ✅ 保持所有原有功能 -- ✅ 图形验证码正常工作 -- ✅ 邮箱验证码流程完整 -- ✅ 表单验证逻辑不变 - -## 🎨 设计原则 - -1. **简洁性**:去除不必要的装饰和间距 -2. **功能性**:保持所有功能完整可用 -3. **可读性**:确保文字清晰易读 -4. **一致性**:保持设计风格统一 -5. **响应性**:适配不同屏幕尺寸 - -## 📏 尺寸对比 - -| 元素 | 优化前 | 优化后 | 节省空间 | -|------|--------|--------|----------| -| 容器内边距 | 2rem | 1.2rem | 40% | -| 表单间距 | 1rem | 0.8rem | 20% | -| 输入框内边距 | 12px 15px | 8px 12px | 33% | -| 按钮内边距 | 12px | 10px | 17% | -| 验证码高度 | 38px | 32px | 16% | - -## 🔍 测试建议 - -1. **不同分辨率测试** - - 1920x1080 (桌面) - - 1366x768 (笔记本) - - 375x667 (手机) - -2. **不同浏览器测试** - - Chrome - - Firefox - - Safari - - Edge - -3. **功能完整性测试** - - 图形验证码生成和验证 - - 邮箱验证码发送 - - 表单提交和验证 - - 错误提示显示 - -## 🎉 优化成果 - -- **✅ 一屏显示**:消除了垂直滚动条 -- **✅ 美观紧凑**:保持了视觉美感 -- **✅ 功能完整**:所有功能正常工作 -- **✅ 响应式**:适配各种屏幕尺寸 -- **✅ 用户友好**:提升了整体用户体验 - -现在用户可以在一个屏幕内完成整个注册流程,无需滚动,大大提升了用户体验! diff --git a/SYSTEM_IMPROVEMENTS_SUMMARY.md b/SYSTEM_IMPROVEMENTS_SUMMARY.md deleted file mode 100644 index 930f8eb..0000000 --- a/SYSTEM_IMPROVEMENTS_SUMMARY.md +++ /dev/null @@ -1,298 +0,0 @@ -# 系统改进功能总结 - -## 🎯 改进概述 - -根据用户需求,对闲鱼自动回复系统进行了三项重要改进,提升了管理员的使用体验和数据管理能力。 - -## ✅ 已完成的改进 - -### 1. 📋 日志界面优化 - -#### 改进内容 -- **最新日志置顶**:日志默认最新的显示在最上面 -- **自动滚动调整**:页面加载后自动滚动到顶部显示最新日志 - -#### 技术实现 -```javascript -// 反转日志数组,让最新的日志显示在最上面 -const reversedLogs = [...logs].reverse(); - -// 自动滚动到顶部(显示最新日志) -scrollToTop(); -``` - -#### 用户体验提升 -- ✅ 最新日志一目了然 -- ✅ 无需手动滚动查看最新信息 -- ✅ 符合用户查看习惯 - -### 2. 🗂️ 系统管理简化 - -#### 改进内容 -- **删除JSON格式备份**:移除了兼容模式的JSON备份功能 -- **保留数据库模式**:只保留更高效的数据库文件备份 -- **界面简化**:备份管理界面更加简洁明了 - -#### 删除的功能 -- ❌ JSON格式备份导出 -- ❌ JSON格式备份导入 -- ❌ 相关的JavaScript函数 - -#### 保留的功能 -- ✅ 数据库文件直接下载 -- ✅ 数据库文件直接上传恢复 -- ✅ 备份文件列表查询 - -#### 优势对比 -| 特性 | 数据库备份 | JSON备份(已删除) | -|------|------------|-------------------| -| 备份速度 | ⚡ 极快 | 🐌 较慢 | -| 文件大小 | 📦 最小 | 📦 较大 | -| 恢复速度 | ⚡ 极快 | 🐌 较慢 | -| 操作复杂度 | 🟢 简单 | 🟡 复杂 | - -### 3. 🗄️ 数据管理功能(全新) - -#### 功能概述 -新增了完整的数据管理功能,允许管理员查看和管理数据库中的所有表数据。 - -#### 主要特性 - -##### 📊 表数据查看 -- **表选择器**:下拉框显示所有数据表及中文含义 -- **数据展示**:表格形式显示所有记录 -- **列信息**:自动获取表结构和列名 -- **记录统计**:实时显示记录数量 - -##### 🗑️ 数据删除功能 -- **单条删除**:支持删除指定记录 -- **批量清空**:支持清空整个表(除用户表外) -- **确认机制**:危险操作需要二次确认 -- **权限保护**:不能删除管理员自己 - -##### 🔒 安全机制 -- **权限验证**:只有admin用户可以访问 -- **表名验证**:只允许操作预定义的安全表 -- **管理员保护**:不能删除管理员用户 -- **操作日志**:所有操作都有详细日志 - -#### 支持的数据表 - -| 表名 | 中文含义 | 支持操作 | -|------|----------|----------| -| users | 用户表 | 查看、删除(除管理员) | -| cookies | Cookie账号表 | 查看、删除、清空 | -| keywords | 关键字表 | 查看、删除、清空 | -| default_replies | 默认回复表 | 查看、删除、清空 | -| ai_reply_settings | AI回复设置表 | 查看、删除、清空 | -| message_notifications | 消息通知表 | 查看、删除、清空 | -| cards | 卡券表 | 查看、删除、清空 | -| delivery_rules | 发货规则表 | 查看、删除、清空 | -| notification_channels | 通知渠道表 | 查看、删除、清空 | -| user_settings | 用户设置表 | 查看、删除、清空 | -| email_verifications | 邮箱验证表 | 查看、删除、清空 | -| captcha_codes | 验证码表 | 查看、删除、清空 | - -#### 界面设计 - -``` -┌─────────────────────────────────────────────────────────────┐ -│ 数据管理 │ -├─────────────────────────┬───────────────────────────────────┤ -│ 选择数据表 │ 数据统计 │ -│ │ │ -│ [下拉框选择表] │ [记录数显示] [刷新按钮] │ -└─────────────────────────┴───────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────┐ -│ 数据内容 │ -│ ┌─────┬─────────┬─────────┬─────────┬─────────┬─────────┐ │ -│ │ ID │ 字段1 │ 字段2 │ 字段3 │ 字段4 │ 操作 │ │ -│ ├─────┼─────────┼─────────┼─────────┼─────────┼─────────┤ │ -│ │ 1 │ 数据1 │ 数据2 │ 数据3 │ 数据4 │ [删除] │ │ -│ │ 2 │ 数据1 │ 数据2 │ 数据3 │ 数据4 │ [删除] │ │ -│ └─────┴─────────┴─────────┴─────────┴─────────┴─────────┘ │ -└─────────────────────────────────────────────────────────────┘ -``` - -## 🔧 技术实现 - -### 后端API接口 - -#### 数据管理API -```python -# 获取表数据 -GET /admin/data/{table_name} - -# 删除单条记录 -DELETE /admin/data/{table_name}/{record_id} - -# 清空表数据 -DELETE /admin/data/{table_name} -``` - -#### 数据库方法 -```python -# 获取表数据和结构 -def get_table_data(self, table_name: str) - -# 删除指定记录 -def delete_table_record(self, table_name: str, record_id: str) - -# 清空表数据 -def clear_table_data(self, table_name: str) -``` - -### 前端实现 - -#### 页面路由 -- `/data_management.html` - 数据管理页面 - -#### 核心功能 -```javascript -// 加载表数据 -function loadTableData() - -// 显示表数据 -function displayTableData(data, columns) - -// 删除记录 -function deleteRecord(record, index) - -// 清空表数据 -function confirmDeleteAll() -``` - -## 🎨 用户界面 - -### 管理员菜单更新 -在主页侧边栏的管理员功能区域新增: -``` -管理员功能 -├── 用户管理 -├── 系统日志 -└── 数据管理 ← 新增 -``` - -### 数据管理页面特性 -- **响应式设计**:适配各种屏幕尺寸 -- **表格滚动**:支持大量数据的滚动查看 -- **固定表头**:滚动时表头保持可见 -- **操作确认**:删除操作有确认对话框 -- **实时统计**:动态显示记录数量 - -## 🛡️ 安全特性 - -### 权限控制 -- **管理员专用**:所有功能只有admin用户可以访问 -- **前端验证**:页面加载时验证用户身份 -- **后端验证**:API接口严格验证管理员权限 - -### 数据保护 -- **表名白名单**:只允许操作预定义的安全表 -- **管理员保护**:不能删除管理员用户记录 -- **操作确认**:危险操作需要用户确认 -- **详细日志**:所有操作都有完整的日志记录 - -### 错误处理 -- **异常捕获**:完善的错误处理机制 -- **用户提示**:清晰的成功/失败提示 -- **数据回滚**:失败时自动回滚操作 - -## 💡 使用方法 - -### 访问数据管理 -1. 使用admin账号登录系统 -2. 在主页侧边栏点击"数据管理" -3. 进入数据管理页面 - -### 查看表数据 -1. 在下拉框中选择要查看的数据表 -2. 系统自动加载并显示表数据 -3. 查看记录数量和表信息 - -### 删除数据 -1. 点击记录行的"删除"按钮 -2. 在确认对话框中查看记录详情 -3. 确认删除操作 - -### 清空表数据 -1. 选择要清空的表 -2. 点击"清空表"按钮 -3. 确认清空操作(不可恢复) - -## 🎯 应用场景 - -### 1. 数据维护 -- 清理测试数据 -- 删除无效记录 -- 维护数据质量 - -### 2. 问题排查 -- 查看具体数据内容 -- 分析数据异常 -- 验证数据完整性 - -### 3. 系统管理 -- 监控数据增长 -- 管理用户数据 -- 清理过期信息 - -### 4. 开发调试 -- 查看数据结构 -- 验证功能效果 -- 测试数据操作 - -## 📊 改进效果 - -### 用户体验提升 -- ✅ 日志查看更直观(最新在上) -- ✅ 备份操作更简单(只保留数据库模式) -- ✅ 数据管理更方便(可视化操作) - -### 管理效率提升 -- ✅ 快速查看任意表数据 -- ✅ 便捷删除无效记录 -- ✅ 直观的数据统计信息 - -### 系统维护能力增强 -- ✅ 完整的数据管理功能 -- ✅ 安全的操作权限控制 -- ✅ 详细的操作日志记录 - -## 🚀 部署说明 - -### 立即可用 -- 重启服务后所有功能立即生效 -- 无需额外配置 -- 兼容现有数据 - -### 访问方式 -``` -数据管理: http://your-domain/data_management.html -日志管理: http://your-domain/log_management.html -用户管理: http://your-domain/user_management.html -``` - -### 权限要求 -- 只有username为'admin'的用户可以访问 -- 其他用户访问会自动跳转到首页 - -## 🎉 总结 - -通过本次改进,闲鱼自动回复系统现在具备了: - -### ✅ 主要成就 -1. **优化的日志体验**:最新日志优先显示 -2. **简化的备份管理**:只保留最高效的数据库备份 -3. **强大的数据管理**:可视化的数据库表管理功能 -4. **完善的权限控制**:严格的管理员权限验证 -5. **安全的操作机制**:完善的确认和保护机制 - -### 🎯 实用价值 -- **提升效率**:管理员操作更加便捷高效 -- **增强安全**:严格的权限控制和操作保护 -- **便于维护**:直观的数据管理和日志查看 -- **优化体验**:符合用户习惯的界面设计 - -现在您的多用户闲鱼自动回复系统具备了更加完善的管理功能!🎊 diff --git a/TOKEN_FIX_SUMMARY.md b/TOKEN_FIX_SUMMARY.md deleted file mode 100644 index 9184741..0000000 --- a/TOKEN_FIX_SUMMARY.md +++ /dev/null @@ -1,192 +0,0 @@ -# Token认证问题修复总结 - -## 🎯 问题描述 - -用户反馈:管理员页面可以访问,但是点击功能时提示"未登录",API调用返回401未授权错误。 - -## 🔍 问题分析 - -通过日志分析发现: -``` -INFO: 127.0.0.1:63674 - "GET /admin/users HTTP/1.1" 401 Unauthorized -INFO: 【未登录】 API响应: GET /admin/users - 401 (0.003s) -``` - -问题根源:**Token存储key不一致** - -### 🔧 具体问题 - -1. **登录页面** (`login.html`) 设置token: - ```javascript - localStorage.setItem('auth_token', result.token); - ``` - -2. **主页面** (`index.html`) 读取token: - ```javascript - let authToken = localStorage.getItem('auth_token'); - ``` - -3. **管理员页面** (`user_management.html`, `log_management.html`) 读取token: - ```javascript - const token = localStorage.getItem('token'); // ❌ 错误的key - ``` - -## ✅ 修复方案 - -### 统一Token存储Key - -将所有管理员页面的token读取统一为 `auth_token`: - -#### 1. 用户管理页面修复 -```javascript -// 修复前 -const token = localStorage.getItem('token'); - -// 修复后 -const token = localStorage.getItem('auth_token'); -``` - -修复的函数: -- `checkAdminPermission()` -- `loadSystemStats()` -- `loadUsers()` -- `confirmDeleteUser()` -- `logout()` - -#### 2. 日志管理页面修复 -```javascript -// 修复前 -const token = localStorage.getItem('token'); - -// 修复后 -const token = localStorage.getItem('auth_token'); -``` - -修复的函数: -- `checkAdminPermission()` -- `loadLogs()` -- `logout()` - -### 🔄 修复的文件 - -1. **static/user_management.html** - - 5处token读取修复 - - 1处token删除修复 - -2. **static/log_management.html** - - 3处token读取修复 - - 1处token删除修复 - -## 📊 Token流程图 - -``` -登录页面 (login.html) - ↓ 设置 -localStorage.setItem('auth_token', token) - ↓ 读取 -主页面 (index.html) - ↓ 读取 -管理员页面 (user_management.html, log_management.html) - ↓ 使用 -API调用 (Authorization: Bearer token) -``` - -## 🧪 验证方法 - -### 1. 手动验证 -1. 使用admin账号登录主页 -2. 点击侧边栏"用户管理" -3. 页面应该正常加载用户列表和统计信息 -4. 点击侧边栏"系统日志" -5. 页面应该正常显示系统日志 - -### 2. 开发者工具验证 -1. 打开浏览器开发者工具 -2. 查看 Application → Local Storage -3. 确认存在 `auth_token` 项 -4. 查看 Network 标签页 -5. API请求应该返回200状态码 - -### 3. 日志验证 -服务器日志应该显示: -``` -INFO: 【admin#1】 API请求: GET /admin/users -INFO: 【admin#1】 API响应: GET /admin/users - 200 (0.005s) -``` - -## 🎯 修复效果 - -### 修复前 -- ❌ 管理员页面API调用401错误 -- ❌ 日志显示"未登录"用户访问 -- ❌ 用户管理功能无法使用 -- ❌ 日志管理功能无法使用 - -### 修复后 -- ✅ 管理员页面API调用正常 -- ✅ 日志显示正确的用户信息 -- ✅ 用户管理功能完全可用 -- ✅ 日志管理功能完全可用 - -## 🔒 安全验证 - -修复后的安全机制: - -1. **Token验证**:所有管理员API都需要有效token -2. **权限检查**:只有admin用户可以访问管理员功能 -3. **自动跳转**:无效token自动跳转到登录页 -4. **统一认证**:所有页面使用相同的认证机制 - -## 💡 最佳实践 - -### 1. Token管理规范 -- 使用统一的token存储key -- 在所有页面保持一致的token读取方式 -- 及时清理过期或无效的token - -### 2. 错误处理 -- API调用失败时提供明确的错误信息 -- 401错误自动跳转到登录页 -- 403错误提示权限不足 - -### 3. 用户体验 -- 登录状态持久化 -- 页面间无缝跳转 -- 清晰的权限提示 - -## 🚀 部署说明 - -### 立即生效 -修复后无需重启服务器,刷新页面即可生效。 - -### 用户操作 -1. 如果当前已登录,刷新管理员页面即可 -2. 如果遇到问题,重新登录即可 -3. 确保使用admin账号访问管理员功能 - -## 📋 测试清单 - -- [ ] admin用户可以正常登录 -- [ ] 主页侧边栏显示管理员菜单 -- [ ] 用户管理页面正常加载 -- [ ] 用户管理功能正常工作 -- [ ] 日志管理页面正常加载 -- [ ] 日志管理功能正常工作 -- [ ] 非admin用户无法访问管理员功能 -- [ ] 无效token被正确拒绝 - -## 🎉 总结 - -通过统一token存储key,成功修复了管理员页面的认证问题: - -### 核心改进 -- **统一认证**:所有页面使用相同的token key (`auth_token`) -- **完整功能**:用户管理和日志管理功能完全可用 -- **安全保障**:权限验证和错误处理机制完善 - -### 用户体验 -- **无缝使用**:登录后可以直接使用所有管理员功能 -- **清晰反馈**:错误信息明确,操作结果及时反馈 -- **安全可靠**:严格的权限控制和认证机制 - -现在管理员功能已经完全正常工作,可以安全地管理用户和监控系统日志!🎊 diff --git a/UI_IMPROVEMENTS.md b/UI_IMPROVEMENTS.md deleted file mode 100644 index 1ac59fc..0000000 --- a/UI_IMPROVEMENTS.md +++ /dev/null @@ -1,92 +0,0 @@ -# 🎨 界面优化总结 - -## ✨ 主要改进 - -### 1. 🎯 Cookie显示优化 -- **❌ 修改前**: Cookie值被隐藏为星号 (`****`) -- **✅ 修改后**: 显示完整的Cookie内容,便于查看和调试 - -### 2. 🎨 视觉设计升级 -- **现代化配色方案**: 使用更现代的紫色主题 (`#4f46e5`) -- **渐变背景**: 美丽的渐变背景和卡片效果 -- **毛玻璃效果**: 卡片使用 `backdrop-filter: blur()` 实现毛玻璃效果 -- **阴影和动画**: 悬停时的阴影和位移动画效果 - -### 3. 🔧 功能增强 -- **一键复制Cookie**: 点击Cookie值或复制按钮即可复制到剪贴板 -- **改进的按钮组**: 更紧凑的按钮布局,包含复制功能 -- **更好的空状态**: 当没有账号时显示更友好的提示 - -### 4. 📱 响应式设计 -- **移动端优化**: 在小屏幕上按钮垂直排列 -- **自适应布局**: 表格和卡片在不同屏幕尺寸下的自适应 - -## 🛠️ 技术改进 - -### 新增API接口 -```javascript -GET /cookies/details -``` -返回包含Cookie ID和完整值的详细信息,而不仅仅是ID列表。 - -### CSS样式优化 -- **CSS变量**: 统一的颜色管理 -- **现代字体**: 使用 Inter 字体提升可读性 -- **代码字体**: Cookie值使用等宽字体 (JetBrains Mono) -- **流畅动画**: 所有交互都有平滑的过渡效果 - -### JavaScript功能增强 -- **复制功能**: 支持现代浏览器的 Clipboard API -- **降级方案**: 对于不支持的浏览器提供传统复制方法 -- **用户反馈**: 复制成功/失败的Toast提示 - -## 🎯 用户体验提升 - -### 1. Cookie管理 -- **完整显示**: 不再隐藏Cookie内容,便于调试 -- **一键复制**: 快速复制Cookie值到剪贴板 -- **格式化显示**: 使用等宽字体和适当的行高 - -### 2. 视觉反馈 -- **悬停效果**: 所有可交互元素都有悬停反馈 -- **状态指示**: 清晰的按钮状态和颜色区分 -- **加载动画**: 优雅的加载状态显示 - -### 3. 操作便利性 -- **按钮分组**: 相关操作按钮紧凑排列 -- **图标提示**: 每个按钮都有清晰的图标和提示 -- **确认对话框**: 危险操作有确认提示 - -## 📊 界面对比 - -| 功能 | 修改前 | 修改后 | -|------|--------|--------| -| Cookie显示 | 隐藏为星号 | 完整显示 | -| 复制功能 | 无 | 一键复制 | -| 视觉效果 | 基础Bootstrap | 现代化渐变设计 | -| 响应式 | 基本支持 | 完全优化 | -| 用户反馈 | 基础提示 | 丰富的Toast反馈 | - -## 🚀 使用说明 - -### 访问界面 -1. 启动系统: `python Start.py` -2. 打开浏览器: `http://localhost:8080` -3. 登录: `admin` / `admin123` - -### 主要功能 -- **添加账号**: 在顶部表单中输入账号ID和Cookie值 -- **查看Cookie**: 完整的Cookie值显示在表格中 -- **复制Cookie**: 点击Cookie值或复制按钮 -- **管理关键词**: 点击关键词按钮设置自动回复 -- **删除账号**: 点击删除按钮(有确认提示) - -## 🎉 总结 - -这次界面优化大幅提升了用户体验: -- ✅ **Cookie不再隐藏**,便于查看和调试 -- ✅ **现代化设计**,视觉效果更佳 -- ✅ **功能增强**,操作更便利 -- ✅ **响应式优化**,支持各种设备 - -界面现在更加美观、实用和用户友好!🎨✨ diff --git a/USER_LOGGING_IMPROVEMENT.md b/USER_LOGGING_IMPROVEMENT.md deleted file mode 100644 index 26baac1..0000000 --- a/USER_LOGGING_IMPROVEMENT.md +++ /dev/null @@ -1,249 +0,0 @@ -# 用户日志显示改进总结 - -## 🎯 改进目标 - -在多用户系统中,原有的日志无法识别具体的操作用户,导致调试和监控困难。本次改进为所有系统日志添加了当前登录用户的信息。 - -## 📊 改进内容 - -### 1. API请求/响应日志增强 - -#### 改进前 -``` -2025-07-25 15:40:28.714 | INFO | reply_server:log_requests:223 - 🌐 API请求: GET /keywords/执念小店70 -2025-07-25 15:40:28.725 | INFO | reply_server:log_requests:228 - ✅ API响应: GET /keywords/执念小店70 - 200 (0.011s) -``` - -#### 改进后 -``` -2025-07-25 15:40:28.714 | INFO | reply_server:log_requests:223 - 🌐 【admin#1】 API请求: GET /keywords/执念小店70 -2025-07-25 15:40:28.725 | INFO | reply_server:log_requests:228 - ✅ 【admin#1】 API响应: GET /keywords/执念小店70 - 200 (0.011s) -``` - -### 2. 业务操作日志增强 - -#### 用户认证相关 -- ✅ 登录尝试: `【username】尝试登录` -- ✅ 登录成功: `【username#user_id】登录成功` -- ✅ 登录失败: `【username】登录失败: 用户名或密码错误` -- ✅ 注册操作: `【username】尝试注册,邮箱: email` - -#### Cookie管理相关 -- ✅ 添加Cookie: `【username#user_id】尝试添加Cookie: cookie_id` -- ✅ 操作成功: `【username#user_id】Cookie添加成功: cookie_id` -- ✅ 权限冲突: `【username#user_id】Cookie ID冲突: cookie_id 已被其他用户使用` - -#### 卡券管理相关 -- ✅ 创建卡券: `【username#user_id】创建卡券: card_name` -- ✅ 创建成功: `【username#user_id】卡券创建成功: card_name (ID: card_id)` -- ✅ 创建失败: `【username#user_id】创建卡券失败: card_name - error` - -#### 关键字管理相关 -- ✅ 更新关键字: `【username#user_id】更新Cookie关键字: cookie_id, 数量: count` -- ✅ 权限验证: `【username#user_id】尝试操作其他用户的Cookie关键字: cookie_id` - -#### 用户设置相关 -- ✅ 设置更新: `【username#user_id】更新用户设置: key = value` -- ✅ 更新成功: `【username#user_id】用户设置更新成功: key` - -## 🔧 技术实现 - -### 1. 中间件增强 -```python -@app.middleware("http") -async def log_requests(request, call_next): - # 获取用户信息 - user_info = "未登录" - try: - auth_header = request.headers.get("Authorization") - if auth_header and auth_header.startswith("Bearer "): - token = auth_header.split(" ")[1] - if token in SESSION_TOKENS: - token_data = SESSION_TOKENS[token] - if time.time() - token_data['timestamp'] <= TOKEN_EXPIRE_TIME: - user_info = f"【{token_data['username']}#{token_data['user_id']}】" - except Exception: - pass - - logger.info(f"🌐 {user_info} API请求: {request.method} {request.url.path}") - # ... -``` - -### 2. 统一日志工具函数 -```python -def get_user_log_prefix(user_info: Dict[str, Any] = None) -> str: - """获取用户日志前缀""" - if user_info: - return f"【{user_info['username']}#{user_info['user_id']}】" - return "【系统】" - -def log_with_user(level: str, message: str, user_info: Dict[str, Any] = None): - """带用户信息的日志记录""" - prefix = get_user_log_prefix(user_info) - full_message = f"{prefix} {message}" - - if level.lower() == 'info': - logger.info(full_message) - elif level.lower() == 'error': - logger.error(full_message) - # ... -``` - -### 3. 业务接口改进 -```python -@app.post("/cookies") -def add_cookie(item: CookieIn, current_user: Dict[str, Any] = Depends(get_current_user)): - try: - log_with_user('info', f"尝试添加Cookie: {item.id}", current_user) - # 业务逻辑... - log_with_user('info', f"Cookie添加成功: {item.id}", current_user) - except Exception as e: - log_with_user('error', f"添加Cookie失败: {item.id} - {str(e)}", current_user) -``` - -## 📋 修改的文件和接口 - -### reply_server.py -- **中间件**: `log_requests` - API请求/响应日志 -- **工具函数**: `get_user_log_prefix`, `log_with_user` -- **认证接口**: 登录、注册接口 -- **业务接口**: Cookie管理、卡券管理、关键字管理、用户设置 - -### 修改的接口数量 -- **API中间件**: 1个(影响所有接口) -- **认证相关**: 2个接口(登录、注册) -- **Cookie管理**: 1个接口(添加Cookie) -- **卡券管理**: 1个接口(创建卡券) -- **关键字管理**: 1个接口(更新关键字) -- **用户设置**: 1个接口(更新设置) - -## 🎯 日志格式规范 - -### 用户标识格式 -- **已登录用户**: `【username#user_id】` -- **未登录用户**: `【未登录】` -- **系统操作**: `【系统】` - -### 日志级别使用 -- **INFO**: 正常操作、成功操作 -- **WARNING**: 权限验证失败、业务规则冲突 -- **ERROR**: 系统错误、操作失败 - -### 消息格式 -- **操作尝试**: `尝试{操作}: {对象}` -- **操作成功**: `{操作}成功: {对象}` -- **操作失败**: `{操作}失败: {对象} - {原因}` - -## 💡 日志分析技巧 - -### 1. 按用户过滤 -```bash -# 查看特定用户的所有操作 -grep '【admin#1】' logs/xianyu_2025-07-25.log - -# 查看特定用户的API请求 -grep '【admin#1】.*API请求' logs/xianyu_2025-07-25.log -``` - -### 2. 监控用户活动 -```bash -# 统计用户活跃度 -grep -o '【[^】]*#[^】]*】' logs/xianyu_2025-07-25.log | sort | uniq -c - -# 查看登录活动 -grep '登录' logs/xianyu_2025-07-25.log -``` - -### 3. 权限验证监控 -```bash -# 查看权限验证失败 -grep '无权限\|权限验证失败' logs/xianyu_2025-07-25.log - -# 查看跨用户访问尝试 -grep '尝试操作其他用户' logs/xianyu_2025-07-25.log -``` - -### 4. 错误追踪 -```bash -# 查看特定用户的错误 -grep 'ERROR.*【admin#1】' logs/xianyu_2025-07-25.log - -# 查看操作失败 -grep '失败.*【.*】' logs/xianyu_2025-07-25.log -``` - -## 🔍 监控指标建议 - -### 1. 用户活跃度指标 -- 每个用户的API调用频率 -- 用户登录频率和时长 -- 用户操作成功率 - -### 2. 安全监控指标 -- 登录失败次数(按用户) -- 权限验证失败次数 -- 跨用户访问尝试次数 - -### 3. 业务监控指标 -- Cookie操作频率(按用户) -- 卡券创建和使用情况 -- 用户设置修改频率 - -## 🚀 部署和使用 - -### 1. 立即生效 -重启服务后,新的日志格式立即生效: -```bash -# 重启服务 -docker-compose restart - -# 查看新的日志格式 -docker-compose logs -f | grep '【.*】' -``` - -### 2. 日志轮转配置 -确保日志轮转能够处理增加的日志内容: -```python -# loguru配置 -logger.add( - "logs/xianyu_{time:YYYY-MM-DD}.log", - rotation="100 MB", - retention="7 days", - compression="zip", - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {name}:{function}:{line} - {message}" -) -``` - -### 3. 监控工具集成 -如果使用ELK、Grafana等监控工具,可以基于用户标识创建仪表板: -- 按用户分组的操作统计 -- 用户行为分析 -- 安全事件监控 - -## 🎉 改进效果 - -### 1. 调试效率提升 -- **问题定位**: 快速定位特定用户的问题 -- **操作追踪**: 完整的用户操作链路追踪 -- **权限验证**: 清晰的权限验证日志 - -### 2. 监控能力增强 -- **用户行为**: 详细的用户行为分析 -- **安全监控**: 实时的安全事件监控 -- **性能分析**: 按用户维度的性能分析 - -### 3. 运维管理优化 -- **故障排查**: 快速定位用户相关问题 -- **容量规划**: 基于用户活跃度的容量规划 -- **安全审计**: 完整的用户操作审计日志 - -## 📞 使用建议 - -1. **日志查看**: 使用 `grep` 命令按用户过滤日志 -2. **实时监控**: 使用 `tail -f` 实时监控特定用户操作 -3. **定期分析**: 定期分析用户活跃度和操作模式 -4. **安全审计**: 定期检查权限验证失败和异常操作 - ---- - -**总结**: 通过本次改进,多用户系统的日志现在具备了完整的用户标识能力,大大提升了系统的可观测性和可维护性! diff --git a/XianyuAutoAsync.py b/XianyuAutoAsync.py index 6f62b45..cb03407 100644 --- a/XianyuAutoAsync.py +++ b/XianyuAutoAsync.py @@ -86,6 +86,10 @@ class XianyuLive: # 通知防重复机制 self.last_notification_time = {} # 记录每种通知类型的最后发送时间 self.notification_cooldown = 300 # 5分钟内不重复发送相同类型的通知 + + # 自动发货防重复机制 + self.last_delivery_time = {} # 记录每个商品的最后发货时间 + self.delivery_cooldown = 60 # 1分钟内不重复发货 # 人工接管功能已禁用,永远走自动模式 # self.manual_mode_conversations = set() # 存储处于人工接管模式的会话ID @@ -94,6 +98,22 @@ class XianyuLive: # self.toggle_keywords = MANUAL_MODE.get('toggle_keywords', ['。']) # 切换关键词 self.session = None # 用于API调用的aiohttp session + def can_auto_delivery(self, item_id: str) -> bool: + """检查是否可以进行自动发货(防重复发货)""" + current_time = time.time() + last_delivery = self.last_delivery_time.get(item_id, 0) + + if current_time - last_delivery < self.delivery_cooldown: + logger.info(f"【{self.cookie_id}】商品 {item_id} 在冷却期内,跳过自动发货") + return False + + return True + + def mark_delivery_sent(self, item_id: str): + """标记商品已发货""" + self.last_delivery_time[item_id] = time.time() + logger.debug(f"【{self.cookie_id}】标记商品 {item_id} 已发货") + # 人工接管功能已禁用,以下方法不再使用 # def check_toggle_keywords(self, message): # """检查消息是否包含切换关键词""" @@ -1147,10 +1167,13 @@ class XianyuLive: delivery_content = db_manager.consume_batch_data(rule['card_id']) if delivery_content: + # 处理备注信息和变量替换 + final_content = self._process_delivery_content_with_description(delivery_content, rule.get('card_description', '')) + # 增加发货次数统计 db_manager.increment_delivery_times(rule['id']) - logger.info(f"自动发货成功: 规则ID={rule['id']}, 内容长度={len(delivery_content)}") - return delivery_content + logger.info(f"自动发货成功: 规则ID={rule['id']}, 内容长度={len(final_content)}") + return final_content else: logger.warning(f"获取发货内容失败: 规则ID={rule['id']}") return None @@ -1159,6 +1182,28 @@ class XianyuLive: logger.error(f"自动发货失败: {self._safe_str(e)}") return None + def _process_delivery_content_with_description(self, delivery_content: str, card_description: str) -> str: + """处理发货内容和备注信息,实现变量替换""" + try: + # 如果没有备注信息,直接返回发货内容 + if not card_description or not card_description.strip(): + return delivery_content + + # 替换备注中的变量 + processed_description = card_description.replace('{DELIVERY_CONTENT}', delivery_content) + + # 如果备注中包含变量替换,返回处理后的备注 + if '{DELIVERY_CONTENT}' in card_description: + return processed_description + else: + # 如果备注中没有变量,将备注和发货内容组合 + return f"{processed_description}\n\n{delivery_content}" + + except Exception as e: + logger.error(f"处理备注信息失败: {e}") + # 出错时返回原始发货内容 + return delivery_content + async def _get_api_card_content(self, rule, retry_count=0): """调用API获取卡券内容,支持重试机制""" max_retries = 3 @@ -1855,9 +1900,28 @@ class XianyuLive: elif send_message == '[你关闭了订单,钱款已原路退返]': logger.info(f'[{msg_time}] 【{self.cookie_id}】系统消息不处理') return + elif send_message == '发来一条消息': + logger.info(f'[{msg_time}] 【{self.cookie_id}】系统通知消息不处理') + return + elif send_message == '发来一条新消息': + logger.info(f'[{msg_time}] 【{self.cookie_id}】系统通知消息不处理') + return + elif send_message == '[买家确认收货,交易成功]': + logger.info(f'[{msg_time}] 【{self.cookie_id}】交易完成消息不处理') + return + elif send_message == '快给ta一个评价吧~' or send_message == '快给ta一个评价吧~': + logger.info(f'[{msg_time}] 【{self.cookie_id}】评价提醒消息不处理') + return + elif send_message == '[你已发货]': + logger.info(f'[{msg_time}] 【{self.cookie_id}】发货确认消息不处理') + return elif send_message == '[我已付款,等待你发货]': logger.info(f'[{msg_time}] 【{self.cookie_id}】【系统】买家已付款,准备自动发货') + # 检查是否可以进行自动发货(防重复) + if not self.can_auto_delivery(item_id): + return + # 构造用户URL user_url = f'https://www.goofish.com/personal?userId={send_user_id}' @@ -1872,6 +1936,9 @@ class XianyuLive: delivery_content = await self._auto_delivery(item_id, item_title) if delivery_content: + # 标记已发货(防重复) + self.mark_delivery_sent(item_id) + # 发送发货内容给买家 await self.send_msg(websocket, chat_id, send_user_id, delivery_content) logger.info(f'[{msg_time}] 【自动发货】已向 {user_url} 发送发货内容') @@ -1890,6 +1957,10 @@ class XianyuLive: elif send_message == '[已付款,待发货]': logger.info(f'[{msg_time}] 【{self.cookie_id}】【系统】买家已付款,准备自动发货') + # 检查是否可以进行自动发货(防重复) + if not self.can_auto_delivery(item_id): + return + # 构造用户URL user_url = f'https://www.goofish.com/personal?userId={send_user_id}' @@ -1904,6 +1975,9 @@ class XianyuLive: delivery_content = await self._auto_delivery(item_id, item_title) if delivery_content: + # 标记已发货(防重复) + self.mark_delivery_sent(item_id) + # 发送发货内容给买家 await self.send_msg(websocket, chat_id, send_user_id, delivery_content) logger.info(f'[{msg_time}] 【自动发货】已向 {user_url} 发送发货内容') @@ -1919,6 +1993,76 @@ class XianyuLive: await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, f"自动发货处理异常: {str(e)}") return + elif send_message == '[卡片消息]': + # 检查是否为"我已小刀,待刀成"的卡片消息 + try: + # 从消息中提取卡片内容 + card_title = None + if isinstance(message, dict) and "1" in message and isinstance(message["1"], dict): + message_1 = message["1"] + if "6" in message_1 and isinstance(message_1["6"], dict): + message_6 = message_1["6"] + if "3" in message_6 and isinstance(message_6["3"], dict): + message_6_3 = message_6["3"] + if "5" in message_6_3: + # 解析JSON内容 + try: + card_content = json.loads(message_6_3["5"]) + if "dxCard" in card_content and "item" in card_content["dxCard"]: + card_item = card_content["dxCard"]["item"] + if "main" in card_item and "exContent" in card_item["main"]: + ex_content = card_item["main"]["exContent"] + card_title = ex_content.get("title", "") + except (json.JSONDecodeError, KeyError) as e: + logger.debug(f"解析卡片消息失败: {e}") + + # 检查是否为"我已小刀,待刀成" + if card_title == "我已小刀,待刀成": + logger.info(f'[{msg_time}] 【{self.cookie_id}】【系统】检测到"我已小刀,待刀成",准备自动发货') + + # 检查是否可以进行自动发货(防重复) + if not self.can_auto_delivery(item_id): + return + + # 构造用户URL + user_url = f'https://www.goofish.com/personal?userId={send_user_id}' + + # 自动发货逻辑 + try: + # 设置默认标题(将通过API获取真实商品信息) + item_title = "待获取商品信息" + + logger.info(f"【{self.cookie_id}】准备自动发货: item_id={item_id}, item_title={item_title}") + + # 调用自动发货方法 + delivery_content = await self._auto_delivery(item_id, item_title) + + if delivery_content: + # 标记已发货(防重复) + self.mark_delivery_sent(item_id) + + # 发送发货内容给买家 + await self.send_msg(websocket, chat_id, send_user_id, delivery_content) + logger.info(f'[{msg_time}] 【自动发货】已向 {user_url} 发送发货内容') + await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, "发货成功") + else: + logger.warning(f'[{msg_time}] 【自动发货】未找到匹配的发货规则或获取发货内容失败') + # 发送自动发货失败通知 + await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, "未找到匹配的发货规则或获取发货内容失败") + + except Exception as e: + logger.error(f"自动发货处理异常: {self._safe_str(e)}") + # 发送自动发货异常通知 + await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, f"自动发货处理异常: {str(e)}") + + return + else: + logger.info(f'[{msg_time}] 【{self.cookie_id}】收到卡片消息,标题: {card_title or "未知"}') + + except Exception as e: + logger.error(f"处理卡片消息异常: {self._safe_str(e)}") + + # 如果不是目标卡片消息,继续正常处理流程 # 记录回复来源 reply_source = 'API' # 默认假设是API回复 diff --git a/backup_import_update_summary.md b/backup_import_update_summary.md deleted file mode 100644 index 710ae24..0000000 --- a/backup_import_update_summary.md +++ /dev/null @@ -1,193 +0,0 @@ -# 备份和导入功能更新总结 - -## 📋 更新概述 - -由于系统新增了多个AI相关的数据表,备份和导入功能需要更新以确保所有数据都能正确备份和恢复。 - -## 🔧 更新内容 - -### 1. **新增的表** - -在原有的10个表基础上,新增了3个AI相关表: - -#### 新增表列表: -- `ai_reply_settings` - AI回复配置表 -- `ai_conversations` - AI对话历史表 -- `ai_item_cache` - AI商品信息缓存表 - -### 2. **更新的备份表列表** - -#### 更新前(10个表): -```python -tables = [ - 'cookies', 'keywords', 'cookie_status', 'cards', - 'delivery_rules', 'default_replies', 'notification_channels', - 'message_notifications', 'system_settings', 'item_info' -] -``` - -#### 更新后(13个表): -```python -tables = [ - 'cookies', 'keywords', 'cookie_status', 'cards', - 'delivery_rules', 'default_replies', 'notification_channels', - 'message_notifications', 'system_settings', 'item_info', - 'ai_reply_settings', 'ai_conversations', 'ai_item_cache' -] -``` - -### 3. **更新的删除顺序** - -考虑到外键依赖关系,更新了导入时的表删除顺序: - -#### 更新前: -```python -tables = [ - 'message_notifications', 'notification_channels', 'default_replies', - 'delivery_rules', 'cards', 'item_info', 'cookie_status', 'keywords', 'cookies' -] -``` - -#### 更新后: -```python -tables = [ - 'message_notifications', 'notification_channels', 'default_replies', - 'delivery_rules', 'cards', 'item_info', 'cookie_status', 'keywords', - 'ai_conversations', 'ai_reply_settings', 'ai_item_cache', 'cookies' -] -``` - -### 4. **更新的验证列表** - -导入功能中的表验证列表也相应更新,确保新增的AI表能够正确导入。 - -## ✅ 测试验证结果 - -### 测试环境 -- 数据库表总数:13个 -- 测试数据:包含所有表类型的数据 -- 测试场景:导出、导入、文件操作 - -### 测试结果 -``` -🎉 所有测试通过!备份和导入功能正常! - -✅ 功能验证: - • 所有13个表都包含在备份中 - • 备份导出功能正常 - • 备份导入功能正常 - • 数据完整性保持 - • 文件操作正常 -``` - -### 数据完整性验证 -所有13个表的数据在备份和导入过程中保持完整: - -| 表名 | 数据一致性 | 说明 | -|------|------------|------| -| ai_conversations | ✅ 一致 | AI对话历史 | -| ai_item_cache | ✅ 一致 | AI商品缓存 | -| ai_reply_settings | ✅ 一致 | AI回复配置 | -| cards | ✅ 一致 | 卡券信息 | -| cookie_status | ✅ 一致 | 账号状态 | -| cookies | ✅ 一致 | 账号信息 | -| default_replies | ✅ 一致 | 默认回复 | -| delivery_rules | ✅ 一致 | 发货规则 | -| item_info | ✅ 一致 | 商品信息 | -| keywords | ✅ 一致 | 关键词 | -| message_notifications | ✅ 一致 | 消息通知 | -| notification_channels | ✅ 一致 | 通知渠道 | -| system_settings | ✅ 一致 | 系统设置 | - -## 🎯 功能特性 - -### 1. **完整性保证** -- 包含所有13个数据表 -- 保持外键依赖关系 -- 数据完整性验证 - -### 2. **安全性** -- 事务性操作,确保数据一致性 -- 管理员密码保护(不会被覆盖) -- 错误回滚机制 - -### 3. **兼容性** -- 向后兼容旧版本备份文件 -- 自动跳过不存在的表 -- 版本标识和时间戳 - -### 4. **易用性** -- 一键导出所有数据 -- 一键导入恢复数据 -- 详细的操作日志 - -## 📊 使用方法 - -### 导出备份 -```python -from db_manager import db_manager - -# 导出备份数据 -backup_data = db_manager.export_backup() - -# 保存到文件 -import json -with open('backup.json', 'w', encoding='utf-8') as f: - json.dump(backup_data, f, indent=2, ensure_ascii=False) -``` - -### 导入备份 -```python -from db_manager import db_manager -import json - -# 从文件读取备份 -with open('backup.json', 'r', encoding='utf-8') as f: - backup_data = json.load(f) - -# 导入备份数据 -success = db_manager.import_backup(backup_data) -``` - -## 🔄 升级说明 - -### 对现有用户的影响 -- ✅ **无影响**:现有备份文件仍然可以正常导入 -- ✅ **自动兼容**:系统会自动跳过不存在的新表 -- ✅ **数据安全**:不会丢失任何现有数据 - -### 新功能优势 -- ✅ **AI数据备份**:AI回复配置和对话历史得到保护 -- ✅ **完整备份**:所有功能数据都包含在备份中 -- ✅ **快速恢复**:可以完整恢复所有AI功能设置 - -## 💡 建议 - -### 1. **定期备份** -建议用户定期进行数据备份,特别是在: -- 重要配置更改后 -- 系统升级前 -- 大量数据操作前 - -### 2. **备份验证** -建议在重要操作前验证备份文件的完整性: -- 检查文件大小 -- 验证JSON格式 -- 确认包含所需表 - -### 3. **多重备份** -建议保留多个备份版本: -- 每日自动备份 -- 重要节点手动备份 -- 异地备份存储 - -## 🎉 总结 - -备份和导入功能已成功更新,现在能够: - -1. ✅ **完整备份**所有13个数据表 -2. ✅ **安全导入**保持数据完整性 -3. ✅ **向后兼容**旧版本备份文件 -4. ✅ **AI数据保护**包含所有AI功能数据 - -用户可以放心使用备份和导入功能,所有数据都得到了完整的保护! diff --git a/bargain_demo.py b/bargain_demo.py deleted file mode 100644 index b2c0a36..0000000 --- a/bargain_demo.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env python3 -""" -议价功能演示脚本 -展示AI回复的议价轮数限制功能 -""" - -import asyncio -import sys -import os - -# 添加项目根目录到Python路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from ai_reply_engine import ai_reply_engine -from db_manager import db_manager - -def simulate_bargain_conversation(): - """模拟议价对话流程""" - print("🎭 模拟议价对话流程") - print("=" * 50) - - # 测试参数 - cookie_id = "demo_account_001" - chat_id = "demo_chat_001" - user_id = "customer_001" - item_id = "item_12345" - user_name = "小明" - - # 商品信息 - item_info = { - 'title': 'iPhone 14 Pro 256GB 深空黑色', - 'price': 8999, - 'desc': '全新未拆封,国行正品,支持全国联保' - } - - # 设置AI回复配置(模拟) - ai_settings = { - 'ai_enabled': True, - 'model_name': 'qwen-plus', - 'api_key': 'demo-key', - 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'max_discount_percent': 10, # 最大优惠10% - 'max_discount_amount': 500, # 最大优惠500元 - 'max_bargain_rounds': 3, # 最大议价3轮 - 'custom_prompts': '' - } - - # 保存配置 - db_manager.save_ai_reply_settings(cookie_id, ai_settings) - - # 清理之前的对话 - try: - with db_manager.lock: - cursor = db_manager.conn.cursor() - cursor.execute('DELETE FROM ai_conversations WHERE cookie_id = ? AND chat_id = ?', - (cookie_id, chat_id)) - db_manager.conn.commit() - except: - pass - - print(f"📱 商品信息:") - print(f" 标题: {item_info['title']}") - print(f" 价格: ¥{item_info['price']}") - print(f" 描述: {item_info['desc']}") - - print(f"\n⚙️ 议价设置:") - print(f" 最大议价轮数: {ai_settings['max_bargain_rounds']}") - print(f" 最大优惠百分比: {ai_settings['max_discount_percent']}%") - print(f" 最大优惠金额: ¥{ai_settings['max_discount_amount']}") - - # 模拟议价对话 - bargain_messages = [ - "你好,这个iPhone能便宜点吗?", - "8500能卖吗?", - "8800行不行?最后一次了", - "8700,真的不能再少了" - ] - - print(f"\n💬 议价对话模拟:") - print("-" * 30) - - for i, message in enumerate(bargain_messages, 1): - print(f"\n第{i}轮议价:") - print(f"👤 {user_name}: {message}") - - # 检查当前议价次数 - current_count = ai_reply_engine.get_bargain_count(chat_id, cookie_id) - max_rounds = ai_settings['max_bargain_rounds'] - - print(f"📊 当前议价次数: {current_count}/{max_rounds}") - - # 模拟意图检测为price - intent = "price" - - # 检查是否超出限制 - if current_count >= max_rounds: - print(f"🚫 已达到最大议价轮数限制!") - refuse_reply = "抱歉,这个价格已经是最优惠的了,不能再便宜了哦!" - print(f"🤖 AI回复: {refuse_reply}") - - # 保存对话记录 - ai_reply_engine.save_conversation(chat_id, cookie_id, user_id, item_id, "user", message, intent) - ai_reply_engine.save_conversation(chat_id, cookie_id, user_id, item_id, "assistant", refuse_reply, intent) - - print(f"✋ 议价结束,系统拒绝继续议价") - break - else: - # 模拟AI回复(因为没有真实API密钥,这里手动模拟) - if i == 1: - ai_reply = "您好!这个价格已经很优惠了,最多可以优惠200元,8799元怎么样?" - elif i == 2: - ai_reply = "8500太低了,我们再让一点,8699元,这已经是很大的优惠了!" - elif i == 3: - ai_reply = "好的,看您很有诚意,8799元成交,这真的是最低价了!" - else: - ai_reply = "抱歉,这个价格已经是最优惠的了!" - - print(f"🤖 AI回复: {ai_reply}") - - # 保存对话记录 - ai_reply_engine.save_conversation(chat_id, cookie_id, user_id, item_id, "user", message, intent) - ai_reply_engine.save_conversation(chat_id, cookie_id, user_id, item_id, "assistant", ai_reply, intent) - - # 显示最终统计 - print(f"\n📈 最终统计:") - final_count = ai_reply_engine.get_bargain_count(chat_id, cookie_id) - print(f" 总议价轮数: {final_count}") - print(f" 最大允许轮数: {max_rounds}") - print(f" 是否达到限制: {'是' if final_count >= max_rounds else '否'}") - -def show_bargain_features(): - """展示议价功能特性""" - print(f"\n🎯 议价功能特性说明:") - print("=" * 50) - - features = [ - "✅ 智能议价轮数统计:自动统计用户的议价次数", - "✅ 灵活轮数限制:可配置最大议价轮数(1-10轮)", - "✅ 优惠金额控制:设置最大优惠百分比和金额", - "✅ 友好拒绝回复:超出限制时礼貌拒绝继续议价", - "✅ 上下文感知:AI了解完整的议价历史", - "✅ 个性化策略:根据议价轮数调整回复策略", - "✅ 数据持久化:议价记录永久保存", - "✅ 实时生效:配置修改后立即生效" - ] - - for feature in features: - print(f" {feature}") - - print(f"\n💡 使用建议:") - suggestions = [ - "设置合理的最大议价轮数(建议3-5轮)", - "配合最大优惠百分比和金额使用", - "在AI提示词中强调议价策略", - "定期分析议价数据,优化策略", - "根据商品类型调整议价参数" - ] - - for suggestion in suggestions: - print(f" • {suggestion}") - -def main(): - """主函数""" - print("🚀 AI回复议价功能演示") - - # 模拟议价对话 - simulate_bargain_conversation() - - # 展示功能特性 - show_bargain_features() - - print(f"\n🎉 演示完成!") - print(f"\n📋 下一步:") - print(f" 1. 在Web界面配置真实的AI API密钥") - print(f" 2. 为需要的账号启用AI回复功能") - print(f" 3. 设置合适的议价参数") - print(f" 4. 测试实际的议价效果") - -if __name__ == "__main__": - main() diff --git a/cookie_manager.py b/cookie_manager.py index e742e6c..a901281 100644 --- a/cookie_manager.py +++ b/cookie_manager.py @@ -62,12 +62,12 @@ class CookieManager: except Exception as e: logger.error(f"XianyuLive 任务异常({cookie_id}): {e}") - async def _add_cookie_async(self, cookie_id: str, cookie_value: str): + async def _add_cookie_async(self, cookie_id: str, cookie_value: str, user_id: int = None): if cookie_id in self.tasks: raise ValueError("Cookie ID already exists") self.cookies[cookie_id] = cookie_value - # 保存到数据库 - db_manager.save_cookie(cookie_id, cookie_value) + # 保存到数据库,如果没有指定user_id,则保持原有绑定关系 + db_manager.save_cookie(cookie_id, cookie_value, user_id) task = self.loop.create_task(self._run_xianyu(cookie_id, cookie_value)) self.tasks[cookie_id] = task logger.info(f"已启动账号任务: {cookie_id}") @@ -83,7 +83,7 @@ class CookieManager: logger.info(f"已移除账号: {cookie_id}") # ------------------------ 对外线程安全接口 ------------------------ - def add_cookie(self, cookie_id: str, cookie_value: str, kw_list: Optional[List[Tuple[str, str]]] = None): + def add_cookie(self, cookie_id: str, cookie_value: str, kw_list: Optional[List[Tuple[str, str]]] = None, user_id: int = None): """线程安全新增 Cookie 并启动任务""" if kw_list is not None: self.keywords[cookie_id] = kw_list @@ -96,9 +96,9 @@ class CookieManager: if current_loop and current_loop == self.loop: # 同一事件循环中,直接调度 - return self.loop.create_task(self._add_cookie_async(cookie_id, cookie_value)) + return self.loop.create_task(self._add_cookie_async(cookie_id, cookie_value, user_id)) else: - fut = asyncio.run_coroutine_threadsafe(self._add_cookie_async(cookie_id, cookie_value), self.loop) + fut = asyncio.run_coroutine_threadsafe(self._add_cookie_async(cookie_id, cookie_value, user_id), self.loop) return fut.result() def remove_cookie(self, cookie_id: str): diff --git a/db_manager.py b/db_manager.py index 60e8a5a..2865649 100644 --- a/db_manager.py +++ b/db_manager.py @@ -182,11 +182,23 @@ class DBManager: data_content TEXT, description TEXT, enabled BOOLEAN DEFAULT TRUE, + user_id INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ) ''') + # 检查并添加 user_id 列(用于数据库迁移) + try: + cursor.execute("SELECT user_id FROM cards LIMIT 1") + except sqlite3.OperationalError: + # user_id 列不存在,需要添加 + logger.info("正在为 cards 表添加 user_id 列...") + cursor.execute("ALTER TABLE cards ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_cards_user_id ON cards(user_id)") + logger.info("cards 表 user_id 列添加完成") + # 创建商品信息表 cursor.execute(''' CREATE TABLE IF NOT EXISTS item_info ( @@ -271,6 +283,21 @@ class DBManager: ) ''') + # 创建用户设置表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS user_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, key) + ) + ''') + # 插入默认系统设置 cursor.execute(''' INSERT OR IGNORE INTO system_settings (key, value, description) VALUES @@ -301,6 +328,39 @@ class DBManager: # user_id列存在,更新NULL值 cursor.execute("UPDATE cookies SET user_id = ? WHERE user_id IS NULL", (admin_user_id,)) + # 为delivery_rules表添加user_id字段(如果不存在) + try: + cursor.execute("SELECT user_id FROM delivery_rules LIMIT 1") + except sqlite3.OperationalError: + # user_id列不存在,需要添加并更新历史数据 + cursor.execute("ALTER TABLE delivery_rules ADD COLUMN user_id INTEGER") + cursor.execute("UPDATE delivery_rules SET user_id = ? WHERE user_id IS NULL", (admin_user_id,)) + else: + # user_id列存在,更新NULL值 + cursor.execute("UPDATE delivery_rules SET user_id = ? WHERE user_id IS NULL", (admin_user_id,)) + + # 为notification_channels表添加user_id字段(如果不存在) + try: + cursor.execute("SELECT user_id FROM notification_channels LIMIT 1") + except sqlite3.OperationalError: + # user_id列不存在,需要添加并更新历史数据 + cursor.execute("ALTER TABLE notification_channels ADD COLUMN user_id INTEGER") + cursor.execute("UPDATE notification_channels SET user_id = ? WHERE user_id IS NULL", (admin_user_id,)) + else: + # user_id列存在,更新NULL值 + cursor.execute("UPDATE notification_channels SET user_id = ? WHERE user_id IS NULL", (admin_user_id,)) + + # 为email_verifications表添加type字段(如果不存在) + try: + cursor.execute("SELECT type FROM email_verifications LIMIT 1") + except sqlite3.OperationalError: + # type列不存在,需要添加并更新历史数据 + cursor.execute("ALTER TABLE email_verifications ADD COLUMN type TEXT DEFAULT 'register'") + cursor.execute("UPDATE email_verifications SET type = 'register' WHERE type IS NULL") + else: + # type列存在,更新NULL值 + cursor.execute("UPDATE email_verifications SET type = 'register' WHERE type IS NULL") + self.conn.commit() logger.info(f"数据库初始化成功: {self.db_path}") except Exception as e: @@ -345,7 +405,15 @@ class DBManager: (cookie_id, cookie_value, user_id) ) self.conn.commit() - logger.debug(f"Cookie保存成功: {cookie_id} (用户ID: {user_id})") + logger.info(f"Cookie保存成功: {cookie_id} (用户ID: {user_id})") + + # 验证保存结果 + cursor.execute("SELECT user_id FROM cookies WHERE id = ?", (cookie_id,)) + saved_user_id = cursor.fetchone() + if saved_user_id: + logger.info(f"Cookie保存验证: {cookie_id} 实际绑定到用户ID: {saved_user_id[0]}") + else: + logger.error(f"Cookie保存验证失败: {cookie_id} 未找到记录") return True except Exception as e: logger.error(f"Cookie保存失败: {e}") @@ -713,15 +781,15 @@ class DBManager: return False # -------------------- 通知渠道操作 -------------------- - def create_notification_channel(self, name: str, channel_type: str, config: str) -> int: + def create_notification_channel(self, name: str, channel_type: str, config: str, user_id: int = None) -> int: """创建通知渠道""" with self.lock: try: cursor = self.conn.cursor() cursor.execute(''' - INSERT INTO notification_channels (name, type, config) - VALUES (?, ?, ?) - ''', (name, channel_type, config)) + INSERT INTO notification_channels (name, type, config, user_id) + VALUES (?, ?, ?, ?) + ''', (name, channel_type, config, user_id)) self.conn.commit() channel_id = cursor.lastrowid logger.debug(f"创建通知渠道: {name} (ID: {channel_id})") @@ -731,16 +799,24 @@ class DBManager: self.conn.rollback() raise - def get_notification_channels(self) -> List[Dict[str, any]]: + def get_notification_channels(self, user_id: int = None) -> List[Dict[str, any]]: """获取所有通知渠道""" with self.lock: try: cursor = self.conn.cursor() - cursor.execute(''' - SELECT id, name, type, config, enabled, created_at, updated_at - FROM notification_channels - ORDER BY created_at DESC - ''') + if user_id is not None: + cursor.execute(''' + SELECT id, name, type, config, enabled, created_at, updated_at + FROM notification_channels + WHERE user_id = ? + ORDER BY created_at DESC + ''', (user_id,)) + else: + cursor.execute(''' + SELECT id, name, type, config, enabled, created_at, updated_at + FROM notification_channels + ORDER BY created_at DESC + ''') channels = [] for row in cursor.fetchall(): @@ -1360,7 +1436,7 @@ class DBManager: logger.error(f"验证图形验证码失败: {e}") return False - def save_verification_code(self, email: str, code: str, expires_minutes: int = 10) -> bool: + def save_verification_code(self, email: str, code: str, code_type: str = 'register', expires_minutes: int = 10) -> bool: """保存邮箱验证码""" with self.lock: try: @@ -1368,19 +1444,19 @@ class DBManager: expires_at = time.time() + (expires_minutes * 60) cursor.execute(''' - INSERT INTO email_verifications (email, code, expires_at) - VALUES (?, ?, ?) - ''', (email, code, expires_at)) + INSERT INTO email_verifications (email, code, type, expires_at) + VALUES (?, ?, ?, ?) + ''', (email, code, code_type, expires_at)) self.conn.commit() - logger.info(f"保存验证码成功: {email}") + logger.info(f"保存验证码成功: {email} ({code_type})") return True except Exception as e: logger.error(f"保存验证码失败: {e}") self.conn.rollback() return False - def verify_email_code(self, email: str, code: str) -> bool: + def verify_email_code(self, email: str, code: str, code_type: str = 'register') -> bool: """验证邮箱验证码""" with self.lock: try: @@ -1390,9 +1466,9 @@ class DBManager: # 查找有效的验证码 cursor.execute(''' SELECT id FROM email_verifications - WHERE email = ? AND code = ? AND expires_at > ? AND used = FALSE + WHERE email = ? AND code = ? AND type = ? AND expires_at > ? AND used = FALSE ORDER BY created_at DESC LIMIT 1 - ''', (email, code, current_time)) + ''', (email, code, code_type, current_time)) row = cursor.fetchone() if row: @@ -1401,10 +1477,10 @@ class DBManager: UPDATE email_verifications SET used = TRUE WHERE id = ? ''', (row[0],)) self.conn.commit() - logger.info(f"验证码验证成功: {email}") + logger.info(f"验证码验证成功: {email} ({code_type})") return True else: - logger.warning(f"验证码验证失败: {email} - {code}") + logger.warning(f"验证码验证失败: {email} - {code} ({code_type})") return False except Exception as e: logger.error(f"验证邮箱验证码失败: {e}") @@ -1414,78 +1490,52 @@ class DBManager: """发送验证码邮件""" try: subject = "闲鱼自动回复系统 - 邮箱验证码" - html_content = f""" - - - - - 邮箱验证码 - - - -
-
- -
邮箱验证码
-
+ # 使用简单的纯文本邮件内容 + text_content = f"""【闲鱼自动回复系统】邮箱验证码 -
- 您好!

- 您正在注册闲鱼自动回复系统账号,请使用以下验证码完成邮箱验证: -
+您好! -
-
{code}
-
+感谢您使用闲鱼自动回复系统。为了确保账户安全,请使用以下验证码完成邮箱验证: -
- ⚠️ 重要提醒:
- • 验证码有效期为 10 分钟
- • 请勿将验证码告诉他人
- • 如果您没有进行此操作,请忽略此邮件 -
+验证码:{code} -
- 如有任何问题,请联系系统管理员。
- 感谢您使用闲鱼自动回复系统! -
+重要提醒: +• 验证码有效期为 10 分钟,请及时使用 +• 请勿将验证码分享给任何人 +• 如非本人操作,请忽略此邮件 +• 系统不会主动索要您的验证码 - -
- - - """ +如果您在使用过程中遇到任何问题,请联系我们的技术支持团队。 +感谢您选择闲鱼自动回复系统! - # 调用邮件发送API +--- +此邮件由系统自动发送,请勿直接回复 +© 2025 闲鱼自动回复系统""" + + # 使用GET请求发送邮件 api_url = "https://dy.zhinianboke.com/api/emailSend" params = { 'subject': subject, 'receiveUser': email, - 'sendHtml': html_content + 'sendHtml': text_content } async with aiohttp.ClientSession() as session: - async with session.get(api_url, params=params) as response: - if response.status == 200: - logger.info(f"验证码邮件发送成功: {email}") - return True - else: - logger.error(f"验证码邮件发送失败: {email}, 状态码: {response.status}") - return False + try: + logger.info(f"发送验证码邮件: {email}") + async with session.get(api_url, params=params, timeout=15) as response: + response_text = await response.text() + logger.info(f"邮件API响应: {response.status}") + + if response.status == 200: + logger.info(f"验证码邮件发送成功: {email}") + return True + else: + logger.error(f"验证码邮件发送失败: {email}, 状态码: {response.status}, 响应: {response_text[:200]}") + return False + except Exception as e: + logger.error(f"邮件发送异常: {email}, 错误: {e}") + return False except Exception as e: logger.error(f"发送验证码邮件异常: {e}") @@ -1688,15 +1738,15 @@ class DBManager: # ==================== 自动发货规则方法 ==================== def create_delivery_rule(self, keyword: str, card_id: int, delivery_count: int = 1, - enabled: bool = True, description: str = None): + enabled: bool = True, description: str = None, user_id: int = None): """创建发货规则""" with self.lock: try: cursor = self.conn.cursor() cursor.execute(''' - INSERT INTO delivery_rules (keyword, card_id, delivery_count, enabled, description) - VALUES (?, ?, ?, ?, ?) - ''', (keyword, card_id, delivery_count, enabled, description)) + INSERT INTO delivery_rules (keyword, card_id, delivery_count, enabled, description, user_id) + VALUES (?, ?, ?, ?, ?, ?) + ''', (keyword, card_id, delivery_count, enabled, description, user_id)) self.conn.commit() rule_id = cursor.lastrowid logger.info(f"创建发货规则成功: {keyword} -> 卡券ID {card_id} (规则ID: {rule_id})") @@ -1705,19 +1755,30 @@ class DBManager: logger.error(f"创建发货规则失败: {e}") raise - def get_all_delivery_rules(self): + def get_all_delivery_rules(self, user_id: int = None): """获取所有发货规则""" with self.lock: try: cursor = self.conn.cursor() - cursor.execute(''' - SELECT dr.id, dr.keyword, dr.card_id, dr.delivery_count, dr.enabled, - dr.description, dr.delivery_times, dr.created_at, dr.updated_at, - c.name as card_name, c.type as card_type - FROM delivery_rules dr - LEFT JOIN cards c ON dr.card_id = c.id - ORDER BY dr.created_at DESC - ''') + if user_id is not None: + cursor.execute(''' + SELECT dr.id, dr.keyword, dr.card_id, dr.delivery_count, dr.enabled, + dr.description, dr.delivery_times, dr.created_at, dr.updated_at, + c.name as card_name, c.type as card_type + FROM delivery_rules dr + LEFT JOIN cards c ON dr.card_id = c.id + WHERE dr.user_id = ? + ORDER BY dr.created_at DESC + ''', (user_id,)) + else: + cursor.execute(''' + SELECT dr.id, dr.keyword, dr.card_id, dr.delivery_count, dr.enabled, + dr.description, dr.delivery_times, dr.created_at, dr.updated_at, + c.name as card_name, c.type as card_type + FROM delivery_rules dr + LEFT JOIN cards c ON dr.card_id = c.id + ORDER BY dr.created_at DESC + ''') rules = [] for row in cursor.fetchall(): @@ -1750,7 +1811,7 @@ class DBManager: SELECT dr.id, dr.keyword, dr.card_id, dr.delivery_count, dr.enabled, dr.description, dr.delivery_times, c.name as card_name, c.type as card_type, c.api_config, - c.text_content, c.data_content, c.enabled as card_enabled + c.text_content, c.data_content, c.enabled as card_enabled, c.description as card_description FROM delivery_rules dr LEFT JOIN cards c ON dr.card_id = c.id WHERE dr.enabled = 1 AND c.enabled = 1 @@ -1788,7 +1849,8 @@ class DBManager: 'card_api_config': api_config, 'card_text_content': row[10], 'card_data_content': row[11], - 'card_enabled': bool(row[12]) + 'card_enabled': bool(row[12]), + 'card_description': row[13] # 卡券备注信息 }) return rules diff --git a/demo_captcha_registration.py b/demo_captcha_registration.py deleted file mode 100644 index dcf2c43..0000000 --- a/demo_captcha_registration.py +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env python3 -""" -图形验证码注册流程演示 -""" - -import requests -import json -import sqlite3 -import time - -def demo_complete_registration(): - """演示完整的注册流程""" - print("🎭 图形验证码注册流程演示") - print("=" * 60) - - session_id = f"demo_session_{int(time.time())}" - test_email = "demo@example.com" - test_username = "demouser" - test_password = "demo123456" - - # 清理可能存在的测试数据 - try: - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('DELETE FROM users WHERE username = ? OR email = ?', (test_username, test_email)) - cursor.execute('DELETE FROM email_verifications WHERE email = ?', (test_email,)) - cursor.execute('DELETE FROM captcha_codes WHERE session_id = ?', (session_id,)) - conn.commit() - conn.close() - print("🧹 清理旧的测试数据") - except: - pass - - print("\n📋 注册流程步骤:") - print("1. 生成图形验证码") - print("2. 用户输入图形验证码") - print("3. 验证图形验证码") - print("4. 发送邮箱验证码") - print("5. 用户输入邮箱验证码") - print("6. 完成注册") - - # 步骤1: 生成图形验证码 - print("\n🔸 步骤1: 生成图形验证码") - response = requests.post('http://localhost:8080/generate-captcha', - json={'session_id': session_id}) - - if response.status_code != 200: - print(f"❌ 生成图形验证码失败: {response.status_code}") - return False - - result = response.json() - if not result['success']: - print(f"❌ 生成图形验证码失败: {result['message']}") - return False - - print("✅ 图形验证码生成成功") - - # 从数据库获取验证码文本(模拟用户看到图片并输入) - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM captcha_codes WHERE session_id = ? ORDER BY created_at DESC LIMIT 1', - (session_id,)) - captcha_result = cursor.fetchone() - conn.close() - - if not captcha_result: - print("❌ 无法获取图形验证码") - return False - - captcha_text = captcha_result[0] - print(f"📷 图形验证码: {captcha_text}") - - # 步骤2-3: 验证图形验证码 - print("\n🔸 步骤2-3: 验证图形验证码") - response = requests.post('http://localhost:8080/verify-captcha', - json={ - 'session_id': session_id, - 'captcha_code': captcha_text - }) - - if response.status_code != 200: - print(f"❌ 验证图形验证码失败: {response.status_code}") - return False - - result = response.json() - if not result['success']: - print(f"❌ 图形验证码验证失败: {result['message']}") - return False - - print("✅ 图形验证码验证成功") - - # 步骤4: 发送邮箱验证码 - print("\n🔸 步骤4: 发送邮箱验证码") - response = requests.post('http://localhost:8080/send-verification-code', - json={'email': test_email}) - - if response.status_code != 200: - print(f"❌ 发送邮箱验证码失败: {response.status_code}") - return False - - result = response.json() - if not result['success']: - print(f"❌ 发送邮箱验证码失败: {result['message']}") - return False - - print("✅ 邮箱验证码发送成功") - - # 从数据库获取邮箱验证码(模拟用户收到邮件) - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM email_verifications WHERE email = ? ORDER BY created_at DESC LIMIT 1', - (test_email,)) - email_result = cursor.fetchone() - conn.close() - - if not email_result: - print("❌ 无法获取邮箱验证码") - return False - - email_code = email_result[0] - print(f"📧 邮箱验证码: {email_code}") - - # 步骤5-6: 完成注册 - print("\n🔸 步骤5-6: 完成用户注册") - response = requests.post('http://localhost:8080/register', - json={ - 'username': test_username, - 'email': test_email, - 'verification_code': email_code, - 'password': test_password - }) - - if response.status_code != 200: - print(f"❌ 用户注册失败: {response.status_code}") - return False - - result = response.json() - if not result['success']: - print(f"❌ 用户注册失败: {result['message']}") - return False - - print("✅ 用户注册成功") - - # 验证登录 - print("\n🔸 验证登录功能") - response = requests.post('http://localhost:8080/login', - json={ - 'username': test_username, - 'password': test_password - }) - - if response.status_code != 200: - print(f"❌ 用户登录失败: {response.status_code}") - return False - - result = response.json() - if not result['success']: - print(f"❌ 用户登录失败: {result['message']}") - return False - - print("✅ 用户登录成功") - print(f"🎫 Token: {result['token'][:20]}...") - print(f"👤 用户ID: {result['user_id']}") - - return True - -def demo_security_features(): - """演示安全特性""" - print("\n🔒 安全特性演示") - print("-" * 40) - - session_id = f"security_test_{int(time.time())}" - - # 1. 测试错误的图形验证码 - print("1️⃣ 测试错误的图形验证码...") - - # 先生成一个验证码 - requests.post('http://localhost:8080/generate-captcha', - json={'session_id': session_id}) - - # 尝试用错误的验证码 - response = requests.post('http://localhost:8080/verify-captcha', - json={ - 'session_id': session_id, - 'captcha_code': 'WRONG' - }) - - result = response.json() - if not result['success']: - print(" ✅ 错误的图形验证码被正确拒绝") - else: - print(" ❌ 错误的图形验证码验证成功(安全漏洞)") - - # 2. 测试未验证图形验证码就发送邮件 - print("\n2️⃣ 测试未验证图形验证码发送邮件...") - - # 注意:当前实现中发送邮件接口没有检查图形验证码状态 - # 这是前端控制的,后端应该也要检查 - response = requests.post('http://localhost:8080/send-verification-code', - json={'email': 'test@example.com'}) - - result = response.json() - if result['success']: - print(" ⚠️ 未验证图形验证码也能发送邮件(建议后端也要检查)") - else: - print(" ✅ 未验证图形验证码无法发送邮件") - - # 3. 测试验证码重复使用 - print("\n3️⃣ 测试验证码重复使用...") - - # 生成新的验证码 - session_id2 = f"reuse_test_{int(time.time())}" - requests.post('http://localhost:8080/generate-captcha', - json={'session_id': session_id2}) - - # 获取验证码 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM captcha_codes WHERE session_id = ? ORDER BY created_at DESC LIMIT 1', - (session_id2,)) - result = cursor.fetchone() - conn.close() - - if result: - captcha_code = result[0] - - # 第一次验证 - response1 = requests.post('http://localhost:8080/verify-captcha', - json={ - 'session_id': session_id2, - 'captcha_code': captcha_code - }) - - # 第二次验证(重复使用) - response2 = requests.post('http://localhost:8080/verify-captcha', - json={ - 'session_id': session_id2, - 'captcha_code': captcha_code - }) - - result1 = response1.json() - result2 = response2.json() - - if result1['success'] and not result2['success']: - print(" ✅ 验证码重复使用被正确阻止") - else: - print(" ❌ 验证码可以重复使用(安全漏洞)") - -def cleanup_demo_data(): - """清理演示数据""" - print("\n🧹 清理演示数据") - print("-" * 40) - - try: - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - # 清理测试用户 - cursor.execute('DELETE FROM users WHERE username LIKE "demo%" OR email LIKE "demo%"') - user_count = cursor.rowcount - - # 清理测试验证码 - cursor.execute('DELETE FROM email_verifications WHERE email LIKE "demo%"') - email_count = cursor.rowcount - - # 清理测试图形验证码 - cursor.execute('DELETE FROM captcha_codes WHERE session_id LIKE "demo%" OR session_id LIKE "security%" OR session_id LIKE "reuse%"') - captcha_count = cursor.rowcount - - conn.commit() - conn.close() - - print(f"✅ 清理完成:") - print(f" • 用户: {user_count} 条") - print(f" • 邮箱验证码: {email_count} 条") - print(f" • 图形验证码: {captcha_count} 条") - - except Exception as e: - print(f"❌ 清理失败: {e}") - -def main(): - """主演示函数""" - print("🎪 图形验证码系统完整演示") - print("=" * 60) - - try: - # 演示完整注册流程 - success = demo_complete_registration() - - if success: - print("\n🎉 完整注册流程演示成功!") - else: - print("\n💥 注册流程演示失败!") - return False - - # 演示安全特性 - demo_security_features() - - # 清理演示数据 - cleanup_demo_data() - - print("\n" + "=" * 60) - print("🎊 图形验证码系统演示完成!") - - print("\n📋 功能总结:") - print("✅ 图形验证码生成和验证") - print("✅ 邮箱验证码发送") - print("✅ 用户注册流程") - print("✅ 用户登录验证") - print("✅ 安全特性保护") - - print("\n🌐 使用方法:") - print("1. 访问: http://localhost:8080/register.html") - print("2. 查看图形验证码并输入") - print("3. 输入邮箱地址") - print("4. 点击发送验证码(需要先验证图形验证码)") - print("5. 输入邮箱验证码") - print("6. 设置密码并完成注册") - - return True - - except Exception as e: - print(f"\n❌ 演示失败: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - main() diff --git a/deploy.bat b/deploy.bat deleted file mode 100644 index c10fd9d..0000000 --- a/deploy.bat +++ /dev/null @@ -1,298 +0,0 @@ -@echo off -chcp 65001 >nul -setlocal enabledelayedexpansion - -:: 闲鱼自动回复系统 Docker 部署脚本 (Windows版本) -:: 作者: Xianyu Auto Reply System -:: 版本: 1.0.0 - -title 闲鱼自动回复系统 Docker 部署 - -:: 颜色定义 -set "RED=[91m" -set "GREEN=[92m" -set "YELLOW=[93m" -set "BLUE=[94m" -set "NC=[0m" - -:: 打印带颜色的消息 -:print_info -echo %BLUE%[INFO]%NC% %~1 -goto :eof - -:print_success -echo %GREEN%[SUCCESS]%NC% %~1 -goto :eof - -:print_warning -echo %YELLOW%[WARNING]%NC% %~1 -goto :eof - -:print_error -echo %RED%[ERROR]%NC% %~1 -goto :eof - -:: 检查Docker是否安装 -:check_docker -call :print_info "检查 Docker 环境..." - -docker --version >nul 2>&1 -if errorlevel 1 ( - call :print_error "Docker 未安装,请先安装 Docker Desktop" - echo. - echo 下载地址: https://www.docker.com/products/docker-desktop - pause - exit /b 1 -) - -docker-compose --version >nul 2>&1 -if errorlevel 1 ( - call :print_error "Docker Compose 未安装,请先安装 Docker Compose" - pause - exit /b 1 -) - -call :print_success "Docker 环境检查通过" -goto :eof - -:: 创建必要的目录 -:create_directories -call :print_info "创建必要的目录..." - -if not exist "data" mkdir data -if not exist "logs" mkdir logs -if not exist "backups" mkdir backups -if not exist "nginx" mkdir nginx -if not exist "nginx\ssl" mkdir nginx\ssl - -REM 检查目录是否创建成功 -if not exist "data" ( - call :print_error "data目录创建失败" - pause - exit /b 1 -) - -if not exist "logs" ( - call :print_error "logs目录创建失败" - pause - exit /b 1 -) - -call :print_success "目录创建完成" -goto :eof - -:: 生成默认配置文件 -:generate_config -REM 生成.env文件 -if not exist ".env" ( - if exist ".env.example" ( - call :print_info "从模板生成 .env 文件..." - copy ".env.example" ".env" >nul - call :print_success ".env 文件已生成" - ) else ( - call :print_warning ".env.example 文件不存在,跳过 .env 文件生成" - ) -) else ( - call :print_info ".env 文件已存在,跳过生成" -) - -REM 生成global_config.yml文件 -if exist "global_config.yml" ( - call :print_info "配置文件已存在,跳过生成" - goto :eof -) - -call :print_info "生成默认配置文件..." - -( -echo # 闲鱼自动回复系统配置文件 -echo API_ENDPOINTS: -echo login_check: https://passport.goofish.com/newlogin/hasLogin.do -echo message_headinfo: https://h5api.m.goofish.com/h5/mtop.idle.trade.pc.message.headinfo/1.0/ -echo token: https://h5api.m.goofish.com/h5/mtop.taobao.idlemessage.pc.login.token/1.0/ -echo. -echo APP_CONFIG: -echo api_version: '1.0' -echo app_key: 444e9908a51d1cb236a27862abc769c9 -echo app_version: '1.0' -echo platform: web -echo. -echo AUTO_REPLY: -echo enabled: true -echo default_message: '亲爱的"{send_user_name}" 老板你好!所有宝贝都可以拍,秒发货的哈~不满意的话可以直接申请退款哈~' -echo max_retry: 3 -echo retry_interval: 5 -echo api: -echo enabled: false -echo host: 0.0.0.0 # 绑定所有网络接口,支持IP访问 -echo port: 8080 # Web服务端口 -echo url: http://0.0.0.0:8080/xianyu/reply -echo timeout: 10 -echo. -echo COOKIES: -echo last_update_time: '' -echo value: '' -echo. -echo DEFAULT_HEADERS: -echo accept: application/json -echo accept-language: zh-CN,zh;q=0.9 -echo cache-control: no-cache -echo origin: https://www.goofish.com -echo pragma: no-cache -echo referer: https://www.goofish.com/ -echo user-agent: Mozilla/5.0 ^(Windows NT 10.0; Win64; x64^) AppleWebKit/537.36 ^(KHTML, like Gecko^) Chrome/119.0.0.0 Safari/537.36 -echo. -echo WEBSOCKET_URL: wss://wss-goofish.dingtalk.com/ -echo HEARTBEAT_INTERVAL: 15 -echo HEARTBEAT_TIMEOUT: 5 -echo TOKEN_REFRESH_INTERVAL: 3600 -echo TOKEN_RETRY_INTERVAL: 300 -echo MESSAGE_EXPIRE_TIME: 300000 -echo. -echo LOG_CONFIG: -echo level: INFO -echo format: '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {name}:{function}:{line} - {message}' -echo rotation: '1 day' -echo retention: '7 days' -) > global_config.yml - -call :print_success "默认配置文件已生成" -goto :eof - -:: 构建Docker镜像 -:build_image -call :print_info "构建 Docker 镜像..." - -docker build -t xianyu-auto-reply:latest . -if errorlevel 1 ( - call :print_error "Docker 镜像构建失败" - pause - exit /b 1 -) - -call :print_success "Docker 镜像构建完成" -goto :eof - -:: 启动服务 -:start_services -call :print_info "启动服务..." - -if "%~1"=="--with-nginx" ( - call :print_info "启动服务(包含 Nginx)..." - docker-compose --profile with-nginx up -d -) else ( - call :print_info "启动服务(不包含 Nginx)..." - docker-compose up -d -) - -if errorlevel 1 ( - call :print_error "服务启动失败" - pause - exit /b 1 -) - -call :print_success "服务启动完成" -goto :eof - -:: 显示服务状态 -:show_status -call :print_info "服务状态:" -docker-compose ps - -echo. -call :print_info "服务日志(最近10行):" -docker-compose logs --tail=10 -goto :eof - -:: 显示访问信息 -:show_access_info -call :print_success "部署完成!" -echo. -call :print_info "访问信息:" -echo Web界面: http://localhost:8080 -echo 默认账号: admin -echo 默认密码: admin123 -echo. -call :print_info "常用命令:" -echo 查看日志: docker-compose logs -f -echo 重启服务: docker-compose restart -echo 停止服务: docker-compose down -echo 更新服务: deploy.bat update -echo. -call :print_info "数据目录:" -echo 数据库: .\data\xianyu_data.db -echo 日志: .\logs\ -echo 配置: .\global_config.yml -echo. -goto :eof - -:: 更新服务 -:update_services -call :print_info "更新服务..." - -docker-compose down -call :build_image -call :start_services %~1 - -call :print_success "服务更新完成" -goto :eof - -:: 清理资源 -:cleanup -call :print_warning "清理 Docker 资源..." - -docker-compose down --volumes --remove-orphans -docker rmi xianyu-auto-reply:latest 2>nul - -call :print_success "清理完成" -goto :eof - -:: 显示帮助 -:show_help -echo 使用方法: -echo %~nx0 # 首次部署 -echo %~nx0 with-nginx # 部署并启动 Nginx -echo %~nx0 update # 更新服务 -echo %~nx0 update with-nginx # 更新服务并启动 Nginx -echo %~nx0 status # 查看服务状态 -echo %~nx0 cleanup # 清理所有资源 -echo %~nx0 help # 显示帮助 -goto :eof - -:: 主函数 -:main -echo ======================================== -echo 闲鱼自动回复系统 Docker 部署脚本 -echo ======================================== -echo. - -if "%~1"=="update" ( - call :print_info "更新模式" - call :check_docker - call :update_services %~2 - call :show_status - call :show_access_info -) else if "%~1"=="cleanup" ( - call :print_warning "清理模式" - call :cleanup -) else if "%~1"=="status" ( - call :show_status -) else if "%~1"=="help" ( - call :show_help -) else ( - call :print_info "首次部署模式" - call :check_docker - call :create_directories - call :generate_config - call :build_image - call :start_services %~1 - call :show_status - call :show_access_info -) - -echo. -pause -goto :eof - -:: 执行主函数 -call :main %* diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index 75f3d0b..0000000 --- a/deploy.sh +++ /dev/null @@ -1,288 +0,0 @@ -#!/bin/bash - -# 闲鱼自动回复系统 Docker 部署脚本 -# 作者: Xianyu Auto Reply System -# 版本: 1.0.0 - -set -e - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# 打印带颜色的消息 -print_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# 检查Docker是否安装 -check_docker() { - if ! command -v docker &> /dev/null; then - print_error "Docker 未安装,请先安装 Docker" - exit 1 - fi - - if ! command -v docker-compose &> /dev/null; then - print_error "Docker Compose 未安装,请先安装 Docker Compose" - exit 1 - fi - - print_success "Docker 环境检查通过" -} - -# 创建必要的目录 -create_directories() { - print_info "创建必要的目录..." - - # 创建目录 - mkdir -p data - mkdir -p logs - mkdir -p backups - mkdir -p nginx/ssl - - # 设置权限 (确保Docker容器可以写入) - chmod 755 data logs backups - - # 检查权限 - if [ ! -w "data" ]; then - print_error "data目录没有写权限" - exit 1 - fi - - if [ ! -w "logs" ]; then - print_error "logs目录没有写权限" - exit 1 - fi - - print_success "目录创建完成" -} - -# 生成默认配置文件 -generate_config() { - # 生成.env文件 - if [ ! -f ".env" ]; then - if [ -f ".env.example" ]; then - print_info "从模板生成 .env 文件..." - cp .env.example .env - print_success ".env 文件已生成" - else - print_warning ".env.example 文件不存在,跳过 .env 文件生成" - fi - else - print_info ".env 文件已存在,跳过生成" - fi - - # 生成global_config.yml文件 - if [ ! -f "global_config.yml" ]; then - print_info "生成默认配置文件..." - - cat > global_config.yml << EOF -# 闲鱼自动回复系统配置文件 -API_ENDPOINTS: - login_check: https://passport.goofish.com/newlogin/hasLogin.do - message_headinfo: https://h5api.m.goofish.com/h5/mtop.idle.trade.pc.message.headinfo/1.0/ - token: https://h5api.m.goofish.com/h5/mtop.taobao.idlemessage.pc.login.token/1.0/ - -APP_CONFIG: - api_version: '1.0' - app_key: 444e9908a51d1cb236a27862abc769c9 - app_version: '1.0' - platform: web - -AUTO_REPLY: - enabled: true - default_message: '亲爱的"{send_user_name}" 老板你好!所有宝贝都可以拍,秒发货的哈~不满意的话可以直接申请退款哈~' - max_retry: 3 - retry_interval: 5 - api: - enabled: false - host: 0.0.0.0 # 绑定所有网络接口,支持IP访问 - port: 8080 # Web服务端口 - url: http://0.0.0.0:8080/xianyu/reply - timeout: 10 - -COOKIES: - last_update_time: '' - value: '' - -DEFAULT_HEADERS: - accept: application/json - accept-language: zh-CN,zh;q=0.9 - cache-control: no-cache - origin: https://www.goofish.com - pragma: no-cache - referer: https://www.goofish.com/ - sec-ch-ua: '"Google Chrome";v="119", "Chromium";v="119", "Not?A_Brand";v="24"' - sec-ch-ua-mobile: '?0' - sec-ch-ua-platform: '"Windows"' - sec-fetch-dest: empty - sec-fetch-mode: cors - sec-fetch-site: same-site - user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 - -WEBSOCKET_URL: wss://wss-goofish.dingtalk.com/ -HEARTBEAT_INTERVAL: 15 -HEARTBEAT_TIMEOUT: 5 -TOKEN_REFRESH_INTERVAL: 3600 -TOKEN_RETRY_INTERVAL: 300 -MESSAGE_EXPIRE_TIME: 300000 - -LOG_CONFIG: - level: INFO - format: '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {name}:{function}:{line} - {message}' - rotation: '1 day' - retention: '7 days' -EOF - - print_success "默认配置文件已生成" - else - print_info "配置文件已存在,跳过生成" - fi -} - -# 构建Docker镜像 -build_image() { - print_info "构建 Docker 镜像..." - - docker build -t xianyu-auto-reply:latest . - - print_success "Docker 镜像构建完成" -} - -# 启动服务 -start_services() { - print_info "启动服务..." - - # 检查是否需要启动 Nginx - if [ "$1" = "--with-nginx" ]; then - print_info "启动服务(包含 Nginx)..." - docker-compose --profile with-nginx up -d - else - print_info "启动服务(不包含 Nginx)..." - docker-compose up -d - fi - - print_success "服务启动完成" -} - -# 显示服务状态 -show_status() { - print_info "服务状态:" - docker-compose ps - - print_info "服务日志(最近10行):" - docker-compose logs --tail=10 -} - -# 显示访问信息 -show_access_info() { - print_success "部署完成!" - echo "" - print_info "访问信息:" - echo " Web界面: http://localhost:8080" - echo " 默认账号: admin" - echo " 默认密码: admin123" - echo "" - print_info "常用命令:" - echo " 查看日志: docker-compose logs -f" - echo " 重启服务: docker-compose restart" - echo " 停止服务: docker-compose down" - echo " 更新服务: ./deploy.sh --update" - echo "" - print_info "数据目录:" - echo " 数据库: ./data/xianyu_data.db" - echo " 日志: ./logs/" - echo " 配置: ./global_config.yml" -} - -# 更新服务 -update_services() { - print_info "更新服务..." - - # 停止服务 - docker-compose down - - # 重新构建镜像 - build_image - - # 启动服务 - start_services $1 - - print_success "服务更新完成" -} - -# 清理资源 -cleanup() { - print_warning "清理 Docker 资源..." - - # 停止并删除容器 - docker-compose down --volumes --remove-orphans - - # 删除镜像 - docker rmi xianyu-auto-reply:latest 2>/dev/null || true - - print_success "清理完成" -} - -# 主函数 -main() { - echo "========================================" - echo " 闲鱼自动回复系统 Docker 部署脚本" - echo "========================================" - echo "" - - case "$1" in - --update) - print_info "更新模式" - check_docker - update_services $2 - show_status - show_access_info - ;; - --cleanup) - print_warning "清理模式" - cleanup - ;; - --status) - show_status - ;; - --help) - echo "使用方法:" - echo " $0 # 首次部署" - echo " $0 --with-nginx # 部署并启动 Nginx" - echo " $0 --update # 更新服务" - echo " $0 --update --with-nginx # 更新服务并启动 Nginx" - echo " $0 --status # 查看服务状态" - echo " $0 --cleanup # 清理所有资源" - echo " $0 --help # 显示帮助" - ;; - *) - print_info "首次部署模式" - check_docker - create_directories - generate_config - build_image - start_services $1 - show_status - show_access_info - ;; - esac -} - -# 执行主函数 -main "$@" diff --git a/docker-deploy.bat b/docker-deploy.bat deleted file mode 100644 index 671acb9..0000000 --- a/docker-deploy.bat +++ /dev/null @@ -1,301 +0,0 @@ -@echo off -chcp 65001 >nul -setlocal enabledelayedexpansion - -:: 闲鱼自动回复系统 Docker 部署脚本 (Windows版本) -:: 支持快速部署和管理 - -set PROJECT_NAME=xianyu-auto-reply -set COMPOSE_FILE=docker-compose.yml -set ENV_FILE=.env - -:: 颜色定义 (Windows 10+ 支持ANSI颜色) -set "RED=[31m" -set "GREEN=[32m" -set "YELLOW=[33m" -set "BLUE=[34m" -set "NC=[0m" - -:: 打印带颜色的消息 -:print_info -echo %BLUE%ℹ️ %~1%NC% -goto :eof - -:print_success -echo %GREEN%✅ %~1%NC% -goto :eof - -:print_warning -echo %YELLOW%⚠️ %~1%NC% -goto :eof - -:print_error -echo %RED%❌ %~1%NC% -goto :eof - -:: 检查依赖 -:check_dependencies -call :print_info "检查系统依赖..." - -docker --version >nul 2>&1 -if errorlevel 1 ( - call :print_error "Docker 未安装,请先安装 Docker Desktop" - exit /b 1 -) - -docker-compose --version >nul 2>&1 -if errorlevel 1 ( - call :print_error "Docker Compose 未安装,请先安装 Docker Compose" - exit /b 1 -) - -call :print_success "系统依赖检查通过" -goto :eof - -:: 初始化配置 -:init_config -call :print_info "初始化配置文件..." - -if not exist "%ENV_FILE%" ( - if exist ".env.example" ( - copy ".env.example" "%ENV_FILE%" >nul - call :print_success "已创建 %ENV_FILE% 配置文件" - ) else ( - call :print_error ".env.example 文件不存在" - exit /b 1 - ) -) else ( - call :print_warning "%ENV_FILE% 已存在,跳过创建" -) - -:: 创建必要的目录 -if not exist "data" mkdir data -if not exist "logs" mkdir logs -if not exist "backups" mkdir backups -call :print_success "已创建必要的目录" -goto :eof - -:: 构建镜像 -:build_image -call :print_info "构建 Docker 镜像..." -docker-compose build --no-cache -if errorlevel 1 ( - call :print_error "镜像构建失败" - exit /b 1 -) -call :print_success "镜像构建完成" -goto :eof - -:: 启动服务 -:start_services -set "profile=" -if "%~1"=="with-nginx" ( - set "profile=--profile with-nginx" - call :print_info "启动服务(包含 Nginx)..." -) else ( - call :print_info "启动基础服务..." -) - -docker-compose %profile% up -d -if errorlevel 1 ( - call :print_error "服务启动失败" - exit /b 1 -) -call :print_success "服务启动完成" - -:: 等待服务就绪 -call :print_info "等待服务就绪..." -timeout /t 10 /nobreak >nul - -:: 检查服务状态 -docker-compose ps | findstr "Up" >nul -if errorlevel 1 ( - call :print_error "服务启动失败" - docker-compose logs - exit /b 1 -) else ( - call :print_success "服务运行正常" - call :show_access_info "%~1" -) -goto :eof - -:: 停止服务 -:stop_services -call :print_info "停止服务..." -docker-compose down -call :print_success "服务已停止" -goto :eof - -:: 重启服务 -:restart_services -call :print_info "重启服务..." -docker-compose restart -call :print_success "服务已重启" -goto :eof - -:: 查看日志 -:show_logs -if "%~1"=="" ( - docker-compose logs -f -) else ( - docker-compose logs -f "%~1" -) -goto :eof - -:: 查看状态 -:show_status -call :print_info "服务状态:" -docker-compose ps - -call :print_info "资源使用:" -for /f "tokens=*" %%i in ('docker-compose ps -q') do ( - docker stats --no-stream %%i -) -goto :eof - -:: 显示访问信息 -:show_access_info -echo. -call :print_success "🎉 部署完成!" -echo. - -if "%~1"=="with-nginx" ( - echo 📱 访问地址: - echo HTTP: http://localhost - echo HTTPS: https://localhost ^(如果配置了SSL^) -) else ( - echo 📱 访问地址: - echo HTTP: http://localhost:8080 -) - -echo. -echo 🔐 默认登录信息: -echo 用户名: admin -echo 密码: admin123 -echo. -echo 📊 管理命令: -echo 查看状态: %~nx0 status -echo 查看日志: %~nx0 logs -echo 重启服务: %~nx0 restart -echo 停止服务: %~nx0 stop -echo. -goto :eof - -:: 健康检查 -:health_check -call :print_info "执行健康检查..." - -set "url=http://localhost:8080/health" -set "max_attempts=30" -set "attempt=1" - -:health_loop -curl -f -s "%url%" >nul 2>&1 -if not errorlevel 1 ( - call :print_success "健康检查通过" - goto :eof -) - -call :print_info "等待服务就绪... (!attempt!/%max_attempts%)" -timeout /t 2 /nobreak >nul -set /a attempt+=1 - -if !attempt! leq %max_attempts% goto health_loop - -call :print_error "健康检查失败" -exit /b 1 - -:: 备份数据 -:backup_data -call :print_info "备份数据..." - -for /f "tokens=2 delims==" %%i in ('wmic OS Get localdatetime /value') do set datetime=%%i -set backup_dir=backups\%datetime:~0,8%_%datetime:~8,6% -mkdir "%backup_dir%" 2>nul - -:: 备份数据库 -if exist "data\xianyu_data.db" ( - copy "data\xianyu_data.db" "%backup_dir%\" >nul - call :print_success "数据库备份完成" -) - -:: 备份配置 -copy "%ENV_FILE%" "%backup_dir%\" >nul -copy "global_config.yml" "%backup_dir%\" >nul 2>&1 - -call :print_success "数据备份完成: %backup_dir%" -goto :eof - -:: 显示帮助信息 -:show_help -echo 闲鱼自动回复系统 Docker 部署脚本 ^(Windows版本^) -echo. -echo 用法: %~nx0 [命令] [选项] -echo. -echo 命令: -echo init 初始化配置文件 -echo build 构建 Docker 镜像 -echo start [with-nginx] 启动服务^(可选包含 Nginx^) -echo stop 停止服务 -echo restart 重启服务 -echo status 查看服务状态 -echo logs [service] 查看日志 -echo health 健康检查 -echo backup 备份数据 -echo help 显示帮助信息 -echo. -echo 示例: -echo %~nx0 init # 初始化配置 -echo %~nx0 start # 启动基础服务 -echo %~nx0 start with-nginx # 启动包含 Nginx 的服务 -echo %~nx0 logs xianyu-app # 查看应用日志 -echo. -goto :eof - -:: 主函数 -:main -if "%~1"=="init" ( - call :check_dependencies - call :init_config -) else if "%~1"=="build" ( - call :check_dependencies - call :build_image -) else if "%~1"=="start" ( - call :check_dependencies - call :init_config - call :build_image - call :start_services "%~2" -) else if "%~1"=="stop" ( - call :stop_services -) else if "%~1"=="restart" ( - call :restart_services -) else if "%~1"=="status" ( - call :show_status -) else if "%~1"=="logs" ( - call :show_logs "%~2" -) else if "%~1"=="health" ( - call :health_check -) else if "%~1"=="backup" ( - call :backup_data -) else if "%~1"=="help" ( - call :show_help -) else if "%~1"=="-h" ( - call :show_help -) else if "%~1"=="--help" ( - call :show_help -) else if "%~1"=="" ( - call :print_info "快速部署模式" - call :check_dependencies - call :init_config - call :build_image - call :start_services -) else ( - call :print_error "未知命令: %~1" - call :show_help - exit /b 1 -) - -goto :eof - -:: 执行主函数 -call :main %* diff --git a/docker_deployment_update.md b/docker_deployment_update.md deleted file mode 100644 index b68877b..0000000 --- a/docker_deployment_update.md +++ /dev/null @@ -1,207 +0,0 @@ -# Docker部署更新检查报告 - -## 📋 检查概述 - -对Docker部署配置进行了全面检查,评估是否需要更新以支持新增的AI回复功能和其他改进。 - -## ✅ 当前状态评估 - -### 🎯 **结论:Docker部署配置已经完善,无需重大更新** - -所有新增功能都已经在现有的Docker配置中得到支持。 - -## 📊 详细检查结果 - -### 1. **依赖包检查** ✅ -#### requirements.txt 状态:**完整** -``` -✅ openai>=1.65.5 # AI回复功能 -✅ python-dotenv>=1.0.1 # 环境变量支持 -✅ python-multipart>=0.0.6 # 文件上传支持 -✅ fastapi>=0.111 # Web框架 -✅ uvicorn[standard]>=0.29 # ASGI服务器 -✅ 其他所有必要依赖 -``` - -### 2. **环境变量配置** ✅ -#### .env.example 状态:**完整** -``` -✅ AI_REPLY_ENABLED=false -✅ DEFAULT_AI_MODEL=qwen-plus -✅ DEFAULT_AI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 -✅ AI_REQUEST_TIMEOUT=30 -✅ AI_MAX_TOKENS=100 -✅ 所有基础配置变量 -``` - -### 3. **Docker Compose配置** ✅ -#### docker-compose.yml 状态:**完整** -``` -✅ AI回复相关环境变量映射 -✅ 数据持久化配置 (/app/data, /app/logs, /app/backups) -✅ 健康检查配置 -✅ 资源限制配置 -✅ 网络配置 -✅ Nginx反向代理支持 -``` - -### 4. **Dockerfile配置** ✅ -#### Dockerfile 状态:**完整** -``` -✅ Python 3.11基础镜像 -✅ 所有系统依赖安装 -✅ 应用依赖安装 -✅ 工作目录配置 -✅ 端口暴露配置 -✅ 启动命令配置 -``` - -### 5. **数据持久化** ✅ -#### 挂载点配置:**完整** -``` -✅ ./data:/app/data:rw # 数据库文件 -✅ ./logs:/app/logs:rw # 日志文件 -✅ ./backups:/app/backups:rw # 备份文件 -✅ ./global_config.yml:/app/global_config.yml:ro # 配置文件 -``` - -### 6. **健康检查** ✅ -#### 健康检查配置:**完整** -``` -✅ HTTP健康检查端点 (/health) -✅ 检查间隔:30秒 -✅ 超时时间:10秒 -✅ 重试次数:3次 -✅ 启动等待:40秒 -``` - -## 🔍 新功能支持验证 - -### AI回复功能 ✅ -- **依赖支持**:openai库已包含 -- **配置支持**:所有AI相关环境变量已配置 -- **数据支持**:AI数据表会自动创建 -- **API支持**:FastAPI框架支持所有新接口 - -### 备份功能增强 ✅ -- **存储支持**:备份目录已挂载 -- **数据支持**:所有新表都包含在备份中 -- **权限支持**:容器有读写权限 - -### 商品管理功能 ✅ -- **文件上传**:python-multipart依赖已包含 -- **数据存储**:数据库挂载支持新表 -- **API支持**:FastAPI支持文件上传接口 - -## 💡 可选优化建议 - -虽然当前配置已经完善,但可以考虑以下优化: - -### 1. **添加AI服务健康检查** -```yaml -# 可选:添加AI服务连通性检查 -healthcheck: - test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:8080/api/ai/health', timeout=5)"] -``` - -### 2. **添加更多监控指标** -```yaml -# 可选:添加Prometheus监控 -environment: - - ENABLE_METRICS=true - - METRICS_PORT=9090 -``` - -### 3. **添加AI配置验证** -```yaml -# 可选:启动时验证AI配置 -environment: - - VALIDATE_AI_CONFIG=true -``` - -## 🚀 部署建议 - -### 生产环境部署 -1. **使用强密码** - ```bash - ADMIN_PASSWORD=$(openssl rand -base64 32) - JWT_SECRET_KEY=$(openssl rand -base64 32) - ``` - -2. **配置AI服务** - ```bash - AI_REPLY_ENABLED=true - # 配置真实的API密钥 - ``` - -3. **启用HTTPS** - ```bash - docker-compose --profile with-nginx up -d - ``` - -4. **配置资源限制** - ```bash - MEMORY_LIMIT=1024 # 如果使用AI功能,建议增加内存 - CPU_LIMIT=1.0 - ``` - -### 开发环境部署 -```bash -# 克隆项目 -git clone -cd xianyuapis - -# 复制环境变量 -cp .env.example .env - -# 启动服务 -docker-compose up -d - -# 查看日志 -docker-compose logs -f -``` - -## 📋 部署检查清单 - -### 部署前检查 ✅ -- [x] Docker和Docker Compose已安装 -- [x] 端口8080未被占用 -- [x] 有足够的磁盘空间(建议>2GB) -- [x] 网络连接正常 - -### 配置检查 ✅ -- [x] .env文件已配置 -- [x] global_config.yml文件存在 -- [x] data、logs、backups目录权限正确 -- [x] AI API密钥已配置(如果使用AI功能) - -### 功能验证 ✅ -- [x] Web界面可访问 -- [x] 账号管理功能正常 -- [x] 自动回复功能正常 -- [x] AI回复功能正常(如果启用) -- [x] 备份功能正常 - -## 🎉 总结 - -### ✅ **Docker部署配置完全就绪** - -1. **无需更新**:当前配置已支持所有新功能 -2. **开箱即用**:可直接部署使用 -3. **功能完整**:支持AI回复、备份、商品管理等所有功能 -4. **生产就绪**:包含安全、监控、资源限制等配置 - -### 🚀 **立即可用的部署命令** - -```bash -# 快速部署 -git clone -cd xianyuapis -cp .env.example .env -docker-compose up -d - -# 访问系统 -open http://localhost:8080 -``` - -**Docker部署配置已经完善,支持所有新功能,可以直接使用!** 🎉 diff --git a/fix-db-permissions.bat b/fix-db-permissions.bat deleted file mode 100644 index fdcb358..0000000 --- a/fix-db-permissions.bat +++ /dev/null @@ -1,156 +0,0 @@ -@echo off -chcp 65001 >nul -setlocal enabledelayedexpansion - -:: 修复数据库权限问题的脚本 (Windows版本) -:: 解决Docker容器中数据库无法创建的问题 - -title 数据库权限修复脚本 - -:: 颜色定义 -set "RED=[91m" -set "GREEN=[92m" -set "YELLOW=[93m" -set "BLUE=[94m" -set "NC=[0m" - -:: 打印带颜色的消息 -:print_info -echo %BLUE%[INFO]%NC% %~1 -goto :eof - -:print_success -echo %GREEN%[SUCCESS]%NC% %~1 -goto :eof - -:print_warning -echo %YELLOW%[WARNING]%NC% %~1 -goto :eof - -:print_error -echo %RED%[ERROR]%NC% %~1 -goto :eof - -echo ======================================== -echo 数据库权限修复脚本 -echo ======================================== -echo. - -:: 1. 停止现有容器 -call :print_info "停止现有容器..." -docker-compose down >nul 2>&1 - -:: 2. 检查并创建目录 -call :print_info "检查并创建必要目录..." - -for %%d in (data logs backups) do ( - if not exist "%%d" ( - call :print_info "创建目录: %%d" - mkdir "%%d" - ) - - if not exist "%%d" ( - call :print_error "目录 %%d 创建失败" - pause - exit /b 1 - ) - - call :print_success "目录 %%d 权限正常" -) - -:: 3. 检查现有数据库文件 -if exist "data\xianyu_data.db" ( - call :print_info "检查现有数据库文件..." - call :print_success "数据库文件存在" -) else ( - call :print_info "数据库文件不存在,将在启动时创建" -) - -:: 4. 测试数据库创建 -call :print_info "测试数据库创建..." -python -c " -import sqlite3 -import os - -db_path = 'data/test_db.sqlite' -try: - conn = sqlite3.connect(db_path) - conn.execute('CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY)') - conn.commit() - conn.close() - print('✅ 数据库创建测试成功') - if os.path.exists(db_path): - os.remove(db_path) -except Exception as e: - print(f'❌ 数据库创建测试失败: {e}') - exit(1) -" - -if !errorlevel! neq 0 ( - call :print_error "数据库创建测试失败" - pause - exit /b 1 -) - -:: 5. 重新构建并启动 -call :print_info "重新构建并启动服务..." -docker-compose build --no-cache -if !errorlevel! neq 0 ( - call :print_error "Docker镜像构建失败" - pause - exit /b 1 -) - -docker-compose up -d -if !errorlevel! neq 0 ( - call :print_error "服务启动失败" - pause - exit /b 1 -) - -:: 6. 等待服务启动 -call :print_info "等待服务启动..." -timeout /t 15 /nobreak >nul - -:: 7. 检查服务状态 -call :print_info "检查服务状态..." -docker-compose ps | findstr "Up" >nul -if !errorlevel! equ 0 ( - call :print_success "服务启动成功" - - :: 检查日志 - call :print_info "检查启动日志..." - docker-compose logs --tail=20 xianyu-app - - :: 测试健康检查 - call :print_info "测试健康检查..." - timeout /t 5 /nobreak >nul - curl -f http://localhost:8080/health >nul 2>&1 - if !errorlevel! equ 0 ( - call :print_success "健康检查通过" - ) else ( - call :print_warning "健康检查失败,但服务可能仍在启动中" - ) -) else ( - call :print_error "服务启动失败" - call :print_info "查看错误日志:" - docker-compose logs xianyu-app - pause - exit /b 1 -) - -echo. -call :print_success "数据库权限修复完成!" -echo. -call :print_info "服务信息:" -echo Web界面: http://localhost:8080 -echo 健康检查: http://localhost:8080/health -echo 默认账号: admin / admin123 -echo. -call :print_info "常用命令:" -echo 查看日志: docker-compose logs -f -echo 重启服务: docker-compose restart -echo 停止服务: docker-compose down -echo. - -pause diff --git a/fix-db-permissions.sh b/fix-db-permissions.sh deleted file mode 100644 index e6ea4ff..0000000 --- a/fix-db-permissions.sh +++ /dev/null @@ -1,167 +0,0 @@ -#!/bin/bash - -# 修复数据库权限问题的脚本 -# 解决Docker容器中数据库无法创建的问题 - -set -e - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -print_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -echo "========================================" -echo " 数据库权限修复脚本" -echo "========================================" -echo "" - -# 1. 停止现有容器 -print_info "停止现有容器..." -docker-compose down 2>/dev/null || true - -# 2. 检查并创建目录 -print_info "检查并创建必要目录..." -for dir in data logs backups; do - if [ ! -d "$dir" ]; then - print_info "创建目录: $dir" - mkdir -p "$dir" - fi - - # 设置权限 - chmod 755 "$dir" - - # 检查权限 - if [ ! -w "$dir" ]; then - print_error "目录 $dir 没有写权限" - - # 尝试修复权限 - print_info "尝试修复权限..." - sudo chmod 755 "$dir" 2>/dev/null || { - print_error "无法修复权限,请手动执行: sudo chmod 755 $dir" - exit 1 - } - fi - - print_success "目录 $dir 权限正常" -done - -# 3. 检查现有数据库文件 -if [ -f "data/xianyu_data.db" ]; then - print_info "检查现有数据库文件权限..." - if [ ! -w "data/xianyu_data.db" ]; then - print_warning "数据库文件没有写权限,尝试修复..." - chmod 644 "data/xianyu_data.db" - print_success "数据库文件权限已修复" - else - print_success "数据库文件权限正常" - fi -fi - -# 4. 检查Docker用户映射 -print_info "检查Docker用户映射..." -CURRENT_UID=$(id -u) -CURRENT_GID=$(id -g) - -print_info "当前用户 UID:GID = $CURRENT_UID:$CURRENT_GID" - -# 5. 创建测试数据库 -print_info "测试数据库创建..." -python3 -c " -import sqlite3 -import os - -db_path = 'data/test_db.sqlite' -try: - conn = sqlite3.connect(db_path) - conn.execute('CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY)') - conn.commit() - conn.close() - print('✅ 数据库创建测试成功') - os.remove(db_path) -except Exception as e: - print(f'❌ 数据库创建测试失败: {e}') - exit(1) -" || { - print_error "数据库创建测试失败" - exit 1 -} - -# 6. 更新docker-compose.yml用户映射 -print_info "检查docker-compose.yml用户映射..." -if ! grep -q "user:" docker-compose.yml; then - print_info "添加用户映射到docker-compose.yml..." - - # 备份原文件 - cp docker-compose.yml docker-compose.yml.backup - - # 在xianyu-app服务中添加user配置 - sed -i '/container_name: xianyu-auto-reply/a\ user: "'$CURRENT_UID':'$CURRENT_GID'"' docker-compose.yml - - print_success "用户映射已添加" -else - print_info "用户映射已存在" -fi - -# 7. 重新构建并启动 -print_info "重新构建并启动服务..." -docker-compose build --no-cache -docker-compose up -d - -# 8. 等待服务启动 -print_info "等待服务启动..." -sleep 10 - -# 9. 检查服务状态 -print_info "检查服务状态..." -if docker-compose ps | grep -q "Up"; then - print_success "服务启动成功" - - # 检查日志 - print_info "检查启动日志..." - docker-compose logs --tail=20 xianyu-app - - # 测试健康检查 - print_info "测试健康检查..." - sleep 5 - if curl -f http://localhost:8080/health >/dev/null 2>&1; then - print_success "健康检查通过" - else - print_warning "健康检查失败,但服务可能仍在启动中" - fi -else - print_error "服务启动失败" - print_info "查看错误日志:" - docker-compose logs xianyu-app - exit 1 -fi - -echo "" -print_success "数据库权限修复完成!" -echo "" -print_info "服务信息:" -echo " Web界面: http://localhost:8080" -echo " 健康检查: http://localhost:8080/health" -echo " 默认账号: admin / admin123" -echo "" -print_info "常用命令:" -echo " 查看日志: docker-compose logs -f" -echo " 重启服务: docker-compose restart" -echo " 停止服务: docker-compose down" diff --git a/fix-docker-warnings.bat b/fix-docker-warnings.bat deleted file mode 100644 index 6810784..0000000 --- a/fix-docker-warnings.bat +++ /dev/null @@ -1,144 +0,0 @@ -@echo off -chcp 65001 >nul -setlocal enabledelayedexpansion - -:: 修复Docker部署警告的快速脚本 (Windows版本) -:: 解决version过时和.env文件缺失问题 - -title Docker部署警告修复脚本 - -:: 颜色定义 -set "RED=[91m" -set "GREEN=[92m" -set "YELLOW=[93m" -set "BLUE=[94m" -set "NC=[0m" - -:: 打印带颜色的消息 -:print_info -echo %BLUE%[INFO]%NC% %~1 -goto :eof - -:print_success -echo %GREEN%[SUCCESS]%NC% %~1 -goto :eof - -:print_warning -echo %YELLOW%[WARNING]%NC% %~1 -goto :eof - -:print_error -echo %RED%[ERROR]%NC% %~1 -goto :eof - -echo ======================================== -echo Docker部署警告修复脚本 -echo ======================================== -echo. - -:: 1. 检查并创建.env文件 -call :print_info "检查 .env 文件..." -if not exist ".env" ( - if exist ".env.example" ( - call :print_info "从 .env.example 创建 .env 文件..." - copy ".env.example" ".env" >nul - call :print_success ".env 文件已创建" - ) else ( - call :print_warning ".env.example 文件不存在" - call :print_info "创建基本的 .env 文件..." - - ( - echo # 闲鱼自动回复系统 Docker 环境变量配置文件 - echo. - echo # 基础配置 - echo TZ=Asia/Shanghai - echo PYTHONUNBUFFERED=1 - echo LOG_LEVEL=INFO - echo. - echo # 数据库配置 - echo DB_PATH=/app/data/xianyu_data.db - echo. - echo # 服务配置 - echo WEB_PORT=8080 - echo. - echo # 安全配置 - echo ADMIN_USERNAME=admin - echo ADMIN_PASSWORD=admin123 - echo JWT_SECRET_KEY=xianyu-auto-reply-secret-key-2024 - echo. - echo # 资源限制 - echo MEMORY_LIMIT=512 - echo CPU_LIMIT=0.5 - echo MEMORY_RESERVATION=256 - echo CPU_RESERVATION=0.25 - echo. - echo # 自动回复配置 - echo AUTO_REPLY_ENABLED=true - echo WEBSOCKET_URL=wss://wss-goofish.dingtalk.com/ - echo HEARTBEAT_INTERVAL=15 - echo TOKEN_REFRESH_INTERVAL=3600 - ) > .env - - call :print_success "基本 .env 文件已创建" - ) -) else ( - call :print_success ".env 文件已存在" -) - -:: 2. 检查docker-compose.yml版本问题 -call :print_info "检查 docker-compose.yml 配置..." -findstr /B "version:" docker-compose.yml >nul 2>&1 -if !errorlevel! equ 0 ( - call :print_warning "发现过时的 version 字段" - call :print_info "移除 version 字段..." - - REM 备份原文件 - copy docker-compose.yml docker-compose.yml.backup >nul - - REM 创建临时文件,移除version行 - ( - for /f "tokens=*" %%a in (docker-compose.yml) do ( - echo %%a | findstr /B "version:" >nul - if !errorlevel! neq 0 ( - echo %%a - ) - ) - ) > docker-compose.yml.tmp - - REM 替换原文件 - move docker-compose.yml.tmp docker-compose.yml >nul - - call :print_success "已移除过时的 version 字段" - call :print_info "原文件已备份为 docker-compose.yml.backup" -) else ( - call :print_success "docker-compose.yml 配置正确" -) - -:: 3. 验证修复结果 -call :print_info "验证修复结果..." - -echo. -call :print_info "测试 Docker Compose 配置..." -docker-compose config >nul 2>&1 -if !errorlevel! equ 0 ( - call :print_success "Docker Compose 配置验证通过" -) else ( - call :print_error "Docker Compose 配置验证失败" - echo 请检查 docker-compose.yml 文件 - pause - exit /b 1 -) - -echo. -call :print_success "所有警告已修复!" -echo. -call :print_info "现在可以正常使用以下命令:" -echo docker-compose up -d # 启动服务 -echo docker-compose ps # 查看状态 -echo docker-compose logs -f # 查看日志 -echo. -call :print_info "如果需要恢复原配置:" -echo move docker-compose.yml.backup docker-compose.yml -echo. - -pause diff --git a/fix-docker-warnings.sh b/fix-docker-warnings.sh deleted file mode 100644 index 63d63e5..0000000 --- a/fix-docker-warnings.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/bash - -# 修复Docker部署警告的快速脚本 -# 解决version过时和.env文件缺失问题 - -set -e - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -print_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -echo "========================================" -echo " Docker部署警告修复脚本" -echo "========================================" -echo "" - -# 1. 检查并创建.env文件 -print_info "检查 .env 文件..." -if [ ! -f ".env" ]; then - if [ -f ".env.example" ]; then - print_info "从 .env.example 创建 .env 文件..." - cp .env.example .env - print_success ".env 文件已创建" - else - print_warning ".env.example 文件不存在" - print_info "创建基本的 .env 文件..." - cat > .env << 'EOF' -# 闲鱼自动回复系统 Docker 环境变量配置文件 - -# 基础配置 -TZ=Asia/Shanghai -PYTHONUNBUFFERED=1 -LOG_LEVEL=INFO - -# 数据库配置 -DB_PATH=/app/data/xianyu_data.db - -# 服务配置 -WEB_PORT=8080 - -# 安全配置 -ADMIN_USERNAME=admin -ADMIN_PASSWORD=admin123 -JWT_SECRET_KEY=xianyu-auto-reply-secret-key-2024 - -# 资源限制 -MEMORY_LIMIT=512 -CPU_LIMIT=0.5 -MEMORY_RESERVATION=256 -CPU_RESERVATION=0.25 - -# 自动回复配置 -AUTO_REPLY_ENABLED=true -WEBSOCKET_URL=wss://wss-goofish.dingtalk.com/ -HEARTBEAT_INTERVAL=15 -TOKEN_REFRESH_INTERVAL=3600 -EOF - print_success "基本 .env 文件已创建" - fi -else - print_success ".env 文件已存在" -fi - -# 2. 检查docker-compose.yml版本问题 -print_info "检查 docker-compose.yml 配置..." -if grep -q "^version:" docker-compose.yml 2>/dev/null; then - print_warning "发现过时的 version 字段" - print_info "移除 version 字段..." - - # 备份原文件 - cp docker-compose.yml docker-compose.yml.backup - - # 移除version行 - sed -i '/^version:/d' docker-compose.yml - sed -i '/^$/N;/^\n$/d' docker-compose.yml # 移除空行 - - print_success "已移除过时的 version 字段" - print_info "原文件已备份为 docker-compose.yml.backup" -else - print_success "docker-compose.yml 配置正确" -fi - -# 3. 检查env_file配置 -print_info "检查 env_file 配置..." -if grep -A1 "env_file:" docker-compose.yml | grep -q "required: false"; then - print_success "env_file 配置正确" -else - print_info "更新 env_file 配置为可选..." - - # 备份文件(如果还没备份) - if [ ! -f "docker-compose.yml.backup" ]; then - cp docker-compose.yml docker-compose.yml.backup - fi - - # 更新env_file配置 - sed -i '/env_file:/,+1c\ - env_file:\ - - path: .env\ - required: false' docker-compose.yml - - print_success "env_file 配置已更新" -fi - -# 4. 验证修复结果 -print_info "验证修复结果..." - -echo "" -print_info "测试 Docker Compose 配置..." -if docker-compose config >/dev/null 2>&1; then - print_success "Docker Compose 配置验证通过" -else - print_error "Docker Compose 配置验证失败" - echo "请检查 docker-compose.yml 文件" - exit 1 -fi - -echo "" -print_success "所有警告已修复!" -echo "" -print_info "现在可以正常使用以下命令:" -echo " docker-compose up -d # 启动服务" -echo " docker-compose ps # 查看状态" -echo " docker-compose logs -f # 查看日志" -echo "" -print_info "如果需要恢复原配置:" -echo " mv docker-compose.yml.backup docker-compose.yml" diff --git a/fix-websocket-issue.sh b/fix-websocket-issue.sh deleted file mode 100644 index d874a86..0000000 --- a/fix-websocket-issue.sh +++ /dev/null @@ -1,121 +0,0 @@ -#!/bin/bash - -# 快速修复WebSocket兼容性问题 -# 解决 "extra_headers" 参数不支持的问题 - -set -e - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -print_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -echo "🔧 WebSocket兼容性问题修复" -echo "================================" - -# 1. 检查当前websockets版本 -print_info "检查当前websockets版本..." -if command -v python3 &> /dev/null; then - PYTHON_CMD="python3" -elif command -v python &> /dev/null; then - PYTHON_CMD="python" -else - print_error "未找到Python解释器" - exit 1 -fi - -CURRENT_VERSION=$($PYTHON_CMD -c "import websockets; print(websockets.__version__)" 2>/dev/null || echo "未安装") -print_info "当前websockets版本: $CURRENT_VERSION" - -# 2. 测试WebSocket兼容性 -print_info "测试WebSocket兼容性..." -$PYTHON_CMD test-websocket-compatibility.py - -# 3. 停止现有服务 -print_info "停止现有Docker服务..." -docker-compose down 2>/dev/null || true - -# 4. 更新websockets版本 -print_info "更新websockets版本到兼容版本..." -if [ -f "requirements.txt" ]; then - # 备份原文件 - cp requirements.txt requirements.txt.backup - - # 更新websockets版本 - sed -i 's/websockets>=.*/websockets>=10.0,<13.0 # 兼容性版本范围/' requirements.txt - - print_success "requirements.txt已更新" -else - print_warning "requirements.txt文件不存在" -fi - -# 5. 重新构建Docker镜像 -print_info "重新构建Docker镜像..." -docker-compose build --no-cache - -# 6. 启动服务 -print_info "启动服务..." -docker-compose up -d - -# 7. 等待服务启动 -print_info "等待服务启动..." -sleep 15 - -# 8. 检查服务状态 -print_info "检查服务状态..." -if docker-compose ps | grep -q "Up"; then - print_success "✅ 服务启动成功!" - - # 检查WebSocket错误 - print_info "检查WebSocket连接状态..." - sleep 5 - - # 查看最近的日志 - echo "" - print_info "最近的服务日志:" - docker-compose logs --tail=20 xianyu-app | grep -E "(WebSocket|extra_headers|ERROR)" || echo "未发现WebSocket相关错误" - - # 测试健康检查 - if curl -f http://localhost:8080/health >/dev/null 2>&1; then - print_success "健康检查通过" - else - print_warning "健康检查失败,服务可能仍在启动中" - fi - -else - print_error "❌ 服务启动失败" - print_info "查看错误日志:" - docker-compose logs --tail=30 xianyu-app - exit 1 -fi - -echo "" -print_success "🎉 WebSocket兼容性问题修复完成!" -echo "" -print_info "服务信息:" -echo " Web界面: http://localhost:8080" -echo " 健康检查: http://localhost:8080/health" -echo " 默认账号: admin / admin123" -echo "" -print_info "如果仍有WebSocket问题,请:" -echo " 1. 查看日志: docker-compose logs -f xianyu-app" -echo " 2. 运行测试: python test-websocket-compatibility.py" -echo " 3. 检查网络连接和防火墙设置" diff --git a/fix_api_isolation.py b/fix_api_isolation.py deleted file mode 100644 index ed6bf2f..0000000 --- a/fix_api_isolation.py +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env python3 -""" -修复API接口的用户隔离问题 -""" - -import re -import os -from loguru import logger - -def fix_cards_api(): - """修复卡券管理API的用户隔离""" - logger.info("修复卡券管理API...") - - # 读取reply_server.py文件 - with open('reply_server.py', 'r', encoding='utf-8') as f: - content = f.read() - - # 修复获取卡券列表接口 - content = re.sub( - r'@app\.get\("/cards"\)\ndef get_cards\(_: None = Depends\(require_auth\)\):', - '@app.get("/cards")\ndef get_cards(current_user: Dict[str, Any] = Depends(get_current_user)):', - content - ) - - # 修复获取卡券列表的实现 - content = re.sub( - r'cards = db_manager\.get_all_cards\(\)\s+return cards', - '''# 只返回当前用户的卡券 - user_id = current_user['user_id'] - cards = db_manager.get_all_cards(user_id) - return cards''', - content, - flags=re.MULTILINE - ) - - # 修复创建卡券接口 - content = re.sub( - r'@app\.post\("/cards"\)\ndef create_card\(card_data: dict, _: None = Depends\(require_auth\)\):', - '@app.post("/cards")\ndef create_card(card_data: dict, current_user: Dict[str, Any] = Depends(get_current_user)):', - content - ) - - # 修复其他卡券接口... - # 这里需要更多的修复代码 - - # 写回文件 - with open('reply_server.py', 'w', encoding='utf-8') as f: - f.write(content) - - logger.info("✅ 卡券管理API修复完成") - -def fix_delivery_rules_api(): - """修复自动发货规则API的用户隔离""" - logger.info("修复自动发货规则API...") - - # 读取reply_server.py文件 - with open('reply_server.py', 'r', encoding='utf-8') as f: - content = f.read() - - # 修复获取发货规则列表接口 - content = re.sub( - r'@app\.get\("/delivery-rules"\)\ndef get_delivery_rules\(_: None = Depends\(require_auth\)\):', - '@app.get("/delivery-rules")\ndef get_delivery_rules(current_user: Dict[str, Any] = Depends(get_current_user)):', - content - ) - - # 修复其他发货规则接口... - - # 写回文件 - with open('reply_server.py', 'w', encoding='utf-8') as f: - f.write(content) - - logger.info("✅ 自动发货规则API修复完成") - -def fix_notification_channels_api(): - """修复通知渠道API的用户隔离""" - logger.info("修复通知渠道API...") - - # 读取reply_server.py文件 - with open('reply_server.py', 'r', encoding='utf-8') as f: - content = f.read() - - # 修复通知渠道接口... - - # 写回文件 - with open('reply_server.py', 'w', encoding='utf-8') as f: - f.write(content) - - logger.info("✅ 通知渠道API修复完成") - -def update_db_manager(): - """更新db_manager.py中的方法以支持用户隔离""" - logger.info("更新数据库管理器...") - - # 读取db_manager.py文件 - with open('db_manager.py', 'r', encoding='utf-8') as f: - content = f.read() - - # 修复get_all_cards方法 - if 'def get_all_cards(self, user_id: int = None):' not in content: - content = re.sub( - r'def get_all_cards\(self\):', - 'def get_all_cards(self, user_id: int = None):', - content - ) - - # 修复方法实现 - content = re.sub( - r'SELECT id, name, type, api_config, text_content, data_content,\s+description, enabled, created_at, updated_at\s+FROM cards\s+ORDER BY created_at DESC', - '''SELECT id, name, type, api_config, text_content, data_content, - description, enabled, created_at, updated_at - FROM cards - WHERE (user_id = ? OR ? IS NULL) - ORDER BY created_at DESC''', - content, - flags=re.MULTILINE - ) - - # 写回文件 - with open('db_manager.py', 'w', encoding='utf-8') as f: - f.write(content) - - logger.info("✅ 数据库管理器更新完成") - -def create_user_settings_api(): - """创建用户设置API""" - logger.info("创建用户设置API...") - - user_settings_api = ''' - -# ------------------------- 用户设置接口 ------------------------- - -@app.get('/user-settings') -def get_user_settings(current_user: Dict[str, Any] = Depends(get_current_user)): - """获取当前用户的设置""" - from db_manager import db_manager - try: - user_id = current_user['user_id'] - settings = db_manager.get_user_settings(user_id) - return settings - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.put('/user-settings/{key}') -def update_user_setting(key: str, setting_data: dict, current_user: Dict[str, Any] = Depends(get_current_user)): - """更新用户设置""" - from db_manager import db_manager - try: - user_id = current_user['user_id'] - value = setting_data.get('value') - description = setting_data.get('description', '') - - success = db_manager.set_user_setting(user_id, key, value, description) - if success: - return {'msg': 'setting updated', 'key': key, 'value': value} - else: - raise HTTPException(status_code=400, detail='更新失败') - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -@app.get('/user-settings/{key}') -def get_user_setting(key: str, current_user: Dict[str, Any] = Depends(get_current_user)): - """获取用户特定设置""" - from db_manager import db_manager - try: - user_id = current_user['user_id'] - setting = db_manager.get_user_setting(user_id, key) - if setting: - return setting - else: - raise HTTPException(status_code=404, detail='设置不存在') - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) -''' - - # 读取reply_server.py文件 - with open('reply_server.py', 'r', encoding='utf-8') as f: - content = f.read() - - # 在文件末尾添加用户设置API - if 'user-settings' not in content: - content += user_settings_api - - # 写回文件 - with open('reply_server.py', 'w', encoding='utf-8') as f: - f.write(content) - - logger.info("✅ 用户设置API创建完成") - else: - logger.info("用户设置API已存在") - -def add_user_settings_methods_to_db(): - """为db_manager添加用户设置相关方法""" - logger.info("为数据库管理器添加用户设置方法...") - - user_settings_methods = ''' - - # ==================== 用户设置管理方法 ==================== - - def get_user_settings(self, user_id: int): - """获取用户的所有设置""" - with self.lock: - try: - cursor = self.conn.cursor() - cursor.execute(''' - SELECT key, value, description, updated_at - FROM user_settings - WHERE user_id = ? - ORDER BY key - ''', (user_id,)) - - settings = {} - for row in cursor.fetchall(): - settings[row[0]] = { - 'value': row[1], - 'description': row[2], - 'updated_at': row[3] - } - - return settings - except Exception as e: - logger.error(f"获取用户设置失败: {e}") - return {} - - def get_user_setting(self, user_id: int, key: str): - """获取用户的特定设置""" - with self.lock: - try: - cursor = self.conn.cursor() - cursor.execute(''' - SELECT value, description, updated_at - FROM user_settings - WHERE user_id = ? AND key = ? - ''', (user_id, key)) - - row = cursor.fetchone() - if row: - return { - 'key': key, - 'value': row[0], - 'description': row[1], - 'updated_at': row[2] - } - return None - except Exception as e: - logger.error(f"获取用户设置失败: {e}") - return None - - def set_user_setting(self, user_id: int, key: str, value: str, description: str = None): - """设置用户配置""" - with self.lock: - try: - cursor = self.conn.cursor() - cursor.execute(''' - INSERT OR REPLACE INTO user_settings (user_id, key, value, description, updated_at) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) - ''', (user_id, key, value, description)) - - self.conn.commit() - logger.info(f"用户设置更新成功: user_id={user_id}, key={key}") - return True - except Exception as e: - logger.error(f"设置用户配置失败: {e}") - self.conn.rollback() - return False -''' - - # 读取db_manager.py文件 - with open('db_manager.py', 'r', encoding='utf-8') as f: - content = f.read() - - # 检查是否已经添加了用户设置方法 - if 'def get_user_settings(self, user_id: int):' not in content: - # 在类的末尾添加方法 - content = content.rstrip() + user_settings_methods - - # 写回文件 - with open('db_manager.py', 'w', encoding='utf-8') as f: - f.write(content) - - logger.info("✅ 用户设置方法添加完成") - else: - logger.info("用户设置方法已存在") - -def main(): - """主函数""" - print("🔧 修复API接口的用户隔离问题") - print("=" * 50) - - try: - # 1. 更新数据库管理器 - print("\n📦 1. 更新数据库管理器") - update_db_manager() - add_user_settings_methods_to_db() - - # 2. 修复卡券管理API - print("\n🎫 2. 修复卡券管理API") - fix_cards_api() - - # 3. 修复自动发货规则API - print("\n🚚 3. 修复自动发货规则API") - fix_delivery_rules_api() - - # 4. 修复通知渠道API - print("\n📢 4. 修复通知渠道API") - fix_notification_channels_api() - - # 5. 创建用户设置API - print("\n⚙️ 5. 创建用户设置API") - create_user_settings_api() - - print("\n" + "=" * 50) - print("🎉 API接口修复完成!") - - print("\n📋 修复内容:") - print("✅ 1. 更新数据库管理器方法") - print("✅ 2. 修复卡券管理API用户隔离") - print("✅ 3. 修复自动发货规则API用户隔离") - print("✅ 4. 修复通知渠道API用户隔离") - print("✅ 5. 创建用户设置API") - - print("\n⚠️ 注意:") - print("1. 部分接口可能需要手动调整") - print("2. 建议重启服务后进行测试") - print("3. 检查前端代码是否需要更新") - - return True - - except Exception as e: - logger.error(f"修复过程中出现错误: {e}") - return False - -if __name__ == "__main__": - main() diff --git a/fix_complete_isolation.py b/fix_complete_isolation.py deleted file mode 100644 index bf1987e..0000000 --- a/fix_complete_isolation.py +++ /dev/null @@ -1,317 +0,0 @@ -#!/usr/bin/env python3 -""" -完整的多用户数据隔离修复脚本 -""" - -import sqlite3 -import json -import time -from loguru import logger - -def backup_database(): - """备份数据库""" - try: - import shutil - timestamp = time.strftime("%Y%m%d_%H%M%S") - backup_file = f"xianyu_data_backup_{timestamp}.db" - shutil.copy2("xianyu_data.db", backup_file) - logger.info(f"数据库备份完成: {backup_file}") - return backup_file - except Exception as e: - logger.error(f"数据库备份失败: {e}") - return None - -def add_user_id_columns(): - """为相关表添加user_id字段""" - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - try: - # 检查并添加user_id字段到cards表 - cursor.execute("PRAGMA table_info(cards)") - columns = [column[1] for column in cursor.fetchall()] - - if 'user_id' not in columns: - logger.info("为cards表添加user_id字段...") - cursor.execute('ALTER TABLE cards ADD COLUMN user_id INTEGER REFERENCES users(id)') - logger.info("✅ cards表user_id字段添加成功") - else: - logger.info("cards表已有user_id字段") - - # 检查并添加user_id字段到delivery_rules表 - cursor.execute("PRAGMA table_info(delivery_rules)") - columns = [column[1] for column in cursor.fetchall()] - - if 'user_id' not in columns: - logger.info("为delivery_rules表添加user_id字段...") - cursor.execute('ALTER TABLE delivery_rules ADD COLUMN user_id INTEGER REFERENCES users(id)') - logger.info("✅ delivery_rules表user_id字段添加成功") - else: - logger.info("delivery_rules表已有user_id字段") - - # 检查并添加user_id字段到notification_channels表 - cursor.execute("PRAGMA table_info(notification_channels)") - columns = [column[1] for column in cursor.fetchall()] - - if 'user_id' not in columns: - logger.info("为notification_channels表添加user_id字段...") - cursor.execute('ALTER TABLE notification_channels ADD COLUMN user_id INTEGER REFERENCES users(id)') - logger.info("✅ notification_channels表user_id字段添加成功") - else: - logger.info("notification_channels表已有user_id字段") - - conn.commit() - return True - - except Exception as e: - logger.error(f"添加user_id字段失败: {e}") - conn.rollback() - return False - finally: - conn.close() - -def create_user_settings_table(): - """创建用户设置表""" - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - try: - # 检查表是否已存在 - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'") - if cursor.fetchone(): - logger.info("user_settings表已存在") - return True - - logger.info("创建user_settings表...") - cursor.execute(''' - CREATE TABLE user_settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - key TEXT NOT NULL, - value TEXT, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, key) - ) - ''') - - conn.commit() - logger.info("✅ user_settings表创建成功") - return True - - except Exception as e: - logger.error(f"创建user_settings表失败: {e}") - conn.rollback() - return False - finally: - conn.close() - -def migrate_existing_data(): - """迁移现有数据到admin用户""" - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - try: - # 获取admin用户ID - cursor.execute("SELECT id FROM users WHERE username = 'admin'") - admin_result = cursor.fetchone() - - if not admin_result: - logger.error("未找到admin用户,请先创建admin用户") - return False - - admin_id = admin_result[0] - logger.info(f"找到admin用户,ID: {admin_id}") - - # 迁移cards表数据 - cursor.execute("SELECT COUNT(*) FROM cards WHERE user_id IS NULL") - unbound_cards = cursor.fetchone()[0] - - if unbound_cards > 0: - logger.info(f"迁移 {unbound_cards} 个未绑定的卡券到admin用户...") - cursor.execute("UPDATE cards SET user_id = ? WHERE user_id IS NULL", (admin_id,)) - logger.info("✅ 卡券数据迁移完成") - - # 迁移delivery_rules表数据 - cursor.execute("SELECT COUNT(*) FROM delivery_rules WHERE user_id IS NULL") - unbound_rules = cursor.fetchone()[0] - - if unbound_rules > 0: - logger.info(f"迁移 {unbound_rules} 个未绑定的发货规则到admin用户...") - cursor.execute("UPDATE delivery_rules SET user_id = ? WHERE user_id IS NULL", (admin_id,)) - logger.info("✅ 发货规则数据迁移完成") - - # 迁移notification_channels表数据 - cursor.execute("SELECT COUNT(*) FROM notification_channels WHERE user_id IS NULL") - unbound_channels = cursor.fetchone()[0] - - if unbound_channels > 0: - logger.info(f"迁移 {unbound_channels} 个未绑定的通知渠道到admin用户...") - cursor.execute("UPDATE notification_channels SET user_id = ? WHERE user_id IS NULL", (admin_id,)) - logger.info("✅ 通知渠道数据迁移完成") - - conn.commit() - return True - - except Exception as e: - logger.error(f"数据迁移失败: {e}") - conn.rollback() - return False - finally: - conn.close() - -def verify_isolation(): - """验证数据隔离效果""" - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - try: - logger.info("验证数据隔离效果...") - - # 检查各表的用户分布 - tables = ['cards', 'delivery_rules', 'notification_channels'] - - for table in tables: - cursor.execute(f''' - SELECT u.username, COUNT(*) as count - FROM {table} t - JOIN users u ON t.user_id = u.id - GROUP BY u.id, u.username - ORDER BY count DESC - ''') - - results = cursor.fetchall() - logger.info(f"📊 {table} 表用户分布:") - for username, count in results: - logger.info(f" • {username}: {count} 条记录") - - # 检查是否有未绑定的数据 - cursor.execute(f"SELECT COUNT(*) FROM {table} WHERE user_id IS NULL") - unbound_count = cursor.fetchone()[0] - if unbound_count > 0: - logger.warning(f"⚠️ {table} 表还有 {unbound_count} 条未绑定用户的记录") - else: - logger.info(f"✅ {table} 表所有记录都已正确绑定用户") - - return True - - except Exception as e: - logger.error(f"验证失败: {e}") - return False - finally: - conn.close() - -def create_default_user_settings(): - """为现有用户创建默认设置""" - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - try: - logger.info("为现有用户创建默认设置...") - - # 获取所有用户 - cursor.execute("SELECT id, username FROM users") - users = cursor.fetchall() - - default_settings = [ - ('theme_color', '#1890ff', '主题颜色'), - ('language', 'zh-CN', '界面语言'), - ('notification_enabled', 'true', '通知开关'), - ('auto_refresh', 'true', '自动刷新'), - ] - - for user_id, username in users: - logger.info(f"为用户 {username} 创建默认设置...") - - for key, value, description in default_settings: - # 检查设置是否已存在 - cursor.execute( - "SELECT id FROM user_settings WHERE user_id = ? AND key = ?", - (user_id, key) - ) - - if not cursor.fetchone(): - cursor.execute(''' - INSERT INTO user_settings (user_id, key, value, description) - VALUES (?, ?, ?, ?) - ''', (user_id, key, value, description)) - - conn.commit() - logger.info("✅ 默认用户设置创建完成") - return True - - except Exception as e: - logger.error(f"创建默认用户设置失败: {e}") - conn.rollback() - return False - finally: - conn.close() - -def main(): - """主函数""" - print("🚀 完整的多用户数据隔离修复") - print("=" * 60) - - # 1. 备份数据库 - print("\n📦 1. 备份数据库") - backup_file = backup_database() - if not backup_file: - print("❌ 数据库备份失败,停止修复") - return False - - # 2. 添加user_id字段 - print("\n🔧 2. 添加用户隔离字段") - if not add_user_id_columns(): - print("❌ 添加user_id字段失败") - return False - - # 3. 创建用户设置表 - print("\n📋 3. 创建用户设置表") - if not create_user_settings_table(): - print("❌ 创建用户设置表失败") - return False - - # 4. 迁移现有数据 - print("\n📦 4. 迁移现有数据") - if not migrate_existing_data(): - print("❌ 数据迁移失败") - return False - - # 5. 创建默认用户设置 - print("\n⚙️ 5. 创建默认用户设置") - if not create_default_user_settings(): - print("❌ 创建默认用户设置失败") - return False - - # 6. 验证隔离效果 - print("\n🔍 6. 验证数据隔离") - if not verify_isolation(): - print("❌ 验证失败") - return False - - print("\n" + "=" * 60) - print("🎉 多用户数据隔离修复完成!") - - print("\n📋 修复内容:") - print("✅ 1. 为cards表添加用户隔离") - print("✅ 2. 为delivery_rules表添加用户隔离") - print("✅ 3. 为notification_channels表添加用户隔离") - print("✅ 4. 创建用户设置表") - print("✅ 5. 迁移现有数据到admin用户") - print("✅ 6. 创建默认用户设置") - - print("\n⚠️ 下一步:") - print("1. 重启服务以应用数据库更改") - print("2. 运行API接口修复脚本") - print("3. 测试多用户数据隔离") - print("4. 更新前端代码") - - print(f"\n💾 数据库备份文件: {backup_file}") - print("如有问题,可以使用备份文件恢复数据") - - return True - -if __name__ == "__main__": - main() diff --git a/fix_user_isolation.py b/fix_user_isolation.py deleted file mode 100644 index f6fef0d..0000000 --- a/fix_user_isolation.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -""" -修复多用户数据隔离的脚本 -""" - -import re - -def fix_user_isolation(): - """修复用户隔离问题""" - print("🔧 修复多用户数据隔离问题") - print("=" * 60) - - # 需要修复的接口列表 - interfaces_to_fix = [ - # 商品管理 - { - 'pattern': r'@app\.put\("/items/\{cookie_id\}/\{item_id\}"\)\ndef update_item_detail\(', - 'description': '更新商品详情接口', - 'add_user_check': True - }, - { - 'pattern': r'@app\.delete\("/items/\{cookie_id\}/\{item_id\}"\)\ndef delete_item_info\(', - 'description': '删除商品信息接口', - 'add_user_check': True - }, - { - 'pattern': r'@app\.delete\("/items/batch"\)\ndef batch_delete_items\(', - 'description': '批量删除商品接口', - 'add_user_check': True - }, - { - 'pattern': r'@app\.post\("/items/get-all-from-account"\)\nasync def get_all_items_from_account\(', - 'description': '从账号获取所有商品接口', - 'add_user_check': True - }, - { - 'pattern': r'@app\.post\("/items/get-by-page"\)\nasync def get_items_by_page\(', - 'description': '分页获取商品接口', - 'add_user_check': True - }, - # 卡券管理 - { - 'pattern': r'@app\.get\("/cards"\)\ndef get_cards\(', - 'description': '获取卡券列表接口', - 'add_user_check': False # 卡券是全局的,不需要用户隔离 - }, - # AI回复设置 - { - 'pattern': r'@app\.get\("/ai-reply-settings/\{cookie_id\}"\)\ndef get_ai_reply_settings\(', - 'description': 'AI回复设置接口', - 'add_user_check': True - }, - # 消息通知 - { - 'pattern': r'@app\.get\("/message-notifications/\{cid\}"\)\ndef get_account_notifications\(', - 'description': '获取账号消息通知接口', - 'add_user_check': True - }, - ] - - print("📋 需要修复的接口:") - for i, interface in enumerate(interfaces_to_fix, 1): - status = "✅ 需要用户检查" if interface['add_user_check'] else "ℹ️ 全局接口" - print(f" {i}. {interface['description']} - {status}") - - print("\n💡 修复建议:") - print("1. 将 '_: None = Depends(require_auth)' 替换为 'current_user: Dict[str, Any] = Depends(get_current_user)'") - print("2. 在需要用户检查的接口中添加用户权限验证") - print("3. 确保只返回当前用户的数据") - - print("\n🔍 检查当前状态...") - - # 读取reply_server.py文件 - try: - with open('reply_server.py', 'r', encoding='utf-8') as f: - content = f.read() - - # 统计还有多少接口使用旧的认证方式 - old_auth_count = len(re.findall(r'_: None = Depends\(require_auth\)', content)) - new_auth_count = len(re.findall(r'current_user: Dict\[str, Any\] = Depends\(get_current_user\)', content)) - - print(f"📊 认证方式统计:") - print(f" • 旧认证方式 (require_auth): {old_auth_count} 个接口") - print(f" • 新认证方式 (get_current_user): {new_auth_count} 个接口") - - if old_auth_count > 0: - print(f"\n⚠️ 还有 {old_auth_count} 个接口需要修复") - - # 找出具体哪些接口还没修复 - old_auth_interfaces = re.findall(r'@app\.\w+\([^)]+\)\s*\ndef\s+(\w+)\([^)]*_: None = Depends\(require_auth\)', content, re.MULTILINE) - - print("📝 未修复的接口:") - for interface in old_auth_interfaces: - print(f" • {interface}") - else: - print("\n🎉 所有接口都已使用新的认证方式!") - - # 检查是否有用户权限验证 - user_check_pattern = r'user_cookies = db_manager\.get_all_cookies\(user_id\)' - user_check_count = len(re.findall(user_check_pattern, content)) - - print(f"\n🔒 用户权限检查统计:") - print(f" • 包含用户权限检查的接口: {user_check_count} 个") - - return old_auth_count == 0 - - except Exception as e: - print(f"❌ 检查失败: {e}") - return False - -def generate_fix_template(): - """生成修复模板""" - print("\n📝 修复模板:") - print("-" * 40) - - template = ''' -# 修复前: -@app.get("/some-endpoint/{cid}") -def some_function(cid: str, _: None = Depends(require_auth)): - """接口描述""" - # 原有逻辑 - pass - -# 修复后: -@app.get("/some-endpoint/{cid}") -def some_function(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)): - """接口描述""" - try: - # 检查cookie是否属于当前用户 - user_id = current_user['user_id'] - from db_manager import db_manager - user_cookies = db_manager.get_all_cookies(user_id) - - if cid not in user_cookies: - raise HTTPException(status_code=403, detail="无权限访问该Cookie") - - # 原有逻辑 - pass - except HTTPException: - raise - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) -''' - - print(template) - -def check_database_isolation(): - """检查数据库隔离情况""" - print("\n🗄️ 检查数据库隔离情况") - print("-" * 40) - - try: - import sqlite3 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - # 检查cookies表是否有user_id字段 - cursor.execute("PRAGMA table_info(cookies)") - columns = [column[1] for column in cursor.fetchall()] - - if 'user_id' in columns: - print("✅ cookies表已支持用户隔离") - - # 统计用户数据分布 - cursor.execute(''' - SELECT u.username, COUNT(c.id) as cookie_count - FROM users u - LEFT JOIN cookies c ON u.id = c.user_id - GROUP BY u.id, u.username - ORDER BY cookie_count DESC - ''') - - user_stats = cursor.fetchall() - print("\n📊 用户数据分布:") - for username, cookie_count in user_stats: - print(f" • {username}: {cookie_count} 个cookies") - - # 检查未绑定的数据 - cursor.execute("SELECT COUNT(*) FROM cookies WHERE user_id IS NULL") - unbound_count = cursor.fetchone()[0] - if unbound_count > 0: - print(f"\n⚠️ 发现 {unbound_count} 个未绑定用户的cookies") - else: - print("\n✅ 所有cookies已正确绑定用户") - else: - print("❌ cookies表不支持用户隔离") - - conn.close() - - except Exception as e: - print(f"❌ 检查数据库失败: {e}") - -def main(): - """主函数""" - print("🚀 多用户数据隔离修复工具") - print("=" * 60) - - # 检查修复状态 - all_fixed = fix_user_isolation() - - # 生成修复模板 - generate_fix_template() - - # 检查数据库隔离 - check_database_isolation() - - print("\n" + "=" * 60) - if all_fixed: - print("🎉 多用户数据隔离检查完成!所有接口都已正确实现用户隔离。") - else: - print("⚠️ 还有接口需要修复,请按照模板进行修复。") - - print("\n📋 功能模块隔离状态:") - print("✅ 1. 账号管理 - Cookie相关接口已隔离") - print("✅ 2. 自动回复 - 关键字和默认回复已隔离") - print("❓ 3. 商品管理 - 部分接口需要检查") - print("❓ 4. 卡券管理 - 需要确认是否需要隔离") - print("❓ 5. 自动发货 - 需要检查") - print("❓ 6. 通知渠道 - 需要确认隔离策略") - print("❓ 7. 消息通知 - 需要检查") - print("❓ 8. 系统设置 - 需要确认哪些需要隔离") - - print("\n💡 下一步:") - print("1. 手动修复剩余的接口") - print("2. 测试所有功能的用户隔离") - print("3. 更新前端代码以支持多用户") - print("4. 编写完整的测试用例") - -if __name__ == "__main__": - main() diff --git a/gitignore_rules_explanation.md b/gitignore_rules_explanation.md deleted file mode 100644 index dacf1e4..0000000 --- a/gitignore_rules_explanation.md +++ /dev/null @@ -1,172 +0,0 @@ -# .gitignore 规则说明 - -## 📋 概述 - -本项目的 `.gitignore` 文件已经过优化,包含了完整的忽略规则,确保敏感文件和不必要的文件不会被提交到版本控制中。 - -## 🔧 主要修复 - -### 1. **数据库文件忽略** ✅ -**问题**: 原来缺少 `*.db` 文件的忽略规则 -**解决**: 添加了完整的数据库文件忽略规则 - -```gitignore -# Database files -*.db -*.sqlite -*.sqlite3 -db.sqlite3 -``` - -### 2. **静态资源例外** ✅ -**问题**: `lib/` 规则会忽略 `static/lib/` 中的本地 CDN 资源 -**解决**: 添加例外规则,允许 `static/lib/` 被版本控制 - -```gitignore -# Python lib directories (but not static/lib) -lib/ -!static/lib/ -``` - -## 📂 完整规则分类 - -### Python 相关 -```gitignore -__pycache__ -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -MANIFEST -*.manifest -*.spec -__pypackages__/ -.venv -venv/ -ENV/ -env.bak/ -venv.bak/ -``` - -### 数据库文件 -```gitignore -*.db -*.sqlite -*.sqlite3 -db.sqlite3 -``` - -### 日志和缓存 -```gitignore -*.log -.cache -``` - -### 临时文件 -```gitignore -*.tmp -*.temp -temp/ -tmp/ -``` - -### 操作系统生成的文件 -```gitignore -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db -``` - -### IDE 和编辑器文件 -```gitignore -.vscode/ -.idea/ -*.swp -*.swo -*~ -``` - -### 环境配置文件 -```gitignore -.env -.env.local -.env.*.local -local_settings.py -``` - -### Node.js 相关 -```gitignore -*node_modules/* -``` - -### 静态资源例外 -```gitignore -!static/lib/ -``` - -## 🎯 特殊说明 - -### 数据库文件保护 -- **目的**: 防止敏感的用户数据和配置信息被意外提交 -- **影响**: `xianyu_data.db` 等数据库文件不会被 Git 跟踪 -- **好处**: 保护用户隐私,避免数据泄露 - -### 静态资源管理 -- **目的**: 允许本地 CDN 资源被版本控制,提升中国大陆访问速度 -- **规则**: `lib/` 被忽略,但 `static/lib/` 不被忽略 -- **包含**: Bootstrap CSS/JS、Bootstrap Icons 等本地资源 - -### 环境配置保护 -- **目的**: 防止敏感的环境变量和配置被提交 -- **影响**: `.env` 文件和本地设置不会被跟踪 -- **好处**: 保护 API 密钥、数据库连接等敏感信息 - -## 🧪 验证方法 - -可以运行以下测试脚本验证规则是否正确: - -```bash -# 测试数据库文件忽略 -python test_gitignore_db.py - -# 测试静态资源例外 -python test_gitignore.py -``` - -## 📊 当前项目状态 - -### 被忽略的文件 -- `xianyu_data.db` (139,264 bytes) - 主数据库 -- `data/xianyu_data.db` (106,496 bytes) - 数据目录中的数据库 -- 各种临时文件、日志文件、IDE 配置等 - -### 不被忽略的重要文件 -- `static/lib/` 目录下的所有本地 CDN 资源 (702 KB) -- 源代码文件 (`.py`, `.html`, `.js` 等) -- 配置模板文件 (`.yml.example`, `.env.example` 等) -- 文档文件 (`.md` 等) - -## 🎉 优势总结 - -1. **数据安全**: 数据库文件不会被意外提交,保护用户数据 -2. **配置安全**: 环境变量和敏感配置得到保护 -3. **仓库整洁**: 临时文件、缓存文件等不会污染仓库 -4. **本地资源**: CDN 资源可以正常版本控制,提升访问速度 -5. **跨平台**: 支持 Windows、macOS、Linux 的常见忽略文件 -6. **IDE 友好**: 支持 VSCode、IntelliJ IDEA 等常见 IDE - -现在的 `.gitignore` 配置既保证了项目的安全性,又确保了必要文件的正常版本控制! diff --git a/migrate_to_multiuser.py b/migrate_to_multiuser.py deleted file mode 100644 index b760084..0000000 --- a/migrate_to_multiuser.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -""" -多用户系统迁移脚本 -将历史数据绑定到admin用户,并创建admin用户记录 -""" - -import sqlite3 -import hashlib -import time -from loguru import logger - -def migrate_to_multiuser(): - """迁移到多用户系统""" - print("🔄 开始迁移到多用户系统...") - print("=" * 60) - - try: - # 连接数据库 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - print("1️⃣ 检查数据库结构...") - - # 检查users表是否存在 - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") - users_table_exists = cursor.fetchone() is not None - - if not users_table_exists: - print(" ❌ users表不存在,请先运行主程序初始化数据库") - return False - - print(" ✅ users表已存在") - - # 检查是否已有admin用户 - cursor.execute("SELECT id FROM users WHERE username = 'admin'") - admin_user = cursor.fetchone() - - if admin_user: - admin_user_id = admin_user[0] - print(f" ✅ admin用户已存在,ID: {admin_user_id}") - else: - print("2️⃣ 创建admin用户...") - - # 获取当前的admin密码哈希 - cursor.execute("SELECT value FROM system_settings WHERE key = 'admin_password_hash'") - password_hash_row = cursor.fetchone() - - if password_hash_row: - admin_password_hash = password_hash_row[0] - else: - # 如果没有设置密码,使用默认密码 admin123 - admin_password_hash = hashlib.sha256('admin123'.encode()).hexdigest() - print(" ⚠️ 未找到admin密码,使用默认密码: admin123") - - # 创建admin用户 - cursor.execute(''' - INSERT INTO users (username, email, password_hash, is_active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ''', ('admin', 'admin@localhost', admin_password_hash, True, time.time(), time.time())) - - admin_user_id = cursor.lastrowid - print(f" ✅ admin用户创建成功,ID: {admin_user_id}") - - print("3️⃣ 检查cookies表结构...") - - # 检查cookies表是否有user_id字段 - cursor.execute("PRAGMA table_info(cookies)") - columns = [column[1] for column in cursor.fetchall()] - - if 'user_id' not in columns: - print(" 🔧 添加user_id字段到cookies表...") - cursor.execute("ALTER TABLE cookies ADD COLUMN user_id INTEGER") - print(" ✅ user_id字段添加成功") - else: - print(" ✅ user_id字段已存在") - - print("4️⃣ 迁移历史数据...") - - # 统计需要迁移的数据 - cursor.execute("SELECT COUNT(*) FROM cookies WHERE user_id IS NULL") - cookies_to_migrate = cursor.fetchone()[0] - - if cookies_to_migrate > 0: - print(f" 📊 发现 {cookies_to_migrate} 个cookies需要绑定到admin用户") - - # 将所有没有user_id的cookies绑定到admin用户 - cursor.execute("UPDATE cookies SET user_id = ? WHERE user_id IS NULL", (admin_user_id,)) - - print(f" ✅ 已将 {cookies_to_migrate} 个cookies绑定到admin用户") - else: - print(" ✅ 所有cookies已正确绑定用户") - - print("5️⃣ 验证迁移结果...") - - # 统计各用户的数据 - cursor.execute(''' - SELECT u.username, COUNT(c.id) as cookie_count - FROM users u - LEFT JOIN cookies c ON u.id = c.user_id - GROUP BY u.id, u.username - ''') - - user_stats = cursor.fetchall() - print(" 📊 用户数据统计:") - for username, cookie_count in user_stats: - print(f" • {username}: {cookie_count} 个cookies") - - # 检查是否还有未绑定的数据 - cursor.execute("SELECT COUNT(*) FROM cookies WHERE user_id IS NULL") - unbound_cookies = cursor.fetchone()[0] - - if unbound_cookies > 0: - print(f" ⚠️ 仍有 {unbound_cookies} 个cookies未绑定用户") - else: - print(" ✅ 所有cookies已正确绑定用户") - - # 提交事务 - conn.commit() - print("\n6️⃣ 迁移完成!") - - print("\n" + "=" * 60) - print("🎉 多用户系统迁移成功!") - print("\n📋 迁移总结:") - print(f" • admin用户ID: {admin_user_id}") - print(f" • 迁移的cookies数量: {cookies_to_migrate}") - print(f" • 当前用户数量: {len(user_stats)}") - - print("\n💡 下一步操作:") - print(" 1. 重启应用程序") - print(" 2. 使用admin账号登录管理现有数据") - print(" 3. 其他用户可以通过注册页面创建新账号") - print(" 4. 每个用户只能看到自己的数据") - - return True - - except Exception as e: - print(f"❌ 迁移失败: {e}") - if 'conn' in locals(): - conn.rollback() - return False - finally: - if 'conn' in locals(): - conn.close() - -def check_migration_status(): - """检查迁移状态""" - print("\n🔍 检查多用户系统状态...") - print("=" * 60) - - try: - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - # 检查users表 - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") - if not cursor.fetchone(): - print("❌ users表不存在,需要先运行主程序初始化数据库") - return - - # 检查用户数量 - cursor.execute("SELECT COUNT(*) FROM users") - user_count = cursor.fetchone()[0] - print(f"👥 用户数量: {user_count}") - - # 检查admin用户 - cursor.execute("SELECT id, username, email, is_active FROM users WHERE username = 'admin'") - admin_user = cursor.fetchone() - if admin_user: - print(f"👑 admin用户: ID={admin_user[0]}, 邮箱={admin_user[2]}, 状态={'激活' if admin_user[3] else '禁用'}") - else: - print("❌ admin用户不存在") - - # 检查cookies表结构 - cursor.execute("PRAGMA table_info(cookies)") - columns = [column[1] for column in cursor.fetchall()] - if 'user_id' in columns: - print("✅ cookies表已支持用户隔离") - - # 统计各用户的cookies - cursor.execute(''' - SELECT u.username, COUNT(c.id) as cookie_count - FROM users u - LEFT JOIN cookies c ON u.id = c.user_id - GROUP BY u.id, u.username - ORDER BY cookie_count DESC - ''') - - user_stats = cursor.fetchall() - print("\n📊 用户数据分布:") - for username, cookie_count in user_stats: - print(f" • {username}: {cookie_count} 个cookies") - - # 检查未绑定的数据 - cursor.execute("SELECT COUNT(*) FROM cookies WHERE user_id IS NULL") - unbound_count = cursor.fetchone()[0] - if unbound_count > 0: - print(f"\n⚠️ 发现 {unbound_count} 个未绑定用户的cookies,建议运行迁移") - else: - print("\n✅ 所有数据已正确绑定用户") - else: - print("❌ cookies表不支持用户隔离,需要运行迁移") - - except Exception as e: - print(f"❌ 检查失败: {e}") - finally: - if 'conn' in locals(): - conn.close() - -if __name__ == "__main__": - import sys - - if len(sys.argv) > 1 and sys.argv[1] == 'check': - check_migration_status() - else: - print("🚀 闲鱼自动回复系统 - 多用户迁移工具") - print("=" * 60) - print("此工具将帮助您将单用户系统迁移到多用户系统") - print("主要功能:") - print("• 创建admin用户账号") - print("• 将历史数据绑定到admin用户") - print("• 支持新用户注册和数据隔离") - print("\n⚠️ 重要提醒:") - print("• 迁移前请备份数据库文件") - print("• 迁移过程中请勿操作系统") - print("• 迁移完成后需要重启应用") - - confirm = input("\n是否继续迁移?(y/N): ").strip().lower() - if confirm in ['y', 'yes']: - success = migrate_to_multiuser() - if success: - print("\n🎊 迁移完成!请重启应用程序。") - else: - print("\n💥 迁移失败!请检查错误信息。") - else: - print("取消迁移。") - - print(f"\n💡 提示: 运行 'python {sys.argv[0]} check' 可以检查迁移状态") diff --git a/qq-group.png b/qq-group.png new file mode 100644 index 0000000000000000000000000000000000000000..f351cf10c7a8cca2b076fae39bddb34ef39269fd GIT binary patch literal 146989 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 Lj5{w{+RyzBi+_-M literal 0 HcmV?d00001 diff --git a/quick-fix-permissions.bat b/quick-fix-permissions.bat deleted file mode 100644 index 762e0e5..0000000 --- a/quick-fix-permissions.bat +++ /dev/null @@ -1,116 +0,0 @@ -@echo off -chcp 65001 >nul -setlocal enabledelayedexpansion - -:: 快速修复Docker权限问题 (Windows版本) - -title 快速修复Docker权限问题 - -:: 颜色定义 -set "RED=[91m" -set "GREEN=[92m" -set "YELLOW=[93m" -set "BLUE=[94m" -set "NC=[0m" - -:print_info -echo %BLUE%[INFO]%NC% %~1 -goto :eof - -:print_success -echo %GREEN%[SUCCESS]%NC% %~1 -goto :eof - -:print_error -echo %RED%[ERROR]%NC% %~1 -goto :eof - -echo 🚀 快速修复Docker权限问题 -echo ================================ -echo. - -:: 1. 停止容器 -call :print_info "停止现有容器..." -docker-compose down >nul 2>&1 - -:: 2. 确保目录存在 -call :print_info "创建必要目录..." -if not exist "data" mkdir data -if not exist "logs" mkdir logs -if not exist "backups" mkdir backups - -:: 3. 检查并修复docker-compose.yml -call :print_info "检查docker-compose.yml配置..." -findstr /C:"user.*0:0" docker-compose.yml >nul 2>&1 -if !errorlevel! neq 0 ( - call :print_info "添加root用户配置..." - - REM 备份原文件 - copy docker-compose.yml docker-compose.yml.backup >nul - - REM 创建临时文件添加user配置 - ( - for /f "tokens=*" %%a in (docker-compose.yml) do ( - echo %%a - echo %%a | findstr /C:"container_name: xianyu-auto-reply" >nul - if !errorlevel! equ 0 ( - echo user: "0:0" - ) - ) - ) > docker-compose.yml.tmp - - REM 替换原文件 - move docker-compose.yml.tmp docker-compose.yml >nul - - call :print_success "已配置使用root用户运行" -) - -:: 4. 重新构建镜像 -call :print_info "重新构建Docker镜像..." -docker-compose build --no-cache -if !errorlevel! neq 0 ( - call :print_error "Docker镜像构建失败" - pause - exit /b 1 -) - -:: 5. 启动服务 -call :print_info "启动服务..." -docker-compose up -d -if !errorlevel! neq 0 ( - call :print_error "服务启动失败" - pause - exit /b 1 -) - -:: 6. 等待启动 -call :print_info "等待服务启动..." -timeout /t 15 /nobreak >nul - -:: 7. 检查状态 -call :print_info "检查服务状态..." -docker-compose ps | findstr "Up" >nul -if !errorlevel! equ 0 ( - call :print_success "✅ 服务启动成功!" - - echo. - call :print_info "最近的日志:" - docker-compose logs --tail=10 xianyu-app - - echo. - call :print_success "🎉 权限问题已修复!" - echo. - echo 访问信息: - echo Web界面: http://localhost:8080 - echo 健康检查: http://localhost:8080/health - echo 默认账号: admin / admin123 - -) else ( - call :print_error "❌ 服务启动失败" - echo. - call :print_info "错误日志:" - docker-compose logs xianyu-app -) - -echo. -pause diff --git a/quick-fix-permissions.sh b/quick-fix-permissions.sh deleted file mode 100644 index fbe55de..0000000 --- a/quick-fix-permissions.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash - -# 快速修复Docker权限问题 -# 这个脚本会立即解决权限问题并重启服务 - -set -e - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -print_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -echo "🚀 快速修复Docker权限问题" -echo "================================" - -# 1. 停止容器 -print_info "停止现有容器..." -docker-compose down - -# 2. 确保目录存在并设置权限 -print_info "设置目录权限..." -mkdir -p data logs backups -chmod 777 data logs backups - -# 3. 检查并修复docker-compose.yml -print_info "检查docker-compose.yml配置..." -if ! grep -q "user.*0:0" docker-compose.yml; then - print_info "添加root用户配置..." - - # 备份原文件 - cp docker-compose.yml docker-compose.yml.backup - - # 在container_name后添加user配置 - sed -i '/container_name: xianyu-auto-reply/a\ user: "0:0"' docker-compose.yml - - print_success "已配置使用root用户运行" -fi - -# 4. 重新构建镜像 -print_info "重新构建Docker镜像..." -docker-compose build --no-cache - -# 5. 启动服务 -print_info "启动服务..." -docker-compose up -d - -# 6. 等待启动 -print_info "等待服务启动..." -sleep 15 - -# 7. 检查状态 -print_info "检查服务状态..." -if docker-compose ps | grep -q "Up"; then - print_success "✅ 服务启动成功!" - - # 显示日志 - echo "" - print_info "最近的日志:" - docker-compose logs --tail=10 xianyu-app - - echo "" - print_success "🎉 权限问题已修复!" - echo "" - echo "访问信息:" - echo " Web界面: http://localhost:8080" - echo " 健康检查: http://localhost:8080/health" - echo " 默认账号: admin / admin123" - -else - print_error "❌ 服务启动失败" - echo "" - print_info "错误日志:" - docker-compose logs xianyu-app -fi diff --git a/reply_server.py b/reply_server.py index d37984a..f647df6 100644 --- a/reply_server.py +++ b/reply_server.py @@ -66,8 +66,10 @@ KEYWORDS_MAPPING = load_keywords() # 认证相关模型 class LoginRequest(BaseModel): - username: str - password: str + username: Optional[str] = None + password: Optional[str] = None + email: Optional[str] = None + verification_code: Optional[str] = None class LoginResponse(BaseModel): @@ -91,7 +93,8 @@ class RegisterResponse(BaseModel): class SendCodeRequest(BaseModel): email: str - session_id: str + session_id: Optional[str] = None + type: Optional[str] = 'register' # 'register' 或 'login' class SendCodeResponse(BaseModel): @@ -414,38 +417,70 @@ async def data_management_page(): async def login(request: LoginRequest): from db_manager import db_manager - logger.info(f"【{request.username}】尝试登录") + # 判断登录方式 + if request.username and request.password: + # 用户名/密码登录 + logger.info(f"【{request.username}】尝试用户名登录") - # 首先检查是否是admin用户(向后兼容) - if request.username == ADMIN_USERNAME and db_manager.verify_admin_password(request.password): - # 获取admin用户信息 - admin_user = db_manager.get_user_by_username('admin') - if admin_user: - user_id = admin_user['id'] - else: - user_id = 1 # 默认admin用户ID + # 首先检查是否是admin用户(向后兼容) + if request.username == ADMIN_USERNAME and db_manager.verify_admin_password(request.password): + # 获取admin用户信息 + admin_user = db_manager.get_user_by_username('admin') + if admin_user: + user_id = admin_user['id'] + else: + user_id = 1 # 默认admin用户ID - # 生成token - token = generate_token() - SESSION_TOKENS[token] = { - 'user_id': user_id, - 'username': 'admin', - 'timestamp': time.time() - } + # 生成token + token = generate_token() + SESSION_TOKENS[token] = { + 'user_id': user_id, + 'username': 'admin', + 'timestamp': time.time() + } - logger.info(f"【admin#{user_id}】登录成功(管理员)") + logger.info(f"【admin#{user_id}】登录成功(管理员)") + return LoginResponse( + success=True, + token=token, + message="登录成功", + user_id=user_id + ) + + # 检查普通用户 + if db_manager.verify_user_password(request.username, request.password): + user = db_manager.get_user_by_username(request.username) + if user: + # 生成token + token = generate_token() + SESSION_TOKENS[token] = { + 'user_id': user['id'], + 'username': user['username'], + 'timestamp': time.time() + } + + logger.info(f"【{user['username']}#{user['id']}】登录成功") + + return LoginResponse( + success=True, + token=token, + message="登录成功", + user_id=user['id'] + ) + + logger.warning(f"【{request.username}】登录失败:用户名或密码错误") return LoginResponse( - success=True, - token=token, - message="登录成功", - user_id=user_id + success=False, + message="用户名或密码错误" ) - # 检查普通用户 - if db_manager.verify_user_password(request.username, request.password): - user = db_manager.get_user_by_username(request.username) - if user: + elif request.email and request.password: + # 邮箱/密码登录 + logger.info(f"【{request.email}】尝试邮箱密码登录") + + user = db_manager.get_user_by_email(request.email) + if user and db_manager.verify_user_password(user['username'], request.password): # 生成token token = generate_token() SESSION_TOKENS[token] = { @@ -454,7 +489,7 @@ async def login(request: LoginRequest): 'timestamp': time.time() } - logger.info(f"【{user['username']}#{user['id']}】登录成功") + logger.info(f"【{user['username']}#{user['id']}】邮箱登录成功") return LoginResponse( success=True, @@ -463,11 +498,55 @@ async def login(request: LoginRequest): user_id=user['id'] ) - logger.warning(f"【{request.username}】登录失败: 用户名或密码错误") - return LoginResponse( - success=False, - message="用户名或密码错误" - ) + logger.warning(f"【{request.email}】邮箱登录失败:邮箱或密码错误") + return LoginResponse( + success=False, + message="邮箱或密码错误" + ) + + elif request.email and request.verification_code: + # 邮箱/验证码登录 + logger.info(f"【{request.email}】尝试邮箱验证码登录") + + # 验证邮箱验证码 + if not db_manager.verify_email_code(request.email, request.verification_code, 'login'): + logger.warning(f"【{request.email}】验证码登录失败:验证码错误或已过期") + return LoginResponse( + success=False, + message="验证码错误或已过期" + ) + + # 获取用户信息 + user = db_manager.get_user_by_email(request.email) + if not user: + logger.warning(f"【{request.email}】验证码登录失败:用户不存在") + return LoginResponse( + success=False, + message="用户不存在" + ) + + # 生成token + token = generate_token() + SESSION_TOKENS[token] = { + 'user_id': user['id'], + 'username': user['username'], + 'timestamp': time.time() + } + + logger.info(f"【{user['username']}#{user['id']}】验证码登录成功") + + return LoginResponse( + success=True, + token=token, + message="登录成功", + user_id=user['id'] + ) + + else: + return LoginResponse( + success=False, + message="请提供有效的登录信息" + ) # 验证token接口 @@ -578,19 +657,29 @@ async def send_verification_code(request: SendCodeRequest): # 或者我们可以在验证成功后设置一个临时标记 pass - # 检查邮箱是否已注册 - existing_user = db_manager.get_user_by_email(request.email) - if existing_user: - return SendCodeResponse( - success=False, - message="该邮箱已被注册" - ) + # 根据验证码类型进行不同的检查 + if request.type == 'register': + # 注册验证码:检查邮箱是否已注册 + existing_user = db_manager.get_user_by_email(request.email) + if existing_user: + return SendCodeResponse( + success=False, + message="该邮箱已被注册" + ) + elif request.type == 'login': + # 登录验证码:检查邮箱是否存在 + existing_user = db_manager.get_user_by_email(request.email) + if not existing_user: + return SendCodeResponse( + success=False, + message="该邮箱未注册" + ) # 生成验证码 code = db_manager.generate_verification_code() # 保存验证码到数据库 - if not db_manager.save_verification_code(request.email, code): + if not db_manager.save_verification_code(request.email, code, request.type): return SendCodeResponse( success=False, message="验证码保存失败,请稍后重试" @@ -788,7 +877,7 @@ def add_cookie(item: CookieIn, current_user: Dict[str, Any] = Depends(get_curren user_id = current_user['user_id'] from db_manager import db_manager - log_with_user('info', f"尝试添加Cookie: {item.id}", current_user) + log_with_user('info', f"尝试添加Cookie: {item.id}, 当前用户ID: {user_id}, 用户名: {current_user.get('username', 'unknown')}", current_user) # 检查cookie是否已存在且属于其他用户 existing_cookies = db_manager.get_all_cookies() @@ -802,8 +891,8 @@ def add_cookie(item: CookieIn, current_user: Dict[str, Any] = Depends(get_curren # 保存到数据库时指定用户ID db_manager.save_cookie(item.id, item.value, user_id) - # 添加到CookieManager - cookie_manager.manager.add_cookie(item.id, item.value) + # 添加到CookieManager,同时指定用户ID + cookie_manager.manager.add_cookie(item.id, item.value, user_id=user_id) log_with_user('info', f"Cookie添加成功: {item.id}", current_user) return {"msg": "success"} except HTTPException: @@ -946,24 +1035,27 @@ def delete_default_reply(cid: str, current_user: Dict[str, Any] = Depends(get_cu # ------------------------- 通知渠道管理接口 ------------------------- @app.get('/notification-channels') -def get_notification_channels(_: None = Depends(require_auth)): +def get_notification_channels(current_user: Dict[str, Any] = Depends(get_current_user)): """获取所有通知渠道""" from db_manager import db_manager try: - return db_manager.get_notification_channels() + user_id = current_user['user_id'] + return db_manager.get_notification_channels(user_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post('/notification-channels') -def create_notification_channel(channel_data: NotificationChannelIn, _: None = Depends(require_auth)): +def create_notification_channel(channel_data: NotificationChannelIn, current_user: Dict[str, Any] = Depends(get_current_user)): """创建通知渠道""" from db_manager import db_manager try: + user_id = current_user['user_id'] channel_id = db_manager.create_notification_channel( channel_data.name, channel_data.type, - channel_data.config + channel_data.config, + user_id ) return {'msg': 'notification channel created', 'id': channel_id} except Exception as e: @@ -1327,27 +1419,30 @@ def update_card(card_id: int, card_data: dict, _: None = Depends(require_auth)): # 自动发货规则API @app.get("/delivery-rules") -def get_delivery_rules(_: None = Depends(require_auth)): +def get_delivery_rules(current_user: Dict[str, Any] = Depends(get_current_user)): """获取发货规则列表""" try: from db_manager import db_manager - rules = db_manager.get_all_delivery_rules() + user_id = current_user['user_id'] + rules = db_manager.get_all_delivery_rules(user_id) return rules except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @app.post("/delivery-rules") -def create_delivery_rule(rule_data: dict, _: None = Depends(require_auth)): +def create_delivery_rule(rule_data: dict, current_user: Dict[str, Any] = Depends(get_current_user)): """创建新发货规则""" try: from db_manager import db_manager + user_id = current_user['user_id'] rule_id = db_manager.create_delivery_rule( keyword=rule_data.get('keyword'), card_id=rule_data.get('card_id'), delivery_count=rule_data.get('delivery_count', 1), enabled=rule_data.get('enabled', True), - description=rule_data.get('description') + description=rule_data.get('description'), + user_id=user_id ) return {"id": rule_id, "message": "发货规则创建成功"} except Exception as e: @@ -1355,11 +1450,12 @@ def create_delivery_rule(rule_data: dict, _: None = Depends(require_auth)): @app.get("/delivery-rules/{rule_id}") -def get_delivery_rule(rule_id: int, _: None = Depends(require_auth)): +def get_delivery_rule(rule_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): """获取单个发货规则详情""" try: from db_manager import db_manager - rule = db_manager.get_delivery_rule_by_id(rule_id) + user_id = current_user['user_id'] + rule = db_manager.get_delivery_rule_by_id(rule_id, user_id) if rule: return rule else: @@ -1369,17 +1465,19 @@ def get_delivery_rule(rule_id: int, _: None = Depends(require_auth)): @app.put("/delivery-rules/{rule_id}") -def update_delivery_rule(rule_id: int, rule_data: dict, _: None = Depends(require_auth)): +def update_delivery_rule(rule_id: int, rule_data: dict, current_user: Dict[str, Any] = Depends(get_current_user)): """更新发货规则""" try: from db_manager import db_manager + user_id = current_user['user_id'] success = db_manager.update_delivery_rule( rule_id=rule_id, keyword=rule_data.get('keyword'), card_id=rule_data.get('card_id'), delivery_count=rule_data.get('delivery_count', 1), enabled=rule_data.get('enabled', True), - description=rule_data.get('description') + description=rule_data.get('description'), + user_id=user_id ) if success: return {"message": "发货规则更新成功"} @@ -1404,11 +1502,12 @@ def delete_card(card_id: int, _: None = Depends(require_auth)): @app.delete("/delivery-rules/{rule_id}") -def delete_delivery_rule(rule_id: int, _: None = Depends(require_auth)): +def delete_delivery_rule(rule_id: int, current_user: Dict[str, Any] = Depends(get_current_user)): """删除发货规则""" try: from db_manager import db_manager - success = db_manager.delete_delivery_rule(rule_id) + user_id = current_user['user_id'] + success = db_manager.delete_delivery_rule(rule_id, user_id) if success: return {"message": "发货规则删除成功"} else: diff --git a/simple_log_test.py b/simple_log_test.py deleted file mode 100644 index c0392e7..0000000 --- a/simple_log_test.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python3 -""" -简单的日志测试 -""" - -import requests -import time - -BASE_URL = "http://localhost:8080" - -def test_admin_login(): - """测试管理员登录的日志显示""" - print("🔧 测试管理员登录...") - - # 管理员登录 - response = requests.post(f"{BASE_URL}/login", - json={'username': 'admin', 'password': 'admin123'}) - - if response.json()['success']: - token = response.json()['token'] - headers = {'Authorization': f'Bearer {token}'} - - print("✅ 管理员登录成功") - - # 测试一些API调用 - print("📋 测试API调用...") - - # 1. 获取Cookie列表 - response = requests.get(f"{BASE_URL}/cookies", headers=headers) - print(f" Cookie列表: {response.status_code}") - - # 2. 获取Cookie详情 - response = requests.get(f"{BASE_URL}/cookies/details", headers=headers) - print(f" Cookie详情: {response.status_code}") - - # 3. 获取卡券列表 - response = requests.get(f"{BASE_URL}/cards", headers=headers) - print(f" 卡券列表: {response.status_code}") - - # 4. 获取用户设置 - response = requests.get(f"{BASE_URL}/user-settings", headers=headers) - print(f" 用户设置: {response.status_code}") - - print("✅ API调用测试完成") - return True - else: - print("❌ 管理员登录失败") - return False - -def main(): - """主函数""" - print("🚀 简单日志测试") - print("=" * 40) - - print("📋 测试内容:") - print("• 管理员登录日志") - print("• API请求/响应日志") - print("• 用户信息显示") - - print("\n🔍 请观察服务器日志输出...") - print("应该看到类似以下格式的日志:") - print("🌐 【admin#1】 API请求: GET /cookies") - print("✅ 【admin#1】 API响应: GET /cookies - 200 (0.005s)") - - print("\n" + "-" * 40) - - # 执行测试 - success = test_admin_login() - - print("-" * 40) - - if success: - print("🎉 测试完成!请检查服务器日志中的用户信息显示。") - print("\n💡 检查要点:") - print("1. 登录日志应显示: 【admin】尝试登录") - print("2. API请求日志应显示: 【admin#1】") - print("3. API响应日志应显示: 【admin#1】") - else: - print("❌ 测试失败") - - return success - -if __name__ == "__main__": - main() diff --git a/static/data_management.html b/static/data_management.html index 4efc64c..9281723 100644 --- a/static/data_management.html +++ b/static/data_management.html @@ -56,6 +56,9 @@ 用户管理 + + 数据管理 + 日志管理 @@ -156,7 +159,7 @@

-
+ - - - -
+ + -
- - - - diff --git a/static/user_management.html b/static/user_management.html index cae0628..5fade08 100644 --- a/static/user_management.html +++ b/static/user_management.html @@ -46,6 +46,12 @@ 首页 + + 用户管理 + + + 数据管理 + 日志管理 diff --git a/test_admin_features.py b/test_admin_features.py deleted file mode 100644 index 17712f0..0000000 --- a/test_admin_features.py +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env python3 -""" -测试管理员功能 -""" - -import requests -import json -import time -import sqlite3 -from loguru import logger - -BASE_URL = "http://localhost:8080" - -def test_admin_login(): - """测试管理员登录""" - logger.info("测试管理员登录...") - - response = requests.post(f"{BASE_URL}/login", - json={'username': 'admin', 'password': 'admin123'}) - - if response.json()['success']: - token = response.json()['token'] - user_id = response.json()['user_id'] - - logger.info(f"管理员登录成功,token: {token[:20]}...") - return { - 'token': token, - 'user_id': user_id, - 'headers': {'Authorization': f'Bearer {token}'} - } - else: - logger.error("管理员登录失败") - return None - -def test_user_management(admin): - """测试用户管理功能""" - logger.info("测试用户管理功能...") - - # 1. 获取所有用户 - response = requests.get(f"{BASE_URL}/admin/users", headers=admin['headers']) - if response.status_code == 200: - users = response.json()['users'] - logger.info(f"✅ 获取用户列表成功,共 {len(users)} 个用户") - - for user in users: - logger.info(f" 用户: {user['username']} (ID: {user['id']}) - Cookie: {user.get('cookie_count', 0)}, 卡券: {user.get('card_count', 0)}") - else: - logger.error(f"❌ 获取用户列表失败: {response.status_code}") - return False - - # 2. 测试非管理员权限验证 - logger.info("测试非管理员权限验证...") - - # 创建一个普通用户token(模拟) - fake_headers = {'Authorization': 'Bearer fake_token'} - response = requests.get(f"{BASE_URL}/admin/users", headers=fake_headers) - - if response.status_code == 401 or response.status_code == 403: - logger.info("✅ 非管理员权限验证正常") - else: - logger.warning(f"⚠️ 权限验证可能有问题: {response.status_code}") - - return True - -def test_system_stats(admin): - """测试系统统计功能""" - logger.info("测试系统统计功能...") - - response = requests.get(f"{BASE_URL}/admin/stats", headers=admin['headers']) - if response.status_code == 200: - stats = response.json() - logger.info("✅ 获取系统统计成功:") - logger.info(f" 总用户数: {stats['users']['total']}") - logger.info(f" 总Cookie数: {stats['cookies']['total']}") - logger.info(f" 总卡券数: {stats['cards']['total']}") - logger.info(f" 系统版本: {stats['system']['version']}") - return True - else: - logger.error(f"❌ 获取系统统计失败: {response.status_code}") - return False - -def test_log_management(admin): - """测试日志管理功能""" - logger.info("测试日志管理功能...") - - # 1. 获取系统日志 - response = requests.get(f"{BASE_URL}/admin/logs?lines=50", headers=admin['headers']) - if response.status_code == 200: - log_data = response.json() - logs = log_data.get('logs', []) - logger.info(f"✅ 获取系统日志成功,共 {len(logs)} 条") - - if logs: - logger.info(" 最新几条日志:") - for i, log in enumerate(logs[-3:]): # 显示最后3条 - logger.info(f" {i+1}. {log[:100]}...") - else: - logger.error(f"❌ 获取系统日志失败: {response.status_code}") - return False - - # 2. 测试日志级别过滤 - response = requests.get(f"{BASE_URL}/admin/logs?lines=20&level=info", headers=admin['headers']) - if response.status_code == 200: - log_data = response.json() - info_logs = log_data.get('logs', []) - logger.info(f"✅ INFO级别日志过滤成功,共 {len(info_logs)} 条") - else: - logger.error(f"❌ 日志级别过滤失败: {response.status_code}") - return False - - return True - -def create_test_user_for_deletion(): - """创建一个测试用户用于删除测试""" - logger.info("创建测试用户用于删除测试...") - - user_data = { - "username": "test_delete_user", - "email": "delete@test.com", - "password": "test123456" - } - - try: - # 清理可能存在的用户 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('DELETE FROM users WHERE username = ? OR email = ?', (user_data['username'], user_data['email'])) - cursor.execute('DELETE FROM email_verifications WHERE email = ?', (user_data['email'],)) - conn.commit() - conn.close() - - # 生成验证码 - session_id = f"delete_test_{int(time.time())}" - - # 生成图形验证码 - captcha_response = requests.post(f"{BASE_URL}/generate-captcha", - json={'session_id': session_id}) - if not captcha_response.json()['success']: - logger.error("图形验证码生成失败") - return None - - # 获取图形验证码 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM captcha_codes WHERE session_id = ? ORDER BY created_at DESC LIMIT 1', - (session_id,)) - captcha_result = cursor.fetchone() - conn.close() - - if not captcha_result: - logger.error("无法获取图形验证码") - return None - - captcha_code = captcha_result[0] - - # 验证图形验证码 - verify_response = requests.post(f"{BASE_URL}/verify-captcha", - json={'session_id': session_id, 'captcha_code': captcha_code}) - if not verify_response.json()['success']: - logger.error("图形验证码验证失败") - return None - - # 发送邮箱验证码 - email_response = requests.post(f"{BASE_URL}/send-verification-code", - json={'email': user_data['email'], 'session_id': session_id}) - if not email_response.json()['success']: - logger.error("邮箱验证码发送失败") - return None - - # 获取邮箱验证码 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM email_verifications WHERE email = ? ORDER BY created_at DESC LIMIT 1', - (user_data['email'],)) - email_result = cursor.fetchone() - conn.close() - - if not email_result: - logger.error("无法获取邮箱验证码") - return None - - email_code = email_result[0] - - # 注册用户 - register_response = requests.post(f"{BASE_URL}/register", - json={ - 'username': user_data['username'], - 'email': user_data['email'], - 'verification_code': email_code, - 'password': user_data['password'] - }) - - if register_response.json()['success']: - logger.info(f"测试用户创建成功: {user_data['username']}") - - # 获取用户ID - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT id FROM users WHERE username = ?', (user_data['username'],)) - result = cursor.fetchone() - conn.close() - - if result: - return { - 'user_id': result[0], - 'username': user_data['username'] - } - - logger.error("测试用户创建失败") - return None - - except Exception as e: - logger.error(f"创建测试用户失败: {e}") - return None - -def test_user_deletion(admin): - """测试用户删除功能""" - logger.info("测试用户删除功能...") - - # 创建测试用户 - test_user = create_test_user_for_deletion() - if not test_user: - logger.error("无法创建测试用户,跳过删除测试") - return False - - user_id = test_user['user_id'] - username = test_user['username'] - - # 删除用户 - response = requests.delete(f"{BASE_URL}/admin/users/{user_id}", headers=admin['headers']) - if response.status_code == 200: - logger.info(f"✅ 用户删除成功: {username}") - - # 验证用户确实被删除 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT id FROM users WHERE id = ?', (user_id,)) - result = cursor.fetchone() - conn.close() - - if not result: - logger.info("✅ 用户删除验证通过") - return True - else: - logger.error("❌ 用户删除验证失败,用户仍然存在") - return False - else: - logger.error(f"❌ 用户删除失败: {response.status_code}") - return False - -def test_admin_self_deletion_protection(admin): - """测试管理员自删除保护""" - logger.info("测试管理员自删除保护...") - - admin_user_id = admin['user_id'] - - response = requests.delete(f"{BASE_URL}/admin/users/{admin_user_id}", headers=admin['headers']) - if response.status_code == 400: - logger.info("✅ 管理员自删除保护正常") - return True - else: - logger.error(f"❌ 管理员自删除保护失败: {response.status_code}") - return False - -def main(): - """主测试函数""" - print("🚀 管理员功能测试") - print("=" * 60) - - print("📋 测试内容:") - print("• 管理员登录") - print("• 用户管理功能") - print("• 系统统计功能") - print("• 日志管理功能") - print("• 用户删除功能") - print("• 管理员保护机制") - - try: - # 管理员登录 - admin = test_admin_login() - if not admin: - print("❌ 测试失败:管理员登录失败") - return False - - print("✅ 管理员登录成功") - - # 测试各项功能 - tests = [ - ("用户管理", lambda: test_user_management(admin)), - ("系统统计", lambda: test_system_stats(admin)), - ("日志管理", lambda: test_log_management(admin)), - ("用户删除", lambda: test_user_deletion(admin)), - ("管理员保护", lambda: test_admin_self_deletion_protection(admin)) - ] - - results = [] - for test_name, test_func in tests: - print(f"\n🧪 测试 {test_name}...") - try: - result = test_func() - results.append((test_name, result)) - if result: - print(f"✅ {test_name} 测试通过") - else: - print(f"❌ {test_name} 测试失败") - except Exception as e: - print(f"💥 {test_name} 测试异常: {e}") - results.append((test_name, False)) - - print("\n" + "=" * 60) - print("🎉 管理员功能测试完成!") - - print("\n📊 测试结果:") - passed = 0 - for test_name, result in results: - status = "✅ 通过" if result else "❌ 失败" - print(f" {test_name}: {status}") - if result: - passed += 1 - - print(f"\n📈 总体结果: {passed}/{len(results)} 项测试通过") - - if passed == len(results): - print("🎊 所有测试都通过了!管理员功能正常工作。") - - print("\n💡 使用说明:") - print("1. 使用admin账号登录系统") - print("2. 在侧边栏可以看到'管理员功能'菜单") - print("3. 点击'用户管理'可以查看和删除用户") - print("4. 点击'系统日志'可以查看系统运行日志") - print("5. 这些功能只有admin用户可以访问") - - return True - else: - print("⚠️ 部分测试失败,请检查相关功能。") - return False - - except Exception as e: - print(f"💥 测试异常: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - main() diff --git a/test_ai_reply.py b/test_ai_reply.py deleted file mode 100644 index 430c355..0000000 --- a/test_ai_reply.py +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env python3 -""" -AI回复功能测试脚本 -用于验证AI回复集成是否正常工作 -""" - -import asyncio -import sys -import os - -# 添加项目根目录到Python路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from ai_reply_engine import ai_reply_engine -from db_manager import db_manager -from loguru import logger - -async def test_ai_reply_basic(): - """测试AI回复基本功能""" - print("🧪 开始测试AI回复基本功能...") - - # 测试数据 - test_cookie_id = "test_cookie_001" - test_item_id = "123456789" - test_message = "你好,这个商品能便宜点吗?" - test_chat_id = "test_chat_001" - test_user_id = "test_user_001" - - # 测试商品信息 - test_item_info = { - 'title': '测试商品', - 'price': 100, - 'desc': '这是一个用于测试的商品' - } - - print(f"📝 测试参数:") - print(f" 账号ID: {test_cookie_id}") - print(f" 商品ID: {test_item_id}") - print(f" 用户消息: {test_message}") - print(f" 商品信息: {test_item_info}") - - # 1. 测试AI回复是否启用检查 - print("\n1️⃣ 测试AI回复启用状态检查...") - is_enabled = ai_reply_engine.is_ai_enabled(test_cookie_id) - print(f" AI回复启用状态: {is_enabled}") - - if not is_enabled: - print(" ⚠️ AI回复未启用,跳过后续测试") - print(" 💡 请在Web界面中为测试账号启用AI回复功能") - return False - - # 2. 测试意图检测 - print("\n2️⃣ 测试意图检测...") - try: - intent = ai_reply_engine.detect_intent(test_message, test_cookie_id) - print(f" 检测到的意图: {intent}") - except Exception as e: - print(f" ❌ 意图检测失败: {e}") - return False - - # 3. 测试AI回复生成 - print("\n3️⃣ 测试AI回复生成...") - try: - reply = ai_reply_engine.generate_reply( - message=test_message, - item_info=test_item_info, - chat_id=test_chat_id, - cookie_id=test_cookie_id, - user_id=test_user_id, - item_id=test_item_id - ) - - if reply: - print(f" ✅ AI回复生成成功: {reply}") - else: - print(f" ❌ AI回复生成失败: 返回空值") - return False - - except Exception as e: - print(f" ❌ AI回复生成异常: {e}") - return False - - print("\n✅ AI回复基本功能测试完成!") - return True - -def test_database_operations(): - """测试数据库操作""" - print("\n🗄️ 开始测试数据库操作...") - - test_cookie_id = "test_cookie_001" - - # 1. 测试获取AI回复设置 - print("\n1️⃣ 测试获取AI回复设置...") - try: - settings = db_manager.get_ai_reply_settings(test_cookie_id) - print(f" AI回复设置: {settings}") - except Exception as e: - print(f" ❌ 获取AI回复设置失败: {e}") - return False - - # 2. 测试保存AI回复设置 - print("\n2️⃣ 测试保存AI回复设置...") - try: - test_settings = { - 'ai_enabled': True, - 'model_name': 'qwen-plus', - 'api_key': 'test-api-key', - 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'max_discount_percent': 10, - 'max_discount_amount': 100, - 'max_bargain_rounds': 3, - 'custom_prompts': '' - } - - success = db_manager.save_ai_reply_settings(test_cookie_id, test_settings) - if success: - print(f" ✅ AI回复设置保存成功") - else: - print(f" ❌ AI回复设置保存失败") - return False - - except Exception as e: - print(f" ❌ 保存AI回复设置异常: {e}") - return False - - # 3. 验证设置是否正确保存 - print("\n3️⃣ 验证设置保存...") - try: - saved_settings = db_manager.get_ai_reply_settings(test_cookie_id) - if saved_settings['ai_enabled'] == True: - print(f" ✅ 设置验证成功: AI回复已启用") - else: - print(f" ❌ 设置验证失败: AI回复未启用") - return False - except Exception as e: - print(f" ❌ 设置验证异常: {e}") - return False - - print("\n✅ 数据库操作测试完成!") - return True - -def test_configuration(): - """测试配置检查""" - print("\n⚙️ 开始测试配置检查...") - - # 1. 检查必要的模块导入 - print("\n1️⃣ 检查模块导入...") - try: - from openai import OpenAI - print(" ✅ OpenAI模块导入成功") - except ImportError as e: - print(f" ❌ OpenAI模块导入失败: {e}") - print(" 💡 请运行: pip install openai>=1.65.5") - return False - - # 2. 检查数据库表结构 - print("\n2️⃣ 检查数据库表结构...") - try: - # 检查ai_reply_settings表是否存在 - with db_manager.lock: - cursor = db_manager.conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_reply_settings'") - if cursor.fetchone(): - print(" ✅ ai_reply_settings表存在") - else: - print(" ❌ ai_reply_settings表不存在") - return False - - # 检查ai_conversations表是否存在 - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ai_conversations'") - if cursor.fetchone(): - print(" ✅ ai_conversations表存在") - else: - print(" ❌ ai_conversations表不存在") - return False - - except Exception as e: - print(f" ❌ 数据库表检查异常: {e}") - return False - - print("\n✅ 配置检查完成!") - return True - -async def main(): - """主测试函数""" - print("🚀 AI回复功能集成测试开始") - print("=" * 50) - - # 测试配置 - config_ok = test_configuration() - if not config_ok: - print("\n❌ 配置检查失败,请修复后重试") - return - - # 测试数据库操作 - db_ok = test_database_operations() - if not db_ok: - print("\n❌ 数据库操作测试失败") - return - - # 测试AI回复功能 - ai_ok = await test_ai_reply_basic() - if not ai_ok: - print("\n❌ AI回复功能测试失败") - return - - print("\n" + "=" * 50) - print("🎉 所有测试通过!AI回复功能集成成功!") - print("\n📋 下一步操作:") - print("1. 在Web界面中配置AI回复API密钥") - print("2. 为需要的账号启用AI回复功能") - print("3. 测试实际的消息回复效果") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/test_ai_reply_fix.py b/test_ai_reply_fix.py deleted file mode 100644 index 771f7c2..0000000 --- a/test_ai_reply_fix.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -""" -AI回复修复验证脚本 -验证settings变量作用域问题是否已修复 -""" - -import asyncio -import sys -import os - -# 添加项目根目录到Python路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from ai_reply_engine import ai_reply_engine -from db_manager import db_manager - -def setup_test_account(): - """设置测试账号""" - test_cookie_id = "test_fix_001" - - # 配置AI回复设置 - ai_settings = { - 'ai_enabled': True, - 'model_name': 'qwen-plus', - 'api_key': 'test-api-key', # 测试用假密钥 - 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'max_discount_percent': 10, - 'max_discount_amount': 100, - 'max_bargain_rounds': 3, - 'custom_prompts': '' - } - - success = db_manager.save_ai_reply_settings(test_cookie_id, ai_settings) - return test_cookie_id if success else None - -def test_settings_variable_scope(): - """测试settings变量作用域问题""" - print("🔧 测试settings变量作用域修复...") - - test_cookie_id = setup_test_account() - if not test_cookie_id: - print(" ❌ 测试账号设置失败") - return False - - # 测试数据 - test_item_info = { - 'title': '测试商品', - 'price': 100, - 'desc': '测试商品描述' - } - - test_chat_id = "test_chat_fix_001" - - # 清理测试数据 - try: - with db_manager.lock: - cursor = db_manager.conn.cursor() - cursor.execute('DELETE FROM ai_conversations WHERE cookie_id = ? AND chat_id = ?', - (test_cookie_id, test_chat_id)) - db_manager.conn.commit() - except: - pass - - print(f" 测试账号: {test_cookie_id}") - print(f" 测试对话: {test_chat_id}") - - # 测试1: 普通消息(非议价) - print(f"\n1️⃣ 测试普通消息处理...") - try: - reply = ai_reply_engine.generate_reply( - message="你好", - item_info=test_item_info, - chat_id=test_chat_id, - cookie_id=test_cookie_id, - user_id="test_user", - item_id="test_item" - ) - - # 由于使用测试API密钥,预期会失败,但不应该出现settings变量错误 - print(f" 普通消息测试完成(预期API调用失败)") - - except Exception as e: - error_msg = str(e) - if "cannot access local variable 'settings'" in error_msg: - print(f" ❌ settings变量作用域问题仍然存在: {error_msg}") - return False - else: - print(f" ✅ settings变量作用域问题已修复(其他错误: {error_msg[:50]}...)") - - # 测试2: 议价消息 - print(f"\n2️⃣ 测试议价消息处理...") - try: - # 先添加一些议价记录,测试轮数限制逻辑 - for i in range(3): # 添加3轮议价记录 - ai_reply_engine.save_conversation( - chat_id=test_chat_id, - cookie_id=test_cookie_id, - user_id="test_user", - item_id="test_item", - role="user", - content=f"第{i+1}次议价", - intent="price" - ) - - # 现在测试第4轮议价(应该被拒绝) - reply = ai_reply_engine.generate_reply( - message="能再便宜点吗?", - item_info=test_item_info, - chat_id=test_chat_id, - cookie_id=test_cookie_id, - user_id="test_user", - item_id="test_item" - ) - - if reply and "不能再便宜" in reply: - print(f" ✅ 议价轮数限制正常工作: {reply}") - else: - print(f" ⚠️ 议价消息处理完成,但结果可能不符合预期") - - except Exception as e: - error_msg = str(e) - if "cannot access local variable 'settings'" in error_msg: - print(f" ❌ settings变量作用域问题仍然存在: {error_msg}") - return False - else: - print(f" ✅ settings变量作用域问题已修复(其他错误: {error_msg[:50]}...)") - - # 测试3: 验证settings获取 - print(f"\n3️⃣ 测试settings获取...") - try: - settings = db_manager.get_ai_reply_settings(test_cookie_id) - print(f" ✅ settings获取成功:") - print(f" AI启用: {settings.get('ai_enabled')}") - print(f" 最大议价轮数: {settings.get('max_bargain_rounds')}") - print(f" 最大优惠百分比: {settings.get('max_discount_percent')}%") - - except Exception as e: - print(f" ❌ settings获取失败: {e}") - return False - - return True - -def main(): - """主测试函数""" - print("🚀 AI回复settings变量修复验证") - print("=" * 50) - - # 测试修复 - fix_ok = test_settings_variable_scope() - - if fix_ok: - print("\n" + "=" * 50) - print("🎉 修复验证成功!") - print("\n✅ 修复内容:") - print(" • settings变量作用域问题已解决") - print(" • 议价轮数限制功能正常") - print(" • AI回复流程完整") - print("\n💡 说明:") - print(" • 由于使用测试API密钥,AI调用会失败") - print(" • 但不会再出现settings变量错误") - print(" • 配置真实API密钥后即可正常使用") - else: - print("\n❌ 修复验证失败,请检查代码") - -if __name__ == "__main__": - main() diff --git a/test_backup_import.py b/test_backup_import.py deleted file mode 100644 index 7ced03a..0000000 --- a/test_backup_import.py +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env python3 -""" -备份和导入功能测试脚本 -验证所有表是否正确包含在备份中 -""" - -import asyncio -import sys -import os -import json -import tempfile - -# 添加项目根目录到Python路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from db_manager import db_manager -from loguru import logger - -def get_all_tables(): - """获取数据库中的所有表""" - try: - with db_manager.lock: - cursor = db_manager.conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") - tables = [row[0] for row in cursor.fetchall()] - return sorted(tables) - except Exception as e: - logger.error(f"获取表列表失败: {e}") - return [] - -def get_table_row_count(table_name): - """获取表的行数""" - try: - with db_manager.lock: - cursor = db_manager.conn.cursor() - cursor.execute(f"SELECT COUNT(*) FROM {table_name}") - return cursor.fetchone()[0] - except Exception as e: - logger.error(f"获取表 {table_name} 行数失败: {e}") - return 0 - -def create_test_data(): - """创建测试数据""" - print("📝 创建测试数据...") - - test_data_created = [] - - try: - # 1. 创建测试账号 - test_cookie_id = "test_backup_001" - success = db_manager.save_cookie(test_cookie_id, "test_cookie_value_for_backup") - if success: - test_data_created.append(f"账号: {test_cookie_id}") - - # 2. 创建关键词 - keywords = [("测试关键词1", "测试回复1"), ("测试关键词2", "测试回复2")] - success = db_manager.save_keywords(test_cookie_id, keywords) - if success: - test_data_created.append(f"关键词: {len(keywords)} 个") - - # 3. 创建AI回复设置 - ai_settings = { - 'ai_enabled': True, - 'model_name': 'qwen-plus', - 'api_key': 'test-backup-key', - 'base_url': 'https://test.com', - 'max_discount_percent': 10, - 'max_discount_amount': 100, - 'max_bargain_rounds': 3, - 'custom_prompts': '{"test": "prompt"}' - } - success = db_manager.save_ai_reply_settings(test_cookie_id, ai_settings) - if success: - test_data_created.append("AI回复设置") - - # 4. 创建默认回复 - success = db_manager.save_default_reply(test_cookie_id, True, "测试默认回复内容") - if success: - test_data_created.append("默认回复") - - # 5. 创建商品信息 - success = db_manager.save_item_basic_info( - test_cookie_id, "test_item_001", - "测试商品", "测试描述", "测试分类", "100", "测试详情" - ) - if success: - test_data_created.append("商品信息") - - print(f" ✅ 测试数据创建成功: {', '.join(test_data_created)}") - return True - - except Exception as e: - print(f" ❌ 创建测试数据失败: {e}") - return False - -def test_backup_export(): - """测试备份导出功能""" - print("\n📤 测试备份导出功能...") - - try: - # 获取所有表 - all_tables = get_all_tables() - print(f" 数据库中的表: {all_tables}") - - # 导出备份 - backup_data = db_manager.export_backup() - - if not backup_data: - print(" ❌ 备份导出失败") - return None - - # 检查备份数据结构 - print(f" ✅ 备份导出成功") - print(f" 备份版本: {backup_data.get('version')}") - print(f" 备份时间: {backup_data.get('timestamp')}") - - # 检查包含的表 - backed_up_tables = list(backup_data['data'].keys()) - print(f" 备份的表: {sorted(backed_up_tables)}") - - # 检查是否有遗漏的表 - missing_tables = set(all_tables) - set(backed_up_tables) - if missing_tables: - print(f" ⚠️ 未备份的表: {sorted(missing_tables)}") - else: - print(f" ✅ 所有表都已备份") - - # 检查每个表的数据量 - print(f"\n 📊 各表数据统计:") - for table in sorted(backed_up_tables): - row_count = len(backup_data['data'][table]['rows']) - print(f" {table}: {row_count} 行") - - return backup_data - - except Exception as e: - print(f" ❌ 备份导出异常: {e}") - return None - -def test_backup_import(backup_data): - """测试备份导入功能""" - print("\n📥 测试备份导入功能...") - - if not backup_data: - print(" ❌ 没有备份数据可导入") - return False - - try: - # 记录导入前的数据量 - print(" 📊 导入前数据统计:") - all_tables = get_all_tables() - before_counts = {} - for table in all_tables: - count = get_table_row_count(table) - before_counts[table] = count - print(f" {table}: {count} 行") - - # 执行导入 - success = db_manager.import_backup(backup_data) - - if not success: - print(" ❌ 备份导入失败") - return False - - print(" ✅ 备份导入成功") - - # 记录导入后的数据量 - print("\n 📊 导入后数据统计:") - after_counts = {} - for table in all_tables: - count = get_table_row_count(table) - after_counts[table] = count - print(f" {table}: {count} 行") - - # 检查数据一致性 - print("\n 🔍 数据一致性检查:") - for table in sorted(backup_data['data'].keys()): - expected_count = len(backup_data['data'][table]['rows']) - actual_count = after_counts.get(table, 0) - - if table == 'system_settings': - # 系统设置表可能保留管理员密码,所以数量可能不完全一致 - print(f" {table}: 期望 {expected_count}, 实际 {actual_count} (系统设置表)") - elif expected_count == actual_count: - print(f" {table}: ✅ 一致 ({actual_count} 行)") - else: - print(f" {table}: ❌ 不一致 (期望 {expected_count}, 实际 {actual_count})") - - return True - - except Exception as e: - print(f" ❌ 备份导入异常: {e}") - return False - -def test_backup_file_operations(): - """测试备份文件操作""" - print("\n💾 测试备份文件操作...") - - try: - # 导出备份 - backup_data = db_manager.export_backup() - if not backup_data: - print(" ❌ 导出备份失败") - return False - - # 保存到临时文件 - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f: - json.dump(backup_data, f, indent=2, ensure_ascii=False) - temp_file = f.name - - print(f" ✅ 备份保存到临时文件: {temp_file}") - - # 从文件读取 - with open(temp_file, 'r', encoding='utf-8') as f: - loaded_backup = json.load(f) - - print(f" ✅ 从文件读取备份成功") - - # 验证数据完整性 - if loaded_backup == backup_data: - print(f" ✅ 文件数据完整性验证通过") - else: - print(f" ❌ 文件数据完整性验证失败") - return False - - # 清理临时文件 - os.unlink(temp_file) - print(f" ✅ 临时文件清理完成") - - return True - - except Exception as e: - print(f" ❌ 备份文件操作异常: {e}") - return False - -def main(): - """主测试函数""" - print("🚀 备份和导入功能测试开始") - print("=" * 50) - - # 创建测试数据 - data_ok = create_test_data() - if not data_ok: - print("\n❌ 测试数据创建失败") - return - - # 测试备份导出 - backup_data = test_backup_export() - if not backup_data: - print("\n❌ 备份导出测试失败") - return - - # 测试备份导入 - import_ok = test_backup_import(backup_data) - if not import_ok: - print("\n❌ 备份导入测试失败") - return - - # 测试文件操作 - file_ok = test_backup_file_operations() - if not file_ok: - print("\n❌ 备份文件操作测试失败") - return - - print("\n" + "=" * 50) - print("🎉 所有测试通过!备份和导入功能正常!") - print("\n✅ 功能验证:") - print(" • 所有13个表都包含在备份中") - print(" • 备份导出功能正常") - print(" • 备份导入功能正常") - print(" • 数据完整性保持") - print(" • 文件操作正常") - print("\n📋 包含的表:") - - # 显示所有备份的表 - if backup_data and 'data' in backup_data: - for table in sorted(backup_data['data'].keys()): - row_count = len(backup_data['data'][table]['rows']) - print(f" • {table}: {row_count} 行数据") - -if __name__ == "__main__": - main() diff --git a/test_bargain_limit.py b/test_bargain_limit.py deleted file mode 100644 index f73e9bd..0000000 --- a/test_bargain_limit.py +++ /dev/null @@ -1,267 +0,0 @@ -#!/usr/bin/env python3 -""" -议价轮数限制功能测试脚本 -用于验证最大议价轮数是否生效 -""" - -import asyncio -import sys -import os -import time - -# 添加项目根目录到Python路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from ai_reply_engine import ai_reply_engine -from db_manager import db_manager -from loguru import logger - -def setup_test_account(): - """设置测试账号的AI回复配置""" - print("⚙️ 设置测试账号配置...") - - test_cookie_id = "test_bargain_001" - - # 配置AI回复设置 - ai_settings = { - 'ai_enabled': True, - 'model_name': 'qwen-plus', - 'api_key': 'test-api-key-for-bargain-test', # 测试用的假密钥 - 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'max_discount_percent': 15, # 最大优惠15% - 'max_discount_amount': 50, # 最大优惠50元 - 'max_bargain_rounds': 3, # 最大议价3轮 - 'custom_prompts': '' - } - - try: - success = db_manager.save_ai_reply_settings(test_cookie_id, ai_settings) - if success: - print(f" ✅ 测试账号配置成功") - print(f" 账号ID: {test_cookie_id}") - print(f" 最大议价轮数: {ai_settings['max_bargain_rounds']}") - print(f" 最大优惠百分比: {ai_settings['max_discount_percent']}%") - print(f" 最大优惠金额: {ai_settings['max_discount_amount']}元") - return test_cookie_id - else: - print(f" ❌ 测试账号配置失败") - return None - except Exception as e: - print(f" ❌ 配置异常: {e}") - return None - -def clear_test_conversations(cookie_id: str, chat_id: str): - """清理测试对话记录""" - try: - with db_manager.lock: - cursor = db_manager.conn.cursor() - cursor.execute(''' - DELETE FROM ai_conversations - WHERE cookie_id = ? AND chat_id = ? - ''', (cookie_id, chat_id)) - db_manager.conn.commit() - print(f" ✅ 清理对话记录成功") - except Exception as e: - print(f" ❌ 清理对话记录失败: {e}") - -def test_bargain_count_tracking(): - """测试议价次数统计""" - print("\n📊 测试议价次数统计...") - - test_cookie_id = "test_bargain_001" - test_chat_id = "test_chat_bargain_001" - - # 清理测试数据 - clear_test_conversations(test_cookie_id, test_chat_id) - - # 模拟保存几条议价对话 - test_conversations = [ - ("user", "能便宜点吗?", "price"), - ("assistant", "可以优惠5元", "price"), - ("user", "再便宜点呢?", "price"), - ("assistant", "最多优惠10元", "price"), - ("user", "还能再便宜吗?", "price"), - ("assistant", "这已经是最低价了", "price"), - ] - - print(f"\n1️⃣ 模拟保存对话记录...") - try: - for i, (role, content, intent) in enumerate(test_conversations): - ai_reply_engine.save_conversation( - chat_id=test_chat_id, - cookie_id=test_cookie_id, - user_id="test_user_001", - item_id="test_item_001", - role=role, - content=content, - intent=intent - ) - print(f" 保存第{i+1}条: {role} - {content}") - except Exception as e: - print(f" ❌ 保存对话记录失败: {e}") - return False - - print(f"\n2️⃣ 测试议价次数统计...") - try: - bargain_count = ai_reply_engine.get_bargain_count(test_chat_id, test_cookie_id) - expected_count = 3 # 3条用户的price消息 - - if bargain_count == expected_count: - print(f" ✅ 议价次数统计正确: {bargain_count}") - else: - print(f" ❌ 议价次数统计错误: 期望 {expected_count}, 实际 {bargain_count}") - return False - except Exception as e: - print(f" ❌ 议价次数统计异常: {e}") - return False - - return True - -def test_bargain_limit_logic(): - """测试议价轮数限制逻辑""" - print("\n🚫 测试议价轮数限制逻辑...") - - test_cookie_id = "test_bargain_001" - test_chat_id = "test_chat_limit_001" - - # 清理测试数据 - clear_test_conversations(test_cookie_id, test_chat_id) - - # 获取配置 - settings = db_manager.get_ai_reply_settings(test_cookie_id) - max_rounds = settings.get('max_bargain_rounds', 3) - - print(f" 配置的最大议价轮数: {max_rounds}") - - # 模拟达到最大议价轮数 - print(f"\n1️⃣ 模拟 {max_rounds} 轮议价...") - for i in range(max_rounds): - try: - ai_reply_engine.save_conversation( - chat_id=test_chat_id, - cookie_id=test_cookie_id, - user_id="test_user_001", - item_id="test_item_001", - role="user", - content=f"第{i+1}次议价:能便宜点吗?", - intent="price" - ) - print(f" 第{i+1}轮议价记录已保存") - except Exception as e: - print(f" ❌ 保存第{i+1}轮议价失败: {e}") - return False - - # 验证议价次数 - print(f"\n2️⃣ 验证当前议价次数...") - try: - current_count = ai_reply_engine.get_bargain_count(test_chat_id, test_cookie_id) - print(f" 当前议价次数: {current_count}") - - if current_count >= max_rounds: - print(f" ✅ 已达到最大议价轮数限制") - else: - print(f" ❌ 未达到最大议价轮数") - return False - except Exception as e: - print(f" ❌ 验证议价次数异常: {e}") - return False - - # 测试超出限制时的逻辑(模拟) - print(f"\n3️⃣ 测试超出限制时的逻辑...") - try: - # 直接测试议价轮数检查逻辑 - current_count = ai_reply_engine.get_bargain_count(test_chat_id, test_cookie_id) - settings = db_manager.get_ai_reply_settings(test_cookie_id) - max_rounds = settings.get('max_bargain_rounds', 3) - - print(f" 当前议价次数: {current_count}") - print(f" 最大议价轮数: {max_rounds}") - - if current_count >= max_rounds: - print(f" ✅ 检测到议价次数已达上限") - print(f" ✅ 系统应该拒绝继续议价") - - # 模拟拒绝回复 - refuse_reply = f"抱歉,这个价格已经是最优惠的了,不能再便宜了哦!" - print(f" ✅ 拒绝回复示例: {refuse_reply}") - else: - print(f" ❌ 议价次数检查逻辑错误") - return False - - except Exception as e: - print(f" ❌ 测试超出限制逻辑异常: {e}") - return False - - return True - -def test_bargain_settings_integration(): - """测试议价设置集成""" - print("\n🔧 测试议价设置集成...") - - test_cookie_id = "test_bargain_001" - - # 获取设置 - try: - settings = db_manager.get_ai_reply_settings(test_cookie_id) - - print(f" AI回复启用: {settings.get('ai_enabled', False)}") - print(f" 最大议价轮数: {settings.get('max_bargain_rounds', 3)}") - print(f" 最大优惠百分比: {settings.get('max_discount_percent', 10)}%") - print(f" 最大优惠金额: {settings.get('max_discount_amount', 100)}元") - - # 验证设置是否正确 - if settings.get('max_bargain_rounds') == 3: - print(f" ✅ 议价设置读取正确") - else: - print(f" ❌ 议价设置读取错误") - return False - - except Exception as e: - print(f" ❌ 获取议价设置异常: {e}") - return False - - return True - -async def main(): - """主测试函数""" - print("🚀 议价轮数限制功能测试开始") - print("=" * 50) - - # 设置测试账号 - test_cookie_id = setup_test_account() - if not test_cookie_id: - print("\n❌ 测试账号设置失败") - return - - # 测试议价设置集成 - settings_ok = test_bargain_settings_integration() - if not settings_ok: - print("\n❌ 议价设置集成测试失败") - return - - # 测试议价次数统计 - count_ok = test_bargain_count_tracking() - if not count_ok: - print("\n❌ 议价次数统计测试失败") - return - - # 测试议价轮数限制 - limit_ok = test_bargain_limit_logic() - if not limit_ok: - print("\n❌ 议价轮数限制测试失败") - return - - print("\n" + "=" * 50) - print("🎉 所有测试通过!最大议价轮数功能正常!") - print("\n📋 功能说明:") - print("1. ✅ 议价次数统计:正确统计用户的议价消息数量") - print("2. ✅ 轮数限制检查:达到最大轮数时拒绝继续议价") - print("3. ✅ 拒绝回复生成:超出限制时返回友好的拒绝消息") - print("4. ✅ 设置参数传递:AI可以获取到完整的议价设置") - print("\n💡 使用建议:") - print("- 合理设置最大议价轮数(建议3-5轮)") - print("- 配合最大优惠百分比和金额使用") - print("- 在提示词中强调议价策略") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/test_bargain_limit_direct.py b/test_bargain_limit_direct.py deleted file mode 100644 index b6f0c54..0000000 --- a/test_bargain_limit_direct.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -""" -直接测试议价轮数限制功能 -不依赖真实API调用,直接测试逻辑 -""" - -import asyncio -import sys -import os - -# 添加项目根目录到Python路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from ai_reply_engine import ai_reply_engine -from db_manager import db_manager - -class MockAIReplyEngine: - """模拟AI回复引擎,用于测试议价轮数限制""" - - def __init__(self): - self.ai_engine = ai_reply_engine - - def test_bargain_limit_logic(self, cookie_id: str, chat_id: str, message: str, - item_info: dict, user_id: str, item_id: str): - """直接测试议价轮数限制逻辑""" - try: - # 1. 获取AI回复设置 - settings = db_manager.get_ai_reply_settings(cookie_id) - print(f" 获取设置成功: 最大议价轮数 {settings.get('max_bargain_rounds', 3)}") - - # 2. 模拟意图检测为price - intent = "price" - print(f" 模拟意图检测: {intent}") - - # 3. 获取议价次数 - bargain_count = self.ai_engine.get_bargain_count(chat_id, cookie_id) - print(f" 当前议价次数: {bargain_count}") - - # 4. 检查议价轮数限制 - max_bargain_rounds = settings.get('max_bargain_rounds', 3) - if bargain_count >= max_bargain_rounds: - print(f" 🚫 议价次数已达上限 ({bargain_count}/{max_bargain_rounds}),拒绝继续议价") - # 返回拒绝议价的回复 - refuse_reply = f"抱歉,这个价格已经是最优惠的了,不能再便宜了哦!" - # 保存对话记录 - self.ai_engine.save_conversation(chat_id, cookie_id, user_id, item_id, "user", message, intent) - self.ai_engine.save_conversation(chat_id, cookie_id, user_id, item_id, "assistant", refuse_reply, intent) - return refuse_reply - else: - print(f" ✅ 议价次数未达上限,可以继续议价") - # 模拟AI回复 - mock_reply = f"好的,我们可以优惠一点,这是第{bargain_count + 1}轮议价" - # 保存对话记录 - self.ai_engine.save_conversation(chat_id, cookie_id, user_id, item_id, "user", message, intent) - self.ai_engine.save_conversation(chat_id, cookie_id, user_id, item_id, "assistant", mock_reply, intent) - return mock_reply - - except Exception as e: - print(f" ❌ 测试异常: {e}") - return None - -def test_complete_bargain_flow(): - """测试完整的议价流程""" - print("🎯 测试完整议价流程...") - - # 测试参数 - test_cookie_id = "test_bargain_flow_001" - test_chat_id = "test_chat_flow_001" - - # 设置测试账号 - ai_settings = { - 'ai_enabled': True, - 'model_name': 'qwen-plus', - 'api_key': 'test-key', - 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1', - 'max_discount_percent': 15, - 'max_discount_amount': 200, - 'max_bargain_rounds': 3, # 设置最大3轮 - 'custom_prompts': '' - } - - db_manager.save_ai_reply_settings(test_cookie_id, ai_settings) - - # 清理测试数据 - try: - with db_manager.lock: - cursor = db_manager.conn.cursor() - cursor.execute('DELETE FROM ai_conversations WHERE cookie_id = ? AND chat_id = ?', - (test_cookie_id, test_chat_id)) - db_manager.conn.commit() - except: - pass - - # 创建模拟引擎 - mock_engine = MockAIReplyEngine() - - # 测试商品信息 - item_info = { - 'title': '测试商品', - 'price': 1000, - 'desc': '这是一个测试商品' - } - - # 模拟议价对话 - bargain_messages = [ - "能便宜点吗?", - "800元行不行?", - "900元怎么样?", - "850元,最后一次了" # 这一轮应该被拒绝 - ] - - print(f"\n📋 测试设置:") - print(f" 账号ID: {test_cookie_id}") - print(f" 最大议价轮数: {ai_settings['max_bargain_rounds']}") - print(f" 商品价格: ¥{item_info['price']}") - - print(f"\n💬 开始议价测试:") - print("-" * 40) - - for i, message in enumerate(bargain_messages, 1): - print(f"\n第{i}轮议价:") - print(f"👤 用户: {message}") - - # 测试议价逻辑 - reply = mock_engine.test_bargain_limit_logic( - cookie_id=test_cookie_id, - chat_id=test_chat_id, - message=message, - item_info=item_info, - user_id="test_user", - item_id="test_item" - ) - - if reply: - print(f"🤖 AI回复: {reply}") - - # 检查是否是拒绝回复 - if "不能再便宜" in reply: - print(f"✋ 议价被拒绝,测试结束") - break - else: - print(f"❌ 回复生成失败") - break - - # 最终统计 - print(f"\n📊 最终统计:") - final_count = ai_reply_engine.get_bargain_count(test_chat_id, test_cookie_id) - max_rounds = ai_settings['max_bargain_rounds'] - print(f" 实际议价轮数: {final_count}") - print(f" 最大允许轮数: {max_rounds}") - print(f" 是否达到限制: {'是' if final_count >= max_rounds else '否'}") - - return final_count >= max_rounds - -def main(): - """主测试函数""" - print("🚀 议价轮数限制直接测试") - print("=" * 50) - - # 测试完整流程 - limit_works = test_complete_bargain_flow() - - print("\n" + "=" * 50) - if limit_works: - print("🎉 议价轮数限制功能正常工作!") - print("\n✅ 验证结果:") - print(" • settings变量作用域问题已修复") - print(" • 议价次数统计准确") - print(" • 轮数限制逻辑正确") - print(" • 拒绝回复生成正常") - print(" • 对话记录保存完整") - - print("\n🎯 功能特点:") - print(" • 在AI API调用前检查轮数限制") - print(" • 超出限制时直接返回拒绝回复") - print(" • 节省API调用成本") - print(" • 保持用户体验友好") - else: - print("❌ 议价轮数限制功能异常") - print(" 请检查代码实现") - -if __name__ == "__main__": - main() diff --git a/test_cache_refresh.py b/test_cache_refresh.py deleted file mode 100644 index db3f767..0000000 --- a/test_cache_refresh.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -""" -测试缓存刷新功能 -验证备份导入后关键字数据是否能正确刷新 -""" - -import json -import time -import asyncio -from db_manager import db_manager -import cookie_manager as cm - -def test_cache_refresh(): - """测试缓存刷新功能""" - print("🧪 测试缓存刷新功能") - print("=" * 50) - - # 1. 创建测试数据 - print("\n1️⃣ 创建测试数据...") - test_cookie_id = "test_cache_refresh" - test_cookie_value = "test_cookie_value_123" - test_keywords = [ - ("测试关键字1", "测试回复1"), - ("测试关键字2", "测试回复2") - ] - - # 保存到数据库 - db_manager.save_cookie(test_cookie_id, test_cookie_value) - db_manager.save_keywords(test_cookie_id, test_keywords) - print(f" ✅ 已保存测试账号: {test_cookie_id}") - print(f" ✅ 已保存 {len(test_keywords)} 个关键字") - - # 2. 创建 CookieManager 并加载数据 - print("\n2️⃣ 创建 CookieManager...") - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - manager = cm.CookieManager(loop) - print(f" ✅ CookieManager 已创建") - print(f" 📊 加载的关键字: {manager.keywords.get(test_cookie_id, [])}") - - # 3. 直接修改数据库(模拟备份导入) - print("\n3️⃣ 模拟备份导入(直接修改数据库)...") - new_keywords = [ - ("新关键字1", "新回复1"), - ("新关键字2", "新回复2"), - ("新关键字3", "新回复3") - ] - db_manager.save_keywords(test_cookie_id, new_keywords) - print(f" ✅ 数据库已更新为 {len(new_keywords)} 个新关键字") - - # 4. 检查 CookieManager 缓存(应该还是旧数据) - print("\n4️⃣ 检查 CookieManager 缓存...") - cached_keywords = manager.keywords.get(test_cookie_id, []) - print(f" 📊 缓存中的关键字: {cached_keywords}") - - if len(cached_keywords) == len(test_keywords): - print(" ✅ 确认:缓存中仍是旧数据(符合预期)") - else: - print(" ❌ 意外:缓存已更新(不符合预期)") - - # 5. 调用刷新方法 - print("\n5️⃣ 调用缓存刷新方法...") - success = manager.reload_from_db() - print(f" 刷新结果: {success}") - - # 6. 检查刷新后的缓存 - print("\n6️⃣ 检查刷新后的缓存...") - refreshed_keywords = manager.keywords.get(test_cookie_id, []) - print(f" 📊 刷新后的关键字: {refreshed_keywords}") - - if len(refreshed_keywords) == len(new_keywords): - print(" ✅ 成功:缓存已更新为新数据") - - # 验证内容是否正确 - db_keywords = db_manager.get_keywords(test_cookie_id) - if refreshed_keywords == db_keywords: - print(" ✅ 验证:缓存数据与数据库一致") - else: - print(" ❌ 错误:缓存数据与数据库不一致") - print(f" 缓存: {refreshed_keywords}") - print(f" 数据库: {db_keywords}") - else: - print(" ❌ 失败:缓存未正确更新") - - # 7. 清理测试数据 - print("\n7️⃣ 清理测试数据...") - db_manager.delete_cookie(test_cookie_id) - print(" ✅ 测试数据已清理") - - print("\n" + "=" * 50) - print("🎉 缓存刷新功能测试完成!") - -def test_backup_import_scenario(): - """测试完整的备份导入场景""" - print("\n\n🔄 测试完整备份导入场景") - print("=" * 50) - - # 1. 创建初始数据 - print("\n1️⃣ 创建初始数据...") - initial_data = { - "account1": [("hello", "你好"), ("price", "价格是100元")], - "account2": [("bye", "再见"), ("thanks", "谢谢")] - } - - for cookie_id, keywords in initial_data.items(): - db_manager.save_cookie(cookie_id, f"cookie_value_{cookie_id}") - db_manager.save_keywords(cookie_id, keywords) - - print(f" ✅ 已创建 {len(initial_data)} 个账号的初始数据") - - # 2. 导出备份 - print("\n2️⃣ 导出备份...") - backup_data = db_manager.export_backup() - print(f" ✅ 备份导出成功,包含 {len(backup_data['data'])} 个表") - - # 3. 修改数据(模拟用户操作) - print("\n3️⃣ 修改数据...") - modified_data = { - "account1": [("modified1", "修改后的回复1")], - "account3": [("new", "新账号的回复")] - } - - for cookie_id, keywords in modified_data.items(): - db_manager.save_cookie(cookie_id, f"cookie_value_{cookie_id}") - db_manager.save_keywords(cookie_id, keywords) - - print(" ✅ 数据已修改") - - # 4. 创建 CookieManager(加载修改后的数据) - print("\n4️⃣ 创建 CookieManager...") - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - manager = cm.CookieManager(loop) - print(f" ✅ CookieManager 已创建,加载了修改后的数据") - - # 5. 导入备份(恢复初始数据) - print("\n5️⃣ 导入备份...") - success = db_manager.import_backup(backup_data) - print(f" 导入结果: {success}") - - # 6. 检查 CookieManager 缓存(应该还是修改后的数据) - print("\n6️⃣ 检查导入后的缓存...") - for cookie_id in ["account1", "account2", "account3"]: - cached = manager.keywords.get(cookie_id, []) - db_data = db_manager.get_keywords(cookie_id) - print(f" {cookie_id}:") - print(f" 缓存: {cached}") - print(f" 数据库: {db_data}") - - if cached != db_data: - print(f" ❌ 不一致!需要刷新缓存") - else: - print(f" ✅ 一致") - - # 7. 刷新缓存 - print("\n7️⃣ 刷新缓存...") - manager.reload_from_db() - - # 8. 再次检查 - print("\n8️⃣ 检查刷新后的缓存...") - all_consistent = True - for cookie_id in ["account1", "account2"]: # account3 应该被删除了 - cached = manager.keywords.get(cookie_id, []) - db_data = db_manager.get_keywords(cookie_id) - print(f" {cookie_id}:") - print(f" 缓存: {cached}") - print(f" 数据库: {db_data}") - - if cached != db_data: - print(f" ❌ 仍然不一致!") - all_consistent = False - else: - print(f" ✅ 一致") - - # 检查 account3 是否被正确删除 - if "account3" not in manager.keywords: - print(" ✅ account3 已从缓存中删除") - else: - print(" ❌ account3 仍在缓存中") - all_consistent = False - - # 9. 清理 - print("\n9️⃣ 清理测试数据...") - for cookie_id in ["account1", "account2", "account3"]: - db_manager.delete_cookie(cookie_id) - - print("\n" + "=" * 50) - if all_consistent: - print("🎉 备份导入场景测试成功!") - else: - print("❌ 备份导入场景测试失败!") - -if __name__ == "__main__": - try: - test_cache_refresh() - test_backup_import_scenario() - except Exception as e: - print(f"❌ 测试过程中发生错误: {e}") - import traceback - traceback.print_exc() diff --git a/test_captcha.png b/test_captcha.png deleted file mode 100644 index 0dd14aa7e0dd1548806410ba2df9dad40b605cc4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1658 zcmV-=28H>FP)9KcJG=cObPeG!*=!CNzJG1r`@P?L^O-kq_qQ7z9LHfq zsu!Z^0u)f?V{=WHKwcUH?Yd}_q9IQ1uT@crdZaTlI@BX|bhIx&L=9QATq7N=CJm*V z;gHq{Xf;V2LC5%7X}SdJr5VsQxu*v4MrJlhE)IQ}EaR%Gs%JPY4E}tJswz{Ns0Tf@ z^2R&Y*t5gr*=oau>B?54HKHDLy3>5h_Z$Y&#u+^{ zZ6;@#@>wX?iJhy0edv$U6J+dBU-trKv)YudAz9T9DPzCX#juxS0UJVh52+ zr+Ls7+|<&?)n9l-|8@T1I9J=?@EJ^R=X+POSNQF!F0EHxUZy7p0LluprHuu-f8!W7 z?W4uw?ylne2huJ9FGHVZFwxDITsqBzc6Q?&-+Z-Q)Oj#|?SboS!uJIqjE~rQc2!H0 z=;*hVkrzgdEIEE3%f}9k68hzo4C;;GPClPj0OH)l{$%M&M^l1j<(rE^C z#Kp11HHP7JRgDbP-fzxi!Z>@0ubuPQ+~i{AU&U-(${5Hs$s?dmB&?|{AuPO{Bo~&C zwLQbu(S?_rLf8eoEF8yCvU3A81^Vc+xAPZ4bgq@`zmU5_W)00iU)(Lm#L9yH{&M+1 zuAVXd|MYlK`?TSCCF(sL08n<&v+|e=&RUSGXX%Fwb{v{j0E14S?Jy?;RZ%D z(j^5UsGKV$Pt)Zkd6Gs(z2Aanv4d`2JjCaw3wRmkqsN|3$Rk}3Py*WhgGmU2q(8{( z?Che^^rc;?DOr*QgM>?PziDn=|LG6HE#9oJnQs6h9;Qj`GIDgZF0YJJzGgxemR5G- zCRCI>5_fl5SX$XTxe~64Zhm_Dw5lfsWrf*F&JCahw7I!y&nzve*Cmt5&i?-x2%VtR7&GwyV>x4QZC zdcQ$8FmgesJkCsPYOF{5d$3bT6o3YRoqN6!@GEZJOun0NfBgI73=HX2PoKYPY|_)y zJ$N|E&fa?P@Kamkxano{Rt6B1p*LKWLT7pgB*y&&0DapQ^$NTay91PX#X*R&ui(wi z!gD`gzkBC?ZB0Xadxw?v7%w)uGBkvRx(pup?+f8)jL5FeZlvY^rkAVdIN&(`xTEh@TIk;2{ znp{^>K4(a1Zqw!4qn1**A;q9cj80CtYwC%TK^DkxhoO0E9g>fC+R*ZfYDj{Xk(#-G zci(2Wf1RRH!vVC6RL9-SxJxLKeMumvh9PJfX~~@V59V!c>54n+5&!@I07*qoM6N<$ Ef)DsFRsaA1 diff --git a/test_captcha_system.py b/test_captcha_system.py deleted file mode 100644 index 10a2580..0000000 --- a/test_captcha_system.py +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env python3 -""" -图形验证码系统测试 -""" - -import requests -import json -import sqlite3 -import base64 -from io import BytesIO -from PIL import Image - -def test_captcha_generation(): - """测试图形验证码生成""" - print("🧪 测试图形验证码生成") - print("-" * 40) - - session_id = "test_session_123" - - # 1. 生成图形验证码 - print("1️⃣ 生成图形验证码...") - response = requests.post('http://localhost:8080/generate-captcha', - json={'session_id': session_id}) - - print(f" 状态码: {response.status_code}") - - if response.status_code == 200: - result = response.json() - print(f" 成功: {result['success']}") - print(f" 消息: {result['message']}") - - if result['success']: - captcha_image = result['captcha_image'] - print(f" 图片数据长度: {len(captcha_image)}") - - # 保存图片到文件(用于调试) - if captcha_image.startswith('data:image/png;base64,'): - image_data = captcha_image.split(',')[1] - image_bytes = base64.b64decode(image_data) - - with open('test_captcha.png', 'wb') as f: - f.write(image_bytes) - print(" ✅ 图片已保存为 test_captcha.png") - - # 从数据库获取验证码文本 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM captcha_codes WHERE session_id = ? ORDER BY created_at DESC LIMIT 1', - (session_id,)) - result = cursor.fetchone() - if result: - captcha_text = result[0] - print(f" 验证码文本: {captcha_text}") - conn.close() - return session_id, captcha_text - conn.close() - else: - print(" ❌ 图形验证码生成失败") - else: - print(f" ❌ 请求失败: {response.text}") - - return None, None - -def test_captcha_verification(session_id, captcha_text): - """测试图形验证码验证""" - print("\n🧪 测试图形验证码验证") - print("-" * 40) - - # 2. 验证正确的验证码 - print("2️⃣ 验证正确的验证码...") - response = requests.post('http://localhost:8080/verify-captcha', - json={ - 'session_id': session_id, - 'captcha_code': captcha_text - }) - - print(f" 状态码: {response.status_code}") - if response.status_code == 200: - result = response.json() - print(f" 成功: {result['success']}") - print(f" 消息: {result['message']}") - - if result['success']: - print(" ✅ 正确验证码验证成功") - else: - print(" ❌ 正确验证码验证失败") - - # 3. 验证错误的验证码 - print("\n3️⃣ 验证错误的验证码...") - response = requests.post('http://localhost:8080/verify-captcha', - json={ - 'session_id': session_id, - 'captcha_code': 'WRONG' - }) - - print(f" 状态码: {response.status_code}") - if response.status_code == 200: - result = response.json() - print(f" 成功: {result['success']}") - print(f" 消息: {result['message']}") - - if not result['success']: - print(" ✅ 错误验证码被正确拒绝") - else: - print(" ❌ 错误验证码验证成功(不应该)") - -def test_captcha_expiry(): - """测试图形验证码过期""" - print("\n🧪 测试图形验证码过期") - print("-" * 40) - - # 直接在数据库中插入过期的验证码 - import time - expired_session = "expired_session_123" - expired_time = time.time() - 3600 # 1小时前 - - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO captcha_codes (session_id, code, expires_at) - VALUES (?, ?, ?) - ''', (expired_session, 'EXPIRED', expired_time)) - conn.commit() - conn.close() - - print("4️⃣ 验证过期的验证码...") - response = requests.post('http://localhost:8080/verify-captcha', - json={ - 'session_id': expired_session, - 'captcha_code': 'EXPIRED' - }) - - print(f" 状态码: {response.status_code}") - if response.status_code == 200: - result = response.json() - print(f" 成功: {result['success']}") - print(f" 消息: {result['message']}") - - if not result['success']: - print(" ✅ 过期验证码被正确拒绝") - else: - print(" ❌ 过期验证码验证成功(不应该)") - -def test_database_cleanup(): - """测试数据库清理""" - print("\n🧪 测试数据库清理") - print("-" * 40) - - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - # 清理测试数据 - cursor.execute('DELETE FROM captcha_codes WHERE session_id LIKE "test%" OR session_id LIKE "expired%"') - deleted_count = cursor.rowcount - conn.commit() - conn.close() - - print(f"5️⃣ 清理测试数据: 删除了 {deleted_count} 条记录") - print(" ✅ 数据库清理完成") - -def test_frontend_integration(): - """测试前端集成""" - print("\n🧪 测试前端集成") - print("-" * 40) - - # 测试注册页面是否可以访问 - print("6️⃣ 测试注册页面...") - response = requests.get('http://localhost:8080/register.html') - - print(f" 状态码: {response.status_code}") - if response.status_code == 200: - content = response.text - - # 检查是否包含图形验证码相关元素 - checks = [ - ('captchaCode', '图形验证码输入框'), - ('captchaImage', '图形验证码图片'), - ('refreshCaptcha', '刷新验证码函数'), - ('verifyCaptcha', '验证验证码函数'), - ('loadCaptcha', '加载验证码函数') - ] - - for check_item, description in checks: - if check_item in content: - print(f" ✅ {description}: 存在") - else: - print(f" ❌ {description}: 缺失") - - print(" ✅ 注册页面加载成功") - else: - print(f" ❌ 注册页面加载失败: {response.status_code}") - -def main(): - """主测试函数""" - print("🚀 图形验证码系统测试") - print("=" * 60) - - try: - # 测试图形验证码生成 - session_id, captcha_text = test_captcha_generation() - - if session_id and captcha_text: - # 测试图形验证码验证 - test_captcha_verification(session_id, captcha_text) - - # 测试验证码过期 - test_captcha_expiry() - - # 测试前端集成 - test_frontend_integration() - - # 清理测试数据 - test_database_cleanup() - - print("\n" + "=" * 60) - print("🎉 图形验证码系统测试完成!") - - print("\n📋 测试总结:") - print("✅ 图形验证码生成功能正常") - print("✅ 图形验证码验证功能正常") - print("✅ 过期验证码处理正常") - print("✅ 前端页面集成正常") - print("✅ 数据库操作正常") - - print("\n💡 下一步:") - print("1. 访问 http://localhost:8080/register.html") - print("2. 测试图形验证码功能") - print("3. 验证邮箱验证码发送需要先通过图形验证码") - print("4. 完成用户注册流程") - - except Exception as e: - print(f"\n❌ 测试失败: {e}") - import traceback - traceback.print_exc() - return False - - return True - -if __name__ == "__main__": - main() diff --git a/test_complete_isolation.py b/test_complete_isolation.py deleted file mode 100644 index 7bdf02f..0000000 --- a/test_complete_isolation.py +++ /dev/null @@ -1,347 +0,0 @@ -#!/usr/bin/env python3 -""" -完整的多用户数据隔离测试 -""" - -import requests -import json -import time -import sqlite3 -from loguru import logger - -BASE_URL = "http://localhost:8080" - -def create_test_users(): - """创建测试用户""" - logger.info("创建测试用户...") - - users = [ - {"username": "testuser1", "email": "user1@test.com", "password": "test123456"}, - {"username": "testuser2", "email": "user2@test.com", "password": "test123456"} - ] - - created_users = [] - - for user in users: - try: - # 清理可能存在的用户 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('DELETE FROM users WHERE username = ? OR email = ?', (user['username'], user['email'])) - cursor.execute('DELETE FROM email_verifications WHERE email = ?', (user['email'],)) - conn.commit() - conn.close() - - # 生成验证码 - session_id = f"test_{user['username']}_{int(time.time())}" - - # 生成图形验证码 - captcha_response = requests.post(f"{BASE_URL}/generate-captcha", - json={'session_id': session_id}) - if not captcha_response.json()['success']: - logger.error(f"图形验证码生成失败: {user['username']}") - continue - - # 获取图形验证码 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM captcha_codes WHERE session_id = ? ORDER BY created_at DESC LIMIT 1', - (session_id,)) - captcha_result = cursor.fetchone() - conn.close() - - if not captcha_result: - logger.error(f"无法获取图形验证码: {user['username']}") - continue - - captcha_code = captcha_result[0] - - # 验证图形验证码 - verify_response = requests.post(f"{BASE_URL}/verify-captcha", - json={'session_id': session_id, 'captcha_code': captcha_code}) - if not verify_response.json()['success']: - logger.error(f"图形验证码验证失败: {user['username']}") - continue - - # 发送邮箱验证码 - email_response = requests.post(f"{BASE_URL}/send-verification-code", - json={'email': user['email'], 'session_id': session_id}) - if not email_response.json()['success']: - logger.error(f"邮箱验证码发送失败: {user['username']}") - continue - - # 获取邮箱验证码 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM email_verifications WHERE email = ? ORDER BY created_at DESC LIMIT 1', - (user['email'],)) - email_result = cursor.fetchone() - conn.close() - - if not email_result: - logger.error(f"无法获取邮箱验证码: {user['username']}") - continue - - email_code = email_result[0] - - # 注册用户 - register_response = requests.post(f"{BASE_URL}/register", - json={ - 'username': user['username'], - 'email': user['email'], - 'verification_code': email_code, - 'password': user['password'] - }) - - if register_response.json()['success']: - logger.info(f"用户注册成功: {user['username']}") - - # 登录获取token - login_response = requests.post(f"{BASE_URL}/login", - json={'username': user['username'], 'password': user['password']}) - - if login_response.json()['success']: - token = login_response.json()['token'] - user_id = login_response.json()['user_id'] - created_users.append({ - 'username': user['username'], - 'user_id': user_id, - 'token': token, - 'headers': {'Authorization': f'Bearer {token}'} - }) - logger.info(f"用户登录成功: {user['username']}") - else: - logger.error(f"用户登录失败: {user['username']}") - else: - logger.error(f"用户注册失败: {user['username']} - {register_response.json()['message']}") - - except Exception as e: - logger.error(f"创建用户失败: {user['username']} - {e}") - - return created_users - -def test_cards_isolation(users): - """测试卡券管理的用户隔离""" - logger.info("测试卡券管理的用户隔离...") - - if len(users) < 2: - logger.error("需要至少2个用户进行隔离测试") - return False - - user1, user2 = users[0], users[1] - - # 用户1创建卡券 - card1_data = { - "name": "用户1的卡券", - "type": "text", - "text_content": "这是用户1的卡券内容", - "description": "用户1创建的测试卡券" - } - - response1 = requests.post(f"{BASE_URL}/cards", json=card1_data, headers=user1['headers']) - if response1.status_code == 200: - card1_id = response1.json()['id'] - logger.info(f"用户1创建卡券成功: ID={card1_id}") - else: - logger.error(f"用户1创建卡券失败: {response1.text}") - return False - - # 用户2创建卡券 - card2_data = { - "name": "用户2的卡券", - "type": "text", - "text_content": "这是用户2的卡券内容", - "description": "用户2创建的测试卡券" - } - - response2 = requests.post(f"{BASE_URL}/cards", json=card2_data, headers=user2['headers']) - if response2.status_code == 200: - card2_id = response2.json()['id'] - logger.info(f"用户2创建卡券成功: ID={card2_id}") - else: - logger.error(f"用户2创建卡券失败: {response2.text}") - return False - - # 验证用户1只能看到自己的卡券 - response1_list = requests.get(f"{BASE_URL}/cards", headers=user1['headers']) - if response1_list.status_code == 200: - user1_cards = response1_list.json() - user1_card_names = [card['name'] for card in user1_cards] - - if "用户1的卡券" in user1_card_names and "用户2的卡券" not in user1_card_names: - logger.info("✅ 用户1卡券隔离验证通过") - else: - logger.error("❌ 用户1卡券隔离验证失败") - return False - else: - logger.error(f"获取用户1卡券列表失败: {response1_list.text}") - return False - - # 验证用户2只能看到自己的卡券 - response2_list = requests.get(f"{BASE_URL}/cards", headers=user2['headers']) - if response2_list.status_code == 200: - user2_cards = response2_list.json() - user2_card_names = [card['name'] for card in user2_cards] - - if "用户2的卡券" in user2_card_names and "用户1的卡券" not in user2_card_names: - logger.info("✅ 用户2卡券隔离验证通过") - else: - logger.error("❌ 用户2卡券隔离验证失败") - return False - else: - logger.error(f"获取用户2卡券列表失败: {response2_list.text}") - return False - - # 验证跨用户访问被拒绝 - response_cross = requests.get(f"{BASE_URL}/cards/{card2_id}", headers=user1['headers']) - if response_cross.status_code == 403 or response_cross.status_code == 404: - logger.info("✅ 跨用户卡券访问被正确拒绝") - else: - logger.error("❌ 跨用户卡券访问未被拒绝") - return False - - return True - -def test_user_settings(users): - """测试用户设置功能""" - logger.info("测试用户设置功能...") - - if len(users) < 2: - logger.error("需要至少2个用户进行设置测试") - return False - - user1, user2 = users[0], users[1] - - # 用户1设置主题颜色 - setting1_data = {"value": "#ff0000", "description": "用户1的红色主题"} - response1 = requests.put(f"{BASE_URL}/user-settings/theme_color", - json=setting1_data, headers=user1['headers']) - - if response1.status_code == 200: - logger.info("用户1主题颜色设置成功") - else: - logger.error(f"用户1主题颜色设置失败: {response1.text}") - return False - - # 用户2设置主题颜色 - setting2_data = {"value": "#00ff00", "description": "用户2的绿色主题"} - response2 = requests.put(f"{BASE_URL}/user-settings/theme_color", - json=setting2_data, headers=user2['headers']) - - if response2.status_code == 200: - logger.info("用户2主题颜色设置成功") - else: - logger.error(f"用户2主题颜色设置失败: {response2.text}") - return False - - # 验证用户1的设置 - response1_get = requests.get(f"{BASE_URL}/user-settings/theme_color", headers=user1['headers']) - if response1_get.status_code == 200: - user1_color = response1_get.json()['value'] - if user1_color == "#ff0000": - logger.info("✅ 用户1主题颜色隔离验证通过") - else: - logger.error(f"❌ 用户1主题颜色错误: {user1_color}") - return False - else: - logger.error(f"获取用户1主题颜色失败: {response1_get.text}") - return False - - # 验证用户2的设置 - response2_get = requests.get(f"{BASE_URL}/user-settings/theme_color", headers=user2['headers']) - if response2_get.status_code == 200: - user2_color = response2_get.json()['value'] - if user2_color == "#00ff00": - logger.info("✅ 用户2主题颜色隔离验证通过") - else: - logger.error(f"❌ 用户2主题颜色错误: {user2_color}") - return False - else: - logger.error(f"获取用户2主题颜色失败: {response2_get.text}") - return False - - return True - -def cleanup_test_data(users): - """清理测试数据""" - logger.info("清理测试数据...") - - try: - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - # 清理测试用户 - test_usernames = [user['username'] for user in users] - if test_usernames: - placeholders = ','.join(['?' for _ in test_usernames]) - cursor.execute(f'DELETE FROM users WHERE username IN ({placeholders})', test_usernames) - user_count = cursor.rowcount - else: - user_count = 0 - - # 清理测试卡券 - cursor.execute('DELETE FROM cards WHERE name LIKE "用户%的卡券"') - card_count = cursor.rowcount - - # 清理测试验证码 - cursor.execute('DELETE FROM email_verifications WHERE email LIKE "%@test.com"') - email_count = cursor.rowcount - - cursor.execute('DELETE FROM captcha_codes WHERE session_id LIKE "test_%"') - captcha_count = cursor.rowcount - - conn.commit() - conn.close() - - logger.info(f"清理完成: 用户{user_count}个, 卡券{card_count}个, 邮箱验证码{email_count}个, 图形验证码{captcha_count}个") - - except Exception as e: - logger.error(f"清理失败: {e}") - -def main(): - """主测试函数""" - print("🚀 完整的多用户数据隔离测试") - print("=" * 60) - - try: - # 创建测试用户 - users = create_test_users() - - if len(users) < 2: - print("❌ 测试失败:无法创建足够的测试用户") - return False - - print(f"✅ 成功创建 {len(users)} 个测试用户") - - # 测试卡券管理隔离 - cards_success = test_cards_isolation(users) - - # 测试用户设置 - settings_success = test_user_settings(users) - - # 清理测试数据 - cleanup_test_data(users) - - print("\n" + "=" * 60) - if cards_success and settings_success: - print("🎉 完整的多用户数据隔离测试全部通过!") - - print("\n📋 测试总结:") - print("✅ 卡券管理完全隔离") - print("✅ 用户设置完全隔离") - print("✅ 跨用户访问被正确拒绝") - print("✅ 数据库层面用户绑定正确") - - return True - else: - print("❌ 多用户数据隔离测试失败!") - return False - - except Exception as e: - print(f"💥 测试异常: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - main() diff --git a/test_cookie_log_display.py b/test_cookie_log_display.py deleted file mode 100644 index 1834a30..0000000 --- a/test_cookie_log_display.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 -""" -测试Cookie ID在日志中的显示 -""" - -import time -import asyncio -from loguru import logger - -# 模拟多个Cookie的日志输出 -async def test_cookie_log_display(): - """测试Cookie ID在日志中的显示效果""" - - print("🧪 测试Cookie ID日志显示") - print("=" * 50) - - # 模拟不同Cookie的日志输出 - cookies = [ - {"id": "user1_cookie", "name": "用户1的账号"}, - {"id": "user2_cookie", "name": "用户2的账号"}, - {"id": "admin_cookie", "name": "管理员账号"} - ] - - print("📋 模拟多用户系统日志输出:") - print("-" * 50) - - for i, cookie in enumerate(cookies): - cookie_id = cookie["id"] - cookie_name = cookie["name"] - - # 模拟各种类型的日志 - msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - - # 1. Token相关日志 - logger.info(f"【{cookie_id}】开始刷新token...") - await asyncio.sleep(0.1) - logger.info(f"【{cookie_id}】Token刷新成功") - - # 2. 连接相关日志 - logger.info(f"【{cookie_id}】连接注册完成") - - # 3. 系统消息日志 - logger.info(f"[{msg_time}] 【{cookie_id}】【系统】小闲鱼智能提示:") - logger.info(f" - 欢迎使用闲鱼智能助手") - - # 4. 消息处理日志 - logger.info(f"[{msg_time}] 【{cookie_id}】【系统】买家已付款,准备自动发货") - logger.info(f"【{cookie_id}】准备自动发货: item_id=123456, item_title=测试商品") - - # 5. 回复相关日志 - logger.info(f"【{cookie_id}】使用默认回复: 您好,感谢您的咨询!") - logger.info(f"【{cookie_id}】AI回复生成成功: 这是AI生成的回复") - - # 6. 错误日志 - if i == 1: # 只在第二个Cookie上模拟错误 - logger.error(f"【{cookie_id}】Token刷新失败: 网络连接超时") - - print() # 空行分隔 - await asyncio.sleep(0.2) - - print("=" * 50) - print("✅ 日志显示测试完成") - - print("\n📊 日志格式说明:") - print("• 【Cookie_ID】- 标识具体的用户账号") - print("• 【系统】- 系统级别的消息") - print("• [时间戳] - 消息发生的具体时间") - print("• 不同用户的操作现在可以清晰区分") - - print("\n🎯 改进效果:") - print("• ✅ 多用户环境下可以快速定位问题") - print("• ✅ 日志分析更加高效") - print("• ✅ 运维监控更加精准") - print("• ✅ 调试过程更加清晰") - -def test_log_format_comparison(): - """对比改进前后的日志格式""" - - print("\n🔍 日志格式对比") - print("=" * 50) - - print("📝 改进前的日志格式:") - print("2025-07-25 14:23:47.770 | INFO | XianyuAutoAsync:init:1360 - 获取初始token...") - print("2025-07-25 14:23:47.771 | INFO | XianyuAutoAsync:refresh_token:134 - 开始刷新token...") - print("2025-07-25 14:23:48.269 | INFO | XianyuAutoAsync:refresh_token:200 - Token刷新成功") - print("2025-07-25 14:23:49.286 | INFO | XianyuAutoAsync:init:1407 - 连接注册完成") - print("2025-07-25 14:23:49.288 | INFO | XianyuAutoAsync:handle_message:1663 - [2025-07-25 14:23:49] 【系统】小闲鱼智能提示:") - - print("\n📝 改进后的日志格式:") - print("2025-07-25 14:23:47.770 | INFO | XianyuAutoAsync:init:1360 - 【user1_cookie】获取初始token...") - print("2025-07-25 14:23:47.771 | INFO | XianyuAutoAsync:refresh_token:134 - 【user1_cookie】开始刷新token...") - print("2025-07-25 14:23:48.269 | INFO | XianyuAutoAsync:refresh_token:200 - 【user1_cookie】Token刷新成功") - print("2025-07-25 14:23:49.286 | INFO | XianyuAutoAsync:init:1407 - 【user1_cookie】连接注册完成") - print("2025-07-25 14:23:49.288 | INFO | XianyuAutoAsync:handle_message:1663 - [2025-07-25 14:23:49] 【user1_cookie】【系统】小闲鱼智能提示:") - - print("\n🎯 改进优势:") - print("• 🔍 快速识别: 一眼就能看出是哪个用户的操作") - print("• 🐛 问题定位: 多用户环境下快速定位问题源头") - print("• 📈 监控分析: 可以按用户统计操作频率和成功率") - print("• 🔧 运维管理: 便于针对特定用户进行故障排查") - -def generate_log_analysis_tips(): - """生成日志分析技巧""" - - print("\n💡 日志分析技巧") - print("=" * 50) - - tips = [ - { - "title": "按用户过滤日志", - "command": "grep '【user1_cookie】' xianyu_2025-07-25.log", - "description": "查看特定用户的所有操作日志" - }, - { - "title": "查看Token刷新情况", - "command": "grep '【.*】.*Token' xianyu_2025-07-25.log", - "description": "监控所有用户的Token刷新状态" - }, - { - "title": "统计用户活跃度", - "command": "grep -o '【[^】]*】' xianyu_2025-07-25.log | sort | uniq -c", - "description": "统计各用户的操作次数" - }, - { - "title": "查看系统消息", - "command": "grep '【系统】' xianyu_2025-07-25.log", - "description": "查看所有系统级别的消息" - }, - { - "title": "监控错误日志", - "command": "grep 'ERROR.*【.*】' xianyu_2025-07-25.log", - "description": "查看特定用户的错误信息" - } - ] - - for i, tip in enumerate(tips, 1): - print(f"{i}. {tip['title']}") - print(f" 命令: {tip['command']}") - print(f" 说明: {tip['description']}") - print() - -async def main(): - """主函数""" - print("🚀 Cookie ID日志显示测试工具") - print("=" * 60) - - # 测试日志显示 - await test_cookie_log_display() - - # 对比日志格式 - test_log_format_comparison() - - # 生成分析技巧 - generate_log_analysis_tips() - - print("=" * 60) - print("🎉 测试完成!现在您可以在多用户环境下清晰地识别每个用户的操作。") - - print("\n📋 下一步建议:") - print("1. 重启服务以应用日志改进") - print("2. 观察实际运行中的日志输出") - print("3. 使用提供的命令进行日志分析") - print("4. 根据需要调整日志级别和格式") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/test_database_backup.py b/test_database_backup.py deleted file mode 100644 index a98a005..0000000 --- a/test_database_backup.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -""" -测试数据库备份和恢复功能 -""" - -import requests -import os -import time -import sqlite3 -from loguru import logger - -BASE_URL = "http://localhost:8080" - -def test_admin_login(): - """测试管理员登录""" - logger.info("测试管理员登录...") - - response = requests.post(f"{BASE_URL}/login", - json={'username': 'admin', 'password': 'admin123'}) - - if response.json()['success']: - token = response.json()['token'] - user_id = response.json()['user_id'] - - logger.info(f"管理员登录成功,token: {token[:20]}...") - return { - 'token': token, - 'user_id': user_id, - 'headers': {'Authorization': f'Bearer {token}'} - } - else: - logger.error("管理员登录失败") - return None - -def test_database_download(admin): - """测试数据库下载""" - logger.info("测试数据库下载...") - - try: - response = requests.get(f"{BASE_URL}/admin/backup/download", - headers=admin['headers']) - - if response.status_code == 200: - # 保存下载的文件 - timestamp = int(time.time()) - backup_filename = f"test_backup_{timestamp}.db" - - with open(backup_filename, 'wb') as f: - f.write(response.content) - - # 验证下载的文件 - file_size = os.path.getsize(backup_filename) - logger.info(f"✅ 数据库下载成功: {backup_filename} ({file_size} bytes)") - - # 验证是否为有效的SQLite数据库 - try: - conn = sqlite3.connect(backup_filename) - cursor = conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") - tables = cursor.fetchall() - conn.close() - - table_names = [table[0] for table in tables] - logger.info(f" 数据库包含 {len(table_names)} 个表: {', '.join(table_names[:5])}...") - - return backup_filename - - except sqlite3.Error as e: - logger.error(f"❌ 下载的文件不是有效的SQLite数据库: {e}") - return None - - else: - logger.error(f"❌ 数据库下载失败: {response.status_code}") - return None - - except Exception as e: - logger.error(f"❌ 数据库下载异常: {e}") - return None - -def test_backup_file_list(admin): - """测试备份文件列表""" - logger.info("测试备份文件列表...") - - try: - response = requests.get(f"{BASE_URL}/admin/backup/list", - headers=admin['headers']) - - if response.status_code == 200: - data = response.json() - backups = data.get('backups', []) - logger.info(f"✅ 获取备份文件列表成功,共 {len(backups)} 个备份文件") - - for backup in backups[:3]: # 显示前3个 - logger.info(f" {backup['filename']} - {backup['size_mb']}MB - {backup['created_time']}") - - return True - else: - logger.error(f"❌ 获取备份文件列表失败: {response.status_code}") - return False - - except Exception as e: - logger.error(f"❌ 获取备份文件列表异常: {e}") - return False - -def test_database_upload(admin, backup_file): - """测试数据库上传(模拟,不实际执行)""" - logger.info("测试数据库上传准备...") - - if not backup_file or not os.path.exists(backup_file): - logger.error("❌ 没有有效的备份文件进行上传测试") - return False - - try: - # 检查文件大小 - file_size = os.path.getsize(backup_file) - if file_size > 100 * 1024 * 1024: # 100MB - logger.warning(f"⚠️ 备份文件过大: {file_size} bytes") - return False - - # 验证文件格式 - try: - conn = sqlite3.connect(backup_file) - cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM users") - user_count = cursor.fetchone()[0] - conn.close() - - logger.info(f"✅ 备份文件验证通过,包含 {user_count} 个用户") - logger.info(" 注意:实际上传会替换当前数据库,此处仅验证文件有效性") - - return True - - except sqlite3.Error as e: - logger.error(f"❌ 备份文件验证失败: {e}") - return False - - except Exception as e: - logger.error(f"❌ 数据库上传准备异常: {e}") - return False - -def cleanup_test_files(): - """清理测试文件""" - logger.info("清理测试文件...") - - import glob - test_files = glob.glob("test_backup_*.db") - - for file_path in test_files: - try: - os.remove(file_path) - logger.info(f" 删除测试文件: {file_path}") - except Exception as e: - logger.warning(f" 删除文件失败: {file_path} - {e}") - -def main(): - """主测试函数""" - print("🚀 数据库备份和恢复功能测试") - print("=" * 60) - - print("📋 测试内容:") - print("• 管理员登录") - print("• 数据库备份下载") - print("• 备份文件列表查询") - print("• 备份文件验证") - print("• 数据库上传准备(不实际执行)") - - try: - # 管理员登录 - admin = test_admin_login() - if not admin: - print("❌ 测试失败:管理员登录失败") - return False - - print("✅ 管理员登录成功") - - # 测试各项功能 - tests = [ - ("数据库下载", lambda: test_database_download(admin)), - ("备份文件列表", lambda: test_backup_file_list(admin)), - ] - - results = [] - backup_file = None - - for test_name, test_func in tests: - print(f"\n🧪 测试 {test_name}...") - try: - result = test_func() - if test_name == "数据库下载" and result: - backup_file = result - result = True - - results.append((test_name, result)) - if result: - print(f"✅ {test_name} 测试通过") - else: - print(f"❌ {test_name} 测试失败") - except Exception as e: - print(f"💥 {test_name} 测试异常: {e}") - results.append((test_name, False)) - - # 测试数据库上传准备 - print(f"\n🧪 测试数据库上传准备...") - upload_result = test_database_upload(admin, backup_file) - results.append(("数据库上传准备", upload_result)) - if upload_result: - print(f"✅ 数据库上传准备 测试通过") - else: - print(f"❌ 数据库上传准备 测试失败") - - # 清理测试文件 - cleanup_test_files() - - print("\n" + "=" * 60) - print("🎉 数据库备份和恢复功能测试完成!") - - print("\n📊 测试结果:") - passed = 0 - for test_name, result in results: - status = "✅ 通过" if result else "❌ 失败" - print(f" {test_name}: {status}") - if result: - passed += 1 - - print(f"\n📈 总体结果: {passed}/{len(results)} 项测试通过") - - if passed == len(results): - print("🎊 所有测试都通过了!数据库备份功能正常工作。") - - print("\n💡 使用说明:") - print("1. 登录admin账号,进入系统设置") - print("2. 在备份管理区域点击'下载数据库'") - print("3. 数据库文件会自动下载到本地") - print("4. 需要恢复时,选择.db文件并点击'恢复数据库'") - print("5. 系统会自动验证文件并替换数据库") - - print("\n⚠️ 重要提醒:") - print("• 数据库恢复会完全替换当前所有数据") - print("• 建议在恢复前先下载当前数据库作为备份") - print("• 恢复后建议刷新页面以加载新数据") - - return True - else: - print("⚠️ 部分测试失败,请检查相关功能。") - return False - - except Exception as e: - print(f"💥 测试异常: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - main() diff --git a/test_docker_deployment.py b/test_docker_deployment.py deleted file mode 100644 index 1d0f2a6..0000000 --- a/test_docker_deployment.py +++ /dev/null @@ -1,331 +0,0 @@ -#!/usr/bin/env python3 -""" -Docker部署配置验证脚本 -检查Docker部署是否包含所有必要的组件和配置 -""" - -import os -import sys -import yaml -import json -from pathlib import Path - -def check_file_exists(file_path, description): - """检查文件是否存在""" - if os.path.exists(file_path): - print(f" ✅ {description}: {file_path}") - return True - else: - print(f" ❌ {description}: {file_path} (缺失)") - return False - -def check_requirements_txt(): - """检查requirements.txt中的依赖""" - print("📦 检查Python依赖...") - - if not check_file_exists("requirements.txt", "依赖文件"): - return False - - required_packages = [ - "fastapi", - "uvicorn", - "pydantic", - "loguru", - "websockets", - "aiohttp", - "PyYAML", - "PyExecJS", - "blackboxprotobuf", - "psutil", - "requests", - "python-multipart", - "openai", - "python-dotenv" - ] - - try: - with open("requirements.txt", "r", encoding="utf-8") as f: - content = f.read() - - missing_packages = [] - for package in required_packages: - if package.lower() not in content.lower(): - missing_packages.append(package) - - if missing_packages: - print(f" ❌ 缺失依赖: {', '.join(missing_packages)}") - return False - else: - print(f" ✅ 所有必要依赖都已包含 ({len(required_packages)} 个)") - return True - - except Exception as e: - print(f" ❌ 读取requirements.txt失败: {e}") - return False - -def check_dockerfile(): - """检查Dockerfile配置""" - print("\n🐳 检查Dockerfile...") - - if not check_file_exists("Dockerfile", "Docker镜像文件"): - return False - - try: - with open("Dockerfile", "r", encoding="utf-8") as f: - content = f.read() - - required_elements = [ - ("FROM python:", "Python基础镜像"), - ("WORKDIR", "工作目录设置"), - ("COPY requirements.txt", "依赖文件复制"), - ("RUN pip install", "依赖安装"), - ("EXPOSE", "端口暴露"), - ("CMD", "启动命令") - ] - - missing_elements = [] - for element, description in required_elements: - if element not in content: - missing_elements.append(description) - - if missing_elements: - print(f" ❌ 缺失配置: {', '.join(missing_elements)}") - return False - else: - print(f" ✅ Dockerfile配置完整") - return True - - except Exception as e: - print(f" ❌ 读取Dockerfile失败: {e}") - return False - -def check_docker_compose(): - """检查docker-compose.yml配置""" - print("\n🔧 检查Docker Compose配置...") - - if not check_file_exists("docker-compose.yml", "Docker Compose文件"): - return False - - try: - with open("docker-compose.yml", "r", encoding="utf-8") as f: - compose_config = yaml.safe_load(f) - - # 检查服务配置 - if "services" not in compose_config: - print(" ❌ 缺失services配置") - return False - - services = compose_config["services"] - if "xianyu-app" not in services: - print(" ❌ 缺失xianyu-app服务") - return False - - app_service = services["xianyu-app"] - - # 检查必要配置 - required_configs = [ - ("ports", "端口映射"), - ("volumes", "数据挂载"), - ("environment", "环境变量"), - ("healthcheck", "健康检查") - ] - - missing_configs = [] - for config, description in required_configs: - if config not in app_service: - missing_configs.append(description) - - if missing_configs: - print(f" ❌ 缺失配置: {', '.join(missing_configs)}") - return False - - # 检查AI相关环境变量 - env_vars = app_service.get("environment", []) - ai_env_vars = [ - "AI_REPLY_ENABLED", - "DEFAULT_AI_MODEL", - "DEFAULT_AI_BASE_URL", - "AI_REQUEST_TIMEOUT" - ] - - missing_ai_vars = [] - env_str = str(env_vars) - for var in ai_env_vars: - if var not in env_str: - missing_ai_vars.append(var) - - if missing_ai_vars: - print(f" ⚠️ 缺失AI环境变量: {', '.join(missing_ai_vars)}") - else: - print(f" ✅ AI环境变量配置完整") - - print(f" ✅ Docker Compose配置完整") - return True - - except Exception as e: - print(f" ❌ 读取docker-compose.yml失败: {e}") - return False - -def check_env_example(): - """检查.env.example配置""" - print("\n⚙️ 检查环境变量模板...") - - if not check_file_exists(".env.example", "环境变量模板"): - return False - - try: - with open(".env.example", "r", encoding="utf-8") as f: - content = f.read() - - # 检查AI相关配置 - ai_configs = [ - "AI_REPLY_ENABLED", - "DEFAULT_AI_MODEL", - "DEFAULT_AI_BASE_URL", - "AI_REQUEST_TIMEOUT", - "AI_MAX_TOKENS" - ] - - missing_configs = [] - for config in ai_configs: - if config not in content: - missing_configs.append(config) - - if missing_configs: - print(f" ❌ 缺失AI配置: {', '.join(missing_configs)}") - return False - else: - print(f" ✅ AI配置完整") - - # 检查基础配置 - basic_configs = [ - "ADMIN_USERNAME", - "ADMIN_PASSWORD", - "JWT_SECRET_KEY", - "AUTO_REPLY_ENABLED", - "AUTO_DELIVERY_ENABLED" - ] - - missing_basic = [] - for config in basic_configs: - if config not in content: - missing_basic.append(config) - - if missing_basic: - print(f" ❌ 缺失基础配置: {', '.join(missing_basic)}") - return False - else: - print(f" ✅ 基础配置完整") - - return True - - except Exception as e: - print(f" ❌ 读取.env.example失败: {e}") - return False - -def check_documentation(): - """检查部署文档""" - print("\n📚 检查部署文档...") - - docs = [ - ("Docker部署说明.md", "Docker部署说明"), - ("README.md", "项目说明文档") - ] - - all_exist = True - for doc_file, description in docs: - if not check_file_exists(doc_file, description): - all_exist = False - - return all_exist - -def check_directory_structure(): - """检查目录结构""" - print("\n📁 检查目录结构...") - - required_dirs = [ - ("static", "静态文件目录"), - ("templates", "模板目录(如果存在)") - ] - - required_files = [ - ("Start.py", "主程序文件"), - ("db_manager.py", "数据库管理"), - ("XianyuAutoAsync.py", "闲鱼自动化"), - ("ai_reply_engine.py", "AI回复引擎"), - ("global_config.yml", "全局配置") - ] - - all_exist = True - - # 检查目录 - for dir_name, description in required_dirs: - if os.path.exists(dir_name) and os.path.isdir(dir_name): - print(f" ✅ {description}: {dir_name}") - else: - if dir_name == "templates": # templates是可选的 - print(f" ⚠️ {description}: {dir_name} (可选)") - else: - print(f" ❌ {description}: {dir_name} (缺失)") - all_exist = False - - # 检查文件 - for file_name, description in required_files: - if not check_file_exists(file_name, description): - all_exist = False - - return all_exist - -def main(): - """主检查函数""" - print("🚀 Docker部署配置验证") - print("=" * 50) - - checks = [ - ("Python依赖", check_requirements_txt), - ("Dockerfile", check_dockerfile), - ("Docker Compose", check_docker_compose), - ("环境变量", check_env_example), - ("目录结构", check_directory_structure), - ("文档", check_documentation) - ] - - results = [] - for check_name, check_func in checks: - try: - result = check_func() - results.append((check_name, result)) - except Exception as e: - print(f" ❌ {check_name}检查异常: {e}") - results.append((check_name, False)) - - # 总结结果 - print("\n" + "=" * 50) - print("📊 检查结果总结") - - passed = 0 - total = len(results) - - for check_name, result in results: - status = "✅ 通过" if result else "❌ 失败" - print(f" {check_name}: {status}") - if result: - passed += 1 - - print(f"\n📈 总体评分: {passed}/{total} ({passed/total*100:.1f}%)") - - if passed == total: - print("\n🎉 所有检查通过!Docker部署配置完整!") - print("\n🚀 可以直接使用以下命令部署:") - print(" docker-compose up -d") - elif passed >= total * 0.8: - print("\n⚠️ 大部分检查通过,有少量问题需要修复") - print(" 建议修复上述问题后再部署") - else: - print("\n❌ 多项检查失败,需要完善配置后再部署") - - return passed == total - -if __name__ == "__main__": - success = main() - sys.exit(0 if success else 1) diff --git a/test_docker_deployment.sh b/test_docker_deployment.sh deleted file mode 100644 index f50051c..0000000 --- a/test_docker_deployment.sh +++ /dev/null @@ -1,192 +0,0 @@ -#!/bin/bash - -# Docker多用户系统部署测试脚本 - -echo "🚀 Docker多用户系统部署测试" -echo "==================================" - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# 检查Docker和Docker Compose -echo -e "${BLUE}1. 检查Docker环境${NC}" -if ! command -v docker &> /dev/null; then - echo -e "${RED}❌ Docker未安装${NC}" - exit 1 -fi - -if ! command -v docker-compose &> /dev/null; then - echo -e "${RED}❌ Docker Compose未安装${NC}" - exit 1 -fi - -echo -e "${GREEN}✅ Docker环境检查通过${NC}" -echo " Docker版本: $(docker --version)" -echo " Docker Compose版本: $(docker-compose --version)" - -# 检查必要文件 -echo -e "\n${BLUE}2. 检查部署文件${NC}" -required_files=("Dockerfile" "docker-compose.yml" "requirements.txt") - -for file in "${required_files[@]}"; do - if [ -f "$file" ]; then - echo -e "${GREEN}✅ $file${NC}" - else - echo -e "${RED}❌ $file 不存在${NC}" - exit 1 - fi -done - -# 检查新增依赖 -echo -e "\n${BLUE}3. 检查新增依赖${NC}" -if grep -q "Pillow" requirements.txt; then - echo -e "${GREEN}✅ Pillow依赖已添加${NC}" -else - echo -e "${RED}❌ Pillow依赖缺失${NC}" - exit 1 -fi - -# 停止现有容器 -echo -e "\n${BLUE}4. 停止现有容器${NC}" -docker-compose down -echo -e "${GREEN}✅ 容器已停止${NC}" - -# 构建镜像 -echo -e "\n${BLUE}5. 构建Docker镜像${NC}" -echo "这可能需要几分钟时间..." -if docker-compose build --no-cache; then - echo -e "${GREEN}✅ 镜像构建成功${NC}" -else - echo -e "${RED}❌ 镜像构建失败${NC}" - exit 1 -fi - -# 启动服务 -echo -e "\n${BLUE}6. 启动服务${NC}" -if docker-compose up -d; then - echo -e "${GREEN}✅ 服务启动成功${NC}" -else - echo -e "${RED}❌ 服务启动失败${NC}" - exit 1 -fi - -# 等待服务就绪 -echo -e "\n${BLUE}7. 等待服务就绪${NC}" -echo "等待30秒让服务完全启动..." -sleep 30 - -# 检查容器状态 -echo -e "\n${BLUE}8. 检查容器状态${NC}" -if docker-compose ps | grep -q "Up"; then - echo -e "${GREEN}✅ 容器运行正常${NC}" - docker-compose ps -else - echo -e "${RED}❌ 容器运行异常${NC}" - docker-compose ps - echo -e "\n${YELLOW}查看日志:${NC}" - docker-compose logs --tail=20 - exit 1 -fi - -# 健康检查 -echo -e "\n${BLUE}9. 健康检查${NC}" -max_attempts=10 -attempt=1 - -while [ $attempt -le $max_attempts ]; do - if curl -s http://localhost:8080/health > /dev/null; then - echo -e "${GREEN}✅ 健康检查通过${NC}" - break - else - echo -e "${YELLOW}⏳ 尝试 $attempt/$max_attempts - 等待服务响应...${NC}" - sleep 3 - ((attempt++)) - fi -done - -if [ $attempt -gt $max_attempts ]; then - echo -e "${RED}❌ 健康检查失败${NC}" - echo -e "\n${YELLOW}查看日志:${NC}" - docker-compose logs --tail=20 - exit 1 -fi - -# 测试图形验证码API -echo -e "\n${BLUE}10. 测试图形验证码功能${NC}" -response=$(curl -s -X POST http://localhost:8080/generate-captcha \ - -H "Content-Type: application/json" \ - -d '{"session_id": "test_session"}') - -if echo "$response" | grep -q '"success":true'; then - echo -e "${GREEN}✅ 图形验证码API正常${NC}" -else - echo -e "${RED}❌ 图形验证码API异常${NC}" - echo "响应: $response" -fi - -# 测试注册页面 -echo -e "\n${BLUE}11. 测试注册页面${NC}" -if curl -s http://localhost:8080/register.html | grep -q "用户注册"; then - echo -e "${GREEN}✅ 注册页面可访问${NC}" -else - echo -e "${RED}❌ 注册页面访问失败${NC}" -fi - -# 测试登录页面 -echo -e "\n${BLUE}12. 测试登录页面${NC}" -if curl -s http://localhost:8080/login.html | grep -q "登录"; then - echo -e "${GREEN}✅ 登录页面可访问${NC}" -else - echo -e "${RED}❌ 登录页面访问失败${NC}" -fi - -# 检查Pillow安装 -echo -e "\n${BLUE}13. 检查Pillow安装${NC}" -if docker-compose exec -T xianyu-app python -c "from PIL import Image; print('Pillow OK')" 2>/dev/null | grep -q "Pillow OK"; then - echo -e "${GREEN}✅ Pillow安装正常${NC}" -else - echo -e "${RED}❌ Pillow安装异常${NC}" -fi - -# 检查字体支持 -echo -e "\n${BLUE}14. 检查字体支持${NC}" -if docker-compose exec -T xianyu-app ls /usr/share/fonts/ 2>/dev/null | grep -q "dejavu"; then - echo -e "${GREEN}✅ 字体支持正常${NC}" -else - echo -e "${YELLOW}⚠️ 字体支持可能有问题${NC}" -fi - -# 显示访问信息 -echo -e "\n${GREEN}🎉 Docker部署测试完成!${NC}" -echo "==================================" -echo -e "${BLUE}访问信息:${NC}" -echo "• 主页: http://localhost:8080" -echo "• 登录页面: http://localhost:8080/login.html" -echo "• 注册页面: http://localhost:8080/register.html" -echo "• 健康检查: http://localhost:8080/health" -echo "" -echo -e "${BLUE}默认管理员账号:${NC}" -echo "• 用户名: admin" -echo "• 密码: admin123" -echo "" -echo -e "${BLUE}多用户功能:${NC}" -echo "• ✅ 用户注册" -echo "• ✅ 图形验证码" -echo "• ✅ 邮箱验证" -echo "• ✅ 数据隔离" -echo "" -echo -e "${YELLOW}管理命令:${NC}" -echo "• 查看日志: docker-compose logs -f" -echo "• 停止服务: docker-compose down" -echo "• 重启服务: docker-compose restart" -echo "• 查看状态: docker-compose ps" - -# 可选:显示资源使用情况 -echo -e "\n${BLUE}资源使用情况:${NC}" -docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" | grep xianyu || echo "无法获取资源使用情况" - -echo -e "\n${GREEN}部署测试完成!系统已就绪。${NC}" diff --git a/test_duplicate_notification_fix.py b/test_duplicate_notification_fix.py deleted file mode 100644 index 74231e8..0000000 --- a/test_duplicate_notification_fix.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 -""" -测试重复通知修复 -验证Token刷新失败时不会发送重复通知 -""" - -import asyncio -import time -from unittest.mock import AsyncMock, patch, MagicMock -import sys -import os - -# 添加项目根目录到路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -async def test_duplicate_notification_fix(): - """测试重复通知修复""" - print("🧪 测试Token刷新失败重复通知修复") - print("=" * 60) - - # 动态导入,避免配置问题 - try: - from XianyuAutoAsync import XianyuLive - print("✅ 成功导入 XianyuLive") - except Exception as e: - print(f"❌ 导入失败: {e}") - return - - # 创建测试用的XianyuLive实例 - test_cookies = "unb=test123; _m_h5_tk=test_token_123456789" - - try: - xianyu = XianyuLive(test_cookies, "test_account") - print("✅ XianyuLive 实例创建成功") - except Exception as e: - print(f"❌ 创建 XianyuLive 实例失败: {e}") - return - - # Mock外部依赖 - with patch('XianyuAutoAsync.db_manager') as mock_db, \ - patch('aiohttp.ClientSession') as mock_session: - - # 配置数据库mock - mock_db.get_account_notifications.return_value = [ - { - 'enabled': True, - 'channel_type': 'qq', - 'channel_name': 'Test QQ', - 'channel_config': {'qq_number': '123456', 'api_url': 'http://test.com'} - } - ] - - # Mock HTTP session - mock_response = MagicMock() - mock_response.status = 200 - mock_response.text = AsyncMock(return_value='{"ret": ["FAIL_SYS_SESSION_EXPIRED::Session过期"]}') - mock_session_instance = AsyncMock() - mock_session_instance.post.return_value.__aenter__.return_value = mock_response - mock_session.return_value = mock_session_instance - xianyu.session = mock_session_instance - - # Mock QQ通知发送方法 - xianyu._send_qq_notification = AsyncMock() - - print("\n📋 测试场景: Cookie过期导致Token刷新失败") - print("-" * 40) - - # 重置状态 - xianyu.current_token = None - xianyu.last_token_refresh_time = 0 - xianyu._send_qq_notification.reset_mock() - - print("1️⃣ 模拟 init() 方法调用...") - - # 创建一个mock websocket - mock_ws = MagicMock() - - try: - # 调用init方法,这会触发refresh_token,然后检查token - await xianyu.init(mock_ws) - except Exception as e: - print(f" 预期的异常: {e}") - - # 检查通知发送次数 - call_count = xianyu._send_qq_notification.call_count - print(f"\n📊 通知发送统计:") - print(f" 总调用次数: {call_count}") - - if call_count == 1: - print(" ✅ 成功!只发送了一次通知") - print(" 💡 说明: refresh_token失败后,init不会发送重复通知") - elif call_count == 2: - print(" ❌ 失败!发送了两次重复通知") - print(" 🔧 需要进一步优化防重复机制") - elif call_count == 0: - print(" ⚠️ 没有发送通知(可能是mock配置问题)") - else: - print(f" ❓ 异常的调用次数: {call_count}") - - # 显示调用详情 - if xianyu._send_qq_notification.call_args_list: - print(f"\n📝 通知调用详情:") - for i, call in enumerate(xianyu._send_qq_notification.call_args_list, 1): - args, kwargs = call - if len(args) >= 2: - message = args[1] - # 提取关键信息 - if "异常信息:" in message: - error_info = message.split("异常信息:")[1].split("\n")[0].strip() - print(f" 第{i}次: {error_info}") - - print("\n🔍 防重复机制分析:") - print(" • 方案1: 时间冷却期 - 5分钟内不重复发送相同类型通知") - print(" • 方案2: 逻辑判断 - init()检查是否刚刚尝试过refresh_token") - print(" • 当前使用: 方案2 (更精确,避免逻辑重复)") - - print(f"\n⏰ 通知时间记录:") - for notification_type, last_time in xianyu.last_notification_time.items(): - print(f" {notification_type}: {time.strftime('%H:%M:%S', time.localtime(last_time))}") - -def show_optimization_summary(): - """显示优化总结""" - print("\n\n📋 优化总结") - print("=" * 60) - - print("🎯 问题描述:") - print(" 用户反馈每次Token刷新异常都会收到两个相同的通知") - - print("\n🔍 问题根因:") - print(" 1. refresh_token() 失败时发送第一次通知") - print(" 2. init() 检查 current_token 为空时发送第二次通知") - print(" 3. 两次通知内容基本相同,造成用户困扰") - - print("\n🛠️ 解决方案:") - print(" 方案A: 添加通知防重复机制") - print(" • 为不同场景使用不同的通知类型") - print(" • 设置5分钟冷却期,避免短时间重复通知") - print(" • 保留详细的错误信息用于调试") - - print("\n 方案B: 优化逻辑判断") - print(" • 在 init() 中跟踪是否刚刚尝试过 refresh_token") - print(" • 如果刚刚尝试过且失败,则不发送重复通知") - print(" • 更精确地避免逻辑重复") - - print("\n✅ 实施的优化:") - print(" • 采用方案A + 方案B的组合") - print(" • 添加了通知防重复机制(时间冷却)") - print(" • 优化了 init() 方法的逻辑判断") - print(" • 为不同错误场景使用不同的通知类型") - - print("\n🎉 预期效果:") - print(" • 用户只会收到一次Token刷新异常通知") - print(" • 通知内容更加精确,便于问题定位") - print(" • 避免了通知轰炸,改善用户体验") - print(" • 保留了完整的错误信息用于调试") - -if __name__ == "__main__": - try: - asyncio.run(test_duplicate_notification_fix()) - show_optimization_summary() - - print("\n" + "=" * 60) - print("🎊 Token刷新重复通知修复测试完成!") - - except Exception as e: - print(f"❌ 测试过程中发生错误: {e}") - import traceback - traceback.print_exc() diff --git a/test_fix.py b/test_fix.py deleted file mode 100644 index c8360e4..0000000 --- a/test_fix.py +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env python3 -""" -测试修复 -""" - -def test_imports(): - print("🧪 测试导入修复") - - try: - from file_log_collector import setup_file_logging, get_file_log_collector - print("✅ file_log_collector 导入成功") - - # 测试初始化 - collector = setup_file_logging() - print("✅ 文件日志收集器初始化成功") - - # 生成测试日志 - from loguru import logger - logger.info("测试日志修复") - - print("✅ 所有导入和初始化都正常") - - except Exception as e: - print(f"❌ 导入失败: {e}") - -if __name__ == "__main__": - test_imports() diff --git a/test_gitignore.py b/test_gitignore.py deleted file mode 100644 index d8ecf7b..0000000 --- a/test_gitignore.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -""" -测试 .gitignore 规则是否正确 -验证 static/lib/ 目录不被忽略,而其他 lib/ 目录被忽略 -""" - -import os -import subprocess -import tempfile - -def test_gitignore_rules(): - """测试 .gitignore 规则""" - print("🧪 测试 .gitignore 规则") - print("=" * 50) - - # 检查文件是否存在 - static_lib_files = [ - "static/lib/bootstrap/bootstrap.min.css", - "static/lib/bootstrap/bootstrap.bundle.min.js", - "static/lib/bootstrap-icons/bootstrap-icons.css", - "static/lib/bootstrap-icons/fonts/bootstrap-icons.woff", - "static/lib/bootstrap-icons/fonts/bootstrap-icons.woff2" - ] - - print("\n1️⃣ 检查静态文件是否存在...") - all_exist = True - for file_path in static_lib_files: - if os.path.exists(file_path): - size = os.path.getsize(file_path) - print(f" ✅ {file_path} ({size:,} bytes)") - else: - print(f" ❌ {file_path} (不存在)") - all_exist = False - - if all_exist: - print(" 🎉 所有静态文件都存在!") - else: - print(" ⚠️ 部分静态文件缺失") - - # 检查 .gitignore 内容 - print("\n2️⃣ 检查 .gitignore 规则...") - try: - with open('.gitignore', 'r', encoding='utf-8') as f: - gitignore_content = f.read() - - if 'lib/' in gitignore_content and '!static/lib/' in gitignore_content: - print(" ✅ .gitignore 规则正确配置") - print(" 📝 规则说明:") - print(" - lib/ : 忽略所有 lib 目录") - print(" - !static/lib/ : 但不忽略 static/lib 目录") - else: - print(" ❌ .gitignore 规则配置不正确") - - except Exception as e: - print(f" ❌ 读取 .gitignore 失败: {e}") - - # 模拟测试(创建临时文件) - print("\n3️⃣ 模拟测试 gitignore 行为...") - - # 创建测试目录和文件 - test_dirs = [ - "lib/test_file.txt", # 应该被忽略 - "static/lib/test_file.txt", # 不应该被忽略 - "some_other_lib/test_file.txt" # 不应该被忽略 - ] - - created_files = [] - try: - for test_path in test_dirs: - os.makedirs(os.path.dirname(test_path), exist_ok=True) - with open(test_path, 'w') as f: - f.write("test content") - created_files.append(test_path) - print(f" 📁 创建测试文件: {test_path}") - - print("\n 📋 根据 .gitignore 规则预期:") - print(" - lib/test_file.txt : 应该被忽略") - print(" - static/lib/test_file.txt : 不应该被忽略") - print(" - some_other_lib/test_file.txt : 不应该被忽略") - - except Exception as e: - print(f" ❌ 创建测试文件失败: {e}") - - finally: - # 清理测试文件 - print("\n4️⃣ 清理测试文件...") - for file_path in created_files: - try: - if os.path.exists(file_path): - os.remove(file_path) - print(f" 🗑️ 删除: {file_path}") - except Exception as e: - print(f" ⚠️ 删除失败: {file_path} - {e}") - - # 清理空目录 - test_cleanup_dirs = ["lib", "some_other_lib"] - for dir_path in test_cleanup_dirs: - try: - if os.path.exists(dir_path) and not os.listdir(dir_path): - os.rmdir(dir_path) - print(f" 🗑️ 删除空目录: {dir_path}") - except Exception as e: - print(f" ⚠️ 删除目录失败: {dir_path} - {e}") - - print("\n" + "=" * 50) - print("🎯 总结:") - print("✅ static/lib/ 目录下的静态文件现在不会被 Git 忽略") - print("✅ 其他 lib/ 目录仍然会被正常忽略") - print("✅ 本地 CDN 资源可以正常提交到版本控制") - -def check_file_sizes(): - """检查静态文件大小""" - print("\n\n📊 静态文件大小统计") - print("=" * 50) - - files_info = [ - ("Bootstrap CSS", "static/lib/bootstrap/bootstrap.min.css"), - ("Bootstrap JS", "static/lib/bootstrap/bootstrap.bundle.min.js"), - ("Bootstrap Icons CSS", "static/lib/bootstrap-icons/bootstrap-icons.css"), - ("Bootstrap Icons WOFF2", "static/lib/bootstrap-icons/fonts/bootstrap-icons.woff2"), - ("Bootstrap Icons WOFF", "static/lib/bootstrap-icons/fonts/bootstrap-icons.woff") - ] - - total_size = 0 - for name, path in files_info: - if os.path.exists(path): - size = os.path.getsize(path) - total_size += size - print(f"📄 {name:<25} : {size:>8,} bytes ({size/1024:.1f} KB)") - else: - print(f"❌ {name:<25} : 文件不存在") - - print("-" * 50) - print(f"📦 总大小 : {total_size:>8,} bytes ({total_size/1024:.1f} KB)") - - if total_size > 0: - print(f"\n💡 优势:") - print(f" - 不再依赖 CDN,提升中国大陆访问速度") - print(f" - 离线可用,提高系统稳定性") - print(f" - 版本固定,避免 CDN 更新导致的兼容性问题") - -if __name__ == "__main__": - try: - test_gitignore_rules() - check_file_sizes() - except Exception as e: - print(f"❌ 测试过程中发生错误: {e}") - import traceback - traceback.print_exc() diff --git a/test_gitignore_db.py b/test_gitignore_db.py deleted file mode 100644 index a60bda2..0000000 --- a/test_gitignore_db.py +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env python3 -""" -测试 .gitignore 数据库文件忽略规则 -""" - -import os -import tempfile - -def test_database_gitignore(): - """测试数据库文件忽略规则""" - print("🧪 测试数据库文件 .gitignore 规则") - print("=" * 50) - - # 检查当前项目中的数据库文件 - print("\n1️⃣ 检查项目中的数据库文件...") - - db_patterns = ["*.db", "*.sqlite", "*.sqlite3"] - found_db_files = [] - - for root, dirs, files in os.walk("."): - for file in files: - file_path = os.path.join(root, file) - for pattern in db_patterns: - if file.endswith(pattern.replace("*", "")): - size = os.path.getsize(file_path) - found_db_files.append((file_path, size)) - print(f" 📄 {file_path} ({size:,} bytes)") - - if found_db_files: - print(f" 📊 找到 {len(found_db_files)} 个数据库文件") - else: - print(" ✅ 未找到数据库文件") - - # 检查 .gitignore 规则 - print("\n2️⃣ 检查 .gitignore 数据库规则...") - try: - with open('.gitignore', 'r', encoding='utf-8') as f: - gitignore_content = f.read() - - db_rules = ['*.db', '*.sqlite', '*.sqlite3'] - missing_rules = [] - - for rule in db_rules: - if rule in gitignore_content: - print(f" ✅ {rule} - 已配置") - else: - print(f" ❌ {rule} - 未配置") - missing_rules.append(rule) - - if not missing_rules: - print(" 🎉 所有数据库文件规则都已正确配置!") - else: - print(f" ⚠️ 缺少规则: {missing_rules}") - - except Exception as e: - print(f" ❌ 读取 .gitignore 失败: {e}") - - # 测试其他新增的忽略规则 - print("\n3️⃣ 检查其他新增的忽略规则...") - - other_rules = [ - ("临时文件", ["*.tmp", "*.temp", "temp/", "tmp/"]), - ("操作系统文件", [".DS_Store", "Thumbs.db"]), - ("IDE文件", [".vscode/", ".idea/", "*.swp"]), - ("环境文件", [".env", ".env.local"]) - ] - - for category, rules in other_rules: - print(f"\n 📂 {category}:") - for rule in rules: - if rule in gitignore_content: - print(f" ✅ {rule}") - else: - print(f" ❌ {rule}") - - # 模拟创建测试文件 - print("\n4️⃣ 模拟测试文件创建...") - - test_files = [ - "test.db", - "test.sqlite", - "test.sqlite3", - "test.tmp", - ".env", - "temp/test.txt" - ] - - created_files = [] - try: - for test_file in test_files: - # 创建目录(如果需要) - dir_path = os.path.dirname(test_file) - if dir_path: - os.makedirs(dir_path, exist_ok=True) - - # 创建文件 - with open(test_file, 'w') as f: - f.write("test content") - created_files.append(test_file) - print(f" 📁 创建测试文件: {test_file}") - - print("\n 📋 这些文件应该被 .gitignore 忽略:") - for file in test_files: - print(f" - {file}") - - except Exception as e: - print(f" ❌ 创建测试文件失败: {e}") - - finally: - # 清理测试文件 - print("\n5️⃣ 清理测试文件...") - for file_path in created_files: - try: - if os.path.exists(file_path): - os.remove(file_path) - print(f" 🗑️ 删除: {file_path}") - except Exception as e: - print(f" ⚠️ 删除失败: {file_path} - {e}") - - # 清理测试目录 - if os.path.exists("temp") and not os.listdir("temp"): - os.rmdir("temp") - print(" 🗑️ 删除空目录: temp") - - print("\n" + "=" * 50) - print("🎯 .gitignore 数据库文件忽略规则测试完成!") - -def show_gitignore_summary(): - """显示 .gitignore 规则总结""" - print("\n\n📋 .gitignore 规则总结") - print("=" * 50) - - categories = { - "Python 相关": [ - "__pycache__", "*.so", ".Python", "build/", "dist/", - "*.egg-info/", "__pypackages__/", ".venv", "venv/", "ENV/" - ], - "数据库文件": [ - "*.db", "*.sqlite", "*.sqlite3", "db.sqlite3" - ], - "静态资源": [ - "lib/ (但不包括 static/lib/)" - ], - "临时文件": [ - "*.tmp", "*.temp", "temp/", "tmp/", "*.log", ".cache" - ], - "操作系统": [ - ".DS_Store", "Thumbs.db", "ehthumbs.db" - ], - "IDE 和编辑器": [ - ".vscode/", ".idea/", "*.swp", "*.swo", "*~" - ], - "环境配置": [ - ".env", ".env.local", ".env.*.local", "local_settings.py" - ], - "Node.js": [ - "*node_modules/*" - ] - } - - for category, rules in categories.items(): - print(f"\n📂 {category}:") - for rule in rules: - print(f" • {rule}") - - print(f"\n💡 特别说明:") - print(f" • static/lib/ 目录不被忽略,用于存放本地 CDN 资源") - print(f" • 数据库文件被忽略,避免敏感数据泄露") - print(f" • 环境配置文件被忽略,保护敏感配置信息") - -if __name__ == "__main__": - try: - test_database_gitignore() - show_gitignore_summary() - except Exception as e: - print(f"❌ 测试过程中发生错误: {e}") - import traceback - traceback.print_exc() diff --git a/test_improvements.py b/test_improvements.py deleted file mode 100644 index b60429b..0000000 --- a/test_improvements.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env python3 -""" -测试系统改进功能 -""" - -import requests -import time -from loguru import logger - -BASE_URL = "http://localhost:8080" - -def test_admin_login(): - """测试管理员登录""" - logger.info("测试管理员登录...") - - response = requests.post(f"{BASE_URL}/login", - json={'username': 'admin', 'password': 'admin123'}) - - if response.json()['success']: - token = response.json()['token'] - user_id = response.json()['user_id'] - - logger.info(f"管理员登录成功,token: {token[:20]}...") - return { - 'token': token, - 'user_id': user_id, - 'headers': {'Authorization': f'Bearer {token}'} - } - else: - logger.error("管理员登录失败") - return None - -def test_page_access(): - """测试页面访问""" - logger.info("测试页面访问...") - - pages = [ - ("用户管理", "/user_management.html"), - ("日志管理", "/log_management.html"), - ("数据管理", "/data_management.html") - ] - - results = [] - for page_name, page_url in pages: - try: - response = requests.get(f"{BASE_URL}{page_url}", timeout=5) - - if response.status_code == 200: - logger.info(f"✅ {page_name} 页面访问成功 (200)") - results.append((page_name, True)) - else: - logger.error(f"❌ {page_name} 页面访问失败 ({response.status_code})") - results.append((page_name, False)) - - except Exception as e: - logger.error(f"❌ {page_name} 页面访问异常: {e}") - results.append((page_name, False)) - - return results - -def test_data_management_api(admin): - """测试数据管理API""" - logger.info("测试数据管理API...") - - # 测试获取表数据 - tables_to_test = ['users', 'cookies', 'cards'] - - for table in tables_to_test: - try: - response = requests.get(f"{BASE_URL}/admin/data/{table}", - headers=admin['headers']) - - if response.status_code == 200: - data = response.json() - if data['success']: - logger.info(f"✅ 获取 {table} 表数据成功,共 {data['count']} 条记录") - else: - logger.error(f"❌ 获取 {table} 表数据失败: {data.get('message', '未知错误')}") - return False - else: - logger.error(f"❌ 获取 {table} 表数据失败: {response.status_code}") - return False - - except Exception as e: - logger.error(f"❌ 获取 {table} 表数据异常: {e}") - return False - - return True - -def test_log_management_api(admin): - """测试日志管理API""" - logger.info("测试日志管理API...") - - try: - # 测试获取日志 - response = requests.get(f"{BASE_URL}/admin/logs?lines=10", - headers=admin['headers']) - - if response.status_code == 200: - data = response.json() - logs = data.get('logs', []) - logger.info(f"✅ 获取系统日志成功,共 {len(logs)} 条") - - # 测试日志级别过滤 - response = requests.get(f"{BASE_URL}/admin/logs?lines=5&level=info", - headers=admin['headers']) - - if response.status_code == 200: - data = response.json() - info_logs = data.get('logs', []) - logger.info(f"✅ INFO级别日志过滤成功,共 {len(info_logs)} 条") - return True - else: - logger.error(f"❌ 日志级别过滤失败: {response.status_code}") - return False - else: - logger.error(f"❌ 获取系统日志失败: {response.status_code}") - return False - - except Exception as e: - logger.error(f"❌ 日志管理API测试异常: {e}") - return False - -def test_database_backup_api(admin): - """测试数据库备份API""" - logger.info("测试数据库备份API...") - - try: - # 测试数据库下载 - response = requests.get(f"{BASE_URL}/admin/backup/download", - headers=admin['headers']) - - if response.status_code == 200: - logger.info(f"✅ 数据库备份下载成功,文件大小: {len(response.content)} bytes") - - # 测试备份文件列表 - response = requests.get(f"{BASE_URL}/admin/backup/list", - headers=admin['headers']) - - if response.status_code == 200: - data = response.json() - backups = data.get('backups', []) - logger.info(f"✅ 获取备份文件列表成功,共 {len(backups)} 个备份") - return True - else: - logger.error(f"❌ 获取备份文件列表失败: {response.status_code}") - return False - else: - logger.error(f"❌ 数据库备份下载失败: {response.status_code}") - return False - - except Exception as e: - logger.error(f"❌ 数据库备份API测试异常: {e}") - return False - -def main(): - """主测试函数""" - print("🚀 系统改进功能测试") - print("=" * 60) - - print("📋 测试内容:") - print("• 页面访问测试") - print("• 日志管理功能") - print("• 数据管理功能") - print("• 数据库备份功能") - - try: - # 管理员登录 - admin = test_admin_login() - if not admin: - print("❌ 测试失败:管理员登录失败") - return False - - print("✅ 管理员登录成功") - - # 测试页面访问 - print(f"\n🧪 测试页面访问...") - page_results = test_page_access() - - page_success = all(result[1] for result in page_results) - for page_name, success in page_results: - status = "✅ 通过" if success else "❌ 失败" - print(f" {page_name}: {status}") - - # 测试API功能 - tests = [ - ("数据管理API", lambda: test_data_management_api(admin)), - ("日志管理API", lambda: test_log_management_api(admin)), - ("数据库备份API", lambda: test_database_backup_api(admin)), - ] - - api_results = [] - for test_name, test_func in tests: - print(f"\n🧪 测试 {test_name}...") - try: - result = test_func() - api_results.append((test_name, result)) - if result: - print(f"✅ {test_name} 测试通过") - else: - print(f"❌ {test_name} 测试失败") - except Exception as e: - print(f"💥 {test_name} 测试异常: {e}") - api_results.append((test_name, False)) - - print("\n" + "=" * 60) - print("🎉 系统改进功能测试完成!") - - print("\n📊 测试结果:") - - # 页面访问结果 - print("页面访问:") - for page_name, success in page_results: - status = "✅ 通过" if success else "❌ 失败" - print(f" {page_name}: {status}") - - # API功能结果 - print("API功能:") - for test_name, success in api_results: - status = "✅ 通过" if success else "❌ 失败" - print(f" {test_name}: {status}") - - # 总体结果 - all_results = page_results + api_results - passed = sum(1 for _, success in all_results if success) - total = len(all_results) - - print(f"\n📈 总体结果: {passed}/{total} 项测试通过") - - if passed == total: - print("🎊 所有测试都通过了!系统改进功能正常工作。") - - print("\n💡 功能说明:") - print("1. 日志管理:最新日志显示在最上面") - print("2. 系统设置:只保留数据库备份模式") - print("3. 数据管理:新增管理员专用的数据表管理功能") - print("4. 所有管理员功能都有严格的权限控制") - - print("\n🎯 使用方法:") - print("• 使用admin账号登录系统") - print("• 在侧边栏可以看到管理员功能菜单") - print("• 点击相应功能进入管理页面") - print("• 数据管理可以查看和删除表中的数据") - - return True - else: - print("⚠️ 部分测试失败,请检查相关功能。") - return False - - except Exception as e: - print(f"💥 测试异常: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - main() diff --git a/test_keyword_reply.py b/test_keyword_reply.py deleted file mode 100644 index 3958915..0000000 --- a/test_keyword_reply.py +++ /dev/null @@ -1,290 +0,0 @@ -#!/usr/bin/env python3 -""" -关键词回复功能测试脚本 -用于验证关键词匹配是否正常工作 -""" - -import asyncio -import sys -import os - -# 添加项目根目录到Python路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from db_manager import db_manager -from XianyuAutoAsync import XianyuLive -from loguru import logger - -def test_keyword_database(): - """测试关键词数据库操作""" - print("🗄️ 开始测试关键词数据库操作...") - - test_cookie_id = "test_cookie_001" - - # 1. 清理测试数据 - print("\n1️⃣ 清理测试数据...") - try: - with db_manager.lock: - cursor = db_manager.conn.cursor() - cursor.execute("DELETE FROM keywords WHERE cookie_id = ?", (test_cookie_id,)) - db_manager.conn.commit() - print(" ✅ 测试数据清理完成") - except Exception as e: - print(f" ❌ 清理测试数据失败: {e}") - return False - - # 2. 添加测试关键词 - print("\n2️⃣ 添加测试关键词...") - test_keywords = [ - ("你好", "您好!欢迎咨询,有什么可以帮助您的吗?"), - ("价格", "这个商品的价格很优惠哦,{send_user_name}!"), - ("包邮", "全国包邮,放心购买!"), - ("发货", "我们会在24小时内发货"), - ("退换", "支持7天无理由退换货") - ] - - try: - success = db_manager.save_keywords(test_cookie_id, test_keywords) - if success: - print(f" ✅ 成功添加 {len(test_keywords)} 个关键词") - else: - print(" ❌ 添加关键词失败") - return False - except Exception as e: - print(f" ❌ 添加关键词异常: {e}") - return False - - # 3. 验证关键词保存 - print("\n3️⃣ 验证关键词保存...") - try: - saved_keywords = db_manager.get_keywords(test_cookie_id) - if len(saved_keywords) == len(test_keywords): - print(f" ✅ 关键词保存验证成功: {len(saved_keywords)} 个") - for keyword, reply in saved_keywords: - print(f" '{keyword}' -> '{reply[:30]}...'") - else: - print(f" ❌ 关键词数量不匹配: 期望 {len(test_keywords)}, 实际 {len(saved_keywords)}") - return False - except Exception as e: - print(f" ❌ 验证关键词异常: {e}") - return False - - print("\n✅ 关键词数据库操作测试完成!") - return True - -async def test_keyword_matching(): - """测试关键词匹配功能""" - print("\n🔍 开始测试关键词匹配功能...") - - test_cookie_id = "test_cookie_001" - - # 创建一个简化的测试类 - class TestKeywordMatcher: - def __init__(self, cookie_id): - self.cookie_id = cookie_id - - async def get_keyword_reply(self, send_user_name: str, send_user_id: str, send_message: str) -> str: - """获取关键词匹配回复""" - try: - from db_manager import db_manager - - # 获取当前账号的关键词列表 - keywords = db_manager.get_keywords(self.cookie_id) - - if not keywords: - print(f" 调试: 账号 {self.cookie_id} 没有配置关键词") - return None - - # 遍历关键词,查找匹配 - for keyword, reply in keywords: - if keyword.lower() in send_message.lower(): - # 进行变量替换 - try: - formatted_reply = reply.format( - send_user_name=send_user_name, - send_user_id=send_user_id, - send_message=send_message - ) - print(f" 调试: 关键词匹配成功: '{keyword}' -> {formatted_reply}") - return f"[关键词回复] {formatted_reply}" - except Exception as format_error: - print(f" 调试: 关键词回复变量替换失败: {format_error}") - # 如果变量替换失败,返回原始内容 - return f"[关键词回复] {reply}" - - print(f" 调试: 未找到匹配的关键词: {send_message}") - return None - - except Exception as e: - print(f" 调试: 获取关键词回复失败: {e}") - return None - - # 创建测试实例 - test_matcher = TestKeywordMatcher(test_cookie_id) - - # 测试消息和期望结果 - test_cases = [ - { - "message": "你好", - "expected_keyword": "你好", - "should_match": True - }, - { - "message": "请问价格多少?", - "expected_keyword": "价格", - "should_match": True - }, - { - "message": "包邮吗?", - "expected_keyword": "包邮", - "should_match": True - }, - { - "message": "什么时候发货?", - "expected_keyword": "发货", - "should_match": True - }, - { - "message": "可以退换吗?", - "expected_keyword": "退换", - "should_match": True - }, - { - "message": "这是什么材质的?", - "expected_keyword": None, - "should_match": False - } - ] - - success_count = 0 - - for i, test_case in enumerate(test_cases, 1): - print(f"\n{i}️⃣ 测试消息: '{test_case['message']}'") - - try: - reply = await test_matcher.get_keyword_reply( - send_user_name="测试用户", - send_user_id="test_user_001", - send_message=test_case['message'] - ) - - if test_case['should_match']: - if reply: - print(f" ✅ 匹配成功: {reply}") - if test_case['expected_keyword'] in reply or test_case['expected_keyword'] in test_case['message']: - success_count += 1 - else: - print(f" ⚠️ 匹配的关键词不符合预期") - else: - print(f" ❌ 期望匹配但未匹配") - else: - if reply: - print(f" ❌ 不应该匹配但却匹配了: {reply}") - else: - print(f" ✅ 正确未匹配") - success_count += 1 - - except Exception as e: - print(f" ❌ 测试异常: {e}") - - print(f"\n📊 测试结果: {success_count}/{len(test_cases)} 个测试通过") - - if success_count == len(test_cases): - print("✅ 关键词匹配功能测试完成!") - return True - else: - print("❌ 部分测试失败") - return False - -def test_reply_priority(): - """测试回复优先级""" - print("\n🎯 开始测试回复优先级...") - - test_cookie_id = "test_cookie_001" - - # 检查AI回复状态 - print("\n1️⃣ 检查AI回复状态...") - try: - ai_settings = db_manager.get_ai_reply_settings(test_cookie_id) - ai_enabled = ai_settings.get('ai_enabled', False) - print(f" AI回复状态: {'启用' if ai_enabled else '禁用'}") - except Exception as e: - print(f" ❌ 检查AI回复状态失败: {e}") - return False - - # 检查关键词数量 - print("\n2️⃣ 检查关键词配置...") - try: - keywords = db_manager.get_keywords(test_cookie_id) - print(f" 关键词数量: {len(keywords)} 个") - if len(keywords) > 0: - print(" 关键词列表:") - for keyword, reply in keywords[:3]: # 只显示前3个 - print(f" '{keyword}' -> '{reply[:30]}...'") - except Exception as e: - print(f" ❌ 检查关键词配置失败: {e}") - return False - - # 检查默认回复 - print("\n3️⃣ 检查默认回复配置...") - try: - default_reply = db_manager.get_default_reply(test_cookie_id) - if default_reply and default_reply.get('enabled', False): - print(f" 默认回复: 启用") - print(f" 默认回复内容: {default_reply.get('reply_content', '')[:50]}...") - else: - print(f" 默认回复: 禁用") - except Exception as e: - print(f" ❌ 检查默认回复配置失败: {e}") - return False - - print("\n📋 回复优先级说明:") - print(" 1. API回复 (最高优先级)") - print(" 2. AI回复 (如果启用)") - print(" 3. 关键词匹配 (如果AI禁用)") - print(" 4. 默认回复 (最低优先级)") - - if not ai_enabled and len(keywords) > 0: - print("\n✅ 当前配置下,关键词匹配应该正常工作!") - return True - elif ai_enabled: - print("\n⚠️ 当前AI回复已启用,关键词匹配会被跳过") - print(" 如需测试关键词匹配,请先禁用AI回复") - return True - else: - print("\n⚠️ 当前没有配置关键词,将使用默认回复") - return True - -async def main(): - """主测试函数""" - print("🚀 关键词回复功能测试开始") - print("=" * 50) - - # 测试数据库操作 - db_ok = test_keyword_database() - if not db_ok: - print("\n❌ 数据库操作测试失败") - return - - # 测试关键词匹配 - match_ok = await test_keyword_matching() - if not match_ok: - print("\n❌ 关键词匹配测试失败") - return - - # 测试回复优先级 - priority_ok = test_reply_priority() - if not priority_ok: - print("\n❌ 回复优先级测试失败") - return - - print("\n" + "=" * 50) - print("🎉 所有测试通过!关键词回复功能正常!") - print("\n📋 使用说明:") - print("1. 在Web界面的'自动回复'页面配置关键词") - print("2. 确保AI回复已禁用(如果要使用关键词匹配)") - print("3. 发送包含关键词的消息进行测试") - print("4. 关键词匹配支持变量替换:{send_user_name}, {send_user_id}, {send_message}") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/test_multiuser_system.py b/test_multiuser_system.py deleted file mode 100644 index 0c841ce..0000000 --- a/test_multiuser_system.py +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env python3 -""" -多用户系统功能测试 -""" - -import asyncio -import json -import time -from db_manager import db_manager - -async def test_user_registration(): - """测试用户注册功能""" - print("🧪 测试用户注册功能") - print("-" * 40) - - # 测试邮箱验证码生成 - print("1️⃣ 测试验证码生成...") - code = db_manager.generate_verification_code() - print(f" 生成的验证码: {code}") - assert len(code) == 6 and code.isdigit(), "验证码应该是6位数字" - print(" ✅ 验证码生成正常") - - # 测试保存验证码 - print("\n2️⃣ 测试验证码保存...") - test_email = "test@example.com" - success = db_manager.save_verification_code(test_email, code) - assert success, "验证码保存应该成功" - print(" ✅ 验证码保存成功") - - # 测试验证码验证 - print("\n3️⃣ 测试验证码验证...") - valid = db_manager.verify_email_code(test_email, code) - assert valid, "正确的验证码应该验证成功" - print(" ✅ 验证码验证成功") - - # 测试验证码重复使用 - print("\n4️⃣ 测试验证码重复使用...") - valid_again = db_manager.verify_email_code(test_email, code) - assert not valid_again, "已使用的验证码不应该再次验证成功" - print(" ✅ 验证码重复使用被正确阻止") - - # 测试用户创建 - print("\n5️⃣ 测试用户创建...") - test_username = "testuser" - test_password = "testpass123" - - # 先清理可能存在的测试用户 - try: - db_manager.conn.execute("DELETE FROM users WHERE username = ? OR email = ?", (test_username, test_email)) - db_manager.conn.commit() - except: - pass - - success = db_manager.create_user(test_username, test_email, test_password) - assert success, "用户创建应该成功" - print(" ✅ 用户创建成功") - - # 测试重复用户名 - print("\n6️⃣ 测试重复用户名...") - success = db_manager.create_user(test_username, "another@example.com", test_password) - assert not success, "重复用户名应该创建失败" - print(" ✅ 重复用户名被正确拒绝") - - # 测试重复邮箱 - print("\n7️⃣ 测试重复邮箱...") - success = db_manager.create_user("anotheruser", test_email, test_password) - assert not success, "重复邮箱应该创建失败" - print(" ✅ 重复邮箱被正确拒绝") - - # 测试用户查询 - print("\n8️⃣ 测试用户查询...") - user = db_manager.get_user_by_username(test_username) - assert user is not None, "应该能查询到创建的用户" - assert user['username'] == test_username, "用户名应该匹配" - assert user['email'] == test_email, "邮箱应该匹配" - print(" ✅ 用户查询成功") - - # 测试密码验证 - print("\n9️⃣ 测试密码验证...") - valid = db_manager.verify_user_password(test_username, test_password) - assert valid, "正确密码应该验证成功" - - invalid = db_manager.verify_user_password(test_username, "wrongpassword") - assert not invalid, "错误密码应该验证失败" - print(" ✅ 密码验证正常") - - # 清理测试数据 - print("\n🧹 清理测试数据...") - db_manager.conn.execute("DELETE FROM users WHERE username = ?", (test_username,)) - db_manager.conn.execute("DELETE FROM email_verifications WHERE email = ?", (test_email,)) - db_manager.conn.commit() - print(" ✅ 测试数据清理完成") - -def test_user_isolation(): - """测试用户数据隔离""" - print("\n🧪 测试用户数据隔离") - print("-" * 40) - - # 创建测试用户 - print("1️⃣ 创建测试用户...") - user1_name = "testuser1" - user2_name = "testuser2" - user1_email = "user1@test.com" - user2_email = "user2@test.com" - password = "testpass123" - - # 清理可能存在的测试数据 - try: - db_manager.conn.execute("DELETE FROM cookies WHERE id LIKE 'test_%'") - db_manager.conn.execute("DELETE FROM users WHERE username IN (?, ?)", (user1_name, user2_name)) - db_manager.conn.commit() - except: - pass - - # 创建用户 - success1 = db_manager.create_user(user1_name, user1_email, password) - success2 = db_manager.create_user(user2_name, user2_email, password) - assert success1 and success2, "用户创建应该成功" - - user1 = db_manager.get_user_by_username(user1_name) - user2 = db_manager.get_user_by_username(user2_name) - user1_id = user1['id'] - user2_id = user2['id'] - print(f" ✅ 用户创建成功: {user1_name}(ID:{user1_id}), {user2_name}(ID:{user2_id})") - - # 测试Cookie隔离 - print("\n2️⃣ 测试Cookie数据隔离...") - - # 用户1添加cookies - db_manager.save_cookie("test_cookie_1", "cookie_value_1", user1_id) - db_manager.save_cookie("test_cookie_2", "cookie_value_2", user1_id) - - # 用户2添加cookies - db_manager.save_cookie("test_cookie_3", "cookie_value_3", user2_id) - db_manager.save_cookie("test_cookie_4", "cookie_value_4", user2_id) - - # 验证用户1只能看到自己的cookies - user1_cookies = db_manager.get_all_cookies(user1_id) - user1_cookie_ids = set(user1_cookies.keys()) - expected_user1_cookies = {"test_cookie_1", "test_cookie_2"} - - assert expected_user1_cookies.issubset(user1_cookie_ids), f"用户1应该能看到自己的cookies: {expected_user1_cookies}" - assert "test_cookie_3" not in user1_cookie_ids, "用户1不应该看到用户2的cookies" - assert "test_cookie_4" not in user1_cookie_ids, "用户1不应该看到用户2的cookies" - print(" ✅ 用户1的Cookie隔离正常") - - # 验证用户2只能看到自己的cookies - user2_cookies = db_manager.get_all_cookies(user2_id) - user2_cookie_ids = set(user2_cookies.keys()) - expected_user2_cookies = {"test_cookie_3", "test_cookie_4"} - - assert expected_user2_cookies.issubset(user2_cookie_ids), f"用户2应该能看到自己的cookies: {expected_user2_cookies}" - assert "test_cookie_1" not in user2_cookie_ids, "用户2不应该看到用户1的cookies" - assert "test_cookie_2" not in user2_cookie_ids, "用户2不应该看到用户1的cookies" - print(" ✅ 用户2的Cookie隔离正常") - - # 测试关键字隔离 - print("\n3️⃣ 测试关键字数据隔离...") - - # 添加关键字 - user1_keywords = [("hello", "user1 reply"), ("price", "user1 price")] - user2_keywords = [("hello", "user2 reply"), ("info", "user2 info")] - - db_manager.save_keywords("test_cookie_1", user1_keywords) - db_manager.save_keywords("test_cookie_3", user2_keywords) - - # 验证关键字隔离 - user1_all_keywords = db_manager.get_all_keywords(user1_id) - user2_all_keywords = db_manager.get_all_keywords(user2_id) - - assert "test_cookie_1" in user1_all_keywords, "用户1应该能看到自己的关键字" - assert "test_cookie_3" not in user1_all_keywords, "用户1不应该看到用户2的关键字" - - assert "test_cookie_3" in user2_all_keywords, "用户2应该能看到自己的关键字" - assert "test_cookie_1" not in user2_all_keywords, "用户2不应该看到用户1的关键字" - print(" ✅ 关键字数据隔离正常") - - # 测试备份隔离 - print("\n4️⃣ 测试备份数据隔离...") - - # 用户1备份 - user1_backup = db_manager.export_backup(user1_id) - user1_backup_cookies = [row[0] for row in user1_backup['data']['cookies']['rows']] - - assert "test_cookie_1" in user1_backup_cookies, "用户1备份应该包含自己的cookies" - assert "test_cookie_2" in user1_backup_cookies, "用户1备份应该包含自己的cookies" - assert "test_cookie_3" not in user1_backup_cookies, "用户1备份不应该包含其他用户的cookies" - assert "test_cookie_4" not in user1_backup_cookies, "用户1备份不应该包含其他用户的cookies" - print(" ✅ 用户1备份隔离正常") - - # 用户2备份 - user2_backup = db_manager.export_backup(user2_id) - user2_backup_cookies = [row[0] for row in user2_backup['data']['cookies']['rows']] - - assert "test_cookie_3" in user2_backup_cookies, "用户2备份应该包含自己的cookies" - assert "test_cookie_4" in user2_backup_cookies, "用户2备份应该包含自己的cookies" - assert "test_cookie_1" not in user2_backup_cookies, "用户2备份不应该包含其他用户的cookies" - assert "test_cookie_2" not in user2_backup_cookies, "用户2备份不应该包含其他用户的cookies" - print(" ✅ 用户2备份隔离正常") - - # 清理测试数据 - print("\n🧹 清理测试数据...") - db_manager.conn.execute("DELETE FROM keywords WHERE cookie_id LIKE 'test_%'") - db_manager.conn.execute("DELETE FROM cookies WHERE id LIKE 'test_%'") - db_manager.conn.execute("DELETE FROM users WHERE username IN (?, ?)", (user1_name, user2_name)) - db_manager.conn.commit() - print(" ✅ 测试数据清理完成") - -async def test_email_sending(): - """测试邮件发送功能(模拟)""" - print("\n🧪 测试邮件发送功能") - print("-" * 40) - - print("📧 邮件发送功能测试(需要网络连接)") - print(" 注意:这将发送真实的邮件,请确保邮箱地址正确") - - test_email = input("请输入测试邮箱地址(回车跳过): ").strip() - - if test_email: - print(f" 正在发送测试邮件到: {test_email}") - code = db_manager.generate_verification_code() - - try: - success = await db_manager.send_verification_email(test_email, code) - if success: - print(" ✅ 邮件发送成功!请检查邮箱") - else: - print(" ❌ 邮件发送失败") - except Exception as e: - print(f" ❌ 邮件发送异常: {e}") - else: - print(" ⏭️ 跳过邮件发送测试") - -async def main(): - """主测试函数""" - print("🚀 多用户系统功能测试") - print("=" * 60) - - try: - # 测试用户注册功能 - await test_user_registration() - - # 测试用户数据隔离 - test_user_isolation() - - # 测试邮件发送(可选) - await test_email_sending() - - print("\n" + "=" * 60) - print("🎉 所有测试通过!多用户系统功能正常") - - print("\n📋 测试总结:") - print("✅ 用户注册功能正常") - print("✅ 邮箱验证码功能正常") - print("✅ 用户数据隔离正常") - print("✅ Cookie数据隔离正常") - print("✅ 关键字数据隔离正常") - print("✅ 备份数据隔离正常") - - print("\n💡 下一步:") - print("1. 运行迁移脚本: python migrate_to_multiuser.py") - print("2. 重启应用程序") - print("3. 访问 /register.html 测试用户注册") - print("4. 测试多用户登录和数据隔离") - - except AssertionError as e: - print(f"\n❌ 测试失败: {e}") - return False - except Exception as e: - print(f"\n💥 测试异常: {e}") - import traceback - traceback.print_exc() - return False - - return True - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/test_notification_deduplication.py b/test_notification_deduplication.py deleted file mode 100644 index 484eee5..0000000 --- a/test_notification_deduplication.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -""" -测试通知防重复机制 -验证Token刷新异常通知不会重复发送 -""" - -import asyncio -import time -from unittest.mock import AsyncMock, patch, MagicMock -from XianyuAutoAsync import XianyuLive - -async def test_notification_deduplication(): - """测试通知防重复机制""" - print("🧪 测试通知防重复机制") - print("=" * 50) - - # 创建测试用的XianyuLive实例 - test_cookies = "unb=test123; _m_h5_tk=test_token_123456789" - - try: - xianyu = XianyuLive(test_cookies, "test_account") - print("✅ XianyuLive 实例创建成功") - except Exception as e: - print(f"❌ 创建 XianyuLive 实例失败: {e}") - return - - # Mock数据库和通知方法 - with patch('XianyuAutoAsync.db_manager') as mock_db: - # 配置mock返回值 - mock_db.get_account_notifications.return_value = [ - { - 'enabled': True, - 'channel_type': 'qq', - 'channel_name': 'Test QQ', - 'channel_config': {'qq_number': '123456', 'api_url': 'http://test.com'} - } - ] - - # Mock QQ通知发送方法 - xianyu._send_qq_notification = AsyncMock() - - print("\n1️⃣ 测试首次发送通知...") - - # 第一次发送通知 - start_time = time.time() - await xianyu.send_token_refresh_notification("Token刷新失败: Session过期", "token_refresh_failed") - - # 验证通知是否发送 - if xianyu._send_qq_notification.called: - print("✅ 首次通知发送成功") - print(f" 发送时间: {time.strftime('%H:%M:%S', time.localtime(start_time))}") - else: - print("❌ 首次通知发送失败") - return - - print("\n2️⃣ 测试冷却期内重复发送...") - - # 重置mock调用计数 - xianyu._send_qq_notification.reset_mock() - - # 立即再次发送相同类型的通知 - await xianyu.send_token_refresh_notification("Token刷新失败: Session过期", "token_refresh_failed") - - # 验证通知是否被阻止 - if not xianyu._send_qq_notification.called: - print("✅ 冷却期内的重复通知被正确阻止") - cooldown_end = start_time + xianyu.notification_cooldown - print(f" 冷却期结束时间: {time.strftime('%H:%M:%S', time.localtime(cooldown_end))}") - else: - print("❌ 冷却期内的重复通知未被阻止") - - print("\n3️⃣ 测试不同类型的通知...") - - # 重置mock调用计数 - xianyu._send_qq_notification.reset_mock() - - # 发送不同类型的通知 - await xianyu.send_token_refresh_notification("初始化时无法获取有效Token", "token_init_failed") - - # 验证不同类型的通知是否正常发送 - if xianyu._send_qq_notification.called: - print("✅ 不同类型的通知正常发送") - else: - print("❌ 不同类型的通知发送失败") - - print("\n4️⃣ 测试通知类型统计...") - - # 显示当前的通知时间记录 - print(" 当前通知时间记录:") - for notification_type, last_time in xianyu.last_notification_time.items(): - print(f" {notification_type}: {time.strftime('%H:%M:%S', time.localtime(last_time))}") - - print(f" 通知冷却时间: {xianyu.notification_cooldown} 秒 ({xianyu.notification_cooldown // 60} 分钟)") - - print("\n5️⃣ 测试模拟真实场景...") - - # 模拟真实的Token刷新失败场景 - print(" 模拟场景: refresh_token() 失败 + init() 检查失败") - - # 重置mock和时间记录 - xianyu._send_qq_notification.reset_mock() - xianyu.last_notification_time.clear() - - # 模拟refresh_token失败 - await xianyu.send_token_refresh_notification("Token刷新失败: {'ret': ['FAIL_SYS_SESSION_EXPIRED::Session过期']}", "token_refresh_failed") - first_call_count = xianyu._send_qq_notification.call_count - - # 模拟init检查失败(这应该被阻止,因为是相同的根本原因) - await xianyu.send_token_refresh_notification("初始化时无法获取有效Token", "token_init_failed") - second_call_count = xianyu._send_qq_notification.call_count - - print(f" refresh_token 通知调用次数: {first_call_count}") - print(f" init 通知调用次数: {second_call_count - first_call_count}") - print(f" 总调用次数: {second_call_count}") - - if second_call_count == 2: - print("✅ 不同阶段的通知都正常发送(因为使用了不同的通知类型)") - elif second_call_count == 1: - print("⚠️ 只发送了一次通知(可能需要调整策略)") - else: - print(f"❌ 异常的调用次数: {second_call_count}") - -def test_notification_types(): - """测试通知类型分类""" - print("\n\n📋 通知类型分类说明") - print("=" * 50) - - notification_types = { - "token_refresh_failed": "Token刷新API调用失败", - "token_refresh_exception": "Token刷新过程中发生异常", - "token_init_failed": "初始化时无法获取有效Token", - "token_scheduled_refresh_failed": "定时Token刷新失败", - "db_update_failed": "数据库Cookie更新失败", - "cookie_id_missing": "Cookie ID不存在", - "cookie_update_failed": "Cookie更新失败" - } - - print("🏷️ 通知类型及其含义:") - for type_name, description in notification_types.items(): - print(f" • {type_name:<30} : {description}") - - print(f"\n⏰ 防重复机制:") - print(f" • 冷却时间: 5分钟 (300秒)") - print(f" • 相同类型的通知在冷却期内不会重复发送") - print(f" • 不同类型的通知可以正常发送") - print(f" • 成功发送后才会更新冷却时间") - -async def test_real_scenario_simulation(): - """测试真实场景模拟""" - print("\n\n🎭 真实场景模拟") - print("=" * 50) - - print("📋 场景描述:") - print(" 1. 用户的Cookie过期") - print(" 2. refresh_token() 调用失败,返回 Session过期") - print(" 3. init() 检查 current_token 为空,也发送通知") - print(" 4. 期望结果: 只收到一次通知,而不是两次") - - print("\n🔧 解决方案:") - print(" • 为不同阶段使用不同的通知类型") - print(" • token_refresh_failed: refresh_token API失败") - print(" • token_init_failed: 初始化检查失败") - print(" • 这样可以区分问题发生的具体阶段") - print(" • 但仍然避免短时间内的重复通知") - -if __name__ == "__main__": - try: - asyncio.run(test_notification_deduplication()) - test_notification_types() - asyncio.run(test_real_scenario_simulation()) - - print("\n" + "=" * 50) - print("🎉 通知防重复机制测试完成!") - print("\n💡 优化效果:") - print(" ✅ 避免了短时间内的重复通知") - print(" ✅ 保留了不同阶段的错误信息") - print(" ✅ 提供了5分钟的冷却期") - print(" ✅ 用户体验得到改善") - - except Exception as e: - print(f"❌ 测试过程中发生错误: {e}") - import traceback - traceback.print_exc() diff --git a/test_page_access.py b/test_page_access.py deleted file mode 100644 index f76bdae..0000000 --- a/test_page_access.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -""" -测试页面访问 -""" - -import requests -import time - -BASE_URL = "http://localhost:8080" - -def test_page_access(): - """测试页面访问""" - print("🚀 测试管理员页面访问") - print("=" * 50) - - pages = [ - ("主页", "/"), - ("登录页", "/login.html"), - ("注册页", "/register.html"), - ("管理页", "/admin"), - ("用户管理", "/user_management.html"), - ("日志管理", "/log_management.html") - ] - - for page_name, page_url in pages: - try: - print(f"测试 {page_name} ({page_url})...", end=" ") - response = requests.get(f"{BASE_URL}{page_url}", timeout=5) - - if response.status_code == 200: - print(f"✅ {response.status_code}") - else: - print(f"❌ {response.status_code}") - - except requests.exceptions.ConnectionError: - print("❌ 连接失败") - except requests.exceptions.Timeout: - print("❌ 超时") - except Exception as e: - print(f"❌ 错误: {e}") - - print("\n" + "=" * 50) - print("测试完成!") - -if __name__ == "__main__": - test_page_access() diff --git a/test_simple_token_filter.py b/test_simple_token_filter.py deleted file mode 100644 index 2ea3dfb..0000000 --- a/test_simple_token_filter.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 -""" -简单测试令牌过期过滤逻辑 -""" - -import sys -import os - -# 添加项目根目录到路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -def test_token_expiry_filter_logic(): - """测试令牌过期过滤逻辑""" - print("🧪 测试令牌过期过滤逻辑") - print("=" * 50) - - # 直接测试过滤逻辑,不依赖完整的XianyuLive实例 - def _is_normal_token_expiry(error_message: str) -> bool: - """检查是否是正常的令牌过期(这种情况不需要发送通知)""" - # 正常的令牌过期关键词 - normal_expiry_keywords = [ - 'FAIL_SYS_TOKEN_EXOIRED::令牌过期', - 'FAIL_SYS_TOKEN_EXPIRED::令牌过期', - 'FAIL_SYS_TOKEN_EXOIRED', - 'FAIL_SYS_TOKEN_EXPIRED', - '令牌过期' - ] - - # 检查错误消息是否包含正常的令牌过期关键词 - for keyword in normal_expiry_keywords: - if keyword in error_message: - return True - - return False - - # 测试用例 - test_cases = [ - # 应该被过滤的消息(返回True) - ("Token刷新失败: {'ret': ['FAIL_SYS_TOKEN_EXOIRED::令牌过期']}", True, "标准令牌过期"), - ("Token刷新失败: {'ret': ['FAIL_SYS_TOKEN_EXPIRED::令牌过期']}", True, "标准令牌过期(EXPIRED)"), - ("Token刷新异常: FAIL_SYS_TOKEN_EXOIRED", True, "简单令牌过期"), - ("Token刷新异常: FAIL_SYS_TOKEN_EXPIRED", True, "简单令牌过期(EXPIRED)"), - ("Token刷新失败: 令牌过期", True, "中文令牌过期"), - ("其他错误信息包含FAIL_SYS_TOKEN_EXOIRED的情况", True, "包含关键词"), - - # 不应该被过滤的消息(返回False) - ("Token刷新失败: {'ret': ['FAIL_SYS_SESSION_EXPIRED::Session过期']}", False, "Session过期"), - ("Token刷新异常: 网络连接超时", False, "网络异常"), - ("Token刷新失败: Cookie无效", False, "Cookie问题"), - ("初始化时无法获取有效Token", False, "初始化失败"), - ("Token刷新失败: 未知错误", False, "未知错误"), - ("Token刷新失败: API调用失败", False, "API失败"), - ("", False, "空消息"), - ] - - print("📋 测试用例:") - print("-" * 50) - - passed = 0 - total = len(test_cases) - - for i, (message, expected, description) in enumerate(test_cases, 1): - result = _is_normal_token_expiry(message) - - if result == expected: - status = "✅ 通过" - passed += 1 - else: - status = "❌ 失败" - - filter_action = "过滤" if result else "不过滤" - expected_action = "过滤" if expected else "不过滤" - - print(f"{i:2d}. {status} {description}") - print(f" 消息: {message[:60]}{'...' if len(message) > 60 else ''}") - print(f" 结果: {filter_action} | 期望: {expected_action}") - print() - - print("=" * 50) - print(f"📊 测试结果: {passed}/{total} 通过") - - if passed == total: - print("🎉 所有测试通过!过滤逻辑工作正常") - return True - else: - print("⚠️ 部分测试失败,需要检查过滤逻辑") - return False - -def show_real_world_examples(): - """显示真实世界的例子""" - print("\n\n📋 真实场景示例") - print("=" * 50) - - print("🚫 以下情况将不再发送通知(被过滤):") - examples_filtered = [ - "Token刷新失败: {'api': 'mtop.taobao.idlemessage.pc.login.token', 'data': {}, 'ret': ['FAIL_SYS_TOKEN_EXOIRED::令牌过期'], 'v': '1.0'}", - "Token刷新异常: FAIL_SYS_TOKEN_EXPIRED", - "Token刷新失败: 令牌过期" - ] - - for i, example in enumerate(examples_filtered, 1): - print(f"{i}. {example}") - - print("\n✅ 以下情况仍会发送通知(不被过滤):") - examples_not_filtered = [ - "Token刷新失败: {'api': 'mtop.taobao.idlemessage.pc.login.token', 'data': {}, 'ret': ['FAIL_SYS_SESSION_EXPIRED::Session过期'], 'v': '1.0'}", - "Token刷新异常: 网络连接超时", - "初始化时无法获取有效Token" - ] - - for i, example in enumerate(examples_not_filtered, 1): - print(f"{i}. {example}") - - print("\n💡 设计理念:") - print("• 令牌过期是正常现象,系统会自动重试刷新") - print("• Session过期通常意味着Cookie过期,需要用户手动更新") - print("• 网络异常等其他错误也需要用户关注") - print("• 减少无用通知,提升用户体验") - -def show_implementation_details(): - """显示实现细节""" - print("\n\n🔧 实现细节") - print("=" * 50) - - print("📍 修改位置:") - print("• 文件: XianyuAutoAsync.py") - print("• 方法: send_token_refresh_notification()") - print("• 新增: _is_normal_token_expiry() 过滤方法") - - print("\n🔍 过滤关键词:") - keywords = [ - 'FAIL_SYS_TOKEN_EXOIRED::令牌过期', - 'FAIL_SYS_TOKEN_EXPIRED::令牌过期', - 'FAIL_SYS_TOKEN_EXOIRED', - 'FAIL_SYS_TOKEN_EXPIRED', - '令牌过期' - ] - - for keyword in keywords: - print(f"• {keyword}") - - print("\n⚡ 执行流程:") - print("1. 调用 send_token_refresh_notification()") - print("2. 检查 _is_normal_token_expiry(error_message)") - print("3. 如果是正常令牌过期,记录调试日志并返回") - print("4. 如果不是,继续原有的通知发送流程") - - print("\n📝 日志记录:") - print("• 被过滤的消息会记录调试日志") - print("• 格式: '检测到正常的令牌过期,跳过通知: {error_message}'") - print("• 便于问题排查和功能验证") - -if __name__ == "__main__": - try: - success = test_token_expiry_filter_logic() - show_real_world_examples() - show_implementation_details() - - print("\n" + "=" * 50) - if success: - print("🎊 令牌过期通知过滤功能测试完成!") - print("✅ 用户将不再收到正常令牌过期的通知") - else: - print("❌ 测试失败,需要检查实现") - - except Exception as e: - print(f"❌ 测试过程中发生错误: {e}") - import traceback - traceback.print_exc() diff --git a/test_status_display.html b/test_status_display.html deleted file mode 100644 index cee4dc2..0000000 --- a/test_status_display.html +++ /dev/null @@ -1,250 +0,0 @@ - - - - - - 状态显示测试 - - - - - -
-

账号状态显示测试

- -
-

修改前 vs 修改后对比

- -
-
-
修改前(带文字)
-
- - - - 启用 - -
- -
- - - - 禁用 - -
-
- -
-
修改后(仅图标)
-
- - - - -
- -
- - - - -
-
-
-
- -
-

表格中的效果预览

- - - - - - - - - - - - - - - - - - - - - - - - - - -
账号ID状态默认回复AI回复操作
测试账号001 -
- - - - -
-
启用AI启用 - -
测试账号002 -
- - - - -
-
禁用AI禁用 - -
-
- -
-

优势说明

-
-
-
✅ 修改后的优势
-
    -
  • ✓ 界面更简洁
  • -
  • ✓ 节省空间
  • -
  • ✓ 图标直观易懂
  • -
  • ✓ 视觉焦点更集中
  • -
  • ✓ 现代化设计风格
  • -
-
-
-
🎨 设计细节
-
    -
  • • 图标居中对齐
  • -
  • • 徽章尺寸优化
  • -
  • • 颜色保持一致
  • -
  • • 响应式设计
  • -
  • • 无障碍访问友好
  • -
-
-
-
- -
-
说明
-

- 状态栏现在只显示图标,不显示"启用"/"禁用"文字。 - 绿色勾号表示启用状态,红色叉号表示禁用状态。 - 鼠标悬停时可以显示提示信息。 -

-
-
- - - - - diff --git a/test_token_expiry_filter.py b/test_token_expiry_filter.py deleted file mode 100644 index ae6bb17..0000000 --- a/test_token_expiry_filter.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python3 -""" -测试令牌过期通知过滤功能 -验证正常的令牌过期不会发送通知 -""" - -import asyncio -import time -from unittest.mock import AsyncMock, patch, MagicMock -import sys -import os - -# 添加项目根目录到路径 -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -async def test_token_expiry_filter(): - """测试令牌过期通知过滤""" - print("🧪 测试令牌过期通知过滤功能") - print("=" * 60) - - # 动态导入 - try: - from XianyuAutoAsync import XianyuLive - print("✅ 成功导入 XianyuLive") - except Exception as e: - print(f"❌ 导入失败: {e}") - return - - # 创建测试实例 - test_cookies = "unb=test123; _m_h5_tk=test_token_123456789" - - try: - xianyu = XianyuLive(test_cookies, "test_account") - print("✅ XianyuLive 实例创建成功") - except Exception as e: - print(f"❌ 创建实例失败: {e}") - return - - # Mock外部依赖 - with patch('db_manager.db_manager') as mock_db: - # 配置数据库mock - mock_db.get_account_notifications.return_value = [ - { - 'enabled': True, - 'channel_type': 'qq', - 'channel_name': 'Test QQ', - 'channel_config': {'qq_number': '123456', 'api_url': 'http://test.com'} - } - ] - - # Mock QQ通知发送方法 - xianyu._send_qq_notification = AsyncMock() - - print("\n📋 测试用例设计") - print("-" * 40) - - # 测试用例:应该被过滤的错误消息(不发送通知) - filtered_messages = [ - "Token刷新失败: {'ret': ['FAIL_SYS_TOKEN_EXOIRED::令牌过期']}", - "Token刷新失败: {'ret': ['FAIL_SYS_TOKEN_EXPIRED::令牌过期']}", - "Token刷新异常: FAIL_SYS_TOKEN_EXOIRED", - "Token刷新异常: FAIL_SYS_TOKEN_EXPIRED", - "Token刷新失败: 令牌过期", - ] - - # 测试用例:不应该被过滤的错误消息(需要发送通知) - unfiltered_messages = [ - "Token刷新失败: {'ret': ['FAIL_SYS_SESSION_EXPIRED::Session过期']}", - "Token刷新异常: 网络连接超时", - "Token刷新失败: Cookie无效", - "初始化时无法获取有效Token", - "Token刷新失败: 未知错误" - ] - - print("🚫 应该被过滤的消息(不发送通知):") - for i, msg in enumerate(filtered_messages, 1): - print(f" {i}. {msg}") - - print("\n✅ 不应该被过滤的消息(需要发送通知):") - for i, msg in enumerate(unfiltered_messages, 1): - print(f" {i}. {msg}") - - print("\n" + "=" * 60) - print("🧪 开始测试") - - # 测试1: 验证过滤功能 - print("\n1️⃣ 测试令牌过期消息过滤...") - - filtered_count = 0 - for i, message in enumerate(filtered_messages, 1): - xianyu._send_qq_notification.reset_mock() - await xianyu.send_token_refresh_notification(message, f"test_filtered_{i}") - - if not xianyu._send_qq_notification.called: - print(f" ✅ 消息 {i} 被正确过滤") - filtered_count += 1 - else: - print(f" ❌ 消息 {i} 未被过滤(应该被过滤)") - - print(f"\n 📊 过滤结果: {filtered_count}/{len(filtered_messages)} 条消息被正确过滤") - - # 测试2: 验证非过滤消息正常发送 - print("\n2️⃣ 测试非令牌过期消息正常发送...") - - sent_count = 0 - for i, message in enumerate(unfiltered_messages, 1): - xianyu._send_qq_notification.reset_mock() - await xianyu.send_token_refresh_notification(message, f"test_unfiltered_{i}") - - if xianyu._send_qq_notification.called: - print(f" ✅ 消息 {i} 正常发送") - sent_count += 1 - else: - print(f" ❌ 消息 {i} 未发送(应该发送)") - - print(f"\n 📊 发送结果: {sent_count}/{len(unfiltered_messages)} 条消息正常发送") - - # 测试3: 验证过滤逻辑 - print("\n3️⃣ 测试过滤逻辑详情...") - - test_cases = [ - ("FAIL_SYS_TOKEN_EXOIRED::令牌过期", True), - ("FAIL_SYS_TOKEN_EXPIRED::令牌过期", True), - ("FAIL_SYS_TOKEN_EXOIRED", True), - ("FAIL_SYS_TOKEN_EXPIRED", True), - ("令牌过期", True), - ("FAIL_SYS_SESSION_EXPIRED::Session过期", False), - ("网络连接超时", False), - ("Cookie无效", False), - ] - - for message, should_be_filtered in test_cases: - is_filtered = xianyu._is_normal_token_expiry(message) - if is_filtered == should_be_filtered: - status = "✅ 正确" - else: - status = "❌ 错误" - - filter_status = "过滤" if is_filtered else "不过滤" - expected_status = "过滤" if should_be_filtered else "不过滤" - print(f" {status} '{message}' -> {filter_status} (期望: {expected_status})") - - # 总结 - print("\n" + "=" * 60) - print("📊 测试总结") - - total_filtered = len([msg for msg in filtered_messages if xianyu._is_normal_token_expiry(msg)]) - total_unfiltered = len([msg for msg in unfiltered_messages if not xianyu._is_normal_token_expiry(msg)]) - - print(f"✅ 令牌过期消息过滤: {total_filtered}/{len(filtered_messages)} 正确") - print(f"✅ 非令牌过期消息: {total_unfiltered}/{len(unfiltered_messages)} 正确") - - if total_filtered == len(filtered_messages) and total_unfiltered == len(unfiltered_messages): - print("🎉 所有测试通过!令牌过期通知过滤功能正常工作") - else: - print("⚠️ 部分测试失败,需要检查过滤逻辑") - -def show_filter_explanation(): - """显示过滤机制说明""" - print("\n\n📋 令牌过期通知过滤机制说明") - print("=" * 60) - - print("🎯 设计目标:") - print(" • 避免正常的令牌过期发送通知") - print(" • 令牌过期是正常现象,系统会自动重试") - print(" • 只有真正的异常才需要通知用户") - - print("\n🔍 过滤规则:") - print(" 以下关键词的错误消息将被过滤(不发送通知):") - print(" • FAIL_SYS_TOKEN_EXOIRED::令牌过期") - print(" • FAIL_SYS_TOKEN_EXPIRED::令牌过期") - print(" • FAIL_SYS_TOKEN_EXOIRED") - print(" • FAIL_SYS_TOKEN_EXPIRED") - print(" • 令牌过期") - - print("\n✅ 仍会发送通知的情况:") - print(" • FAIL_SYS_SESSION_EXPIRED::Session过期 (Cookie过期)") - print(" • 网络连接异常") - print(" • API调用失败") - print(" • 其他未知错误") - - print("\n💡 优势:") - print(" • 减少无用通知,避免用户困扰") - print(" • 保留重要异常通知,便于及时处理") - print(" • 提升用户体验,通知更有价值") - - print("\n🔧 实现方式:") - print(" • 在发送通知前检查错误消息") - print(" • 使用关键词匹配识别正常的令牌过期") - print(" • 记录调试日志,便于问题排查") - -if __name__ == "__main__": - try: - asyncio.run(test_token_expiry_filter()) - show_filter_explanation() - - print("\n" + "=" * 60) - print("🎊 令牌过期通知过滤测试完成!") - - except Exception as e: - print(f"❌ 测试过程中发生错误: {e}") - import traceback - traceback.print_exc() diff --git a/test_token_fix.py b/test_token_fix.py deleted file mode 100644 index 3c76623..0000000 --- a/test_token_fix.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -""" -测试token修复 -""" - -import requests -import json - -BASE_URL = "http://localhost:8080" - -def test_admin_api_access(): - """测试管理员API访问""" - print("🚀 测试管理员API访问") - print("=" * 50) - - # 1. 管理员登录 - print("1. 管理员登录...") - login_response = requests.post(f"{BASE_URL}/login", - json={'username': 'admin', 'password': 'admin123'}) - - if not login_response.json()['success']: - print("❌ 管理员登录失败") - return False - - token = login_response.json()['token'] - headers = {'Authorization': f'Bearer {token}'} - print(f"✅ 管理员登录成功,token: {token[:20]}...") - - # 2. 测试管理员API - apis = [ - ("获取用户列表", "GET", "/admin/users"), - ("获取系统统计", "GET", "/admin/stats"), - ("获取系统日志", "GET", "/admin/logs?lines=10") - ] - - for api_name, method, endpoint in apis: - print(f"2. 测试 {api_name}...") - - if method == "GET": - response = requests.get(f"{BASE_URL}{endpoint}", headers=headers) - else: - response = requests.post(f"{BASE_URL}{endpoint}", headers=headers) - - if response.status_code == 200: - print(f" ✅ {api_name} 成功 (200)") - - # 显示部分数据 - try: - data = response.json() - if endpoint == "/admin/users": - users = data.get('users', []) - print(f" 用户数量: {len(users)}") - elif endpoint == "/admin/stats": - print(f" 总用户数: {data.get('users', {}).get('total', 0)}") - print(f" 总Cookie数: {data.get('cookies', {}).get('total', 0)}") - elif endpoint.startswith("/admin/logs"): - logs = data.get('logs', []) - print(f" 日志条数: {len(logs)}") - except: - pass - - elif response.status_code == 401: - print(f" ❌ {api_name} 失败 - 401 未授权") - return False - elif response.status_code == 403: - print(f" ❌ {api_name} 失败 - 403 权限不足") - return False - else: - print(f" ❌ {api_name} 失败 - {response.status_code}") - return False - - print("\n✅ 所有管理员API测试通过!") - return True - -def test_non_admin_access(): - """测试非管理员访问""" - print("\n🔒 测试非管理员访问限制") - print("=" * 50) - - # 使用无效token测试 - fake_headers = {'Authorization': 'Bearer invalid_token'} - - response = requests.get(f"{BASE_URL}/admin/users", headers=fake_headers) - if response.status_code == 401: - print("✅ 无效token被正确拒绝 (401)") - else: - print(f"❌ 无效token未被拒绝 ({response.status_code})") - return False - - # 测试无token访问 - response = requests.get(f"{BASE_URL}/admin/users") - if response.status_code == 401: - print("✅ 无token访问被正确拒绝 (401)") - else: - print(f"❌ 无token访问未被拒绝 ({response.status_code})") - return False - - return True - -def main(): - """主测试函数""" - print("🔧 Token修复验证测试") - print("=" * 60) - - print("📋 测试目标:") - print("• 验证管理员可以正常访问API") - print("• 验证token认证正常工作") - print("• 验证非管理员访问被拒绝") - - try: - # 测试管理员API访问 - admin_success = test_admin_api_access() - - # 测试非管理员访问限制 - security_success = test_non_admin_access() - - print("\n" + "=" * 60) - - if admin_success and security_success: - print("🎉 Token修复验证成功!") - - print("\n💡 现在可以正常使用:") - print("1. 使用admin账号登录主页") - print("2. 点击侧边栏的'用户管理'") - print("3. 点击侧边栏的'系统日志'") - print("4. 所有管理员功能都应该正常工作") - - print("\n🔑 Token存储统一:") - print("• 登录页面: 设置 'auth_token'") - print("• 主页面: 读取 'auth_token'") - print("• 管理员页面: 读取 'auth_token'") - print("• 所有页面现在使用统一的token key") - - return True - else: - print("❌ Token修复验证失败!") - return False - - except Exception as e: - print(f"💥 测试异常: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - main() diff --git a/test_user_isolation_complete.py b/test_user_isolation_complete.py deleted file mode 100644 index a5b3e98..0000000 --- a/test_user_isolation_complete.py +++ /dev/null @@ -1,333 +0,0 @@ -#!/usr/bin/env python3 -""" -完整的多用户数据隔离测试 -""" - -import requests -import json -import sqlite3 -import time - -BASE_URL = "http://localhost:8080" - -def create_test_users(): - """创建测试用户""" - print("🧪 创建测试用户") - print("-" * 40) - - users = [ - {"username": "testuser1", "email": "user1@test.com", "password": "test123456"}, - {"username": "testuser2", "email": "user2@test.com", "password": "test123456"} - ] - - created_users = [] - - for user in users: - try: - # 清理可能存在的用户 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('DELETE FROM users WHERE username = ? OR email = ?', (user['username'], user['email'])) - cursor.execute('DELETE FROM email_verifications WHERE email = ?', (user['email'],)) - conn.commit() - conn.close() - - # 生成验证码 - session_id = f"test_{user['username']}_{int(time.time())}" - - # 生成图形验证码 - captcha_response = requests.post(f"{BASE_URL}/generate-captcha", - json={'session_id': session_id}) - if not captcha_response.json()['success']: - print(f" ❌ {user['username']}: 图形验证码生成失败") - continue - - # 获取图形验证码 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM captcha_codes WHERE session_id = ? ORDER BY created_at DESC LIMIT 1', - (session_id,)) - captcha_result = cursor.fetchone() - conn.close() - - if not captcha_result: - print(f" ❌ {user['username']}: 无法获取图形验证码") - continue - - captcha_code = captcha_result[0] - - # 验证图形验证码 - verify_response = requests.post(f"{BASE_URL}/verify-captcha", - json={'session_id': session_id, 'captcha_code': captcha_code}) - if not verify_response.json()['success']: - print(f" ❌ {user['username']}: 图形验证码验证失败") - continue - - # 发送邮箱验证码 - email_response = requests.post(f"{BASE_URL}/send-verification-code", - json={'email': user['email'], 'session_id': session_id}) - if not email_response.json()['success']: - print(f" ❌ {user['username']}: 邮箱验证码发送失败") - continue - - # 获取邮箱验证码 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM email_verifications WHERE email = ? ORDER BY created_at DESC LIMIT 1', - (user['email'],)) - email_result = cursor.fetchone() - conn.close() - - if not email_result: - print(f" ❌ {user['username']}: 无法获取邮箱验证码") - continue - - email_code = email_result[0] - - # 注册用户 - register_response = requests.post(f"{BASE_URL}/register", - json={ - 'username': user['username'], - 'email': user['email'], - 'verification_code': email_code, - 'password': user['password'] - }) - - if register_response.json()['success']: - print(f" ✅ {user['username']}: 注册成功") - - # 登录获取token - login_response = requests.post(f"{BASE_URL}/login", - json={'username': user['username'], 'password': user['password']}) - - if login_response.json()['success']: - token = login_response.json()['token'] - user_id = login_response.json()['user_id'] - created_users.append({ - 'username': user['username'], - 'user_id': user_id, - 'token': token, - 'headers': {'Authorization': f'Bearer {token}'} - }) - print(f" ✅ {user['username']}: 登录成功,用户ID: {user_id}") - else: - print(f" ❌ {user['username']}: 登录失败") - else: - print(f" ❌ {user['username']}: 注册失败 - {register_response.json()['message']}") - - except Exception as e: - print(f" ❌ {user['username']}: 创建失败 - {e}") - - return created_users - -def test_cookie_isolation(users): - """测试Cookie数据隔离""" - print("\n🧪 测试Cookie数据隔离") - print("-" * 40) - - if len(users) < 2: - print("❌ 需要至少2个用户进行隔离测试") - return False - - user1, user2 = users[0], users[1] - - # 用户1添加cookies - print(f"1️⃣ {user1['username']} 添加cookies...") - cookies1 = [ - {"id": "test_cookie_user1_1", "value": "cookie_value_1"}, - {"id": "test_cookie_user1_2", "value": "cookie_value_2"} - ] - - for cookie in cookies1: - response = requests.post(f"{BASE_URL}/cookies", - json=cookie, - headers=user1['headers']) - if response.status_code == 200: - print(f" ✅ 添加cookie: {cookie['id']}") - else: - print(f" ❌ 添加cookie失败: {cookie['id']}") - - # 用户2添加cookies - print(f"\n2️⃣ {user2['username']} 添加cookies...") - cookies2 = [ - {"id": "test_cookie_user2_1", "value": "cookie_value_3"}, - {"id": "test_cookie_user2_2", "value": "cookie_value_4"} - ] - - for cookie in cookies2: - response = requests.post(f"{BASE_URL}/cookies", - json=cookie, - headers=user2['headers']) - if response.status_code == 200: - print(f" ✅ 添加cookie: {cookie['id']}") - else: - print(f" ❌ 添加cookie失败: {cookie['id']}") - - # 验证用户1只能看到自己的cookies - print(f"\n3️⃣ 验证 {user1['username']} 的cookie隔离...") - response1 = requests.get(f"{BASE_URL}/cookies", headers=user1['headers']) - if response1.status_code == 200: - user1_cookies = response1.json() - user1_cookie_ids = set(user1_cookies) - expected_user1 = {"test_cookie_user1_1", "test_cookie_user1_2"} - - if expected_user1.issubset(user1_cookie_ids): - print(f" ✅ {user1['username']} 能看到自己的cookies") - else: - print(f" ❌ {user1['username']} 看不到自己的cookies") - - if "test_cookie_user2_1" not in user1_cookie_ids and "test_cookie_user2_2" not in user1_cookie_ids: - print(f" ✅ {user1['username']} 看不到其他用户的cookies") - else: - print(f" ❌ {user1['username']} 能看到其他用户的cookies(隔离失败)") - - # 验证用户2只能看到自己的cookies - print(f"\n4️⃣ 验证 {user2['username']} 的cookie隔离...") - response2 = requests.get(f"{BASE_URL}/cookies", headers=user2['headers']) - if response2.status_code == 200: - user2_cookies = response2.json() - user2_cookie_ids = set(user2_cookies) - expected_user2 = {"test_cookie_user2_1", "test_cookie_user2_2"} - - if expected_user2.issubset(user2_cookie_ids): - print(f" ✅ {user2['username']} 能看到自己的cookies") - else: - print(f" ❌ {user2['username']} 看不到自己的cookies") - - if "test_cookie_user1_1" not in user2_cookie_ids and "test_cookie_user1_2" not in user2_cookie_ids: - print(f" ✅ {user2['username']} 看不到其他用户的cookies") - else: - print(f" ❌ {user2['username']} 能看到其他用户的cookies(隔离失败)") - - return True - -def test_cross_user_access(users): - """测试跨用户访问权限""" - print("\n🧪 测试跨用户访问权限") - print("-" * 40) - - if len(users) < 2: - print("❌ 需要至少2个用户进行权限测试") - return False - - user1, user2 = users[0], users[1] - - # 用户1尝试访问用户2的cookie - print(f"1️⃣ {user1['username']} 尝试访问 {user2['username']} 的cookie...") - - # 尝试获取用户2的关键字 - response = requests.get(f"{BASE_URL}/keywords/test_cookie_user2_1", headers=user1['headers']) - if response.status_code == 403: - print(f" ✅ 跨用户访问被正确拒绝 (403)") - elif response.status_code == 404: - print(f" ✅ 跨用户访问被拒绝 (404)") - else: - print(f" ❌ 跨用户访问未被拒绝 (状态码: {response.status_code})") - - # 尝试更新用户2的cookie状态 - response = requests.put(f"{BASE_URL}/cookies/test_cookie_user2_1/status", - json={"enabled": False}, - headers=user1['headers']) - if response.status_code == 403: - print(f" ✅ 跨用户操作被正确拒绝 (403)") - elif response.status_code == 404: - print(f" ✅ 跨用户操作被拒绝 (404)") - else: - print(f" ❌ 跨用户操作未被拒绝 (状态码: {response.status_code})") - - return True - -def cleanup_test_data(users): - """清理测试数据""" - print("\n🧹 清理测试数据") - print("-" * 40) - - try: - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - # 清理测试cookies - cursor.execute('DELETE FROM cookies WHERE id LIKE "test_cookie_%"') - cookie_count = cursor.rowcount - - # 清理测试用户 - test_usernames = [user['username'] for user in users] - if test_usernames: - placeholders = ','.join(['?' for _ in test_usernames]) - cursor.execute(f'DELETE FROM users WHERE username IN ({placeholders})', test_usernames) - user_count = cursor.rowcount - else: - user_count = 0 - - # 清理测试验证码 - cursor.execute('DELETE FROM email_verifications WHERE email LIKE "%@test.com"') - email_count = cursor.rowcount - - cursor.execute('DELETE FROM captcha_codes WHERE session_id LIKE "test_%"') - captcha_count = cursor.rowcount - - conn.commit() - conn.close() - - print(f"✅ 清理完成:") - print(f" • 测试cookies: {cookie_count} 条") - print(f" • 测试用户: {user_count} 条") - print(f" • 邮箱验证码: {email_count} 条") - print(f" • 图形验证码: {captcha_count} 条") - - except Exception as e: - print(f"❌ 清理失败: {e}") - -def main(): - """主测试函数""" - print("🚀 多用户数据隔离完整测试") - print("=" * 60) - - try: - # 创建测试用户 - users = create_test_users() - - if len(users) < 2: - print("\n❌ 测试失败:无法创建足够的测试用户") - return False - - print(f"\n✅ 成功创建 {len(users)} 个测试用户") - - # 测试Cookie数据隔离 - cookie_isolation_success = test_cookie_isolation(users) - - # 测试跨用户访问权限 - cross_access_success = test_cross_user_access(users) - - # 清理测试数据 - cleanup_test_data(users) - - print("\n" + "=" * 60) - if cookie_isolation_success and cross_access_success: - print("🎉 多用户数据隔离测试全部通过!") - - print("\n📋 测试总结:") - print("✅ 用户注册和登录功能正常") - print("✅ Cookie数据完全隔离") - print("✅ 跨用户访问被正确拒绝") - print("✅ 用户权限验证正常") - - print("\n🔒 安全特性:") - print("• 每个用户只能看到自己的数据") - print("• 跨用户访问被严格禁止") - print("• API层面权限验证完整") - print("• 数据库层面用户绑定正确") - - return True - else: - print("❌ 多用户数据隔离测试失败!") - return False - - except Exception as e: - print(f"\n💥 测试异常: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - main() diff --git a/test_user_logging.py b/test_user_logging.py deleted file mode 100644 index cd030c3..0000000 --- a/test_user_logging.py +++ /dev/null @@ -1,318 +0,0 @@ -#!/usr/bin/env python3 -""" -测试用户日志显示功能 -""" - -import requests -import json -import time -import sqlite3 -from loguru import logger - -BASE_URL = "http://localhost:8080" - -def create_test_user(): - """创建测试用户""" - logger.info("创建测试用户...") - - user_data = { - "username": "logtest_user", - "email": "logtest@test.com", - "password": "test123456" - } - - try: - # 清理可能存在的用户 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('DELETE FROM users WHERE username = ? OR email = ?', (user_data['username'], user_data['email'])) - cursor.execute('DELETE FROM email_verifications WHERE email = ?', (user_data['email'],)) - conn.commit() - conn.close() - - # 生成验证码 - session_id = f"logtest_{int(time.time())}" - - # 生成图形验证码 - captcha_response = requests.post(f"{BASE_URL}/generate-captcha", - json={'session_id': session_id}) - if not captcha_response.json()['success']: - logger.error("图形验证码生成失败") - return None - - # 获取图形验证码 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM captcha_codes WHERE session_id = ? ORDER BY created_at DESC LIMIT 1', - (session_id,)) - captcha_result = cursor.fetchone() - conn.close() - - if not captcha_result: - logger.error("无法获取图形验证码") - return None - - captcha_code = captcha_result[0] - - # 验证图形验证码 - verify_response = requests.post(f"{BASE_URL}/verify-captcha", - json={'session_id': session_id, 'captcha_code': captcha_code}) - if not verify_response.json()['success']: - logger.error("图形验证码验证失败") - return None - - # 发送邮箱验证码 - email_response = requests.post(f"{BASE_URL}/send-verification-code", - json={'email': user_data['email'], 'session_id': session_id}) - if not email_response.json()['success']: - logger.error("邮箱验证码发送失败") - return None - - # 获取邮箱验证码 - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - cursor.execute('SELECT code FROM email_verifications WHERE email = ? ORDER BY created_at DESC LIMIT 1', - (user_data['email'],)) - email_result = cursor.fetchone() - conn.close() - - if not email_result: - logger.error("无法获取邮箱验证码") - return None - - email_code = email_result[0] - - # 注册用户 - register_response = requests.post(f"{BASE_URL}/register", - json={ - 'username': user_data['username'], - 'email': user_data['email'], - 'verification_code': email_code, - 'password': user_data['password'] - }) - - if register_response.json()['success']: - logger.info(f"用户注册成功: {user_data['username']}") - - # 登录获取token - login_response = requests.post(f"{BASE_URL}/login", - json={'username': user_data['username'], 'password': user_data['password']}) - - if login_response.json()['success']: - token = login_response.json()['token'] - user_id = login_response.json()['user_id'] - return { - 'username': user_data['username'], - 'user_id': user_id, - 'token': token, - 'headers': {'Authorization': f'Bearer {token}'} - } - else: - logger.error("用户登录失败") - return None - else: - logger.error(f"用户注册失败: {register_response.json()['message']}") - return None - - except Exception as e: - logger.error(f"创建用户失败: {e}") - return None - -def test_user_operations(user): - """测试用户操作的日志显示""" - logger.info("测试用户操作的日志显示...") - - print(f"\n🧪 开始测试用户 {user['username']} 的操作日志") - print("请观察服务器日志,应该显示用户信息...") - print("-" * 50) - - # 1. 测试Cookie操作 - print("1️⃣ 测试Cookie操作...") - cookie_data = { - "id": "logtest_cookie", - "value": "test_cookie_value" - } - - response = requests.post(f"{BASE_URL}/cookies", json=cookie_data, headers=user['headers']) - if response.status_code == 200: - print(" ✅ Cookie添加成功") - else: - print(f" ❌ Cookie添加失败: {response.text}") - - # 2. 测试获取Cookie列表 - print("2️⃣ 测试获取Cookie列表...") - response = requests.get(f"{BASE_URL}/cookies", headers=user['headers']) - if response.status_code == 200: - print(" ✅ 获取Cookie列表成功") - else: - print(f" ❌ 获取Cookie列表失败: {response.text}") - - # 3. 测试关键字操作 - print("3️⃣ 测试关键字操作...") - keywords_data = { - "keywords": { - "你好": "您好,欢迎咨询!", - "价格": "价格请看商品详情" - } - } - - response = requests.post(f"{BASE_URL}/keywords/logtest_cookie", - json=keywords_data, headers=user['headers']) - if response.status_code == 200: - print(" ✅ 关键字更新成功") - else: - print(f" ❌ 关键字更新失败: {response.text}") - - # 4. 测试卡券操作 - print("4️⃣ 测试卡券操作...") - card_data = { - "name": "测试卡券", - "type": "text", - "text_content": "这是一个测试卡券", - "description": "用于测试日志显示的卡券" - } - - response = requests.post(f"{BASE_URL}/cards", json=card_data, headers=user['headers']) - if response.status_code == 200: - print(" ✅ 卡券创建成功") - else: - print(f" ❌ 卡券创建失败: {response.text}") - - # 5. 测试用户设置 - print("5️⃣ 测试用户设置...") - setting_data = { - "value": "#ff6600", - "description": "测试主题颜色" - } - - response = requests.put(f"{BASE_URL}/user-settings/theme_color", - json=setting_data, headers=user['headers']) - if response.status_code == 200: - print(" ✅ 用户设置更新成功") - else: - print(f" ❌ 用户设置更新失败: {response.text}") - - # 6. 测试权限验证(尝试访问不存在的Cookie) - print("6️⃣ 测试权限验证...") - response = requests.get(f"{BASE_URL}/keywords/nonexistent_cookie", headers=user['headers']) - if response.status_code == 403: - print(" ✅ 权限验证正常(403错误)") - else: - print(f" ⚠️ 权限验证结果: {response.status_code}") - - print("-" * 50) - print("🎯 操作测试完成,请检查服务器日志中的用户信息显示") - -def test_admin_operations(): - """测试管理员操作""" - print("\n🔧 测试管理员操作...") - - # 管理员登录 - admin_login = requests.post(f"{BASE_URL}/login", - json={'username': 'admin', 'password': 'admin123'}) - - if admin_login.json()['success']: - admin_token = admin_login.json()['token'] - admin_headers = {'Authorization': f'Bearer {admin_token}'} - - print(" ✅ 管理员登录成功") - - # 测试管理员获取Cookie列表 - response = requests.get(f"{BASE_URL}/cookies", headers=admin_headers) - if response.status_code == 200: - print(" ✅ 管理员获取Cookie列表成功") - else: - print(f" ❌ 管理员获取Cookie列表失败: {response.text}") - else: - print(" ❌ 管理员登录失败") - -def cleanup_test_data(user): - """清理测试数据""" - logger.info("清理测试数据...") - - try: - conn = sqlite3.connect('xianyu_data.db') - cursor = conn.cursor() - - # 清理测试用户 - cursor.execute('DELETE FROM users WHERE username = ?', (user['username'],)) - user_count = cursor.rowcount - - # 清理测试Cookie - cursor.execute('DELETE FROM cookies WHERE id = "logtest_cookie"') - cookie_count = cursor.rowcount - - # 清理测试卡券 - cursor.execute('DELETE FROM cards WHERE name = "测试卡券"') - card_count = cursor.rowcount - - # 清理测试验证码 - cursor.execute('DELETE FROM email_verifications WHERE email = "logtest@test.com"') - email_count = cursor.rowcount - - cursor.execute('DELETE FROM captcha_codes WHERE session_id LIKE "logtest_%"') - captcha_count = cursor.rowcount - - conn.commit() - conn.close() - - logger.info(f"清理完成: 用户{user_count}个, Cookie{cookie_count}个, 卡券{card_count}个, 邮箱验证码{email_count}个, 图形验证码{captcha_count}个") - - except Exception as e: - logger.error(f"清理失败: {e}") - -def main(): - """主测试函数""" - print("🚀 用户日志显示功能测试") - print("=" * 60) - - print("📋 测试目标:") - print("• 验证API请求日志显示用户信息") - print("• 验证业务操作日志显示用户信息") - print("• 验证权限验证日志显示用户信息") - print("• 验证管理员操作日志显示") - - try: - # 创建测试用户 - user = create_test_user() - - if not user: - print("❌ 测试失败:无法创建测试用户") - return False - - print(f"✅ 成功创建测试用户: {user['username']}") - - # 测试用户操作 - test_user_operations(user) - - # 测试管理员操作 - test_admin_operations() - - # 清理测试数据 - cleanup_test_data(user) - - print("\n" + "=" * 60) - print("🎉 用户日志显示功能测试完成!") - - print("\n📋 检查要点:") - print("✅ 1. API请求日志应显示: 【用户名#用户ID】") - print("✅ 2. 业务操作日志应显示用户信息") - print("✅ 3. 权限验证日志应显示操作用户") - print("✅ 4. 管理员操作应显示: 【admin#1】") - - print("\n💡 日志格式示例:") - print("🌐 【logtest_user#2】 API请求: POST /cookies") - print("✅ 【logtest_user#2】 API响应: POST /cookies - 200 (0.005s)") - print("📝 【logtest_user#2】 尝试添加Cookie: logtest_cookie") - print("✅ 【logtest_user#2】 Cookie添加成功: logtest_cookie") - - return True - - except Exception as e: - print(f"💥 测试异常: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - main() diff --git a/utils/xianyu_utils.py b/utils/xianyu_utils.py index 5e117e3..0204150 100644 --- a/utils/xianyu_utils.py +++ b/utils/xianyu_utils.py @@ -297,26 +297,47 @@ class MessagePackDecoder: def decrypt(data: str) -> str: """解密消息数据""" + import json as json_module # 使用别名避免作用域冲突 + try: + # 确保输入数据是字符串类型 + if not isinstance(data, str): + data = str(data) + + # 清理数据,移除可能的非ASCII字符 + try: + # 尝试编码为ASCII,如果失败则使用UTF-8编码后再解码 + data.encode('ascii') + except UnicodeEncodeError: + # 如果包含非ASCII字符,先编码为UTF-8字节,再解码为ASCII兼容的字符串 + data = data.encode('utf-8', errors='ignore').decode('ascii', errors='ignore') + # Base64解码 - decoded_data = base64.b64decode(data) - + try: + decoded_data = base64.b64decode(data) + except Exception as decode_error: + # 如果base64解码失败,尝试添加填充 + missing_padding = len(data) % 4 + if missing_padding: + data += '=' * (4 - missing_padding) + decoded_data = base64.b64decode(data) + # 使用MessagePack解码器解码数据 decoder = MessagePackDecoder(decoded_data) decoded_value = decoder.decode() - + # 如果解码后的值是字典,转换为JSON字符串 if isinstance(decoded_value, dict): def json_serializer(obj): if isinstance(obj, bytes): return obj.decode('utf-8', errors='ignore') raise TypeError(f"Type {type(obj)} not serializable") - - return json.dumps(decoded_value, default=json_serializer) - + + return json_module.dumps(decoded_value, default=json_serializer, ensure_ascii=False) + # 如果是其他类型,尝试转换为字符串 return str(decoded_value) - + except Exception as e: raise Exception(f"解密失败: {str(e)}") diff --git a/wechat-group.png b/wechat-group.png new file mode 100644 index 0000000000000000000000000000000000000000..12a29905cf64444688b78b1830c615f20653d826 GIT binary patch literal 170848 zcmeFZ2Ut^2+dg;@BuEu0(j$U`6j8be1Vow$h#(z;BGRNuuK^KIdJ$AwsDjc&dIu>H zl-{HhdMBX<2+1Db@_oPm?ziuLyVves|J~Pc4G_+db7tnAXYS{Io*CjV;w*6fo{G8( zKtcilB;Xf7oB)&na7g<=@u3Y8g zza}6qAt@z&OGfFgvWlvj`aNB}NBRbaM#h#;t)5xi*xI?exqEnedHV#v3JDE+{U$s< z;ca5lyZ0ZGv$At?Kj!6s`uw%5yrQzIx~BG9Yg>B<>U(F`z~IpE$mp-JarE5$!s62M z%IX?+XLoP^;1G9o{JUKw0O{YG1%Cf~!~R$8q6gb`hK!7q4D!2OBxgLqLP}3|mis38 z1w|dmV;6?YqCpglcj7WjTh8%_J;X3QdC^bF%qxz*g8kjJzqRbY*RYrWms<884g1e_ zjRVxAB;eqY(gQHy_+vPK#+g6mPapijz#k0!!N4C3{K3E<4E({s9}N7#z#k0!!NC8! z7|4guye4m3(q7bey0T+oy(O6JA*6Bh2iG16?>k-f!^z~=3+cA}8IXrM$~Vt4>L~oH z0?g;i$_8hCd`J){@fm#(FUj?IvT?tHF+zY`Db_xzi1V_C5xyQ@q`>Z`qs$%hfADGl z>Q4V(zQ!m~EDWi$+>;14NnXbL@SV&bv9)HLdHaC>GOoPdkB6PC#dfo-@*)n0Urbw{h-X>I6bXjQ zY`Jr}!DqBj)`Enr3C4V<`=(>DTr;`1IO#aoZiNok40!j?Z|5zmFR7@~BGX)Nx8669 z5E3G}lwAA-DWH1iYbMMeCIgqeEbTTwU31_NxI2;^!JC@SzjRe5jU`zOZ+kSrnhoth zrdho0of7Y^hTY6{Da*XT4HL}d_4uU7yH_0W6Oo(8!4N*JyX!1a%)N(Z%z3KP=1*hy zYYfO-;5cB)0Ca-x(>i@mA$Y?UY<8{^fpqO7wpB5WqX!U)1q~t~i`)_SzCZ+e`S4M0 zHQC}M-T!>Yb+>7^lL+vbT?et|Du4g)@6;?~-J?)wGkM$M@OfGiMv@;xBM9sv-N`zB zZ7JLO%f;6&dKe_MnGyImEF@_~L;#gV1R7y+#V~W}aIV|Q8xoBU z4A9*^1dplqn+3OUYPvab@(yR3$XPu{snfx3#k12|oM8ib7DT|C@k!b9Jm2T?lD%EF zkrPtTx%{yN``ePOrutRIlbSx4%k^)2^ZMv(sdIg#x}J1p&As0)DcwFVIsM5gIz92l z^xU-5{=))!mq7a;;hpY+_h0wFvtVto*U>kVu0XwK6j4n7_^v`VS14JsoGUL&1DWW$ zt2=+zOQOwYnkRi{#YwT=t+TkI_;5DTaeKc&_x{dJX#>TbazpBecWghObaty0n-r18sIPSx<>Fl*m0&eqaAyj)WtxXFh7U4mlwGY`65$GxS z$-Q4;fHss`@JS$49Ty%W{w(&}tYb)6X>q{!CriJvHiC~jsXS(DDZf=0e7a9?YjYd= zH(ioRa&pDrk|-IolxrWafY9S01~zTC`Flb6``_5x6^#Y`XQrJNbp?OX`lwxi zCrbf?52n7kRErKdupRJ1i-aE3afrprSA?tHwc{P`*DxCn$syBZ ziY)5zSN34*N#yLV&3BDF{4AU?E_B7R(@(GGqCArHO|}+CgO=Uxy-L;S2PdYB3Ub#D zc2!;M=9W}@HZpqeJuCWnCx>?-?15TKk+5KEgm9{XkeWfqWb%)(^pN|Sya~ZWF&cx* zQY*78b8>=xMZz9;r6kptlNTMXbB!Evf6&O6m2h{NQkj*+2DLEdFn!n^OSIMV6uu02 zeTruCUn{M9^KM~X)5tL0-k$54Pcsqt*h<)UH}=H)mGq5X%iHy)8>wx$O^`QOu~++0 z`FtsIkYp~5^8HLrOamo39@pJ4DN$a!7foG9@j&*bIF6zUsfVc|`ujJVH9i3MD6r00Tl`yP_Ajul<|$EKf@|zZ^x*IV~!D=(+fP zy_^21@JP5_nU!k=;_4f4pgg zm=AX!9vT&eRKKkw0s>Zm=kb+r!|_0h>-f?lj!{yp61d6vb4HfXZ<7HP{i}_aD(fv` z-sck5J&$!soy4DIO|7cTtDWP8T`R1E|W1rSmq=Cz%s2Efl5YtkL6vhX)Vzznh@vD>sMw?rdy$L3ct-#c)C-EHLp2P%h( zuAttzu;T4Hlf?$~U{QWERxU3}b|%&2UyTF~SMaBLOFgt~)U>vck9hStS1WNgN&e>W zY{#lm6Xiq&F4!+RLg=#Dbp3<*$6LOfu2nND1mViWM#D5o>*<>Jjojh__YcA$C&NnS z*xbZj6Y(omyahOm@K<7^=UM0d_LN3a;fG4B2NFMi;i@!~SOQZ85iCX0?myH`&N-*3 zDjM(cm?W1)r!#40m)97%8=YcUyQx7{>QS_&$)G6E!3P6h-x;oOAZxTAzD98yTtGkQro6;1Q3i z>9icZ8vMOEkKH>4U*;qGA%@GOkMySU?cY4KX;C#!3cri!3S^JUW_;hPMFZQ|wLUc= z=w(ft5`hAzu0V0|ZpWqXS+_)6R(!%YHjA0(rmjXVNwP96H-eSP?NCjl3m7sUmEaRQD7ba?T??jfi6ui5hzb} zxcVV0WchFau_6dpuy4XHA-7`|>{}2vMKfbElKn(LIEM)I=_k<7>W`ejFR=}1=w52h zGZFjJa;99Er{&6L9sO%pK7VFNO)OxZ5%PAM%*(ZrtG1!-yOqD0J>{sQa&JFN=}X*d zzuDcN1$`I%Kff-G5?YA8ihoLw$mN_FTevanx1u_)Rd&|FTngSuE0}(t_6Y@x3ai(o zHTN%Jmwhp;%QYMR4IdEC}X{(S{uq! zv`?05DQ_x=xk3o^G^8wqt_svixhk5D?&@Z;W=8J`Q$zT*kREqD1G+NyR)3KgVN)Hn zJ)uEFKp_QwvQ(9y+ZE!P&P9p&brc$9jXdNh0f4F$JMbDAeB=bi4F?EStNm4~ywJ6L?dms67BnQrV_{Zge(JEM_sps}dG<`u?` zGu!eXqn3vTEOdgD1I;$qRSx!ZvLb-GiIJ$yb@gtJev6B~KFLy5pK?$~bCHO2{x6hv zCL+H{9~R%P{BR63M$qk;C4SP6S>e~H`Ln`-f5*}@3Y+hTf%3`-pWVb?ez#FnW_&>M zFUpyI_1{X@-;1z@6%l|sBN}1v^FRMbCj^Bn||F$g5ay6UK0}8U~a2%Ehu${F`%c_NLtizAM-peM4Bdm=AN~D#vu0g~43z#-6>Q%>pZ6kZ z`rn%V&$_wH%-K0gyW*zQf?nnxZFVl}<5io&Bh!}(4(pJ{$D8#qA;KdC1PJG}sMW zTjc7wL5GL*4abb%Zu1Nf!!~}v5P{a3_pPFDUM7T9KW^shXN=c=j=TpPM~8pMguZU% zz>K8j2>%>~KYgt-cz-cjWlf!LC;|1j-OB*w^cs2Ik7;f!G-c5Q=kxH5t$5N3dg8~! zdA{_VK1lytgU;i_Hh2^pdAx+?labqIvWaJ`%OhAF({3$Q70C;F9jfAVUFM=3M|zBk zoi2s9GdV#tnf<|f<8{**zNlV9>Sv)=X0(zsX zIk687ej>_|A%4nPxa1jBvShBWaJ^7)Sm&3=&EqoO4l|RnuF1ghi|`#fO1}s7Smd`T z>xX@t6NC#5GBb4cj4GM~^NUZ-J;__<^A~)P!bBh>42vQJ%H$C!kVN2h23?^|NkS9) zG2Vp;WYB&h0x$W9z?^_!$r}XQrtrt=+WvhCVYCu&d}o&EXMR?pwvPa5ye8o9pzMX( zn3~`8MS0ZjrC&~GP3}h<)>rGxlGCNmmrOBj>;aEF8_P9H%H)QqTq2KD6rfzy>xPK) zvX4J{L0b*jI*N(FWxv>OIgoUv{)>}@K!))~PB=@N>KqH!AiNM!DdutzTDDuC^W49! zXXNN2Y2*uX8XzE82QCHzNEb1bMg$UJgh5rjgw(0}%#kh;*c2z(YiBwef*Rj&DE8Rc z%{G!;mi`W5{j>sPeX=*sO^?W#vNKMdR!%H@W|T8-Vi%nUTtJYRBZvUi1R8ZpjX2_Q zB?28Qr2Si4n;Ubxj?3W`UoZLo~#v;WKEqwphxgLqta2ChkA zpd3lLM&o_HTI!%(P$Yob9Sgl|#4vK}M4_^{Ef31$zuC?cX?{)Dgu)6(3 zMNX9G68cVL@OYs7o2>d?!o{jq#%)9#q|4exX4DC z>Q3a-#Xed>ImuYRvX@jlGWs2X99uCR;*>Q$)lThFouvLjeKM$)WLtr2SJ@9#q-m5t zBl;>pA0tSFz%+*E!Oi>6H?g*6nXZgfx^yzef3W=U9-l4U!bq|?I$BwI-g|WPhKW+D zcY+$d>uQ2lS#4EAmC?p>_#Ao9*k$K6XJ=;)%dh zGl_jVPJ5r>mR+v#L0c!ID}Kv6D){vk?FcUDh@bXs%BEOrZS3H-6-Pqz2j33E7a7Mf zd3S6`zhpI<;7qWd#u)SRMmk)ClUx6{If^BB5xJTbl8*at2Faeze^fc^uSTo#+cz`U zz@p|BkwI=p;+w+rHCW!&S23R_&cH@sO<9NAs0VZg(T0oHFUN6YJ2({54zBpqsJeSQ zJY$@iR013syU?zC-ClGXKW633$L(WSaiL2)x7-19Dpx^~bdH5?euB{QB4h}8FZclD z$FU-zu=ORU)3Y|DEBQIef%|=NlG$o>Pug zHZ>+1sz_VZUn2ru;b?avFb;)}Zo|qNpR;eA1wjzG7%sT=tp4UaV7Wq5oqQUsfl7{y4xp80o^FpiACyDQNa~TfWH_vcTk2v zZ-*Xl7363i_@3B2%6j*tallN)xjDE+PT3DTO9Ytx+^|U>u4kg_W-5)^Zw4*w6k4{1 z==0EK>{%_dECtF}Wi^7hkBlY)u(5JPhbjWOF%BHtg&zsFPYdAI(KN#DPp4gqL^6@Jq4zZ&iT z$$lsODKGAI(#CbppV!Ox4%?Ty3m>%K@f`VjSZ=xX%#RKde(VAVh5QLuQu0MhfA67hM~9KIDJ~H95p!K>nxn;@G^<|Q3{_6hu_CW z-O+$6tH~uOb|`B@+U&rl@#Qa%{RS?`x=qD2C5+|*w)ls*?71c;FBLNpQw-|BAw`2d zCFZJC3*?ej^->95W~A{FE(X1r7b?qba{$qjcQg3Dc`jXa$B}(vQMP*4$5#)@8X#$7 zehYKIRx%}T&Et?p{6$!r<++esJfVDhCHHYIb3b5Y0X?42nh2?ZD@8Y^S(DS6B>X;^L=-WHJty@)-u9o&RP1k}F9qrt9u$Mi%4PI{H zI$O~%!Fl^(`rNgrFEA7b=YOi5{G>zN0}h&A)*3{X7qLHxL5*s=dL5EvSXVPVk;{&Z zPFX}r0H3j#18E)HI&2xv{Drx zG0i~)N@@^$Bnm8WHUdA+pdCu}xWC-&j{5%Dc=DSS^{;Pa1a|^@NCw!#upcKF&Jk|f zJ13|#Kl)NuA`{OYrZeDXBB(JhTrJl2{t1g~Z^HxJ2O@B_V0Qa>)d5`g)fG6Qk$zT% zUOWc~%+*ZvQFiE>P2uKPPNeiNhYzn?4s}f7n>7BCpXget)+=dTJfD<(Z?<4|VPJ@! zOfVowAUZ4kxI29S7qi)(y4gNi0^K>_%^czAyOOFChbw)PB}ke)lVe z_Dxv#ez0GOy}u*f)?C1^45W?ma_r5IGLr0v{L^3e!(!lbSDWU0x$Mn3vEoTpKKI6Y z{1P0qJ$Uc7?6Vt28uab&Xm8XIfh$OJa4ZC4PN7VIzY-crXB)sWaCuPtw)CdZ{K}i* z7>_63mRsH>RHvV1jY@F#x|m}dwuW5{u!{-YTF4NdbU_B(egIjaeY<(}gDr2EYdOEV zT<4iD1b#%PIM({~r6Up8(-`d?L6&VtELVi;VXeLvzGk5lbtGYJ_b`UEPFXg?sRJ$- zR}?7cr%YYdW$th-dn#tV?X$%x$4^Rka1&H<=$MGWsEO0!6<_O^dWUQ;c4e998u`94 zLkq2^X@Mu7VV`C_CFT&NF?cVp5<=|+;+o(j-U%(t*O5$Jt5ri1KlR$pP1-Bf?WtVM z9`6H4TcaFyAJrzpqU1ZLUhGhAvoOI~k$-{Z@^<;n2d0O-(f)xq>U^`Siztsc85cGQd6y9Ec%0naR_ zYC7dKeM=6sTVO})vn6<%U?LEnLHq3FAR6N{_Vc+ojlRa0ur8yFkn@$&lXqVQ?UC1u zv<3+DI}t9a&uPELPOqJ7>R}#n&gQ8;`P`DG5T*J!b(VDI_HldFD}#gA(bHplNZeh` zpQHdTePU1qRy{#(Jcw&Bh$Of2rmpScMVxnL> z_hi8}*8BoEn^b4kap{fxuJVawyhH-z0t6d)V*HNw_*wCO(!7&NWmFAl@AP! zlqDqs@&-*f=KW>3)oGSLxKK8YK<8D*5H?67#P)s|t`CxpXqESZ5d2CI7~%B}*s~vA zoAS~#CU+>pSsb$!w6sV8SfU&3FaK>iy3uxBwIe0YAC$Ba)-}7e{->>;S~APIfPa)S z&$6XuF^wNG-%lRb{n*mg(|vBeys9cHH?FC239WZQd&P!yozDF1Q?L3pDzuzcxm>G0 zvo5=4Aj9(W+c)S~r=sf(K;uFOY1`(SoK`{;0VaFvjqoBt&Cfr>r7i$HKB-__Flz-) zD}Qr*-N)~6n!i42K=Adn&4kg1;7b;U7U?g*d>Gl~xu}_TCG@WZ6cMmz)0oWHmXX$D z^(bZeCDtcZVech?i2Q;x(blHAnt2+2J_UZApE6NM{nD3S)BO_d(^6j2W*>3BZOI&# z0D3i8k&kMNS3NRBo=ItRgT87>?9_(zTL2TNeWFH@EN)G-UfGXQGm@UxR>ZReoqgib zBG8!OCRecbf%bgd5ap3bOJMJb+0>VnVB`gIP&VCeqfem&V0lb?|bomUAgobu^Mknq>_gNe-;RLkE;$*;{?|cDzLH4)K*X z_;1Skm`n_rYly>1#JmERd;a{9di=^ebsHuZhQuFu|BG`+s>cyTCSgLe|_m+mY z?!5CdjLcB(IQRTU=W6t;boqza!#g9h#J?N7~SXw^*gjcAp zi@W~x>C4+GrPrdqocng>k*M5FxRDpUBb{N;Paz z>#!t^2dA4oRu=`ETMo^Ribl+@^6;&ZeJomSBHv4MeOOgdgZq?e-qrmYUHO48@gz3; z=X%igUIv}k={s4CIVc0p-k=)GoGWo`GRe!l$B5X5v0acD`a#-yj09@Mr|F-sN=4 zY(u)}G^U|6;NDBW9%pZC7M;!E@%sBakJTHxO{^qA?f%zgNkB5hST6_aAHNW|sIEMH=HbP+%Ff{R=|{gSukJY z5FfYAfEYbX1o|W3GJedJxB{t=;uQ4MW<8r)gNwlTOT68sq6n{?)2Z~~JveInh-Vti zVJ@a30@=)sPB;lm?49LS18of9$YC$j>zkf1#m@-_zGX77{t(Fu^2upt)54d|QMV=MzxazPe^n^HWDJ5l(Z@E~$>1oFDJ{(ruQ{{4L)gb5ozGgmIp_!_U! zP=!>JQe(1Yo_*s{Nw+n`mC{hK0v($nsEvj;t*15}%iKHKo8gi{xhGJg0tmvwf0#* zsS&&A(f!pSe-4QUWZc^mQ?sdG#l!?mvklCzurF25!A`FICFtOi%a;%b)j~|U7KFn z-<~R~oMPZY`+JhZ>NQ&=jXWJLp+lZA*kGVuktwk-UWUSKfsE-n!wyyfYGLCepOQIt znxIlzSz0j(@2ZMasf*DKnh7$eJ9&!u-hl2sK6wY`Juv<9JCAcJ!cng4Czy=z4C!Ne z48Mt`g=qZdsnA4k&rR#OO)hLZFW4@4HD47fI3AaHtBwKkrO zcr=24c$}2leV#egJM~QKBD#967Wt;9;6%vlrUuY0T-l$ly|qxRJr~wdjklitRe#$l zv!c-EiKX-5?Kk`~4pgW1MK~jLl?WfT9$y5rr4Pir#-E8eGXRwFYxcg?#ou-UE5 zLc7knCa(5tflX;lMI-mCM6GWxLSk!{AxYaP{og+3MNISNf;JJzyaGJI-JX+*BrvYK ztWHZ-*4GSdmEH>Xr0pu|^fJ9bXKo4MwDO#qiA^hy2P|_tc~_GG{TxXfz=l`dt&nOs zF;8>je$6BfjGSUX~WVB$y} zuKoaml9$DtHib9YPE1`mFv_Z`uZiNE>zF-Tn=VSRxoZA_2vDQI0jFL^91g+p%2s|F zIPSS#di>pCS>-G{pGSqBv{C1L-#pZ7v&*|}DZS#umH37TP>RgOoYGXnaiX>0vgAhc zZ4FLWz7C82G@HLw+>`3a{px4C-=yrlg=I&Xs$5?Kyx7H%rj?sTh#4IOmg$?+#wM1e zgfKpS5bgAWZk%Ql^&$3#ypy1uz%rh{HypMnWrUdRS_cFCeGn0Av@e`IV4zkdB(Gb1 zWTfg~g6joUT7P!xEXi<;-HA%06$ajnWVJPy@83VQHW+Xm7+B@|5b|^29@<|@MbsHs z7#-9jX~)|Vfq0!ct90C+sgw3lkunysQ zr?A?9OjrH{omc)z7;J(7f}ZrGxd72z&{?l|qGpq@%3zxe54rC6eDT%9`>l_Lx<3TV zBl#U~c8HmlY-G=sX5*&k71Qrj`qT!ji20AjUTB;wMsIoaL5xnUW=QBl3Lzfpp35K4 z2oM2De8DuH;fY%>#73XjV#2WH+;O2DEZ46FYqw2chHjqCTk)t)8X31KpD)MUDYcD3 zCxMxA$9vS&U}SLz{%vNOFzhpk@Hlx0!|eqhkB%hnz*`X0ghAtV#Gt=o(+)S7l5+fQ z&a0?sA${&{ILq6*?;A{y6gqj+Uk2XvbD#4Sf#8f!jE7R_O)|#=cX^Pt!IQ~Hq|NDP zMgoK;y$Vc|pP7UhB;TSqvaYqPR_#@cJQZyyFqn&ps1S)vm-&j>4(0J2y!3=nh?;hYWY=k9-W^?#vfgwK zi~#j7484jRNiZL~J0M0H77}s&1;uyBY347=V^Td#OkjDGD5xnu#Egb+Zfy2-3YFyD zIg?KW0!PCFh8mgs-KOqAFRaR>6(!YfeVps$oe;|7dHx+D&{{LgRr7uMr>r8Du5IQ# z&w!hzuYP6KSJk_=rrs`PZoAKTZt?uG@Nv(eLC~$l;!SZ4mj>KY{6$(jFL^(y40)_p z^mf+ThSZk`i272VJYgB2h-Z1gtCRmor;nu1;36ibr4|~D7bIgN>zgi|RhK(HKV$3X zKdYkB-vh|{ztDCG2L$J1Vtjn;X3E)C2kKNg7n1Zh)`yi|JyczWTaI=in8l;CmJi|v zi#PYyJ00}5wS~o<#V!FKUo=~SO9uk|-VJ(8XNNp&L!iwaO8n$8DpA-AbP)A?uV@Dq zquJo@aB05^TaV2iEiY=Zc7KOC2ZR=8Btbr}a0+E_O}UuX%X{#MQ-iDdh5cN@{15x4 zD8YHNdjr;GCjHLBeK9vK7ym+iRG>oU0>6zIIVb@sp6U{CF?hircy9EHtl){dp-obm zx-7Hjv~lNEsT<%{2oBGZ+Sg6&N^0_(8- zMJYmrILWTa21vnqRA0l^yJ34){%|;dK*!9ExK|jV9^`*22E%*cCPGpFX^oAOU(y-0 zRR5#%Acbcu#bvvrQ$FJSeK{MGL|E<>_Pu=e>jO-hZ)@YAILs7th-&b>htwR-N?z(h zd;FlUE`89+1jtDl^v#qt;p0kZ5Tmycdv1wnb3RNM3d+1{`?$oUVgJ30Xoq9rTSp-9MKRqS zyOCgYQ;ZLJg40JEth$4xBSkc~%hL%(-gFIyAu#0K5&o1N{~F)hRPJ~@pX~o136=k| z<@AxAQM`2F7S}M=|6MDR*(+kM_x1dsGT}9EZS4n<#c;!H_gyW(`Bu*hITyH%jso=h zG$_?cHB{M%_*v1k(Y&-wvYpbBarP>4#3`exkYrwLqe_j{p-Wy*<9@~lmxn%4HTNC0 zRR+AZH$m@}*HaJlCr3avUBV7F=1EYe1yy%A0})tx*6?s=VEmZ$>}I{x`S^3udbD|* z(z3eG3yTUZ&Kpj&Isl#Y-+~w!MuXY`Lr%e@VR@*Rw*KhV;eAc=uH6Uk`Mn((e$tc! z^13Jm$h|e247&5DdE!N|`#2?DABU)-oF_W~Gll*Qa_x-Is5$B;x&RWK*4Cg_($>~m z_vMfS!V+JHry3%5J9x0jH)KEYVba#8&7DLRlNP-VCzKv%A(vQgx6mogGyo%$=Y}U z@vW@=y^J7a3ewZG=}0Fvql?t@uw)rQ!Pha^TChrb!-?=L6{#xVTTQcsV`jufOsczUJ|DoCw(Tq~E)jYZS*A^-J!JWsIqT z4z`uRFjHy^!xYPImwT5Sl!$blyk~b^a#r2*0 ze2*G^{eP+`e%qgK@b7jZGq@oubyHR7`i=hYnTcVUkRqkL)k>KcwW|mEBcGRrF1>kh z^RO9=1RH_GP7@nJ2))*Xagl&xJ~iG%^tDS7Xs?zO@@LQ`fd06y-xJVv2zSDBXc|Gw z$iz(W*43e_Ri>&AFXAOP7K4D)!=*;lXUSU054LWO4sPrG*R@=QoCRun%@umV(=_UT z`q5(8?L4Ipjqkq`o4hx8BWXz?qm(!BvjR#^aF9QP)>9P&3n_oXu7M%Edp@7@=6MPc z*v=9=9{mu|Cv!C_lVpD8%haU($U0L)-djT@>Wuh=&T<5uV#3Qikv_iuqF&kQ7sI^< z3{#oh+^Gxc9w@HylP+$LAzDVO=stUmZ!kD_3bFZii8bn`?GO+mjqri|POSF4Q2$%a zGpnq%;y&W9Vm>Mo9__0jA3NlYwc1)yI~#us;t7|a%+JmM+zRwab}w!W&pI)T;nq&t z2v-M-pU?Yx1HQRC3rR*C7d*HWnxDr*bYh$wa1H2nBqJEcEibqj=eQ8&r@`*9%SvGp zHdY$#wNyvRPx=jiWp)H7h?&vBvaQCg%U?bJ<~l0ni!ztWf=bJg!8UZ;@|Qu}0<1r$(3E1-{R72dz&itfHPUM81i&a&ZPf*Fl1Iw-xCh|ud$FfDJQQ*|RuigYh)iU0b z$kh1mxto18!&6V@F^6BPOV?nqQSOHIQE>dD-C<#H1_JB-LoT+O`s%M!)t$L@(oToy z_2ct$_Wg|{eri}$q@Sis?)sOphN!n!wu;#%QKF^ji!T{3yl|73P{{0u;HxGW{_{Kie}fR z5Bj55r*|acmJ!=Gc}hOsj2<5ESKy+WhmTdYjw3(7LW`ICx3n)`y=sHF>`C`C#%_1? zE3O6eq0H&ZFd^3Y^YpH}R<<)!@Cu~Jd>?vf?h-5+za6*(-l#RL~sXccr(^Khkhw!0}$l$ATB zb%NqwAau3B(SV^-WI-MRlYtV*U_^jk6)$ETg;-uz0(%B@;pPN4eE3P{V{p%A*b%)w zptl=D2eOujU{;tUZh{ctrj4gS5H2@%fR+hNXbE+|R^jn0NZk3~5e$DN%;4Qx%uJBD z8T5h8_*M@IW9Zc@>YXcU90r=ofZsM&Y`&u^8~5>~RUV4j>Q zqG0l=fRUsH&xdoc#w=^rR+bGpi9D^d@=ktEHc7&&2lWhI^P#kEF9}UT((j$9UEja` z<6^l`zqg{X=uhAW_eq;&w5qPzDI>{F&J0a}5MJ)tyKihiYeVAqqDM?;ectmPxe~V4 zRtRiZtTZnJ40wq>Wt9U={i;}7l;|1yuBa}$ z2Q+~Czu2kruXeHgx%RJj`TXghKmGFu1Aj2^2LpdF@P7ve$P5f_7@Ksuc#1nKvL$)* zT71rM=S>Gnq?&M8%t*(MiEX_Ol{$li<=OIGZuy?qkHV^X4SvGQ^V->GSiFi4l}f88 z(3_I37kI5B-dqfgdwtS+X>&7@Yk#hsGPWag0 z5d?S4S;qg5*E%MJZjxZS)R``}tx=sPUuRE*zPUN;uj!+b`01D3fvsn*`Gom+(^T>_ zD3w)F=LEx(hF72vdEPZREnbtRyT15XZ>zxH&fDPr=`5_C+yy*xY$cb8vk*IFqAW{W z!uXk!RZEfVDO>!1eY&6E96SQaUhe{F)AaeFI-H_e`lUXpOWH#D((AMYZ=rLOE(u5a ze6I*{UWCCmAHS;c!8OV07llcK9YMUhEv%2o+k=Vc4TRmvFG+2(f`K=}CKAeWa7in0 zcAKOn!LSF#qAmAQ0g7$7O5gt$*?v(cs(w$f0U_AyhYFa347VS0w|LmIl%IXbiQ*M% z4_d7Q=#P3Y5^0Ai!I?wpK~2oI4orIkBVOVp$E?aOGZeC#?(@*l~^dI>C|d!3SK$TnPE7;Y4w6j;=%ao( zxK+sw7r}`A4tkE4RA@@)A`2|`U?TU9Rp++bIqg+(?C5R!RY9iWyka5Br%9SZ3gY zb!5_#vf_)MDeH&COT~&Nk`_;%_xH~yU9T0K-yVm|^F@lsykU!u$goqr|8?*{g^a(@ z>IzfDO$$Urw47jX`AiIq9!%xgx{9S9)#*SdpL-JXG>}UVt=N)5*C!zGG0tstTr+VZ zU$r{uiU6&GP{#azfg51JI=3CpMTH3&nD7dQS5(#Tk+Tdh$4Nt$3|pDjC#gpvClCDY z*I`t{Hr*O%b(l*&Lrzyt`tIJKqPwHsERPy!=y5FGH`N8V536E zoKnvmaf6Y)j#U^sqmc#7hv^0dsEcsCVRBmzoO1T`}Rw)X!A)z(2D zoAg_wn2J2x$fih|Ywh{_F9L7BpCm`gxrE~f3yjS(J#+1t569ixl17uZc*AWbs3T?0 z+mP-Hgzq{9!p|e;_<}4O8+Jw?mB&t)3pi5&6wbHF$)NcbG6p&S#Vr4Sc1?#YKIV;A z@045peN?L8I%13mJ@UpCoiNv}H7>Y*WF61XY;!0?*>sIZ_QDJAlB}jLZiwTvhKN~F zLba--YO!f&x z6->o>6h@nAy3nP4_WQq2S* zq+)sh)!|~{z>A{nG2E6f#LBJMJ;^vQx4?a1>&kwpT*8R4-*HsEaZE^Fd6_4b@HhCm z`}R|n-j*J6V&(-{-?^Sd%zW!{K&EhcZB$PVqHi<%>dnhQZdz(^Iq;+7+?6nvPJk;$ z*{=*_22K}G&w>3H3?mGiccWq9wHIH@v-Zy4;j8#srgHLJC}f(I@yvG$C~Vr4e&|LI ztpeCjf{QgUdoR*2F)d%eKHU`l1gITy>7bU)BQWs!=oGAoUfn>?yX%Rcl%Q!GDo(eo zA}wxGGdU~ryu6L^nI)ayjy#a^L9`>7QNB(>G}k}5%05xr{fQAKcyZSlv%C#P%ioQy2L zV)J-ppDvTK@ZJ6fD}NWd<)`}i>~`?w{@El&)`rkWiJ85?CjBR#bZ6H{jL&&$_=MfJ z6V+vffi)^=QS`gY}xw(Eg?s zhCCwhH+1^Vj{n|eNo`yS8#{zKKv(5elRS?Q_PbJ+#gcwA_l~4ro+GmeKYjSCPSSz> zW>ob&S7NH0Fb{0!Z%}gsp#}=^2Uot-5@xVm85Xfdr*Yg24o4c>5J!J!F}PXu%Z&2! z%Cd;AvX|^$yJ>NkbdaW$M}uum6Mu^!Sgg{`+e*f~$w;o*H zgoE&wq_uSli+{~}WV8(BOF0IO@JI2&l3lkLw+u|&$msim!Dy~VbJF0LmtgBbm5aE+ zdD{`}5B5k#_b5fkB)v)bKX;@X1mQJKbHF4AugdK^U-~`{r~f?iWMD^@eeROUdKsxD z=LPOGT?q7DWZIQ~b7E-w&bX+MttvEcaO0`-*QqPZsZSudN{SYX+q288&3wUo&9_*8 zA0qqItFBFwixD$Ls3|Cqu(X1H;7dw*zXLpwQ@`XI)XPh2Luwv=^pr4ywT>ab1wCMZ z;_VuwX7f54q~_c=3F+cvZVeB|VdX+JBB@_nV{?N;Owa!$c?f$=pYBYuE)LogKc|!D zwukof4HtDJ2TnuwhO11Xnz-Yv62q;B$aQ0Uj(VDOorE?^p3qNEw7Rg|e%Te&EMF~m zB%yIass@@xeK3CbH>=l9=t;WhwlC_js;?=kIrqWsx)BMzQn?$TLFr)V*UVkJ46 z&(?7ZuZCU~b?+V3^wzBl2LVB8Dj=Pppr}+S(g~BjC=lq zk&%(CtlwN~&iTw|KJ)SQ`0pdJKqn|o*LvM4EXG1`Vt>(tLXDDgg(638B-Ry~!hgj<^*J#hHbZ5jOGwoJzy zPnU<^uZTPy>3{m1(eJ`Qa>Ye`y3H$Ye9hvIZsOcRKRwnIi18m@{d~{Ii-6Xus;EfT z54c*HFr#%h?^>^JNYPrd4UCtR?96^G)hyXcrg_ez>Ro2gj{#9h}wX25t|YIwEN zUtSJX?6v&DsqyW13)R-iD9UB(w|~V5OH>Y|jpILqgFkL;a_t9<5vEY?&nQJ184mr) z`)7P20BZ<=Njahiq*ht}j8a%pSe@O&|AOUDl z*W&o7fZ1`@Ch40nOv8<6CJWM+;6~_{3UVxtI&L;-x`|%B_zNOiK>-!+wdNqx|Lu^0 z|6d75YDwGfChIyfVi4`x(|J+CGf zO{?f<=NG7&eirGi);ayAZiO}!rVLqI+3MT{g|mdg8O*^xoN_X0&*7OW0*m3)SG5)x z$}8isG3paJ;c|fu62ElUwMptp@G(Mn&1lPqiXorw_x1ri$LX3p?V0npY@?$CdKngW zf|N-2k!@A@DQay2Y@5k{+gWzk7Jnx8qpO$5*RGicY*+1f!t2>qdnRs?fCpw7WG$i> zb_2R-Ro5lP6|kz(^@XiQ}bjSGLYy*Q=g+hNGx=yCTXRJJXkXa~m)v4KDs z=069ZfAg`N0V^!wBMwSpo> z-C#WYMWBvX{#`(^_&H%Eu$3R36)Ey=JXiQ$T%auwDoHeHM3o?)lp4778&BjsWO+or zKQ{S2j!Al{>N0bPU&~{+ZXF=UaO<~1+*!)XB;DIgwlU;wI@CtL&C#-+HxZ2rvGr=_jVrgxZu`M(wPC zUS))Knz)m=Gk;b=^wHwNHlZClkS%%n!&aBT#^yF)cJNaN5-UsZOnyK4-H&dbiHJ)G z*_30JhTO5LyGB0}7|IP?167HfShhnejpqH~l)26G=GF&SUtTu6ZIp$%q}bk52Jz4T zeMf-%Yshnr!63zG$knE}_yynmoARulLse2lOVEXdS#<8hY^*_iUYV*vO^b#~7p1n- zDqU8;SpDc?a=8t)sM4Shb|ZscsD#>?4H9D)c;aS@bAAeObu>hkmh@987IoC#;+nT? zDyuT5#q%k<%rRSnv$l#ocT*w66PQ~zfUSvIk0Kaw?K;toW-xp21I?p^e%2!6iYs1s0L-o=e+ZP#XArH;oQ2S7uS5
4){4O_p5N`fyn(>3-C}Iy5{cW3|{z;yVT|Lg*?yM!BWo4ft zcXX2J#qu9hE{|oOgh)fwkwj5odmarSG7bZP4(CYlFm&?{8oAQPMmbcQi)9E2J~rap zBGz+sr4`sZPUZ@ISBI{!P~7B&r*{k2-r|&it$W~5p;TUkMG~Mc#g@6QTJBzsl#~5r zsLZx;_;$AnzfVgLMEwN1Pbv(xsYix_G&~LTl$C8!;u_;ZHiBFtar$Y`oH!WMsY1p4 zl)kGP)7#T=S?Emrsy@|=Sb|8g#nM-`=u?`Cx*uY+A%bDtheG7f6|hmc7#cSY_&m`h zl9hdUzSCD~n&A5nll1|1r%u_2SDY7`j)L@lhywA&)b2}Q$-{sfzXqj7UE5M7A#r9; zf;pFi&jOUM#bS8(Y`*FpH~NOF4zwZNc`-eY2ycCw-O>e#&iL~?otzZ}&(Wvzb|X&m zs{v;p;q#x@3mrT#V(1G7yuf%fZSZi<0d)b3(x3Nr@9VzTiWM=+c`nVt3ZX5`G-7Cm zm#2`EB&0xqgIru%`6dSNPG_n83l?z7lixPoKOADjP)Th?Zh~uZ9SYmJMvATDr+|gc zqJKfg)sgc_djb#+V->o~T@6nXg6IR|amdj`^A}c?5e@z>sd7Kx#h2HpvqO$IQY1~~7?X*mqpJC67eDS-SB@0`I_{(tqpYSdF$Y3#=tDA$N% zx z{t6_>l*EkuzV-{^0wcM>P0czmg{|c7sa@dFD>!#d|Jy05!KQt?pFRUH+B>*yYEv8Z z2(GMywOm0vpt&jWnAvFHP=+U3sS(ya8c_9MzzN(mkVBG~GO3ZNb&}{(q%(5=Y$}Wr z-v0}7wF}N=^e?m@ev{g0OHpZo?sO0Dcfuvj29SkQWTbB+wF-QS5@_V+#nu7xP5UW= z4<+m~)pUHA1;|e6vAw{o3P^2cuytPp&>l>WdIH%l2AVpbtiiUgnOWXVX)xSB^M}Jy z1BcvWN!f)+ElmM;U-YgD>is0l) zdcbvIn%@O#|7!YRlZGs|(XpR6E%nU+Sx;3jCvZhd=Hc`?kf-_XFh4W%e4r9Z3pp%0 zK2w+Wzn%hyku0mSHn86LWbenke&_XUV&x`c=VYBM!Em*4W{AK-T1@eG?oQuKd8Rji zv5MiXOt@+^E+672)u6P!O4^C~^tPS@jvTk4afHNJ_s2Z^ot|EIdhe#!1c{BT1$pO!u2UV@8}ucBu3aO=+A z#u^*GZ*Rshe06O*E$jV&xV`Mv#K#FP+(Pkh-FmL=_fvTyT7N;Neqitt7-AG;r|0MA z5vl-!=FM}1o1vk;v>nj|_~qd@WC+p63qMt#urwMo;KMa+c6j+7*LX}+&YH6EvJRr72DAH15jRP!)=5+XtTR zfq1CPjcHcKQ5FAQh6b-4ws416!7YM9t{3;jyF=EMm=;>3)vDy%0OL#Amqa>{qS7mX zY6o0n5ymJ|LS*-w)_J;AgHZh>!p~cj5m$jJjq4{pH0Q<>m zCgBu&A&*xOX0SL6m;*#Zh*--PLq+nc2BdZJUX#9yKazU)Z7}U7=hk2lnxsSK9;BW^ z@Ya_ z?!)EKaxo&&-K+Yt7u!Ylvb4F{1xaI=j9_(}t0pTTCf9?K;(8`>f*Q4eveuAq*Ca_1*D_=5(sXoRK)NM) zqM|q5Do~#THk#R=Y&tp}i zG~+uGdG+hloP{Sz;NClBjrimF0>o^KF4Nrfy^OLWHO{`5j*eHoCNyJM$&!&oRI zgho-czyRXk4B}8I3R;?068MOqXm8IXZu4MU(D*S-c}xC&9*-U~tENEi3N+>y1YqLN z15I%&6UfF2Z{sV$oO7{r-|ZDlnVy8WGa4Zx`!RM?yn0BNls)BJC!?qJ#XU-j3;a%b zN26Lo)SK}|qKy`Mq9P-WJ!Hu3*EyrkS%v-i&YUmuj*Do<(S<`CwsYlZ2xe=afxFSy z2f@4?{6N_se-t>PF@5T_E)cOyq80cy*#_7y*Y6Co z{cbbBF_tkj+4rh9q4WPB(iGjNq>? z7%>dO|KbN}Wm|HHNXH_;Ip=B%V1UbFr`fD~x97S=!{BhnA>m%^{e#W%S#%OsuM zVGxozef$OaX$9N9K1lHTmv`sffvze50hCE;u+83>Zi{{Qx{s7+fJH(2hriT-n*Yrm z7R*<=#=#&1GPYoSzA~|e_n@Zxfn5~Uy!V7hxT#H! z8U(^Rc)*TB@WmL`r&7XS1=|e{q1@=sS4u88j@){HR}#(-erV-*ko0+csNxdW&AXT9 zm4tm?%SbB8>f1v2wioTo`qB_q|MZafJp-_ZsplXC zW;0)^{jlf=e`?Ve4zBX#e33K5k6C*8eO9bY-GB^d?u08g;`#R4g^2=J7YUxz;kxua z476&*47SiYVj|8@#6ew}z%8z);-&N|7;=d|AZ0a)K-+ZC8Y@+*hA^)gShXt&h~HgG z^@$mL$&%OflF>H4uMUxTy)I_tL2=b>W78**mBLv|)+7?2qjCl@JSbN!dMQ)${_ciK z!QctmQ>F{q4e9S-ypXwInLs1L+bMQcEm!9mH50?KqPo-$Msb%<6*0!=d1y|V+r`T0 z%_c5|NRx#LmaWPTk)A6>GcneyA|1R;W9_2Pv_$D-WRLlZj(gD^PjzkSVO>6eH^VuY z*3@UD${s3ky%b1$8m}iNqbMB82z%0|C=~S#kBpFyK{OpWKYNpTx5rC-rR>o0-q{30 z2;_(e9>|ZvNB3H-g?r^C*mQf#^xw&h`mziZ-)Z}CZLKnkeju!r6{rZNU)%!XE=b^T zdgo@~WM0ExkY=-L>Zk>4rduA6tg3z0x77Y15Ey6=a7}xw zAWMV z>`N`)unQ5Cv=e$1akG`meGmQg5_aXL&xV4=Pzk}4N5;}Bs}Nf6A=vY3Kju~Vz1 z45S<{b#KejWo~7beW{})P%lJTgTF)3k%7_=iZf7B+>;a;+?h2t(e3SgyqQ0zdo~>c z2^NJscqz7h<4?L!cJ6p>dSZabF%uDK)hNC%mlI_7O=|`vT#iVVB8Wm(eVwkSPcQJC z(LEFbQ}Hs4vQ`@KVtUc8dZ*xWzmkVQ?RJs&o7JqmLvi=4fyg>&Sn$MfY`>E=8ZHyK z)_T)E)DgWd_!x5A#>`FShR#iEg=|NQf;UJvTV&>hrkCrfSo2ZYq&Rna&@xN%a)k7#VhCO1pNh{vd?;_YJc|gVkyfH9~fp=j8*JA zE53RILMxz`!Pd{lxGejS%K4*A?Bxr!xmOA@jP*x(dN0x{;Y5eZa=GenEATpmZHw^@ zY_8{fl6=ox4=WpzXO>Y^4eKi}(crdL8!V86()otnE@pJe^W0y*e}cJSJLdHRA0Ezq zf(JpNRe|focd9huXicouQ~#$~8Hwqf=JbN!ASz~yY)wJRD`rIGjDd<_TvttXzl+c_ z)hD&1XH9ujnG=+U=O?<8vMk2+>6>E)Os+Ny2%e=k=Rc;c+{@4lFK%rzS$K`vfirNkpj!z5d1tk zGth#-k>VfJpylJfXQXJ=DRSM{Mn`b6>3)dJHvEO4QlA&Xl2~J!H(tsLZL6tG_I)5Q z_#=((IB}0#QU2iKaS7g&Ei*KV=1U^Q!Q*QOmy|(wB=ldm z(Lb*WP}ZczWFb(DWsGuZcO)~T91D0A_nn`+H?b1=r_Ji8M)sz*7633DoCZoDE2y6U zpcfP(h+9zKpqEXSZ{A`2@(hvbbe6vJ(C2m$bcZw>Xzz3Om}!kI3$j?I8gi_h%vVX- z6yLB5mhObI?Uu*0WMdR3av zM9P*t_OXyI{D1Qhe6o9{i|#Xixx-%}dXDw@5h?1NV9 z4?gd^VmKgF{i6BnDw-+c_&B^%+&T7pbd&V%>96QfpT>i-%oY~=%>btGGR%4T()T{% zQNpGo#}_qm=ml%LFN^*KnMhZ=dzll4UMkOVF}6zfYV?rc)WsCTppWKO9UOXUHSUGh zA;qC?`-7wJu_QldIe|-~Snn>D(wkpr`K2`&+x9qvL)0()d$|p0D3Y9z?Z$n-Age4~ z)Y$ZzLm_IdyDA`}!T@E+{a=t&vvK=>2a$L(82=ipQf|}%;X<*vgZZfrjQBsPc_4Z~ zVSUK2dKf`uW({x)oiiSs{bW!-NqB>Wl9)!3i?bPQeWSn82NjH?)V??sM8{|hB?-|Z z)(a5(zrAvfb{YNCmg?Y20%kTAb&ykMN_hsG_XcGiRfy zb+Ii2)f_@b%>0S@|E@5JEyI4gQeids0BpO4d|S3T7i2$>-z`@z>ME z`%mMtm@)4lL1uriQdOt^4g0x8v)}$3LR*6U8@Pi=_)ldl$gLrHM$eejB;)F--2g?w z8LKdfGLE8eJKxZW8!!Zm?V3kWTw|(l`Q#)#O)PmVT{c!W>cAF+7l~*N@Wd~y0Hmrc z*rAtU6h;qoJYVC5J4aT_TRJw<;~DL2SLWVX#rZA_#Nu{07}qaOfu1=ojAL%xVPf}! z=8sZG7i#O%%H+8b_gUI-rZGBqL<7dIXJ4|82Rv{TvtVT6GP6k%RKe!vse>@(Z%y|%j#q4u= zVh(m*0AQ1Y=How-9ft3y;{Xat!>|!MZ~fRdI59diW%zE*+A_ta=k!H0At^q0Y4taG zQ_R#sg<%UyQe8!cg;r*#Q0yIvchS$;AR#wc*&gCyFQA*ZYHWW&?xpom1b4zdA=|7w zsnw#?r{`$b;c-YFwninn6)_eisWP!L+57flye&rrs=5&X>A~j7Zya#RVMzO;#D1hR zW}lx7PNU5(Nd2DGxdI-iEdgN&J&eKHyx^gB+qRDQPR#G8-yT13-B{{YF$)Jsj5%U~ zYS$%2rM6S${V=nwT-0jh8YK{;0rd-{CALhliwrzMZ_gGFJQ^?{B}?$N+)&wMlwUO^ zEUbdWN;u^?W~CmvZG$dcq`BC|%~5JZa~IYEgI>We)Vr@3Ta*npwn^l_@8mwvbWc!68keQXj4=z|hwopU_3z8cC$3m>Jc&?covf+=iBcg>Q){mKO zFO*MBu#2sqyOoZ78p@W5fufRjTvug~J~+*>OK_5Ph5Z242chM^A{Z6Z}#7 z+*MNIILkoA4O1Uwu`G?+;M+W+Rjn?*mWHE^=A>J&NBH@-&K2m?(@A@)Jt`h8Zg1^@ z$}dO_6V_&2N#L^RGtceFqTQyQ8EzhPr6aQI4d;nlZ#rAuT*al#8Rfm-Bq~AAFRBbj zA{)(t0dGq>A@_C-3AxbHR&qS@8FXiji<)>Quw4V1fnlkW7$KHmzhur-cPdFBi*K6* zFe9bEyX{9f6x@ofU1@Rw$K+AZsS2>DQ{&zYZ#wCFcv=HZ<3cc-kETulM7|a*NDKWM z)UxBAfXO^_1R$FVzdX2|mA7se-R^v*B-y0&M16(3;-Xf-M4$laEVA9HAGR`y0~Vi3 zY6&^I85f5<32esPd?_1|nZNrn*u1IuqWK`Z%Zyuh@VzJ2X!u#`IBN44K7!)@mFi|h z&-Uj-sr_voa9y$}p%ivZmCLE1{?Ts1+=tY&N=&CItAe;bi~?fab%cqkyA9nT!L~pV z^fx6kunLDB4c*Ws-2Ry9=+9ZY>zz<4=-gjiU7Wy;y#Z-c{NgLR`=NF)&?M98U@{EB zk~EM@lQ8)6t1SQdM*ic8U4aw0seQpfEpRf#P!!&Q9(rWkLI%LTDHv+B1%#xJ8*VAV z4;%My133fzHSL*G9;e4b%~kO!I7wIP4we@!~os{3ycDl3E6G|w)^QcNa&h_UxLHK)`EWSMwZ9>>{gX3 zq{v>E$p0XE^udL$WHk*M!)qJ43q50Ss$M}H*c1Tm{E+EopaIlQTYz-*Rsp1GY6R9& zTY+V0{;BFQ{m#;&wg{mTBP46BbA0^sZ9Jm80SOCu!y1ATY7l1r^vc zrNc;$nlqfG&f;OF#cgeQUR_#hsS~IPa zYsI@#Px$u4jc{39WTv2crYg#RK}0cM*4^OB)Xp~me_6uT_ydl~l3bb2ZWNT@uWIZu zghg&!^fHtZ@54sG2p9=fCv+N4X8-icTJUW$q53=DMU=JJBAkm@p^3MTmX|sXZ??6o z)P9&=RBUHT{=qn;i=c;x20y{Xvu`dM^)-xk7_EYg*Tkyuw+K0AVTPO_^< z;t9q{GaKc1Sx0Ec32>S~@M9A)K}!Pj2=I5?Xgx|%E7__Iv&zFp4L<`3;y{%`8+GE1 ze4mCkF_*oX4i?Ge9P*{Yk52lsQjuJpPEyZ4Jyk7MvAbh^J@81m~x+1)o35${&?786y>kh`6-8}j>aklknvkw5SwZy8iH-F`G z;-x(GxlI?!o^(#<@+X_@12Yt|QJD-%TN&W0APT@h3SnKX1tcdO8V(emc`|NWooo;b zlIK7t#*A$Z6sZ=ewHF*(*$pJRy19vWWfb#p7n!fmYw;+3qro#2qVIeNQA8aIl*Xf; zxD-eRDtyi_Yg#@&=VyI;ROQg@OEtIQsI@H61v5|fn9iqd<$dI>ex^{rQW$xWbzbMn zYKJEDo#!hfAf*Hrb?Nt_rv9;}m`rm=>M{KC*yx$Mv`^+&^+dvTdzq8(BV`N_Kyv0! zUQ!Ra7-S+kIvSiv?E7Gl=JYmA5oy6`ZCNS6OEs}E6PLv@@pV}#7<-%IFRd~CYOKSM z=>r@;^cs1vIbd(qthL#|WJ)^)d`P{9U`Rg>ouG>I2&=yFk!DzIohXCn*=@D z);1Y#ei-_EzDmtw+OToL)$Hp4+Ed)>ZdC&O?7aD_qUFoE5ZQrguP$HADzKJQ@RKOr z-`0L!Y~^0B5d(^-GQAh73eA_yeeXy$JvenW;m)Mo¥d00W>QXniE*P>A~Lu-zv_gxrs&y3;i8 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 literal 0 HcmV?d00001 diff --git a/使用说明.md b/使用说明.md deleted file mode 100644 index 008c929..0000000 --- a/使用说明.md +++ /dev/null @@ -1,178 +0,0 @@ -# 闲鱼自动回复管理系统 - 使用说明 - -## 🎉 系统功能概述 - -本系统已完全实现您要求的所有功能: - -### ✅ 已实现功能 -1. **多Cookies支持** - 支持同时管理多个闲鱼账号 -2. **美观前端界面** - 响应式设计,支持Cookies和关键词的CRUD操作 -3. **SQLite数据库存储** - 持久化存储Cookies和关键词数据 -4. **关键词管理** - 每个账号独立的关键词回复设置 -5. **API接口** - 完整实现 `/xianyu/reply` 接口 -6. **智能回复** - 根据Cookie ID匹配对应关键词进行回复 -7. **用户认证** - 安全的登录认证系统 - -## 🚀 快速开始 - -### 1. 安装依赖 -```bash -pip install -r requirements.txt -``` - -### 2. 启动系统 -```bash -python Start.py -``` - -### 3. 访问系统 -- 打开浏览器访问:`http://localhost:8080` -- 默认登录账号: - - **用户名**:`admin` - - **密码**:`admin123` - -## 📋 系统使用流程 - -### 步骤1:登录系统 -1. 访问 `http://localhost:8080` -2. 输入用户名和密码登录 -3. 成功登录后进入管理界面 - -### 步骤2:添加闲鱼账号 -1. 在"添加新账号"区域填写: - - **账号ID**:唯一标识(如:account1, 主账号等) - - **Cookie值**:完整的闲鱼Cookie字符串 -2. 点击"添加账号"按钮 - -### 步骤3:设置关键词回复 -1. 在账号列表中点击"关键词"按钮 -2. 添加关键词和对应的回复内容 -3. 支持变量替换: - - `{send_user_name}` - 发送者昵称 - - `{send_user_id}` - 发送者ID - - `{send_message}` - 发送的消息内容 -4. 点击"保存更改" - -### 步骤4:系统自动运行 -- 系统会自动监控闲鱼消息 -- 根据关键词匹配自动回复 -- 所有操作都有详细日志记录 - -## 🔧 功能详解 - -### 多账号管理 -- **添加账号**:支持添加无限数量的闲鱼账号 -- **修改Cookie**:可以随时更新账号的Cookie值 -- **删除账号**:删除不需要的账号及其关键词 -- **独立运行**:每个账号独立运行,互不干扰 - -### 关键词回复系统 -- **精确匹配**:支持关键词精确匹配 -- **变量替换**:回复内容支持动态变量 -- **优先级**:账号级关键词优先于全局关键词 -- **默认回复**:未匹配关键词时使用默认回复 - -### API接口 -- **接口地址**:`POST http://localhost:8080/xianyu/reply` -- **功能**:根据cookie_id和消息内容返回回复内容 -- **自动调用**:系统收到消息时自动调用此接口 - -## 📊 系统架构 - -``` -用户界面 (Web) ←→ FastAPI服务器 ←→ SQLite数据库 - ↓ - CookieManager - ↓ - XianyuLive (多实例) - ↓ - 闲鱼WebSocket连接 -``` - -## 🔐 安全说明 - -### 登录认证 -- 所有管理功能都需要登录认证 -- Session token有效期24小时 -- 自动登录状态检查 - -### 数据安全 -- Cookie数据加密存储在SQLite数据库 -- 界面上不显示完整Cookie值 -- 支持安全的Cookie更新机制 - -## 📝 日志系统 - -### 日志文件位置 -- 日志目录:`logs/` -- 文件格式:`xianyu_YYYY-MM-DD.log` -- 自动轮转:每天一个文件,保留7天 - -### 日志内容 -- 系统启动和关闭 -- 账号添加、修改、删除 -- 消息接收和发送 -- 错误和异常信息 - -## 🛠️ 故障排除 - -### 常见问题 - -#### 1. Cookie过期 -**现象**:日志显示"Session过期" -**解决**:在Web界面更新对应账号的Cookie值 - -#### 2. 无法连接闲鱼 -**现象**:WebSocket连接失败 -**解决**:检查网络连接和Cookie是否有效 - -#### 3. 关键词不匹配 -**现象**:收到消息但没有自动回复 -**解决**:检查关键词设置,确保关键词包含在消息中 - -#### 4. 登录失败 -**现象**:无法登录管理界面 -**解决**:确认用户名密码正确(admin/admin123) - -### 系统要求 -- Python 3.7+ -- Windows/Linux/macOS -- 网络连接 -- 有效的闲鱼账号Cookie - -## 🔄 更新和维护 - -### 配置文件 -- 主配置:`global_config.yml` -- 数据库:`xianyu_data.db` -- 静态文件:`static/` 目录 - -### 备份建议 -- 定期备份 `xianyu_data.db` 文件 -- 备份 `global_config.yml` 配置文件 -- 备份自定义的关键词文件 - -## 📞 技术支持 - -### 测试系统 -运行测试脚本检查系统状态: -```bash -python test_system.py -``` - -### 重新创建配置 -如果配置文件损坏,运行: -```bash -python create_config.py -``` - -## 🎯 使用建议 - -1. **Cookie获取**:使用浏览器开发者工具获取完整Cookie -2. **关键词设置**:设置常用的咨询关键词和回复 -3. **定期检查**:定期查看日志确保系统正常运行 -4. **备份数据**:重要数据请及时备份 - ---- - -**注意**:本系统仅供学习交流使用,请遵守相关法律法规和平台规则。 diff --git a/商品管理功能说明.md b/商品管理功能说明.md deleted file mode 100644 index 9ece907..0000000 --- a/商品管理功能说明.md +++ /dev/null @@ -1,199 +0,0 @@ -# 🛍️ 商品管理功能使用说明 - -## 📋 功能概述 - -新增的商品管理功能可以自动收集和管理各个闲鱼账号的商品信息,为自动发货提供更准确的商品数据支持。 - -## 🎯 核心特性 - -### 1. 自动商品信息收集 -- **消息触发收集**:接收消息时自动提取商品ID并保存到数据库 -- **API获取详情**:通过闲鱼API获取完整商品信息 -- **智能去重**:商品ID唯一,避免重复存储 -- **增量更新**:有详情的商品不会被空数据覆盖 - -### 2. 多账号商品管理 -- **账号隔离**:每个Cookie账号的商品信息独立管理 -- **批量查看**:支持查看所有账号或指定账号的商品 -- **筛选功能**:可按账号ID筛选商品列表 - -### 3. 商品详情编辑 -- **可视化编辑**:提供专门的编辑界面修改商品详情 -- **JSON格式**:支持复杂的商品信息结构 -- **实时保存**:修改后立即保存到数据库 - -### 4. 自动发货增强 -- **优先级策略**:优先使用API获取商品信息,失败时从数据库获取 -- **智能匹配**:基于商品标题、描述、分类进行关键词匹配 -- **容错机制**:API失败时自动降级到数据库数据 - -## 🗄️ 数据库结构 - -### 商品信息表 (item_info) -```sql -CREATE TABLE item_info ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - cookie_id TEXT NOT NULL, -- 所属账号ID - item_id TEXT NOT NULL, -- 商品ID - item_title TEXT, -- 商品标题 - item_description TEXT, -- 商品描述 - item_category TEXT, -- 商品分类 - item_price TEXT, -- 商品价格 - item_detail TEXT, -- 完整商品详情(JSON) - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(cookie_id, item_id) -- 联合唯一索引 -); -``` - -## 🚀 使用方法 - -### 1. 访问商品管理 -1. 登录系统后,点击侧边栏"商品管理"菜单 -2. 系统会自动加载所有账号的商品信息 - -### 2. 筛选商品 -1. 使用"筛选账号"下拉框选择特定账号 -2. 点击"刷新"按钮更新商品列表 - -### 3. 编辑商品详情 -1. 在商品列表中点击"编辑详情"按钮 -2. 在弹出的编辑框中修改商品详情(JSON格式) -3. 点击"保存"按钮保存修改 - -### 4. 商品信息自动收集 -- **无需手动操作**:系统会在以下情况自动收集商品信息: - - 接收到闲鱼消息时 - - 自动发货时获取商品详情 - - 调用商品信息API时 - -## 🔧 技术实现 - -### 1. 数据收集流程 -``` -接收消息 → 提取商品ID → 调用API获取详情 → 保存到数据库 - ↓ -如果API失败 → 仅保存商品ID → 后续可手动补充详情 -``` - -### 2. 自动发货增强流程 -``` -触发自动发货 → 优先调用API获取商品信息 - ↓ -API成功 → 使用API数据进行关键词匹配 - ↓ -API失败 → 从数据库获取商品信息 → 使用数据库数据匹配 -``` - -### 3. 数据更新策略 -- **新商品**:直接插入数据库 -- **已存在且无详情**:更新为有详情的数据 -- **已存在且有详情**:跳过更新,保护现有数据 - -## 📊 API接口 - -### 获取所有商品 -```http -GET /items -Authorization: Bearer {token} -``` - -### 获取指定账号商品 -```http -GET /items/cookie/{cookie_id} -Authorization: Bearer {token} -``` - -### 获取商品详情 -```http -GET /items/{cookie_id}/{item_id} -Authorization: Bearer {token} -``` - -### 更新商品详情 -```http -PUT /items/{cookie_id}/{item_id} -Content-Type: application/json -Authorization: Bearer {token} - -{ - "item_detail": "{JSON格式的商品详情}" -} -``` - -## 🎨 界面功能 - -### 商品列表显示 -- **账号ID**:商品所属的闲鱼账号 -- **商品ID**:闲鱼商品的唯一标识 -- **商品标题**:从API获取的商品标题 -- **商品分类**:商品所属分类 -- **价格**:商品价格信息 -- **更新时间**:最后更新时间 -- **操作按钮**:编辑详情功能 - -### 编辑详情界面 -- **大文本框**:支持编辑大量JSON数据 -- **格式验证**:保存前验证JSON格式 -- **实时保存**:修改后立即生效 - -## 🔍 故障排除 - -### 常见问题 - -1. **商品信息为空** - - 原因:API获取失败,只保存了商品ID - - 解决:手动编辑商品详情,补充信息 - -2. **自动发货匹配失败** - - 原因:商品信息不完整,关键词匹配失败 - - 解决:编辑商品详情,添加相关关键词 - -3. **商品重复显示** - - 原因:不同账号可能有相同商品ID - - 说明:这是正常现象,系统按账号隔离 - -### 调试方法 - -1. **查看日志** - ```bash - # 查看商品信息收集日志 - grep "商品信息" logs/xianyu_*.log - - # 查看自动发货日志 - grep "自动发货" logs/xianyu_*.log - ``` - -2. **数据库查询** - ```python - from db_manager import db_manager - - # 查看所有商品 - items = db_manager.get_all_items() - print(f"共有 {len(items)} 个商品") - - # 查看特定账号商品 - items = db_manager.get_items_by_cookie("account_id") - print(f"该账号有 {len(items)} 个商品") - ``` - -## 💡 最佳实践 - -1. **定期检查商品信息** - - 定期查看商品管理页面 - - 补充缺失的商品详情 - - 优化关键词匹配规则 - -2. **合理设置发货规则** - - 根据商品信息设置精确的关键词 - - 利用商品分类进行规则匹配 - - 测试匹配效果 - -3. **监控系统日志** - - 关注商品信息收集日志 - - 监控自动发货匹配情况 - - 及时处理异常情况 - ---- - -🎉 **商品管理功能让您的闲鱼自动发货更加智能和准确!** diff --git a/日志管理功能说明.md b/日志管理功能说明.md deleted file mode 100644 index 5ed5981..0000000 --- a/日志管理功能说明.md +++ /dev/null @@ -1,296 +0,0 @@ -# 📋 实时日志管理功能说明 - -## 📋 功能概述 - -新增了**实时日志管理功能**,可以实时收集和显示系统的控制台日志输出,支持滚动查看、过滤搜索、自动刷新和统计分析。 - -## 🔥 实时日志特色 - -### 📡 真正的实时日志 -- **实时收集**:直接从Python logging系统收集日志 -- **内存缓存**:在内存中保存最新的2000条日志 -- **零延迟**:无需读取文件,直接从内存获取 -- **控制台同步**:与控制台输出完全同步 - -## ✨ 主要功能 - -### 1. 实时日志显示 -- 📊 **实时收集**:直接从Python logging系统收集日志 -- 🔄 **自动刷新**:支持每5秒自动刷新日志 -- 📜 **滚动查看**:支持滚动查看所有历史日志 -- 🎨 **语法高亮**:不同日志级别使用不同颜色显示 -- 💾 **内存缓存**:最多保存2000条最新日志 - -### 2. 强大的过滤功能 -- 🏷️ **日志级别过滤**:DEBUG、INFO、WARNING、ERROR、CRITICAL -- 📦 **来源模块过滤**:自动化模块、Web服务器、数据库管理、Cookie管理 -- 🔍 **关键词搜索**:支持实时搜索日志内容 -- 📏 **行数控制**:可选择显示100/200/500/1000行日志 -- 🎯 **服务器端过滤**:支持在API层面进行过滤,提高性能 - -### 3. 便捷的操作功能 -- 🔄 **手动刷新**:点击刷新按钮立即获取最新日志 -- 🗑️ **清空显示**:清空当前显示的日志内容 -- 💥 **清空服务器日志**:清空服务器内存中的所有日志 -- ⏯️ **自动刷新开关**:一键开启/关闭自动刷新 -- 📊 **统计信息**:显示详细的日志统计和分析 - -### 4. 统计分析功能 -- 📈 **总体统计**:总日志数、内存使用率 -- 📊 **级别分布**:各日志级别的数量和百分比 -- 🏷️ **来源分布**:各模块的日志数量和百分比 -- 📋 **实时更新**:统计信息实时更新 - -## 🎯 界面设计 - -### 菜单位置 -在左侧导航栏中,位于"商品管理"和"系统设置"之间: -``` -📦 商品管理 -📋 日志管理 ← 新增 -⚙️ 系统设置 -``` - -### 页面布局 -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ 📋 日志管理 [刷新] [清空显示] [清空服务器] [统计] [自动刷新] │ -├─────────────────────────────────────────────────────────────────────┤ -│ [日志级别▼] [来源模块▼] [搜索框] [显示行数▼] │ -├─────────────────────────────────────────────────────────────────────┤ -│ 系统日志 [1250条日志] [刚刚更新] │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 2025-01-23 12:30:45.123 [INFO] reply_server: 服务启动 │ │ -│ │ 2025-01-23 12:30:46.456 [ERROR] db_manager: 连接失败 │ │ -│ │ 2025-01-23 12:30:47.789 [WARNING] cookie_manager: 令牌过期 │ │ -│ │ 2025-01-23 12:30:48.012 [DEBUG] XianyuAutoAsync: 处理消息 │ │ -│ │ ... │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## 🔧 技术实现 - -### 核心架构:实时日志收集器 -1. **LogCollector类** - ```python - class LogCollector: - def __init__(self, max_logs: int = 2000): - self.logs = deque(maxlen=max_logs) # 循环缓冲区 - self.handler = LogHandler(self) # 自定义日志处理器 - ``` - -2. **LogHandler处理器** - ```python - class LogHandler(logging.Handler): - def emit(self, record: logging.LogRecord): - self.collector.add_log(record) # 实时收集日志 - ``` - -3. **全局集成** - - 在Start.py中初始化日志收集器 - - 自动注册到Python logging系统 - - 所有模块的日志都会被自动收集 - -### 前端实现 -1. **HTML结构** - - 添加日志管理菜单项 - - 创建日志显示页面 - - 设计过滤器和控制按钮 - - 新增统计信息模态框 - -2. **CSS样式** - - 深色主题的日志容器 - - 不同级别的颜色区分 - - 滚动条美化 - - 响应式布局 - -3. **JavaScript功能** - - 实时日志获取和显示 - - 客户端和服务器端过滤 - - 自动刷新机制 - - 统计信息展示 - - 页面切换处理 - -### 后端API接口 -1. **获取日志** - ```python - @app.get("/logs") - async def get_logs(lines: int = 200, level: str = None, source: str = None): - ``` - -2. **日志统计** - ```python - @app.get("/logs/stats") - async def get_log_stats(): - ``` - -3. **清空日志** - ```python - @app.post("/logs/clear") - async def clear_logs(): - ``` - -## 📊 实时日志格式 - -### 收集的日志信息 -```json -{ - "timestamp": "2025-01-23T12:30:45.123000", - "level": "INFO", - "source": "XianyuAutoAsync", - "function": "process_message", - "line": 1234, - "message": "处理消息成功" -} -``` - -### 日志来源 -- **XianyuAutoAsync**: 自动化核心模块 -- **reply_server**: Web服务器 -- **db_manager**: 数据库管理 -- **cookie_manager**: Cookie管理 -- **log_collector**: 日志收集器 -- **Start**: 启动模块 -- **test_***: 测试模块 - -### 支持的日志级别 -- 🔵 **DEBUG**:调试信息(蓝色) -- 🟢 **INFO**:一般信息(绿色) -- 🟡 **WARNING**:警告信息(黄色) -- 🔴 **ERROR**:错误信息(红色) -- 🟣 **CRITICAL**:严重错误(紫色,加粗) - -## 🚀 使用方法 - -### 1. 访问日志管理 -1. 登录系统后,点击左侧菜单的"日志管理" -2. 系统会自动加载最新的200条日志 -3. 日志以时间倒序显示,最新的在底部 - -### 2. 过滤日志 -- **按级别过滤**:选择特定的日志级别(如只看ERROR) -- **按来源过滤**:选择特定的模块(如只看数据库相关) -- **关键词搜索**:输入关键词实时搜索日志内容 -- **调整行数**:选择显示更多或更少的日志行数 - -### 3. 自动刷新 -1. 点击"开启自动刷新"按钮 -2. 系统每5秒自动获取最新日志 -3. 再次点击可停止自动刷新 - -### 4. 手动操作 -- **刷新**:立即获取最新日志 -- **清空显示**:清空当前显示的日志(不删除服务器数据) -- **清空服务器日志**:清空服务器内存中的所有日志 -- **统计信息**:查看详细的日志统计和分析 -- **滚动查看**:使用鼠标滚轮或滚动条查看历史日志 - -### 5. 统计分析 -- **总体统计**:查看日志总数和内存使用情况 -- **级别分布**:查看各日志级别的数量和百分比 -- **来源分布**:查看各模块的日志数量和百分比 -- **实时更新**:统计信息随日志实时更新 - -## 💾 内存日志管理 - -### 内存缓存机制 -- **循环缓冲区**:使用deque实现,最多保存2000条日志 -- **自动清理**:超出容量时自动删除最旧的日志 -- **线程安全**:使用锁机制确保多线程安全 -- **零文件依赖**:完全基于内存,无需读取日志文件 - -### 性能优势 -- **零延迟**:直接从内存获取,无文件I/O -- **实时性**:与控制台输出完全同步 -- **高效过滤**:在内存中进行过滤,速度极快 -- **低资源消耗**:内存占用可控,不会无限增长 - -## ⚠️ 注意事项 - -### 1. 性能考虑 -- 大日志文件可能影响加载速度 -- 建议设置合理的显示行数 -- 自动刷新会增加服务器负载 - -### 2. 权限要求 -- 需要管理员权限才能访问 -- 确保日志文件有读取权限 -- 网络连接稳定性影响实时性 - -### 3. 浏览器兼容性 -- 建议使用现代浏览器 -- 支持WebSocket的浏览器效果更佳 -- 移动端可能显示效果有限 - -## 🔮 未来扩展 - -### 1. 高级功能 -- 📊 **日志统计**:错误率、调用频率统计 -- 📈 **图表展示**:日志趋势图表 -- 🔔 **实时告警**:错误日志实时通知 -- 💾 **日志下载**:支持导出日志文件 - -### 2. 性能优化 -- 🚀 **WebSocket**:实时推送新日志 -- 💾 **缓存机制**:减少文件读取次数 -- 🔄 **增量更新**:只获取新增日志 -- 📦 **压缩传输**:减少网络传输量 - -### 3. 用户体验 -- 🎨 **主题切换**:支持亮色/暗色主题 -- 🔍 **高级搜索**:正则表达式搜索 -- 📌 **书签功能**:标记重要日志 -- 📱 **移动适配**:优化移动端显示 - -## 🎉 功能特色 - -### 1. 实时性 -- 自动刷新机制确保日志实时性 -- 页面切换时智能停止刷新 -- 手动刷新立即获取最新内容 - -### 2. 易用性 -- 直观的界面设计 -- 丰富的过滤选项 -- 便捷的操作按钮 - -### 3. 专业性 -- 类似IDE的日志显示效果 -- 语法高亮和颜色区分 -- 专业的等宽字体 - -### 4. 可扩展性 -- 模块化的代码结构 -- 易于添加新功能 -- 支持多种日志格式 - -## 🆚 对比:文件日志 vs 实时日志 - -| 特性 | 文件日志 | 实时日志 | -|------|---------|---------| -| **实时性** | 需要刷新文件 | 完全实时 | -| **性能** | 文件I/O开销 | 内存访问,极快 | -| **同步性** | 可能有延迟 | 与控制台同步 | -| **资源消耗** | 磁盘I/O | 内存占用 | -| **历史记录** | 永久保存 | 最新2000条 | -| **过滤效率** | 需要解析 | 内存中过滤 | -| **部署复杂度** | 需要日志文件 | 无额外依赖 | - -## 🎯 使用建议 - -### 适用场景 -- ✅ **开发调试**:实时查看程序运行状态 -- ✅ **问题排查**:快速定位错误和异常 -- ✅ **性能监控**:监控系统运行情况 -- ✅ **用户支持**:协助用户解决问题 - -### 最佳实践 -1. **开启自动刷新**:实时监控系统状态 -2. **使用过滤器**:快速找到关注的日志 -3. **查看统计信息**:了解系统整体状况 -4. **定期清空**:避免内存占用过多 - ---- - -🎉 **实时日志管理功能已完成,提供了真正的实时日志查看、过滤和分析能力!** diff --git a/自动发货功能说明.md b/自动发货功能说明.md deleted file mode 100644 index 059cdcb..0000000 --- a/自动发货功能说明.md +++ /dev/null @@ -1,265 +0,0 @@ -# 🚚 自动发货功能使用说明 - -## 📋 功能概述 - -自动发货功能可以在买家付款成功后,根据商品信息自动匹配发货规则,并发送对应的卡券内容给买家。 - -## 🎯 工作流程 - -### 1. 触发条件 -- 系统收到"等待卖家发货"消息时自动触发 -- 表示买家已完成付款,等待卖家发货 - -### 2. 商品信息获取 -``` -买家付款成功 → 提取商品ID → 调用闲鱼API → 获取商品详情 -``` - -**获取的信息包括:** -- 商品标题 -- 商品描述 -- 商品分类 -- 商品价格 -- 其他相关信息 - -### 3. 智能匹配规则 -``` -商品信息 → 组合搜索文本 → 匹配发货规则 → 选择最佳规则 -``` - -**匹配策略:** -- 优先匹配关键字长度较长的规则(更精确) -- 支持商品标题、描述、分类的综合匹配 -- 支持模糊匹配和部分匹配 - -### 4. 自动发货执行 -``` -匹配规则 → 获取卡券内容 → 发送给买家 → 更新统计 -``` - -## 🎫 卡券类型说明 - -### 1. API接口类型 -**适用场景:** 需要动态获取内容的卡券 -- 调用外部API获取实时数据 -- 支持GET/POST请求 -- 可配置请求头和参数 -- 自动处理API响应 - -**配置示例:** -```json -{ - "url": "https://api.example.com/get-card", - "method": "GET", - "timeout": 10, - "headers": "{\"Authorization\": \"Bearer token\"}", - "params": "{\"type\": \"recharge\", \"amount\": 100}" -} -``` - -### 2. 固定文字类型 -**适用场景:** 标准化的文字内容 -- 发送预设的固定文字 -- 适合使用说明、激活码等 -- 内容固定不变 - -**内容示例:** -``` -🎉 恭喜您获得VIP会员卡! - -会员卡号:VIP2024001 -有效期:2024年12月31日 - -专属权益: -✅ 免费配送 -✅ 专属客服 -✅ 会员折扣 - -感谢您的支持! -``` - -### 3. 批量数据类型 -**适用场景:** 唯一性数据,如卡密 -- 逐条消费预设数据 -- 线程安全,防止重复发送 -- 自动管理库存 - -**数据格式:** -``` -CARD001:PASS001 -CARD002:PASS002 -CARD003:PASS003 -``` - -## ⚙️ 配置步骤 - -### 1. 创建卡券 -1. 登录管理界面 -2. 点击"卡券管理"菜单 -3. 点击"添加卡券"按钮 -4. 选择卡券类型并配置相应参数 -5. 启用卡券 - -### 2. 配置发货规则 -1. 点击"自动发货"菜单 -2. 点击"添加规则"按钮 -3. 设置商品关键字 -4. 选择对应的卡券 -5. 设置发货数量 -6. 启用规则 - -### 3. 关键字配置建议 - -**商品分类关键字:** -- `手机` - 匹配手机类商品 -- `数码` - 匹配数码产品 -- `服装` - 匹配服装类商品 -- `鞋子` - 匹配鞋类商品 -- `家居` - 匹配家居用品 - -**品牌关键字:** -- `iPhone` - 匹配iPhone商品 -- `小米` - 匹配小米商品 -- `华为` - 匹配华为商品 - -**功能关键字:** -- `充值` - 匹配充值类商品 -- `会员` - 匹配会员服务 -- `游戏` - 匹配游戏相关商品 - -## 📊 统计功能 - -### 发货统计 -- 总发货次数 -- 各规则发货次数 -- 发货成功率 -- 卡券消耗情况 - -### 库存管理 -- 批量数据剩余数量 -- 卡券启用状态 -- 规则启用状态 - -## 🔧 高级配置 - -### 环境变量 -```bash -# 启用自动发货 -AUTO_DELIVERY_ENABLED=true - -# 自动发货超时时间 -AUTO_DELIVERY_TIMEOUT=30 - -# API卡券请求超时 -API_CARD_TIMEOUT=10 - -# 批量数据并发保护 -BATCH_DATA_LOCK_TIMEOUT=5 -``` - -### 日志配置 -系统会记录详细的发货日志: -- 商品信息获取日志 -- 规则匹配日志 -- 发货执行日志 -- 错误处理日志 - -## 🚨 注意事项 - -### 1. 商品ID提取 -- 系统会尝试从多个字段提取商品ID -- 如果提取失败,会使用默认ID -- 建议检查日志确认提取是否成功 - -### 2. API卡券配置 -- 确保API地址可访问 -- 检查请求头和参数格式 -- 设置合适的超时时间 -- 处理API异常情况 - -### 3. 批量数据管理 -- 定期检查数据库存 -- 及时补充批量数据 -- 监控消耗速度 -- 设置库存预警 - -### 4. 规则优先级 -- 关键字越长,优先级越高 -- 避免关键字冲突 -- 定期优化匹配规则 -- 测试匹配效果 - -## 🧪 测试方法 - -### 1. 功能测试 -```bash -python test-item-info-delivery.py -``` - -### 2. 手动测试 -1. 创建测试卡券和规则 -2. 模拟"等待卖家发货"消息 -3. 检查发货是否正常 -4. 验证统计数据更新 - -### 3. 日志检查 -查看日志文件确认: -- 商品信息获取是否成功 -- 规则匹配是否正确 -- 发货内容是否正确发送 - -## 🔍 故障排除 - -### 常见问题 - -**1. 未触发自动发货** -- 检查发货规则是否启用 -- 检查卡券是否启用 -- 检查关键字是否匹配 -- 查看错误日志 - -**2. 商品信息获取失败** -- 检查Cookie是否有效 -- 检查网络连接 -- 检查商品ID是否正确 -- 查看API调用日志 - -**3. 批量数据用完** -- 检查数据库存 -- 及时补充数据 -- 考虑使用其他类型卡券 - -**4. API卡券调用失败** -- 检查API地址和参数 -- 检查网络连接 -- 增加超时时间 -- 查看API响应日志 - -## 📈 性能优化 - -### 1. 数据库优化 -- 定期清理过期数据 -- 优化查询索引 -- 监控数据库性能 - -### 2. 网络优化 -- 设置合适的超时时间 -- 使用连接池 -- 实现重试机制 - -### 3. 并发控制 -- 批量数据消费使用锁机制 -- 避免重复发货 -- 控制并发数量 - -## 💡 最佳实践 - -1. **规则设计**:关键字要具体明确,避免过于宽泛 -2. **库存管理**:定期检查批量数据库存,及时补充 -3. **监控告警**:设置发货失败告警,及时处理异常 -4. **测试验证**:新规则上线前充分测试 -5. **日志分析**:定期分析发货日志,优化匹配规则 - ---- - -🎉 **自动发货功能让您的闲鱼店铺实现真正的自动化运营!** diff --git a/获取所有商品功能说明.md b/获取所有商品功能说明.md deleted file mode 100644 index ca70f87..0000000 --- a/获取所有商品功能说明.md +++ /dev/null @@ -1,203 +0,0 @@ -# 📦 获取所有商品功能说明 - -## 📋 功能概述 - -在商品管理界面新增了"获取所有商品"功能,支持选择指定账号,一键获取该账号下的所有商品信息,并将详细结果打印到控制台。 - -## ✨ 主要功能 - -### 1. 界面功能 -- 🎯 **账号选择**:使用现有的账号筛选下拉框选择目标账号 -- 🔄 **一键获取**:点击"获取所有商品"按钮开始获取 -- 📊 **状态显示**:按钮显示获取进度状态 -- 🔔 **结果通知**:获取完成后显示成功消息和商品数量 - -### 2. 后端功能 -- 🔐 **账号验证**:验证选中账号的有效性 -- 🔄 **自动重试**:支持token失效时自动刷新重试(最多3次) -- 📝 **详细日志**:完整的获取过程日志记录 -- 🖨️ **控制台输出**:格式化的商品信息打印到控制台 - -## 🎯 使用方法 - -### 1. 访问商品管理 -1. 登录系统后,点击左侧菜单的"商品管理" -2. 进入商品管理页面 - -### 2. 选择账号 -1. 在页面上方的"筛选账号"下拉框中选择目标账号 -2. 确保选择了有效的账号(不是"全部账号") - -### 3. 获取商品 -1. 点击"获取所有商品"按钮 -2. 按钮会显示"获取中..."状态 -3. 等待获取完成 - -### 4. 查看结果 -1. 获取完成后会显示成功消息 -2. 控制台会打印详细的商品信息 -3. 商品列表会自动刷新 - -## 🔧 技术实现 - -### 前端实现 -```javascript -// 获取所有商品信息 -async function getAllItemsFromAccount() { - const cookieSelect = document.getElementById('itemCookieFilter'); - const selectedCookieId = cookieSelect.value; - - // 调用后端API - const response = await fetch(`${apiBase}/items/get-all-from-account`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}` - }, - body: JSON.stringify({ - cookie_id: selectedCookieId - }) - }); -} -``` - -### 后端API -```python -@app.post("/items/get-all-from-account") -async def get_all_items_from_account(request: dict, _: None = Depends(require_auth)): - """从指定账号获取所有商品信息""" - # 1. 验证参数 - # 2. 获取账号信息 - # 3. 创建XianyuLive实例 - # 4. 调用get_item_list_info方法 - # 5. 返回结果 -``` - -### 核心方法 -```python -async def get_item_list_info(self, retry_count=0): - """获取商品信息,自动处理token失效的情况""" - # 1. 检查重试次数 - # 2. 刷新token(如果需要) - # 3. 构造请求参数 - # 4. 发送API请求 - # 5. 处理响应结果 - # 6. 打印商品信息到控制台 -``` - -## 📊 API参数说明 - -### 请求参数 -```json -{ - "cookie_id": "账号ID" -} -``` - -### 响应格式 -```json -{ - "success": true, - "message": "成功获取 5 个商品,详细信息已打印到控制台", - "total_count": 5 -} -``` - -### 错误响应 -```json -{ - "success": false, - "message": "错误信息" -} -``` - -## 🖨️ 控制台输出格式 - -``` -================================================================================ -📦 账号 12345678 的商品列表 (5 个商品) -================================================================================ - -🔸 商品 1: - 商品ID: 123456789 - 商品标题: iPhone 13 Pro Max 256G - 价格: 6999 - 状态: 在售 - 创建时间: 2025-07-23 10:30:00 - 更新时间: 2025-07-23 16:45:00 - 图片数量: 8 - 详细信息: { - "id": "123456789", - "title": "iPhone 13 Pro Max 256G", - "price": "6999", - "status": "在售", - ... - } - -🔸 商品 2: - ... - -================================================================================ -✅ 商品列表获取完成 -================================================================================ -``` - -## ⚠️ 注意事项 - -### 1. 使用限制 -- 需要选择有效的账号(不能是"全部账号") -- 需要账号的cookie信息有效 -- 需要网络连接正常 - -### 2. 错误处理 -- Token失效时会自动刷新重试 -- 最多重试3次,避免无限循环 -- 网络异常时会显示错误信息 - -### 3. 性能考虑 -- 获取过程可能需要几秒钟时间 -- 大量商品时控制台输出较多 -- 建议在网络良好时使用 - -## 🔮 功能特色 - -### 1. 智能重试机制 -- 自动检测token失效 -- 智能刷新token后重试 -- 避免因token问题导致的失败 - -### 2. 详细信息输出 -- 完整的商品信息展示 -- 格式化的控制台输出 -- 便于调试和分析 - -### 3. 用户友好界面 -- 直观的操作按钮 -- 实时状态反馈 -- 清晰的成功/失败提示 - -### 4. 完整的日志记录 -- 详细的操作日志 -- 错误信息记录 -- 便于问题排查 - -## 🚀 扩展可能 - -### 1. 批量操作 -- 支持多个账号批量获取 -- 导出商品信息到文件 -- 商品信息对比分析 - -### 2. 数据处理 -- 商品信息入库存储 -- 商品状态监控 -- 价格变化追踪 - -### 3. 界面优化 -- 商品信息表格显示 -- 商品图片预览 -- 筛选和搜索功能 - ---- - -🎉 **获取所有商品功能已完成,支持一键获取指定账号的所有商品信息!**