diff --git a/README.md b/README.md index 2df2ac0..d6051b6 100644 --- a/README.md +++ b/README.md @@ -75,45 +75,48 @@ ``` xianyu-auto-reply/ ├── 📄 核心文件 -│ ├── Start.py # 项目启动入口 -│ ├── XianyuAutoAsync.py # 闲鱼WebSocket连接和消息处理 -│ ├── reply_server.py # FastAPI Web服务器和API接口 -│ ├── db_manager.py # SQLite数据库管理 -│ ├── cookie_manager.py # Cookie和账号管理 -│ ├── ai_reply_engine.py # AI智能回复引擎 -│ ├── file_log_collector.py # 日志收集和管理 -│ ├── config.py # 配置文件管理 -│ └── secure_confirm_ultra.py # 自动确认发货模块(超级加密保护) +│ ├── Start.py # 项目启动入口,初始化所有服务 +│ ├── XianyuAutoAsync.py # 闲鱼WebSocket连接和消息处理核心 +│ ├── reply_server.py # FastAPI Web服务器和完整API接口 +│ ├── db_manager.py # SQLite数据库管理,支持多用户数据隔离 +│ ├── cookie_manager.py # 多账号Cookie管理和任务调度 +│ ├── ai_reply_engine.py # AI智能回复引擎,支持多种AI模型 +│ ├── file_log_collector.py # 实时日志收集和管理系统 +│ ├── config.py # 全局配置文件管理器 +│ └── secure_confirm_ultra.py # 自动确认发货模块(多层加密保护) ├── 🛠️ 工具模块 │ └── utils/ -│ ├── xianyu_utils.py # 闲鱼API工具函数 -│ ├── message_utils.py # 消息格式化工具 -│ ├── ws_utils.py # WebSocket客户端工具 -│ └── item_search.py # 商品搜索功能 +│ ├── xianyu_utils.py # 闲鱼API工具函数(加密、签名、解析) +│ ├── message_utils.py # 消息格式化和处理工具 +│ ├── ws_utils.py # WebSocket客户端封装 +│ ├── qr_login.py # 二维码登录功能 +│ ├── item_search.py # 商品搜索功能(基于Playwright) +│ └── order_detail_fetcher.py # 订单详情获取工具 ├── 🌐 前端界面 │ └── static/ -│ ├── index.html # 主管理界面 +│ ├── index.html # 主管理界面(账号管理、系统监控) │ ├── login.html # 用户登录页面 -│ ├── register.html # 用户注册页面 -│ ├── user_management.html # 用户管理页面 -│ ├── data_management.html # 数据管理页面 -│ ├── log_management.html # 日志管理页面 -│ ├── item_search.html # 商品搜索页面 -│ └── lib/ # 前端依赖库 +│ ├── register.html # 用户注册页面(邮箱验证) +│ ├── user_management.html # 用户管理页面(管理员功能) +│ ├── data_management.html # 数据管理页面(导入导出) +│ ├── log_management.html # 日志管理页面(实时日志查看) +│ ├── item_search.html # 商品搜索页面(真实数据获取) +│ ├── xianyu_js_version_2.js # 闲鱼JavaScript工具库 +│ └── lib/ # 前端依赖库(Bootstrap等) ├── 🐳 Docker部署 │ ├── Dockerfile # Docker镜像构建文件 -│ ├── docker-compose.yml # Docker Compose配置 -│ ├── docker-deploy.sh # Docker部署脚本 -│ ├── .env # 环境变量配置文件 +│ ├── docker-compose.yml # Docker Compose一键部署配置 +│ ├── docker-deploy.sh # Docker部署管理脚本 │ └── nginx/ # Nginx反向代理配置 ├── 📋 配置文件 -│ ├── global_config.yml # 全局配置文件 -│ ├── requirements.txt # Python依赖包 +│ ├── global_config.yml # 全局配置文件(WebSocket、API等) +│ ├── requirements.txt # Python依赖包列表 │ └── README.md # 项目说明文档 └── 📊 数据目录 - ├── data/ # 数据库和数据文件 - ├── logs/ # 日志文件 - └── backups/ # 备份文件 + ├── xianyu_data.db # SQLite数据库文件 + ├── logs/ # 按日期分割的日志文件 + ├── backups/ # 数据备份文件 + └── realtime.log # 实时日志文件 ``` ## 🚀 快速开始 diff --git a/XianyuAutoAsync.py b/XianyuAutoAsync.py index 834cfb8..7d71fa9 100644 --- a/XianyuAutoAsync.py +++ b/XianyuAutoAsync.py @@ -257,12 +257,13 @@ class XianyuLive: # 发送Cookie更新失败通知 await self.send_token_refresh_notification(f"Cookie更新失败: {str(e)}", "cookie_update_failed") - async def save_item_info_to_db(self, item_id: str, item_detail: str = None): + async def save_item_info_to_db(self, item_id: str, item_detail: str = None, item_title: str = None): """保存商品信息到数据库 Args: item_id: 商品ID item_detail: 商品详情内容(可以是任意格式的文本) + item_title: 商品标题 """ try: # 跳过以 auto_ 开头的商品ID @@ -270,6 +271,16 @@ class XianyuLive: logger.debug(f"跳过保存自动生成的商品ID: {item_id}") return + # 验证:如果只有商品ID,没有商品标题和商品详情,则不插入数据库 + if not item_title and not item_detail: + logger.debug(f"跳过保存商品信息:缺少商品标题和详情 - {item_id}") + return + + # 如果有商品标题但没有详情,也跳过(根据需求,需要同时有标题和详情) + if not item_title or not item_detail: + logger.debug(f"跳过保存商品信息:商品标题或详情不完整 - {item_id}") + return + from db_manager import db_manager # 直接使用传入的详情内容 @@ -1288,8 +1299,22 @@ class XianyuLive: # 使用第一个匹配的规则(按关键字长度降序排列,优先匹配更精确的规则) rule = delivery_rules[0] - # 保存商品信息到数据库 - await self.save_item_info_to_db(item_id, search_text) + # 保存商品信息到数据库(需要有商品标题才保存) + # 尝试获取商品标题 + item_title_for_save = None + try: + from db_manager import db_manager + db_item_info = db_manager.get_item_info(self.cookie_id, item_id) + if db_item_info: + item_title_for_save = db_item_info.get('item_title', '').strip() + except: + pass + + # 如果有商品标题,则保存商品信息 + if item_title_for_save: + await self.save_item_info_to_db(item_id, search_text, item_title_for_save) + else: + logger.debug(f"跳过保存商品信息:缺少商品标题 - {item_id}") # 详细的匹配结果日志 if rule.get('is_multi_spec'): @@ -2457,9 +2482,8 @@ class XianyuLive: reply = await self.get_default_reply(send_user_name, send_user_id, send_message) reply_source = '默认' # 标记为默认回复 - # 保存商品信息到数据库(记录通知时传递的item_id) - if item_id: - await self.save_item_info_to_db(item_id, None) + # 注意:这里只有商品ID,没有标题和详情,根据新的规则不保存到数据库 + # 商品信息会在其他有完整信息的地方保存(如发货规则匹配时) # 发送通知 await self.send_notification(send_user_name, send_user_id, send_message, item_id) diff --git a/db_manager.py b/db_manager.py index babe2de..d13b771 100644 --- a/db_manager.py +++ b/db_manager.py @@ -125,7 +125,6 @@ class DBManager: keyword TEXT, reply TEXT, item_id TEXT, - PRIMARY KEY (cookie_id, keyword), FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE ) ''') @@ -434,6 +433,10 @@ class DBManager: self._execute_sql(cursor, "ALTER TABLE item_info ADD COLUMN is_multi_spec BOOLEAN DEFAULT FALSE") logger.info("为item_info表添加多规格字段") + # 处理keywords表的唯一约束问题 + # 由于SQLite不支持直接修改约束,我们需要重建表 + self._migrate_keywords_table_constraints(cursor) + self.conn.commit() logger.info(f"数据库初始化成功: {self.db_path}") except Exception as e: @@ -442,6 +445,66 @@ class DBManager: self.conn.close() raise + def _migrate_keywords_table_constraints(self, cursor): + """迁移keywords表的约束,支持基于商品ID的唯一性校验""" + try: + # 检查是否已经迁移过(通过检查是否存在新的唯一索引) + cursor.execute("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_keywords_unique_with_item'") + if cursor.fetchone(): + logger.info("keywords表约束已经迁移过,跳过") + return + + logger.info("开始迁移keywords表约束...") + + # 1. 创建临时表,不设置主键约束 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS keywords_temp ( + cookie_id TEXT, + keyword TEXT, + reply TEXT, + item_id TEXT, + FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE + ) + ''') + + # 2. 复制现有数据到临时表 + cursor.execute(''' + INSERT INTO keywords_temp (cookie_id, keyword, reply, item_id) + SELECT cookie_id, keyword, reply, item_id FROM keywords + ''') + + # 3. 删除原表 + cursor.execute('DROP TABLE keywords') + + # 4. 重命名临时表 + cursor.execute('ALTER TABLE keywords_temp RENAME TO keywords') + + # 5. 创建复合唯一索引来实现我们需要的约束逻辑 + # 对于item_id为空的情况:(cookie_id, keyword)必须唯一 + cursor.execute(''' + CREATE UNIQUE INDEX idx_keywords_unique_no_item + ON keywords(cookie_id, keyword) + WHERE item_id IS NULL OR item_id = '' + ''') + + # 对于item_id不为空的情况:(cookie_id, keyword, item_id)必须唯一 + cursor.execute(''' + CREATE UNIQUE INDEX idx_keywords_unique_with_item + ON keywords(cookie_id, keyword, item_id) + WHERE item_id IS NOT NULL AND item_id != '' + ''') + + logger.info("keywords表约束迁移完成") + + except Exception as e: + logger.error(f"迁移keywords表约束失败: {e}") + # 如果迁移失败,尝试回滚 + try: + cursor.execute('DROP TABLE IF EXISTS keywords_temp') + except: + pass + raise + def close(self): """关闭数据库连接""" if self.conn: @@ -672,11 +735,20 @@ class DBManager: # 先删除该cookie_id的所有关键字 self._execute_sql(cursor, "DELETE FROM keywords WHERE cookie_id = ?", (cookie_id,)) - # 插入新关键字 + # 插入新关键字,使用INSERT OR REPLACE来处理可能的唯一约束冲突 for keyword, reply, item_id in keywords: - self._execute_sql(cursor, - "INSERT INTO keywords (cookie_id, keyword, reply, item_id) VALUES (?, ?, ?, ?)", - (cookie_id, keyword, reply, item_id)) + # 标准化item_id:空字符串转为NULL + normalized_item_id = item_id if item_id and item_id.strip() else None + + try: + self._execute_sql(cursor, + "INSERT INTO keywords (cookie_id, keyword, reply, item_id) VALUES (?, ?, ?, ?)", + (cookie_id, keyword, reply, normalized_item_id)) + except sqlite3.IntegrityError as ie: + # 如果遇到唯一约束冲突,记录详细错误信息 + item_desc = f"商品ID: {normalized_item_id}" if normalized_item_id else "通用关键词" + logger.error(f"关键词唯一约束冲突: Cookie={cookie_id}, 关键词='{keyword}', {item_desc}") + raise ie self.conn.commit() logger.info(f"关键字保存成功: {cookie_id}, {len(keywords)}条") @@ -2563,6 +2635,23 @@ class DBManager: bool: 操作是否成功 """ try: + # 验证:如果只有商品ID,没有商品详情数据,则不插入数据库 + if not item_data: + logger.debug(f"跳过保存商品信息:缺少商品详情数据 - {item_id}") + return False + + # 如果是字典类型,检查是否有标题信息 + if isinstance(item_data, dict): + title = item_data.get('title', '').strip() + if not title: + logger.debug(f"跳过保存商品信息:缺少商品标题 - {item_id}") + return False + + # 如果是字符串类型,检查是否为空 + if isinstance(item_data, str) and not item_data.strip(): + logger.debug(f"跳过保存商品信息:商品详情为空 - {item_id}") + return False + with self.lock: cursor = self.conn.cursor() @@ -2633,6 +2722,7 @@ class DBManager: except Exception as e: logger.error(f"保存商品信息失败: {e}") + self.conn.rollback() return False def get_item_info(self, cookie_id: str, item_id: str) -> Optional[Dict]: @@ -2897,6 +2987,11 @@ class DBManager: if not cookie_id or not item_id: continue + # 验证:如果没有商品标题,则跳过保存 + if not item_title or not item_title.strip(): + logger.debug(f"跳过批量保存商品信息:缺少商品标题 - {item_id}") + continue + # 使用 INSERT OR IGNORE + UPDATE 模式 cursor.execute(''' INSERT OR IGNORE INTO item_info (cookie_id, item_id, item_title, item_description, diff --git a/reply_server.py b/reply_server.py index e4b5501..5b313ad 100644 --- a/reply_server.py +++ b/reply_server.py @@ -19,6 +19,8 @@ import cookie_manager from db_manager import db_manager from file_log_collector import setup_file_logging, get_file_log_collector from ai_reply_engine import ai_reply_engine +from utils.qr_login import qr_login_manager +from utils.xianyu_utils import trans_cookies from loguru import logger # 关键字文件路径 @@ -966,6 +968,121 @@ def update_cookie(cid: str, item: CookieIn, current_user: Dict[str, Any] = Depen raise HTTPException(status_code=400, detail=str(e)) +# ========================= 扫码登录相关接口 ========================= + +@app.post("/qr-login/generate") +async def generate_qr_code(current_user: Dict[str, Any] = Depends(get_current_user)): + """生成扫码登录二维码""" + try: + log_with_user('info', "请求生成扫码登录二维码", current_user) + + result = await qr_login_manager.generate_qr_code() + + if result['success']: + log_with_user('info', f"扫码登录二维码生成成功: {result['session_id']}", current_user) + else: + log_with_user('warning', f"扫码登录二维码生成失败: {result.get('message', '未知错误')}", current_user) + + return result + + except Exception as e: + log_with_user('error', f"生成扫码登录二维码异常: {str(e)}", current_user) + return {'success': False, 'message': f'生成二维码失败: {str(e)}'} + + +@app.get("/qr-login/check/{session_id}") +async def check_qr_code_status(session_id: str, current_user: Dict[str, Any] = Depends(get_current_user)): + """检查扫码登录状态""" + try: + # 清理过期会话 + qr_login_manager.cleanup_expired_sessions() + + # 获取会话状态 + status_info = qr_login_manager.get_session_status(session_id) + + if status_info['status'] == 'success': + # 登录成功,处理Cookie + cookies_info = qr_login_manager.get_session_cookies(session_id) + if cookies_info: + account_info = await process_qr_login_cookies( + cookies_info['cookies'], + cookies_info['unb'], + current_user + ) + status_info['account_info'] = account_info + + log_with_user('info', f"扫码登录成功处理完成: {session_id}, 账号: {account_info.get('account_id', 'unknown')}", current_user) + + return status_info + + except Exception as e: + log_with_user('error', f"检查扫码登录状态异常: {str(e)}", current_user) + return {'status': 'error', 'message': str(e)} + + +async def process_qr_login_cookies(cookies: str, unb: str, current_user: Dict[str, Any]) -> Dict[str, Any]: + """处理扫码登录获取的Cookie""" + try: + user_id = current_user['user_id'] + + # 检查是否已存在相同unb的账号 + existing_cookies = db_manager.get_all_cookies(user_id) + existing_account_id = None + + for account_id, cookie_value in existing_cookies.items(): + try: + # 解析现有Cookie中的unb + existing_cookie_dict = trans_cookies(cookie_value) + if existing_cookie_dict.get('unb') == unb: + existing_account_id = account_id + break + except: + continue + + if existing_account_id: + # 更新现有账号的Cookie + db_manager.save_cookie(existing_account_id, cookies, user_id) + + # 更新cookie_manager中的Cookie + if cookie_manager.manager: + cookie_manager.manager.update_cookie(existing_account_id, cookies) + + log_with_user('info', f"扫码登录更新现有账号Cookie: {existing_account_id}, UNB: {unb}", current_user) + + return { + 'account_id': existing_account_id, + 'is_new_account': False + } + else: + # 创建新账号,使用unb作为账号ID + account_id = unb + + # 确保账号ID唯一 + counter = 1 + original_account_id = account_id + while account_id in existing_cookies: + account_id = f"{original_account_id}_{counter}" + counter += 1 + + # 保存新账号 + db_manager.save_cookie(account_id, cookies, user_id) + + # 添加到cookie_manager + if cookie_manager.manager: + cookie_manager.manager.add_cookie(account_id, cookies) + + log_with_user('info', f"扫码登录创建新账号: {account_id}, UNB: {unb}", current_user) + + return { + 'account_id': account_id, + 'is_new_account': True + } + + except Exception as e: + log_with_user('error', f"处理扫码登录Cookie失败: {str(e)}", current_user) + raise e + + @app.put('/cookies/{cid}/status') def update_cookie_status(cid: str, status_data: CookieStatusIn, current_user: Dict[str, Any] = Depends(get_current_user)): """更新账号的启用/禁用状态""" @@ -1403,7 +1520,20 @@ def get_keywords(cid: str, current_user: Dict[str, Any] = Depends(get_current_us if cid not in user_cookies: raise HTTPException(status_code=403, detail="无权限访问该Cookie") - return cookie_manager.manager.get_keywords(cid) + # 直接从数据库获取所有关键词(避免重复计算) + item_keywords = db_manager.get_keywords_with_item_id(cid) + + # 转换为统一格式 + all_keywords = [] + for keyword, reply, item_id in item_keywords: + all_keywords.append({ + "keyword": keyword, + "reply": reply, + "item_id": item_id, + "type": "item" if item_id else "normal" + }) + + return all_keywords @app.get("/keywords-with-item-id/{cid}") diff --git a/requirements.txt b/requirements.txt index 0b6418a..b914198 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,7 @@ loguru>=0.7.0 websockets>=10.0,<13.0 aiohttp>=3.9.0 requests>=2.31.0 +httpx>=0.25.0 # 配置文件处理 PyYAML>=6.0.0 @@ -34,8 +35,9 @@ python-multipart>=0.0.6 # AI回复引擎 openai>=1.65.5 -# 图像处理(验证码生成) +# 图像处理(验证码生成、二维码生成) Pillow>=10.0.0 +qrcode[pil]>=7.4.2 # 浏览器自动化(商品搜索、订单详情获取) playwright>=1.40.0 @@ -55,7 +57,13 @@ regex>=2023.10.3 pandas>=2.0.0 openpyxl>=3.1.0 +# 邮件发送(用户注册验证) +email-validator>=2.0.0 + # 其他工具库 typing-extensions>=4.7.0 -# 注意:sqlite3 是Python内置模块,无需安装 \ No newline at end of file +# 注意: +# - sqlite3 是Python内置模块,无需安装 +# - smtplib 是Python内置模块,无需安装 +# - email 是Python内置模块,无需安装 \ No newline at end of file diff --git a/static/index.html b/static/index.html index 9110fd0..ce91944 100644 --- a/static/index.html +++ b/static/index.html @@ -1100,6 +1100,112 @@ text-overflow: ellipsis; white-space: nowrap; } + + /* 扫码登录按钮特殊样式 */ + .qr-login-btn { + background: linear-gradient(135deg, #28a745 0%, #20c997 100%); + border: none; + box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3); + transition: all 0.3s ease; + position: relative; + overflow: hidden; + } + + .qr-login-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4); + background: linear-gradient(135deg, #218838 0%, #1ea085 100%); + } + + .qr-login-btn:active { + transform: translateY(0); + } + + .qr-login-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); + transition: left 0.5s; + } + + .qr-login-btn:hover::before { + left: 100%; + } + + /* 二维码容器样式 */ + .qr-code-wrapper { + border: 3px solid #28a745; + box-shadow: 0 4px 15px rgba(40, 167, 69, 0.2); + transition: all 0.3s ease; + } + + .qr-code-wrapper:hover { + box-shadow: 0 6px 20px rgba(40, 167, 69, 0.3); + } + + /* 步骤指引样式 */ + .step-item { + text-align: center; + } + + .step-number { + font-weight: bold; + font-size: 14px; + box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3); + } + + /* 手动输入按钮样式 */ + .manual-input-btn { + transition: all 0.3s ease; + } + + .manual-input-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(108, 117, 125, 0.2); + } + + /* 等待提示样式 */ + .bg-light-warning { + background-color: #fff3cd !important; + } + + .qr-loading-tip { + animation: pulse-warning 2s infinite; + } + + @keyframes pulse-warning { + 0% { + opacity: 0.8; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.8; + } + } + + /* 响应式调整 */ + @media (max-width: 768px) { + .qr-login-btn, .manual-input-btn { + margin-bottom: 10px; + font-size: 0.9rem; + } + + .step-item { + margin-bottom: 15px; + } + + .step-number { + width: 25px !important; + height: 25px !important; + font-size: 12px; + } + } @@ -1300,25 +1406,55 @@
-
+
添加新账号
-
-
- - -
-
- - -
+ +
- +
+ + +
- +
+ + +
@@ -2064,6 +2200,85 @@
+ + +