Compare commits

...

1 Commits

Author SHA1 Message Date
chenpei
72f0eff1bd fix: protect resumable uploads during Redis outages 2026-08-03 14:26:54 +08:00
22 changed files with 1331 additions and 197 deletions

View File

@ -0,0 +1,57 @@
/** @jest-environment node */
import { POST } from '@/app/api/materials/upload/chunk/route'
import { getAuthenticatedUploadUserId } from '@/lib/upload-auth'
import {
getUploadSession,
UploadSessionStoreUnavailableError,
} from '@/lib/upload-sessions'
jest.mock('@/lib/upload-auth', () => ({
getAuthenticatedUploadUserId: jest.fn(),
}))
jest.mock('@/lib/upload-sessions', () => {
class MockUploadSessionStoreUnavailableError extends Error {}
return {
getUploadSession: jest.fn(),
updateChunkWithStats: jest.fn(),
UploadSessionStoreUnavailableError: MockUploadSessionStoreUnavailableError,
}
})
function createRequest() {
const headers = new Map<string, string>([
['content-type', 'application/octet-stream'],
['x-upload-id', 'upload-active'],
['x-chunk-index', '0'],
['x-file-name', 'source.psd'],
['x-chunk-size', '4'],
])
return {
headers: { get: (name: string) => headers.get(name.toLowerCase()) || null },
nextUrl: { searchParams: new URLSearchParams() },
} as any
}
describe('/api/materials/upload/chunk Redis availability', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getAuthenticatedUploadUserId as jest.Mock).mockResolvedValue('user-1')
})
test('会话存储不可用时返回可重试 503,而不是 404', async () => {
;(getUploadSession as jest.Mock).mockRejectedValue(
new UploadSessionStoreUnavailableError('redis unavailable'),
)
const response = await POST(createRequest())
expect(response.status).toBe(503)
expect(response.headers.get('retry-after')).toBe('3')
await expect(response.json()).resolves.toEqual({
error: '上传会话服务暂时不可用,已保留上传进度,请稍后重试',
code: 'UPLOAD_SESSION_STORE_UNAVAILABLE',
})
})
})

View File

@ -4,7 +4,11 @@ import { mkdir, writeFile } from 'fs/promises'
import path from 'path'
import { NextRequest, NextResponse } from 'next/server'
import { getAuthenticatedUploadUserId } from '@/lib/upload-auth'
import { getUploadSession, updateChunkWithStats } from '@/lib/upload-sessions'
import {
getUploadSession,
updateChunkWithStats,
UploadSessionStoreUnavailableError,
} from '@/lib/upload-sessions'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@ -118,6 +122,15 @@ export async function POST(req: NextRequest) {
})
} catch (error) {
console.error('[UploadChunk] Error:', error)
if (error instanceof UploadSessionStoreUnavailableError) {
return NextResponse.json(
{
error: '上传会话服务暂时不可用,已保留上传进度,请稍后重试',
code: 'UPLOAD_SESSION_STORE_UNAVAILABLE',
},
{ status: 503, headers: { 'Retry-After': '3' } },
)
}
return NextResponse.json({ error: '上传块失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,37 @@
/** @jest-environment node */
import { GET } from '@/app/api/materials/upload/cleanup/route'
import { getServerSession } from 'next-auth'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({
hasSystemAdminPermission: jest.fn(() => true),
}))
jest.mock('@/lib/upload-sessions', () => ({
deleteOwnedUploadSession: jest.fn(),
getOwnedUploadSession: jest.fn(),
getUploadSessionPresence: jest.fn(),
isSafeUploadId: jest.fn(() => true),
resolveOwnedMergedUpload: jest.fn(),
}))
describe('/api/materials/upload/cleanup GET safety gate', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'admin-1', role: 'admin', isSuperAdmin: true },
})
})
test.each(['1', '7'])('拒绝小于 8 小时的 maxAge=%s,避免删除仍可恢复的上传', async (maxAge) => {
const response = await GET({
url: `http://localhost/api/materials/upload/cleanup?maxAge=${maxAge}`,
} as any)
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
error: 'maxAge 必须是 8 到 168 之间的整数',
})
})
})

View File

