Files
cmhub/apps/billing/models.py
T

310 lines
11 KiB
Python
Raw Normal View History

2026-07-02 15:06:00 +08:00
from django.conf import settings
from django.core.exceptions import ValidationError
2026-07-02 09:07:15 +08:00
from django.db import models
2026-07-02 15:06:00 +08:00
from django.db.models import Q
2026-07-02 09:07:15 +08:00
2026-07-02 15:06:00 +08:00
from apps.users.models import ApiKey
2026-07-02 15:28:19 +08:00
def normalize_resolution(value: str | None) -> str:
return "" if value is None else str(value).strip().upper()
2026-07-02 15:06:00 +08:00
class CallRecord(models.Model):
class OperationType(models.TextChoices):
TITLE = "title", "生成标题"
IMAGE = "image", "生成图片"
2026-07-02 15:06:00 +08:00
class Status(models.TextChoices):
PENDING = "pending", "待处理"
SUCCESS = "success", "成功"
FAILED = "failed", "失败"
2026-07-02 15:06:00 +08:00
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
2026-07-02 15:06:00 +08:00
on_delete=models.PROTECT,
related_name="call_records",
)
api_key = models.ForeignKey(
ApiKey,
verbose_name="API 密钥",
2026-07-02 15:06:00 +08:00
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)
2026-07-02 15:06:00 +08:00
status = models.CharField(
"状态",
2026-07-02 15:06:00 +08:00
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)
2026-07-02 15:06:00 +08:00
class Meta:
db_table = "call_record"
verbose_name = "调用记录"
verbose_name_plural = "调用记录"
2026-07-02 15:06:00 +08:00
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}"
2026-07-02 15:28:19 +08:00
class PricingRule(models.Model):
operation_type = models.CharField("操作类型", max_length=32, choices=CallRecord.OperationType.choices)
alias = models.CharField("能力别名", max_length=64)
2026-07-02 15:28:19 +08:00
resolution = models.CharField(
"分辨率",
2026-07-02 15:28:19 +08:00
max_length=32,
blank=True,
default="",
help_text="留空表示该别名所有分辨率通用。",
2026-07-02 15:28:19 +08:00
)
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)
2026-07-02 15:28:19 +08:00
class Meta:
db_table = "pricing_rule"
verbose_name = "计费规则"
verbose_name_plural = "计费规则"
2026-07-02 15:28:19 +08:00
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)
2026-07-02 15:28:19 +08:00
class Meta:
db_table = "exchange_rate"
verbose_name = "汇率"
verbose_name_plural = "汇率"
2026-07-02 15:28:19 +08:00
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)
2026-07-03 09:07:21 +08:00
class RechargeOrder(models.Model):
class PayMethod(models.TextChoices):
WEIXIN = "weixin", "微信"
ALIPAY = "alipay", "支付宝"
2026-07-03 09:07:21 +08:00
class Status(models.TextChoices):
PENDING = "pending", "待支付"
PAID = "paid", "已支付"
FAILED = "failed", "失败"
EXPIRED = "expired", "已过期"
2026-07-03 09:07:21 +08:00
order_no = models.CharField("订单号", max_length=64, unique=True)
2026-07-03 09:07:21 +08:00
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
2026-07-03 09:07:21 +08:00
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("到账点数")
2026-07-03 09:07:21 +08:00
status = models.CharField(
"状态",
2026-07-03 09:07:21 +08:00
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)
2026-07-03 09:07:21 +08:00
class Meta:
db_table = "recharge_order"
verbose_name = "充值订单"
verbose_name_plural = "充值订单"
2026-07-03 09:07:21 +08:00
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)
2026-07-02 15:06:00 +08:00
class PointsLedger(models.Model):
class ChangeType(models.TextChoices):
RECHARGE = "recharge", "充值"
CONSUME = "consume", "消费"
ADJUST = "adjust", "调整"
REFUND = "refund", "退款"
2026-07-02 15:06:00 +08:00
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
2026-07-02 15:06:00 +08:00
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("关联订单 ID", null=True, blank=True)
2026-07-02 15:06:00 +08:00
ref_call = models.ForeignKey(
CallRecord,
verbose_name="关联调用",
2026-07-02 15:06:00 +08:00
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)
2026-07-02 15:06:00 +08:00
class Meta:
db_table = "points_ledger"
verbose_name = "点数流水"
verbose_name_plural = "点数流水"
2026-07-02 15:06:00 +08:00
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",
),
2026-07-02 16:59:02 +08:00
models.UniqueConstraint(
fields=("ref_call", "change_type"),
name="unique_ledger_change_type_per_call",
),
2026-07-03 09:07:21 +08:00
models.UniqueConstraint(
fields=("ref_order_id", "change_type"),
name="unique_ledger_change_type_per_order",
),
2026-07-02 15:06:00 +08:00
]
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."})