大文件分片上传

  1. 获取文件并进行分片,使用file.slice分为多个blob片段

  2. Blob 分片转为 ArrayBuffer ,再使用 webWorker 多线程通过spark-md5算出分片 Hash

  3. 使用 Merkle 算法通过分片 Hash 计算出文件 Hash

  4. 向后端发送请求(fileHash 文件的hash值),询问文件上传状态

    1. 文件已经完整上传过了:上传完成(秒传)
    2. 文件存在但分片不完整:计算未上传分片序列并上传对应分片(断点续传)
    3. 文件不存在:将所有分片上传
  5. 将所有分片上传后,发送请求通知后端合并文件分片

0. 获取文件
1
2
3
4
5
6
7
const uploadHandler = (e: Event) => {
// 0.读取文件
file.value = (e.target as HTMLInputElement).files?.[0] || null;
if (!file.value) return;
// 1.使用 Blob 对象的 slice 方法进行文件分片
const chunks = createChunks(file.value, CHUNK_SIZE);
};
1. 文件分片

Blob 对象的 slice 方法进行分片,start 和 end 代表字节的起止,contentType 赋予新的文档类型

.slice(start, end, contentType)

1
2
3
4
5
6
7
8
// 创建分片
const createChunks = (file: File, chunkSize: number) => {
const chunks = [];
for(let i = 0; i < file.size; i += chunkSize) {
chunks.push(file.slice(i, i + chunkSize));
};
return chunks;
};
2. Blob 转为 ArrayBuffer

Blob 数组不能直接用于计算分片 hash, 需转成 ArrayBuffer 数组,使用 FileReader转换,兼容性更好一些

由于 File 或 Blob 并不是 Worker 中的可 Transfer 对象,会导致主线程与 Worker 通信时进行结构化克隆, 由此会产生额外的CPU性能消耗和内存消耗

这一步是 I/O 密集型,可以使用异步来优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 读取单个分片并转换为 ArrayBuffer
const readAsArrayBuffer = (file: Blob) => {
return new Promise((resolve) => {
const fileReader = new FileReader();
// 读取完成时触发 onload 事件
fileReader.onload = (e: ProgressEvent<FileReader>) => {
resolve(e.target?.result as ArrayBuffer);
}
// 使用 readAsArrayBuffer 方法读取文件即为 ArrayBuffer 格式
fileReader.readAsArrayBuffer(file);
})
}

// 批量将分片转换为 ArrayBuffer
const getArrayBufFromBlobs = (chunks: Blob[]) => {
return Promise.all(chunks.map(chunk => readAsArrayBuffer(chunk)));
}
3. 计算分片Hash

使用 spark-md5 根据文件内容产生 hash 用以区分每个分片,这步并非必须,若不需要实现分片级别的完整性校验、去重或校验和功能,可直接计算文件 Hash

JavaScript 是单线程的,计算文件分片 Hash 是一个 CPU 密集型任务, 直接在主线程中计算 hash 必定会导致 UI 卡死,Web Worker 允许在后台线程运行脚本,与主线程并行,不阻塞 UI,故而放到 WebWorker 中计算 Hash。

ArrayBuffer 是可 Transfer 的对象, 在主线程与 Worker 线程通信时, 可以通过移交控制权的方式通信, 避免线程通信引起的结构化克隆

分片之间的 Hash 计算没有关联, 而 WebWorker 可以用来开额外的计算线程, 考虑基于 WebWorker 实现线程池(WorkerPool)来加速计算分片 Hash

通过 WebWorker 计算 md5 有两种方式:

  1. 获取当前浏览器所在设备核心数量,将总分片数平分给每个 worker
  2. 构建 WorkerPool,用于管理 WorkerWrapper,使用发布订阅模式来订阅当前正在跑的 Worker 数量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 分多个线程计算每个分片的Hash值
* @param arrayBufs - 文件分片的ArrayBuffer数组
* @returns 返回分片计算后的Hash值数组
*/
const getChunksHash = async (arrayBufs: ArrayBuffer[]) => {
// 根据当前浏览器所在设备的核心数量设置线程数量
const THREAD_COUNT = navigator.hardwareConcurrency || 4;
// 每个线程能够分到的分片数量
const chunkPerThread = Math.ceil(arrayBufs.length / THREAD_COUNT);
// 根据线程数量循环计算分片hash
const chunksHashT: string[] = [];
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
const worker = new Worker(new URL('./hashWorker.js', import.meta.url), { type: 'module' });
worker.postMessage({
arrayBufs: arrayBufs.slice(i * chunkPerThread, (i + 1) * chunkPerThread)
});

workerPromises.push(new Promise((resolve) => {
worker.onmessage = (e) => {
worker.terminate();
// 此处不能使用 push,因为 worker 完成的先后无法保证,所以使用下标
chunksHashT[i] = e.data;
resolve(e.data);
}
}));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ./hashWorker.js
import SparkMD5 from "spark-md5";

// 计算文件每个分片的 MD5 值
const getChunksHash = (arrayBufs) => {
return arrayBufs.map(arrayBuf => {
return SparkMD5.ArrayBuffer.hash(arrayBuf);
})
}

onmessage = (e) => {
const { arrayBufs } = e.data;
const chunksHash = getChunksHash(arrayBufs);
postMessage(chunksHash);
}
4. 计算文件 Hash

如果已经计算出所有分片Hash,可使用 Merkle Tree 算法递归算出 fileHash,若无此需要,可以通过上一步方法,在每个 webWorker 中直接使用 sparkMD5 的 append () 依次将每个分片 ArrayBuffer 添加,最后使用 end() 获取文件 Hash

5. 控制并发上传所需分片

通过向服务端发送文件Hash,获取所需要上传的分片List,控制并发数量完成每个分片上传

a. 上传单个分片,通过 formData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* 上传单个分片
* @param chunk - 分片数据
* @param filename - 文件名
* @param fileHash - 文件哈希
* @param index - 分片索引
* @param onProgress - 进度回调函数
* @returns 返回上传结果
*/
export const uploadChunk = async (
chunk: Blob,
filename: string,
fileHash: string,
index: number,
onProgress?: (e: AxiosProgressEvent) => void
) => {
// 创建FormData对象
const formData = new FormData();

// 添加一个文件名为`chunk`的文件对象
// 注意: 文件名必须唯一,避免浏览器缓存问题
const chunkFile = new File([chunk], `chunk-${index}-${Date.now()}`, {
type: 'application/octet-stream'
});
formData.append('chunk', chunkFile);

// 添加其他表单字段
formData.append('filename', filename);
formData.append('fileHash', fileHash);
formData.append('chunkIndex', index.toString());

try {
console.log(`开始上传分片 ${index}, 大小: ${chunk.size} 字节`);

const response = await axios.post(`${API_BASE_URL}/upload/chunk`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: onProgress,
// 增加超时时间
timeout: 60000,
});

console.log(`分片 ${index} 上传成功:`, response.data);
return response.data;
} catch (error) {
console.error(`上传分片${index}失败:`, error);

// 添加更详细的错误信息
if (axios.isAxiosError(error) && error.response) {
console.error(`服务器返回错误 (${error.response.status}):`, error.response.data);
}

throw error;
}
}

