fix: address phase 1 ai review

This commit is contained in:
QiuSW
2026-07-02 14:47:22 +08:00
parent 2d9c233683
commit 2f93374193
18 changed files with 499 additions and 56 deletions
-2
View File
@@ -14,8 +14,6 @@ MYSQL_CHARSET=utf8mb4
# AI key encryption # AI key encryption
AI_KEY_ENCRYPTION_KEY=base64-fernet-key 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 V3 native
WECHAT_PAY_APPID=wx-your-appid WECHAT_PAY_APPID=wx-your-appid
+1 -1
View File
@@ -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) 第四节计费时序。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+1 -1
View File
@@ -124,7 +124,7 @@ def _ensure_default_aliases(models: list[AiModel]) -> int:
).update(is_default=False) ).update(is_default=False)
ModelAlias.objects.update_or_create( ModelAlias.objects.update_or_create(
operation_type=ModelAlias.OperationType.IMAGE, operation_type=ModelAlias.OperationType.IMAGE,
alias="image-standard", alias="image-hd",
defaults={"ai_model": image_model, "is_default": True, "is_active": True}, defaults={"ai_model": image_model, "is_default": True, "is_active": True},
) )
aliases += 1 aliases += 1
@@ -14,7 +14,7 @@ class Command(BaseCommand):
parser.add_argument( parser.add_argument(
"--create-default-aliases", "--create-default-aliases",
action="store_true", 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): def handle(self, *args, **options):
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import base64
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from time import perf_counter from time import perf_counter
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -23,6 +25,7 @@ RECORDED_TITLE_CONTENT = (
"2. 纯净白色基础款T恤舒适亲肤上衣\n" "2. 纯净白色基础款T恤舒适亲肤上衣\n"
"3. 夏季百搭白T休闲宽松男女同款" "3. 夏季百搭白T休闲宽松男女同款"
) )
RECORDED_IMAGE_BYTES = b"recorded-image-bytes"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -32,20 +35,25 @@ class SmokeResult:
alias: str alias: str
model_used: str model_used: str
elapsed_ms: int elapsed_ms: int
title_count: int title_count: int | None = None
first_title: str first_title: str = ""
image_bytes: int | None = None
class RecordedResponse: class RecordedResponse:
def __init__(self, payload: dict[str, Any]) -> None:
self.payload = payload
def json(self) -> dict[str, Any]: def json(self) -> dict[str, Any]:
return {"choices": [{"message": {"content": RECORDED_TITLE_CONTENT}}]} return self.payload
def raise_for_status(self) -> None: def raise_for_status(self) -> None:
return None return None
class RecordedSession: class RecordedSession:
def __init__(self) -> None: def __init__(self, payload: dict[str, Any]) -> None:
self.payload = payload
self.posts: list[dict[str, Any]] = [] self.posts: list[dict[str, Any]] = []
self.trust_env = True self.trust_env = True
@@ -54,17 +62,19 @@ class RecordedSession:
key: value for key, value in kwargs.items() if key != "headers" key: value for key, value in kwargs.items() if key != "headers"
} }
self.posts.append({"url": url, **sanitized_kwargs}) self.posts.append({"url": url, **sanitized_kwargs})
return RecordedResponse() return RecordedResponse(self.payload)
class Command(BaseCommand): class Command(BaseCommand):
help = "Run a minimal AI generation smoke through alias resolution and provider code." help = "Run a minimal AI generation smoke through alias resolution and provider code."
def add_arguments(self, parser): 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("--alias", help="Alias to resolve. Defaults to operation default.")
parser.add_argument("--prompt", default=DEFAULT_TITLE_PROMPT) parser.add_argument("--prompt", default=DEFAULT_TITLE_PROMPT)
parser.add_argument("--resolution", default="1K") 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( parser.add_argument(
"--recorded", "--recorded",
action="store_true", action="store_true",
@@ -72,14 +82,18 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): def handle(self, *args, **options):
if options["operation"] != "title": if options["operation"] == "title":
raise CommandError("Only title smoke is implemented.") result = (
self._run_recorded_title(options)
result = ( if options["recorded"]
self._run_recorded_title(options) else self._run_real_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) self._write_result(result)
def _run_real_title(self, options) -> SmokeResult: def _run_real_title(self, options) -> SmokeResult:
@@ -119,7 +133,57 @@ class Command(BaseCommand):
alias=alias, alias=alias,
prompt=options["prompt"], prompt=options["prompt"],
resolution=options["resolution"], 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) transaction.set_rollback(True)
return result return result
@@ -131,8 +195,11 @@ class Command(BaseCommand):
self.stdout.write(f"alias={result.alias}") self.stdout.write(f"alias={result.alias}")
self.stdout.write(f"model_used={result.model_used}") self.stdout.write(f"model_used={result.model_used}")
self.stdout.write(f"elapsed_ms={result.elapsed_ms}") self.stdout.write(f"elapsed_ms={result.elapsed_ms}")
self.stdout.write(f"title_count={result.title_count}") if result.title_count is not None:
self.stdout.write(f"first_title={result.first_title}") 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( def run_title_smoke(
@@ -162,3 +229,69 @@ def run_title_smoke(
title_count=len(generation.titles), title_count=len(generation.titles),
first_title=generation.titles[0] if generation.titles else generation.text, 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 "<default:image>",
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
+45 -2
View File
@@ -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: class BaseHttpProvider:
def __init__(self, session: requests.Session | None = None): def __init__(self, session: requests.Session | None = None):
self.session = session or requests.Session() self.session = session or requests.Session()
@@ -371,6 +400,20 @@ def apply_extra_body(
model: ResolvedModel, model: ResolvedModel,
parameters: Mapping[str, Any] | None = None, parameters: Mapping[str, Any] | None = None,
) -> None: ) -> None:
payload.update(model.extra_body) apply_safe_parameters(payload, model.extra_body)
if parameters: 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
+3 -2
View File
@@ -110,14 +110,15 @@ def decode_image_data_url(data_url: str) -> bytes:
def resolution_to_size(resolution: str) -> str: def resolution_to_size(resolution: str) -> str:
key = str(resolution).strip().upper()
mapping = { mapping = {
"512": "512x512", "512": "512x512",
"512px": "512x512", "512PX": "512x512",
"1K": "1024x1024", "1K": "1024x1024",
"2K": "2048x2048", "2K": "2048x2048",
"4K": "4096x4096", "4K": "4096x4096",
} }
return mapping.get(str(resolution), str(resolution)) return mapping.get(key, str(resolution).strip())
def extract_image_from_response( def extract_image_from_response(
+119 -2
View File
@@ -13,6 +13,7 @@ from apps.ai.importers import import_ai_models_config
from apps.ai.models import AiConfigAuditLog, AiModel, ModelAlias from apps.ai.models import AiConfigAuditLog, AiModel, ModelAlias
from apps.ai.providers import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type 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.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider
from apps.ai.providers.utils import resolution_to_size
TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("ascii") TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("ascii")
@@ -54,6 +55,12 @@ class ProviderRegistryTests(SimpleTestCase):
self.assertIsInstance(get_provider("auto", url), ChatCompletionsProvider) 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): class ChatCompletionsProviderTests(SimpleTestCase):
def test_generate_text_builds_chat_payload_and_cleans_titles(self): def test_generate_text_builds_chat_payload_and_cleans_titles(self):
session = FakeSession( session = FakeSession(
@@ -92,6 +99,54 @@ class ChatCompletionsProviderTests(SimpleTestCase):
self.assertFalse(request["json"]["stream"]) self.assertFalse(request["json"]["stream"])
self.assertEqual(request["json"]["temperature"], 0.2) 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): def test_generate_image_parses_chat_multimodal_data_url(self):
generated = b"generated-image" generated = b"generated-image"
encoded = base64.b64encode(generated).decode("ascii") encoded = base64.b64encode(generated).decode("ascii")
@@ -172,6 +227,44 @@ class ImagesEditsProviderTests(SimpleTestCase):
("source.png", b"source-image", "image/png"), ("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): def test_images_edits_requires_input_image(self):
provider = ImagesEditsProvider(session=FakeSession()) provider = ImagesEditsProvider(session=FakeSession())
model = ResolvedModel( model = ResolvedModel(
@@ -367,7 +460,7 @@ class AiModelsImportTests(TestCase):
) )
image_alias = ModelAlias.objects.get( image_alias = ModelAlias.objects.get(
operation_type=ModelAlias.OperationType.IMAGE, operation_type=ModelAlias.OperationType.IMAGE,
alias="image-standard", alias="image-hd",
) )
self.assertEqual(title_alias.ai_model, text_model) self.assertEqual(title_alias.ai_model, text_model)
self.assertEqual(image_alias.ai_model, image_model) self.assertEqual(image_alias.ai_model, image_model)
@@ -523,7 +616,7 @@ class AiConfigAuditAdminTests(TestCase):
) )
alias = ModelAlias.objects.create( alias = ModelAlias.objects.create(
operation_type=ModelAlias.OperationType.IMAGE, operation_type=ModelAlias.OperationType.IMAGE,
alias="image-standard", alias="image-hd",
ai_model=text_model, ai_model=text_model,
) )
alias.ai_model = image_model alias.ai_model = image_model
@@ -588,3 +681,27 @@ class AiGenerationSmokeCommandTests(TestCase):
self.assertNotIn("Bearer", text) self.assertNotIn("Bearer", text)
self.assertEqual(AiModel.objects.count(), 0) self.assertEqual(AiModel.objects.count(), 0)
self.assertEqual(ModelAlias.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)
+2 -2
View File
@@ -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 审核修补项已完成。 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 行)+ 计费规则 + 调用记录。 3. Phase 2:计费核心 —— User/UserWallet/ApiKey 模型 + 点数扣减(并发安全,锁 Wallet 行)+ 计费规则 + 调用记录。
4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。 4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
+2 -1
View File
@@ -58,7 +58,8 @@
| 创建后台管理员 | T-003 后执行 `py -3.12 manage.py createsuperuser` | | 创建后台管理员 | T-003 后执行 `py -3.12 manage.py createsuperuser` |
| 数据库迁移 | T-002 接入 MySQL 后执行 `makemigrations` / `migrate` | | 数据库迁移 | T-002 接入 MySQL 后执行 `makemigrations` / `migrate` |
| 导入 cmbot AI 模型配置 | `py -3.12 manage.py import_ai_models path\to\ai_models.json --create-default-aliases` | | 导入 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 .`(待定,确定后写死) | | 格式化 / 静态检查 | `ruff check .`(待定,确定后写死) |
统一入口仍是根目录 `./init.ps1`(Windows)或 `./init.sh`(Unix/WSL)。T-001 阶段只做框架检查,不跑迁移;T-002 接入 MySQL 与自定义 User 后再启用迁移路径。 统一入口仍是根目录 `./init.ps1`(Windows)或 `./init.sh`(Unix/WSL)。T-001 阶段只做框架检查,不跑迁移;T-002 接入 MySQL 与自定义 User 后再启用迁移路径。
+6 -5
View File
@@ -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 改图,两图片模型非标准生成、需分别解析返回。** - **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` 校验请求(如图片模型不能生成文字),在扣点和调上游**之前**拒掉非法组合。 - **能力校验前置**:按目标模型声明的 `capabilities` 校验请求(如图片模型不能生成文字),在扣点和调上游**之前**拒掉非法组合。
- **配置热生效**:模型配置从数据库读取,后台修改后运行时及时生效(每次查库或带缓存失效),不在进程内缓存到失效。 - **配置热生效**:模型配置从数据库读取,后台修改后运行时及时生效(每次查库或带缓存失效),不在进程内缓存到失效。
- 输入:prompt、可选图片、能力别名、分辨率、`parameters` 透传;输出:文字或图片,或明确错误。 - 输入:prompt、可选图片、能力别名、分辨率、`parameters` 安全子集;输出:文字或图片,或明确错误。`parameters` / `extra_body` 不得覆盖服务端固定的核心/计费字段(如 `model`、`n`、`size`、`resolution`、`messages`、`image*`)。
- 处理超时、错误,向上层抛出可分类的异常(区分「上游失败」与「参数错误」)。不在本层写点数逻辑。 - 处理超时、错误,向上层抛出可分类的异常(区分「上游失败」与「参数错误」)。不在本层写点数逻辑。
**数据库 / 存储** **数据库 / 存储**
- 持久化:账号、API Key、点数余额、计费规则、汇率、充值订单、点数流水、调用记录、模型配置。 - 持久化:账号、API Key、点数余额、计费规则、汇率、充值订单、点数流水、调用记录、模型配置。
- 点数余额是权威账本;不持久化上游返回的大图原文时,调用记录只存引用/路径/摘要(大对象存储策略 MVP 待定)。 - 点数余额是权威账本;不持久化上游返回的大图原文,调用记录只存引用/路径/摘要,不整包保存 provider `raw` 或 base64 图片(大对象存储策略 MVP 待定)。
- 点数余额可变但只能经计费层修改;流水与调用记录只追加、不可改。 - 点数余额可变但只能经计费层修改;流水与调用记录只追加、不可改。
## 三、数据模型 ## 三、数据模型
@@ -86,6 +86,7 @@
- `api_key` **使用 Fernet 加密存储到 `api_key_encrypted`**,admin 只提供写入型 `api_key` 字段、脱敏显示、不回显明文;模型/密钥/别名映射的变更需审计留痕。 - `api_key` **使用 Fernet 加密存储到 `api_key_encrypted`**,admin 只提供写入型 `api_key` 字段、脱敏显示、不回显明文;模型/密钥/别名映射的变更需审计留痕。
- `AiConfigAuditLog` 只追加、不在后台编辑删除;密钥变更只记录 `api_key: empty/set` 状态变化,不记录明文 key 或 Fernet 密文。 - `AiConfigAuditLog` 只追加、不在后台编辑删除;密钥变更只记录 `api_key: empty/set` 状态变化,不记录明文 key 或 Fernet 密文。
- 汇率在**充值下单创建订单时**锁定并记录到订单,同时计算预计到账点数;后续改汇率不影响已创建订单。支付回调入账时使用订单上的 `exchange_rate` / `points_granted`,不重新读取当前汇率。 - 汇率在**充值下单创建订单时**锁定并记录到订单,同时计算预计到账点数;后续改汇率不影响已创建订单。支付回调入账时使用订单上的 `exchange_rate` / `points_granted`,不重新读取当前汇率。
- Provider 适配器只允许白名单安全参数透传;外部调用方或后台 `extra_body` 传入核心字段时忽略,由服务端按别名解析和计费口径固定。
#### 配置表目标 schema #### 配置表目标 schema
@@ -226,7 +227,7 @@ CREATE TABLE call_record (
status TEXT NOT NULL, -- pending / success / failed status TEXT NOT NULL, -- pending / success / failed
upstream_latency_ms INTEGER, upstream_latency_ms INTEGER,
error_message TEXT, error_message TEXT,
result_ref TEXT, -- 结果引用(URL/路径/摘要) result_ref TEXT, -- 结果引用(URL/路径/摘要),不存 raw/base64
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
``` ```
@@ -305,7 +306,7 @@ CREATE TABLE call_record (
| 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | 每模型 timeout 必须生效并设上限;worker 数/网关超时按最慢模型预留;V2 异步化 | | 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | 每模型 timeout 必须生效并设上限;worker 数/网关超时按最慢模型预留;V2 异步化 |
| 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 | | 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 |
| 凭证安全 | 上游 api_key、支付密钥 | 应用层加密存储,admin 脱敏不回显;其余密钥走环境变量,不入代码与样例 | | 凭证安全 | 上游 api_key、支付密钥 | 应用层加密存储,admin 脱敏不回显;其余密钥走环境变量,不入代码与样例 |
| 抽象泄漏 | 各供应商入参出参不一致(resolution/aspect/vision/返回形态) | Provider 适配器 + capabilities 声明 + `parameters` 透传,核心字段稳定不取交集 | | 抽象泄漏 / 参数越权 | 各供应商入参出参不一致;若调用方 `parameters` 可覆盖 `model`/`n`/`size` 会击穿别名计费 | Provider 适配器 + capabilities 声明 + `parameters` 白名单过滤;核心/计费字段服务端固定,不取交集、不允许覆盖 |
| 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 | | 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 |
| 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 | | 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 |
| 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 | | 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 |
@@ -320,7 +321,7 @@ CREATE TABLE call_record (
桌面端 cmbot 可以**不改交互骨架**,继续用同步方式调 cmhub、cmhub 再同步转中转站,正常使用——决定成败的不是同步/异步本身,而是下面两件事有没有配对好;配好就稳,配不好会「小量正常、上量假死」: 桌面端 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,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。 2. **worker/线程数按峰值总并发预留**:同步下每个在飞请求占住一个 worker 整个生成周期(几十秒~分钟)。用 Gunicorn `gthread`(`--threads`)或多 sync worker,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。
适用边界:**单接入方、小并发批量**(桌面端 `concurrency` 1~4)完全够用,点数一致性也更简单。当出现「worker 被长连接占满拖慢快接口/后台」「接入方总并发明显上涨」「需要关窗重连/任务持久化」任一信号时,才转 V2 异步(队列),在此之前保持同步;但适配器接口与 `call_record` 的 `pending/success/failed` 三态需为异步预留口子(见 4.1、七、八)。 适用边界:**单接入方、小并发批量**(桌面端 `concurrency` 1~4)完全够用,点数一致性也更简单。当出现「worker 被长连接占满拖慢快接口/后台」「接入方总并发明显上涨」「需要关窗重连/任务持久化」任一信号时,才转 V2 异步(队列),在此之前保持同步;但适配器接口与 `call_record` 的 `pending/success/failed` 三态需为异步预留口子(见 4.1、七、八)。
+6 -5
View File
@@ -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-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-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-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 · 计费核心 ## Phase 2 · 计费核心
| ID | 任务 | 依赖 | 验收要点 | 状态 | | 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-202 | PricingRule / ExchangeRate 模型 + 计费计算 | T-201, T-102 | **按「操作 + 能力别名(+ 可选分辨率)」定价**;换底层模型不影响计费;缺规则返回 `no_pricing_rule` | TODO |
| T-203 | 并发安全扣点 / 退点(billing 层) | T-201 | 锁 `UserWallet` 行或 F() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | TODO | | T-203 | 并发安全扣点 / 退点(billing 层) | T-201 | 锁 `UserWallet` 行或 F() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | TODO |
@@ -49,7 +50,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| T-301 | API Key 鉴权(DRF Authentication) | T-201 | 对请求 Key 哈希比对定位 ApiKey→User;**只挂 Key 认证、不挂 Session**;无效/缺失 401;用户或 Key 禁用 403 | TODO | | 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-303 | 余额查询接口 | T-301 | 返回余额等于流水累加;含测试 | TODO |
| T-304 | 充值回调(微信/支付宝验签 + 幂等入账) | T-202 | 两端点 `@csrf_exempt`;微信 SDK 验签解密、支付宝 SDK verify;验签失败不入账;同一 order_no 重复回调只入账一次;校验回调金额与订单金额一致;使用订单创建时锁定的 `points_granted` 锁 wallet 入账写流水;补主动查单兜底;含幂等测试 | 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 | | 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-401 | 运营后台完善 | T-302, T-304 | admin 可管理用户/钱包/ApiKey(脱敏)、配规则/汇率、检索充值订单/流水/调用记录;手工调点带原因并经计费层写流水 | TODO |
| T-402 | 完整验收 MVP | T-401, T-504 | `02-requirements.md` 的 P0 验收全部通过(含用户端注册/充值/API Key/记录) | 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。 - 别名按比例分流到多个模型(灰度 / A/B / 故障转移);供应商 A 故障自动切 B。
- 注册赠点 / 试用额度(需邮箱/图形验证码 + 限流防薅羊毛)。 - 注册赠点 / 试用额度(需邮箱/图形验证码 + 限流防薅羊毛)。
- 用量统计报表。 - 用量统计报表。
- 退款对账自动化、API Key 轮换、限流细化。 - 退款对账自动化、API Key 轮换、Provider key 轮换(MultiFernet / 双 key 迁移)、限流细化。
+1
View File
@@ -21,6 +21,7 @@
- [编码规则](05-coding-rules.md):硬性约束,含资金/点数安全专项(第 8 节)。 - [编码规则](05-coding-rules.md):硬性约束,含资金/点数安全专项(第 8 节)。
- [任务看板](06-tasks.md):按依赖拆分的小任务,agent 每轮只做一个。 - [任务看板](06-tasks.md):按依赖拆分的小任务,agent 每轮只做一个。
- [Phase 0 骨架审核](phase-0-review.md):T-001~003 代码审核结论与修补清单(P1/P2/P3),对应任务 T-004。 - [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 调用模块合约、错误码。 - [API 合约](api.md):对外接口、支付回调、AI 调用模块合约、错误码。
- [路由与页面结构](routes.md):API 路由与 django-admin 后台职责。 - [路由与页面结构](routes.md):API 路由与 django-admin 后台职责。
- [环境变量与配置](env.md):Django、数据库、AI 密钥加密、支付、对象存储等配置项。 - [环境变量与配置](env.md):Django、数据库、AI 密钥加密、支付、对象存储等配置项。
+6 -6
View File
@@ -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` ### `POST /api/v1/generate/title`
@@ -54,7 +54,7 @@
"model": "title-standard", // 能力别名,非具体模型名;可选,缺省用该操作的默认别名 "model": "title-standard", // 能力别名,非具体模型名;可选,缺省用该操作的默认别名
"image_url": "https://...", // 可选,看图生成时传商品图(或 image_base64) "image_url": "https://...", // 可选,看图生成时传商品图(或 image_base64)
"resolution": "1K", // 可选,影响计费规则匹配 "resolution": "1K", // 可选,影响计费规则匹配
"parameters": {} // 可选,供应商特有参数透传,未知字段忽略 "parameters": {} // 可选,安全供应商参数;未知字段/核心字段忽略
} }
``` ```
@@ -84,7 +84,7 @@
"image_base64": "data:image/png;base64,...", // 或 image_url;改图类模型(如映射到 gpt-image-2)原图必传 "image_base64": "data:image/png;base64,...", // 或 image_url;改图类模型(如映射到 gpt-image-2)原图必传
"resolution": "1K", "resolution": "1K",
"aspect_ratio": "1:1", "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` ### `GET /api/v1/balance`
@@ -217,9 +217,9 @@ class Provider(Protocol):
要点: 要点:
- `ResolvedModel` 来自数据库 AiModel(形状同 `cmbot/config/ai_models.json`,外加 `capabilities`;`api_key` 在库中以 Fernet 加密,使用时解密,不落明文)。 - `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`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。 - 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。
- `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。 - `parameters` 为供应商特有参数的安全子集;适配器负责白名单过滤并把统一入参翻译成各家上游格式,核心字段不可被 `parameters` 或 `extra_body` 覆盖。
- 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。 - 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
- 配置审计:后台保存/删除 AiModel、ModelAlias 时写 `AiConfigAuditLog`;记录 actor/action/target/changed_fields/changes/created_at,admin 只读查看;密钥变更只记录 empty/set 状态。 - 配置审计:后台保存/删除 AiModel、ModelAlias 时写 `AiConfigAuditLog`;记录 actor/action/target/changed_fields/changes/created_at,admin 只读查看;密钥变更只记录 empty/set 状态。
- 区分异常:别名/能力不匹配 → 业务错误(400 类,如 `model_not_allowed`);上游网络/超时/服务错误 → `upstream_error`(502,触发退点)。 - 区分异常:别名/能力不匹配 → 业务错误(400 类,如 `model_not_allowed`);上游网络/超时/服务错误 → `upstream_error`(502,触发退点)。
+9 -7
View File
@@ -12,16 +12,16 @@
## 当前快照 ## 当前快照
- 日期:2026-07-02 - 日期: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` - 技术栈:系统 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 - 生产代码:已有最小 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` 和后续任务风险登记
- 测试:`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 连接阶段超时,非断言失败 - 测试: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` 命令加密导入 - 数据: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 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 标准验证路径: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` 决策 - 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策
- 配置基线:运行环境变量集中见 `docs/env.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` | | `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 加密 | | `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 | | `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 创建 | | `manage.py` | 已有 | T-001 创建 |
| `tests/` | 待建 | 随各任务补充 | | `tests/` | 待建 | 随各任务补充 |
@@ -41,7 +41,7 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 任务状态以 [`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:无。 - 当前 blocker:无。
- 下一个可领取任务:**T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型**。 - 下一个可领取任务:**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 runserver
py -3.12 manage.py import_ai_models path\to\ai_models.json --create-default-aliases 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 title --recorded
py -3.12 manage.py smoke_ai_generation image --recorded
# Unix / WSL # Unix / WSL
python3.12 -m pip install -r requirements.txt 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 runserver
python3.12 manage.py import_ai_models path/to/ai_models.json --create-default-aliases 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 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` 跑真实标题/图片。
## 开始编码前检查 ## 开始编码前检查
+2 -2
View File
@@ -38,13 +38,13 @@
| 变量 | 必填 | 示例 | 说明 | | 变量 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `AI_KEY_ENCRYPTION_KEY` | 是 | `base64-fernet-key` | Fernet 主密钥,用于加密 `AiModel.api_key_encrypted`;生产不可更换,除非完成密钥轮换 | | `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())"` 生成。 `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` 由后台或数据迁移维护,不通过环境变量硬编码具体模型。 `AiModel.url`、`AiModel.model`、`AiModel.api_type`、`AiModel.capabilities` 与加密后的 `api_key` 由后台或数据迁移维护,不通过环境变量硬编码具体模型。
AI 上游连接超时与读取超时不走全局环境变量:连接超时由 `AiModel.connect_timeout_seconds` 控制,读取超时由 `AiModel.timeout_seconds` 控制;当读取超时为 `0` 时,Provider 按分辨率使用内置默认值。
## 五、支付配置 ## 五、支付配置
### 微信 V3 native ### 微信 V3 native
+106
View File
@@ -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 有登记,不遗失。
+39
View File
@@ -363,3 +363,42 @@
- 阻塞:真实上游 smoke 未执行,因为当前环境未配置 `AI_KEY_ENCRYPTION_KEY`,且业务库没有 AiModel/ModelAlias。配置 Fernet 主密钥并导入模型后,可去掉 `--recorded` 用同一命令跑真实标题 smoke。 - 阻塞:真实上游 smoke 未执行,因为当前环境未配置 `AI_KEY_ENCRYPTION_KEY`,且业务库没有 AiModel/ModelAlias。配置 Fernet 主密钥并导入模型后,可去掉 `--recorded` 用同一命令跑真实标题 smoke。
- 决策:T-104 采用“录制标题生成”作为验收路径,验证别名解析、Provider 选择、响应解析和同步耗时记录;真实图片同步耗时仍需在后续配置真实模型后补测。 - 决策:T-104 采用“录制标题生成”作为验收路径,验证别名解析、Provider 选择、响应解析和同步耗时记录;真实图片同步耗时仍需在后续配置真实模型后补测。
- 下一步:领取 T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型。 - 下一步:领取 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 模型。