From bf20de956cf075ad0b1abe13b83963a0893e20fd Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Tue, 7 Jul 2026 10:04:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(billing):=20=E8=AE=A1=E8=B4=B9=E5=95=86?= =?UTF-8?q?=E4=B8=9A=E5=8C=96=E9=87=8D=E6=9E=84(=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E5=88=B6)+=20=E5=9B=A2=E9=98=9F=E5=B7=AE=E5=BC=82=E5=8C=96?= =?UTF-8?q?=E8=B0=83=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一计价引擎 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 --- core/backend/.env | 2 +- core/backend/airshelf/settings/base.py | 3 +- .../migrations/0008_points_rescale.py | 38 +++ .../migrations/0009_team_price_multiplier.py | 18 ++ core/backend/apps/accounts/models.py | 5 + core/backend/apps/accounts/tests.py | 17 +- core/backend/apps/accounts/views.py | 2 +- core/backend/apps/adminpanel/serializers.py | 24 +- core/backend/apps/adminpanel/urls.py | 4 + core/backend/apps/adminpanel/views.py | 81 ++++- core/backend/apps/ai/free_video.py | 38 ++- .../ai/migrations/0024_aitask_base_cost.py | 18 ++ .../apps/ai/migrations/0025_points_rescale.py | 35 +++ core/backend/apps/ai/models.py | 4 + core/backend/apps/ai/services.py | 108 ++++++- core/backend/apps/ai/test_free_video.py | 17 +- .../billing/migrations/0003_billingconfig.py | 51 ++++ .../billing/migrations/0004_points_rescale.py | 44 +++ core/backend/apps/billing/models.py | 17 ++ core/backend/apps/billing/pricing.py | 178 +++++++++++ core/backend/apps/billing/services/ledger.py | 37 ++- core/backend/apps/billing/tests.py | 288 +++++++++++++++++- core/backend/apps/billing/urls.py | 3 +- core/backend/apps/billing/views.py | 60 +++- core/backend/apps/projects/models.py | 2 + core/backend/apps/projects/tests.py | 84 +++++ core/frontend/src/App.tsx | 4 +- core/frontend/src/api.ts | 19 +- .../src/components/free-create/constants.ts | 23 +- .../free-create/generation-card.tsx | 2 +- .../src/components/free-create/input-bar.tsx | 6 +- .../src/components/free-create/toolbar.tsx | 11 +- .../free-create/video-detail-modal.tsx | 2 +- core/frontend/src/components/pager.tsx | 10 +- core/frontend/src/routes/account.tsx | 40 ++- .../src/routes/admin/admin-billing.tsx | 92 +++++- .../src/routes/admin/admin-teams-users.tsx | 93 +++++- core/frontend/src/routes/ai-tools.tsx | 38 ++- core/frontend/src/routes/dashboard.tsx | 2 +- core/frontend/src/routes/free-create.tsx | 11 + core/frontend/src/routes/pipeline.tsx | 22 +- core/frontend/src/routes/products.tsx | 2 +- core/frontend/src/routes/stage-config.ts | 19 +- core/frontend/src/routes/team.tsx | 29 +- core/frontend/src/types.ts | 18 ++ core/qa/audit_billing.py | 19 ++ 46 files changed, 1490 insertions(+), 150 deletions(-) create mode 100644 core/backend/apps/accounts/migrations/0008_points_rescale.py create mode 100644 core/backend/apps/accounts/migrations/0009_team_price_multiplier.py create mode 100644 core/backend/apps/ai/migrations/0024_aitask_base_cost.py create mode 100644 core/backend/apps/ai/migrations/0025_points_rescale.py create mode 100644 core/backend/apps/billing/migrations/0003_billingconfig.py create mode 100644 core/backend/apps/billing/migrations/0004_points_rescale.py create mode 100644 core/backend/apps/billing/pricing.py diff --git a/core/backend/.env b/core/backend/.env index 9a786cf..9b4b4c6 100644 --- a/core/backend/.env +++ b/core/backend/.env @@ -23,7 +23,7 @@ VOLCANO_ARK_API_KEY=ark-24d5627e-28e4-4412-8679-46a6e9f26aab-6e951 VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 # 临时:视频(Seedance)借 AirDrama 火山账号 ARK key,与真人素材库审核同账号 → asset:// 可解析。待自有账号开通素材库后删此行。 VIDEO_ARK_API_KEY=9225161a-a640-47e6-94ed-81f6c5610072 -DEFAULT_TRIAL_CREDITS=1000.0000 +DEFAULT_TRIAL_CREDITS=0 YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf YUNQI_BASE_URL=https://www.yunqiai.chat/v1 # YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度) diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 1f9497d..9963c91 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -243,4 +243,5 @@ ASSETS_API = { "project_name": env("ASSETS_API_PROJECT_NAME", "int_dev_Airlabs"), } -DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000") +# 开户赠送额度(积分)。商业决策(2026-07-03):不赠送(=0);genesis 流水仅 trial>0 才落(I8 天然满足)。 +DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "0") diff --git a/core/backend/apps/accounts/migrations/0008_points_rescale.py b/core/backend/apps/accounts/migrations/0008_points_rescale.py new file mode 100644 index 0000000..fb64f1c --- /dev/null +++ b/core/backend/apps/accounts/migrations/0008_points_rescale.py @@ -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))] diff --git a/core/backend/apps/accounts/migrations/0009_team_price_multiplier.py b/core/backend/apps/accounts/migrations/0009_team_price_multiplier.py new file mode 100644 index 0000000..534e222 --- /dev/null +++ b/core/backend/apps/accounts/migrations/0009_team_price_multiplier.py @@ -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), + ), + ] diff --git a/core/backend/apps/accounts/models.py b/core/backend/apps/accounts/models.py index e1bdf1a..0b541ed 100644 --- a/core/backend/apps/accounts/models.py +++ b/core/backend/apps/accounts/models.py @@ -35,6 +35,11 @@ class Team(TimeStampedModel): # 团队级月限额(超管在团队页设置,自然月重置)。语义三态,与成员级 0=不限 不同: # None = 未设置(前端按成员月度额度累加作团队月限额)· -1 = 不限 · >=0 = 固定上限。 monthly_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, default=None) + # 团队价格系数(差异化调价,jimeng 同款诉求):最终积分价 = 标准挂牌价 × 系数(HALF_UP,最低 1 积分)。 + # <1 = 大客户折扣,>1 = 渠道加价;全部计费类型统一生效;对团队侧静默(只体现在预估/实扣数字里)。 + # 视频类按实结算用**下单时的快照系数**(request_payload.price_multiplier),中途改价不影响在途任务。 + # 仅平台超管可改(admin 团队定价端点,0.10~10.00 边界)。 + price_multiplier = models.DecimalField(max_digits=5, decimal_places=2, default=1) def __str__(self) -> str: return self.name diff --git a/core/backend/apps/accounts/tests.py b/core/backend/apps/accounts/tests.py index e5c4933..756e712 100644 --- a/core/backend/apps/accounts/tests.py +++ b/core/backend/apps/accounts/tests.py @@ -54,9 +54,15 @@ class AuthApiTests(TestCase): account = CreditAccount.objects.get(team=team) self.assertEqual(account.balance, trial) genesis = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.RECHARGE) - self.assertEqual(genesis.count(), 1) - self.assertEqual(genesis.first().amount, trial) - self.assertEqual(genesis.first().balance_after, trial) # 流水终点 == 账户余额,可对账 + if trial > 0: + # 若运营期把赠送额度调回 >0,genesis 凭证契约必须成立(I8) + self.assertEqual(genesis.count(), 1) + self.assertEqual(genesis.first().amount, trial) + self.assertEqual(genesis.first().balance_after, trial) # 流水终点 == 账户余额,可对账 + else: + # 商业决策(2026-07-03):开户不赠送 → 余额 0 且不落赠送流水(I8 隐含余额 0 天然满足) + self.assertEqual(trial, Decimal("0")) + self.assertEqual(genesis.count(), 0) class InvitationFlowTests(TestCase): @@ -150,7 +156,7 @@ class MemberQuotaAndRoleTests(TestCase): r = self._register(APIClient(), "solo-owner", team_name="Solo", invite_code=make_create_team_code()) self.assertEqual(r.data.get("role"), "owner") - def test_create_member_defaults_limit_100(self): + def test_create_member_defaults_limit_1000(self): res = self.owner_client.post( "/api/auth/team/members/", {"username": "sub-a", "password": "strong-password", "role": "member"}, @@ -158,7 +164,8 @@ class MemberQuotaAndRoleTests(TestCase): ) self.assertEqual(res.status_code, 201, res.content) member = TeamMember.objects.get(user__username="sub-a", team=self.team) - self.assertEqual(member.monthly_credit_limit, Decimal("100")) + # 积分制:默认月限额跟随 ×10(¥100 → 1000 积分),否则新成员限额实际缩水 10 倍 + self.assertEqual(member.monthly_credit_limit, Decimal("1000")) def test_create_member_explicit_limit_respected(self): res = self.owner_client.post( diff --git a/core/backend/apps/accounts/views.py b/core/backend/apps/accounts/views.py index ea5a211..08ed292 100644 --- a/core/backend/apps/accounts/views.py +++ b/core/backend/apps/accounts/views.py @@ -27,7 +27,7 @@ from .serializers import ( # 子账号默认月度限额(0=不限,只留给主账号/超管) -DEFAULT_MEMBER_MONTHLY_LIMIT = 100 +DEFAULT_MEMBER_MONTHLY_LIMIT = 1000 def member_role(user, team): diff --git a/core/backend/apps/adminpanel/serializers.py b/core/backend/apps/adminpanel/serializers.py index 639c2e1..6fd9c54 100644 --- a/core/backend/apps/adminpanel/serializers.py +++ b/core/backend/apps/adminpanel/serializers.py @@ -75,7 +75,7 @@ class AdminTeamSerializer(serializers.ModelSerializer): class Meta: model = Team - fields = ["id", "name", "status", "owner", "owner_username", "member_count", "balance", "created_at"] + fields = ["id", "name", "status", "owner", "owner_username", "member_count", "balance", "price_multiplier", "created_at"] read_only_fields = fields def get_member_count(self, obj): @@ -116,18 +116,38 @@ class AdminTaskSerializer(serializers.ModelSerializer): team_name = serializers.CharField(source="team.name", read_only=True, default=None) model_name = serializers.CharField(source="model_config.name", read_only=True, default=None) cost_anomaly = serializers.SerializerMethodField() + # 单任务毛利(¥):actual_cost(积分)÷汇率 − base_cost。base_cost=0(成本未知)时 None,报表侧过滤 + margin_yuan = serializers.SerializerMethodField() class Meta: model = AITask fields = [ "id", "task_type", "status", "team", "team_name", "model_name", - "estimated_cost", "actual_cost", "cost_anomaly", "error_code", "created_at", + "estimated_cost", "actual_cost", "base_cost", "margin_yuan", "cost_anomaly", "error_code", "created_at", ] read_only_fields = fields def get_cost_anomaly(self, obj) -> bool: return is_cost_anomaly(obj.estimated_cost, obj.actual_cost) + def get_margin_yuan(self, obj) -> str | None: + base = obj.base_cost or Decimal("0") + actual = obj.actual_cost or Decimal("0") + if base <= 0 or actual <= 0: + return None + from apps.billing.pricing import get_billing_config + + # 优先用任务计价当时的汇率快照:汇率调整不追溯历史,毛利报表不整体漂移(review 确认)。 + # __dict__ 直取避免触发 deferred 列加载(有的列表 queryset 会 defer payload)。 + payload = obj.__dict__.get("request_payload") or {} + try: + rate = Decimal(str(payload.get("points_per_yuan_snapshot") or "")) if payload.get("points_per_yuan_snapshot") else get_billing_config().points_per_yuan + except Exception: # noqa: BLE001 + rate = get_billing_config().points_per_yuan + if rate <= 0: + return None + return str((actual / rate - base).quantize(Decimal("0.01"))) + class AdminTaskDetailSerializer(AdminTaskSerializer): class Meta(AdminTaskSerializer.Meta): diff --git a/core/backend/apps/adminpanel/urls.py b/core/backend/apps/adminpanel/urls.py index 515ed1b..1dc4656 100644 --- a/core/backend/apps/adminpanel/urls.py +++ b/core/backend/apps/adminpanel/urls.py @@ -2,6 +2,7 @@ from django.urls import path from .views import ( admin_asset_reviews, + admin_billing_config, admin_asset_reviews_poll, admin_asset_reviews_submit, admin_invitations, @@ -25,6 +26,7 @@ from .views import ( admin_quality_words, admin_revoke_invitation, admin_team_detail, + admin_team_pricing, admin_team_toggle, admin_teams, admin_user_reset_password, @@ -39,6 +41,7 @@ urlpatterns = [ path("teams/", admin_teams, name="admin-teams"), path("teams//", admin_team_detail, name="admin-team-detail"), path("teams//toggle/", admin_team_toggle, name="admin-team-toggle"), + path("teams//pricing/", admin_team_pricing, name="admin-team-pricing"), path("users/", admin_users, name="admin-users"), path("users//toggle/", admin_user_toggle, name="admin-user-toggle"), path("users//reset-password/", admin_user_reset_password, name="admin-user-reset-password"), @@ -54,6 +57,7 @@ urlpatterns = [ path("tasks//retry/", admin_task_retry, name="admin-task-retry"), path("ledgers/", admin_ledgers, name="admin-ledgers"), path("ledgers/adjust/", admin_ledger_adjust, name="admin-ledger-adjust"), + path("billing-config/", admin_billing_config, name="admin-billing-config"), path("quota-policies/", admin_quota_policies, name="admin-quota-policies"), path("quota-policies//", admin_quota_policy_detail, name="admin-quota-policy-detail"), path("providers/", admin_providers, name="admin-providers"), diff --git a/core/backend/apps/adminpanel/views.py b/core/backend/apps/adminpanel/views.py index 788fedb..2cebb8f 100644 --- a/core/backend/apps/adminpanel/views.py +++ b/core/backend/apps/adminpanel/views.py @@ -1,6 +1,6 @@ """平台超管后台 · 跨团队端点。所有视图统一挂 IsPlatformAdmin,非超管一律 403,写操作记审计。""" -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP from django.db.models import Count, F, Q from rest_framework import status @@ -15,7 +15,8 @@ from apps.accounts.serializers import InvitationSerializer from apps.ai.models import AITask, ModelConfig, ModelProvider, PromptTemplate, QualityWord from apps.assets.models import Asset from apps.assets.review import poll_asset_review, submit_asset_for_review -from apps.billing.models import CreditLedger, QuotaPolicy +from apps.billing.models import BillingConfig, CreditLedger, QuotaPolicy +from apps.billing.pricing import get_billing_config, invalidate_billing_config_cache from apps.billing.services.ledger import adjust_credit from apps.common.pagination import DefaultPagination from apps.products.models import Product @@ -160,6 +161,38 @@ def admin_team_toggle(request, team_id): # ─────────────────────────── 用户管理 ─────────────────────────── +@api_view(["POST"]) +@permission_classes([IsPlatformAdmin]) +def admin_team_pricing(request, team_id): + """团队差异化调价(jimeng 同款诉求):设置团队价格系数(0.10~10.00)。 + 最终积分价 = 挂牌价 × 系数;全部计费类型统一生效;视频在途任务用下单快照不受影响。""" + from decimal import InvalidOperation + + team = Team.objects.filter(id=team_id).first() + if team is None: + return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND) + try: + value = Decimal(str(request.data.get("price_multiplier"))) + except (InvalidOperation, TypeError, ValueError): + return Response({"price_multiplier": ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST) + if not value.is_finite() or value < Decimal("0.10") or value > Decimal("10"): + return Response({"price_multiplier": ["系数需在 0.10 ~ 10.00 之间"]}, status=status.HTTP_400_BAD_REQUEST) + before = str(team.price_multiplier) + # 显式 HALF_UP:quantize 默认银行家舍入(0.125→0.12),与全仓取整纪律不一致(review 确认) + team.price_multiplier = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + team.save(update_fields=["price_multiplier", "updated_at"]) + log_admin_action( + request, + "team.pricing_update", + target_type="team", + target_id=team.id, + target_name=team.name, + before={"price_multiplier": before}, + after={"price_multiplier": str(team.price_multiplier)}, + ) + return Response(AdminTeamSerializer(_team_qs().get(id=team.id)).data) + + @api_view(["GET"]) @permission_classes([IsPlatformAdmin]) def admin_users(request): @@ -496,6 +529,50 @@ def admin_ledger_adjust(request): return Response(AdminLedgerSerializer(ledger).data, status=status.HTTP_201_CREATED) +def _billing_config_payload(cfg: BillingConfig) -> dict: + return { + "points_per_yuan": str(cfg.points_per_yuan), + "video_margin_multiplier": str(cfg.video_margin_multiplier), + "video_reserve_buffer": str(cfg.video_reserve_buffer), + "updated_at": cfg.updated_at, + } + + +@api_view(["GET", "PATCH"]) +@permission_classes([IsPlatformAdmin]) +def admin_billing_config(request): + """平台计费配置(积分汇率/视频毛利系数/预留 buffer)。PATCH 即刻生效于下一次估价/结算。""" + from decimal import InvalidOperation + + cfg = get_billing_config() + if request.method == "GET": + return Response(_billing_config_payload(cfg)) + before = _billing_config_payload(cfg) + updates: dict = {} + for field, minimum in (("points_per_yuan", Decimal("0.01")), ("video_margin_multiplier", Decimal("0.01")), ("video_reserve_buffer", Decimal("1"))): + if request.data.get(field) is None: + continue + try: + value = Decimal(str(request.data[field])) + except (InvalidOperation, TypeError, ValueError): + return Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST) + # Decimal("NaN") 构造不抛,比较才抛 InvalidOperation → 500(review 确认),显式拦 + if not value.is_finite(): + return Response({field: ["数值格式不正确"]}, status=status.HTTP_400_BAD_REQUEST) + if value < minimum: + return Response({field: [f"不能小于 {minimum}"]}, status=status.HTTP_400_BAD_REQUEST) + updates[field] = value + if not updates: + return Response({"detail": "没有可更新的字段"}, status=status.HTTP_400_BAD_REQUEST) + for field, value in updates.items(): + setattr(cfg, field, value) + cfg.save(update_fields=[*updates.keys(), "updated_at"]) + invalidate_billing_config_cache() + after = _billing_config_payload(cfg) + log_admin_action(request, "billing_config.update", target_type="billing_config", target_id=cfg.id, before=before, after=after) + return Response(after) + + @api_view(["GET", "POST"]) @permission_classes([IsPlatformAdmin]) def admin_quota_policies(request): diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index c55b191..91e5341 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -15,7 +15,6 @@ import logging import re import uuid from datetime import timedelta -from decimal import Decimal, ROUND_HALF_UP from io import BytesIO from django.conf import settings @@ -25,17 +24,13 @@ from django.utils import timezone from apps.assets.models import Asset, AssetFile, FreeAsset, FreeAssetGroup from apps.assets.storage import TosStorage +from apps.billing.pricing import quote_video_actual, quote_video_estimate, video_reserve_amount from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit from .models import AITask, ModelConfig from .providers.volcano import VolcanoArkProvider from .video_errors import map_video_error, parse_provider_error -from .video_pricing import ( - RESERVE_BUFFER, - estimate_video_cost, - get_resolution, - tokens_to_cost, -) +from .video_pricing import get_resolution logger = logging.getLogger(__name__) @@ -372,14 +367,16 @@ def submit_free_video(*, team, user, params: dict) -> AITask: built = build_content_items(team=team, prompt=prompt, mode=mode, references=references) - tokens, cost = estimate_video_cost( + # 统一计价引擎:¥成本 × 毛利系数 → 积分;预留 = 积分 × buffer(均为 BillingConfig 可配) + tokens, quote = quote_video_estimate( model_config, aspect_ratio=aspect_ratio, resolution=resolution, duration=duration, references=built["snapshots"], + team=team, ) - reserve_amount = (cost * RESERVE_BUFFER).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + reserve_amount = video_reserve_amount(quote.points) request_payload = { "feature": "free_video", @@ -395,6 +392,9 @@ def submit_free_video(*, team, user, params: dict) -> AITask: "generate_audio": generate_audio, "search_mode": search_mode, "estimated_tokens": tokens, + # 团队价格系数快照:按实结算用它,中途改价不影响在途任务 + "price_multiplier": quote.meta.get("price_multiplier", "1"), + "points_per_yuan_snapshot": quote.meta.get("rate", ""), "references": built["snapshots"], } @@ -409,7 +409,8 @@ def submit_free_video(*, team, user, params: dict) -> AITask: model_config=model_config, idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}", request_payload=request_payload, - estimated_cost=cost, + estimated_cost=quote.points, + base_cost=quote.base_cost_yuan, ) try: reserve_credit(team=team, user=user, task=task, amount=reserve_amount) @@ -619,12 +620,18 @@ def finalize_free_video(*, task: AITask) -> AITask: with_video_ref = any((r or {}).get("type") == "video" for r in payload.get("references") or []) resolution = payload.get("resolution") or "720p" if total_tokens > 0: - actual = tokens_to_cost( - locked.model_config, total_tokens, with_video_ref=with_video_ref, resolution=resolution + from decimal import Decimal + + settle = quote_video_actual( + locked.model_config, tokens=total_tokens, with_video_ref=with_video_ref, resolution=resolution, + multiplier=Decimal(str(payload.get("price_multiplier") or "1")), ) + actual, base_cost = settle.points, settle.base_cost_yuan payload["actual_tokens"] = total_tokens + if settle.meta.get("rate"): + payload["points_per_yuan_snapshot"] = settle.meta["rate"] else: - actual = locked.estimated_cost + actual, base_cost = locked.estimated_cost, locked.base_cost seed_out = response.get("seed") if seed_out is not None: payload["seed_used"] = seed_out @@ -635,7 +642,7 @@ def finalize_free_video(*, task: AITask) -> AITask: return locked reservation = locked.credit_reservation if actual > reservation.amount: - # ledger 禁超预留扣费 → clamp 到预留额,差额平台承担并告警(长期观测调 RESERVE_BUFFER) + # ledger 禁超预留扣费 → clamp 到预留额,差额平台承担并告警(长期观测调 buffer) logger.warning( "free video task %s actual cost %s exceeds reserved %s, clamped", locked.id, actual, reservation.amount, @@ -643,11 +650,12 @@ def finalize_free_video(*, task: AITask) -> AITask: actual = reservation.amount locked.status = AITask.Status.SUCCEEDED locked.actual_cost = actual + locked.base_cost = base_cost locked.request_payload = payload locked.response_payload = response locked.completed_at = timezone.now() locked.save( - update_fields=["status", "actual_cost", "request_payload", "response_payload", "completed_at", "updated_at"] + update_fields=["status", "actual_cost", "base_cost", "request_payload", "response_payload", "completed_at", "updated_at"] ) charge_reserved_credit(reservation=reservation, actual_amount=actual) return locked diff --git a/core/backend/apps/ai/migrations/0024_aitask_base_cost.py b/core/backend/apps/ai/migrations/0024_aitask_base_cost.py new file mode 100644 index 0000000..9682a91 --- /dev/null +++ b/core/backend/apps/ai/migrations/0024_aitask_base_cost.py @@ -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), + ), + ] diff --git a/core/backend/apps/ai/migrations/0025_points_rescale.py b/core/backend/apps/ai/migrations/0025_points_rescale.py new file mode 100644 index 0000000..254c580 --- /dev/null +++ b/core/backend/apps/ai/migrations/0025_points_rescale.py @@ -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))] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index 292cf9c..6534b7b 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -133,8 +133,12 @@ class AITask(TeamOwnedModel): provider_task_id = models.CharField(max_length=255, blank=True) request_payload = models.JSONField(default=dict, blank=True) response_payload = models.JSONField(default=dict, blank=True) + # estimated/actual_cost:用户侧计价,单位**积分**(积分制重构后;历史行已 rescale ×10) estimated_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0) actual_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0) + # 平台真实成本,单位**人民币**(供应商结算口径)。0 = 成本未知(历史任务/未配置成本的模型)。 + # 毛利 = actual_cost/points_per_yuan − base_cost;adminpanel 与 audit I9 消费。 + base_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0) error_code = models.CharField(max_length=64, blank=True) error_message = models.TextField(blank=True) submitted_at = models.DateTimeField(null=True, blank=True) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index e51add4..8f8f9b5 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -120,8 +120,8 @@ def get_video_provider(model_config: ModelConfig): return build_provider(model_config) -def estimate_cost(model_config: ModelConfig) -> Decimal: - return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000") +# estimate_cost() 已退役:全平台定价统一走 apps/billing/pricing.py 计价引擎(积分制)。 +# flat 类型默认价由 create_ai_task 内 quote_flat 提供;视频/配音各入口自带 quote。 def parse_segment_fields(block: str) -> tuple[str, str]: @@ -629,8 +629,28 @@ def split_script_into_segments(content: str, count: int = 4) -> list[str]: @transaction.atomic -def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig, request_payload: dict) -> AITask: - cost = estimate_cost(model_config) +def create_ai_task( + *, + project, + user, + task_type: str, + model_config: ModelConfig, + request_payload: dict, + quote: "Quote | None" = None, + reserve_amount: Decimal | None = None, +) -> AITask: + """建任务 + 预留积分(统一计价枢纽)。 + + quote 不传 = flat 计价(unit_price 积分/次,文本/图像走这里);视频/配音入口自带 quote。 + reserve_amount 仅视频类传(= 积分×buffer,应对真实 tokens 超预估;ledger 禁超预留扣费)。 + estimated_cost 记用户价(积分),base_cost 记平台成本(¥,未配置=0)。 + """ + from apps.billing.pricing import quote_flat + + quote = quote or quote_flat(model_config, team=project.team) + # 汇率快照:margin_yuan 报表用「计价当时」的 points_per_yuan,汇率调整不追溯历史任务(review 确认) + if quote.meta.get("rate"): + request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]} task = AITask.objects.create( team=project.team, created_by=user, @@ -640,9 +660,10 @@ def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig, model_config=model_config, idempotency_key=f"{task_type}:{project.id}:{uuid.uuid4()}", request_payload=request_payload, - estimated_cost=cost, + estimated_cost=quote.points, + base_cost=quote.base_cost_yuan, ) - reserve_credit(team=project.team, user=user, task=task, amount=cost) + reserve_credit(team=project.team, user=user, task=task, amount=reserve_amount or quote.points) task.status = AITask.Status.RESERVED task.save(update_fields=["status", "updated_at"]) return task @@ -2216,17 +2237,36 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V reference_images = [r["url"] for r in refs] final_prompt = build_video_segment_prompt(project, video_segment, scene, refs, prompt) + # 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按 9:16/720p/目标时长预估, + # 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。 + # 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。 + from apps.billing.pricing import quote_video_estimate, video_reserve_amount + + est_tokens, quote = quote_video_estimate( + model_config, + aspect_ratio="9:16", + resolution="720p", + duration=video_segment.target_duration_seconds, + references=[], + team=project.team, + ) task = create_ai_task( project=project, user=user, task_type=AITask.Type.VIDEO_SEGMENT, model_config=model_config, + quote=quote, + reserve_amount=video_reserve_amount(quote.points), request_payload={ "model": model_config.name, "endpoint": model_config.endpoint, "prompt": final_prompt, "duration": video_segment.target_duration_seconds, "ratio": "9:16", + "resolution": "720p", + "estimated_tokens": est_tokens, + # 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律) + "price_multiplier": quote.meta.get("price_multiplier", "1"), "video_segment_id": str(video_segment.id), "reference_images": reference_images, }, @@ -2343,12 +2383,44 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers existing = video_segment.versions.filter(task=locked_task).order_by("-created_at").first() if existing is not None: return existing + # 按火山真实 usage.total_tokens 结算(true-up,与自由创作同口径): + # 多退(charge 差额自动 RELEASE)/超预留 clamp(ledger 禁超扣,差额平台承担并告警)。 + # usage 缺失(异常响应)回落预估价,不阻断出片。 + from apps.billing.pricing import quote_video_actual + + reservation = locked_task.credit_reservation + payload = locked_task.request_payload or {} + try: + usage_tokens = int((response.get("usage") or {}).get("total_tokens") or 0) + except (TypeError, ValueError): + usage_tokens = 0 + if usage_tokens > 0: + settle = quote_video_actual( + locked_task.model_config, + tokens=usage_tokens, + with_video_ref=False, + resolution=str(payload.get("resolution") or "720p"), + multiplier=Decimal(str(payload.get("price_multiplier") or "1")), + ) + actual_points, base_cost = settle.points, settle.base_cost_yuan + if actual_points > reservation.amount: + logger.warning( + "video segment task %s actual %s exceeds reserved %s, clamped", + locked_task.id, actual_points, reservation.amount, + ) + actual_points = reservation.amount + else: + actual_points, base_cost = locked_task.estimated_cost, locked_task.base_cost + if usage_tokens > 0 and settle.meta.get("rate"): + payload["points_per_yuan_snapshot"] = settle.meta["rate"] + locked_task.request_payload = payload locked_task.status = AITask.Status.SUCCEEDED locked_task.response_payload = response - locked_task.actual_cost = locked_task.estimated_cost + locked_task.actual_cost = actual_points + locked_task.base_cost = base_cost locked_task.completed_at = timezone.now() - locked_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) - charge_reserved_credit(reservation=locked_task.credit_reservation, actual_amount=locked_task.actual_cost) + locked_task.save(update_fields=["status", "request_payload", "response_payload", "actual_cost", "base_cost", "completed_at", "updated_at"]) + charge_reserved_credit(reservation=reservation, actual_amount=actual_points) version = VideoSegmentVersion.objects.create( video_segment=video_segment, task=locked_task, @@ -2452,13 +2524,17 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c # 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。 platform_key = str(platform_id or "").strip() if mode == "cover" else "" platform_name = _PLATFORM_NAMES.get(platform_key, "") + from apps.billing.pricing import quote_flat + tasks: list[AITask] = [] for index in range(count): - cost = estimate_cost(model_config) + quote = quote_flat(model_config, team=team) request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None} # 只在重跑/补图时落键(不落 False):workbench 用 KeyTextTransform 抽文本,"false" 字符串也是真值,会误判 if is_append: request_payload["batch_append"] = True + if quote.meta.get("rate"): + request_payload["points_per_yuan_snapshot"] = quote.meta["rate"] task = AITask.objects.create( team=team, created_by=user, @@ -2469,10 +2545,11 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c model_config=model_config, idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}", request_payload=request_payload, - estimated_cost=cost, + estimated_cost=quote.points, + base_cost=quote.base_cost_yuan, ) # 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务) - reserve_credit(team=team, user=user, task=task, amount=cost) + reserve_credit(team=team, user=user, task=task, amount=quote.points) task.status = AITask.Status.RESERVED task.save(update_fields=["status", "updated_at"]) tasks.append(task) @@ -2678,14 +2755,21 @@ def synthesize_project_voiceover(*, project, user, items: list[dict], voice_type model_config = get_default_model(ModelConfig.Capability.AUDIO) if model_config is None: raise ValueError("no active audio model configured") + # 配音按字符数阶梯计价(默认每 500 字 10 积分,不足按 500):长短脚本不再同价 + from apps.billing.pricing import quote_voiceover + + char_count = sum(len(text) for _, _, text in texts) + quote = quote_voiceover(model_config, char_count=char_count, team=project.team) task = create_ai_task( project=project, user=user, task_type=AITask.Type.VOICEOVER, model_config=model_config, + quote=quote, request_payload={ "voice_type": voice_type, "speed_ratio": float(speed_ratio or 1.0), + "char_count": char_count, "items": [{"index": idx, "cue": j, "text": text} for idx, j, text in texts], }, ) diff --git a/core/backend/apps/ai/test_free_video.py b/core/backend/apps/ai/test_free_video.py index 46fe4c6..59410cb 100644 --- a/core/backend/apps/ai/test_free_video.py +++ b/core/backend/apps/ai/test_free_video.py @@ -26,6 +26,7 @@ from apps.ai.video_pricing import ( get_token_price, ) from apps.billing.models import CreditAccount, CreditLedger, CreditReservation +from apps.billing.pricing import quote_video_actual, quote_video_estimate, video_reserve_amount STANDARD = "doubao-seedance-2-0-260128" FAST = "doubao-seedance-2-0-fast-260128" @@ -165,12 +166,15 @@ class SubmitFreeVideoTests(TestCase): task = submit_free_video(team=self.team, user=self.user, params=self._params()) self.assertEqual(task.status, AITask.Status.SUBMITTED) self.assertEqual(task.provider_task_id, "ark-1") - tokens, cost = estimate_video_cost( + # 积分制:estimated_cost=积分(¥成本×毛利×汇率取整),base_cost=¥成本;预留=积分×buffer + tokens, quote = quote_video_estimate( _model(STANDARD), aspect_ratio="16:9", resolution="480p", duration=4, references=[] ) - self.assertEqual(task.estimated_cost, cost) + self.assertEqual(task.estimated_cost, quote.points) + self.assertEqual(task.base_cost, quote.base_cost_yuan) + self.assertGreater(quote.points, 0) reservation = CreditReservation.objects.get(task=task) - self.assertEqual(reservation.amount, (cost * RESERVE_BUFFER).quantize(Decimal("0.01"))) + self.assertEqual(reservation.amount, video_reserve_amount(quote.points)) # 提交参数按契约落 payload self.assertEqual(task.request_payload["estimated_tokens"], tokens) self.assertEqual(task.request_payload["feature"], "free_video") @@ -284,13 +288,14 @@ class FinalizeFreeVideoTests(TestCase): } task = finalize_free_video(task=self.task) self.assertEqual(task.status, AITask.Status.SUCCEEDED) - expected = calculate_cost(30000, Decimal("46")) - self.assertEqual(task.actual_cost, expected) + settle = quote_video_actual(_model(STANDARD), tokens=30000, with_video_ref=False, resolution="480p") + self.assertEqual(task.actual_cost, settle.points) + self.assertEqual(task.base_cost, settle.base_cost_yuan) # 平台成本(¥)随真实 tokens 落库 self.assertEqual(task.request_payload["seed_used"], 42) reservation = CreditReservation.objects.get(task=task) self.assertEqual(reservation.status, CreditReservation.Status.CHARGED) account = CreditAccount.objects.get(team=self.team) - self.assertEqual(account.balance, Decimal("100.0000") - expected) + self.assertEqual(account.balance, Decimal("100.0000") - settle.points) self.assertEqual(account.reserved_balance, Decimal("0")) self.store.assert_called_once() diff --git a/core/backend/apps/billing/migrations/0003_billingconfig.py b/core/backend/apps/billing/migrations/0003_billingconfig.py new file mode 100644 index 0000000..a9aaa6b --- /dev/null +++ b/core/backend/apps/billing/migrations/0003_billingconfig.py @@ -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, + ), + ] diff --git a/core/backend/apps/billing/migrations/0004_points_rescale.py b/core/backend/apps/billing/migrations/0004_points_rescale.py new file mode 100644 index 0000000..c395239 --- /dev/null +++ b/core/backend/apps/billing/migrations/0004_points_rescale.py @@ -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))] diff --git a/core/backend/apps/billing/models.py b/core/backend/apps/billing/models.py index 1461f91..6f71c4a 100644 --- a/core/backend/apps/billing/models.py +++ b/core/backend/apps/billing/models.py @@ -69,6 +69,23 @@ class CreditReservation(TimeStampedModel): expires_at = models.DateTimeField(null=True, blank=True) +class BillingConfig(TimeStampedModel): + """平台计费全局配置 · 单行单例(pk 恒定,经 pricing.get_billing_config() 缓存读取)。 + + 积分制核心参数:1 积分 = 1/points_per_yuan 元(默认 ¥0.1);用户侧余额/价格/流水全用积分, + 平台成本(base_cost)仍以 ¥ 记账。视频类用户价 = 火山成本价 × video_margin_multiplier 换积分 + (首发 ×1.5,商业决策;adminpanel 可调,改动即刻生效于下一次估价/结算)。 + """ + + points_per_yuan = models.DecimalField(max_digits=8, decimal_places=2, default=10) + video_margin_multiplier = models.DecimalField(max_digits=6, decimal_places=2, default=1.50) + # 视频预留 buffer(收编 free_video.RESERVE_BUFFER):预留=预估积分×buffer,应对真实 tokens 略超预估 + video_reserve_buffer = models.DecimalField(max_digits=4, decimal_places=2, default=1.10) + + def __str__(self) -> str: + return f"1¥={self.points_per_yuan}积分 · 视频毛利×{self.video_margin_multiplier}" + + class QuotaPolicy(TimeStampedModel): team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="quota_policies") user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, null=True, blank=True, related_name="quota_policies") diff --git a/core/backend/apps/billing/pricing.py b/core/backend/apps/billing/pricing.py new file mode 100644 index 0000000..a2caf39 --- /dev/null +++ b/core/backend/apps/billing/pricing.py @@ -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) diff --git a/core/backend/apps/billing/services/ledger.py b/core/backend/apps/billing/services/ledger.py index f4fe99d..f50024d 100644 --- a/core/backend/apps/billing/services/ledger.py +++ b/core/backend/apps/billing/services/ledger.py @@ -34,20 +34,44 @@ def _enforce_member_monthly_limit(*, team, user, amount: Decimal) -> None: day_start = now.replace(hour=0, minute=0, second=0, microsecond=0) charged = _charged_since(day_start) if charged + reserved + amount > day_limit: - raise ValueError(f"成员今日额度不足:每日限额 ¥{day_limit},今日已用 ¥{charged}(另有在途 ¥{reserved})") + raise ValueError(f"成员今日额度不足:每日限额 {day_limit} 积分,今日已用 {charged}(另有在途 {reserved})") month_limit = member.monthly_credit_limit or Decimal("0") if month_limit > 0: month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) charged = _charged_since(month_start) if charged + reserved + amount > month_limit: - raise ValueError(f"成员本月额度不足:限额 ¥{month_limit},本月已用 ¥{charged}(另有在途 ¥{reserved})") + raise ValueError(f"成员本月额度不足:限额 {month_limit} 积分,本月已用 {charged}(另有在途 {reserved})") total_limit = member.total_credit_limit or Decimal("0") if total_limit > 0: charged = _charged_since(None) if charged + reserved + amount > total_limit: - raise ValueError(f"成员累计额度不足:总额度 ¥{total_limit},累计已用 ¥{charged}(另有在途 ¥{reserved})") + raise ValueError(f"成员累计额度不足:总额度 {total_limit} 积分,累计已用 {charged}(另有在途 {reserved})") + + + +def _enforce_team_monthly_limit(*, team, amount: Decimal) -> None: + """Team.monthly_credit_limit 真管控(此前是装饰性字段:团队页可配,reserve 从不读 → 形同虚设)。 + 语义:None/-1/0 均不管控,仅正数为硬上限 —— 0 必须视为「未设置/不限」而非「冻结」: + 前端把 null 映射成 0 展示、成员限额也是 0=不限,存量数据里存在 0 行,若 0=冻结会在 + 部署当天把这些团队全体静默冻结(review 确认的语义冲突)。冻结消费请用 QuotaPolicy(monthly=0)。 + 口径与成员/QuotaPolicy 月度一致:自然月团队 CHARGE 合计 + 团队全部 ACTIVE 预留 + 本次。 + 调用方(reserve_credit)已持 account 行锁,团队内 reserve 串行化,无竞态。""" + limit = team.monthly_credit_limit + if limit is None or limit <= 0: + return + month_start = timezone.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + charged = ( + CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE, created_at__gte=month_start) + .aggregate(s=Sum("amount"))["s"] or Decimal("0") + ) + reserved = ( + CreditReservation.objects.filter(team=team, status=CreditReservation.Status.ACTIVE) + .aggregate(s=Sum("amount"))["s"] or Decimal("0") + ) + if charged + reserved + amount > limit: + raise ValueError(f"团队本月额度不足:限额 {limit} 积分,本月已用 {charged}(另有在途 {reserved})") def _enforce_quota_policy(*, team, project, amount: Decimal) -> None: @@ -61,7 +85,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None: if policy is None: return if policy.per_task_limit is not None and amount > policy.per_task_limit: - raise ValueError(f"单任务额度超限:上限 ¥{policy.per_task_limit}") + raise ValueError(f"单任务额度超限:上限 {policy.per_task_limit} 积分") now = timezone.now() month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) if policy.monthly_limit is not None: @@ -74,7 +98,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None: .aggregate(s=Sum("amount"))["s"] or Decimal("0") ) if charged + reserved + amount > policy.monthly_limit: - raise ValueError(f"团队本月额度超限:上限 ¥{policy.monthly_limit}") + raise ValueError(f"团队本月额度超限:上限 {policy.monthly_limit} 积分") if policy.project_limit is not None and project is not None: p_charged = ( CreditLedger.objects.filter(team=team, project=project, ledger_type=CreditLedger.Type.CHARGE) @@ -85,7 +109,7 @@ def _enforce_quota_policy(*, team, project, amount: Decimal) -> None: .aggregate(s=Sum("amount"))["s"] or Decimal("0") ) if p_charged + p_reserved + amount > policy.project_limit: - raise ValueError(f"项目额度超限:上限 ¥{policy.project_limit}") + raise ValueError(f"项目额度超限:上限 {policy.project_limit} 积分") @transaction.atomic @@ -116,6 +140,7 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation: if available < amount: raise ValueError("insufficient credit") _enforce_member_monthly_limit(team=team, user=user, amount=amount) + _enforce_team_monthly_limit(team=team, amount=amount) _enforce_quota_policy(team=team, project=task.project, amount=amount) account.reserved_balance += amount diff --git a/core/backend/apps/billing/tests.py b/core/backend/apps/billing/tests.py index 24cb449..532b660 100644 --- a/core/backend/apps/billing/tests.py +++ b/core/backend/apps/billing/tests.py @@ -190,8 +190,10 @@ class RechargePermissionTests(TestCase): client.force_authenticate(self.owner) response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json") self.assertEqual(response.status_code, 201) + # 积分制:支付 ¥100 × 10 = 到账 1000 积分;响应带换算快照 + self.assertEqual(response.json()["credited_points"], "1000") account = CreditAccount.objects.get(team=self.team) - self.assertEqual(account.balance, Decimal("100.0000")) + self.assertEqual(account.balance, Decimal("1000.0000")) def test_member_cannot_recharge(self): client = APIClient() @@ -201,3 +203,287 @@ class RechargePermissionTests(TestCase): account = CreditAccount.objects.get(team=self.team) self.assertEqual(account.balance, Decimal("0.0000")) # 余额未变,越权被拦 + + +class PricingEngineTests(TestCase): + """计价引擎(apps/billing/pricing):flat 回落 / 配音阶梯 / 视频毛利与取整 / 最低 1 积分。""" + + def setUp(self): + from apps.billing.pricing import invalidate_billing_config_cache + + invalidate_billing_config_cache() + provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"}) + self.text_model = ModelConfig.objects.create( + provider=provider, name="pe-text", display_name="T", capability=ModelConfig.Capability.TEXT, + unit_price=Decimal("10"), metadata={"pricing": {"mode": "flat", "base_cost_yuan": 0.1}}, + ) + self.audio_model = ModelConfig.objects.create( + provider=provider, name="pe-audio", display_name="A", capability=ModelConfig.Capability.AUDIO, + metadata={"pricing": {"mode": "per_chars", "chars_per_unit": 500, "points_per_unit": 10, "min_units": 1, "base_cost_yuan_per_unit": 0.25}}, + ) + + def test_flat_uses_unit_price_as_points(self): + from apps.billing.pricing import quote_flat + + quote = quote_flat(self.text_model) + self.assertEqual(quote.points, Decimal("10")) + self.assertEqual(quote.base_cost_yuan, Decimal("0.1")) + # 多单位(单图批量按张) + self.assertEqual(quote_flat(self.text_model, units=3).points, Decimal("30")) + + def test_flat_fallback_when_unit_price_unset(self): + from apps.billing.pricing import FLAT_FALLBACK_POINTS, quote_flat + + self.text_model.unit_price = Decimal("0") + quote = quote_flat(self.text_model) + self.assertEqual(quote.points, FLAT_FALLBACK_POINTS) + + def test_voiceover_char_tiers(self): + from apps.billing.pricing import quote_voiceover + + cases = {0: 1, 1: 1, 499: 1, 500: 1, 501: 2, 1500: 3, 1501: 4} + for chars, units in cases.items(): + quote = quote_voiceover(self.audio_model, char_count=chars) + self.assertEqual(quote.points, Decimal(units * 10), f"chars={chars}") + self.assertEqual(quote.base_cost_yuan, Decimal("0.25") * units, f"chars={chars}") + + def test_video_margin_and_rounding(self): + from apps.billing.pricing import get_billing_config, quote_video_from_cost + + cfg = get_billing_config() + self.assertEqual(cfg.video_margin_multiplier, Decimal("1.50")) # 首发 ×1.5(商业决策) + # ¥1.85 成本 × 1.5 × 10 = 27.75 → HALF_UP → 28 积分;base_cost 保留 ¥ 原值 + quote = quote_video_from_cost(Decimal("1.85")) + self.assertEqual(quote.points, Decimal("28")) + self.assertEqual(quote.base_cost_yuan, Decimal("1.85")) + + def test_minimum_one_point(self): + from apps.billing.pricing import quote_video_from_cost, yuan_to_points + + self.assertEqual(quote_video_from_cost(Decimal("0.001")).points, Decimal("1")) + self.assertEqual(yuan_to_points(Decimal("0")), Decimal("0")) # 0 成本不强收 + + def test_video_reserve_buffer(self): + from apps.billing.pricing import video_reserve_amount + + # 28 × 1.10 = 30.8 → 31 积分 + self.assertEqual(video_reserve_amount(Decimal("28")), Decimal("31")) + + +class TeamMonthlyLimitTests(TestCase): + """Team.monthly_credit_limit 真管控:None/-1/0 均不管控,仅正数=硬上限(含在途预留)。""" + + def setUp(self): + self.user = User.objects.create_user(username="tml-owner", password="p") + self.team = Team.objects.create(name="TML", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + CreditAccount.objects.create(team=self.team, balance=Decimal("1000.0000")) + provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"}) + self.model = ModelConfig.objects.create( + provider=provider, name="tml-m", display_name="M", capability=ModelConfig.Capability.TEXT, + ) + + def _task(self, key): + return AITask.objects.create( + team=self.team, created_by=self.user, task_type=AITask.Type.SCRIPT_GENERATION, + model_config=self.model, idempotency_key=f"tml-{key}", + ) + + def _reserve(self, key, amount): + return reserve_credit(team=self.team, user=self.user, task=self._task(key), amount=Decimal(str(amount))) + + def test_none_means_no_enforcement(self): + self.team.monthly_credit_limit = None + self.team.save(update_fields=["monthly_credit_limit"]) + self._reserve("a", 500) # 不抛 + + def test_minus_one_means_unlimited(self): + self.team.monthly_credit_limit = Decimal("-1") + self.team.save(update_fields=["monthly_credit_limit"]) + self._reserve("b", 500) # 不抛 + + def test_zero_means_no_enforcement(self): + # 0 必须不管控:前端把 null 映射成 0、成员限额 0=不限,存量 0 行若被解释成「冻结」 + # 会在部署当天静默冻结整个团队(review 确认)。冻结消费用 QuotaPolicy(monthly=0)。 + self.team.monthly_credit_limit = Decimal("0") + self.team.save(update_fields=["monthly_credit_limit"]) + self._reserve("c", 500) # 不抛 + + def test_positive_limit_counts_active_reservations(self): + self.team.monthly_credit_limit = Decimal("100") + self.team.save(update_fields=["monthly_credit_limit"]) + reservation = self._reserve("d", 60) # 在途 60 + with self.assertRaisesMessage(ValueError, "团队本月额度不足"): + self._reserve("e", 50) # 60+50 > 100 + # 结算 40(在途转已扣 40,当月已用 40):40+50 <= 100 → 放行 + charge_reserved_credit(reservation=reservation, actual_amount=Decimal("40")) + self._reserve("f", 50) + + +class RechargePointsTests(TestCase): + """充值积分语义:¥ × points_per_yuan + bonus_points;metadata 快照支付金额与汇率。""" + + def setUp(self): + self.owner = User.objects.create_user(username="rp-owner", password="p") + self.team = Team.objects.create(name="RP", owner=self.owner) + TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER) + CreditAccount.objects.create(team=self.team, balance=Decimal("0")) + self.client = APIClient() + self.client.force_authenticate(self.owner) + + def test_recharge_converts_yuan_to_points_with_bonus(self): + resp = self.client.post("/api/billing/recharge/", {"amount": "500", "bonus_points": "300"}, format="json") + self.assertEqual(resp.status_code, 201) + body = resp.json() + self.assertEqual(body["credited_points"], "5300") + self.assertEqual(body["points_per_yuan"], "10.00") + account = CreditAccount.objects.get(team=self.team) + self.assertEqual(account.balance, Decimal("5300")) + ledger = CreditLedger.objects.get(team=self.team, ledger_type=CreditLedger.Type.RECHARGE) + # 真实支付金额只能从 metadata 读(老流水不可用 amount÷rate 反推) + self.assertEqual(ledger.metadata["paid_amount"], "500") + self.assertEqual(ledger.metadata["bonus_points"], "300") + self.assertEqual(ledger.metadata["points_per_yuan"], "10.00") + + def test_legacy_bonus_yuan_converted(self): + resp = self.client.post("/api/billing/recharge/", {"amount": "100", "bonus": "20"}, format="json") + self.assertEqual(resp.status_code, 201) + self.assertEqual(resp.json()["credited_points"], "1200") # 100×10 + 20×10 + + +class BillingConfigEndpointTests(TestCase): + """公共 GET /api/billing/config/(鉴权)+ admin PATCH(权限/生效/审计)。""" + + def setUp(self): + from apps.billing.pricing import invalidate_billing_config_cache + + invalidate_billing_config_cache() + self.user = User.objects.create_user(username="bc-user", password="p") + self.team = Team.objects.create(name="BC", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + self.admin = User.objects.create_user(username="bc-admin", password="p", is_platform_admin=True) + + def test_public_config_requires_auth(self): + self.assertEqual(APIClient().get("/api/billing/config/").status_code, 401) + client = APIClient() + client.force_authenticate(self.user) + body = client.get("/api/billing/config/").json() + self.assertEqual(body["currency_unit"], "points") + self.assertEqual(body["points_per_yuan"], "10.00") + self.assertEqual(body["video_margin_multiplier"], "1.50") + + def test_admin_patch_updates_and_takes_effect(self): + from apps.billing.pricing import get_billing_config, invalidate_billing_config_cache + + client = APIClient() + client.force_authenticate(self.admin) + resp = client.patch("/api/admin/billing-config/", {"video_margin_multiplier": "2.5"}, format="json") + self.assertEqual(resp.status_code, 200) + invalidate_billing_config_cache() + self.assertEqual(get_billing_config().video_margin_multiplier, Decimal("2.5")) + # 非超管 PATCH 拒绝 + stranger = APIClient() + stranger.force_authenticate(self.user) + self.assertEqual(stranger.patch("/api/admin/billing-config/", {"points_per_yuan": "1"}, format="json").status_code, 403) + + +class TeamPriceMultiplierTests(TestCase): + """团队差异化调价:全类型统一系数、两步取整、视频结算用下单快照(中途改价不影响在途)。""" + + def setUp(self): + from apps.billing.pricing import invalidate_billing_config_cache + + invalidate_billing_config_cache() + self.user = User.objects.create_user(username="tpm-owner", password="p") + self.team = Team.objects.create(name="TPM", owner=self.user, price_multiplier=Decimal("0.80")) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + CreditAccount.objects.create(team=self.team, balance=Decimal("10000")) + provider, _ = ModelProvider.objects.get_or_create(name="volcengine", defaults={"display_name": "V"}) + self.image_model = ModelConfig.objects.create( + provider=provider, name="tpm-img", display_name="I", capability=ModelConfig.Capability.IMAGE, + unit_price=Decimal("20"), + ) + + def test_flat_applies_multiplier(self): + from apps.billing.pricing import quote_flat + + # 20 × 0.8 = 16;不传 team = 标准价(零回归) + quote = quote_flat(self.image_model, team=self.team) + self.assertEqual(quote.points, Decimal("16")) + self.assertEqual(quote_flat(self.image_model).points, Decimal("20")) + # meta.rate 是汇率快照契约:create_ai_task 落 payload.points_per_yuan_snapshot,毛利报表用它防汇率漂移 + self.assertEqual(Decimal(quote.meta["rate"]), Decimal("10")) + + def test_two_step_rounding(self): + from apps.billing.pricing import quote_video_from_cost + + # 挂牌:¥1.85×1.5×10=27.75→28;再 ×0.8=22.4→22(两步取整,与前端镜像逐字一致) + quote = quote_video_from_cost(Decimal("1.85"), multiplier=Decimal("0.80")) + self.assertEqual(quote.points, Decimal("22")) + self.assertEqual(quote.meta["price_multiplier"], "0.80") + + def test_minimum_one_point_after_discount(self): + from apps.billing.pricing import apply_team_price + + self.assertEqual(apply_team_price(Decimal("1"), Decimal("0.10")), Decimal("1")) + + def test_invalid_multiplier_falls_back_to_one(self): + from apps.billing.pricing import team_price_multiplier + + self.team.price_multiplier = Decimal("0") + self.assertEqual(team_price_multiplier(self.team), Decimal("1")) + self.assertEqual(team_price_multiplier(None), Decimal("1")) + + def test_video_settle_uses_snapshot_not_current(self): + """下单时 0.8 → 中途管理员改成 2.0 → 结算仍按 0.8 快照(jimeng 同款纪律)。""" + from unittest.mock import MagicMock, patch + + from apps.ai.free_video import finalize_free_video, submit_free_video + from apps.ai.models import AITask + + provider = MagicMock() + provider.create_video_task.return_value = {"id": "ark-tpm", "status": "queued"} + provider.extract_first_media_url.return_value = "http://x/v.mp4" + patch("apps.ai.services.build_provider", return_value=provider).start() + patch("apps.ai.tasks.poll_free_video_task.apply_async").start() + patch("apps.ai.free_video._store_free_video_media").start() + self.addCleanup(patch.stopall) + + task = submit_free_video(team=self.team, user=self.user, params={ + "prompt": "测试", "mode": "universal", "model": "doubao-seedance-2-0-260128", + "aspect_ratio": "16:9", "resolution": "480p", "duration": 4, "references": [], + }) + self.assertEqual(task.request_payload["price_multiplier"], "0.80") + # 挂牌 28 × 0.8 = 22.4 → 22 积分 + self.assertEqual(task.estimated_cost, Decimal("22")) + # 中途涨价到 2.0(在途任务不受影响) + self.team.price_multiplier = Decimal("2.00") + self.team.save(update_fields=["price_multiplier"]) + provider.poll_video_task.return_value = {"status": "succeeded", "usage": {"total_tokens": 30000}} + task = finalize_free_video(task=task) + # 真实 30000 tokens:挂牌 1.38×1.5×10=20.7→21;×快照0.8=16.8→17(而非 ×2.0=42) + self.assertEqual(task.status, AITask.Status.SUCCEEDED) + self.assertEqual(task.actual_cost, Decimal("17")) + + def test_admin_pricing_endpoint(self): + admin = User.objects.create_user(username="tpm-admin", password="p", is_platform_admin=True) + client = APIClient() + client.force_authenticate(admin) + resp = client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.9"}, format="json") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["price_multiplier"], "0.90") + # 三位小数走 HALF_UP 而非银行家舍入:0.125 → 0.13(quantize 默认会给 0.12) + resp = client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.125"}, format="json") + self.assertEqual(resp.json()["price_multiplier"], "0.13") + # 边界与非法值 + self.assertEqual(client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.05"}, format="json").status_code, 400) + self.assertEqual(client.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "NaN"}, format="json").status_code, 400) + # 非超管 403 + stranger = APIClient() + stranger.force_authenticate(self.user) + self.assertEqual(stranger.post(f"/api/admin/teams/{self.team.id}/pricing/", {"price_multiplier": "0.5"}, format="json").status_code, 403) + + def test_public_config_returns_team_multiplier(self): + client = APIClient() + client.force_authenticate(self.user) + self.assertEqual(client.get("/api/billing/config/").json()["team_price_multiplier"], "0.80") diff --git a/core/backend/apps/billing/urls.py b/core/backend/apps/billing/urls.py index 1c0595a..df65fe3 100644 --- a/core/backend/apps/billing/urls.py +++ b/core/backend/apps/billing/urls.py @@ -1,10 +1,11 @@ from django.urls import path -from .views import ledgers, recharge, summary, trend +from .views import config, ledgers, recharge, summary, trend urlpatterns = [ path("summary/", summary, name="billing-summary"), path("ledgers/", ledgers, name="billing-ledgers"), path("recharge/", recharge, name="billing-recharge"), path("trend/", trend, name="billing-trend"), + path("config/", config, name="billing-config"), ] diff --git a/core/backend/apps/billing/views.py b/core/backend/apps/billing/views.py index bc69dc0..d7a33b8 100644 --- a/core/backend/apps/billing/views.py +++ b/core/backend/apps/billing/views.py @@ -1,5 +1,5 @@ from datetime import timedelta -from decimal import Decimal, InvalidOperation +from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from django.db import transaction from django.db.models import Sum @@ -14,17 +14,21 @@ from apps.ai.models import AITask from apps.common.api import can_manage_team, get_current_team from .models import CreditAccount, CreditLedger +from .pricing import get_billing_config, team_price_multiplier from .serializers import CreditAccountSerializer, CreditLedgerSerializer # AITask.task_type → 账户页「按阶段分布」的 4 个聚合桶 _STAGE_BUCKET = { AITask.Type.SCRIPT_GENERATION: "script", AITask.Type.SCRIPT_OPTIMIZATION: "script", + AITask.Type.ENTITY_EXTRACTION: "script", AITask.Type.PRODUCT_IMAGE: "base", AITask.Type.PERSON_IMAGE: "base", AITask.Type.SCENE_IMAGE: "base", AITask.Type.STORYBOARD: "storyboard", AITask.Type.VIDEO_SEGMENT: "video", + AITask.Type.VOICEOVER: "video", + AITask.Type.FREE_VIDEO: "video", AITask.Type.EXPORT: "video", } @@ -90,24 +94,59 @@ def ledgers(request): ) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def config(request): + """计费公共配置:前端预估(自由创作 token 预估 / 图片单价 / 充值到账换算)统一读这里, + 与后端计价引擎同一来源,杜绝两端口径漂移。""" + cfg = get_billing_config() + # 团队价格系数(差异化调价):预估所见即所扣。平台超管无团队 → 回落 1。 + try: + multiplier = team_price_multiplier(get_current_team(request.user)) + except Exception: # noqa: BLE001 — 无团队(平台超管)等情况一律按标准价 + multiplier = Decimal("1") + return Response( + { + "currency_unit": "points", + "points_per_yuan": str(cfg.points_per_yuan), + "video_margin_multiplier": str(cfg.video_margin_multiplier), + "video_reserve_buffer": str(cfg.video_reserve_buffer), + "team_price_multiplier": str(multiplier), + } + ) + + @api_view(["POST"]) @permission_classes([IsAuthenticated]) def recharge(request): + """充值:amount 是真实支付人民币(¥),到账积分 = ¥ × points_per_yuan + bonus_points(赠送积分)。 + metadata 快照 paid_amount(¥)与当时汇率——老流水的支付金额只能从这里读,不可拿 amount÷rate 反推。""" team = get_current_team(request.user) # 充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客一律拒绝 if not can_manage_team(request.user, team): return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN) try: amount = Decimal(str(request.data.get("amount", "0"))) - bonus = Decimal(str(request.data.get("bonus", "0"))) + # 兼容旧键 bonus(历史前端按 ¥ 传):按汇率折成积分;新契约 bonus_points 直接是积分 + if request.data.get("bonus_points") is not None: + bonus_points = Decimal(str(request.data.get("bonus_points"))) + else: + bonus_yuan = Decimal(str(request.data.get("bonus", "0"))) + bonus_points = bonus_yuan * get_billing_config().points_per_yuan except (InvalidOperation, TypeError): return Response({"detail": "invalid amount"}, status=status.HTTP_400_BAD_REQUEST) - if amount <= 0: - return Response({"detail": "amount must be positive"}, status=status.HTTP_400_BAD_REQUEST) - if bonus < 0: + # Decimal("NaN")/("Infinity") 构造不抛,后续比较/入库才炸 500 → 显式拦(review 确认) + if not amount.is_finite() or not bonus_points.is_finite(): + return Response({"detail": "invalid amount"}, status=status.HTTP_400_BAD_REQUEST) + # 最低 ¥1:分币级支付按 HALF_UP 会到账 0 积分(付了钱拿 0,投诉必至);上限防长尾脏数据 + if amount < Decimal("1") or amount > Decimal("1000000"): + return Response({"detail": "充值金额需在 ¥1 ~ ¥1,000,000 之间"}, status=status.HTTP_400_BAD_REQUEST) + if bonus_points < 0: return Response({"detail": "bonus cannot be negative"}, status=status.HTTP_400_BAD_REQUEST) channel = str(request.data.get("channel") or "manual")[:32] - credited = amount + bonus + rate = get_billing_config().points_per_yuan + # 取整与计价引擎同口径 HALF_UP(quantize 默认是银行家舍入,¥0.25×10 会得 2 而非 3) + credited = (amount * rate + bonus_points).quantize(Decimal("1"), rounding=ROUND_HALF_UP) with transaction.atomic(): account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team) account.balance += credited @@ -119,12 +158,19 @@ def recharge(request): amount=credited, balance_after=account.balance, reason="团队充值", - metadata={"channel": channel, "paid_amount": str(amount), "bonus": str(bonus)}, + metadata={ + "channel": channel, + "paid_amount": str(amount), + "points_per_yuan": str(rate), + "bonus_points": str(bonus_points), + }, ) return Response( { "account": CreditAccountSerializer(account).data, "ledger": CreditLedgerSerializer(ledger).data, + "credited_points": str(credited), + "points_per_yuan": str(rate), }, status=status.HTTP_201_CREATED, ) diff --git a/core/backend/apps/projects/models.py b/core/backend/apps/projects/models.py index b0a3709..667285f 100644 --- a/core/backend/apps/projects/models.py +++ b/core/backend/apps/projects/models.py @@ -18,6 +18,8 @@ class Project(TeamOwnedModel): product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="projects") status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT) current_stage = models.CharField(max_length=32, default="script") + # ⚠️ 废弃休眠字段:从未有过写入路径与运行语义,单位未定义(积分制 rescale 有意未缩放它)。 + # 勿接入额度管控/展示;项目级限额请用 QuotaPolicy.project_limit。留列仅为免迁移。 budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) failure_reason = models.TextField(blank=True) metadata = models.JSONField(default=dict, blank=True) diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index 0809e9c..def7278 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -1030,3 +1030,87 @@ class AttachOfficialModelTests(TestCase): self.assertEqual((group.metadata or {}).get("adopt"), "adopted") # 自动采用 sub.assert_called_once() # 自动送审(on_commit 在 TestCase 事务提交点触发) self.assertEqual(sub.call_args.args[0].id, mine.id) + + +class VideoSegmentTrueUpTests(TestCase): + """pipeline 视频段积分计价 + 按火山真实 usage.total_tokens 结算(true-up)。 + 终结「视频 ¥1/段、成本 ¥15」的倒贴定价:预留=预估积分×buffer,终态按实结算、差额自动 RELEASE。""" + + def setUp(self): + from decimal import Decimal + + self.user = User.objects.create_user(username="tu-owner", password="p") + self.team = Team.objects.create(name="TU", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + CreditAccount.objects.create(team=self.team, balance=Decimal("10000.0000")) + self.product = Product.objects.create(team=self.team, created_by=self.user, title="P") + self.project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="TU-P") + self.segment = VideoSegment.objects.create(project=self.project, sort_order=0, target_duration_seconds=15) + self.provider = patch("apps.ai.services.build_provider").start().return_value + self.provider.create_video_task.return_value = {"id": "ark-tu-1", "status": "queued"} + self.addCleanup(patch.stopall) + + def _submit(self): + from apps.ai.services import submit_video_segment + + submit_video_segment(video_segment=self.segment, user=self.user, prompt="测试") + return AITask.objects.get(team=self.team, task_type=AITask.Type.VIDEO_SEGMENT) + + def test_submit_prices_by_tokens_with_buffer(self): + from apps.billing.pricing import quote_video_estimate, video_reserve_amount + + task = self._submit() + model = task.model_config + tokens, quote = quote_video_estimate(model, aspect_ratio="9:16", resolution="720p", duration=15, references=[]) + self.assertEqual(task.estimated_cost, quote.points) + self.assertEqual(task.base_cost, quote.base_cost_yuan) + self.assertGreater(quote.points, 100) # 告别 ¥1/段:15s 720p 竖屏应是三位数积分 + self.assertEqual(task.credit_reservation.amount, video_reserve_amount(quote.points)) + self.assertEqual(task.request_payload["estimated_tokens"], tokens) + + @patch("apps.ai.services._store_generated_media") + def test_poll_settles_by_actual_usage_tokens(self, store): + from decimal import Decimal + + from apps.ai.services import poll_video_segment + from apps.billing.pricing import quote_video_actual + + task = self._submit() + store.return_value = Asset.objects.create( + team=self.team, created_by=self.user, name="clip", + asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP, + ) + self.provider.poll_video_task.return_value = { + "status": "succeeded", + "usage": {"total_tokens": 300000}, + "content": {"video_url": "http://x/v.mp4"}, + } + self.provider.extract_first_media_url.return_value = "http://x/v.mp4" + poll_video_segment(video_segment=self.segment, user=self.user) + task.refresh_from_db() + settle = quote_video_actual(task.model_config, tokens=300000, with_video_ref=False, resolution="720p") + self.assertEqual(task.status, AITask.Status.SUCCEEDED) + self.assertEqual(task.actual_cost, settle.points) + self.assertEqual(task.base_cost, settle.base_cost_yuan) + # 真实 tokens(30万) < 预估(32.4万):实扣 < 预留,charge 自动 RELEASE 差额,冻结清零 + account = CreditAccount.objects.get(team=self.team) + self.assertEqual(account.reserved_balance, Decimal("0")) + self.assertEqual(account.balance, Decimal("10000.0000") - settle.points) + charges = CreditLedger.objects.filter(task=task, ledger_type=CreditLedger.Type.CHARGE) + self.assertEqual(charges.count(), 1) + self.assertEqual(charges.first().amount, settle.points) + + @patch("apps.ai.services._store_generated_media") + def test_poll_falls_back_to_estimate_when_usage_missing(self, store): + from apps.ai.services import poll_video_segment + + task = self._submit() + store.return_value = Asset.objects.create( + team=self.team, created_by=self.user, name="clip2", + asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP, + ) + self.provider.poll_video_task.return_value = {"status": "succeeded", "content": {"video_url": "http://x/v.mp4"}} + self.provider.extract_first_media_url.return_value = "http://x/v.mp4" + poll_video_segment(video_segment=self.segment, user=self.user) + task.refresh_from_db() + self.assertEqual(task.actual_cost, task.estimated_cost) # usage 缺失回落预估,不阻断出片 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 11984e8..a82c5de 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -850,7 +850,7 @@ export function App() { billing={billing} projects={projects} team={currentTeam} - onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")} + onRecharge={(amount, bonusPoints) => action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")} /> ); case "team": @@ -864,7 +864,7 @@ export function App() { onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")} onRemoveMember={(id) => action(() => api.removeTeamMember(id), "成员已移除")} 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": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 1443499..13d12bc 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -15,6 +15,7 @@ import type { AITask, Asset, AuthPayload, + BillingConfigInfo, BillingSummary, BillingTrend, FreeAssetGroup, @@ -726,9 +727,14 @@ export const api = { pollFreeAsset(id: string) { 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("/api/billing/recharge/", { method: "POST", body: JSON.stringify(payload) }); }, + // 计费公共配置(积分汇率/视频毛利/预留 buffer):所有前端预估统一读这里,与后端计价引擎同源 + billingConfig() { + return request("/api/billing/config/"); + }, // 收件箱按页拉取 —— 滚动加载逐页向后端要(tab/搜索 走服务端,计数随响应回来) listNotifications(params?: { type?: string; unread?: boolean; search?: string; page?: number; pageSize?: number }) { const query = new URLSearchParams(); @@ -794,6 +800,9 @@ export const adminApi = { teamDetail(id: string) { return request(`/api/admin/teams/${id}/`); }, + setTeamPricing(id: string, priceMultiplier: string) { + return request(`/api/admin/teams/${id}/pricing/`, { method: "POST", body: JSON.stringify({ price_multiplier: priceMultiplier }) }); + }, toggleTeam(id: string) { return request(`/api/admin/teams/${id}/toggle/`, { method: "POST" }); }, @@ -878,10 +887,16 @@ export const adminApi = { const q = qs.toString(); return request>(`/api/admin/ledgers/${q ? `?${q}` : ""}`); }, + billingConfig() { + return request("/api/admin/billing-config/"); + }, + updateBillingConfig(payload: { points_per_yuan?: string; video_margin_multiplier?: string; video_reserve_buffer?: string }) { + return request("/api/admin/billing-config/", { method: "PATCH", body: JSON.stringify(payload) }); + }, adjustCredit(payload: { team: string; amount: string; reason: string }) { return request("/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(); if (params?.team) qs.set("team", params.team); if (params?.page_size) qs.set("page_size", String(params.page_size)); diff --git a/core/frontend/src/components/free-create/constants.ts b/core/frontend/src/components/free-create/constants.ts index e5a2cc3..7b02eed 100644 --- a/core/frontend/src/components/free-create/constants.ts +++ b/core/frontend/src/components/free-create/constants.ts @@ -83,17 +83,34 @@ export function tokenPrice(config: ModelConfig | undefined, resolution: string, 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( config: ModelConfig | undefined, - params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] } -): { tokens: number; cost: number } { + params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] }, + billing: BillingRates = DEFAULT_BILLING_RATES +): { tokens: number; points: number } { const inputVideoSeconds = params.refs .filter((r) => r.type === "video") .reduce((sum, r) => sum + (r.duration || 0), 0); const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds); const hasVideoRef = params.refs.some((r) => r.type === "video"); 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 { diff --git a/core/frontend/src/components/free-create/generation-card.tsx b/core/frontend/src/components/free-create/generation-card.tsx index 3ed2a32..36030e4 100644 --- a/core/frontend/src/components/free-create/generation-card.tsx +++ b/core/frontend/src/components/free-create/generation-card.tsx @@ -90,7 +90,7 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
{task.prompt}
// {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)} 积分`}
diff --git a/core/frontend/src/components/free-create/input-bar.tsx b/core/frontend/src/components/free-create/input-bar.tsx index 54ac5a5..b3455b2 100644 --- a/core/frontend/src/components/free-create/input-bar.tsx +++ b/core/frontend/src/components/free-create/input-bar.tsx @@ -3,7 +3,7 @@ import { useRef, useState, type RefObject } from "react"; import { IconKitSvg } from "../IconKitSvg"; 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 { 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; model: string; ratio: string; @@ -62,6 +62,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r seed: number; refs: LocalRef[]; videoConfigs: ModelConfig[]; + billingRates?: BillingRates; submitting: boolean; promptRef: RefObject; /** 用户选择/拖入文件;keyframe 模式下带目标 role */ @@ -156,6 +157,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r seed={seed} refs={refs} videoConfigs={videoConfigs} + billingRates={billingRates} hasPrompt={hasPrompt} submitting={submitting} onModeChange={onModeChange} diff --git a/core/frontend/src/components/free-create/toolbar.tsx b/core/frontend/src/components/free-create/toolbar.tsx index f525ba8..a16c4eb 100644 --- a/core/frontend/src/components/free-create/toolbar.tsx +++ b/core/frontend/src/components/free-create/toolbar.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import type { ModelConfig } from "../../types"; import { + DEFAULT_BILLING_RATES, FC_DURATIONS, FC_MODELS, FC_RATIOS, @@ -10,6 +11,7 @@ import { FC_STANDARD_MODEL, MODE_LABELS, estimateCost, + type BillingRates, type FreeMode, type LocalRef } 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; model: string; ratio: string; @@ -72,6 +74,7 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re seed: number; refs: LocalRef[]; videoConfigs: ModelConfig[]; + billingRates?: BillingRates; hasPrompt: boolean; submitting: boolean; 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 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 seedRef = useRef(null); useEffect(() => { @@ -164,8 +167,8 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
- - ≈ {tokens.toLocaleString()} tokens · ¥{cost.toFixed(2)} + + ≈ {tokens.toLocaleString()} tokens · {points.toLocaleString()} 积分 @@ -118,6 +121,11 @@ export function AccountPage({ billing, projects, team, onRecharge }: { onRecharge: (amount: number, bonus: number) => void | Promise; }) { const [tab, setTab] = useState("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 [customAmt, setCustomAmt] = useState(""); // 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势 @@ -289,7 +297,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: {

快速充值

// 充值后立刻到账,可开发票 · 仅超管可操作
-
已选 ¥{effectiveAmount}{effectiveBonus > 0 ? ` + ¥${effectiveBonus} 赠送` : ""}
+
已选 ¥{effectiveAmount}(到账 {Math.round(effectiveAmount * pointsRate + effectiveBonus)} 积分){effectiveBonus > 0 ? ` · 含 ${effectiveBonus} 积分赠送` : ""}
{RECHARGE.map((item) => ( @@ -322,6 +330,12 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
+
+ 计费说明 + 1 元 = 10 积分。脚本/文案 10 积分/次;图片生成 20 积分/张;配音 10 积分/500 字; + 视频按时长与分辨率计量、按实际用量结算(如 15 秒 720P 竖屏约 224 积分),多退少不补超; + 拼接导出免费。生成失败、超时均不扣费,自动退回预留积分。 +
@@ -443,7 +457,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: { ? {l.user_label.slice(0, 1).toUpperCase()}{l.user_label} : 系统} 成功 - {l.amount} + {pts(l.amount)} ))} @@ -565,7 +579,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: { open={topupChannel !== null} channel={topupChannel ?? "wechat"} amount={effectiveAmount} - bonus={effectiveBonus} + bonus={effectiveBonus} rate={pointsRate} close={() => setTopupChannel(null)} onDone={submitRecharge} /> diff --git a/core/frontend/src/routes/admin/admin-billing.tsx b/core/frontend/src/routes/admin/admin-billing.tsx index 99c221f..5b2b799 100644 --- a/core/frontend/src/routes/admin/admin-billing.tsx +++ b/core/frontend/src/routes/admin/admin-billing.tsx @@ -1,11 +1,15 @@ import { useCallback, useEffect, useState } from "react"; import { Gauge, Wallet, X } from "lucide-react"; import { adminApi } from "../../api"; +import { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types"; +import { pts } from "../stage-config"; type Notify = (type: "success" | "error" | "info", text: string) => void; +const PAGE_SIZE = 10; + function fmtDate(iso: string) { if (!iso) return "—"; const d = new Date(iso); @@ -37,6 +41,70 @@ function useTeams() { 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 ( +
+
+

平台计费配置

+ [ /billing-config ] +
+
+
+ + setForm((f) => ({ ...f, points_per_yuan: e.target.value }))} /> +
1 元兑换的积分数
+
+
+ + setForm((f) => ({ ...f, video_margin_multiplier: e.target.value }))} /> +
用户价 = 火山成本 × 系数
+
+
+ + setForm((f) => ({ ...f, video_reserve_buffer: e.target.value }))} /> +
预留 = 预估积分 × buffer
+
+ +
+
+ ); +} + // ─────────────────────────── 计费审计 ─────────────────────────── export function AdminLedgersPage({ notify }: { notify: Notify }) { @@ -44,6 +112,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) { const [count, setCount] = useState(0); const [loading, setLoading] = useState(true); const [tab, setTab] = useState(""); + const [page, setPage] = useState(1); const [modalOpen, setModalOpen] = useState(false); const [form, setForm] = useState({ team: "", amount: "", reason: "" }); const [saving, setSaving] = useState(false); @@ -52,7 +121,8 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) { const load = useCallback(async () => { setLoading(true); 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); setCount(res.count); } catch { @@ -61,7 +131,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tab]); + }, [tab, page]); useEffect(() => { void load(); }, [load]); @@ -94,11 +164,12 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) { +
{LEDGER_TABS.map((t) => ( - + ))}
@@ -117,14 +188,15 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) { {l.team_name || } {l.username || } {ledgerPill(l.ledger_type)} - ¥{l.amount} - ¥{l.balance_after} + {pts(l.amount)} 积分 + {pts(l.balance_after)} 积分 {l.reason || } {fmtDate(l.created_at)} ))} + )} @@ -179,19 +251,22 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) { const [saving, setSaving] = useState(false); const teams = useTeams(); + const [page, setPage] = useState(1); const load = useCallback(async () => { setLoading(true); 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); setCount(res.count); } catch { + if (page > 1) { setPage(1); return; } notify("error", "加载额度策略失败"); } finally { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [page]); useEffect(() => { void load(); }, [load]); @@ -241,7 +316,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) { } } - const lim = (v: string | null) => (v == null ? 不限 : `¥${v}`); + const lim = (v: string | null) => (v == null ? 不限 : `${pts(v)} 积分`); return ( <> @@ -279,6 +354,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) { ))} + )} diff --git a/core/frontend/src/routes/admin/admin-teams-users.tsx b/core/frontend/src/routes/admin/admin-teams-users.tsx index afb4195..78ef346 100644 --- a/core/frontend/src/routes/admin/admin-teams-users.tsx +++ b/core/frontend/src/routes/admin/admin-teams-users.tsx @@ -1,8 +1,10 @@ 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 { Pager } from "../../components/pager"; import { IconKitSvg } from "../../components/IconKitSvg"; import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types"; +import { pts } from "../stage-config"; type Notify = (type: "success" | "error" | "info", text: string) => void; @@ -19,6 +21,8 @@ function statusPill(active: boolean) { : 停用; } +const PAGE_SIZE = 10; + const STATUS_TABS = [ { key: "", label: "全部" }, { key: "active", label: "启用" }, @@ -33,22 +37,36 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) { const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState(""); const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + // 差异化调价:行级「定价」弹窗(系数 0.10~10.00,<1 折扣 / >1 加价,静默生效) + const [pricingTarget, setPricingTarget] = useState(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 () => { setLoading(true); 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); setCount(res.count); } catch { + if (page > 1) { setPage(1); return; } // 页码越界(后端 404)→ 回第一页 notify("error", "加载团队失败"); } finally { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [statusFilter, search]); + }, [statusFilter, search, page]); 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 ( <>
@@ -86,10 +121,10 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
{STATUS_TABS.map((t) => ( - + ))}
- setSearch(e.target.value)} /> + { setSearch(e.target.value); setPage(1); }} />
{loading ? ( @@ -100,7 +135,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
- + {teams.map((t) => ( @@ -108,10 +143,12 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) { - + +
团队名超管成员余额状态创建时间操作
团队名超管成员余额价格系数状态创建时间操作
{t.name} {t.owner_username || } {t.member_count}¥{t.balance}{pts(t.balance)} 积分{Number(t.price_multiplier) === 1 ? 标准价 : `×${Number(t.price_multiplier).toFixed(2)}`} {statusPill(t.status === "active")} {fmtDate(t.created_at)} +
+ +
+ )} + + {pricingTarget && ( +
{ if (e.target === e.currentTarget) setPricingTarget(null); }}> +
+ ++ +
+
+
团队定价// {pricingTarget.name}
+ +
+
+

最终积分价 = 标准挂牌价 × 系数(全部计费类型统一生效,对团队静默)。<1 为折扣,>1 为加价;视频在途任务按下单快照不受影响。

+
+ + setPricingValue(e.target.value)} /> + {Number(pricingValue) < lossThreshold &&
// ⚠ 低于 ×{lossThreshold.toFixed(2)} 时视频将卖低于平台成本(毛利 ×{videoMargin} 被抵消),I9 审计会告警
} +
+
+
+ + +
+
)} @@ -141,7 +204,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
超管{detail.owner_username || "—"}
成员数{detail.member_count}
-
余额¥{detail.balance}
+
余额{pts(detail.balance)} 积分
状态{statusPill(detail.status === "active")}
成员 · {detail.members.length}
@@ -153,7 +216,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) { {m.username} {m.role} {statusPill(m.user_status === "active")} - ¥{m.monthly_credit_limit} + {pts(m.monthly_credit_limit)} 积分 ))} @@ -179,6 +242,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) { const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState(""); const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); const [pwdTarget, setPwdTarget] = useState(null); const [pwd, setPwd] = useState(""); const [saving, setSaving] = useState(false); @@ -186,16 +250,18 @@ export function AdminUsersPage({ notify }: { notify: Notify }) { const load = useCallback(async () => { setLoading(true); 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); setCount(res.count); } catch { + if (page > 1) { setPage(1); return; } notify("error", "加载用户失败"); } finally { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [statusFilter, search]); + }, [statusFilter, search, page]); useEffect(() => { void load(); }, [load]); @@ -238,10 +304,10 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
{STATUS_TABS.map((t) => ( - + ))}
- setSearch(e.target.value)} /> + { setSearch(e.target.value); setPage(1); }} />
{loading ? ( @@ -284,6 +350,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) { ))} +
)} diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index f3f7b6c..251bf7f 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -121,21 +121,21 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void tag: "[ MODEL · TRY-ON ]", title: "模特上身图", desc: "选择模特,AI 生成商品模特上身效果图", - cost: "≈ ¥0.30 / 张" + cost: "≈ 20 积分 / 张" }, { page: "platformCover" as Page, tag: "[ PLATFORM · KIT ]", title: "平台套图", desc: "选择平台模板,AI 生成电商平台套图", - cost: "≈ ¥0.50 / 张" + cost: "≈ 20 积分 / 张" }, { page: "imageOptimize" as Page, tag: "[ IMAGE · STUDIO ]", title: "图片创作", desc: "自由创作 AI 图片,适用于详情图 / 海报 / 灵感速写", - cost: "≈ ¥0.40 / 组" + cost: "≈ 20 积分 / 张" } ]; @@ -729,15 +729,23 @@ export function ImageWorkbenchPage({ } 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),没匹配上用第一个图像模型, - // 再兜底 ¥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 want = genModel === "gpt-image" ? "gpt-image" : genModel === "volcano" ? "seedream" : genModel; const m = imageModels.find((x) => x.name.toLowerCase().includes(want)) || imageModels[0]; 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 改为「模特库」(顶级实体,引用其形象图当上身图参考)。 映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。 @@ -2477,8 +2485,8 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
- 预估扣费 ≈ ¥1.20 - 余额 ¥327.40 + 预估扣费 ≈ 20 积分/张 + 余额以消费页为准
@@ -2527,7 +2535,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
Zoe × 4 张
{active.title} · 3:4 · 12 分钟前{" "} - · ¥1.20 + · 20 积分/张
@@ -2624,7 +2632,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
3:4· 3 分钟前· - ¥1.20 + 20 积分
@@ -2647,7 +2655,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
3:4· 12 分钟前· - ¥1.20 + 20 积分
@@ -2670,7 +2678,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
3:4· 刚刚· - ¥0.60 + 20 积分
@@ -2697,7 +2705,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va
3:4· 昨天 18:24· - ¥1.20 + 20 积分
@@ -2762,7 +2770,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { va + 添加 - 预估 ¥1.20 · 余额 ¥327.40 + 预估 20 积分/张
-
[ LLM 用量 ~2.4k tokens · ¥0.04 · 失败不扣 · 通过后扣 ]
+
[ 脚本生成 {pts(10)} 积分/次 · 失败不扣 ]
@@ -2897,8 +2905,8 @@ export function PipelinePage(props: {
基础资产是后续故事板的素材。所有卡片同时展示,点左侧分类直接定位。

- // 人物 +¥0.20/张 - // 场景 +¥0.15/张 + // 人物 +{pts(20)} 积分/张 + // 场景 +{pts(20)} 积分/张 商品图无成本(直接复用商品库) {(entitiesExtracted || hasAnyAsset) && ( - ~¥0.30 / 次 + {pts(20)} 积分 / 次
)}
@@ -3290,7 +3298,7 @@ export function PipelinePage(props: { )} - ~¥0.45/场 + {pts(20)} 积分/镜
// 本场历史版本({activeVers.length})· 点击预览
@@ -3405,7 +3413,7 @@ export function PipelinePage(props: { {statusLabel(seg.status)}
-
{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · ~¥0.45{seg.error_message ? ` · ${seg.error_message}` : ""}
+
{seg.target_duration_seconds}s · {timeline?.resolution || "1080×1920"} · 按时长计量{seg.error_message ? ` · ${seg.error_message}` : ""}
{/* 多版本入口:N>1 才显示,点开详情弹窗看历史/切换/采用(数据全留着,不吞历史) */} @@ -3633,7 +3641,7 @@ export function PipelinePage(props: {
- + {voInfo && }
diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 7d5017b..9d903bd 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -922,7 +922,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na )} - ~¥0.30 / 次 + 20 积分 / 次
{/* 版本历史:点缩略图切换查看;已采用版本主橙描边 + 徽标 */}
diff --git a/core/frontend/src/routes/stage-config.ts b/core/frontend/src/routes/stage-config.ts index 9071b80..f7433f3 100644 --- a/core/frontend/src/routes/stage-config.ts +++ b/core/frontend/src/routes/stage-config.ts @@ -8,8 +8,25 @@ export const stageMeta: Record = { 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) { - 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); return Number.isFinite(n) ? `¥${n.toFixed(2)}` : `¥${value}`; } diff --git a/core/frontend/src/routes/team.tsx b/core/frontend/src/routes/team.tsx index 1003546..01428ec 100644 --- a/core/frontend/src/routes/team.tsx +++ b/core/frontend/src/routes/team.tsx @@ -26,12 +26,12 @@ function genPassword() { for (let i = 0; i < 12; i++) s += chars[Math.floor(Math.random() * chars.length)]; return s; } -// 千分位金额格式化(V1 限额弹窗用 ¥3,000 / ¥2,837.40 风格)· money() 无千分位故本地补 +// 千分位积分格式化(积分制:限额/已用全是积分) function yuan(n: number, decimals = 0) { const fixed = n.toFixed(decimals); const [int, dec] = fixed.split("."); const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ","); - return `¥${grouped}${dec ? "." + dec : ""}`; + return `${grouped}${dec ? "." + dec : ""} 积分`; } // 复制到剪贴板:优先 navigator.clipboard,兜底 textarea + execCommand 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[1]) => { const r = await onUpdateMemberRaw(id, p); 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; }; + // 积分汇率(积分/¥):充值 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(""); @@ -526,7 +531,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
- + setInvMonthly(e.target.value)} />
{inviteErr &&
{inviteErr}
} @@ -558,7 +563,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea >
- + setLimitVal(e.target.value)} />
@@ -619,18 +624,18 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
- +
@@ -687,7 +692,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea dismissable={false} footer={} > -
setRechargeAmt(e.target.value)} placeholder="最低 ¥50" />
+
setRechargeAmt(e.target.value)} placeholder="最低 ¥50" />
// 到账积分 = 支付金额 × {pointsRate}(当前汇率)
{/* 编辑成员 · 角色双卡 + 三档额度 */} @@ -717,15 +722,15 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
- + setEdDaily(e.target.value)} />
- + setEdMonthly(e.target.value)} />
- + setEdTotal(e.target.value)} />
diff --git a/core/frontend/src/types.ts b/core/frontend/src/types.ts index 16579e1..fd01077 100644 --- a/core/frontend/src/types.ts +++ b/core/frontend/src/types.ts @@ -63,6 +63,8 @@ export type AdminTeam = { owner_username: string | null; member_count: number; balance: string; + // 团队价格系数(差异化调价):最终积分价 = 挂牌价 × 系数 + price_multiplier: string; created_at: string; }; export type AdminTeamMember = { @@ -122,6 +124,9 @@ export type AdminTask = { model_name: string | null; estimated_cost: string; actual_cost: string; + // 平台成本(¥)与单任务毛利(¥,成本未知时 null)—— 积分制重构后毛利首次可审 + base_cost?: string; + margin_yuan?: string | null; cost_anomaly: boolean; error_code: string; created_at: string; @@ -713,4 +718,17 @@ export type NotificationList = Paginated & { export type RechargeResult = { account: BillingSummary["account"]; 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; }; diff --git a/core/qa/audit_billing.py b/core/qa/audit_billing.py index 83f2a77..b351e54 100644 --- a/core/qa/audit_billing.py +++ b/core/qa/audit_billing.py @@ -140,6 +140,25 @@ def audit_account(acct): else: 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(): accts = CreditAccount.objects.select_related("team").all()