This commit is contained in:
2026-08-16 22:38:44 +08:00
parent 1945d34894
commit 560ae46b82
19 changed files with 345 additions and 112 deletions
+21 -8
View File
@@ -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
View File
@@ -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)
}
+14
View File
@@ -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":
-7
View File
@@ -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}
+31 -15
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
}
+1 -9
View File
@@ -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 {
+51
View File
@@ -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")
}
}