461 lines
15 KiB
TypeScript
461 lines
15 KiB
TypeScript
/**
|
||
* 外部接口:产品全案素材查询和删除
|
||
* GET - 通过任务号查询素材详情
|
||
* DELETE - 通过任务号删除素材
|
||
*/
|
||
|
||
import { NextRequest } from 'next/server';
|
||
import {
|
||
validateApiKey,
|
||
getApiKeyFromRequest,
|
||
logApiCall,
|
||
errorResponse,
|
||
successResponse,
|
||
API_ERROR,
|
||
getClientIp,
|
||
} from '@/lib/external-api';
|
||
import {
|
||
validateTaskNo,
|
||
getMaterialById,
|
||
getMaterialByTaskNo,
|
||
updateTaskStatus,
|
||
softDeleteByTaskNo,
|
||
TASK_STATUS,
|
||
ASSET_TYPE,
|
||
} from '@/lib/product-package';
|
||
import { generateDownloadUrl } from '@/lib/download-token';
|
||
import { rewriteStorageUrlForClient } from '@/lib/url-rewrite';
|
||
import { isProductPackageType } from '@/lib/material-type-mapper';
|
||
import { getPublicAppOrigin } from '@/lib/public-app-url';
|
||
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
|
||
import { isVideoMaterialType } from '@/lib/video-material-types';
|
||
import path from 'path';
|
||
|
||
function getBaseUrl(): string {
|
||
return getPublicAppOrigin();
|
||
}
|
||
|
||
function isValidMaterialId(materialId: string | null): materialId is string {
|
||
if (!materialId) {
|
||
return false
|
||
}
|
||
|
||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(materialId)
|
||
}
|
||
|
||
/**
|
||
* GET - 查询素材详情
|
||
*/
|
||
export async function GET(request: NextRequest) {
|
||
const startTime = Date.now();
|
||
const apiKey = getApiKeyFromRequest(request);
|
||
const taskNo = request.nextUrl.searchParams.get('taskNo');
|
||
const materialId = request.nextUrl.searchParams.get('materialId')
|
||
|
||
// 验证 API Key
|
||
const keyRecord = await validateApiKey(apiKey);
|
||
if (!keyRecord) {
|
||
return errorResponse(API_ERROR.INVALID_API_KEY);
|
||
}
|
||
|
||
if (!taskNo && !materialId) {
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
statusCode: 400,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
return errorResponse(API_ERROR.INVALID_QUERY);
|
||
}
|
||
|
||
if (materialId && !isValidMaterialId(materialId)) {
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
statusCode: 400,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
return errorResponse(API_ERROR.INVALID_MATERIAL_ID);
|
||
}
|
||
|
||
if (!materialId && taskNo && !validateTaskNo(taskNo)) {
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
taskNo,
|
||
statusCode: 400,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
return errorResponse(API_ERROR.INVALID_TASK_NO);
|
||
}
|
||
|
||
try {
|
||
// 查询素材
|
||
console.log('[external/product-assets] Querying material:', { taskNo, materialId });
|
||
const material: any = materialId ? await getMaterialById(materialId) : await getMaterialByTaskNo(taskNo as string);
|
||
console.log('[external/product-assets] Material found:', material ? {
|
||
id: material.id,
|
||
taskNo: material.taskNo,
|
||
materialType: material.materialType,
|
||
taskStatus: material.taskStatus,
|
||
assetsCount: material.assets?.length || 0,
|
||
hasPreviewUrl: !!material.previewUrl,
|
||
} : 'null');
|
||
|
||
if (!material) {
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
taskNo: taskNo || undefined,
|
||
statusCode: 404,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
return errorResponse(materialId ? API_ERROR.MATERIAL_NOT_FOUND : API_ERROR.TASK_NOT_FOUND);
|
||
}
|
||
|
||
if (material.deletedAt) {
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
taskNo: taskNo || undefined,
|
||
statusCode: 410,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
return errorResponse(API_ERROR.MATERIAL_DELETED);
|
||
}
|
||
|
||
// 更新任务状态为 fetched
|
||
if (material.taskStatus !== TASK_STATUS.FETCHED) {
|
||
await updateTaskStatus(material.id, TASK_STATUS.FETCHED, {
|
||
taskFetchedAt: new Date(),
|
||
});
|
||
}
|
||
|
||
const baseUrl = getBaseUrl();
|
||
const resolvedIsFullSet = deriveTaskDetailIsFullSet(
|
||
material.materialType,
|
||
material.assets,
|
||
Boolean(material.isFullSet),
|
||
);
|
||
console.log('[external/product-assets] Base URL:', baseUrl);
|
||
console.log('[external/product-assets] Material type:', material.materialType);
|
||
|
||
// 根据素材类型返回不同结构
|
||
if (isProductPackageType(material.materialType)) {
|
||
// 任务-详情
|
||
// 注意:任务平台主要使用 downloadUrls.all
|
||
const mainAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.MAIN);
|
||
const skuAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.SKU);
|
||
const detailAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.DETAIL);
|
||
const psdAssets = (material.assets || []).filter((a: any) => a.assetType === ASSET_TYPE.PSD);
|
||
|
||
console.log('[external/product-assets] Assets grouped:', {
|
||
main: mainAssets.length,
|
||
sku: skuAssets.length,
|
||
detail: detailAssets.length,
|
||
psd: psdAssets.length
|
||
});
|
||
|
||
// 检查是否有资产(产品全案至少应该有主图、SKU图或详情图)
|
||
if (mainAssets.length === 0 && skuAssets.length === 0 && detailAssets.length === 0) {
|
||
console.warn('[external/product-assets] Warning: Product package material has no assets:', {
|
||
materialId: material.id,
|
||
taskNo: material.taskNo,
|
||
});
|
||
}
|
||
|
||
// 生成资产列表(仅返回资源信息,不返回分类型下载链接)
|
||
const formatAsset = (asset: any) => {
|
||
try {
|
||
// 验证资产数据
|
||
if (!asset || !asset.id) {
|
||
throw new Error(`Invalid asset data: ${JSON.stringify(asset)}`);
|
||
}
|
||
|
||
if (!asset.fileUrl) {
|
||
console.warn('[external/product-assets] Asset has no fileUrl:', {
|
||
assetId: asset.id,
|
||
assetType: asset.assetType,
|
||
fileName: asset.fileName,
|
||
});
|
||
}
|
||
|
||
if (!material.id) {
|
||
throw new Error(`Material has no id: ${JSON.stringify({ taskNo: material.taskNo, materialId: material.id })}`);
|
||
}
|
||
|
||
const rewrittenUrl = rewriteStorageUrlForClient(asset.fileUrl, request);
|
||
return {
|
||
id: asset.id,
|
||
url: rewrittenUrl,
|
||
fileName: asset.fileName || '',
|
||
fileSize: Number(asset.fileSize || 0),
|
||
sortOrder: asset.sortOrder || 0,
|
||
};
|
||
} catch (formatError) {
|
||
console.error('[external/product-assets] Error formatting asset:', {
|
||
assetId: asset?.id,
|
||
assetType: asset?.assetType,
|
||
materialId: material.id,
|
||
taskNo: material.taskNo,
|
||
error: formatError instanceof Error ? formatError.message : String(formatError),
|
||
stack: formatError instanceof Error ? formatError.stack : undefined,
|
||
});
|
||
throw formatError;
|
||
}
|
||
};
|
||
|
||
const allDownloadUrl = generateDownloadUrl(baseUrl, {
|
||
materialId: material.id,
|
||
downloadType: 'all',
|
||
});
|
||
|
||
const responseData: Record<string, any> = {
|
||
materialId: material.id,
|
||
taskNo: material.taskNo,
|
||
productModel: material.productModel,
|
||
categoryName: material.categoryName,
|
||
shopName: material.categoryName,
|
||
sceneName: material.sceneName,
|
||
isFullSet: resolvedIsFullSet,
|
||
status: TASK_STATUS.FETCHED,
|
||
createdAt: material.createdAt.toISOString(),
|
||
updatedAt: material.updatedAt.toISOString(),
|
||
assets: {
|
||
main: mainAssets.map((a: any) => formatAsset(a)),
|
||
sku: skuAssets.map((a: any) => formatAsset(a)),
|
||
detail: detailAssets.map((a: any) => formatAsset(a)),
|
||
psd: psdAssets.map((a: any) => formatAsset(a)),
|
||
},
|
||
// 兼容字段:给任务平台一个“单一”的下载链接(全部打包)。
|
||
// 避免返回 main/sku/detail 等下载链接,导致对方做可用性校验时被 404(NO_ASSETS) 拦截。
|
||
downloadUrl: allDownloadUrl,
|
||
downloadUrls: {
|
||
all: allDownloadUrl, // 任务平台只需要这个
|
||
},
|
||
};
|
||
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
taskNo: material.taskNo || taskNo || undefined,
|
||
statusCode: 200,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
|
||
return successResponse(responseData);
|
||
} else {
|
||
// 其他素材类型:返回统一的 files 结构和单个下载链接
|
||
const previewUrl = material.previewUrl || '';
|
||
const rewrittenPreviewUrl = previewUrl ? rewriteStorageUrlForClient(previewUrl, request) : '';
|
||
|
||
// 生成文件名(从zipFileName或productModel推断)
|
||
let fileName = material.zipFileName || '';
|
||
if (!fileName) {
|
||
// 如果没有zipFileName,尝试从productModel生成
|
||
if (material.productModel) {
|
||
// 根据素材类型推断扩展名
|
||
const ext = material.materialType === '音乐' ? '.mp3' :
|
||
isVideoMaterialType(material.materialType) ? '.mp4' :
|
||
material.materialType === '品牌VI灵感' ? '.pdf' :
|
||
'.zip';
|
||
fileName = `${material.productModel}${ext}`;
|
||
} else {
|
||
fileName = 'material.zip';
|
||
}
|
||
}
|
||
|
||
const responseData: Record<string, any> = {
|
||
materialId: material.id,
|
||
taskNo: material.taskNo,
|
||
productModel: material.productModel,
|
||
categoryName: material.categoryName,
|
||
shopName: material.categoryName,
|
||
sceneName: material.sceneName,
|
||
isFullSet: resolvedIsFullSet,
|
||
status: material.taskStatus || TASK_STATUS.FETCHED,
|
||
createdAt: material.createdAt.toISOString(),
|
||
updatedAt: material.updatedAt.toISOString(),
|
||
files: [
|
||
{
|
||
id: material.id,
|
||
url: rewrittenPreviewUrl,
|
||
downloadUrl: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }),
|
||
fileName: fileName || 'material.zip',
|
||
fileSize: Number(material.zipFileSize || 0),
|
||
...(material.videoDuration && { duration: material.videoDuration }),
|
||
},
|
||
],
|
||
downloadUrls: {
|
||
all: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }),
|
||
},
|
||
};
|
||
|
||
if (material.materialType === '任务-其他') {
|
||
responseData.materialType = material.materialType;
|
||
responseData.status = TASK_STATUS.FETCHED;
|
||
}
|
||
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
taskNo: material.taskNo || taskNo || undefined,
|
||
statusCode: 200,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
|
||
return successResponse(responseData);
|
||
}
|
||
|
||
} catch (error) {
|
||
// 记录详细的错误信息
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||
console.error('[external/product-assets] GET error:', {
|
||
taskNo,
|
||
materialId,
|
||
error: errorMessage,
|
||
stack: errorStack,
|
||
apiKeyId: keyRecord?.id,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
// 尝试记录日志(即使keyRecord可能为null)
|
||
try {
|
||
if (keyRecord) {
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
taskNo: taskNo || undefined,
|
||
statusCode: 500,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
}
|
||
} catch (logError) {
|
||
console.error('[external/product-assets] Failed to log API call:', logError);
|
||
}
|
||
|
||
return errorResponse(API_ERROR.SERVER_ERROR);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* DELETE - 删除素材
|
||
*/
|
||
export async function DELETE(request: NextRequest) {
|
||
const startTime = Date.now();
|
||
const apiKey = getApiKeyFromRequest(request);
|
||
const taskNo = request.nextUrl.searchParams.get('taskNo');
|
||
|
||
// 验证 API Key
|
||
const keyRecord = await validateApiKey(apiKey);
|
||
if (!keyRecord) {
|
||
return errorResponse(API_ERROR.INVALID_API_KEY);
|
||
}
|
||
|
||
// 验证任务号格式
|
||
if (!taskNo || !validateTaskNo(taskNo)) {
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'DELETE',
|
||
taskNo: taskNo || undefined,
|
||
statusCode: 400,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
return errorResponse(API_ERROR.INVALID_TASK_NO);
|
||
}
|
||
|
||
try {
|
||
// 查询素材
|
||
const material: any = await getMaterialByTaskNo(taskNo);
|
||
|
||
if (!material) {
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'DELETE',
|
||
taskNo,
|
||
statusCode: 404,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
return errorResponse(API_ERROR.TASK_NOT_FOUND);
|
||
}
|
||
|
||
if (material.deletedAt) {
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'DELETE',
|
||
taskNo,
|
||
statusCode: 410,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
return errorResponse(API_ERROR.MATERIAL_DELETED);
|
||
}
|
||
|
||
// 软删除
|
||
await softDeleteByTaskNo(taskNo);
|
||
|
||
// 记录日志
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'DELETE',
|
||
taskNo,
|
||
statusCode: 200,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
|
||
return successResponse({
|
||
taskNo,
|
||
deletedAt: new Date().toISOString(),
|
||
}, '素材已删除');
|
||
} catch (error) {
|
||
console.error('[external/product-assets] DELETE error:', error);
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'DELETE',
|
||
taskNo,
|
||
statusCode: 500,
|
||
duration: Date.now() - startTime,
|
||
ipAddress: getClientIp(request),
|
||
userAgent: request.headers.get('user-agent') || undefined,
|
||
});
|
||
return errorResponse(API_ERROR.SERVER_ERROR);
|
||
}
|
||
}
|