187 lines
11 KiB
TypeScript
187 lines
11 KiB
TypeScript
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 } }
|
||
}
|