333 lines
11 KiB
TypeScript
333 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { auth } from "@/app/(auth)/auth";
|
|
import { getSystemSetting } from "@/lib/db/queries";
|
|
import {
|
|
AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY,
|
|
normalizeAgentWorkflowMaterialApiConfig,
|
|
} from "@/lib/agents/workflow-material-config";
|
|
import type {
|
|
AgentDetailReplacementReferenceItem,
|
|
AgentModeReferenceCandidate,
|
|
AgentWorkflowMaterialQueryType,
|
|
} from "@/types/agent";
|
|
|
|
export const maxDuration = 180;
|
|
|
|
const querySchema = z.object({
|
|
keyword: z.string().trim().min(1).max(64),
|
|
queryType: z.enum(["sku", "spu"]).default("spu"),
|
|
target: z.enum(["main", "detail", "sku"]),
|
|
storeName: z.string().trim().max(64).optional(),
|
|
});
|
|
|
|
type ExternalTaskDetailCandidate = {
|
|
materialId?: string;
|
|
taskNo?: string;
|
|
productModel?: string;
|
|
categoryName?: string;
|
|
shopName?: string;
|
|
sceneName?: string;
|
|
uploadedAt?: string;
|
|
isFullSet?: boolean;
|
|
downloadUrls?: {
|
|
main?: string[];
|
|
sku?: string[];
|
|
detail?: string[];
|
|
all?: string[];
|
|
};
|
|
};
|
|
|
|
type ExternalTaskDetailResponse = {
|
|
success?: boolean;
|
|
data?: {
|
|
resourceType?: string;
|
|
spu?: string | null;
|
|
sku?: string | null;
|
|
categoryName?: string | null;
|
|
shopName?: string | null;
|
|
candidateCount?: number;
|
|
candidates?: ExternalTaskDetailCandidate[];
|
|
};
|
|
error?: {
|
|
code?: string;
|
|
message?: string;
|
|
};
|
|
};
|
|
|
|
function normalizeBaseUrl(value: string) {
|
|
return String(value || "").trim().replace(/\/+$/, "");
|
|
}
|
|
|
|
function normalizeShopName(value: unknown) {
|
|
return String(value || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s+/g, "");
|
|
}
|
|
|
|
function normalizeUrlList(value: unknown) {
|
|
if (!Array.isArray(value)) return [] as string[];
|
|
return Array.from(
|
|
new Set(
|
|
value
|
|
.map((item) => String(item || "").trim())
|
|
.filter((item) => /^https?:\/\//i.test(item))
|
|
)
|
|
).slice(0, 64);
|
|
}
|
|
|
|
function mapUpstreamErrorStatus(code: string) {
|
|
if (
|
|
code === "SKU_NOT_FOUND" ||
|
|
code === "SPU_NOT_FOUND" ||
|
|
code === "TASK_DETAIL_NOT_FOUND" ||
|
|
code === "FULL_SET_NOT_FOUND"
|
|
) {
|
|
return 404;
|
|
}
|
|
if (code === "INVALID_API_KEY") {
|
|
return 500;
|
|
}
|
|
if (code === "SKU_NOT_UNIQUE") {
|
|
return 409;
|
|
}
|
|
return 502;
|
|
}
|
|
|
|
function normalizeCandidate(
|
|
value: ExternalTaskDetailCandidate,
|
|
index: number
|
|
): AgentModeReferenceCandidate {
|
|
const uploadedAt = String(value.uploadedAt || "").trim() || null;
|
|
const taskNo = String(value.taskNo || "").trim() || null;
|
|
const materialId = String(value.materialId || "").trim() || null;
|
|
const sceneName = String(value.sceneName || "").trim() || null;
|
|
const id = taskNo || materialId || `candidate-${index + 1}`;
|
|
|
|
return {
|
|
id,
|
|
materialId,
|
|
taskNo,
|
|
productModel: String(value.productModel || "").trim() || null,
|
|
categoryName: String(value.categoryName || "").trim() || null,
|
|
shopName: String(value.shopName || "").trim() || null,
|
|
sceneName,
|
|
uploadedAt,
|
|
isFullSet: value.isFullSet !== false,
|
|
mainImageUrls: normalizeUrlList(value.downloadUrls?.main),
|
|
skuImageUrls: normalizeUrlList(value.downloadUrls?.sku),
|
|
detailImageUrls: normalizeUrlList(value.downloadUrls?.detail),
|
|
allImageUrls: normalizeUrlList(value.downloadUrls?.all),
|
|
};
|
|
}
|
|
|
|
function getCandidateTargetUrls(
|
|
candidate: AgentModeReferenceCandidate,
|
|
target: "main" | "detail" | "sku"
|
|
) {
|
|
if (target === "main") return candidate.mainImageUrls;
|
|
return target === "detail" ? candidate.detailImageUrls : candidate.skuImageUrls;
|
|
}
|
|
|
|
function getRenderableCandidateTargetUrls(
|
|
candidate: AgentModeReferenceCandidate,
|
|
target: "main" | "detail" | "sku"
|
|
) {
|
|
return getCandidateTargetUrls(candidate, target);
|
|
}
|
|
|
|
function buildReferenceItems(params: {
|
|
candidate: AgentModeReferenceCandidate;
|
|
target: "main" | "detail" | "sku";
|
|
}): AgentDetailReplacementReferenceItem[] {
|
|
const urls = getRenderableCandidateTargetUrls(params.candidate, params.target);
|
|
const targetLabel =
|
|
params.target === "main"
|
|
? "主图参考图"
|
|
: params.target === "detail"
|
|
? "详情参考图"
|
|
: "SKU参考图";
|
|
const prefix =
|
|
params.candidate.sceneName ||
|
|
params.candidate.taskNo ||
|
|
params.candidate.productModel ||
|
|
"候选整套";
|
|
|
|
return urls.map((url, index) => ({
|
|
id: `${params.candidate.id}-${params.target}-${index + 1}`,
|
|
label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`,
|
|
imageUrl: url,
|
|
candidateId: params.candidate.id,
|
|
sortOrder: index,
|
|
}));
|
|
}
|
|
|
|
function sortCandidatesByUploadedAtDesc(candidates: AgentModeReferenceCandidate[]) {
|
|
return candidates.slice().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;
|
|
});
|
|
}
|
|
|
|
function isCandidateMatchedStore(
|
|
candidate: AgentModeReferenceCandidate,
|
|
storeName?: string
|
|
) {
|
|
const normalizedRequestedStore = normalizeShopName(storeName);
|
|
if (!normalizedRequestedStore) return true;
|
|
|
|
const normalizedShopName = normalizeShopName(candidate.shopName);
|
|
const normalizedCategoryName = normalizeShopName(candidate.categoryName);
|
|
|
|
return (
|
|
normalizedShopName === normalizedRequestedStore ||
|
|
normalizedCategoryName === normalizedRequestedStore ||
|
|
(Boolean(normalizedShopName) && normalizedShopName.includes(normalizedRequestedStore)) ||
|
|
(Boolean(normalizedShopName) && normalizedRequestedStore.includes(normalizedShopName)) ||
|
|
(Boolean(normalizedCategoryName) && normalizedCategoryName.includes(normalizedRequestedStore)) ||
|
|
(Boolean(normalizedCategoryName) && normalizedRequestedStore.includes(normalizedCategoryName))
|
|
);
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const session = await auth();
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
}
|
|
|
|
const parsed = querySchema.parse({
|
|
keyword: request.nextUrl.searchParams.get("keyword"),
|
|
queryType: request.nextUrl.searchParams.get("queryType") || "spu",
|
|
target: request.nextUrl.searchParams.get("target"),
|
|
storeName: request.nextUrl.searchParams.get("storeName") || undefined,
|
|
});
|
|
|
|
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY });
|
|
const config = normalizeAgentWorkflowMaterialApiConfig(setting?.value);
|
|
if (!config.enabled) {
|
|
return NextResponse.json(
|
|
{ error: "素材搜索功能未开启,请先在系统设置中启用" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
if (!config.apiKey) {
|
|
return NextResponse.json(
|
|
{ error: "素材搜索 API Key 未配置,请先在系统设置中保存" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
const normalizedKeyword = parsed.keyword.trim().toUpperCase();
|
|
const queryType = parsed.queryType as AgentWorkflowMaterialQueryType;
|
|
const upstreamUrl = `${normalizeBaseUrl(
|
|
config.baseUrl
|
|
)}/standard-model-white-background?resourceType=task-detail&${queryType}=${encodeURIComponent(
|
|
normalizedKeyword
|
|
)}`;
|
|
|
|
const upstreamResponse = await fetch(upstreamUrl, {
|
|
method: "GET",
|
|
headers: {
|
|
"X-API-Key": config.apiKey,
|
|
},
|
|
cache: "no-store",
|
|
});
|
|
const upstreamResult = (await upstreamResponse.json().catch(() => ({}))) as ExternalTaskDetailResponse;
|
|
|
|
if (!upstreamResponse.ok || upstreamResult.success !== true || !upstreamResult.data) {
|
|
const errorCode = String(upstreamResult.error?.code || "UPSTREAM_ERROR").trim();
|
|
const errorMessage =
|
|
String(upstreamResult.error?.message || "").trim() ||
|
|
`参考图搜索失败(${upstreamResponse.status})`;
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: {
|
|
code: errorCode,
|
|
message: errorMessage,
|
|
},
|
|
},
|
|
{ status: mapUpstreamErrorStatus(errorCode) }
|
|
);
|
|
}
|
|
|
|
const requestedStoreName = String(parsed.storeName || "").trim();
|
|
const normalizedCandidates = sortCandidatesByUploadedAtDesc(
|
|
(upstreamResult.data.candidates || []).map((item, index) => normalizeCandidate(item, index))
|
|
)
|
|
.filter((candidate) => isCandidateMatchedStore(candidate, requestedStoreName))
|
|
.filter((candidate) => getRenderableCandidateTargetUrls(candidate, parsed.target).length > 0);
|
|
|
|
if (normalizedCandidates.length === 0) {
|
|
return NextResponse.json(
|
|
{
|
|
success: false,
|
|
error: {
|
|
code: requestedStoreName
|
|
? "STORE_REFERENCE_NOT_FOUND"
|
|
: parsed.target === "main"
|
|
? "MAIN_REFERENCE_NOT_FOUND"
|
|
: parsed.target === "detail"
|
|
? "DETAIL_REFERENCE_NOT_FOUND"
|
|
: "SKU_REFERENCE_NOT_FOUND",
|
|
message: requestedStoreName
|
|
? `当前店铺「${requestedStoreName}」下没有可用的${
|
|
parsed.target === "main"
|
|
? "主图参考图"
|
|
: parsed.target === "detail"
|
|
? "详情参考图"
|
|
: "SKU参考图"
|
|
}`
|
|
: parsed.target === "main"
|
|
? "当前整套素材中没有可用的主图参考图"
|
|
: parsed.target === "detail"
|
|
? "当前整套素材中没有可用的详情参考图"
|
|
: "当前整套素材中没有可用的 SKU 参考图",
|
|
},
|
|
},
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
const selectedCandidate = normalizedCandidates[0];
|
|
const references = buildReferenceItems({
|
|
candidate: selectedCandidate,
|
|
target: parsed.target,
|
|
});
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
data: {
|
|
query: normalizedKeyword,
|
|
queryType,
|
|
target: parsed.target,
|
|
matchedSpu: String(upstreamResult.data.spu || "").trim() || null,
|
|
matchedSku: String(upstreamResult.data.sku || "").trim() || null,
|
|
categoryName: selectedCandidate.categoryName || String(upstreamResult.data.categoryName || "").trim() || null,
|
|
shopName: selectedCandidate.shopName || String(upstreamResult.data.shopName || "").trim() || null,
|
|
candidateCount: normalizedCandidates.length,
|
|
candidates: normalizedCandidates,
|
|
selectedCandidateId: selectedCandidate.id,
|
|
references,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error("[Agent Workflow Reference Search] Error:", error);
|
|
|
|
if (error instanceof z.ZodError) {
|
|
return NextResponse.json(
|
|
{ error: "搜索参数无效", details: error.errors },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: "参考图搜索失败,请稍后重试" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|