gongxiangp2/lib/external-material-import.ts
2026-07-24 14:47:27 +08:00

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