feat: add ai model alias configuration

This commit is contained in:
QiuSW
2026-07-02 11:07:44 +08:00
parent c9fbabb04f
commit 18ac054dc5
23 changed files with 955 additions and 48 deletions
+1 -1
View File
@@ -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) 第四节计费时序。
+113 -1
View File
@@ -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")
+55
View File
@@ -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()
+158
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -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']}"
)
)
+56
View File
@@ -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')],
},
),
]
+133 -1
View File
@@ -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)
+6
View File
@@ -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)
+46
View File
@@ -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
+251 -1
View File
@@ -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))
+1
View File
@@ -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)
+2 -2
View File
@@ -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 管理、个人中心/记录页、充值页。
+3 -1
View File
@@ -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 后再启用迁移路径。
+27 -20
View File
@@ -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 业务动态数据
+1 -1
View File
@@ -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 |
+12 -3
View File
@@ -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`)。
+12 -10
View File
@@ -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`。
## 维护规则
+3 -1
View File
@@ -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` 由后台或数据迁移维护,不通过环境变量硬编码具体模型。
## 五、支付配置
+4 -4
View File
@@ -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`。*
+2 -2
View File
@@ -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`(完整介绍)。*
+26
View File
@@ -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 配置变更审计。