# 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 環境變數設定