276 lines
8.8 KiB
TypeScript
276 lines
8.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
import { authOptions } from '@/lib/auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
|
||
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
|
||
import { readFile, access } from 'fs/promises'
|
||
import path from 'path'
|
||
import { Readable } from 'stream'
|
||
|
||
export const dynamic = 'force-dynamic'
|
||
|
||
export async function GET(req: NextRequest) {
|
||
try {
|
||
// Check if user is logged in
|
||
const session = await getServerSession(authOptions)
|
||
if (!session) {
|
||
console.log('[Download] No session found')
|
||
return NextResponse.json(
|
||
{ error: '请先登录' },
|
||
{ status: 401 }
|
||
)
|
||
}
|
||
|
||
// Check if user has download permission (skip check for admins)
|
||
const userId = (session.user as any).id
|
||
const userRole = (session.user as any).role
|
||
const userEmail = (session.user as any).email
|
||
|
||
console.log(`[Download] User ${userId} (${userEmail}, role: ${userRole}) attempting download`)
|
||
|
||
if (userRole !== 'ADMIN') {
|
||
const user = await prisma.user.findUnique({
|
||
where: { id: userId },
|
||
select: { canDownload: true, email: true, username: true }
|
||
})
|
||
|
||
console.log(`[Download] User ${userId} canDownload status:`, user?.canDownload)
|
||
|
||
if (!user) {
|
||
console.error(`[Download] User ${userId} not found in database`)
|
||
return NextResponse.json(
|
||
{ error: '用户不存在,请联系管理员' },
|
||
{ status: 403 }
|
||
)
|
||
}
|
||
|
||
if (!user.canDownload) {
|
||
console.warn(`[Download] User ${userId} (${user.email}) download permission is disabled`)
|
||
return NextResponse.json(
|
||
{ error: '您的下载权限已被限制,请联系管理员' },
|
||
{ status: 403 }
|
||
)
|
||
}
|
||
} else {
|
||
console.log(`[Download] Admin user ${userId} skipping permission check`)
|
||
}
|
||
|
||
const { searchParams } = new URL(req.url)
|
||
const id = searchParams.get('id')
|
||
|
||
if (!id) {
|
||
return NextResponse.json(
|
||
{ error: '缺少素材ID' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
const material = await prisma.material.findUnique({
|
||
where: { id },
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
productModel: true,
|
||
zipFilePath: true,
|
||
zipFileName: true,
|
||
materialType: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
authorName: true,
|
||
}
|
||
})
|
||
|
||
if (!material) {
|
||
console.error(`[Download] Material ${id} not found`)
|
||
return NextResponse.json(
|
||
{ error: '素材不存在' },
|
||
{ status: 404 }
|
||
)
|
||
}
|
||
|
||
console.log(`[Download] Material ${id} (${material.productModel}) found. Owner: ${material.userId}, Requester: ${userId}`)
|
||
|
||
const isOwnMaterial = material.userId === userId
|
||
let shouldIncrementDownload = false
|
||
|
||
if (!isOwnMaterial) {
|
||
try {
|
||
await ensureMaterialDownloadLogsTable()
|
||
const now = new Date()
|
||
shouldIncrementDownload = true
|
||
// 每次下载都记录一条日志
|
||
await prisma.$executeRaw`
|
||
INSERT INTO material_download_logs (id, "materialId", "userId", "downloadedAt")
|
||
VALUES (gen_random_uuid(), ${id}, ${userId}, ${now})
|
||
`
|
||
console.log(`[Download] Created download log for material ${id}, user ${userId}`)
|
||
} catch (logError) {
|
||
console.error(`[Download] Error handling download log:`, logError)
|
||
// 如果日志记录失败,仍然允许下载,但不增加计数
|
||
shouldIncrementDownload = false
|
||
console.warn(`[Download] Continuing download without logging due to error`)
|
||
}
|
||
} else {
|
||
// 自己下载自己的素材,不记录下载日志,也不增加下载次数
|
||
console.log(`[Download] User ${userId} downloaded own material ${id}. Not incrementing count.`)
|
||
}
|
||
|
||
if (shouldIncrementDownload) {
|
||
await prisma.material.update({
|
||
where: { id },
|
||
data: {
|
||
downloadCount: {
|
||
increment: 1,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
if (!material.zipFilePath) {
|
||
console.error(`[Download] Material ${id} has no zipFilePath`)
|
||
return NextResponse.json(
|
||
{ error: '没有找到素材文件,请重新上传' },
|
||
{ status: 404 }
|
||
)
|
||
}
|
||
|
||
console.log(`[Download] Material ${id} file path: ${material.zipFilePath}`)
|
||
|
||
// 生成新的文件名:SPU_一级分类_二级分类_用户名.扩展名
|
||
// 获取原文件扩展名
|
||
const zipNameFallback = material.zipFileName || ''
|
||
const originalExt = path.extname(zipNameFallback) || path.extname(material.zipFilePath) || ''
|
||
const newFileName = `${material.productModel}_${material.categoryName}_${material.sceneName}_${material.authorName}${originalExt}`
|
||
|
||
console.log('Download filename:', newFileName)
|
||
|
||
// 根据文件类型和素材类型确定Content-Type
|
||
let contentType = 'application/zip'
|
||
let contentDisposition = `attachment; filename="${encodeURIComponent(newFileName)}"`
|
||
const rangeHeader = req.headers.get('range')
|
||
|
||
// 如果是视频类型,使用视频的Content-Type
|
||
if (material.materialType === '视频') {
|
||
const ext = originalExt.toLowerCase()
|
||
const videoTypes: { [key: string]: string } = {
|
||
'.mp4': 'video/mp4',
|
||
'.avi': 'video/x-msvideo',
|
||
'.mov': 'video/quicktime',
|
||
'.wmv': 'video/x-ms-wmv',
|
||
'.flv': 'video/x-flv',
|
||
'.mkv': 'video/x-matroska',
|
||
'.webm': 'video/webm',
|
||
}
|
||
contentType = videoTypes[ext] || 'video/mp4'
|
||
if (!rangeHeader) {
|
||
contentDisposition = `inline; filename="${encodeURIComponent(newFileName)}"`
|
||
}
|
||
}
|
||
|
||
const contentDispositionHeader = `${contentDisposition}; filename*=UTF-8''${encodeURIComponent(newFileName)}`
|
||
|
||
if (isS3Url(material.zipFilePath)) {
|
||
const s3Object = await getS3ObjectStream(material.zipFilePath, rangeHeader)
|
||
const stream = toWebStream(s3Object.body)
|
||
const headers: Record<string, string> = {
|
||
'Content-Type': s3Object.contentType || contentType,
|
||
'Content-Disposition': contentDispositionHeader,
|
||
'Accept-Ranges': s3Object.acceptRanges || 'bytes',
|
||
'Cache-Control': 'no-store',
|
||
}
|
||
|
||
if (rangeHeader && s3Object.contentRange) {
|
||
headers['Content-Range'] = s3Object.contentRange
|
||
}
|
||
|
||
if (s3Object.contentLength) {
|
||
headers['Content-Length'] = s3Object.contentLength.toString()
|
||
}
|
||
|
||
const status = rangeHeader ? 206 : 200
|
||
|
||
return new NextResponse(stream as any, {
|
||
status,
|
||
headers,
|
||
})
|
||
}
|
||
|
||
// Read legacy local files (backward compatibility)
|
||
let filePath = material.zipFilePath
|
||
if (!path.isAbsolute(filePath)) {
|
||
filePath = path.join(process.cwd(), filePath)
|
||
}
|
||
|
||
console.log('Attempting to download legacy file:', filePath)
|
||
|
||
try {
|
||
await access(filePath)
|
||
} catch (error) {
|
||
console.error('File not found:', filePath)
|
||
return NextResponse.json(
|
||
{ error: '文件不存在,可能已被删除' },
|
||
{ status: 404 }
|
||
)
|
||
}
|
||
|
||
const fileBuffer = await readFile(filePath)
|
||
|
||
if (material.materialType === '视频' && rangeHeader) {
|
||
const fileSize = fileBuffer.length
|
||
const parts = rangeHeader.replace(/bytes=/, '').split('-')
|
||
const start = parseInt(parts[0], 10)
|
||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1
|
||
const chunkSize = end - start + 1
|
||
const chunk = fileBuffer.slice(start, end + 1)
|
||
|
||
return new NextResponse(chunk as any, {
|
||
status: 206,
|
||
headers: {
|
||
'Content-Type': contentType,
|
||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||
'Accept-Ranges': 'bytes',
|
||
'Content-Length': chunkSize.toString(),
|
||
},
|
||
})
|
||
}
|
||
|
||
return new NextResponse(fileBuffer as any, {
|
||
headers: {
|
||
'Content-Type': contentType,
|
||
'Content-Disposition': contentDispositionHeader,
|
||
'Content-Length': fileBuffer.length.toString(),
|
||
'Accept-Ranges': 'bytes', // 支持Range请求(用于视频流式传输)
|
||
},
|
||
})
|
||
} catch (error) {
|
||
console.error('[Download] Error:', error)
|
||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||
console.error('[Download] Error details:', {
|
||
message: errorMessage,
|
||
stack: error instanceof Error ? error.stack : undefined,
|
||
})
|
||
return NextResponse.json(
|
||
{ error: '下载失败: ' + errorMessage },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|
||
function toWebStream(body: any) {
|
||
if (!body) {
|
||
throw new Error('S3 object body is empty')
|
||
}
|
||
|
||
if (typeof (body as any).transformToWebStream === 'function') {
|
||
return (body as any).transformToWebStream()
|
||
}
|
||
|
||
if (body instanceof Readable) {
|
||
return Readable.toWeb(body)
|
||
}
|
||
|
||
return body as ReadableStream<any>
|
||
}
|
||
|