fix(billing): 封住发布前计费竞态
ci / verify (pull_request) Failing after 8s

阻止上游提交状态不明的任务被租约接管后重复执行,并为人工复核保留可操作的结算记录。

将生产提交绑定到当前估价签名,统一复用预处理快照,并补强规则形状、定点溢出与历史规则兼容校验。

已通过 PostgreSQL 16 集成测试、Go 全量测试与静态检查、前端测试与构建、OpenAPI、依赖审计、镜像、迁移、流水线和 SemVer 门禁。
This commit is contained in:
2026-07-21 10:23:58 +08:00
parent 257ee09e58
commit 8beb8501fa
18 changed files with 762 additions and 75 deletions
@@ -3,6 +3,7 @@ import type { GatewayPricingEstimate } from '@easyai-ai-gateway/contracts';
import {
MEDIA_ESTIMATE_DEBOUNCE_MS,
billingEstimateSignature,
mediaEstimateAllowsProduction,
mediaEstimateFromResponse,
} from './PlaygroundPage';
@@ -46,4 +47,11 @@ describe('playground pricing estimate', () => {
status: 'free',
});
});
it('only allows production submission for the current pricing signature', () => {
expect(mediaEstimateAllowsProduction({ status: 'ready', signature: 'current' }, 'current')).toBe(true);
expect(mediaEstimateAllowsProduction({ status: 'free', signature: 'current' }, 'current')).toBe(true);
expect(mediaEstimateAllowsProduction({ status: 'ready', signature: 'stale' }, 'current')).toBe(false);
expect(mediaEstimateAllowsProduction({ status: 'loading', signature: 'current' }, 'current')).toBe(false);
});
});
+28 -9
View File
@@ -66,6 +66,7 @@ type MediaEstimateState = {
error?: string;
pricingVersion?: string;
resolver?: string;
signature?: string;
status: 'idle' | 'loading' | 'ready' | 'free' | 'unavailable' | 'error';
};
@@ -192,18 +193,19 @@ export function PlaygroundPage(props: {
const sequence = mediaEstimateSequenceRef.current + 1;
mediaEstimateSequenceRef.current = sequence;
const controller = new AbortController();
setMediaEstimate((current) => ({ ...current, error: '', status: 'loading' }));
setMediaEstimate((current) => ({ ...current, error: '', signature: mediaEstimateSignature, status: 'loading' }));
const timer = window.setTimeout(() => {
estimatePricing(credential, mediaEstimatePayload, controller.signal)
.then((estimate) => {
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
setMediaEstimate(mediaEstimateFromResponse(estimate));
setMediaEstimate({ ...mediaEstimateFromResponse(estimate), signature: mediaEstimateSignature });
})
.catch((err) => {
if (controller.signal.aborted || sequence !== mediaEstimateSequenceRef.current) return;
const unavailable = err instanceof GatewayApiError && err.details.code === 'pricing_unavailable';
setMediaEstimate({
error: err instanceof Error ? err.message : unavailable ? '价格不可用' : '预计扣费计算失败',
signature: mediaEstimateSignature,
status: unavailable ? 'unavailable' : 'error',
});
});
@@ -326,17 +328,34 @@ export function PlaygroundPage(props: {
setMediaMessage(runMode === 'video' ? '请输入视频提示词。' : '请输入图片提示词。');
return;
}
if (!overrides && !mediaEstimateAllowsProduction(mediaEstimate)) {
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
const runVideoMode = overrides?.videoMode ?? videoMode;
const runModelOption = modelOptions.find((item) => item.value === runModel);
const runEstimatePayload = buildMediaEstimatePayload(runMode, runModel, trimmedPrompt, runSettings, runUploads, runVideoMode, {
supportsQualityControl: runModelOption
? deriveMediaModelCapabilities(runModelOption.models, runMode, runVideoMode, runSettings.resolution).supportsQualityControl
: mediaCapabilities?.supportsQualityControl,
});
const runEstimateSignature = billingEstimateSignature(runEstimatePayload);
if (!overrides && !mediaEstimateAllowsProduction(mediaEstimate, runEstimateSignature)) {
setMediaMessage(mediaEstimate.status === 'unavailable'
? '当前参数没有可用价格,已阻止生产生成。'
: '请等待当前参数的预计费用计算完成后再生成。');
return;
}
if (overrides) {
try {
await estimatePricing(credential, runEstimatePayload);
} catch (error) {
const unavailable = error instanceof GatewayApiError && error.details.code === 'pricing_unavailable';
setMediaMessage(unavailable
? '当前参数没有可用价格,已阻止生产生成。'
: `预计扣费计算失败,未提交生产任务:${error instanceof Error ? error.message : '未知错误'}`);
return;
}
}
const localId = newLocalId();
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
const runVideoMode = overrides?.videoMode ?? videoMode;
const runModelOption = modelOptions.find((item) => item.value === runModel);
const modelLabel = runModelOption?.label ?? runModel;
const run: MediaGenerationRun = {
createdAt: new Date().toISOString(),
@@ -475,7 +494,7 @@ export function PlaygroundPage(props: {
imageHasReference={effectiveImageHasReference}
mediaSettings={mediaSettings}
mediaEstimate={mediaEstimate}
submitDisabled={!mediaEstimateAllowsProduction(mediaEstimate)}
submitDisabled={!mediaEstimateAllowsProduction(mediaEstimate, mediaEstimateSignature)}
mediaCapabilities={mediaCapabilities}
uploadAccept={mediaUploadAcceptValue}
uploadMessage={mediaUploadMessage}
@@ -901,8 +920,8 @@ function mediaEstimateAriaLabel(estimate: MediaEstimateState) {
return `预计扣费 ${formatEstimateAmount(estimate.amount)} ${estimate.currency || 'resource'}`;
}
function mediaEstimateAllowsProduction(estimate: MediaEstimateState) {
return estimate.status === 'ready' || estimate.status === 'free';
export function mediaEstimateAllowsProduction(estimate: MediaEstimateState, signature: string) {
return estimate.signature === signature && (estimate.status === 'ready' || estimate.status === 'free');
}
export function billingEstimateSignature(payload: Record<string, unknown> | null) {
@@ -2,13 +2,14 @@ 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';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, 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 [confirming, setConfirming] = useState<BillingSettlement | null>(null);
const [message, setMessage] = useState('');
const load = useCallback(async (signal?: AbortSignal) => {
@@ -95,8 +96,8 @@ export function BillingSettlementsPanel(props: { token: string }) {
<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 type="button" size="xs" variant="outline" disabled={retrying === item.id} onClick={() => setConfirming(item)}>
<RotateCcw size={13} />{retrying === item.id ? '提交中' : retryActionLabel(item)}
</Button>
) : '-'}
</TableCell>
@@ -106,6 +107,19 @@ export function BillingSettlementsPanel(props: { token: string }) {
) : (
<Card><CardContent className="emptyState"><AlertTriangle size={18} /><strong>{loading ? '正在加载结算队列' : '暂无结算记录'}</strong><span></span></CardContent></Card>
)}
<ConfirmDialog
open={Boolean(confirming)}
loading={Boolean(confirming && retrying === confirming.id)}
title={confirming ? `${retryActionLabel(confirming)}这条计费记录?` : '处理计费记录?'}
description={confirming ? retryActionDescription(confirming) : ''}
confirmLabel={confirming ? retryActionLabel(confirming) : '确认'}
onCancel={() => setConfirming(null)}
onConfirm={() => {
if (!confirming) return;
const item = confirming;
void retry(item).finally(() => setConfirming(null));
}}
/>
</div>
);
}
@@ -126,6 +140,23 @@ function formatAmount(value: number) {
return new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 9 }).format(value);
}
function retryActionLabel(item: BillingSettlement) {
if (item.status !== 'manual_review') return '重试';
if (item.manualReviewReason === 'upstream_submission_unknown' && item.action === 'release') return '确认释放';
if (item.manualReviewReason === 'historical_success_without_charge' && item.action === 'settle') return '确认结算';
return '重新入队';
}
function retryActionDescription(item: BillingSettlement) {
if (item.manualReviewReason === 'upstream_submission_unknown' && item.action === 'release') {
return '上游提交结果不明。确认后只会释放当前冻结,不会再次调用上游;请先完成外部核查。';
}
if (item.manualReviewReason === 'historical_success_without_charge' && item.action === 'settle') {
return `确认后将按历史快照结算 ${formatAmount(item.amount)} ${item.currency},不会重新调用上游。`;
}
return '确认后会将该记录重新放入结算队列,不会重新调用上游。';
}
function shortId(value: string) {
return value.length > 12 ? `${value.slice(0, 8)}${value.slice(-4)}` : value;
}