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:
zyc
2026-07-07 10:04:00 +08:00
co-authored by Claude Sonnet 5
parent c1420316c2
commit bf20de956c
46 changed files with 1490 additions and 150 deletions
+53 -7
View File
@@ -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,
)