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:
+1
-1
@@ -23,7 +23,7 @@ VOLCANO_ARK_API_KEY=ark-24d5627e-28e4-4412-8679-46a6e9f26aab-6e951
|
||||
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
# 临时:视频(Seedance)借 AirDrama 火山账号 ARK key,与真人素材库审核同账号 → asset:// 可解析。待自有账号开通素材库后删此行。
|
||||
VIDEO_ARK_API_KEY=9225161a-a640-47e6-94ed-81f6c5610072
|
||||
DEFAULT_TRIAL_CREDITS=1000.0000
|
||||
DEFAULT_TRIAL_CREDITS=0
|
||||
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
|
||||
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
|
||||
# YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度)
|
||||
|
||||
@@ -243,4 +243,5 @@ ASSETS_API = {
|
||||
"project_name": env("ASSETS_API_PROJECT_NAME", "int_dev_Airlabs"),
|
||||
}
|
||||
|
||||
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000")
|
||||
# 开户赠送额度(积分)。商业决策(2026-07-03):不赠送(=0);genesis 流水仅 trial>0 才落(I8 天然满足)。
|
||||
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "0")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""积分制切换 · 限额字段 ×10(与 billing/0004 同窗口)。
|
||||
|
||||
- TeamMember 三档限额:0=不限,0×10=0 语义不变;
|
||||
- Team.monthly_credit_limit:**-1 是「不限」哨兵,乘 10 变 -10 会破坏前端 === -1 判断**,
|
||||
必须 WHERE > 0 才乘(0 保持不动);NULL 自然跳过;
|
||||
- Invitation.monthly_credit_limit:join_team 邀请码会把它落到新成员限额,漏乘会发 1/10 限额。
|
||||
|
||||
写法纪律(review 修正):RunPython + atomic 防 MySQL 中断重放 ×100;反向除 10.0 防 sqlite 整数截断。
|
||||
详见 billing/0004_points_rescale.py 同款说明。
|
||||
"""
|
||||
from django.db import migrations, transaction
|
||||
|
||||
RESCALE_STATEMENTS = [
|
||||
"UPDATE accounts_teammember SET daily_credit_limit = daily_credit_limit * 10, monthly_credit_limit = monthly_credit_limit * 10, total_credit_limit = total_credit_limit * 10",
|
||||
"UPDATE accounts_team SET monthly_credit_limit = monthly_credit_limit * 10 WHERE monthly_credit_limit > 0",
|
||||
"UPDATE accounts_invitation SET monthly_credit_limit = monthly_credit_limit * 10",
|
||||
]
|
||||
|
||||
REVERSE_STATEMENTS = [
|
||||
"UPDATE accounts_teammember SET daily_credit_limit = daily_credit_limit / 10.0, monthly_credit_limit = monthly_credit_limit / 10.0, total_credit_limit = total_credit_limit / 10.0",
|
||||
"UPDATE accounts_team SET monthly_credit_limit = monthly_credit_limit / 10.0 WHERE monthly_credit_limit > 0",
|
||||
"UPDATE accounts_invitation SET monthly_credit_limit = monthly_credit_limit / 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 = [("accounts", "0007_team_monthly_credit_limit")]
|
||||
operations = [migrations.RunPython(_run(RESCALE_STATEMENTS), _run(REVERSE_STATEMENTS))]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.15 on 2026-07-03 07:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0008_points_rescale"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="team",
|
||||
name="price_multiplier",
|
||||
field=models.DecimalField(decimal_places=2, default=1, max_digits=5),
|
||||
),
|
||||
]
|
||||
@@ -35,6 +35,11 @@ class Team(TimeStampedModel):
|
||||
# 团队级月限额(超管在团队页设置,自然月重置)。语义三态,与成员级 0=不限 不同:
|
||||
# None = 未设置(前端按成员月度额度累加作团队月限额)· -1 = 不限 · >=0 = 固定上限。
|
||||
monthly_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, default=None)
|
||||
# 团队价格系数(差异化调价,jimeng 同款诉求):最终积分价 = 标准挂牌价 × 系数(HALF_UP,最低 1 积分)。
|
||||
# <1 = 大客户折扣,>1 = 渠道加价;全部计费类型统一生效;对团队侧静默(只体现在预估/实扣数字里)。
|
||||
# 视频类按实结算用**下单时的快照系数**(request_payload.price_multiplier),中途改价不影响在途任务。
|
||||
# 仅平台超管可改(admin 团队定价端点,0.10~10.00 边界)。
|
||||
price_multiplier = models.DecimalField(max_digits=5, decimal_places=2, default=1)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@@ -54,9 +54,15 @@ class AuthApiTests(TestCase):
|
||||
account = CreditAccount.objects.get(team=team)
|
||||
self.assertEqual(account.balance, trial)
|
||||
genesis = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.RECHARGE)
|
||||
self.assertEqual(genesis.count(), 1)
|
||||
self.assertEqual(genesis.first().amount, trial)
|
||||
self.assertEqual(genesis.first().balance_after, trial) # 流水终点 == 账户余额,可对账
|
||||
if trial > 0:
|
||||
# 若运营期把赠送额度调回 >0,genesis 凭证契约必须成立(I8)
|
||||
self.assertEqual(genesis.count(), 1)
|
||||
self.assertEqual(genesis.first().amount, trial)
|
||||
self.assertEqual(genesis.first().balance_after, trial) # 流水终点 == 账户余额,可对账
|
||||
else:
|
||||
# 商业决策(2026-07-03):开户不赠送 → 余额 0 且不落赠送流水(I8 隐含余额 0 天然满足)
|
||||
self.assertEqual(trial, Decimal("0"))
|
||||
self.assertEqual(genesis.count(), 0)
|
||||
|
||||
|
||||
class InvitationFlowTests(TestCase):
|
||||
@@ -150,7 +156,7 @@ class MemberQuotaAndRoleTests(TestCase):
|
||||
r = self._register(APIClient(), "solo-owner", team_name="Solo", invite_code=make_create_team_code())
|
||||
self.assertEqual(r.data.get("role"), "owner")
|
||||
|
||||
def test_create_member_defaults_limit_100(self):
|
||||
def test_create_member_defaults_limit_1000(self):
|
||||
res = self.owner_client.post(
|
||||
"/api/auth/team/members/",
|
||||
{"username": "sub-a", "password": "strong-password", "role": "member"},
|
||||
@@ -158,7 +164,8 @@ class MemberQuotaAndRoleTests(TestCase):
|
||||
)
|
||||
self.assertEqual(res.status_code, 201, res.content)
|
||||
member = TeamMember.objects.get(user__username="sub-a", team=self.team)
|
||||
self.assertEqual(member.monthly_credit_limit, Decimal("100"))
|
||||
# 积分制:默认月限额跟随 ×10(¥100 → 1000 积分),否则新成员限额实际缩水 10 倍
|
||||
self.assertEqual(member.monthly_credit_limit, Decimal("1000"))
|
||||
|
||||
def test_create_member_explicit_limit_respected(self):
|
||||
res = self.owner_client.post(
|
||||
|
||||
@@ -27,7 +27,7 @@ from .serializers import (
|
||||
|
||||
|
||||
# 子账号默认月度限额(0=不限,只留给主账号/超管)
|
||||
DEFAULT_MEMBER_MONTHLY_LIMIT = 100
|
||||
DEFAULT_MEMBER_MONTHLY_LIMIT = 1000
|
||||
|
||||
|
||||
def member_role(user, team):
|
||||
|
||||
@@ -75,7 +75,7 @@ class AdminTeamSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ["id", "name", "status", "owner", "owner_username", "member_count", "balance", "created_at"]
|
||||
fields = ["id", "name", "status", "owner", "owner_username", "member_count", "balance", "price_multiplier", "created_at"]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_member_count(self, obj):
|
||||
@@ -116,18 +116,38 @@ class AdminTaskSerializer(serializers.ModelSerializer):
|
||||
team_name = serializers.CharField(source="team.name", read_only=True, default=None)
|
||||
model_name = serializers.CharField(source="model_config.name", read_only=True, default=None)
|
||||
cost_anomaly = serializers.SerializerMethodField()
|
||||
# 单任务毛利(¥):actual_cost(积分)÷汇率 − base_cost。base_cost=0(成本未知)时 None,报表侧过滤
|
||||
margin_yuan = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AITask
|
||||
fields = [
|
||||
"id", "task_type", "status", "team", "team_name", "model_name",
|
||||
"estimated_cost", "actual_cost", "cost_anomaly", "error_code", "created_at",
|
||||
"estimated_cost", "actual_cost", "base_cost", "margin_yuan", "cost_anomaly", "error_code", "created_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_cost_anomaly(self, obj) -> bool:
|
||||
return is_cost_anomaly(obj.estimated_cost, obj.actual_cost)
|
||||
|
||||
def get_margin_yuan(self, obj) -> str | None:
|
||||
base = obj.base_cost or Decimal("0")
|
||||
actual = obj.actual_cost or Decimal("0")
|
||||
if base <= 0 or actual <= 0:
|
||||
return None
|
||||
from apps.billing.pricing import get_billing_config
|
||||
|
||||
# 优先用任务计价当时的汇率快照:汇率调整不追溯历史,毛利报表不整体漂移(review 确认)。
|
||||
# __dict__ 直取避免触发 deferred 列加载(有的列表 queryset 会 defer payload)。
|
||||
payload = obj.__dict__.get("request_payload") or {}
|
||||
try:
|
||||
rate = Decimal(str(payload.get("points_per_yuan_snapshot") or "")) if payload.get("points_per_yuan_snapshot") else get_billing_config().points_per_yuan
|
||||
except Exception: # noqa: BLE001
|
||||
rate = get_billing_config().points_per_yuan
|
||||
if rate <= 0:
|
||||
return None
|
||||
return str((actual / rate - base).quantize(Decimal("0.01")))
|
||||
|
||||
|
||||
class AdminTaskDetailSerializer(AdminTaskSerializer):
|
||||
class Meta(AdminTaskSerializer.Meta):
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.urls import path
|
||||
|
||||
from .views import (
|
||||
admin_asset_reviews,
|
||||
admin_billing_config,
|
||||
admin_asset_reviews_poll,
|
||||
admin_asset_reviews_submit,
|
||||
admin_invitations,
|
||||
@@ -25,6 +26,7 @@ from .views import (
|
||||
admin_quality_words,
|
||||
admin_revoke_invitation,
|
||||
admin_team_detail,
|
||||
admin_team_pricing,
|
||||
admin_team_toggle,
|
||||
admin_teams,
|
||||
admin_user_reset_password,
|
||||
@@ -39,6 +41,7 @@ urlpatterns = [
|
||||
path("teams/", admin_teams, name="admin-teams"),
|
||||
path("teams/<uuid:team_id>/", admin_team_detail, name="admin-team-detail"),
|
||||
path("teams/<uuid:team_id>/toggle/", admin_team_toggle, name="admin-team-toggle"),
|
||||
path("teams/<uuid:team_id>/pricing/", admin_team_pricing, name="admin-team-pricing"),
|
||||
path("users/", admin_users, name="admin-users"),
|
||||
path("users/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
|
||||
path("users/<uuid:user_id>/reset-password/", admin_user_reset_password, name="admin-user-reset-password"),
|
||||
@@ -54,6 +57,7 @@ urlpatterns = [
|
||||
path("tasks/<uuid:task_id>/retry/", admin_task_retry, name="admin-task-retry"),
|
||||
path("ledgers/", admin_ledgers, name="admin-ledgers"),
|
||||
path("ledgers/adjust/", admin_ledger_adjust, name="admin-ledger-adjust"),
|
||||
path("billing-config/", admin_billing_config, name="admin-billing-config"),
|
||||
path("quota-policies/", admin_quota_policies, name="admin-quota-policies"),
|
||||
path("quota-policies/<uuid:policy_id>/", admin_quota_policy_detail, name="admin-quota-policy-detail"),
|
||||
path("providers/", admin_providers, name="admin-providers"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
|
||||
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from django.db.models import Count, F, Q
|
||||
from rest_framework import status
|
||||
@@ -15,7 +15,8 @@ from apps.accounts.serializers import InvitationSerializer
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.review import poll_asset_review, submit_asset_for_review
|
||||
from apps.billing.models import CreditLedger, QuotaPolicy
|
||||
from apps.billing.models import BillingConfig, CreditLedger, QuotaPolicy
|
||||
from apps.billing.pricing import get_billing_config, invalidate_billing_config_cache
|
||||
from apps.billing.services.ledger import adjust_credit
|
||||
from apps.common.pagination import DefaultPagination
|
||||
from apps.products.models import Product
|
||||
@@ -160,6 +161,38 @@ def admin_team_toggle(request, team_id):
|
||||
# ─────────────────────────── 用户管理 ───────────────────────────
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_team_pricing(request, team_id):
|
||||
"""团队差异化调价(jimeng 同款诉求):设置团队价格系数(0.10~10.00)。
|
||||
最终积分价 = 挂牌价 × 系数;全部计费类型统一生效;视频在途任务用下单快照不受影响。"""
|
||||
from decimal import InvalidOperation
|
||||
|
||||
team = Team.objects.filter(id=team_id).first()
|
||||
if team is None:
|
||||
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
try:
|
||||
value = Decimal(str(request.data.get("price_multiplier")))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return Response({"price_multiplier": ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not value.is_finite() or value < Decimal("0.10") or value > Decimal("10"):
|
||||
return Response({"price_multiplier": ["系数需在 0.10 ~ 10.00 之间"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
before = str(team.price_multiplier)
|
||||
# 显式 HALF_UP:quantize 默认银行家舍入(0.125→0.12),与全仓取整纪律不一致(review 确认)
|
||||
team.price_multiplier = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
team.save(update_fields=["price_multiplier", "updated_at"])
|
||||
log_admin_action(
|
||||
request,
|
||||
"team.pricing_update",
|
||||
target_type="team",
|
||||
target_id=team.id,
|
||||
target_name=team.name,
|
||||
before={"price_multiplier": before},
|
||||
after={"price_multiplier": str(team.price_multiplier)},
|
||||
)
|
||||
return Response(AdminTeamSerializer(_team_qs().get(id=team.id)).data)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_users(request):
|
||||
@@ -496,6 +529,50 @@ def admin_ledger_adjust(request):
|
||||
return Response(AdminLedgerSerializer(ledger).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
def _billing_config_payload(cfg: BillingConfig) -> dict:
|
||||
return {
|
||||
"points_per_yuan": str(cfg.points_per_yuan),
|
||||
"video_margin_multiplier": str(cfg.video_margin_multiplier),
|
||||
"video_reserve_buffer": str(cfg.video_reserve_buffer),
|
||||
"updated_at": cfg.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@api_view(["GET", "PATCH"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_billing_config(request):
|
||||
"""平台计费配置(积分汇率/视频毛利系数/预留 buffer)。PATCH 即刻生效于下一次估价/结算。"""
|
||||
from decimal import InvalidOperation
|
||||
|
||||
cfg = get_billing_config()
|
||||
if request.method == "GET":
|
||||
return Response(_billing_config_payload(cfg))
|
||||
before = _billing_config_payload(cfg)
|
||||
updates: dict = {}
|
||||
for field, minimum in (("points_per_yuan", Decimal("0.01")), ("video_margin_multiplier", Decimal("0.01")), ("video_reserve_buffer", Decimal("1"))):
|
||||
if request.data.get(field) is None:
|
||||
continue
|
||||
try:
|
||||
value = Decimal(str(request.data[field]))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
# Decimal("NaN") 构造不抛,比较才抛 InvalidOperation → 500(review 确认),显式拦
|
||||
if not value.is_finite():
|
||||
return Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if value < minimum:
|
||||
return Response({field: [f"不能小于 {minimum}"]}, status=status.HTTP_400_BAD_REQUEST)
|
||||
updates[field] = value
|
||||
if not updates:
|
||||
return Response({"detail": "没有可更新的字段"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
for field, value in updates.items():
|
||||
setattr(cfg, field, value)
|
||||
cfg.save(update_fields=[*updates.keys(), "updated_at"])
|
||||
invalidate_billing_config_cache()
|
||||
after = _billing_config_payload(cfg)
|
||||
log_admin_action(request, "billing_config.update", target_type="billing_config", target_id=cfg.id, before=before, after=after)
|
||||
return Response(after)
|
||||
|
||||
|
||||
@api_view(["GET", "POST"])
|
||||
@permission_classes([IsPlatformAdmin])
|
||||
def admin_quota_policies(request):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.1.15 on 2026-07-03 03:08
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("billing", "0002_remove_creditledger_billing_cre_team_id_e0f18f_idx_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BillingConfig",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"points_per_yuan",
|
||||
models.DecimalField(decimal_places=2, default=10, max_digits=8),
|
||||
),
|
||||
(
|
||||
"video_margin_multiplier",
|
||||
models.DecimalField(decimal_places=2, default=1.5, max_digits=6),
|
||||
),
|
||||
(
|
||||
"video_reserve_buffer",
|
||||
models.DecimalField(decimal_places=2, default=1.1, max_digits=4),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
# seed 默认配置行(幂等):1¥=10积分 · 视频毛利×1.5(首发,商业决策) · 预留 buffer×1.10
|
||||
migrations.RunPython(
|
||||
lambda apps, schema_editor: apps.get_model("billing", "BillingConfig").objects.exists()
|
||||
or apps.get_model("billing", "BillingConfig").objects.create(),
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""积分制切换 · 账本全量 ×10 线性缩放(1 积分 = ¥0.1,points_per_yuan=10)。
|
||||
|
||||
全部金额列同乘 10:线性变换保持 audit_billing I1~I8 全部不变量。
|
||||
**不缩放**:CreditLedger.metadata 内的 paid_amount/bonus(真实支付¥,JSON 列不受影响)。
|
||||
|
||||
写法纪律(review 修正):
|
||||
· 用 RunPython + transaction.atomic 而非裸 RunSQL —— MySQL 的迁移不包事务
|
||||
(can_rollback_ddl=False),裸 RunSQL 逐条 autocommit,中途被杀后 django_migrations
|
||||
未记录 → 重跑会把已乘过的表再乘 10(×100 错账)。atomic 包住纯 DML(InnoDB 可回滚)
|
||||
后要么全做并记录、要么全回滚,重跑安全。
|
||||
· 反向除法用 10.0 —— sqlite NUMERIC 亲和下整数值 `/ 10` 是整数除法,回滚会静默截断。
|
||||
|
||||
⚠️ 部署纪律:必须与积分制新代码同一次停机窗口上线(短停机一段式)。
|
||||
"""
|
||||
from django.db import migrations, transaction
|
||||
|
||||
RESCALE_STATEMENTS = [
|
||||
"UPDATE billing_creditaccount SET balance = balance * 10, reserved_balance = reserved_balance * 10",
|
||||
"UPDATE billing_creditledger SET amount = amount * 10, balance_after = balance_after * 10",
|
||||
"UPDATE billing_creditreservation SET amount = amount * 10",
|
||||
"UPDATE billing_quotapolicy SET monthly_limit = monthly_limit * 10, project_limit = project_limit * 10, per_task_limit = per_task_limit * 10",
|
||||
]
|
||||
|
||||
REVERSE_STATEMENTS = [
|
||||
"UPDATE billing_creditaccount SET balance = balance / 10.0, reserved_balance = reserved_balance / 10.0",
|
||||
"UPDATE billing_creditledger SET amount = amount / 10.0, balance_after = balance_after / 10.0",
|
||||
"UPDATE billing_creditreservation SET amount = amount / 10.0",
|
||||
"UPDATE billing_quotapolicy SET monthly_limit = monthly_limit / 10.0, project_limit = project_limit / 10.0, per_task_limit = per_task_limit / 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 = [("billing", "0003_billingconfig")]
|
||||
operations = [migrations.RunPython(_run(RESCALE_STATEMENTS), _run(REVERSE_STATEMENTS))]
|
||||
@@ -69,6 +69,23 @@ class CreditReservation(TimeStampedModel):
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
class BillingConfig(TimeStampedModel):
|
||||
"""平台计费全局配置 · 单行单例(pk 恒定,经 pricing.get_billing_config() 缓存读取)。
|
||||
|
||||
积分制核心参数:1 积分 = 1/points_per_yuan 元(默认 ¥0.1);用户侧余额/价格/流水全用积分,
|
||||
平台成本(base_cost)仍以 ¥ 记账。视频类用户价 = 火山成本价 × video_margin_multiplier 换积分
|
||||
(首发 ×1.5,商业决策;adminpanel 可调,改动即刻生效于下一次估价/结算)。
|
||||
"""
|
||||
|
||||
points_per_yuan = models.DecimalField(max_digits=8, decimal_places=2, default=10)
|
||||
video_margin_multiplier = models.DecimalField(max_digits=6, decimal_places=2, default=1.50)
|
||||
# 视频预留 buffer(收编 free_video.RESERVE_BUFFER):预留=预估积分×buffer,应对真实 tokens 略超预估
|
||||
video_reserve_buffer = models.DecimalField(max_digits=4, decimal_places=2, default=1.10)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"1¥={self.points_per_yuan}积分 · 视频毛利×{self.video_margin_multiplier}"
|
||||
|
||||
|
||||
class QuotaPolicy(TimeStampedModel):
|
||||
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="quota_policies")
|
||||
user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, null=True, blank=True, related_name="quota_policies")
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""平台统一计价引擎(积分制 · 参考 jimeng-clone 成本加成模型)。
|
||||
|
||||
单位约定:
|
||||
· 用户侧(余额/价格/流水/限额)一律**积分**,1 积分 = 1/points_per_yuan 元(默认 ¥0.1);
|
||||
· 平台成本(Quote.base_cost_yuan / AITask.base_cost)一律**人民币**——供应商结算是 ¥,
|
||||
两本账各自诚实,毛利 = actual_cost/points_per_yuan − base_cost。
|
||||
|
||||
规则来源:
|
||||
· flat 类型(文本/图像):ModelConfig.unit_price,语义已重定义为「积分/次(张)」(rescale 迁移 ×10);
|
||||
平台成本配在 metadata.pricing.base_cost_yuan(未配=0,毛利报表按未知处理)。
|
||||
· 配音:metadata.pricing = {"mode":"per_chars","chars_per_unit":500,"points_per_unit":10,
|
||||
"min_units":1,"base_cost_yuan_per_unit":...},按字符数阶梯。
|
||||
· 视频:metadata.pricing 是火山**成本价表**(元/百万tokens,见 apps/ai/video_pricing.py),
|
||||
用户价 = ¥成本 × video_margin_multiplier → 积分。按真实 usage.total_tokens 结算。
|
||||
|
||||
取整(前后端必须逐字一致,前端镜像在 frontend/src/components/free-create/constants.ts):
|
||||
¥成本先 quantize 到 0.01(video_pricing.calculate_cost 现状)→ ×毛利 ×汇率 → ROUND_HALF_UP
|
||||
到整数积分,最低 1 积分。
|
||||
"""
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from .models import BillingConfig
|
||||
|
||||
_CONFIG_CACHE_KEY = "billing_config_v1"
|
||||
_CONFIG_TTL = 60
|
||||
|
||||
# flat 类型 unit_price 未配置(≤0)时的兜底价(积分/次):按能力对齐公示价目
|
||||
# (图像 20 积分/张、其余 10 积分/次)——否则 unit_price=0 的图像模型(火山 Seedream)
|
||||
# 实扣会比消费页公示价低一半(review 确认的三方不一致)。
|
||||
FLAT_FALLBACK_POINTS = Decimal("10")
|
||||
FLAT_FALLBACK_POINTS_IMAGE = Decimal("20")
|
||||
|
||||
|
||||
def get_billing_config() -> BillingConfig:
|
||||
"""单例读取(60s 缓存;PATCH 端点写后主动失效)。无行则建默认行(幂等,迁移已 seed)。"""
|
||||
config = cache.get(_CONFIG_CACHE_KEY)
|
||||
if config is None:
|
||||
config = BillingConfig.objects.order_by("created_at").first()
|
||||
if config is None:
|
||||
config = BillingConfig.objects.create()
|
||||
# create() 返回的实例属性是模型默认值的 Python float,Decimal×float 直接 TypeError
|
||||
# 且会被缓存 60s(结算侧炸 = 成片被误判失败退费)。回读一次拿 DB 转换后的 Decimal。
|
||||
config.refresh_from_db()
|
||||
cache.set(_CONFIG_CACHE_KEY, config, _CONFIG_TTL)
|
||||
return config
|
||||
|
||||
|
||||
def invalidate_billing_config_cache() -> None:
|
||||
cache.delete(_CONFIG_CACHE_KEY)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Quote:
|
||||
points: Decimal # 用户应扣积分(整数,最低 1)
|
||||
base_cost_yuan: Decimal # 平台成本 ¥(未知配 0)
|
||||
meta: dict = field(default_factory=dict) # 计价快照(rule/units/tokens/margin/rate...)
|
||||
|
||||
|
||||
def team_price_multiplier(team) -> Decimal:
|
||||
"""团队价格系数(差异化调价):默认 1。非法/缺失一律回落 1,绝不因配置脏数据把计价打挂。"""
|
||||
raw = getattr(team, "price_multiplier", None) if team is not None else None
|
||||
try:
|
||||
value = Decimal(str(raw)) if raw is not None else Decimal("1")
|
||||
except Exception: # noqa: BLE001
|
||||
return Decimal("1")
|
||||
if not value.is_finite() or value <= 0:
|
||||
return Decimal("1")
|
||||
return value
|
||||
|
||||
|
||||
def apply_team_price(points: Decimal, multiplier: Decimal) -> Decimal:
|
||||
"""标准挂牌积分价 × 团队系数 → HALF_UP 取整,最低 1(0 价不强收)。
|
||||
这是差异化调价的唯一落点:与前端预估两步取整逐字对齐(先算挂牌价取整,再乘系数取整)。"""
|
||||
if points <= 0:
|
||||
return points
|
||||
if multiplier == Decimal("1"):
|
||||
return points
|
||||
scaled = (points * multiplier).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
return max(scaled, Decimal("1"))
|
||||
|
||||
|
||||
def yuan_to_points(yuan: Decimal, *, rate: Decimal | None = None) -> Decimal:
|
||||
"""¥ × points_per_yuan → ROUND_HALF_UP 整数积分,最低 1(0 元除外)。"""
|
||||
if rate is None:
|
||||
rate = get_billing_config().points_per_yuan
|
||||
if yuan <= 0:
|
||||
return Decimal("0")
|
||||
points = (Decimal(str(yuan)) * rate).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
return max(points, Decimal("1"))
|
||||
|
||||
|
||||
def _pricing_meta(model_config) -> dict:
|
||||
return ((getattr(model_config, "metadata", None) or {}).get("pricing")) or {}
|
||||
|
||||
|
||||
def quote_flat(model_config, *, units: int = 1, team=None) -> Quote:
|
||||
"""文本(次)/ 图像(张)。unit_price = 积分/单位;≤0 回落 10 积分。team 传入则乘团队价格系数。"""
|
||||
unit_points = Decimal(str(getattr(model_config, "unit_price", 0) or 0))
|
||||
if unit_points <= 0:
|
||||
is_image = str(getattr(model_config, "capability", "")) == "image"
|
||||
unit_points = FLAT_FALLBACK_POINTS_IMAGE if is_image else FLAT_FALLBACK_POINTS
|
||||
unit_points = unit_points.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
base_per_unit = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0))
|
||||
multiplier = team_price_multiplier(team)
|
||||
points = apply_team_price(max(unit_points * units, Decimal("1")), multiplier)
|
||||
return Quote(
|
||||
points=points,
|
||||
base_cost_yuan=base_per_unit * units,
|
||||
meta={"rule": "flat", "units": units, "unit_points": str(unit_points), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)},
|
||||
)
|
||||
|
||||
|
||||
def quote_voiceover(model_config, *, char_count: int, team=None) -> Quote:
|
||||
"""配音字符阶梯:ceil(chars / chars_per_unit) × points_per_unit,最低 min_units 档。team 传入则乘系数。"""
|
||||
pricing = _pricing_meta(model_config)
|
||||
chars_per_unit = int(pricing.get("chars_per_unit") or 500)
|
||||
points_per_unit = Decimal(str(pricing.get("points_per_unit") or 10))
|
||||
min_units = int(pricing.get("min_units") or 1)
|
||||
base_per_unit = Decimal(str(pricing.get("base_cost_yuan_per_unit") or 0))
|
||||
units = max(math.ceil(max(char_count, 0) / chars_per_unit), min_units)
|
||||
multiplier = team_price_multiplier(team)
|
||||
points = apply_team_price(max((points_per_unit * units).quantize(Decimal("1"), rounding=ROUND_HALF_UP), Decimal("1")), multiplier)
|
||||
return Quote(
|
||||
points=points,
|
||||
base_cost_yuan=base_per_unit * units,
|
||||
meta={"rule": "per_chars", "char_count": char_count, "units": units, "chars_per_unit": chars_per_unit, "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan)},
|
||||
)
|
||||
|
||||
|
||||
def quote_video_from_cost(cost_yuan: Decimal, *, tokens: int = 0, multiplier: Decimal | None = None) -> Quote:
|
||||
"""视频通用:¥成本(video_pricing 已 quantize 0.01)× 毛利 → 挂牌积分 → ×团队系数。
|
||||
multiplier 由调用方决定来源:估价=团队当前系数;**按实结算=下单时的快照系数**
|
||||
(学 jimeng:中途改价不影响在途任务,否则预留 buffer 可能被中途涨价击穿)。"""
|
||||
config = get_billing_config()
|
||||
user_yuan = Decimal(str(cost_yuan)) * config.video_margin_multiplier
|
||||
list_points = yuan_to_points(user_yuan, rate=config.points_per_yuan)
|
||||
multiplier = multiplier if multiplier is not None else Decimal("1")
|
||||
points = apply_team_price(list_points, multiplier)
|
||||
return Quote(
|
||||
points=points,
|
||||
base_cost_yuan=Decimal(str(cost_yuan)),
|
||||
meta={
|
||||
"rule": "video_tokens",
|
||||
"tokens": tokens,
|
||||
"margin": str(config.video_margin_multiplier),
|
||||
"rate": str(config.points_per_yuan),
|
||||
"price_multiplier": str(multiplier),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def quote_video_estimate(model_config, *, aspect_ratio: str, resolution: str, duration: int, references: list, team=None) -> tuple[int, Quote]:
|
||||
"""预估:apps/ai/video_pricing 的 ¥成本 × 毛利 → 积分 → ×团队当前系数。返回 (tokens, Quote)。"""
|
||||
from apps.ai.video_pricing import estimate_video_cost
|
||||
|
||||
tokens, cost_yuan = estimate_video_cost(
|
||||
model_config, aspect_ratio=aspect_ratio, resolution=resolution, duration=duration, references=references
|
||||
)
|
||||
return tokens, quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=team_price_multiplier(team))
|
||||
|
||||
|
||||
def quote_video_actual(model_config, *, tokens: int, with_video_ref: bool, resolution: str, multiplier: Decimal | None = None) -> Quote:
|
||||
"""按真实 usage.total_tokens 结算(与预估同一张成本表+同一毛利)。
|
||||
multiplier 必须传**下单时的快照系数**(request_payload.price_multiplier),不读团队当前值。"""
|
||||
from apps.ai.video_pricing import tokens_to_cost
|
||||
|
||||
cost_yuan = tokens_to_cost(model_config, tokens, with_video_ref=with_video_ref, resolution=resolution)
|
||||
return quote_video_from_cost(cost_yuan, tokens=tokens, multiplier=multiplier)
|
||||
|
||||
|
||||
def video_reserve_amount(points: Decimal) -> Decimal:
|
||||
"""视频预留额 = 预估积分 × buffer(真实 tokens 可能略超预估;ledger 禁超预留扣费)。"""
|
||||
buffer = get_billing_config().video_reserve_buffer
|
||||
return (points * buffer).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
@@ -34,20 +34,44 @@ def _enforce_member_monthly_limit(*, team, user, amount: Decimal) -> None:
|
||||
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
charged = _charged_since(day_start)
|
||||
if charged + reserved + amount > day_limit:
|
||||
raise ValueError(f"成员今日额度不足:每日限额 ¥{day_limit},今日已用 ¥{charged}(另有在途 ¥{reserved})")
|
||||
raise ValueError(f"成员今日额度不足:每日限额 {day_limit} 积分,今日已用 {charged}(另有在途 {reserved})")
|
||||
|
||||
month_limit = member.monthly_credit_limit or Decimal("0")
|
||||
if month_limit > 0:
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
charged = _charged_since(month_start)
|
||||
if charged + reserved + amount > month_limit:
|
||||
raise ValueError(f"成员本月额度不足:限额 ¥{month_limit},本月已用 ¥{charged}(另有在途 ¥{reserved})")
|
||||
raise ValueError(f"成员本月额度不足:限额 {month_limit} 积分,本月已用 {charged}(另有在途 {reserved})")
|
||||
|
||||
total_limit = member.total_credit_limit or Decimal("0")
|
||||
if total_limit > 0:
|
||||
charged = _charged_since(None)
|
||||
if charged + reserved + amount > total_limit:
|
||||
raise ValueError(f"成员累计额度不足:总额度 ¥{total_limit},累计已用 ¥{charged}(另有在途 ¥{reserved})")
|
||||
raise ValueError(f"成员累计额度不足:总额度 {total_limit} 积分,累计已用 {charged}(另有在途 {reserved})")
|
||||
|
||||
|
||||
|
||||
def _enforce_team_monthly_limit(*, team, amount: Decimal) -> None:
|
||||
"""Team.monthly_credit_limit 真管控(此前是装饰性字段:团队页可配,reserve 从不读 → 形同虚设)。
|
||||
语义:None/-1/0 均不管控,仅正数为硬上限 —— 0 必须视为「未设置/不限」而非「冻结」:
|
||||
前端把 null 映射成 0 展示、成员限额也是 0=不限,存量数据里存在 0 行,若 0=冻结会在
|
||||
部署当天把这些团队全体静默冻结(review 确认的语义冲突)。冻结消费请用 QuotaPolicy(monthly=0)。
|
||||
口径与成员/QuotaPolicy 月度一致:自然月团队 CHARGE 合计 + 团队全部 ACTIVE 预留 + 本次。
|
||||
调用方(reserve_credit)已持 account 行锁,团队内 reserve 串行化,无竞态。"""
|
||||
limit = team.monthly_credit_limit
|
||||
if limit is None or limit <= 0:
|
||||
return
|
||||
month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
charged = (
|
||||
CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE, created_at__gte=month_start)
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
reserved = (
|
||||
CreditReservation.objects.filter(team=team, status=CreditReservation.Status.ACTIVE)
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
if charged + reserved + amount > limit:
|
||||
raise ValueError(f"团队本月额度不足:限额 {limit} 积分,本月已用 {charged}(另有在途 {reserved})")
|
||||
|
||||
|
||||
def _enforce_quota_policy(*, team, project, amount: Decimal) -> None:
|
||||
@@ -61,7 +85,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None:
|
||||
if policy is None:
|
||||
return
|
||||
if policy.per_task_limit is not None and amount > policy.per_task_limit:
|
||||
raise ValueError(f"单任务额度超限:上限 ¥{policy.per_task_limit}")
|
||||
raise ValueError(f"单任务额度超限:上限 {policy.per_task_limit} 积分")
|
||||
now = timezone.now()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
if policy.monthly_limit is not None:
|
||||
@@ -74,7 +98,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None:
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
if charged + reserved + amount > policy.monthly_limit:
|
||||
raise ValueError(f"团队本月额度超限:上限 ¥{policy.monthly_limit}")
|
||||
raise ValueError(f"团队本月额度超限:上限 {policy.monthly_limit} 积分")
|
||||
if policy.project_limit is not None and project is not None:
|
||||
p_charged = (
|
||||
CreditLedger.objects.filter(team=team, project=project, ledger_type=CreditLedger.Type.CHARGE)
|
||||
@@ -85,7 +109,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None:
|
||||
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
)
|
||||
if p_charged + p_reserved + amount > policy.project_limit:
|
||||
raise ValueError(f"项目额度超限:上限 ¥{policy.project_limit}")
|
||||
raise ValueError(f"项目额度超限:上限 {policy.project_limit} 积分")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -116,6 +140,7 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
|
||||
if available < amount:
|
||||
raise ValueError("insufficient credit")
|
||||
_enforce_member_monthly_limit(team=team, user=user, amount=amount)
|
||||
_enforce_team_monthly_limit(team=team, amount=amount)
|
||||
_enforce_quota_policy(team=team, project=task.project, amount=amount)
|
||||
|
||||
account.reserved_balance += amount
|
||||
|
||||
@@ -190,8 +190,10 @@ class RechargePermissionTests(TestCase):
|
||||
client.force_authenticate(self.owner)
|
||||
response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
# 积分制:支付 ¥100 × 10 = 到账 1000 积分;响应带换算快照
|
||||
self.assertEqual(response.json()["credited_points"], "1000")
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.balance, Decimal("100.0000"))
|
||||
self.assertEqual(account.balance, Decimal("1000.0000"))
|
||||
|
||||
def test_member_cannot_recharge(self):
|
||||
client = APIClient()
|
||||
@@ -201,3 +203,287 @@ class RechargePermissionTests(TestCase):
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.balance, Decimal("0.0000")) # 余额未变,越权被拦
|
||||
|
||||
|
||||
|
||||
class PricingEngineTests(TestCase):
|
||||
"""计价引擎(apps/billing/pricing):flat 回落 / 配音阶梯 / 视频毛利与取整 / 最低 1 积分。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.billing.pricing import invalidate_billing_config_cache
|
||||
|
||||
invalidate_billing_config_cache()
|
||||
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
|
||||
self.text_model = ModelConfig.objects.create(
|
||||
provider=provider, name="pe-text", display_name="T", capability=ModelConfig.Capability.TEXT,
|
||||
unit_price=Decimal("10"), metadata={"pricing": {"mode": "flat", "base_cost_yuan": 0.1}},
|
||||
)
|
||||
self.audio_model = ModelConfig.objects.create(
|
||||
provider=provider, name="pe-audio", display_name="A", capability=ModelConfig.Capability.AUDIO,
|
||||
metadata={"pricing": {"mode": "per_chars", "chars_per_unit": 500, "points_per_unit": 10, "min_units": 1, "base_cost_yuan_per_unit": 0.25}},
|
||||
)
|
||||
|
||||
def test_flat_uses_unit_price_as_points(self):
|
||||
from apps.billing.pricing import quote_flat
|
||||
|
||||
quote = quote_flat(self.text_model)
|
||||
self.assertEqual(quote.points, Decimal("10"))
|
||||
self.assertEqual(quote.base_cost_yuan, Decimal("0.1"))
|
||||
# 多单位(单图批量按张)
|
||||
self.assertEqual(quote_flat(self.text_model, units=3).points, Decimal("30"))
|
||||
|
||||
def test_flat_fallback_when_unit_price_unset(self):
|
||||
from apps.billing.pricing import FLAT_FALLBACK_POINTS, quote_flat
|
||||
|
||||
self.text_model.unit_price = Decimal("0")
|
||||
quote = quote_flat(self.text_model)
|
||||
self.assertEqual(quote.points, FLAT_FALLBACK_POINTS)
|
||||
|
||||
def test_voiceover_char_tiers(self):
|
||||
from apps.billing.pricing import quote_voiceover
|
||||
|
||||
cases = {0: 1, 1: 1, 499: 1, 500: 1, 501: 2, 1500: 3, 1501: 4}
|
||||
for chars, units in cases.items():
|
||||
quote = quote_voiceover(self.audio_model, char_count=chars)
|
||||
self.assertEqual(quote.points, Decimal(units * 10), f"chars={chars}")
|
||||
self.assertEqual(quote.base_cost_yuan, Decimal("0.25") * units, f"chars={chars}")
|
||||
|
||||
def test_video_margin_and_rounding(self):
|
||||
from apps.billing.pricing import get_billing_config, quote_video_from_cost
|
||||
|
||||
cfg = get_billing_config()
|
||||
self.assertEqual(cfg.video_margin_multiplier, Decimal("1.50")) # 首发 ×1.5(商业决策)
|
||||
# ¥1.85 成本 × 1.5 × 10 = 27.75 → HALF_UP → 28 积分;base_cost 保留 ¥ 原值
|
||||
quote = quote_video_from_cost(Decimal("1.85"))
|
||||
self.assertEqual(quote.points, Decimal("28"))
|
||||
self.assertEqual(quote.base_cost_yuan, Decimal("1.85"))
|
||||
|
||||
def test_minimum_one_point(self):
|
||||
from apps.billing.pricing import quote_video_from_cost, yuan_to_points
|
||||
|
||||
self.assertEqual(quote_video_from_cost(Decimal("0.001")).points, Decimal("1"))
|
||||
self.assertEqual(yuan_to_points(Decimal("0")), Decimal("0")) # 0 成本不强收
|
||||
|
||||
def test_video_reserve_buffer(self):
|
||||
from apps.billing.pricing import video_reserve_amount
|
||||
|
||||
# 28 × 1.10 = 30.8 → 31 积分
|
||||
self.assertEqual(video_reserve_amount(Decimal("28")), Decimal("31"))
|
||||
|
||||
|
||||
class TeamMonthlyLimitTests(TestCase):
|
||||
"""Team.monthly_credit_limit 真管控:None/-1/0 均不管控,仅正数=硬上限(含在途预留)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="tml-owner", password="p")
|
||||
self.team = Team.objects.create(name="TML", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("1000.0000"))
|
||||
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=provider, name="tml-m", display_name="M", capability=ModelConfig.Capability.TEXT,
|
||||
)
|
||||
|
||||
def _task(self, key):
|
||||
return AITask.objects.create(
|
||||
team=self.team, created_by=self.user, task_type=AITask.Type.SCRIPT_GENERATION,
|
||||
model_config=self.model, idempotency_key=f"tml-{key}",
|
||||
)
|
||||
|
||||
def _reserve(self, key, amount):
|
||||
return reserve_credit(team=self.team, user=self.user, task=self._task(key), amount=Decimal(str(amount)))
|
||||
|
||||
def test_none_means_no_enforcement(self):
|
||||
self.team.monthly_credit_limit = None
|
||||
self.team.save(update_fields=["monthly_credit_limit"])
|
||||
self._reserve("a", 500) # 不抛
|
||||
|
||||
def test_minus_one_means_unlimited(self):
|
||||
self.team.monthly_credit_limit = Decimal("-1")
|
||||
self.team.save(update_fields=["monthly_credit_limit"])
|
||||
self._reserve("b", 500) # 不抛
|
||||
|
||||
def test_zero_means_no_enforcement(self):
|
||||
# 0 必须不管控:前端把 null 映射成 0、成员限额 0=不限,存量 0 行若被解释成「冻结」
|
||||
# 会在部署当天静默冻结整个团队(review 确认)。冻结消费用 QuotaPolicy(monthly=0)。
|
||||
self.team.monthly_credit_limit = Decimal("0")
|
||||
self.team.save(update_fields=["monthly_credit_limit"])
|
||||
self._reserve("c", 500) # 不抛
|
||||
|
||||
def test_positive_limit_counts_active_reservations(self):
|
||||
self.team.monthly_credit_limit = Decimal("100")
|
||||
self.team.save(update_fields=["monthly_credit_limit"])
|
||||
reservation = self._reserve("d", 60) # 在途 60
|
||||
with self.assertRaisesMessage(ValueError, "团队本月额度不足"):
|
||||
self._reserve("e", 50) # 60+50 > 100
|
||||
# 结算 40(在途转已扣 40,当月已用 40):40+50 <= 100 → 放行
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=Decimal("40"))
|
||||
self._reserve("f", 50)
|
||||
|
||||
|
||||
class RechargePointsTests(TestCase):
|
||||
"""充值积分语义:¥ × points_per_yuan + bonus_points;metadata 快照支付金额与汇率。"""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create_user(username="rp-owner", password="p")
|
||||
self.team = Team.objects.create(name="RP", owner=self.owner)
|
||||
TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("0"))
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.owner)
|
||||
|
||||
def test_recharge_converts_yuan_to_points_with_bonus(self):
|
||||
resp = self.client.post("/api/billing/recharge/", {"amount": "500", "bonus_points": "300"}, format="json")
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
body = resp.json()
|
||||
self.assertEqual(body["credited_points"], "5300")
|
||||
self.assertEqual(body["points_per_yuan"], "10.00")
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.balance, Decimal("5300"))
|
||||
ledger = CreditLedger.objects.get(team=self.team, ledger_type=CreditLedger.Type.RECHARGE)
|
||||
# 真实支付金额只能从 metadata 读(老流水不可用 amount÷rate 反推)
|
||||
self.assertEqual(ledger.metadata["paid_amount"], "500")
|
||||
self.assertEqual(ledger.metadata["bonus_points"], "300")
|
||||
self.assertEqual(ledger.metadata["points_per_yuan"], "10.00")
|
||||
|
||||
def test_legacy_bonus_yuan_converted(self):
|
||||
resp = self.client.post("/api/billing/recharge/", {"amount": "100", "bonus": "20"}, format="json")
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
self.assertEqual(resp.json()["credited_points"], "1200") # 100×10 + 20×10
|
||||
|
||||
|
||||
class BillingConfigEndpointTests(TestCase):
|
||||
"""公共 GET /api/billing/config/(鉴权)+ admin PATCH(权限/生效/审计)。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.billing.pricing import invalidate_billing_config_cache
|
||||
|
||||
invalidate_billing_config_cache()
|
||||
self.user = User.objects.create_user(username="bc-user", password="p")
|
||||
self.team = Team.objects.create(name="BC", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.admin = User.objects.create_user(username="bc-admin", password="p", is_platform_admin=True)
|
||||
|
||||
def test_public_config_requires_auth(self):
|
||||
self.assertEqual(APIClient().get("/api/billing/config/").status_code, 401)
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.user)
|
||||
body = client.get("/api/billing/config/").json()
|
||||
self.assertEqual(body["currency_unit"], "points")
|
||||
self.assertEqual(body["points_per_yuan"], "10.00")
|
||||
self.assertEqual(body["video_margin_multiplier"], "1.50")
|
||||
|
||||
def test_admin_patch_updates_and_takes_effect(self):
|
||||
from apps.billing.pricing import get_billing_config, invalidate_billing_config_cache
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.admin)
|
||||
resp = client.patch("/api/admin/billing-config/", {"video_margin_multiplier": "2.5"}, format="json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
invalidate_billing_config_cache()
|
||||
self.assertEqual(get_billing_config().video_margin_multiplier, Decimal("2.5"))
|
||||
# 非超管 PATCH 拒绝
|
||||
stranger = APIClient()
|
||||
stranger.force_authenticate(self.user)
|
||||
self.assertEqual(stranger.patch("/api/admin/billing-config/", {"points_per_yuan": "1"}, format="json").status_code, 403)
|
||||
|
||||
|
||||
class TeamPriceMultiplierTests(TestCase):
|
||||
"""团队差异化调价:全类型统一系数、两步取整、视频结算用下单快照(中途改价不影响在途)。"""
|
||||
|
||||
def setUp(self):
|
||||
from apps.billing.pricing import invalidate_billing_config_cache
|
||||
|
||||
invalidate_billing_config_cache()
|
||||
self.user = User.objects.create_user(username="tpm-owner", password="p")
|
||||
self.team = Team.objects.create(name="TPM", owner=self.user, price_multiplier=Decimal("0.80"))
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("10000"))
|
||||
provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"})
|
||||
self.image_model = ModelConfig.objects.create(
|
||||
provider=provider, name="tpm-img", display_name="I", capability=ModelConfig.Capability.IMAGE,
|
||||
unit_price=Decimal("20"),
|
||||
)
|
||||
|
||||
def test_flat_applies_multiplier(self):
|
||||
from apps.billing.pricing import quote_flat
|
||||
|
||||
# 20 × 0.8 = 16;不传 team = 标准价(零回归)
|
||||
quote = quote_flat(self.image_model, team=self.team)
|
||||
self.assertEqual(quote.points, Decimal("16"))
|
||||
self.assertEqual(quote_flat(self.image_model).points, Decimal("20"))
|
||||
# meta.rate 是汇率快照契约:create_ai_task 落 payload.points_per_yuan_snapshot,毛利报表用它防汇率漂移
|
||||
self.assertEqual(Decimal(quote.meta["rate"]), Decimal("10"))
|
||||
|
||||
def test_two_step_rounding(self):
|
||||
from apps.billing.pricing import quote_video_from_cost
|
||||
|
||||
# 挂牌:¥1.85×1.5×10=27.75→28;再 ×0.8=22.4→22(两步取整,与前端镜像逐字一致)
|
||||
quote = quote_video_from_cost(Decimal("1.85"), multiplier=Decimal("0.80"))
|
||||
self.assertEqual(quote.points, Decimal("22"))
|
||||
self.assertEqual(quote.meta["price_multiplier"], "0.80")
|
||||
|
||||
def test_minimum_one_point_after_discount(self):
|
||||
from apps.billing.pricing import apply_team_price
|
||||
|
||||
self.assertEqual(apply_team_price(Decimal("1"), Decimal("0.10")), Decimal("1"))
|
||||
|
||||
def test_invalid_multiplier_falls_back_to_one(self):
|
||||
from apps.billing.pricing import team_price_multiplier
|
||||
|
||||
self.team.price_multiplier = Decimal("0")
|
||||
self.assertEqual(team_price_multiplier(self.team), Decimal("1"))
|
||||
self.assertEqual(team_price_multiplier(None), Decimal("1"))
|
||||
|
||||
def test_video_settle_uses_snapshot_not_current(self):
|
||||
"""下单时 0.8 → 中途管理员改成 2.0 → 结算仍按 0.8 快照(jimeng 同款纪律)。"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.ai.free_video import finalize_free_video, submit_free_video
|
||||
from apps.ai.models import AITask
|
||||
|
||||
provider = MagicMock()
|
||||
provider.create_video_task.return_value = {"id": "ark-tpm", "status": "queued"}
|
||||
provider.extract_first_media_url.return_value = "http://x/v.mp4"
|
||||
patch("apps.ai.services.build_provider", return_value=provider).start()
|
||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||
patch("apps.ai.free_video._store_free_video_media").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
task = submit_free_video(team=self.team, user=self.user, params={
|
||||
"prompt": "测试", "mode": "universal", "model": "doubao-seedance-2-0-260128",
|
||||
"aspect_ratio": "16:9", "resolution": "480p", "duration": 4, "references": [],
|
||||
})
|
||||
self.assertEqual(task.request_payload["price_multiplier"], "0.80")
|
||||
# 挂牌 28 × 0.8 = 22.4 → 22 积分
|
||||
self.assertEqual(task.estimated_cost, Decimal("22"))
|
||||
# 中途涨价到 2.0(在途任务不受影响)
|
||||
self.team.price_multiplier = Decimal("2.00")
|
||||
self.team.save(update_fields=["price_multiplier"])
|
||||
provider.poll_video_task.return_value = {"status": "succeeded", "usage": {"total_tokens": 30000}}
|
||||
task = finalize_free_video(task=task)
|
||||
# 真实 30000 tokens:挂牌 1.38×1.5×10=20.7→21;×快照0.8=16.8→17(而非 ×2.0=42)
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(task.actual_cost, Decimal("17"))
|
||||
|
||||
def test_admin_pricing_endpoint(self):
|
||||
admin = User.objects.create_user(username="tpm-admin", password="p", is_platform_admin=True)
|
||||
client = APIClient()
|
||||
client.force_authenticate(admin)
|
||||
resp = client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.9"}, format="json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()["price_multiplier"], "0.90")
|
||||
# 三位小数走 HALF_UP 而非银行家舍入:0.125 → 0.13(quantize 默认会给 0.12)
|
||||
resp = client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.125"}, format="json")
|
||||
self.assertEqual(resp.json()["price_multiplier"], "0.13")
|
||||
# 边界与非法值
|
||||
self.assertEqual(client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.05"}, format="json").status_code, 400)
|
||||
self.assertEqual(client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "NaN"}, format="json").status_code, 400)
|
||||
# 非超管 403
|
||||
stranger = APIClient()
|
||||
stranger.force_authenticate(self.user)
|
||||
self.assertEqual(stranger.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.5"}, format="json").status_code, 403)
|
||||
|
||||
def test_public_config_returns_team_multiplier(self):
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.user)
|
||||
self.assertEqual(client.get("/api/billing/config/").json()["team_price_multiplier"], "0.80")
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import ledgers, recharge, summary, trend
|
||||
from .views import config, ledgers, recharge, summary, trend
|
||||
|
||||
urlpatterns = [
|
||||
path("summary/", summary, name="billing-summary"),
|
||||
path("ledgers/", ledgers, name="billing-ledgers"),
|
||||
path("recharge/", recharge, name="billing-recharge"),
|
||||
path("trend/", trend, name="billing-trend"),
|
||||
path("config/", config, name="billing-config"),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,8 @@ class Project(TeamOwnedModel):
|
||||
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="projects")
|
||||
status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT)
|
||||
current_stage = models.CharField(max_length=32, default="script")
|
||||
# ⚠️ 废弃休眠字段:从未有过写入路径与运行语义,单位未定义(积分制 rescale 有意未缩放它)。
|
||||
# 勿接入额度管控/展示;项目级限额请用 QuotaPolicy.project_limit。留列仅为免迁移。
|
||||
budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||
failure_reason = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
@@ -1030,3 +1030,87 @@ class AttachOfficialModelTests(TestCase):
|
||||
self.assertEqual((group.metadata or {}).get("adopt"), "adopted") # 自动采用
|
||||
sub.assert_called_once() # 自动送审(on_commit 在 TestCase 事务提交点触发)
|
||||
self.assertEqual(sub.call_args.args[0].id, mine.id)
|
||||
|
||||
|
||||
class VideoSegmentTrueUpTests(TestCase):
|
||||
"""pipeline 视频段积分计价 + 按火山真实 usage.total_tokens 结算(true-up)。
|
||||
终结「视频 ¥1/段、成本 ¥15」的倒贴定价:预留=预估积分×buffer,终态按实结算、差额自动 RELEASE。"""
|
||||
|
||||
def setUp(self):
|
||||
from decimal import Decimal
|
||||
|
||||
self.user = User.objects.create_user(username="tu-owner", password="p")
|
||||
self.team = Team.objects.create(name="TU", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance=Decimal("10000.0000"))
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
|
||||
self.project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="TU-P")
|
||||
self.segment = VideoSegment.objects.create(project=self.project, sort_order=0, target_duration_seconds=15)
|
||||
self.provider = patch("apps.ai.services.build_provider").start().return_value
|
||||
self.provider.create_video_task.return_value = {"id": "ark-tu-1", "status": "queued"}
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _submit(self):
|
||||
from apps.ai.services import submit_video_segment
|
||||
|
||||
submit_video_segment(video_segment=self.segment, user=self.user, prompt="测试")
|
||||
return AITask.objects.get(team=self.team, task_type=AITask.Type.VIDEO_SEGMENT)
|
||||
|
||||
def test_submit_prices_by_tokens_with_buffer(self):
|
||||
from apps.billing.pricing import quote_video_estimate, video_reserve_amount
|
||||
|
||||
task = self._submit()
|
||||
model = task.model_config
|
||||
tokens, quote = quote_video_estimate(model, aspect_ratio="9:16", resolution="720p", duration=15, references=[])
|
||||
self.assertEqual(task.estimated_cost, quote.points)
|
||||
self.assertEqual(task.base_cost, quote.base_cost_yuan)
|
||||
self.assertGreater(quote.points, 100) # 告别 ¥1/段:15s 720p 竖屏应是三位数积分
|
||||
self.assertEqual(task.credit_reservation.amount, video_reserve_amount(quote.points))
|
||||
self.assertEqual(task.request_payload["estimated_tokens"], tokens)
|
||||
|
||||
@patch("apps.ai.services._store_generated_media")
|
||||
def test_poll_settles_by_actual_usage_tokens(self, store):
|
||||
from decimal import Decimal
|
||||
|
||||
from apps.ai.services import poll_video_segment
|
||||
from apps.billing.pricing import quote_video_actual
|
||||
|
||||
task = self._submit()
|
||||
store.return_value = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="clip",
|
||||
asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
self.provider.poll_video_task.return_value = {
|
||||
"status": "succeeded",
|
||||
"usage": {"total_tokens": 300000},
|
||||
"content": {"video_url": "http://x/v.mp4"},
|
||||
}
|
||||
self.provider.extract_first_media_url.return_value = "http://x/v.mp4"
|
||||
poll_video_segment(video_segment=self.segment, user=self.user)
|
||||
task.refresh_from_db()
|
||||
settle = quote_video_actual(task.model_config, tokens=300000, with_video_ref=False, resolution="720p")
|
||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||
self.assertEqual(task.actual_cost, settle.points)
|
||||
self.assertEqual(task.base_cost, settle.base_cost_yuan)
|
||||
# 真实 tokens(30万) < 预估(32.4万):实扣 < 预留,charge 自动 RELEASE 差额,冻结清零
|
||||
account = CreditAccount.objects.get(team=self.team)
|
||||
self.assertEqual(account.reserved_balance, Decimal("0"))
|
||||
self.assertEqual(account.balance, Decimal("10000.0000") - settle.points)
|
||||
charges = CreditLedger.objects.filter(task=task, ledger_type=CreditLedger.Type.CHARGE)
|
||||
self.assertEqual(charges.count(), 1)
|
||||
self.assertEqual(charges.first().amount, settle.points)
|
||||
|
||||
@patch("apps.ai.services._store_generated_media")
|
||||
def test_poll_falls_back_to_estimate_when_usage_missing(self, store):
|
||||
from apps.ai.services import poll_video_segment
|
||||
|
||||
task = self._submit()
|
||||
store.return_value = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="clip2",
|
||||
asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
self.provider.poll_video_task.return_value = {"status": "succeeded", "content": {"video_url": "http://x/v.mp4"}}
|
||||
self.provider.extract_first_media_url.return_value = "http://x/v.mp4"
|
||||
poll_video_segment(video_segment=self.segment, user=self.user)
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(task.actual_cost, task.estimated_cost) # usage 缺失回落预估,不阻断出片
|
||||
|
||||
Reference in New Issue
Block a user