feat: support task detail barcode assets for product platform

This commit is contained in:
chenpei 2026-04-22 15:47:58 +08:00
parent 02ebaabd0e
commit 96fcf312cb
19 changed files with 1854 additions and 168 deletions

2
.gitignore vendored
View File

@ -58,6 +58,8 @@ shadow_*.js
# 数据库和部署脚本(本地使用)
*.sql
!prisma/migrations/
!prisma/migrations/**/*.sql
check-*.ps1
deploy-*.ps1
sync-*.ps1

View 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)
}
}

View File

@ -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',
})),
};

View File

@ -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;

View File

@ -19,6 +19,8 @@ interface AssetUpload {
fileName: string
fileSize: number
sortOrder?: number
skuCode?: string
barcodes?: string[]
}
interface PsdAsset {

View File

@ -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">

View File

@ -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

View 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 校验,请在素材库后台配置对应鉴权方式

View File

@ -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*
*更新内容: 补充 `任务-其他` 对接口径,统一 `任务-详情` / 轻量任务素材的返回说明,并更新任务平台调用指引*

View File

@ -1,6 +1,6 @@
# 自动上架平台成套入库通知接口
更新时间:2026-03-25
更新时间:2026-04-22
## 目标

View File

@ -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',
},
])
})
})

View 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,
}),
)
})
})

View File

@ -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('不能同时绑定')
})
})

View File

@ -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,

View File

@ -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) {

View File

@ -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,

View File

@ -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 };
}

View File

@ -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");

View File

@ -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")
}