331 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 素材资产管理接口
* GET - 获取资产列表
* POST - 添加资产
* DELETE - 删除资产
*/
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { getAssetsByMaterialId } from '@/lib/product-package';
import { deleteFileIfUnreferenced } from '@/lib/file-cleanup';
import { createMaterialLog } from '@/lib/material-log';
import { normalizeDisplayImages } from '@/lib/material-cleanup';
import {
canUserViewMaterial,
} from '@/lib/department-permission';
import {
canManageMaterial,
type MaterialAuthorizationUser,
} from '@/lib/material-authorization';
import { isProductPackageType } from '@/lib/material-type-mapper';
type AssetRouteSessionUser = MaterialAuthorizationUser & {
department?: string | null;
managedDepartment?: string | null;
};
async function canViewMaterial(
user: AssetRouteSessionUser,
material: { userId: string; materialType: string },
) {
if (material.userId === user.id) {
return true;
}
return canUserViewMaterial(
user.department,
user.role || 'USER',
user.managedDepartment,
material.materialType,
user.id,
);
}
/**
* GET - 获取资产列表
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const materialId = params.id;
// 验证素材是否存在
const material = await prisma.material.findUnique({
where: { id: materialId, deletedAt: null },
select: {
id: true,
userId: true,
materialType: true,
previewUrl: true,
zipFilePath: true,
zipFileName: true,
zipFileSize: true,
displayImages: true,
},
});
if (!material) {
return NextResponse.json(
{ success: false, error: '素材不存在' },
{ status: 404 }
);
}
if (!(await canViewMaterial(session.user as AssetRouteSessionUser, material))) {
return NextResponse.json(
{ success: false, error: '无权限查看此素材' },
{ status: 403 }
);
}
// 获取资产列表
const assets = await getAssetsByMaterialId(materialId);
return NextResponse.json({
success: true,
data: assets,
});
} catch (error) {
console.error('[materials/assets] GET error:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}
/**
* POST - 添加资产
*/
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
// P0 止血:客户端提交的 fileUrl 不能作为服务端资产记录或删除依据。
// 资产只能由受控上传完成链路在服务端生成;恢复此入口前必须改为服务端签发的存储引用。
return NextResponse.json(
{
success: false,
error: '当前不支持直接注册素材文件地址,请通过受控上传流程添加资产',
},
{ status: 403 },
);
} catch (error) {
console.error('[materials/assets] POST error:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}
/**
* DELETE - 删除资产
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user || !(session.user as any).id) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
const userId = (session.user as any).id;
const materialId = params.id;
const { searchParams } = new URL(request.url);
const assetId = searchParams.get('assetId');
if (!assetId) {
return NextResponse.json(
{ success: false, error: '缺少 assetId 参数' },
{ status: 400 }
);
}
// 验证素材是否存在且属于当前用户
const material = await prisma.material.findUnique({
where: { id: materialId, deletedAt: null },
select: {
id: true,
userId: true,
materialType: true,
previewUrl: true,
zipFilePath: true,
zipFileName: true,
zipFileSize: true,
displayImages: true,
},
});
if (!material) {
return NextResponse.json(
{ success: false, error: '素材不存在' },
{ status: 404 }
);
}
if (!(await canManageMaterial(session.user as AssetRouteSessionUser, material))) {
return NextResponse.json(
{ success: false, error: '无权限操作此素材' },
{ status: 403 }
);
}
// 任务-详情的资产是打包产物:只删记录会让 ZIP 里仍保留旧图。
// 单图删除必须走 /assets/[assetId] 接口(会重建分组 ZIP 和 all.zip)。
if (isProductPackageType(material.materialType)) {
return NextResponse.json(
{
success: false,
error: '任务-详情素材请使用单图编辑接口删除图片,直接删除会导致下载包与预览不一致',
},
{ status: 409 },
);
}
// 验证资产是否属于该素材
const asset = await prisma.materialAsset.findUnique({
where: { id: assetId },
select: {
id: true,
materialId: true,
assetType: true,
fileUrl: true,
fileName: true,
},
});
if (!asset || asset.materialId !== materialId) {
return NextResponse.json(
{ success: false, error: '资产不存在或不属于该素材' },
{ status: 404 }
);
}
const previewAssetTypes = ['preview', 'main-preview', 'sku-preview', 'detail-preview'];
const currentDisplayImages = normalizeDisplayImages((material as any).displayImages);
const displayImages = currentDisplayImages.filter((url) => url !== asset.fileUrl);
const materialUpdateData: Record<string, unknown> = {};
if ((material as any).previewUrl === asset.fileUrl) {
const nextPreviewAsset = await prisma.materialAsset.findFirst({
where: {
materialId,
id: { not: assetId },
assetType: { in: previewAssetTypes },
},
orderBy: [
{ sortOrder: 'asc' },
{ createdAt: 'asc' },
],
select: { fileUrl: true },
});
materialUpdateData.previewUrl = nextPreviewAsset?.fileUrl || '';
}
if ((material as any).zipFilePath === asset.fileUrl) {
materialUpdateData.zipFilePath = '';
materialUpdateData.zipFileName = '';
materialUpdateData.zipFileSize = BigInt(0);
}
if (displayImages.length !== currentDisplayImages.length) {
materialUpdateData.displayImages = displayImages;
}
await prisma.$transaction(async (tx) => {
await tx.materialAsset.delete({
where: { id: assetId },
});
if (Object.keys(materialUpdateData).length > 0) {
await tx.material.update({
where: { id: materialId },
data: materialUpdateData as any,
});
}
});
const cleanupResult = await deleteFileIfUnreferenced(asset.fileUrl, {
excludeAssetIds: [assetId],
// 历史资产 fileUrl 可能来自客户端,P0 阶段只删除数据库关联,不执行物理删除。
allowPhysicalDelete: false,
});
await createMaterialLog({
materialId,
userId,
action: 'UPDATE',
changes: {
assetOperation: {
oldValue: null,
newValue: 'DELETE',
},
fileCleanup: {
oldValue: null,
newValue: cleanupResult.skipped ? 'skipped' : cleanupResult.success ? 'deleted' : 'failed',
},
assetId: {
oldValue: asset.id,
newValue: asset.id,
},
assetType: {
oldValue: asset.assetType,
newValue: asset.assetType,
},
assetFileName: {
oldValue: asset.fileName || null,
newValue: null,
},
assetFileUrl: {
oldValue: asset.fileUrl,
newValue: null,
},
},
});
return NextResponse.json({
success: true,
message: '删除成功',
details: {
fileDeleted: !cleanupResult.skipped && cleanupResult.success,
fileSkipped: !!cleanupResult.skipped,
fileCleanupError: cleanupResult.success ? null : cleanupResult.error || null,
},
});
} catch (error) {
console.error('[materials/assets] DELETE error:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },
{ status: 500 }
);
}
}