@ -6,10 +6,14 @@ import { authOptions } from '@/lib/auth'
import {
deleteOwnedUploadSession,
getOwnedUploadSession,
getUploadSession,
getUploadSessionPresence,
isSafeUploadId,
resolveOwnedMergedUpload,
} from '@/lib/upload-sessions'
import {
getLatestUploadActivityAt,
shouldCleanupUploadTempDirectory,
} from '@/lib/upload-temp-cleanup-policy'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import { promises as fs } from 'fs'
import path from 'path'
@ -118,7 +122,7 @@ async function cleanupUploadSession(uploadId: string, uploadDir: string): Promis
* GET - 清理过期的临时上传文件
* 参数:
* - uploadId: 可选,指定要清理的上传ID
* - expiredOnly: 可选,是否只清理过期的(默认true)
* - expiredOnly: 兼容旧调用;为保护上传数据,不再允许绕过“已过期”门槛
* - maxAge: 可选,最大年龄(小时),默认8小时
*/
export async function GET(req: NextRequest) {
@ -139,11 +143,11 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const uploadId = searchParams.get('uploadId')
const expiredOnly = searchParams.get('expiredOnly') !== 'false'
const requestedUnsafeCleanup = searchParams.get('expiredOnly') === 'false'
const maxAgeHours = Number(searchParams.get('maxAge') || '8')
const scanLimit = Number(searchParams.get('limit') || '100')
if (!Number.isInteger(maxAgeHours) || maxAgeHours < 1 || maxAgeHours > 168) {
return NextResponse.json({ error: 'maxAge 必须是 1 到 168 之间的整数' }, { status: 400 })
if (!Number.isInteger(maxAgeHours) || maxAgeHours < 8 || maxAgeHours > 168) {
return NextResponse.json({ error: 'maxAge 必须是 8 到 168 之间的整数' }, { status: 400 })
}
if (!Number.isInteger(scanLimit) || scanLimit < 1 || scanLimit > 500) {
return NextResponse.json({ error: 'limit 必须是 1 到 500 之间的整数' }, { status: 400 })
@ -169,81 +173,75 @@ export async function GET(req: NextRequest) {
let cleaned = 0
let totalFreedSpace = 0
let skippedActive = 0
let skippedUnavailable = 0
let sessionStoreUnavailable = false
const errors: string[] = []
if (requestedUnsafeCleanup) {
console.warn('[Cleanup] 已忽略 expiredOnly=false;批量清理始终要求目录过期且会话明确不存在')
}
if (uploadId) {
// 清理指定的上传ID
const result = await cleanupUploadSession(uploadId, uploadDir)
if (result.success) {
cleaned = result.deletedFiles
totalFreedSpace = result.freedSpace
} else {
errors.push(result.error || '清理失败')
const now = Date.now()
const tempDirs = uploadId
? [uploadId]
: (await fs.readdir(tempBaseDir)).sort().slice(0, scanLimit)
for (const dir of tempDirs) {
const uploadIdFromDir = dir
if (!isSafeUploadId(uploadIdFromDir)) {
continue
}
} else {
// 清理所有过期或无效的临时文件
const now = Date.now()
const tempDirs = (await fs.readdir(tempBaseDir)).slice(0, scanLimit)
const tempDir = path.join(tempBaseDir, dir)
for (const dir of tempDirs) {
const uploadIdFromDir = dir
if (!isSafeUploadId(uploadIdFromDir)) {
try {
const stat = await fs.stat(tempDir)
if (!stat.isDirectory()) {
continue
}
const tempDir = path.join(tempBaseDir, dir)
const metaPath = path.join(tempDir, 'meta.json')
let metadata: { createdAt?: unknown; lastActivityAt?: unknown } | null = null
try {
// 检查目录
const stat = await fs.stat(tempDir)
if (!stat.isDirectory()) {
continue
}
// 读取元数据文件(如果存在)
const metaPath = path.join(tempDir, 'meta.json')
let createdAt = stat.mtimeMs // 使用目录修改时间作为fallback
try {
const metaContent = await fs.readFile(metaPath, 'utf-8')
const meta = JSON.parse(metaContent)
if (meta.createdAt) {
createdAt = meta.createdAt
}
} catch {
// 元数据文件不存在,使用目录修改时间
}
// 检查是否过期
const age = now - createdAt
const isExpired = age > maxAge
// 检查会话是否存在
const session = await getUploadSession(uploadIdFromDir)
const sessionExists = !!session
// 决定是否清理
let shouldCleanup = false
if (expiredOnly) {
// 只清理过期的
shouldCleanup = isExpired
} else {
// 清理所有:过期的 或 会话不存在的
shouldCleanup = isExpired || !sessionExists
}
if (shouldCleanup) {
const result = await cleanupUploadSession(uploadIdFromDir, uploadDir)
if (result.success) {
cleaned += result.deletedFiles
totalFreedSpace += result.freedSpace
console.log(`[Cleanup] 清理上传会话: ${uploadIdFromDir}, 文件数: ${result.deletedFiles}, 释放空间: ${formatBytes(result.freedSpace)}`)
} else {
errors.push(`${uploadIdFromDir}: ${result.error}`)
}
}
} catch (err: any) {
errors.push(`${dir}: ${err.message || '读取失败'}`)
metadata = JSON.parse(await fs.readFile(metaPath, 'utf-8'))
} catch {
// 历史目录可能没有元数据,继续使用目录修改时间。
}
const lastActivityAt = getLatestUploadActivityAt(stat.mtimeMs, metadata)
const isExpired = now - lastActivityAt > maxAge
if (!isExpired) {
continue
}
if (sessionStoreUnavailable) {
skippedUnavailable++
continue
}
const presence = await getUploadSessionPresence(uploadIdFromDir)
if (presence === 'present') {
skippedActive++
continue
}
if (presence === 'unavailable') {
sessionStoreUnavailable = true
skippedUnavailable++
continue
}
if (!shouldCleanupUploadTempDirectory(isExpired, presence)) {
continue
}
const result = await cleanupUploadSession(uploadIdFromDir, uploadDir)
if (result.success) {
cleaned += result.deletedFiles
totalFreedSpace += result.freedSpace
console.log(`[Cleanup] 清理上传会话: ${uploadIdFromDir}, 文件数: ${result.deletedFiles}, 释放空间: ${formatBytes(result.freedSpace)}`)
} else {
errors.push(`${uploadIdFromDir}: ${result.error}`)
}
} catch (err: any) {
errors.push(`${dir}: ${err.message || '读取失败'}`)
}
}
@ -252,6 +250,8 @@ export async function GET(req: NextRequest) {
cleaned,
freedSpace: totalFreedSpace,
freedSpaceFormatted: formatBytes(totalFreedSpace),
skippedActive,
skippedUnavailable,
errors: errors.length > 0 ? errors : undefined
})
} catch (error: any) {

View File

@ -0,0 +1,47 @@
/** @jest-environment node */
import { POST } from '@/app/api/materials/upload/complete/route'
import { getServerSession } from 'next-auth'
import {
getUploadSession,
UploadSessionStoreUnavailableError,
} from '@/lib/upload-sessions'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/image-processor', () => ({ sanitizeFileName: (value: string) => value }))
jest.mock('@/lib/upload-sessions', () => {
class MockUploadSessionStoreUnavailableError extends Error {}
return {
getUploadSession: jest.fn(),
UploadSessionStoreUnavailableError: MockUploadSessionStoreUnavailableError,
}
})
describe('/api/materials/upload/complete Redis availability', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
})
test('会话存储不可用时返回可重试 503,而不是 404', async () => {
;(getUploadSession as jest.Mock).mockRejectedValue(
new UploadSessionStoreUnavailableError('redis unavailable'),
)
const request = {
json: jest.fn().mockResolvedValue({
uploadId: 'upload-active',
originalFileName: 'source.psd',
}),
} as any
const response = await POST(request)
expect(response.status).toBe(503)
expect(response.headers.get('retry-after')).toBe('3')
await expect(response.json()).resolves.toEqual({
error: '上传会话服务暂时不可用,已保留上传进度,请稍后重试',
code: 'UPLOAD_SESSION_STORE_UNAVAILABLE',
})
})
})

View File

@ -8,7 +8,10 @@ import path from 'path'
import fs from 'fs/promises'
import { createWriteStream, createReadStream } from 'fs'
import { pipeline } from 'stream/promises'
import { getUploadSession, deleteUploadSession } from '@/lib/upload-sessions'
import {
getUploadSession,
UploadSessionStoreUnavailableError,
} from '@/lib/upload-sessions'
import { sanitizeFileName } from '@/lib/image-processor'
export const runtime = 'nodejs'
@ -117,6 +120,15 @@ export async function POST(req: NextRequest) {
})
} catch (error) {
console.error('[UploadComplete] Error:', error)
if (error instanceof UploadSessionStoreUnavailableError) {
return NextResponse.json(
{
error: '上传会话服务暂时不可用,已保留上传进度,请稍后重试',
code: 'UPLOAD_SESSION_STORE_UNAVAILABLE',
},
{ status: 503, headers: { 'Retry-After': '3' } },
)
}
return NextResponse.json({ error: '合并分片失败' }, { status: 500 })
}
}

View File

@ -0,0 +1,85 @@
/** @jest-environment node */
import { POST } from '@/app/api/materials/upload/refresh/route'
import { getAuthenticatedUploadUserId } from '@/lib/upload-auth'
import {
refreshOwnedUploadSession,
UploadSessionStoreUnavailableError,
} from '@/lib/upload-sessions'
jest.mock('@/lib/upload-auth', () => ({
getAuthenticatedUploadUserId: jest.fn(),
}))
jest.mock('@/lib/upload-sessions', () => {
class MockUploadSessionStoreUnavailableError extends Error {
constructor(message = '上传会话服务暂时不可用') {
super(message)
this.name = 'UploadSessionStoreUnavailableError'
}
}
return {
isSafeUploadId: (uploadId: string) => /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(uploadId),
refreshOwnedUploadSession: jest.fn(),
UploadSessionStoreUnavailableError: MockUploadSessionStoreUnavailableError,
}
})
function createRequest(uploadId: unknown) {
return {
json: jest.fn().mockResolvedValue({ uploadId }),
} as any
}
describe('/api/materials/upload/refresh', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getAuthenticatedUploadUserId as jest.Mock).mockResolvedValue('user-1')
})
test('Redis 暂时不可用时返回 503 与可重试提示', async () => {
;(refreshOwnedUploadSession as jest.Mock).mockRejectedValue(
new UploadSessionStoreUnavailableError(),
)
const response = await POST(createRequest('upload-active'))
expect(response.status).toBe(503)
expect(response.headers.get('retry-after')).toBe('3')
await expect(response.json()).resolves.toEqual({
error: '上传会话服务暂时不可用,已保留上传进度,请稍后重试',
code: 'UPLOAD_SESSION_STORE_UNAVAILABLE',
})
})
test.each(['missing', 'forbidden'])('%s 只返回确认不存在的 404 语义', async (result) => {
;(refreshOwnedUploadSession as jest.Mock).mockResolvedValue(result)
const response = await POST(createRequest('upload-missing'))
expect(response.status).toBe(404)
await expect(response.json()).resolves.toEqual({ error: '会话不存在或已过期' })
})
test('合法且属于当前用户的会话刷新成功', async () => {
;(refreshOwnedUploadSession as jest.Mock).mockResolvedValue('refreshed')
const response = await POST(createRequest('upload-owned'))
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
success: true,
uploadId: 'upload-owned',
ttl: 4 * 60 * 60,
})
expect(refreshOwnedUploadSession).toHaveBeenCalledWith('upload-owned', 'user-1')
})
test('非法 uploadId 在访问会话存储前被拒绝', async () => {
const response = await POST(createRequest('../outside'))
expect(response.status).toBe(400)
expect(refreshOwnedUploadSession).not.toHaveBeenCalled()
})
})

View File

