diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index f367ae3..834b372 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -3,6 +3,8 @@ import type { AuthUser, BaseModelCatalogItem, BaseModelUpsertRequest, + BillingSettlementListResponse, + BillingSettlementRetryResponse, CatalogProvider, CatalogProviderUpsertRequest, 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 { + 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(`/api/admin/runtime/billing-settlements?${search.toString()}`, { token, signal: input.signal }); +} + +export async function retryBillingSettlement(token: string, settlementId: string): Promise { + const idempotencyKey = crypto.randomUUID(); + return request(`/api/admin/runtime/billing-settlements/${settlementId}/retry`, { + method: 'POST', + token, + headers: { 'Idempotency-Key': idempotencyKey }, + }); +} + export async function getNetworkProxyConfig(token: string): Promise { return request('/api/admin/config/network-proxy', { token }); } diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index 59404bd..f3649fc 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -26,6 +26,7 @@ import type { AdminSection, LoadState, PlatformWithModelsInput } from '../types' import { AccessRulesPanel } from './admin/AccessRulesPanel'; import { AuditLogsPanel } from './admin/AuditLogsPanel'; import { BaseModelCatalogPanel } from './admin/BaseModelCatalogPanel'; +import { BillingSettlementsPanel } from './admin/BillingSettlementsPanel'; import { TenantsPanel, UserGroupsPanel, UsersPanel } from './admin/IdentityManagementPanels'; import { PlatformManagementPanel } from './admin/PlatformManagementPanel'; import { PricingRulesPanel } from './admin/PricingRulesPanel'; @@ -131,15 +132,18 @@ export function AdminPage(props: { /> )} {props.section === 'runtime' && ( - +
+ + +
)} {props.section === 'accessRules' && ( ([]); + 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 ( +
+ + +
+
+
+ 计费结算 +

任务结果与账务状态相互独立;异常记录可审计重试,不会重新调用上游。

+
+ {exceptions ? `${exceptions} 条异常` : `${items.length} 条`} +
+
+ +
+ + +
+ {message &&

{message}

} +
+
+ {items.length ? ( + + + 任务 / 动作 + 状态 + 金额 + 尝试 + 异常分类 + 操作 + + {items.map((item) => ( + + {shortId(item.taskId)}{item.action === 'settle' ? '扣费' : '释放冻结'} · {shortId(item.id)} + {statusLabel(item.status)} + {formatAmount(item.amount)} {item.currency} + {item.attempts} / 20 + {item.lastErrorCode || item.manualReviewReason || '-'} + + {(item.status === 'retryable_failed' || item.status === 'manual_review') ? ( + + ) : '-'} + + + ))} +
+ ) : ( + {loading ? '正在加载结算队列' : '暂无结算记录'}可切换状态筛选,查看待处理与人工复核记录。 + )} +
+ ); +} + +function statusLabel(status: string) { + return ({ pending: '待处理', processing: '处理中', retryable_failed: '等待重试', manual_review: '人工复核', completed: '已完成' } as Record)[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; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ea9504d..4f19f2e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1156,6 +1156,15 @@ export interface GatewayTask { metrics?: Record; billingSummary?: Record; finalChargeAmount?: number; + billingVersion?: string; + billingStatus?: 'not_started' | 'pending' | 'processing' | 'settled' | 'released' | 'retryable_failed' | 'manual_review' | 'not_required' | string; + billingCurrency?: string; + pricingSnapshot?: Record; + requestFingerprint?: string; + reservationAmount?: number; + executionLeaseExpiresAt?: string; + billingUpdatedAt?: string; + billingSettledAt?: string; responseStartedAt?: string; responseFinishedAt?: string; responseDurationMs?: number; @@ -1212,12 +1221,49 @@ export interface GatewayTaskAttempt { responseStartedAt?: string; responseFinishedAt?: string; responseDurationMs?: number; + pricingSnapshot?: Record; + requestFingerprint?: string; + upstreamSubmissionStatus?: 'not_submitted' | 'submitting' | 'response_received' | string; + upstreamSubmissionUpdatedAt?: string; errorCode?: string; errorMessage?: string; startedAt: 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; + payload?: Record; + 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 { id: string; taskId: string;