mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:48:45 +08:00
Merge pull request #4593 from StarryKira/fix/image-storage-env-unreachable
fix: 异步生图开关配了却不生效(环境变量被静默丢弃 + 迁移到后台开关)
This commit is contained in:
@@ -200,7 +200,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)
|
||||
@@ -279,11 +281,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)
|
||||
|
||||
@@ -257,6 +257,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"`
|
||||
@@ -1656,6 +1672,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
|
||||
}
|
||||
@@ -2025,6 +2044,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)
|
||||
@@ -2311,6 +2339,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 {
|
||||
|
||||
@@ -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 "))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -142,7 +142,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,
|
||||
@@ -175,17 +175,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 连接。
|
||||
|
||||
@@ -579,6 +579,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -521,20 +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 {
|
||||
logger.L().Warn("image_storage.enabled is true but object storage is not fully configured; async image tasks are disabled")
|
||||
}
|
||||
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
|
||||
@@ -680,6 +694,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewAdminService,
|
||||
NewGatewayService,
|
||||
NewOpenAIGatewayService,
|
||||
ProvideImageStorageSettingService,
|
||||
ProvideImageTaskService,
|
||||
ProvideBatchImageModelPricingResolver,
|
||||
NewBatchImagePublicService,
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +55,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
|
||||
|
||||
@@ -60,6 +60,52 @@ export async function testS3Connection(config: BackupS3Config): Promise<TestS3Re
|
||||
return data
|
||||
}
|
||||
|
||||
// Async image object storage
|
||||
//
|
||||
// Shares the S3 client with backups, so `reuse_backup_s3` borrows the endpoint and
|
||||
// credentials configured above and only keeps its own bucket/prefix.
|
||||
export interface ImageStorageConfig {
|
||||
enabled: boolean
|
||||
reuse_backup_s3: boolean
|
||||
bucket: string
|
||||
prefix: string
|
||||
public_base_url: string
|
||||
presign_expiry_hours: number
|
||||
max_download_bytes: number
|
||||
endpoint: string
|
||||
region: string
|
||||
access_key_id: string
|
||||
secret_access_key?: string
|
||||
force_path_style: boolean
|
||||
}
|
||||
|
||||
export interface ImageStorageConfigResponse {
|
||||
config: ImageStorageConfig
|
||||
secret_configured: boolean
|
||||
}
|
||||
|
||||
export async function getImageStorageConfig(): Promise<ImageStorageConfigResponse> {
|
||||
const { data } = await apiClient.get<ImageStorageConfigResponse>('/admin/backups/image-storage')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateImageStorageConfig(
|
||||
config: ImageStorageConfig,
|
||||
): Promise<ImageStorageConfig> {
|
||||
const { data } = await apiClient.put<ImageStorageConfig>('/admin/backups/image-storage', config)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function testImageStorageConnection(
|
||||
config: ImageStorageConfig,
|
||||
): Promise<TestS3Response> {
|
||||
const { data } = await apiClient.post<TestS3Response>(
|
||||
'/admin/backups/image-storage/test',
|
||||
config,
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
// Schedule
|
||||
export async function getSchedule(): Promise<BackupScheduleConfig> {
|
||||
const { data } = await apiClient.get<BackupScheduleConfig>('/admin/backups/schedule')
|
||||
@@ -106,6 +152,9 @@ export const backupAPI = {
|
||||
getS3Config,
|
||||
updateS3Config,
|
||||
testS3Connection,
|
||||
getImageStorageConfig,
|
||||
updateImageStorageConfig,
|
||||
testImageStorageConnection,
|
||||
getSchedule,
|
||||
updateSchedule,
|
||||
createBackup,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '配置自动定时备份',
|
||||
|
||||
@@ -54,6 +54,81 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Async image object storage -->
|
||||
<div class="card p-6">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.backup.imageStorage.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.backup.imageStorage.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="imageStorageForm.enabled" type="checkbox" />
|
||||
<span>{{ t('admin.backup.imageStorage.enabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="imageStorageForm.reuse_backup_s3" type="checkbox" />
|
||||
<span>{{ t('admin.backup.imageStorage.reuseBackupS3') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.bucket') }}</label>
|
||||
<input v-model="imageStorageForm.bucket" class="input w-full" :placeholder="imageStorageForm.reuse_backup_s3 ? t('admin.backup.imageStorage.bucketInherited') : ''" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.prefix') }}</label>
|
||||
<input v-model="imageStorageForm.prefix" class="input w-full" placeholder="images/" />
|
||||
</div>
|
||||
|
||||
<template v-if="!imageStorageForm.reuse_backup_s3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.endpoint') }}</label>
|
||||
<input v-model="imageStorageForm.endpoint" class="input w-full" placeholder="https://<account_id>.r2.cloudflarestorage.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.region') }}</label>
|
||||
<input v-model="imageStorageForm.region" class="input w-full" placeholder="auto" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.accessKeyId') }}</label>
|
||||
<input v-model="imageStorageForm.access_key_id" class="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.secretAccessKey') }}</label>
|
||||
<input v-model="imageStorageForm.secret_access_key" type="password" class="input w-full" :placeholder="imageStorageSecretConfigured ? t('admin.backup.s3.secretConfigured') : ''" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
|
||||
<input v-model="imageStorageForm.force_path_style" type="checkbox" />
|
||||
<span>{{ t('admin.backup.s3.forcePathStyle') }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.publicBaseUrl') }}</label>
|
||||
<input v-model="imageStorageForm.public_base_url" class="input w-full" :placeholder="t('admin.backup.imageStorage.publicBaseUrlPlaceholder')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.presignExpiryHours') }}</label>
|
||||
<input v-model.number="imageStorageForm.presign_expiry_hours" type="number" min="1" class="input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingImageStorage" @click="testImageStorage">
|
||||
{{ testingImageStorage ? t('common.loading') : t('admin.backup.s3.testConnection') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" :disabled="savingImageStorage" @click="saveImageStorageConfig">
|
||||
{{ savingImageStorage ? t('common.loading') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Config -->
|
||||
<div class="card p-6">
|
||||
<div class="mb-4">
|
||||
@@ -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<ImageStorageConfig>({
|
||||
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<BackupScheduleConfig>({
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user