331 lines
9.0 KiB
TypeScript
331 lines
9.0 KiB
TypeScript
/**
|
||
* 素材资产管理接口
|
||
* 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 }
|
||
);
|
||
}
|
||
}
|