b. 包装上传单个分片函数、以增加重试逻辑(可选)

c. 设置并发请求数,每次从待上传的分片中向后截取该数量分片发送请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 最大并发请求数
const MAX_CONCURRENT_REQUESTS = 6;

// 并发控制上传
const uploadQueue = async () => {
let failed = false;

for (let i = 0; i < pendingChunks.length && !failed; i += MAX_CONCURRENT_REQUESTS) {
const batch = pendingChunks.slice(i, i + MAX_CONCURRENT_REQUESTS);

try {
// 并行上传当前批次
await Promise.all(
batch.map(({ chunk, index }) => uploadChunkWithRetry(chunk, index))
);
} catch (error) {
console.error('批次上传失败:', error);
failed = true;
throw error;
}
}

return !failed;
};

如果需要判断并发数量可采用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 获取最优的并发请求数
* 根据网络条件和设备性能动态调整
*/
const getOptimalConcurrency = (): number => {
// 尝试获取网络信息
const connection = (navigator as any).connection;
if (connection) {
// 根据网络类型调整
const { effectiveType, downlink } = connection;
console.log(`网络类型: ${effectiveType}, 下行速度: ${downlink}Mbps`);

if (effectiveType === '4g' || downlink > 5) {
return 8; // 高速网络
} else if (effectiveType === '3g' || downlink > 1) {
return 4; // 中速网络
} else {
return 2; // 低速网络
}
}

// 根据设备核心数调整
const cores = navigator.hardwareConcurrency || 4;
console.log(`设备核心数: ${cores}`);

if (cores > 4) {
return 6; // 高性能设备
} else {
return 3; // 低性能设备
}
};

// 最大并发请求数
const MAX_CONCURRENT_REQUESTS = getOptimalConcurrency();
console.log(`设置的并发请求数: ${MAX_CONCURRENT_REQUESTS}`);
6. 请求服务器合并文件

使用闭包在递归时保留 retryCount 以实现重试机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 请求服务器合并分片
* @param fileHash - 文件哈希
* @param filename - 文件名
* @param size - 文件大小
* @returns 返回合并结果
*/
export const mergeChunks = async (fileHash: string, filename: string, size: number) => {
// 最大重试次数
const MAX_RETRY = 2;
let retryCount = 0;

const doMerge = async () => {
try {
console.log('请求合并分片:', { fileHash, filename, size });

const response = await axios.post(`${API_BASE_URL}/merge`, {
fileHash,
filename,
size
}, {
timeout: 120000, // 2分钟超时,大文件合并可能需要较长时间
});

console.log('合并分片成功:', response.data);
return response.data;
} catch (error) {
console.error('合并分片失败:', error);

if (axios.isAxiosError(error) && error.response) {
console.error('服务器返回错误:', error.response.data);

// 如果是400错误,可能是请求参数问题,不再重试
if (error.response.status === 400) {
throw new Error(`合并分片请求错误: ${error.response.data.message || '参数错误'}`);
}
}

// 如果已达到最大重试次数,抛出错误
if (retryCount >= MAX_RETRY) {
throw error;
}

// 重试
retryCount++;
console.log(`合并分片失败,第${retryCount}次重试`);
// 重试前等待一段时间
await new Promise(resolve => setTimeout(resolve, 2000 * retryCount));
return doMerge();
}
};

return doMerge();
}
7. 实现真实进度条

基于 Axios 的 onUploadProgress 事件回调更新维护进度 chunkProgressMap

a. 初始化分片进度

1
2
3
4
5
6
// 记录每个分片的上传进度
const chunkProgressMap = new Map<number, number>();
chunks.forEach((_, index) => {
// 已上传的分片进度设为100%,未上传的设为0
chunkProgressMap.set(index, uploadedChunks.includes(index) ? 100 : 0);
});

b. 上传单个分片时,通过 onUploadProgress 事件将单个分片的进度维护到 chunkProgressMap

1
2
3
4
5
6
7
8
(e: AxiosProgressEvent) => {
// 更新单个分片的进度
if (e.total !== undefined && e.loaded !== undefined) {
const chunkProgress = Math.floor((e.loaded / e.total) * 100);
chunkProgressMap.set(index, chunkProgress);
updateTotalProgress();
}
}

c. 计算总进度(通过所维护的 chunkProgressMap)

1
2
3
4
5
6
const updateTotalProgress = () => {
if (!onProgress) return;
const totalProgress = Array.from(chunkProgressMap.values())
.reduce((sum, progress) => sum + progress, 0) / chunks.length;
onProgress(Math.floor(totalProgress));
};
x. 更进一步
  1. 可构建 WebWorker Pool 来管理 Worker,提高利用率;同理可使用 Promise Pool 来管理上传时并发请求
  2. 文件过大时可以不计算 MD5,而采用 CRC32,因分片的 hash 其实只是为了标识分片, 对于唯一性要求并不高。CRC32的十六进制表示只有8位(MD5有32位), 且 CPU 对计算 CRC32 有硬件加速, 速度会比计算 MD5 快得多
  3. 在每轮 Hash 计算开始前释放掉上一次计算 Hash 时使用的 ArrayBuffer ,从而减少大量内存占用

服务端部分

核心功能
  1. 目录管理和初始化
  2. 文件秒传检测
  3. 分片上传机制
  4. 分片合并处理
  5. 文件管理(列表、下载)
详细实现逻辑
1. 服务器初始化
  • 创建上传主目录和临时目录
  • 检查目录权限确保可写
  • 配置跨域和请求解析中间件
1
2
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');
const TEMP_DIR = path.resolve(UPLOAD_DIR, 'temp');
2. 文件秒传功能
  • 基于文件哈希值检查文件是否已存在
  • 存在则直接返回文件路径,无需重新上传
  • API路径:POST /api/check
