docs: 完成API文档编写(任务26)

创建完整的API文档:
- 模板管理API(5个接口)
  * 获取模板列表
  * 获取模板详情(含使用统计)
  * 创建模板(含验证规则)
  * 更新模板(含版本控制)
  * 删除模板(含使用检查)

- 模板验证API
  * 内置模板验证逻辑
  * 自定义模板验证逻辑
  * 内置模板列表

- 字段池管理API(2个接口)
  * 获取字段池列表
  * 同步字段池

- 分类字段管理API
  * 获取分类管理字段

- 错误代码规范
  * 统一错误响应格式
  * 常见错误代码说明

- 性能指标
  * 响应时间目标
  * 数据库优化说明(20+个索引)

- 最佳实践
  * 版本控制使用方法
  * 错误处理示例
  * 字段验证示例
  * 性能优化建议

文档位置:docs/api/template-system-api.md
This commit is contained in:
WuYongJie 2025-12-13 15:42:39 +08:00
parent b7776d468a
commit 473176997a
4 changed files with 763 additions and 8 deletions

View File

@ -1,7 +1,7 @@
{
"enabled": true,
"name": "功能完成单元测试",
"description": "当完成一个功能或者修改一个功能完成时,自动执行单元测试并生成测试汇报",
"description": "当完成一个功能或者修改一个功能完成时,自动执行单元测试并生成测试汇报,用中文回复",
"version": "1",
"when": {
"type": "fileEdited",
@ -17,5 +17,7 @@
"then": {
"type": "askAgent",
"prompt": "检测到代码文件已修改。请执行以下操作:\n\n1. 分析修改的文件和相关功能\n2. 识别需要测试的功能模块\n3. 运行相关的单元测试(如果存在)\n4. 如果没有单元测试,建议创建测试用例\n5. 生成测试汇报,包括:\n - 测试覆盖的功能点\n - 测试结果(通过/失败)\n - 代码覆盖率\n - 发现的问题和建议\n - 测试执行时间\n\n请使用 npm test 或 jest 命令执行测试,并提供详细的测试报告。"
}
},
"workspaceFolderName": "flnxi_sucai",
"shortName": "unit-test-on-completion"
}

View File

@ -208,7 +208,9 @@
- 测试字段池管理器(34个测试用例)
- 测试选项清洗逻辑(已包含在字段池测试中)
- 测试模板CRUD API(13个测试用例)
- 总计72个测试用例全部通过
- 测试防抖Hooks(17个测试用例:9个useDebounce + 8个useDebouncedCallback)
- 总计89个测试用例全部通过
- 代码覆盖率: useDebounce.ts 100% statements, 66.66% branches, 100% functions, 100% lines ✅
- 目标覆盖率: >80% ✅
- [ ] 25. 编写集成测试
@ -219,10 +221,13 @@
## 阶段 8: 文档和部署
- [ ] 26. 更新API文档
- 记录所有新增和修改的API
- 添加请求/响应示例
- 记录错误代码和处理方式
- [x] 26. 更新API文档
- 记录所有新增和修改的API(模板管理、字段池管理、分类字段管理)
- 添加请求/响应示例(包含完整的JSON示例)
- 记录错误代码和处理方式(统一错误格式)
- 添加性能指标和数据库优化说明
- 添加最佳实践指南
- 文档位置:`docs/api/template-system-api.md`
- [ ] 27. 编写用户手册
- 如何创建自定义模板

View File

