✅ 任务1: 完善模板验证工具 - 添加详细的错误代码和错误信息 - 添加性能监控(记录验证耗时) - 添加模板状态检查 - 添加批量验证功能 - 添加结构化日志记录 ✅ 任务2: 优化上传API的模板验证 - 集成增强的validateTemplateId函数 - 添加详细的验证日志 - 返回错误代码便于前端处理 ✅ 任务3: 增强字段池同步机制 - 添加增量同步支持 - 添加同步失败重试机制(指数退避) - 添加详细的同步统计信息 - 添加同步日志和监控 - 优化错误处理 相关需求: 1.1-1.4, 3.1-3.4, 6.1-6.4, 24.1-24.5
25 KiB
25 KiB
自定义上传模板系统优化 - 技术设计文档
概述
本文档描述了FLNXI素材库自定义上传模板系统的优化设计方案。该系统是整个平台的核心基础设施,负责定义素材的上传表单结构、元数据字段、分类筛选和共享展示。
设计目标
- 稳定性: 确保模板系统在各种场景下都能可靠运行,不会因为数据异常导致系统崩溃
- 性能: 优化模板加载、字段同步、分类筛选等关键操作的性能
- 可维护性: 提供清晰的代码结构和完善的错误处理,便于后续维护和扩展
- 向后兼容: 确保优化不会破坏现有功能,支持旧数据的平滑迁移
架构设计
系统架构图
┌─────────────────────────────────────────────────────────────┐
│ 前端层 │
├─────────────────────────────────────────────────────────────┤
│ UploadModal │ TemplateManager │ CategoriesTab │ 首页 │
│ (上传表单) │ (模板管理) │ (分类管理) │ (筛选) │
└────────┬──────────────┬──────────────┬──────────────┬───────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ API层 │
├─────────────────────────────────────────────────────────────┤
│ /api/materials/upload │
│ /api/upload-templates │
│ /api/admin/upload-templates │
│ /api/template-categories │
│ /api/category-fields │
└────────┬──────────────┬──────────────┬──────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ 业务逻辑层 │
├─────────────────────────────────────────────────────────────┤
│ template-validator.ts │ field-pool.ts │ upload-template-│
│ (模板验证) │ (字段池管理) │ presets.ts │
│ │ │ (内置模板) │
└────────┬────────────────┬─────────────────┬─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ 数据访问层 │
├─────────────────────────────────────────────────────────────┤
│ Prisma ORM │
│ - UploadTemplate │
│ - UploadField │
│ - FieldDefinition │
│ - FieldOption │
│ - Material │
└────────┬────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 数据库层 │
├─────────────────────────────────────────────────────────────┤
│ PostgreSQL │
└─────────────────────────────────────────────────────────────┘
核心组件
1. 模板验证器 (template-validator.ts)
职责: 统一处理内置模板和自定义模板的验证逻辑
核心函数:
// 验证模板ID是否有效
validateTemplateId(templateId: string | null): Promise<TemplateValidationResult>
// 检查是否为内置模板
isBuiltinTemplate(templateId: string): boolean
// 提取内置模板key
extractBuiltinTemplateKey(templateId: string): BuiltinTemplateKey | null
// 获取模板完整信息(包括字段)
getTemplateWithFields(templateId: string): Promise<Template | null>
验证流程:
输入: templateId
│
├─ 是否为空? ──Yes──> 返回 valid=true (向后兼容)
│
└─ No
│
├─ 是否以 "builtin-" 开头?
│ │
│ ├─ Yes (内置模板)
│ │ │
│ │ └─ 验证 key 是否在预设列表中
│ │ │
│ │ ├─ Yes ──> 返回 valid=true
│ │ └─ No ──> 返回 valid=false, error="无效的内置模板"
│ │
│ └─ No (自定义模板)
│ │
│ └─ 从数据库查询
│ │
│ ├─ 存在且状态为active ──> 返回 valid=true
│ └─ 不存在或状态非active ──> 返回 valid=false, error="模板不存在或未启用"
2. 字段池管理器 (field-pool.ts)
职责: 管理模板字段与字段池之间的同步
核心函数:
// 从模板同步字段到字段池
syncFieldPoolFromTemplates(): Promise<void>
// 获取所有字段池记录
getFieldPoolRecords(): Promise<FieldDefinition[]>
// 获取已启用的筛选字段
getEnabledFilterFields(): Promise<FilterField[]>
// 更新字段的启用状态
updateFieldFilterState(fieldKey: string, enabled: boolean): Promise<void>
// 规范化字段选项值
normalizeFieldOptionValue(option: string): string
// 解析静态选项
parseOptionsStatic(raw: string): string[]
// 格式化字段选项值
formatFieldOptionValues(optionRecords: FieldOption[]): string[]
同步流程:
触发: 模板创建/更新/删除
│
├─ 1. 扫描所有模板的 addToCategory=true 字段
│ │
│ └─ 聚合相同key的字段
│ │
│ └─ 合并选项列表
│
├─ 2. 更新 FieldDefinition 表
│ │
│ ├─ 新增字段: 创建记录
│ ├─ 已存在字段: 更新 sourceTemplateIds
│ └─ 孤立字段: 删除记录
│
├─ 3. 同步 FieldOption 表
│ │
│ ├─ 清洗选项值 (去括号、trim、去重)
│ ├─ 新增选项: 创建记录
│ ├─ 已存在选项: 更新 orderIndex
│ └─ 废弃选项: 删除记录
│
└─ 4. 返回结果
3. 内置模板预设 (upload-template-presets.ts)
职责: 定义和生成内置模板
内置模板列表:
builtin-3dmax-material: 3DMAX 素材模板builtin-psd-material: PSD 素材模板builtin-ai-material: AI 工作流builtin-video-material: 视频素材模板builtin-model-material: 模特素材模板builtin-other-material: 其他素材模板
标准字段:
{
productModel: SPU型号 (text, required)
categoryName: 使用店铺 (select, required, addToCategory)
sceneName: 场景类别 (select, required, addToCategory)
platformName: 平台分类 (text, addToCategory)
renderer: 渲染器 (select, addToCategory)
brandName: 品牌名称 (text)
seriesName: 系列/产品线 (text)
skuCode: SKU编码 (text)
materialTexture: 材质/工艺 (text)
renderTime: 渲染耗时 (text)
imageResolution: 输出分辨率 (text)
colorMode: 颜色模式 (text)
authorName: 作者 (text)
}
数据模型
UploadTemplate (上传模板)
{
id: string // UUID
name: string // 模板名称
description: string? // 描述
status: string // draft | active | archived
materialType: string // 素材类型文案
materialKind: string // 素材大类: IMAGE | VIDEO | OTHER
version: int // 版本号(用于并发控制)
fieldGroups: string? // 字段分组配置 (JSON)
displayConfig: string? // 显示配置 (JSON)
createdAt: DateTime
updatedAt: DateTime
fields: UploadField[] // 关联字段
}
UploadField (上传字段)
{
id: string // UUID
templateId: string // 所属模板ID
key: string // 字段键名
label: string // 字段标签
type: string // 字段类型: text | textarea | select | number | date | checkbox | file | image
required: boolean // 是否必填
orderIndex: int // 排序索引
layoutWidth: string? // 布局宽度: full | half
validations: string? // 验证规则 (JSON)
optionsSource: string? // 选项来源
optionsStatic: string? // 静态选项 (JSON字符串)
allowedExtensions: string? // 允许的文件扩展名
visibleOnHomeFilter: boolean // 首页筛选可见
isSearchable: boolean // 可搜索
isReadOnly: boolean // 只读
addToCategory: boolean // 添加到分类管理
conditions: string? // 显示条件 (JSON)
createdAt: DateTime
updatedAt: DateTime
}
FieldDefinition (字段池定义)
{
id: string // UUID
key: string // 字段键名 (unique)
label: string // 字段标签
type: string // 字段类型
description: string? // 描述
source: string // 来源: standard | custom
sourceTemplateIds: string? // 引用此字段的模板ID列表 (JSON数组)
isStandard: boolean // 是否标准字段
isFilterEnabled: boolean // 是否启用筛选
filterDisplayName: string? // 筛选显示名称
filterOrder: int // 筛选排序
createdAt: DateTime
updatedAt: DateTime
options: FieldOption[] // 关联选项
}
FieldOption (字段选项)
{
id: string // UUID
fieldId: string // 所属字段ID
value: string // 选项值
label: string // 选项标签
orderIndex: int // 排序索引
createdAt: DateTime
updatedAt: DateTime
// 联合唯一约束: [fieldId, value]
}
Material (素材)
{
id: string // 素材ID
templateId: string? // 关联的模板ID (可为null)
metadata: string? // 自定义字段数据 (JSON)
// 标准字段
productModel: string
categoryName: string
sceneName: string
platformName: string
materialType: string
materialKind: string
renderer: string
brandName: string?
seriesName: string?
skuCode: string?
materialTexture: string?
renderTime: string?
imageResolution: string?
colorMode: string?
authorName: string
// ... 其他字段
}
组件接口
API接口
1. 获取上传模板列表
GET /api/upload-templates
Response: {
id: string
name: string
description: string
status: string
materialType: string
materialKind: string
isBuiltin: boolean
materialCount: number
fields: UploadField[]
}[]
2. 创建自定义模板
POST /api/admin/upload-templates
Request: {
name: string
description?: string
status?: string
materialType: string
materialKind: string
fieldGroups?: string
displayConfig?: string
fields: {
key: string
label: string
type: string
required: boolean
orderIndex: number
layoutWidth?: string
validations?: string
optionsStatic?: string
visibleOnHomeFilter: boolean
isSearchable: boolean
isReadOnly: boolean
addToCategory: boolean
conditions?: string
}[]
}
Response: UploadTemplate
3. 更新自定义模板
PUT /api/admin/upload-templates/[id]
Request: {
name?: string
description?: string
status?: string
materialType?: string
materialKind?: string
fieldGroups?: string
displayConfig?: string
version: number // 用于并发控制
fields?: UploadField[]
}
Response: UploadTemplate
Error (409): {
error: "模板已被其他人修改,请刷新后重试"
}
4. 删除自定义模板
DELETE /api/admin/upload-templates/[id]
Response: {
success: boolean
}
5. 获取分类字段
GET /api/template-categories
Response: {
key: string
label: string
defaultLabel: string
filterOrder: number
options: string[]
}[]
6. 更新分类字段
PUT /api/category-fields
Request: {
fields: {
key: string
filterOrder: number
filterDisplayName: string
}[]
}
Response: {
success: boolean
}
7. 上传素材
POST /api/materials/upload
Request (FormData): {
templateId?: string
templateFields?: string // JSON字符串
previewImages: File[]
displayImage?: File
zipFile?: File
// 标准字段
productModel: string
categoryName: string
sceneName: string
platformName?: string
materialType: string
materialKind: string
renderer?: string
brandName?: string
seriesName?: string
skuCode?: string
materialTexture?: string
renderTime?: string
imageResolution?: string
colorMode?: string
}
Response: {
id: string
previewUrl: string
// ... 其他字段
}
Error (400): {
error: "模板不存在:builtin-xxx" | "无效的内置模板:builtin-xxx" | "模板未启用"
}
错误处理
错误类型
-
模板验证错误
- 内置模板key无效
- 自定义模板不存在
- 模板状态非active
-
字段验证错误
- 字段key重复
- 字段类型无效
- 必填字段为空
- 验证规则不满足
-
数据完整性错误
- 版本冲突
- 外键约束违反
- 唯一约束违反
-
字段池同步错误
- 选项解析失败
- 数据库操作失败
错误处理策略
// 1. API层错误处理
try {
// 业务逻辑
} catch (error) {
console.error('[API] Error:', {
endpoint: '/api/xxx',
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
context: { /* 相关上下文 */ }
})
// 返回用户友好的错误信息
return NextResponse.json(
{ error: getUserFriendlyMessage(error) },
{ status: getStatusCode(error) }
)
}
// 2. 业务逻辑层错误处理
async function validateTemplateId(templateId: string) {
try {
// 验证逻辑
} catch (error) {
console.error('[TemplateValidator] Error:', error)
return {
valid: false,
error: `验证模板时发生错误:${error.message}`
}
}
}
// 3. 字段池同步错误处理
async function syncFieldPoolFromTemplates() {
try {
// 同步逻辑
} catch (error) {
console.error('[FieldPool] Sync error:', error)
// 记录错误但不抛出,避免影响模板保存
}
}
测试策略
单元测试
模板验证器测试:
describe('template-validator', () => {
test('应该验证内置模板ID', async () => {
const result = await validateTemplateId('builtin-video-material')
expect(result.valid).toBe(true)
expect(result.isBuiltin).toBe(true)
})
test('应该拒绝无效的内置模板ID', async () => {
const result = await validateTemplateId('builtin-invalid')
expect(result.valid).toBe(false)
expect(result.error).toContain('无效的内置模板')
})
test('应该验证自定义模板ID', async () => {
// Mock数据库查询
const result = await validateTemplateId('custom-template-id')
expect(result.valid).toBe(true)
expect(result.isBuiltin).toBe(false)
})
})
字段池管理器测试:
describe('field-pool', () => {
test('应该规范化字段选项值', () => {
expect(normalizeFieldOptionValue('[[[选项]]]')).toBe('选项')
expect(normalizeFieldOptionValue(' 选项 ')).toBe('选项')
expect(normalizeFieldOptionValue('"选项"')).toBe('选项')
})
test('应该解析静态选项', () => {
const options = parseOptionsStatic('["选项1", "选项2"]')
expect(options).toEqual(['选项1', '选项2'])
})
test('应该去重选项', () => {
const options = parseOptionsStatic('["选项1", "选项1", "选项2"]')
expect(options).toEqual(['选项1', '选项2'])
})
})
集成测试
模板创建流程测试:
describe('Template Creation Flow', () => {
test('应该创建自定义模板并同步字段池', async () => {
// 1. 创建模板
const template = await createTemplate({
name: '测试模板',
fields: [
{ key: 'testField', label: '测试字段', addToCategory: true }
]
})
// 2. 验证模板创建成功
expect(template.id).toBeDefined()
// 3. 验证字段池同步
const fieldDef = await getFieldDefinition('testField')
expect(fieldDef).toBeDefined()
expect(fieldDef.sourceTemplateIds).toContain(template.id)
})
})
素材上传流程测试:
describe('Material Upload Flow', () => {
test('应该使用内置模板上传素材', async () => {
const material = await uploadMaterial({
templateId: 'builtin-video-material',
productModel: '测试产品',
categoryName: '测试分类',
sceneName: '测试场景'
})
expect(material.id).toBeDefined()
expect(material.templateId).toBe('builtin-video-material')
})
test('应该使用自定义模板上传素材', async () => {
const template = await createTemplate({ /* ... */ })
const material = await uploadMaterial({
templateId: template.id,
templateFields: { testField: '测试值' }
})
expect(material.metadata).toContain('testField')
})
})
性能优化
1. 数据库查询优化
使用索引:
-- Material表索引
CREATE INDEX idx_material_template_id ON materials(templateId);
CREATE INDEX idx_material_category_name ON materials(categoryName);
CREATE INDEX idx_material_scene_name ON materials(sceneName);
-- FieldDefinition表索引
CREATE INDEX idx_field_definition_key ON field_definitions(key);
CREATE INDEX idx_field_definition_filter_enabled ON field_definitions(isFilterEnabled);
CREATE INDEX idx_field_definition_filter_order ON field_definitions(filterOrder);
-- FieldOption表索引
CREATE INDEX idx_field_option_field_id ON field_options(fieldId);
CREATE INDEX idx_field_option_order ON field_options(orderIndex);
批量操作:
// ❌ 不好的做法:逐个插入
for (const option of options) {
await prisma.fieldOption.create({ data: option })
}
// ✅ 好的做法:批量插入
await prisma.fieldOption.createMany({
data: options
})
使用事务:
await prisma.$transaction(async (tx) => {
// 1. 更新模板
await tx.uploadTemplate.update({ /* ... */ })
// 2. 删除旧字段
await tx.uploadField.deleteMany({ /* ... */ })
// 3. 创建新字段
await tx.uploadField.createMany({ /* ... */ })
})
2. 前端性能优化
虚拟滚动:
// 当字段数量超过30个时使用虚拟滚动
{fields.length > 30 ? (
<VirtualList
items={fields}
itemHeight={60}
renderItem={(field) => <FieldCard field={field} />}
/>
) : (
fields.map(field => <FieldCard key={field.id} field={field} />)
)}
防抖:
// 用户输入时使用防抖减少验证频率
const debouncedValidate = useMemo(
() => debounce((value) => validateField(value), 300),
[]
)
缓存:
// 缓存模板列表
const { data: templates, isLoading } = useSWR(
'/api/upload-templates',
fetcher,
{ revalidateOnFocus: false }
)
3. 字段池同步优化
增量同步:
// 只同步变更的字段,而不是全量同步
async function syncChangedFields(templateId: string, changedFields: string[]) {
for (const fieldKey of changedFields) {
await syncSingleField(fieldKey)
}
}
异步同步:
// 模板保存后异步同步字段池,不阻塞响应
await prisma.uploadTemplate.update({ /* ... */ })
// 异步同步
setImmediate(async () => {
try {
await syncFieldPoolFromTemplates()
} catch (error) {
console.error('[FieldPool] Async sync error:', error)
}
})
return NextResponse.json(template)
部署和迁移
数据库迁移
添加版本控制字段:
ALTER TABLE upload_templates ADD COLUMN version INTEGER DEFAULT 1;
添加字段池表:
-- 已存在,无需迁移
数据迁移脚本:
// 迁移旧素材的templateId
async function migrateOldMaterials() {
const materials = await prisma.material.findMany({
where: { templateId: null }
})
for (const material of materials) {
// 根据materialType推断templateId
const templateId = inferTemplateId(material.materialType)
await prisma.material.update({
where: { id: material.id },
data: { templateId }
})
}
}
部署步骤
-
备份数据库
pg_dump -U postgres -d flnxi > backup_$(date +%Y%m%d).sql -
运行数据库迁移
npm run prisma:migrate deploy -
同步字段池
npm run sync-field-pool -
部署新代码
./deploy-new-fixed.ps1 -
验证部署
curl http://localhost:3000/api/health
监控和日志
关键指标
-
模板操作
- 模板创建/更新/删除次数
- 模板验证失败次数
- 平均响应时间
-
字段池同步
- 同步成功/失败次数
- 同步耗时
- 字段数量变化
-
素材上传
- 使用各模板的上传次数
- 上传失败次数(按错误类型分类)
- 平均上传时间
日志格式
// 结构化日志
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
level: 'info',
module: 'template-validator',
action: 'validate',
templateId: 'builtin-video-material',
result: 'success',
duration: 15
}))
安全考虑
1. 权限控制
// 只有管理员可以创建/修改/删除模板
async function requireAdmin(session: Session | null) {
if (!session || session.user.role !== 'ADMIN') {
throw new Error('Unauthorized')
}
}
2. 输入验证
// 验证字段key格式
const FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/
if (!FIELD_KEY_PATTERN.test(fieldKey)) {
throw new Error('字段key只能包含字母、数字和下划线,且必须以字母开头')
}
3. SQL注入防护
// 使用Prisma ORM,自动防护SQL注入
await prisma.uploadTemplate.findUnique({
where: { id: templateId } // 参数化查询
})
4. XSS防护
// React自动转义,但对于动态HTML需要额外处理
import DOMPurify from 'dompurify'
const sanitizedHtml = DOMPurify.sanitize(userInput)
未来扩展
1. 模板版本历史
记录模板的每次修改,支持回滚到历史版本。
2. 模板市场
允许用户分享和下载模板配置。
3. 智能字段推荐
根据素材类型和历史数据,推荐常用字段。
4. 字段依赖关系
支持更复杂的字段显示条件和联动逻辑。
5. 多语言支持
支持字段标签和选项的多语言配置。