From 20e2dcdc96cae46107bb41421599c79296653595 Mon Sep 17 00:00:00 2001 From: zhinianboke <115088296+zhinianboke@users.noreply.github.com> Date: Wed, 30 Jul 2025 09:26:06 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=BB=B6=E6=97=B6=E5=8F=91?= =?UTF-8?q?=E8=B4=A7=EF=BC=8C=E5=AE=8C=E5=96=84=E8=87=AA=E5=8A=A8=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 40 ++++- Dockerfile | 4 + README.md | 80 ++++++++- XianyuAutoAsync.py | 45 ++++-- db_manager.py | 108 ++++++++++--- reply_server.py | 297 +++++++++++++++++++++++++++++++++- requirements.txt | 6 +- static/index.html | 367 +++++++++++++++++++++++++++++++++++++----- utils/xianyu_utils.py | 31 +++- 9 files changed, 904 insertions(+), 74 deletions(-) diff --git a/.gitignore b/.gitignore index 94e245b..aac865b 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,42 @@ Thumbs.db *~ # Local environment files - .env.local -.env.*.local \ No newline at end of file +.env.*.local + +# ==================== 项目特定文件 ==================== +# 日志文件 +logs/ +realtime.log + +# 数据目录 +data/ +backups/ + +# Excel测试文件 +keywords_*.xlsx +*.xls + +# 图片缓存 +*.png.cache +*.jpg.cache + +# 配置文件(包含敏感信息) +config.local.yml +global_config.local.yml + +# 测试文件 +test_*.py +*_test.py +keywords_sample.xlsx + +# 备份文件 +*.bak +*.backup +*.old + +# 压缩文件 +*.zip +*.tar.gz +*.rar +*.7z \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index d402246..15a5c4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,6 +76,10 @@ RUN apt-get update && \ # 设置时区 RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone +# 验证Node.js安装并设置环境变量 +RUN node --version && npm --version +ENV NODE_PATH=/usr/lib/node_modules + # 复制requirements.txt并安装Python依赖 COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip && \ diff --git a/README.md b/README.md index c283254..52620cb 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,20 @@ ### 🤖 智能回复系统 - **关键词匹配** - 支持精确关键词匹配回复 +- **商品专用回复** - 支持为特定商品设置专用关键词回复 +- **通用关键词** - 支持全局通用关键词,适用于所有商品 +- **批量导入导出** - 支持Excel格式的关键词批量导入导出 - **AI智能回复** - 集成OpenAI API,支持上下文理解 - **变量替换** - 回复内容支持动态变量(用户名、商品信息等) -- **优先级策略** - 关键词回复优先,AI回复兜底 +- **优先级策略** - 商品专用关键词 > 通用关键词 > AI回复 ### 🚚 自动发货功能 - **智能匹配** - 基于商品信息自动匹配发货规则 +- **延时发货** - 支持设置发货延时时间(0-3600秒) - **多种触发** - 支持付款消息、小刀消息等多种触发条件 - **防重复发货** - 智能防重复机制,避免重复发货 -- **卡密发货** - 支持文本内容和卡密文件发货 +- **多种发货方式** - 支持文本内容、卡密文件、API调用等发货方式 +- **发货统计** - 完整的发货记录和统计功能 ### 🛍️ 商品管理 - **自动收集** - 消息触发时自动收集商品信息 @@ -44,12 +49,63 @@ - **前端分页** - 灵活的前端分页显示 - **商品详情** - 支持查看完整商品详情信息 -### �📊 系统监控 +### 📊 系统监控 - **实时日志** - 完整的操作日志记录和查看 - **性能监控** - 系统资源使用情况监控 - **健康检查** - 服务状态健康检查 + +### 📁 数据管理 +- **Excel导入导出** - 支持关键词数据的Excel格式导入导出 +- **模板生成** - 自动生成包含示例数据的导入模板 +- **批量操作** - 支持批量添加、更新关键词数据 +- **数据验证** - 导入时自动验证数据格式和重复性 - **数据备份** - 自动数据备份和恢复 +## 📁 项目结构 + +``` +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 # 配置文件管理 +├── 🛠️ 工具模块 +│ └── utils/ +│ ├── xianyu_utils.py # 闲鱼API工具函数 +│ ├── message_utils.py # 消息格式化工具 +│ ├── ws_utils.py # WebSocket客户端工具 +│ └── item_search.py # 商品搜索功能 +├── 🌐 前端界面 +│ └── static/ +│ ├── index.html # 主管理界面 +│ ├── login.html # 用户登录页面 +│ ├── register.html # 用户注册页面 +│ ├── user_management.html # 用户管理页面 +│ ├── data_management.html # 数据管理页面 +│ ├── log_management.html # 日志管理页面 +│ ├── item_search.html # 商品搜索页面 +│ └── lib/ # 前端依赖库 +├── 🐳 Docker部署 +│ ├── Dockerfile # Docker镜像构建文件 +│ ├── docker-compose.yml # Docker Compose配置 +│ ├── docker-deploy.sh # Docker部署脚本 +│ ├── .env # 环境变量配置文件 +│ └── nginx/ # Nginx反向代理配置 +├── 📋 配置文件 +│ ├── global_config.yml # 全局配置文件 +│ ├── requirements.txt # Python依赖包 +│ └── README.md # 项目说明文档 +└── 📊 数据目录 + ├── data/ # 数据库和数据文件 + ├── logs/ # 日志文件 + └── backups/ # 备份文件 +``` + ## 🚀 快速开始 ### 方式一:Docker 一键部署(最简单) @@ -351,6 +407,24 @@ exit docker restart xianyu-auto-reply ``` +**2. JavaScript运行时错误** +```bash +# 错误信息:Could not find an available JavaScript runtime + +# 解决方案1:重新构建镜像(推荐) +docker-compose down +docker-compose build --no-cache +docker-compose up -d + +# 解决方案2:手动修复 +docker exec -it xianyu-auto-reply bash +apt-get update +apt-get install -y nodejs npm +python fix_js_runtime.py +exit +docker restart xianyu-auto-reply +``` + **2. 其他问题排查** 1. 查看日志:`docker-compose logs -f` 2. 检查状态:`./docker-deploy.sh status` diff --git a/XianyuAutoAsync.py b/XianyuAutoAsync.py index 8364f8b..7ccc65c 100644 --- a/XianyuAutoAsync.py +++ b/XianyuAutoAsync.py @@ -752,21 +752,39 @@ class XianyuLive: logger.error(f"获取默认回复失败: {self._safe_str(e)}") return None - async def get_keyword_reply(self, send_user_name: str, send_user_id: str, send_message: str) -> str: - """获取关键词匹配回复""" + async def get_keyword_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str = None) -> str: + """获取关键词匹配回复(支持商品ID优先匹配)""" try: from db_manager import db_manager - # 获取当前账号的关键词列表 - keywords = db_manager.get_keywords(self.cookie_id) + # 获取当前账号的关键词列表(包含商品ID) + keywords = db_manager.get_keywords_with_item_id(self.cookie_id) if not keywords: logger.debug(f"账号 {self.cookie_id} 没有配置关键词") return None - # 遍历关键词,查找匹配 - for keyword, reply in keywords: - if keyword.lower() in send_message.lower(): + # 1. 如果有商品ID,优先匹配该商品ID对应的关键词 + if item_id: + for keyword, reply, keyword_item_id in keywords: + if keyword_item_id == item_id and 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 + ) + logger.info(f"商品ID关键词匹配成功: 商品{item_id} '{keyword}' -> {formatted_reply}") + return formatted_reply + except Exception as format_error: + logger.error(f"关键词回复变量替换失败: {self._safe_str(format_error)}") + # 如果变量替换失败,返回原始内容 + return reply + + # 2. 如果商品ID匹配失败或没有商品ID,匹配没有商品ID的通用关键词 + for keyword, reply, keyword_item_id in keywords: + if not keyword_item_id and keyword.lower() in send_message.lower(): # 进行变量替换 try: formatted_reply = reply.format( @@ -774,7 +792,7 @@ class XianyuLive: send_user_id=send_user_id, send_message=send_message ) - logger.info(f"关键词匹配成功: '{keyword}' -> {formatted_reply}") + logger.info(f"通用关键词匹配成功: '{keyword}' -> {formatted_reply}") return formatted_reply except Exception as format_error: logger.error(f"关键词回复变量替换失败: {self._safe_str(format_error)}") @@ -1164,6 +1182,13 @@ class XianyuLive: rule = delivery_rules[0] logger.info(f"找到匹配的发货规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})") + # 检查是否需要延时发货 + delay_seconds = rule.get('card_delay_seconds', 0) + if delay_seconds and delay_seconds > 0: + logger.info(f"检测到延时发货设置: {delay_seconds}秒,开始延时...") + await asyncio.sleep(delay_seconds) + logger.info(f"延时发货完成,开始发送内容") + delivery_content = None # 根据卡券类型处理发货内容 @@ -2099,8 +2124,8 @@ class XianyuLive: # 如果API回复失败或未启用API,按新的优先级顺序处理 if not reply: - # 1. 首先尝试关键词匹配 - reply = await self.get_keyword_reply(send_user_name, send_user_id, send_message) + # 1. 首先尝试关键词匹配(传入商品ID) + reply = await self.get_keyword_reply(send_user_name, send_user_id, send_message, item_id) if reply: reply_source = '关键词' # 标记为关键词回复 else: diff --git a/db_manager.py b/db_manager.py index cb80516..0959903 100644 --- a/db_manager.py +++ b/db_manager.py @@ -123,6 +123,7 @@ class DBManager: cookie_id TEXT, keyword TEXT, reply TEXT, + item_id TEXT, PRIMARY KEY (cookie_id, keyword), FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE ) @@ -195,6 +196,7 @@ class DBManager: data_content TEXT, description TEXT, enabled BOOLEAN DEFAULT TRUE, + delay_seconds INTEGER DEFAULT 0, user_id INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -212,6 +214,24 @@ class DBManager: self._execute_sql(cursor, "CREATE INDEX IF NOT EXISTS idx_cards_user_id ON cards(user_id)") logger.info("cards 表 user_id 列添加完成") + # 检查并添加 delay_seconds 列(用于自动发货延时功能) + try: + self._execute_sql(cursor, "SELECT delay_seconds FROM cards LIMIT 1") + except sqlite3.OperationalError: + # delay_seconds 列不存在,需要添加 + logger.info("正在为 cards 表添加 delay_seconds 列...") + self._execute_sql(cursor, "ALTER TABLE cards ADD COLUMN delay_seconds INTEGER DEFAULT 0") + logger.info("cards 表 delay_seconds 列添加完成") + + # 检查并添加 item_id 列(用于自动回复商品ID功能) + try: + self._execute_sql(cursor, "SELECT item_id FROM keywords LIMIT 1") + except sqlite3.OperationalError: + # item_id 列不存在,需要添加 + logger.info("正在为 keywords 表添加 item_id 列...") + self._execute_sql(cursor, "ALTER TABLE keywords ADD COLUMN item_id TEXT") + logger.info("keywords 表 item_id 列添加完成") + # 创建商品信息表 cursor.execute(''' CREATE TABLE IF NOT EXISTS item_info ( @@ -576,7 +596,13 @@ class DBManager: # -------------------- 关键字操作 -------------------- def save_keywords(self, cookie_id: str, keywords: List[Tuple[str, str]]) -> bool: - """保存关键字列表,先删除旧数据再插入新数据""" + """保存关键字列表,先删除旧数据再插入新数据(向后兼容方法)""" + # 转换为新格式(不包含item_id) + keywords_with_item_id = [(keyword, reply, None) for keyword, reply in keywords] + return self.save_keywords_with_item_id(cookie_id, keywords_with_item_id) + + def save_keywords_with_item_id(self, cookie_id: str, keywords: List[Tuple[str, str, str]]) -> bool: + """保存关键字列表(包含商品ID),先删除旧数据再插入新数据""" with self.lock: try: cursor = self.conn.cursor() @@ -585,8 +611,10 @@ class DBManager: self._execute_sql(cursor, "DELETE FROM keywords WHERE cookie_id = ?", (cookie_id,)) # 插入新关键字 - for keyword, reply in keywords: - self._execute_sql(cursor, "INSERT INTO keywords (cookie_id, keyword, reply) VALUES (?, ?, ?)", (cookie_id, keyword, reply)) + 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)) self.conn.commit() logger.info(f"关键字保存成功: {cookie_id}, {len(keywords)}条") @@ -597,7 +625,7 @@ class DBManager: return False def get_keywords(self, cookie_id: str) -> List[Tuple[str, str]]: - """获取指定Cookie的关键字列表""" + """获取指定Cookie的关键字列表(向后兼容方法)""" with self.lock: try: cursor = self.conn.cursor() @@ -606,6 +634,41 @@ class DBManager: except Exception as e: logger.error(f"获取关键字失败: {e}") return [] + + def get_keywords_with_item_id(self, cookie_id: str) -> List[Tuple[str, str, str]]: + """获取指定Cookie的关键字列表(包含商品ID)""" + with self.lock: + try: + cursor = self.conn.cursor() + self._execute_sql(cursor, "SELECT keyword, reply, item_id FROM keywords WHERE cookie_id = ?", (cookie_id,)) + return [(row[0], row[1], row[2]) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"获取关键字失败: {e}") + return [] + + def check_keyword_duplicate(self, cookie_id: str, keyword: str, item_id: str = None) -> bool: + """检查关键词是否重复""" + with self.lock: + try: + cursor = self.conn.cursor() + if item_id: + # 如果有商品ID,检查相同cookie_id、keyword、item_id的组合 + self._execute_sql(cursor, + "SELECT COUNT(*) FROM keywords WHERE cookie_id = ? AND keyword = ? AND item_id = ?", + (cookie_id, keyword, item_id)) + else: + # 如果没有商品ID,检查相同cookie_id、keyword且item_id为空的组合 + self._execute_sql(cursor, + "SELECT COUNT(*) FROM keywords WHERE cookie_id = ? AND keyword = ? AND (item_id IS NULL OR item_id = '')", + (cookie_id, keyword)) + + count = cursor.fetchone()[0] + return count > 0 + except Exception as e: + logger.error(f"检查关键词重复失败: {e}") + return False + + def get_all_keywords(self, user_id: int = None) -> Dict[str, List[Tuple[str, str]]]: """获取所有Cookie的关键字(支持用户隔离)""" @@ -1637,7 +1700,7 @@ class DBManager: def create_card(self, name: str, card_type: str, api_config=None, text_content: str = None, data_content: str = None, - description: str = None, enabled: bool = True, user_id: int = None): + description: str = None, enabled: bool = True, delay_seconds: int = 0, user_id: int = None): """创建新卡券""" with self.lock: try: @@ -1653,10 +1716,10 @@ class DBManager: cursor = self.conn.cursor() cursor.execute(''' INSERT INTO cards (name, type, api_config, text_content, data_content, - description, enabled, user_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + description, enabled, delay_seconds, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', (name, card_type, api_config_str, text_content, data_content, - description, enabled, user_id)) + description, enabled, delay_seconds, user_id)) self.conn.commit() card_id = cursor.lastrowid logger.info(f"创建卡券成功: {name} (ID: {card_id})") @@ -1673,7 +1736,7 @@ class DBManager: if user_id is not None: cursor.execute(''' SELECT id, name, type, api_config, text_content, data_content, - description, enabled, created_at, updated_at + description, enabled, delay_seconds, created_at, updated_at FROM cards WHERE user_id = ? ORDER BY created_at DESC @@ -1681,7 +1744,7 @@ class DBManager: else: cursor.execute(''' SELECT id, name, type, api_config, text_content, data_content, - description, enabled, created_at, updated_at + description, enabled, delay_seconds, created_at, updated_at FROM cards ORDER BY created_at DESC ''') @@ -1707,8 +1770,9 @@ class DBManager: 'data_content': row[5], 'description': row[6], 'enabled': bool(row[7]), - 'created_at': row[8], - 'updated_at': row[9] + 'delay_seconds': row[8] or 0, + 'created_at': row[9], + 'updated_at': row[10] }) return cards @@ -1724,13 +1788,13 @@ class DBManager: if user_id is not None: cursor.execute(''' SELECT id, name, type, api_config, text_content, data_content, - description, enabled, created_at, updated_at + description, enabled, delay_seconds, created_at, updated_at FROM cards WHERE id = ? AND user_id = ? ''', (card_id, user_id)) else: cursor.execute(''' SELECT id, name, type, api_config, text_content, data_content, - description, enabled, created_at, updated_at + description, enabled, delay_seconds, created_at, updated_at FROM cards WHERE id = ? ''', (card_id,)) @@ -1755,8 +1819,9 @@ class DBManager: 'data_content': row[5], 'description': row[6], 'enabled': bool(row[7]), - 'created_at': row[8], - 'updated_at': row[9] + 'delay_seconds': row[8] or 0, + 'created_at': row[9], + 'updated_at': row[10] } return None except Exception as e: @@ -1765,7 +1830,7 @@ class DBManager: def update_card(self, card_id: int, name: str = None, card_type: str = None, api_config=None, text_content: str = None, data_content: str = None, - description: str = None, enabled: bool = None): + description: str = None, enabled: bool = None, delay_seconds: int = None): """更新卡券""" with self.lock: try: @@ -1805,6 +1870,9 @@ class DBManager: if enabled is not None: update_fields.append("enabled = ?") params.append(enabled) + if delay_seconds is not None: + update_fields.append("delay_seconds = ?") + params.append(delay_seconds) if not update_fields: return True # 没有需要更新的字段 @@ -1903,7 +1971,8 @@ 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.description as card_description + c.text_content, c.data_content, c.enabled as card_enabled, c.description as card_description, + c.delay_seconds as card_delay_seconds FROM delivery_rules dr LEFT JOIN cards c ON dr.card_id = c.id WHERE dr.enabled = 1 AND c.enabled = 1 @@ -1942,7 +2011,8 @@ class DBManager: 'card_text_content': row[10], 'card_data_content': row[11], 'card_enabled': bool(row[12]), - 'card_description': row[13] # 卡券备注信息 + 'card_description': row[13], # 卡券备注信息 + 'card_delay_seconds': row[14] or 0 # 延时秒数 }) return rules diff --git a/reply_server.py b/reply_server.py index f45531c..a0b3f11 100644 --- a/reply_server.py +++ b/reply_server.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, HTTPException, Depends, status, UploadFile, File from fastapi.staticfiles import StaticFiles -from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse +from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel from typing import List, Tuple, Optional, Dict, Any @@ -12,6 +12,8 @@ import time import json import os import uvicorn +import pandas as pd +import io import cookie_manager from db_manager import db_manager @@ -1318,6 +1320,9 @@ def remove_cookie(cid: str, current_user: Dict[str, Any] = Depends(get_current_u class KeywordIn(BaseModel): keywords: Dict[str, str] # key -> reply +class KeywordWithItemIdIn(BaseModel): + keywords: List[Dict[str, Any]] # [{"keyword": str, "reply": str, "item_id": str}] + @app.get("/keywords/{cid}") def get_keywords(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)): @@ -1335,6 +1340,35 @@ def get_keywords(cid: str, current_user: Dict[str, Any] = Depends(get_current_us return cookie_manager.manager.get_keywords(cid) +@app.get("/keywords-with-item-id/{cid}") +def get_keywords_with_item_id(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)): + """获取包含商品ID的关键词列表""" + if cookie_manager.manager is None: + raise HTTPException(status_code=500, detail="CookieManager 未就绪") + + # 检查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") + + # 获取包含商品ID的关键词 + keywords = db_manager.get_keywords_with_item_id(cid) + + # 转换为前端需要的格式 + result = [] + for keyword, reply, item_id in keywords: + result.append({ + "keyword": keyword, + "reply": reply, + "item_id": item_id or "" + }) + + return result + + @app.post("/keywords/{cid}") def update_keywords(cid: str, body: KeywordIn, current_user: Dict[str, Any] = Depends(get_current_user)): if cookie_manager.manager is None: @@ -1357,6 +1391,263 @@ def update_keywords(cid: str, body: KeywordIn, current_user: Dict[str, Any] = De return {"msg": "updated", "count": len(kw_list)} +@app.post("/keywords-with-item-id/{cid}") +def update_keywords_with_item_id(cid: str, body: KeywordWithItemIdIn, current_user: Dict[str, Any] = Depends(get_current_user)): + """更新包含商品ID的关键词列表""" + if cookie_manager.manager is None: + raise HTTPException(status_code=500, detail="CookieManager 未就绪") + + # 检查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: + log_with_user('warning', f"尝试操作其他用户的Cookie关键字: {cid}", current_user) + raise HTTPException(status_code=403, detail="无权限操作该Cookie") + + # 验证数据格式 + keywords_to_save = [] + keyword_set = set() # 用于检查当前提交的关键词中是否有重复 + + for kw_data in body.keywords: + keyword = kw_data.get('keyword', '').strip() + reply = kw_data.get('reply', '').strip() + item_id = kw_data.get('item_id', '').strip() or None + + if not keyword or not reply: + raise HTTPException(status_code=400, detail="关键词和回复内容不能为空") + + # 检查当前提交的关键词中是否有重复 + keyword_key = f"{keyword}|{item_id or ''}" + if keyword_key in keyword_set: + item_id_text = f"(商品ID: {item_id})" if item_id else "(通用关键词)" + raise HTTPException(status_code=400, detail=f"关键词 '{keyword}' {item_id_text} 在当前提交中重复") + keyword_set.add(keyword_key) + + keywords_to_save.append((keyword, reply, item_id)) + + # 保存关键词 + success = db_manager.save_keywords_with_item_id(cid, keywords_to_save) + if not success: + raise HTTPException(status_code=500, detail="保存关键词失败") + + log_with_user('info', f"更新Cookie关键字(含商品ID): {cid}, 数量: {len(keywords_to_save)}", current_user) + return {"msg": "updated", "count": len(keywords_to_save)} + + +@app.get("/items/{cid}") +def get_items_list(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)): + """获取指定账号的商品列表""" + if cookie_manager.manager is None: + raise HTTPException(status_code=500, detail="CookieManager 未就绪") + + # 检查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") + + try: + # 获取该账号的所有商品 + with db_manager.lock: + cursor = db_manager.conn.cursor() + cursor.execute(''' + SELECT item_id, item_title, item_price, created_at + FROM item_info + WHERE cookie_id = ? + ORDER BY created_at DESC + ''', (cid,)) + + items = [] + for row in cursor.fetchall(): + items.append({ + 'item_id': row[0], + 'item_title': row[1] or '未知商品', + 'item_price': row[2] or '价格未知', + 'created_at': row[3] + }) + + return {"items": items, "count": len(items)} + + except Exception as e: + logger.error(f"获取商品列表失败: {e}") + raise HTTPException(status_code=500, detail="获取商品列表失败") + + +@app.get("/keywords-export/{cid}") +def export_keywords(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)): + """导出指定账号的关键词为Excel文件""" + if cookie_manager.manager is None: + raise HTTPException(status_code=500, detail="CookieManager 未就绪") + + # 检查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") + + try: + # 获取关键词数据 + keywords = db_manager.get_keywords_with_item_id(cid) + + # 创建DataFrame + data = [] + for keyword, reply, item_id in keywords: + data.append({ + '关键词': keyword, + '商品ID': item_id or '', + '关键词内容': reply + }) + + # 如果没有数据,创建空的DataFrame但保留列名(作为模板) + if not data: + df = pd.DataFrame(columns=['关键词', '商品ID', '关键词内容']) + else: + df = pd.DataFrame(data) + + # 创建Excel文件 + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, sheet_name='关键词数据', index=False) + + # 如果是空模板,添加一些示例说明 + if data == []: + worksheet = writer.sheets['关键词数据'] + # 添加示例数据作为注释(从第2行开始) + worksheet['A2'] = '你好' + worksheet['B2'] = '' + worksheet['C2'] = '您好!欢迎咨询,有什么可以帮助您的吗?' + + worksheet['A3'] = '价格' + worksheet['B3'] = '123456' + worksheet['C3'] = '这个商品的价格是99元,现在有优惠活动哦!' + + worksheet['A4'] = '发货' + worksheet['B4'] = '' + worksheet['C4'] = '我们会在24小时内发货,请耐心等待。' + + # 设置示例行的样式(浅灰色背景) + from openpyxl.styles import PatternFill + gray_fill = PatternFill(start_color='F0F0F0', end_color='F0F0F0', fill_type='solid') + for row in range(2, 5): + for col in range(1, 4): + worksheet.cell(row=row, column=col).fill = gray_fill + + output.seek(0) + + # 生成文件名(使用URL编码处理中文) + from urllib.parse import quote + if not data: + filename = f"keywords_template_{cid}_{int(time.time())}.xlsx" + else: + filename = f"keywords_{cid}_{int(time.time())}.xlsx" + encoded_filename = quote(filename.encode('utf-8')) + + # 返回文件 + return StreamingResponse( + io.BytesIO(output.read()), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + } + ) + + except Exception as e: + logger.error(f"导出关键词失败: {e}") + raise HTTPException(status_code=500, detail=f"导出关键词失败: {str(e)}") + + +@app.post("/keywords-import/{cid}") +async def import_keywords(cid: str, file: UploadFile = File(...), current_user: Dict[str, Any] = Depends(get_current_user)): + """导入Excel文件中的关键词到指定账号""" + if cookie_manager.manager is None: + raise HTTPException(status_code=500, detail="CookieManager 未就绪") + + # 检查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") + + # 检查文件类型 + if not file.filename.endswith(('.xlsx', '.xls')): + raise HTTPException(status_code=400, detail="请上传Excel文件(.xlsx或.xls)") + + try: + # 读取Excel文件 + contents = await file.read() + df = pd.read_excel(io.BytesIO(contents)) + + # 检查必要的列 + required_columns = ['关键词', '商品ID', '关键词内容'] + missing_columns = [col for col in required_columns if col not in df.columns] + if missing_columns: + raise HTTPException(status_code=400, detail=f"Excel文件缺少必要的列: {', '.join(missing_columns)}") + + # 获取现有关键词 + existing_keywords = db_manager.get_keywords_with_item_id(cid) + existing_dict = {} + for keyword, reply, item_id in existing_keywords: + key = f"{keyword}|{item_id or ''}" + existing_dict[key] = (keyword, reply, item_id) + + # 处理导入数据 + import_data = [] + update_count = 0 + add_count = 0 + + for index, row in df.iterrows(): + keyword = str(row['关键词']).strip() + item_id = str(row['商品ID']).strip() if pd.notna(row['商品ID']) and str(row['商品ID']).strip() else None + reply = str(row['关键词内容']).strip() + + if not keyword or not reply: + continue # 跳过空行 + + # 检查是否重复 + key = f"{keyword}|{item_id or ''}" + if key in existing_dict: + # 更新现有关键词 + update_count += 1 + else: + # 新增关键词 + add_count += 1 + + import_data.append((keyword, reply, item_id)) + + if not import_data: + raise HTTPException(status_code=400, detail="Excel文件中没有有效的关键词数据") + + # 保存到数据库 + success = db_manager.save_keywords_with_item_id(cid, import_data) + if not success: + raise HTTPException(status_code=500, detail="保存关键词到数据库失败") + + log_with_user('info', f"导入关键词成功: {cid}, 新增: {add_count}, 更新: {update_count}", current_user) + + return { + "msg": "导入成功", + "total": len(import_data), + "added": add_count, + "updated": update_count + } + + except pd.errors.EmptyDataError: + raise HTTPException(status_code=400, detail="Excel文件为空") + except pd.errors.ParserError: + raise HTTPException(status_code=400, detail="Excel文件格式错误") + except Exception as e: + logger.error(f"导入关键词失败: {e}") + raise HTTPException(status_code=500, detail=f"导入关键词失败: {str(e)}") + + # 卡券管理API @app.get("/cards") def get_cards(current_user: Dict[str, Any] = Depends(get_current_user)): @@ -1388,6 +1679,7 @@ def create_card(card_data: dict, current_user: Dict[str, Any] = Depends(get_curr data_content=card_data.get('data_content'), description=card_data.get('description'), enabled=card_data.get('enabled', True), + delay_seconds=card_data.get('delay_seconds', 0), user_id=user_id ) @@ -1426,7 +1718,8 @@ def update_card(card_id: int, card_data: dict, _: None = Depends(require_auth)): text_content=card_data.get('text_content'), data_content=card_data.get('data_content'), description=card_data.get('description'), - enabled=card_data.get('enabled', True) + enabled=card_data.get('enabled', True), + delay_seconds=card_data.get('delay_seconds') ) if success: return {"message": "卡券更新成功"} diff --git a/requirements.txt b/requirements.txt index 0919cce..cbcabf5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,4 +47,8 @@ bcrypt>=4.0.1 python-dateutil>=2.8.2 # 正则表达式增强 -regex>=2023.10.3 \ No newline at end of file +regex>=2023.10.3 + +# Excel文件处理(导入导出功能) +pandas>=2.0.0 +openpyxl>=3.1.0 \ No newline at end of file diff --git a/static/index.html b/static/index.html index f1a35a2..aa5a720 100644 --- a/static/index.html +++ b/static/index.html @@ -378,7 +378,7 @@ .keyword-input-group { display: grid; - grid-template-columns: 1fr 2fr auto; + grid-template-columns: 1fr 2fr 1fr auto; gap: 1rem; align-items: end; } @@ -399,7 +399,7 @@ z-index: 1; } - .input-field input { + .input-field input, .input-field select { width: 100%; padding: 1rem 0.75rem 0.75rem; border: 2px solid #e5e7eb; @@ -1483,8 +1483,18 @@ 关键词管理 -