@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { refreshUploadSession } from '@/lib/upload-sessions'
import { getAuthenticatedUploadUserId } from '@/lib/upload-auth'
import {
isSafeUploadId,
refreshOwnedUploadSession,
UploadSessionStoreUnavailableError,
} from '@/lib/upload-sessions'
// Configure route
export const runtime = 'nodejs'
@ -24,8 +27,8 @@ export async function POST(req: NextRequest) {
try {
// 验证用户身份
const session = await getServerSession(authOptions)
if (!session) {
const userId = await getAuthenticatedUploadUserId(req)
if (!userId) {
console.log(`[RefreshSession] 用户未登录`)
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
@ -33,36 +36,49 @@ export async function POST(req: NextRequest) {
// 解析请求参数
const { uploadId } = await req.json()
if (!uploadId) {
if (typeof uploadId !== 'string' || !uploadId.trim()) {
console.log(`[RefreshSession] 缺少uploadId参数`)
return NextResponse.json(
{ error: '缺少uploadId参数' },
{ status: 400 }
)
}
const normalizedUploadId = uploadId.trim()
if (!isSafeUploadId(normalizedUploadId)) {
return NextResponse.json({ error: 'uploadId 格式无效' }, { status: 400 })
}
console.log(`[RefreshSession] 尝试刷新会话: ${uploadId}, 用户: ${(session.user as any).id}`)
console.log(`[RefreshSession] 尝试刷新会话: ${normalizedUploadId}, 用户: ${userId}`)
// 刷新会话TTL
const success = await refreshUploadSession(uploadId)
// 刷新会话TTL;Redis 键已过期时允许从当前用户的 meta.json 安全重建。
const result = await refreshOwnedUploadSession(normalizedUploadId, userId)
if (!success) {
console.log(`[RefreshSession] 会话不存在或已过期: ${uploadId}`)
if (result !== 'refreshed') {
console.log(`[RefreshSession] 会话不存在或不属于当前用户: ${normalizedUploadId}`)
return NextResponse.json(
{ error: '会话不存在或已过期' },
{ status: 404 }
)
}
console.log(`[RefreshSession] 会话刷新成功: ${uploadId}, TTL: 4小时`)
console.log(`[RefreshSession] 会话刷新成功: ${normalizedUploadId}, TTL: 4小时`)
return NextResponse.json({
success: true,
uploadId,
uploadId: normalizedUploadId,
ttl: 4 * 60 * 60 // 4小时(秒)
})
} catch (error) {
console.error('[RefreshSession] 刷新会话失败:', error)
if (error instanceof UploadSessionStoreUnavailableError) {
return NextResponse.json(
{
error: '上传会话服务暂时不可用,已保留上传进度,请稍后重试',
code: 'UPLOAD_SESSION_STORE_UNAVAILABLE',
},
{ status: 503, headers: { 'Retry-After': '3' } },
)
}
return NextResponse.json(
{ error: '刷新会话失败' },
{ status: 500 }

View File

@ -2,12 +2,31 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UploadModal from '@/components/UploadModal'
import {
submitFormData,
uploadSingleFile,
} from '@/components/upload/file-upload-handlers'
jest.mock('@/components/upload/file-upload-handlers', () => ({
...jest.requireActual('@/components/upload/file-upload-handlers'),
uploadSingleFile: jest.fn(),
submitFormData: jest.fn(),
}))
jest.mock('@/components/upload/image-input-handlers', () => ({
...jest.requireActual('@/components/upload/image-input-handlers'),
handlePreviewImageInput: jest.fn(async (
files: File[],
onSuccess: (processedFiles: File[], urls: string[]) => void,
) => onSuccess(files, files.map((file) => `blob:test-${file.name}`))),
}))
jest.mock('next/image', () => ({
__esModule: true,
default: (props: any) => {
const { priority, fill, unoptimized, sizes, ...rest } = props
return <img {...rest} />
const { priority, fill, unoptimized, sizes, alt = '', ...rest } = props
// eslint-disable-next-line @next/next/no-img-element
return <img alt={alt} {...rest} />
},
}))
@ -43,6 +62,9 @@ describe('UploadModal - 任务-其他', () => {
beforeEach(() => {
jest.clearAllMocks()
;(uploadSingleFile as jest.Mock).mockResolvedValue(null)
;(submitFormData as jest.Mock).mockResolvedValue(undefined)
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
@ -97,4 +119,75 @@ describe('UploadModal - 任务-其他', () => {
expect(await screen.findByText('task-source.zip')).toBeInTheDocument()
expect(screen.getByText(/readme\.txt/)).toBeInTheDocument()
})
async function prepareLargeTaskOtherUpload(container: HTMLElement) {
const user = userEvent.setup()
const materialTypeSelect = (await screen.findAllByRole('combobox'))[0]
await user.selectOptions(materialTypeSelect, '任务-其他')
await user.type(screen.getByLabelText('任务号 *'), 'TASK202601030184')
const previewInput = container.querySelector<HTMLInputElement>('input[accept="image/*"]')
expect(previewInput).not.toBeNull()
fireEvent.change(previewInput!, {
target: {
files: [new File(['preview'], 'preview.jpg', { type: 'image/jpeg' })],
},
})
await screen.findByAltText('预览图 1')
const sourceInput = container.querySelector<HTMLInputElement>('input[accept=".zip,.rar,.7z"]')
expect(sourceInput).not.toBeNull()
const sourceFile = new File(
[new Uint8Array(5 * 1024 * 1024 + 1)],
'task-source.zip',
{ type: 'application/zip' },
)
fireEvent.change(sourceInput!, { target: { files: [sourceFile] } })
await screen.findByText('task-source.zip')
return { user, sourceFile }
}
test('分片上传临时失败时不调用服务端清理,保留断点续传数据', async () => {
;(uploadSingleFile as jest.Mock).mockRejectedValue(
new Error('上传会话服务暂时不可用,已保留上传进度,请稍后重试'),
)
const { container } = render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
const { user } = await prepareLargeTaskOtherUpload(container)
await user.click(screen.getByRole('button', { name: '上传' }))
expect(await screen.findByText(/已保留上传进度/)).toBeInTheDocument()
expect(uploadSingleFile).toHaveBeenCalledTimes(1)
expect(submitFormData).not.toHaveBeenCalled()
expect(global.fetch).not.toHaveBeenCalledWith(
'/api/materials/upload/cleanup',
expect.anything(),
)
})
test('分片合并完成但表单提交失败时只清理一次已合并的服务端文件', async () => {
;(uploadSingleFile as jest.Mock).mockResolvedValue({
uploadId: 'upload-form-failed',
existingFilePath: '/uploads/temp/upload-form-failed/merged-task-source.zip',
})
;(submitFormData as jest.Mock).mockRejectedValue(new Error('保存素材记录失败'))
const { container } = render(<UploadModal onClose={jest.fn()} onSuccess={jest.fn()} />)
const { user } = await prepareLargeTaskOtherUpload(container)
await user.click(screen.getByRole('button', { name: '上传' }))
expect(await screen.findByText('保存素材记录失败')).toBeInTheDocument()
const cleanupCalls = (global.fetch as jest.Mock).mock.calls.filter(
([url]) => url === '/api/materials/upload/cleanup',
)
expect(cleanupCalls).toHaveLength(1)
expect(cleanupCalls[0][1]).toEqual(expect.objectContaining({
method: 'POST',
body: JSON.stringify({
uploadId: 'upload-form-failed',
existingFilePath: '/uploads/temp/upload-form-failed/merged-task-source.zip',
}),
}))
})
})

View File

@ -1662,17 +1662,9 @@ export default function UploadModal({ onClose, onSuccess }: UploadModalProps) {
} catch (error) {
console.error('Upload error:', error)
// 如果使用了分片上传,清理可能残留的 IndexedDB 状态
if (zipFile && zipFile.size >= CHUNK_THRESHOLD) {
try {
const { deleteUploadState, generateFileId } = await import('@/lib/chunk-upload')
const fileId = generateFileId(zipFile)
await deleteUploadState(fileId)
console.log('[UploadModal] 已清理上传状态:', fileId)
} catch (cleanupError) {
console.warn('[UploadModal] 清理上传状态失败(忽略):', cleanupError)
}
// chunkUpload 尚未返回时,不能把暂时的 Redis/网络故障当成失败清理;
// 它会自行处理明确过期或不可重试错误,其他情况保留断点续传状态。
if (zipFile && zipFile.size >= CHUNK_THRESHOLD && singleUploadServerStateRef.current) {
try {
const cleanupPayload = singleUploadServerStateRef.current
if (cleanupPayload?.uploadId || cleanupPayload?.existingFilePath) {

View File

@ -1,6 +1,6 @@
# 素材库未完成问题处理记录(2026-08-01)
> 当前代码基线:`origin/main@776c6bafce9a819fd141339d6a6991c47f15f6ca`
> 当前代码审查基线:`origin/main@7c609a5ca891e95e23b7e1ad3a7fd6cfa680fb21`;生产运行版本以每次只读验收记录为准。
> 记录原则:代码、生产运行状态和历史事件分开判断;没有完成真实验收的项目不标记为“已关闭”。
## 一、当前结论
@ -173,6 +173,27 @@
Edge 补充边界:真实 Edge 已能登录隔离站点并正常显示已上传预览,但 Codex 的 Edge 扩展未开启“允许访问文件 URL”,自动化工具因此无法把本地测试图片交给网页;这是测试工具权限,不是素材库返回的上传失败。用户此前已经用自己的 Edge 完成人工上传。生产发布后仍需再做一次真实业务账号的小批量上传、预览和下载验收,才能把本项标记为“生产已关闭”。
### 代码批次 2.2:Redis 故障期间的断点续传与临时目录数据安全(2026-08-03 已完成代码修复,待生产发布)
复核发现,上传会话查询此前会把 Redis 超时、断连或数据损坏伪装成“会话不存在”。应用启动时和每小时运行的临时目录清理任务可能因此删除仍在上传或仍可恢复的大文件;浏览器也可能把暂时故障当成真正过期,从头上传并清空本地断点。
本次处理:
1. 会话状态改为“存在 / 明确不存在 / 暂时无法确认”三种;只有目录已超过 8 小时且会话存储明确确认不存在时,自动清理和管理员批量清理才允许删除。
2. Redis 一旦不可用,本轮剩余目录全部跳过,避免逐个等待超时;管理员接口不再允许用 `expiredOnly=false` 或小于 8 小时的参数绕过安全门槛。
3. `chunk`、`complete` 和 `refresh` 接口把 Redis 暂时故障统一返回为可重试的 `503`,与真正不存在的 `404` 分开。
4. 合法的本地 `meta.json` 可在 Redis 键自然过期后恢复当前用户的会话;恢复入口同时校验目录、文件名、所有者、时间、文件大小和符号链接边界。
5. 浏览器只在明确收到 `404` 时重建上传;`503`、网络中断和完成阶段重试耗尽都会保留原 `uploadId`、已上传分片和 IndexedDB 断点。
6. 上传弹窗只有在分片已经合并成功、后续素材记录提交失败时才清理服务端合并文件;分片上传本身的临时故障不再被外层 catch 二次误删。
7. 非 Redis 模式的内存会话和目录年龄统一按“最后一次上传活动时间”判断,持续上传的超大文件不会只因创建时间较早而过期。
验证结果:
- Redis 故障、会话恢复、自动/管理员清理、三个上传接口、浏览器断点续传及真实上传弹窗边界均有直接回归测试。
- 前端验收代理与发布守门代理分别只读复核当前差异,结论均为 `GO`,没有数据误删阻断项。
- 代码尚未部署生产;本轮没有停止素材库、没有修改生产目录或 Redis,也没有执行任何数据迁移或删除。
- 部署后仍需在受控账号下验证一次“上传一部分后短暂断网/Redis 不可用,再继续上传”,并核对临时目录未被清理,才能标记为“生产已关闭”。
### 后续 P1
- 普通主上传和部分 ZIP 下载仍会把完整文件或最终压缩包放入内存。
@ -203,7 +224,8 @@ Edge 补充边界:真实 Edge 已能登录隔离站点并正常显示已上传
3. **代码批次 1**:P1-1/P1-3 已提交并推送,待合并和部署;上线后验收网络状态 GET、POST 405、历史图片/视频预览和 Range 播放。
4. **代码批次 2**:已提交并推送,待合并、部署及真实业务验收。
5. **代码批次 2.1**:已随 `62295a0` 提交并推送,Linux Docker 构建和 Chromium 大图上传验收通过;待生产门禁恢复后合并、部署,并补做真实业务账号验收。
6. **生产发布门禁**:先处理或等待 TrueNAS 可用内存恢复,禁止在当前不足 2GiB 余量下切换生产。
7. **代码批次 3**:继续处理上传/ZIP 内存、长期下载 Token、权限和数据库一致性。
6. **代码批次 2.2**:Redis 故障期间的上传数据保护已完成代码与独立门禁验证,待提交、合并、部署及生产断点续传验收。
7. **生产发布门禁**:先处理或等待 TrueNAS 可用内存恢复,禁止在当前不足 2GiB 余量下切换生产。
8. **代码批次 3**:继续处理上传/ZIP 内存、长期下载 Token、权限和数据库一致性。
当前可以确认“旧影子漏洞入口已不可达”,但在旧凭据失效状态和访问日志核对完成前,不能把历史凭据事件标记为完全结案;`uploads` 的 `0777` 权限问题按用户决定暂缓,仍属于已知未关闭项。

View File

@ -88,6 +88,7 @@ function createIndexedDbMock(): IDBFactory {
class MockXMLHttpRequest {
static instances: MockXMLHttpRequest[] = []
static statuses: number[] = []
timeout = 0
status = 413
@ -119,6 +120,7 @@ class MockXMLHttpRequest {
send() {
setTimeout(() => {
this.status = MockXMLHttpRequest.statuses.shift() ?? 413
this.upload.onloadstart?.()
this.onload?.()
}, 0)
@ -129,6 +131,7 @@ describe('chunkUpload', () => {
beforeEach(() => {
jest.clearAllMocks()
MockXMLHttpRequest.instances = []
MockXMLHttpRequest.statuses = []
Object.defineProperty(global, 'indexedDB', {
configurable: true,
@ -236,4 +239,189 @@ describe('chunkUpload', () => {
expect(getOptimalConcurrency).not.toHaveBeenCalled()
expect(MockXMLHttpRequest.instances).toHaveLength(1)
})
test('会话刷新返回 503 时保留原 uploadId 和本地进度,不重新初始化', async () => {
MockXMLHttpRequest.statuses = [200, 404]
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ uploadId: 'upload-original' }),
})
.mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({
error: '上传会话服务暂时不可用,已保留上传进度,请稍后重试',
code: 'UPLOAD_SESSION_STORE_UNAVAILABLE',
}),
})
const file = new File(
[new Uint8Array(1024 * 1024 + 1)],
'source.psb',
{ type: 'application/octet-stream' },
)
await expect(
chunkUpload(
file,
{ materialType: '任务-详情' },
{ maxRetries: 1, retryDelay: 0, requestHeaders: { 'X-Session-Ticket': 'ticket-1' } },
),
).rejects.toThrow('已保留上传进度')
expect(global.fetch).toHaveBeenCalledTimes(2)
expect(global.fetch).toHaveBeenNthCalledWith(
2,
'/api/materials/upload/refresh',
expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json',
'X-Session-Ticket': 'ticket-1',
}),
body: JSON.stringify({ uploadId: 'upload-original' }),
}),
)
expect(global.fetch).not.toHaveBeenCalledWith(
'/api/materials/upload/cleanup',
expect.anything(),
)
const pendingUploads = await getAllPendingUploads()
expect(pendingUploads).toHaveLength(1)
expect(pendingUploads[0]).toEqual(expect.objectContaining({
uploadId: 'upload-original',
uploadedChunks: expect.any(Set),
}))
expect(pendingUploads[0].uploadedChunks).toEqual(new Set([0]))
})
test('会话刷新网络中断时同样保留原 uploadId 和已上传分片', async () => {
MockXMLHttpRequest.statuses = [200, 404]
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ uploadId: 'upload-network-retry' }),
})
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
const file = new File(
[new Uint8Array(1024 * 1024 + 1)],
'network-source.psb',
{ type: 'application/octet-stream' },
)
await expect(
chunkUpload(
file,
{ materialType: '任务-详情' },
{ maxRetries: 1, retryDelay: 0 },
),
).rejects.toThrow('已保留上传进度')
expect(global.fetch).toHaveBeenCalledTimes(2)
expect(global.fetch).not.toHaveBeenCalledWith(
'/api/materials/upload/cleanup',
expect.anything(),
)
const pendingUploads = await getAllPendingUploads()
expect(pendingUploads).toEqual([
expect.objectContaining({
uploadId: 'upload-network-retry',
uploadedChunks: new Set([0]),
}),
])
})
test('refresh 的 404 即使不是 JSON 响应也会按确认过期重新初始化', async () => {
MockXMLHttpRequest.statuses = [404, 200, 200]
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ uploadId: 'upload-expired' }),
})
.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => {
throw new Error('gateway html response')
},
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ uploadId: 'upload-reinitialized' }),
})
const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
const result = await chunkUpload(
file,
{ materialType: '任务-详情' },
{
maxRetries: 1,
retryDelay: 0,
skipComplete: true,
requestHeaders: {
Authorization: 'Bearer dingtalk-token',
'X-DingTalk-Performance-Session': 'session-token',
},
},
)
expect(result).toEqual(expect.objectContaining({
uploadId: 'upload-reinitialized',
chunksUploaded: true,
}))
expect(global.fetch).toHaveBeenCalledTimes(3)
expect(global.fetch).toHaveBeenNthCalledWith(
3,
'/api/materials/upload/init',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer dingtalk-token',
'X-DingTalk-Performance-Session': 'session-token',
}),
}),
)
})
test('complete 返回 503 且重试耗尽时仍保留本地和服务器进度', async () => {
MockXMLHttpRequest.statuses = [200]
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ uploadId: 'upload-complete-retry' }),
})
.mockResolvedValueOnce({
ok: false,
status: 503,
json: async () => ({
error: '上传会话服务暂时不可用,已保留上传进度,请稍后重试',
}),
})
const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
await expect(
chunkUpload(
file,
{ materialType: '任务-详情' },
{ maxRetries: 1, retryDelay: 0 },
),
).rejects.toThrow('已保留上传进度')
expect(global.fetch).toHaveBeenCalledTimes(2)
expect(global.fetch).not.toHaveBeenCalledWith(
'/api/materials/upload/cleanup',
expect.anything(),
)
const pendingUploads = await getAllPendingUploads()
expect(pendingUploads).toEqual([
expect.objectContaining({ uploadId: 'upload-complete-retry' }),
])
})
})

