fix: cap image results and dedupe history inserts

This commit is contained in:
FengLee
2026-05-30 18:55:34 +08:00
parent 9461531ff3
commit fee527e1a3
4 changed files with 68 additions and 18 deletions

View File

@@ -51,6 +51,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Job remains queued | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-runner.ts` | `processNextGenerationJob()` invoked, stale job handling, DB locks/status, internal base URL. |
| Job remains running forever | `src/app/api/generation-jobs/[id]/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-job-estimates.ts` | Stale timeout updates, `updated_at`, worker exceptions swallowed into error field. |
| Image generation returns upstream error | `src/app/api/generate/image/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts`, `src/lib/server-api-config.ts` | Resolved custom/system API credentials, endpoint URL, New API normalization, timeout, stream/progress parser, and system-default stream timeout fallback. Gateway 502/503/504 errors are retried once; system default model failures should return the last actionable upstream timeout/gateway message instead of hiding everything behind the generic busy message. |
| One submitted image task shows extra images, or the same generated URL appears twice in history | `src/app/api/generate/image/route.ts`, `src/app/api/creation-history/route.ts`, `src/lib/generation-job-worker.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | First check production API logs for `count:1` with upstream messages such as `Got 2 images`, then query `generation_jobs.result.images` and `works` grouped by `user_id,result_url`. The image route should cap persisted response images to the requested count because some upstream/custom providers can return more images than `n`; creation-history POST should serialize same-user same-URL inserts before the existing lookup so concurrent completion/local persistence cannot insert duplicate `works` rows. |
| Video generation returns upstream error | `src/app/api/generate/video/route.ts`, `src/lib/custom-api-fetch.ts`, `src/lib/server-api-config.ts` | Reference image upload/compression, endpoint URL, response parser, persistence timeout. |
| Wrong image size, aspect ratio, or custom API says returned resolution is lower than requested | `src/lib/model-config.ts`, `src/app/api/generate/image/route.ts` | `resolveImageSize`, `resolveCustomApiImageSize`, New API/DALL-E size normalization, prompt aspect hint, and custom API result qualification. Exact or larger generated images pass normally; lower-resolution images with matching aspect ratio and at least 60% of the requested dimensions are accepted as degraded upstream output instead of failing the job, while wrong-ratio or much smaller images are still rejected. |
| Text-to-image or image-to-image says `请在提示词中写明画面比例` even after selecting a Yuanjie resolution such as `4K 竖版 (3:4)` | `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/lib/yuanjie-image-model-templates.ts` | Some Yuanjie image templates set `supportsAspectRatio: false` and encode orientation in `resolution`/`size` options. Generation validation must derive the ratio from the selected resolution label or dimensions instead of requiring a separate aspect-ratio control. Image-to-image should also default count to `1` rather than requiring prompt inference for `生成数量`. |

View File

