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
+23 -15
View File
@@ -15,7 +15,6 @@ import logging
import re
import uuid
from datetime import timedelta
from decimal import Decimal, ROUND_HALF_UP
from io import BytesIO
from django.conf import settings
@@ -25,17 +24,13 @@ from django.utils import timezone
from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup
from apps.assets.storage import TosStorage
from apps.billing.pricing import quote_video_actual, quote_video_estimate, video_reserve_amount
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
from .models import AITask, ModelConfig
from .providers.volcano import VolcanoArkProvider
from .video_errors import map_video_error, parse_provider_error
from .video_pricing import (
RESERVE_BUFFER,
estimate_video_cost,
get_resolution,
tokens_to_cost,
)
from .video_pricing import get_resolution
logger = logging.getLogger(__name__)
@@ -372,14 +367,16 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
built = build_content_items(team=team, prompt=prompt, mode=mode, references=references)
tokens, cost = estimate_video_cost(
# 统一计价引擎:¥成本 × 毛利系数 → 积分;预留 = 积分 × buffer(均为 BillingConfig 可配)
tokens, quote = quote_video_estimate(
model_config,
aspect_ratio=aspect_ratio,
resolution=resolution,
duration=duration,
references=built["snapshots"],
team=team,
)
reserve_amount = (cost * RESERVE_BUFFER).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
reserve_amount = video_reserve_amount(quote.points)
request_payload = {
"feature": "free_video",
@@ -395,6 +392,9 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
"generate_audio": generate_audio,
"search_mode": search_mode,
"estimated_tokens": tokens,
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务
"price_multiplier": quote.meta.get("price_multiplier", "1"),
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
"references": built["snapshots"],
}
@@ -409,7 +409,8 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
model_config=model_config,
idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}",
request_payload=request_payload,
estimated_cost=cost,
estimated_cost=quote.points,
base_cost=quote.base_cost_yuan,
)
try:
reserve_credit(team=team, user=user, task=task, amount=reserve_amount)
@@ -619,12 +620,18 @@ def finalize_free_video(*, task: AITask) -> AITask:
with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or [])
resolution = payload.get("resolution") or "720p"
if total_tokens > 0:
actual = tokens_to_cost(
locked.model_config, total_tokens, with_video_ref=with_video_ref, resolution=resolution
from decimal import Decimal
settle = quote_video_actual(
locked.model_config, tokens=total_tokens, with_video_ref=with_video_ref, resolution=resolution,
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
)
actual, base_cost = settle.points, settle.base_cost_yuan
payload["actual_tokens"] = total_tokens
if settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
else:
actual = locked.estimated_cost
actual, base_cost = locked.estimated_cost, locked.base_cost
seed_out = response.get("seed")
if seed_out is not None:
payload["seed_used"] = seed_out
@@ -635,7 +642,7 @@ def finalize_free_video(*, task: AITask) -> AITask:
return locked
reservation = locked.credit_reservation
if actual > reservation.amount:
# ledger 禁超预留扣费 → clamp 到预留额,差额平台承担并告警(长期观测调 RESERVE_BUFFER)
# ledger 禁超预留扣费 → clamp 到预留额,差额平台承担并告警(长期观测调 buffer)
logger.warning(
"free video task %s actual cost %s exceeds reserved %s, clamped",
locked.id, actual, reservation.amount,
@@ -643,11 +650,12 @@ def finalize_free_video(*, task: AITask) -> AITask:
actual = reservation.amount
locked.status = AITask.Status.SUCCEEDED
locked.actual_cost = actual
locked.base_cost = base_cost
locked.request_payload = payload
locked.response_payload = response
locked.completed_at = timezone.now()
locked.save(
update_fields=["status", "actual_cost", "request_payload", "response_payload", "completed_at", "updated_at"]
update_fields=["status", "actual_cost", "base_cost", "request_payload", "response_payload", "completed_at", "updated_at"]
)
charge_reserved_credit(reservation=reservation, actual_amount=actual)
return locked