WuYongJie 40cf6f6c26 feat: 完成阶段1和阶段2优化任务
阶段1: 核心基础设施优化
- 完善模板验证工具(template-validator.ts)
- 优化上传API的模板验证
- 增强字段池同步机制(支持增量同步和重试)
- 优化选项清洗逻辑(处理10种脏数据场景)

阶段2: 模板管理功能增强
- 实现模板版本控制(并发冲突检测)
- 增强模板数据完整性验证(必填字段、字段key唯一性)
- 优化模板CRUD API错误处理(统一格式、Prisma错误处理)
- 实现模板使用统计(materialCount、字段填写率分析)

详细改进:
- lib/field-pool.ts: 新增OptionCleaningIssue枚举、增强normalizeFieldOptionValue函数、支持增量同步
- app/api/admin/upload-templates/route.ts: 增强验证逻辑、优化错误处理、添加materialCount统计
- app/api/admin/upload-templates/[id]/route.ts: 版本控制、字段统计、删除保护、详细日志
- .kiro/specs/template-system-optimization/tasks.md: 更新任务完成状态(8/30完成)
2025-12-13 15:07:11 +08:00

286 lines
8.6 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 { Prisma } from '@prisma/client'
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
const baseSelect = {
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
export async function GET() {
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
console.warn('[TemplateList] Unauthorized access attempt')
return NextResponse.json({
error: '无权限',
errorCode: 'UNAUTHORIZED',
}, { status: 403 })
}
console.log('[TemplateList] Fetching templates...')
const templates = await prisma.uploadTemplate.findMany({
orderBy: { updatedAt: 'desc' },
select: {
...baseSelect,
_count: {
select: { materials: true }
}
},
})
// 转换数据,将_count.materials映射为materialCount
const templatesWithCount = templates.map(template => ({
...template,
materialCount: template._count.materials,
_count: undefined, // 移除_count字段
}))
console.log(`[TemplateList] Found ${templates.length} templates`)
return NextResponse.json(templatesWithCount)
} catch (error: any) {
console.error('[TemplateList] List error:', {
error: error?.message,
code: error?.code,
stack: error?.stack,
})
const errorMessage = error?.message || '获取上传模板失败'
return NextResponse.json({
error: errorMessage,
errorCode: 'LIST_FAILED',
}, { status: 500 })
}
}
export async function POST(req: NextRequest) {
let body: any = null
try {
const session = await getServerSession(authOptions)
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
body = await req.json()
const {
name,
description,
status = 'draft',
materialType,
materialKind,
fieldGroups,
displayConfig,
fields = [],
} = body || {}
// 验证必填字段
if (!name || typeof name !== 'string' || !name.trim()) {
console.warn('[TemplateCreate] Validation failed: name is required')
return NextResponse.json({
error: '模板名称不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'name',
}, { status: 400 })
}
// 验证materialType
if (!materialType || typeof materialType !== 'string' || !materialType.trim()) {
console.warn('[TemplateCreate] Validation failed: materialType is required')
return NextResponse.json({
error: '素材类型不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'materialType',
}, { status: 400 })
}
// 验证materialKind
const allowedKinds = ['IMAGE', 'VIDEO', 'OTHER'] as const
const rawKind = typeof materialKind === 'string' && materialKind.trim() ? materialKind.trim().toUpperCase() : ''
if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) {
console.warn('[TemplateCreate] Validation failed: invalid materialKind:', materialKind)
return NextResponse.json({
error: `素材大类必须是以下之一:${allowedKinds.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'materialKind',
}, { status: 400 })
}
const finalMaterialType = materialType.trim()
const finalMaterialKind = rawKind
// 验证字段数组
if (!Array.isArray(fields)) {
console.warn('[TemplateCreate] Validation failed: fields must be an array')
return NextResponse.json({
error: '字段列表必须是数组',
errorCode: 'VALIDATION_ERROR',
field: 'fields',
}, { status: 400 })
}
// 检查字段key的唯一性
const fieldKeys = new Set<string>()
const duplicateKeys: string[] = []
for (const field of fields) {
const key = String(field.key || '').trim()
if (!key) {
console.warn('[TemplateCreate] 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('[TemplateCreate] Validation failed: duplicate field keys:', duplicateKeys)
return NextResponse.json({
error: `字段key重复:${duplicateKeys.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'fields',
details: { duplicateKeys },
}, { status: 400 })
}
const 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(`[TemplateCreate] Converted optionsStatic array to JSON for field: ${key}`)
} else {
optionsStatic = String(optionsStatic)
}
}
return {
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,
}
})
const createData = {
name,
description: description ?? null,
status,
materialType: finalMaterialType,
materialKind: finalMaterialKind,
fieldGroups: fieldGroups ?? null,
displayConfig: displayConfig ?? null,
fields: formattedFields.length
? { createMany: { data: formattedFields } }
: undefined,
} as Prisma.UploadTemplateCreateInput
const template = await prisma.uploadTemplate.create({
data: createData,
select: baseSelect,
})
await syncFieldPoolFromTemplates()
return NextResponse.json(template, { status: 201 })
} catch (error: any) {
console.error('[TemplateCreate] Create error:', {
error: error?.message,
code: error?.code,
meta: error?.meta,
stack: error?.stack,
body: body,
})
// 处理Prisma特定错误
if (error?.code) {
if (error.code === 'P2002') {
const target = error?.meta?.target
const field = Array.isArray(target) ? target[0] : 'name'
return NextResponse.json({
error: `${field === 'name' ? '模板名称' : '字段'}已存在,请使用其他${field === 'name' ? '名称' : '值'}`,
errorCode: 'DUPLICATE_ENTRY',
field,
}, { status: 400 })
}
if (error.code === 'P2003') {
return NextResponse.json({
error: '关联数据不存在',
errorCode: 'FOREIGN_KEY_CONSTRAINT',
}, { status: 400 })
}
if (error.code === 'P2000') {
return NextResponse.json({
error: '字段值过长,请缩短后重试',
errorCode: 'VALUE_TOO_LONG',
}, { status: 400 })
}
}
const errorMessage = error?.message || '创建上传模板失败'
return NextResponse.json({
error: errorMessage,
errorCode: 'CREATE_FAILED',
}, { status: 500 })
}
}