Your Name 1c1f7026d5
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: keep first detail reference image
2026-03-23 19:07:38 +08:00

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 }
);
}
}