From 5fb1b5e65216da62150fcddc7b19cf6dcb3c80a1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 27 Apr 2026 22:24:52 +0800 Subject: [PATCH] fix: validate reference brand by goods info --- .../agent-workflow/reference-search/route.ts | 177 +++++++++++++++++- .../agents/AgentGenerationWorkspaceDarkV2.tsx | 24 +-- 2 files changed, 187 insertions(+), 14 deletions(-) diff --git a/app/api/agent-workflow/reference-search/route.ts b/app/api/agent-workflow/reference-search/route.ts index 114ce5094..1c35071b2 100644 --- a/app/api/agent-workflow/reference-search/route.ts +++ b/app/api/agent-workflow/reference-search/route.ts @@ -1,4 +1,6 @@ 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"; @@ -6,6 +8,10 @@ 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, @@ -82,10 +88,20 @@ type ExternalTaskDetailResponse = { }; }; +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() @@ -127,6 +143,115 @@ function getExternalBrandName(value: { ); } +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" || @@ -227,11 +352,18 @@ function sortCandidatesByUploadedAtDesc(candidates: AgentModeReferenceCandidate[ 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(candidate.brandName); + const normalizedBrandName = normalizeComparableName(candidateBrandName); if (!normalizedBrandName) return false; return ( @@ -269,6 +401,12 @@ export async function GET(request: NextRequest) { 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: "素材搜索功能未开启,请先在系统设置中启用" }, @@ -285,12 +423,39 @@ export async function GET(request: NextRequest) { 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, }); if (requestedBrandName) { upstreamParams.set("brandName", requestedBrandName); + upstreamParams.set("brand", requestedBrandName); + upstreamParams.set("productBrand", requestedBrandName); } const upstreamUrl = `${normalizeBaseUrl( config.baseUrl @@ -323,9 +488,15 @@ export async function GET(request: NextRequest) { } const upstreamBrandName = getExternalBrandName(upstreamResult.data); + const fallbackBrandName = + upstreamBrandName || + (verifiedGoodsBrandName && + isBrandNameMatched(verifiedGoodsBrandName, requestedBrandName) + ? verifiedGoodsBrandName + : null); const sortedCandidates = sortCandidatesByUploadedAtDesc( (upstreamResult.data.candidates || []).map((item, index) => - normalizeCandidate(item, index, upstreamBrandName) + normalizeCandidate(item, index, fallbackBrandName) ) ); const normalizedCandidates = filterCandidatesByBrand(sortedCandidates, requestedBrandName) @@ -378,7 +549,7 @@ export async function GET(request: NextRequest) { matchedSku: String(upstreamResult.data.sku || "").trim() || null, brandName: selectedCandidate.brandName || - upstreamBrandName || + fallbackBrandName || requestedBrandName || null, categoryName: selectedCandidate.categoryName || String(upstreamResult.data.categoryName || "").trim() || null, diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index da43320d1..7528e8af5 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -1347,7 +1347,11 @@ export function AgentGenerationWorkspaceDarkV2({ const skuReferenceTarget = presetModeConfig.sku.referenceImageSource; const detailReferenceSourceLabel = getTaskDetailReferenceSourceLabel(detailReferenceTarget); const skuReferenceSourceLabel = getTaskDetailReferenceSourceLabel(skuReferenceTarget); - const workflowBrandName = String(workflowContext?.brand || "").trim(); + const getCurrentWorkflowBrandName = useCallback( + () => String((getLatestWorkflowContext?.() ?? workflowContext)?.brand || "").trim(), + [getLatestWorkflowContext, workflowContext] + ); + const workflowBrandName = getCurrentWorkflowBrandName(); const shouldAllowModelOverride = isDirectModelGenerationMode || !isSceneGenerationPreset || presetModeConfig.scene.allowModelOverride; const brandModelAttachmentKeys = useMemo(() => { @@ -3969,7 +3973,8 @@ export function AgentGenerationWorkspaceDarkV2({ } const handleDetailReplacementSearch = useCallback(async () => { const normalized = detailReplacementQueryInput.trim().toUpperCase(); - if (!workflowBrandName) { + const currentWorkflowBrandName = getCurrentWorkflowBrandName(); + if (!currentWorkflowBrandName) { toast.error("请先在第一步完成品牌选择后再查询参考图"); return; } @@ -3989,9 +3994,7 @@ export function AgentGenerationWorkspaceDarkV2({ queryType: "spu", target: detailReferenceTarget, }); - if (workflowBrandName) { - searchParams.set("brandName", workflowBrandName); - } + searchParams.set("brandName", currentWorkflowBrandName); const response = await fetch( `/api/agent-workflow/reference-search?${searchParams.toString()}`, { cache: "no-store" } @@ -4026,8 +4029,8 @@ export function AgentGenerationWorkspaceDarkV2({ detailReferenceSourceLabel, detailReferenceTarget, detailReplacementQueryInput, + getCurrentWorkflowBrandName, updateDetailReplacementState, - workflowBrandName, ]); const handleSelectDetailReplacementCandidate = useCallback((candidateId: string) => { updateDetailReplacementState((current) => { @@ -4069,7 +4072,8 @@ export function AgentGenerationWorkspaceDarkV2({ ); const handleSkuReplacementSearch = useCallback(async () => { const normalized = skuReplacementQueryInput.trim().toUpperCase(); - if (!workflowBrandName) { + const currentWorkflowBrandName = getCurrentWorkflowBrandName(); + if (!currentWorkflowBrandName) { toast.error("请先在第一步完成品牌选择后再查询参考图"); return; } @@ -4089,9 +4093,7 @@ export function AgentGenerationWorkspaceDarkV2({ queryType: skuReplacementQueryType, target: skuReferenceTarget, }); - if (workflowBrandName) { - searchParams.set("brandName", workflowBrandName); - } + searchParams.set("brandName", currentWorkflowBrandName); const response = await fetch( `/api/agent-workflow/reference-search?${searchParams.toString()}`, { cache: "no-store" } @@ -4135,8 +4137,8 @@ export function AgentGenerationWorkspaceDarkV2({ skuReferenceTarget, skuReplacementQueryInput, skuReplacementQueryType, + getCurrentWorkflowBrandName, updateSkuReplacementState, - workflowBrandName, ]); const handleSelectSkuReplacementCandidate = useCallback((candidateId: string) => { updateSkuReplacementState((current) => {