# Cloudflare R2 圖片儲存遷移指南 ## 概述 將 DramaLing 後端的例句圖片儲存從本地檔案系統遷移到 Cloudflare R2 雲端儲存服務。 ## 目前架構分析 ### 現有圖片儲存系統 - **接口**: `IImageStorageService` - **實現**: `LocalImageStorageService` - **儲存位置**: `wwwroot/images/examples` - **URL 格式**: `https://localhost:5008/images/examples/{fileName}` - **依賴注入**: 已在 `ServiceCollectionExtensions.cs` 注冊 ### 系統優點 ✅ 良好的抽象設計,便於替換實現 ✅ 完整的接口定義,包含所有必要操作 ✅ 已整合到圖片生成工作流程中 ## Phase 1: Cloudflare R2 環境準備 ### 1.1 建立 R2 Bucket 1. **登入 Cloudflare Dashboard** - 前往 https://dash.cloudflare.com/ - 選擇你的帳戶 2. **建立 R2 Bucket** ``` 左側導航 → R2 Object Storage → Create bucket Bucket 名稱: dramaling-images 區域: 建議選擇離用戶較近的區域 (如 Asia-Pacific) ``` ### 1.2 設定 API 憑證 1. **建立 R2 API Token** ``` R2 Dashboard → Manage R2 API tokens → Create API token Permission: Object Read & Write Bucket: dramaling-images TTL: 永不過期 (或根據需求設定) ``` 2. **記錄重要資訊** ``` Access Key ID: [記錄此值] Secret Access Key: [記錄此值] Account ID: [從 R2 Dashboard 右側取得] Bucket Name: dramaling-images Endpoint URL: https://[account-id].r2.cloudflarestorage.com ``` ### 1.3 設定 CORS (跨域存取) 在 R2 Dashboard → dramaling-images → Settings → CORS policy: ```json [ { "AllowedOrigins": [ "http://localhost:3000", "http://localhost:5000", "https://你的前端域名.com" ], "AllowedMethods": ["GET", "HEAD"], "AllowedHeaders": ["*"], "ExposeHeaders": [], "MaxAgeSeconds": 86400 } ] ``` ### 1.4 設定 Public URL (可選) 如果需要 CDN 加速: ``` R2 Dashboard → dramaling-images → Settings → Public URL Connect Custom Domain: images.dramaling.com (需要你有 Cloudflare 管理的域名) ``` ## Phase 2: .NET 專案設定 ### 2.1 安裝 NuGet 套件 在 `DramaLing.Api.csproj` 中添加: ```xml ``` 或使用 Package Manager Console: ```powershell dotnet add package AWSSDK.S3 dotnet add package AWSSDK.Extensions.NETCore.Setup ``` ### 2.2 設定模型類別 建立 `backend/DramaLing.Api/Models/Configuration/CloudflareR2Options.cs`: ```csharp namespace DramaLing.Api.Models.Configuration; public class CloudflareR2Options { public const string SectionName = "CloudflareR2"; public string AccessKeyId { get; set; } = string.Empty; public string SecretAccessKey { get; set; } = string.Empty; public string AccountId { get; set; } = string.Empty; public string BucketName { get; set; } = string.Empty; public string EndpointUrl { get; set; } = string.Empty; public string PublicUrlBase { get; set; } = string.Empty; // 用於 CDN URL public bool UsePublicUrl { get; set; } = false; } public class CloudflareR2OptionsValidator : IValidateOptions { public ValidateOptionsResult Validate(string name, CloudflareR2Options options) { var failures = new List(); if (string.IsNullOrEmpty(options.AccessKeyId)) failures.Add("CloudflareR2:AccessKeyId is required"); if (string.IsNullOrEmpty(options.SecretAccessKey)) failures.Add("CloudflareR2:SecretAccessKey is required"); if (string.IsNullOrEmpty(options.AccountId)) failures.Add("CloudflareR2:AccountId is required"); if (string.IsNullOrEmpty(options.BucketName)) failures.Add("CloudflareR2:BucketName is required"); if (string.IsNullOrEmpty(options.EndpointUrl)) failures.Add("CloudflareR2:EndpointUrl is required"); return failures.Count > 0 ? ValidateOptionsResult.Fail(failures) : ValidateOptionsResult.Success; } } ``` ## Phase 3: 實現 R2 儲存服務 ### 3.1 建立 R2ImageStorageService 建立 `backend/DramaLing.Api/Services/Media/Storage/R2ImageStorageService.cs`: ```csharp using Amazon.S3; using Amazon.S3.Model; using DramaLing.Api.Models.Configuration; using DramaLing.Api.Services.Storage; using Microsoft.Extensions.Options; namespace DramaLing.Api.Services.Media.Storage; public class R2ImageStorageService : IImageStorageService { private readonly AmazonS3Client _s3Client; private readonly CloudflareR2Options _options; private readonly ILogger _logger; public R2ImageStorageService( IOptions options, ILogger logger) { _options = options.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); // 設定 S3 Client 連接 Cloudflare R2 var config = new AmazonS3Config { ServiceURL = _options.EndpointUrl, ForcePathStyle = true, // R2 要求使用 Path Style UseHttp = false // 強制 HTTPS }; _s3Client = new AmazonS3Client(_options.AccessKeyId, _options.SecretAccessKey, config); _logger.LogInformation("R2ImageStorageService initialized with bucket: {BucketName}", _options.BucketName); } public async Task SaveImageAsync(Stream imageStream, string fileName) { try { var key = $"examples/{fileName}"; // R2 中的檔案路徑 var request = new PutObjectRequest { BucketName = _options.BucketName, Key = key, InputStream = imageStream, ContentType = GetContentType(fileName), CannedACL = S3CannedACL.PublicRead // 設定為公開讀取 }; var response = await _s3Client.PutObjectAsync(request); if (response.HttpStatusCode == System.Net.HttpStatusCode.OK) { _logger.LogInformation("Image uploaded successfully to R2: {Key}", key); return key; // 回傳 R2 中的檔案路徑 } throw new Exception($"Upload failed with status: {response.HttpStatusCode}"); } catch (Exception ex) { _logger.LogError(ex, "Failed to save image to R2: {FileName}", fileName); throw; } } public Task GetImageUrlAsync(string imagePath) { // 如果設定了 CDN 域名,使用公開 URL if (_options.UsePublicUrl && !string.IsNullOrEmpty(_options.PublicUrlBase)) { var publicUrl = $"{_options.PublicUrlBase.TrimEnd('/')}/{imagePath.TrimStart('/')}"; return Task.FromResult(publicUrl); } // 否則使用 R2 直接 URL var r2Url = $"{_options.EndpointUrl.TrimEnd('/')}/{_options.BucketName}/{imagePath.TrimStart('/')}"; return Task.FromResult(r2Url); } public async Task DeleteImageAsync(string imagePath) { try { var request = new DeleteObjectRequest { BucketName = _options.BucketName, Key = imagePath }; var response = await _s3Client.DeleteObjectAsync(request); _logger.LogInformation("Image deleted from R2: {Key}", imagePath); return response.HttpStatusCode == System.Net.HttpStatusCode.NoContent; } catch (Exception ex) { _logger.LogError(ex, "Failed to delete image from R2: {ImagePath}", imagePath); return false; } } public async Task ImageExistsAsync(string imagePath) { try { var request = new GetObjectMetadataRequest { BucketName = _options.BucketName, Key = imagePath }; await _s3Client.GetObjectMetadataAsync(request); return true; } catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { return false; } catch (Exception ex) { _logger.LogError(ex, "Failed to check image existence in R2: {ImagePath}", imagePath); return false; } } public async Task GetStorageInfoAsync() { try { // 取得 bucket 資訊 (簡化版本,R2 API 限制較多) var listRequest = new ListObjectsV2Request { BucketName = _options.BucketName, Prefix = "examples/", MaxKeys = 1000 // 限制查詢數量避免超時 }; var response = await _s3Client.ListObjectsV2Async(listRequest); var totalSize = response.S3Objects.Sum(obj => obj.Size); var fileCount = response.S3Objects.Count; return new StorageInfo { Provider = "Cloudflare R2", TotalSizeBytes = totalSize, FileCount = fileCount, Status = "Available" }; } catch (Exception ex) { _logger.LogError(ex, "Failed to get R2 storage info"); return new StorageInfo { Provider = "Cloudflare R2", Status = $"Error: {ex.Message}" }; } } private static string GetContentType(string fileName) { var extension = Path.GetExtension(fileName).ToLowerInvariant(); return extension switch { ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "application/octet-stream" }; } public void Dispose() { _s3Client?.Dispose(); } } ``` ## Phase 4: 更新應用配置 ### 4.1 更新 ServiceCollectionExtensions.cs 修改 `AddBusinessServices` 方法: ```csharp public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration) { services.AddScoped(); // 媒體服務 services.AddScoped(); // 圖片儲存服務 - 根據設定選擇實現 var useR2Storage = configuration.GetValue("CloudflareR2:Enabled", false); if (useR2Storage) { // 配置 Cloudflare R2 選項 services.Configure(configuration.GetSection(CloudflareR2Options.SectionName)); services.AddSingleton, CloudflareR2OptionsValidator>(); // 注冊 R2 服務 services.AddScoped(); // AWS SDK 設定 (R2 相容 S3 API) services.AddAWSService(); } else { // 使用本地儲存 services.AddScoped(); } // 其他服務保持不變... return services; } ``` ### 4.2 更新 appsettings.json ```json { "CloudflareR2": { "Enabled": false, "AccessKeyId": "", // 從環境變數載入 "SecretAccessKey": "", // 從環境變數載入 "AccountId": "", // 從環境變數載入 "BucketName": "dramaling-images", "EndpointUrl": "", // 會從 AccountId 計算 "PublicUrlBase": "", // 如果有設定 CDN 域名 "UsePublicUrl": false }, "ImageStorage": { "Local": { "BasePath": "wwwroot/images/examples", "BaseUrl": "https://localhost:5008/images/examples" } } } ``` ### 4.3 生產環境配置 (appsettings.Production.json) ```json { "CloudflareR2": { "Enabled": true, "BucketName": "dramaling-images", "EndpointUrl": "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com", "PublicUrlBase": "https://images.dramaling.com", // 如果設定了 CDN "UsePublicUrl": true } } ``` ## Phase 5: 環境變數設定 ### 5.1 開發環境 (.env 或 user secrets) ```bash # Cloudflare R2 設定 CLOUDFLARE_R2_ACCESS_KEY_ID=你的AccessKeyId CLOUDFLARE_R2_SECRET_ACCESS_KEY=你的SecretAccessKey CLOUDFLARE_R2_ACCOUNT_ID=你的AccountId ``` ### 5.2 生產環境 (Render 環境變數) 在 Render Dashboard 設定以下環境變數: ``` CLOUDFLARE_R2_ACCESS_KEY_ID=實際的AccessKeyId CLOUDFLARE_R2_SECRET_ACCESS_KEY=實際的SecretAccessKey CLOUDFLARE_R2_ACCOUNT_ID=實際的AccountId ``` ### 5.3 配置載入邏輯 在 `Program.cs` 中添加環境變數覆蓋: ```csharp // 在 builder.Services.Configure 之前添加 builder.Configuration.AddInMemoryCollection(new Dictionary { ["CloudflareR2:AccessKeyId"] = Environment.GetEnvironmentVariable("CLOUDFLARE_R2_ACCESS_KEY_ID") ?? "", ["CloudflareR2:SecretAccessKey"] = Environment.GetEnvironmentVariable("CLOUDFLARE_R2_SECRET_ACCESS_KEY") ?? "", ["CloudflareR2:AccountId"] = Environment.GetEnvironmentVariable("CLOUDFLARE_R2_ACCOUNT_ID") ?? "" }); // 動態計算 EndpointUrl var accountId = Environment.GetEnvironmentVariable("CLOUDFLARE_R2_ACCOUNT_ID"); if (!string.IsNullOrEmpty(accountId)) { builder.Configuration["CloudflareR2:EndpointUrl"] = $"https://{accountId}.r2.cloudflarestorage.com"; } ``` ## Phase 6: 測試和部署 ### 6.1 本地測試步驟 1. **設定環境變數** ```bash export CLOUDFLARE_R2_ACCESS_KEY_ID=你的AccessKeyId export CLOUDFLARE_R2_SECRET_ACCESS_KEY=你的SecretAccessKey export CLOUDFLARE_R2_ACCOUNT_ID=你的AccountId ``` 2. **修改 appsettings.Development.json** ```json { "CloudflareR2": { "Enabled": true } } ``` 3. **測試圖片生成功能** - 前往 AI 生成頁面 - 分析句子並生成例句圖 - 檢查圖片是否正確上傳到 R2 - 檢查圖片 URL 是否可正常存取 ### 6.2 驗證清單 - [ ] R2 Bucket 中出現新圖片 - [ ] 圖片 URL 可在瀏覽器中正常開啟 - [ ] 前端可正確顯示 R2 圖片 - [ ] 圖片刪除功能正常 - [ ] 錯誤處理和日誌記錄正常 ### 6.3 回滾計劃 如果需要回滾到本地儲存: 1. **修改設定** ```json { "CloudflareR2": { "Enabled": false } } ``` 2. **重啟應用** - 系統自動切換回 LocalImageStorageService ## Phase 7: 生產環境部署 ### 7.1 Render 部署設定 1. **設定環境變數** - 在 Render Dashboard 設定上述的環境變數 2. **更新生產配置** ```json { "CloudflareR2": { "Enabled": true } } ``` 3. **重新部署應用** ### 7.2 CDN 設定 (可選) 如果需要 CDN 加速: 1. **設定 Custom Domain** ``` Cloudflare Dashboard → 你的域名 → DNS → Add record: Type: CNAME Name: images Content: YOUR_ACCOUNT_ID.r2.cloudflarestorage.com ``` 2. **更新應用設定** ```json { "CloudflareR2": { "PublicUrlBase": "https://images.yourdomain.com", "UsePublicUrl": true } } ``` ## 成本效益分析 ### Cloudflare R2 優勢 - **成本效益**: 無 egress 費用 - **效能**: CDN 全球加速 - **可靠性**: 99.999999999% 耐久性 - **擴展性**: 無限容量 - **相容性**: S3 API 相容 ### 預期成本 (以1000張圖片為例) - **儲存費用**: ~$0.015/GB/月 - **操作費用**: $4.50/百萬次請求 - **CDN**: 免費 (Cloudflare 域名) ## 注意事項 1. **圖片命名**: 保持現有的檔案命名邏輯 2. **錯誤處理**: 網路問題時的重試機制 3. **快取**: 考慮前端圖片快取策略 4. **安全性**: API 金鑰務必使用環境變數 5. **監控**: 設定 R2 使用量監控 ## 實施時間表 - **Phase 1-2**: 1-2 小時 (環境準備) - **Phase 3**: 2-3 小時 (代碼實現) - **Phase 4-5**: 1 小時 (設定和測試) - **Phase 6-7**: 1 小時 (部署和驗證) **總計**: 約 5-7 小時完成完整遷移 ## 檔案清單 ### 新增檔案 - `Models/Configuration/CloudflareR2Options.cs` - `Services/Media/Storage/R2ImageStorageService.cs` ### 修改檔案 - `Extensions/ServiceCollectionExtensions.cs` - `appsettings.json` - `appsettings.Production.json` - `DramaLing.Api.csproj` ### 環境設定 - Cloudflare R2 Dashboard 設定 - Render 環境變數設定