View File

@ -0,0 +1,75 @@
/** @jest-environment node */
import os from 'os'
import path from 'path'
import {
access,
mkdir,
mkdtemp,
rm,
utimes,
writeFile,
} from 'fs/promises'
describe('temp cleanup fail-closed behavior', () => {
const previousUploadDir = process.env.UPLOAD_DIR
let uploadRoot = ''
afterEach(async () => {
jest.dontMock('@/lib/upload-sessions')
jest.resetModules()
if (uploadRoot) {
await rm(uploadRoot, { recursive: true, force: true })
uploadRoot = ''
}
if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
else process.env.UPLOAD_DIR = previousUploadDir
})
test('仅删除已过期且明确 missing 的目录', async () => {
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'temp-cleanup-'))
process.env.UPLOAD_DIR = uploadRoot
const tempRoot = path.join(uploadRoot, 'temp')
const oldTime = new Date(Date.now() - 9 * 60 * 60 * 1000)
const cases = [
['upload-a-missing', 'missing'],
['upload-b-active', 'present'],
['upload-c-unknown', 'unavailable'],
['upload-d-after-outage', 'missing'],
['upload-e-recent-activity', 'missing'],
] as const
for (const [uploadId] of cases) {
const dirPath = path.join(tempRoot, uploadId)
await mkdir(dirPath, { recursive: true })
await writeFile(path.join(dirPath, 'meta.json'), JSON.stringify({
createdAt: oldTime.getTime(),
}))
await writeFile(path.join(dirPath, 'chunk-0'), 'data')
if (uploadId !== 'upload-e-recent-activity') {
await utimes(dirPath, oldTime, oldTime)
}
}
const getUploadSessionPresence = jest.fn(async (uploadId: string) => {
return cases.find(([candidate]) => candidate === uploadId)?.[1] || 'unavailable'
})
jest.doMock('@/lib/upload-sessions', () => ({ getUploadSessionPresence }))
const { runCleanup } = await import('@/lib/temp-cleanup')
const result = await runCleanup()
expect(result).toEqual(expect.objectContaining({
cleaned: 1,
skippedActive: 1,
skippedUnavailable: 2,
}))
await expect(access(path.join(tempRoot, 'upload-a-missing'))).rejects.toMatchObject({ code: 'ENOENT' })
await expect(access(path.join(tempRoot, 'upload-b-active'))).resolves.toBeUndefined()
await expect(access(path.join(tempRoot, 'upload-c-unknown'))).resolves.toBeUndefined()
await expect(access(path.join(tempRoot, 'upload-d-after-outage'))).resolves.toBeUndefined()
await expect(access(path.join(tempRoot, 'upload-e-recent-activity'))).resolves.toBeUndefined()
expect(getUploadSessionPresence).not.toHaveBeenCalledWith('upload-d-after-outage')
expect(getUploadSessionPresence).not.toHaveBeenCalledWith('upload-e-recent-activity')
})
})

