feat: add billing core models

This commit is contained in:
QiuSW
2026-07-02 15:06:00 +08:00
parent 2f93374193
commit cbc56ee15d
16 changed files with 660 additions and 24 deletions
+1 -1
View File
@@ -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) 第四节计费时序。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+58 -1
View File
@@ -1,3 +1,60 @@
from django.contrib import admin 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")
+105
View File
@@ -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'),
),
]
+119 -1
View File
@@ -1,3 +1,121 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models 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 '<default>'} {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."})
+122 -1
View File
@@ -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 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)
+53 -1
View File
@@ -1,7 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from .models import User from .models import ApiKey, User, UserWallet
@admin.register(User) @admin.register(User)
@@ -20,3 +20,55 @@ class UserAdmin(DjangoUserAdmin):
) )
list_filter = DjangoUserAdmin.list_filter + ("status",) list_filter = DjangoUserAdmin.list_filter + ("status",)
search_fields = ("username", "email", "payment_user_id") 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
@@ -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')],
},
),
]
+99
View File
@@ -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.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.db.models import Q
class User(AbstractUser): class User(AbstractUser):
@@ -26,3 +34,94 @@ class User(AbstractUser):
class Meta: class Meta:
db_table = "user" 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
+2 -2
View File
@@ -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 审核修补项已完成。 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 前补测。 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 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。 4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。 6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
+10 -5
View File
@@ -168,6 +168,7 @@ CREATE TABLE user_wallet (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL REFERENCES "user"(id), user_id INTEGER UNIQUE NOT NULL REFERENCES "user"(id),
points_balance BIGINT NOT NULL DEFAULT 0, -- >=0,仅计费层可改 points_balance BIGINT NOT NULL DEFAULT 0, -- >=0,仅计费层可改
created_at TEXT NOT NULL,
updated_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…,列表展示/定位用 key_prefix TEXT NOT NULL, -- 如 sk_live_abc…,列表展示/定位用
status TEXT NOT NULL DEFAULT 'active', -- active / revoked status TEXT NOT NULL DEFAULT 'active', -- active / revoked
last_used_at TEXT, 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 change_type TEXT NOT NULL, -- recharge / consume / adjust / refund
points_delta BIGINT NOT NULL, -- 正为加、负为减 points_delta BIGINT NOT NULL, -- 正为加、负为减
balance_after 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), ref_call_id INTEGER REFERENCES call_record(id),
reason TEXT, -- 运营手工调整必填原因 reason TEXT, -- 运营手工调整必填原因
created_at TEXT NOT NULL created_at TEXT NOT NULL
@@ -220,7 +222,7 @@ CREATE TABLE call_record (
api_key_id INTEGER REFERENCES api_key(id), -- 本次调用所用的 Key api_key_id INTEGER REFERENCES api_key(id), -- 本次调用所用的 Key
operation_type TEXT NOT NULL, -- title / image operation_type TEXT NOT NULL, -- title / image
alias TEXT, -- 调用方请求的能力别名(对外稳定标识) alias TEXT, -- 调用方请求的能力别名(对外稳定标识)
model_name TEXT, -- 实际服务该次请求的具体模型(解析后,便于排障/对账) model_used TEXT, -- 实际服务该次请求的具体模型(解析后,便于排障/对账)
resolution TEXT, resolution TEXT,
prompt TEXT, prompt TEXT,
points_cost BIGINT NOT NULL DEFAULT 0, points_cost BIGINT NOT NULL DEFAULT 0,
@@ -228,7 +230,9 @@ CREATE TABLE call_record (
upstream_latency_ms INTEGER, upstream_latency_ms INTEGER,
error_message TEXT, error_message TEXT,
result_ref TEXT, -- 结果引用(URL/路径/摘要),不存 raw/base64 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` 唯一。 - 主键自增;`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)`。 - 重要索引:`call_record(user_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`。
- 不软删除业务流水;用户/账号可标记 `disabled` 而非物理删;API Key 用 `revoked` 状态而非物理删。 - 不软删除业务流水;用户/账号可标记 `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` 为对账预留,字段先建。 - `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。
- `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。 - `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` 状态,避免调用结果与账务动作混在一个字段里。 - `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` 在充值订单模型落地前保持索引化数值引用。
## 四、计费时序(核心,务必照此实现) ## 四、计费时序(核心,务必照此实现)
+1 -1
View File
@@ -41,7 +41,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | 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-202 | PricingRule / ExchangeRate 模型 + 计费计算 | T-201, T-102 | **按「操作 + 能力别名(+ 可选分辨率)」定价**;换底层模型不影响计费;缺规则返回 `no_pricing_rule` | TODO |
| T-203 | 并发安全扣点 / 退点(billing 层) | T-201 | 锁 `UserWallet` 行或 F() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | TODO | | T-203 | 并发安全扣点 / 退点(billing 层) | T-201 | 锁 `UserWallet` 行或 F() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | TODO |
+2
View File
@@ -14,6 +14,8 @@
- **对外 API 只接受 API Key 认证,不接受 Web session**(浏览器带 cookie 也不能调 API,防绕过计费归属)。 - **对外 API 只接受 API Key 认证,不接受 Web session**(浏览器带 cookie 也不能调 API,防绕过计费归属)。
- 图片生成为**同步**接口,可能耗时较长,调用方与网关需设置足够超时(≥ 300s)。 - 图片生成为**同步**接口,可能耗时较长,调用方与网关需设置足够超时(≥ 300s)。
- 用户端注册使用同一个 `User` 账本主体;注册邮箱必须验证且唯一,避免同邮箱对应多个点数账户。 - 用户端注册使用同一个 `User` 账本主体;注册邮箱必须验证且唯一,避免同邮箱对应多个点数账户。
- API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。
- 每次生成调用写 `CallRecord`;只允许保存 `result_ref` / `result_summary` 这类引用或摘要,不保存 provider `raw`、base64 图片或敏感上游字段。
通用错误响应: 通用错误响应:
+8 -8
View File
@@ -12,10 +12,10 @@
## 当前快照 ## 当前快照
- 日期:2026-07-02 - 日期: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` - 技术栈:系统 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` 和后续任务风险登记 - 生产代码:已有最小 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-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` 连接/重连阶段超时(非断言失败) - 测试:`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` 命令加密导入 - 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh` - 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
@@ -33,7 +33,7 @@
| `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` | | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 | | `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 |
| `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | | `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi |
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;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 创建 | | `manage.py` | 已有 | T-001 创建 |
| `tests/` | 待建 | 随各任务补充 | | `tests/` | 待建 | 随各任务补充 |
@@ -41,10 +41,10 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-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:无。 - 当前 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 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`。 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。 2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-201)。 4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-202)。
5. 将该任务状态改为 `DOING`。 5. 将该任务状态改为 `DOING`。
## 维护规则 ## 维护规则
+1 -1
View File
@@ -102,7 +102,7 @@
- M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。 - M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。
- Phase 1 已完成 T-101~T-104:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置审计与录制标题生成 smoke 已落地。 - 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`。* *更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。*
+2 -2
View File
@@ -33,8 +33,8 @@
| 管理项 | 对应模型 | 运营能做什么 | | 管理项 | 对应模型 | 运营能做什么 |
| --- | --- | --- | | --- | --- | --- |
| 注册用户 | User | 查看/禁用注册用户;查看其钱包点数余额 | | 注册用户 | User | 查看/禁用注册用户;查看其钱包点数余额 |
| 点数钱包 | UserWallet | 查看余额;手工调整点数(必填原因,经计费层写流水,不直接改字段) | | 点数钱包 | UserWallet | T-201 先只读查看余额;手工调整点数留到 T-401,必须经计费层写流水,不直接改字段 |
| API Key | ApiKey | 查看 / 吊销用户的 Key(脱敏显示 prefix,不回显明文) | | API Key | ApiKey | 查看 / 吊销用户的 Key(只显示 prefix 和 hash 摘要,不回显明文) |
| 计费规则 | PricingRule | 配置「操作类型 × 能力别名(+ 可选分辨率)→ 点数单价」 | | 计费规则 | PricingRule | 配置「操作类型 × 能力别名(+ 可选分辨率)→ 点数单价」 |
| 汇率 | ExchangeRate | 配置金额→点数汇率 | | 汇率 | ExchangeRate | 配置金额→点数汇率 |
| 充值订单 | RechargeOrder | 检索订单、查看状态/金额/入账点数/支付流水号 | | 充值订单 | RechargeOrder | 检索订单、查看状态/金额/入账点数/支付流水号 |
+28
View File
@@ -402,3 +402,31 @@
- 阻塞:无代码阻塞。真实图片同步耗时仍未验证,已登记到 T-302/T-403;需要真实 `AI_KEY_ENCRYPTION_KEY`、AiModel/ModelAlias 与上游 key 后执行。 - 阻塞:无代码阻塞。真实图片同步耗时仍未验证,已登记到 T-302/T-403;需要真实 `AI_KEY_ENCRYPTION_KEY`、AiModel/ModelAlias 与上游 key 后执行。
- 决策:对外 `parameters` 不是任意直通上游,而是 provider 白名单安全参数;服务端固定字段优先,计费仍以能力别名/分辨率为准。未生效的全局 AI timeout 环境变量删除,避免运维误以为可通过 env 调整。 - 决策:对外 `parameters` 不是任意直通上游,而是 provider 白名单安全参数;服务端固定字段优先,计费仍以能力别名/分辨率为准。未生效的全局 AI timeout 环境变量删除,避免运维误以为可通过 env 调整。
- 下一步:领取 T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型。 - 下一步:领取 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 模型 + 计费计算。