fix: harden yuanjie image result persistence

This commit is contained in:
FengLee
2026-05-20 19:13:56 +08:00
parent 705b54adfe
commit afd8585882
9 changed files with 216 additions and 13 deletions

View File

@@ -157,7 +157,7 @@ Secrets must be encrypted at rest with `src/lib/server-crypto.ts` and never retu
User-level intelligent API imports add a fourth data artifact tied to source 2: a per-key JSON Manifest in local storage. `src/app/api/user-api-keys/smart-import/route.ts` parses either a full `{ customProviders, profiles }` bundle or one provider Manifest, creates a separate `user_api_keys` row for every profile/model, and writes `user-api-manifests/<userId>/<keyId>.json`. `user_api_keys.manifest_path` is the only runtime pointer. The imported row keeps a human-readable provider/supplier name for editing and derives the visible request URL from the Manifest profile/provider; incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` is stored in the Manifest and returned to the client so the selected model can constrain or hide image aspect ratio, resolution, image format, and quality choices. Manifest poll endpoints should put task IDs in `query: { task_id: "{task_id}" }` when the upstream documents a query string, so the executor sends a real query parameter instead of embedding `?task_id=` into the pathname. Even for the same user, different request configuration files must remain separate because generation dispatch is selected-model based, not user based.
At generation time, `src/lib/server-api-config.ts` returns `manifestPath` for user custom keys and admin system API keys. `src/app/api/generate/image/route.ts` and `src/app/api/generate/video/route.ts` call `src/lib/user-api-manifest-executor.ts` first when that path exists. The executor handles JSON, multipart file fields, `{task_id}` polling, `*` JSON-path extraction, and media persistence handoff. Imported Manifest rows still need the user or admin to edit and save an API Key before they can generate.
At generation time, `src/lib/server-api-config.ts` returns `manifestPath` for user custom keys and admin system API keys. `src/app/api/generate/image/route.ts` and `src/app/api/generate/video/route.ts` call `src/lib/user-api-manifest-executor.ts` first when that path exists. The executor handles JSON, multipart file fields, `{task_id}` polling, `*` JSON-path extraction, and media persistence handoff. For image Manifest results, the route persists returned result URLs through `src/lib/media-storage.ts`; external result URL downloads use `src/lib/remote-fetch.ts` with browser-like headers and limited retry so provider/CDN-side 403, 429, 5xx, or timeout failures are distinguished from upstream generation failures. If the provider returned a result but MiaoJing cannot download or save the image media, the API should report a platform download/save failure instead of a resolution mismatch. Imported Manifest rows still need the user or admin to edit and save an API Key before they can generate.
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. The `智能配置 API` section is generic Manifest import only: each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. Provider-specific built-in templates such as 元界 AI are not exposed in this smart import UI; 元界 definitions remain in `src/lib/yuanjie-image-model-templates.ts` and `src/lib/yuanjie-video-model-templates.ts` for the system-default-model management path, where admins configure each model row's Key, pricing/member visibility/polling, `video_usage_modes`, and enablement before it is available to users. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed media type plus admin display name so the create page shows a single default model label, and image generation expands the selected row back into all allowed supplier candidates with the same display name. The upstream `model_name` can differ between suppliers and is only used as that supplier's request model. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If a system image supplier fails because a stream request idles until Cloudflare 524, `/api/generate/image` retries that candidate once with `stream:false`; 502/503/504 gateway responses are retried once by the shared transport. If every supplier still fails or returns no usable result, the route returns the last actionable upstream error when available, otherwise the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.

View File

@@ -89,6 +89,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
| Admin intelligent API import is missing or generated system models ignore Manifest | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts` | Admin imports must create one `system_api_configs` row per Manifest profile, write `system-api-manifests/<systemApiId>.json`, persist `manifest_path`, and resolve that path from the selected `systemApiId`. Imported rows still need API Key and pricing review before use. |
| 元界 AI 同步后出现大量接口/参数名模型或模型行反复显示 Key | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/yuanjie-template-installer.ts`, `src/components/admin/api-management-tab.tsx` | 元界不应再从 `/v1/skills``/v1/skills/guide` 猜模型,也不应在 `智能配置 API` 页面暴露内置模板安装/同步入口。检查安装路由是否使用内置图片/视频模板、是否只删除当前媒体类型的 `provider = '元界 AI'` 行、是否创建 inactive rows and per-model Manifest files, and whether admins configure Key/pricing/usage modes/enablement per model through the system-default-model management flow. The admin list should not show repeated imported key placeholders, and the create page should show only documented controls from the selected template capabilities. |
| 元界任务在元界后台成功但妙境报模型繁忙或接口路径不存在 | `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts`, `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Check whether the Manifest poll endpoint uses `path: "v1/media/status"` plus `query: { task_id: "{task_id}" }`. If the path is stored as `v1/media/status?task_id={task_id}`, the executor can encode the query string into the pathname and 元界 will return a not-found error even though the create request already produced a task. Also verify 元界 media templates use `finalPath: "is_final"`, `finalValues: [true]`, `statusPath: "state"`, `successValues: ["success"]`, and `failureValues: ["failed"]`; `status` / `status_group` are display fields only. |
| 元界后台显示已生成图片但妙境任务失败,日志出现下载 403、timeout 或保存失败 | `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, `src/lib/remote-fetch.ts`, `src/lib/user-api-manifest-executor.ts` | 这通常不是元界提交或轮询失败,而是 Manifest 结果 URL 返回后,妙境下载外部图片或保存原图/缩略图失败。先查 PM2 日志中 `[User API Manifest Image] Failed to persist generated image`,区分 `下载图片失败: 403``fetch failed``Persist generated image media timed out`、对象存储/缩略图错误。外部生成图 URL 应通过 `fetchPublicHttpUrlWithRetry` 发送浏览器式 `User-Agent`/`Accept` 并有限重试;`/api/generate/image` 应返回“上游已返回生成结果,但平台下载或保存结果图片失败”,不要再误包装为“上游返回图片分辨率不符合”或泛化成模型繁忙。 |
| 元界图生图提交后妙境报 `Manifest 未能从 ... 读取任务 ID` or generic `模型繁忙` while 元界 may have accepted the job | `src/lib/user-api-manifest-executor.ts`, `src/lib/yuanjie-image-model-templates.ts`, `src/lib/yuanjie-video-model-templates.ts` | The submit response can put the task identifier inside nested `result` objects. The executor must normalize `task_id`, `taskId`, `id`, and nested `data/result/output` objects before polling. Template `taskIdPath` should include `result.task_id`, `result.taskId`, and `result.id` before the broad `result` fallback. |
| 视频系统模型出现在错误入口或缺少参数选项 | `src/lib/server-api-config.ts`, `src/components/admin/api-management-tab.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx`, `src/lib/model-capabilities.ts` | Check `system_api_configs.video_usage_modes`. 文生视频 should only show rows including `text-to-video`; 图生视频 should only show rows including `image-to-video`. Selected system video models should read Manifest `capabilities` for aspect ratio, duration, and resolution controls. |
| 管理后台刷新后跳回仪表盘 | `src/modules/console/pages/console-dashboard-page.tsx` | The active view should be restored from `sessionStorage` on refresh and removed on logout. If it jumps to dashboard after a plain refresh, inspect the session key `miaojing_console_active_view` and whether the view is still allowed by the current membership/admin config. |

View File

@@ -17,6 +17,7 @@ Use this document before changing non-generic provider/platform behavior. If a u
- Built-in 元界 templates are not generic OpenAI-compatible models. Their manifests may map UI fields to provider-specific params such as `size`, `aspect_ratio`, `aspectRatio`, `imageSize`, `resolution`, `quality`, `images`, or task polling fields.
- Some image models expose orientation through a `size`/`resolution` value instead of a separate aspect-ratio field. In those cases the create panel must derive the ratio from the selected option label or pixel dimensions, rather than requiring the user to write the ratio in the prompt.
- 元界 media submit responses may return the task identifier under nested result objects such as `result.task_id`, `result.taskId`, or `result.id`. The Manifest executor must extract task IDs from those nested objects before polling `v1/media/status`.
- If 元界后台 shows a successful image but MiaoJing marks the job failed, treat it as a result-media download/persistence issue before changing submit/poll config. Check `src/app/api/generate/image/route.ts`, `src/lib/media-storage.ts`, and `src/lib/remote-fetch.ts` for 403, timeout, object-storage, or thumbnail errors. Result URL fetches should use browser-like headers plus limited retry, and Manifest result persistence failures should be reported as platform download/save failures, not as image-resolution mismatch.
- Do not add `自动` back to controls where the user explicitly asked for explicit manual choices. Image count should default to `1` when automatic inference is not part of the requested workflow.
- Admin default models must use `system_api_configs.name` as the frontend display name, while `model_name` remains the upstream request model.
- When 元界 is used as a system default model, credit deduction must still follow the selected `system_api_configs` row's pricing through the generation job backend. New-job balance preflight should include same-user queued/running system-default jobs. Image and video create UI should display only the completed job's returned `creditsCost` and refresh the profile balance from `creditsBalance`, not a separate predicted button cost. Failed jobs must not write consume transactions.

View File

@@ -73,7 +73,7 @@ Use this document to jump directly to code before broad searching.
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default image/video generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. Failed generation jobs do not enter the charge path. |
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. User custom APIs remain single-config and do not use this polling fallback. |
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. If a Manifest provider such as 元界 returns result URLs but MiaoJing cannot download or save them, the route reports a platform download/save failure instead of a resolution mismatch. User custom APIs remain single-config and do not use this polling fallback. |
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. |
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
| Server API resolution | `src/lib/server-api-config.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). The upstream `model_name` remains the per-provider request model only. |
@@ -140,7 +140,7 @@ Use this document to jump directly to code before broad searching.
| Rainyun ROS object storage preparation | `scripts/rainyun-ros-prepare.mjs` | Uses the Rainyun control-plane API `POST /product/ros/bucket` to create a bucket from `RAINYUN_ROS_BUCKET_NAME` and `RAINYUN_ROS_INSTANCE_ID`, then writes a private `.env.rainyun-object.generated` file containing standard `OBJECT_STORAGE_*` variables. Do not use this control-plane API for runtime media reads/writes; runtime storage remains S3-compatible through `src/lib/local-storage.ts`. |
| Local/object file API | `src/app/api/local-storage/[...path]/route.ts`, `src/proxy.ts` | Serves storage objects by key without changing existing frontend URLs. Thumbnail keys under `thumbnails/...` are read from local disk and use long immutable browser cache headers because the filename contains the thumbnail profile; `src/proxy.ts` must preserve those cache headers instead of applying global `/api` no-store. Originals redirect to short-lived object-storage signed URLs when configured. |
| Download proxy | `src/app/api/download/route.ts` | Supports remote URL, same-origin URL, and `/api/local-storage/*`. |
| Remote fetch guard | `src/lib/remote-fetch.ts` | Use for server-side external fetches. |
| Remote fetch guard | `src/lib/remote-fetch.ts` | Use for server-side external fetches. It blocks private/local network targets, sends browser-like public-resource headers by default, and exposes `fetchPublicHttpUrlWithRetry` for generated image/result URL downloads that may transiently return 403, 429, 5xx, or timeout. |
## Database And Persistence

