diff --git a/app/api/materials/upload/refresh/route.ts b/app/api/materials/upload/refresh/route.ts index f3e7005..b7e9255 100644 --- a/app/api/materials/upload/refresh/route.ts +++ b/app/api/materials/upload/refresh/route.ts @@ -54,12 +54,12 @@ export async function POST(req: NextRequest) { ) } - console.log(`[RefreshSession] 会话刷新成功: ${uploadId}, TTL: 4小时`) + console.log(`[RefreshSession] 会话刷新成功: ${uploadId}, TTL: 8小时`) return NextResponse.json({ success: true, uploadId, - ttl: 4 * 60 * 60 // 4小时(秒) + ttl: 8 * 60 * 60 // 8小时(秒) }) } catch (error) { console.error('[RefreshSession] 刷新会话失败:', error) diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx index 6e1bd5a..891ad58 100644 --- a/components/upload/ProductPackageUploader.test.tsx +++ b/components/upload/ProductPackageUploader.test.tsx @@ -331,6 +331,8 @@ describe('ProductPackageUploader', () => { maxPsdFileCount: 10, maxTotalSourceFileCount: 10, maxTotalSourceBytes: 100, + maxSinglePsdBytes: 100, + maxTotalPsdBytes: 100, } const groupResult = selectTaskDetailFilesWithinLimits( @@ -376,7 +378,17 @@ describe('ProductPackageUploader', () => { { ...baseLimits, maxTotalSourceBytes: 4 }, ) expect(totalBytesResult.acceptedFiles.map((item) => item.name)).toEqual(['fits.jpg']) - expect(totalBytesResult.errors.join('')).toContain('原图和源文件总大小最多 4B') + expect(totalBytesResult.errors.join('')).toContain('本次图片总大小最多 4B') + + const largePsd = file('sku-sheet.psd', 80) + const psdBytesResult = selectTaskDetailFilesWithinLimits( + 'psd', + [largePsd], + { ...emptySnapshot(), main: [file('hero.jpg', 90)] }, + { ...baseLimits, maxTotalSourceBytes: 100, maxSinglePsdBytes: 100, maxTotalPsdBytes: 100 }, + ) + expect(psdBytesResult.acceptedFiles.map((item) => item.name)).toEqual(['sku-sheet.psd']) + expect(psdBytesResult.errors).toEqual([]) }) test('快速连续选图使用同步快照计数,超限文件不创建 Blob URL', async () => { diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index b8b0825..5cfbac8 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -115,11 +115,13 @@ type TaskDetailFileSelectionLimitKey = | 'maxTotalImageCount' | 'maxPsdFileCount' | 'maxTotalSourceFileCount' - | 'maxTotalSourceBytes'; + | 'maxTotalSourceBytes' + | 'maxSinglePsdBytes' + | 'maxTotalPsdBytes'; type TaskDetailFileSelectionLimits = Readonly>; -type SelectionLimitReason = 'group' | 'totalImages' | 'totalSources' | 'totalBytes'; +type SelectionLimitReason = 'group' | 'totalImages' | 'totalSources' | 'totalBytes' | 'psdSingle' | 'psdBytes'; const UPLOAD_GROUP_LABELS: Record = { main: '主图', @@ -153,14 +155,17 @@ export function selectTaskDetailFilesWithinLimits( totalImages: [], totalSources: [], totalBytes: [], + psdSingle: [], + psdBytes: [], }; const acceptedFiles: File[] = []; const isImageGroup = group !== 'psd'; let groupCount = existing[group].length; let totalImageCount = existing.main.length + existing.sku.length + existing.detail.length; let totalSourceCount = totalImageCount + existing.psd.length; - let totalSourceBytes = Object.values(existing) - .flat() + let totalImageBytes = [...existing.main, ...existing.sku, ...existing.detail] + .reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0); + let totalPsdBytes = existing.psd .reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0); candidates.forEach((file) => { @@ -172,8 +177,12 @@ export function selectTaskDetailFilesWithinLimits( reason = 'totalImages'; } else if (totalSourceCount >= limits.maxTotalSourceFileCount) { reason = 'totalSources'; - } else if (totalSourceBytes + file.size > limits.maxTotalSourceBytes) { + } else if (isImageGroup && totalImageBytes + file.size > limits.maxTotalSourceBytes) { reason = 'totalBytes'; + } else if (!isImageGroup && file.size > limits.maxSinglePsdBytes) { + reason = 'psdSingle'; + } else if (!isImageGroup && totalPsdBytes + file.size > limits.maxTotalPsdBytes) { + reason = 'psdBytes'; } if (reason) { @@ -183,9 +192,13 @@ export function selectTaskDetailFilesWithinLimits( acceptedFiles.push(file); groupCount += 1; - if (isImageGroup) totalImageCount += 1; + if (isImageGroup) { + totalImageCount += 1; + totalImageBytes += file.size; + } else { + totalPsdBytes += file.size; + } totalSourceCount += 1; - totalSourceBytes += file.size; }); const errors: string[] = []; @@ -207,7 +220,17 @@ export function selectTaskDetailFilesWithinLimits( } if (rejected.totalBytes.length > 0) { errors.push( - `本次原图和源文件总大小最多 ${formatSelectionLimitBytes(limits.maxTotalSourceBytes)},已忽略 ${summarizeRejectedFiles(rejected.totalBytes)};请压缩、删除部分文件,或分批提交`, + `本次图片总大小最多 ${formatSelectionLimitBytes(limits.maxTotalSourceBytes)},已忽略 ${summarizeRejectedFiles(rejected.totalBytes)};请压缩、删除部分图片,或分批提交`, + ); + } + if (rejected.psdSingle.length > 0) { + errors.push( + `单个源文件最多 ${formatSelectionLimitBytes(limits.maxSinglePsdBytes)},已忽略 ${summarizeRejectedFiles(rejected.psdSingle)}`, + ); + } + if (rejected.psdBytes.length > 0) { + errors.push( + `本次源文件总大小最多 ${formatSelectionLimitBytes(limits.maxTotalPsdBytes)},已忽略 ${summarizeRejectedFiles(rejected.psdBytes)};请分批提交`, ); } diff --git a/lib/__tests__/product-package-completion.test.ts b/lib/__tests__/product-package-completion.test.ts index 37013ee..65e676e 100644 --- a/lib/__tests__/product-package-completion.test.ts +++ b/lib/__tests__/product-package-completion.test.ts @@ -70,6 +70,7 @@ jest.mock('@/lib/task-material-processor', () => ({ jest.mock('@/lib/upload-sessions', () => ({ getUploadedFile: jest.fn(), getOwnedUploadSession: jest.fn().mockResolvedValue({ userId: 'user-1' }), + refreshOwnedUploadSessionTtls: jest.fn().mockResolvedValue(undefined), })) jest.mock('@/lib/material-log', () => ({ @@ -219,6 +220,43 @@ describe('product-package-completion', () => { }, 'user-1')).resolves.toBeUndefined() }) + test('PSD 源文件不占用图片 1GB 总量,单文件按 10GB 计', async () => { + const twoGb = 2 * 1024 * 1024 * 1024 + ;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({ + userId: 'user-1', + fileSize: uploadId === 'preview-1' ? 1 : uploadId === 'source-1' ? twoGb : 10 * 1024 * 1024, + })) + + await expect(assertOwnedProductPackageUploadSessions({ + main: [{ + uploadId: 'image-1', + fileName: 'main.png', + fileSize: 10 * 1024 * 1024, + previewAssets: [{ + uploadId: 'preview-1', + fileName: 'preview.webp', + fileSize: 1, + width: 1, + height: 1, + kind: 'preview', + }], + }], + psd: [{ + uploadId: 'source-1', + fileName: '顾家750SKU.psd', + fileSize: twoGb, + }], + }, 'user-1')).resolves.toBeUndefined() + + await expect(() => assertTaskDetailUploadAssetLimits({ + psd: [{ + uploadId: 'source-huge', + fileName: 'too-big.psd', + fileSize: 10 * 1024 * 1024 * 1024 + 1, + }], + })).toThrow('超过 10GB') + }) + test('客户端 WebP 预览使用文件路径读取真实元数据并匹配申报尺寸', async () => { const metadata = jest.fn().mockResolvedValue({ format: 'webp', @@ -594,7 +632,7 @@ describe('product-package-completion', () => { assets: { main: [{ uploadId: 'upload-other', fileName: 'main.png', fileSize: 10 }], }, - })).rejects.toThrow('不属于当前用户') + })).rejects.toThrow('上传已过期') }) test('同批同名 PSD 不会覆盖,成功后会清理全部上传会话', async () => { diff --git a/lib/__tests__/task-detail-upload-jobs.test.ts b/lib/__tests__/task-detail-upload-jobs.test.ts index 6d10685..69d015f 100644 --- a/lib/__tests__/task-detail-upload-jobs.test.ts +++ b/lib/__tests__/task-detail-upload-jobs.test.ts @@ -52,9 +52,14 @@ jest.mock('@/lib/task-detail-upload-runtime', () => ({ jest.mock('@/lib/product-package-completion', () => ({ assertTaskDetailUploadAssetLimits: jest.fn(), + assertOwnedProductPackageUploadSessions: jest.fn(), completeProductPackageUpload: jest.fn(), })) +jest.mock('@/lib/upload-sessions', () => ({ + refreshOwnedUploadSessionTtls: jest.fn().mockResolvedValue(undefined), +})) + const now = new Date('2026-08-01T00:00:00.000Z') const releaseExecutionLock = Object.assign(jest.fn(async () => undefined), { assertHeld: jest.fn(), diff --git a/lib/product-package-completion.ts b/lib/product-package-completion.ts index 49f50b4..e584524 100644 --- a/lib/product-package-completion.ts +++ b/lib/product-package-completion.ts @@ -13,7 +13,11 @@ import { } from '@/lib/task-material-processor' import { buildStoredUploadPath, getUploadRootDir } from '@/lib/upload-paths' import { isRemoteUploadSyncEnabled, syncStoredUploadFileToRemote } from '@/lib/remote-upload-sync' -import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions' +import { + getOwnedUploadSession, + getUploadedFile, + refreshOwnedUploadSessionTtls, +} from '@/lib/upload-sessions' import { createMaterialLog } from '@/lib/material-log' import { isTaskDetailFullSetFromUploadGroups } from '@/lib/task-detail-full-set' import { @@ -140,23 +144,33 @@ export function assertTaskDetailUploadAssetLimits(assets: ProductPackageAssetsIn throw new Error(`本次原图和源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount} 个,请分批提交`) } - let totalSourceBytes = 0 + let totalImageBytes = 0 + let totalPsdBytes = 0 for (const asset of sourceAssets) { if (!Number.isFinite(asset.fileSize) || asset.fileSize <= 0) { throw new Error(`文件大小无效: ${asset.fileName}`) } - if ( - imageAssets.includes(asset as ProductPackageImageInput) - && asset.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes - ) { + const isImage = imageAssets.includes(asset as ProductPackageImageInput) + if (isImage && asset.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) { throw new Error( `图片 ${asset.fileName} 超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`, ) } - totalSourceBytes += asset.fileSize + if (!isImage && asset.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes) { + throw new Error( + `源文件 ${asset.fileName} 超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes / 1024 / 1024 / 1024)}GB`, + ) + } + if (isImage) totalImageBytes += asset.fileSize + else totalPsdBytes += asset.fileSize } - if (totalSourceBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) { - throw new Error('本次上传素材总大小超过 1GB,请分批提交') + if (totalImageBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) { + throw new Error('本次图片总大小超过 1GB,请分批提交') + } + if (totalPsdBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes) { + throw new Error( + `本次源文件总大小超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes / 1024 / 1024 / 1024)}GB,请分批提交`, + ) } let totalPreviewCount = 0 @@ -423,29 +437,39 @@ export async function assertOwnedProductPackageUploadSessions( const allAssets = [...imageAssets, ...(assets.psd || [])] const seenUploadIds = new Set() const maxImageBytes = TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes + const maxPsdBytes = TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes const configuredMaxPreviewBytes = Number(process.env.TASK_DETAIL_PREVIEW_MAX_BYTES) const maxPreviewBytes = Number.isFinite(configuredMaxPreviewBytes) && configuredMaxPreviewBytes > 0 ? configuredMaxPreviewBytes : 10 * 1024 * 1024 - const maxTotalSourceBytes = TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes + const maxTotalImageBytes = TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes + const maxTotalPsdBytes = TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes const maxTotalPreviewBytes = TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewBytes const maxTotalPreviewCount = TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount - let totalSourceBytes = 0 + let totalImageBytes = 0 + let totalPsdBytes = 0 let totalPreviewBytes = 0 let totalPreviewCount = 0 for (const asset of allAssets) { const uploadId = String(asset.uploadId || '').trim() + const isImage = imageAssets.includes(asset) if (!uploadId) { // 内部历史导入仍可传受信任的本地路径/已签名地址;公开 HTTP 路由在 // parsePublicProductPackageAssets 阶段已经拒绝这两类来源。 if (!('fileUrl' in asset && asset.fileUrl) && !('localFilePath' in asset && asset.localFilePath)) { throw new Error(`资产缺少 uploadId: ${asset.fileName}`) } - if (imageAssets.includes(asset) && asset.fileSize > maxImageBytes) { + if (isImage && asset.fileSize > maxImageBytes) { throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`) } - totalSourceBytes += Math.max(0, asset.fileSize) + if (!isImage && asset.fileSize > maxPsdBytes) { + throw new Error( + `源文件 ${asset.fileName} 超过 ${Math.round(maxPsdBytes / 1024 / 1024 / 1024)}GB`, + ) + } + if (isImage) totalImageBytes += Math.max(0, asset.fileSize) + else totalPsdBytes += Math.max(0, asset.fileSize) continue } if (seenUploadIds.has(uploadId)) { @@ -453,16 +477,23 @@ export async function assertOwnedProductPackageUploadSessions( } const session = await getOwnedUploadSession(uploadId, operatorUserId) if (!session) { - throw new Error(`上传会话不存在、已失效或不属于当前用户: ${uploadId}`) + console.warn('[ProductPackage] 上传会话不可用', { uploadId, fileName: asset.fileName, operatorUserId }) + throw new Error(`「${asset.fileName}」上传已过期,请重新选择这个文件后再提交`) } seenUploadIds.add(uploadId) if (session.fileSize !== asset.fileSize) { throw new Error(`文件大小校验失败,请重新上传: ${asset.fileName}`) } - if (imageAssets.includes(asset) && session.fileSize > maxImageBytes) { + if (isImage && session.fileSize > maxImageBytes) { throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`) } - totalSourceBytes += session.fileSize + if (!isImage && session.fileSize > maxPsdBytes) { + throw new Error( + `源文件 ${asset.fileName} 超过 ${Math.round(maxPsdBytes / 1024 / 1024 / 1024)}GB`, + ) + } + if (isImage) totalImageBytes += session.fileSize + else totalPsdBytes += session.fileSize } for (const asset of imageAssets) { @@ -511,8 +542,13 @@ export async function assertOwnedProductPackageUploadSessions( } } - if (totalSourceBytes > maxTotalSourceBytes) { - throw new Error('本次上传素材总大小超过 1GB,请分批提交') + if (totalImageBytes > maxTotalImageBytes) { + throw new Error('本次图片总大小超过 1GB,请分批提交') + } + if (totalPsdBytes > maxTotalPsdBytes) { + throw new Error( + `本次源文件总大小超过 ${Math.round(maxTotalPsdBytes / 1024 / 1024 / 1024)}GB,请分批提交`, + ) } if (totalPreviewBytes > maxTotalPreviewBytes) { throw new Error('本次客户端预览图总大小超过 512MB,请分批提交') @@ -1237,6 +1273,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage releaseCompletionLock = await acquireTaskMaterialCompletionLock(materialId) if (requestedUploadIds.length > 0) { releaseUploadSessionLocks = await acquireTaskMaterialUploadSessionLocks(requestedUploadIds) + await refreshOwnedUploadSessionTtls(requestedUploadIds) } await assertOwnedProductPackageUploadSessions(assets, operatorUserId) await reportProgress('validating', 5) diff --git a/lib/task-detail-upload-jobs.ts b/lib/task-detail-upload-jobs.ts index e318c55..e3cdee6 100644 --- a/lib/task-detail-upload-jobs.ts +++ b/lib/task-detail-upload-jobs.ts @@ -2,11 +2,13 @@ import type { Prisma } from '@prisma/client' import { randomUUID } from 'crypto' import { prisma } from '@/lib/prisma' import { + assertOwnedProductPackageUploadSessions, assertTaskDetailUploadAssetLimits, completeProductPackageUpload, type ProductPackageAssetsInput, type CompleteProductPackageUploadInput, } from '@/lib/product-package-completion' +import { refreshOwnedUploadSessionTtls } from '@/lib/upload-sessions' import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' import { acquireTaskDetailUploadExecutionLock, @@ -658,6 +660,18 @@ export function triggerTaskDetailUploadJob(jobId: string) { export async function createTaskDetailUploadJob(input: CreateTaskDetailUploadJobInput) { assertTaskDetailUploadAssetLimits(input.assets) + await assertOwnedProductPackageUploadSessions(input.assets, input.operatorUserId) + const uploadIds = [ + ...(input.assets.main || []), + ...(input.assets.sku || []), + ...(input.assets.detail || []), + ...(input.assets.psd || []), + ].flatMap((asset) => [ + String(asset.uploadId || '').trim(), + ...((asset as { previewAssets?: Array<{ uploadId?: string }> }).previewAssets || []) + .map((preview) => String(preview.uploadId || '').trim()), + ]).filter(Boolean) + await refreshOwnedUploadSessionTtls(uploadIds) await ensureProductPackageUploadJobTable() const job = await prisma.$transaction(async (tx) => { await acquireTaskDetailUploadLock(tx, 'product-package-upload-create') diff --git a/lib/task-detail-upload-policy.ts b/lib/task-detail-upload-policy.ts index 6c656c0..b2f8d6b 100644 --- a/lib/task-detail-upload-policy.ts +++ b/lib/task-detail-upload-policy.ts @@ -1,3 +1,5 @@ +import { MAX_LOGICAL_UPLOAD_BYTES } from '@/lib/material-upload-chunk-policy' + export const TASK_DETAIL_UPLOAD_LIMITS = { /** 单个图片原图的保护上限,避免浏览器解码/服务器兜底处理一次吞掉过多内存。 */ maxSingleImageBytes: 100 * 1024 * 1024, @@ -8,8 +10,12 @@ export const TASK_DETAIL_UPLOAD_LIMITS = { maxPsdFileCount: 20, /** 原图与源文件的总条目数上限。 */ maxTotalSourceFileCount: 140, - /** 原图总大小保护上限,给大批量上传留出空间,同时避免误操作打爆客户端和服务器。 */ + /** 仅图片原图总大小。PSD/压缩包不算进这项,避免把源文件误卡在 1GB。 */ maxTotalSourceBytes: 1024 * 1024 * 1024, + /** 单个 PSD/压缩包与分片上传单文件上限对齐。 */ + maxSinglePsdBytes: MAX_LOGICAL_UPLOAD_BYTES, + /** 一次提交的源文件总量:大约够主图/SKU/详情各一份大源文件。 */ + maxTotalPsdBytes: 3 * MAX_LOGICAL_UPLOAD_BYTES, maxPreviewCountPerImage: 60, maxTotalPreviewCount: 1_000, maxSinglePreviewBytes: 10 * 1024 * 1024, @@ -68,10 +74,32 @@ export function validateTaskDetailUploadFiles(groups: TaskDetailUploadFileGroups if (allFiles.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount) { return `本次原图和源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount} 个,请分批提交` } - const totalBytes = allFiles.reduce((sum, file) => sum + Math.max(0, file.size), 0) - if (totalBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) { - return `本次素材总大小超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes / 1024 / 1024 / 1024)}GB,请分批提交` + const totalImageBytes = imageFiles.reduce((sum, file) => sum + Math.max(0, file.size), 0) + if (totalImageBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) { + return `本次图片总大小超过 ${formatTaskDetailByteLimit(TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes)},请分批提交` + } + const oversizedPsd = psdFiles.find((file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes) + if (oversizedPsd) { + return `源文件「${oversizedPsd.name}」超过 ${formatTaskDetailByteLimit(TASK_DETAIL_UPLOAD_LIMITS.maxSinglePsdBytes)},请先拆分或压缩` + } + const totalPsdBytes = psdFiles.reduce((sum, file) => sum + Math.max(0, file.size), 0) + if (totalPsdBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes) { + return `本次源文件总大小超过 ${formatTaskDetailByteLimit(TASK_DETAIL_UPLOAD_LIMITS.maxTotalPsdBytes)},请分批提交` } return null } + +export function formatTaskDetailByteLimit(bytes: number): string { + const gib = 1024 * 1024 * 1024 + const mib = 1024 * 1024 + if (bytes >= gib) { + const value = bytes / gib + return Number.isInteger(value) ? `${value}GB` : `${value.toFixed(1)}GB` + } + if (bytes >= mib) { + const value = bytes / mib + return Number.isInteger(value) ? `${value}MB` : `${value.toFixed(1)}MB` + } + return `${bytes}B` +} diff --git a/lib/upload-sessions-redis.ts b/lib/upload-sessions-redis.ts index efff962..40a887e 100644 --- a/lib/upload-sessions-redis.ts +++ b/lib/upload-sessions-redis.ts @@ -37,7 +37,7 @@ const redis = new Redis(redisUrl, { // Redis键前缀 const KEY_PREFIX = 'upload:session:' -const EXPIRE_TIME = 4 * 60 * 60 // 4小时(秒),支持大文件上传(17.5分钟×13≈220GB) +const EXPIRE_TIME = 8 * 60 * 60 // 8小时,覆盖任务-详情跳过 complete 后排队处理的窗口 function parsePositiveIntEnv(name: string, fallback: number): number { const value = Number.parseInt(process.env[name] || '', 10) diff --git a/lib/upload-sessions.ts b/lib/upload-sessions.ts index 1a63fd5..46cb2df 100644 --- a/lib/upload-sessions.ts +++ b/lib/upload-sessions.ts @@ -336,6 +336,19 @@ export async function getUploadSession( ) } +export async function refreshOwnedUploadSessionTtls(uploadIds: string[]): Promise { + const uniqueIds = Array.from(new Set(uploadIds.map((id) => String(id || '').trim()).filter(isSafeUploadId))) + if (uniqueIds.length === 0) return + + await withRedisFallback( + 'refreshOwnedUploadSessionTtls', + async () => { + await Promise.all(uniqueIds.map((uploadId) => redisModule!.refreshSessionTTL(uploadId))) + }, + async () => {}, + ) +} + /** * 只返回属于指定用户的上传会话。 * complete 端点会提前删除 Redis 会话,因此允许从同目录 meta.json 恢复所有权信息。 @@ -761,9 +774,7 @@ export async function refreshUploadSession(uploadId: string): Promise { // 内存Map版本:更新过期时间 const session = memorySessions.get(uploadId) if (session) { - // 内存会话自动延长4小时 - const expireTime = 4 * 60 * 60 * 1000 // 4小时 - session.createdAt = Date.now() - expireTime + (4 * 60 * 60 * 1000) + session.createdAt = Date.now() console.log(`[MemoryMap] 刷新上传会话成功: ${uploadId}`) return true }