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
+53 -1
View File
@@ -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
@@ -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.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