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
+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)];
return s;
}
// 千分位金额格式化(V1 限额弹窗用 ¥3,000 / ¥2,837.40 风格)· money() 无千分位故本地补
// 千分位积分格式化(积分制:限额/已用全是积分)
function yuan(n: number, decimals = 0) {
const fixed = n.toFixed(decimals);
const [int, dec] = fixed.split(".");
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return `¥${grouped}${dec ? "." + dec : ""}`;
return `${grouped}${dec ? "." + dec : ""} 积分`;
}
// 复制到剪贴板:优先 navigator.clipboard,兜底 textarea + execCommand
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 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; };
// 积分汇率(积分/¥):充值 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("");
@@ -526,7 +531,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
</select>
</div>
<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)} />
</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="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)} />
</div>
<div className="field" style={{ marginBottom: 0 }}>
@@ -619,18 +624,18 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
</div>
</div>
<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">
<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)} />
</label>
<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)} />
</label>
<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)} />
</label>
</div>
@@ -687,7 +692,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
dismissable={false}
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>
{/* 编辑成员 · 角色双卡 + 三档额度 */}
@@ -717,15 +722,15 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
</div>
</div>
<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)} />
</div>
<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)} />
</div>
<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)} />
</div>
</div>