3. 分片上传流程
  • 获取已上传分片列表(GET /api/uploaded/chunks
  • 上传单个分片(POST /api/upload/chunk
    • 将分片保存到临时目录的对应哈希值子目录中
    • 使用分片索引作为文件名
4. 分片合并处理
  • 接收合并请求(POST /api/merge
  • 按索引顺序合并所有分片
  • 使用流式处理减少内存占用
  • 合并完成后删除临时分片
5. 文件管理
  • 获取文件列表(GET /api/files
  • 提供文件下载(GET /api/download/:hash
  • 清理临时文件(POST /api/clean
关键实现点
  1. 哈希去重:使用文件哈希实现秒传和文件唯一标识

    1
    2
    3
    4
    5
    // 检查是否存在以fileHash开头的文件
    const exists = files.some(file => {
    const fileBaseName = path.basename(file, path.extname(file));
    return fileBaseName === fileHash;
    });
  2. 分片目录结构:按文件哈希创建目录,分片索引作为文件名

    1
    2
    const chunkDir = path.resolve(TEMP_DIR, fileHash);
    const finalPath = path.resolve(chunkDir, `${chunkIndex}`);
  3. 流式合并:使用读写流减少内存占用

    1
    2
    const writeStream = fs.createWriteStream(filePath);
    readStream.pipe(writeStream, { end: false });
  4. 元数据存储:保存原始文件名等信息

    1
    2
    3
    4
    await fsPromises.writeFile(
    path.resolve(UPLOAD_DIR, `${fileHash}.json`),
    JSON.stringify(fileInfo, null, 2)
    );
  5. 异步处理:使用async/await和Promise处理异步操作

    • 规避回调地狱
    • 更好的错误处理机制
  6. 错误处理:完善的错误捕获和日志记录

  7. 文件权限检查:启动时验证目录权限,确保服务稳定

附录

为什么不采用流式

必须要要先计算所有的文件分片才能拿到文件的特征值,这样才能确定这个文件是不是已经上传过了,所以没办法采用流式上传,即:计算一片,上传一片的方式

SparkMD5

SparkMD5 是一个用于计算 MD5 哈希值的 JavaScript 库,SparkMD5.ArrayBuffer() 创建了一个专门用于处理 ArrayBuffer 数据的 MD5 计算器实例。

1
2
3
4
5
6
7
// 用于直接计算整段 ArrayBuffer 的MD5,无需分块追加数据
SparkMD5.ArrayBuffer.hash();
// 分块追加,最后一并读出
const spark = new SparkMD5.ArrayBuffer();
spark.append();
spark.append();
spark.end();
FileReader

FileReader 是浏览器提供的一个 API,用于读取文件内容。在大文件分片上传的场景中,它的主要作用包括:

  1. FileReader 可以将文件内容读取为不同的格式,如:

    • ArrayBuffer(二进制数据)
    • DataURL(Base64 编码的字符串)
    • Text(文本内容)
    • BinaryString(二进制字符串)
  2. FileReader 提供了异步读取文件的方法,不会阻塞主线程,通过事件监听机制处理读取结果:

    • onload:读取成功时触发
    • onerror:读取失败时触发
    • onprogress:读取过程中触发,可用于显示进度
  3. 分片处理支持:

    • 读取每个文件分片的内容
    • 将分片内容转换为 ArrayBuffer 用于 MD5 计算
    • 处理上传进度显示
ArrayBuffer
  • ArrayBuffer 是 JavaScript 中用于处理二进制数据的底层对象
  • 它特别适合处理文件数据,因为文件本质上就是二进制数据
  • ArrayBuffer 是可 Transfer 的对象, 在主线程与 Worker 线程通信时, 可以通过移交控制权的方式通信, 避免线程通信引起的结构化克隆
完整代码
fileUpload.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
// fileUpload.ts
import SparkMD5 from 'spark-md5';
import axios from 'axios';
import type { AxiosProgressEvent } from 'axios';

// API基础URL,指向Express后端服务器
const API_BASE_URL = 'http://localhost:3000/api';
// 最大并发请求数
const MAX_CONCURRENT_REQUESTS = 6;

/**
* 创建分片
* @param file - 需要分片的文件对象
* @param chunkSize - 每个分片的大小(MB)
* @returns 返回分片后的Blob数组
*/
export const createChunks = (file: File, chunkSize: number) => {
const CHUNK_SIZE = 1024 * 1024;
chunkSize *= CHUNK_SIZE;
const chunks = [];
for(let i = 0; i < file.size; i += chunkSize) {
chunks.push(file.slice(i, i + chunkSize));
};
return chunks;
};

// 读取单个分片并转换为 ArrayBuffer
export const readAsArrayBuffer = (chunk: Blob) => {
return new Promise((resolve) => {
const fileReader = new FileReader();
// 注册 onload 事件,当读取完成时触发
fileReader.onload = (e: ProgressEvent<FileReader>) => {
resolve(e.target?.result as ArrayBuffer);
}
// 使用 readAsArrayBuffer 方法读取文件,即为 ArrayBuffer 格式
fileReader.readAsArrayBuffer(chunk);
})
}

// 批量将分片转换为 ArrayBuffer
export const getArrayBufFromBlobs = (chunks: Blob[]) => {
return Promise.all(chunks.map(chunk => readAsArrayBuffer(chunk)));
}

/**
* 分多个线程计算每个分片的Hash值
* @param arrayBufs - 文件分片的ArrayBuffer数组
* @returns 返回分片计算后的Hash值数组
*/
export const getChunksHash = async (arrayBufs: ArrayBuffer[]) => {
// 根据当前浏览器所在设备的核心数量设置线程数量
const THREAD_COUNT = navigator.hardwareConcurrency || 4;
// 每个线程能够分到的分片数量
const chunkPerThread = Math.ceil(arrayBufs.length / THREAD_COUNT);
// 根据线程数量循环计算分片hash
const chunksHashT: string[] = [];
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
const worker = new Worker(new URL('../hashWorker.js', import.meta.url), { type: 'module' });
worker.postMessage({
arrayBufs: arrayBufs.slice(i * chunkPerThread, (i + 1) * chunkPerThread)
});

workerPromises.push(new Promise((resolve) => {
worker.onmessage = (e) => {
worker.terminate();
// 此处不能使用 push,因为 worker 完成的先后无法保证,所以使用下标
chunksHashT[i] = e.data;
resolve(e.data);
}
}));
}

await Promise.all(workerPromises);
return chunksHashT.flat();
}

/**
* 计算文件 Hash 使用 Merkle Tree 算法
* @param chunksHash - 分片 Hash 数组
* @returns 返回文件 Hash
*/
export const getFileHash = (chunksHash: string[]): string => {
// 如果只有一个分片,直接返回它的hash
if (chunksHash.length === 1) {
return chunksHash[0];
}

// 初始化一个新数组用于存储中间结果
const nextLevel = [];

// 两两配对计算父节点的hash
for (let i = 0; i < chunksHash.length; i += 2) {
if (i + 1 < chunksHash.length) {
// 如果有两个子节点,将它们的hash合并并计算父节点的hash
const combined = chunksHash[i] + chunksHash[i + 1];
nextLevel.push(SparkMD5.hash(combined));
} else {
// 如果只剩一个子节点,直接将它放入下一层
nextLevel.push(chunksHash[i]);
}
}

// 递归计算,直到只剩一个根节点
return getFileHash(nextLevel);
};

/**
* 检查文件是否已存在(秒传检查)
* @param fileHash - 文件的哈希值
* @param filename - 文件名
* @returns 返回检查结果,如果存在则返回相关信息
*/
export const checkFileExists = async (fileHash: string, filename: string): Promise<{exists: boolean, url?: string}> => {
try {
console.log(`请求检查文件是否存在: fileHash=${fileHash}, filename=${filename}`);

const response = await axios.post(`${API_BASE_URL}/check`, {
fileHash,
filename
});

console.log('检查文件是否存在的响应:', response.data);

return {
exists: response.data.exists,
url: response.data.url
};
} catch (error) {
console.error('检查文件是否存在失败:', error);
if (axios.isAxiosError(error) && error.response) {
console.error('服务器返回错误:', error.response.data);
}
return { exists: false };
}
}

/**
* 上传单个分片
* @param chunk - 分片数据
* @param filename - 文件名
* @param fileHash - 文件哈希
* @param index - 分片索引
* @param onProgress - 进度回调函数
* @returns 返回上传结果
*/
export const uploadChunk = async (
chunk: Blob,
filename: string,
fileHash: string,
index: number,
onProgress?: (e: AxiosProgressEvent) => void
) => {
// 创建FormData对象
const formData = new FormData();

// 添加一个文件名为`chunk`的文件对象
// 注意: 文件名必须唯一,避免浏览器缓存问题
const chunkFile = new File([chunk], `chunk-${index}-${Date.now()}`, {
type: 'application/octet-stream'
});
formData.append('chunk', chunkFile);

// 添加其他表单字段
formData.append('filename', filename);
formData.append('fileHash', fileHash);
formData.append('chunkIndex', index.toString());

try {
console.log(`开始上传分片 ${index}, 大小: ${chunk.size} 字节`);

const response = await axios.post(`${API_BASE_URL}/upload/chunk`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: onProgress,
// 增加超时时间
timeout: 60000,
});

console.log(`分片 ${index} 上传成功:`, response.data);
return response.data;
} catch (error) {
console.error(`上传分片${index}失败:`, error);

// 添加更详细的错误信息
if (axios.isAxiosError(error) && error.response) {
console.error(`服务器返回错误 (${error.response.status}):`, error.response.data);
}

throw error;
}
}

/**
* 获取已上传的分片列表
* @param fileHash - 文件哈希
* @returns 返回已上传的分片索引数组
*/
export const getUploadedChunks = async (fileHash: string): Promise<number[]> => {
try {
console.log(`请求已上传分片列表: fileHash=${fileHash}`);

const response = await axios.get(`${API_BASE_URL}/uploaded/chunks`, {
params: { fileHash },
timeout: 10000, // 10秒超时
});

console.log('获取已上传分片列表成功:', response.data);
return response.data.uploadedChunks || [];
} catch (error) {
console.error('获取已上传分片列表失败:', error);
if (axios.isAxiosError(error) && error.response) {
console.error('服务器返回错误:', error.response.data);
}
throw error;
}
}

/**
* 控制并发上传分片
* @param chunks - 分片数组
* @param filename - 文件名
* @param fileHash - 文件哈希
* @param onProgress - 总进度回调函数
* @returns 返回是否全部上传成功
*/
export const uploadChunksWithConcurrencyControl = async (
chunks: Blob[],
filename: string,
fileHash: string,
onProgress?: (percent: number) => void
): Promise<boolean> => {
// 获取已上传的分片列表(断点续传)
let uploadedChunks: number[] = [];
try {
const response = await getUploadedChunks(fileHash);
uploadedChunks = response;
console.log('已上传的分片:', uploadedChunks);
} catch (error) {
console.error('获取已上传分片列表失败:', error);
// 如果获取失败,假设没有已上传的分片
uploadedChunks = [];
}

// 过滤出未上传的分片
const pendingChunks = chunks
.map((chunk, index) => ({ chunk, index }))
.filter(item => !uploadedChunks.includes(item.index));

// 如果所有分片都已上传,直接返回成功
if (pendingChunks.length === 0) {
onProgress && onProgress(100);
return true;
}

// 记录每个分片的上传进度
const chunkProgressMap = new Map<number, number>();
chunks.forEach((_, index) => {
// 已上传的分片进度设为100%,未上传的设为0
chunkProgressMap.set(index, uploadedChunks.includes(index) ? 100 : 0);
});

// 计算并调用总进度回调
const updateTotalProgress = () => {
if (!onProgress) return;

const totalProgress = Array.from(chunkProgressMap.values())
.reduce((sum, progress) => sum + progress, 0) / chunks.length;

onProgress(Math.floor(totalProgress));
};

// 初始更新一次进度(显示已上传部分)
updateTotalProgress();

// 最大重试次数
const MAX_RETRY = 3;

// 上传单个分片的包装函数,包含重试逻辑
const uploadChunkWithRetry = async (chunk: Blob, index: number): Promise<any> => {
let retryCount = 0;

while (retryCount < MAX_RETRY) {
try {
const result = await uploadChunk(
chunk,
filename,
fileHash,
index,
(e: AxiosProgressEvent) => {
console.log(e);
// 更新单个分片的进度
if (e.total !== undefined && e.loaded !== undefined) {
const chunkProgress = Math.floor((e.loaded / e.total) * 100);
chunkProgressMap.set(index, chunkProgress);
updateTotalProgress();
}
}
);

// 上传成功,设置为100%
chunkProgressMap.set(index, 100);
updateTotalProgress();

return result;
} catch (error) {
retryCount++;
console.error(`分片${index}上传失败,第${retryCount}次重试:`, error);

if (retryCount >= MAX_RETRY) {
throw new Error(`分片${index}上传失败,已达到最大重试次数`);
}

// 重试前等待一段时间
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
}
}
};

// 并发控制上传
const uploadQueue = async () => {
let failed = false;

for (let i = 0; i < pendingChunks.length && !failed; i += MAX_CONCURRENT_REQUESTS) {
const batch = pendingChunks.slice(i, i + MAX_CONCURRENT_REQUESTS);

try {
// 并行上传当前批次
await Promise.all(
batch.map(({ chunk, index }) => uploadChunkWithRetry(chunk, index))
);
} catch (error) {
console.error('批次上传失败:', error);
failed = true;
throw error;
}
}

return !failed;
};

try {
const result = await uploadQueue();
return result;
} catch (error) {
console.error('上传分片失败:', error);
return false;
}
};

/**
* 请求服务器合并分片
* @param fileHash - 文件哈希
* @param filename - 文件名
* @param size - 文件大小
* @returns 返回合并结果
*/
export const mergeChunks = async (fileHash: string, filename: string, size: number) => {
// 最大重试次数
const MAX_RETRY = 2;
let retryCount = 0;

const doMerge = async () => {
try {
console.log('请求合并分片:', { fileHash, filename, size });

const response = await axios.post(`${API_BASE_URL}/merge`, {
fileHash,
filename,
size
}, {
timeout: 120000, // 2分钟超时,大文件合并可能需要较长时间
});

console.log('合并分片成功:', response.data);
return response.data;
} catch (error) {
console.error('合并分片失败:', error);

if (axios.isAxiosError(error) && error.response) {
console.error('服务器返回错误:', error.response.data);

// 如果是400错误,可能是请求参数问题,不再重试
if (error.response.status === 400) {
throw new Error(`合并分片请求错误: ${error.response.data.message || '参数错误'}`);
}
}

// 如果已达到最大重试次数,抛出错误
if (retryCount >= MAX_RETRY) {
throw error;
}

// 重试
retryCount++;
console.log(`合并分片失败,第${retryCount}次重试`);
// 重试前等待一段时间
await new Promise(resolve => setTimeout(resolve, 2000 * retryCount));
return doMerge();
}
};

return doMerge();
}

/**
* 处理文件上传
* @param file - 要上传的文件对象
* @param onProgress - 进度回调函数
* @returns 返回上传结果
*/
export const processFileUpload = async (file: File, onProgress?: (percent: number) => void) => {
if (!file) return null;

// 1.使用 Blob 对象的 slice 方法进行文件分片
const chunks = createChunks(file, 10);

// 2. 将分片转换为 ArrayBuffer
const arrayBufs = await getArrayBufFromBlobs(chunks);

// 3. 计算分片 Hash
const chunksHash = await getChunksHash(arrayBufs as ArrayBuffer[]);
console.log(chunksHash);

// 4. 计算文件 Hash
const fileHash = getFileHash(chunksHash);
console.log(fileHash);

// 5. 检查文件是否已存在(秒传)
const { exists, url } = await checkFileExists(fileHash, file.name);
if (exists) {
console.log('文件已存在,秒传成功', url);
onProgress && onProgress(100);
return {
fileHash,
success: true,
skipUpload: true,
url
};
}

// 6. 上传所有分片
const uploadSuccess = await uploadChunksWithConcurrencyControl(
chunks,
file.name,
fileHash,
onProgress
);

if (!uploadSuccess) {
return { fileHash, success: false };
}

// 7. 请求合并分片
try {
const mergeResult = await mergeChunks(fileHash, file.name, file.size);
return {
fileHash,
success: true,
url: mergeResult.url
};
} catch (error) {
console.error('合并分片失败:', error);
return { fileHash, success: false };
}
}
App.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
// App.vue
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { processFileUpload } from './utils/fileUpload';
import axios from 'axios';

const API_BASE_URL = 'http://localhost:3000/api';
const file = ref<File | null>(null);
const uploadProgress = ref<number>(0);
const fileHash = ref<string | null>(null);
const fileUrl = ref<string | null>(null);
const fileList = ref<any[]>([]);
const loadingFiles = ref(false);
const showFileList = ref(false);

const uploadStatus = reactive({
uploading: false,
cleaning: false,
success: false,
error: false,
skipUpload: false,
message: ''
});

// 获取已上传文件列表
const getFileList = async () => {
loadingFiles.value = true;
try {
const response = await axios.get(`${API_BASE_URL}/files`);
if (response.data.success) {
fileList.value = response.data.files;
} else {
console.error('获取文件列表失败:', response.data.message);
uploadStatus.message = '获取文件列表失败';
}
} catch (error) {
console.error('获取文件列表出错:', error);
uploadStatus.message = '获取文件列表出错';
} finally {
loadingFiles.value = false;
}
};

// 切换显示文件列表
const toggleFileList = async () => {
showFileList.value = !showFileList.value;
if (showFileList.value && fileList.value.length === 0) {
await getFileList();
}
};

// 下载文件
const downloadFile = (hash: string, name: string) => {
window.location.href = `${API_BASE_URL}/download/${hash}`;
};

// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';

const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));

return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};

// 格式化日期时间
const formatDateTime = (dateString: string): string => {
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch (e) {
return dateString;
}
};

const uploadHandler = async (e: Event) => {
file.value = (e.target as HTMLInputElement).files?.[0] || null;
if (!file.value) return;

uploadStatus.uploading = true;
uploadStatus.success = false;
uploadStatus.error = false;
uploadStatus.skipUpload = false;
uploadStatus.message = '开始上传...';
fileUrl.value = null;

try {
const result = await processFileUpload(file.value, (percent) => {
uploadProgress.value = percent;
});

if (result) {
fileHash.value = result.fileHash;

if (result.url) {
fileUrl.value = `http://localhost:3000${result.url}`;
}

if (result.skipUpload) {
uploadStatus.skipUpload = true;
uploadStatus.message = '文件已存在,秒传成功!';

// 秒传成功后也更新文件列表
if (showFileList.value) {
await getFileList();
}
} else if (result.success) {
uploadStatus.success = true;
uploadStatus.message = '上传成功!';

// 刷新文件列表
if (showFileList.value) {
await getFileList();
}
} else {
uploadStatus.error = true;
uploadStatus.message = '上传失败,请重试。';
}
} else {
uploadStatus.error = true;
uploadStatus.message = '上传过程出错。';
}
} catch (error) {
console.error('上传过程出错:', error);
uploadStatus.error = true;
uploadStatus.message = `上传出错: ${error instanceof Error ? error.message : '未知错误'}`;
} finally {
uploadStatus.uploading = false;
}
};

// 重置上传状态
const resetUpload = () => {
file.value = null;
uploadProgress.value = 0;
fileHash.value = null;
fileUrl.value = null;
uploadStatus.uploading = false;
uploadStatus.success = false;
uploadStatus.error = false;
uploadStatus.skipUpload = false;
uploadStatus.message = '';

// 清空文件输入框
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
};

// 页面加载时初始化
onMounted(() => {
getFileList();
});
</script>

<template>
<div class="container">
<h1>大文件分片上传示例</h1>

<div class="upload-area">
<input @change="uploadHandler" type="file" :disabled="uploadStatus.uploading || uploadStatus.cleaning" />
<button @click="toggleFileList" class="list-btn">
{{ showFileList ? '隐藏文件列表' : '查看已上传文件' }}
</button>
</div>

<div v-if="file" class="file-info">
<h3>文件信息</h3>
<p>名称: {{ file.name }}</p>
<p>大小: {{ (file.size / (1024 * 1024)).toFixed(2) }} MB</p>
<p>类型: {{ file.type || '未知' }}</p>
</div>

<div v-if="uploadStatus.uploading || uploadProgress > 0" class="progress-container">
<div class="progress-label">上传进度: {{ uploadProgress }}%</div>
<div class="progress-bar">
<div class="progress-fill" :style="{width: `${uploadProgress}%`}"></div>
</div>
</div>

<div v-if="uploadStatus.message" :class="[
'status-message',
{ 'success': uploadStatus.success || uploadStatus.skipUpload,
'error': uploadStatus.error }
]">
{{ uploadStatus.message }}
</div>

