877 lines
27 KiB
TypeScript
877 lines
27 KiB
TypeScript
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 } from "@/lib/db/queries";
|
||
import {
|
||
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
||
buildAgentWorkflowMaterialApiUrl,
|
||
isAgentWorkflowMaterialResourceEnabled,
|
||
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(),
|
||
appearanceRecognitionEnabled: z.enum(["true", "false", "1", "0"]).optional(),
|
||
});
|
||
|
||
const QWEN_VL_MODEL_MATCHER = /(qwen|千问|通义).*(vl|vision)|qwen3-vl|qwen-vl/i;
|
||
const FUNCTIONAL_IMAGE_HINT_REGEX = /开门|开柜|柜门|抽屉|打开|开合|功能|展示|收纳|储物|细节/i;
|
||
const GOODS_INFO_FETCH_TIMEOUT_MS = 1500;
|
||
|
||
type ExternalImageRecord = {
|
||
key?: string;
|
||
label?: string;
|
||
fileName?: string;
|
||
fileSize?: number;
|
||
previewUrl?: string;
|
||
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;
|
||
};
|
||
|
||
type MaterialSearchImage = {
|
||
id: string;
|
||
key: string;
|
||
label: string;
|
||
fileName: string;
|
||
imageUrl: string;
|
||
previewUrl: string | null;
|
||
assetId: string | null;
|
||
mimeType: string;
|
||
group: "fixed" | "other";
|
||
sortOrder: number;
|
||
};
|
||
|
||
function normalizeBaseUrl(value: string) {
|
||
return String(value || "").trim().replace(/\/+$/, "");
|
||
}
|
||
|
||
function sanitizeGoodsInfoText(value: unknown, maxLength = 255) {
|
||
return String(value || "").trim().slice(0, maxLength);
|
||
}
|
||
|
||
function deriveSpuFromSku(value: unknown) {
|
||
const normalized = String(value || "").trim().toUpperCase();
|
||
if (!/[0-9]{2}$/.test(normalized) || normalized.length <= 2) {
|
||
return "";
|
||
}
|
||
return normalized.slice(0, -2);
|
||
}
|
||
|
||
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",
|
||
signal: AbortSignal.timeout(GOODS_INFO_FETCH_TIMEOUT_MS),
|
||
});
|
||
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: MaterialSearchImage[];
|
||
requiredCount: number;
|
||
visionAnalysisEnabled?: boolean;
|
||
}) {
|
||
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 (
|
||
params.visionAnalysisEnabled === true &&
|
||
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 inferMimeTypeFromFilename(filename: string) {
|
||
const normalized = String(filename || "").toLowerCase();
|
||
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg";
|
||
if (normalized.endsWith(".webp")) return "image/webp";
|
||
if (normalized.endsWith(".gif")) return "image/gif";
|
||
return "image/png";
|
||
}
|
||
|
||
function mapExternalImage(params: {
|
||
keyword: string;
|
||
matchedSku: string;
|
||
group: "fixed" | "other";
|
||
sortOrder: number;
|
||
image: ExternalImageRecord;
|
||
}): MaterialSearchImage | null {
|
||
const downloadUrl = String(params.image.downloadUrl || "").trim();
|
||
if (!downloadUrl) return null;
|
||
const filename =
|
||
String(params.image.fileName || "").trim() ||
|
||
String(params.image.label || "").trim() ||
|
||
`${params.matchedSku || params.keyword}-${params.group}-${params.sortOrder + 1}.png`;
|
||
const identityHash = createHash("sha1")
|
||
.update(`${params.group}:${params.sortOrder}:${downloadUrl}`, "utf8")
|
||
.digest("hex")
|
||
.slice(0, 12);
|
||
|
||
return {
|
||
id: `external-${params.group}-${params.sortOrder + 1}-${identityHash}`,
|
||
key: String(params.image.key || "").trim() || `${params.group}-${params.sortOrder + 1}`,
|
||
label: String(params.image.label || "").trim() || `图片${params.sortOrder + 1}`,
|
||
fileName: filename,
|
||
imageUrl: downloadUrl,
|
||
previewUrl: String(params.image.previewUrl || "").trim() || null,
|
||
assetId: null,
|
||
mimeType: inferMimeTypeFromFilename(filename),
|
||
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,
|
||
appearanceRecognitionEnabled:
|
||
request.nextUrl.searchParams.get("appearanceRecognitionEnabled") || 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 (!isAgentWorkflowMaterialResourceEnabled(config, "white-background")) {
|
||
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 goodsInfoPromise =
|
||
queryType === "sku"
|
||
? fetchGoodsInfoByKeyword({
|
||
keyword: normalizedKeyword,
|
||
config: goodsInfoConfig,
|
||
}).catch((error) => {
|
||
console.warn("[Agent Workflow Material Search] Goods info fetch failed:", error);
|
||
return null;
|
||
})
|
||
: Promise.resolve(null);
|
||
const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, {
|
||
resourceType: "white-background",
|
||
queryType,
|
||
keyword: normalizedKeyword,
|
||
});
|
||
const upstreamResponse = await fetch(upstreamUrl, {
|
||
method: "GET",
|
||
headers: {
|
||
"X-API-Key": config.apiKey,
|
||
},
|
||
cache: "no-store",
|
||
signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000),
|
||
});
|
||
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 = await goodsInfoPromise;
|
||
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 externalImages = [
|
||
...fixedImages.map((image, index) =>
|
||
mapExternalImage({
|
||
keyword: normalizedKeyword,
|
||
matchedSku,
|
||
group: "fixed",
|
||
sortOrder: index,
|
||
image,
|
||
})
|
||
),
|
||
...otherImages.map((image, index) =>
|
||
mapExternalImage({
|
||
keyword: normalizedKeyword,
|
||
matchedSku,
|
||
group: "other",
|
||
sortOrder: fixedImages.length + index,
|
||
image,
|
||
})
|
||
),
|
||
].filter((item): item is MaterialSearchImage => Boolean(item));
|
||
|
||
if (externalImages.length === 0) {
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "IMAGE_URL_NOT_FOUND",
|
||
message: "已获取到素材记录,但未返回可用图片地址,请稍后重试",
|
||
},
|
||
},
|
||
{ status: 502 }
|
||
);
|
||
}
|
||
|
||
const recommendedImageIds = requiredCount
|
||
? await recommendMaterialImageIds({
|
||
images: externalImages,
|
||
requiredCount,
|
||
})
|
||
: [];
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
query: normalizedKeyword,
|
||
queryType: "sku",
|
||
matchedSku,
|
||
matchedSpu: goodsInfo?.matchedSpu || productModel || deriveSpuFromSku(matchedSku),
|
||
materialId: String(upstreamResult.data.materialId || "").trim() || null,
|
||
productModel,
|
||
zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null,
|
||
fixedCount: externalImages.filter((item) => item.group === "fixed").length,
|
||
otherCount: externalImages.filter((item) => item.group === "other").length,
|
||
totalCount: externalImages.length,
|
||
images: externalImages,
|
||
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 }
|
||
);
|
||
}
|
||
}
|