Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 363f9169ba | |||
| d0183cb636 | |||
| eca1aa635e | |||
| f8317e8312 |
@@ -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: '图片额度服务未启用' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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() {
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user