From 37db8d031bd7aa18905d76f7b5cb19c34d76245f Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sat, 18 Jul 2026 05:24:32 -0700 Subject: [PATCH 1/4] =?UTF-8?q?fix(config):=20=E8=AE=A9=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=8F=98=E9=87=8F=E8=83=BD=E7=9C=9F=E6=AD=A3=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=20image=5Fstorage=20=E7=AD=89=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit viper.Unmarshal 只解码 AllKeys() 返回的键,而 AllKeys() 只汇总 SetDefault、 配置文件和显式 BindEnv 三个来源。AutomaticEnv 仅能覆盖已在其中的键,无法引入 新键;能兜底的 viper_bind_struct 又被 build tag 排除(我们只用 -tags embed)。 因此任何「没有注册默认值、且不在 config.yaml 里」的配置项,其环境变量会被静默 丢弃。image_storage 的 endpoint/bucket/access_key_id/secret_access_key/ public_base_url 正属此列,于是纯环境变量部署落到最坏组合:IMAGE_STORAGE_ENABLED 生效使 Enabled=true,四个凭证却为空 → Active()=false → 异步生图接口整体 404, 运维看到的却是"凭证不完整"。deploy/docker-compose.yml 默认就是纯环境变量驱动, 且自动生成的 config.yaml 从不写 image_storage 段,必然踩中(见 #4458、#4542)。 同类缺口不止于此:github_oauth、google_oauth、dingtalk_connect 三组第三方登录 配置(含 client_secret)同样完全无法用环境变量设置。 - 为这些键注册零值默认,使其进入 AllKeys() 而可被环境变量覆盖。零值与"键缺失" 时的解码结果一致,故行为不变。 - sticky_escape_enabled 例外:它的实际默认是 true(靠 IsSet 守卫在解码后补上), 注册 false 会让 IsSet 恒真而永久关闭该特性,故直接注册 true。 - 启动告警补上 missing_keys 字段,指明到底哪个凭证为空。 - 新增反射守卫测试:Config 结构体上每个可由环境变量表达的字段都必须已注册默认值。 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y --- backend/internal/config/config.go | 98 +++++++++++++++++++ .../internal/config/env_reachability_test.go | 90 +++++++++++++++++ .../internal/config/image_storage_env_test.go | 41 ++++++++ backend/internal/service/wire.go | 6 +- docs/ASYNC_IMAGE_TASKS.md | 16 +++ 5 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 backend/internal/config/env_reachability_test.go create mode 100644 backend/internal/config/image_storage_env_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index def68f3a2..2db2418c4 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -255,6 +255,22 @@ func (c *ImageStorageConfig) Active() bool { return c.Enabled && c.IsConfigured() } +// MissingCredentialKeys 返回 IsConfigured 所缺的配置键名。 +// 用于启动日志:只说"凭证不完整"会让运维以为自己漏填了,而实际可能是值填了却没被读到。 +func (c *ImageStorageConfig) MissingCredentialKeys() []string { + var missing []string + if c.Bucket == "" { + missing = append(missing, "image_storage.bucket") + } + if c.AccessKeyID == "" { + missing = append(missing, "image_storage.access_key_id") + } + if c.SecretAccessKey == "" { + missing = append(missing, "image_storage.secret_access_key") + } + return missing +} + type LinuxDoConnectConfig struct { Enabled bool `mapstructure:"enabled"` ClientID string `mapstructure:"client_id"` @@ -1562,6 +1578,9 @@ func load(allowMissingJWTSecret bool) (*Config, error) { if cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate == 0 { cfg.Gateway.OpenAIScheduler.StickyEscapeErrorRate = 0.5 } + // Kept as a backstop: setEnvReachableDefaults now registers this key with its + // effective default (true), so IsSet always reports true and this branch no + // longer fires. It still guards the default if that registration is dropped. if !cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled && !viper.IsSet("gateway.openai_scheduler.sticky_escape_enabled") { cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true } @@ -1926,6 +1945,15 @@ func setDefaults() { viper.SetDefault("image_storage.force_path_style", false) viper.SetDefault("image_storage.presign_expiry_hours", 24) viper.SetDefault("image_storage.max_download_bytes", 33554432) + // Registered with empty defaults so AutomaticEnv can reach them: viper only + // decodes keys present in AllKeys(), so a credential that is supplied purely + // via IMAGE_STORAGE_* and never appears in config.yaml would be dropped and + // silently disable the whole async image feature. + viper.SetDefault("image_storage.endpoint", "") + viper.SetDefault("image_storage.bucket", "") + viper.SetDefault("image_storage.access_key_id", "") + viper.SetDefault("image_storage.secret_access_key", "") + viper.SetDefault("image_storage.public_base_url", "") // Ops (vNext) viper.SetDefault("ops.enabled", true) @@ -2205,6 +2233,76 @@ func setDefaults() { viper.SetDefault("subscription_maintenance.worker_count", 2) viper.SetDefault("subscription_maintenance.queue_size", 1024) + setEnvReachableDefaults() +} + +// setEnvReachableDefaults registers zero-valued defaults for keys that are +// documented in deploy/config.example.yaml but had no default of their own. +// +// viper.Unmarshal only decodes the keys returned by AllKeys(), which unions +// SetDefault keys, config-file keys and explicitly bound BindEnv keys. +// AutomaticEnv can override a key already in that union, but it never adds one, +// and the viper_bind_struct escape hatch is compiled out (we build with +// -tags embed). So a key that lives only in the example file was unreachable by +// environment variable: the value was read from the process environment and +// then silently dropped. Deployments driven purely by env — which is what +// deploy/docker-compose.yml does — got the zero value with no warning. +// +// The values below are deliberately zero rather than the documented example +// values: an absent key already unmarshalled to the zero value, so registering +// zero keeps behavior identical while making the key addressable from the +// environment. Any subsystem that wants a richer default still applies it after +// unmarshal, exactly as before. +func setEnvReachableDefaults() { + viper.SetDefault("gateway.forced_codex_instructions_template_file", "") + viper.SetDefault("gateway.session_idle_timeout_minutes", 0) + viper.SetDefault("gateway.user_message_queue.mode", "") + viper.SetDefault("update.proxy_url", "") + + // sticky_escape_enabled is the one exception to the zero-value rule: its + // effective default is true, applied post-unmarshal via a viper.IsSet guard. + // Registering false would make IsSet always report true and permanently + // disable sticky escape, so register the effective default instead. An + // explicit false in config or env still wins. + viper.SetDefault("gateway.openai_scheduler.sticky_escape_enabled", true) + viper.SetDefault("gateway.openai_scheduler.sticky_escape_error_rate", 0.0) + viper.SetDefault("gateway.openai_scheduler.sticky_escape_ttft_ms", 0) + + // Third-party login providers. These carry client secrets and are exactly + // the settings an operator expects to inject via the environment, but every + // key here was previously unreachable that way. + for _, provider := range []string{"github_oauth", "google_oauth"} { + viper.SetDefault(provider+".enabled", false) + viper.SetDefault(provider+".client_id", "") + viper.SetDefault(provider+".client_secret", "") + viper.SetDefault(provider+".authorize_url", "") + viper.SetDefault(provider+".token_url", "") + viper.SetDefault(provider+".userinfo_url", "") + viper.SetDefault(provider+".emails_url", "") + viper.SetDefault(provider+".scopes", "") + viper.SetDefault(provider+".redirect_url", "") + viper.SetDefault(provider+".frontend_redirect_url", "") + } + + viper.SetDefault("dingtalk_connect.client_id", "") + viper.SetDefault("dingtalk_connect.client_secret", "") + viper.SetDefault("dingtalk_connect.internal_corp_id", "") + viper.SetDefault("dingtalk_connect.redirect_url", "") + viper.SetDefault("dingtalk_connect.bypass_registration", false) + viper.SetDefault("dingtalk_connect.username_attribute_key", "") + viper.SetDefault("dingtalk_connect.enable_attribute_matching", false) + viper.SetDefault("dingtalk_connect.enable_attribute_sync", false) + viper.SetDefault("dingtalk_connect.attribute_sync_fields", []string{}) + viper.SetDefault("dingtalk_connect.attribute_sync_overwrite_policy", "") + viper.SetDefault("dingtalk_connect.sync_display_name", false) + viper.SetDefault("dingtalk_connect.sync_display_name_attr_key", "") + viper.SetDefault("dingtalk_connect.sync_display_name_attr_name", "") + viper.SetDefault("dingtalk_connect.sync_dept", false) + viper.SetDefault("dingtalk_connect.sync_dept_attr_key", "") + viper.SetDefault("dingtalk_connect.sync_dept_attr_name", "") + viper.SetDefault("dingtalk_connect.sync_corp_email", false) + viper.SetDefault("dingtalk_connect.sync_corp_email_attr_key", "") + viper.SetDefault("dingtalk_connect.sync_corp_email_attr_name", "") } func (c *Config) Validate() error { diff --git a/backend/internal/config/env_reachability_test.go b/backend/internal/config/env_reachability_test.go new file mode 100644 index 000000000..8141a2ec3 --- /dev/null +++ b/backend/internal/config/env_reachability_test.go @@ -0,0 +1,90 @@ +//go:build unit + +package config + +import ( + "reflect" + "sort" + "strings" + "testing" + + "github.com/spf13/viper" +) + +// collectMapstructureKeys walks a config struct and returns every dotted key +// viper would need in order to populate it. +func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]string) { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" { + continue // unexported + } + tag := field.Tag.Get("mapstructure") + name, _, _ := strings.Cut(tag, ",") + if name == "-" { + continue + } + if name == "" { + name = strings.ToLower(field.Name) + } + key := name + if prefix != "" { + key = prefix + "." + name + } + + ft := field.Type + for ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + collectMapstructureKeys(ft, key, out) + continue + } + if ft.Kind() == reflect.Map { + // A map cannot be expressed in a single environment variable, so it + // is out of scope here — such settings need a config file either way. + continue + } + out[strings.ToLower(key)] = ft.String() + } +} + +// TestConfigKeysAreEnvReachable is the systemic guard behind the image_storage +// bug: viper.Unmarshal only decodes keys returned by AllKeys(), which unions +// SetDefault keys, config-file keys and explicit BindEnv keys. AutomaticEnv can +// override a key already in that union but never introduces one, and the +// viper_bind_struct escape hatch is compiled out (we build with -tags embed). +// +// So a Config field with no registered default is unreachable by environment +// variable whenever the deployment has no config.yaml containing it — the +// operator sets the variable, the loader discards it, and the feature behaves +// as if it were never configured. That is exactly how image_storage credentials +// were lost, silently disabling async image tasks for env-driven deployments. +// +// When this fails, register a zero-valued default in setEnvReachableDefaults +// for each reported key. +func TestConfigKeysAreEnvReachable(t *testing.T) { + bound := map[string]string{} + collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound) + + viper.Reset() + t.Cleanup(viper.Reset) + setDefaults() + registered := map[string]struct{}{} + for _, key := range viper.AllKeys() { + registered[key] = struct{}{} + } + + var unreachable []string + for key, kind := range bound { + if _, ok := registered[key]; !ok { + unreachable = append(unreachable, key+" ("+kind+")") + } + } + sort.Strings(unreachable) + + if len(unreachable) > 0 { + t.Fatalf("%d config keys have no default registered, so their environment variables are silently ignored:\n %s", + len(unreachable), strings.Join(unreachable, "\n ")) + } +} diff --git a/backend/internal/config/image_storage_env_test.go b/backend/internal/config/image_storage_env_test.go new file mode 100644 index 000000000..8061ee2ee --- /dev/null +++ b/backend/internal/config/image_storage_env_test.go @@ -0,0 +1,41 @@ +//go:build unit + +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestLoadImageStorageFromEnv guards against a viper trap that silently disabled +// asynchronous image tasks for every environment-variable-only deployment. +// +// viper only decodes keys returned by AllKeys(), which unions SetDefault keys, +// config-file keys and explicit BindEnv keys. AutomaticEnv can override a key +// that is already in that list, but it never introduces a new one. Credentials +// such as image_storage.bucket therefore need an (empty) default registered, or +// IMAGE_STORAGE_BUCKET is dropped on the floor and Active() stays false while +// image_storage.enabled reads true — the endpoints 404 with no useful signal. +func TestLoadImageStorageFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("IMAGE_STORAGE_ENABLED", "true") + t.Setenv("IMAGE_STORAGE_ENDPOINT", "https://acct.r2.cloudflarestorage.com") + t.Setenv("IMAGE_STORAGE_BUCKET", "my-images") + t.Setenv("IMAGE_STORAGE_ACCESS_KEY_ID", "ak") + t.Setenv("IMAGE_STORAGE_SECRET_ACCESS_KEY", "sk") + t.Setenv("IMAGE_STORAGE_PUBLIC_BASE_URL", "https://cdn.example.com") + + cfg, err := Load() + require.NoError(t, err) + + require.True(t, cfg.ImageStorage.Enabled) + require.Equal(t, "https://acct.r2.cloudflarestorage.com", cfg.ImageStorage.Endpoint) + require.Equal(t, "my-images", cfg.ImageStorage.Bucket) + require.Equal(t, "ak", cfg.ImageStorage.AccessKeyID) + require.Equal(t, "sk", cfg.ImageStorage.SecretAccessKey) + require.Equal(t, "https://cdn.example.com", cfg.ImageStorage.PublicBaseURL) + + require.True(t, cfg.ImageStorage.IsConfigured()) + require.True(t, cfg.ImageStorage.Active(), "async image tasks must be active when every credential is supplied via env") +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 59037d188..8c6abd899 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -12,6 +12,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/google/wire" "github.com/redis/go-redis/v9" + "go.uber.org/zap" ) // BuildInfo contains build information @@ -529,7 +530,10 @@ func ProvideAPIKeyAuthCacheInvalidator(apiKeyService *APIKeyService) APIKeyAuthC func ProvideImageTaskService(store ImageTaskStore, storage ImageStorage, cfg *config.Config) *ImageTaskService { if !cfg.ImageStorage.Active() { if cfg.ImageStorage.Enabled { - logger.L().Warn("image_storage.enabled is true but object storage is not fully configured; async image tasks are disabled") + // 列出具体缺失的键。若这些键其实已在环境变量里设过,说明它们没被读进来, + // 请确认 setDefaults 中已为其注册默认值(见 config.setEnvReachableDefaults)。 + logger.L().Warn("image_storage.enabled is true but object storage is not fully configured; async image tasks are disabled", + zap.Strings("missing_keys", cfg.ImageStorage.MissingCredentialKeys())) } return NewImageTaskService(store) } diff --git a/docs/ASYNC_IMAGE_TASKS.md b/docs/ASYNC_IMAGE_TASKS.md index 4c6b744e9..12793f2a7 100644 --- a/docs/ASYNC_IMAGE_TASKS.md +++ b/docs/ASYNC_IMAGE_TASKS.md @@ -41,6 +41,22 @@ When a task completes, each generated image is uploaded to the bucket and the re To support a different vendor beyond the S3-compatible client, implement the `service.ImageStorage` interface (`Save(ctx, key, contentType, data) (url, error)`) and provide it in place of the S3 implementation. +### Troubleshooting: the endpoints return 404 after enabling + +`404 async image tasks are not enabled` means `image_storage` did not resolve to a complete configuration, so the feature stayed off. The route exists either way — the 404 comes from the handler, not from an unregistered path, which makes it easy to mistake for a missing build. + +Check the startup log for: + +```text +WARN image_storage.enabled is true but object storage is not fully configured; async image tasks are disabled missing_keys=[...] +``` + +`missing_keys` names exactly which credentials were empty when the config was loaded. + +Note that releases **before v0.1.161 silently dropped `IMAGE_STORAGE_ENDPOINT`, `_BUCKET`, `_ACCESS_KEY_ID`, `_SECRET_ACCESS_KEY` and `_PUBLIC_BASE_URL`** when they were supplied only through the environment: those keys had no registered default, and viper cannot see an environment variable for a key it does not already know about. Deployments driven purely by `environment:` — which is what `deploy/docker-compose.yml` does by default — therefore reported `enabled: true` with empty credentials and 404'd on every async call. On an affected release the workaround is to also place the `image_storage` block in `/app/data/config.yaml` (copy it from `deploy/config.example.yaml`); once the keys exist in the file, the environment overrides apply normally. + +Two further causes of a 404 that are unrelated to storage: the API key's group must be on the **OpenAI or Grok** platform (any other platform, or a key with no group at all, yields `Images API is not supported for this platform`), and a task may only be polled with the **same API key that submitted it** — polling with a different key of the same user returns `image task not found` by design. + ## Submit a task ```bash From b08cab91a953eef0f6246e1ad53ebae8ff92f4e5 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 19 Jul 2026 00:56:21 -0700 Subject: [PATCH 2/4] =?UTF-8?q?feat(image-storage):=20=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E7=94=9F=E5=9B=BE=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=90=8E=E5=8F=B0=E9=85=8D=E7=BD=AE=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E5=8D=B3=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前开启异步生图必须改服务器上的 config.yaml 并重启容器(#4542),且若想 复用已配置的备份 S3,还得把同一套凭证再填一遍(#4458)。 - 新增 ImageStorageSettingService:配置存 settings 表,SecretAccessKey 经 SecretEncryptor 加密落库、读回脱敏、留空表示沿用旧值,与备份 S3 配置同一套做法。 - reuse_backup_s3(默认开)直接借用 backup_s3_config 的端点与密钥,只用自己的 bucket/prefix 区分对象,因此备份走 backups/、图片走 images/,且密钥不会在库里存两份。 - ImageTaskService 的启用状态改由 ImageStorageResolver 在运行时解析并缓存, 保存设置后 Invalidate 使下次请求重建客户端——不再需要重启。 - repository 侧由提供实例改为提供工厂,客户端才可能在运行期重建。 - 轮询接口的门控从 enabled() 放宽为 Pollable():关掉开关只拒绝新提交, 已受理的任务仍可取回结果,不再被中途吞掉。 - config.yaml 的 image_storage 保留为回落,后台从未保存过时沿用, 升级前已用配置文件开启的部署不受影响。 - 管理端 GET/PUT/POST /admin/backups/image-storage,PUT 与备份 S3 配置一样要求 step-up 2FA:改写存储目标同样能把生成内容导向外部账号。 注:go generate ./cmd/server 在当前 upstream 基线上即失败(securityaudit. PromptAdminService 缺 provider),故 wire_gen.go 为手工同步。 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y --- backend/cmd/server/wire_gen.go | 10 +- .../internal/handler/admin/backup_handler.go | 49 ++- .../handler/image_task_admin_toggle_test.go | 134 +++++++ .../internal/handler/image_task_handler.go | 13 +- backend/internal/repository/wire.go | 19 +- backend/internal/server/routes/admin.go | 6 + .../service/image_storage_settings.go | 342 ++++++++++++++++++ .../service/image_storage_settings_test.go | 220 +++++++++++ backend/internal/service/image_task.go | 43 ++- backend/internal/service/wire.go | 39 +- docs/ASYNC_IMAGE_TASKS.md | 14 + 11 files changed, 853 insertions(+), 36 deletions(-) create mode 100644 backend/internal/handler/image_task_admin_toggle_test.go create mode 100644 backend/internal/service/image_storage_settings.go create mode 100644 backend/internal/service/image_storage_settings_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 7a88fa355..d80eacef6 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -198,7 +198,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { backupObjectStoreFactory := repository.NewS3BackupStoreFactory() dbDumper := repository.NewPgDumper(configConfig) backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper) - backupHandler := admin.NewBackupHandler(backupService, userService) + imageStorageFactory := repository.ProvideImageStorageFactory() + imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig) + backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService) oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) @@ -277,11 +279,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry) availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService) imageTaskStore := repository.NewImageTaskStore(redisClient) - imageStorage, err := repository.ProvideImageStorage(configConfig) - if err != nil { - return nil, err - } - imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorage, configConfig) + imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService) asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler) batchImageRepository := repository.NewBatchImageRepository(db) batchImageQueue := repository.NewBatchImageQueue(redisClient, configConfig) diff --git a/backend/internal/handler/admin/backup_handler.go b/backend/internal/handler/admin/backup_handler.go index 2f528322f..6c51e2f77 100644 --- a/backend/internal/handler/admin/backup_handler.go +++ b/backend/internal/handler/admin/backup_handler.go @@ -10,12 +10,14 @@ import ( type BackupHandler struct { backupService *service.BackupService userService *service.UserService + imageStorage *service.ImageStorageSettingService } -func NewBackupHandler(backupService *service.BackupService, userService *service.UserService) *BackupHandler { +func NewBackupHandler(backupService *service.BackupService, userService *service.UserService, imageStorage *service.ImageStorageSettingService) *BackupHandler { return &BackupHandler{ backupService: backupService, userService: userService, + imageStorage: imageStorage, } } @@ -203,3 +205,48 @@ func (h *BackupHandler) RestoreBackup(c *gin.Context) { } response.Accepted(c, record) } + +// ─── 异步生图对象存储配置 ─── +// +// 与备份共用一套 S3 客户端构造,因此放在同一个页面下:勾选"复用备份 S3"即可直接 +// 借用备份已配置的端点与密钥,只用不同的前缀区分对象(备份走 backups/,图片走 images/)。 + +func (h *BackupHandler) GetImageStorageConfig(c *gin.Context) { + ctx := c.Request.Context() + cfg, err := h.imageStorage.Get(ctx) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{ + "config": cfg, + "secret_configured": h.imageStorage.SecretConfigured(ctx), + }) +} + +func (h *BackupHandler) UpdateImageStorageConfig(c *gin.Context) { + var req service.ImageStorageSettings + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + cfg, err := h.imageStorage.Update(c.Request.Context(), req) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, cfg) +} + +func (h *BackupHandler) TestImageStorageConnection(c *gin.Context) { + var req service.ImageStorageSettings + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if err := h.imageStorage.TestConnection(c.Request.Context(), req); err != nil { + response.Success(c, gin.H{"ok": false, "message": err.Error()}) + return + } + response.Success(c, gin.H{"ok": true, "message": "connection successful"}) +} diff --git a/backend/internal/handler/image_task_admin_toggle_test.go b/backend/internal/handler/image_task_admin_toggle_test.go new file mode 100644 index 000000000..e172adae4 --- /dev/null +++ b/backend/internal/handler/image_task_admin_toggle_test.go @@ -0,0 +1,134 @@ +//go:build unit + +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type toggleSettingRepo struct { + mu sync.Mutex + values map[string]string +} + +func (r *toggleSettingRepo) Get(context.Context, string) (*service.Setting, error) { return nil, nil } +func (r *toggleSettingRepo) GetValue(_ context.Context, key string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.values[key], nil +} + +func (r *toggleSettingRepo) Set(_ context.Context, key, value string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.values[key] = value + return nil +} + +func (r *toggleSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *toggleSettingRepo) SetMultiple(context.Context, map[string]string) error { return nil } +func (r *toggleSettingRepo) GetAll(context.Context) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *toggleSettingRepo) Delete(context.Context, string) error { return nil } + +type passthroughEncryptor struct{} + +func (passthroughEncryptor) Encrypt(plaintext string) (string, error) { return plaintext, nil } +func (passthroughEncryptor) Decrypt(ciphertext string) (string, error) { return ciphertext, nil } + +type noopImageStorage struct{} + +func (noopImageStorage) Save(context.Context, string, string, []byte) (string, error) { + return "https://cdn.example.test/object.png", nil +} + +// TestAsyncImageEnablesWithoutRestart drives the actual HTTP path for the bug behind +// #4458 and #4542: with object storage unconfigured the async endpoint 404s, and the +// only way to turn it on used to be editing config.yaml and restarting the container. +// Flipping the admin setting must flip the endpoint over in the same process. +func TestAsyncImageEnablesWithoutRestart(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &toggleSettingRepo{values: map[string]string{}} + backup := service.NewBackupService(repo, &config.Config{}, passthroughEncryptor{}, nil, nil) + factory := func(context.Context, *config.ImageStorageConfig) (service.ImageStorage, error) { + return noopImageStorage{}, nil + } + settings := service.NewImageStorageSettingService(repo, passthroughEncryptor{}, backup, factory, config.ImageStorageConfig{}) + + store := &asyncImageMemoryStore{tasks: make(map[string]*service.ImageTaskRecord)} + tasks := service.NewImageTaskServiceWithResolver(store, settings.Resolver(), time.Hour, time.Minute) + + h := &AsyncImageHandler{tasks: tasks} + h.execute = func(_ string, c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"created": 1, "data": []gin.H{{"url": "https://upstream.test/i.png"}}}) + } + + router := gin.New() + router.Use(func(c *gin.Context) { + groupID := int64(3) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 9, UserID: 7, GroupID: &groupID, + Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, AllowImageGeneration: true}, + }) + c.Next() + }) + router.POST("/v1/images/generations/async", h.Submit) + router.GET("/v1/images/tasks/:task_id", h.Get) + + submit := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations/async", + strings.NewReader(`{"model":"gpt-image-1","prompt":"a lighthouse"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + + rec := submit() + require.Equal(t, http.StatusNotFound, rec.Code, "disabled until an admin configures object storage") + require.Contains(t, rec.Body.String(), "async image tasks are not enabled") + + // The admin saves the setting — no restart, same process. + _, err := settings.Update(context.Background(), service.ImageStorageSettings{ + Enabled: true, Bucket: "my-images", + Endpoint: "https://acct.r2.cloudflarestorage.com", AccessKeyID: "ak", SecretAccessKey: "sk", + }) + require.NoError(t, err) + + rec = submit() + require.Equal(t, http.StatusAccepted, rec.Code, "the endpoint must go live as soon as the setting is saved") + + var accepted struct { + TaskID string `json:"task_id"` + PollURL string `json:"poll_url"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &accepted)) + require.NotEmpty(t, accepted.TaskID) + + // Turning the feature back off must not strand a task that was already accepted. + _, err = settings.Update(context.Background(), service.ImageStorageSettings{Enabled: false}) + require.NoError(t, err) + + require.Equal(t, http.StatusNotFound, submit().Code, "new submissions are refused again") + + pollRec := httptest.NewRecorder() + router.ServeHTTP(pollRec, httptest.NewRequest(http.MethodGet, accepted.PollURL, nil)) + require.Equal(t, http.StatusOK, pollRec.Code, "an already-accepted task stays pollable after the switch is turned off") +} diff --git a/backend/internal/handler/image_task_handler.go b/backend/internal/handler/image_task_handler.go index dfbb870db..f9b28668a 100644 --- a/backend/internal/handler/image_task_handler.go +++ b/backend/internal/handler/image_task_handler.go @@ -39,6 +39,13 @@ func (h *AsyncImageHandler) enabled() bool { return h != nil && h.tasks != nil && h.tasks.Enabled() } +// pollable reports whether task lookups can be served. It is deliberately weaker +// than enabled(): results already written to Redis stay readable after the +// feature is switched off, so an in-flight task is never stranded. +func (h *AsyncImageHandler) pollable() bool { + return h != nil && h.tasks != nil && h.tasks.Pollable() +} + // Submit accepts the same payload as the synchronous Images endpoint and // returns before the upstream image generation begins. func (h *AsyncImageHandler) Submit(c *gin.Context) { @@ -155,7 +162,11 @@ func (h *AsyncImageHandler) checkSecurityAuditBeforeSubmit(c *gin.Context, apiKe } func (h *AsyncImageHandler) Get(c *gin.Context) { - if !h.enabled() { + // Polling deliberately does not require the feature to be enabled, only that + // the task store is reachable. Turning the switch off in the admin UI must not + // strand tasks that were already accepted — their results are still in Redis + // and their submitters are still polling. + if !h.pollable() { imageTaskJSONError(c, http.StatusNotFound, "not_found_error", "async image tasks are not enabled") return } diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 581831912..4a79e5be9 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -141,7 +141,7 @@ var ProviderSet = wire.NewSet( NewS3BackupStoreFactory, // Image storage (async image task result offload) - ProvideImageStorage, + ProvideImageStorageFactory, // HTTP service ports (DI Strategy A: return interface directly) NewTurnstileVerifier, @@ -174,17 +174,14 @@ func ProvideEnt(cfg *config.Config) (*ent.Client, error) { return client, err } -// ProvideImageStorage 提供异步图片任务结果转存所用的对象存储实现。 -// 仅当开关打开且 S3 凭证齐全时返回具体实现,否则返回 nil(功能整体禁用)。 -func ProvideImageStorage(cfg *config.Config) (service.ImageStorage, error) { - if !cfg.ImageStorage.Active() { - return nil, nil +// ProvideImageStorageFactory 提供按需构造对象存储客户端的工厂。 +// +// 这里返回工厂而不是实例:异步生图的开关与凭证可以在后台随时改动,客户端必须能在 +// 设置保存后重建,而不是在启动时定死一份。 +func ProvideImageStorageFactory() service.ImageStorageFactory { + return func(ctx context.Context, cfg *config.ImageStorageConfig) (service.ImageStorage, error) { + return NewS3ImageStorage(ctx, cfg) } - store, err := NewS3ImageStorage(context.Background(), &cfg.ImageStorage) - if err != nil { - return nil, err - } - return store, nil } // ProvideSQLDB 从 Ent 客户端提取底层的 *sql.DB 连接。 diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 54cf0ae95..80f8dd369 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -574,6 +574,12 @@ func registerBackupRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAut backup.PUT("/s3-config", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.UpdateS3Config) backup.POST("/s3-config/test", h.Admin.Backup.TestS3Connection) + // 异步生图对象存储配置(与备份共用 S3 客户端,可直接复用备份凭证) + backup.GET("/image-storage", h.Admin.Backup.GetImageStorageConfig) + // 同 S3 配置:改写对象存储目标可将生成内容导向外部账号——要求 step-up 2FA + backup.PUT("/image-storage", gin.HandlerFunc(stepUpAuth), h.Admin.Backup.UpdateImageStorageConfig) + backup.POST("/image-storage/test", h.Admin.Backup.TestImageStorageConnection) + // 定时备份配置 backup.GET("/schedule", h.Admin.Backup.GetSchedule) backup.PUT("/schedule", h.Admin.Backup.UpdateSchedule) diff --git a/backend/internal/service/image_storage_settings.go b/backend/internal/service/image_storage_settings.go new file mode 100644 index 000000000..c8ee2c7f3 --- /dev/null +++ b/backend/internal/service/image_storage_settings.go @@ -0,0 +1,342 @@ +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 { + 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 + } +} diff --git a/backend/internal/service/image_storage_settings_test.go b/backend/internal/service/image_storage_settings_test.go new file mode 100644 index 000000000..3d1d6a2e8 --- /dev/null +++ b/backend/internal/service/image_storage_settings_test.go @@ -0,0 +1,220 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type stubSettingRepo struct { + mu sync.Mutex + values map[string]string +} + +func newStubSettingRepo() *stubSettingRepo { + return &stubSettingRepo{values: map[string]string{}} +} + +func (r *stubSettingRepo) Get(context.Context, string) (*Setting, error) { return nil, nil } +func (r *stubSettingRepo) GetValue(_ context.Context, key string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.values[key], nil +} + +func (r *stubSettingRepo) Set(_ context.Context, key, value string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.values[key] = value + return nil +} +func (r *stubSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *stubSettingRepo) SetMultiple(context.Context, map[string]string) error { return nil } +func (r *stubSettingRepo) GetAll(context.Context) (map[string]string, error) { + return map[string]string{}, nil +} +func (r *stubSettingRepo) Delete(context.Context, string) error { return nil } + +// reversibleEncryptor stands in for AES: prefixed so a test can tell ciphertext +// from plaintext, and so decrypting a plaintext value fails like the real one. +type reversibleEncryptor struct{} + +func (reversibleEncryptor) Encrypt(plaintext string) (string, error) { + return "enc:" + plaintext, nil +} + +func (reversibleEncryptor) Decrypt(ciphertext string) (string, error) { + rest, ok := strings.CutPrefix(ciphertext, "enc:") + if !ok { + return "", errors.New("not encrypted") + } + return rest, nil +} + +type recordingStorage struct{ saved []string } + +func (s *recordingStorage) Save(_ context.Context, key, _ string, _ []byte) (string, error) { + s.saved = append(s.saved, key) + return "https://cdn.example.com/" + key, nil +} + +func newImageStorageFixture(t *testing.T, fallback config.ImageStorageConfig) (*ImageStorageSettingService, *stubSettingRepo, *[]config.ImageStorageConfig) { + t.Helper() + repo := newStubSettingRepo() + encryptor := reversibleEncryptor{} + backup := NewBackupService(repo, &config.Config{}, encryptor, nil, nil) + + var built []config.ImageStorageConfig + factory := func(_ context.Context, cfg *config.ImageStorageConfig) (ImageStorage, error) { + built = append(built, *cfg) + return &recordingStorage{}, nil + } + return NewImageStorageSettingService(repo, encryptor, backup, factory, fallback), repo, &built +} + +func seedBackupS3(t *testing.T, repo *stubSettingRepo, cfg BackupS3Config) { + t.Helper() + cfg.SecretAccessKey = "enc:" + cfg.SecretAccessKey + data, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, repo.Set(context.Background(), settingKeyBackupS3Config, string(data))) +} + +// The admin switch must take effect without a restart: that is the entire point +// of moving image_storage out of config.yaml (#4542). +func TestImageStorageSettingsToggleTakesEffectWithoutRestart(t *testing.T) { + svc, repo, built := newImageStorageFixture(t, config.ImageStorageConfig{}) + ctx := context.Background() + seedBackupS3(t, repo, BackupS3Config{ + Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "auto", + Bucket: "backup-bucket", AccessKeyID: "ak", SecretAccessKey: "sk", + Prefix: "backups/", + }) + + uploader, enabled := svc.resolve() + require.False(t, enabled, "disabled until an admin turns it on") + require.Nil(t, uploader) + + _, err := svc.Update(ctx, ImageStorageSettings{Enabled: true, ReuseBackupS3: true}) + require.NoError(t, err) + + uploader, enabled = svc.resolve() + require.True(t, enabled, "saving the setting must enable the feature immediately") + require.NotNil(t, uploader) + + _, err = svc.Update(ctx, ImageStorageSettings{Enabled: false, ReuseBackupS3: true}) + require.NoError(t, err) + _, enabled = svc.resolve() + require.False(t, enabled, "turning it back off must also apply immediately") + + require.Len(t, *built, 1, "the S3 client is built only when the feature is on") +} + +func TestImageStorageSettingsReuseBackupCredentials(t *testing.T) { + svc, repo, built := newImageStorageFixture(t, config.ImageStorageConfig{}) + ctx := context.Background() + seedBackupS3(t, repo, BackupS3Config{ + Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "wnam", + Bucket: "backup-bucket", AccessKeyID: "backup-ak", SecretAccessKey: "backup-sk", + Prefix: "backups/", ForcePathStyle: true, + }) + + _, err := svc.Update(ctx, ImageStorageSettings{Enabled: true, ReuseBackupS3: true, Prefix: "images"}) + require.NoError(t, err) + _, enabled := svc.resolve() + require.True(t, enabled) + + require.Len(t, *built, 1) + got := (*built)[0] + require.Equal(t, "https://acct.r2.cloudflarestorage.com", got.Endpoint) + require.Equal(t, "wnam", got.Region) + require.Equal(t, "backup-ak", got.AccessKeyID) + require.Equal(t, "backup-sk", got.SecretAccessKey, "the backup secret must be decrypted before use") + require.True(t, got.ForcePathStyle) + require.Equal(t, "backup-bucket", got.Bucket, "an empty bucket falls back to the backup bucket") + require.Equal(t, "images/", got.Prefix, "images stay under their own prefix so they never collide with backups/") + + // Reusing must not duplicate the secret into a second row. + raw, err := repo.GetValue(ctx, settingKeyImageStorageConfig) + require.NoError(t, err) + require.NotContains(t, raw, "backup-sk") + require.NotContains(t, raw, "enc:") +} + +func TestImageStorageSettingsOwnCredentialsAreEncryptedAndMasked(t *testing.T) { + svc, repo, built := newImageStorageFixture(t, config.ImageStorageConfig{}) + ctx := context.Background() + + saved, err := svc.Update(ctx, ImageStorageSettings{ + Enabled: true, Bucket: "my-images", + Endpoint: "https://acct.r2.cloudflarestorage.com", + AccessKeyID: "ak", SecretAccessKey: "super-secret", + }) + require.NoError(t, err) + require.Empty(t, saved.SecretAccessKey, "the response must never echo the secret back") + + raw, err := repo.GetValue(ctx, settingKeyImageStorageConfig) + require.NoError(t, err) + require.NotContains(t, raw, `"secret_access_key":"super-secret"`, "the secret must be encrypted at rest") + require.Contains(t, raw, "enc:super-secret") + + fetched, err := svc.Get(ctx) + require.NoError(t, err) + require.Empty(t, fetched.SecretAccessKey) + require.True(t, svc.SecretConfigured(ctx)) + + _, enabled := svc.resolve() + require.True(t, enabled) + require.Equal(t, "super-secret", (*built)[0].SecretAccessKey, "the stored secret must be decrypted before use") + + // An update that omits the secret keeps the stored one rather than wiping it. + _, err = svc.Update(ctx, ImageStorageSettings{ + Enabled: true, Bucket: "my-images", + Endpoint: "https://acct.r2.cloudflarestorage.com", AccessKeyID: "ak", + }) + require.NoError(t, err) + svc.resolve() + require.Equal(t, "super-secret", (*built)[1].SecretAccessKey) +} + +func TestImageStorageSettingsIncompleteStaysDisabled(t *testing.T) { + svc, _, built := newImageStorageFixture(t, config.ImageStorageConfig{}) + ctx := context.Background() + + _, err := svc.Update(ctx, ImageStorageSettings{Enabled: true, Bucket: "my-images"}) + require.NoError(t, err) + + _, enabled := svc.resolve() + require.False(t, enabled, "missing credentials must not enable the feature") + require.Empty(t, *built, "no client is built from an incomplete configuration") +} + +// Deployments that already enabled the feature through config.yaml must keep +// working after the setting moves into the database. +func TestImageStorageSettingsFallBackToConfigFile(t *testing.T) { + svc, _, built := newImageStorageFixture(t, config.ImageStorageConfig{ + Enabled: true, Endpoint: "https://acct.r2.cloudflarestorage.com", Region: "auto", + Bucket: "yaml-bucket", AccessKeyID: "yaml-ak", SecretAccessKey: "yaml-sk", + Prefix: "images/", MaxDownloadByte: 1024, + }) + + _, enabled := svc.resolve() + require.True(t, enabled, "config.yaml still enables the feature when nothing is stored yet") + require.Equal(t, "yaml-bucket", (*built)[0].Bucket) + + fetched, err := svc.Get(context.Background()) + require.NoError(t, err) + require.True(t, fetched.Enabled) + require.Equal(t, "yaml-bucket", fetched.Bucket) + require.Empty(t, fetched.SecretAccessKey) +} diff --git a/backend/internal/service/image_task.go b/backend/internal/service/image_task.go index 3eba193fc..58ccfabc7 100644 --- a/backend/internal/service/image_task.go +++ b/backend/internal/service/image_task.go @@ -69,10 +69,17 @@ type ImageTaskStore interface { Get(ctx context.Context, id string) (*ImageTaskRecord, error) } +// ImageStorageResolver reports the currently effective object-storage binding. +// It exists so the async image feature can be switched on and off from the admin +// UI without a restart: the wiring below is fixed at startup, but the answer to +// "is object storage configured right now" is re-read (and cached) per call. +type ImageStorageResolver func() (uploader *ImageResultUploader, enabled bool) + type ImageTaskService struct { store ImageTaskStore uploader *ImageResultUploader enabled bool + resolve ImageStorageResolver ttl time.Duration executionTimeout time.Duration } @@ -100,10 +107,40 @@ func NewImageTaskServiceWithUploader(store ImageTaskStore, uploader *ImageResult return s } +// NewImageTaskServiceWithResolver 构造一个由 resolver 决定启用状态的服务: +// 开关与凭证来自后台设置,保存后立即生效,无需重启。 +func NewImageTaskServiceWithResolver(store ImageTaskStore, resolve ImageStorageResolver, ttl, executionTimeout time.Duration) *ImageTaskService { + s := NewImageTaskServiceWithOptions(store, ttl, executionTimeout) + s.resolve = resolve + return s +} + +// current 返回当前生效的 uploader 与启用状态。 +// 注入了 resolver 时以 resolver 为准(后台设置可热切换),否则回落到构造时固定的值。 +func (s *ImageTaskService) current() (*ImageResultUploader, bool) { + if s == nil { + return nil, false + } + if s.resolve != nil { + return s.resolve() + } + return s.uploader, s.enabled +} + // Enabled 表示异步图片任务功能是否可用(总开关 + 凭证齐全)。 // 关闭时 handler 直接返回 404,不创建任务、不写 Redis。 func (s *ImageTaskService) Enabled() bool { - return s != nil && s.enabled && s.store != nil + if s == nil || s.store == nil { + return false + } + _, enabled := s.current() + return enabled +} + +// Pollable 表示已创建的任务能否被查询。 +// 比 Enabled 弱:只要 store 可用即可,从而在功能被关掉后仍能取回进行中的任务结果。 +func (s *ImageTaskService) Pollable() bool { + return s != nil && s.store != nil } func (s *ImageTaskService) ExecutionTimeout() time.Duration { @@ -154,8 +191,8 @@ func (s *ImageTaskService) Complete(ctx context.Context, id string, statusCode i if !json.Valid(result) { return s.Fail(ctx, id, http.StatusBadGateway, imageTaskErrorJSON("api_error", "upstream returned a non-JSON image response")) } - if s.uploader != nil { - rewritten, err := s.uploader.Rewrite(ctx, id, result) + if uploader, _ := s.current(); uploader != nil { + rewritten, err := uploader.Rewrite(ctx, id, result) if err != nil { // 转存失败不回退存 base64,避免大 blob 撑爆 Redis:直接把任务标记为失败。 logger.L().Error("image_task.offload_failed", zap.String("task_id", id), zap.Error(err)) diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 8c6abd899..f90a57431 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -522,23 +522,33 @@ func ProvideAPIKeyAuthCacheInvalidator(apiKeyService *APIKeyService) APIKeyAuthC return apiKeyService } +// ProvideImageStorageSettingService 构造异步生图对象存储的后台设置服务。 +// +// config.yaml 里的 image_storage 作为回落:后台从未保存过设置时沿用它, +// 使升级前已通过配置文件开启该功能的部署不被打断。 +func ProvideImageStorageSettingService( + settingRepo SettingRepository, + encryptor SecretEncryptor, + backup *BackupService, + factory ImageStorageFactory, + cfg *config.Config, +) *ImageStorageSettingService { + if cfg.ImageStorage.Enabled && !cfg.ImageStorage.Active() { + // 列出具体缺失的键。若这些键其实已在环境变量里设过,说明它们没被读进来, + // 请确认 setDefaults 中已为其注册默认值(见 config.setEnvReachableDefaults)。 + logger.L().Warn("image_storage.enabled is true in config but object storage is not fully configured; configure it in the admin UI or complete the config file", + zap.Strings("missing_keys", cfg.ImageStorage.MissingCredentialKeys())) + } + return NewImageStorageSettingService(settingRepo, encryptor, backup, factory, cfg.ImageStorage) +} + // ProvideImageTaskService 构造异步图片任务服务。 // -// 对象存储是异步图片任务的启用前提:仅当 image_storage 开关打开且凭证齐全时, -// 服务才启用,并挂上把结果转存到对象存储的 uploader;否则功能整体禁用 +// 对象存储是异步图片任务的启用前提:仅当开关打开且凭证齐全时功能才可用,否则整体禁用 // (handler 返回 404,不创建任务、不写 Redis),从而避免大 base64 结果撑爆 Redis。 -func ProvideImageTaskService(store ImageTaskStore, storage ImageStorage, cfg *config.Config) *ImageTaskService { - if !cfg.ImageStorage.Active() { - if cfg.ImageStorage.Enabled { - // 列出具体缺失的键。若这些键其实已在环境变量里设过,说明它们没被读进来, - // 请确认 setDefaults 中已为其注册默认值(见 config.setEnvReachableDefaults)。 - logger.L().Warn("image_storage.enabled is true but object storage is not fully configured; async image tasks are disabled", - zap.Strings("missing_keys", cfg.ImageStorage.MissingCredentialKeys())) - } - return NewImageTaskService(store) - } - uploader := NewImageResultUploader(storage, cfg.ImageStorage.Prefix, cfg.ImageStorage.MaxDownloadByte, nil) - return NewImageTaskServiceWithUploader(store, uploader, defaultImageTaskTTL, defaultImageTaskExecutionTimeout) +// 启用状态由 settings 服务在运行时解析,因此后台改开关后无需重启即可生效。 +func ProvideImageTaskService(store ImageTaskStore, settings *ImageStorageSettingService) *ImageTaskService { + return NewImageTaskServiceWithResolver(store, settings.Resolver(), defaultImageTaskTTL, defaultImageTaskExecutionTimeout) } // ProvideBackupService creates and starts BackupService @@ -665,6 +675,7 @@ var ProviderSet = wire.NewSet( NewAdminService, NewGatewayService, NewOpenAIGatewayService, + ProvideImageStorageSettingService, ProvideImageTaskService, ProvideBatchImageModelPricingResolver, NewBatchImagePublicService, diff --git a/docs/ASYNC_IMAGE_TASKS.md b/docs/ASYNC_IMAGE_TASKS.md index 12793f2a7..542723312 100644 --- a/docs/ASYNC_IMAGE_TASKS.md +++ b/docs/ASYNC_IMAGE_TASKS.md @@ -20,6 +20,20 @@ Only OpenAI and Grok groups are supported. Requests use the same JSON or multipa Asynchronous image tasks are **disabled by default** and gated on object storage. When the switch is off — or the S3 credentials are incomplete — the async endpoints return `404` and never create a task or write to Redis. This is deliberate: without offloading, large `b64_json` results (several MB each, e.g. `gpt-image-1`) would accumulate in Redis and exhaust its memory. +### From the admin UI (recommended) + +**Admin → Backup → Async image object storage.** Saving the form takes effect immediately — the object-storage client is rebuilt on the next request, so there is no container restart. + +Because the async image storage and the database backup share one S3 client, the form defaults to **reusing the backup S3 configuration**: it borrows the endpoint, region and credentials already configured above and keeps only its own bucket and prefix, so backups stay under `backups/` while images go to `images/`. Leave the bucket empty to use the backup bucket as well. Untick the box to point images at a completely separate account. + +Saving requires step-up 2FA when that gate is enabled, for the same reason the backup S3 form does: changing the target redirects generated content to another account. + +Turning the switch off stops new submissions but keeps already-accepted tasks pollable, so nothing in flight is stranded. + +### From the config file + +The admin setting takes precedence. When nothing has ever been saved there, the `image_storage` block in `config.yaml` is used instead, so deployments that enabled the feature before the admin UI existed keep working untouched. + Configure an S3-compatible object store (AWS S3, Cloudflare R2, Aliyun OSS, MinIO, …) in `config.yaml` (all keys also accept the `IMAGE_STORAGE_*` environment overrides): ```yaml From 0343dba916e9d22451020f6f870982dd1f8b9be1 Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 19 Jul 2026 00:56:33 -0700 Subject: [PATCH 3/4] =?UTF-8?q?feat(admin):=20=E5=A4=87=E4=BB=BD=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BC=82=E6=AD=A5=E7=94=9F=E5=9B=BE=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E5=AD=98=E5=82=A8=E9=85=8D=E7=BD=AE=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对应后端 /admin/backups/image-storage。放在备份页而非系统设置页,是因为它与 数据库备份共用同一套 S3 客户端与凭证——用户的心智就是"在备份的 S3 上加个开关"。 - 默认勾选"复用备份 S3",此时隐藏端点/密钥字段,只留 bucket 与 prefix, bucket 留空则沿用备份桶。 - 保存走 useStepUp,与备份 S3 配置的保存路径一致。 - 补中英文案。 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y --- frontend/src/api/admin/backup.ts | 49 ++++++ .../src/i18n/locales/en/admin/overview.ts | 13 ++ .../src/i18n/locales/zh/admin/overview.ts | 13 ++ frontend/src/views/admin/BackupView.vue | 152 +++++++++++++++++- 4 files changed, 225 insertions(+), 2 deletions(-) diff --git a/frontend/src/api/admin/backup.ts b/frontend/src/api/admin/backup.ts index bccb1f802..f8e978b00 100644 --- a/frontend/src/api/admin/backup.ts +++ b/frontend/src/api/admin/backup.ts @@ -60,6 +60,52 @@ export async function testS3Connection(config: BackupS3Config): Promise { + const { data } = await apiClient.get('/admin/backups/image-storage') + return data +} + +export async function updateImageStorageConfig( + config: ImageStorageConfig, +): Promise { + const { data } = await apiClient.put('/admin/backups/image-storage', config) + return data +} + +export async function testImageStorageConnection( + config: ImageStorageConfig, +): Promise { + const { data } = await apiClient.post( + '/admin/backups/image-storage/test', + config, + ) + return data +} + // Schedule export async function getSchedule(): Promise { const { data } = await apiClient.get('/admin/backups/schedule') @@ -106,6 +152,9 @@ export const backupAPI = { getS3Config, updateS3Config, testS3Connection, + getImageStorageConfig, + updateImageStorageConfig, + testImageStorageConnection, getSchedule, updateSchedule, createBackup, diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 9c56819fb..e35bfe09c 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -102,6 +102,19 @@ export default { testFailed: 'S3 connection test failed', saved: 'S3 configuration saved' }, + imageStorage: { + title: 'Async image object storage', + description: 'Enables the asynchronous image endpoints and offloads generated images to object storage, keeping only short links in Redis. Shares the S3 client with backups and takes effect on save — no restart needed.', + enabled: 'Enable async image tasks', + reuseBackupS3: 'Reuse the backup S3 configuration above (different bucket/prefix only)', + bucket: 'Bucket', + bucketInherited: 'Leave empty to use the backup bucket', + prefix: 'Key prefix', + publicBaseUrl: 'Public base URL', + publicBaseUrlPlaceholder: 'Leave empty to return presigned links', + presignExpiryHours: 'Presigned link TTL (hours)', + saved: 'Async image object storage saved' + }, schedule: { title: 'Scheduled Backup', description: 'Configure automatic scheduled backups', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 7e4834ec2..2fde58919 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -102,6 +102,19 @@ export default { testFailed: 'S3 连接测试失败', saved: 'S3 配置已保存' }, + imageStorage: { + title: '异步生图对象存储', + description: '开启后,异步生图接口可用,生成结果转存到对象存储,只把短链接写入 Redis。与备份共用同一套 S3 客户端,保存后立即生效,无需重启。', + enabled: '启用异步生图', + reuseBackupS3: '复用上方备份的 S3 配置(只用不同的存储桶/前缀)', + bucket: '存储桶', + bucketInherited: '留空则沿用备份存储桶', + prefix: 'Key 前缀', + publicBaseUrl: '公开访问域名', + publicBaseUrlPlaceholder: '留空则返回预签名临时链接', + presignExpiryHours: '预签名链接有效期(小时)', + saved: '异步生图对象存储配置已保存' + }, schedule: { title: '定时备份', description: '配置自动定时备份', diff --git a/frontend/src/views/admin/BackupView.vue b/frontend/src/views/admin/BackupView.vue index a2223f941..f0c3e5b72 100644 --- a/frontend/src/views/admin/BackupView.vue +++ b/frontend/src/views/admin/BackupView.vue @@ -54,6 +54,81 @@ + +
+
+
+

+ {{ t('admin.backup.imageStorage.title') }} +

+

+ {{ t('admin.backup.imageStorage.description') }} +

+
+ +
+ + + +
+
+ + +
+
+ + +
+ + + +
+ + +
+
+ + +
+
+ +
+ + +
+
+
@@ -284,7 +359,12 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import { adminAPI } from '@/api' import { useAppStore } from '@/stores' -import type { BackupS3Config, BackupScheduleConfig, BackupRecord } from '@/api/admin/backup' +import type { + BackupS3Config, + BackupScheduleConfig, + BackupRecord, + ImageStorageConfig, +} from '@/api/admin/backup' import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp' import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue' @@ -317,6 +397,26 @@ const s3SecretConfigured = ref(false) const savingS3 = ref(false) const testingS3 = ref(false) +// Async image object storage. Shares the S3 client with backups, so the default is +// to reuse the credentials configured above and only differ by prefix. +const imageStorageForm = ref({ + enabled: false, + reuse_backup_s3: true, + bucket: '', + prefix: 'images/', + public_base_url: '', + presign_expiry_hours: 24, + max_download_bytes: 33554432, + endpoint: '', + region: 'auto', + access_key_id: '', + secret_access_key: '', + force_path_style: false, +}) +const imageStorageSecretConfigured = ref(false) +const savingImageStorage = ref(false) +const testingImageStorage = ref(false) + // Schedule config const scheduleForm = ref({ enabled: false, @@ -485,6 +585,54 @@ async function saveS3Config() { } } +async function loadImageStorageConfig() { + try { + const { config, secret_configured } = await adminAPI.backup.getImageStorageConfig() + imageStorageForm.value = { + ...config, + prefix: config.prefix || 'images/', + region: config.region || 'auto', + secret_access_key: '', + } + imageStorageSecretConfigured.value = secret_configured + } catch (error) { + appStore.showError((error as { message?: string })?.message || t('errors.networkError')) + } +} + +async function saveImageStorageConfig() { + savingImageStorage.value = true + try { + await backupStepUp.run(() => adminAPI.backup.updateImageStorageConfig(imageStorageForm.value)) + appStore.showSuccess(t('admin.backup.imageStorage.saved')) + await loadImageStorageConfig() + } catch (error) { + if (isStepUpCancelled(error)) { + savingImageStorage.value = false + return + } + appStore.showError((error as { message?: string })?.message || t('errors.networkError')) + } finally { + savingImageStorage.value = false + } +} + +async function testImageStorage() { + testingImageStorage.value = true + try { + const result = await adminAPI.backup.testImageStorageConnection(imageStorageForm.value) + if (result.ok) { + appStore.showSuccess(result.message || t('admin.backup.s3.testSuccess')) + } else { + appStore.showError(result.message || t('admin.backup.s3.testFailed')) + } + } catch (error) { + appStore.showError((error as { message?: string })?.message || t('errors.networkError')) + } finally { + testingImageStorage.value = false + } +} + async function testS3() { testingS3.value = true try { @@ -642,7 +790,7 @@ function formatDate(value?: string): string { onMounted(async () => { document.addEventListener('visibilitychange', handleVisibilityChange) - await Promise.all([loadS3Config(), loadSchedule(), loadBackups()]) + await Promise.all([loadS3Config(), loadImageStorageConfig(), loadSchedule(), loadBackups()]) // 如果有正在 running 的备份,恢复轮询 const runningBackup = backups.value.find(r => r.status === 'running') From 6102d64274fa002df81803f2b840d3ac9659a39c Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Sun, 19 Jul 2026 01:03:41 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(wire):=20=E8=A1=A5=E4=B8=8A=20PromptAdm?= =?UTF-8?q?inService=20=E7=9A=84=E7=BB=91=E5=AE=9A=EF=BC=8C=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=20wire=20=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProviderSet 提供了 NewPromptAdminHandler,也绑定了 PromptEngine,却没有为 PromptAdminService 绑定实现,导致 go generate ./cmd/server 直接失败: no provider found for securityaudit.PromptAdminService needed by *securityaudit.PromptAdminHandler *PromptService 本就实现了该接口的全部方法,wire_gen.go 也一直是手工维护到位的 (补上绑定后重新生成的结果与现有文件逐字节一致),只是生成这条路被堵死了, 后续任何依赖改动都无法再靠 go generate 同步。 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y --- backend/internal/securityaudit/prompt_module.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/internal/securityaudit/prompt_module.go b/backend/internal/securityaudit/prompt_module.go index 691cc7dbd..e1199fcfa 100644 --- a/backend/internal/securityaudit/prompt_module.go +++ b/backend/internal/securityaudit/prompt_module.go @@ -16,6 +16,7 @@ var ProviderSet = wire.NewSet( wire.Bind(new(ConfigStore), new(*ConfigManager)), NewPromptService, wire.Bind(new(PromptEngine), new(*PromptService)), + wire.Bind(new(PromptAdminService), new(*PromptService)), NewLegacyModerationAdapter, NewCoordinator, NewPromptAdminHandler,