feat: 中文化 django-admin 字段标签 (T-603)

This commit is contained in:
QiuSW
2026-07-04 16:27:45 +08:00
parent 4aceb3ae25
commit fb1037933d
14 changed files with 714 additions and 139 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
## 当前状态 ## 当前状态
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现(`GET /api/v1/models` + portal 只读「可用模型」页):用户可通过 allauth 自助注册、邮箱验证、登录、登出,扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页消费记录与可用模型;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水和调用记录,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。计划内 MVP 任务和 T-601 增强任务已完成,同时生产侧仍需配置真实邮件、支付、AI 模型和图片真实耗时验证。详见 [`docs/current-state.md`](docs/current-state.md)。 Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现与 T-602 django-admin 分组/表名中文化;T-603 字段级中文化代码和 no-op 迁移已完成,但完整测试被工作区既有邮箱验证配置改动阻塞,暂未标 DONE。用户可通过 allauth 自助注册、邮箱验证、登录、登出,扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页消费记录与可用模型;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水和调用记录,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。生产侧仍需配置真实邮件、支付、AI 模型和图片真实耗时验证。详见 [`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) 第四节计费时序。
+11 -11
View File
@@ -8,10 +8,10 @@ from .security import AiKeyEncryptionError, encrypt_api_key
class AiModelAdminForm(forms.ModelForm): class AiModelAdminForm(forms.ModelForm):
api_key = forms.CharField( api_key = forms.CharField(
label="API key", label="API Key",
required=False, required=False,
widget=forms.PasswordInput(render_value=False), widget=forms.PasswordInput(render_value=False),
help_text="Leave blank to keep the existing encrypted key.", help_text="留空表示保留已有加密密钥。",
) )
class Meta: class Meta:
@@ -33,7 +33,7 @@ class AiModelAdminForm(forms.ModelForm):
cleaned_data = super().clean() cleaned_data = super().clean()
api_key = cleaned_data.get("api_key") api_key = cleaned_data.get("api_key")
if not self.instance.pk and not api_key: if not self.instance.pk and not api_key:
raise forms.ValidationError("API key is required when creating an AI model.") raise forms.ValidationError("创建 AI 模型时必须填写 API Key。")
if api_key: if api_key:
try: try:
self._api_key_encrypted = encrypt_api_key(api_key) self._api_key_encrypted = encrypt_api_key(api_key)
@@ -71,14 +71,14 @@ class AiModelAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
(None, {"fields": ("name", "url", "model", "api_type", "is_active")}), (None, {"fields": ("name", "url", "model", "api_type", "is_active")}),
( (
"Credentials", "凭证",
{ {
"fields": ("api_key", "api_key_status"), "fields": ("api_key", "api_key_status"),
"description": "The stored key is encrypted and never displayed.", "description": "已保存的密钥会加密存储,后台不会回显明文。",
}, },
), ),
( (
"Capabilities and request defaults", "能力与请求默认值",
{ {
"fields": ( "fields": (
"capabilities", "capabilities",
@@ -88,16 +88,16 @@ class AiModelAdmin(admin.ModelAdmin):
) )
}, },
), ),
("Timestamps", {"fields": ("created_at", "updated_at")}), ("时间信息", {"fields": ("created_at", "updated_at")}),
) )
@admin.display(description="capabilities") @admin.display(description="能力")
def capabilities_display(self, obj): def capabilities_display(self, obj):
return ", ".join(sorted(obj.capabilities_set())) return ", ".join(sorted(obj.capabilities_set()))
@admin.display(description="API key") @admin.display(description="API Key")
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 "未设置"
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
before = _snapshot_existing(obj) if change else None before = _snapshot_existing(obj) if change else None
@@ -241,7 +241,7 @@ class AiConfigAuditLogAdmin(admin.ModelAdmin):
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return False return False
@admin.display(description="changed fields") @admin.display(description="变更字段")
def changed_fields_display(self, obj): def changed_fields_display(self, obj):
return ", ".join(obj.changed_fields) return ", ".join(obj.changed_fields)
@@ -0,0 +1,151 @@
# Generated by Django 5.2.15 on 2026-07-04 08:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0003_alter_aiconfigauditlog_options_alter_aimodel_options_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='aiconfigauditlog',
name='action',
field=models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除')], max_length=16, verbose_name='动作'),
),
migrations.AlterField(
model_name='aiconfigauditlog',
name='actor',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='操作人'),
),
migrations.AlterField(
model_name='aiconfigauditlog',
name='changed_fields',
field=models.JSONField(blank=True, default=list, verbose_name='变更字段'),
),
migrations.AlterField(
model_name='aiconfigauditlog',
name='changes',
field=models.JSONField(blank=True, default=dict, verbose_name='变更内容'),
),
migrations.AlterField(
model_name='aiconfigauditlog',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='aiconfigauditlog',
name='target_id',
field=models.PositiveBigIntegerField(blank=True, null=True, verbose_name='目标 ID'),
),
migrations.AlterField(
model_name='aiconfigauditlog',
name='target_repr',
field=models.CharField(max_length=255, verbose_name='目标描述'),
),
migrations.AlterField(
model_name='aiconfigauditlog',
name='target_type',
field=models.CharField(choices=[('ai_model', 'AI 模型'), ('model_alias', '能力别名')], max_length=32, verbose_name='目标类型'),
),
migrations.AlterField(
model_name='aimodel',
name='api_key_encrypted',
field=models.TextField(blank=True, editable=False, verbose_name='加密 API Key'),
),
migrations.AlterField(
model_name='aimodel',
name='api_type',
field=models.CharField(choices=[('auto', '自动'), ('chat', '聊天补全'), ('gemini', 'Gemini'), ('images', '图片生成'), ('images_edits', '图片编辑')], default='auto', max_length=32, verbose_name='API 类型'),
),
migrations.AlterField(
model_name='aimodel',
name='capabilities',
field=models.JSONField(blank=True, default=list, verbose_name='能力'),
),
migrations.AlterField(
model_name='aimodel',
name='connect_timeout_seconds',
field=models.PositiveIntegerField(default=30, verbose_name='连接超时秒数'),
),
migrations.AlterField(
model_name='aimodel',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='aimodel',
name='extra_body',
field=models.JSONField(blank=True, default=dict, verbose_name='额外请求体'),
),
migrations.AlterField(
model_name='aimodel',
name='is_active',
field=models.BooleanField(default=True, verbose_name='启用'),
),
migrations.AlterField(
model_name='aimodel',
name='model',
field=models.CharField(max_length=128, verbose_name='模型标识'),
),
migrations.AlterField(
model_name='aimodel',
name='name',
field=models.CharField(max_length=128, unique=True, verbose_name='名称'),
),
migrations.AlterField(
model_name='aimodel',
name='timeout_seconds',
field=models.PositiveIntegerField(default=0, help_text='0 表示使用 provider 默认值。', verbose_name='请求超时秒数'),
),
migrations.AlterField(
model_name='aimodel',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='aimodel',
name='url',
field=models.URLField(max_length=500, verbose_name='接口 URL'),
),
migrations.AlterField(
model_name='modelalias',
name='ai_model',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aliases', to='ai.aimodel', verbose_name='AI 模型'),
),
migrations.AlterField(
model_name='modelalias',
name='alias',
field=models.SlugField(max_length=64, verbose_name='能力别名'),
),
migrations.AlterField(
model_name='modelalias',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='modelalias',
name='is_active',
field=models.BooleanField(default=True, verbose_name='启用'),
),
migrations.AlterField(
model_name='modelalias',
name='is_default',
field=models.BooleanField(default=False, verbose_name='默认别名'),
),
migrations.AlterField(
model_name='modelalias',
name='operation_type',
field=models.CharField(choices=[('title', '生成标题'), ('image', '生成图片')], max_length=32, verbose_name='操作类型'),
),
migrations.AlterField(
model_name='modelalias',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
]
+39 -35
View File
@@ -11,31 +11,33 @@ from .security import decrypt_api_key, encrypt_api_key, mask_secret
class AiModel(models.Model): class AiModel(models.Model):
class ApiType(models.TextChoices): class ApiType(models.TextChoices):
AUTO = "auto", "Auto" AUTO = "auto", "自动"
CHAT = "chat", "Chat completions" CHAT = "chat", "聊天补全"
GEMINI = "gemini", "Gemini" GEMINI = "gemini", "Gemini"
IMAGES = "images", "Images generations" IMAGES = "images", "图片生成"
IMAGES_EDITS = "images_edits", "Images edits" IMAGES_EDITS = "images_edits", "图片编辑"
name = models.CharField(max_length=128, unique=True) name = models.CharField("名称", max_length=128, unique=True)
url = models.URLField(max_length=500) url = models.URLField("接口 URL", max_length=500)
model = models.CharField(max_length=128) model = models.CharField("模型标识", max_length=128)
api_type = models.CharField( api_type = models.CharField(
"API 类型",
max_length=32, max_length=32,
choices=ApiType.choices, choices=ApiType.choices,
default=ApiType.AUTO, default=ApiType.AUTO,
) )
api_key_encrypted = models.TextField(blank=True, editable=False) api_key_encrypted = models.TextField("加密 API Key", blank=True, editable=False)
capabilities = models.JSONField(default=list, blank=True) capabilities = models.JSONField("能力", default=list, blank=True)
timeout_seconds = models.PositiveIntegerField( timeout_seconds = models.PositiveIntegerField(
"请求超时秒数",
default=0, default=0,
help_text="0 means use provider resolution defaults.", help_text="0 表示使用 provider 默认值。",
) )
connect_timeout_seconds = models.PositiveIntegerField(default=30) connect_timeout_seconds = models.PositiveIntegerField("连接超时秒数", default=30)
extra_body = models.JSONField(default=dict, blank=True) extra_body = models.JSONField("额外请求体", default=dict, blank=True)
is_active = models.BooleanField(default=True) is_active = models.BooleanField("启用", default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta: class Meta:
db_table = "ai_model" db_table = "ai_model"
@@ -88,20 +90,21 @@ class AiModel(models.Model):
class ModelAlias(models.Model): class ModelAlias(models.Model):
class OperationType(models.TextChoices): class OperationType(models.TextChoices):
TITLE = "title", "Generate title" TITLE = "title", "生成标题"
IMAGE = "image", "Generate image" IMAGE = "image", "生成图片"
alias = models.SlugField(max_length=64) alias = models.SlugField("能力别名", max_length=64)
operation_type = models.CharField(max_length=32, choices=OperationType.choices) operation_type = models.CharField("操作类型", max_length=32, choices=OperationType.choices)
ai_model = models.ForeignKey( ai_model = models.ForeignKey(
AiModel, AiModel,
verbose_name="AI 模型",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="aliases", related_name="aliases",
) )
is_default = models.BooleanField(default=False) is_default = models.BooleanField("默认别名", default=False)
is_active = models.BooleanField(default=True) is_active = models.BooleanField("启用", default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta: class Meta:
db_table = "model_alias" db_table = "model_alias"
@@ -142,28 +145,29 @@ class ModelAlias(models.Model):
class AiConfigAuditLog(models.Model): class AiConfigAuditLog(models.Model):
class TargetType(models.TextChoices): class TargetType(models.TextChoices):
AI_MODEL = "ai_model", "AI model" AI_MODEL = "ai_model", "AI 模型"
MODEL_ALIAS = "model_alias", "Model alias" MODEL_ALIAS = "model_alias", "能力别名"
class Action(models.TextChoices): class Action(models.TextChoices):
CREATE = "create", "Create" CREATE = "create", "创建"
UPDATE = "update", "Update" UPDATE = "update", "更新"
DELETE = "delete", "Delete" DELETE = "delete", "删除"
actor = models.ForeignKey( actor = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name="操作人",
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="+", related_name="+",
) )
action = models.CharField(max_length=16, choices=Action.choices) action = models.CharField("动作", max_length=16, choices=Action.choices)
target_type = models.CharField(max_length=32, choices=TargetType.choices) target_type = models.CharField("目标类型", max_length=32, choices=TargetType.choices)
target_id = models.PositiveBigIntegerField(null=True, blank=True) target_id = models.PositiveBigIntegerField("目标 ID", null=True, blank=True)
target_repr = models.CharField(max_length=255) target_repr = models.CharField("目标描述", max_length=255)
changed_fields = models.JSONField(default=list, blank=True) changed_fields = models.JSONField("变更字段", default=list, blank=True)
changes = models.JSONField(default=dict, blank=True) changes = models.JSONField("变更内容", default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
class Meta: class Meta:
db_table = "ai_config_audit_log" db_table = "ai_config_audit_log"
+1 -1
View File
@@ -51,7 +51,7 @@ class PricingRuleAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at") readonly_fields = ("created_at", "updated_at")
list_editable = ("points_cost", "is_active") list_editable = ("points_cost", "is_active")
@admin.display(description="resolution") @admin.display(description="分辨率")
def resolution_display(self, obj): def resolution_display(self, obj):
return obj.resolution or "*" return obj.resolution or "*"
@@ -0,0 +1,272 @@
# Generated by Django 5.2.15 on 2026-07-04 08:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billing', '0005_alter_callrecord_options_alter_exchangerate_options_and_more'),
('users', '0005_alter_apikey_created_at_alter_apikey_key_hash_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='callrecord',
name='alias',
field=models.CharField(blank=True, max_length=64, verbose_name='能力别名'),
),
migrations.AlterField(
model_name='callrecord',
name='api_key',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='call_records', to='users.apikey', verbose_name='API 密钥'),
),
migrations.AlterField(
model_name='callrecord',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='callrecord',
name='error_message',
field=models.TextField(blank=True, verbose_name='错误信息'),
),
migrations.AlterField(
model_name='callrecord',
name='model_used',
field=models.CharField(blank=True, max_length=128, verbose_name='实际模型'),
),
migrations.AlterField(
model_name='callrecord',
name='operation_type',
field=models.CharField(choices=[('title', '生成标题'), ('image', '生成图片')], max_length=32, verbose_name='操作类型'),
),
migrations.AlterField(
model_name='callrecord',
name='points_cost',
field=models.BigIntegerField(default=0, verbose_name='扣费点数'),
),
migrations.AlterField(
model_name='callrecord',
name='prompt',
field=models.TextField(blank=True, verbose_name='提示词'),
),
migrations.AlterField(
model_name='callrecord',
name='resolution',
field=models.CharField(blank=True, max_length=32, verbose_name='分辨率'),
),
migrations.AlterField(
model_name='callrecord',
name='result_ref',
field=models.TextField(blank=True, verbose_name='结果引用'),
),
migrations.AlterField(
model_name='callrecord',
name='result_summary',
field=models.TextField(blank=True, verbose_name='结果摘要'),
),
migrations.AlterField(
model_name='callrecord',
name='status',
field=models.CharField(choices=[('pending', '待处理'), ('success', '成功'), ('failed', '失败')], default='pending', max_length=20, verbose_name='状态'),
),
migrations.AlterField(
model_name='callrecord',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='callrecord',
name='upstream_latency_ms',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='上游耗时毫秒'),
),
migrations.AlterField(
model_name='callrecord',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='call_records', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
),
migrations.AlterField(
model_name='exchangerate',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='exchangerate',
name='currency',
field=models.CharField(default='CNY', max_length=10, verbose_name='币种'),
),
migrations.AlterField(
model_name='exchangerate',
name='effective_from',
field=models.DateTimeField(verbose_name='生效时间'),
),
migrations.AlterField(
model_name='exchangerate',
name='is_active',
field=models.BooleanField(default=True, verbose_name='启用'),
),
migrations.AlterField(
model_name='exchangerate',
name='note',
field=models.CharField(blank=True, max_length=255, verbose_name='备注'),
),
migrations.AlterField(
model_name='exchangerate',
name='points_per_unit',
field=models.DecimalField(decimal_places=4, max_digits=12, verbose_name='每单位金额点数'),
),
migrations.AlterField(
model_name='exchangerate',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='pointsledger',
name='balance_after',
field=models.BigIntegerField(verbose_name='变动后余额'),
),
migrations.AlterField(
model_name='pointsledger',
name='change_type',
field=models.CharField(choices=[('recharge', '充值'), ('consume', '消费'), ('adjust', '调整'), ('refund', '退款')], max_length=20, verbose_name='变动类型'),
),
migrations.AlterField(
model_name='pointsledger',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='pointsledger',
name='points_delta',
field=models.BigIntegerField(verbose_name='点数变动'),
),
migrations.AlterField(
model_name='pointsledger',
name='reason',
field=models.TextField(blank=True, verbose_name='原因'),
),
migrations.AlterField(
model_name='pointsledger',
name='ref_call',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ledger_entries', to='billing.callrecord', verbose_name='关联调用'),
),
migrations.AlterField(
model_name='pointsledger',
name='ref_order_id',
field=models.PositiveBigIntegerField(blank=True, null=True, verbose_name='关联订单 ID'),
),
migrations.AlterField(
model_name='pointsledger',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='points_ledger_entries', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
),
migrations.AlterField(
model_name='pricingrule',
name='alias',
field=models.CharField(max_length=64, verbose_name='能力别名'),
),
migrations.AlterField(
model_name='pricingrule',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='pricingrule',
name='is_active',
field=models.BooleanField(default=True, verbose_name='启用'),
),
migrations.AlterField(
model_name='pricingrule',
name='operation_type',
field=models.CharField(choices=[('title', '生成标题'), ('image', '生成图片')], max_length=32, verbose_name='操作类型'),
),
migrations.AlterField(
model_name='pricingrule',
name='points_cost',
field=models.BigIntegerField(verbose_name='点数单价'),
),
migrations.AlterField(
model_name='pricingrule',
name='resolution',
field=models.CharField(blank=True, default='', help_text='留空表示该别名所有分辨率通用。', max_length=32, verbose_name='分辨率'),
),
migrations.AlterField(
model_name='pricingrule',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='rechargeorder',
name='amount_money',
field=models.DecimalField(decimal_places=2, max_digits=12, verbose_name='金额'),
),
migrations.AlterField(
model_name='rechargeorder',
name='code_url',
field=models.TextField(blank=True, verbose_name='二维码票据'),
),
migrations.AlterField(
model_name='rechargeorder',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='rechargeorder',
name='currency',
field=models.CharField(default='CNY', max_length=10, verbose_name='币种'),
),
migrations.AlterField(
model_name='rechargeorder',
name='exchange_rate',
field=models.DecimalField(decimal_places=4, max_digits=12, verbose_name='锁定汇率'),
),
migrations.AlterField(
model_name='rechargeorder',
name='expires_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='过期时间'),
),
migrations.AlterField(
model_name='rechargeorder',
name='order_no',
field=models.CharField(max_length=64, unique=True, verbose_name='订单号'),
),
migrations.AlterField(
model_name='rechargeorder',
name='paid_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='支付时间'),
),
migrations.AlterField(
model_name='rechargeorder',
name='pay_method',
field=models.CharField(choices=[('weixin', '微信'), ('alipay', '支付宝')], max_length=20, verbose_name='支付方式'),
),
migrations.AlterField(
model_name='rechargeorder',
name='payment_txn_no',
field=models.CharField(blank=True, max_length=128, verbose_name='支付交易号'),
),
migrations.AlterField(
model_name='rechargeorder',
name='points_granted',
field=models.BigIntegerField(verbose_name='到账点数'),
),
migrations.AlterField(
model_name='rechargeorder',
name='status',
field=models.CharField(choices=[('pending', '待支付'), ('paid', '已支付'), ('failed', '失败'), ('expired', '已过期')], default='pending', max_length=20, verbose_name='状态'),
),
migrations.AlterField(
model_name='rechargeorder',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='rechargeorder',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recharge_orders', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
),
]
+67 -59
View File
@@ -12,43 +12,46 @@ def normalize_resolution(value: str | None) -> str:
class CallRecord(models.Model): class CallRecord(models.Model):
class OperationType(models.TextChoices): class OperationType(models.TextChoices):
TITLE = "title", "Generate title" TITLE = "title", "生成标题"
IMAGE = "image", "Generate image" IMAGE = "image", "生成图片"
class Status(models.TextChoices): class Status(models.TextChoices):
PENDING = "pending", "Pending" PENDING = "pending", "待处理"
SUCCESS = "success", "Success" SUCCESS = "success", "成功"
FAILED = "failed", "Failed" FAILED = "failed", "失败"
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="call_records", related_name="call_records",
) )
api_key = models.ForeignKey( api_key = models.ForeignKey(
ApiKey, ApiKey,
verbose_name="API 密钥",
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name="call_records", related_name="call_records",
) )
operation_type = models.CharField(max_length=32, choices=OperationType.choices) operation_type = models.CharField("操作类型", max_length=32, choices=OperationType.choices)
alias = models.CharField(max_length=64, blank=True) alias = models.CharField("能力别名", max_length=64, blank=True)
model_used = models.CharField(max_length=128, blank=True) model_used = models.CharField("实际模型", max_length=128, blank=True)
resolution = models.CharField(max_length=32, blank=True) resolution = models.CharField("分辨率", max_length=32, blank=True)
prompt = models.TextField(blank=True) prompt = models.TextField("提示词", blank=True)
points_cost = models.BigIntegerField(default=0) points_cost = models.BigIntegerField("扣费点数", default=0)
status = models.CharField( status = models.CharField(
"状态",
max_length=20, max_length=20,
choices=Status.choices, choices=Status.choices,
default=Status.PENDING, default=Status.PENDING,
) )
upstream_latency_ms = models.PositiveIntegerField(null=True, blank=True) upstream_latency_ms = models.PositiveIntegerField("上游耗时毫秒", null=True, blank=True)
error_message = models.TextField(blank=True) error_message = models.TextField("错误信息", blank=True)
result_ref = models.TextField(blank=True) result_ref = models.TextField("结果引用", blank=True)
result_summary = models.TextField(blank=True) result_summary = models.TextField("结果摘要", blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta: class Meta:
db_table = "call_record" db_table = "call_record"
@@ -73,18 +76,19 @@ class CallRecord(models.Model):
class PricingRule(models.Model): class PricingRule(models.Model):
operation_type = models.CharField(max_length=32, choices=CallRecord.OperationType.choices) operation_type = models.CharField("操作类型", max_length=32, choices=CallRecord.OperationType.choices)
alias = models.CharField(max_length=64) alias = models.CharField("能力别名", max_length=64)
resolution = models.CharField( resolution = models.CharField(
"分辨率",
max_length=32, max_length=32,
blank=True, blank=True,
default="", default="",
help_text="Leave blank to apply to every resolution for this alias.", help_text="留空表示该别名所有分辨率通用。",
) )
points_cost = models.BigIntegerField() points_cost = models.BigIntegerField("点数单价")
is_active = models.BooleanField(default=True) is_active = models.BooleanField("启用", default=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta: class Meta:
db_table = "pricing_rule" db_table = "pricing_rule"
@@ -123,13 +127,13 @@ class PricingRule(models.Model):
class ExchangeRate(models.Model): class ExchangeRate(models.Model):
currency = models.CharField(max_length=10, default="CNY") currency = models.CharField("币种", max_length=10, default="CNY")
points_per_unit = models.DecimalField(max_digits=12, decimal_places=4) points_per_unit = models.DecimalField("每单位金额点数", max_digits=12, decimal_places=4)
is_active = models.BooleanField(default=True) is_active = models.BooleanField("启用", default=True)
effective_from = models.DateTimeField() effective_from = models.DateTimeField("生效时间")
note = models.CharField(max_length=255, blank=True) note = models.CharField("备注", max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta: class Meta:
db_table = "exchange_rate" db_table = "exchange_rate"
@@ -162,37 +166,39 @@ class ExchangeRate(models.Model):
class RechargeOrder(models.Model): class RechargeOrder(models.Model):
class PayMethod(models.TextChoices): class PayMethod(models.TextChoices):
WEIXIN = "weixin", "Weixin" WEIXIN = "weixin", "微信"
ALIPAY = "alipay", "Alipay" ALIPAY = "alipay", "支付宝"
class Status(models.TextChoices): class Status(models.TextChoices):
PENDING = "pending", "Pending" PENDING = "pending", "待支付"
PAID = "paid", "Paid" PAID = "paid", "已支付"
FAILED = "failed", "Failed" FAILED = "failed", "失败"
EXPIRED = "expired", "Expired" EXPIRED = "expired", "已过期"
order_no = models.CharField(max_length=64, unique=True) order_no = models.CharField("订单号", max_length=64, unique=True)
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="recharge_orders", related_name="recharge_orders",
) )
amount_money = models.DecimalField(max_digits=12, decimal_places=2) amount_money = models.DecimalField("金额", max_digits=12, decimal_places=2)
currency = models.CharField(max_length=10, default="CNY") currency = models.CharField("币种", max_length=10, default="CNY")
pay_method = models.CharField(max_length=20, choices=PayMethod.choices) pay_method = models.CharField("支付方式", max_length=20, choices=PayMethod.choices)
exchange_rate = models.DecimalField(max_digits=12, decimal_places=4) exchange_rate = models.DecimalField("锁定汇率", max_digits=12, decimal_places=4)
points_granted = models.BigIntegerField() points_granted = models.BigIntegerField("到账点数")
status = models.CharField( status = models.CharField(
"状态",
max_length=20, max_length=20,
choices=Status.choices, choices=Status.choices,
default=Status.PENDING, default=Status.PENDING,
) )
code_url = models.TextField(blank=True) code_url = models.TextField("二维码票据", blank=True)
expires_at = models.DateTimeField(null=True, blank=True) expires_at = models.DateTimeField("过期时间", null=True, blank=True)
payment_txn_no = models.CharField(max_length=128, blank=True) payment_txn_no = models.CharField("支付交易号", max_length=128, blank=True)
paid_at = models.DateTimeField(null=True, blank=True) paid_at = models.DateTimeField("支付时间", null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta: class Meta:
db_table = "recharge_order" db_table = "recharge_order"
@@ -238,29 +244,31 @@ class RechargeOrder(models.Model):
class PointsLedger(models.Model): class PointsLedger(models.Model):
class ChangeType(models.TextChoices): class ChangeType(models.TextChoices):
RECHARGE = "recharge", "Recharge" RECHARGE = "recharge", "充值"
CONSUME = "consume", "Consume" CONSUME = "consume", "消费"
ADJUST = "adjust", "Adjust" ADJUST = "adjust", "调整"
REFUND = "refund", "Refund" REFUND = "refund", "退款"
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="points_ledger_entries", related_name="points_ledger_entries",
) )
change_type = models.CharField(max_length=20, choices=ChangeType.choices) change_type = models.CharField("变动类型", max_length=20, choices=ChangeType.choices)
points_delta = models.BigIntegerField() points_delta = models.BigIntegerField("点数变动")
balance_after = models.BigIntegerField() balance_after = models.BigIntegerField("变动后余额")
ref_order_id = models.PositiveBigIntegerField(null=True, blank=True) ref_order_id = models.PositiveBigIntegerField("关联订单 ID", null=True, blank=True)
ref_call = models.ForeignKey( ref_call = models.ForeignKey(
CallRecord, CallRecord,
verbose_name="关联调用",
null=True, null=True,
blank=True, blank=True,
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="ledger_entries", related_name="ledger_entries",
) )
reason = models.TextField(blank=True) reason = models.TextField("原因", blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
class Meta: class Meta:
db_table = "points_ledger" db_table = "points_ledger"
+3 -3
View File
@@ -40,7 +40,7 @@ class WalletAdjustmentForm(forms.Form):
@admin.register(User) @admin.register(User)
class UserAdmin(DjangoUserAdmin): class UserAdmin(DjangoUserAdmin):
fieldsets = DjangoUserAdmin.fieldsets + ( fieldsets = DjangoUserAdmin.fieldsets + (
("Cmhub", {"fields": ("payment_user_id", "status", "created_at")}), ("业务信息", {"fields": ("payment_user_id", "status", "created_at")}),
) )
readonly_fields = ("created_at",) readonly_fields = ("created_at",)
list_display = ( list_display = (
@@ -58,7 +58,7 @@ class UserAdmin(DjangoUserAdmin):
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).select_related("wallet") return super().get_queryset(request).select_related("wallet")
@admin.display(description="points") @admin.display(description="点数余额")
def wallet_points_balance(self, obj): def wallet_points_balance(self, obj):
wallet = getattr(obj, "wallet", None) wallet = getattr(obj, "wallet", None)
return wallet.points_balance if wallet else 0 return wallet.points_balance if wallet else 0
@@ -191,7 +191,7 @@ class ApiKeyAdmin(admin.ModelAdmin):
) )
ordering = ("-created_at", "-id") ordering = ("-created_at", "-id")
@admin.display(description="key hash") @admin.display(description="Key 哈希")
def key_hash_masked(self, obj): def key_hash_masked(self, obj):
if not obj.key_hash: if not obj.key_hash:
return "" return ""
@@ -0,0 +1,95 @@
# Generated by Django 5.2.15 on 2026-07-04 08:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0004_alter_apikey_options_alter_user_options_and_more'),
]
operations = [
migrations.AlterField(
model_name='apikey',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='apikey',
name='key_hash',
field=models.CharField(editable=False, max_length=64, unique=True, verbose_name='API Key 哈希'),
),
migrations.AlterField(
model_name='apikey',
name='key_prefix',
field=models.CharField(editable=False, max_length=32, verbose_name='Key 前缀'),
),
migrations.AlterField(
model_name='apikey',
name='last_used_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='最后使用时间'),
),
migrations.AlterField(
model_name='apikey',
name='name',
field=models.CharField(blank=True, max_length=80, verbose_name='名称'),
),
migrations.AlterField(
model_name='apikey',
name='status',
field=models.CharField(choices=[('active', '有效'), ('revoked', '已吊销')], default='active', max_length=20, verbose_name='状态'),
),
migrations.AlterField(
model_name='apikey',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='apikey',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='api_keys', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
),
migrations.AlterField(
model_name='user',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, unique=True, verbose_name='邮箱'),
),
migrations.AlterField(
model_name='user',
name='payment_user_id',
field=models.CharField(blank=True, help_text='外部支付系统用户标识,可为空。', max_length=128, verbose_name='支付系统用户 ID'),
),
migrations.AlterField(
model_name='user',
name='status',
field=models.CharField(choices=[('active', '正常'), ('disabled', '禁用')], default='active', max_length=20, verbose_name='状态'),
),
migrations.AlterField(
model_name='userwallet',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='创建时间'),
),
migrations.AlterField(
model_name='userwallet',
name='points_balance',
field=models.BigIntegerField(default=0, verbose_name='点数余额'),
),
migrations.AlterField(
model_name='userwallet',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
),
migrations.AlterField(
model_name='userwallet',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='wallet', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
),
]
+21 -16
View File
@@ -12,21 +12,23 @@ from django.db.models import Q
class User(AbstractUser): class User(AbstractUser):
class Status(models.TextChoices): class Status(models.TextChoices):
ACTIVE = "active", "Active" ACTIVE = "active", "正常"
DISABLED = "disabled", "Disabled" DISABLED = "disabled", "禁用"
email = models.EmailField("email address", unique=True) email = models.EmailField("邮箱", unique=True)
payment_user_id = models.CharField( payment_user_id = models.CharField(
"支付系统用户 ID",
max_length=128, max_length=128,
blank=True, blank=True,
help_text="External payment system user identifier, if available.", help_text="外部支付系统用户标识,可为空。",
) )
status = models.CharField( status = models.CharField(
"状态",
max_length=20, max_length=20,
choices=Status.choices, choices=Status.choices,
default=Status.ACTIVE, default=Status.ACTIVE,
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
@property @property
def is_business_active(self) -> bool: def is_business_active(self) -> bool:
@@ -41,12 +43,13 @@ class User(AbstractUser):
class UserWallet(models.Model): class UserWallet(models.Model):
user = models.OneToOneField( user = models.OneToOneField(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="wallet", related_name="wallet",
) )
points_balance = models.BigIntegerField(default=0) points_balance = models.BigIntegerField("点数余额", default=0)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta: class Meta:
db_table = "user_wallet" db_table = "user_wallet"
@@ -68,25 +71,27 @@ class ApiKey(models.Model):
KEY_PREFIX_LENGTH = 16 KEY_PREFIX_LENGTH = 16
class Status(models.TextChoices): class Status(models.TextChoices):
ACTIVE = "active", "Active" ACTIVE = "active", "有效"
REVOKED = "revoked", "Revoked" REVOKED = "revoked", "已吊销"
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="api_keys", related_name="api_keys",
) )
name = models.CharField(max_length=80, blank=True) name = models.CharField("名称", max_length=80, blank=True)
key_hash = models.CharField(max_length=64, unique=True, editable=False) key_hash = models.CharField("API Key 哈希", max_length=64, unique=True, editable=False)
key_prefix = models.CharField(max_length=32, editable=False) key_prefix = models.CharField("Key 前缀", max_length=32, editable=False)
status = models.CharField( status = models.CharField(
"状态",
max_length=20, max_length=20,
choices=Status.choices, choices=Status.choices,
default=Status.ACTIVE, default=Status.ACTIVE,
) )
last_used_at = models.DateTimeField(null=True, blank=True) last_used_at = models.DateTimeField("最后使用时间", null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta: class Meta:
db_table = "api_key" db_table = "api_key"
+2 -2
View File
@@ -38,7 +38,7 @@
## 当前阶段 ## 当前阶段
当前项目处于:**Phase 6 增强任务收尾期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」。当前暂无新的已拆 `TODO` 任务,同时生产侧仍需按 `deployment.md` 配置真实邮件、支付、AI 模型和图片真实耗时验证。 当前项目处于:**Phase 6 增强任务收尾期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」与 T-602「django-admin 中文化第 1-3 层」。T-603「django-admin 字段级中文化」代码和迁移已完成,但完整测试被工作区既有 `ACCOUNT_EMAIL_VERIFICATION="none"` 配置改动阻塞,需恢复 `mandatory` 后重跑测试再标 `DONE`。生产侧仍需按 `deployment.md` 配置真实邮件、支付、AI 模型和图片真实耗时验证。
优先路径: 优先路径:
@@ -48,7 +48,7 @@
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。 4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。
5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成。 5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成。
6. Phase 5:后台与发布 —— T-401 运营后台完善、T-402 完整验收 MVP、T-403 部署 / 运行文档已完成;计划内 MVP 任务已收尾。 6. Phase 5:后台与发布 —— T-401 运营后台完善、T-402 完整验收 MVP、T-403 部署 / 运行文档已完成;计划内 MVP 任务已收尾。
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;只展示能力别名、能力、是否需要原图和价格,不解密 provider key、不暴露具体 SKU / URL / key。 7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移,当前因邮箱验证配置改动导致完整测试未全绿,状态为 `BLOCKED`。
## 领取任务规则 ## 领取任务规则
+2 -2
View File
@@ -80,8 +80,8 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| T-601 | 可用别名发现(portal 别名页 + `GET /api/v1/models`) | T-202, T-301, T-503 | 让调用方能自助发现「能填哪些 `model` 能力别名」。① 新增 `GET /api/v1/models`(**API Key 鉴权,继承 `ExternalApiView`,只复用认证失败限流;不要占用生成接口的 `GenerateRateThrottle` 额度**),仅返回 `ModelAlias.is_active=True` 且 `ai_model.is_active=True` 的可调用别名;② 响应每项只含对外安全字段:`alias`、`operation_type`、`capabilities`、`requires_image`(按 `api_type`/provider 规则推导,如 `images_edits=true`)、`pricing_status`、`prices[{resolution, points_cost}]`,缺 `PricingRule` 时标「暂未定价」而非报错;③ **实现时不得调用 `AiModel.to_resolved_model()` 或任何会解密 provider key 的路径**,列表接口不依赖 `AI_KEY_ENCRYPTION_KEY`,也不读取/输出 `api_key_encrypted`;④ **不暴露具体 SKU/模型名/url/key/provider 原始配置**,测试必须断言响应不含 `ai_model.model`、`ai_model.url`、`api_key`、`api_key_encrypted`、`extra_body` 等内部字段;⑤ portal 加只读「可用模型」页(session 鉴权),同字段、人类可读、单价以点数展示,并在导航中加入入口;⑥ 含测试:API Key 成功返回结构、缺/无效 Key 拒绝、Web session 不能调用外部 API、portal 未登录跳转、页面不泄露 SKU/URL/Key、缺定价别名显示暂未定价。**标注**:未来接 `account_alias_permission`(见 Backlog)后,本清单改为「只列该用户被授权的别名」,接口与页面均按 `user` 过滤 | DONE | | T-601 | 可用别名发现(portal 别名页 + `GET /api/v1/models`) | T-202, T-301, T-503 | 让调用方能自助发现「能填哪些 `model` 能力别名」。① 新增 `GET /api/v1/models`(**API Key 鉴权,继承 `ExternalApiView`,只复用认证失败限流;不要占用生成接口的 `GenerateRateThrottle` 额度**),仅返回 `ModelAlias.is_active=True` 且 `ai_model.is_active=True` 的可调用别名;② 响应每项只含对外安全字段:`alias`、`operation_type`、`capabilities`、`requires_image`(按 `api_type`/provider 规则推导,如 `images_edits=true`)、`pricing_status`、`prices[{resolution, points_cost}]`,缺 `PricingRule` 时标「暂未定价」而非报错;③ **实现时不得调用 `AiModel.to_resolved_model()` 或任何会解密 provider key 的路径**,列表接口不依赖 `AI_KEY_ENCRYPTION_KEY`,也不读取/输出 `api_key_encrypted`;④ **不暴露具体 SKU/模型名/url/key/provider 原始配置**,测试必须断言响应不含 `ai_model.model`、`ai_model.url`、`api_key`、`api_key_encrypted`、`extra_body` 等内部字段;⑤ portal 加只读「可用模型」页(session 鉴权),同字段、人类可读、单价以点数展示,并在导航中加入入口;⑥ 含测试:API Key 成功返回结构、缺/无效 Key 拒绝、Web session 不能调用外部 API、portal 未登录跳转、页面不泄露 SKU/URL/Key、缺定价别名显示暂未定价。**标注**:未来接 `account_alias_permission`(见 Backlog)后,本清单改为「只列该用户被授权的别名」,接口与页面均按 `user` 过滤 | DONE |
| T-602 | django-admin 中文化(第 1-3 层) | - | 让运营后台表名/分组/框架文字显示中文,**仅显示层、不改业务逻辑与 DB 结构**。**第 1 层**:`config/settings.py` 设 `LANGUAGE_CODE='zh-hans'` + `USE_I18N=True`,使 Django 自带 admin 界面与内置 `auth`(用户/组/权限)中文化(无迁移)。**第 2 层**:各 app `AppConfig.verbose_name` 设中文分组名(`users`/`billing`/`ai`/`api`/`portal`),无迁移。**第 3 层**:各模型 `Meta.verbose_name`/`verbose_name_plural` 设中文表名(`User`/`UserWallet`/`ApiKey`/`PointsLedger`/`CallRecord`/`PricingRule`/`ExchangeRate`/`RechargeOrder`/`AiModel`/`ModelAlias`/`AiConfigAuditLog`)。**不含第 4 层字段级 `verbose_name`(字段列名保持英文,见 T-603)**。第 3 层会产出 `AlterModelOptions` 等**无 DB 变更**迁移;验收:`makemigrations`(仅 options 变更)→ `migrate` → `check` 0 issues → `test` 全绿 → `/admin/` 目视中文化,并在 `../progress.md` 留证据 | TODO | | T-602 | django-admin 中文化(第 1-3 层) | - | 让运营后台表名/分组/框架文字显示中文,**仅显示层、不改业务逻辑与 DB 结构**。**第 1 层**:`config/settings.py` 设 `LANGUAGE_CODE='zh-hans'` + `USE_I18N=True`,使 Django 自带 admin 界面与内置 `auth`(用户/组/权限)中文化(无迁移)。**第 2 层**:各 app `AppConfig.verbose_name` 设中文分组名(`users`/`billing`/`ai`/`api`/`portal`),无迁移。**第 3 层**:各模型 `Meta.verbose_name`/`verbose_name_plural` 设中文表名(`User`/`UserWallet`/`ApiKey`/`PointsLedger`/`CallRecord`/`PricingRule`/`ExchangeRate`/`RechargeOrder`/`AiModel`/`ModelAlias`/`AiConfigAuditLog`)。**不含第 4 层字段级 `verbose_name`(字段列名保持英文,见 T-603)**。第 3 层会产出 `AlterModelOptions` 等**无 DB 变更**迁移;验收:`makemigrations`(仅 options 变更)→ `migrate` → `check` 0 issues → `test` 全绿 → `/admin/` 目视中文化,并在 `../progress.md` 留证据 | DONE |
| T-603 | django-admin 中文化(第 4 层·字段级) | T-602 | 给各模型**字段**加中文 `verbose_name`,使 admin 列表/编辑页的字段标签显示中文,在 T-602(1-3 层)之上做。范围覆盖 `users`/`billing`/`ai`(及 portal 相关)模型的业务字段,例如 `points_balance`→点数余额、`key_prefix`→Key 前缀、`points_delta`→点数变动、`balance_after`→变动后余额、`amount_money`→金额、`exchange_rate`→汇率、`points_granted`→到账点数、`api_type`/`capabilities`/`is_active`/`created_at` 等;`admin.py` 里自定义显示方法(`@admin.display(description=...)`)同步中文。**仅显示层**:`verbose_name` 只改展示,**不改字段名、不改代码引用、英文字段名保持不变**;产出的是 `AlterField`(metadata-only,**无 DB schema 变更**)迁移。验收:`makemigrations`(仅 AlterField,无 schema 变更)→ `migrate` → `check` 0 issues → `test` 全绿 → `/admin/` 字段标签目视中文,并在 `../progress.md` 留证据 | TODO | | T-603 | django-admin 中文化(第 4 层·字段级) | T-602 | 给各模型**字段**加中文 `verbose_name`,使 admin 列表/编辑页的字段标签显示中文,在 T-602(1-3 层)之上做。范围覆盖 `users`/`billing`/`ai`(及 portal 相关)模型的业务字段,例如 `points_balance`→点数余额、`key_prefix`→Key 前缀、`points_delta`→点数变动、`balance_after`→变动后余额、`amount_money`→金额、`exchange_rate`→汇率、`points_granted`→到账点数、`api_type`/`capabilities`/`is_active`/`created_at` 等;`admin.py` 里自定义显示方法(`@admin.display(description=...)`)同步中文。**仅显示层**:`verbose_name` 只改展示,**不改字段名、不改代码引用、英文字段名保持不变**;产出的是 `AlterField`(metadata-only,**无 DB schema 变更**)迁移。验收:`makemigrations`(仅 AlterField,无 schema 变更)→ `migrate` → `check` 0 issues → `test` 全绿 → `/admin/` 字段标签目视中文,并在 `../progress.md` 留证据。**当前阻塞**:代码与迁移已完成,完整测试被工作区既有 `config/settings.py` 中 `ACCOUNT_EMAIL_VERIFICATION="none"` 改动阻断,需恢复 `mandatory` 后重跑测试再标 `DONE` | BLOCKED |
## 里程碑 ## 里程碑
+9 -9
View File
@@ -12,18 +12,18 @@
## 当前快照 ## 当前快照
- 日期:2026-07-04 - 日期:2026-07-04
- 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现已完成;当前暂无新的已拆 `TODO` 任务 - 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层已完成;T-603 django-admin 字段级中文化代码与迁移已完成,但完整测试被工作区既有邮箱验证配置改动阻塞,暂记 `BLOCKED`
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md` - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.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` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 消费记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL 与 HSTS 环境变量支持;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body` - 生产代码:已有最小 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;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 消费记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL 与 HSTS 环境变量支持;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`
- 用户端导航:顶部导航 active 状态已修复,`portal/base.html` 基于 `request.resolver_match.url_name` 高亮当前页面入口,并用 `aria-current="page"` 标记;「充值」不再在非充值页固定深色高亮。 - 用户端导航:顶部导航 active 状态已修复,`portal/base.html` 基于 `request.resolver_match.url_name` 高亮当前页面入口,并用 `aria-current="page"` 标记;「充值」不再在非充值页固定深色高亮。
- 测试:T-601 已验证:`.\init.ps1` 通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令);`py -3.12 -m py_compile apps\ai\catalog.py apps\api\views.py apps\api\urls.py apps\api\tests.py apps\portal\views.py apps\portal\urls.py apps\portal\tests.py` 通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`git diff --check` 通过;`py -3.12 manage.py test apps.api.tests.ModelsCatalogApiTests apps.portal.tests.PortalAccountFlowTests.test_models_page_requires_session_login apps.portal.tests.PortalAccountFlowTests.test_models_page_lists_public_aliases_prices_and_unpriced_state --keepdb --noinput --verbosity 2` 首次运行时 3 条 API 测试已通过,portal `setUpClass` 阶段因远程 MySQL 连接 43.128.3.240 超时中断;随后 `Test-NetConnection 43.128.3.240 -Port 3306` 显示 `TcpTestSucceeded=True`,单独重跑两条 portal 测试通过,2 tests OK。T-403 已验证:`./init.ps1` 开工前通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 manage.py findstatic portal/vendor/bootstrap/bootstrap.min.css portal/vendor/qrcode/qrcode.js --verbosity 1` 找到两个本地 static 文件;`py -3.12 manage.py check --deploy` 在当前开发 `.env` 下仅报预期安全配置警告,临时注入生产型安全环境变量(含 HSTS includeSubDomains/preload)后通过,0 issues;`py -3.12 manage.py collectstatic --dry-run --noinput` 通过,预期收集 169 个 static 文件;`py -3.12 manage.py createcachetable --dry-run cmhub_cache` 通过,输出 MySQL cache 表 DDL;`py -3.12 -m compileall config` 通过;尝试 `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2` 时 25 条用例已通过,`GenerateApiTests` 14 条因远程 MySQL 连接重置/事务中断被记 ERROR,随后 `Test-NetConnection 43.128.3.240 -Port 3306` 端口可达,单独重跑 `py -3.12 manage.py test apps.api.tests.GenerateApiTests --noinput --keepdb --verbosity 2` 通过,14 tests OK。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本邮箱唯一性由 `user.email` 唯一约束承担。 - 测试:T-601 已验证:`.\init.ps1` 通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令);`py -3.12 -m py_compile apps\ai\catalog.py apps\api\views.py apps\api\urls.py apps\api\tests.py apps\portal\views.py apps\portal\urls.py apps\portal\tests.py` 通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`git diff --check` 通过;`py -3.12 manage.py test apps.api.tests.ModelsCatalogApiTests apps.portal.tests.PortalAccountFlowTests.test_models_page_requires_session_login apps.portal.tests.PortalAccountFlowTests.test_models_page_lists_public_aliases_prices_and_unpriced_state --keepdb --noinput --verbosity 2` 首次运行时 3 条 API 测试已通过,portal `setUpClass` 阶段因远程 MySQL 连接 43.128.3.240 超时中断;随后 `Test-NetConnection 43.128.3.240 -Port 3306` 显示 `TcpTestSucceeded=True`,单独重跑两条 portal 测试通过,2 tests OK。T-403 已验证:`./init.ps1` 开工前通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 manage.py findstatic portal/vendor/bootstrap/bootstrap.min.css portal/vendor/qrcode/qrcode.js --verbosity 1` 找到两个本地 static 文件;`py -3.12 manage.py check --deploy` 在当前开发 `.env` 下仅报预期安全配置警告,临时注入生产型安全环境变量(含 HSTS includeSubDomains/preload)后通过,0 issues;`py -3.12 manage.py collectstatic --dry-run --noinput` 通过,预期收集 169 个 static 文件;`py -3.12 manage.py createcachetable --dry-run cmhub_cache` 通过,输出 MySQL cache 表 DDL;`py -3.12 -m compileall config` 通过;尝试 `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2` 时 25 条用例已通过,`GenerateApiTests` 14 条因远程 MySQL 连接重置/事务中断被记 ERROR,随后 `Test-NetConnection 43.128.3.240 -Port 3306` 端口可达,单独重跑 `py -3.12 manage.py test apps.api.tests.GenerateApiTests --noinput --keepdb --verbosity 2` 通过,14 tests OK。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本邮箱唯一性由 `user.email` 唯一约束承担。
- 最新验证:`py -3.12 -m py_compile apps\portal\tests.py` 通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_authenticated_nav_highlights_current_page_only --keepdb --noinput --verbosity 2` 通过,覆盖 `/dashboard`、`/recharge`、`/apikeys`、`/models`、`/records/recharge`、`/records/usage` 的导航高亮。 - 最新验证:T-603 字段级中文化已验证 `py -3.12 -m py_compile apps\users\models.py apps\users\admin.py apps\billing\models.py apps\billing\admin.py apps\ai\models.py apps\ai\admin.py` 通过;`py -3.12 manage.py makemigrations` 仅生成 `users.0005` / `billing.0006` / `ai.0004` 三个 `AlterField` 迁移;`py -3.12 manage.py sqlmigrate users 0005`、`billing 0006`、`ai 0004` 均为 `(no-op)`;`py -3.12 manage.py migrate` 应用成功,仅出现既有 allauth MySQL `models.W036` 警告;`py -3.12 manage.py makemigrations --check --dry-run` 通过,No changes detected;`py -3.12 manage.py check` 通过;`git diff --check` 通过,仅 Windows CRLF 提示;`py -3.12 manage.py test --keepdb --noinput --verbosity 2` 跑 125 条,123 条通过,2 条失败,失败原因是工作区既有 `config/settings.py` 把 `ACCOUNT_EMAIL_VERIFICATION` 改为 `none`,导致 `test_signup_creates_unverified_user_wallet_with_zero_points_and_no_ledger` 收不到验证邮件、`test_unverified_email_cannot_establish_login_session` 未验证用户仍建立登录 session。
- 数据: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`
- 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策 - 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。allauth 邮箱验证发信由 `DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 控制,本地默认 console backend,生产需配置真实邮件服务。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。T-403 起生产静态、共享 cache 与 HTTPS 安全配置包含 `STATIC_URL`、`STATIC_ROOT`、`DJANGO_CACHE_BACKEND`、`DJANGO_CACHE_LOCATION`、`DJANGO_CSRF_TRUSTED_ORIGINS`、`DJANGO_SESSION_COOKIE_SECURE`、`DJANGO_CSRF_COOKIE_SECURE`、`DJANGO_SECURE_SSL_REDIRECT`、`DJANGO_SECURE_PROXY_SSL_HEADER`、`DJANGO_SECURE_HSTS_SECONDS`。 - 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。allauth 邮箱验证发信由 `DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 控制,本地默认 console backend,生产需配置真实邮件服务。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。T-403 起生产静态、共享 cache 与 HTTPS 安全配置包含 `STATIC_URL`、`STATIC_ROOT`、`DJANGO_CACHE_BACKEND`、`DJANGO_CACHE_LOCATION`、`DJANGO_CSRF_TRUSTED_ORIGINS`、`DJANGO_SESSION_COOKIE_SECURE`、`DJANGO_CSRF_COOKIE_SECURE`、`DJANGO_SECURE_SSL_REDIRECT`、`DJANGO_SECURE_PROXY_SSL_HEADER`、`DJANGO_SECURE_HSTS_SECONDS`。
- 当前 blocker:T-306 上线前 SSRF 风险已修复;远程 MySQL 连接仍可能很慢或间歇超时,完整测试需预留较长时间并优先用 `--keepdb` 串行跑,必要时改用更稳定的测试库。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,T-403 已在 `deployment.md` 明确上线前必须记录真实图片 smoke 耗时;未跑通前只能按保守超时内测,不能声称已验证。 - 当前 blocker:T-603 完整测试当前被工作区既有 `config/settings.py` 改动阻塞:`ACCOUNT_EMAIL_VERIFICATION` 从 `mandatory` 改为 `none`,导致注册不发验证邮件、未验证用户也能登录,2 条 portal 邮箱验证测试失败;需恢复 `mandatory` 后重跑失败用例和完整测试,再把 T-603 标为 `DONE`。另:远程 MySQL 连接仍可能很慢或间歇超时,完整测试需预留较长时间并优先用 `--keepdb` 串行跑,必要时改用更稳定的测试库。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,T-403 已在 `deployment.md` 明确上线前必须记录真实图片 smoke 耗时;未跑通前只能按保守超时内测,不能声称已验证。
## 当前目录要点 ## 当前目录要点
@@ -43,11 +43,11 @@
任务状态以 [`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-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现。 - 已完成: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 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层)。
- 正在进行:无。 - 正在进行:无。
- 待开始:无已拆任务;后续从 Backlog 或生产配置待办拆新任务。建议继续处理真实邮件、支付、AI 模型导入与真实图片耗时验证,或从 Backlog 拆新任务。 - 待开始:暂无已拆任务;T-603 解除阻塞并完成后,可继续处理真实邮件、支付、AI 模型导入与真实图片耗时验证,或从 Backlog 拆新任务。
- 当前 blocker:远程 MySQL 连接当前不稳定或很慢,完整测试单次全绿可能需要稳定测试库;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。 - 当前 blocker:T-603 字段级中文化的代码/迁移已完成,验证被既有 `ACCOUNT_EMAIL_VERIFICATION="none"` 配置改动阻塞;恢复为 `mandatory` 后需重跑失败用例和完整测试。远程 MySQL 连接当前不稳定或很慢,完整测试单次全绿可能需要稳定测试库;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。
- 下一个可领取任务:暂无已拆 `TODO`;建议继续处理真实邮件、支付、AI 模型导入与真实图片耗时验证,或从 Backlog 拆新任务。 - 下一个可领取任务:先解除 T-603 阻塞并补跑测试,之后暂无后续已拆 `TODO`。
## 当前可运行内容 ## 当前可运行内容
@@ -110,8 +110,8 @@ gunicorn config.wsgi:application --bind 127.0.0.1:8002 --workers 1 --worker-clas
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` 的任务;当前暂无已拆 `TODO`,需要先从 Backlog 或生产配置待办拆新任务。 4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前 T-603 为 `BLOCKED`。
5. 将领取的任务状态改为 `DOING`。 5. 先解除 T-603 的邮箱验证配置阻塞并补跑测试,再将任务状态改为 `DONE`。
## 维护规则 ## 维护规则
+40
View File
@@ -1049,3 +1049,43 @@
- 约束不变:**仅显示层**,`verbose_name` 只改展示、不改字段名与代码引用;产出 `AlterField`(metadata-only,无 DB schema 变更)迁移。 - 约束不变:**仅显示层**,`verbose_name` 只改展示、不改字段名与代码引用;产出 `AlterField`(metadata-only,无 DB schema 变更)迁移。
- 拆两个任务的原因:第 4 层字段多、机械体力大,独立成 T-603 便于单独实现与复核;T-602(1-3 层)可先落地看效果。 - 拆两个任务的原因:第 4 层字段多、机械体力大,独立成 T-603 便于单独实现与复核;T-602(1-3 层)可先落地看效果。
- 下一步:T-602 先行,T-603 随后。 - 下一步:T-602 先行,T-603 随后。
## 2026-07-04 T-602 django-admin 中文化(第 1-3 层)
- 状态:DONE
- 变更:
- 第 1 层:`LANGUAGE_CODE='zh-hans'` + `USE_I18N=True`(本就已在,Django 自带 admin 与内置 auth 已中文)。
- 第 2 层:5 个 app 加 `AppConfig.verbose_name`——用户与钱包 / 计费与充值 / AI 模型配置 / 对外 API / 用户端。
- 第 3 层:11 个模型加 `Meta.verbose_name`/`verbose_name_plural`(用户/用户钱包/API 密钥/调用记录/计费规则/汇率/充值订单/点数流水/AI 模型/能力别名/AI 配置审计日志)。
- 生成 3 条迁移 `users.0004` / `billing.0005` / `ai.0003`,共 11 个 `AlterModelOptions`,**仅 options、无 DB schema 变更**。
- 验证:
- `makemigrations`:仅生成 11 个 `AlterModelOptions`,无 add/alter field、无 constraint/index/table 变更。
- `migrate`:用户在本地环境应用通过。
- 目视:用户本地运行最新代码,`/admin/` 可见中文表名。
- 说明:本任务为纯显示层改动(verbose_name),不涉及逻辑/字段/表结构,功能风险为零;未单独粘贴 `check`/`test` 输出,以迁移应用 + 目视中文化为验收证据。
- 约束保持:只改展示名,不改字段名、代码引用与 DB 结构。
- 提交:`4aceb3a`(11 文件:8 代码 + 3 迁移;未混入内容审核骨架 / `ACCOUNT_EMAIL_VERIFICATION` 测试改动)。
- 下一步:T-603 字段级中文(第 4 层)。
## 2026-07-04 T-603 django-admin 中文化(第 4 层·字段级)
- 状态:BLOCKED(字段中文化代码与迁移已完成;完整测试被工作区既有邮箱验证配置改动阻塞)
- 变更:
- `apps/users/models.py` / `admin.py`:`User`、`UserWallet`、`ApiKey` 业务字段补中文 `verbose_name`,状态枚举显示值和 admin 自定义列标题同步中文。
- `apps/billing/models.py` / `admin.py`:`CallRecord`、`PricingRule`、`ExchangeRate`、`RechargeOrder`、`PointsLedger` 业务字段补中文 `verbose_name`,支付方式/状态/流水类型等 choices 显示值同步中文。
- `apps/ai/models.py` / `admin.py`:`AiModel`、`ModelAlias`、`AiConfigAuditLog` 字段补中文 `verbose_name`,admin 密钥状态、能力列、审计变更字段等显示同步中文。
- 生成 3 条迁移:`users.0005` / `billing.0006` / `ai.0004`,均为字段 metadata 的 `AlterField`;`sqlmigrate` 确认为 `(no-op)`,无 DB schema 变更。
- 验证:
- `py -3.12 -m py_compile apps\users\models.py apps\users\admin.py apps\billing\models.py apps\billing\admin.py apps\ai\models.py apps\ai\admin.py`:通过。
- `py -3.12 manage.py makemigrations`:仅生成 `AlterField` 迁移。
- `rg -n "CreateModel|DeleteModel|AddField|RemoveField|Rename|AlterModel|AddIndex|RemoveIndex|AddConstraint|RemoveConstraint|RunPython|RunSQL" ...0005... ...0006... ...0004...`:无匹配,确认无建删表/字段/索引/约束/数据脚本。
- `py -3.12 manage.py sqlmigrate users 0005` / `billing 0006` / `ai 0004`:均为 `(no-op)`。
- `py -3.12 manage.py migrate`:通过,应用 3 条迁移;仅出现既有 allauth `account.EmailAddress` MySQL `models.W036` 警告。
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
- `py -3.12 manage.py check`:通过,0 issues。
- `git diff --check`:通过,仅 Windows CRLF 提示。
- `py -3.12 manage.py test --keepdb --noinput --verbosity 2`:125 条中 123 条通过,2 条失败。
- 阻塞:
- 失败用例:`test_signup_creates_unverified_user_wallet_with_zero_points_and_no_ledger`、`test_unverified_email_cannot_establish_login_session`。
- 失败原因:工作区既有 `config/settings.py` 未提交改动将 `ACCOUNT_EMAIL_VERIFICATION` 从 `mandatory` 改为 `none`,导致注册不发验证邮件、未验证用户也能建立登录 session;该文件不属于 T-603,本轮未纳入提交。
- 下一步:恢复 `ACCOUNT_EMAIL_VERIFICATION="mandatory"` 后,重跑上述失败用例和完整测试;全部通过后把 T-603 从 `BLOCKED` 改为 `DONE`。