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,11 +1,15 @@
import { useCallback, useEffect, useState } from "react";
import { Gauge, Wallet, X } from "lucide-react";
import { adminApi } from "../../api";
import { Pager } from "../../components/pager";
import { IconKitSvg } from "../../components/IconKitSvg";
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
import { pts } from "../stage-config";
type Notify = (type: "success" | "error" | "info", text: string) => void;
const PAGE_SIZE = 10;
function fmtDate(iso: string) {
if (!iso) return "—";
const d = new Date(iso);
@@ -37,6 +41,70 @@ function useTeams() {
return teams;
}
// ─────────────────────────── 平台计费配置 ───────────────────────────
// 积分汇率 / 视频毛利系数 / 预留 buffer(与后端计价引擎同源,改动即刻生效于下一次估价/结算)
function BillingConfigCard({ notify }: { notify: Notify }) {
const [form, setForm] = useState({ points_per_yuan: "", video_margin_multiplier: "", video_reserve_buffer: "" });
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const cfg = await adminApi.billingConfig();
setForm({
points_per_yuan: cfg.points_per_yuan,
video_margin_multiplier: cfg.video_margin_multiplier,
video_reserve_buffer: cfg.video_reserve_buffer
});
setLoaded(true);
} catch {
notify("error", "计费配置加载失败");
}
}, [notify]);
useEffect(() => { void load(); }, [load]);
const save = async () => {
setSaving(true);
try {
await adminApi.updateBillingConfig(form);
notify("success", "计费配置已更新,即刻生效");
} catch (e) {
notify("error", e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
if (!loaded) return null;
return (
<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 }) {
@@ -44,6 +112,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState("");
const [page, setPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState({ team: "", amount: "", reason: "" });
const [saving, setSaving] = useState(false);
@@ -52,7 +121,8 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.ledgers({ ledger_type: tab || undefined, page_size: 80 });
const res = await adminApi.ledgers({ ledger_type: tab || undefined, page, page_size: PAGE_SIZE });
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setLedgers(res.results);
setCount(res.count);
} catch {
@@ -61,7 +131,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
}, [tab, page]);
useEffect(() => { void load(); }, [load]);
@@ -94,11 +164,12 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
<button className="btn btn-primary" type="button" onClick={() => setModalOpen(true)}>+ 手动调额</button>
</div>
</div>
<BillingConfigCard notify={notify} />
<div className="admin-toolbar">
<div className="tabs-sub">
{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>
@@ -117,14 +188,15 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
<td>{l.team_name || <span className="muted">—</span>}</td>
<td>{l.username || <span className="muted">—</span>}</td>
<td>{ledgerPill(l.ledger_type)}</td>
<td className="num mono">¥{l.amount}</td>
<td className="num mono">¥{l.balance_after}</td>
<td className="num mono">{pts(l.amount)} 积分</td>
<td className="num mono">{pts(l.balance_after)} 积分</td>
<td>{l.reason || <span className="muted">—</span>}</td>
<td className="mono col-time">{fmtDate(l.created_at)}</td>
</tr>
))}
</tbody>
</table>
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
)}
@@ -179,19 +251,22 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
const [saving, setSaving] = useState(false);
const teams = useTeams();
const [page, setPage] = useState(1);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.quotaPolicies({ page_size: 100 });
const res = await adminApi.quotaPolicies({ page, page_size: PAGE_SIZE });
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setPolicies(res.results);
setCount(res.count);
} catch {
if (page > 1) { setPage(1); return; }
notify("error", "加载额度策略失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [page]);
useEffect(() => { void load(); }, [load]);
@@ -241,7 +316,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
}
}
const lim = (v: string | null) => (v == null ? <span className="muted">不限</span> : `¥${v}`);
const lim = (v: string | null) => (v == null ? <span className="muted">不限</span> : `${pts(v)} 积分`);
return (
<>
@@ -279,6 +354,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
))}
</tbody>
</table>
<Pager page={page} total={count} pageSize={PAGE_SIZE} onChange={setPage} alwaysShow />
</div>
)}