fix: 任务-详情源文件不再误套 1GB 图片上限

PSD/压缩包按单文件 10GB、合计 30GB 单独限制;提交时续上传会话,避免大文件传完再排队后会话丢失。
This commit is contained in:
chenpei 2026-08-17 18:48:00 +08:00
parent d04d4ec34f
commit 2b5220fdd7
10 changed files with 206 additions and 38 deletions

View File

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

View File

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

View File

@ -115,11 +115,13 @@ type TaskDetailFileSelectionLimitKey =
| 'maxTotalImageCount'
| 'maxPsdFileCount'
| 'maxTotalSourceFileCount'
| 'maxTotalSourceBytes';
| 'maxTotalSourceBytes'
| 'maxSinglePsdBytes'
| 'maxTotalPsdBytes';
type TaskDetailFileSelectionLimits = Readonly<Record<TaskDetailFileSelectionLimitKey, number>>;
type SelectionLimitReason = 'group' | 'totalImages' | 'totalSources' | 'totalBytes';
type SelectionLimitReason = 'group' | 'totalImages' | 'totalSources' | 'totalBytes' | 'psdSingle' | 'psdBytes';
const UPLOAD_GROUP_LABELS: Record<UploadGroup, string> = {
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)};请分批提交`,
);
}

View File

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

View File

@ -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(),

View File

@ -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<string>()
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)

View File

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

View File

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

View File

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

View File

@ -336,6 +336,19 @@ export async function getUploadSession(
)
}
export async function refreshOwnedUploadSessionTtls(uploadIds: string[]): Promise<void> {
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<boolean> {
// 内存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
}