From 85fb484bfa121da014f85a3bf7e9082858030a95 Mon Sep 17 00:00:00 2001 From: 24024 <240241002@qq.com> Date: Wed, 4 Feb 2026 23:55:42 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(task-service):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E4=BB=BB=E5=8A=A1=E6=A8=A1=E5=BC=8F=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E5=9B=BE=E7=89=87=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现异步任务提交机制,添加 async=true 参数启用异步模式 - 增加重试机制,最多重试3次任务提交 - 实现轮询机制监控异步任务状态,最大轮询600次(30分钟超时) - 支持多种响应结构的数据解析,增强兼容性 - 优化状态更新逻辑,提供更准确的任务进度反馈 - 调整超时配置,使用不同的超时时间适应不同场景 fix(ui): 修复保存提示词模态框显示问题 - 修改事件处理器阻止默认行为和事件冒泡 - 改进登录状态检查逻辑,通过CSS类判断可见性 - 优化模态框显示/隐藏的过渡动画效果 - 添加内联样式兜底方案确保正确显示 - 修复页面布局相关的位置偏移问题 ``` --- services/task_service.py | 142 +++++++++++++++++++++++++---- static/js/main.js | 69 ++++++++++++--- templates/index.html | 187 ++++++++++++++++++++------------------- 3 files changed, 275 insertions(+), 123 deletions(-) diff --git a/services/task_service.py b/services/task_service.py index 84cdf8a..c2464de 100644 --- a/services/task_service.py +++ b/services/task_service.py @@ -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) - - # 记录详细的失败上下文 - 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 + # 1. 提交异步请求 (带重试机制) + submit_resp = None + last_error = None + + 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 + ) + + 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}") - api_result = resp.json() - raw_urls = [item['url'] for item in api_result.get('data', [])] + 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})) diff --git a/static/js/main.js b/static/js/main.js index a9707cb..9ed8418 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -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); } } diff --git a/templates/index.html b/templates/index.html index 1d1770c..7847d89 100644 --- a/templates/index.html +++ b/templates/index.html @@ -115,7 +115,7 @@ + + - -