fix: validate reference brand by goods info
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-27 22:24:52 +08:00
parent f83dc0aff3
commit 5fb1b5e652
2 changed files with 187 additions and 14 deletions

View File

@ -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<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()
@ -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<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" ||
@ -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,

View File

@ -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) => {