Your Name dc1665d7ba
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: restore sku goods info display
2026-03-22 23:54:49 +08:00

931 lines
29 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 { generateText } from "ai";
import { execFileSync } from "node:child_process";
import { createCipheriv, createHash } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { getEnabledAiModelsWithProvider, 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";
import {
AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
normalizeAgentWorkflowGoodsInfoApiConfig,
} from "@/lib/agents/workflow-goods-info-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(),
requiredCount: z.coerce.number().int().min(1).max(4).optional(),
});
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
const FUNCTIONAL_IMAGE_HINT_REGEX = /开门|开柜|柜门|抽屉|打开|开合|功能|展示|收纳|储物|细节/i;
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;
};
};
type ExternalGoodsInfoResponse = {
success?: boolean;
errMsg?: string | null;
data?: Record<string, unknown> | null;
};
function normalizeBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "");
}
function sanitizeGoodsInfoText(value: unknown, maxLength = 255) {
return String(value || "").trim().slice(0, maxLength);
}
function createGoodsInfoSignature(timespan: string, secret: string) {
const secretBuffer = Buffer.from(String(secret || "").trim(), "utf8");
if (secretBuffer.length !== 8) {
throw new Error("商品信息接口 Secret 长度必须为 8 位");
}
const plaintext = String(timespan || "").trim();
let encryptedHex = "";
try {
const cipher = createCipheriv("des-cbc", secretBuffer, secretBuffer);
cipher.setAutoPadding(true);
const encrypted = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
encryptedHex = encrypted.toString("hex").toUpperCase();
} catch (error) {
const opensslOutput = execFileSync(
"openssl",
[
"enc",
"-des-cbc",
"-provider",
"legacy",
"-K",
secretBuffer.toString("hex"),
"-iv",
secretBuffer.toString("hex"),
"-nosalt",
],
{
input: Buffer.from(plaintext, "utf8"),
}
);
encryptedHex = Buffer.from(opensslOutput).toString("hex").toUpperCase();
console.warn(
"[Agent Workflow Material Search] Fallback to openssl legacy DES for goods info signature:",
error instanceof Error ? error.message : error
);
}
return createHash("md5").update(encryptedHex, "utf8").digest("hex").toUpperCase();
}
function buildGoodsInfoSummary(data: Record<string, unknown>) {
const productName =
sanitizeGoodsInfoText(data["商品名称"], 255) ||
sanitizeGoodsInfoText(data["名称"], 255) ||
null;
const sizeInfoParts = [
sanitizeGoodsInfoText(data["商品尺寸"], 128),
[data["长"], data["宽"], data["高"]]
.map((item) => sanitizeGoodsInfoText(item, 32))
.filter(Boolean)
.join(" x "),
].filter(Boolean);
const materialInfoParts = [
sanitizeGoodsInfoText(data["材质"], 128),
sanitizeGoodsInfoText(data["材质类型"], 128),
].filter(Boolean);
return {
productName,
sizeInfo: sizeInfoParts.join(" · ") || null,
materialInfo: materialInfoParts.join(" · ") || null,
designer: sanitizeGoodsInfoText(data["设计师"], 128) || null,
buyer: sanitizeGoodsInfoText(data["采购负责人"], 128) || null,
boardType: sanitizeGoodsInfoText(data["板材类型"], 128) || null,
matchedSpu:
sanitizeGoodsInfoText(data["spu"], 64) ||
sanitizeGoodsInfoText(data["SPU"], 64) ||
null,
};
}
async function fetchGoodsInfoByKeyword(params: {
keyword: string;
config: ReturnType<typeof normalizeAgentWorkflowGoodsInfoApiConfig>;
}) {
const baseUrl = normalizeBaseUrl(params.config.baseUrl);
const clientKey = String(params.config.clientKey || "").trim();
const secret = String(params.config.secret || "").trim();
if (!params.config.enabled || !baseUrl || !clientKey || !secret) {
return null;
}
const timespan = String(Math.floor(Date.now() / 1000));
const sign = createGoodsInfoSignature(timespan, secret);
const commonHeaders = {
Key: clientKey,
Timespan: timespan,
Sign: sign,
Accept: "application/json",
};
const requestPaths = [
`/api/GoodsInfo/SearchByProcode?procode=${encodeURIComponent(params.keyword)}`,
`/api/GoodsInfo/SearchBySkuoid?skuoid=${encodeURIComponent(params.keyword)}`,
];
for (const requestPath of requestPaths) {
try {
const response = await fetch(`${baseUrl}${requestPath}`, {
method: "GET",
headers: commonHeaders,
cache: "no-store",
});
const result = (await response.json().catch(() => ({}))) as ExternalGoodsInfoResponse;
if (response.ok && result.success === true && result.data && typeof result.data === "object") {
return buildGoodsInfoSummary(result.data);
}
} catch (error) {
console.warn("[Agent Workflow Material Search] Goods info query failed:", {
requestPath,
error,
});
}
}
return null;
}
function normalizeImageIdentityText(...values: Array<string | null | undefined>) {
return values
.map((value) => String(value || "").trim().toLowerCase().replace(/\s+/g, ""))
.filter(Boolean)
.join(" ");
}
function normalizeBooleanLike(value: unknown): boolean | null {
if (typeof value === "boolean") {
return value;
}
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) {
return null;
}
if (["true", "yes", "y", "1", "是"].includes(normalized)) {
return true;
}
if (["false", "no", "n", "0", "否"].includes(normalized)) {
return false;
}
return null;
}
function extractJsonObjectText(text: string): string | null {
const normalized = String(text || "").trim();
if (!normalized) {
return null;
}
const fencedMatch = normalized.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fencedMatch?.[1]) {
const fencedText = fencedMatch[1].trim();
if (fencedText.startsWith("{") && fencedText.endsWith("}")) {
return fencedText;
}
}
if (normalized.startsWith("{") && normalized.endsWith("}")) {
return normalized;
}
const firstBraceIndex = normalized.indexOf("{");
const lastBraceIndex = normalized.lastIndexOf("}");
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
return normalized.slice(firstBraceIndex, lastBraceIndex + 1);
}
return null;
}
function parseFunctionalShowcaseDecision(text: string): {
isFunctionalShowcase: boolean;
score: number;
} | null {
const jsonText = extractJsonObjectText(text);
if (jsonText) {
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
const isFunctionalShowcase =
normalizeBooleanLike(parsed.isFunctionalShowcase) ??
normalizeBooleanLike(parsed.isFunctional) ??
normalizeBooleanLike(parsed.shouldUse);
const score = Number(parsed.score);
if (typeof isFunctionalShowcase === "boolean") {
return {
isFunctionalShowcase,
score: Number.isFinite(score)
? Math.max(0, Math.min(100, Math.round(score)))
: isFunctionalShowcase
? 80
: 0,
};
}
} catch (error) {
console.warn("[Agent Workflow Material Search] Failed to parse functional showcase JSON:", error);
}
}
const normalized = String(text || "").replace(/\s+/g, " ").trim();
if (!normalized) {
return null;
}
const positive = /功能图|功能展示|打开图|开门图|开柜图|抽屉图|展示图|适合作为第4张|可作为d档|yes|true/i.test(
normalized,
);
const negative = /不是功能图|不适合|非功能展示|no|false/i.test(normalized);
if (!positive && !negative) {
return null;
}
return {
isFunctionalShowcase: positive && !negative,
score: positive && !negative ? 75 : 0,
};
}
function getFixedAnglePriority(image: {
key: string;
label: string;
fileName: string;
group: "fixed" | "other";
}) {
if (image.group !== "fixed") {
return null;
}
const normalized = normalizeImageIdentityText(image.key, image.label, image.fileName);
if (normalized.includes("leftfront45") || /左前45/.test(normalized)) {
return "A" as const;
}
if (normalized.includes("rightfront45") || /右前45/.test(normalized)) {
return "B" as const;
}
if (normalized === "front" || normalized.includes(" front") || /正视|正面/.test(normalized)) {
return "C" as const;
}
return null;
}
function getFallbackFixedAngleScore(image: {
key: string;
label: string;
fileName: string;
group: "fixed" | "other";
}) {
if (image.group !== "fixed") {
return -1;
}
const normalized = normalizeImageIdentityText(image.key, image.label, image.fileName);
if (normalized.includes("left") && !normalized.includes("front") && !normalized.includes("back")) {
return 60;
}
if (normalized.includes("right") && !normalized.includes("front") && !normalized.includes("back")) {
return 58;
}
if (normalized.includes("leftback45") || /左后45/.test(normalized)) {
return 52;
}
if (normalized.includes("rightback45") || /右后45/.test(normalized)) {
return 50;
}
if (normalized.includes("back") || /背面/.test(normalized)) {
return 46;
}
return 20;
}
function getFunctionalHintScore(image: {
key: string;
label: string;
fileName: string;
group: "fixed" | "other";
}) {
const normalized = normalizeImageIdentityText(image.key, image.label, image.fileName);
if (FUNCTIONAL_IMAGE_HINT_REGEX.test(normalized)) {
return 85;
}
return image.group === "other" ? 10 : 0;
}
async function analyzeFunctionalShowcaseImage(params: {
imageUrl: string;
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) {
const prompt = [
"你是白底图角度与功能展示判定器。",
"请判断这张白底产品图是否适合作为第4优先级的功能展示图。",
"功能展示图指:柜门打开、抽屉拉开、功能结构展示、收纳展示、机构展示等。",
"普通角度白底图、纯正面图、纯45度图、纯侧面图,都不是功能展示图。",
'只输出 JSON:{"isFunctionalShowcase":true,"score":92,"reason":"不超过20字"}',
].join("\n");
const configuredModels = Array.isArray(params.models)
? params.models
: await getEnabledAiModelsWithProvider().catch((error) => {
console.warn(
"[Agent Workflow Material Search] Failed to load configured Qwen-VL model list:",
error,
);
return [];
});
const configuredQwenVisionModel = configuredModels.find(
({ model }) =>
model.supportsVision &&
!model.isSubModel &&
QWEN_VL_MODEL_MATCHER.test(`${model.displayName} ${model.modelId}`),
);
if (configuredQwenVisionModel) {
try {
const languageModel = await getLanguageModelById(configuredQwenVisionModel.model.id);
if (languageModel) {
const { text } = await generateText({
model: languageModel,
messages: [
{
role: "user",
content: [
{
type: "text",
text: prompt,
},
{
type: "image",
image: params.imageUrl,
},
],
},
],
temperature: 0,
maxTokens: 120,
abortSignal: AbortSignal.timeout(25_000),
} as any);
const parsed = parseFunctionalShowcaseDecision(text);
if (parsed) {
return parsed;
}
}
} catch (error) {
console.warn("[Agent Workflow Material Search] Configured Qwen-VL material analysis failed:", error);
}
}
try {
const { callQwen3Vl } = await import("@/lib/ai/qwen3-vl");
const result = await callQwen3Vl({
imageUrl: params.imageUrl,
prompt,
temperature: 0,
maxTokens: 120,
timeoutMs: 25_000,
retries: 1,
});
return (
parseFunctionalShowcaseDecision(result.text) || {
isFunctionalShowcase: false,
score: 0,
}
);
} catch (error) {
console.warn("[Agent Workflow Material Search] Fallback Qwen-VL material analysis failed:", error);
}
return {
isFunctionalShowcase: false,
score: 0,
};
}
async function recommendMaterialImageIds(params: {
images: Array<{
id: string;
key: string;
label: string;
fileName: string;
imageUrl: string;
assetId: string | null;
mimeType: string;
group: "fixed" | "other";
sortOrder: number;
}>;
requiredCount: number;
}) {
const requiredCount = Math.max(1, Math.min(4, Math.round(params.requiredCount)));
const selectedIds: string[] = [];
const fixedImages = params.images.filter((image) => image.group === "fixed");
const otherImages = params.images.filter((image) => image.group === "other");
const pushImage = (image?: { id: string } | null) => {
if (!image?.id) return;
if (!selectedIds.includes(image.id)) {
selectedIds.push(image.id);
}
};
pushImage(fixedImages.find((image) => getFixedAnglePriority(image) === "A"));
if (selectedIds.length >= requiredCount) return selectedIds.slice(0, requiredCount);
pushImage(fixedImages.find((image) => getFixedAnglePriority(image) === "B"));
if (selectedIds.length >= requiredCount) return selectedIds.slice(0, requiredCount);
pushImage(fixedImages.find((image) => getFixedAnglePriority(image) === "C"));
if (selectedIds.length >= requiredCount) return selectedIds.slice(0, requiredCount);
const remainingNeedCount = requiredCount - selectedIds.length;
if (remainingNeedCount > 0 && otherImages.length > 0) {
const heuristicFunctionalCandidates = otherImages
.map((image) => ({
image,
score: getFunctionalHintScore(image),
}))
.filter((item) => item.score >= 80)
.sort((left, right) => right.score - left.score || left.image.sortOrder - right.image.sortOrder);
heuristicFunctionalCandidates
.slice(0, remainingNeedCount)
.forEach((item) => pushImage(item.image));
}
const functionalRemainingNeedCount = requiredCount - selectedIds.length;
if (functionalRemainingNeedCount > 0 && otherImages.length > 0) {
const analyzableImages = otherImages
.filter((image) => !selectedIds.includes(image.id))
.slice(0, 6);
if (analyzableImages.length > 0) {
const configuredModels = await getEnabledAiModelsWithProvider().catch((error) => {
console.warn("[Agent Workflow Material Search] Failed to load vision models:", error);
return undefined;
});
const analyzedCandidates = await Promise.all(
analyzableImages.map(async (image) => {
const analyzed = await analyzeFunctionalShowcaseImage({
imageUrl: image.imageUrl,
models: configuredModels,
});
return {
image,
score: analyzed.isFunctionalShowcase ? analyzed.score : 0,
};
}),
);
analyzedCandidates
.filter((item) => item.score > 0)
.sort((left, right) => right.score - left.score || left.image.sortOrder - right.image.sortOrder)
.slice(0, functionalRemainingNeedCount)
.forEach((item) => pushImage(item.image));
}
}
const stillNeedCount = requiredCount - selectedIds.length;
if (stillNeedCount > 0) {
const remainingFixedImages = fixedImages
.filter((image) => !selectedIds.includes(image.id))
.sort(
(left, right) =>
getFallbackFixedAngleScore(right) - getFallbackFixedAngleScore(left) ||
left.sortOrder - right.sortOrder,
);
remainingFixedImages.slice(0, stillNeedCount).forEach((image) => pushImage(image));
}
const finalNeedCount = requiredCount - selectedIds.length;
if (finalNeedCount > 0) {
params.images
.filter((image) => !selectedIds.includes(image.id))
.sort((left, right) => left.sortOrder - right.sortOrder)
.slice(0, finalNeedCount)
.forEach((image) => pushImage(image));
}
return selectedIds.slice(0, requiredCount);
}
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"),
requiredCount: request.nextUrl.searchParams.get("requiredCount") || undefined,
});
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
const goodsInfoSetting = await getSystemSetting({
key: AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY,
});
const goodsInfoConfig = normalizeAgentWorkflowGoodsInfoApiConfig(
goodsInfoSetting?.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,
goodsInfo: null,
},
});
}
const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword;
const productModel = String(upstreamResult.data.productModel || "").trim() || null;
const goodsInfo =
queryType === "sku"
? await fetchGoodsInfoByKeyword({
keyword: normalizedKeyword,
config: goodsInfoConfig,
}).catch((error) => {
console.warn("[Agent Workflow Material Search] Goods info fetch failed:", error);
return null;
})
: null;
const requiredCount = Number.isFinite(Number(parsed.requiredCount))
? Math.max(1, Math.min(4, Math.round(Number(parsed.requiredCount))))
: 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 }
);
}
const recommendedImageIds = requiredCount
? await recommendMaterialImageIds({
images: importedImages,
requiredCount,
})
: [];
return NextResponse.json({
success: true,
data: {
query: normalizedKeyword,
queryType: "sku",
matchedSku,
matchedSpu: goodsInfo?.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,
recommendedImageIds,
skuOptions: [],
goodsInfo: goodsInfo
? {
productName: goodsInfo.productName,
sizeInfo: goodsInfo.sizeInfo,
materialInfo: goodsInfo.materialInfo,
designer: goodsInfo.designer,
buyer: goodsInfo.buyer,
boardType: goodsInfo.boardType,
}
: null,
},
});
} 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 }
);
}
}