mirror of
https://github.com/zhinianboke/xianyu-auto-reply.git
synced 2025-08-29 09:07:36 +08:00
Compare commits
5 Commits
765c9fe48f
...
3a09c74963
Author | SHA1 | Date | |
---|---|---|---|
![]() |
3a09c74963 | ||
![]() |
0b37cdd869 | ||
![]() |
7f6ca55ef9 | ||
![]() |
c8045da0b0 | ||
![]() |
1c2cd1bce8 |
7
.gitignore
vendored
7
.gitignore
vendored
@ -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
|
||||
|
22
Dockerfile
22
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用户运行
|
||||
# 在生产环境中,建议配置适当的用户映射
|
||||
@ -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
|
||||
|
||||
# 启动命令
|
||||
|
@ -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
178
README.md
@ -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
|
||||
```
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
325
db_manager.py
325
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()
|
||||
|
227
docker-deploy.bat
Normal file
227
docker-deploy.bat
Normal 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
|
||||
)
|
@ -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
15
entrypoint.sh
Normal 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
|
271
reply_server.py
271
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)
|
||||
# 移除自动启动,由Start.py或手动启动
|
||||
# if __name__ == "__main__":
|
||||
# uvicorn.run(app, host="0.0.0.0", port=8080)
|
120
requirements.txt
120
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 (类型提示)
|
||||
# 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
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
612
static/js/app.js
612
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 ?
|
||||
'<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
216
utils/image_uploader.py
Normal 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
256
utils/image_utils.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user