301 lines
9.2 KiB
TypeScript
301 lines
9.2 KiB
TypeScript
'use server'
|
|
|
|
import { promises as fs } from 'fs'
|
|
import { createReadStream, existsSync } from 'fs'
|
|
import { stat } from 'fs/promises'
|
|
import path from 'path'
|
|
import JSZip from 'jszip'
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { getServerSession } from 'next-auth'
|
|
import { authOptions } from '@/lib/auth'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
|
|
import {
|
|
hasAnyWhiteBackgroundAssetGroup,
|
|
getWhiteBackgroundAssetWhereForSku,
|
|
getWhiteBackgroundZipFileName,
|
|
groupWhiteBackgroundAssetsBySku,
|
|
type GroupedWhiteBackgroundAssets,
|
|
STANDARD_MODEL_WHITE_BG_VIEWS,
|
|
} from '@/lib/standard-model-white-background'
|
|
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
|
|
import { isPathInAllowedDirs } from '@/lib/path-security'
|
|
import { canDownloadSkuMaterial } from '../../authorization'
|
|
|
|
type DownloadType = 'model' | 'white-background'
|
|
|
|
type WhiteBackgroundAssetRecord = {
|
|
assetType: string
|
|
fileUrl: string
|
|
fileName: string | null
|
|
sortOrder: number
|
|
}
|
|
|
|
function resolveModelDownloadFileName(
|
|
skuName: string,
|
|
rawFileName?: string | null,
|
|
rawFilePath?: string | null,
|
|
): string {
|
|
const ext = path.extname(rawFileName || rawFilePath || '') || '.zip'
|
|
return `${skuName}${ext}`
|
|
}
|
|
|
|
function resolveLocalFilePath(fileUrl: string): string | null {
|
|
const resolvedPath = resolveStoredUploadPath(fileUrl)
|
|
return isPathInAllowedDirs(resolvedPath, [getUploadRootDir()]) ? resolvedPath : null
|
|
}
|
|
|
|
function getWhiteBackgroundZipEntries(group: GroupedWhiteBackgroundAssets<WhiteBackgroundAssetRecord>) {
|
|
const fixedEntries = STANDARD_MODEL_WHITE_BG_VIEWS
|
|
.map((view) => {
|
|
const asset = group.fixed[view.key]
|
|
if (!asset) {
|
|
return null
|
|
}
|
|
return {
|
|
fileName: asset.fileName || `${view.label}${path.extname(asset.fileUrl) || '.jpg'}`,
|
|
fileUrl: asset.fileUrl,
|
|
}
|
|
})
|
|
.filter((entry): entry is { fileName: string; fileUrl: string } => Boolean(entry))
|
|
|
|
const otherEntries = group.other.map((asset) => ({
|
|
fileName: asset.fileName || path.basename(asset.fileUrl),
|
|
fileUrl: asset.fileUrl,
|
|
directory: 'other',
|
|
}))
|
|
|
|
return {
|
|
fixedEntries,
|
|
otherEntries,
|
|
}
|
|
}
|
|
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: { id: string; skuId: string } }
|
|
) {
|
|
try {
|
|
const { searchParams } = new URL(request.url)
|
|
const validateOnly = searchParams.get('validate') === '1'
|
|
const downloadType: DownloadType =
|
|
searchParams.get('downloadType') === 'white-background' ? 'white-background' : 'model'
|
|
|
|
const session = await getServerSession(authOptions)
|
|
if (!session?.user || !(session.user as any).id) {
|
|
return NextResponse.json({ error: '请先登录' }, { status: 401 })
|
|
}
|
|
|
|
const userId = (session.user as any).id
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { canDownload: true },
|
|
})
|
|
|
|
if (!user?.canDownload) {
|
|
return NextResponse.json({ error: '您没有下载权限,请联系管理员' }, { status: 403 })
|
|
}
|
|
|
|
const { skuId } = params
|
|
const materialId = params.id
|
|
|
|
const material = await prisma.material.findFirst({
|
|
where: { id: materialId, deletedAt: null },
|
|
select: { id: true, userId: true, materialType: true },
|
|
})
|
|
|
|
if (!material) {
|
|
return NextResponse.json({ error: '素材不存在或已被删除' }, { status: 404 })
|
|
}
|
|
|
|
if (material.materialType !== '标准模型') {
|
|
return NextResponse.json({ error: '只有标准模型类型才支持 SKU 下载' }, { status: 400 })
|
|
}
|
|
|
|
if (!(await canDownloadSkuMaterial(session.user as any, material))) {
|
|
return NextResponse.json({ error: '无权限下载此素材' }, { status: 403 })
|
|
}
|
|
|
|
const sku = await prisma.materialSku.findFirst({
|
|
where: {
|
|
id: skuId,
|
|
materialId,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
skuName: true,
|
|
filePath: true,
|
|
fileName: true,
|
|
},
|
|
})
|
|
|
|
if (!sku) {
|
|
return NextResponse.json({ error: 'SKU 不存在' }, { status: 404 })
|
|
}
|
|
|
|
if (downloadType === 'white-background') {
|
|
const whiteBackgroundAssets = await prisma.materialAsset.findMany({
|
|
where: {
|
|
materialId,
|
|
...getWhiteBackgroundAssetWhereForSku(skuId),
|
|
},
|
|
select: {
|
|
assetType: true,
|
|
fileUrl: true,
|
|
fileName: true,
|
|
sortOrder: true,
|
|
},
|
|
})
|
|
|
|
const whiteBackgroundGroup = groupWhiteBackgroundAssetsBySku<WhiteBackgroundAssetRecord>(
|
|
whiteBackgroundAssets,
|
|
).get(skuId)
|
|
|
|
if (!whiteBackgroundGroup || !hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) {
|
|
return NextResponse.json({ error: '该 SKU 暂无可下载的白底图' }, { status: 404 })
|
|
}
|
|
|
|
const { fixedEntries, otherEntries } = getWhiteBackgroundZipEntries(whiteBackgroundGroup)
|
|
if (fixedEntries.length === 0 && otherEntries.length === 0) {
|
|
return NextResponse.json({ error: '该 SKU 暂无可下载的白底图' }, { status: 404 })
|
|
}
|
|
const allEntries = [...fixedEntries, ...otherEntries]
|
|
const downloadFileName = getWhiteBackgroundZipFileName(sku.skuName)
|
|
|
|
let totalSize = 0
|
|
for (const entry of allEntries) {
|
|
const absolutePath = resolveLocalFilePath(entry.fileUrl)
|
|
if (!absolutePath || !existsSync(absolutePath)) {
|
|
console.error(`[SKU Download] 白底图文件不存在: ${absolutePath}`)
|
|
return NextResponse.json({ error: '白底图文件不存在' }, { status: 404 })
|
|
}
|
|
totalSize += (await stat(absolutePath)).size
|
|
}
|
|
|
|
if (validateOnly) {
|
|
return NextResponse.json({
|
|
success: true,
|
|
fileName: downloadFileName,
|
|
fileSize: totalSize,
|
|
contentType: 'application/zip',
|
|
})
|
|
}
|
|
|
|
const zip = new JSZip()
|
|
for (const entry of fixedEntries) {
|
|
const filePath = resolveLocalFilePath(entry.fileUrl)
|
|
if (!filePath) {
|
|
return NextResponse.json({ error: '白底图文件路径无效' }, { status: 404 })
|
|
}
|
|
const fileBuffer = await fs.readFile(filePath)
|
|
zip.file(entry.fileName, fileBuffer)
|
|
}
|
|
for (const entry of otherEntries) {
|
|
const filePath = resolveLocalFilePath(entry.fileUrl)
|
|
if (!filePath) {
|
|
return NextResponse.json({ error: '白底图文件路径无效' }, { status: 404 })
|
|
}
|
|
const fileBuffer = await fs.readFile(filePath)
|
|
zip.file(`other/${entry.fileName}`, fileBuffer)
|
|
}
|
|
|
|
const zipBuffer = await zip.generateAsync({
|
|
type: 'nodebuffer',
|
|
compression: 'DEFLATE',
|
|
compressionOptions: { level: 6 },
|
|
})
|
|
|
|
recordSkuDownloadLog(materialId, userId).catch((error) => {
|
|
console.error('[SKU Download] 记录下载日志失败:', error)
|
|
})
|
|
|
|
return new NextResponse(zipBuffer as any, {
|
|
headers: {
|
|
'Content-Type': 'application/zip',
|
|
'Content-Disposition': `attachment; filename="${encodeURIComponent(downloadFileName)}"`,
|
|
'Content-Length': zipBuffer.length.toString(),
|
|
},
|
|
})
|
|
}
|
|
|
|
if (!sku.filePath) {
|
|
return NextResponse.json({ error: 'SKU 没有可下载的模型文件' }, { status: 404 })
|
|
}
|
|
|
|
const filePath = resolveLocalFilePath(sku.filePath)
|
|
const fileName = resolveModelDownloadFileName(
|
|
sku.skuName,
|
|
sku.fileName || null,
|
|
sku.filePath || null,
|
|
)
|
|
|
|
if (!filePath || !existsSync(filePath)) {
|
|
console.error(`[SKU Download] 文件不存在: ${filePath}`)
|
|
return NextResponse.json({ error: '文件不存在' }, { status: 404 })
|
|
}
|
|
|
|
const fileStats = await stat(filePath)
|
|
|
|
if (validateOnly) {
|
|
return NextResponse.json({
|
|
success: true,
|
|
fileName,
|
|
fileSize: fileStats.size,
|
|
contentType: 'application/zip',
|
|
})
|
|
}
|
|
|
|
const fileStream = createReadStream(filePath)
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
fileStream.on('data', (chunk) => controller.enqueue(chunk))
|
|
fileStream.on('end', () => controller.close())
|
|
fileStream.on('error', (err) => {
|
|
fileStream.destroy()
|
|
controller.error(err)
|
|
})
|
|
},
|
|
cancel() {
|
|
fileStream.destroy()
|
|
},
|
|
})
|
|
|
|
recordSkuDownloadLog(materialId, userId).catch((error) => {
|
|
console.error('[SKU Download] 记录下载日志失败:', error)
|
|
})
|
|
|
|
return new NextResponse(stream, {
|
|
headers: {
|
|
'Content-Type': 'application/zip',
|
|
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
|
|
'Content-Length': fileStats.size.toString(),
|
|
},
|
|
})
|
|
} catch (error) {
|
|
console.error('[SKU Download] 下载失败:', error)
|
|
return NextResponse.json({ error: '下载失败' }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
async function recordSkuDownloadLog(materialId: string, userId: string) {
|
|
await ensureMaterialDownloadLogsTable()
|
|
await Promise.all([
|
|
prisma.materialDownloadLog.create({
|
|
data: {
|
|
materialId,
|
|
userId,
|
|
downloadedAt: new Date(),
|
|
},
|
|
}),
|
|
prisma.material.update({
|
|
where: { id: materialId },
|
|
data: {
|
|
downloadCount: {
|
|
increment: 1,
|
|
},
|
|
},
|
|
}),
|
|
])
|
|
}
|