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
+27 -13
View File
@@ -3,7 +3,7 @@ import { CreditCard, X } from "lucide-react";
import { createPortal } from "react-dom";
import { api } from "../api";
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 { 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";
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" },
week: { chip: "周", sub: "// 近 8 周 · 单位 ¥", totalLabel: "8 周合计", avgLabel: "周均" },
month: { chip: "月", sub: "// 近 6 个月 · 单位 ¥", totalLabel: "6 月合计", avgLabel: "月均" }
day: { chip: "日", sub: "// 近 14 天 · 单位 积分", totalLabel: "14 天合计", avgLabel: "日均" },
week: { chip: "周", sub: "// 近 8 周 · 单位 积分", totalLabel: "8 周合计", avgLabel: "周均" },
month: { chip: "月", sub: "// 近 6 个月 · 单位 积分", totalLabel: "6 月合计", avgLabel: "月均" }
};
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 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 }> = [
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
{ amt: 1000, gift: "+ ¥80 赠送", bonus: true, bonusAmt: 80 },
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true, bonusAmt: 300 }
{ amt: 500, gift: "+300 积分赠送", bonus: true, bonusAmt: 300, ribbon: "推荐" },
{ amt: 1000, gift: "+800 积分赠送", bonus: true, bonusAmt: 800 },
{ amt: 3000, gift: "+3000 积分赠送", bonus: true, bonusAmt: 3000 }
];
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 分钟有效 + 双按钮)
// 关闭与进出场走 overlays 的 useOverlayTransition(ESC / 点遮罩关),完成支付回调交父级弹全局 toast。
function TopupModal({ open, channel, amount, bonus, close, onDone }: {
function TopupModal({ open, channel, amount, bonus, rate, close, onDone }: {
open: boolean;
channel: "wechat" | "alipay";
amount: number;
bonus: number;
rate: number; // 积分汇率(积分/¥),来自 /api/billing/config/,与后端到账口径同源
close: () => void;
onDone: () => void | Promise<unknown>;
}) {
@@ -93,12 +95,13 @@ function TopupModal({ open, channel, amount, bonus, close, onDone }: {
</div>
<div className="modal-b">
<div className="topup-info"></div>
<div className="topup-amt">{money(amount)}</div>
<div className="topup-note">{bonus > 0 ? `// 含 ¥${bonus} 赠送 · 实到账 ${money(amount + bonus)}` : "// 无赠送"}</div>
<div className="topup-amt">{yuan(amount)}</div>
<div className="topup-note">{`// 到账 ${money(Math.round(amount * rate + bonus))}`}{bonus > 0 ? `(含 ${bonus} 积分赠送)` : ""}</div>
<div className="topup-qr" aria-label="二维码占位">
<div className="center">{scanLabel}<br /><span className="ref">{txRef}</span></div>
</div>
<div className="topup-valid">// 5 分钟内有效 · 到账后自动关闭</div>
<div className="topup-valid">// 积分不可提现、不可转让 · 长期有效 · 发票按实际支付金额开具</div>
</div>
<div className="modal-f">
<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>;
}) {
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 [customAmt, setCustomAmt] = useState("");
// 账户页数据本页自取(不再走全局 bootstrap):充值后 bump 刷新流水/趋势
@@ -289,7 +297,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
<h3></h3>
<div className="desc">// 充值后立刻到账,可开发票 · 仅超管可操作</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 className="recharge-row">
{RECHARGE.map((item) => (
@@ -322,6 +330,12 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
</button>
</div>
</div>
<div className="tip" style={{ marginTop: 14 }}>
<strong></strong>
1 = 10 / 10 /; 20 /; 10 /500 ;
( 15 720P 224 ),退;
,退
</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="sys"></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>
))}
</tbody>
@@ -565,7 +579,7 @@ export function AccountPage({ billing, projects, team, onRecharge }: {
open={topupChannel !== null}
channel={topupChannel ?? "wechat"}
amount={effectiveAmount}
bonus={effectiveBonus}
bonus={effectiveBonus} rate={pointsRate}
close={() => setTopupChannel(null)}
onDone={submitRecharge}
/>