<div v-if="fileHash" class="hash-result">
<h3>文件Hash值:</h3>
<p>{{ fileHash }}</p>

<div v-if="fileUrl" class="file-url">
<h3>文件链接:</h3>
<p>
<a :href="fileUrl" target="_blank">{{ fileUrl }}</a>
</p>
</div>
</div>

<!-- 文件列表 -->
<div v-if="showFileList" class="file-list-container">
<h3>已上传文件列表</h3>

<div v-if="loadingFiles" class="loading">
加载文件列表中...
</div>

<div v-else-if="fileList.length === 0" class="no-files">
暂无已上传文件
<button @click="getFileList" class="refresh-btn">刷新</button>
</div>

<table v-else class="file-table">
<thead>
<tr>
<th>文件名</th>
<th>大小</th>
<th>类型</th>
<th>上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in fileList" :key="item.hash">
<td>{{ item.name }}</td>
<td>{{ formatFileSize(item.size) }}</td>
<td>{{ item.type }}</td>
<td>{{ formatDateTime(item.uploadTime) }}</td>
<td>
<a :href="item.url" target="_blank" class="view-btn">查看</a>
<button @click="downloadFile(item.hash, item.name)" class="download-btn">下载</button>
</td>
</tr>
</tbody>
</table>

<div class="file-list-footer">
<button @click="getFileList" class="refresh-btn">刷新列表</button>
</div>
</div>
</div>
</template>

