diff --git a/.gitignore b/.gitignore index ac75cdc..dce783b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,8 @@ shadow_*.js # 数据库和部署脚本(本地使用) *.sql +!prisma/migrations/ +!prisma/migrations/**/*.sql check-*.ps1 deploy-*.ps1 sync-*.ps1 diff --git a/app/api/external/product-barcode-images/route.ts b/app/api/external/product-barcode-images/route.ts new file mode 100644 index 0000000..01d9d16 --- /dev/null +++ b/app/api/external/product-barcode-images/route.ts @@ -0,0 +1,206 @@ +import { NextRequest } from 'next/server' +import { prisma } from '@/lib/prisma' +import { rewriteStorageUrlForClient } from '@/lib/url-rewrite' +import { + API_ERROR, + errorResponse, + getApiKeyFromRequest, + getClientIp, + logApiCall, + successResponse, + validateApiKey, +} from '@/lib/external-api' + +interface BarcodeImageItem { + materialId: string + assetId: string + taskNo: string | null + productModel: string + categoryName: string + shopName: string + sceneName: string + skuCode: string | null + barcodes: string[] + previewUrl: string + fileName: string + sortOrder: number + uploadedAt: string + updatedAt: string + uploaderName: string + isLatest: boolean +} + +export async function GET(request: NextRequest) { + const startTime = Date.now() + const apiKey = getApiKeyFromRequest(request) + const rawBarcode = request.nextUrl.searchParams.get('barcode') + const barcode = String(rawBarcode || '').trim() + + const keyRecord = await validateApiKey(apiKey) + if (!keyRecord) { + return errorResponse(API_ERROR.INVALID_API_KEY) + } + + if (!barcode) { + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-barcode-images', + method: 'GET', + queryType: 'barcode', + queryValue: rawBarcode || undefined, + statusCode: 400, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }) + return errorResponse(API_ERROR.INVALID_BARCODE) + } + + try { + const matches = await prisma.materialAssetBarcode.findMany({ + where: { + barcode, + asset: { + assetType: 'sku-preview', + material: { + materialType: '任务-详情', + deletedAt: null, + }, + }, + }, + select: { + assetId: true, + barcode: true, + asset: { + select: { + id: true, + fileUrl: true, + fileName: true, + sortOrder: true, + skuCode: true, + createdAt: true, + updatedAt: true, + barcodes: { + select: { + barcode: true, + }, + }, + material: { + select: { + id: true, + taskNo: true, + productModel: true, + categoryName: true, + sceneName: true, + authorName: true, + user: { + select: { + realName: true, + username: true, + }, + }, + }, + }, + }, + }, + }, + }) + + const items = matches + .map((match): BarcodeImageItem | null => { + const asset = match.asset + const material = asset?.material + if (!asset || !material) { + return null + } + + return { + materialId: String(material.id), + assetId: String(asset.id), + taskNo: material.taskNo || null, + productModel: String(material.productModel || ''), + categoryName: String(material.categoryName || ''), + shopName: String(material.categoryName || ''), + sceneName: String(material.sceneName || ''), + skuCode: asset.skuCode || null, + barcodes: Array.from( + new Set((asset.barcodes || []).map((item: any) => String(item.barcode || '').trim()).filter(Boolean)), + ), + previewUrl: rewriteStorageUrlForClient(asset.fileUrl, request) || String(asset.fileUrl || ''), + fileName: String(asset.fileName || ''), + sortOrder: Number(asset.sortOrder || 0), + uploadedAt: asset.createdAt.toISOString(), + updatedAt: asset.updatedAt.toISOString(), + uploaderName: + material.user?.realName || material.user?.username || material.authorName || '', + isLatest: false, + } + }) + .filter((item): item is BarcodeImageItem => Boolean(item)) + .sort((left, right) => { + const uploadedAtDiff = + new Date(right.uploadedAt).getTime() - new Date(left.uploadedAt).getTime() + if (uploadedAtDiff !== 0) { + return uploadedAtDiff + } + + return left.sortOrder - right.sortOrder + }) + + if (items.length === 0) { + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-barcode-images', + method: 'GET', + queryType: 'barcode', + queryValue: barcode, + statusCode: 404, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }) + return errorResponse(API_ERROR.BARCODE_NOT_FOUND) + } + + const latestAssetId = items[0].assetId + const responseData = { + barcode, + total: items.length, + items: items.map((item) => ({ + ...item, + isLatest: item.assetId === latestAssetId, + })), + } + + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-barcode-images', + method: 'GET', + taskNo: responseData.items[0]?.taskNo || undefined, + queryType: 'barcode', + queryValue: barcode, + statusCode: 200, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }) + + return successResponse(responseData) + } catch (error) { + console.error('[external/product-barcode-images] GET error:', error) + + await logApiCall({ + apiKeyId: keyRecord.id, + endpoint: '/api/external/product-barcode-images', + method: 'GET', + queryType: 'barcode', + queryValue: barcode, + statusCode: 500, + duration: Date.now() - startTime, + ipAddress: getClientIp(request), + userAgent: request.headers.get('user-agent') || undefined, + }) + + return errorResponse(API_ERROR.SERVER_ERROR) + } +} diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index afadb3c..9d9215b 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -33,6 +33,31 @@ function parseDisplayImages(displayImages: any): string[] { return []; } +function formatTaskDetailAsset(asset: any, uploaderName: string, rewriteFileUrl?: (url: string) => string) { + const nextFileUrl = rewriteFileUrl ? rewriteFileUrl(asset.fileUrl) : asset.fileUrl + + if (asset.assetType !== 'sku-preview') { + return { + ...asset, + fileUrl: nextFileUrl, + } + } + + return { + ...asset, + fileUrl: nextFileUrl, + skuCode: asset.skuCode || null, + barcodes: Array.isArray(asset.barcodes) + ? asset.barcodes + .map((item: any) => String(item?.barcode || '').trim()) + .filter(Boolean) + : [], + uploadedAt: asset.createdAt || null, + updatedAt: asset.updatedAt || null, + uploaderName, + } +} + // GET /api/materials/[id] - 获取单个素材详情 export async function GET( request: NextRequest, @@ -92,6 +117,14 @@ export async function GET( fileName: true, fileSize: true, sortOrder: true, + createdAt: true, + updatedAt: true, + skuCode: true, + barcodes: { + select: { + barcode: true, + }, + }, }, orderBy: { sortOrder: 'asc' }, }, @@ -134,13 +167,33 @@ export async function GET( taskStatus: true, taskFetchedAt: true, isFullSet: true, - user: { - select: { - id: true, - username: true, - }, - }, - } as any, + user: { + select: { + id: true, + username: true, + realName: true, + }, + }, + assets: { + select: { + id: true, + assetType: true, + fileUrl: true, + fileName: true, + fileSize: true, + sortOrder: true, + createdAt: true, + updatedAt: true, + skuCode: true, + barcodes: { + select: { + barcode: true, + }, + }, + }, + orderBy: { sortOrder: 'asc' }, + }, + } as any, }); } else { throw error; @@ -194,6 +247,10 @@ export async function GET( // 优先使用用户的真实姓名或用户名,如果没有才使用 authorName const displayAuthorName = materialWithUser.user?.realName || materialWithUser.user?.username || materialWithUser.authorName; + const formattedAssets = (materialWithUser.assets || []).map((asset: any) => + formatTaskDetailAsset(asset, displayAuthorName), + ); + const response = { id: materialWithUser.id, userId: materialWithUser.userId, @@ -227,7 +284,7 @@ export async function GET( materialWithUser.assets, Boolean(materialWithUser.isFullSet), ), - assets: materialWithUser.assets || [], + assets: formattedAssets, user: { username: materialWithUser.user?.username || '', }, @@ -272,9 +329,15 @@ export async function GET( videoSourceUrl: rewriteMediaUrlForClient(response.videoSourceUrl, request, 'video'), // 音频文件:特殊处理 zipFileName: processedZipFileName, - assets: response.assets?.map((asset: any) => ({ + assets: formattedAssets.map((asset: any) => ({ ...asset, fileUrl: rewriteMediaUrlForClient(asset.fileUrl, request, 'image'), + fileSize: + typeof asset.fileSize === 'bigint' + ? asset.fileSize.toString() + : asset.fileSize != null + ? String(asset.fileSize) + : '0', })), }; diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 55717ac..28cfce9 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -46,6 +46,41 @@ function parseDisplayImages(displayImages: any): string[] { return []; } +function formatTaskDetailAssetSummary( + asset: any, + uploaderName: string, + rewriteFileUrl?: (url: string) => string | null, +) { + const nextFileUrl = rewriteFileUrl ? rewriteFileUrl(asset.fileUrl) : asset.fileUrl; + const baseAsset = { + ...asset, + fileUrl: nextFileUrl || asset.fileUrl || '', + fileSize: + typeof asset.fileSize === 'bigint' + ? asset.fileSize.toString() + : asset.fileSize != null + ? String(asset.fileSize) + : '0', + }; + + if (asset.assetType !== 'sku-preview') { + return baseAsset; + } + + return { + ...baseAsset, + skuCode: asset.skuCode || null, + barcodes: Array.isArray(asset.barcodes) + ? asset.barcodes + .map((item: any) => String(item?.barcode || '').trim()) + .filter(Boolean) + : [], + uploadedAt: asset.createdAt || null, + updatedAt: asset.updatedAt || null, + uploaderName, + }; +} + // 素材类型映射(兼容旧数据) const MATERIAL_TYPE_MAP: Record = { // 3DMAX → 3D场景 @@ -122,6 +157,14 @@ export async function GET(request: NextRequest) { fileName: true, fileSize: true, sortOrder: true, + createdAt: true, + updatedAt: true, + skuCode: true, + barcodes: { + select: { + barcode: true, + }, + }, }, orderBy: { sortOrder: 'asc' }, }, @@ -212,6 +255,13 @@ export async function GET(request: NextRequest) { // 优先使用用户的真实姓名或用户名,如果没有才使用 authorName const displayAuthorName = material.user?.realName || material.user?.username || material.authorName; + const formattedAssets = (material.assets || []).map((asset: any) => + formatTaskDetailAssetSummary( + asset, + displayAuthorName, + (fileUrl) => rewriteMediaUrlForClient(fileUrl, request, 'image'), + ), + ); const normalizedMaterial = normalizeMaterial({ ...material, @@ -225,6 +275,7 @@ export async function GET(request: NextRequest) { ...material.user, avatar: normalizeUrl(material.user.avatar), } : null, + assets: formattedAssets, }); return NextResponse.json({ material: normalizedMaterial }); @@ -487,7 +538,16 @@ export async function GET(request: NextRequest) { assetType: true, fileUrl: true, fileName: true, + fileSize: true, sortOrder: true, + createdAt: true, + updatedAt: true, + skuCode: true, + barcodes: { + select: { + barcode: true, + }, + }, }, where: { assetType: { in: ['main-preview', 'sku-preview', 'detail-preview'] }, @@ -872,6 +932,13 @@ export async function GET(request: NextRequest) { // 优先使用用户的真实姓名或用户名,如果没有才使用 authorName const displayAuthorName = material.user?.realName || material.user?.username || material.authorName; + const formattedAssets = (material.assets || []).map((asset: any) => + formatTaskDetailAssetSummary( + asset, + displayAuthorName, + (fileUrl) => rewriteMediaUrlForClient(fileUrl, request, 'image'), + ), + ); const formatted = { ...material, @@ -885,6 +952,7 @@ export async function GET(request: NextRequest) { ...material.user, avatar: normalizeUrl(material.user.avatar), } : null, + assets: formattedAssets, }; // 应用统一规则:内网用户直接访问,外网用户根据文件大小智能选择 // 注意:不要先调用 normalizeMaterial,因为它会将URL转换为代理URL,导致内网用户无法重写为内网地址 @@ -924,6 +992,7 @@ export async function GET(request: NextRequest) { videoSourceUrl: rewriteMediaUrlForClient(formatted.videoSourceUrl, request, 'video'), // 音频文件:特殊处理 zipFileName: processedZipFileName, + assets: formattedAssets, }; return rewrittenMaterial; diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index 0684d31..3f3ed5e 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -19,6 +19,8 @@ interface AssetUpload { fileName: string fileSize: number sortOrder?: number + skuCode?: string + barcodes?: string[] } interface PsdAsset { diff --git a/components/SuperAdminsTab.tsx b/components/SuperAdminsTab.tsx index d05a0a3..ebcb037 100644 --- a/components/SuperAdminsTab.tsx +++ b/components/SuperAdminsTab.tsx @@ -64,9 +64,23 @@ interface NotificationTargetFormState { token: string headerName: string headerValue: string + eventTypes: string[] isActive: boolean } +const NOTIFICATION_EVENT_OPTIONS = [ + { + value: 'task-detail.full-set.ready', + label: '任务-详情全套入库完成', + description: '整套素材完成后推送成套资源信息。', + }, + { + value: 'task-detail.barcode-images.updated', + label: '任务-详情条码图片更新', + description: '带产品条码的 SKU 预览图新增或更新后推送条码清单。', + }, +] + const EMPTY_NOTIFICATION_TARGET_FORM: NotificationTargetFormState = { name: '', callbackUrl: '', @@ -74,6 +88,7 @@ const EMPTY_NOTIFICATION_TARGET_FORM: NotificationTargetFormState = { token: '', headerName: '', headerValue: '', + eventTypes: ['task-detail.full-set.ready'], isActive: true, } @@ -371,6 +386,11 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) { return } + if (notificationTargetForm.eventTypes.length === 0) { + showToast('请至少选择 1 个通知事件', 'error') + return + } + setSavingNotificationTarget(true) try { const authConfig = @@ -389,6 +409,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) { isActive: notificationTargetForm.isActive, authType: notificationTargetForm.authType, authConfig, + eventTypes: notificationTargetForm.eventTypes, } const response = await fetch( @@ -428,6 +449,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) { token: target.authConfig.token || '', headerName: target.authConfig.headerName || '', headerValue: target.authConfig.headerValue || '', + eventTypes: target.eventTypes?.length > 0 ? target.eventTypes : ['task-detail.full-set.ready'], isActive: target.isActive, }) } @@ -762,10 +784,10 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {

