import "server-only"; import { createHash } from "node:crypto"; import { getSystemSetting } from "@/lib/db/queries"; import { AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, buildAgentWorkflowMaterialApiUrl, isAgentWorkflowMaterialResourceEnabled, normalizeAgentWorkflowMaterialApiConfig, } from "@/lib/agents/workflow-material-config"; import type { AgentWorkflowMaterialQueryType, AgentWorkflowMaterialResourceType, } from "@/types/agent"; type UpstreamMaterialResponse = { success?: boolean; data?: Record; error?: { code?: string; message?: string; }; }; type UpstreamImageRecord = { key?: string; label?: string; fileName?: string; fileSize?: number; previewUrl?: string; downloadUrl?: string; }; type ThirdPartyImageMaterialDiagnostics = { status: number; traceId: string | null; serverDurationMs: string | null; serverTiming: string | null; resourceType: string | null; requestSku: string | null; requestSpu: string | null; }; export class ThirdPartyImageMaterialError extends Error { readonly code: string; readonly status: number; constructor(code: string, message: string, status = 500) { super(message); this.name = "ThirdPartyImageMaterialError"; this.code = code; this.status = status; } } function sanitizeText(value: unknown, maxLength = 500) { return String(value || "").trim().slice(0, maxLength); } function normalizeUrlList(value: unknown) { if (!Array.isArray(value)) return []; return Array.from( new Set( value .map((item) => sanitizeText(item, 2000)) .filter((item) => /^https?:\/\//i.test(item)) ) ); } function inferMimeTypeFromFilename(filename: string) { const normalized = filename.toLowerCase(); if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg"; if (normalized.endsWith(".webp")) return "image/webp"; if (normalized.endsWith(".gif")) return "image/gif"; return "image/png"; } function getRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function getDownloadUrls(value: unknown) { return getRecord(value).downloadUrls && typeof getRecord(value).downloadUrls === "object" && !Array.isArray(getRecord(value).downloadUrls) ? (getRecord(value).downloadUrls as Record) : {}; } function mapUpstreamErrorStatus(code: string) { if (code === "MISSING_PARAM" || code === "RESOURCE_TYPE_INVALID") return 400; if ( code === "SKU_NOT_FOUND" || code === "SPU_NOT_FOUND" || code === "NO_SKUS" || code === "WHITE_BACKGROUND_NOT_FOUND" || code === "TASK_DETAIL_NOT_FOUND" || code === "FULL_SET_NOT_FOUND" ) { return 404; } if (code === "SKU_NOT_UNIQUE") return 409; if (code === "INVALID_API_KEY") return 500; return 502; } function readDiagnostics(response: Response): ThirdPartyImageMaterialDiagnostics { return { status: response.status, traceId: response.headers.get("x-trace-id"), serverDurationMs: response.headers.get("x-server-duration-ms"), serverTiming: response.headers.get("server-timing"), resourceType: response.headers.get("x-resource-type"), requestSku: response.headers.get("x-request-sku"), requestSpu: response.headers.get("x-request-spu"), }; } function mapWhiteBackgroundImage(params: { matchedSku: string; group: "fixed" | "other"; sortOrder: number; image: UpstreamImageRecord; }) { const previewUrl = sanitizeText(params.image.previewUrl, 2000); const downloadUrl = sanitizeText(params.image.downloadUrl, 2000); const imageUrl = previewUrl || downloadUrl; if (!imageUrl) return null; const filename = sanitizeText(params.image.fileName, 255) || sanitizeText(params.image.label, 128) || `${params.matchedSku}-${params.group}-${params.sortOrder + 1}.webp`; const identityHash = createHash("sha1") .update(`${params.group}:${params.sortOrder}:${imageUrl}`, "utf8") .digest("hex") .slice(0, 12); return { id: `third-party-${params.group}-${params.sortOrder + 1}-${identityHash}`, key: sanitizeText(params.image.key, 64) || `${params.group}-${params.sortOrder + 1}`, label: sanitizeText(params.image.label, 128) || `图片${params.sortOrder + 1}`, fileName: filename, fileSize: Number.isFinite(Number(params.image.fileSize)) ? Math.max(0, Math.round(Number(params.image.fileSize))) : null, imageUrl, previewUrl: previewUrl || null, downloadUrl: downloadUrl || null, mimeType: inferMimeTypeFromFilename(filename), group: params.group, sortOrder: params.sortOrder, }; } function normalizeWhiteBackgroundData(params: { query: string; queryType: AgentWorkflowMaterialQueryType; data: Record; }) { const data = params.data; const matchedSku = sanitizeText(data.sku, 64); const matchedSpu = sanitizeText(data.spu, 64) || sanitizeText(data.productModel, 128) || null; const skuList = Array.isArray(data.skuList) ? data.skuList : []; const skuOptions = skuList .map((item) => { const raw = getRecord(item); const skuName = sanitizeText(raw.skuName, 64); if (!skuName) return null; const previewImage = getRecord(raw.previewImage); return { skuName, skuId: sanitizeText(raw.skuId, 128) || null, hasWhiteBackground: raw.hasWhiteBackground === true, whiteBackgroundCount: Number.isFinite(Number(raw.whiteBackgroundCount)) ? Math.max(0, Math.round(Number(raw.whiteBackgroundCount))) : 0, previewImageUrl: sanitizeText(previewImage.previewUrl, 2000) || null, downloadUrl: sanitizeText(previewImage.downloadUrl, 2000) || null, previewFileName: sanitizeText(previewImage.fileName, 255) || null, previewFileSize: Number.isFinite(Number(previewImage.fileSize)) ? Math.max(0, Math.round(Number(previewImage.fileSize))) : null, }; }) .filter((item): item is NonNullable => Boolean(item)); const whiteBackground = getRecord(data.whiteBackground); const fixedImages = Array.isArray(whiteBackground.fixed) ? whiteBackground.fixed : []; const otherImages = Array.isArray(whiteBackground.other) ? whiteBackground.other : []; const images = [ ...fixedImages.map((image, index) => mapWhiteBackgroundImage({ matchedSku: matchedSku || params.query, group: "fixed", sortOrder: index, image: getRecord(image), }) ), ...otherImages.map((image, index) => mapWhiteBackgroundImage({ matchedSku: matchedSku || params.query, group: "other", sortOrder: fixedImages.length + index, image: getRecord(image), }) ), ].filter((item): item is NonNullable => Boolean(item)); return { resourceType: "white-background" as const, query: params.query, queryType: params.queryType, matchedSku: matchedSku || null, matchedSpu, materialId: sanitizeText(data.materialId, 128) || null, productModel: sanitizeText(data.productModel, 128) || matchedSpu, categoryName: sanitizeText(data.categoryName, 128) || null, shopName: sanitizeText(data.shopName, 128) || sanitizeText(data.categoryName, 128) || null, whiteBackgroundUploaded: data.whiteBackgroundUploaded === true, whiteBackgroundComplete: data.whiteBackgroundComplete === true, fixedCount: Number.isFinite(Number(data.whiteBackgroundFixedCount)) ? Math.max(0, Math.round(Number(data.whiteBackgroundFixedCount))) : images.filter((item) => item.group === "fixed").length, otherCount: Number.isFinite(Number(data.whiteBackgroundOtherCount)) ? Math.max(0, Math.round(Number(data.whiteBackgroundOtherCount))) : images.filter((item) => item.group === "other").length, totalCount: Number.isFinite(Number(data.whiteBackgroundCount)) ? Math.max(0, Math.round(Number(data.whiteBackgroundCount))) : images.length || skuOptions.length, missingViews: Array.isArray(data.missingViews) ? data.missingViews : [], images, skuOptions, downloadUrls: { zip: sanitizeText(getDownloadUrls(data).zip, 2000) || null, }, }; } function normalizeTaskDetailData(params: { query: string; queryType: AgentWorkflowMaterialQueryType; data: Record; }) { const candidates = (Array.isArray(params.data.candidates) ? params.data.candidates : []) .map((item, index) => { const raw = getRecord(item); const downloadUrls = getDownloadUrls(raw); const mainImageUrls = normalizeUrlList(downloadUrls.main); const skuImageUrls = normalizeUrlList(downloadUrls.sku); const detailImageUrls = normalizeUrlList(downloadUrls.detail); const allImageUrls = normalizeUrlList(downloadUrls.all); const mergedAllImageUrls = allImageUrls.length > 0 ? allImageUrls : Array.from(new Set([...mainImageUrls, ...skuImageUrls, ...detailImageUrls])); const materialId = sanitizeText(raw.materialId, 128) || null; const taskNo = sanitizeText(raw.taskNo, 128) || null; return { id: taskNo || materialId || `candidate-${index + 1}`, materialId, taskNo, productModel: sanitizeText(raw.productModel, 128) || null, categoryName: sanitizeText(raw.categoryName, 128) || null, shopName: sanitizeText(raw.shopName, 128) || sanitizeText(raw.categoryName, 128) || null, sceneName: sanitizeText(raw.sceneName, 128) || null, uploadedAt: sanitizeText(raw.uploadedAt, 64) || null, isFullSet: raw.isFullSet !== false, mainImageUrls, skuImageUrls, detailImageUrls, allImageUrls: mergedAllImageUrls, }; }) .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; }); return { resourceType: "task-detail" as const, query: params.query, queryType: params.queryType, matchedSku: sanitizeText(params.data.sku, 64) || null, matchedSpu: sanitizeText(params.data.spu, 64) || null, categoryName: sanitizeText(params.data.categoryName, 128) || null, shopName: sanitizeText(params.data.shopName, 128) || sanitizeText(params.data.categoryName, 128) || null, candidateCount: Number.isFinite(Number(params.data.candidateCount)) ? Math.max(0, Math.round(Number(params.data.candidateCount))) : candidates.length, candidates, }; } export async function getThirdPartyImageMaterialPublicConfig() { const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, }); const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value); return { enabled: config.enabled, endpoint: "/api/third-party-image-materials/search", queryTypes: ["sku", "spu"] as const, resourceTypes: [ { value: "white-background" as const, label: "标准模型白底图", enabled: config.enabled && isAgentWorkflowMaterialResourceEnabled(config, "white-background"), }, { value: "task-detail" as const, label: "任务-详情整套预览", enabled: config.enabled && isAgentWorkflowMaterialResourceEnabled(config, "task-detail"), }, ], requestTimeoutMs: config.requestTimeoutMs || 30_000, }; } export async function queryThirdPartyImageMaterials(params: { resourceType: AgentWorkflowMaterialResourceType; queryType: AgentWorkflowMaterialQueryType; keyword: string; }) { const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, }); const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value); const keyword = sanitizeText(params.keyword, 64).toUpperCase(); if (!config.enabled) { throw new ThirdPartyImageMaterialError( "MATERIAL_API_DISABLED", "第三方图片素材接口未开启,请先在系统设置中启用", 400 ); } if (!isAgentWorkflowMaterialResourceEnabled(config, params.resourceType)) { throw new ThirdPartyImageMaterialError( "RESOURCE_TYPE_DISABLED", params.resourceType === "task-detail" ? "任务-详情整套素材接口未开启,请先在系统设置中启用" : "白底图素材接口未开启,请先在系统设置中启用", 400 ); } if (!config.apiKey) { throw new ThirdPartyImageMaterialError( "MATERIAL_API_KEY_MISSING", "第三方图片素材 API Key 未配置,请先在系统设置中保存", 500 ); } if (!keyword) { throw new ThirdPartyImageMaterialError("MISSING_PARAM", "sku 和 spu 二选一必填", 400); } const upstreamUrl = buildAgentWorkflowMaterialApiUrl(config, { resourceType: params.resourceType, queryType: params.queryType, keyword, }); let response: Response; try { response = await fetch(upstreamUrl, { method: "GET", headers: { Accept: "application/json", "X-API-Key": config.apiKey, }, cache: "no-store", signal: AbortSignal.timeout(config.requestTimeoutMs || 30_000), }); } catch (error) { const message = error instanceof Error ? error.message : "上游接口访问失败"; throw new ThirdPartyImageMaterialError( "UPSTREAM_FETCH_FAILED", message.includes("timeout") ? "第三方图片素材接口访问超时" : "第三方图片素材接口访问失败", message.includes("timeout") ? 504 : 502 ); } const diagnostics = readDiagnostics(response); const upstreamResult = (await response.json().catch(() => ({}))) as UpstreamMaterialResponse; if (!response.ok || upstreamResult.success !== true || !upstreamResult.data) { const code = sanitizeText(upstreamResult.error?.code, 64) || "UPSTREAM_ERROR"; const message = sanitizeText(upstreamResult.error?.message, 500) || `第三方图片素材接口返回异常(${response.status})`; throw new ThirdPartyImageMaterialError(code, message, mapUpstreamErrorStatus(code)); } const data = params.resourceType === "task-detail" ? normalizeTaskDetailData({ query: keyword, queryType: params.queryType, data: upstreamResult.data, }) : normalizeWhiteBackgroundData({ query: keyword, queryType: params.queryType, data: upstreamResult.data, }); return { success: true, data, diagnostics, }; }