feat: support task detail barcode assets for product platform
This commit is contained in:
parent
02ebaabd0e
commit
96fcf312cb
2
.gitignore
vendored
2
.gitignore
vendored
@ -58,6 +58,8 @@ shadow_*.js
|
||||
|
||||
# 数据库和部署脚本(本地使用)
|
||||
*.sql
|
||||
!prisma/migrations/
|
||||
!prisma/migrations/**/*.sql
|
||||
check-*.ps1
|
||||
deploy-*.ps1
|
||||
sync-*.ps1
|
||||
|
||||
206
app/api/external/product-barcode-images/route.ts
vendored
Normal file
206
app/api/external/product-barcode-images/route.ts
vendored
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@ -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<string, string[]> = {
|
||||
// 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;
|
||||
|
||||
@ -19,6 +19,8 @@ interface AssetUpload {
|
||||
fileName: string
|
||||
fileSize: number
|
||||
sortOrder?: number
|
||||
skuCode?: string
|
||||
barcodes?: string[]
|
||||
}
|
||||
|
||||
interface PsdAsset {
|
||||
|
||||
@ -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) {
|
||||
<div className="flex flex-col gap-3 mb-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
自动上架通知目标
|
||||
外部系统通知目标
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
这里配置素材库主动通知的目标系统。当前事件固定为“任务-详情全套入库完成”。
|
||||
这里配置素材库主动通知的目标系统。`task-detail.full-set.ready` 主要给自动上架平台,`task-detail.barcode-images.updated` 主要给产品平台。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@ -824,6 +846,20 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
鉴权:{getNotificationTargetAuthSummary(target)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{target.eventTypes.map((eventType) => (
|
||||
<span
|
||||
key={eventType}
|
||||
className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-200"
|
||||
>
|
||||
{eventType === 'task-detail.full-set.ready'
|
||||
? '自动上架平台 / 全套入库'
|
||||
: eventType === 'task-detail.barcode-images.updated'
|
||||
? '产品平台 / 条码图片更新'
|
||||
: eventType}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
更新时间:{formatDateTime(target.updatedAt)}
|
||||
</div>
|
||||
@ -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"
|
||||
/>
|
||||
</div>
|
||||
@ -992,8 +1028,37 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
|
||||
创建后立即启用
|
||||
</label>
|
||||
|
||||
<div className="rounded-lg border border-blue-200 dark:border-blue-900/40 bg-blue-50 dark:bg-blue-900/10 px-3 py-2 text-xs text-blue-700 dark:text-blue-200">
|
||||
当前固定订阅事件:任务-详情全套入库完成(`task-detail.full-set.ready`)
|
||||
<div className="space-y-2 rounded-lg border border-blue-200 dark:border-blue-900/40 bg-blue-50 dark:bg-blue-900/10 px-3 py-3 text-xs text-blue-700 dark:text-blue-200">
|
||||
<div className="font-medium">订阅事件</div>
|
||||
{NOTIFICATION_EVENT_OPTIONS.map((eventOption) => (
|
||||
<label
|
||||
key={eventOption.value}
|
||||
className="flex items-start gap-2 text-sm text-blue-900 dark:text-blue-100"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationTargetForm.eventTypes.includes(eventOption.value)}
|
||||
onChange={(e) =>
|
||||
setNotificationTargetForm((current) => ({
|
||||
...current,
|
||||
eventTypes: e.target.checked
|
||||
? Array.from(new Set([...current.eventTypes, eventOption.value]))
|
||||
: current.eventTypes.filter((eventType) => eventType !== eventOption.value),
|
||||
}))
|
||||
}
|
||||
className="mt-0.5 rounded border-blue-300 dark:border-blue-700"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">{eventOption.label}</span>
|
||||
<span className="block text-xs text-blue-700/80 dark:text-blue-200/80">
|
||||
{eventOption.description}
|
||||
</span>
|
||||
<span className="block font-mono text-[11px] text-blue-700/80 dark:text-blue-200/80">
|
||||
{eventOption.value}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
|
||||
@ -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<AssetFile>) => {
|
||||
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<string, string>();
|
||||
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({
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传
|
||||
支持 JPG、PNG、WebP 等格式,可一次选择多张或拖拽上传。每张 SKU 图都要填写 SKU 编号,并录入至少 1 个产品条码。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -703,7 +766,7 @@ export default function ProductPackageUploader({
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{skuImages.map((img) => (
|
||||
<div key={img.id} className="relative group">
|
||||
<div key={img.id} className="relative group space-y-3 rounded-apple border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800/80">
|
||||
<div className="relative w-full h-32 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
|
||||
<img src={img.preview} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
@ -715,6 +778,34 @@ export default function ProductPackageUploader({
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
SKU 编号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={img.skuCode || ''}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
产品条码
|
||||
</label>
|
||||
<textarea
|
||||
value={img.barcodeInput || ''}
|
||||
onChange={(e) => updateSkuImageMeta(img.id, { barcodeInput: e.target.value })}
|
||||
placeholder="每行一个,或用逗号分隔"
|
||||
disabled={uploading}
|
||||
rows={4}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
|
||||
151
docs/产品平台条码图片推送接口.md
Normal file
151
docs/产品平台条码图片推送接口.md
Normal file
@ -0,0 +1,151 @@
|
||||
# 产品平台条码图片推送接口
|
||||
|
||||
更新时间:2026-04-22
|
||||
|
||||
## 目标
|
||||
|
||||
这组对接能力用于让素材库在出现新的「带产品条码的 SKU 预览图」时,主动通知产品平台。
|
||||
|
||||
产品平台收到通知后可以:
|
||||
|
||||
1. 直接使用通知里的预览图地址做图片处理
|
||||
2. 按 `lookupUrl` 反查同一个产品条码的全部历史图片
|
||||
3. 根据 `isLatest` 判断当前最新生效图片
|
||||
|
||||
## 触发规则
|
||||
|
||||
- 仅对 `任务-详情` 生效
|
||||
- 当一次保存里出现“带产品条码的 SKU 预览图新增或更新”时触发
|
||||
- 同一批次只发 1 次通知
|
||||
- 只推送本次变更的条码图片,不回推历史全量
|
||||
- 当前不发送“条码解绑/删除”通知
|
||||
|
||||
## 主识别键
|
||||
|
||||
- 主键:`barcode`
|
||||
- 辅助字段:`materialId`、`assetId`
|
||||
|
||||
说明:
|
||||
|
||||
- 同一个 `barcode` 可能命中多条历史图片
|
||||
- “当前有效”按最新上传时间判断
|
||||
- 产品平台不要把 `skuCode` 当成唯一键
|
||||
|
||||
## 通知方式
|
||||
|
||||
- 方法:`POST`
|
||||
- 方向:素材库服务端主动调用产品平台提供的回调地址
|
||||
- `Content-Type`:`application/json`
|
||||
|
||||
回调地址、鉴权方式、是否启用,由素材库后台配置。
|
||||
|
||||
当前支持的回调鉴权方式:
|
||||
|
||||
- 无鉴权
|
||||
- Bearer Token
|
||||
- 自定义 Header
|
||||
|
||||
## 通知事件
|
||||
|
||||
- `eventType`: `task-detail.barcode-images.updated`
|
||||
|
||||
## 通知 payload
|
||||
|
||||
### 顶层字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `eventId` | 事件唯一 ID |
|
||||
| `eventType` | 固定为 `task-detail.barcode-images.updated` |
|
||||
| `materialId` | 素材记录 ID |
|
||||
| `materialType` | 当前固定为 `任务-详情` |
|
||||
| `taskNo` | 任务号,可为空 |
|
||||
| `productModel` | SPU |
|
||||
| `categoryName` | 店铺 |
|
||||
| `shopName` | 店铺,当前与 `categoryName` 相同 |
|
||||
| `sceneName` | 场景 |
|
||||
| `occurredAt` | 本次推送时间 |
|
||||
| `items` | 条码图片清单,按“一条码一记录”展开 |
|
||||
|
||||
### `items[]` 字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `materialId` | 素材记录 ID |
|
||||
| `assetId` | SKU 预览图资产 ID |
|
||||
| `skuCode` | SKU 编号 |
|
||||
| `barcode` | 产品条码 |
|
||||
| `previewUrl` | SKU 预览图地址 |
|
||||
| `fileName` | 预览图文件名 |
|
||||
| `sortOrder` | 预览图顺序 |
|
||||
| `uploadedAt` | 该张 SKU 预览图上传时间 |
|
||||
| `updatedAt` | 最近更新时间 |
|
||||
| `uploaderName` | 上传人 |
|
||||
| `isLatest` | 该条码当前最新生效的图片 |
|
||||
| `lookupUrl` | 反查接口地址,可用来拉该条码全部历史记录 |
|
||||
|
||||
## 通知示例
|
||||
|
||||
```json
|
||||
{
|
||||
"eventId": "evt-20260422-0001",
|
||||
"eventType": "task-detail.barcode-images.updated",
|
||||
"materialId": "35d8644b-1e09-4ce8-9bb0-c529b39ba52e",
|
||||
"materialType": "任务-详情",
|
||||
"taskNo": "TASK202604220001",
|
||||
"productModel": "XG025503",
|
||||
"categoryName": "旗舰店",
|
||||
"shopName": "旗舰店",
|
||||
"sceneName": "客厅",
|
||||
"occurredAt": "2026-04-22T09:10:00.000Z",
|
||||
"items": [
|
||||
{
|
||||
"materialId": "35d8644b-1e09-4ce8-9bb0-c529b39ba52e",
|
||||
"assetId": "asset-sku-1",
|
||||
"skuCode": "SKU-001",
|
||||
"barcode": "690000000001",
|
||||
"previewUrl": "https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp",
|
||||
"fileName": "sku-1.webp",
|
||||
"sortOrder": 0,
|
||||
"uploadedAt": "2026-04-22T09:00:00.000Z",
|
||||
"updatedAt": "2026-04-22T09:05:00.000Z",
|
||||
"uploaderName": "张三",
|
||||
"isLatest": true,
|
||||
"lookupUrl": "https://sc.jiansun.vip/api/external/product-barcode-images?barcode=690000000001"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 反查接口
|
||||
|
||||
产品平台如果要主动按条码拉历史图片,调用:
|
||||
|
||||
```http
|
||||
GET /api/external/product-barcode-images?barcode={barcode}
|
||||
```
|
||||
|
||||
请求头:
|
||||
|
||||
```http
|
||||
X-API-Key: your-api-key
|
||||
```
|
||||
|
||||
## 成功判定
|
||||
|
||||
- 回调返回 `2xx`:视为成功
|
||||
- 回调超时、网络失败、非 `2xx`:视为失败
|
||||
|
||||
## 重试与补发
|
||||
|
||||
- 通知发送是异步执行,不阻塞素材入库成功
|
||||
- 失败后会自动重试
|
||||
- 超过重试上限后,会进入待人工处理
|
||||
- 素材库后台支持手工补发
|
||||
|
||||
## 对接建议
|
||||
|
||||
- 产品平台接到通知后,建议按 `eventId + barcode` 做幂等去重
|
||||
- 需要最新图片时,优先使用 `isLatest = true` 的记录
|
||||
- 需要完整历史时,直接调用 `lookupUrl`
|
||||
- 如果回调接口会做验签或 Header 校验,请在素材库后台配置对应鉴权方式
|
||||
228
docs/外部接口文档.md
228
docs/外部接口文档.md
@ -7,7 +7,7 @@
|
||||
|
||||
## 概述
|
||||
|
||||
素材平台提供外部接口,允许任务系统通过任务号查询、下载和删除各类素材(包括产品全案、3D场景、详情页、主图视频等所有素材类型)。
|
||||
素材平台提供外部接口,允许任务系统通过任务号查询、下载和删除各类素材(包括 `任务-详情`、`任务-其他`、3D 场景、详情模板、主图视频等所有素材类型)。
|
||||
|
||||
### 基础信息
|
||||
|
||||
@ -65,9 +65,13 @@ X-API-Key: your-api-key-here
|
||||
### 支持的素材类型
|
||||
|
||||
接口支持所有素材类型,包括但不限于:
|
||||
- 产品全案
|
||||
- 3D场景
|
||||
- 详情页
|
||||
- `任务-详情`
|
||||
- `任务-其他`
|
||||
- `任务-3D原图`
|
||||
- `任务-AI原图`
|
||||
- `任务-实拍原图`
|
||||
- `任务-视频截图`
|
||||
- 3D 场景
|
||||
- 详情模板
|
||||
- 主图视频
|
||||
- 安装视频
|
||||
@ -77,8 +81,8 @@ X-API-Key: your-api-key-here
|
||||
- 其他所有素材类型
|
||||
|
||||
**注意**:不同素材类型返回的数据结构略有不同:
|
||||
- **产品全案**:返回 `assets` 结构(包含 main、sku、detail、psd 分类)
|
||||
- **其他类型**:返回 `files` 结构(统一文件列表)
|
||||
- **`任务-详情`**:返回 `assets` 结构(包含 `main`、`sku`、`detail`、`psd` 分类)
|
||||
- **`任务-其他` 及其它非 `任务-详情` 类型**:返回 `files` 结构(统一文件列表)
|
||||
|
||||
---
|
||||
|
||||
@ -137,7 +141,7 @@ Host: sc.jiansun.vip
|
||||
X-API-Key: your-api-key-here
|
||||
```
|
||||
|
||||
**响应示例(成功 - 产品全案)**
|
||||
**响应示例(成功 - 任务-详情)**
|
||||
|
||||
```json
|
||||
{
|
||||
@ -186,7 +190,8 @@ X-API-Key: your-api-key-here
|
||||
"id": "asset-uuid-5",
|
||||
"url": "uploads/product-package/xxx/psd/psd-xxx.psd",
|
||||
"fileName": "源文件1.psd",
|
||||
"fileSize": 52428800
|
||||
"fileSize": 52428800,
|
||||
"sortOrder": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -198,28 +203,29 @@ X-API-Key: your-api-key-here
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(成功 - 其他素材类型)**
|
||||
**响应示例(成功 - 任务-其他)**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"materialId": "material-uuid-1",
|
||||
"taskNo": "TASK202601030185",
|
||||
"productModel": "YYD-002",
|
||||
"categoryName": "天猫旗舰店",
|
||||
"shopName": "天猫旗舰店",
|
||||
"sceneName": "客厅场景",
|
||||
"materialId": "material-task-other-1",
|
||||
"materialType": "任务-其他",
|
||||
"taskNo": "TASK202603310001",
|
||||
"productModel": "TASK202603310001",
|
||||
"categoryName": "通用",
|
||||
"shopName": "通用",
|
||||
"sceneName": "通用",
|
||||
"isFullSet": false,
|
||||
"status": "fetched",
|
||||
"createdAt": "2026-01-03T10:00:00.000Z",
|
||||
"updatedAt": "2026-01-03T10:30:00.000Z",
|
||||
"createdAt": "2026-03-31T08:00:00.000Z",
|
||||
"updatedAt": "2026-03-31T08:05:00.000Z",
|
||||
"files": [
|
||||
{
|
||||
"id": "material-uuid-1",
|
||||
"url": "https://s3.jiansun.vip/...",
|
||||
"id": "material-task-other-1",
|
||||
"url": "https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp",
|
||||
"downloadUrl": "https://sc.jiansun.vip/api/external/download?token=xxx",
|
||||
"fileName": "material.zip",
|
||||
"fileName": "任务其他源文件.zip",
|
||||
"fileSize": 52428800
|
||||
}
|
||||
],
|
||||
@ -235,19 +241,105 @@ X-API-Key: your-api-key-here
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| materialId | string | 素材记录唯一标识,后续推荐作为稳定主键 |
|
||||
| materialType | string | 素材类型;`任务-其他` 等轻量任务素材会显式返回 |
|
||||
| taskNo | string | 任务号 |
|
||||
| productModel | string | SPU型号 |
|
||||
| categoryName | string | 店铺名称 |
|
||||
| productModel | string | 业务型号;`任务-其他` 固定等于 `taskNo` |
|
||||
| categoryName | string | 店铺名称;`任务-其他` 固定返回 `通用` |
|
||||
| shopName | string | 店铺名称,当前与 `categoryName` 相同 |
|
||||
| sceneName | string | 场景类别(可能为空) |
|
||||
| sceneName | string | 场景类别;`任务-其他` 固定返回 `通用` |
|
||||
| isFullSet | boolean | 是否整套;当前按素材类型和资产情况自动判定 |
|
||||
| status | string | 状态:completed/fetched |
|
||||
| assets | object | **仅产品全案类型**:资产分类对象(包含 main、sku、detail、psd) |
|
||||
| files | array | **仅非产品全案类型**:文件列表(统一结构) |
|
||||
| downloadUrl | string | **仅产品全案**:兼容字段,值与 `downloadUrls.all` 相同 |
|
||||
| assets | object | **仅 `任务-详情` 类型**:资产分类对象(包含 `main`、`sku`、`detail`、`psd`) |
|
||||
| files | array | **仅非 `任务-详情` 类型**:文件列表(统一结构) |
|
||||
| downloadUrl | string | **仅 `任务-详情`**:兼容字段,值与 `downloadUrls.all` 相同 |
|
||||
| downloadUrls.all | string | **所有类型**:全部文件下载链接 |
|
||||
|
||||
**资产对象字段说明(产品全案)**
|
||||
### 2. 按产品条码查询 SKU 预览图
|
||||
|
||||
给产品平台按“产品条码”反查 `任务-详情` 里的 SKU 预览图。接口会返回全部历史命中记录,并按最新上传时间排在最前面。
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/external/product-barcode-images?barcode={产品条码}
|
||||
```
|
||||
|
||||
**请求头**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| X-API-Key | string | 是 | API 访问密钥 |
|
||||
|
||||
**请求参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|-----|------|-----|------|
|
||||
| barcode | string | 是 | 产品条码,精确匹配,不会去掉前导零 |
|
||||
|
||||
**完整请求示例**
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
'https://sc.jiansun.vip/api/external/product-barcode-images?barcode=690000000001' \
|
||||
-H 'X-API-Key: your-api-key-here'
|
||||
```
|
||||
|
||||
**响应示例(成功)**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"barcode": "690000000001",
|
||||
"total": 2,
|
||||
"items": [
|
||||
{
|
||||
"materialId": "material-new",
|
||||
"assetId": "asset-new",
|
||||
"taskNo": "TASK202604220001",
|
||||
"productModel": "SPU-NEW",
|
||||
"categoryName": "新店铺",
|
||||
"shopName": "新店铺",
|
||||
"sceneName": "客厅",
|
||||
"skuCode": "SKU-NEW",
|
||||
"barcodes": ["690000000001", "690000000002"],
|
||||
"previewUrl": "https://s3.jiansun.vip/...",
|
||||
"fileName": "new.webp",
|
||||
"sortOrder": 0,
|
||||
"uploadedAt": "2026-04-22T09:00:00.000Z",
|
||||
"updatedAt": "2026-04-22T09:05:00.000Z",
|
||||
"uploaderName": "张三",
|
||||
"isLatest": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----|------|------|
|
||||
| barcode | string | 请求时传入的产品条码 |
|
||||
| total | number | 命中的历史记录总数 |
|
||||
| items[].materialId | string | 素材记录 ID |
|
||||
| items[].assetId | string | SKU 预览图资产 ID |
|
||||
| items[].taskNo | string \| null | 任务号 |
|
||||
| items[].productModel | string | SPU |
|
||||
| items[].categoryName | string | 店铺 |
|
||||
| items[].shopName | string | 店铺,当前与 `categoryName` 相同 |
|
||||
| items[].sceneName | string | 场景 |
|
||||
| items[].skuCode | string \| null | SKU 编号 |
|
||||
| items[].barcodes | string[] | 该张 SKU 图绑定的全部产品条码 |
|
||||
| items[].previewUrl | string | SKU 预览图地址 |
|
||||
| items[].fileName | string | 预览图文件名 |
|
||||
| items[].sortOrder | number | 预览图顺序 |
|
||||
| items[].uploadedAt | string | 该张 SKU 预览图上传时间 |
|
||||
| items[].updatedAt | string | 最近更新时间 |
|
||||
| items[].uploaderName | string | 上传人姓名,没有实名时回退用户名 |
|
||||
| items[].isLatest | boolean | 该条码当前最新生效的图片 |
|
||||
|
||||
**资产对象字段说明(任务-详情)**
|
||||
|
||||
每个资产对象包含以下字段:
|
||||
|
||||
@ -259,7 +351,7 @@ X-API-Key: your-api-key-here
|
||||
| fileSize | number | 文件大小(字节) |
|
||||
| sortOrder | number | 排序序号(主图 / SKU 图 / 详情图 / PSD 均会显式返回) |
|
||||
|
||||
**文件对象字段说明(其他素材类型)**
|
||||
**文件对象字段说明(`任务-其他` 及其它非 `任务-详情` 类型)**
|
||||
|
||||
每个文件对象包含以下字段:
|
||||
|
||||
@ -272,6 +364,24 @@ X-API-Key: your-api-key-here
|
||||
| fileSize | number | 文件大小(字节) |
|
||||
| duration | number | 视频/音频时长(秒,仅视频/音频类型有此字段) |
|
||||
|
||||
**任务平台新增类型:`任务-其他`**
|
||||
|
||||
- 不新开接口,继续使用:`GET /api/external/product-assets?taskNo={taskNo}`
|
||||
- 素材库上传要求只有 3 项:
|
||||
- `taskNo`:必填
|
||||
- `previewImage`:必填
|
||||
- `zipFile`:必填,当前按压缩包处理
|
||||
- 入库时后端会自动补齐:
|
||||
- `productModel = taskNo`
|
||||
- `categoryName = 通用`
|
||||
- `sceneName = 通用`
|
||||
- 对任务平台来说,`任务-其他` 不是 `main / sku / detail` 结构,不要按 `任务-详情` 的方式解析
|
||||
- 正确使用方式:
|
||||
- `files[0].url`:预览图地址
|
||||
- `files[0].downloadUrl`:该条源文件下载地址
|
||||
- `downloadUrls.all`:整条素材的统一下载地址,当前与上面下载地址等价
|
||||
- 当前 `任务-其他` 一条素材只对应 1 张预览图和 1 个压缩包
|
||||
|
||||
**响应示例(失败)**
|
||||
|
||||
```json
|
||||
@ -308,6 +418,7 @@ GET /api/external/download?token={下载令牌}
|
||||
- 打包下载链接会自动下载对应类型的所有文件
|
||||
- 当前下载链接按长期有效设计,代码口径为 `100y`(近似长期有效)
|
||||
- `TOKEN_EXPIRED` 仅会在令牌理论到期或服务端策略调整时触发,日常对接一般无需按短周期重复刷新链接
|
||||
- `任务-其他` 直接使用查询结果里的 `downloadUrls.all` 即可,不需要再拼 `main / sku / detail`
|
||||
|
||||
**完整请求示例**
|
||||
|
||||
@ -612,6 +723,50 @@ AI 生图平台的详细字段说明、完整响应示例、错误码和 cURL
|
||||
|
||||
- `docs/自动上架平台成套入库通知接口.md`
|
||||
|
||||
---
|
||||
|
||||
### 6. 产品平台条码图片推送
|
||||
|
||||
这组能力用于在素材库出现新的“带产品条码的 SKU 预览图”后,主动通知产品平台。
|
||||
|
||||
#### 基本原则
|
||||
|
||||
- 通知由素材库服务端主动 `POST` 到产品平台提供的回调地址
|
||||
- 触发条件是:一次保存里出现带条码的 `sku-preview` 新增或更新
|
||||
- 通知主识别键使用 `barcode`
|
||||
- `materialId`、`assetId`、`skuCode` 作为辅助字段一起返回
|
||||
- 通知中直接附带 `previewUrl`,供产品平台先做图片处理
|
||||
- 需要完整历史时,再调用:
|
||||
- `GET /api/external/product-barcode-images?barcode={barcode}`
|
||||
|
||||
#### 通知事件
|
||||
|
||||
- `eventType`: `task-detail.barcode-images.updated`
|
||||
|
||||
#### payload 重点字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| eventId | 事件唯一 ID |
|
||||
| eventType | 固定为 `task-detail.barcode-images.updated` |
|
||||
| materialId | 素材记录 ID |
|
||||
| taskNo | 任务号,可为空 |
|
||||
| productModel | SPU |
|
||||
| items | 条码图片清单,按“一条码一记录”展开 |
|
||||
| items[].barcode | 产品条码 |
|
||||
| items[].skuCode | SKU 编号 |
|
||||
| items[].previewUrl | SKU 预览图地址 |
|
||||
| items[].uploadedAt | 上传时间 |
|
||||
| items[].uploaderName | 上传人 |
|
||||
| items[].isLatest | 是否该条码当前最新图片 |
|
||||
| items[].lookupUrl | 按条码反查全部历史图片的接口地址 |
|
||||
|
||||
#### 详细文档
|
||||
|
||||
产品平台条码图片推送的详细回调说明、字段解释和对接建议,请以单独文档为准:
|
||||
|
||||
- `docs/产品平台条码图片推送接口.md`
|
||||
|
||||
**本接口补充错误码**
|
||||
|
||||
| 状态码 | code | 说明 |
|
||||
@ -640,8 +795,10 @@ AI 生图平台的详细字段说明、完整响应示例、错误码和 cURL
|
||||
| INVALID_TOKEN | 401 | 下载令牌无效 |
|
||||
| INVALID_TASK_NO | 400 | 任务号格式错误 |
|
||||
| INVALID_QUERY | 400 | 没有传 `taskNo` 或 `materialId` |
|
||||
| INVALID_BARCODE | 400 | `barcode` 为空或只有空白字符 |
|
||||
| INVALID_MATERIAL_ID | 400 | `materialId` 格式错误 |
|
||||
| MATERIAL_NOT_FOUND | 404 | 指定 `materialId` 的素材不存在 |
|
||||
| BARCODE_NOT_FOUND | 404 | 未找到对应的产品条码图片 |
|
||||
| MISSING_PARAM | 400 | 没有传 `sku` 或 `spu` |
|
||||
| RESOURCE_TYPE_INVALID | 400 | `resourceType` 非法 |
|
||||
| SKU_NOT_FOUND | 404 | 未找到对应标准模型 SKU |
|
||||
@ -686,10 +843,11 @@ AI 生图平台的详细字段说明、完整响应示例、错误码和 cURL
|
||||
|
||||
### 打包下载说明
|
||||
|
||||
- `downloadUrls.all` 包含:主图 + SKU图 + 详情图(**不含PSD**)
|
||||
- 当前产品全案接口不再返回 `downloadUrls.psd`
|
||||
- 当前产品全案接口也不再给每个 asset 返回单独 `downloadUrl`
|
||||
- `任务-详情` 的 `downloadUrls.all` 包含:主图 + SKU 图 + 详情图(**不含 PSD**)
|
||||
- 当前 `任务-详情` 接口不再返回 `downloadUrls.psd`
|
||||
- 当前 `任务-详情` 接口也不再给每个 asset 返回单独 `downloadUrl`
|
||||
- ZIP 文件内按文件夹分类:`main/`、`sku/`、`detail/`
|
||||
- `任务-其他` 不参与上述分组逻辑,直接下载上传时的压缩包
|
||||
|
||||
### 调用频率限制
|
||||
|
||||
@ -734,7 +892,7 @@ async function downloadFile(downloadUrl, savePath) {
|
||||
async function main() {
|
||||
const result = await queryAssets('TASK202601030184');
|
||||
if (result.success) {
|
||||
// 下载全部(产品全案口径下与 result.data.downloadUrl 等价)
|
||||
// 下载全部
|
||||
await downloadFile(result.data.downloadUrls.all, './assets.zip');
|
||||
}
|
||||
}
|
||||
@ -800,7 +958,7 @@ if result['success']:
|
||||
|
||||
---
|
||||
|
||||
*文档版本: 1.6*
|
||||
*更新日期: 2026-03-25*
|
||||
*更新内容: 补充 `materialId` 查询口径、自动上架平台通知说明,以及相关错误码与排序字段说明*
|
||||
*文档版本: 1.7*
|
||||
*更新日期: 2026-03-31*
|
||||
*更新内容: 补充 `任务-其他` 对接口径,统一 `任务-详情` / 轻量任务素材的返回说明,并更新任务平台调用指引*
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 自动上架平台成套入库通知接口
|
||||
|
||||
更新时间:2026-03-25
|
||||
更新时间:2026-04-22
|
||||
|
||||
## 目标
|
||||
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
createExternalNotificationTarget,
|
||||
listExternalNotificationDeliveries,
|
||||
listExternalNotificationTargets,
|
||||
queueTaskDetailBarcodeImagesUpdatedNotification,
|
||||
queueTaskDetailFullSetNotification,
|
||||
processExternalNotificationDelivery,
|
||||
TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE,
|
||||
retryExternalNotificationDelivery,
|
||||
updateExternalNotificationTarget,
|
||||
} from '@/lib/external-auto-listing'
|
||||
@ -21,6 +23,9 @@ jest.mock('@/lib/prisma', () => ({
|
||||
material: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
materialAssetBarcode: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@ -40,6 +45,7 @@ describe('external auto listing', () => {
|
||||
$queryRawUnsafe: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
)
|
||||
;(prisma.materialAssetBarcode.findMany as jest.Mock).mockResolvedValue([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -56,7 +62,7 @@ describe('external auto listing', () => {
|
||||
isActive: true,
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE, TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
},
|
||||
@ -69,7 +75,7 @@ describe('external auto listing', () => {
|
||||
isActive: true,
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE, TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
},
|
||||
@ -82,7 +88,7 @@ describe('external auto listing', () => {
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE, TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:30:00.000Z'),
|
||||
},
|
||||
@ -95,7 +101,7 @@ describe('external auto listing', () => {
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE, TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE],
|
||||
createdAt: new Date('2026-03-25T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-03-25T10:30:00.000Z'),
|
||||
},
|
||||
@ -115,7 +121,7 @@ describe('external auto listing', () => {
|
||||
isActive: true,
|
||||
authType: 'bearer',
|
||||
authConfig: { token: 'secret-token' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE, TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE],
|
||||
createdAt: '2026-03-25T10:00:00.000Z',
|
||||
updatedAt: '2026-03-25T10:00:00.000Z',
|
||||
})
|
||||
@ -141,7 +147,7 @@ describe('external auto listing', () => {
|
||||
isActive: false,
|
||||
authType: 'header',
|
||||
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE],
|
||||
eventTypes: [AUTO_LISTING_EVENT_TYPE, TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE],
|
||||
createdAt: '2026-03-25T10:00:00.000Z',
|
||||
updatedAt: '2026-03-25T10:30:00.000Z',
|
||||
})
|
||||
@ -242,7 +248,7 @@ describe('external auto listing', () => {
|
||||
|
||||
const createdIds = await queueTaskDetailFullSetNotification('material-1')
|
||||
const insertCall = (prisma.$queryRawUnsafe as jest.Mock).mock.calls[1]
|
||||
const payload = JSON.parse(insertCall[8])
|
||||
const payload = JSON.parse(insertCall[9])
|
||||
|
||||
expect(createdIds).toEqual(['delivery-1'])
|
||||
expect(payload.pullUrl).toBe(
|
||||
@ -344,4 +350,107 @@ describe('external auto listing', () => {
|
||||
AUTO_LISTING_DELIVERY_STATUS.PENDING,
|
||||
)
|
||||
})
|
||||
|
||||
test('条码图片更新通知应展开成一条码一记录并附带 lookupUrl', async () => {
|
||||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||||
id: 'material-1',
|
||||
taskNo: 'TASK202604220001',
|
||||
productModel: 'XG025503',
|
||||
categoryName: '旗舰店',
|
||||
sceneName: '客厅',
|
||||
materialType: '任务-详情',
|
||||
authorName: '旧作者',
|
||||
createdAt: new Date('2026-04-22T09:00:00.000Z'),
|
||||
user: {
|
||||
realName: '张三',
|
||||
username: 'zhangsan',
|
||||
},
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-sku-1',
|
||||
fileUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
|
||||
fileName: 'sku-1.webp',
|
||||
sortOrder: 0,
|
||||
skuCode: 'SKU-001',
|
||||
createdAt: new Date('2026-04-22T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-22T09:05:00.000Z'),
|
||||
barcodes: [{ barcode: '690000000001' }, { barcode: '690000000002' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
;(prisma.materialAssetBarcode.findMany as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
barcode: '690000000001',
|
||||
assetId: 'asset-sku-1',
|
||||
asset: {
|
||||
createdAt: new Date('2026-04-22T09:00:00.000Z'),
|
||||
sortOrder: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
barcode: '690000000002',
|
||||
assetId: 'asset-sku-1',
|
||||
asset: {
|
||||
createdAt: new Date('2026-04-22T09:00:00.000Z'),
|
||||
sortOrder: 0,
|
||||
},
|
||||
},
|
||||
])
|
||||
;(prisma.$queryRawUnsafe as jest.Mock)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'target-1',
|
||||
name: '自动上架平台',
|
||||
callbackUrl: 'https://auto.example.com/callback',
|
||||
isActive: true,
|
||||
authType: 'none',
|
||||
authConfig: {},
|
||||
eventTypes: [TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE],
|
||||
createdAt: new Date('2026-04-22T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-22T10:00:00.000Z'),
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'delivery-barcode-1' }])
|
||||
|
||||
const createdIds = await queueTaskDetailBarcodeImagesUpdatedNotification('material-1', ['asset-sku-1'])
|
||||
const insertCall = (prisma.$queryRawUnsafe as jest.Mock).mock.calls[1]
|
||||
const payload = JSON.parse(insertCall[9])
|
||||
|
||||
expect(createdIds).toEqual(['delivery-barcode-1'])
|
||||
expect(insertCall[3]).toBe(TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE)
|
||||
expect(typeof insertCall[7]).toBe('string')
|
||||
expect(insertCall[7]).toBe(payload.eventId)
|
||||
expect(payload.items).toEqual([
|
||||
{
|
||||
materialId: 'material-1',
|
||||
assetId: 'asset-sku-1',
|
||||
skuCode: 'SKU-001',
|
||||
barcode: '690000000001',
|
||||
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
|
||||
fileName: 'sku-1.webp',
|
||||
sortOrder: 0,
|
||||
uploadedAt: '2026-04-22T09:00:00.000Z',
|
||||
updatedAt: '2026-04-22T09:05:00.000Z',
|
||||
uploaderName: '张三',
|
||||
isLatest: true,
|
||||
lookupUrl:
|
||||
'https://sc.jiansun.vip/api/external/product-barcode-images?barcode=690000000001',
|
||||
},
|
||||
{
|
||||
materialId: 'material-1',
|
||||
assetId: 'asset-sku-1',
|
||||
skuCode: 'SKU-001',
|
||||
barcode: '690000000002',
|
||||
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/task-detail/material-1/sku/1.webp',
|
||||
fileName: 'sku-1.webp',
|
||||
sortOrder: 0,
|
||||
uploadedAt: '2026-04-22T09:00:00.000Z',
|
||||
updatedAt: '2026-04-22T09:05:00.000Z',
|
||||
uploaderName: '张三',
|
||||
isLatest: true,
|
||||
lookupUrl:
|
||||
'https://sc.jiansun.vip/api/external/product-barcode-images?barcode=690000000002',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
226
lib/__tests__/product-barcode-images-route.test.ts
Normal file
226
lib/__tests__/product-barcode-images-route.test.ts
Normal file
@ -0,0 +1,226 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { GET } from '@/app/api/external/product-barcode-images/route'
|
||||
import { logApiCall, validateApiKey } from '@/lib/external-api'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
materialAssetBarcode: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/url-rewrite', () => ({
|
||||
rewriteStorageUrlForClient: jest.fn((url?: string | null) => (url ? `rewritten:${url}` : null)),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/external-api', () => {
|
||||
const { NextResponse } = require('next/server')
|
||||
return {
|
||||
getApiKeyFromRequest: jest.fn((request: { headers: Headers }) => request.headers.get('X-API-Key')),
|
||||
getClientIp: jest.fn(() => '127.0.0.1'),
|
||||
logApiCall: jest.fn().mockResolvedValue(undefined),
|
||||
successResponse: jest.fn((data: unknown, message?: string) =>
|
||||
NextResponse.json({
|
||||
success: true,
|
||||
...(message ? { message } : {}),
|
||||
data,
|
||||
}),
|
||||
),
|
||||
errorResponse: jest.fn((error: { code: string; message: string; status: number }) =>
|
||||
NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
},
|
||||
{ status: error.status },
|
||||
),
|
||||
),
|
||||
validateApiKey: jest.fn(),
|
||||
API_ERROR: {
|
||||
INVALID_API_KEY: { code: 'INVALID_API_KEY', message: 'API Key 无效或缺失', status: 401 },
|
||||
INVALID_BARCODE: { code: 'INVALID_BARCODE', message: '产品条码不能为空', status: 400 },
|
||||
BARCODE_NOT_FOUND: { code: 'BARCODE_NOT_FOUND', message: '未找到对应的产品条码图片', status: 404 },
|
||||
SERVER_ERROR: { code: 'SERVER_ERROR', message: '服务器内部错误', status: 500 },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('external product-barcode-images route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(validateApiKey as jest.Mock).mockResolvedValue({
|
||||
id: 'api-key-1',
|
||||
name: 'product-platform',
|
||||
isActive: true,
|
||||
})
|
||||
;(prisma.materialAssetBarcode.findMany as jest.Mock).mockResolvedValue([])
|
||||
})
|
||||
|
||||
function createRequest(url: string) {
|
||||
return {
|
||||
nextUrl: new URL(url),
|
||||
headers: new Headers({
|
||||
'X-API-Key': 'test-key',
|
||||
'user-agent': 'jest',
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
test('API Key 无效时应返回 401', async () => {
|
||||
;(validateApiKey as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const response = await GET(
|
||||
createRequest('https://example.com/api/external/product-barcode-images?barcode=690000000001'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(body.error.code).toBe('INVALID_API_KEY')
|
||||
})
|
||||
|
||||
test('barcode 为空时应返回 400', async () => {
|
||||
const response = await GET(
|
||||
createRequest('https://example.com/api/external/product-barcode-images?barcode='),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body.error.code).toBe('INVALID_BARCODE')
|
||||
expect(logApiCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryType: 'barcode',
|
||||
statusCode: 400,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('找不到条码时应返回 404', async () => {
|
||||
const response = await GET(
|
||||
createRequest('https://example.com/api/external/product-barcode-images?barcode=690000000001'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(body.error.code).toBe('BARCODE_NOT_FOUND')
|
||||
})
|
||||
|
||||
test('命中多条历史记录时应按最新优先返回并标记 isLatest', async () => {
|
||||
;(prisma.materialAssetBarcode.findMany as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
assetId: 'asset-old',
|
||||
barcode: '690000000001',
|
||||
asset: {
|
||||
id: 'asset-old',
|
||||
fileUrl: 'https://s3.example/old.webp',
|
||||
fileName: 'old.webp',
|
||||
sortOrder: 1,
|
||||
skuCode: 'SKU-OLD',
|
||||
createdAt: new Date('2026-04-21T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-21T09:05:00.000Z'),
|
||||
barcodes: [{ barcode: '690000000001' }],
|
||||
material: {
|
||||
id: 'material-old',
|
||||
taskNo: 'TASK202604210001',
|
||||
productModel: 'SPU-OLD',
|
||||
categoryName: '老店铺',
|
||||
sceneName: '卧室',
|
||||
authorName: '旧作者',
|
||||
user: {
|
||||
realName: '李四',
|
||||
username: 'lisi',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
assetId: 'asset-new',
|
||||
barcode: '690000000001',
|
||||
asset: {
|
||||
id: 'asset-new',
|
||||
fileUrl: 'https://s3.example/new.webp',
|
||||
fileName: 'new.webp',
|
||||
sortOrder: 0,
|
||||
skuCode: 'SKU-NEW',
|
||||
createdAt: new Date('2026-04-22T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-22T09:05:00.000Z'),
|
||||
barcodes: [{ barcode: '690000000001' }, { barcode: '690000000002' }],
|
||||
material: {
|
||||
id: 'material-new',
|
||||
taskNo: 'TASK202604220001',
|
||||
productModel: 'SPU-NEW',
|
||||
categoryName: '新店铺',
|
||||
sceneName: '客厅',
|
||||
authorName: '新作者',
|
||||
user: {
|
||||
realName: '张三',
|
||||
username: 'zhangsan',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const response = await GET(
|
||||
createRequest('https://example.com/api/external/product-barcode-images?barcode=690000000001'),
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data).toEqual({
|
||||
barcode: '690000000001',
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
materialId: 'material-new',
|
||||
assetId: 'asset-new',
|
||||
taskNo: 'TASK202604220001',
|
||||
productModel: 'SPU-NEW',
|
||||
categoryName: '新店铺',
|
||||
shopName: '新店铺',
|
||||
sceneName: '客厅',
|
||||
skuCode: 'SKU-NEW',
|
||||
barcodes: ['690000000001', '690000000002'],
|
||||
previewUrl: 'rewritten:https://s3.example/new.webp',
|
||||
fileName: 'new.webp',
|
||||
sortOrder: 0,
|
||||
uploadedAt: '2026-04-22T09:00:00.000Z',
|
||||
updatedAt: '2026-04-22T09:05:00.000Z',
|
||||
uploaderName: '张三',
|
||||
isLatest: true,
|
||||
},
|
||||
{
|
||||
materialId: 'material-old',
|
||||
assetId: 'asset-old',
|
||||
taskNo: 'TASK202604210001',
|
||||
productModel: 'SPU-OLD',
|
||||
categoryName: '老店铺',
|
||||
shopName: '老店铺',
|
||||
sceneName: '卧室',
|
||||
skuCode: 'SKU-OLD',
|
||||
barcodes: ['690000000001'],
|
||||
previewUrl: 'rewritten:https://s3.example/old.webp',
|
||||
fileName: 'old.webp',
|
||||
sortOrder: 1,
|
||||
uploadedAt: '2026-04-21T09:00:00.000Z',
|
||||
updatedAt: '2026-04-21T09:05:00.000Z',
|
||||
uploaderName: '李四',
|
||||
isLatest: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(logApiCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryType: 'barcode',
|
||||
queryValue: '690000000001',
|
||||
statusCode: 200,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
import { completeProductPackageUpload } from '@/lib/product-package-completion'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
|
||||
import {
|
||||
queueTaskDetailBarcodeImagesUpdatedNotification,
|
||||
queueTaskDetailFullSetNotification,
|
||||
} from '@/lib/external-auto-listing'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
@ -27,7 +30,20 @@ jest.mock('@/lib/task-material-processor', () => ({
|
||||
.mockResolvedValueOnce({ filePath: 'zip-sku', fileName: 'sku.zip', fileSize: 22 })
|
||||
.mockResolvedValueOnce({ filePath: 'zip-detail', fileName: 'detail.zip', fileSize: 33 }),
|
||||
createZipFromZips: jest.fn().mockResolvedValue({ filePath: 'zip-all', fileName: 'all.zip', fileSize: 66 }),
|
||||
saveProductPackageAssets: jest.fn().mockResolvedValue(undefined),
|
||||
saveProductPackageAssets: jest.fn().mockResolvedValue({
|
||||
createdSkuPreviewAssets: [
|
||||
{
|
||||
id: 'sku-preview-1',
|
||||
fileUrl: 'https://s3.example/sku-preview-1.webp',
|
||||
fileName: 'sku-preview-1.webp',
|
||||
sortOrder: 0,
|
||||
skuCode: 'SKU-001',
|
||||
createdAt: new Date('2026-04-22T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-04-22T09:00:00.000Z'),
|
||||
barcodes: ['690000000001'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/upload-sessions', () => ({
|
||||
@ -48,7 +64,8 @@ jest.mock('@/lib/public-display', () => ({
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/external-auto-listing', () => ({
|
||||
queueTaskDetailFullSetNotification: jest.fn().mockResolvedValue(undefined),
|
||||
queueTaskDetailFullSetNotification: jest.fn().mockResolvedValue([]),
|
||||
queueTaskDetailBarcodeImagesUpdatedNotification: jest.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/image-processor', () => ({
|
||||
@ -107,6 +124,8 @@ describe('product-package-completion', () => {
|
||||
fileName: 'sku.png',
|
||||
fileSize: 10,
|
||||
sortOrder: 0,
|
||||
skuCode: 'SKU-001',
|
||||
barcodes: ['690000000001'],
|
||||
},
|
||||
],
|
||||
detail: [
|
||||
@ -126,6 +145,82 @@ describe('product-package-completion', () => {
|
||||
isFullSet: true,
|
||||
})
|
||||
expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1')
|
||||
expect(queueTaskDetailBarcodeImagesUpdatedNotification).toHaveBeenCalledWith('material-1', [
|
||||
'sku-preview-1',
|
||||
])
|
||||
expect(createMaterialLog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('SKU 图缺少 skuCode 时应直接报错', async () => {
|
||||
await expect(
|
||||
completeProductPackageUpload({
|
||||
materialId: 'material-1',
|
||||
taskNo: null,
|
||||
operatorUserId: 'user-1',
|
||||
assets: {
|
||||
sku: [
|
||||
{
|
||||
fileUrl: 'https://img.example/sku.png',
|
||||
fileName: 'sku.png',
|
||||
fileSize: 10,
|
||||
sortOrder: 0,
|
||||
barcodes: ['690000000001'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('缺少 SKU 编号')
|
||||
})
|
||||
|
||||
test('SKU 图缺少产品条码时应直接报错', async () => {
|
||||
await expect(
|
||||
completeProductPackageUpload({
|
||||
materialId: 'material-1',
|
||||
taskNo: null,
|
||||
operatorUserId: 'user-1',
|
||||
assets: {
|
||||
sku: [
|
||||
{
|
||||
fileUrl: 'https://img.example/sku.png',
|
||||
fileName: 'sku.png',
|
||||
fileSize: 10,
|
||||
sortOrder: 0,
|
||||
skuCode: 'SKU-001',
|
||||
barcodes: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('至少需要 1 个产品条码')
|
||||
})
|
||||
|
||||
test('同一批次重复条码绑定两张 SKU 图时应拒绝', async () => {
|
||||
await expect(
|
||||
completeProductPackageUpload({
|
||||
materialId: 'material-1',
|
||||
taskNo: null,
|
||||
operatorUserId: 'user-1',
|
||||
assets: {
|
||||
sku: [
|
||||
{
|
||||
fileUrl: 'https://img.example/sku-a.png',
|
||||
fileName: 'sku-a.png',
|
||||
fileSize: 10,
|
||||
sortOrder: 0,
|
||||
skuCode: 'SKU-001',
|
||||
barcodes: ['690000000001'],
|
||||
},
|
||||
{
|
||||
fileUrl: 'https://img.example/sku-b.png',
|
||||
fileName: 'sku-b.png',
|
||||
fileSize: 10,
|
||||
sortOrder: 1,
|
||||
skuCode: 'SKU-002',
|
||||
barcodes: ['690000000001'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('不能同时绑定')
|
||||
})
|
||||
})
|
||||
|
||||
@ -43,6 +43,11 @@ export const API_ERROR = {
|
||||
message: '请提供 taskNo 或 materialId',
|
||||
status: 400,
|
||||
},
|
||||
INVALID_BARCODE: {
|
||||
code: 'INVALID_BARCODE',
|
||||
message: '产品条码不能为空',
|
||||
status: 400,
|
||||
},
|
||||
INVALID_MATERIAL_ID: {
|
||||
code: 'INVALID_MATERIAL_ID',
|
||||
message: '素材 ID 格式错误',
|
||||
@ -53,6 +58,11 @@ export const API_ERROR = {
|
||||
message: '素材不存在',
|
||||
status: 404,
|
||||
},
|
||||
BARCODE_NOT_FOUND: {
|
||||
code: 'BARCODE_NOT_FOUND',
|
||||
message: '未找到对应的产品条码图片',
|
||||
status: 404,
|
||||
},
|
||||
SERVER_ERROR: {
|
||||
code: 'SERVER_ERROR',
|
||||
message: '服务器内部错误',
|
||||
@ -108,6 +118,8 @@ export async function logApiCall(data: {
|
||||
endpoint: string;
|
||||
method: string;
|
||||
taskNo?: string;
|
||||
queryType?: string;
|
||||
queryValue?: string;
|
||||
statusCode: number;
|
||||
duration: number;
|
||||
ipAddress?: string;
|
||||
@ -120,6 +132,8 @@ export async function logApiCall(data: {
|
||||
endpoint: data.endpoint,
|
||||
method: data.method,
|
||||
taskNo: data.taskNo,
|
||||
queryType: data.queryType,
|
||||
queryValue: data.queryValue,
|
||||
statusCode: data.statusCode,
|
||||
duration: data.duration,
|
||||
ipAddress: data.ipAddress,
|
||||
|
||||
@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
||||
|
||||
export const AUTO_LISTING_EVENT_TYPE = 'task-detail.full-set.ready'
|
||||
export const TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE = 'task-detail.barcode-images.updated'
|
||||
|
||||
export const AUTO_LISTING_AUTH_TYPE = {
|
||||
NONE: 'none',
|
||||
@ -62,11 +63,17 @@ export interface ExternalNotificationTargetInput {
|
||||
|
||||
const TARGET_TABLE = 'external_notification_targets'
|
||||
const DELIVERY_TABLE = 'external_notification_deliveries'
|
||||
const OLD_DELIVERY_UNIQUE_INDEX = 'external_notification_deliveries_target_event_material_key'
|
||||
const DELIVERY_DEDUPE_UNIQUE_INDEX = 'external_notification_deliveries_target_event_dedupe_key'
|
||||
const DELIVERY_MAX_ATTEMPTS = 5
|
||||
const DELIVERY_RESPONSE_BODY_LIMIT = 2000
|
||||
const RECOVERY_INTERVAL_MS = 60_000
|
||||
const STALE_PROCESSING_MS = 10 * 60 * 1000
|
||||
const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
|
||||
const SUPPORTED_EVENT_TYPES = [
|
||||
AUTO_LISTING_EVENT_TYPE,
|
||||
TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE,
|
||||
] as const
|
||||
|
||||
let isTableInitialized = false
|
||||
let isWorkerStarted = false
|
||||
@ -119,12 +126,111 @@ function normalizeAuthConfig(input: unknown): Record<string, string> {
|
||||
|
||||
function normalizeEventTypes(eventTypes?: string[] | null): string[] {
|
||||
const normalized = Array.isArray(eventTypes)
|
||||
? eventTypes.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
? eventTypes
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter((item): item is (typeof SUPPORTED_EVENT_TYPES)[number] =>
|
||||
Boolean(item) && (SUPPORTED_EVENT_TYPES as readonly string[]).includes(item),
|
||||
)
|
||||
: []
|
||||
|
||||
return normalized.length > 0 ? Array.from(new Set(normalized)) : [AUTO_LISTING_EVENT_TYPE]
|
||||
}
|
||||
|
||||
function buildBarcodeLookupUrl(barcode: string) {
|
||||
return `${getPublicAppOrigin()}/api/external/product-barcode-images?barcode=${encodeURIComponent(barcode)}`
|
||||
}
|
||||
|
||||
function compareBarcodeTimeline(
|
||||
left: { createdAt: Date; sortOrder: number },
|
||||
right: { createdAt: Date; sortOrder: number },
|
||||
) {
|
||||
const createdAtDiff = right.createdAt.getTime() - left.createdAt.getTime()
|
||||
if (createdAtDiff !== 0) {
|
||||
return createdAtDiff
|
||||
}
|
||||
|
||||
return left.sortOrder - right.sortOrder
|
||||
}
|
||||
|
||||
async function listActiveTargetsByEventType(eventType: string) {
|
||||
return queryRaw<any>(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
callback_url AS "callbackUrl",
|
||||
is_active AS "isActive",
|
||||
auth_type AS "authType",
|
||||
auth_config AS "authConfig",
|
||||
event_types AS "eventTypes",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM "${TARGET_TABLE}"
|
||||
WHERE is_active = TRUE
|
||||
AND event_types ? $1
|
||||
ORDER BY created_at ASC
|
||||
`,
|
||||
eventType,
|
||||
)
|
||||
}
|
||||
|
||||
async function enqueueNotificationDeliveries(options: {
|
||||
eventType: string
|
||||
materialId: string
|
||||
materialType: string
|
||||
taskNo?: string | null
|
||||
dedupeKey: string
|
||||
payload: Record<string, unknown>
|
||||
}) {
|
||||
const targets = await listActiveTargetsByEventType(options.eventType)
|
||||
const createdIds: string[] = []
|
||||
|
||||
for (const row of targets) {
|
||||
const deliveryId = randomUUID()
|
||||
const insertedRows = await queryRaw<{ id: string }>(
|
||||
`
|
||||
INSERT INTO "${DELIVERY_TABLE}" (
|
||||
id,
|
||||
target_id,
|
||||
event_type,
|
||||
material_id,
|
||||
material_type,
|
||||
task_no,
|
||||
dedupe_key,
|
||||
status,
|
||||
payload,
|
||||
attempt_count,
|
||||
max_attempts,
|
||||
next_retry_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 0, $10, NOW(), NOW(), NOW())
|
||||
ON CONFLICT ("target_id", "event_type", "dedupe_key") DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
deliveryId,
|
||||
String(row.id),
|
||||
options.eventType,
|
||||
options.materialId,
|
||||
options.materialType,
|
||||
options.taskNo || null,
|
||||
options.dedupeKey,
|
||||
AUTO_LISTING_DELIVERY_STATUS.PENDING,
|
||||
asJson(options.payload, '{}'),
|
||||
DELIVERY_MAX_ATTEMPTS,
|
||||
)
|
||||
|
||||
const inserted = insertedRows[0]
|
||||
if (inserted?.id) {
|
||||
createdIds.push(String(inserted.id))
|
||||
scheduleDeliveryProcessing(String(inserted.id), 0)
|
||||
}
|
||||
}
|
||||
|
||||
return createdIds
|
||||
}
|
||||
|
||||
function normalizeTargetRow(row: any): ExternalNotificationTarget {
|
||||
return {
|
||||
id: String(row.id),
|
||||
@ -465,6 +571,7 @@ export async function ensureExternalNotificationTables() {
|
||||
"material_id" TEXT NOT NULL,
|
||||
"material_type" TEXT NOT NULL,
|
||||
"task_no" TEXT,
|
||||
"dedupe_key" TEXT,
|
||||
"status" TEXT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"attempt_count" INT NOT NULL DEFAULT 0,
|
||||
@ -485,8 +592,20 @@ export async function ensureExternalNotificationTables() {
|
||||
ON "${TARGET_TABLE}" ("name")
|
||||
`)
|
||||
await executeRaw(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "external_notification_deliveries_target_event_material_key"
|
||||
ON "${DELIVERY_TABLE}" ("target_id", "event_type", "material_id")
|
||||
ALTER TABLE "${DELIVERY_TABLE}"
|
||||
ADD COLUMN IF NOT EXISTS "dedupe_key" TEXT
|
||||
`)
|
||||
await executeRaw(`
|
||||
UPDATE "${DELIVERY_TABLE}"
|
||||
SET "dedupe_key" = COALESCE("dedupe_key", "material_id")
|
||||
WHERE "dedupe_key" IS NULL
|
||||
`)
|
||||
await executeRaw(`
|
||||
DROP INDEX IF EXISTS "${OLD_DELIVERY_UNIQUE_INDEX}"
|
||||
`)
|
||||
await executeRaw(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "${DELIVERY_DEDUPE_UNIQUE_INDEX}"
|
||||
ON "${DELIVERY_TABLE}" ("target_id", "event_type", "dedupe_key")
|
||||
`)
|
||||
await executeRaw(`
|
||||
CREATE INDEX IF NOT EXISTS "external_notification_deliveries_status_next_retry_idx"
|
||||
@ -658,6 +777,154 @@ async function buildTaskDetailNotificationPayload(materialId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function buildTaskDetailBarcodeNotificationPayload(materialId: string, assetIds: string[]) {
|
||||
const normalizedAssetIds = Array.from(new Set(assetIds.map((assetId) => String(assetId || '').trim()).filter(Boolean)))
|
||||
if (normalizedAssetIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const material = await prisma.material.findFirst({
|
||||
where: {
|
||||
id: materialId,
|
||||
deletedAt: null,
|
||||
materialType: '任务-详情',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
taskNo: true,
|
||||
productModel: true,
|
||||
categoryName: true,
|
||||
sceneName: true,
|
||||
materialType: true,
|
||||
authorName: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: {
|
||||
realName: true,
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
where: {
|
||||
id: { in: normalizedAssetIds },
|
||||
assetType: 'sku-preview',
|
||||
},
|
||||
orderBy: [
|
||||
{ createdAt: 'desc' },
|
||||
{ sortOrder: 'asc' },
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
fileUrl: true,
|
||||
fileName: true,
|
||||
sortOrder: true,
|
||||
skuCode: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
barcodes: {
|
||||
select: {
|
||||
barcode: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!material || material.assets.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const barcodes = Array.from(
|
||||
new Set(
|
||||
material.assets.flatMap((asset: any) =>
|
||||
(asset.barcodes || []).map((item: any) => String(item.barcode || '').trim()).filter(Boolean),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const latestBarcodeRecords = await prisma.materialAssetBarcode.findMany({
|
||||
where: {
|
||||
barcode: { in: barcodes },
|
||||
asset: {
|
||||
assetType: 'sku-preview',
|
||||
material: {
|
||||
deletedAt: null,
|
||||
materialType: '任务-详情',
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
barcode: true,
|
||||
assetId: true,
|
||||
asset: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
sortOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const latestAssetByBarcode = new Map<string, string>()
|
||||
const latestMetaByBarcode = new Map<string, { createdAt: Date; sortOrder: number }>()
|
||||
|
||||
for (const record of latestBarcodeRecords) {
|
||||
const barcode = String(record.barcode || '').trim()
|
||||
if (!barcode || !record.asset) {
|
||||
continue
|
||||
}
|
||||
|
||||
const nextMeta = {
|
||||
createdAt: record.asset.createdAt,
|
||||
sortOrder: Number(record.asset.sortOrder || 0),
|
||||
}
|
||||
const currentMeta = latestMetaByBarcode.get(barcode)
|
||||
if (!currentMeta || compareBarcodeTimeline(nextMeta, currentMeta) < 0) {
|
||||
latestMetaByBarcode.set(barcode, nextMeta)
|
||||
latestAssetByBarcode.set(barcode, String(record.assetId))
|
||||
}
|
||||
}
|
||||
|
||||
const uploaderName =
|
||||
material.user?.realName || material.user?.username || material.authorName || ''
|
||||
|
||||
const items = material.assets.flatMap((asset: any) => {
|
||||
const assetBarcodes: string[] = Array.from(
|
||||
new Set((asset.barcodes || []).map((item: any) => String(item.barcode || '').trim()).filter(Boolean)),
|
||||
)
|
||||
|
||||
return assetBarcodes.map((barcode) => ({
|
||||
materialId: material.id,
|
||||
assetId: String(asset.id),
|
||||
skuCode: asset.skuCode || null,
|
||||
barcode,
|
||||
previewUrl: String(asset.fileUrl || ''),
|
||||
fileName: String(asset.fileName || ''),
|
||||
sortOrder: Number(asset.sortOrder || 0),
|
||||
uploadedAt: asset.createdAt.toISOString(),
|
||||
updatedAt: asset.updatedAt.toISOString(),
|
||||
uploaderName,
|
||||
isLatest: latestAssetByBarcode.get(barcode) === String(asset.id),
|
||||
lookupUrl: buildBarcodeLookupUrl(barcode),
|
||||
}))
|
||||
})
|
||||
|
||||
return {
|
||||
eventId: randomUUID(),
|
||||
eventType: TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE,
|
||||
materialId: material.id,
|
||||
materialType: material.materialType,
|
||||
taskNo: material.taskNo,
|
||||
productModel: material.productModel,
|
||||
categoryName: material.categoryName,
|
||||
shopName: material.categoryName,
|
||||
sceneName: material.sceneName,
|
||||
occurredAt: new Date().toISOString(),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
export async function queueTaskDetailFullSetNotification(materialId: string) {
|
||||
await ensureExternalNotificationTables()
|
||||
ensureWorkerStarted()
|
||||
@ -667,70 +934,37 @@ export async function queueTaskDetailFullSetNotification(materialId: string) {
|
||||
return []
|
||||
}
|
||||
|
||||
const targets = await queryRaw<any>(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
callback_url AS "callbackUrl",
|
||||
is_active AS "isActive",
|
||||
auth_type AS "authType",
|
||||
auth_config AS "authConfig",
|
||||
event_types AS "eventTypes",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM "${TARGET_TABLE}"
|
||||
WHERE is_active = TRUE
|
||||
AND event_types ? $1
|
||||
ORDER BY created_at ASC
|
||||
`,
|
||||
AUTO_LISTING_EVENT_TYPE,
|
||||
)
|
||||
return enqueueNotificationDeliveries({
|
||||
eventType: AUTO_LISTING_EVENT_TYPE,
|
||||
materialId: payload.materialId,
|
||||
materialType: payload.materialType,
|
||||
taskNo: payload.taskNo,
|
||||
dedupeKey: payload.materialId,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
const createdIds: string[] = []
|
||||
export async function queueTaskDetailBarcodeImagesUpdatedNotification(
|
||||
materialId: string,
|
||||
assetIds: string[],
|
||||
) {
|
||||
await ensureExternalNotificationTables()
|
||||
ensureWorkerStarted()
|
||||
|
||||
for (const row of targets) {
|
||||
const deliveryId = randomUUID()
|
||||
const insertedRows = await queryRaw<{ id: string }>(
|
||||
`
|
||||
INSERT INTO "${DELIVERY_TABLE}" (
|
||||
id,
|
||||
target_id,
|
||||
event_type,
|
||||
material_id,
|
||||
material_type,
|
||||
task_no,
|
||||
status,
|
||||
payload,
|
||||
attempt_count,
|
||||
max_attempts,
|
||||
next_retry_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 0, $9, NOW(), NOW(), NOW())
|
||||
ON CONFLICT ("target_id", "event_type", "material_id") DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
deliveryId,
|
||||
String(row.id),
|
||||
AUTO_LISTING_EVENT_TYPE,
|
||||
payload.materialId,
|
||||
payload.materialType,
|
||||
payload.taskNo,
|
||||
AUTO_LISTING_DELIVERY_STATUS.PENDING,
|
||||
asJson(payload, '{}'),
|
||||
DELIVERY_MAX_ATTEMPTS,
|
||||
)
|
||||
|
||||
const inserted = insertedRows[0]
|
||||
if (inserted?.id) {
|
||||
createdIds.push(String(inserted.id))
|
||||
scheduleDeliveryProcessing(String(inserted.id), 0)
|
||||
}
|
||||
const normalizedAssetIds = Array.from(new Set(assetIds.map((assetId) => String(assetId || '').trim()).filter(Boolean)))
|
||||
const payload = await buildTaskDetailBarcodeNotificationPayload(materialId, normalizedAssetIds)
|
||||
if (!payload || payload.items.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return createdIds
|
||||
return enqueueNotificationDeliveries({
|
||||
eventType: TASK_DETAIL_BARCODE_IMAGES_UPDATED_EVENT_TYPE,
|
||||
materialId: payload.materialId,
|
||||
materialType: payload.materialType,
|
||||
taskNo: payload.taskNo,
|
||||
dedupeKey: payload.eventId,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
export async function retryExternalNotificationDelivery(deliveryId: string) {
|
||||
|
||||
@ -14,7 +14,10 @@ import {
|
||||
buildTaskDetailPublicDisplayPath,
|
||||
uploadPublicDisplayToS3,
|
||||
} from '@/lib/public-display'
|
||||
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
|
||||
import {
|
||||
queueTaskDetailBarcodeImagesUpdatedNotification,
|
||||
queueTaskDetailFullSetNotification,
|
||||
} from '@/lib/external-auto-listing'
|
||||
import path from 'path'
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
|
||||
@ -24,6 +27,8 @@ export interface ProductPackageImageInput {
|
||||
fileName: string
|
||||
fileSize: number
|
||||
sortOrder?: number
|
||||
skuCode?: string
|
||||
barcodes?: string[]
|
||||
}
|
||||
|
||||
export interface ProductPackageSourceInput {
|
||||
@ -45,6 +50,56 @@ export interface CompleteProductPackageUploadInput {
|
||||
}
|
||||
}
|
||||
|
||||
interface GeneratedProductPackagePreviewAsset {
|
||||
fileUrl: string
|
||||
fileName: string
|
||||
sortOrder: number
|
||||
skuCode?: string
|
||||
barcodes?: string[]
|
||||
}
|
||||
|
||||
function normalizeBarcodeValue(barcode: string | null | undefined) {
|
||||
return String(barcode || '').trim()
|
||||
}
|
||||
|
||||
function normalizeSkuPreviewAssets(assets: ProductPackageImageInput[] | undefined) {
|
||||
if (!assets || assets.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const barcodeToAssetName = new Map<string, string>()
|
||||
|
||||
return assets.map((asset, index) => {
|
||||
const skuCode = String(asset.skuCode || '').trim()
|
||||
if (!skuCode) {
|
||||
throw new Error(`第 ${index + 1} 张 SKU 预览图缺少 SKU 编号`)
|
||||
}
|
||||
|
||||
const barcodes = Array.from(
|
||||
new Set((asset.barcodes || []).map((barcode) => normalizeBarcodeValue(barcode)).filter(Boolean)),
|
||||
)
|
||||
|
||||
if (barcodes.length === 0) {
|
||||
throw new Error(`SKU 预览图 ${asset.fileName} 至少需要 1 个产品条码`)
|
||||
}
|
||||
|
||||
for (const barcode of barcodes) {
|
||||
const existingOwner = barcodeToAssetName.get(barcode)
|
||||
if (existingOwner && existingOwner !== asset.fileName) {
|
||||
throw new Error(`产品条码 ${barcode} 不能同时绑定到 ${existingOwner} 和 ${asset.fileName}`)
|
||||
}
|
||||
|
||||
barcodeToAssetName.set(barcode, asset.fileName)
|
||||
}
|
||||
|
||||
return {
|
||||
...asset,
|
||||
skuCode,
|
||||
barcodes,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function downloadToTempFile(prefix: string, asset: ProductPackageImageInput | ProductPackageSourceInput) {
|
||||
if (!asset.fileUrl) {
|
||||
throw new Error(`缺少文件地址: ${asset.fileName}`)
|
||||
@ -123,7 +178,7 @@ async function generatePreviewsFromAssets(
|
||||
return []
|
||||
}
|
||||
|
||||
const previews: string[] = []
|
||||
const previews: GeneratedProductPackagePreviewAsset[] = []
|
||||
const { compressImage, sliceImage } = await import('@/lib/image-processor')
|
||||
const sharp = await import('sharp')
|
||||
|
||||
@ -170,7 +225,17 @@ async function generatePreviewsFromAssets(
|
||||
buildTaskDetailPublicDisplayPath(materialId, type, fileName),
|
||||
'image/webp',
|
||||
)
|
||||
previews.push(previewUrl)
|
||||
previews.push({
|
||||
fileUrl: previewUrl,
|
||||
fileName,
|
||||
sortOrder: previews.length,
|
||||
...(type === 'sku'
|
||||
? {
|
||||
skuCode: asset.skuCode,
|
||||
barcodes: asset.barcodes || [],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
uploadedPreviewUrls.push(previewUrl)
|
||||
}
|
||||
}
|
||||
@ -248,13 +313,20 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
const uploadIds: string[] = []
|
||||
const uploadedPreviewUrls: string[] = []
|
||||
const createdZipPaths: string[] = []
|
||||
const derivedIsFullSet = isTaskDetailFullSetFromUploadGroups(assets)
|
||||
const normalizedAssets = {
|
||||
main: assets.main || [],
|
||||
sku: normalizeSkuPreviewAssets(assets.sku),
|
||||
detail: assets.detail || [],
|
||||
psd: assets.psd || [],
|
||||
}
|
||||
const derivedIsFullSet = isTaskDetailFullSetFromUploadGroups(normalizedAssets)
|
||||
|
||||
if (!materialId || !assets) {
|
||||
throw new Error('缺少必填参数')
|
||||
}
|
||||
|
||||
const totalImages = (assets.main?.length || 0) + (assets.sku?.length || 0) + (assets.detail?.length || 0)
|
||||
const totalImages =
|
||||
normalizedAssets.main.length + normalizedAssets.sku.length + normalizedAssets.detail.length
|
||||
if (totalImages === 0) {
|
||||
throw new Error('请至少上传一张图片')
|
||||
}
|
||||
@ -284,14 +356,14 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
const authorName = material.user?.realName || material.user?.username || '未知作者'
|
||||
const categoryName = material.categoryName || '未知店铺'
|
||||
|
||||
const mainFiles = await collectAssetFiles(assets.main, 'main', tempFiles, uploadIds)
|
||||
const skuFiles = await collectAssetFiles(assets.sku, 'sku', tempFiles, uploadIds)
|
||||
const detailFiles = await collectAssetFiles(assets.detail, 'detail', tempFiles, uploadIds)
|
||||
const mainFiles = await collectAssetFiles(normalizedAssets.main, 'main', tempFiles, uploadIds)
|
||||
const skuFiles = await collectAssetFiles(normalizedAssets.sku, 'sku', tempFiles, uploadIds)
|
||||
const detailFiles = await collectAssetFiles(normalizedAssets.detail, 'detail', tempFiles, uploadIds)
|
||||
|
||||
const previewUrls = {
|
||||
main: await generatePreviewsFromAssets(materialId, assets.main, 'main', uploadedPreviewUrls),
|
||||
sku: await generatePreviewsFromAssets(materialId, assets.sku, 'sku', uploadedPreviewUrls),
|
||||
detail: await generatePreviewsFromAssets(materialId, assets.detail, 'detail', uploadedPreviewUrls),
|
||||
const previewAssets = {
|
||||
main: await generatePreviewsFromAssets(materialId, normalizedAssets.main, 'main', uploadedPreviewUrls),
|
||||
sku: await generatePreviewsFromAssets(materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls),
|
||||
detail: await generatePreviewsFromAssets(materialId, normalizedAssets.detail, 'detail', uploadedPreviewUrls),
|
||||
}
|
||||
|
||||
const zipInfos: {
|
||||
@ -353,12 +425,15 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
)
|
||||
createdZipPaths.push(zipInfos.all.filePath)
|
||||
|
||||
const psdPermanentPaths = await movePsdFilesToPermanentDir(materialId, assets.psd, tempFiles)
|
||||
const psdPermanentPaths = await movePsdFilesToPermanentDir(materialId, normalizedAssets.psd, tempFiles)
|
||||
|
||||
await saveProductPackageAssets(materialId, previewUrls, zipInfos, psdPermanentPaths)
|
||||
const saveResult = await saveProductPackageAssets(materialId, previewAssets, zipInfos, psdPermanentPaths)
|
||||
|
||||
const cardPreviewUrl =
|
||||
previewUrls.main[0] || previewUrls.sku[0] || previewUrls.detail[0] || material.previewUrl
|
||||
previewAssets.main[0]?.fileUrl ||
|
||||
previewAssets.sku[0]?.fileUrl ||
|
||||
previewAssets.detail[0]?.fileUrl ||
|
||||
material.previewUrl
|
||||
|
||||
await assignTaskNoOnComplete(materialId, normalizedTaskNo)
|
||||
await prisma.material.update({
|
||||
@ -394,7 +469,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
main: mainFiles.length,
|
||||
sku: skuFiles.length,
|
||||
detail: detailFiles.length,
|
||||
psd: assets.psd?.length || 0,
|
||||
psd: normalizedAssets.psd.length,
|
||||
},
|
||||
},
|
||||
zipSizes: {
|
||||
@ -413,6 +488,13 @@ export async function completeProductPackageUpload(input: CompleteProductPackage
|
||||
void queueTaskDetailFullSetNotification(materialId)
|
||||
}
|
||||
|
||||
if (saveResult.createdSkuPreviewAssets.some((asset) => asset.barcodes.length > 0)) {
|
||||
void queueTaskDetailBarcodeImagesUpdatedNotification(
|
||||
materialId,
|
||||
saveResult.createdSkuPreviewAssets.map((asset) => asset.id),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
materialId,
|
||||
taskNo: normalizedTaskNo,
|
||||
|
||||
@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { uploadToS3, deleteFromS3 } from '@/lib/s3';
|
||||
import { deleteFromS3 } from '@/lib/s3';
|
||||
import {
|
||||
buildMaterialPublicDisplayPath,
|
||||
uploadPublicDisplayToS3,
|
||||
} from '@/lib/public-display';
|
||||
import { compressImage } from '@/lib/image-processor';
|
||||
import { mkdir, unlink, readFile, rm } from 'fs/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
@ -26,6 +30,25 @@ export const ASSET_TYPE = {
|
||||
PSD: 'psd',
|
||||
} as const;
|
||||
|
||||
export interface ProductPackagePreviewAssetInput {
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
sortOrder?: number;
|
||||
skuCode?: string;
|
||||
barcodes?: string[];
|
||||
}
|
||||
|
||||
export interface SavedProductPackageSkuPreviewAsset {
|
||||
id: string;
|
||||
fileUrl: string;
|
||||
fileName: string | null;
|
||||
sortOrder: number;
|
||||
skuCode: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
barcodes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从chunk临时目录打包文件成zip
|
||||
*/
|
||||
@ -367,9 +390,11 @@ export async function generatePreviewImages(
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 9);
|
||||
const fileName = `preview-${timestamp}-${i}-${randomSuffix}.webp`;
|
||||
const s3Path = `product-package/${fileName}`;
|
||||
|
||||
const previewUrl = await uploadToS3(compressedBuffer, s3Path, 'image/webp');
|
||||
const previewUrl = await uploadPublicDisplayToS3(
|
||||
compressedBuffer,
|
||||
buildMaterialPublicDisplayPath(fileName),
|
||||
'image/webp',
|
||||
);
|
||||
uploadedPreviewUrls.push(previewUrl);
|
||||
|
||||
console.log(`[Preview] Generated preview ${i + 1}/${uploadIds.length}:`, previewUrl);
|
||||
@ -582,10 +607,10 @@ export async function saveAssets(
|
||||
*/
|
||||
export async function saveProductPackageAssets(
|
||||
materialId: string,
|
||||
previewUrls: {
|
||||
main: string[];
|
||||
sku: string[];
|
||||
detail: string[];
|
||||
previewAssets: {
|
||||
main: ProductPackagePreviewAssetInput[];
|
||||
sku: ProductPackagePreviewAssetInput[];
|
||||
detail: ProductPackagePreviewAssetInput[];
|
||||
},
|
||||
zipInfos: {
|
||||
main?: { filePath: string; fileName: string; fileSize: number };
|
||||
@ -594,7 +619,7 @@ export async function saveProductPackageAssets(
|
||||
all: { filePath: string; fileName: string; fileSize: number };
|
||||
},
|
||||
psdInfos?: Array<{ filePath: string; fileName: string; fileSize: number }>
|
||||
): Promise<void> {
|
||||
): Promise<{ createdSkuPreviewAssets: SavedProductPackageSkuPreviewAsset[] }> {
|
||||
const assetRecords: Array<{
|
||||
materialId: string;
|
||||
assetType: string;
|
||||
@ -608,34 +633,34 @@ export async function saveProductPackageAssets(
|
||||
let previewIndex = 0;
|
||||
|
||||
// 主图预览
|
||||
previewUrls.main.forEach((url, index) => {
|
||||
previewAssets.main.forEach((asset, index) => {
|
||||
assetRecords.push({
|
||||
materialId,
|
||||
assetType: ASSET_TYPE.MAIN_PREVIEW,
|
||||
fileUrl: url,
|
||||
fileName: `main-preview-${index}.webp`,
|
||||
fileUrl: asset.fileUrl,
|
||||
fileName: asset.fileName || `main-preview-${index}.webp`,
|
||||
sortOrder: previewIndex++,
|
||||
});
|
||||
});
|
||||
|
||||
// SKU图预览
|
||||
previewUrls.sku.forEach((url, index) => {
|
||||
assetRecords.push({
|
||||
materialId,
|
||||
assetType: ASSET_TYPE.SKU_PREVIEW,
|
||||
fileUrl: url,
|
||||
fileName: `sku-preview-${index}.webp`,
|
||||
sortOrder: previewIndex++,
|
||||
});
|
||||
});
|
||||
const skuPreviewRecords = previewAssets.sku.map((asset, index) => ({
|
||||
materialId,
|
||||
assetType: ASSET_TYPE.SKU_PREVIEW,
|
||||
fileUrl: asset.fileUrl,
|
||||
fileName: asset.fileName || `sku-preview-${index}.webp`,
|
||||
sortOrder: previewIndex++,
|
||||
skuCode: asset.skuCode?.trim() || null,
|
||||
barcodes: Array.from(new Set((asset.barcodes || []).filter(Boolean))),
|
||||
}));
|
||||
|
||||
// 详情图预览
|
||||
previewUrls.detail.forEach((url, index) => {
|
||||
previewAssets.detail.forEach((asset, index) => {
|
||||
assetRecords.push({
|
||||
materialId,
|
||||
assetType: ASSET_TYPE.DETAIL_PREVIEW,
|
||||
fileUrl: url,
|
||||
fileName: `detail-preview-${index}.webp`,
|
||||
fileUrl: asset.fileUrl,
|
||||
fileName: asset.fileName || `detail-preview-${index}.webp`,
|
||||
sortOrder: previewIndex++,
|
||||
});
|
||||
});
|
||||
@ -699,7 +724,7 @@ export async function saveProductPackageAssets(
|
||||
}
|
||||
|
||||
// 使用事务保存
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const createdSkuPreviewAssets = await prisma.$transaction(async (tx) => {
|
||||
// 批量创建资产记录
|
||||
if (assetRecords.length > 0) {
|
||||
await tx.materialAsset.createMany({
|
||||
@ -716,7 +741,47 @@ export async function saveProductPackageAssets(
|
||||
zipFileSize: BigInt(zipInfos.all.fileSize),
|
||||
},
|
||||
});
|
||||
const createdAssets: SavedProductPackageSkuPreviewAsset[] = [];
|
||||
|
||||
for (const skuPreviewRecord of skuPreviewRecords) {
|
||||
const createdAsset = await tx.materialAsset.create({
|
||||
data: {
|
||||
materialId: skuPreviewRecord.materialId,
|
||||
assetType: skuPreviewRecord.assetType,
|
||||
fileUrl: skuPreviewRecord.fileUrl,
|
||||
fileName: skuPreviewRecord.fileName,
|
||||
sortOrder: skuPreviewRecord.sortOrder,
|
||||
skuCode: skuPreviewRecord.skuCode,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
fileUrl: true,
|
||||
fileName: true,
|
||||
sortOrder: true,
|
||||
skuCode: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (skuPreviewRecord.barcodes.length > 0) {
|
||||
await tx.materialAssetBarcode.createMany({
|
||||
data: skuPreviewRecord.barcodes.map((barcode) => ({
|
||||
assetId: createdAsset.id,
|
||||
barcode,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
createdAssets.push({
|
||||
...createdAsset,
|
||||
barcodes: skuPreviewRecord.barcodes,
|
||||
});
|
||||
}
|
||||
|
||||
return createdAssets;
|
||||
});
|
||||
|
||||
console.log(`[Database] Saved ${assetRecords.length} asset records for product package`);
|
||||
console.log(`[Database] Saved ${assetRecords.length + createdSkuPreviewAssets.length} asset records for product package`);
|
||||
return { createdSkuPreviewAssets };
|
||||
}
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
ALTER TABLE "material_assets"
|
||||
ADD COLUMN IF NOT EXISTS "sku_code" TEXT;
|
||||
|
||||
ALTER TABLE "api_call_logs"
|
||||
ADD COLUMN IF NOT EXISTS "query_type" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "query_value" TEXT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "material_asset_barcodes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"asset_id" TEXT NOT NULL,
|
||||
"barcode" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "material_asset_barcodes_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "material_asset_barcodes_asset_id_fkey"
|
||||
FOREIGN KEY ("asset_id") REFERENCES "material_assets"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "material_asset_barcodes_asset_id_barcode_key"
|
||||
ON "material_asset_barcodes" ("asset_id", "barcode");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "material_asset_barcodes_barcode_idx"
|
||||
ON "material_asset_barcodes" ("barcode");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "material_asset_barcodes_asset_id_idx"
|
||||
ON "material_asset_barcodes" ("asset_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "api_call_logs_query_type_idx"
|
||||
ON "api_call_logs" ("query_type");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "api_call_logs_query_value_idx"
|
||||
ON "api_call_logs" ("query_value");
|
||||
@ -347,9 +347,11 @@ model MaterialAsset {
|
||||
fileName String? @map("file_name")
|
||||
fileSize BigInt @default(0) @map("file_size")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
skuCode String? @map("sku_code")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)
|
||||
barcodes MaterialAssetBarcode[]
|
||||
|
||||
@@index([materialId])
|
||||
@@index([assetType])
|
||||
@ -357,6 +359,20 @@ model MaterialAsset {
|
||||
@@map("material_assets")
|
||||
}
|
||||
|
||||
model MaterialAssetBarcode {
|
||||
id String @id @default(uuid())
|
||||
assetId String @map("asset_id")
|
||||
barcode String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
asset MaterialAsset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([assetId, barcode])
|
||||
@@index([barcode])
|
||||
@@index([assetId])
|
||||
@@map("material_asset_barcodes")
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
@ -380,6 +396,8 @@ model ApiCallLog {
|
||||
endpoint String
|
||||
method String
|
||||
taskNo String? @map("task_no")
|
||||
queryType String? @map("query_type")
|
||||
queryValue String? @map("query_value")
|
||||
statusCode Int @map("status_code")
|
||||
duration Int
|
||||
ipAddress String? @map("ip_address")
|
||||
@ -389,6 +407,8 @@ model ApiCallLog {
|
||||
|
||||
@@index([apiKeyId])
|
||||
@@index([taskNo])
|
||||
@@index([queryType])
|
||||
@@index([queryValue])
|
||||
@@index([createdAt])
|
||||
@@map("api_call_logs")
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user