大文件上传

TUS 文件上传功能文档

目录


功能概述

本次新增了基于 TUS 协议的大文件上传功能,支持以下特性:

  • 客户端分块上传:将大文件分成多个小块上传,提高上传效率
  • 断点续传:网络中断后可以从中断点继续上传,无需重新开始
  • 自动重试:上传失败时自动重试,提高上传成功率
  • 上传进度监控:实时显示上传进度
  • 大文件支持:支持 GB 级别的大文件上传
  • 并发控制:支持多个文件并发上传

技术选型

  • 前端@uppy/core + @uppy/tus(基于 Uppy 的 TUS 插件)
  • 后端@tus/server + @tus/file-store(TUS 协议服务器实现)
  • 协议:TUS (Resumable Upload Protocol) v1.0.0

依赖包版本

前端依赖(packages/ui/package.json)
包名 版本 作用
@uppy/core ^5.2.0 Uppy 核心库,提供文件管理和插件架构
@uppy/react ^5.1.1 Uppy React 组件(本项目未直接使用)
@uppy/tus ^5.1.0 TUS 协议插件,实现分块上传和断点续传
后端依赖(packages/server/package.json)
包名 版本 作用
@tus/server ^1.10.2 TUS 协议服务器核心实现
@tus/file-store ^1.5.1 文件存储插件,将文件存储到本地文件系统

包之间的关系

复制代码
前端:
@uppy/core (核心)
  └── @uppy/tus (TUS 插件,依赖 @uppy/core)
      └── tus-js-client (底层 TUS 客户端,@uppy/tus 内部使用)

后端:
@tus/server (TUS 服务器核心)
  └── @tus/file-store (存储插件,依赖 @tus/server)

说明

  • @uppy/tus 内部使用 tus-js-client 作为底层 TUS 客户端
  • @tus/file-store@tus/server 的存储插件,可以替换为其他存储实现(如 S3、GCS)
  • 前端和后端通过 TUS 协议(HTTP)通信,无需直接依赖关系

技术架构

TUS 协议工作流程

复制代码
1. 客户端发起 POST 请求创建上传会话
   ↓
2. 服务器返回 Location 头(包含上传 URL)
   ↓
3. 客户端分块上传(PATCH 请求)
   ↓
4. 服务器返回 upload-offset(已上传字节数)
   ↓
5. 重复步骤 3-4 直到上传完成
   ↓
6. 客户端可随时查询上传状态(HEAD 请求)

文件存储结构

复制代码
~/.agentfactory/uploads/tus/
├── {upload-id}          # 实际文件内容
└── {upload-id}.json     # 文件元数据

前端实现

依赖安装

前端依赖已包含在 packages/ui/package.json 中:

json 复制代码
{
    "@uppy/core": "^5.2.0", // Uppy 核心库,提供文件管理和插件架构
    "@uppy/react": "^5.1.1", // Uppy React 组件(可选,本项目未使用)
    "@uppy/tus": "^5.1.0" // TUS 协议插件,实现分块上传和断点续传
}

安装命令

bash 复制代码
cd packages/ui
pnpm install @uppy/core @uppy/tus

核心组件

项目中有两个文件上传组件:

  1. FileUploadButton:基础版本,仅文件选择,不上传

    • 文件位置packages/ui/FileUploadButton.jsx
    • 功能 :使用 @uppy/core 实现无头样式的文件选择组件
  2. FileUploadButtonEnhanced:增强版本,支持 TUS 协议上传

    • 文件位置packages/ui/FileUploadButtonEnhanced.jsx
    • 功能 :基于 @uppy/core + @uppy/tus 实现大文件分块上传

FileUploadButton 实现详解

文件位置packages/ui/FileUploadButton.jsx

这是一个基础的文件选择组件,使用 @uppy/core 实现,不包含上传功能:

jsx 复制代码
import Uppy from '@uppy/core'

