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:
@@ -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: '系统测试账号服务未启用' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -110,6 +110,7 @@ ready
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
@@ -138,6 +139,7 @@ ready
|
||||
orchestratorObservabilityService,
|
||||
personalMemoryCandidateStore,
|
||||
skillRuntimeConfigService,
|
||||
billingConfigService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
|
||||
Reference in New Issue
Block a user