From 10ccc005be1f6492f4625bffb8d6d12c515b4204 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Mon, 17 Aug 2026 15:52:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/config.yaml | 104 +------ internal/data/config_management.go | 12 +- internal/data/config_store.go | 77 ++++- internal/data/config_watch.go | 4 + internal/data/data.go | 23 +- internal/data/integration_config.go | 311 +++++++++++++++++++++ internal/data/integration_config_test.go | 295 +++++++++++++++++++ internal/data/system_init.go | 61 +++- web/src/api/system.js | 14 + web/src/modules/email/view/index.vue | 2 +- web/src/view/systemTools/system/system.vue | 91 +++++- 11 files changed, 884 insertions(+), 110 deletions(-) create mode 100644 internal/data/integration_config.go create mode 100644 internal/data/integration_config_test.go diff --git a/configs/config.yaml b/configs/config.yaml index f4ca916..6dbca13 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -31,8 +31,8 @@ data: db: 0 use_cluster: false cluster_addrs: [] - read_timeout: 0.2s - write_timeout: 0.2s + read_timeout: 0.200s + write_timeout: 0.200s # Additional databases use alias_name as the lookup key. Disabled entries # remain available as configuration examples without opening connections. database_list: [] @@ -75,10 +75,10 @@ data: username: "" password: "" auth_source: "" - min_pool_size: 0 - max_pool_size: 100 - socket_timeout_ms: 0 - connect_timeout_ms: 0 + min_pool_size: "0" + max_pool_size: "100" + socket_timeout_ms: "0" + connect_timeout_ms: "0" is_zap: false hosts: - host: "" @@ -103,7 +103,7 @@ admin: media: # Upload chunks are stored below .chunks in the selected storage backend. session_ttl: 24 - max_file_size: 0 + max_file_size: "0" chunk_dir: uploads/chunks system: use_redis: false @@ -116,7 +116,7 @@ admin: iplimit_time: 0 zap: level: info - prefix: "[kra] " + prefix: '[kra] ' format: json director: logs encode_level: LowercaseLevelEncoder @@ -145,91 +145,3 @@ admin: env: development disk_list: - mount_point: / - storage: - # local, qiniu, aliyun-oss, huawei-obs, tencent-cos, aws-s3, - # cloudflare-r2 or minio - type: local - qiniu: - zone: ZoneHuadong - bucket: "" - base_url: "" - access_key: "" - secret_key: "" - use_https: false - use_cdn_domains: false - aliyun_oss: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: true - force_path_style: false - account_id: "" - huawei_obs: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: true - force_path_style: false - account_id: "" - tencent_cos: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: true - force_path_style: false - account_id: "" - aws_s3: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: true - force_path_style: false - account_id: "" - cloudflare_r2: - endpoint: "" - region: auto - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: uploads - use_ssl: true - force_path_style: false - account_id: "" - minio: - endpoint: "" - region: "" - bucket: "" - access_key: "" - secret_key: "" - base_url: "" - path_prefix: "" - use_ssl: false - force_path_style: true - account_id: "" - email: - # Leave host/from/secret empty to disable SMTP error notifications. - to: "" - from: "" - host: "" - secret: "" - nickname: "" - port: 465 - is_ssl: true - is_login_auth: false diff --git a/internal/data/config_management.go b/internal/data/config_management.go index 89f645c..798c0dc 100644 --- a/internal/data/config_management.go +++ b/internal/data/config_management.go @@ -62,7 +62,11 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize, "chunkDir": chunkDir} } if adminConfig.Email != nil { - email = map[string]any{"to": adminConfig.Email.To, "from": adminConfig.Email.From, "host": adminConfig.Email.Host, "secret": "******", "nickname": adminConfig.Email.Nickname, "port": adminConfig.Email.Port, "is-ssl": adminConfig.Email.IsSsl, "is-loginauth": adminConfig.Email.IsLoginAuth} + secret := "" + if adminConfig.Email.Secret != "" { + secret = "******" + } + email = map[string]any{"to": adminConfig.Email.To, "from": adminConfig.Email.From, "host": adminConfig.Email.Host, "secret": secret, "nickname": adminConfig.Email.Nickname, "port": adminConfig.Email.Port, "is-ssl": adminConfig.Email.IsSsl, "is-loginauth": adminConfig.Email.IsLoginAuth} } if adminConfig.Storage != nil { storage := proto.Clone(adminConfig.Storage).(*conf.AdminBackend_Storage) @@ -97,7 +101,7 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { if safeAdmin.Jwt != nil { safeAdmin.Jwt.SigningKey = "******" } - if safeAdmin.Email != nil { + if safeAdmin.Email != nil && safeAdmin.Email.Secret != "" { safeAdmin.Email.Secret = "******" } maskStorageSecrets(safeAdmin.Storage) @@ -432,11 +436,11 @@ func maskStorageSecrets(storage *conf.AdminBackend_Storage) { if storage == nil { return } - if storage.Qiniu != nil { + if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" { storage.Qiniu.SecretKey = "******" } for _, item := range objectStores(storage) { - if item != nil { + if item != nil && item.SecretKey != "" { item.SecretKey = "******" } } diff --git a/internal/data/config_store.go b/internal/data/config_store.go index 8a6a457..60dd5c5 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -113,6 +113,30 @@ func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node { return node } +func deleteYAMLMapping(node *yaml.Node, keys ...string) { + if len(keys) == 0 || node == nil { + return + } + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + if node.Kind != yaml.MappingNode { + return + } + key := keys[0] + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value != key { + continue + } + if len(keys) == 1 { + node.Content = append(node.Content[:i], node.Content[i+2:]...) + return + } + deleteYAMLMapping(node.Content[i+1], keys[1:]...) + return + } +} + func setServerHTTPPort(document *yaml.Node, port int32) error { if port <= 0 { return nil @@ -154,7 +178,10 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con if err != nil { return err } - adminValue, err := protoMap(adminConfig) + fileAdmin := cloneAdminConfig(adminConfig) + fileAdmin.Storage = nil + fileAdmin.Email = nil + adminValue, err := protoMap(fileAdmin) if err != nil { return err } @@ -164,6 +191,8 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con if err = setYAMLMapping(&document, "admin", adminValue); err != nil { return err } + deleteYAMLMapping(&document, "admin", "storage") + deleteYAMLMapping(&document, "admin", "email") if adminConfig.System != nil { if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil { return err @@ -221,6 +250,31 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st return err } } + deleteYAMLMapping(&document, "admin", "storage") + deleteYAMLMapping(&document, "admin", "email") + return writeConfigDocument(configPath, &document) +} + +func (d *Data) removeIntegrationConfigFromFile() error { + d.configMu.Lock() + defer d.configMu.Unlock() + configPath := d.runtime.ConfigPath() + if configPath == "" { + return nil + } + raw, err := os.ReadFile(configPath) + if err != nil { + return err + } + var document yaml.Node + if err = yaml.Unmarshal(raw, &document); err != nil { + return err + } + if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil { + return nil + } + deleteYAMLMapping(&document, "admin", "storage") + deleteYAMLMapping(&document, "admin", "email") return writeConfigDocument(configPath, &document) } @@ -300,6 +354,27 @@ func (d *Data) reloadConfig(ctx context.Context) error { return fmt.Errorf("reload database migrations: %w", err) } } + legacyStorage := next.Admin.Storage + legacyEmail := next.Admin.Email + currentAdmin := d.runtime.Admin() + if legacyStorage == nil { + if currentAdmin != nil { + legacyStorage = currentAdmin.Storage + } + } + if legacyEmail == nil && currentAdmin != nil { + legacyEmail = currentAdmin.Email + } + storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage) + if err != nil { + return fmt.Errorf("reload storage configuration: %w", err) + } + next.Admin.Storage = storageConfig + emailConfig, err := resolveEmailIntegrationConfig(candidateDB.WithContext(ctx), legacyEmail) + if err != nil { + return fmt.Errorf("reload email configuration: %w", err) + } + next.Admin.Email = emailConfig candidateStorage, err := buildFileStorage(next.Admin) if err != nil { return fmt.Errorf("reload storage: %w", err) diff --git a/internal/data/config_watch.go b/internal/data/config_watch.go index f7130a1..f679566 100644 --- a/internal/data/config_watch.go +++ b/internal/data/config_watch.go @@ -55,6 +55,10 @@ func (d *Data) watchConfig() func() { logger.Error("reload changed config: data and admin configuration are required", "mod", "system") return } + if current := d.runtime.Admin(); current != nil { + next.Admin.Storage = current.Storage + next.Admin.Email = current.Email + } next.Admin.ConfigPath = absolute d.runtime.Replace(next.Data, next.Admin) logger.Info("config file changed", "mod", "system", "path", absolute) diff --git a/internal/data/data.go b/internal/data/data.go index 8293739..7bb6d5c 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -133,12 +133,33 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro registerDataScopeCallbacks(item, d.enqueueDataScopeAudit) } admin := runtime.Admin() - disableAutoMigrate := admin != nil && admin.System != nil && admin.System.DisableAutoMigrate + if admin == nil { + admin = &conf.AdminBackend{} + } + disableAutoMigrate := admin.System != nil && admin.System.DisableAutoMigrate if !usingFallback && !disableAutoMigrate { if err = migrateAll(db); err != nil { return nil, nil, fmt.Errorf("migrate tables: %w", err) } } + if !usingFallback { + storageConfig, storageErr := resolveStorageIntegrationConfig(db, admin.Storage) + if storageErr != nil { + return nil, nil, fmt.Errorf("load storage integration configuration: %w", storageErr) + } + emailConfig, emailErr := resolveEmailIntegrationConfig(db, admin.Email) + if emailErr != nil { + return nil, nil, fmt.Errorf("load email integration configuration: %w", emailErr) + } + admin.Storage = storageConfig + admin.Email = emailConfig + runtime.Replace(c, admin) + if db.Migrator().HasTable(&integrationConfigPO{}) { + if removeErr := d.removeIntegrationConfigFromFile(); removeErr != nil { + appLogger.Warn("remove legacy integration configuration from file", "mod", "integration", "error", removeErr) + } + } + } useRedis := admin != nil && admin.System != nil && admin.System.UseRedis d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger)) useMongo := admin != nil && admin.System != nil && admin.System.UseMongo diff --git a/internal/data/integration_config.go b/internal/data/integration_config.go new file mode 100644 index 0000000..e5e59c7 --- /dev/null +++ b/internal/data/integration_config.go @@ -0,0 +1,311 @@ +package data + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "kra/internal/conf" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "gorm.io/gorm" +) + +const ( + integrationKindStorage = "storage" + integrationKindEmail = "email" + integrationKindPayment = "payment" +) + +// integrationConfigPO stores credentials and provider-specific options for +// external services. Payment integrations use the same table with kind +// "payment", keeping secrets out of the bootstrap configuration file. +type integrationConfigPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + Kind string `gorm:"size:32;not null;uniqueIndex:idx_integration_kind_provider"` + Provider string `gorm:"size:64;not null;uniqueIndex:idx_integration_kind_provider"` + Enabled bool `gorm:"not null;default:false;index"` + Config string `gorm:"type:text;not null"` +} + +func (integrationConfigPO) TableName() string { return "sys_integration_configs" } + +var storageProviderNames = []string{ + "local", + "qiniu", + "aliyun-oss", + "huawei-obs", + "tencent-cos", + "aws-s3", + "cloudflare-r2", + "minio", +} + +func normalizeStorageType(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "local" + } + return value +} + +func storageProviderMessage(storage *conf.AdminBackend_Storage, provider string) proto.Message { + if storage == nil { + storage = &conf.AdminBackend_Storage{} + } + switch provider { + case "qiniu": + if storage.Qiniu == nil { + storage.Qiniu = &conf.AdminBackend_Qiniu{} + } + return storage.Qiniu + case "aliyun-oss": + return ensureObjectStore(&storage.AliyunOss) + case "huawei-obs": + return ensureObjectStore(&storage.HuaweiObs) + case "tencent-cos": + return ensureObjectStore(&storage.TencentCos) + case "aws-s3": + return ensureObjectStore(&storage.AwsS3) + case "cloudflare-r2": + return ensureObjectStore(&storage.CloudflareR2) + case "minio": + return ensureObjectStore(&storage.Minio) + default: + return nil + } +} + +func ensureObjectStore(value **conf.AdminBackend_ObjectStore) proto.Message { + if *value == nil { + *value = &conf.AdminBackend_ObjectStore{} + } + return *value +} + +func marshalStorageProvider(storage *conf.AdminBackend_Storage, provider string) (string, error) { + message := storageProviderMessage(storage, provider) + if message == nil { + return "{}", nil + } + raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(message) + if err != nil { + return "", err + } + return string(raw), nil +} + +func unmarshalStorageProvider(storage *conf.AdminBackend_Storage, provider, value string) error { + if provider == "local" || strings.TrimSpace(value) == "" { + return nil + } + message := storageProviderMessage(storage, provider) + if message == nil { + return nil + } + if !json.Valid([]byte(value)) { + return fmt.Errorf("invalid %s integration configuration", provider) + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(value), message); err != nil { + return fmt.Errorf("decode %s integration configuration: %w", provider, err) + } + return nil +} + +func saveStorageIntegrationConfig(db *gorm.DB, storage *conf.AdminBackend_Storage) error { + if storage == nil { + storage = &conf.AdminBackend_Storage{} + } + active := normalizeStorageType(storage.Type) + known := false + for _, provider := range storageProviderNames { + if provider == active { + known = true + break + } + } + if !known { + return fmt.Errorf("unsupported storage type %q", active) + } + + return db.Session(&gorm.Session{NewDB: true}).Transaction(func(tx *gorm.DB) error { + for _, provider := range storageProviderNames { + value, err := marshalStorageProvider(storage, provider) + if err != nil { + return fmt.Errorf("encode %s integration configuration: %w", provider, err) + } + var current integrationConfigPO + err = tx.Where("kind = ? AND provider = ?", integrationKindStorage, provider).First(¤t).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + current = integrationConfigPO{Kind: integrationKindStorage, Provider: provider} + current.Enabled, current.Config = provider == active, value + if err = tx.Create(¤t).Error; err != nil { + return err + } + case err != nil: + return err + default: + if err = tx.Model(¤t).Updates(map[string]any{"enabled": provider == active, "config": value}).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func loadStorageIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Storage, bool, error) { + var rows []integrationConfigPO + err := db.Session(&gorm.Session{NewDB: true}). + Where("kind = ?", integrationKindStorage). + Order("id ASC"). + Find(&rows).Error + if err != nil { + return nil, false, err + } + if len(rows) == 0 { + return nil, false, nil + } + + storage := &conf.AdminBackend_Storage{Type: "local"} + for _, row := range rows { + if err = unmarshalStorageProvider(storage, row.Provider, row.Config); err != nil { + return nil, false, err + } + if row.Enabled { + storage.Type = row.Provider + } + } + return storage, true, nil +} + +// resolveStorageIntegrationConfig upgrades a legacy YAML configuration only +// when the database has no storage rows yet. From then on the database is the +// sole source of truth. +func resolveStorageIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Storage) (*conf.AdminBackend_Storage, error) { + clean := db.Session(&gorm.Session{NewDB: true}) + if !clean.Migrator().HasTable(&integrationConfigPO{}) { + if legacy == nil { + return &conf.AdminBackend_Storage{Type: "local"}, nil + } + return proto.Clone(legacy).(*conf.AdminBackend_Storage), nil + } + storage, found, err := loadStorageIntegrationConfig(clean) + if err != nil { + return nil, err + } + if found { + return storage, nil + } + if legacy == nil { + legacy = &conf.AdminBackend_Storage{Type: "local"} + } + if err = saveStorageIntegrationConfig(clean, legacy); err != nil { + return nil, err + } + storage, _, err = loadStorageIntegrationConfig(clean) + return storage, err +} + +func (d *Data) persistStorageIntegrationConfig(ctx context.Context, storage *conf.AdminBackend_Storage) error { + if !d.databaseReady.Load() { + return errors.New("database is not initialized") + } + db := d.gormDB.WithContext(ctx) + if !db.Migrator().HasTable(&integrationConfigPO{}) { + return errors.New("integration configuration table does not exist") + } + return saveStorageIntegrationConfig(db, storage) +} + +func defaultEmailIntegrationConfig() *conf.AdminBackend_Email { + return &conf.AdminBackend_Email{Port: 465, IsSsl: true} +} + +func saveEmailIntegrationConfig(db *gorm.DB, email *conf.AdminBackend_Email) error { + if email == nil { + email = defaultEmailIntegrationConfig() + } + raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(email) + if err != nil { + return fmt.Errorf("encode smtp integration configuration: %w", err) + } + enabled := email.Host != "" && email.From != "" && email.Secret != "" && email.Port > 0 + clean := db.Session(&gorm.Session{NewDB: true}) + var current integrationConfigPO + err = clean.Where("kind = ? AND provider = ?", integrationKindEmail, "smtp").First(¤t).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + return clean.Create(&integrationConfigPO{ + Kind: integrationKindEmail, Provider: "smtp", Enabled: enabled, Config: string(raw), + }).Error + case err != nil: + return err + default: + return clean.Model(¤t).Updates(map[string]any{"enabled": enabled, "config": string(raw)}).Error + } +} + +func loadEmailIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_Email, bool, error) { + var row integrationConfigPO + err := db.Session(&gorm.Session{NewDB: true}). + Where("kind = ? AND provider = ?", integrationKindEmail, "smtp"). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if !json.Valid([]byte(row.Config)) { + return nil, false, errors.New("invalid smtp integration configuration") + } + email := defaultEmailIntegrationConfig() + if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), email); err != nil { + return nil, false, fmt.Errorf("decode smtp integration configuration: %w", err) + } + return email, true, nil +} + +func resolveEmailIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_Email) (*conf.AdminBackend_Email, error) { + clean := db.Session(&gorm.Session{NewDB: true}) + if !clean.Migrator().HasTable(&integrationConfigPO{}) { + if legacy == nil { + return defaultEmailIntegrationConfig(), nil + } + return proto.Clone(legacy).(*conf.AdminBackend_Email), nil + } + email, found, err := loadEmailIntegrationConfig(clean) + if err != nil { + return nil, err + } + if found { + return email, nil + } + if legacy == nil { + legacy = defaultEmailIntegrationConfig() + } + if err = saveEmailIntegrationConfig(clean, legacy); err != nil { + return nil, err + } + email, _, err = loadEmailIntegrationConfig(clean) + return email, err +} + +func (d *Data) persistEmailIntegrationConfig(ctx context.Context, email *conf.AdminBackend_Email) error { + if !d.databaseReady.Load() { + return errors.New("database is not initialized") + } + db := d.gormDB.WithContext(ctx) + if !db.Migrator().HasTable(&integrationConfigPO{}) { + return errors.New("integration configuration table does not exist") + } + return saveEmailIntegrationConfig(db, email) +} diff --git a/internal/data/integration_config_test.go b/internal/data/integration_config_test.go new file mode 100644 index 0000000..e9490a2 --- /dev/null +++ b/internal/data/integration_config_test.go @@ -0,0 +1,295 @@ +package data + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "kra/internal/conf" + + "google.golang.org/protobuf/encoding/protojson" + "gopkg.in/yaml.v3" + "gorm.io/gorm" +) + +func openIntegrationConfigTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err = db.AutoMigrate(&integrationConfigPO{}); err != nil { + t.Fatal(err) + } + return db +} + +func TestMigrateAllCreatesIntegrationConfigTable(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err = migrateAll(db); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasTable(&integrationConfigPO{}) { + t.Fatal("migrateAll did not create sys_integration_configs") + } +} + +func TestStorageIntegrationConfigRoundTrip(t *testing.T) { + db := openIntegrationConfigTestDB(t) + storage := &conf.AdminBackend_Storage{ + Type: "aliyun-oss", + Qiniu: &conf.AdminBackend_Qiniu{ + Zone: "ZoneHuadong", Bucket: "qiniu-bucket", AccessKey: "qiniu-key", SecretKey: "qiniu-secret", + }, + AliyunOss: &conf.AdminBackend_ObjectStore{ + Endpoint: "oss-cn-hangzhou.aliyuncs.com", Region: "cn-hangzhou", Bucket: "assets", + AccessKey: "aliyun-key", SecretKey: "aliyun-secret", BaseUrl: "https://cdn.example.com", PathPrefix: "uploads", + }, + Minio: &conf.AdminBackend_ObjectStore{Endpoint: "127.0.0.1:9000", Bucket: "local", ForcePathStyle: true}, + } + if err := saveStorageIntegrationConfig(db, storage); err != nil { + t.Fatal(err) + } + email := &conf.AdminBackend_Email{ + To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com", + Secret: "smtp-secret", Nickname: "Kra", Port: 465, IsSsl: true, + } + if err := saveEmailIntegrationConfig(db, email); err != nil { + t.Fatal(err) + } + if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: "wechat-pay", Config: `{"merchant_id":"123"}`}).Error; err != nil { + t.Fatal(err) + } + + loaded, found, err := loadStorageIntegrationConfig(db) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("storage integration configuration was not found") + } + if loaded.Type != "aliyun-oss" { + t.Fatalf("storage type = %q, want aliyun-oss", loaded.Type) + } + if loaded.AliyunOss == nil || loaded.AliyunOss.SecretKey != "aliyun-secret" || loaded.AliyunOss.PathPrefix != "uploads" { + t.Fatalf("aliyun configuration = %#v", loaded.AliyunOss) + } + if loaded.Qiniu == nil || loaded.Qiniu.SecretKey != "qiniu-secret" { + t.Fatalf("qiniu configuration = %#v", loaded.Qiniu) + } + + loadedEmail, found, err := loadEmailIntegrationConfig(db) + if err != nil || !found { + t.Fatalf("email configuration found=%v, err=%v", found, err) + } + if loadedEmail.Host != "smtp.example.com" || loadedEmail.Secret != "smtp-secret" || loadedEmail.Port != 465 { + t.Fatalf("email configuration = %#v", loadedEmail) + } + + var storageCount, emailCount, paymentCount int64 + if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindStorage).Count(&storageCount).Error; err != nil { + t.Fatal(err) + } + if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindPayment).Count(&paymentCount).Error; err != nil { + t.Fatal(err) + } + if err = db.Model(&integrationConfigPO{}).Where("kind = ?", integrationKindEmail).Count(&emailCount).Error; err != nil { + t.Fatal(err) + } + if storageCount != int64(len(storageProviderNames)) { + t.Fatalf("storage row count = %d, want %d", storageCount, len(storageProviderNames)) + } + if paymentCount != 1 { + t.Fatalf("payment row count = %d, want 1", paymentCount) + } + if emailCount != 1 { + t.Fatalf("email row count = %d, want 1", emailCount) + } +} + +func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { + db := openIntegrationConfigTestDB(t) + legacy := &conf.AdminBackend_Storage{ + Type: "qiniu", + Qiniu: &conf.AdminBackend_Qiniu{Bucket: "legacy", SecretKey: "legacy-secret"}, + } + loaded, err := resolveStorageIntegrationConfig(db, legacy) + if err != nil { + t.Fatal(err) + } + if loaded.Type != "qiniu" || loaded.Qiniu.GetBucket() != "legacy" { + t.Fatalf("migrated storage = %#v", loaded) + } + + other := &conf.AdminBackend_Storage{ + Type: "minio", + Minio: &conf.AdminBackend_ObjectStore{Bucket: "must-not-replace-database"}, + } + loaded, err = resolveStorageIntegrationConfig(db, other) + if err != nil { + t.Fatal(err) + } + if loaded.Type != "qiniu" || loaded.Qiniu.GetSecretKey() != "legacy-secret" { + t.Fatalf("database configuration was replaced by legacy config: %#v", loaded) + } +} + +func TestMaskStorageSecretsLeavesUnconfiguredProvidersEmpty(t *testing.T) { + storage := &conf.AdminBackend_Storage{ + Qiniu: &conf.AdminBackend_Qiniu{}, + AliyunOss: &conf.AdminBackend_ObjectStore{SecretKey: "configured-secret"}, + Minio: &conf.AdminBackend_ObjectStore{}, + } + maskStorageSecrets(storage) + if storage.Qiniu.SecretKey != "" || storage.Minio.SecretKey != "" { + t.Fatalf("empty provider secrets were masked: %#v", storage) + } + if storage.AliyunOss.SecretKey != "******" { + t.Fatalf("configured secret was not masked: %q", storage.AliyunOss.SecretKey) + } +} + +func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) { + db := openIntegrationConfigTestDB(t) + legacy := &conf.AdminBackend_Email{To: "ops@example.com", From: "old@example.com", Host: "smtp.old.example.com", Secret: "old-secret", Port: 465, IsSsl: true} + loaded, err := resolveEmailIntegrationConfig(db, legacy) + if err != nil { + t.Fatal(err) + } + if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret { + t.Fatalf("migrated email = %#v", loaded) + } + loaded, err = resolveEmailIntegrationConfig(db, &conf.AdminBackend_Email{Host: "must-not-replace.example.com"}) + if err != nil { + t.Fatal(err) + } + if loaded.Host != legacy.Host || loaded.Secret != legacy.Secret { + t.Fatalf("database email was replaced by legacy config: %#v", loaded) + } +} + +func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + input := []byte("data: {}\nadmin:\n router_prefix: /old\n storage:\n type: qiniu\n qiniu:\n secret_key: legacy-secret\n email:\n host: smtp.legacy.example.com\n secret: legacy-email-secret\n extension_key: retained\n") + if err := os.WriteFile(path, input, 0o600); err != nil { + t.Fatal(err) + } + d := &Data{} + admin := &conf.AdminBackend{ + ConfigPath: path, + RouterPrefix: "/api", + Storage: &conf.AdminBackend_Storage{ + Type: "qiniu", + Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"}, + }, + Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"}, + } + if err := d.persistConfigValues(&conf.Data{}, admin); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err = yaml.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + adminValue, ok := document["admin"].(map[string]any) + if !ok { + t.Fatalf("admin config = %#v", document["admin"]) + } + if _, exists := adminValue["storage"]; exists { + t.Fatalf("storage remained in YAML: %s", raw) + } + if _, exists := adminValue["email"]; exists { + t.Fatalf("email remained in YAML: %s", raw) + } + if adminValue["extension_key"] != "retained" { + t.Fatalf("extension key was not retained: %#v", adminValue) + } +} + +func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) { + db := openIntegrationConfigTestDB(t) + configPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(configPath, []byte("data: {}\nadmin: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + oldRoot := filepath.Join(t.TempDir(), "old") + newRoot := filepath.Join(t.TempDir(), "new") + currentAdmin := &conf.AdminBackend{ + ConfigPath: configPath, + Local: &conf.AdminBackend_Local{StorePath: oldRoot, PathPrefix: "old-files"}, + Storage: &conf.AdminBackend_Storage{Type: "local"}, + } + currentStorage, err := buildFileStorage(currentAdmin) + if err != nil { + t.Fatal(err) + } + reloadableDB := &reloadableDB{} + reloadableDB.current.Store(db) + d := &Data{ + runtime: conf.NewRuntime(&conf.Data{}, currentAdmin), + gormDB: reloadableDB, + storage: &reloadableStorage{current: currentStorage}, + } + d.databaseReady.Store(true) + + nextAdmin := cloneAdminConfig(currentAdmin) + nextAdmin.Local = &conf.AdminBackend_Local{StorePath: newRoot, PathPrefix: "new-files"} + nextAdmin.Email = &conf.AdminBackend_Email{ + To: "ops@example.com", From: "mailer@example.com", Host: "smtp.example.com", + Secret: "runtime-secret", Port: 465, IsSsl: true, + } + dataRaw, err := protojson.Marshal(&conf.Data{}) + if err != nil { + t.Fatal(err) + } + adminRaw, err := protojson.Marshal(nextAdmin) + if err != nil { + t.Fatal(err) + } + repo := &initializationRepo{data: d} + if err = repo.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil { + t.Fatal(err) + } + + stored, err := d.storage.Put(context.Background(), "active.txt", strings.NewReader("active")) + if err != nil { + t.Fatal(err) + } + if stored.URL != "/new-files/active.txt" { + t.Fatalf("active storage URL = %q, want /new-files/active.txt", stored.URL) + } + if _, err = os.Stat(filepath.Join(newRoot, "active.txt")); err != nil { + t.Fatalf("active storage did not write to the new root: %v", err) + } + loaded, found, err := loadStorageIntegrationConfig(db) + if err != nil || !found || loaded.Type != "local" { + t.Fatalf("database storage config = %#v, found=%v, err=%v", loaded, found, err) + } + loadedEmail, found, err := loadEmailIntegrationConfig(db) + if err != nil || !found || loadedEmail.Secret != "runtime-secret" { + t.Fatalf("database email config = %#v, found=%v, err=%v", loadedEmail, found, err) + } + if runtimeEmail := d.runtime.Admin().Email; runtimeEmail == nil || runtimeEmail.Host != "smtp.example.com" { + t.Fatalf("runtime email config = %#v", runtimeEmail) + } +} diff --git a/internal/data/system_init.go b/internal/data/system_init.go index 0e657a9..01a4d80 100644 --- a/internal/data/system_init.go +++ b/internal/data/system_init.go @@ -18,22 +18,41 @@ import ( ) func (r *initializationRepo) PersistConfig(context.Context) error { return r.data.persistConfig() } -func (r *initializationRepo) PersistAdminConfig(_ context.Context, raw []byte) error { +func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte) error { currentData, currentAdmin := r.data.runtime.Values() next := proto.Clone(currentAdmin).(*conf.AdminBackend) if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil { return err } + if next.Storage == nil { + next.Storage = currentAdmin.Storage + } + if next.Email == nil { + next.Email = currentAdmin.Email + } next.ConfigPath = currentAdmin.ConfigPath + candidateStorage, err := buildFileStorage(next) + if err != nil { + return err + } + if err := r.data.persistStorageIntegrationConfig(ctx, next.Storage); err != nil { + return err + } + if err := r.data.persistEmailIntegrationConfig(ctx, next.Email); err != nil { + return err + } if err := r.data.persistConfigValues(currentData, next); err != nil { return err } // Writing through the management API updates the same in-memory values // immediately; the file watcher remains the fallback for external edits. r.data.runtime.Replace(currentData, next) + if r.data.storage != nil { + r.data.storage.replace(candidateStorage) + } return nil } -func (r *initializationRepo) PersistRuntimeConfig(_ context.Context, dataRaw, adminRaw []byte) error { +func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error { currentData, currentAdmin := r.data.runtime.Values() nextData := proto.Clone(currentData).(*conf.Data) nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend) @@ -44,11 +63,30 @@ func (r *initializationRepo) PersistRuntimeConfig(_ context.Context, dataRaw, ad if err := options.Unmarshal(adminRaw, nextAdmin); err != nil { return err } + if nextAdmin.Storage == nil { + nextAdmin.Storage = currentAdmin.Storage + } + if nextAdmin.Email == nil { + nextAdmin.Email = currentAdmin.Email + } nextAdmin.ConfigPath = currentAdmin.ConfigPath + candidateStorage, err := buildFileStorage(nextAdmin) + if err != nil { + return err + } + if err := r.data.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil { + return err + } + if err := r.data.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil { + return err + } if err := r.data.persistConfigValues(nextData, nextAdmin); err != nil { return err } r.data.runtime.Replace(nextData, nextAdmin) + if r.data.storage != nil { + r.data.storage.replace(candidateStorage) + } return nil } func (r *initializationRepo) ReloadConfig(ctx context.Context) error { @@ -236,6 +274,23 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database }); err != nil { return err } + currentAdmin := r.data.runtime.Admin() + var legacyStorage *conf.AdminBackend_Storage + if currentAdmin != nil { + legacyStorage = currentAdmin.Storage + } + storageConfig, err := resolveStorageIntegrationConfig(candidate.WithContext(ctx), legacyStorage) + if err != nil { + return fmt.Errorf("initialize storage integration configuration: %w", err) + } + var legacyEmail *conf.AdminBackend_Email + if currentAdmin != nil { + legacyEmail = currentAdmin.Email + } + emailConfig, err := resolveEmailIntegrationConfig(candidate.WithContext(ctx), legacyEmail) + if err != nil { + return fmt.Errorf("initialize email integration configuration: %w", err) + } signingKey := uuid.NewString() if err := r.data.persistDatabaseConfig(config, signingKey); err != nil { return fmt.Errorf("persist database configuration: %w", err) @@ -249,6 +304,8 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database currentAdmin.Jwt = &conf.AdminBackend_JWT{} } currentAdmin.Jwt.SigningKey = signingKey + currentAdmin.Storage = storageConfig + currentAdmin.Email = emailConfig r.data.runtime.Replace(currentData, currentAdmin) activated = true return nil diff --git a/web/src/api/system.js b/web/src/api/system.js index ff41abf..abccfb0 100644 --- a/web/src/api/system.js +++ b/web/src/api/system.js @@ -27,6 +27,20 @@ export const setSystemConfig = (data) => { }) } +// 对象存储配置以局部 payload 提交,避免连带覆盖其他尚未保存的表单项。 +export const setStorageConfig = (storage) => { + return setSystemConfig({ + config: { + admin: { storage } + } + }) +} + +// 邮件配置同样以局部 payload 提交,SMTP 密钥由后端写入集成配置表。 +export const setEmailConfig = (email) => { + return setSystemConfig({ config: { email } }) +} + // @Tags system // @Summary 获取服务器运行状态 // @Security ApiKeyAuth diff --git a/web/src/modules/email/view/index.vue b/web/src/modules/email/view/index.vue index 0241e73..d603677 100644 --- a/web/src/modules/email/view/index.vue +++ b/web/src/modules/email/view/index.vue @@ -1,7 +1,7 @@ -

保存后点击“重载服务”,新的对象存储配置会立即生效。

+
+ 保存对象存储 +
@@ -223,8 +225,11 @@ - - 发送测试邮件 + +
+ 保存邮件配置 + 保存并发送测试邮件 +
@@ -235,7 +240,7 @@