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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user