This commit is contained in:
parent
1945d34894
commit
560ae46b82
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue