feat: support multi-image image generation

This commit is contained in:
QiuSW
2026-07-17 15:55:38 +08:00
parent bd274e26e3
commit 4a0becbb4d
23 changed files with 690 additions and 80 deletions
+3
View File
@@ -48,6 +48,9 @@ IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30
API_GENERATE_THROTTLE_RATE=60/min API_GENERATE_THROTTLE_RATE=60/min
API_AUTH_FAILURE_THROTTLE_RATE=30/min API_AUTH_FAILURE_THROTTLE_RATE=30/min
IMAGE_URL_MAX_BYTES=10485760 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_MAX_REDIRECTS=3
IMAGE_URL_CONNECT_TIMEOUT_SECONDS=10 IMAGE_URL_CONNECT_TIMEOUT_SECONDS=10
IMAGE_URL_READ_TIMEOUT_SECONDS=60 IMAGE_URL_READ_TIMEOUT_SECONDS=60
+1 -1
View File
@@ -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)。 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) 第四节计费时序。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+2
View File
@@ -82,6 +82,7 @@ class ImageGenerationResult:
class MultimodalImage: class MultimodalImage:
data: bytes data: bytes
mime_type: str = "image/png" mime_type: str = "image/png"
filename: str = "image.png"
class Provider(Protocol): class Provider(Protocol):
@@ -108,6 +109,7 @@ class Provider(Protocol):
image: bytes | None = None, image: bytes | None = None,
image_mime_type: str = "image/png", image_mime_type: str = "image/png",
image_filename: str = "image.png", image_filename: str = "image.png",
images: Sequence[MultimodalImage] | None = None,
resolution: str = "1K", resolution: str = "1K",
aspect_ratio: str = "1:1", aspect_ratio: str = "1:1",
parameters: Mapping[str, Any] | None = None, parameters: Mapping[str, Any] | None = None,
+67 -9
View File
@@ -138,6 +138,7 @@ class ChatCompletionsProvider(BaseHttpProvider):
image: bytes | None = None, image: bytes | None = None,
image_mime_type: str = "image/png", image_mime_type: str = "image/png",
image_filename: str = "image.png", image_filename: str = "image.png",
images: Sequence[MultimodalImage] | None = None,
resolution: str = "1K", resolution: str = "1K",
aspect_ratio: str = "1:1", aspect_ratio: str = "1:1",
parameters: Mapping[str, Any] | None = None, parameters: Mapping[str, Any] | None = None,
@@ -149,6 +150,7 @@ class ChatCompletionsProvider(BaseHttpProvider):
prompt, prompt,
image=image, image=image,
image_mime_type=image_mime_type, image_mime_type=image_mime_type,
images=images,
parameters=parameters, parameters=parameters,
) )
response = self.session.post( response = self.session.post(
@@ -243,6 +245,7 @@ class GeminiProvider(ChatCompletionsProvider):
image: bytes | None = None, image: bytes | None = None,
image_mime_type: str = "image/png", image_mime_type: str = "image/png",
image_filename: str = "image.png", image_filename: str = "image.png",
images: Sequence[MultimodalImage] | None = None,
resolution: str = "1K", resolution: str = "1K",
aspect_ratio: str = "1:1", aspect_ratio: str = "1:1",
parameters: Mapping[str, Any] | None = None, parameters: Mapping[str, Any] | None = None,
@@ -254,6 +257,7 @@ class GeminiProvider(ChatCompletionsProvider):
prompt, prompt,
image=image, image=image,
image_mime_type=image_mime_type, image_mime_type=image_mime_type,
images=images,
response_modalities=["TEXT", "IMAGE"], response_modalities=["TEXT", "IMAGE"],
parameters=parameters, parameters=parameters,
) )
@@ -324,6 +328,7 @@ class ImagesGenerationProvider(BaseHttpProvider):
image: bytes | None = None, image: bytes | None = None,
image_mime_type: str = "image/png", image_mime_type: str = "image/png",
image_filename: str = "image.png", image_filename: str = "image.png",
images: Sequence[MultimodalImage] | None = None,
resolution: str = "1K", resolution: str = "1K",
aspect_ratio: str = "1:1", aspect_ratio: str = "1:1",
parameters: Mapping[str, Any] | None = None, parameters: Mapping[str, Any] | None = None,
@@ -337,8 +342,17 @@ class ImagesGenerationProvider(BaseHttpProvider):
"resolution": resolution, "resolution": resolution,
"n": 1, "n": 1,
} }
if image is not None: image_inputs = generation_image_inputs(
payload["image_urls"] = [image_bytes_to_data_url(image, image_mime_type)] 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) apply_extra_body(payload, model, parameters)
response = self.session.post( response = self.session.post(
url, url,
@@ -376,12 +390,19 @@ class ImagesEditsProvider(BaseHttpProvider):
image: bytes | None = None, image: bytes | None = None,
image_mime_type: str = "image/png", image_mime_type: str = "image/png",
image_filename: str = "image.png", image_filename: str = "image.png",
images: Sequence[MultimodalImage] | None = None,
resolution: str = "1K", resolution: str = "1K",
aspect_ratio: str = "1:1", aspect_ratio: str = "1:1",
parameters: Mapping[str, Any] | None = None, parameters: Mapping[str, Any] | None = None,
) -> ImageGenerationResult: ) -> ImageGenerationResult:
validate_model_config(model) 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") raise AiCapabilityError("images edits provider requires an input image")
url = normalize_api_url(model.url, API_IMAGES_EDITS) url = normalize_api_url(model.url, API_IMAGES_EDITS)
@@ -392,7 +413,15 @@ class ImagesEditsProvider(BaseHttpProvider):
"size": resolution_to_size(resolution), "size": resolution_to_size(resolution),
} }
apply_extra_body(data, model, parameters) 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( response = self.session.post(
url, url,
headers=self._headers(model), headers=self._headers(model),
@@ -443,13 +472,17 @@ def build_chat_image_payload(
*, *,
image: bytes | None = None, image: bytes | None = None,
image_mime_type: str = "image/png", image_mime_type: str = "image/png",
images: Sequence[MultimodalImage] | None = None,
parameters: Mapping[str, Any] | None = None, parameters: Mapping[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return build_chat_text_payload( return build_chat_vision_payload(
model, model,
prompt, prompt,
image=image, images=generation_image_inputs(
image_mime_type=image_mime_type, image=image,
image_mime_type=image_mime_type,
images=images,
),
parameters=parameters, parameters=parameters,
) )
@@ -486,12 +519,17 @@ def build_gemini_payload(
*, *,
image: bytes | None = None, image: bytes | None = None,
image_mime_type: str = "image/png", image_mime_type: str = "image/png",
images: Sequence[MultimodalImage] | None = None,
response_modalities: list[str], response_modalities: list[str],
parameters: Mapping[str, Any] | None = None, parameters: Mapping[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
parts: list[dict[str, Any]] = [{"text": prompt}] parts: list[dict[str, Any]] = [{"text": prompt}]
if image is not None: for image_input in generation_image_inputs(
data_url = image_bytes_to_data_url(image, image_mime_type) 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) mime_type, data = split_data_url(data_url)
parts.append({"inlineData": {"mimeType": mime_type, "data": data}}) parts.append({"inlineData": {"mimeType": mime_type, "data": data}})
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -522,6 +560,26 @@ def build_gemini_vision_payload(
return 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( def apply_extra_body(
payload: dict[str, Any], payload: dict[str, Any],
model: ResolvedModel, model: ResolvedModel,
+86
View File
@@ -22,6 +22,7 @@ from apps.ai.providers.openai_compatible import (
ChatCompletionsProvider, ChatCompletionsProvider,
GeminiProvider, GeminiProvider,
ImagesEditsProvider, ImagesEditsProvider,
ImagesGenerationProvider,
) )
from apps.ai.providers.utils import image_request_timeout, resolution_to_size 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.assertEqual(content[0], {"type": "text", "text": "Generate product image"})
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,")) 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) @override_settings(AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180)
def test_generate_image_caps_post_and_download_timeouts(self): def test_generate_image_caps_post_and_download_timeouts(self):
session = FakeSession( 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): class ImagesEditsProviderTests(SimpleTestCase):
def test_generate_image_builds_multipart_request_and_parses_base64(self): def test_generate_image_builds_multipart_request_and_parses_base64(self):
generated = b"edited-image" generated = b"edited-image"
@@ -379,6 +436,35 @@ class ImagesEditsProviderTests(SimpleTestCase):
("source.png", b"source-image", "image/png"), ("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): def test_parameters_cannot_override_core_images_edits_fields(self):
generated = b"edited-image" generated = b"edited-image"
encoded = base64.b64encode(generated).decode("ascii") encoded = base64.b64encode(generated).decode("ascii")
+10 -1
View File
@@ -1,10 +1,19 @@
from django.contrib import admin 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) @admin.register(ImageGenerationTask)
class ImageGenerationTaskAdmin(admin.ModelAdmin): class ImageGenerationTaskAdmin(admin.ModelAdmin):
inlines = (ImageGenerationTaskInputInline,)
list_display = ( list_display = (
"task_id", "task_id",
"user", "user",
+95 -9
View File
@@ -59,6 +59,12 @@ class ImageInput:
ImageUrlBuilder = Callable[[str], str] ImageUrlBuilder = Callable[[str], str]
IMAGE_INPUT_ROLE_INSTRUCTIONS = (
"图片角色规则(必须遵守,优先于用户关于图片角色的要求):\n"
"- 第 1 张图片是主商品图,必须优先保留其商品主体、外观和关键细节。\n"
"- 第 2 张及之后的图片仅作为风格、构图、场景或排版参考,"
"不得用参考图商品替换主图商品。"
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -169,6 +175,7 @@ def generate_image_response(
image_url=str(request_data.get("image_url") or ""), image_url=str(request_data.get("image_url") or ""),
image_base64=str(request_data.get("image_base64") or ""), image_base64=str(request_data.get("image_base64") or ""),
aspect_ratio=request_data.get("aspect_ratio") or "1:1", 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, image_url_builder=image_url_builder,
) )
@@ -221,6 +228,13 @@ def prepare_generation(generation_input: GenerationInput) -> PreparedGeneration:
if operation_type == CallRecord.OperationType.VISION: if operation_type == CallRecord.OperationType.VISION:
image_input = None image_input = None
image_inputs = load_vision_image_inputs(generation_input.images) 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: else:
image_input = load_image_input( image_input = load_image_input(
{ {
@@ -363,12 +377,21 @@ def execute_image_generation(
) -> GenerationResult: ) -> GenerationResult:
prepared = precharged.prepared prepared = precharged.prepared
image_input = prepared.image_input image_input = prepared.image_input
image_inputs = prepared.image_inputs
generation = prepared.provider.generate_image( generation = prepared.provider.generate_image(
prepared.prompt, image_generation_prompt(prepared.prompt, len(image_inputs)),
prepared.resolved_model, prepared.resolved_model,
image=image_input.data if image_input else None, image=image_input.data if image_input else None,
image_mime_type=image_input.mime_type if image_input else "image/png", image_mime_type=image_input.mime_type if image_input else "image/png",
image_filename=image_input.filename 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, resolution=prepared.resolution,
aspect_ratio=prepared.aspect_ratio, aspect_ratio=prepared.aspect_ratio,
parameters=prepared.parameters, parameters=prepared.parameters,
@@ -530,13 +553,21 @@ def upstream_error(exc: Exception) -> ApiRequestError:
def load_image_input(data: Mapping[str, Any]) -> ImageInput | None: 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() raw_base64 = str(data.get("image_base64") or "").strip()
if raw_base64: 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() image_url = str(data.get("image_url") or "").strip()
if image_url: if image_url:
return download_image_input(image_url) return download_image_input(image_url, max_bytes=max_bytes)
return None return None
@@ -544,15 +575,54 @@ def load_image_input(data: Mapping[str, Any]) -> ImageInput | None:
def load_vision_image_inputs( def load_vision_image_inputs(
items: Sequence[Mapping[str, Any]], items: Sequence[Mapping[str, Any]],
) -> tuple[ImageInput, ...]: ) -> 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( max_image_bytes = max(
1, 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( if items:
1, return load_ordered_image_inputs(
int(getattr(settings, "VISION_MAX_TOTAL_BYTES", 32 * 1024 * 1024)), 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: if not items:
raise ApiRequestError("bad_request", "images 至少需要一张图片", status.HTTP_400_BAD_REQUEST) raise ApiRequestError("bad_request", "images 至少需要一张图片", status.HTTP_400_BAD_REQUEST)
if len(items) > max_images: if len(items) > max_images:
@@ -591,6 +661,12 @@ def load_vision_image_inputs(
return tuple(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: def decode_image_input(value: str, *, max_bytes: int | None = None) -> ImageInput:
mime_type = "image/png" mime_type = "image/png"
encoded = value 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() content_type = response.headers.get("Content-Type", "image/png").split(";", 1)[0].strip().lower()
if not content_type.startswith("image/"): if not content_type.startswith("image/"):
raise ApiRequestError("bad_request", "image_url 不是图片资源", status.HTTP_400_BAD_REQUEST) 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: if not image:
raise ApiRequestError("bad_request", "image_url 图片内容为空", status.HTTP_400_BAD_REQUEST) raise ApiRequestError("bad_request", "image_url 图片内容为空", status.HTTP_400_BAD_REQUEST)
return ImageInput( return ImageInput(
@@ -746,6 +825,13 @@ def read_limited_image_response(response, *, max_bytes: int | None = None) -> by
return b"".join(chunks) 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: def filename_for_mime(mime_type: str) -> str:
extension = { extension = {
"image/jpeg": "jpg", "image/jpeg": "jpg",
+116 -38
View File
@@ -25,7 +25,7 @@ from .generation import (
prepare_generation, prepare_generation,
precharge_generation, precharge_generation,
) )
from .models import ImageGenerationTask from .models import ImageGenerationTask, ImageGenerationTaskInput
IDEMPOTENCY_KEY_MAX_LENGTH = 128 IDEMPOTENCY_KEY_MAX_LENGTH = 128
@@ -61,6 +61,7 @@ def create_image_generation_task(
image_url=str(request_data.get("image_url") or ""), image_url=str(request_data.get("image_url") or ""),
image_base64=str(request_data.get("image_base64") or ""), image_base64=str(request_data.get("image_base64") or ""),
aspect_ratio=request_data.get("aspect_ratio") or "1:1", 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_hash=request_hash,
request_payload=task_request_payload( request_payload=task_request_payload(
request_data=request_data, request_data=request_data,
image_input=prepared.image_input, image_inputs=prepared.image_inputs,
), ),
points_balance_after_charge=precharged.points_balance_after_charge, points_balance_after_charge=precharged.points_balance_after_charge,
expires_at=now + timedelta(hours=image_task_retention_hours()), 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 return task, True
except IntegrityError: except IntegrityError:
if idempotency_key_hash: 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: def request_hash_for_image_request(request_data: Mapping[str, Any]) -> str:
image_base64 = str(request_data.get("image_base64") or "")
payload = { payload = {
"prompt": str(request_data.get("prompt") or ""), "prompt": str(request_data.get("prompt") or ""),
"model": str(request_data.get("model") or ""), "model": str(request_data.get("model") or ""),
"resolution": str(request_data.get("resolution") or "1K"), "resolution": str(request_data.get("resolution") or "1K"),
"aspect_ratio": str(request_data.get("aspect_ratio") or "1:1"), "aspect_ratio": str(request_data.get("aspect_ratio") or "1:1"),
"parameters": dict(request_data.get("parameters") or {}), "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) return hash_json(payload)
def task_request_payload( def task_request_payload(
*, *,
request_data: Mapping[str, Any], request_data: Mapping[str, Any],
image_input: ImageInput | None, image_inputs: tuple[ImageInput, ...],
) -> dict[str, Any]: ) -> dict[str, Any]:
image_source = "none" return {
if request_data.get("image_base64"):
image_source = "base64_stored"
elif request_data.get("image_url"):
image_source = "url_stored"
payload = {
"prompt": str(request_data.get("prompt") or ""), "prompt": str(request_data.get("prompt") or ""),
"model": str(request_data.get("model") or ""), "model": str(request_data.get("model") or ""),
"resolution": str(request_data.get("resolution") or "1K"), "resolution": str(request_data.get("resolution") or "1K"),
"aspect_ratio": str(request_data.get("aspect_ratio") or "1:1"), "aspect_ratio": str(request_data.get("aspect_ratio") or "1:1"),
"parameters": dict(request_data.get("parameters") or {}), "parameters": dict(request_data.get("parameters") or {}),
"image_url": str(request_data.get("image_url") or ""), "image_inputs": [
"image_input": None, {
"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, def image_request_input_hash_data(request_data: Mapping[str, Any]) -> list[dict[str, str]]:
"mime_type": image_input.mime_type, items = request_data.get("images") or ()
"filename": image_input.filename, if items:
"storage_path": "", 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, task: ImageGenerationTask,
image_input: ImageInput | None, *,
image_inputs: tuple[ImageInput, ...],
request_data: Mapping[str, Any],
) -> None: ) -> None:
if image_input is None: if not image_inputs:
return return
task.input_image.save(
image_input.filename,
ContentFile(image_input.data),
save=True,
)
payload = dict(task.request_payload or {}) payload = dict(task.request_payload or {})
image_info = dict(payload.get("image_input") or {}) image_metadata = list(payload.get("image_inputs") or [])
image_info["storage_path"] = task.input_image.name for ordinal, image_input in enumerate(image_inputs):
payload["image_input"] = image_info 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.request_payload = payload
task.save(update_fields=("request_payload", "updated_at")) 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"), aspect_ratio=str(payload.get("aspect_ratio") or "1:1"),
) )
) )
image_input = stored_image_input_for_task(task) image_inputs = stored_image_inputs_for_task(task)
if image_input is not None: if image_inputs:
prepared = replace(prepared, image_input=image_input) prepared = replace(
prepared,
image_input=image_inputs[0],
image_inputs=image_inputs,
)
return PrechargedGeneration( return PrechargedGeneration(
prepared=prepared, 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: if not task.input_image:
return None return None
payload = dict(task.request_payload or {}) 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: def refresh_task(task: ImageGenerationTask) -> ImageGenerationTask:
return ( return (
ImageGenerationTask.objects.select_related("user", "api_key", "call_record") ImageGenerationTask.objects.select_related("user", "api_key", "call_record")
.prefetch_related("input_images")
.get(pk=task.pk) .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')],
},
),
]
+32
View File
@@ -98,3 +98,35 @@ class ImageGenerationTask(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.task_id} {self.status}" 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
View File
@@ -35,6 +35,7 @@ class GenerateImageRequestSerializer(serializers.Serializer):
) )
image_url = serializers.URLField(required=False, allow_blank=True) image_url = serializers.URLField(required=False, allow_blank=True)
image_base64 = serializers.CharField(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( resolution = serializers.CharField(
required=False, required=False,
allow_blank=True, allow_blank=True,
@@ -49,8 +50,29 @@ class GenerateImageRequestSerializer(serializers.Serializer):
) )
parameters = serializers.DictField(required=False, default=dict) 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_url = serializers.URLField(required=False, allow_blank=True)
image_base64 = serializers.CharField(required=False, allow_blank=True) image_base64 = serializers.CharField(required=False, allow_blank=True)
@@ -64,6 +86,10 @@ class VisionImageInputSerializer(serializers.Serializer):
return attrs return attrs
class VisionImageInputSerializer(ImageInputSerializer):
pass
class AnalyzeImagesRequestSerializer(serializers.Serializer): class AnalyzeImagesRequestSerializer(serializers.Serializer):
prompt = serializers.CharField(trim_whitespace=True, allow_blank=False) prompt = serializers.CharField(trim_whitespace=True, allow_blank=False)
model = serializers.CharField( model = serializers.CharField(
+146 -1
View File
@@ -36,7 +36,7 @@ from apps.api.image_tasks import (
reap_stale_image_tasks, reap_stale_image_tasks,
run_image_generation_task, 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.throttles import GenerateRateThrottle
from apps.api.views import ClientLatestReleaseView, ExternalApiView, ModelsView from apps.api.views import ClientLatestReleaseView, ExternalApiView, ModelsView
from apps.ai.models import AiModel, ModelAlias from apps.ai.models import AiModel, ModelAlias
@@ -1672,6 +1672,112 @@ class GenerateApiTests(TestCase):
self.assertEqual(call.result_summary, "image_bytes=21") self.assertEqual(call.result_summary, "image_bytes=21")
self.assertNotIn("SECRET_RAW", call.result_ref + call.result_summary) 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): def test_sync_image_usage_telemetry_logs_safe_client_version_and_key_identity(self):
encoded = base64.b64encode(b"input-image").decode("ascii") encoded = base64.b64encode(b"input-image").decode("ascii")
payload = { payload = {
@@ -1883,6 +1989,45 @@ class GenerateApiTests(TestCase):
self.assertEqual(poll.data["result"]["image_url"], task.result_url) self.assertEqual(poll.data["result"]["image_url"], task.result_url)
self.assertEqual(repeat.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): def test_async_image_poll_rejects_cross_user_access(self):
response = self.post_with_provider( response = self.post_with_provider(
"/api/v1/generate/image/tasks", "/api/v1/generate/image/tasks",
+3
View File
@@ -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_MAX_REDIRECTS = env_int("IMAGE_URL_MAX_REDIRECTS", 3)
IMAGE_URL_CONNECT_TIMEOUT_SECONDS = env_int("IMAGE_URL_CONNECT_TIMEOUT_SECONDS", 10) 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_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_IMAGES = env_int("VISION_MAX_IMAGES", 8)
VISION_MAX_IMAGE_BYTES = env_int("VISION_MAX_IMAGE_BYTES", 10 * 1024 * 1024) 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) VISION_MAX_TOTAL_BYTES = env_int("VISION_MAX_TOTAL_BYTES", 32 * 1024 * 1024)
+2 -2
View File
@@ -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 已给旧同步 / 新异步提交路径接入结构化日志并形成旧同步接口退出条件。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。 当前项目处于:**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 安全加固已完成。 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 用户端审核优化已完成。 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 任务已收尾。 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 已完成,覆盖可用别名发现、后台中文化、敏感词过滤、免邮箱验证、公开首页与客户端发布、注册赠点、用户端品牌、生图异步化 / 遥测 / 自动重试、客户端发布校验元数据、同步多图理解和图生图多输入主图 / 参考图语义。
## 领取任务规则 ## 领取任务规则
+3 -3
View File
@@ -30,7 +30,7 @@
| 扫码充值 | 用户端发起充值→展示支付二维码→扫码付款→回调到账;金额按汇率转点数 | P0 | | 扫码充值 | 用户端发起充值→展示支付二维码→扫码付款→回调到账;金额按汇率转点数 | P0 |
| 个人中心 | 查看剩余点数、充值总额、充值记录、点数使用(消费)记录 | P0 | | 个人中心 | 查看剩余点数、充值总额、充值记录、点数使用(消费)记录 | P0 |
| 生成标题 API | 带 API Key + prompt(+可选商品图)调用,拿到标题文字 | P0 | | 生成标题 API | 带 API Key + prompt(+可选商品图)调用,拿到标题文字 | P0 |
| 生成图片 API | 带 API Key + prompt + 图 + 模型/分辨率调用;旧同步接口可直接返回图片 URL,新版客户端可用异步提交 / 轮询拿结果 | P0 | | 生成图片 API | 带 API Key + prompt + 一张或多张图 + 模型/分辨率调用;第一张为主商品图、后续图片为参考图;旧同步接口可直接返回图片 URL,新版客户端可用异步提交 / 轮询拿结果 | P0 |
| 多图理解 API | 带 API Key + prompt + 一张或多张图片调用,按图片顺序理解、比较或提取信息并返回完整文字 | P1 | | 多图理解 API | 带 API Key + prompt + 一张或多张图片调用,按图片顺序理解、比较或提取信息并返回完整文字 | P1 |
| 点数计费与扣减 | 按「操作类型+能力别名+分辨率」算点数,余额足够则扣点放行,不足则报错 | P0 | | 点数计费与扣减 | 按「操作类型+能力别名+分辨率」算点数,余额足够则扣点放行,不足则报错 | P0 |
| 余额查询 API | 查询当前用户点数余额 | P0 | | 余额查询 API | 查询当前用户点数余额 | P0 |
@@ -57,7 +57,7 @@
1. 作为新用户,我在用户端自助注册登录后先获得 100 点试用点数;后续扫码充值一笔钱,看到对应点数按汇率到账。 1. 作为新用户,我在用户端自助注册登录后先获得 100 点试用点数;后续扫码充值一笔钱,看到对应点数按汇率到账。
2. 作为已登录用户,我自助生成一把 API Key(明文只显示一次),也能删除不用的 Key。 2. 作为已登录用户,我自助生成一把 API Key(明文只显示一次),也能删除不用的 Key。
3. 作为接入方,我带着 API Key 调用生成标题接口,传入 prompt,能拿到生成的标题。 3. 作为接入方,我带着 API Key 调用生成标题接口,传入 prompt,能拿到生成的标题。
4. 作为接入方,我可以调用旧同步生成图片接口在同一个响应里拿到图片结果;也可以调用异步图片任务接口先拿到 `task_id`,再轮询直到成功或失败。 4. 作为接入方,我可以提交一张或多张图片生成商品图:第一张作为主商品图,后续图片仅作风格、构图、场景或排版参考;可调用旧同步接口在同一个响应里拿到图片结果,也可先提交异步图片任务拿到 `task_id` 再轮询直到成功或失败。
5. 作为接入方,我可以提交一张或多张商品图,让支持视觉理解的模型按输入顺序分析并返回文字;一次请求只按对应能力别名扣一次点数。 5. 作为接入方,我可以提交一张或多张商品图,让支持视觉理解的模型按输入顺序分析并返回文字;一次请求只按对应能力别名扣一次点数。
6. 当我的点数不足以支付本次调用时,系统拒绝调用并返回「点数不足,请先充值」,且不调用上游、不扣点。 6. 当我的点数不足以支付本次调用时,系统拒绝调用并返回「点数不足,请先充值」,且不调用上游、不扣点。
7. 作为已登录用户,我在个人中心看到剩余点数、充值总额、充值记录和消费(调用)记录。 7. 作为已登录用户,我在个人中心看到剩余点数、充值总额、充值记录和消费(调用)记录。
@@ -68,7 +68,7 @@
每条 P0 功能对应可验证的判据: 每条 P0 功能对应可验证的判据:
- **生成标题 API**:携带有效 API Key + 合法 prompt 调用,返回 200 且响应含标题文本;无效/缺失 Key 返回 401。 - **生成标题 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 不安全时不扣点,上游失败时已扣点数只退一次。 - **多图理解 API**:携带有效 Key、合法 prompt 和 1–8 张图片调用,返回 200 且响应含完整文字;图片顺序保持不变;输入无效、超限、命中敏感词或 URL 不安全时不扣点,上游失败时已扣点数只退一次。
- **点数计费与扣减**:调用前按规则算出点数 N;余额 ≥ N 才放行并精确扣 N;余额 < N 返回点数不足错误码且不调上游。并发同时多次调用,最终扣点总额正确,**不出现超扣或扣成负数**。 - **点数计费与扣减**:调用前按规则算出点数 N;余额 ≥ N 才放行并精确扣 N;余额 < N 返回点数不足错误码且不调上游。并发同时多次调用,最终扣点总额正确,**不出现超扣或扣成负数**。
- **余额查询 API**:返回的余额等于该账号点数流水累加结果。 - **余额查询 API**:返回的余额等于该账号点数流水累加结果。
+16 -4
View File
@@ -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-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-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。 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-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`)** **计费层(`apps/billing`)**
@@ -338,7 +338,7 @@ CREATE TABLE image_generation_task (
idempotency_key_hash CHAR(64), -- api_key + hash 唯一;NULL 允许无幂等键任务重复存在 idempotency_key_hash CHAR(64), -- api_key + hash 唯一;NULL 允许无幂等键任务重复存在
request_hash CHAR(64) NOT NULL, request_hash CHAR(64) NOT NULL,
request_payload JSON NOT NULL, -- prompt/model/resolution/parameters/输入文件引用,不存 base64 原文 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 '', result_url TEXT NOT NULL DEFAULT '',
error_code VARCHAR(64) NOT NULL DEFAULT '', error_code VARCHAR(64) NOT NULL DEFAULT '',
error_message TEXT NOT NULL DEFAULT '', error_message TEXT NOT NULL DEFAULT '',
@@ -356,6 +356,18 @@ CREATE TABLE image_generation_task (
updated_at DATETIME(6) NOT NULL, updated_at DATETIME(6) NOT NULL,
UNIQUE(api_key_id, idempotency_key_hash) 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
View File
@@ -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-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-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-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
View File
@@ -21,7 +21,7 @@
T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer <API_KEY>`;生成、余额等外部 API 视图应继承 `apps.api.views.ExternalApiView`,不接受 Web session。 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 的准备 / 预扣 / 执行 / 成功确认 / 失败退点核心,不改变标题和生图接口。 T-619 已实现 `POST /api/v1/analyze/images`:使用独立 `vision` 操作和能力别名,同步接收一张或多张有序图片并返回完整文字。该接口复用 T-613 的准备 / 预扣 / 执行 / 成功确认 / 失败退点核心,不改变标题和生图接口。
@@ -228,7 +228,7 @@ X-Client-Version: 0.1.1
{ {
"prompt": "把这件衣服换成模特上身的穿搭图", "prompt": "把这件衣服换成模特上身的穿搭图",
"model": "image-hd", // 能力别名,非具体模型名 "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", "resolution": "1K",
"aspect_ratio": "1:1", "aspect_ratio": "1:1",
"parameters": {} // 可选,安全供应商参数;未知字段/核心字段忽略 "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` ### `POST /api/v1/generate/image/tasks`
@@ -268,7 +283,10 @@ Content-Type: application/json
{ {
"prompt": "把这件衣服换成模特上身的穿搭图", "prompt": "把这件衣服换成模特上身的穿搭图",
"model": "image-hd", "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", "resolution": "1K",
"aspect_ratio": "1:1", "aspect_ratio": "1:1",
"parameters": {} "parameters": {}
@@ -297,6 +315,7 @@ Content-Type: application/json
- 提交阶段同步执行 prompt 审核;命中敏感词返回 `400 content_blocked`,不建任务、不扣点、不调上游。 - 提交阶段同步执行 prompt 审核;命中敏感词返回 `400 content_blocked`,不建任务、不扣点、不调上游。
- 提交阶段预扣点数;余额不足返回 `402 insufficient_points`,不建任务。 - 提交阶段预扣点数;余额不足返回 `402 insufficient_points`,不建任务。
- `Idempotency-Key` 按当前 API Key 去重。同 key + 同 payload 返回同一 `task_id`,不重复扣点;同 key + 不同 payload 返回 `409 idempotency_conflict`。 - `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 会解码后作为输入文件引用保存,任务请求快照只保存必要字段和文件引用。 - 不保存 provider `raw`、上游密钥或 `image_base64` 原文;base64 会解码后作为输入文件引用保存,任务请求快照只保存必要字段和文件引用。
- 桌面端主链路推荐传 `image_base64`。该路径在 submit 阶段只做解码和输入文件落盘,不发生外部网络请求,提交请求应保持短耗时。 - 桌面端主链路推荐传 `image_base64`。该路径在 submit 阶段只做解码和输入文件落盘,不发生外部网络请求,提交请求应保持短耗时。
- `image_url` 仍按同步接口的 SSRF 与大小规则处理,并在 submit 阶段下载成输入文件引用;失败不扣点。这个路径可能因远程图片下载变慢而让 submit 阻塞,适合作为边缘兼容能力,不建议桌面端批量生图主流程使用。 - `image_url` 仍按同步接口的 SSRF 与大小规则处理,并在 submit 阶段下载成输入文件引用;失败不扣点。这个路径可能因远程图片下载变慢而让 submit 阻塞,适合作为边缘兼容能力,不建议桌面端批量生图主流程使用。
+2 -2
View File
@@ -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-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-619 多张图片理解并返回文字。
- 正在进行:无。 - 正在进行:无。
- 待开始:T-620 图生图支持单图 / 多图主图与参考图;仍按真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察继续拆任务。 - 待开始:仍按真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察继续拆任务。
- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 - 当前 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
View File
@@ -25,7 +25,7 @@
- 使用系统 Python 3.12,不使用虚拟环境。 - 使用系统 Python 3.12,不使用虚拟环境。
- 图片生成已提供旧同步接口和新异步提交 / 轮询接口;真实图片生成耗时仍需在生产链路记录,并用于校准旧同步超时和异步 worker 容量。 - 图片生成已提供旧同步接口和新异步提交 / 轮询接口;真实图片生成耗时仍需在生产链路记录,并用于校准旧同步超时和异步 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`。 - 真实支付需微信 / 支付宝商户密钥、证书、生产 SDK 与公网回调地址;未配置前只能用 `PAYMENT_CALLBACK_MODE=mock`。
## 二、系统准备 ## 二、系统准备
@@ -124,6 +124,9 @@ IMAGE_TASK_REAPER_INTERVAL_SECONDS=60
IMAGE_TASK_LEASE_SECONDS=600 IMAGE_TASK_LEASE_SECONDS=600
IMAGE_TASK_MAX_RETRIES=2 IMAGE_TASK_MAX_RETRIES=2
IMAGE_TASK_RETRY_BACKOFF_SECONDS=10,30 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_BACKEND=django.core.cache.backends.db.DatabaseCache
DJANGO_CACHE_LOCATION=cmhub_cache DJANGO_CACHE_LOCATION=cmhub_cache
+4 -1
View File
@@ -79,6 +79,9 @@ T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和
| `IMAGE_URL_MAX_REDIRECTS` | 否 | `3` | `image_url` 手动跟随重定向次数上限;每跳都会重新校验目标地址 | | `IMAGE_URL_MAX_REDIRECTS` | 否 | `3` | `image_url` 手动跟随重定向次数上限;每跳都会重新校验目标地址 |
| `IMAGE_URL_CONNECT_TIMEOUT_SECONDS` | 否 | `10` | `image_url` 下载连接超时秒数 | | `IMAGE_URL_CONNECT_TIMEOUT_SECONDS` | 否 | `10` | `image_url` 下载连接超时秒数 |
| `IMAGE_URL_READ_TIMEOUT_SECONDS` | 否 | `60` | `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_IMAGES` | 否 | `8` | 多图理解接口单次最多接受的图片数量,最小按 1 处理 |
| `VISION_MAX_IMAGE_BYTES` | 否 | `10485760` | 多图理解接口每张图片解码或下载后的最大字节数,默认 10 MiB | | `VISION_MAX_IMAGE_BYTES` | 否 | `10485760` | 多图理解接口每张图片解码或下载后的最大字节数,默认 10 MiB |
| `VISION_MAX_TOTAL_BYTES` | 否 | `33554432` | 多图理解接口单次所有图片的总字节数上限,默认 32 MiB | | `VISION_MAX_TOTAL_BYTES` | 否 | `33554432` | 多图理解接口单次所有图片的总字节数上限,默认 32 MiB |
@@ -86,7 +89,7 @@ T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和
`image_url` 只允许 `http` / `https`,服务端会在请求前解析域名,拒绝私有网段、回环、链路本地、保留地址、组播、未指定地址;重定向后的目标地址也会重复执行同样校验。内网图片不应通过 `image_url` 传入,调用方应改用 `image_base64`。 `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
View File
@@ -26,8 +26,8 @@ T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 `
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `/api/v1/generate/title` | POST | 生成标题 | API Key | | `/api/v1/generate/title` | POST | 生成标题 | API Key |
| `/api/v1/analyze/images` | POST | 理解一张或多张图片并同步返回文字 | API Key | | `/api/v1/analyze/images` | POST | 理解一张或多张图片并同步返回文字 | API Key |
| `/api/v1/generate/image` | POST | 生成图片(同步) | API Key | | `/api/v1/generate/image` | POST | 生成图片(同步,支持单图 / 多图) | API Key |
| `/api/v1/generate/image/tasks` | POST | 提交异步图片生成任务,返回 `task_id` | API Key | | `/api/v1/generate/image/tasks` | POST | 提交异步图片生成任务(支持单图 / 多图),返回 `task_id` | API Key |
| `/api/v1/generate/image/tasks/{task_id}` | GET | 轮询异步图片生成任务状态和结果 | API Key | | `/api/v1/generate/image/tasks/{task_id}` | GET | 轮询异步图片生成任务状态和结果 | API Key |
| `/api/v1/balance` | GET | 查询点数余额 | API Key | | `/api/v1/balance` | GET | 查询点数余额 | API Key |
| `/api/v1/models` | 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-619 已落地 `/api/v1/analyze/images`:使用独立 `vision` 操作和能力别名,同步接收有序多图并返回文字;不复用标题接口语义,也不改变现有标题 / 生图路由。
T-620 已扩展两个图生图路由:旧 `image_url` / `image_base64` 单图字段保持兼容;新 `images` 为有序列表,每项必须且只能提供其中一种来源,且不能和旧字段混用。服务端固定把第 1 张作为主商品图、后续图作为参考图;异步任务在后台可查看有序输入文件,不保存 base64 原文。
## 运营后台(django-admin,`/admin/`) ## 运营后台(django-admin,`/admin/`)
后台用 Django Session 登录,按模型注册 Admin: 后台用 Django Session 登录,按模型注册 Admin:
+10
View File
@@ -1991,3 +1991,13 @@
- 输入数量、单图和总大小、SSRF、敏感词、无定价等校验均在预扣前完成;不保存 base64、输入图片内容或 provider raw。 - 输入数量、单图和总大小、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 和真实两张明显不同图片验收确认。 - 中转站直连验收证据:使用 `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 检查;未运行代码测试,因为本轮未改实现。 - 验证:任务登记完成后执行文档 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 和有效图片,作为真实上游多文件传输证据。