const FileUploadButton = ({ onFilesAdded, disabled, maxFiles = 5, allowedFileTypes, className = '' }) => {
    const uppyRef = useRef(null)
    const inputRef = useRef(null)
    const [files, setFiles] = useState([])

    // 初始化 Uppy 实例(仅用于文件管理,不上传)
    useEffect(() => {
        const uppy = new Uppy({
            restrictions: {
                maxNumberOfFiles: maxFiles,
                allowedFileTypes: allowedFileTypes || null
            },
            autoProceed: false // 不自动处理
        })

        // 监听文件添加
        uppy.on('file-added', (file) => {
            const currentFiles = uppy.getFiles()
            setFiles(currentFiles)
            if (onFilesAdded) {
                onFilesAdded(currentFiles)
            }
        })

        // 监听文件移除
        uppy.on('file-removed', (file) => {
            const currentFiles = uppy.getFiles()
            setFiles(currentFiles)
            if (onFilesAdded) {
                onFilesAdded(currentFiles)
            }
        })

        uppyRef.current = uppy

        return () => {
            uppy.close()
        }
    }, [maxFiles, allowedFileTypes, onFilesAdded])

    // 处理文件选择
    const handleFileSelect = useCallback((event) => {
        const selectedFiles = Array.from(event.target.files || [])
        if (uppyRef.current && selectedFiles.length > 0) {
            selectedFiles.forEach((file) => {
                uppyRef.current.addFile({
                    source: 'Local',
                    name: file.name,
                    type: file.type,
                    data: file
                })
            })
        }
        // 重置 input 值,允许重复选择同一文件
        if (inputRef.current) {
            inputRef.current.value = ''
        }
    }, [])

    // UI 渲染:显示已选文件列表和上传按钮
    return (
        <div className={`relative ${className}`}>
            <input ref={inputRef} type='file' multiple style={{ display: 'none' }} onChange={handleFileSelect} />
            {/* 文件列表和上传按钮 UI */}
        </div>
    )
}

特点

  • ✅ 使用 @uppy/core 进行文件管理
  • ✅ 无头样式设计,UI 完全自定义
  • ✅ 仅负责文件选择,不处理上传
  • ✅ 支持多文件选择
  • ✅ 支持文件类型限制

FileUploadButtonEnhanced 实现详解

文件位置packages/ui/FileUploadButtonEnhanced.jsx

这是基于 TUS 协议的增强版上传组件,完整实现代码解析:

1. 导入依赖
jsx 复制代码
import Uppy from '@uppy/core' // Uppy 核心库
import Tus from '@uppy/tus' // TUS 协议插件
import { useRef, useEffect, useState, useCallback, useMemo } from 'react'
2. 初始化 Uppy 实例
jsx 复制代码
useEffect(() => {
    // 创建 Uppy 实例
    const uppy = new Uppy({
        restrictions: {
            maxNumberOfFiles: maxFiles, // 最大文件数
            maxFileSize: maxFileSize, // 最大文件大小
            allowedFileTypes: stableAllowedFileTypes || null // 允许的文件类型
        },
        autoProceed: false, // 手动控制上传
        allowMultipleUploadBatches: true // 允许多批次上传
    })

    // 配置 TUS 插件
    uppy.use(Tus, {
        endpoint: uploadUrl, // TUS 服务器端点
        chunkSize: chunkSize, // 分块大小(建议值)
        retryDelays: [0, 1000, 3000, 5000], // 重试延迟(毫秒)
        maxConcurrentUploads: 2, // 最大并发上传数
        removeFingerprintOnSuccess: true, // 成功后清理缓存
        storage: window.localStorage, // 存储上传 URL(支持断点续传)
        storageName: 'uppy-tus-urls' // 存储键名
    })

    // 事件监听...
}, [maxFiles, maxFileSize, uploadUrl, chunkSize, stableAllowedFileTypes])
3. 事件监听
jsx 复制代码
// 文件添加事件 - 自动开始上传
uppy.on('file-added', (_file) => {
    const currentFiles = uppy.getFiles()
    setFiles(currentFiles)
    if (callbacksRef.current.onFilesAdded) {
        callbacksRef.current.onFilesAdded(currentFiles)
    }
    // 文件添加后自动开始上传
    if (uppyRef.current) {
        setTimeout(() => {
            uppyRef.current.upload()
        }, 100)
    }
})

