2026-06-03 11:13:19 +08:00

664 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service";
export const runtime = "nodejs";
export const maxDuration = 300;
type StitchReferenceInput = {
id: string;
label?: string | null;
imageUrl: string;
previewUrl?: string | null;
width?: number | null;
height?: number | null;
candidateId?: string | null;
sortOrder?: number | null;
passthrough?: boolean | null;
assignedProductIndex?: number | null;
};
type StitchSourceSegment = {
card: StitchReferenceInput;
top: number;
height: number;
sourceWidth?: number;
sourceHeight?: number;
};
const MAX_REFERENCE_COUNT = 120;
const FETCH_TIMEOUT_MS = 20_000;
const FETCH_MAX_ATTEMPTS = 3;
const FETCH_RETRY_BASE_DELAY_MS = 350;
const FETCH_CONCURRENCY = 12;
const SLICE_UPLOAD_CONCURRENCY = 4;
const PREVIEW_MAX_WIDTH = 720;
const PREVIEW_MIN_WIDTH = 96;
const PREVIEW_MAX_HEIGHT = 32000;
const PREVIEW_MAX_PIXELS = 36_000_000;
const SEGMENT_MATCH_TOLERANCE = 2;
const logger = createLogger("detail-stitch");
class ImageFetchError extends Error {
retryable: boolean;
constructor(message: string, retryable = false) {
super(message);
this.name = "ImageFetchError";
this.retryable = retryable;
}
}
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error || "");
}
function isTimeoutLikeError(error: unknown) {
if (!(error instanceof Error)) return false;
return (
error.name === "TimeoutError" ||
error.name === "AbortError" ||
/timeout|aborted|operation was aborted/i.test(error.message)
);
}
function isRetryableFetchError(error: unknown) {
if (error instanceof ImageFetchError) {
return error.retryable;
}
return isTimeoutLikeError(error);
}
function toPublicErrorMessage(error: unknown) {
const message = getErrorMessage(error);
if (isTimeoutLikeError(error) || /timeout|aborted|operation was aborted/i.test(message)) {
return "切割图片下载超时,通常是源图过大、源站响应慢或网络抖动导致。请稍后重试,或减少本次切割图片数量后再应用。";
}
return message || "拼合切割失败";
}
async function runBoundedParallel<T, R>(
items: T[],
workerCount: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(items.length);
const maxWorkers = Math.max(1, Math.min(workerCount, items.length));
let cursor = 0;
await Promise.all(
Array.from({ length: maxWorkers }, async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await worker(items[index]!, index);
}
}),
);
return results;
}
function getRequestOrigin(request: NextRequest): string {
const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim();
const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim();
const host = forwardedHost || (request.headers.get("host") || "").split(",")[0]?.trim();
if (host) {
const requestUrl = new URL(request.url);
const isLocalHost =
host.startsWith("localhost") ||
host.startsWith("127.0.0.1") ||
host.startsWith("[::1]");
const proto = forwardedProto || (isLocalHost ? requestUrl.protocol.replace(/:$/, "") : "https");
return `${proto}://${host}`;
}
return new URL(request.url).origin;
}
function resolveHttpUrl(rawUrl: unknown, origin: string): string {
const value = String(rawUrl || "").trim();
if (!value) return "";
try {
const parsed = new URL(value, origin);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
return parsed.toString();
} catch {
return "";
}
}
function sanitizeReferenceInput(input: unknown, origin: string): StitchReferenceInput[] {
if (!Array.isArray(input)) return [];
return input
.map((item, index): StitchReferenceInput | null => {
const raw = (item || {}) as Record<string, unknown>;
const imageUrl = resolveHttpUrl(raw.imageUrl, origin);
if (!imageUrl) return null;
const previewUrl = resolveHttpUrl(raw.previewUrl, origin);
const width = Number(raw.width);
const height = Number(raw.height);
return {
id: String(raw.id || `reference-${index + 1}`).trim() || `reference-${index + 1}`,
label: String(raw.label || "").trim() || `参考图 ${index + 1}`,
imageUrl,
previewUrl: previewUrl || null,
width: Number.isFinite(width) && width > 0 ? Math.round(width) : null,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : null,
candidateId: String(raw.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(raw.sortOrder)) ? Number(raw.sortOrder) : index,
passthrough: raw.passthrough === true,
assignedProductIndex: Number.isFinite(Number(raw.assignedProductIndex))
? Number(raw.assignedProductIndex)
: null,
} satisfies StitchReferenceInput;
})
.filter((item): item is StitchReferenceInput => Boolean(item))
.slice(0, MAX_REFERENCE_COUNT);
}
function buildFetchHeaders(url: string, requestOrigin: string, cookie: string): HeadersInit {
const headers: Record<string, string> = {
Accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
"User-Agent": "Mozilla/5.0 (compatible; BuyiDetailStitch/1.0)",
Referer: requestOrigin,
};
try {
if (new URL(url).origin === requestOrigin && cookie) {
headers.cookie = cookie;
}
} catch {
// URL has already been normalized.
}
return headers;
}
function buildReferenceFetchUrl(url: string): string {
return buildImageProxyUrl(url, {
width: PREVIEW_MAX_WIDTH,
quality: 82,
format: "jpeg",
});
}
async function fetchImageBuffer(url: string, requestOrigin: string, cookie: string): Promise<Buffer> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= FETCH_MAX_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(url, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: buildFetchHeaders(url, requestOrigin, cookie),
});
if (!response.ok) {
throw new ImageFetchError(
`图片下载失败:HTTP ${response.status}`,
response.status === 408 || response.status === 429 || response.status >= 500,
);
}
const contentType = response.headers.get("content-type") || "";
const normalizedContentType = contentType.toLowerCase();
if (
normalizedContentType &&
!normalizedContentType.startsWith("image/") &&
(normalizedContentType.startsWith("text/") ||
normalizedContentType.includes("json") ||
normalizedContentType.includes("xml"))
) {
throw new ImageFetchError("返回内容不是图片");
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new ImageFetchError("图片内容为空");
}
return buffer;
} catch (error) {
lastError = error;
if (attempt >= FETCH_MAX_ATTEMPTS || !isRetryableFetchError(error)) {
break;
}
logger.warn("image_fetch_retry", {
attempt,
maxAttempts: FETCH_MAX_ATTEMPTS,
timeoutMs: FETCH_TIMEOUT_MS,
reason: getErrorMessage(error),
});
await sleep(FETCH_RETRY_BASE_DELAY_MS * attempt);
}
}
throw new ImageFetchError(toPublicErrorMessage(lastError), false);
}
function resolvePreviewWidth(items: Array<{ width: number; height: number }>) {
const maxSourceWidth = Math.max(...items.map((item) => item.width), 1);
const baseWidth = Math.max(1, Math.min(PREVIEW_MAX_WIDTH, maxSourceWidth));
const heightRatioSum = items.reduce(
(total, item) => total + item.height / Math.max(item.width, 1),
0,
);
const heightLimitedWidth = Math.floor(PREVIEW_MAX_HEIGHT / Math.max(heightRatioSum, 1));
const pixelLimitedWidth = Math.floor(
Math.sqrt(PREVIEW_MAX_PIXELS / Math.max(heightRatioSum, 1)),
);
const constrainedWidth = Math.min(
baseWidth,
Math.max(1, heightLimitedWidth),
Math.max(1, pixelLimitedWidth),
);
return constrainedWidth >= PREVIEW_MIN_WIDTH
? constrainedWidth
: Math.max(1, constrainedWidth);
}
function normalizeCutLines(cutLines: unknown, height: number) {
if (!Array.isArray(cutLines)) return [];
return Array.from(
new Set(
cutLines
.map((value) => Math.round(Number(value)))
.filter((value) => Number.isFinite(value) && value > 0 && value < height)
.sort((left, right) => left - right),
),
);
}
function findMatchingSourceSegment(
segment: { top: number; height: number },
sourceSegments: StitchSourceSegment[],
) {
return (
sourceSegments.find(
(source) =>
Math.abs(source.top - segment.top) <= SEGMENT_MATCH_TOLERANCE &&
Math.abs(source.height - segment.height) <= SEGMENT_MATCH_TOLERANCE,
) || null
);
}
async function prepareStitch(params: {
references: StitchReferenceInput[];
requestOrigin: string;
cookie: string;
}) {
let sharp: ((input: Buffer, options?: import("sharp").SharpOptions) => import("sharp").Sharp) | null = null;
const fetched = await runBoundedParallel(
params.references,
FETCH_CONCURRENCY,
async (card, index) => {
try {
if (card.width && card.height) {
return {
ok: true as const,
card,
index,
width: card.width,
height: card.height,
};
}
const buffer = await fetchImageBuffer(
buildReferenceFetchUrl(card.imageUrl),
params.requestOrigin,
params.cookie,
);
sharp = sharp || (await import("sharp")).default;
const metadata = await sharp(buffer, { failOn: "none" }).rotate().metadata();
const width = metadata.width || 0;
const height = metadata.height || 0;
if (!width || !height) {
throw new Error("无法读取图片尺寸");
}
return {
ok: true as const,
card: {
...card,
width,
height,
},
index,
width,
height,
};
} catch (error) {
return {
ok: false as const,
card,
index,
error: error instanceof Error ? error.message : "图片加载失败",
};
}
},
);
const loaded = fetched.filter((item): item is Extract<(typeof fetched)[number], { ok: true }> => item.ok);
const failed = fetched.filter((item): item is Extract<(typeof fetched)[number], { ok: false }> => !item.ok);
if (loaded.length === 0) {
throw new Error("当前参考图加载失败,请检查图片链接");
}
const previewWidth = resolvePreviewWidth(loaded);
let top = 0;
const sourceSegments: StitchSourceSegment[] = [];
const defaultCutLines: number[] = [];
loaded.forEach((item, index) => {
const height = Math.max(1, Math.round((previewWidth / Math.max(item.width, 1)) * item.height));
const currentTop = top;
top += height;
if (index < loaded.length - 1) {
defaultCutLines.push(top);
}
sourceSegments.push({
card: item.card,
top: currentTop,
height,
sourceWidth: item.width,
sourceHeight: item.height,
});
});
const previewHeight = Math.max(1, top);
return {
previewUrl: "",
width: previewWidth,
height: previewHeight,
defaultCutLines,
sourceSegments,
failed: failed.map((item) => ({
id: item.card.id,
label: item.card.label || `参考图 ${item.index + 1}`,
imageUrl: item.card.imageUrl,
error: item.error,
})),
};
}
async function applyStitch(params: {
previewUrl: string;
width: number;
height: number;
cutLines: number[];
sourceSegments: StitchSourceSegment[];
passthroughSegmentIndexes: number[];
requestOrigin: string;
cookie: string;
}) {
const sharp = (await import("sharp")).default;
const points = [0, ...normalizeCutLines(params.cutLines, params.height), params.height];
const segments = Array.from({ length: Math.max(points.length - 1, 0) }, (_, index) => ({
index,
top: points[index]!,
height: Math.max(points[index + 1]! - points[index]!, 1),
}));
const passthroughSet = new Set(params.passthroughSegmentIndexes);
const segmentMatches = segments.map((segment) => findMatchingSourceSegment(segment, params.sourceSegments));
const preparedSourceCache = new Map<string, Promise<Buffer>>();
const getPreparedSourceBuffer = (source: StitchSourceSegment) => {
const sourceUrl = source.card.imageUrl;
const key = `${sourceUrl}|${params.width}|${source.height}`;
const cached = preparedSourceCache.get(key);
if (cached) return cached;
const prepared = (async () => {
const buffer = await fetchImageBuffer(
buildReferenceFetchUrl(sourceUrl),
params.requestOrigin,
params.cookie,
);
return sharp(buffer, { failOn: "none" })
.rotate()
.resize(params.width, source.height, {
fit: "contain",
position: "centre",
background: { r: 255, g: 255, b: 255, alpha: 1 },
withoutEnlargement: false,
})
.jpeg({
quality: 88,
mozjpeg: true,
progressive: true,
})
.toBuffer();
})();
preparedSourceCache.set(key, prepared);
return prepared;
};
const cards = await runBoundedParallel(segments, SLICE_UPLOAD_CONCURRENCY, async (segment) => {
const matchedSource = segmentMatches[segment.index] || null;
if (matchedSource) {
return {
...matchedSource.card,
id: `detail-stitch-slice-${Date.now()}-${segment.index + 1}`,
label: `拼合切片 ${segment.index + 1}`,
imageUrl: matchedSource.card.imageUrl,
sortOrder: segment.index,
passthrough: passthroughSet.has(segment.index),
};
}
const segmentTop = Math.max(0, Math.round(segment.top));
const segmentHeight = Math.max(
1,
Math.min(params.height - segmentTop, Math.round(segment.height)),
);
const segmentBottom = segmentTop + segmentHeight;
const overlappingSources = params.sourceSegments.filter((source) => {
const sourceTop = Math.round(source.top);
const sourceBottom = sourceTop + Math.round(source.height);
return sourceTop < segmentBottom && sourceBottom > segmentTop;
});
if (overlappingSources.length === 0) {
throw new Error("当前切片没有匹配到原始参考图");
}
const compositeItems = await Promise.all(
overlappingSources.map(async (source) => {
const sourceTop = Math.round(source.top);
const sourceHeight = Math.max(1, Math.round(source.height));
const overlapTop = Math.max(segmentTop, sourceTop);
const overlapBottom = Math.min(segmentBottom, sourceTop + sourceHeight);
const cropTop = Math.max(0, Math.min(sourceHeight - 1, overlapTop - sourceTop));
const cropHeight = Math.max(
1,
Math.min(sourceHeight - cropTop, Math.round(overlapBottom - overlapTop)),
);
const sourceBuffer = await getPreparedSourceBuffer(source);
const input = await sharp(sourceBuffer, { failOn: "none" })
.extract({
left: 0,
top: cropTop,
width: params.width,
height: cropHeight,
})
.jpeg({
quality: 88,
mozjpeg: true,
progressive: true,
})
.toBuffer();
return {
input,
top: Math.max(0, Math.round(overlapTop - segmentTop)),
left: 0,
};
}),
);
const sliceBuffer = await sharp({
create: {
width: params.width,
height: segmentHeight,
channels: 3,
background: "#ffffff",
},
})
.composite(compositeItems)
.jpeg({
quality: 88,
mozjpeg: true,
progressive: true,
})
.toBuffer();
const upload = await uploadFile(
`detail-reference-slice-${Date.now()}-${segment.index + 1}.jpg`,
bufferToArrayBuffer(sliceBuffer),
);
const sourceSegment =
params.sourceSegments.find(
(item) => segment.top >= item.top && segment.top < item.top + item.height,
) || params.sourceSegments[0] || null;
return {
id: `detail-stitch-slice-${Date.now()}-${segment.index + 1}`,
label: `拼合切片 ${segment.index + 1}`,
imageUrl: upload.url,
previewUrl: upload.url,
width: params.width,
height: segmentHeight,
candidateId: sourceSegment?.card.candidateId || null,
sortOrder: segment.index,
passthrough: passthroughSet.has(segment.index),
assignedProductIndex: sourceSegment?.card.assignedProductIndex ?? null,
};
});
return { cards };
}
export async function POST(request: NextRequest) {
const startedAt = Date.now();
let mode = "unknown";
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
mode = String(body.mode || "").trim();
const requestOrigin = getRequestOrigin(request);
const cookie = request.headers.get("cookie") || "";
if (mode === "prepare") {
const references = sanitizeReferenceInput(body.references, requestOrigin);
if (references.length === 0) {
return NextResponse.json({ success: false, error: "没有可拼合的参考图" }, { status: 400 });
}
const data = await prepareStitch({ references, requestOrigin, cookie });
logger.info("prepare_completed", {
referenceCount: references.length,
sourceSegmentCount: data.sourceSegments.length,
failedCount: data.failed.length,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ success: true, data });
}
if (mode === "apply") {
const previewUrl = resolveHttpUrl(body.previewUrl, requestOrigin);
const width = Math.round(Number(body.width));
const height = Math.round(Number(body.height));
const rawSegments = Array.isArray(body.sourceSegments) ? body.sourceSegments : [];
const sourceSegments = rawSegments
.map((item): StitchSourceSegment | null => {
const raw = (item || {}) as Record<string, unknown>;
const rawCard = (raw.card || {}) as Record<string, unknown>;
const imageUrl = resolveHttpUrl(rawCard.imageUrl, requestOrigin);
const previewUrl = resolveHttpUrl(rawCard.previewUrl, requestOrigin);
const top = Math.round(Number(raw.top));
const segmentHeight = Math.round(Number(raw.height));
const sourceWidth = Math.round(Number(raw.sourceWidth));
const sourceHeight = Math.round(Number(raw.sourceHeight));
const cardWidth = Math.round(Number(rawCard.width));
const cardHeight = Math.round(Number(rawCard.height));
if (!imageUrl || !Number.isFinite(top) || !Number.isFinite(segmentHeight)) {
return null;
}
return {
top,
height: Math.max(1, segmentHeight),
sourceWidth: Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : undefined,
sourceHeight: Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : undefined,
card: {
id: String(rawCard.id || "").trim() || `source-${top}`,
label: String(rawCard.label || "").trim() || "参考图",
imageUrl,
previewUrl: previewUrl || null,
width: Number.isFinite(cardWidth) && cardWidth > 0 ? cardWidth : null,
height: Number.isFinite(cardHeight) && cardHeight > 0 ? cardHeight : null,
candidateId: String(rawCard.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(rawCard.sortOrder)) ? Number(rawCard.sortOrder) : null,
passthrough: rawCard.passthrough === true,
assignedProductIndex: Number.isFinite(Number(rawCard.assignedProductIndex))
? Number(rawCard.assignedProductIndex)
: null,
},
} satisfies StitchSourceSegment;
})
.filter((item): item is StitchSourceSegment => Boolean(item));
if (!width || !height || sourceSegments.length === 0) {
return NextResponse.json({ success: false, error: "缺少切割参数" }, { status: 400 });
}
const passthroughSegmentIndexes = Array.isArray(body.passthroughSegmentIndexes)
? body.passthroughSegmentIndexes
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value >= 0)
: [];
const data = await applyStitch({
previewUrl,
width,
height,
cutLines: normalizeCutLines(body.cutLines, height),
sourceSegments,
passthroughSegmentIndexes,
requestOrigin,
cookie,
});
logger.info("apply_completed", {
sourceSegmentCount: sourceSegments.length,
cutLineCount: normalizeCutLines(body.cutLines, height).length,
outputCount: data.cards.length,
durationMs: Date.now() - startedAt,
});
return NextResponse.json({ success: true, data });
}
return NextResponse.json({ success: false, error: "未知操作" }, { status: 400 });
} catch (error) {
logger.error("request_failed", error, {
mode,
durationMs: Date.now() - startedAt,
});
return NextResponse.json(
{
success: false,
error: toPublicErrorMessage(error),
},
{ status: 500 },
);
}
}