144 lines
4.6 KiB
Python
144 lines
4.6 KiB
Python
"""AI 功能用量与配额(批次 B2,为批次 C billing 预留接口)。
|
|
|
|
设计:每次 AI 调用记一条 `AiUsage` 流水;配额从 `quota_for()` 取——
|
|
批次 C 的 billing 落地后,把 `apps/billing/quota.py` 放进来即自动接管,
|
|
本模块无需改动(导入失败则回落到 settings.AI_FREE_MONTHLY_QUOTA)。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from django.db.models import Count
|
|
|
|
from apps.core.base_models import TenantScopedModel
|
|
|
|
# 用量类型
|
|
KIND_PARSE_ORDER = "parse_order"
|
|
KIND_ASK = "ask"
|
|
KIND_ADVICE = "advice"
|
|
|
|
|
|
try: # billing(批次 C1)已安装时复用其异常类,视图层只需 catch 一处
|
|
from apps.billing.quota import QuotaExceeded as _QuotaBase
|
|
except Exception: # pragma: no cover - billing 未安装时的兜底基类
|
|
_QuotaBase = Exception
|
|
|
|
|
|
class QuotaExceeded(_QuotaBase):
|
|
"""AI 用量超出套餐配额(调用方翻译为 403 + 升级引导)。"""
|
|
|
|
def __init__(self, kind: str, used: int, quota: int, **extra):
|
|
self.kind = kind
|
|
self.used = used
|
|
self.quota = quota
|
|
self.extra = extra
|
|
# 直接调 Exception.__init__:父类(billing)的签名不同,不适合链式调用
|
|
Exception.__init__(self, f"ai quota exceeded for {kind}: {used}/{quota}")
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
"code": "quota_exceeded",
|
|
"kind": self.kind,
|
|
"detail": f"AI 功能本月已用 {self.used}/{self.quota} 次,升级套餐可解除限制",
|
|
"used": self.used,
|
|
"limit": self.quota,
|
|
"upgrade_url": "/#/pricing",
|
|
}
|
|
|
|
|
|
class AiUsage(TenantScopedModel):
|
|
"""AI 调用流水(一次调用一条)。"""
|
|
|
|
kind = models.CharField(max_length=32, help_text="parse_order/ask/advice")
|
|
period = models.CharField(max_length=7, help_text="账期 YYYY-MM(便于按月聚合)")
|
|
detail = models.JSONField(default=dict, blank=True, help_text="调用元数据(不含敏感原文)")
|
|
|
|
class Meta:
|
|
db_table = "ai_usage"
|
|
verbose_name_plural = "AI 调用流水"
|
|
indexes = [
|
|
models.Index(fields=["tenant", "kind", "period"]),
|
|
]
|
|
ordering = ["-created_at"]
|
|
|
|
def __str__(self):
|
|
return f"{self.kind}@{self.period}"
|
|
|
|
|
|
def current_period(today: date | None = None) -> str:
|
|
return (today or date.today()).strftime("%Y-%m")
|
|
|
|
|
|
def quota_for(tenant, kind: str) -> int:
|
|
"""该租户该 AI 功能的月度配额(0 = 不限)。
|
|
|
|
优先走批次 C 的 billing(若已安装),否则用全局免费额度。
|
|
"""
|
|
try:
|
|
from apps.billing.quota import quota_for as billing_quota # type: ignore
|
|
|
|
return int(billing_quota(tenant, f"ai_{kind}"))
|
|
except Exception:
|
|
return int(getattr(settings, "AI_FREE_MONTHLY_QUOTA", 10))
|
|
|
|
|
|
def monthly_count(tenant, kind: str, *, period: str | None = None) -> int:
|
|
"""本月该功能的已用次数。"""
|
|
return AiUsage.objects.filter(
|
|
tenant=tenant, kind=kind, period=period or current_period()
|
|
).count()
|
|
|
|
|
|
def check_quota(tenant, kind: str, *, period: str | None = None) -> dict:
|
|
"""检查并返回配额状态 {used, quota, remaining, allowed}。
|
|
|
|
超限抛 QuotaExceeded(不在此处吞掉,让视图层翻译 HTTP 语义)。
|
|
"""
|
|
period = period or current_period()
|
|
quota = quota_for(tenant, kind)
|
|
used = monthly_count(tenant, kind, period=period)
|
|
if quota > 0 and used >= quota:
|
|
raise QuotaExceeded(kind, used, quota)
|
|
return {
|
|
"used": used,
|
|
"quota": quota,
|
|
"remaining": (quota - used) if quota > 0 else None,
|
|
"allowed": True,
|
|
}
|
|
|
|
|
|
def record_usage(tenant, kind: str, *, detail: dict | None = None) -> AiUsage:
|
|
"""登记一次调用。"""
|
|
return AiUsage.objects.create(
|
|
tenant=tenant,
|
|
kind=kind,
|
|
period=current_period(),
|
|
detail=detail or {},
|
|
)
|
|
|
|
|
|
def usage_summary(tenant) -> dict:
|
|
"""该租户各 AI 功能本月用量总览(前端展示"免费版 10 次/月"进度)。"""
|
|
period = current_period()
|
|
rows = (
|
|
AiUsage.objects.filter(tenant=tenant, period=period)
|
|
.values("kind")
|
|
.annotate(n=Count("id"))
|
|
)
|
|
used = {r["kind"]: r["n"] for r in rows}
|
|
kinds = [KIND_PARSE_ORDER, KIND_ASK, KIND_ADVICE]
|
|
return {
|
|
"period": period,
|
|
"items": [
|
|
{
|
|
"kind": k,
|
|
"used": used.get(k, 0),
|
|
"quota": quota_for(tenant, k),
|
|
}
|
|
for k in kinds
|
|
],
|
|
}
|