View File

@ -0,0 +1,58 @@
/** @jest-environment node */
jest.mock('ioredis', () => {
const client = {
get: jest.fn(),
exists: jest.fn(),
expire: jest.fn(),
on: jest.fn(),
}
const RedisMock = jest.fn(() => client)
;(RedisMock as any).__client = client
return RedisMock
})
import Redis from 'ioredis'
import {
getUploadSession,
hasUploadSession,
refreshUploadSession,
} from '@/lib/upload-sessions-redis'
const redisClient = (Redis as unknown as {
__client: {
get: jest.Mock
exists: jest.Mock
expire: jest.Mock
}
}).__client
describe('upload-sessions Redis adapter error semantics', () => {
beforeEach(() => {
jest.clearAllMocks()
})
test('Redis 成功返回 nil/0 时才表示会话不存在', async () => {
redisClient.get.mockResolvedValueOnce(null)
redisClient.exists.mockResolvedValueOnce(0)
redisClient.expire.mockResolvedValueOnce(0)
await expect(getUploadSession('upload-missing')).resolves.toBeNull()
await expect(hasUploadSession('upload-missing')).resolves.toBe(false)
await expect(refreshUploadSession('upload-missing')).resolves.toBe(false)
})
test.each([
['get', getUploadSession],
['exists', hasUploadSession],
['expire', refreshUploadSession],
] as const)('%s 命令异常时向上抛出,而不是伪装成不存在', async (method, action) => {
redisClient[method].mockRejectedValueOnce(new Error('redis unavailable'))
await expect(action('upload-active')).rejects.toThrow('redis unavailable')
})
test('损坏的 Redis 会话 JSON 也不会被当成不存在', async () => {
redisClient.get.mockResolvedValueOnce('{broken-json')
await expect(getUploadSession('upload-corrupted')).rejects.toThrow()
})
})

View File

