feat(web): 增加计费结算异常管理界面
管理端支持按状态查看结算与释放记录,展示精确到九位的小数金额、错误分类和重试次数,并使用一次性 Idempotency-Key 发起审计化重试。
This commit is contained in:
@@ -3,6 +3,8 @@ import type {
|
|||||||
AuthUser,
|
AuthUser,
|
||||||
BaseModelCatalogItem,
|
BaseModelCatalogItem,
|
||||||
BaseModelUpsertRequest,
|
BaseModelUpsertRequest,
|
||||||
|
BillingSettlementListResponse,
|
||||||
|
BillingSettlementRetryResponse,
|
||||||
CatalogProvider,
|
CatalogProvider,
|
||||||
CatalogProviderUpsertRequest,
|
CatalogProviderUpsertRequest,
|
||||||
ClientCustomizationSettings,
|
ClientCustomizationSettings,
|
||||||
@@ -1000,6 +1002,28 @@ export async function restoreModelRuntimeStatus(token: string, platformModelId:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listBillingSettlements(
|
||||||
|
token: string,
|
||||||
|
input: { status?: string; action?: string; page?: number; pageSize?: number; signal?: AbortSignal } = {},
|
||||||
|
): Promise<BillingSettlementListResponse> {
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
page: String(input.page ?? 1),
|
||||||
|
pageSize: String(input.pageSize ?? 50),
|
||||||
|
});
|
||||||
|
if (input.status) search.set('status', input.status);
|
||||||
|
if (input.action) search.set('action', input.action);
|
||||||
|
return request<BillingSettlementListResponse>(`/api/admin/runtime/billing-settlements?${search.toString()}`, { token, signal: input.signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryBillingSettlement(token: string, settlementId: string): Promise<BillingSettlementRetryResponse> {
|
||||||
|
const idempotencyKey = crypto.randomUUID();
|
||||||
|
return request<BillingSettlementRetryResponse>(`/api/admin/runtime/billing-settlements/${settlementId}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
token,
|
||||||
|
headers: { 'Idempotency-Key': idempotencyKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getNetworkProxyConfig(token: string): Promise<GatewayNetworkProxyConfig> {
|
export async function getNetworkProxyConfig(token: string): Promise<GatewayNetworkProxyConfig> {
|
||||||
return request<GatewayNetworkProxyConfig>('/api/admin/config/network-proxy', { token });
|
return request<GatewayNetworkProxyConfig>('/api/admin/config/network-proxy', { token });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type { AdminSection, LoadState, PlatformWithModelsInput } from '../types'
|
|||||||
import { AccessRulesPanel } from './admin/AccessRulesPanel';
|
import { AccessRulesPanel } from './admin/AccessRulesPanel';
|
||||||
import { AuditLogsPanel } from './admin/AuditLogsPanel';
|
import { AuditLogsPanel } from './admin/AuditLogsPanel';
|
||||||
import { BaseModelCatalogPanel } from './admin/BaseModelCatalogPanel';
|
import { BaseModelCatalogPanel } from './admin/BaseModelCatalogPanel';
|
||||||
|
import { BillingSettlementsPanel } from './admin/BillingSettlementsPanel';
|
||||||
import { TenantsPanel, UserGroupsPanel, UsersPanel } from './admin/IdentityManagementPanels';
|
import { TenantsPanel, UserGroupsPanel, UsersPanel } from './admin/IdentityManagementPanels';
|
||||||
import { PlatformManagementPanel } from './admin/PlatformManagementPanel';
|
import { PlatformManagementPanel } from './admin/PlatformManagementPanel';
|
||||||
import { PricingRulesPanel } from './admin/PricingRulesPanel';
|
import { PricingRulesPanel } from './admin/PricingRulesPanel';
|
||||||
@@ -131,15 +132,18 @@ export function AdminPage(props: {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{props.section === 'runtime' && (
|
{props.section === 'runtime' && (
|
||||||
<RuntimePoliciesPanel
|
<div className="pageStack">
|
||||||
message={props.operationMessage}
|
<BillingSettlementsPanel token={props.token} />
|
||||||
runnerPolicy={props.data.runnerPolicy}
|
<RuntimePoliciesPanel
|
||||||
runtimePolicySets={props.data.runtimePolicySets}
|
message={props.operationMessage}
|
||||||
state={props.state}
|
runnerPolicy={props.data.runnerPolicy}
|
||||||
onDeleteRuntimePolicySet={props.onDeleteRuntimePolicySet}
|
runtimePolicySets={props.data.runtimePolicySets}
|
||||||
onSaveRunnerPolicy={props.onSaveRunnerPolicy}
|
state={props.state}
|
||||||
onSaveRuntimePolicySet={props.onSaveRuntimePolicySet}
|
onDeleteRuntimePolicySet={props.onDeleteRuntimePolicySet}
|
||||||
/>
|
onSaveRunnerPolicy={props.onSaveRunnerPolicy}
|
||||||
|
onSaveRuntimePolicySet={props.onSaveRuntimePolicySet}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{props.section === 'accessRules' && (
|
{props.section === 'accessRules' && (
|
||||||
<AccessRulesPanel
|
<AccessRulesPanel
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle, ReceiptText, RefreshCw, RotateCcw } from 'lucide-react';
|
||||||
|
import type { BillingSettlement } from '@easyai-ai-gateway/contracts';
|
||||||
|
import { listBillingSettlements, retryBillingSettlement } from '../../api';
|
||||||
|
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
|
||||||
|
|
||||||
|
export function BillingSettlementsPanel(props: { token: string }) {
|
||||||
|
const [items, setItems] = useState<BillingSettlement[]>([]);
|
||||||
|
const [status, setStatus] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [retrying, setRetrying] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
|
setLoading(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const result = await listBillingSettlements(props.token, { status, pageSize: 100, signal });
|
||||||
|
setItems(result.items);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
setMessage(error instanceof Error ? error.message : '结算队列加载失败');
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [props.token, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
void load(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function retry(item: BillingSettlement) {
|
||||||
|
setRetrying(item.id);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await retryBillingSettlement(props.token, item.id);
|
||||||
|
setMessage(`结算 ${shortId(item.id)} 已重新进入队列`);
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : '结算重试失败');
|
||||||
|
} finally {
|
||||||
|
setRetrying('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exceptions = items.filter((item) => item.status === 'retryable_failed' || item.status === 'manual_review').length;
|
||||||
|
return (
|
||||||
|
<div className="pageStack">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="identityHeaderTitle">
|
||||||
|
<div className="iconBox"><ReceiptText size={17} /></div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>计费结算</CardTitle>
|
||||||
|
<p className="mutedText">任务结果与账务状态相互独立;异常记录可审计重试,不会重新调用上游。</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={exceptions ? 'warning' : 'secondary'}>{exceptions ? `${exceptions} 条异常` : `${items.length} 条`}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="tableActions">
|
||||||
|
<Select size="sm" aria-label="结算状态" value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="pending">待处理</option>
|
||||||
|
<option value="processing">处理中</option>
|
||||||
|
<option value="retryable_failed">等待重试</option>
|
||||||
|
<option value="manual_review">人工复核</option>
|
||||||
|
<option value="completed">已完成</option>
|
||||||
|
</Select>
|
||||||
|
<Button type="button" size="sm" variant="outline" disabled={loading} onClick={() => void load()}>
|
||||||
|
<RefreshCw size={14} />{loading ? '刷新中' : '刷新'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{message && <p className="formMessage">{message}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{items.length ? (
|
||||||
|
<Table density="compact" className="identityDataTable">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>任务 / 动作</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>金额</TableHead>
|
||||||
|
<TableHead>尝试</TableHead>
|
||||||
|
<TableHead>异常分类</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
{items.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell><span className="identityTableName"><strong>{shortId(item.taskId)}</strong><small>{item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)}</small></span></TableCell>
|
||||||
|
<TableCell><Badge variant={statusVariant(item.status)}>{statusLabel(item.status)}</Badge></TableCell>
|
||||||
|
<TableCell>{formatAmount(item.amount)} {item.currency}</TableCell>
|
||||||
|
<TableCell>{item.attempts} / 20</TableCell>
|
||||||
|
<TableCell>{item.lastErrorCode || item.manualReviewReason || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{(item.status === 'retryable_failed' || item.status === 'manual_review') ? (
|
||||||
|
<Button type="button" size="xs" variant="outline" disabled={retrying === item.id} onClick={() => void retry(item)}>
|
||||||
|
<RotateCcw size={13} />{retrying === item.id ? '提交中' : '重试'}
|
||||||
|
</Button>
|
||||||
|
) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<Card><CardContent className="emptyState"><AlertTriangle size={18} /><strong>{loading ? '正在加载结算队列' : '暂无结算记录'}</strong><span>可切换状态筛选,查看待处理与人工复核记录。</span></CardContent></Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
return ({ pending: '待处理', processing: '处理中', retryable_failed: '等待重试', manual_review: '人工复核', completed: '已完成' } as Record<string, string>)[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(status: string): 'secondary' | 'warning' | 'destructive' | 'success' | 'outline' {
|
||||||
|
if (status === 'completed') return 'success';
|
||||||
|
if (status === 'manual_review') return 'destructive';
|
||||||
|
if (status === 'retryable_failed') return 'warning';
|
||||||
|
if (status === 'processing') return 'outline';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(value: number) {
|
||||||
|
return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 9 }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(value: string) {
|
||||||
|
return value.length > 12 ? `${value.slice(0, 8)}…${value.slice(-4)}` : value;
|
||||||
|
}
|
||||||
@@ -1156,6 +1156,15 @@ export interface GatewayTask {
|
|||||||
metrics?: Record<string, unknown>;
|
metrics?: Record<string, unknown>;
|
||||||
billingSummary?: Record<string, unknown>;
|
billingSummary?: Record<string, unknown>;
|
||||||
finalChargeAmount?: number;
|
finalChargeAmount?: number;
|
||||||
|
billingVersion?: string;
|
||||||
|
billingStatus?: 'not_started' | 'pending' | 'processing' | 'settled' | 'released' | 'retryable_failed' | 'manual_review' | 'not_required' | string;
|
||||||
|
billingCurrency?: string;
|
||||||
|
pricingSnapshot?: Record<string, unknown>;
|
||||||
|
requestFingerprint?: string;
|
||||||
|
reservationAmount?: number;
|
||||||
|
executionLeaseExpiresAt?: string;
|
||||||
|
billingUpdatedAt?: string;
|
||||||
|
billingSettledAt?: string;
|
||||||
responseStartedAt?: string;
|
responseStartedAt?: string;
|
||||||
responseFinishedAt?: string;
|
responseFinishedAt?: string;
|
||||||
responseDurationMs?: number;
|
responseDurationMs?: number;
|
||||||
@@ -1212,12 +1221,49 @@ export interface GatewayTaskAttempt {
|
|||||||
responseStartedAt?: string;
|
responseStartedAt?: string;
|
||||||
responseFinishedAt?: string;
|
responseFinishedAt?: string;
|
||||||
responseDurationMs?: number;
|
responseDurationMs?: number;
|
||||||
|
pricingSnapshot?: Record<string, unknown>;
|
||||||
|
requestFingerprint?: string;
|
||||||
|
upstreamSubmissionStatus?: 'not_submitted' | 'submitting' | 'response_received' | string;
|
||||||
|
upstreamSubmissionUpdatedAt?: string;
|
||||||
errorCode?: string;
|
errorCode?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
finishedAt?: string;
|
finishedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BillingSettlement {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
action: 'settle' | 'release' | string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: 'pending' | 'processing' | 'retryable_failed' | 'completed' | 'manual_review' | string;
|
||||||
|
attempts: number;
|
||||||
|
nextAttemptAt: string;
|
||||||
|
lockedBy?: string;
|
||||||
|
lockedAt?: string;
|
||||||
|
lastErrorCode?: string;
|
||||||
|
lastErrorMessage?: string;
|
||||||
|
manualReviewReason?: string;
|
||||||
|
pricingSnapshot?: Record<string, unknown>;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
completedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillingSettlementListResponse {
|
||||||
|
items: BillingSettlement[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillingSettlementRetryResponse {
|
||||||
|
settlement: BillingSettlement;
|
||||||
|
auditLog?: GatewayAuditLog;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayTaskEvent {
|
export interface GatewayTaskEvent {
|
||||||
id: string;
|
id: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user