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,
},
},
}),
])
}