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