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
@@ -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">
+6 -4
View File
@@ -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;