594 lines
17 KiB
Markdown
594 lines
17 KiB
Markdown
# 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
|
||
<PackageReference Include="AWSSDK.S3" Version="3.7.307.25" />
|
||
<PackageReference Include="AWSSDK.Extensions.NETCore.Setup" Version="3.7.300" />
|
||
```
|
||
|
||
或使用 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<CloudflareR2Options>
|
||
{
|
||
public ValidateOptionsResult Validate(string name, CloudflareR2Options options)
|
||
{
|
||
var failures = new List<string>();
|
||
|
||
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<R2ImageStorageService> _logger;
|
||
|
||
public R2ImageStorageService(
|
||
IOptions<CloudflareR2Options> options,
|
||
ILogger<R2ImageStorageService> 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<string> 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<string> 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<bool> 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<bool> 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<StorageInfo> 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<IAuthService, AuthService>();
|
||
|
||
// 媒體服務
|
||
services.AddScoped<IImageProcessingService, ImageProcessingService>();
|
||
|
||
// 圖片儲存服務 - 根據設定選擇實現
|
||
var useR2Storage = configuration.GetValue<bool>("CloudflareR2:Enabled", false);
|
||
|
||
if (useR2Storage)
|
||
{
|
||
// 配置 Cloudflare R2 選項
|
||
services.Configure<CloudflareR2Options>(configuration.GetSection(CloudflareR2Options.SectionName));
|
||
services.AddSingleton<IValidateOptions<CloudflareR2Options>, CloudflareR2OptionsValidator>();
|
||
|
||
// 注冊 R2 服務
|
||
services.AddScoped<IImageStorageService, R2ImageStorageService>();
|
||
|
||
// AWS SDK 設定 (R2 相容 S3 API)
|
||
services.AddAWSService<IAmazonS3>();
|
||
}
|
||
else
|
||
{
|
||
// 使用本地儲存
|
||
services.AddScoped<IImageStorageService, LocalImageStorageService>();
|
||
}
|
||
|
||
// 其他服務保持不變...
|
||
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<CloudflareR2Options> 之前添加
|
||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string>
|
||
{
|
||
["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 環境變數設定 |