feat: audit ai config changes
This commit is contained in:
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
|
||||
|
||||
## 当前状态
|
||||
|
||||
Phase 1 已完成 T-102:Provider 适配器层、AiModel/ModelAlias 数据模型、Fernet 加密密钥存储、别名解析和配置导入命令已落地。下一步是 T-103 配置变更审计。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
Phase 1 已完成 T-103:Provider 适配器层、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置导入命令和配置变更审计已落地。下一步是 T-104 跑通一次真实/录制的标题或图片生成。详见 [`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) 第四节计费时序。
|
||||
|
||||
|
||||
+136
-1
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import AiModel, ModelAlias
|
||||
from .audit import create_config_audit_log, snapshot_config
|
||||
from .models import AiConfigAuditLog, AiModel, ModelAlias
|
||||
from .security import AiKeyEncryptionError, encrypt_api_key
|
||||
|
||||
|
||||
@@ -98,6 +99,53 @@ class AiModelAdmin(admin.ModelAdmin):
|
||||
def api_key_status(self, obj):
|
||||
return obj.api_key_masked or "not set"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
before = _snapshot_existing(obj) if change else None
|
||||
super().save_model(request, obj, form, change)
|
||||
create_config_audit_log(
|
||||
actor=request.user,
|
||||
action=(
|
||||
AiConfigAuditLog.Action.UPDATE
|
||||
if change
|
||||
else AiConfigAuditLog.Action.CREATE
|
||||
),
|
||||
instance=obj,
|
||||
before=before,
|
||||
after=snapshot_config(obj),
|
||||
)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
before = snapshot_config(obj)
|
||||
target_id = obj.pk
|
||||
target_repr = str(obj)
|
||||
super().delete_model(request, obj)
|
||||
create_config_audit_log(
|
||||
actor=request.user,
|
||||
action=AiConfigAuditLog.Action.DELETE,
|
||||
instance=obj,
|
||||
before=before,
|
||||
after=None,
|
||||
target_id=target_id,
|
||||
target_repr=target_repr,
|
||||
)
|
||||
|
||||
def delete_queryset(self, request, queryset):
|
||||
snapshots = [
|
||||
(obj, obj.pk, str(obj), snapshot_config(obj))
|
||||
for obj in queryset
|
||||
]
|
||||
super().delete_queryset(request, queryset)
|
||||
for obj, target_id, target_repr, before in snapshots:
|
||||
create_config_audit_log(
|
||||
actor=request.user,
|
||||
action=AiConfigAuditLog.Action.DELETE,
|
||||
instance=obj,
|
||||
before=before,
|
||||
after=None,
|
||||
target_id=target_id,
|
||||
target_repr=target_repr,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ModelAlias)
|
||||
class ModelAliasAdmin(admin.ModelAdmin):
|
||||
@@ -113,3 +161,90 @@ class ModelAliasAdmin(admin.ModelAdmin):
|
||||
search_fields = ("alias", "ai_model__name", "ai_model__model")
|
||||
autocomplete_fields = ("ai_model",)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
before = _snapshot_existing(obj) if change else None
|
||||
super().save_model(request, obj, form, change)
|
||||
create_config_audit_log(
|
||||
actor=request.user,
|
||||
action=(
|
||||
AiConfigAuditLog.Action.UPDATE
|
||||
if change
|
||||
else AiConfigAuditLog.Action.CREATE
|
||||
),
|
||||
instance=obj,
|
||||
before=before,
|
||||
after=snapshot_config(obj),
|
||||
)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
before = snapshot_config(obj)
|
||||
target_id = obj.pk
|
||||
target_repr = str(obj)
|
||||
super().delete_model(request, obj)
|
||||
create_config_audit_log(
|
||||
actor=request.user,
|
||||
action=AiConfigAuditLog.Action.DELETE,
|
||||
instance=obj,
|
||||
before=before,
|
||||
after=None,
|
||||
target_id=target_id,
|
||||
target_repr=target_repr,
|
||||
)
|
||||
|
||||
def delete_queryset(self, request, queryset):
|
||||
snapshots = [
|
||||
(obj, obj.pk, str(obj), snapshot_config(obj))
|
||||
for obj in queryset
|
||||
]
|
||||
super().delete_queryset(request, queryset)
|
||||
for obj, target_id, target_repr, before in snapshots:
|
||||
create_config_audit_log(
|
||||
actor=request.user,
|
||||
action=AiConfigAuditLog.Action.DELETE,
|
||||
instance=obj,
|
||||
before=before,
|
||||
after=None,
|
||||
target_id=target_id,
|
||||
target_repr=target_repr,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(AiConfigAuditLog)
|
||||
class AiConfigAuditLogAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"created_at",
|
||||
"actor",
|
||||
"action",
|
||||
"target_type",
|
||||
"target_id",
|
||||
"target_repr",
|
||||
"changed_fields_display",
|
||||
)
|
||||
list_filter = ("action", "target_type", "created_at")
|
||||
search_fields = (
|
||||
"target_repr",
|
||||
"actor__username",
|
||||
"actor__email",
|
||||
)
|
||||
ordering = ("-created_at", "-id")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
return tuple(field.name for field in self.model._meta.fields)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
@admin.display(description="changed fields")
|
||||
def changed_fields_display(self, obj):
|
||||
return ", ".join(obj.changed_fields)
|
||||
|
||||
|
||||
def _snapshot_existing(obj):
|
||||
return snapshot_config(obj.__class__.objects.get(pk=obj.pk))
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db import models
|
||||
|
||||
from .models import AiConfigAuditLog, AiModel, ModelAlias
|
||||
|
||||
|
||||
AI_MODEL_AUDIT_FIELDS = (
|
||||
"name",
|
||||
"url",
|
||||
"model",
|
||||
"api_type",
|
||||
"api_key_encrypted",
|
||||
"capabilities",
|
||||
"timeout_seconds",
|
||||
"connect_timeout_seconds",
|
||||
"extra_body",
|
||||
"is_active",
|
||||
)
|
||||
MODEL_ALIAS_AUDIT_FIELDS = (
|
||||
"alias",
|
||||
"operation_type",
|
||||
"ai_model_id",
|
||||
"is_default",
|
||||
"is_active",
|
||||
)
|
||||
FIELD_ALIASES = {"api_key_encrypted": "api_key", "ai_model_id": "ai_model"}
|
||||
|
||||
|
||||
def create_config_audit_log(
|
||||
*,
|
||||
actor: Any,
|
||||
action: str,
|
||||
instance: AiModel | ModelAlias,
|
||||
before: dict[str, Any] | None,
|
||||
after: dict[str, Any] | None,
|
||||
target_id: int | None = None,
|
||||
target_repr: str | None = None,
|
||||
) -> AiConfigAuditLog | None:
|
||||
"""Create a sanitized audit log for AI config changes."""
|
||||
changed_fields, changes = _diff_snapshots(before, after)
|
||||
if action == AiConfigAuditLog.Action.UPDATE and not changes:
|
||||
return None
|
||||
|
||||
actor_value = actor if getattr(actor, "is_authenticated", False) else None
|
||||
return AiConfigAuditLog.objects.create(
|
||||
actor=actor_value,
|
||||
action=action,
|
||||
target_type=_target_type(instance),
|
||||
target_id=target_id if target_id is not None else instance.pk,
|
||||
target_repr=(target_repr or str(instance))[:255],
|
||||
changed_fields=changed_fields,
|
||||
changes=changes,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_config(instance: AiModel | ModelAlias) -> dict[str, Any]:
|
||||
fields = _audit_fields(instance)
|
||||
return {field: _field_value(instance, field) for field in fields}
|
||||
|
||||
|
||||
def _diff_snapshots(
|
||||
before: dict[str, Any] | None,
|
||||
after: dict[str, Any] | None,
|
||||
) -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
fields = tuple((after or before or {}).keys())
|
||||
changed_fields = []
|
||||
changes = {}
|
||||
for field in fields:
|
||||
old_value = None if before is None else before.get(field)
|
||||
new_value = None if after is None else after.get(field)
|
||||
if old_value == new_value:
|
||||
continue
|
||||
public_field = FIELD_ALIASES.get(field, field)
|
||||
changed_fields.append(public_field)
|
||||
changes[public_field] = {
|
||||
"old": _sanitize_value(field, old_value),
|
||||
"new": _sanitize_value(field, new_value),
|
||||
}
|
||||
return changed_fields, changes
|
||||
|
||||
|
||||
def _target_type(instance: AiModel | ModelAlias) -> str:
|
||||
if isinstance(instance, AiModel):
|
||||
return AiConfigAuditLog.TargetType.AI_MODEL
|
||||
if isinstance(instance, ModelAlias):
|
||||
return AiConfigAuditLog.TargetType.MODEL_ALIAS
|
||||
raise TypeError(f"unsupported audit target: {type(instance)!r}")
|
||||
|
||||
|
||||
def _audit_fields(instance: AiModel | ModelAlias) -> tuple[str, ...]:
|
||||
if isinstance(instance, AiModel):
|
||||
return AI_MODEL_AUDIT_FIELDS
|
||||
if isinstance(instance, ModelAlias):
|
||||
return MODEL_ALIAS_AUDIT_FIELDS
|
||||
raise TypeError(f"unsupported audit target: {type(instance)!r}")
|
||||
|
||||
|
||||
def _field_value(instance: models.Model, field: str) -> Any:
|
||||
value = getattr(instance, field)
|
||||
if isinstance(value, (list, dict, str, int, bool)) or value is None:
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def _sanitize_value(field: str, value: Any) -> Any:
|
||||
if field == "api_key_encrypted":
|
||||
return "set" if value else "empty"
|
||||
return value
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-02 03:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AiConfigAuditLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('action', models.CharField(choices=[('create', 'Create'), ('update', 'Update'), ('delete', 'Delete')], max_length=16)),
|
||||
('target_type', models.CharField(choices=[('ai_model', 'AI model'), ('model_alias', 'Model alias')], max_length=32)),
|
||||
('target_id', models.PositiveBigIntegerField(blank=True, null=True)),
|
||||
('target_repr', models.CharField(max_length=255)),
|
||||
('changed_fields', models.JSONField(blank=True, default=list)),
|
||||
('changes', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'ai_config_audit_log',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
'indexes': [models.Index(fields=['target_type', 'target_id'], name='ai_config_a_target__3e1809_idx'), models.Index(fields=['action', 'created_at'], name='ai_config_a_action_924966_idx'), models.Index(fields=['actor', 'created_at'], name='ai_config_a_actor_i_a11e67_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
@@ -133,3 +134,41 @@ class ModelAlias(models.Model):
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class AiConfigAuditLog(models.Model):
|
||||
class TargetType(models.TextChoices):
|
||||
AI_MODEL = "ai_model", "AI model"
|
||||
MODEL_ALIAS = "model_alias", "Model alias"
|
||||
|
||||
class Action(models.TextChoices):
|
||||
CREATE = "create", "Create"
|
||||
UPDATE = "update", "Update"
|
||||
DELETE = "delete", "Delete"
|
||||
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="+",
|
||||
)
|
||||
action = models.CharField(max_length=16, choices=Action.choices)
|
||||
target_type = models.CharField(max_length=32, choices=TargetType.choices)
|
||||
target_id = models.PositiveBigIntegerField(null=True, blank=True)
|
||||
target_repr = models.CharField(max_length=255)
|
||||
changed_fields = models.JSONField(default=list, blank=True)
|
||||
changes = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "ai_config_audit_log"
|
||||
ordering = ("-created_at", "-id")
|
||||
indexes = [
|
||||
models.Index(fields=("target_type", "target_id")),
|
||||
models.Index(fields=("action", "created_at")),
|
||||
models.Index(fields=("actor", "created_at")),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.created_at:%Y-%m-%d %H:%M:%S} {self.action} {self.target_repr}"
|
||||
|
||||
+137
-2
@@ -2,12 +2,13 @@ import base64
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
|
||||
|
||||
from apps.ai.admin import AiModelAdmin
|
||||
from apps.ai.admin import AiConfigAuditLogAdmin, AiModelAdmin, ModelAliasAdmin
|
||||
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.models import AiConfigAuditLog, AiModel, ModelAlias
|
||||
from apps.ai.providers import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type
|
||||
from apps.ai.providers.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider
|
||||
|
||||
@@ -425,3 +426,137 @@ class AiModelAdminTests(TestCase):
|
||||
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn("AI_KEY_ENCRYPTION_KEY is not configured", str(form.errors))
|
||||
|
||||
|
||||
@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY)
|
||||
class AiConfigAuditAdminTests(TestCase):
|
||||
def setUp(self):
|
||||
self.site = AdminSite()
|
||||
self.request = RequestFactory().post("/admin/")
|
||||
self.request.user = get_user_model().objects.create_superuser(
|
||||
username="auditor",
|
||||
email="auditor@example.com",
|
||||
password="password",
|
||||
)
|
||||
|
||||
def create_ai_model(
|
||||
self,
|
||||
*,
|
||||
name="GPT-5.5 text",
|
||||
model="gpt-5.5",
|
||||
capabilities=None,
|
||||
api_type=AiModel.ApiType.CHAT,
|
||||
url="https://api.vectorengine.ai/v1",
|
||||
api_key="sk-test-secret",
|
||||
):
|
||||
ai_model = AiModel(
|
||||
name=name,
|
||||
url=url,
|
||||
model=model,
|
||||
api_type=api_type,
|
||||
capabilities=capabilities or ["text"],
|
||||
)
|
||||
ai_model.set_api_key(api_key)
|
||||
ai_model.save()
|
||||
return ai_model
|
||||
|
||||
def test_aimodel_admin_create_writes_sanitized_audit_log(self):
|
||||
ai_model = AiModel(
|
||||
name="GPT-5.5 text",
|
||||
url="https://api.vectorengine.ai/v1",
|
||||
model="gpt-5.5",
|
||||
api_type=AiModel.ApiType.CHAT,
|
||||
capabilities=["text"],
|
||||
)
|
||||
ai_model.set_api_key("sk-created-secret")
|
||||
AiModelAdmin(AiModel, self.site).save_model(
|
||||
self.request,
|
||||
ai_model,
|
||||
form=None,
|
||||
change=False,
|
||||
)
|
||||
|
||||
log = AiConfigAuditLog.objects.get()
|
||||
self.assertEqual(log.actor, self.request.user)
|
||||
self.assertEqual(log.action, AiConfigAuditLog.Action.CREATE)
|
||||
self.assertEqual(log.target_type, AiConfigAuditLog.TargetType.AI_MODEL)
|
||||
self.assertIn("api_key", log.changed_fields)
|
||||
self.assertEqual(log.changes["api_key"], {"old": "empty", "new": "set"})
|
||||
self.assertNotIn("sk-created-secret", str(log.changes))
|
||||
self.assertNotIn("fernet:", str(log.changes))
|
||||
|
||||
def test_aimodel_admin_update_logs_field_and_key_changes(self):
|
||||
ai_model = self.create_ai_model()
|
||||
ai_model.url = "https://api.vectorengine.ai/v2"
|
||||
ai_model.set_api_key("sk-new-secret")
|
||||
|
||||
AiModelAdmin(AiModel, self.site).save_model(
|
||||
self.request,
|
||||
ai_model,
|
||||
form=None,
|
||||
change=True,
|
||||
)
|
||||
|
||||
log = AiConfigAuditLog.objects.get()
|
||||
self.assertEqual(log.action, AiConfigAuditLog.Action.UPDATE)
|
||||
self.assertEqual(set(log.changed_fields), {"url", "api_key"})
|
||||
self.assertEqual(
|
||||
log.changes["url"],
|
||||
{
|
||||
"old": "https://api.vectorengine.ai/v1",
|
||||
"new": "https://api.vectorengine.ai/v2",
|
||||
},
|
||||
)
|
||||
self.assertEqual(log.changes["api_key"], {"old": "set", "new": "set"})
|
||||
self.assertNotIn("sk-new-secret", str(log.changes))
|
||||
|
||||
def test_model_alias_admin_update_logs_mapping_change(self):
|
||||
text_model = self.create_ai_model(name="Text model", model="gpt-5.5")
|
||||
image_model = self.create_ai_model(
|
||||
name="Image model",
|
||||
model="gpt-image-2",
|
||||
capabilities=["image"],
|
||||
api_type=AiModel.ApiType.IMAGES_EDITS,
|
||||
url="https://api.vectorengine.ai/v1/images/edits",
|
||||
)
|
||||
alias = ModelAlias.objects.create(
|
||||
operation_type=ModelAlias.OperationType.IMAGE,
|
||||
alias="image-standard",
|
||||
ai_model=text_model,
|
||||
)
|
||||
alias.ai_model = image_model
|
||||
|
||||
ModelAliasAdmin(ModelAlias, self.site).save_model(
|
||||
self.request,
|
||||
alias,
|
||||
form=None,
|
||||
change=True,
|
||||
)
|
||||
|
||||
log = AiConfigAuditLog.objects.get()
|
||||
self.assertEqual(log.target_type, AiConfigAuditLog.TargetType.MODEL_ALIAS)
|
||||
self.assertEqual(log.changed_fields, ["ai_model"])
|
||||
self.assertEqual(
|
||||
log.changes["ai_model"],
|
||||
{"old": text_model.id, "new": image_model.id},
|
||||
)
|
||||
|
||||
def test_aimodel_admin_delete_writes_audit_log(self):
|
||||
ai_model = self.create_ai_model()
|
||||
target_id = ai_model.id
|
||||
|
||||
AiModelAdmin(AiModel, self.site).delete_model(self.request, ai_model)
|
||||
|
||||
log = AiConfigAuditLog.objects.get()
|
||||
self.assertEqual(log.action, AiConfigAuditLog.Action.DELETE)
|
||||
self.assertEqual(log.target_id, target_id)
|
||||
self.assertIn("api_key", log.changed_fields)
|
||||
self.assertEqual(log.changes["api_key"], {"old": "set", "new": "empty"})
|
||||
|
||||
def test_audit_log_admin_is_read_only(self):
|
||||
model_admin = AiConfigAuditLogAdmin(AiConfigAuditLog, self.site)
|
||||
|
||||
self.assertFalse(model_admin.has_add_permission(self.request))
|
||||
self.assertFalse(model_admin.has_change_permission(self.request))
|
||||
self.assertFalse(model_admin.has_delete_permission(self.request))
|
||||
self.assertIn("changes", model_admin.get_readonly_fields(self.request))
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目处于:**Phase 1**。T-101 Provider 适配器层与 T-102 AiModel/ModelAlias 数据模型、Fernet 加密密钥存储、别名解析已完成,下一步进入 T-103 配置变更审计。
|
||||
当前项目处于:**Phase 1**。T-101 Provider 适配器层、T-102 AiModel/ModelAlias 数据模型与 T-103 配置变更审计已完成,下一步进入 T-104 跑通一次真实/录制的标题或图片生成。
|
||||
|
||||
优先路径:
|
||||
|
||||
1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。
|
||||
2. Phase 1:最高风险功能原型 —— T-101/T-102 已完成 provider 层、模型配置表与别名解析;下一步 T-103 补配置审计,随后 T-104 跑通一次标题/图片生成。
|
||||
2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103 已完成 provider 层、模型配置表、别名解析与配置审计;下一步 T-104 跑通一次标题/图片生成。
|
||||
3. Phase 2:计费核心 —— User/UserWallet/ApiKey 模型 + 点数扣减(并发安全,锁 Wallet 行)+ 计费规则 + 调用记录。
|
||||
4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。
|
||||
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 + `requests` HTTP 客户端 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) |
|
||||
| 供应商密钥存储 | 应用层 Fernet 加密(`cryptography`) | 已定 | `AiModel.api_key_encrypted` 加密入库、admin 写入型字段不回显;加密主密钥 `AI_KEY_ENCRYPTION_KEY` 走环境变量,配置清单见 `env.md` |
|
||||
| 图片结果存储 | 对象存储(S3 兼容 / 本地存储)返回 URL | 待定 | 同步响应默认返回 `image_url`,避免大 base64 进响应体 |
|
||||
| 配置变更审计 | django-admin LogEntry 或自建审计表 | 待定 | 模型/别名/密钥变更留痕,与「账目对得上」一致 |
|
||||
| 配置变更审计 | 自建 `AiConfigAuditLog` 审计表 + django-admin 只读查看 | 已定 | 记录 AiModel / ModelAlias / api_key 变更的 actor、时间、目标、动作和字段差异;密钥只记录 empty/set 状态,不记录明文或密文 |
|
||||
| 数据库 | 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`) |
|
||||
| MySQL 驱动 | PyMySQL + cryptography | 已定 | PyMySQL 负责 Django 连接 MySQL;MySQL 8 默认 `caching_sha2_password` 认证需要 `cryptography` 支持 |
|
||||
| 对外鉴权 | API Key(DRF 自定义 Authentication,哈希存储比对) | 已定 | 用户自助生成 Key;**API 只认 Key、不挂 SessionAuthentication**,防浏览器 cookie 绕过计费 |
|
||||
@@ -35,6 +35,7 @@
|
||||
- **预付费点数而非实时查支付余额**:充值时按汇率把金额转点数存本地,解耦支付系统、降低调用延迟、并发扣减用本地数据库事务即可保证。代价是需处理充值幂等与对账。
|
||||
- **稳定接口 + 可插拔供应商**:对外只 `generate text/image` 两接口 + 能力别名;具体模型在后台配置并经适配器调用。收益是换供应商对调用方零改动、计费按别名稳定、可加授权与故障转移;代价是需维护适配器层与别名映射。**调用方不绑具体模型 SKU**。
|
||||
- **供应商密钥存储采用 Fernet 应用层加密**:T-102 已落地 `AiModel.api_key_encrypted`,密文带 `fernet:` 前缀;明文只在 admin 表单提交或调用 `resolve_alias()` 后进入内存,不入库、不回显。主密钥来自 `AI_KEY_ENCRYPTION_KEY`,生产不可随意更换,除非后续做密钥轮换。
|
||||
- **配置变更审计采用专表而非只依赖 admin LogEntry**:T-103 起后台保存/删除 `AiModel`、`ModelAlias` 时写 `AiConfigAuditLog`,记录谁、何时、对哪个配置做了 create/update/delete、哪些字段发生变化;`api_key` 变更只记 empty/set,不保存明文或 Fernet 密文。
|
||||
- **同步生成而非任务队列**: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) 第五节结论。
|
||||
|
||||
+17
-2
@@ -69,6 +69,7 @@
|
||||
| --- | --- | --- |
|
||||
| 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 |
|
||||
| AiConfigAuditLog | 后台自动写入 | AiModel / ModelAlias / api_key 变更审计;记录 actor、action、target、changed_fields、changes、created_at;只读 |
|
||||
| PricingRule | 运营配置 | 操作类型 × **能力别名**(+ 可选分辨率)→ 点数单价 |
|
||||
| ExchangeRate | 运营配置 | 金额 → 点数 的汇率(如 1 元 = N 点),可带生效时间 |
|
||||
|
||||
@@ -83,6 +84,7 @@
|
||||
- ⚠️ 图片返回结构(URL / base64、在返回中的位置)**首次对接须抓一次真实响应,再定适配器解析代码**。
|
||||
- `capabilities` 显式声明每个模型能做什么;标题接口只允许声明 `text` 的模型,图片接口只允许声明 `image` 的模型。
|
||||
- `api_key` **使用 Fernet 加密存储到 `api_key_encrypted`**,admin 只提供写入型 `api_key` 字段、脱敏显示、不回显明文;模型/密钥/别名映射的变更需审计留痕。
|
||||
- `AiConfigAuditLog` 只追加、不在后台编辑删除;密钥变更只记录 `api_key: empty/set` 状态变化,不记录明文 key 或 Fernet 密文。
|
||||
- 汇率在**充值下单创建订单时**锁定并记录到订单,同时计算预计到账点数;后续改汇率不影响已创建订单。支付回调入账时使用订单上的 `exchange_rate` / `points_granted`,不重新读取当前汇率。
|
||||
|
||||
#### 配置表目标 schema
|
||||
@@ -118,6 +120,19 @@ CREATE TABLE model_alias (
|
||||
UNIQUE(operation_type, alias)
|
||||
);
|
||||
|
||||
-- AI 配置审计(只追加,后台只读)
|
||||
CREATE TABLE ai_config_audit_log (
|
||||
id BIGINT PRIMARY KEY,
|
||||
actor_id BIGINT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
action VARCHAR(16) NOT NULL, -- create / update / delete
|
||||
target_type VARCHAR(32) NOT NULL, -- ai_model / model_alias
|
||||
target_id BIGINT,
|
||||
target_repr VARCHAR(255) NOT NULL,
|
||||
changed_fields JSON NOT NULL, -- ["url", "api_key"]
|
||||
changes JSON NOT NULL, -- 字段 old/new;api_key 仅 empty/set
|
||||
created_at DATETIME(6) NOT NULL
|
||||
);
|
||||
|
||||
-- 计费规则(按别名定价,换底层模型不影响计费)
|
||||
CREATE TABLE pricing_rule (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -129,7 +144,7 @@ CREATE TABLE pricing_rule (
|
||||
);
|
||||
```
|
||||
|
||||
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,后续若要强制数据库层约束可在 T-103/T-401 评估触发器或约束表。
|
||||
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,后续若要强制数据库层约束可在 T-401 评估触发器或约束表。
|
||||
|
||||
> 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。
|
||||
|
||||
@@ -293,7 +308,7 @@ CREATE TABLE call_record (
|
||||
| 抽象泄漏 | 各供应商入参出参不一致(resolution/aspect/vision/返回形态) | Provider 适配器 + capabilities 声明 + `parameters` 透传,核心字段稳定不取交集 |
|
||||
| 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 |
|
||||
| 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 |
|
||||
| 配置变更审计 | 改密钥/模型/别名映射无痕 | 后台变更留痕(谁、何时、改了什么),与「账目对得上」原则一致 |
|
||||
| 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 |
|
||||
| API Key 泄露 | 明文存库一旦泄露全泄 | Key **哈希存储**(sha256),明文只在创建时显示一次,库存 `key_hash`+`key_prefix`,鉴权做哈希比对 |
|
||||
| 注册滥用 | 自助注册被批量刷 | 邮箱验证 + 生成接口限流(DRF throttle);**注册不送点数**,无免费额度可薅 |
|
||||
| Web/API 抢 worker | 图片同步长请求占满 worker、拖慢用户端页面 | 单体下按路径把 `/api/generate/*` 与用户端页面**分流到不同 gunicorn/worker 池**(见 5.1) |
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T-101 | Provider 适配器层 + 移植 cmbot 调用 | T-002 | 定义 `Provider` 接口(`capabilities`/`generate_text`/`generate_image`),按 `api_type` 注册;把 `ai_text_service.py`/`ai_image_service.py` 搬进 `apps/ai/providers/` 并去除桌面依赖;**注意 3 模型机制不同(chat / chat 多模态返图 nano-banana2 / images_edits 改图 gpt-image-2,见 `04` 3.1),两图片模型非标准生成需分别解析、首次对接抓真实响应**;mock 上游单测验证解析与适配器选取 | DONE |
|
||||
| T-102 | AiModel + ModelAlias 模型 + 别名解析 | T-101 | AiModel 含 `capabilities`、`api_key` **加密存储**(admin 脱敏不回显);ModelAlias 映射别名→模型;`resolve_alias()` 能解析并按能力校验;配置迁移自 `ai_models.json`;后台改配置运行时热生效 | DONE |
|
||||
| T-103 | 配置变更审计 | T-102 | AiModel/ModelAlias/密钥的后台变更留痕(谁、何时、改了什么);可在 admin 查看 | TODO |
|
||||
| T-103 | 配置变更审计 | T-102 | AiModel/ModelAlias/密钥的后台变更留痕(谁、何时、改了什么);可在 admin 查看 | DONE |
|
||||
| T-104 | 跑通一次真实/录制的标题或图片生成 | T-102 | 用别名 + 最小输入跑通一次生成,结论写入 `progress.md`(含耗时,验证图片同步可行性与超时配置) | TODO |
|
||||
|
||||
## Phase 2 · 计费核心
|
||||
|
||||
+1
-1
@@ -221,6 +221,7 @@ class Provider(Protocol):
|
||||
- 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。
|
||||
- `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。
|
||||
- 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
|
||||
- 配置审计:后台保存/删除 AiModel、ModelAlias 时写 `AiConfigAuditLog`;记录 actor/action/target/changed_fields/changes/created_at,admin 只读查看;密钥变更只记录 empty/set 状态。
|
||||
- 区分异常:别名/能力不匹配 → 业务错误(400 类,如 `model_not_allowed`);上游网络/超时/服务错误 → `upstream_error`(502,触发退点)。
|
||||
- 不在本模块写点数逻辑,只负责解析、调上游与解析返回。
|
||||
|
||||
@@ -228,7 +229,6 @@ class Provider(Protocol):
|
||||
|
||||
- 支付商户真实配置:微信 `appid/mchid/apiv3_key/证书/notify_url`,支付宝 `appid/应用私钥/支付宝公钥/notify_url`。协议字段、验签方式与成功应答已按同系统实现明确;缺真实配置时用 mock。
|
||||
- 汇率与各操作/别名(+ 分辨率档)的点数单价。
|
||||
- 配置变更审计: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,10 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-02
|
||||
- 阶段:Phase 1,T-102 已完成;下一步 T-103
|
||||
- 阶段:Phase 1,T-103 已完成;下一步 T-104
|
||||
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md`
|
||||
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令
|
||||
- 测试:`manage.py test` 通过(18 tests);`manage.py test apps.ai --noinput` 通过(16 tests);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`./init.ps1` 通过
|
||||
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook
|
||||
- 测试:`manage.py test --noinput --keepdb` 通过(23 tests);`manage.py test apps.ai --noinput` 通过(21 tests);`manage.py test apps.ai.tests.AiConfigAuditAdminTests --noinput` 通过(5 tests);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`./init.ps1` 通过。标准 `manage.py test --noinput` 多次在远程 MySQL 创建/销毁测试库阶段超时,非断言失败
|
||||
- 数据: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`
|
||||
@@ -33,7 +33,7 @@
|
||||
| `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
|
||||
| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 |
|
||||
| `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi |
|
||||
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移 |
|
||||
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移 |
|
||||
| `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-102 AiModel + ModelAlias 模型 + 别名解析。
|
||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计。
|
||||
- 正在进行:无。
|
||||
- 当前 blocker:无。
|
||||
- 下一个可领取任务:**T-103 配置变更审计**。
|
||||
- 下一个可领取任务:**T-104 跑通一次真实/录制的标题或图片生成**。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
@@ -64,14 +64,14 @@ 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 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-102 仍不做配置审计、真实上游生成或计费;这些分别从 T-103、T-104、T-201/T-302 继续。
|
||||
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库存在间歇超时,必要时用 `--keepdb` 验证。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-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-103)。
|
||||
4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-104)。
|
||||
5. 将该任务状态改为 `DOING`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
@@ -101,8 +101,8 @@
|
||||
## 十、当前状态
|
||||
|
||||
- M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。
|
||||
- Phase 1 已完成 T-101/T-102:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析与配置导入命令已落地。
|
||||
- 下一步是 T-103 配置变更审计,随后 T-104 跑通一次真实/录制的标题或图片生成。
|
||||
- Phase 1 已完成 T-101/T-102/T-103:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置导入命令与配置变更审计已落地。
|
||||
- 下一步是 T-104 跑通一次真实/录制的标题或图片生成。
|
||||
|
||||
---
|
||||
*更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。*
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
## 进度
|
||||
|
||||
M1 骨架已完成;Phase 1 已完成 Provider 适配器、AiModel/ModelAlias、加密密钥存储和别名解析。下一步补配置变更审计,再跑通一次真实/录制生成。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
|
||||
M1 骨架已完成;Phase 1 已完成 Provider 适配器、AiModel/ModelAlias、加密密钥存储、别名解析和配置变更审计。下一步跑通一次真实/录制生成。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
|
||||
|
||||
---
|
||||
*详见 `project-brief.md`(完整介绍)。*
|
||||
|
||||
+2
-1
@@ -42,6 +42,7 @@
|
||||
| 调用记录 | CallRecord | 按账号/时间/状态检索调用、查看别名/实际模型/消耗/错误(只读) |
|
||||
| 模型配置 | AiModel | 维护上游模型(url/model/api_key[加密脱敏]/api_type/capabilities/timeout) |
|
||||
| 能力别名 | ModelAlias | 维护对外别名 → 具体模型的映射;换供应商在此改指向 |
|
||||
| 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 |
|
||||
|
||||
## 后台职责约定
|
||||
|
||||
@@ -55,7 +56,7 @@
|
||||
|
||||
- AiModel 的 `api_key` 加密存储,列表/详情脱敏显示,不回显明文。
|
||||
- 换供应商:改 `ModelAlias` 的指向即可,对外别名与计费规则不变。
|
||||
- AiModel / ModelAlias / 密钥的变更需留痕(谁、何时、改了什么)。
|
||||
- AiModel / ModelAlias / 密钥的后台变更写入 `AiConfigAuditLog`(谁、何时、改了什么),日志只读;密钥变更只记录 empty/set 状态,不记录明文或密文。
|
||||
|
||||
### 流水 / 调用记录
|
||||
|
||||
|
||||
+25
@@ -315,3 +315,28 @@
|
||||
- T-102 不自动导入真实 `D:\chengma\cmbot\config\ai_models.json`,避免真实上游 key 被误暴露;后续由管理员在确认环境密钥后手动执行导入命令。
|
||||
- 默认别名唯一性先由 model validation、admin 与导入器保证;MySQL partial unique 约束留待审计/后台完善阶段评估。
|
||||
- 下一步:领取 T-103 配置变更审计。
|
||||
|
||||
## 2026-07-02 T-103 配置变更审计
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:
|
||||
- 新增 `AiConfigAuditLog` 审计模型与 `apps/ai/migrations/0002_aiconfigauditlog.py`,字段包括 `actor`、`action`、`target_type`、`target_id`、`target_repr`、`changed_fields`、`changes`、`created_at`。
|
||||
- 新增 `apps/ai/audit.py`:对 `AiModel` / `ModelAlias` 做变更快照与字段 diff,`api_key_encrypted` 对外记录为 `api_key`,只保留 `empty` / `set` 状态,不记录明文或 Fernet 密文。
|
||||
- 更新 `apps/ai/admin.py`:`AiModelAdmin` / `ModelAliasAdmin` 的保存和删除 hook 自动写审计日志;新增 `AiConfigAuditLogAdmin`,仅允许只读查看,不允许新增、修改、删除审计日志。
|
||||
- 扩展 `apps/ai/tests.py`:新增 5 条审计测试,覆盖 AiModel 创建、密钥更新、ModelAlias 映射变更、删除审计、审计 admin 只读。
|
||||
- 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`、汇报文档。
|
||||
- 验证:
|
||||
- `./init.ps1`:通过,依赖同步与基础检查正常。
|
||||
- `py -3.12 manage.py migrate`:通过,应用 `ai.0002_aiconfigauditlog`。
|
||||
- `py -3.12 manage.py showmigrations ai`:通过,`0001_initial` / `0002_aiconfigauditlog` 均为 `[X]`。
|
||||
- `py -3.12 manage.py test apps.ai.tests.AiConfigAuditAdminTests --noinput`:通过,5 tests OK。
|
||||
- `py -3.12 manage.py test apps.ai --noinput`:通过,21 tests OK。
|
||||
- `py -3.12 manage.py test --noinput --keepdb`:通过,23 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`:通过。
|
||||
- `git diff --check`:通过,仅有 CRLF 提示。
|
||||
- 标准 `py -3.12 manage.py test --noinput` 多次在远程 MySQL `43.128.3.240:3306` 创建/销毁测试库或测试中途重连时超时;失败点为 MySQL 连接/建库,不是断言失败。为降低远程建库压力,使用 `--keepdb` 完整测试已通过。
|
||||
- 阻塞:无代码阻塞;远程 MySQL 偶发连接超时会影响标准测试建库稳定性。
|
||||
- 决策:T-103 使用自建 `AiConfigAuditLog` 专表,不只依赖 django-admin `LogEntry`;密钥审计只记录状态变化,不记录明文或密文。
|
||||
- 下一步:领取 T-104 跑通一次真实/录制的标题或图片生成。
|
||||
|
||||
Reference in New Issue
Block a user