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="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
|
||||||
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.10" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -135,13 +135,15 @@ public static class ServiceCollectionExtensions
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 配置業務服務
|
/// 配置業務服務
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddBusinessServices(this IServiceCollection services)
|
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddScoped<IAuthService, AuthService>();
|
services.AddScoped<IAuthService, AuthService>();
|
||||||
|
|
||||||
// 媒體服務
|
// 媒體服務
|
||||||
services.AddScoped<IImageProcessingService, ImageProcessingService>();
|
services.AddScoped<IImageProcessingService, ImageProcessingService>();
|
||||||
services.AddScoped<IImageStorageService, LocalImageStorageService>();
|
|
||||||
|
// 圖片儲存服務 - 條件式選擇實現
|
||||||
|
ConfigureImageStorageService(services, configuration);
|
||||||
|
|
||||||
// Replicate 服務
|
// Replicate 服務
|
||||||
services.AddHttpClient<IReplicateService, ReplicateService>();
|
services.AddHttpClient<IReplicateService, ReplicateService>();
|
||||||
|
|
@ -254,4 +256,31 @@ public static class ServiceCollectionExtensions
|
||||||
|
|
||||||
return services;
|
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 和業務服務
|
// 配置 AI 和業務服務
|
||||||
builder.Services.AddAIServices(builder.Configuration);
|
builder.Services.AddAIServices(builder.Configuration);
|
||||||
builder.Services.AddBusinessServices();
|
builder.Services.AddBusinessServices(builder.Configuration);
|
||||||
|
|
||||||
// 🆕 選項詞彙庫服務註冊
|
// 🆕 選項詞彙庫服務註冊
|
||||||
builder.Services.Configure<OptionsVocabularyOptions>(
|
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": {
|
"ImageStorage": {
|
||||||
"Provider": "Local",
|
"Provider": "GoogleCloud",
|
||||||
"Local": {
|
"Local": {
|
||||||
"BasePath": "wwwroot/images/examples",
|
"BasePath": "wwwroot/images/examples",
|
||||||
"BaseUrl": "http://localhost:5008/images/examples",
|
"BaseUrl": "http://localhost:5008/images/examples",
|
||||||
"MaxFileSize": 10485760,
|
"MaxFileSize": 10485760,
|
||||||
"AllowedFormats": ["png", "jpg", "jpeg", "webp"]
|
"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