import path from 'path' import { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' import { generateDownloadUrl } from '@/lib/download-token' import { getPublicAppOrigin } from '@/lib/public-app-url' import { parseStandardModelWhiteBgAssetType } from '@/lib/standard-model-white-background' // These are resource categories, not filename guesses. Models, PSD templates, // video and task-other attachments are deliberately outside this API's scope. export const EXTERNAL_IMAGE_RESOURCE_TYPES = { image: ['实拍图片', '买家秀图片', '任务-实拍原图', '任务-视频截图', '任务-详情', '标准模型'], 'ai-original': ['任务-AI原图'], 'render-original': ['任务-3D原图'], } as const type ResourceType = keyof typeof EXTERNAL_IMAGE_RESOURCE_TYPES export class ExternalImageAssetsError extends Error { constructor(readonly status: number, readonly code: string, message: string) { super(message) } } export interface ExternalImageQuery { type: 'sku' | 'barcode' value: string resourceType: ResourceType | 'all' page: number pageSize: number } export function parseExternalImageQuery(params: URLSearchParams): ExternalImageQuery { const allowed = new Set(['sku', 'barcode', 'resourceType', 'page', 'pageSize']) for (const key of Array.from(params.keys())) { if (!allowed.has(key) || params.getAll(key).length !== 1) { throw new ExternalImageAssetsError(400, 'INVALID_QUERY', '查询参数无效或重复') } } const sku = (params.get('sku') || '').trim(), barcode = (params.get('barcode') || '').trim() if (Boolean(sku) === Boolean(barcode)) throw new ExternalImageAssetsError(400, 'INVALID_QUERY', 'sku 与 barcode 必须且只能提供一个') const value = (sku || barcode).normalize('NFC') if (value.length > 200 || /[\x00-\x1f\x7f]/.test(value)) throw new ExternalImageAssetsError(400, 'INVALID_QUERY', '产品编号无效') const resourceType = params.get('resourceType') || 'all' if (resourceType !== 'all' && !Object.hasOwn(EXTERNAL_IMAGE_RESOURCE_TYPES, resourceType)) { throw new ExternalImageAssetsError(400, 'INVALID_RESOURCE_TYPE', 'resourceType 仅支持 image、ai-original、render-original 或 all') } const integer = (key: string, fallback: number, max: number) => { const raw = params.get(key) if (raw === null) return fallback if (!/^[1-9]\d{0,5}$/.test(raw) || Number(raw) > max) throw new ExternalImageAssetsError(400, 'INVALID_PAGINATION', '分页参数无效') return Number(raw) } return { type: sku ? 'sku' : 'barcode', value: sku ? value.toUpperCase() : value, resourceType: resourceType as ExternalImageQuery['resourceType'], page: integer('page', 1, 10000), pageSize: integer('pageSize', 20, 50) } } const VISIBLE: Prisma.MaterialWhereInput = { deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false } const equal = (value: string) => ({ equals: value, mode: 'insensitive' as const }) async function resolveProduct(query: ExternalImageQuery) { // Reuse the database's SKU and barcode bindings; never derive an SPU by // truncating a SKU or use a broad title/name search for a product identity. const [skus, assets] = await Promise.all([ prisma.materialSku.findMany({ where: { deletedAt: null, material: { is: VISIBLE }, ...(query.type === 'sku' ? { skuName: equal(query.value) } : { barcodes: { some: { barcode: query.value, deletedAt: null } } }) }, select: { skuName: true, material: { select: { productModel: true } } }, take: 101, }), prisma.materialAsset.findMany({ where: { material: { is: VISIBLE }, ...(query.type === 'sku' ? { skuCode: equal(query.value) } : { barcodes: { some: { barcode: query.value } } }) }, select: { skuCode: true, material: { select: { productModel: true } } }, take: 101, }), ]) if (skus.length > 100 || assets.length > 100) throw new ExternalImageAssetsError(409, 'PRODUCT_BINDING_LIMIT', '产品绑定过多,请先核对 SKU/条码关联') const targets = [...skus.map(s => ({ sku: s.skuName, spu: s.material.productModel })), ...assets.map(a => ({ sku: a.skuCode, spu: a.material.productModel }))] if (!targets.length) throw new ExternalImageAssetsError(404, query.type === 'sku' ? 'SKU_NOT_FOUND' : 'BARCODE_NOT_FOUND', '未找到有效的产品编号绑定') const spus = Array.from(new Set(targets.map(t => t.spu.trim().toUpperCase()).filter(Boolean))) const skuNames = Array.from(new Set(targets.map(t => t.sku?.trim().toUpperCase()).filter((s): s is string => Boolean(s)))) if (spus.length !== 1 || skuNames.length > 1) throw new ExternalImageAssetsError(409, 'PRODUCT_BINDING_AMBIGUOUS', '该编号关联多个产品或规格,请先核对绑定') return { sku: skuNames[0] || null, spu: spus[0], resourceScope: 'spu' as const } } const select = { id: true, productModel: true, materialType: true, taskNo: true, categoryName: true, createdAt: true, zipFilePath: true, zipFileName: true, zipFileSize: true, assets: { select: { id: true, assetType: true, fileUrl: true, fileName: true, fileSize: true, sortOrder: true }, orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] }, skus: { where: { deletedAt: null }, select: { id: true, skuName: true } }, } satisfies Prisma.MaterialSelect type ImageMaterial = Prisma.MaterialGetPayload<{ select: typeof select }> export interface ExternalImageResource { materialId: string assetId: string | null materialType: string resourceType: ResourceType taskNo: string | null productModel: string skuCode: string | null matchScope: 'sku' | 'spu' group: string | null fileName: string fileSize: number kind: 'image' | 'archive' | 'source' downloadUrl: string uploadedAt: string } /** Only the new image-source token reaches this resolver; old callers do not. */ export async function resolveExternalImageDownloadSource(material: { id: string; materialType: string; deletedAt?: Date | null; taskDetailPrivate?: boolean; isIncompleteShell?: boolean; zipFilePath?: string; zipFileName?: string; assets?: Array<{ id: string; assetType: string; fileUrl: string; fileName?: string | null }>; }, assetId?: string) { const allowed = Object.values(EXTERNAL_IMAGE_RESOURCE_TYPES).flat() as readonly string[] if (material.deletedAt || material.taskDetailPrivate || material.isIncompleteShell || !allowed.includes(material.materialType)) return null if (assetId) { const asset = material.assets?.find(a => a.id === assetId) if (!asset?.fileUrl.trim()) return null if (material.materialType === '标准模型') { const parsed = parseStandardModelWhiteBgAssetType(asset.assetType) if (!parsed || parsed.kind === 'zip') return null const sku = await prisma.materialSku.findFirst({ where: { id: parsed.skuId, materialId: material.id, deletedAt: null }, select: { id: true } }) return sku ? asset : null } const sourceTypes = material.materialType === '任务-详情' ? ['main', 'sku', 'detail', 'all'] : ['zip'] return sourceTypes.includes(asset.assetType) ? asset : null } if (['标准模型', '任务-详情'].includes(material.materialType) || !material.zipFilePath?.trim()) return null return { fileUrl: material.zipFilePath, fileName: material.zipFileName || leaf(material.zipFilePath) } } function leaf(value: string): string { try { return decodeURIComponent(path.posix.basename(new URL(value).pathname)) } catch { return path.posix.basename(value.replace(/\\/g, '/')) } } function kind(fileName: string, storedPath: string): ExternalImageResource['kind'] { const extension = (path.extname(fileName) || path.extname(leaf(storedPath))).toLowerCase() if (['.zip', '.rar'].includes(extension)) return 'archive' if (/^\.(png|jpe?g|webp|gif|bmp|tiff?|avif|heic|heif)$/.test(extension)) return 'image' return 'source' } export async function findExternalImageAssets(query: ExternalImageQuery) { const resolution = await resolveProduct(query) const types = query.resourceType === 'all' ? Object.values(EXTERNAL_IMAGE_RESOURCE_TYPES).flat() : [...EXTERNAL_IMAGE_RESOURCE_TYPES[query.resourceType]] const where: Prisma.MaterialWhereInput = { ...VISIBLE, productModel: equal(resolution.spu), materialType: { in: types } } const [totalMaterials, materials] = await Promise.all([ prisma.material.count({ where }), prisma.material.findMany({ where, select, orderBy: [{ createdAt: 'desc' }, { id: 'asc' }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }), ]) const baseUrl = getPublicAppOrigin(), items: ExternalImageResource[] = [] const issues: Array<{ materialId: string; code: string; message: string }> = [] const append = (m: ImageMaterial, source: { id?: string; fileUrl: string; fileName: string | null; fileSize: bigint }, group: string | null = null, skuCode: string | null = null) => { if (!source.fileUrl.trim()) return const resourceType = (Object.entries(EXTERNAL_IMAGE_RESOURCE_TYPES).find(([, values]) => (values as readonly string[]).includes(m.materialType))?.[0] || 'image') as ResourceType const size = Number(source.fileSize) items.push({ materialId: m.id, assetId: source.id || null, materialType: m.materialType, resourceType, taskNo: m.taskNo, productModel: m.productModel, skuCode, matchScope: skuCode ? 'sku' : 'spu', group, fileName: leaf(source.fileName || source.fileUrl), fileSize: Number.isSafeInteger(size) && size >= 0 ? size : 0, kind: kind(source.fileName || '', source.fileUrl), downloadUrl: generateDownloadUrl(baseUrl, { materialId: m.id, downloadType: 'image-source', ...(source.id ? { assetId: source.id } : {}) }), uploadedAt: m.createdAt.toISOString() }) } for (const m of materials) { const countBefore = items.length if (m.materialType === '标准模型') { // Standard-model source ZIPs contain 3D models, not image originals. // Only return white-background image assets belonging to the exact SKU. const ids = new Set(m.skus.filter(s => s.skuName.trim().toUpperCase() === resolution.sku).map(s => s.id)) for (const a of m.assets) { const parsed = parseStandardModelWhiteBgAssetType(a.assetType) if (parsed && parsed.kind !== 'zip' && ids.has(parsed.skuId)) append(m, a, 'white-background', resolution.sku) } } else if (m.materialType === '任务-详情') { const groups = m.assets.filter(a => ['main', 'sku', 'detail'].includes(a.assetType)) for (const a of groups) append(m, a, a.assetType) if (!groups.length) for (const a of m.assets.filter(a => a.assetType === 'all')) append(m, a, 'all') } else { const archives = m.assets.filter(a => a.assetType === 'zip' && a.fileUrl.trim()) if (archives.length) for (const a of archives) append(m, a) else if (m.zipFilePath.trim()) append(m, { fileUrl: m.zipFilePath, fileName: m.zipFileName, fileSize: m.zipFileSize }) } if (items.length === countBefore) issues.push({ materialId: m.id, code: 'SOURCE_NOT_AVAILABLE', message: '暂无对应的原始资源,不返回预览图代替' }) } return { query: { type: query.type, value: query.value, resourceType: query.resourceType }, resolution, count: items.length, items, issues, pagination: { page: query.page, pageSize: query.pageSize, totalMaterials, hasMore: query.page * query.pageSize < totalMaterials } } }