package service import ( "context" "encoding/json" "errors" "fmt" "strings" "sync" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "go.uber.org/zap" ) const settingKeyImageStorageConfig = "image_storage_config" // ErrImageStorageIncomplete 表示开关已打开但凭证不全,无法启用异步生图。 var ErrImageStorageIncomplete = errors.New("image storage is enabled but bucket/access_key_id/secret_access_key are incomplete") // ImageStorageFactory 由 repository 层提供,把配置变成一个可用的对象存储实现。 // 与 BackupObjectStoreFactory 同样的注入方式,避免 service 反向依赖 repository。 type ImageStorageFactory func(ctx context.Context, cfg *config.ImageStorageConfig) (ImageStorage, error) // ImageStorageSettings 是后台可编辑的异步生图对象存储配置。 // // ReuseBackupS3 为真时不保存自己的凭证,直接借用数据库备份已配置的 S3 端点与密钥, // 只用自己的 Bucket/Prefix 区分对象;这样"数据走 backups/、图片走 images/"无需重复配置。 type ImageStorageSettings struct { Enabled bool `json:"enabled"` ReuseBackupS3 bool `json:"reuse_backup_s3"` Bucket string `json:"bucket"` // 留空且复用备份时,沿用备份桶 Prefix string `json:"prefix"` PublicBaseURL string `json:"public_base_url"` PresignExpiry int `json:"presign_expiry_hours"` MaxDownloadBytes int64 `json:"max_download_bytes"` // 以下仅在 ReuseBackupS3 为假时使用 Endpoint string `json:"endpoint"` Region string `json:"region"` AccessKeyID string `json:"access_key_id"` SecretAccessKey string `json:"secret_access_key,omitempty"` //nolint:revive // field name follows AWS convention ForcePathStyle bool `json:"force_path_style"` } // ImageStorageSettingService 读写后台设置,并把结果解析成一个可直接使用的 uploader。 // // 解析结果带缓存:网关每次请求都要判断功能是否开启,不能每次都查库。保存设置时调用 // Invalidate 清缓存,下一次请求即重建客户端——这是"后台开关立即生效、无需重启"的实现。 type ImageStorageSettingService struct { settingRepo SettingRepository encryptor SecretEncryptor backup *BackupService factory ImageStorageFactory // fallback 是 config.yaml 里的配置。后台从未保存过设置时沿用它, // 保证升级前已用配置文件开启该功能的部署不被打断。 fallback config.ImageStorageConfig mu sync.Mutex resolved bool uploader *ImageResultUploader enabled bool } func NewImageStorageSettingService( settingRepo SettingRepository, encryptor SecretEncryptor, backup *BackupService, factory ImageStorageFactory, fallback config.ImageStorageConfig, ) *ImageStorageSettingService { return &ImageStorageSettingService{ settingRepo: settingRepo, encryptor: encryptor, backup: backup, factory: factory, fallback: fallback, } } // Resolver 返回可注入 ImageTaskService 的解析函数。 func (s *ImageStorageSettingService) Resolver() ImageStorageResolver { return func() (*ImageResultUploader, bool) { return s.resolve() } } func (s *ImageStorageSettingService) resolve() (*ImageResultUploader, bool) { if s == nil { return nil, false } s.mu.Lock() defer s.mu.Unlock() if s.resolved { return s.uploader, s.enabled } ctx := context.Background() s.resolved = true s.uploader, s.enabled = nil, false cfg, err := s.effectiveConfig(ctx) if err != nil { logger.L().Warn("image_storage.settings_load_failed; async image tasks stay disabled", zap.Error(err)) return nil, false } if !cfg.Enabled { return nil, false } if !cfg.IsConfigured() { logger.L().Warn("image_storage is enabled but not fully configured; async image tasks are disabled", zap.Strings("missing_keys", cfg.MissingCredentialKeys())) return nil, false } storage, err := s.factory(ctx, cfg) if err != nil { logger.L().Error("image_storage.client_build_failed; async image tasks stay disabled", zap.Error(err)) return nil, false } s.uploader = NewImageResultUploader(storage, cfg.Prefix, cfg.MaxDownloadByte, nil) s.enabled = true return s.uploader, true } // Invalidate 丢弃缓存,使下一次请求按最新设置重新解析。 func (s *ImageStorageSettingService) Invalidate() { if s == nil { return } s.mu.Lock() s.resolved = false s.uploader = nil s.enabled = false s.mu.Unlock() } // Get 返回后台设置(SecretAccessKey 已脱敏)。从未保存过时返回 config.yaml 的等价值。 func (s *ImageStorageSettingService) Get(ctx context.Context) (*ImageStorageSettings, error) { settings, err := s.load(ctx) if err != nil { return nil, err } if settings == nil { settings = settingsFromConfig(s.fallback) } settings.SecretAccessKey = "" return settings, nil } // SecretConfigured 供前端展示"已配置"占位符。 func (s *ImageStorageSettingService) SecretConfigured(ctx context.Context) bool { settings, err := s.load(ctx) if err != nil || settings == nil { return s.fallback.SecretAccessKey != "" } if settings.ReuseBackupS3 { cfg, err := s.backupCredentials(ctx) return err == nil && cfg != nil && cfg.SecretAccessKey != "" } return settings.SecretAccessKey != "" } // Update 保存设置并立即生效。SecretAccessKey 留空表示沿用已保存的值。 func (s *ImageStorageSettingService) Update(ctx context.Context, in ImageStorageSettings) (*ImageStorageSettings, error) { normalizeImageStorageSettings(&in) if in.ReuseBackupS3 { // 复用备份凭证时不落自己的密钥,避免同一份密钥在库里存两份。 in.Endpoint, in.Region, in.AccessKeyID, in.SecretAccessKey = "", "", "", "" in.ForcePathStyle = false } else if in.SecretAccessKey == "" { if old, err := s.load(ctx); err == nil && old != nil { in.SecretAccessKey = old.SecretAccessKey } } else { // 拒绝用自动生成的临时密钥加密:重启后密文无法解密(#4524)。 // 与备份 S3 配置共用同一把密钥,故复用其配置状态判断。 if s.backup == nil || !s.backup.EncryptionKeyConfigured() { return nil, ErrSecretEncryptionKeyNotConfigured } encrypted, err := s.encryptor.Encrypt(in.SecretAccessKey) if err != nil { return nil, fmt.Errorf("encrypt secret: %w", err) } in.SecretAccessKey = encrypted } data, err := json.Marshal(in) if err != nil { return nil, fmt.Errorf("marshal image storage settings: %w", err) } if err := s.settingRepo.Set(ctx, settingKeyImageStorageConfig, string(data)); err != nil { return nil, fmt.Errorf("save image storage settings: %w", err) } s.Invalidate() in.SecretAccessKey = "" return &in, nil } // TestConnection 用给定设置试建一次客户端,用于后台的"测试连接"按钮。 // 与 Update 一样支持留空 SecretAccessKey 表示沿用已保存的值。 func (s *ImageStorageSettingService) TestConnection(ctx context.Context, in ImageStorageSettings) error { normalizeImageStorageSettings(&in) if !in.ReuseBackupS3 && in.SecretAccessKey == "" { old, err := s.load(ctx) if err == nil && old != nil { in.SecretAccessKey = old.SecretAccessKey } } cfg, err := s.toImageStorageConfig(ctx, &in) if err != nil { return err } if !cfg.IsConfigured() { return ErrImageStorageIncomplete } if _, err := s.factory(ctx, cfg); err != nil { return err } return nil } // effectiveConfig 把后台设置(或 config.yaml 回落)解析成运行时配置。 func (s *ImageStorageSettingService) effectiveConfig(ctx context.Context) (*config.ImageStorageConfig, error) { settings, err := s.load(ctx) if err != nil { return nil, err } if settings == nil { fallback := s.fallback return &fallback, nil } return s.toImageStorageConfig(ctx, settings) } func (s *ImageStorageSettingService) toImageStorageConfig(ctx context.Context, in *ImageStorageSettings) (*config.ImageStorageConfig, error) { cfg := &config.ImageStorageConfig{ Enabled: in.Enabled, Bucket: in.Bucket, Prefix: in.Prefix, PublicBaseURL: in.PublicBaseURL, PresignExpiry: in.PresignExpiry, MaxDownloadByte: in.MaxDownloadBytes, Endpoint: in.Endpoint, Region: in.Region, AccessKeyID: in.AccessKeyID, SecretAccessKey: in.SecretAccessKey, ForcePathStyle: in.ForcePathStyle, } if in.ReuseBackupS3 { backupCfg, err := s.backupCredentials(ctx) if err != nil { return nil, err } if backupCfg == nil { return nil, errors.New("image storage is set to reuse the backup S3 configuration, but no backup S3 configuration exists") } cfg.Endpoint = backupCfg.Endpoint cfg.Region = backupCfg.Region cfg.AccessKeyID = backupCfg.AccessKeyID cfg.SecretAccessKey = backupCfg.SecretAccessKey cfg.ForcePathStyle = backupCfg.ForcePathStyle if cfg.Bucket == "" { cfg.Bucket = backupCfg.Bucket } } else if cfg.SecretAccessKey != "" { decrypted, err := s.encryptor.Decrypt(cfg.SecretAccessKey) if err != nil { // 兼容未加密的旧数据,与备份配置的处理保持一致。 logger.L().Warn("image_storage secret decrypt failed; treating the stored value as plaintext", zap.Error(err)) } else { cfg.SecretAccessKey = decrypted } } return cfg, nil } // backupCredentials 取备份已配置的 S3 凭证(已解密)。 func (s *ImageStorageSettingService) backupCredentials(ctx context.Context) (*BackupS3Config, error) { if s.backup == nil { return nil, errors.New("backup service is unavailable") } return s.backup.loadS3Config(ctx) } // load 读出后台设置;从未保存过时返回 nil。 func (s *ImageStorageSettingService) load(ctx context.Context) (*ImageStorageSettings, error) { if s.settingRepo == nil { return nil, nil //nolint:nilnil // no repository means no stored settings } raw, err := s.settingRepo.GetValue(ctx, settingKeyImageStorageConfig) if err != nil || strings.TrimSpace(raw) == "" { return nil, nil //nolint:nilnil // never configured is a valid state } var settings ImageStorageSettings if err := json.Unmarshal([]byte(raw), &settings); err != nil { return nil, fmt.Errorf("parse image storage settings: %w", err) } return &settings, nil } func settingsFromConfig(cfg config.ImageStorageConfig) *ImageStorageSettings { return &ImageStorageSettings{ Enabled: cfg.Enabled, Bucket: cfg.Bucket, Prefix: cfg.Prefix, PublicBaseURL: cfg.PublicBaseURL, PresignExpiry: cfg.PresignExpiry, MaxDownloadBytes: cfg.MaxDownloadByte, Endpoint: cfg.Endpoint, Region: cfg.Region, AccessKeyID: cfg.AccessKeyID, SecretAccessKey: cfg.SecretAccessKey, ForcePathStyle: cfg.ForcePathStyle, } } func normalizeImageStorageSettings(in *ImageStorageSettings) { in.Bucket = strings.TrimSpace(in.Bucket) in.Endpoint = strings.TrimSpace(in.Endpoint) in.Region = strings.TrimSpace(in.Region) in.AccessKeyID = strings.TrimSpace(in.AccessKeyID) in.SecretAccessKey = strings.TrimSpace(in.SecretAccessKey) in.PublicBaseURL = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(in.PublicBaseURL), "/")) in.Prefix = strings.TrimSpace(in.Prefix) if in.Prefix == "" { in.Prefix = "images/" } if !strings.HasSuffix(in.Prefix, "/") { in.Prefix += "/" } if in.Region == "" { in.Region = "auto" } if in.PresignExpiry <= 0 { in.PresignExpiry = 24 } if in.MaxDownloadBytes <= 0 { in.MaxDownloadBytes = defaultImageMaxDownloadBytes } }