Compare commits

...

5 Commits

Author SHA1 Message Date
zhinianboke
3a09c74963 Update README.md 2025-08-04 20:01:51 +08:00
zhinianboke
0b37cdd869 修复bug 2025-08-04 19:49:04 +08:00
zhinianboke
7f6ca55ef9 修改bug 2025-08-04 19:46:05 +08:00
zhinianboke
c8045da0b0 新增windows部署 2025-08-04 19:40:31 +08:00
zhinianboke
1c2cd1bce8 新增自动回复图片,发货发图片 2025-08-04 19:28:02 +08:00
16 changed files with 2545 additions and 300 deletions

7
.gitignore vendored
View File

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

View File

@ -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用户运行
# 在生产环境中,建议配置适当的用户映射
@ -99,22 +99,8 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# 创建启动脚本
COPY <<EOF /app/entrypoint.sh
#!/bin/bash
set -e
echo "🚀 启动闲鱼自动回复系统..."
echo "📊 数据库将在应用启动时自动初始化..."
echo "🎯 启动主应用..."
# 确保数据目录存在
mkdir -p /app/data /app/logs /app/backups
# 启动主应用
exec python Start.py
EOF
# 复制启动脚本
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# 启动命令

View File

@ -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用户运行
# 在生产环境中,建议配置适当的用户映射
@ -102,22 +102,8 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
# 创建启动脚本
COPY <<EOF /app/entrypoint.sh
#!/bin/bash
set -e
echo "🚀 启动闲鱼自动回复系统..."
echo "📊 数据库将在应用启动时自动初始化..."
echo "🎯 启动主应用..."
# 确保数据目录存在
mkdir -p /app/data /app/logs /app/backups
# 启动主应用
exec python Start.py
EOF
# 复制启动脚本
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# 启动命令

178
README.md
View File

