feat: 调用gpt\nano模型方式修改,功能优化,增加下载采集图片等功能
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"""导出采集图片:把采集到的源站图片打包成 ZIP 下载到本地。
|
||||
|
||||
参考图 URL 可能是源站 CDN(需 Referer 绕过防盗链)或本地上传的 media 文件。
|
||||
ZIP 内部结构沿用现有分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传),
|
||||
文件名沿用采集 key(main-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import mimetypes
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.proxy import guess_referer
|
||||
from services import storage
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["export"])
|
||||
|
||||
_EXT_BY_CTYPE = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/bmp": ".bmp",
|
||||
}
|
||||
|
||||
|
||||
class ExportImageItem(BaseModel):
|
||||
url: str
|
||||
groupName: str = "主图"
|
||||
variantName: str | None = None
|
||||
key: str = "" # 采集 key,如 main-001 / sku-002 / upload-001
|
||||
|
||||
|
||||
class ExportImagesRequest(BaseModel):
|
||||
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
|
||||
images: list[ExportImageItem]
|
||||
|
||||
|
||||
def _clean(name: str) -> str:
|
||||
"""清洗文件夹/文件名非法字符(与插件 cleanFilename 同规则,Windows 兼容)。"""
|
||||
s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", (name or "").strip())
|
||||
s = re.sub(r"\s+", "_", s)
|
||||
return s.strip(" .")[:80]
|
||||
|
||||
|
||||
def _ext(url: str, ctype: str) -> str:
|
||||
"""由 content-type(优先)或 URL 后缀决定扩展名。"""
|
||||
ctype = ctype.split(";")[0].strip().lower()
|
||||
if ctype in _EXT_BY_CTYPE:
|
||||
return _EXT_BY_CTYPE[ctype]
|
||||
if ctype.startswith("image/"):
|
||||
return "." + ctype.split("/")[-1]
|
||||
path = url.split("?")[0].lower()
|
||||
for ext in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"):
|
||||
if path.endswith(ext):
|
||||
return ".jpg" if ext == ".jpeg" else ext
|
||||
return ".jpg"
|
||||
|
||||
|
||||
def _is_image(url: str, ctype: str) -> bool:
|
||||
ctype = ctype.split(";")[0].strip().lower()
|
||||
if ctype.startswith("image/"):
|
||||
return True
|
||||
return bool(re.search(r"\.(jpe?g|png|webp|gif|bmp)(\?|$)", url, re.IGNORECASE))
|
||||
|
||||
|
||||
async def _download(url: str) -> tuple[bytes, str]:
|
||||
"""本地 media 文件直读磁盘;远程 URL 带 Referer 下载。"""
|
||||
path = storage.local_path(url)
|
||||
if path is not None:
|
||||
mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
return path.read_bytes(), mime
|
||||
return await storage.download_bytes(url, referer=guess_referer(url))
|
||||
|
||||
|
||||
@router.post("/export-images")
|
||||
async def export_images(req: ExportImagesRequest):
|
||||
if not req.images:
|
||||
raise HTTPException(status_code=400, detail="没有可导出的图片")
|
||||
|
||||
root = _clean(req.title) or "采集图片"
|
||||
buf = io.BytesIO()
|
||||
used: set[str] = set()
|
||||
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for img in req.images:
|
||||
try:
|
||||
data, ctype = await _download(img.url)
|
||||
except Exception: # noqa: BLE001
|
||||
continue # 单张失败不中断整包
|
||||
if not _is_image(img.url, ctype):
|
||||
continue
|
||||
|
||||
ext = _ext(img.url, ctype)
|
||||
base = _clean(img.key) or "image"
|
||||
if img.variantName:
|
||||
base += f"-{_clean(img.variantName)}"
|
||||
filename = f"{base}{ext}"
|
||||
if filename in used: # 同名加序号防覆盖
|
||||
stem = filename[: -len(ext)]
|
||||
n = 2
|
||||
while f"{stem}-{n}{ext}" in used:
|
||||
n += 1
|
||||
filename = f"{stem}-{n}{ext}"
|
||||
used.add(filename)
|
||||
|
||||
group = _clean(img.groupName) or "图片"
|
||||
zf.writestr(f"{root}/{group}/{filename}", data)
|
||||
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="collect.zip"'},
|
||||
)
|
||||
+1
-3
@@ -41,14 +41,12 @@ class Settings(BaseSettings):
|
||||
dashscope_base_url: str = "" # 留空按模型自动选择万象异步/千问同步端点
|
||||
dashscope_model: str = "wan2.7-image-pro"
|
||||
|
||||
# RightAPI(OpenAI 兼容中转,gpt-image 系列)
|
||||
# RightAPI(OpenAI 兼容中转,gpt-image / nano-banana 系列)
|
||||
rightapi_api_key: str = ""
|
||||
rightapi_base_url: str = "https://rightapi.ai/draw"
|
||||
rightapi_image_model: str = "gpt-image-2"
|
||||
rightapi_image_quality: str = "high" # auto | low | medium | high
|
||||
rightapi_max_retries: int = 3 # 429/5xx/超时的重试次数(1 = 不重试)
|
||||
rightapi_retry_wait: int = 60 # 重试基础等待秒数,按 60→120→240 递增
|
||||
rightapi_input_fidelity: str = "high" # edits 端点高保真档(high | low,留空关闭)
|
||||
|
||||
# DeepSeek(出图方案规划器)
|
||||
deepseek_api_key: str = ""
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api import generate, proxy, suites, upload
|
||||
from api import export, generate, proxy, suites, upload
|
||||
from config import get_settings
|
||||
from services.storage import media_root
|
||||
|
||||
@@ -26,6 +26,7 @@ app.include_router(generate.router)
|
||||
app.include_router(suites.router)
|
||||
app.include_router(proxy.router)
|
||||
app.include_router(upload.router)
|
||||
app.include_router(export.router)
|
||||
|
||||
# 静态托管生成的图片
|
||||
app.mount("/media", StaticFiles(directory=str(media_root())), name="media")
|
||||
|
||||
@@ -234,71 +234,103 @@ async def generate_tongyi(prompt: str, ref_images: list[str], size: str = "2048*
|
||||
return dl.content
|
||||
|
||||
|
||||
# ── Provider:RightAPI(gpt-image,OpenAI 兼容中转)──────────────────────
|
||||
# ── Provider:RightAPI(gpt-image / nano-banana,OpenAI 兼容中转)──────────
|
||||
|
||||
# 可重试的状态码:中转限流/网关抖动(该中转限流时返回 Cloudflare 502 而非 429)
|
||||
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
||||
|
||||
# 中转对 input_fidelity 参数的支持探测:按模型记忆不支持该参数的模型(gpt-image 系列支持,
|
||||
# nano-banana 系列可能不认;降级只影响触发过的模型,不牵连其他模型)
|
||||
_rightapi_fidelity_unsupported: set[str] = set()
|
||||
|
||||
async def _rightapi_poll_task(client: httpx.AsyncClient, headers: dict, origin: str,
|
||||
task_id: str, max_wait: int) -> dict:
|
||||
"""轮询站点级任务查询接口 GET /v1/tasks/{task_id}(不带 /draw 前缀)。
|
||||
|
||||
实测要点(docs/rightapi-调用排查与修复方案.md §2.2):
|
||||
- 完成响应**没有** status:"completed" 字段,完成判定 = 响应里出现 data;
|
||||
- progress 基本不动(0~2),不能当进度条依据;
|
||||
- 失败态 = status 为 failed / error / cancelled。
|
||||
"""
|
||||
poll_url = f"{origin}/v1/tasks/{task_id}"
|
||||
elapsed, interval = 0, 3
|
||||
while elapsed < max_wait:
|
||||
resp = await client.get(poll_url, headers=headers, timeout=30)
|
||||
_raise_api_error(resp, "RightAPI")
|
||||
result = resp.json()
|
||||
status = result.get("status", "")
|
||||
if status in ("failed", "error", "cancelled"):
|
||||
err = result.get("error") or {}
|
||||
raise RuntimeError(f"RightAPI 任务失败: {err.get('message') or result}")
|
||||
if "data" in result:
|
||||
return result
|
||||
await asyncio.sleep(interval)
|
||||
elapsed += interval
|
||||
interval = min(interval + 2, 10)
|
||||
raise TimeoutError(f"RightAPI 异步任务超时 ({max_wait}s): task_id={task_id}")
|
||||
|
||||
|
||||
def _rightapi_extract_image(result: dict) -> tuple[str | None, str | None]:
|
||||
"""从轮询完成结果里取 (kind, payload):kind ∈ url | b64,未取到返回 (None, None)。
|
||||
|
||||
完成形状为 Images 协议:{"created":..., "data":[{"url": "..."}]}(实测只见 url)。
|
||||
"""
|
||||
data = result.get("data") or []
|
||||
if data:
|
||||
item = data[0] or {}
|
||||
url = item.get("url") or ""
|
||||
if url:
|
||||
return ("url", url)
|
||||
b64 = item.get("b64_json") or ""
|
||||
if b64:
|
||||
return ("b64", b64)
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _rightapi_request(s, prompt: str, ref_images: list[str], size: str, model: str) -> bytes:
|
||||
"""RightAPI 各模型:有参考图走 /v1/images/edits(multipart),无参考图走 /v1/images/generations。
|
||||
"""RightAPI 各模型:统一走 /v1/images/generations(异步)。
|
||||
|
||||
OpenAI 兼容协议:响应固定 b64_json(不支持 response_format 参数,传了报 400);
|
||||
同步调用无任务轮询,高质量档单张 1-5 分钟,超时按文档建议兜底 600s。
|
||||
input_fidelity=high 是 gpt-image-1 的 edits 保真参数(gpt-image-2 官方已移除、默认高保真,
|
||||
官逆通道更是不识别);带上是为了兼容按 gpt-image-1 语义实现的中转,中转不认(400)则按模型
|
||||
自动去掉重试并记住,该模型后续请求不再带。
|
||||
官方协议(docs.rightapi.ai/docs/rc_draw/,2026-07 起统一异步):
|
||||
- POST /draw/v1/images/generations,请求体固定带 async:true,参考图放 image 数组(data-URI);
|
||||
- 返回 task_id 后轮询 GET /v1/tasks/{task_id}(站点级,不带 /draw);
|
||||
- 参数只有 model/prompt/n/size/imageSize/image/async;不传 quality/output_format/input_fidelity。
|
||||
参考图沿用现选图逻辑(≤2 张,image 数组)。单张 1-5 分钟,轮询上限 poll_max_wait 兜底。
|
||||
"""
|
||||
base = s.rightapi_base_url.rstrip("/")
|
||||
# 任务查询是站点级接口,不带 /draw:从 base 里拆出 origin(https://rightapi.ai/draw → https://rightapi.ai)
|
||||
origin = base.split("/draw", 1)[0].rstrip("/") or base
|
||||
headers = {"Authorization": f"Bearer {s.rightapi_api_key}"}
|
||||
use_fidelity = (
|
||||
bool(ref_images) and s.rightapi_input_fidelity and model not in _rightapi_fidelity_unsupported
|
||||
)
|
||||
|
||||
body = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"async": True,
|
||||
}
|
||||
if ref_images:
|
||||
body["image"] = [await _resolve_ref(u) for u in ref_images]
|
||||
|
||||
async with httpx.AsyncClient(timeout=max(s.request_timeout, 600), verify=False) as client:
|
||||
common = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"quality": s.rightapi_image_quality,
|
||||
"output_format": "jpeg", # 与落盘 .jpg 后缀一致
|
||||
"n": 1,
|
||||
}
|
||||
if use_fidelity:
|
||||
common["input_fidelity"] = s.rightapi_input_fidelity
|
||||
if ref_images:
|
||||
files = []
|
||||
for i, u in enumerate(ref_images):
|
||||
data, mime = await _resolve_ref_bytes(u)
|
||||
files.append(("image[]", (f"ref-{i + 1}.{mime.split('/')[-1]}", data, mime)))
|
||||
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
|
||||
# 中转不认 input_fidelity:去掉参数重试一次(仅一次探测),降级只记到当前模型
|
||||
if resp.status_code == 400 and use_fidelity and "input_fidelity" in resp.text:
|
||||
_rightapi_fidelity_unsupported.add(model)
|
||||
log.warning("RightAPI 模型 %s 不支持 input_fidelity 参数,已自动去掉并降级(该模型后续请求不再带)", model)
|
||||
common.pop("input_fidelity", None)
|
||||
resp = await client.post(f"{base}/v1/images/edits", headers=headers, files=files, data=common)
|
||||
else:
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/generations",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=common,
|
||||
)
|
||||
resp = await client.post(
|
||||
f"{base}/v1/images/generations",
|
||||
headers={**headers, "Content-Type": "application/json"},
|
||||
json=body,
|
||||
)
|
||||
_raise_api_error(resp, "RightAPI")
|
||||
item = resp.json()["data"][0]
|
||||
b64 = item.get("b64_json") or ""
|
||||
if b64:
|
||||
return base64.b64decode(b64.split(",", 1)[-1] if "," in b64 else b64)
|
||||
img_url = item.get("url") or ""
|
||||
if not img_url:
|
||||
raise RuntimeError(f"RightAPI 响应里没有图片数据: {item}")
|
||||
dl = await client.get(img_url, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
submitted = resp.json()
|
||||
task_id = submitted.get("task_id") or ""
|
||||
if task_id:
|
||||
result = await _rightapi_poll_task(client, headers, origin, task_id, s.poll_max_wait)
|
||||
else:
|
||||
# 极端兜底:个别中转可能同步返回 data(文档不保证,但防御处理)
|
||||
result = submitted
|
||||
|
||||
kind, payload = _rightapi_extract_image(result)
|
||||
if kind == "b64" and payload:
|
||||
return base64.b64decode(payload.split(",", 1)[-1] if "," in payload else payload)
|
||||
if kind == "url" and payload:
|
||||
dl = await client.get(payload, timeout=s.request_timeout)
|
||||
dl.raise_for_status()
|
||||
return dl.content
|
||||
raise RuntimeError(f"RightAPI 任务完成但没有图片数据: {result}")
|
||||
|
||||
|
||||
async def generate_rightapi(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
|
||||
|
||||
Reference in New Issue
Block a user