This commit is contained in:
Yvan 2026-08-17 15:52:59 +08:00
parent 083cc3a9ea
commit 10ccc005be
11 changed files with 884 additions and 110 deletions

View File

@ -31,8 +31,8 @@ data:
db: 0 db: 0
use_cluster: false use_cluster: false
cluster_addrs: [] cluster_addrs: []
read_timeout: 0.2s read_timeout: 0.200s
write_timeout: 0.2s write_timeout: 0.200s
# Additional databases use alias_name as the lookup key. Disabled entries # Additional databases use alias_name as the lookup key. Disabled entries
# remain available as configuration examples without opening connections. # remain available as configuration examples without opening connections.
database_list: [] database_list: []
@ -75,10 +75,10 @@ data:
username: "" username: ""
password: "" password: ""
auth_source: "" auth_source: ""
min_pool_size: 0 min_pool_size: "0"
max_pool_size: 100 max_pool_size: "100"
socket_timeout_ms: 0 socket_timeout_ms: "0"
connect_timeout_ms: 0 connect_timeout_ms: "0"
is_zap: false is_zap: false
hosts: hosts:
- host: "" - host: ""
@ -103,7 +103,7 @@ admin:
media: media:
# Upload chunks are stored below .chunks in the selected storage backend. # Upload chunks are stored below .chunks in the selected storage backend.
session_ttl: 24 session_ttl: 24
max_file_size: 0 max_file_size: "0"
chunk_dir: uploads/chunks chunk_dir: uploads/chunks
system: system:
use_redis: false use_redis: false
@ -116,7 +116,7 @@ admin:
iplimit_time: 0 iplimit_time: 0
zap: zap:
level: info level: info
prefix: "[kra] " prefix: '[kra] '
format: json format: json
director: logs director: logs
encode_level: LowercaseLevelEncoder encode_level: LowercaseLevelEncoder
@ -145,91 +145,3 @@ admin:
env: development env: development
disk_list: disk_list:
- mount_point: / - 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

View File