// 上传进度事件
uppy.on('upload-progress', (file, progress) => {
    setUploadingFiles((prev) => {
        const next = new Map(prev)
        next.set(file.id, {
            progress: progress.bytesUploaded / progress.bytesTotal,
            bytesUploaded: progress.bytesUploaded,
            bytesTotal: progress.bytesTotal
        })
        return next
    })
    if (callbacksRef.current.onUploadProgress) {
        callbacksRef.current.onUploadProgress(file, progress)
    }
})

// 上传成功事件
uppy.on('upload-success', (file, response) => {
    setUploadingFiles((prev) => {
        const next = new Map(prev)
        next.set(file.id, { ...next.get(file.id), status: 'success' })
        return next
    })
    if (callbacksRef.current.onUploadComplete) {
        callbacksRef.current.onUploadComplete(file, response)
    }
})

// 上传错误事件
uppy.on('upload-error', (file, error, response) => {
    setUploadingFiles((prev) => {
        const next = new Map(prev)
        next.set(file.id, { ...next.get(file.id), status: 'error', error: error.message })
        return next
    })
    if (callbacksRef.current.onUploadError) {
        callbacksRef.current.onUploadError(file, error, response)
    }
})
4. 性能优化策略
jsx 复制代码
// 使用 ref 存储回调函数,避免在依赖数组中包含它们
const callbacksRef = useRef({ onFilesAdded, onUploadProgress, onUploadComplete, onUploadError })
useEffect(() => {
    callbacksRef.current = { onFilesAdded, onUploadProgress, onUploadComplete, onUploadError }
}, [onFilesAdded, onUploadProgress, onUploadComplete, onUploadError])

// 稳定 allowedFileTypes,避免数组引用变化导致重新创建 uppy 实例
const allowedFileTypesStr = allowedFileTypes ? allowedFileTypes.join(',') : ''
const stableAllowedFileTypes = useMemo(() => allowedFileTypes, [allowedFileTypesStr])

优化说明

  • 使用 useRef 存储回调函数,避免 Uppy 实例因回调函数引用变化而重新创建
  • 使用 useMemo 稳定 allowedFileTypes 数组引用,减少不必要的重新渲染
5. UI 渲染

组件提供完整的 UI,包括:

  • 文件列表显示
  • 上传进度条
  • 成功/错误状态图标
  • 重试按钮
  • 移除文件按钮
jsx 复制代码
{files.map((file) => {
    const uploadStatus = uploadingFiles.get(file.id)
    return (
        <div key={file.id} className={/* 根据状态设置样式 */}>
            <span>{file.name}</span>
            {uploadStatus?.status === 'uploading' && (
                <div className='progress-bar'>
                    <div style={{ width: `${uploadStatus.progress * 100}%` }} />
                </div>
            )}
            {uploadStatus?.status === 'success' && <CheckCircle2 />}
            {uploadStatus?.status === 'error' && (
                <button onClick={() => handleUpload(file.id)}>重试</button>
            )}
        </div>
    )
})}

组件 API

jsx 复制代码
<FileUploadButtonEnhanced
    onFilesAdded={(files) => {}}
    onUploadProgress={(file, progress) => {}}
    onUploadComplete={(file, response) => {}}
    onUploadError={(file, error) => {}}
    disabled={false}
    maxFiles={5}
    maxFileSize={100 * 1024 * 1024} // 100MB
    allowedFileTypes={['.json', '.txt']}
    uploadUrl='/api/v1/tus/upload'
    chunkSize={50 * 1024} // 50KB 分块
    className=''
/>

主要配置参数