<style scoped>
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}

.upload-area {
margin: 20px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}

.upload-area button {
padding: 8px 15px;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}

.upload-area button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}

button.clean-btn {
background-color: #2196F3;
}

button.list-btn {
background-color: #4CAF50;
}

button.refresh-btn {
background-color: #ff9800;
color: white;
border: none;
border-radius: 4px;
padding: 5px 10px;
cursor: pointer;
margin-top: 10px;
}

button:not(:disabled):hover {
opacity: 0.9;
}

.file-info {
margin-top: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 4px;
border-left: 4px solid #2196F3;
}

.progress-container {
margin-top: 20px;
}

.progress-label {
margin-bottom: 5px;
}

.progress-bar {
height: 20px;
width: 100%;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
}

.progress-fill {
height: 100%;
background-color: #4CAF50;
transition: width 0.3s ease;
}

.status-message {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
}

.status-message.success {
background-color: #e8f5e9;
color: #2e7d32;
border-left: 4px solid #4CAF50;
}

.status-message.error {
background-color: #ffebee;
color: #c62828;
border-left: 4px solid #f44336;
}

.hash-result, .file-url {
margin-top: 20px;
padding: 15px;
background: #f5f5f5;
border-radius: 4px;
word-break: break-all;
}

.file-url a {
color: #2196F3;
text-decoration: none;
}

