Your Name 8b9d9d42e0
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: gate detail references by selected brand
2026-04-27 23:19:18 +08:00

626 lines
19 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,
} 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<typeof NextResponse.json>[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<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
);
}
async function fetchGoodsBrandByKeyword(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 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,
}));
}
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);
}
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)
);
}
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)
) {
return jsonNoStore(
{
success: false,
error: {
code: "BRAND_REFERENCE_NOT_FOUND",
message: `当前搜索商品属于品牌「${verifiedGoodsBrandName}」,与当前选择品牌「${requestedBrandName}」不一致`,
},
},
{ status: 404 }
);
}
const upstreamParams = new URLSearchParams({
resourceType: "task-detail",
[queryType]: normalizedKeyword,
});
const upstreamUrl = `${normalizeBaseUrl(
config.baseUrl
)}/standard-model-white-background?${upstreamParams.toString()}`;
const upstreamResponse = await fetch(upstreamUrl, {
method: "GET",
headers: {
"X-API-Key": config.apiKey,
},
cache: "no-store",
});
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
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})`;
if (
requestedBrandName &&
(errorCode === "TASK_DETAIL_NOT_FOUND" || errorCode === "FULL_SET_NOT_FOUND")
) {
return jsonNoStore(
{
success: false,
error: {
code: "BRAND_REFERENCE_NOT_FOUND",
message: `当前品牌「${requestedBrandName}」下没有可用的${
parsed.target === "main"
? "主图参考图"
: parsed.target === "detail"
? "详情参考图"
: "SKU参考图"
}`,
},
},
{ status: 404 }
);
}
return jsonNoStore(
{
success: false,
error: {
code: errorCode,
message: errorMessage,
},
},
{ status: mapUpstreamErrorStatus(errorCode) }
);
}
const upstreamBrandName = getExternalBrandName(upstreamResult.data);
const rawCandidates = upstreamResult.data.candidates || [];
const candidateBrandSamples = Array.from(
new Set(
rawCandidates
.map((candidate) => getExternalBrandName(candidate))
.filter((brandName): brandName is string => Boolean(brandName))
)
).slice(0, 8);
if (requestedBrandName) {
console.info("[Agent Workflow Reference Search] Brand gate:", {
keyword: normalizedKeyword,
queryType,
target: parsed.target,
requestedBrandName,
verifiedGoodsBrandName,
upstreamBrandName,
candidateCount: rawCandidates.length,
candidateBrandSamples,
});
}
const hasVerifiedRequestedBrand =
Boolean(verifiedGoodsBrandName) &&
isBrandNameMatched(verifiedGoodsBrandName, requestedBrandName);
const hasUpstreamRequestedBrand =
Boolean(upstreamBrandName) &&
isBrandNameMatched(upstreamBrandName, requestedBrandName);
let fallbackBrandName: string | null = null;
if (requestedBrandName) {
if (hasVerifiedRequestedBrand) {
fallbackBrandName = verifiedGoodsBrandName || null;
} else if (hasUpstreamRequestedBrand) {
fallbackBrandName = upstreamBrandName || null;
}
} else {
fallbackBrandName = upstreamBrandName;
}
const sortedCandidates = sortCandidatesByUploadedAtDesc(
rawCandidates.map((item, index) =>
normalizeCandidate(item, index, fallbackBrandName)
)
);
const normalizedCandidates = filterCandidatesByBrand(sortedCandidates, requestedBrandName)
.filter((candidate) => getRenderableCandidateTargetUrls(candidate, parsed.target).length > 0);
if (normalizedCandidates.length === 0) {
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 selectedCandidate = normalizedCandidates[0];
const references = buildReferenceItems({
candidate: selectedCandidate,
target: parsed.target,
});
return jsonNoStore({
success: true,
data: {
query: normalizedKeyword,
queryType,
target: parsed.target,
matchedSpu: String(upstreamResult.data.spu || "").trim() || null,
matchedSku: String(upstreamResult.data.sku || "").trim() || null,
brandName:
selectedCandidate.brandName ||
fallbackBrandName ||
null,
categoryName: selectedCandidate.categoryName || String(upstreamResult.data.categoryName || "").trim() || null,
shopName: selectedCandidate.shopName || String(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 }
);
}
}