image_storage_settings.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "strings"
  8. "sync"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "go.uber.org/zap"
  12. )
  13. const settingKeyImageStorageConfig = "image_storage_config"
  14. // ErrImageStorageIncomplete 表示开关已打开但凭证不全,无法启用异步生图。
  15. var ErrImageStorageIncomplete = errors.New("image storage is enabled but bucket/access_key_id/secret_access_key are incomplete")
  16. // ImageStorageFactory 由 repository 层提供,把配置变成一个可用的对象存储实现。
  17. // 与 BackupObjectStoreFactory 同样的注入方式,避免 service 反向依赖 repository。
  18. type ImageStorageFactory func(ctx context.Context, cfg *config.ImageStorageConfig) (ImageStorage, error)
  19. // ImageStorageSettings 是后台可编辑的异步生图对象存储配置。
  20. //
  21. // ReuseBackupS3 为真时不保存自己的凭证,直接借用数据库备份已配置的 S3 端点与密钥,
  22. // 只用自己的 Bucket/Prefix 区分对象;这样"数据走 backups/、图片走 images/"无需重复配置。
  23. type ImageStorageSettings struct {
  24. Enabled bool `json:"enabled"`
  25. ReuseBackupS3 bool `json:"reuse_backup_s3"`
  26. Bucket string `json:"bucket"` // 留空且复用备份时,沿用备份桶
  27. Prefix string `json:"prefix"`
  28. PublicBaseURL string `json:"public_base_url"`
  29. PresignExpiry int `json:"presign_expiry_hours"`
  30. MaxDownloadBytes int64 `json:"max_download_bytes"`
  31. // 以下仅在 ReuseBackupS3 为假时使用
  32. Endpoint string `json:"endpoint"`
  33. Region string `json:"region"`
  34. AccessKeyID string `json:"access_key_id"`
  35. SecretAccessKey string `json:"secret_access_key,omitempty"` //nolint:revive // field name follows AWS convention
  36. ForcePathStyle bool `json:"force_path_style"`
  37. }
  38. // ImageStorageSettingService 读写后台设置,并把结果解析成一个可直接使用的 uploader。
  39. //
  40. // 解析结果带缓存:网关每次请求都要判断功能是否开启,不能每次都查库。保存设置时调用
  41. // Invalidate 清缓存,下一次请求即重建客户端——这是"后台开关立即生效、无需重启"的实现。
  42. type ImageStorageSettingService struct {
  43. settingRepo SettingRepository
  44. encryptor SecretEncryptor
  45. backup *BackupService
  46. factory ImageStorageFactory
  47. // fallback 是 config.yaml 里的配置。后台从未保存过设置时沿用它,
  48. // 保证升级前已用配置文件开启该功能的部署不被打断。
  49. fallback config.ImageStorageConfig
  50. mu sync.Mutex
  51. resolved bool
  52. uploader *ImageResultUploader
  53. enabled bool
  54. }
  55. func NewImageStorageSettingService(
  56. settingRepo SettingRepository,
  57. encryptor SecretEncryptor,
  58. backup *BackupService,
  59. factory ImageStorageFactory,
  60. fallback config.ImageStorageConfig,
  61. ) *ImageStorageSettingService {
  62. return &ImageStorageSettingService{
  63. settingRepo: settingRepo,
  64. encryptor: encryptor,
  65. backup: backup,
  66. factory: factory,
  67. fallback: fallback,
  68. }
  69. }
  70. // Resolver 返回可注入 ImageTaskService 的解析函数。
  71. func (s *ImageStorageSettingService) Resolver() ImageStorageResolver {
  72. return func() (*ImageResultUploader, bool) {
  73. return s.resolve()
  74. }
  75. }
  76. func (s *ImageStorageSettingService) resolve() (*ImageResultUploader, bool) {
  77. if s == nil {
  78. return nil, false
  79. }
  80. s.mu.Lock()
  81. defer s.mu.Unlock()
  82. if s.resolved {
  83. return s.uploader, s.enabled
  84. }
  85. ctx := context.Background()
  86. s.resolved = true
  87. s.uploader, s.enabled = nil, false
  88. cfg, err := s.effectiveConfig(ctx)
  89. if err != nil {
  90. logger.L().Warn("image_storage.settings_load_failed; async image tasks stay disabled", zap.Error(err))
  91. return nil, false
  92. }
  93. if !cfg.Enabled {
  94. return nil, false
  95. }
  96. if !cfg.IsConfigured() {
  97. logger.L().Warn("image_storage is enabled but not fully configured; async image tasks are disabled",
  98. zap.Strings("missing_keys", cfg.MissingCredentialKeys()))
  99. return nil, false
  100. }
  101. storage, err := s.factory(ctx, cfg)
  102. if err != nil {
  103. logger.L().Error("image_storage.client_build_failed; async image tasks stay disabled", zap.Error(err))
  104. return nil, false
  105. }
  106. s.uploader = NewImageResultUploader(storage, cfg.Prefix, cfg.MaxDownloadByte, nil)
  107. s.enabled = true
  108. return s.uploader, true
  109. }
  110. // Invalidate 丢弃缓存,使下一次请求按最新设置重新解析。
  111. func (s *ImageStorageSettingService) Invalidate() {
  112. if s == nil {
  113. return
  114. }
  115. s.mu.Lock()
  116. s.resolved = false
  117. s.uploader = nil
  118. s.enabled = false
  119. s.mu.Unlock()
  120. }
  121. // Get 返回后台设置(SecretAccessKey 已脱敏)。从未保存过时返回 config.yaml 的等价值。
  122. func (s *ImageStorageSettingService) Get(ctx context.Context) (*ImageStorageSettings, error) {
  123. settings, err := s.load(ctx)
  124. if err != nil {
  125. return nil, err
  126. }
  127. if settings == nil {
  128. settings = settingsFromConfig(s.fallback)
  129. }
  130. settings.SecretAccessKey = ""
  131. return settings, nil
  132. }
  133. // SecretConfigured 供前端展示"已配置"占位符。
  134. func (s *ImageStorageSettingService) SecretConfigured(ctx context.Context) bool {
  135. settings, err := s.load(ctx)
  136. if err != nil || settings == nil {
  137. return s.fallback.SecretAccessKey != ""
  138. }
  139. if settings.ReuseBackupS3 {
  140. cfg, err := s.backupCredentials(ctx)
  141. return err == nil && cfg != nil && cfg.SecretAccessKey != ""
  142. }
  143. return settings.SecretAccessKey != ""
  144. }
  145. // Update 保存设置并立即生效。SecretAccessKey 留空表示沿用已保存的值。
  146. func (s *ImageStorageSettingService) Update(ctx context.Context, in ImageStorageSettings) (*ImageStorageSettings, error) {
  147. normalizeImageStorageSettings(&in)
  148. if in.ReuseBackupS3 {
  149. // 复用备份凭证时不落自己的密钥,避免同一份密钥在库里存两份。
  150. in.Endpoint, in.Region, in.AccessKeyID, in.SecretAccessKey = "", "", "", ""
  151. in.ForcePathStyle = false
  152. } else if in.SecretAccessKey == "" {
  153. if old, err := s.load(ctx); err == nil && old != nil {
  154. in.SecretAccessKey = old.SecretAccessKey
  155. }
  156. } else {
  157. // 拒绝用自动生成的临时密钥加密:重启后密文无法解密(#4524)。
  158. // 与备份 S3 配置共用同一把密钥,故复用其配置状态判断。
  159. if s.backup == nil || !s.backup.EncryptionKeyConfigured() {
  160. return nil, ErrSecretEncryptionKeyNotConfigured
  161. }
  162. encrypted, err := s.encryptor.Encrypt(in.SecretAccessKey)
  163. if err != nil {
  164. return nil, fmt.Errorf("encrypt secret: %w", err)
  165. }
  166. in.SecretAccessKey = encrypted
  167. }
  168. data, err := json.Marshal(in)
  169. if err != nil {
  170. return nil, fmt.Errorf("marshal image storage settings: %w", err)
  171. }
  172. if err := s.settingRepo.Set(ctx, settingKeyImageStorageConfig, string(data)); err != nil {
  173. return nil, fmt.Errorf("save image storage settings: %w", err)
  174. }
  175. s.Invalidate()
  176. in.SecretAccessKey = ""
  177. return &in, nil
  178. }
  179. // TestConnection 用给定设置试建一次客户端,用于后台的"测试连接"按钮。
  180. // 与 Update 一样支持留空 SecretAccessKey 表示沿用已保存的值。
  181. func (s *ImageStorageSettingService) TestConnection(ctx context.Context, in ImageStorageSettings) error {
  182. normalizeImageStorageSettings(&in)
  183. if !in.ReuseBackupS3 && in.SecretAccessKey == "" {
  184. old, err := s.load(ctx)
  185. if err == nil && old != nil {
  186. in.SecretAccessKey = old.SecretAccessKey
  187. }
  188. }
  189. cfg, err := s.toImageStorageConfig(ctx, &in)
  190. if err != nil {
  191. return err
  192. }
  193. if !cfg.IsConfigured() {
  194. return ErrImageStorageIncomplete
  195. }
  196. if _, err := s.factory(ctx, cfg); err != nil {
  197. return err
  198. }
  199. return nil
  200. }
  201. // effectiveConfig 把后台设置(或 config.yaml 回落)解析成运行时配置。
  202. func (s *ImageStorageSettingService) effectiveConfig(ctx context.Context) (*config.ImageStorageConfig, error) {
  203. settings, err := s.load(ctx)
  204. if err != nil {
  205. return nil, err
  206. }
  207. if settings == nil {
  208. fallback := s.fallback
  209. return &fallback, nil
  210. }
  211. return s.toImageStorageConfig(ctx, settings)
  212. }
  213. func (s *ImageStorageSettingService) toImageStorageConfig(ctx context.Context, in *ImageStorageSettings) (*config.ImageStorageConfig, error) {
  214. cfg := &config.ImageStorageConfig{
  215. Enabled: in.Enabled,
  216. Bucket: in.Bucket,
  217. Prefix: in.Prefix,
  218. PublicBaseURL: in.PublicBaseURL,
  219. PresignExpiry: in.PresignExpiry,
  220. MaxDownloadByte: in.MaxDownloadBytes,
  221. Endpoint: in.Endpoint,
  222. Region: in.Region,
  223. AccessKeyID: in.AccessKeyID,
  224. SecretAccessKey: in.SecretAccessKey,
  225. ForcePathStyle: in.ForcePathStyle,
  226. }
  227. if in.ReuseBackupS3 {
  228. backupCfg, err := s.backupCredentials(ctx)
  229. if err != nil {
  230. return nil, err
  231. }
  232. if backupCfg == nil {
  233. return nil, errors.New("image storage is set to reuse the backup S3 configuration, but no backup S3 configuration exists")
  234. }
  235. cfg.Endpoint = backupCfg.Endpoint
  236. cfg.Region = backupCfg.Region
  237. cfg.AccessKeyID = backupCfg.AccessKeyID
  238. cfg.SecretAccessKey = backupCfg.SecretAccessKey
  239. cfg.ForcePathStyle = backupCfg.ForcePathStyle
  240. if cfg.Bucket == "" {
  241. cfg.Bucket = backupCfg.Bucket
  242. }
  243. } else if cfg.SecretAccessKey != "" {
  244. decrypted, err := s.encryptor.Decrypt(cfg.SecretAccessKey)
  245. if err != nil {
  246. // 兼容未加密的旧数据,与备份配置的处理保持一致。
  247. logger.L().Warn("image_storage secret decrypt failed; treating the stored value as plaintext", zap.Error(err))
  248. } else {
  249. cfg.SecretAccessKey = decrypted
  250. }
  251. }
  252. return cfg, nil
  253. }
  254. // backupCredentials 取备份已配置的 S3 凭证(已解密)。
  255. func (s *ImageStorageSettingService) backupCredentials(ctx context.Context) (*BackupS3Config, error) {
  256. if s.backup == nil {
  257. return nil, errors.New("backup service is unavailable")
  258. }
  259. return s.backup.loadS3Config(ctx)
  260. }
  261. // load 读出后台设置;从未保存过时返回 nil。
  262. func (s *ImageStorageSettingService) load(ctx context.Context) (*ImageStorageSettings, error) {
  263. if s.settingRepo == nil {
  264. return nil, nil //nolint:nilnil // no repository means no stored settings
  265. }
  266. raw, err := s.settingRepo.GetValue(ctx, settingKeyImageStorageConfig)
  267. if err != nil || strings.TrimSpace(raw) == "" {
  268. return nil, nil //nolint:nilnil // never configured is a valid state
  269. }
  270. var settings ImageStorageSettings
  271. if err := json.Unmarshal([]byte(raw), &settings); err != nil {
  272. return nil, fmt.Errorf("parse image storage settings: %w", err)
  273. }
  274. return &settings, nil
  275. }
  276. func settingsFromConfig(cfg config.ImageStorageConfig) *ImageStorageSettings {
  277. return &ImageStorageSettings{
  278. Enabled: cfg.Enabled,
  279. Bucket: cfg.Bucket,
  280. Prefix: cfg.Prefix,
  281. PublicBaseURL: cfg.PublicBaseURL,
  282. PresignExpiry: cfg.PresignExpiry,
  283. MaxDownloadBytes: cfg.MaxDownloadByte,
  284. Endpoint: cfg.Endpoint,
  285. Region: cfg.Region,
  286. AccessKeyID: cfg.AccessKeyID,
  287. SecretAccessKey: cfg.SecretAccessKey,
  288. ForcePathStyle: cfg.ForcePathStyle,
  289. }
  290. }
  291. func normalizeImageStorageSettings(in *ImageStorageSettings) {
  292. in.Bucket = strings.TrimSpace(in.Bucket)
  293. in.Endpoint = strings.TrimSpace(in.Endpoint)
  294. in.Region = strings.TrimSpace(in.Region)
  295. in.AccessKeyID = strings.TrimSpace(in.AccessKeyID)
  296. in.SecretAccessKey = strings.TrimSpace(in.SecretAccessKey)
  297. in.PublicBaseURL = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(in.PublicBaseURL), "/"))
  298. in.Prefix = strings.TrimSpace(in.Prefix)
  299. if in.Prefix == "" {
  300. in.Prefix = "images/"
  301. }
  302. if !strings.HasSuffix(in.Prefix, "/") {
  303. in.Prefix += "/"
  304. }
  305. if in.Region == "" {
  306. in.Region = "auto"
  307. }
  308. if in.PresignExpiry <= 0 {
  309. in.PresignExpiry = 24
  310. }
  311. if in.MaxDownloadBytes <= 0 {
  312. in.MaxDownloadBytes = defaultImageMaxDownloadBytes
  313. }
  314. }