优化
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func protoMap(message proto.Message) (map[string]any, error) {
|
||||
@@ -260,9 +261,18 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
return fmt.Errorf("data.database and admin configuration are required")
|
||||
}
|
||||
next.Admin.ConfigPath = configPath
|
||||
candidateDB, err := openDatabase(next.Data.Database, false, "", d.logger())
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload database: %w", err)
|
||||
databaseReady := databaseConnectionConfigured(next.Data.Database)
|
||||
var candidateDB *gorm.DB
|
||||
if databaseReady {
|
||||
candidateDB, err = openDatabase(next.Data.Database, false, "", d.logger())
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload database: %w", err)
|
||||
}
|
||||
} else {
|
||||
candidateDB, err = openFallbackDatabase(d.logger())
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload bootstrap database: %w", err)
|
||||
}
|
||||
}
|
||||
closeCandidate := true
|
||||
defer func() {
|
||||
@@ -272,12 +282,14 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
}()
|
||||
if sqlDB, dbErr := candidateDB.DB(); dbErr != nil {
|
||||
return dbErr
|
||||
} else if err = sqlDB.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("reload database: %w", err)
|
||||
if databaseReady {
|
||||
if sqlDB, dbErr := candidateDB.DB(); dbErr != nil {
|
||||
return dbErr
|
||||
} else if err = sqlDB.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("reload database: %w", err)
|
||||
}
|
||||
}
|
||||
if next.Admin.System == nil || !next.Admin.System.DisableAutoMigrate {
|
||||
if databaseReady && (next.Admin.System == nil || !next.Admin.System.DisableAutoMigrate) {
|
||||
if err = migrateAll(candidateDB.WithContext(ctx)); err != nil {
|
||||
return fmt.Errorf("reload database migrations: %w", err)
|
||||
}
|
||||
@@ -305,6 +317,7 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
}
|
||||
|
||||
d.gormDB.replace(candidateDB, d.enqueueDataScopeAudit)
|
||||
d.databaseReady.Store(databaseReady)
|
||||
for _, item := range candidateDBList {
|
||||
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
|
||||
}
|
||||
|
||||
+33
-17
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/wire"
|
||||
@@ -16,17 +17,18 @@ import (
|
||||
var ProviderSet = wire.NewSet(NewData, NewRuntimeSettings, NewTokenIssuer, NewUserRepo, NewInitializationRepo, NewAuthorityAccessRepo, NewAPIRepo, NewPermissionRepo, NewMenuRepo, NewDepartmentRepo, NewPositionRepo, NewDictionaryRepo, NewParameterRepo, NewAPITokenRepo, NewSecurityRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewAuditRecorderRepo, NewLogFileRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewCache, NewFileStorage)
|
||||
|
||||
type Data struct {
|
||||
initMu sync.Mutex
|
||||
configMu sync.Mutex
|
||||
gormDB *reloadableDB
|
||||
redis *reloadableRedis
|
||||
mongo *reloadableMongo
|
||||
runtime *conf.Runtime
|
||||
storage *reloadableStorage
|
||||
dbListMu sync.RWMutex
|
||||
dbList map[string]*gorm.DB
|
||||
appLogger *slog.Logger
|
||||
auditLog *dataScopeAuditWriter
|
||||
initMu sync.Mutex
|
||||
configMu sync.Mutex
|
||||
databaseReady atomic.Bool
|
||||
gormDB *reloadableDB
|
||||
redis *reloadableRedis
|
||||
mongo *reloadableMongo
|
||||
runtime *conf.Runtime
|
||||
storage *reloadableStorage
|
||||
dbListMu sync.RWMutex
|
||||
dbList map[string]*gorm.DB
|
||||
appLogger *slog.Logger
|
||||
auditLog *dataScopeAuditWriter
|
||||
}
|
||||
|
||||
func (d *Data) logger() *slog.Logger {
|
||||
@@ -90,22 +92,35 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
|
||||
appLogger = slog.Default()
|
||||
}
|
||||
c := runtime.Data()
|
||||
if c == nil || c.Database == nil {
|
||||
return nil, nil, fmt.Errorf("database configuration is required")
|
||||
if c == nil {
|
||||
c = &conf.Data{}
|
||||
}
|
||||
if c.Database == nil {
|
||||
// An empty database block is the Kratos equivalent of GVA's empty
|
||||
// Dbname: the service starts on the bootstrap database so /init/checkdb
|
||||
// and /init/initdb remain available.
|
||||
c.Database = &conf.Data_Database{}
|
||||
}
|
||||
d := &Data{runtime: runtime, appLogger: appLogger}
|
||||
db, err := openDatabase(c.Database, false, "", appLogger)
|
||||
usingFallback := false
|
||||
if err != nil {
|
||||
usingFallback := !databaseConnectionConfigured(c.Database)
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
if !usingFallback {
|
||||
db, err = openDatabase(c.Database, false, "", appLogger)
|
||||
}
|
||||
if usingFallback || err != nil {
|
||||
// The initialization endpoint must remain available when the configured
|
||||
// target database has not been created yet.
|
||||
appLogger.Warn("configured database unavailable before initialization", "mod", "system", "error", err)
|
||||
if err != nil {
|
||||
appLogger.Warn("configured database unavailable before initialization", "mod", "system", "error", err)
|
||||
}
|
||||
db, err = openFallbackDatabase(appLogger)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open bootstrap database: %w", err)
|
||||
}
|
||||
usingFallback = true
|
||||
}
|
||||
d.databaseReady.Store(!usingFallback)
|
||||
d.gormDB = newReloadableDB(db, d.enqueueDataScopeAudit)
|
||||
d.auditLog = newDataScopeAuditWriter(d, appLogger)
|
||||
d.dbList, err = openDatabaseList(c.DatabaseList, appLogger)
|
||||
@@ -183,4 +198,5 @@ func openRedis(config *conf.Data_Redis, enabled bool, appLogger ...*slog.Logger)
|
||||
func (d *Data) activateDatabase(db *gorm.DB, config *conf.Data_Database) {
|
||||
d.gormDB.replace(db, d.enqueueDataScopeAudit)
|
||||
d.runtime.UpdateDatabase(config)
|
||||
d.databaseReady.Store(true)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,20 @@ import (
|
||||
|
||||
var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`)
|
||||
|
||||
// databaseConnectionConfigured mirrors the initialization contract of the
|
||||
// administration backend: an explicitly selected database (or a standalone
|
||||
// DSN) means a real database connection is expected to exist. The internal
|
||||
// bootstrap database is deliberately excluded from this state.
|
||||
func databaseConnectionConfigured(c *conf.Data_Database) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(c.Name) != "" {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(c.Source) != "" && strings.TrimSpace(c.Host) == "" && strings.TrimSpace(c.Path) == ""
|
||||
}
|
||||
|
||||
func normalizedDriver(driver string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(driver)) {
|
||||
case "postgres", "postgresql", "pgsql":
|
||||
|
||||
@@ -79,13 +79,6 @@ func (r *emailRepo) Send(ctx context.Context, to []string, subject, body string)
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
if !config.IsSsl {
|
||||
if supported, _ := client.Extension("STARTTLS"); supported {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: config.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
var auth smtp.Auth
|
||||
if config.IsLoginAuth {
|
||||
auth = &loginAuth{username: config.From, password: config.Secret}
|
||||
|
||||
@@ -14,13 +14,13 @@ type errorRecordPO struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
Form string `gorm:"type:text"`
|
||||
Info string `gorm:"type:text"`
|
||||
Form *string `gorm:"type:text"`
|
||||
Info *string `gorm:"type:text"`
|
||||
Level string
|
||||
RequestID string `gorm:"index"`
|
||||
TraceID string `gorm:"index"`
|
||||
Solution string `gorm:"type:text"`
|
||||
Status string `gorm:"default:未处理"`
|
||||
RequestID string `gorm:"index"`
|
||||
TraceID string `gorm:"index"`
|
||||
Solution *string `gorm:"type:text"`
|
||||
Status string `gorm:"default:未处理"`
|
||||
}
|
||||
|
||||
func (errorRecordPO) TableName() string { return "sys_error" }
|
||||
@@ -29,23 +29,39 @@ func errorFromPO(v errorRecordPO) *biz.ErrorRecord {
|
||||
return &biz.ErrorRecord{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}
|
||||
}
|
||||
func (r *auditRecorderRepo) CreateError(ctx context.Context, v *biz.ErrorRecord) error {
|
||||
if !r.data.databaseReady.Load() {
|
||||
// GVA silently ignores error records before GVA_DB is initialized.
|
||||
return nil
|
||||
}
|
||||
if v.Status == "" {
|
||||
v.Status = "未处理"
|
||||
}
|
||||
return r.data.gormDB.WithContext(ctx).Create(&errorRecordPO{Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}).Error
|
||||
return r.data.gormDB.WithContext(ctx).Create(&errorRecordPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Form: v.Form, Info: v.Info, Level: v.Level, RequestID: v.RequestID, TraceID: v.TraceID, Solution: v.Solution, Status: v.Status}).Error
|
||||
}
|
||||
func (r *auditQueryRepo) UpdateError(ctx context.Context, v *biz.ErrorRecord) error {
|
||||
updates := make(map[string]any, 5)
|
||||
if v.Form != "" {
|
||||
updates := make(map[string]any, 9)
|
||||
if v.ID != 0 {
|
||||
updates["id"] = v.ID
|
||||
}
|
||||
if !v.CreatedAt.IsZero() {
|
||||
updates["created_at"] = v.CreatedAt
|
||||
}
|
||||
if v.Form != nil {
|
||||
updates["form"] = v.Form
|
||||
}
|
||||
if v.Info != "" {
|
||||
if v.Info != nil {
|
||||
updates["info"] = v.Info
|
||||
}
|
||||
if v.Level != "" {
|
||||
updates["level"] = v.Level
|
||||
}
|
||||
if v.Solution != "" {
|
||||
if v.RequestID != "" {
|
||||
updates["request_id"] = v.RequestID
|
||||
}
|
||||
if v.TraceID != "" {
|
||||
updates["trace_id"] = v.TraceID
|
||||
}
|
||||
if v.Solution != nil {
|
||||
updates["solution"] = v.Solution
|
||||
}
|
||||
if v.Status != "" {
|
||||
@@ -69,11 +85,11 @@ func (r *auditQueryRepo) ListErrors(ctx context.Context, page, size int, q *biz.
|
||||
if len(q.CreatedAtRange) == 2 {
|
||||
db = db.Where("created_at BETWEEN ? AND ?", q.CreatedAtRange[0], q.CreatedAtRange[1])
|
||||
}
|
||||
if q.Form != "" {
|
||||
db = db.Where("form = ?", q.Form)
|
||||
if q.Form != nil && *q.Form != "" {
|
||||
db = db.Where("form = ?", *q.Form)
|
||||
}
|
||||
if q.Info != "" {
|
||||
db = db.Where("info LIKE ?", "%"+q.Info+"%")
|
||||
if q.Info != nil && *q.Info != "" {
|
||||
db = db.Where("info LIKE ?", "%"+*q.Info+"%")
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
|
||||
+2
-10
@@ -86,11 +86,7 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
|
||||
if resetIDs {
|
||||
id = 0
|
||||
}
|
||||
templateID := x.TemplateID
|
||||
if templateID == "" {
|
||||
templateID = v.TemplateID
|
||||
}
|
||||
conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, From: x.From, Column: x.Column, Operator: x.Operator})
|
||||
conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator})
|
||||
}
|
||||
if len(conditions) > 0 {
|
||||
create := tx
|
||||
@@ -109,11 +105,7 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
|
||||
if resetIDs {
|
||||
id = 0
|
||||
}
|
||||
templateID := x.TemplateID
|
||||
if templateID == "" {
|
||||
templateID = v.TemplateID
|
||||
}
|
||||
joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, Join: x.Join, Table: x.Table, On: x.On})
|
||||
joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, Join: x.Join, Table: x.Table, On: x.On})
|
||||
}
|
||||
if len(joins) > 0 {
|
||||
create := tx
|
||||
|
||||
@@ -23,6 +23,11 @@ type loginLogPO struct {
|
||||
func (loginLogPO) TableName() string { return "sys_login_logs" }
|
||||
|
||||
func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *biz.LoginLog) error {
|
||||
if !r.data.databaseReady.Load() {
|
||||
// The login endpoint remains reachable before database initialization;
|
||||
// GVA skips this audit write while GVA_DB is nil.
|
||||
return nil
|
||||
}
|
||||
return r.data.gormDB.WithContext(ctx).Create(&loginLogPO{Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID}).Error
|
||||
}
|
||||
func loginFromPO(v loginLogPO) *biz.LoginLog {
|
||||
|
||||
+14
-14
@@ -52,6 +52,10 @@ func securityToPO(v *biz.SecurityConfig) securityConfigPO {
|
||||
return securityConfigPO{ID: 1, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, CaptchaOpen: v.CaptchaOpen, CaptchaTimeout: v.CaptchaTimeout, KeyLong: v.KeyLong, ImgWidth: v.ImgWidth, ImgHeight: v.ImgHeight, PwdMinLength: v.PwdMinLength, PwdRequireUpper: v.PwdRequireUpper, PwdRequireLower: v.PwdRequireLower, PwdRequireDigit: v.PwdRequireDigit, PwdRequireSpecial: v.PwdRequireSpecial, LimitEnable: v.LimitEnable, LimitWindow: v.LimitWindow, LimitCount: v.LimitCount, LockEnable: v.LockEnable, LockThreshold: v.LockThreshold, LockDuration: v.LockDuration, PwdExpireEnable: v.PwdExpireEnable, PwdExpireDays: v.PwdExpireDays, ForceNewUserChangePassword: v.ForceNewUserChangePassword}
|
||||
}
|
||||
func (r *securityRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig, error) {
|
||||
if !r.data.databaseReady.Load() {
|
||||
po := defaultSecurityConfig()
|
||||
return securityFromPO(po), errors.New("数据库未初始化")
|
||||
}
|
||||
var po securityConfigPO
|
||||
err := r.data.gormDB.WithContext(ctx).First(&po, 1).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -64,20 +68,16 @@ func (r *securityRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig,
|
||||
return securityFromPO(po), nil
|
||||
}
|
||||
func (r *securityRepo) SaveSecurityConfig(ctx context.Context, v *biz.SecurityConfig) error {
|
||||
previous, err := r.SecurityConfig(ctx)
|
||||
if err != nil {
|
||||
po := securityToPO(v)
|
||||
if err := r.data.gormDB.WithContext(ctx).Save(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
po := securityToPO(v)
|
||||
po.CreatedAt, po.UpdatedAt = previous.CreatedAt, previous.UpdatedAt
|
||||
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(&po).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if v.PwdExpireEnable && !previous.PwdExpireEnable {
|
||||
return tx.Model(&userPO{}).Where("password_updated_at IS NULL").Update("password_updated_at", time.Now()).Error
|
||||
}
|
||||
v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
|
||||
return nil
|
||||
})
|
||||
v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *securityRepo) BackfillPasswordUpdatedAt(ctx context.Context, at time.Time) error {
|
||||
return r.data.gormDB.WithContext(ctx).Model(&userPO{}).
|
||||
Where("password_updated_at IS NULL").
|
||||
Update("password_updated_at", at).Error
|
||||
}
|
||||
|
||||
@@ -56,15 +56,7 @@ func (r *initializationRepo) ReloadConfig(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (r *initializationRepo) IsInitialized(ctx context.Context) (bool, error) {
|
||||
db := r.data.gormDB.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&userPO{}) {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&userPO{}).Where("username = ?", "admin").Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
return r.data.databaseReady.Load(), nil
|
||||
}
|
||||
|
||||
func (r *initializationRepo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"kra/internal/conf"
|
||||
)
|
||||
|
||||
func TestDatabaseConnectionConfigured(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *conf.Data_Database
|
||||
want bool
|
||||
}{
|
||||
{name: "missing config", config: nil, want: false},
|
||||
{name: "empty database", config: &conf.Data_Database{Driver: "mysql"}, want: false},
|
||||
{name: "named database", config: &conf.Data_Database{Driver: "mysql", Name: "kra"}, want: true},
|
||||
{name: "standalone DSN", config: &conf.Data_Database{Driver: "sqlite", Source: "file:kra.db"}, want: true},
|
||||
{name: "generated DSN still requires a name", config: &conf.Data_Database{Driver: "mysql", Source: "root@tcp(localhost)/", Host: "localhost"}, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := databaseConnectionConfigured(test.config); got != test.want {
|
||||
t.Fatalf("databaseConnectionConfigured() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
|
||||
data := &Data{}
|
||||
repo := &initializationRepo{data: data}
|
||||
|
||||
initialized, err := repo.IsInitialized(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInitialized() error = %v", err)
|
||||
}
|
||||
if initialized {
|
||||
t.Fatal("bootstrap state must require initialization")
|
||||
}
|
||||
|
||||
data.databaseReady.Store(true)
|
||||
initialized, err = repo.IsInitialized(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInitialized() error = %v", err)
|
||||
}
|
||||
if !initialized {
|
||||
t.Fatal("an active configured database must not require initialization")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user