314 lines
14 KiB
TypeScript
314 lines
14 KiB
TypeScript
import { randomUUID } from 'crypto'
|
|
import { rename, unlink, writeFile } from 'fs/promises'
|
|
import path from 'path'
|
|
import { getServerSession } from 'next-auth'
|
|
import { NextResponse } from 'next/server'
|
|
import { authOptions } from '@/lib/auth'
|
|
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { getUploadRootDir } from '@/lib/upload-paths'
|
|
import { ensureSafeDirectoryWithinRoot, isPathFreeOfSymbolicLinks, normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security'
|
|
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard'
|
|
import { boundedMultipartRequestLimiter, getBoundedMultipartFailure } from '@/lib/multipart-route-limits'
|
|
import { validateBrandImage } from '@/lib/brand-image-validation'
|
|
import { isWebFileLike } from '@/lib/web-file'
|
|
import {
|
|
BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS,
|
|
BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES,
|
|
BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES,
|
|
BRAND_KNOWLEDGE_MAX_REQUEST_BYTES,
|
|
BRAND_KNOWLEDGE_TYPE_OPTIONS,
|
|
} from '@/lib/brand-knowledge-file-types'
|
|
|
|
export const runtime = 'nodejs'
|
|
export const dynamic = 'force-dynamic'
|
|
export const revalidate = 0
|
|
|
|
const MAX_FILE_SIZE = BRAND_KNOWLEDGE_MAX_MAIN_FILE_BYTES
|
|
const MAX_REQUEST_SIZE = BRAND_KNOWLEDGE_MAX_REQUEST_BYTES
|
|
const MAX_PREVIEW_IMAGE_SIZE = BRAND_KNOWLEDGE_MAX_PREVIEW_IMAGE_BYTES
|
|
const KNOWLEDGE_TYPES: ReadonlySet<string> = new Set(BRAND_KNOWLEDGE_TYPE_OPTIONS)
|
|
// The source file stays independent from Material/MaterialAsset rows.
|
|
const ALLOWED_EXTENSIONS: ReadonlySet<string> = new Set(BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS)
|
|
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
|
|
|
|
function accessErrorResponse(error: unknown) {
|
|
if (error instanceof BrandCenterDashboardAccessError) {
|
|
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
|
|
}
|
|
return null
|
|
}
|
|
|
|
function normalizeText(value: FormDataEntryValue | null, maxLength: number) {
|
|
return typeof value === 'string' ? value.trim().slice(0, maxLength) : ''
|
|
}
|
|
|
|
function serializeKnowledge(item: {
|
|
id: string
|
|
title: string
|
|
knowledgeType: string
|
|
brand: string
|
|
productCategoryName: string
|
|
fileName: string
|
|
mimeType: string
|
|
fileSize: bigint
|
|
downloadCount: number
|
|
previewImageName: string | null
|
|
previewImagePath: string | null
|
|
previewImageMimeType: string | null
|
|
previewImageSize: bigint | null
|
|
createdAt: Date
|
|
uploader: { realName: string | null; username: string }
|
|
likes: { id: string }[]
|
|
favorites: { id: string }[]
|
|
_count: { likes: number }
|
|
}) {
|
|
return {
|
|
id: item.id,
|
|
title: item.title,
|
|
knowledgeType: item.knowledgeType,
|
|
brand: item.brand,
|
|
productCategoryName: item.productCategoryName,
|
|
fileName: item.fileName,
|
|
mimeType: item.mimeType,
|
|
fileSize: Number(item.fileSize),
|
|
downloadCount: item.downloadCount,
|
|
createdAt: item.createdAt.toISOString(),
|
|
uploadedBy: item.uploader.realName || item.uploader.username,
|
|
downloadUrl: `/api/department-dashboard/brand-knowledge/${item.id}/file`,
|
|
previewUrl: item.previewImagePath ? `/api/department-dashboard/brand-knowledge/${item.id}/preview` : null,
|
|
previewImageName: item.previewImageName,
|
|
likeCount: item._count.likes,
|
|
isLiked: item.likes.length > 0,
|
|
isFavorited: item.favorites.length > 0,
|
|
}
|
|
}
|
|
|
|
export async function GET(request: Request) {
|
|
try {
|
|
const session = await getServerSession(authOptions)
|
|
await assertBrandCenterDashboardAccess(session)
|
|
const userId = session?.user?.id
|
|
if (!userId) return NextResponse.json({ success: false, error: '请先登录' }, { status: 401, headers: PRIVATE_HEADERS })
|
|
const params = new URL(request.url).searchParams
|
|
const type = params.get('type')?.trim() || ''
|
|
const query = params.get('q')?.trim() || ''
|
|
const items = await prisma.brandKnowledge.findMany({
|
|
where: {
|
|
...(KNOWLEDGE_TYPES.has(type) ? { knowledgeType: type } : {}),
|
|
...(query ? {
|
|
OR: [
|
|
{ title: { contains: query, mode: 'insensitive' } },
|
|
{ fileName: { contains: query, mode: 'insensitive' } },
|
|
{ brand: { contains: query, mode: 'insensitive' } },
|
|
{ productCategoryName: { contains: query, mode: 'insensitive' } },
|
|
],
|
|
} : {}),
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 200,
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
knowledgeType: true,
|
|
brand: true,
|
|
productCategoryName: true,
|
|
fileName: true,
|
|
mimeType: true,
|
|
fileSize: true,
|
|
downloadCount: true,
|
|
previewImageName: true,
|
|
previewImagePath: true,
|
|
previewImageMimeType: true,
|
|
previewImageSize: true,
|
|
createdAt: true,
|
|
uploader: { select: { realName: true, username: true } },
|
|
likes: { where: { userId }, select: { id: true } },
|
|
favorites: { where: { userId }, select: { id: true } },
|
|
_count: { select: { likes: true } },
|
|
},
|
|
})
|
|
return NextResponse.json({ success: true, data: items.map(serializeKnowledge) }, { headers: PRIVATE_HEADERS })
|
|
} catch (error) {
|
|
const response = accessErrorResponse(error)
|
|
if (response) return response
|
|
console.error('[department-dashboard] 品牌知识列表读取失败:', error)
|
|
return NextResponse.json({ success: false, error: '品牌知识列表读取失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
|
|
}
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
let storedPath: string | null = null
|
|
let storedPreviewPath: string | null = null
|
|
let temporaryPath: string | null = null
|
|
let temporaryPreviewPath: string | null = null
|
|
let releaseRequestSlot: (() => void) | null = null
|
|
try {
|
|
const session = await getServerSession(authOptions)
|
|
await assertBrandCenterDashboardAccess(session)
|
|
const userId = session?.user?.id
|
|
if (!userId) return NextResponse.json({ success: false, error: '请先登录' }, { status: 401, headers: PRIVATE_HEADERS })
|
|
|
|
assertBoundedMultipartHeaders(request.headers, MAX_REQUEST_SIZE)
|
|
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire()
|
|
const formData = await request.formData()
|
|
assertMultipartFiles(formData, {
|
|
fields: {
|
|
file: { maxCount: 1, maxFileBytes: MAX_FILE_SIZE },
|
|
previewImage: {
|
|
maxCount: 1,
|
|
maxFileBytes: MAX_PREVIEW_IMAGE_SIZE,
|
|
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
|
},
|
|
},
|
|
maxFiles: 2,
|
|
maxTotalBytes: MAX_FILE_SIZE + MAX_PREVIEW_IMAGE_SIZE,
|
|
})
|
|
const file = formData.get('file')
|
|
const previewImage = formData.get('previewImage')
|
|
if (!isWebFileLike(file)) {
|
|
return NextResponse.json({ success: false, error: '请选择知识文件' }, { status: 400, headers: PRIVATE_HEADERS })
|
|
}
|
|
if (previewImage !== null && !isWebFileLike(previewImage)) {
|
|
return NextResponse.json({ success: false, error: '预览图文件无效' }, { status: 400, headers: PRIVATE_HEADERS })
|
|
}
|
|
const title = normalizeText(formData.get('title'), 200)
|
|
const knowledgeType = normalizeText(formData.get('knowledgeType'), 30)
|
|
const brand = normalizeText(formData.get('brand'), 40)
|
|
const productCategoryName = normalizeText(formData.get('productCategoryName'), 80)
|
|
if (!title) return NextResponse.json({ success: false, error: '请填写知识标题' }, { status: 400, headers: PRIVATE_HEADERS })
|
|
if (!KNOWLEDGE_TYPES.has(knowledgeType)) return NextResponse.json({ success: false, error: '知识类型不合法' }, { status: 400, headers: PRIVATE_HEADERS })
|
|
if (file.size <= 0 || file.size > MAX_FILE_SIZE) return NextResponse.json({ success: false, error: '主文件大小需在 1B 至 1GB 之间' }, { status: 400, headers: PRIVATE_HEADERS })
|
|
|
|
let validatedPreview: Awaited<ReturnType<typeof validateBrandImage>> | null = null
|
|
let previewImageName: string | null = null
|
|
if (isWebFileLike(previewImage)) {
|
|
if (previewImage.size <= 0 || previewImage.size > MAX_PREVIEW_IMAGE_SIZE) {
|
|
return NextResponse.json({ success: false, error: '预览图需为 1B 至 50MB 之间的 PNG、JPEG 或 WebP 图片' }, { status: 400, headers: PRIVATE_HEADERS })
|
|
}
|
|
try {
|
|
validatedPreview = await validateBrandImage(
|
|
Buffer.from(await previewImage.arrayBuffer()),
|
|
previewImage.type,
|
|
{ maxBytes: MAX_PREVIEW_IMAGE_SIZE, maxBytesLabel: '50MB' },
|
|
)
|
|
} catch (error) {
|
|
return NextResponse.json(
|
|
{ success: false, error: error instanceof Error ? error.message : '预览图无效,请上传 PNG、JPEG 或 WebP 图片' },
|
|
{ status: 400, headers: PRIVATE_HEADERS },
|
|
)
|
|
}
|
|
previewImageName = normalizeSafeFileName(previewImage.name) || `preview.${validatedPreview.extension}`
|
|
}
|
|
|
|
const safeOriginalName = normalizeSafeFileName(file.name)
|
|
if (!safeOriginalName) return NextResponse.json({ success: false, error: '文件名不合法' }, { status: 400, headers: PRIVATE_HEADERS })
|
|
const extension = path.extname(safeOriginalName).toLowerCase()
|
|
if (!ALLOWED_EXTENSIONS.has(extension)) return NextResponse.json({ success: false, error: '文件格式不支持,请参考素材资产库支持的格式' }, { status: 400, headers: PRIVATE_HEADERS })
|
|
|
|
const uploadRoot = getUploadRootDir()
|
|
const knowledgeDirectory = await ensureSafeDirectoryWithinRoot(uploadRoot, 'brand-knowledge')
|
|
if (!knowledgeDirectory) throw new Error('品牌知识存储目录不可用')
|
|
const storageName = `${randomUUID()}${extension}`
|
|
const destination = resolveStrictChildPath(knowledgeDirectory, storageName)
|
|
if (!destination) throw new Error('品牌知识文件路径不可用')
|
|
if (!(await isPathFreeOfSymbolicLinks(uploadRoot, destination, { allowMissingLeaf: true }))) throw new Error('品牌知识存储目录不安全')
|
|
temporaryPath = `${destination}.uploading`
|
|
await writeFile(temporaryPath, Buffer.from(await file.arrayBuffer()), { flag: 'wx' })
|
|
await rename(temporaryPath, destination)
|
|
temporaryPath = null
|
|
storedPath = `uploads/brand-knowledge/${storageName}`
|
|
|
|
let previewStorageName: string | null = null
|
|
if (isWebFileLike(previewImage) && validatedPreview) {
|
|
previewStorageName = `${randomUUID()}.${validatedPreview.extension}`
|
|
const previewDestination = resolveStrictChildPath(knowledgeDirectory, previewStorageName)
|
|
if (!previewDestination || !(await isPathFreeOfSymbolicLinks(uploadRoot, previewDestination, { allowMissingLeaf: true }))) {
|
|
throw new Error('品牌知识预览图路径不可用')
|
|
}
|
|
temporaryPreviewPath = `${previewDestination}.uploading`
|
|
await writeFile(temporaryPreviewPath, Buffer.from(await previewImage.arrayBuffer()), { flag: 'wx' })
|
|
await rename(temporaryPreviewPath, previewDestination)
|
|
temporaryPreviewPath = null
|
|
storedPreviewPath = `uploads/brand-knowledge/${previewStorageName}`
|
|
}
|
|
|
|
const created = await prisma.brandKnowledge.create({
|
|
data: {
|
|
title,
|
|
knowledgeType,
|
|
brand,
|
|
productCategoryName,
|
|
fileName: safeOriginalName,
|
|
filePath: storedPath,
|
|
mimeType: file.type || 'application/octet-stream',
|
|
fileSize: BigInt(file.size),
|
|
previewImageName,
|
|
previewImagePath: storedPreviewPath,
|
|
previewImageMimeType: validatedPreview?.contentType || null,
|
|
previewImageSize: isWebFileLike(previewImage) ? BigInt(previewImage.size) : null,
|
|
uploadedBy: userId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
knowledgeType: true,
|
|
brand: true,
|
|
productCategoryName: true,
|
|
fileName: true,
|
|
mimeType: true,
|
|
fileSize: true,
|
|
downloadCount: true,
|
|
previewImageName: true,
|
|
previewImagePath: true,
|
|
previewImageMimeType: true,
|
|
previewImageSize: true,
|
|
createdAt: true,
|
|
uploader: { select: { realName: true, username: true } },
|
|
likes: { select: { id: true } },
|
|
favorites: { select: { id: true } },
|
|
_count: { select: { likes: true } },
|
|
},
|
|
})
|
|
storedPath = null
|
|
storedPreviewPath = null
|
|
return NextResponse.json({ success: true, data: serializeKnowledge(created) }, { status: 201, headers: PRIVATE_HEADERS })
|
|
} catch (error) {
|
|
if (temporaryPath) {
|
|
await unlink(temporaryPath).catch(() => undefined)
|
|
}
|
|
if (temporaryPreviewPath) {
|
|
await unlink(temporaryPreviewPath).catch(() => undefined)
|
|
}
|
|
if (storedPath) {
|
|
try {
|
|
const absolute = path.join(getUploadRootDir(), storedPath.replace(/^uploads\//, ''))
|
|
await unlink(absolute)
|
|
} catch {
|
|
// 数据库写入失败时尽力清理孤立文件,不能覆盖原始错误。
|
|
}
|
|
}
|
|
if (storedPreviewPath) {
|
|
try {
|
|
const absolute = path.join(getUploadRootDir(), storedPreviewPath.replace(/^uploads\//, ''))
|
|
await unlink(absolute)
|
|
} catch {
|
|
// Keep the original error if cleanup of an orphan preview fails.
|
|
}
|
|
}
|
|
const response = accessErrorResponse(error)
|
|
if (response) return response
|
|
const boundedFailure = getBoundedMultipartFailure(error)
|
|
if (boundedFailure) {
|
|
return NextResponse.json(
|
|
{ success: false, error: boundedFailure.message, code: boundedFailure.code },
|
|
{ status: boundedFailure.status, headers: { ...PRIVATE_HEADERS, ...(boundedFailure.retryAfterSeconds ? { 'Retry-After': String(boundedFailure.retryAfterSeconds) } : {}) } },
|
|
)
|
|
}
|
|
console.error('[department-dashboard] 品牌知识上传失败:', error)
|
|
return NextResponse.json({ success: false, error: '品牌知识上传失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
|
|
} finally {
|
|
releaseRequestSlot?.()
|
|
}
|
|
}
|