427 lines
14 KiB
TypeScript
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,
|
|
};
|
|
}
|