View File

@@ -18,6 +18,7 @@
"test:custom-image-fallback": "tsx ./scripts/test-custom-image-fallback.mjs",
"test:generation-credit-policy": "tsx ./scripts/test-generation-credit-policy.mjs",
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
"test:yuanjie-image2-persistence": "tsx ./scripts/test-yuanjie-image2-persistence.mjs",
"test:ops-hardening": "node --no-warnings ./scripts/test-ops-hardening.mjs",
"pm2:restart": "pm2 startOrReload ecosystem.config.cjs --update-env",
"pm2:save": "pm2 save",

View File

@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import dns from 'node:dns/promises';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = path.resolve(import.meta.dirname, '..');
async function runTest(name, fn) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
console.error(`FAIL ${name}`);
console.error(error);
process.exitCode = 1;
}
}
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
}
await runTest('元界 GPT Image 2 Manifest keeps task_id as poll query parameter', () => {
const source = read('src/lib/yuanjie-image-model-templates.ts');
const pollPath = source.indexOf("path: 'v1/media/status'");
const pollQuery = source.indexOf("task_id: '{task_id}'");
const legacyPath = source.indexOf("path: 'v1/media/status?task_id={task_id}'");
assert.ok(pollPath > -1, '元界轮询 path 必须是纯路径 v1/media/status');
assert.ok(pollQuery > pollPath, 'task_id 必须通过 poll.query 传入');
assert.equal(legacyPath, -1, '不能把 task_id 拼进 path避免被编码成错误路径');
});
await runTest('public image fetch sends browser-like headers and retries transient image download failures', async () => {
const originalLookup = dns.lookup;
const originalFetch = globalThis.fetch;
const calls = [];
dns.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
globalThis.fetch = async (url, init = {}) => {
calls.push({ url: String(url), headers: new Headers(init.headers) });
if (calls.length === 1) return new Response('forbidden once', { status: 403 });
return new Response('ok', { status: 200, headers: { 'content-type': 'image/png' } });
};
try {
const { fetchPublicHttpUrlWithRetry } = await import(`../src/lib/remote-fetch.ts?test=${Date.now()}`);
const response = await fetchPublicHttpUrlWithRetry('https://example.com/generated.png', {}, {
attempts: 2,
retryDelayMs: 0,
timeoutMs: 1_000,
});
assert.equal(response.status, 200);
assert.equal(calls.length, 2);
assert.match(calls[0].headers.get('accept') || '', /image\/\*/);
assert.match(calls[0].headers.get('user-agent') || '', /Mozilla\/5\.0/);
} finally {
dns.lookup = originalLookup;
globalThis.fetch = originalFetch;
}
});
await runTest('manifest result persistence failures are reported as download or storage failures, not low resolution', () => {
const source = read('src/app/api/generate/image/route.ts');
const manifestBlockStart = source.indexOf("'User API Manifest Image'");
const manifestFailure = source.indexOf('generatedImagePersistenceError(persisted)', manifestBlockStart);
const oldLowResolution = source.indexOf('lowResolutionError(targetSize, persisted.rejected)', manifestBlockStart);
assert.ok(manifestBlockStart > -1, '应保留 Manifest 图片持久化上下文');
assert.ok(manifestFailure > manifestBlockStart, 'Manifest 结果持久化失败应走专门错误文案');
assert.equal(oldLowResolution, -1, 'Manifest 结果下载/保存失败不能再包装成分辨率不符合');
assert.match(source, /上游已返回生成结果,但平台下载或保存结果图片失败/);
});
await runTest('media storage uses retrying public fetch for external generated image URLs', () => {
const source = read('src/lib/media-storage.ts');
assert.match(source, /import \{ fetchPublicHttpUrl,\s*fetchPublicHttpUrlWithRetry \} from '@\/lib\/remote-fetch';/);
assert.match(source, /fetchPublicHttpUrlWithRetry\(url,\s*\{\},\s*\{\s*attempts:\s*3,/s);
});
if (process.exitCode) process.exit(process.exitCode);

View File

@@ -193,20 +193,45 @@ async function persistImageWithMetadata(url: string, prefix: string): Promise<Pe
);
}
type GeneratedImagePersistenceFailureKind = 'download' | 'storage' | 'invalid_image';
type PersistQualifiedImageUrlsResult = {
images: string[];
thumbnails: Record<string, string>;
rejected: string[];
failureKinds: GeneratedImagePersistenceFailureKind[];
};
function classifyGeneratedImagePersistenceError(error: unknown): GeneratedImagePersistenceFailureKind {
if (error instanceof DOMException && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
return 'download';
}
const message = error instanceof Error ? error.message : String(error || '');
if (/下载图片失败|fetch failed|Too many redirects|Invalid URL|Only HTTP|Private or local network|timeout|timed out|aborted|ECONNRESET|ETIMEDOUT/i.test(message)) {
return 'download';
}
if (/无法读取生成图片尺寸|unsupported image|Input buffer/i.test(message)) {
return 'invalid_image';
}
return 'storage';
}
async function persistQualifiedImageUrls(
urls: string[],
prefix: string,
targetSize: TargetImageSize | null,
context: string,
): Promise<{ images: string[]; thumbnails: Record<string, string>; rejected: string[] }> {
): Promise<PersistQualifiedImageUrlsResult> {
const images: QualifiedImageResult[] = [];
const rejected: string[] = [];
const failureKinds: GeneratedImagePersistenceFailureKind[] = [];
for (const url of urls) {
try {
const persisted = await persistImageWithMetadata(url, prefix);
if (!persisted) {
rejected.push('无法读取生成图片');
failureKinds.push('download');
continue;
}
if (targetSize && !imageMeetsTargetSize(persisted.width, persisted.height, targetSize)) {
@@ -219,6 +244,7 @@ async function persistQualifiedImageUrls(
const message = err instanceof Error ? err.message : '图片处理失败';
console.warn(`[${context}] Failed to persist generated image:`, message);
rejected.push(message);
failureKinds.push(classifyGeneratedImagePersistenceError(err));
}
}
@@ -227,6 +253,7 @@ async function persistQualifiedImageUrls(
images: images.map(image => image.url),
thumbnails: Object.fromEntries(images.map(image => [image.url, image.thumbnailUrl])),
rejected,
failureKinds,
};
}
@@ -277,10 +304,11 @@ async function requestQualifiedCustomImages(
targetSize: TargetImageSize | null,
onProgress?: (progress: Record<string, unknown>) => void | Promise<void>,
options: { autoRetryWithoutStream?: boolean } = {},
): Promise<{ images: string[]; thumbnails: Record<string, string>; rejected: string[]; upstreamError?: { status: number; text: string } }> {
): Promise<PersistQualifiedImageUrlsResult & { upstreamError?: { status: number; text: string } }> {
const accepted: string[] = [];
const thumbnails: Record<string, string> = {};
const rejected: string[] = [];
const failureKinds: GeneratedImagePersistenceFailureKind[] = [];
const maxAttempts = 1;
for (let attempt = 1; attempt <= maxAttempts && accepted.length < targetCount; attempt += 1) {
@@ -314,6 +342,7 @@ async function requestQualifiedCustomImages(
images: accepted,
thumbnails,
rejected,
failureKinds,
upstreamError: { status: response.response.status, text: response.errorText },
};
}
@@ -332,6 +361,7 @@ async function requestQualifiedCustomImages(
accepted.push(...persisted.images);
Object.assign(thumbnails, persisted.thumbnails);
rejected.push(...persisted.rejected);
failureKinds.push(...persisted.failureKinds);
}
const images = accepted.slice(0, targetCount);
@@ -339,6 +369,7 @@ async function requestQualifiedCustomImages(
images,
thumbnails: Object.fromEntries(images.map(url => [url, thumbnails[url] || url])),
rejected,
failureKinds,
};
}
@@ -348,6 +379,25 @@ function lowResolutionError(targetSize: TargetImageSize | null, rejected: string
return `上游返回图片分辨率不符合${target}${actual}`;
}
function hasGeneratedImagePersistenceFailure(result: { failureKinds?: GeneratedImagePersistenceFailureKind[] }): boolean {
return !!result.failureKinds?.some(kind => kind === 'download' || kind === 'storage' || kind === 'invalid_image');
}
function generatedImagePersistenceError(result: { rejected: string[] }): string {
const detail = result.rejected.length > 0 ? `,失败原因:${result.rejected.join('')}` : '';
return `上游已返回生成结果,但平台下载或保存结果图片失败,请稍后重试${detail}`;
}
function imageResultFailureError(
targetSize: TargetImageSize | null,
result: { rejected: string[]; failureKinds?: GeneratedImagePersistenceFailureKind[] },
): string {
if (hasGeneratedImagePersistenceFailure(result)) {
return generatedImagePersistenceError(result);
}
return lowResolutionError(targetSize, result.rejected);
}
/** Helper: wrap a promise with a timeout that rejects with a descriptive message */
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
@@ -805,7 +855,7 @@ async function customApiImageToImage(
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));
result1 = { ...result1, success: false, error: lowResolutionError(targetSize, persisted.rejected) };
result1 = { ...result1, success: false, error: imageResultFailureError(targetSize, persisted) };
}
}
@@ -822,7 +872,7 @@ async function customApiImageToImage(
const persisted = await persistQualifiedImageUrls(result2.images, 'generated/images', targetSize, 'Custom API img2img strategy2');
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
result2.success = false;
result2.error = lowResolutionError(targetSize, persisted.rejected);
result2.error = imageResultFailureError(targetSize, persisted);
}
// --- Strategy 3: /v1/images/generations with init_image (Reference code / SD style) ---
@@ -838,7 +888,7 @@ async function customApiImageToImage(
const persisted = await persistQualifiedImageUrls(result3.images, 'generated/images', targetSize, 'Custom API img2img strategy3');
if (persisted.images.length > 0) return NextResponse.json(imageResponsePayload(persisted));
result3.success = false;
result3.error = lowResolutionError(targetSize, persisted.rejected);
result3.error = imageResultFailureError(targetSize, persisted);
}
const upstreamError = result1?.error || result2.error || result3.error;
@@ -992,6 +1042,9 @@ export async function POST(request: NextRequest) {
onProgress: handleUpstreamProgress,
});
if (manifestResult) {
if (manifestResult.images.length === 0) {
return NextResponse.json({ error: '上游任务已完成,但响应中无图片数据' }, { status: 502 });
}
const persisted = await persistQualifiedImageUrls(
manifestResult.images,
'generated/images',
@@ -999,7 +1052,7 @@ export async function POST(request: NextRequest) {
'User API Manifest Image',
);
if (persisted.images.length === 0) {
return NextResponse.json({ error: lowResolutionError(targetSize, persisted.rejected) }, { status: 502 });
return NextResponse.json({ error: generatedImagePersistenceError(persisted) }, { status: 502 });
}
return NextResponse.json(imageResponsePayload(persisted));
}
@@ -1110,7 +1163,7 @@ export async function POST(request: NextRequest) {
}
if (customGenerationResult.images.length === 0) {
return NextResponse.json({ error: lowResolutionError(customTargetSize, customGenerationResult.rejected) }, { status: 502 });
return NextResponse.json({ error: imageResultFailureError(customTargetSize, customGenerationResult) }, { status: 502 });
}
console.log('[Custom API Image] Persisted', customGenerationResult.images.length, '/', n, 'qualified images',
'| target:', customTargetSize ? formatTargetSize(customTargetSize) : 'none');
@@ -1248,7 +1301,7 @@ export async function POST(request: NextRequest) {
const persistedImages = await persistQualifiedImageUrls(images, 'generated/images', targetSize, 'SDK Image');
if (persistedImages.images.length === 0) {
return NextResponse.json({ error: lowResolutionError(targetSize, persistedImages.rejected) }, { status: 502 });
return NextResponse.json({ error: imageResultFailureError(targetSize, persistedImages) }, { status: 502 });
}
return NextResponse.json(imageResponsePayload(persistedImages));
} catch (error: unknown) {

View File

@@ -2,7 +2,7 @@ import crypto from 'crypto';
import path from 'path';
import sharp from 'sharp';
import { localStorage } from '@/lib/local-storage';
import { fetchPublicHttpUrl } from '@/lib/remote-fetch';
import { fetchPublicHttpUrl, fetchPublicHttpUrlWithRetry } from '@/lib/remote-fetch';
const THUMBNAIL_MAX_EDGE = Number(process.env.IMAGE_THUMBNAIL_MAX_EDGE || 1280);
const THUMBNAIL_WEBP_QUALITY = Number(process.env.IMAGE_THUMBNAIL_WEBP_QUALITY || 86);
@@ -76,7 +76,11 @@ export async function readImageBufferFromUrl(url: string): Promise<ImageBufferSo
}
if (!url.startsWith('http')) return null;
const response = await fetchPublicHttpUrl(url, { signal: AbortSignal.timeout(30_000) });
const response = await fetchPublicHttpUrlWithRetry(url, {}, {
attempts: 3,
retryDelayMs: 500,
timeoutMs: 30_000,
});
if (!response.ok) throw new Error(`下载图片失败: ${response.status}`);
const mimeType = response.headers.get('content-type')?.split(';')[0] || 'image/png';
return {

View File

@@ -2,6 +2,16 @@ import dns from 'dns/promises';
import net from 'net';
const MAX_REDIRECTS = 3;
const DEFAULT_RETRY_STATUSES = new Set([403, 408, 429, 500, 502, 503, 504]);
const PUBLIC_RESOURCE_ACCEPT = 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8';
const PUBLIC_RESOURCE_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36';
export type FetchPublicHttpUrlRetryOptions = {
attempts?: number;
retryDelayMs?: number;
retryStatuses?: number[];
timeoutMs?: number;
};
function isPrivateIpv4(ip: string): boolean {
const parts = ip.split('.').map(part => Number(part));
@@ -45,6 +55,26 @@ async function assertPublicHttpUrl(url: URL): Promise<void> {
}
}
function buildPublicResourceHeaders(headersInit?: HeadersInit): Headers {
const headers = new Headers(headersInit);
if (!headers.has('accept')) headers.set('Accept', PUBLIC_RESOURCE_ACCEPT);
if (!headers.has('user-agent')) headers.set('User-Agent', PUBLIC_RESOURCE_USER_AGENT);
return headers;
}
function isRetryableFetchError(error: unknown): boolean {
if (error instanceof DOMException) {
return error.name === 'AbortError' || error.name === 'TimeoutError';
}
const message = error instanceof Error ? error.message : String(error || '');
return /fetch failed|network|timeout|aborted|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message);
}
function delay(ms: number): Promise<void> {
if (ms <= 0) return Promise.resolve();
return new Promise(resolve => setTimeout(resolve, ms));
}
export async function fetchPublicHttpUrl(input: string, init: RequestInit = {}, redirectCount = 0): Promise<Response> {
if (redirectCount > MAX_REDIRECTS) throw new Error('Too many redirects');
@@ -58,6 +88,7 @@ export async function fetchPublicHttpUrl(input: string, init: RequestInit = {},
await assertPublicHttpUrl(url);
const response = await fetch(url, {
...init,
headers: buildPublicResourceHeaders(init.headers),
redirect: 'manual',
});
@@ -70,3 +101,32 @@ export async function fetchPublicHttpUrl(input: string, init: RequestInit = {},
return response;
}
export async function fetchPublicHttpUrlWithRetry(
input: string,
init: RequestInit = {},
options: FetchPublicHttpUrlRetryOptions = {},
): Promise<Response> {
const attempts = Math.max(1, Math.floor(options.attempts || 3));
const retryDelayMs = Math.max(0, Math.floor(options.retryDelayMs ?? 500));
const retryStatuses = new Set(options.retryStatuses || Array.from(DEFAULT_RETRY_STATUSES));
let lastError: unknown = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const attemptInit: RequestInit = options.timeoutMs && !init.signal
? { ...init, signal: AbortSignal.timeout(options.timeoutMs) }
: init;
const response = await fetchPublicHttpUrl(input, attemptInit);
if (!retryStatuses.has(response.status) || attempt === attempts) return response;
} catch (error) {
lastError = error;
if (attempt === attempts || !isRetryableFetchError(error)) throw error;
}
await delay(retryDelayMs);
}
if (lastError instanceof Error) throw lastError;
throw new Error('Failed to fetch public HTTP URL');
}