@@ -46,6 +46,20 @@ await runTest('generation worker persists completed jobs back into creation hist
assert.match(source, /status: 'succeeded'/);
});
await runTest('image generation caps persisted images to the requested count', () => {
const source = read('src/app/api/generate/image/route.ts');
assert.match(source, /function capPersistedImagesToRequestedCount/);
assert.match(source, /imageResponsePayload\([^,\n]+,\s*n\)/);
assert.match(source, /persistQualifiedImageUrls\([^)]*requestedCount/s);
});
await runTest('creation history serializes same-user same-url inserts to prevent duplicate rows', () => {
const source = read('src/app/api/creation-history/route.ts');
assert.match(source, /pg_advisory_xact_lock/);
assert.match(source, /historyRecordDedupeLockKey/);
assert.match(source, /WHERE user_id = \$1 AND result_url = \$2/);
});
await runTest('create panels restore active jobs from the server after reload or auth change', () => {
for (const relativePath of [
'src/components/create/text-to-image.tsx',

View File

@@ -142,6 +142,10 @@ function dedupeRowsByResultUrl(rows: Record<string, unknown>[]) {
return deduped;
}
function historyRecordDedupeLockKey(userId: string, url: string): string {
return `${userId}:${url}`;
}
export async function GET(request: NextRequest) {
const userId = await getAuthenticatedUserId(request);
if (!userId) return NextResponse.json({ error: '请先登录' }, { status: 401 });
@@ -196,6 +200,9 @@ export async function POST(request: NextRequest) {
url = url && !url.startsWith('data:') ? url : `[reverse-prompt:${record.id || Date.now()}]`;
}
if (!url || url.startsWith('data:')) continue;
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [
historyRecordDedupeLockKey(userId, url),
]);
if (!thumbnailUrl && isVideoWorkType(workType)) {
try {
thumbnailUrl = await ensureLocalVideoThumbnail(url, 'thumbnails/works/videos', String(record.prompt || 'Video'));

View File

@@ -264,10 +264,12 @@ async function persistQualifiedImageUrls(
prefix: string,
targetSize: TargetImageSize | null,
context: string,
requestedCount = urls.length,
): Promise<PersistQualifiedImageUrlsResult> {
const images: QualifiedImageResult[] = [];
const rejected: string[] = [];
const failureKinds: GeneratedImagePersistenceFailureKind[] = [];
const cappedCount = Math.max(1, Math.floor(Number(requestedCount) || 1));
for (const url of urls) {
try {
@@ -292,21 +294,45 @@ async function persistQualifiedImageUrls(
}
images.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.bytes - a.bytes);
const selected = images.slice(0, cappedCount);
return {
images: images.map(image => image.url),
thumbnails: Object.fromEntries(images.map(image => [image.url, image.thumbnailUrl])),
dimensions: Object.fromEntries(images.map(image => [image.url, { width: image.width, height: image.height }])),
images: selected.map(image => image.url),
thumbnails: Object.fromEntries(selected.map(image => [image.url, image.thumbnailUrl])),
dimensions: Object.fromEntries(selected.map(image => [image.url, { width: image.width, height: image.height }])),
rejected,
failureKinds,
};
}
function imageResponsePayload(result: { images: string[]; thumbnails: Record<string, string>; dimensions: Record<string, { width: number; height: number }> }) {
function capPersistedImagesToRequestedCount<T extends { images: string[]; thumbnails: Record<string, string>; dimensions: Record<string, { width: number; height: number }> }>(
result: T,
requestedCount: number,
) {
const cappedCount = Math.max(1, Math.floor(Number(requestedCount) || 1));
if (result.images.length <= cappedCount) return result;
const images = result.images.slice(0, cappedCount);
return {
images: result.images,
thumbnails: result.thumbnails,
thumbnailUrls: result.images.map(url => result.thumbnails[url] || url),
dimensions: result.dimensions,
...result,
images,
thumbnails: Object.fromEntries(images.map(url => [url, result.thumbnails[url] || url])),
dimensions: Object.fromEntries(
images
.map(url => [url, result.dimensions[url]])
.filter((entry): entry is [string, { width: number; height: number }] => Boolean(entry[1])),
),
};
}
function imageResponsePayload(
result: { images: string[]; thumbnails: Record<string, string>; dimensions: Record<string, { width: number; height: number }> },
requestedCount = result.images.length,
) {
const capped = capPersistedImagesToRequestedCount(result, requestedCount);
return {
images: capped.images,
thumbnails: capped.thumbnails,
thumbnailUrls: capped.images.map(url => capped.thumbnails[url] || url),
dimensions: capped.dimensions,
};
}
@@ -404,6 +430,7 @@ async function requestQualifiedCustomImages(
'generated/images',
targetSize,
`Custom API Image attempt ${attempt}`,
targetCount - accepted.length,
);
accepted.push(...persisted.images);
Object.assign(thumbnails, persisted.thumbnails);
@@ -913,8 +940,8 @@ async function customApiImageToImage(
onProgress,
);
if (result1.success && result1.images) {
const persisted = await persistQualifiedImageUrls(result1.images, 'generated/images', targetSize, 'Custom API img2img strategy1');
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
const persisted = await persistQualifiedImageUrls(result1.images, 'generated/images', targetSize, 'Custom API img2img strategy1', count);
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted, count));
result1 = { ...result1, success: false, error: imageResultFailureError(targetSize, persisted) };
}
}
@@ -929,8 +956,8 @@ async function customApiImageToImage(
onProgress,
);
if (result2.success && result2.images) {
const persisted = await persistQualifiedImageUrls(result2.images, 'generated/images', targetSize, 'Custom API img2img strategy2');
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
const persisted = await persistQualifiedImageUrls(result2.images, 'generated/images', targetSize, 'Custom API img2img strategy2', count);
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted, count));
result2.success = false;
result2.error = imageResultFailureError(targetSize, persisted);
}
@@ -945,8 +972,8 @@ async function customApiImageToImage(
onProgress,
);
if (result3.success && result3.images) {
const persisted = await persistQualifiedImageUrls(result3.images, 'generated/images', targetSize, 'Custom API img2img strategy3');
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
const persisted = await persistQualifiedImageUrls(result3.images, 'generated/images', targetSize, 'Custom API img2img strategy3', count);
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted, count));
result3.success = false;
result3.error = imageResultFailureError(targetSize, persisted);
}
@@ -1113,11 +1140,12 @@ export async function POST(request: NextRequest) {
'generated/images',
targetSize,
'User API Manifest Image',
resolvedAutoParams.count,
);
if (persisted.images.length === 0) {
return NextResponse.json({ error: generatedImagePersistenceError(persisted) }, { status: 502 });
}
return NextResponse.json(imageResponsePayload(persisted));
return NextResponse.json(imageResponsePayload(persisted, resolvedAutoParams.count));
}
}
@@ -1230,7 +1258,7 @@ export async function POST(request: NextRequest) {
}
console.log('[Custom API Image] Persisted', customGenerationResult.images.length, '/', n, 'qualified images',
'| target:', customTargetSize ? formatTargetSize(customTargetSize) : 'none');
return NextResponse.json(imageResponsePayload(customGenerationResult));
return NextResponse.json(imageResponsePayload(customGenerationResult, n));
} catch (customError: unknown) {
const msg = customError instanceof Error ? customError.message : '自定义API请求异常';
console.error('[Custom API Image Exception]', msg);
@@ -1365,11 +1393,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '图片生成失败,请稍后重试' }, { status: 500 });
}
const persistedImages = await persistQualifiedImageUrls(images, 'generated/images', targetSize, 'SDK Image');
const persistedImages = await persistQualifiedImageUrls(images, 'generated/images', targetSize, 'SDK Image', resolvedAutoParams.count);
if (persistedImages.images.length === 0) {
return NextResponse.json({ error: imageResultFailureError(targetSize, persistedImages) }, { status: 502 });
}
return NextResponse.json(imageResponsePayload(persistedImages));
return NextResponse.json(imageResponsePayload(persistedImages, resolvedAutoParams.count));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : '图片生成失败';
console.error('[Image Generation Error]', message, error instanceof Error ? error.stack : '');