From 363f9169ba8e122e8175b76055157adaf9178e90 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 6 Aug 2026 15:29:16 +0800 Subject: [PATCH] feat(billing): add metering formula admin tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose margin multiplier, FX rate, and cost-mode settings under 计费中心 so operators can adjust DeepSeek billing without env edits. Co-authored-by: Cursor --- server/app.mjs | 35 ++++ server/billing-config-routes.test.mjs | 111 +++++++++++ server/bootstrap.mjs | 6 + server/index.mjs | 2 + src/admin/pages/BillingPage.tsx | 259 +++++++++++++++++++++++++- src/api/client.ts | 15 ++ src/types.ts | 21 +++ 7 files changed, 446 insertions(+), 3 deletions(-) create mode 100644 server/billing-config-routes.test.mjs diff --git a/server/app.mjs b/server/app.mjs index 990bfa3..497c61b 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -104,6 +104,7 @@ export function createAdminApp(services) { orchestratorObservabilityService, personalMemoryCandidateStore, skillRuntimeConfigService, + billingConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, @@ -628,6 +629,40 @@ export function createAdminApp(services) { res.json(await skillRuntimeConfigService.getPublicRuntimeConfig()); }); + adminApi.get('/billing/config', requireAdmin, async (_req, res) => { + if (!billingConfigService?.getAdminConfig) { + return res.status(503).json({ message: '计费公式配置服务未启用' }); + } + return res.json(await billingConfigService.getAdminConfig()); + }); + + const updateBillingConfig = async (req, res) => { + if (!billingConfigService?.updateAdminConfig) { + return res.status(503).json({ message: '计费公式配置服务未启用' }); + } + try { + return res.json(await billingConfigService.updateAdminConfig( + req.body?.config ?? req.body ?? {}, + { updatedBy: req.currentUser.id }, + )); + } catch (error) { + if (error?.code === 'BILLING_CONFIG_ENV_LOCKED') { + return res.status(409).json({ message: error.message, code: error.code }); + } + throw error; + } + }; + + adminApi.put('/billing/config', requireAdmin, updateBillingConfig); + adminApi.patch('/billing/config', requireAdmin, updateBillingConfig); + + adminApi.get('/billing/runtime', requireAdmin, async (_req, res) => { + if (!billingConfigService?.getRuntimeState) { + return res.status(503).json({ message: '计费公式配置服务未启用' }); + } + return res.json(await billingConfigService.getRuntimeState()); + }); + adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => { if (!systemTestAccountService) { return res.status(503).json({ message: '系统测试账号服务未启用' }); diff --git a/server/billing-config-routes.test.mjs b/server/billing-config-routes.test.mjs new file mode 100644 index 0000000..74a6151 --- /dev/null +++ b/server/billing-config-routes.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import test from 'node:test'; +import { createAdminApp } from './app.mjs'; + +function createServices({ role = 'admin' } = {}) { + let stored = { + useBackendCost: true, + usdCnyRate: 7.2, + marginMultiplier: 1.2, + inputCentsPer1k: 2, + outputCentsPer1k: 6, + minBillCents: 1, + costEstimateFromTokens: true, + costEstimateInputUsdPer1M: 0.27, + costEstimateOutputUsdPer1M: 1.1, + }; + + return { + services: { + ready: Promise.resolve(), + parseCookies: () => ({ test_session: 'token' }), + USER_COOKIE: 'test_session', + userLoginCookies: () => [], + clearUserSessionCookie: () => {}, + resolveCookieDomainForRequest: () => undefined, + userAuth: { + getMe: async () => ({ id: 'admin-id', username: 'admin', role }), + }, + billingConfigService: { + getAdminConfig: async () => ({ + config: stored, + source: 'env', + updatedAt: null, + updatedBy: null, + formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数', + }), + updateAdminConfig: async (patch, context) => { + stored = { ...stored, ...(patch.config ?? patch) }; + return { + config: stored, + source: 'admin-db', + updatedAt: Date.now(), + updatedBy: context?.updatedBy ?? null, + formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数', + }; + }, + getRuntimeState: async () => ({ + source: 'admin-db', + config: stored, + compute: stored, + }), + }, + }, + }; +} + +async function startApp(options) { + const harness = createServices(options); + const server = createAdminApp(harness.services).listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + ...harness, + request: (path, init = {}) => fetch(`http://127.0.0.1:${address.port}${path}`, { + ...init, + headers: { + Cookie: 'test_session=token', + 'Content-Type': 'application/json', + ...init.headers, + }, + }), + async close() { + server.close(); + await once(server, 'close'); + }, + }; +} + +test('admin can read and update billing formula config', async (t) => { + const app = await startApp(); + t.after(() => app.close()); + + const read = await app.request('/admin-api/billing/config'); + assert.equal(read.status, 200); + const body = await read.json(); + assert.equal(body.config.marginMultiplier, 1.2); + + const update = await app.request('/admin-api/billing/config', { + method: 'PUT', + body: JSON.stringify({ + config: { + ...body.config, + marginMultiplier: 1.5, + }, + }), + }); + assert.equal(update.status, 200); + const updated = await update.json(); + assert.equal(updated.config.marginMultiplier, 1.5); + assert.equal(updated.source, 'admin-db'); +}); + +test('ordinary users cannot access billing formula config', async (t) => { + const app = await startApp({ role: 'user' }); + t.after(() => app.close()); + + const read = await app.request('/admin-api/billing/config'); + assert.equal(read.status, 403); +}); diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index bb73016..e7e70aa 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -39,6 +39,7 @@ export async function bootstrapAdminServices() { ); const { createPersonalMemoryCandidateStore } = await importMemind('memory-v2-personal-store.mjs'); const { createSkillRuntimeAdminConfigService } = await importMemind('skill-runtime-admin-config.mjs'); + const { createBillingAdminConfigService } = await importMemind('billing-admin-config.mjs'); const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs'); const { ensureAssetGatewaySchema } = await importMemind('db.mjs'); const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs'); @@ -118,6 +119,10 @@ export async function bootstrapAdminServices() { env: process.env, h5Root, }); + const billingConfigService = createBillingAdminConfigService(pool, { + env: process.env, + }); + await billingConfigService.ensureSchema(); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool, { env: process.env, }); @@ -184,6 +189,7 @@ export async function bootstrapAdminServices() { orchestratorObservabilityService, personalMemoryCandidateStore, skillRuntimeConfigService, + billingConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, diff --git a/server/index.mjs b/server/index.mjs index b4e3868..1d22725 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -110,6 +110,7 @@ ready orchestratorObservabilityService, personalMemoryCandidateStore, skillRuntimeConfigService, + billingConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, @@ -138,6 +139,7 @@ ready orchestratorObservabilityService, personalMemoryCandidateStore, skillRuntimeConfigService, + billingConfigService, wechatScheduleLlmConfigService, adminSystemTestService, systemTestAccountService, diff --git a/src/admin/pages/BillingPage.tsx b/src/admin/pages/BillingPage.tsx index f97ccbd..7a9eaa4 100644 --- a/src/admin/pages/BillingPage.tsx +++ b/src/admin/pages/BillingPage.tsx @@ -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 = { @@ -130,6 +143,14 @@ const TAB_PATHS: Record = { recharge: '/billing/recharge', usage: '/billing/usage', ledger: '/billing/ledger', + formula: '/billing/formula', +}; + +const SOURCE_LABELS: Record = { + '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(DEFAULT_BILLING_FORMULA); + const [source, setSource] = useState('default'); + const [updatedAt, setUpdatedAt] = useState(null); + const [formula, setFormula] = useState(''); + const [envLocked, setEnvLocked] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [message, setMessage] = useState(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 ( +
+

计量公式

+

+ 成本模式:最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数。无上游成本时回退 Token 单价。 +

+ {loading ?

加载中…

: null} + {error &&

{error}

} + {message &&

{message}

} + {!loading ? ( +
+

+ 当前来源:{SOURCE_LABELS[source] ?? source} + {updatedAt + ? ` · 更新于 ${new Date(updatedAt).toLocaleString('zh-CN', { hour12: false })}` + : ''} +

+ {formula ?

{formula}

: null} + + + + + + + +

Token 回退单价(成本缺失时)

+ + + +

上游成本估算(Finish 无 cost 时)

+ + + + +

+ 预览:成本模式扣费 ≈ 上游 USD × {draft.usdCnyRate} × {draft.marginMultiplier} +

+ + +
+ ) : null} +
+ ); +} + const PAGE_SIZE = 20; function RechargeTab() { @@ -1453,7 +1705,7 @@ export function BillingPage() {

计费中心

-

充值、用量与资金流水

+

充值、用量、资金流水与计量公式

{TABS.map((tab) => ( @@ -1469,6 +1721,7 @@ export function BillingPage() { {activeTab === 'usage' && } {activeTab === 'ledger' && } {activeTab === 'subscriptions' && } + {activeTab === 'formula' && }
); diff --git a/src/api/client.ts b/src/api/client.ts index c368ca6..43025dc 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -8,6 +8,8 @@ import type { AdminSubscription, AdminUserRow, AuthStatus, + BillingAdminConfig, + BillingAdminConfigResponse, BlockedWord, CapabilityDefinition, CapabilityMap, @@ -1304,6 +1306,19 @@ export async function syncSubscriptionPlansToProduction(): Promise { + return portalFetch('/admin-api/billing/config'); +} + +export async function updateBillingConfig( + config: BillingAdminConfig, +): Promise { + return portalFetch('/admin-api/billing/config', { + method: 'PUT', + body: JSON.stringify({ config }), + }); +} + export async function listAdminSubscriptions(opts?: { userId?: string; status?: string; diff --git a/src/types.ts b/src/types.ts index 86275f8..f14046c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;