2026-05-25 16:48:19 +08:00

779 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
import { canUseCustomTemplates } from '@/lib/license'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
const selectTemplate = {
id: true,
name: true,
description: true,
status: true,
materialType: true,
materialKind: true,
version: true,
fieldGroups: true,
displayConfig: true,
createdAt: true,
updatedAt: true,
fields: {
orderBy: { orderIndex: 'asc' },
select: {
id: true,
key: true,
label: true,
type: true,
required: true,
orderIndex: true,
layoutWidth: true,
validations: true,
optionsSource: true,
optionsStatic: true,
allowedExtensions: true,
visibleOnHomeFilter: true,
isSearchable: true,
isReadOnly: true,
addToCategory: true,
conditions: true,
createdAt: true,
updatedAt: true,
},
},
} as const
async function ensureAdmin() {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return null
}
return session
}
type TemplateStructureChange =
| {
type: 'materialTypeChanged'
before: string
after: string
}
| {
type: 'materialKindChanged'
before: string
after: string
}
| {
type: 'fieldDeleted'
fieldId: string
fieldKey: string
fieldLabel: string
}
| {
type: 'fieldKeyChanged'
fieldId: string
before: string
after: string
fieldLabel: string
}
| {
type: 'fieldTypeChanged'
fieldId: string
fieldKey: string
fieldLabel: string
before: string
after: string
}
function detectDangerousTemplateChanges(args: {
existingTemplate: {
materialType: string
materialKind: string
fields: Array<{ id: string; key: string; type: string; label: string }>
}
nextMaterialType?: string
nextMaterialKind?: string
nextFields?: Array<{ id?: string | null; key: string; type: string; label: string }>
}): TemplateStructureChange[] {
const { existingTemplate, nextMaterialType, nextMaterialKind, nextFields } = args
const changes: TemplateStructureChange[] = []
if (nextMaterialType && nextMaterialType !== existingTemplate.materialType) {
changes.push({
type: 'materialTypeChanged',
before: existingTemplate.materialType,
after: nextMaterialType,
})
}
if (nextMaterialKind && nextMaterialKind !== existingTemplate.materialKind) {
changes.push({
type: 'materialKindChanged',
before: existingTemplate.materialKind,
after: nextMaterialKind,
})
}
if (!nextFields) {
return changes
}
const incomingById = new Map(
nextFields
.filter((field) => field.id)
.map((field) => [field.id as string, field])
)
for (const existingField of existingTemplate.fields) {
const incomingField = incomingById.get(existingField.id)
if (!incomingField) {
changes.push({
type: 'fieldDeleted',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
})
continue
}
if (incomingField.key !== existingField.key) {
changes.push({
type: 'fieldKeyChanged',
fieldId: existingField.id,
before: existingField.key,
after: incomingField.key,
fieldLabel: existingField.label,
})
}
if (incomingField.type !== existingField.type) {
changes.push({
type: 'fieldTypeChanged',
fieldId: existingField.id,
fieldKey: existingField.key,
fieldLabel: existingField.label,
before: existingField.type,
after: incomingField.type,
})
}
}
return changes
}
export async function GET(_: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await ensureAdmin()
if (!session) {
console.warn('[TemplateGet] Unauthorized access attempt')
return NextResponse.json({
error: '无权限',
errorCode: 'UNAUTHORIZED',
}, { status: 403 })
}
const { id } = params
console.log('[TemplateGet] Fetching template:', id)
const template = await prisma.uploadTemplate.findUnique({
where: { id },
select: {
...selectTemplate,
_count: {
select: { materials: true }
}
},
})
if (!template) {
console.warn('[TemplateGet] Template not found:', id)
return NextResponse.json({
error: '模板不存在',
errorCode: 'NOT_FOUND',
}, { status: 404 })
}
// 获取使用该模板的素材列表(最多10个,用于预览)
const materials = await prisma.material.findMany({
where: { templateId: id },
take: 10,
orderBy: { createdAt: 'desc' },
select: {
id: true,
productModel: true,
previewUrl: true,
createdAt: true,
},
})
// 统计字段填写率(如果有素材使用该模板)
let fieldStats = null
if (template._count.materials > 0) {
fieldStats = await calculateFieldStats(id, template.fields)
}
const result = {
...template,
materialCount: template._count.materials,
materials,
fieldStats,
_count: undefined,
}
console.log('[TemplateGet] Template fetched:', {
templateId: id,
templateName: template.name,
materialCount: template._count.materials,
})
return NextResponse.json(result)
} catch (error: any) {
console.error('[TemplateGet] Get error:', {
templateId: params.id,
error: error?.message,
stack: error?.stack,
})
const errorMessage = error?.message || '获取模板失败'
return NextResponse.json({
error: errorMessage,
errorCode: 'GET_FAILED',
}, { status: 500 })
}
}
/**
* 计算字段填写率统计
*/
async function calculateFieldStats(templateId: string, fields: any[]) {
try {
// 获取所有使用该模板的素材
const materials = await prisma.material.findMany({
where: { templateId },
select: {
id: true,
metadata: true,
},
})
if (materials.length === 0) {
return null
}
const totalMaterials = materials.length
const fieldStats = fields.map(field => {
let filledCount = 0
// 统计该字段在多少素材中被填写
for (const material of materials) {
if (!material.metadata) continue
try {
const metadata = JSON.parse(material.metadata)
const templateFields = metadata.templateFields || {}
const value = templateFields[field.key]
// 判断字段是否被填写(非空、非null、非undefined)
if (value !== null && value !== undefined && value !== '') {
filledCount++
}
} catch (error) {
// 忽略JSON解析错误
}
}
const fillRate = (filledCount / totalMaterials) * 100
return {
fieldKey: field.key,
fieldLabel: field.label,
fieldType: field.type,
filledCount,
totalCount: totalMaterials,
fillRate: Math.round(fillRate * 100) / 100, // 保留2位小数
isLowFillRate: fillRate < 30, // 填写率低于30%标记为低填写率
}
})
// 按填写率排序
fieldStats.sort((a, b) => a.fillRate - b.fillRate)
return {
totalMaterials,
fields: fieldStats,
lowFillRateFields: fieldStats.filter(f => f.isLowFillRate),
}
} catch (error) {
console.error('[TemplateGet] Error calculating field stats:', error)
return null
}
}
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await ensureAdmin()
if (!session) return NextResponse.json({ error: '无权限' }, { status: 403 })
if (!(await isCurrentSuperAdmin(session))) {
return NextResponse.json({ error: '仅最高权限账号可更新上传模板' }, { status: 403 })
}
if (!(await canUseCustomTemplates())) {
return NextResponse.json({
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
errorCode: 'LICENSE_REQUIRED',
}, { status: 403 })
}
const { id } = params
const body = await req.json()
const {
name,
description,
status,
materialType,
materialKind,
fieldGroups,
displayConfig,
fields,
version,
} = body || {}
const existing = await prisma.uploadTemplate.findUnique({
where: { id },
select: {
id: true,
version: true,
name: true,
updatedAt: true,
materialType: true,
materialKind: true,
fields: {
select: {
id: true,
key: true,
type: true,
label: true,
},
orderBy: { orderIndex: 'asc' },
},
_count: {
select: {
materials: true,
},
},
},
})
if (!existing) {
console.warn(`[TemplateUpdate] Template not found: ${id}`)
return NextResponse.json({ error: '模板不存在' }, { status: 404 })
}
// 版本冲突检测
if (version !== undefined && version !== existing.version) {
console.warn('[TemplateUpdate] Version conflict detected:', {
templateId: id,
templateName: existing.name,
clientVersion: version,
serverVersion: existing.version,
lastUpdated: existing.updatedAt,
})
return NextResponse.json({
error: '模板已被其他人修改,请刷新后重试',
errorCode: 'VERSION_CONFLICT',
details: {
templateName: existing.name,
clientVersion: version,
serverVersion: existing.version,
lastUpdated: existing.updatedAt,
}
}, { status: 409 })
}
console.log('[TemplateUpdate] Version check passed:', {
templateId: id,
templateName: existing.name,
currentVersion: existing.version,
newVersion: (existing.version || 1) + 1,
})
// 验证必填字段
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
console.warn('[TemplateUpdate] Validation failed: invalid name')
return NextResponse.json({
error: '模板名称不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'name',
}, { status: 400 })
}
// 验证materialType
if (materialType !== undefined && (typeof materialType !== 'string' || !materialType.trim())) {
console.warn('[TemplateUpdate] Validation failed: invalid materialType')
return NextResponse.json({
error: '素材类型不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'materialType',
}, { status: 400 })
}
// 验证materialKind
if (materialKind !== undefined) {
const allowedKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] as const
const rawKind = typeof materialKind === 'string' ? materialKind.trim().toUpperCase() : ''
if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) {
console.warn('[TemplateUpdate] Validation failed: invalid materialKind:', materialKind)
return NextResponse.json({
error: `素材大类必须是以下之一:${allowedKinds.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'materialKind',
}, { status: 400 })
}
}
const updateData: any = {}
if (name !== undefined) updateData.name = name.trim()
if (description !== undefined) updateData.description = description
if (status !== undefined) updateData.status = status
if (fieldGroups !== undefined) updateData.fieldGroups = fieldGroups
if (displayConfig !== undefined) updateData.displayConfig = displayConfig
if (materialType !== undefined) {
updateData.materialType = materialType.trim()
}
if (materialKind !== undefined) {
const rawKind = materialKind.trim().toUpperCase()
updateData.materialKind = rawKind
}
updateData.version = (existing.version || 1) + 1
let formattedFields = null
if (Array.isArray(fields)) {
// 检查字段key的唯一性
const fieldKeys = new Set<string>()
const duplicateKeys: string[] = []
for (const field of fields) {
const key = String(field.key || '').trim()
if (!key) {
console.warn('[TemplateUpdate] Validation failed: field key is required')
return NextResponse.json({
error: '字段key不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'fields',
}, { status: 400 })
}
if (fieldKeys.has(key)) {
duplicateKeys.push(key)
} else {
fieldKeys.add(key)
}
}
if (duplicateKeys.length > 0) {
console.warn('[TemplateUpdate] Validation failed: duplicate field keys:', duplicateKeys)
return NextResponse.json({
error: `字段key重复:${duplicateKeys.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'fields',
details: { duplicateKeys },
}, { status: 400 })
}
formattedFields = fields.map((field: any, index: number) => {
const key = String(field.key || '').trim()
const label = String(field.label || '').trim() || `字段${index + 1}`
const type = String(field.type || 'text')
// 自动转换optionsStatic数组为JSON字符串
let optionsStatic = field.optionsStatic ?? null
if (optionsStatic !== null && optionsStatic !== undefined) {
if (Array.isArray(optionsStatic)) {
optionsStatic = JSON.stringify(optionsStatic)
console.log(`[TemplateUpdate] Converted optionsStatic array to JSON for field: ${key}`)
} else {
optionsStatic = String(optionsStatic)
}
}
return {
id: field.id ?? null,
key,
label,
type,
required: !!field.required,
orderIndex: field.orderIndex ?? index,
layoutWidth: field.layoutWidth || 'full',
validations: field.validations ?? null,
optionsSource: field.optionsSource ?? null,
optionsStatic,
allowedExtensions: field.allowedExtensions ?? null,
visibleOnHomeFilter: !!field.visibleOnHomeFilter,
isSearchable: !!field.isSearchable,
isReadOnly: !!field.isReadOnly,
addToCategory: !!field.addToCategory,
conditions: field.conditions ?? null,
}
})
}
if (existing._count.materials > 0) {
const dangerousChanges = detectDangerousTemplateChanges({
existingTemplate: {
materialType: existing.materialType,
materialKind: existing.materialKind,
fields: existing.fields,
},
nextMaterialType: updateData.materialType,
nextMaterialKind: updateData.materialKind,
nextFields: formattedFields || undefined,
})
if (dangerousChanges.length > 0) {
console.warn('[TemplateUpdate] Dangerous structure change blocked:', {
templateId: id,
templateName: existing.name,
materialCount: existing._count.materials,
dangerousChanges,
})
return NextResponse.json({
error: `模板“${existing.name}”已有 ${existing._count.materials} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key 或修改字段类型。请新建模板后迁移素材。`,
errorCode: 'TEMPLATE_STRUCTURE_LOCKED',
details: {
templateName: existing.name,
materialCount: existing._count.materials,
dangerousChanges,
},
}, { status: 409 })
}
}
const result = await prisma.$transaction(async (tx) => {
console.log('[TemplateUpdate] Updating template:', {
templateId: id,
templateName: existing.name,
oldVersion: existing.version,
newVersion: updateData.version,
fieldsCount: formattedFields?.length || 0,
})
const updated = await tx.uploadTemplate.update({
where: { id },
data: updateData,
select: { id: true, version: true },
})
console.log('[TemplateUpdate] Template updated successfully:', {
templateId: updated.id,
newVersion: updated.version,
})
if (formattedFields) {
const incomingIds = formattedFields.map((f) => f.id).filter(Boolean) as string[]
await tx.uploadField.deleteMany({
where: {
templateId: id,
id: { notIn: incomingIds.length ? incomingIds : [''] },
},
})
for (const field of formattedFields) {
if (field.id) {
await tx.uploadField.update({
where: { id: field.id },
data: {
key: field.key,
label: field.label,
type: field.type,
required: field.required,
orderIndex: field.orderIndex,
layoutWidth: field.layoutWidth,
validations: field.validations,
optionsSource: field.optionsSource,
optionsStatic: field.optionsStatic,
allowedExtensions: field.allowedExtensions,
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: field.addToCategory,
conditions: field.conditions,
},
})
} else {
await tx.uploadField.create({
data: {
templateId: id,
key: field.key,
label: field.label,
type: field.type,
required: field.required,
orderIndex: field.orderIndex,
layoutWidth: field.layoutWidth,
validations: field.validations,
optionsSource: field.optionsSource,
optionsStatic: field.optionsStatic,
allowedExtensions: field.allowedExtensions,
visibleOnHomeFilter: field.visibleOnHomeFilter,
isSearchable: field.isSearchable,
isReadOnly: field.isReadOnly,
addToCategory: field.addToCategory,
conditions: field.conditions,
},
})
}
}
}
return updated
})
const template = await prisma.uploadTemplate.findUnique({
where: { id: result.id },
select: selectTemplate,
})
console.log('[TemplateUpdate] Syncing field pool after template update...')
await syncFieldPoolFromTemplates()
console.log('[TemplateUpdate] Update completed:', {
templateId: template?.id,
templateName: template?.name,
version: template?.version,
})
return NextResponse.json(template)
} catch (error: any) {
console.error('[TemplateUpdate] Update error:', {
templateId: params.id,
error: error?.message,
code: error?.code,
meta: error?.meta,
stack: error?.stack,
})
// 处理Prisma特定错误
if (error?.code) {
if (error.code === 'P2002') {
return NextResponse.json({
error: '模板名称已存在,请使用其他名称',
errorCode: 'DUPLICATE_NAME',
}, { status: 400 })
}
if (error.code === 'P2003') {
return NextResponse.json({
error: '关联数据不存在',
errorCode: 'FOREIGN_KEY_CONSTRAINT',
}, { status: 400 })
}
if (error.code === 'P2025') {
return NextResponse.json({
error: '模板不存在或已被删除',
errorCode: 'NOT_FOUND',
}, { status: 404 })
}
}
const errorMessage = error?.message || '更新模板失败'
return NextResponse.json({
error: errorMessage,
errorCode: 'UPDATE_FAILED',
}, { status: 500 })
}
}
export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) {
try {
const session = await ensureAdmin()
if (!session) return NextResponse.json({ error: '无权限' }, { status: 403 })
if (!(await isCurrentSuperAdmin(session))) {
return NextResponse.json({ error: '仅最高权限账号可删除上传模板' }, { status: 403 })
}
if (!(await canUseCustomTemplates())) {
return NextResponse.json({
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
errorCode: 'LICENSE_REQUIRED',
}, { status: 403 })
}
const { id } = params
// 检查模板是否存在
const template = await prisma.uploadTemplate.findUnique({
where: { id },
select: {
id: true,
name: true,
_count: {
select: { materials: true }
}
},
})
if (!template) {
console.warn(`[TemplateDelete] Template not found: ${id}`)
return NextResponse.json({ error: '模板不存在' }, { status: 404 })
}
// 检查是否有素材使用该模板
const materialCount = template._count.materials
if (materialCount > 0) {
console.warn('[TemplateDelete] Cannot delete template with materials:', {
templateId: id,
templateName: template.name,
materialCount,
})
return NextResponse.json({
error: `无法删除模板"${template.name}",因为有 ${materialCount} 个素材正在使用该模板。请先删除或迁移这些素材。`,
errorCode: 'TEMPLATE_IN_USE',
details: {
templateName: template.name,
materialCount,
}
}, { status: 400 })
}
console.log('[TemplateDelete] Deleting template:', {
templateId: id,
templateName: template.name,
})
await prisma.uploadTemplate.delete({ where: { id } })
console.log('[TemplateDelete] Syncing field pool after template deletion...')
await syncFieldPoolFromTemplates()
console.log('[TemplateDelete] Template deleted successfully:', {
templateId: id,
templateName: template.name,
})
return NextResponse.json({ success: true })
} catch (error: any) {
console.error('[TemplateDelete] Delete error:', {
templateId: params.id,
error: error?.message,
code: error?.code,
stack: error?.stack,
})
// 处理Prisma特定错误
if (error?.code === 'P2025') {
return NextResponse.json({
error: '模板不存在或已被删除',
errorCode: 'NOT_FOUND',
}, { status: 404 })
}
const errorMessage = error?.message || '删除模板失败'
return NextResponse.json({
error: errorMessage,
errorCode: 'DELETE_FAILED',
}, { status: 500 })
}
}