Merge pull request 'feat(billing): add metering formula admin tab' (#5) from feature/billing-formula-admin-config into main

This commit was merged in pull request #5.
This commit is contained in:
2026-08-06 09:00:04 +00:00
7 changed files with 446 additions and 3 deletions
+35
View File
@@ -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: '系统测试账号服务未启用' });
+111
View File
@@ -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);
});
+6
View File
@@ -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,
+2
View File
@@ -110,6 +110,7 @@ ready
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
billingConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
@@ -138,6 +139,7 @@ ready
orchestratorObservabilityService,
personalMemoryCandidateStore,
skillRuntimeConfigService,
billingConfigService,
wechatScheduleLlmConfigService,
adminSystemTestService,
systemTestAccountService,
+256 -3
View File
@@ -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('当前环境已锁定为仅读 envH5_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>USDCNY</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>
);
+15
View File
@@ -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;
+21
View File
@@ -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;