424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
/**
|
||
* 外部接口:产品全案素材查询和删除
|
||
* GET - 通过任务号查询素材详情
|
||
* DELETE - 通过任务号删除素材
|
||
*/
|
||
|
||
import { NextRequest } from 'next/server';
|
||
import {
|
||
validateApiKey,
|
||
getApiKeyFromRequest,
|
||
logApiCall,
|
||
errorResponse,
|
||
successResponse,
|
||
API_ERROR,
|
||
getClientIp,
|
||
} from '@/lib/external-api';
|
||
import {
|
||
validateTaskNo,
|
||
getMaterialByTaskNo,
|
||
updateTaskStatus,
|
||
softDeleteByTaskNo,
|
||
TASK_STATUS,
|
||
ASSET_TYPE,
|
||
} from '@/lib/product-package';
|
||
import { generateDownloadUrl, DownloadType } from '@/lib/download-token';
|
||
import { rewriteStorageUrlForClient } from '@/lib/url-rewrite';
|
||
import { isProductPackageType } from '@/lib/material-type-mapper';
|
||
import path from 'path';
|
||
|
||
// 获取基础 URL
|
||
function getBaseUrl(request: NextRequest): string {
|
||
const host = request.headers.get('host') || 'localhost:3000';
|
||
const protocol = request.headers.get('x-forwarded-proto') || 'http';
|
||
return `${protocol}://${host}`;
|
||
}
|
||
|
||
/**
|
||
* GET - 查询素材详情
|
||
*/
|
||
export async function GET(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) {
|
||
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_TASK_NO);
|
||
}
|
||
|
||
if (!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 for taskNo:', taskNo);
|
||
const material: any = await getMaterialByTaskNo(taskNo);
|
||
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,
|
||
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: 'GET',
|
||
taskNo,
|
||
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(request);
|
||
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, downloadType: DownloadType) => {
|
||
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);
|
||
const downloadUrl = generateDownloadUrl(baseUrl, {
|
||
materialId: material.id,
|
||
downloadType: asset.assetType === ASSET_TYPE.PSD ? 'psd' : downloadType,
|
||
assetId: asset.assetType === ASSET_TYPE.PSD ? asset.id : undefined,
|
||
});
|
||
return {
|
||
id: asset.id,
|
||
url: rewrittenUrl,
|
||
downloadUrl,
|
||
fileName: asset.fileName || '',
|
||
fileSize: Number(asset.fileSize || 0),
|
||
...(asset.assetType === ASSET_TYPE.DETAIL && { 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 responseData = {
|
||
taskNo: material.taskNo,
|
||
productModel: material.productModel,
|
||
categoryName: material.categoryName,
|
||
sceneName: material.sceneName,
|
||
status: TASK_STATUS.FETCHED,
|
||
createdAt: material.createdAt.toISOString(),
|
||
updatedAt: material.updatedAt.toISOString(),
|
||
assets: {
|
||
main: mainAssets.map((a: any) => formatAsset(a, 'main')),
|
||
sku: skuAssets.map((a: any) => formatAsset(a, 'sku')),
|
||
detail: detailAssets.map((a: any) => formatAsset(a, 'detail')),
|
||
psd: psdAssets.map((a: any) => formatAsset(a, 'psd')),
|
||
},
|
||
downloadUrls: {
|
||
all: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'all' }), // 任务平台主要使用这个
|
||
main: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'main' }),
|
||
sku: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'sku' }),
|
||
detail: generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'detail' }),
|
||
psd: psdAssets.length > 0 ? generateDownloadUrl(baseUrl, { materialId: material.id, downloadType: 'psd' }) : null,
|
||
},
|
||
};
|
||
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
taskNo,
|
||
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' :
|
||
['主图视频', '安装视频', '种草视频'].includes(material.materialType) ? '.mp4' :
|
||
material.materialType === '品牌VI灵感' ? '.pdf' :
|
||
'.zip';
|
||
fileName = `${material.productModel}${ext}`;
|
||
} else {
|
||
fileName = 'material.zip';
|
||
}
|
||
}
|
||
|
||
const responseData = {
|
||
taskNo: material.taskNo,
|
||
productModel: material.productModel,
|
||
categoryName: material.categoryName,
|
||
sceneName: material.sceneName,
|
||
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' }),
|
||
},
|
||
};
|
||
|
||
await logApiCall({
|
||
apiKeyId: keyRecord.id,
|
||
endpoint: '/api/external/product-assets',
|
||
method: 'GET',
|
||
taskNo,
|
||
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,
|
||
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,
|
||
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);
|
||
}
|
||
}
|