import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { uploadToS3 } from '@/lib/s3'; import { normalizeUrl } from '@/lib/utils'; import { compressImage } from '@/lib/image-processor'; import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard'; import { boundedMultipartRequestLimiter, getBoundedMultipartFailure, MULTIPART_ROUTE_LIMITS, } from '@/lib/multipart-route-limits'; // POST /api/user/avatar - 上传用户头像 export async function POST(request: NextRequest) { let releaseRequestSlot: (() => void) | null = null; try { const session = await getServerSession(authOptions); const userId = session?.user ? (session.user as any).id : undefined; console.log('[UserAvatar] Session:', { userId, hasSession: !!session, hasUser: !!session?.user }); if (!userId) { console.log('[UserAvatar] No user ID in session'); return NextResponse.json({ error: '未登录' }, { status: 401 }); } assertBoundedMultipartHeaders(request.headers, MULTIPART_ROUTE_LIMITS.avatarRequestBytes); releaseRequestSlot = await boundedMultipartRequestLimiter.acquire(); const formData = await request.formData(); assertMultipartFiles(formData, { fields: { avatar: { maxCount: 1, maxFileBytes: MULTIPART_ROUTE_LIMITS.avatarFileBytes, allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], }, }, maxFiles: 1, maxTotalBytes: MULTIPART_ROUTE_LIMITS.avatarFileBytes, }); const avatar = formData.get('avatar') as File | null; if (!avatar) { console.log('[UserAvatar] No avatar file provided'); return NextResponse.json({ error: '请选择头像文件' }, { status: 400 }); } console.log('[UserAvatar] File info:', { name: avatar.name, type: avatar.type, size: avatar.size }); // 验证文件类型 if (!['image/jpeg', 'image/jpg', 'image/png', 'image/webp'].includes(avatar.type)) { return NextResponse.json( { error: '不支持的图片格式,请使用 JPG、PNG 或 WebP 格式' }, { status: 400 } ); } // 验证文件大小(5MB) if (avatar.size > MULTIPART_ROUTE_LIMITS.avatarFileBytes) { return NextResponse.json( { error: `头像过大,请使用小于5MB的图片(当前:${(avatar.size / 1024 / 1024).toFixed(2)}MB)` }, { status: 400 } ); } // 压缩图片 const imageBuffer = Buffer.from(await avatar.arrayBuffer()); const compressedBuffer = await compressImage(imageBuffer, { maxSizeKB: 100, maxWidth: 200, maxHeight: 200, format: 'webp', }); // 上传到S3 const fileName = `avatar-${userId}-${Date.now()}.webp`; console.log('[UserAvatar] Uploading to S3:', fileName, `(${(compressedBuffer.length / 1024).toFixed(2)}KB)`); const avatarUrl = await uploadToS3(compressedBuffer, fileName, 'image/webp'); console.log('[UserAvatar] S3 URL:', avatarUrl); // 更新用户头像 console.log('[UserAvatar] Updating user:', userId); const updatedUser = await prisma.user.update({ where: { id: userId }, data: { avatar: avatarUrl }, }); console.log('[UserAvatar] User updated:', { id: updatedUser.id, avatar: updatedUser.avatar }); // 规范化URL const normalizedUrl = normalizeUrl(avatarUrl); return NextResponse.json({ avatarUrl: normalizedUrl }); } catch (error) { console.error('[UserAvatar] POST error:', error); const boundedFailure = getBoundedMultipartFailure(error); if (boundedFailure) { return NextResponse.json( { error: boundedFailure.message, code: boundedFailure.code }, { status: boundedFailure.status, ...(boundedFailure.retryAfterSeconds ? { headers: { 'Retry-After': String(boundedFailure.retryAfterSeconds) } } : {}), }, ); } return NextResponse.json({ error: '上传头像失败' }, { status: 500 }); } finally { releaseRequestSlot?.(); } }