.file-url a:hover {
text-decoration: underline;
}

/* 文件列表样式 */
.file-list-container {
margin-top: 30px;
padding: 15px;
background: #f9f9f9;
border-radius: 4px;
}

.file-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}

.file-table th, .file-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}

.file-table th {
background-color: #f2f2f2;
}

.file-table tr:hover {
background-color: #f5f5f5;
}

.loading, .no-files {
padding: 20px;
text-align: center;
color: #666;
}

.view-btn, .download-btn {
padding: 5px 10px;
margin-right: 5px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
}

.view-btn {
display: inline-block;
background-color: #2196F3;
color: white;
}

.download-btn {
background-color: #4CAF50;
color: white;
border: none;
}

.file-list-footer {
margin-top: 15px;
text-align: center;
}
</style>

server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
const express = require('express');
const cors = require('cors');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const fsPromises = fs.promises;

const app = express();
const port = 3000;

// 上传目录设置
const UPLOAD_DIR = path.resolve(__dirname, 'uploads');
const TEMP_DIR = path.resolve(UPLOAD_DIR, 'temp');

/**
* 确保上传目录存在并可写
*/
const ensureUploadDirs = () => {
console.log('检查上传目录...');

try {
// 创建主上传目录
if (!fs.existsSync(UPLOAD_DIR)) {
console.log(`创建上传目录: ${UPLOAD_DIR}`);
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}

// 创建临时目录
if (!fs.existsSync(TEMP_DIR)) {
console.log(`创建临时目录: ${TEMP_DIR}`);
fs.mkdirSync(TEMP_DIR, { recursive: true });
}

// 检查目录权限
try {
const testFile = path.join(TEMP_DIR, '.test');
fs.writeFileSync(testFile, 'test');
fs.unlinkSync(testFile);
console.log('目录权限检查通过');
} catch (error) {
console.error('目录写入权限检查失败:', error);
console.error('请确保应用程序有上传目录的写入权限');
throw new Error('目录权限错误,无法进行文件上传');
}

console.log('上传目录检查完成');
} catch (error) {
console.error('创建上传目录失败:', error);
throw error;
}
};

// 在应用启动前确保目录存在
ensureUploadDirs();

// 增强的CORS配置
const corsOptions = {
origin: '*', // 允许所有域名访问,生产环境中应该改为具体的前端域名
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400 // 24小时内不再预检
};

// 简单的字段解析中间件
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
app.use(express.json());

// 配置 multer - 使用临时存储
const upload = multer({
storage: multer.diskStorage({
destination: function (req, file, cb) {
// 使用临时目录
cb(null, TEMP_DIR);
},
filename: function (req, file, cb) {
// 使用时间戳 + 原始文件名作为临时文件名
cb(null, Date.now() + '-' + file.originalname);
}
}),
limits: {
fileSize: 50 * 1024 * 1024, // 限制50MB
}
});

// 中间件
app.use(cors(corsOptions));

// 基本路由
app.get('/', (req, res) => {
res.json({ message: 'Express 服务器正在运行!' });
});

/**
* 检查文件是否已存在(秒传)
* POST /api/check
*/
app.post('/api/check', async (req, res) => {
try {
const { fileHash, filename } = req.body;

if (!fileHash) {
return res.status(400).json({
exists: false,
message: '缺少必要参数: fileHash'
});
}

console.log(`检查文件是否存在: fileHash=${fileHash}, filename=${filename}`);

// 获取文件扩展名
const ext = filename ? path.extname(filename) : '';

// 列出uploads目录下的所有文件
const files = await fsPromises.readdir(UPLOAD_DIR);

// 检查是否存在以fileHash开头的文件(可能带有扩展名)
const exists = files.some(file => {
// 排除元数据文件
if (file.endsWith('.json')) {
return false;
}

// 获取文件名(不含扩展名)
const fileBaseName = path.basename(file, path.extname(file));

return fileBaseName === fileHash;
});

console.log(`文件${exists ? '已存在' : '不存在'}: ${fileHash}`);

// 如果文件存在,找到完整的文件路径
let fileUrl = null;
if (exists) {
// 查找实际的文件名(带扩展名)
const actualFile = files.find(file => {
if (file.endsWith('.json')) return false;
return path.basename(file, path.extname(file)) === fileHash;
});

if (actualFile) {
fileUrl = `/uploads/${actualFile}`;
}
}

res.json({
exists,
message: exists ? '文件已存在,可以秒传' : '文件不存在,需要上传',
url: fileUrl
});
} catch (error) {
console.error('检查文件是否存在出错:', error);
res.status(500).json({
exists: false,
message: '服务器错误',
error: error.toString()
});
}
});

