# 图片上传改造方案梳理 **文档日期**:2025年1月 **改造目标**:将图片上传流程改为"先上传到服务器本地,再上传到OSS" --- ## 一、当前实现(备份方法) ### 1.1 当前流程 **标准文件上传**(`Uploader_bak`): 1. 验证文件类型 2. 生成文件路径和文件名 3. **直接上传到OSS**(`UploadFileByType_bak`) 4. 获取OSS访问URL 5. 返回结果 **Base64图片上传**(`UploadBase64Image_bak`): 1. 解析Base64数据 2. 验证图片格式 3. 生成文件路径和文件名 4. **直接上传到OSS** 5. 获取OSS访问URL 6. 返回结果 **问题**: - ❌ 如果OSS上传失败,没有本地备份 - ❌ 如果OSS服务不可用,无法提供服务 - ❌ 无法进行本地验证和预览 --- ## 二、改造方案 ### 2.1 改造后的流程 **标准文件上传**(`Uploader`): 1. 验证文件类型 2. 生成文件路径和文件名 3. **先上传到服务器本地** 4. **从服务器本地上传到OSS** 5. **OSS上传成功 → 删除本地文件** 6. **OSS上传失败 → 保留本地文件** 7. 获取访问URL(OSS成功用OSS URL,失败用本地URL) 8. 返回结果 **Base64图片上传**(`UploadBase64Image`): 1. 解析Base64数据 2. 验证图片格式 3. 生成文件路径和文件名 4. **先保存到服务器本地** 5. **从服务器本地上传到OSS** 6. **OSS上传成功 → 删除本地文件** 7. **OSS上传失败 → 保留本地文件** 8. 获取访问URL(OSS成功用OSS URL,失败用本地URL) 9. 返回结果 --- ## 三、详细改造逻辑 ### 3.1 Uploader 方法改造逻辑 **改造前**: ```csharp // 直接上传到OSS await UploadFileByType(file, uploadFilePath, _fileName, forceStoreType); ``` **改造后**: ```csharp // 1. 先上传到服务器本地 var localFilePath = GetPathByType(type); // 获取本地存储路径 var localFileName = _fileName; var localFullPath = Path.Combine(localFilePath, localFileName); // 确保目录存在 if (!Directory.Exists(localFilePath)) { Directory.CreateDirectory(localFilePath); } // 保存到本地 using (var localStream = File.Create(localFullPath)) { await file.CopyToAsync(localStream); } // 2. 从服务器本地上传到OSS bool ossUploadSuccess = false; string fileUrl; try { if (type == "annexpic" || forceStoreType == "aliyun-oss") { // 读取本地文件并上传到OSS var bucketName = KeyVariable.BucketName; var ossPath = $"{uploadFilePath.TrimEnd('/').TrimEnd('\\')}/{_fileName}"; using (var localFileStream = File.OpenRead(localFullPath)) { await _oSSServiceFactory.Create("aliyun").PutObjectAsync(bucketName, ossPath, localFileStream); } ossUploadSuccess = true; // 3. OSS上传成功,删除本地文件 if (File.Exists(localFullPath)) { File.Delete(localFullPath); } // 获取OSS访问URL fileUrl = await GetOSSAccessUrl(uploadFilePath, _fileName); } else { // 非OSS类型,使用本地URL fileUrl = string.Format("/api/File/Image/{0}/{1}", type, _fileName); ossUploadSuccess = true; // 本地存储视为成功 } } catch (Exception ossEx) { // 4. OSS上传失败,保留本地文件 ossUploadSuccess = false; // 使用本地URL作为降级方案 fileUrl = string.Format("/api/File/Image/{0}/{1}", type, _fileName); // 记录错误日志(可选) // _logger.LogError(ossEx, "OSS上传失败,使用本地文件作为降级方案"); } ``` --- ### 3.2 UploadBase64Image 方法改造逻辑 **改造前**: ```csharp // 直接上传到OSS using (var stream = new MemoryStream(imageData)) { await _oSSServiceFactory.Create("aliyun").PutObjectAsync(bucketName, ossPath, stream); } ``` **改造后**: ```csharp // 1. 先保存到服务器本地 var localFilePath = GetPathByType(imageType); var localFileName = fileName; var localFullPath = Path.Combine(localFilePath, localFileName); // 确保目录存在 if (!Directory.Exists(localFilePath)) { Directory.CreateDirectory(localFilePath); } // 保存Base64数据到本地 await File.WriteAllBytesAsync(localFullPath, imageData); // 2. 从服务器本地上传到OSS bool ossUploadSuccess = false; string accessUrl; try { var bucketName = KeyVariable.BucketName; var ossPath = $"{uploadFilePath.TrimEnd('/').TrimEnd('\\')}/{fileName}"; // 读取本地文件并上传到OSS using (var localFileStream = File.OpenRead(localFullPath)) { await _oSSServiceFactory.Create("aliyun").PutObjectAsync(bucketName, ossPath, localFileStream); } ossUploadSuccess = true; // 3. OSS上传成功,删除本地文件 if (File.Exists(localFullPath)) { File.Delete(localFullPath); } // 获取OSS访问URL accessUrl = await GetOSSAccessUrl(uploadFilePath, fileName); } catch (Exception ossEx) { // 4. OSS上传失败,保留本地文件 ossUploadSuccess = false; // 使用本地URL作为降级方案 accessUrl = string.Format("/api/File/Image/{0}/{1}", imageType, fileName); // 记录错误日志(可选) // _logger.LogError(ossEx, "OSS上传失败,使用本地文件作为降级方案"); } ``` --- ### 3.3 UploadFileByType 方法改造逻辑 **说明**:`UploadFileByType` 方法需要改造,但改造方式取决于调用场景: **方案A:保持方法签名不变,内部实现改造** - 优点:调用方不需要修改 - 缺点:方法职责不清晰(既要处理本地又要处理OSS) **方案B:创建新方法 `UploadFileToLocalThenOSS`** - 优点:职责清晰,不影响现有逻辑 - 缺点:需要修改调用方 **推荐方案B**:创建新方法,保留原方法作为备份 ```csharp /// /// 先上传到本地,再上传到OSS(改造后的方法) /// [NonAction] public async Task<(bool OssSuccess, string LocalPath, string OssPath)> UploadFileToLocalThenOSS( IFormFile file, string localFilePath, string ossFilePath, string fileName, string forceStoreType = null) { var localFullPath = Path.Combine(localFilePath, fileName); bool ossUploadSuccess = false; string ossPath = null; try { // 1. 先保存到本地 if (!Directory.Exists(localFilePath)) { Directory.CreateDirectory(localFilePath); } using (var localStream = File.Create(localFullPath)) { await file.CopyToAsync(localStream); } // 2. 判断是否需要上传到OSS var fileStoreType = !string.IsNullOrEmpty(forceStoreType) ? forceStoreType : KeyVariable.FileStoreType; if (fileStoreType == "aliyun-oss") { // 3. 从本地文件上传到OSS var bucketName = KeyVariable.BucketName; ossPath = $"{ossFilePath.TrimEnd('/').TrimEnd('\\')}/{fileName}"; using (var localFileStream = File.OpenRead(localFullPath)) { await _oSSServiceFactory.Create("aliyun").PutObjectAsync(bucketName, ossPath, localFileStream); } ossUploadSuccess = true; // 4. OSS上传成功,删除本地文件 if (File.Exists(localFullPath)) { File.Delete(localFullPath); } } else { // 非OSS类型,本地存储视为成功 ossUploadSuccess = true; } return (ossUploadSuccess, localFullPath, ossPath); } catch (Exception ex) { // OSS上传失败,保留本地文件 ossUploadSuccess = false; // 记录错误日志 // _logger.LogError(ex, $"文件上传到OSS失败,保留本地文件: {localFullPath}"); return (ossUploadSuccess, localFullPath, null); } } ``` --- ## 四、改造要点总结 ### 4.1 关键步骤 1. **先保存到本地** - 使用 `File.Create` 或 `File.WriteAllBytesAsync` 保存文件 - 确保目录存在(`Directory.CreateDirectory`) 2. **从本地上传到OSS** - 使用 `File.OpenRead` 读取本地文件 - 使用 `PutObjectAsync` 上传到OSS 3. **OSS上传成功处理** - 删除本地文件(`File.Delete`) - 返回OSS访问URL 4. **OSS上传失败处理** - 保留本地文件 - 返回本地访问URL(降级方案) - 记录错误日志(可选) --- ### 4.2 异常处理 **异常场景**: 1. **本地保存失败**:直接抛出异常,不继续OSS上传 2. **OSS上传失败**:捕获异常,保留本地文件,返回本地URL 3. **OSS上传成功但删除本地文件失败**:记录警告日志,但不影响返回结果 **异常处理代码结构**: ```csharp try { // 1. 保存到本地 // ... // 2. 上传到OSS try { // OSS上传逻辑 // ... // 3. 删除本地文件 if (File.Exists(localFullPath)) { File.Delete(localFullPath); } } catch (Exception ossEx) { // OSS上传失败,保留本地文件 // 返回本地URL } } catch (Exception localEx) { // 本地保存失败,抛出异常 throw NCCException.Oh($"文件保存到本地失败: {localEx.Message}", localEx); } ``` --- ### 4.3 文件路径处理 **本地路径**: - 使用 `GetPathByType(type)` 获取本地存储路径 - 使用 `Path.Combine(localFilePath, fileName)` 组合完整路径 - 确保使用系统路径分隔符 **OSS路径**: - 使用正斜杠 `/`,不使用 `Path.Combine` - 格式:`{uploadFilePath}/{fileName}` **路径示例**: ```csharp // 本地路径(Windows: D:\Files\annexpic\20250123_123.jpg) var localPath = Path.Combine("D:\\Files\\annexpic", "20250123_123.jpg"); // OSS路径(2025/01/23/20250123_123.jpg) var ossPath = "2025/01/23/20250123_123.jpg"; ``` --- ### 4.4 URL返回逻辑 **OSS上传成功**: - 返回OSS访问URL(带签名) - 使用 `GetOSSAccessUrl` 方法生成 **OSS上传失败**: - 返回本地访问URL - 格式:`/api/File/Image/{type}/{fileName}` - 通过 `GetImg` 方法提供访问 --- ## 五、改造影响分析 ### 5.1 优点 ✅ **数据安全**: - 本地有备份,即使OSS失败也能提供服务 - 可以定期清理本地文件 ✅ **可维护性**: - 可以本地验证文件是否正确 - 便于调试和问题排查 ✅ **降级方案**: - OSS服务不可用时,自动降级到本地存储 - 不影响业务连续性 --- ### 5.2 缺点 ⚠️ **存储空间**: - 需要额外的本地存储空间 - 如果OSS上传失败,本地文件会累积 ⚠️ **性能影响**: - 需要两次写入(本地 + OSS) - 可能略微增加上传时间 ⚠️ **清理机制**: - 需要定期清理OSS上传失败后保留的本地文件 - 需要监控本地存储空间 --- ### 5.3 风险点 1. **本地存储空间不足**: - 风险:如果本地磁盘空间不足,可能导致上传失败 - 缓解:定期清理、监控磁盘空间 2. **OSS上传失败后本地文件累积**: - 风险:如果OSS长期不可用,本地文件会大量累积 - 缓解:定期清理机制、监控本地文件数量 3. **并发问题**: - 风险:多线程同时操作同一文件 - 缓解:文件名使用唯一ID,避免冲突 --- ## 六、改造实施步骤 ### 6.1 第一步:接口备份 ✅ - ✅ 已完成:`Uploader_bak`、`UploadBase64Image_bak`、`UploadFileByType_bak` ### 6.2 第二步:梳理逻辑 ✅ - ✅ 已完成:本文档 ### 6.3 第三步:创建新方法(可选) - 创建 `UploadFileToLocalThenOSS` 方法 - 或直接改造 `UploadFileByType` 方法 ### 6.4 第四步:改造 Uploader 方法 - 修改 `Uploader` 方法,使用新的上传逻辑 - 测试标准文件上传功能 ### 6.5 第五步:改造 UploadBase64Image 方法 - 修改 `UploadBase64Image` 方法,使用新的上传逻辑 - 测试Base64图片上传功能 ### 6.6 第六步:测试验证 - 测试OSS上传成功场景 - 测试OSS上传失败场景(模拟OSS服务不可用) - 测试本地文件删除逻辑 - 测试降级方案(返回本地URL) ### 6.7 第七步:清理机制(可选) - 实现定期清理OSS上传失败后保留的本地文件 - 添加监控和告警 --- ## 七、代码结构建议 ### 7.1 方法组织 ```csharp // 备份方法(保持不变) Uploader_bak UploadBase64Image_bak UploadFileByType_bak // 改造后的方法 Uploader // 使用新的上传逻辑 UploadBase64Image // 使用新的上传逻辑 UploadFileToLocalThenOSS // 新的核心上传方法(可选) ``` ### 7.2 辅助方法 可能需要添加的辅助方法: - `SaveFileToLocal`:保存文件到本地 - `UploadLocalFileToOSS`:从本地上传到OSS - `DeleteLocalFile`:删除本地文件(带异常处理) - `GetFileUrl`:根据OSS上传结果返回URL --- ## 八、注意事项 ### 8.1 文件权限 - 确保应用有本地文件写入权限 - 确保应用有本地文件删除权限 ### 8.2 路径安全 - 验证文件路径,防止路径遍历攻击 - 使用 `Path.GetFullPath` 规范化路径 ### 8.3 资源释放 - 确保文件流正确释放(使用 `using` 语句) - 避免文件句柄泄漏 ### 8.4 日志记录 - 记录OSS上传成功/失败日志 - 记录本地文件删除成功/失败日志 - 便于问题排查和监控 --- ## 九、测试用例 ### 9.1 正常流程测试 1. **OSS上传成功**: - 文件保存到本地 ✅ - 文件上传到OSS ✅ - 本地文件被删除 ✅ - 返回OSS URL ✅ 2. **非OSS类型**: - 文件保存到本地 ✅ - 不进行OSS上传 ✅ - 返回本地URL ✅ ### 9.2 异常流程测试 1. **OSS上传失败**: - 文件保存到本地 ✅ - OSS上传失败(模拟) ✅ - 本地文件保留 ✅ - 返回本地URL ✅ 2. **本地保存失败**: - 本地保存失败(模拟磁盘满) ✅ - 抛出异常,不进行OSS上传 ✅ 3. **OSS上传成功但删除本地文件失败**: - 文件上传到OSS ✅ - 删除本地文件失败(模拟) ✅ - 记录警告日志 ✅ - 返回OSS URL ✅ --- ## 十、总结 ### 10.1 改造核心 1. **先本地后OSS**:确保数据安全 2. **成功删除本地**:节省存储空间 3. **失败保留本地**:提供降级方案 4. **异常处理完善**:保证系统稳定 ### 10.2 改造收益 - ✅ 提高数据安全性 - ✅ 提供降级方案 - ✅ 便于问题排查 - ✅ 不影响现有功能(有备份) ### 10.3 后续优化 - 定期清理机制 - 监控和告警 - 性能优化(如需要) --- **文档完成时间**:2025年1月 **文档状态**:✅ **逻辑梳理完成,等待实施**