2026-09-14 16:45:42 +08:00

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?.()
}
}