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['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['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>, ): ProductPackageImportAssets { const assets: ProductPackageImportAssets = { main: [], sku: [], detail: [], } const seenKeys = new Set() 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['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 { if (typeof payload === 'string') { return parseCreateBody(JSON.parse(payload)) } return parseCreateBody(payload) } async function createImportedProductPackageMaterial(payload: z.infer, 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() }