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);