@ -39,7 +39,8 @@
- **延时发货** - 支持设置发货延时时间0-3600秒
- **多种触发** - 支持付款消息、小刀消息等多种触发条件
- **防重复发货** - 智能防重复机制,避免重复发货
- **多种发货方式** - 支持固定文字、批量数据、API调用等发货方式
- **多种发货方式** - 支持固定文字、批量数据、API调用、图片发货等方式
- **图片发货** - 支持上传图片并自动发送给买家图片自动上传到CDN
- **自动确认发货** - 检测到付款后自动调用闲鱼API确认发货
- **防重复确认** - 智能防重复确认机制避免重复API调用
- **发货统计** - 完整的发货记录和统计功能
@ -71,6 +72,7 @@
- **多规格卡券管理** - 支持创建和管理多规格卡券
- **发货规则管理** - 支持多规格发货规则的创建和管理
- **数据备份** - 自动数据备份和恢复
- **一键部署** - 提供预构建Docker镜像无需编译即可快速部署
## 📁 项目结构
@ -97,7 +99,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,61 +111,94 @@ 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镜像构建文件
│ ├── Dockerfile-cn # Docker镜像构建文件中国镜像源
│ ├── docker-compose.yml # Docker Compose一键部署配置
│ ├── docker-deploy.sh # Docker部署管理脚本
│ └── nginx/ # Nginx反向代理配置
│ ├── docker-compose-cn.yml # Docker Compose配置中国镜像源
│ ├── docker-deploy.sh # Docker部署管理脚本Linux/macOS
│ ├── docker-deploy.bat # Docker部署管理脚本Windows
│ └── entrypoint.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/ # 图片文件存储(已忽略)
```
</details>
## 🚀 快速开始
**⚡ 最快部署方式(推荐)**:使用预构建镜像,无需下载源码,一条命令即可启动!
### 方式一Docker 一键部署(最简单)
```bash
# 创建数据目录
# 1. 创建数据目录
mkdir -p xianyu-auto-reply
# 一键启动容器
# 2. 一键启动容器
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
# 访问系统
# 3. 访问系统
# http://localhost:8080
```
### 方式二Docker Compose 部署(推荐)
**Windows用户**
```cmd
# 创建数据目录
mkdir xianyu-auto-reply
# 启动容器
docker run -d -p 8080:8080 -v %cd%/xianyu-auto-reply/:/app/data/ --name xianyu-auto-reply registry.cn-shanghai.aliyuncs.com/zhinian-software/xianyu-auto-reply:1.0
```
### 方式二:从源码构建部署
```bash
# 1. 克隆项目
git clone https://github.com/zhinianboke/xianyu-auto-reply.git
cd xianyu-auto-reply
# 2. 一键部署
# 2. 设置脚本执行权限Linux/macOS
chmod +x docker-deploy.sh
# 3. 一键部署(自动构建镜像)
./docker-deploy.sh
# 3. 访问系统
# 4. 访问系统
# http://localhost:8080
```
### 方式三:本地部署(开发环境)
**Windows用户**
```cmd
# 使用Windows批处理脚本推荐
docker-deploy.bat
# 或者使用Git Bash/WSL
bash docker-deploy.sh
# 或者直接使用Docker Compose
docker-compose up -d --build
```
### 方式三:本地开发部署
```bash
# 1. 克隆项目
@ -322,42 +359,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依赖包列表精简版本无冗余依赖
## ⚙️ 配置说明
@ -461,6 +506,61 @@ python Start.py
- 提交 Pull Request
## ❓ 常见问题
### 1. 端口被占用
如果8080端口被占用可以修改 `.env` 文件中的 `WEB_PORT` 配置。
### 2. 数据库连接失败
检查数据库文件权限,确保应用有读写权限。
### 3. WebSocket连接失败
检查防火墙设置确保WebSocket端口可以访问。
### 4. Shell脚本执行错误Linux/macOS
如果遇到 `bad interpreter` 错误,说明脚本的行结束符格式不正确:
```bash
# 方法1手动修复行结束符
sed -i 's/\r$//' docker-deploy.sh
chmod +x docker-deploy.sh
./docker-deploy.sh
# 方法2直接使用bash运行
bash docker-deploy.sh
```
### 5. Docker容器启动失败
如果遇到 `exec /app/entrypoint.sh: no such file or directory` 错误:
```bash
# 确保entrypoint.sh文件存在并重新构建
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
### 6. 预构建镜像拉取失败
如果无法拉取预构建镜像,可以使用源码构建:
```bash
# 克隆项目并从源码构建
git clone https://github.com/zhinianboke/xianyu-auto-reply.git
cd xianyu-auto-reply
./docker-deploy.sh
```
### 7. Windows系统部署
Windows用户推荐使用批处理脚本
```cmd
# 使用Windows批处理脚本
docker-deploy.bat
# 或者使用PowerShell
powershell -ExecutionPolicy Bypass -File docker-deploy.bat
```
## 📞 技术支持

View File

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

View File

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

227
docker-deploy.bat Normal file
View File

@ -0,0 +1,227 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
REM 闲鱼自动回复系统 Docker 部署脚本 (Windows版本)
REM 支持快速部署和管理
title 闲鱼自动回复系统 Docker 部署
REM 项目配置
set PROJECT_NAME=xianyu-auto-reply
set COMPOSE_FILE=docker-compose.yml
set ENV_FILE=.env
REM 颜色定义Windows CMD不支持ANSI颜色使用echo代替
set "INFO_PREFIX=[INFO]"
set "SUCCESS_PREFIX=[SUCCESS]"
set "WARNING_PREFIX=[WARNING]"
set "ERROR_PREFIX=[ERROR]"
REM 检查依赖
echo %INFO_PREFIX% 检查系统依赖...
where docker >nul 2>&1
if %errorlevel% neq 0 (
echo %ERROR_PREFIX% Docker 未安装,请先安装 Docker Desktop
echo 下载地址: https://www.docker.com/products/docker-desktop
pause
exit /b 1
)
where docker-compose >nul 2>&1
if %errorlevel% neq 0 (
echo %ERROR_PREFIX% Docker Compose 未安装,请先安装 Docker Compose
pause
exit /b 1
)
echo %SUCCESS_PREFIX% 系统依赖检查通过
REM 初始化配置
echo %INFO_PREFIX% 初始化配置文件...
if not exist "%ENV_FILE%" (
echo %WARNING_PREFIX% %ENV_FILE% 文件不存在,将使用默认配置
echo %INFO_PREFIX% 如需自定义配置,请创建 %ENV_FILE% 文件
) else (
echo %SUCCESS_PREFIX% %ENV_FILE% 配置文件已存在
)
REM 检查关键文件
if not exist "entrypoint.sh" (
echo %ERROR_PREFIX% entrypoint.sh 文件不存在Docker容器将无法启动
echo %INFO_PREFIX% 请确保项目文件完整
pause
exit /b 1
) else (
echo %SUCCESS_PREFIX% entrypoint.sh 文件已存在
)
REM 创建必要的目录
if not exist "data" mkdir data
if not exist "logs" mkdir logs
if not exist "backups" mkdir backups
if not exist "static\uploads\images" mkdir static\uploads\images
echo %SUCCESS_PREFIX% 已创建必要的目录
REM 处理命令行参数
if "%1"=="" goto quick_deploy
if "%1"=="help" goto show_help
if "%1"=="start" goto start_services
if "%1"=="stop" goto stop_services
if "%1"=="restart" goto restart_services
if "%1"=="status" goto show_status
if "%1"=="logs" goto show_logs
if "%1"=="build" goto build_image
if "%1"=="cleanup" goto cleanup
goto unknown_command
:quick_deploy
echo %INFO_PREFIX% 快速部署模式
goto build_and_start
:build_image
echo %INFO_PREFIX% 构建 Docker 镜像...
set /p use_cn="是否使用国内镜像源?(y/n): "
if /i "!use_cn!"=="y" (
docker-compose -f docker-compose-cn.yml build --no-cache
) else (
docker-compose build --no-cache
)
if %errorlevel% neq 0 (
echo %ERROR_PREFIX% 镜像构建失败
pause
exit /b 1
)
echo %SUCCESS_PREFIX% 镜像构建完成
goto end
:build_and_start
call :build_image
if %errorlevel% neq 0 exit /b 1
:start_services
echo %INFO_PREFIX% 启动服务...
docker-compose up -d
if %errorlevel% neq 0 (
echo %ERROR_PREFIX% 服务启动失败
docker-compose logs
pause
exit /b 1
)
echo %SUCCESS_PREFIX% 服务启动完成
REM 等待服务就绪
echo %INFO_PREFIX% 等待服务就绪...
timeout /t 10 /nobreak >nul
REM 检查服务状态
docker-compose ps | findstr "Up" >nul
if %errorlevel% equ 0 (
echo %SUCCESS_PREFIX% 服务运行正常
call :show_access_info
) else (
echo %ERROR_PREFIX% 服务启动失败
docker-compose logs
pause
exit /b 1
)
goto end
:stop_services
echo %INFO_PREFIX% 停止服务...
docker-compose down
echo %SUCCESS_PREFIX% 服务已停止
goto end
:restart_services
echo %INFO_PREFIX% 重启服务...
docker-compose restart
echo %SUCCESS_PREFIX% 服务已重启
goto end
:show_status
echo %INFO_PREFIX% 服务状态:
docker-compose ps
echo.
echo %INFO_PREFIX% 资源使用:
for /f "tokens=*" %%i in ('docker-compose ps -q') do (
docker stats --no-stream %%i
)
goto end
:show_logs
if "%2"=="" (
docker-compose logs -f
) else (
docker-compose logs -f %2
)
goto end
:cleanup
echo %WARNING_PREFIX% 这将删除所有容器、镜像和数据,确定要继续吗?
set /p confirm="请输入 y 确认: "
if /i "!confirm!"=="y" (
echo %INFO_PREFIX% 清理环境...
docker-compose down -v --rmi all
rmdir /s /q data logs backups 2>nul
echo %SUCCESS_PREFIX% 环境清理完成
) else (
echo %INFO_PREFIX% 取消清理操作
)
goto end
:show_access_info
echo.
echo %SUCCESS_PREFIX% 🎉 部署完成!
echo.
echo 📱 访问地址:
echo HTTP: http://localhost:8080
echo.
echo 🔐 默认登录信息:
echo 用户名: admin
echo 密码: admin123
echo.
echo 📊 管理命令:
echo 查看状态: %~nx0 status
echo 查看日志: %~nx0 logs
echo 重启服务: %~nx0 restart
echo 停止服务: %~nx0 stop
echo.
goto :eof
:show_help
echo 闲鱼自动回复系统 Docker 部署脚本 (Windows版本)
echo.
echo 用法: %~nx0 [命令]
echo.
echo 命令:
echo start 启动服务
echo stop 停止服务
echo restart 重启服务
echo status 查看服务状态
echo logs 查看日志
echo build 构建镜像
echo cleanup 清理环境
echo help 显示帮助信息
echo.
echo 示例:
echo %~nx0 # 快速部署
echo %~nx0 start # 启动服务
echo %~nx0 logs # 查看日志
echo.
goto end
:unknown_command
echo %ERROR_PREFIX% 未知命令: %1
call :show_help
exit /b 1
:end
if "%1"=="" (
echo.
echo 按任意键退出...
pause >nul
)

View File

@ -62,8 +62,17 @@ init_config() {
print_success "$ENV_FILE 配置文件已存在"
fi
# 检查关键文件
if [ ! -f "entrypoint.sh" ]; then
print_error "entrypoint.sh 文件不存在Docker容器将无法启动"
print_info "请确保项目文件完整"
exit 1
else
print_success "entrypoint.sh 文件已存在"
fi
# 创建必要的目录
mkdir -p data logs backups
mkdir -p data logs backups static/uploads/images
print_success "已创建必要的目录"
}

15
entrypoint.sh Normal file
View File

@ -0,0 +1,15 @@
#!/bin/bash
set -e
echo "🚀 启动闲鱼自动回复系统..."
echo "📊 数据库将在应用启动时自动初始化..."
echo "🎯 启动主应用..."
# 确保数据目录存在
mkdir -p /app/data /app/logs /app/backups /app/static/uploads/images
# 设置目录权限
chmod 777 /app/data /app/logs /app/backups /app/static/uploads /app/static/uploads/images
# 启动主应用
exec python Start.py

View File

@ -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)
# 移除自动启动由Start.py或手动启动
# if __name__ == "__main__":
# uvicorn.run(app, host="0.0.0.0", port=8080)

View File

@ -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 (类型提示)
# 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

View File

@ -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;
}
}

View File

@ -456,7 +456,11 @@
</div>
<button class="add-btn" onclick="addKeyword()">
<i class="bi bi-plus-lg"></i>
添加
添加文本关键词
</button>
<button class="add-btn btn-image" onclick="showAddImageKeywordModal()">
<i class="bi bi-image"></i>
添加图片关键词
</button>
</div>
<div class="mt-3">
@ -1340,6 +1344,7 @@
<option value="api">API接口</option>
<option value="text">固定文字</option>
<option value="data">批量数据</option>
<option value="image">图片</option>
</select>
</div>
@ -1404,6 +1409,31 @@
</div>
</div>
<!-- 图片配置 -->
<div id="imageFields" class="card mb-3" style="display: none;">
<div class="card-header">
<h6 class="mb-0">图片配置</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">选择图片 <span class="text-danger">*</span></label>
<input type="file" class="form-control" id="cardImageFile" accept="image/*">
<small class="form-text text-muted">
<i class="bi bi-info-circle me-1"></i>
支持JPG、PNG、GIF格式最大5MB建议尺寸不超过4096x4096像素
</small>
</div>
<div id="cardImagePreview" class="mb-3" style="display: none;">
<label class="form-label">图片预览</label>
<div class="preview-container">
<img id="cardPreviewImg" src="" alt="预览图片"
style="max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid #ddd;">
</div>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">延时发货时间</label>
<div class="input-group">
@ -2081,7 +2111,7 @@
<input type="file" class="form-control" id="importFileInput" accept=".xlsx,.xls">
<div class="form-text">
<i class="bi bi-info-circle me-1"></i>
请上传包含"关键词"、"商品ID"、"关键词内容"三列的Excel文件
请上传包含"关键词"、"商品ID"、"关键词内容"三列的Excel文件(仅导入文本类型关键词,图片关键词将保留)
</div>
</div>
<div class="alert alert-warning">
@ -2089,8 +2119,10 @@
<ul class="mb-0">
<li>Excel文件必须包含三列关键词、商品ID、关键词内容</li>
<li>商品ID可以为空表示通用关键词</li>
<li>导入的关键词默认为文本类型</li>
<li>如果关键词+商品ID组合已存在将更新关键词内容</li>
<li>导入将覆盖当前账号的所有关键词数据</li>
<li><strong>导入只会替换文本类型关键词,图片关键词将被保留</strong></li>
<li>导入完成后,您的图片关键词仍然存在且正常工作</li>
</ul>
</div>
<div id="importProgress" style="display: none;">
@ -2110,5 +2142,69 @@
</div>
</div>
<!-- 添加图片关键词模态框 -->
<div class="modal fade" id="addImageKeywordModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-image me-2"></i>添加图片关键词
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addImageKeywordForm">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">关键词 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="imageKeyword" placeholder="例如:图片、照片" required>
<div class="form-text">用户发送此关键词时将回复图片</div>
</div>
<div class="mb-3">
<label class="form-label">关联商品(可选)</label>
<select class="form-select" id="imageItemIdSelect">
<option value="">选择商品或留空表示通用关键词</option>
</select>
<div class="form-text">选择特定商品时,仅在该商品对话中生效</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">上传图片 <span class="text-danger">*</span></label>
<input type="file" class="form-control" id="imageFile" accept="image/*" required>
<div class="form-text">支持 JPG、PNG、GIF 格式,建议大小不超过 5MB</div>
</div>
<div class="mb-3">
<div class="image-preview" id="imagePreview" style="display: none;">
<label class="form-label">图片预览</label>
<div class="preview-container">
<img id="previewImg" src="" alt="预览图片" style="max-width: 100%; max-height: 200px; border-radius: 8px; border: 1px solid #ddd;">
</div>
</div>
</div>
</div>
</div>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>说明:</strong>
<ul class="mb-0 mt-2">
<li>图片关键词优先级高于文本关键词</li>
<li>用户发送匹配的关键词时,系统将回复上传的图片</li>
<li>图片将被转换为适合聊天的格式</li>
</ul>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="addImageKeyword()">
<i class="bi bi-plus-lg me-1"></i>添加图片关键词
</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -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 ?
'<span class="keyword-type-badge keyword-type-image"><i class="bi bi-image"></i> 图片</span>' :
'<span class="keyword-type-badge keyword-type-text"><i class="bi bi-chat-text"></i> 文本</span>';
// 商品ID显示
const itemIdDisplay = item.item_id ?
`<small class="text-muted d-block"><i class="bi bi-box"></i> 商品ID: ${item.item_id}</small>` :
'<small class="text-muted d-block"><i class="bi bi-globe"></i> 通用关键词</small>';
// 内容显示
let contentDisplay = '';
if (isImageType) {
// 图片类型显示图片预览
const imageUrl = item.reply || item.image_url || '';
contentDisplay = imageUrl ?
`<div class="d-flex align-items-center gap-3">
<img src="${imageUrl}" alt="关键词图片" class="keyword-image-preview" onclick="showImageModal('${imageUrl}')">
<div class="flex-grow-1">
<p class="reply-text mb-0">用户发送关键词时将回复此图片</p>
<small class="text-muted">点击图片查看大图</small>
</div>
</div>` :
'<p class="reply-text text-muted">图片加载失败</p>';
} else {
// 文本类型显示文本内容
contentDisplay = `<p class="reply-text">${item.reply || ''}</p>`;
}
keywordItem.innerHTML = `
<div class="keyword-item-header">
<div class="keyword-tag">
<i class="bi bi-tag-fill"></i>
${item.keyword}
${typeBadge}
${itemIdDisplay}
</div>
<div class="keyword-actions">
<button class="action-btn edit-btn" onclick="editKeyword(${index})" title="编辑">
<button class="action-btn edit-btn ${isImageType ? 'edit-btn-disabled' : ''}" onclick="${isImageType ? 'editImageKeyword' : 'editKeyword'}(${index})" title="${isImageType ? '图片关键词不支持编辑' : '编辑'}">
<i class="bi bi-pencil"></i>
</button>
<button class="action-btn delete-btn" onclick="deleteKeyword('${currentCookieId}', ${index})" title="删除">
@ -726,7 +756,7 @@ function renderKeywordsList(keywords) {
</div>
</div>
<div class="keyword-content">
<p class="reply-text">${item.reply}</p>
${contentDisplay}
</div>
`;
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 = '<span class="badge bg-warning">批量数据</span>';
break;
case 'image':
typeBadge = '<span class="badge bg-primary">图片</span>';
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 = '<span class="badge bg-warning">批量数据</span>';
break;
case 'image':
cardTypeBadge = '<span class="badge bg-primary">图片</span>';
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 = '<option value="">选择商品或留空表示通用关键词</option>';
// 添加商品选项
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 = `
<div class="modal fade" id="imageViewModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">图片预览</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<img src="${imageUrl}" alt="关键词图片" style="max-width: 100%; max-height: 70vh; border-radius: 8px;">
</div>
</div>
</div>
</div>
`;
// 移除已存在的模态框
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);
}
}

216
utils/image_uploader.py Normal file
View File

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

256
utils/image_utils.py Normal file
View File

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