feat(billing): 计费商业化重构(积分制)+ 团队差异化调价
统一计价引擎 apps/billing/pricing.py:1积分=¥0.1,平台成本(¥)与用户价(积分)双记账, 视频按火山真实 usage.total_tokens 结算(true-up,终结¥1/段倒贴)+ 首发×1.5毛利系数。 Team.price_multiplier 差异化调价(jimeng同款):挂牌价×系数两步HALF_UP取整,视频类 按下单时的价格/汇率快照结算(中途改配不影响在途任务)。 BillingConfig 单例(汇率/毛利系数/预留buffer,admin可调即刻生效)。存量数据 ×10 rescale 迁移(RunPython+atomic,MySQL 迁移不可中断重跑)。开户赠送归零(DEFAULT_TRIAL_CREDITS=0, 商业决策)。audit_billing 加 I9(卖亏审计)。271 条测试 + tsc/build 全绿。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
@@ -14,17 +14,21 @@ from apps.ai.models import AITask
|
||||
from apps.common.api import can_manage_team, get_current_team
|
||||
|
||||
from .models import CreditAccount, CreditLedger
|
||||
from .pricing import get_billing_config, team_price_multiplier
|
||||
from .serializers import CreditAccountSerializer, CreditLedgerSerializer
|
||||
|
||||
# AITask.task_type → 账户页「按阶段分布」的 4 个聚合桶
|
||||
_STAGE_BUCKET = {
|
||||
AITask.Type.SCRIPT_GENERATION: "script",
|
||||
AITask.Type.SCRIPT_OPTIMIZATION: "script",
|
||||
AITask.Type.ENTITY_EXTRACTION: "script",
|
||||
AITask.Type.PRODUCT_IMAGE: "base",
|
||||
AITask.Type.PERSON_IMAGE: "base",
|
||||
AITask.Type.SCENE_IMAGE: "base",
|
||||
AITask.Type.STORYBOARD: "storyboard",
|
||||
AITask.Type.VIDEO_SEGMENT: "video",
|
||||
AITask.Type.VOICEOVER: "video",
|
||||
AITask.Type.FREE_VIDEO: "video",
|
||||
AITask.Type.EXPORT: "video",
|
||||
}
|
||||
|
||||
@@ -90,24 +94,59 @@ def ledgers(request):
|
||||
)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def config(request):
|
||||
"""计费公共配置:前端预估(自由创作 token 预估 / 图片单价 / 充值到账换算)统一读这里,
|
||||
与后端计价引擎同一来源,杜绝两端口径漂移。"""
|
||||
cfg = get_billing_config()
|
||||
# 团队价格系数(差异化调价):预估所见即所扣。平台超管无团队 → 回落 1。
|
||||
try:
|
||||
multiplier = team_price_multiplier(get_current_team(request.user))
|
||||
except Exception: # noqa: BLE001 — 无团队(平台超管)等情况一律按标准价
|
||||
multiplier = Decimal("1")
|
||||
return Response(
|
||||
{
|
||||
"currency_unit": "points",
|
||||
"points_per_yuan": str(cfg.points_per_yuan),
|
||||
"video_margin_multiplier": str(cfg.video_margin_multiplier),
|
||||
"video_reserve_buffer": str(cfg.video_reserve_buffer),
|
||||
"team_price_multiplier": str(multiplier),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def recharge(request):
|
||||
"""充值:amount 是真实支付人民币(¥),到账积分 = ¥ × points_per_yuan + bonus_points(赠送积分)。
|
||||
metadata 快照 paid_amount(¥)与当时汇率——老流水的支付金额只能从这里读,不可拿 amount÷rate 反推。"""
|
||||
team = get_current_team(request.user)
|
||||
# 充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客一律拒绝
|
||||
if not can_manage_team(request.user, team):
|
||||
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
|
||||
try:
|
||||
amount = Decimal(str(request.data.get("amount", "0")))
|
||||
bonus = Decimal(str(request.data.get("bonus", "0")))
|
||||
# 兼容旧键 bonus(历史前端按 ¥ 传):按汇率折成积分;新契约 bonus_points 直接是积分
|
||||
if request.data.get("bonus_points") is not None:
|
||||
bonus_points = Decimal(str(request.data.get("bonus_points")))
|
||||
else:
|
||||
bonus_yuan = Decimal(str(request.data.get("bonus", "0")))
|
||||
bonus_points = bonus_yuan * get_billing_config().points_per_yuan
|
||||
except (InvalidOperation, TypeError):
|
||||
return Response({"detail": "invalid amount"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if amount <= 0:
|
||||
return Response({"detail": "amount must be positive"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if bonus < 0:
|
||||
# Decimal("NaN")/("Infinity") 构造不抛,后续比较/入库才炸 500 → 显式拦(review 确认)
|
||||
if not amount.is_finite() or not bonus_points.is_finite():
|
||||
return Response({"detail": "invalid amount"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# 最低 ¥1:分币级支付按 HALF_UP 会到账 0 积分(付了钱拿 0,投诉必至);上限防长尾脏数据
|
||||
if amount < Decimal("1") or amount > Decimal("1000000"):
|
||||
return Response({"detail": "充值金额需在 ¥1 ~ ¥1,000,000 之间"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if bonus_points < 0:
|
||||
return Response({"detail": "bonus cannot be negative"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
channel = str(request.data.get("channel") or "manual")[:32]
|
||||
credited = amount + bonus
|
||||
rate = get_billing_config().points_per_yuan
|
||||
# 取整与计价引擎同口径 HALF_UP(quantize 默认是银行家舍入,¥0.25×10 会得 2 而非 3)
|
||||
credited = (amount * rate + bonus_points).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
with transaction.atomic():
|
||||
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
|
||||
account.balance += credited
|
||||
@@ -119,12 +158,19 @@ def recharge(request):
|
||||
amount=credited,
|
||||
balance_after=account.balance,
|
||||
reason="团队充值",
|
||||
metadata={"channel": channel, "paid_amount": str(amount), "bonus": str(bonus)},
|
||||
metadata={
|
||||
"channel": channel,
|
||||
"paid_amount": str(amount),
|
||||
"points_per_yuan": str(rate),
|
||||
"bonus_points": str(bonus_points),
|
||||
},
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"account": CreditAccountSerializer(account).data,
|
||||
"ledger": CreditLedgerSerializer(ledger).data,
|
||||
"credited_points": str(credited),
|
||||
"points_per_yuan": str(rate),
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user