382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { auth } from "@/app/(auth)/auth";
|
|
import { getSystemSetting, upsertMediaAsset } from "@/lib/db/queries";
|
|
import { uploadFile } from "@/lib/storage/service";
|
|
import {
|
|
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
|
normalizeAgentWorkflowMaterialApiConfig,
|
|
} from "@/lib/agents/workflow-material-config";
|
|
|
|
export const maxDuration = 180;
|
|
|
|
const querySchema = z.object({
|
|
keyword: z.string().trim().min(1).max(64),
|
|
source: z.enum(["primary", "secondary"]).optional(),
|
|
queryType: z.enum(["sku", "spu"]).optional(),
|
|
});
|
|
|
|
type ExternalImageRecord = {
|
|
key?: string;
|
|
label?: string;
|
|
fileName?: string;
|
|
fileSize?: number;
|
|
downloadUrl?: string;
|
|
};
|
|
|
|
type ExternalSearchResponse = {
|
|
success?: boolean;
|
|
data?: {
|
|
sku?: string;
|
|
spu?: string;
|
|
materialId?: string;
|
|
productModel?: string;
|
|
skuCount?: number;
|
|
skuList?: Array<{
|
|
skuName?: string;
|
|
skuId?: string;
|
|
hasWhiteBackground?: boolean;
|
|
whiteBackgroundCount?: number;
|
|
previewImage?: {
|
|
downloadUrl?: string;
|
|
fileName?: string;
|
|
} | null;
|
|
}>;
|
|
whiteBackground?: {
|
|
fixed?: ExternalImageRecord[];
|
|
other?: ExternalImageRecord[];
|
|
};
|
|
downloadUrls?: {
|
|
zip?: string;
|
|
};
|
|
};
|
|
error?: {
|
|
code?: string;
|
|
message?: string;
|
|
};
|
|
};
|
|
|
|
function normalizeBaseUrl(value: string) {
|
|
return String(value || "").trim().replace(/\/+$/, "");
|
|
}
|
|
|
|
function sanitizeFilenamePart(value: string) {
|
|
return String(value || "")
|
|
.trim()
|
|
.replace(/[\\/:*?"<>|]+/g, "-")
|
|
.replace(/\s+/g, "-")
|
|
.slice(0, 80);
|
|
}
|
|
|
|
function getExtensionFromMimeType(mimeType: string) {
|
|
const normalized = String(mimeType || "").toLowerCase();
|
|
if (normalized.includes("png")) return "png";
|
|
if (normalized.includes("webp")) return "webp";
|
|
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
|
|
if (normalized.includes("gif")) return "gif";
|
|
return "png";
|
|
}
|
|
|
|
function ensureFilenameWithExtension(filename: string, mimeType: string) {
|
|
const trimmed = sanitizeFilenamePart(filename) || `workflow-material-${Date.now()}`;
|
|
if (/\.[a-z0-9]+$/i.test(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
return `${trimmed}.${getExtensionFromMimeType(mimeType)}`;
|
|
}
|
|
|
|
async function importExternalImage(params: {
|
|
userId: string;
|
|
keyword: string;
|
|
source: "primary" | "secondary";
|
|
matchedSku: string;
|
|
productModel?: string | null;
|
|
group: "fixed" | "other";
|
|
sortOrder: number;
|
|
image: ExternalImageRecord;
|
|
}) {
|
|
const downloadUrl = String(params.image.downloadUrl || "").trim();
|
|
if (!downloadUrl) return null;
|
|
|
|
const imageResponse = await fetch(downloadUrl, {
|
|
cache: "no-store",
|
|
redirect: "follow",
|
|
});
|
|
if (!imageResponse.ok) {
|
|
throw new Error(`下载图片失败(${imageResponse.status})`);
|
|
}
|
|
|
|
const imageBuffer = await imageResponse.arrayBuffer();
|
|
const mimeType =
|
|
String(imageResponse.headers.get("content-type") || "").split(";")[0].trim() || "image/png";
|
|
const filename = ensureFilenameWithExtension(
|
|
params.image.fileName ||
|
|
params.image.label ||
|
|
`${params.matchedSku || params.keyword}-${params.group}-${params.sortOrder + 1}`,
|
|
mimeType
|
|
);
|
|
const uploaded = await uploadFile(filename, imageBuffer);
|
|
|
|
let assetId: string | null = null;
|
|
try {
|
|
const asset = await upsertMediaAsset({
|
|
userId: params.userId,
|
|
mediaType: "image",
|
|
sourceType: "agent_workflow_material_search",
|
|
status: "ready",
|
|
originalUrl: uploaded.url,
|
|
previewUrl: uploaded.url,
|
|
thumbnailUrl: uploaded.url,
|
|
mimeType: uploaded.contentType || mimeType,
|
|
displayName: filename,
|
|
sourceTable: "agent_workflow_material_search",
|
|
metadata: {
|
|
keyword: params.keyword,
|
|
source: params.source,
|
|
matchedSku: params.matchedSku,
|
|
productModel: params.productModel || null,
|
|
group: params.group,
|
|
externalKey: String(params.image.key || "").trim() || null,
|
|
fileSize: Number(params.image.fileSize || 0) || null,
|
|
pathname: uploaded.pathname,
|
|
},
|
|
});
|
|
assetId = asset?.id ?? null;
|
|
} catch (error) {
|
|
console.warn("[Agent Workflow Material Search] Failed to register media asset:", error);
|
|
}
|
|
|
|
return {
|
|
id: assetId || `${params.group}-${params.sortOrder + 1}`,
|
|
key: String(params.image.key || "").trim() || `${params.group}-${params.sortOrder + 1}`,
|
|
label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`,
|
|
fileName: filename,
|
|
imageUrl: uploaded.url,
|
|
assetId,
|
|
mimeType: uploaded.contentType || mimeType,
|
|
group: params.group,
|
|
sortOrder: params.sortOrder,
|
|
};
|
|
}
|
|
|
|
function mapUpstreamErrorStatus(code: string) {
|
|
if (
|
|
code === "SKU_NOT_FOUND" ||
|
|
code === "SPU_NOT_FOUND" ||
|
|
code === "NO_SKUS" ||
|
|
code === "WHITE_BACKGROUND_NOT_FOUND"
|
|
) {
|
|
return 404;
|
|
}
|
|
if (code === "INVALID_API_KEY") {
|
|
return 500;
|
|
}
|
|
if (code === "SKU_NOT_UNIQUE") {
|
|
return 409;
|
|
}
|
|
return 502;
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
}
|
|
|
|
const parsed = querySchema.parse({
|
|
keyword: request.nextUrl.searchParams.get("keyword"),
|
|
source: request.nextUrl.searchParams.get("source"),
|
|
queryType: request.nextUrl.searchParams.get("queryType"),
|
|
});
|
|
|
|
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
|
|
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
|
|
if (!config.enabled) {
|
|
return NextResponse.json(
|
|
{ error: "素材搜索功能未开启,请先在系统设置中启用" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
if (!config.apiKey) {
|
|
return NextResponse.json(
|
|
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
const normalizedKeyword = parsed.keyword.trim().toUpperCase();
|
|
const queryType = parsed.queryType === "spu" ? "spu" : "sku";
|
|
const upstreamUrl = `${normalizeBaseUrl(
|
|
config.baseUrl
|
|
)}/standard-model-white-background?resourceType=white-background&${queryType}=${encodeURIComponent(normalizedKeyword)}`;
|
|
const upstreamResponse = await fetch(upstreamUrl, {
|
|
method: "GET",
|
|
headers: {
|
|
"X-API-Key": config.apiKey,
|
|
},
|
|
cache: "no-store",
|
|
});
|
|
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalSearchResponse;
|
|
|
|
if (!upstreamResponse.ok || upstreamResult.success !== true || !upstreamResult.data) {
|
|
const errorCode = String(upstreamResult.error?.code || "UPSTREAM_ERROR").trim();
|
|
const errorMessage =
|
|
String(upstreamResult.error?.message || "").trim() ||
|
|
`素材搜索失败(${upstreamResponse.status})`;
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: {
|
|
code: errorCode,
|
|
message: errorMessage,
|
|
},
|
|
},
|
|
{ status: mapUpstreamErrorStatus(errorCode) }
|
|
);
|
|
}
|
|
|
|
if (queryType === "spu") {
|
|
const skuOptions = Array.isArray(upstreamResult.data.skuList)
|
|
? upstreamResult.data.skuList
|
|
.map((item) => {
|
|
const skuName = String(item?.skuName || "").trim();
|
|
if (!skuName) return null;
|
|
return {
|
|
skuName,
|
|
skuId: String(item?.skuId || "").trim() || null,
|
|
hasWhiteBackground: item?.hasWhiteBackground === true,
|
|
whiteBackgroundCount: Number.isFinite(Number(item?.whiteBackgroundCount))
|
|
? Math.max(0, Math.min(999, Math.round(Number(item?.whiteBackgroundCount))))
|
|
: 0,
|
|
previewImageUrl: String(item?.previewImage?.downloadUrl || "").trim() || null,
|
|
previewFileName: String(item?.previewImage?.fileName || "").trim() || null,
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: {
|
|
query: normalizedKeyword,
|
|
queryType: "spu",
|
|
matchedSku: "",
|
|
matchedSpu: String(upstreamResult.data.spu || normalizedKeyword).trim() || normalizedKeyword,
|
|
materialId: String(upstreamResult.data.materialId || "").trim() || null,
|
|
productModel: String(upstreamResult.data.spu || normalizedKeyword).trim() || null,
|
|
zipUrl: null,
|
|
fixedCount: 0,
|
|
otherCount: 0,
|
|
totalCount: skuOptions.length,
|
|
images: [],
|
|
skuOptions,
|
|
},
|
|
});
|
|
}
|
|
|
|
const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword;
|
|
const productModel = String(upstreamResult.data.productModel || "").trim() || null;
|
|
const fixedImages = Array.isArray(upstreamResult.data.whiteBackground?.fixed)
|
|
? upstreamResult.data.whiteBackground?.fixed || []
|
|
: [];
|
|
const otherImages = Array.isArray(upstreamResult.data.whiteBackground?.other)
|
|
? upstreamResult.data.whiteBackground?.other || []
|
|
: [];
|
|
|
|
const importedImages = (
|
|
await Promise.all([
|
|
...fixedImages.map((image, index) =>
|
|
importExternalImage({
|
|
userId: session.user.id,
|
|
keyword: normalizedKeyword,
|
|
source: parsed.source || "primary",
|
|
matchedSku,
|
|
productModel,
|
|
group: "fixed",
|
|
sortOrder: index,
|
|
image,
|
|
}).catch((error) => {
|
|
console.warn("[Agent Workflow Material Search] Fixed image import failed:", error);
|
|
return null;
|
|
})
|
|
),
|
|
...otherImages.map((image, index) =>
|
|
importExternalImage({
|
|
userId: session.user.id,
|
|
keyword: normalizedKeyword,
|
|
source: parsed.source || "primary",
|
|
matchedSku,
|
|
productModel,
|
|
group: "other",
|
|
sortOrder: fixedImages.length + index,
|
|
image,
|
|
}).catch((error) => {
|
|
console.warn("[Agent Workflow Material Search] Other image import failed:", error);
|
|
return null;
|
|
})
|
|
),
|
|
])
|
|
).filter(
|
|
(
|
|
item
|
|
): item is {
|
|
id: string;
|
|
key: string;
|
|
label: string;
|
|
fileName: string;
|
|
imageUrl: string;
|
|
assetId: string | null;
|
|
mimeType: string;
|
|
group: "fixed" | "other";
|
|
sortOrder: number;
|
|
} => Boolean(item)
|
|
);
|
|
|
|
if (importedImages.length === 0) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: {
|
|
code: "IMAGE_IMPORT_FAILED",
|
|
message: "已获取到素材记录,但图片导入失败,请稍后重试",
|
|
},
|
|
},
|
|
{ status: 502 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: {
|
|
query: normalizedKeyword,
|
|
queryType: "sku",
|
|
matchedSku,
|
|
matchedSpu: productModel,
|
|
materialId: String(upstreamResult.data.materialId || "").trim() || null,
|
|
productModel,
|
|
zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null,
|
|
fixedCount: importedImages.filter((item) => item.group === "fixed").length,
|
|
otherCount: importedImages.filter((item) => item.group === "other").length,
|
|
totalCount: importedImages.length,
|
|
images: importedImages,
|
|
skuOptions: [],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("[Agent Workflow Material Search] Error:", error);
|
|
|
|
if (error instanceof z.ZodError) {
|
|
return NextResponse.json(
|
|
{ error: "搜索参数无效", details: error.errors },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: "素材搜索失败,请稍后重试" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|