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
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-07-03 03:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ai", "0023_seed_seedance_free_video_models"),
]
operations = [
migrations.AddField(
model_name="aitask",
name="base_cost",
field=models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
]
@@ -0,0 +1,35 @@
"""积分制切换 · 任务计价与模型单价 ×10(与 billing/0004 同窗口)。
- AITask.estimated/actual_cost:历史任务金额转积分语义;
- ModelConfig.unit_price:语义重定义为「积分/次(张)」——¥1→10、¥2→20,恰好等于目标价目;
- **不缩放** AITask.base_cost(¥ 口径)与 ModelConfig.metadata.pricing(火山 ¥ 成本表)。
写法纪律(review 修正):RunPython + atomic 防 MySQL 中断重放 ×100;反向除 10.0 防 sqlite 整数截断。
详见 billing/0004_points_rescale.py 同款说明。
"""
from django.db import migrations, transaction
RESCALE_STATEMENTS = [
"UPDATE ai_aitask SET estimated_cost = estimated_cost * 10, actual_cost = actual_cost * 10",
"UPDATE ai_modelconfig SET unit_price = unit_price * 10",
]
REVERSE_STATEMENTS = [
"UPDATE ai_aitask SET estimated_cost = estimated_cost / 10.0, actual_cost = actual_cost / 10.0",
"UPDATE ai_modelconfig SET unit_price = unit_price / 10.0",
]
def _run(statements):
def apply(apps, schema_editor):
with transaction.atomic(using=schema_editor.connection.alias):
with schema_editor.connection.cursor() as cursor:
for sql in statements:
cursor.execute(sql)
return apply
class Migration(migrations.Migration):
dependencies = [("ai", "0024_aitask_base_cost")]
operations = [migrations.RunPython(_run(RESCALE_STATEMENTS), _run(REVERSE_STATEMENTS))]
+4
View File
@@ -133,8 +133,12 @@ class AITask(TeamOwnedModel):
provider_task_id = models.CharField(max_length=255, blank=True)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
# estimated/actual_cost:用户侧计价,单位**积分**(积分制重构后;历史行已 rescale ×10)
estimated_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
actual_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
# 平台真实成本,单位**人民币**(供应商结算口径)。0 = 成本未知(历史任务/未配置成本的模型)。
# 毛利 = actual_cost/points_per_yuan − base_cost;adminpanel 与 audit I9 消费。
base_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
error_code = models.CharField(max_length=64, blank=True)
error_message = models.TextField(blank=True)
submitted_at = models.DateTimeField(null=True, blank=True)
+96 -12
View File
@@ -120,8 +120,8 @@ def get_video_provider(model_config: ModelConfig):
return build_provider(model_config)
def estimate_cost(model_config: ModelConfig) -> Decimal:
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
# estimate_cost() 已退役:全平台定价统一走 apps/billing/pricing.py 计价引擎(积分制)。
# flat 类型默认价由 create_ai_task 内 quote_flat 提供;视频/配音各入口自带 quote。
def parse_segment_fields(block: str) -> tuple[str, str]:
@@ -629,8 +629,28 @@ def split_script_into_segments(content: str, count: int = 4) -> list[str]:
@transaction.atomic
def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig, request_payload: dict) -> AITask:
cost = estimate_cost(model_config)
def create_ai_task(
*,
project,
user,
task_type: str,
model_config: ModelConfig,
request_payload: dict,
quote: "Quote | None" = None,
reserve_amount: Decimal | None = None,
) -> AITask:
"""建任务 + 预留积分(统一计价枢纽)。
quote 不传 = flat 计价(unit_price 积分/次,文本/图像走这里);视频/配音入口自带 quote。
reserve_amount 仅视频类传(= 积分×buffer,应对真实 tokens 超预估;ledger 禁超预留扣费)。
estimated_cost 记用户价(积分),base_cost 记平台成本(¥,未配置=0)。
"""
from apps.billing.pricing import quote_flat
quote = quote or quote_flat(model_config, team=project.team)
# 汇率快照:margin_yuan 报表用「计价当时」的 points_per_yuan,汇率调整不追溯历史任务(review 确认)
if quote.meta.get("rate"):
request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]}
task = AITask.objects.create(
team=project.team,
created_by=user,
@@ -640,9 +660,10 @@ def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig,
model_config=model_config,
idempotency_key=f"{task_type}:{project.id}:{uuid.uuid4()}",
request_payload=request_payload,
estimated_cost=cost,
estimated_cost=quote.points,
base_cost=quote.base_cost_yuan,
)
reserve_credit(team=project.team, user=user, task=task, amount=cost)
reserve_credit(team=project.team, user=user, task=task, amount=reserve_amount or quote.points)
task.status = AITask.Status.RESERVED
task.save(update_fields=["status", "updated_at"])
return task
@@ -2216,17 +2237,36 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
reference_images = [r["url"] for r in refs]
final_prompt = build_video_segment_prompt(project, video_segment, scene, refs, prompt)
# 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按 9:16/720p/目标时长预估,
# 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。
# 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。
from apps.billing.pricing import quote_video_estimate, video_reserve_amount
est_tokens, quote = quote_video_estimate(
model_config,
aspect_ratio="9:16",
resolution="720p",
duration=video_segment.target_duration_seconds,
references=[],
team=project.team,
)
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VIDEO_SEGMENT,
model_config=model_config,
quote=quote,
reserve_amount=video_reserve_amount(quote.points),
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": final_prompt,
"duration": video_segment.target_duration_seconds,
"ratio": "9:16",
"resolution": "720p",
"estimated_tokens": est_tokens,
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律)
"price_multiplier": quote.meta.get("price_multiplier", "1"),
"video_segment_id": str(video_segment.id),
"reference_images": reference_images,
},
@@ -2343,12 +2383,44 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
existing = video_segment.versions.filter(task=locked_task).order_by("-created_at").first()
if existing is not None:
return existing
# 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径):
# 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。
# usage 缺失(异常响应)回落预估价,不阻断出片。
from apps.billing.pricing import quote_video_actual
reservation = locked_task.credit_reservation
payload = locked_task.request_payload or {}
try:
usage_tokens = int((response.get("usage") or {}).get("total_tokens") or 0)
except (TypeError, ValueError):
usage_tokens = 0
if usage_tokens > 0:
settle = quote_video_actual(
locked_task.model_config,
tokens=usage_tokens,
with_video_ref=False,
resolution=str(payload.get("resolution") or "720p"),
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
)
actual_points, base_cost = settle.points, settle.base_cost_yuan
if actual_points > reservation.amount:
logger.warning(
"video segment task %s actual %s exceeds reserved %s, clamped",
locked_task.id, actual_points, reservation.amount,
)
actual_points = reservation.amount
else:
actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost
if usage_tokens > 0 and settle.meta.get("rate"):
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
locked_task.request_payload = payload
locked_task.status = AITask.Status.SUCCEEDED
locked_task.response_payload = response
locked_task.actual_cost = locked_task.estimated_cost
locked_task.actual_cost = actual_points
locked_task.base_cost = base_cost
locked_task.completed_at = timezone.now()
locked_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=locked_task.credit_reservation, actual_amount=locked_task.actual_cost)
locked_task.save(update_fields=["status", "request_payload", "response_payload", "actual_cost", "base_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=actual_points)
version = VideoSegmentVersion.objects.create(
video_segment=video_segment,
task=locked_task,
@@ -2452,13 +2524,17 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
# 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。
platform_key = str(platform_id or "").strip() if mode == "cover" else ""
platform_name = _PLATFORM_NAMES.get(platform_key, "")
from apps.billing.pricing import quote_flat
tasks: list[AITask] = []
for index in range(count):
cost = estimate_cost(model_config)
quote = quote_flat(model_config, team=team)
request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None}
# 只在重跑/补图时落键(不落 False):workbench 用 KeyTextTransform 抽文本,"false" 字符串也是真值,会误判
if is_append:
request_payload["batch_append"] = True
if quote.meta.get("rate"):
request_payload["points_per_yuan_snapshot"] = quote.meta["rate"]
task = AITask.objects.create(
team=team,
created_by=user,
@@ -2469,10 +2545,11 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
model_config=model_config,
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
request_payload=request_payload,
estimated_cost=cost,
estimated_cost=quote.points,
base_cost=quote.base_cost_yuan,
)
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
reserve_credit(team=team, user=user, task=task, amount=cost)
reserve_credit(team=team, user=user, task=task, amount=quote.points)
task.status = AITask.Status.RESERVED
task.save(update_fields=["status", "updated_at"])
tasks.append(task)
@@ -2678,14 +2755,21 @@ def synthesize_project_voiceover(*, project, user, items: list[dict], voice_type
model_config = get_default_model(ModelConfig.Capability.AUDIO)
if model_config is None:
raise ValueError("no active audio model configured")
# 配音按字符数阶梯计价(默认每 500 字 10 积分,不足按 500):长短脚本不再同价
from apps.billing.pricing import quote_voiceover
char_count = sum(len(text) for _, _, text in texts)
quote = quote_voiceover(model_config, char_count=char_count, team=project.team)
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VOICEOVER,
model_config=model_config,
quote=quote,
request_payload={
"voice_type": voice_type,
"speed_ratio": float(speed_ratio or 1.0),
"char_count": char_count,
"items": [{"index": idx, "cue": j, "text": text} for idx, j, text in texts],
},
)
+11 -6
View File
@@ -26,6 +26,7 @@ from apps.ai.video_pricing import (
get_token_price,
)
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
from apps.billing.pricing import quote_video_actual, quote_video_estimate, video_reserve_amount
STANDARD = "doubao-seedance-2-0-260128"
FAST = "doubao-seedance-2-0-fast-260128"
@@ -165,12 +166,15 @@ class SubmitFreeVideoTests(TestCase):
task = submit_free_video(team=self.team, user=self.user, params=self._params())
self.assertEqual(task.status, AITask.Status.SUBMITTED)
self.assertEqual(task.provider_task_id, "ark-1")
tokens, cost = estimate_video_cost(
# 积分制:estimated_cost=积分(¥成本×毛利×汇率取整),base_cost=¥成本;预留=积分×buffer
tokens, quote = quote_video_estimate(
_model(STANDARD), aspect_ratio="16:9", resolution="480p", duration=4, references=[]
)
self.assertEqual(task.estimated_cost, cost)
self.assertEqual(task.estimated_cost, quote.points)
self.assertEqual(task.base_cost, quote.base_cost_yuan)
self.assertGreater(quote.points, 0)
reservation = CreditReservation.objects.get(task=task)
self.assertEqual(reservation.amount, (cost * RESERVE_BUFFER).quantize(Decimal("0.01")))
self.assertEqual(reservation.amount, video_reserve_amount(quote.points))
# 提交参数按契约落 payload
self.assertEqual(task.request_payload["estimated_tokens"], tokens)
self.assertEqual(task.request_payload["feature"], "free_video")
@@ -284,13 +288,14 @@ class FinalizeFreeVideoTests(TestCase):
}
task = finalize_free_video(task=self.task)
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
expected = calculate_cost(30000, Decimal("46"))
self.assertEqual(task.actual_cost, expected)
settle = quote_video_actual(_model(STANDARD), tokens=30000, with_video_ref=False, resolution="480p")
self.assertEqual(task.actual_cost, settle.points)
self.assertEqual(task.base_cost, settle.base_cost_yuan) # 平台成本(¥)随真实 tokens 落库
self.assertEqual(task.request_payload["seed_used"], 42)
reservation = CreditReservation.objects.get(task=task)
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
account = CreditAccount.objects.get(team=self.team)
self.assertEqual(account.balance, Decimal("100.0000") - expected)
self.assertEqual(account.balance, Decimal("100.0000") - settle.points)
self.assertEqual(account.reserved_balance, Decimal("0"))
self.store.assert_called_once()