/**
* 获取已上传的分片列表
* GET /api/uploaded/chunks
*/
app.get('/api/uploaded/chunks', async (req, res) => {
try {
const { fileHash } = req.query;
const chunkDir = path.resolve(TEMP_DIR, fileHash);

let uploadedChunks = [];

// 检查分片目录是否存在
if (fs.existsSync(chunkDir)) {
// 读取目录中的文件名(即分片索引)
const files = await fsPromises.readdir(chunkDir);
uploadedChunks = files.map(Number).sort((a, b) => a - b);
}

res.json({
uploadedChunks,
message: `已上传${uploadedChunks.length}个分片`
});
} catch (error) {
console.error('获取已上传分片出错:', error);
res.status(500).json({
uploadedChunks: [],
message: '服务器错误'
});
}
});

/**
* 上传分片
* POST /api/upload/chunk
*/
app.post('/api/upload/chunk', upload.single('chunk'), async (req, res) => {
try {
console.log('收到上传请求,准备处理文件分片');

if (!req.file) {
console.error('请求中没有找到文件');
return res.status(400).json({
success: false,
message: '请求中未找到文件'
});
}

// 从请求体中获取参数
const { fileHash, chunkIndex, filename } = req.body;

console.log('分片上传请求参数:', {
fileHash,
chunkIndex,
filename,
tempFile: req.file.path
});

if (!fileHash || chunkIndex === undefined) {
console.error('请求参数不完整');
return res.status(400).json({
success: false,
message: '请求参数不完整,需要 fileHash 和 chunkIndex'
});
}

// 确保分片目录存在
const chunkDir = path.resolve(TEMP_DIR, fileHash);
if (!fs.existsSync(chunkDir)) {
console.log(`创建分片目录: ${chunkDir}`);
fs.mkdirSync(chunkDir, { recursive: true });
}

// 将临时文件移动到正确的分片目录中
const finalPath = path.resolve(chunkDir, `${chunkIndex}`);

// 使用 fs.promises 的方式移动文件
try {
// 如果目标文件已存在,先删除
if (fs.existsSync(finalPath)) {
await fsPromises.unlink(finalPath);
}

// 读取临时文件
const data = await fsPromises.readFile(req.file.path);

// 写入到目标位置
await fsPromises.writeFile(finalPath, data);

// 删除临时文件
await fsPromises.unlink(req.file.path);

console.log(`分片移动成功: ${req.file.path} -> ${finalPath}`);
} catch (err) {
console.error('移动分片文件失败:', err);
return res.status(500).json({
success: false,
message: '保存分片失败: ' + err.message
});
}

res.json({
success: true,
message: `分片${chunkIndex}上传成功`,
path: finalPath
});
} catch (error) {
console.error('处理上传分片请求出错:', error);
res.status(500).json({
success: false,
message: '服务器错误',
error: error.toString()
});
}
});

/**
* 合并分片
* POST /api/merge
*/
app.post('/api/merge', async (req, res) => {
try {
console.log('收到合并分片请求:', req.body);
const { fileHash, filename, size } = req.body;

if (!fileHash) {
return res.status(400).json({
success: false,
message: '缺少必要参数: fileHash'
});
}

// 获取文件扩展名
const ext = path.extname(filename);

const chunkDir = path.resolve(TEMP_DIR, fileHash);
// 使用哈希值和原始文件扩展名作为文件名
const filePath = path.resolve(UPLOAD_DIR, `${fileHash}${ext}`);

console.log('合并文件信息:', {
chunkDir,
filePath,
filename,
size,
extension: ext
});

// 检查分片目录是否存在
if (!fs.existsSync(chunkDir)) {
console.error(`分片目录不存在: ${chunkDir}`);

// 列出临时目录下的所有文件和子目录,帮助调试
try {
const tempFiles = await fsPromises.readdir(TEMP_DIR);
console.log('临时目录下的文件/目录:', tempFiles);
} catch (err) {
console.error('读取临时目录失败:', err);
}

return res.status(400).json({
success: false,
message: '没有找到文件分片',
detail: `分片目录 ${chunkDir} 不存在`
});
}

// 读取所有分片
let chunks;
try {
chunks = await fsPromises.readdir(chunkDir);
} catch (err) {
console.error(`读取分片目录 ${chunkDir} 失败:`, err);
return res.status(500).json({
success: false,
message: '读取分片失败',
error: err.message
});
}

if (chunks.length === 0) {
console.error('分片目录为空');
return res.status(400).json({
success: false,
message: '分片目录为空,无法合并'
});
}

console.log(`找到${chunks.length}个分片, 开始合并...`);

// 按照索引排序(确保是数字排序,而不是字符串排序)
chunks.sort((a, b) => parseInt(a) - parseInt(b));

// 创建写入流
const writeStream = fs.createWriteStream(filePath);
let mergeSuccess = true;

// 使用 Promise 方式合并分片
await new Promise((resolve, reject) => {
// 写入完成事件
writeStream.on('finish', () => {
console.log('所有分片写入完成');
resolve();
});

// 写入错误事件
writeStream.on('error', (err) => {
console.error('写入文件错误:', err);
mergeSuccess = false;
reject(err);
});

// 按顺序写入每个分片
function writeChunk(index) {
if (index >= chunks.length) {
// 所有分片都已写入,结束流
writeStream.end();
return;
}

const chunkPath = path.resolve(chunkDir, chunks[index]);
console.log(`正在合并分片 ${chunks[index]}, 路径: ${chunkPath}`);

// 检查分片文件是否存在
if (!fs.existsSync(chunkPath)) {
console.error(`分片文件不存在: ${chunkPath}`);
writeChunk(index + 1); // 跳过并继续处理下一个分片
return;
}

// 创建读取流
const readStream = fs.createReadStream(chunkPath);

readStream.on('end', () => {
console.log(`分片 ${chunks[index]} 合并完成`);

// 继续下一个分片
writeChunk(index + 1);
});

readStream.on('error', (err) => {
console.error(`读取分片 ${chunks[index]} 错误:`, err);

// 继续下一个分片
writeChunk(index + 1);
});

// 管道连接:将读取流的数据导入写入流
readStream.pipe(writeStream, { end: false });
}

// 开始第一个分片
writeChunk(0);
});

if (!mergeSuccess) {
return res.status(500).json({
success: false,
message: '合并分片过程中出错'
});
}

// 合并完成后,删除分片目录
console.log('删除临时分片目录:', chunkDir);
try {
// 先删除目录中的所有文件
for (const chunk of chunks) {
const chunkPath = path.resolve(chunkDir, chunk);
if (fs.existsSync(chunkPath)) {
await fsPromises.unlink(chunkPath);
}
}

// 再删除目录
await fsPromises.rmdir(chunkDir);
console.log('临时分片目录删除成功');
} catch (error) {
console.error('删除临时分片目录失败:', error);
// 继续执行,不影响结果
}

// 保存原始文件名和其他元数据
const fileInfo = {
originalName: filename,
size,
extension: ext,
uploadTime: new Date().toISOString()
};

await fsPromises.writeFile(
path.resolve(UPLOAD_DIR, `${fileHash}.json`),
JSON.stringify(fileInfo, null, 2)
);

console.log('文件合并成功:', filePath);

res.json({
success: true,
message: '文件合并成功',
url: `/uploads/${fileHash}${ext}`,
fileInfo
});
} catch (error) {
console.error('合并分片出错:', error);
res.status(500).json({
success: false,
message: error.message || '服务器错误',
error: error.toString()
});
}
});

