diff --git a/internal/biz/audit.go b/internal/biz/audit.go index cf2eb65..be37bde 100644 --- a/internal/biz/audit.go +++ b/internal/biz/audit.go @@ -81,10 +81,13 @@ func NewLogViewerUsecase(repo LogFileRepo) *LogViewerUsecase { } type ErrorRecord struct { - ID uint - CreatedAt, UpdatedAt time.Time - Form, Info, Level, RequestID, TraceID, Solution, Status string - CreatedAtRange []time.Time + ID uint + CreatedAt, UpdatedAt time.Time + Form, Info, Solution *string + Level string + RequestID, TraceID string + Status string + CreatedAtRange []time.Time } type AuditRecordRepo interface { diff --git a/internal/biz/media_upload.go b/internal/biz/media_upload.go index 4e2087e..d495c6b 100644 --- a/internal/biz/media_upload.go +++ b/internal/biz/media_upload.go @@ -173,17 +173,13 @@ func (uc *MediaUsecase) CleanupStale(ctx context.Context, ttlHours int) error { if ttlHours <= 0 { ttlHours = 24 } - ids, err := uc.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour)) - if err != nil { - return err - } + // The reference cleanup job is best-effort: a stale-session query or an + // individual storage/database cleanup failure is not propagated to the + // scheduler. Keep the endpoint-independent background behavior compatible. + ids, _ := uc.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour)) for _, id := range ids { - if err = uc.DeleteUploadData(ctx, id); err != nil { - return err - } - if err = uc.files.DeletePrefix(ctx, uc.chunkPrefix(id)); err != nil { - return err - } + _ = uc.DeleteUploadData(ctx, id) + _ = uc.files.DeletePrefix(ctx, uc.chunkPrefix(id)) } return nil } diff --git a/internal/biz/security.go b/internal/biz/security.go index c1c0bb1..6b216ce 100644 --- a/internal/biz/security.go +++ b/internal/biz/security.go @@ -40,6 +40,7 @@ type SecurityConfig struct { type SecurityRepo interface { SecurityConfig(context.Context) (*SecurityConfig, error) SaveSecurityConfig(context.Context, *SecurityConfig) error + BackfillPasswordUpdatedAt(context.Context, time.Time) error } type SecurityUsecase struct { @@ -63,6 +64,13 @@ func NewSecurityUsecase(repo SecurityRepo, cache Cache, settings RuntimeSettings } func (uc *SecurityUsecase) UpdateSecurity(ctx context.Context, value *SecurityConfig) error { + previous, err := uc.repo.SecurityConfig(ctx) + if err != nil { + return err + } + value.ID = previous.ID + value.CreatedAt = previous.CreatedAt + value.UpdatedAt = previous.UpdatedAt if err := uc.repo.SaveSecurityConfig(ctx, value); err != nil { return err } @@ -70,6 +78,14 @@ func (uc *SecurityUsecase) UpdateSecurity(ctx context.Context, value *SecurityCo copy := *value uc.cachedConfig = © uc.mu.Unlock() + // Keep the same observable ordering as the administration backend: + // persist and activate the new configuration first, then backfill legacy + // users when password expiration changes from disabled to enabled. A + // backfill failure is returned to the caller without rolling back the + // already effective configuration. + if value.PwdExpireEnable && !previous.PwdExpireEnable { + return uc.repo.BackfillPasswordUpdatedAt(ctx, time.Now()) + } return nil } @@ -83,6 +99,13 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error) uc.mu.RUnlock() value, err := uc.repo.SecurityConfig(ctx) if err != nil { + // GVA returns its default value together with the database-not-ready + // error. Callers such as login/runtime policy consumers intentionally + // ignore the error and continue with that default, while the HTTP + // settings endpoint still reports the failure. + if value != nil { + return value, err + } return nil, err } uc.mu.Lock() diff --git a/internal/biz/security_test.go b/internal/biz/security_test.go new file mode 100644 index 0000000..1f592df --- /dev/null +++ b/internal/biz/security_test.go @@ -0,0 +1,83 @@ +package biz + +import ( + "context" + "errors" + "testing" + "time" +) + +type securityUpdateRepo struct { + current *SecurityConfig + backfillErr error + callOrder []string + backfillAt time.Time + persistedCopy *SecurityConfig +} + +func (r *securityUpdateRepo) SecurityConfig(context.Context) (*SecurityConfig, error) { + r.callOrder = append(r.callOrder, "get") + copy := *r.current + return ©, nil +} + +func (r *securityUpdateRepo) SaveSecurityConfig(_ context.Context, value *SecurityConfig) error { + r.callOrder = append(r.callOrder, "save") + value.UpdatedAt = value.UpdatedAt.Add(time.Second) + copy := *value + r.persistedCopy = © + return nil +} + +func (r *securityUpdateRepo) BackfillPasswordUpdatedAt(_ context.Context, at time.Time) error { + r.callOrder = append(r.callOrder, "backfill") + r.backfillAt = at + return r.backfillErr +} + +func TestUpdateSecurityKeepsSavedConfigWhenPasswordBackfillFails(t *testing.T) { + createdAt := time.Date(2026, time.August, 16, 12, 0, 0, 0, time.Local) + backfillErr := errors.New("backfill failed") + repo := &securityUpdateRepo{ + current: &SecurityConfig{ID: 1, CreatedAt: createdAt, UpdatedAt: createdAt, PwdExpireEnable: false}, + backfillErr: backfillErr, + } + uc := NewSecurityUsecase(repo, nil, nil, nil) + next := &SecurityConfig{ID: 99, PwdExpireEnable: true, PwdExpireDays: 30} + + err := uc.UpdateSecurity(context.Background(), next) + if !errors.Is(err, backfillErr) { + t.Fatalf("UpdateSecurity() error = %v, want %v", err, backfillErr) + } + if got := repo.callOrder; len(got) != 3 || got[0] != "get" || got[1] != "save" || got[2] != "backfill" { + t.Fatalf("call order = %v, want [get save backfill]", got) + } + if repo.persistedCopy == nil || repo.persistedCopy.ID != 1 || !repo.persistedCopy.PwdExpireEnable { + t.Fatalf("persisted config = %+v", repo.persistedCopy) + } + if repo.backfillAt.IsZero() { + t.Fatal("password timestamp backfill was not attempted") + } + cached, currentErr := uc.Current(context.Background()) + if currentErr != nil { + t.Fatalf("Current() error = %v", currentErr) + } + if !cached.PwdExpireEnable || cached.ID != 1 { + t.Fatalf("effective config = %+v, want newly saved config", cached) + } + if got := repo.callOrder; len(got) != 3 { + t.Fatalf("Current() unexpectedly reloaded repository: %v", got) + } +} + +func TestUpdateSecurityDoesNotBackfillWithoutDisabledToEnabledTransition(t *testing.T) { + repo := &securityUpdateRepo{current: &SecurityConfig{ID: 1, PwdExpireEnable: true}} + uc := NewSecurityUsecase(repo, nil, nil, nil) + + if err := uc.UpdateSecurity(context.Background(), &SecurityConfig{PwdExpireEnable: true}); err != nil { + t.Fatalf("UpdateSecurity() error = %v", err) + } + if got := repo.callOrder; len(got) != 2 || got[0] != "get" || got[1] != "save" { + t.Fatalf("call order = %v, want [get save]", got) + } +} diff --git a/internal/data/config_store.go b/internal/data/config_store.go index 2809ac2..519c702 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -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) } diff --git a/internal/data/data.go b/internal/data/data.go index ff75630..ca7e014 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -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) } diff --git a/internal/data/database.go b/internal/data/database.go index d8bc829..84ee7f1 100644 --- a/internal/data/database.go +++ b/internal/data/database.go @@ -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": diff --git a/internal/data/email.go b/internal/data/email.go index f925c14..8b5e0e0 100644 --- a/internal/data/email.go +++ b/internal/data/email.go @@ -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} diff --git a/internal/data/error_record.go b/internal/data/error_record.go index 02f8d27..9747989 100644 --- a/internal/data/error_record.go +++ b/internal/data/error_record.go @@ -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 diff --git a/internal/data/export.go b/internal/data/export.go index acb5581..1ab6a5d 100644 --- a/internal/data/export.go +++ b/internal/data/export.go @@ -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 diff --git a/internal/data/login_log.go b/internal/data/login_log.go index 9ab76f1..c316663 100644 --- a/internal/data/login_log.go +++ b/internal/data/login_log.go @@ -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 { diff --git a/internal/data/security.go b/internal/data/security.go index 79e9586..7fd3af0 100644 --- a/internal/data/security.go +++ b/internal/data/security.go @@ -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 } diff --git a/internal/data/system_init.go b/internal/data/system_init.go index c29c6ea..c2ebe40 100644 --- a/internal/data/system_init.go +++ b/internal/data/system_init.go @@ -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 { diff --git a/internal/data/system_init_state_test.go b/internal/data/system_init_state_test.go new file mode 100644 index 0000000..c319e72 --- /dev/null +++ b/internal/data/system_init_state_test.go @@ -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") + } +} diff --git a/internal/server/handler/audit.go b/internal/server/handler/audit.go index 3931749..cb0745e 100644 --- a/internal/server/handler/audit.go +++ b/internal/server/handler/audit.go @@ -322,7 +322,7 @@ func (h *Audit) DeleteErrors(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, gin.H{}, "批量删除成功") } func (h *Audit) UpdateError(c *gin.Context) { - var req dto.ErrorRecordRequest + var req dto.ErrorRecordMutationRequest if err := c.ShouldBindJSON(&req); err != nil { httpx.Fail(c, err.Error()) return @@ -361,12 +361,12 @@ func (h *Audit) Errors(c *gin.Context) { httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: p, PageSize: size}, "获取成功") } func (h *Audit) CreateError(c *gin.Context) { - var req dto.ErrorRecordRequest + var req dto.ErrorRecordMutationRequest if err := c.ShouldBindJSON(&req); err != nil { httpx.Fail(c, err.Error()) return } - if err := h.recorder.CreateErrorRequest(c.Request.Context(), &req); err != nil { + if err := h.recorder.CreateErrorMutationRequest(c.Request.Context(), &req); err != nil { httpx.Fail(c, "创建失败:"+err.Error()) return } diff --git a/internal/server/middleware/rate_limit_test.go b/internal/server/middleware/rate_limit_test.go index ea6789c..eb8ec4a 100644 --- a/internal/server/middleware/rate_limit_test.go +++ b/internal/server/middleware/rate_limit_test.go @@ -22,6 +22,9 @@ func (rateLimitSecurityRepo) SecurityConfig(context.Context) (*biz.SecurityConfi func (rateLimitSecurityRepo) SaveSecurityConfig(context.Context, *biz.SecurityConfig) error { return nil } +func (rateLimitSecurityRepo) BackfillPasswordUpdatedAt(context.Context, time.Time) error { + return nil +} type rateLimitCache struct{} diff --git a/internal/service/audit_error.go b/internal/service/audit_error.go index 3bd2baf..01a86a4 100644 --- a/internal/service/audit_error.go +++ b/internal/service/audit_error.go @@ -14,17 +14,36 @@ func errorDTO(v *biz.ErrorRecord) *dto.ErrorRecordResponse { func (s *AuditRecorder) CreateError(ctx context.Context, v *biz.ErrorRecord) error { return s.uc.CreateError(ctx, v) } -func errorDomain(value *dto.ErrorRecordRequest) *biz.ErrorRecord { - return &biz.ErrorRecord{ID: value.ID, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status} +func recordedErrorDomain(value *dto.ErrorRecordRequest) *biz.ErrorRecord { + form, info := value.Form, value.Info + result := &biz.ErrorRecord{Form: &form, Info: &info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Status: value.Status} + if value.Solution != "" { + solution := value.Solution + result.Solution = &solution + } + return result +} +func mutatedErrorDomain(value *dto.ErrorRecordMutationRequest) *biz.ErrorRecord { + return &biz.ErrorRecord{ID: value.ID, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, Form: value.Form, Info: value.Info, Level: value.Level, RequestID: value.RequestID, TraceID: value.TraceID, Solution: value.Solution, Status: value.Status} } func (s *AuditRecorder) CreateErrorRequest(ctx context.Context, req *dto.ErrorRecordRequest) error { - return s.CreateError(ctx, errorDomain(req)) + return s.CreateError(ctx, recordedErrorDomain(req)) } -func (s *AuditService) UpdateErrorRequest(ctx context.Context, req *dto.ErrorRecordRequest) error { - return s.UpdateError(ctx, errorDomain(req)) +func (s *AuditRecorder) CreateErrorMutationRequest(ctx context.Context, req *dto.ErrorRecordMutationRequest) error { + return s.CreateError(ctx, mutatedErrorDomain(req)) +} +func (s *AuditService) UpdateErrorRequest(ctx context.Context, req *dto.ErrorRecordMutationRequest) error { + return s.UpdateError(ctx, mutatedErrorDomain(req)) } func (s *AuditService) ErrorsFilter(ctx context.Context, page, size int, form, info string, createdAtRange []time.Time) ([]*dto.ErrorRecordResponse, int64, error) { - return s.Errors(ctx, page, size, &biz.ErrorRecord{Form: form, Info: info, CreatedAtRange: createdAtRange}) + query := &biz.ErrorRecord{CreatedAtRange: createdAtRange} + if form != "" { + query.Form = &form + } + if info != "" { + query.Info = &info + } + return s.Errors(ctx, page, size, query) } func (s *AuditService) UpdateError(ctx context.Context, v *biz.ErrorRecord) error { return s.uc.UpdateError(ctx, v) diff --git a/internal/service/dto/audit.go b/internal/service/dto/audit.go index 8f5e9ed..29decd9 100644 --- a/internal/service/dto/audit.go +++ b/internal/service/dto/audit.go @@ -56,8 +56,7 @@ type AuditIDQuery struct { } type ErrorRecordRequest struct { - ID uint `json:"ID"` - Form string `json:"form" binding:"required"` + Form string `json:"form"` Info string `json:"info"` Level string `json:"level"` RequestID string `json:"request_id"` @@ -66,6 +65,22 @@ type ErrorRecordRequest struct { Status string `json:"status"` } +// ErrorRecordMutationRequest mirrors the public error-record model. Pointer +// fields preserve the distinction between an omitted value and an explicitly +// supplied empty string, which is required by the update contract. +type ErrorRecordMutationRequest struct { + ID uint `json:"ID"` + CreatedAt time.Time `json:"CreatedAt"` + UpdatedAt time.Time `json:"UpdatedAt"` + Form *string `json:"form" binding:"required"` + Info *string `json:"info"` + Level string `json:"level"` + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + Solution *string `json:"solution"` + Status string `json:"status"` +} + type OperationRecordResponse struct { ID uint `json:"ID"` CreatedAt time.Time `json:"CreatedAt"` @@ -123,12 +138,12 @@ type ErrorRecordResponse struct { CreatedAt time.Time `json:"CreatedAt"` UpdatedAt time.Time `json:"UpdatedAt"` DeletedAt any `json:"-"` - Form string `json:"form"` - Info string `json:"info"` + Form *string `json:"form"` + Info *string `json:"info"` Level string `json:"level"` RequestID string `json:"request_id"` TraceID string `json:"trace_id"` - Solution string `json:"solution"` + Solution *string `json:"solution"` Status string `json:"status"` } diff --git a/internal/worker/task_scheduler.go b/internal/worker/task_scheduler.go index 9744a9d..be2e59b 100644 --- a/internal/worker/task_scheduler.go +++ b/internal/worker/task_scheduler.go @@ -48,17 +48,16 @@ func (s *TaskScheduler) Start(ctx context.Context) error { s.seconds.Start() items, _, err := s.tasks.ListTasks(ctx, 0, 0, nil) if err == nil { - loaded := 0 for _, task := range items { if task.Enabled { if scheduleErr := s.Schedule(task); scheduleErr != nil { s.logger.ErrorContext(ctx, "restore timed task failed", "id", task.ID, "error", scheduleErr) - } else { - loaded++ } } } - s.logger.InfoContext(ctx, "定时任务加载完成", "task_count", loaded) + // GVA reports the number of rows read from the database, not only + // the enabled rows that were scheduled successfully. + s.logger.InfoContext(ctx, "定时任务加载完成", "task_count", len(items)) } else { s.logger.WarnContext(ctx, "timed task table is not ready", "error", err) }