diff --git a/app/api/materials/upload/chunk/route.ts b/app/api/materials/upload/chunk/route.ts index 308fea4..1f88ef0 100644 --- a/app/api/materials/upload/chunk/route.ts +++ b/app/api/materials/upload/chunk/route.ts @@ -3,8 +3,7 @@ import { createWriteStream } from 'fs' import { mkdir, writeFile } from 'fs/promises' import path from 'path' import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' +import { getAuthenticatedUploadUserId } from '@/lib/upload-auth' import { getUploadSession, updateChunkWithStats } from '@/lib/upload-sessions' export const runtime = 'nodejs' @@ -26,11 +25,11 @@ export async function POST(req: NextRequest) { try { const authStartedAt = Date.now() - const session = await getServerSession(authOptions) + const userId = await getAuthenticatedUploadUserId(req) const authTime = Date.now() - authStartedAt console.log(`[UploadChunk] 🔐 认证完成: ${authTime}ms`) - if (!session) { + if (!userId) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } @@ -55,7 +54,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 }) } - if (sessionData.userId !== (session.user as any).id) { + if (sessionData.userId !== userId) { return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 }) } diff --git a/app/api/materials/upload/init/route.ts b/app/api/materials/upload/init/route.ts index 36ce7d7..f737783 100644 --- a/app/api/materials/upload/init/route.ts +++ b/app/api/materials/upload/init/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' +import { getAuthenticatedUploadUserId } from '@/lib/upload-auth' import { saveUploadSession } from '@/lib/upload-sessions' import { randomBytes } from 'crypto' import { mkdir, writeFile } from 'fs/promises' @@ -8,8 +7,8 @@ import path from 'path' export async function POST(req: NextRequest) { try { - const session = await getServerSession(authOptions) - if (!session) { + const userId = await getAuthenticatedUploadUserId(req) + if (!userId) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } @@ -53,7 +52,7 @@ export async function POST(req: NextRequest) { fileName: normalizedFileName, fileSize: normalizedFileSize, totalChunks: normalizedTotalChunks, - userId: (session.user as any).id, + userId, createdAt: Date.now(), } await writeFile(metaPath, JSON.stringify(metaPayload), 'utf-8') @@ -71,7 +70,7 @@ export async function POST(req: NextRequest) { currentSpeed: 0, _speedSampleAt: now, _speedSampleBytes: 0, - userId: (session.user as any).id, + userId, }) console.log(`[UploadInit] Created upload session: ${uploadId}, chunks: ${normalizedTotalChunks}`) diff --git a/app/performance-survey/[[...path]]/page.tsx b/app/performance-survey/[[...path]]/page.tsx index ccd8d18..4932605 100644 --- a/app/performance-survey/[[...path]]/page.tsx +++ b/app/performance-survey/[[...path]]/page.tsx @@ -102,9 +102,8 @@ const API_BASE = '/api/performance-survey' const DINGTALK_PERFORMANCE_SESSION_STORAGE_KEY = 'performanceSurveyDingtalkToken' const DINGTALK_PERFORMANCE_SESSION_HEADER = 'x-performance-survey-dingtalk-session' const DINGTALK_PERFORMANCE_SESSION_EXPIRED_EVENT = 'performance-survey:dingtalk-session-expired' -const PERFORMANCE_SURVEY_MAX_IMAGE_COUNT = 12 -const PERFORMANCE_SURVEY_MAX_IMAGE_BYTES = 20 * 1024 * 1024 -const PERFORMANCE_SURVEY_MAX_IMAGE_BATCH_BYTES = 60 * 1024 * 1024 +const PERFORMANCE_SURVEY_MAX_IMAGE_COUNT = 60 +const PERFORMANCE_SURVEY_MAX_IMAGE_BYTES = 200 * 1024 * 1024 const DEFAULT_VISUAL_CASE_COUNT = 3 const PERFORMANCE_SURVEY_IMAGE_PROXY_HOSTS = new Set([ 's3.jiansun.vip', @@ -190,13 +189,39 @@ function validatePerformanceSurveyImageFiles(files: File[]) { const invalidType = files.find((file) => !file.type.startsWith('image/')) if (invalidType) return `文件「${invalidType.name}」不是图片` const oversized = files.find((file) => file.size > PERFORMANCE_SURVEY_MAX_IMAGE_BYTES) - if (oversized) return `图片「${oversized.name}」超过 20MB` - if (files.reduce((sum, file) => sum + file.size, 0) > PERFORMANCE_SURVEY_MAX_IMAGE_BATCH_BYTES) { - return '单个案例上传总大小不能超过 60MB' - } + if (oversized) return `图片「${oversized.name}」超过 200MB` return null } +async function uploadPerformanceSurveyImageFiles( + files: File[], + onProgress?: (label: string) => void, +) { + const { chunkUpload } = await import('@/lib/chunk-upload') + const uploaded: Array<{ uploadId: string; mimeType: string }> = [] + const dingtalkToken = getStoredDingtalkPerformanceToken() + const requestHeaders = dingtalkToken + ? { + Authorization: `Bearer ${dingtalkToken}`, + [DINGTALK_PERFORMANCE_SESSION_HEADER]: dingtalkToken, + } + : undefined + + for (let index = 0; index < files.length; index += 1) { + const file = files[index] + onProgress?.(`正在上传图片 ${index + 1}/${files.length}`) + const result = await chunkUpload( + file, + { materialType: '考核调研-视觉评分', fileName: file.name }, + { skipComplete: true, requestHeaders }, + ) + if (!result?.uploadId) throw new Error(`图片「${file.name}」上传失败,请重试`) + uploaded.push({ uploadId: result.uploadId, mimeType: file.type }) + } + + return uploaded +} + function formatPerformanceSurveyImageFileSize(bytes: number) { if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB` return `${(bytes / 1024 / 1024).toFixed(1)} MB` @@ -2726,20 +2751,24 @@ function CreateSurveyForm({ for (let index = 0; index < visualCasesToSubmit.length; index += 1) { const draft = visualCasesToSubmit[index] setPublishingLabel(`正在上传作品 ${index + 1}/${visualCasesToSubmit.length}`) - const formData = new FormData() - formData.set('targetId', uploadTarget.id) - formData.set('title', draft.title.trim() || `案例 ${index + 1}`) - formData.set('description', draft.description.trim()) - draft.files.forEach((file) => formData.append('images', file)) + const images = await uploadPerformanceSurveyImageFiles(draft.files, (label) => { + setPublishingLabel(`作品 ${index + 1}/${visualCasesToSubmit.length} · ${label}`) + }) await apiRequest(`/surveys/${created.id}/work-items`, { method: 'POST', - body: formData, + body: JSON.stringify({ + targetId: uploadTarget.id, + title: draft.title.trim() || `案例 ${index + 1}`, + description: draft.description.trim(), + images, + }), }) } } setTitle('') setDescription('') + setTemplateCode('') setAssessmentMonth(getCurrentMonthValue()) setDeadlineAt('') setTargetEmployees([]) @@ -2750,6 +2779,7 @@ function CreateSurveyForm({ setItemName('') setVisualCases(createVisualCaseDrafts()) await onCreated() + setCreatedSurvey(null) if (uploadTarget?.id) { router.push(`/performance-survey/survey/${created.id}?targetId=${encodeURIComponent(uploadTarget.id)}`) @@ -2902,27 +2932,25 @@ function CreateSurveyForm({
{error &&
{error}
} - {!createdSurvey && ( -
0 - ? 'border border-[#f2d184] bg-[#fffaf0] text-[#8a4b08]' - : 'border border-[#b9ead0] bg-[#f0fff6] text-[#087a3c]' - }`} - aria-live="polite" - > - {publishValidationIssues.length > 0 - ? `还需完成:${publishValidationIssues.join(';')}` - : visualTemplateSelected - ? '配置已完整,发布后请先完成自评' - : '配置已完整,可以发布调研'} -
- )} +
0 + ? 'border border-[#f2d184] bg-[#fffaf0] text-[#8a4b08]' + : 'border border-[#b9ead0] bg-[#f0fff6] text-[#087a3c]' + }`} + aria-live="polite" + > + {publishValidationIssues.length > 0 + ? `还需完成:${publishValidationIssues.join(';')}` + : visualTemplateSelected + ? '配置已完整,发布后请先完成自评' + : '配置已完整,可以发布调研'} +
) @@ -4340,14 +4368,16 @@ function WorkItemUploadForm({ setBusy(true) setMessage(null) try { - const formData = new FormData() - formData.set('targetId', targetId) - formData.set('title', title.trim() || '案例作品') - formData.set('description', description.trim()) - files.forEach((file) => formData.append('images', file)) + const images = await uploadPerformanceSurveyImageFiles(files, setMessage) + setMessage('图片上传完成,正在生成评分预览') await apiRequest(`/surveys/${surveyId}/work-items`, { method: 'POST', - body: formData, + body: JSON.stringify({ + targetId, + title: title.trim() || '案例作品', + description: description.trim(), + images, + }), }) setTitle('') setDescription('') @@ -5079,21 +5109,21 @@ function SurveyResults({ surveyId }: { surveyId: string }) { const isCancelled = survey.status === 'cancelled' return ( -
-
+
+
返回
调研结果
-

+

{survey.title}

-
+
{survey.closedAt && 关闭 {formatDate(survey.closedAt)}}
@@ -5105,7 +5135,7 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
{isCancelled && ( -
+
此调研已撤销,页面数据仅供留档,不再作为当前考核结果。
)} @@ -5123,12 +5153,12 @@ function SurveyResults({ surveyId }: { surveyId: string }) { .filter((item: { value: unknown }) => item.value !== null && item.value !== undefined) return ( -
+
-
评价对象
-

{target.targetName}

-
+
评价对象
+

{target.targetName}

+
{visualTemplate ? '权威评分' : '有效'} {aggregate.effectiveCount || 0}/{aggregate.responseCount || 0} {aggregate.rawAverage != null && {visualTemplate ? '权威原始分' : '原始均分'} {formatResultScore(aggregate.rawAverage)}}
@@ -5140,13 +5170,13 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
{visualTemplate && ( -
+
最终分只采用指定最终评分人的评分;自评和主管评分保留在下方明细中,仅供参考。
)} {aggregate.band && aggregate.responseCount > 0 && ( -
+
{aggregate.band}
)} @@ -5156,12 +5186,12 @@ function SurveyResults({ surveyId }: { surveyId: string }) { {dimensionEntries.map(({ question, value }: { question: Template['questions'][number]; value: number }) => { const maxScore = getQuestionMaxScore(template, question) return ( -
+
-
{question.title}
+
{question.title}
{formatResultScore(value)}/{maxScore}
-
+
@@ -5171,7 +5201,7 @@ function SurveyResults({ surveyId }: { surveyId: string }) { )} {aggregate.responseCount > 0 && aggregate.trimNote && ( -
+
{aggregate.trimNote} {aggregate.removedHigh && aggregate.removedLow && ( ;最高 {aggregate.removedHigh.totalScore},最低 {aggregate.removedLow.totalScore} @@ -5181,16 +5211,16 @@ function SurveyResults({ surveyId }: { surveyId: string }) { {visualTemplate && workItems.length > 0 && (
-

视觉评分作品

+

视觉评分作品

{workItems.map((item: any, itemIndex: number) => ( -
+
作品 {itemIndex + 1}
-
{item.title}
- {item.description &&

{item.description}

} +
{item.title}
+ {item.description &&

{item.description}

}
- + {formatNullableScore(item.averageScore)} 分{visualTemplate ? '(最终评分)' : ''}
@@ -5209,13 +5239,13 @@ function SurveyResults({ surveyId }: { surveyId: string }) { )}
-

{visualTemplate ? '全部评分记录' : '评价明细'}

+

{visualTemplate ? '全部评分记录' : '评价明细'}

{target.responses.map((response: any) => ( -
+
- + {response.respondentName} {visualTemplate && ['visual_final', 'self_visual_final'].includes(response.workflowRole) && ( 计入最终分 @@ -5227,13 +5257,13 @@ function SurveyResults({ surveyId }: { surveyId: string }) { 主管评分 )} - {formatResultScore(response.totalScore)} 分 + {formatResultScore(response.totalScore)} 分
- {response.comment &&

{response.comment}

} + {response.comment &&

{response.comment}

}
))} {target.responses.length === 0 && ( -
暂无评价
+
暂无评价
)}
@@ -5305,7 +5335,7 @@ function StatusBadge({ status }: { status: string }) { processing: '处理中', } return ( - + {map[status] || status} ) @@ -5332,10 +5362,10 @@ function ResultMetric({ label, value, emphasis = false }: { label: string; value
{formatResultScore(value)}
-
{label}
+
{label}
) } diff --git a/lib/__tests__/performance-survey-image-processing.test.ts b/lib/__tests__/performance-survey-image-processing.test.ts index 911e5db..095e6ff 100644 --- a/lib/__tests__/performance-survey-image-processing.test.ts +++ b/lib/__tests__/performance-survey-image-processing.test.ts @@ -49,7 +49,7 @@ describe('performance survey image processing', () => { sourceName: `${index}.png`, })) - await expect(processPerformanceSurveyImages(inputs)).rejects.toThrow('最多上传 12 张图片') + await expect(processPerformanceSurveyImages(inputs)).rejects.toThrow('最多上传 60 张图片') }) test('downscales wide sources before slicing and creates a lightweight top preview', async () => { diff --git a/lib/chunk-upload.ts b/lib/chunk-upload.ts index 9d55030..70983b4 100644 --- a/lib/chunk-upload.ts +++ b/lib/chunk-upload.ts @@ -19,6 +19,7 @@ interface ChunkUploadOptions { skipComplete?: boolean // 如果为true,分片上传完成后立即返回,不调用completeUpload(用于后台异步处理) isCancelled?: () => boolean // 取消检查回调,返回 true 时停止上传 onActiveXhrChange?: (xhrs: Set) => void // 暴露所有活跃的XHR请求集合(用于批量取消) + requestHeaders?: Record // 供钉钉等独立登录态透传认证头 } interface ChunkUploadState { @@ -156,10 +157,15 @@ export function generateFileId(file: File): string { } // 初始化上传(获取uploadId) -async function initUpload(fileName: string, fileSize: number, totalChunks: number): Promise { +async function initUpload( + fileName: string, + fileSize: number, + totalChunks: number, + requestHeaders: Record = {}, +): Promise { const response = await fetch('/api/materials/upload/init', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...requestHeaders }, body: JSON.stringify({ fileName, fileSize, totalChunks }), }) @@ -189,6 +195,7 @@ async function uploadChunk( options: { onChunkProgress?: (loaded: number, total: number) => void onRequest?: (xhr: XMLHttpRequest | null) => void + requestHeaders?: Record } = {} ): Promise { const startTime = Date.now() @@ -212,6 +219,7 @@ async function uploadChunk( xhr.setRequestHeader('X-Chunk-Index', chunkIndex.toString()) xhr.setRequestHeader('X-File-Name', encodeURIComponent(fileName)) xhr.setRequestHeader('X-Chunk-Size', chunk.size.toString()) + Object.entries(options.requestHeaders || {}).forEach(([key, value]) => xhr.setRequestHeader(key, value)) // 记录开始上传时间 xhr.upload.onloadstart = () => { @@ -390,6 +398,7 @@ export async function chunkUpload( skipComplete = false, // 默认不跳过complete isCancelled, // 取消检查回调 onActiveXhrChange, // 活跃XHR集合回调 + requestHeaders = {}, } = options // 跟踪所有活跃的 XHR 请求(用于批量取消) @@ -426,7 +435,7 @@ export async function chunkUpload( // 初始化上传(如果需要) if (!state.uploadId) { try { - state.uploadId = await initUpload(file.name, file.size, totalChunks) + state.uploadId = await initUpload(file.name, file.size, totalChunks, requestHeaders) await saveUploadState(state) } catch (error) { if (!isRetryableUploadError(error)) { @@ -439,7 +448,7 @@ export async function chunkUpload( while (retries < maxRetries) { await new Promise(resolve => setTimeout(resolve, retryDelay)) try { - state.uploadId = await initUpload(file.name, file.size, totalChunks) + state.uploadId = await initUpload(file.name, file.size, totalChunks, requestHeaders) await saveUploadState(state) break } catch (e) { @@ -606,6 +615,7 @@ export async function chunkUpload( // 同时调用原来的回调 onRequest?.(xhr) }, + requestHeaders, }) state.uploadedChunks.add(chunkIndex) @@ -710,7 +720,7 @@ export async function chunkUpload( console.warn(`[ChunkUpload] ⚠️ 会话刷新失败(${refreshResult.error}),重新初始化上传...`) // 刷新失败,需要重新初始化 - state.uploadId = await initUpload(file.name, file.size, totalChunks) + state.uploadId = await initUpload(file.name, file.size, totalChunks, requestHeaders) state.uploadedChunks.clear() completedBytes = 0 await saveUploadState(state) diff --git a/lib/performance-survey-image-processing.ts b/lib/performance-survey-image-processing.ts index da84ae7..05b6878 100644 --- a/lib/performance-survey-image-processing.ts +++ b/lib/performance-survey-image-processing.ts @@ -1,16 +1,16 @@ import sharp from 'sharp' export const PERFORMANCE_SURVEY_IMAGE_LIMITS = { - maxSourceCount: 12, - maxSourceBytes: 20 * 1024 * 1024, - maxBatchSourceBytes: 60 * 1024 * 1024, + maxSourceCount: 60, + maxSourceBytes: 200 * 1024 * 1024, + maxLegacyBatchSourceBytes: 60 * 1024 * 1024, maxRequestBytes: 64 * 1024 * 1024, - maxPixelsPerImage: 40_000_000, + maxPixelsPerImage: 60_000_000, maxWidth: 12_000, maxOutputWidth: 1_600, sliceHeight: 1_500, - maxSlicesPerImage: 36, - maxSlicesPerBatch: 48, + maxSlicesPerImage: 48, + maxSlicesPerBatch: 2_880, targetSliceSizeKB: 500, previewWidth: 720, previewHeight: 560, @@ -117,7 +117,7 @@ export function assertPerformanceSurveyImageRequestSize(contentLength: string | const bytes = Number(contentLength) if (!Number.isFinite(bytes) || bytes <= 0) return if (bytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxRequestBytes) { - throw new PerformanceSurveyImageProcessingError('单个案例上传总大小不能超过 60MB') + throw new PerformanceSurveyImageProcessingError('旧版直传请求总大小不能超过 60MB,请刷新页面后重试') } } @@ -155,7 +155,7 @@ export async function inspectPerformanceSurveyImage( throw new PerformanceSurveyImageProcessingError(`文件「${input.sourceName}」不是图片`) } if (input.buffer.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) { - throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」超过 20MB`) + throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」超过 200MB`) } try { @@ -318,7 +318,7 @@ export async function processPerformanceSurveyImages( ) } const totalBytes = inputs.reduce((sum, input) => sum + input.buffer.length, 0) - if (totalBytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxBatchSourceBytes) { + if (totalBytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxLegacyBatchSourceBytes) { throw new PerformanceSurveyImageProcessingError('单个案例上传总大小不能超过 60MB') } diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index 23566f1..a128ebc 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -1,4 +1,6 @@ import { randomUUID } from 'crypto' +import { readFile, rm, unlink } from 'fs/promises' +import path from 'path' import type { Session } from 'next-auth' import { pinyin } from 'pinyin-pro' import { prisma } from '@/lib/prisma' @@ -42,6 +44,11 @@ import { } from '@/lib/performance-survey-image-processing' import { getPerformanceSurveyTargetEmployeeIds } from '@/lib/performance-survey-target-policy' import { deleteFromS3, uploadToS3 } from '@/lib/s3' +import { + deleteOwnedUploadSession, + getOwnedUploadSession, + getOwnedUploadedFile, +} from '@/lib/upload-sessions' export class PerformanceSurveyApiError extends Error { status: number @@ -2709,14 +2716,43 @@ function readImageFiles(formData: FormData) { .filter((value): value is File => typeof File !== 'undefined' && value instanceof File && value.size > 0) } +type PerformanceSurveyWorkItemImageSource = { + name: string + size: number + mimeType: string + readBuffer: () => Promise +} + +type PerformanceSurveyChunkedImage = { + uploadId?: unknown + mimeType?: unknown +} + +async function cleanupPerformanceSurveyChunkedUploads( + userId: string, + uploads: Array<{ uploadId: string; filePath: string }>, +) { + await Promise.allSettled(uploads.flatMap(({ uploadId, filePath }) => [ + unlink(filePath), + rm(path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId), { recursive: true, force: true }), + deleteOwnedUploadSession(uploadId, userId), + ])) +} + export async function createPerformanceSurveyWorkItemFromRequest( session: Session | null, surveyId: string, request: Request, ) { try { - assertPerformanceSurveyImageRequestSize(request.headers.get('content-length')) return await withPerformanceSurveyImageProcessingSlot(async () => { + const contentType = (request.headers.get('content-type') || '').toLowerCase() + if (contentType.includes('application/json')) { + const body = await request.json() + return createPerformanceSurveyWorkItemFromChunkedUploads(session, surveyId, body) + } + + assertPerformanceSurveyImageRequestSize(request.headers.get('content-length')) const formData = await request.formData() return createPerformanceSurveyWorkItem(session, surveyId, formData) }) @@ -2729,9 +2765,92 @@ export async function createPerformanceSurveyWorkItemFromRequest( } } +async function createPerformanceSurveyWorkItemFromChunkedUploads( + session: Session | null, + surveyId: string, + body: any, +) { + const userId = clean((session?.user as any)?.id) + if (!userId) throw new PerformanceSurveyApiError('请先登录', 401) + + const declaredImages = Array.isArray(body?.images) ? body.images as PerformanceSurveyChunkedImage[] : [] + if (declaredImages.length === 0) throw new PerformanceSurveyApiError('每个案例至少上传 1 张图片') + if (declaredImages.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) { + throw new PerformanceSurveyApiError(`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张图片`) + } + + const uploads: Array<{ uploadId: string; filePath: string }> = [] + const sources: PerformanceSurveyWorkItemImageSource[] = [] + try { + for (const declared of declaredImages) { + const uploadId = clean(declared?.uploadId) + const mimeType = clean(declared?.mimeType) || 'image/unknown' + if (!uploadId) throw new PerformanceSurveyApiError('图片上传凭证无效') + if (uploads.some((item) => item.uploadId === uploadId)) { + throw new PerformanceSurveyApiError('图片上传凭证重复') + } + if (!mimeType.startsWith('image/')) throw new PerformanceSurveyApiError('只支持上传图片文件') + + const uploadSession = await getOwnedUploadSession(uploadId, userId) + if (!uploadSession) throw new PerformanceSurveyApiError('图片上传已过期,请重新选择图片') + if (uploadSession.fileSize > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) { + throw new PerformanceSurveyApiError('单张图片不能超过 200MB') + } + const filePath = await getOwnedUploadedFile(uploadId, userId) + if (!filePath) throw new PerformanceSurveyApiError(`图片「${uploadSession.fileName}」上传不完整,请重试`) + + uploads.push({ uploadId, filePath }) + sources.push({ + name: uploadSession.fileName, + size: uploadSession.fileSize, + mimeType, + readBuffer: () => readFile(filePath), + }) + } + + return await createPerformanceSurveyWorkItemFromSources(session, surveyId, { + targetId: clean(body?.targetId), + title: clean(body?.title), + description: clean(body?.description), + sources, + }) + } finally { + await cleanupPerformanceSurveyChunkedUploads(userId, uploads) + } +} + export async function createPerformanceSurveyWorkItem(session: Session | null, surveyId: string, formData: FormData) { + const files = readImageFiles(formData) + const totalSourceBytes = files.reduce((sum, file) => sum + file.size, 0) + if (totalSourceBytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxLegacyBatchSourceBytes) { + throw new PerformanceSurveyApiError('旧版直传请求总大小不能超过 60MB,请刷新页面后重试') + } + + return createPerformanceSurveyWorkItemFromSources(session, surveyId, { + targetId: readFormText(formData, 'targetId'), + title: readFormText(formData, 'title'), + description: readFormText(formData, 'description'), + sources: files.map((file) => ({ + name: file.name, + size: file.size, + mimeType: file.type, + readBuffer: async () => Buffer.from(await file.arrayBuffer()), + })), + }) +} + +async function createPerformanceSurveyWorkItemFromSources( + session: Session | null, + surveyId: string, + input: { + targetId: string | null + title: string | null + description: string | null + sources: PerformanceSurveyWorkItemImageSource[] + }, +) { const ctx = await getPerformanceSurveyContext(session) - const targetId = readFormText(formData, 'targetId') + const targetId = input.targetId if (!targetId) throw new PerformanceSurveyApiError('缺少评价对象') const survey = await prisma.performanceSurvey.findUnique({ @@ -2756,29 +2875,25 @@ export async function createPerformanceSurveyWorkItem(session: Session | null, s throw new PerformanceSurveyApiError(`当前调研最多提交 ${requiredWorkItemCount} 个案例`) } - const files = readImageFiles(formData) - if (files.length === 0) throw new PerformanceSurveyApiError('每个案例至少上传 1 张图片') - if (files.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) { + const sources = input.sources + if (sources.length === 0) throw new PerformanceSurveyApiError('每个案例至少上传 1 张图片') + if (sources.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) { throw new PerformanceSurveyApiError(`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张图片`) } - const totalSourceBytes = files.reduce((sum, file) => sum + file.size, 0) - if (totalSourceBytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxBatchSourceBytes) { - throw new PerformanceSurveyApiError('单个案例上传总大小不能超过 60MB') - } - const title = readFormText(formData, 'title') || `案例 ${survey.workItems.length + 1}` - const description = readFormText(formData, 'description') + const title = input.title || `案例 ${survey.workItems.length + 1}` + const description = input.description const plans = [] - for (let sourceIndex = 0; sourceIndex < files.length; sourceIndex += 1) { - const file = files[sourceIndex] - if (!file.type.startsWith('image/')) throw new PerformanceSurveyApiError('只支持上传图片文件') - if (file.size > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) { - throw new PerformanceSurveyApiError('单张图片不能超过 20MB') + for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) { + const source = sources[sourceIndex] + if (!source.mimeType.startsWith('image/')) throw new PerformanceSurveyApiError('只支持上传图片文件') + if (source.size > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) { + throw new PerformanceSurveyApiError('单张图片不能超过 200MB') } const input = { - buffer: Buffer.from(await file.arrayBuffer()), - mimeType: file.type, - sourceName: file.name || `case-image-${sourceIndex + 1}`, + buffer: await source.readBuffer(), + mimeType: source.mimeType, + sourceName: source.name || `case-image-${sourceIndex + 1}`, } try { plans.push(await inspectPerformanceSurveyImage(input, sourceIndex)) @@ -2802,12 +2917,12 @@ export async function createPerformanceSurveyWorkItem(session: Session | null, s const uploadedUrls: string[] = [] const imageGroups: PerformanceSurveyStoredImageGroup[] = [] try { - for (let sourceIndex = 0; sourceIndex < files.length; sourceIndex += 1) { - const file = files[sourceIndex] + for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) { + const source = sources[sourceIndex] const input = { - buffer: Buffer.from(await file.arrayBuffer()), - mimeType: file.type, - sourceName: file.name || `case-image-${sourceIndex + 1}`, + buffer: await source.readBuffer(), + mimeType: source.mimeType, + sourceName: source.name || `case-image-${sourceIndex + 1}`, } const sliceRecords: PerformanceSurveyStoredImageGroup['slices'] = [] const processed = await processPerformanceSurveyImage(input, plans[sourceIndex], async (image) => { diff --git a/lib/upload-auth.ts b/lib/upload-auth.ts new file mode 100644 index 0000000..5637e10 --- /dev/null +++ b/lib/upload-auth.ts @@ -0,0 +1,19 @@ +import { getServerSession } from 'next-auth' +import type { NextRequest } from 'next/server' +import { authOptions } from '@/lib/auth' +import { + DINGTALK_PERFORMANCE_SESSION_HEADER, + verifyDingtalkPerformanceSessionToken, +} from '@/lib/dingtalk-performance-session' + +export async function getAuthenticatedUploadUserId(request: NextRequest): Promise { + const session = await getServerSession(authOptions) + const sessionUserId = String((session?.user as any)?.id || '').trim() + if (sessionUserId) return sessionUserId + + const headerToken = request.headers.get(DINGTALK_PERFORMANCE_SESSION_HEADER) + const authorization = request.headers.get('authorization') || '' + const bearerToken = authorization.match(/^Bearer\s+(.+)$/i)?.[1]?.trim() + const payload = verifyDingtalkPerformanceSessionToken(headerToken || bearerToken || null) + return payload?.userId ? String(payload.userId).trim() : null +} diff --git a/lib/upload-sessions.ts b/lib/upload-sessions.ts index 6c81f90..84373e0 100644 --- a/lib/upload-sessions.ts +++ b/lib/upload-sessions.ts @@ -346,7 +346,7 @@ export async function getUploadedFile(uploadId: string): Promise } const path = await import('path'); - const { readdir, readFile, writeFile, mkdir, access } = await import('fs/promises'); + const { readdir, readFile, mkdir, access, open, stat } = await import('fs/promises'); // Chunk文件所在目录 const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId); @@ -359,8 +359,11 @@ export async function getUploadedFile(uploadId: string): Promise // 先检查文件是否已经存在(避免重复组装) try { await access(outputPath); - console.log(`[Upload Session] File already exists, skip assembly: ${outputPath}`); - return outputPath; + const existingStats = await stat(outputPath); + if (existingStats.size === session.fileSize) { + console.log(`[Upload Session] File already exists, skip assembly: ${outputPath}`); + return outputPath; + } } catch (e) { // 文件不存在,需要组装 } @@ -378,23 +381,37 @@ export async function getUploadedFile(uploadId: string): Promise if (chunkFiles.length === 0) { return null; } + if (chunkFiles.length !== session.totalChunks) { + console.warn( + `[Upload Session] Incomplete chunks: ${uploadId}, expected=${session.totalChunks}, actual=${chunkFiles.length}`, + ); + return null; + } // 创建输出目录 await mkdir(outputDir, { recursive: true }); - // 按顺序读取并写入所有chunk - const chunks: Buffer[] = []; - for (const chunkFile of chunkFiles) { - const chunkPath = path.join(tempDir, chunkFile); - const chunkBuffer = await readFile(chunkPath); - chunks.push(chunkBuffer); + // 分片逐块写入,避免大文件合并时把所有分片同时堆进内存。 + const outputHandle = await open(outputPath, 'w'); + try { + for (const chunkFile of chunkFiles) { + const chunkPath = path.join(tempDir, chunkFile); + const chunkBuffer = await readFile(chunkPath); + await outputHandle.write(chunkBuffer); + } + } finally { + await outputHandle.close(); } - - // 合并所有chunk并写入文件 - const completeBuffer = Buffer.concat(chunks); - await writeFile(outputPath, completeBuffer); - - console.log(`[Upload Session] Assembled file: ${outputPath} (${completeBuffer.length} bytes)`); + + const outputStats = await stat(outputPath); + if (outputStats.size !== session.fileSize) { + console.warn( + `[Upload Session] Assembled size mismatch: ${uploadId}, expected=${session.fileSize}, actual=${outputStats.size}`, + ); + return null; + } + + console.log(`[Upload Session] Assembled file: ${outputPath} (${outputStats.size} bytes)`); return outputPath; } catch (error) { console.error(`[Upload Session] Failed to get uploaded file for ${uploadId}:`, error);