feat: audit ai config changes

This commit is contained in:
QiuSW
2026-07-02 11:42:39 +08:00
parent 18ac054dc5
commit c0020e8d29
16 changed files with 520 additions and 23 deletions
+1 -1
View File
@@ -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) 第四节计费时序。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+136 -1
View File
@@ -1,7 +1,8 @@
from django import forms from django import forms
from django.contrib import admin 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 from .security import AiKeyEncryptionError, encrypt_api_key
@@ -98,6 +99,53 @@ class AiModelAdmin(admin.ModelAdmin):
def api_key_status(self, obj): def api_key_status(self, obj):
return obj.api_key_masked or "not set" 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) @admin.register(ModelAlias)
class ModelAliasAdmin(admin.ModelAdmin): class ModelAliasAdmin(admin.ModelAdmin):
@@ -113,3 +161,90 @@ class ModelAliasAdmin(admin.ModelAdmin):
search_fields = ("alias", "ai_model__name", "ai_model__model") search_fields = ("alias", "ai_model__name", "ai_model__model")
autocomplete_fields = ("ai_model",) autocomplete_fields = ("ai_model",)
readonly_fields = ("created_at", "updated_at") 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))
+111
View File
@@ -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')],
},
),
]
+39
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@@ -133,3 +134,41 @@ class ModelAlias(models.Model):
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs) -> None:
self.full_clean() self.full_clean()
super().save(*args, **kwargs) 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
View File
@@ -2,12 +2,13 @@ import base64
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from django.contrib.admin.sites import AdminSite 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 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.aliases import AliasNotFoundError, ModelCapabilityError, resolve_alias
from apps.ai.importers import import_ai_models_config 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 import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type
from apps.ai.providers.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider from apps.ai.providers.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider
@@ -425,3 +426,137 @@ class AiModelAdminTests(TestCase):
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertIn("AI_KEY_ENCRYPTION_KEY is not configured", str(form.errors)) 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))
+2 -2
View File
@@ -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 审核修补项已完成。 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 行)+ 计费规则 + 调用记录。 3. Phase 2:计费核心 —— User/UserWallet/ApiKey 模型 + 点数扣减(并发安全,锁 Wallet 行)+ 计费规则 + 调用记录。
4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。 4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
+2 -1
View File
@@ -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) | | 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` | | 供应商密钥存储 | 应用层 Fernet 加密(`cryptography`) | 已定 | `AiModel.api_key_encrypted` 加密入库、admin 写入型字段不回显;加密主密钥 `AI_KEY_ENCRYPTION_KEY` 走环境变量,配置清单见 `env.md` |
| 图片结果存储 | 对象存储(S3 兼容 / 本地存储)返回 URL | 待定 | 同步响应默认返回 `image_url`,避免大 base64 进响应体 | | 图片结果存储 | 对象存储(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 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` 支持 | | MySQL 驱动 | PyMySQL + cryptography | 已定 | PyMySQL 负责 Django 连接 MySQL;MySQL 8 默认 `caching_sha2_password` 认证需要 `cryptography` 支持 |
| 对外鉴权 | API Key(DRF 自定义 Authentication,哈希存储比对) | 已定 | 用户自助生成 Key;**API 只认 Key、不挂 SessionAuthentication**,防浏览器 cookie 绕过计费 | | 对外鉴权 | API Key(DRF 自定义 Authentication,哈希存储比对) | 已定 | 用户自助生成 Key;**API 只认 Key、不挂 SessionAuthentication**,防浏览器 cookie 绕过计费 |
@@ -35,6 +35,7 @@
- **预付费点数而非实时查支付余额**:充值时按汇率把金额转点数存本地,解耦支付系统、降低调用延迟、并发扣减用本地数据库事务即可保证。代价是需处理充值幂等与对账。 - **预付费点数而非实时查支付余额**:充值时按汇率把金额转点数存本地,解耦支付系统、降低调用延迟、并发扣减用本地数据库事务即可保证。代价是需处理充值幂等与对账。
- **稳定接口 + 可插拔供应商**:对外只 `generate text/image` 两接口 + 能力别名;具体模型在后台配置并经适配器调用。收益是换供应商对调用方零改动、计费按别名稳定、可加授权与故障转移;代价是需维护适配器层与别名映射。**调用方不绑具体模型 SKU**。 - **稳定接口 + 可插拔供应商**:对外只 `generate text/image` 两接口 + 能力别名;具体模型在后台配置并经适配器调用。收益是换供应商对调用方零改动、计费按别名稳定、可加授权与故障转移;代价是需维护适配器层与别名映射。**调用方不绑具体模型 SKU**。
- **供应商密钥存储采用 Fernet 应用层加密**:T-102 已落地 `AiModel.api_key_encrypted`,密文带 `fernet:` 前缀;明文只在 admin 表单提交或调用 `resolve_alias()` 后进入内存,不入库、不回显。主密钥来自 `AI_KEY_ENCRYPTION_KEY`,生产不可随意更换,除非后续做密钥轮换。 - **供应商密钥存储采用 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 再异步化。 - **同步生成而非任务队列**:MVP 不引入 Celery/Redis,降低复杂度;图片耗时长,靠调大超时支撑,V2 再异步化。
- **桌面端可不改、全同步接入**:cmbot 现有批处理引擎(后台线程池 + 进度/心跳/重试/停止)只需把 service 层 URL/密钥从直连中转站换成 cmhub 的 `base_url + API Key`,即可正常使用;`桌面端 → cmhub → 中转站` 多一跳不影响可用性,点数一致性反而更简单(一次请求闭环:预扣→同步调→成功/失败退点)。 - **桌面端可不改、全同步接入**: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) 第五节结论。 - **同步可用的前提是「超时链路 + worker 容量」配对**,否则会「小量正常、上量假死」:① 每模型 `timeout_seconds` 设有限值(`ai_models.json` 现为 `0`,迁入须改);② Gunicorn `--timeout` 与网关 `proxy_read_timeout` 按最慢图片放大(别用默认 30s / 60s);③ worker/线程数按**峰值总并发**预留。详见 [架构设计](04-architecture.md) 第五节结论。
+17 -2
View File
@@ -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 加密存储)** | | 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 | | ModelAlias | 运营配置 | `operation_type + alias` → 映射到一个具体 AiModel;支持每个操作一个默认别名;调用方只见别名,不见 SKU |
| AiConfigAuditLog | 后台自动写入 | AiModel / ModelAlias / api_key 变更审计;记录 actor、action、target、changed_fields、changes、created_at;只读 |
| PricingRule | 运营配置 | 操作类型 × **能力别名**(+ 可选分辨率)→ 点数单价 | | PricingRule | 运营配置 | 操作类型 × **能力别名**(+ 可选分辨率)→ 点数单价 |
| ExchangeRate | 运营配置 | 金额 → 点数 的汇率(如 1 元 = N 点),可带生效时间 | | ExchangeRate | 运营配置 | 金额 → 点数 的汇率(如 1 元 = N 点),可带生效时间 |
@@ -83,6 +84,7 @@
- ⚠️ 图片返回结构(URL / base64、在返回中的位置)**首次对接须抓一次真实响应,再定适配器解析代码**。 - ⚠️ 图片返回结构(URL / base64、在返回中的位置)**首次对接须抓一次真实响应,再定适配器解析代码**。
- `capabilities` 显式声明每个模型能做什么;标题接口只允许声明 `text` 的模型,图片接口只允许声明 `image` 的模型。 - `capabilities` 显式声明每个模型能做什么;标题接口只允许声明 `text` 的模型,图片接口只允许声明 `image` 的模型。
- `api_key` **使用 Fernet 加密存储到 `api_key_encrypted`**,admin 只提供写入型 `api_key` 字段、脱敏显示、不回显明文;模型/密钥/别名映射的变更需审计留痕。 - `api_key` **使用 Fernet 加密存储到 `api_key_encrypted`**,admin 只提供写入型 `api_key` 字段、脱敏显示、不回显明文;模型/密钥/别名映射的变更需审计留痕。
- `AiConfigAuditLog` 只追加、不在后台编辑删除;密钥变更只记录 `api_key: empty/set` 状态变化,不记录明文 key 或 Fernet 密文。
- 汇率在**充值下单创建订单时**锁定并记录到订单,同时计算预计到账点数;后续改汇率不影响已创建订单。支付回调入账时使用订单上的 `exchange_rate` / `points_granted`,不重新读取当前汇率。 - 汇率在**充值下单创建订单时**锁定并记录到订单,同时计算预计到账点数;后续改汇率不影响已创建订单。支付回调入账时使用订单上的 `exchange_rate` / `points_granted`,不重新读取当前汇率。
#### 配置表目标 schema #### 配置表目标 schema
@@ -118,6 +120,19 @@ CREATE TABLE model_alias (
UNIQUE(operation_type, 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 ( CREATE TABLE pricing_rule (
id INTEGER PRIMARY KEY, 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/故障转移)。适配器接口需为此留口子。 > 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。
@@ -293,7 +308,7 @@ CREATE TABLE call_record (
| 抽象泄漏 | 各供应商入参出参不一致(resolution/aspect/vision/返回形态) | Provider 适配器 + capabilities 声明 + `parameters` 透传,核心字段稳定不取交集 | | 抽象泄漏 | 各供应商入参出参不一致(resolution/aspect/vision/返回形态) | Provider 适配器 + capabilities 声明 + `parameters` 透传,核心字段稳定不取交集 |
| 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 | | 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 |
| 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 | | 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 |
| 配置变更审计 | 改密钥/模型/别名映射无痕 | 后台变更留痕(谁、何时、改了什么),与「账目对得上」原则一致 | | 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 |
| API Key 泄露 | 明文存库一旦泄露全泄 | Key **哈希存储**(sha256),明文只在创建时显示一次,库存 `key_hash`+`key_prefix`,鉴权做哈希比对 | | API Key 泄露 | 明文存库一旦泄露全泄 | Key **哈希存储**(sha256),明文只在创建时显示一次,库存 `key_hash`+`key_prefix`,鉴权做哈希比对 |
| 注册滥用 | 自助注册被批量刷 | 邮箱验证 + 生成接口限流(DRF throttle);**注册不送点数**,无免费额度可薅 | | 注册滥用 | 自助注册被批量刷 | 邮箱验证 + 生成接口限流(DRF throttle);**注册不送点数**,无免费额度可薅 |
| Web/API 抢 worker | 图片同步长请求占满 worker、拖慢用户端页面 | 单体下按路径把 `/api/generate/*` 与用户端页面**分流到不同 gunicorn/worker 池**(见 5.1) | | Web/API 抢 worker | 图片同步长请求占满 worker、拖慢用户端页面 | 单体下按路径把 `/api/generate/*` 与用户端页面**分流到不同 gunicorn/worker 池**(见 5.1) |
+1 -1
View File
@@ -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-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-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 | | T-104 | 跑通一次真实/录制的标题或图片生成 | T-102 | 用别名 + 最小输入跑通一次生成,结论写入 `progress.md`(含耗时,验证图片同步可行性与超时配置) | TODO |
## Phase 2 · 计费核心 ## Phase 2 · 计费核心
+1 -1
View File
@@ -221,6 +221,7 @@ class Provider(Protocol):
- 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。 - 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。
- `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。 - `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。
- 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。 - 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
- 配置审计:后台保存/删除 AiModel、ModelAlias 时写 `AiConfigAuditLog`;记录 actor/action/target/changed_fields/changes/created_at,admin 只读查看;密钥变更只记录 empty/set 状态。
- 区分异常:别名/能力不匹配 → 业务错误(400 类,如 `model_not_allowed`);上游网络/超时/服务错误 → `upstream_error`(502,触发退点)。 - 区分异常:别名/能力不匹配 → 业务错误(400 类,如 `model_not_allowed`);上游网络/超时/服务错误 → `upstream_error`(502,触发退点)。
- 不在本模块写点数逻辑,只负责解析、调上游与解析返回。 - 不在本模块写点数逻辑,只负责解析、调上游与解析返回。
@@ -228,7 +229,6 @@ class Provider(Protocol):
- 支付商户真实配置:微信 `appid/mchid/apiv3_key/证书/notify_url`,支付宝 `appid/应用私钥/支付宝公钥/notify_url`。协议字段、验签方式与成功应答已按同系统实现明确;缺真实配置时用 mock。 - 支付商户真实配置:微信 `appid/mchid/apiv3_key/证书/notify_url`,支付宝 `appid/应用私钥/支付宝公钥/notify_url`。协议字段、验签方式与成功应答已按同系统实现明确;缺真实配置时用 mock。
- 汇率与各操作/别名(+ 分辨率档)的点数单价。 - 汇率与各操作/别名(+ 分辨率档)的点数单价。
- 配置变更审计:T-103 补 AiModel / ModelAlias / 密钥变更留痕(谁、何时、改了什么)。
- 图片结果存储:对象存储选型与 `image_url` 生成(默认走存储返回 URL)。 - 图片结果存储:对象存储选型与 `image_url` 生成(默认走存储返回 URL)。
- **图片模型调用机制**:`gpt-image-2` 走 `images/edits`(改图,原图必传)、`nano-banana2` 走 `chat/completions` 多模态返图(需自定义解析),两者非标准 `images/generations`。图片返回结构(URL / base64 / 位置)**首次对接抓真实响应确认**后再定适配器解析。 - **图片模型调用机制**:`gpt-image-2` 走 `images/edits`(改图,原图必传)、`nano-banana2` 走 `chat/completions` 多模态返图(需自定义解析),两者非标准 `images/generations`。图片返回结构(URL / base64 / 位置)**首次对接抓真实响应确认**后再定适配器解析。
- 别名命名规范、默认别名、是否按账号授权可用别名(`account_alias_permission`)。 - 别名命名规范、默认别名、是否按账号授权可用别名(`account_alias_permission`)。
+8 -8
View File
@@ -12,10 +12,10 @@
## 当前快照 ## 当前快照
- 日期:2026-07-02 - 日期: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` - 技术栈:系统 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` 导入命令 - 生产代码:已有最小 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` 通过(18 tests);`manage.py test apps.ai --noinput` 通过(16 tests);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`./init.ps1` 通过 - 测试:`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` 命令加密导入 - 数据: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 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 标准验证路径: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` | | `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 加密 | | `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 | | `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 创建 | | `manage.py` | 已有 | T-001 创建 |
| `tests/` | 待建 | 随各任务补充 | | `tests/` | 待建 | 随各任务补充 |
@@ -41,10 +41,10 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 任务状态以 [`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:无。 - 当前 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 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`。 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。 2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 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`。 5. 将该任务状态改为 `DOING`。
## 维护规则 ## 维护规则
+2 -2
View File
@@ -101,8 +101,8 @@
## 十、当前状态 ## 十、当前状态
- M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。 - M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。
- Phase 1 已完成 T-101/T-102:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析与配置导入命令已落地。 - Phase 1 已完成 T-101/T-102/T-103:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置导入命令与配置变更审计已落地。
- 下一步是 T-103 配置变更审计,随后 T-104 跑通一次真实/录制的标题或图片生成。 - 下一步是 T-104 跑通一次真实/录制的标题或图片生成。
--- ---
*更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。* *更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。*
+1 -1
View File
@@ -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`(完整介绍)。* *详见 `project-brief.md`(完整介绍)。*
+2 -1
View File
@@ -42,6 +42,7 @@
| 调用记录 | CallRecord | 按账号/时间/状态检索调用、查看别名/实际模型/消耗/错误(只读) | | 调用记录 | CallRecord | 按账号/时间/状态检索调用、查看别名/实际模型/消耗/错误(只读) |
| 模型配置 | AiModel | 维护上游模型(url/model/api_key[加密脱敏]/api_type/capabilities/timeout) | | 模型配置 | AiModel | 维护上游模型(url/model/api_key[加密脱敏]/api_type/capabilities/timeout) |
| 能力别名 | ModelAlias | 维护对外别名 → 具体模型的映射;换供应商在此改指向 | | 能力别名 | ModelAlias | 维护对外别名 → 具体模型的映射;换供应商在此改指向 |
| 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 |
## 后台职责约定 ## 后台职责约定
@@ -55,7 +56,7 @@
- AiModel 的 `api_key` 加密存储,列表/详情脱敏显示,不回显明文。 - AiModel 的 `api_key` 加密存储,列表/详情脱敏显示,不回显明文。
- 换供应商:改 `ModelAlias` 的指向即可,对外别名与计费规则不变。 - 换供应商:改 `ModelAlias` 的指向即可,对外别名与计费规则不变。
- AiModel / ModelAlias / 密钥的变更需留痕(谁、何时、改了什么)。 - AiModel / ModelAlias / 密钥的后台变更写入 `AiConfigAuditLog`(谁、何时、改了什么),日志只读;密钥变更只记录 empty/set 状态,不记录明文或密文。
### 流水 / 调用记录 ### 流水 / 调用记录
+25
View File
@@ -315,3 +315,28 @@
- T-102 不自动导入真实 `D:\chengma\cmbot\config\ai_models.json`,避免真实上游 key 被误暴露;后续由管理员在确认环境密钥后手动执行导入命令。 - T-102 不自动导入真实 `D:\chengma\cmbot\config\ai_models.json`,避免真实上游 key 被误暴露;后续由管理员在确认环境密钥后手动执行导入命令。
- 默认别名唯一性先由 model validation、admin 与导入器保证;MySQL partial unique 约束留待审计/后台完善阶段评估。 - 默认别名唯一性先由 model validation、admin 与导入器保证;MySQL partial unique 约束留待审计/后台完善阶段评估。
- 下一步:领取 T-103 配置变更审计。 - 下一步:领取 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 跑通一次真实/录制的标题或图片生成。