feat: 實現 Google Cloud Storage 圖片儲存整合
重大功能更新: - 新增完整的 Google Cloud Storage 抽換式圖片儲存架構 - 支援條件式切換:本地儲存 ↔ Google Cloud Storage - 實現 GoogleCloudImageStorageService 服務類別 - 整合 Application Default Credentials 認證機制 - 修正前端圖片 URL 處理邏輯,支援 Google Cloud URL - 建立完整的 Google Cloud Storage 遷移手冊 - 設定 CORS 政策允許跨域圖片存取 技術特色: - 零程式碼修改的儲存方式切換 - 完整的錯誤處理和日誌記錄 - 支援 CDN 和自訂域名 - 符合生產環境的安全性標準 測試驗證: - Google Cloud Storage 認證設定成功 - 圖片成功上傳到雲端 bucket - CORS 設定解決跨域問題 - 前端圖片 URL 處理正確 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b9f89361d9
commit
1a20a562d2
|
|
@ -22,6 +22,8 @@
|
|||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.10" />
|
||||
<PackageReference Include="Google.Cloud.Storage.V1" Version="4.7.0" />
|
||||
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -135,13 +135,15 @@ public static class ServiceCollectionExtensions
|
|||
/// <summary>
|
||||
/// 配置業務服務
|
||||
/// </summary>
|
||||
public static IServiceCollection AddBusinessServices(this IServiceCollection services)
|
||||
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
|
||||
// 媒體服務
|
||||
services.AddScoped<IImageProcessingService, ImageProcessingService>();
|
||||
services.AddScoped<IImageStorageService, LocalImageStorageService>();
|
||||
|
||||
// 圖片儲存服務 - 條件式選擇實現
|
||||
ConfigureImageStorageService(services, configuration);
|
||||
|
||||
// Replicate 服務
|
||||
services.AddHttpClient<IReplicateService, ReplicateService>();
|
||||
|
|
@ -254,4 +256,31 @@ public static class ServiceCollectionExtensions
|
|||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置圖片儲存服務 - 支援條件式切換
|
||||
/// </summary>
|
||||
private static void ConfigureImageStorageService(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var storageProvider = configuration.GetValue<string>("ImageStorage:Provider", "Local");
|
||||
|
||||
switch (storageProvider.ToLowerInvariant())
|
||||
{
|
||||
case "googlecloud" or "gcs":
|
||||
// 配置 Google Cloud Storage
|
||||
services.Configure<DramaLing.Api.Models.Configuration.GoogleCloudStorageOptions>(
|
||||
configuration.GetSection(DramaLing.Api.Models.Configuration.GoogleCloudStorageOptions.SectionName));
|
||||
services.AddSingleton<IValidateOptions<DramaLing.Api.Models.Configuration.GoogleCloudStorageOptions>,
|
||||
DramaLing.Api.Models.Configuration.GoogleCloudStorageOptionsValidator>();
|
||||
|
||||
services.AddScoped<IImageStorageService, DramaLing.Api.Services.Media.Storage.GoogleCloudImageStorageService>();
|
||||
break;
|
||||
|
||||
case "local":
|
||||
default:
|
||||
// 使用本地儲存 (預設)
|
||||
services.AddScoped<IImageStorageService, LocalImageStorageService>();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DramaLing.Api.Models.Configuration;
|
||||
|
||||
public class GoogleCloudStorageOptions
|
||||
{
|
||||
public const string SectionName = "GoogleCloudStorage";
|
||||
|
||||
/// <summary>
|
||||
/// Google Cloud 專案 ID
|
||||
/// </summary>
|
||||
public string ProjectId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Storage Bucket 名稱
|
||||
/// </summary>
|
||||
public string BucketName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Service Account JSON 金鑰檔案路徑
|
||||
/// </summary>
|
||||
public string CredentialsPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Service Account JSON 金鑰內容 (用於環境變數)
|
||||
/// </summary>
|
||||
public string CredentialsJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 自訂域名 (用於 CDN)
|
||||
/// </summary>
|
||||
public string CustomDomain { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否使用自訂域名
|
||||
/// </summary>
|
||||
public bool UseCustomDomain { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 圖片路徑前綴
|
||||
/// </summary>
|
||||
public string PathPrefix { get; set; } = "examples";
|
||||
|
||||
/// <summary>
|
||||
/// 傳統 API Key (如果使用)
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class GoogleCloudStorageOptionsValidator : IValidateOptions<GoogleCloudStorageOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, GoogleCloudStorageOptions options)
|
||||
{
|
||||
var failures = new List<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +69,7 @@ builder.Services.AddCachingServices();
|
|||
|
||||
// 配置 AI 和業務服務
|
||||
builder.Services.AddAIServices(builder.Configuration);
|
||||
builder.Services.AddBusinessServices();
|
||||
builder.Services.AddBusinessServices(builder.Configuration);
|
||||
|
||||
// 🆕 選項詞彙庫服務註冊
|
||||
builder.Services.Configure<OptionsVocabularyOptions>(
|
||||
|
|
|
|||
|
|
@ -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<GoogleCloudImageStorageService> _logger;
|
||||
|
||||
public GoogleCloudImageStorageService(
|
||||
IOptions<GoogleCloudStorageOptions> options,
|
||||
IConfiguration configuration,
|
||||
ILogger<GoogleCloudImageStorageService> 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<string> 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<string> 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<bool> 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<bool> 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<StorageInfo> 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue