WuYongJie 6756d5e72c feat: 完成阶段1核心基础设施优化
✅ 任务1: 完善模板验证工具
- 添加详细的错误代码和错误信息
- 添加性能监控(记录验证耗时)
- 添加模板状态检查
- 添加批量验证功能
- 添加结构化日志记录

✅ 任务2: 优化上传API的模板验证
- 集成增强的validateTemplateId函数
- 添加详细的验证日志
- 返回错误代码便于前端处理

✅ 任务3: 增强字段池同步机制
- 添加增量同步支持
- 添加同步失败重试机制(指数退避)
- 添加详细的同步统计信息
- 添加同步日志和监控
- 优化错误处理

相关需求: 1.1-1.4, 3.1-3.4, 6.1-6.4, 24.1-24.5
2025-12-13 14:48:03 +08:00

25 KiB
Raw Blame History

自定义上传模板系统优化 - 技术设计文档

概述

本文档描述了FLNXI素材库自定义上传模板系统的优化设计方案。该系统是整个平台的核心基础设施,负责定义素材的上传表单结构、元数据字段、分类筛选和共享展示。

设计目标

  1. 稳定性: 确保模板系统在各种场景下都能可靠运行,不会因为数据异常导致系统崩溃
  2. 性能: 优化模板加载、字段同步、分类筛选等关键操作的性能
  3. 可维护性: 提供清晰的代码结构和完善的错误处理,便于后续维护和扩展
  4. 向后兼容: 确保优化不会破坏现有功能,支持旧数据的平滑迁移

架构设计

系统架构图

┌─────────────────────────────────────────────────────────────┐
│                        前端层                                 │
├─────────────────────────────────────────────────────────────┤
│  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" | "模板未启用"
}

错误处理

错误类型

  1. 模板验证错误

    • 内置模板key无效
    • 自定义模板不存在
    • 模板状态非active
  2. 字段验证错误

    • 字段key重复
    • 字段类型无效
    • 必填字段为空
    • 验证规则不满足
  3. 数据完整性错误

    • 版本冲突
    • 外键约束违反
    • 唯一约束违反
  4. 字段池同步错误

    • 选项解析失败
    • 数据库操作失败

错误处理策略

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

部署步骤

  1. 备份数据库

    pg_dump -U postgres -d flnxi > backup_$(date +%Y%m%d).sql
    
  2. 运行数据库迁移

    npm run prisma:migrate deploy
    
  3. 同步字段池

    npm run sync-field-pool
    
  4. 部署新代码

    ./deploy-new-fixed.ps1
    
  5. 验证部署

    curl http://localhost:3000/api/health
    

监控和日志

关键指标

  1. 模板操作

    • 模板创建/更新/删除次数
    • 模板验证失败次数
    • 平均响应时间
  2. 字段池同步

    • 同步成功/失败次数
    • 同步耗时
    • 字段数量变化
  3. 素材上传

    • 使用各模板的上传次数
    • 上传失败次数(按错误类型分类)
    • 平均上传时间

日志格式

// 结构化日志
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. 多语言支持

支持字段标签和选项的多语言配置。