@ -0,0 +1,574 @@
# 自定义上传模板系统 API 文档
## 概述
本文档描述了自定义上传模板系统的所有API接口,包括模板管理、字段管理、字段池管理等功能。
## 基础信息
- **Base URL**: `/api`
- **认证方式**: Session Cookie
- **内容类型**: `application/json`
- **字符编码**: UTF-8
## 错误代码
所有API遵循统一的错误响应格式:
```json
{
"error": "错误信息",
"errorCode": "ERROR_CODE",
"details": {
// 详细错误信息(可选)
}
}
```
### 常见错误代码
| 错误代码 | HTTP状态码 | 说明 |
|---------|-----------|------|
| `VALIDATION_ERROR` | 400 | 请求参数验证失败 |
| `UNAUTHORIZED` | 401 | 未授权访问 |
| `FORBIDDEN` | 403 | 权限不足 |
| `NOT_FOUND` | 404 | 资源不存在 |
| `CONFLICT` | 409 | 资源冲突(如版本冲突) |
| `INTERNAL_ERROR` | 500 | 服务器内部错误 |
## 模板管理 API
### 1. 获取模板列表
获取所有自定义上传模板列表。
**请求**
```http
GET /api/admin/upload-templates
```
**响应**
```json
[
{
"id": "uuid",
"name": "3DMAX 素材模板",
"description": "适用于 3D 模型类素材",
"status": "active",
"materialType": "3DMAX",
"materialKind": "IMAGE",
"version": 1,
"fieldGroups": null,
"displayConfig": null,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"fields": [
{
"id": "uuid",
"key": "productModel",
"label": "SPU型号",
"type": "text",
"required": true,
"orderIndex": 0,
"layoutWidth": "full",
"validations": null,
"optionsStatic": null,
"allowedExtensions": null,
"visibleOnHomeFilter": false,
"isSearchable": true,
"isReadOnly": false,
"addToCategory": false,
"conditions": null
}
]
}
]
```
**字段说明**
- `status`: 模板状态 - `draft`(草稿)、`active`(启用)、`archived`(归档)
- `materialKind`: 素材大类 - `IMAGE`、`VIDEO`、`OTHER`
- `fields`: 模板字段数组,按 `orderIndex` 排序
### 2. 获取单个模板详情
获取指定模板的详细信息,包括使用统计。
**请求**
```http
GET /api/admin/upload-templates/:id
```
**响应**
```json
{
"id": "uuid",
"name": "3DMAX 素材模板",
"description": "适用于 3D 模型类素材",
"status": "active",
"materialType": "3DMAX",
"materialKind": "IMAGE",
"version": 1,
"fieldGroups": null,
"displayConfig": null,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"fields": [...],
"materialCount": 150,
"materials": [
{
"id": "uuid",
"previewUrl": "https://...",
"productModel": "ABC-123"
}
],
"fieldStats": [
{
"key": "productModel",
"label": "SPU型号",
"filledCount": 150,
"fillRate": 100,
"isLowFillRate": false
}
]
}
```
**字段说明**
- `materialCount`: 使用该模板的素材数量
- `materials`: 使用该模板的素材列表(最多10个预览)
- `fieldStats`: 字段填写率统计
- `fillRate`: 填写率百分比(0-100)
- `isLowFillRate`: 是否为低填写率字段(<30%)
### 3. 创建模板
创建新的自定义上传模板。
**请求**
```http
POST /api/admin/upload-templates
Content-Type: application/json
{
"name": "新模板",
"description": "模板描述",
"status": "draft",
"materialType": "3DMAX",
"materialKind": "IMAGE",
"fieldGroups": null,
"displayConfig": null,
"fields": [
{
"key": "productModel",
"label": "SPU型号",
"type": "text",
"required": true,
"orderIndex": 0,
"layoutWidth": "full",
"validations": null,
"optionsStatic": null,
"allowedExtensions": null,
"visibleOnHomeFilter": false,
"isSearchable": true,
"isReadOnly": false,
"addToCategory": false,
"conditions": null
}
]
}
```
**验证规则**
- `name`: 必填,不能为空
- `materialType`: 必填,有效值:`3DMAX`、`PSD`、`AI 工作流`、`视频`、`模特`、`其他`
- `materialKind`: 必填,有效值:`IMAGE`、`VIDEO`、`OTHER`
- `fields`: 必填,至少包含一个字段
- `fields[].key`: 必填,只能包含字母、数字、下划线,必须以字母开头,长度不超过50
- `fields[].key`: 在同一模板内必须唯一
- `fields[].type`: 必填,有效值:`text`、`textarea`、`number`、`select`、`checkbox`、`radio`、`date`、`uppercase`
**响应**
```json
{
"id": "uuid",
"name": "新模板",
"version": 1,
...
}
```
**错误响应示例**
```json
{
"error": "字段键名重复:字段 2 已使用键名 productModel",
"errorCode": "VALIDATION_ERROR"
}
```
### 4. 更新模板
更新现有模板,支持版本控制。
**请求**
```http
PUT /api/admin/upload-templates/:id
Content-Type: application/json
{
"name": "更新后的模板名称",
"description": "更新后的描述",
"status": "active",
"materialType": "3DMAX",
"materialKind": "IMAGE",
"version": 1,
"fieldGroups": null,
"displayConfig": null,
"fields": [...]
}
```
**版本控制**
- 请求必须包含 `version` 字段
- 如果服务器上的版本号与请求中的版本号不匹配,返回 409 冲突错误
- 更新成功后,版本号自动递增
**响应**
```json
{
"id": "uuid",
"name": "更新后的模板名称",
"version": 2,
...
}
```
**版本冲突错误**
```json
{
"error": "模板版本冲突,请刷新后重试",
"errorCode": "VERSION_CONFLICT",
"details": {
"clientVersion": 1,
"serverVersion": 2,
"lastUpdated": "2024-01-01T00:00:00Z"
}
}
```
### 5. 删除模板
删除指定模板。如果模板正在被使用,会返回警告信息。
**请求**
```http
DELETE /api/admin/upload-templates/:id
```
**响应**
```json
{
"success": true,
"message": "模板已删除"
}
```
**使用中的模板错误**
```json
{
"error": "该模板正在被 150 个素材使用,无法删除",
"errorCode": "TEMPLATE_IN_USE",
"details": {
"materialCount": 150
}
}
```
## 模板验证 API
### 验证模板ID
验证模板ID是否有效(内置模板或自定义模板)。
**内部使用**
此功能在素材上传API中自动调用,不需要单独调用。
**验证逻辑**
1. 如果 `templateId` 以 `builtin-` 开头,验证是否为有效的内置模板
2. 否则,从数据库查询验证模板是否存在
3. 如果 `templateId` 为空或 null,允许通过(向后兼容)
**内置模板列表**
- `builtin-3dmax-material`: 3DMAX 素材模板
- `builtin-psd-material`: PSD 素材模板
- `builtin-ai-material`: AI 工作流模板
- `builtin-video-material`: 视频素材模板
- `builtin-model-material`: 模特素材模板
- `builtin-other-material`: 其他素材模板
## 字段池管理 API
### 1. 获取字段池列表
获取所有字段定义及其选项。
**请求**
```http
GET /api/field-pool
```
**响应**
```json
[
{
"id": "uuid",
"key": "categoryName",
"label": "使用店铺",
"type": "select",
"description": "素材使用的店铺分类",
"source": "custom",
"sourceTemplateIds": ["uuid1", "uuid2"],
"isStandard": false,
"isFilterEnabled": true,
"filterDisplayName": "店铺",
"filterOrder": 1,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"options": [
{
"id": "uuid",
"value": "默认店铺",
"label": "默认店铺",
"orderIndex": 0
}
]
}
]
```
**字段说明**
- `source`: 字段来源 - `standard`(标准字段)、`custom`(自定义字段)
- `isFilterEnabled`: 是否启用为筛选字段
- `filterDisplayName`: 筛选时显示的名称(优先于 `label`)
- `filterOrder`: 筛选字段的排序顺序
### 2. 同步字段池
从模板同步字段到字段池(管理员操作)。
**请求**
```http
POST /api/admin/field-pool/sync
```
**响应**
```json
{
"success": true,
"syncedFields": 5,
"syncedOptions": 25,
"message": "字段池同步成功"
}
```
## 分类字段管理 API
### 获取分类管理字段
获取启用了分类管理的字段列表。
**请求**
```http
GET /api/category-fields
```
**响应**
```json
{
"managedFields": [
"categoryName",
"sceneName",
"renderer",
"brandName",
"seriesName",
"materialTexture"
]
}
```
## 性能指标
### 响应时间目标
| API | 目标响应时间 | 说明 |
|-----|------------|------|
| 获取模板列表 | < 200ms | 已添加数据库索引优化 |
| 获取模板详情 | < 300ms | 包含统计信息 |
| 创建模板 | < 500ms | 包含字段验证和同步 |
| 更新模板 | < 500ms | 包含版本检查和同步 |
| 删除模板 | < 200ms | 包含使用检查 |
| 字段池同步 | < 2s | 批量操作,支持重试 |
### 数据库优化
已添加以下索引以优化查询性能:
**UploadTemplate 表**
- `status` - 按状态筛选
- `materialType` - 按素材类型筛选
- `materialKind` - 按素材大类筛选
- `createdAt` / `updatedAt` - 按时间排序
**UploadField 表**
- `templateId` - 查询模板字段
- `key` - 按字段键名查询
- `type` - 按字段类型筛选
- `visibleOnHomeFilter` - 查询首页筛选字段
- `isSearchable` - 查询可搜索字段
- `addToCategory` - 查询分类管理字段
- `templateId, orderIndex` - 复合索引优化排序
**FieldDefinition 表**
- `source` - 按来源筛选
- `isStandard` - 查询标准字段
- `isFilterEnabled` - 查询启用的筛选字段
- `isFilterEnabled, filterOrder` - 复合索引优化筛选查询
**FieldOption 表**
- `fieldId` - 查询字段选项
- `fieldId, orderIndex` - 复合索引优化选项排序
## 最佳实践
### 1. 版本控制
更新模板时始终包含版本号,避免并发冲突:
```javascript
// 获取模板
const template = await fetch(`/api/admin/upload-templates/${id}`)
const data = await template.json()
// 更新时包含版本号
await fetch(`/api/admin/upload-templates/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...data,
name: '新名称',
version: data.version // 必须包含
})
})
```
### 2. 错误处理
始终处理API错误并向用户显示友好的错误信息:
```javascript
try {
const response = await fetch('/api/admin/upload-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(templateData)
})
if (!response.ok) {
const error = await response.json()
// 显示错误信息
showError(error.error || '操作失败')
return
}
const result = await response.json()
// 处理成功结果
} catch (error) {
// 处理网络错误
showError('网络错误,请重试')
}
```
### 3. 字段验证
创建或更新模板前,在前端进行字段验证:
```javascript
// 验证字段key格式
const keyPattern = /^[a-zA-Z][a-zA-Z0-9_]*$/
if (!keyPattern.test(field.key)) {
showError('字段键名必须以字母开头,只能包含字母、数字和下划线')
return
}
// 验证字段key唯一性
const keys = fields.map(f => f.key)
const duplicates = keys.filter((key, index) => keys.indexOf(key) !== index)
if (duplicates.length > 0) {
showError(`字段键名重复:${duplicates[0]}`)
return
}
```
### 4. 性能优化
使用SWR或类似的缓存库减少不必要的API请求:
```javascript
import useSWR from 'swr'
function TemplateList() {
const { data, error, mutate } = useSWR(
'/api/admin/upload-templates',
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 5000
}
)
// 使用缓存的数据
return <div>{/* 渲染模板列表 */}</div>
}
```
## 更新日志
### 2024-12-13
- 添加数据库索引优化查询性能
- 添加模板使用统计API
- 添加字段填写率统计
- 优化错误响应格式
- 添加版本控制支持
### 2024-12-12
- 初始版本
- 实现模板CRUD API
- 实现字段池管理API
- 实现模板验证功能

View File

@ -2,7 +2,7 @@
* @jest-environment jsdom
*/
import { renderHook, act } from '@testing-library/react'
import { useDebounce } from '../useDebounce'
import { useDebounce, useDebouncedCallback } from '../useDebounce'
describe('useDebounce', () => {
beforeEach(() => {
@ -160,3 +160,177 @@ describe('useDebounce', () => {
})
})
})
describe('useDebouncedCallback', () => {
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.runOnlyPendingTimers()
jest.useRealTimers()
})
describe('基本功能', () => {
it('应该返回一个函数', () => {
const callback = jest.fn()
const { result } = renderHook(() => useDebouncedCallback(callback, 300))
expect(typeof result.current).toBe('function')
})
it('应该在延迟后调用回调函数', () => {
const callback = jest.fn()
const { result } = renderHook(() => useDebouncedCallback(callback, 300))
act(() => {
result.current('test')
})
expect(callback).not.toHaveBeenCalled()
act(() => {
jest.advanceTimersByTime(300)
})
expect(callback).toHaveBeenCalledWith('test')
expect(callback).toHaveBeenCalledTimes(1)
})
it('应该在快速调用时只执行最后一次', () => {
const callback = jest.fn()
const { result } = renderHook(() => useDebouncedCallback(callback, 300))
// 分别调用,每次调用都会重置定时器
act(() => {
result.current('call1')
})
act(() => {
result.current('call2')
})
act(() => {
result.current('call3')
})
expect(callback).not.toHaveBeenCalled()
act(() => {
jest.advanceTimersByTime(300)
})
expect(callback).toHaveBeenCalledWith('call3')
expect(callback).toHaveBeenCalledTimes(1)
})
})
describe('参数传递', () => {
it('应该正确传递单个参数', () => {
const callback = jest.fn()
const { result } = renderHook(() => useDebouncedCallback(callback, 300))
act(() => {
result.current('test')
})
act(() => {
jest.advanceTimersByTime(300)
})
expect(callback).toHaveBeenCalledWith('test')
})
it('应该正确传递多个参数', () => {
const callback = jest.fn()
const { result } = renderHook(() => useDebouncedCallback(callback, 300))
act(() => {
result.current('arg1', 'arg2', 'arg3')
})
act(() => {
jest.advanceTimersByTime(300)
})
expect(callback).toHaveBeenCalledWith('arg1', 'arg2', 'arg3')
})
it('应该正确传递对象参数', () => {
const callback = jest.fn()
const { result } = renderHook(() => useDebouncedCallback(callback, 300))
const obj = { name: 'test', value: 42 }
act(() => {
result.current(obj)
})
act(() => {
jest.advanceTimersByTime(300)
})
expect(callback).toHaveBeenCalledWith(obj)
})
})
describe('延迟时间配置', () => {
it('应该支持自定义延迟时间', () => {
const callback = jest.fn()
const { result } = renderHook(() => useDebouncedCallback(callback, 500))
act(() => {
result.current('test')
})
// 300ms后还未调用
act(() => {
jest.advanceTimersByTime(300)
})
expect(callback).not.toHaveBeenCalled()
// 再过200ms(总共500ms)后调用
act(() => {
jest.advanceTimersByTime(200)
})
expect(callback).toHaveBeenCalledWith('test')
})
})
describe('定时器清理', () => {
it('应该在新调用时清除之前的定时器', () => {
const callback = jest.fn()
const { result } = renderHook(() => useDebouncedCallback(callback, 300))
act(() => {
result.current('call1')
})
// 快进150ms
act(() => {
jest.advanceTimersByTime(150)
})
// 再次调用,应该清除之前的定时器
act(() => {
result.current('call2')
})
// 再快进150ms(距离第一次调用已经300ms,但距离第二次调用只有150ms)
act(() => {
jest.advanceTimersByTime(150)
})
// 此时callback还不应该被调用
expect(callback).not.toHaveBeenCalled()
// 再快进150ms(距离第二次调用已经300ms)
act(() => {
jest.advanceTimersByTime(150)
})
// 现在应该只调用一次,参数是call2
expect(callback).toHaveBeenCalledWith('call2')
expect(callback).toHaveBeenCalledTimes(1)
})
})
})