diff --git a/Cloudflare-R2圖片儲存遷移指南.md b/Cloudflare-R2圖片儲存遷移指南.md new file mode 100644 index 0000000..687a5d1 --- /dev/null +++ b/Cloudflare-R2圖片儲存遷移指南.md @@ -0,0 +1,594 @@ +# 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 環境變數設定 \ No newline at end of file diff --git a/Generate頁面UX改善計劃.md b/Generate頁面UX改善計劃.md new file mode 100644 index 0000000..6bef873 --- /dev/null +++ b/Generate頁面UX改善計劃.md @@ -0,0 +1,228 @@ +# Generate 頁面 UX 改善計劃 + +## 🎯 問題描述 + +### 目前的問題 +當用戶在 `http://localhost:3001/generate` 頁面輸入英文文本進行分析後: + +1. **第一次分析**:用戶輸入文本 → 點擊「分析句子」→ 下方顯示分析結果 ✅ +2. **想要分析新文本時**:用戶在輸入框中輸入新文本 → **舊的分析結果仍然顯示** ❌ +3. **用戶體驗問題**:新輸入的文本和下方顯示的舊分析結果不匹配,造成混淆 + +### 期望的使用流程 +1. 用戶輸入文本 +2. 點擊「分析句子」→ 顯示對應的分析結果 +3. 當用戶開始輸入**新文本**時 → **自動清除舊的分析結果** +4. 用戶需要再次點擊「分析句子」才會顯示新文本的分析結果 + +--- + +## 🔧 解決方案 + +### 核心改善邏輯 +添加**智能清除機制**:當用戶開始修改輸入文本時,自動清除之前的分析結果,避免新輸入和舊結果的不匹配。 + +### 技術實現方案 + +#### 1. **新增狀態管理** +```typescript +// 新增以下狀態 +const [lastAnalyzedText, setLastAnalyzedText] = useState('') +const [isInitialLoad, setIsInitialLoad] = useState(true) +``` + +#### 2. **實現清除邏輯** +```typescript +// 監聽文本輸入變化 +useEffect(() => { + // 如果不是初始載入,且文本與上次分析的不同 + if (!isInitialLoad && textInput !== lastAnalyzedText) { + // 清除分析結果 + setSentenceAnalysis(null) + setSentenceMeaning('') + setGrammarCorrection(null) + setSelectedIdiom(null) + setSelectedWord(null) + } +}, [textInput, lastAnalyzedText, isInitialLoad]) +``` + +#### 3. **修改分析函數** +```typescript +const handleAnalyzeSentence = async () => { + // ... 現有邏輯 ... + + // 分析成功後,記錄此次分析的文本 + setLastAnalyzedText(textInput) + setIsInitialLoad(false) + + // ... 其他邏輯 ... +} +``` + +#### 4. **優化快取邏輯** +```typescript +// 恢復快取時標記為初始載入 +useEffect(() => { + const cached = loadAnalysisFromCache() + if (cached) { + setTextInput(cached.textInput || '') + setLastAnalyzedText(cached.textInput || '') // 同步記錄 + setSentenceAnalysis(cached.sentenceAnalysis || null) + setSentenceMeaning(cached.sentenceMeaning || '') + setGrammarCorrection(cached.grammarCorrection || null) + setIsInitialLoad(false) // 標記快取載入完成 + console.log('✅ 已恢復快取的分析結果') + } else { + setIsInitialLoad(false) // 沒有快取也要標記載入完成 + } +}, [loadAnalysisFromCache]) +``` + +--- + +## 📋 詳細修改步驟 + +### 步驟 1:新增狀態變數 +在 `GenerateContent` 函數中新增: +```typescript +const [lastAnalyzedText, setLastAnalyzedText] = useState('') +const [isInitialLoad, setIsInitialLoad] = useState(true) +``` + +### 步驟 2:添加文本變化監聽 +在現有的 `useEffect` 後添加新的 `useEffect`: +```typescript +// 監聽文本變化,自動清除不匹配的分析結果 +useEffect(() => { + if (!isInitialLoad && textInput !== lastAnalyzedText && sentenceAnalysis) { + // 清除所有分析結果 + setSentenceAnalysis(null) + setSentenceMeaning('') + setGrammarCorrection(null) + setSelectedIdiom(null) + setSelectedWord(null) + console.log('🧹 已清除舊的分析結果,因為文本已改變') + } +}, [textInput, lastAnalyzedText, isInitialLoad, sentenceAnalysis]) +``` + +### 步驟 3:修改 `handleAnalyzeSentence` 函數 +在分析成功後添加: +```typescript +// 在 setSentenceAnalysis(analysisData) 之後添加 +setLastAnalyzedText(textInput) +setIsInitialLoad(false) +``` + +### 步驟 4:修改快取恢復邏輯 +更新現有的快取恢復 `useEffect`: +```typescript +useEffect(() => { + const cached = loadAnalysisFromCache() + if (cached) { + setTextInput(cached.textInput || '') + setLastAnalyzedText(cached.textInput || '') // 新增這行 + setSentenceAnalysis(cached.sentenceAnalysis || null) + setSentenceMeaning(cached.sentenceMeaning || '') + setGrammarCorrection(cached.grammarCorrection || null) + console.log('✅ 已恢復快取的分析結果') + } + setIsInitialLoad(false) // 新增這行,標記載入完成 +}, [loadAnalysisFromCache]) +``` + +### 步驟 5:優化用戶體驗(可選) +在分析結果區域添加提示訊息,當沒有分析結果時顯示: +```typescript +{/* 在分析結果區域前添加 */} +{!sentenceAnalysis && textInput && ( +
+
💡
+

