跳到主要内容

上传流程详解 (Upload Flow)

本文档详细讲解文件上传的完整流程,包括普通上传和秒传两种场景,帮助你理解每一步的执行逻辑。


🎬 核心流程概览

两种上传模式对比

模式触发条件流程耗时
秒传MD5在数据库中已存在前端→后端→返回已有文件ID~100ms
普通上传MD5不存在,需要实际上传前端→后端→云端→确认视文件大小而定

核心优势

graph LR
A[传统上传] -->|文件经过后端| B[慢 + 占带宽]
C[预签名URL上传] -->|文件直传云端| D[快 + 省带宽]
E[秒传] -->|复用已有文件| F[最快 + 零带宽]

style B fill:#ff9999
style D fill:#99ff99
style F fill:#66ccff

🔄 普通上传流程

流程图

sequenceDiagram
autonumber
participant 用户 as 👤 用户
participant 前端 as 🖥️ 浏览器
participant 后端 as 🚀 Spring Boot
participant DB as 💾 MySQL
participant S3 as ☁️ Bitiful S4

用户->>前端: 选择文件
前端->>前端: 计算MD5哈希

Note over 前端,后端: 阶段 1: 获取预签名URL
前端->>后端: POST /presigned<br/>{fileName, size, md5}
后端->>DB: SELECT WHERE md5=? AND size=?

alt MD5不存在(需要上传)
DB-->>后端: 未找到
后端->>后端: 生成fileKey:<br/>uploads/2025/12/13/uuid.jpg
后端->>DB: INSERT file_file<br/>(status=PENDING)
后端->>S3: 调用S3Presigner<br/>生成PUT预签名URL
S3-->>后端: 返回签名URL<br/>(30分钟有效)
后端-->>前端: {instant:false, uploadUrl, fileId}

Note over 前端,S3: 阶段 2: 直传云端
前端->>S3: PUT uploadUrl<br/>(直接上传文件)
S3-->>前端: 200 OK

Note over 前端,后端: 阶段 3: 确认上传
前端->>后端: PATCH /files/{fileId}/confirm
后端->>DB: UPDATE status=COMPLETED
后端-->>前端: 上传成功
前端->>用户: 🎉 上传成功!

else MD5已存在(秒传)
Note over 前端,后端: 详见秒传流程
end

阶段详解

阶段1: 获取预签名URL

前端代码

// 1.1 计算文件MD5
async function calculateMD5(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
const chunkSize = 2097152; // 2MB
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;

fileReader.onload = (e) => {
spark.append(e.target.result);
currentChunk++;

if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};

const loadNext = () => {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
fileReader.readAsArrayBuffer(file.slice(start, end));
};

loadNext();
});
}

// 1.2 请求预签名URL
async function getPresignedUrl(file) {
const md5 = await calculateMD5(file);

const response = await fetch('/api/v1/files/presigned', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
fileName: file.name,
fileSize: file.size,
fileType: file.type,
md5: md5
})
});

const result = await response.json();
return result.data; // {uploadUrl, fileId, fileKey, instant}
}

后端代码

@PostMapping("/presigned")
public Result<PreSignedUploadVO> generateUploadUrl(
@Valid @RequestBody PreSignedUrlRequest request
) {
// 1. 参数验证(@Valid自动执行)

// 2. 文件验证
validateFile(request);

// 3. 秒传检测
Optional<FileFile> existing = checkInstantUpload(
request.getMd5(),
request.getFileSize()
);

if (existing.isPresent()) {
// 秒传逻辑(详见秒传章节)
return Result.success(createInstantUploadResponse(existing.get()));
}

// 4. 生成文件路径
String fileKey = generateFileKey(request.getFileName());
// 示例: uploads/2025/12/13/abc123def456.jpg

// 5. 创建数据库记录(状态:PENDING)
FileFile file = FileFile.builder()
.fileName(request.getFileName())
.fileSize(request.getFileSize())
.fileType(request.getFileType())
.md5(request.getMd5())
.fileKey(fileKey)
.storageType("BITIFUL")
.bucketName(properties.getBucket())
.uploadStatus(0) // PENDING
.createBy(SecurityUtils.getCurrentUserId())
.build();

fileMapper.insert(file);

// 6. 生成预签名URL(30分钟有效)
String uploadUrl = storageStrategy.generatePresignedUrl(fileKey, 30);

// 7. 返回响应
return Result.success(PreSignedUploadVO.builder()
.uploadUrl(uploadUrl)
.fileId(file.getId())
.fileKey(fileKey)
.instant(false)
.build());
}