@ -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} admin["media"] = map[string]any{"sessionTtl": adminConfig.Media.SessionTtl, "maxFileSize": adminConfig.Media.MaxFileSize, "chunkDir": chunkDir}
} }
if adminConfig.Email != nil { 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 { if adminConfig.Storage != nil {
storage := proto.Clone(adminConfig.Storage).(*conf.AdminBackend_Storage) storage := proto.Clone(adminConfig.Storage).(*conf.AdminBackend_Storage)
@ -97,7 +101,7 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) {
if safeAdmin.Jwt != nil { if safeAdmin.Jwt != nil {
safeAdmin.Jwt.SigningKey = "******" safeAdmin.Jwt.SigningKey = "******"
} }
if safeAdmin.Email != nil { if safeAdmin.Email != nil && safeAdmin.Email.Secret != "" {
safeAdmin.Email.Secret = "******" safeAdmin.Email.Secret = "******"
} }
maskStorageSecrets(safeAdmin.Storage) maskStorageSecrets(safeAdmin.Storage)
@ -432,11 +436,11 @@ func maskStorageSecrets(storage *conf.AdminBackend_Storage) {
if storage == nil { if storage == nil {
return return
} }
if storage.Qiniu != nil { if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" {
storage.Qiniu.SecretKey = "******" storage.Qiniu.SecretKey = "******"
} }
for _, item := range objectStores(storage) { for _, item := range objectStores(storage) {
if item != nil { if item != nil && item.SecretKey != "" {
item.SecretKey = "******" item.SecretKey = "******"
} }
} }

View File

@ -113,6 +113,30 @@ func yamlMappingValue(node *yaml.Node, keys ...string) *yaml.Node {
return 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 { func setServerHTTPPort(document *yaml.Node, port int32) error {
if port <= 0 { if port <= 0 {
return nil return nil
@ -154,7 +178,10 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
if err != nil { if err != nil {
return err return err
} }
adminValue, err := protoMap(adminConfig) fileAdmin := cloneAdminConfig(adminConfig)
fileAdmin.Storage = nil
fileAdmin.Email = nil
adminValue, err := protoMap(fileAdmin)
if err != nil { if err != nil {
return err return err
} }
@ -164,6 +191,8 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
if err = setYAMLMapping(&document, "admin", adminValue); err != nil { if err = setYAMLMapping(&document, "admin", adminValue); err != nil {
return err return err
} }
deleteYAMLMapping(&document, "admin", "storage")
deleteYAMLMapping(&document, "admin", "email")
if adminConfig.System != nil { if adminConfig.System != nil {
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil { if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
return err return err
@ -221,6 +250,31 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st
return err 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) return writeConfigDocument(configPath, &document)
} }
@ -300,6 +354,27 @@ func (d *Data) reloadConfig(ctx context.Context) error {
return fmt.Errorf("reload database migrations: %w", err) 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) candidateStorage, err := buildFileStorage(next.Admin)
if err != nil { if err != nil {
return fmt.Errorf("reload storage: %w", err) return fmt.Errorf("reload storage: %w", err)

View File

@ -55,6 +55,10 @@ func (d *Data) watchConfig() func() {
logger.Error("reload changed config: data and admin configuration are required", "mod", "system") logger.Error("reload changed config: data and admin configuration are required", "mod", "system")
return return
} }
if current := d.runtime.Admin(); current != nil {
next.Admin.Storage = current.Storage
next.Admin.Email = current.Email
}
next.Admin.ConfigPath = absolute next.Admin.ConfigPath = absolute
d.runtime.Replace(next.Data, next.Admin) d.runtime.Replace(next.Data, next.Admin)
logger.Info("config file changed", "mod", "system", "path", absolute) logger.Info("config file changed", "mod", "system", "path", absolute)

View File

@ -133,12 +133,33 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit) registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
} }
admin := runtime.Admin() 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 !usingFallback && !disableAutoMigrate {
if err = migrateAll(db); err != nil { if err = migrateAll(db); err != nil {
return nil, nil, fmt.Errorf("migrate tables: %w", err) 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 useRedis := admin != nil && admin.System != nil && admin.System.UseRedis
d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger)) d.redis = newReloadableRedis(openRedis(c.Redis, useRedis, appLogger))
useMongo := admin != nil && admin.System != nil && admin.System.UseMongo useMongo := admin != nil && admin.System != nil && admin.System.UseMongo

View File

@ -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(&current).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(&current).Error; err != nil {
return err
}
case err != nil:
return err
default:
if err = tx.Model(&current).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(&current).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(&current).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)
}

View File

@ -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)
}
}

View File

@ -18,22 +18,41 @@ import (
) )
func (r *initializationRepo) PersistConfig(context.Context) error { return r.data.persistConfig() } 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() currentData, currentAdmin := r.data.runtime.Values()
next := proto.Clone(currentAdmin).(*conf.AdminBackend) next := proto.Clone(currentAdmin).(*conf.AdminBackend)
if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil { if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil {
return err return err
} }
if next.Storage == nil {
next.Storage = currentAdmin.Storage
}
if next.Email == nil {
next.Email = currentAdmin.Email
}
next.ConfigPath = currentAdmin.ConfigPath 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 { if err := r.data.persistConfigValues(currentData, next); err != nil {
return err return err
} }
// Writing through the management API updates the same in-memory values // Writing through the management API updates the same in-memory values
// immediately; the file watcher remains the fallback for external edits. // immediately; the file watcher remains the fallback for external edits.
r.data.runtime.Replace(currentData, next) r.data.runtime.Replace(currentData, next)
if r.data.storage != nil {
r.data.storage.replace(candidateStorage)
}
return nil 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() currentData, currentAdmin := r.data.runtime.Values()
nextData := proto.Clone(currentData).(*conf.Data) nextData := proto.Clone(currentData).(*conf.Data)
nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend) 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 { if err := options.Unmarshal(adminRaw, nextAdmin); err != nil {
return err return err
} }
if nextAdmin.Storage == nil {
nextAdmin.Storage = currentAdmin.Storage
}
if nextAdmin.Email == nil {
nextAdmin.Email = currentAdmin.Email
}
nextAdmin.ConfigPath = currentAdmin.ConfigPath 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 { if err := r.data.persistConfigValues(nextData, nextAdmin); err != nil {
return err return err
} }
r.data.runtime.Replace(nextData, nextAdmin) r.data.runtime.Replace(nextData, nextAdmin)
if r.data.storage != nil {
r.data.storage.replace(candidateStorage)
}
return nil return nil
} }
func (r *initializationRepo) ReloadConfig(ctx context.Context) error { func (r *initializationRepo) ReloadConfig(ctx context.Context) error {
@ -236,6 +274,23 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database
}); err != nil { }); err != nil {
return err 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() signingKey := uuid.NewString()
if err := r.data.persistDatabaseConfig(config, signingKey); err != nil { if err := r.data.persistDatabaseConfig(config, signingKey); err != nil {
return fmt.Errorf("persist database configuration: %w", err) 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 = &conf.AdminBackend_JWT{}
} }
currentAdmin.Jwt.SigningKey = signingKey currentAdmin.Jwt.SigningKey = signingKey
currentAdmin.Storage = storageConfig
currentAdmin.Email = emailConfig
r.data.runtime.Replace(currentData, currentAdmin) r.data.runtime.Replace(currentData, currentAdmin)
activated = true activated = true
return nil return nil

View File

@ -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 // @Tags system
// @Summary 获取服务器运行状态 // @Summary 获取服务器运行状态
// @Security ApiKeyAuth // @Security ApiKeyAuth

View File

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<warning-bar <warning-bar
title="需要提前配置email配置文件为防止不必要的垃圾邮件在线体验功能不开放此功能体验。" title="请先在系统设置的邮件设置中完成 SMTP 配置。为防止不必要的垃圾邮件,在线体验不开放此功能。"
/> />
<div class="kra-form-box"> <div class="kra-form-box">
<el-form <el-form

View File

@ -193,7 +193,9 @@
<el-form-item v-if="config.admin.storage.type === 'cloudflare-r2'" label="Account ID"><el-input v-model.trim="currentObjectStorage.account_id" /></el-form-item> <el-form-item v-if="config.admin.storage.type === 'cloudflare-r2'" label="Account ID"><el-input v-model.trim="currentObjectStorage.account_id" /></el-form-item>
<el-form-item label="连接选项"><div class="flex gap-5"><el-switch v-model="currentObjectStorage.use_ssl" active-text="HTTPS" /><el-switch v-model="currentObjectStorage.force_path_style" active-text="Path Style" /></div></el-form-item> <el-form-item label="连接选项"><div class="flex gap-5"><el-switch v-model="currentObjectStorage.use_ssl" active-text="HTTPS" /><el-switch v-model="currentObjectStorage.force_path_style" active-text="Path Style" /></div></el-form-item>
</template> </template>
<p class="md:col-span-2 text-sm text-gray-500">保存后点击重载服务新的对象存储配置会立即生效</p> <div class="md:col-span-2 flex justify-end">
<el-button type="primary" :loading="storageSaving" @click="saveStorage">保存对象存储</el-button>
</div>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
@ -223,8 +225,11 @@
<el-switch v-model="config.email['is-loginauth']" active-text="LOGIN 认证" /> <el-switch v-model="config.email['is-loginauth']" active-text="LOGIN 认证" />
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="连通性测试"> <el-form-item label="配置操作" class="md:col-span-2">
<el-button :loading="testing" @click="testEmail">发送测试邮件</el-button> <div class="flex gap-2">
<el-button type="primary" :loading="emailSaving" @click="saveEmail">保存邮件配置</el-button>
<el-button :loading="testing" @click="testEmail">保存并发送测试邮件</el-button>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
@ -235,7 +240,7 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getSystemConfig, reloadSystem, setSystemConfig } from '@/api/system' import { getSystemConfig, reloadSystem, setEmailConfig, setStorageConfig, setSystemConfig } from '@/api/system'
import { emailTest } from '@/api/email' import { emailTest } from '@/api/email'
import { CreateUUID } from '@/utils/format' import { CreateUUID } from '@/utils/format'
@ -243,6 +248,8 @@
const activeKey = ref('basic') const activeKey = ref('basic')
const saving = ref(false) const saving = ref(false)
const storageSaving = ref(false)
const emailSaving = ref(false)
const testing = ref(false) const testing = ref(false)
const config = ref({ const config = ref({
admin: { admin: {
@ -256,7 +263,7 @@
storage: { type: 'local', qiniu: {}, aliyun_oss: {}, huawei_obs: {}, tencent_cos: {}, aws_s3: {}, cloudflare_r2: {}, minio: {} } storage: { type: 'local', qiniu: {}, aliyun_oss: {}, huawei_obs: {}, tencent_cos: {}, aws_s3: {}, cloudflare_r2: {}, minio: {} }
}, },
email: { email: {
to: '', from: '', host: '', secret: '******', nickname: '', port: 465, to: '', from: '', host: '', secret: '', nickname: '', port: 465,
'is-ssl': true, 'is-loginauth': false 'is-ssl': true, 'is-loginauth': false
}, },
data: { data: {
@ -352,6 +359,49 @@
config.value.admin.jwt.signingKey = CreateUUID() config.value.admin.jwt.signingKey = CreateUUID()
} }
const storageFieldLabels = {
endpoint: 'Endpoint', region: 'Region', bucket: 'Bucket',
access_key: 'Access Key', secret_key: 'Secret Key', account_id: 'Account ID'
}
const validateStorage = () => {
const storage = config.value.admin.storage
if (storage.type === 'local') return true
const target = storage.type === 'qiniu' ? storage.qiniu : currentObjectStorage.value
const required = {
qiniu: ['bucket', 'access_key', 'secret_key'],
'aliyun-oss': ['endpoint', 'bucket', 'access_key', 'secret_key'],
'huawei-obs': ['endpoint', 'bucket', 'access_key', 'secret_key'],
'tencent-cos': ['region', 'bucket', 'access_key', 'secret_key'],
'aws-s3': ['region', 'bucket', 'access_key', 'secret_key'],
'cloudflare-r2': ['bucket', 'access_key', 'secret_key'],
minio: ['endpoint', 'bucket', 'access_key', 'secret_key']
}[storage.type] || []
const missing = required.filter(key => !String(target?.[key] || '').trim())
if (storage.type === 'cloudflare-r2' && !String(target?.endpoint || target?.account_id || '').trim()) {
missing.push('account_id')
}
if (missing.length) {
ElMessage.warning(`请填写:${missing.map(key => storageFieldLabels[key]).join('、')}`)
return false
}
return true
}
const saveStorage = async () => {
if (!validateStorage()) return
storageSaving.value = true
try {
const res = await setStorageConfig(config.value.admin.storage)
if (res.code === 0) {
ElMessage.success('对象存储配置已保存并生效')
await initForm()
}
} finally {
storageSaving.value = false
}
}
const reload = async () => { const reload = async () => {
try { try {
await ElMessageBox.confirm('确定要重载服务配置吗?', '提示') await ElMessageBox.confirm('确定要重载服务配置吗?', '提示')
@ -363,9 +413,40 @@
if (res.code === 0) ElMessage.success('配置已重载') if (res.code === 0) ElMessage.success('配置已重载')
} }
const validateEmail = () => {
const email = config.value.email
const fields = [
['to', '默认收件人'], ['from', '发件人'], ['host', 'SMTP 主机'], ['secret', 'SMTP 密钥']
]
const missing = fields.filter(([key]) => !String(email[key] || '').trim()).map(([, label]) => label)
if (!email.port) missing.push('SMTP 端口')
if (missing.length) {
ElMessage.warning(`请填写:${missing.join('、')}`)
return false
}
return true
}
const saveEmail = async () => {
if (!validateEmail()) return false
emailSaving.value = true
try {
const res = await setEmailConfig(config.value.email)
if (res.code !== 0) return false
ElMessage.success('邮件配置已保存并生效')
await initForm()
return true
} finally {
emailSaving.value = false
}
}
const testEmail = async () => { const testEmail = async () => {
if (!validateEmail()) return
testing.value = true testing.value = true
try { try {
const saved = await setEmailConfig(config.value.email)
if (saved.code !== 0) return
const res = await emailTest() const res = await emailTest()
if (res.code === 0) { if (res.code === 0) {
ElMessage.success('测试邮件发送成功') ElMessage.success('测试邮件发送成功')