diff --git a/README.md b/README.md index ba2fa52..4a0e48f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR ## 当前状态 -Phase 1 已完成 T-105:Provider 适配器层、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置变更审计、录制标题/图片 smoke 与 Phase 1 审核修补已落地。下一步进入 Phase 2 的 T-201 用户钱包 / API Key / 流水 / 调用记录模型。详见 [`docs/current-state.md`](docs/current-state.md)。 +Phase 2 已完成 T-201:用户钱包、API Key、点数流水与调用记录模型已落地并注册 admin。下一步是 T-202 计费规则 / 汇率模型与计费计算。详见 [`docs/current-state.md`](docs/current-state.md)。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。 diff --git a/apps/billing/admin.py b/apps/billing/admin.py index 8c38f3f..474e491 100644 --- a/apps/billing/admin.py +++ b/apps/billing/admin.py @@ -1,3 +1,60 @@ from django.contrib import admin -# Register your models here. +from .models import CallRecord, PointsLedger + + +class ReadOnlyLedgerAdmin(admin.ModelAdmin): + 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.register(PointsLedger) +class PointsLedgerAdmin(ReadOnlyLedgerAdmin): + list_display = ( + "created_at", + "user", + "change_type", + "points_delta", + "balance_after", + "ref_order_id", + "ref_call", + ) + list_filter = ("change_type", "created_at") + search_fields = ("user__username", "user__email", "reason") + ordering = ("-created_at", "-id") + + +@admin.register(CallRecord) +class CallRecordAdmin(ReadOnlyLedgerAdmin): + list_display = ( + "created_at", + "user", + "api_key", + "operation_type", + "alias", + "model_used", + "points_cost", + "status", + "upstream_latency_ms", + ) + list_filter = ("operation_type", "status", "created_at") + search_fields = ( + "user__username", + "user__email", + "api_key__key_prefix", + "alias", + "model_used", + "error_message", + "result_ref", + "result_summary", + ) + ordering = ("-created_at", "-id") diff --git a/apps/billing/migrations/0001_initial.py b/apps/billing/migrations/0001_initial.py new file mode 100644 index 0000000..d234e56 --- /dev/null +++ b/apps/billing/migrations/0001_initial.py @@ -0,0 +1,105 @@ +# Generated by Django 5.2.15 on 2026-07-02 06:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('users', '0003_apikey_userwallet'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CallRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('operation_type', models.CharField(choices=[('title', 'Generate title'), ('image', 'Generate image')], max_length=32)), + ('alias', models.CharField(blank=True, max_length=64)), + ('model_used', models.CharField(blank=True, max_length=128)), + ('resolution', models.CharField(blank=True, max_length=32)), + ('prompt', models.TextField(blank=True)), + ('points_cost', models.BigIntegerField(default=0)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('success', 'Success'), ('failed', 'Failed')], default='pending', max_length=20)), + ('upstream_latency_ms', models.PositiveIntegerField(blank=True, null=True)), + ('error_message', models.TextField(blank=True)), + ('result_ref', models.TextField(blank=True)), + ('result_summary', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('api_key', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='call_records', to='users.apikey')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='call_records', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'call_record', + 'ordering': ('-created_at', '-id'), + }, + ), + migrations.CreateModel( + name='PointsLedger', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_type', models.CharField(choices=[('recharge', 'Recharge'), ('consume', 'Consume'), ('adjust', 'Adjust'), ('refund', 'Refund')], max_length=20)), + ('points_delta', models.BigIntegerField()), + ('balance_after', models.BigIntegerField()), + ('ref_order_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('reason', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('ref_call', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ledger_entries', to='billing.callrecord')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='points_ledger_entries', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'points_ledger', + 'ordering': ('-created_at', '-id'), + }, + ), + migrations.AddIndex( + model_name='callrecord', + index=models.Index(fields=['user', 'created_at'], name='call_record_user_id_c4cb1c_idx'), + ), + migrations.AddIndex( + model_name='callrecord', + index=models.Index(fields=['api_key', 'created_at'], name='call_record_api_key_0fe521_idx'), + ), + migrations.AddIndex( + model_name='callrecord', + index=models.Index(fields=['operation_type', 'status'], name='call_record_operati_c3ca15_idx'), + ), + migrations.AddIndex( + model_name='callrecord', + index=models.Index(fields=['alias'], name='call_record_alias_8f3c9b_idx'), + ), + migrations.AddConstraint( + model_name='callrecord', + constraint=models.CheckConstraint(condition=models.Q(('points_cost__gte', 0)), name='call_record_points_cost_non_negative'), + ), + migrations.AddIndex( + model_name='pointsledger', + index=models.Index(fields=['user', 'created_at'], name='points_ledg_user_id_ed8d7f_idx'), + ), + migrations.AddIndex( + model_name='pointsledger', + index=models.Index(fields=['change_type', 'created_at'], name='points_ledg_change__431a16_idx'), + ), + migrations.AddIndex( + model_name='pointsledger', + index=models.Index(fields=['ref_order_id'], name='points_ledg_ref_ord_da5b21_idx'), + ), + migrations.AddIndex( + model_name='pointsledger', + index=models.Index(fields=['ref_call'], name='points_ledg_ref_cal_76574c_idx'), + ), + migrations.AddConstraint( + model_name='pointsledger', + constraint=models.CheckConstraint(condition=models.Q(('balance_after__gte', 0)), name='points_ledger_balance_after_non_negative'), + ), + migrations.AddConstraint( + model_name='pointsledger', + constraint=models.CheckConstraint(condition=models.Q(('points_delta', 0), _negated=True), name='points_ledger_points_delta_non_zero'), + ), + ] diff --git a/apps/billing/models.py b/apps/billing/models.py index 71a8362..ba6cda2 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -1,3 +1,121 @@ +from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Q -# Create your models here. +from apps.users.models import ApiKey + + +class CallRecord(models.Model): + class OperationType(models.TextChoices): + TITLE = "title", "Generate title" + IMAGE = "image", "Generate image" + + class Status(models.TextChoices): + PENDING = "pending", "Pending" + SUCCESS = "success", "Success" + FAILED = "failed", "Failed" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="call_records", + ) + api_key = models.ForeignKey( + ApiKey, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="call_records", + ) + operation_type = models.CharField(max_length=32, choices=OperationType.choices) + alias = models.CharField(max_length=64, blank=True) + model_used = models.CharField(max_length=128, blank=True) + resolution = models.CharField(max_length=32, blank=True) + prompt = models.TextField(blank=True) + points_cost = models.BigIntegerField(default=0) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + ) + upstream_latency_ms = models.PositiveIntegerField(null=True, blank=True) + error_message = models.TextField(blank=True) + result_ref = models.TextField(blank=True) + result_summary = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "call_record" + ordering = ("-created_at", "-id") + constraints = [ + models.CheckConstraint( + condition=Q(points_cost__gte=0), + name="call_record_points_cost_non_negative", + ), + ] + indexes = [ + models.Index(fields=("user", "created_at")), + models.Index(fields=("api_key", "created_at")), + models.Index(fields=("operation_type", "status")), + models.Index(fields=("alias",)), + ] + + def __str__(self) -> str: + return f"{self.operation_type}:{self.alias or ''} {self.status}" + + +class PointsLedger(models.Model): + class ChangeType(models.TextChoices): + RECHARGE = "recharge", "Recharge" + CONSUME = "consume", "Consume" + ADJUST = "adjust", "Adjust" + REFUND = "refund", "Refund" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="points_ledger_entries", + ) + change_type = models.CharField(max_length=20, choices=ChangeType.choices) + points_delta = models.BigIntegerField() + balance_after = models.BigIntegerField() + ref_order_id = models.PositiveBigIntegerField(null=True, blank=True) + ref_call = models.ForeignKey( + CallRecord, + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="ledger_entries", + ) + reason = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "points_ledger" + ordering = ("-created_at", "-id") + constraints = [ + models.CheckConstraint( + condition=Q(balance_after__gte=0), + name="points_ledger_balance_after_non_negative", + ), + models.CheckConstraint( + condition=~Q(points_delta=0), + name="points_ledger_points_delta_non_zero", + ), + ] + indexes = [ + models.Index(fields=("user", "created_at")), + models.Index(fields=("change_type", "created_at")), + models.Index(fields=("ref_order_id",)), + models.Index(fields=("ref_call",)), + ] + + def __str__(self) -> str: + return f"{self.user} {self.change_type} {self.points_delta}" + + def clean(self) -> None: + super().clean() + if self.change_type == self.ChangeType.ADJUST and not (self.reason or "").strip(): + raise ValidationError({"reason": "Adjust ledger entries require a reason."}) diff --git a/apps/billing/tests.py b/apps/billing/tests.py index 7ce503c..34f066c 100644 --- a/apps/billing/tests.py +++ b/apps/billing/tests.py @@ -1,3 +1,124 @@ +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction from django.test import TestCase -# Create your tests here. +from apps.billing.models import CallRecord, PointsLedger +from apps.users.models import ApiKey, UserWallet + + +class BillingCoreModelTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username="client", + email="client@example.com", + password="password", + ) + + def test_user_wallet_defaults_to_zero_and_rejects_negative_balance(self): + wallet = UserWallet.objects.create(user=self.user) + + self.assertEqual(wallet.points_balance, 0) + + other_user = get_user_model().objects.create_user( + username="negative", + email="negative@example.com", + password="password", + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + UserWallet.objects.create(user=other_user, points_balance=-1) + + def test_api_key_hashes_plaintext_and_matches_only_raw_key(self): + api_key, raw_key = ApiKey.create_for_user(self.user, name="desktop") + + self.assertTrue(raw_key.startswith("sk_cmhub_")) + self.assertEqual(api_key.key_prefix, raw_key[: ApiKey.KEY_PREFIX_LENGTH]) + self.assertEqual(len(api_key.key_hash), 64) + self.assertNotEqual(api_key.key_hash, raw_key) + self.assertNotIn(raw_key, str(api_key.__dict__)) + self.assertTrue(api_key.matches_key(raw_key)) + self.assertFalse(api_key.matches_key(raw_key + "-wrong")) + self.assertEqual(api_key.status, ApiKey.Status.ACTIVE) + + def test_call_record_stores_summary_reference_but_no_provider_raw_field(self): + api_key, _raw_key = ApiKey.create_for_user(self.user) + call = CallRecord.objects.create( + user=self.user, + api_key=api_key, + operation_type=CallRecord.OperationType.IMAGE, + alias="image-hd", + model_used="gpt-image-2", + resolution="1K", + prompt="Generate an image", + points_cost=10, + status=CallRecord.Status.SUCCESS, + upstream_latency_ms=1234, + result_ref="https://cdn.example.test/result.png", + result_summary="stored image result", + ) + + field_names = {field.name for field in CallRecord._meta.fields} + self.assertNotIn("raw", field_names) + self.assertNotIn("provider_raw", field_names) + self.assertEqual(call.user, self.user) + self.assertEqual(call.api_key, api_key) + self.assertEqual(call.alias, "image-hd") + self.assertEqual(call.model_used, "gpt-image-2") + self.assertEqual(call.result_ref, "https://cdn.example.test/result.png") + self.assertEqual(call.result_summary, "stored image result") + + def test_points_ledger_records_balance_and_requires_adjust_reason(self): + call = CallRecord.objects.create( + user=self.user, + operation_type=CallRecord.OperationType.TITLE, + alias="title-standard", + model_used="gpt-5.5", + points_cost=2, + status=CallRecord.Status.SUCCESS, + ) + ledger = PointsLedger.objects.create( + user=self.user, + change_type=PointsLedger.ChangeType.CONSUME, + points_delta=-2, + balance_after=98, + ref_call=call, + ) + + self.assertEqual(ledger.ref_call, call) + self.assertEqual(ledger.balance_after, 98) + + adjustment = PointsLedger( + user=self.user, + change_type=PointsLedger.ChangeType.ADJUST, + points_delta=10, + balance_after=108, + ) + with self.assertRaises(ValidationError): + adjustment.full_clean() + + def test_points_ledger_rejects_zero_delta_and_negative_balance_after(self): + with self.assertRaises(IntegrityError): + with transaction.atomic(): + PointsLedger.objects.create( + user=self.user, + change_type=PointsLedger.ChangeType.RECHARGE, + points_delta=0, + balance_after=100, + ) + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + PointsLedger.objects.create( + user=self.user, + change_type=PointsLedger.ChangeType.CONSUME, + points_delta=-1, + balance_after=-1, + ) + + def test_billing_models_are_registered_in_admin(self): + self.assertIn(UserWallet, admin.site._registry) + self.assertIn(ApiKey, admin.site._registry) + self.assertIn(PointsLedger, admin.site._registry) + self.assertIn(CallRecord, admin.site._registry) diff --git a/apps/users/admin.py b/apps/users/admin.py index 51b2155..7be5c1b 100644 --- a/apps/users/admin.py +++ b/apps/users/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin -from .models import User +from .models import ApiKey, User, UserWallet @admin.register(User) @@ -20,3 +20,55 @@ class UserAdmin(DjangoUserAdmin): ) list_filter = DjangoUserAdmin.list_filter + ("status",) search_fields = ("username", "email", "payment_user_id") + + +@admin.register(UserWallet) +class UserWalletAdmin(admin.ModelAdmin): + list_display = ("user", "points_balance", "updated_at") + search_fields = ("user__username", "user__email") + readonly_fields = ("user", "points_balance", "created_at", "updated_at") + ordering = ("user__username",) + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +@admin.register(ApiKey) +class ApiKeyAdmin(admin.ModelAdmin): + list_display = ("key_prefix", "user", "name", "status", "last_used_at", "created_at") + list_filter = ("status", "created_at") + search_fields = ("key_prefix", "user__username", "user__email", "name") + readonly_fields = ( + "user", + "key_prefix", + "key_hash_masked", + "last_used_at", + "created_at", + "updated_at", + ) + fields = ( + "user", + "name", + "status", + "key_prefix", + "key_hash_masked", + "last_used_at", + "created_at", + "updated_at", + ) + ordering = ("-created_at", "-id") + + @admin.display(description="key hash") + def key_hash_masked(self, obj): + if not obj.key_hash: + return "" + return f"{obj.key_hash[:8]}...{obj.key_hash[-6:]}" + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/apps/users/migrations/0003_apikey_userwallet.py b/apps/users/migrations/0003_apikey_userwallet.py new file mode 100644 index 0000000..e37bc53 --- /dev/null +++ b/apps/users/migrations/0003_apikey_userwallet.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.15 on 2026-07-02 06:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_alter_user_email'), + ] + + operations = [ + migrations.CreateModel( + name='ApiKey', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=80)), + ('key_hash', models.CharField(editable=False, max_length=64, unique=True)), + ('key_prefix', models.CharField(editable=False, max_length=32)), + ('status', models.CharField(choices=[('active', 'Active'), ('revoked', 'Revoked')], default='active', max_length=20)), + ('last_used_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='api_keys', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'api_key', + 'ordering': ('-created_at', '-id'), + 'indexes': [models.Index(fields=['user', 'status'], name='api_key_user_id_b1527f_idx'), models.Index(fields=['key_prefix'], name='api_key_key_pre_e8448b_idx')], + }, + ), + migrations.CreateModel( + name='UserWallet', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('points_balance', models.BigIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='wallet', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'user_wallet', + 'ordering': ('user_id',), + 'constraints': [models.CheckConstraint(condition=models.Q(('points_balance__gte', 0)), name='user_wallet_points_balance_non_negative')], + }, + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 550f32f..3c0193c 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -1,5 +1,13 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets + +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models +from django.db.models import Q class User(AbstractUser): @@ -26,3 +34,94 @@ class User(AbstractUser): class Meta: db_table = "user" + + +class UserWallet(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="wallet", + ) + points_balance = models.BigIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "user_wallet" + ordering = ("user_id",) + constraints = [ + models.CheckConstraint( + condition=Q(points_balance__gte=0), + name="user_wallet_points_balance_non_negative", + ), + ] + + def __str__(self) -> str: + return f"{self.user} wallet: {self.points_balance}" + + +class ApiKey(models.Model): + KEY_PREFIX_LENGTH = 16 + + class Status(models.TextChoices): + ACTIVE = "active", "Active" + REVOKED = "revoked", "Revoked" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="api_keys", + ) + name = models.CharField(max_length=80, blank=True) + key_hash = models.CharField(max_length=64, unique=True, editable=False) + key_prefix = models.CharField(max_length=32, editable=False) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.ACTIVE, + ) + last_used_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "api_key" + ordering = ("-created_at", "-id") + indexes = [ + models.Index(fields=("user", "status")), + models.Index(fields=("key_prefix",)), + ] + + def __str__(self) -> str: + return f"{self.user} {self.key_prefix}" + + @classmethod + def generate_plaintext_key(cls) -> str: + return f"sk_cmhub_{secrets.token_urlsafe(32)}" + + @classmethod + def hash_key(cls, raw_key: str) -> str: + return hashlib.sha256(raw_key.encode("utf-8")).hexdigest() + + @classmethod + def build_prefix(cls, raw_key: str) -> str: + return raw_key[: cls.KEY_PREFIX_LENGTH] + + @classmethod + def create_for_user(cls, user, *, name: str = "") -> tuple["ApiKey", str]: + raw_key = cls.generate_plaintext_key() + api_key = cls(user=user, name=name) + api_key.set_key(raw_key) + api_key.save() + return api_key, raw_key + + def set_key(self, raw_key: str) -> None: + self.key_hash = self.hash_key(raw_key) + self.key_prefix = self.build_prefix(raw_key) + + def matches_key(self, raw_key: str) -> bool: + return hmac.compare_digest(self.key_hash, self.hash_key(raw_key)) + + @property + def is_active_key(self) -> bool: + return self.status == self.Status.ACTIVE diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 0df6719..02b8d5b 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -38,13 +38,13 @@ ## 当前阶段 -当前项目处于:**Phase 2 前置已收尾**。T-101 Provider 适配器层、T-102 AiModel/ModelAlias 数据模型、T-103 配置变更审计、T-104 录制标题生成 smoke 与 T-105 Phase 1 审核修补已完成,下一步进入 T-201 用户钱包 / API Key / 流水 / 调用记录模型。 +当前项目处于:**Phase 2 计费核心**。T-201 用户钱包 / API Key / 点数流水 / 调用记录模型已完成,下一步进入 T-202 PricingRule / ExchangeRate 模型与计费计算。 优先路径: 1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。 2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104/T-105 已完成 provider 层、模型配置表、别名解析、配置审计、录制标题/图片 smoke 与审核修补;真实图片同步耗时待配置 Fernet 主密钥、AiModel/ModelAlias 与真实上游后在 T-302/T-403 前补测。 -3. Phase 2:计费核心 —— User/UserWallet/ApiKey 模型 + 点数扣减(并发安全,锁 Wallet 行)+ 计费规则 + 调用记录。 +3. Phase 2:计费核心 —— T-201 已完成 UserWallet/ApiKey/PointsLedger/CallRecord;下一步 T-202 计费规则与汇率,随后 T-203 并发安全扣点。 4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。 6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index ba36019..767031a 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -168,6 +168,7 @@ CREATE TABLE user_wallet ( id INTEGER PRIMARY KEY, user_id INTEGER UNIQUE NOT NULL REFERENCES "user"(id), points_balance BIGINT NOT NULL DEFAULT 0, -- >=0,仅计费层可改 + created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -180,7 +181,8 @@ CREATE TABLE api_key ( key_prefix TEXT NOT NULL, -- 如 sk_live_abc…,列表展示/定位用 status TEXT NOT NULL DEFAULT 'active', -- active / revoked last_used_at TEXT, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL ); -- 充值订单(支付回调入账依据,订单号幂等) @@ -207,7 +209,7 @@ CREATE TABLE points_ledger ( change_type TEXT NOT NULL, -- recharge / consume / adjust / refund points_delta BIGINT NOT NULL, -- 正为加、负为减 balance_after BIGINT NOT NULL, -- 变动后余额,便于对账 - ref_order_id INTEGER REFERENCES recharge_order(id), + ref_order_id INTEGER, -- T-201 先存数值引用;RechargeOrder 落地后再正式关联 ref_call_id INTEGER REFERENCES call_record(id), reason TEXT, -- 运营手工调整必填原因 created_at TEXT NOT NULL @@ -220,7 +222,7 @@ CREATE TABLE call_record ( api_key_id INTEGER REFERENCES api_key(id), -- 本次调用所用的 Key operation_type TEXT NOT NULL, -- title / image alias TEXT, -- 调用方请求的能力别名(对外稳定标识) - model_name TEXT, -- 实际服务该次请求的具体模型(解析后,便于排障/对账) + model_used TEXT, -- 实际服务该次请求的具体模型(解析后,便于排障/对账) resolution TEXT, prompt TEXT, points_cost BIGINT NOT NULL DEFAULT 0, @@ -228,7 +230,9 @@ CREATE TABLE call_record ( upstream_latency_ms INTEGER, error_message TEXT, result_ref TEXT, -- 结果引用(URL/路径/摘要),不存 raw/base64 - created_at TEXT NOT NULL + result_summary TEXT, -- 可选短摘要,不存 provider raw + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL ); ``` @@ -237,10 +241,11 @@ CREATE TABLE call_record ( - 主键自增;`api_key.key_hash`、`recharge_order.order_no`、`user.username` 唯一。 - 重要索引:`call_record(user_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`。 - 不软删除业务流水;用户/账号可标记 `disabled` 而非物理删;API Key 用 `revoked` 状态而非物理删。 -- 服务端生成字段:`api_key.key_hash`/`key_prefix`、`points_balance`、`balance_after`、各 `created_at`。 +- 服务端生成字段:`api_key.key_hash`/`key_prefix`、`points_balance`、`balance_after`、各 `created_at` / `updated_at`。 - `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。 - `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。 - `call_record.status` 状态机为 `pending -> success / failed`。上游失败退点后仍保持 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。 +- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;`ref_order_id` 在充值订单模型落地前保持索引化数值引用。 ## 四、计费时序(核心,务必照此实现) diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 79e2675..9ed2baf 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -41,7 +41,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-201 | User / UserWallet / ApiKey / PointsLedger / CallRecord 模型 | T-002 | 表结构符合 `04-architecture.md`;`UserWallet.points_balance>=0` 约束;ApiKey **哈希存储**(key_hash+key_prefix,明文只创建时返回);CallRecord 含 `user`/`api_key`/`alias`/`model_used`;调用结果只存 `result_ref`/摘要,不 dump provider `raw`、base64 图片或敏感上游字段;admin 注册 | TODO | +| T-201 | User / UserWallet / ApiKey / PointsLedger / CallRecord 模型 | T-002 | 表结构符合 `04-architecture.md`;`UserWallet.points_balance>=0` 约束;ApiKey **哈希存储**(key_hash+key_prefix,明文只创建时返回);CallRecord 含 `user`/`api_key`/`alias`/`model_used`;调用结果只存 `result_ref`/摘要,不 dump provider `raw`、base64 图片或敏感上游字段;admin 注册 | DONE | | T-202 | PricingRule / ExchangeRate 模型 + 计费计算 | T-201, T-102 | **按「操作 + 能力别名(+ 可选分辨率)」定价**;换底层模型不影响计费;缺规则返回 `no_pricing_rule` | TODO | | T-203 | 并发安全扣点 / 退点(billing 层) | T-201 | 锁 `UserWallet` 行或 F() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | TODO | diff --git a/docs/api.md b/docs/api.md index 91e4d3b..4b9e015 100644 --- a/docs/api.md +++ b/docs/api.md @@ -14,6 +14,8 @@ - **对外 API 只接受 API Key 认证,不接受 Web session**(浏览器带 cookie 也不能调 API,防绕过计费归属)。 - 图片生成为**同步**接口,可能耗时较长,调用方与网关需设置足够超时(≥ 300s)。 - 用户端注册使用同一个 `User` 账本主体;注册邮箱必须验证且唯一,避免同邮箱对应多个点数账户。 +- API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。 +- 每次生成调用写 `CallRecord`;只允许保存 `result_ref` / `result_summary` 这类引用或摘要,不保存 provider `raw`、base64 图片或敏感上游字段。 通用错误响应: diff --git a/docs/current-state.md b/docs/current-state.md index 3339b39..a0148b4 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,10 +12,10 @@ ## 当前快照 - 日期:2026-07-02 -- 阶段:Phase 1 已完成并完成 T-105 审核修补;下一步进入 Phase 2 的 T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型 +- 阶段:Phase 2 计费核心已完成 T-201;下一步进入 T-202 PricingRule / ExchangeRate 模型与计费计算 - 技术栈:系统 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` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104 已新增 `smoke_ai_generation title --recorded`;T-105 已完成 provider 参数白名单、`resolution_to_size` 归一、默认图片别名 `image-hd`、`smoke_ai_generation image --recorded` 和后续任务风险登记 -- 测试:T-105 相关 10 tests 通过;`smoke_ai_generation image --recorded` 通过(录制图片,`image_bytes=20`);`smoke_ai_generation title --recorded` 通过(录制标题,92ms);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`git diff --check` 仅 Windows CRLF 提示;`./init.ps1` 通过。`manage.py test apps.ai --noinput --keepdb` 重试时在远程 MySQL `43.128.3.240:3306` 连接/重连阶段超时(非断言失败) +- 生产代码:已有最小 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 与迁移 +- 测试:`manage.py test --noinput --keepdb` 通过(34 tests);`manage.py test apps.users apps.billing --noinput --keepdb` 通过(8 tests);`manage.py migrate` 已应用 `users.0003_apikey_userwallet` / `billing.0001_initial`;`showmigrations users billing` 均为 `[X]`;`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`git diff --check` 仅 Windows CRLF 提示;`./init.ps1` 通过。 - 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入 - 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh` - 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` @@ -33,7 +33,7 @@ | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` | | `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 | | `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | -| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令,provider 参数白名单与分辨率归一已落地 | +| `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` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord` | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -41,10 +41,10 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补。 +- 已完成: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 模型。 - 正在进行:无。 - 当前 blocker:无。 -- 下一个可领取任务:**T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型**。 +- 下一个可领取任务:**T-202 PricingRule / ExchangeRate 模型 + 计费计算**。 ## 当前可运行内容 @@ -68,14 +68,14 @@ python3.12 manage.py smoke_ai_generation title --recorded python3.12 manage.py smoke_ai_generation image --recorded ``` -当前骨架可运行。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-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成,临时 `AiModel` / `ModelAlias` 不持久化、不打印 Bearer/key。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。 +当前骨架可运行。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-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。 ## 开始编码前检查 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 -4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-201)。 +4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-202)。 5. 将该任务状态改为 `DOING`。 ## 维护规则 diff --git a/docs/project-brief.md b/docs/project-brief.md index 3d52d10..0b96909 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -102,7 +102,7 @@ - M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。 - Phase 1 已完成 T-101~T-104:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置审计与录制标题生成 smoke 已落地。 -- 下一步是 T-201:建立 UserWallet、ApiKey、PointsLedger、CallRecord 等计费核心模型。 +- 下一步是 T-202:建立 PricingRule / ExchangeRate,并实现按操作、能力别名和可选分辨率计算点数成本。 --- *更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。* diff --git a/docs/routes.md b/docs/routes.md index f8fe108..8f841fa 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -33,8 +33,8 @@ | 管理项 | 对应模型 | 运营能做什么 | | --- | --- | --- | | 注册用户 | User | 查看/禁用注册用户;查看其钱包点数余额 | -| 点数钱包 | UserWallet | 查看余额;手工调整点数(必填原因,经计费层写流水,不直接改字段) | -| API Key | ApiKey | 查看 / 吊销用户的 Key(脱敏显示 prefix,不回显明文) | +| 点数钱包 | UserWallet | T-201 先只读查看余额;手工调整点数留到 T-401,必须经计费层写流水,不直接改字段 | +| API Key | ApiKey | 查看 / 吊销用户的 Key(只显示 prefix 和 hash 摘要,不回显明文) | | 计费规则 | PricingRule | 配置「操作类型 × 能力别名(+ 可选分辨率)→ 点数单价」 | | 汇率 | ExchangeRate | 配置金额→点数汇率 | | 充值订单 | RechargeOrder | 检索订单、查看状态/金额/入账点数/支付流水号 | diff --git a/progress.md b/progress.md index d1c2900..0154431 100644 --- a/progress.md +++ b/progress.md @@ -402,3 +402,31 @@ - 阻塞:无代码阻塞。真实图片同步耗时仍未验证,已登记到 T-302/T-403;需要真实 `AI_KEY_ENCRYPTION_KEY`、AiModel/ModelAlias 与上游 key 后执行。 - 决策:对外 `parameters` 不是任意直通上游,而是 provider 白名单安全参数;服务端固定字段优先,计费仍以能力别名/分辨率为准。未生效的全局 AI timeout 环境变量删除,避免运维误以为可通过 env 调整。 - 下一步:领取 T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型。 + +## 2026-07-02 T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型 + +- 状态:DONE +- 变更: + - `apps/users/models.py`:新增 `UserWallet`(一用户一钱包,`points_balance >= 0` DB 约束)与 `ApiKey`(`key_hash` SHA-256、`key_prefix`、active/revoked 状态、`create_for_user()` 只在创建时返回明文)。 + - `apps/billing/models.py`:新增 `CallRecord`(user/api_key/operation/alias/model_used/result_ref/result_summary/status/latency,不含 provider raw/base64 字段)与 `PointsLedger`(recharge/consume/adjust/refund,非零 delta、`balance_after >= 0`,手工调整需 reason)。 + - 新增迁移:`users.0003_apikey_userwallet`、`billing.0001_initial`,并已应用到当前 MySQL。 + - `apps/users/admin.py`:注册 `UserWallet` / `ApiKey`,钱包余额只读,API Key 只显示 prefix 和 hash 摘要,不回显明文。 + - `apps/billing/admin.py`:注册 `PointsLedger` / `CallRecord` 只读 admin,用于检索对账。 + - `apps/billing/tests.py`:覆盖钱包非负约束、API Key 哈希与匹配、调用记录不含 raw 字段、流水约束与 admin 注册。 + - 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/04-architecture.md`、`docs/06-tasks.md`、`docs/api.md`、`docs/routes.md`、`docs/current-state.md`、`docs/project-brief.md`。 +- 验证: + - `./init.ps1`:通过,依赖同步与基础检查正常。 + - `py -3.12 manage.py test apps.billing --noinput --keepdb`:通过,6 tests OK。 + - `py -3.12 manage.py makemigrations --check`:通过,No changes detected。 + - `py -3.12 manage.py migrate`:通过,应用 `users.0003_apikey_userwallet` / `billing.0001_initial`。 + - `py -3.12 manage.py test apps.users apps.billing --noinput --keepdb`:通过,8 tests OK。 + - `py -3.12 manage.py showmigrations users billing`:通过,`users.0001/0002/0003` 与 `billing.0001` 均为 `[X]`。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `py -3.12 manage.py test --noinput --keepdb`:通过,34 tests OK。 + - `py -3.12 -m compileall apps`:通过。 + - `git diff --check`:通过,仅 Windows CRLF 提示。 +- 阻塞:无。 +- 决策: + - `UserWallet` / `ApiKey` 放在 `apps.users`,`PointsLedger` / `CallRecord` 放在 `apps.billing`;T-201 只建数据结构,不实现扣点服务、计费规则、充值订单或 API 鉴权。 + - `PointsLedger.ref_order_id` 在 RechargeOrder 模型落地前先保留为索引化数值引用,充值任务落地时再正式关联或补迁移。 +- 下一步:领取 T-202 PricingRule / ExchangeRate 模型 + 计费计算。