fix: restore task page previews and bound download memory

This commit is contained in:
chenpei 2026-08-03 15:42:02 +08:00
parent 7c609a5ca8
commit 57a9a946ab
20 changed files with 1022 additions and 371 deletions

View File

@ -15,8 +15,9 @@ import fs from 'fs';
import { createReadStream } from 'fs';
import { access, stat } from 'fs/promises';
import { lookup, extension } from 'mime-types';
import JSZip from 'jszip';
import { isProductPackageType } from '@/lib/material-type-mapper';
import { nodeReadableToWebStream } from '@/lib/node-readable-stream';
import { createArchiveDownloadStream } from '@/lib/archive-download-stream';
import {
getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage,
@ -293,8 +294,8 @@ async function downloadStandardModelWhiteBackground(
const whiteBackgroundGroup = groupWhiteBackgroundAssetsBySku(material.assets || []).get(sku.id);
if (whiteBackgroundGroup && hasAnyWhiteBackgroundAssetGroup(whiteBackgroundGroup)) {
const zipBuffer = await buildExternalWhiteBackgroundZipBuffer(whiteBackgroundGroup);
if (zipBuffer.length === 0) {
const zipStream = await buildExternalWhiteBackgroundZipStream(whiteBackgroundGroup);
if (!zipStream) {
return NextResponse.json(
{ success: false, error: { code: 'FILE_NOT_FOUND', message: '白底图不存在' } },
{ status: 404 }
@ -302,11 +303,10 @@ async function downloadStandardModelWhiteBackground(
}
const fileName = getWhiteBackgroundZipFileName(sku.skuName);
return new NextResponse(zipBuffer as any, {
return new NextResponse(zipStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
'Content-Length': zipBuffer.length.toString(),
},
});
}
@ -367,22 +367,7 @@ async function downloadSingleFile(asset: any) {
}
// 使用流式传输,避免将整个文件加载到内存中(修复 R24)
const fileStream = createReadStream(localPath);
// N6修复:确保文件流在任何情况下都能正确清理
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => {
// 错误时也要销毁文件流,防止文件描述符泄漏
fileStream.destroy();
controller.error(err);
});
},
cancel() {
fileStream.destroy();
},
});
const stream = nodeReadableToWebStream(createReadStream(localPath));
const contentType = getContentType(fileName);
const fileStats = await stat(localPath);
@ -410,16 +395,7 @@ async function downloadSingleFile(asset: any) {
const contentType = response.headers.get('content-type') || getContentType(fileName);
const contentLength = response.headers.get('content-length');
const nodeStream = Readable.fromWeb(response.body as any);
const stream = new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk) => controller.enqueue(chunk));
nodeStream.on('end', () => controller.close());
nodeStream.on('error', (error) => controller.error(error));
},
cancel() {
nodeStream.destroy();
},
});
const stream = nodeReadableToWebStream(nodeStream);
return new NextResponse(stream as any, {
headers: {
@ -443,55 +419,72 @@ async function downloadSingleFile(asset: any) {
);
}
async function buildExternalWhiteBackgroundZipBuffer(whiteBackgroundGroup: {
async function buildExternalWhiteBackgroundZipStream(whiteBackgroundGroup: {
fixed: Record<string, any>;
other: any[];
}) {
const zip = new JSZip();
}): Promise<ReadableStream<Uint8Array> | null> {
const archive = archiver('zip', { zlib: { level: 6 } });
const archiveDownload = createArchiveDownloadStream(archive);
let addedCount = 0
for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) {
const asset = whiteBackgroundGroup.fixed[view.key];
if (!asset?.fileUrl) {
continue;
try {
for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) {
const asset = whiteBackgroundGroup.fixed[view.key];
if (!asset?.fileUrl) {
continue;
}
const ext = path.extname(asset.fileName || asset.fileUrl) || '.jpg';
const fileName = asset.fileName || `${view.label}${ext}`;
await appendExternalAssetToArchive(
archive,
archiveDownload.trackSource,
asset.fileUrl,
fileName,
);
addedCount += 1
}
const fileBuffer = await readExternalAssetBuffer(asset.fileUrl);
const ext = path.extname(asset.fileName || asset.fileUrl) || '.jpg';
const fileName = asset.fileName || `${view.label}${ext}`;
zip.file(fileName, fileBuffer);
addedCount += 1
}
for (const asset of whiteBackgroundGroup.other || []) {
if (!asset?.fileUrl) {
continue;
}
for (const asset of whiteBackgroundGroup.other || []) {
if (!asset?.fileUrl) {
continue;
await appendExternalAssetToArchive(
archive,
archiveDownload.trackSource,
asset.fileUrl,
`other/${asset.fileName || path.basename(asset.fileUrl)}`,
);
addedCount += 1
}
const fileBuffer = await readExternalAssetBuffer(asset.fileUrl);
zip.file(`other/${asset.fileName || path.basename(asset.fileUrl)}`, fileBuffer);
addedCount += 1
}
if (addedCount === 0) {
archiveDownload.abort();
return null;
}
if (addedCount === 0) {
return Buffer.alloc(0);
return archiveDownload.finalize();
} catch (error) {
archiveDownload.abort();
throw error;
}
return zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
}
async function readExternalAssetBuffer(fileUrl: string): Promise<Buffer> {
async function appendExternalAssetToArchive(
archive: archiver.Archiver,
trackSource: <T extends Readable>(source: T) => T,
fileUrl: string,
entryName: string,
): Promise<void> {
if (isHttpUrl(fileUrl)) {
const response = await fetch(fileUrl);
if (!response.ok) {
if (!response.ok || !response.body) {
throw new Error(`读取资源失败: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
archive.append(trackSource(Readable.fromWeb(response.body as any)), { name: entryName });
return;
}
const localPath = resolveAssetLocalPath(fileUrl);
@ -499,7 +492,8 @@ async function readExternalAssetBuffer(fileUrl: string): Promise<Buffer> {
throw new Error('资源路径无效');
}
return fs.promises.readFile(localPath);
await access(localPath);
archive.file(localPath, { name: entryName });
}
/**
@ -514,6 +508,7 @@ async function downloadAsZip(assets: any[], taskNo: string, downloadType: string
});
const archive = archiver('zip', { zlib: { level: 5 } });
const archiveDownload = createArchiveDownloadStream(archive);
let successCount = 0;
let failCount = 0;
@ -538,7 +533,10 @@ async function downloadAsZip(assets: any[], taskNo: string, downloadType: string
if (isHttpUrl(fileUrl)) {
const response = await fetch(fileUrl);
if (response.ok && response.body) {
archive.append(Readable.fromWeb(response.body as any), { name: entryName });
archive.append(
archiveDownload.trackSource(Readable.fromWeb(response.body as any)),
{ name: entryName },
);
successCount++;
continue;
}
@ -554,39 +552,14 @@ async function downloadAsZip(assets: any[], taskNo: string, downloadType: string
}
if (successCount === 0) {
archive.destroy();
archiveDownload.abort();
return NextResponse.json(
{ success: false, error: { code: 'NO_FILES', message: '没有可下载的文件' } },
{ status: 404 }
);
}
const stream = new ReadableStream({
start(controller) {
let settled = false;
const close = () => {
if (!settled) {
settled = true;
console.log(`[downloadAsZip] 打包完成: 成功 ${successCount} 个, 失败 ${failCount} 个`);
controller.close();
}
};
const fail = (error: Error) => {
if (!settled) {
settled = true;
controller.error(error);
}
};
archive.on('data', (chunk) => controller.enqueue(chunk));
archive.on('end', close);
archive.on('error', fail);
void archive.finalize().catch(fail);
},
cancel() {
archive.abort();
},
});
const stream = archiveDownload.finalize();
return new NextResponse(stream as any, {
headers: {
@ -703,22 +676,7 @@ async function downloadNonProductPackageMaterial(material: any, downloadType: st
console.log(`[external/download] 使用本地 ZIP 文件: ${localZipPath}`);
const fileStream = createReadStream(localZipPath);
// N6修复:确保文件流在任何情况下都能正确清理
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => {
// 错误时也要销毁文件流,防止文件描述符泄漏
fileStream.destroy();
controller.error(err);
});
},
cancel() {
fileStream.destroy();
},
});
const stream = nodeReadableToWebStream(createReadStream(localZipPath));
const ext = path.extname(material.zipFileName || localZipPath) || '.zip';
const mimeType = lookup(ext) || 'application/octet-stream';
@ -779,16 +737,7 @@ async function downloadNonProductPackageMaterial(material: any, downloadType: st
}
const nodeStream = Readable.fromWeb(response.body as any);
const stream = new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk) => controller.enqueue(chunk));
nodeStream.on('end', () => controller.close());
nodeStream.on('error', (err) => controller.error(err));
},
cancel() {
nodeStream.destroy();
},
});
const stream = nodeReadableToWebStream(nodeStream);
return new NextResponse(stream as any, {
status: 200,
@ -865,16 +814,7 @@ async function downloadNonProductPackageMaterial(material: any, downloadType: st
const filename = `${filenameParts.join('_')}${finalExt}`;
const nodeStream = Readable.fromWeb(response.body as any);
const stream = new ReadableStream({
start(controller) {
nodeStream.on('data', (chunk) => controller.enqueue(chunk));
nodeStream.on('end', () => controller.close());
nodeStream.on('error', (err) => controller.error(err));
},
cancel() {
nodeStream.destroy();
},
});
const stream = nodeReadableToWebStream(nodeStream);
return new NextResponse(stream as any, {
status: 200,
@ -989,23 +929,7 @@ async function downloadNonProductPackageMaterial(material: any, downloadType: st
}
const chunkSize = end - start + 1;
const fileStream = createReadStream(filePath, { start, end });
// N6修复:确保文件流在任何情况下都能正确清理
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => {
// 错误时也要销毁文件流,防止文件描述符泄漏
fileStream.destroy();
controller.error(err);
});
},
cancel() {
fileStream.destroy();
},
});
const stream = nodeReadableToWebStream(createReadStream(filePath, { start, end }));
return new NextResponse(stream, {
status: 206,
@ -1020,23 +944,7 @@ async function downloadNonProductPackageMaterial(material: any, downloadType: st
}
// 正常下载
const fileStream = createReadStream(filePath);
// N6修复:确保文件流在任何情况下都能正确清理
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => {
// 错误时也要销毁文件流,防止文件描述符泄漏
fileStream.destroy();
controller.error(err);
});
},
cancel() {
fileStream.destroy();
},
});
const stream = nodeReadableToWebStream(createReadStream(filePath));
return new NextResponse(stream, {
headers: {
@ -1064,6 +972,7 @@ async function buildDynamicZipFromDisplayImages(material: any) {
}
const archive = archiver('zip', { zlib: { level: 6 } });
const archiveDownload = createArchiveDownloadStream(archive);
let addedCount = 0;
for (let i = 0; i < displayImages.length; i++) {
@ -1077,7 +986,10 @@ async function buildDynamicZipFromDisplayImages(material: any) {
const urlObj = new URL(imageUrl);
const filename = (path.basename(urlObj.pathname) || `image-${i + 1}.webp`).replace(/[^\w\-.]+/g, '_');
archive.append(Readable.fromWeb(response.body as any), { name: filename });
archive.append(
archiveDownload.trackSource(Readable.fromWeb(response.body as any)),
{ name: filename },
);
addedCount++;
} catch (error) {
console.error(`[external/download] Error while adding display image index=${i} url=${imageUrl} to zip:`, error);
@ -1085,7 +997,7 @@ async function buildDynamicZipFromDisplayImages(material: any) {
}
if (addedCount === 0) {
archive.destroy();
archiveDownload.abort();
return NextResponse.json(
{ success: false, error: { code: 'NO_DOWNLOADABLE_FILES', message: '该素材可能只包含图片' } },
{ status: 404 }
@ -1102,31 +1014,7 @@ async function buildDynamicZipFromDisplayImages(material: any) {
const filename = `${filenameParts.join('_')}.zip`;
const stream = new ReadableStream({
start(controller) {
let settled = false;
const close = () => {
if (!settled) {
settled = true;
controller.close();
}
};
const fail = (error: Error) => {
if (!settled) {
settled = true;
controller.error(error);
}
};
archive.on('data', (chunk) => controller.enqueue(chunk));
archive.on('end', close);
archive.on('error', fail);
void archive.finalize().catch(fail);
},
cancel() {
archive.abort();
},
});
const stream = archiveDownload.finalize();
return new NextResponse(stream as any, {
headers: {

View File

@ -1,10 +1,9 @@
'use server'
import { promises as fs } from 'fs'
import { createReadStream, existsSync } from 'fs'
import { stat } from 'fs/promises'
import path from 'path'
import JSZip from 'jszip'
import archiver from 'archiver'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
@ -21,6 +20,8 @@ import {
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import { isPathInAllowedDirs } from '@/lib/path-security'
import { canDownloadSkuMaterial } from '../../authorization'
import { nodeReadableToWebStream } from '@/lib/node-readable-stream'
import { createArchiveDownloadStream } from '@/lib/archive-download-stream'
type DownloadType = 'model' | 'white-background'
@ -182,39 +183,33 @@ export async function GET(
})
}
const zip = new JSZip()
const archive = archiver('zip', { zlib: { level: 6 } })
const archiveDownload = createArchiveDownloadStream(archive)
for (const entry of fixedEntries) {
const filePath = resolveLocalFilePath(entry.fileUrl)
if (!filePath) {
return NextResponse.json({ error: '白底图文件路径无效' }, { status: 404 })
}
const fileBuffer = await fs.readFile(filePath)
zip.file(entry.fileName, fileBuffer)
archive.file(filePath, { name: entry.fileName })
}
for (const entry of otherEntries) {
const filePath = resolveLocalFilePath(entry.fileUrl)
if (!filePath) {
return NextResponse.json({ error: '白底图文件路径无效' }, { status: 404 })
}
const fileBuffer = await fs.readFile(filePath)
zip.file(`other/${entry.fileName}`, fileBuffer)
archive.file(filePath, { name: `other/${entry.fileName}` })
}
const zipBuffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
})
const stream = archiveDownload.finalize()
recordSkuDownloadLog(materialId, userId).catch((error) => {
console.error('[SKU Download] 记录下载日志失败:', error)
})
return new NextResponse(zipBuffer as any, {
return new NextResponse(stream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${encodeURIComponent(downloadFileName)}"`,
'Content-Length': zipBuffer.length.toString(),
},
})
}
@ -246,20 +241,7 @@ export async function GET(
})
}
const fileStream = createReadStream(filePath)
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk))
fileStream.on('end', () => controller.close())
fileStream.on('error', (err) => {
fileStream.destroy()
controller.error(err)
})
},
cancel() {
fileStream.destroy()
},
})
const stream = nodeReadableToWebStream(createReadStream(filePath))
recordSkuDownloadLog(materialId, userId).catch((error) => {
console.error('[SKU Download] 记录下载日志失败:', error)

View File

@ -16,6 +16,7 @@ import {
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import { isPathInAllowedDirs } from '@/lib/path-security'
import { canDownloadSkuMaterial } from '../authorization'
import { createArchiveDownloadStream } from '@/lib/archive-download-stream'
type DownloadType = 'model' | 'white-background'
@ -224,35 +225,12 @@ export async function POST(
const filename = `${material.productModel}_${downloadType === 'white-background' ? 'SKU白底图' : 'SKU模型'}_${Date.now()}.zip`
const archive = archiver('zip', { zlib: { level: 6 } })
const archiveDownload = createArchiveDownloadStream(archive)
archiveEntries.forEach((entry) => {
archive.file(entry.filePath, { name: entry.name })
})
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let settled = false
const close = () => {
if (!settled) {
settled = true
controller.close()
}
}
const fail = (error: Error) => {
if (!settled) {
settled = true
controller.error(error)
}
}
archive.on('data', (chunk: Buffer) => controller.enqueue(chunk))
archive.on('end', close)
archive.on('error', fail)
void archive.finalize().catch(fail)
},
cancel() {
archive.abort()
},
})
const stream = archiveDownload.finalize()
recordBatchDownloadLog(materialId, userId).catch((error) => {
console.error('[SKU Batch Download] 记录下载日志失败:', error)

View File

@ -8,7 +8,6 @@ import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { access, stat } from 'fs/promises';
import { createReadStream } from 'fs';
import { Readable } from 'stream';
import path from 'path';
import { lookup } from 'mime-types';
import {
@ -26,6 +25,7 @@ import {
getVideoDownloadAvailability,
getVideoDownloadUnavailableMessage,
} from '@/lib/video-download-policy';
import { nodeReadableToWebStream } from '@/lib/node-readable-stream';
function normalizeStoredLocalPath(filePath: string) {
return String(filePath || '').replace(/\\/g, '/');
@ -264,23 +264,6 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string
try {
await access(filePath);
const fileStats = await stat(filePath);
const fileStream = createReadStream(filePath);
// N6修复:确保文件流在任何情况下都能正确清理
const stream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => controller.enqueue(chunk));
fileStream.on('end', () => controller.close());
fileStream.on('error', (err) => {
// 错误时也要销毁文件流,防止文件描述符泄漏
fileStream.destroy();
controller.error(err);
});
},
cancel() {
fileStream.destroy();
},
});
const ext = path.extname(filePath);
const mimeType = lookup(ext) || 'application/octet-stream';
@ -295,7 +278,6 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string
const filename = `${filenameParts.join('_')}${ext}`;
if (validateOnly) {
fileStream.destroy();
return NextResponse.json({
success: true,
fileName: filename,
@ -304,6 +286,8 @@ async function serveFile(filePath: string, material: any, fileNameSuffix: string
});
}
const stream = nodeReadableToWebStream(createReadStream(filePath));
console.log(`[Download] 开始下载: ${filename}, size=${fileStats.size} bytes`);
// 修复:记录下载日志并更新下载次数(在后台异步执行)

View File

@ -234,6 +234,14 @@ export async function GET(request: NextRequest) {
console.log('[Materials API] Using preview from assets:', previewUrl);
}
}
// 任务-页面类型:完成上传后预览资源的 assetType 为 preview。
if (!previewUrl && material.materialType === '任务-页面' && material.assets && material.assets.length > 0) {
const previewAsset = material.assets.find((a: any) => a.assetType === 'preview');
if (previewAsset) {
previewUrl = previewAsset.fileUrl;
}
}
// 其他素材类型:使用 displayImages
if (!previewUrl && material.displayImages) {
@ -388,7 +396,7 @@ export async function GET(request: NextRequest) {
},
},
where: {
assetType: { in: ['main-preview', 'sku-preview', 'detail-preview'] },
assetType: { in: ['main-preview', 'sku-preview', 'detail-preview', 'preview'] },
},
orderBy: { sortOrder: 'asc' },
},
@ -749,6 +757,13 @@ export async function GET(request: NextRequest) {
const formattedMaterials = materials.map(material => {
// 处理previewUrl的fallback逻辑
let previewUrl = material.previewUrl || material.videoCoverUrl || '';
if (!previewUrl && material.materialType === '任务-页面') {
const previewAsset = (material.assets || []).find((asset: any) => asset.assetType === 'preview');
if (previewAsset) {
previewUrl = previewAsset.fileUrl;
}
}
// 任务-详情类型:优先使用从 assets 获取的封面图
if (isProductPackageType(material.materialType)) {

View File

@ -11,11 +11,20 @@ import {
saveAssets,
} from '@/lib/task-material-processor';
import { createMaterialLog } from '@/lib/material-log';
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy';
import { stat } from 'fs/promises';
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800
class TaskPageUploadValidationError extends Error {
constructor(message: string, readonly status: number = 400) {
super(message)
this.name = 'TaskPageUploadValidationError'
}
}
// POST - 完成任务-页面上传
export async function POST(request: NextRequest) {
let materialId: string | null = null;
@ -23,6 +32,8 @@ export async function POST(request: NextRequest) {
let zipFilePath: string | null = null;
const uploadedPreviewUrls: string[] = []; // 新生成的S3预览图URLs
const uploadIds: string[] = []; // 需要清理的uploadId列表
let claimedForCompletion = false;
let completed = false;
try {
const session = await getServerSession(authOptions);
@ -42,7 +53,8 @@ export async function POST(request: NextRequest) {
);
}
if (!assets.main || assets.main.length === 0) {
const mainAssets = Array.isArray(assets.main) ? assets.main : [];
if (mainAssets.length === 0) {
return NextResponse.json(
{ error: '至少需要上传一张预览图' },
{ status: 400 }
@ -50,7 +62,7 @@ export async function POST(request: NextRequest) {
}
console.log('[Task Page Complete] Material ID:', materialId);
console.log('[Task Page Complete] Preview images count:', assets.main.length);
console.log('[Task Page Complete] Preview images count:', mainAssets.length);
// 验证素材是否存在且属于当前用户
const material: any = await prisma.material.findUnique({
@ -71,27 +83,90 @@ export async function POST(request: NextRequest) {
);
}
if (material.materialType !== '任务-页面') {
return NextResponse.json(
{ success: false, error: '素材类型不匹配' },
{ status: 400 }
);
}
// 只有一个请求能把 uploading 原子地切换为 processing。重复或并发完成请求
// 不会进入清理分支,更不会误删已经完成的素材。
const claimResult = await prisma.material.updateMany({
where: {
id: materialId,
userId: (session.user as any).id,
materialType: '任务-页面',
deletedAt: null,
taskStatus: 'uploading',
},
data: { taskStatus: 'processing' },
});
if (claimResult.count !== 1) {
return NextResponse.json(
{ success: false, error: '该素材正在处理或已经完成,请勿重复提交' },
{ status: 409 },
);
}
claimedForCompletion = true;
if (mainAssets.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) {
throw new TaskPageUploadValidationError(
`预览图最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批提交`,
);
}
const requestedUploadIds = mainAssets.map((asset: any) => String(asset?.uploadId || '').trim());
if (requestedUploadIds.some((uploadId: string) => !uploadId)) {
throw new TaskPageUploadValidationError('上传会话编号无效');
}
if (new Set(requestedUploadIds).size !== requestedUploadIds.length) {
throw new TaskPageUploadValidationError('上传会话编号重复,请重新选择文件');
}
// 步骤1: 从chunk临时目录读取预览图文件
console.log(`[Task Page] Processing ${assets.main.length} preview images...`);
console.log(`[Task Page] Processing ${mainAssets.length} preview images...`);
const { getOwnedUploadedFile } = await import('@/lib/upload-sessions');
const previewFiles: Array<{ path: string; name: string; size: number }> = [];
let totalSourceBytes = 0;
for (const asset of assets.main) {
for (const asset of mainAssets) {
try {
const filePath = await getOwnedUploadedFile(asset.uploadId, (session.user as any).id);
const uploadId = String(asset.uploadId).trim();
const fileName = String(asset.fileName || 'image');
const filePath = await getOwnedUploadedFile(uploadId, (session.user as any).id);
if (filePath) {
tempFiles.push(filePath);
uploadIds.push(uploadId);
const fileStats = await stat(filePath);
if (!fileStats.isFile()) {
throw new TaskPageUploadValidationError(`上传文件无效: ${fileName}`);
}
if (fileStats.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) {
throw new TaskPageUploadValidationError(
`图片「${fileName}」超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`,
413,
);
}
totalSourceBytes += fileStats.size;
if (totalSourceBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) {
throw new TaskPageUploadValidationError('本次图片总大小超过 1GB,请分批提交', 413);
}
previewFiles.push({
path: filePath,
name: asset.fileName,
size: asset.fileSize,
name: fileName,
size: fileStats.size,
});
tempFiles.push(filePath);
uploadIds.push(asset.uploadId);
} else {
throw new Error(`找不到uploadId对应的文件: ${asset.uploadId}`);
throw new Error(`找不到uploadId对应的文件: ${uploadId}`);
}
} catch (error) {
if (error instanceof TaskPageUploadValidationError) {
throw error;
}
console.error(`[Task Page] 获取预览图文件失败 ${asset.uploadId}:`, error);
throw new Error(`获取预览图文件失败: ${asset.fileName}`);
}
@ -110,7 +185,7 @@ export async function POST(request: NextRequest) {
// 步骤3: 生成预览图并上传到S3
const previewUrls = await generatePreviewImages(
assets.main.map((a: any) => ({ uploadId: a.uploadId, fileName: a.fileName })),
mainAssets.map((a: any) => ({ uploadId: a.uploadId, fileName: a.fileName })),
'任务-页面'
);
uploadedPreviewUrls.push(...previewUrls);
@ -124,9 +199,12 @@ export async function POST(request: NextRequest) {
// 步骤4.5: 仅在素材成功完成后写入任务号
await assignTaskNoOnComplete(materialId!, taskNo, dingtalkApprovalNo);
completed = true;
// 步骤5: 清理临时文件
await cleanupTempFiles(tempFiles, uploadIds);
await cleanupTempFiles(tempFiles, uploadIds).catch((cleanupError) => {
console.warn('[Task Page] 上传已完成,但临时文件清理失败:', cleanupError);
});
// 步骤6: 记录上传日志
await createMaterialLog({
@ -147,6 +225,8 @@ export async function POST(request: NextRequest) {
newValue: zipInfo.fileName,
},
},
}).catch((logError) => {
console.error('[Task Page] 上传已完成,但日志记录失败:', logError);
});
console.log('[Task Page] Complete success!');
@ -157,12 +237,23 @@ export async function POST(request: NextRequest) {
} catch (error: any) {
console.error('[Task Page Complete Error]', error);
// 清理失败时的资源
await cleanupOnError(uploadedPreviewUrls, zipFilePath, tempFiles, materialId);
// 只有成功抢占 processing 状态的请求,才有资格清理由本请求创建的资源和素材。
// 重复请求、鉴权失败或查询异常绝不能根据用户传入的 materialId 删除数据。
if (claimedForCompletion && !completed) {
await cleanupOnError(
uploadedPreviewUrls,
zipFilePath,
tempFiles,
materialId,
uploadIds,
true,
{ taskStatus: 'processing' },
);
}
return NextResponse.json(
{ error: error.message || '完成上传失败' },
{ status: 500 }
{ status: error instanceof TaskPageUploadValidationError ? error.status : 500 }
);
}
}

View File

@ -92,6 +92,7 @@ export async function POST(request: NextRequest) {
zipFilePath: '', // 临时空值,完成上传时更新
zipFileName: '', // 临时空值,完成上传时更新
renderer: '', // 任务-页面不需要渲染器
taskStatus: 'uploading',
},
});

View File

@ -1,8 +1,7 @@
'use client'
import { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { parseSPU, type SPUParseResult } from '@/lib/spu-parser';
import { parseSPU } from '@/lib/spu-parser';
import {
hasDragLeftDropZone,
prepareFileDrag,
@ -10,6 +9,7 @@ import {
} from './drag-drop-utils';
import TaskReferenceFields from './TaskReferenceFields';
import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no';
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy';
interface AssetFile {
file: File;
@ -150,7 +150,26 @@ export default function TaskPageUploader({
}
const currentList = previewImages;
const { acceptedFiles, errors } = validateImageFiles(files);
const validation = validateImageFiles(files, {
maxBytes: TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes,
maxCount: TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup,
existingCount: currentList.length,
});
const currentBytes = currentList.reduce((sum, item) => sum + item.file.size, 0);
let nextTotalBytes = currentBytes;
const rejectedByTotal: string[] = [];
const acceptedFiles = validation.acceptedFiles.filter((file) => {
if (nextTotalBytes + file.size > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) {
rejectedByTotal.push(file.name);
return false;
}
nextTotalBytes += file.size;
return true;
});
const errors = [...validation.errors];
if (rejectedByTotal.length > 0) {
errors.push(`本次图片总大小最多 1GB,已忽略:${rejectedByTotal.join('、')}`);
}
const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({
file,
preview: URL.createObjectURL(file),
@ -223,8 +242,11 @@ export default function TaskPageUploader({
throw new Error(errorData.error || '初始化失败');
}
const { data: initData } = await initResponse.json();
const materialId = initData.materialId;
const initData = await initResponse.json();
const materialId = typeof initData?.materialId === 'string' ? initData.materialId : '';
if (!materialId) {
throw new Error('初始化失败:服务器未返回素材编号');
}
setUploadProgress(10);

View File

@ -0,0 +1,115 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import TaskPageUploader from '@/components/upload/TaskPageUploader'
import { chunkUpload } from '@/lib/chunk-upload'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
jest.mock('@/lib/chunk-upload', () => ({
chunkUpload: jest.fn(),
}))
const originalFetch = global.fetch
describe('TaskPageUploader upload contract', () => {
beforeEach(() => {
jest.clearAllMocks()
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
writable: true,
value: jest.fn(() => 'blob:task-page-preview'),
})
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
writable: true,
value: jest.fn(),
})
})
afterAll(() => {
global.fetch = originalFetch
})
test('应读取初始化接口顶层 materialId 并提交完成请求', async () => {
const fetchMock = jest
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, materialId: 'material-task-page-1' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, materialId: 'material-task-page-1' }),
})
global.fetch = fetchMock as unknown as typeof fetch
;(chunkUpload as jest.Mock).mockResolvedValue({ uploadId: 'upload-preview-1' })
const onSuccess = jest.fn()
const { container } = render(
<TaskPageUploader
onSuccess={onSuccess}
onCancel={jest.fn()}
categories={[{ id: 'category-1', name: '索菲亚' }]}
scenes={[{ id: 'scene-1', name: '书房' }]}
platforms={[]}
productCategories={[]}
onRefreshCategories={jest.fn()}
onRefreshScenes={jest.fn()}
/>,
)
fireEvent.change(screen.getByLabelText(/SPU型号/), { target: { value: 'SJ0371' } })
fireEvent.change(screen.getByLabelText(/使用店铺/), { target: { value: '索菲亚' } })
fireEvent.change(screen.getByLabelText(/场景类别/), { target: { value: '书房' } })
const imageInput = container.querySelector('input[type="file"][accept="image/*"]') as HTMLInputElement
const image = new File(['preview'], 'page-1.jpg', { type: 'image/jpeg' })
fireEvent.change(imageInput, { target: { files: [image] } })
fireEvent.click(screen.getByRole('button', { name: '提交' }))
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
expect(fetchMock.mock.calls[0][0]).toBe('/api/materials/upload/task-page/init')
expect(fetchMock.mock.calls[1][0]).toBe('/api/materials/upload/task-page/complete')
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual(
expect.objectContaining({
materialId: 'material-task-page-1',
assets: {
main: [
expect.objectContaining({
uploadId: 'upload-preview-1',
fileName: 'page-1.jpg',
}),
],
},
}),
)
await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1), { timeout: 2000 })
})
test('选择文件时应在上传前拒绝超过 100MB 的单张图片', () => {
const { container } = render(
<TaskPageUploader
onSuccess={jest.fn()}
onCancel={jest.fn()}
categories={[]}
scenes={[]}
platforms={[]}
productCategories={[]}
onRefreshCategories={jest.fn()}
onRefreshScenes={jest.fn()}
/>,
)
const imageInput = container.querySelector('input[type="file"][accept="image/*"]') as HTMLInputElement
const oversized = new File(['small fixture'], 'too-large.jpg', { type: 'image/jpeg' })
Object.defineProperty(oversized, 'size', {
configurable: true,
value: TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes + 1,
})
fireEvent.change(imageInput, { target: { files: [oversized] } })
expect(screen.getByText(/too-large\.jpg/)).toHaveTextContent('超过 100MB')
expect(screen.queryByAltText('预览图 1')).not.toBeInTheDocument()
})
})

View File

@ -1,6 +1,6 @@
# 素材库未完成问题处理记录(2026-08-01)
> 当前代码基线:`origin/main@776c6bafce9a819fd141339d6a6991c47f15f6ca`
> 当前代码基线:`origin/main@7c609a5ca891e95e23b7e1ad3a7fd6cfa680fb21`
> 记录原则:代码、生产运行状态和历史事件分开判断;没有完成真实验收的项目不标记为“已关闭”。
## 一、当前结论
@ -173,9 +173,41 @@
Edge 补充边界:真实 Edge 已能登录隔离站点并正常显示已上传预览,但 Codex 的 Edge 扩展未开启“允许访问文件 URL”,自动化工具因此无法把本地测试图片交给网页;这是测试工具权限,不是素材库返回的上传失败。用户此前已经用自己的 Edge 完成人工上传。生产发布后仍需再做一次真实业务账号的小批量上传、预览和下载验收,才能把本项标记为“生产已关闭”。
### 代码批次 3.1:任务-页面空卡片与大文件下载背压(2026-08-03 本地完成并通过提交门禁,待发布)
本轮对生产数据库做了只读核查,没有修改或删除任何数据。生产共有 14 条未删除的“任务-页面”素材,其中 13 条同时满足“顶层预览为空、ZIP 路径为空、资源记录为 0”,只有 1 条具备真实预览和文件。这与首页截图中的 13 张“暂无预览图”空卡片和 1 张正常卡片完全一致。
已确认根因与处理:
1. 初始化接口返回的是顶层 `materialId`,前端却按 `data.materialId` 读取;后端刚创建空素材记录,前端便立即报错,后续图片上传和完成接口都没有执行。前端现已按真实接口契约读取,并在素材编号缺失时给出明确错误。
2. 新建记录会标记为 `uploading`;完成请求必须原子地把本人名下这条记录抢占为 `processing`,只有一个请求能成功。重复点击或并发完成会返回 409,不能进入清理分支,也不能删除已经完成的素材。
3. 完成接口增加素材类型校验,不能用“任务-页面”接口覆盖用户自己的其它素材。完成阶段失败时,临时文件、上传会话、S3 预览、ZIP 和素材会进入统一清理;数据库删除还必须再次满足 `taskStatus=processing`,不能只根据客户端传来的素材 ID 删除。
4. 首页列表会从 `assetType=preview` 的任务页面资源回退取封面;没有预览、没有 ZIP、也没有任何资源的任务页面空壳会从正常列表和筛选数量隐藏,但不会被删除,回收站查询也不受影响。
5. 生产只读验证表明该过滤条件上线后,“任务-页面”的正常列表数量将从 14 变为 1;13 条历史空壳仍保留在数据库中,后续如需物理清理,必须先备份并单独授权。
6. 任务页面前端和后端统一增加单图 100MB、最多 60 张、总量 1GB 的硬门禁;后端对用户拥有的上传会话去重,并以磁盘实际 `stat` 大小为准,在打包和 `readFile()` 之前拒绝超限文件。图片解码阶段仍受 60MP、最大边长和全局单并发门禁保护。
同时修复了下载链路的真实内存风险:原实现虽然使用了 Web Stream,但通过 Node `data` 事件持续推送文件,用户网络慢或多人并发时无法向源文件施加背压,进程仍可能积压大量数据。现已统一为“下游每请求一块,Node 源流才读取一块”的转换器,并覆盖:
- 素材主下载与 Range 下载;
- SKU 单个模型下载和批量动态 ZIP;
- SKU 白底图动态 ZIP;
- 外部 Token 的本地文件、远程文件、Range、动态 ZIP 和展示图 ZIP。
SKU 白底图 ZIP 已从 JSZip 的“读取全部图片并生成完整内存 Buffer”改为 Archiver 流式压缩;动态 ZIP 不再先生成完整压缩包再返回。用户取消动态 ZIP 时会同时销毁显式远程输入流、调用 Archiver `abort()` 清空排队任务并销毁输出流;测试已验证源流销毁、archive 进入 aborted/destroyed 状态且队列归零。
验证结果:
- 任务页面、并发完成、条件清理、下载背压和 ZIP 取消的定向回归全部通过。
- 全量 Jest 回归共 150 个测试套件、962 项测试全部通过。
- TypeScript 类型检查通过;相关文件 ESLint 为 0 error,仅保留任务页面既有的动态 Blob `<img>` 性能提示。
- Next.js 生产编译成功;Impeccable 检测没有发现新增界面问题。
- 本机隔离工作树通过 junction 复用原项目 `node_modules`,standalone 文件复制阶段仍有 Windows `EPERM` 符号链接提示,因此本次构建只证明代码和页面编译通过,不能替代 Linux 镜像打包与部署后验收。
尚未完成:本批尚未部署生产;需要在安全发布窗口完成 Linux 镜像构建、真实“任务-页面”小文件上传、首页卡片预览、单文件/Range/动态 ZIP 下载,以及下载期间容器内存峰值验收后,才能标记为生产关闭。
### 后续 P1
- 普通主上传和部分 ZIP 下载仍会把完整文件或最终压缩包放入内存。
- 普通主上传的小文件接口仍使用 `formData()`,SKU 模型上传仍会把单个 ZIP 完整放入内存;两者需要改为服务端强制限额或统一分片上传,不能只依赖前端约束。
- 下载 Token 有效期仍接近永久,且外部下载没有完成实时权限复核。
- `canShare` 权限配置未被分享接口真正执行;部分管理员接口角色口径不一致;素材所有者仍可能通过编辑接口转移素材归属。
- 部门改名、移动层级和负责人角色更新仍缺完整事务。
@ -205,5 +237,6 @@ Edge 补充边界:真实 Edge 已能登录隔离站点并正常显示已上传
5. **代码批次 2.1**:已随 `62295a0` 提交并推送,Linux Docker 构建和 Chromium 大图上传验收通过;待生产门禁恢复后合并、部署,并补做真实业务账号验收。
6. **生产发布门禁**:先处理或等待 TrueNAS 可用内存恢复,禁止在当前不足 2GiB 余量下切换生产。
7. **代码批次 3**:继续处理上传/ZIP 内存、长期下载 Token、权限和数据库一致性。
8. **代码批次 3.1**:任务-页面上传/首页预览和下载背压已完成本地修复并通过独立提交门禁;生产发布前仍须完成 Linux 镜像和真实运行验收。
当前可以确认“旧影子漏洞入口已不可达”,但在旧凭据失效状态和访问日志核对完成前,不能把历史凭据事件标记为完全结案;`uploads` 的 `0777` 权限问题按用户决定暂缓,仍属于已知未关闭项。

View File

@ -0,0 +1,40 @@
/** @jest-environment node */
import archiver from 'archiver'
import { Readable } from 'node:stream'
import { createArchiveDownloadStream } from '@/lib/archive-download-stream'
class OpenReadable extends Readable {
private emitted = false
override _read(): void {
if (!this.emitted) {
this.emitted = true
this.push(Buffer.alloc(64 * 1024, 1))
}
}
}
describe('createArchiveDownloadStream', () => {
test('取消动态 ZIP 时应中止 Archiver 并销毁显式输入流', async () => {
const archive = archiver('zip', { zlib: { level: 1 } })
const abortSpy = jest.spyOn(archive, 'abort')
const source = new OpenReadable()
const sourceDestroySpy = jest.spyOn(source, 'destroy')
const download = createArchiveDownloadStream(archive)
archive.append(download.trackSource(source), { name: 'open.bin' })
const reader = download.finalize().getReader()
await reader.read()
await reader.cancel()
expect(abortSpy).toHaveBeenCalledTimes(1)
expect(sourceDestroySpy).toHaveBeenCalled()
expect(source.destroyed).toBe(true)
expect(archive.destroyed).toBe(true)
expect((archive as any)._state?.aborted).toBe(true)
expect((archive as any)._queue?.length?.() || 0).toBe(0)
})
})

View File

@ -1,91 +1,30 @@
/**
* N6修复测试:文件流错误时正确清理
*
* 验证:
* 1. 所有 fileStream.on('error') 回调中都调用了 fileStream.destroy()
* 2. 确保文件描述符不会泄漏
* 大文件下载流门禁:路由必须使用统一的背压转换器,不能通过 data 事件
* 无限制地把源文件推入 Web Stream 队列。
*/
describe('N6: 文件流错误清理测试', () => {
test('所有下载路由的文件流错误处理应该包含 destroy() 调用', async () => {
const fs = require('fs')
const path = require('path')
const downloadRoutes = [
'app/api/materials/download/route.ts',
'app/api/materials/[id]/skus/[skuId]/download/route.ts',
'app/api/external/download/route.ts',
]
for (const routePath of downloadRoutes) {
const filePath = path.join(__dirname, '../..', routePath)
if (!fs.existsSync(filePath)) {
console.log(`跳过测试:文件不存在 ${routePath}`)
continue
}
const content = fs.readFileSync(filePath, 'utf-8')
// 查找所有 fileStream.on('error' 模式
const errorHandlerPattern = /fileStream\.on\('error',\s*\(err\)\s*=>\s*\{[^}]*\}/g
const matches = content.match(errorHandlerPattern)
if (matches) {
for (const match of matches) {
// 每个错误处理回调中都应该有 destroy() 调用
expect(match).toContain('destroy()')
}
}
}
})
describe('大文件下载流清理与背压', () => {
const fs = require('fs')
const path = require('path')
const downloadRoutes = [
['app/api/materials/download/route.ts', 'nodeReadableToWebStream'],
['app/api/materials/[id]/skus/[skuId]/download/route.ts', 'createArchiveDownloadStream'],
['app/api/materials/[id]/skus/batch-download/route.ts', 'createArchiveDownloadStream'],
['app/api/external/download/route.ts', 'createArchiveDownloadStream'],
]
test('不应该有只调用 controller.error 而不调用 destroy 的错误处理', async () => {
const fs = require('fs')
const path = require('path')
const downloadRoutes = [
'app/api/materials/download/route.ts',
'app/api/materials/[id]/skus/[skuId]/download/route.ts',
'app/api/external/download/route.ts',
]
for (const routePath of downloadRoutes) {
const filePath = path.join(__dirname, '../..', routePath)
if (!fs.existsSync(filePath)) {
console.log(`跳过测试:文件不存在 ${routePath}`)
continue
}
const content = fs.readFileSync(filePath, 'utf-8')
// 查找简单的错误处理模式(只有 controller.error,没有 destroy)
// 这种模式是有问题的:fileStream.on('error', (err) => controller.error(err))
const badPattern = /fileStream\.on\('error',\s*\(err\)\s*=>\s*controller\.error\(err\)\)/g
const badMatches = content.match(badPattern)
// 不应该有这种简单的错误处理
expect(badMatches).toBeNull()
}
})
test('所有错误处理应该先调用 destroy 再调用 controller.error', async () => {
const fs = require('fs')
const path = require('path')
const filePath = path.join(__dirname, '../../app/api/materials/download/route.ts')
if (!fs.existsSync(filePath)) {
console.log('跳过测试:文件不存在')
return
}
test.each(downloadRoutes)('%s 应使用统一的背压/取消流转换器', (routePath, helperName) => {
const filePath = path.join(__dirname, '../..', routePath)
const content = fs.readFileSync(filePath, 'utf-8')
// 检查错误处理的顺序:先 destroy() 再 controller.error()
const correctPattern = /fileStream\.destroy\(\)[\s\S]*?controller\.error\(err\)/
expect(correctPattern.test(content)).toBe(true)
expect(content).toContain(helperName)
expect(content).not.toMatch(/(?:fileStream|nodeStream|archive)\.on\(['"]data['"]/)
})
test('转换器取消时必须销毁 Node 源流', () => {
const helperPath = path.join(__dirname, '..', 'node-readable-stream.ts')
const content = fs.readFileSync(helperPath, 'utf-8')
expect(content).toMatch(/cancel\([^)]*\)[\s\S]*?source\.destroy\(\)/)
})
})

View File

@ -113,4 +113,33 @@ describe('material search conditions', () => {
expect(context.isSuperAdmin).toBe(true)
expect(getUserViewableMaterialTypes).not.toHaveBeenCalled()
})
test('正常列表应隐藏没有任何文件的任务-页面空记录', async () => {
;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-页面'])
const result = await buildMaterialWhereFromSearchParams(
new URLSearchParams('materialType=任务-页面'),
{ id: 'user-1', role: 'USER', department: '品牌创意部' },
)
expect(result.empty).toBe(false)
expect(result.where.NOT).toEqual({
materialType: '任务-页面',
previewUrl: '',
zipFilePath: '',
assets: { none: {} },
})
})
test('回收站查询不应套用任务-页面空记录隐藏条件', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
const result = await buildMaterialWhereFromSearchParams(
new URLSearchParams('showDeleted=true'),
{ id: 'super-admin', role: 'ADMIN' },
)
expect(result.where.deletedAt).toEqual({ not: null })
expect(result.where.NOT).toBeUndefined()
})
})

View File

@ -208,4 +208,81 @@ describe('materials route ai generated', () => {
expect(body.totalCount).toBe(0)
expect(prisma.material.findMany).not.toHaveBeenCalled()
})
test('任务-页面缺少顶层 previewUrl 时应使用 preview 资产作为首页封面', async () => {
;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-页面'])
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
{
id: 'task-page-1',
userId: 'user-1',
previewUrl: '',
productModel: 'SJ0371',
categoryName: '索菲亚',
sceneName: '书房',
platformName: '',
productCategoryName: '',
materialType: '任务-页面',
renderer: '',
authorName: '张三',
zipFileName: 'task-page.zip',
zipFileSize: BigInt(1024),
downloadCount: 0,
likeCount: 0,
createdAt: new Date('2026-06-26T08:00:00.000Z'),
videoSourceUrl: null,
displayImages: [],
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
videoDuration: null,
videoProcessingStatus: null,
transcodeLastError: null,
transcodeAttempts: 0,
isAiGenerated: false,
taskDetailSourceType: null,
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
user: {
username: 'zhangsan',
realName: '张三',
avatar: null,
},
assets: [
{
id: 'preview-1',
assetType: 'preview',
fileUrl: 'https://example.com/task-page-preview.webp',
fileName: 'preview-0.webp',
fileSize: null,
sortOrder: 0,
createdAt: new Date('2026-06-26T08:00:00.000Z'),
updatedAt: new Date('2026-06-26T08:00:00.000Z'),
skuCode: null,
barcodes: [],
},
],
},
])
const response = await GET(
new NextRequest('http://localhost/api/materials?materialType=任务-页面&page=1&pageSize=20'),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(prisma.material.findMany).toHaveBeenCalledWith(
expect.objectContaining({
select: expect.objectContaining({
assets: expect.objectContaining({
where: {
assetType: {
in: ['main-preview', 'sku-preview', 'detail-preview', 'preview'],
},
},
}),
}),
}),
)
expect(body.materials[0].previewUrl).toBe('https://example.com/task-page-preview.webp')
})
})

View File

@ -0,0 +1,111 @@
/** @jest-environment node */
import { Readable } from 'node:stream'
import { nodeReadableToWebStream } from '@/lib/node-readable-stream'
async function readAll(stream: ReadableStream<Uint8Array>): Promise<Buffer> {
const reader = stream.getReader()
const chunks: Buffer[] = []
try {
while (true) {
const { value, done } = await reader.read()
if (done) {
return Buffer.concat(chunks)
}
chunks.push(Buffer.from(value))
}
} finally {
reader.releaseLock()
}
}
class CountingReadable extends Readable {
produced = 0
private nextChunk = 0
constructor(private readonly totalChunks: number) {
// One-byte high water mark keeps Node's own buffering from hiding a
// converter that eagerly drains the source.
super({ highWaterMark: 1 })
}
override _read(): void {
if (this.nextChunk >= this.totalChunks) {
this.push(null)
return
}
this.produced += 1
this.push(Buffer.from([this.nextChunk]))
this.nextChunk += 1
}
}
class CancellableReadable extends Readable {
private emitted = false
constructor() {
super({ highWaterMark: 1 })
}
override _read(): void {
// Keep the source open after the first chunk so cancellation, rather than
// natural EOF, is what must release it.
if (!this.emitted) {
this.emitted = true
this.push(Buffer.from('first chunk'))
}
}
}
describe('nodeReadableToWebStream', () => {
test('preserves every byte across the Node-to-Web conversion', async () => {
const sourceChunks = [
Buffer.from('header\0', 'utf8'),
Buffer.from([0, 1, 127, 128, 254, 255]),
Buffer.from('tail', 'utf8'),
]
const source = Readable.from(sourceChunks)
const result = await readAll(nodeReadableToWebStream(source))
expect(result).toEqual(Buffer.concat(sourceChunks))
})
test('does not pre-read every source chunk before the Web stream is consumed', async () => {
const totalChunks = 64
const source = new CountingReadable(totalChunks)
const webStream = nodeReadableToWebStream(source)
try {
// Let the stream's initial pull settle without introducing a time-based
// delay. A correctly backpressured adapter may read one chunk (and Node
// may briefly request one extra), but it must not drain the source.
await Promise.resolve()
expect(source.produced).toBeLessThan(totalChunks)
} finally {
await webStream.cancel()
}
})
test('destroys the Node stream when the Web reader is cancelled', async () => {
const source = new CancellableReadable()
const destroySpy = jest.spyOn(source, 'destroy')
const reader = nodeReadableToWebStream(source).getReader()
await expect(reader.read()).resolves.toEqual({
done: false,
value: expect.any(Uint8Array),
})
await reader.cancel()
expect(destroySpy).toHaveBeenCalledTimes(1)
expect(source.destroyed).toBe(true)
})
})

View File

@ -0,0 +1,199 @@
/** @jest-environment node */
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { POST as initTaskPage } from '@/app/api/materials/upload/task-page/init/route'
import { POST as completeTaskPage } from '@/app/api/materials/upload/task-page/complete/route'
import { prisma } from '@/lib/prisma'
import { cleanupOnError, createZipFromFiles } from '@/lib/task-material-processor'
import { getOwnedUploadedFile } from '@/lib/upload-sessions'
import { stat } from 'fs/promises'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: { findUnique: jest.fn() },
material: {
create: jest.fn(),
findFirst: jest.fn(),
findUnique: jest.fn(),
updateMany: jest.fn(),
},
},
}))
jest.mock('@/lib/product-package', () => ({
assignTaskNoOnComplete: jest.fn(),
purgeDeletedMaterialsByTaskNo: jest.fn(),
validateTaskNo: jest.fn(() => true),
}))
jest.mock('@/lib/dingtalk-approval-no', () => ({
getDingtalkApprovalNoError: jest.fn(() => null),
}))
jest.mock('@/lib/task-material-processor', () => ({
createZipFromFiles: jest.fn(),
generatePreviewImages: jest.fn(),
cleanupTempFiles: jest.fn(),
cleanupOnError: jest.fn(),
saveAssets: jest.fn(),
}))
jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn() }))
jest.mock('@/lib/upload-sessions', () => ({ getOwnedUploadedFile: jest.fn() }))
jest.mock('fs/promises', () => ({ stat: jest.fn() }))
describe('任务-页面上传接口', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.spyOn(console, 'log').mockImplementation(() => undefined)
jest.spyOn(console, 'error').mockImplementation(() => undefined)
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ username: '张三' })
;(prisma.material.create as jest.Mock).mockResolvedValue({ id: 'material-1' })
;(prisma.material.updateMany as jest.Mock).mockResolvedValue({ count: 1 })
;(stat as jest.Mock).mockResolvedValue({ isFile: () => true, size: 10 })
})
afterEach(() => {
jest.restoreAllMocks()
})
test('初始化接口应返回顶层 materialId,并标记为 uploading', async () => {
const response = await initTaskPage(
new NextRequest('http://localhost/api/materials/upload/task-page/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productModel: 'SJ0371',
categoryName: '索菲亚',
sceneName: '书房',
}),
}),
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body).toEqual({ success: true, materialId: 'material-1' })
expect(prisma.material.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
materialType: '任务-页面',
taskStatus: 'uploading',
}),
}),
)
})
test('完成接口不能覆盖其它素材类型', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
})
const response = await completeTaskPage(
makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'page.jpg', fileSize: 10 }]),
)
expect(response.status).toBe(400)
expect(getOwnedUploadedFile).not.toHaveBeenCalled()
})
test('打包失败时应把上传会话一并交给统一清理', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-页面',
productModel: 'SJ0371',
})
;(getOwnedUploadedFile as jest.Mock).mockResolvedValue('uploads/temp/upload-1/merged.jpg')
;(createZipFromFiles as jest.Mock).mockRejectedValue(new Error('打包失败'))
const response = await completeTaskPage(
makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'page.jpg', fileSize: 10 }]),
)
expect(response.status).toBe(500)
expect(cleanupOnError).toHaveBeenCalledWith(
[],
null,
['uploads/temp/upload-1/merged.jpg'],
'material-1',
['upload-1'],
true,
{ taskStatus: 'processing' },
)
})
test('重复完成请求应返回 409,且不能进入清理删除素材', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-页面',
taskStatus: 'completed',
})
;(prisma.material.updateMany as jest.Mock).mockResolvedValue({ count: 0 })
const response = await completeTaskPage(
makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'page.jpg', fileSize: 10 }]),
)
expect(response.status).toBe(409)
expect(cleanupOnError).not.toHaveBeenCalled()
expect(getOwnedUploadedFile).not.toHaveBeenCalled()
})
test('应按磁盘实际大小在 readFile 前拒绝超大图片并清理本请求', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
materialType: '任务-页面',
taskStatus: 'uploading',
productModel: 'SJ0371',
})
;(getOwnedUploadedFile as jest.Mock).mockResolvedValue('uploads/temp/upload-1/merged.jpg')
;(stat as jest.Mock).mockResolvedValue({
isFile: () => true,
size: TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes + 1,
})
const response = await completeTaskPage(
makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'large.jpg', fileSize: 1 }]),
)
expect(response.status).toBe(413)
expect(createZipFromFiles).not.toHaveBeenCalled()
expect(cleanupOnError).toHaveBeenCalledWith(
[],
null,
['uploads/temp/upload-1/merged.jpg'],
'material-1',
['upload-1'],
true,
{ taskStatus: 'processing' },
)
})
test('素材查询失败时不能按请求中的 materialId 执行删除清理', async () => {
;(prisma.material.findUnique as jest.Mock).mockRejectedValue(new Error('database unavailable'))
const response = await completeTaskPage(
makeCompleteRequest([{ uploadId: 'upload-1', fileName: 'page.jpg', fileSize: 10 }]),
)
expect(response.status).toBe(500)
expect(cleanupOnError).not.toHaveBeenCalled()
})
})
function makeCompleteRequest(assets: Array<{ uploadId: string; fileName: string; fileSize: number }>) {
return new NextRequest('http://localhost/api/materials/upload/task-page/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
materialId: 'material-1',
assets: { main: assets },
}),
})
}

