import { NextRequest, NextResponse } from "next/server"; import { execFileSync } from "node:child_process"; import { createCipheriv, createHash } from "node:crypto"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { getSystemSetting } from "@/lib/db/queries"; 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"; import type { AgentDetailReplacementReferenceItem, AgentModeReferenceCandidate, AgentWorkflowMaterialQueryType, } from "@/types/agent"; export const maxDuration = 180; const querySchema = z.object({ keyword: z.string().trim().min(1).max(64), queryType: z.enum(["sku", "spu"]).default("spu"), target: z.enum(["main", "detail", "sku"]), brandName: z.string().trim().max(64).optional(), }); function jsonNoStore( body: Parameters[0], init?: ResponseInit ) { return NextResponse.json(body, { ...init, headers: { "Cache-Control": "no-store, max-age=0", ...(init?.headers || {}), }, }); } type ExternalTaskDetailCandidate = { materialId?: string; taskNo?: string; productModel?: string; brandName?: string; brand?: string; productBrand?: string; brand_name?: string; brand_name_cn?: string; brandTitle?: string; brand_title?: string; categoryName?: string; shopName?: string; sceneName?: string; uploadedAt?: string; isFullSet?: boolean; downloadUrls?: { main?: string[]; sku?: string[]; detail?: string[]; all?: string[]; }; }; type ExternalTaskDetailResponse = { success?: boolean; data?: { resourceType?: string; spu?: string | null; sku?: string | null; brandName?: string | null; brand?: string | null; productBrand?: string | null; brand_name?: string | null; brand_name_cn?: string | null; brandTitle?: string | null; brand_title?: string | null; categoryName?: string | null; shopName?: string | null; candidateCount?: number; candidates?: ExternalTaskDetailCandidate[]; }; error?: { code?: string; message?: string; }; }; type ExternalGoodsInfoResponse = { success?: boolean; errMsg?: string | null; data?: Record | null; }; function normalizeBaseUrl(value: string) { return String(value || "").trim().replace(/\/+$/, ""); } function sanitizeText(value: unknown, maxLength: number) { return String(value || "").trim().slice(0, maxLength); } function normalizeComparableName(value: unknown) { return String(value || "") .trim() .toLowerCase() .replace(/\s+/g, ""); } function normalizeUrlList(value: unknown) { if (!Array.isArray(value)) return [] as string[]; return Array.from( new Set( value .map((item) => String(item || "").trim()) .filter((item) => /^https?:\/\//i.test(item)) ) ).slice(0, 64); } function getExternalBrandName(value: { brandName?: string | null; brand?: string | null; productBrand?: string | null; brand_name?: string | null; brand_name_cn?: string | null; brandTitle?: string | null; brand_title?: string | null; }) { return ( String( value.brandName || value.brand || value.productBrand || value.brand_name || value.brand_name_cn || value.brandTitle || value.brand_title || "" ).trim() || null ); } 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 Reference 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 getGoodsInfoBrandName(data: Record) { return ( sanitizeText(data["品牌"], 64) || sanitizeText(data["品牌名称"], 64) || sanitizeText(data["商品品牌"], 64) || sanitizeText(data["品牌名"], 64) || sanitizeText(data["所属品牌"], 64) || sanitizeText(data["brandName"], 64) || sanitizeText(data["brand"], 64) || sanitizeText(data["productBrand"], 64) || null ); } async function fetchGoodsBrandByKeyword(params: { keyword: string; config: ReturnType; }) { 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) { const brandName = getGoodsInfoBrandName(result.data); if (brandName) { return brandName; } } } catch (error) { console.warn("[Agent Workflow Reference Search] Goods brand query failed:", { requestPath, error, }); } } return null; } function mapUpstreamErrorStatus(code: string) { if ( code === "SKU_NOT_FOUND" || code === "SPU_NOT_FOUND" || code === "TASK_DETAIL_NOT_FOUND" || code === "FULL_SET_NOT_FOUND" ) { return 404; } if (code === "INVALID_API_KEY") { return 500; } if (code === "SKU_NOT_UNIQUE") { return 409; } return 502; } function normalizeCandidate( value: ExternalTaskDetailCandidate, index: number, fallbackBrandName?: string | null ): AgentModeReferenceCandidate { const uploadedAt = String(value.uploadedAt || "").trim() || null; const taskNo = String(value.taskNo || "").trim() || null; const materialId = String(value.materialId || "").trim() || null; const sceneName = String(value.sceneName || "").trim() || null; const id = taskNo || materialId || `candidate-${index + 1}`; const brandName = getExternalBrandName(value) || fallbackBrandName || null; return { id, materialId, taskNo, productModel: String(value.productModel || "").trim() || null, brandName, categoryName: String(value.categoryName || "").trim() || null, shopName: String(value.shopName || "").trim() || null, sceneName, uploadedAt, isFullSet: value.isFullSet !== false, mainImageUrls: normalizeUrlList(value.downloadUrls?.main), skuImageUrls: normalizeUrlList(value.downloadUrls?.sku), detailImageUrls: normalizeUrlList(value.downloadUrls?.detail), allImageUrls: normalizeUrlList(value.downloadUrls?.all), }; } function getCandidateTargetUrls( candidate: AgentModeReferenceCandidate, target: "main" | "detail" | "sku" ) { if (target === "main") return candidate.mainImageUrls; return target === "detail" ? candidate.detailImageUrls : candidate.skuImageUrls; } function getRenderableCandidateTargetUrls( candidate: AgentModeReferenceCandidate, target: "main" | "detail" | "sku" ) { return getCandidateTargetUrls(candidate, target); } function buildReferenceItems(params: { candidate: AgentModeReferenceCandidate; target: "main" | "detail" | "sku"; }): AgentDetailReplacementReferenceItem[] { const urls = getRenderableCandidateTargetUrls(params.candidate, params.target); const targetLabel = params.target === "main" ? "主图参考图" : params.target === "detail" ? "详情参考图" : "SKU参考图"; const prefix = params.candidate.sceneName || params.candidate.taskNo || params.candidate.productModel || "候选整套"; return urls.map((url, index) => ({ id: `${params.candidate.id}-${params.target}-${index + 1}`, label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`, imageUrl: url, candidateId: params.candidate.id, sortOrder: index, passthrough: false, })); } function sortCandidatesByUploadedAtDesc(candidates: AgentModeReferenceCandidate[]) { return candidates.slice().sort((left, right) => { const leftTime = left.uploadedAt ? new Date(left.uploadedAt).getTime() : 0; const rightTime = right.uploadedAt ? new Date(right.uploadedAt).getTime() : 0; return rightTime - leftTime; }); } function isCandidateMatchedBrand( candidate: AgentModeReferenceCandidate, brandName?: string ) { return ( isBrandNameMatched(candidate.brandName, brandName) || isBrandNameMatched(candidate.shopName, brandName) ); } function isBrandNameMatched( candidateBrandName?: string | null, brandName?: string ) { const normalizedRequestedBrand = normalizeComparableName(brandName); if (!normalizedRequestedBrand) return true; const normalizedBrandName = normalizeComparableName(candidateBrandName); if (!normalizedBrandName) return false; if (normalizedBrandName === normalizedRequestedBrand) return true; // 只允许“候选品牌包含当前品牌”的弱匹配,避免短品牌名反向包含导致串品牌。 return ( normalizedRequestedBrand.length >= 2 && normalizedBrandName.includes(normalizedRequestedBrand) ); } function filterCandidatesByBrand( candidates: AgentModeReferenceCandidate[], brandName?: string ) { const normalizedRequestedBrand = normalizeComparableName(brandName); if (!normalizedRequestedBrand) return candidates; return candidates.filter((candidate) => isCandidateMatchedBrand(candidate, normalizedRequestedBrand) ); } async function requestTaskDetailAttempt(params: { keyword: string; queryType: AgentWorkflowMaterialQueryType; target: "main" | "detail" | "sku"; requestedBrandName: string; verifiedGoodsBrandName?: string | null; config: ReturnType; }) { const upstreamParams = new URLSearchParams({ resourceType: "task-detail", [params.queryType]: params.keyword, }); const upstreamUrl = `${normalizeBaseUrl( params.config.baseUrl )}/standard-model-white-background?${upstreamParams.toString()}`; const upstreamResponse = await fetch(upstreamUrl, { method: "GET", headers: { "X-API-Key": params.config.apiKey, }, cache: "no-store", }); const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse; const upstreamBrandName = getExternalBrandName(upstreamResult.data || {}); const rawCandidates = upstreamResult.data?.candidates || []; const candidateBrandSamples = Array.from( new Set( rawCandidates .flatMap((candidate) => [ getExternalBrandName(candidate), String(candidate.shopName || "").trim() || null, ]) .filter((brandName): brandName is string => Boolean(brandName)) ) ).slice(0, 8); const hasVerifiedRequestedBrand = Boolean(params.verifiedGoodsBrandName) && isBrandNameMatched(params.verifiedGoodsBrandName, params.requestedBrandName); const hasUpstreamRequestedBrand = Boolean(upstreamBrandName) && isBrandNameMatched(upstreamBrandName, params.requestedBrandName); let fallbackBrandName: string | null = null; if (params.requestedBrandName) { if (hasVerifiedRequestedBrand) { fallbackBrandName = params.verifiedGoodsBrandName || null; } else if (hasUpstreamRequestedBrand) { fallbackBrandName = upstreamBrandName || null; } } else { fallbackBrandName = upstreamBrandName; } const normalizedCandidates = upstreamResponse.ok && upstreamResult.success === true && upstreamResult.data ? filterCandidatesByBrand( sortCandidatesByUploadedAtDesc( rawCandidates.map((item, index) => normalizeCandidate(item, index, fallbackBrandName) ) ), params.requestedBrandName, ).filter( (candidate) => getRenderableCandidateTargetUrls(candidate, params.target).length > 0 ) : []; return { queryType: params.queryType, upstreamResponse, upstreamResult, upstreamBrandName, rawCandidates, candidateBrandSamples, fallbackBrandName, normalizedCandidates, }; } export async function GET(request: NextRequest) { try { const session = await auth(); if (!session?.user?.id) { return jsonNoStore({ error: "未登录" }, { status: 401 }); } const parsed = querySchema.parse({ keyword: request.nextUrl.searchParams.get("keyword"), queryType: request.nextUrl.searchParams.get("queryType") || "spu", target: request.nextUrl.searchParams.get("target"), brandName: request.nextUrl.searchParams.get("brandName") || 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 jsonNoStore( { error: "素材搜索功能未开启,请先在系统设置中启用" }, { status: 400 } ); } if (!config.apiKey) { return jsonNoStore( { error: "素材搜索 API Key 未配置,请先在系统设置中保存" }, { status: 500 } ); } const normalizedKeyword = parsed.keyword.trim().toUpperCase(); const queryType = parsed.queryType as AgentWorkflowMaterialQueryType; const requestedBrandName = String(parsed.brandName || "").trim(); const verifiedGoodsBrandName = requestedBrandName ? await fetchGoodsBrandByKeyword({ keyword: normalizedKeyword, config: goodsInfoConfig, }).catch((error) => { console.warn("[Agent Workflow Reference Search] Goods brand lookup failed:", error); return null; }) : null; if ( requestedBrandName && verifiedGoodsBrandName && !isBrandNameMatched(verifiedGoodsBrandName, requestedBrandName) ) { console.warn("[Agent Workflow Reference Search] Goods brand hint mismatched selected brand, keep searching with upstream data:", { keyword: normalizedKeyword, requestedBrandName, verifiedGoodsBrandName, }); } const queryTypesToTry = Array.from( new Set([ queryType, queryType === "spu" ? "sku" : "spu", ]) ); let selectedAttempt: | Awaited> | null = null; let firstUpstreamFailure: | Awaited> | null = null; let firstEmptyAttempt: | Awaited> | null = null; for (const currentQueryType of queryTypesToTry) { const attempt = await requestTaskDetailAttempt({ keyword: normalizedKeyword, queryType: currentQueryType, target: parsed.target, requestedBrandName, verifiedGoodsBrandName, config, }); if (requestedBrandName) { console.info("[Agent Workflow Reference Search] Brand gate:", { keyword: normalizedKeyword, queryType: currentQueryType, target: parsed.target, requestedBrandName, verifiedGoodsBrandName, upstreamBrandName: attempt.upstreamBrandName, candidateCount: attempt.rawCandidates.length, candidateBrandSamples: attempt.candidateBrandSamples, }); } if ( attempt.upstreamResponse.ok && attempt.upstreamResult.success === true && attempt.upstreamResult.data ) { if (!firstEmptyAttempt) { firstEmptyAttempt = attempt; } if (attempt.normalizedCandidates.length > 0) { selectedAttempt = attempt; break; } continue; } if (!firstUpstreamFailure) { firstUpstreamFailure = attempt; } } if (!selectedAttempt) { if (firstEmptyAttempt) { return jsonNoStore( { success: false, error: { code: requestedBrandName ? "BRAND_REFERENCE_NOT_FOUND" : parsed.target === "main" ? "MAIN_REFERENCE_NOT_FOUND" : parsed.target === "detail" ? "DETAIL_REFERENCE_NOT_FOUND" : "SKU_REFERENCE_NOT_FOUND", message: requestedBrandName ? `当前品牌「${requestedBrandName}」下没有可用的${ parsed.target === "main" ? "主图参考图" : parsed.target === "detail" ? "详情参考图" : "SKU参考图" }` : parsed.target === "main" ? "当前整套素材中没有可用的主图参考图" : parsed.target === "detail" ? "当前整套素材中没有可用的详情参考图" : "当前整套素材中没有可用的 SKU 参考图", }, }, { status: 404 } ); } const errorCode = String( firstUpstreamFailure?.upstreamResult.error?.code || "UPSTREAM_ERROR" ).trim(); const errorMessage = String(firstUpstreamFailure?.upstreamResult.error?.message || "").trim() || `参考图搜索失败(${firstUpstreamFailure?.upstreamResponse.status || 500})`; return jsonNoStore( { success: false, error: { code: errorCode, message: errorMessage, }, }, { status: mapUpstreamErrorStatus(errorCode) } ); } if (selectedAttempt.queryType !== queryType) { console.info("[Agent Workflow Reference Search] Query type fallback succeeded:", { keyword: normalizedKeyword, requestedQueryType: queryType, resolvedQueryType: selectedAttempt.queryType, target: parsed.target, }); } const normalizedCandidates = selectedAttempt.normalizedCandidates; const selectedCandidate = normalizedCandidates[0]; const references = buildReferenceItems({ candidate: selectedCandidate, target: parsed.target, }); return jsonNoStore({ success: true, data: { query: normalizedKeyword, queryType: selectedAttempt.queryType, target: parsed.target, matchedSpu: String(selectedAttempt.upstreamResult.data?.spu || "").trim() || null, matchedSku: String(selectedAttempt.upstreamResult.data?.sku || "").trim() || null, brandName: selectedCandidate.brandName || selectedAttempt.fallbackBrandName || selectedCandidate.shopName || null, categoryName: selectedCandidate.categoryName || String(selectedAttempt.upstreamResult.data?.categoryName || "").trim() || null, shopName: selectedCandidate.shopName || String(selectedAttempt.upstreamResult.data?.shopName || "").trim() || null, candidateCount: normalizedCandidates.length, candidates: normalizedCandidates, selectedCandidateId: selectedCandidate.id, references, }, }); } catch (error) { console.error("[Agent Workflow Reference Search] Error:", error); if (error instanceof z.ZodError) { return jsonNoStore( { error: "搜索参数无效", details: error.errors }, { status: 400 } ); } return jsonNoStore( { error: "参考图搜索失败,请稍后重试" }, { status: 500 } ); } }