@ -0,0 +1,161 @@
/** @jest-environment node */
import os from 'os'
import path from 'path'
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import type { UploadSession } from '@/lib/upload-sessions'
type RedisModuleMock = {
getUploadSession: jest.Mock
hasUploadSession: jest.Mock
refreshUploadSession: jest.Mock
saveUploadSession: jest.Mock
}
function createSession(userId = 'user-1'): UploadSession {
return {
fileName: 'source.psd',
fileSize: 1024,
totalChunks: 1,
chunks: new Map(),
createdAt: Date.now(),
lastActivityAt: Date.now(),
userId,
}
}
async function loadRedisBackedUploadSessions(overrides: Partial<RedisModuleMock> = {}) {
jest.resetModules()
process.env.USE_REDIS = 'true'
const redisMock: RedisModuleMock = {
getUploadSession: jest.fn().mockResolvedValue(null),
hasUploadSession: jest.fn().mockResolvedValue(false),
refreshUploadSession: jest.fn().mockResolvedValue(false),
saveUploadSession: jest.fn().mockResolvedValue(undefined),
...overrides,
}
jest.doMock('@/lib/upload-sessions-redis', () => redisMock)
const uploadSessionsModule = await import('@/lib/upload-sessions')
return { uploadSessionsModule, redisMock }
}
describe('upload-sessions Redis resilience', () => {
const previousUseRedis = process.env.USE_REDIS
const previousUploadDir = process.env.UPLOAD_DIR
let uploadRoot = ''
afterEach(async () => {
jest.dontMock('@/lib/upload-sessions-redis')
jest.resetModules()
if (uploadRoot) {
await rm(uploadRoot, { recursive: true, force: true })
uploadRoot = ''
}
if (previousUseRedis === undefined) delete process.env.USE_REDIS
else process.env.USE_REDIS = previousUseRedis
if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
else process.env.UPLOAD_DIR = previousUploadDir
})
test('Redis 故障且没有本机缓存时返回 unavailable,并拒绝布尔误判', async () => {
const failure = jest.fn().mockRejectedValue(new Error('redis unavailable'))
const { uploadSessionsModule } = await loadRedisBackedUploadSessions({
hasUploadSession: failure,
})
await expect(uploadSessionsModule.getUploadSessionPresence('upload-active')).resolves.toBe('unavailable')
await expect(uploadSessionsModule.hasUploadSession('upload-active')).rejects.toBeInstanceOf(
uploadSessionsModule.UploadSessionStoreUnavailableError,
)
})
test('Redis 故障时允许使用当前进程已缓存的会话', async () => {
const { uploadSessionsModule } = await loadRedisBackedUploadSessions({
getUploadSession: jest.fn().mockRejectedValue(new Error('redis unavailable')),
hasUploadSession: jest.fn().mockRejectedValue(new Error('redis unavailable')),
})
const session = createSession()
uploadSessionsModule.uploadSessions.set('upload-cached', session)
await expect(uploadSessionsModule.getUploadSession('upload-cached')).resolves.toBe(session)
await expect(uploadSessionsModule.getUploadSessionPresence('upload-cached')).resolves.toBe('present')
})
test('Redis 键已过期时可从当前用户的 meta.json 重建会话', async () => {
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'upload-session-refresh-'))
process.env.UPLOAD_DIR = uploadRoot
const uploadId = 'upload-meta-recovery'
const sessionDir = path.join(uploadRoot, 'temp', uploadId)
await mkdir(sessionDir, { recursive: true })
await writeFile(path.join(sessionDir, 'meta.json'), JSON.stringify({
fileName: 'source.psd',
fileSize: 1024,
totalChunks: 1,
createdAt: Date.now(),
userId: 'user-1',
}))
const { uploadSessionsModule, redisMock } = await loadRedisBackedUploadSessions()
await expect(
uploadSessionsModule.refreshOwnedUploadSession(uploadId, 'user-1'),
).resolves.toBe('refreshed')
expect(redisMock.saveUploadSession).toHaveBeenCalledWith(
uploadId,
expect.objectContaining({ userId: 'user-1', fileName: 'source.psd' }),
)
})
test('Redis 故障且本地没有可验证元数据时不会误报 missing', async () => {
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'upload-session-unavailable-'))
process.env.UPLOAD_DIR = uploadRoot
const { uploadSessionsModule } = await loadRedisBackedUploadSessions({
getUploadSession: jest.fn().mockRejectedValue(new Error('redis unavailable')),
})
await expect(
uploadSessionsModule.refreshOwnedUploadSession('upload-unknown', 'user-1'),
).rejects.toBeInstanceOf(uploadSessionsModule.UploadSessionStoreUnavailableError)
})
test('Redis 故障时仍可用合法 meta.json 完成所有权核验', async () => {
uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'upload-session-owned-meta-'))
process.env.UPLOAD_DIR = uploadRoot
const uploadId = 'upload-owned-meta'
const sessionDir = path.join(uploadRoot, 'temp', uploadId)
await mkdir(sessionDir, { recursive: true })
await writeFile(path.join(sessionDir, 'meta.json'), JSON.stringify({
fileName: 'source.psd',
fileSize: 1024,
totalChunks: 1,
createdAt: Date.now(),
userId: 'owner-1',
}))
const { uploadSessionsModule } = await loadRedisBackedUploadSessions({
getUploadSession: jest.fn().mockRejectedValue(new Error('redis unavailable')),
})
await expect(
uploadSessionsModule.getOwnedUploadSession(uploadId, 'owner-1'),
).resolves.toEqual(expect.objectContaining({
userId: 'owner-1',
fileName: 'source.psd',
}))
await expect(
uploadSessionsModule.getOwnedUploadSession(uploadId, 'other-user'),
).resolves.toBeNull()
})
test('其他用户不能刷新已有会话', async () => {
const { uploadSessionsModule, redisMock } = await loadRedisBackedUploadSessions({
getUploadSession: jest.fn().mockResolvedValue(createSession('owner-1')),
})
await expect(
uploadSessionsModule.refreshOwnedUploadSession('upload-owned', 'other-user'),
).resolves.toBe('forbidden')
expect(redisMock.saveUploadSession).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,20 @@
/** @jest-environment node */
import {
getLatestUploadActivityAt,
shouldCleanupUploadTempDirectory,
} from '@/lib/upload-temp-cleanup-policy'
describe('upload temp cleanup policy', () => {
test('只有已过期且会话明确不存在时才允许清理', () => {
expect(shouldCleanupUploadTempDirectory(true, 'missing')).toBe(true)
expect(shouldCleanupUploadTempDirectory(false, 'missing')).toBe(false)
expect(shouldCleanupUploadTempDirectory(true, 'present')).toBe(false)
expect(shouldCleanupUploadTempDirectory(true, 'unavailable')).toBe(false)
})
test('活动时间取目录与元数据中的最新值', () => {
expect(getLatestUploadActivityAt(200, { createdAt: 100, lastActivityAt: 300 })).toBe(300)
expect(getLatestUploadActivityAt(400, { createdAt: 100, lastActivityAt: 'bad' })).toBe(400)
})
})

View File

@ -702,18 +702,23 @@ export async function chunkUpload(
// 尝试刷新会话(保留uploadId和已上传进度)
const refreshResponse = await fetch('/api/materials/upload/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(requestHeaders || {}),
},
body: JSON.stringify({ uploadId: state.uploadId })
})
const refreshResult = await refreshResponse.json()
const refreshResult = await refreshResponse.json().catch(() => ({}))
if (refreshResult.success) {
if (refreshResponse.ok && refreshResult.success) {
console.log(`[ChunkUpload] ✅ 会话刷新成功,保留 ${state.uploadedChunks.size} 个已上传分片,继续上传`)
sessionExpired = false
// 不清空 uploadedChunks,保留进度
return
} else {
}
if (refreshResponse.status === 404) {
// ⚠️ 修复:如果刷新失败但所有分片已上传,不要重新初始化
if (state.uploadedChunks.size >= totalChunks) {
console.warn(`[ChunkUpload] ⚠️ 会话刷新失败但所有分片已上传完成,跳过重新初始化`)
@ -736,9 +741,29 @@ export async function chunkUpload(
sessionExpired = false
return
}
const refreshErrorMessage =
refreshResult?.error ||
'上传会话服务暂时不可用,已保留上传进度,请稍后重试'
throw createUploadHttpError(
refreshErrorMessage,
refreshResponse.status,
isRetryableHttpStatus(refreshResponse.status),
)
} catch (refreshError) {
console.error(`[ChunkUpload] ❌ 会话刷新/重新初始化失败:`, refreshError)
throw new Error('上传会话已过期且无法恢复,请刷新页面后重试')
if (
refreshError &&
typeof refreshError === 'object' &&
'status' in refreshError
) {
throw refreshError
}
throw createUploadHttpError(
'上传会话服务暂时无法连接,已保留上传进度,请稍后重试',
undefined,
true,
)
}
})()
@ -838,20 +863,11 @@ export async function chunkUpload(
if (retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, retryDelay * retries))
} else {
// 达到最大重试次数,清理状态以避免残留
await deleteUploadState(fileId).catch(() => {
console.warn('[ChunkUpload] 清理上传状态失败(忽略)')
})
// 清理后端临时文件
if (state.uploadId) {
console.log('[ChunkUpload] 上传失败,调用后端清理临时文件...')
fetch('/api/materials/upload/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId: state.uploadId })
}).catch(() => {})
}
throw new Error('完成上传失败,已重试多次')
// 服务器/网络类故障可能只是暂时的。保留 IndexedDB 与服务器分片,
// 用户稍后重试时可以继续原会话;只有明确 404 或不可重试错误才清理。
throw error instanceof Error
? error
: new Error('完成上传暂时失败,已保留上传进度,请稍后重试')
}
}
}

View File

