diff --git a/.env.example b/.env.example index e5cf98b..fe135b3 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,6 @@ MYSQL_CHARSET=utf8mb4 # AI key encryption AI_KEY_ENCRYPTION_KEY=base64-fernet-key -AI_DEFAULT_CONNECT_TIMEOUT_SECONDS=10 -AI_DEFAULT_READ_TIMEOUT_SECONDS=300 # WeChat Pay V3 native WECHAT_PAY_APPID=wx-your-appid diff --git a/README.md b/README.md index 46bf5d5..ba2fa52 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR ## 当前状态 -Phase 1 已完成 T-104:Provider 适配器层、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置变更审计和录制标题生成 smoke 已落地。下一步进入 Phase 2 的 T-201 用户钱包 / API Key / 流水 / 调用记录模型。详见 [`docs/current-state.md`](docs/current-state.md)。 +Phase 1 已完成 T-105:Provider 适配器层、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置变更审计、录制标题/图片 smoke 与 Phase 1 审核修补已落地。下一步进入 Phase 2 的 T-201 用户钱包 / API Key / 流水 / 调用记录模型。详见 [`docs/current-state.md`](docs/current-state.md)。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。 diff --git a/apps/ai/importers.py b/apps/ai/importers.py index b735db4..809c07e 100644 --- a/apps/ai/importers.py +++ b/apps/ai/importers.py @@ -124,7 +124,7 @@ def _ensure_default_aliases(models: list[AiModel]) -> int: ).update(is_default=False) ModelAlias.objects.update_or_create( operation_type=ModelAlias.OperationType.IMAGE, - alias="image-standard", + alias="image-hd", defaults={"ai_model": image_model, "is_default": True, "is_active": True}, ) aliases += 1 diff --git a/apps/ai/management/commands/import_ai_models.py b/apps/ai/management/commands/import_ai_models.py index 21df762..20cc152 100644 --- a/apps/ai/management/commands/import_ai_models.py +++ b/apps/ai/management/commands/import_ai_models.py @@ -14,7 +14,7 @@ class Command(BaseCommand): parser.add_argument( "--create-default-aliases", action="store_true", - help="Create title-standard and image-standard default aliases when possible.", + help="Create title-standard and image-hd default aliases when possible.", ) def handle(self, *args, **options): diff --git a/apps/ai/management/commands/smoke_ai_generation.py b/apps/ai/management/commands/smoke_ai_generation.py index 0811d97..f2f5df9 100644 --- a/apps/ai/management/commands/smoke_ai_generation.py +++ b/apps/ai/management/commands/smoke_ai_generation.py @@ -1,6 +1,8 @@ from __future__ import annotations +import base64 from dataclasses import dataclass +from pathlib import Path from time import perf_counter from typing import Any from uuid import uuid4 @@ -23,6 +25,7 @@ RECORDED_TITLE_CONTENT = ( "2. 纯净白色基础款T恤舒适亲肤上衣\n" "3. 夏季百搭白T休闲宽松男女同款" ) +RECORDED_IMAGE_BYTES = b"recorded-image-bytes" @dataclass(frozen=True) @@ -32,20 +35,25 @@ class SmokeResult: alias: str model_used: str elapsed_ms: int - title_count: int - first_title: str + title_count: int | None = None + first_title: str = "" + image_bytes: int | None = None class RecordedResponse: + def __init__(self, payload: dict[str, Any]) -> None: + self.payload = payload + def json(self) -> dict[str, Any]: - return {"choices": [{"message": {"content": RECORDED_TITLE_CONTENT}}]} + return self.payload def raise_for_status(self) -> None: return None class RecordedSession: - def __init__(self) -> None: + def __init__(self, payload: dict[str, Any]) -> None: + self.payload = payload self.posts: list[dict[str, Any]] = [] self.trust_env = True @@ -54,17 +62,19 @@ class RecordedSession: key: value for key, value in kwargs.items() if key != "headers" } self.posts.append({"url": url, **sanitized_kwargs}) - return RecordedResponse() + return RecordedResponse(self.payload) class Command(BaseCommand): help = "Run a minimal AI generation smoke through alias resolution and provider code." def add_arguments(self, parser): - parser.add_argument("operation", choices=("title",)) + parser.add_argument("operation", choices=("title", "image")) parser.add_argument("--alias", help="Alias to resolve. Defaults to operation default.") parser.add_argument("--prompt", default=DEFAULT_TITLE_PROMPT) parser.add_argument("--resolution", default="1K") + parser.add_argument("--image-file", help="Input image path for image/edit smoke.") + parser.add_argument("--image-mime-type", default="image/png") parser.add_argument( "--recorded", action="store_true", @@ -72,14 +82,18 @@ class Command(BaseCommand): ) def handle(self, *args, **options): - if options["operation"] != "title": - raise CommandError("Only title smoke is implemented.") - - result = ( - self._run_recorded_title(options) - if options["recorded"] - else self._run_real_title(options) - ) + if options["operation"] == "title": + result = ( + self._run_recorded_title(options) + if options["recorded"] + else self._run_real_title(options) + ) + else: + result = ( + self._run_recorded_image(options) + if options["recorded"] + else self._run_real_image(options) + ) self._write_result(result) def _run_real_title(self, options) -> SmokeResult: @@ -119,7 +133,57 @@ class Command(BaseCommand): alias=alias, prompt=options["prompt"], resolution=options["resolution"], - provider=ChatCompletionsProvider(session=RecordedSession()), + provider=ChatCompletionsProvider( + session=RecordedSession(recorded_title_payload()) + ), + ) + transaction.set_rollback(True) + return result + + def _run_real_image(self, options) -> SmokeResult: + if not settings.AI_KEY_ENCRYPTION_KEY: + raise CommandError("AI_KEY_ENCRYPTION_KEY is not configured.") + return run_image_smoke( + mode="real", + alias=options["alias"], + prompt=options["prompt"], + resolution=options["resolution"], + image=read_image_file(options["image_file"]), + image_mime_type=options["image_mime_type"], + provider=None, + ) + + def _run_recorded_image(self, options) -> SmokeResult: + alias = options["alias"] or f"t-105-recorded-image-{uuid4().hex[:8]}" + encryption_key = Fernet.generate_key().decode("ascii") + with override_settings(AI_KEY_ENCRYPTION_KEY=encryption_key): + with transaction.atomic(): + ai_model = AiModel( + name=f"T-105 recorded image {uuid4().hex[:8]}", + url="https://recorded.invalid/v1/chat/completions", + model="recorded-image-model", + api_type=AiModel.ApiType.CHAT, + capabilities=["image", "vision"], + timeout_seconds=0, + connect_timeout_seconds=30, + ) + ai_model.set_api_key("sk-recorded-placeholder") + ai_model.save() + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.IMAGE, + alias=alias, + ai_model=ai_model, + ) + result = run_image_smoke( + mode="recorded", + alias=alias, + prompt=options["prompt"], + resolution=options["resolution"], + image=b"recorded-input-image", + image_mime_type=options["image_mime_type"], + provider=ChatCompletionsProvider( + session=RecordedSession(recorded_image_payload()) + ), ) transaction.set_rollback(True) return result @@ -131,8 +195,11 @@ class Command(BaseCommand): self.stdout.write(f"alias={result.alias}") self.stdout.write(f"model_used={result.model_used}") self.stdout.write(f"elapsed_ms={result.elapsed_ms}") - self.stdout.write(f"title_count={result.title_count}") - self.stdout.write(f"first_title={result.first_title}") + if result.title_count is not None: + self.stdout.write(f"title_count={result.title_count}") + self.stdout.write(f"first_title={result.first_title}") + if result.image_bytes is not None: + self.stdout.write(f"image_bytes={result.image_bytes}") def run_title_smoke( @@ -162,3 +229,69 @@ def run_title_smoke( title_count=len(generation.titles), first_title=generation.titles[0] if generation.titles else generation.text, ) + + +def run_image_smoke( + *, + mode: str, + alias: str | None, + prompt: str, + resolution: str, + image: bytes | None, + image_mime_type: str, + provider, +) -> SmokeResult: + started = perf_counter() + model = resolve_alias(ModelAlias.OperationType.IMAGE, alias) + selected_provider = provider or get_provider(model.api_type, model.url) + generation = selected_provider.generate_image( + prompt, + model, + image=image, + image_mime_type=image_mime_type, + resolution=resolution, + parameters={"temperature": 0}, + ) + elapsed_ms = int((perf_counter() - started) * 1000) + return SmokeResult( + mode=mode, + operation=ModelAlias.OperationType.IMAGE, + alias=alias or "", + model_used=generation.model_used, + elapsed_ms=elapsed_ms, + image_bytes=len(generation.image), + ) + + +def recorded_title_payload() -> dict[str, Any]: + return {"choices": [{"message": {"content": RECORDED_TITLE_CONTENT}}]} + + +def recorded_image_payload() -> dict[str, Any]: + encoded = base64.b64encode(RECORDED_IMAGE_BYTES).decode("ascii") + return { + "choices": [ + { + "message": { + "content": [ + {"type": "text", "text": "done"}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{encoded}", + }, + }, + ] + } + } + ] + } + + +def read_image_file(path: str | None) -> bytes | None: + if not path: + return None + try: + return Path(path).read_bytes() + except OSError as exc: + raise CommandError(f"Unable to read image file: {path}") from exc diff --git a/apps/ai/providers/openai_compatible.py b/apps/ai/providers/openai_compatible.py index 997862f..22e049d 100644 --- a/apps/ai/providers/openai_compatible.py +++ b/apps/ai/providers/openai_compatible.py @@ -28,6 +28,35 @@ from .utils import ( ) +SAFE_PARAMETER_KEYS = frozenset( + { + "temperature", + "top_p", + "topP", + "top_k", + "topK", + "max_tokens", + "max_output_tokens", + "maxOutputTokens", + "presence_penalty", + "presencePenalty", + "frequency_penalty", + "frequencyPenalty", + "seed", + "stop", + } +) +SAFE_GENERATION_CONFIG_KEYS = frozenset( + { + "temperature", + "topP", + "topK", + "maxOutputTokens", + "stopSequences", + } +) + + class BaseHttpProvider: def __init__(self, session: requests.Session | None = None): self.session = session or requests.Session() @@ -371,6 +400,20 @@ def apply_extra_body( model: ResolvedModel, parameters: Mapping[str, Any] | None = None, ) -> None: - payload.update(model.extra_body) + apply_safe_parameters(payload, model.extra_body) if parameters: - payload.update(dict(parameters)) + apply_safe_parameters(payload, parameters) + + +def apply_safe_parameters(payload: dict[str, Any], values: Mapping[str, Any]) -> None: + for key, value in values.items(): + if key == "generationConfig" and isinstance(value, Mapping): + generation_config = payload.setdefault("generationConfig", {}) + if not isinstance(generation_config, dict): + continue + for config_key, config_value in value.items(): + if config_key in SAFE_GENERATION_CONFIG_KEYS: + generation_config[config_key] = config_value + continue + if key in SAFE_PARAMETER_KEYS: + payload[key] = value diff --git a/apps/ai/providers/utils.py b/apps/ai/providers/utils.py index 3e4e844..f709a81 100644 --- a/apps/ai/providers/utils.py +++ b/apps/ai/providers/utils.py @@ -110,14 +110,15 @@ def decode_image_data_url(data_url: str) -> bytes: def resolution_to_size(resolution: str) -> str: + key = str(resolution).strip().upper() mapping = { "512": "512x512", - "512px": "512x512", + "512PX": "512x512", "1K": "1024x1024", "2K": "2048x2048", "4K": "4096x4096", } - return mapping.get(str(resolution), str(resolution)) + return mapping.get(key, str(resolution).strip()) def extract_image_from_response( diff --git a/apps/ai/tests.py b/apps/ai/tests.py index f0cd898..0541e78 100644 --- a/apps/ai/tests.py +++ b/apps/ai/tests.py @@ -13,6 +13,7 @@ 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.utils import resolution_to_size TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("ascii") @@ -54,6 +55,12 @@ class ProviderRegistryTests(SimpleTestCase): self.assertIsInstance(get_provider("auto", url), ChatCompletionsProvider) +class ProviderUtilsTests(SimpleTestCase): + def test_resolution_to_size_normalizes_case(self): + self.assertEqual(resolution_to_size("1k"), "1024x1024") + self.assertEqual(resolution_to_size("512px"), "512x512") + + class ChatCompletionsProviderTests(SimpleTestCase): def test_generate_text_builds_chat_payload_and_cleans_titles(self): session = FakeSession( @@ -92,6 +99,54 @@ class ChatCompletionsProviderTests(SimpleTestCase): self.assertFalse(request["json"]["stream"]) self.assertEqual(request["json"]["temperature"], 0.2) + def test_parameters_cannot_override_core_chat_payload_fields(self): + session = FakeSession( + FakeResponse( + { + "choices": [ + {"message": {"content": "1. Safe Title"}} + ] + } + ) + ) + provider = ChatCompletionsProvider(session=session) + model = ResolvedModel( + name="GPT-5.5 text", + url="https://api.vectorengine.ai/v1", + model="gpt-5.5", + api_key="test-key", + api_type="chat", + extra_body={ + "model": "bad-extra-model", + "stream": True, + "temperature": 0.1, + }, + ) + + provider.generate_text( + "Generate titles", + model, + parameters={ + "model": "gpt-image-2", + "n": 5, + "messages": [], + "stream": True, + "size": "4096x4096", + "temperature": 0.2, + }, + ) + + payload = session.posts[0]["json"] + self.assertEqual(payload["model"], "gpt-5.5") + self.assertFalse(payload["stream"]) + self.assertEqual( + payload["messages"], + [{"role": "user", "content": [{"type": "text", "text": "Generate titles"}]}], + ) + self.assertNotIn("n", payload) + self.assertNotIn("size", payload) + self.assertEqual(payload["temperature"], 0.2) + def test_generate_image_parses_chat_multimodal_data_url(self): generated = b"generated-image" encoded = base64.b64encode(generated).decode("ascii") @@ -172,6 +227,44 @@ class ImagesEditsProviderTests(SimpleTestCase): ("source.png", b"source-image", "image/png"), ) + def test_parameters_cannot_override_core_images_edits_fields(self): + generated = b"edited-image" + encoded = base64.b64encode(generated).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", + extra_body={ + "model": "bad-extra-model", + "n": "9", + "size": "4096x4096", + "temperature": 0.3, + }, + ) + + provider.generate_image( + "Replace background", + model, + image=b"source-image", + resolution="1k", + parameters={ + "model": "bad-parameter-model", + "n": "3", + "size": "1x1", + "temperature": 0.7, + }, + ) + + data = session.posts[0]["data"] + self.assertEqual(data["model"], "gpt-image-2") + self.assertEqual(data["n"], "1") + self.assertEqual(data["size"], "1024x1024") + self.assertEqual(data["temperature"], 0.7) + def test_images_edits_requires_input_image(self): provider = ImagesEditsProvider(session=FakeSession()) model = ResolvedModel( @@ -367,7 +460,7 @@ class AiModelsImportTests(TestCase): ) image_alias = ModelAlias.objects.get( operation_type=ModelAlias.OperationType.IMAGE, - alias="image-standard", + alias="image-hd", ) self.assertEqual(title_alias.ai_model, text_model) self.assertEqual(image_alias.ai_model, image_model) @@ -523,7 +616,7 @@ class AiConfigAuditAdminTests(TestCase): ) alias = ModelAlias.objects.create( operation_type=ModelAlias.OperationType.IMAGE, - alias="image-standard", + alias="image-hd", ai_model=text_model, ) alias.ai_model = image_model @@ -588,3 +681,27 @@ class AiGenerationSmokeCommandTests(TestCase): self.assertNotIn("Bearer", text) self.assertEqual(AiModel.objects.count(), 0) self.assertEqual(ModelAlias.objects.count(), 0) + + def test_recorded_image_smoke_runs_through_alias_and_provider_without_persisting(self): + output = io.StringIO() + + call_command( + "smoke_ai_generation", + "image", + "--recorded", + "--prompt", + "生成测试图片", + stdout=output, + ) + + text = output.getvalue() + self.assertIn("Smoke generation OK", text) + self.assertIn("mode=recorded", text) + self.assertIn("operation=image", text) + self.assertIn("alias=t-105-recorded-image-", text) + self.assertIn("model_used=recorded-image-model", text) + self.assertIn("image_bytes=20", text) + self.assertNotIn("sk-recorded-placeholder", text) + self.assertNotIn("Bearer", text) + self.assertEqual(AiModel.objects.count(), 0) + self.assertEqual(ModelAlias.objects.count(), 0) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 8538cc4..0df6719 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -38,12 +38,12 @@ ## 当前阶段 -当前项目处于:**Phase 2 前置**。T-101 Provider 适配器层、T-102 AiModel/ModelAlias 数据模型、T-103 配置变更审计与 T-104 录制标题生成 smoke 已完成,下一步进入 T-201 用户钱包 / API Key / 流水 / 调用记录模型。 +当前项目处于:**Phase 2 前置已收尾**。T-101 Provider 适配器层、T-102 AiModel/ModelAlias 数据模型、T-103 配置变更审计、T-104 录制标题生成 smoke 与 T-105 Phase 1 审核修补已完成,下一步进入 T-201 用户钱包 / API Key / 流水 / 调用记录模型。 优先路径: 1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。 -2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104 已完成 provider 层、模型配置表、别名解析、配置审计与录制标题生成 smoke;真实上游调用待配置 Fernet 主密钥与 AiModel/ModelAlias 后执行。 +2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104/T-105 已完成 provider 层、模型配置表、别名解析、配置审计、录制标题/图片 smoke 与审核修补;真实图片同步耗时待配置 Fernet 主密钥、AiModel/ModelAlias 与真实上游后在 T-302/T-403 前补测。 3. Phase 2:计费核心 —— User/UserWallet/ApiKey 模型 + 点数扣减(并发安全,锁 Wallet 行)+ 计费规则 + 调用记录。 4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 46e383a..d56c999 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -58,7 +58,8 @@ | 创建后台管理员 | T-003 后执行 `py -3.12 manage.py createsuperuser` | | 数据库迁移 | T-002 接入 MySQL 后执行 `makemigrations` / `migrate` | | 导入 cmbot AI 模型配置 | `py -3.12 manage.py import_ai_models path\to\ai_models.json --create-default-aliases` | -| AI 生成 smoke(录制) | `py -3.12 manage.py smoke_ai_generation title --recorded` | +| AI 生成 smoke(录制标题) | `py -3.12 manage.py smoke_ai_generation title --recorded` | +| AI 生成 smoke(录制图片) | `py -3.12 manage.py smoke_ai_generation image --recorded` | | 格式化 / 静态检查 | `ruff check .`(待定,确定后写死) | 统一入口仍是根目录 `./init.ps1`(Windows)或 `./init.sh`(Unix/WSL)。T-001 阶段只做框架检查,不跑迁移;T-002 接入 MySQL 与自定义 User 后再启用迁移路径。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index aecfe1d..ba36019 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -50,13 +50,13 @@ - **Provider 适配器**:每个供应商一个适配器(`apps/ai/providers/`),实现统一接口 `generate_text()` / `generate_image()` / `capabilities()`;用注册表按 `api_type` 选适配器,取代一长串 `if api_type == ...`。移植 `cmbot/src/services/ai_text_service.py`、`ai_image_service.py` 的上游调用与解析逻辑到对应适配器内。**注意当前 3 个模型调用机制不同(见 3.1):文本走标准 chat,`nano-banana2` 走 chat 多模态返图、`gpt-image-2` 走 images/edits 改图,两图片模型非标准生成、需分别解析返回。** - **能力校验前置**:按目标模型声明的 `capabilities` 校验请求(如图片模型不能生成文字),在扣点和调上游**之前**拒掉非法组合。 - **配置热生效**:模型配置从数据库读取,后台修改后运行时及时生效(每次查库或带缓存失效),不在进程内缓存到失效。 -- 输入:prompt、可选图片、能力别名、分辨率、`parameters` 透传;输出:文字或图片,或明确错误。 +- 输入:prompt、可选图片、能力别名、分辨率、`parameters` 安全子集;输出:文字或图片,或明确错误。`parameters` / `extra_body` 不得覆盖服务端固定的核心/计费字段(如 `model`、`n`、`size`、`resolution`、`messages`、`image*`)。 - 处理超时、错误,向上层抛出可分类的异常(区分「上游失败」与「参数错误」)。不在本层写点数逻辑。 **数据库 / 存储** - 持久化:账号、API Key、点数余额、计费规则、汇率、充值订单、点数流水、调用记录、模型配置。 -- 点数余额是权威账本;不持久化上游返回的大图原文时,调用记录只存引用/路径/摘要(大对象存储策略 MVP 待定)。 +- 点数余额是权威账本;不持久化上游返回的大图原文,调用记录只存引用/路径/摘要,不整包保存 provider `raw` 或 base64 图片(大对象存储策略 MVP 待定)。 - 点数余额可变但只能经计费层修改;流水与调用记录只追加、不可改。 ## 三、数据模型 @@ -86,6 +86,7 @@ - `api_key` **使用 Fernet 加密存储到 `api_key_encrypted`**,admin 只提供写入型 `api_key` 字段、脱敏显示、不回显明文;模型/密钥/别名映射的变更需审计留痕。 - `AiConfigAuditLog` 只追加、不在后台编辑删除;密钥变更只记录 `api_key: empty/set` 状态变化,不记录明文 key 或 Fernet 密文。 - 汇率在**充值下单创建订单时**锁定并记录到订单,同时计算预计到账点数;后续改汇率不影响已创建订单。支付回调入账时使用订单上的 `exchange_rate` / `points_granted`,不重新读取当前汇率。 +- Provider 适配器只允许白名单安全参数透传;外部调用方或后台 `extra_body` 传入核心字段时忽略,由服务端按别名解析和计费口径固定。 #### 配置表目标 schema @@ -226,7 +227,7 @@ CREATE TABLE call_record ( status TEXT NOT NULL, -- pending / success / failed upstream_latency_ms INTEGER, error_message TEXT, - result_ref TEXT, -- 结果引用(URL/路径/摘要) + result_ref TEXT, -- 结果引用(URL/路径/摘要),不存 raw/base64 created_at TEXT NOT NULL ); ``` @@ -305,7 +306,7 @@ CREATE TABLE call_record ( | 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | 每模型 timeout 必须生效并设上限;worker 数/网关超时按最慢模型预留;V2 异步化 | | 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 | | 凭证安全 | 上游 api_key、支付密钥 | 应用层加密存储,admin 脱敏不回显;其余密钥走环境变量,不入代码与样例 | -| 抽象泄漏 | 各供应商入参出参不一致(resolution/aspect/vision/返回形态) | Provider 适配器 + capabilities 声明 + `parameters` 透传,核心字段稳定不取交集 | +| 抽象泄漏 / 参数越权 | 各供应商入参出参不一致;若调用方 `parameters` 可覆盖 `model`/`n`/`size` 会击穿别名计费 | Provider 适配器 + capabilities 声明 + `parameters` 白名单过滤;核心/计费字段服务端固定,不取交集、不允许覆盖 | | 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 | | 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 | | 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 | @@ -320,7 +321,7 @@ CREATE TABLE call_record ( 桌面端 cmbot 可以**不改交互骨架**,继续用同步方式调 cmhub、cmhub 再同步转中转站,正常使用——决定成败的不是同步/异步本身,而是下面两件事有没有配对好;配好就稳,配不好会「小量正常、上量假死」: -1. **超时链路层层放大且对齐**(外层 ≥ 内层):桌面端读超时 ≥ Gunicorn `--timeout` / 网关 `proxy_read_timeout` ≥ cmhub→中转站读超时。三个默认值是雷:`AiModel.timeout_seconds`(`ai_models.json` 现为 `0`=无限等,迁入须改有限值)、Gunicorn 默认 `30s`(会直接杀掉跑图片的 worker)、Nginx 默认 `60s`。图片链路建议统一放到 `300s` 量级。 +1. **超时链路层层放大且对齐**(外层 ≥ 内层):桌面端读超时 ≥ Gunicorn `--timeout` / 网关 `proxy_read_timeout` ≥ cmhub→中转站读超时。三个默认值是雷:`AiModel.timeout_seconds`(`0` 表示按 Provider 分辨率默认值,而不是无限等待)、Gunicorn 默认 `30s`(会直接杀掉跑图片的 worker)、Nginx 默认 `60s`。图片链路建议统一放到 `300s` 量级,并在 T-302/T-403 前用一次真实图片生成耗时校准。 2. **worker/线程数按峰值总并发预留**:同步下每个在飞请求占住一个 worker 整个生成周期(几十秒~分钟)。用 Gunicorn `gthread`(`--threads`)或多 sync worker,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。 适用边界:**单接入方、小并发批量**(桌面端 `concurrency` 1~4)完全够用,点数一致性也更简单。当出现「worker 被长连接占满拖慢快接口/后台」「接入方总并发明显上涨」「需要关窗重连/任务持久化」任一信号时,才转 V2 异步(队列),在此之前保持同步;但适配器接口与 `call_record` 的 `pending/success/failed` 三态需为异步预留口子(见 4.1、七、八)。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index c9d66d8..79e2675 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -34,13 +34,14 @@ | T-101 | Provider 适配器层 + 移植 cmbot 调用 | T-002 | 定义 `Provider` 接口(`capabilities`/`generate_text`/`generate_image`),按 `api_type` 注册;把 `ai_text_service.py`/`ai_image_service.py` 搬进 `apps/ai/providers/` 并去除桌面依赖;**注意 3 模型机制不同(chat / chat 多模态返图 nano-banana2 / images_edits 改图 gpt-image-2,见 `04` 3.1),两图片模型非标准生成需分别解析、首次对接抓真实响应**;mock 上游单测验证解析与适配器选取 | DONE | | T-102 | AiModel + ModelAlias 模型 + 别名解析 | T-101 | AiModel 含 `capabilities`、`api_key` **加密存储**(admin 脱敏不回显);ModelAlias 映射别名→模型;`resolve_alias()` 能解析并按能力校验;配置迁移自 `ai_models.json`;后台改配置运行时热生效 | DONE | | T-103 | 配置变更审计 | T-102 | AiModel/ModelAlias/密钥的后台变更留痕(谁、何时、改了什么);可在 admin 查看 | DONE | -| T-104 | 跑通一次真实/录制的标题或图片生成 | T-102 | 用别名 + 最小输入跑通一次生成,结论写入 `progress.md`(含耗时,验证图片同步可行性与超时配置) | DONE | +| T-104 | 跑通一次真实/录制的标题或图片生成 | T-102 | 用别名 + 最小输入跑通一次生成,结论写入 `progress.md`(含耗时,验证图片同步可行性与超时配置) | DONE(管路已验;**图片同步耗时风险未退,已挂到 T-302/T-403,见 phase-1-review P1-2**) | +| T-105 | Phase 1 AI 层审核修补 | T-104 | 按 [`phase-1-review.md`](phase-1-review.md) 修 **P1**(`parameters`/`extra_body` 透传白名单化,核心/计费字段不可被调用方覆盖,含单测;T-104 结论改写为「图片同步风险未退」并把该未决风险显式登记到 T-302/T-403)与 **P2**(`.env.example` 的 `AI_DEFAULT_*` 超时键接上或删除、`resolution_to_size` 大小写归一、默认别名口径对齐);`check`/`test`/`init` 全绿并在 `../progress.md` 留证据。**P1-1 建议先于 T-302 完成**(否则易顺手 `parameters=request.data` 造成越权计费) | DONE | ## Phase 2 · 计费核心 | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-201 | User / UserWallet / ApiKey / PointsLedger / CallRecord 模型 | T-002 | 表结构符合 `04-architecture.md`;`UserWallet.points_balance>=0` 约束;ApiKey **哈希存储**(key_hash+key_prefix,明文只创建时返回);CallRecord 含 `user`/`api_key`/`alias`/`model_used`;admin 注册 | TODO | +| T-201 | User / UserWallet / ApiKey / PointsLedger / CallRecord 模型 | T-002 | 表结构符合 `04-architecture.md`;`UserWallet.points_balance>=0` 约束;ApiKey **哈希存储**(key_hash+key_prefix,明文只创建时返回);CallRecord 含 `user`/`api_key`/`alias`/`model_used`;调用结果只存 `result_ref`/摘要,不 dump provider `raw`、base64 图片或敏感上游字段;admin 注册 | TODO | | T-202 | PricingRule / ExchangeRate 模型 + 计费计算 | T-201, T-102 | **按「操作 + 能力别名(+ 可选分辨率)」定价**;换底层模型不影响计费;缺规则返回 `no_pricing_rule` | TODO | | T-203 | 并发安全扣点 / 退点(billing 层) | T-201 | 锁 `UserWallet` 行或 F() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | TODO | @@ -49,7 +50,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-301 | API Key 鉴权(DRF Authentication) | T-201 | 对请求 Key 哈希比对定位 ApiKey→User;**只挂 Key 认证、不挂 Session**;无效/缺失 401;用户或 Key 禁用 403 | TODO | -| T-302 | 生成标题 / 图片接口 | T-104, T-203, T-301 | 按 `api.md` 实现;请求传**能力别名**+ `parameters` 透传;编排「别名解析+能力校验→预扣→调上游→成功确认/失败退点→写记录」;点数不足返回 402;含测试 | TODO | +| T-302 | 生成标题 / 图片接口 | T-104, T-203, T-301 | 按 `api.md` 实现;请求传**能力别名**+ `parameters`,但 `parameters` 只能经 Provider 白名单透传,核心/计费字段不可被覆盖;编排「别名解析+能力校验→预扣→调上游→成功确认/失败退点→写记录」;评估 `Provider.capabilities()` 与模型声明能力的二次校验;`images_edits` 缺原图 / `AiCapabilityError` 翻译为 400,不落 500;调用记录不保存 provider `raw` / base64;点数不足返回 402;含测试;建议随本任务或 T-403 前跑一次真实图片生成并记录真实耗时 | TODO | | T-303 | 余额查询接口 | T-301 | 返回余额等于流水累加;含测试 | TODO | | T-304 | 充值回调(微信/支付宝验签 + 幂等入账) | T-202 | 两端点 `@csrf_exempt`;微信 SDK 验签解密、支付宝 SDK verify;验签失败不入账;同一 order_no 重复回调只入账一次;校验回调金额与订单金额一致;使用订单创建时锁定的 `points_granted` 锁 wallet 入账写流水;补主动查单兜底;含幂等测试 | TODO | | T-305 | 扫码充值下单 + 轮询(create/status) | T-304 | 支持 weixin(native,金额分)/alipay(precreate,金额元);建 pending 订单绑定 user,并在下单时锁定汇率/预计点数→取 code_url/qr_code→前端渲染 + 轮询 status;缺商户密钥时 mock | TODO | @@ -69,7 +70,7 @@ | --- | --- | --- | --- | --- | | T-401 | 运营后台完善 | T-302, T-304 | admin 可管理用户/钱包/ApiKey(脱敏)、配规则/汇率、检索充值订单/流水/调用记录;手工调点带原因并经计费层写流水 | TODO | | T-402 | 完整验收 MVP | T-401, T-504 | `02-requirements.md` 的 P0 验收全部通过(含用户端注册/充值/API Key/记录) | TODO | -| T-403 | 部署 / 运行文档 | T-402 | 新环境可按文档运行;明确图片接口超时配置 | TODO | +| T-403 | 部署 / 运行文档 | T-402 | 新环境可按文档运行;明确图片接口超时配置;上线前必须引用一次真实图片生成耗时来设置 Gunicorn `--timeout`、Nginx `proxy_read_timeout`、客户端 read timeout;若尚无真实耗时,部署文档必须显式标注图片同步风险未退,不得声称已验证 | TODO | ## 里程碑 @@ -86,4 +87,4 @@ - 别名按比例分流到多个模型(灰度 / A/B / 故障转移);供应商 A 故障自动切 B。 - 注册赠点 / 试用额度(需邮箱/图形验证码 + 限流防薅羊毛)。 - 用量统计报表。 -- 退款对账自动化、API Key 轮换、限流细化。 +- 退款对账自动化、API Key 轮换、Provider key 轮换(MultiFernet / 双 key 迁移)、限流细化。 diff --git a/docs/README.md b/docs/README.md index 9166020..82babb1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ - [编码规则](05-coding-rules.md):硬性约束,含资金/点数安全专项(第 8 节)。 - [任务看板](06-tasks.md):按依赖拆分的小任务,agent 每轮只做一个。 - [Phase 0 骨架审核](phase-0-review.md):T-001~003 代码审核结论与修补清单(P1/P2/P3),对应任务 T-004。 +- [Phase 1 AI 层审核](phase-1-review.md):T-101~104 代码审核结论与修补清单(P1/P2/P3),对应任务 T-105;重点提示图片同步风险未退与 `parameters` 越权计费隐患。 - [API 合约](api.md):对外接口、支付回调、AI 调用模块合约、错误码。 - [路由与页面结构](routes.md):API 路由与 django-admin 后台职责。 - [环境变量与配置](env.md):Django、数据库、AI 密钥加密、支付、对象存储等配置项。 diff --git a/docs/api.md b/docs/api.md index 857b3f1..91e4d3b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -42,7 +42,7 @@ ## 对外接口 -> **重要约定**:`model` 字段传的是**能力别名**(如 `title-standard` / `image-hd`),不是具体供应商模型名。后台把别名映射到当前的具体模型,换供应商时调用方零改动。供应商特有参数放 `parameters` 透传对象,核心字段保持稳定。 +> **重要约定**:`model` 字段传的是**能力别名**(如 `title-standard` / `image-hd`),不是具体供应商模型名。后台把别名映射到当前的具体模型,换供应商时调用方零改动。供应商特有参数放 `parameters`,但只允许 Provider 白名单内的安全参数透传;`model`、`n`、`size`、`resolution`、`messages`、`image*` 等核心/计费字段由服务端固定,调用方传入时忽略。 ### `POST /api/v1/generate/title` @@ -54,7 +54,7 @@ "model": "title-standard", // 能力别名,非具体模型名;可选,缺省用该操作的默认别名 "image_url": "https://...", // 可选,看图生成时传商品图(或 image_base64) "resolution": "1K", // 可选,影响计费规则匹配 - "parameters": {} // 可选,供应商特有参数透传,未知字段忽略 + "parameters": {} // 可选,安全供应商参数;未知字段/核心字段忽略 } ``` @@ -84,7 +84,7 @@ "image_base64": "data:image/png;base64,...", // 或 image_url;改图类模型(如映射到 gpt-image-2)原图必传 "resolution": "1K", "aspect_ratio": "1:1", - "parameters": {} // 可选,供应商特有参数透传 + "parameters": {} // 可选,安全供应商参数;未知字段/核心字段忽略 } ``` @@ -101,7 +101,7 @@ } ``` -要点:上游失败返回 `upstream_error` 且**不扣点**(已预扣则退回)。结果默认存对象存储返回 `image_url`,避免同步响应体过大。 +要点:改图类模型缺原图返回 `bad_request`,不落 500;上游失败返回 `upstream_error` 且**不扣点**(已预扣则退回)。结果默认存对象存储返回 `image_url`,避免同步响应体过大。 ### `GET /api/v1/balance` @@ -217,9 +217,9 @@ class Provider(Protocol): 要点: - `ResolvedModel` 来自数据库 AiModel(形状同 `cmbot/config/ai_models.json`,外加 `capabilities`;`api_key` 在库中以 Fernet 加密,使用时解密,不落明文)。 -- `TextGenerationResult` 包含 `text`、清洗后的 `titles`、`model_used`、`raw`;`ImageGenerationResult` 包含图片 bytes、`model_used`、`raw`。图片落对象存储并返回 URL 属 T-302 之后的 API 编排职责。 +- `TextGenerationResult` 包含 `text`、清洗后的 `titles`、`model_used`、`raw`;`ImageGenerationResult` 包含图片 bytes、`model_used`、`raw`。`raw` 仅供本次解析/排障摘要使用,调用记录不得整包保存或打印,避免 base64 大图和上游敏感字段入库;图片落对象存储并返回 URL 属 T-302 之后的 API 编排职责。 - 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。 -- `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。 +- `parameters` 为供应商特有参数的安全子集;适配器负责白名单过滤并把统一入参翻译成各家上游格式,核心字段不可被 `parameters` 或 `extra_body` 覆盖。 - 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。 - 配置审计:后台保存/删除 AiModel、ModelAlias 时写 `AiConfigAuditLog`;记录 actor/action/target/changed_fields/changes/created_at,admin 只读查看;密钥变更只记录 empty/set 状态。 - 区分异常:别名/能力不匹配 → 业务错误(400 类,如 `model_not_allowed`);上游网络/超时/服务错误 → `upstream_error`(502,触发退点)。 diff --git a/docs/current-state.md b/docs/current-state.md index 97c8d06..3339b39 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,16 +12,16 @@ ## 当前快照 - 日期:2026-07-02 -- 阶段:Phase 1 已完成;下一步 T-201 +- 阶段:Phase 1 已完成并完成 T-105 审核修补;下一步进入 Phase 2 的 T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型 - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md` -- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104 已新增 `smoke_ai_generation` 管理命令并跑通录制标题生成 smoke -- 测试:`smoke_ai_generation title --recorded` 通过(录制标题,149ms);`manage.py test apps.ai.tests.AiGenerationSmokeCommandTests --noinput --keepdb` 通过(1 test);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`./init.ps1` 通过。`manage.py test apps.ai --noinput --keepdb` 多次在远程 MySQL 连接阶段超时,非断言失败 +- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104 已新增 `smoke_ai_generation title --recorded`;T-105 已完成 provider 参数白名单、`resolution_to_size` 归一、默认图片别名 `image-hd`、`smoke_ai_generation image --recorded` 和后续任务风险登记 +- 测试:T-105 相关 10 tests 通过;`smoke_ai_generation image --recorded` 通过(录制图片,`image_bytes=20`);`smoke_ai_generation title --recorded` 通过(录制标题,92ms);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`git diff --check` 仅 Windows CRLF 提示;`./init.ps1` 通过。`manage.py test apps.ai --noinput --keepdb` 重试时在远程 MySQL `43.128.3.240:3306` 连接/重连阶段超时(非断言失败) - 数据: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 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策 - 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算 -- 当前 blocker:无。支付商户密钥/证书仍缺真实值,但不阻塞当前 Phase 2;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。 +- 当前 blocker:无。支付商户密钥/证书仍缺真实值,但不阻塞当前 Phase 2;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。 ## 当前目录要点 @@ -33,7 +33,7 @@ | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` | | `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 | | `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | -| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104 已新增 `smoke_ai_generation` 录制 smoke 命令 | +| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令,provider 参数白名单与分辨率归一已落地 | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -41,7 +41,7 @@ 任务状态以 [`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-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 层审核修补。 - 正在进行:无。 - 当前 blocker:无。 - 下一个可领取任务:**T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型**。 @@ -56,6 +56,7 @@ py -3.12 manage.py test py -3.12 manage.py runserver py -3.12 manage.py import_ai_models path\to\ai_models.json --create-default-aliases py -3.12 manage.py smoke_ai_generation title --recorded +py -3.12 manage.py smoke_ai_generation image --recorded # Unix / WSL python3.12 -m pip install -r requirements.txt @@ -64,9 +65,10 @@ python3.12 manage.py test python3.12 manage.py runserver python3.12 manage.py import_ai_models path/to/ai_models.json --create-default-aliases python3.12 manage.py smoke_ai_generation title --recorded +python3.12 manage.py smoke_ai_generation image --recorded ``` -当前骨架可运行。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 已用临时回滚配置跑通录制标题生成:`alias=t-104-recorded-title-c054d919`,`model_used=recorded-title-model`,耗时 149ms,输出 3 个标题。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题。 +当前骨架可运行。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 已用临时回滚配置跑通录制标题和录制图片生成,临时 `AiModel` / `ModelAlias` 不持久化、不打印 Bearer/key。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。 ## 开始编码前检查 diff --git a/docs/env.md b/docs/env.md index f046e6b..7cddaa7 100644 --- a/docs/env.md +++ b/docs/env.md @@ -38,13 +38,13 @@ | 变量 | 必填 | 示例 | 说明 | | --- | --- | --- | --- | | `AI_KEY_ENCRYPTION_KEY` | 是 | `base64-fernet-key` | Fernet 主密钥,用于加密 `AiModel.api_key_encrypted`;生产不可更换,除非完成密钥轮换 | -| `AI_DEFAULT_CONNECT_TIMEOUT_SECONDS` | 否 | `10` | 上游连接超时默认值 | -| `AI_DEFAULT_READ_TIMEOUT_SECONDS` | 否 | `300` | 图片同步生成链路建议 300 秒量级 | `AI_KEY_ENCRYPTION_KEY` 必须是 `cryptography.fernet.Fernet.generate_key()` 生成的 base64 字符串,可用 `py -3.12 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成。 `AiModel.url`、`AiModel.model`、`AiModel.api_type`、`AiModel.capabilities` 与加密后的 `api_key` 由后台或数据迁移维护,不通过环境变量硬编码具体模型。 +AI 上游连接超时与读取超时不走全局环境变量:连接超时由 `AiModel.connect_timeout_seconds` 控制,读取超时由 `AiModel.timeout_seconds` 控制;当读取超时为 `0` 时,Provider 按分辨率使用内置默认值。 + ## 五、支付配置 ### 微信 V3 native diff --git a/docs/phase-1-review.md b/docs/phase-1-review.md new file mode 100644 index 0000000..18425ca --- /dev/null +++ b/docs/phase-1-review.md @@ -0,0 +1,106 @@ +# Phase 1 骨架审核报告(T-101 ~ T-104) + +> 审核人:Claude Code(全栈视角)|日期:2026-07-02|结论:**验收通过,但最高风险未退**。 +> Phase 1 是全项目最高风险段(AI 上游同步调用 + 可插拔供应商 + 密钥)。代码质量高、三模型机制忠实落地;但 **T-104 只验证了管路,没退掉「图片同步耗时」这个核心风险**,且 `parameters` 透传存在资金安全隐患。 +> 本文面向 codex 执行:每条修补项给出「症状 / 位置 / 怎么改 / 怎么验证」。修补任务见 [`06-tasks.md`](06-tasks.md) 的 **T-105**。 + +## 一、验收核对 + +| 任务 | 验收要点 | 结果 | +| --- | --- | --- | +| T-101 | Provider 接口 + 按 api_type 注册;移植 cmbot 调用去桌面依赖;3 模型机制分别解析;mock 单测 | ✅ | +| T-102 | AiModel/ModelAlias + `api_key` 加密存储(admin 脱敏不回显);`resolve_alias()` 按能力校验;导入 `ai_models.json`;后台改配置热生效 | ✅ | +| T-103 | AiModel/ModelAlias/密钥后台变更留痕(谁/何时/改了什么);admin 可查 | ✅ | +| T-104 | 用别名 + 最小输入跑通一次生成,结论写入 progress(含耗时,**验证图片同步可行性与超时配置**) | ⚠️ **部分**:管路跑通,但只跑了 recorded **标题**,图片同步耗时/超时这一核心目的未验证(见 P1-2) | + +## 二、做对的(勿在修补中回退) + +以下是已正确落地的关键点,T-105 修补时**不要改坏**: + +1. **三模型机制忠实落地**:`chat`(GPT-5.5 文本)、`auto→chat` 多模态返图(Nano Banana 2 走 `ChatCompletionsProvider.generate_image`)、`images_edits` 改图(GPT Image 2,`ImagesEditsProvider` 强制要求原图)。注册表按 `api_type` 选适配器,`auto` 按 URL 探测(`detect_api_type`),与 `04-architecture.md` 3.1 一致。 +2. **超时策略修对了桌面端的坑**:`timeout_seconds=0` 不再是「无限」,而是按分辨率映射到有限值(512→180s / 1K→240s / 2K→360s / 4K→600s,见 `providers/utils.py:RESOLUTION_TIMEOUTS`);connect/read 分离。这正是同步部署最关键的一处。 +3. **密钥零泄露闭环**:Fernet 加密、`fernet:` 前缀、**拒绝明文 fallback**、缺 `AI_KEY_ENCRYPTION_KEY` 时 `_fernet()` fail-closed 报错;admin 用写入型 `api_key` 字段(`PasswordInput`,不回显)、`api_key_encrypted` 设 `editable=False`;审计只记 `api_key: empty/set` 状态,**绝不记明文或密文**(`audit._sanitize_value`)。测试正向断言了「密文不含明文、不含 `fernet:`」。 +4. **审计 admin 真只读**:`AiConfigAuditLogAdmin` 的 add/change/delete 权限全 `False`,`get_readonly_fields` 覆盖全字段;`delete_queryset` 也逐条留痕,不只 `delete_model`。 +5. **测试与运行卫生**:`session.trust_env=False`(防止串到系统代理);smoke 命令用 `override_settings + transaction.atomic + set_rollback(True)` 临时建配置跑完即回滚,不落业务库、不打印 `Bearer`/占位 key,并有测试断言。 +6. **热生效 + 能力前置校验**:`resolve_alias()` 每次查库取 active 配置(无进程内缓存到失效);标题要求 `text` 能力、图片要求 `image` 能力,能力不匹配抛 `ModelCapabilityError`。 +7. **别名唯一性 DB 级**:`ModelAlias` 有 `UniqueConstraint(operation_type, alias)`;单默认由 `clean()`/`save(full_clean)` 兜。 + +## 三、修补清单 + +### P1 · 进 T-302(对外生成接口)落地前必须处理 + +#### P1-1 `parameters` / `extra_body` 无差别覆盖核心字段 —— 资金安全隐患 + +- **症状**:`providers/openai_compatible.py:apply_extra_body()` 做的是 `payload.update(model.extra_body)` 后再 `payload.update(parameters)`。`parameters` 是**调用方透传**(见 `api.md`)。这意味着 T-302 把外部请求的 `parameters` 直接喂进来时,调用方可以覆盖服务端固定的核心/计费字段: + - 覆盖 `model` → 实际调用与「计费别名」不同的上游 SKU(更贵/未授权),**直接击穿「按别名计费、换底层模型不影响计费」的契约**(`05-coding-rules.md` §8、`04-architecture.md` 3.1)。 + - 注入/覆盖 `n`(chat/images 一次返多图)、`size`/`resolution`/`aspect_ratio` → **绕过按分辨率定价**,一次请求放大上游成本。 + - 覆盖 `messages`/`stream` → 破坏请求编排。 + - `images_edits` 走的是 multipart `data`,`apply_extra_body(data, ...)` 同样能被覆盖 `model`/`size`/`n`。 +- **位置**:`apps/ai/providers/openai_compatible.py`(`apply_extra_body` 及各 provider 组装 payload 处)。 +- **怎么改**:把「服务端权威字段」与「调用方可透传字段」分离—— + 1. 服务端固定字段(`model`、`messages`/`contents`、`stream`、`n`、`size`、`resolution`、`aspect_ratio`、`image*`)在 payload 组装后**最后写入,不允许被 parameters 覆盖**; + 2. `parameters` 只允许一个**白名单安全子集**(如 `temperature`、`top_p`、`max_tokens`、`seed` 等无计费/无路由影响的),未知键丢弃或显式拒绝; + 3. `extra_body`(后台运营配置,可信)可保留覆盖能力,但仍不应覆盖 `model`。 +- **归属**:这个 seam 在 T-101 建成,真正被外部输入触达是在 **T-302**。**至少要在 T-302 开工前修好**,或在本层加显式约束 + 注释,避免 T-302 顺手 `parameters=request.data` 造成越权计费。 +- **验证**:新增单测——传 `parameters={"model":"gpt-image-2","n":5,"temperature":0.2}`,断言最终 payload 的 `model` 仍是 `ResolvedModel.model`、`n` 未被改,`temperature` 生效。 + +#### P1-2 T-104 未退掉「图片同步耗时」这一全项目最高风险 + +- **症状**:Phase 1 存在的**唯一理由**是验证最高风险假设——「同步请求上游图片生成,在 Gunicorn(默认 30s worker timeout)/ Nginx 下不会超时」。T-104 的验收要点原文即「验证图片同步可行性与超时配置」。但实际只跑了 `smoke_ai_generation title --recorded`: + - 是 **标题**(chat 文本),不是图片; + - 是 **recorded**(`RecordedSession` 本地返回,无网络),`elapsed_ms=149` 对「同步耗时够不够」这个问题**没有任何信息量**。 + - 结论:管路(别名解析 → provider 选择 → 响应解析 → 耗时记录)已验证;**核心风险未退**。 +- **位置**:`apps/ai/management/commands/smoke_ai_generation.py`(目前 `operation` 只接受 `title`);风险登记见 `06-tasks.md` / `progress.md`。 +- **怎么改**: + 1. 把 T-104 的结论明确改写为「管路已验、图片同步风险未退」,不要留下「T-104 = 同步可行性已验证」的错觉; + 2. 在依赖同步部署前(最迟 **T-403 部署**、建议随 T-302 一并)**跑一次真实或贴近真实延迟的「图片」生成**(Nano Banana 2 或 GPT Image 2),记录**真实耗时**,据此定 Gunicorn `--timeout` 与 Nginx `proxy_read_timeout`; + 3. smoke 命令补 `operation=image`(可先 recorded 验证图片解析路径,再在配置真实 key 后去掉 `--recorded` 实测)。 +- **不阻塞**:Phase 2 计费编码可先行;但这条风险必须在看板**显式登记为未决**,不能被 T-104 的 DONE 状态掩盖。 +- **验证**:progress 中出现一条带**真实网络耗时**的图片生成记录(毫秒级不算),且 T-403 的超时配置引用该实测值。 + +### P2 · 规范性 / 一致性 + +#### P2-1 `.env.example` 的 AI 超时默认键代码未读取 + +- **症状**:`.env.example` 声明了 `AI_DEFAULT_CONNECT_TIMEOUT_SECONDS=10` / `AI_DEFAULT_READ_TIMEOUT_SECONDS=300`,但**全代码库无任何地方读取**(provider 用的是 `model.connect_timeout_seconds` 默认 30 与分辨率超时表)。属于「文档承诺了、代码没兑现」的死配置,会误导运维以为改这两个值能生效。 +- **位置**:`.env.example` / `docs/env.md` ↔ `apps/ai/providers/`。 +- **怎么改(二选一)**:**A(推荐)** 在 provider/`ResolvedModel` 缺省值处接上这两个 env 作为**全局兜底默认**(模型未单独配置时用它),保持文档兑现;**B** 从 `.env.example` 与 `env.md` 删除这两个键,避免误导。选哪个都要让文档与代码一致。 +- **验证**:`grep` 确认「声明即被读取」或「已删除」;若走 A,加一条断言全局默认生效的测试。 + +#### P2-2 `resolution_to_size` 未做大小写归一 + +- **症状**:`utils.resolution_timeout()` 有 `.upper()` 归一,但 `resolution_to_size()` 没有。传入 `"1k"`(小写)时,超时表能命中,size 表 miss → 原样把 `"1k"` 作为 `size` 发给 `images/edits`,上游大概率 400。 +- **位置**:`apps/ai/providers/utils.py:resolution_to_size()`。 +- **怎么改**:与 `resolution_timeout` 一致地归一化键(如统一 `.strip().upper()` 后查表,或在入口统一 resolution 取值)。 +- **验证**:单测 `resolution_to_size("1k") == "1024x1024"`。 + +#### P2-3 导入器默认别名 `image-standard` 与文档示例 `image-hd` 不一致 + +- **症状**:`importers._ensure_default_aliases` 建的默认图片别名叫 `image-standard`;而 `api.md` / `02-requirements.md` / `04-architecture.md` 的示例别名是 `image-hd`。别名是运营可配的、非硬契约,功能无害,但对外文档与默认落地口径不一致,容易让接入方困惑。 +- **位置**:`apps/ai/importers.py` ↔ `docs/api.md` 等。 +- **怎么改**:二选一对齐——把默认别名改成 `image-hd`,或在文档注明「`image-hd` 仅为示例,导入器默认建 `image-standard`,以后台实际配置为准」。 +- **验证**:文档与导入器口径一致。 + +### P3 · 登记 / 后续处理(不在 T-105 硬性范围) + +- **`Provider.capabilities()` 半冗余**:全项目无调用点(能力校验实际走 `AiModel.capabilities_set()`)。保留无害,建议加注释说明用途,或在 T-302 用作「provider 能力 ∩ 模型声明能力」的二次校验。 +- **图生图 vs 文生图都归 `operation_type=IMAGE`**:当别名指向 `images_edits` 模型而调用方没传原图,当前抛 `AiCapabilityError`。T-302 需把它翻译成干净的 **400(缺原图)** 而不是 500。 +- **CallRecord 写入禁止 dump `result.raw`**:`raw` 可能含 base64 大图/上游敏感字段;T-201/T-302 落记录时只存摘要/引用,别整包入库或打日志。 +- **Provider 密钥无轮换**:单 `AI_KEY_ENCRYPTION_KEY`(非 `MultiFernet`),`env.md` 已注明「生产不可更换除非完成密钥轮换」,但轮换流程未实现。登记 Backlog(现有条目是 user API Key 轮换,补「provider key 轮换」)。 +- **ModelAlias 单默认/唯一仅 app 级保证**:`clean()` 有理论并发 race;后台改配置低并发,可接受。codex 已记「MySQL partial unique 约束留待评估」,保留登记即可。 + +## 四、说明:未本地复跑 + +审核机(WSL)无 `python3.12`,**未本地复跑 `check`/`test`**。本报告基于:静态审查(providers / models / security / aliases / audit / importers / admin / tests / 迁移 / settings)+ codex 的执行记录(T-101 7 tests、T-102 18 tests、T-103 23 tests、T-104 recorded smoke,含真实 MySQL 建库超时的诚实记录,可信度高)。 + +其中 **T-104 的 `elapsed_ms=149` 是 recorded 本地耗时,不能作为「图片同步可行性」的证据**(见 P1-2)。 + +T-105 修补后,请重跑 `check` / `test` / `init`,并把命令与结果记入 `progress.md`(按 `06-tasks.md` 使用规则第 5 条)。 + +## 五、T-105 完成定义 + +- **P1-1** 已处理:`parameters` 透传白名单化 / 核心字段不可被覆盖,且有单测;或在本层加显式约束 + 注释并在 T-302 验收项中明确落地要求。 +- **P1-2** 已处理:T-104 结论改写为「图片同步风险未退」,`06-tasks.md` / `progress.md` **显式登记该未决风险**并挂到 T-302 / T-403;smoke 命令支持 `image`。(真实图片实测可延到有真实 key 时,但登记不能少。) +- **P2-1 / P2-2 / P2-3** 全部处理(文档与代码一致、`resolution_to_size` 归一、别名口径对齐)。 +- `check` 0 issues、`test` 全绿、`init` 通过,证据入 `progress.md`。 +- P3 各项已在 `06-tasks.md` 对应任务(T-302 / T-403)或 Backlog 有登记,不遗失。 diff --git a/progress.md b/progress.md index d4231f5..d1c2900 100644 --- a/progress.md +++ b/progress.md @@ -363,3 +363,42 @@ - 阻塞:真实上游 smoke 未执行,因为当前环境未配置 `AI_KEY_ENCRYPTION_KEY`,且业务库没有 AiModel/ModelAlias。配置 Fernet 主密钥并导入模型后,可去掉 `--recorded` 用同一命令跑真实标题 smoke。 - 决策:T-104 采用“录制标题生成”作为验收路径,验证别名解析、Provider 选择、响应解析和同步耗时记录;真实图片同步耗时仍需在后续配置真实模型后补测。 - 下一步:领取 T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型。 + +## 2026-07-02 Phase 1 审核(Claude Code review,非任务) + +- 状态:DONE(审核完成) +- 变更:新增 `docs/phase-1-review.md`;`06-tasks.md` 新增 T-105 修补任务、T-104 状态标注「图片同步风险未退」;`docs/README.md` 导航登记;`current-state.md` 阶段/下一步改 T-105。 +- 审核结论:**T-101~104 验收达标,代码质量高**。三模型机制忠实落地(chat / auto→chat 多模态返图 nano-banana2 / images_edits 改图 gpt-image-2);`timeout_seconds=0→按分辨率有限超时` 修对了桌面端「0=无限」的坑;Fernet 加密 + 拒绝明文 fallback + 缺主密钥 fail-closed + admin 写入型脱敏字段 + 审计只记 empty/set,密钥零泄露闭环;审计 admin 真只读、delete_queryset 也留痕;smoke 用 override_settings+atomic+rollback 不落库、不打印 Bearer/key。 +- 发现(详见 `phase-1-review.md`): + - P1-1(进 T-302 前必修,资金安全):`apps/ai/providers/openai_compatible.py:apply_extra_body` 无差别 `payload.update(parameters)`,调用方透传可覆盖 `model`/`n`/`size`/`resolution` 等核心/计费字段,击穿「按别名计费」契约。须白名单化、核心字段服务端固定不可覆盖。 + - P1-2(最高风险未退):T-104 只跑了 recorded **标题**(`elapsed_ms=149` 无网络意义),Phase 1 存在的唯一理由「图片同步在 Gunicorn/nginx 下不超时」**未验证**。结论应改为「管路已验、图片同步风险未退」,并在依赖同步部署前(最迟 T-403)跑一次真实图片生成记录真实耗时。 + - P2:`.env.example` 的 `AI_DEFAULT_CONNECT/READ_TIMEOUT_SECONDS` 代码未读取(死配置,接上或删除);`resolution_to_size` 未大小写归一(`"1k"` 漏命中);导入器默认别名 `image-standard` 与文档示例 `image-hd` 口径不一致。 + - P3(登记):`Provider.capabilities()` 无调用点(半冗余);图生图 vs 改图都归 `operation_type=IMAGE`,T-302 需把「缺原图」翻成 400 而非 500;CallRecord 写入禁 dump `result.raw`(含 base64 大图/敏感);provider 密钥无轮换(单 Fernet key)。 +- 未本地复跑:审核机无 `python3.12`,结论基于静态审查(providers/models/security/aliases/audit/importers/admin/tests/迁移/settings)+ codex 验证记录(7→18→23 tests 递增、含真实 MySQL 建库超时的诚实记录,可信度高)。T-104 的 149ms 不作为图片同步可行性证据。 +- 下一步:codex 领 T-105,按 `phase-1-review.md` 修 P1/P2;P1-1 须先于 T-302,全绿留证。 + +## 2026-07-02 T-105 Phase 1 AI 层审核修补 + +- 状态:DONE +- 变更: + - `apps/ai/providers/openai_compatible.py`:`parameters` / `extra_body` 改为白名单安全透传,`model`、`messages`、`n`、`size`、`stream` 等核心/计费字段不可被覆盖;`generationConfig` 仅允许安全子字段合并。 + - `apps/ai/providers/utils.py`:`resolution_to_size()` 增加大小写归一,`"1k"` / `"512px"` 可正确映射。 + - `apps/ai/importers.py` 与 `import_ai_models` 帮助文案:默认图片别名从 `image-standard` 对齐为 `image-hd`。 + - `smoke_ai_generation` 支持 `image --recorded`,录制图片路径走别名解析 + Provider 解析 + 事务回滚,不打印 key/Bearer、不持久化临时配置;保留真实 image smoke 入口和 `--image-file`。 + - 扩展 `apps/ai/tests.py`:覆盖 provider 参数越权拦截、分辨率归一、默认别名、录制 image smoke。 + - `.env.example` / `docs/env.md` 删除未被代码读取的 `AI_DEFAULT_CONNECT_TIMEOUT_SECONDS` / `AI_DEFAULT_READ_TIMEOUT_SECONDS`,改为说明超时来自 `AiModel` 字段与 Provider 分辨率默认值。 + - 同步更新 `docs/06-tasks.md`、`docs/api.md`、`docs/04-architecture.md`、`docs/03-tech-stack.md`、`README.md`、`docs/00-ai-start-here.md`、`docs/current-state.md`:T-105 完成,P1/P2 已修;P3 已挂到 T-201/T-302/T-403 或 Backlog。 +- 验证: + - `py -3.12 -m py_compile apps\ai\providers\openai_compatible.py apps\ai\providers\utils.py apps\ai\importers.py apps\ai\management\commands\smoke_ai_generation.py apps\ai\tests.py`:通过。 + - `py -3.12 manage.py test apps.ai.tests.ProviderUtilsTests apps.ai.tests.ChatCompletionsProviderTests apps.ai.tests.ImagesEditsProviderTests apps.ai.tests.AiModelsImportTests apps.ai.tests.AiGenerationSmokeCommandTests --noinput --keepdb`:通过,10 tests OK。 + - `py -3.12 manage.py smoke_ai_generation image --recorded --prompt "生成测试图片" --resolution 1k`:通过;`alias=t-105-recorded-image-27c25b3a`,`model_used=recorded-image-model`,`elapsed_ms=71`,`image_bytes=20`。 + - `py -3.12 manage.py smoke_ai_generation title --recorded --prompt "为测试商品生成3个中文标题" --resolution 1K`:通过;`alias=t-104-recorded-title-ceec8dab`,`model_used=recorded-title-model`,`elapsed_ms=92`,`title_count=3`。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `py -3.12 manage.py makemigrations --check`:通过,No changes detected。 + - `py -3.12 -m compileall apps`:通过。 + - `git diff --check`:通过,仅 Windows CRLF 提示。 + - `./init.ps1`:通过,依赖同步与基础检查正常。 + - `py -3.12 manage.py test apps.ai --noinput --keepdb`:两次未作为绿灯;一次在连接远程 MySQL 测试库前超时,一次跑到 23 tests 后于 `AiModelAdminTests.setUpClass` 事务连接阶段超时。失败点均为远程 MySQL `43.128.3.240:3306` 连接/重连,不是断言失败。 +- 阻塞:无代码阻塞。真实图片同步耗时仍未验证,已登记到 T-302/T-403;需要真实 `AI_KEY_ENCRYPTION_KEY`、AiModel/ModelAlias 与上游 key 后执行。 +- 决策:对外 `parameters` 不是任意直通上游,而是 provider 白名单安全参数;服务端固定字段优先,计费仍以能力别名/分辨率为准。未生效的全局 AI timeout 环境变量删除,避免运维误以为可通过 env 调整。 +- 下一步:领取 T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型。