From c0020e8d2916d18eac12ccbea5ad9eefb5d4bda9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 2 Jul 2026 11:42:39 +0800 Subject: [PATCH] feat: audit ai config changes --- README.md | 2 +- apps/ai/admin.py | 137 ++++++++++++++++++- apps/ai/audit.py | 111 ++++++++++++++++ apps/ai/migrations/0002_aiconfigauditlog.py | 35 +++++ apps/ai/models.py | 39 ++++++ apps/ai/tests.py | 139 +++++++++++++++++++- docs/00-ai-start-here.md | 4 +- docs/03-tech-stack.md | 3 +- docs/04-architecture.md | 19 ++- docs/06-tasks.md | 2 +- docs/api.md | 2 +- docs/current-state.md | 16 +-- docs/project-brief.md | 4 +- docs/project-onepager.md | 2 +- docs/routes.md | 3 +- progress.md | 25 ++++ 16 files changed, 520 insertions(+), 23 deletions(-) create mode 100644 apps/ai/audit.py create mode 100644 apps/ai/migrations/0002_aiconfigauditlog.py diff --git a/README.md b/README.md index b228fe5..a1a2cb5 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR ## 当前状态 -Phase 1 已完成 T-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) 第四节计费时序。 diff --git a/apps/ai/admin.py b/apps/ai/admin.py index b69674e..6c50037 100644 --- a/apps/ai/admin.py +++ b/apps/ai/admin.py @@ -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)) diff --git a/apps/ai/audit.py b/apps/ai/audit.py new file mode 100644 index 0000000..f7cdf47 --- /dev/null +++ b/apps/ai/audit.py @@ -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 diff --git a/apps/ai/migrations/0002_aiconfigauditlog.py b/apps/ai/migrations/0002_aiconfigauditlog.py new file mode 100644 index 0000000..6e8e668 --- /dev/null +++ b/apps/ai/migrations/0002_aiconfigauditlog.py @@ -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')], + }, + ), + ] diff --git a/apps/ai/models.py b/apps/ai/models.py index edc7db3..3695a30 100644 --- a/apps/ai/models.py +++ b/apps/ai/models.py @@ -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}" diff --git a/apps/ai/tests.py b/apps/ai/tests.py index 193602d..338cd14 100644 --- a/apps/ai/tests.py +++ b/apps/ai/tests.py @@ -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)) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 2949869..c9e0ab8 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -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 管理、个人中心/记录页、充值页。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 8d33e85..8bb0227 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -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) 第五节结论。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index b3b54a1..aecfe1d 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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) | diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 8ea19c2..7ea3410 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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 · 计费核心 diff --git a/docs/api.md b/docs/api.md index 180a195..857b3f1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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`)。 diff --git a/docs/current-state.md b/docs/current-state.md index 16e297c..a911852 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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`。 ## 维护规则 diff --git a/docs/project-brief.md b/docs/project-brief.md index 40236b2..c5f0710 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -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`。* diff --git a/docs/project-onepager.md b/docs/project-onepager.md index a11a1e1..9659f7e 100644 --- a/docs/project-onepager.md +++ b/docs/project-onepager.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`(完整介绍)。* diff --git a/docs/routes.md b/docs/routes.md index 399d29c..f8fe108 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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 状态,不记录明文或密文。 ### 流水 / 调用记录 diff --git a/progress.md b/progress.md index 93150c2..c5e4576 100644 --- a/progress.md +++ b/progress.md @@ -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 跑通一次真实/录制的标题或图片生成。