feat(billing): 计费商业化重构(积分制)+ 团队差异化调价

统一计价引擎 apps/billing/pricing.py:1积分=¥0.1,平台成本(¥)与用户价(积分)双记账,
视频按火山真实 usage.total_tokens 结算(true-up,终结¥1/段倒贴)+ 首发×1.5毛利系数。
Team.price_multiplier 差异化调价(jimeng同款):挂牌价×系数两步HALF_UP取整,视频类
按下单时的价格/汇率快照结算(中途改配不影响在途任务)。

BillingConfig 单例(汇率/毛利系数/预留buffer,admin可调即刻生效)。存量数据 ×10 rescale
迁移(RunPython+atomic,MySQL 迁移不可中断重跑)。开户赠送归零(DEFAULT_TRIAL_CREDITS=0,
商业决策)。audit_billing 加 I9(卖亏审计)。271 条测试 + tsc/build 全绿。

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