- 自动上架通知目标 + 外部系统通知目标

- 这里配置素材库主动通知的目标系统。当前事件固定为“任务-详情全套入库完成”。 + 这里配置素材库主动通知的目标系统。`task-detail.full-set.ready` 主要给自动上架平台,`task-detail.barcode-images.updated` 主要给产品平台。

+
+ {target.eventTypes.map((eventType) => ( + + {eventType === 'task-detail.full-set.ready' + ? '自动上架平台 / 全套入库' + : eventType === 'task-detail.barcode-images.updated' + ? '产品平台 / 条码图片更新' + : eventType} + + ))} +
更新时间:{formatDateTime(target.updatedAt)}
@@ -875,7 +911,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) { name: e.target.value, })) } - placeholder="例如:自动上架平台" + placeholder="例如:自动上架平台 / 产品平台" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500" /> @@ -992,8 +1028,37 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) { 创建后立即启用 -
- 当前固定订阅事件:任务-详情全套入库完成(`task-detail.full-set.ready`) +
+
订阅事件
+ {NOTIFICATION_EVENT_OPTIONS.map((eventOption) => ( + + ))}
diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index 55ef031..39ddc58 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -62,6 +62,20 @@ interface AssetFile { preview: string; id: string; sortOrder: number; + skuCode?: string; + barcodeInput?: string; + barcodes?: string[]; +} + +function parseBarcodeInput(input: string): string[] { + return Array.from( + new Set( + String(input || '') + .split(/[\r\n,,、;;]+/) + .map((item) => item.trim()) + .filter(Boolean), + ), + ); } export default function ProductPackageUploader({ @@ -265,6 +279,12 @@ export default function ProductPackageUploader({ }); }; + const updateSkuImageMeta = (id: string, updates: Partial) => { + setSkuImages((prev) => + prev.map((item) => (item.id === id ? { ...item, ...updates } : item)), + ); + }; + // 处理 PSD 文件选择 const handlePsdSelect = (files: FileList | null) => { if (!files || files.length === 0) return; @@ -372,6 +392,44 @@ export default function ProductPackageUploader({ return; } + let normalizedSkuImages: AssetFile[]; + try { + normalizedSkuImages = skuImages.map((image, index) => { + const skuCode = String(image.skuCode || '').trim(); + if (!skuCode) { + throw new Error(`请为第 ${index + 1} 张 SKU 图填写 SKU 编号`); + } + + const barcodes = parseBarcodeInput(image.barcodeInput || ''); + if (barcodes.length === 0) { + throw new Error(`请为 SKU 图 ${image.file.name} 至少填写 1 个产品条码`); + } + + return { + ...image, + skuCode, + barcodeInput: barcodes.join('\n'), + barcodes, + }; + }); + } catch (validationError) { + setError(validationError instanceof Error ? validationError.message : 'SKU 条码信息校验失败'); + return; + } + + const barcodeOwners = new Map(); + for (const image of normalizedSkuImages) { + for (const barcode of image.barcodes || []) { + const existingOwner = barcodeOwners.get(barcode); + if (existingOwner && existingOwner !== image.id) { + setError(`产品条码 ${barcode} 不能同时绑定两张 SKU 图`); + return; + } + + barcodeOwners.set(barcode, image.id); + } + } + setUploading(true); setUploadProgress(0); setProcessingStep(''); @@ -471,7 +529,12 @@ export default function ProductPackageUploader({ const mainAssets = await uploadAssets(mainImages, 'main'); setProcessingStep('上传SKU图中...'); - const skuAssets = await uploadAssets(skuImages, 'sku'); + const uploadedSkuAssets = await uploadAssets(normalizedSkuImages, 'sku'); + const skuAssets = uploadedSkuAssets.map((asset, index) => ({ + ...asset, + skuCode: normalizedSkuImages[index].skuCode, + barcodes: normalizedSkuImages[index].barcodes || [], + })); setProcessingStep('上传详情图中...'); const detailAssets = await uploadAssets(detailImages, 'detail'); @@ -669,7 +732,7 @@ export default function ProductPackageUploader({

- 支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传 + 支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传。每张 SKU 图都要填写 SKU 编号,并录入至少 1 个产品条码。

@@ -703,7 +766,7 @@ export default function ProductPackageUploader({ />
{skuImages.map((img) => ( -
+
@@ -715,6 +778,34 @@ export default function ProductPackageUploader({ > × +
+
+ + updateSkuImageMeta(img.id, { skuCode: e.target.value })} + placeholder="例如 SKU001" + disabled={uploading} + className="w-full rounded-apple border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white" + /> +
+
+ +