2026-06-02 13:50:52 +08:00

1155 lines
35 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 { 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 }
);
}
}