From db27513301f6fe5ed1d83a5753d881815bfd6cb0 Mon Sep 17 00:00:00 2001
From: zhinianboke
Date: Tue, 30 Dec 2025 23:21:33 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=BB=98=E8=AE=A4=E5=9B=9E?=
=?UTF-8?q?=E5=A4=8D=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
XianyuAutoAsync.py | 163 +++++++++++++++++++--
db_manager.py | 50 +++++--
frontend/src/api/keywords.ts | 7 +-
frontend/src/components/layout/Sidebar.tsx | 2 +-
frontend/src/pages/accounts/Accounts.tsx | 81 +++++++++-
reply_server.py | 5 +-
6 files changed, 276 insertions(+), 32 deletions(-)
diff --git a/XianyuAutoAsync.py b/XianyuAutoAsync.py
index 27762a3..e312db8 100644
--- a/XianyuAutoAsync.py
+++ b/XianyuAutoAsync.py
@@ -1734,7 +1734,7 @@ class XianyuLive:
# user_id=f"{self.cookie_id}_{int(time.time() * 1000)}", # 使用唯一ID避免冲突
user_id=f"{self.cookie_id}", # 使用唯一ID避免冲突
enable_learning=True, # 启用学习功能
- headless=True # 使用无头模式
+ headless=False # 使用无头模式
)
# 在线程池中执行滑块验证
@@ -3088,8 +3088,14 @@ class XianyuLive:
except Exception as e:
logger.error(f"调试消息结构时发生错误: {self._safe_str(e)}")
- async def get_default_reply(self, send_user_name: str, send_user_id: str, send_message: str, chat_id: str, item_id: str = None) -> str:
- """获取默认回复内容,支持指定商品回复、变量替换和只回复一次功能"""
+ async def get_default_reply(self, send_user_name: str, send_user_id: str, send_message: str, chat_id: str, item_id: str = None) -> dict:
+ """获取默认回复内容,支持指定商品回复、变量替换、只回复一次功能和图片发送
+
+ Returns:
+ dict: 包含 'text' (文字回复) 和 'image_url' (图片URL,可选) 的字典
+ 或 None (无回复)
+ 或 "EMPTY_REPLY" (空回复标记)
+ """
try:
from db_manager import db_manager
@@ -3109,11 +3115,11 @@ class XianyuLive:
item_id=item_id
)
logger.info(f"【{self.cookie_id}】指定商品回复内容: {formatted_reply}")
- return formatted_reply
+ return {'text': formatted_reply, 'image_url': None}
except Exception as format_error:
logger.error(f"指定商品回复变量替换失败: {self._safe_str(format_error)}")
# 如果变量替换失败,返回原始内容
- return reply_content
+ return {'text': reply_content, 'image_url': None}
else:
logger.warning(f"【{self.cookie_id}】商品ID {item_id} 没有配置指定回复,使用默认回复")
@@ -3132,8 +3138,11 @@ class XianyuLive:
return None
reply_content = default_reply_settings.get('reply_content', '')
- if not reply_content or (reply_content and reply_content.strip() == ''):
- logger.info(f"账号 {self.cookie_id} 默认回复内容为空,不进行回复")
+ reply_image_url = default_reply_settings.get('reply_image_url', '')
+
+ # 如果文字和图片都为空,返回空回复标记
+ if (not reply_content or reply_content.strip() == '') and (not reply_image_url or reply_image_url.strip() == ''):
+ logger.info(f"账号 {self.cookie_id} 默认回复内容和图片都为空,不进行回复")
return "EMPTY_REPLY" # 返回特殊标记表示不回复
# 进行变量替换
@@ -3145,7 +3154,7 @@ class XianyuLive:
send_user_name=send_user_name,
send_user_id=send_user_id,
send_message=send_message
- )
+ ) if reply_content else ''
if item_replay:
formatted_reply = item_replay.get('reply_content', '')
@@ -3155,12 +3164,12 @@ class XianyuLive:
db_manager.add_default_reply_record(self.cookie_id, chat_id)
logger.info(f"【{self.cookie_id}】记录默认回复: chat_id={chat_id}")
- logger.info(f"【{self.cookie_id}】使用默认回复: {formatted_reply}")
- return formatted_reply
+ logger.info(f"【{self.cookie_id}】使用默认回复: 文字={formatted_reply}, 图片={reply_image_url}")
+ return {'text': formatted_reply, 'image_url': reply_image_url if reply_image_url and reply_image_url.strip() else None}
except Exception as format_error:
logger.error(f"默认回复变量替换失败: {self._safe_str(format_error)}")
# 如果变量替换失败,返回原始内容
- return reply_content
+ return {'text': reply_content, 'image_url': reply_image_url if reply_image_url and reply_image_url.strip() else None}
except Exception as e:
logger.error(f"获取默认回复失败: {self._safe_str(e)}")
@@ -3329,6 +3338,45 @@ class XianyuLive:
return False
+ async def _get_image_size_from_url(self, image_url: str) -> tuple:
+ """从URL获取图片尺寸
+
+ Args:
+ image_url: 图片URL
+
+ Returns:
+ (width, height) 元组,失败返回 (None, None)
+ """
+ import aiohttp
+ from io import BytesIO
+
+ try:
+ logger.info(f"【{self.cookie_id}】开始从URL获取图片尺寸: {image_url[:80]}...")
+
+ # 不接受AVIF格式(PIL默认不支持),让CDN返回WEBP/JPEG等格式
+ 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',
+ 'Accept': 'image/jpeg,image/png,image/gif,image/webp,*/*;q=0.8',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
+ 'Referer': 'https://www.goofish.com/',
+ }
+
+ async with aiohttp.ClientSession() as session:
+ async with session.get(image_url, headers=headers, timeout=aiohttp.ClientTimeout(total=10)) as response:
+ if response.status == 200:
+ image_data = await response.read()
+ from PIL import Image
+ with Image.open(BytesIO(image_data)) as img:
+ width, height = img.size
+ logger.info(f"【{self.cookie_id}】解析图片尺寸成功: {width}x{height}")
+ return (width, height)
+ else:
+ logger.warning(f"【{self.cookie_id}】下载图片失败,HTTP状态码: {response.status}")
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】从URL获取图片尺寸失败: {e}")
+
+ return (None, None)
+
async def _update_keyword_image_url(self, keyword: str, new_image_url: str):
"""更新关键词的图片URL"""
try:
@@ -3353,6 +3401,18 @@ class XianyuLive:
except Exception as e:
logger.error(f"更新卡券图片URL失败: {e}")
+ async def _update_default_reply_image_url(self, new_image_url: str):
+ """更新默认回复的图片URL为CDN URL"""
+ try:
+ from db_manager import db_manager
+ success = db_manager.update_default_reply_image_url(self.cookie_id, new_image_url)
+ if success:
+ logger.info(f"【{self.cookie_id}】默认回复图片URL已更新: {new_image_url}")
+ else:
+ logger.warning(f"【{self.cookie_id}】默认回复图片URL更新失败")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】更新默认回复图片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:
@@ -7095,12 +7155,87 @@ class XianyuLive:
reply_source = 'AI' # 标记为AI回复
else:
# 3. 最后使用默认回复
- reply = await self.get_default_reply(send_user_name, send_user_id, send_message, chat_id, item_id)
- if reply == "EMPTY_REPLY":
+ default_reply_result = await self.get_default_reply(send_user_name, send_user_id, send_message, chat_id, item_id)
+ if default_reply_result == "EMPTY_REPLY":
# 默认回复内容为空,不进行任何回复
logger.info(f"[{msg_time}] 【{self.cookie_id}】默认回复内容为空,跳过自动回复")
return
- reply_source = '默认' # 标记为默认回复
+
+ # 处理默认回复(可能包含图片和文字)
+ if default_reply_result and isinstance(default_reply_result, dict):
+ reply_source = '默认' # 标记为默认回复
+ default_image_url = default_reply_result.get('image_url')
+ default_text = default_reply_result.get('text')
+
+ # 如果存在图片,先发送图片
+ if default_image_url:
+ try:
+ # 处理图片URL(上传到CDN如果需要)
+ final_image_url = default_image_url
+ image_width, image_height = 800, 600 # 默认尺寸
+
+ if self._is_cdn_url(default_image_url):
+ # 已经是CDN链接,获取真实尺寸
+ logger.info(f"【{self.cookie_id}】默认回复使用CDN图片: {default_image_url}")
+ width, height = await self._get_image_size_from_url(default_image_url)
+ if width and height:
+ image_width, image_height = width, height
+ elif default_image_url.startswith('/static/uploads/') or default_image_url.startswith('static/uploads/'):
+ # 本地图片,需要上传到闲鱼CDN
+ local_image_path = default_image_url.replace('/static/uploads/', 'static/uploads/')
+ if os.path.exists(local_image_path):
+ logger.info(f"【{self.cookie_id}】准备上传默认回复本地图片到闲鱼CDN: {local_image_path}")
+
+ 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}")
+ final_image_url = cdn_url
+
+ # 更新数据库中的图片URL为CDN URL
+ await self._update_default_reply_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:
+ image_width, image_height = actual_width, actual_height
+ except Exception as e:
+ logger.warning(f"【{self.cookie_id}】获取图片尺寸失败,使用默认尺寸: {e}")
+ else:
+ logger.error(f"【{self.cookie_id}】默认回复图片上传失败: {local_image_path}")
+ final_image_url = None
+ else:
+ logger.error(f"【{self.cookie_id}】默认回复本地图片文件不存在: {local_image_path}")
+ final_image_url = None
+ else:
+ # 其他类型的URL,获取真实尺寸
+ width, height = await self._get_image_size_from_url(default_image_url)
+ if width and height:
+ image_width, image_height = width, height
+
+ # 发送图片
+ if final_image_url:
+ await self.send_image_msg(websocket, chat_id, send_user_id, final_image_url, image_width, image_height)
+ 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}): 图片 {final_image_url}")
+ except Exception as e:
+ logger.error(f"【{self.cookie_id}】默认回复图片发送失败: {self._safe_str(e)}")
+
+ # 然后发送文字(如果有)
+ if default_text and default_text.strip():
+ reply = default_text
+ else:
+ # 只有图片没有文字,已经发送完毕
+ if default_image_url:
+ return
+ reply = None
+ else:
+ reply = None
# 注意:这里只有商品ID,没有标题和详情,根据新的规则不保存到数据库
# 商品信息会在其他有完整信息的地方保存(如发货规则匹配时)
diff --git a/db_manager.py b/db_manager.py
index 9eb470f..5d302ce 100644
--- a/db_manager.py
+++ b/db_manager.py
@@ -322,6 +322,7 @@ class DBManager:
cookie_id TEXT PRIMARY KEY,
enabled BOOLEAN DEFAULT FALSE,
reply_content TEXT,
+ reply_image_url TEXT,
reply_once BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -338,6 +339,15 @@ class DBManager:
if "duplicate column name" not in str(e).lower():
logger.warning(f"添加 reply_once 字段失败: {e}")
+ # 添加 reply_image_url 字段(如果不存在)
+ try:
+ cursor.execute('ALTER TABLE default_replies ADD COLUMN reply_image_url TEXT')
+ self.conn.commit()
+ logger.info("已添加 reply_image_url 字段到 default_replies 表")
+ except sqlite3.OperationalError as e:
+ if "duplicate column name" not in str(e).lower():
+ logger.warning(f"添加 reply_image_url 字段失败: {e}")
+
# 创建指定商品回复表
cursor.execute('''
CREATE TABLE IF NOT EXISTS item_replay (
@@ -1905,17 +1915,17 @@ class DBManager:
return {}
# -------------------- 默认回复操作 --------------------
- def save_default_reply(self, cookie_id: str, enabled: bool, reply_content: str = None, reply_once: bool = False):
+ def save_default_reply(self, cookie_id: str, enabled: bool, reply_content: str = None, reply_once: bool = False, reply_image_url: str = None):
"""保存默认回复设置"""
with self.lock:
try:
cursor = self.conn.cursor()
cursor.execute('''
- INSERT OR REPLACE INTO default_replies (cookie_id, enabled, reply_content, reply_once, updated_at)
- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
- ''', (cookie_id, enabled, reply_content, reply_once))
+ INSERT OR REPLACE INTO default_replies (cookie_id, enabled, reply_content, reply_image_url, reply_once, updated_at)
+ VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ ''', (cookie_id, enabled, reply_content, reply_image_url, reply_once))
self.conn.commit()
- logger.debug(f"保存默认回复设置: {cookie_id} -> {'启用' if enabled else '禁用'}, 只回复一次: {'是' if reply_once else '否'}")
+ logger.debug(f"保存默认回复设置: {cookie_id} -> {'启用' if enabled else '禁用'}, 只回复一次: {'是' if reply_once else '否'}, 图片: {reply_image_url}")
except Exception as e:
logger.error(f"保存默认回复设置失败: {e}")
raise
@@ -1926,15 +1936,16 @@ class DBManager:
try:
cursor = self.conn.cursor()
cursor.execute('''
- SELECT enabled, reply_content, reply_once FROM default_replies WHERE cookie_id = ?
+ SELECT enabled, reply_content, reply_once, reply_image_url FROM default_replies WHERE cookie_id = ?
''', (cookie_id,))
result = cursor.fetchone()
if result:
- enabled, reply_content, reply_once = result
+ enabled, reply_content, reply_once, reply_image_url = result
return {
'enabled': bool(enabled),
'reply_content': reply_content or '',
- 'reply_once': bool(reply_once) if reply_once is not None else False
+ 'reply_once': bool(reply_once) if reply_once is not None else False,
+ 'reply_image_url': reply_image_url or ''
}
return None
except Exception as e:
@@ -1946,15 +1957,16 @@ class DBManager:
with self.lock:
try:
cursor = self.conn.cursor()
- cursor.execute('SELECT cookie_id, enabled, reply_content, reply_once FROM default_replies')
+ cursor.execute('SELECT cookie_id, enabled, reply_content, reply_once, reply_image_url FROM default_replies')
result = {}
for row in cursor.fetchall():
- cookie_id, enabled, reply_content, reply_once = row
+ cookie_id, enabled, reply_content, reply_once, reply_image_url = row
result[cookie_id] = {
'enabled': bool(enabled),
'reply_content': reply_content or '',
- 'reply_once': bool(reply_once) if reply_once is not None else False
+ 'reply_once': bool(reply_once) if reply_once is not None else False,
+ 'reply_image_url': reply_image_url or ''
}
return result
@@ -2015,6 +2027,22 @@ class DBManager:
self.conn.rollback()
return False
+ def update_default_reply_image_url(self, cookie_id: str, new_image_url: str) -> bool:
+ """更新默认回复的图片URL(用于将本地图片URL更新为CDN URL)"""
+ with self.lock:
+ try:
+ cursor = self.conn.cursor()
+ cursor.execute('''
+ UPDATE default_replies SET reply_image_url = ? WHERE cookie_id = ?
+ ''', (new_image_url, cookie_id))
+ self.conn.commit()
+ logger.debug(f"更新默认回复图片URL: {cookie_id} -> {new_image_url}")
+ return True
+ except Exception as e:
+ logger.error(f"更新默认回复图片URL失败: {e}")
+ self.conn.rollback()
+ return False
+
# -------------------- 通知渠道操作 --------------------
def create_notification_channel(self, name: str, channel_type: str, config: str, user_id: int = None) -> int:
"""创建通知渠道"""
diff --git a/frontend/src/api/keywords.ts b/frontend/src/api/keywords.ts
index fd38f7e..bd9eb39 100644
--- a/frontend/src/api/keywords.ts
+++ b/frontend/src/api/keywords.ts
@@ -98,16 +98,17 @@ export const batchDeleteKeywords = (cookieId: string, keywordIds: string[]): Pro
}
// 获取默认回复
-export const getDefaultReply = (cookieId: string): Promise<{ enabled?: boolean; reply_content?: string; reply_once?: boolean }> => {
+export const getDefaultReply = (cookieId: string): Promise<{ enabled?: boolean; reply_content?: string; reply_once?: boolean; reply_image_url?: string }> => {
return get(`/default-reply/${cookieId}`)
}
// 更新默认回复
-export const updateDefaultReply = (cookieId: string, replyContent: string, enabled: boolean = true, replyOnce: boolean = false): Promise => {
+export const updateDefaultReply = (cookieId: string, replyContent: string, enabled: boolean = true, replyOnce: boolean = false, replyImageUrl: string = ''): Promise => {
return put(`/default-reply/${cookieId}`, {
enabled,
reply_content: replyContent,
- reply_once: replyOnce
+ reply_once: replyOnce,
+ reply_image_url: replyImageUrl
})
}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
index 02870ab..c2f164a 100644
--- a/frontend/src/components/layout/Sidebar.tsx
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -40,7 +40,7 @@ const mainNavItems: NavItem[] = [
{ icon: Package, label: '商品管理', path: '/items' },
{ icon: ShoppingCart, label: '订单管理', path: '/orders' },
{ icon: MessageSquare, label: '自动回复', path: '/keywords' },
- // { icon: MessageCircle, label: '指定商品回复', path: '/item-replies' },
+ { icon: MessageCircle, label: '指定商品回复', path: '/item-replies' },
{ icon: CreditCard, label: '卡券管理', path: '/cards' },
{ icon: Truck, label: '自动发货', path: '/delivery' },
{ icon: Bell, label: '通知渠道', path: '/notification-channels' },
diff --git a/frontend/src/pages/accounts/Accounts.tsx b/frontend/src/pages/accounts/Accounts.tsx
index 17627eb..04be1a7 100644
--- a/frontend/src/pages/accounts/Accounts.tsx
+++ b/frontend/src/pages/accounts/Accounts.tsx
@@ -30,7 +30,9 @@ export function Accounts() {
// 默认回复管理状态
const [defaultReplyAccount, setDefaultReplyAccount] = useState(null)
const [defaultReplyContent, setDefaultReplyContent] = useState('')
+ const [defaultReplyImageUrl, setDefaultReplyImageUrl] = useState('')
const [defaultReplySaving, setDefaultReplySaving] = useState(false)
+ const [uploadingDefaultReplyImage, setUploadingDefaultReplyImage] = useState(false)
// 扫码登录状态
const [qrCodeUrl, setQrCodeUrl] = useState('')
@@ -420,12 +422,14 @@ export function Accounts() {
const openDefaultReplyModal = async (account: AccountWithKeywordCount) => {
setDefaultReplyAccount(account)
setDefaultReplyContent('')
+ setDefaultReplyImageUrl('')
setActiveModal('default-reply')
// 加载当前默认回复
try {
const result = await getDefaultReply(account.id)
setDefaultReplyContent(result.reply_content || '')
+ setDefaultReplyImageUrl(result.reply_image_url || '')
} catch {
// ignore
}
@@ -436,7 +440,7 @@ export function Accounts() {
try {
setDefaultReplySaving(true)
- await updateDefaultReply(defaultReplyAccount.id, defaultReplyContent, true)
+ await updateDefaultReply(defaultReplyAccount.id, defaultReplyContent, true, false, defaultReplyImageUrl)
addToast({ type: 'success', message: '默认回复已保存' })
closeModal()
} catch {
@@ -446,6 +450,39 @@ export function Accounts() {
}
}
+ // 上传默认回复图片
+ const handleUploadDefaultReplyImage = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ try {
+ setUploadingDefaultReplyImage(true)
+ const formData = new FormData()
+ formData.append('image', file)
+
+ const response = await fetch('/upload-image', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
+ },
+ body: formData
+ })
+
+ const result = await response.json()
+ if (result.image_url) {
+ setDefaultReplyImageUrl(result.image_url)
+ addToast({ type: 'success', message: '图片上传成功' })
+ } else {
+ addToast({ type: 'error', message: result.detail || '图片上传失败' })
+ }
+ } catch {
+ addToast({ type: 'error', message: '图片上传失败' })
+ } finally {
+ setUploadingDefaultReplyImage(false)
+ e.target.value = ''
+ }
+ }
+
// ==================== AI回复开关 ====================
const handleToggleAI = async (account: AccountWithKeywordCount) => {
const newEnabled = !account.aiEnabled
@@ -1098,6 +1135,48 @@ export function Accounts() {
当没有匹配到任何关键词时,将使用此默认回复。留空表示不自动回复。
+
+
+
+ setDefaultReplyImageUrl(e.target.value)}
+ className="input-ios flex-1"
+ placeholder="图片URL或上传图片"
+ />
+
+
+ {defaultReplyImageUrl && (
+
+

+
+
+ )}
+
支持变量:
diff --git a/reply_server.py b/reply_server.py
index c7808db..ff8a845 100644
--- a/reply_server.py
+++ b/reply_server.py
@@ -1327,6 +1327,7 @@ class CookieStatusIn(BaseModel):
class DefaultReplyIn(BaseModel):
enabled: bool
reply_content: Optional[str] = None
+ reply_image_url: Optional[str] = None
reply_once: bool = False
@@ -2656,8 +2657,8 @@ def update_default_reply(cid: str, reply_data: DefaultReplyIn, current_user: Dic
if cid not in user_cookies:
raise HTTPException(status_code=403, detail="无权限操作该Cookie")
- db_manager.save_default_reply(cid, reply_data.enabled, reply_data.reply_content, reply_data.reply_once)
- return {'msg': 'default reply updated', 'enabled': reply_data.enabled, 'reply_once': reply_data.reply_once}
+ db_manager.save_default_reply(cid, reply_data.enabled, reply_data.reply_content, reply_data.reply_once, reply_data.reply_image_url)
+ return {'msg': 'default reply updated', 'enabled': reply_data.enabled, 'reply_once': reply_data.reply_once, 'reply_image_url': reply_data.reply_image_url}
except HTTPException:
raise
except Exception as e: