diff --git a/backend/DramaLing.Api/DramaLing.Api.csproj b/backend/DramaLing.Api/DramaLing.Api.csproj
index c0d1354..2b67f5c 100644
--- a/backend/DramaLing.Api/DramaLing.Api.csproj
+++ b/backend/DramaLing.Api/DramaLing.Api.csproj
@@ -22,6 +22,8 @@
+
+
diff --git a/backend/DramaLing.Api/Extensions/ServiceCollectionExtensions.cs b/backend/DramaLing.Api/Extensions/ServiceCollectionExtensions.cs
index 2a30ccd..7ded7a7 100644
--- a/backend/DramaLing.Api/Extensions/ServiceCollectionExtensions.cs
+++ b/backend/DramaLing.Api/Extensions/ServiceCollectionExtensions.cs
@@ -135,13 +135,15 @@ public static class ServiceCollectionExtensions
///
/// 配置業務服務
///
- public static IServiceCollection AddBusinessServices(this IServiceCollection services)
+ public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddScoped();
// 媒體服務
services.AddScoped();
- services.AddScoped();
+
+ // 圖片儲存服務 - 條件式選擇實現
+ ConfigureImageStorageService(services, configuration);
// Replicate 服務
services.AddHttpClient();
@@ -254,4 +256,31 @@ public static class ServiceCollectionExtensions
return services;
}
+
+ ///
+ /// 配置圖片儲存服務 - 支援條件式切換
+ ///
+ private static void ConfigureImageStorageService(IServiceCollection services, IConfiguration configuration)
+ {
+ var storageProvider = configuration.GetValue("ImageStorage:Provider", "Local");
+
+ switch (storageProvider.ToLowerInvariant())
+ {
+ case "googlecloud" or "gcs":
+ // 配置 Google Cloud Storage
+ services.Configure(
+ configuration.GetSection(DramaLing.Api.Models.Configuration.GoogleCloudStorageOptions.SectionName));
+ services.AddSingleton,
+ DramaLing.Api.Models.Configuration.GoogleCloudStorageOptionsValidator>();
+
+ services.AddScoped();
+ break;
+
+ case "local":
+ default:
+ // 使用本地儲存 (預設)
+ services.AddScoped();
+ break;
+ }
+ }
}
\ No newline at end of file
diff --git a/backend/DramaLing.Api/Models/Configuration/GoogleCloudStorageOptions.cs b/backend/DramaLing.Api/Models/Configuration/GoogleCloudStorageOptions.cs
new file mode 100644
index 0000000..605ee21
--- /dev/null
+++ b/backend/DramaLing.Api/Models/Configuration/GoogleCloudStorageOptions.cs
@@ -0,0 +1,69 @@
+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";
+
+ ///
+ /// 傳統 API Key (如果使用)
+ ///
+ public string ApiKey { get; set; } = string.Empty;
+}
+
+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");
+
+ // 認證方式是可選的 - 可以使用 Application Default Credentials
+ // 不強制要求明確的認證設定
+
+ return failures.Count > 0
+ ? ValidateOptionsResult.Fail(failures)
+ : ValidateOptionsResult.Success;
+ }
+}
\ No newline at end of file
diff --git a/backend/DramaLing.Api/Program.cs b/backend/DramaLing.Api/Program.cs
index 5189a2b..53b3b54 100644
--- a/backend/DramaLing.Api/Program.cs
+++ b/backend/DramaLing.Api/Program.cs
@@ -69,7 +69,7 @@ builder.Services.AddCachingServices();
// 配置 AI 和業務服務
builder.Services.AddAIServices(builder.Configuration);
-builder.Services.AddBusinessServices();
+builder.Services.AddBusinessServices(builder.Configuration);
// 🆕 選項詞彙庫服務註冊
builder.Services.Configure(
diff --git a/backend/DramaLing.Api/Services/Media/Storage/GoogleCloudImageStorageService.cs b/backend/DramaLing.Api/Services/Media/Storage/GoogleCloudImageStorageService.cs
new file mode 100644
index 0000000..bfa16da
--- /dev/null
+++ b/backend/DramaLing.Api/Services/Media/Storage/GoogleCloudImageStorageService.cs
@@ -0,0 +1,173 @@
+using Google.Cloud.Storage.V1;
+using Google.Apis.Auth.OAuth2;
+using DramaLing.Api.Models.Configuration;
+using DramaLing.Api.Services.Storage;
+using Microsoft.Extensions.Options;
+
+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,
+ IConfiguration configuration,
+ ILogger logger)
+ {
+ _options = options.Value ?? throw new ArgumentNullException(nameof(options));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ // 從 User Secrets 取得配置
+ var projectId = configuration["GoogleCloudStorage:ProjectId"] ?? "dramaling-project";
+ var bucketName = configuration["GoogleCloudStorage:BucketName"] ?? "dramaling-images";
+
+ _options.ProjectId = projectId;
+ _options.BucketName = bucketName;
+
+ // 初始化 Storage Client
+ _storageClient = CreateStorageClient();
+
+ _logger.LogInformation("GoogleCloudImageStorageService initialized with bucket: {BucketName}", bucketName);
+ }
+
+ private StorageClient CreateStorageClient()
+ {
+ try
+ {
+ // 嘗試使用預設認證 (本地開發使用 gcloud auth application-default login)
+ return StorageClient.Create();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to initialize Google Cloud Storage client. Please ensure you have run 'gcloud auth application-default login' or set up Service Account credentials.");
+ throw;
+ }
+ }
+
+ public async Task SaveImageAsync(Stream imageStream, string fileName)
+ {
+ try
+ {
+ var objectName = $"{_options.PathPrefix}/{fileName}";
+
+ // 使用 UploadObjectAsync 的 簡化版本
+ var uploadedObject = await _storageClient.UploadObjectAsync(
+ _options.BucketName,
+ objectName,
+ GetContentType(fileName),
+ imageStream);
+
+ _logger.LogInformation("Image uploaded successfully to GCS: {ObjectName}", objectName);
+
+ return objectName;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to save image to GCS: {FileName}", fileName);
+ throw;
+ }
+ }
+
+ public Task GetImageUrlAsync(string imagePath)
+ {
+ try
+ {
+ // 如果設定了自訂域名 (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);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to generate image URL for: {ImagePath}", imagePath);
+ throw;
+ }
+ }
+
+ public async Task DeleteImageAsync(string imagePath)
+ {
+ try
+ {
+ await _storageClient.DeleteObjectAsync(_options.BucketName, imagePath);
+ _logger.LogInformation("Image deleted from GCS: {ObjectName}", imagePath);
+ return true;
+ }
+ catch (Exception ex) when (ex.Message.Contains("404") || ex.Message.Contains("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 (Exception ex) when (ex.Message.Contains("404") || ex.Message.Contains("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
+ {
+ // 簡化版本 - 只返回基本資訊
+ return new StorageInfo
+ {
+ Provider = "Google Cloud Storage",
+ TotalSizeBytes = 0, // GCS 不提供簡單的總計查詢
+ FileCount = 0,
+ 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",
+ ".bmp" => "image/bmp",
+ ".tiff" or ".tif" => "image/tiff",
+ _ => "application/octet-stream"
+ };
+ }
+}
\ No newline at end of file
diff --git a/backend/DramaLing.Api/appsettings.json b/backend/DramaLing.Api/appsettings.json
index cc80520..ed52a1f 100644
--- a/backend/DramaLing.Api/appsettings.json
+++ b/backend/DramaLing.Api/appsettings.json
@@ -43,12 +43,21 @@
}
},
"ImageStorage": {
- "Provider": "Local",
+ "Provider": "GoogleCloud",
"Local": {
"BasePath": "wwwroot/images/examples",
"BaseUrl": "http://localhost:5008/images/examples",
"MaxFileSize": 10485760,
"AllowedFormats": ["png", "jpg", "jpeg", "webp"]
}
+ },
+ "GoogleCloudStorage": {
+ "ProjectId": "dramaling-vocab-learning",
+ "BucketName": "dramaling-images",
+ "CredentialsPath": "",
+ "CredentialsJson": "",
+ "CustomDomain": "",
+ "UseCustomDomain": false,
+ "PathPrefix": "examples"
}
}
\ No newline at end of file