feat: support multi-image image generation
This commit is contained in:
@@ -48,6 +48,9 @@ IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30
|
||||
API_GENERATE_THROTTLE_RATE=60/min
|
||||
API_AUTH_FAILURE_THROTTLE_RATE=30/min
|
||||
IMAGE_URL_MAX_BYTES=10485760
|
||||
IMAGE_MAX_INPUT_IMAGES=8
|
||||
IMAGE_MAX_INPUT_IMAGE_BYTES=10485760
|
||||
IMAGE_MAX_INPUT_TOTAL_BYTES=33554432
|
||||
IMAGE_URL_MAX_REDIRECTS=3
|
||||
IMAGE_URL_CONNECT_TIMEOUT_SECONDS=10
|
||||
IMAGE_URL_READ_TIMEOUT_SECONDS=60
|
||||
|
||||
@@ -27,7 +27,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
|
||||
|
||||
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为“虾皮圈”、T-612 生图同步接口止血、T-613 抽生成核心 service、T-614 生图异步任务化接口、T-615 旧同步生图接口遥测 / 弃用口径、T-616 生图失败自动重试 2 次、T-617 桌面端版本文件大小字段与 T-618 客户端发布版本后台必填校验元数据。用户可通过公开首页进入注册、登录、下载客户端和下载导入模板;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON,并读取 `release.force_update` 判断是否必须升级、读取 `release.size_bytes` 校验安装包大小;新版桌面端可用异步生图提交 / 轮询接口,旧同步生图接口继续兼容并写结构化用量日志;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录、图片生成任务、客户端发布版本和导入模板,客户端发布版本新增 / 编辑时必须填写 SHA256 与文件大小,并通过计费层带原因手工调点。生产侧仍需补真实支付回调到账闭环,并按日志观察旧同步生图接口迁移进度。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
|
||||
T-619 已完成:新增独立 `vision` 能力和同步 `POST /api/v1/analyze/images` 多图理解接口,按一次请求固定扣点;既有标题、生图和异步生图接口保持兼容。生产启用前需在 admin 配置同时具备 `text`、`vision` 能力的模型、`vision-standard` 默认别名及默认计费规则。T-620 已登记:图生图接口支持单图 / 多图,第一张为主图、后续为参考图。
|
||||
T-619 已完成:新增独立 `vision` 能力和同步 `POST /api/v1/analyze/images` 多图理解接口,按一次请求固定扣点;既有标题、生图和异步生图接口保持兼容。生产启用前需在 admin 配置同时具备 `text`、`vision` 能力的模型、`vision-standard` 默认别名及默认计费规则。T-620 已完成:同步 / 异步图生图兼容旧单图字段并支持有序多图,第一张为主商品图、后续图为参考图。
|
||||
|
||||
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ class ImageGenerationResult:
|
||||
class MultimodalImage:
|
||||
data: bytes
|
||||
mime_type: str = "image/png"
|
||||
filename: str = "image.png"
|
||||
|
||||
|
||||
class Provider(Protocol):
|
||||
@@ -108,6 +109,7 @@ class Provider(Protocol):
|
||||
image: bytes | None = None,
|
||||
image_mime_type: str = "image/png",
|
||||
image_filename: str = "image.png",
|
||||
images: Sequence[MultimodalImage] | None = None,
|
||||
resolution: str = "1K",
|
||||
aspect_ratio: str = "1:1",
|
||||
parameters: Mapping[str, Any] | None = None,
|
||||
|
||||
@@ -138,6 +138,7 @@ class ChatCompletionsProvider(BaseHttpProvider):
|
||||
image: bytes | None = None,
|
||||
image_mime_type: str = "image/png",
|
||||
image_filename: str = "image.png",
|
||||
images: Sequence[MultimodalImage] | None = None,
|
||||
resolution: str = "1K",
|
||||
aspect_ratio: str = "1:1",
|
||||
parameters: Mapping[str, Any] | None = None,
|
||||
@@ -149,6 +150,7 @@ class ChatCompletionsProvider(BaseHttpProvider):
|
||||
prompt,
|
||||
image=image,
|
||||
image_mime_type=image_mime_type,
|
||||
images=images,
|
||||
parameters=parameters,
|
||||
)
|
||||
response = self.session.post(
|
||||
@@ -243,6 +245,7 @@ class GeminiProvider(ChatCompletionsProvider):
|
||||
image: bytes | None = None,
|
||||
image_mime_type: str = "image/png",
|
||||
image_filename: str = "image.png",
|
||||
images: Sequence[MultimodalImage] | None = None,
|
||||
resolution: str = "1K",
|
||||
aspect_ratio: str = "1:1",
|
||||
parameters: Mapping[str, Any] | None = None,
|
||||
@@ -254,6 +257,7 @@ class GeminiProvider(ChatCompletionsProvider):
|
||||
prompt,
|
||||
image=image,
|
||||
image_mime_type=image_mime_type,
|
||||
images=images,
|
||||
response_modalities=["TEXT", "IMAGE"],
|
||||
parameters=parameters,
|
||||
)
|
||||
@@ -324,6 +328,7 @@ class ImagesGenerationProvider(BaseHttpProvider):
|
||||
image: bytes | None = None,
|
||||
image_mime_type: str = "image/png",
|
||||
image_filename: str = "image.png",
|
||||
images: Sequence[MultimodalImage] | None = None,
|
||||
resolution: str = "1K",
|
||||
aspect_ratio: str = "1:1",
|
||||
parameters: Mapping[str, Any] | None = None,
|
||||
@@ -337,8 +342,17 @@ class ImagesGenerationProvider(BaseHttpProvider):
|
||||
"resolution": resolution,
|
||||
"n": 1,
|
||||
}
|
||||
if image is not None:
|
||||
payload["image_urls"] = [image_bytes_to_data_url(image, image_mime_type)]
|
||||
image_inputs = generation_image_inputs(
|
||||
image=image,
|
||||
image_mime_type=image_mime_type,
|
||||
image_filename=image_filename,
|
||||
images=images,
|
||||
)
|
||||
if image_inputs:
|
||||
payload["image_urls"] = [
|
||||
image_bytes_to_data_url(item.data, item.mime_type)
|
||||
for item in image_inputs
|
||||
]
|
||||
apply_extra_body(payload, model, parameters)
|
||||
response = self.session.post(
|
||||
url,
|
||||
@@ -376,12 +390,19 @@ class ImagesEditsProvider(BaseHttpProvider):
|
||||
image: bytes | None = None,
|
||||
image_mime_type: str = "image/png",
|
||||
image_filename: str = "image.png",
|
||||
images: Sequence[MultimodalImage] | None = None,
|
||||
resolution: str = "1K",
|
||||
aspect_ratio: str = "1:1",
|
||||
parameters: Mapping[str, Any] | None = None,
|
||||
) -> ImageGenerationResult:
|
||||
validate_model_config(model)
|
||||
if image is None:
|
||||
image_inputs = generation_image_inputs(
|
||||
image=image,
|
||||
image_mime_type=image_mime_type,
|
||||
image_filename=image_filename,
|
||||
images=images,
|
||||
)
|
||||
if not image_inputs:
|
||||
raise AiCapabilityError("images edits provider requires an input image")
|
||||
|
||||
url = normalize_api_url(model.url, API_IMAGES_EDITS)
|
||||
@@ -392,7 +413,15 @@ class ImagesEditsProvider(BaseHttpProvider):
|
||||
"size": resolution_to_size(resolution),
|
||||
}
|
||||
apply_extra_body(data, model, parameters)
|
||||
files = {"image": (image_filename, image, image_mime_type)}
|
||||
files: dict[str, tuple[str, bytes, str]] | list[tuple[str, tuple[str, bytes, str]]]
|
||||
if len(image_inputs) == 1:
|
||||
item = image_inputs[0]
|
||||
files = {"image": (item.filename, item.data, item.mime_type)}
|
||||
else:
|
||||
files = [
|
||||
("image", (item.filename, item.data, item.mime_type))
|
||||
for item in image_inputs
|
||||
]
|
||||
response = self.session.post(
|
||||
url,
|
||||
headers=self._headers(model),
|
||||
@@ -443,13 +472,17 @@ def build_chat_image_payload(
|
||||
*,
|
||||
image: bytes | None = None,
|
||||
image_mime_type: str = "image/png",
|
||||
images: Sequence[MultimodalImage] | None = None,
|
||||
parameters: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return build_chat_text_payload(
|
||||
return build_chat_vision_payload(
|
||||
model,
|
||||
prompt,
|
||||
image=image,
|
||||
image_mime_type=image_mime_type,
|
||||
images=generation_image_inputs(
|
||||
image=image,
|
||||
image_mime_type=image_mime_type,
|
||||
images=images,
|
||||
),
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
@@ -486,12 +519,17 @@ def build_gemini_payload(
|
||||
*,
|
||||
image: bytes | None = None,
|
||||
image_mime_type: str = "image/png",
|
||||
images: Sequence[MultimodalImage] | None = None,
|
||||
response_modalities: list[str],
|
||||
parameters: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
parts: list[dict[str, Any]] = [{"text": prompt}]
|
||||
if image is not None:
|
||||
data_url = image_bytes_to_data_url(image, image_mime_type)
|
||||
for image_input in generation_image_inputs(
|
||||
image=image,
|
||||
image_mime_type=image_mime_type,
|
||||
images=images,
|
||||
):
|
||||
data_url = image_bytes_to_data_url(image_input.data, image_input.mime_type)
|
||||
mime_type, data = split_data_url(data_url)
|
||||
parts.append({"inlineData": {"mimeType": mime_type, "data": data}})
|
||||
payload: dict[str, Any] = {
|
||||
@@ -522,6 +560,26 @@ def build_gemini_vision_payload(
|
||||
return payload
|
||||
|
||||
|
||||
def generation_image_inputs(
|
||||
*,
|
||||
image: bytes | None = None,
|
||||
image_mime_type: str = "image/png",
|
||||
image_filename: str = "image.png",
|
||||
images: Sequence[MultimodalImage] | None = None,
|
||||
) -> tuple[MultimodalImage, ...]:
|
||||
if images is not None:
|
||||
return tuple(images)
|
||||
if image is None:
|
||||
return ()
|
||||
return (
|
||||
MultimodalImage(
|
||||
data=image,
|
||||
mime_type=image_mime_type,
|
||||
filename=image_filename,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def apply_extra_body(
|
||||
payload: dict[str, Any],
|
||||
model: ResolvedModel,
|
||||
|
||||
@@ -22,6 +22,7 @@ from apps.ai.providers.openai_compatible import (
|
||||
ChatCompletionsProvider,
|
||||
GeminiProvider,
|
||||
ImagesEditsProvider,
|
||||
ImagesGenerationProvider,
|
||||
)
|
||||
from apps.ai.providers.utils import image_request_timeout, resolution_to_size
|
||||
|
||||
@@ -207,6 +208,34 @@ class ChatCompletionsProviderTests(SimpleTestCase):
|
||||
self.assertEqual(content[0], {"type": "text", "text": "Generate product image"})
|
||||
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,"))
|
||||
|
||||
def test_generate_image_sends_multiple_chat_images_in_order(self):
|
||||
encoded = base64.b64encode(b"generated-image").decode("ascii")
|
||||
session = FakeSession(
|
||||
FakeResponse({"data": [{"b64_json": encoded}]})
|
||||
)
|
||||
provider = ChatCompletionsProvider(session=session)
|
||||
model = ResolvedModel(
|
||||
name="Nano Banana 2",
|
||||
url="https://api.vectorengine.ai/v1/chat/completions",
|
||||
model="gemini-3.1-flash-image-preview",
|
||||
api_key="test-key",
|
||||
api_type="chat",
|
||||
)
|
||||
|
||||
provider.generate_image(
|
||||
"Generate product image",
|
||||
model,
|
||||
images=(
|
||||
MultimodalImage(b"main-image", "image/jpeg", "main.jpg"),
|
||||
MultimodalImage(b"reference-image", "image/png", "reference.png"),
|
||||
),
|
||||
)
|
||||
|
||||
content = session.posts[0]["json"]["messages"][0]["content"]
|
||||
self.assertEqual(content[0]["text"], "Generate product image")
|
||||
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,"))
|
||||
self.assertTrue(content[2]["image_url"]["url"].startswith("data:image/png;base64,"))
|
||||
|
||||
@override_settings(AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180)
|
||||
def test_generate_image_caps_post_and_download_timeouts(self):
|
||||
session = FakeSession(
|
||||
@@ -345,6 +374,34 @@ class ChatCompletionsProviderTests(SimpleTestCase):
|
||||
)
|
||||
|
||||
|
||||
class ImagesGenerationProviderTests(SimpleTestCase):
|
||||
def test_generate_image_sends_multiple_json_images_in_order(self):
|
||||
encoded = base64.b64encode(b"generated-image").decode("ascii")
|
||||
session = FakeSession(FakeResponse({"data": [{"b64_json": encoded}]}))
|
||||
provider = ImagesGenerationProvider(session=session)
|
||||
model = ResolvedModel(
|
||||
name="JSON image provider",
|
||||
url="https://images.example.test/v1/images/generations",
|
||||
model="image-model",
|
||||
api_key="test-key",
|
||||
api_type="images",
|
||||
)
|
||||
|
||||
provider.generate_image(
|
||||
"Use the first image as the main product",
|
||||
model,
|
||||
images=(
|
||||
MultimodalImage(b"main-image", "image/jpeg", "main.jpg"),
|
||||
MultimodalImage(b"reference-image", "image/png", "reference.png"),
|
||||
),
|
||||
)
|
||||
|
||||
image_urls = session.posts[0]["json"]["image_urls"]
|
||||
self.assertEqual(len(image_urls), 2)
|
||||
self.assertTrue(image_urls[0].startswith("data:image/jpeg;base64,"))
|
||||
self.assertTrue(image_urls[1].startswith("data:image/png;base64,"))
|
||||
|
||||
|
||||
class ImagesEditsProviderTests(SimpleTestCase):
|
||||
def test_generate_image_builds_multipart_request_and_parses_base64(self):
|
||||
generated = b"edited-image"
|
||||
@@ -379,6 +436,35 @@ class ImagesEditsProviderTests(SimpleTestCase):
|
||||
("source.png", b"source-image", "image/png"),
|
||||
)
|
||||
|
||||
def test_generate_image_sends_multiple_multipart_images_in_order(self):
|
||||
encoded = base64.b64encode(b"edited-image").decode("ascii")
|
||||
session = FakeSession(FakeResponse({"data": [{"b64_json": encoded}]}))
|
||||
provider = ImagesEditsProvider(session=session)
|
||||
model = ResolvedModel(
|
||||
name="GPT Image 2",
|
||||
url="https://api.vectorengine.ai/v1/images/edits",
|
||||
model="gpt-image-2",
|
||||
api_key="test-key",
|
||||
api_type="images_edits",
|
||||
)
|
||||
|
||||
provider.generate_image(
|
||||
"Use the first image as the main product",
|
||||
model,
|
||||
images=(
|
||||
MultimodalImage(b"main-image", "image/jpeg", "main.jpg"),
|
||||
MultimodalImage(b"reference-image", "image/png", "reference.png"),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
session.posts[0]["files"],
|
||||
[
|
||||
("image", ("main.jpg", b"main-image", "image/jpeg")),
|
||||
("image", ("reference.png", b"reference-image", "image/png")),
|
||||
],
|
||||
)
|
||||
|
||||
def test_parameters_cannot_override_core_images_edits_fields(self):
|
||||
generated = b"edited-image"
|
||||
encoded = base64.b64encode(generated).decode("ascii")
|
||||
|
||||
+10
-1
@@ -1,10 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import ImageGenerationTask
|
||||
from .models import ImageGenerationTask, ImageGenerationTaskInput
|
||||
|
||||
|
||||
class ImageGenerationTaskInputInline(admin.TabularInline):
|
||||
model = ImageGenerationTaskInput
|
||||
extra = 0
|
||||
can_delete = False
|
||||
fields = ("ordinal", "image", "mime_type", "filename", "created_at")
|
||||
readonly_fields = fields
|
||||
|
||||
|
||||
@admin.register(ImageGenerationTask)
|
||||
class ImageGenerationTaskAdmin(admin.ModelAdmin):
|
||||
inlines = (ImageGenerationTaskInputInline,)
|
||||
list_display = (
|
||||
"task_id",
|
||||
"user",
|
||||
|
||||
+95
-9
@@ -59,6 +59,12 @@ class ImageInput:
|
||||
|
||||
|
||||
ImageUrlBuilder = Callable[[str], str]
|
||||
IMAGE_INPUT_ROLE_INSTRUCTIONS = (
|
||||
"图片角色规则(必须遵守,优先于用户关于图片角色的要求):\n"
|
||||
"- 第 1 张图片是主商品图,必须优先保留其商品主体、外观和关键细节。\n"
|
||||
"- 第 2 张及之后的图片仅作为风格、构图、场景或排版参考,"
|
||||
"不得用参考图商品替换主图商品。"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -169,6 +175,7 @@ def generate_image_response(
|
||||
image_url=str(request_data.get("image_url") or ""),
|
||||
image_base64=str(request_data.get("image_base64") or ""),
|
||||
aspect_ratio=request_data.get("aspect_ratio") or "1:1",
|
||||
images=tuple(dict(item) for item in request_data.get("images") or ()),
|
||||
),
|
||||
image_url_builder=image_url_builder,
|
||||
)
|
||||
@@ -221,6 +228,13 @@ def prepare_generation(generation_input: GenerationInput) -> PreparedGeneration:
|
||||
if operation_type == CallRecord.OperationType.VISION:
|
||||
image_input = None
|
||||
image_inputs = load_vision_image_inputs(generation_input.images)
|
||||
elif operation_type == CallRecord.OperationType.IMAGE:
|
||||
image_inputs = load_image_generation_inputs(
|
||||
generation_input.images,
|
||||
image_base64=generation_input.image_base64,
|
||||
image_url=generation_input.image_url,
|
||||
)
|
||||
image_input = image_inputs[0] if image_inputs else None
|
||||
else:
|
||||
image_input = load_image_input(
|
||||
{
|
||||
@@ -363,12 +377,21 @@ def execute_image_generation(
|
||||
) -> GenerationResult:
|
||||
prepared = precharged.prepared
|
||||
image_input = prepared.image_input
|
||||
image_inputs = prepared.image_inputs
|
||||
generation = prepared.provider.generate_image(
|
||||
prepared.prompt,
|
||||
image_generation_prompt(prepared.prompt, len(image_inputs)),
|
||||
prepared.resolved_model,
|
||||
image=image_input.data if image_input else None,
|
||||
image_mime_type=image_input.mime_type if image_input else "image/png",
|
||||
image_filename=image_input.filename if image_input else "image.png",
|
||||
images=tuple(
|
||||
MultimodalImage(
|
||||
data=item.data,
|
||||
mime_type=item.mime_type,
|
||||
filename=item.filename,
|
||||
)
|
||||
for item in image_inputs
|
||||
),
|
||||
resolution=prepared.resolution,
|
||||
aspect_ratio=prepared.aspect_ratio,
|
||||
parameters=prepared.parameters,
|
||||
@@ -530,13 +553,21 @@ def upstream_error(exc: Exception) -> ApiRequestError:
|
||||
|
||||
|
||||
def load_image_input(data: Mapping[str, Any]) -> ImageInput | None:
|
||||
return load_image_input_with_limit(data)
|
||||
|
||||
|
||||
def load_image_input_with_limit(
|
||||
data: Mapping[str, Any],
|
||||
*,
|
||||
max_bytes: int | None = None,
|
||||
) -> ImageInput | None:
|
||||
raw_base64 = str(data.get("image_base64") or "").strip()
|
||||
if raw_base64:
|
||||
return decode_image_input(raw_base64)
|
||||
return decode_image_input(raw_base64, max_bytes=max_bytes)
|
||||
|
||||
image_url = str(data.get("image_url") or "").strip()
|
||||
if image_url:
|
||||
return download_image_input(image_url)
|
||||
return download_image_input(image_url, max_bytes=max_bytes)
|
||||
|
||||
return None
|
||||
|
||||
@@ -544,15 +575,54 @@ def load_image_input(data: Mapping[str, Any]) -> ImageInput | None:
|
||||
def load_vision_image_inputs(
|
||||
items: Sequence[Mapping[str, Any]],
|
||||
) -> tuple[ImageInput, ...]:
|
||||
max_images = max(1, int(getattr(settings, "VISION_MAX_IMAGES", 8)))
|
||||
return load_ordered_image_inputs(
|
||||
items,
|
||||
max_images=max(1, int(getattr(settings, "VISION_MAX_IMAGES", 8))),
|
||||
max_image_bytes=max(
|
||||
1,
|
||||
int(getattr(settings, "VISION_MAX_IMAGE_BYTES", 10 * 1024 * 1024)),
|
||||
),
|
||||
max_total_bytes=max(
|
||||
1,
|
||||
int(getattr(settings, "VISION_MAX_TOTAL_BYTES", 32 * 1024 * 1024)),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def load_image_generation_inputs(
|
||||
items: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
image_base64: str = "",
|
||||
image_url: str = "",
|
||||
) -> tuple[ImageInput, ...]:
|
||||
max_image_bytes = max(
|
||||
1,
|
||||
int(getattr(settings, "VISION_MAX_IMAGE_BYTES", 10 * 1024 * 1024)),
|
||||
int(getattr(settings, "IMAGE_MAX_INPUT_IMAGE_BYTES", 10 * 1024 * 1024)),
|
||||
)
|
||||
max_total_bytes = max(
|
||||
1,
|
||||
int(getattr(settings, "VISION_MAX_TOTAL_BYTES", 32 * 1024 * 1024)),
|
||||
if items:
|
||||
return load_ordered_image_inputs(
|
||||
items,
|
||||
max_images=max(1, int(getattr(settings, "IMAGE_MAX_INPUT_IMAGES", 8))),
|
||||
max_image_bytes=max_image_bytes,
|
||||
max_total_bytes=max(
|
||||
1,
|
||||
int(getattr(settings, "IMAGE_MAX_INPUT_TOTAL_BYTES", 32 * 1024 * 1024)),
|
||||
),
|
||||
)
|
||||
image_input = load_image_input_with_limit(
|
||||
{"image_base64": image_base64, "image_url": image_url},
|
||||
max_bytes=max_image_bytes,
|
||||
)
|
||||
return (image_input,) if image_input is not None else ()
|
||||
|
||||
|
||||
def load_ordered_image_inputs(
|
||||
items: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
max_images: int,
|
||||
max_image_bytes: int,
|
||||
max_total_bytes: int,
|
||||
) -> tuple[ImageInput, ...]:
|
||||
if not items:
|
||||
raise ApiRequestError("bad_request", "images 至少需要一张图片", status.HTTP_400_BAD_REQUEST)
|
||||
if len(items) > max_images:
|
||||
@@ -591,6 +661,12 @@ def load_vision_image_inputs(
|
||||
return tuple(image_inputs)
|
||||
|
||||
|
||||
def image_generation_prompt(prompt: str, image_count: int) -> str:
|
||||
if image_count < 1:
|
||||
return prompt
|
||||
return f"{prompt}\n\n{IMAGE_INPUT_ROLE_INSTRUCTIONS}"
|
||||
|
||||
|
||||
def decode_image_input(value: str, *, max_bytes: int | None = None) -> ImageInput:
|
||||
mime_type = "image/png"
|
||||
encoded = value
|
||||
@@ -646,7 +722,10 @@ def download_image_input(url: str, *, max_bytes: int | None = None) -> ImageInpu
|
||||
content_type = response.headers.get("Content-Type", "image/png").split(";", 1)[0].strip().lower()
|
||||
if not content_type.startswith("image/"):
|
||||
raise ApiRequestError("bad_request", "image_url 不是图片资源", status.HTTP_400_BAD_REQUEST)
|
||||
image = read_limited_image_response(response, max_bytes=max_bytes)
|
||||
image = read_limited_image_response(
|
||||
response,
|
||||
max_bytes=effective_image_url_max_bytes(max_bytes),
|
||||
)
|
||||
if not image:
|
||||
raise ApiRequestError("bad_request", "image_url 图片内容为空", status.HTTP_400_BAD_REQUEST)
|
||||
return ImageInput(
|
||||
@@ -746,6 +825,13 @@ def read_limited_image_response(response, *, max_bytes: int | None = None) -> by
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def effective_image_url_max_bytes(max_bytes: int | None) -> int:
|
||||
url_limit = max(1, int(getattr(settings, "IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024)))
|
||||
if max_bytes is None:
|
||||
return url_limit
|
||||
return min(url_limit, max(1, int(max_bytes)))
|
||||
|
||||
|
||||
def filename_for_mime(mime_type: str) -> str:
|
||||
extension = {
|
||||
"image/jpeg": "jpg",
|
||||
|
||||
+116
-38
@@ -25,7 +25,7 @@ from .generation import (
|
||||
prepare_generation,
|
||||
precharge_generation,
|
||||
)
|
||||
from .models import ImageGenerationTask
|
||||
from .models import ImageGenerationTask, ImageGenerationTaskInput
|
||||
|
||||
|
||||
IDEMPOTENCY_KEY_MAX_LENGTH = 128
|
||||
@@ -61,6 +61,7 @@ def create_image_generation_task(
|
||||
image_url=str(request_data.get("image_url") or ""),
|
||||
image_base64=str(request_data.get("image_base64") or ""),
|
||||
aspect_ratio=request_data.get("aspect_ratio") or "1:1",
|
||||
images=tuple(dict(item) for item in request_data.get("images") or ()),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -85,12 +86,16 @@ def create_image_generation_task(
|
||||
request_hash=request_hash,
|
||||
request_payload=task_request_payload(
|
||||
request_data=request_data,
|
||||
image_input=prepared.image_input,
|
||||
image_inputs=prepared.image_inputs,
|
||||
),
|
||||
points_balance_after_charge=precharged.points_balance_after_charge,
|
||||
expires_at=now + timedelta(hours=image_task_retention_hours()),
|
||||
)
|
||||
store_task_input_image(task, prepared.image_input)
|
||||
store_task_input_images(
|
||||
task,
|
||||
image_inputs=prepared.image_inputs,
|
||||
request_data=request_data,
|
||||
)
|
||||
return task, True
|
||||
except IntegrityError:
|
||||
if idempotency_key_hash:
|
||||
@@ -137,64 +142,111 @@ def normalize_idempotency_key(value: str) -> str:
|
||||
|
||||
|
||||
def request_hash_for_image_request(request_data: Mapping[str, Any]) -> str:
|
||||
image_base64 = str(request_data.get("image_base64") or "")
|
||||
payload = {
|
||||
"prompt": str(request_data.get("prompt") or ""),
|
||||
"model": str(request_data.get("model") or ""),
|
||||
"resolution": str(request_data.get("resolution") or "1K"),
|
||||
"aspect_ratio": str(request_data.get("aspect_ratio") or "1:1"),
|
||||
"parameters": dict(request_data.get("parameters") or {}),
|
||||
"image_url": str(request_data.get("image_url") or ""),
|
||||
"image_base64_sha256": hash_text(image_base64) if image_base64 else "",
|
||||
}
|
||||
image_items = request_data.get("images") or ()
|
||||
if image_items:
|
||||
payload["images"] = image_request_input_hash_data(request_data)
|
||||
else:
|
||||
image_base64 = str(request_data.get("image_base64") or "")
|
||||
payload["image_url"] = str(request_data.get("image_url") or "")
|
||||
payload["image_base64_sha256"] = hash_text(image_base64) if image_base64 else ""
|
||||
return hash_json(payload)
|
||||
|
||||
|
||||
def task_request_payload(
|
||||
*,
|
||||
request_data: Mapping[str, Any],
|
||||
image_input: ImageInput | None,
|
||||
image_inputs: tuple[ImageInput, ...],
|
||||
) -> dict[str, Any]:
|
||||
image_source = "none"
|
||||
if request_data.get("image_base64"):
|
||||
image_source = "base64_stored"
|
||||
elif request_data.get("image_url"):
|
||||
image_source = "url_stored"
|
||||
|
||||
payload = {
|
||||
return {
|
||||
"prompt": str(request_data.get("prompt") or ""),
|
||||
"model": str(request_data.get("model") or ""),
|
||||
"resolution": str(request_data.get("resolution") or "1K"),
|
||||
"aspect_ratio": str(request_data.get("aspect_ratio") or "1:1"),
|
||||
"parameters": dict(request_data.get("parameters") or {}),
|
||||
"image_url": str(request_data.get("image_url") or ""),
|
||||
"image_input": None,
|
||||
"image_inputs": [
|
||||
{
|
||||
"source": source,
|
||||
"mime_type": image_input.mime_type,
|
||||
"filename": image_input.filename,
|
||||
"storage_path": "",
|
||||
}
|
||||
for image_input, source in zip(
|
||||
image_inputs,
|
||||
image_request_input_sources(request_data),
|
||||
strict=True,
|
||||
)
|
||||
],
|
||||
}
|
||||
if image_input is not None:
|
||||
payload["image_input"] = {
|
||||
"source": image_source,
|
||||
"mime_type": image_input.mime_type,
|
||||
"filename": image_input.filename,
|
||||
"storage_path": "",
|
||||
|
||||
|
||||
def image_request_input_hash_data(request_data: Mapping[str, Any]) -> list[dict[str, str]]:
|
||||
items = request_data.get("images") or ()
|
||||
if items:
|
||||
return [
|
||||
{
|
||||
"image_url": str(item.get("image_url") or ""),
|
||||
"image_base64_sha256": hash_text(str(item.get("image_base64") or ""))
|
||||
if item.get("image_base64")
|
||||
else "",
|
||||
}
|
||||
for item in items
|
||||
]
|
||||
image_base64 = str(request_data.get("image_base64") or "")
|
||||
return [
|
||||
{
|
||||
"image_url": str(request_data.get("image_url") or ""),
|
||||
"image_base64_sha256": hash_text(image_base64) if image_base64 else "",
|
||||
}
|
||||
return payload
|
||||
]
|
||||
|
||||
|
||||
def store_task_input_image(
|
||||
def image_request_input_sources(request_data: Mapping[str, Any]) -> tuple[str, ...]:
|
||||
items = request_data.get("images") or ()
|
||||
if items:
|
||||
return tuple(
|
||||
"base64_stored" if item.get("image_base64") else "url_stored"
|
||||
for item in items
|
||||
)
|
||||
if request_data.get("image_base64"):
|
||||
return ("base64_stored",)
|
||||
if request_data.get("image_url"):
|
||||
return ("url_stored",)
|
||||
return ()
|
||||
|
||||
|
||||
def store_task_input_images(
|
||||
task: ImageGenerationTask,
|
||||
image_input: ImageInput | None,
|
||||
*,
|
||||
image_inputs: tuple[ImageInput, ...],
|
||||
request_data: Mapping[str, Any],
|
||||
) -> None:
|
||||
if image_input is None:
|
||||
if not image_inputs:
|
||||
return
|
||||
task.input_image.save(
|
||||
image_input.filename,
|
||||
ContentFile(image_input.data),
|
||||
save=True,
|
||||
)
|
||||
|
||||
payload = dict(task.request_payload or {})
|
||||
image_info = dict(payload.get("image_input") or {})
|
||||
image_info["storage_path"] = task.input_image.name
|
||||
payload["image_input"] = image_info
|
||||
image_metadata = list(payload.get("image_inputs") or [])
|
||||
for ordinal, image_input in enumerate(image_inputs):
|
||||
task_input = ImageGenerationTaskInput(
|
||||
task=task,
|
||||
ordinal=ordinal,
|
||||
mime_type=image_input.mime_type,
|
||||
filename=image_input.filename,
|
||||
)
|
||||
task_input.image.save(
|
||||
image_input.filename,
|
||||
ContentFile(image_input.data),
|
||||
save=False,
|
||||
)
|
||||
task_input.save()
|
||||
image_metadata[ordinal]["storage_path"] = task_input.image.name
|
||||
payload["image_inputs"] = image_metadata
|
||||
task.request_payload = payload
|
||||
task.save(update_fields=("request_payload", "updated_at"))
|
||||
|
||||
@@ -332,9 +384,13 @@ def precharged_generation_for_task(task: ImageGenerationTask) -> PrechargedGener
|
||||
aspect_ratio=str(payload.get("aspect_ratio") or "1:1"),
|
||||
)
|
||||
)
|
||||
image_input = stored_image_input_for_task(task)
|
||||
if image_input is not None:
|
||||
prepared = replace(prepared, image_input=image_input)
|
||||
image_inputs = stored_image_inputs_for_task(task)
|
||||
if image_inputs:
|
||||
prepared = replace(
|
||||
prepared,
|
||||
image_input=image_inputs[0],
|
||||
image_inputs=image_inputs,
|
||||
)
|
||||
|
||||
return PrechargedGeneration(
|
||||
prepared=prepared,
|
||||
@@ -344,7 +400,28 @@ def precharged_generation_for_task(task: ImageGenerationTask) -> PrechargedGener
|
||||
)
|
||||
|
||||
|
||||
def stored_image_input_for_task(task: ImageGenerationTask) -> ImageInput | None:
|
||||
def stored_image_inputs_for_task(task: ImageGenerationTask) -> tuple[ImageInput, ...]:
|
||||
task_inputs = list(task.input_images.all())
|
||||
if task_inputs:
|
||||
return tuple(
|
||||
ImageInput(
|
||||
data=read_task_input_image(task_input),
|
||||
mime_type=task_input.mime_type,
|
||||
filename=task_input.filename,
|
||||
)
|
||||
for task_input in task_inputs
|
||||
)
|
||||
|
||||
legacy_input = stored_legacy_image_input_for_task(task)
|
||||
return (legacy_input,) if legacy_input is not None else ()
|
||||
|
||||
|
||||
def read_task_input_image(task_input: ImageGenerationTaskInput) -> bytes:
|
||||
with task_input.image.open("rb") as image_file:
|
||||
return image_file.read()
|
||||
|
||||
|
||||
def stored_legacy_image_input_for_task(task: ImageGenerationTask) -> ImageInput | None:
|
||||
if not task.input_image:
|
||||
return None
|
||||
payload = dict(task.request_payload or {})
|
||||
@@ -589,6 +666,7 @@ def refund_task_call(task: ImageGenerationTask, error_message: str) -> None:
|
||||
def refresh_task(task: ImageGenerationTask) -> ImageGenerationTask:
|
||||
return (
|
||||
ImageGenerationTask.objects.select_related("user", "api_key", "call_record")
|
||||
.prefetch_related("input_images")
|
||||
.get(pk=task.pk)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-17 07:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0002_imagegenerationtask_next_attempt_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ImageGenerationTaskInput',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ordinal', models.PositiveSmallIntegerField(verbose_name='输入顺序')),
|
||||
('image', models.FileField(upload_to='generated/task_inputs/%Y/%m/%d/', verbose_name='输入图片')),
|
||||
('mime_type', models.CharField(max_length=100, verbose_name='MIME 类型')),
|
||||
('filename', models.CharField(max_length=255, verbose_name='原始文件名')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='input_images', to='api.imagegenerationtask', verbose_name='图片生成任务')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '图片生成任务输入',
|
||||
'verbose_name_plural': '图片生成任务输入',
|
||||
'db_table': 'image_generation_task_input',
|
||||
'ordering': ('ordinal', 'id'),
|
||||
'constraints': [models.UniqueConstraint(fields=('task', 'ordinal'), name='unique_image_task_input_ordinal')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -98,3 +98,35 @@ class ImageGenerationTask(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.task_id} {self.status}"
|
||||
|
||||
|
||||
class ImageGenerationTaskInput(models.Model):
|
||||
task = models.ForeignKey(
|
||||
ImageGenerationTask,
|
||||
verbose_name="图片生成任务",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="input_images",
|
||||
)
|
||||
ordinal = models.PositiveSmallIntegerField("输入顺序")
|
||||
image = models.FileField(
|
||||
"输入图片",
|
||||
upload_to="generated/task_inputs/%Y/%m/%d/",
|
||||
)
|
||||
mime_type = models.CharField("MIME 类型", max_length=100)
|
||||
filename = models.CharField("原始文件名", max_length=255)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "image_generation_task_input"
|
||||
verbose_name = "图片生成任务输入"
|
||||
verbose_name_plural = "图片生成任务输入"
|
||||
ordering = ("ordinal", "id")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("task", "ordinal"),
|
||||
name="unique_image_task_input_ordinal",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.task.task_id} #{self.ordinal + 1}"
|
||||
|
||||
+27
-1
@@ -35,6 +35,7 @@ class GenerateImageRequestSerializer(serializers.Serializer):
|
||||
)
|
||||
image_url = serializers.URLField(required=False, allow_blank=True)
|
||||
image_base64 = serializers.CharField(required=False, allow_blank=True)
|
||||
images = serializers.ListField(required=False, write_only=True)
|
||||
resolution = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
@@ -49,8 +50,29 @@ class GenerateImageRequestSerializer(serializers.Serializer):
|
||||
)
|
||||
parameters = serializers.DictField(required=False, default=dict)
|
||||
|
||||
def validate_images(self, value):
|
||||
if not value:
|
||||
raise serializers.ValidationError("images 至少需要一张图片。")
|
||||
max_images = max(1, int(settings.IMAGE_MAX_INPUT_IMAGES))
|
||||
if len(value) > max_images:
|
||||
raise serializers.ValidationError(f"单次最多上传 {max_images} 张图片。")
|
||||
|
||||
class VisionImageInputSerializer(serializers.Serializer):
|
||||
serializer = ImageInputSerializer(data=value, many=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return serializer.validated_data
|
||||
|
||||
def validate(self, attrs):
|
||||
has_legacy_image = bool(str(attrs.get("image_url") or "").strip()) or bool(
|
||||
str(attrs.get("image_base64") or "").strip()
|
||||
)
|
||||
if attrs.get("images") is not None and has_legacy_image:
|
||||
raise serializers.ValidationError(
|
||||
"images 不能与 image_url 或 image_base64 同时提供。"
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class ImageInputSerializer(serializers.Serializer):
|
||||
image_url = serializers.URLField(required=False, allow_blank=True)
|
||||
image_base64 = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
@@ -64,6 +86,10 @@ class VisionImageInputSerializer(serializers.Serializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class VisionImageInputSerializer(ImageInputSerializer):
|
||||
pass
|
||||
|
||||
|
||||
class AnalyzeImagesRequestSerializer(serializers.Serializer):
|
||||
prompt = serializers.CharField(trim_whitespace=True, allow_blank=False)
|
||||
model = serializers.CharField(
|
||||
|
||||
+146
-1
@@ -36,7 +36,7 @@ from apps.api.image_tasks import (
|
||||
reap_stale_image_tasks,
|
||||
run_image_generation_task,
|
||||
)
|
||||
from apps.api.models import ImageGenerationTask
|
||||
from apps.api.models import ImageGenerationTask, ImageGenerationTaskInput
|
||||
from apps.api.throttles import GenerateRateThrottle
|
||||
from apps.api.views import ClientLatestReleaseView, ExternalApiView, ModelsView
|
||||
from apps.ai.models import AiModel, ModelAlias
|
||||
@@ -1672,6 +1672,112 @@ class GenerateApiTests(TestCase):
|
||||
self.assertEqual(call.result_summary, "image_bytes=21")
|
||||
self.assertNotIn("SECRET_RAW", call.result_ref + call.result_summary)
|
||||
|
||||
def test_generate_image_accepts_ordered_images_and_injects_role_rules(self):
|
||||
first = base64.b64encode(b"main-image").decode("ascii")
|
||||
second = base64.b64encode(b"reference-image").decode("ascii")
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "生成新的商品主图",
|
||||
"model": self.image_alias,
|
||||
"images": [
|
||||
{"image_base64": f"data:image/jpeg;base64,{first}"},
|
||||
{"image_base64": f"data:image/png;base64,{second}"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["points_cost"], 10)
|
||||
self.assertEqual(response.data["points_balance"], 90)
|
||||
provider_call = self.provider.image_calls[0]
|
||||
self.assertEqual(provider_call["image"], b"main-image")
|
||||
self.assertEqual(
|
||||
[image.data for image in provider_call["images"]],
|
||||
[b"main-image", b"reference-image"],
|
||||
)
|
||||
self.assertIn("第 1 张图片是主商品图", provider_call["prompt"])
|
||||
self.assertIn("第 2 张及之后的图片仅作为", provider_call["prompt"])
|
||||
self.assertIn("生成新的商品主图", provider_call["prompt"])
|
||||
|
||||
def test_generate_image_accepts_mixed_base64_and_url_images_in_order(self):
|
||||
encoded = base64.b64encode(b"main-image").decode("ascii")
|
||||
downloaded = FakeImageUrlResponse(
|
||||
headers={"Content-Type": "image/jpeg"},
|
||||
chunks=(b"reference-image",),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"apps.api.generation.socket.getaddrinfo",
|
||||
return_value=dns_result("93.184.216.34"),
|
||||
),
|
||||
patch(
|
||||
"apps.api.generation.requests.Session.get",
|
||||
return_value=downloaded,
|
||||
),
|
||||
):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "生成新的商品主图",
|
||||
"model": self.image_alias,
|
||||
"images": [
|
||||
{"image_base64": f"data:image/png;base64,{encoded}"},
|
||||
{"image_url": "https://images.example.test/reference.jpg"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
provider_call = self.provider.image_calls[0]
|
||||
self.assertEqual(
|
||||
[image.data for image in provider_call["images"]],
|
||||
[b"main-image", b"reference-image"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[image.mime_type for image in provider_call["images"]],
|
||||
["image/png", "image/jpeg"],
|
||||
)
|
||||
|
||||
def test_generate_image_rejects_mixed_legacy_and_images_inputs_without_charge(self):
|
||||
encoded = base64.b64encode(b"main-image").decode("ascii")
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "生成图片",
|
||||
"model": self.image_alias,
|
||||
"image_base64": f"data:image/png;base64,{encoded}",
|
||||
"images": [{"image_base64": f"data:image/png;base64,{encoded}"}],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(self.provider.image_calls, [])
|
||||
self.assert_generation_not_charged()
|
||||
|
||||
@override_settings(IMAGE_MAX_INPUT_IMAGES=1)
|
||||
def test_generate_image_rejects_too_many_input_images_without_charge(self):
|
||||
encoded = base64.b64encode(b"input-image").decode("ascii")
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "生成图片",
|
||||
"model": self.image_alias,
|
||||
"images": [
|
||||
{"image_base64": encoded},
|
||||
{"image_base64": encoded},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(self.provider.image_calls, [])
|
||||
self.assert_generation_not_charged()
|
||||
|
||||
def test_sync_image_usage_telemetry_logs_safe_client_version_and_key_identity(self):
|
||||
encoded = base64.b64encode(b"input-image").decode("ascii")
|
||||
payload = {
|
||||
@@ -1883,6 +1989,45 @@ class GenerateApiTests(TestCase):
|
||||
self.assertEqual(poll.data["result"]["image_url"], task.result_url)
|
||||
self.assertEqual(repeat.data["result"]["image_url"], task.result_url)
|
||||
|
||||
@override_settings(MEDIA_PUBLIC_BASE_URL="https://cm.example.test")
|
||||
def test_async_image_task_stores_and_restores_ordered_inputs(self):
|
||||
first = base64.b64encode(b"main-image").decode("ascii")
|
||||
second = base64.b64encode(b"reference-image").decode("ascii")
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image/tasks",
|
||||
{
|
||||
"prompt": "生成新的商品主图",
|
||||
"model": self.image_alias,
|
||||
"images": [
|
||||
{"image_base64": f"data:image/jpeg;base64,{first}"},
|
||||
{"image_base64": f"data:image/png;base64,{second}"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
task = ImageGenerationTask.objects.get(task_id=response.data["task_id"])
|
||||
stored_inputs = list(task.input_images.order_by("ordinal"))
|
||||
self.assertEqual(len(stored_inputs), 2)
|
||||
self.assertEqual([item.ordinal for item in stored_inputs], [0, 1])
|
||||
self.assertFalse(bool(task.input_image))
|
||||
self.assertFalse(ImageGenerationTaskInput.objects.filter(task=task, image__isnull=True).exists())
|
||||
serialized = json.dumps(task.request_payload, ensure_ascii=False)
|
||||
self.assertNotIn(first, serialized)
|
||||
self.assertNotIn(second, serialized)
|
||||
|
||||
with patch("apps.api.generation.get_provider", return_value=self.provider):
|
||||
claimed = claim_next_image_task("worker-multi")
|
||||
completed = run_image_generation_task(claimed, worker_id="worker-multi")
|
||||
|
||||
self.assertEqual(completed.status, ImageGenerationTask.Status.SUCCEEDED)
|
||||
provider_call = self.provider.image_calls[0]
|
||||
self.assertEqual(
|
||||
[image.data for image in provider_call["images"]],
|
||||
[b"main-image", b"reference-image"],
|
||||
)
|
||||
self.assertIn("第 1 张图片是主商品图", provider_call["prompt"])
|
||||
|
||||
def test_async_image_poll_rejects_cross_user_access(self):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image/tasks",
|
||||
|
||||
@@ -117,6 +117,9 @@ IMAGE_URL_MAX_BYTES = env_int("IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024)
|
||||
IMAGE_URL_MAX_REDIRECTS = env_int("IMAGE_URL_MAX_REDIRECTS", 3)
|
||||
IMAGE_URL_CONNECT_TIMEOUT_SECONDS = env_int("IMAGE_URL_CONNECT_TIMEOUT_SECONDS", 10)
|
||||
IMAGE_URL_READ_TIMEOUT_SECONDS = env_int("IMAGE_URL_READ_TIMEOUT_SECONDS", 60)
|
||||
IMAGE_MAX_INPUT_IMAGES = env_int("IMAGE_MAX_INPUT_IMAGES", 8)
|
||||
IMAGE_MAX_INPUT_IMAGE_BYTES = env_int("IMAGE_MAX_INPUT_IMAGE_BYTES", 10 * 1024 * 1024)
|
||||
IMAGE_MAX_INPUT_TOTAL_BYTES = env_int("IMAGE_MAX_INPUT_TOTAL_BYTES", 32 * 1024 * 1024)
|
||||
VISION_MAX_IMAGES = env_int("VISION_MAX_IMAGES", 8)
|
||||
VISION_MAX_IMAGE_BYTES = env_int("VISION_MAX_IMAGE_BYTES", 10 * 1024 * 1024)
|
||||
VISION_MAX_TOTAL_BYTES = env_int("VISION_MAX_TOTAL_BYTES", 32 * 1024 * 1024)
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送 100 点试用点数」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」、T-612「生图同步接口止血」、T-613「抽生成核心 service」、T-614「生图异步任务化接口」与 T-615「旧同步生图接口遥测 / 弃用口径」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615 并完成:T-612 已做同步接口上游硬截止止血,T-613 已抽共享生成 core,T-614 已新增异步提交 / 轮询接口与 DB worker,T-615 已给旧同步 / 新异步提交路径接入结构化日志并形成旧同步接口退出条件。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
|
||||
|
||||
T-616~T-619 也已完成:生图失败自动重试、客户端版本文件校验元数据、多图理解 `vision` 操作与同步接口均已落地。生产启用多图理解前仍需在 admin 配置视觉模型、默认别名和计费规则。T-620 已登记为下一项:扩展同步 / 异步图生图接口接收单图或多图,并约定第一张为主图、后续为参考图。
|
||||
T-616~T-620 也已完成:生图失败自动重试、客户端版本文件校验元数据、多图理解 `vision` 操作与同步接口,以及单图 / 多图图生图均已落地。T-620 保持旧单图字段兼容,并固定第一张为主图、后续为参考图;生产启用多图理解前仍需在 admin 配置视觉模型、默认别名和计费规则。
|
||||
|
||||
优先路径:
|
||||
|
||||
@@ -50,7 +50,7 @@ T-616~T-619 也已完成:生图失败自动重试、客户端版本文件校
|
||||
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。
|
||||
5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成。
|
||||
6. Phase 5:后台与发布 —— T-401 运营后台完善、T-402 完整验收 MVP、T-403 部署 / 运行文档已完成;计划内 MVP 任务已收尾。
|
||||
7. Phase 6:增强(MVP 后)—— T-601~T-619 已完成,覆盖可用别名发现、后台中文化、敏感词过滤、免邮箱验证、公开首页与客户端发布、注册赠点、用户端品牌、生图异步化 / 遥测 / 自动重试、客户端发布校验元数据和同步多图理解;T-620「图生图支持单图 / 多图主图与参考图」已登记为下一项 `TODO`。
|
||||
7. Phase 6:增强(MVP 后)—— T-601~T-620 已完成,覆盖可用别名发现、后台中文化、敏感词过滤、免邮箱验证、公开首页与客户端发布、注册赠点、用户端品牌、生图异步化 / 遥测 / 自动重试、客户端发布校验元数据、同步多图理解和图生图多输入主图 / 参考图语义。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
| 扫码充值 | 用户端发起充值→展示支付二维码→扫码付款→回调到账;金额按汇率转点数 | P0 |
|
||||
| 个人中心 | 查看剩余点数、充值总额、充值记录、点数使用(消费)记录 | P0 |
|
||||
| 生成标题 API | 带 API Key + prompt(+可选商品图)调用,拿到标题文字 | P0 |
|
||||
| 生成图片 API | 带 API Key + prompt + 图 + 模型/分辨率调用;旧同步接口可直接返回图片 URL,新版客户端可用异步提交 / 轮询拿结果 | P0 |
|
||||
| 生成图片 API | 带 API Key + prompt + 一张或多张图 + 模型/分辨率调用;第一张为主商品图、后续图片为参考图;旧同步接口可直接返回图片 URL,新版客户端可用异步提交 / 轮询拿结果 | P0 |
|
||||
| 多图理解 API | 带 API Key + prompt + 一张或多张图片调用,按图片顺序理解、比较或提取信息并返回完整文字 | P1 |
|
||||
| 点数计费与扣减 | 按「操作类型+能力别名+分辨率」算点数,余额足够则扣点放行,不足则报错 | P0 |
|
||||
| 余额查询 API | 查询当前用户点数余额 | P0 |
|
||||
@@ -57,7 +57,7 @@
|
||||
1. 作为新用户,我在用户端自助注册登录后先获得 100 点试用点数;后续扫码充值一笔钱,看到对应点数按汇率到账。
|
||||
2. 作为已登录用户,我自助生成一把 API Key(明文只显示一次),也能删除不用的 Key。
|
||||
3. 作为接入方,我带着 API Key 调用生成标题接口,传入 prompt,能拿到生成的标题。
|
||||
4. 作为接入方,我可以调用旧同步生成图片接口在同一个响应里拿到图片结果;也可以调用异步图片任务接口先拿到 `task_id`,再轮询直到成功或失败。
|
||||
4. 作为接入方,我可以提交一张或多张图片生成商品图:第一张作为主商品图,后续图片仅作风格、构图、场景或排版参考;可调用旧同步接口在同一个响应里拿到图片结果,也可先提交异步图片任务拿到 `task_id` 再轮询直到成功或失败。
|
||||
5. 作为接入方,我可以提交一张或多张商品图,让支持视觉理解的模型按输入顺序分析并返回文字;一次请求只按对应能力别名扣一次点数。
|
||||
6. 当我的点数不足以支付本次调用时,系统拒绝调用并返回「点数不足,请先充值」,且不调用上游、不扣点。
|
||||
7. 作为已登录用户,我在个人中心看到剩余点数、充值总额、充值记录和消费(调用)记录。
|
||||
@@ -68,7 +68,7 @@
|
||||
每条 P0 功能对应可验证的判据:
|
||||
|
||||
- **生成标题 API**:携带有效 API Key + 合法 prompt 调用,返回 200 且响应含标题文本;无效/缺失 Key 返回 401。
|
||||
- **生成图片 API**:旧同步接口携带有效 Key 调用,在超时上限内返回 200 且含图片 URL;异步接口提交后返回 `task_id`,轮询成功后返回稳定图片 URL。上游失败、超时或异步任务僵死时返回明确错误码,且**不最终扣点**(已扣则冲正)。
|
||||
- **生成图片 API**:同步与异步接口均可兼容旧单图字段,或使用 1–8 张有序图片;第一张为主商品图,后续图仅作参考。接口在超时上限内返回图片 URL / `task_id`;输入无效、超限、命中敏感词或 URL 不安全时不扣点。上游失败、超时或异步任务僵死时返回明确错误码,且**不最终扣点**(已扣则冲正)。
|
||||
- **多图理解 API**:携带有效 Key、合法 prompt 和 1–8 张图片调用,返回 200 且响应含完整文字;图片顺序保持不变;输入无效、超限、命中敏感词或 URL 不安全时不扣点,上游失败时已扣点数只退一次。
|
||||
- **点数计费与扣减**:调用前按规则算出点数 N;余额 ≥ N 才放行并精确扣 N;余额 < N 返回点数不足错误码且不调上游。并发同时多次调用,最终扣点总额正确,**不出现超扣或扣成负数**。
|
||||
- **余额查询 API**:返回的余额等于该账号点数流水累加结果。
|
||||
|
||||
+16
-4
@@ -39,11 +39,11 @@
|
||||
|
||||
T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 `Authorization: Bearer <API_KEY>`,通过 SHA-256 hash 定位 `ApiKey -> User`,成功后 `request.user` 为所属用户、`request.auth` 为本次 API Key;缺失/无效 Key 返回 `401 unauthorized`,用户或 Key 禁用返回 `403 account_disabled`。生成/余额等外部 API 应继承 `ExternalApiView`,不要挂 `SessionAuthentication`。
|
||||
|
||||
T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。T-613 已把生成链路抽为 `apps.api.generation` 的核心阶段:`prepare_generation()` 负责审核、图片输入、别名、Provider 与计费准备;`precharge_generation()` 只调用 billing 预扣;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游并成功确认或失败退点,供旧同步接口和后续异步 worker 共用。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;核心阶段通过 URL 构建器生成外部 URL,不依赖 DRF `Request`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。
|
||||
T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。T-613 已把生成链路抽为 `apps.api.generation` 的核心阶段:`prepare_generation()` 负责审核、图片输入、别名、Provider 与计费准备;`precharge_generation()` 只调用 billing 预扣;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游并成功确认或失败退点,供旧同步接口和后续异步 worker 共用。T-620 将图生图输入扩展为有序集合:兼容旧单图字段,新 `images[0]` 为主商品图、`images[1:]` 为参考图;服务端在审核用户 prompt 后固定追加角色规则,再将完整顺序交给 Provider。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;核心阶段通过 URL 构建器生成外部 URL,不依赖 DRF `Request`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。
|
||||
|
||||
T-619 已在同一生成核心增加 `vision` 分支和 `/api/v1/analyze/images`:API 接收有序的 `images[]`,每项二选一提供 `image_url` / `image_base64`;prompt 审核通过后才下载或解码图片,随后校验同时具备 `text + vision` 的模型与 Provider、按 `vision + alias` 默认价格预扣一次、同步调用 Chat Completions / Gemini 多模态接口并返回完整文字。输入图片只在请求内存中使用,不落库;`CallRecord` 只保存最多 500 字结果摘要。
|
||||
|
||||
T-614 已实现 `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}`:提交接口同步审核 prompt、解析图片输入和预扣点,创建 `ImageGenerationTask(status=queued)` 后立即返回公开 UUID `task_id`;后台 worker 通过 `select_for_update(skip_locked)` 抢任务,复用 T-613 `execute_precharged_generation()` 对已预扣 `CallRecord` 调上游、保存结果、成功确认或失败退点。任务表记录 worker 租约、心跳和尝试次数;reaper 识别僵尸 `running` 任务后默认判失败并幂等退点,不默认重排队。异步 worker 没有 DRF request,结果 URL 由 `MEDIA_PUBLIC_BASE_URL` / `PUBLIC_BASE_URL` 生成。当前实现中 worker 会基于任务快照再次运行 `prepare_generation()`,因此会复审 prompt、重解析别名 / Provider / 定价;账务仍使用已预扣 `CallRecord.points_cost`,不会重复扣点。这个取舍偏安全(排队期间敏感词库更新后仍能拦截并退款),但如果未来队列积压明显,应单独实现“提交时模型配置快照”,避免执行时别名映射变化导致按旧价预扣、按新模型执行。
|
||||
T-614 已实现 `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}`:提交接口同步审核 prompt、解析图片输入和预扣点,创建 `ImageGenerationTask(status=queued)` 后立即返回公开 UUID `task_id`;后台 worker 通过 `select_for_update(skip_locked)` 抢任务,复用 T-613 `execute_precharged_generation()` 对已预扣 `CallRecord` 调上游、保存结果、成功确认或失败退点。T-620 新增 `ImageGenerationTaskInput` 子表,按序号保存多张任务输入文件和 MIME / 文件名;旧 `input_image` 仍只为历史单图任务读取兼容。任务表记录 worker 租约、心跳和尝试次数;reaper 识别僵尸 `running` 任务后默认判失败并幂等退点,不默认重排队。异步 worker 没有 DRF request,结果 URL 由 `MEDIA_PUBLIC_BASE_URL` / `PUBLIC_BASE_URL` 生成。当前实现中 worker 会基于任务快照再次运行 `prepare_generation()`,因此会复审 prompt、重解析别名 / Provider / 定价;账务仍使用已预扣 `CallRecord.points_cost`,不会重复扣点。这个取舍偏安全(排队期间敏感词库更新后仍能拦截并退款),但如果未来队列积压明显,应单独实现“提交时模型配置快照”,避免执行时别名映射变化导致按旧价预扣、按新模型执行。
|
||||
|
||||
T-616 起异步生图 worker 对临时性上游失败增加自动重试:`upstream_timeout` / `upstream_error` 在未达到最大次数前回到 `queued` 并设置 `next_attempt_at`,`CallRecord` 保持 `pending` 且不退点;不可重试错误或最后一次失败才置 `failed` 并幂等退款。默认 `IMAGE_TASK_MAX_RETRIES=2`,因此 `attempt_count` 最多为 3。
|
||||
|
||||
@@ -59,7 +59,7 @@ T-504 已实现用户端 `/recharge` 页面:GET 展示当前余额、充值表
|
||||
|
||||
T-306 已实现对外 API 安全加固:`download_image_input()` 在请求前校验 `image_url` 协议与解析后的 IP,只允许公网 `http` / `https`,拒绝私有、回环、链路本地、保留、组播、未指定地址;重定向由服务端手动跟随并逐跳重新校验,响应按 `IMAGE_URL_MAX_BYTES` 流式限长读取。`REST_FRAMEWORK` 全局默认认证为空、默认权限为 `IsAuthenticated`,外部 API 和用户端 session API 必须显式声明认证类;生成接口挂 `GenerateRateThrottle`,认证失败挂 IP 限流;充值下单通过 `RECHARGE_MAX_AMOUNT_CNY` 控制单笔上限。T-607/T-609/T-617 版本检查接口已显式使用 `AllowAny` / 空认证,且只返回公开发布元数据、强制更新标记和文件大小字节数。
|
||||
|
||||
T-619 多图理解继续复用上述 URL 下载器和逐跳 SSRF 校验,并额外用 `VISION_MAX_IMAGES`、`VISION_MAX_IMAGE_BYTES`、`VISION_MAX_TOTAL_BYTES` 控制图片数量、单图解码后大小和请求总大小。第一版只审核文字 prompt,不包含图片内容审核。
|
||||
T-619 多图理解继续复用上述 URL 下载器和逐跳 SSRF 校验,并额外用 `VISION_MAX_IMAGES`、`VISION_MAX_IMAGE_BYTES`、`VISION_MAX_TOTAL_BYTES` 控制图片数量、单图解码后大小和请求总大小。T-620 图生图同样复用 URL 校验,并用 `IMAGE_MAX_INPUT_IMAGES`、`IMAGE_MAX_INPUT_IMAGE_BYTES`、`IMAGE_MAX_INPUT_TOTAL_BYTES` 控制输入;新旧输入字段不可混用,校验、审核和图片下载 / 解码均在预扣前完成。第一版只审核文字 prompt,不包含图片内容审核。
|
||||
|
||||
**计费层(`apps/billing`)**
|
||||
|
||||
@@ -338,7 +338,7 @@ CREATE TABLE image_generation_task (
|
||||
idempotency_key_hash CHAR(64), -- api_key + hash 唯一;NULL 允许无幂等键任务重复存在
|
||||
request_hash CHAR(64) NOT NULL,
|
||||
request_payload JSON NOT NULL, -- prompt/model/resolution/parameters/输入文件引用,不存 base64 原文
|
||||
input_image VARCHAR(100) NOT NULL DEFAULT '',
|
||||
input_image VARCHAR(100) NOT NULL DEFAULT '', -- 历史单图任务兼容读取
|
||||
result_url TEXT NOT NULL DEFAULT '',
|
||||
error_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
@@ -356,6 +356,18 @@ CREATE TABLE image_generation_task (
|
||||
updated_at DATETIME(6) NOT NULL,
|
||||
UNIQUE(api_key_id, idempotency_key_hash)
|
||||
);
|
||||
|
||||
-- 异步任务的有序输入图片(T-620)
|
||||
CREATE TABLE image_generation_task_input (
|
||||
id INTEGER PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL REFERENCES image_generation_task(id),
|
||||
ordinal SMALLINT UNSIGNED NOT NULL,
|
||||
image VARCHAR(100) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL DEFAULT 'image/png',
|
||||
filename VARCHAR(255) NOT NULL DEFAULT 'image.png',
|
||||
created_at DATETIME(6) NOT NULL,
|
||||
UNIQUE (task_id, ordinal)
|
||||
);
|
||||
```
|
||||
|
||||
需要说明:
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@
|
||||
| T-617 | 桌面端版本检查接口增加文件大小字段 | T-607, T-609 | 在 `GET /api/v1/client/releases/latest?platform=windows` 的当前版本响应中,为 `release` 对象新增 `size_bytes` 字段,用于桌面端下载后比对文件大小。**接口兼容**:只新增字段,不删除既有 `version` / `download_url` / `sha256` / `release_notes` / `force_update` / `published_at`;无当前版本或无下载地址时仍返回 `release:null`,不返回顶层 `size_bytes`。**数据来源**:`DownloadRelease` 新增 `size_bytes` 可空正整数字段,单位字节,django-admin 可填写并在列表展示;老数据为空时接口返回 `null`,客户端只在值为正整数时做大小校验。**安全**:接口仍公开匿名只读,不读取用户、不扣点、不暴露后台 ID、本地文件路径或内部状态。**文档**:同步 `api.md`、`routes.md`、`04-architecture.md`、`02-requirements.md`、`current-state.md`。**测试**:覆盖有值返回整数、未配置返回 `null`、未发布响应不返回顶层字段、响应字段白名单更新、admin 字段可见;`makemigrations --check` / `check` / 目标测试通过并在 `../progress.md` 留证据 | DONE |
|
||||
| T-618 | 客户端发布版本后台必填文件校验元数据 | T-617 | 把 django-admin 里 `DownloadRelease` 的 `sha256` 和 `size_bytes` 改为必填,避免运营发布客户端安装包时漏填校验元数据,导致桌面端无法完整校验下载文件。**范围**:本任务只要求 admin 后台保存时必填;为兼容历史数据和现有 API 合约,第一步不直接把数据库字段改成 `NOT NULL`,`size_bytes` 仍允许旧记录为空,API 暂保持可返回 `null`;若后续要数据库级强约束,需先单独回填全量历史发布记录再做迁移。**实现**:`DownloadReleaseAdmin` 已挂专用 `ModelForm`,将 `sha256`、`size_bytes` 设为 required,并保留 `sha256` 64 位十六进制校验、`size_bytes >= 1` 校验;admin 新增 / 编辑保存时未填会显示字段级错误,不写库。**生产数据**:上线后必须补齐当前 `windows` 发布版本的 `size_bytes`,再验证 `/api/v1/client/releases/latest?platform=windows` 返回正整数 `release.size_bytes`;已有 `sha256` 继续保留并核对。**兼容性**:不删除既有 API 字段,不改变无当前版本时的 `release:null`;非 admin 的历史数据、迁移和只读接口不应因空值直接 500。**文档**:同步 `api.md`、`04-architecture.md`、`routes.md`、`current-state.md` 和 `progress.md` 的发布校验口径。**测试**:已覆盖 admin 表单缺 `sha256` / 缺 `size_bytes` 拒绝保存、合法 `sha256 + size_bytes` 可保存、API 仍能读取历史空值记录、当前版本补齐后接口返回正整数;`check` / `makemigrations --check` / 目标测试通过并在 `../progress.md` 留证据 | DONE |
|
||||
| T-619 | 多张图片理解并返回文字 | T-613, T-601, T-306 | 新增独立的多模态图片理解能力,不复用“生成标题”语义。**操作与别名**:在 `ModelAlias.OperationType`、`CallRecord.OperationType` 和别名解析中新增 `vision`;建议首个数据库别名为 `vision-standard`,但不得在接口中暴露具体供应商模型名。`vision` 别名指向的 `AiModel` 及 Provider 必须同时具备 `vision` 与文字输出能力,图片生成 / 编辑专用 Provider 不得误接。**新增接口**:`POST /api/v1/analyze/images`,继承 `ExternalApiView`、使用 API Key 鉴权和生成限流,同步返回 `{text, alias, model_used, points_cost, points_balance, call_id}`;不改变 `/api/v1/generate/title`、`/api/v1/generate/image` 及异步生图接口。**请求结构**:`prompt` 必填,`model` 可选,`images` 为有序列表且至少 1 张;每项必须且只能提供一个 `image_url` 或 `image_base64`,允许 URL/base64 混合。新增 `VISION_MAX_IMAGES=8`、`VISION_MAX_IMAGE_BYTES=10485760`、`VISION_MAX_TOTAL_BYTES=33554432` 三个可配置上限,分别控制单次图片数量、单图解码后字节数和总字节数;超限、空列表、格式错误或同项双来源返回 `400 bad_request`,不扣点、不调上游。**安全时序**:serializer 后先按 T-604 审核 prompt,再下载 URL / 解码 base64;每个 URL 必须复用 T-306 的协议、公网地址、重定向逐跳校验、超时和响应大小保护,不另写弱化下载器。第一版只审核文字 prompt,不声称已做图片内容审核。**Provider**:复用 `apps/ai/providers` 现有 HTTP 适配层,增加向 Chat Completions / Gemini 发送多张图片并保序的兼容能力;不得复制第二套上游调用实现。现有单图标题 / 生图调用签名与行为保持兼容。**计费与留痕**:复用 T-613 共享生成 core 与 billing 的预扣 / 成功确认 / 幂等退点;第一版按 `operation_type=vision + alias` 的默认 `PricingRule` 对一次请求固定扣点,不按图片张数重复扣费,图片数量上限用于控制成本;上游失败必须退点。`CallRecord` 记录 `vision`、别名、实际模型、点数、耗时和结果摘要,不保存输入图片、base64、provider raw 或完整上游响应。**目录与运营配置**:`GET /api/v1/models` 和 portal 可用模型页按 T-601 既有语义列出 active、能力匹配的 `vision` 别名并显示 `requires_image=true`;有定价时返回 `priced`,缺定价时仍返回 `unpriced`,不得改变现有目录兼容行为。代码部署 / migrate 后由运营在 admin 配置支持视觉理解的 `AiModel`、`vision-standard` 默认别名和计费规则,不通过数据迁移写入真实上游配置或密钥。**范围边界**:本任务只做同步文字结果,不做流式输出、异步 vision task、OCR 专用接口、结构化 JSON schema、图片内容审核,也不为 OCR / 商品识别 / 图片对比各建别名;这些用途先由 prompt 表达,只有底层模型或价格确实不同时再增加别名。**文档**:实现时同步 `02-requirements.md`、`04-architecture.md`、`api.md`、`routes.md`、`env.md`、`current-state.md` 和 `progress.md`。**测试**:覆盖单图 / 多图 / 混合来源及顺序、默认 / 指定别名、模型与 Provider 能力拒绝、图片数量与大小限制、SSRF、敏感词先拦截、成功仅扣一次、上游失败仅退一次、调用记录不保存图片 / raw、模型目录安全字段、旧标题 / 生图接口回归;`makemigrations` / `migrate` / `check` / 目标测试 / `init` 通过并在 `../progress.md` 留证据 | DONE |
|
||||
| T-620 | 图生图支持单图 / 多图主图与参考图 | T-613, T-614, T-616, T-619 | 扩展现有同步与异步图生图接口,使调用方可以提交单张或多张输入图片,并把全部图片按原顺序与提示词一起发送给中转站。**接口兼容**:`POST /api/v1/generate/image` 与 `POST /api/v1/generate/image/tasks` 新增有序 `images` 列表;每项必须且只能提供 `image_url` 或 `image_base64`。旧的单个 `image_url` / `image_base64` 字段继续可用,单图旧请求的响应、状态码、计费和错误语义不变;新旧字段同时出现、空列表、空图片项或超过限制时返回 `400 bad_request`,不得静默选择其中一套输入。**图片角色**:服务端固定注入图生图规则:`images[0]` 是主商品图,必须优先保留其主体与关键细节;`images[1:]` 仅作为风格、构图、场景或排版参考,不得替换主图商品。客户端提示词不能关闭或覆盖该规则;用户原始 prompt 仍先做 T-604 敏感词审核,固定规则只作为发送给上游的有效 prompt 组成部分。**输入与安全**:按顺序加载所有图片,新增 `IMAGE_MAX_INPUT_IMAGES=8`、`IMAGE_MAX_INPUT_IMAGE_BYTES=10485760`、`IMAGE_MAX_INPUT_TOTAL_BYTES=33554432`(或明确复用等价的统一多图上限);每个 `image_url` 继续复用 T-306 的公网校验、逐跳重定向校验、超时和流式大小保护;输入校验、敏感词、SSRF、大小或别名/定价错误发生在预扣前,不扣点、不调上游。一次请求无论单图还是多图只按当前 `image` 别名 / 分辨率规则扣一次,上游失败沿用既有幂等退点。**Provider 传递**:`apps/ai/providers` 的生成接口增加有序图片集合能力并保留旧单图调用兼容;Chat Completions 按顺序发送多段图片内容;JSON 图片接口按中转站契约发送有序 `image_urls` / `images` 数组;`images_edits` multipart 按顺序重复发送 `image` 字段。不得只发送第一张,也不得把第二张及之后的图拼接成一张图片。实现必须使用服务端固定规则明确主图 / 参考图语义;真实中转站验收使用两张明显不同的图片,确认多图请求返回图片。**异步任务**:`ImageGenerationTask` 需支持多个有序输入文件;建议新增任务输入子表(任务、序号、文件、MIME / 文件名),保留旧单 `input_image` 记录的读取兼容,不把 base64 原文或 provider raw 存入数据库;幂等提交、worker 重试、租约回收、成功确认和失败退款逻辑不复制第二套。**响应与留痕**:成功响应仍返回一个 `image_url`、`alias`、`model_used`、`points_cost`、`points_balance`、`call_id`;`CallRecord` 只记录输入数量等非敏感摘要,不保存 base64、完整 prompt、输入图片内容或 provider raw。**文档**:实现时同步 `02-requirements.md`、`04-architecture.md`、`api.md`、`routes.md`、`env.md`、`deployment.md`、`current-state.md` 和 `progress.md`,明确 `images[0]` 主图、`images[1:]` 参考图及中转站字段映射。**测试**:覆盖旧单图字段、`images` 单图、多图和混合 URL/base64;断言图片顺序传入 Chat / JSON / multipart Provider,固定主图规则出现在上游 prompt;断言新旧字段混用、空列表、超限、SSRF、敏感词和无定价均不扣点;同步与异步成功各只扣一次,失败/重试/僵任务退款不重复;幂等提交与跨用户轮询不回归;输入文件不泄露原始 base64;使用真实中转站两张不同图片完成一次成功验收;`makemigrations` / `migrate` / `check` / 目标测试 / `init` 通过并在 `../progress.md` 留证据 | TODO |
|
||||
| T-620 | 图生图支持单图 / 多图主图与参考图 | T-613, T-614, T-616, T-619 | 已完成:同步 / 异步图生图支持有序 `images`,旧单图字段继续兼容且禁止混用;服务端固定注入首图主商品、后续参考图规则。新增 `IMAGE_MAX_INPUT_IMAGES` / `IMAGE_MAX_INPUT_IMAGE_BYTES` / `IMAGE_MAX_INPUT_TOTAL_BYTES`,图片 URL 同时受新单图限制与既有 `IMAGE_URL_MAX_BYTES` 的较小值保护。Provider 已验证 Chat 多段、JSON `image_urls` 与 `images_edits` 重复 multipart `image` 的顺序传递;异步任务新增 `ImageGenerationTaskInput` 有序输入表,旧 `input_image` 读取保持兼容。已通过 `check`、迁移一致性、13 条 Provider 测试、序列化器边界校验与 6 条目标 API 测试(同步多图 / URL+base64 混合 / 互斥输入 / 超限 / 异步恢复 / URL 上限);完整 `GenerateApiTests` 曾跑出 53 条且发现并修复 URL 上限回归,远程 MySQL 测试库后续整类重跑超过 5 分钟未完成。此前已使用真实中转站 `gpt-image-2` 对两张图片重复 multipart `image` 调用获得 HTTP 200 与图片结果,确认上游多文件传输契约。详见 `progress.md`。 | DONE |
|
||||
|
||||
## 里程碑
|
||||
|
||||
|
||||
+23
-4
@@ -21,7 +21,7 @@
|
||||
|
||||
T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer <API_KEY>`;生成、余额等外部 API 视图应继承 `apps.api.views.ExternalApiView`,不接受 Web session。
|
||||
|
||||
T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url`,后续可替换为对象存储。T-613 起内部实现已抽为生成核心 service,旧同步接口仍保持原字段、状态码和错误语义。T-614 起新增异步生图任务接口,提交阶段同步审核 prompt 和预扣点,worker 复用同一套已预扣执行 / 成功确认 / 失败退点阶段,不复制第二套资金逻辑。
|
||||
T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url`,后续可替换为对象存储。T-613 起内部实现已抽为生成核心 service,旧同步接口仍保持原字段、状态码和错误语义。T-614 起新增异步生图任务接口,提交阶段同步审核 prompt 和预扣点,worker 复用同一套已预扣执行 / 成功确认 / 失败退点阶段,不复制第二套资金逻辑。T-620 起两个图生图接口都支持有序 `images`,服务端固定约定第 1 张为主商品图、后续图为参考图,旧单图字段保持兼容。
|
||||
|
||||
T-619 已实现 `POST /api/v1/analyze/images`:使用独立 `vision` 操作和能力别名,同步接收一张或多张有序图片并返回完整文字。该接口复用 T-613 的准备 / 预扣 / 执行 / 成功确认 / 失败退点核心,不改变标题和生图接口。
|
||||
|
||||
@@ -228,7 +228,7 @@ X-Client-Version: 0.1.1
|
||||
{
|
||||
"prompt": "把这件衣服换成模特上身的穿搭图",
|
||||
"model": "image-hd", // 能力别名,非具体模型名
|
||||
"image_base64": "data:image/png;base64,...", // 或 image_url;改图类模型(如映射到 gpt-image-2)原图必传
|
||||
"image_base64": "data:image/png;base64,...", // 旧单图字段:或 image_url;不可和 images 同时传
|
||||
"resolution": "1K",
|
||||
"aspect_ratio": "1:1",
|
||||
"parameters": {} // 可选,安全供应商参数;未知字段/核心字段忽略
|
||||
@@ -248,7 +248,22 @@ X-Client-Version: 0.1.1
|
||||
}
|
||||
```
|
||||
|
||||
要点:改图类模型缺原图返回 `bad_request`,不落 500;上游超时返回 `upstream_timeout`,其他上游失败返回 `upstream_error`,两者都**不最终扣点**(已预扣则退回)。T-604 后 prompt 命中本地敏感词时返回 `content_blocked`,并且不得下载 `image_url` 或解码大图后再拦截。T-612 后生图上游读取超时会受 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止保护,避免旧同步接口长期占用生成池线程。结果默认存对象存储返回 `image_url`,避免同步响应体过大。请求里的 `image_url` 适用同标题接口相同的 SSRF 防护和大小上限;内网图片请用 `image_base64`。
|
||||
多图请求示例(不要与旧单图字段混用):
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "为主商品图生成新的电商展示图",
|
||||
"model": "image-hd",
|
||||
"images": [
|
||||
{"image_base64": "data:image/png;base64,..."},
|
||||
{"image_url": "https://cdn.example.com/style-reference.jpg"}
|
||||
],
|
||||
"resolution": "1K",
|
||||
"aspect_ratio": "1:1"
|
||||
}
|
||||
```
|
||||
|
||||
要点:`images` 必须为 1–`IMAGE_MAX_INPUT_IMAGES` 张的有序列表,每项必须且只能提供 `image_url` 或 `image_base64`。`images[0]` 是主商品图,`images[1:]` 只作为风格、构图、场景或排版参考;该规则由服务端追加,调用方 prompt 不能关闭或覆盖。改图类模型缺原图返回 `bad_request`,不落 500;新旧字段同时传、空列表或超限同样返回 `400 bad_request`。上游超时返回 `upstream_timeout`,其他上游失败返回 `upstream_error`,两者都**不最终扣点**(已预扣则退回)。T-604 后 prompt 命中本地敏感词时返回 `content_blocked`,并且不得下载 `image_url` 或解码大图后再拦截。T-612 后生图上游读取超时会受 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止保护,避免旧同步接口长期占用生成池线程。结果默认存对象存储返回 `image_url`,避免同步响应体过大。每个 `image_url` 都适用同标题接口相同的 SSRF 防护和大小上限;内网图片请用 `image_base64`。
|
||||
|
||||
### `POST /api/v1/generate/image/tasks`
|
||||
|
||||
@@ -268,7 +283,10 @@ Content-Type: application/json
|
||||
{
|
||||
"prompt": "把这件衣服换成模特上身的穿搭图",
|
||||
"model": "image-hd",
|
||||
"image_base64": "data:image/png;base64,...",
|
||||
"images": [
|
||||
{"image_base64": "data:image/png;base64,..."},
|
||||
{"image_url": "https://cdn.example.com/style-reference.jpg"}
|
||||
],
|
||||
"resolution": "1K",
|
||||
"aspect_ratio": "1:1",
|
||||
"parameters": {}
|
||||
@@ -297,6 +315,7 @@ Content-Type: application/json
|
||||
- 提交阶段同步执行 prompt 审核;命中敏感词返回 `400 content_blocked`,不建任务、不扣点、不调上游。
|
||||
- 提交阶段预扣点数;余额不足返回 `402 insufficient_points`,不建任务。
|
||||
- `Idempotency-Key` 按当前 API Key 去重。同 key + 同 payload 返回同一 `task_id`,不重复扣点;同 key + 不同 payload 返回 `409 idempotency_conflict`。
|
||||
- `images` 与旧单个 `image_url` / `image_base64` 字段互斥;新数组保持提交顺序,第 1 张为主图、后续图为参考图。异步任务按顺序保存输入文件,旧单图任务仍可读取其历史 `input_image`。
|
||||
- 不保存 provider `raw`、上游密钥或 `image_base64` 原文;base64 会解码后作为输入文件引用保存,任务请求快照只保存必要字段和文件引用。
|
||||
- 桌面端主链路推荐传 `image_base64`。该路径在 submit 阶段只做解码和输入文件落盘,不发生外部网络请求,提交请求应保持短耗时。
|
||||
- `image_url` 仍按同步接口的 SSRF 与大小规则处理,并在 submit 阶段下载成输入文件引用;失败不扣点。这个路径可能因远程图片下载变慢而让 submit 阻塞,适合作为边缘兼容能力,不建议桌面端批量生图主流程使用。
|
||||
|
||||
@@ -76,9 +76,9 @@
|
||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送 100 点试用点数;T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。
|
||||
- 已完成补充:T-619 多张图片理解并返回文字。
|
||||
- 正在进行:无。
|
||||
- 待开始:T-620 图生图支持单图 / 多图主图与参考图;仍按真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察继续拆任务。
|
||||
- 待开始:仍按真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察继续拆任务。
|
||||
- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。
|
||||
- 下一个可领取任务:T-620 图生图支持单图 / 多图主图与参考图;具体范围与验收以 [`06-tasks.md`](06-tasks.md) 为准。
|
||||
- 下一个可领取任务:任务看板暂无已登记 TODO;应先依据真实支付回调闭环、客户端发布观察或线上多图图生图验收补充下一项任务。具体范围以 [`06-tasks.md`](06-tasks.md) 为准。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
|
||||
+4
-1
@@ -25,7 +25,7 @@
|
||||
|
||||
- 使用系统 Python 3.12,不使用虚拟环境。
|
||||
- 图片生成已提供旧同步接口和新异步提交 / 轮询接口;真实图片生成耗时仍需在生产链路记录,并用于校准旧同步超时和异步 worker 容量。
|
||||
- T-619 多图理解为同步接口,必须分流到长请求池;请求体上限需覆盖 Base64 膨胀,默认 32 MiB 解码后总图片上限建议配置至少 50 MiB 的 Nginx `client_max_body_size`。
|
||||
- T-619 多图理解和 T-620 多图图生图均有 Base64 请求体;请求体上限需覆盖 Base64 膨胀,默认 32 MiB 解码后总图片上限建议配置至少 50 MiB 的 Nginx `client_max_body_size`。图生图提交多个 `image_url` 时,Web 请求会在预扣前等待每张图片下载完成。
|
||||
- 真实支付需微信 / 支付宝商户密钥、证书、生产 SDK 与公网回调地址;未配置前只能用 `PAYMENT_CALLBACK_MODE=mock`。
|
||||
|
||||
## 二、系统准备
|
||||
@@ -124,6 +124,9 @@ IMAGE_TASK_REAPER_INTERVAL_SECONDS=60
|
||||
IMAGE_TASK_LEASE_SECONDS=600
|
||||
IMAGE_TASK_MAX_RETRIES=2
|
||||
IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30
|
||||
IMAGE_MAX_INPUT_IMAGES=8
|
||||
IMAGE_MAX_INPUT_IMAGE_BYTES=10485760
|
||||
IMAGE_MAX_INPUT_TOTAL_BYTES=33554432
|
||||
|
||||
DJANGO_CACHE_BACKEND=django.core.cache.backends.db.DatabaseCache
|
||||
DJANGO_CACHE_LOCATION=cmhub_cache
|
||||
|
||||
+4
-1
@@ -79,6 +79,9 @@ T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和
|
||||
| `IMAGE_URL_MAX_REDIRECTS` | 否 | `3` | `image_url` 手动跟随重定向次数上限;每跳都会重新校验目标地址 |
|
||||
| `IMAGE_URL_CONNECT_TIMEOUT_SECONDS` | 否 | `10` | `image_url` 下载连接超时秒数 |
|
||||
| `IMAGE_URL_READ_TIMEOUT_SECONDS` | 否 | `60` | `image_url` 下载读取超时秒数 |
|
||||
| `IMAGE_MAX_INPUT_IMAGES` | 否 | `8` | 图生图接口单次最多接受的输入图片数量,最小按 1 处理 |
|
||||
| `IMAGE_MAX_INPUT_IMAGE_BYTES` | 否 | `10485760` | 图生图接口每张图片解码或下载后的最大字节数,默认 10 MiB |
|
||||
| `IMAGE_MAX_INPUT_TOTAL_BYTES` | 否 | `33554432` | 图生图接口单次所有图片的总字节数上限,默认 32 MiB |
|
||||
| `VISION_MAX_IMAGES` | 否 | `8` | 多图理解接口单次最多接受的图片数量,最小按 1 处理 |
|
||||
| `VISION_MAX_IMAGE_BYTES` | 否 | `10485760` | 多图理解接口每张图片解码或下载后的最大字节数,默认 10 MiB |
|
||||
| `VISION_MAX_TOTAL_BYTES` | 否 | `33554432` | 多图理解接口单次所有图片的总字节数上限,默认 32 MiB |
|
||||
@@ -86,7 +89,7 @@ T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和
|
||||
|
||||
`image_url` 只允许 `http` / `https`,服务端会在请求前解析域名,拒绝私有网段、回环、链路本地、保留地址、组播、未指定地址;重定向后的目标地址也会重复执行同样校验。内网图片不应通过 `image_url` 传入,调用方应改用 `image_base64`。
|
||||
|
||||
多图理解接口会先审核 prompt,再按 `images` 顺序下载或解码图片;单图同时受 `VISION_MAX_IMAGE_BYTES` 限制,URL 下载不会绕过现有 SSRF、重定向和超时检查。三个 `VISION_*` 限制应按上游模型输入上限与 Web worker 内存共同校准。
|
||||
图生图和多图理解接口都会先审核 prompt,再按 `images` 顺序下载或解码图片;图生图的 `images[0]` 是主商品图、`images[1:]` 是参考图。单图分别受 `IMAGE_MAX_INPUT_IMAGE_BYTES` / `VISION_MAX_IMAGE_BYTES` 限制,URL 下载不会绕过现有 SSRF、重定向和超时检查。两组 `*_MAX_*` 限制应按上游模型输入上限与 Web worker 内存共同校准。
|
||||
|
||||
## 六、内容安全 / 本地敏感词配置
|
||||
|
||||
|
||||
+4
-2
@@ -26,8 +26,8 @@ T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 `
|
||||
| --- | --- | --- | --- |
|
||||
| `/api/v1/generate/title` | POST | 生成标题 | API Key |
|
||||
| `/api/v1/analyze/images` | POST | 理解一张或多张图片并同步返回文字 | API Key |
|
||||
| `/api/v1/generate/image` | POST | 生成图片(同步) | API Key |
|
||||
| `/api/v1/generate/image/tasks` | POST | 提交异步图片生成任务,返回 `task_id` | API Key |
|
||||
| `/api/v1/generate/image` | POST | 生成图片(同步,支持单图 / 多图) | API Key |
|
||||
| `/api/v1/generate/image/tasks` | POST | 提交异步图片生成任务(支持单图 / 多图),返回 `task_id` | API Key |
|
||||
| `/api/v1/generate/image/tasks/{task_id}` | GET | 轮询异步图片生成任务状态和结果 | API Key |
|
||||
| `/api/v1/balance` | GET | 查询点数余额 | API Key |
|
||||
| `/api/v1/models` | GET | 查询可调用能力别名、能力和点数单价 | API Key |
|
||||
@@ -42,6 +42,8 @@ T-614 已落地 `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks
|
||||
|
||||
T-619 已落地 `/api/v1/analyze/images`:使用独立 `vision` 操作和能力别名,同步接收有序多图并返回文字;不复用标题接口语义,也不改变现有标题 / 生图路由。
|
||||
|
||||
T-620 已扩展两个图生图路由:旧 `image_url` / `image_base64` 单图字段保持兼容;新 `images` 为有序列表,每项必须且只能提供其中一种来源,且不能和旧字段混用。服务端固定把第 1 张作为主商品图、后续图作为参考图;异步任务在后台可查看有序输入文件,不保存 base64 原文。
|
||||
|
||||
## 运营后台(django-admin,`/admin/`)
|
||||
|
||||
后台用 Django Session 登录,按模型注册 Admin:
|
||||
|
||||
+10
@@ -1991,3 +1991,13 @@
|
||||
- 输入数量、单图和总大小、SSRF、敏感词、无定价等校验均在预扣前完成;不保存 base64、输入图片内容或 provider raw。
|
||||
- 中转站直连验收证据:使用 `gpt-image-2`、两张本地图片调用 `/v1/images/edits`,HTTP 200,约 73 秒返回 `data[0].b64_json`,解码后图片约 2.04 MB;输出保存于工作区外的 `D:\chengma\cmhub-relay-tests\multi-images-edits-result.bin`。该结果确认重复 `image` 字段可被接受,但主图 / 参考图语义仍由 T-620 的服务端固定 prompt 和真实两张明显不同图片验收确认。
|
||||
- 验证:任务登记完成后执行文档 diff 检查;未运行代码测试,因为本轮未改实现。
|
||||
|
||||
## 2026-07-17 完成:T-620 图生图支持单图 / 多图主图与参考图
|
||||
|
||||
- 接口与输入:`POST /api/v1/generate/image`、`POST /api/v1/generate/image/tasks` 新增有序 `images`。每项必须且只能给出 `image_url` 或 `image_base64`;旧单图字段继续兼容,但不得与 `images` 混用。图片数量、单图和总大小分别由 `IMAGE_MAX_INPUT_IMAGES=8`、`IMAGE_MAX_INPUT_IMAGE_BYTES=10485760`、`IMAGE_MAX_INPUT_TOTAL_BYTES=33554432` 控制。
|
||||
- 角色语义:用户 prompt 先经既有敏感词审核;只要有输入图片,服务端才追加不可由调用方覆盖的规则,明确第 1 张为主商品图、第 2 张及之后仅为参考图。一次请求仍按既有 image 别名 / 分辨率预扣一次,校验、审核、SSRF、下载与解码都在预扣前;失败、重试与僵任务退款继续复用原有幂等链路。
|
||||
- Provider:`MultimodalImage` 增加文件名,生成接口增加有序 `images` 参数。Chat Completions 按顺序生成图片内容段;Gemini 按顺序生成 inlineData;JSON 图片 Provider 发送有序 `image_urls`;`images_edits` 多图使用重复的 multipart `image` 字段,单图仍保持原请求形状。
|
||||
- 异步:新增 `api.0003_image_generation_task_input` 与 `ImageGenerationTaskInput(task, ordinal, image, mime_type, filename)`。提交时只保存解码后的输入文件和非敏感元数据,不保存 base64 原文;worker 按序恢复所有输入。没有子记录时仍读取历史任务的 `input_image`。
|
||||
- 安全回归:完整 `GenerateApiTests` 首次运行 53 条时发现旧 `image_url` 会因新多图单图限制而绕过 `IMAGE_URL_MAX_BYTES`。已修复为 URL 下载取既有 URL 上限与请求上下文单图上限中的较小值,恢复旧接口保护语义。
|
||||
- 文档:同步更新 `README.md`、`00-ai-start-here.md`、`02-requirements.md`、`04-architecture.md`、`api.md`、`routes.md`、`env.md`、`deployment.md`、`current-state.md` 与任务看板,明确多图请求格式、主图 / 参考图语义、异步存储和 Nginx 请求体配置。
|
||||
- 验证:`C:/Python312/python.exe -m compileall -q apps config`、`manage.py check`、`manage.py makemigrations --check --dry-run` 通过;迁移 `api.0003_image_generation_task_input` 已应用到当前开发库。Provider 测试 13 条通过;序列化器多图、空数组与混用边界通过;目标 API 6 条通过(同步多图、URL+base64 混合、混用拒绝、超限拒绝、异步存储 / worker 恢复、旧 URL 上限)。完整 53 条 `GenerateApiTests` 的修复后重跑受远程 MySQL 测试库耗时影响超过 5 分钟未完成,未出现新的失败输出;此前完整运行唯一失败已修复并由目标回归用例确认。中转站两图 `images/edits` 重复 multipart `image` 直连验收此前返回 HTTP 200 和有效图片,作为真实上游多文件传输证据。
|
||||
|
||||
Reference in New Issue
Block a user