feat(billing): add metering formula admin tab
Expose margin multiplier, FX rate, and cost-mode settings under 计费中心 so operators can adjust DeepSeek billing without env edits. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
deleteSubscriptionPlan,
|
||||
getAdminUsageStats,
|
||||
getAdminUsageSummary,
|
||||
getBillingConfig,
|
||||
grantUserSubscription,
|
||||
listAdminLedger,
|
||||
listAdminSubscriptions,
|
||||
@@ -13,10 +14,21 @@ import {
|
||||
listSubscriptionPlans,
|
||||
rechargeUser,
|
||||
syncSubscriptionPlansToProduction,
|
||||
updateBillingConfig,
|
||||
updateSubscriptionPlan,
|
||||
} from '../../api/client';
|
||||
import type { PagedResult } from '../../api/client';
|
||||
import type { AdminSubscription, AdminUserRow, LedgerEntry, PlanDefinition, UsageRecord, UsageStatsResult, UsageSummaryResult, UsageTotals } from '../../types';
|
||||
import type {
|
||||
AdminSubscription,
|
||||
AdminUserRow,
|
||||
BillingAdminConfig,
|
||||
LedgerEntry,
|
||||
PlanDefinition,
|
||||
UsageRecord,
|
||||
UsageStatsResult,
|
||||
UsageSummaryResult,
|
||||
UsageTotals,
|
||||
} from '../../types';
|
||||
import { Pagination } from '../../components/Pagination';
|
||||
import {
|
||||
dateRangeToUnix,
|
||||
@@ -116,13 +128,14 @@ function UserCombobox({
|
||||
);
|
||||
}
|
||||
|
||||
type TabKey = 'recharge' | 'usage' | 'ledger' | 'subscriptions';
|
||||
type TabKey = 'recharge' | 'usage' | 'ledger' | 'subscriptions' | 'formula';
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'subscriptions', label: '订阅记录' },
|
||||
{ key: 'recharge', label: '充值' },
|
||||
{ key: 'usage', label: '用量记录' },
|
||||
{ key: 'ledger', label: '资金流水' },
|
||||
{ key: 'formula', label: '计量公式' },
|
||||
];
|
||||
|
||||
const TAB_PATHS: Record<TabKey, string> = {
|
||||
@@ -130,6 +143,14 @@ const TAB_PATHS: Record<TabKey, string> = {
|
||||
recharge: '/billing/recharge',
|
||||
usage: '/billing/usage',
|
||||
ledger: '/billing/ledger',
|
||||
formula: '/billing/formula',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
'admin-db': '后台配置',
|
||||
env: '环境变量',
|
||||
'env-override': '环境变量锁定',
|
||||
default: '默认值',
|
||||
};
|
||||
|
||||
function tabFromPath(pathname: string): TabKey {
|
||||
@@ -137,10 +158,241 @@ function tabFromPath(pathname: string): TabKey {
|
||||
if (suffix === 'usage' || suffix.startsWith('usage/')) return 'usage';
|
||||
if (suffix === 'recharge') return 'recharge';
|
||||
if (suffix === 'ledger') return 'ledger';
|
||||
if (suffix === 'formula') return 'formula';
|
||||
if (suffix === 'subscriptions') return 'subscriptions';
|
||||
return 'subscriptions';
|
||||
}
|
||||
|
||||
const DEFAULT_BILLING_FORMULA: BillingAdminConfig = {
|
||||
useBackendCost: true,
|
||||
usdCnyRate: 7.2,
|
||||
marginMultiplier: 1.2,
|
||||
inputCentsPer1k: 2,
|
||||
outputCentsPer1k: 6,
|
||||
minBillCents: 1,
|
||||
costEstimateFromTokens: true,
|
||||
costEstimateInputUsdPer1M: 0.27,
|
||||
costEstimateOutputUsdPer1M: 1.1,
|
||||
};
|
||||
|
||||
function FormulaTab() {
|
||||
const [draft, setDraft] = useState<BillingAdminConfig>(DEFAULT_BILLING_FORMULA);
|
||||
const [source, setSource] = useState('default');
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const [formula, setFormula] = useState<string>('');
|
||||
const [envLocked, setEnvLocked] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getBillingConfig();
|
||||
setDraft(result.config);
|
||||
setSource(result.source ?? 'default');
|
||||
setUpdatedAt(result.updatedAt ?? null);
|
||||
setFormula(result.formula ?? '');
|
||||
setEnvLocked(Boolean(result.envOverrideActive));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载计量公式失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const patchNumber = (key: keyof BillingAdminConfig, value: string) => {
|
||||
const num = Number(value);
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[key]: Number.isFinite(num) ? num : prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (envLocked) {
|
||||
setError('当前环境已锁定为仅读 env(H5_BILLING_CONFIG_SOURCE=env),无法保存。');
|
||||
return;
|
||||
}
|
||||
if (draft.marginMultiplier <= 0 || draft.usdCnyRate <= 0) {
|
||||
setError('汇率与毛利倍数必须大于 0。');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
draft.useBackendCost
|
||||
&& !window.confirm(
|
||||
`确认保存成本模式扣费?\n最终扣费 = 上游成本(USD) × ${draft.usdCnyRate} × ${draft.marginMultiplier}`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await updateBillingConfig(draft);
|
||||
setDraft(result.config);
|
||||
setSource(result.source ?? 'admin-db');
|
||||
setUpdatedAt(result.updatedAt ?? null);
|
||||
setFormula(result.formula ?? '');
|
||||
setEnvLocked(Boolean(result.envOverrideActive));
|
||||
setMessage('计量公式已保存。Portal 扣费会在数秒内读取新配置,无需重启。');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<h2>计量公式</h2>
|
||||
<p className="muted">
|
||||
成本模式:最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数。无上游成本时回退 Token 单价。
|
||||
</p>
|
||||
{loading ? <p className="muted">加载中…</p> : null}
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{!loading ? (
|
||||
<form className="admin-form" onSubmit={handleSave}>
|
||||
<p className="muted">
|
||||
当前来源:{SOURCE_LABELS[source] ?? source}
|
||||
{updatedAt
|
||||
? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}`
|
||||
: ''}
|
||||
</p>
|
||||
{formula ? <p className="muted">{formula}</p> : null}
|
||||
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.useBackendCost}
|
||||
disabled={envLocked}
|
||||
onChange={(event) => setDraft((prev) => ({ ...prev, useBackendCost: event.target.checked }))}
|
||||
/>
|
||||
<span>
|
||||
<strong>启用成本模式</strong>
|
||||
<span className="muted"> 按上游真实 USD 成本扣费(推荐)</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>汇率(USD→CNY)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.usdCnyRate}
|
||||
onChange={(e) => patchNumber('usdCnyRate', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>毛利倍数</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.marginMultiplier}
|
||||
onChange={(e) => patchNumber('marginMultiplier', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>最低扣费(分)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
disabled={envLocked}
|
||||
value={draft.minBillCents}
|
||||
onChange={(e) => patchNumber('minBillCents', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<h3>Token 回退单价(成本缺失时)</h3>
|
||||
<label>
|
||||
<span>输入(分 / 1k tokens)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.inputCentsPer1k}
|
||||
onChange={(e) => patchNumber('inputCentsPer1k', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>输出(分 / 1k tokens)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked}
|
||||
value={draft.outputCentsPer1k}
|
||||
onChange={(e) => patchNumber('outputCentsPer1k', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<h3>上游成本估算(Finish 无 cost 时)</h3>
|
||||
<label className="inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.costEstimateFromTokens}
|
||||
disabled={envLocked || !draft.useBackendCost}
|
||||
onChange={(event) => setDraft((prev) => ({
|
||||
...prev,
|
||||
costEstimateFromTokens: event.target.checked,
|
||||
}))}
|
||||
/>
|
||||
<span>
|
||||
<strong>按 Token 估算上游成本</strong>
|
||||
<span className="muted"> 仅成本模式生效</span>
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>估算输入(USD / 1M)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked || !draft.useBackendCost}
|
||||
value={draft.costEstimateInputUsdPer1M}
|
||||
onChange={(e) => patchNumber('costEstimateInputUsdPer1M', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>估算输出(USD / 1M)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={envLocked || !draft.useBackendCost}
|
||||
value={draft.costEstimateOutputUsdPer1M}
|
||||
onChange={(e) => patchNumber('costEstimateOutputUsdPer1M', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="muted">
|
||||
预览:成本模式扣费 ≈ 上游 USD × {draft.usdCnyRate} × {draft.marginMultiplier}
|
||||
</p>
|
||||
|
||||
<button type="submit" className="send-btn" disabled={saving || envLocked}>
|
||||
{saving ? '保存中…' : '保存计量公式'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function RechargeTab() {
|
||||
@@ -1453,7 +1705,7 @@ export function BillingPage() {
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>计费中心</h2>
|
||||
<p className="muted">充值、用量与资金流水</p>
|
||||
<p className="muted">充值、用量、资金流水与计量公式</p>
|
||||
</div>
|
||||
<div className="admin-tabs" role="tablist">
|
||||
{TABS.map((tab) => (
|
||||
@@ -1469,6 +1721,7 @@ export function BillingPage() {
|
||||
{activeTab === 'usage' && <UsageTab />}
|
||||
{activeTab === 'ledger' && <LedgerTab />}
|
||||
{activeTab === 'subscriptions' && <SubscriptionsTab />}
|
||||
{activeTab === 'formula' && <FormulaTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
AdminSubscription,
|
||||
AdminUserRow,
|
||||
AuthStatus,
|
||||
BillingAdminConfig,
|
||||
BillingAdminConfigResponse,
|
||||
BlockedWord,
|
||||
CapabilityDefinition,
|
||||
CapabilityMap,
|
||||
@@ -1304,6 +1306,19 @@ export async function syncSubscriptionPlansToProduction(): Promise<PlanSyncResul
|
||||
return result.sync;
|
||||
}
|
||||
|
||||
export async function getBillingConfig(): Promise<BillingAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/billing/config');
|
||||
}
|
||||
|
||||
export async function updateBillingConfig(
|
||||
config: BillingAdminConfig,
|
||||
): Promise<BillingAdminConfigResponse> {
|
||||
return portalFetch('/admin-api/billing/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAdminSubscriptions(opts?: {
|
||||
userId?: string;
|
||||
status?: string;
|
||||
|
||||
@@ -646,6 +646,27 @@ export type BlockedWord = {
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
export type BillingAdminConfig = {
|
||||
useBackendCost: boolean;
|
||||
usdCnyRate: number;
|
||||
marginMultiplier: number;
|
||||
inputCentsPer1k: number;
|
||||
outputCentsPer1k: number;
|
||||
minBillCents: number;
|
||||
costEstimateFromTokens: boolean;
|
||||
costEstimateInputUsdPer1M: number;
|
||||
costEstimateOutputUsdPer1M: number;
|
||||
};
|
||||
|
||||
export type BillingAdminConfigResponse = {
|
||||
config: BillingAdminConfig;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
source?: string;
|
||||
envOverrideActive?: boolean;
|
||||
formula?: string;
|
||||
};
|
||||
|
||||
export type PlanDefinition = {
|
||||
planType: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user