baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+110
View File
@@ -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)