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:
@@ -83,17 +83,34 @@ export function tokenPrice(config: ModelConfig | undefined, resolution: string,
|
||||
return (hasVideoRef ? tier.with_ref_video : tier.no_ref_video) || 0;
|
||||
}
|
||||
|
||||
// 积分制:与后端 apps/billing/pricing.py 取整规则逐字一致 ——
|
||||
// ¥成本先 round 到 0.01 → ×毛利系数 ×积分汇率 → HALF_UP 取整积分,最低 1。
|
||||
// billing(毛利/汇率)来自 GET /api/billing/config/,与后端计价引擎同源;未加载时回退首发默认值。
|
||||
export type BillingRates = { margin: number; rate: number; multiplier: number };
|
||||
export const DEFAULT_BILLING_RATES: BillingRates = { margin: 1.5, rate: 10, multiplier: 1 };
|
||||
|
||||
export function estimateCost(
|
||||
config: ModelConfig | undefined,
|
||||
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] }
|
||||
): { tokens: number; cost: number } {
|
||||
params: { ratio: string; resolution: string; duration: number; refs: { type: string; duration?: number }[] },
|
||||
billing: BillingRates = DEFAULT_BILLING_RATES
|
||||
): { tokens: number; points: number } {
|
||||
const inputVideoSeconds = params.refs
|
||||
.filter((r) => r.type === "video")
|
||||
.reduce((sum, r) => sum + (r.duration || 0), 0);
|
||||
const tokens = estimateTokens(params.ratio, params.resolution, params.duration, inputVideoSeconds);
|
||||
const hasVideoRef = params.refs.some((r) => r.type === "video");
|
||||
const price = tokenPrice(config, params.resolution, hasVideoRef);
|
||||
return { tokens, cost: Math.round(((tokens * price) / 1e6) * 100) / 100 };
|
||||
const costYuan = Math.round(((tokens * price) / 1e6) * 100) / 100;
|
||||
if (costYuan <= 0) return { tokens, points: 0 };
|
||||
// 浮点乘积在 .5 边界可能落成 27.499999…,先吸附 6 位小数再 round,
|
||||
// 与后端 Decimal ROUND_HALF_UP 逐字对齐。只吸浮点噪声(1e-9 级),
|
||||
// 不能用 toFixed(2):非默认 margin 下 x.495 会被先四舍五入成 x.50 多显 1 积分(review 确认)
|
||||
const raw = Number((costYuan * billing.margin * billing.rate).toFixed(6));
|
||||
const listPoints = Math.max(1, Math.round(raw));
|
||||
// 团队价格系数(差异化调价):两步取整与后端 apply_team_price 逐字对齐(先挂牌取整,再乘系数取整)
|
||||
const multiplier = billing.multiplier || 1;
|
||||
const points = multiplier === 1 ? listPoints : Math.max(1, Math.round(Number((listPoints * multiplier).toFixed(6))));
|
||||
return { tokens, points };
|
||||
}
|
||||
|
||||
export function modelLabel(name: string): string {
|
||||
|
||||
@@ -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-sub mono">
|
||||
// {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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { IconKitSvg } from "../IconKitSvg";
|
||||
import type { ModelConfig } from "../../types";
|
||||
import { MODE_LABELS, type FreeMode, type LocalRef } from "./constants";
|
||||
import { MODE_LABELS, type BillingRates, type FreeMode, type LocalRef } from "./constants";
|
||||
import { PromptInput, type PromptInputHandle } from "./prompt-input";
|
||||
import { FreeToolbar } from "./toolbar";
|
||||
|
||||
@@ -53,7 +53,7 @@ function KeyframeSlot({ role, item, onPick, onRemove }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
|
||||
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onClear, onSend }: {
|
||||
mode: FreeMode;
|
||||
model: string;
|
||||
ratio: string;
|
||||
@@ -62,6 +62,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
seed: number;
|
||||
refs: LocalRef[];
|
||||
videoConfigs: ModelConfig[];
|
||||
billingRates?: BillingRates;
|
||||
submitting: boolean;
|
||||
promptRef: RefObject<PromptInputHandle | null>;
|
||||
/** 用户选择/拖入文件;keyframe 模式下带目标 role */
|
||||
@@ -156,6 +157,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
|
||||
seed={seed}
|
||||
refs={refs}
|
||||
videoConfigs={videoConfigs}
|
||||
billingRates={billingRates}
|
||||
hasPrompt={hasPrompt}
|
||||
submitting={submitting}
|
||||
onModeChange={onModeChange}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ModelConfig } from "../../types";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
FC_DURATIONS,
|
||||
FC_MODELS,
|
||||
FC_RATIOS,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
FC_STANDARD_MODEL,
|
||||
MODE_LABELS,
|
||||
estimateCost,
|
||||
type BillingRates,
|
||||
type FreeMode,
|
||||
type LocalRef
|
||||
} from "./constants";
|
||||
@@ -63,7 +65,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
|
||||
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
|
||||
mode: FreeMode;
|
||||
model: string;
|
||||
ratio: string;
|
||||
@@ -72,6 +74,7 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
seed: number;
|
||||
refs: LocalRef[];
|
||||
videoConfigs: ModelConfig[];
|
||||
billingRates?: BillingRates;
|
||||
hasPrompt: boolean;
|
||||
submitting: boolean;
|
||||
onModeChange: (mode: FreeMode) => void;
|
||||
@@ -85,7 +88,7 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
}) {
|
||||
const isStandard = model === FC_STANDARD_MODEL;
|
||||
const config = videoConfigs.find((c) => c.name === model);
|
||||
const { tokens, cost } = estimateCost(config, { ratio, resolution, duration, refs });
|
||||
const { tokens, points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES);
|
||||
const [seedOpen, setSeedOpen] = useState(false);
|
||||
const seedRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -164,8 +167,8 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
</div>
|
||||
</div>
|
||||
<div className="fc-toolbar-r">
|
||||
<span className="fc-estimate mono" title="预估消耗(实际按火山返回用量结算)">
|
||||
≈ {tokens.toLocaleString()} tokens · ¥{cost.toFixed(2)}
|
||||
<span className="fc-estimate mono" title="预估消耗(实际按真实用量结算,多退少不补超)">
|
||||
≈ {tokens.toLocaleString()} tokens · {points.toLocaleString()} 积分
|
||||
</span>
|
||||
<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">
|
||||
|
||||
@@ -142,7 +142,7 @@ export function VideoDetailModal({ task, hasPrev, hasNext, onPrev, onNext, onClo
|
||||
<div className="fc-player-sub mono">
|
||||
// {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}`}
|
||||
{` · ¥${Number(task.actual_cost || 0).toFixed(2)}`}
|
||||
{` · ${Number(task.actual_cost || 0)} 积分`}
|
||||
</div>
|
||||
{task.fallback_note && <div className="fc-player-warn mono">// {task.fallback_note}</div>}
|
||||
<div className="fc-player-actions">
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// onPageSizeChange 传则「每页 N 条」变成可点击循环按钮(默认循环 12/24/48/96)
|
||||
// pageSizeOptions 自定义每页条数候选
|
||||
// sticky 传则分页器吸底浮动
|
||||
// total <= pageSize 且不可改每页条数时不渲染(单页无需分页器)。
|
||||
// alwaysShow 单页也渲染(admin 列表用:让「共 N 条 · 每页 10 条」可见,否则数据少时像没接分页)
|
||||
// 默认 total <= pageSize 且不可改每页条数时不渲染(单页无需分页器)。
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -21,7 +22,7 @@ export function pageWindow(current: number, total: number): Array<number | "elli
|
||||
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;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
@@ -29,12 +30,13 @@ export function Pager({ page, total, pageSize, onChange, onPageSizeChange, pageS
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
sticky?: boolean;
|
||||
alwaysShow?: boolean;
|
||||
}) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const cur = Math.min(Math.max(1, page), totalPages);
|
||||
const [jump, setJump] = useState("");
|
||||
// 单页且不可改每页条数 → 不渲染;可改条数时仍渲染(让用户能切回更小分页)
|
||||
if (total <= pageSize && !onPageSizeChange) return null;
|
||||
// 单页且不可改每页条数 → 不渲染;可改条数或 alwaysShow 时仍渲染
|
||||
if (total <= pageSize && !onPageSizeChange && !alwaysShow) return null;
|
||||
|
||||
function cycleSize() {
|
||||
if (!onPageSizeChange) return;
|
||||
|
||||
Reference in New Issue
Block a user