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" 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 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" 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" 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 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."})