参数 类型 默认值 说明
uploadUrl string /api/v1/tus/upload TUS 上传端点
chunkSize number 50 * 1024 分块大小(字节),建议值,实际分块取决于文件大小
maxFiles number 5 最大文件数量
maxFileSize number 100 * 1024 * 1024 最大文件大小(100MB)
allowedFileTypes string[] null 允许的文件类型(MIME 类型或扩展名)
disabled boolean false 是否禁用上传
onFilesAdded function - 文件添加回调
onUploadProgress function - 上传进度回调
onUploadComplete function - 上传完成回调
onUploadError function - 上传错误回调

TUS 插件配置

javascript 复制代码
uppy.use(Tus, {
    endpoint: uploadUrl, // TUS 服务器端点
    chunkSize: chunkSize, // 分块大小(建议值)
    retryDelays: [0, 1000, 3000, 5000], // 重试延迟(毫秒)
    maxConcurrentUploads: 2, // 最大并发上传数
    removeFingerprintOnSuccess: true, // 成功后清理缓存
    storage: window.localStorage, // 存储上传 URL(支持断点续传)
    storageName: 'uppy-tus-urls' // 存储键名
})

性能优化

组件使用了以下优化策略:

  1. 使用 useRef 存储回调函数 :避免在 useEffect 依赖数组中包含回调,减少不必要的重新创建
  2. 使用 useMemo 稳定 allowedFileTypes:避免数组引用变化导致 Uppy 实例重新创建
  3. 动态导入优化:TUS 插件按需加载(已改为直接导入)

后端实现

依赖安装

后端依赖已包含在 packages/server/package.json 中:

json 复制代码
{
    "@tus/file-store": "^1.5.1", // TUS 文件存储插件,将文件存储到本地文件系统
    "@tus/server": "^1.10.2" // TUS 协议服务器核心实现
}

安装命令

bash 复制代码
cd packages/server
pnpm install @tus/server @tus/file-store

路由注册

文件位置packages/server/src/routes/index.ts

TUS 路由在主路由文件中注册:

typescript 复制代码
import tusRouter from './tus'

// 注册 TUS 路由
router.use('/tus', tusRouter)

