ai-chatbot/lib/agents/third-party-image-material-service.ts

427 lines
14 KiB
TypeScript

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<string, unknown>;
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function getDownloadUrls(value: unknown) {
return getRecord(value).downloadUrls &&
typeof getRecord(value).downloadUrls === "object" &&
!Array.isArray(getRecord(value).downloadUrls)
? (getRecord(value).downloadUrls as Record<string, unknown>)
: {};
}
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<string, unknown>;
}) {
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<typeof item> => 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<typeof item> => 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<string, unknown>;
}) {
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,
};
}