图片上传改造方案梳理.md 14.6 KB

图片上传改造方案梳理

文档日期:2025年1月
改造目标:将图片上传流程改为"先上传到服务器本地,再上传到OSS"


一、当前实现(备份方法)

1.1 当前流程

标准文件上传Uploader_bak):

  1. 验证文件类型
  2. 生成文件路径和文件名
  3. 直接上传到OSSUploadFileByType_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 方法改造逻辑

改造前

// 直接上传到OSS
await UploadFileByType(file, uploadFilePath, _fileName, forceStoreType);

改造后

// 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 方法改造逻辑

改造前

// 直接上传到OSS
using (var stream = new MemoryStream(imageData))
{
    await _oSSServiceFactory.Create("aliyun").PutObjectAsync(bucketName, ossPath, stream);
}

改造后

// 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:创建新方法,保留原方法作为备份

/// <summary>
/// 先上传到本地,再上传到OSS(改造后的方法)
/// </summary>
[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.CreateFile.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上传成功但删除本地文件失败:记录警告日志,但不影响返回结果

异常处理代码结构

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}

路径示例

// 本地路径(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_bakUploadBase64Image_bakUploadFileByType_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 方法组织

// 备份方法(保持不变)
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月
文档状态:✅ 逻辑梳理完成,等待实施