View File

@ -0,0 +1,65 @@
import type { Archiver } from 'archiver'
import { Readable } from 'node:stream'
import { nodeReadableToWebStream } from '@/lib/node-readable-stream'
/**
* 管理动态 ZIP 的输出与外部输入流。
*
* Archiver 仅销毁输出流时不会保证取消排队任务或销毁已 append 的远程流,
* 因此下载取消必须同时 abort 压缩队列并销毁所有仍存活的显式输入流。
*/
export function createArchiveDownloadStream(archive: Archiver) {
const trackedSources = new Set<Readable>()
let stopped = false
let finalized = false
const destroyTrackedSources = () => {
trackedSources.forEach((source) => {
if (!source.destroyed) {
source.destroy()
}
})
trackedSources.clear()
}
const abort = () => {
if (stopped) {
return
}
stopped = true
destroyTrackedSources()
try {
archive.abort()
} catch {
// 已结束或已销毁的 archive 仍继续执行下方 destroy,确保输出关闭。
}
if (!archive.destroyed) {
archive.destroy()
}
}
archive.once('error', destroyTrackedSources)
archive.once('end', () => trackedSources.clear())
return {
trackSource<T extends Readable>(source: T): T {
if (!source.destroyed) {
trackedSources.add(source)
source.once('close', () => trackedSources.delete(source))
}
return source
},
abort,
finalize(): ReadableStream<Uint8Array> {
if (finalized) {
throw new Error('ZIP 下载流已经开始生成')
}
finalized = true
const stream = nodeReadableToWebStream(archive, { onCancel: abort })
void archive.finalize().catch(() => abort())
return stream
},
}
}

