diff --git a/blueprints/api.py b/blueprints/api.py index 6815d91..205b1de 100644 --- a/blueprints/api.py +++ b/blueprints/api.py @@ -5,280 +5,311 @@ from middlewares.auth import login_required from services.logger import system_logger import json -# Import Services -from services.system_service import get_system_config_data, get_user_latest_notification, mark_notification_as_read +from services.system_service import ( + get_system_config_data, + get_user_latest_notification, + mark_notification_as_read, +) from services.history_service import get_user_history_data from services.file_service import handle_file_uploads, get_remote_file_stream from services.generation_service import ( - validate_generation_request, deduct_points, handle_chat_generation_sync, - start_async_image_task, validate_video_request, start_async_video_task + validate_generation_request, + deduct_points, + handle_chat_generation_sync, + start_async_image_task, + validate_video_request, + start_async_video_task, ) -api_bp = Blueprint('api', __name__) +api_bp = Blueprint("api", __name__) -@api_bp.route('/api/task_status/') + +@api_bp.route("/api/task_status/") @login_required def get_task_status(task_id): - """查询异步任务状态""" try: data = redis_client.get(f"task:{task_id}") if not data: return jsonify({"status": "pending"}) - + if isinstance(data, bytes): - data = data.decode('utf-8') - + data = data.decode("utf-8") + return jsonify(json.loads(data)) except Exception as e: system_logger.error(f"查询任务状态异常: {str(e)}") return jsonify({"status": "error", "message": "状态查询失败"}) -@api_bp.route('/api/config') + +@api_bp.route("/api/config") def get_config(): - """从本地数据库字典获取配置""" try: return jsonify(get_system_config_data()) except Exception as e: return jsonify({"error": str(e)}), 500 -@api_bp.route('/api/upload', methods=['POST']) + +@api_bp.route("/api/upload", methods=["POST"]) @login_required def upload(): try: - files = request.files.getlist('images') + files = request.files.getlist("images") img_urls = handle_file_uploads(files) - system_logger.info(f"用户上传文件: {len(files)} 个", user_id=session.get('user_id')) + system_logger.info(f"用户上传文件: {len(files)} 张", user_id=session.get("user_id")) return jsonify({"urls": img_urls}) except Exception as e: return jsonify({"error": str(e)}), 500 -@api_bp.route('/api/generate', methods=['POST']) + +@api_bp.route("/api/generate", methods=["POST"]) @login_required def generate(): try: - user_id = session.get('user_id') + user_id = session.get("user_id") user = db.session.get(User, user_id) - data = request.json if request.is_json else request.form - - # 1. 验证请求与权限 + api_key, target_api, cost, use_trial, error = validate_generation_request(user, data) if error: return jsonify({"error": error}), 400 - # 2. 扣除积分 (如果是试用模式) if use_trial: deduct_points(user_id, cost) - model_value = data.get('model') - prompt = data.get('prompt') + model_value = data.get("model") + prompt = (data.get("prompt") or "").strip() + if not prompt: + return jsonify({"error": "提示词不能为空"}), 400 model_lower = model_value.lower() is_chat_model = ("gemini" in model_lower or "gpt" in model_lower) and "image" not in model_lower - # 3. 处理聊天模型 (同步) if is_chat_model: - result, status_code = handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost) + result, status_code = handle_chat_generation_sync( + user_id, api_key, model_value, prompt, use_trial, cost + ) return jsonify(result), status_code - # 4. 构造生图 Payload - payload = { - "prompt": prompt, - "model": model_value, - "response_format": "url", - "aspect_ratio": data.get('ratio') - } - image_data = data.get('image_data', []) - if image_data: - payload["image"] = [img.split(',', 1)[1] if ',' in img else img for img in image_data] - - if model_value in ("nano-banana-2", "gemini-3.1-flash-image-preview") and data.get('size'): - payload["image_size"] = data.get('size') + image_data = data.get("image_data", []) + if model_value == "gpt-image-2": + if not image_data: + return jsonify({"error": "gpt-image-2 编辑模式至少需要上传 1 张参考图"}), 400 + + payload = { + "prompt": prompt, + "model": model_value, + "images": [{"image_url": img} for img in image_data], + "size": data.get("size") or "2160x3840", + "quality": "high", + "output_format": "png", + } + else: + payload = { + "prompt": prompt, + "model": model_value, + "response_format": "url", + "aspect_ratio": data.get("ratio"), + } + if image_data: + payload["image"] = [img.split(",", 1)[1] if "," in img else img for img in image_data] + + if model_value in ("nano-banana-2", "gemini-3.1-flash-image-preview") and data.get("size"): + payload["image_size"] = data.get("size") - # 5. 启动异步生图任务 app = current_app._get_current_object() - task_id = start_async_image_task(app, user_id, payload, api_key, target_api, cost, data.get('mode'), model_value, use_trial) + task_id = start_async_image_task( + app, + user_id, + payload, + api_key, + target_api, + cost, + data.get("mode"), + model_value, + use_trial, + ) return jsonify({ "task_id": task_id, - "message": "已开启异步生成任务" + "message": "已开启异步生成任务", }) except Exception as e: return jsonify({"error": str(e)}), 500 -@api_bp.route('/api/video/generate', methods=['POST']) + +@api_bp.route("/api/video/generate", methods=["POST"]) @login_required def video_generate(): try: - user_id = session.get('user_id') + user_id = session.get("user_id") user = db.session.get(User, user_id) - data = request.json - - # 1. 验证请求 + model_value, cost, error = validate_video_request(user, data) if error: return jsonify({"error": error}), 400 - - # 2. 扣除积分 + deduct_points(user_id, cost) - # 3. 构造 Payload payload = { "model": model_value, - "prompt": data.get('prompt'), - "enhance_prompt": data.get('enhance_prompt', False), - "images": data.get('images', []), - "aspect_ratio": data.get('aspect_ratio', '9:16') + "prompt": data.get("prompt"), + "enhance_prompt": data.get("enhance_prompt", False), + "images": data.get("images", []), + "aspect_ratio": data.get("aspect_ratio", "9:16"), } - - # 处理 Base64 图片 (去掉 header) - if payload.get("images"): - payload["images"] = [ - img.split(',', 1)[1] if ',' in img else img - for img in payload["images"] - ] - # 4. 启动异步视频任务 + if payload.get("images"): + payload["images"] = [img.split(",", 1)[1] if "," in img else img for img in payload["images"]] + app = current_app._get_current_object() task_id = start_async_video_task(app, user_id, payload, cost, model_value) return jsonify({ "task_id": task_id, - "message": "视频生成任务已提交,系统正在导演中..." + "message": "视频生成任务已提交,系统正在处理中", }) except Exception as e: return jsonify({"error": str(e)}), 500 -@api_bp.route('/api/notifications/latest', methods=['GET']) + +@api_bp.route("/api/notifications/latest", methods=["GET"]) @login_required def get_latest_notification(): try: - user_id = session.get('user_id') + user_id = session.get("user_id") data = get_user_latest_notification(user_id) return jsonify(data) except Exception as e: return jsonify({"error": str(e)}), 500 -@api_bp.route('/api/notifications/read', methods=['POST']) + +@api_bp.route("/api/notifications/read", methods=["POST"]) @login_required def mark_notif_read(): try: - user_id = session.get('user_id') + user_id = session.get("user_id") data = request.json - notif_id = data.get('id') + notif_id = data.get("id") if not notif_id: return jsonify({"error": "缺少通知 ID"}), 400 - + mark_notification_as_read(user_id, notif_id) return jsonify({"status": "ok"}) except Exception as e: return jsonify({"error": str(e)}), 500 -@api_bp.route('/api/history', methods=['GET']) + +@api_bp.route("/api/history", methods=["GET"]) @login_required def get_history(): try: - user_id = session.get('user_id') - page = request.args.get('page', 1, type=int) - per_page = request.args.get('per_page', 10, type=int) - filter_type = request.args.get('filter_type', 'all') - + user_id = session.get("user_id") + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 10, type=int) + filter_type = request.args.get("filter_type", "all") data = get_user_history_data(user_id, page, per_page, filter_type) return jsonify(data) except Exception as e: return jsonify({"error": str(e)}), 500 -@api_bp.route('/api/stats/points', methods=['GET']) + +@api_bp.route("/api/stats/points", methods=["GET"]) @login_required def get_user_point_stats(): - """获取积分统计图表数据""" from services.stats_service import get_point_stats - user_id = session.get('user_id') - days = request.args.get('days', 7, type=int) + + user_id = session.get("user_id") + days = request.args.get("days", 7, type=int) return jsonify(get_point_stats(user_id, days)) -@api_bp.route('/api/stats/details', methods=['GET']) + +@api_bp.route("/api/stats/details", methods=["GET"]) @login_required def get_user_point_details(): - """获取积分消耗明细""" from services.stats_service import get_point_details - user_id = session.get('user_id') - page = request.args.get('page', 1, type=int) + + user_id = session.get("user_id") + page = request.args.get("page", 1, type=int) return jsonify(get_point_details(user_id, page)) -@api_bp.route('/api/download_proxy', methods=['GET']) + +@api_bp.route("/api/download_proxy", methods=["GET"]) @login_required def download_proxy(): import time - url = request.args.get('url') - # 默认文件名逻辑 + + url = request.args.get("url") default_name = f"video-{int(time.time())}.mp4" - filename = request.args.get('filename') or default_name - + filename = request.args.get("filename") or default_name + if not url: return jsonify({"error": "缺少 URL 参数"}), 400 - + try: req, headers = get_remote_file_stream(url) - headers['Content-Disposition'] = f'attachment; filename="{filename}"' - - def generate(): + headers["Content-Disposition"] = f'attachment; filename="{filename}"' + + def generate_stream(): for chunk in req.iter_content(chunk_size=4096): yield chunk - - return current_app.response_class(generate(), headers=headers) - + + return current_app.response_class(generate_stream(), headers=headers) except Exception as e: system_logger.error(f"代理下载失败: {str(e)}") return jsonify({"error": "下载失败"}), 500 -@api_bp.route('/api/prompts', methods=['GET']) + +@api_bp.route("/api/prompts", methods=["GET"]) @login_required def list_prompts(): try: - user_id = session.get('user_id') + user_id = session.get("user_id") prompts = SavedPrompt.query.filter_by(user_id=user_id).order_by(SavedPrompt.created_at.desc()).all() - return jsonify([{ - "id": p.id, - "label": p.title, # Use label/value structure for frontend select - "value": p.prompt - } for p in prompts]) + return jsonify([ + { + "id": p.id, + "label": p.title, + "value": p.prompt, + } + for p in prompts + ]) except Exception as e: return jsonify({"error": str(e)}), 500 -@api_bp.route('/api/prompts', methods=['POST']) + +@api_bp.route("/api/prompts", methods=["POST"]) @login_required def save_prompt(): try: - user_id = session.get('user_id') + user_id = session.get("user_id") data = request.json - title = data.get('title') - prompt_text = data.get('prompt') - + title = data.get("title") + prompt_text = data.get("prompt") + if not title or not prompt_text: return jsonify({"error": "标题和内容不能为空"}), 400 - - # Limit to 50 saved prompts + count = SavedPrompt.query.filter_by(user_id=user_id).count() if count >= 50: return jsonify({"error": "收藏数量已达上限 (50)"}), 400 - - p = SavedPrompt(user_id=user_id, title=title, prompt=prompt_text) - db.session.add(p) + + prompt = SavedPrompt(user_id=user_id, title=title, prompt=prompt_text) + db.session.add(prompt) db.session.commit() - - return jsonify({"message": "保存成功", "id": p.id}) + + return jsonify({"message": "保存成功", "id": prompt.id}) except Exception as e: return jsonify({"error": str(e)}), 500 -@api_bp.route('/api/prompts/', methods=['DELETE']) + +@api_bp.route("/api/prompts/", methods=["DELETE"]) @login_required def delete_prompt(id): try: - user_id = session.get('user_id') - p = SavedPrompt.query.filter_by(id=id, user_id=user_id).first() - if p: - db.session.delete(p) + user_id = session.get("user_id") + prompt = SavedPrompt.query.filter_by(id=id, user_id=user_id).first() + if prompt: + db.session.delete(prompt) db.session.commit() return jsonify({"message": "删除成功"}) except Exception as e: diff --git a/config.py b/config.py index 225d6d9..adb8504 100644 --- a/config.py +++ b/config.py @@ -37,6 +37,8 @@ class Config: CHAT_API = f"{AI_BASE_URL}/v1/chat/completions" VIDEO_GEN_API = f"{AI_BASE_URL}/v2/videos/generations" VIDEO_POLL_API = f"{AI_BASE_URL}/v2/videos/generations/{{task_id}}" + GPT_IMAGE_EDIT_API = "https://nas.4x4g.com:8317/v1/images/edits" + GPT_IMAGE_EDIT_KEY = "sk-vDmmYNzoe7UjWskKx" # 试用模式配置 TRIAL_API = f"{AI_BASE_URL}/v1/images/generations" diff --git a/services/generation_service.py b/services/generation_service.py index cf73317..09ad31b 100644 --- a/services/generation_service.py +++ b/services/generation_service.py @@ -1,100 +1,114 @@ +import json +import threading +import uuid + +import requests + from config import Config -from models import SystemDict, GenerationRecord, User, db -from extensions import redis_client +from extensions import db, redis_client +from models import GenerationRecord, SystemDict, User from services.logger import system_logger from services.task_service import process_image_generation, process_video_generation -import requests -import json -import uuid -import threading -from flask import current_app from utils import get_api_candidates, get_proxied_url, should_switch_to_backup + def get_model_cost(model_value, is_video=False): - """获取模型消耗积分""" - dict_type = 'video_model' if is_video else 'ai_model' + """获取模型消耗积分。""" + dict_type = "video_model" if is_video else "ai_model" model_dict = SystemDict.query.filter_by(dict_type=dict_type, value=model_value).first() - + if model_dict: return model_dict.cost - - # Default costs + + # 默认计费兜底 if is_video: return 15 if "pro" in model_value.lower() or "3.1" in model_value else 10 - else: - return 1 + return 1 + def validate_generation_request(user, data): - """验证生图请求并返回配置 (api_key, target_api, cost, use_trial)""" - mode = data.get('mode', 'trial') - is_premium = data.get('is_premium', False) - input_key = data.get('apiKey') - model_value = data.get('model') - is_gemini_flash_image_preview = model_value == 'gemini-3.1-flash-image-preview' + """校验生图请求并返回 (api_key, target_api, cost, use_trial, error)。""" + mode = data.get("mode", "trial") + is_premium = data.get("is_premium", False) + input_key = data.get("apiKey") + model_value = data.get("model") + is_gemini_flash_image_preview = model_value == "gemini-3.1-flash-image-preview" + is_gpt_image_edit = model_value == "gpt-image-2" if isinstance(is_premium, str): - is_premium = is_premium.lower() in ('1', 'true', 'yes', 'on') - - target_api = Config.AI_API + is_premium = is_premium.lower() in ("1", "true", "yes", "on") + + # gpt-image-2 走独立的 edits 接口,其余模型沿用现有 generations 接口 + target_api = Config.GPT_IMAGE_EDIT_API if is_gpt_image_edit else Config.AI_API api_key = None use_trial = False - - if mode == 'key': + + if mode == "key": api_key = input_key or user.api_key if not api_key: return None, None, 0, False, "请先输入您的 API 密钥" - - # Update user key if changed + + # 用户手动输入了新 key 时,同步保存到账号 if input_key and input_key != user.api_key: user.api_key = input_key db.session.commit() else: - if user.points > 0: - if is_premium: - api_key = Config.GEMINI_FLASH_IMAGE_PREMIUM_KEY if is_gemini_flash_image_preview else Config.PREMIUM_KEY - elif is_gemini_flash_image_preview: - api_key = Config.PREMIUM_KEY - else: - api_key = Config.TRIAL_KEY - target_api = Config.TRIAL_API - use_trial = True - else: + if user.points <= 0: return None, None, 0, False, "可用积分已耗尽,请充值或切换至自定义 Key 模式" - - # 计算单价 + + # 积分模式下,gpt-image-2 使用固定的专用 key + if is_gpt_image_edit: + api_key = Config.GPT_IMAGE_EDIT_KEY + elif is_premium: + api_key = ( + Config.GEMINI_FLASH_IMAGE_PREMIUM_KEY + if is_gemini_flash_image_preview + else Config.PREMIUM_KEY + ) + elif is_gemini_flash_image_preview: + api_key = Config.PREMIUM_KEY + else: + api_key = Config.TRIAL_KEY + + target_api = Config.GPT_IMAGE_EDIT_API if is_gpt_image_edit else Config.TRIAL_API + use_trial = True + + # 计算本次任务消耗 cost = get_model_cost(model_value, is_video=False) if use_trial and is_premium: cost *= 2 - - if use_trial: - if user.points < cost: - return None, None, cost, True, f"可用积分不足(本次需要 {cost} 积分)" - + + if use_trial and user.points < cost: + return None, None, cost, True, f"可用积分不足,本次需要 {cost} 积分" + return api_key, target_api, cost, use_trial, None + def deduct_points(user_id, cost): - """原子扣除积分""" + """原子扣减积分。""" user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first() if user: user.points -= cost user.has_used_points = True db.session.commit() + def refund_points(user_id, cost): - """原子退还积分""" + """原子退回积分。""" try: user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first() if user: user.points += cost db.session.commit() - except: + except Exception: db.session.rollback() + def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost): - """同步处理对话类模型""" + """同步处理聊天类模型。""" chat_payload = { "model": model_value, - "messages": [{"role": "user", "content": prompt}] + "messages": [{"role": "user", "content": prompt}], } try: resp = None @@ -102,13 +116,16 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial candidates = get_api_candidates(Config.CHAT_API, api_key) for idx, candidate in enumerate(candidates): - headers = {"Authorization": f"Bearer {candidate['api_key']}", "Content-Type": "application/json"} + headers = { + "Authorization": f"Bearer {candidate['api_key']}", + "Content-Type": "application/json", + } try: resp = requests.post( - get_proxied_url(candidate['url']), + get_proxied_url(candidate["url"]), json=chat_payload, headers=headers, - timeout=Config.PROXY_TIMEOUT_LONG + timeout=Config.PROXY_TIMEOUT_LONG, ) if resp.status_code == 200: break @@ -120,7 +137,7 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial "聊天主线路失败,切换备用线路", user_id=user_id, model=model_value, - status_code=resp.status_code + status_code=resp.status_code, ) continue break @@ -131,7 +148,7 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial "聊天请求异常,切换备用线路", user_id=user_id, model=model_value, - error=last_error + error=last_error, ) continue raise @@ -140,72 +157,83 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial if use_trial: refund_points(user_id, cost) return {"error": last_error or "聊天请求失败"}, resp.status_code if resp else 500 - + api_result = resp.json() - content = api_result['choices'][0]['message']['content'] - - # 记录聊天历史 + content = api_result["choices"][0]["message"]["content"] + + # 非验光单解读的文本生成写入历史 if prompt != "解读验光单": new_record = GenerationRecord( user_id=user_id, prompt=prompt, model=model_value, cost=cost, - image_urls=json.dumps([{"type": "text", "content": content}]) + image_urls=json.dumps([{"type": "text", "content": content}]), ) db.session.add(new_record) db.session.commit() - + return { "data": [{"content": content, "type": "text"}], - "message": "生成成功!" + "message": "生成成功", }, 200 except Exception as e: if use_trial: refund_points(user_id, cost) return {"error": str(e)}, 500 + def start_async_image_task(app, user_id, payload, api_key, target_api, cost, mode, model_value, use_trial=False): - """启动异步生图任务""" + """启动异步生图任务。""" task_id = str(uuid.uuid4()) - - log_msg = "用户发起验光单解读" if payload.get('prompt') == "解读验光单" else "用户发起生图任务" + + log_msg = "用户发起验光单解读" if payload.get("prompt") == "解读验光单" else "用户发起生图任务" system_logger.info(log_msg, model=model_value, mode=mode) - - redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "queued", "message": "任务已提交,等待处理..."})) - + + redis_client.setex( + f"task:{task_id}", + 3600, + json.dumps({"status": "queued", "message": "任务已提交,等待处理..."}), + ) + threading.Thread( target=process_image_generation, - args=(app, user_id, task_id, payload, api_key, target_api, cost, use_trial) + args=(app, user_id, task_id, payload, api_key, target_api, cost, use_trial), ).start() - + return task_id + def validate_video_request(user, data): - """验证视频生成请求""" + """校验视频生成请求。""" if user.points <= 0: return None, 0, "可用积分不足,请先充值" - - model_value = data.get('model', 'veo3.1') + + model_value = data.get("model", "veo3.1") cost = get_model_cost(model_value, is_video=True) - + if user.points < cost: return None, cost, f"积分不足,生成该视频需要 {cost} 积分" - + return model_value, cost, None + def start_async_video_task(app, user_id, payload, cost, model_value): - """启动异步视频任务""" + """启动异步视频任务。""" api_key = Config.VIDEO_KEY task_id = str(uuid.uuid4()) - + system_logger.info("用户发起视频生成任务 (积分模式)", model=model_value, cost=cost) - - redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "queued", "message": "视频任务已提交,准备开始导演..."})) + + redis_client.setex( + f"task:{task_id}", + 3600, + json.dumps({"status": "queued", "message": "视频任务已提交,准备开始处理..."}), + ) threading.Thread( target=process_video_generation, - args=(app, user_id, task_id, payload, api_key, cost, True) # 视频目前默认为积分模式 + args=(app, user_id, task_id, payload, api_key, cost, True), ).start() - + return task_id diff --git a/services/task_service.py b/services/task_service.py index 930ec6e..bf554b8 100644 --- a/services/task_service.py +++ b/services/task_service.py @@ -5,6 +5,7 @@ import requests import io import time import base64 +import binascii import threading from urllib.parse import quote from requests.exceptions import ConnectionError, ConnectTimeout @@ -46,6 +47,171 @@ def _extract_error_detail(data, default="未知错误"): return default + +def _guess_image_extension(content_type, fallback=".png"): + mapping = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/webp": ".webp", + } + return mapping.get((content_type or "").lower(), fallback) + + +def _put_object_bytes(object_name, content, content_type): + s3_client.put_object( + Bucket=Config.MINIO["bucket"], + Key=object_name, + Body=content, + ContentType=content_type, + ) + + +def _upload_image_bytes_to_minio(content, content_type="image/png"): + ext = _guess_image_extension(content_type) + base_filename = f"gen-{uuid.uuid4().hex}" + full_filename = f"{base_filename}{ext}" + thumb_filename = f"{base_filename}-thumb.jpg" + + try: + _put_object_bytes(full_filename, content, content_type) + except Exception as upload_e: + raise Exception(f"MinIO 上传原图失败: {upload_e}") + + full_url = f"{Config.MINIO['public_url']}{quote(full_filename)}" + thumb_url = full_url + + try: + img = Image.open(io.BytesIO(content)) + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + + w, h = img.size + if w > 400: + ratio = 400 / float(w) + img.thumbnail((400, int(h * ratio)), Image.Resampling.LANCZOS) + + thumb_io = io.BytesIO() + img.save(thumb_io, format="JPEG", quality=80, optimize=True) + thumb_io.seek(0) + + _put_object_bytes(thumb_filename, thumb_io.getvalue(), "image/jpeg") + thumb_url = f"{Config.MINIO['public_url']}{quote(thumb_filename)}" + except Exception as thumb_e: + system_logger.warning(f"缩略图生成失败: {thumb_e}") + + return {"url": full_url, "thumb": thumb_url} + + +def _decode_base64_image(image_value): + raw_value = image_value or "" + content_type = "image/png" + if raw_value.startswith("data:") and "," in raw_value: + header, raw_value = raw_value.split(",", 1) + if ";" in header: + content_type = header[5:].split(";", 1)[0] or content_type + + # 清理换行和空白,兼容部分服务端返回的分段 Base64 + raw_value = "".join(raw_value.strip().split()) + padding = (-len(raw_value)) % 4 + if padding: + raw_value += "=" * padding + + try: + decoded = base64.b64decode(raw_value, validate=True) + except binascii.Error: + try: + decoded = base64.urlsafe_b64decode(raw_value) + except Exception as decode_e: + raise Exception(f"Base64 解码失败: {decode_e}") + + if not decoded: + raise Exception("Base64 解码失败: 结果为空") + + # 提前验证图片内容,避免把损坏字节流误判成 MinIO 上传问题 + try: + img = Image.open(io.BytesIO(decoded)) + img.verify() + except Exception as verify_e: + raise Exception(f"Base64 已解码,但图片数据无效: {verify_e}") + + return decoded, content_type + + +def _extract_b64_images(result): + images = [] + for item in result.get("data", []) or []: + if not isinstance(item, dict): + continue + if item.get("b64_json"): + images.append(item["b64_json"]) + elif item.get("image_base64"): + images.append(item["image_base64"]) + return images + + +def _process_gpt_image_edit(app, user_id, task_id, payload, api_key, target_api, cost, use_trial): + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + # gpt-image-2 的 edits 接口走站内地址,直连比经过公共代理更稳定 + response = requests.post( + target_api, + json=payload, + headers=headers, + timeout=Config.PROXY_TIMEOUT_GENERATION, + ) + + if response.status_code != 200: + try: + error_data = response.json() + except Exception: + error_data = {"message": response.text} + raise Exception(f"gpt-image-2 请求失败: {_extract_error_detail(error_data)}") + + result = response.json() + b64_images = _extract_b64_images(result) + if not b64_images: + raise Exception("gpt-image-2 未返回可用图片数据") + + processed_data = [] + final_urls = [] + output_format = (payload.get("output_format") or "png").lower() + default_content_type = f"image/{output_format}" if output_format in ("png", "jpeg", "jpg", "webp") else "image/png" + + for b64_image in b64_images: + try: + image_bytes, content_type = _decode_base64_image(b64_image) + except Exception as decode_e: + raise Exception(f"gpt-image-2 返回的 Base64 图片无效: {decode_e}") + if content_type == "image/png" and default_content_type != "image/png": + content_type = default_content_type + try: + uploaded = _upload_image_bytes_to_minio(image_bytes, content_type) + except Exception as upload_e: + raise Exception(f"gpt-image-2 结果已生成,但同步到 MinIO 失败: {upload_e}") + processed_data.append(uploaded) + final_urls.append(uploaded["url"]) + + new_record = GenerationRecord( + user_id=user_id, + prompt=payload.get("prompt"), + model=payload.get("model"), + cost=cost, + image_urls=json.dumps(processed_data), + ) + db.session.add(new_record) + db.session.commit() + + system_logger.info("gpt-image-2 生图任务完成", 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": final_urls, "record_id": new_record.id}), + ) + + def sync_images_background(app, record_id, raw_urls): """后台同步图片至 MinIO,并生成缩略图,带重试机制""" with app.app_context(): @@ -129,6 +295,10 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "processing", "message": "任务已提交,正在排队处理..."})) try: # 1. 提交异步请求 (带重试机制) + if payload.get('model') == 'gpt-image-2': + _process_gpt_image_edit(app, user_id, task_id, payload, api_key, target_api, cost, use_trial) + return + submit_resp = None last_error = None active_api = target_api diff --git a/static/js/main.js b/static/js/main.js index d6958f7..60fb028 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -457,10 +457,46 @@ document.addEventListener('paste', (e) => { } }); -const SIZE_MODELS = ['nano-banana-pro', 'gemini-3.1-flash-image-preview']; +const GPT_IMAGE_EDIT_MODEL = 'gpt-image-2'; +const GPT_IMAGE_EDIT_SIZES = [ + { label: '1024x1024 (square)', value: '1024x1024' }, + { label: '1536x1024 (landscape)', value: '1536x1024' }, + { label: '1024x1536 (portrait)', value: '1024x1536' }, + { label: '2048x2048 (2K square)', value: '2048x2048' }, + { label: '2048x1152 (2K landscape)', value: '2048x1152' }, + { label: '3840x2160 (4K landscape)', value: '3840x2160' }, + { label: '2160x3840 (4K portrait)', value: '2160x3840' }, + { label: 'auto (default)', value: 'auto' } +]; +const SIZE_MODELS = ['nano-banana-2', 'gemini-3.1-flash-image-preview', GPT_IMAGE_EDIT_MODEL]; +let defaultSizeOptions = []; -document.getElementById('modelSelect').onchange = (e) => { - document.getElementById('sizeGroup').classList.toggle('hidden', !SIZE_MODELS.includes(e.target.value)); +function updateSizeOptions() { + const modelSelect = document.getElementById('modelSelect'); + const sizeGroup = document.getElementById('sizeGroup'); + const sizeSelect = document.getElementById('sizeSelect'); + const ratioGroup = document.getElementById('ratioGroup'); + if (!modelSelect || !sizeGroup || !sizeSelect) return; + + const selectedModel = modelSelect.value; + const previousValue = sizeSelect.value; + const nextSizes = selectedModel === GPT_IMAGE_EDIT_MODEL ? GPT_IMAGE_EDIT_SIZES : defaultSizeOptions; + fillSelect('sizeSelect', nextSizes); + + if (selectedModel === GPT_IMAGE_EDIT_MODEL) { + sizeSelect.value = '2160x3840'; + } else if (nextSizes.some(item => item.value === previousValue)) { + sizeSelect.value = previousValue; + } + + sizeGroup.classList.toggle('hidden', !SIZE_MODELS.includes(selectedModel)); + if (ratioGroup) { + ratioGroup.classList.toggle('hidden', selectedModel === GPT_IMAGE_EDIT_MODEL); + } +} + +document.getElementById('modelSelect').onchange = () => { + updateSizeOptions(); updateCostPreview(); // 切换模型时更新计费预览 }; @@ -490,7 +526,7 @@ document.getElementById('promptTpl').onchange = (e) => { document.getElementById('submitBtn').onclick = async () => { const btn = document.getElementById('submitBtn'); - const prompt = document.getElementById('manualPrompt').value; + const prompt = document.getElementById('manualPrompt').value.trim(); const apiKey = document.getElementById('apiKey').value; const num = parseInt(document.getElementById('numSelect').value); @@ -510,11 +546,24 @@ document.getElementById('submitBtn').onclick = async () => { } // 允许文生图(不强制要求图片),但至少得有提示词或图片 - if (!prompt && uploadedFiles.length === 0) { + if (!prompt) { + return showToast('提示词不能为空', 'warning'); return showToast('请至少输入提示词或上传参考图', 'warning'); } // UI 锁定 + if (document.getElementById('modelSelect').value === GPT_IMAGE_EDIT_MODEL && uploadedFiles.length === 0) { + return showToast('gpt-image-2 requires a reference image', 'warning'); + } + + if (document.getElementById('modelSelect').value === GPT_IMAGE_EDIT_MODEL && uploadedFiles.length === 0) { + return showToast('gpt-image-2 妯″瀷蹇呴』涓婁紶鍙傝€冨浘', 'warning'); + } + + if (document.getElementById('modelSelect').value === GPT_IMAGE_EDIT_MODEL && uploadedFiles.length === 0) { + return showToast('gpt-image-2 requires a reference image', 'warning'); + } + btn.disabled = true; const btnText = btn.querySelector('span'); btnText.innerText = uploadedFiles.length > 0 ? "正在同步参考图..." : "正在开启 AI 引擎..."; @@ -597,7 +646,7 @@ document.getElementById('submitBtn').onclick = async () => { apiKey: currentMode === 'key' ? apiKey : '', prompt: document.getElementById('manualPrompt').value, model: document.getElementById('modelSelect').value, - ratio: document.getElementById('ratioSelect').value, + ratio: document.getElementById('modelSelect').value === GPT_IMAGE_EDIT_MODEL ? undefined : document.getElementById('ratioSelect').value, size: document.getElementById('sizeSelect').value, image_data // 发送 Base64 数组 }) @@ -787,7 +836,7 @@ window.addEventListener('message', (e) => { if (modelSelect) { modelSelect.value = 'nano-banana-2'; modelSelect.disabled = true; // 锁定选择 - sizeGroup.classList.remove('hidden'); + updateSizeOptions(); updateCostPreview(); } @@ -1036,12 +1085,13 @@ async function refreshPromptsList() { fillSelect('modelSelect', d.models); fillSelect('ratioSelect', d.ratios); - fillSelect('sizeSelect', d.sizes); + defaultSizeOptions = d.sizes || []; + updateSizeOptions(); // 初始化后检查默认模型是否需要显示尺寸选择器 const modelSelect = document.getElementById('modelSelect'); if (modelSelect && typeof SIZE_MODELS !== 'undefined') { - document.getElementById('sizeGroup').classList.toggle('hidden', !SIZE_MODELS.includes(modelSelect.value)); + updateSizeOptions(); } const userPrompts = await loadUserPrompts(); diff --git a/templates/index.html b/templates/index.html index 7847d89..350312d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -81,7 +81,7 @@ -
+
@@ -481,4 +481,4 @@ {% block scripts %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/mobile.html b/templates/mobile.html index 17b81ab..84b548e 100644 --- a/templates/mobile.html +++ b/templates/mobile.html @@ -191,7 +191,7 @@
-
+
@@ -424,7 +424,7 @@ fillSelect('ratioSelect', config.ratios); // 填充尺寸选择 - fillSelect('sizeSelect', config.sizes); + defaultSizeOptions = config.sizes || []; // 加载用户收藏的提示词 const userPrompts = await loadUserPrompts(); @@ -449,14 +449,40 @@ } // 尺寸模型常量 (与PC端一致) - const SIZE_MODELS = ['nano-banana-2', 'gemini-3.1-flash-image-preview']; + const GPT_IMAGE_EDIT_MODEL = 'gpt-image-2'; + const GPT_IMAGE_EDIT_SIZES = [ + { label: '1024x1024 (square)', value: '1024x1024' }, + { label: '1536x1024 (landscape)', value: '1536x1024' }, + { label: '1024x1536 (portrait)', value: '1024x1536' }, + { label: '2048x2048 (2K square)', value: '2048x2048' }, + { label: '2048x1152 (2K landscape)', value: '2048x1152' }, + { label: '3840x2160 (4K landscape)', value: '3840x2160' }, + { label: '2160x3840 (4K portrait)', value: '2160x3840' }, + { label: 'auto (default)', value: 'auto' } + ]; + const SIZE_MODELS = ['nano-banana-2', 'gemini-3.1-flash-image-preview', GPT_IMAGE_EDIT_MODEL]; + let defaultSizeOptions = []; // 更新尺寸选择器显示状态 function updateSizeVisibility() { const modelSelect = document.getElementById('modelSelect'); const sizeGroup = document.getElementById('sizeGroup'); - if (modelSelect && sizeGroup) { - sizeGroup.classList.toggle('hidden', !SIZE_MODELS.includes(modelSelect.value)); + const sizeSelect = document.getElementById('sizeSelect'); + const ratioGroup = document.getElementById('ratioGroup'); + if (modelSelect && sizeGroup && sizeSelect) { + const selectedModel = modelSelect.value; + const previousValue = sizeSelect.value; + const nextSizes = selectedModel === GPT_IMAGE_EDIT_MODEL ? GPT_IMAGE_EDIT_SIZES : defaultSizeOptions; + fillSelect('sizeSelect', nextSizes); + if (selectedModel === GPT_IMAGE_EDIT_MODEL) { + sizeSelect.value = '2160x3840'; + } else if (nextSizes.some(item => item.value === previousValue)) { + sizeSelect.value = previousValue; + } + sizeGroup.classList.toggle('hidden', !SIZE_MODELS.includes(selectedModel)); + if (ratioGroup) { + ratioGroup.classList.toggle('hidden', selectedModel === GPT_IMAGE_EDIT_MODEL); + } } } @@ -627,6 +653,7 @@ const prompt = document.getElementById('manualPrompt').value.trim(); if (!prompt) { + showToast('提示词不能为空', 'warning'); showToast('请输入提示词', 'warning'); return; } @@ -637,8 +664,14 @@ const num = parseInt(document.getElementById('numSelect').value); const isPremium = document.getElementById('isPremium').checked; + if (model === GPT_IMAGE_EDIT_MODEL && uploadedFiles.length === 0) { + showToast('gpt-image-2 模型必须上传参考图', 'warning'); + return; + } + // 显示加载状态 document.getElementById('placeholder').classList.add('hidden'); + document.getElementById('placeholder').classList.add('hidden'); document.getElementById('resultGrid').classList.add('hidden'); document.getElementById('loadingState').classList.remove('hidden'); @@ -674,7 +707,7 @@ is_premium: isPremium, prompt, model, - ratio, + ratio: model === GPT_IMAGE_EDIT_MODEL ? undefined : ratio, size, image_data: imageData.length > 0 ? imageData : undefined };