/**
* 清理分片目录 - 用于测试
* POST /api/clean
*/
app.post('/api/clean', async (req, res) => {
try {
// 列出临时目录中的所有内容
const dirs = await fsPromises.readdir(TEMP_DIR);
console.log('待清理的目录:', dirs);

// 清理每个子目录
for (const dir of dirs) {
const dirPath = path.join(TEMP_DIR, dir);
const stat = await fsPromises.stat(dirPath);

if (stat.isDirectory()) {
console.log(`清理目录: ${dirPath}`);
try {
// 读取目录中的所有文件
const files = await fsPromises.readdir(dirPath);

// 删除每个文件
for (const file of files) {
await fsPromises.unlink(path.join(dirPath, file));
}

// 删除目录
await fsPromises.rmdir(dirPath);
console.log(`目录 ${dirPath} 已清理`);
} catch (err) {
console.error(`清理目录 ${dirPath} 时出错:`, err);
}
}
}

res.json({
success: true,
message: '分片目录已清理'
});
} catch (error) {
console.error('清理分片目录出错:', error);
res.status(500).json({
success: false,
message: error.message
});
}
});

/**
* 获取已上传文件列表
* GET /api/files
*/
app.get('/api/files', async (req, res) => {
try {
// 获取uploads目录中的所有文件
const files = await fsPromises.readdir(UPLOAD_DIR);
const fileList = [];

// 处理每个文件
for (const file of files) {
// 跳过JSON文件和隐藏文件
if (file.endsWith('.json') || file.startsWith('.')) {
continue;
}

try {
const filePath = path.resolve(UPLOAD_DIR, file);
const stat = await fsPromises.stat(filePath);

// 尝试读取对应的元数据文件
const fileHash = path.basename(file).split('.')[0]; // 获取文件名的哈希部分
const metaFilePath = path.resolve(UPLOAD_DIR, `${fileHash}.json`);

let metaData = {};
if (fs.existsSync(metaFilePath)) {
const metaContent = await fsPromises.readFile(metaFilePath, 'utf8');
metaData = JSON.parse(metaContent);
}

fileList.push({
name: metaData.originalName || file,
hash: fileHash,
size: stat.size,
uploadTime: metaData.uploadTime || stat.mtime.toISOString(),
url: `/uploads/${file}`,
type: metaData.extension || path.extname(file) || '未知'
});
} catch (err) {
console.error(`处理文件 ${file} 时出错:`, err);
}
}

// 按上传时间降序排序
fileList.sort((a, b) => new Date(b.uploadTime).getTime() - new Date(a.uploadTime).getTime());

res.json({
success: true,
files: fileList
});
} catch (error) {
console.error('获取文件列表出错:', error);
res.status(500).json({
success: false,
message: '获取文件列表失败',
error: error.message
});
}
});

/**
* 下载文件
* GET /api/download/:hash
*/
app.get('/api/download/:hash', async (req, res) => {
try {
const { hash } = req.params;

// 查找该哈希对应的文件
const files = await fsPromises.readdir(UPLOAD_DIR);
let targetFile = null;
let metaData = null;

// 查找以hash开头的文件
for (const file of files) {
if (file.startsWith(hash) && !file.endsWith('.json')) {
targetFile = file;
break;
}
}

if (!targetFile) {
return res.status(404).json({
success: false,
message: '文件不存在'
});
}

// 尝试读取元数据
try {
const metaContent = await fsPromises.readFile(
path.resolve(UPLOAD_DIR, `${hash}.json`),
'utf8'
);
metaData = JSON.parse(metaContent);
} catch (err) {
console.warn(`未找到元数据文件 ${hash}.json:`, err);
}

const filePath = path.resolve(UPLOAD_DIR, targetFile);

// 设置文件下载的响应头
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(metaData?.originalName || targetFile)}"`);
res.setHeader('Content-Type', 'application/octet-stream');

// 创建读取流并输出到响应
const fileStream = fs.createReadStream(filePath);
fileStream.pipe(res);
} catch (error) {
console.error('下载文件时出错:', error);
res.status(500).json({
success: false,
message: '下载文件失败',
error: error.message
});
}
});

// 提供静态文件访问
app.use('/uploads', express.static(UPLOAD_DIR));

// 错误处理中间件
app.use((err, req, res, next) => {
console.error('服务器错误:', err);
res.status(500).json({
success: false,
message: '服务器内部错误',
error: err.toString()
});
});

// 处理未找到的路由
app.use((req, res) => {
console.log(`未找到路由: ${req.method} ${req.url}`);
res.status(404).json({
success: false,
message: '未找到请求的资源'
});
});

// 启动服务器
const server = app.listen(port, () => {
console.log('===========================================');
console.log(`大文件分片上传服务已启动: http://localhost:${port}`);
console.log('上传目录信息:');
console.log(`- 主目录: ${UPLOAD_DIR}`);
console.log(`- 临时目录: ${TEMP_DIR}`);
console.log('API 路径:');
console.log(`- 检查文件: POST http://localhost:${port}/api/check`);
console.log(`- 获取分片: GET http://localhost:${port}/api/uploaded/chunks?fileHash={hash}`);
console.log(`- 上传分片: POST http://localhost:${port}/api/upload/chunk`);
console.log(`- 合并文件: POST http://localhost:${port}/api/merge`);
console.log(`- 访问文件: GET http://localhost:${port}/uploads/{hash}`);
console.log('===========================================');
});

// 错误处理
server.on('error', (error) => {
console.error('服务器启动失败:', error);
if (error.code === 'EADDRINUSE') {
console.error(`端口 ${port} 已被占用,请尝试使用其他端口`);
}
process.exit(1);
});