From 5325eacdfed56a7de339e469a1c701ea1a0783a8 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 16 Jul 2026 14:13:19 +0800 Subject: [PATCH] feat: add multi-image vision analysis --- .env.example | 3 + README.md | 6 +- apps/ai/aliases.py | 14 +- apps/ai/catalog.py | 8 +- .../0005_alter_modelalias_operation_type.py | 18 + apps/ai/models.py | 1 + apps/ai/providers/__init__.py | 2 + apps/ai/providers/base.py | 18 +- apps/ai/providers/openai_compatible.py | 118 ++++++- apps/ai/tests.py | 123 ++++++- apps/api/generation.py | 171 ++++++++- apps/api/serializers.py | 32 ++ apps/api/tests.py | 329 +++++++++++++++++- apps/api/urls.py | 2 + apps/api/views.py | 23 ++ ...lter_callrecord_operation_type_and_more.py | 23 ++ apps/billing/models.py | 1 + apps/portal/templates/portal/models.html | 2 + apps/portal/tests.py | 17 +- config/settings.py | 3 + docs/00-ai-start-here.md | 8 +- docs/02-requirements.md | 9 +- docs/04-architecture.md | 13 +- docs/06-tasks.md | 2 +- docs/api.md | 57 ++- docs/current-state.md | 14 +- docs/env.md | 7 +- docs/routes.md | 3 + progress.md | 37 ++ 29 files changed, 1013 insertions(+), 51 deletions(-) create mode 100644 apps/ai/migrations/0005_alter_modelalias_operation_type.py create mode 100644 apps/billing/migrations/0008_alter_callrecord_operation_type_and_more.py diff --git a/.env.example b/.env.example index 62a06ee..00ce2db 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,9 @@ IMAGE_URL_MAX_BYTES=10485760 IMAGE_URL_MAX_REDIRECTS=3 IMAGE_URL_CONNECT_TIMEOUT_SECONDS=10 IMAGE_URL_READ_TIMEOUT_SECONDS=60 +VISION_MAX_IMAGES=8 +VISION_MAX_IMAGE_BYTES=10485760 +VISION_MAX_TOTAL_BYTES=33554432 RECHARGE_MAX_AMOUNT_CNY=100000.00 # Content moderation diff --git a/README.md b/README.md index ec367a0..d5a95ae 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # cmhub -`cmhub` 是一个**自助用户端 + 计费型 AI 能力网关 + 运营后台**三合一服务:终端用户自助注册、扫码充值、管理 API Key;用 API Key 调用把桌面工具 `cmbot` 的「生成标题」「生成图片」能力封装成的 HTTP API,按**点数计费**;运营用 django-admin 管理用户、点数和记录。 +`cmhub` 是一个**自助用户端 + 计费型 AI 能力网关 + 运营后台**三合一服务:终端用户自助注册、扫码充值、管理 API Key;用 API Key 调用「生成标题」「生成图片」「多图理解」等 HTTP API,按**点数计费**;运营用 django-admin 管理用户、点数和记录。 ## 它做什么 - 用户端(自助):公开首页、注册/登录、扫码充值、查看充值记录/剩余点数/消费记录、查看可用模型、生成与删除 API Key、下载桌面端(新用户注册成功赠送 100 点试用点数)。 -- 对外提供 HTTP API:生成标题、生成图片(旧同步接口 + 新异步提交/轮询接口)、查询点数余额、查询可用能力别名。 +- 对外提供 HTTP API:生成标题、生成图片(旧同步接口 + 新异步提交/轮询接口)、多张图片理解并返回文字、查询点数余额、查询可用能力别名。 - 按「操作类型 + 能力别名(+ 可选分辨率)」计费,调用消耗不同点数;余额不足返回「点数不足,请先充值」。 - **预付费点数模型**:用户在外部支付系统充值,付款成功由支付系统回调本服务,按汇率把金额转成点数存在本地;之后调用直接扣本地点数,扣费与上游解耦、低延迟。 - django-admin 运营后台:管理注册用户、点数余额、计费规则、充值订单、点数流水、调用记录。 @@ -27,6 +27,8 @@ 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` 默认别名及默认计费规则。 + > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。 ## 启动 diff --git a/apps/ai/aliases.py b/apps/ai/aliases.py index b737b4f..20d9f90 100644 --- a/apps/ai/aliases.py +++ b/apps/ai/aliases.py @@ -18,15 +18,16 @@ class ModelCapabilityError(AliasResolutionError): REQUIRED_CAPABILITIES = { - ModelAlias.OperationType.TITLE: "text", - ModelAlias.OperationType.IMAGE: "image", + ModelAlias.OperationType.TITLE: frozenset({"text"}), + ModelAlias.OperationType.IMAGE: frozenset({"image"}), + ModelAlias.OperationType.VISION: frozenset({"text", "vision"}), } def resolve_model_alias(operation_type: str, alias: str | None = None) -> ModelAlias: """Resolve an external capability alias to an active ModelAlias row.""" - required_capability = REQUIRED_CAPABILITIES.get(operation_type) - if required_capability is None: + required_capabilities = REQUIRED_CAPABILITIES.get(operation_type) + if required_capabilities is None: raise AliasResolutionError(f"unsupported operation_type: {operation_type}") queryset = ModelAlias.objects.select_related("ai_model").filter( @@ -47,10 +48,11 @@ def resolve_model_alias(operation_type: str, alias: str | None = None) -> ModelA ai_model: AiModel = model_alias.ai_model capabilities = ai_model.capabilities_set() - if required_capability not in capabilities: + missing_capabilities = required_capabilities - capabilities + if missing_capabilities: raise ModelCapabilityError( f"alias {model_alias.alias} maps to model {ai_model.name} without " - f"{required_capability} capability" + f"{', '.join(sorted(missing_capabilities))} capability" ) return model_alias diff --git a/apps/ai/catalog.py b/apps/ai/catalog.py index af19203..35f19c3 100644 --- a/apps/ai/catalog.py +++ b/apps/ai/catalog.py @@ -14,13 +14,15 @@ PUBLIC_DEFAULT_RESOLUTION = "default" def _has_required_capability(model_alias: ModelAlias) -> bool: - required_capability = REQUIRED_CAPABILITIES.get(model_alias.operation_type) - if required_capability is None: + required_capabilities = REQUIRED_CAPABILITIES.get(model_alias.operation_type) + if required_capabilities is None: return False - return required_capability in model_alias.ai_model.capabilities_set() + return required_capabilities.issubset(model_alias.ai_model.capabilities_set()) def _requires_image_input(ai_model: AiModel, operation_type: str) -> bool: + if operation_type == ModelAlias.OperationType.VISION: + return True if operation_type != ModelAlias.OperationType.IMAGE: return False try: diff --git a/apps/ai/migrations/0005_alter_modelalias_operation_type.py b/apps/ai/migrations/0005_alter_modelalias_operation_type.py new file mode 100644 index 0000000..5b50b59 --- /dev/null +++ b/apps/ai/migrations/0005_alter_modelalias_operation_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-16 03:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai', '0004_alter_aiconfigauditlog_action_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='modelalias', + name='operation_type', + field=models.CharField(choices=[('title', '生成标题'), ('image', '生成图片'), ('vision', '图片理解')], max_length=32, verbose_name='操作类型'), + ), + ] diff --git a/apps/ai/models.py b/apps/ai/models.py index 504b534..db2c843 100644 --- a/apps/ai/models.py +++ b/apps/ai/models.py @@ -92,6 +92,7 @@ class ModelAlias(models.Model): class OperationType(models.TextChoices): TITLE = "title", "生成标题" IMAGE = "image", "生成图片" + VISION = "vision", "图片理解" alias = models.SlugField("能力别名", max_length=64) operation_type = models.CharField("操作类型", max_length=32, choices=OperationType.choices) diff --git a/apps/ai/providers/__init__.py b/apps/ai/providers/__init__.py index 532322d..41b9203 100644 --- a/apps/ai/providers/__init__.py +++ b/apps/ai/providers/__init__.py @@ -4,6 +4,7 @@ from .base import ( AiProviderError, AiResponseParseError, ImageGenerationResult, + MultimodalImage, Provider, ResolvedModel, TextGenerationResult, @@ -16,6 +17,7 @@ __all__ = [ "AiProviderError", "AiResponseParseError", "ImageGenerationResult", + "MultimodalImage", "Provider", "ResolvedModel", "TextGenerationResult", diff --git a/apps/ai/providers/base.py b/apps/ai/providers/base.py index a8d7305..e27a8a4 100644 --- a/apps/ai/providers/base.py +++ b/apps/ai/providers/base.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Mapping, Protocol +from typing import Any, Mapping, Protocol, Sequence class AiProviderError(RuntimeError): @@ -78,6 +78,12 @@ class ImageGenerationResult: raw: Mapping[str, Any] +@dataclass(frozen=True) +class MultimodalImage: + data: bytes + mime_type: str = "image/png" + + class Provider(Protocol): def capabilities(self) -> set[str]: ... @@ -108,6 +114,16 @@ class Provider(Protocol): ) -> ImageGenerationResult: ... + def analyze_images( + self, + prompt: str, + model: ResolvedModel, + *, + images: Sequence[MultimodalImage], + parameters: Mapping[str, Any] | None = None, + ) -> TextGenerationResult: + ... + def validate_model_config(model: ResolvedModel) -> None: errors = [] diff --git a/apps/ai/providers/openai_compatible.py b/apps/ai/providers/openai_compatible.py index f8b000a..91b4e9d 100644 --- a/apps/ai/providers/openai_compatible.py +++ b/apps/ai/providers/openai_compatible.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Mapping +from typing import Any, Mapping, Sequence import requests @@ -8,6 +8,7 @@ from .base import ( AiCapabilityError, AiResponseParseError, ImageGenerationResult, + MultimodalImage, ResolvedModel, TextGenerationResult, validate_model_config, @@ -18,6 +19,7 @@ from .utils import ( API_IMAGES, API_IMAGES_EDITS, extract_image_from_response, + extract_raw_text, extract_text_from_response, extract_titles_from_response, image_request_timeout, @@ -166,6 +168,37 @@ class ChatCompletionsProvider(BaseHttpProvider): raise AiResponseParseError("AI response did not contain an image") return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw) + def analyze_images( + self, + prompt: str, + model: ResolvedModel, + *, + images: Sequence[MultimodalImage], + parameters: Mapping[str, Any] | None = None, + ) -> TextGenerationResult: + validate_model_config(model) + if not images: + raise AiCapabilityError("vision analysis requires at least one image") + url = normalize_api_url(model.url, API_CHAT) + payload = build_chat_vision_payload( + model, + prompt, + images=images, + parameters=parameters, + ) + response = self.session.post( + url, + headers=self._headers(model, json=True), + json=payload, + timeout=self._timeout(model, "1K"), + ) + response.raise_for_status() + raw = response.json() + text = extract_raw_text(raw).strip() + if not text: + raise AiResponseParseError("AI response did not contain text") + return TextGenerationResult(text=text, titles=(), model_used=model.model, raw=raw) + class GeminiProvider(ChatCompletionsProvider): def generate_text( @@ -241,6 +274,37 @@ class GeminiProvider(ChatCompletionsProvider): raise AiResponseParseError("AI response did not contain an image") return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw) + def analyze_images( + self, + prompt: str, + model: ResolvedModel, + *, + images: Sequence[MultimodalImage], + parameters: Mapping[str, Any] | None = None, + ) -> TextGenerationResult: + validate_model_config(model) + if not images: + raise AiCapabilityError("vision analysis requires at least one image") + url = normalize_api_url(model.url, API_GEMINI).replace("{model}", model.model) + payload = build_gemini_vision_payload( + model, + prompt, + images=images, + parameters=parameters, + ) + response = self.session.post( + url, + headers=self._headers(model, json=True), + json=payload, + timeout=self._timeout(model, "1K"), + ) + response.raise_for_status() + raw = response.json() + text = extract_raw_text(raw).strip() + if not text: + raise AiResponseParseError("AI response did not contain text") + return TextGenerationResult(text=text, titles=(), model_used=model.model, raw=raw) + class ImagesGenerationProvider(BaseHttpProvider): def capabilities(self) -> set[str]: @@ -249,6 +313,9 @@ class ImagesGenerationProvider(BaseHttpProvider): def generate_text(self, *args: Any, **kwargs: Any) -> TextGenerationResult: raise AiCapabilityError("images generation provider cannot generate text") + def analyze_images(self, *args: Any, **kwargs: Any) -> TextGenerationResult: + raise AiCapabilityError("images generation provider cannot analyze images") + def generate_image( self, prompt: str, @@ -298,6 +365,9 @@ class ImagesEditsProvider(BaseHttpProvider): def generate_text(self, *args: Any, **kwargs: Any) -> TextGenerationResult: raise AiCapabilityError("images edits provider cannot generate text") + def analyze_images(self, *args: Any, **kwargs: Any) -> TextGenerationResult: + raise AiCapabilityError("images edits provider cannot analyze images") + def generate_image( self, prompt: str, @@ -384,6 +454,32 @@ def build_chat_image_payload( ) +def build_chat_vision_payload( + model: ResolvedModel, + prompt: str, + *, + images: Sequence[MultimodalImage], + parameters: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + content: list[dict[str, Any]] = [{"type": "text", "text": prompt}] + content.extend( + { + "type": "image_url", + "image_url": { + "url": image_bytes_to_data_url(image.data, image.mime_type), + }, + } + for image in images + ) + payload: dict[str, Any] = { + "model": model.model, + "messages": [{"role": "user", "content": content}], + "stream": False, + } + apply_extra_body(payload, model, parameters) + return payload + + def build_gemini_payload( model: ResolvedModel, prompt: str, @@ -406,6 +502,26 @@ def build_gemini_payload( return payload +def build_gemini_vision_payload( + model: ResolvedModel, + prompt: str, + *, + images: Sequence[MultimodalImage], + parameters: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + parts: list[dict[str, Any]] = [{"text": prompt}] + for image in images: + data_url = image_bytes_to_data_url(image.data, image.mime_type) + mime_type, data = split_data_url(data_url) + parts.append({"inlineData": {"mimeType": mime_type, "data": data}}) + payload: dict[str, Any] = { + "contents": [{"parts": parts}], + "generationConfig": {"responseModalities": ["TEXT"]}, + } + apply_extra_body(payload, model, parameters) + return payload + + def apply_extra_body( payload: dict[str, Any], model: ResolvedModel, diff --git a/apps/ai/tests.py b/apps/ai/tests.py index 72d1ef1..77eed59 100644 --- a/apps/ai/tests.py +++ b/apps/ai/tests.py @@ -11,8 +11,18 @@ from apps.ai.admin import AiConfigAuditLogAdmin, AiModelAdmin, ModelAliasAdmin from apps.ai.aliases import AliasNotFoundError, ModelCapabilityError, resolve_alias from apps.ai.importers import import_ai_models_config from apps.ai.models import AiConfigAuditLog, AiModel, ModelAlias -from apps.ai.providers import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type -from apps.ai.providers.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider +from apps.ai.providers import ( + AiCapabilityError, + MultimodalImage, + ResolvedModel, + get_provider, + resolve_api_type, +) +from apps.ai.providers.openai_compatible import ( + ChatCompletionsProvider, + GeminiProvider, + ImagesEditsProvider, +) from apps.ai.providers.utils import image_request_timeout, resolution_to_size @@ -255,6 +265,85 @@ class ChatCompletionsProviderTests(SimpleTestCase): self.assertEqual(session.posts[0]["timeout"], (30, 600)) + def test_analyze_images_builds_ordered_payload_and_preserves_full_text(self): + session = FakeSession( + FakeResponse( + { + "choices": [ + { + "message": { + "content": "第一张是正面图。\n第二张是细节图。" + } + } + ] + } + ) + ) + provider = ChatCompletionsProvider(session=session) + model = ResolvedModel( + name="Vision text", + url="https://api.vectorengine.ai/v1", + model="vision-model", + api_key="test-key", + api_type="chat", + ) + + result = provider.analyze_images( + "比较两张商品图", + model, + images=( + MultimodalImage(b"first-image", "image/jpeg"), + MultimodalImage(b"second-image", "image/png"), + ), + parameters={"temperature": 0.2, "messages": []}, + ) + + self.assertEqual(result.text, "第一张是正面图。\n第二张是细节图。") + self.assertEqual(result.titles, ()) + content = session.posts[0]["json"]["messages"][0]["content"] + self.assertEqual(content[0], {"type": "text", "text": "比较两张商品图"}) + self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,")) + self.assertTrue(content[2]["image_url"]["url"].startswith("data:image/png;base64,")) + self.assertEqual(session.posts[0]["json"]["temperature"], 0.2) + + def test_gemini_analyze_images_builds_ordered_inline_data(self): + session = FakeSession( + FakeResponse( + { + "candidates": [ + {"content": {"parts": [{"text": "多图分析结果"}]}} + ] + } + ) + ) + provider = GeminiProvider(session=session) + model = ResolvedModel( + name="Gemini vision", + url="https://gemini.example.com", + model="gemini-vision", + api_key="test-key", + api_type="gemini", + ) + + result = provider.analyze_images( + "理解这些图片", + model, + images=( + MultimodalImage(b"one", "image/webp"), + MultimodalImage(b"two", "image/jpeg"), + ), + ) + + self.assertEqual(result.text, "多图分析结果") + parts = session.posts[0]["json"]["contents"][0]["parts"] + self.assertEqual(parts[0], {"text": "理解这些图片"}) + self.assertEqual(parts[1]["inlineData"]["mimeType"], "image/webp") + self.assertEqual(parts[2]["inlineData"]["mimeType"], "image/jpeg") + self.assertEqual( + session.posts[0]["json"]["generationConfig"]["responseModalities"], + ["TEXT"], + ) + class ImagesEditsProviderTests(SimpleTestCase): def test_generate_image_builds_multipart_request_and_parses_base64(self): @@ -344,6 +433,13 @@ class ImagesEditsProviderTests(SimpleTestCase): with self.assertRaises(AiCapabilityError): provider.generate_text("Generate title", model) + with self.assertRaises(AiCapabilityError): + provider.analyze_images( + "Analyze image", + model, + images=(MultimodalImage(b"source-image"),), + ) + @override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) class AiModelEncryptionTests(TestCase): @@ -440,6 +536,29 @@ class AliasResolutionTests(TestCase): self.assertEqual(resolved.model, "gpt-image-2") self.assertIn("image", resolved.capabilities) + def test_resolve_vision_alias_requires_text_and_vision_capabilities(self): + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.VISION, + alias="vision-standard", + ai_model=self.text_model, + is_default=True, + ) + + resolved = resolve_alias(ModelAlias.OperationType.VISION) + + self.assertEqual(resolved.model, "gpt-5.5") + self.assertTrue({"text", "vision"}.issubset(resolved.capabilities)) + + def test_resolve_vision_alias_rejects_vision_model_without_text(self): + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.VISION, + alias="bad-vision", + ai_model=self.image_model, + ) + + with self.assertRaises(ModelCapabilityError): + resolve_alias(ModelAlias.OperationType.VISION, "bad-vision") + def test_resolve_alias_rejects_capability_mismatch(self): ModelAlias.objects.create( operation_type=ModelAlias.OperationType.TITLE, diff --git a/apps/api/generation.py b/apps/api/generation.py index 72fa41d..8d0f22d 100644 --- a/apps/api/generation.py +++ b/apps/api/generation.py @@ -6,7 +6,7 @@ import ipaddress import socket from dataclasses import dataclass, field from time import perf_counter -from typing import Any, Callable, Mapping +from typing import Any, Callable, Mapping, Sequence from urllib.parse import urljoin, urlsplit import requests @@ -19,7 +19,12 @@ from apps.ai.aliases import ( REQUIRED_CAPABILITIES, resolve_model_alias, ) -from apps.ai.providers import AiCapabilityError, AiProviderError, get_provider +from apps.ai.providers import ( + AiCapabilityError, + AiProviderError, + MultimodalImage, + get_provider, +) from apps.billing.models import CallRecord, normalize_resolution from apps.billing.pricing import NoPricingRuleError, calculate_points_cost from apps.billing.services import ( @@ -68,6 +73,7 @@ class GenerationInput: image_url: str = "" image_base64: str = "" aspect_ratio: str = "1:1" + images: tuple[Mapping[str, Any], ...] = field(default_factory=tuple) @dataclass(frozen=True) @@ -80,6 +86,7 @@ class PreparedGeneration: resolution: str parameters: dict[str, Any] image_input: ImageInput | None + image_inputs: tuple[ImageInput, ...] model_alias: Any resolved_model: Any provider: Any @@ -105,6 +112,7 @@ class GenerationResult: call_record: CallRecord titles: tuple[str, ...] = field(default_factory=tuple) image_url: str = "" + text: str = "" def as_response_data(self) -> dict[str, Any]: common = { @@ -116,6 +124,8 @@ class GenerationResult: } if self.operation_type == CallRecord.OperationType.TITLE: return {"titles": list(self.titles), **common} + if self.operation_type == CallRecord.OperationType.VISION: + return {"text": self.text, **common} return {"image_url": self.image_url, **common} @@ -165,6 +175,21 @@ def generate_image_response( return result.as_response_data() +def analyze_images_response(*, user, api_key, request_data: Mapping[str, Any]) -> dict: + result = run_synchronous_generation( + GenerationInput( + user=user, + api_key=api_key, + operation_type=CallRecord.OperationType.VISION, + prompt=request_data["prompt"], + alias=request_data.get("model") or None, + parameters=dict(request_data.get("parameters") or {}), + images=tuple(dict(item) for item in request_data.get("images") or ()), + ) + ) + return result.as_response_data() + + def run_synchronous_generation( generation_input: GenerationInput, *, @@ -180,7 +205,11 @@ def run_synchronous_generation( def prepare_generation(generation_input: GenerationInput) -> PreparedGeneration: operation_type = normalize_operation_type(generation_input.operation_type) - resolution = normalize_resolution(generation_input.resolution or "1K") or "1K" + resolution = ( + "" + if operation_type == CallRecord.OperationType.VISION + else normalize_resolution(generation_input.resolution or "1K") or "1K" + ) parameters = dict(generation_input.parameters or {}) prompt = str(generation_input.prompt or "") @@ -189,12 +218,17 @@ def prepare_generation(generation_input: GenerationInput) -> PreparedGeneration: api_key=generation_input.api_key, prompt=prompt, ) - image_input = load_image_input( - { - "image_base64": generation_input.image_base64, - "image_url": generation_input.image_url, - } - ) + if operation_type == CallRecord.OperationType.VISION: + image_input = None + image_inputs = load_vision_image_inputs(generation_input.images) + else: + image_input = load_image_input( + { + "image_base64": generation_input.image_base64, + "image_url": generation_input.image_url, + } + ) + image_inputs = () model_alias = resolve_model_alias_or_raise(operation_type, generation_input.alias) resolved_model = resolved_model_or_raise(model_alias) @@ -215,6 +249,7 @@ def prepare_generation(generation_input: GenerationInput) -> PreparedGeneration: resolution=resolution, parameters=parameters, image_input=image_input, + image_inputs=image_inputs, model_alias=model_alias, resolved_model=resolved_model, provider=provider, @@ -253,6 +288,8 @@ def execute_precharged_generation( started = perf_counter() if prepared.operation_type == CallRecord.OperationType.TITLE: result = execute_title_generation(precharged, started) + elif prepared.operation_type == CallRecord.OperationType.VISION: + result = execute_vision_generation(precharged, started) else: result = execute_image_generation( precharged, @@ -358,11 +395,44 @@ def execute_image_generation( ) +def execute_vision_generation( + precharged: PrechargedGeneration, + started: float, +) -> GenerationResult: + prepared = precharged.prepared + generation = prepared.provider.analyze_images( + prepared.prompt, + prepared.resolved_model, + images=tuple( + MultimodalImage(data=image.data, mime_type=image.mime_type) + for image in prepared.image_inputs + ), + parameters=prepared.parameters, + ) + latency_ms = elapsed_ms(started) + text = str(generation.text or "").strip() + call_record = mark_call_success( + precharged.call_record, + result_summary=summarize_text(text), + upstream_latency_ms=latency_ms, + ) + return GenerationResult( + operation_type=prepared.operation_type, + alias=prepared.alias, + model_used=generation.model_used, + points_cost=precharged.points_cost, + points_balance=precharged.points_balance_after_charge, + call_record=call_record, + text=text, + ) + + def normalize_operation_type(operation_type: str) -> str: normalized = str(operation_type or "").strip() if normalized not in { CallRecord.OperationType.TITLE, CallRecord.OperationType.IMAGE, + CallRecord.OperationType.VISION, }: raise ValueError(f"Unsupported generation operation type: {operation_type}") return normalized @@ -392,8 +462,8 @@ def resolve_model_alias_or_raise(operation_type: str, alias: str | None): def ensure_provider_supports(provider, operation_type: str) -> None: - required_capability = REQUIRED_CAPABILITIES[operation_type] - if required_capability not in provider.capabilities(): + required_capabilities = REQUIRED_CAPABILITIES[operation_type] + if not required_capabilities.issubset(provider.capabilities()): raise ApiRequestError( "model_not_allowed", "该模型不支持此操作", @@ -471,7 +541,57 @@ def load_image_input(data: Mapping[str, Any]) -> ImageInput | None: return None -def decode_image_input(value: str) -> ImageInput: +def load_vision_image_inputs( + items: Sequence[Mapping[str, Any]], +) -> tuple[ImageInput, ...]: + 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)), + ) + if not items: + raise ApiRequestError("bad_request", "images 至少需要一张图片", status.HTTP_400_BAD_REQUEST) + if len(items) > max_images: + raise ApiRequestError( + "bad_request", + f"单次最多上传 {max_images} 张图片", + status.HTTP_400_BAD_REQUEST, + ) + + image_inputs = [] + total_bytes = 0 + for item in items: + raw_base64 = str(item.get("image_base64") or "").strip() + image_url = str(item.get("image_url") or "").strip() + if bool(raw_base64) == bool(image_url): + raise ApiRequestError( + "bad_request", + "每张图片必须且只能提供 image_url 或 image_base64", + status.HTTP_400_BAD_REQUEST, + ) + image_input = ( + decode_image_input(raw_base64, max_bytes=max_image_bytes) + if raw_base64 + else download_image_input(image_url, max_bytes=max_image_bytes) + ) + if not image_input.mime_type.lower().startswith("image/"): + raise ApiRequestError("bad_request", "图片格式无效", status.HTTP_400_BAD_REQUEST) + total_bytes += len(image_input.data) + if total_bytes > max_total_bytes: + raise ApiRequestError( + "bad_request", + "图片总大小超过限制", + status.HTTP_400_BAD_REQUEST, + ) + image_inputs.append(image_input) + return tuple(image_inputs) + + +def decode_image_input(value: str, *, max_bytes: int | None = None) -> ImageInput: mime_type = "image/png" encoded = value if value.startswith("data:"): @@ -479,16 +599,20 @@ def decode_image_input(value: str) -> ImageInput: raise ApiRequestError("bad_request", "image_base64 格式无效", status.HTTP_400_BAD_REQUEST) prefix, encoded = value.split(",", 1) mime_type = prefix[len("data:") :].split(";", 1)[0] or mime_type + if max_bytes is not None and len(encoded) > ((max_bytes + 2) // 3) * 4: + raise ApiRequestError("bad_request", "图片过大", status.HTTP_400_BAD_REQUEST) try: image = base64.b64decode(encoded, validate=True) except (binascii.Error, ValueError) as exc: raise ApiRequestError("bad_request", "image_base64 格式无效", status.HTTP_400_BAD_REQUEST) from exc if not image: raise ApiRequestError("bad_request", "image_base64 不能为空", status.HTTP_400_BAD_REQUEST) + if max_bytes is not None and len(image) > max_bytes: + raise ApiRequestError("bad_request", "图片过大", status.HTTP_400_BAD_REQUEST) return ImageInput(data=image, mime_type=mime_type, filename=filename_for_mime(mime_type)) -def download_image_input(url: str) -> ImageInput: +def download_image_input(url: str, *, max_bytes: int | None = None) -> ImageInput: session = requests.Session() session.trust_env = False current_url = validated_image_url(url) @@ -522,7 +646,7 @@ def download_image_input(url: str) -> ImageInput: 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) + image = read_limited_image_response(response, max_bytes=max_bytes) if not image: raise ApiRequestError("bad_request", "image_url 图片内容为空", status.HTTP_400_BAD_REQUEST) return ImageInput( @@ -593,12 +717,19 @@ def is_redirect_response(response) -> bool: return 300 <= int(getattr(response, "status_code", 0)) < 400 -def read_limited_image_response(response) -> bytes: - max_bytes = max(1, int(getattr(settings, "IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024))) +def read_limited_image_response(response, *, max_bytes: int | None = None) -> bytes: + byte_limit = max( + 1, + int( + max_bytes + if max_bytes is not None + else getattr(settings, "IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024) + ), + ) content_length = response.headers.get("Content-Length") if content_length: try: - if int(content_length) > max_bytes: + if int(content_length) > byte_limit: raise ApiRequestError("bad_request", "image_url 图片过大", status.HTTP_400_BAD_REQUEST) except ValueError: pass @@ -609,7 +740,7 @@ def read_limited_image_response(response) -> bytes: if not chunk: continue total += len(chunk) - if total > max_bytes: + if total > byte_limit: raise ApiRequestError("bad_request", "image_url 图片过大", status.HTTP_400_BAD_REQUEST) chunks.append(chunk) return b"".join(chunks) @@ -629,5 +760,9 @@ def summarize_titles(titles: list[str], text: str) -> str: return summary[:500] +def summarize_text(text: str) -> str: + return str(text or "").strip()[:500] + + def elapsed_ms(started: float) -> int: return int((perf_counter() - started) * 1000) diff --git a/apps/api/serializers.py b/apps/api/serializers.py index 112b87e..6fcb8d4 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -50,6 +50,38 @@ class GenerateImageRequestSerializer(serializers.Serializer): parameters = serializers.DictField(required=False, default=dict) +class VisionImageInputSerializer(serializers.Serializer): + image_url = serializers.URLField(required=False, allow_blank=True) + image_base64 = serializers.CharField(required=False, allow_blank=True) + + def validate(self, attrs): + has_url = bool(str(attrs.get("image_url") or "").strip()) + has_base64 = bool(str(attrs.get("image_base64") or "").strip()) + if has_url == has_base64: + raise serializers.ValidationError( + "每张图片必须且只能提供 image_url 或 image_base64。" + ) + return attrs + + +class AnalyzeImagesRequestSerializer(serializers.Serializer): + prompt = serializers.CharField(trim_whitespace=True, allow_blank=False) + model = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=True, + max_length=64, + ) + images = VisionImageInputSerializer(many=True, allow_empty=False) + parameters = serializers.DictField(required=False, default=dict) + + def validate_images(self, value): + max_images = max(1, int(settings.VISION_MAX_IMAGES)) + if len(value) > max_images: + raise serializers.ValidationError(f"单次最多上传 {max_images} 张图片。") + return value + + class RechargeCreateRequestSerializer(serializers.Serializer): amount = serializers.DecimalField( max_digits=12, diff --git a/apps/api/tests.py b/apps/api/tests.py index 4f19d80..1e36ab0 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -331,6 +331,12 @@ class ModelsCatalogApiTests(TestCase): url="https://provider-secret.example/v1/images/edits", model_sku="secret-sku-image-2", ) + vision_alias = self.create_alias( + alias="vision-standard", + operation_type=ModelAlias.OperationType.VISION, + capabilities=["text", "vision"], + model_sku="secret-sku-vision", + ) PricingRule.objects.create( operation_type=title_alias.operation_type, alias=title_alias.alias, @@ -349,13 +355,19 @@ class ModelsCatalogApiTests(TestCase): resolution="1k", points_cost=12, ) + PricingRule.objects.create( + operation_type=vision_alias.operation_type, + alias=vision_alias.alias, + resolution="", + points_cost=3, + ) response = self.client.get(self.url, **self.auth_header()) self.assertEqual(response.status_code, 200) self.assertNotIn(GenerateRateThrottle, ModelsView.throttle_classes) models = {item["alias"]: item for item in response.data["models"]} - self.assertEqual(set(models), {"title-standard", "image-edit"}) + self.assertEqual(set(models), {"title-standard", "image-edit", "vision-standard"}) self.assertEqual( set(models["title-standard"]), { @@ -384,6 +396,14 @@ class ModelsCatalogApiTests(TestCase): {"resolution": "1K", "points_cost": 12}, ], ) + self.assertEqual(models["vision-standard"]["operation_type"], "vision") + self.assertEqual(models["vision-standard"]["capabilities"], ["text", "vision"]) + self.assertTrue(models["vision-standard"]["requires_image"]) + self.assertEqual(models["vision-standard"]["pricing_status"], "priced") + self.assertEqual( + models["vision-standard"]["prices"], + [{"resolution": "default", "points_cost": 3}], + ) response_body = json.dumps(response.data, ensure_ascii=False) self.assertNotIn("secret-sku", response_body) self.assertNotIn("provider-secret.example", response_body) @@ -1056,8 +1076,10 @@ class FakeGenerationProvider: self._capabilities = set(capabilities or {"text", "image", "vision"}) self.text_calls = [] self.image_calls = [] + self.vision_calls = [] self.text_error = None self.image_error = None + self.vision_error = None def capabilities(self): return set(self._capabilities) @@ -1083,6 +1105,17 @@ class FakeGenerationProvider: raw={"b64_json": "SECRET_RAW_SHOULD_NOT_BE_STORED"}, ) + def analyze_images(self, prompt, model, **kwargs): + self.vision_calls.append({"prompt": prompt, "model": model, **kwargs}) + if self.vision_error is not None: + raise self.vision_error + return TextGenerationResult( + text="第一张展示商品正面。\n第二张展示商品细节。", + titles=(), + model_used=model.model, + raw={"secret": "SECRET_RAW_SHOULD_NOT_BE_STORED"}, + ) + class FakeImageUrlResponse: def __init__(self, *, status_code=200, headers=None, chunks=()): @@ -1143,14 +1176,26 @@ class GenerateApiTests(TestCase): model=f"gpt-image-{suffix}", capabilities=["image", "vision"], ) + self.vision_model = self.create_ai_model( + name=f"vision-model-{suffix}", + model=f"gpt-vision-{suffix}", + capabilities=["text", "vision"], + ) self.title_alias = f"title-standard-{suffix}" self.image_alias = f"image-hd-{suffix}" + self.vision_alias = f"vision-standard-{suffix}" ModelAlias.objects.create( operation_type=ModelAlias.OperationType.TITLE, alias=self.title_alias, ai_model=self.title_model, is_default=True, ) + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.VISION, + alias=self.vision_alias, + ai_model=self.vision_model, + is_default=True, + ) ModelAlias.objects.create( operation_type=ModelAlias.OperationType.IMAGE, alias=self.image_alias, @@ -1168,6 +1213,11 @@ class GenerateApiTests(TestCase): resolution="1K", points_cost=10, ) + PricingRule.objects.create( + operation_type=CallRecord.OperationType.VISION, + alias=self.vision_alias, + points_cost=3, + ) def create_ai_model(self, *, name, model, capabilities): ai_model = AiModel( @@ -1314,6 +1364,283 @@ class GenerateApiTests(TestCase): 1, ) + def test_analyze_images_supports_single_image_with_explicit_alias(self): + encoded = base64.b64encode(b"single-image").decode("ascii") + + response = self.post_with_provider( + "/api/v1/analyze/images", + { + "prompt": "描述这张商品图", + "model": self.vision_alias, + "images": [{"image_base64": encoded}], + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["alias"], self.vision_alias) + self.assertEqual(response.data["points_cost"], 3) + self.assertEqual(len(self.provider.vision_calls), 1) + self.assertEqual( + [image.data for image in self.provider.vision_calls[0]["images"]], + [b"single-image"], + ) + + def test_analyze_images_supports_ordered_mixed_sources_and_charges_once(self): + first = base64.b64encode(b"first-image").decode("ascii") + response_from_url = FakeImageUrlResponse( + headers={"Content-Type": "image/jpeg"}, + chunks=(b"second-", b"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=response_from_url, + ), + ): + response = self.post_with_provider( + "/api/v1/analyze/images", + { + "prompt": "比较两张商品图", + "images": [ + {"image_base64": f"data:image/png;base64,{first}"}, + {"image_url": "https://images.example.test/detail.jpg"}, + ], + "parameters": {"temperature": 0.2}, + }, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["text"], "第一张展示商品正面。\n第二张展示商品细节。") + self.assertEqual(response.data["alias"], self.vision_alias) + self.assertEqual(response.data["model_used"], self.vision_model.model) + self.assertEqual(response.data["points_cost"], 3) + self.assertEqual(response.data["points_balance"], 97) + self.assertEqual(len(self.provider.vision_calls), 1) + images = self.provider.vision_calls[0]["images"] + self.assertEqual([image.data for image in images], [b"first-image", b"second-image"]) + self.assertEqual( + [image.mime_type for image in images], + ["image/png", "image/jpeg"], + ) + + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 97) + call = CallRecord.objects.get(pk=response.data["call_id"]) + self.assertEqual(call.operation_type, CallRecord.OperationType.VISION) + self.assertEqual(call.status, CallRecord.Status.SUCCESS) + self.assertEqual(call.resolution, "") + self.assertEqual(call.result_summary, response.data["text"]) + self.assertNotIn("first-image", call.result_summary) + self.assertNotIn("SECRET_RAW", call.result_summary) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.CONSUME, + ).count(), + 1, + ) + + def test_analyze_images_requires_nonempty_exclusive_image_sources(self): + empty = self.post_with_provider( + "/api/v1/analyze/images", + {"prompt": "分析图片", "images": []}, + ) + both = self.post_with_provider( + "/api/v1/analyze/images", + { + "prompt": "分析图片", + "images": [ + { + "image_url": "https://images.example.test/input.jpg", + "image_base64": "aW1hZ2U=", + } + ], + }, + ) + + self.assertEqual(empty.status_code, 400) + self.assertEqual(empty.data["error"]["code"], "bad_request") + self.assertEqual(both.status_code, 400) + self.assertEqual(both.data["error"]["code"], "bad_request") + self.assertEqual(self.provider.vision_calls, []) + self.assert_generation_not_charged() + + @override_settings(VISION_MAX_IMAGES=1) + def test_analyze_images_rejects_too_many_images_before_charge(self): + encoded = base64.b64encode(b"image").decode("ascii") + + response = self.post_with_provider( + "/api/v1/analyze/images", + { + "prompt": "分析图片", + "images": [ + {"image_base64": encoded}, + {"image_base64": encoded}, + ], + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"]["code"], "bad_request") + self.assert_generation_not_charged() + + @override_settings(VISION_MAX_IMAGE_BYTES=3, VISION_MAX_TOTAL_BYTES=10) + def test_analyze_images_rejects_oversized_single_image_before_charge(self): + encoded = base64.b64encode(b"four").decode("ascii") + + response = self.post_with_provider( + "/api/v1/analyze/images", + {"prompt": "分析图片", "images": [{"image_base64": encoded}]}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"]["code"], "bad_request") + self.assert_generation_not_charged() + + @override_settings(VISION_MAX_IMAGE_BYTES=10, VISION_MAX_TOTAL_BYTES=5) + def test_analyze_images_rejects_oversized_total_before_charge(self): + encoded = base64.b64encode(b"abc").decode("ascii") + + response = self.post_with_provider( + "/api/v1/analyze/images", + { + "prompt": "分析图片", + "images": [ + {"image_base64": encoded}, + {"image_base64": encoded}, + ], + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"]["code"], "bad_request") + self.assert_generation_not_charged() + + def test_analyze_images_rejects_private_image_url_before_charge(self): + with patch( + "apps.api.generation.socket.getaddrinfo", + return_value=dns_result("127.0.0.1"), + ): + response = self.post_with_provider( + "/api/v1/analyze/images", + { + "prompt": "分析图片", + "images": [{"image_url": "http://internal.example.test/input.jpg"}], + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"]["code"], "bad_request") + self.assertEqual(self.provider.vision_calls, []) + self.assert_generation_not_charged() + + def test_analyze_images_requires_api_key(self): + encoded = base64.b64encode(b"image").decode("ascii") + + response = self.client.post( + "/api/v1/analyze/images", + {"prompt": "分析图片", "images": [{"image_base64": encoded}]}, + format="json", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.data["error"]["code"], "unauthorized") + self.assert_generation_not_charged() + + @override_settings( + MODERATION_ENABLED=True, + MODERATION_PROVIDER="keyword", + MODERATION_CACHE_VERSION_KEY="test:api:vision:moderation:version", + ) + def test_analyze_images_blocks_prompt_before_loading_images_or_charge(self): + SensitiveWord.objects.create(word="敏感词", category="policy") + + with ( + patch("apps.api.generation.decode_image_input") as decode_image, + patch("apps.api.generation.download_image_input") as download_image, + ): + response = self.post_with_provider( + "/api/v1/analyze/images", + { + "prompt": "分析敏-感​词图片", + "images": [ + {"image_url": "https://images.example.test/input.jpg"} + ], + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"]["code"], "content_blocked") + decode_image.assert_not_called() + download_image.assert_not_called() + self.assert_generation_not_charged() + + def test_analyze_images_rejects_model_or_provider_without_text_vision(self): + bad_alias = f"vision-without-text-{uuid.uuid4().hex[:8]}" + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.VISION, + alias=bad_alias, + ai_model=self.image_model, + ) + encoded = base64.b64encode(b"image").decode("ascii") + payload = { + "prompt": "分析图片", + "model": bad_alias, + "images": [{"image_base64": encoded}], + } + + model_rejected = self.post_with_provider("/api/v1/analyze/images", payload) + provider_rejected = self.post_with_provider( + "/api/v1/analyze/images", + {**payload, "model": self.vision_alias}, + provider=FakeGenerationProvider(capabilities={"vision"}), + ) + + self.assertEqual(model_rejected.status_code, 400) + self.assertEqual(model_rejected.data["error"]["code"], "model_not_allowed") + self.assertEqual(provider_rejected.status_code, 400) + self.assertEqual(provider_rejected.data["error"]["code"], "model_not_allowed") + self.assert_generation_not_charged() + + def test_analyze_images_upstream_failure_refunds_once(self): + encoded = base64.b64encode(b"image").decode("ascii") + self.provider.vision_error = requests.Timeout("vision timeout") + + response = self.post_with_provider( + "/api/v1/analyze/images", + { + "prompt": "分析图片", + "model": self.vision_alias, + "images": [{"image_base64": encoded}], + }, + ) + + self.assertEqual(response.status_code, 502) + self.assertEqual(response.data["error"]["code"], "upstream_timeout") + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + call = CallRecord.objects.get(operation_type=CallRecord.OperationType.VISION) + self.assertEqual(call.status, CallRecord.Status.FAILED) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.CONSUME, + ).count(), + 1, + ) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.REFUND, + ).count(), + 1, + ) + def test_generate_image_stores_file_returns_url_and_does_not_store_raw_base64(self): encoded = base64.b64encode(b"input-image").decode("ascii") diff --git a/apps/api/urls.py b/apps/api/urls.py index 6ad6c47..b0fd954 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -2,6 +2,7 @@ from django.urls import path from .views import ( AlipayRechargeCallbackView, + AnalyzeImagesView, BalanceView, ClientLatestReleaseView, GenerateImageTaskDetailView, @@ -23,6 +24,7 @@ urlpatterns = [ name="api-client-release-latest", ), path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"), + path("v1/analyze/images", AnalyzeImagesView.as_view(), name="api-analyze-images"), path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"), path( "v1/generate/image/tasks", diff --git a/apps/api/views.py b/apps/api/views.py index 4c67169..d72c82f 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -15,6 +15,7 @@ from apps.api.authentication import ApiKeyAuthentication from apps.api.errors import api_error from apps.api.generation import ( ApiRequestError, + analyze_images_response, generate_image_response, generate_title_response, ) @@ -25,6 +26,7 @@ from apps.api.image_tasks import ( ) from apps.api.models import ImageGenerationTask from apps.api.serializers import ( + AnalyzeImagesRequestSerializer, GenerateImageRequestSerializer, GenerateTitleRequestSerializer, RechargeCreateRequestSerializer, @@ -97,6 +99,27 @@ class GenerateTitleView(ExternalApiView): return Response(data, status=status.HTTP_200_OK) +class AnalyzeImagesView(ExternalApiView): + throttle_classes = (GenerateRateThrottle,) + + def post(self, request): + serializer = AnalyzeImagesRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + api_error("bad_request", "参数错误"), + status=status.HTTP_400_BAD_REQUEST, + ) + try: + data = analyze_images_response( + user=request.user, + api_key=request.auth, + request_data=serializer.validated_data, + ) + except ApiRequestError as exc: + return Response(exc.as_response_data(), status=exc.http_status) + return Response(data, status=status.HTTP_200_OK) + + class GenerateImageView(ExternalApiView): throttle_classes = (GenerateRateThrottle,) diff --git a/apps/billing/migrations/0008_alter_callrecord_operation_type_and_more.py b/apps/billing/migrations/0008_alter_callrecord_operation_type_and_more.py new file mode 100644 index 0000000..fd6bcbc --- /dev/null +++ b/apps/billing/migrations/0008_alter_callrecord_operation_type_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.15 on 2026-07-16 03:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0007_alter_pointsledger_change_type_signupbonusgrant'), + ] + + operations = [ + migrations.AlterField( + model_name='callrecord', + name='operation_type', + field=models.CharField(choices=[('title', '生成标题'), ('image', '生成图片'), ('vision', '图片理解')], max_length=32, verbose_name='操作类型'), + ), + migrations.AlterField( + model_name='pricingrule', + name='operation_type', + field=models.CharField(choices=[('title', '生成标题'), ('image', '生成图片'), ('vision', '图片理解')], max_length=32, verbose_name='操作类型'), + ), + ] diff --git a/apps/billing/models.py b/apps/billing/models.py index 6157033..8fe84cf 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -14,6 +14,7 @@ class CallRecord(models.Model): class OperationType(models.TextChoices): TITLE = "title", "生成标题" IMAGE = "image", "生成图片" + VISION = "vision", "图片理解" class Status(models.TextChoices): PENDING = "pending", "待处理" diff --git a/apps/portal/templates/portal/models.html b/apps/portal/templates/portal/models.html index a8900a2..e14e014 100644 --- a/apps/portal/templates/portal/models.html +++ b/apps/portal/templates/portal/models.html @@ -30,6 +30,8 @@ 生成标题 {% elif item.operation_type == "image" %} 生成图片 + {% elif item.operation_type == "vision" %} + 图片理解 {% else %} {{ item.operation_type }} {% endif %} diff --git a/apps/portal/tests.py b/apps/portal/tests.py index c0ec16a..34ad350 100644 --- a/apps/portal/tests.py +++ b/apps/portal/tests.py @@ -503,6 +503,12 @@ class PortalAccountFlowTests(TestCase): url="https://provider-secret.example/v1/images/edits", model_sku="secret-sku-image-2", ) + vision_alias = self.create_model_alias( + alias="vision-standard", + operation_type=ModelAlias.OperationType.VISION, + capabilities=["text", "vision"], + model_sku="secret-sku-vision", + ) PricingRule.objects.create( operation_type=title_alias.operation_type, alias=title_alias.alias, @@ -516,12 +522,18 @@ class PortalAccountFlowTests(TestCase): points_cost=12, is_active=False, ) + PricingRule.objects.create( + operation_type=vision_alias.operation_type, + alias=vision_alias.alias, + resolution="", + points_cost=3, + ) self.client.force_login(user) response = self.client.get("/models") self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context["models"]), 2) + self.assertEqual(len(response.context["models"]), 3) self.assertContains(response, "可用模型") self.assertContains(response, "title-standard") self.assertContains(response, "生成标题") @@ -529,6 +541,9 @@ class PortalAccountFlowTests(TestCase): self.assertContains(response, "2 点") self.assertContains(response, "image-edit") self.assertContains(response, "生成图片") + self.assertContains(response, "vision-standard") + self.assertContains(response, "图片理解") + self.assertContains(response, "3 点") self.assertContains(response, "需要") self.assertContains(response, "暂未定价") self.assertNotContains(response, "secret-sku") diff --git a/config/settings.py b/config/settings.py index 92dcc1b..636320c 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) +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) PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/") MEDIA_PUBLIC_BASE_URL = os.environ.get("MEDIA_PUBLIC_BASE_URL", PUBLIC_BASE_URL).rstrip("/") IMAGE_TASK_RETENTION_HOURS = env_int("IMAGE_TASK_RETENTION_HOURS", 24) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 3eab158..a758dd7 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -4,7 +4,7 @@ ## 一句话定位 -`cmhub` 是「**自助用户端 + 计费 API + 运营后台**」三合一服务:终端用户在用户端自助注册、扫码充值、管理 API Key,用 API Key 调用把 `cmbot` 的「生成标题」「生成图片」能力封装成的 HTTP 接口,按**点数计费**;运营用 django-admin 管理用户、点数与记录。 +`cmhub` 是「**自助用户端 + 计费 API + 运营后台**」三合一服务:终端用户在用户端自助注册、扫码充值、管理 API Key,用 API Key 调用「生成标题」「生成图片」「多图理解」等 HTTP 接口,按**点数计费**;运营用 django-admin 管理用户、点数与记录。 第一版 MVP 做:**用户自助注册登录 → 注册成功赠送 100 点试用点数 → 扫码充值转点数 → 自助生成/删除 API Key → 带 Key 调用按规则算点数 → 点数足够则调上游生成并扣点 / 不足则报错 → 写调用与消费记录 → django-admin 运营后台**。注册赠点也是账本资产,必须经计费层入账并写点数流水。 @@ -40,6 +40,8 @@ 当前项目处于:**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 配置视觉模型、默认别名和计费规则。 + 优先路径: 1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。 @@ -48,7 +50,7 @@ 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-618 已完成,覆盖可用别名发现、后台中文化、敏感词过滤、免邮箱验证、公开首页与客户端发布、注册赠点、用户端品牌、生图异步化 / 遥测 / 自动重试和客户端发布校验元数据;T-619「多张图片理解并返回文字」已登记为下一项 `TODO`,将新增独立 `vision` 操作、能力别名和同步多图理解接口,当前尚未实现。 +7. Phase 6:增强(MVP 后)—— T-601~T-619 已完成,覆盖可用别名发现、后台中文化、敏感词过滤、免邮箱验证、公开首页与客户端发布、注册赠点、用户端品牌、生图异步化 / 遥测 / 自动重试、客户端发布校验元数据和同步多图理解;当前没有已登记的下一编号任务。 ## 领取任务规则 @@ -69,7 +71,7 @@ MVP 只做: - **用户端(Django 模板 SSR + Bootstrap)**:自助注册/登录、注册成功一次性赠送 100 点试用点数、扫码充值、查看充值记录/充值总额/剩余点数/消费记录、自助生成与删除 API Key。 -- 对外生成接口:`POST /api/v1/generate/title`(同步返回标题)、`POST /api/v1/generate/image`(旧同步生图,保留兼容)、`POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`(异步生图提交 / 轮询)。 +- 对外生成 / 理解接口:`POST /api/v1/generate/title`(同步返回标题)、`POST /api/v1/generate/image`(旧同步生图,保留兼容)、`POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`(异步生图提交 / 轮询)、`POST /api/v1/analyze/images`(同步多图理解并返回文字)。 - API Key 鉴权,识别所属注册用户(API 只认 Key,不认 Web session)。 - 点数计费:按「操作类型 + 能力别名(+ 可选分辨率)」查计费规则得点数;本地余额原子扣减;不足报错。 - 支付充值回调:支付系统服务端回调 → 验签 + 幂等 → 按汇率把金额转点数入账。 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 141a5e2..f5b2e3e 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -31,6 +31,7 @@ | 个人中心 | 查看剩余点数、充值总额、充值记录、点数使用(消费)记录 | P0 | | 生成标题 API | 带 API Key + prompt(+可选商品图)调用,拿到标题文字 | P0 | | 生成图片 API | 带 API Key + prompt + 图 + 模型/分辨率调用;旧同步接口可直接返回图片 URL,新版客户端可用异步提交 / 轮询拿结果 | P0 | +| 多图理解 API | 带 API Key + prompt + 一张或多张图片调用,按图片顺序理解、比较或提取信息并返回完整文字 | P1 | | 点数计费与扣减 | 按「操作类型+能力别名+分辨率」算点数,余额足够则扣点放行,不足则报错 | P0 | | 余额查询 API | 查询当前用户点数余额 | P0 | | 调用记录 | 每次调用落库:用户、Key、时间、操作、模型、消耗点数、成功/失败、错误 | P0 | @@ -57,9 +58,10 @@ 2. 作为已登录用户,我自助生成一把 API Key(明文只显示一次),也能删除不用的 Key。 3. 作为接入方,我带着 API Key 调用生成标题接口,传入 prompt,能拿到生成的标题。 4. 作为接入方,我可以调用旧同步生成图片接口在同一个响应里拿到图片结果;也可以调用异步图片任务接口先拿到 `task_id`,再轮询直到成功或失败。 -5. 当我的点数不足以支付本次调用时,系统拒绝调用并返回「点数不足,请先充值」,且不调用上游、不扣点。 -6. 作为已登录用户,我在个人中心看到剩余点数、充值总额、充值记录和消费(调用)记录。 -7. 作为运营,我在后台能管理用户与点数(手工调点带原因),配置某操作的点数单价,查看某用户的调用记录和点数流水。 +5. 作为接入方,我可以提交一张或多张商品图,让支持视觉理解的模型按输入顺序分析并返回文字;一次请求只按对应能力别名扣一次点数。 +6. 当我的点数不足以支付本次调用时,系统拒绝调用并返回「点数不足,请先充值」,且不调用上游、不扣点。 +7. 作为已登录用户,我在个人中心看到剩余点数、充值总额、充值记录和消费(调用)记录。 +8. 作为运营,我在后台能管理用户与点数(手工调点带原因),配置某操作的点数单价,查看某用户的调用记录和点数流水。 ## 五、验收标准(MVP) @@ -67,6 +69,7 @@ - **生成标题 API**:携带有效 API Key + 合法 prompt 调用,返回 200 且响应含标题文本;无效/缺失 Key 返回 401。 - **生成图片 API**:旧同步接口携带有效 Key 调用,在超时上限内返回 200 且含图片 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 29305d1..a6c7c85 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -41,6 +41,8 @@ T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 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-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-616 起异步生图 worker 对临时性上游失败增加自动重试:`upstream_timeout` / `upstream_error` 在未达到最大次数前回到 `queued` 并设置 `next_attempt_at`,`CallRecord` 保持 `pending` 且不退点;不可重试错误或最后一次失败才置 `failed` 并幂等退款。默认 `IMAGE_TASK_MAX_RETRIES=2`,因此 `attempt_count` 最多为 3。 @@ -57,6 +59,8 @@ 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,不包含图片内容审核。 + **计费层(`apps/billing`)** - 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。 @@ -146,8 +150,8 @@ CREATE TABLE ai_model ( -- 能力别名(对外稳定标识 → 当前映射的具体模型) CREATE TABLE model_alias ( id BIGINT PRIMARY KEY, - alias VARCHAR(64) NOT NULL, -- 如 title-standard / image-hd,对外用 - operation_type VARCHAR(32) NOT NULL, -- title / image + alias VARCHAR(64) NOT NULL, -- 如 title-standard / image-hd / vision-standard,对外用 + operation_type VARCHAR(32) NOT NULL, -- title / image / vision ai_model_id BIGINT NOT NULL REFERENCES ai_model(id), -- 可随时改指向 is_default BOOLEAN NOT NULL DEFAULT FALSE, is_active BOOLEAN NOT NULL DEFAULT TRUE, @@ -172,7 +176,7 @@ CREATE TABLE ai_config_audit_log ( -- 计费规则(按别名定价,换底层模型不影响计费) CREATE TABLE pricing_rule ( id BIGINT PRIMARY KEY, - operation_type VARCHAR(32) NOT NULL, -- title / image + operation_type VARCHAR(32) NOT NULL, -- title / image / vision alias VARCHAR(64) NOT NULL, -- 能力别名字符串,不绑具体模型 resolution VARCHAR(32) NOT NULL DEFAULT '', -- 空字符串表示该别名默认价 points_cost BIGINT NOT NULL, -- > 0 @@ -307,7 +311,7 @@ CREATE TABLE call_record ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES "user"(id), api_key_id INTEGER REFERENCES api_key(id), -- 本次调用所用的 Key - operation_type TEXT NOT NULL, -- title / image + operation_type TEXT NOT NULL, -- title / image / vision alias TEXT, -- 调用方请求的能力别名(对外稳定标识) model_used TEXT, -- 实际服务该次请求的具体模型(解析后,便于排障/对账) resolution TEXT, @@ -389,6 +393,7 @@ CREATE TABLE image_generation_task ( - 扣点锁 `user_wallet` 行(`select_for_update()`)或 `F('points_balance') - N` 原子更新 + DB 约束 `points_balance >= 0`(MySQL 的 CHECK 需 ≥8.0.16),**杜绝并发超扣 / 扣成负数**;钱包与 auth `user` 表分离,扣点不与登录/资料更新抢锁。 - **先扣后调、失败必退**:保证不会“调用成功但没扣到”或“失败还扣钱”。 - 余额不足在调上游**之前**拦截。 +- `vision` 同步调用在别名解析前先审核 prompt,再按顺序读取 1–8 张图片;输入失败不进入预扣。计费规则使用空 `resolution` 默认价,一次请求固定预扣一次,不随图片数量重复扣费。 ### 4.1.1 调用扣点(异步图片任务) diff --git a/docs/06-tasks.md b/docs/06-tasks.md index e96d1f5..5e54747 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -98,7 +98,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` 留证据 | TODO | +| 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 | ## 里程碑 diff --git a/docs/api.md b/docs/api.md index e083328..645009d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,6 +23,8 @@ T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthenti 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-619 已实现 `POST /api/v1/analyze/images`:使用独立 `vision` 操作和能力别名,同步接收一张或多张有序图片并返回完整文字。该接口复用 T-613 的准备 / 预扣 / 执行 / 成功确认 / 失败退点核心,不改变标题和生图接口。 + T-614 已实现生图异步任务化:`POST /api/v1/generate/image/tasks` 返回 `202` 与公开 UUID `task_id`,`GET /api/v1/generate/image/tasks/{task_id}` 轮询任务状态和结果。任务表 `ImageGenerationTask` 关联 `user`、`api_key` 与已预扣 `CallRecord`,支持 `Idempotency-Key` 去重、payload 冲突 `409 idempotency_conflict`、租约 / 心跳 / reaper 处理僵尸 running 任务并幂等退点。异步结果 URL 由 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 生成,不透传上游临时链接。 T-615 已实现生图接口用量遥测:旧同步 `POST /api/v1/generate/image` 与新异步提交 `POST /api/v1/generate/image/tasks` 都会写 `cmhub.api.generation_usage` 结构化日志,事件名 `generation_route_usage`。调用方可选带 `X-Client-Version` 请求头,便于按客户端版本观察旧同步路迁移进度;该请求头不参与鉴权、计费或幂等判断。日志只记录 route/user/key 前缀/客户端版本/别名/状态/耗时/错误码等白名单字段,不记录 API Key 明文、prompt、图片 base64 或 provider raw。 @@ -171,6 +173,45 @@ GET /api/v1/client/releases/latest?platform=windows 要点:别名必须映射到声明 `text` 能力的模型;映射到图片模型时返回 `model_not_allowed`;别名无计费规则返回 `no_pricing_rule`。T-604 后 prompt 命中本地敏感词时返回 `content_blocked`,不扣点、不写调用记录、不调上游。若传 `image_url`,服务端只会在 prompt 审核通过后下载公网 `http` / `https` 图片,并在扣点前拒绝内网、回环、链路本地、元数据地址、跳转到内网的地址和超过大小上限的响应;被拒绝时返回 `bad_request` 且不预扣点。 +### `POST /api/v1/analyze/images` + +理解一张或多张图片并同步返回文字。请求: + +```json +{ + "prompt": "比较这些商品图,说明款式、材质和细节差异", + "model": "vision-standard", + "images": [ + {"image_base64": "data:image/jpeg;base64,..."}, + {"image_url": "https://example.com/detail.png"} + ], + "parameters": {"temperature": 0.2} +} +``` + +`images` 必填且保持顺序,每项必须且只能提供一个 `image_url` 或 `image_base64`,允许两种来源混合。默认限制由 `VISION_MAX_IMAGES=8`、`VISION_MAX_IMAGE_BYTES=10485760`、`VISION_MAX_TOTAL_BYTES=33554432` 控制;超限、空列表、格式错误或同项双来源返回 `400 bad_request`,不扣点、不调上游。 + +成功响应: + +```json +{ + "text": "第一张是商品正面图,第二张展示了领口和面料细节。", + "alias": "vision-standard", + "model_used": "provider-model-for-debugging", + "points_cost": 3, + "points_balance": 95, + "call_id": 12346 +} +``` + +要点: + +- `model` 是 `vision` 操作的能力别名;缺省时使用后台配置的默认别名。别名指向的 `AiModel` 与 Provider 必须同时具备 `text` 和 `vision` 能力,否则返回 `model_not_allowed`。 +- prompt 审核先于图片下载 / 解码;命中敏感词返回 `content_blocked`。每个 URL 复用现有公网地址、逐跳重定向、超时和 SSRF 防护。第一版不包含图片内容审核。 +- 第一版按 `PricingRule(operation_type=vision, alias, resolution="")` 对一次请求固定扣点,不按图片数量重复扣费。输入校验失败不预扣;上游失败按现有账务流程幂等退点。 +- Chat Completions / Gemini Provider 按请求顺序发送多张图片;返回 `text` 保留上游完整文字,不执行标题拆分清洗。 +- 输入图片只在本次同步请求内存中使用,不保存到数据库或 media;`CallRecord` 只保存最多 500 字结果摘要,不保存 base64、provider raw 或完整上游响应。 + ### `POST /api/v1/generate/image` 生成图片(同步等待)。请求: @@ -389,6 +430,16 @@ Authorization: Bearer sk_cmhub_xxx "requires_image": true, "pricing_status": "unpriced", "prices": [] + }, + { + "alias": "vision-standard", + "operation_type": "vision", + "capabilities": ["text", "vision"], + "requires_image": true, + "pricing_status": "priced", + "prices": [ + {"resolution": "default", "points_cost": 3} + ] } ] } @@ -529,7 +580,7 @@ query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult resolve_alias(operation_type: str, alias: str | None) -> ResolvedModel ``` -T-102 后,别名解析已接入数据库表 `AiModel` / `ModelAlias`:每次调用读取当前 active 记录,缺省 `alias=None` 时取该 `operation_type` 的默认别名;标题要求 `text` 能力,图片要求 `image` 能力。密钥以 `AiModel.api_key_encrypted` 存储,使用 Fernet 解密后进入 `ResolvedModel`。 +T-102/T-619 后,别名解析已接入数据库表 `AiModel` / `ModelAlias`:每次调用读取当前 active 记录,缺省 `alias=None` 时取该 `operation_type` 的默认别名;标题要求 `text`,图片生成要求 `image`,图片理解要求同时具备 `text + vision`。密钥以 `AiModel.api_key_encrypted` 存储,使用 Fernet 解密后进入 `ResolvedModel`。 从 `cmbot` 形状导入配置的管理命令: @@ -557,12 +608,16 @@ class Provider(Protocol): image_filename: str = "image.png", resolution: str = "1K", aspect_ratio: str = "1:1", parameters: dict | None = None) -> ImageGenerationResult: ... + def analyze_images(self, prompt: str, model: ResolvedModel, + images: Sequence[MultimodalImage], + parameters: dict | None = None) -> TextGenerationResult: ... ``` 要点: - `ResolvedModel` 来自数据库 AiModel(形状同 `cmbot/config/ai_models.json`,外加 `capabilities`;`api_key` 在库中以 Fernet 加密,使用时解密,不落明文)。 - `TextGenerationResult` 包含 `text`、清洗后的 `titles`、`model_used`、`raw`;`ImageGenerationResult` 包含图片 bytes、`model_used`、`raw`。`raw` 仅供本次解析/排障摘要使用,调用记录不得整包保存或打印,避免 base64 大图和上游敏感字段入库;图片落对象存储并返回 URL 属 T-302 之后的 API 编排职责。 +- `analyze_images()` 接收有序 `MultimodalImage(data, mime_type)` 序列;Chat / Gemini 适配器翻译为各自多模态 payload,并从原始响应提取完整文字。图片生成 / 编辑专用 Provider 必须明确拒绝该操作。 - 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。 - `parameters` 为供应商特有参数的安全子集;适配器负责白名单过滤并把统一入参翻译成各家上游格式,核心字段不可被 `parameters` 或 `extra_body` 覆盖。 - 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。 diff --git a/docs/current-state.md b/docs/current-state.md index f22e1f4..190bbfe 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -18,6 +18,10 @@ - T-614/T-616 生产代码补充:已新增 `ImageGenerationTask` 与 `api.0001_initial`,新增 `POST /api/v1/generate/image/tasks`、`GET /api/v1/generate/image/tasks/{task_id}`、`apps.api.image_tasks` 任务服务、`run_image_tasks` management command 和只读 admin;异步提交支持 `Idempotency-Key` 去重 / 冲突检测,worker 使用 DB 任务表、租约、心跳与 reaper,成功返回 cmhub 托管 URL;T-616 已通过 `api.0002_imagegenerationtask_next_attempt_at_and_more` 增加 `next_attempt_at` 与 `(status, next_attempt_at)` 索引,临时性 `upstream_timeout` / `upstream_error` 默认最多重试 2 次,重试期间不退点,最终失败 / 僵任务才走计费层幂等退款。 - T-617 生产代码补充:`DownloadRelease` 已新增 `size_bytes` 可空正整数字段和迁移 `portal.0004_downloadrelease_size_bytes`,django-admin 可填写并在列表展示;`GET /api/v1/client/releases/latest` 的 `release` 对象新增 `size_bytes`,有值返回整数,未配置返回 `null`,未发布时仍只返回 `release:null`。 - T-618 生产代码补充:`DownloadReleaseAdmin` 已挂专用 `ModelForm`,后台新增 / 编辑客户端发布版本时 `sha256` 与 `size_bytes` 必填;数据库字段仍兼容历史空值,API 仍可读取历史 `size_bytes=null` 记录。 +- T-619 生产代码补充:`ModelAlias` / `CallRecord` 已新增 `vision` 操作并生成 `ai.0005` / `billing.0008` 迁移;`POST /api/v1/analyze/images` 已接入 API Key 鉴权、生成限流、prompt 前置审核、有序多图 URL/Base64 读取、SSRF 与大小保护、Chat/Gemini 多模态 Provider、固定单次预扣 / 成功确认 / 幂等退款及安全调用摘要。模型目录与 portal 可用模型页会展示能力匹配的 active `vision` 别名并标记 `requires_image=true`。生产仍需配置同时具备 `text`、`vision` 能力的 `AiModel`、`vision-standard` 默认别名和默认 `PricingRule`。 +- 最新验证:T-619 已完成 `ai.0005_alter_modelalias_operation_type` 与 `billing.0008_alter_callrecord_operation_type_and_more` 迁移并应用到当前开发库;`manage.py check` 0 issues,`makemigrations --check --dry-run` 无变化;Provider 专项 10 tests OK,T-619 API / 别名 / 目录专项 13 tests OK,包含旧标题 / 生图的扩展回归 84 tests OK,users / billing / moderation 分组 46 tests OK。完整单命令回归在 604 秒达到执行器超时,之后 API 全量重试在测试库初始化阶段遇到远程 MySQL `43.128.3.240` 连接超时,均未产生断言失败;测试期仍有 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 +- 验证补充:API 全量 88 条测试已重试两次,均在执行断言前因远程 MySQL 握手超时中断;`Test-NetConnection 43.128.3.240 -Port 3306` 同期显示 TCP 可达。收尾 `./init.ps1` 通过(Python 3.12.3,Django system check 0 issues)。 +- 验证恢复:远程 MySQL 恢复后,新增“单图 + 指定 vision 别名”成功测试 1 test OK;API 非生成类拆分为鉴权 / 余额 / 模型目录 / 客户端版本 28 tests OK、充值回调 / 下单状态 13 tests OK,结合此前 `GenerateApiTests` 整类回归与新增单测,当前 API 89 条均有通过证据;`apps.portal.tests` 全量 39 tests OK。因此完整回归已通过拆分执行完成,单命令超时仅作为环境现象保留。 - 最新验证:T-618 客户端发布版本后台必填文件校验元数据已验证 `.\init.ps1` 开工前通过;`py -3.12 manage.py check` 通过,0 issues;`py -3.12 manage.py makemigrations --check --dry-run` 通过,No changes detected;`py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_download_release_admin_requires_sha256_and_size_bytes apps.portal.tests.PortalAccountFlowTests.test_download_release_admin_saves_valid_release_metadata apps.api.tests.ClientLatestReleaseApiTests.test_latest_release_returns_null_size_bytes_when_not_configured apps.api.tests.ClientLatestReleaseApiTests.test_latest_release_is_public_without_api_key_and_returns_current_release --keepdb --noinput --verbosity 2` 通过,4 tests OK。测试期仍保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 - 最新验证:T-617 桌面端版本文件大小字段已验证 `py -3.12 manage.py makemigrations --check --dry-run` 通过,No changes detected;`py -3.12 manage.py check` 通过,0 issues;`py -3.12 manage.py test apps.api.tests.ClientLatestReleaseApiTests --keepdb --noinput --verbosity 2` 通过,12 tests OK,覆盖 `size_bytes` 有值、未配置为 `null`、未发布不返回顶层字段、响应字段白名单和 admin 字段可见;`py -3.12 manage.py migrate portal --noinput` 120 秒超时,但 `py -3.12 manage.py showmigrations portal` 确认 `portal.0004_downloadrelease_size_bytes` 已应用,且无残留 migrate 进程。测试期仍保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 - T-614 实现取舍补充:worker 执行前会基于任务快照复跑 `prepare_generation()`,即复审 prompt 并重解析别名 / Provider / 定价;账务使用已预扣 `CallRecord.points_cost`,不会重复扣点。短队列下这是偏安全取舍,词库变更后排队任务仍可被拦截并退款;若后续队列积压或频繁切换模型,应单独做提交时模型配置快照。异步 submit 对 `image_base64` 只解码并保存输入文件,桌面端主链路推荐继续使用;`image_url` 会在 submit 阶段完成 SSRF 校验、远程下载和大小限制,可能阻塞提交请求,属于边缘兼容路径。 @@ -45,7 +49,7 @@ - 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入 - 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh` - 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` -- 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;新用户注册成功一次性赠送 100 点试用点数,必须经 billing 写 `signup_bonus` 流水,且通过 `SignupBonusGrant(user UNIQUE)` 防重复发放。详见 `04-architecture.md` 与 2026-07-08 的 `progress.md` 决策 +- 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);生成标题、生成图片和多图理解均通过**能力别名 + Provider 适配器**对外提供,不暴露具体供应商模型;自助扫码充值;新用户注册成功一次性赠送 100 点试用点数,必须经 billing 写 `signup_bonus` 流水,且通过 `SignupBonusGrant(user UNIQUE)` 防重复发放。详见 `04-architecture.md` 与 2026-07-08 的 `progress.md` 决策 - 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。用户端注册策略固定为 `ACCOUNT_EMAIL_VERIFICATION="none"`:免邮箱验证、注册即可用、邮箱仍必填且唯一;`DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 仅用于后续密码找回、通知或恢复邮箱验证等邮件能力,不作为当前注册登录前置条件。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。T-403 起生产静态、共享 cache 与 HTTPS 安全配置包含 `STATIC_URL`、`STATIC_ROOT`、`DJANGO_CACHE_BACKEND`、`DJANGO_CACHE_LOCATION`、`DJANGO_CSRF_TRUSTED_ORIGINS`、`DJANGO_SESSION_COOKIE_SECURE`、`DJANGO_CSRF_COOKIE_SECURE`、`DJANGO_SECURE_SSL_REDIRECT`、`DJANGO_SECURE_PROXY_SSL_HEADER`、`DJANGO_SECURE_HSTS_SECONDS`。T-604 起内容安全配置包含 `MODERATION_ENABLED`、`MODERATION_PROVIDER=keyword`、`MODERATION_FAIL_CLOSED`、`MODERATION_BLOCK_ON_REVIEW`、`MODERATION_CACHE_VERSION_KEY`;生产多 worker 下必须使用共享 cache 承载敏感词版本号。T-612 起生图同步接口止血配置包含 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,只作用于 `generate_image` 上游读取硬截止。T-614/T-616 起异步生图配置包含 `PUBLIC_BASE_URL`、`MEDIA_PUBLIC_BASE_URL`、`IMAGE_TASK_RETENTION_HOURS`、`GENERATED_IMAGE_RETENTION_HOURS`、`IMAGE_TASK_REAPER_INTERVAL_SECONDS`、`IMAGE_TASK_LEASE_SECONDS`、`IMAGE_TASK_MAX_RETRIES`、`IMAGE_TASK_RETRY_BACKOFF_SECONDS`;生产必须运行 `run_image_tasks` worker。 - 当前 blocker:微信正式下单已能返回二维码,但线上微信回调曾出现 `PaymentVerificationError`,仍需单独修复并完成“付款后自动入账”闭环验收;支付宝恢复依赖开放平台把 `43.128.3.240` 加入可信 IP。线上真实标题生成已跑通并验证扣点;图片同步旧接口已由 T-612 加上游硬截止止血,T-614 已提供异步任务化路径,T-615 已补旧同步 / 新异步提交遥测和旧路弃用口径。 @@ -69,10 +73,11 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 - 已完成: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 多张图片理解并返回文字;后续仍按真实支付回调到账闭环、客户端发布和线上旧同步接口用量观察继续拆任务。 +- 待开始:仍按真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察继续拆任务。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 -- 下一个可领取任务:T-619 多张图片理解并返回文字。任务只新增同步 `POST /api/v1/analyze/images` 与独立 `vision` 操作 / 别名,不改变现有标题、生图和异步生图接口;具体范围与验收以 [`06-tasks.md`](06-tasks.md) 为准。 +- 下一个可领取任务:当前无明确编号任务;先根据生产优先级登记新任务,再按 [`06-tasks.md`](06-tasks.md) 领取。 ## 当前可运行内容 @@ -108,6 +113,7 @@ python3.12 manage.py run_image_tasks --worker-id cmhub-image-worker-1 --sleep-se 当前对外 API: - `POST /api/v1/generate/title` +- `POST /api/v1/analyze/images` - `POST /api/v1/generate/image` - `POST /api/v1/generate/image/tasks` - `GET /api/v1/generate/image/tasks/{task_id}` @@ -136,6 +142,8 @@ python3.12 manage.py run_image_tasks --worker-id cmhub-image-worker-1 --sleep-se 当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302/T-613 已落地生成接口与共享核心:旧同步接口仍保持原响应契约;`prepare_generation()` 执行 prompt 审核、图片输入处理、别名解析、Provider 选择和计费计算;`precharge_generation()` 调 billing 预扣;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游并成功确认或失败退点;图片结果保存到本地 media 并通过 URL 构建器返回外部 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回兼容字段 `user` / `points_balance`,以及不含邮箱的 `account.username` / `account.display_name`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501/T-605/T-608 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="none"`,免邮箱验证、注册即可用,邮箱仍必填且唯一;注册成功经 billing 发放 100 点并写 `signup_bonus` 流水,重复调用或并发触发由 `SignupBonusGrant` 幂等标记兜底,注册限流由 allauth signup rate limit 执行。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503/T-608 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、获得点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 signup_bonus/consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。T-401 已落地运营后台完善:用户列表显示钱包余额,钱包余额只读且通过专用表单手工调点,调点必须填原因、非 0、不得扣成负数,并经 `adjust_wallet_points()` 锁钱包写 `PointsLedger(adjust)`;API Key admin 只展示 prefix 和 hash 摘要,不回显明文或完整 hash;订单、流水、注册赠点记录、调用记录继续只读并增强检索。T-402 已完成 MVP P0 验收并新增 `docs/mvp-acceptance.md`。T-403 已完成部署 / 运行文档,生产按 `deployment.md` 执行,并已补 settings 对生产静态目录、共享 cache、CSRF trusted origins、HTTPS cookie/proxy/HSTS 与注册限流的环境变量支持。T-601 已落地可用别名发现:`GET /api/v1/models` 用 API Key 鉴权返回公开别名目录,`/models` 用 session 展示只读「可用模型」页;两者均不解密 provider key,不输出底层 SKU、URL、key 或 `extra_body`。T-603 已完成 django-admin 字段级中文化并人工确认字段标签中文。T-604 已落地本地 prompt 敏感词过滤:`MODERATION_ENABLED=false` 默认 no-op,启用 `keyword` 后命中返回 `content_blocked`,并在下载 `image_url`、解析别名、计费、预扣点和上游调用前拦截。T-606 已落地公开首页:`GET /` 匿名 200、不跳登录;下载区读取 Windows 当前 `DownloadRelease`,优先 `external_url`,无当前版本显示「暂未发布」;现有 portal 页面已共用 `brand.css` 品牌 token。T-607/T-609 已落地公开版本检查接口:`GET /api/v1/client/releases/latest` 匿名 200,无需 API Key;支持 `windows`/`macos`/`linux`,返回当前版本 JSON 或 `release:null`,当前版本响应包含 `release.force_update`。真实标题上游生成已在线上跑通并验证扣点;图片生成真实耗时仍需补测。 T-614 已落地异步生图任务化:提交接口预扣后返回 `task_id`,轮询接口返回 queued/running/succeeded/failed,worker 和 reaper 复用同一套成功确认 / 失败退点路径,旧同步接口继续兼容。当前 worker 会复跑审核 / 别名解析 / 定价,但不重复扣点;`image_url` 输入会在 submit 阶段下载,桌面端主链路应优先用 `image_base64`。T-615 已落地旧同步 / 新异步提交遥测:两个入口都会写 `generation_route_usage` 结构化日志,便于按客户端版本、API Key 和 route_type 观察迁移进度;旧同步接口下线前必须继续保持响应兼容,并单独立下线任务。 +T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服务按 `vision + alias` 默认价固定扣点,成功返回文字,失败幂等退款;输入图片和 provider raw 不写入调用记录。代码部署并迁移后,需由运营配置视觉模型、默认别名与默认价格,才会成为可调用能力。 + ## 开始编码前检查 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 diff --git a/docs/env.md b/docs/env.md index 81cdfb2..8aa0001 100644 --- a/docs/env.md +++ b/docs/env.md @@ -73,16 +73,21 @@ T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和 | 变量 | 必填 | 示例 | 说明 | | --- | --- | --- | --- | -| `API_GENERATE_THROTTLE_RATE` | 否 | `60/min` | 生成标题 / 图片接口按 API Key 或用户限流,DRF throttle rate 格式 | +| `API_GENERATE_THROTTLE_RATE` | 否 | `60/min` | 生成标题 / 图片 / 多图理解接口按 API Key 或用户限流,DRF throttle rate 格式 | | `API_AUTH_FAILURE_THROTTLE_RATE` | 否 | `30/min` | 缺失、畸形或无效 API Key 的认证失败按 IP 限流 | | `IMAGE_URL_MAX_BYTES` | 否 | `10485760` | `image_url` 服务端下载的最大响应字节数,默认 10 MiB | | `IMAGE_URL_MAX_REDIRECTS` | 否 | `3` | `image_url` 手动跟随重定向次数上限;每跳都会重新校验目标地址 | | `IMAGE_URL_CONNECT_TIMEOUT_SECONDS` | 否 | `10` | `image_url` 下载连接超时秒数 | | `IMAGE_URL_READ_TIMEOUT_SECONDS` | 否 | `60` | `image_url` 下载读取超时秒数 | +| `VISION_MAX_IMAGES` | 否 | `8` | 多图理解接口单次最多接受的图片数量,最小按 1 处理 | +| `VISION_MAX_IMAGE_BYTES` | 否 | `10485760` | 多图理解接口每张图片解码或下载后的最大字节数,默认 10 MiB | +| `VISION_MAX_TOTAL_BYTES` | 否 | `33554432` | 多图理解接口单次所有图片的总字节数上限,默认 32 MiB | | `RECHARGE_MAX_AMOUNT_CNY` | 否 | `100000.00` | 用户端单笔充值金额上限,超过则拒绝创建订单 | `image_url` 只允许 `http` / `https`,服务端会在请求前解析域名,拒绝私有网段、回环、链路本地、保留地址、组播、未指定地址;重定向后的目标地址也会重复执行同样校验。内网图片不应通过 `image_url` 传入,调用方应改用 `image_base64`。 +多图理解接口会先审核 prompt,再按 `images` 顺序下载或解码图片;单图同时受 `VISION_MAX_IMAGE_BYTES` 限制,URL 下载不会绕过现有 SSRF、重定向和超时检查。三个 `VISION_*` 限制应按上游模型输入上限与 Web worker 内存共同校准。 + ## 六、内容安全 / 本地敏感词配置 | 变量 | 必填 | 示例 | 说明 | diff --git a/docs/routes.md b/docs/routes.md index b4e526d..4e2c6d2 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -25,6 +25,7 @@ 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/tasks/{task_id}` | GET | 轮询异步图片生成任务状态和结果 | API Key | @@ -39,6 +40,8 @@ T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 ` T-607/T-609/T-617 已落地 `/api/v1/client/releases/latest`:公开匿名可访问,不需要 API Key,不读取用户账本,只返回当前 `DownloadRelease` 的版本、下载 URL、SHA256、发布说明、强制更新标记、文件大小字节数和发布时间;无当前版本返回 `release:null`。 T-614 已落地 `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}`:新版桌面端可先提交异步生图任务,再轮询状态;旧 `/api/v1/generate/image` 同步接口继续保留。异步查询只允许同一用户访问自己的任务,跨用户按不存在处理。 +T-619 已落地 `/api/v1/analyze/images`:使用独立 `vision` 操作和能力别名,同步接收有序多图并返回文字;不复用标题接口语义,也不改变现有标题 / 生图路由。 + ## 运营后台(django-admin,`/admin/`) 后台用 Django Session 登录,按模型注册 Admin: diff --git a/progress.md b/progress.md index ab97692..6a52129 100644 --- a/progress.md +++ b/progress.md @@ -1913,3 +1913,40 @@ - 多图上游调用必须扩展既有 Provider 和 T-613 共享生成 core;图片 URL 必须复用现有 SSRF 防护,失败复用计费层幂等退点。 - 本任务不包含流式、异步 vision task、OCR 专用接口或结构化 JSON 输出;OCR、商品识别和图片对比先通过 prompt 表达。 - 验证:`./init.ps1` 通过,Python 3.12.3,依赖已满足,`manage.py check` 0 issues;任务登记完成后继续执行文档 diff 检查。 + +## 2026-07-16 开工:T-619 多张图片理解并返回文字 + +- 状态:DOING。 +- 基线:执行 `./init.ps1` 通过,Python 3.12.3,依赖已满足,`manage.py check` 0 issues。 +- 范围:按 `docs/06-tasks.md` 新增独立 `vision` 操作、多图理解同步接口、Provider 多图支持、固定单次计费、目录展示和输入资源限制;不改旧标题、生图和异步生图契约。 +- 工作区说明:开工时存在用户侧 `.gitignore` 修改、已跟踪测试脚本 / 提示词删除和临时截图,均不属于 T-619,不恢复、不覆盖、不纳入本任务。 + +## 2026-07-16 实施:T-619 多张图片理解并返回文字 + +- 状态:DONE。 +- 代码变更: + - `ModelAlias.OperationType`、`CallRecord.OperationType` 与 `PricingRule` 可选操作新增 `vision`,生成并应用 `ai.0005` / `billing.0008` 迁移。 + - `apps.ai.aliases` 把能力要求统一为集合;`vision` 模型和 Provider 必须同时支持 `text`、`vision`,图片生成 / 编辑专用 Provider 不可误接。 + - Provider 协议新增 `analyze_images()` 与 `MultimodalImage`;Chat Completions、Gemini 按请求顺序发送多张图片并解析完整文字结果。 + - 新增 `POST /api/v1/analyze/images`,支持有序 URL/Base64 混合图片;prompt 审核先于图片读取,URL 复用现有 SSRF 防护;数量、单图和总大小受三个 `VISION_*` 环境变量限制。 + - 多图理解复用 T-613 生成核心和 billing 预扣 / 成功确认 / 幂等退款;每次请求按 `vision + alias` 默认价固定扣一次,调用记录只保留最多 500 字文字摘要,不保存输入图片或 provider raw。 + - `GET /api/v1/models` 与 portal 可用模型页支持展示能力匹配的 active `vision` 别名,并返回 / 显示 `requires_image=true` 与计费状态。 +- 文档变更:同步 `README.md`、`docs/00-ai-start-here.md`、`02-requirements.md`、`04-architecture.md`、`06-tasks.md`、`api.md`、`current-state.md`、`env.md`、`routes.md` 与 `.env.example`;T-619 标记为 DONE。 +- 验证: + - `./init.ps1` 开工和收尾均通过;收尾为 Python 3.12.3、依赖已满足、Django system check 0 issues。 + - `py -3.12 -m py_compile ...`:T-619 相关 Python 文件通过。 + - `py -3.12 manage.py migrate --noinput`:通过,已应用 `ai.0005` 与 `billing.0008`。 + - `py -3.12 manage.py check`:通过,0 issues;`py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。 + - Provider 专项:10 tests OK;T-619 API / 别名 / 目录专项:13 tests OK;旧标题 / 生图关键回归:5 tests OK。 + - `apps.ai.tests + ModelsCatalogApiTests + GenerateApiTests + portal 模型页` 扩展回归:84 tests OK。 + - `apps.users.tests + apps.billing.tests + apps.moderation.tests` 分组回归:46 tests OK。 + - 完整单命令回归运行 604 秒后达到执行器超时,未取得最终结果;随后 `apps.api.tests` 全量 88 条测试重试两次,均在测试库初始化阶段因远程 MySQL `43.128.3.240` 握手超时中断,未进入断言。同期 TCP 3306 探测可达,按环境稳定性问题记录,不把该两次运行记为通过。 + - `git diff --check`:通过,仅 Windows CRLF 提示。 +- 生产启用提醒:部署代码并执行 migrate 后,需在 admin 配置同时具有 `text`、`vision` 能力的 active `AiModel`、`vision-standard` active 默认别名和 `operation_type=vision` 的默认 `PricingRule`;本任务不写入真实模型、URL、密钥或价格。 +- 工作区说明:用户侧 `.gitignore` 修改、`xiaxiuxiu.py` / `图生图提示词.txt` 删除及 `.tmp-t609-layout.png` 未跟踪文件保持原状,未作为 T-619 内容处理。 +- 数据库恢复后的补充验证: + - 新增 `GenerateApiTests.test_analyze_images_supports_single_image_with_explicit_alias`,补齐单图 + 指定别名成功矩阵;单独运行 1 test OK。 + - API 鉴权 / 余额 / 模型目录 / 客户端版本分组:28 tests OK。 + - API 充值回调 / 下单状态分组:13 tests OK。 + - `apps.portal.tests` 全量:39 tests OK。 + - 结合此前 `GenerateApiTests` 整类 47 tests OK 与新增单测,当前 API 89 条测试均有通过证据;所有应用已通过拆分方式完成完整回归。完整单命令超时仍作为远程 MySQL / 执行器稳定性现象保留,不再构成测试覆盖缺口。