diff --git a/.gitignore b/.gitignore index aac865b..3986ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,13 @@ keywords_*.xlsx *.png.cache *.jpg.cache +# 上传的文件(保留目录结构,忽略文件内容) +static/uploads/* +!static/uploads/.gitkeep +!static/uploads/images/ +static/uploads/images/* +!static/uploads/images/.gitkeep + # 配置文件(包含敏感信息) config.local.yml global_config.local.yml diff --git a/Dockerfile b/Dockerfile index 059f3fd..10cf9f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,8 +86,8 @@ RUN playwright install chromium && \ playwright install-deps chromium # 创建必要的目录并设置权限 -RUN mkdir -p /app/logs /app/data /app/backups && \ - chmod 777 /app/logs /app/data /app/backups +RUN mkdir -p /app/logs /app/data /app/backups /app/static/uploads/images && \ + chmod 777 /app/logs /app/data /app/backups /app/static/uploads /app/static/uploads/images # 注意: 为了简化权限问题,使用root用户运行 # 在生产环境中,建议配置适当的用户映射 @@ -109,7 +109,7 @@ echo "📊 数据库将在应用启动时自动初始化..." echo "🎯 启动主应用..." # 确保数据目录存在 -mkdir -p /app/data /app/logs /app/backups +mkdir -p /app/data /app/logs /app/backups /app/static/uploads/images # 启动主应用 exec python Start.py diff --git a/Dockerfile-cn b/Dockerfile-cn index e051bb1..a984c61 100644 --- a/Dockerfile-cn +++ b/Dockerfile-cn @@ -89,8 +89,8 @@ RUN playwright install chromium && \ playwright install-deps chromium # 创建必要的目录并设置权限 -RUN mkdir -p /app/logs /app/data /app/backups && \ - chmod 777 /app/logs /app/data /app/backups +RUN mkdir -p /app/logs /app/data /app/backups /app/static/uploads/images && \ + chmod 777 /app/logs /app/data /app/backups /app/static/uploads /app/static/uploads/images # 注意: 为了简化权限问题,使用root用户运行 # 在生产环境中,建议配置适当的用户映射 @@ -112,7 +112,7 @@ echo "📊 数据库将在应用启动时自动初始化..." echo "🎯 启动主应用..." # 确保数据目录存在 -mkdir -p /app/data /app/logs /app/backups +mkdir -p /app/data /app/logs /app/backups /app/static/uploads/images # 启动主应用 exec python Start.py diff --git a/README.md b/README.md index 7798ce8..fad9fc9 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ - **延时发货** - 支持设置发货延时时间(0-3600秒) - **多种触发** - 支持付款消息、小刀消息等多种触发条件 - **防重复发货** - 智能防重复机制,避免重复发货 -- **多种发货方式** - 支持固定文字、批量数据、API调用等发货方式 +- **多种发货方式** - 支持固定文字、批量数据、API调用、图片发货等方式 +- **图片发货** - 支持上传图片并自动发送给买家,图片自动上传到CDN - **自动确认发货** - 检测到付款后自动调用闲鱼API确认发货 - **防重复确认** - 智能防重复确认机制,避免重复API调用 - **发货统计** - 完整的发货记录和统计功能 @@ -97,7 +98,9 @@ xianyu-auto-reply/ │ ├── ws_utils.py # WebSocket客户端封装 │ ├── qr_login.py # 二维码登录功能 │ ├── item_search.py # 商品搜索功能(基于Playwright) -│ └── order_detail_fetcher.py # 订单详情获取工具 +│ ├── order_detail_fetcher.py # 订单详情获取工具 +│ ├── image_utils.py # 图片处理工具(压缩、格式转换) +│ └── image_uploader.py # 图片上传到CDN工具 ├── 🌐 前端界面 │ └── static/ │ ├── index.html # 主管理界面(账号管理、系统监控) @@ -107,23 +110,26 @@ xianyu-auto-reply/ │ ├── data_management.html # 数据管理页面(导入导出) │ ├── log_management.html # 日志管理页面(实时日志查看) │ ├── item_search.html # 商品搜索页面(真实数据获取) +│ ├── js/app.js # 主要JavaScript逻辑 +│ ├── css/style.css # 自定义样式文件 │ ├── xianyu_js_version_2.js # 闲鱼JavaScript工具库 │ └── lib/ # 前端依赖库(Bootstrap等) ├── 🐳 Docker部署 │ ├── Dockerfile # Docker镜像构建文件 │ ├── docker-compose.yml # Docker Compose一键部署配置 -│ ├── docker-deploy.sh # Docker部署管理脚本 -│ └── nginx/ # Nginx反向代理配置 +│ └── docker-deploy.sh # Docker部署管理脚本 ├── 📋 配置文件 │ ├── global_config.yml # 全局配置文件(WebSocket、API等) │ ├── requirements.txt # Python依赖包列表 │ ├── .env # 环境变量配置文件 │ └── README.md # 项目说明文档 └── 📊 数据目录 - ├── xianyu_data.db # SQLite数据库文件 + ├── data/ # 数据目录(Docker挂载) + │ └── xianyu_data.db # SQLite数据库文件 ├── logs/ # 按日期分割的日志文件 ├── backups/ # 数据备份文件 - └── realtime.log # 实时日志文件 + └── static/uploads/ # 上传文件目录(已忽略) + └── images/ # 图片文件存储(已忽略) ``` @@ -132,36 +138,36 @@ xianyu-auto-reply/ ### 方式一:Docker 一键部署(最简单) -```bash -# 创建数据目录 -mkdir -p xianyu-auto-reply - -# 一键启动容器 -docker run -d \ - -p 8080:8080 \ - -v $PWD/xianyu-auto-reply/:/app/data/ \ - --name xianyu-auto-reply \ - 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. 一键部署 +# 2. 一键部署(自动构建镜像) ./docker-deploy.sh # 3. 访问系统 # http://localhost:8080 ``` -### 方式三:本地部署(开发环境) +### 方式二:Docker Compose 手动部署 + +```bash +# 1. 克隆项目 +git clone https://github.com/zhinianboke/xianyu-auto-reply.git +cd xianyu-auto-reply + +# 2. 构建并启动服务 +docker-compose up -d --build + +# 3. 查看服务状态 +docker-compose ps + +# 4. 访问系统 +# http://localhost:8080 +``` + +### 方式三:本地开发部署 ```bash # 1. 克隆项目 @@ -322,42 +328,50 @@ python Start.py ## 📁 核心文件功能说明 ### 🚀 启动和核心模块 -- **`Start.py`** - 项目启动入口,初始化所有服务和组件 -- **`XianyuAutoAsync.py`** - 闲鱼WebSocket连接核心,处理消息收发和自动回复 -- **`reply_server.py`** - FastAPI Web服务器,提供管理界面和API接口 -- **`cookie_manager.py`** - 多账号Cookie管理,负责账号任务的启动和停止 +- **`Start.py`** - 项目启动入口,初始化CookieManager和FastAPI服务,管理多账号任务 +- **`XianyuAutoAsync.py`** - 闲鱼WebSocket连接核心,处理消息收发、自动回复、自动发货 +- **`reply_server.py`** - FastAPI Web服务器,提供完整的管理界面和RESTful API接口 +- **`cookie_manager.py`** - 多账号Cookie管理器,负责账号任务的启动、停止和状态管理 ### 🗄️ 数据和配置管理 -- **`db_manager.py`** - SQLite数据库管理,处理用户数据、商品信息、关键词等 -- **`config.py`** - 配置文件管理,加载和管理全局配置 -- **`global_config.yml`** - 全局配置文件,包含所有系统配置项 +- **`db_manager.py`** - SQLite数据库管理器,支持多用户数据隔离、自动迁移、版本管理 +- **`config.py`** - 全局配置文件管理器,加载YAML配置和环境变量 +- **`global_config.yml`** - 全局配置文件,包含WebSocket、API、自动回复等所有配置项 ### 🤖 智能功能模块 -- **`ai_reply_engine.py`** - AI智能回复引擎,支持多种AI模型 -- **`secure_confirm_ultra.py`** - 自动确认发货模块(超级加密保护) -- **`file_log_collector.py`** - 日志收集和管理,提供实时日志查看 +- **`ai_reply_engine.py`** - AI智能回复引擎,支持OpenAI、通义千问等多种AI模型 +- **`secure_confirm_ultra.py`** - 自动确认发货模块,采用多层加密保护核心业务逻辑 +- **`secure_freeshipping_ultra.py`** - 自动免拼发货模块,支持批量处理和异常恢复 +- **`file_log_collector.py`** - 实时日志收集器,提供Web界面日志查看和管理 ### 🛠️ 工具模块 -- **`utils/xianyu_utils.py`** - 闲鱼API工具函数,包含加密解密、签名生成等 -- **`utils/message_utils.py`** - 消息格式化工具 -- **`utils/ws_utils.py`** - WebSocket客户端工具 -- **`utils/item_search.py`** - 商品搜索功能,基于Playwright技术 +- **`utils/xianyu_utils.py`** - 闲鱼API工具函数,包含加密解密、签名生成、数据解析 +- **`utils/message_utils.py`** - 消息格式化和处理工具,支持变量替换和模板渲染 +- **`utils/ws_utils.py`** - WebSocket客户端封装,提供连接管理和重连机制 +- **`utils/item_search.py`** - 商品搜索功能,基于Playwright获取真实闲鱼数据 +- **`utils/order_detail_fetcher.py`** - 订单详情获取工具,支持多规格商品信息解析 +- **`utils/image_utils.py`** - 图片处理工具,支持压缩、格式转换、尺寸调整 +- **`utils/image_uploader.py`** - 图片上传到CDN工具,支持闲鱼图片服务器上传 +- **`utils/qr_login.py`** - 二维码登录功能,支持扫码获取Cookie ### 🌐 前端界面 -- **`static/index.html`** - 主管理界面,账号管理和系统监控 -- **`static/login.html`** - 用户登录页面 -- **`static/register.html`** - 用户注册页面,支持邮箱验证 -- **`static/user_management.html`** - 用户管理页面(管理员功能) -- **`static/data_management.html`** - 数据管理页面,关键词导入导出 -- **`static/log_management.html`** - 日志管理页面,实时日志查看 -- **`static/item_search.html`** - 商品搜索页面,获取真实闲鱼数据 +- **`static/index.html`** - 主管理界面,集成账号管理、系统监控、功能配置 +- **`static/login.html`** - 用户登录页面,支持图形验证码和记住登录状态 +- **`static/register.html`** - 用户注册页面,支持邮箱验证和实时验证 +- **`static/user_management.html`** - 用户管理页面,管理员专用功能 +- **`static/data_management.html`** - 数据管理页面,支持Excel导入导出和批量操作 +- **`static/log_management.html`** - 日志管理页面,实时日志查看和过滤 +- **`static/item_search.html`** - 商品搜索页面,获取真实闲鱼商品数据 +- **`static/js/app.js`** - 主要JavaScript逻辑,处理前端交互和API调用 +- **`static/css/style.css`** - 自定义样式文件,美化界面和响应式设计 ### 🐳 部署配置 -- **`Dockerfile`** - Docker镜像构建文件,包含完整运行环境 -- **`docker-compose.yml`** - Docker Compose配置,支持一键部署 -- **`docker-deploy.sh`** - Docker部署脚本,提供完整的部署管理功能 -- **`.env`** - 环境变量配置文件,包含所有可配置项 -- **`requirements.txt`** - Python依赖包列表 +- **`Dockerfile`** - Docker镜像构建文件,包含Python环境、Playwright浏览器等 +- **`docker-compose.yml`** - Docker Compose配置,支持一键部署和Nginx反向代理 +- **`docker-deploy.sh`** - Docker部署管理脚本,提供构建、启动、监控等功能 +- **`nginx/nginx.conf`** - Nginx反向代理配置,支持负载均衡和SSL终端 +- **`.env`** - 环境变量配置文件,包含所有可配置的系统参数 +- **`requirements.txt`** - Python依赖包列表,精简版本无冗余依赖 ## ⚙️ 配置说明 diff --git a/XianyuAutoAsync.py b/XianyuAutoAsync.py index 391d107..45d7bd1 100644 --- a/XianyuAutoAsync.py +++ b/XianyuAutoAsync.py @@ -259,10 +259,24 @@ class XianyuLive: # 标记已发货(防重复)- 基于订单ID self.mark_delivery_sent(order_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, "发货成功") + # 检查是否是图片发送标记 + if delivery_content.startswith("__IMAGE_SEND__"): + # 提取图片URL + image_url = delivery_content.replace("__IMAGE_SEND__", "") + # 发送图片消息 + try: + await self.send_image_msg(websocket, chat_id, send_user_id, image_url) + logger.info(f'[{msg_time}] 【自动发货图片】已向 {user_url} 发送图片: {image_url}') + 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_msg(websocket, chat_id, send_user_id, "抱歉,图片发送失败,请联系客服。") + await self.send_delivery_failure_notification(send_user_name, send_user_id, item_id, "图片发送失败") + else: + # 普通文本发货内容 + 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}] 【自动发货】未找到匹配的发货规则或获取发货内容失败') # 发送自动发货失败通知 @@ -904,12 +918,12 @@ class XianyuLive: return None async def get_keyword_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str = None) -> str: - """获取关键词匹配回复(支持商品ID优先匹配)""" + """获取关键词匹配回复(支持商品ID优先匹配和图片类型)""" try: from db_manager import db_manager - # 获取当前账号的关键词列表(包含商品ID) - keywords = db_manager.get_keywords_with_item_id(self.cookie_id) + # 获取当前账号的关键词列表(包含类型信息) + keywords = db_manager.get_keywords_with_type(self.cookie_id) if not keywords: logger.debug(f"账号 {self.cookie_id} 没有配置关键词") @@ -917,39 +931,65 @@ class XianyuLive: # 1. 如果有商品ID,优先匹配该商品ID对应的关键词 if item_id: - for keyword, reply, keyword_item_id in keywords: + for keyword_data in keywords: + keyword = keyword_data['keyword'] + reply = keyword_data['reply'] + keyword_item_id = keyword_data['item_id'] + keyword_type = keyword_data.get('type', 'text') + image_url = keyword_data.get('image_url') + if keyword_item_id == item_id and keyword.lower() in send_message.lower(): - # 进行变量替换 + logger.info(f"商品ID关键词匹配成功: 商品{item_id} '{keyword}' (类型: {keyword_type})") + + # 根据关键词类型处理 + if keyword_type == 'image' and image_url: + # 图片类型关键词,发送图片 + return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message) + else: + # 文本类型关键词,进行变量替换 + 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文本关键词回复: {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_data in keywords: + keyword = keyword_data['keyword'] + reply = keyword_data['reply'] + keyword_item_id = keyword_data['item_id'] + keyword_type = keyword_data.get('type', 'text') + image_url = keyword_data.get('image_url') + + if not keyword_item_id and keyword.lower() in send_message.lower(): + logger.info(f"通用关键词匹配成功: '{keyword}' (类型: {keyword_type})") + + # 根据关键词类型处理 + if keyword_type == 'image' and image_url: + # 图片类型关键词,发送图片 + return await self._handle_image_keyword(keyword, image_url, send_user_name, send_user_id, send_message) + else: + # 文本类型关键词,进行变量替换 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}") + logger.info(f"通用文本关键词回复: {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( - send_user_name=send_user_name, - send_user_id=send_user_id, - send_message=send_message - ) - logger.info(f"通用关键词匹配成功: '{keyword}' -> {formatted_reply}") - return formatted_reply - except Exception as format_error: - logger.error(f"关键词回复变量替换失败: {self._safe_str(format_error)}") - # 如果变量替换失败,返回原始内容 - return reply - logger.debug(f"未找到匹配的关键词: {send_message}") return None @@ -957,6 +997,90 @@ class XianyuLive: logger.error(f"获取关键词回复失败: {self._safe_str(e)}") return None + async def _handle_image_keyword(self, keyword: str, image_url: str, send_user_name: str, send_user_id: str, send_message: str) -> str: + """处理图片类型关键词""" + try: + # 检查图片URL类型 + if self._is_cdn_url(image_url): + # 已经是CDN链接,直接使用 + logger.info(f"使用已有的CDN图片链接: {image_url}") + return f"__IMAGE_SEND__{image_url}" + + elif image_url.startswith('/static/uploads/') or image_url.startswith('static/uploads/'): + # 本地图片,需要上传到闲鱼CDN + local_image_path = image_url.replace('/static/uploads/', 'static/uploads/') + if os.path.exists(local_image_path): + logger.info(f"准备上传本地图片到闲鱼CDN: {local_image_path}") + + # 使用图片上传器上传到闲鱼CDN + from utils.image_uploader import ImageUploader + uploader = ImageUploader(self.cookies_str) + + async with uploader: + cdn_url = await uploader.upload_image(local_image_path) + if cdn_url: + logger.info(f"图片上传成功,CDN URL: {cdn_url}") + # 更新数据库中的图片URL为CDN URL + await self._update_keyword_image_url(keyword, cdn_url) + image_url = cdn_url + else: + logger.error(f"图片上传失败: {local_image_path}") + return f"抱歉,图片发送失败,请稍后重试。" + else: + logger.error(f"本地图片文件不存在: {local_image_path}") + return f"抱歉,图片文件不存在。" + + else: + # 其他类型的URL(可能是外部链接),直接使用 + logger.info(f"使用外部图片链接: {image_url}") + + # 发送图片(这里返回特殊标记,在调用处处理实际发送) + return f"__IMAGE_SEND__{image_url}" + + except Exception as e: + logger.error(f"处理图片关键词失败: {e}") + return f"抱歉,图片发送失败: {str(e)}" + + def _is_cdn_url(self, url: str) -> bool: + """检查URL是否是闲鱼CDN链接""" + if not url: + return False + + # 闲鱼CDN域名列表 + cdn_domains = [ + 'gw.alicdn.com', + 'img.alicdn.com', + 'cloud.goofish.com', + 'goofish.com', + 'taobaocdn.com', + 'tbcdn.cn', + 'aliimg.com' + ] + + # 检查是否包含CDN域名 + url_lower = url.lower() + for domain in cdn_domains: + if domain in url_lower: + return True + + # 检查是否是HTTPS链接且包含图片特征 + if url_lower.startswith('https://') and any(ext in url_lower for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']): + return True + + return False + + async def _update_keyword_image_url(self, keyword: str, new_image_url: str): + """更新关键词的图片URL""" + try: + from db_manager import db_manager + success = db_manager.update_keyword_image_url(self.cookie_id, keyword, new_image_url) + if success: + logger.info(f"图片URL已更新: {keyword} -> {new_image_url}") + else: + logger.warning(f"图片URL更新失败: {keyword}") + except Exception as e: + logger.error(f"更新关键词图片URL失败: {e}") + async def get_ai_reply(self, send_user_name: str, send_user_id: str, send_message: str, item_id: str, chat_id: str): """获取AI回复""" try: @@ -1810,6 +1934,16 @@ class XianyuLive: # 批量数据类型:获取并消费第一条数据 delivery_content = db_manager.consume_batch_data(rule['card_id']) + elif rule['card_type'] == 'image': + # 图片类型:返回图片发送标记 + image_url = rule.get('image_url') + if image_url: + delivery_content = f"__IMAGE_SEND__{image_url}" + logger.info(f"准备发送图片: {image_url}") + else: + logger.error(f"图片卡券缺少图片URL: 卡券ID={rule['card_id']}") + delivery_content = None + if delivery_content: # 处理备注信息和变量替换 final_content = self._process_delivery_content_with_description(delivery_content, rule.get('card_description', '')) @@ -2652,10 +2786,28 @@ class XianyuLive: # 如果有回复内容,发送消息 if reply: - await self.send_msg(websocket, chat_id, send_user_id, reply) - # 记录发出的消息 - msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) - logger.info(f"[{msg_time}] 【{reply_source}发出】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {reply}") + # 检查是否是图片发送标记 + if reply.startswith("__IMAGE_SEND__"): + # 提取图片URL + image_url = reply.replace("__IMAGE_SEND__", "") + # 发送图片消息 + try: + await self.send_image_msg(websocket, chat_id, send_user_id, image_url) + # 记录发出的图片消息 + msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + logger.info(f"[{msg_time}] 【{reply_source}图片发出】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): 图片 {image_url}") + except Exception as e: + # 图片发送失败,发送错误提示 + logger.error(f"图片发送失败: {self._safe_str(e)}") + await self.send_msg(websocket, chat_id, send_user_id, "抱歉,图片发送失败,请稍后重试。") + msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + logger.error(f"[{msg_time}] 【{reply_source}图片发送失败】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id})") + else: + # 普通文本消息 + await self.send_msg(websocket, chat_id, send_user_id, reply) + # 记录发出的消息 + msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + logger.info(f"[{msg_time}] 【{reply_source}发出】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {reply}") else: msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) logger.info(f"[{msg_time}] 【{self.cookie_id}】【系统】未找到匹配的回复规则,不回复") @@ -2972,6 +3124,159 @@ class XianyuLive: "items": all_items } + async def send_image_msg(self, ws, cid, toid, image_url, width=800, height=600): + """发送图片消息""" + try: + # 检查图片URL是否需要上传到CDN + original_url = image_url + + if self._is_cdn_url(image_url): + # 已经是CDN链接,直接使用 + logger.info(f"【{self.cookie_id}】使用已有的CDN图片链接: {image_url}") + elif image_url.startswith('/static/uploads/') or image_url.startswith('static/uploads/'): + # 本地图片,需要上传到闲鱼CDN + local_image_path = image_url.replace('/static/uploads/', 'static/uploads/') + if os.path.exists(local_image_path): + logger.info(f"【{self.cookie_id}】准备上传本地图片到闲鱼CDN: {local_image_path}") + + # 使用图片上传器上传到闲鱼CDN + from utils.image_uploader import ImageUploader + uploader = ImageUploader(self.cookies_str) + + async with uploader: + cdn_url = await uploader.upload_image(local_image_path) + if cdn_url: + logger.info(f"【{self.cookie_id}】图片上传成功,CDN URL: {cdn_url}") + image_url = cdn_url + + # 获取实际图片尺寸 + from utils.image_utils import image_manager + try: + actual_width, actual_height = image_manager.get_image_size(local_image_path) + if actual_width and actual_height: + width, height = actual_width, actual_height + logger.info(f"【{self.cookie_id}】获取到实际图片尺寸: {width}x{height}") + except Exception as e: + logger.warning(f"【{self.cookie_id}】获取图片尺寸失败,使用默认尺寸: {e}") + else: + logger.error(f"【{self.cookie_id}】图片上传失败: {local_image_path}") + raise Exception(f"图片上传失败: {local_image_path}") + else: + logger.error(f"【{self.cookie_id}】本地图片文件不存在: {local_image_path}") + raise Exception(f"本地图片文件不存在: {local_image_path}") + else: + logger.warning(f"【{self.cookie_id}】未知的图片URL格式: {image_url}") + + # 记录详细的图片信息 + logger.info(f"【{self.cookie_id}】准备发送图片消息:") + logger.info(f" - 原始URL: {original_url}") + logger.info(f" - CDN URL: {image_url}") + logger.info(f" - 图片尺寸: {width}x{height}") + logger.info(f" - 聊天ID: {cid}") + logger.info(f" - 接收者ID: {toid}") + + # 构造图片消息内容 - 使用正确的闲鱼格式 + image_content = { + "contentType": 2, # 图片消息类型 + "image": { + "pics": [ + { + "height": int(height), + "type": 0, + "url": image_url, + "width": int(width) + } + ] + } + } + + # Base64编码 + content_json = json.dumps(image_content, ensure_ascii=False) + content_base64 = str(base64.b64encode(content_json.encode('utf-8')), 'utf-8') + + logger.info(f"【{self.cookie_id}】图片内容JSON: {content_json}") + logger.info(f"【{self.cookie_id}】Base64编码长度: {len(content_base64)}") + + # 构造WebSocket消息(完全参考send_msg的格式) + msg = { + "lwp": "/r/MessageSend/sendByReceiverScope", + "headers": { + "mid": generate_mid() + }, + "body": [ + { + "uuid": generate_uuid(), + "cid": f"{cid}@goofish", + "conversationType": 1, + "content": { + "contentType": 101, + "custom": { + "type": 1, + "data": content_base64 + } + }, + "redPointPolicy": 0, + "extension": { + "extJson": "{}" + }, + "ctx": { + "appVersion": "1.0", + "platform": "web" + }, + "mtags": {}, + "msgReadStatusSetting": 1 + }, + { + "actualReceivers": [ + f"{toid}@goofish", + f"{self.myid}@goofish" + ] + } + ] + } + + await ws.send(json.dumps(msg)) + logger.info(f"【{self.cookie_id}】图片消息发送成功: {image_url}") + + except Exception as e: + logger.error(f"【{self.cookie_id}】发送图片消息失败: {self._safe_str(e)}") + raise + + async def send_image_from_file(self, ws, cid, toid, image_path): + """从本地文件发送图片""" + try: + # 上传图片到闲鱼CDN + logger.info(f"【{self.cookie_id}】开始上传图片: {image_path}") + + from utils.image_uploader import ImageUploader + uploader = ImageUploader(self.cookies_str) + + async with uploader: + image_url = await uploader.upload_image(image_path) + + if image_url: + # 获取图片信息 + from utils.image_utils import image_manager + try: + from PIL import Image + with Image.open(image_path) as img: + width, height = img.size + except Exception as e: + logger.warning(f"无法获取图片尺寸,使用默认值: {e}") + width, height = 800, 600 + + # 发送图片消息 + await self.send_image_msg(ws, cid, toid, image_url, width, height) + logger.info(f"【{self.cookie_id}】图片发送完成: {image_path} -> {image_url}") + return True + else: + logger.error(f"【{self.cookie_id}】图片上传失败: {image_path}") + return False + + except Exception as e: + logger.error(f"【{self.cookie_id}】从文件发送图片失败: {self._safe_str(e)}") + return False + if __name__ == '__main__': cookies_str = os.getenv('COOKIES_STR') xianyuLive = XianyuLive(cookies_str) diff --git a/db_manager.py b/db_manager.py index df71596..dee8dfa 100644 --- a/db_manager.py +++ b/db_manager.py @@ -125,6 +125,8 @@ class DBManager: keyword TEXT, reply TEXT, item_id TEXT, + type TEXT DEFAULT 'text', + image_url TEXT, FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE ) ''') @@ -190,10 +192,11 @@ class DBManager: CREATE TABLE IF NOT EXISTS cards ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, - type TEXT NOT NULL CHECK (type IN ('api', 'text', 'data')), + type TEXT NOT NULL CHECK (type IN ('api', 'text', 'data', 'image')), api_config TEXT, text_content TEXT, data_content TEXT, + image_url TEXT, description TEXT, enabled BOOLEAN DEFAULT TRUE, delay_seconds INTEGER DEFAULT 0, @@ -344,12 +347,104 @@ class DBManager: # 检查并升级数据库 self.check_and_upgrade_db(cursor) + # 执行数据库迁移 + self._migrate_database(cursor) + self.conn.commit() logger.info("数据库初始化完成") except Exception as e: logger.error(f"数据库初始化失败: {e}") self.conn.rollback() raise + + def _migrate_database(self, cursor): + """执行数据库迁移""" + try: + # 检查cards表是否存在image_url列 + cursor.execute("PRAGMA table_info(cards)") + columns = [column[1] for column in cursor.fetchall()] + + if 'image_url' not in columns: + logger.info("添加cards表的image_url列...") + cursor.execute("ALTER TABLE cards ADD COLUMN image_url TEXT") + logger.info("数据库迁移完成:添加image_url列") + + # 检查并更新CHECK约束(重建表以支持image类型) + self._update_cards_table_constraints(cursor) + + except Exception as e: + logger.error(f"数据库迁移失败: {e}") + # 迁移失败不应该阻止程序启动 + pass + + def _update_cards_table_constraints(self, cursor): + """更新cards表的CHECK约束以支持image类型""" + try: + # 尝试插入一个测试的image类型记录来检查约束 + cursor.execute(''' + INSERT INTO cards (name, type, user_id) + VALUES ('__test_image_constraint__', 'image', 1) + ''') + # 如果插入成功,立即删除测试记录 + cursor.execute("DELETE FROM cards WHERE name = '__test_image_constraint__'") + logger.info("cards表约束检查通过,支持image类型") + except Exception as e: + if "CHECK constraint failed" in str(e) or "constraint" in str(e).lower(): + logger.info("检测到旧的CHECK约束,开始更新cards表...") + + # 重建表以更新约束 + try: + # 1. 创建新表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS cards_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK (type IN ('api', 'text', 'data', 'image')), + api_config TEXT, + text_content TEXT, + data_content TEXT, + image_url TEXT, + description TEXT, + enabled BOOLEAN DEFAULT TRUE, + delay_seconds INTEGER DEFAULT 0, + is_multi_spec BOOLEAN DEFAULT FALSE, + spec_name TEXT, + spec_value TEXT, + user_id INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ) + ''') + + # 2. 复制数据 + cursor.execute(''' + INSERT INTO cards_new (id, name, type, api_config, text_content, data_content, image_url, + description, enabled, delay_seconds, is_multi_spec, spec_name, spec_value, + user_id, created_at, updated_at) + SELECT id, name, type, api_config, text_content, data_content, image_url, + description, enabled, delay_seconds, is_multi_spec, spec_name, spec_value, + user_id, created_at, updated_at + FROM cards + ''') + + # 3. 删除旧表 + cursor.execute("DROP TABLE cards") + + # 4. 重命名新表 + cursor.execute("ALTER TABLE cards_new RENAME TO cards") + + logger.info("cards表约束更新完成,现在支持image类型") + + except Exception as rebuild_error: + logger.error(f"重建cards表失败: {rebuild_error}") + # 如果重建失败,尝试回滚 + try: + cursor.execute("DROP TABLE IF EXISTS cards_new") + except: + pass + else: + logger.error(f"检查cards表约束时出现未知错误: {e}") def check_and_upgrade_db(self, cursor): """检查数据库版本并执行必要的升级""" @@ -378,6 +473,13 @@ class DBManager: self.set_system_setting("db_version", "1.2", "数据库版本号") logger.info("数据库升级到版本1.2完成") + # 升级到版本1.3 - 添加关键词类型和图片URL字段 + if current_version < "1.3": + logger.info("开始升级数据库到版本1.3...") + self.upgrade_keywords_table_for_image_support(cursor) + self.set_system_setting("db_version", "1.3", "数据库版本号") + logger.info("数据库升级到版本1.3完成") + # 迁移遗留数据(在所有版本升级完成后执行) self.migrate_legacy_data(cursor) @@ -1059,6 +1161,40 @@ class DBManager: logger.error(f"关键字保存失败: {e}") self.conn.rollback() return False + + def save_text_keywords_only(self, cookie_id: str, keywords: List[Tuple[str, str, str]]) -> bool: + """保存文本关键字列表,只删除文本类型的关键词,保留图片关键词""" + with self.lock: + try: + cursor = self.conn.cursor() + + # 只删除该cookie_id的文本类型关键字,保留图片关键词 + self._execute_sql(cursor, + "DELETE FROM keywords WHERE cookie_id = ? AND (type IS NULL OR type = 'text')", + (cookie_id,)) + + # 插入新的文本关键字 + for keyword, reply, item_id in keywords: + # 标准化item_id:空字符串转为NULL + normalized_item_id = item_id if item_id and item_id.strip() else None + + try: + self._execute_sql(cursor, + "INSERT INTO keywords (cookie_id, keyword, reply, item_id, type) VALUES (?, ?, ?, ?, 'text')", + (cookie_id, keyword, reply, normalized_item_id)) + except sqlite3.IntegrityError as ie: + # 如果遇到唯一约束冲突,记录详细错误信息 + item_desc = f"商品ID: {normalized_item_id}" if normalized_item_id else "通用关键词" + logger.error(f"关键词唯一约束冲突: Cookie={cookie_id}, 关键词='{keyword}', {item_desc}") + raise ie + + self.conn.commit() + logger.info(f"文本关键字保存成功: {cookie_id}, {len(keywords)}条,图片关键词已保留") + return True + except Exception as e: + logger.error(f"文本关键字保存失败: {e}") + self.conn.rollback() + return False def get_keywords(self, cookie_id: str) -> List[Tuple[str, str]]: """获取指定Cookie的关键字列表(向后兼容方法)""" @@ -1104,8 +1240,107 @@ class DBManager: logger.error(f"检查关键词重复失败: {e}") return False + def save_image_keyword(self, cookie_id: str, keyword: str, image_url: str, item_id: str = None) -> bool: + """保存图片关键词(调用前应先检查重复)""" + with self.lock: + try: + cursor = self.conn.cursor() + + # 标准化item_id:空字符串转为NULL + normalized_item_id = item_id if item_id and item_id.strip() else None + + # 直接插入图片关键词(重复检查应在调用前完成) + self._execute_sql(cursor, + "INSERT INTO keywords (cookie_id, keyword, reply, item_id, type, image_url) VALUES (?, ?, ?, ?, ?, ?)", + (cookie_id, keyword, '', normalized_item_id, 'image', image_url)) + + self.conn.commit() + logger.info(f"图片关键词保存成功: {cookie_id}, 关键词: {keyword}, 图片: {image_url}") + return True + except Exception as e: + logger.error(f"图片关键词保存失败: {e}") + self.conn.rollback() + return False + + def get_keywords_with_type(self, cookie_id: str) -> List[Dict[str, any]]: + """获取指定Cookie的关键字列表(包含类型信息)""" + with self.lock: + try: + cursor = self.conn.cursor() + self._execute_sql(cursor, + "SELECT keyword, reply, item_id, type, image_url FROM keywords WHERE cookie_id = ?", + (cookie_id,)) + + results = [] + for row in cursor.fetchall(): + keyword_data = { + 'keyword': row[0], + 'reply': row[1], + 'item_id': row[2], + 'type': row[3] or 'text', # 默认为text类型 + 'image_url': row[4] + } + results.append(keyword_data) + + return results + except Exception as e: + logger.error(f"获取关键字失败: {e}") + return [] + + def update_keyword_image_url(self, cookie_id: str, keyword: str, new_image_url: str) -> bool: + """更新关键词的图片URL""" + with self.lock: + try: + cursor = self.conn.cursor() + + # 更新图片URL + self._execute_sql(cursor, + "UPDATE keywords SET image_url = ? WHERE cookie_id = ? AND keyword = ? AND type = 'image'", + (new_image_url, cookie_id, keyword)) + + self.conn.commit() + + # 检查是否有行被更新 + if cursor.rowcount > 0: + logger.info(f"关键词图片URL更新成功: {cookie_id}, 关键词: {keyword}, 新URL: {new_image_url}") + return True + else: + logger.warning(f"未找到匹配的图片关键词: {cookie_id}, 关键词: {keyword}") + return False + + except Exception as e: + logger.error(f"更新关键词图片URL失败: {e}") + self.conn.rollback() + return False + + def delete_keyword_by_index(self, cookie_id: str, index: int) -> bool: + """根据索引删除关键词""" + with self.lock: + try: + cursor = self.conn.cursor() + + # 先获取所有关键词 + self._execute_sql(cursor, + "SELECT rowid FROM keywords WHERE cookie_id = ? ORDER BY rowid", + (cookie_id,)) + rows = cursor.fetchall() + + if 0 <= index < len(rows): + rowid = rows[index][0] + self._execute_sql(cursor, "DELETE FROM keywords WHERE rowid = ?", (rowid,)) + self.conn.commit() + logger.info(f"删除关键词成功: {cookie_id}, 索引: {index}") + return True + else: + logger.warning(f"关键词索引超出范围: {index}") + return False + + except Exception as e: + logger.error(f"删除关键词失败: {e}") + self.conn.rollback() + return False + - def get_all_keywords(self, user_id: int = None) -> Dict[str, List[Tuple[str, str]]]: """获取所有Cookie的关键字(支持用户隔离)""" with self.lock: @@ -2135,7 +2370,7 @@ class DBManager: # ==================== 卡券管理方法 ==================== def create_card(self, name: str, card_type: str, api_config=None, - text_content: str = None, data_content: str = None, + text_content: str = None, data_content: str = None, image_url: str = None, description: str = None, enabled: bool = True, delay_seconds: int = 0, is_multi_spec: bool = False, spec_name: str = None, spec_value: str = None, user_id: int = None): @@ -2177,11 +2412,11 @@ class DBManager: api_config_str = str(api_config) cursor.execute(''' - INSERT INTO cards (name, type, api_config, text_content, data_content, + INSERT INTO cards (name, type, api_config, text_content, data_content, image_url, description, enabled, delay_seconds, is_multi_spec, spec_name, spec_value, user_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ''', (name, card_type, api_config_str, text_content, data_content, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (name, card_type, api_config_str, text_content, data_content, image_url, description, enabled, delay_seconds, is_multi_spec, spec_name, spec_value, user_id)) self.conn.commit() @@ -2203,7 +2438,7 @@ class DBManager: cursor = self.conn.cursor() if user_id is not None: cursor.execute(''' - SELECT id, name, type, api_config, text_content, data_content, + SELECT id, name, type, api_config, text_content, data_content, image_url, description, enabled, delay_seconds, is_multi_spec, spec_name, spec_value, created_at, updated_at FROM cards @@ -2212,7 +2447,7 @@ class DBManager: ''', (user_id,)) else: cursor.execute(''' - SELECT id, name, type, api_config, text_content, data_content, + SELECT id, name, type, api_config, text_content, data_content, image_url, description, enabled, delay_seconds, is_multi_spec, spec_name, spec_value, created_at, updated_at FROM cards @@ -2238,14 +2473,15 @@ class DBManager: 'api_config': api_config, 'text_content': row[4], 'data_content': row[5], - 'description': row[6], - 'enabled': bool(row[7]), - 'delay_seconds': row[8] or 0, - 'is_multi_spec': bool(row[9]) if row[9] is not None else False, - 'spec_name': row[10], - 'spec_value': row[11], - 'created_at': row[12], - 'updated_at': row[13] + 'image_url': row[6], + 'description': row[7], + 'enabled': bool(row[8]), + 'delay_seconds': row[9] or 0, + 'is_multi_spec': bool(row[10]) if row[10] is not None else False, + 'spec_name': row[11], + 'spec_value': row[12], + 'created_at': row[13], + 'updated_at': row[14] }) return cards @@ -2260,14 +2496,14 @@ class DBManager: cursor = self.conn.cursor() if user_id is not None: cursor.execute(''' - SELECT id, name, type, api_config, text_content, data_content, + SELECT id, name, type, api_config, text_content, data_content, image_url, description, enabled, delay_seconds, is_multi_spec, spec_name, spec_value, 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, + SELECT id, name, type, api_config, text_content, data_content, image_url, description, enabled, delay_seconds, is_multi_spec, spec_name, spec_value, created_at, updated_at FROM cards WHERE id = ? @@ -2292,14 +2528,15 @@ class DBManager: 'api_config': api_config, 'text_content': row[4], 'data_content': row[5], - 'description': row[6], - 'enabled': bool(row[7]), - 'delay_seconds': row[8] or 0, - 'is_multi_spec': bool(row[9]) if row[9] is not None else False, - 'spec_name': row[10], - 'spec_value': row[11], - 'created_at': row[12], - 'updated_at': row[13] + 'image_url': row[6], + 'description': row[7], + 'enabled': bool(row[8]), + 'delay_seconds': row[9] or 0, + 'is_multi_spec': bool(row[10]) if row[10] is not None else False, + 'spec_name': row[11], + 'spec_value': row[12], + 'created_at': row[13], + 'updated_at': row[14] } return None except Exception as e: @@ -2464,7 +2701,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.description as card_description, + c.text_content, c.data_content, c.image_url, 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 @@ -2503,9 +2740,10 @@ class DBManager: 'api_config': api_config, # 修复字段名 'text_content': row[10], 'data_content': row[11], - 'card_enabled': bool(row[12]), - 'card_description': row[13], # 卡券备注信息 - 'card_delay_seconds': row[14] or 0 # 延时秒数 + 'image_url': row[12], + 'card_enabled': bool(row[13]), + 'card_description': row[14], # 卡券备注信息 + 'card_delay_seconds': row[15] or 0 # 延时秒数 }) return rules @@ -3683,6 +3921,33 @@ class DBManager: self.conn.rollback() return False + def upgrade_keywords_table_for_image_support(self, cursor): + """升级keywords表以支持图片关键词""" + try: + logger.info("开始升级keywords表以支持图片关键词...") + + # 检查是否已经有type字段 + cursor.execute("PRAGMA table_info(keywords)") + columns = [column[1] for column in cursor.fetchall()] + + if 'type' not in columns: + logger.info("添加type字段到keywords表...") + cursor.execute("ALTER TABLE keywords ADD COLUMN type TEXT DEFAULT 'text'") + + if 'image_url' not in columns: + logger.info("添加image_url字段到keywords表...") + cursor.execute("ALTER TABLE keywords ADD COLUMN image_url TEXT") + + # 为现有记录设置默认类型 + cursor.execute("UPDATE keywords SET type = 'text' WHERE type IS NULL") + + logger.info("keywords表升级完成") + return True + + except Exception as e: + logger.error(f"升级keywords表失败: {e}") + raise + # 全局单例 db_manager = DBManager() diff --git a/docker-deploy.sh b/docker-deploy.sh index 47434bd..281a9d3 100644 --- a/docker-deploy.sh +++ b/docker-deploy.sh @@ -63,7 +63,7 @@ init_config() { fi # 创建必要的目录 - mkdir -p data logs backups + mkdir -p data logs backups static/uploads/images print_success "已创建必要的目录" } diff --git a/reply_server.py b/reply_server.py index 5b313ad..7f5a205 100644 --- a/reply_server.py +++ b/reply_server.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, HTTPException, Depends, status, UploadFile, File +from fastapi import FastAPI, HTTPException, Depends, status, UploadFile, File, Form from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials @@ -21,6 +21,7 @@ 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 utils.image_utils import image_manager from loguru import logger # 关键字文件路径 @@ -317,6 +318,11 @@ if not os.path.exists(static_dir): app.mount('/static', StaticFiles(directory=static_dir), name='static') +# 确保图片上传目录存在 +uploads_dir = os.path.join(static_dir, 'uploads', 'images') +if not os.path.exists(uploads_dir): + os.makedirs(uploads_dir, exist_ok=True) + logger.info(f"创建图片上传目录: {uploads_dir}") # 健康检查端点 @app.get('/health') @@ -1550,16 +1556,18 @@ def get_keywords_with_item_id(cid: str, current_user: Dict[str, Any] = Depends(g if cid not in user_cookies: raise HTTPException(status_code=403, detail="无权限访问该Cookie") - # 获取包含商品ID的关键词 - keywords = db_manager.get_keywords_with_item_id(cid) + # 获取包含类型信息的关键词 + keywords = db_manager.get_keywords_with_type(cid) # 转换为前端需要的格式 result = [] - for keyword, reply, item_id in keywords: + for keyword_data in keywords: result.append({ - "keyword": keyword, - "reply": reply, - "item_id": item_id or "" + "keyword": keyword_data['keyword'], + "reply": keyword_data['reply'], + "item_id": keyword_data['item_id'] or "", + "type": keyword_data['type'], + "image_url": keyword_data['image_url'] }) return result @@ -1688,17 +1696,19 @@ def export_keywords(cid: str, current_user: Dict[str, Any] = Depends(get_current raise HTTPException(status_code=403, detail="无权限访问该Cookie") try: - # 获取关键词数据 - keywords = db_manager.get_keywords_with_item_id(cid) + # 获取关键词数据(包含类型信息) + keywords = db_manager.get_keywords_with_type(cid) - # 创建DataFrame + # 创建DataFrame,只导出文本类型的关键词 data = [] - for keyword, reply, item_id in keywords: - data.append({ - '关键词': keyword, - '商品ID': item_id or '', - '关键词内容': reply - }) + for keyword_data in keywords: + # 只导出文本类型的关键词 + if keyword_data.get('type', 'text') == 'text': + data.append({ + '关键词': keyword_data['keyword'], + '商品ID': keyword_data['item_id'] or '', + '关键词内容': keyword_data['reply'] + }) # 如果没有数据,创建空的DataFrame但保留列名(作为模板) if not data: @@ -1787,12 +1797,17 @@ async def import_keywords(cid: str, file: UploadFile = File(...), current_user: 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_keywords = db_manager.get_keywords_with_type(cid) existing_dict = {} - for keyword, reply, item_id in existing_keywords: - key = f"{keyword}|{item_id or ''}" - existing_dict[key] = (keyword, reply, item_id) + for keyword_data in existing_keywords: + # 只考虑文本类型的关键词 + if keyword_data.get('type', 'text') == 'text': + keyword = keyword_data['keyword'] + reply = keyword_data['reply'] + item_id = keyword_data['item_id'] + key = f"{keyword}|{item_id or ''}" + existing_dict[key] = (keyword, reply, item_id) # 处理导入数据 import_data = [] @@ -1821,8 +1836,8 @@ async def import_keywords(cid: str, file: UploadFile = File(...), current_user: if not import_data: raise HTTPException(status_code=400, detail="Excel文件中没有有效的关键词数据") - # 保存到数据库 - success = db_manager.save_keywords_with_item_id(cid, import_data) + # 保存到数据库(只影响文本关键词,保留图片关键词) + success = db_manager.save_text_keywords_only(cid, import_data) if not success: raise HTTPException(status_code=500, detail="保存关键词到数据库失败") @@ -1844,6 +1859,210 @@ async def import_keywords(cid: str, file: UploadFile = File(...), current_user: raise HTTPException(status_code=500, detail=f"导入关键词失败: {str(e)}") +@app.post("/keywords/{cid}/image") +async def add_image_keyword( + cid: str, + keyword: str = Form(...), + item_id: str = Form(default=""), + image: UploadFile = File(...), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """添加图片关键词""" + logger.info(f"接收到图片关键词添加请求: cid={cid}, keyword={keyword}, item_id={item_id}") + + if cookie_manager.manager is None: + raise HTTPException(status_code=500, detail="CookieManager 未就绪") + + # 检查参数 + if not keyword or not keyword.strip(): + raise HTTPException(status_code=400, detail="关键词不能为空") + + if not image or not image.filename: + raise HTTPException(status_code=400, detail="请选择图片文件") + + # 检查cookie是否属于当前用户 + cookie_details = db_manager.get_cookie_details(cid) + if not cookie_details or cookie_details['user_id'] != current_user['user_id']: + raise HTTPException(status_code=404, detail="账号不存在或无权限") + + try: + logger.info(f"接收到图片关键词添加请求: cid={cid}, keyword={keyword}, item_id={item_id}, filename={image.filename}") + + # 验证图片文件 + if not image.content_type or not image.content_type.startswith('image/'): + logger.warning(f"无效的图片文件类型: {image.content_type}") + raise HTTPException(status_code=400, detail="请上传图片文件") + + # 读取图片数据 + image_data = await image.read() + logger.info(f"读取图片数据成功,大小: {len(image_data)} bytes") + + # 保存图片 + image_url = image_manager.save_image(image_data, image.filename) + if not image_url: + logger.error("图片保存失败") + raise HTTPException(status_code=400, detail="图片保存失败") + + logger.info(f"图片保存成功: {image_url}") + + # 先检查关键词是否已存在 + normalized_item_id = item_id if item_id and item_id.strip() else None + if db_manager.check_keyword_duplicate(cid, keyword, normalized_item_id): + # 删除已保存的图片 + image_manager.delete_image(image_url) + if normalized_item_id: + raise HTTPException(status_code=400, detail=f"关键词 '{keyword}' 在商品 '{normalized_item_id}' 中已存在") + else: + raise HTTPException(status_code=400, detail=f"通用关键词 '{keyword}' 已存在") + + # 保存图片关键词到数据库 + success = db_manager.save_image_keyword(cid, keyword, image_url, item_id or None) + if not success: + # 如果数据库保存失败,删除已保存的图片 + logger.error("数据库保存失败,删除已保存的图片") + image_manager.delete_image(image_url) + raise HTTPException(status_code=400, detail="图片关键词保存失败,请稍后重试") + + log_with_user('info', f"添加图片关键词成功: {cid}, 关键词: {keyword}", current_user) + + return { + "msg": "图片关键词添加成功", + "keyword": keyword, + "image_url": image_url, + "item_id": item_id or None + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"添加图片关键词失败: {e}") + raise HTTPException(status_code=500, detail=f"添加图片关键词失败: {str(e)}") + + +@app.post("/upload-image") +async def upload_image( + image: UploadFile = File(...), + current_user: Dict[str, Any] = Depends(get_current_user) +): + """上传图片(用于卡券等功能)""" + try: + logger.info(f"接收到图片上传请求: filename={image.filename}") + + # 验证图片文件 + if not image.content_type or not image.content_type.startswith('image/'): + logger.warning(f"无效的图片文件类型: {image.content_type}") + raise HTTPException(status_code=400, detail="请上传图片文件") + + # 读取图片数据 + image_data = await image.read() + logger.info(f"读取图片数据成功,大小: {len(image_data)} bytes") + + # 保存图片 + image_url = image_manager.save_image(image_data, image.filename) + if not image_url: + logger.error("图片保存失败") + raise HTTPException(status_code=400, detail="图片保存失败") + + logger.info(f"图片上传成功: {image_url}") + + return { + "message": "图片上传成功", + "image_url": image_url + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"图片上传失败: {e}") + raise HTTPException(status_code=500, detail=f"图片上传失败: {str(e)}") + + +@app.get("/keywords-with-type/{cid}") +def get_keywords_with_type(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是否属于当前用户 + cookie_details = db_manager.get_cookie_details(cid) + if not cookie_details or cookie_details['user_id'] != current_user['user_id']: + raise HTTPException(status_code=404, detail="账号不存在或无权限") + + try: + keywords = db_manager.get_keywords_with_type(cid) + return keywords + except Exception as e: + logger.error(f"获取关键词列表失败: {e}") + raise HTTPException(status_code=500, detail=f"获取关键词列表失败: {str(e)}") + + +@app.delete("/keywords/{cid}/{index}") +def delete_keyword_by_index(cid: str, index: int, current_user: Dict[str, Any] = Depends(get_current_user)): + """根据索引删除关键词""" + if cookie_manager.manager is None: + raise HTTPException(status_code=500, detail="CookieManager 未就绪") + + # 检查cookie是否属于当前用户 + cookie_details = db_manager.get_cookie_details(cid) + if not cookie_details or cookie_details['user_id'] != current_user['user_id']: + raise HTTPException(status_code=404, detail="账号不存在或无权限") + + try: + # 先获取要删除的关键词信息(用于删除图片文件) + keywords = db_manager.get_keywords_with_type(cid) + if 0 <= index < len(keywords): + keyword_data = keywords[index] + + # 删除关键词 + success = db_manager.delete_keyword_by_index(cid, index) + if not success: + raise HTTPException(status_code=400, detail="删除关键词失败") + + # 如果是图片关键词,删除对应的图片文件 + if keyword_data.get('type') == 'image' and keyword_data.get('image_url'): + image_manager.delete_image(keyword_data['image_url']) + + log_with_user('info', f"删除关键词成功: {cid}, 索引: {index}, 关键词: {keyword_data.get('keyword')}", current_user) + + return {"msg": "删除成功"} + else: + raise HTTPException(status_code=400, detail="关键词索引无效") + + except HTTPException: + raise + except Exception as e: + logger.error(f"删除关键词失败: {e}") + raise HTTPException(status_code=500, detail=f"删除关键词失败: {str(e)}") + + +@app.get("/debug/keywords-table-info") +def debug_keywords_table_info(current_user: Dict[str, Any] = Depends(get_current_user)): + """调试:检查keywords表结构""" + try: + import sqlite3 + conn = sqlite3.connect(db_manager.db_path) + cursor = conn.cursor() + + # 获取表结构信息 + cursor.execute("PRAGMA table_info(keywords)") + columns = cursor.fetchall() + + # 获取数据库版本 + cursor.execute("SELECT value FROM system_settings WHERE key = 'db_version'") + version_result = cursor.fetchone() + db_version = version_result[0] if version_result else "未知" + + conn.close() + + return { + "db_version": db_version, + "table_columns": [{"name": col[1], "type": col[2], "default": col[4]} for col in columns] + } + 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)): @@ -1879,6 +2098,7 @@ def create_card(card_data: dict, current_user: Dict[str, Any] = Depends(get_curr api_config=card_data.get('api_config'), text_content=card_data.get('text_content'), data_content=card_data.get('data_content'), + image_url=card_data.get('image_url'), description=card_data.get('description'), enabled=card_data.get('enabled', True), delay_seconds=card_data.get('delay_seconds', 0), @@ -3207,5 +3427,6 @@ def update_item_multi_spec(cookie_id: str, item_id: str, spec_data: dict, _: Non raise HTTPException(status_code=500, detail=str(e)) -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8080) \ No newline at end of file +# 移除自动启动,由Start.py或手动启动 +# if __name__ == "__main__": +# uvicorn.run(app, host="0.0.0.0", port=8080) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 076571e..eae45da 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,12 +36,10 @@ python-multipart>=0.0.6 openai>=1.65.5 # ==================== 图像处理 ==================== -# 验证码生成、二维码生成 Pillow>=10.0.0 qrcode[pil]>=7.4.2 # ==================== 浏览器自动化 ==================== -# 商品搜索、订单详情获取 playwright>=1.40.0 # ==================== 加密和安全 ==================== @@ -52,125 +50,17 @@ cryptography>=41.0.0 # ==================== 时间处理 ==================== python-dateutil>=2.8.2 -# ==================== 正则表达式增强 ==================== -regex>=2023.10.3 - # ==================== Excel文件处理 ==================== -# 数据导入导出功能 pandas>=2.0.0 openpyxl>=3.1.0 # ==================== 邮件发送 ==================== -# 用户注册验证 email-validator>=2.0.0 -# ==================== 其他工具库 ==================== -typing-extensions>=4.7.0 - # ==================== 说明 ==================== # 以下模块是Python内置模块,无需安装: -# - sqlite3 (数据库) -# - smtplib (邮件发送) -# - email (邮件处理) -# - json (JSON处理) -# - base64 (编码解码) -# - hashlib (哈希算法) -# - hmac (消息认证码) -# - time (时间处理) -# - datetime (日期时间) -# - os (操作系统接口) -# - sys (系统相关) -# - re (正则表达式) -# - urllib (URL处理) -# - asyncio (异步编程) -# - threading (多线程) -# - multiprocessing (多进程) -# - pathlib (路径处理) -# - uuid (UUID生成) -# - random (随机数) -# - secrets (安全随机数) -# - traceback (异常追踪) -# - logging (日志记录) -# - collections (集合类型) -# - itertools (迭代工具) -# - functools (函数工具) -# - operator (操作符函数) -# - copy (对象复制) -# - pickle (对象序列化) -# - gzip (压缩) -# - zlib (数据压缩) -# - zipfile (ZIP文件) -# - tarfile (TAR文件) -# - shutil (文件操作) -# - tempfile (临时文件) -# - io (输入输出) -# - csv (CSV文件) -# - xml (XML处理) -# - html (HTML处理) -# - http (HTTP客户端/服务器) -# - socket (网络编程) -# - ssl (SSL/TLS) -# - ftplib (FTP客户端) -# - poplib (POP3客户端) -# - imaplib (IMAP客户端) -# - telnetlib (Telnet客户端) -# - subprocess (子进程) -# - signal (信号处理) -# - atexit (退出处理) -# - weakref (弱引用) -# - gc (垃圾回收) -# - inspect (对象检查) -# - ast (抽象语法树) -# - dis (字节码反汇编) -# - keyword (关键字) -# - token (令牌) -# - tokenize (词法分析) -# - parser (语法分析) -# - symbol (符号) -# - code (代码对象) -# - codeop (代码编译) -# - py_compile (Python编译) -# - compileall (批量编译) -# - importlib (导入机制) -# - pkgutil (包工具) -# - modulefinder (模块查找) -# - runpy (运行Python模块) -# - argparse (命令行参数) -# - getopt (命令行选项) -# - optparse (选项解析) -# - configparser (配置文件) -# - fileinput (文件输入) -# - linecache (行缓存) -# - glob (文件名模式匹配) -# - fnmatch (文件名匹配) -# - difflib (差异比较) -# - textwrap (文本包装) -# - string (字符串) -# - struct (二进制数据) -# - codecs (编解码器) -# - unicodedata (Unicode数据) -# - stringprep (字符串预处理) -# - readline (行编辑) -# - rlcompleter (自动补全) -# - pprint (美化打印) -# - reprlib (repr替代) -# - enum (枚举) -# - numbers (数字抽象基类) -# - math (数学函数) -# - cmath (复数数学) -# - decimal (十进制浮点) -# - fractions (分数) -# - statistics (统计函数) -# - array (数组) -# - bisect (二分查找) -# - heapq (堆队列) -# - queue (队列) -# - types (动态类型) -# - contextlib (上下文管理) -# - abc (抽象基类) -# - atexit (退出处理) -# - traceback (异常追踪) -# - __future__ (未来特性) -# - warnings (警告) -# - dataclasses (数据类) -# - typing (类型提示) \ No newline at end of file +# sqlite3, json, base64, hashlib, hmac, time, datetime, os, sys, re, urllib +# asyncio, threading, pathlib, uuid, random, secrets, traceback, logging +# collections, itertools, functools, copy, pickle, gzip, zipfile, shutil +# tempfile, io, csv, xml, html, http, socket, ssl, subprocess, signal +# inspect, ast, enum, math, decimal, array, queue, contextlib, warnings \ No newline at end of file diff --git a/static/css/app.css b/static/css/app.css index d37c0dd..877633d 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -498,6 +498,17 @@ color: #374151; } + .edit-btn-disabled { + background: #f9fafb !important; + color: #9ca3af !important; + cursor: not-allowed !important; + } + + .edit-btn-disabled:hover { + background: #f9fafb !important; + color: #9ca3af !important; + } + .delete-btn { background: #fef2f2; color: #ef4444; @@ -1275,4 +1286,87 @@ .channel-type-card { min-height: 200px; /* 大屏幕稍微增加高度 */ } + } + + /* 图片关键词相关样式 */ + .btn-image { + background: linear-gradient(135deg, #28a745, #20c997) !important; + border: none !important; + margin-left: 10px; + } + + .btn-image:hover { + background: linear-gradient(135deg, #218838, #1ea085) !important; + transform: translateY(-1px); + } + + .keyword-input-group { + display: flex; + gap: 15px; + align-items: end; + flex-wrap: wrap; + } + + .keyword-input-group .add-btn { + white-space: nowrap; + min-width: auto; + } + + .image-preview { + margin-top: 10px; + } + + .preview-container { + padding: 10px; + border: 2px dashed #ddd; + border-radius: 8px; + text-align: center; + background-color: #f8f9fa; + } + + .keyword-type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + margin-left: 8px; + } + + .keyword-type-text { + background-color: #e3f2fd; + color: #1976d2; + } + + .keyword-type-image { + background-color: #f3e5f5; + color: #7b1fa2; + } + + /* 关键词列表中的图片预览 */ + .keyword-image-preview { + max-width: 60px; + max-height: 60px; + border-radius: 4px; + border: 1px solid #ddd; + cursor: pointer; + } + + .keyword-image-preview:hover { + border-color: #007bff; + transform: scale(1.05); + transition: all 0.2s ease; + } + + /* 响应式调整 */ + @media (max-width: 768px) { + .keyword-input-group { + flex-direction: column; + align-items: stretch; + } + + .keyword-input-group .add-btn { + margin-left: 0; + margin-top: 10px; + } } \ No newline at end of file diff --git a/static/index.html b/static/index.html index 630b6ab..0641f7f 100644 --- a/static/index.html +++ b/static/index.html @@ -456,7 +456,11 @@ +
@@ -1340,6 +1344,7 @@ +
@@ -1404,6 +1409,31 @@ + + +
@@ -2081,7 +2111,7 @@
- 请上传包含"关键词"、"商品ID"、"关键词内容"三列的Excel文件 + 请上传包含"关键词"、"商品ID"、"关键词内容"三列的Excel文件(仅导入文本类型关键词,图片关键词将保留)
@@ -2089,8 +2119,10 @@
+ + + \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index fd1562e..91c7a45 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -451,7 +451,7 @@ async function loadAccountKeywords() { const data = await response.json(); console.log('从服务器获取的关键词数据:', data); // 调试信息 - // 后端返回的是 [{keyword, reply, item_id}, ...] 格式,直接使用 + // 后端返回的是 [{keyword, reply, item_id, type, image_url}, ...] 格式,直接使用 const formattedData = data; console.log('格式化后的关键词数据:', formattedData); // 调试信息 @@ -704,20 +704,50 @@ function renderKeywordsList(keywords) { const keywordItem = document.createElement('div'); keywordItem.className = 'keyword-item'; + + // 判断关键词类型 + const keywordType = item.type || 'text'; // 默认为文本类型 + const isImageType = keywordType === 'image'; + + // 类型标识 + const typeBadge = isImageType ? + ' 图片' : + ' 文本'; + // 商品ID显示 const itemIdDisplay = item.item_id ? ` 商品ID: ${item.item_id}` : ' 通用关键词'; + // 内容显示 + let contentDisplay = ''; + if (isImageType) { + // 图片类型显示图片预览 + const imageUrl = item.reply || item.image_url || ''; + contentDisplay = imageUrl ? + `
+ 关键词图片 +
+

用户发送关键词时将回复此图片

+ 点击图片查看大图 +
+
` : + '

图片加载失败

'; + } else { + // 文本类型显示文本内容 + contentDisplay = `

${item.reply || ''}

`; + } + keywordItem.innerHTML = `
${item.keyword} + ${typeBadge} ${itemIdDisplay}
-
-

${item.reply}

+ ${contentDisplay}
`; container.appendChild(keywordItem); @@ -841,27 +871,18 @@ async function deleteKeyword(cookieId, index) { try { toggleLoading(true); - // 获取当前关键词列表 - const currentKeywords = keywordsData[cookieId] || []; - // 移除指定索引的关键词 - currentKeywords.splice(index, 1); - - // 更新服务器 - const response = await fetch(`${apiBase}/keywords-with-item-id/${cookieId}`, { - method: 'POST', + // 使用新的删除API + const response = await fetch(`${apiBase}/keywords/${cookieId}/${index}`, { + method: 'DELETE', headers: { - 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}` - }, - body: JSON.stringify({ - keywords: currentKeywords - }) + } }); if (response.ok) { showToast('关键词删除成功', 'success'); - keywordsData[cookieId] = currentKeywords; - renderKeywordsList(currentKeywords); + // 重新加载关键词列表 + loadAccountKeywords(); clearKeywordCache(); // 清除缓存 } else { const errorText = await response.text(); @@ -1655,6 +1676,12 @@ document.addEventListener('DOMContentLoaded', async () => { // 初始加载仪表盘 loadDashboard(); + // 初始化图片关键词事件监听器 + initImageKeywordEventListeners(); + + // 初始化卡券图片文件选择器 + initCardImageFileSelector(); + // 点击侧边栏外部关闭移动端菜单 document.addEventListener('click', function(e) { const sidebar = document.getElementById('sidebar'); @@ -2948,6 +2975,9 @@ function renderCardsList(cards) { case 'data': typeBadge = '批量数据'; break; + case 'image': + typeBadge = '图片'; + break; } // 状态标签 @@ -2964,6 +2994,8 @@ function renderCardsList(cards) { dataCount = '∞'; } else if (card.type === 'text') { dataCount = '1'; + } else if (card.type === 'image') { + dataCount = '1'; } // 延时时间显示 @@ -3037,6 +3069,7 @@ function toggleCardTypeFields() { document.getElementById('apiFields').style.display = cardType === 'api' ? 'block' : 'none'; document.getElementById('textFields').style.display = cardType === 'text' ? 'block' : 'none'; document.getElementById('dataFields').style.display = cardType === 'data' ? 'block' : 'none'; + document.getElementById('imageFields').style.display = cardType === 'image' ? 'block' : 'none'; } // 切换多规格字段显示 @@ -3045,6 +3078,111 @@ function toggleMultiSpecFields() { document.getElementById('multiSpecFields').style.display = isMultiSpec ? 'block' : 'none'; } +// 初始化卡券图片文件选择器 +function initCardImageFileSelector() { + const fileInput = document.getElementById('cardImageFile'); + if (fileInput) { + fileInput.addEventListener('change', function(e) { + const file = e.target.files[0]; + if (file) { + // 验证文件类型 + if (!file.type.startsWith('image/')) { + showToast('❌ 请选择图片文件,当前文件类型:' + file.type, 'warning'); + e.target.value = ''; + hideCardImagePreview(); + return; + } + + // 验证文件大小(5MB) + if (file.size > 5 * 1024 * 1024) { + showToast('❌ 图片文件大小不能超过 5MB,当前文件大小:' + (file.size / 1024 / 1024).toFixed(1) + 'MB', 'warning'); + e.target.value = ''; + hideCardImagePreview(); + return; + } + + // 验证图片尺寸 + validateCardImageDimensions(file, e.target); + } else { + hideCardImagePreview(); + } + }); + } +} + +// 验证卡券图片尺寸 +function validateCardImageDimensions(file, inputElement) { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = function() { + const width = this.naturalWidth; + const height = this.naturalHeight; + + // 释放对象URL + URL.revokeObjectURL(url); + + // 检查图片尺寸 + const maxDimension = 4096; + const maxPixels = 8 * 1024 * 1024; // 8M像素 + const totalPixels = width * height; + + if (width > maxDimension || height > maxDimension) { + showToast(`❌ 图片尺寸过大:${width}x${height},最大允许:${maxDimension}x${maxDimension}像素`, 'warning'); + inputElement.value = ''; + hideCardImagePreview(); + return; + } + + if (totalPixels > maxPixels) { + showToast(`❌ 图片像素总数过大:${(totalPixels / 1024 / 1024).toFixed(1)}M像素,最大允许:8M像素`, 'warning'); + inputElement.value = ''; + hideCardImagePreview(); + return; + } + + // 尺寸检查通过,显示预览和提示信息 + showCardImagePreview(file); + + // 如果图片较大,提示会被压缩 + if (width > 2048 || height > 2048) { + showToast(`ℹ️ 图片尺寸较大(${width}x${height}),上传时将自动压缩以优化性能`, 'info'); + } else { + showToast(`✅ 图片尺寸合适(${width}x${height}),可以上传`, 'success'); + } + }; + + img.onerror = function() { + URL.revokeObjectURL(url); + showToast('❌ 无法读取图片文件,请选择有效的图片', 'warning'); + inputElement.value = ''; + hideCardImagePreview(); + }; + + img.src = url; +} + +// 显示卡券图片预览 +function showCardImagePreview(file) { + const reader = new FileReader(); + reader.onload = function(e) { + const previewContainer = document.getElementById('cardImagePreview'); + const previewImg = document.getElementById('cardPreviewImg'); + + previewImg.src = e.target.result; + previewContainer.style.display = 'block'; + }; + reader.readAsDataURL(file); +} + +// 隐藏卡券图片预览 +function hideCardImagePreview() { + const previewContainer = document.getElementById('cardImagePreview'); + if (previewContainer) { + previewContainer.style.display = 'none'; + } +} + // 切换编辑多规格字段显示 function toggleEditMultiSpecFields() { const checkbox = document.getElementById('editIsMultiSpec'); @@ -3202,6 +3340,35 @@ async function saveCard() { case 'data': cardData.data_content = document.getElementById('dataContent').value; break; + case 'image': + // 处理图片上传 + const imageFile = document.getElementById('cardImageFile').files[0]; + if (!imageFile) { + showToast('请选择图片文件', 'warning'); + return; + } + + // 上传图片 + const formData = new FormData(); + formData.append('image', imageFile); + + const uploadResponse = await fetch(`${apiBase}/upload-image`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authToken}` + }, + body: formData + }); + + if (!uploadResponse.ok) { + const errorData = await uploadResponse.json(); + showToast(`图片上传失败: ${errorData.detail || '未知错误'}`, 'danger'); + return; + } + + const uploadResult = await uploadResponse.json(); + cardData.image_url = uploadResult.image_url; + break; } const response = await fetch(`${apiBase}/cards`, { @@ -3307,6 +3474,9 @@ function renderDeliveryRulesList(rules) { case 'data': cardTypeBadge = '批量数据'; break; + case 'image': + cardTypeBadge = '图片'; + break; } } @@ -3396,7 +3566,23 @@ async function loadCardsForSelect() { let displayText = card.name; // 添加类型信息 - const typeText = card.type === 'api' ? 'API' : card.type === 'text' ? '固定文字' : '批量数据'; + let typeText; + switch(card.type) { + case 'api': + typeText = 'API'; + break; + case 'text': + typeText = '固定文字'; + break; + case 'data': + typeText = '批量数据'; + break; + case 'image': + typeText = '图片'; + break; + default: + typeText = '未知类型'; + } displayText += ` (${typeText})`; // 添加规格信息 @@ -3734,7 +3920,23 @@ async function loadCardsForEditSelect() { let displayText = card.name; // 添加类型信息 - const typeText = card.type === 'api' ? 'API' : card.type === 'text' ? '固定文字' : '批量数据'; + let typeText; + switch(card.type) { + case 'api': + typeText = 'API'; + break; + case 'text': + typeText = '固定文字'; + break; + case 'data': + typeText = '批量数据'; + break; + case 'image': + typeText = '图片'; + break; + default: + typeText = '未知类型'; + } displayText += ` (${typeText})`; // 添加规格信息 @@ -5513,3 +5715,373 @@ function refreshQRCode() { clearQRCodeCheck(); generateQRCode(); } + +// ==================== 图片关键词管理功能 ==================== + +// 显示添加图片关键词模态框 +function showAddImageKeywordModal() { + if (!currentCookieId) { + showToast('请先选择账号', 'warning'); + return; + } + + // 加载商品列表到图片关键词模态框 + loadItemsListForImageKeyword(); + + // 显示模态框 + const modal = new bootstrap.Modal(document.getElementById('addImageKeywordModal')); + modal.show(); + + // 清空表单 + document.getElementById('imageKeyword').value = ''; + document.getElementById('imageItemIdSelect').value = ''; + document.getElementById('imageFile').value = ''; + hideImagePreview(); +} + +// 为图片关键词模态框加载商品列表 +async function loadItemsListForImageKeyword() { + try { + const response = await fetch(`${apiBase}/items/${currentCookieId}`, { + headers: { + 'Authorization': `Bearer ${authToken}` + } + }); + + if (response.ok) { + const data = await response.json(); + const items = data.items || []; + + // 更新商品选择下拉框 + const selectElement = document.getElementById('imageItemIdSelect'); + if (selectElement) { + // 清空现有选项(保留第一个默认选项) + selectElement.innerHTML = ''; + + // 添加商品选项 + items.forEach(item => { + const option = document.createElement('option'); + option.value = item.item_id; + option.textContent = `${item.item_id} - ${item.item_title}`; + selectElement.appendChild(option); + }); + } + + console.log(`为图片关键词加载了 ${items.length} 个商品到选择列表`); + } else { + console.warn('加载商品列表失败:', response.status); + } + } catch (error) { + console.error('加载商品列表时发生错误:', error); + } +} + +// 处理图片文件选择事件监听器 +function initImageKeywordEventListeners() { + const imageFileInput = document.getElementById('imageFile'); + if (imageFileInput && !imageFileInput.hasEventListener) { + imageFileInput.addEventListener('change', function(e) { + const file = e.target.files[0]; + if (file) { + // 验证文件类型 + if (!file.type.startsWith('image/')) { + showToast('请选择图片文件', 'warning'); + e.target.value = ''; + hideImagePreview(); + return; + } + + // 验证文件大小(5MB) + if (file.size > 5 * 1024 * 1024) { + showToast('❌ 图片文件大小不能超过 5MB,当前文件大小:' + (file.size / 1024 / 1024).toFixed(1) + 'MB', 'warning'); + e.target.value = ''; + hideImagePreview(); + return; + } + + // 验证图片尺寸 + validateImageDimensions(file, e.target); + } else { + hideImagePreview(); + } + }); + imageFileInput.hasEventListener = true; + } +} + +// 验证图片尺寸 +function validateImageDimensions(file, inputElement) { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = function() { + const width = this.naturalWidth; + const height = this.naturalHeight; + + // 释放对象URL + URL.revokeObjectURL(url); + + // 检查图片尺寸 + const maxDimension = 4096; + const maxPixels = 8 * 1024 * 1024; // 8M像素 + const totalPixels = width * height; + + if (width > maxDimension || height > maxDimension) { + showToast(`❌ 图片尺寸过大:${width}x${height},最大允许:${maxDimension}x${maxDimension}像素`, 'warning'); + inputElement.value = ''; + hideImagePreview(); + return; + } + + if (totalPixels > maxPixels) { + showToast(`❌ 图片像素总数过大:${(totalPixels / 1024 / 1024).toFixed(1)}M像素,最大允许:8M像素`, 'warning'); + inputElement.value = ''; + hideImagePreview(); + return; + } + + // 尺寸检查通过,显示预览和提示信息 + showImagePreview(file); + + // 如果图片较大,提示会被压缩 + if (width > 2048 || height > 2048) { + showToast(`ℹ️ 图片尺寸较大(${width}x${height}),上传时将自动压缩以优化性能`, 'info'); + } else { + showToast(`✅ 图片尺寸合适(${width}x${height}),可以上传`, 'success'); + } + }; + + img.onerror = function() { + URL.revokeObjectURL(url); + showToast('❌ 无法读取图片文件,请选择有效的图片', 'warning'); + inputElement.value = ''; + hideImagePreview(); + }; + + img.src = url; +} + +// 显示图片预览 +function showImagePreview(file) { + const reader = new FileReader(); + reader.onload = function(e) { + const previewContainer = document.getElementById('imagePreview'); + const previewImg = document.getElementById('previewImg'); + + previewImg.src = e.target.result; + previewContainer.style.display = 'block'; + }; + reader.readAsDataURL(file); +} + +// 隐藏图片预览 +function hideImagePreview() { + const previewContainer = document.getElementById('imagePreview'); + if (previewContainer) { + previewContainer.style.display = 'none'; + } +} + +// 添加图片关键词 +async function addImageKeyword() { + const keyword = document.getElementById('imageKeyword').value.trim(); + const itemId = document.getElementById('imageItemIdSelect').value.trim(); + const fileInput = document.getElementById('imageFile'); + const file = fileInput.files[0]; + + if (!keyword) { + showToast('请填写关键词', 'warning'); + return; + } + + if (!file) { + showToast('请选择图片文件', 'warning'); + return; + } + + if (!currentCookieId) { + showToast('请先选择账号', 'warning'); + return; + } + + try { + toggleLoading(true); + + // 创建FormData对象 + const formData = new FormData(); + formData.append('keyword', keyword); + formData.append('item_id', itemId || ''); + formData.append('image', file); + + const response = await fetch(`${apiBase}/keywords/${currentCookieId}/image`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authToken}` + }, + body: formData + }); + + if (response.ok) { + showToast(`✨ 图片关键词 "${keyword}" 添加成功!`, 'success'); + + // 关闭模态框 + const modal = bootstrap.Modal.getInstance(document.getElementById('addImageKeywordModal')); + modal.hide(); + + // 重新加载关键词列表 + loadAccountKeywords(); + clearKeywordCache(); + } else { + try { + const errorData = await response.json(); + let errorMessage = errorData.detail || '图片关键词添加失败'; + + // 根据不同的错误类型提供更友好的提示 + if (errorMessage.includes('图片尺寸过大')) { + errorMessage = '❌ 图片尺寸过大,请选择尺寸较小的图片(建议不超过4096x4096像素)'; + } else if (errorMessage.includes('图片像素总数过大')) { + errorMessage = '❌ 图片像素总数过大,请选择分辨率较低的图片'; + } else if (errorMessage.includes('图片数据验证失败')) { + errorMessage = '❌ 图片格式不支持或文件损坏,请选择JPG、PNG、GIF格式的图片'; + } else if (errorMessage.includes('图片保存失败')) { + errorMessage = '❌ 图片保存失败,请检查图片格式和大小后重试'; + } else if (errorMessage.includes('文件大小超过限制')) { + errorMessage = '❌ 图片文件过大,请选择小于5MB的图片'; + } else if (errorMessage.includes('不支持的图片格式')) { + errorMessage = '❌ 不支持的图片格式,请选择JPG、PNG、GIF格式的图片'; + } else if (response.status === 413) { + errorMessage = '❌ 图片文件过大,请选择小于5MB的图片'; + } else if (response.status === 400) { + errorMessage = `❌ 请求参数错误:${errorMessage}`; + } else if (response.status === 500) { + errorMessage = '❌ 服务器内部错误,请稍后重试'; + } + + console.error('图片关键词添加失败:', errorMessage); + showToast(errorMessage, 'danger'); + } catch (e) { + // 如果不是JSON格式,使用文本 + const errorText = await response.text(); + console.error('图片关键词添加失败:', errorText); + + let friendlyMessage = '图片关键词添加失败'; + if (response.status === 413) { + friendlyMessage = '❌ 图片文件过大,请选择小于5MB的图片'; + } else if (response.status === 400) { + friendlyMessage = '❌ 图片格式不正确或参数错误,请检查后重试'; + } else if (response.status === 500) { + friendlyMessage = '❌ 服务器内部错误,请稍后重试'; + } + + showToast(friendlyMessage, 'danger'); + } + } + } catch (error) { + console.error('添加图片关键词失败:', error); + showToast('添加图片关键词失败', 'danger'); + } finally { + toggleLoading(false); + } +} + +// 显示图片模态框 +function showImageModal(imageUrl) { + // 创建模态框HTML + const modalHtml = ` + + `; + + // 移除已存在的模态框 + const existingModal = document.getElementById('imageViewModal'); + if (existingModal) { + existingModal.remove(); + } + + // 添加新模态框 + document.body.insertAdjacentHTML('beforeend', modalHtml); + + // 显示模态框 + const modal = new bootstrap.Modal(document.getElementById('imageViewModal')); + modal.show(); + + // 模态框关闭后移除DOM元素 + document.getElementById('imageViewModal').addEventListener('hidden.bs.modal', function() { + this.remove(); + }); +} + +// 编辑图片关键词(不允许修改) +function editImageKeyword(index) { + showToast('图片关键词不允许修改,请删除后重新添加', 'warning'); +} + +// 修改导出关键词函数,使用后端导出API +async function exportKeywords() { + if (!currentCookieId) { + showToast('请先选择账号', 'warning'); + return; + } + + try { + toggleLoading(true); + + // 使用后端导出API + const response = await fetch(`${apiBase}/keywords-export/${currentCookieId}`, { + headers: { + 'Authorization': `Bearer ${authToken}` + } + }); + + if (response.ok) { + // 获取文件blob + const blob = await response.blob(); + + // 从响应头获取文件名 + const contentDisposition = response.headers.get('Content-Disposition'); + let fileName = `关键词数据_${currentCookieId}_${new Date().toISOString().slice(0, 10)}.xlsx`; + + if (contentDisposition) { + const fileNameMatch = contentDisposition.match(/filename\*=UTF-8''(.+)/); + if (fileNameMatch) { + fileName = decodeURIComponent(fileNameMatch[1]); + } + } + + // 创建下载链接 + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + + // 清理 + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + showToast('✅ 关键词导出成功', 'success'); + } else { + const errorText = await response.text(); + console.error('导出关键词失败:', errorText); + showToast('导出关键词失败', 'danger'); + } + } catch (error) { + console.error('导出关键词失败:', error); + showToast('导出关键词失败', 'danger'); + } finally { + toggleLoading(false); + } +} diff --git a/utils/image_uploader.py b/utils/image_uploader.py new file mode 100644 index 0000000..4e817be --- /dev/null +++ b/utils/image_uploader.py @@ -0,0 +1,216 @@ +""" +图片上传器 - 负责将图片上传到闲鱼CDN +""" +import aiohttp +import asyncio +import json +import os +import tempfile +from typing import Optional, Dict, Any +from loguru import logger +from PIL import Image +import io + + +class ImageUploader: + """图片上传器 - 上传图片到闲鱼CDN""" + + def __init__(self, cookies_str: str): + self.cookies_str = cookies_str + self.upload_url = "https://stream-upload.goofish.com/api/upload.api?floderId=0&appkey=xy_chat&_input_charset=utf-8" + self.session = None + + async def create_session(self): + """创建HTTP会话""" + if not self.session: + connector = aiohttp.TCPConnector(limit=100, limit_per_host=30) + timeout = aiohttp.ClientTimeout(total=30) + self.session = aiohttp.ClientSession( + connector=connector, + timeout=timeout, + headers={ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + ) + + async def close_session(self): + """关闭HTTP会话""" + if self.session: + await self.session.close() + self.session = None + + def _compress_image(self, image_path: str, max_size: int = 5 * 1024 * 1024, quality: int = 85) -> Optional[str]: + """压缩图片""" + try: + with Image.open(image_path) as img: + # 转换为RGB模式(如果是RGBA或其他模式) + if img.mode in ('RGBA', 'LA', 'P'): + # 创建白色背景 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + # 获取原始尺寸 + original_width, original_height = img.size + + # 如果图片太大,调整尺寸 + max_dimension = 1920 + if original_width > max_dimension or original_height > max_dimension: + if original_width > original_height: + new_width = max_dimension + new_height = int((original_height * max_dimension) / original_width) + else: + new_height = max_dimension + new_width = int((original_width * max_dimension) / original_height) + + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + logger.info(f"图片尺寸调整: {original_width}x{original_height} -> {new_width}x{new_height}") + + # 创建临时文件 + temp_fd, temp_path = tempfile.mkstemp(suffix='.jpg') + os.close(temp_fd) + + # 保存压缩后的图片 + img.save(temp_path, 'JPEG', quality=quality, optimize=True) + + # 检查文件大小 + file_size = os.path.getsize(temp_path) + if file_size > max_size: + # 如果还是太大,降低质量 + quality = max(30, quality - 20) + img.save(temp_path, 'JPEG', quality=quality, optimize=True) + file_size = os.path.getsize(temp_path) + logger.info(f"图片质量调整为 {quality}%,文件大小: {file_size / 1024:.1f}KB") + + logger.info(f"图片压缩完成: {file_size / 1024:.1f}KB") + return temp_path + + except Exception as e: + logger.error(f"图片压缩失败: {e}") + return None + + async def upload_image(self, image_path: str) -> Optional[str]: + """上传图片到闲鱼CDN""" + temp_path = None + try: + if not self.session: + await self.create_session() + + # 压缩图片 + temp_path = self._compress_image(image_path) + if not temp_path: + logger.error("图片压缩失败") + return None + + # 读取压缩后的图片数据 + with open(temp_path, 'rb') as f: + image_data = f.read() + + # 构造文件名 + filename = os.path.basename(image_path) + if not filename.lower().endswith(('.jpg', '.jpeg')): + filename = os.path.splitext(filename)[0] + '.jpg' + + # 构造请求头 + headers = { + 'cookie': self.cookies_str, + 'Referer': 'https://www.goofish.com/', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'x-requested-with': 'XMLHttpRequest', + 'Accept': 'application/json, text/javascript, */*; q=0.01', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-site' + } + + # 构造multipart/form-data + data = aiohttp.FormData() + data.add_field('file', image_data, filename=filename, content_type='image/jpeg') + + # 发送上传请求 + logger.info(f"开始上传图片到闲鱼CDN: {filename}") + async with self.session.post(self.upload_url, data=data, headers=headers) as response: + if response.status == 200: + response_text = await response.text() + logger.debug(f"上传响应: {response_text}") + + # 解析响应获取图片URL + image_url = self._parse_upload_response(response_text) + if image_url: + logger.info(f"图片上传成功: {image_url}") + return image_url + else: + logger.error("解析上传响应失败") + return None + else: + logger.error(f"图片上传失败: HTTP {response.status}") + return None + + except Exception as e: + logger.error(f"图片上传异常: {e}") + return None + finally: + # 清理临时文件 + if temp_path and os.path.exists(temp_path): + try: + os.remove(temp_path) + except: + pass + + def _parse_upload_response(self, response_text: str) -> Optional[str]: + """解析上传响应获取图片URL""" + try: + # 尝试解析JSON响应 + response_data = json.loads(response_text) + + # 方式1: 标准响应格式 + if 'data' in response_data and 'url' in response_data['data']: + return response_data['data']['url'] + + # 方式2: 在object字段中(闲鱼CDN的响应格式) + if 'object' in response_data and isinstance(response_data['object'], dict): + obj = response_data['object'] + if 'url' in obj: + logger.info(f"从object.url提取到图片URL: {obj['url']}") + return obj['url'] + + # 方式3: 直接在根级别 + if 'url' in response_data: + return response_data['url'] + + # 方式4: 在result中 + if 'result' in response_data and 'url' in response_data['result']: + return response_data['result']['url'] + + # 方式5: 检查是否有文件信息 + if 'data' in response_data and isinstance(response_data['data'], dict): + data = response_data['data'] + if 'fileUrl' in data: + return data['fileUrl'] + if 'file_url' in data: + return data['file_url'] + + logger.error(f"无法从响应中提取图片URL: {response_data}") + return None + + except json.JSONDecodeError: + # 如果不是JSON格式,尝试其他解析方式 + logger.error(f"响应不是有效的JSON格式: {response_text}") + return None + except Exception as e: + logger.error(f"解析上传响应异常: {e}") + return None + + async def __aenter__(self): + await self.create_session() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close_session() diff --git a/utils/image_utils.py b/utils/image_utils.py new file mode 100644 index 0000000..03a43ac --- /dev/null +++ b/utils/image_utils.py @@ -0,0 +1,256 @@ +import os +import uuid +import hashlib +from PIL import Image +from typing import Optional, Tuple +from loguru import logger + +class ImageManager: + """图片管理器,负责图片的保存、压缩和访问""" + + def __init__(self, upload_dir: str = "static/uploads/images"): + """初始化图片管理器 + + Args: + upload_dir: 图片上传目录 + """ + self.upload_dir = upload_dir + self.max_size = 5 * 1024 * 1024 # 5MB + self.max_width = 1920 + self.max_height = 1080 + self.allowed_formats = {'JPEG', 'PNG', 'GIF', 'WEBP'} + + # 确保上传目录存在 + self._ensure_upload_dir() + + def _ensure_upload_dir(self): + """确保上传目录存在""" + try: + os.makedirs(self.upload_dir, exist_ok=True) + logger.info(f"图片上传目录已准备: {self.upload_dir}") + except Exception as e: + logger.error(f"创建图片上传目录失败: {e}") + raise + + def save_image(self, image_data: bytes, original_filename: str = None) -> Optional[str]: + """保存图片文件 + + Args: + image_data: 图片二进制数据 + original_filename: 原始文件名(可选) + + Returns: + 保存成功返回相对路径,失败返回None + """ + try: + logger.info(f"开始保存图片,数据大小: {len(image_data)} bytes") + + # 验证图片数据 + if not self._validate_image_data(image_data): + logger.error("图片数据验证失败") + return None + + # 生成唯一文件名 + file_hash = hashlib.md5(image_data).hexdigest() + file_extension = self._get_image_extension(image_data) + filename = f"{file_hash}_{uuid.uuid4().hex[:8]}.{file_extension}" + + # 完整文件路径 + file_path = os.path.join(self.upload_dir, filename) + + # 检查文件是否已存在 + if os.path.exists(file_path): + logger.info(f"图片文件已存在,跳过保存: {filename}") + return self._get_relative_path(file_path) + + # 处理和保存图片 + processed_image_data = self._process_image(image_data) + + with open(file_path, 'wb') as f: + f.write(processed_image_data) + + logger.info(f"图片保存成功: {filename}") + return self._get_relative_path(file_path) + + except Exception as e: + logger.error(f"保存图片失败: {e}") + return None + + def _validate_image_data(self, image_data: bytes) -> bool: + """验证图片数据""" + try: + # 检查文件大小 + if len(image_data) > self.max_size: + logger.warning(f"图片文件过大: {len(image_data)} bytes > {self.max_size} bytes") + return False + + # 尝试打开图片验证格式 + from io import BytesIO + with Image.open(BytesIO(image_data)) as img: + if img.format not in self.allowed_formats: + logger.warning(f"不支持的图片格式: {img.format}") + return False + + # 检查图片尺寸(允许更大的尺寸,特别是手机长截图) + width, height = img.size + max_dimension = 4096 # 最大边长4096像素 + if width > max_dimension or height > max_dimension: + logger.warning(f"图片尺寸过大: {width}x{height},最大允许: {max_dimension}x{max_dimension}") + return False + + # 检查图片像素总数(防止过大的图片占用太多内存) + total_pixels = width * height + max_pixels = 8 * 1024 * 1024 # 8M像素 + if total_pixels > max_pixels: + logger.warning(f"图片像素总数过大: {total_pixels},最大允许: {max_pixels}") + return False + + return True + + except Exception as e: + logger.error(f"图片验证失败: {e}") + return False + + def _get_image_extension(self, image_data: bytes) -> str: + """获取图片扩展名""" + try: + from io import BytesIO + with Image.open(BytesIO(image_data)) as img: + format_to_ext = { + 'JPEG': 'jpg', + 'PNG': 'png', + 'GIF': 'gif', + 'WEBP': 'webp' + } + return format_to_ext.get(img.format, 'jpg') + except: + return 'jpg' + + def _process_image(self, image_data: bytes) -> bytes: + """处理图片(压缩、调整尺寸等)""" + try: + from io import BytesIO + + with Image.open(BytesIO(image_data)) as img: + # 转换为RGB模式(如果需要) + if img.mode in ('RGBA', 'LA', 'P'): + # 创建白色背景 + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + # 调整尺寸(如果需要)- 允许更大的尺寸 + width, height = img.size + max_output_dimension = 2048 # 输出最大边长2048像素 + + if width > max_output_dimension or height > max_output_dimension: + # 计算缩放比例,保持宽高比 + ratio = min(max_output_dimension / width, max_output_dimension / height) + new_width = int(width * ratio) + new_height = int(height * ratio) + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + logger.info(f"图片已调整尺寸: {width}x{height} -> {new_width}x{new_height}") + else: + logger.info(f"图片尺寸合适,无需调整: {width}x{height}") + + # 保存为JPEG格式,适度压缩 + output = BytesIO() + img.save(output, format='JPEG', quality=85, optimize=True) + return output.getvalue() + + except Exception as e: + logger.error(f"图片处理失败: {e}") + # 如果处理失败,返回原始数据 + return image_data + + def _get_relative_path(self, file_path: str) -> str: + """获取相对于项目根目录的路径""" + # 将绝对路径转换为相对路径 + rel_path = os.path.relpath(file_path) + # 统一使用正斜杠 + return rel_path.replace('\\', '/') + + def delete_image(self, image_path: str) -> bool: + """删除图片文件 + + Args: + image_path: 图片相对路径 + + Returns: + 删除成功返回True,失败返回False + """ + try: + # 构建完整路径 + if not image_path.startswith(self.upload_dir): + full_path = os.path.join(os.getcwd(), image_path) + else: + full_path = image_path + + if os.path.exists(full_path): + os.remove(full_path) + logger.info(f"图片删除成功: {image_path}") + return True + else: + logger.warning(f"图片文件不存在: {image_path}") + return False + + except Exception as e: + logger.error(f"删除图片失败: {e}") + return False + + def get_image_info(self, image_path: str) -> Optional[dict]: + """获取图片信息 + + Args: + image_path: 图片相对路径 + + Returns: + 图片信息字典或None + """ + try: + # 构建完整路径 + if not image_path.startswith(self.upload_dir): + full_path = os.path.join(os.getcwd(), image_path) + else: + full_path = image_path + + if not os.path.exists(full_path): + return None + + with Image.open(full_path) as img: + return { + 'width': img.width, + 'height': img.height, + 'format': img.format, + 'mode': img.mode, + 'size': os.path.getsize(full_path) + } + + except Exception as e: + logger.error(f"获取图片信息失败: {e}") + return None + + def get_image_size(self, image_path: str) -> tuple: + """获取图片尺寸 + + Args: + image_path: 图片相对路径 + + Returns: + (width, height) 或 (None, None) + """ + try: + info = self.get_image_info(image_path) + if info: + return info['width'], info['height'] + return None, None + except Exception as e: + logger.error(f"获取图片尺寸失败: {e}") + return None, None + +# 创建全局图片管理器实例 +image_manager = ImageManager()