- 5 个 app 加 AppConfig.verbose_name(中文分组名) - 11 个模型加 Meta.verbose_name/verbose_name_plural(中文表名) - 3 条 AlterModelOptions 迁移(仅 options,无 DB schema 变更) - LANGUAGE_CODE=zh-hans / USE_I18N=True 已在,Django 自带 admin 与 auth 已中文 - 仅显示层,不改字段名/逻辑/表结构 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
302 lines
11 KiB
Python
302 lines
11 KiB
Python
from django.conf import settings
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.db.models import Q
|
|
|
|
from apps.users.models import ApiKey
|
|
|
|
|
|
def normalize_resolution(value: str | None) -> str:
|
|
return "" if value is None else str(value).strip().upper()
|
|
|
|
|
|
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"
|
|
verbose_name = "调用记录"
|
|
verbose_name_plural = "调用记录"
|
|
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 PricingRule(models.Model):
|
|
operation_type = models.CharField(max_length=32, choices=CallRecord.OperationType.choices)
|
|
alias = models.CharField(max_length=64)
|
|
resolution = models.CharField(
|
|
max_length=32,
|
|
blank=True,
|
|
default="",
|
|
help_text="Leave blank to apply to every resolution for this alias.",
|
|
)
|
|
points_cost = models.BigIntegerField()
|
|
is_active = models.BooleanField(default=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = "pricing_rule"
|
|
verbose_name = "计费规则"
|
|
verbose_name_plural = "计费规则"
|
|
ordering = ("operation_type", "alias", "resolution")
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=("operation_type", "alias", "resolution"),
|
|
name="unique_pricing_rule_per_alias_resolution",
|
|
),
|
|
models.CheckConstraint(
|
|
condition=Q(points_cost__gt=0),
|
|
name="pricing_rule_points_cost_positive",
|
|
),
|
|
]
|
|
indexes = [
|
|
models.Index(fields=("operation_type", "alias", "is_active")),
|
|
models.Index(fields=("operation_type", "alias", "resolution", "is_active")),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
resolution = self.resolution or "*"
|
|
return f"{self.operation_type}:{self.alias}:{resolution} = {self.points_cost}"
|
|
|
|
def clean(self) -> None:
|
|
super().clean()
|
|
self.alias = str(self.alias or "").strip()
|
|
self.resolution = normalize_resolution(self.resolution)
|
|
if not self.alias:
|
|
raise ValidationError({"alias": "Alias is required."})
|
|
|
|
def save(self, *args, **kwargs) -> None:
|
|
self.full_clean()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class ExchangeRate(models.Model):
|
|
currency = models.CharField(max_length=10, default="CNY")
|
|
points_per_unit = models.DecimalField(max_digits=12, decimal_places=4)
|
|
is_active = models.BooleanField(default=True)
|
|
effective_from = models.DateTimeField()
|
|
note = models.CharField(max_length=255, blank=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = "exchange_rate"
|
|
verbose_name = "汇率"
|
|
verbose_name_plural = "汇率"
|
|
ordering = ("-effective_from", "-id")
|
|
constraints = [
|
|
models.CheckConstraint(
|
|
condition=Q(points_per_unit__gt=0),
|
|
name="exchange_rate_points_per_unit_positive",
|
|
),
|
|
]
|
|
indexes = [
|
|
models.Index(fields=("currency", "is_active", "effective_from")),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.currency} 1 = {self.points_per_unit} points"
|
|
|
|
def clean(self) -> None:
|
|
super().clean()
|
|
self.currency = str(self.currency or "").strip().upper()
|
|
if not self.currency:
|
|
raise ValidationError({"currency": "Currency is required."})
|
|
|
|
def save(self, *args, **kwargs) -> None:
|
|
self.full_clean()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class RechargeOrder(models.Model):
|
|
class PayMethod(models.TextChoices):
|
|
WEIXIN = "weixin", "Weixin"
|
|
ALIPAY = "alipay", "Alipay"
|
|
|
|
class Status(models.TextChoices):
|
|
PENDING = "pending", "Pending"
|
|
PAID = "paid", "Paid"
|
|
FAILED = "failed", "Failed"
|
|
EXPIRED = "expired", "Expired"
|
|
|
|
order_no = models.CharField(max_length=64, unique=True)
|
|
user = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.PROTECT,
|
|
related_name="recharge_orders",
|
|
)
|
|
amount_money = models.DecimalField(max_digits=12, decimal_places=2)
|
|
currency = models.CharField(max_length=10, default="CNY")
|
|
pay_method = models.CharField(max_length=20, choices=PayMethod.choices)
|
|
exchange_rate = models.DecimalField(max_digits=12, decimal_places=4)
|
|
points_granted = models.BigIntegerField()
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=Status.choices,
|
|
default=Status.PENDING,
|
|
)
|
|
code_url = models.TextField(blank=True)
|
|
expires_at = models.DateTimeField(null=True, blank=True)
|
|
payment_txn_no = models.CharField(max_length=128, blank=True)
|
|
paid_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 = "recharge_order"
|
|
verbose_name = "充值订单"
|
|
verbose_name_plural = "充值订单"
|
|
ordering = ("-created_at", "-id")
|
|
constraints = [
|
|
models.CheckConstraint(
|
|
condition=Q(amount_money__gt=0),
|
|
name="recharge_order_amount_money_positive",
|
|
),
|
|
models.CheckConstraint(
|
|
condition=Q(exchange_rate__gt=0),
|
|
name="recharge_order_exchange_rate_positive",
|
|
),
|
|
models.CheckConstraint(
|
|
condition=Q(points_granted__gt=0),
|
|
name="recharge_order_points_granted_positive",
|
|
),
|
|
]
|
|
indexes = [
|
|
models.Index(fields=("user", "created_at")),
|
|
models.Index(fields=("status", "created_at")),
|
|
models.Index(fields=("pay_method", "status")),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.order_no} {self.status}"
|
|
|
|
def clean(self) -> None:
|
|
super().clean()
|
|
self.order_no = str(self.order_no or "").strip()
|
|
self.currency = str(self.currency or "").strip().upper()
|
|
if not self.order_no:
|
|
raise ValidationError({"order_no": "Order number is required."})
|
|
if not self.currency:
|
|
raise ValidationError({"currency": "Currency is required."})
|
|
|
|
def save(self, *args, **kwargs) -> None:
|
|
self.full_clean()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
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"
|
|
verbose_name = "点数流水"
|
|
verbose_name_plural = "点数流水"
|
|
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",
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=("ref_call", "change_type"),
|
|
name="unique_ledger_change_type_per_call",
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=("ref_order_id", "change_type"),
|
|
name="unique_ledger_change_type_per_order",
|
|
),
|
|
]
|
|
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."})
|