feat(task-service): 支持异步任务模式进行图片生成

- 实现异步任务提交机制,添加 async=true 参数启用异步模式
- 增加重试机制,最多重试3次任务提交
- 实现轮询机制监控异步任务状态,最大轮询600次(30分钟超时)
- 支持多种响应结构的数据解析,增强兼容性
- 优化状态更新逻辑,提供更准确的任务进度反馈
- 调整超时配置,使用不同的超时时间适应不同场景

fix(ui): 修复保存提示词模态框显示问题

- 修改事件处理器阻止默认行为和事件冒泡
- 改进登录状态检查逻辑,通过CSS类判断可见性
- 优化模态框显示/隐藏的过渡动画效果
- 添加内联样式兜底方案确保正确显示
- 修复页面布局相关的位置偏移问题
```
This commit is contained in:
24024 2026-02-04 23:55:42 +08:00
parent 4431a558f9
commit 85fb484bfa
3 changed files with 275 additions and 123 deletions

View File

@ -92,29 +92,135 @@ def sync_images_background(app, record_id, raw_urls):
print(f"❌ 更新记录失败: {e}")
def process_image_generation(app, user_id, task_id, payload, api_key, target_api, cost, use_trial=False):
"""异步执行图片生成并存入 Redis"""
"""异步执行图片生成并存入 Redis (支持异步任务)"""
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:
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:
if use_trial:
from services.generation_service import refund_points
refund_points(user_id, cost)
# 1. 提交异步请求 (带重试机制)
submit_resp = None
last_error = None
# 记录详细的失败上下文
system_logger.error(f"生图任务失败: {resp.text}", user_id=user_id, task_id=task_id, prompt=payload.get('prompt'), model=payload.get('model'))
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text}))
return
for attempt in range(3):
try:
# 添加 async=true 参数启用异步模式
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()
raw_urls = [item['url'] for item in api_result.get('data', [])]
if submit_resp.status_code == 200:
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(
user_id=user_id,
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.commit()
# 后台线程处理:下载 AI 原始图片并同步到私有 MinIO
# 4. 后台线程同步 MinIO
threading.Thread(
target=sync_images_background,
args=(app, new_record.id, raw_urls)
).start()
# 存入 Redis 标记完成
# 5. 完成
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}))

View File

@ -210,7 +210,13 @@ async function init() {
if (openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal;
if (closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal;
const openSavePromptBtn = document.getElementById('openSavePromptBtn');
if (openSavePromptBtn) openSavePromptBtn.onclick = openSavePromptModal;
if (openSavePromptBtn) {
openSavePromptBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
openSavePromptModal();
};
}
if (showHistoryBtn) {
showHistoryBtn.onclick = () => {
@ -1045,15 +1051,19 @@ async function refreshPromptsList() {
}
function openSavePromptModal() {
// Check login
if (document.getElementById('loginEntryBtn').offsetParent !== null) {
// Check login by checking visibility of login button
const loginBtn = document.getElementById('loginEntryBtn');
if (loginBtn && !loginBtn.classList.contains('hidden')) {
showToast('请先登录', 'warning');
return;
}
const modal = document.getElementById('savePromptModal');
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()) {
showToast('请先输入一些提示词内容', 'warning');
@ -1061,22 +1071,57 @@ function openSavePromptModal() {
}
if (modal) {
// 使用内联样式强制显示,避免 Tailwind 类可能未编译的问题
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(() => {
modal.classList.add('opacity-100');
modal.querySelector('div').classList.remove('scale-95');
input.focus();
}, 10);
if (input) {
try { input.focus(); } catch (e) { }
}
}, 50);
}
}
function closeSavePromptModal() {
const modal = document.getElementById('savePromptModal');
if (modal) {
modal.classList.remove('opacity-100');
modal.querySelector('div').classList.add('scale-95');
setTimeout(() => modal.classList.add('hidden'), 300);
// 先触发过渡动画
modal.style.opacity = '0';
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);
}
}

View File

@ -115,7 +115,7 @@
</select>
<div id="deletePromptContainer" class="hidden relative h-0">
<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="删除这个收藏">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
@ -125,7 +125,7 @@
class="w-full p-3 text-xs outline-none resize-none leading-relaxed"
placeholder="描述您的需求..."></textarea>
<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="收藏当前提示词">
<i data-lucide="bookmark-plus" class="w-4 h-4"></i>
</button>
@ -378,9 +378,11 @@
</div>
</div>
</div>
<!-- 修改密码弹窗 -->
<div id="pwdModal"
<!-- 修改密码弹窗 -->
<div id="pwdModal"
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">
<div
class="bg-white w-full max-w-sm rounded-[2.5rem] shadow-2xl p-10 space-y-8 transform scale-95 transition-transform duration-300">
@ -404,10 +406,10 @@
</div>
</form>
</div>
</div>
</div>
<!-- 拍摄角度设置器弹窗 -->
<div id="visualizerModal"
<!-- 拍摄角度设置器弹窗 -->
<div id="visualizerModal"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[60] flex items-center justify-center hidden opacity-0 transition-opacity duration-300">
<div
class="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl overflow-hidden transform scale-95 transition-transform duration-300 flex flex-col h-[85vh]">
@ -430,17 +432,16 @@
<iframe id="visualizerFrame" src="/visualizer" class="w-full h-full border-none"></iframe>
</div>
</div>
</div>
</div>
<!-- 保存提示词弹窗 -->
<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">
<!-- 保存提示词弹窗 -->
<div id="savePromptModal"
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
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>
<div class="space-y-2">
<label
class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">给这个灵感起个名字</label>
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">给这个灵感起个名字</label>
<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">
</div>
@ -451,11 +452,11 @@
class="flex-1 btn-primary px-6 py-3 rounded-2xl font-bold shadow-lg shadow-indigo-100">保存</button>
</div>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<div id="deleteConfirmModal"
<!-- 删除确认弹窗 -->
<div id="deleteConfirmModal"
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">
<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">
@ -475,9 +476,9 @@
class="flex-1 bg-rose-500 text-white px-6 py-3 rounded-2xl font-bold shadow-lg shadow-rose-200 hover:bg-rose-600 transition-all">确认删除</button>
</div>
</div>
</div>
{% endblock %}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% endblock %}