gongxiangp2/lib/external-image-assets.ts

187 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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