700 lines
20 KiB
TypeScript
700 lines
20 KiB
TypeScript
import { Prisma } from '@prisma/client'
|
|
import { z } from 'zod'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { requiresCategoryAndScene, requiresProductCategoryName } from '@/lib/material-validation'
|
|
import { validateTaskNo } from '@/lib/product-package'
|
|
import { completeProductPackageUpload, type ProductPackageImageInput } from '@/lib/product-package-completion'
|
|
|
|
const IMPORT_ENDPOINT_MATERIAL_TYPE = '任务-详情'
|
|
|
|
export const EXTERNAL_MATERIAL_IMPORT_STATUS = {
|
|
PROCESSING: 'processing',
|
|
SUCCEEDED: 'succeeded',
|
|
FAILED: 'failed',
|
|
} as const
|
|
|
|
type ExternalMaterialImportStatus =
|
|
typeof EXTERNAL_MATERIAL_IMPORT_STATUS[keyof typeof EXTERNAL_MATERIAL_IMPORT_STATUS]
|
|
|
|
type SchemaField = {
|
|
name: string
|
|
label: string
|
|
control: 'text' | 'select'
|
|
required: boolean
|
|
options?: Array<{ label: string; value: string }>
|
|
}
|
|
|
|
type ProductPackageImportAssets = {
|
|
main: ProductPackageImageInput[]
|
|
sku: ProductPackageImageInput[]
|
|
detail: ProductPackageImageInput[]
|
|
}
|
|
|
|
const imageSchema = z.object({
|
|
url: z.string().url('图片地址格式不正确'),
|
|
fileName: z.string().trim().min(1, '文件名不能为空'),
|
|
fileSize: z.number().int().nonnegative('文件大小不能为负数'),
|
|
sortOrder: z.number().int('图片顺序必须是整数'),
|
|
})
|
|
|
|
const sectionSchema = z.object({
|
|
key: z.string().trim().min(1, '图片分组不能为空'),
|
|
images: z.array(imageSchema).default([]),
|
|
})
|
|
|
|
const createJobBodySchema = z.object({
|
|
clientRequestId: z.string().trim().min(1, '请求唯一号不能为空'),
|
|
materialType: z.string().trim().min(1, '素材类型不能为空'),
|
|
requester: z
|
|
.object({
|
|
dingtalkUserId: z.string().trim().optional(),
|
|
dingTalkUserId: z.string().trim().optional(),
|
|
userId: z.string().trim().optional(),
|
|
unionId: z.string().trim().optional(),
|
|
staffId: z.string().trim().optional(),
|
|
email: z.string().trim().optional(),
|
|
username: z.string().trim().optional(),
|
|
})
|
|
.default({}),
|
|
formData: z.object({
|
|
taskNo: z.string().trim().min(1, '任务号不能为空'),
|
|
productModel: z.string().trim().min(1, 'SPU 不能为空'),
|
|
categoryName: z.string().trim().optional().default(''),
|
|
sceneName: z.string().trim().optional().default(''),
|
|
platformName: z.string().trim().optional().default(''),
|
|
productCategoryName: z.string().trim().optional().default(''),
|
|
}),
|
|
sections: z.array(sectionSchema).default([]),
|
|
submissionSnapshot: z.unknown().optional(),
|
|
})
|
|
|
|
export class ExternalMaterialImportError extends Error {
|
|
status: number
|
|
code: string
|
|
|
|
constructor(status: number, code: string, message: string) {
|
|
super(message)
|
|
this.status = status
|
|
this.code = code
|
|
}
|
|
}
|
|
|
|
let isExternalMaterialImportTableInitialized = false
|
|
|
|
async function executeRaw(sql: string) {
|
|
try {
|
|
await prisma.$executeRawUnsafe(sql)
|
|
} catch (error) {
|
|
console.error('[external-material-import] 执行 SQL 失败:', error)
|
|
}
|
|
}
|
|
|
|
export async function ensureExternalMaterialImportJobTable() {
|
|
if (isExternalMaterialImportTableInitialized) {
|
|
return
|
|
}
|
|
|
|
await executeRaw(`
|
|
CREATE TABLE IF NOT EXISTS "external_material_import_jobs" (
|
|
"id" TEXT PRIMARY KEY,
|
|
"api_key_id" TEXT NOT NULL,
|
|
"client_request_id" TEXT NOT NULL,
|
|
"material_type" TEXT NOT NULL,
|
|
"task_no" TEXT NOT NULL,
|
|
"status" TEXT NOT NULL,
|
|
"resolved_user_id" TEXT,
|
|
"request_payload" JSONB NOT NULL,
|
|
"submission_snapshot" JSONB NOT NULL,
|
|
"result_material_id" TEXT,
|
|
"error_code" TEXT,
|
|
"error_message" TEXT,
|
|
"completed_at" TIMESTAMP(3),
|
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`)
|
|
|
|
await executeRaw(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS "external_material_import_jobs_api_key_id_client_request_id_key"
|
|
ON "external_material_import_jobs" ("api_key_id", "client_request_id")
|
|
`)
|
|
await executeRaw(`
|
|
CREATE INDEX IF NOT EXISTS "external_material_import_jobs_task_no_status_idx"
|
|
ON "external_material_import_jobs" ("task_no", "status")
|
|
`)
|
|
await executeRaw(`
|
|
CREATE INDEX IF NOT EXISTS "external_material_import_jobs_status_idx"
|
|
ON "external_material_import_jobs" ("status")
|
|
`)
|
|
await executeRaw(`
|
|
CREATE INDEX IF NOT EXISTS "external_material_import_jobs_created_at_idx"
|
|
ON "external_material_import_jobs" ("created_at")
|
|
`)
|
|
await executeRaw(`
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM pg_constraint
|
|
WHERE conname = 'external_material_import_jobs_api_key_id_fkey'
|
|
) THEN
|
|
ALTER TABLE "external_material_import_jobs"
|
|
ADD CONSTRAINT "external_material_import_jobs_api_key_id_fkey"
|
|
FOREIGN KEY ("api_key_id") REFERENCES "api_keys"("id")
|
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
|
END IF;
|
|
END
|
|
$$;
|
|
`)
|
|
|
|
isExternalMaterialImportTableInitialized = true
|
|
}
|
|
|
|
function normalizeMaterialType(materialType: string) {
|
|
return materialType.trim()
|
|
}
|
|
|
|
function isOpenMaterialType(materialType: string) {
|
|
return normalizeMaterialType(materialType) === IMPORT_ENDPOINT_MATERIAL_TYPE
|
|
}
|
|
|
|
function toOptionRecords(records: Array<{ name: string }>) {
|
|
return records.map((item) => ({
|
|
label: item.name,
|
|
value: item.name,
|
|
}))
|
|
}
|
|
|
|
async function loadSelectableOptions(db: any) {
|
|
const [categories, scenes, platforms, productCategories] = await Promise.all([
|
|
db.category.findMany({
|
|
orderBy: { name: 'asc' },
|
|
select: { name: true },
|
|
}),
|
|
db.scene.findMany({
|
|
orderBy: { name: 'asc' },
|
|
select: { name: true },
|
|
}),
|
|
db.platform.findMany({
|
|
orderBy: { createdAt: 'asc' },
|
|
select: { name: true },
|
|
}),
|
|
db.productCategory.findMany({
|
|
orderBy: { name: 'asc' },
|
|
select: { name: true },
|
|
}),
|
|
])
|
|
|
|
return {
|
|
categories: toOptionRecords(categories),
|
|
scenes: toOptionRecords(scenes),
|
|
platforms: toOptionRecords(platforms),
|
|
productCategories: toOptionRecords(productCategories),
|
|
}
|
|
}
|
|
|
|
export async function getExternalMaterialImportSchema(materialType: string) {
|
|
await ensureExternalMaterialImportJobTable()
|
|
|
|
const normalizedMaterialType = normalizeMaterialType(materialType || '')
|
|
if (!isOpenMaterialType(normalizedMaterialType)) {
|
|
return {
|
|
materialType: normalizedMaterialType || IMPORT_ENDPOINT_MATERIAL_TYPE,
|
|
open: false,
|
|
fields: [] as SchemaField[],
|
|
imageGroups: {
|
|
allowedKeys: [] as string[],
|
|
minTotalImages: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
const options = await loadSelectableOptions(prisma)
|
|
const fields: SchemaField[] = [
|
|
{
|
|
name: 'taskNo',
|
|
label: '任务号',
|
|
control: 'text',
|
|
required: true,
|
|
},
|
|
{
|
|
name: 'productModel',
|
|
label: 'SPU',
|
|
control: 'text',
|
|
required: true,
|
|
},
|
|
{
|
|
name: 'categoryName',
|
|
label: '店铺',
|
|
control: 'select',
|
|
required: requiresCategoryAndScene(IMPORT_ENDPOINT_MATERIAL_TYPE),
|
|
options: options.categories,
|
|
},
|
|
{
|
|
name: 'sceneName',
|
|
label: '场景',
|
|
control: 'select',
|
|
required: requiresCategoryAndScene(IMPORT_ENDPOINT_MATERIAL_TYPE),
|
|
options: options.scenes,
|
|
},
|
|
{
|
|
name: 'platformName',
|
|
label: '平台',
|
|
control: 'select',
|
|
required: false,
|
|
options: options.platforms,
|
|
},
|
|
{
|
|
name: 'productCategoryName',
|
|
label: '品类',
|
|
control: 'select',
|
|
required: requiresProductCategoryName(IMPORT_ENDPOINT_MATERIAL_TYPE),
|
|
options: options.productCategories,
|
|
},
|
|
]
|
|
|
|
return {
|
|
materialType: IMPORT_ENDPOINT_MATERIAL_TYPE,
|
|
open: true,
|
|
fields,
|
|
imageGroups: {
|
|
allowedKeys: ['main', 'sku', 'detail'],
|
|
minTotalImages: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
function normalizeRequesterIdentity(requester: z.infer<typeof createJobBodySchema>['requester']) {
|
|
const dingtalkUserId =
|
|
requester.dingtalkUserId ||
|
|
requester.dingTalkUserId ||
|
|
requester.userId ||
|
|
requester.staffId ||
|
|
''
|
|
|
|
return {
|
|
dingtalkUserId: dingtalkUserId.trim(),
|
|
unionId: requester.unionId?.trim() || '',
|
|
email: requester.email?.trim() || '',
|
|
username: requester.username?.trim() || '',
|
|
}
|
|
}
|
|
|
|
async function resolveImportUser(db: any, requester: z.infer<typeof createJobBodySchema>['requester']) {
|
|
const identity = normalizeRequesterIdentity(requester)
|
|
if (!identity.dingtalkUserId) {
|
|
throw new ExternalMaterialImportError(400, 'USER_NOT_FOUND', '无法匹配素材库用户:缺少钉钉唯一标识')
|
|
}
|
|
|
|
const user = await db.user.findFirst({
|
|
where: {
|
|
dingtalkUserId: identity.dingtalkUserId,
|
|
archivedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
realName: true,
|
|
dingtalkUserId: true,
|
|
},
|
|
})
|
|
|
|
if (!user) {
|
|
throw new ExternalMaterialImportError(400, 'USER_NOT_FOUND', '无法匹配素材库用户,请先在素材库完成钉钉账号映射')
|
|
}
|
|
|
|
return user
|
|
}
|
|
|
|
function assertSelectableValue(
|
|
label: string,
|
|
value: string,
|
|
options: Array<{ label: string; value: string }>,
|
|
required: boolean,
|
|
) {
|
|
const normalized = (value || '').trim()
|
|
if (!normalized) {
|
|
if (required) {
|
|
throw new ExternalMaterialImportError(400, 'FORM_VALUE_INVALID', `${label}不能为空`)
|
|
}
|
|
return
|
|
}
|
|
|
|
const valid = options.some((option) => option.value === normalized)
|
|
if (!valid) {
|
|
throw new ExternalMaterialImportError(400, 'FORM_VALUE_INVALID', `${label}不在当前可选项内,请先在素材库维护`)
|
|
}
|
|
}
|
|
|
|
function parseCreateBody(body: unknown) {
|
|
try {
|
|
return createJobBodySchema.parse(body)
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
throw new ExternalMaterialImportError(400, 'INVALID_REQUEST', error.issues[0]?.message || '请求参数不正确')
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function validateMaterialImportType(materialType: string) {
|
|
const normalizedMaterialType = normalizeMaterialType(materialType)
|
|
if (!isOpenMaterialType(normalizedMaterialType)) {
|
|
throw new ExternalMaterialImportError(400, 'MATERIAL_TYPE_NOT_OPEN', '当前素材类型未开放')
|
|
}
|
|
return normalizedMaterialType
|
|
}
|
|
|
|
function mapSectionKeyToAssetKey(key: string): keyof ProductPackageImportAssets {
|
|
const normalizedKey = key.trim().toLowerCase()
|
|
if (normalizedKey === 'main' || normalizedKey === 'sku' || normalizedKey === 'detail') {
|
|
return normalizedKey
|
|
}
|
|
|
|
throw new ExternalMaterialImportError(400, 'IMAGE_GROUP_INVALID', `不支持的图片分组:${key}`)
|
|
}
|
|
|
|
export function buildProductPackageImportAssets(
|
|
sections: Array<z.infer<typeof sectionSchema>>,
|
|
): ProductPackageImportAssets {
|
|
const assets: ProductPackageImportAssets = {
|
|
main: [],
|
|
sku: [],
|
|
detail: [],
|
|
}
|
|
const seenKeys = new Set<keyof ProductPackageImportAssets>()
|
|
|
|
for (const section of sections) {
|
|
const assetKey = mapSectionKeyToAssetKey(section.key)
|
|
if (seenKeys.has(assetKey)) {
|
|
throw new ExternalMaterialImportError(400, 'IMAGE_GROUP_INVALID', `图片分组重复:${section.key}`)
|
|
}
|
|
seenKeys.add(assetKey)
|
|
const images = [...section.images].sort((left, right) => left.sortOrder - right.sortOrder)
|
|
|
|
assets[assetKey] = images.map((image) => ({
|
|
fileUrl: image.url,
|
|
fileName: image.fileName,
|
|
fileSize: image.fileSize,
|
|
sortOrder: image.sortOrder,
|
|
}))
|
|
}
|
|
|
|
const totalImages = assets.main.length + assets.sku.length + assets.detail.length
|
|
if (totalImages === 0) {
|
|
throw new ExternalMaterialImportError(400, 'IMAGE_GROUP_INVALID', '请至少提交一张图片')
|
|
}
|
|
|
|
return assets
|
|
}
|
|
|
|
async function validateFormData(
|
|
db: any,
|
|
materialType: string,
|
|
formData: z.infer<typeof createJobBodySchema>['formData'],
|
|
) {
|
|
if (!formData.taskNo || !validateTaskNo(formData.taskNo.toUpperCase())) {
|
|
throw new ExternalMaterialImportError(400, 'INVALID_TASK_NO', '任务号格式错误')
|
|
}
|
|
|
|
if (!formData.productModel.trim()) {
|
|
throw new ExternalMaterialImportError(400, 'FORM_VALUE_INVALID', 'SPU 不能为空')
|
|
}
|
|
|
|
const options = await loadSelectableOptions(db)
|
|
|
|
assertSelectableValue(
|
|
'店铺',
|
|
formData.categoryName,
|
|
options.categories,
|
|
requiresCategoryAndScene(materialType),
|
|
)
|
|
assertSelectableValue(
|
|
'场景',
|
|
formData.sceneName,
|
|
options.scenes,
|
|
requiresCategoryAndScene(materialType),
|
|
)
|
|
assertSelectableValue('平台', formData.platformName, options.platforms, false)
|
|
assertSelectableValue(
|
|
'品类',
|
|
formData.productCategoryName,
|
|
options.productCategories,
|
|
requiresProductCategoryName(materialType),
|
|
)
|
|
}
|
|
|
|
async function assertTaskNoAvailable(db: any, taskNo: string) {
|
|
const existingMaterial = await db.material.findFirst({
|
|
where: {
|
|
taskNo,
|
|
deletedAt: null,
|
|
},
|
|
select: { id: true },
|
|
})
|
|
|
|
if (existingMaterial) {
|
|
throw new ExternalMaterialImportError(409, 'TASK_NO_OCCUPIED', '该任务号已有成功素材,不允许重复导入')
|
|
}
|
|
|
|
const blockingJob = await db.externalMaterialImportJob.findFirst({
|
|
where: {
|
|
taskNo,
|
|
status: {
|
|
in: [EXTERNAL_MATERIAL_IMPORT_STATUS.PROCESSING, EXTERNAL_MATERIAL_IMPORT_STATUS.SUCCEEDED],
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
},
|
|
})
|
|
|
|
if (blockingJob) {
|
|
throw new ExternalMaterialImportError(409, 'TASK_NO_OCCUPIED', '该任务号已有进行中或成功的导入任务')
|
|
}
|
|
}
|
|
|
|
function mapJobStatus(job: any) {
|
|
return {
|
|
jobId: job.id,
|
|
materialType: job.materialType,
|
|
taskNo: job.taskNo,
|
|
status: job.status as ExternalMaterialImportStatus,
|
|
success: job.status === EXTERNAL_MATERIAL_IMPORT_STATUS.SUCCEEDED,
|
|
resultMaterialId: job.resultMaterialId || null,
|
|
errorCode: job.errorCode || null,
|
|
errorMessage: job.errorMessage || null,
|
|
completedAt: job.completedAt ? new Date(job.completedAt).toISOString() : null,
|
|
}
|
|
}
|
|
|
|
export async function createExternalMaterialImportJob(options: {
|
|
apiKeyId: string
|
|
body: unknown
|
|
}) {
|
|
await ensureExternalMaterialImportJobTable()
|
|
const parsedBody = parseCreateBody(options.body)
|
|
const materialType = validateMaterialImportType(parsedBody.materialType)
|
|
const normalizedTaskNo = parsedBody.formData.taskNo.trim().toUpperCase()
|
|
const assets = buildProductPackageImportAssets(parsedBody.sections)
|
|
|
|
try {
|
|
const result = await prisma.$transaction(
|
|
async (tx: any) => {
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${normalizedTaskNo}))`
|
|
|
|
const existingJob = await tx.externalMaterialImportJob.findUnique({
|
|
where: {
|
|
apiKeyId_clientRequestId: {
|
|
apiKeyId: options.apiKeyId,
|
|
clientRequestId: parsedBody.clientRequestId,
|
|
},
|
|
},
|
|
})
|
|
|
|
if (existingJob) {
|
|
return {
|
|
created: false,
|
|
job: mapJobStatus(existingJob),
|
|
}
|
|
}
|
|
|
|
const resolvedUser = await resolveImportUser(tx, parsedBody.requester)
|
|
await validateFormData(tx, materialType, {
|
|
...parsedBody.formData,
|
|
taskNo: normalizedTaskNo,
|
|
})
|
|
await assertTaskNoAvailable(tx, normalizedTaskNo)
|
|
|
|
const job = await tx.externalMaterialImportJob.create({
|
|
data: {
|
|
apiKeyId: options.apiKeyId,
|
|
clientRequestId: parsedBody.clientRequestId,
|
|
materialType,
|
|
taskNo: normalizedTaskNo,
|
|
status: EXTERNAL_MATERIAL_IMPORT_STATUS.PROCESSING,
|
|
resolvedUserId: resolvedUser.id,
|
|
requestPayload: {
|
|
...parsedBody,
|
|
materialType,
|
|
formData: {
|
|
...parsedBody.formData,
|
|
taskNo: normalizedTaskNo,
|
|
},
|
|
normalizedAssets: assets,
|
|
},
|
|
submissionSnapshot: parsedBody.submissionSnapshot ?? options.body,
|
|
},
|
|
})
|
|
|
|
return {
|
|
created: true,
|
|
job: mapJobStatus(job),
|
|
}
|
|
},
|
|
{
|
|
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
|
},
|
|
)
|
|
|
|
return result
|
|
} catch (error: any) {
|
|
if (error instanceof ExternalMaterialImportError) {
|
|
throw error
|
|
}
|
|
|
|
if (error?.code === 'P2002') {
|
|
const existingJob = await (prisma as any).externalMaterialImportJob.findFirst({
|
|
where: {
|
|
apiKeyId: options.apiKeyId,
|
|
clientRequestId: parsedBody.clientRequestId,
|
|
},
|
|
})
|
|
if (existingJob) {
|
|
return {
|
|
created: false,
|
|
job: mapJobStatus(existingJob),
|
|
}
|
|
}
|
|
}
|
|
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function getExternalMaterialImportJobStatus(options: {
|
|
apiKeyId: string
|
|
jobId: string
|
|
}) {
|
|
await ensureExternalMaterialImportJobTable()
|
|
|
|
const job = await (prisma as any).externalMaterialImportJob.findFirst({
|
|
where: {
|
|
id: options.jobId,
|
|
apiKeyId: options.apiKeyId,
|
|
},
|
|
})
|
|
|
|
if (!job) {
|
|
throw new ExternalMaterialImportError(404, 'JOB_NOT_FOUND', '导入任务不存在')
|
|
}
|
|
|
|
return mapJobStatus(job)
|
|
}
|
|
|
|
function parseStoredPayload(payload: unknown): z.infer<typeof createJobBodySchema> {
|
|
if (typeof payload === 'string') {
|
|
return parseCreateBody(JSON.parse(payload))
|
|
}
|
|
|
|
return parseCreateBody(payload)
|
|
}
|
|
|
|
async function createImportedProductPackageMaterial(payload: z.infer<typeof createJobBodySchema>, resolvedUserId: string) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: resolvedUserId },
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
realName: true,
|
|
},
|
|
})
|
|
|
|
if (!user) {
|
|
throw new ExternalMaterialImportError(400, 'USER_NOT_FOUND', '素材归属用户不存在')
|
|
}
|
|
|
|
return prisma.material.create({
|
|
data: {
|
|
productModel: payload.formData.productModel.trim(),
|
|
categoryName: payload.formData.categoryName.trim(),
|
|
sceneName: payload.formData.sceneName.trim(),
|
|
platformName: payload.formData.platformName.trim(),
|
|
productCategoryName: payload.formData.productCategoryName.trim(),
|
|
materialType: IMPORT_ENDPOINT_MATERIAL_TYPE,
|
|
previewUrl: '',
|
|
authorName: user.realName || user.username,
|
|
userId: user.id,
|
|
renderer: '',
|
|
isFullSet: false,
|
|
isAiGenerated: true,
|
|
taskDetailRequesterUserId: user.id,
|
|
taskDetailRequesterName: user.realName || user.username,
|
|
} as any,
|
|
})
|
|
}
|
|
|
|
export async function processExternalMaterialImportJob(jobId: string) {
|
|
await ensureExternalMaterialImportJobTable()
|
|
|
|
const job = await (prisma as any).externalMaterialImportJob.findUnique({
|
|
where: { id: jobId },
|
|
})
|
|
|
|
if (!job || job.status !== EXTERNAL_MATERIAL_IMPORT_STATUS.PROCESSING) {
|
|
return null
|
|
}
|
|
|
|
const payload = parseStoredPayload(job.requestPayload)
|
|
const assets = buildProductPackageImportAssets(payload.sections)
|
|
|
|
try {
|
|
const material = await createImportedProductPackageMaterial(payload, job.resolvedUserId)
|
|
|
|
await completeProductPackageUpload({
|
|
materialId: material.id,
|
|
taskNo: payload.formData.taskNo.trim().toUpperCase(),
|
|
operatorUserId: job.resolvedUserId,
|
|
assets,
|
|
})
|
|
|
|
await (prisma as any).externalMaterialImportJob.update({
|
|
where: { id: jobId },
|
|
data: {
|
|
status: EXTERNAL_MATERIAL_IMPORT_STATUS.SUCCEEDED,
|
|
resultMaterialId: material.id,
|
|
errorCode: null,
|
|
errorMessage: null,
|
|
completedAt: new Date(),
|
|
},
|
|
})
|
|
|
|
return {
|
|
jobId,
|
|
materialId: material.id,
|
|
}
|
|
} catch (error) {
|
|
await (prisma as any).externalMaterialImportJob.update({
|
|
where: { id: jobId },
|
|
data: {
|
|
status: EXTERNAL_MATERIAL_IMPORT_STATUS.FAILED,
|
|
errorCode: error instanceof ExternalMaterialImportError ? error.code : 'IMPORT_FAILED',
|
|
errorMessage: error instanceof Error ? error.message : '导入失败',
|
|
completedAt: new Date(),
|
|
},
|
|
})
|
|
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function triggerExternalMaterialImportJob(jobId: string) {
|
|
const run = async () => {
|
|
try {
|
|
await processExternalMaterialImportJob(jobId)
|
|
} catch (error) {
|
|
console.error('[external-material-import] 后台处理失败:', error)
|
|
}
|
|
}
|
|
|
|
if (typeof setImmediate === 'function') {
|
|
setImmediate(() => {
|
|
void run()
|
|
})
|
|
return
|
|
}
|
|
|
|
void run()
|
|
}
|