diff --git a/README.md b/README.md index 2da7d56..b228fe5 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-101:Provider 适配器层、`cmbot` AI 调用逻辑迁移骨架和 mock 单测已落地。下一步是 T-102 AiModel + ModelAlias 数据模型与别名解析。详见 [`docs/current-state.md`](docs/current-state.md)。 +Phase 1 已完成 T-102:Provider 适配器层、AiModel/ModelAlias 数据模型、Fernet 加密密钥存储、别名解析和配置导入命令已落地。下一步是 T-103 配置变更审计。详见 [`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/admin.py b/apps/ai/admin.py index 8c38f3f..b69674e 100644 --- a/apps/ai/admin.py +++ b/apps/ai/admin.py @@ -1,3 +1,115 @@ +from django import forms from django.contrib import admin -# Register your models here. +from .models import AiModel, ModelAlias +from .security import AiKeyEncryptionError, encrypt_api_key + + +class AiModelAdminForm(forms.ModelForm): + api_key = forms.CharField( + label="API key", + required=False, + widget=forms.PasswordInput(render_value=False), + help_text="Leave blank to keep the existing encrypted key.", + ) + + class Meta: + model = AiModel + fields = ( + "name", + "url", + "model", + "api_type", + "api_key", + "capabilities", + "timeout_seconds", + "connect_timeout_seconds", + "extra_body", + "is_active", + ) + + def clean(self): + cleaned_data = super().clean() + api_key = cleaned_data.get("api_key") + if not self.instance.pk and not api_key: + raise forms.ValidationError("API key is required when creating an AI model.") + if api_key: + try: + self._api_key_encrypted = encrypt_api_key(api_key) + except AiKeyEncryptionError as exc: + raise forms.ValidationError({"api_key": str(exc)}) from exc + else: + self._api_key_encrypted = "" + return cleaned_data + + def save(self, commit=True): + instance = super().save(commit=False) + if self._api_key_encrypted: + instance.api_key_encrypted = self._api_key_encrypted + if commit: + instance.save() + self.save_m2m() + return instance + + +@admin.register(AiModel) +class AiModelAdmin(admin.ModelAdmin): + form = AiModelAdminForm + list_display = ( + "name", + "model", + "api_type", + "capabilities_display", + "api_key_status", + "is_active", + "updated_at", + ) + list_filter = ("api_type", "is_active") + search_fields = ("name", "model", "url") + readonly_fields = ("api_key_status", "created_at", "updated_at") + fieldsets = ( + (None, {"fields": ("name", "url", "model", "api_type", "is_active")}), + ( + "Credentials", + { + "fields": ("api_key", "api_key_status"), + "description": "The stored key is encrypted and never displayed.", + }, + ), + ( + "Capabilities and request defaults", + { + "fields": ( + "capabilities", + "timeout_seconds", + "connect_timeout_seconds", + "extra_body", + ) + }, + ), + ("Timestamps", {"fields": ("created_at", "updated_at")}), + ) + + @admin.display(description="capabilities") + def capabilities_display(self, obj): + return ", ".join(sorted(obj.capabilities_set())) + + @admin.display(description="API key") + def api_key_status(self, obj): + return obj.api_key_masked or "not set" + + +@admin.register(ModelAlias) +class ModelAliasAdmin(admin.ModelAdmin): + list_display = ( + "operation_type", + "alias", + "ai_model", + "is_default", + "is_active", + "updated_at", + ) + list_filter = ("operation_type", "is_default", "is_active") + search_fields = ("alias", "ai_model__name", "ai_model__model") + autocomplete_fields = ("ai_model",) + readonly_fields = ("created_at", "updated_at") diff --git a/apps/ai/aliases.py b/apps/ai/aliases.py new file mode 100644 index 0000000..81860f5 --- /dev/null +++ b/apps/ai/aliases.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from apps.ai.providers import ResolvedModel + +from .models import AiModel, ModelAlias + + +class AliasResolutionError(RuntimeError): + """Base error for alias resolution failures.""" + + +class AliasNotFoundError(AliasResolutionError): + """Raised when an active alias cannot be found.""" + + +class ModelCapabilityError(AliasResolutionError): + """Raised when an alias points to a model without the required capability.""" + + +REQUIRED_CAPABILITIES = { + ModelAlias.OperationType.TITLE: "text", + ModelAlias.OperationType.IMAGE: "image", +} + + +def resolve_alias(operation_type: str, alias: str | None = None) -> ResolvedModel: + """Resolve an external capability alias to a provider-ready model config.""" + required_capability = REQUIRED_CAPABILITIES.get(operation_type) + if required_capability is None: + raise AliasResolutionError(f"unsupported operation_type: {operation_type}") + + queryset = ModelAlias.objects.select_related("ai_model").filter( + operation_type=operation_type, + is_active=True, + ai_model__is_active=True, + ) + if alias: + queryset = queryset.filter(alias=alias) + else: + queryset = queryset.filter(is_default=True) + + model_alias = queryset.order_by("id").first() + if model_alias is None: + if alias: + raise AliasNotFoundError(f"alias not found: {operation_type}:{alias}") + raise AliasNotFoundError(f"default alias not found: {operation_type}") + + ai_model: AiModel = model_alias.ai_model + capabilities = ai_model.capabilities_set() + if required_capability not in capabilities: + raise ModelCapabilityError( + f"alias {model_alias.alias} maps to model {ai_model.name} without " + f"{required_capability} capability" + ) + return ai_model.to_resolved_model() diff --git a/apps/ai/importers.py b/apps/ai/importers.py new file mode 100644 index 0000000..b735db4 --- /dev/null +++ b/apps/ai/importers.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from typing import Any, Mapping + +from apps.ai.providers.utils import ( + API_CHAT, + API_GEMINI, + API_IMAGES, + API_IMAGES_EDITS, + detect_api_type, +) + +from .models import AiModel, ModelAlias + + +def import_ai_models_config( + config: Mapping[str, Any], + *, + create_default_aliases: bool = False, +) -> dict[str, int]: + """Import cmbot-style ai_models.json data into AiModel records.""" + raw_models = config.get("models") + if not isinstance(raw_models, list): + raise ValueError("ai_models config must contain a models list") + + created = 0 + updated = 0 + imported_models: list[AiModel] = [] + for raw_model in raw_models: + if not isinstance(raw_model, Mapping): + raise ValueError("each model config must be an object") + ai_model, was_created = _upsert_ai_model(raw_model) + imported_models.append(ai_model) + if was_created: + created += 1 + else: + updated += 1 + + aliases = 0 + if create_default_aliases: + aliases = _ensure_default_aliases(imported_models) + + return {"created": created, "updated": updated, "aliases": aliases} + + +def _upsert_ai_model(raw_model: Mapping[str, Any]) -> tuple[AiModel, bool]: + name = str(raw_model.get("name") or raw_model.get("model") or "").strip() + if not name: + raise ValueError("model config is missing name/model") + + defaults = { + "url": str(raw_model.get("url") or ""), + "model": str(raw_model.get("model") or ""), + "api_type": str(raw_model.get("api_type") or AiModel.ApiType.AUTO), + "timeout_seconds": _to_non_negative_int(raw_model.get("timeout_seconds"), 0), + "connect_timeout_seconds": _to_positive_int( + raw_model.get("connect_timeout_seconds"), 30 + ), + "extra_body": dict(raw_model.get("extra_body") or {}), + "capabilities": _infer_capabilities(raw_model), + "is_active": True, + } + ai_model, created = AiModel.objects.update_or_create(name=name, defaults=defaults) + + api_key = str(raw_model.get("api_key") or "").strip() + if api_key: + ai_model.set_api_key(api_key) + ai_model.save(update_fields=("api_key_encrypted", "updated_at")) + return ai_model, created + + +def _infer_capabilities(raw_model: Mapping[str, Any]) -> list[str]: + explicit = raw_model.get("capabilities") + if explicit: + if isinstance(explicit, dict): + return [ + str(key) + for key, value in explicit.items() + if key in {"text", "image", "vision"} and bool(value) + ] + if isinstance(explicit, str): + return [item.strip() for item in explicit.split(",") if item.strip()] + return [str(item) for item in explicit] + + api_type = str(raw_model.get("api_type") or AiModel.ApiType.AUTO) + url = str(raw_model.get("url") or "") + resolved_api_type = detect_api_type(url, api_type) + name_model_text = f"{raw_model.get('name', '')} {raw_model.get('model', '')}".lower() + + if resolved_api_type == API_IMAGES_EDITS: + return ["image", "vision"] + if resolved_api_type == API_IMAGES: + return ["image"] + if resolved_api_type == API_GEMINI: + return ["text", "image", "vision"] + if resolved_api_type == API_CHAT and ( + "image" in name_model_text or "banana" in name_model_text + ): + return ["image", "vision"] + return ["text", "vision"] + + +def _ensure_default_aliases(models: list[AiModel]) -> int: + aliases = 0 + text_model = _first_model_with_capability(models, "text") + image_model = _first_model_with_capability(models, "image") + + if text_model: + ModelAlias.objects.filter( + operation_type=ModelAlias.OperationType.TITLE, + is_default=True, + ).update(is_default=False) + ModelAlias.objects.update_or_create( + operation_type=ModelAlias.OperationType.TITLE, + alias="title-standard", + defaults={"ai_model": text_model, "is_default": True, "is_active": True}, + ) + aliases += 1 + + if image_model: + ModelAlias.objects.filter( + operation_type=ModelAlias.OperationType.IMAGE, + is_default=True, + ).update(is_default=False) + ModelAlias.objects.update_or_create( + operation_type=ModelAlias.OperationType.IMAGE, + alias="image-standard", + defaults={"ai_model": image_model, "is_default": True, "is_active": True}, + ) + aliases += 1 + + return aliases + + +def _first_model_with_capability(models: list[AiModel], capability: str) -> AiModel | None: + for model in models: + if capability in model.capabilities_set(): + return model + return None + + +def _to_non_negative_int(value: Any, default: int) -> int: + parsed = _to_int(value, default) + return parsed if parsed >= 0 else default + + +def _to_positive_int(value: Any, default: int) -> int: + parsed = _to_int(value, default) + return parsed if parsed > 0 else default + + +def _to_int(value: Any, default: int) -> int: + if value is None or value == "": + return default + try: + return int(value) + except (TypeError, ValueError): + return default diff --git a/apps/ai/management/__init__.py b/apps/ai/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/ai/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/ai/management/commands/__init__.py b/apps/ai/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/ai/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/ai/management/commands/import_ai_models.py b/apps/ai/management/commands/import_ai_models.py new file mode 100644 index 0000000..21df762 --- /dev/null +++ b/apps/ai/management/commands/import_ai_models.py @@ -0,0 +1,41 @@ +import json +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError + +from apps.ai.importers import import_ai_models_config + + +class Command(BaseCommand): + help = "Import cmbot-style ai_models.json into encrypted AiModel records." + + def add_arguments(self, parser): + parser.add_argument("path", help="Path to ai_models.json.") + parser.add_argument( + "--create-default-aliases", + action="store_true", + help="Create title-standard and image-standard default aliases when possible.", + ) + + def handle(self, *args, **options): + path = Path(options["path"]) + if not path.exists(): + raise CommandError(f"File not found: {path}") + + try: + config = json.loads(path.read_text(encoding="utf-8-sig")) + result = import_ai_models_config( + config, + create_default_aliases=options["create_default_aliases"], + ) + except Exception as exc: + raise CommandError(str(exc)) from exc + + self.stdout.write( + self.style.SUCCESS( + "Imported AI models: " + f"created={result['created']}, " + f"updated={result['updated']}, " + f"aliases={result['aliases']}" + ) + ) diff --git a/apps/ai/migrations/0001_initial.py b/apps/ai/migrations/0001_initial.py new file mode 100644 index 0000000..bd2db8f --- /dev/null +++ b/apps/ai/migrations/0001_initial.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.15 on 2026-07-02 02:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AiModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, unique=True)), + ('url', models.URLField(max_length=500)), + ('model', models.CharField(max_length=128)), + ('api_type', models.CharField(choices=[('auto', 'Auto'), ('chat', 'Chat completions'), ('gemini', 'Gemini'), ('images', 'Images generations'), ('images_edits', 'Images edits')], default='auto', max_length=32)), + ('api_key_encrypted', models.TextField(blank=True, editable=False)), + ('capabilities', models.JSONField(blank=True, default=list)), + ('timeout_seconds', models.PositiveIntegerField(default=0, help_text='0 means use provider resolution defaults.')), + ('connect_timeout_seconds', models.PositiveIntegerField(default=30)), + ('extra_body', models.JSONField(blank=True, default=dict)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'ai_model', + 'ordering': ('name',), + }, + ), + migrations.CreateModel( + name='ModelAlias', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('alias', models.SlugField(max_length=64)), + ('operation_type', models.CharField(choices=[('title', 'Generate title'), ('image', 'Generate image')], max_length=32)), + ('is_default', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('ai_model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aliases', to='ai.aimodel')), + ], + options={ + 'db_table': 'model_alias', + 'ordering': ('operation_type', 'alias'), + 'indexes': [models.Index(fields=['operation_type', 'is_default'], name='model_alias_operati_622f4b_idx'), models.Index(fields=['operation_type', 'is_active'], name='model_alias_operati_32d832_idx')], + 'constraints': [models.UniqueConstraint(fields=('operation_type', 'alias'), name='unique_model_alias_per_operation')], + }, + ), + ] diff --git a/apps/ai/models.py b/apps/ai/models.py index 71a8362..edc7db3 100644 --- a/apps/ai/models.py +++ b/apps/ai/models.py @@ -1,3 +1,135 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError from django.db import models -# Create your models here. +from apps.ai.providers import ResolvedModel + +from .security import decrypt_api_key, encrypt_api_key, mask_secret + + +class AiModel(models.Model): + class ApiType(models.TextChoices): + AUTO = "auto", "Auto" + CHAT = "chat", "Chat completions" + GEMINI = "gemini", "Gemini" + IMAGES = "images", "Images generations" + IMAGES_EDITS = "images_edits", "Images edits" + + name = models.CharField(max_length=128, unique=True) + url = models.URLField(max_length=500) + model = models.CharField(max_length=128) + api_type = models.CharField( + max_length=32, + choices=ApiType.choices, + default=ApiType.AUTO, + ) + api_key_encrypted = models.TextField(blank=True, editable=False) + capabilities = models.JSONField(default=list, blank=True) + timeout_seconds = models.PositiveIntegerField( + default=0, + help_text="0 means use provider resolution defaults.", + ) + connect_timeout_seconds = models.PositiveIntegerField(default=30) + extra_body = models.JSONField(default=dict, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ai_model" + ordering = ("name",) + + def __str__(self) -> str: + return self.name + + def set_api_key(self, raw_api_key: str) -> None: + self.api_key_encrypted = encrypt_api_key(raw_api_key) + + def get_api_key(self) -> str: + return decrypt_api_key(self.api_key_encrypted) + + @property + def has_api_key(self) -> bool: + return bool(self.api_key_encrypted) + + @property + def api_key_masked(self) -> str: + return mask_secret(self.api_key_encrypted) + + def capabilities_set(self) -> frozenset[str]: + raw = self.capabilities or [] + if isinstance(raw, dict): + return frozenset( + str(key) + for key, value in raw.items() + if key in {"text", "image", "vision"} and bool(value) + ) + if isinstance(raw, str): + return frozenset(item.strip() for item in raw.split(",") if item.strip()) + return frozenset(str(item) for item in raw) + + def to_resolved_model(self) -> ResolvedModel: + return ResolvedModel( + name=self.name, + url=self.url, + model=self.model, + api_key=self.get_api_key(), + api_type=self.api_type, + timeout_seconds=self.timeout_seconds, + connect_timeout_seconds=self.connect_timeout_seconds, + extra_body=dict(self.extra_body or {}), + capabilities=self.capabilities_set(), + ) + + +class ModelAlias(models.Model): + class OperationType(models.TextChoices): + TITLE = "title", "Generate title" + IMAGE = "image", "Generate image" + + alias = models.SlugField(max_length=64) + operation_type = models.CharField(max_length=32, choices=OperationType.choices) + ai_model = models.ForeignKey( + AiModel, + on_delete=models.PROTECT, + related_name="aliases", + ) + is_default = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "model_alias" + ordering = ("operation_type", "alias") + constraints = [ + models.UniqueConstraint( + fields=("operation_type", "alias"), + name="unique_model_alias_per_operation", + ), + ] + indexes = [ + models.Index(fields=("operation_type", "is_default")), + models.Index(fields=("operation_type", "is_active")), + ] + + def __str__(self) -> str: + marker = " default" if self.is_default else "" + return f"{self.operation_type}:{self.alias}{marker}" + + def clean(self) -> None: + super().clean() + if self.is_default: + duplicate_default = ModelAlias.objects.filter( + operation_type=self.operation_type, + is_default=True, + ).exclude(pk=self.pk) + if duplicate_default.exists(): + raise ValidationError( + {"is_default": "Only one default alias is allowed per operation."} + ) + + def save(self, *args, **kwargs) -> None: + self.full_clean() + super().save(*args, **kwargs) diff --git a/apps/ai/providers/base.py b/apps/ai/providers/base.py index d35eb51..a8d7305 100644 --- a/apps/ai/providers/base.py +++ b/apps/ai/providers/base.py @@ -41,6 +41,12 @@ class ResolvedModel: capabilities = frozenset( item.strip() for item in raw_capabilities.split(",") if item.strip() ) + elif isinstance(raw_capabilities, Mapping): + capabilities = frozenset( + str(key) + for key, value in raw_capabilities.items() + if key in {"text", "image", "vision"} and bool(value) + ) else: capabilities = frozenset(str(item) for item in raw_capabilities) diff --git a/apps/ai/security.py b/apps/ai/security.py new file mode 100644 index 0000000..32ec227 --- /dev/null +++ b/apps/ai/security.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from cryptography.fernet import Fernet, InvalidToken +from django.conf import settings + + +class AiKeyEncryptionError(RuntimeError): + """Raised when AI provider key encryption/decryption cannot proceed.""" + + +PREFIX = "fernet:" + + +def encrypt_api_key(raw_api_key: str) -> str: + api_key = str(raw_api_key or "").strip() + if not api_key: + raise AiKeyEncryptionError("AI api_key cannot be blank") + token = _fernet().encrypt(api_key.encode("utf-8")).decode("ascii") + return f"{PREFIX}{token}" + + +def decrypt_api_key(encrypted_api_key: str) -> str: + encrypted = str(encrypted_api_key or "") + if not encrypted: + return "" + if not encrypted.startswith(PREFIX): + raise AiKeyEncryptionError("AI api_key is not stored in encrypted form") + token = encrypted[len(PREFIX) :].encode("ascii") + try: + return _fernet().decrypt(token).decode("utf-8") + except InvalidToken as exc: + raise AiKeyEncryptionError("AI api_key cannot be decrypted") from exc + + +def mask_secret(encrypted_api_key: str) -> str: + return "********" if encrypted_api_key else "" + + +def _fernet() -> Fernet: + key = getattr(settings, "AI_KEY_ENCRYPTION_KEY", "") + if not key: + raise AiKeyEncryptionError("AI_KEY_ENCRYPTION_KEY is not configured") + try: + return Fernet(str(key).encode("ascii")) + except (TypeError, ValueError) as exc: + raise AiKeyEncryptionError("AI_KEY_ENCRYPTION_KEY is invalid") from exc diff --git a/apps/ai/tests.py b/apps/ai/tests.py index b9e1cb7..193602d 100644 --- a/apps/ai/tests.py +++ b/apps/ai/tests.py @@ -1,11 +1,20 @@ import base64 -from django.test import SimpleTestCase +from cryptography.fernet import Fernet +from django.contrib.admin.sites import AdminSite +from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings +from apps.ai.admin import AiModelAdmin +from apps.ai.aliases import AliasNotFoundError, ModelCapabilityError, resolve_alias +from apps.ai.importers import import_ai_models_config +from apps.ai.models import AiModel, ModelAlias from apps.ai.providers import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type from apps.ai.providers.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider +TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("ascii") + + class FakeResponse: def __init__(self, payload, content=b""): self.payload = payload @@ -175,3 +184,244 @@ class ImagesEditsProviderTests(SimpleTestCase): with self.assertRaises(AiCapabilityError): provider.generate_text("Generate title", model) + + +@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class AiModelEncryptionTests(TestCase): + def test_api_key_is_encrypted_and_resolved_model_decrypts_it(self): + model = AiModel( + name="GPT-5.5 text", + url="https://api.vectorengine.ai/v1", + model="gpt-5.5", + api_type=AiModel.ApiType.CHAT, + capabilities=["text"], + ) + model.set_api_key("sk-test-secret") + model.save() + + self.assertNotIn("sk-test-secret", model.api_key_encrypted) + self.assertTrue(model.api_key_encrypted.startswith("fernet:")) + self.assertEqual(model.get_api_key(), "sk-test-secret") + + resolved = model.to_resolved_model() + self.assertEqual(resolved.api_key, "sk-test-secret") + self.assertEqual(resolved.capabilities, frozenset({"text"})) + + def test_capabilities_accept_dict_shape(self): + model = AiModel( + name="Vision image", + url="https://api.vectorengine.ai/v1/images/edits", + model="gpt-image-2", + api_type=AiModel.ApiType.IMAGES_EDITS, + capabilities={"image": True, "vision": True, "text": False}, + ) + + self.assertEqual(model.capabilities_set(), frozenset({"image", "vision"})) + + +@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class AliasResolutionTests(TestCase): + def setUp(self): + self.text_model = self.create_model( + name="GPT-5.5 text", + model="gpt-5.5", + api_type=AiModel.ApiType.CHAT, + capabilities=["text", "vision"], + ) + self.image_model = self.create_model( + name="GPT Image 2", + url="https://api.vectorengine.ai/v1/images/edits", + model="gpt-image-2", + api_type=AiModel.ApiType.IMAGES_EDITS, + capabilities=["image", "vision"], + ) + + def create_model( + self, + *, + name, + model, + capabilities, + url="https://api.vectorengine.ai/v1", + api_type=AiModel.ApiType.CHAT, + ): + ai_model = AiModel( + name=name, + url=url, + model=model, + api_type=api_type, + capabilities=capabilities, + ) + ai_model.set_api_key("sk-test-secret") + ai_model.save() + return ai_model + + def test_resolve_default_title_alias(self): + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.TITLE, + alias="title-standard", + ai_model=self.text_model, + is_default=True, + ) + + resolved = resolve_alias(ModelAlias.OperationType.TITLE) + + self.assertEqual(resolved.model, "gpt-5.5") + self.assertEqual(resolved.api_key, "sk-test-secret") + + def test_resolve_named_image_alias(self): + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.IMAGE, + alias="image-edit", + ai_model=self.image_model, + ) + + resolved = resolve_alias(ModelAlias.OperationType.IMAGE, "image-edit") + + self.assertEqual(resolved.model, "gpt-image-2") + self.assertIn("image", resolved.capabilities) + + def test_resolve_alias_rejects_capability_mismatch(self): + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.TITLE, + alias="bad-title", + ai_model=self.image_model, + ) + + with self.assertRaises(ModelCapabilityError): + resolve_alias(ModelAlias.OperationType.TITLE, "bad-title") + + def test_resolve_alias_ignores_inactive_aliases(self): + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.TITLE, + alias="inactive-title", + ai_model=self.text_model, + is_active=False, + ) + + with self.assertRaises(AliasNotFoundError): + resolve_alias(ModelAlias.OperationType.TITLE, "inactive-title") + + def test_model_alias_save_rejects_second_default_for_operation(self): + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.TITLE, + alias="title-standard", + ai_model=self.text_model, + is_default=True, + ) + + with self.assertRaisesMessage(Exception, "Only one default alias"): + ModelAlias.objects.create( + operation_type=ModelAlias.OperationType.TITLE, + alias="title-backup", + ai_model=self.text_model, + is_default=True, + ) + + +@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class AiModelsImportTests(TestCase): + def test_import_cmbot_config_encrypts_keys_and_creates_default_aliases(self): + result = import_ai_models_config( + { + "models": [ + { + "name": "Nano Banana 2", + "url": "https://api.vectorengine.ai/v1/chat/completions", + "model": "gemini-3.1-flash-image-preview", + "api_key": "sk-image", + "api_type": "auto", + "timeout_seconds": 0, + "connect_timeout_seconds": 30, + "extra_body": {}, + }, + { + "name": "GPT-5.5 text", + "url": "https://api.vectorengine.ai/v1", + "model": "gpt-5.5", + "api_key": "sk-text", + "api_type": "chat", + "timeout_seconds": 0, + "connect_timeout_seconds": 30, + "extra_body": {}, + }, + ] + }, + create_default_aliases=True, + ) + + self.assertEqual(result, {"created": 2, "updated": 0, "aliases": 2}) + text_model = AiModel.objects.get(name="GPT-5.5 text") + image_model = AiModel.objects.get(name="Nano Banana 2") + self.assertEqual(text_model.capabilities_set(), frozenset({"text", "vision"})) + self.assertEqual(image_model.capabilities_set(), frozenset({"image", "vision"})) + self.assertNotIn("sk-text", text_model.api_key_encrypted) + self.assertEqual(text_model.get_api_key(), "sk-text") + + title_alias = ModelAlias.objects.get( + operation_type=ModelAlias.OperationType.TITLE, + alias="title-standard", + ) + image_alias = ModelAlias.objects.get( + operation_type=ModelAlias.OperationType.IMAGE, + alias="image-standard", + ) + self.assertEqual(title_alias.ai_model, text_model) + self.assertEqual(image_alias.ai_model, image_model) + + +@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) +class AiModelAdminTests(TestCase): + def test_admin_form_uses_write_only_api_key_field(self): + request = RequestFactory().get("/admin/apps/ai/aimodel/add/") + model_admin = AiModelAdmin(AiModel, AdminSite()) + + form_class = model_admin.get_form(request) + + self.assertIn("api_key", form_class.base_fields) + self.assertNotIn("api_key_encrypted", form_class.base_fields) + self.assertFalse(form_class.base_fields["api_key"].widget.render_value) + + def test_admin_form_encrypts_api_key_on_save(self): + form_class = AiModelAdmin(AiModel, AdminSite()).form + form = form_class( + data={ + "name": "GPT-5.5 text", + "url": "https://api.vectorengine.ai/v1", + "model": "gpt-5.5", + "api_type": AiModel.ApiType.CHAT, + "api_key": "sk-admin-secret", + "capabilities": '["text"]', + "timeout_seconds": 0, + "connect_timeout_seconds": 30, + "extra_body": "{}", + "is_active": "on", + } + ) + + self.assertTrue(form.is_valid(), form.errors) + ai_model = form.save() + + self.assertNotIn("sk-admin-secret", ai_model.api_key_encrypted) + self.assertEqual(ai_model.get_api_key(), "sk-admin-secret") + + @override_settings(AI_KEY_ENCRYPTION_KEY="") + def test_admin_form_reports_missing_encryption_key(self): + form_class = AiModelAdmin(AiModel, AdminSite()).form + form = form_class( + data={ + "name": "GPT-5.5 text", + "url": "https://api.vectorengine.ai/v1", + "model": "gpt-5.5", + "api_type": AiModel.ApiType.CHAT, + "api_key": "sk-admin-secret", + "capabilities": '["text"]', + "timeout_seconds": 0, + "connect_timeout_seconds": 30, + "extra_body": "{}", + "is_active": "on", + } + ) + + self.assertFalse(form.is_valid()) + self.assertIn("AI_KEY_ENCRYPTION_KEY is not configured", str(form.errors)) diff --git a/config/settings.py b/config/settings.py index 444db61..a776fe5 100644 --- a/config/settings.py +++ b/config/settings.py @@ -54,6 +54,7 @@ load_dotenv(BASE_DIR / ".env") # SECURITY WARNING: keep the secret key used in production secret. SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-dev-only-change-me") +AI_KEY_ENCRYPTION_KEY = os.environ.get("AI_KEY_ENCRYPTION_KEY", "") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env_bool("DJANGO_DEBUG", True) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 166d577..2949869 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -38,12 +38,12 @@ ## 当前阶段 -当前项目处于:**Phase 1**。T-101 Provider 适配器层已完成并通过 mock 单测,下一步进入 T-102 AiModel + ModelAlias 数据模型与别名解析。 +当前项目处于:**Phase 1**。T-101 Provider 适配器层与 T-102 AiModel/ModelAlias 数据模型、Fernet 加密密钥存储、别名解析已完成,下一步进入 T-103 配置变更审计。 优先路径: 1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。 -2. Phase 1:最高风险功能原型 —— T-101 已移植 `cmbot` 的 AI 调用 provider 层;下一步 T-102/T-104 完成模型配置、别名解析并跑通一次标题/图片生成。 +2. Phase 1:最高风险功能原型 —— T-101/T-102 已完成 provider 层、模型配置表与别名解析;下一步 T-103 补配置审计,随后 T-104 跑通一次标题/图片生成。 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 8f94c34..8d33e85 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -14,7 +14,7 @@ | 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth + crispy-forms | 已定 | 自助注册/登录/充值/API Key 管理/记录页;allauth 出注册登录邮箱验证,crispy + 现成 Bootstrap 模板出页面,单体不引前端框架 | | 后台美化 | django-unfold 或 simpleui | 待定 | 仅外观,MVP 可先用原生 admin,后期按需引入 | | AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 + `requests` HTTP 客户端 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) | -| 供应商密钥存储 | 应用层对称加密(如 `cryptography` Fernet)或 KMS | 待定 | `AiModel.api_key` 加密入库、admin 脱敏不回显;加密主密钥走环境变量,配置清单见 `env.md` | +| 供应商密钥存储 | 应用层 Fernet 加密(`cryptography`) | 已定 | `AiModel.api_key_encrypted` 加密入库、admin 写入型字段不回显;加密主密钥 `AI_KEY_ENCRYPTION_KEY` 走环境变量,配置清单见 `env.md` | | 图片结果存储 | 对象存储(S3 兼容 / 本地存储)返回 URL | 待定 | 同步响应默认返回 `image_url`,避免大 base64 进响应体 | | 配置变更审计 | django-admin LogEntry 或自建审计表 | 待定 | 模型/别名/密钥变更留痕,与「账目对得上」一致 | | 数据库 | MySQL 8.4 LTS(cmhub 专用独立实例) | 已定 | 满足 Django 5.2 的 MySQL ≥8.0.11;引擎 InnoDB + 字符集 utf8mb4;行锁 `select_for_update` / 条件更新保并发扣点。**不复用 VPS 已有的 MySQL 5.7**(跑不了 Django 5.2、无 CHECK 约束)。开发亦用 MySQL,勿用 SQLite(不支持 `select_for_update`) | @@ -34,6 +34,7 @@ - **锁定 Django 5.2 LTS + Python 3.12**:① 版本红线属安全而非性能——生文/图生图瓶颈在「等上游 + worker 并发 + 超时」,不在框架版本(详见 [架构设计](04-architecture.md) 5.1),故版本选择只按安全与维护窗口定;② Django 4.0/4.1 已 EOL、无安全补丁,涉资金服务禁用;4.2 LTS 支持窗口临近尾声,不从其起步;5.2 LTS 维护到 2028,窗口最长。③ Django 5.2 支持 Python 3.10–3.13,锁 3.12 取「稳定 + 库全 + 性能」的平衡,避开 3.10(临近 EOL)与 3.14(不在 5.2 官方矩阵)。T-001 已按用户要求固定使用系统 Python 3.12:Windows PowerShell 用 `py -3.12`,Unix/WSL 用 `python3.12`;T-004 起 init 脚本在安装依赖前显式断言运行解释器版本在 `>=3.12,<3.14`。 - **预付费点数而非实时查支付余额**:充值时按汇率把金额转点数存本地,解耦支付系统、降低调用延迟、并发扣减用本地数据库事务即可保证。代价是需处理充值幂等与对账。 - **稳定接口 + 可插拔供应商**:对外只 `generate text/image` 两接口 + 能力别名;具体模型在后台配置并经适配器调用。收益是换供应商对调用方零改动、计费按别名稳定、可加授权与故障转移;代价是需维护适配器层与别名映射。**调用方不绑具体模型 SKU**。 +- **供应商密钥存储采用 Fernet 应用层加密**:T-102 已落地 `AiModel.api_key_encrypted`,密文带 `fernet:` 前缀;明文只在 admin 表单提交或调用 `resolve_alias()` 后进入内存,不入库、不回显。主密钥来自 `AI_KEY_ENCRYPTION_KEY`,生产不可随意更换,除非后续做密钥轮换。 - **同步生成而非任务队列**:MVP 不引入 Celery/Redis,降低复杂度;图片耗时长,靠调大超时支撑,V2 再异步化。 - **桌面端可不改、全同步接入**:cmbot 现有批处理引擎(后台线程池 + 进度/心跳/重试/停止)只需把 service 层 URL/密钥从直连中转站换成 cmhub 的 `base_url + API Key`,即可正常使用;`桌面端 → cmhub → 中转站` 多一跳不影响可用性,点数一致性反而更简单(一次请求闭环:预扣→同步调→成功/失败退点)。 - **同步可用的前提是「超时链路 + worker 容量」配对**,否则会「小量正常、上量假死」:① 每模型 `timeout_seconds` 设有限值(`ai_models.json` 现为 `0`,迁入须改);② Gunicorn `--timeout` 与网关 `proxy_read_timeout` 按最慢图片放大(别用默认 30s / 60s);③ worker/线程数按**峰值总并发**预留。详见 [架构设计](04-architecture.md) 第五节结论。 @@ -55,6 +56,7 @@ | 本地开发(Windows) | `py -3.12 manage.py runserver` | | 创建后台管理员 | 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` | | 格式化 / 静态检查 | `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 a92753b..b3b54a1 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -67,8 +67,8 @@ | 数据 | 来源 | 说明 | | --- | --- | --- | -| AiModel | 迁移自 `cmbot/config/ai_models.json` | name、url、model、**api_key(加密存储)**、api_type、timeout、**capabilities**(text/image/vision、支持的分辨率) | -| ModelAlias | 运营配置 | 对外**能力别名** → 映射到一个具体 AiModel;调用方只见别名,不见 SKU | +| AiModel | 迁移自 `cmbot/config/ai_models.json` | name、url、model、`api_type`、`capabilities`、`timeout_seconds`、`connect_timeout_seconds`、`extra_body`、`is_active`、**api_key_encrypted(Fernet 加密存储)** | +| ModelAlias | 运营配置 | `operation_type + alias` → 映射到一个具体 AiModel;支持每个操作一个默认别名;调用方只见别名,不见 SKU | | PricingRule | 运营配置 | 操作类型 × **能力别名**(+ 可选分辨率)→ 点数单价 | | ExchangeRate | 运营配置 | 金额 → 点数 的汇率(如 1 元 = N 点),可带生效时间 | @@ -82,7 +82,7 @@ - ⚠️ 两个图片模型**都不是**标准 `images/generations`,不可套用通用图片生成 SDK 写法;每个模型**独立 url + key**,不共享 base_url。 - ⚠️ 图片返回结构(URL / base64、在返回中的位置)**首次对接须抓一次真实响应,再定适配器解析代码**。 - `capabilities` 显式声明每个模型能做什么;标题接口只允许声明 `text` 的模型,图片接口只允许声明 `image` 的模型。 -- `api_key` **加密存储**,admin 列表脱敏、不回显明文;模型/密钥/别名映射的变更需审计留痕。 +- `api_key` **使用 Fernet 加密存储到 `api_key_encrypted`**,admin 只提供写入型 `api_key` 字段、脱敏显示、不回显明文;模型/密钥/别名映射的变更需审计留痕。 - 汇率在**充值下单创建订单时**锁定并记录到订单,同时计算预计到账点数;后续改汇率不影响已创建订单。支付回调入账时使用订单上的 `exchange_rate` / `points_granted`,不重新读取当前汇率。 #### 配置表目标 schema @@ -90,27 +90,32 @@ ```sql -- 上游模型(具体 SKU,后台维护,密钥加密) CREATE TABLE ai_model ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, -- 后台展示名 - url TEXT NOT NULL, - model TEXT NOT NULL, -- 供应商模型标识 - api_key_enc TEXT NOT NULL, -- 加密后的密钥,不存明文 - api_type TEXT NOT NULL, -- chat/gemini/images/images_edits/auto - capabilities TEXT NOT NULL, -- JSON: {text,image,vision, resolutions:[...]} - timeout_seconds INTEGER, - connect_timeout_seconds INTEGER, - status TEXT NOT NULL DEFAULT 'active', - updated_at TEXT NOT NULL + id BIGINT PRIMARY KEY, + name VARCHAR(128) NOT NULL UNIQUE, -- 后台展示名 + url VARCHAR(500) NOT NULL, + model VARCHAR(128) NOT NULL, -- 供应商模型标识 + api_key_encrypted LONGTEXT NOT NULL, -- Fernet 密文,不存明文 + api_type VARCHAR(32) NOT NULL, -- chat/gemini/images/images_edits/auto + capabilities JSON NOT NULL, -- ["text","image","vision"] 或等价对象 + timeout_seconds INTEGER NOT NULL DEFAULT 0, + connect_timeout_seconds INTEGER NOT NULL DEFAULT 30, + extra_body JSON NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL ); -- 能力别名(对外稳定标识 → 当前映射的具体模型) CREATE TABLE model_alias ( - id INTEGER PRIMARY KEY, - alias TEXT UNIQUE NOT NULL, -- 如 title-standard / image-hd,对外用 - operation_type TEXT NOT NULL, -- title / image - ai_model_id INTEGER NOT NULL REFERENCES ai_model(id), -- 可随时改指向 - status TEXT NOT NULL DEFAULT 'active', - updated_at TEXT NOT NULL + id BIGINT PRIMARY KEY, + alias VARCHAR(64) NOT NULL, -- 如 title-standard / image-hd,对外用 + operation_type VARCHAR(32) NOT NULL, -- title / image + ai_model_id BIGINT NOT NULL REFERENCES ai_model(id), -- 可随时改指向 + is_default BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + UNIQUE(operation_type, alias) ); -- 计费规则(按别名定价,换底层模型不影响计费) @@ -124,6 +129,8 @@ CREATE TABLE pricing_rule ( ); ``` +T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,后续若要强制数据库层约束可在 T-103/T-401 评估触发器或约束表。 + > 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。 ### 3.2 业务动态数据 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 3c0f09f..8ea19c2 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -32,7 +32,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | 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`;后台改配置运行时热生效 | TODO | +| 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 查看 | TODO | | T-104 | 跑通一次真实/录制的标题或图片生成 | T-102 | 用别名 + 最小输入跑通一次生成,结论写入 `progress.md`(含耗时,验证图片同步可行性与超时配置) | TODO | diff --git a/docs/api.md b/docs/api.md index de8a7df..180a195 100644 --- a/docs/api.md +++ b/docs/api.md @@ -184,6 +184,16 @@ resolve_alias(operation_type: str, alias: str | None) -> ResolvedModel ``` +T-102 后,别名解析已接入数据库表 `AiModel` / `ModelAlias`:每次调用读取当前 active 记录,缺省 `alias=None` 时取该 `operation_type` 的默认别名;标题要求 `text` 能力,图片要求 `image` 能力。密钥以 `AiModel.api_key_encrypted` 存储,使用 Fernet 解密后进入 `ResolvedModel`。 + +从 `cmbot` 形状导入配置的管理命令: + +```bash +py -3.12 manage.py import_ai_models path\to\ai_models.json --create-default-aliases +``` + +导入前必须配置有效 `AI_KEY_ENCRYPTION_KEY`。不要把真实 `ai_models.json` 或真实上游 key 提交到 git。 + ### Provider 适配器接口 每个供应商一个适配器,按 `api_type` 注册;移植自 cmbot 的调用逻辑落到各适配器内: @@ -206,8 +216,7 @@ class Provider(Protocol): 要点: -- `ResolvedModel` 来自数据库 AiModel(形状同 `cmbot/config/ai_models.json`,外加 `capabilities`;`api_key` 在库中加密,使用时解密,不落明文)。 -- T-101 先用 `ResolvedModel` dataclass 承接配置,不建数据库表;T-102 再把它接到 AiModel/ModelAlias。 +- `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 编排职责。 - 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。 - `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。 @@ -219,7 +228,7 @@ class Provider(Protocol): - 支付商户真实配置:微信 `appid/mchid/apiv3_key/证书/notify_url`,支付宝 `appid/应用私钥/支付宝公钥/notify_url`。协议字段、验签方式与成功应答已按同系统实现明确;缺真实配置时用 mock。 - 汇率与各操作/别名(+ 分辨率档)的点数单价。 -- `AiModel.api_key` 加密方案(应用层 Fernet / KMS)与后台脱敏展示方式;运行配置见 `env.md`。 +- 配置变更审计:T-103 补 AiModel / ModelAlias / 密钥变更留痕(谁、何时、改了什么)。 - 图片结果存储:对象存储选型与 `image_url` 生成(默认走存储返回 URL)。 - **图片模型调用机制**:`gpt-image-2` 走 `images/edits`(改图,原图必传)、`nano-banana2` 走 `chat/completions` 多模态返图(需自定义解析),两者非标准 `images/generations`。图片返回结构(URL / base64 / 位置)**首次对接抓真实响应确认**后再定适配器解析。 - 别名命名规范、默认别名、是否按账号授权可用别名(`account_alias_permission`)。 diff --git a/docs/current-state.md b/docs/current-state.md index dea7dc5..16e297c 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,11 +12,11 @@ ## 当前快照 - 日期:2026-07-02 -- 阶段:Phase 1,T-101 已完成;下一步 T-102 +- 阶段:Phase 1,T-102 已完成;下一步 T-103 - 技术栈:系统 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 适配器) -- 测试:`manage.py test` 通过(7 tests);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`./init.ps1` 通过 -- 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`) +- 生产代码:已有最小 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` 导入命令 +- 测试:`manage.py test` 通过(18 tests);`manage.py test apps.ai --noinput` 通过(16 tests);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`./init.ps1` 通过 +- 数据: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` 决策 @@ -31,9 +31,9 @@ | `AGENTS.md` / `CLAUDE.md` | 已有 | 仓库级入口 | | `progress.md` | 已有 | 执行流水,已记录多轮文档决策;后续任务继续追加 | | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` | -| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests` | +| `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` | +| `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` 迁移 | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -41,10 +41,10 @@ 任务状态以 [`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-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析。 - 正在进行:无。 - 当前 blocker:无。 -- 下一个可领取任务:**T-102 AiModel + ModelAlias 模型 + 别名解析**。 +- 下一个可领取任务:**T-103 配置变更审计**。 ## 当前可运行内容 @@ -54,22 +54,24 @@ py -3.12 -m pip install -r requirements.txt py -3.12 manage.py check 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 # Unix / WSL python3.12 -m pip install -r requirements.txt python3.12 manage.py check 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 ``` -当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,标准测试可创建/销毁 `test_cmhub` 测试库并通过。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析,不做数据库模型、别名解析或计费;这些从 T-102/T-201/T-302 继续。 +当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,标准测试可创建/销毁 `test_cmhub` 测试库并通过。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-102 仍不做配置审计、真实上游生成或计费;这些分别从 T-103、T-104、T-201/T-302 继续。 ## 开始编码前检查 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 -4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-102)。 +4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-103)。 5. 将该任务状态改为 `DOING`。 ## 维护规则 diff --git a/docs/env.md b/docs/env.md index 2113a31..f046e6b 100644 --- a/docs/env.md +++ b/docs/env.md @@ -37,10 +37,12 @@ | 变量 | 必填 | 示例 | 说明 | | --- | --- | --- | --- | -| `AI_KEY_ENCRYPTION_KEY` | 是 | `base64-fernet-key` | 用于加密 `AiModel.api_key`;生产不可更换,除非完成密钥轮换 | +| `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` 由后台或数据迁移维护,不通过环境变量硬编码具体模型。 ## 五、支付配置 diff --git a/docs/project-brief.md b/docs/project-brief.md index 1fd416e..40236b2 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -1,7 +1,7 @@ # cmhub 项目介绍(给管理层) > 面向决策与汇报的项目概览。技术细节见同目录架构与需求文档。 -> 日期:2026-06-29 | 阶段:MVP 起步(文档就绪,待开工) +> 日期:2026-07-02 | 阶段:Phase 1 进行中(AI 调用与模型配置原型) ## 一句话概括 @@ -100,9 +100,9 @@ ## 十、当前状态 -- 需求、架构、接口、任务规划等**全套文档已就绪**。 -- 代码尚未开始,下一步进入**第一阶段(搭建骨架)**。 -- 技术选型已定(成熟开源方案,开发后台几乎零额外成本),风险可控。 +- M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。 +- Phase 1 已完成 T-101/T-102:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析与配置导入命令已落地。 +- 下一步是 T-103 配置变更审计,随后 T-104 跑通一次真实/录制的标题或图片生成。 --- *更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。* diff --git a/docs/project-onepager.md b/docs/project-onepager.md index 8a88a46..a11a1e1 100644 --- a/docs/project-onepager.md +++ b/docs/project-onepager.md @@ -1,6 +1,6 @@ # cmhub · 一页汇报版 -> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-06-29 | MVP 起步 +> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-07-02 | Phase 1 进行中 ## 电梯陈述(30 秒) @@ -40,7 +40,7 @@ ## 进度 -文档全套就绪 → 下一步搭骨架。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。 +M1 骨架已完成;Phase 1 已完成 Provider 适配器、AiModel/ModelAlias、加密密钥存储和别名解析。下一步补配置变更审计,再跑通一次真实/录制生成。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。 --- *详见 `project-brief.md`(完整介绍)。* diff --git a/progress.md b/progress.md index 3098a67..93150c2 100644 --- a/progress.md +++ b/progress.md @@ -289,3 +289,29 @@ - 阻塞:无。 - 决策:T-101 不创建 AiModel/ModelAlias 数据表、不做别名解析数据库读取、不接计费;先用 `ResolvedModel` dataclass 承接后续 T-102 的数据库模型。 - 下一步:领取 T-102 AiModel + ModelAlias 模型 + 别名解析。 + +## 2026-07-02 T-102 AiModel + ModelAlias 模型 + 别名解析 + +- 状态:DONE +- 变更: + - 新增 `AiModel` / `ModelAlias` 数据模型与 `apps/ai/migrations/0001_initial.py`:`AiModel` 保存上游 url/model/api_type/capabilities/timeout/extra_body/is_active 与 Fernet 密文 `api_key_encrypted`;`ModelAlias` 用 `operation_type + alias` 映射到具体模型,支持默认别名与启停。 + - 新增 `apps/ai/security.py`:`AI_KEY_ENCRYPTION_KEY` 驱动 Fernet 加解密,密文带 `fernet:` 前缀;拒绝明文 fallback。 + - 新增 `apps/ai/aliases.py`:`resolve_alias(operation_type, alias=None)` 每次查当前 active 配置,标题要求 `text` 能力,图片要求 `image` 能力,返回 provider 可用的 `ResolvedModel`。 + - 新增 `apps/ai/importers.py` 与 `import_ai_models` 管理命令:支持导入 `cmbot` 形状的 `ai_models.json`,导入时加密 key、推断 capabilities,可选创建 `title-standard` / `image-standard` 默认别名。 + - 完善 `apps/ai/admin.py`:admin 提供写入型 `api_key` 字段,密钥脱敏显示且不回显;缺少 Fernet 主密钥时以表单错误提示;`api_key_encrypted` 不暴露。 + - 扩展 `apps/ai/tests.py`:覆盖密钥加密/解密、别名解析、能力不匹配、inactive alias、配置导入、admin 脱敏与缺主密钥错误;同步更新 README、入口文档、技术栈、架构、API、环境配置、任务看板和当前状态。 +- 验证: + - `py -3.12 manage.py migrate`:通过,`ai.0001_initial` 已应用;`py -3.12 manage.py showmigrations ai` 显示 `[X] 0001_initial`。 + - `py -3.12 manage.py test apps.ai --noinput`:通过,16 tests OK。 + - `py -3.12 manage.py test --noinput`:通过,18 tests OK。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `py -3.12 manage.py makemigrations --check`:通过,No changes detected。 + - `py -3.12 -m compileall apps` / `py -3.12 -m compileall apps\ai`:通过。 + - `./init.ps1`:通过,依赖同步与基础检查正常。 + - 验证期间远程 MySQL `43.128.3.240:3306` 多次短暂超时,`Test-NetConnection 43.128.3.240 -Port 3306` 恢复后重试通过;失败点均为建库/事务前连接超时,不是断言失败。 +- 阻塞:无。 +- 决策: + - 供应商密钥存储采用 `cryptography` Fernet 应用层加密,主密钥只走 `AI_KEY_ENCRYPTION_KEY`;不实现明文兼容。 + - T-102 不自动导入真实 `D:\chengma\cmbot\config\ai_models.json`,避免真实上游 key 被误暴露;后续由管理员在确认环境密钥后手动执行导入命令。 + - 默认别名唯一性先由 model validation、admin 与导入器保证;MySQL partial unique 约束留待审计/后台完善阶段评估。 +- 下一步:领取 T-103 配置变更审计。