@ -13,6 +13,14 @@
import { promises as fs } from 'fs'
import path from 'path'
import {
getUploadSessionPresence,
type UploadSessionPresence,
} from './upload-sessions'
import {
getLatestUploadActivityAt,
shouldCleanupUploadTempDirectory,
} from './upload-temp-cleanup-policy'
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
const TEMP_DIR = path.join(UPLOAD_DIR, 'temp')
@ -24,16 +32,15 @@ let cleanupTimer: NodeJS.Timeout | null = null
let isCleanupRunning = false
/**
* 检查上传会话是否在 Redis 中活跃
* 查询上传会话状态。任何无法确认的异常都返回 unavailable,禁止据此删除目录。
*/
async function isSessionActive(uploadId: string): Promise<boolean> {
async function getSessionPresence(uploadId: string): Promise<UploadSessionPresence> {
try {
const { hasUploadSession } = await import('./upload-sessions-redis')
return await hasUploadSession(uploadId)
return await getUploadSessionPresence(uploadId)
} catch (error) {
// Redis 不可用时,保守处理,认为会话可能活跃
// 防御性保守处理:即便状态查询自身出现意外,也绝不能当成“不存在”。
console.warn('[TempCleanup] 检查会话失败:', error)
return false
return 'unavailable'
}
}
@ -75,10 +82,17 @@ export async function runCleanup(): Promise<{
failed: number
freedBytes: number
skippedActive: number
skippedUnavailable: number
}> {
if (isCleanupRunning) {
console.log('[TempCleanup] 清理任务正在运行,跳过')
return { cleaned: 0, failed: 0, freedBytes: 0, skippedActive: 0 }
return {
cleaned: 0,
failed: 0,
freedBytes: 0,
skippedActive: 0,
skippedUnavailable: 0,
}
}
isCleanupRunning = true
@ -88,6 +102,8 @@ export async function runCleanup(): Promise<{
let failed = 0
let freedBytes = 0
let skippedActive = 0
let skippedUnavailable = 0
let sessionStoreUnavailable = false
try {
// 检查 temp 目录是否存在
@ -95,11 +111,11 @@ export async function runCleanup(): Promise<{
await fs.access(TEMP_DIR)
} catch {
console.log('[TempCleanup] temp 目录不存在,跳过清理')
return { cleaned, failed, freedBytes, skippedActive }
return { cleaned, failed, freedBytes, skippedActive, skippedUnavailable }
}
// 遍历 temp 目录
const dirs = await fs.readdir(TEMP_DIR)
const dirs = (await fs.readdir(TEMP_DIR)).sort()
const now = Date.now()
console.log(`[TempCleanup] 检查 ${dirs.length} 个目录...`)
@ -111,18 +127,40 @@ export async function runCleanup(): Promise<{
const stat = await fs.stat(dirPath)
if (!stat.isDirectory()) continue
// 检查是否过期(使用目录修改时间)- 先检查时间,减少 Redis 查询
const age = now - stat.mtimeMs
if (age < MAX_AGE_MS) {
let metadata: { createdAt?: unknown; lastActivityAt?: unknown } | null = null
try {
metadata = JSON.parse(await fs.readFile(path.join(dirPath, 'meta.json'), 'utf8'))
} catch {
// 历史目录可能没有元数据,继续使用目录修改时间。
}
const lastActivityAt = getLatestUploadActivityAt(stat.mtimeMs, metadata)
const age = now - lastActivityAt
const isExpired = age >= MAX_AGE_MS
if (!isExpired) {
continue // 还没过期,跳过
}
if (sessionStoreUnavailable) {
skippedUnavailable++
continue
}
// 检查是否是活跃会话(只对过期目录检查)
const isActive = await isSessionActive(dir)
if (isActive) {
// 只允许删除“已过期且存储明确确认不存在”的目录。
const presence = await getSessionPresence(dir)
if (presence === 'present') {
skippedActive++
continue
}
if (presence === 'unavailable') {
// Redis 通常是全局故障;本轮剩余目录不再逐个等待命令超时。
sessionStoreUnavailable = true
skippedUnavailable++
continue
}
if (!shouldCleanupUploadTempDirectory(isExpired, presence)) {
continue
}
// 执行清理
const result = await cleanupTempDir(dirPath)
@ -140,13 +178,13 @@ export async function runCleanup(): Promise<{
const duration = Date.now() - startTime
const freedMB = (freedBytes / 1024 / 1024).toFixed(2)
console.log(`[TempCleanup] 清理完成: 删除 ${cleaned} 个目录,释放 ${freedMB} MB,跳过活跃 ${skippedActive} 个,耗时 ${duration}ms`)
console.log(`[TempCleanup] 清理完成: 删除 ${cleaned} 个目录,释放 ${freedMB} MB,跳过活跃 ${skippedActive} 个,状态不明 ${skippedUnavailable} 个,耗时 ${duration}ms`)
} finally {
isCleanupRunning = false
}
return { cleaned, failed, freedBytes, skippedActive }
return { cleaned, failed, freedBytes, skippedActive, skippedUnavailable }
}
/**

View File

@ -128,7 +128,9 @@ export async function getUploadSession(
}
} catch (error) {
console.error(`[Redis] 获取上传会话失败: ${uploadId}`, error)
return null
// null 只能表示 Redis 已成功响应且键不存在。
// 超时、断连或数据损坏必须交给上层区分“暂时不可用”和“确实不存在”。
throw error
}
}
@ -180,7 +182,8 @@ export async function hasUploadSession(uploadId: string): Promise<boolean> {
return exists === 1
} catch (error) {
console.error(`[Redis] 检查会话是否存在失败: ${uploadId}`, error)
return false
// false 只能表示 Redis 已成功确认键不存在。
throw error
}
}
@ -242,7 +245,8 @@ export async function refreshUploadSession(uploadId: string): Promise<boolean> {
}
} catch (error) {
console.error(`[Redis] 刷新上传会话失败: ${uploadId}`, error)
return false
// 不能把 Redis 故障伪装成“会话不存在”,否则客户端会错误地从头重传。
throw error
}
}

View File

@ -60,6 +60,17 @@ export interface UploadSession {
userId: string
}
export type UploadSessionPresence = 'present' | 'missing' | 'unavailable'
export type RefreshOwnedUploadSessionResult = 'refreshed' | 'missing' | 'forbidden'
export class UploadSessionStoreUnavailableError extends Error {
constructor(message = '上传会话服务暂时不可用') {
super(message)
this.name = 'UploadSessionStoreUnavailableError'
}
}
function parsePositiveIntEnv(name: string, fallback: number): number {
const value = Number.parseInt(process.env[name] || '', 10)
return Number.isFinite(value) && value > 0 ? value : fallback
@ -148,7 +159,8 @@ function getMemorySession(uploadId: string): UploadSession | null {
return null
}
if (Date.now() - session.createdAt > MAX_UPLOAD_SESSION_AGE_MS) {
const activityAt = session.lastActivityAt || session.createdAt
if (Date.now() - activityAt > MAX_UPLOAD_SESSION_AGE_MS) {
memorySessions.delete(uploadId)
return null
}
@ -183,16 +195,37 @@ async function getUploadSessionFromMetadata(uploadId: string): Promise<UploadSes
}
try {
const rawMetadata = await readFile(path.join(sessionDir, 'meta.json'), 'utf8')
const sessionDirStats = await lstat(sessionDir)
if (
sessionDirStats.isSymbolicLink() ||
!sessionDirStats.isDirectory() ||
!(await isPathFreeOfSymbolicLinks(tempRoot, sessionDir))
) {
return null
}
const metadataPath = path.join(sessionDir, 'meta.json')
const metadataStats = await lstat(metadataPath)
if (metadataStats.isSymbolicLink() || !metadataStats.isFile()) {
return null
}
const rawMetadata = await readFile(metadataPath, 'utf8')
const metadata = JSON.parse(rawMetadata)
const createdAt = Number(metadata?.createdAt)
const metadataLastActivityAt = Number(metadata?.lastActivityAt)
const fileSize = Number(metadata?.fileSize)
const totalChunks = Number(metadata?.totalChunks)
const fileName = typeof metadata?.fileName === 'string'
? normalizeSafeFileName(metadata.fileName)
: null
const userId = typeof metadata?.userId === 'string' ? metadata.userId : ''
const ageMs = Date.now() - createdAt
const lastActivityAt = Math.max(
createdAt,
Number.isFinite(metadataLastActivityAt) ? metadataLastActivityAt : 0,
sessionDirStats.mtimeMs,
)
const ageMs = Date.now() - lastActivityAt
if (
!fileName ||
@ -214,6 +247,7 @@ async function getUploadSessionFromMetadata(uploadId: string): Promise<UploadSes
totalChunks,
chunks: new Map(),
createdAt,
lastActivityAt,
userId,
}
setMemorySession(uploadId, recoveredSession)
@ -247,7 +281,8 @@ if (!USE_REDIS || !redisModule) {
const keysToDelete: string[] = []
memorySessions.forEach((session, uploadId) => {
if (now - session.createdAt > expireTime) {
const activityAt = session.lastActivityAt || session.createdAt
if (now - activityAt > expireTime) {
keysToDelete.push(uploadId)
}
})
@ -298,18 +333,25 @@ export async function saveUploadSession(
export async function getUploadSession(
uploadId: string
): Promise<UploadSession | null> {
return withRedisFallback(
'getUploadSession',
async () => {
const session = await redisModule!.getUploadSession(uploadId)
if (USE_REDIS && redisModule) {
try {
const session = await redisModule.getUploadSession(uploadId)
if (session) {
setMemorySession(uploadId, session)
return session
}
return getMemorySession(uploadId)
},
async () => getMemorySession(uploadId)
)
} catch (error) {
console.error('[UploadSessions] Redis getUploadSession failed:', error)
const memorySession = getMemorySession(uploadId)
if (memorySession) {
return memorySession
}
throw new UploadSessionStoreUnavailableError('读取上传会话时,Redis 暂时不可用')
}
}
return getMemorySession(uploadId)
}
/**
@ -326,9 +368,23 @@ export async function getOwnedUploadSession(
return null
}
const session =
(await getUploadSession(normalizedUploadId)) ||
(await getUploadSessionFromMetadata(normalizedUploadId))
let session: UploadSession | null = null
try {
session = await getUploadSession(normalizedUploadId)
} catch (error) {
if (!(error instanceof UploadSessionStoreUnavailableError)) {
throw error
}
// Redis 暂时不可用时,合法的本地 meta.json 仍可用于所有权核验;
// 若本地也无法确认,则继续抛出 unavailable,不能伪装成“不属于当前用户”。
session = await getUploadSessionFromMetadata(normalizedUploadId)
if (!session) {
throw error
}
}
session = session || (await getUploadSessionFromMetadata(normalizedUploadId))
return (
session?.userId === normalizedUserId &&
@ -649,14 +705,39 @@ export async function deleteOwnedUploadSession(uploadId: string, userId: string)
}
/**
* 检查会话是否存在
* 查询上传会话是否存在。
*
* missing 只表示 Redis/内存存储已成功确认不存在;Redis 故障时返回 unavailable,
* 供清理任务采用“状态不明就不删除”的保守策略。
*/
export async function getUploadSessionPresence(
uploadId: string,
): Promise<UploadSessionPresence> {
if (USE_REDIS && redisModule) {
try {
const existsInRedis = await redisModule.hasUploadSession(uploadId)
if (existsInRedis || getMemorySession(uploadId)) {
return 'present'
}
return 'missing'
} catch (error) {
console.error('[UploadSessions] Redis hasUploadSession failed:', error)
return getMemorySession(uploadId) ? 'present' : 'unavailable'
}
}
return getMemorySession(uploadId) ? 'present' : 'missing'
}
/**
* 向后兼容的布尔查询;调用方若需要据此删除数据,必须改用三态接口。
*/
export async function hasUploadSession(uploadId: string): Promise<boolean> {
return withRedisFallback(
'hasUploadSession',
() => redisModule!.hasUploadSession(uploadId),
async () => memorySessions.has(uploadId)
)
const presence = await getUploadSessionPresence(uploadId)
if (presence === 'unavailable') {
throw new UploadSessionStoreUnavailableError('检查上传会话时,Redis 暂时不可用')
}
return presence === 'present'
}
/**
@ -744,25 +825,101 @@ export async function updateChunkWithStats(
* 用于会话过期后恢复上传,不清空已上传的分片记录
*/
export async function refreshUploadSession(uploadId: string): Promise<boolean> {
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}`)
if (USE_REDIS && redisModule) {
try {
const refreshed = await redisModule.refreshUploadSession(uploadId)
if (refreshed) {
return true
}
console.warn(`[MemoryMap] 刷新上传会话失败,会话不存在: ${uploadId}`)
return false
} catch (error) {
console.error('[UploadSessions] Redis refreshUploadSession failed:', error)
if (!refreshMemoryUploadSession(uploadId)) {
throw new UploadSessionStoreUnavailableError('刷新上传会话时,Redis 暂时不可用')
}
return true
}
)
}
return refreshMemoryUploadSession(uploadId)
}
function refreshMemoryUploadSession(uploadId: string): boolean {
const session = getMemorySession(uploadId)
if (!session) {
return false
}
session.lastActivityAt = Date.now()
setMemorySession(uploadId, session)
console.log(`[MemoryMap] 刷新上传会话成功: ${uploadId}`)
return true
}
/**
* 刷新属于当前用户的上传会话。
* Redis 键确实过期时,可以从同一上传目录的 meta.json 恢复;Redis 故障时则抛出
* unavailable,让 API 返回 503,避免客户端误清空已上传分片。
*/
export async function refreshOwnedUploadSession(
uploadId: string,
userId: string,
): Promise<RefreshOwnedUploadSessionResult> {
const normalizedUploadId = String(uploadId || '').trim()
const normalizedUserId = String(userId || '').trim()
if (!isSafeUploadId(normalizedUploadId) || !normalizedUserId) {
return 'missing'
}
let session: UploadSession | null = null
let redisUnavailable = false
if (USE_REDIS && redisModule) {
try {
session = await redisModule.getUploadSession(normalizedUploadId)
if (session) {
setMemorySession(normalizedUploadId, session)
}
} catch (error) {
redisUnavailable = true
console.error('[UploadSessions] Redis refresh ownership lookup failed:', error)
}
} else {
session = getMemorySession(normalizedUploadId)
}
session =
session ||
getMemorySession(normalizedUploadId) ||
(await getUploadSessionFromMetadata(normalizedUploadId))
if (!session) {
if (redisUnavailable) {
throw new UploadSessionStoreUnavailableError('刷新上传会话时,Redis 暂时不可用')
}
return 'missing'
}
if (session.userId !== normalizedUserId) {
return 'forbidden'
}
const refreshedSession: UploadSession = {
...session,
lastActivityAt: Date.now(),
}
setMemorySession(normalizedUploadId, refreshedSession)
if (USE_REDIS && redisModule) {
try {
// SETEX 同时覆盖保存进度并重建已过期的 Redis 键。
await redisModule.saveUploadSession(normalizedUploadId, refreshedSession)
} catch (error) {
console.error('[UploadSessions] Redis refresh persistence failed:', error)
throw new UploadSessionStoreUnavailableError('刷新上传会话时,Redis 暂时不可用')
}
}
return 'refreshed'
}
// 导出内存Map(用于向后兼容,但不推荐直接使用)

View File

@ -0,0 +1,35 @@
import type { UploadSessionPresence } from './upload-sessions'
export interface UploadTempMetadata {
createdAt?: unknown
lastActivityAt?: unknown
}
function toFiniteTimestamp(value: unknown): number {
const timestamp = Number(value)
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : 0
}
/**
* 目录修改时间与元数据时间取最新值,避免用旧 createdAt 覆盖仍在写入的目录。
*/
export function getLatestUploadActivityAt(
directoryMtimeMs: number,
metadata?: UploadTempMetadata | null,
): number {
return Math.max(
toFiniteTimestamp(directoryMtimeMs),
toFiniteTimestamp(metadata?.createdAt),
toFiniteTimestamp(metadata?.lastActivityAt),
)
}
/**
* 自动或批量清理的唯一安全门槛:目录已过期,并且会话存储明确确认不存在。
*/
export function shouldCleanupUploadTempDirectory(
isExpired: boolean,
presence: UploadSessionPresence,
): boolean {
return isExpired && presence === 'missing'
}

View File

@ -11,7 +11,11 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getUploadSession } from '../lib/upload-sessions'
import { getUploadSessionPresence } from '../lib/upload-sessions'
import {
getLatestUploadActivityAt,
shouldCleanupUploadTempDirectory,
} from '../lib/upload-temp-cleanup-policy'
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
const MAX_AGE_HOURS = 8 // 8小时后清理
@ -95,11 +99,12 @@ async function main() {
}
const now = Date.now()
const tempDirs = await fs.readdir(tempBaseDir)
const tempDirs = (await fs.readdir(tempBaseDir)).sort()
let cleaned = 0
let totalFreedSpace = 0
const errors: string[] = []
let sessionStoreUnavailable = false
for (const dir of tempDirs) {
const uploadId = dir
@ -114,38 +119,41 @@ async function main() {
// 读取元数据文件(如果存在)
const metaPath = path.join(tempDir, 'meta.json')
let createdAt = stat.mtimeMs // 使用目录修改时间作为fallback
let metadata: { createdAt?: unknown; lastActivityAt?: unknown } | null = null
try {
const metaContent = await fs.readFile(metaPath, 'utf-8')
const meta = JSON.parse(metaContent)
if (meta.createdAt) {
createdAt = meta.createdAt
}
metadata = JSON.parse(metaContent)
} catch {
// 元数据文件不存在,使用目录修改时间
}
// 检查是否过期
const age = now - createdAt
const lastActivityAt = getLatestUploadActivityAt(stat.mtimeMs, metadata)
const age = now - lastActivityAt
const ageHours = age / (60 * 60 * 1000)
const isExpired = age > MAX_AGE
// 检查会话是否存在
let sessionExists = false
try {
const session = await getUploadSession(uploadId)
sessionExists = !!session
} catch (err) {
// 获取会话失败,认为不存在
sessionExists = false
if (!isExpired) {
continue
}
// 决定是否清理:过期的 或 会话不存在的
const shouldCleanup = isExpired || !sessionExists
if (sessionStoreUnavailable) {
console.warn(`[Cleanup] 跳过上传会话: ${uploadId}(会话存储本轮不可用)`)
continue
}
// Redis 故障和“会话不存在”必须严格区分;状态不明时跳过本轮。
const presence = await getUploadSessionPresence(uploadId)
if (presence === 'unavailable') {
sessionStoreUnavailable = true
console.warn(`[Cleanup] 跳过上传会话: ${uploadId}(会话状态暂时无法确认)`)
continue
}
const shouldCleanup = shouldCleanupUploadTempDirectory(isExpired, presence)
if (shouldCleanup) {
const reason = isExpired ? `过期 (${ageHours.toFixed(1)}小时)` : '会话不存在'
const reason = `过期 (${ageHours.toFixed(1)}小时) 且会话不存在`
console.log(`[Cleanup] 清理上传会话: ${uploadId} (${reason})`)
const result = await cleanupUploadSession(uploadId)