优化考核调研结果页与大图上传
This commit is contained in:
parent
88e3594cbb
commit
365e79f3a0
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
|
||||
@ -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
19
lib/upload-auth.ts
Normal 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
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user