Compare commits

...

4 Commits

Author SHA1 Message Date
john 363f9169ba 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>
2026-08-06 15:29:16 +08:00
john d0183cb636 fix(admin): persist image quota by setting remaining directly
Use PUT setImageQuota so admins can lower remaining below the stored plan
limit; grant-only bonus updates silently no-op when bonus is already zero.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:22:22 +08:00
john eca1aa635e fix(admin): set user image quota by total instead of delta
Operators expect direct total capacity like space quota; the delta-based
grant UI caused wrong results when entering a target amount on production.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:07:49 +08:00
john f8317e8312 feat(billing): show user display name in ledger records
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 14:18:17 +08:00
9 changed files with 537 additions and 23 deletions
+54
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: '系统测试账号服务未启用' });
@@ -1233,6 +1268,25 @@ export function createAdminApp(services) {
});
});
adminApi.put('/users/:userId/image-quota', requireAdmin, async (req, res) => {
if (!subscriptionService?.setImageQuota) {
return res.status(503).json({ message: '图片额度服务未启用' });
}
const remaining = req.body?.remaining;
const total = req.body?.total;
const note = String(req.body?.note ?? '').trim();
const result = await subscriptionService.setImageQuota(
req.params.userId,
{
remaining: remaining === undefined || remaining === null ? null : Math.floor(Number(remaining)),
total: total === undefined || total === null ? null : Math.floor(Number(total)),
},
{ operatorId: req.currentUser.id, note },
);
if (!result.ok) return res.status(400).json({ message: result.message });
res.json(result);
});
adminApi.post('/users/:userId/image-quota/grant', requireAdmin, async (req, res) => {
if (!subscriptionService?.grantImageQuota) {
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,
+2 -1
View File
@@ -200,7 +200,7 @@ export async function listLedgerPaged(pool, query) {
params,
);
const [rows] = await pool.query(
`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens,
`SELECT l.id, l.user_id, u.username, u.display_name, l.type, l.amount_cents, l.tokens,
l.session_id, l.note, l.created_at
FROM h5_billing_ledger l
JOIN h5_users u ON u.id = l.user_id
@@ -214,6 +214,7 @@ export async function listLedgerPaged(pool, query) {
id: Number(row.id),
userId: row.user_id,
username: row.username,
displayName: row.display_name,
type: row.type,
amountCents: Number(row.amount_cents),
tokens: Number(row.tokens),
+260 -4
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() {
@@ -839,7 +1091,10 @@ function LedgerTab() {
{result.items.map((row) => (
<tr key={row.id}>
<td className="billing-time">{formatTime(row.createdAt)}</td>
<td>@{row.username}</td>
<td>
<span>{row.displayName || row.username}</span>
<span className="muted"> @{row.username}</span>
</td>
<td><span className={`ledger-type-tag ledger-type-${row.type}`}>{TYPE_LABEL[row.type] ?? row.type}</span></td>
<td className={`billing-num ${row.amountCents < 0 ? 'text-error' : 'text-income'}`}>
{row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))}
@@ -1450,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) => (
@@ -1466,6 +1721,7 @@ export function BillingPage() {
{activeTab === 'usage' && <UsageTab />}
{activeTab === 'ledger' && <LedgerTab />}
{activeTab === 'subscriptions' && <SubscriptionsTab />}
{activeTab === 'formula' && <FormulaTab />}
</div>
</div>
);
+49 -18
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, grantUserImageQuota } from '../../api/client';
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota } from '../../api/client';
import { CapabilitySettings } from '../../components/CapabilitySettings';
import { PolicySettings } from '../../components/PolicySettings';
import { SkillSettings } from '../../components/SkillSettings';
@@ -25,7 +25,8 @@ export function UserDetailPage() {
const [spaceQuotaMb, setSpaceQuotaMb] = useState('5');
const [imageQuota, setImageQuota] = useState<ImageQuotaView | null>(null);
const [imageQuotaLoading, setImageQuotaLoading] = useState(false);
const [imageGrant, setImageGrant] = useState({ delta: '', note: '' });
const [imageRemaining, setImageRemaining] = useState('');
const [imageGrantNote, setImageGrantNote] = useState('');
useEffect(() => {
if (!user?.spaceQuotaBytes) return;
@@ -82,6 +83,14 @@ export function UserDetailPage() {
};
}, [user?.id, user?.role]);
useEffect(() => {
if (!imageQuota || imageQuota.unlimited || imageQuota.remaining == null) {
setImageRemaining('');
return;
}
setImageRemaining(String(imageQuota.remaining));
}, [imageQuota?.remaining, imageQuota?.unlimited]);
const handleRecharge = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
@@ -100,24 +109,36 @@ export function UserDetailPage() {
}
};
const handleImageGrant = async (e: React.FormEvent) => {
const handleImageQuotaSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
setMessage(null);
setLocalError(null);
setError(null);
const delta = Math.floor(Number(imageGrant.delta));
if (!Number.isFinite(delta) || delta === 0) {
setLocalError('请输入非零整数额度');
if (!imageQuota) {
setLocalError('暂无额度信息,用户可能尚无有效订阅');
return;
}
if (imageQuota.unlimited) {
setLocalError('当前为无限额度套餐,无法在此调整');
return;
}
const targetRemaining = Math.floor(Number(imageRemaining));
if (!Number.isFinite(targetRemaining) || targetRemaining < 0) {
setLocalError('请输入非负整数剩余额度');
return;
}
if (targetRemaining === imageQuota.remaining) {
setMessage('额度未变化');
return;
}
try {
const result = await grantUserImageQuota(user.id, delta, imageGrant.note.trim());
const result = await setUserImageQuota(user.id, { remaining: targetRemaining }, imageGrantNote.trim());
setImageQuota(result.quota);
setMessage('图片额度已更新');
setImageGrant({ delta: '', note: '' });
setMessage(result.unchanged ? '额度未变化' : '图片额度已更新');
setImageGrantNote('');
} catch (err) {
setLocalError(err instanceof Error ? err.message : '图片额度调整失败');
setLocalError(err instanceof Error ? err.message : '图片额度设置失败');
}
};
@@ -245,21 +266,31 @@ export function UserDetailPage() {
) : (
<p className="muted">{imageQuotaSummary || '暂无额度信息(用户可能尚无订阅)'}</p>
)}
<form className="admin-form" onSubmit={handleImageGrant}>
<p className="muted">
</p>
<form className="admin-form" onSubmit={handleImageQuotaSave}>
<input
placeholder="调整额度(张,正数充值、负数扣减"
placeholder="剩余额度(张)"
type="number"
min="0"
step="1"
value={imageGrant.delta}
onChange={(e) => setImageGrant((s) => ({ ...s, delta: e.target.value }))}
value={imageRemaining}
onChange={(e) => setImageRemaining(e.target.value)}
disabled={!imageQuota || imageQuota.unlimited}
/>
<input
placeholder="备注"
value={imageGrant.note}
onChange={(e) => setImageGrant((s) => ({ ...s, note: e.target.value }))}
value={imageGrantNote}
onChange={(e) => setImageGrantNote(e.target.value)}
disabled={!imageQuota || imageQuota.unlimited}
/>
<button type="submit" className="send-btn">
<button
type="submit"
className="send-btn"
disabled={!imageQuota || imageQuota.unlimited}
>
</button>
</form>
</section>
+31
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;
@@ -1373,6 +1388,22 @@ export async function fetchUserImageQuota(userId: string) {
}>(`/admin-api/users/${userId}/image-quota`);
}
export async function setUserImageQuota(
userId: string,
payload: { remaining?: number; total?: number },
note = '',
) {
return portalFetch<{
ok: boolean;
unchanged?: boolean;
subscription: AdminSubscription;
quota: ImageQuotaView;
}>(`/admin-api/users/${userId}/image-quota`, {
method: 'PUT',
body: JSON.stringify({ ...payload, note }),
});
}
export async function grantUserImageQuota(userId: string, delta: number, note = '') {
return portalFetch<{
ok: boolean;
+22
View File
@@ -282,6 +282,7 @@ export type LedgerEntry = {
id: number;
userId: string;
username: string;
displayName: string;
type: 'recharge' | 'deduct' | 'refund' | 'adjust';
amountCents: number;
tokens: number;
@@ -645,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;