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
|
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
# 临时:视频(Seedance)借 AirDrama 火山账号 ARK key,与真人素材库审核同账号 → asset:// 可解析。待自有账号开通素材库后删此行。
|
# 临时:视频(Seedance)借 AirDrama 火山账号 ARK key,与真人素材库审核同账号 → asset:// 可解析。待自有账号开通素材库后删此行。
|
||||||
VIDEO_ARK_API_KEY=9225161a-a640-47e6-94ed-81f6c5610072
|
VIDEO_ARK_API_KEY=9225161a-a640-47e6-94ed-81f6c5610072
|
||||||
DEFAULT_TRIAL_CREDITS=1000.0000
|
DEFAULT_TRIAL_CREDITS=0
|
||||||
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
|
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
|
||||||
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
|
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
|
||||||
# YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度)
|
# YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度)
|
||||||
|
|||||||
@@ -243,4 +243,5 @@ ASSETS_API = {
|
|||||||
"project_name": env("ASSETS_API_PROJECT_NAME", "int_dev_Airlabs"),
|
"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=不限 不同:
|
# 团队级月限额(超管在团队页设置,自然月重置)。语义三态,与成员级 0=不限 不同:
|
||||||
# None = 未设置(前端按成员月度额度累加作团队月限额)· -1 = 不限 · >=0 = 固定上限。
|
# None = 未设置(前端按成员月度额度累加作团队月限额)· -1 = 不限 · >=0 = 固定上限。
|
||||||
monthly_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, default=None)
|
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:
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@@ -54,9 +54,15 @@ class AuthApiTests(TestCase):
|
|||||||
account = CreditAccount.objects.get(team=team)
|
account = CreditAccount.objects.get(team=team)
|
||||||
self.assertEqual(account.balance, trial)
|
self.assertEqual(account.balance, trial)
|
||||||
genesis = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.RECHARGE)
|
genesis = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.RECHARGE)
|
||||||
self.assertEqual(genesis.count(), 1)
|
if trial > 0:
|
||||||
self.assertEqual(genesis.first().amount, trial)
|
# 若运营期把赠送额度调回 >0,genesis 凭证契约必须成立(I8)
|
||||||
self.assertEqual(genesis.first().balance_after, trial) # 流水终点 == 账户余额,可对账
|
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):
|
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())
|
r = self._register(APIClient(), "solo-owner", team_name="Solo", invite_code=make_create_team_code())
|
||||||
self.assertEqual(r.data.get("role"), "owner")
|
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(
|
res = self.owner_client.post(
|
||||||
"/api/auth/team/members/",
|
"/api/auth/team/members/",
|
||||||
{"username": "sub-a", "password": "strong-password", "role": "member"},
|
{"username": "sub-a", "password": "strong-password", "role": "member"},
|
||||||
@@ -158,7 +164,8 @@ class MemberQuotaAndRoleTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(res.status_code, 201, res.content)
|
self.assertEqual(res.status_code, 201, res.content)
|
||||||
member = TeamMember.objects.get(user__username="sub-a", team=self.team)
|
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):
|
def test_create_member_explicit_limit_respected(self):
|
||||||
res = self.owner_client.post(
|
res = self.owner_client.post(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from .serializers import (
|
|||||||
|
|
||||||
|
|
||||||
# 子账号默认月度限额(0=不限,只留给主账号/超管)
|
# 子账号默认月度限额(0=不限,只留给主账号/超管)
|
||||||
DEFAULT_MEMBER_MONTHLY_LIMIT = 100
|
DEFAULT_MEMBER_MONTHLY_LIMIT = 1000
|
||||||
|
|
||||||
|
|
||||||
def member_role(user, team):
|
def member_role(user, team):
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class AdminTeamSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Team
|
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
|
read_only_fields = fields
|
||||||
|
|
||||||
def get_member_count(self, obj):
|
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)
|
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)
|
model_name = serializers.CharField(source="model_config.name", read_only=True, default=None)
|
||||||
cost_anomaly = serializers.SerializerMethodField()
|
cost_anomaly = serializers.SerializerMethodField()
|
||||||
|
# 单任务毛利(¥):actual_cost(积分)÷汇率 − base_cost。base_cost=0(成本未知)时 None,报表侧过滤
|
||||||
|
margin_yuan = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AITask
|
model = AITask
|
||||||
fields = [
|
fields = [
|
||||||
"id", "task_type", "status", "team", "team_name", "model_name",
|
"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
|
read_only_fields = fields
|
||||||
|
|
||||||
def get_cost_anomaly(self, obj) -> bool:
|
def get_cost_anomaly(self, obj) -> bool:
|
||||||
return is_cost_anomaly(obj.estimated_cost, obj.actual_cost)
|
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 AdminTaskDetailSerializer(AdminTaskSerializer):
|
||||||
class Meta(AdminTaskSerializer.Meta):
|
class Meta(AdminTaskSerializer.Meta):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from django.urls import path
|
|||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
admin_asset_reviews,
|
admin_asset_reviews,
|
||||||
|
admin_billing_config,
|
||||||
admin_asset_reviews_poll,
|
admin_asset_reviews_poll,
|
||||||
admin_asset_reviews_submit,
|
admin_asset_reviews_submit,
|
||||||
admin_invitations,
|
admin_invitations,
|
||||||
@@ -25,6 +26,7 @@ from .views import (
|
|||||||
admin_quality_words,
|
admin_quality_words,
|
||||||
admin_revoke_invitation,
|
admin_revoke_invitation,
|
||||||
admin_team_detail,
|
admin_team_detail,
|
||||||
|
admin_team_pricing,
|
||||||
admin_team_toggle,
|
admin_team_toggle,
|
||||||
admin_teams,
|
admin_teams,
|
||||||
admin_user_reset_password,
|
admin_user_reset_password,
|
||||||
@@ -39,6 +41,7 @@ urlpatterns = [
|
|||||||
path("teams/", admin_teams, name="admin-teams"),
|
path("teams/", admin_teams, name="admin-teams"),
|
||||||
path("teams/<uuid:team_id>/", admin_team_detail, name="admin-team-detail"),
|
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>/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/", admin_users, name="admin-users"),
|
||||||
path("users/<uuid:user_id>/toggle/", admin_user_toggle, name="admin-user-toggle"),
|
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"),
|
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("tasks/<uuid:task_id>/retry/", admin_task_retry, name="admin-task-retry"),
|
||||||
path("ledgers/", admin_ledgers, name="admin-ledgers"),
|
path("ledgers/", admin_ledgers, name="admin-ledgers"),
|
||||||
path("ledgers/adjust/", admin_ledger_adjust, name="admin-ledger-adjust"),
|
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/", admin_quota_policies, name="admin-quota-policies"),
|
||||||
path("quota-policies/<uuid:policy_id>/", admin_quota_policy_detail, name="admin-quota-policy-detail"),
|
path("quota-policies/<uuid:policy_id>/", admin_quota_policy_detail, name="admin-quota-policy-detail"),
|
||||||
path("providers/", admin_providers, name="admin-providers"),
|
path("providers/", admin_providers, name="admin-providers"),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
|
"""平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。"""
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
from django.db.models import Count, F, Q
|
from django.db.models import Count, F, Q
|
||||||
from rest_framework import status
|
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.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord
|
||||||
from apps.assets.models import Asset
|
from apps.assets.models import Asset
|
||||||
from apps.assets.review import poll_asset_review, submit_asset_for_review
|
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.billing.services.ledger import adjust_credit
|
||||||
from apps.common.pagination import DefaultPagination
|
from apps.common.pagination import DefaultPagination
|
||||||
from apps.products.models import Product
|
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"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsPlatformAdmin])
|
@permission_classes([IsPlatformAdmin])
|
||||||
def admin_users(request):
|
def admin_users(request):
|
||||||
@@ -496,6 +529,50 @@ def admin_ledger_adjust(request):
|
|||||||
return Response(AdminLedgerSerializer(ledger).data, status=status.HTTP_201_CREATED)
|
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"])
|
@api_view(["GET", "POST"])
|
||||||
@permission_classes([IsPlatformAdmin])
|
@permission_classes([IsPlatformAdmin])
|
||||||
def admin_quota_policies(request):
|
def admin_quota_policies(request):
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from django.conf import settings
|
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.models import Asset, AssetFile, FreeAsset, FreeAssetGroup
|
||||||
from apps.assets.storage import TosStorage
|
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 apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
|
||||||
|
|
||||||
from .models import AITask, ModelConfig
|
from .models import AITask, ModelConfig
|
||||||
from .providers.volcano import VolcanoArkProvider
|
from .providers.volcano import VolcanoArkProvider
|
||||||
from .video_errors import map_video_error, parse_provider_error
|
from .video_errors import map_video_error, parse_provider_error
|
||||||
from .video_pricing import (
|
from .video_pricing import get_resolution
|
||||||
RESERVE_BUFFER,
|
|
||||||
estimate_video_cost,
|
|
||||||
get_resolution,
|
|
||||||
tokens_to_cost,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
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,
|
model_config,
|
||||||
aspect_ratio=aspect_ratio,
|
aspect_ratio=aspect_ratio,
|
||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
references=built["snapshots"],
|
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 = {
|
request_payload = {
|
||||||
"feature": "free_video",
|
"feature": "free_video",
|
||||||
@@ -395,6 +392,9 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
|||||||
"generate_audio": generate_audio,
|
"generate_audio": generate_audio,
|
||||||
"search_mode": search_mode,
|
"search_mode": search_mode,
|
||||||
"estimated_tokens": tokens,
|
"estimated_tokens": tokens,
|
||||||
|
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务
|
||||||
|
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
||||||
|
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
|
||||||
"references": built["snapshots"],
|
"references": built["snapshots"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +409,8 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
|||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}",
|
idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}",
|
||||||
request_payload=request_payload,
|
request_payload=request_payload,
|
||||||
estimated_cost=cost,
|
estimated_cost=quote.points,
|
||||||
|
base_cost=quote.base_cost_yuan,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
reserve_credit(team=team, user=user, task=task, amount=reserve_amount)
|
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 [])
|
with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or [])
|
||||||
resolution = payload.get("resolution") or "720p"
|
resolution = payload.get("resolution") or "720p"
|
||||||
if total_tokens > 0:
|
if total_tokens > 0:
|
||||||
actual = tokens_to_cost(
|
from decimal import Decimal
|
||||||
locked.model_config, total_tokens, with_video_ref=with_video_ref, resolution=resolution
|
|
||||||
|
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
|
payload["actual_tokens"] = total_tokens
|
||||||
|
if settle.meta.get("rate"):
|
||||||
|
payload["points_per_yuan_snapshot"] = settle.meta["rate"]
|
||||||
else:
|
else:
|
||||||
actual = locked.estimated_cost
|
actual, base_cost = locked.estimated_cost, locked.base_cost
|
||||||
seed_out = response.get("seed")
|
seed_out = response.get("seed")
|
||||||
if seed_out is not None:
|
if seed_out is not None:
|
||||||
payload["seed_used"] = seed_out
|
payload["seed_used"] = seed_out
|
||||||
@@ -635,7 +642,7 @@ def finalize_free_video(*, task: AITask) -> AITask:
|
|||||||
return locked
|
return locked
|
||||||
reservation = locked.credit_reservation
|
reservation = locked.credit_reservation
|
||||||
if actual > reservation.amount:
|
if actual > reservation.amount:
|
||||||
# ledger 禁超预留扣费 → clamp 到预留额,差额平台承担并告警(长期观测调 RESERVE_BUFFER)
|
# ledger 禁超预留扣费 → clamp 到预留额,差额平台承担并告警(长期观测调 buffer)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"free video task %s actual cost %s exceeds reserved %s, clamped",
|
"free video task %s actual cost %s exceeds reserved %s, clamped",
|
||||||
locked.id, actual, reservation.amount,
|
locked.id, actual, reservation.amount,
|
||||||
@@ -643,11 +650,12 @@ def finalize_free_video(*, task: AITask) -> AITask:
|
|||||||
actual = reservation.amount
|
actual = reservation.amount
|
||||||
locked.status = AITask.Status.SUCCEEDED
|
locked.status = AITask.Status.SUCCEEDED
|
||||||
locked.actual_cost = actual
|
locked.actual_cost = actual
|
||||||
|
locked.base_cost = base_cost
|
||||||
locked.request_payload = payload
|
locked.request_payload = payload
|
||||||
locked.response_payload = response
|
locked.response_payload = response
|
||||||
locked.completed_at = timezone.now()
|
locked.completed_at = timezone.now()
|
||||||
locked.save(
|
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)
|
charge_reserved_credit(reservation=reservation, actual_amount=actual)
|
||||||
return locked
|
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)
|
provider_task_id = models.CharField(max_length=255, blank=True)
|
||||||
request_payload = models.JSONField(default=dict, blank=True)
|
request_payload = models.JSONField(default=dict, blank=True)
|
||||||
response_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)
|
estimated_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
|
||||||
actual_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_code = models.CharField(max_length=64, blank=True)
|
||||||
error_message = models.TextField(blank=True)
|
error_message = models.TextField(blank=True)
|
||||||
submitted_at = models.DateTimeField(null=True, 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)
|
return build_provider(model_config)
|
||||||
|
|
||||||
|
|
||||||
def estimate_cost(model_config: ModelConfig) -> Decimal:
|
# estimate_cost() 已退役:全平台定价统一走 apps/billing/pricing.py 计价引擎(积分制)。
|
||||||
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
|
# flat 类型默认价由 create_ai_task 内 quote_flat 提供;视频/配音各入口自带 quote。
|
||||||
|
|
||||||
|
|
||||||
def parse_segment_fields(block: str) -> tuple[str, str]:
|
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
|
@transaction.atomic
|
||||||
def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig, request_payload: dict) -> AITask:
|
def create_ai_task(
|
||||||
cost = estimate_cost(model_config)
|
*,
|
||||||
|
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(
|
task = AITask.objects.create(
|
||||||
team=project.team,
|
team=project.team,
|
||||||
created_by=user,
|
created_by=user,
|
||||||
@@ -640,9 +660,10 @@ def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig,
|
|||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
idempotency_key=f"{task_type}:{project.id}:{uuid.uuid4()}",
|
idempotency_key=f"{task_type}:{project.id}:{uuid.uuid4()}",
|
||||||
request_payload=request_payload,
|
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.status = AITask.Status.RESERVED
|
||||||
task.save(update_fields=["status", "updated_at"])
|
task.save(update_fields=["status", "updated_at"])
|
||||||
return task
|
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]
|
reference_images = [r["url"] for r in refs]
|
||||||
final_prompt = build_video_segment_prompt(project, video_segment, scene, refs, prompt)
|
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(
|
task = create_ai_task(
|
||||||
project=project,
|
project=project,
|
||||||
user=user,
|
user=user,
|
||||||
task_type=AITask.Type.VIDEO_SEGMENT,
|
task_type=AITask.Type.VIDEO_SEGMENT,
|
||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
|
quote=quote,
|
||||||
|
reserve_amount=video_reserve_amount(quote.points),
|
||||||
request_payload={
|
request_payload={
|
||||||
"model": model_config.name,
|
"model": model_config.name,
|
||||||
"endpoint": model_config.endpoint,
|
"endpoint": model_config.endpoint,
|
||||||
"prompt": final_prompt,
|
"prompt": final_prompt,
|
||||||
"duration": video_segment.target_duration_seconds,
|
"duration": video_segment.target_duration_seconds,
|
||||||
"ratio": "9:16",
|
"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),
|
"video_segment_id": str(video_segment.id),
|
||||||
"reference_images": reference_images,
|
"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()
|
existing = video_segment.versions.filter(task=locked_task).order_by("-created_at").first()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
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.status = AITask.Status.SUCCEEDED
|
||||||
locked_task.response_payload = response
|
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.completed_at = timezone.now()
|
||||||
locked_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
locked_task.save(update_fields=["status", "request_payload", "response_payload", "actual_cost", "base_cost", "completed_at", "updated_at"])
|
||||||
charge_reserved_credit(reservation=locked_task.credit_reservation, actual_amount=locked_task.actual_cost)
|
charge_reserved_credit(reservation=reservation, actual_amount=actual_points)
|
||||||
version = VideoSegmentVersion.objects.create(
|
version = VideoSegmentVersion.objects.create(
|
||||||
video_segment=video_segment,
|
video_segment=video_segment,
|
||||||
task=locked_task,
|
task=locked_task,
|
||||||
@@ -2452,13 +2524,17 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
|||||||
# 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。
|
# 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。
|
||||||
platform_key = str(platform_id or "").strip() if mode == "cover" else ""
|
platform_key = str(platform_id or "").strip() if mode == "cover" else ""
|
||||||
platform_name = _PLATFORM_NAMES.get(platform_key, "")
|
platform_name = _PLATFORM_NAMES.get(platform_key, "")
|
||||||
|
from apps.billing.pricing import quote_flat
|
||||||
|
|
||||||
tasks: list[AITask] = []
|
tasks: list[AITask] = []
|
||||||
for index in range(count):
|
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}
|
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" 字符串也是真值,会误判
|
# 只在重跑/补图时落键(不落 False):workbench 用 KeyTextTransform 抽文本,"false" 字符串也是真值,会误判
|
||||||
if is_append:
|
if is_append:
|
||||||
request_payload["batch_append"] = True
|
request_payload["batch_append"] = True
|
||||||
|
if quote.meta.get("rate"):
|
||||||
|
request_payload["points_per_yuan_snapshot"] = quote.meta["rate"]
|
||||||
task = AITask.objects.create(
|
task = AITask.objects.create(
|
||||||
team=team,
|
team=team,
|
||||||
created_by=user,
|
created_by=user,
|
||||||
@@ -2469,10 +2545,11 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
|||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
||||||
request_payload=request_payload,
|
request_payload=request_payload,
|
||||||
estimated_cost=cost,
|
estimated_cost=quote.points,
|
||||||
|
base_cost=quote.base_cost_yuan,
|
||||||
)
|
)
|
||||||
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
# 预留额度若余额不足会抛 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.status = AITask.Status.RESERVED
|
||||||
task.save(update_fields=["status", "updated_at"])
|
task.save(update_fields=["status", "updated_at"])
|
||||||
tasks.append(task)
|
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)
|
model_config = get_default_model(ModelConfig.Capability.AUDIO)
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
raise ValueError("no active audio model configured")
|
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(
|
task = create_ai_task(
|
||||||
project=project,
|
project=project,
|
||||||
user=user,
|
user=user,
|
||||||
task_type=AITask.Type.VOICEOVER,
|
task_type=AITask.Type.VOICEOVER,
|
||||||
model_config=model_config,
|
model_config=model_config,
|
||||||
|
quote=quote,
|
||||||
request_payload={
|
request_payload={
|
||||||
"voice_type": voice_type,
|
"voice_type": voice_type,
|
||||||
"speed_ratio": float(speed_ratio or 1.0),
|
"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],
|
"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,
|
get_token_price,
|
||||||
)
|
)
|
||||||
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
|
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"
|
STANDARD = "doubao-seedance-2-0-260128"
|
||||||
FAST = "doubao-seedance-2-0-fast-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())
|
task = submit_free_video(team=self.team, user=self.user, params=self._params())
|
||||||
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||||
self.assertEqual(task.provider_task_id, "ark-1")
|
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=[]
|
_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)
|
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
|
# 提交参数按契约落 payload
|
||||||
self.assertEqual(task.request_payload["estimated_tokens"], tokens)
|
self.assertEqual(task.request_payload["estimated_tokens"], tokens)
|
||||||
self.assertEqual(task.request_payload["feature"], "free_video")
|
self.assertEqual(task.request_payload["feature"], "free_video")
|
||||||
@@ -284,13 +288,14 @@ class FinalizeFreeVideoTests(TestCase):
|
|||||||
}
|
}
|
||||||
task = finalize_free_video(task=self.task)
|
task = finalize_free_video(task=self.task)
|
||||||
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
|
||||||
expected = calculate_cost(30000, Decimal("46"))
|
settle = quote_video_actual(_model(STANDARD), tokens=30000, with_video_ref=False, resolution="480p")
|
||||||
self.assertEqual(task.actual_cost, expected)
|
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)
|
self.assertEqual(task.request_payload["seed_used"], 42)
|
||||||
reservation = CreditReservation.objects.get(task=task)
|
reservation = CreditReservation.objects.get(task=task)
|
||||||
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
|
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
|
||||||
account = CreditAccount.objects.get(team=self.team)
|
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.assertEqual(account.reserved_balance, Decimal("0"))
|
||||||
self.store.assert_called_once()
|
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)
|
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):
|
class QuotaPolicy(TimeStampedModel):
|
||||||
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="quota_policies")
|
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")
|
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)
|
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
charged = _charged_since(day_start)
|
charged = _charged_since(day_start)
|
||||||
if charged + reserved + amount > day_limit:
|
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")
|
month_limit = member.monthly_credit_limit or Decimal("0")
|
||||||
if month_limit > 0:
|
if month_limit > 0:
|
||||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
charged = _charged_since(month_start)
|
charged = _charged_since(month_start)
|
||||||
if charged + reserved + amount > month_limit:
|
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")
|
total_limit = member.total_credit_limit or Decimal("0")
|
||||||
if total_limit > 0:
|
if total_limit > 0:
|
||||||
charged = _charged_since(None)
|
charged = _charged_since(None)
|
||||||
if charged + reserved + amount > total_limit:
|
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:
|
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:
|
if policy is None:
|
||||||
return
|
return
|
||||||
if policy.per_task_limit is not None and amount > policy.per_task_limit:
|
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()
|
now = timezone.now()
|
||||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
if policy.monthly_limit is not None:
|
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")
|
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||||
)
|
)
|
||||||
if charged + reserved + amount > policy.monthly_limit:
|
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:
|
if policy.project_limit is not None and project is not None:
|
||||||
p_charged = (
|
p_charged = (
|
||||||
CreditLedger.objects.filter(team=team, project=project, ledger_type=CreditLedger.Type.CHARGE)
|
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")
|
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||||
)
|
)
|
||||||
if p_charged + p_reserved + amount > policy.project_limit:
|
if p_charged + p_reserved + amount > policy.project_limit:
|
||||||
raise ValueError(f"项目额度超限:上限 ¥{policy.project_limit}")
|
raise ValueError(f"项目额度超限:上限 {policy.project_limit} 积分")
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@@ -116,6 +140,7 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
|
|||||||
if available < amount:
|
if available < amount:
|
||||||
raise ValueError("insufficient credit")
|
raise ValueError("insufficient credit")
|
||||||
_enforce_member_monthly_limit(team=team, user=user, amount=amount)
|
_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)
|
_enforce_quota_policy(team=team, project=task.project, amount=amount)
|
||||||
|
|
||||||
account.reserved_balance += amount
|
account.reserved_balance += amount
|
||||||
|
|||||||
@@ -190,8 +190,10 @@ class RechargePermissionTests(TestCase):
|
|||||||
client.force_authenticate(self.owner)
|
client.force_authenticate(self.owner)
|
||||||
response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json")
|
response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json")
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 201)
|
||||||
|
# 积分制:支付 ¥100 × 10 = 到账 1000 积分;响应带换算快照
|
||||||
|
self.assertEqual(response.json()["credited_points"], "1000")
|
||||||
account = CreditAccount.objects.get(team=self.team)
|
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):
|
def test_member_cannot_recharge(self):
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
@@ -201,3 +203,287 @@ class RechargePermissionTests(TestCase):
|
|||||||
account = CreditAccount.objects.get(team=self.team)
|
account = CreditAccount.objects.get(team=self.team)
|
||||||
self.assertEqual(account.balance, Decimal("0.0000")) # 余额未变,越权被拦
|
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 django.urls import path
|
||||||
|
|
||||||
from .views import ledgers, recharge, summary, trend
|
from .views import config, ledgers, recharge, summary, trend
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("summary/", summary, name="billing-summary"),
|
path("summary/", summary, name="billing-summary"),
|
||||||
path("ledgers/", ledgers, name="billing-ledgers"),
|
path("ledgers/", ledgers, name="billing-ledgers"),
|
||||||
path("recharge/", recharge, name="billing-recharge"),
|
path("recharge/", recharge, name="billing-recharge"),
|
||||||
path("trend/", trend, name="billing-trend"),
|
path("trend/", trend, name="billing-trend"),
|
||||||
|
path("config/", config, name="billing-config"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import timedelta
|
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 import transaction
|
||||||
from django.db.models import Sum
|
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 apps.common.api import can_manage_team, get_current_team
|
||||||
|
|
||||||
from .models import CreditAccount, CreditLedger
|
from .models import CreditAccount, CreditLedger
|
||||||
|
from .pricing import get_billing_config, team_price_multiplier
|
||||||
from .serializers import CreditAccountSerializer, CreditLedgerSerializer
|
from .serializers import CreditAccountSerializer, CreditLedgerSerializer
|
||||||
|
|
||||||
# AITask.task_type → 账户页「按阶段分布」的 4 个聚合桶
|
# AITask.task_type → 账户页「按阶段分布」的 4 个聚合桶
|
||||||
_STAGE_BUCKET = {
|
_STAGE_BUCKET = {
|
||||||
AITask.Type.SCRIPT_GENERATION: "script",
|
AITask.Type.SCRIPT_GENERATION: "script",
|
||||||
AITask.Type.SCRIPT_OPTIMIZATION: "script",
|
AITask.Type.SCRIPT_OPTIMIZATION: "script",
|
||||||
|
AITask.Type.ENTITY_EXTRACTION: "script",
|
||||||
AITask.Type.PRODUCT_IMAGE: "base",
|
AITask.Type.PRODUCT_IMAGE: "base",
|
||||||
AITask.Type.PERSON_IMAGE: "base",
|
AITask.Type.PERSON_IMAGE: "base",
|
||||||
AITask.Type.SCENE_IMAGE: "base",
|
AITask.Type.SCENE_IMAGE: "base",
|
||||||
AITask.Type.STORYBOARD: "storyboard",
|
AITask.Type.STORYBOARD: "storyboard",
|
||||||
AITask.Type.VIDEO_SEGMENT: "video",
|
AITask.Type.VIDEO_SEGMENT: "video",
|
||||||
|
AITask.Type.VOICEOVER: "video",
|
||||||
|
AITask.Type.FREE_VIDEO: "video",
|
||||||
AITask.Type.EXPORT: "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"])
|
@api_view(["POST"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def recharge(request):
|
def recharge(request):
|
||||||
|
"""充值:amount 是真实支付人民币(¥),到账积分 = ¥ × points_per_yuan + bonus_points(赠送积分)。
|
||||||
|
metadata 快照 paid_amount(¥)与当时汇率——老流水的支付金额只能从这里读,不可拿 amount÷rate 反推。"""
|
||||||
team = get_current_team(request.user)
|
team = get_current_team(request.user)
|
||||||
# 充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客一律拒绝
|
# 充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客一律拒绝
|
||||||
if not can_manage_team(request.user, team):
|
if not can_manage_team(request.user, team):
|
||||||
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
|
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
try:
|
try:
|
||||||
amount = Decimal(str(request.data.get("amount", "0")))
|
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):
|
except (InvalidOperation, TypeError):
|
||||||
return Response({"detail": "invalid amount"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"detail": "invalid amount"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
if amount <= 0:
|
# Decimal("NaN")/("Infinity") 构造不抛,后续比较/入库才炸 500 → 显式拦(review 确认)
|
||||||
return Response({"detail": "amount must be positive"}, status=status.HTTP_400_BAD_REQUEST)
|
if not amount.is_finite() or not bonus_points.is_finite():
|
||||||
if bonus < 0:
|
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)
|
return Response({"detail": "bonus cannot be negative"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
channel = str(request.data.get("channel") or "manual")[:32]
|
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():
|
with transaction.atomic():
|
||||||
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
|
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
|
||||||
account.balance += credited
|
account.balance += credited
|
||||||
@@ -119,12 +158,19 @@ def recharge(request):
|
|||||||
amount=credited,
|
amount=credited,
|
||||||
balance_after=account.balance,
|
balance_after=account.balance,
|
||||||
reason="团队充值",
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"account": CreditAccountSerializer(account).data,
|
"account": CreditAccountSerializer(account).data,
|
||||||
"ledger": CreditLedgerSerializer(ledger).data,
|
"ledger": CreditLedgerSerializer(ledger).data,
|
||||||
|
"credited_points": str(credited),
|
||||||
|
"points_per_yuan": str(rate),
|
||||||
},
|
},
|
||||||
status=status.HTTP_201_CREATED,
|
status=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class Project(TeamOwnedModel):
|
|||||||
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="projects")
|
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="projects")
|
||||||
status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT)
|
status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT)
|
||||||
current_stage = models.CharField(max_length=32, default="script")
|
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)
|
budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||||
failure_reason = models.TextField(blank=True)
|
failure_reason = models.TextField(blank=True)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|||||||
@@ -1030,3 +1030,87 @@ class AttachOfficialModelTests(TestCase):
|
|||||||
self.assertEqual((group.metadata or {}).get("adopt"), "adopted") # 自动采用
|
self.assertEqual((group.metadata or {}).get("adopt"), "adopted") # 自动采用
|
||||||
sub.assert_called_once() # 自动送审(on_commit 在 TestCase 事务提交点触发)
|
sub.assert_called_once() # 自动送审(on_commit 在 TestCase 事务提交点触发)
|
||||||
self.assertEqual(sub.call_args.args[0].id, mine.id)
|
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 缺失回落预估,不阻断出片
|
||||||
|
|||||||
@@ -850,7 +850,7 @@ export function App() {
|
|||||||
billing={billing}
|
billing={billing}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
team={currentTeam}
|
team={currentTeam}
|
||||||
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
|
onRecharge={(amount, bonusPoints) => action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "team":
|
case "team":
|
||||||
@@ -864,7 +864,7 @@ export function App() {
|
|||||||
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
|
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
|
||||||
onRemoveMember={(id) => action(() => api.removeTeamMember(id), "成员已移除")}
|
onRemoveMember={(id) => action(() => api.removeTeamMember(id), "成员已移除")}
|
||||||
onResetPassword={(id, password) => action(() => api.resetMemberPassword(id, password), "密码已重置")}
|
onResetPassword={(id, password) => action(() => api.resetMemberPassword(id, password), "密码已重置")}
|
||||||
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
|
onRecharge={(amount, bonusPoints) => action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "messages":
|
case "messages":
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
AITask,
|
AITask,
|
||||||
Asset,
|
Asset,
|
||||||
AuthPayload,
|
AuthPayload,
|
||||||
|
BillingConfigInfo,
|
||||||
BillingSummary,
|
BillingSummary,
|
||||||
BillingTrend,
|
BillingTrend,
|
||||||
FreeAssetGroup,
|
FreeAssetGroup,
|
||||||
@@ -726,9 +727,14 @@ export const api = {
|
|||||||
pollFreeAsset(id: string) {
|
pollFreeAsset(id: string) {
|
||||||
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/poll/`, { method: "POST" });
|
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/poll/`, { method: "POST" });
|
||||||
},
|
},
|
||||||
recharge(payload: { amount: number | string; bonus?: number | string; channel?: string }) {
|
recharge(payload: { amount: number | string; bonus_points?: number | string; channel?: string }) {
|
||||||
|
// amount 是真实支付 ¥,到账积分 = ¥ × points_per_yuan + bonus_points(赠送积分)
|
||||||
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
|
return request<RechargeResult>("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
|
// 计费公共配置(积分汇率/视频毛利/预留 buffer):所有前端预估统一读这里,与后端计价引擎同源
|
||||||
|
billingConfig() {
|
||||||
|
return request<BillingConfigInfo>("/api/billing/config/");
|
||||||
|
},
|
||||||
// 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来)
|
// 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来)
|
||||||
listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number }) {
|
listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number }) {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
@@ -794,6 +800,9 @@ export const adminApi = {
|
|||||||
teamDetail(id: string) {
|
teamDetail(id: string) {
|
||||||
return request<AdminTeamDetail>(`/api/admin/teams/${id}/`);
|
return request<AdminTeamDetail>(`/api/admin/teams/${id}/`);
|
||||||
},
|
},
|
||||||
|
setTeamPricing(id: string, priceMultiplier: string) {
|
||||||
|
return request<AdminTeam>(`/api/admin/teams/${id}/pricing/`, { method: "POST", body: JSON.stringify({ price_multiplier: priceMultiplier }) });
|
||||||
|
},
|
||||||
toggleTeam(id: string) {
|
toggleTeam(id: string) {
|
||||||
return request<AdminTeam>(`/api/admin/teams/${id}/toggle/`, { method: "POST" });
|
return request<AdminTeam>(`/api/admin/teams/${id}/toggle/`, { method: "POST" });
|
||||||
},
|
},
|
||||||
@@ -878,10 +887,16 @@ export const adminApi = {
|
|||||||
const q = qs.toString();
|
const q = qs.toString();
|
||||||
return request<Paginated<AdminLedger>>(`/api/admin/ledgers/${q ? `?${q}` : ""}`);
|
return request<Paginated<AdminLedger>>(`/api/admin/ledgers/${q ? `?${q}` : ""}`);
|
||||||
},
|
},
|
||||||
|
billingConfig() {
|
||||||
|
return request<BillingConfigInfo & { updated_at: string }>("/api/admin/billing-config/");
|
||||||
|
},
|
||||||
|
updateBillingConfig(payload: { points_per_yuan?: string; video_margin_multiplier?: string; video_reserve_buffer?: string }) {
|
||||||
|
return request<BillingConfigInfo & { updated_at: string }>("/api/admin/billing-config/", { method: "PATCH", body: JSON.stringify(payload) });
|
||||||
|
},
|
||||||
adjustCredit(payload: { team: string; amount: string; reason: string }) {
|
adjustCredit(payload: { team: string; amount: string; reason: string }) {
|
||||||
return request<AdminLedger>("/api/admin/ledgers/adjust/", { method: "POST", body: JSON.stringify(payload) });
|
return request<AdminLedger>("/api/admin/ledgers/adjust/", { method: "POST", body: JSON.stringify(payload) });
|
||||||
},
|
},
|
||||||
quotaPolicies(params?: { team?: string; page_size?: number }) {
|
quotaPolicies(params?: { team?: string; page?: number; page_size?: number }) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params?.team) qs.set("team", params.team);
|
if (params?.team) qs.set("team", params.team);
|
||||||
if (params?.page_size) qs.set("page_size", String(params.page_size));
|
if (params?.page_size) qs.set("page_size", String(params.page_size));
|
||||||
|
|||||||
@@ -83,17 +83,34 @@ export function tokenPrice(config: ModelConfig | undefined, resolution: string,
|
|||||||
return (hasVideoRef ? tier.with_ref_video : tier.no_ref_video) || 0;
|
return (hasVideoRef ? tier.with_ref_video : tier.no_ref_video) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 积分制:与后端 apps/billing/pricing.py 取整规则逐字一致 ——
|
||||||
|
// ¥成本先 round 到 0.01 → ×毛利系数 ×积分汇率 → HALF_UP 取整积分,最低 1。
|
||||||
|
// billing(毛利/汇率)来自 GET /api/billing/config/,与后端计价引擎同源;未加载时回退首发默认值。
|
||||||
|
export type BillingRates = { margin: number; rate: number; multiplier: number };
|
||||||
|
export const DEFAULT_BILLING_RATES: BillingRates = { margin: 1.5, rate: 10, multiplier: 1 };
|
||||||
|
|
||||||
export function estimateCost(
|
export function estimateCost(
|
||||||
config: ModelConfig | undefined,
|
config: ModelConfig | undefined,
|
||||||
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] }
|
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] },
|
||||||
): { tokens: number; cost: number } {
|
billing: BillingRates = DEFAULT_BILLING_RATES
|
||||||
|
): { tokens: number; points: number } {
|
||||||
const inputVideoSeconds = params.refs
|
const inputVideoSeconds = params.refs
|
||||||
.filter((r) => r.type === "video")
|
.filter((r) => r.type === "video")
|
||||||
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||||||
const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds);
|
const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds);
|
||||||
const hasVideoRef = params.refs.some((r) => r.type === "video");
|
const hasVideoRef = params.refs.some((r) => r.type === "video");
|
||||||
const price = tokenPrice(config, params.resolution, hasVideoRef);
|
const price = tokenPrice(config, params.resolution, hasVideoRef);
|
||||||
return { tokens, cost: Math.round(((tokens * price) / 1e6) * 100) / 100 };
|
const costYuan = Math.round(((tokens * price) / 1e6) * 100) / 100;
|
||||||
|
if (costYuan <= 0) return { tokens, points: 0 };
|
||||||
|
// 浮点乘积在 .5 边界可能落成 27.499999…,先吸附 6 位小数再 round,
|
||||||
|
// 与后端 Decimal ROUND_HALF_UP 逐字对齐。只吸浮点噪声(1e-9 级),
|
||||||
|
// 不能用 toFixed(2):非默认 margin 下 x.495 会被先四舍五入成 x.50 多显 1 积分(review 确认)
|
||||||
|
const raw = Number((costYuan * billing.margin * billing.rate).toFixed(6));
|
||||||
|
const listPoints = Math.max(1, Math.round(raw));
|
||||||
|
// 团队价格系数(差异化调价):两步取整与后端 apply_team_price 逐字对齐(先挂牌取整,再乘系数取整)
|
||||||
|
const multiplier = billing.multiplier || 1;
|
||||||
|
const points = multiplier === 1 ? listPoints : Math.max(1, Math.round(Number((listPoints * multiplier).toFixed(6))));
|
||||||
|
return { tokens, points };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function modelLabel(name: string): string {
|
export function modelLabel(name: string): string {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
|
|||||||
<div className="fc-card-prompt" title={task.prompt}>{task.prompt}</div>
|
<div className="fc-card-prompt" title={task.prompt}>{task.prompt}</div>
|
||||||
<div className="fc-card-sub mono">
|
<div className="fc-card-sub mono">
|
||||||
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.resolution.toUpperCase()} · {task.duration}s
|
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.resolution.toUpperCase()} · {task.duration}s
|
||||||
{task.status === "succeeded" && ` · ¥${Number(task.actual_cost || 0).toFixed(2)}`}
|
{task.status === "succeeded" && ` · ${Number(task.actual_cost || 0)} 积分`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useRef, useState, type RefObject } from "react";
|
import { useRef, useState, type RefObject } from "react";
|
||||||
import { IconKitSvg } from "../IconKitSvg";
|
import { IconKitSvg } from "../IconKitSvg";
|
||||||
import type { ModelConfig } from "../../types";
|
import type { ModelConfig } from "../../types";
|
||||||
import { MODE_LABELS, type FreeMode, type LocalRef } from "./constants";
|
import { MODE_LABELS, type BillingRates, type FreeMode, type LocalRef } from "./constants";
|
||||||
import { PromptInput, type PromptInputHandle } from "./prompt-input";
|
import { PromptInput, type PromptInputHandle } from "./prompt-input";
|
||||||
import { FreeToolbar } from "./toolbar";
|
import { FreeToolbar } from "./toolbar";
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ function KeyframeSlot({ role, item, onPick, onRemove }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
|
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
|
||||||
mode: FreeMode;
|
mode: FreeMode;
|
||||||
model: string;
|
model: string;
|
||||||
ratio: string;
|
ratio: string;
|
||||||
@@ -62,6 +62,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
|||||||
seed: number;
|
seed: number;
|
||||||
refs: LocalRef[];
|
refs: LocalRef[];
|
||||||
videoConfigs: ModelConfig[];
|
videoConfigs: ModelConfig[];
|
||||||
|
billingRates?: BillingRates;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
promptRef: RefObject<PromptInputHandle | null>;
|
promptRef: RefObject<PromptInputHandle | null>;
|
||||||
/** 用户选择/拖入文件;keyframe 模式下带目标 role */
|
/** 用户选择/拖入文件;keyframe 模式下带目标 role */
|
||||||
@@ -156,6 +157,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
|||||||
seed={seed}
|
seed={seed}
|
||||||
refs={refs}
|
refs={refs}
|
||||||
videoConfigs={videoConfigs}
|
videoConfigs={videoConfigs}
|
||||||
|
billingRates={billingRates}
|
||||||
hasPrompt={hasPrompt}
|
hasPrompt={hasPrompt}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
onModeChange={onModeChange}
|
onModeChange={onModeChange}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ModelConfig } from "../../types";
|
import type { ModelConfig } from "../../types";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_BILLING_RATES,
|
||||||
FC_DURATIONS,
|
FC_DURATIONS,
|
||||||
FC_MODELS,
|
FC_MODELS,
|
||||||
FC_RATIOS,
|
FC_RATIOS,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
FC_STANDARD_MODEL,
|
FC_STANDARD_MODEL,
|
||||||
MODE_LABELS,
|
MODE_LABELS,
|
||||||
estimateCost,
|
estimateCost,
|
||||||
|
type BillingRates,
|
||||||
type FreeMode,
|
type FreeMode,
|
||||||
type LocalRef
|
type LocalRef
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
@@ -63,7 +65,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
|
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
|
||||||
mode: FreeMode;
|
mode: FreeMode;
|
||||||
model: string;
|
model: string;
|
||||||
ratio: string;
|
ratio: string;
|
||||||
@@ -72,6 +74,7 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
|||||||
seed: number;
|
seed: number;
|
||||||
refs: LocalRef[];
|
refs: LocalRef[];
|
||||||
videoConfigs: ModelConfig[];
|
videoConfigs: ModelConfig[];
|
||||||
|
billingRates?: BillingRates;
|
||||||
hasPrompt: boolean;
|
hasPrompt: boolean;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
onModeChange: (mode: FreeMode) => void;
|
onModeChange: (mode: FreeMode) => void;
|
||||||
@@ -85,7 +88,7 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
|||||||
}) {
|
}) {
|
||||||
const isStandard = model === FC_STANDARD_MODEL;
|
const isStandard = model === FC_STANDARD_MODEL;
|
||||||
const config = videoConfigs.find((c) => c.name === model);
|
const config = videoConfigs.find((c) => c.name === model);
|
||||||
const { tokens, cost } = estimateCost(config, { ratio, resolution, duration, refs });
|
const { tokens, points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES);
|
||||||
const [seedOpen, setSeedOpen] = useState(false);
|
const [seedOpen, setSeedOpen] = useState(false);
|
||||||
const seedRef = useRef<HTMLDivElement>(null);
|
const seedRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -164,8 +167,8 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fc-toolbar-r">
|
<div className="fc-toolbar-r">
|
||||||
<span className="fc-estimate mono" title="预估消耗(实际按火山返回用量结算)">
|
<span className="fc-estimate mono" title="预估消耗(实际按真实用量结算,多退少不补超)">
|
||||||
≈ {tokens.toLocaleString()} tokens · ¥{cost.toFixed(2)}
|
≈ {tokens.toLocaleString()} tokens · {points.toLocaleString()} 积分
|
||||||
</span>
|
</span>
|
||||||
<button type="button" className="btn btn-sm btn-ghost" onClick={onClear}>清空</button>
|
<button type="button" className="btn btn-sm btn-ghost" onClick={onClear}>清空</button>
|
||||||
<button type="button" className="btn btn-sm btn-primary" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
|
<button type="button" className="btn btn-sm btn-primary" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function VideoDetailModal({ task, hasPrev, hasNext, onPrev, onNext, onClo
|
|||||||
<div className="fc-player-sub mono">
|
<div className="fc-player-sub mono">
|
||||||
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.aspect_ratio} · {task.resolution.toUpperCase()} · {task.duration}s
|
// {MODE_LABELS[task.mode] || task.mode} · {modelLabel(task.model)} · {task.aspect_ratio} · {task.resolution.toUpperCase()} · {task.duration}s
|
||||||
{task.seed_used != null && ` · seed ${task.seed_used}`}
|
{task.seed_used != null && ` · seed ${task.seed_used}`}
|
||||||
{` · ¥${Number(task.actual_cost || 0).toFixed(2)}`}
|
{` · ${Number(task.actual_cost || 0)} 积分`}
|
||||||
</div>
|
</div>
|
||||||
{task.fallback_note && <div className="fc-player-warn mono">// {task.fallback_note}</div>}
|
{task.fallback_note && <div className="fc-player-warn mono">// {task.fallback_note}</div>}
|
||||||
<div className="fc-player-actions">
|
<div className="fc-player-actions">
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
// onPageSizeChange 传则「每页 N 条」变成可点击循环按钮(默认循环 12/24/48/96)
|
// onPageSizeChange 传则「每页 N 条」变成可点击循环按钮(默认循环 12/24/48/96)
|
||||||
// pageSizeOptions 自定义每页条数候选
|
// pageSizeOptions 自定义每页条数候选
|
||||||
// sticky 传则分页器吸底浮动
|
// sticky 传则分页器吸底浮动
|
||||||
// total <= pageSize 且不可改每页条数时不渲染(单页无需分页器)。
|
// alwaysShow 单页也渲染(admin 列表用:让「共 N 条 · 每页 10 条」可见,否则数据少时像没接分页)
|
||||||
|
// 默认 total <= pageSize 且不可改每页条数时不渲染(单页无需分页器)。
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export function pageWindow(current: number, total: number): Array<number | "elli
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Pager({ page, total, pageSize, onChange, onPageSizeChange, pageSizeOptions = [12, 24, 48, 96], sticky = false }: {
|
export function Pager({ page, total, pageSize, onChange, onPageSizeChange, pageSizeOptions = [12, 24, 48, 96], sticky = false, alwaysShow = false }: {
|
||||||
page: number;
|
page: number;
|
||||||
total: number;
|
total: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -29,12 +30,13 @@ export function Pager({ page, total, pageSize, onChange, onPageSizeChange, pageS
|
|||||||
onPageSizeChange?: (size: number) => void;
|
onPageSizeChange?: (size: number) => void;
|
||||||
pageSizeOptions?: number[];
|
pageSizeOptions?: number[];
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
|
alwaysShow?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
const cur = Math.min(Math.max(1, page), totalPages);
|
const cur = Math.min(Math.max(1, page), totalPages);
|
||||||
const [jump, setJump] = useState("");
|
const [jump, setJump] = useState("");
|
||||||
// 单页且不可改每页条数 → 不渲染;可改条数时仍渲染(让用户能切回更小分页)
|
// 单页且不可改每页条数 → 不渲染;可改条数或 alwaysShow 时仍渲染
|
||||||
if (total <= pageSize && !onPageSizeChange) return null;
|
if (total <= pageSize && !onPageSizeChange && !alwaysShow) return null;
|
||||||
|
|
||||||
function cycleSize() {
|
function cycleSize() {
|
||||||
if (!onPageSizeChange) return;
|
if (!onPageSizeChange) return;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CreditCard, X } from "lucide-react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types";
|
import type { BillingSummary, BillingTrend, Ledger, Project, Team, TeamMember } from "../types";
|
||||||
import { money, stageMeta } from "./stage-config";
|
import { money, pts, stageMeta, yuan } from "./stage-config";
|
||||||
import { pageWindow } from "../components/pager";
|
import { pageWindow } from "../components/pager";
|
||||||
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
import { useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ const ROLE_PILL: Record<string, string> = { owner: "role-super", admin: "role-ad
|
|||||||
|
|
||||||
type TrendRange = "day" | "week" | "month";
|
type TrendRange = "day" | "week" | "month";
|
||||||
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
|
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
|
||||||
day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" },
|
day: { chip: "日", sub: "// 近 14 天 · 单位 积分", totalLabel: "14 天合计", avgLabel: "日均" },
|
||||||
week: { chip: "周", sub: "// 近 8 周 · 单位 ¥", totalLabel: "8 周合计", avgLabel: "周均" },
|
week: { chip: "周", sub: "// 近 8 周 · 单位 积分", totalLabel: "8 周合计", avgLabel: "周均" },
|
||||||
month: { chip: "月", sub: "// 近 6 个月 · 单位 ¥", totalLabel: "6 月合计", avgLabel: "月均" }
|
month: { chip: "月", sub: "// 近 6 个月 · 单位 积分", totalLabel: "6 月合计", avgLabel: "月均" }
|
||||||
};
|
};
|
||||||
|
|
||||||
type Tab = "overview" | "by-project" | "by-member" | "bills";
|
type Tab = "overview" | "by-project" | "by-member" | "bills";
|
||||||
@@ -52,11 +52,12 @@ const LEDGER_REASON_LABEL: Record<string, string> = {
|
|||||||
const ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t;
|
const ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t;
|
||||||
const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r;
|
const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r;
|
||||||
|
|
||||||
|
// 积分制:amt 是真实支付 ¥,到账积分 = ¥×10;bonusAmt 是**赠送积分**(等值维持原 ¥ 赠送 ×10)
|
||||||
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
|
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
|
||||||
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
|
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
|
||||||
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
|
{ amt: 500, gift: "+300 积分赠送", bonus: true, bonusAmt: 300, ribbon: "推荐" },
|
||||||
{ amt: 1000, gift: "+ ¥80 赠送", bonus: true, bonusAmt: 80 },
|
{ amt: 1000, gift: "+800 积分赠送", bonus: true, bonusAmt: 800 },
|
||||||
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true, bonusAmt: 300 }
|
{ amt: 3000, gift: "+3000 积分赠送", bonus: true, bonusAmt: 3000 }
|
||||||
];
|
];
|
||||||
|
|
||||||
const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
|
const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
|
||||||
@@ -68,11 +69,12 @@ const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_s
|
|||||||
|
|
||||||
// 充值扫码弹窗 · 复用设计系统 .modal-bg/.modal 外壳(QR 占位 + 金额 + 赠送 + 5 分钟有效 + 双按钮)
|
// 充值扫码弹窗 · 复用设计系统 .modal-bg/.modal 外壳(QR 占位 + 金额 + 赠送 + 5 分钟有效 + 双按钮)
|
||||||
// 关闭与进出场走 overlays 的 useOverlayTransition(ESC / 点遮罩关),完成支付回调交父级弹全局 toast。
|
// 关闭与进出场走 overlays 的 useOverlayTransition(ESC / 点遮罩关),完成支付回调交父级弹全局 toast。
|
||||||
function TopupModal({ open, channel, amount, bonus, close, onDone }: {
|
function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
channel: "wechat" | "alipay";
|
channel: "wechat" | "alipay";
|
||||||
amount: number;
|
amount: number;
|
||||||
bonus: number;
|
bonus: number;
|
||||||
|
rate: number; // 积分汇率(积分/¥),来自 /api/billing/config/,与后端到账口径同源
|
||||||
close: () => void;
|
close: () => void;
|
||||||
onDone: () => void | Promise<unknown>;
|
onDone: () => void | Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
@@ -93,12 +95,13 @@ function TopupModal({ open, channel, amount, bonus, close, onDone }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="modal-b">
|
<div className="modal-b">
|
||||||
<div className="topup-info">支付金额</div>
|
<div className="topup-info">支付金额</div>
|
||||||
<div className="topup-amt">{money(amount)}</div>
|
<div className="topup-amt">{yuan(amount)}</div>
|
||||||
<div className="topup-note">{bonus > 0 ? `// 含 ¥${bonus} 赠送 · 实到账 ${money(amount + bonus)}` : "// 无赠送"}</div>
|
<div className="topup-note">{`// 到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}</div>
|
||||||
<div className="topup-qr" aria-label="二维码占位">
|
<div className="topup-qr" aria-label="二维码占位">
|
||||||
<div className="center">{scanLabel}<br /><span className="ref">{txRef}</span></div>
|
<div className="center">{scanLabel}<br /><span className="ref">{txRef}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="topup-valid">// 5 分钟内有效 · 到账后自动关闭</div>
|
<div className="topup-valid">// 5 分钟内有效 · 到账后自动关闭</div>
|
||||||
|
<div className="topup-valid">// 积分不可提现、不可转让 · 长期有效 · 发票按实际支付金额开具</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-f">
|
<div className="modal-f">
|
||||||
<button className="btn" type="button" onClick={close}>取消</button>
|
<button className="btn" type="button" onClick={close}>取消</button>
|
||||||
@@ -118,6 +121,11 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
|
|||||||
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<Tab>("overview");
|
const [tab, setTab] = useState<Tab>("overview");
|
||||||
|
// 积分汇率(积分/¥):预览换算与后端 recharge 同源,汇率改配后不至于「预览 ×10 实到 ×N」
|
||||||
|
const [pointsRate, setPointsRate] = useState(10);
|
||||||
|
useEffect(() => {
|
||||||
|
void api.billingConfig().then((cfg) => setPointsRate(Number(cfg.points_per_yuan) || 10)).catch(() => undefined);
|
||||||
|
}, []);
|
||||||
const [recharge, setRecharge] = useState(500);
|
const [recharge, setRecharge] = useState(500);
|
||||||
const [customAmt, setCustomAmt] = useState("");
|
const [customAmt, setCustomAmt] = useState("");
|
||||||
// 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势
|
// 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势
|
||||||
@@ -289,7 +297,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
|
|||||||
<h3>快速充值</h3>
|
<h3>快速充值</h3>
|
||||||
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
|
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="topup-selected">已选 ¥{effectiveAmount}{effectiveBonus > 0 ? ` + ¥${effectiveBonus} 赠送` : ""}</div>
|
<div className="topup-selected">已选 ¥{effectiveAmount}(到账 {Math.round(effectiveAmount * pointsRate + effectiveBonus)} 积分){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="recharge-row">
|
<div className="recharge-row">
|
||||||
{RECHARGE.map((item) => (
|
{RECHARGE.map((item) => (
|
||||||
@@ -322,6 +330,12 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="tip" style={{ marginTop: 14 }}>
|
||||||
|
<strong>计费说明</strong>
|
||||||
|
1 元 = 10 积分。脚本/文案 10 积分/次;图片生成 20 积分/张;配音 10 积分/500 字;
|
||||||
|
视频按时长与分辨率计量、按实际用量结算(如 15 秒 720P 竖屏约 224 积分),多退少不补超;
|
||||||
|
拼接导出免费。生成失败、超时均不扣费,自动退回预留积分。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -443,7 +457,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
|
|||||||
? <span className="who"><span className="av">{l.user_label.slice(0, 1).toUpperCase()}</span>{l.user_label}</span>
|
? <span className="who"><span className="av">{l.user_label.slice(0, 1).toUpperCase()}</span>{l.user_label}</span>
|
||||||
: <span className="sys">系统</span>}</td>
|
: <span className="sys">系统</span>}</td>
|
||||||
<td className="bill-status"><span className="status-tag ok">成功</span></td>
|
<td className="bill-status"><span className="status-tag ok">成功</span></td>
|
||||||
<td className="neg">{l.amount}</td>
|
<td className="neg">{pts(l.amount)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -565,7 +579,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
|
|||||||
open={topupChannel !== null}
|
open={topupChannel !== null}
|
||||||
channel={topupChannel ?? "wechat"}
|
channel={topupChannel ?? "wechat"}
|
||||||
amount={effectiveAmount}
|
amount={effectiveAmount}
|
||||||
bonus={effectiveBonus}
|
bonus={effectiveBonus} rate={pointsRate}
|
||||||
close={() => setTopupChannel(null)}
|
close={() => setTopupChannel(null)}
|
||||||
onDone={submitRecharge}
|
onDone={submitRecharge}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Gauge, Wallet, X } from "lucide-react";
|
import { Gauge, Wallet, X } from "lucide-react";
|
||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
|
import { Pager } from "../../components/pager";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||||
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
|
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
|
||||||
|
import { pts } from "../stage-config";
|
||||||
|
|
||||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
function fmtDate(iso: string) {
|
function fmtDate(iso: string) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -37,6 +41,70 @@ function useTeams() {
|
|||||||
return teams;
|
return teams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── 平台计费配置 ───────────────────────────
|
||||||
|
|
||||||
|
// 积分汇率 / 视频毛利系数 / 预留 buffer(与后端计价引擎同源,改动即刻生效于下一次估价/结算)
|
||||||
|
function BillingConfigCard({ notify }: { notify: Notify }) {
|
||||||
|
const [form, setForm] = useState({ points_per_yuan: "", video_margin_multiplier: "", video_reserve_buffer: "" });
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await adminApi.billingConfig();
|
||||||
|
setForm({
|
||||||
|
points_per_yuan: cfg.points_per_yuan,
|
||||||
|
video_margin_multiplier: cfg.video_margin_multiplier,
|
||||||
|
video_reserve_buffer: cfg.video_reserve_buffer
|
||||||
|
});
|
||||||
|
setLoaded(true);
|
||||||
|
} catch {
|
||||||
|
notify("error", "计费配置加载失败");
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await adminApi.updateBillingConfig(form);
|
||||||
|
notify("success", "计费配置已更新,即刻生效");
|
||||||
|
} catch (e) {
|
||||||
|
notify("error", e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!loaded) return null;
|
||||||
|
return (
|
||||||
|
<div className="card-hard admin-billing-config" style={{ padding: "18px 20px", marginBottom: 20 }}>
|
||||||
|
<div className="section-h" style={{ marginBottom: 12 }}>
|
||||||
|
<h2 style={{ fontSize: 16 }}>平台计费配置</h2>
|
||||||
|
<span className="more">[ /billing-config ]</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||||
|
<div className="field" style={{ width: 160 }}>
|
||||||
|
<label className="field-label">积分汇率(积分/¥)</label>
|
||||||
|
<input className="input" type="number" step="0.01" value={form.points_per_yuan} onChange={(e) => setForm((f) => ({ ...f, points_per_yuan: e.target.value }))} />
|
||||||
|
<div className="field-hint">1 元兑换的积分数</div>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ width: 160 }}>
|
||||||
|
<label className="field-label">视频毛利系数</label>
|
||||||
|
<input className="input" type="number" step="0.01" value={form.video_margin_multiplier} onChange={(e) => setForm((f) => ({ ...f, video_margin_multiplier: e.target.value }))} />
|
||||||
|
<div className="field-hint">用户价 = 火山成本 × 系数</div>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ width: 160 }}>
|
||||||
|
<label className="field-label">视频预留 buffer</label>
|
||||||
|
<input className="input" type="number" step="0.01" value={form.video_reserve_buffer} onChange={(e) => setForm((f) => ({ ...f, video_reserve_buffer: e.target.value }))} />
|
||||||
|
<div className="field-hint">预留 = 预估积分 × buffer</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void save()}>{saving ? "保存中…" : "保存配置"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────── 计费审计 ───────────────────────────
|
// ─────────────────────────── 计费审计 ───────────────────────────
|
||||||
|
|
||||||
export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||||
@@ -44,6 +112,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
|||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tab, setTab] = useState("");
|
const [tab, setTab] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ team: "", amount: "", reason: "" });
|
const [form, setForm] = useState({ team: "", amount: "", reason: "" });
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -52,7 +121,8 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await adminApi.ledgers({ ledger_type: tab || undefined, page_size: 80 });
|
const res = await adminApi.ledgers({ ledger_type: tab || undefined, page, page_size: PAGE_SIZE });
|
||||||
|
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||||
setLedgers(res.results);
|
setLedgers(res.results);
|
||||||
setCount(res.count);
|
setCount(res.count);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -61,7 +131,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [tab]);
|
}, [tab, page]);
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
@@ -94,11 +164,12 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
|||||||
<button className="btn btn-primary" type="button" onClick={() => setModalOpen(true)}>+ 手动调额</button>
|
<button className="btn btn-primary" type="button" onClick={() => setModalOpen(true)}>+ 手动调额</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<BillingConfigCard notify={notify} />
|
||||||
|
|
||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
<div className="tabs-sub">
|
<div className="tabs-sub">
|
||||||
{LEDGER_TABS.map((t) => (
|
{LEDGER_TABS.map((t) => (
|
||||||
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => setTab(t.key)}>{t.label}</button>
|
<button key={t.key || "all"} type="button" className={`tab-sub${tab === t.key ? " active" : ""}`} onClick={() => { setTab(t.key); setPage(1); }}>{t.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,14 +188,15 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
|||||||
<td>{l.team_name || <span className="muted">—</span>}</td>
|
<td>{l.team_name || <span className="muted">—</span>}</td>
|
||||||
<td>{l.username || <span className="muted">—</span>}</td>
|
<td>{l.username || <span className="muted">—</span>}</td>
|
||||||
<td>{ledgerPill(l.ledger_type)}</td>
|
<td>{ledgerPill(l.ledger_type)}</td>
|
||||||
<td className="num mono">¥{l.amount}</td>
|
<td className="num mono">{pts(l.amount)} 积分</td>
|
||||||
<td className="num mono">¥{l.balance_after}</td>
|
<td className="num mono">{pts(l.balance_after)} 积分</td>
|
||||||
<td>{l.reason || <span className="muted">—</span>}</td>
|
<td>{l.reason || <span className="muted">—</span>}</td>
|
||||||
<td className="mono col-time">{fmtDate(l.created_at)}</td>
|
<td className="mono col-time">{fmtDate(l.created_at)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -179,19 +251,22 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const teams = useTeams();
|
const teams = useTeams();
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await adminApi.quotaPolicies({ page_size: 100 });
|
const res = await adminApi.quotaPolicies({ page, page_size: PAGE_SIZE });
|
||||||
|
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||||
setPolicies(res.results);
|
setPolicies(res.results);
|
||||||
setCount(res.count);
|
setCount(res.count);
|
||||||
} catch {
|
} catch {
|
||||||
|
if (page > 1) { setPage(1); return; }
|
||||||
notify("error", "加载额度策略失败");
|
notify("error", "加载额度策略失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [page]);
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
@@ -241,7 +316,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lim = (v: string | null) => (v == null ? <span className="muted">不限</span> : `¥${v}`);
|
const lim = (v: string | null) => (v == null ? <span className="muted">不限</span> : `${pts(v)} 积分`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -279,6 +354,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Building, KeyRound, X } from "lucide-react";
|
import { Building, KeyRound, Percent, X } from "lucide-react";
|
||||||
import { adminApi } from "../../api";
|
import { adminApi } from "../../api";
|
||||||
|
import { Pager } from "../../components/pager";
|
||||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||||
import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types";
|
import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types";
|
||||||
|
import { pts } from "../stage-config";
|
||||||
|
|
||||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ function statusPill(active: boolean) {
|
|||||||
: <span className="pill err"><span className="dot" />停用</span>;
|
: <span className="pill err"><span className="dot" />停用</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
const STATUS_TABS = [
|
const STATUS_TABS = [
|
||||||
{ key: "", label: "全部" },
|
{ key: "", label: "全部" },
|
||||||
{ key: "active", label: "启用" },
|
{ key: "active", label: "启用" },
|
||||||
@@ -33,22 +37,36 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [detail, setDetail] = useState<AdminTeamDetail | null>(null);
|
const [detail, setDetail] = useState<AdminTeamDetail | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
// 差异化调价:行级「定价」弹窗(系数 0.10~10.00,<1 折扣 / >1 加价,静默生效)
|
||||||
|
const [pricingTarget, setPricingTarget] = useState<AdminTeam | null>(null);
|
||||||
|
const [pricingValue, setPricingValue] = useState("1.00");
|
||||||
|
const [pricingSaving, setPricingSaving] = useState(false);
|
||||||
|
// 卖亏告警阈值 = 1/毛利系数(系数×毛利<1 即视频卖低于成本):跟随计费配置,不硬编码 0.67(review 确认)
|
||||||
|
const [videoMargin, setVideoMargin] = useState(1.5);
|
||||||
|
useEffect(() => {
|
||||||
|
void adminApi.billingConfig().then((cfg) => setVideoMargin(Number(cfg.video_margin_multiplier) || 1.5)).catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
const lossThreshold = 1 / videoMargin;
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await adminApi.teams({ status: statusFilter || undefined, search: search.trim() || undefined, page_size: 100 });
|
const res = await adminApi.teams({ status: statusFilter || undefined, search: search.trim() || undefined, page, page_size: PAGE_SIZE });
|
||||||
|
// 删完当前页最后一条(或筛选变化)导致本页悬空 → 回退一页
|
||||||
|
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||||
setTeams(res.results);
|
setTeams(res.results);
|
||||||
setCount(res.count);
|
setCount(res.count);
|
||||||
} catch {
|
} catch {
|
||||||
|
if (page > 1) { setPage(1); return; } // 页码越界(后端 404)→ 回第一页
|
||||||
notify("error", "加载团队失败");
|
notify("error", "加载团队失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [statusFilter, search]);
|
}, [statusFilter, search, page]);
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
@@ -74,6 +92,23 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function savePricing() {
|
||||||
|
if (!pricingTarget || pricingSaving) return;
|
||||||
|
const v = Number(pricingValue);
|
||||||
|
if (!Number.isFinite(v) || v < 0.1 || v > 10) { notify("error", "系数需在 0.10 ~ 10.00 之间"); return; }
|
||||||
|
setPricingSaving(true);
|
||||||
|
try {
|
||||||
|
await adminApi.setTeamPricing(pricingTarget.id, pricingValue);
|
||||||
|
notify("success", `「${pricingTarget.name}」价格系数已设为 ×${Number(pricingValue).toFixed(2)}`);
|
||||||
|
setPricingTarget(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
notify("error", e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setPricingSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
@@ -86,10 +121,10 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
<div className="tabs-sub">
|
<div className="tabs-sub">
|
||||||
{STATUS_TABS.map((t) => (
|
{STATUS_TABS.map((t) => (
|
||||||
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => setStatusFilter(t.key)}>{t.label}</button>
|
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => { setStatusFilter(t.key); setPage(1); }}>{t.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<input className="input admin-search" type="text" placeholder="搜索团队名…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<input className="input admin-search" type="text" placeholder="搜索团队名…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -100,7 +135,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
<div className="admin-table-wrap">
|
<div className="admin-table-wrap">
|
||||||
<table className="t admin-table">
|
<table className="t admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>团队名</th><th>超管</th><th>成员</th><th>余额</th><th>状态</th><th>创建时间</th><th className="col-actions">操作</th></tr>
|
<tr><th>团队名</th><th>超管</th><th>成员</th><th>余额</th><th>价格系数</th><th>状态</th><th>创建时间</th><th className="col-actions">操作</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{teams.map((t) => (
|
{teams.map((t) => (
|
||||||
@@ -108,10 +143,12 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
<td>{t.name}</td>
|
<td>{t.name}</td>
|
||||||
<td>{t.owner_username || <span className="muted">—</span>}</td>
|
<td>{t.owner_username || <span className="muted">—</span>}</td>
|
||||||
<td className="num">{t.member_count}</td>
|
<td className="num">{t.member_count}</td>
|
||||||
<td className="num mono">¥{t.balance}</td>
|
<td className="num mono">{pts(t.balance)} 积分</td>
|
||||||
|
<td className="num mono">{Number(t.price_multiplier) === 1 ? <span className="muted">标准价</span> : `×${Number(t.price_multiplier).toFixed(2)}`}</td>
|
||||||
<td>{statusPill(t.status === "active")}</td>
|
<td>{statusPill(t.status === "active")}</td>
|
||||||
<td className="mono col-time">{fmtDate(t.created_at)}</td>
|
<td className="mono col-time">{fmtDate(t.created_at)}</td>
|
||||||
<td className="col-actions">
|
<td className="col-actions">
|
||||||
|
<button className="btn btn-sm btn-ghost" type="button" onClick={() => { setPricingTarget(t); setPricingValue(Number(t.price_multiplier).toFixed(2)); }}>定价</button>
|
||||||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openDetail(t.id)}>详情</button>
|
<button className="btn btn-sm btn-ghost" type="button" onClick={() => openDetail(t.id)}>详情</button>
|
||||||
<button className={`btn btn-sm btn-ghost${t.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(t)}>
|
<button className={`btn btn-sm btn-ghost${t.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggle(t)}>
|
||||||
{t.status === "active" ? "停用" : "启用"}
|
{t.status === "active" ? "停用" : "启用"}
|
||||||
@@ -121,6 +158,32 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pricingTarget && (
|
||||||
|
<div className="modal-bg show" onClick={(e) => { if (e.target === e.currentTarget) setPricingTarget(null); }}>
|
||||||
|
<div className="modal" role="dialog" aria-modal="true" aria-label="团队定价">
|
||||||
|
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||||||
|
<div className="modal-h">
|
||||||
|
<div className="ic-m"><Percent size={16} /></div>
|
||||||
|
<div className="ti">团队定价<span>// {pricingTarget.name}</span></div>
|
||||||
|
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setPricingTarget(null)}><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-b">
|
||||||
|
<p className="admin-modal-desc">最终积分价 = 标准挂牌价 × 系数(全部计费类型统一生效,对团队静默)。<1 为折扣,>1 为加价;视频在途任务按下单快照不受影响。</p>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">价格系数 <span className="lbl-note">(0.10 ~ 10.00,1.00 = 标准价)</span></label>
|
||||||
|
<input className="input num-input" type="number" step="0.05" min="0.1" max="10" value={pricingValue} onChange={(e) => setPricingValue(e.target.value)} />
|
||||||
|
{Number(pricingValue) < lossThreshold && <div className="field-hint">// ⚠ 低于 ×{lossThreshold.toFixed(2)} 时视频将卖低于平台成本(毛利 ×{videoMargin} 被抵消),I9 审计会告警</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-f">
|
||||||
|
<button className="btn" type="button" onClick={() => setPricingTarget(null)}>取消</button>
|
||||||
|
<button className="btn btn-primary" type="button" disabled={pricingSaving} onClick={() => void savePricing()}>{pricingSaving ? "保存中…" : "保存"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -141,7 +204,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
<div className="admin-detail-meta">
|
<div className="admin-detail-meta">
|
||||||
<div><span className="k">超管</span><span className="v">{detail.owner_username || "—"}</span></div>
|
<div><span className="k">超管</span><span className="v">{detail.owner_username || "—"}</span></div>
|
||||||
<div><span className="k">成员数</span><span className="v">{detail.member_count}</span></div>
|
<div><span className="k">成员数</span><span className="v">{detail.member_count}</span></div>
|
||||||
<div><span className="k">余额</span><span className="v mono">¥{detail.balance}</span></div>
|
<div><span className="k">余额</span><span className="v mono">{pts(detail.balance)} 积分</span></div>
|
||||||
<div><span className="k">状态</span><span className="v">{statusPill(detail.status === "active")}</span></div>
|
<div><span className="k">状态</span><span className="v">{statusPill(detail.status === "active")}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-detail-subhead">成员 · {detail.members.length}</div>
|
<div className="admin-detail-subhead">成员 · {detail.members.length}</div>
|
||||||
@@ -153,7 +216,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
|||||||
<td>{m.username}</td>
|
<td>{m.username}</td>
|
||||||
<td>{m.role}</td>
|
<td>{m.role}</td>
|
||||||
<td>{statusPill(m.user_status === "active")}</td>
|
<td>{statusPill(m.user_status === "active")}</td>
|
||||||
<td className="num mono">¥{m.monthly_credit_limit}</td>
|
<td className="num mono">{pts(m.monthly_credit_limit)} 积分</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -179,6 +242,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const [pwdTarget, setPwdTarget] = useState<AdminUser | null>(null);
|
const [pwdTarget, setPwdTarget] = useState<AdminUser | null>(null);
|
||||||
const [pwd, setPwd] = useState("");
|
const [pwd, setPwd] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -186,16 +250,18 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await adminApi.users({ status: statusFilter || undefined, search: search.trim() || undefined, page_size: 100 });
|
const res = await adminApi.users({ status: statusFilter || undefined, search: search.trim() || undefined, page, page_size: PAGE_SIZE });
|
||||||
|
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||||
setUsers(res.results);
|
setUsers(res.results);
|
||||||
setCount(res.count);
|
setCount(res.count);
|
||||||
} catch {
|
} catch {
|
||||||
|
if (page > 1) { setPage(1); return; }
|
||||||
notify("error", "加载用户失败");
|
notify("error", "加载用户失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [statusFilter, search]);
|
}, [statusFilter, search, page]);
|
||||||
|
|
||||||
useEffect(() => { void load(); }, [load]);
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
@@ -238,10 +304,10 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
|||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
<div className="tabs-sub">
|
<div className="tabs-sub">
|
||||||
{STATUS_TABS.map((t) => (
|
{STATUS_TABS.map((t) => (
|
||||||
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => setStatusFilter(t.key)}>{t.label}</button>
|
<button key={t.key || "all"} type="button" className={`tab-sub${statusFilter === t.key ? " active" : ""}`} onClick={() => { setStatusFilter(t.key); setPage(1); }}>{t.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<input className="input admin-search" type="text" placeholder="搜索用户名…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<input className="input admin-search" type="text" placeholder="搜索用户名…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -284,6 +350,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -121,21 +121,21 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
|||||||
tag: "[ MODEL · TRY-ON ]",
|
tag: "[ MODEL · TRY-ON ]",
|
||||||
title: "模特上身图",
|
title: "模特上身图",
|
||||||
desc: "选择模特,AI 生成商品模特上身效果图",
|
desc: "选择模特,AI 生成商品模特上身效果图",
|
||||||
cost: "≈ ¥0.30 / 张"
|
cost: "≈ 20 积分 / 张"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
page: "platformCover" as Page,
|
page: "platformCover" as Page,
|
||||||
tag: "[ PLATFORM · KIT ]",
|
tag: "[ PLATFORM · KIT ]",
|
||||||
title: "平台套图",
|
title: "平台套图",
|
||||||
desc: "选择平台模板,AI 生成电商平台套图",
|
desc: "选择平台模板,AI 生成电商平台套图",
|
||||||
cost: "≈ ¥0.50 / 张"
|
cost: "≈ 20 积分 / 张"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
page: "imageOptimize" as Page,
|
page: "imageOptimize" as Page,
|
||||||
tag: "[ IMAGE · STUDIO ]",
|
tag: "[ IMAGE · STUDIO ]",
|
||||||
title: "图片创作",
|
title: "图片创作",
|
||||||
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
|
desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写",
|
||||||
cost: "≈ ¥0.40 / 组"
|
cost: "≈ 20 积分 / 张"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -729,15 +729,23 @@ export function ImageWorkbenchPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||||||
|
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
|
||||||
|
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
||||||
|
useEffect(() => {
|
||||||
|
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
||||||
|
}, []);
|
||||||
// 每张图实扣单价:取「当前选的生图模型」的 unit_price(火山/gpt-image),没匹配上用第一个图像模型,
|
// 每张图实扣单价:取「当前选的生图模型」的 unit_price(火山/gpt-image),没匹配上用第一个图像模型,
|
||||||
// 再兜底 ¥2(后端 estimate_cost=unit_price,默认模型 gpt-image-2=¥2)。前端预估据此算,和后端实扣一致(PMC#20)。
|
// 再兜底 20 积分(后端 quote_flat=unit_price 积分/张,默认模型 gpt-image-2=20 积分)。前端预估据此算,和后端实扣一致(PMC#20)。
|
||||||
const perImagePrice = (() => {
|
const perImagePrice = (() => {
|
||||||
const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel;
|
const want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel;
|
||||||
const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0];
|
const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0];
|
||||||
const p = Number(m?.unit_price);
|
const p = Number(m?.unit_price);
|
||||||
return Number.isFinite(p) && p > 0 ? p : 2;
|
return Number.isFinite(p) && p > 0 ? p : 20;
|
||||||
})();
|
})();
|
||||||
const estimateFee = (n: number) => `¥${(n * perImagePrice).toFixed(2)}`;
|
// 与后端逐张对齐:每张 = 挂牌单价取整 × 系数 → HALF_UP 最低 1,总价 = 张数 × 单张
|
||||||
|
// (不能先乘张数再取整:0.85 系数 × 3 张会比后端逐张各取整少 1-2 积分,review 确认)
|
||||||
|
const perImageFinal = Math.max(1, Math.round(Number((Math.round(perImagePrice) * priceMultiplier).toFixed(6))));
|
||||||
|
const estimateFee = (n: number) => `${n * perImageFinal} 积分`;
|
||||||
|
|
||||||
/* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
|
/* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
|
||||||
映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。
|
映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。
|
||||||
@@ -2477,8 +2485,8 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
|
|||||||
|
|
||||||
<div className="dm-form-cta">
|
<div className="dm-form-cta">
|
||||||
<div className="dm-cost">
|
<div className="dm-cost">
|
||||||
<span>预估扣费 <span className="v">≈ ¥1.20</span></span>
|
<span>预估扣费 <span className="v">≈ 20 积分/张</span></span>
|
||||||
<span>余额 ¥327.40</span>
|
<span>余额以消费页为准</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="dm-gen" type="button" onClick={toRealTool}>
|
<button className="dm-gen" type="button" onClick={toRealTool}>
|
||||||
<Sparkles size={15} />
|
<Sparkles size={15} />
|
||||||
@@ -2502,7 +2510,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
|
|||||||
<div className="nm">Ava × 4 张</div>
|
<div className="nm">Ava × 4 张</div>
|
||||||
<div className="info">
|
<div className="info">
|
||||||
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 3 分钟前{" "}
|
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 3 分钟前{" "}
|
||||||
<span className="sep">·</span> ¥1.20
|
<span className="sep">·</span> 20 积分/张
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops">
|
<div className="ops">
|
||||||
@@ -2527,7 +2535,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
|
|||||||
<div className="nm">Zoe × 4 张</div>
|
<div className="nm">Zoe × 4 张</div>
|
||||||
<div className="info">
|
<div className="info">
|
||||||
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 12 分钟前{" "}
|
{active.title} <span className="sep">·</span> 3:4 <span className="sep">·</span> 12 分钟前{" "}
|
||||||
<span className="sep">·</span> ¥1.20
|
<span className="sep">·</span> 20 积分/张
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops">
|
<div className="ops">
|
||||||
@@ -2624,7 +2632,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
|
|||||||
<div className="info">
|
<div className="info">
|
||||||
<span>3:4</span><span className="sep">·</span>
|
<span>3:4</span><span className="sep">·</span>
|
||||||
<span>3 分钟前</span><span className="sep">·</span>
|
<span>3 分钟前</span><span className="sep">·</span>
|
||||||
<span>¥1.20</span>
|
<span>20 积分</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops">
|
<div className="ops">
|
||||||
@@ -2647,7 +2655,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
|
|||||||
<div className="info">
|
<div className="info">
|
||||||
<span>3:4</span><span className="sep">·</span>
|
<span>3:4</span><span className="sep">·</span>
|
||||||
<span>12 分钟前</span><span className="sep">·</span>
|
<span>12 分钟前</span><span className="sep">·</span>
|
||||||
<span>¥1.20</span>
|
<span>20 积分</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops">
|
<div className="ops">
|
||||||
@@ -2670,7 +2678,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
|
|||||||
<div className="info">
|
<div className="info">
|
||||||
<span>3:4</span><span className="sep">·</span>
|
<span>3:4</span><span className="sep">·</span>
|
||||||
<span>刚刚</span><span className="sep">·</span>
|
<span>刚刚</span><span className="sep">·</span>
|
||||||
<span>¥0.60</span>
|
<span>20 积分</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops">
|
<div className="ops">
|
||||||
@@ -2697,7 +2705,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
|
|||||||
<div className="info">
|
<div className="info">
|
||||||
<span>3:4</span><span className="sep">·</span>
|
<span>3:4</span><span className="sep">·</span>
|
||||||
<span>昨天 18:24</span><span className="sep">·</span>
|
<span>昨天 18:24</span><span className="sep">·</span>
|
||||||
<span>¥1.20</span>
|
<span>20 积分</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ops">
|
<div className="ops">
|
||||||
@@ -2762,7 +2770,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
|
|||||||
<span className="muted">+ 添加</span>
|
<span className="muted">+ 添加</span>
|
||||||
</button>
|
</button>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="meta-right">预估 <span className="v">¥1.20</span> · 余额 <span className="v">¥327.40</span></span>
|
<span className="meta-right">预估 <span className="v">20 积分/张</span></span>
|
||||||
<button className="gen-btn" type="button" onClick={toRealTool}>
|
<button className="gen-btn" type="button" onClick={toRealTool}>
|
||||||
<Sparkles size={14} />
|
<Sparkles size={14} />
|
||||||
生成 · {active.title} × Ava
|
生成 · {active.title} × Ava
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
|
|||||||
const allProjectsLoaded = projects.length >= projCount;
|
const allProjectsLoaded = projects.length >= projCount;
|
||||||
const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined;
|
const totalDelta = allProjectsLoaded && newThisMonth > 0 ? `↑ 本月 +${newThisMonth}` : undefined;
|
||||||
|
|
||||||
// 冻结信息:真实「冻结 ¥X / 共 ¥Y」,无 budget/已用字段,故不复刻 V1「已用 ¥X/¥Y」。
|
// 冻结信息:真实「冻结 X 积分 / 共 Y 积分」,无 budget/已用字段,故不复刻 V1「已用/共」。
|
||||||
// R41:余额 stat 块已从 KPI 行移除,冻结信息改放 page-head 小标题行右侧(见下方 .sub 内)。
|
// R41:余额 stat 块已从 KPI 行移除,冻结信息改放 page-head 小标题行右侧(见下方 .sub 内)。
|
||||||
const balanceNum = Number(billing?.account.balance ?? 0);
|
const balanceNum = Number(billing?.account.balance ?? 0);
|
||||||
const reservedNum = Number(billing?.account.reserved_balance ?? 0);
|
const reservedNum = Number(billing?.account.reserved_balance ?? 0);
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { Users } from "lucide-react";
|
|||||||
import { api, ApiError } from "../api";
|
import { api, ApiError } from "../api";
|
||||||
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
|
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_BILLING_RATES,
|
||||||
FC_MODELS,
|
FC_MODELS,
|
||||||
FC_STANDARD_MODEL,
|
FC_STANDARD_MODEL,
|
||||||
|
type BillingRates,
|
||||||
MAX_AUDIOS,
|
MAX_AUDIOS,
|
||||||
MAX_IMAGES,
|
MAX_IMAGES,
|
||||||
MAX_VIDEOS,
|
MAX_VIDEOS,
|
||||||
@@ -59,6 +61,14 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
|
|||||||
[modelConfigs]
|
[modelConfigs]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 计费配置(积分汇率/视频毛利):预估口径与后端计价引擎同源;拉不到用首发默认值兜底
|
||||||
|
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
|
||||||
|
useEffect(() => {
|
||||||
|
void api.billingConfig()
|
||||||
|
.then((cfg) => setBillingRates({ margin: Number(cfg.video_margin_multiplier) || DEFAULT_BILLING_RATES.margin, rate: Number(cfg.points_per_yuan) || DEFAULT_BILLING_RATES.rate, multiplier: Number(cfg.team_price_multiplier) || 1 }))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// —— 任务流 ——
|
// —— 任务流 ——
|
||||||
const [tasks, setTasks] = useState<FreeVideoTask[]>([]);
|
const [tasks, setTasks] = useState<FreeVideoTask[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -504,6 +514,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
|
|||||||
seed={seed}
|
seed={seed}
|
||||||
refs={refs}
|
refs={refs}
|
||||||
videoConfigs={videoConfigs}
|
videoConfigs={videoConfigs}
|
||||||
|
billingRates={billingRates}
|
||||||
submitting={submitting}
|
submitting={submitting}
|
||||||
promptRef={promptRef}
|
promptRef={promptRef}
|
||||||
onFiles={(files, role) => void addFiles(files, role)}
|
onFiles={(files, role) => void addFiles(files, role)}
|
||||||
|
|||||||
@@ -507,6 +507,14 @@ export function PipelinePage(props: {
|
|||||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
// ── 团队价格系数(差异化调价):页面各处「N 积分/次」文案按团队系数动态显示,拉不到按标准价 1 ──
|
||||||
|
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
||||||
|
useEffect(() => {
|
||||||
|
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
// 与后端 apply_team_price 逐字对齐:挂牌整数积分 × 系数 → HALF_UP 最低 1(toFixed(6) 只吸浮点噪声)
|
||||||
|
const pts = (base: number) => (priceMultiplier === 1 ? base : Math.max(1, Math.round(Number((base * priceMultiplier).toFixed(6)))));
|
||||||
|
|
||||||
// ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)──
|
// ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)──
|
||||||
const byId = new Map(assets.map((a) => [a.id, a] as const));
|
const byId = new Map(assets.map((a) => [a.id, a] as const));
|
||||||
const assetUrl = (id: string | null | undefined): string => {
|
const assetUrl = (id: string | null | undefined): string => {
|
||||||
@@ -2821,7 +2829,7 @@ export function PipelinePage(props: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stage-foot">
|
<div className="stage-foot">
|
||||||
<div className="info"><span className="mono">[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]</span></div>
|
<div className="info"><span className="mono">[ 脚本生成 {pts(10)} 积分/次 · 失败不扣 ]</span></div>
|
||||||
<div className="hstack">
|
<div className="hstack">
|
||||||
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> 重新生成全部</button>
|
<button className="btn" type="button" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑", "重新生成全部", undefined, "auto")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> 重新生成全部</button>
|
||||||
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
|
||||||
@@ -2897,8 +2905,8 @@ export function PipelinePage(props: {
|
|||||||
<div className="info">
|
<div className="info">
|
||||||
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
|
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。
|
||||||
<br /><br />
|
<br /><br />
|
||||||
<strong className="mono">// 人物 +¥0.20/张</strong>
|
<strong className="mono">// 人物 +{pts(20)} 积分/张</strong>
|
||||||
<strong className="mono">// 场景 +¥0.15/张</strong>
|
<strong className="mono">// 场景 +{pts(20)} 积分/张</strong>
|
||||||
<span style={{ color: "var(--black-alpha-48)" }}>商品图无成本(直接复用商品库)</span>
|
<span style={{ color: "var(--black-alpha-48)" }}>商品图无成本(直接复用商品库)</span>
|
||||||
{(entitiesExtracted || hasAnyAsset) && (
|
{(entitiesExtracted || hasAnyAsset) && (
|
||||||
<button type="button" className="btn btn-sm eg-reextract" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}>
|
<button type="button" className="btn btn-sm eg-reextract" disabled={extractState === "running"} data-stop onClick={() => void runExtract("only")}>
|
||||||
@@ -2977,7 +2985,7 @@ export function PipelinePage(props: {
|
|||||||
{previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
|
{previewTriAsset === adoptedTriAsset ? "已采用" : "采用此版本"}
|
||||||
</button>
|
</button>
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<span className="muted-2 mono" style={{ fontSize: 12 }}>~¥0.30 / 次</span>
|
<span className="muted-2 mono" style={{ fontSize: 12 }}>{pts(20)} 积分 / 次</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`prod-preview-history${productVersions.length ? " show" : ""}`} id="prod-preview-history">
|
<div className={`prod-preview-history${productVersions.length ? " show" : ""}`} id="prod-preview-history">
|
||||||
@@ -3290,7 +3298,7 @@ export function PipelinePage(props: {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>~¥0.45/场</span>
|
<span className="muted-2 mono" style={{ fontSize: "12px", alignSelf: "center" }}>{pts(20)} 积分/镜</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sb-history">
|
<div className="sb-history">
|
||||||
<div className="sb-history-h">// 本场历史版本(<span id="sb-history-ct">{activeVers.length}</span>)· 点击预览</div>
|
<div className="sb-history-h">// 本场历史版本(<span id="sb-history-ct">{activeVers.length}</span>)· 点击预览</div>
|
||||||
@@ -3405,7 +3413,7 @@ export function PipelinePage(props: {
|
|||||||
</strong>
|
</strong>
|
||||||
<span className={`pill ${tone}`}><span className="dot"></span>{statusLabel(seg.status)}</span>
|
<span className={`pill ${tone}`}><span className="dot"></span>{statusLabel(seg.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="video-meta">{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · ~¥0.45{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
|
<div className="video-meta">{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · 按时长计量{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
|
||||||
<div className="video-actions">
|
<div className="video-actions">
|
||||||
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || showBusy} onClick={() => guardVideoGen(shots.filter((s) => s.sort_order === seg.sort_order), seg.id, () => submitVideoOptimistic(seg.id, `${videoPrompt} 第 ${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds} 秒`))}>{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "重跑"}</button>
|
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || showBusy} onClick={() => guardVideoGen(shots.filter((s) => s.sort_order === seg.sort_order), seg.id, () => submitVideoOptimistic(seg.id, `${videoPrompt} 第 ${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds} 秒`))}>{showBusy ? <><span className="spinner btn-spin" aria-hidden="true" />{busy ? "生成中…" : "提交中…"}</> : "重跑"}</button>
|
||||||
{/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */}
|
{/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */}
|
||||||
@@ -3633,7 +3641,7 @@ export function PipelinePage(props: {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
<button className="btn btn-sm" type="button" disabled={loading || !edState.clips.some((c) => c.subtitle.trim())} onClick={() => onGenerateVoiceover({ items: edState.clips.map((c, idx) => ({ index: idx, text: c.subtitle.trim() })).filter((item) => item.text), voice_type: voVoicePick || voInfo?.voice_type || VO_VOICES[0].key })}>{voInfo ? "重新生成配音" : "生成配音 · ~¥1"}</button>
|
<button className="btn btn-sm" type="button" disabled={loading || !edState.clips.some((c) => c.subtitle.trim())} onClick={() => onGenerateVoiceover({ items: edState.clips.map((c, idx) => ({ index: idx, text: c.subtitle.trim() })).filter((item) => item.text), voice_type: voVoicePick || voInfo?.voice_type || VO_VOICES[0].key })}>{voInfo ? "重新生成配音" : `生成配音 · ${pts(10)} 积分/500字`}</button>
|
||||||
{voInfo && <button className="btn btn-sm btn-ghost" type="button" disabled={loading} onClick={() => onSaveTimeline({ voiceover: { clear: true } })}>移除配音</button>}
|
{voInfo && <button className="btn btn-sm btn-ghost" type="button" disabled={loading} onClick={() => onSaveTimeline({ voiceover: { clear: true } })}>移除配音</button>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -922,7 +922,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span style={{ flex: 1 }}></span>
|
<span style={{ flex: 1 }}></span>
|
||||||
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-56)" }}>~¥0.30 / 次</span>
|
<span className="mono" style={{ fontSize: "12px", color: "var(--black-alpha-56)" }}>20 积分 / 次</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 版本历史:点缩略图切换查看;已采用版本主橙描边 + 徽标 */}
|
{/* 版本历史:点缩略图切换查看;已采用版本主橙描边 + 徽标 */}
|
||||||
<div className={`prod-preview-history${triVersions.length ? " show" : ""}`} id="ov-tri-history">
|
<div className={`prod-preview-history${triVersions.length ? " show" : ""}`} id="ov-tri-history">
|
||||||
|
|||||||
@@ -8,8 +8,25 @@ export const stageMeta: Record<string, { no: string; label: string }> = {
|
|||||||
export: { no: "5", label: "拼接导出" }
|
export: { no: "5", label: "拼接导出" }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 积分裸数字格式化:DecimalField 序列化的 "75.0000" → "75"、"23.1000" → "23.1"(历史 ¥×10 迁移数据留有效小数)。
|
||||||
|
// admin 列表 / 账户流水等自带「积分」后缀的位置用它;带后缀的整串用 money()。
|
||||||
|
export function pts(value: string | number | undefined | null) {
|
||||||
|
if (value === undefined || value === "" || value === null) return "0";
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return String(value);
|
||||||
|
return Number.isInteger(n)
|
||||||
|
? n.toLocaleString("en-US")
|
||||||
|
: n.toLocaleString("en-US", { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 积分制重构(2026-07-03):余额/流水/限额/任务计价全站换积分,money() 语义随之重定义为积分格式化
|
||||||
|
// (调用点的值在 rescale 迁移后本来就是积分,函数名保留避免全站改名)。真实人民币(充值支付/成本/毛利)用 yuan()。
|
||||||
export function money(value: string | number | undefined) {
|
export function money(value: string | number | undefined) {
|
||||||
if (value === undefined || value === "") return "¥0.00";
|
return `${pts(value)} 积分`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yuan(value: string | number | undefined) {
|
||||||
|
if (value === undefined || value === "" || value === null) return "¥0.00";
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : `¥${value}`;
|
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : `¥${value}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ function genPassword() {
|
|||||||
for (let i = 0; i < 12; i++) s += chars[Math.floor(Math.random() * chars.length)];
|
for (let i = 0; i < 12; i++) s += chars[Math.floor(Math.random() * chars.length)];
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
// 千分位金额格式化(V1 限额弹窗用 ¥3,000 / ¥2,837.40 风格)· money() 无千分位故本地补
|
// 千分位积分格式化(积分制:限额/已用全是积分)
|
||||||
function yuan(n: number, decimals = 0) {
|
function yuan(n: number, decimals = 0) {
|
||||||
const fixed = n.toFixed(decimals);
|
const fixed = n.toFixed(decimals);
|
||||||
const [int, dec] = fixed.split(".");
|
const [int, dec] = fixed.split(".");
|
||||||
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
return `¥${grouped}${dec ? "." + dec : ""}`;
|
return `${grouped}${dec ? "." + dec : ""} 积分`;
|
||||||
}
|
}
|
||||||
// 复制到剪贴板:优先 navigator.clipboard,兜底 textarea + execCommand
|
// 复制到剪贴板:优先 navigator.clipboard,兜底 textarea + execCommand
|
||||||
async function copyText(text: string) {
|
async function copyText(text: string) {
|
||||||
@@ -113,6 +113,11 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
const onUpdateMember = async (id: string, p: Parameters<typeof onUpdateMemberRaw>[1]) => { const r = await onUpdateMemberRaw(id, p); reloadTeam(); return r; };
|
const onUpdateMember = async (id: string, p: Parameters<typeof onUpdateMemberRaw>[1]) => { const r = await onUpdateMemberRaw(id, p); reloadTeam(); return r; };
|
||||||
const onRemoveMember = async (id: string) => { const r = await onRemoveMemberRaw(id); reloadTeam(); return r; };
|
const onRemoveMember = async (id: string) => { const r = await onRemoveMemberRaw(id); reloadTeam(); return r; };
|
||||||
const onRecharge = async (amount: number, bonus: number) => { const r = await onRechargeRaw(amount, bonus); reloadTeam(); return r; };
|
const onRecharge = async (amount: number, bonus: number) => { const r = await onRechargeRaw(amount, bonus); reloadTeam(); return r; };
|
||||||
|
// 积分汇率(积分/¥):充值 hint 与后端到账口径同源,汇率改配后不写死 ×10
|
||||||
|
const [pointsRate, setPointsRate] = useState(10);
|
||||||
|
useEffect(() => {
|
||||||
|
void api.billingConfig().then((cfg) => setPointsRate(Number(cfg.points_per_yuan) || 10)).catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 创建账户表单
|
// 创建账户表单
|
||||||
const [cuUser, setCuUser] = useState("");
|
const [cuUser, setCuUser] = useState("");
|
||||||
@@ -526,7 +531,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
<label className="field-label">月限额 ¥ <span className="lbl-note">(-1 为不限)</span></label>
|
<label className="field-label">月限额(积分)<span className="lbl-note">(-1 为不限)</span></label>
|
||||||
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 2000" value={invMonthly} onChange={(e) => setInvMonthly(e.target.value)} />
|
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 2000" value={invMonthly} onChange={(e) => setInvMonthly(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
{inviteErr && <div className="form-error" role="alert" style={{ marginTop: 12 }}>{inviteErr}</div>}
|
{inviteErr && <div className="form-error" role="alert" style={{ marginTop: 12 }}>{inviteErr}</div>}
|
||||||
@@ -558,7 +563,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
>
|
>
|
||||||
<div className="limit-modal">
|
<div className="limit-modal">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">月限额 ¥ <span className="lbl-note">(-1 为不限)</span></label>
|
<label className="field-label">月限额(积分)<span className="lbl-note">(-1 为不限)</span></label>
|
||||||
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 3000" value={limitVal} onChange={(e) => setLimitVal(e.target.value)} />
|
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 3000" value={limitVal} onChange={(e) => setLimitVal(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
@@ -619,18 +624,18 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
<label className="field-label">额度配置 <span className="lbl-note">(¥ · -1 为不限)</span></label>
|
<label className="field-label">额度配置 <span className="lbl-note">(积分 · -1 为不限)</span></label>
|
||||||
<div className="quota-grid">
|
<div className="quota-grid">
|
||||||
<label className="quota-cell">
|
<label className="quota-cell">
|
||||||
<span className="qk">每日额度 ¥</span>
|
<span className="qk">每日额度(积分)</span>
|
||||||
<input className="input num-input" type="number" inputMode="numeric" value={cuDaily} onChange={(e) => setCuDaily(e.target.value)} />
|
<input className="input num-input" type="number" inputMode="numeric" value={cuDaily} onChange={(e) => setCuDaily(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="quota-cell">
|
<label className="quota-cell">
|
||||||
<span className="qk">每月额度 ¥</span>
|
<span className="qk">每月额度(积分)</span>
|
||||||
<input className="input num-input" type="number" inputMode="numeric" value={cuMonthly} onChange={(e) => setCuMonthly(e.target.value)} />
|
<input className="input num-input" type="number" inputMode="numeric" value={cuMonthly} onChange={(e) => setCuMonthly(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="quota-cell">
|
<label className="quota-cell">
|
||||||
<span className="qk">总额度 ¥</span>
|
<span className="qk">总额度(积分)</span>
|
||||||
<input className="input num-input" type="number" inputMode="numeric" value={cuTotal} onChange={(e) => setCuTotal(e.target.value)} />
|
<input className="input num-input" type="number" inputMode="numeric" value={cuTotal} onChange={(e) => setCuTotal(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -687,7 +692,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
dismissable={false}
|
dismissable={false}
|
||||||
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}>确认充值</button>}
|
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}>确认充值</button>}
|
||||||
>
|
>
|
||||||
<div className="field"><label className="field-label">充值金额 ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /></div>
|
<div className="field"><label className="field-label">充值金额 ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /><div className="field-hint">// 到账积分 = 支付金额 × {pointsRate}(当前汇率)</div></div>
|
||||||
</TeamModal>
|
</TeamModal>
|
||||||
|
|
||||||
{/* 编辑成员 · 角色双卡 + 三档额度 */}
|
{/* 编辑成员 · 角色双卡 + 三档额度 */}
|
||||||
@@ -717,15 +722,15 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">每日额度 ¥ <span className="lbl-note">(-1 为不限)</span></label>
|
<label className="field-label">每日额度(积分)<span className="lbl-note">(-1 为不限)</span></label>
|
||||||
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 100" value={edDaily} onChange={(e) => setEdDaily(e.target.value)} />
|
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 100" value={edDaily} onChange={(e) => setEdDaily(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">每月额度 ¥ <span className="lbl-note">(-1 为不限)</span></label>
|
<label className="field-label">每月额度(积分)<span className="lbl-note">(-1 为不限)</span></label>
|
||||||
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 2000" value={edMonthly} onChange={(e) => setEdMonthly(e.target.value)} />
|
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: 2000" value={edMonthly} onChange={(e) => setEdMonthly(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
<label className="field-label">总额度 ¥ <span className="lbl-note">(-1 为不限)</span></label>
|
<label className="field-label">总额度(积分)<span className="lbl-note">(-1 为不限)</span></label>
|
||||||
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: -1" value={edTotal} onChange={(e) => setEdTotal(e.target.value)} />
|
<input className="input num-input" type="number" inputMode="numeric" placeholder="例: -1" value={edTotal} onChange={(e) => setEdTotal(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export type AdminTeam = {
|
|||||||
owner_username: string | null;
|
owner_username: string | null;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
balance: string;
|
balance: string;
|
||||||
|
// 团队价格系数(差异化调价):最终积分价 = 挂牌价 × 系数
|
||||||
|
price_multiplier: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
export type AdminTeamMember = {
|
export type AdminTeamMember = {
|
||||||
@@ -122,6 +124,9 @@ export type AdminTask = {
|
|||||||
model_name: string | null;
|
model_name: string | null;
|
||||||
estimated_cost: string;
|
estimated_cost: string;
|
||||||
actual_cost: string;
|
actual_cost: string;
|
||||||
|
// 平台成本(¥)与单任务毛利(¥,成本未知时 null)—— 积分制重构后毛利首次可审
|
||||||
|
base_cost?: string;
|
||||||
|
margin_yuan?: string | null;
|
||||||
cost_anomaly: boolean;
|
cost_anomaly: boolean;
|
||||||
error_code: string;
|
error_code: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -713,4 +718,17 @@ export type NotificationList = Paginated<Notification> & {
|
|||||||
export type RechargeResult = {
|
export type RechargeResult = {
|
||||||
account: BillingSummary["account"];
|
account: BillingSummary["account"];
|
||||||
ledger: Ledger;
|
ledger: Ledger;
|
||||||
|
// 积分制:到账积分与当时汇率快照(支付金额是 ¥,到账是积分)
|
||||||
|
credited_points?: string;
|
||||||
|
points_per_yuan?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 平台计费配置(公共 GET /api/billing/config/):前端预估与后端计价引擎同源
|
||||||
|
export type BillingConfigInfo = {
|
||||||
|
currency_unit: string;
|
||||||
|
points_per_yuan: string;
|
||||||
|
video_margin_multiplier: string;
|
||||||
|
video_reserve_buffer: string;
|
||||||
|
// 当前请求者团队的价格系数(差异化调价,默认 "1"):预估所见即所扣
|
||||||
|
team_price_multiplier?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -140,6 +140,25 @@ def audit_account(acct):
|
|||||||
else:
|
else:
|
||||||
print(" ✓ I8 流水完整(开户额度有凭证)")
|
print(" ✓ I8 流水完整(开户额度有凭证)")
|
||||||
|
|
||||||
|
# I9 毛利审计(积分制新增,先 warning 不计入失败):succeeded 且 base_cost>0(成本已知)的任务,
|
||||||
|
# 用户实收 actual_cost/points_per_yuan(¥)不应低于平台成本 base_cost —— 低于即「卖亏」。
|
||||||
|
# 注:充值赠送积分会稀释实际实现汇率(paid/credited < 名义 0.1),此处按名义汇率近似。
|
||||||
|
from apps.billing.pricing import get_billing_config
|
||||||
|
|
||||||
|
rate = get_billing_config().points_per_yuan
|
||||||
|
loss_tasks = []
|
||||||
|
for t in AITask.objects.filter(team=team, status="succeeded", base_cost__gt=0).only("id", "task_type", "actual_cost", "base_cost"):
|
||||||
|
revenue_yuan = (t.actual_cost or Z) / rate
|
||||||
|
if revenue_yuan < t.base_cost:
|
||||||
|
loss_tasks.append((t, revenue_yuan))
|
||||||
|
if loss_tasks:
|
||||||
|
for t, revenue_yuan in loss_tasks[:5]:
|
||||||
|
print(f" ⚠ I9 卖亏任务(warning) {t.task_type}:{t.id} 实收¥{revenue_yuan:.2f} < 成本¥{t.base_cost}")
|
||||||
|
if len(loss_tasks) > 5:
|
||||||
|
print(f" ⚠ I9 …另有 {len(loss_tasks) - 5} 条卖亏任务")
|
||||||
|
else:
|
||||||
|
print(" ✓ I9 无卖亏任务(base_cost>0 口径)")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
accts = CreditAccount.objects.select_related("team").all()
|
accts = CreditAccount.objects.select_related("team").all()
|
||||||
|
|||||||
Reference in New Issue
Block a user