111 lines
3.6 KiB
Python
111 lines
3.6 KiB
Python
"""套餐与计费 API(批次 C1)。
|
||
|
||
- GET /api/v1/billing/plans/ 套餐列表(官网价格页数据源)
|
||
- GET /api/v1/billing/subscription/ 我的套餐 + 用量
|
||
- POST /api/v1/billing/subscribe/ 变更套餐(真实支付接入后由回调驱动)
|
||
"""
|
||
|
||
from asgiref.sync import sync_to_async
|
||
from adrf.decorators import api_view
|
||
from rest_framework import status
|
||
from rest_framework.decorators import authentication_classes, permission_classes
|
||
from rest_framework.permissions import AllowAny
|
||
from rest_framework.response import Response
|
||
from rest_framework.exceptions import ValidationError
|
||
|
||
from apps.core.viewset import resolve_tenant
|
||
from .models import Plan, Subscription, upgrade, get_subscription, seed_plans
|
||
from . import quota as billing_quota
|
||
|
||
|
||
async def _tenant(request):
|
||
code = request.META.get("HTTP_X_TENANT_ID", "")
|
||
tenant = await sync_to_async(resolve_tenant)(code)
|
||
if tenant is None:
|
||
raise ValidationError({"tenant": "无法识别租户"})
|
||
return tenant
|
||
|
||
|
||
@api_view(["GET"])
|
||
@authentication_classes([])
|
||
@permission_classes([AllowAny])
|
||
async def plan_list(request):
|
||
"""套餐列表(价格 + 功能对比)。公开:官网价格页/登录页都需要。"""
|
||
|
||
def _load():
|
||
seed_plans()
|
||
return list(Plan.objects.filter(is_active=True).order_by("sort_order", "code"))
|
||
|
||
plans = await sync_to_async(_load)()
|
||
return Response([
|
||
{
|
||
"code": p.code,
|
||
"name": p.name,
|
||
"price_monthly": str(p.price_monthly),
|
||
"description": p.description,
|
||
"limits": p.limits,
|
||
}
|
||
for p in plans
|
||
])
|
||
|
||
|
||
@api_view(["GET"])
|
||
async def my_subscription(request):
|
||
"""我的套餐状态 + 各项用量(升级引导弹窗数据源)。"""
|
||
tenant = await _tenant(request)
|
||
snap = await sync_to_async(billing_quota.usage_snapshot)(tenant)
|
||
return Response(snap)
|
||
|
||
|
||
@api_view(["POST"])
|
||
async def change_plan(request):
|
||
"""变更套餐:POST {plan_code, months?}。
|
||
|
||
真实支付接入前:直接切换(演示/内部开通用);接入支付后应改为
|
||
"创建订单 → 支付回调 → upgrade",此接口仅保留给管理员。
|
||
"""
|
||
tenant = await _tenant(request)
|
||
payload = request.data or {}
|
||
plan_code = (payload.get("plan_code") or "").strip()
|
||
if not plan_code:
|
||
raise ValidationError({"detail": "plan_code 必填"})
|
||
months = int(payload.get("months") or 1)
|
||
|
||
def _do():
|
||
sub = upgrade(tenant, plan_code, months=months)
|
||
return sub
|
||
|
||
try:
|
||
sub = await sync_to_async(_do)()
|
||
except ValueError as exc:
|
||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||
|
||
return Response({
|
||
"ok": True,
|
||
"plan_code": sub.plan.code,
|
||
"plan_name": sub.plan.name,
|
||
"status": sub.status,
|
||
"period_end": sub.period_end.isoformat() if sub.period_end else None,
|
||
})
|
||
|
||
|
||
@api_view(["GET"])
|
||
async def quota_check(request):
|
||
"""预检某配额项:?kind=products(前端按钮置灰/拦截提示用)。
|
||
|
||
语义是"还能不能再加一个":delta=1,因此 used 已达上限即返回 403。
|
||
"""
|
||
tenant = await _tenant(request)
|
||
kind = request.query_params.get("kind") or ""
|
||
if not kind:
|
||
raise ValidationError({"detail": "kind 必填(products/bills_monthly/ai_parse_order/...)"})
|
||
|
||
def _check():
|
||
return billing_quota.check_and_count(tenant, kind, delta=1)
|
||
|
||
try:
|
||
out = await sync_to_async(_check)()
|
||
except billing_quota.QuotaExceeded as exc:
|
||
return Response(exc.as_dict(), status=status.HTTP_403_FORBIDDEN)
|
||
return Response(out)
|