105 lines
7.4 KiB
TypeScript
105 lines
7.4 KiB
TypeScript
import path from 'path'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { canUserViewMaterial } from '@/lib/department-permission'
|
|
import { canAccessMaterialPrivacy } from '@/lib/task-detail-privacy'
|
|
import { getS3ObjectStream, getTrustedS3MutationKey, isS3Url } from '@/lib/s3'
|
|
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
|
|
import { readBoundedRegularFile } from '@/lib/bounded-file-reader'
|
|
import { isPathFreeOfSymbolicLinks } from '@/lib/path-security'
|
|
import { parseStandardModelWhiteBgAssetType } from '@/lib/standard-model-white-background'
|
|
import { DetailGeneratorError } from './access'
|
|
|
|
const MAX_IMAGE_BYTES = 16 * 1024 * 1024
|
|
const imagePath = (value: string) => /\.(png|jpe?g|webp)(?:\?|$)/i.test(value)
|
|
|
|
async function assertVisible(material: any, ownerId: string) {
|
|
if (material.deletedAt || material.isIncompleteShell || !(await canAccessMaterialPrivacy(material, ownerId)) || !(await canUserViewMaterial(undefined, '', undefined, material.materialType, ownerId))) {
|
|
throw new DetailGeneratorError('素材不可访问', 403, 'material_forbidden')
|
|
}
|
|
}
|
|
|
|
export async function findDetailMaterialMatches(ownerId: string, identifierType: unknown, identifier: unknown) {
|
|
if (!['sku', 'spu', 'barcode'].includes(String(identifierType)) || typeof identifier !== 'string' || !identifier.trim() || identifier.length > 200) throw new DetailGeneratorError('请提供有效的产品编号')
|
|
const value = identifier.trim().toUpperCase()
|
|
const match = identifierType === 'spu' ? { productModel: { equals: value, mode: 'insensitive' as const } }
|
|
: identifierType === 'sku' ? { OR: [{ skus: { some: { deletedAt: null, skuName: { equals: value, mode: 'insensitive' as const } } } }, { assets: { some: { skuCode: { equals: value, mode: 'insensitive' as const } } } }] }
|
|
: { OR: [{ skus: { some: { deletedAt: null, barcodes: { some: { barcode: value, deletedAt: null } } } } }, { assets: { some: { barcodes: { some: { barcode: value } } } } }] }
|
|
const rows = await prisma.material.findMany({
|
|
where: { deletedAt: null, isIncompleteShell: false, ...match }, take: 30,
|
|
include: { assets: { include: { barcodes: true }, orderBy: { sortOrder: 'asc' } }, skus: { where: { deletedAt: null }, include: { barcodes: { where: { deletedAt: null } } }, orderBy: { sortOrder: 'asc' } } },
|
|
})
|
|
const matches: any[] = []
|
|
for (const material of rows) {
|
|
try { await assertVisible(material, ownerId) } catch (error) { if (error instanceof DetailGeneratorError && error.status === 403) continue; throw error }
|
|
const candidates = new Map<string, { materialId: string; productModel: string; skuCode: string; variant: string; assets: any[] }>()
|
|
const append = (variant: string, item: any) => {
|
|
const found = candidates.get(variant) || { materialId: material.id, productModel: material.productModel, skuCode: variant, variant, assets: [] }
|
|
found.assets.push(item); candidates.set(variant, found)
|
|
}
|
|
for (const sku of material.skus) {
|
|
if (identifierType === 'sku' && sku.skuName.toUpperCase() !== value) continue
|
|
if (identifierType === 'barcode' && !sku.barcodes.some(code => code.barcode === value)) continue
|
|
const source = sku.previewUrl && imagePath(sku.previewUrl) ? sku.previewUrl : imagePath(sku.filePath) ? sku.filePath : null
|
|
if (source) append(sku.skuName, { id: sku.id, source: 'sku', name: sku.skuName, previewUrl: `/api/detail-generator/materials/${material.id}/images/sku/${sku.id}` })
|
|
}
|
|
for (const asset of material.assets) {
|
|
if (!imagePath(asset.fileUrl)) continue
|
|
const whiteBackground = parseStandardModelWhiteBgAssetType(asset.assetType)
|
|
const linkedSku = whiteBackground ? material.skus.find(sku => sku.id === whiteBackground.skuId) : undefined
|
|
const variant = asset.skuCode || linkedSku?.skuName || ''
|
|
if (identifierType === 'sku' && variant.toUpperCase() !== value) continue
|
|
if (identifierType === 'barcode' && !asset.barcodes.some(code => code.barcode === value) && !linkedSku?.barcodes.some(code => code.barcode === value)) continue
|
|
append(variant, { id: asset.id, source: 'asset', name: asset.fileName || asset.assetType, previewUrl: `/api/detail-generator/materials/${material.id}/images/asset/${asset.id}` })
|
|
}
|
|
matches.push(...Array.from(candidates.values()))
|
|
}
|
|
return matches.slice(0, 40)
|
|
}
|
|
|
|
export async function readDetailMaterialImage(ownerId: string, materialId: string, source: string, sourceId: string): Promise<{ buffer: Buffer; name: string; variant: string; productModel: string; barcodes: string[] }> {
|
|
const material = await prisma.material.findUnique({ where: { id: materialId }, include: { assets: { include: { barcodes: true } }, skus: { where: { deletedAt: null }, include: { barcodes: { where: { deletedAt: null } } } } } })
|
|
if (!material) throw new DetailGeneratorError('素材不存在', 404)
|
|
await assertVisible(material, ownerId)
|
|
let storedPath: string
|
|
let name: string
|
|
let variant: string
|
|
let barcodes: string[]
|
|
if (source === 'asset') {
|
|
const asset = material.assets.find(item => item.id === sourceId)
|
|
if (!asset || !imagePath(asset.fileUrl)) throw new DetailGeneratorError('图片不存在', 404)
|
|
const whiteBackground = parseStandardModelWhiteBgAssetType(asset.assetType)
|
|
const linkedSku = whiteBackground ? material.skus.find(sku => sku.id === whiteBackground.skuId) : undefined
|
|
storedPath = asset.fileUrl; name = asset.fileName || asset.assetType; variant = asset.skuCode || linkedSku?.skuName || ''
|
|
barcodes = Array.from(new Set([...asset.barcodes.map(value => value.barcode), ...(linkedSku?.barcodes.map(value => value.barcode) || [])]))
|
|
} else if (source === 'sku') {
|
|
const sku = material.skus.find(item => item.id === sourceId)
|
|
if (!sku) throw new DetailGeneratorError('具体产品款式不存在', 404)
|
|
storedPath = sku.previewUrl && imagePath(sku.previewUrl) ? sku.previewUrl : sku.filePath
|
|
if (!imagePath(storedPath)) throw new DetailGeneratorError('款式没有可用产品图片', 400)
|
|
name = sku.skuName; variant = sku.skuName
|
|
barcodes = sku.barcodes.map(value => value.barcode)
|
|
} else throw new DetailGeneratorError('图片来源无效')
|
|
let buffer: Buffer
|
|
if (isS3Url(storedPath)) {
|
|
const { body, contentLength } = await getS3ObjectStream(getTrustedS3MutationKey(storedPath))
|
|
if (contentLength && contentLength > MAX_IMAGE_BYTES) { body.destroy(); throw new DetailGeneratorError('产品图片超过 16MB 限制', 413) }
|
|
const chunks: Buffer[] = []; let bytes = 0
|
|
try {
|
|
for await (const chunk of body) {
|
|
bytes += chunk.length
|
|
if (bytes > MAX_IMAGE_BYTES) throw new DetailGeneratorError('产品图片超过 16MB 限制', 413)
|
|
chunks.push(Buffer.from(chunk))
|
|
}
|
|
buffer = Buffer.concat(chunks, bytes)
|
|
} finally { body.destroy() }
|
|
} else {
|
|
if (/^[a-z]+:/i.test(storedPath)) throw new DetailGeneratorError('该图片不在当前受信任存储中')
|
|
const file = resolveStoredUploadPath(storedPath.replace(/^\/uploads\//i, 'uploads/'))
|
|
const root = path.resolve(getUploadRootDir())
|
|
const relative = path.relative(root, path.resolve(file))
|
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || !(await isPathFreeOfSymbolicLinks(root, file))) throw new DetailGeneratorError('图片存储路径不可访问', 403)
|
|
buffer = await readBoundedRegularFile(file, { maxBytes: MAX_IMAGE_BYTES, label: '产品图片' })
|
|
}
|
|
return { buffer, name, variant, productModel: material.productModel, barcodes }
|
|
}
|