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
|
||||
|
||||
@@ -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))]
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user