dramaling-vocab-learning/Cloudflare-R2圖片儲存遷移指南.md

594 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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