关键点

  • ✅ MD5计算使用分块(2MB),支持大文件
  • ✅ 数据库记录先创建,状态为PENDING
  • ✅ 预签名URL只在有效期内可用(30分钟)
  • ✅ fileId返回为String(防止JavaScript精度丢失)

阶段2: 直传云端

前端代码

async function uploadToCloud(uploadUrl, file) {
// 直接PUT上传,无需任何headers
const response = await fetch(uploadUrl, {
method: 'PUT',
body: file
});

if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}

console.log('✅ 文件已上传到云端');
}

重要提示

  • 不要添加Content-Type header(会导致签名不匹配)
  • 不要添加Authorization header
  • ✅ 直接PUT原始文件内容
  • ✅ 让浏览器自动检测Content-Type

常见错误

// ❌ 错误写法
await fetch(uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'image/jpeg' // 导致403 Forbidden
},
body: file
});

// ✅ 正确写法
await fetch(uploadUrl, {
method: 'PUT',
body: file // 浏览器自动处理Content-Type
});

阶段3: 确认上传

前端代码

async function confirmUpload(fileId) {
const response = await fetch(`/api/v1/files/${fileId}/confirm`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`
}
});

const result = await response.json();
if (result.code === 0) {
console.log('🎉 上传完成!');
}
}

后端代码

@PatchMapping("/{fileId}/confirm")
public Result<Void> confirmUpload(@PathVariable Long fileId) {
// 1. 查询文件记录
FileFile file = fileMapper.selectById(fileId);
if (file == null) {
throw new BusinessException(FileErrorCode.FILE_NOT_FOUND);
}

// 2. 幂等性检查
if (file.getUploadStatus() == 1) {
log.info("文件已确认,跳过: id={}", fileId);
return Result.success();
}

// 3. 更新状态为COMPLETED
file.setUploadStatus(1);
file.setUpdateBy(SecurityUtils.getCurrentUserId());
fileMapper.updateById(file);

// 4. 发布事件(可选)
eventPublisher.publishEvent(new FileUploadedEvent(fileId));

return Result.success();
}

关键点

  • 幂等性:重复调用不会报错
  • ✅ 状态流转:PENDING(0) → COMPLETED(1)
  • ✅ 可扩展:通过事件触发后续处理(如生成缩略图)

⚡ 秒传流程

流程图

sequenceDiagram
autonumber
participant 前端 as 🖥️ 浏览器
participant 后端 as 🚀 Spring Boot
participant DB as 💾 MySQL

前端->>前端: 计算MD5哈希
前端->>后端: POST /presigned<br/>{fileName, size, md5}

后端->>DB: SELECT WHERE md5=?<br/>AND fileSize=?<br/>AND status=COMPLETED
DB-->>后端: 返回已有文件记录

后端->>后端: 复用fileId和fileKey
后端-->>前端: {instant:true,<br/>uploadUrl:null,<br/>fileId: 已有ID}

前端->>前端: 检测instant=true
前端->>前端: ⚡ 秒传成功!<br/>跳过上传步骤

代码实现

后端秒传检测

private Optional<FileFile> checkInstantUpload(String md5, Long fileSize) {
if (StringUtils.isBlank(md5)) {
return Optional.empty();
}

// 查询条件:
// 1. MD5完全匹配
// 2. 文件大小匹配(双重保险)
// 3. 上传状态为COMPLETED(1)
LambdaQueryWrapper<FileFile> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(FileFile::getMd5, md5)
.eq(FileFile::getFileSize, fileSize)
.eq(FileFile::getUploadStatus, 1)
.last("LIMIT 1");

FileFile existing = fileMapper.selectOne(wrapper);

if (existing != null) {
log.info("⚡ 秒传命中: md5={}, size={}, fileId={}",
md5, fileSize, existing.getId());
}

return Optional.ofNullable(existing);
}

private PreSignedUploadVO createInstantUploadResponse(FileFile existingFile) {
return PreSignedUploadVO.builder()
.instant(true) // 秒传标志
.uploadUrl(null) // 无需上传URL
.fileId(existingFile.getId())
.fileKey(existingFile.getFileKey())
.build();
}

前端秒传处理

async function uploadFile(file) {
const { instant, uploadUrl, fileId, fileKey } =
await getPresignedUrl(file);

if (instant) {
// 秒传成功
console.log('⚡ 秒传成功!fileId:', fileId);
showStatus('success', '⚡ 秒传成功!文件已存在,无需重复上传');
return fileId;
}

// 普通上传流程
await uploadToCloud(uploadUrl, file);
await confirmUpload(fileId);
showStatus('success', '🎉 上传成功!');

return fileId;
}

🎯 状态流转

文件状态机

stateDiagram-v2
[*] --> PENDING: 创建预签名URL
PENDING --> COMPLETED: 确认上传
PENDING --> [*]: 超时/删除
COMPLETED --> [*]: 删除

note right of PENDING
upload_status = 0
已生成fileId
等待上传完成
end note

note right of COMPLETED
upload_status = 1
文件可正常访问
end note

###状态说明

状态upload_status说明可执行操作
PENDING0已创建记录,等待上传confirm, delete
COMPLETED1上传完成,可正常访问getAccessUrl, delete

🔍 关键细节

1. 文件路径生成规则

private String generateFileKey(String originalFilename) {
// 1. 提取扩展名
String ext = FilenameUtils.getExtension(originalFilename);

// 2. 生成日期路径(按天分目录)
String datePath = LocalDate.now()
.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));

// 3. 生成UUID(去掉横杠)
String uuid = UUID.randomUUID().toString().replace("-", "");

// 4. 拼接完整路径
return String.format("uploads/%s/%s.%s", datePath, uuid, ext);
}

// 示例输出:
// uploads/2025/12/13/abc123def456789abcdef.jpg

优势

  • 按日期分目录,便于管理
  • UUID保证唯一性
  • 保留原始扩展名

2. 预签名URL特点

URL结构(示例):

https://s3.bitiful.net/blog-files/uploads/2025/12/13/xxx.jpg
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=xxx
&X-Amz-Date=20251213T140000Z
&X-Amz-Expires=1800
&X-Amz-Signature=xxx

参数说明

  • X-Amz-Expires: 1800秒(30分钟)
  • X-Amz-Signature: 签名,确保未篡改
  • X-Amz-Credential: 身份凭证

安全特性

  • ✅ 只能PUT指定的fileKey
  • ✅ 超过30分钟自动失效
  • ✅ 签名验证,无法伪造

3. MD5索引优化

数据库索引

CREATE INDEX idx_md5_size ON file_file(md5, file_size);

查询性能

-- 使用索引,O(log n) 复杂度
EXPLAIN SELECT * FROM file_file
WHERE md5 = 'abc123' AND file_size = 1024;

-- 结果: Using index condition

📊 性能对比

不同文件大小的上传耗时

文件大小传统上传预签名URL秒传
100KB200ms150ms~50ms
1MB800ms500ms~50ms
10MB5s3s~50ms
100MB50s30s~50ms

结论

  • 预签名URL比传统上传快40%
  • 秒传比普通上传快99%

🛠️ 完整示例

前端完整代码

<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
</head>
<body>
<input type="file" id="fileInput">
<button onclick="handleUpload()">上传</button>
<div id="status"></div>

<script>
async function handleUpload() {
const file = document.getElementById('fileInput').files[0];
if (!file) return;

try {
// 1. 计算MD5
updateStatus('计算MD5...');
const md5 = await calculateMD5(file);

// 2. 获取预签名URL
updateStatus('获取上传权限...');
const {instant, uploadUrl, fileId} = await getPresignedUrl(file, md5);

if (instant) {
// 秒传
updateStatus('⚡ 秒传成功!');
return fileId;
}

// 3. 上传到云端
updateStatus('上传中...');
await uploadToCloud(uploadUrl, file);

// 4. 确认上传
await confirmUpload(fileId);
updateStatus('🎉 上传成功!');

return fileId;
} catch (error) {
updateStatus('❌ 上传失败: ' + error.message);
}
}

// ... (其他函数实现见前文)
</script>
</body>
</html>

📚 延伸阅读


🎓 学习建议:理解上传流程后,建议查看 API文档,了解接口详细参数。