This commit is contained in:
yvan 2026-08-16 22:38:44 +08:00
parent 1945d34894
commit 560ae46b82
19 changed files with 345 additions and 112 deletions

View File

@ -83,7 +83,10 @@ func NewLogViewerUsecase(repo LogFileRepo) *LogViewerUsecase {
type ErrorRecord struct { type ErrorRecord struct {
ID uint ID uint
CreatedAt, UpdatedAt time.Time CreatedAt, UpdatedAt time.Time
Form, Info, Level, RequestID, TraceID, Solution, Status string Form, Info, Solution *string
Level string
RequestID, TraceID string
Status string
CreatedAtRange []time.Time CreatedAtRange []time.Time
} }

View File

@ -173,17 +173,13 @@ func (uc *MediaUsecase) CleanupStale(ctx context.Context, ttlHours int) error {
if ttlHours <= 0 { if ttlHours <= 0 {
ttlHours = 24 ttlHours = 24
} }
ids, err := uc.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour)) // The reference cleanup job is best-effort: a stale-session query or an
if err != nil { // individual storage/database cleanup failure is not propagated to the
return err // scheduler. Keep the endpoint-independent background behavior compatible.
} ids, _ := uc.StaleUploadSessionIDs(ctx, time.Now().Add(-time.Duration(ttlHours)*time.Hour))
for _, id := range ids { for _, id := range ids {
if err = uc.DeleteUploadData(ctx, id); err != nil { _ = uc.DeleteUploadData(ctx, id)
return err _ = uc.files.DeletePrefix(ctx, uc.chunkPrefix(id))
}
if err = uc.files.DeletePrefix(ctx, uc.chunkPrefix(id)); err != nil {
return err
}
} }
return nil return nil
} }

View File

@ -40,6 +40,7 @@ type SecurityConfig struct {
type SecurityRepo interface { type SecurityRepo interface {
SecurityConfig(context.Context) (*SecurityConfig, error) SecurityConfig(context.Context) (*SecurityConfig, error)
SaveSecurityConfig(context.Context, *SecurityConfig) error SaveSecurityConfig(context.Context, *SecurityConfig) error
BackfillPasswordUpdatedAt(context.Context, time.Time) error
} }
type SecurityUsecase struct { 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 { 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 { if err := uc.repo.SaveSecurityConfig(ctx, value); err != nil {
return err return err
} }
@ -70,6 +78,14 @@ func (uc *SecurityUsecase) UpdateSecurity(ctx context.Context, value *SecurityCo
copy := *value copy := *value
uc.cachedConfig = &copy uc.cachedConfig = &copy
uc.mu.Unlock() 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 return nil
} }
@ -83,6 +99,13 @@ func (uc *SecurityUsecase) Current(ctx context.Context) (*SecurityConfig, error)
uc.mu.RUnlock() uc.mu.RUnlock()
value, err := uc.repo.SecurityConfig(ctx) value, err := uc.repo.SecurityConfig(ctx)
if err != nil { 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 return nil, err
} }
uc.mu.Lock() uc.mu.Lock()

View File

@ -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 &copy, 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 = &copy
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)
}
}

View File