完整路由路径/api/v1/tus/upload/api/v1/tus/upload/*

核心实现详解

文件位置packages/server/src/routes/tus/index.ts

1. 导入依赖
typescript 复制代码
import express, { Request, Response } from 'express'
import { Server } from '@tus/server' // TUS 协议服务器
import { FileStore } from '@tus/file-store' // 文件存储实现
import path from 'path'
import { getUploadPath } from '../../utils' // 获取上传路径的工具函数
import fs from 'fs'
2. 创建上传目录
typescript 复制代码
const router = express.Router()

// 确保上传目录存在
const uploadDir = path.join(getUploadPath(), 'tus')
if (!fs.existsSync(uploadDir)) {
    fs.mkdirSync(uploadDir, { recursive: true })
}

说明

  • getUploadPath() 返回上传根目录(默认:~/.agentfactory/uploads
  • TUS 文件存储在 {uploadPath}/tus/ 子目录中
  • 使用 recursive: true 确保父目录不存在时也能创建
3. 创建文件存储(DataStore)
typescript 复制代码
// 创建 TUS 数据存储(使用 FileStore 存储到本地文件系统)
const datastore = new FileStore({
    directory: uploadDir // 存储目录路径
})

FileStore 说明

  • @tus/file-store@tus/server 的存储插件
  • 将上传的文件存储到本地文件系统
  • 每个上传会生成两个文件:
    • {upload-id}:实际文件内容
    • {upload-id}.json:文件元数据(文件名、大小、MIME 类型等)
4. 创建 TUS 服务器实例
typescript 复制代码
const tusServer = new Server({
    path: '/api/v1/tus', // TUS 协议路径前缀
    datastore: datastore, // 文件存储实例
    respectForwardedHeaders: true, // 允许跨域,尊重代理头
    generateUrl: (req, { id, host, proto }) => {
        // 生成客户端可访问的上传 URL
        // 策略:优先使用 Origin 头(前端请求会带这个)
        const origin = req.headers?.['origin']
        if (origin && typeof origin === 'string') {
            // Origin 头已经是完整的 URL(如 http://localhost:8081)
            // 直接拼接路径即可
            return `${origin}/api/v1/tus/upload/${id}`
        }
        // 如果 Origin 头不存在,使用传入的 host 和 proto(最后的回退)
        return `${proto}://${host}/api/v1/tus/upload/${id}`
    }
})

Server 配置说明

配置项 说明
path TUS 协议路径前缀,所有 TUS 请求都会匹配这个路径
datastore 文件存储实例,负责文件的保存和读取
respectForwardedHeaders 是否尊重代理头(x-forwarded-*),用于支持反向代理场景
generateUrl 自定义 URL 生成函数,用于生成客户端可访问的上传 URL

generateUrl 函数详解

  • 作用 :TUS 协议要求服务器在创建上传会话时返回 Location 头,包含后续 PATCH/HEAD 请求的 URL
  • 策略
    1. 优先使用请求的 Origin 头(前端请求会自动携带,如 http://localhost:8081
    2. 如果 Origin 不存在,使用传入的 hostproto 参数
  • 为什么重要:确保返回的 URL 是前端可以访问的地址,而不是后端内部地址
5. 注册路由处理器
typescript 复制代码
// TUS 协议端点 - 处理所有 TUS 相关请求
router.all('/upload', (req: Request, res: Response) => {
    tusServer.handle(req, res)
})

router.all('/upload/*', (req: Request, res: Response) => {
    tusServer.handle(req, res)
})

路由说明

  • router.all():处理所有 HTTP 方法(GET、POST、PATCH、HEAD、DELETE、OPTIONS)
  • /upload:创建上传会话的端点(POST 请求)
  • /upload/*:处理具体上传操作的端点(PATCH、HEAD 请求)
  • tusServer.handle():将请求交给 TUS 服务器处理

TUS 协议请求类型

方法 路径 说明
POST /api/v1/tus/upload 创建上传会话,返回 Location
PATCH /api/v1/tus/upload/{id} 上传文件块,包含 Upload-OffsetContent-Length
HEAD /api/v1/tus/upload/{id} 查询上传状态,返回 Upload-OffsetUpload-Length
DELETE /api/v1/tus/upload/{id} 删除上传(可选)
OPTIONS /api/v1/tus/upload CORS 预检请求
6. 导出路由
typescript 复制代码
export default router

使用的包详解

@tus/server

版本^1.10.2

作用:TUS 协议的核心服务器实现

主要功能

  • 实现 TUS 协议规范(v1.0.0)
  • 处理 POST、PATCH、HEAD、DELETE、OPTIONS 请求
  • 管理上传会话和状态
  • 支持扩展(如 generateUrl

官方文档https://github.com/tus/tus-node-server

@tus/file-store

版本^1.5.1

作用@tus/server 的文件存储插件,将文件存储到本地文件系统

主要功能

  • 将上传的文件块写入本地文件
  • 保存文件元数据(JSON 格式)
  • 支持查询上传状态
  • 支持删除上传

存储结构

复制代码
{tus-upload-dir}/
├── {upload-id}          # 实际文件内容(二进制)
└── {upload-id}.json     # 文件元数据(JSON)

元数据示例

json 复制代码
{
    "id": "aeea1f39fe5b69ae86c5351072559ac2",
    "upload_length": 134486,
    "upload_metadata": "filename dGVzdC50eHQ=",
    "upload_offset": 134486
}

文件存储路径

默认路径

复制代码
~/.agentfactory/uploads/tus/

自定义路径

如果设置了 BLOB_STORAGE_PATH 环境变量:

复制代码
${BLOB_STORAGE_PATH}/uploads/tus/

获取路径的函数

typescript 复制代码
// packages/server/src/utils/index.ts
export const getUploadPath = (): string => {
    return process.env.BLOB_STORAGE_PATH
        ? path.join(process.env.BLOB_STORAGE_PATH, 'uploads')
        : path.join(getUserHome(), '.agentfactory', 'uploads')
}

URL 生成策略

generateUrl 函数用于生成客户端可访问的上传 URL,策略如下:

  1. 优先使用 Origin :前端请求会自动携带 Origin 头(如 http://localhost:8081),直接使用确保返回前端可访问的地址
  2. 回退到传入参数 :如果 Origin 头不存在,使用 @tus/server 传入的 hostproto 参数

白名单配置

TUS 上传接口已加入白名单,无需权限验证:

文件位置packages/server/src/utils/constants.ts

typescript 复制代码
export const WHITELIST_URLS = [
    // ...
    '/api/v1/tus/upload' // TUS 文件上传接口(不需要权限验证)
]

文件存储路径

文件存储在以下路径:

  • 默认路径~/.agentfactory/uploads/tus/
  • 自定义路径 :如果设置了 BLOB_STORAGE_PATH 环境变量,则为 ${BLOB_STORAGE_PATH}/uploads/tus/

每个上传的文件会生成两个文件:

  • {upload-id}:实际文件内容
  • {upload-id}.json:文件元数据(文件名、大小等)

配置说明

环境变量

后端环境变量
变量名 说明 默认值
BLOB_STORAGE_PATH 文件存储根路径 ~/.agentfactory/uploads
FRONTEND_URL 前端地址(可选,用于 URL 生成) -

分块大小配置

注意chunkSize建议值,不是强制值。TUS 客户端会根据以下因素决定实际分块:

  1. 文件大小 :小文件(< chunkSize * 2)可能会一次性上传
  2. 网络状况:网络良好时可能会合并多个小块
  3. 客户端优化tus-js-client 内部会优化分块策略

示例

  • 设置 chunkSize = 50KB,上传 131KB 文件 → 可能一次性上传(优化行为)
  • 设置 chunkSize = 50KB,上传 10MB 文件 → 会按 50KB 分块上传

使用示例

基础使用

jsx 复制代码
import FileUploadButtonEnhanced from './FileUploadButtonEnhanced'

function MyComponent() {
    const [uploadedFiles, setUploadedFiles] = useState([])

    return (
        <FileUploadButtonEnhanced
            onFilesAdded={setUploadedFiles}
            onUploadProgress={(file, progress) => {
                console.log(`上传进度: ${progress.bytesUploaded}/${progress.bytesTotal}`)
            }}
            onUploadComplete={(file, response) => {
                console.log('上传完成:', file.name)
            }}
            onUploadError={(file, error) => {
                console.error('上传失败:', error.message)
            }}
            maxFiles={5}
            maxFileSize={100 * 1024 * 1024}
            uploadUrl='/api/v1/tus/upload'
            chunkSize={5 * 1024 * 1024} // 5MB 分块
        />
    )
}

在 NatureWorkFlowDialog 中使用

jsx 复制代码
<FileUploadButtonEnhanced
    onFilesAdded={setUploadedFiles}
    onUploadProgress={(_file, _progress) => {
        // 上传进度处理(可选)
    }}
    onUploadComplete={(_file, _response) => {
        console.log('上传完成:', _file, _response)
    }}
    onUploadError={(file, error) => {
        enqueueSnackbar({
            message: `文件 ${file.name} 上传失败: ${error.message}`,
            options: { key: Date.now() + Math.random(), variant: 'error', autoHideDuration: 3000 }
        })
    }}
    disabled={loading}
    maxFiles={5}
    maxFileSize={100 * 1024 * 1024} // 100MB
    uploadUrl='/api/v1/tus/upload'
    chunkSize={5 * 1024 * 1024} // 5MB 分块
/>

前后端联调指南

1. 启动服务

启动后端服务
bash 复制代码
cd packages/server
pnpm dev
# 或
pnpm start

后端服务默认运行在 http://localhost:3098

启动前端服务
bash 复制代码
cd packages/ui
pnpm dev
# 或
pnpm start

前端服务默认运行在 http://localhost:8081

2. 验证后端 TUS 服务

检查路由注册

确保 TUS 路由已注册:

typescript 复制代码
// packages/server/src/routes/index.ts
import tusRouter from './tus'
router.use('/tus', tusRouter)
测试 TUS 端点

使用 curl 测试 TUS 服务:

bash 复制代码
# 创建上传会话(POST)
curl -X POST http://localhost:3098/api/v1/tus/upload \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: 1024" \
  -H "Upload-Metadata: filename dGVzdC50eHQ=" \
  -v

# 预期响应:
# HTTP/1.1 201 Created
# Location: http://localhost:8081/api/v1/tus/upload/{upload-id}
# Upload-Offset: 0

3. 验证前端组件

检查依赖

确保前端依赖已安装:

bash 复制代码
cd packages/ui
pnpm install
检查组件导入

确保 FileUploadButtonEnhanced 组件可以正常导入和使用。

4. 联调测试步骤

步骤 1:上传小文件(< 100KB)
  1. 打开前端页面
  2. 点击上传按钮
  3. 选择一个小于 100KB 的文件
  4. 观察上传行为:
    • 可能一次性上传(优化行为)
    • 或按 chunkSize 分块上传

预期结果

  • 文件成功上传
  • 控制台显示上传进度
  • 文件存储在 ~/.agentfactory/uploads/tus/
步骤 2:上传大文件(> 5MB)
  1. 选择一个大于 5MB 的文件
  2. 开始上传
  3. 观察网络请求:
    • 应该看到多个 PATCH 请求
    • 每个请求的 content-length 约为 chunkSize
    • upload-offset 递增

预期结果

  • 文件按 chunkSize 分块上传
  • 每个分块请求的 content-length 约为配置的分块大小
  • 上传完成后文件完整
步骤 3:测试断点续传
  1. 开始上传一个大文件
  2. 在上传过程中断开网络(或关闭浏览器标签页)
  3. 重新连接网络(或重新打开页面)
  4. 重新选择同一个文件上传

预期结果

  • 上传从中断点继续
  • 不会重新上传已上传的部分
  • 最终文件完整
步骤 4:测试错误处理
  1. 上传一个文件
  2. 在上传过程中停止后端服务
  3. 观察前端行为

预期结果

  • 前端显示错误信息
  • 自动重试(根据 retryDelays 配置)
  • 重试失败后调用 onUploadError 回调

5. 调试技巧

查看网络请求

打开浏览器开发者工具 → Network 标签:

  1. POST 请求:创建上传会话

    • URL: /api/v1/tus/upload
    • Headers: Tus-Resumable: 1.0.0, Upload-Length: {文件大小}
    • Response: Location 头包含上传 URL
  2. PATCH 请求:分块上传

    • URL: /api/v1/tus/upload/{upload-id}
    • Headers: Upload-Offset: {已上传字节数}, Content-Length: {当前块大小}
    • Response: Upload-Offset: {新的已上传字节数}
  3. HEAD 请求:查询上传状态

    • URL: /api/v1/tus/upload/{upload-id}
    • Response: Upload-Offset: {已上传字节数}, Upload-Length: {总大小}
查看服务器日志

后端日志会显示 TUS 请求信息:

复制代码
[info] PATCH /api/v1/tus/upload/{upload-id}
[info] request: { headers: { 'upload-offset': '102400', 'content-length': '5242880' } }
查看存储文件
bash 复制代码
# 查看上传目录
ls -lh ~/.agentfactory/uploads/tus/

# 查看文件元数据
cat ~/.agentfactory/uploads/tus/{upload-id}.json

常见问题

Q1: 为什么小文件没有分块上传?

A : chunkSize 是建议值,不是强制值。TUS 客户端会根据文件大小和网络状况优化分块策略。对于小文件(< chunkSize * 2),可能会一次性上传以提高效率。这是正常行为。

Q2: 上传失败后如何重试?

A : TUS 客户端会自动重试,重试延迟由 retryDelays 配置(默认:[0, 1000, 3000, 5000] 毫秒)。如果所有重试都失败,会调用 onUploadError 回调。

Q3: 如何实现断点续传?

A : 断点续传已自动启用。上传 URL 会存储在 localStorage 中(键名:uppy-tus-urls)。重新上传同一文件时,客户端会先发送 HEAD 请求查询已上传的字节数,然后从中断点继续。

Q4: 上传的文件存储在哪里?

A : 默认存储在 ~/.agentfactory/uploads/tus/。可以通过设置 BLOB_STORAGE_PATH 环境变量自定义存储路径。

Q5: 为什么返回的 URL 是前端地址而不是后端地址?

A : 这是为了确保客户端可以访问返回的 URL。generateUrl 函数会优先使用请求的 Origin 头(前端地址),确保返回的 URL 是前端可访问的。

Q6: 如何配置认证?

A: 当前 TUS 路由已加入白名单,无需认证。如果需要认证,可以:

  1. WHITELIST_URLS 中移除 /api/v1/tus/upload
  2. FileUploadButtonEnhanced.jsx 中取消注释认证相关代码
  3. 在 TUS 路由中添加认证中间件

Q7: 如何调整分块大小?

A : 在组件中设置 chunkSize 属性:

jsx 复制代码
<FileUploadButtonEnhanced
    chunkSize={5 * 1024 * 1024} // 5MB 分块
/>

注意chunkSize 是建议值,实际分块可能因文件大小和网络状况而有所不同。

Q8: 上传进度不准确?

A: 上传进度是基于已上传的字节数计算的。由于 TUS 协议的特性,进度更新可能有延迟。如果进度长时间不更新,检查网络连接和服务器日志。

Q9: 如何限制文件类型?

A : 使用 allowedFileTypes 属性:

jsx 复制代码
<FileUploadButtonEnhanced allowedFileTypes={['.json', '.txt', '.pdf']} />

Q10: 如何查看上传的文件?

A: 上传的文件存储在服务器本地文件系统。可以通过以下方式查看:

bash 复制代码
# 列出所有上传的文件
ls -lh ~/.agentfactory/uploads/tus/

# 查看文件元数据
cat ~/.agentfactory/uploads/tus/{upload-id}.json

相关资源


更新日志

2026-01-30

  • ✅ 实现基于 TUS 协议的大文件上传功能
  • ✅ 支持客户端分块上传和断点续传
  • ✅ 优化 URL 生成策略,使用动态 Origin 头
  • ✅ 移除硬编码端口,使用动态配置
  • ✅ 添加白名单配置,无需权限验证
  • ✅ 实现上传进度监控和错误处理

技术支持

如有问题,请查看:

  1. 浏览器控制台错误信息
  2. 服务器日志
  3. 网络请求详情(开发者工具 → Network)

或联系开发团队。

相关推荐
2501_944525548 小时前
Flutter for OpenHarmony 个人理财管理App实战 - 预算详情页面
android·开发语言·前端·javascript·flutter·ecmascript
打小就很皮...8 小时前
《在 React/Vue 项目中引入 Supademo 实现交互式新手指引》
前端·supademo·新手指引
C澒8 小时前
系统初始化成功率下降排查实践
前端·安全·运维开发
C澒9 小时前
面单打印服务的监控检查事项
前端·后端·安全·运维开发·交通物流
pas1369 小时前
39-mini-vue 实现解析 text 功能
前端·javascript·vue.js
qq_532453539 小时前
使用 GaussianSplats3D 在 Vue 3 中构建交互式 3D 高斯点云查看器
前端·vue.js·3d
Swift社区9 小时前
Flutter 路由系统,对比 RN / Web / iOS 有什么本质不同?
前端·flutter·ios
雾眠气泡水@10 小时前
前端:解决同一张图片由于页面大小不统一导致图片模糊
前端
开发者小天10 小时前
python中计算平均值
开发语言·前端·python
我谈山美,我说你媚10 小时前
qiankun微前端 若依vue2主应用与vue2主应用
前端