feat: add ai model alias configuration
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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']}"
|
||||
)
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 管理、个人中心/记录页、充值页。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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` 由后台或数据迁移维护,不通过环境变量硬编码具体模型。
|
||||
|
||||
## 五、支付配置
|
||||
|
||||
@@ -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`。*
|
||||
|
||||
@@ -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
@@ -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 配置变更审计。
|
||||
|
||||
Reference in New Issue
Block a user