This commit is contained in:
Yvan 2026-09-04 16:51:03 +08:00
parent dce95412dd
commit d83720c8fd
3 changed files with 83 additions and 2 deletions

View File

@ -37,6 +37,29 @@ type SecurityConfig struct {
ForceNewUserChangePassword bool ForceNewUserChangePassword bool
} }
// ErrDatabaseNotInitialized is returned by storage-backed settings that are
// intentionally unavailable during the first-install bootstrap phase.
var ErrDatabaseNotInitialized = errors.New("database not initialized")
// DefaultSecurityConfig matches the seed values used for a fresh installation.
// It is owned by biz so bootstrap callers do not need to depend on data.
func DefaultSecurityConfig() *SecurityConfig {
return &SecurityConfig{
ID: 1,
CaptchaTimeout: 3600,
KeyLong: 6,
ImgWidth: 240,
ImgHeight: 80,
PwdMinLength: 8,
LimitWindow: 60,
LimitCount: 30,
LockThreshold: 5,
LockDuration: 30,
PwdExpireDays: 90,
ForceNewUserChangePassword: false,
}
}
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
@ -99,8 +122,17 @@ 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 {
// The login page and captcha are also the entry point for first install.
// Do not cache this fallback: after initdb succeeds the next request must
// load the persisted row instead of serving bootstrap defaults forever.
if errors.Is(err, ErrDatabaseNotInitialized) {
return DefaultSecurityConfig(), nil
}
return nil, err return nil, err
} }
if value == nil {
return nil, errors.New("security config is nil")
}
uc.mu.Lock() uc.mu.Lock()
copy := *value copy := *value
uc.cachedConfig = &copy uc.cachedConfig = &copy

View File

@ -15,6 +15,27 @@ type securityUpdateRepo struct {
persistedCopy *SecurityConfig persistedCopy *SecurityConfig
} }
type bootstrapSecurityRepo struct {
initialized bool
calls int
}
func (r *bootstrapSecurityRepo) SecurityConfig(context.Context) (*SecurityConfig, error) {
r.calls++
if !r.initialized {
return nil, ErrDatabaseNotInitialized
}
return &SecurityConfig{ID: 1, LimitCount: 42}, nil
}
func (*bootstrapSecurityRepo) SaveSecurityConfig(context.Context, *SecurityConfig) error {
return nil
}
func (*bootstrapSecurityRepo) BackfillPasswordUpdatedAt(context.Context, time.Time) error {
return nil
}
func (r *securityUpdateRepo) SecurityConfig(context.Context) (*SecurityConfig, error) { func (r *securityUpdateRepo) SecurityConfig(context.Context) (*SecurityConfig, error) {
r.callOrder = append(r.callOrder, "get") r.callOrder = append(r.callOrder, "get")
copy := *r.current copy := *r.current
@ -110,3 +131,31 @@ func TestSecurityReadsPersistedValueWhileCurrentUsesCache(t *testing.T) {
t.Fatalf("Security() returned stale value: %d", fresh.LimitCount) t.Fatalf("Security() returned stale value: %d", fresh.LimitCount)
} }
} }
func TestCurrentUsesBootstrapDefaultsWithoutCachingThem(t *testing.T) {
repo := &bootstrapSecurityRepo{}
uc := NewSecurityUsecase(repo, nil, nil, nil)
bootstrap, err := uc.Current(context.Background())
if err != nil {
t.Fatalf("Current() during bootstrap error = %v", err)
}
if bootstrap.ID != 1 || bootstrap.KeyLong != 6 || bootstrap.ImgWidth != 240 || bootstrap.ImgHeight != 80 || bootstrap.LimitEnable {
t.Fatalf("bootstrap config = %+v", bootstrap)
}
if repo.calls != 1 {
t.Fatalf("bootstrap repository calls = %d, want 1", repo.calls)
}
repo.initialized = true
active, err := uc.Current(context.Background())
if err != nil {
t.Fatalf("Current() after initialization error = %v", err)
}
if active.LimitCount != 42 {
t.Fatalf("active config = %+v, want persisted config", active)
}
if repo.calls != 2 {
t.Fatalf("repository calls = %d, want fallback to be uncached", repo.calls)
}
}

View File

@ -43,7 +43,7 @@ type SecurityConfigPO struct {
func (SecurityConfigPO) TableName() string { return "sys_security_config" } func (SecurityConfigPO) TableName() string { return "sys_security_config" }
func DefaultSecurityConfig() SecurityConfigPO { func DefaultSecurityConfig() SecurityConfigPO {
return SecurityConfigPO{ID: 1, CaptchaTimeout: 3600, KeyLong: 6, ImgWidth: 240, ImgHeight: 80, PwdMinLength: 8, LimitWindow: 60, LimitCount: 30, LockThreshold: 5, LockDuration: 30, PwdExpireDays: 90} return securityToPO(system.DefaultSecurityConfig())
} }
type securityRepo struct{ data DatabaseProvider } type securityRepo struct{ data DatabaseProvider }
@ -60,7 +60,7 @@ func securityToPO(v *system.SecurityConfig) SecurityConfigPO {
func (r *securityRepo) SecurityConfig(ctx context.Context) (*system.SecurityConfig, error) { func (r *securityRepo) SecurityConfig(ctx context.Context) (*system.SecurityConfig, error) {
if !r.data.DatabaseReady() { if !r.data.DatabaseReady() {
return nil, errors.New("数据库未初始化") return nil, system.ErrDatabaseNotInitialized
} }
db := r.data.DB().WithContext(ctx) db := r.data.DB().WithContext(ctx)
var po SecurityConfigPO var po SecurityConfigPO