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