diff --git a/.env.example b/.env.example index 00ce2db..30b0c31 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index e72911e..f90f30f 100644 --- a/README.md +++ b/README.md @@ -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) 第四节计费时序。 diff --git a/apps/ai/providers/base.py b/apps/ai/providers/base.py index e27a8a4..1d2c70d 100644 --- a/apps/ai/providers/base.py +++ b/apps/ai/providers/base.py @@ -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, diff --git a/apps/ai/providers/openai_compatible.py b/apps/ai/providers/openai_compatible.py index 91b4e9d..da52b62 100644 --- a/apps/ai/providers/openai_compatible.py +++ b/apps/ai/providers/openai_compatible.py @@ -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, diff --git a/apps/ai/tests.py b/apps/ai/tests.py index 77eed59..14800b4 100644 --- a/apps/ai/tests.py +++ b/apps/ai/tests.py @@ -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") diff --git a/apps/api/admin.py b/apps/api/admin.py index 2c07a5e..3318b9d 100644 --- a/apps/api/admin.py +++ b/apps/api/admin.py @@ -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", diff --git a/apps/api/generation.py b/apps/api/generation.py index 8d0f22d..f26a9ad 100644 --- a/apps/api/generation.py +++ b/apps/api/generation.py @@ -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", diff --git a/apps/api/image_tasks.py b/apps/api/image_tasks.py index e6cdc26..3e01259 100644 --- a/apps/api/image_tasks.py +++ b/apps/api/image_tasks.py @@ -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) ) diff --git a/apps/api/migrations/0003_image_generation_task_input.py b/apps/api/migrations/0003_image_generation_task_input.py new file mode 100644 index 0000000..9b0e861 --- /dev/null +++ b/apps/api/migrations/0003_image_generation_task_input.py @@ -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')], + }, + ), + ] diff --git a/apps/api/models.py b/apps/api/models.py index a7c64b8..0af7955 100644 --- a/apps/api/models.py +++ b/apps/api/models.py @@ -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}" diff --git a/apps/api/serializers.py b/apps/api/serializers.py index 6fcb8d4..b72db95 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -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( diff --git a/apps/api/tests.py b/apps/api/tests.py index 1e36ab0..5d09f7e 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -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", diff --git a/config/settings.py b/config/settings.py index 636320c..8a1c721 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index b8b125b..c438c7e 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -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 已完成,覆盖可用别名发现、后台中文化、敏感词过滤、免邮箱验证、公开首页与客户端发布、注册赠点、用户端品牌、生图异步化 / 遥测 / 自动重试、客户端发布校验元数据、同步多图理解和图生图多输入主图 / 参考图语义。 ## 领取任务规则 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index f5b2e3e..de5e5ed 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -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**:返回的余额等于该账号点数流水累加结果。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index a6c7c85..3a35bc4 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -39,11 +39,11 @@ T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 `Authorization: Bearer `,通过 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) +); ``` 需要说明: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index ed5bb9b..983e2ac 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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 | ## 里程碑 diff --git a/docs/api.md b/docs/api.md index 645009d..b202d4b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -21,7 +21,7 @@ T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer `;生成、余额等外部 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 阻塞,适合作为边缘兼容能力,不建议桌面端批量生图主流程使用。 diff --git a/docs/current-state.md b/docs/current-state.md index 2bb58f5..7dac0d4 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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) 为准。 ## 当前可运行内容 diff --git a/docs/deployment.md b/docs/deployment.md index 5a939bb..0bb0538 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/docs/env.md b/docs/env.md index 8aa0001..fcee23a 100644 --- a/docs/env.md +++ b/docs/env.md @@ -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 内存共同校准。 ## 六、内容安全 / 本地敏感词配置 diff --git a/docs/routes.md b/docs/routes.md index 4e2c6d2..bf071b4 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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: diff --git a/progress.md b/progress.md index 5505d7b..cfabeb8 100644 --- a/progress.md +++ b/progress.md @@ -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 和有效图片,作为真实上游多文件传输证据。