优化考核调研结果页与大图上传

This commit is contained in:
chenpei 2026-07-13 17:42:27 +08:00
parent 88e3594cbb
commit 365e79f3a0
9 changed files with 322 additions and 133 deletions

View File

@ -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 })
}

View File

@ -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}`)

View File

@ -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({
<div className="space-y-3 border-t border-[#e2e2e2] pt-5 lg:col-span-2">
{error && <div className="border border-[#ffdad6] bg-[#fff6f4] px-4 py-3 text-sm font-semibold text-[#93000a]" role="alert">{error}</div>}
{!createdSurvey && (
<div
id="performance-survey-publish-readiness"
className={`px-4 py-3 text-[13px] font-semibold ${
publishValidationIssues.length > 0
? 'border border-[#f2d184] bg-[#fffaf0] text-[#8a4b08]'
: 'border border-[#b9ead0] bg-[#f0fff6] text-[#087a3c]'
}`}
aria-live="polite"
>
{publishValidationIssues.length > 0
? `还需完成:${publishValidationIssues.join(';')}`
: visualTemplateSelected
? '配置已完整,发布后请先完成自评'
: '配置已完整,可以发布调研'}
</div>
)}
<div
id="performance-survey-publish-readiness"
className={`px-4 py-3 text-[13px] font-semibold ${
publishValidationIssues.length > 0
? 'border border-[#f2d184] bg-[#fffaf0] text-[#8a4b08]'
: 'border border-[#b9ead0] bg-[#f0fff6] text-[#087a3c]'
}`}
aria-live="polite"
>
{publishValidationIssues.length > 0
? `还需完成:${publishValidationIssues.join(';')}`
: visualTemplateSelected
? '配置已完整,发布后请先完成自评'
: '配置已完整,可以发布调研'}
</div>
<button
type="button"
disabled={publishDisabled}
aria-describedby={createdSurvey ? undefined : 'performance-survey-publish-readiness'}
aria-describedby="performance-survey-publish-readiness"
onClick={submit}
className="h-12 w-full rounded bg-[#0050cb] px-5 text-[15px] font-bold text-white hover:bg-[#003fa4] disabled:cursor-not-allowed disabled:bg-[#aeb9c9]"
>
@ -3026,7 +3054,7 @@ function PerformanceSurveyImageDropzone({
>
<span className={`flex h-8 w-8 items-center justify-center rounded-full text-[20px] font-light ${dragging ? 'bg-[#0050cb] text-white' : 'bg-[#eef5ff] text-[#0050cb]'}`}>↑</span>
<span className="mt-2 text-[13px] font-bold text-[#0050cb]">{dragging ? '松开即可添加' : '拖拽图片到这里'}</span>
<span className="mt-0.5 text-[11px] text-[#98a2b3]">或点击选择</span>
<span className="mt-0.5 text-[11px] text-[#98a2b3]">或点击选择 · 最多 60 张,单张 200MB</span>
</button>
</div>
)
@ -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 (
<main className="mx-auto max-w-[430px] bg-white px-5 py-6 pb-24 text-[#101828] dark:bg-gray-950 sm:max-w-6xl sm:bg-transparent sm:py-6 md:pb-6">
<section className="mb-5 rounded-xl bg-white px-5 py-5 shadow-[0_12px_30px_rgba(45,43,80,0.08)] dark:border dark:border-gray-700 dark:bg-gray-900">
<main className="mx-auto min-h-screen max-w-[430px] bg-[#f6f8fc] px-5 py-6 pb-24 text-[#101828] sm:max-w-6xl sm:py-6 md:pb-6">
<section className="mb-5 rounded-xl border border-[#e3e9f2] bg-white px-5 py-5 shadow-[0_12px_30px_rgba(45,43,80,0.08)]">
<Link
href={backHref}
className="inline-flex min-h-[34px] items-center rounded-full bg-[#f3f6fb] px-3 text-[13px] font-semibold text-[#344054] hover:bg-[#e8eef7] dark:bg-gray-800 dark:text-gray-100"
className="inline-flex min-h-[34px] items-center rounded-full bg-[#f3f6fb] px-3 text-[13px] font-semibold text-[#344054] hover:bg-[#e8eef7]"
>
返回
</Link>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-[#0052d9]">调研结果</div>
<h1 className="mt-2 break-words text-[26px] font-semibold leading-tight tracking-[-0.01em] text-[#101828] dark:text-white sm:text-3xl">
<h1 className="mt-2 break-words text-[26px] font-semibold leading-tight tracking-[-0.01em] text-[#101828] sm:text-3xl">
{survey.title}
</h1>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] font-medium text-[#667085] dark:text-gray-300">
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] font-medium text-[#667085]">
<StatusBadge status={survey.status} />
{survey.closedAt && <span>关闭 {formatDate(survey.closedAt)}</span>}
</div>
@ -5105,7 +5135,7 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
</div>
</div>
{isCancelled && (
<div className="mt-4 rounded-[10px] border border-[#fecdca] bg-[#fff6f5] px-4 py-3 text-sm font-medium text-[#b42318] dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
<div className="mt-4 rounded-[10px] border border-[#fecdca] bg-[#fff6f5] px-4 py-3 text-sm font-medium text-[#b42318]">
此调研已撤销,页面数据仅供留档,不再作为当前考核结果。
</div>
)}
@ -5123,12 +5153,12 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
.filter((item: { value: unknown }) => item.value !== null && item.value !== undefined)
return (
<article key={target.id} className="rounded-xl bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)] dark:border dark:border-gray-700 dark:bg-gray-900">
<article key={target.id} className="rounded-xl border border-[#e3e9f2] bg-white px-5 py-5 shadow-[0_10px_24px_rgba(45,43,80,0.07)]">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-[13px] font-semibold text-[#667085] dark:text-gray-400">评价对象</div>
<h2 className="mt-2 break-words text-[22px] font-semibold leading-tight text-[#101828] dark:text-white">{target.targetName}</h2>
<div className="mt-3 flex flex-wrap gap-2 text-[13px] font-medium text-[#667085] dark:text-gray-300">
<div className="text-[13px] font-semibold text-[#667085]">评价对象</div>
<h2 className="mt-2 break-words text-[22px] font-semibold leading-tight text-[#101828]">{target.targetName}</h2>
<div className="mt-3 flex flex-wrap gap-2 text-[13px] font-medium text-[#667085]">
<span>{visualTemplate ? '权威评分' : '有效'} {aggregate.effectiveCount || 0}/{aggregate.responseCount || 0}</span>
{aggregate.rawAverage != null && <span>{visualTemplate ? '权威原始分' : '原始均分'} {formatResultScore(aggregate.rawAverage)}</span>}
</div>
@ -5140,13 +5170,13 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
</div>
{visualTemplate && (
<div className="mt-4 rounded-[10px] border border-[#c7d7fe] bg-[#f5f8ff] px-4 py-3 text-[13px] leading-5 text-[#344054] dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-gray-200">
<div className="mt-4 rounded-[10px] border border-[#c7d7fe] bg-[#f5f8ff] px-4 py-3 text-[13px] leading-5 text-[#344054]">
最终分只采用指定最终评分人的评分;自评和主管评分保留在下方明细中,仅供参考。
</div>
)}
{aggregate.band && aggregate.responseCount > 0 && (
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm font-semibold text-[#344054] dark:bg-gray-800 dark:text-gray-200">
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm font-semibold text-[#344054]">
{aggregate.band}
</div>
)}
@ -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 (
<div key={question.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3 dark:bg-gray-800">
<div key={question.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="truncate text-sm font-semibold text-[#101828] dark:text-white">{question.title}</div>
<div className="truncate text-sm font-semibold text-[#101828]">{question.title}</div>
<div className="shrink-0 text-sm font-semibold text-[#0052d9]">{formatResultScore(value)}/{maxScore}</div>
</div>
<div className="mt-3 h-2 rounded-full bg-[#e8eef7] dark:bg-gray-700">
<div className="mt-3 h-2 rounded-full bg-[#e8eef7]">
<div className="h-full rounded-full bg-[#0052d9]" style={{ width: `${getScorePercent(value, maxScore)}%` }} />
</div>
</div>
@ -5171,7 +5201,7 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
)}
{aggregate.responseCount > 0 && aggregate.trimNote && (
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085] dark:bg-gray-800 dark:text-gray-300">
<div className="mt-4 rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085]">
{aggregate.trimNote}
{aggregate.removedHigh && aggregate.removedLow && (
<span>;最高 {aggregate.removedHigh.totalScore},最低 {aggregate.removedLow.totalScore}</span>
@ -5181,16 +5211,16 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
{visualTemplate && workItems.length > 0 && (
<section className="mt-5 space-y-4">
<h3 className="text-sm font-semibold text-[#101828] dark:text-white">视觉评分作品</h3>
<h3 className="text-sm font-semibold text-[#101828]">视觉评分作品</h3>
{workItems.map((item: any, itemIndex: number) => (
<div key={item.id} className="rounded-[14px] border border-[#e5edf6] bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<div key={item.id} className="rounded-[14px] border border-[#e5edf6] bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[12px] font-semibold text-[#0052d9]">作品 {itemIndex + 1}</div>
<div className="mt-1 break-words text-[16px] font-semibold text-[#101828] dark:text-white">{item.title}</div>
{item.description && <p className="mt-1 whitespace-pre-wrap text-sm text-[#667085] dark:text-gray-300">{item.description}</p>}
<div className="mt-1 break-words text-[16px] font-semibold text-[#101828]">{item.title}</div>
{item.description && <p className="mt-1 whitespace-pre-wrap text-sm text-[#667085]">{item.description}</p>}
</div>
<span className="shrink-0 rounded bg-[#f3f6fb] px-3 py-1.5 text-sm font-semibold text-[#0052d9] dark:bg-gray-800">
<span className="shrink-0 rounded bg-[#f3f6fb] px-3 py-1.5 text-sm font-semibold text-[#0052d9]">
{formatNullableScore(item.averageScore)} 分{visualTemplate ? '(最终评分)' : ''}
</span>
</div>
@ -5209,13 +5239,13 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
)}
<div className="mt-5">
<h3 className="text-sm font-semibold text-[#101828] dark:text-white">{visualTemplate ? '全部评分记录' : '评价明细'}</h3>
<h3 className="text-sm font-semibold text-[#101828]">{visualTemplate ? '全部评分记录' : '评价明细'}</h3>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{target.responses.map((response: any) => (
<div key={response.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3 dark:bg-gray-800">
<div key={response.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-[#101828] dark:text-white">
<span className="text-sm font-semibold text-[#101828]">
{response.respondentName}
{visualTemplate && ['visual_final', 'self_visual_final'].includes(response.workflowRole) && (
<span className="ml-2 rounded-full bg-[#e8f0ff] px-2 py-0.5 text-[11px] text-[#0050cb]">计入最终分</span>
@ -5227,13 +5257,13 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
<span className="ml-2 rounded-full bg-[#f0f1f3] px-2 py-0.5 text-[11px] text-[#5f5e5e]">主管评分</span>
)}
</span>
<span className="text-sm font-semibold text-[#667085] dark:text-gray-300">{formatResultScore(response.totalScore)} 分</span>
<span className="text-sm font-semibold text-[#667085]">{formatResultScore(response.totalScore)} 分</span>
</div>
{response.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#344054] dark:text-gray-300">{response.comment}</p>}
{response.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#344054]">{response.comment}</p>}
</div>
))}
{target.responses.length === 0 && (
<div className="rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085] dark:bg-gray-800 dark:text-gray-300">暂无评价</div>
<div className="rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085]">暂无评价</div>
)}
</div>
</article>
@ -5305,7 +5335,7 @@ function StatusBadge({ status }: { status: string }) {
processing: '处理中',
}
return (
<span className="inline-flex rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
<span className="inline-flex rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700">
{map[status] || status}
</span>
)
@ -5332,10 +5362,10 @@ function ResultMetric({ label, value, emphasis = false }: { label: string; value
<div className={`min-w-0 rounded-[12px] px-3 py-3 text-center ${
emphasis
? 'bg-[#0052d9] text-white'
: 'bg-[#f8fafc] text-[#101828] dark:bg-gray-800 dark:text-white'
: 'bg-[#f8fafc] text-[#101828]'
}`}>
<div className="truncate text-[22px] font-semibold leading-none">{formatResultScore(value)}</div>
<div className={`mt-1 truncate text-[11px] font-semibold ${emphasis ? 'text-white/75' : 'text-[#667085] dark:text-gray-400'}`}>{label}</div>
<div className={`mt-1 truncate text-[11px] font-semibold ${emphasis ? 'text-white/75' : 'text-[#667085]'}`}>{label}</div>
</div>
)
}

View File

@ -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 () => {

View File

@ -19,6 +19,7 @@ interface ChunkUploadOptions {
skipComplete?: boolean // 如果为true,分片上传完成后立即返回,不调用completeUpload(用于后台异步处理)
isCancelled?: () => boolean // 取消检查回调,返回 true 时停止上传
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => void // 暴露所有活跃的XHR请求集合(用于批量取消)
requestHeaders?: Record<string, string> // 供钉钉等独立登录态透传认证头
}
interface ChunkUploadState {
@ -156,10 +157,15 @@ export function generateFileId(file: File): string {
}
// 初始化上传(获取uploadId)
async function initUpload(fileName: string, fileSize: number, totalChunks: number): Promise<string> {
async function initUpload(
fileName: string,
fileSize: number,
totalChunks: number,
requestHeaders: Record<string, string> = {},
): Promise<string> {
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<string, string>
} = {}
): Promise<void> {
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)

View File

@ -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')
}

View File

@ -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<Buffer>
}
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) => {

19
lib/upload-auth.ts Normal file
View File

@ -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<string | null> {
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
}

View File

@ -346,7 +346,7 @@ export async function getUploadedFile(uploadId: string): Promise<string | null>
}
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<string | null>
// 先检查文件是否已经存在(避免重复组装)
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<string | null>
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);