```
feat(task-service): 支持异步任务模式进行图片生成 - 实现异步任务提交机制,添加 async=true 参数启用异步模式 - 增加重试机制,最多重试3次任务提交 - 实现轮询机制监控异步任务状态,最大轮询600次(30分钟超时) - 支持多种响应结构的数据解析,增强兼容性 - 优化状态更新逻辑,提供更准确的任务进度反馈 - 调整超时配置,使用不同的超时时间适应不同场景 fix(ui): 修复保存提示词模态框显示问题 - 修改事件处理器阻止默认行为和事件冒泡 - 改进登录状态检查逻辑,通过CSS类判断可见性 - 优化模态框显示/隐藏的过渡动画效果 - 添加内联样式兜底方案确保正确显示 - 修复页面布局相关的位置偏移问题 ```
This commit is contained in:
parent
4431a558f9
commit
85fb484bfa
@ -92,29 +92,135 @@ def sync_images_background(app, record_id, raw_urls):
|
|||||||
print(f"❌ 更新记录失败: {e}")
|
print(f"❌ 更新记录失败: {e}")
|
||||||
|
|
||||||
def process_image_generation(app, user_id, task_id, payload, api_key, target_api, cost, use_trial=False):
|
def process_image_generation(app, user_id, task_id, payload, api_key, target_api, cost, use_trial=False):
|
||||||
"""异步执行图片生成并存入 Redis"""
|
"""异步执行图片生成并存入 Redis (支持异步任务)"""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# 更新状态为处理中
|
# 更新状态为处理中
|
||||||
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "processing", "message": "正如火如荼地绘制中..."}))
|
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "processing", "message": "任务已提交,正在排队处理..."}))
|
||||||
try:
|
try:
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
# 使用较长的超时时间 (10分钟),确保长耗时任务不被中断
|
|
||||||
resp = requests.post(get_proxied_url(target_api), json=payload, headers=headers, timeout=Config.PROXY_TIMEOUT_GENERATION)
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
# 1. 提交异步请求 (带重试机制)
|
||||||
if use_trial:
|
submit_resp = None
|
||||||
from services.generation_service import refund_points
|
last_error = None
|
||||||
refund_points(user_id, cost)
|
|
||||||
|
|
||||||
# 记录详细的失败上下文
|
for attempt in range(3):
|
||||||
system_logger.error(f"生图任务失败: {resp.text}", user_id=user_id, task_id=task_id, prompt=payload.get('prompt'), model=payload.get('model'))
|
try:
|
||||||
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text}))
|
# 添加 async=true 参数启用异步模式
|
||||||
return
|
submit_resp = requests.post(
|
||||||
|
get_proxied_url(target_api),
|
||||||
|
params={"async": "true"},
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=Config.PROXY_TIMEOUT_DEFAULT
|
||||||
|
)
|
||||||
|
|
||||||
api_result = resp.json()
|
if submit_resp.status_code == 200:
|
||||||
raw_urls = [item['url'] for item in api_result.get('data', [])]
|
break # 成功
|
||||||
|
else:
|
||||||
|
system_logger.warning(f"任务提交失败(第{attempt+1}次): {submit_resp.status_code} - {submit_resp.text[:100]}", user_id=user_id, task_id=task_id)
|
||||||
|
last_error = f"HTTP {submit_resp.status_code}: {submit_resp.text}"
|
||||||
|
except Exception as e:
|
||||||
|
system_logger.warning(f"任务提交异常(第{attempt+1}次): {str(e)}", user_id=user_id, task_id=task_id)
|
||||||
|
last_error = str(e)
|
||||||
|
time.sleep(1) # 短暂等待重试
|
||||||
|
|
||||||
# 持久化记录
|
if not submit_resp or submit_resp.status_code != 200:
|
||||||
|
raise Exception(f"任务提交失败(重试3次后): {last_error}")
|
||||||
|
|
||||||
|
submit_result = submit_resp.json()
|
||||||
|
|
||||||
|
# 判断是否返回了 task_id (异步模式)
|
||||||
|
raw_urls = []
|
||||||
|
if 'task_id' in submit_result:
|
||||||
|
remote_task_id = submit_result['task_id']
|
||||||
|
system_logger.info(f"外部异步任务已提交: {remote_task_id}", user_id=user_id, task_id=task_id)
|
||||||
|
|
||||||
|
# 构造查询 URL: .../images/generations -> .../images/tasks/{task_id}
|
||||||
|
poll_url = target_api.replace('/generations', f'/tasks/{remote_task_id}')
|
||||||
|
if poll_url == target_api: # Fallback if replace failed
|
||||||
|
import posixpath
|
||||||
|
base_url = posixpath.dirname(target_api)
|
||||||
|
poll_url = f"{base_url}/tasks/{remote_task_id}"
|
||||||
|
|
||||||
|
system_logger.info(f"开始轮询异步任务: {poll_url}", user_id=user_id, task_id=task_id)
|
||||||
|
|
||||||
|
# 2. 轮询状态
|
||||||
|
max_retries = 600 # 30分钟超时 (平均3s)
|
||||||
|
generation_success = False
|
||||||
|
|
||||||
|
for i in range(max_retries):
|
||||||
|
# 动态调整轮询间隔:前15次(约15秒) 1秒一次,之后 3秒一次
|
||||||
|
sleep_time = 1 if i < 15 else 3
|
||||||
|
if i > 0:
|
||||||
|
time.sleep(sleep_time)
|
||||||
|
|
||||||
|
# 更新本地心跳
|
||||||
|
if i % 5 == 0:
|
||||||
|
elapsed = i if i < 15 else (15 + (i-15)*3)
|
||||||
|
redis_client.setex(f"task:{task_id}", 3600, json.dumps({
|
||||||
|
"status": "processing",
|
||||||
|
"message": f"正在生成中 (已耗时 {elapsed} 秒)..."
|
||||||
|
}))
|
||||||
|
|
||||||
|
try:
|
||||||
|
poll_resp = requests.get(get_proxied_url(poll_url), headers=headers, timeout=Config.PROXY_TIMEOUT_SHORT)
|
||||||
|
|
||||||
|
if poll_resp.status_code != 200:
|
||||||
|
system_logger.warning(f"轮询非 200: {poll_resp.status_code}", user_id=user_id, task_id=task_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
poll_data = poll_resp.json()
|
||||||
|
remote_status = poll_data.get('status')
|
||||||
|
if not remote_status and 'data' in poll_data and isinstance(poll_data['data'], dict):
|
||||||
|
remote_status = poll_data['data'].get('status')
|
||||||
|
|
||||||
|
if remote_status == 'SUCCESS':
|
||||||
|
|
||||||
|
# 解析结果 (增强鲁棒性)
|
||||||
|
data_node = poll_data.get('data')
|
||||||
|
raw_urls = []
|
||||||
|
|
||||||
|
if isinstance(data_node, dict):
|
||||||
|
# 尝试多层级查找 data.data.data
|
||||||
|
inner_node = data_node.get('data')
|
||||||
|
|
||||||
|
if isinstance(inner_node, dict) and 'data' in inner_node and isinstance(inner_node['data'], list):
|
||||||
|
# data -> data -> data -> [...] (Comfly structure)
|
||||||
|
raw_urls = [item.get('url') for item in inner_node['data'] if isinstance(item, dict) and item.get('url')]
|
||||||
|
elif isinstance(inner_node, list):
|
||||||
|
# data -> data -> [...] (Standard)
|
||||||
|
raw_urls = [item.get('url') for item in inner_node if isinstance(item, dict) and item.get('url')]
|
||||||
|
elif 'url' in data_node:
|
||||||
|
raw_urls = [data_node['url']]
|
||||||
|
elif isinstance(data_node, list):
|
||||||
|
# data -> [...]
|
||||||
|
raw_urls = [item.get('url') for item in data_node if isinstance(item, dict) and item.get('url')]
|
||||||
|
|
||||||
|
# Fallback: check for top-level url
|
||||||
|
if not raw_urls and 'url' in poll_data:
|
||||||
|
raw_urls = [poll_data['url']]
|
||||||
|
|
||||||
|
|
||||||
|
if raw_urls:
|
||||||
|
generation_success = True
|
||||||
|
break
|
||||||
|
elif remote_status == 'FAILURE':
|
||||||
|
raise Exception(f"生成任务失败: {poll_data.get('fail_reason', '未知错误')}")
|
||||||
|
|
||||||
|
except requests.RequestException:
|
||||||
|
continue # 网络波动重试
|
||||||
|
|
||||||
|
if not generation_success:
|
||||||
|
raise Exception("生成任务超时或未获取到结果")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 兼容旧的同步返回模式
|
||||||
|
raw_urls = [item['url'] for item in submit_result.get('data', [])]
|
||||||
|
|
||||||
|
if not raw_urls:
|
||||||
|
raise Exception("未获取到图片地址")
|
||||||
|
|
||||||
|
# 3. 持久化记录
|
||||||
new_record = GenerationRecord(
|
new_record = GenerationRecord(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
prompt=payload.get('prompt'),
|
prompt=payload.get('prompt'),
|
||||||
@ -125,13 +231,13 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api
|
|||||||
db.session.add(new_record)
|
db.session.add(new_record)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# 后台线程处理:下载 AI 原始图片并同步到私有 MinIO
|
# 4. 后台线程同步 MinIO
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=sync_images_background,
|
target=sync_images_background,
|
||||||
args=(app, new_record.id, raw_urls)
|
args=(app, new_record.id, raw_urls)
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
# 存入 Redis 标记完成
|
# 5. 完成
|
||||||
system_logger.info(f"生图任务完成", user_id=user_id, task_id=task_id, model=payload.get('model'))
|
system_logger.info(f"生图任务完成", user_id=user_id, task_id=task_id, model=payload.get('model'))
|
||||||
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "complete", "urls": raw_urls}))
|
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "complete", "urls": raw_urls}))
|
||||||
|
|
||||||
|
|||||||
@ -210,7 +210,13 @@ async function init() {
|
|||||||
if (openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal;
|
if (openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal;
|
||||||
if (closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal;
|
if (closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal;
|
||||||
const openSavePromptBtn = document.getElementById('openSavePromptBtn');
|
const openSavePromptBtn = document.getElementById('openSavePromptBtn');
|
||||||
if (openSavePromptBtn) openSavePromptBtn.onclick = openSavePromptModal;
|
if (openSavePromptBtn) {
|
||||||
|
openSavePromptBtn.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openSavePromptModal();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (showHistoryBtn) {
|
if (showHistoryBtn) {
|
||||||
showHistoryBtn.onclick = () => {
|
showHistoryBtn.onclick = () => {
|
||||||
@ -1045,15 +1051,19 @@ async function refreshPromptsList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSavePromptModal() {
|
function openSavePromptModal() {
|
||||||
// Check login
|
// Check login by checking visibility of login button
|
||||||
if (document.getElementById('loginEntryBtn').offsetParent !== null) {
|
const loginBtn = document.getElementById('loginEntryBtn');
|
||||||
|
if (loginBtn && !loginBtn.classList.contains('hidden')) {
|
||||||
showToast('请先登录', 'warning');
|
showToast('请先登录', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modal = document.getElementById('savePromptModal');
|
const modal = document.getElementById('savePromptModal');
|
||||||
const input = document.getElementById('promptTitleInput');
|
const input = document.getElementById('promptTitleInput');
|
||||||
const prompt = document.getElementById('manualPrompt').value;
|
const promptArea = document.getElementById('manualPrompt');
|
||||||
|
|
||||||
|
if (!promptArea) return;
|
||||||
|
const prompt = promptArea.value;
|
||||||
|
|
||||||
if (!prompt || !prompt.trim()) {
|
if (!prompt || !prompt.trim()) {
|
||||||
showToast('请先输入一些提示词内容', 'warning');
|
showToast('请先输入一些提示词内容', 'warning');
|
||||||
@ -1061,22 +1071,57 @@ function openSavePromptModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (modal) {
|
if (modal) {
|
||||||
|
// 使用内联样式强制显示,避免 Tailwind 类可能未编译的问题
|
||||||
modal.classList.remove('hidden');
|
modal.classList.remove('hidden');
|
||||||
input.value = '';
|
modal.style.display = 'flex';
|
||||||
|
modal.style.zIndex = '9999';
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
if (input) input.value = ''; // Clear input immediately
|
||||||
|
|
||||||
|
// 强制重绘
|
||||||
|
void modal.offsetWidth;
|
||||||
|
|
||||||
|
// 简单直接的显示
|
||||||
|
modal.style.transition = 'opacity 0.3s ease';
|
||||||
|
modal.style.opacity = '1';
|
||||||
|
|
||||||
|
const innerDiv = modal.querySelector('div');
|
||||||
|
if (innerDiv) {
|
||||||
|
innerDiv.style.transition = 'transform 0.3s ease';
|
||||||
|
innerDiv.classList.remove('scale-95');
|
||||||
|
innerDiv.classList.add('scale-100');
|
||||||
|
// 同样用内联样式兜底
|
||||||
|
innerDiv.style.transform = 'scale(1)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延时聚焦
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
modal.classList.add('opacity-100');
|
if (input) {
|
||||||
modal.querySelector('div').classList.remove('scale-95');
|
try { input.focus(); } catch (e) { }
|
||||||
input.focus();
|
}
|
||||||
}, 10);
|
}, 50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSavePromptModal() {
|
function closeSavePromptModal() {
|
||||||
const modal = document.getElementById('savePromptModal');
|
const modal = document.getElementById('savePromptModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.classList.remove('opacity-100');
|
// 先触发过渡动画
|
||||||
modal.querySelector('div').classList.add('scale-95');
|
modal.style.opacity = '0';
|
||||||
setTimeout(() => modal.classList.add('hidden'), 300);
|
|
||||||
|
const innerDiv = modal.querySelector('div');
|
||||||
|
if (innerDiv) {
|
||||||
|
innerDiv.style.transform = 'scale(0.95)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待动画结束后隐藏
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
// 清理样式
|
||||||
|
modal.style.zIndex = '';
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -115,7 +115,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<div id="deletePromptContainer" class="hidden relative h-0">
|
<div id="deletePromptContainer" class="hidden relative h-0">
|
||||||
<button id="deletePromptBtn"
|
<button id="deletePromptBtn"
|
||||||
class="absolute -top-10 right-2 w-8 h-8 rounded-lg bg-rose-50 border border-rose-100 text-rose-500 hover:bg-rose-100 transition-colors flex items-center justify-center z-10"
|
class="absolute -top-10 right-12 w-8 h-8 rounded-lg bg-rose-50 border border-rose-100 text-rose-500 hover:bg-rose-100 transition-colors flex items-center justify-center z-10"
|
||||||
title="删除这个收藏">
|
title="删除这个收藏">
|
||||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -125,7 +125,7 @@
|
|||||||
class="w-full p-3 text-xs outline-none resize-none leading-relaxed"
|
class="w-full p-3 text-xs outline-none resize-none leading-relaxed"
|
||||||
placeholder="描述您的需求..."></textarea>
|
placeholder="描述您的需求..."></textarea>
|
||||||
<button id="openSavePromptBtn"
|
<button id="openSavePromptBtn"
|
||||||
class="absolute bottom-2 right-2 p-1.5 bg-white/80 backdrop-blur-sm border border-slate-100 text-slate-400 rounded-lg hover:text-indigo-600 hover:border-indigo-200 transition-all opacity-0 group-hover/area:opacity-100"
|
class="absolute bottom-2 right-2 p-1.5 bg-white/80 backdrop-blur-sm border border-slate-100 text-slate-400 rounded-lg hover:text-indigo-600 hover:border-indigo-200 transition-all z-10"
|
||||||
title="收藏当前提示词">
|
title="收藏当前提示词">
|
||||||
<i data-lucide="bookmark-plus" class="w-4 h-4"></i>
|
<i data-lucide="bookmark-plus" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -378,6 +378,8 @@
|
|||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 修改密码弹窗 -->
|
<!-- 修改密码弹窗 -->
|
||||||
<div id="pwdModal"
|
<div id="pwdModal"
|
||||||
@ -434,13 +436,12 @@
|
|||||||
|
|
||||||
<!-- 保存提示词弹窗 -->
|
<!-- 保存提示词弹窗 -->
|
||||||
<div id="savePromptModal"
|
<div id="savePromptModal"
|
||||||
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-50 flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
|
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
|
||||||
<div
|
<div
|
||||||
class="bg-white w-full max-w-sm rounded-[2rem] shadow-2xl p-8 space-y-6 transform scale-95 transition-transform duration-300">
|
class="bg-white w-full max-w-sm rounded-[2rem] shadow-2xl p-8 space-y-6 transform scale-95 transition-transform duration-300">
|
||||||
<h2 class="text-xl font-black text-slate-900">收藏提示词</h2>
|
<h2 class="text-xl font-black text-slate-900">收藏提示词</h2>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label
|
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">给这个灵感起个名字</label>
|
||||||
class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">给这个灵感起个名字</label>
|
|
||||||
<input type="text" id="promptTitleInput" placeholder="例如:赛博朋克风格"
|
<input type="text" id="promptTitleInput" placeholder="例如:赛博朋克风格"
|
||||||
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
|
class="w-full bg-slate-50 border border-slate-100 rounded-2xl p-4 outline-none focus:border-indigo-500 transition-all text-sm font-bold">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user