主要改动: 1. 统一使用50MB分片(内网公网一致) - 请求数减少80%(1GB文件从100个减少到20个) - TCP利用率更高,网络开销更低 2. 延长会话TTL:5分钟 → 4小时 - 支持220GB大文件上传 - 解决大文件上传中途失败问题 3. 优化超时时间:支持50MB分片(5-10分钟) 4. 新增会话刷新机制(不清空进度) - 新增 /api/materials/upload/refresh API - 会话过期后保留已上传进度 5. 新增TUS协议迁移方案文档 - docs/TUS协议迁移方案.md Co-Authored-By: Claude <noreply@anthropic.com>
280 lines
7.1 KiB
TypeScript
280 lines
7.1 KiB
TypeScript
/**
|
||
* Redis实现 - 生产环境推荐
|
||
*
|
||
* 功能:
|
||
* - 数据持久化(RDB + AOF)
|
||
* - 支持多实例部署
|
||
* - 自动过期(TTL)
|
||
* - 高可用性
|
||
* - 跨服务共享
|
||
*/
|
||
|
||
import Redis from 'ioredis'
|
||
import type { UploadSession } from './upload-sessions'
|
||
|
||
// 初始化Redis连接
|
||
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'
|
||
const redis = new Redis(redisUrl, {
|
||
retryStrategy: (times) => {
|
||
// 重试策略:指数退避,最大延迟2秒,最多重试10次
|
||
if (times > 10) {
|
||
console.error('[Redis] 重试次数超过10次,停止重试')
|
||
return null // 停止重试
|
||
}
|
||
const delay = Math.min(times * 50, 2000)
|
||
return delay
|
||
},
|
||
maxRetriesPerRequest: 3,
|
||
enableReadyCheck: true,
|
||
enableOfflineQueue: true, // 启用离线队列,允许在连接恢复前缓存命令
|
||
lazyConnect: false, // 立即连接
|
||
connectTimeout: 5000, // 连接超时5秒
|
||
commandTimeout: 3000, // 命令超时3秒
|
||
// 自动重连配置
|
||
autoResubscribe: true,
|
||
autoResendUnfulfilledCommands: true,
|
||
})
|
||
|
||
// Redis键前缀
|
||
const KEY_PREFIX = 'upload:session:'
|
||
const EXPIRE_TIME = 4 * 60 * 60 // 4小时(秒),支持大文件上传(17.5分钟×13≈220GB)
|
||
|
||
/**
|
||
* 保存上传会话
|
||
*/
|
||
export async function saveUploadSession(
|
||
uploadId: string,
|
||
session: UploadSession
|
||
): Promise<void> {
|
||
try {
|
||
const key = `${KEY_PREFIX}${uploadId}`
|
||
|
||
// 将Map转换为数组以便序列化
|
||
const data = {
|
||
...session,
|
||
chunks: Array.from(session.chunks.entries()),
|
||
}
|
||
|
||
// 使用SETEX设置键值并指定过期时间
|
||
await redis.setex(key, EXPIRE_TIME, JSON.stringify(data))
|
||
|
||
console.log(`[Redis] 保存上传会话: ${uploadId}, 过期时间: ${EXPIRE_TIME}秒`)
|
||
} catch (error) {
|
||
console.error(`[Redis] 保存上传会话失败: ${uploadId}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取上传会话
|
||
*/
|
||
export async function getUploadSession(
|
||
uploadId: string
|
||
): Promise<UploadSession | null> {
|
||
try {
|
||
const key = `${KEY_PREFIX}${uploadId}`
|
||
const data = await redis.get(key)
|
||
|
||
if (!data) {
|
||
return null
|
||
}
|
||
|
||
const session = JSON.parse(data)
|
||
|
||
// 将数组转换回Map
|
||
return {
|
||
...session,
|
||
chunks: new Map(session.chunks || []),
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Redis] 获取上传会话失败: ${uploadId}`, error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除上传会话
|
||
*/
|
||
export async function deleteUploadSession(uploadId: string): Promise<void> {
|
||
try {
|
||
const key = `${KEY_PREFIX}${uploadId}`
|
||
await redis.del(key)
|
||
console.log(`[Redis] 删除上传会话: ${uploadId}`)
|
||
} catch (error) {
|
||
console.error(`[Redis] 删除上传会话失败: ${uploadId}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新块信息
|
||
*/
|
||
export async function updateChunk(
|
||
uploadId: string,
|
||
chunkIndex: number,
|
||
chunkPath: string
|
||
): Promise<void> {
|
||
try {
|
||
const session = await getUploadSession(uploadId)
|
||
if (!session) {
|
||
throw new Error(`上传会话不存在: ${uploadId}`)
|
||
}
|
||
|
||
session.chunks.set(chunkIndex, chunkPath)
|
||
await saveUploadSession(uploadId, session)
|
||
|
||
console.log(`[Redis] 更新块信息: ${uploadId}, 块 ${chunkIndex}`)
|
||
} catch (error) {
|
||
console.error(`[Redis] 更新块信息失败: ${uploadId}, 块 ${chunkIndex}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查会话是否存在
|
||
*/
|
||
export async function hasUploadSession(uploadId: string): Promise<boolean> {
|
||
try {
|
||
const key = `${KEY_PREFIX}${uploadId}`
|
||
const exists = await redis.exists(key)
|
||
return exists === 1
|
||
} catch (error) {
|
||
console.error(`[Redis] 检查会话是否存在失败: ${uploadId}`, error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取会话的剩余过期时间
|
||
*/
|
||
export async function getSessionTTL(uploadId: string): Promise<number> {
|
||
try {
|
||
const key = `${KEY_PREFIX}${uploadId}`
|
||
const ttl = await redis.ttl(key)
|
||
return ttl
|
||
} catch (error) {
|
||
console.error(`[Redis] 获取会话TTL失败: ${uploadId}`, error)
|
||
return -2 // -2表示键不存在
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取所有活跃会话数量
|
||
*/
|
||
export async function getActiveSessionsCount(): Promise<number> {
|
||
try {
|
||
const keys = await redis.keys(`${KEY_PREFIX}*`)
|
||
return keys.length
|
||
} catch (error) {
|
||
console.error('[Redis] 获取活跃会话数量失败', error)
|
||
return 0
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 刷新会话过期时间
|
||
*/
|
||
export async function refreshSessionTTL(uploadId: string): Promise<void> {
|
||
try {
|
||
const key = `${KEY_PREFIX}${uploadId}`
|
||
await redis.expire(key, EXPIRE_TIME)
|
||
console.log(`[Redis] 刷新会话过期时间: ${uploadId}`)
|
||
} catch (error) {
|
||
console.error(`[Redis] 刷新会话过期时间失败: ${uploadId}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 刷新上传会话(保留已上传进度)
|
||
* 用于会话过期后恢复上传,不清空已上传的分片记录
|
||
*/
|
||
export async function refreshUploadSession(uploadId: string): Promise<boolean> {
|
||
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 分钟(立即清理)
|
||
*/
|
||
export async function setCompletedSessionTTL(uploadId: string): Promise<void> {
|
||
try {
|
||
const key = `${KEY_PREFIX}${uploadId}`
|
||
const COMPLETED_TTL = 60 // 1分钟
|
||
await redis.expire(key, COMPLETED_TTL)
|
||
console.log(`[Redis] 已完成会话,TTL 缩短为 1 分钟: ${uploadId}`)
|
||
} catch (error) {
|
||
console.error(`[Redis] 设置已完成会话 TTL 失败: ${uploadId}`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取所有上传会话
|
||
*/
|
||
export async function getAllSessions(): Promise<Map<string, UploadSession>> {
|
||
try {
|
||
const keys = await redis.keys(`${KEY_PREFIX}*`)
|
||
const sessions = new Map<string, UploadSession>()
|
||
|
||
for (const key of keys) {
|
||
try {
|
||
const data = await redis.get(key)
|
||
if (data) {
|
||
const uploadId = key.replace(KEY_PREFIX, '')
|
||
const session = JSON.parse(data)
|
||
sessions.set(uploadId, {
|
||
...session,
|
||
chunks: new Map(session.chunks || []),
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Redis] 解析会话失败: ${key}`, error)
|
||
}
|
||
}
|
||
|
||
return sessions
|
||
} catch (error) {
|
||
console.error('[Redis] 获取所有会话失败', error)
|
||
return new Map()
|
||
}
|
||
}
|
||
|
||
// 错误处理
|
||
redis.on('error', (err) => {
|
||
console.error('[Redis] 连接错误:', err)
|
||
// 不抛出错误,允许应用继续运行(降级到内存Map)
|
||
})
|
||
|
||
redis.on('connect', () => {
|
||
console.log('[Redis] 连接成功')
|
||
})
|
||
|
||
redis.on('ready', () => {
|
||
console.log('[Redis] 准备就绪')
|
||
})
|
||
|
||
redis.on('close', () => {
|
||
console.warn('[Redis] 连接关闭')
|
||
})
|
||
|
||
redis.on('reconnecting', () => {
|
||
console.log('[Redis] 重新连接中...')
|
||
})
|
||
|
||
// 导出Redis实例(用于其他操作)
|
||
export { redis }
|
||
|