diff --git a/app/api/materials/upload/refresh/route.ts b/app/api/materials/upload/refresh/route.ts new file mode 100644 index 0000000..f3e7005 --- /dev/null +++ b/app/api/materials/upload/refresh/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { refreshUploadSession } from '@/lib/upload-sessions' + +// Configure route +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * 刷新上传会话API + * 用于会话过期后恢复上传,不清空已上传的分片记录 + * + * POST /api/materials/upload/refresh + * Body: { uploadId: string } + * + * 返回: + * - success: true/false + * - uploadId: string + * - ttl: number (秒) + */ +export async function POST(req: NextRequest) { + console.log(`[RefreshSession] 收到会话刷新请求 at ${new Date().toISOString()}`) + + try { + // 验证用户身份 + const session = await getServerSession(authOptions) + if (!session) { + console.log(`[RefreshSession] 用户未登录`) + return NextResponse.json({ error: '请先登录' }, { status: 401 }) + } + + // 解析请求参数 + const { uploadId } = await req.json() + + if (!uploadId) { + console.log(`[RefreshSession] 缺少uploadId参数`) + return NextResponse.json( + { error: '缺少uploadId参数' }, + { status: 400 } + ) + } + + console.log(`[RefreshSession] 尝试刷新会话: ${uploadId}, 用户: ${(session.user as any).id}`) + + // 刷新会话TTL + const success = await refreshUploadSession(uploadId) + + if (!success) { + console.log(`[RefreshSession] 会话不存在或已过期: ${uploadId}`) + return NextResponse.json( + { error: '会话不存在或已过期' }, + { status: 404 } + ) + } + + console.log(`[RefreshSession] 会话刷新成功: ${uploadId}, TTL: 4小时`) + + return NextResponse.json({ + success: true, + uploadId, + ttl: 4 * 60 * 60 // 4小时(秒) + }) + } catch (error) { + console.error('[RefreshSession] 刷新会话失败:', error) + return NextResponse.json( + { error: '刷新会话失败' }, + { status: 500 } + ) + } +} diff --git a/docs/TUS协议迁移方案.md b/docs/TUS协议迁移方案.md new file mode 100644 index 0000000..14194b5 --- /dev/null +++ b/docs/TUS协议迁移方案.md @@ -0,0 +1,391 @@ +# TUS 协议迁移方案文档 + +> **状态**:已规划,暂不实施 +> **原因**:优先采用方案A(优化当前方案),TUS作为长期备选方案 + +--- + +## 📋 文档概述 + +本文档记录了 TUS 协议迁移的完整技术方案,作为未来系统升级的参考。 + +**核心原则**: +- ✅ 零新增环境变量(复用现有配置) +- ✅ 零新增 Docker 依赖 +- ✅ 渐进式迁移(保留回滚方案) +- ✅ 一次性成功,避免返工 + +--- + +## 🎯 TUS 协议简介 + +### 什么是 TUS + +**TUS(Tus Upload System)** 是一个基于 HTTP 的**可断点续传上传协议**,专门设计用于解决大文件上传的可靠性问题。 + +### 核心特性 + +| 特性 | 说明 | +|------|------| +| **断点续传** | 上传中断后可从断点继续,无需重新上传 | +| **开放标准** | RFC 协议规范,跨平台互操作性 | +| **可扩展性** | 支持多种扩展(校验、过期、终止等) | +| **标准化实现** | 官方提供多语言客户端和服务端库 | + +### 工作流程 + +``` +1. POST /files + → 创建上传会话 + → 返回: 201 Created + → Header: Location: /files/abc123 + +2. PATCH /files/abc123 + → Header: Upload-Offset: 0 + → Header: Content-Type: application/offset+octet-stream + → Body: [前50MB数据] + → 返回: 204 No Content + → Header: Upload-Offset: 52428800 (50MB) + +3. 重复步骤2直到上传完成 +``` + +--- + +## 📊 方案对比分析 + +### 方案A(已实施)vs 方案B(TUS) + +| 维度 | 方案A(优化当前方案) | 方案B(TUS协议) | +|------|---------------------|----------------| +| **开发周期** | ✅ 1天完成 | ⚠️ 5-7天 | +| **测试周期** | ✅ 已验证 | ⚠️ 3-5天 | +| **配置风险** | ✅ 零新增变量 | ⚠️ 需要5+个新变量 | +| **兼容性** | ✅ 100%向后兼容 | ⚠️ 需客户端升级 | +| **代码量** | ✅ 100行 | ⚠️ 需重写2000+行 | +| **维护成本** | ✅ 低(熟悉代码) | ⚠️ 中(新协议) | +| **标准化** | ❌ 自定义 | ✅ RFC标准 | +| **跨平台** | ❌ 需自己实现客户端 | ✅ 官方库支持 | + +### 性能对比 + +| 指标 | 方案A | 方案B | +|------|-------|-------| +| **分片大小** | 50MB | 可配置(推荐50MB) | +| **并发上传** | 2-3并发 | 原生支持 | +| **断点续传** | ✅ 自定义实现 | ✅ 协议级支持 | +| **请求数(1GB)** | 20个 | 20个 | +| **网络开销** | 低 | 低 | + +### 结论 + +**短期(当前)**:采用方案A +- ✅ 立即见效 +- ✅ 零风险 +- ✅ 已完成部署 + +**长期(未来)**:评估是否迁移到TUS +- 需要跨平台客户端支持时 +- 需要标准化协议时 +- 有充足开发和测试时间时 + +--- + +## 🚀 TUS 迁移方案(预留) + +### 技术选型 + +#### 服务端:@tus/server + +```json +{ + "dependencies": { + "@tus/server": "^2.3.0", + "@tus/file-store": "^1.0.0" + } +} +``` + +#### 客户端:tus-js-client + +```json +{ + "dependencies": { + "tus-js-client": "^3.0.0" + } +} +``` + +### 零配置迁移策略 + +#### 原则:复用现有配置 + +**❌ 不推荐**:新增大量环境变量 + +```env +# 不推荐这样做! +TUS_MAX_FILE_SIZE=107374182400 +TUS_UPLOAD_DIR=/app/uploads/tus +TUS_LOCK_STORAGE=redis +TUS_REDIS_PREFIX=tus:lock: +TUS_LOCK_TIMEOUT=3600 +``` + +**✅ 推荐**:复用现有配置 + +```typescript +// lib/tus-config.ts +export const TUS_CONFIG = { + // 复用现有UPLOAD_DIR + uploadDir: process.env.UPLOAD_DIR || './uploads', + + // 复用现有REDIS_URL + redisUrl: process.env.REDIS_URL, + + // 固定配置(无需环境变量) + maxSize: 100 * 1024 * 1024 * 1024, // 100GB + chunkSize: 50 * 1024 * 1024, // 50MB +} +``` + +### API 设计 + +#### 路由结构 + +``` +app/api/files/ +├── route.ts # TUS主端点 +└── [fileId]/ + └── route.ts # TUS文件操作 +``` + +#### 端点实现 + +```typescript +// app/api/files/route.ts +import { Server } from '@tus/server' +import { FileStore } from '@tus/file-store' + +// 创建TUS服务器实例(复用现有配置) +const tusServer = new Server({ + path: '/api/files', + datastore: new FileStore({ + directory: process.env.UPLOAD_DIR || './uploads', + }), +}) + +// 导出处理器 +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function POST(req: Request) { + return tusServer.handle(req) +} + +export async function OPTIONS(req: Request) { + return tusServer.handle(req) +} +``` + +### 渐进式迁移方案 + +#### 阶段1:双模式运行(1-2周) + +```typescript +// components/UploadModal.tsx +const USE_TUS = process.env.NEXT_PUBLIC_ENABLE_TUS === 'true' + +export function UploadModal() { + if (USE_TUS) { + return + } + return +} +``` + +#### 阶段2:灰度发布(2-4周) + +```typescript +// 10% 用户使用TUS +const USE_TUS = Math.random() < 0.1 + +// 监控错误率和性能 +// 逐步提升到50%、100% +``` + +#### 阶段3:全量切换(1-2月) + +- 所有用户使用TUS +- 保留旧代码1个月作为回滚 +- 确认稳定后删除旧代码 + +--- + +## ⚠️ 风险预防 + +### 避免Redis配置问题 + +#### 历史教训 + +```typescript +// ❌ 错误:独立配置Redis连接 +const tusRedis = new Redis(process.env.TUS_REDIS_URL, { + // 新的配置,可能出错 +}) + +// ✅ 正确:复用现有Redis实例 +import { redis } from '@/lib/upload-sessions-redis' + +export class TusDataStore { + constructor() { + this.redis = redis // 复用现有连接 + } +} +``` + +### 避免Docker配置问题 + +#### ❌ 不推荐:修改Docker配置 + +```yaml +# docker-compose.yml +services: + app: + environment: + # 新增5+个环境变量 + - TUS_MAX_FILE_SIZE=107374182400 + - TUS_UPLOAD_DIR=/app/uploads/tus + - TUS_LOCK_STORAGE=redis + # ... +``` + +#### ✅ 推荐:使用默认值 + +```typescript +// lib/tus-config.ts +export const TUS_CONFIG = { + // 使用现有环境变量或默认值 + uploadDir: process.env.UPLOAD_DIR || './uploads', + + // 硬编码配置(无需环境变量) + maxSize: 100 * 1024 * 1024 * 1024, // 100GB + chunkSize: 50 * 1024 * 1024, // 50MB + expirationPeriodInMs: 24 * 60 * 60 * 1000, // 24小时 +} +``` + +### 错误处理与降级 + +```typescript +// lib/tus-server.ts +export async function initTusServer() { + try { + const tusServer = new Server({ + datastore: new FileStore({ + directory: TUS_CONFIG.uploadDir, + }), + }) + + // 验证配置 + await validateTusConfig() + + return tusServer + } catch (error) { + console.error('[TUS] 初始化失败,降级到传统上传:', error) + return null // 降级到传统上传 + } +} +``` + +--- + +## 📋 实施清单 + +### 前置条件 + +- [ ] 充足的开发时间(1-2周) +- [ ] 充足的测试时间(1-2周) +- [ ] 跨平台客户端需求明确 +- [ ] 团队完成TUS协议培训 + +### 开发阶段 + +- [ ] 安装依赖包(@tus/server, tus-js-client) +- [ ] 实现TUS服务端(/api/files) +- [ ] 实现TUS客户端组件 +- [ ] 实现降级机制 + +### 测试阶段 + +- [ ] 单元测试(TUS协议) +- [ ] 集成测试(与现有系统) +- [ ] 性能测试(对比方案A) +- [ ] 压力测试(并发上传) +- [ ] 兼容性测试(各浏览器) + +### 部署阶段 + +- [ ] 影子环境部署 +- [ ] 10% 灰度发布 +- [ ] 50% 灰度发布 +- [ ] 100% 全量发布 +- [ ] 监控关键指标 + +--- + +## 🎯 触发条件 + +**建议在以下情况下考虑迁移**: + +1. **需要跨平台客户端** + - iOS、Android、桌面应用等 + - TUS有官方库支持 + +2. **需要标准化协议** + - 与第三方系统集成 + - 需要遵循RFC标准 + +3. **有充足的开发时间** + - 当前系统稳定运行 + - 有1-2个月的迁移窗口 + +4. **方案A遇到瓶颈** + - 当前方案无法满足需求 + - 需要TUS的特定特性 + +--- + +## 📚 参考资料 + +### 官方资源 + +- [TUS 协议官网](https://tus.io/) +- [TUS 协议规范](https://tus.io/protocols/resumable-upload.html) +- [TUS Node.js Server](https://github.com/tus/tus-node-server) +- [TUS JavaScript Client](https://github.com/tus/tus-js-client) + +### NPM 包 + +- `@tus/server`: 服务端实现 +- `@tus/file-store`: 文件系统存储 +- `@tus/s3-store`: AWS S3 存储 +- `tus-js-client`: 客户端库 + +### 社区实践 + +- [NextJS-TUS 示例项目](https://github.com/zacheryvaughn/NextJS-TUS) +- [Designing a High-Load File Upload System with TUS](https://ductruong.com/en/blog/2024/05/designing-a-high-load-file-upload-system-with-the-tus-protocol/) + +--- + +## 📝 版本历史 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v1.0 | 2026-01-27 | 初始版本,基于方案A的研究分析 | + +--- + +**最后更新**:2026-01-27 +**状态**:已规划,暂不实施 +**优先级**:低(方案A已满足需求) \ No newline at end of file diff --git a/lib/chunk-upload-config.ts b/lib/chunk-upload-config.ts index 096317f..012f879 100644 --- a/lib/chunk-upload-config.ts +++ b/lib/chunk-upload-config.ts @@ -1,80 +1,62 @@ /** * 分片上传配置 - * 根据网络环境自动选择最优分片大小 + * 统一使用50MB大分片,提升上传效率 * * 策略: - * - 内网用户:20MB分片(千兆网络,速度快,充分利用带宽) - * - 公网用户:10MB分片(平衡性能和稳定性,减少超时风险) + * - 统一使用50MB分片(内网公网一致) + * - 请求数减少80%(1GB文件从100个减少到20个) + * - TCP利用率更高(单个分片传输时间更长) * * 优势: - * - 内网用户:请求数减少75%,上传速度提升40%+ - * - 公网用户:请求数减少50%,超时风险可控(19.6秒 < 60秒) + * - 减少网络开销(请求数少) + * - 提升传输效率(TCP慢启动影响更小) + * - 降低服务器压力(并发处理更少) */ import { getNetworkStatus, initializeNetwork } from './network' /** - * 获取最优分片大小(根据网络环境) + * 获取最优分片大小(统一50MB) * @returns 分片大小(字节) */ export async function getOptimalChunkSize(): Promise { - // 获取当前网络状态 - let network = getNetworkStatus() + // 统一使用50MB分片(内网公网一致) + // 优势: + // 1. 减少请求数(1GB文件从100个减少到20个) + // 2. TCP利用率更高(单个分片传输时间更长) + // 3. 降低网络开销 + const chunkSize = 50 * 1024 * 1024 // 50MB - // 如果还没检测过,先初始化网络检测 - if (!network.lastChecked) { - try { - network = await initializeNetwork() - } catch (error) { - console.warn('[ChunkConfig] 网络检测失败,使用默认配置(公网10MB):', error) - return 10 * 1024 * 1024 - } - } - - // 根据网络环境返回最优分片大小 - const chunkSize = network.isInternal - ? 20 * 1024 * 1024 // 内网:20MB - : 10 * 1024 * 1024 // 公网:10MB - - console.log(`[ChunkConfig] 检测到${network.isInternal ? '内网' : '公网'}访问,使用 ${chunkSize / 1024 / 1024}MB 分片`) + console.log(`[ChunkConfig] 使用 50MB 分片(统一配置)`) return chunkSize } /** - * 同步获取分片大小(如果网络状态已缓存) - * 如果未检测过,返回默认值(公网10MB) + * 同步获取分片大小(统一50MB) * @returns 分片大小(字节) */ export function getChunkSizeSync(): number { - const network = getNetworkStatus() - - // 如果还没检测过,返回默认值(公网配置,更保守) - if (!network.lastChecked) { - console.warn('[ChunkConfig] 网络状态未检测,使用默认配置(公网10MB)') - return 10 * 1024 * 1024 - } - - return network.isInternal - ? 20 * 1024 * 1024 // 内网:20MB - : 10 * 1024 * 1024 // 公网:10MB + const chunkSize = 50 * 1024 * 1024 // 50MB + console.log(`[ChunkConfig] 使用 50MB 分片(同步)`) + return chunkSize } /** * 获取超时时间(根据分片大小动态调整) - * 假设最低速度 500KB/s,加上50%缓冲 + * 假设最低速度 300KB/s,加上100%缓冲(更保守) * @param chunkSize 分片大小(字节) * @returns 超时时间(毫秒) */ export function getChunkTimeout(chunkSize: number): number { - const minSpeed = 500 * 1024 // 500KB/s(最低速度) + const minSpeed = 300 * 1024 // 300KB/s(更保守的最低速度) const baseTimeout = (chunkSize / minSpeed) * 1000 - // 加上50%缓冲,确保不会轻易超时 - const timeout = Math.ceil(baseTimeout * 1.5) + // 加上100%缓冲,确保不会轻易超时 + const timeout = Math.ceil(baseTimeout * 2) - // 最小60秒,最大180秒 - return Math.max(60000, Math.min(180000, timeout)) + // 最小5分钟,最大10分钟 + return Math.max(300000, Math.min(600000, timeout)) } /** diff --git a/lib/chunk-upload.ts b/lib/chunk-upload.ts index 7b52d1c..1973878 100644 --- a/lib/chunk-upload.ts +++ b/lib/chunk-upload.ts @@ -504,48 +504,64 @@ export async function chunkUpload( throw error } - // 如果是会话过期,重新初始化上传会话 + // 如果是会话过期,尝试刷新会话(保留进度) if ((error as Error)?.message === 'UPLOAD_SESSION_EXPIRED') { - // 如果已经有其他worker在重新初始化,等待它完成 + // 如果已经有其他worker在处理,等待它完成 if (reinitPromise) { - console.log(`[ChunkUpload] 等待其他worker完成会话重新初始化...`) + console.log(`[ChunkUpload] 等待其他worker完成会话刷新...`) await reinitPromise - // 重新初始化完成后,继续上传当前chunk continue } - - // 标记会话过期,防止其他worker重复初始化 + + // 标记会话过期,防止其他worker重复处理 if (!sessionExpired) { sessionExpired = true - console.warn(`[ChunkUpload] 🔄 检测到会话过期,重新初始化上传会话...`) - + console.warn(`[ChunkUpload] 🔄 检测到会话过期,尝试刷新会话(保留已上传进度)...`) + reinitPromise = (async () => { try { - // 重新初始化 - state.uploadId = await initUpload(file.name, file.size, totalChunks) - // 清空已上传记录(因为新会话不知道之前的进度) - state.uploadedChunks.clear() - completedBytes = 0 - await saveUploadState(state) - // 将所有chunk重新加入队列 - pendingChunks.length = 0 - for (let i = 0; i < totalChunks; i++) { - pendingChunks.push(i) + // 尝试刷新会话(保留uploadId和已上传进度) + const refreshResponse = await fetch('/api/materials/upload/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uploadId: state.uploadId }) + }) + + const refreshResult = await refreshResponse.json() + + if (refreshResult.success) { + console.log(`[ChunkUpload] ✅ 会话刷新成功,保留 ${state.uploadedChunks.size} 个已上传分片,继续上传`) + sessionExpired = false + // 不清空 uploadedChunks,保留进度 + return true + } else { + console.warn(`[ChunkUpload] ⚠️ 会话刷新失败(${refreshResult.error}),重新初始化上传...`) + // 刷新失败,需要重新初始化 + state.uploadId = await initUpload(file.name, file.size, totalChunks) + state.uploadedChunks.clear() + completedBytes = 0 + await saveUploadState(state) + // 将所有chunk重新加入队列 + pendingChunks.length = 0 + for (let i = 0; i < totalChunks; i++) { + pendingChunks.push(i) + } + console.log(`[ChunkUpload] ✅ 会话重新初始化成功,重新开始上传`) + sessionExpired = false + return false } - console.log(`[ChunkUpload] ✅ 会话重新初始化成功,重新开始上传`) - sessionExpired = false - } catch (reinitError) { - console.error(`[ChunkUpload] ❌ 重新初始化失败:`, reinitError) - throw new Error('上传会话已过期且无法重新初始化,请刷新页面后重试') + } catch (refreshError) { + console.error(`[ChunkUpload] ❌ 会话刷新/重新初始化失败:`, refreshError) + throw new Error('上传会话已过期且无法恢复,请刷新页面后重试') } })() - + await reinitPromise reinitPromise = null // 不增加重试次数,直接继续 continue } else { - // 等待重新初始化完成 + // 等待刷新/重新初始化完成 if (reinitPromise) { await reinitPromise continue diff --git a/lib/upload-sessions-redis.ts b/lib/upload-sessions-redis.ts index 26c6c92..9afaa59 100644 --- a/lib/upload-sessions-redis.ts +++ b/lib/upload-sessions-redis.ts @@ -37,7 +37,7 @@ const redis = new Redis(redisUrl, { // Redis键前缀 const KEY_PREFIX = 'upload:session:' -const EXPIRE_TIME = 5 * 60 // 5分钟(秒),基础过期时间 - 防止旧会话堆积 +const EXPIRE_TIME = 4 * 60 * 60 // 4小时(秒),支持大文件上传(17.5分钟×13≈220GB) /** * 保存上传会话 @@ -185,6 +185,27 @@ export async function refreshSessionTTL(uploadId: string): Promise { } } +/** + * 刷新上传会话(保留已上传进度) + * 用于会话过期后恢复上传,不清空已上传的分片记录 + */ +export async function refreshUploadSession(uploadId: string): Promise { + try { + const key = `${KEY_PREFIX}${uploadId}` + const exists = await redis.expire(key, EXPIRE_TIME) + if (exists === 1) { + console.log(`[Redis] 刷新上传会话成功: ${uploadId}, TTL: ${EXPIRE_TIME}秒`) + return true + } else { + console.warn(`[Redis] 刷新上传会话失败,会话不存在: ${uploadId}`) + return false + } + } catch (error) { + console.error(`[Redis] 刷新上传会话失败: ${uploadId}`, error) + return false + } +} + /** * 设置已完成会话的过期时间为 1 分钟(立即清理) */ diff --git a/lib/upload-sessions.ts b/lib/upload-sessions.ts index 6c1afe0..6de509c 100644 --- a/lib/upload-sessions.ts +++ b/lib/upload-sessions.ts @@ -254,6 +254,32 @@ export async function updateChunk( ) } +/** + * 刷新上传会话(保留已上传进度) + * 用于会话过期后恢复上传,不清空已上传的分片记录 + */ +export async function refreshUploadSession(uploadId: string): Promise { + return withRedisFallback( + 'refreshUploadSession', + async () => { + return await redisModule!.refreshUploadSession(uploadId) + }, + async () => { + // 内存Map版本:更新过期时间 + const session = memorySessions.get(uploadId) + if (session) { + // 内存会话自动延长4小时 + const expireTime = 4 * 60 * 60 * 1000 // 4小时 + session.createdAt = Date.now() - expireTime + (4 * 60 * 60 * 1000) + console.log(`[MemoryMap] 刷新上传会话成功: ${uploadId}`) + return true + } + console.warn(`[MemoryMap] 刷新上传会话失败,会话不存在: ${uploadId}`) + return false + } + ) +} + // 导出内存Map(用于向后兼容,但不推荐直接使用) export const uploadSessions = memorySessions