diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index 02e3727a9..bdb086c42 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -58,6 +58,10 @@ import { defaultAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfig, } from "@/lib/agents/workflow-material-config"; +import { + defaultAgentWorkflowGoodsInfoApiConfig, + normalizeAgentWorkflowGoodsInfoApiConfig, +} from "@/lib/agents/workflow-goods-info-config"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -76,6 +80,7 @@ import type { AgentPresetLibrarySection, AgentWorkflowBrandModelConfig, AgentWorkflowBrandStoreEntry, + AgentWorkflowGoodsInfoApiConfig, AgentWorkflowMaterialApiConfig, AgentWorkflowOptionUiConfig, AgentWorkflowShowcaseConfig, @@ -396,6 +401,7 @@ export function AgentWorkflowSettingsClient({ agentPresetLibraryConfig?: AgentPresetLibraryConfig; agentWorkflowUiConfig?: AgentWorkflowUiConfig; agentWorkflowMaterialApiConfig?: AgentWorkflowMaterialApiConfig; + agentWorkflowGoodsInfoApiConfig?: AgentWorkflowGoodsInfoApiConfig; agentWorkflowBrandModelConfig?: AgentWorkflowBrandModelConfig; agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig; }>("/api/site-settings", fetcher); @@ -406,6 +412,9 @@ export function AgentWorkflowSettingsClient({ const [materialApiConfig, setMaterialApiConfig] = useState( defaultAgentWorkflowMaterialApiConfig ); + const [goodsInfoApiConfig, setGoodsInfoApiConfig] = useState( + defaultAgentWorkflowGoodsInfoApiConfig + ); const [showcaseConfig, setShowcaseConfig] = useState( defaultAgentWorkflowShowcaseConfig ); @@ -438,6 +447,9 @@ export function AgentWorkflowSettingsClient({ setMaterialApiConfig( normalizeAgentWorkflowMaterialApiConfig(data.agentWorkflowMaterialApiConfig) ); + setGoodsInfoApiConfig( + normalizeAgentWorkflowGoodsInfoApiConfig(data.agentWorkflowGoodsInfoApiConfig) + ); setShowcaseConfig( normalizeAgentWorkflowShowcaseConfig(data.agentWorkflowShowcaseConfig) ); @@ -887,6 +899,7 @@ export function AgentWorkflowSettingsClient({ agentPresetLibraryConfig: presetLibraryConfig, agentWorkflowUiConfig: config, agentWorkflowMaterialApiConfig: materialApiConfig, + agentWorkflowGoodsInfoApiConfig: goodsInfoApiConfig, agentWorkflowBrandModelConfig: brandModelConfig, agentWorkflowShowcaseConfig: showcaseConfig, }), @@ -2054,6 +2067,79 @@ export function AgentWorkflowSettingsClient({ + + + +
+
+ +
+
+ SKU 商品信息接口 + 配置货号 / 条码查询商品资料接口 +
+
+
+ +
+
+
启用商品信息查询
+
+ 开启后 SKU 查询结果会显示商品名称、尺寸、材质、设计师、采购负责人、板材类型 +
+
+ + setGoodsInfoApiConfig((prev) => ({ ...prev, enabled: checked })) + } + /> +
+ +
+ + + setGoodsInfoApiConfig((prev) => ({ ...prev, baseUrl: e.target.value })) + } + placeholder="https://www.jiansun.vip:63211" + /> +
+ +
+
+ + + setGoodsInfoApiConfig((prev) => ({ + ...prev, + clientKey: e.target.value, + })) + } + placeholder="例如:yunyingb" + /> +
+
+ + + setGoodsInfoApiConfig((prev) => ({ + ...prev, + secret: e.target.value, + })) + } + placeholder="输入 Secret" + /> +
+
+
+
+
+ diff --git a/app/api/agent-workflow/material-search/route.ts b/app/api/agent-workflow/material-search/route.ts index d1163520a..730634e22 100644 --- a/app/api/agent-workflow/material-search/route.ts +++ b/app/api/agent-workflow/material-search/route.ts @@ -1,4 +1,5 @@ import { generateText } from "ai"; +import { createCipheriv, createHash } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; @@ -9,6 +10,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"; export const maxDuration = 180; @@ -62,10 +67,114 @@ type ExternalSearchResponse = { }; }; +type ExternalGoodsInfoResponse = { + success?: boolean; + errMsg?: string | null; + data?: Record | null; +}; + function normalizeBaseUrl(value: string) { return String(value || "").trim().replace(/\/+$/, ""); } +function sanitizeGoodsInfoText(value: unknown, maxLength = 255) { + return String(value || "").trim().slice(0, maxLength); +} + +function createGoodsInfoSignature(timespan: string, secret: string) { + const secretBuffer = Buffer.from(String(secret || "").trim(), "utf8"); + if (secretBuffer.length !== 8) { + throw new Error("商品信息接口 Secret 长度必须为 8 位"); + } + + const cipher = createCipheriv("des-cbc", secretBuffer, secretBuffer); + cipher.setAutoPadding(true); + const encrypted = Buffer.concat([ + cipher.update(String(timespan || "").trim(), "utf8"), + cipher.final(), + ]); + const encryptedHex = encrypted.toString("hex").toUpperCase(); + return createHash("md5").update(encryptedHex, "utf8").digest("hex").toUpperCase(); +} + +function buildGoodsInfoSummary(data: Record) { + const productName = + sanitizeGoodsInfoText(data["商品名称"], 255) || + sanitizeGoodsInfoText(data["名称"], 255) || + null; + const sizeInfoParts = [ + sanitizeGoodsInfoText(data["商品尺寸"], 128), + [data["长"], data["宽"], data["高"]] + .map((item) => sanitizeGoodsInfoText(item, 32)) + .filter(Boolean) + .join(" x "), + ].filter(Boolean); + const materialInfoParts = [ + sanitizeGoodsInfoText(data["材质"], 128), + sanitizeGoodsInfoText(data["材质类型"], 128), + ].filter(Boolean); + + return { + productName, + sizeInfo: sizeInfoParts.join(" · ") || null, + materialInfo: materialInfoParts.join(" · ") || null, + designer: sanitizeGoodsInfoText(data["设计师"], 128) || null, + buyer: sanitizeGoodsInfoText(data["采购负责人"], 128) || null, + boardType: sanitizeGoodsInfoText(data["板材类型"], 128) || null, + matchedSpu: + sanitizeGoodsInfoText(data["spu"], 64) || + sanitizeGoodsInfoText(data["SPU"], 64) || + null, + }; +} + +async function fetchGoodsInfoByKeyword(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 && typeof result.data === "object") { + return buildGoodsInfoSummary(result.data); + } + } catch (error) { + console.warn("[Agent Workflow Material Search] Goods info query failed:", { + requestPath, + error, + }); + } + } + + return null; +} + function normalizeImageIdentityText(...values: Array) { return values .map((value) => String(value || "").trim().toLowerCase().replace(/\s+/g, "")) @@ -564,6 +673,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 NextResponse.json( { error: "素材搜索功能未开启,请先在系统设置中启用" }, @@ -643,12 +758,23 @@ export async function GET(request: NextRequest) { totalCount: skuOptions.length, images: [], skuOptions, + goodsInfo: null, }, }); } const matchedSku = String(upstreamResult.data.sku || normalizedKeyword).trim() || normalizedKeyword; const productModel = String(upstreamResult.data.productModel || "").trim() || null; + const goodsInfo = + queryType === "sku" + ? await fetchGoodsInfoByKeyword({ + keyword: normalizedKeyword, + config: goodsInfoConfig, + }).catch((error) => { + console.warn("[Agent Workflow Material Search] Goods info fetch failed:", error); + return null; + }) + : null; const requiredCount = Number.isFinite(Number(parsed.requiredCount)) ? Math.max(1, Math.min(4, Math.round(Number(parsed.requiredCount)))) : null; @@ -734,7 +860,7 @@ export async function GET(request: NextRequest) { query: normalizedKeyword, queryType: "sku", matchedSku, - matchedSpu: productModel, + matchedSpu: goodsInfo?.matchedSpu || productModel, materialId: String(upstreamResult.data.materialId || "").trim() || null, productModel, zipUrl: String(upstreamResult.data.downloadUrls?.zip || "").trim() || null, @@ -744,6 +870,16 @@ export async function GET(request: NextRequest) { images: importedImages, recommendedImageIds, skuOptions: [], + goodsInfo: goodsInfo + ? { + productName: goodsInfo.productName, + sizeInfo: goodsInfo.sizeInfo, + materialInfo: goodsInfo.materialInfo, + designer: goodsInfo.designer, + buyer: goodsInfo.buyer, + boardType: goodsInfo.boardType, + } + : null, }, }); } catch (error) { diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index aa4d47aa6..99eca1294 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -46,6 +46,12 @@ import { normalizeAgentWorkflowMaterialApiConfig, normalizeAgentWorkflowMaterialApiConfigForPublic, } from "@/lib/agents/workflow-material-config"; +import { + AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY, + defaultAgentWorkflowGoodsInfoApiConfig, + normalizeAgentWorkflowGoodsInfoApiConfig, + normalizeAgentWorkflowGoodsInfoApiConfigForPublic, +} from "@/lib/agents/workflow-goods-info-config"; import { AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY, defaultAgentWorkflowShowcaseConfig, @@ -245,6 +251,7 @@ export async function GET() { agentSecondaryTypeConfigSetting, agentWorkflowUiConfigSetting, agentWorkflowMaterialApiConfigSetting, + agentWorkflowGoodsInfoApiConfigSetting, agentWorkflowBrandModelConfigSetting, agentWorkflowShowcaseConfigSetting, ] = await Promise.all([ @@ -375,6 +382,12 @@ export async function GET() { .where(eq(systemSettings.key, AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY)) .limit(1) .then(rows => rows[0]), + db + .select() + .from(systemSettings) + .where(eq(systemSettings.key, AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY)) + .limit(1) + .then(rows => rows[0]), db .select() .from(systemSettings) @@ -439,6 +452,11 @@ export async function GET() { : normalizeAgentWorkflowMaterialApiConfigForPublic( agentWorkflowMaterialApiConfigSetting?.value ); + const agentWorkflowGoodsInfoApiConfig = canManageAgentWorkflow + ? normalizeAgentWorkflowGoodsInfoApiConfig(agentWorkflowGoodsInfoApiConfigSetting?.value) + : normalizeAgentWorkflowGoodsInfoApiConfigForPublic( + agentWorkflowGoodsInfoApiConfigSetting?.value + ); const agentWorkflowBrandModelConfig = normalizeAgentWorkflowBrandModelConfig( agentWorkflowBrandModelConfigSetting?.value ); @@ -475,6 +493,7 @@ export async function GET() { agentSecondaryTypeConfig: defaultAgentSecondaryTypeConfig, agentWorkflowUiConfig: defaultAgentWorkflowUiConfig, agentWorkflowMaterialApiConfig: defaultAgentWorkflowMaterialApiConfig, + agentWorkflowGoodsInfoApiConfig: defaultAgentWorkflowGoodsInfoApiConfig, agentWorkflowBrandModelConfig: defaultAgentWorkflowBrandModelConfig, agentWorkflowShowcaseConfig: defaultAgentWorkflowShowcaseConfig, realtimeCanvasEnabled: true, @@ -524,6 +543,7 @@ export async function GET() { agentSecondaryTypeConfig, agentWorkflowUiConfig, agentWorkflowMaterialApiConfig, + agentWorkflowGoodsInfoApiConfig, agentWorkflowBrandModelConfig, agentWorkflowShowcaseConfig, membershipConfig, @@ -696,6 +716,14 @@ const updateSchema = z.object({ apiKey: z.string().trim().max(500).optional(), }) .optional(), + agentWorkflowGoodsInfoApiConfig: z + .object({ + enabled: z.boolean().optional(), + baseUrl: z.string().trim().max(255).optional(), + clientKey: z.string().trim().max(255).optional(), + secret: z.string().trim().max(255).optional(), + }) + .optional(), agentWorkflowBrandModelConfig: z .object({ entries: z @@ -807,6 +835,7 @@ export async function PUT(request: NextRequest) { "agentPresetLibraryConfig", "agentWorkflowUiConfig", "agentWorkflowMaterialApiConfig", + "agentWorkflowGoodsInfoApiConfig", "agentWorkflowBrandModelConfig", "agentWorkflowShowcaseConfig", ]); @@ -829,6 +858,7 @@ export async function PUT(request: NextRequest) { agentSecondaryTypeConfig, agentWorkflowUiConfig, agentWorkflowMaterialApiConfig, + agentWorkflowGoodsInfoApiConfig, agentWorkflowBrandModelConfig, agentWorkflowShowcaseConfig, membershipConfig, @@ -1097,6 +1127,28 @@ export async function PUT(request: NextRequest) { }); } + if (agentWorkflowGoodsInfoApiConfig !== undefined) { + const normalized = normalizeAgentWorkflowGoodsInfoApiConfig( + agentWorkflowGoodsInfoApiConfig + ); + await db + .insert(systemSettings) + .values({ + key: AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY, + value: normalized, + updatedAt: new Date(), + updatedBy: session.user.id, + }) + .onConflictDoUpdate({ + target: systemSettings.key, + set: { + value: normalized, + updatedAt: new Date(), + updatedBy: session.user.id, + }, + }); + } + if (agentWorkflowBrandModelConfig !== undefined) { const normalized = normalizeAgentWorkflowBrandModelConfig( agentWorkflowBrandModelConfig @@ -1323,6 +1375,10 @@ export async function PUT(request: NextRequest) { agentWorkflowMaterialApiConfig !== undefined ? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig) : defaultAgentWorkflowMaterialApiConfig, + agentWorkflowGoodsInfoApiConfig: + agentWorkflowGoodsInfoApiConfig !== undefined + ? normalizeAgentWorkflowGoodsInfoApiConfig(agentWorkflowGoodsInfoApiConfig) + : defaultAgentWorkflowGoodsInfoApiConfig, agentWorkflowBrandModelConfig: agentWorkflowBrandModelConfig !== undefined ? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig) @@ -1868,6 +1924,10 @@ export async function PUT(request: NextRequest) { agentWorkflowMaterialApiConfig !== undefined ? normalizeAgentWorkflowMaterialApiConfig(agentWorkflowMaterialApiConfig) : defaultAgentWorkflowMaterialApiConfig, + agentWorkflowGoodsInfoApiConfig: + agentWorkflowGoodsInfoApiConfig !== undefined + ? normalizeAgentWorkflowGoodsInfoApiConfig(agentWorkflowGoodsInfoApiConfig) + : defaultAgentWorkflowGoodsInfoApiConfig, agentWorkflowBrandModelConfig: agentWorkflowBrandModelConfig !== undefined ? normalizeAgentWorkflowBrandModelConfig(agentWorkflowBrandModelConfig) diff --git a/components/agents/AgentWorkflowMaterialPicker.tsx b/components/agents/AgentWorkflowMaterialPicker.tsx index cbe2122c2..085933339 100644 --- a/components/agents/AgentWorkflowMaterialPicker.tsx +++ b/components/agents/AgentWorkflowMaterialPicker.tsx @@ -73,6 +73,33 @@ export function AgentWorkflowMaterialPicker({ const hasSelectableImages = Boolean( result?.images?.some((item) => Boolean(String(item.imageUrl || "").trim())) ); + const goodsInfoItems = [ + { + label: "商品名称", + value: result?.goodsInfo?.productName || null, + fullWidth: true, + }, + { + label: "尺寸信息", + value: result?.goodsInfo?.sizeInfo || null, + }, + { + label: "材质信息", + value: result?.goodsInfo?.materialInfo || null, + }, + { + label: "设计师", + value: result?.goodsInfo?.designer || null, + }, + { + label: "采购负责人", + value: result?.goodsInfo?.buyer || null, + }, + { + label: "板材类型", + value: result?.goodsInfo?.boardType || null, + }, + ].filter((item) => Boolean(item.value)); const remainingSelectableCount = resolvedSelectionLimit === null ? null : Math.max(resolvedSelectionLimit - selectedCount, 0); @@ -309,6 +336,36 @@ export function AgentWorkflowMaterialPicker({ + {goodsInfoItems.length > 0 ? ( +
+ {goodsInfoItems.map((item) => ( +
+
+ {item.label} +
+
+ {item.value} +
+
+ ))} +
+ ) : null} + {result.skuOptions?.length ? (
{result.skuOptions.map((option) => ( diff --git a/lib/agents/workflow-goods-info-config.ts b/lib/agents/workflow-goods-info-config.ts new file mode 100644 index 000000000..2c4914504 --- /dev/null +++ b/lib/agents/workflow-goods-info-config.ts @@ -0,0 +1,44 @@ +import type { AgentWorkflowGoodsInfoApiConfig } from "@/types/agent"; + +export const AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY = + "agent_workflow_goods_info_api_config"; + +export const defaultAgentWorkflowGoodsInfoApiConfig: AgentWorkflowGoodsInfoApiConfig = { + enabled: false, + baseUrl: "https://www.jiansun.vip:63211", + clientKey: "", + secret: "", +}; + +function sanitizeText(value: unknown, maxLength: number) { + return String(value || "").trim().slice(0, maxLength); +} + +export function normalizeAgentWorkflowGoodsInfoApiConfig( + value: unknown +): AgentWorkflowGoodsInfoApiConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return defaultAgentWorkflowGoodsInfoApiConfig; + } + + const raw = value as Partial; + + return { + enabled: raw.enabled === true, + baseUrl: + sanitizeText(raw.baseUrl, 255) || defaultAgentWorkflowGoodsInfoApiConfig.baseUrl, + clientKey: sanitizeText(raw.clientKey, 255), + secret: sanitizeText(raw.secret, 255), + }; +} + +export function normalizeAgentWorkflowGoodsInfoApiConfigForPublic( + value: unknown +): AgentWorkflowGoodsInfoApiConfig { + const config = normalizeAgentWorkflowGoodsInfoApiConfig(value); + return { + ...config, + clientKey: "", + secret: "", + }; +} diff --git a/lib/agents/workflow-material-config.ts b/lib/agents/workflow-material-config.ts index 9cc34f464..5d2d946d9 100644 --- a/lib/agents/workflow-material-config.ts +++ b/lib/agents/workflow-material-config.ts @@ -121,6 +121,17 @@ function normalizeMaterialSearchResult(value: unknown): AgentWorkflowMaterialSea ) : [], skuOptions, + goodsInfo: + raw.goodsInfo && typeof raw.goodsInfo === "object" && !Array.isArray(raw.goodsInfo) + ? { + productName: sanitizeText((raw.goodsInfo as Record).productName, 255) || null, + sizeInfo: sanitizeText((raw.goodsInfo as Record).sizeInfo, 255) || null, + materialInfo: sanitizeText((raw.goodsInfo as Record).materialInfo, 255) || null, + designer: sanitizeText((raw.goodsInfo as Record).designer, 128) || null, + buyer: sanitizeText((raw.goodsInfo as Record).buyer, 128) || null, + boardType: sanitizeText((raw.goodsInfo as Record).boardType, 128) || null, + } + : null, }; } diff --git a/types/agent.ts b/types/agent.ts index 7b284cc3e..2d04f1553 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -118,6 +118,13 @@ export interface AgentWorkflowMaterialApiConfig { apiKey: string; } +export interface AgentWorkflowGoodsInfoApiConfig { + enabled: boolean; + baseUrl: string; + clientKey: string; + secret: string; +} + export type AgentWorkflowMaterialQueryType = "sku" | "spu"; export interface AgentWorkflowBrandModelImage { @@ -190,6 +197,14 @@ export interface AgentWorkflowMaterialSearchResult { images: AgentWorkflowMaterialImage[]; recommendedImageIds?: string[]; skuOptions?: AgentWorkflowMaterialSkuOption[]; + goodsInfo?: { + productName?: string | null; + sizeInfo?: string | null; + materialInfo?: string | null; + designer?: string | null; + buyer?: string | null; + boardType?: string | null; + } | null; } export interface AgentWorkflowMaterialSelectionState {