"""套餐与计费 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)