@ -14,6 +14,7 @@ import (
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"gorm.io/gorm"
) )
func protoMap(message proto.Message) (map[string]any, error) { func protoMap(message proto.Message) (map[string]any, error) {
@ -260,10 +261,19 @@ func (d *Data) reloadConfig(ctx context.Context) error {
return fmt.Errorf("data.database and admin configuration are required") return fmt.Errorf("data.database and admin configuration are required")
} }
next.Admin.ConfigPath = configPath next.Admin.ConfigPath = configPath
candidateDB, err := openDatabase(next.Data.Database, false, "", d.logger()) databaseReady := databaseConnectionConfigured(next.Data.Database)
var candidateDB *gorm.DB
if databaseReady {
candidateDB, err = openDatabase(next.Data.Database, false, "", d.logger())
if err != nil { if err != nil {
return fmt.Errorf("reload database: %w", err) 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 closeCandidate := true
defer func() { defer func() {
if closeCandidate { if closeCandidate {
@ -272,12 +282,14 @@ func (d *Data) reloadConfig(ctx context.Context) error {
} }
} }
}() }()
if databaseReady {
if sqlDB, dbErr := candidateDB.DB(); dbErr != nil { if sqlDB, dbErr := candidateDB.DB(); dbErr != nil {
return dbErr return dbErr
} else if err = sqlDB.PingContext(ctx); err != nil { } else if err = sqlDB.PingContext(ctx); err != nil {
return fmt.Errorf("reload database: %w", err) 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 { if err = migrateAll(candidateDB.WithContext(ctx)); err != nil {
return fmt.Errorf("reload database migrations: %w", err) 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.gormDB.replace(candidateDB, d.enqueueDataScopeAudit)
d.databaseReady.Store(databaseReady)
for _, item := range candidateDBList { for _, item := range candidateDBList {
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit) registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
} }

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/google/wire" "github.com/google/wire"
@ -18,6 +19,7 @@ var ProviderSet = wire.NewSet(NewData, NewRuntimeSettings, NewTokenIssuer, NewUs
type Data struct { type Data struct {
initMu sync.Mutex initMu sync.Mutex
configMu sync.Mutex configMu sync.Mutex
databaseReady atomic.Bool
gormDB *reloadableDB gormDB *reloadableDB
redis *reloadableRedis redis *reloadableRedis
mongo *reloadableMongo mongo *reloadableMongo
@ -90,22 +92,35 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger) (*Data, func(), erro
appLogger = slog.Default() appLogger = slog.Default()
} }
c := runtime.Data() c := runtime.Data()
if c == nil || c.Database == nil { if c == nil {
return nil, nil, fmt.Errorf("database configuration is required") 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} d := &Data{runtime: runtime, appLogger: appLogger}
db, err := openDatabase(c.Database, false, "", appLogger) usingFallback := !databaseConnectionConfigured(c.Database)
usingFallback := false var db *gorm.DB
if err != nil { 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 // The initialization endpoint must remain available when the configured
// target database has not been created yet. // target database has not been created yet.
if err != nil {
appLogger.Warn("configured database unavailable before initialization", "mod", "system", "error", err) appLogger.Warn("configured database unavailable before initialization", "mod", "system", "error", err)
}
db, err = openFallbackDatabase(appLogger) db, err = openFallbackDatabase(appLogger)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("open bootstrap database: %w", err) return nil, nil, fmt.Errorf("open bootstrap database: %w", err)
} }
usingFallback = true usingFallback = true
} }
d.databaseReady.Store(!usingFallback)
d.gormDB = newReloadableDB(db, d.enqueueDataScopeAudit) d.gormDB = newReloadableDB(db, d.enqueueDataScopeAudit)
d.auditLog = newDataScopeAuditWriter(d, appLogger) d.auditLog = newDataScopeAuditWriter(d, appLogger)
d.dbList, err = openDatabaseList(c.DatabaseList, 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) { func (d *Data) activateDatabase(db *gorm.DB, config *conf.Data_Database) {
d.gormDB.replace(db, d.enqueueDataScopeAudit) d.gormDB.replace(db, d.enqueueDataScopeAudit)
d.runtime.UpdateDatabase(config) d.runtime.UpdateDatabase(config)
d.databaseReady.Store(true)
} }

View File

@ -24,6 +24,20 @@ import (
var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`) 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 { func normalizedDriver(driver string) string {
switch strings.ToLower(strings.TrimSpace(driver)) { switch strings.ToLower(strings.TrimSpace(driver)) {
case "postgres", "postgresql", "pgsql": case "postgres", "postgresql", "pgsql":

View File

@ -79,13 +79,6 @@ func (r *emailRepo) Send(ctx context.Context, to []string, subject, body string)
return err return err
} }
defer client.Close() 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 var auth smtp.Auth
if config.IsLoginAuth { if config.IsLoginAuth {
auth = &loginAuth{username: config.From, password: config.Secret} auth = &loginAuth{username: config.From, password: config.Secret}

View File

@ -14,12 +14,12 @@ type errorRecordPO struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
Form string `gorm:"type:text"` Form *string `gorm:"type:text"`
Info string `gorm:"type:text"` Info *string `gorm:"type:text"`
Level string Level string
RequestID string `gorm:"index"` RequestID string `gorm:"index"`
TraceID string `gorm:"index"` TraceID string `gorm:"index"`
Solution string `gorm:"type:text"` Solution *string `gorm:"type:text"`
Status string `gorm:"default:未处理"` Status string `gorm:"default:未处理"`
} }
@ -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} 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 { 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 == "" { if v.Status == "" {
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 { func (r *auditQueryRepo) UpdateError(ctx context.Context, v *biz.ErrorRecord) error {
updates := make(map[string]any, 5) updates := make(map[string]any, 9)
if v.Form != "" { 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 updates["form"] = v.Form
} }
if v.Info != "" { if v.Info != nil {
updates["info"] = v.Info updates["info"] = v.Info
} }
if v.Level != "" { if v.Level != "" {
updates["level"] = 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 updates["solution"] = v.Solution
} }
if v.Status != "" { if v.Status != "" {
@ -69,11 +85,11 @@ func (r *auditQueryRepo) ListErrors(ctx context.Context, page, size int, q *biz.
if len(q.CreatedAtRange) == 2 { if len(q.CreatedAtRange) == 2 {
db = db.Where("created_at BETWEEN ? AND ?", q.CreatedAtRange[0], q.CreatedAtRange[1]) db = db.Where("created_at BETWEEN ? AND ?", q.CreatedAtRange[0], q.CreatedAtRange[1])
} }
if q.Form != "" { if q.Form != nil && *q.Form != "" {
db = db.Where("form = ?", q.Form) db = db.Where("form = ?", *q.Form)
} }
if q.Info != "" { if q.Info != nil && *q.Info != "" {
db = db.Where("info LIKE ?", "%"+q.Info+"%") db = db.Where("info LIKE ?", "%"+*q.Info+"%")
} }
} }
var total int64 var total int64

View File

@ -86,11 +86,7 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
if resetIDs { if resetIDs {
id = 0 id = 0
} }
templateID := x.TemplateID 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 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})
} }
if len(conditions) > 0 { if len(conditions) > 0 {
create := tx create := tx
@ -109,11 +105,7 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs
if resetIDs { if resetIDs {
id = 0 id = 0
} }
templateID := x.TemplateID 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 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})
} }
if len(joins) > 0 { if len(joins) > 0 {
create := tx create := tx

View File

@ -23,6 +23,11 @@ type loginLogPO struct {
func (loginLogPO) TableName() string { return "sys_login_logs" } func (loginLogPO) TableName() string { return "sys_login_logs" }
func (r *auditRecorderRepo) RecordLogin(ctx context.Context, v *biz.LoginLog) error { 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 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 { func loginFromPO(v loginLogPO) *biz.LoginLog {

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} 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) { 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 var po securityConfigPO
err := r.data.gormDB.WithContext(ctx).First(&po, 1).Error err := r.data.gormDB.WithContext(ctx).First(&po, 1).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@ -64,20 +68,16 @@ func (r *securityRepo) SecurityConfig(ctx context.Context) (*biz.SecurityConfig,
return securityFromPO(po), nil return securityFromPO(po), nil
} }
func (r *securityRepo) SaveSecurityConfig(ctx context.Context, v *biz.SecurityConfig) error { func (r *securityRepo) SaveSecurityConfig(ctx context.Context, v *biz.SecurityConfig) error {
previous, err := r.SecurityConfig(ctx)
if err != nil {
return err
}
po := securityToPO(v) po := securityToPO(v)
po.CreatedAt, po.UpdatedAt = previous.CreatedAt, previous.UpdatedAt if err := r.data.gormDB.WithContext(ctx).Save(&po).Error; err != nil {
return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&po).Error; err != nil {
return err 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 v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
return nil 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
} }

View File

@ -56,15 +56,7 @@ func (r *initializationRepo) ReloadConfig(ctx context.Context) error {
} }
func (r *initializationRepo) IsInitialized(ctx context.Context) (bool, error) { func (r *initializationRepo) IsInitialized(ctx context.Context) (bool, error) {
db := r.data.gormDB.WithContext(ctx) return r.data.databaseReady.Load(), nil
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
} }
func (r *initializationRepo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error { func (r *initializationRepo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error {

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

View File

@ -322,7 +322,7 @@ func (h *Audit) DeleteErrors(c *gin.Context) {
httpx.Write(c, httpx.CodeSuccess, gin.H{}, "批量删除成功") httpx.Write(c, httpx.CodeSuccess, gin.H{}, "批量删除成功")
} }
func (h *Audit) UpdateError(c *gin.Context) { func (h *Audit) UpdateError(c *gin.Context) {
var req dto.ErrorRecordRequest var req dto.ErrorRecordMutationRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return 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}, "获取成功") httpx.Write(c, httpx.CodeSuccess, httpx.PageResult{List: items, Total: total, Page: p, PageSize: size}, "获取成功")
} }
func (h *Audit) CreateError(c *gin.Context) { func (h *Audit) CreateError(c *gin.Context) {
var req dto.ErrorRecordRequest var req dto.ErrorRecordMutationRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
httpx.Fail(c, err.Error()) httpx.Fail(c, err.Error())
return 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()) httpx.Fail(c, "创建失败:"+err.Error())
return return
} }

View File

@ -22,6 +22,9 @@ func (rateLimitSecurityRepo) SecurityConfig(context.Context) (*biz.SecurityConfi
func (rateLimitSecurityRepo) SaveSecurityConfig(context.Context, *biz.SecurityConfig) error { func (rateLimitSecurityRepo) SaveSecurityConfig(context.Context, *biz.SecurityConfig) error {
return nil return nil
} }
func (rateLimitSecurityRepo) BackfillPasswordUpdatedAt(context.Context, time.Time) error {
return nil
}
type rateLimitCache struct{} type rateLimitCache struct{}

View File

@ -14,17 +14,36 @@ func errorDTO(v *biz.ErrorRecord) *dto.ErrorRecordResponse {
func (s *AuditRecorder) CreateError(ctx context.Context, v *biz.ErrorRecord) error { func (s *AuditRecorder) CreateError(ctx context.Context, v *biz.ErrorRecord) error {
return s.uc.CreateError(ctx, v) return s.uc.CreateError(ctx, v)
} }
func errorDomain(value *dto.ErrorRecordRequest) *biz.ErrorRecord { func recordedErrorDomain(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} 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 { 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 { func (s *AuditRecorder) CreateErrorMutationRequest(ctx context.Context, req *dto.ErrorRecordMutationRequest) error {
return s.UpdateError(ctx, errorDomain(req)) 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) { 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 { func (s *AuditService) UpdateError(ctx context.Context, v *biz.ErrorRecord) error {
return s.uc.UpdateError(ctx, v) return s.uc.UpdateError(ctx, v)

View File

@ -56,8 +56,7 @@ type AuditIDQuery struct {
} }
type ErrorRecordRequest struct { type ErrorRecordRequest struct {
ID uint `json:"ID"` Form string `json:"form"`
Form string `json:"form" binding:"required"`
Info string `json:"info"` Info string `json:"info"`
Level string `json:"level"` Level string `json:"level"`
RequestID string `json:"request_id"` RequestID string `json:"request_id"`
@ -66,6 +65,22 @@ type ErrorRecordRequest struct {
Status string `json:"status"` 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 { type OperationRecordResponse struct {
ID uint `json:"ID"` ID uint `json:"ID"`
CreatedAt time.Time `json:"CreatedAt"` CreatedAt time.Time `json:"CreatedAt"`
@ -123,12 +138,12 @@ type ErrorRecordResponse struct {
CreatedAt time.Time `json:"CreatedAt"` CreatedAt time.Time `json:"CreatedAt"`
UpdatedAt time.Time `json:"UpdatedAt"` UpdatedAt time.Time `json:"UpdatedAt"`
DeletedAt any `json:"-"` DeletedAt any `json:"-"`
Form string `json:"form"` Form *string `json:"form"`
Info string `json:"info"` Info *string `json:"info"`
Level string `json:"level"` Level string `json:"level"`
RequestID string `json:"request_id"` RequestID string `json:"request_id"`
TraceID string `json:"trace_id"` TraceID string `json:"trace_id"`
Solution string `json:"solution"` Solution *string `json:"solution"`
Status string `json:"status"` Status string `json:"status"`
} }

View File

@ -48,17 +48,16 @@ func (s *TaskScheduler) Start(ctx context.Context) error {
s.seconds.Start() s.seconds.Start()
items, _, err := s.tasks.ListTasks(ctx, 0, 0, nil) items, _, err := s.tasks.ListTasks(ctx, 0, 0, nil)
if err == nil { if err == nil {
loaded := 0
for _, task := range items { for _, task := range items {
if task.Enabled { if task.Enabled {
if scheduleErr := s.Schedule(task); scheduleErr != nil { if scheduleErr := s.Schedule(task); scheduleErr != nil {
s.logger.ErrorContext(ctx, "restore timed task failed", "id", task.ID, "error", scheduleErr) 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 { } else {
s.logger.WarnContext(ctx, "timed task table is not ready", "error", err) s.logger.WarnContext(ctx, "timed task table is not ready", "error", err)
} }