View File

@ -276,6 +276,15 @@ export async function buildMaterialWhereFromSearchParams(
where.deletedAt = { not: null }
} else {
where.deletedAt = null
// “任务-页面”会先创建素材记录,再上传并生成预览图。历史上传中断留下的
// 空记录没有预览、ZIP 或任何资产,不应出现在正常素材列表和筛选计数中。
// 这里只隐藏可确认没有文件的空壳,不删除数据,也不影响回收站查看。
where.NOT = {
materialType: '任务-页面',
previewUrl: '',
zipFilePath: '',
assets: { none: {} },
}
}
const category = searchParams.get('category') || ''

View File

@ -0,0 +1,61 @@
import { Readable } from 'node:stream'
export interface NodeReadableToWebStreamOptions {
onCancel?: (reason?: unknown) => void | Promise<void>
}
/**
* 将 Node.js Readable 转换为受 Web Stream 消费速度约束的流。
*
* 每次 pull 只向下游交付一个分块,避免 data 事件持续读取大文件、
* 而浏览器或代理来不及接收时在 Node 进程内堆积大量内存。
*/
export function nodeReadableToWebStream(
source: Readable,
options: NodeReadableToWebStreamOptions = {},
): ReadableStream<Uint8Array> {
const iterator = source[Symbol.asyncIterator]()
let finished = false
return new ReadableStream<Uint8Array>(
{
async pull(controller) {
if (finished) {
return
}
try {
const next = await iterator.next()
if (next.done) {
finished = true
controller.close()
return
}
const chunk = next.value
controller.enqueue(chunk instanceof Uint8Array ? chunk : Buffer.from(chunk))
} catch (error) {
finished = true
if (!source.destroyed) {
source.destroy()
}
controller.error(error)
}
},
async cancel(reason) {
finished = true
try {
await options.onCancel?.(reason)
} finally {
if (!source.destroyed) {
source.destroy()
}
}
},
},
{
highWaterMark: 1,
size: () => 1,
},
)
}

View File

@ -562,6 +562,7 @@ export async function cleanupOnError(
materialId: string | null,
uploadIds: string[] = [],
deleteMaterial = true,
deleteMaterialCondition?: Record<string, unknown>,
): Promise<void> {
console.log('[Cleanup] Starting error cleanup...');
@ -618,10 +619,21 @@ export async function cleanupOnError(
// 删除素材记录
if (materialId && deleteMaterial) {
try {
await prisma.material.delete({
where: { id: materialId },
});
console.log(`[Cleanup] Deleted material record: ${materialId}`);
if (deleteMaterialCondition) {
const result = await prisma.material.deleteMany({
where: { id: materialId, ...deleteMaterialCondition } as any,
});
if (result.count === 1) {
console.log(`[Cleanup] Deleted conditionally claimed material record: ${materialId}`);
} else {
console.warn(`[Cleanup] Skipped material deletion because claim state changed: ${materialId}`);
}
} else {
await prisma.material.delete({
where: { id: materialId },
});
console.log(`[Cleanup] Deleted material record: ${materialId}`);
}
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete material record:', cleanupError);
}