fix: 任务-详情源文件不再误套 1GB 图片上限
PSD/压缩包按单文件 10GB、合计 30GB 单独限制;提交时续上传会话,避免大文件传完再排队后会话丢失。
This commit is contained in:
parent
d04d4ec34f
commit
2b5220fdd7
@ -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)
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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)};请分批提交`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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`
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user