請點擊「分析句子」查看文本的詳細分析

+
+)} +``` + +--- + +## 🎨 預期效果 + +### 修改前(問題) +1. 用戶輸入 "Hello world" → 分析 → 顯示結果 +2. 用戶修改為 "Good morning" → **舊的 "Hello world" 分析結果仍然顯示** ❌ +3. 造成混淆:新輸入 vs 舊結果不匹配 + +### 修改後(解決) +1. 用戶輸入 "Hello world" → 分析 → 顯示結果 +2. 用戶修改為 "Good morning" → **自動清除舊分析結果** ✅ +3. 用戶點擊「分析句子」→ 顯示 "Good morning" 的新分析結果 ✅ + +--- + +## 🔍 技術細節 + +### 狀態管理邏輯 +- **`lastAnalyzedText`**: 記錄上次成功分析的文本內容 +- **`isInitialLoad`**: 區分頁面初始載入和用戶操作,避免載入時誤清除快取 +- **清除條件**: `textInput !== lastAnalyzedText` 且不是初始載入狀態 + +### 快取兼容性 +- ✅ 保持現有的 localStorage 快取機制 +- ✅ 頁面重新載入時正確恢復分析結果 +- ✅ 只在用戶主動修改文本時才清除結果 + +### 邊界情況處理 +- **頁面載入時**: 不會意外清除快取的分析結果 +- **空文本**: 當用戶清空輸入框時,分析結果會被清除 +- **相同文本**: 如果用戶修改後又改回原來的文本,不會重複清除 + +--- + +## 📁 需要修改的文件 + +### 主要文件 +- **`frontend/app/generate/page.tsx`** - 實現所有邏輯修改 + +### 修改範圍 +- 新增狀態變數 (2 行) +- 新增 useEffect 監聽 (約 10 行) +- 修改分析函數 (2 行) +- 修改快取邏輯 (2 行) +- 可選的 UI 提示 (約 8 行) + +**總計**: 約 25 行代碼修改,影響範圍小,風險低 + +--- + +## ✅ 驗收標準 + +### 功能驗收 +1. ✅ 用戶輸入文本並分析後,修改輸入時舊結果立即消失 +2. ✅ 頁面重新載入時,快取的分析結果正確恢復 +3. ✅ 分析按鈕的狀態管理保持正常(loading、disabled 等) +4. ✅ 語法修正面板的交互功能不受影響 + +### 用戶體驗驗收 +1. ✅ 新輸入和分析結果始終保持一致 +2. ✅ 沒有意外的結果清除或誤操作 +3. ✅ 清晰的視覺反饋,用戶知道何時需要重新分析 + +--- + +## 🚀 實施建議 + +### 開發順序 +1. **先實現核心邏輯** - 狀態管理和清除機制 +2. **測試基本功能** - 確保清除邏輯正常運作 +3. **優化快取邏輯** - 確保快取恢復不受影響 +4. **添加用戶提示** - 提升用戶體驗 +5. **全面測試** - 驗收所有功能點 + +### 測試重點 +- 多次輸入不同文本的分析流程 +- 頁面重新載入的快取恢復 +- 語法修正功能的正常運作 +- 詞彙彈窗和保存功能的正常運作 + +這個改善方案將顯著提升 Generate 頁面的用戶體驗,避免輸入和分析結果不匹配的混淆問題。 \ No newline at end of file diff --git a/Google-Cloud-Storage圖片儲存遷移手冊.md b/Google-Cloud-Storage圖片儲存遷移手冊.md new file mode 100644 index 0000000..327b39c --- /dev/null +++ b/Google-Cloud-Storage圖片儲存遷移手冊.md @@ -0,0 +1,931 @@ +# Google Cloud Storage 圖片儲存遷移手冊 + +## 概述 + +將 DramaLing 後端的例句圖片儲存從本地檔案系統遷移到 Google Cloud Storage (GCS),利用 Google 的全球 CDN 網路提供更快的圖片載入速度和更高的可靠性。 + +## 目前系統分析 + +### 現有架構優勢 +- ✅ 使用 `IImageStorageService` 接口抽象化 +- ✅ 依賴注入已完整設定 +- ✅ 支援條件式服務切換 +- ✅ 完整的錯誤處理和日誌 + +### 當前實現 +- **服務**: `LocalImageStorageService` +- **儲存位置**: `wwwroot/images/examples` +- **URL 模式**: `https://localhost:5008/images/examples/{fileName}` + +## Phase 1: Google Cloud 環境準備 + +### 1.1 建立 Google Cloud 專案 + +1. **前往 Google Cloud Console** + ``` + 訪問: https://console.cloud.google.com/ + 登入你的 Google 帳戶 + ``` + +2. **建立新專案** + ``` + 點擊頂部專案選擇器 → 新增專案 + + 專案名稱: dramaling-storage (或你偏好的名稱) + 組織: 選擇適當的組織 (可選) + 專案 ID: 記錄此 ID,後續會用到 + ``` + +### 1.2 啟用 Cloud Storage API + +``` +Google Cloud Console → API 和服務 → 程式庫 +搜尋: "Cloud Storage API" +點擊 → 啟用 +``` + +### 1.3 建立 Service Account + +1. **建立服務帳戶** + ``` + Google Cloud Console → IAM 和管理 → 服務帳戶 → 建立服務帳戶 + + 服務帳戶名稱: dramaling-storage-service + 說明: DramaLing application storage service account + ``` + +2. **設定權限** + ``` + 選擇角色: Storage Object Admin (允許完整的物件管理) + 或更細緻的權限: + - Storage Object Creator (建立物件) + - Storage Object Viewer (檢視物件) + - Storage Object Admin (完整管理) + ``` + +3. **建立和下載金鑰檔案** + ``` + 服務帳戶 → 金鑰 → 新增金鑰 → JSON + + 下載 JSON 檔案並妥善保存 + 檔案名建議: dramaling-storage-service-account.json + ``` + +### 1.4 建立 Storage Bucket + +1. **建立 Bucket** + ``` + Google Cloud Console → Cloud Storage → 瀏覽器 → 建立值區 + + 值區名稱: dramaling-images (需全球唯一) + 位置類型: Region + 位置: asia-east1 (台灣) 或 asia-southeast1 (新加坡) + 儲存類別: Standard + 存取控制: 統一 (Uniform) + ``` + +2. **設定公開存取權限** + ``` + 選擇建立的 bucket → 權限 → 新增主體 + + 新主體: allUsers + 角色: Storage Object Viewer + + 這會讓圖片可以透過 URL 公開存取 + ``` + +### 1.5 設定 CORS + +在 Google Cloud Console 中設定 CORS: + +```bash +# 建立 cors.json 檔案 +[ + { + "origin": ["http://localhost:3000", "http://localhost:5000", "https://你的域名.com"], + "method": ["GET", "HEAD"], + "responseHeader": ["Content-Type"], + "maxAgeSeconds": 86400 + } +] + +# 使用 gsutil 設定 (需要安裝 Google Cloud SDK) +gsutil cors set cors.json gs://dramaling-images +``` + +## Phase 2: .NET 專案設定 + +### 2.1 安裝 NuGet 套件 + +在 `backend/DramaLing.Api/DramaLing.Api.csproj` 中添加: + +```xml + + +``` + +或使用命令列: +```bash +cd backend/DramaLing.Api +dotnet add package Google.Cloud.Storage.V1 +dotnet add package Google.Apis.Auth +``` + +### 2.2 建立配置模型 + +建立 `backend/DramaLing.Api/Models/Configuration/GoogleCloudStorageOptions.cs`: + +```csharp +using Microsoft.Extensions.Options; + +namespace DramaLing.Api.Models.Configuration; + +public class GoogleCloudStorageOptions +{ + public const string SectionName = "GoogleCloudStorage"; + + /// + /// Google Cloud 專案 ID + /// + public string ProjectId { get; set; } = string.Empty; + + /// + /// Storage Bucket 名稱 + /// + public string BucketName { get; set; } = string.Empty; + + /// + /// Service Account JSON 金鑰檔案路徑 + /// + public string CredentialsPath { get; set; } = string.Empty; + + /// + /// Service Account JSON 金鑰內容 (用於環境變數) + /// + public string CredentialsJson { get; set; } = string.Empty; + + /// + /// 自訂域名 (用於 CDN) + /// + public string CustomDomain { get; set; } = string.Empty; + + /// + /// 是否使用自訂域名 + /// + public bool UseCustomDomain { get; set; } = false; + + /// + /// 圖片路徑前綴 + /// + public string PathPrefix { get; set; } = "examples"; +} + +public class GoogleCloudStorageOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string name, GoogleCloudStorageOptions options) + { + var failures = new List(); + + if (string.IsNullOrEmpty(options.ProjectId)) + failures.Add("GoogleCloudStorage:ProjectId is required"); + + if (string.IsNullOrEmpty(options.BucketName)) + failures.Add("GoogleCloudStorage:BucketName is required"); + + if (string.IsNullOrEmpty(options.CredentialsPath) && string.IsNullOrEmpty(options.CredentialsJson)) + failures.Add("Either GoogleCloudStorage:CredentialsPath or GoogleCloudStorage:CredentialsJson must be provided"); + + return failures.Count > 0 + ? ValidateOptionsResult.Fail(failures) + : ValidateOptionsResult.Success; + } +} +``` + +## Phase 3: 實現 Google Cloud Storage 服務 + +### 3.1 建立 GoogleCloudImageStorageService + +建立 `backend/DramaLing.Api/Services/Media/Storage/GoogleCloudImageStorageService.cs`: + +```csharp +using Google.Cloud.Storage.V1; +using Google.Apis.Auth.OAuth2; +using DramaLing.Api.Models.Configuration; +using DramaLing.Api.Services.Storage; +using Microsoft.Extensions.Options; +using System.Text; + +namespace DramaLing.Api.Services.Media.Storage; + +public class GoogleCloudImageStorageService : IImageStorageService +{ + private readonly StorageClient _storageClient; + private readonly GoogleCloudStorageOptions _options; + private readonly ILogger _logger; + + public GoogleCloudImageStorageService( + IOptions options, + ILogger logger) + { + _options = options.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // 初始化 Storage Client + _storageClient = CreateStorageClient(); + + _logger.LogInformation("GoogleCloudImageStorageService initialized with bucket: {BucketName}", + _options.BucketName); + } + + private StorageClient CreateStorageClient() + { + GoogleCredential credential; + + // 優先使用 JSON 字串 (適合 Render 等雲端部署) + if (!string.IsNullOrEmpty(_options.CredentialsJson)) + { + credential = GoogleCredential.FromJson(_options.CredentialsJson); + } + // 次要使用檔案路徑 (適合本地開發) + else if (!string.IsNullOrEmpty(_options.CredentialsPath) && File.Exists(_options.CredentialsPath)) + { + credential = GoogleCredential.FromFile(_options.CredentialsPath); + } + // 最後嘗試使用預設認證 (適合 Google Cloud 環境) + else + { + credential = GoogleCredential.GetApplicationDefault(); + } + + return StorageClient.Create(credential); + } + + public async Task SaveImageAsync(Stream imageStream, string fileName) + { + try + { + var objectName = $"{_options.PathPrefix}/{fileName}"; + + var obj = new Google.Cloud.Storage.V1.Object + { + Bucket = _options.BucketName, + Name = objectName, + ContentType = GetContentType(fileName) + }; + + // 上傳檔案 + var uploadedObject = await _storageClient.UploadObjectAsync(obj, imageStream); + + _logger.LogInformation("Image uploaded successfully to GCS: {ObjectName}", objectName); + + return objectName; // 回傳 GCS 中的物件名稱 + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save image to GCS: {FileName}", fileName); + throw; + } + } + + public Task GetImageUrlAsync(string imagePath) + { + // 如果設定了自訂域名 (CDN) + if (_options.UseCustomDomain && !string.IsNullOrEmpty(_options.CustomDomain)) + { + var cdnUrl = $"https://{_options.CustomDomain.TrimEnd('/')}/{imagePath.TrimStart('/')}"; + return Task.FromResult(cdnUrl); + } + + // 使用標準 Google Cloud Storage URL + var gcsUrl = $"https://storage.googleapis.com/{_options.BucketName}/{imagePath.TrimStart('/')}"; + return Task.FromResult(gcsUrl); + } + + public async Task DeleteImageAsync(string imagePath) + { + try + { + await _storageClient.DeleteObjectAsync(_options.BucketName, imagePath); + + _logger.LogInformation("Image deleted from GCS: {ObjectName}", imagePath); + return true; + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogWarning("Attempted to delete non-existent image: {ObjectName}", imagePath); + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete image from GCS: {ObjectName}", imagePath); + return false; + } + } + + public async Task ImageExistsAsync(string imagePath) + { + try + { + var obj = await _storageClient.GetObjectAsync(_options.BucketName, imagePath); + return obj != null; + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound) + { + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to check image existence in GCS: {ObjectName}", imagePath); + return false; + } + } + + public async Task GetStorageInfoAsync() + { + try + { + var request = new ListObjectsOptions + { + Prefix = _options.PathPrefix, + PageSize = 1000 // 限制查詢數量 + }; + + var objects = _storageClient.ListObjectsAsync(_options.BucketName, request); + + long totalSize = 0; + int fileCount = 0; + + await foreach (var obj in objects) + { + totalSize += (long)(obj.Size ?? 0); + fileCount++; + } + + return new StorageInfo + { + Provider = "Google Cloud Storage", + TotalSizeBytes = totalSize, + FileCount = fileCount, + Status = "Available" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get GCS storage info"); + return new StorageInfo + { + Provider = "Google Cloud Storage", + 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", + ".svg" => "image/svg+xml", + _ => "application/octet-stream" + }; + } +} +``` + +## Phase 4: 應用配置更新 + +### 4.1 更新 ServiceCollectionExtensions.cs + +修改 `AddBusinessServices` 方法: + +```csharp +/// +/// 配置業務服務 +/// +public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration) +{ + services.AddScoped(); + + // 媒體服務 + services.AddScoped(); + + // 圖片儲存服務 - 根據設定選擇實現 + var storageProvider = configuration.GetValue("ImageStorage:Provider", "Local"); + + switch (storageProvider.ToLowerInvariant()) + { + case "googlecloud" or "gcs": + ConfigureGoogleCloudStorage(services, configuration); + break; + + case "local": + default: + services.AddScoped(); + break; + } + + // 其他服務保持不變... + services.AddHttpClient(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; +} + +private static void ConfigureGoogleCloudStorage(IServiceCollection services, IConfiguration configuration) +{ + // 配置 Google Cloud Storage 選項 + services.Configure(configuration.GetSection(GoogleCloudStorageOptions.SectionName)); + services.AddSingleton, GoogleCloudStorageOptionsValidator>(); + + // 註冊 Google Cloud Storage 服務 + services.AddScoped(); +} +``` + +### 4.2 更新 appsettings.json + +```json +{ + "ImageStorage": { + "Provider": "Local", + "Local": { + "BasePath": "wwwroot/images/examples", + "BaseUrl": "https://localhost:5008/images/examples" + } + }, + "GoogleCloudStorage": { + "ProjectId": "", + "BucketName": "dramaling-images", + "CredentialsPath": "", + "CredentialsJson": "", + "CustomDomain": "", + "UseCustomDomain": false, + "PathPrefix": "examples" + } +} +``` + +### 4.3 開發環境設定 (appsettings.Development.json) + +```json +{ + "ImageStorage": { + "Provider": "GoogleCloud" + }, + "GoogleCloudStorage": { + "ProjectId": "your-project-id", + "BucketName": "dramaling-images", + "CredentialsPath": "path/to/your/service-account.json", + "PathPrefix": "examples" + } +} +``` + +### 4.4 生產環境設定 (appsettings.Production.json) + +```json +{ + "ImageStorage": { + "Provider": "GoogleCloud" + }, + "GoogleCloudStorage": { + "ProjectId": "your-production-project-id", + "BucketName": "dramaling-images-prod", + "CustomDomain": "images.dramaling.com", + "UseCustomDomain": true, + "PathPrefix": "examples" + } +} +``` + +## Phase 5: 認證設定 + +### 5.1 本地開發環境 + +**方法 1: Service Account JSON 檔案** + +1. **儲存金鑰檔案** + ``` + 將下載的 JSON 檔案放到安全位置 + 建議: backend/secrets/dramaling-storage-service-account.json + + ⚠️ 務必將 secrets/ 目錄加入 .gitignore + ``` + +2. **設定檔案路徑** + ```json + { + "GoogleCloudStorage": { + "CredentialsPath": "secrets/dramaling-storage-service-account.json" + } + } + ``` + +**方法 2: 環境變數 (推薦)** + +```bash +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" +``` + +**方法 3: User Secrets (最安全)** + +```bash +cd backend/DramaLing.Api +dotnet user-secrets init +dotnet user-secrets set "GoogleCloudStorage:ProjectId" "your-project-id" +dotnet user-secrets set "GoogleCloudStorage:CredentialsJson" "$(cat path/to/service-account.json)" +``` + +### 5.2 生產環境 (Render) + +在 Render Dashboard 設定環境變數: + +``` +GOOGLE_CLOUD_PROJECT_ID=your-project-id +GOOGLE_CLOUD_STORAGE_BUCKET=dramaling-images-prod +GOOGLE_CLOUD_CREDENTIALS_JSON=[整個JSON檔案內容] +``` + +然後在 `Program.cs` 中添加環境變數載入: + +```csharp +// 在建立 builder 後添加 +builder.Configuration.AddInMemoryCollection(new Dictionary +{ + ["GoogleCloudStorage:ProjectId"] = Environment.GetEnvironmentVariable("GOOGLE_CLOUD_PROJECT_ID") ?? "", + ["GoogleCloudStorage:BucketName"] = Environment.GetEnvironmentVariable("GOOGLE_CLOUD_STORAGE_BUCKET") ?? "", + ["GoogleCloudStorage:CredentialsJson"] = Environment.GetEnvironmentVariable("GOOGLE_CLOUD_CREDENTIALS_JSON") ?? "" +}!); +``` + +## Phase 6: 測試和驗證 + +### 6.1 本地測試步驟 + +1. **設定開發環境** + ```bash + # 設定環境變數 + export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" + + # 或使用 user secrets + cd backend/DramaLing.Api + dotnet user-secrets set "GoogleCloudStorage:ProjectId" "your-project-id" + dotnet user-secrets set "GoogleCloudStorage:BucketName" "dramaling-images" + ``` + +2. **修改開發設定** + ```json + { + "ImageStorage": { + "Provider": "GoogleCloud" + } + } + ``` + +3. **測試圖片功能** + - 啟動後端 API + - 前往 AI 生成頁面 + - 輸入句子並生成例句圖 + - 檢查 Google Cloud Console 中的 bucket 是否有新檔案 + - 檢查前端是否正確顯示圖片 + +### 6.2 功能驗證清單 + +- [ ] **圖片上傳**: 新圖片出現在 GCS bucket 中 +- [ ] **圖片顯示**: 前端可正確載入並顯示 GCS 圖片 +- [ ] **URL 生成**: 圖片 URL 格式正確 +- [ ] **圖片刪除**: 刪除功能正常運作 +- [ ] **錯誤處理**: 網路錯誤時有適當的錯誤訊息 +- [ ] **日誌記錄**: 操作日誌正確記錄 +- [ ] **效能**: 圖片載入速度合理 + +### 6.3 常見問題排除 + +**問題 1**: `The Application Default Credentials are not available` +``` +解決方法: +1. 檢查環境變數 GOOGLE_APPLICATION_CREDENTIALS 是否設定 +2. 檢查 JSON 檔案路徑是否正確 +3. 檢查 JSON 檔案格式是否正確 +``` + +**問題 2**: `Access denied` 錯誤 +``` +解決方法: +1. 檢查 Service Account 是否有 Storage Object Admin 權限 +2. 檢查 bucket 名稱是否正確 +3. 檢查專案 ID 是否正確 +``` + +**問題 3**: CORS 錯誤 +``` +解決方法: +1. 設定 bucket 的 CORS 政策 +2. 檢查前端域名是否在允許清單中 +``` + +## Phase 7: 生產環境部署 + +### 7.1 Render 環境設定 + +1. **設定環境變數** + ``` + 在 Render Dashboard → Your Service → Environment: + + GOOGLE_CLOUD_PROJECT_ID = your-production-project-id + GOOGLE_CLOUD_STORAGE_BUCKET = dramaling-images-prod + GOOGLE_CLOUD_CREDENTIALS_JSON = [完整的JSON內容,單行格式] + ``` + +2. **JSON 內容格式化** + ```bash + # 將多行 JSON 轉為單行 (用於環境變數) + cat service-account.json | jq -c . + ``` + +### 7.2 CDN 設定 (可選) + +如果需要 CDN 加速: + +1. **設定 Load Balancer** + ``` + Google Cloud Console → 網路服務 → Cloud CDN + 建立 HTTP(S) Load Balancer + 後端指向你的 Storage bucket + ``` + +2. **自訂域名設定** + ```json + { + "GoogleCloudStorage": { + "CustomDomain": "images.dramaling.com", + "UseCustomDomain": true + } + } + ``` + +### 7.3 部署流程 + +1. **更新生產設定** + ```json + { + "ImageStorage": { + "Provider": "GoogleCloud" + } + } + ``` + +2. **部署到 Render** + - 推送代碼到 Git + - Render 自動部署 + - 檢查部署日誌 + +3. **驗證功能** + - 測試圖片生成 + - 檢查 GCS bucket + - 測試圖片載入速度 + +## 成本分析 + +### Google Cloud Storage 定價 (2024年價格) + +- **Storage**: $0.020 per GB/month (Standard class, Asia region) +- **Operations**: + - Class A (write): $0.05 per 10,000 operations + - Class B (read): $0.004 per 10,000 operations +- **Network**: + - Asia to Asia: $0.05 per GB + - Global CDN: $0.08-0.20 per GB (depending on region) + +### 預期成本估算 (1000 張圖片範例) + +假設每張圖片 500KB: +- **儲存成本**: 0.5GB × $0.02 = $0.01/月 +- **上傳操作**: 1000 × $0.05/10,000 = $0.005 +- **瀏覽操作**: 10,000 次 × $0.004/10,000 = $0.004 + +**每月總成本約**: $0.02-0.05 USD (非常便宜) + +### 與其他方案比較 + +| 方案 | 月成本 | 效能 | 可靠性 | 管理複雜度 | +|------|-------|------|--------|------------| +| 本地儲存 | $0 | 低 | 低 | 高 | +| Google Cloud | $0.02-0.05 | 高 | 高 | 低 | +| AWS S3 | $0.03-0.08 | 高 | 高 | 中 | +| Cloudflare R2 | $0.01-0.03 | 高 | 高 | 低 | + +## 遷移時程表 + +### 建議實施順序 + +1. **準備階段** (1-2 小時): + - 建立 Google Cloud 專案 + - 設定 Service Account 和 Bucket + - 下載認證檔案 + +2. **開發階段** (2-3 小時): + - 安裝 NuGet 套件 + - 實現 GoogleCloudImageStorageService + - 建立配置模型 + +3. **測試階段** (1-2 小時): + - 本地環境測試 + - 功能驗證 + - 效能測試 + +4. **部署階段** (1 小時): + - 設定生產環境變數 + - 部署到 Render + - 最終驗證 + +**總計時間**: 5-8 小時 + +## 安全性考量 + +### 最佳實務 + +1. **認證管理**: + - ✅ 使用環境變數存放敏感資訊 + - ✅ 本地使用 user secrets + - ✅ 生產使用 Render 環境變數 + - ❌ 絕不將金鑰提交到 Git + +2. **權限管理**: + - ✅ Service Account 最小權限原則 + - ✅ Bucket 層級的權限控制 + - ✅ 定期輪換 Service Account 金鑰 + +3. **網路安全**: + - ✅ 使用 HTTPS 傳輸 + - ✅ 設定 CORS 限制 + - ✅ 監控異常存取 + +## 回滾策略 + +如果需要回到本地儲存: + +1. **快速回滾** + ```json + { + "ImageStorage": { + "Provider": "Local" + } + } + ``` + +2. **重新部署** + - 系統自動切換回 LocalImageStorageService + +3. **資料遷移** (可選) + - 從 GCS 下載圖片回本地 (如果需要) + +## 監控和維護 + +### 日誌監控 + +- 設定 Google Cloud Logging 監控 +- 關注 Storage API 錯誤率 +- 監控上傳/下載效能 + +### 成本監控 + +- 設定 Google Cloud 計費警告 +- 定期檢查 Storage 使用量 +- 監控 API 調用頻率 + +### 維護建議 + +- 定期檢查圖片存取權限 +- 清理未使用的圖片 (可選) +- 備份重要圖片 (可選) + +## 技術支援 + +### 文檔資源 +- [Google Cloud Storage .NET SDK](https://cloud.google.com/storage/docs/reference/libraries#client-libraries-install-csharp) +- [Service Account 認證](https://cloud.google.com/docs/authentication/production) +- [Storage 最佳實務](https://cloud.google.com/storage/docs/best-practices) + +### 故障排除指令 + +```bash +# 檢查 GCS 連線 +gsutil ls gs://your-bucket-name + +# 測試認證 +gcloud auth application-default print-access-token + +# 檢查 bucket 權限 +gsutil iam get gs://your-bucket-name +``` + +--- + +## 實施檢查清單 + +### 準備階段 +- [ ] 建立 Google Cloud 專案 +- [ ] 啟用 Cloud Storage API +- [ ] 建立 Service Account +- [ ] 下載 JSON 認證檔案 +- [ ] 建立 Storage Bucket +- [ ] 設定 Bucket 權限和 CORS + +### 開發階段 +- [x] 安裝 Google.Cloud.Storage.V1 NuGet 套件 ✅ **已完成 2024-10-08** +- [x] 建立 GoogleCloudStorageOptions 配置模型 ✅ **已完成 2024-10-08** +- [x] 實現 GoogleCloudImageStorageService ✅ **已完成 2024-10-08** +- [x] 更新 ServiceCollectionExtensions ✅ **已完成 2024-10-08** +- [x] 更新 appsettings.json 配置 ✅ **已完成 2024-10-08** +- [x] 編譯測試通過 ✅ **已完成 2024-10-08** +- [ ] 設定本地認證 + +### 測試階段 +- [ ] 本地環境測試圖片上傳 +- [ ] 驗證圖片 URL 可存取 +- [ ] 測試圖片刪除功能 +- [ ] 檢查錯誤處理 +- [ ] 驗證日誌記錄 + +### 部署階段 +- [ ] 設定 Render 環境變數 +- [ ] 更新生產配置 +- [ ] 部署並驗證功能 +- [ ] 設定監控和警告 +- [ ] 準備回滾計劃 + +## 🚀 實施進度 + +### 已完成項目 (2024-10-08) + +✅ **NuGet 套件安裝** +- 已在 `DramaLing.Api.csproj` 中添加: + - `Google.Cloud.Storage.V1` v4.7.0 + - `Google.Apis.Auth` v1.68.0 + +✅ **配置模型建立** +- 已建立 `Models/Configuration/GoogleCloudStorageOptions.cs` +- 支援多種認證方式:Service Account JSON、檔案路徑、API Key +- 包含配置驗證器 + +✅ **服務實現完成** +- 已建立 `Services/Media/Storage/GoogleCloudImageStorageService.cs` +- 完整實現 `IImageStorageService` 接口 +- 支援現有的 User Secrets 中的 `GoogleStorage:ApiKey` +- 包含錯誤處理和日誌記錄 + +### 設計特色 ⭐ + +🔄 **條件式切換支援**: +- 可透過設定檔在本地儲存 ↔ Google Cloud 之間切換 +- 零程式碼修改,完全向後相容 +- 支援開發/測試/生產環境不同配置 + +🔐 **多重認證支援**: +- Service Account JSON (推薦) +- 檔案路徑認證 +- 現有 API Key 支援 +- 環境變數配置 + +✅ **依賴注入設定完成** +- 已在 `ServiceCollectionExtensions.cs` 中添加條件式切換邏輯 +- 支援通過 `ImageStorage:Provider` 配置選擇實現 +- 已更新 `Program.cs` 傳入 configuration 參數 + +✅ **編譯測試通過** +- **Build succeeded with 0 Error(s)** +- 所有組件整合成功 +- 準備就緒可進行實際測試 + +### 🎯 當前狀態 + +**系統已具備完整的抽換式圖片儲存架構!** + +**立即可用的切換方式**: +```json +// 保持本地儲存 (當前設定) +"ImageStorage": { "Provider": "Local" } + +// 切換到 Google Cloud Storage +"ImageStorage": { "Provider": "GoogleCloud" } +``` + +### 下一步 (準備實際使用) + +要啟用 Google Cloud Storage: +1. 建立 Google Cloud 專案和 Bucket +2. 設定認證 (`gcloud auth application-default login`) +3. 修改設定檔 `Provider` 為 `GoogleCloud` + +**抽換式架構開發完成!** 🚀 + +這份手冊提供了完整的 Google Cloud Storage 遷移指南,讓你能夠安全且有效地從本地儲存遷移到雲端儲存方案。 \ No newline at end of file diff --git a/cors.json b/cors.json new file mode 100644 index 0000000..0f730b4 --- /dev/null +++ b/cors.json @@ -0,0 +1,12 @@ +[ + { + "origin": [ + "http://localhost:3000", + "http://localhost:3001", + "https://your-domain.com" + ], + "method": ["GET", "HEAD"], + "responseHeader": ["Content-Type"], + "maxAgeSeconds": 3600 + } +] \ No newline at end of file diff --git a/前端圖片URL處理機制詳解.md b/前端圖片URL處理機制詳解.md new file mode 100644 index 0000000..300215a --- /dev/null +++ b/前端圖片URL處理機制詳解.md @@ -0,0 +1,220 @@ +# 前端圖片 URL 處理機制詳解 + +## 📋 概述 + +本文檔詳細解釋 DramaLing 前端如何處理詞卡圖片 URL,以及為什麼不同格式的 URL 都能正常運作。 + +## 🎯 核心工具:`flashcardUtils.ts` + +### 檔案位置 +``` +frontend/lib/utils/flashcardUtils.ts +``` + +### 檔案目的 +**統一管理詞卡相關的顯示和處理邏輯** - 避免在各個元件中重複寫相同的處理代碼 + +--- + +## 📝 核心函數詳細解析 + +### 1. **圖片 URL 處理 - `getFlashcardImageUrl()`** + +**位置**: 第 77-99 行 +**用途**: 智能處理詞卡圖片 URL,支援多種格式和來源 + +#### 處理邏輯流程: + +```typescript +export const getFlashcardImageUrl = (flashcard: any): string | null => { + // 第一優先:檢查 primaryImageUrl + if (flashcard.primaryImageUrl) { + // 判斷是相對路徑還是完整 URL + if (flashcard.primaryImageUrl.startsWith('/')) { + // 相對路徑:拼接後端基礎 URL + return `${API_CONFIG.BASE_URL}${flashcard.primaryImageUrl}` + } + // 完整 URL:直接使用 + return flashcard.primaryImageUrl + } + + // 第二優先:檢查 exampleImages 陣列 + if (flashcard.exampleImages && flashcard.exampleImages.length > 0) { + // 尋找標記為主要的圖片 + const primaryImage = flashcard.exampleImages.find((img: any) => img.isPrimary) + if (primaryImage) { + const imageUrl = primaryImage.imageUrl + return imageUrl?.startsWith('/') ? `${API_CONFIG.BASE_URL}${imageUrl}` : imageUrl + } + // 沒有主要圖片,使用第一張 + const firstImageUrl = flashcard.exampleImages[0].imageUrl + return firstImageUrl?.startsWith('/') ? `${API_CONFIG.BASE_URL}${firstImageUrl}` : firstImageUrl + } + + // 都沒有圖片 + return null +} +``` + +#### 支援的 URL 格式: + +| 格式類型 | 範例 | 處理方式 | +|---------|------|----------| +| **Google Cloud Storage** | `https://storage.googleapis.com/dramaling-images/examples/file.png` | 直接使用完整 URL | +| **本地服務** | `http://localhost:5008/images/examples/file.png` | 直接使用完整 URL | +| **相對路徑** | `/images/examples/file.png` | 拼接為 `http://localhost:5008/images/examples/file.png` | + +--- + +### 2. **其他工具函數** + +#### **詞性顯示 - `getPartOfSpeechDisplay()`** +```typescript +// 輸入:"noun" → 輸出:"n." +// 輸入:"adjective" → 輸出:"adj." +// 輸入:"preposition/adverb" → 輸出:"prep./adv." (支援複合詞性) +``` + +#### **CEFR 等級顏色 - `getCEFRColor()`** +```typescript +// A1 → "bg-green-100 text-green-700 border-green-200" (綠色) +// B1 → "bg-yellow-100 text-yellow-700 border-yellow-200" (黃色) +// C2 → "bg-purple-100 text-purple-700 border-purple-200" (紫色) +``` + +#### **熟練度處理** +- **`getMasteryColor()`**: 根據數字返回顏色 (90+→綠色, <50→紅色) +- **`getMasteryText()`**: 轉換為中文 (90+→"精通", <50→"學習中") + +#### **日期格式化** +- **`formatNextReviewDate()`**: 複習時間 (過期→"需要複習", 明天→"明天") +- **`formatCreatedDate()`**: 台灣日期格式 ("2024/1/15") + +#### **統計計算 - `calculateFlashcardStats()`** +```typescript +{ + total: 總詞卡數, + mastered: 精通詞卡數 (熟練度 ≥ 80), + learning: 學習中詞卡數 (熟練度 40-79), + new: 新詞卡數 (熟練度 < 40), + favorites: 收藏詞卡數, + masteryPercentage: 精通百分比 +} +``` + +--- + +## 🔍 實際運作流程 + +### API 回應格式 + +**目前狀態(修復後):** +- `/api/flashcards`: 回傳完整 Google Cloud Storage URL +- `/api/flashcards/{id}`: 回傳完整 Google Cloud Storage URL + +```json +{ + "primaryImageUrl": "https://storage.googleapis.com/dramaling-images/examples/b2bb23b8-16dd-44b2-bf64-34c468f2d362_e6498ba6-742b-473f-93b6-f4b58c3dd3e9.png" +} +``` + +### 前端處理流程 + +1. **接收 API 回應** → 取得 `primaryImageUrl` +2. **呼叫 `getFlashcardImageUrl()`** → 檢查 URL 格式 +3. **格式判斷**: + - ✅ 完整 URL (`https://storage.googleapis.com/...`) → 直接使用 + - ❌ 相對路徑 (`/images/...`) → 拼接後端域名(目前不會發生) +4. **元件渲染** → `` + +--- + +## 🎯 設計優勢 + +### **1. 兼容性設計** +- 支援多種 URL 格式(相對路徑/完整 URL) +- 可以無縫切換本地/雲端儲存 +- 向後兼容舊版 API + +### **2. 防禦性編程** +- 多重備用方案(primaryImageUrl → exampleImages → null) +- 自動處理路徑拼接邏輯 +- 避免因 API 格式變更導致圖片顯示失敗 + +### **3. 集中化管理** +- 所有圖片 URL 處理邏輯集中在一個函數 +- 修改邏輯時只需要改一個地方 +- 多個元件可以重用相同邏輯 + +### **4. 模組化架構** +- 每個功能都有專門的工具函數 +- 易於測試和維護 +- 符合 DRY (Don't Repeat Yourself) 原則 + +--- + +## 🔧 使用範例 + +### 在元件中使用 + +```typescript +// FlashcardCard.tsx +import { getFlashcardImageUrl, getCEFRColor, getPartOfSpeechDisplay } from '@/lib/utils/flashcardUtils' + +// 取得圖片 URL +const imageUrl = getFlashcardImageUrl(flashcard) +// 結果:https://storage.googleapis.com/dramaling-images/examples/file.png + +// 取得 CEFR 顏色 +const cefrClasses = getCEFRColor(flashcard.cefr) +// 結果:"bg-blue-100 text-blue-700 border-blue-200" + +// 取得詞性簡寫 +const partOfSpeech = getPartOfSpeechDisplay(flashcard.partOfSpeech) +// 結果:"n." + +// 在 JSX 中使用 +example +{flashcard.cefr} +{partOfSpeech} +``` + +--- + +## 📊 問題解答 + +### Q: 為什麼不同格式的 URL 都能正常運作? + +**A**: 前端設計了智能兼容性處理: + +1. **完整 URL** → 直接使用(目前的情況) +2. **相對路徝** → 自動拼接後端域名(向後兼容) +3. **多重備用** → primaryImageUrl 失敗時使用 exampleImages + +### Q: 這樣的設計有什麼好處? + +**A**: +- ✅ **彈性切換**:可以在本地開發和雲端部署間切換 +- ✅ **向後兼容**:支援舊版 API 格式 +- ✅ **錯誤處理**:多重備用方案確保圖片顯示 +- ✅ **維護性**:集中化管理,易於修改 + +### Q: 目前系統的實際運作狀況? + +**A**: +- 後端統一回傳完整的 Google Cloud Storage URLs +- 前端接收到完整 URL,直接使用(不進入相對路徑處理邏輯) +- 圖片正常顯示,系統運作正常 + +--- + +## 📈 總結 + +`flashcardUtils.ts` 是一個設計良好的工具函數庫,實現了: + +- **統一化**:所有詞卡相關的顯示邏輯集中管理 +- **兼容性**:支援多種 URL 格式和資料來源 +- **可維護性**:模組化設計,易於擴展和修改 +- **可靠性**:防禦性編程,確保系統穩定運作 + +這種設計確保了前端系統的健壯性和可維護性,是現代前端架構的最佳實務範例。 \ No newline at end of file