1155 lines
35 KiB
TypeScript
1155 lines
35 KiB
TypeScript
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,
|
||
AgentWorkflowProductDetailChannel,
|
||
AgentWorkflowReferenceQueryType,
|
||
} from "@/types/agent";
|
||
|
||
export const maxDuration = 180;
|
||
|
||
const productDetailChannelSchema = z.preprocess((value) => {
|
||
const normalized = typeof value === "string" ? value.trim() : "";
|
||
return normalized || undefined;
|
||
}, z.enum(["taobao", "1688", "douyin", "jd"]).optional());
|
||
|
||
const querySchema = z.object({
|
||
keyword: z.string().trim().min(1).max(64),
|
||
queryType: z.enum(["sku", "spu", "productId"]).default("spu"),
|
||
target: z.enum(["main", "detail", "sku"]),
|
||
brandName: z.string().trim().max(64).optional(),
|
||
channel: productDetailChannelSchema,
|
||
strictQueryType: z.enum(["true", "false", "1", "0"]).optional(),
|
||
});
|
||
|
||
function jsonNoStore(
|
||
body: Parameters<typeof NextResponse.json>[0],
|
||
init?: ResponseInit
|
||
) {
|
||
return NextResponse.json(body, {
|
||
...init,
|
||
headers: {
|
||
"Cache-Control": "no-store, max-age=0",
|
||
...(init?.headers || {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
function normalizeProductDetailChannel(
|
||
value: unknown,
|
||
fallback: AgentWorkflowProductDetailChannel = "taobao"
|
||
): AgentWorkflowProductDetailChannel {
|
||
const normalized = String(value || "").trim();
|
||
if (
|
||
normalized === "taobao" ||
|
||
normalized === "1688" ||
|
||
normalized === "douyin" ||
|
||
normalized === "jd"
|
||
) {
|
||
return normalized;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function getProductDetailRequestLogUrl(value: string) {
|
||
try {
|
||
const url = new URL(value);
|
||
return `${url.origin}${url.pathname}?${url.searchParams.toString()}`;
|
||
} catch {
|
||
return value.slice(0, 500);
|
||
}
|
||
}
|
||
|
||
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<string, unknown> | 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<string, unknown>) {
|
||
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
|
||
);
|
||
}
|
||
|
||
function getGoodsInfoMatchedSpu(data: Record<string, unknown>) {
|
||
return (
|
||
sanitizeText(data["spu"], 64) ||
|
||
sanitizeText(data["SPU"], 64) ||
|
||
sanitizeText(data["productSpu"], 64) ||
|
||
sanitizeText(data["ProductSpu"], 64) ||
|
||
sanitizeText(data["商品SPU"], 64) ||
|
||
sanitizeText(data["商品spu"], 64) ||
|
||
sanitizeText(data["款号"], 64) ||
|
||
sanitizeText(data["商品款号"], 64) ||
|
||
null
|
||
);
|
||
}
|
||
|
||
function getGoodsInfoMatchedSku(data: Record<string, unknown>) {
|
||
return (
|
||
sanitizeText(data["sku"], 64) ||
|
||
sanitizeText(data["SKU"], 64) ||
|
||
sanitizeText(data["skuId"], 64) ||
|
||
sanitizeText(data["SkuId"], 64) ||
|
||
sanitizeText(data["skuoid"], 64) ||
|
||
sanitizeText(data["SKUOID"], 64) ||
|
||
sanitizeText(data["商品SKU"], 64) ||
|
||
sanitizeText(data["商品sku"], 64) ||
|
||
null
|
||
);
|
||
}
|
||
|
||
async function fetchGoodsLookupByKeyword(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) {
|
||
const data = result.data;
|
||
const brandName = getGoodsInfoBrandName(data);
|
||
const matchedSpu = getGoodsInfoMatchedSpu(data);
|
||
const matchedSku = getGoodsInfoMatchedSku(data);
|
||
if (brandName || matchedSpu || matchedSku) {
|
||
return {
|
||
brandName,
|
||
matchedSpu,
|
||
matchedSku,
|
||
};
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn("[Agent Workflow Reference Search] Goods info lookup 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,
|
||
previewUrl: 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<typeof normalizeAgentWorkflowMaterialApiConfig>;
|
||
}) {
|
||
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,
|
||
};
|
||
}
|
||
|
||
function buildProductDetailRequestUrl(params: {
|
||
productId: string;
|
||
channel: AgentWorkflowProductDetailChannel;
|
||
method: "GET" | "POST";
|
||
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
|
||
}) {
|
||
const baseUrl = normalizeBaseUrl(params.config.productDetailBaseUrl || "");
|
||
const channel = normalizeProductDetailChannel(
|
||
params.channel,
|
||
normalizeProductDetailChannel(params.config.productDetailDefaultChannel)
|
||
);
|
||
const rawPath =
|
||
String(params.config.productDetailPath || "").trim() ||
|
||
(params.method === "POST"
|
||
? "/open-api/detail-images/query"
|
||
: "/open-api/detail-images");
|
||
if (!baseUrl && !/^https?:\/\//i.test(rawPath)) {
|
||
throw new Error("商品ID详情图接口基地址未配置");
|
||
}
|
||
|
||
let requestUrl = /^https?:\/\//i.test(rawPath)
|
||
? rawPath
|
||
: `${baseUrl}/${rawPath.replace(/^\/+/, "")}`;
|
||
const encodedProductId = encodeURIComponent(params.productId);
|
||
const encodedChannel = encodeURIComponent(channel);
|
||
const beforeReplace = requestUrl;
|
||
requestUrl = requestUrl
|
||
.replace(/\{\{\s*item_id\s*\}\}/gi, encodedProductId)
|
||
.replace(/\{\s*item_id\s*\}/gi, encodedProductId)
|
||
.replace(/\{\{\s*productId\s*\}\}/gi, encodedProductId)
|
||
.replace(/\{\s*productId\s*\}/gi, encodedProductId)
|
||
.replace(/\{\{\s*channel\s*\}\}/gi, encodedChannel)
|
||
.replace(/\{\s*channel\s*\}/gi, encodedChannel);
|
||
|
||
if (params.method === "GET") {
|
||
const url = new URL(requestUrl);
|
||
if (!String(url.searchParams.get("item_id") || "").trim()) {
|
||
url.searchParams.set("item_id", params.productId);
|
||
}
|
||
url.searchParams.set(
|
||
"channel",
|
||
normalizeProductDetailChannel(url.searchParams.get("channel"), channel)
|
||
);
|
||
if (!url.searchParams.has("refresh")) {
|
||
url.searchParams.set("refresh", "false");
|
||
}
|
||
if (!url.searchParams.has("sync_analysis")) {
|
||
url.searchParams.set(
|
||
"sync_analysis",
|
||
params.config.productDetailSyncAnalysis === false ? "0" : "1"
|
||
);
|
||
}
|
||
if (!url.searchParams.has("force_reanalyze")) {
|
||
url.searchParams.set(
|
||
"force_reanalyze",
|
||
params.config.productDetailForceReanalyze === true ? "1" : "0"
|
||
);
|
||
}
|
||
requestUrl = url.toString();
|
||
} else if (requestUrl === beforeReplace) {
|
||
return requestUrl;
|
||
}
|
||
|
||
return requestUrl;
|
||
}
|
||
|
||
function getProductDetailResponseData(value: unknown) {
|
||
const raw =
|
||
value && typeof value === "object" && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
return raw.data && typeof raw.data === "object" && !Array.isArray(raw.data)
|
||
? (raw.data as Record<string, unknown>)
|
||
: {};
|
||
}
|
||
|
||
function extractProductDetailImageUrls(value: unknown) {
|
||
const matchedUrls = new Set<string>();
|
||
const keyPattern = /(detail|desc|description|product).*?(image|img|pic|url|urls)|(image|img|pic|url|urls).*?(detail|desc|description|product)|^(images?|imageUrls?|detailImages?|detailImageUrls?|pics?|urls?)$/i;
|
||
|
||
const visit = (input: unknown, keyHint = "", forced = false, depth = 0) => {
|
||
if (depth > 8 || matchedUrls.size >= 64) return;
|
||
|
||
if (typeof input === "string") {
|
||
const normalized = input.trim();
|
||
if ((forced || keyPattern.test(keyHint)) && /^https?:\/\//i.test(normalized)) {
|
||
matchedUrls.add(normalized);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (Array.isArray(input)) {
|
||
const nextForced = forced || keyPattern.test(keyHint);
|
||
input.forEach((item) => visit(item, keyHint, nextForced, depth + 1));
|
||
return;
|
||
}
|
||
|
||
if (!input || typeof input !== "object") {
|
||
return;
|
||
}
|
||
|
||
Object.entries(input as Record<string, unknown>).forEach(([key, child]) => {
|
||
visit(child, key, forced || keyPattern.test(key), depth + 1);
|
||
});
|
||
};
|
||
|
||
visit(value, Array.isArray(value) ? "images" : "", Array.isArray(value), 0);
|
||
return Array.from(matchedUrls);
|
||
}
|
||
|
||
async function requestProductDetailReferences(params: {
|
||
productId: string;
|
||
channel: AgentWorkflowProductDetailChannel;
|
||
target: "main" | "detail";
|
||
config: ReturnType<typeof normalizeAgentWorkflowMaterialApiConfig>;
|
||
}) {
|
||
const channel = normalizeProductDetailChannel(
|
||
params.channel,
|
||
normalizeProductDetailChannel(params.config.productDetailDefaultChannel)
|
||
);
|
||
|
||
if (!params.config.productDetailEnabled) {
|
||
return jsonNoStore(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "PRODUCT_DETAIL_API_DISABLED",
|
||
message: "商品ID详情图接口未启用,请先在系统设置中配置",
|
||
},
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
if (!params.config.productDetailBaseUrl && !params.config.productDetailPath) {
|
||
return jsonNoStore(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "PRODUCT_DETAIL_API_NOT_CONFIGURED",
|
||
message: "商品ID详情图接口地址未配置,请先在系统设置中填写",
|
||
},
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
const apiKey = String(params.config.productDetailApiKey || params.config.apiKey || "").trim();
|
||
if (!apiKey) {
|
||
return jsonNoStore(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "PRODUCT_DETAIL_API_KEY_MISSING",
|
||
message: "商品ID详情图接口 API Key 未配置,请先在系统设置中填写",
|
||
},
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
|
||
const method = params.config.productDetailMethod === "POST" ? "POST" : "GET";
|
||
const requestUrl = buildProductDetailRequestUrl({
|
||
...params,
|
||
channel,
|
||
method,
|
||
});
|
||
const requestBody = {
|
||
item_id: params.productId,
|
||
channel,
|
||
refresh: false,
|
||
sync_analysis: params.config.productDetailSyncAnalysis !== false,
|
||
force_reanalyze: params.config.productDetailForceReanalyze === true,
|
||
};
|
||
const startedAt = Date.now();
|
||
console.info("[Agent Workflow Reference Search] Product detail request:", {
|
||
method,
|
||
productId: params.productId,
|
||
channel,
|
||
url: getProductDetailRequestLogUrl(requestUrl),
|
||
});
|
||
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(requestUrl, {
|
||
method,
|
||
headers: {
|
||
Accept: "application/json",
|
||
Connection: "close",
|
||
"User-Agent": "curl/8.0",
|
||
"X-API-KEY": apiKey,
|
||
...(method === "POST" ? { "Content-Type": "application/json" } : {}),
|
||
},
|
||
body: method === "POST" ? JSON.stringify(requestBody) : undefined,
|
||
cache: "no-store",
|
||
});
|
||
} catch (error) {
|
||
console.warn("[Agent Workflow Reference Search] Product detail request failed:", {
|
||
method,
|
||
productId: params.productId,
|
||
channel,
|
||
elapsedMs: Date.now() - startedAt,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
});
|
||
return jsonNoStore(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "PRODUCT_DETAIL_API_FETCH_FAILED",
|
||
message: "商品ID详情图接口访问失败,请稍后重试或检查接口网络",
|
||
},
|
||
},
|
||
{ status: 502 }
|
||
);
|
||
}
|
||
const result = await response.json().catch(() => ({}));
|
||
const responseRecord =
|
||
result && typeof result === "object" && !Array.isArray(result)
|
||
? (result as Record<string, unknown>)
|
||
: {};
|
||
if (responseRecord.success === false) {
|
||
const errorCode = sanitizeText(responseRecord.error, 128) || "PRODUCT_DETAIL_API_ERROR";
|
||
const errorMessage =
|
||
sanitizeText(responseRecord.message, 255) ||
|
||
sanitizeText(responseRecord.error, 255) ||
|
||
"商品ID详情图查询失败";
|
||
return jsonNoStore(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: errorCode,
|
||
message: errorMessage,
|
||
},
|
||
},
|
||
{ status: response.ok ? 502 : response.status }
|
||
);
|
||
}
|
||
if (!response.ok) {
|
||
return jsonNoStore(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "PRODUCT_DETAIL_API_ERROR",
|
||
message: `商品ID详情图查询失败(${response.status})`,
|
||
},
|
||
},
|
||
{ status: 502 }
|
||
);
|
||
}
|
||
|
||
const productData = getProductDetailResponseData(result);
|
||
const detailImageUrls = normalizeUrlList(productData.detailImages);
|
||
const fallbackDetailImageUrls =
|
||
detailImageUrls.length > 0 ? detailImageUrls : extractProductDetailImageUrls(result);
|
||
const mainImageUrls = normalizeUrlList(productData.mainImages);
|
||
const resolvedTarget = params.target === "main" ? "main" : "detail";
|
||
if (resolvedTarget === "detail" && fallbackDetailImageUrls.length === 0) {
|
||
return jsonNoStore(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "PRODUCT_DETAIL_IMAGES_NOT_FOUND",
|
||
message: "商品ID接口未返回可用详情图片",
|
||
},
|
||
},
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
if (resolvedTarget === "main" && mainImageUrls.length === 0) {
|
||
return jsonNoStore(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "PRODUCT_MAIN_IMAGES_NOT_FOUND",
|
||
message: "商品ID接口未返回可用主图",
|
||
},
|
||
},
|
||
{ status: 404 }
|
||
);
|
||
}
|
||
const resolvedItemId =
|
||
sanitizeText(productData.itemId, 128) ||
|
||
sanitizeText(productData.sourceItemId, 128) ||
|
||
params.productId;
|
||
const title = sanitizeText(productData.title, 255);
|
||
const shopName = sanitizeText(productData.shopName, 128) || null;
|
||
const channelLabel =
|
||
sanitizeText(productData.channelLabel, 32) ||
|
||
(channel === "taobao"
|
||
? "淘宝"
|
||
: channel === "douyin"
|
||
? "抖音"
|
||
: channel === "jd"
|
||
? "京东"
|
||
: "1688");
|
||
|
||
const candidate: AgentModeReferenceCandidate = {
|
||
id: `product-id-${channel}-${resolvedItemId}`,
|
||
materialId: resolvedItemId,
|
||
taskNo: sanitizeText(responseRecord.request_id, 128) || null,
|
||
productModel: title || resolvedItemId,
|
||
brandName: null,
|
||
categoryName: null,
|
||
shopName,
|
||
sceneName: title || `${channelLabel}商品 ${resolvedItemId}`,
|
||
uploadedAt: sanitizeText(productData.fetchedAt, 64) || new Date().toISOString(),
|
||
isFullSet: true,
|
||
mainImageUrls,
|
||
skuImageUrls: [],
|
||
detailImageUrls: fallbackDetailImageUrls,
|
||
allImageUrls: Array.from(new Set([...mainImageUrls, ...fallbackDetailImageUrls])),
|
||
};
|
||
const references = buildReferenceItems({
|
||
candidate,
|
||
target: resolvedTarget,
|
||
});
|
||
|
||
return jsonNoStore({
|
||
success: true,
|
||
data: {
|
||
query: resolvedItemId,
|
||
queryType: "productId" as const,
|
||
target: resolvedTarget,
|
||
matchedSpu: null,
|
||
matchedSku: resolvedItemId,
|
||
brandName: null,
|
||
categoryName: null,
|
||
shopName,
|
||
channel,
|
||
candidateCount: 1,
|
||
candidates: [candidate],
|
||
selectedCandidateId: candidate.id,
|
||
references,
|
||
},
|
||
});
|
||
}
|
||
|
||
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,
|
||
channel: request.nextUrl.searchParams.get("channel") || undefined,
|
||
strictQueryType:
|
||
request.nextUrl.searchParams.get("strictQueryType") || 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
|
||
);
|
||
const queryType = parsed.queryType as AgentWorkflowReferenceQueryType;
|
||
const normalizedKeyword =
|
||
queryType === "productId" ? parsed.keyword.trim() : parsed.keyword.trim().toUpperCase();
|
||
if (queryType === "productId") {
|
||
if (parsed.target !== "detail" && parsed.target !== "main") {
|
||
return jsonNoStore(
|
||
{
|
||
success: false,
|
||
error: {
|
||
code: "UNSUPPORTED_PRODUCT_ID_TARGET",
|
||
message: "商品ID查询当前仅支持主图或详情图",
|
||
},
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
const productDetailChannel = normalizeProductDetailChannel(
|
||
parsed.channel,
|
||
normalizeProductDetailChannel(config.productDetailDefaultChannel)
|
||
);
|
||
return requestProductDetailReferences({
|
||
productId: normalizedKeyword,
|
||
channel: productDetailChannel,
|
||
target: parsed.target,
|
||
config,
|
||
});
|
||
}
|
||
|
||
if (!config.enabled) {
|
||
return jsonNoStore(
|
||
{ error: "素材搜索功能未开启,请先在系统设置中启用" },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
if (!config.apiKey) {
|
||
return jsonNoStore(
|
||
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
|
||
const materialQueryType = queryType as AgentWorkflowMaterialQueryType;
|
||
const requestedBrandName = String(parsed.brandName || "").trim();
|
||
const goodsLookup = await fetchGoodsLookupByKeyword({
|
||
keyword: normalizedKeyword,
|
||
config: goodsInfoConfig,
|
||
}).catch((error) => {
|
||
console.warn("[Agent Workflow Reference Search] Goods info lookup failed:", error);
|
||
return null;
|
||
});
|
||
const verifiedGoodsBrandName = goodsLookup?.brandName || null;
|
||
const resolvedMaterialKeyword =
|
||
materialQueryType === "spu" && goodsLookup?.matchedSpu
|
||
? goodsLookup.matchedSpu.trim().toUpperCase()
|
||
: normalizedKeyword;
|
||
if (materialQueryType === "spu" && resolvedMaterialKeyword !== normalizedKeyword) {
|
||
console.info("[Agent Workflow Reference Search] Resolved SPU query keyword:", {
|
||
keyword: normalizedKeyword,
|
||
resolvedSpu: resolvedMaterialKeyword,
|
||
matchedSku: goodsLookup?.matchedSku || 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 strictQueryType = parsed.strictQueryType === "true" || parsed.strictQueryType === "1";
|
||
const queryTypesToTry = strictQueryType
|
||
? [materialQueryType]
|
||
: Array.from(
|
||
new Set<AgentWorkflowMaterialQueryType>([
|
||
materialQueryType,
|
||
materialQueryType === "spu" ? "sku" : "spu",
|
||
])
|
||
);
|
||
|
||
let selectedAttempt:
|
||
| Awaited<ReturnType<typeof requestTaskDetailAttempt>>
|
||
| null = null;
|
||
let firstUpstreamFailure:
|
||
| Awaited<ReturnType<typeof requestTaskDetailAttempt>>
|
||
| null = null;
|
||
let firstEmptyAttempt:
|
||
| Awaited<ReturnType<typeof requestTaskDetailAttempt>>
|
||
| null = null;
|
||
|
||
for (const currentQueryType of queryTypesToTry) {
|
||
const attempt = await requestTaskDetailAttempt({
|
||
keyword: resolvedMaterialKeyword,
|
||
queryType: currentQueryType,
|
||
target: parsed.target,
|
||
requestedBrandName,
|
||
verifiedGoodsBrandName,
|
||
config,
|
||
});
|
||
|
||
if (requestedBrandName) {
|
||
console.info("[Agent Workflow Reference Search] Brand gate:", {
|
||
keyword: normalizedKeyword,
|
||
resolvedKeyword: resolvedMaterialKeyword,
|
||
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 !== materialQueryType) {
|
||
console.info("[Agent Workflow Reference Search] Query type fallback succeeded:", {
|
||
keyword: normalizedKeyword,
|
||
resolvedKeyword: resolvedMaterialKeyword,
|
||
requestedQueryType: materialQueryType,
|
||
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,
|
||
resolvedQuery: resolvedMaterialKeyword,
|
||
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 }
|
||
);
|
||
}
|
||
}
|