优化结构

This commit is contained in:
Yvan
2026-08-21 08:54:29 +08:00
parent c968f9f24e
commit 77e192b6c0
167 changed files with 3657 additions and 1528 deletions
+27 -1
View File
@@ -182,6 +182,8 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
fileAdmin := cloneAdminConfig(adminConfig)
fileAdmin.Storage = nil
fileAdmin.Email = nil
fileAdmin.Websocket = nil
fileAdmin.Mq = nil
adminValue, err := protoMap(fileAdmin)
if err != nil {
return err
@@ -194,6 +196,8 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
}
deleteYAMLMapping(&document, "admin", "storage")
deleteYAMLMapping(&document, "admin", "email")
deleteYAMLMapping(&document, "admin", "websocket")
deleteYAMLMapping(&document, "admin", "mq")
if adminConfig.System != nil {
if err = setServerHTTPPort(&document, adminConfig.System.Addr); err != nil {
return err
@@ -253,6 +257,8 @@ func (d *Data) persistDatabaseConfig(database *conf.Data_Database, signingKey st
}
deleteYAMLMapping(&document, "admin", "storage")
deleteYAMLMapping(&document, "admin", "email")
deleteYAMLMapping(&document, "admin", "websocket")
deleteYAMLMapping(&document, "admin", "mq")
return writeConfigDocument(configPath, &document)
}
@@ -271,11 +277,13 @@ func (d *Data) removeIntegrationConfigFromFile() error {
if err = yaml.Unmarshal(raw, &document); err != nil {
return err
}
if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil {
if yamlMappingValue(&document, "admin", "storage") == nil && yamlMappingValue(&document, "admin", "email") == nil && yamlMappingValue(&document, "admin", "websocket") == nil && yamlMappingValue(&document, "admin", "mq") == nil {
return nil
}
deleteYAMLMapping(&document, "admin", "storage")
deleteYAMLMapping(&document, "admin", "email")
deleteYAMLMapping(&document, "admin", "websocket")
deleteYAMLMapping(&document, "admin", "mq")
return writeConfigDocument(configPath, &document)
}
@@ -357,6 +365,8 @@ func (d *Data) reloadConfig(ctx context.Context) error {
}
legacyStorage := next.Admin.Storage
legacyEmail := next.Admin.Email
legacyWebSocket := next.Admin.Websocket
legacyMQ := next.Admin.Mq
currentAdmin := d.runtime.Admin()
if legacyStorage == nil {
if currentAdmin != nil {
@@ -366,6 +376,12 @@ func (d *Data) reloadConfig(ctx context.Context) error {
if legacyEmail == nil && currentAdmin != nil {
legacyEmail = currentAdmin.Email
}
if legacyWebSocket == nil && currentAdmin != nil {
legacyWebSocket = currentAdmin.Websocket
}
if legacyMQ == nil && currentAdmin != nil {
legacyMQ = currentAdmin.Mq
}
storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage)
if err != nil {
return fmt.Errorf("reload storage configuration: %w", err)
@@ -376,6 +392,16 @@ func (d *Data) reloadConfig(ctx context.Context) error {
return fmt.Errorf("reload email configuration: %w", err)
}
next.Admin.Email = emailConfig
websocketConfig, err := resolveWebSocketIntegrationConfig(candidateDB.WithContext(ctx), legacyWebSocket)
if err != nil {
return fmt.Errorf("reload websocket configuration: %w", err)
}
next.Admin.Websocket = websocketConfig
mqConfig, err := resolveMQIntegrationConfig(candidateDB.WithContext(ctx), legacyMQ)
if err != nil {
return fmt.Errorf("reload mq configuration: %w", err)
}
next.Admin.Mq = mqConfig
candidateStorage, err := storage.New(next.Admin)
if err != nil {
return fmt.Errorf("reload storage: %w", err)
+2
View File
@@ -58,6 +58,8 @@ func (d *Data) watchConfig() func() {
if current := d.runtime.Admin(); current != nil {
next.Admin.Storage = current.Storage
next.Admin.Email = current.Email
next.Admin.Mq = current.Mq
next.Admin.Websocket = current.Websocket
}
next.Admin.ConfigPath = absolute
d.runtime.Replace(next.Data, next.Admin)
+10
View File
@@ -207,6 +207,16 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
}
admin.Storage = storageConfig
admin.Email = emailConfig
websocketConfig, websocketErr := resolveWebSocketIntegrationConfig(db, admin.Websocket)
if websocketErr != nil {
return nil, nil, fmt.Errorf("load websocket integration configuration: %w", websocketErr)
}
admin.Websocket = websocketConfig
mqConfig, mqErr := resolveMQIntegrationConfig(db, admin.Mq)
if mqErr != nil {
return nil, nil, fmt.Errorf("load mq integration configuration: %w", mqErr)
}
admin.Mq = mqConfig
runtime.Replace(c, admin)
activeStorage, storageErr := storage.New(admin)
if storageErr != nil {
@@ -69,6 +69,12 @@ func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error {
if next.Email == nil {
next.Email = currentAdmin.Email
}
if next.Websocket == nil {
next.Websocket = currentAdmin.Websocket
}
if next.Mq == nil {
next.Mq = currentAdmin.Mq
}
next.ConfigPath = currentAdmin.ConfigPath
candidateStorage, err := storage.New(next)
if err != nil {
@@ -80,6 +86,12 @@ func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error {
if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
return err
}
if err := d.PersistWebSocketConfig(ctx, next.Websocket); err != nil {
return err
}
if err := d.persistMQIntegrationConfig(ctx, next.Mq); err != nil {
return err
}
if err := d.persistConfigValues(currentData, next); err != nil {
return err
}
@@ -108,6 +120,12 @@ func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byt
if nextAdmin.Email == nil {
nextAdmin.Email = currentAdmin.Email
}
if nextAdmin.Websocket == nil {
nextAdmin.Websocket = currentAdmin.Websocket
}
if nextAdmin.Mq == nil {
nextAdmin.Mq = currentAdmin.Mq
}
nextAdmin.ConfigPath = currentAdmin.ConfigPath
candidateStorage, err := storage.New(nextAdmin)
if err != nil {
@@ -119,6 +137,12 @@ func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byt
if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
return err
}
if err := d.PersistWebSocketConfig(ctx, nextAdmin.Websocket); err != nil {
return err
}
if err := d.persistMQIntegrationConfig(ctx, nextAdmin.Mq); err != nil {
return err
}
if err := d.persistConfigValues(nextData, nextAdmin); err != nil {
return err
}
@@ -205,6 +229,14 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig
if err != nil {
return fmt.Errorf("initialize email integration configuration: %w", err)
}
var legacyWebSocket *conf.AdminBackend_WebSocket
if currentAdmin != nil {
legacyWebSocket = currentAdmin.Websocket
}
websocketConfig, err := resolveWebSocketIntegrationConfig(candidate.WithContext(ctx), legacyWebSocket)
if err != nil {
return fmt.Errorf("initialize websocket integration configuration: %w", err)
}
signingKey := uuid.NewString()
if err := d.persistDatabaseConfig(config, signingKey); err != nil {
return fmt.Errorf("persist database configuration: %w", err)
@@ -219,7 +251,13 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig
}
currentAdmin.Jwt.SigningKey = signingKey
currentAdmin.Storage = storageConfig
currentAdmin.Websocket = websocketConfig
currentAdmin.Email = emailConfig
mqConfig, err := resolveMQIntegrationConfig(candidate.WithContext(ctx), currentAdmin.Mq)
if err != nil {
return fmt.Errorf("initialize mq integration configuration: %w", err)
}
currentAdmin.Mq = mqConfig
d.runtime.Replace(currentData, currentAdmin)
activated = true
return nil
@@ -19,6 +19,7 @@ const (
integrationKindStorage = "storage"
integrationKindEmail = "email"
integrationKindPayment = "payment"
integrationKindMQ = "mq"
)
// integrationConfigPO stores credentials and provider-specific options for
@@ -34,6 +35,88 @@ type integrationConfigPO struct {
Config string `gorm:"type:text;not null"`
}
func defaultMQIntegrationConfig() *conf.AdminBackend_MQ {
return &conf.AdminBackend_MQ{CleanSession: true, KeepAlive: 30, ConnectTimeout: 10}
}
func saveMQIntegrationConfig(db *gorm.DB, config *conf.AdminBackend_MQ) error {
if config == nil {
config = defaultMQIntegrationConfig()
}
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(config)
if err != nil {
return fmt.Errorf("encode emqx integration configuration: %w", err)
}
enabled := config.Enabled && strings.TrimSpace(config.Broker) != ""
clean := db.Session(&gorm.Session{NewDB: true})
var row integrationConfigPO
err = clean.Where("kind = ? AND provider = ?", integrationKindMQ, "emqx").First(&row).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return clean.Create(&integrationConfigPO{Kind: integrationKindMQ, Provider: "emqx", Enabled: enabled, Config: string(raw)}).Error
case err != nil:
return err
default:
return clean.Model(&row).Updates(map[string]any{"enabled": enabled, "config": string(raw)}).Error
}
}
func loadMQIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_MQ, bool, error) {
var row integrationConfigPO
err := db.Session(&gorm.Session{NewDB: true}).Where("kind = ? AND provider = ?", integrationKindMQ, "emqx").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 emqx integration configuration")
}
config := defaultMQIntegrationConfig()
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), config); err != nil {
return nil, false, fmt.Errorf("decode emqx integration configuration: %w", err)
}
config.Enabled = row.Enabled
return config, true, nil
}
func resolveMQIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_MQ) (*conf.AdminBackend_MQ, error) {
clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
if legacy == nil {
return defaultMQIntegrationConfig(), nil
}
return proto.Clone(legacy).(*conf.AdminBackend_MQ), nil
}
loaded, found, err := loadMQIntegrationConfig(clean)
if err != nil {
return nil, err
}
if found {
return loaded, nil
}
if legacy == nil {
legacy = defaultMQIntegrationConfig()
}
if err = saveMQIntegrationConfig(clean, legacy); err != nil {
return nil, err
}
loaded, _, err = loadMQIntegrationConfig(clean)
return loaded, err
}
func (d *Data) persistMQIntegrationConfig(ctx context.Context, config *conf.AdminBackend_MQ) 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 saveMQIntegrationConfig(db, config)
}
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }
var storageProviderNames = []string{
@@ -123,6 +123,25 @@ func TestStorageIntegrationConfigRoundTrip(t *testing.T) {
}
}
func TestMQIntegrationConfigRoundTrip(t *testing.T) {
db := openIntegrationConfigTestDB(t)
legacy := &conf.AdminBackend_MQ{Enabled: true, Broker: "mqtt://emqx.example.com:1883", ClientId: "system", Username: "app", Password: "secret", KeepAlive: 45, CleanSession: true, ConnectTimeout: 12}
if err := saveMQIntegrationConfig(db, legacy); err != nil {
t.Fatal(err)
}
loaded, found, err := loadMQIntegrationConfig(db)
if err != nil || !found {
t.Fatalf("found=%v err=%v", found, err)
}
if loaded.Broker != legacy.Broker || loaded.Password != legacy.Password || loaded.KeepAlive != 45 {
t.Fatalf("loaded mq = %#v", loaded)
}
loaded, err = resolveMQIntegrationConfig(db, &conf.AdminBackend_MQ{Broker: "must-not-replace"})
if err != nil || loaded.Broker != legacy.Broker {
t.Fatalf("database mq was replaced: %#v err=%v", loaded, err)
}
}
func TestResolveStorageIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
db := openIntegrationConfigTestDB(t)
legacy := &conf.AdminBackend_Storage{
@@ -186,7 +205,7 @@ func TestResolveEmailIntegrationConfigMigratesLegacyOnlyOnce(t *testing.T) {
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")
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 mq:\n enabled: true\n broker: tcp://localhost:1883\n password: legacy-mq-secret\n extension_key: retained\n")
if err := os.WriteFile(path, input, 0o600); err != nil {
t.Fatal(err)
}
@@ -199,6 +218,7 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
Qiniu: &conf.AdminBackend_Qiniu{SecretKey: "database-only-secret"},
},
Email: &conf.AdminBackend_Email{Host: "smtp.database.example.com", Secret: "database-only-email-secret"},
Mq: &conf.AdminBackend_MQ{Enabled: true, Broker: "tcp://emqx:1883", Password: "database-only-mq-secret"},
}
if err := d.persistConfigValues(&conf.Data{}, admin); err != nil {
t.Fatal(err)
@@ -222,6 +242,9 @@ func TestPersistConfigValuesRemovesStorageFromYAML(t *testing.T) {
if _, exists := adminValue["email"]; exists {
t.Fatalf("email remained in YAML: %s", raw)
}
if _, exists := adminValue["mq"]; exists {
t.Fatalf("mq remained in YAML: %s", raw)
}
if adminValue["extension_key"] != "retained" {
t.Fatalf("extension key was not retained: %#v", adminValue)
}
@@ -8,7 +8,7 @@ import (
"kra/app/system/internal/biz"
"kra/app/system/internal/conf"
"kra/app/system/internal/security/adminauth"
"kra/app/system/internal/security"
jwt "github.com/golang-jwt/jwt/v5"
)
@@ -95,7 +95,7 @@ func (i *tokenIssuer) IssueToken(user *biz.User, authorityID uint, mustChangePas
if user.PasswordUpdatedAt != nil {
passwordVersion = user.PasswordUpdatedAt.UnixNano()
}
token, claims, err := adminauth.Generate(settings.SigningKey, settings.Issuer, expires, settings.Buffer, user.ID, authorityID, user.UUID, user.Username, user.NickName, mustChangePassword, passwordVersion)
token, claims, err := security.Generate(settings.SigningKey, settings.Issuer, expires, settings.Buffer, user.ID, authorityID, user.UUID, user.Username, user.NickName, mustChangePassword, passwordVersion)
if err != nil {
return nil, err
}
@@ -107,7 +107,7 @@ func (i *tokenIssuer) ReissueToken(source *biz.AuthClaims, authorityID uint) (*b
return nil, errors.New("nil JWT claims")
}
settings := i.settings.JWTSettings()
claims := &adminauth.Claims{
claims := &security.Claims{
UUID: source.UUID, ID: source.ID, Username: source.Username, NickName: source.NickName,
AuthorityID: authorityID, BufferTime: int64(source.BufferTime / time.Second), UserType: source.UserType,
MustChangePwd: source.MustChangePwd, PasswordVersion: source.PasswordVersion,
@@ -116,7 +116,7 @@ func (i *tokenIssuer) ReissueToken(source *biz.AuthClaims, authorityID uint) (*b
IssuedAt: jwt.NewNumericDate(source.IssuedAt), NotBefore: jwt.NewNumericDate(source.NotBefore), ExpiresAt: jwt.NewNumericDate(source.ExpiresAt),
},
}
token, err := adminauth.Sign(settings.SigningKey, claims)
token, err := security.Sign(settings.SigningKey, claims)
if err != nil {
return nil, err
}
@@ -124,16 +124,16 @@ func (i *tokenIssuer) ReissueToken(source *biz.AuthClaims, authorityID uint) (*b
}
func (i *tokenIssuer) ParseToken(token string) (*biz.AuthClaims, error) {
claims, err := adminauth.Parse(token, i.settings.JWTSettings().SigningKey)
claims, err := security.Parse(token, i.settings.JWTSettings().SigningKey)
if err != nil {
switch {
case errors.Is(err, adminauth.ErrTokenExpired):
case errors.Is(err, security.ErrTokenExpired):
return nil, biz.ErrTokenExpired
case errors.Is(err, adminauth.ErrTokenMalformed):
case errors.Is(err, security.ErrTokenMalformed):
return nil, biz.ErrTokenMalformed
case errors.Is(err, adminauth.ErrTokenSignatureInvalid):
case errors.Is(err, security.ErrTokenSignatureInvalid):
return nil, biz.ErrTokenSignatureInvalid
case errors.Is(err, adminauth.ErrTokenNotValidYet):
case errors.Is(err, security.ErrTokenNotValidYet):
return nil, biz.ErrTokenNotValidYet
default:
return nil, biz.ErrTokenInvalid
@@ -0,0 +1,96 @@
package data
import (
"context"
"encoding/json"
"errors"
"fmt"
"kra/app/system/internal/conf"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"gorm.io/gorm"
)
const integrationKindWebSocket = "websocket"
// saveWebSocketIntegrationConfig persists the Melody settings in the shared
// integration table. It intentionally lives separately from storage/payment
// persistence so adding another transport does not expand their API surface.
func saveWebSocketIntegrationConfig(db *gorm.DB, config *conf.AdminBackend_WebSocket) error {
if config == nil {
config = &conf.AdminBackend_WebSocket{}
}
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(config)
if err != nil {
return fmt.Errorf("encode websocket integration configuration: %w", err)
}
clean := db.Session(&gorm.Session{NewDB: true})
var current integrationConfigPO
err = clean.Where("kind = ? AND provider = ?", integrationKindWebSocket, "melody").First(&current).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return clean.Create(&integrationConfigPO{Kind: integrationKindWebSocket, Provider: "melody", Enabled: config.Enabled, Config: string(raw)}).Error
case err != nil:
return err
default:
return clean.Model(&current).Updates(map[string]any{"enabled": config.Enabled, "config": string(raw)}).Error
}
}
func loadWebSocketIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_WebSocket, bool, error) {
var row integrationConfigPO
err := db.Session(&gorm.Session{NewDB: true}).Where("kind = ? AND provider = ?", integrationKindWebSocket, "melody").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 websocket integration configuration")
}
config := &conf.AdminBackend_WebSocket{}
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), config); err != nil {
return nil, false, fmt.Errorf("decode websocket integration configuration: %w", err)
}
config.Enabled = row.Enabled
return config, true, nil
}
func resolveWebSocketIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_WebSocket) (*conf.AdminBackend_WebSocket, error) {
clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
if legacy == nil {
return &conf.AdminBackend_WebSocket{}, nil
}
return proto.Clone(legacy).(*conf.AdminBackend_WebSocket), nil
}
config, found, err := loadWebSocketIntegrationConfig(clean)
if err != nil {
return nil, err
}
if found {
return config, nil
}
if legacy == nil {
legacy = &conf.AdminBackend_WebSocket{}
}
if err = saveWebSocketIntegrationConfig(clean, legacy); err != nil {
return nil, err
}
config, _, err = loadWebSocketIntegrationConfig(clean)
return config, err
}
func (d *Data) PersistWebSocketConfig(ctx context.Context, config *conf.AdminBackend_WebSocket) 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 saveWebSocketIntegrationConfig(db, config)
}
@@ -0,0 +1,50 @@
package data
import (
"testing"
"time"
"kra/app/system/internal/conf"
"google.golang.org/protobuf/types/known/durationpb"
)
func TestWebSocketIntegrationConfigRoundTrip(t *testing.T) {
db := openIntegrationConfigTestDB(t)
want := &conf.AdminBackend_WebSocket{
Enabled: true, Path: "/events", AllowOrigins: []string{"https://admin.example.com"},
MaxMessageSize: 4096, WriteWait: durationpb.New(3 * time.Second),
PongWait: durationpb.New(20 * time.Second), PingPeriod: durationpb.New(15 * time.Second),
MessageBufferSize: 32, ConcurrentMessageHandling: true,
}
if err := saveWebSocketIntegrationConfig(db, want); err != nil {
t.Fatal(err)
}
got, found, err := loadWebSocketIntegrationConfig(db)
if err != nil {
t.Fatal(err)
}
if !found {
t.Fatal("websocket integration configuration was not found")
}
if !got.Enabled || got.Path != want.Path || got.MaxMessageSize != want.MaxMessageSize || got.MessageBufferSize != want.MessageBufferSize {
t.Fatalf("loaded websocket config = %#v", got)
}
if len(got.AllowOrigins) != 1 || got.AllowOrigins[0] != want.AllowOrigins[0] {
t.Fatalf("allow origins = %v", got.AllowOrigins)
}
}
func TestResolveWebSocketIntegrationConfigPrefersDatabase(t *testing.T) {
db := openIntegrationConfigTestDB(t)
if err := saveWebSocketIntegrationConfig(db, &conf.AdminBackend_WebSocket{Enabled: true, Path: "/database"}); err != nil {
t.Fatal(err)
}
got, err := resolveWebSocketIntegrationConfig(db, &conf.AdminBackend_WebSocket{Path: "/legacy"})
if err != nil {
t.Fatal(err)
}
if got.Path != "/database" || !got.Enabled {
t.Fatalf("resolved websocket config = %#v", got)
}
}