baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""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
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user