From 23bc29ce0f8f7f50ddc3bc86684f1b477096b707 Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Thu, 20 Aug 2026 20:46:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/data/audit.go | 13 - internal/data/config_store.go | 5 +- internal/data/data_scope_record.go | 22 + internal/data/file_storage.go | 65 --- internal/data/initialization.go | 9 + internal/data/integration_config_test.go | 5 +- internal/data/migrations.go | 3 + internal/data/payment/migrations.go | 35 ++ internal/data/payment/models.go | 17 + internal/data/{ => payment}/payment.go | 14 +- .../data/{ => payment}/payment_config_test.go | 2 +- .../data/{ => payment}/payment_helpers.go | 2 +- .../data/{ => payment}/payment_native_test.go | 2 +- internal/data/{ => payment}/payment_order.go | 48 +- .../data/{ => payment}/payment_order_test.go | 26 +- internal/data/payment/provider.go | 11 + internal/data/payment/testing_support_test.go | 67 +++ internal/data/{ => system}/announcement.go | 28 +- .../data/{ => system}/announcement_test.go | 2 +- internal/data/{ => system}/api.go | 25 +- internal/data/{ => system}/api_policy.go | 20 +- internal/data/{ => system}/api_policy_test.go | 2 +- internal/data/{ => system}/api_sync.go | 10 +- internal/data/{ => system}/api_token.go | 31 +- internal/data/system/audit.go | 13 + .../data/{ => system}/audit_delete_test.go | 2 +- internal/data/{ => system}/authority.go | 52 +- internal/data/{ => system}/authority_test.go | 2 +- internal/data/system/bootstrap.go | 25 + internal/data/{ => system}/casbin.go | 2 +- internal/data/{ => system}/data_access_log.go | 11 +- internal/data/{ => system}/department.go | 34 +- internal/data/{ => system}/dictionary.go | 57 +- .../dictionary_department_parity_test.go | 2 +- internal/data/{ => system}/error_record.go | 17 +- .../data/{ => system}/error_record_test.go | 2 +- internal/data/{ => system}/export.go | 22 +- internal/data/{ => system}/log_file.go | 4 +- internal/data/{ => system}/log_file_test.go | 2 +- internal/data/{ => system}/login_log.go | 19 +- internal/data/{ => system}/media.go | 32 +- internal/data/{ => system}/media_test.go | 2 +- internal/data/{ => system}/media_upload.go | 28 +- internal/data/{ => system}/menu.go | 50 +- internal/data/{ => system}/menu_test.go | 2 +- internal/data/system/migrations.go | 507 ++++++++++++++++++ internal/data/{ => system}/migrations_test.go | 110 +++- internal/data/{system.go => system/models.go} | 9 +- internal/data/{ => system}/operation_log.go | 17 +- .../data/{ => system}/organization_test.go | 2 +- internal/data/{ => system}/parameter.go | 23 +- internal/data/{ => system}/parameter_test.go | 2 +- internal/data/{ => system}/permission.go | 20 +- internal/data/{ => system}/permission_test.go | 2 +- internal/data/{ => system}/position.go | 24 +- internal/data/system/provider.go | 16 + internal/data/system/seed.go | 156 ++++++ .../{ => system}/system_init_ignore_test.go | 4 +- internal/data/{ => system}/task.go | 38 +- internal/data/system/testing_support_test.go | 76 +++ .../data/{ => system}/transactions_test.go | 2 +- internal/data/{ => system}/user.go | 55 +- .../data/{ => system}/user_strict_test.go | 2 +- internal/data/{ => system}/version.go | 29 +- internal/data/{ => system}/version_test.go | 2 +- internal/{data => integration/cache}/cache.go | 51 +- internal/integration/storage/reloadable.go | 68 +++ 67 files changed, 1594 insertions(+), 465 deletions(-) delete mode 100644 internal/data/audit.go create mode 100644 internal/data/data_scope_record.go delete mode 100644 internal/data/file_storage.go create mode 100644 internal/data/initialization.go create mode 100644 internal/data/payment/migrations.go create mode 100644 internal/data/payment/models.go rename internal/data/{ => payment}/payment.go (97%) rename internal/data/{ => payment}/payment_config_test.go (99%) rename internal/data/{ => payment}/payment_helpers.go (97%) rename internal/data/{ => payment}/payment_native_test.go (99%) rename internal/data/{ => payment}/payment_order.go (91%) rename internal/data/{ => payment}/payment_order_test.go (82%) create mode 100644 internal/data/payment/provider.go create mode 100644 internal/data/payment/testing_support_test.go rename internal/data/{ => system}/announcement.go (72%) rename internal/data/{ => system}/announcement_test.go (99%) rename internal/data/{ => system}/api.go (85%) rename internal/data/{ => system}/api_policy.go (88%) rename internal/data/{ => system}/api_policy_test.go (99%) rename internal/data/{ => system}/api_sync.go (83%) rename internal/data/{ => system}/api_token.go (76%) create mode 100644 internal/data/system/audit.go rename internal/data/{ => system}/audit_delete_test.go (97%) rename internal/data/{ => system}/authority.go (91%) rename internal/data/{ => system}/authority_test.go (99%) rename internal/data/{ => system}/casbin.go (99%) rename internal/data/{ => system}/data_access_log.go (75%) rename internal/data/{ => system}/department.go (80%) rename internal/data/{ => system}/dictionary.go (85%) rename internal/data/{ => system}/dictionary_department_parity_test.go (99%) rename internal/data/{ => system}/error_record.go (78%) rename internal/data/{ => system}/error_record_test.go (99%) rename internal/data/{ => system}/export.go (96%) rename internal/data/{ => system}/log_file.go (99%) rename internal/data/{ => system}/log_file_test.go (98%) rename internal/data/{ => system}/login_log.go (73%) rename internal/data/{ => system}/media.go (82%) rename internal/data/{ => system}/media_test.go (98%) rename internal/data/{ => system}/media_upload.go (72%) rename internal/data/{ => system}/menu.go (84%) rename internal/data/{ => system}/menu_test.go (99%) create mode 100644 internal/data/system/migrations.go rename internal/data/{ => system}/migrations_test.go (59%) rename internal/data/{system.go => system/models.go} (91%) rename internal/data/{ => system}/operation_log.go (78%) rename internal/data/{ => system}/organization_test.go (98%) rename internal/data/{ => system}/parameter.go (69%) rename internal/data/{ => system}/parameter_test.go (99%) rename internal/data/{ => system}/permission.go (82%) rename internal/data/{ => system}/permission_test.go (99%) rename internal/data/{ => system}/position.go (76%) create mode 100644 internal/data/system/provider.go create mode 100644 internal/data/system/seed.go rename internal/data/{ => system}/system_init_ignore_test.go (85%) rename internal/data/{ => system}/task.go (71%) create mode 100644 internal/data/system/testing_support_test.go rename internal/data/{ => system}/transactions_test.go (99%) rename internal/data/{ => system}/user.go (86%) rename internal/data/{ => system}/user_strict_test.go (99%) rename internal/data/{ => system}/version.go (87%) rename internal/data/{ => system}/version_test.go (98%) rename internal/{data => integration/cache}/cache.go (64%) create mode 100644 internal/integration/storage/reloadable.go diff --git a/internal/data/audit.go b/internal/data/audit.go deleted file mode 100644 index 4fa449a..0000000 --- a/internal/data/audit.go +++ /dev/null @@ -1,13 +0,0 @@ -package data - -import "kra/internal/biz" - -type auditQueryRepo struct{ data *Data } -type auditRecorderRepo struct{ data *Data } -type logFileRepo struct{ data *Data } - -func NewAuditRepo(data *Data) biz.AuditQueryRepo { return &auditQueryRepo{data: data} } - -func NewAuditRecorderRepo(data *Data) biz.AuditRecordRepo { return &auditRecorderRepo{data: data} } - -func NewLogFileRepo(data *Data) biz.LogFileRepo { return &logFileRepo{data: data} } diff --git a/internal/data/config_store.go b/internal/data/config_store.go index 60dd5c5..0b48402 100644 --- a/internal/data/config_store.go +++ b/internal/data/config_store.go @@ -10,6 +10,7 @@ import ( "strconv" "kra/internal/conf" + "kra/internal/integration/storage" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -375,7 +376,7 @@ func (d *Data) reloadConfig(ctx context.Context) error { return fmt.Errorf("reload email configuration: %w", err) } next.Admin.Email = emailConfig - candidateStorage, err := buildFileStorage(next.Admin) + candidateStorage, err := storage.New(next.Admin) if err != nil { return fmt.Errorf("reload storage: %w", err) } @@ -410,7 +411,7 @@ func (d *Data) reloadConfig(ctx context.Context) error { } d.runtime.Replace(next.Data, next.Admin) if d.storage != nil { - d.storage.replace(candidateStorage) + d.storage.Replace(candidateStorage) } closeCandidate = false return nil diff --git a/internal/data/data_scope_record.go b/internal/data/data_scope_record.go new file mode 100644 index 0000000..c46be18 --- /dev/null +++ b/internal/data/data_scope_record.go @@ -0,0 +1,22 @@ +package data + +import ( + "time" + + "gorm.io/gorm" +) + +// dataAccessLogPO is the infrastructure-side write model used by GORM +// callbacks. The system module owns the query repository for the same table. +type dataAccessLogPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + EventType, TargetTable, Operation string + UserID, AuthorityID uint + Scope int + RequestID, Method, Path, Detail string +} + +func (dataAccessLogPO) TableName() string { return "sys_data_access_logs" } diff --git a/internal/data/file_storage.go b/internal/data/file_storage.go deleted file mode 100644 index a1b5268..0000000 --- a/internal/data/file_storage.go +++ /dev/null @@ -1,65 +0,0 @@ -package data - -import ( - "context" - "io" - "sync" - - "kra/internal/biz" - "kra/internal/conf" - datastorage "kra/internal/data/storage" -) - -type reloadableStorage struct { - mu sync.RWMutex - current biz.FileStorage -} - -func NewFileStorage(data *Data) (biz.FileStorage, error) { - storage, err := buildFileStorage(data.runtime.Admin()) - if err != nil { - return nil, err - } - wrapper := &reloadableStorage{current: storage} - data.storage = wrapper - return wrapper, nil -} - -func buildFileStorage(config *conf.AdminBackend) (biz.FileStorage, error) { - return datastorage.New(config) -} -func (s *reloadableStorage) replace(storage biz.FileStorage) { - s.mu.Lock() - s.current = storage - s.mu.Unlock() -} -func (s *reloadableStorage) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.current.Put(ctx, name, reader) -} -func (s *reloadableStorage) Open(ctx context.Context, name string) (io.ReadCloser, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.current.Open(ctx, name) -} -func (s *reloadableStorage) Delete(ctx context.Context, name string) error { - s.mu.RLock() - defer s.mu.RUnlock() - return s.current.Delete(ctx, name) -} -func (s *reloadableStorage) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.current.Compose(ctx, names, destination) -} -func (s *reloadableStorage) DeletePrefix(ctx context.Context, prefix string) error { - s.mu.RLock() - defer s.mu.RUnlock() - return s.current.DeletePrefix(ctx, prefix) -} -func (s *reloadableStorage) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { - s.mu.RLock() - defer s.mu.RUnlock() - return s.current.List(ctx, prefix, cursor, limit) -} diff --git a/internal/data/initialization.go b/internal/data/initialization.go new file mode 100644 index 0000000..9160f2c --- /dev/null +++ b/internal/data/initialization.go @@ -0,0 +1,9 @@ +package data + +import "kra/internal/biz" + +type initializationRepo struct{ data *Data } + +func NewInitializationRepo(data *Data) biz.InitializationRepo { + return &initializationRepo{data: data} +} diff --git a/internal/data/integration_config_test.go b/internal/data/integration_config_test.go index e9490a2..629561d 100644 --- a/internal/data/integration_config_test.go +++ b/internal/data/integration_config_test.go @@ -8,6 +8,7 @@ import ( "testing" "kra/internal/conf" + "kra/internal/integration/storage" "google.golang.org/protobuf/encoding/protojson" "gopkg.in/yaml.v3" @@ -239,7 +240,7 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) { Local: &conf.AdminBackend_Local{StorePath: oldRoot, PathPrefix: "old-files"}, Storage: &conf.AdminBackend_Storage{Type: "local"}, } - currentStorage, err := buildFileStorage(currentAdmin) + currentStorage, err := storage.NewReloadable(currentAdmin) if err != nil { t.Fatal(err) } @@ -248,7 +249,7 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) { d := &Data{ runtime: conf.NewRuntime(&conf.Data{}, currentAdmin), gormDB: reloadableDB, - storage: &reloadableStorage{current: currentStorage}, + storage: currentStorage, } d.databaseReady.Store(true) diff --git a/internal/data/migrations.go b/internal/data/migrations.go index 387832c..1f5ffc2 100644 --- a/internal/data/migrations.go +++ b/internal/data/migrations.go @@ -24,5 +24,8 @@ func migrateAll(db *gorm.DB) error { } return datasystem.CurrentDataMigration(db) }}, + {ID: "202608200003_payment_admin_surface", Migrate: func(db *gorm.DB) error { + return datasystem.EnsureAdminSurface(db, datapayment.AdminSurface()) + }}, }) } diff --git a/internal/data/payment/migrations.go b/internal/data/payment/migrations.go new file mode 100644 index 0000000..953ff52 --- /dev/null +++ b/internal/data/payment/migrations.go @@ -0,0 +1,35 @@ +package payment + +import ( + "kra/internal/data/system" + + "gorm.io/gorm" +) + +// Migrate creates the persistence owned by the payment module. +func Migrate(db *gorm.DB) error { + return db.AutoMigrate(&integrationConfigPO{}, &paymentOrderPO{}) +} + +// Reconcile seeds disabled configuration rows for all built-in providers. +func Reconcile(db *gorm.DB) error { + return ensurePaymentIntegrationConfigs(db) +} + +// AdminSurface describes the payment-owned entries shown in the system +// administration UI. The system module persists these records because it owns +// the menu/API/policy tables. +func AdminSurface() system.AdminSurface { + return system.AdminSurface{ + Menus: []system.AdminMenu{ + {Name: "paymentOrders", Path: "paymentOrders", ParentName: "extensions", Component: "view/systemTools/payment/orders.vue", Title: "支付订单", Icon: "wallet", Sort: 6}, + {Name: "paymentConfig", Path: "paymentConfig", ParentName: "extensions", Component: "view/systemTools/payment/config.vue", Title: "支付配置", Icon: "credit-card", Sort: 7}, + }, + APIs: []system.AdminAPI{ + {Path: "/payment/configs", Method: "GET", APIGroup: "支付", Description: "获取支付渠道配置"}, + {Path: "/payment/config", Method: "POST", APIGroup: "支付", Description: "保存支付渠道配置"}, + {Path: "/payment/orders", Method: "GET", APIGroup: "支付", Description: "分页查询支付订单"}, + {Path: "/payment/order", Method: "POST", APIGroup: "支付", Description: "查询支付订单"}, + }, + } +} diff --git a/internal/data/payment/models.go b/internal/data/payment/models.go new file mode 100644 index 0000000..af0bc97 --- /dev/null +++ b/internal/data/payment/models.go @@ -0,0 +1,17 @@ +package payment + +import "time" + +const integrationKindPayment = "payment" + +type integrationConfigPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + Kind string `gorm:"size:32;not null;uniqueIndex:idx_integration_kind_provider"` + Provider string `gorm:"size:64;not null;uniqueIndex:idx_integration_kind_provider"` + Enabled bool `gorm:"not null;default:false;index"` + Config string `gorm:"type:text;not null"` +} + +func (integrationConfigPO) TableName() string { return "sys_integration_configs" } diff --git a/internal/data/payment.go b/internal/data/payment/payment.go similarity index 97% rename from internal/data/payment.go rename to internal/data/payment/payment.go index 3cf6161..ac55cb8 100644 --- a/internal/data/payment.go +++ b/internal/data/payment/payment.go @@ -1,4 +1,4 @@ -package data +package payment import ( "context" @@ -12,14 +12,14 @@ import ( "strings" "kra/internal/biz" - datapayment "kra/internal/data/payment" + datapayment "kra/internal/integration/payment" "gorm.io/gorm" ) -type paymentRepo struct{ data *Data } +type paymentRepo struct{ data Provider } -func NewPaymentRepo(data *Data) biz.PaymentRepo { return &paymentRepo{data: data} } +func NewPaymentRepo(data Provider) biz.PaymentRepo { return &paymentRepo{data: data} } func ensurePaymentIntegrationConfigs(db *gorm.DB) error { for _, provider := range biz.SupportedPaymentProviders { @@ -38,7 +38,7 @@ func ensurePaymentIntegrationConfigs(db *gorm.DB) error { func (r *paymentRepo) row(ctx context.Context, provider string) (*integrationConfigPO, map[string]any, error) { var row integrationConfigPO - if err := r.data.gormDB.WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", integrationKindPayment, provider).First(&row).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, biz.ErrPaymentProviderNotFound } @@ -56,7 +56,7 @@ func (r *paymentRepo) row(ctx context.Context, provider string) (*integrationCon func (r *paymentRepo) ListConfigs(ctx context.Context) ([]*biz.PaymentConfig, error) { var rows []integrationConfigPO - if err := r.data.gormDB.WithContext(ctx).Where("kind = ?", integrationKindPayment).Order("provider ASC").Find(&rows).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("kind = ?", integrationKindPayment).Order("provider ASC").Find(&rows).Error; err != nil { return nil, err } out := make([]*biz.PaymentConfig, 0, len(rows)) @@ -84,7 +84,7 @@ func (r *paymentRepo) SaveConfig(ctx context.Context, config *biz.PaymentConfig) if err := json.Unmarshal(config.Values, &values); err != nil { return errors.New("支付配置必须是 JSON 对象") } - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var row integrationConfigPO err := db.Where("kind = ? AND provider = ?", integrationKindPayment, config.Provider).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/data/payment_config_test.go b/internal/data/payment/payment_config_test.go similarity index 99% rename from internal/data/payment_config_test.go rename to internal/data/payment/payment_config_test.go index 42668c3..e06d289 100644 --- a/internal/data/payment_config_test.go +++ b/internal/data/payment/payment_config_test.go @@ -1,4 +1,4 @@ -package data +package payment import ( "context" diff --git a/internal/data/payment_helpers.go b/internal/data/payment/payment_helpers.go similarity index 97% rename from internal/data/payment_helpers.go rename to internal/data/payment/payment_helpers.go index 5a9ca11..c7e8b88 100644 --- a/internal/data/payment_helpers.go +++ b/internal/data/payment/payment_helpers.go @@ -1,4 +1,4 @@ -package data +package payment import "kra/pkg/paymentkit" diff --git a/internal/data/payment_native_test.go b/internal/data/payment/payment_native_test.go similarity index 99% rename from internal/data/payment_native_test.go rename to internal/data/payment/payment_native_test.go index cfc9eba..f4f0922 100644 --- a/internal/data/payment_native_test.go +++ b/internal/data/payment/payment_native_test.go @@ -1,4 +1,4 @@ -package data +package payment import ( "context" diff --git a/internal/data/payment_order.go b/internal/data/payment/payment_order.go similarity index 91% rename from internal/data/payment_order.go rename to internal/data/payment/payment_order.go index 8fd5766..2308588 100644 --- a/internal/data/payment_order.go +++ b/internal/data/payment/payment_order.go @@ -1,4 +1,4 @@ -package data +package payment import ( "context" @@ -11,6 +11,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" "kra/internal/biz" + "kra/pkg/pagination" ) type paymentOrderPO struct { @@ -64,9 +65,9 @@ type paymentOrderPO struct { func (paymentOrderPO) TableName() string { return "pay_orders" } -type paymentOrderRepo struct{ data *Data } +type paymentOrderRepo struct{ data Provider } -func NewPaymentOrderRepo(data *Data) biz.PaymentOrderRepo { +func NewPaymentOrderRepo(data Provider) biz.PaymentOrderRepo { return &paymentOrderRepo{data: data} } @@ -134,7 +135,7 @@ func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *biz.Pa if err != nil { return nil, false, err } - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var current paymentOrderPO err = db.Where("provider = ? AND trade_no = ?", po.Provider, po.TradeNo).First(¤t).Error if err == nil { @@ -154,7 +155,7 @@ func (r *paymentOrderRepo) CreatePaymentOrder(ctx context.Context, order *biz.Pa func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, tradeNo string) (*biz.PaymentOrder, error) { var po paymentOrderPO - if err := r.data.gormDB.WithContext(ctx).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, biz.ErrPaymentOrderNotFound } @@ -163,6 +164,41 @@ func (r *paymentOrderRepo) FindPaymentOrder(ctx context.Context, provider, trade return toBizPaymentOrder(&po), nil } +func (r *paymentOrderRepo) ListPaymentOrders(ctx context.Context, page, pageSize int, filter biz.PaymentOrderFilter) ([]*biz.PaymentOrder, int64, error) { + db := r.data.DB().WithContext(ctx).Model(&paymentOrderPO{}) + if value := strings.TrimSpace(filter.Provider); value != "" { + db = db.Where("provider = ?", value) + } + if value := strings.TrimSpace(filter.TradeNo); value != "" { + db = db.Where("trade_no LIKE ?", "%"+value+"%") + } + if value := strings.TrimSpace(filter.BusinessType); value != "" { + db = db.Where("business_type = ?", value) + } + if value := strings.TrimSpace(filter.BusinessID); value != "" { + db = db.Where("business_id LIKE ?", "%"+value+"%") + } + if value := strings.TrimSpace(filter.PaymentStatus); value != "" { + db = db.Where("payment_status = ?", value) + } + if value := strings.TrimSpace(filter.RefundStatus); value != "" { + db = db.Where("refund_status = ?", value) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []paymentOrderPO + if err := pagination.ApplyRequired(db.Order("id desc"), page, pageSize, 100).Find(&rows).Error; err != nil { + return nil, 0, err + } + items := make([]*biz.PaymentOrder, 0, len(rows)) + for i := range rows { + items = append(items, toBizPaymentOrder(&rows[i])) + } + return items, total, nil +} + func (r *paymentOrderRepo) RecordPaymentCreate(ctx context.Context, provider, tradeNo string, update *biz.PaymentProviderUpdate) (*biz.PaymentOrder, error) { return r.withLockedOrder(ctx, provider, tradeNo, func(tx *gorm.DB, po *paymentOrderPO) error { if update == nil { @@ -396,7 +432,7 @@ func (r *paymentOrderRepo) ConfirmPaymentRefund(ctx context.Context, provider, t func (r *paymentOrderRepo) withLockedOrder(ctx context.Context, provider, tradeNo string, fn func(*gorm.DB, *paymentOrderPO) error) (*biz.PaymentOrder, error) { var result *biz.PaymentOrder - err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var po paymentOrderPO if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("provider = ? AND trade_no = ?", provider, tradeNo).First(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/data/payment_order_test.go b/internal/data/payment/payment_order_test.go similarity index 82% rename from internal/data/payment_order_test.go rename to internal/data/payment/payment_order_test.go index 6e92eac..af5ced5 100644 --- a/internal/data/payment_order_test.go +++ b/internal/data/payment/payment_order_test.go @@ -1,4 +1,4 @@ -package data +package payment import ( "context" @@ -91,3 +91,27 @@ func TestPaymentOrderRepositoryRejectsProviderTradeReuse(t *testing.T) { t.Fatalf("provider trade reuse err = %v", err) } } + +func TestPaymentOrderRepositoryListsWithFilters(t *testing.T) { + repo := newPaymentOrderRepoForTest(t) + ctx := context.Background() + first := testPaymentOrder() + if _, _, err := repo.CreatePaymentOrder(ctx, first); err != nil { + t.Fatal(err) + } + second := testPaymentOrder() + second.TradeNo = "wechat-order-2" + second.Provider = biz.PaymentWechatV3 + second.BusinessID = "item-2" + second.ConfirmationID = "33333333-3333-3333-3333-333333333333" + if _, _, err := repo.CreatePaymentOrder(ctx, second); err != nil { + t.Fatal(err) + } + items, total, err := repo.ListPaymentOrders(ctx, 1, 10, biz.PaymentOrderFilter{Provider: biz.PaymentWechatV3, TradeNo: "wechat", BusinessID: "item-2"}) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(items) != 1 || items[0].TradeNo != second.TradeNo { + t.Fatalf("filtered orders = %#v total=%d", items, total) + } +} diff --git a/internal/data/payment/provider.go b/internal/data/payment/provider.go new file mode 100644 index 0000000..9c968c7 --- /dev/null +++ b/internal/data/payment/provider.go @@ -0,0 +1,11 @@ +package payment + +import ( + "gorm.io/gorm" +) + +// Provider is the narrow persistence seam required by payment repositories. +// Keeping it here lets payment remain an independent data module. +type Provider interface { + DB() *gorm.DB +} diff --git a/internal/data/payment/testing_support_test.go b/internal/data/payment/testing_support_test.go new file mode 100644 index 0000000..e8e714c --- /dev/null +++ b/internal/data/payment/testing_support_test.go @@ -0,0 +1,67 @@ +package payment + +import ( + "fmt" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "kra/internal/biz" +) + +type Data struct{ gormDB *reloadableDB } + +type reloadableDB struct{ db *gorm.DB } + +func newReloadableDB(db *gorm.DB, _ any) *reloadableDB { return &reloadableDB{db: db} } +func (r *reloadableDB) close() { + if r == nil || r.db == nil { + return + } + if sqlDB, err := r.db.DB(); err == nil { + _ = sqlDB.Close() + } +} +func (d *Data) DB() *gorm.DB { return d.gormDB.db } + +func openWithDriver(driver, dsn string) (*gorm.DB, error) { + if driver != "sqlite" { + return nil, fmt.Errorf("unsupported test database driver %q", driver) + } + return gorm.Open(sqlite.Open(dsn), &gorm.Config{}) +} + +func openIntegrationConfigTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&integrationConfigPO{}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func migrateAll(db *gorm.DB) error { + if err := db.AutoMigrate(&integrationConfigPO{}, &paymentOrderPO{}); err != nil { + return err + } + for _, provider := range biz.SupportedPaymentProviders { + var count int64 + if err := db.Model(&integrationConfigPO{}).Where("kind = ? AND provider = ?", integrationKindPayment, provider).Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := db.Create(&integrationConfigPO{Kind: integrationKindPayment, Provider: provider, Config: "{}"}).Error; err != nil { + return err + } + } + } + return nil +} diff --git a/internal/data/announcement.go b/internal/data/system/announcement.go similarity index 72% rename from internal/data/announcement.go rename to internal/data/system/announcement.go index c1fa2df..c847bfd 100644 --- a/internal/data/announcement.go +++ b/internal/data/system/announcement.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -6,6 +6,8 @@ import ( "time" "kra/internal/biz" + "kra/pkg/gormkit" + "kra/pkg/pagination" "gorm.io/gorm" ) @@ -18,17 +20,17 @@ type announcementPO struct { Title string Content string `gorm:"type:text"` UserID *int `gorm:"column:user_id"` - Attachments jsonPO + Attachments gormkit.JSON } func (announcementPO) TableName() string { return "kra_announcements_info" } -type announcementRepo struct{ data *Data } +type announcementRepo struct{ data Provider } -func NewAnnouncementRepo(data *Data) biz.AnnouncementRepo { return &announcementRepo{data: data} } +func NewAnnouncementRepo(data Provider) biz.AnnouncementRepo { return &announcementRepo{data: data} } func newAnnouncement(item *biz.Announcement) announcementPO { - return announcementPO{ID: item.ID, Title: item.Title, Content: item.Content, UserID: item.UserID, Attachments: jsonPO(item.Attachments)} + return announcementPO{ID: item.ID, Title: item.Title, Content: item.Content, UserID: item.UserID, Attachments: gormkit.JSON(item.Attachments)} } func announcementToBiz(item announcementPO) *biz.Announcement { @@ -37,7 +39,7 @@ func announcementToBiz(item announcementPO) *biz.Announcement { func (r *announcementRepo) Create(ctx context.Context, item *biz.Announcement) error { po := newAnnouncement(item) - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } item.ID, item.CreatedAt, item.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt @@ -45,28 +47,28 @@ func (r *announcementRepo) Create(ctx context.Context, item *biz.Announcement) e } func (r *announcementRepo) Delete(ctx context.Context, id string) error { - return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, "id = ?", id).Error + return r.data.DB().WithContext(ctx).Delete(&announcementPO{}, "id = ?", id).Error } func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []string) error { - return r.data.gormDB.WithContext(ctx).Delete(&[]announcementPO{}, "id IN ?", ids).Error + return r.data.DB().WithContext(ctx).Delete(&[]announcementPO{}, "id IN ?", ids).Error } func (r *announcementRepo) Update(ctx context.Context, item *biz.Announcement) error { po := newAnnouncement(item) - return r.data.gormDB.WithContext(ctx).Model(&announcementPO{}).Where("id = ?", item.ID).Updates(&po).Error + return r.data.DB().WithContext(ctx).Model(&announcementPO{}).Where("id = ?", item.ID).Updates(&po).Error } func (r *announcementRepo) Find(ctx context.Context, id string) (*biz.Announcement, error) { var po announcementPO - if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { return nil, err } return announcementToBiz(po), nil } func (r *announcementRepo) List(ctx context.Context, filter biz.AnnouncementFilter) ([]*biz.Announcement, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&announcementPO{}) + db := r.data.DB().WithContext(ctx).Model(&announcementPO{}) if filter.StartCreatedAt != nil && filter.EndCreatedAt != nil { db = db.Where("created_at BETWEEN ? AND ?", filter.StartCreatedAt, filter.EndCreatedAt) } @@ -75,7 +77,7 @@ func (r *announcementRepo) List(ctx context.Context, filter biz.AnnouncementFilt return nil, 0, err } if filter.PageSize > 0 { - db = applyPagination(db, filter.Page, filter.PageSize, 100) + db = pagination.Apply(db, filter.Page, filter.PageSize, 100) } var pos []announcementPO if err := db.Find(&pos).Error; err != nil { @@ -95,7 +97,7 @@ func (r *announcementRepo) UserOptions(ctx context.Context) ([]biz.UserOption, e } // The generated data-source endpoint is best effort: return collected // options even when the underlying scan reports an error. - _ = r.data.gormDB.WithContext(ctx).Table("sys_users").Select("nick_name AS label, id AS value").Scan(&rows).Error + _ = r.data.DB().WithContext(ctx).Table("sys_users").Select("nick_name AS label, id AS value").Scan(&rows).Error items := make([]biz.UserOption, 0, len(rows)) for _, row := range rows { items = append(items, biz.UserOption{Label: row.Label, Value: row.Value}) diff --git a/internal/data/announcement_test.go b/internal/data/system/announcement_test.go similarity index 99% rename from internal/data/announcement_test.go rename to internal/data/system/announcement_test.go index d9687cb..1623352 100644 --- a/internal/data/announcement_test.go +++ b/internal/data/system/announcement_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/api.go b/internal/data/system/api.go similarity index 85% rename from internal/data/api.go rename to internal/data/system/api.go index 90168be..6a11bc8 100644 --- a/internal/data/api.go +++ b/internal/data/system/api.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -7,13 +7,14 @@ import ( "time" "kra/internal/biz" + "kra/pkg/pagination" "gorm.io/gorm" ) -type apiRepo struct{ data *Data } +type apiRepo struct{ data Provider } -func NewAPIRepo(data *Data) biz.APIRepo { return &apiRepo{data: data} } +func NewAPIRepo(data Provider) biz.APIRepo { return &apiRepo{data: data} } type apiPO struct { ID uint `gorm:"primaryKey"` @@ -52,20 +53,20 @@ func apiFromPO(po apiPO) *biz.API { func (r *apiRepo) CreateAPI(ctx context.Context, v *biz.API) error { po := apiPO{Path: v.Path, Description: v.Description, APIGroup: v.APIGroup, Method: v.Method} var count int64 - if err := r.data.gormDB.WithContext(ctx).Model(&apiPO{}).Where("path = ? AND method = ?", po.Path, po.Method).Count(&count).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&apiPO{}).Where("path = ? AND method = ?", po.Path, po.Method).Count(&count).Error; err != nil { return err } if count > 0 { return errors.New("存在相同api") } - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID, v.CreatedAt, v.UpdatedAt, v.Method = po.ID, po.CreatedAt, po.UpdatedAt, po.Method return nil } func (r *apiRepo) UpdateAPI(ctx context.Context, v *biz.API) error { - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var old apiPO if err := db.First(&old, v.ID).Error; err != nil { return err @@ -90,7 +91,7 @@ func (r *apiRepo) UpdateAPI(ctx context.Context, v *biz.API) error { return db.Model(&old).Updates(map[string]any{"path": v.Path, "description": v.Description, "api_group": v.APIGroup, "method": method}).Error } func (r *apiRepo) DeleteAPIs(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var apis []apiPO if err := tx.Where("id IN ?", ids).Find(&apis).Error; err != nil { return err @@ -110,18 +111,18 @@ func (r *apiRepo) DeleteAPIs(ctx context.Context, ids []uint) error { } func (r *apiRepo) FindAPI(ctx context.Context, id uint) (*biz.API, error) { var po apiPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return apiFromPO(po), nil } func (r *apiRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]*biz.API, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&apiPO{}) + db := r.data.DB().WithContext(ctx).Model(&apiPO{}) if q != nil && q.StrictAll { - config := r.data.runtime.Admin() + config := r.data.Runtime().Admin() if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { var authority authorityPO - if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { return nil, 0, err } if authority.ParentID != nil && *authority.ParentID != 0 { @@ -167,7 +168,7 @@ func (r *apiRepo) ListAPIs(ctx context.Context, page, size int, q *biz.API) ([]* var pos []apiPO query := db.Order(order) if q == nil || !q.StrictAll { - query = applyRequiredPagination(query, page, size, 100) + query = pagination.ApplyRequired(query, page, size, 100) } if err := query.Find(&pos).Error; err != nil { return nil, 0, err diff --git a/internal/data/api_policy.go b/internal/data/system/api_policy.go similarity index 88% rename from internal/data/api_policy.go rename to internal/data/system/api_policy.go index 1b35cc4..95a4eb8 100644 --- a/internal/data/api_policy.go +++ b/internal/data/system/api_policy.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -15,7 +15,7 @@ import ( func (r *apiRepo) APIRoleIDs(ctx context.Context, path, method string) ([]uint, error) { rows := make([]casbinRulePO, 0) - err := policyScope(r.data.gormDB.WithContext(ctx)). + err := policyScope(r.data.DB().WithContext(ctx)). Where("v1 = ? AND v2 = ?", path, method). Find(&rows).Error if err != nil { @@ -47,7 +47,7 @@ func (r *apiRepo) SetAPIRoles(ctx context.Context, path, method string, ids []ui return err } } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if strict { authorityIDs := make([]string, 0, len(allowedAuthorities)) for authorityID := range allowedAuthorities { @@ -74,11 +74,11 @@ func (r *apiRepo) SetAPIRoles(ctx context.Context, path, method string, ids []ui func (r *apiRepo) CheckPolicyStore(ctx context.Context) error { var count int64 - return r.data.gormDB.WithContext(ctx).Model(&casbinRulePO{}).Count(&count).Error + return r.data.DB().WithContext(ctx).Model(&casbinRulePO{}).Count(&count).Error } func (r *apiRepo) Authorize(ctx context.Context, aid uint, path, method string) (bool, error) { - rows, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), aid) + rows, err := policyRowsForAuthority(r.data.DB().WithContext(ctx), aid) if err != nil { return false, err } @@ -106,7 +106,7 @@ m = r.sub == p.sub && keyMatch2(r.obj, p.obj) && r.act == p.act`) return enforcer.Enforce(subject, path, method) } func (r *apiRepo) PolicyPaths(ctx context.Context, aid uint) ([]*biz.API, error) { - rows, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), aid) + rows, err := policyRowsForAuthority(r.data.DB().WithContext(ctx), aid) if err != nil { return nil, err } @@ -126,7 +126,7 @@ func (r *apiRepo) SetPolicyPaths(ctx context.Context, aid uint, paths []*biz.API if err := r.checkPolicyPathsAuth(ctx, paths); err != nil { return err } - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) // The reference enforcer removes the old authority policies before it // attempts to add the replacement set. Keep that ordering visible even // though Kra reads policies directly from the database rather than through @@ -161,11 +161,11 @@ func (r *apiRepo) checkPolicyPathsAuth(ctx context.Context, paths []*biz.API) er return err } var authority authorityPO - if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { return err } var registered []apiPO - if err := r.data.gormDB.WithContext(ctx).Find(®istered).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Find(®istered).Error; err != nil { return err } allowedSet := make(map[string]bool, len(registered)) @@ -174,7 +174,7 @@ func (r *apiRepo) checkPolicyPathsAuth(ctx context.Context, paths []*biz.API) er allowedSet[item.Path+"\x00"+item.Method] = true } } else { - policies, err := policyRowsForAuthority(r.data.gormDB.WithContext(ctx), actor.AuthorityID) + policies, err := policyRowsForAuthority(r.data.DB().WithContext(ctx), actor.AuthorityID) if err != nil { return err } diff --git a/internal/data/api_policy_test.go b/internal/data/system/api_policy_test.go similarity index 99% rename from internal/data/api_policy_test.go rename to internal/data/system/api_policy_test.go index 7a8a3bf..2d87813 100644 --- a/internal/data/api_policy_test.go +++ b/internal/data/system/api_policy_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/api_sync.go b/internal/data/system/api_sync.go similarity index 83% rename from internal/data/api_sync.go rename to internal/data/system/api_sync.go index 31fb0ab..e3aa634 100644 --- a/internal/data/api_sync.go +++ b/internal/data/system/api_sync.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -10,7 +10,7 @@ import ( func (r *apiRepo) IgnoredAPIs(ctx context.Context) ([]*biz.API, error) { var pos []ignoredAPIPO - if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Find(&pos).Error; err != nil { return nil, err } out := make([]*biz.API, 0, len(pos)) @@ -24,12 +24,12 @@ func (r *apiRepo) SetAPIIgnored(ctx context.Context, path, method string, ignore if ignored { // The compatible endpoint creates an ignore row on every request (the table has no // path/method uniqueness constraint); retain that observable behavior. - return r.data.gormDB.WithContext(ctx).Create(&po).Error + return r.data.DB().WithContext(ctx).Create(&po).Error } - return r.data.gormDB.WithContext(ctx).Unscoped().Where("path = ? AND method = ?", po.Path, po.Method).Delete(&ignoredAPIPO{}).Error + return r.data.DB().WithContext(ctx).Unscoped().Where("path = ? AND method = ?", po.Path, po.Method).Delete(&ignoredAPIPO{}).Error } func (r *apiRepo) ApplyAPISync(ctx context.Context, added, deleted []*biz.API) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if len(added) > 0 { pos := make([]apiPO, 0, len(added)) for _, item := range added { diff --git a/internal/data/api_token.go b/internal/data/system/api_token.go similarity index 76% rename from internal/data/api_token.go rename to internal/data/system/api_token.go index 85242a6..347f635 100644 --- a/internal/data/api_token.go +++ b/internal/data/system/api_token.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -6,13 +6,14 @@ import ( "time" "kra/internal/biz" + "kra/pkg/pagination" "gorm.io/gorm" ) -type apiTokenRepo struct{ data *Data } +type apiTokenRepo struct{ data Provider } -func NewAPITokenRepo(data *Data) biz.APITokenRepo { return &apiTokenRepo{data: data} } +func NewAPITokenRepo(data Provider) biz.APITokenRepo { return &apiTokenRepo{data: data} } type apiTokenPO struct { ID uint `gorm:"primaryKey"` @@ -41,14 +42,14 @@ func (jwtBlacklistPO) TableName() string { return "jwt_blacklists" } func (r *apiTokenRepo) UserHasAuthority(ctx context.Context, userID, authorityID uint) (*biz.User, bool, error) { var po userPO - if err := r.data.gormDB.WithContext(ctx).First(&po, userID).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, userID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, errors.New("用户不存在") } return nil, false, err } var count int64 - err := r.data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}). + err := r.data.DB().WithContext(ctx).Model(&userAuthorityPO{}). Joins("JOIN sys_authorities ON sys_authorities.authority_id = sys_user_authority.sys_authority_authority_id"). Where("sys_user_authority.sys_user_id = ? AND sys_user_authority.sys_authority_authority_id = ? AND sys_authorities.deleted_at IS NULL", userID, authorityID). Count(&count).Error @@ -60,7 +61,7 @@ func (r *apiTokenRepo) UserHasAuthority(ctx context.Context, userID, authorityID } func (r *apiTokenRepo) CreateAPIToken(ctx context.Context, v *biz.APIToken) error { po := apiTokenPO{UserID: v.UserID, AuthorityID: v.AuthorityID, Token: v.Token, Status: v.Status, ExpiresAt: v.ExpiresAt, Remark: v.Remark} - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID = po.ID @@ -68,7 +69,7 @@ func (r *apiTokenRepo) CreateAPIToken(ctx context.Context, v *biz.APIToken) erro return nil } func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID uint, status *bool) ([]*biz.APIToken, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&apiTokenPO{}) + db := r.data.DB().WithContext(ctx).Model(&apiTokenPO{}) if userID != 0 { db = db.Where("user_id = ?", userID) } @@ -80,7 +81,7 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID return nil, 0, err } var pos []apiTokenPO - if err := applyRequiredPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.ApplyRequired(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } userIDs := make([]uint, 0, len(pos)) @@ -89,7 +90,7 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID } var userPOs []userPO if len(userIDs) > 0 { - if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", userIDs).Find(&userPOs).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("id IN ?", userIDs).Find(&userPOs).Error; err != nil { return nil, 0, err } } @@ -106,10 +107,10 @@ func (r *apiTokenRepo) ListAPITokens(ctx context.Context, page, size int, userID } func (r *apiTokenRepo) DisableAPIToken(ctx context.Context, id uint) (string, error) { var po apiTokenPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return "", err } - return po.Token, r.data.gormDB.WithContext(ctx).Model(&po).Update("status", false).Error + return po.Token, r.data.DB().WithContext(ctx).Model(&po).Update("status", false).Error } func (r *apiTokenRepo) DisableAndBlacklistAPIToken(ctx context.Context, id uint) error { // Persist the blacklist row first, then mark the @@ -117,20 +118,20 @@ func (r *apiTokenRepo) DisableAndBlacklistAPIToken(ctx context.Context, id uint) // the reference implementation, so a storage error has the same observable // result here. var po apiTokenPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return err } if err := r.BlacklistToken(ctx, po.Token); err != nil { return err } - return r.data.gormDB.WithContext(ctx).Model(&po).Update("status", false).Error + return r.data.DB().WithContext(ctx).Model(&po).Update("status", false).Error } func (r *apiTokenRepo) BlacklistToken(ctx context.Context, token string) error { - return r.data.gormDB.WithContext(ctx).Create(&jwtBlacklistPO{JWT: token}).Error + return r.data.DB().WithContext(ctx).Create(&jwtBlacklistPO{JWT: token}).Error } func (r *apiTokenRepo) IsTokenDisabled(ctx context.Context, token string) (bool, error) { var blacklistCount int64 - if err := r.data.gormDB.WithContext(ctx).Model(&jwtBlacklistPO{}).Where("jwt = ?", token).Count(&blacklistCount).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&jwtBlacklistPO{}).Where("jwt = ?", token).Count(&blacklistCount).Error; err != nil { return false, err } // The JWT middleware consults only jwt_blacklists and lets JWT parsing diff --git a/internal/data/system/audit.go b/internal/data/system/audit.go new file mode 100644 index 0000000..e10dc88 --- /dev/null +++ b/internal/data/system/audit.go @@ -0,0 +1,13 @@ +package system + +import "kra/internal/biz" + +type auditQueryRepo struct{ data Provider } +type auditRecorderRepo struct{ data Provider } +type logFileRepo struct{ data Provider } + +func NewAuditRepo(data Provider) biz.AuditQueryRepo { return &auditQueryRepo{data: data} } + +func NewAuditRecorderRepo(data Provider) biz.AuditRecordRepo { return &auditRecorderRepo{data: data} } + +func NewLogFileRepo(data Provider) biz.LogFileRepo { return &logFileRepo{data: data} } diff --git a/internal/data/audit_delete_test.go b/internal/data/system/audit_delete_test.go similarity index 97% rename from internal/data/audit_delete_test.go rename to internal/data/system/audit_delete_test.go index cfc0332..fda27bf 100644 --- a/internal/data/audit_delete_test.go +++ b/internal/data/system/audit_delete_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/authority.go b/internal/data/system/authority.go similarity index 91% rename from internal/data/authority.go rename to internal/data/system/authority.go index b86f277..2eae127 100644 --- a/internal/data/authority.go +++ b/internal/data/system/authority.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -9,9 +9,11 @@ import ( "gorm.io/gorm" ) -type authorityAccessRepo struct{ data *Data } +var errInvalidDataScope = errors.New("数据权限范围不合法") -func NewAuthorityAccessRepo(data *Data) biz.AuthorityAccessRepo { +type authorityAccessRepo struct{ data Provider } + +func NewAuthorityAccessRepo(data Provider) biz.AuthorityAccessRepo { return &authorityAccessRepo{data: data} } @@ -21,11 +23,11 @@ func (r *authorityAccessRepo) strictAuthorityIDs(ctx context.Context, actorID ui return allowed, nil } var actor authorityPO - if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actorID).First(&actor).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("authority_id = ?", actorID).First(&actor).Error; err != nil { return nil, err } var authorities []authorityPO - if err := r.data.gormDB.WithContext(ctx).Find(&authorities).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Find(&authorities).Error; err != nil { return nil, err } children := make(map[uint][]uint) @@ -57,7 +59,7 @@ func (r *authorityAccessRepo) strictAuthorityIDs(ctx context.Context, actorID ui } func (r *authorityAccessRepo) strictAuthorityAccess(ctx context.Context) (biz.Actor, map[uint]bool, bool, error) { - config := r.data.runtime.Admin() + config := r.data.Runtime().Admin() if config == nil || config.System == nil || !config.System.UseStrictAuth { return biz.Actor{}, nil, false, nil } @@ -118,7 +120,7 @@ func (r *authorityAccessRepo) ensureAuthorityParentAcyclic(ctx context.Context, } visited[current] = struct{}{} var authority authorityPO - if err := r.data.gormDB.WithContext(ctx).Select("authority_id", "parent_id").Where("authority_id = ?", current).First(&authority).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Select("authority_id", "parent_id").Where("authority_id = ?", current).First(&authority).Error; err != nil { return err } if authority.ParentID == nil { @@ -148,7 +150,7 @@ func (r *authorityAccessRepo) CreateAuthority(ctx context.Context, value *biz.Au return err } } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { // The administration contract checks existence with First and treats every result other than // ErrRecordNotFound as a duplicate-role error. Keep that precedence so // duplicate IDs win over strict-tree validation and the API message stays @@ -220,7 +222,7 @@ func (r *authorityAccessRepo) CopyAuthority(ctx context.Context, sourceID uint, if err != nil { return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Reject a duplicate target ID before performing the // hierarchy/Casbin checks that happen later in UpdateCasbin. var existing authorityPO @@ -393,7 +395,7 @@ func (r *authorityAccessRepo) UpdateAuthority(ctx context.Context, value *biz.Au if err = r.ensureAuthorityParentAcyclic(ctx, value.AuthorityID, value.ParentID); err != nil { return err } - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var current authorityPO if err := db.Where("authority_id = ?", value.AuthorityID).First(¤t).Error; err != nil { return errors.New("查询角色数据失败") @@ -405,7 +407,7 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro if err := r.checkAuthorityIDAuth(ctx, id); err != nil { return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var authority authorityPO if err := tx.Where("authority_id = ?", id).First(&authority).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -463,9 +465,9 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro }) } func (r *authorityAccessRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) { - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var allowed map[uint]bool - config := r.data.runtime.Admin() + config := r.data.Runtime().Admin() if actor, ok := biz.ActorFromContext(ctx); ok { // The current authority is loaded even when strict mode is disabled; // an invalid token authority therefore fails the list request instead of @@ -504,7 +506,7 @@ func (r *authorityAccessRepo) SetAuthorityUsers(ctx context.Context, id uint, id if strict && !allowed[id] { return errors.New("您提交的角色ID不合法") } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var oldIDs []uint if err := tx.Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_user_id", &oldIDs).Error; err != nil { return err @@ -609,7 +611,7 @@ func (r *authorityAccessRepo) checkUserIDAuth(ctx context.Context, id uint, allo if allowSelf && actor.UserID != 0 && actor.UserID == id { return nil } - return checkManagedUserIDs(r.data.gormDB.WithContext(ctx), []uint{id}, allowed, true) + return checkManagedUserIDs(r.data.DB().WithContext(ctx), []uint{id}, allowed, true) } func (r *authorityAccessRepo) checkDepartmentIDsAuth(ctx context.Context, ids []uint) error { @@ -621,7 +623,7 @@ func (r *authorityAccessRepo) checkDepartmentIDsAuth(ctx context.Context, ids [] return nil } var authority authorityPO - if err := r.data.gormDB.WithContext(ctx).Select("authority_id", "data_scope").Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Select("authority_id", "data_scope").Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { return err } if authority.DataScope == 1 { @@ -645,7 +647,7 @@ func (r *authorityAccessRepo) checkDepartmentIDsAuth(ctx context.Context, ids [] func (r *authorityAccessRepo) AuthorityUserIDs(ctx context.Context, id uint) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_user_id", &ids).Error + err := r.data.DB().WithContext(ctx).Model(&userAuthorityPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_user_id", &ids).Error return ids, err } func (r *authorityAccessRepo) SetDataScope(ctx context.Context, id uint, scope int, deptIDs []uint) error { @@ -658,7 +660,7 @@ func (r *authorityAccessRepo) SetDataScope(ctx context.Context, id uint, scope i if err := r.checkDataScopeGrant(ctx, scope, deptIDs); err != nil { return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&authorityPO{}).Where("authority_id = ?", id).Update("data_scope", scope).Error; err != nil { return err } @@ -693,7 +695,7 @@ func (r *authorityAccessRepo) checkDataScopeGrant(ctx context.Context, requested } if len(requestedDepartments) > 0 { var count int64 - if err := r.data.gormDB.WithContext(ctx).Model(&departmentPO{}).Where("id IN ?", departmentIDs).Count(&count).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&departmentPO{}).Where("id IN ?", departmentIDs).Count(&count).Error; err != nil { return err } if count != int64(len(requestedDepartments)) { @@ -705,7 +707,7 @@ func (r *authorityAccessRepo) checkDataScopeGrant(ctx context.Context, requested return nil } var actorAuthority authorityPO - if err := r.data.gormDB.WithContext(ctx).Select("authority_id", "data_scope").Where("authority_id = ?", actor.AuthorityID).First(&actorAuthority).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Select("authority_id", "data_scope").Where("authority_id = ?", actor.AuthorityID).First(&actorAuthority).Error; err != nil { return err } grantable := false @@ -747,18 +749,18 @@ func (r *authorityAccessRepo) checkDataScopeGrant(ctx context.Context, requested func (r *authorityAccessRepo) DataScopeDepartmentIDs(ctx context.Context, id uint) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_department_id", &ids).Error + err := r.data.DB().WithContext(ctx).Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_department_id", &ids).Error return ids, err } func (r *authorityAccessRepo) ResolveDataScope(ctx context.Context, authorityID, userID uint) (biz.DataScope, error) { identity := biz.DataScope{UserID: userID, AuthorityID: authorityID} var user userPO - if err := r.data.gormDB.WithContext(ctx).Select("id", "dept_id").First(&user, userID).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Select("id", "dept_id").First(&user, userID).Error; err != nil { return identity, err } identity.PrimaryDeptID = user.DeptID var authority authorityPO - if err := r.data.gormDB.WithContext(ctx).Select("authority_id", "data_scope").First(&authority, "authority_id = ?", authorityID).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Select("authority_id", "data_scope").First(&authority, "authority_id = ?", authorityID).Error; err != nil { return identity, err } identity.Scope = authority.DataScope @@ -772,7 +774,7 @@ func (r *authorityAccessRepo) ResolveDataScope(ctx context.Context, authorityID, var ids []uint selected := make(map[uint]bool) if identity.Scope == 2 || identity.Scope == 3 { - if err := r.data.gormDB.WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_user_id = ?", userID).Pluck("sys_department_id", &ids).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_user_id = ?", userID).Pluck("sys_department_id", &ids).Error; err != nil { return identity, err } selected = make(map[uint]bool, len(ids)+1) @@ -792,7 +794,7 @@ func (r *authorityAccessRepo) ResolveDataScope(ctx context.Context, authorityID, // parent_id edges at read time, rather than trusting the denormalized // ancestors string (which may be stale after a department move). var departments []departmentPO - if err := r.data.gormDB.WithContext(ctx).Select("id", "parent_id").Find(&departments).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Select("id", "parent_id").Find(&departments).Error; err != nil { return identity, err } children := make(map[uint][]uint, len(departments)) diff --git a/internal/data/authority_test.go b/internal/data/system/authority_test.go similarity index 99% rename from internal/data/authority_test.go rename to internal/data/system/authority_test.go index f9b441c..cec035d 100644 --- a/internal/data/authority_test.go +++ b/internal/data/system/authority_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/system/bootstrap.go b/internal/data/system/bootstrap.go index 0ddff95..b7a4cd5 100644 --- a/internal/data/system/bootstrap.go +++ b/internal/data/system/bootstrap.go @@ -10,6 +10,31 @@ type IgnoredAPI struct { Path string } +// AdminMenu describes a menu entry contributed by a module. ParentName is +// resolved by the system persistence layer so feature modules do not need to +// know the menu PO shape. +type AdminMenu struct { + Name string + Path string + ParentName string + Component string + Title string + Icon string + Sort int +} + +type AdminAPI struct { + Path string + Method string + APIGroup string + Description string +} + +type AdminSurface struct { + Menus []AdminMenu + APIs []AdminAPI +} + func DefaultIgnoredAPIs(staticPath string) []IgnoredAPI { staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath" return []IgnoredAPI{ diff --git a/internal/data/casbin.go b/internal/data/system/casbin.go similarity index 99% rename from internal/data/casbin.go rename to internal/data/system/casbin.go index baef4fc..8495151 100644 --- a/internal/data/casbin.go +++ b/internal/data/system/casbin.go @@ -1,4 +1,4 @@ -package data +package system import ( "strconv" diff --git a/internal/data/data_access_log.go b/internal/data/system/data_access_log.go similarity index 75% rename from internal/data/data_access_log.go rename to internal/data/system/data_access_log.go index 98194d1..5343458 100644 --- a/internal/data/data_access_log.go +++ b/internal/data/system/data_access_log.go @@ -1,10 +1,11 @@ -package data +package system import ( "context" "time" "kra/internal/biz" + "kra/pkg/pagination" "gorm.io/gorm" ) @@ -23,13 +24,13 @@ type dataAccessLogPO struct { func (dataAccessLogPO) TableName() string { return "sys_data_access_logs" } func (r *auditRecorderRepo) RecordDataAccess(ctx context.Context, v *biz.DataAccessLog) error { - return r.data.gormDB.WithContext(ctx).Create(&dataAccessLogPO{EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail}).Error + return r.data.DB().WithContext(ctx).Create(&dataAccessLogPO{EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail}).Error } func dataAccessFromPO(v dataAccessLogPO) *biz.DataAccessLog { return &biz.DataAccessLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, EventType: v.EventType, TargetTable: v.TargetTable, Operation: v.Operation, UserID: v.UserID, AuthorityID: v.AuthorityID, Scope: v.Scope, RequestID: v.RequestID, Method: v.Method, Path: v.Path, Detail: v.Detail} } func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q *biz.DataAccessLog) ([]*biz.DataAccessLog, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&dataAccessLogPO{}) + db := r.data.DB().WithContext(ctx).Model(&dataAccessLogPO{}) if q != nil { if q.EventType != "" { db = db.Where("event_type = ?", q.EventType) @@ -43,7 +44,7 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q * return nil, 0, err } var pos []dataAccessLogPO - if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.ApplyRequired(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.DataAccessLog, 0, len(pos)) @@ -53,5 +54,5 @@ func (r *auditQueryRepo) ListDataAccess(ctx context.Context, page, size int, q * return out, total, nil } func (r *auditQueryRepo) DeleteDataAccess(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Delete(&dataAccessLogPO{}, "id IN ?", ids).Error + return r.data.DB().WithContext(ctx).Delete(&dataAccessLogPO{}, "id IN ?", ids).Error } diff --git a/internal/data/department.go b/internal/data/system/department.go similarity index 80% rename from internal/data/department.go rename to internal/data/system/department.go index 5826fb6..0a4cf19 100644 --- a/internal/data/department.go +++ b/internal/data/system/department.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -12,9 +12,9 @@ import ( "gorm.io/gorm" ) -type departmentRepo struct{ data *Data } +type departmentRepo struct{ data Provider } -func NewDepartmentRepo(data *Data) biz.DepartmentRepo { return &departmentRepo{data: data} } +func NewDepartmentRepo(data Provider) biz.DepartmentRepo { return &departmentRepo{data: data} } type departmentPO struct { ID uint `gorm:"primaryKey"` @@ -62,7 +62,7 @@ func (r *departmentRepo) attachDepartmentLeaders(ctx context.Context, department return nil } var leaders []userPO - if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", leaderIDs).Find(&leaders).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("id IN ?", leaderIDs).Find(&leaders).Error; err != nil { return err } leaderByID := make(map[uint]*biz.User, len(leaders)) @@ -78,7 +78,7 @@ func (r *departmentRepo) CreateDepartment(ctx context.Context, v *biz.Department v.Ancestors = "0" if v.ParentID != 0 { var parent departmentPO - if err := r.data.gormDB.WithContext(ctx).First(&parent, v.ParentID).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&parent, v.ParentID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("父部门不存在") } @@ -86,7 +86,7 @@ func (r *departmentRepo) CreateDepartment(ctx context.Context, v *biz.Department } v.Ancestors = strings.Trim(parent.Ancestors+","+strconv.FormatUint(uint64(parent.ID), 10), ",") } - return r.data.gormDB.WithContext(ctx).Create(&departmentPO{Name: v.Name, ParentID: v.ParentID, Ancestors: v.Ancestors, Sort: v.Sort, LeaderID: v.LeaderID, Status: v.Status}).Error + return r.data.DB().WithContext(ctx).Create(&departmentPO{Name: v.Name, ParentID: v.ParentID, Ancestors: v.Ancestors, Sort: v.Sort, LeaderID: v.LeaderID, Status: v.Status}).Error } func (r *departmentRepo) UpdateDepartment(ctx context.Context, v *biz.Department) error { if v.ParentID == v.ID { @@ -95,7 +95,7 @@ func (r *departmentRepo) UpdateDepartment(ctx context.Context, v *biz.Department v.Ancestors = "0" if v.ParentID != 0 { var parent departmentPO - if err := r.data.gormDB.WithContext(ctx).First(&parent, v.ParentID).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&parent, v.ParentID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("父部门不存在") } @@ -103,36 +103,36 @@ func (r *departmentRepo) UpdateDepartment(ctx context.Context, v *biz.Department } v.Ancestors = strings.Trim(parent.Ancestors+","+strconv.FormatUint(uint64(parent.ID), 10), ",") } - return r.data.gormDB.WithContext(ctx).Model(&departmentPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "parent_id": v.ParentID, "ancestors": v.Ancestors, "sort": v.Sort, "leader_id": v.LeaderID, "status": v.Status}).Error + return r.data.DB().WithContext(ctx).Model(&departmentPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "parent_id": v.ParentID, "ancestors": v.Ancestors, "sort": v.Sort, "leader_id": v.LeaderID, "status": v.Status}).Error } func (r *departmentRepo) DeleteDepartment(ctx context.Context, id uint) error { if id == 0 { return errors.New("部门ID不能为空") } var count int64 - if err := r.data.gormDB.WithContext(ctx).Model(&departmentPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&departmentPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { return err } if count > 0 { return errors.New("存在子部门,不允许删除") } - if err := r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("dept_id = ?", id).Count(&count).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&userPO{}).Where("dept_id = ?", id).Count(&count).Error; err != nil { return err } if count > 0 { return errors.New("该部门下存在用户,不允许删除") } - if err := r.data.gormDB.WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_department_id = ?", id).Count(&count).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_department_id = ?", id).Count(&count).Error; err != nil { return err } if count > 0 { return errors.New("该部门下存在用户,不允许删除") } - return r.data.gormDB.WithContext(ctx).Delete(&departmentPO{}, id).Error + return r.data.DB().WithContext(ctx).Delete(&departmentPO{}, id).Error } func (r *departmentRepo) FindDepartment(ctx context.Context, id uint) (*biz.Department, error) { var po departmentPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } department := deptFromPO(po) @@ -143,7 +143,7 @@ func (r *departmentRepo) FindDepartment(ctx context.Context, id uint) (*biz.Depa } func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*biz.Department, error) { var pos []departmentPO - db := r.data.gormDB.WithContext(ctx).Order("sort") + db := r.data.DB().WithContext(ctx).Order("sort") if name != "" { db = db.Where("name LIKE ?", "%"+name+"%") } @@ -182,7 +182,7 @@ func (r *departmentRepo) ListDepartments(ctx context.Context, name string) ([]*b } func (r *departmentRepo) DepartmentUserIDs(ctx context.Context, id uint) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_department_id = ?", id).Pluck("sys_user_id", &ids).Error + err := r.data.DB().WithContext(ctx).Model(&userDepartmentPO{}).Where("sys_department_id = ?", id).Pluck("sys_user_id", &ids).Error return ids, err } func (r *departmentRepo) SetDepartmentUsers(ctx context.Context, id uint, ids []uint) error { @@ -192,7 +192,7 @@ func (r *departmentRepo) replaceUserDepartments(ctx context.Context, id uint, id if id == 0 { return errors.New("部门ID不能为空") } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var current []uint if err := tx.Model(&userDepartmentPO{}).Where("sys_department_id = ?", id).Pluck("sys_user_id", ¤t).Error; err != nil { return err @@ -233,7 +233,7 @@ func (r *departmentRepo) replaceUserDepartments(ctx context.Context, id uint, id }) } func (r *departmentRepo) SetUserDepartments(ctx context.Context, uid uint, ids []uint, primary uint) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var user userPO if err := tx.First(&user, uid).Error; err != nil { return errors.New("查询用户数据失败") diff --git a/internal/data/dictionary.go b/internal/data/system/dictionary.go similarity index 85% rename from internal/data/dictionary.go rename to internal/data/system/dictionary.go index 42bd6bf..f0ffacd 100644 --- a/internal/data/dictionary.go +++ b/internal/data/system/dictionary.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -8,13 +8,14 @@ import ( "time" "kra/internal/biz" + "kra/pkg/pagination" "gorm.io/gorm" ) -type dictionaryRepo struct{ data *Data } +type dictionaryRepo struct{ data Provider } -func NewDictionaryRepo(data *Data) biz.DictionaryRepo { return &dictionaryRepo{data: data} } +func NewDictionaryRepo(data Provider) biz.DictionaryRepo { return &dictionaryRepo{data: data} } type dictionaryPO struct { ID uint `gorm:"primaryKey"` @@ -60,13 +61,13 @@ func parameterFromPO(po parameterPO) *biz.SystemParameter { func (r *dictionaryRepo) CreateDictionary(ctx context.Context, v *biz.Dictionary) error { var existing dictionaryPO - if err := r.data.gormDB.WithContext(ctx).Where("type = ?", v.Type).First(&existing).Error; err == nil { + if err := r.data.DB().WithContext(ctx).Where("type = ?", v.Type).First(&existing).Error; err == nil { return errors.New("存在相同的type,不允许创建") } else if !errors.Is(err, gorm.ErrRecordNotFound) { return err } po := dictionaryPO{Name: v.Name, Type: v.Type, Status: v.Status, Desc: v.Desc, ParentID: v.ParentID} - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID = po.ID @@ -76,10 +77,10 @@ func (r *dictionaryRepo) CreateDictionary(ctx context.Context, v *biz.Dictionary func (r *dictionaryRepo) ImportDictionary(ctx context.Context, dictionary *biz.Dictionary, details []*biz.DictionaryDetail) error { var existing dictionaryPO - if err := r.data.gormDB.WithContext(ctx).Where("type = ?", dictionary.Type).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + if err := r.data.DB().WithContext(ctx).Where("type = ?", dictionary.Type).First(&existing).Error; !errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("存在相同的type,不允许导入") } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { po := dictionaryPO{Name: dictionary.Name, Type: dictionary.Type, Status: dictionary.Status, Desc: dictionary.Desc, ParentID: dictionary.ParentID} if err := tx.Create(&po).Error; err != nil { return err @@ -119,7 +120,7 @@ func (r *dictionaryRepo) ImportDictionary(ctx context.Context, dictionary *biz.D }) } func (r *dictionaryRepo) UpdateDictionary(ctx context.Context, v *biz.Dictionary) error { - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var current dictionaryPO if err := db.Where("id = ?", v.ID).First(¤t).Error; err != nil { return errors.New("查询字典数据失败") @@ -148,7 +149,7 @@ func (r *dictionaryRepo) checkDictionaryCircularReference(ctx context.Context, c return errors.New("不能将字典设置为自己的父级") } var parent dictionaryPO - err := r.data.gormDB.WithContext(ctx).Where("id = ?", parentID).First(&parent).Error + err := r.data.DB().WithContext(ctx).Where("id = ?", parentID).First(&parent).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } @@ -161,7 +162,7 @@ func (r *dictionaryRepo) checkDictionaryCircularReference(ctx context.Context, c return nil } func (r *dictionaryRepo) DeleteDictionary(ctx context.Context, id uint) error { - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var dictionary dictionaryPO if err := db.First(&dictionary, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -176,7 +177,7 @@ func (r *dictionaryRepo) DeleteDictionary(ctx context.Context, id uint) error { } func (r *dictionaryRepo) FindDictionary(ctx context.Context, id uint, typ string, status *bool, details bool) (*biz.Dictionary, error) { var po dictionaryPO - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) active := true if status != nil { active = *status @@ -201,12 +202,12 @@ func (r *dictionaryRepo) FindDictionary(ctx context.Context, id uint, typ string func (r *dictionaryRepo) ExportDictionary(ctx context.Context, id uint) (*biz.Dictionary, error) { var po dictionaryPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } value := dictionaryFromPO(po) var detailPOs []dictionaryDetailPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", id).Order("sort").Find(&detailPOs).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_dictionary_id = ?", id).Order("sort").Find(&detailPOs).Error; err != nil { return nil, err } value.Details = make([]*biz.DictionaryDetail, 0, len(detailPOs)) @@ -216,7 +217,7 @@ func (r *dictionaryRepo) ExportDictionary(ctx context.Context, id uint) (*biz.Di return value, nil } func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, name, typ string, details bool) ([]*biz.Dictionary, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&dictionaryPO{}) + db := r.data.DB().WithContext(ctx).Model(&dictionaryPO{}) if name != "" { like := "%" + name + "%" db = db.Where("name LIKE ? OR type LIKE ?", like, like) @@ -245,7 +246,7 @@ func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, n childrenByParent := make(map[uint][]dictionaryPO) if !details && len(parentIDs) > 0 { var children []dictionaryPO - if err := r.data.gormDB.WithContext(ctx).Where("parent_id IN ?", parentIDs).Find(&children).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("parent_id IN ?", parentIDs).Find(&children).Error; err != nil { return nil, 0, err } for _, child := range children { @@ -257,7 +258,7 @@ func (r *dictionaryRepo) ListDictionaries(ctx context.Context, page, size int, n detailsByDictionary := make(map[uint][]dictionaryDetailPO) if details && len(dictionaryIDs) > 0 { var values []dictionaryDetailPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id IN ?", dictionaryIDs).Order("sort").Find(&values).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_dictionary_id IN ?", dictionaryIDs).Order("sort").Find(&values).Error; err != nil { return nil, 0, err } for _, value := range values { @@ -289,13 +290,13 @@ func (r *dictionaryRepo) CreateDictionaryDetail(ctx context.Context, v *biz.Dict po.Path = "" if v.ParentID != nil { var parent dictionaryDetailPO - if err := r.data.gormDB.WithContext(ctx).First(&parent, *v.ParentID).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&parent, *v.ParentID).Error; err != nil { return err } po.Level = parent.Level + 1 po.Path = strings.Trim(strings.Join([]string{parent.Path, fmt.Sprint(parent.ID)}, ","), ",") } - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID = po.ID @@ -305,7 +306,7 @@ func (r *dictionaryRepo) CreateDictionaryDetail(ctx context.Context, v *biz.Dict } func (r *dictionaryRepo) UpdateDictionaryDetail(ctx context.Context, v *biz.DictionaryDetail) error { var po dictionaryDetailPO - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) if err := db.First(&po, v.ID).Error; err != nil { return err } @@ -350,23 +351,23 @@ func (r *dictionaryRepo) updateDictionaryDetailChildren(db *gorm.DB, parentID ui } func (r *dictionaryRepo) DeleteDictionaryDetail(ctx context.Context, id uint) error { var count int64 - if err := r.data.gormDB.WithContext(ctx).Model(&dictionaryDetailPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&dictionaryDetailPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { return err } if count > 0 { return errors.New("该字典详情下还有子项,无法删除") } - return r.data.gormDB.WithContext(ctx).Delete(&dictionaryDetailPO{}, id).Error + return r.data.DB().WithContext(ctx).Delete(&dictionaryDetailPO{}, id).Error } func (r *dictionaryRepo) FindDictionaryDetail(ctx context.Context, id uint) (*biz.DictionaryDetail, error) { var po dictionaryDetailPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return detailFromPO(po), nil } func (r *dictionaryRepo) ListDictionaryDetails(ctx context.Context, page, size int, filter biz.DictionaryDetailFilter) ([]*biz.DictionaryDetail, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&dictionaryDetailPO{}) + db := r.data.DB().WithContext(ctx).Model(&dictionaryDetailPO{}) if filter.DictionaryID != 0 { db = db.Where("sys_dictionary_id = ?", filter.DictionaryID) } @@ -390,7 +391,7 @@ func (r *dictionaryRepo) ListDictionaryDetails(ctx context.Context, page, size i return nil, 0, err } var pos []dictionaryDetailPO - if err := applyRequiredPagination(db.Order("sort,id"), page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.ApplyRequired(db.Order("sort,id"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.DictionaryDetail, 0, len(pos)) @@ -405,13 +406,13 @@ func (r *dictionaryRepo) DictionaryDetailTree(ctx context.Context, dictionaryID // public dictionary lookup, it does not require the dictionary itself to // be enabled. var dictionary dictionaryPO - if err := r.data.gormDB.WithContext(ctx).Where("type = ?", typ).First(&dictionary).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("type = ?", typ).First(&dictionary).Error; err != nil { return nil, err } dictionaryID = dictionary.ID } var pos []dictionaryDetailPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ? AND parent_id IS NULL", dictionaryID).Order("sort").Find(&pos).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_dictionary_id = ? AND parent_id IS NULL", dictionaryID).Order("sort").Find(&pos).Error; err != nil { return nil, err } roots := make([]*biz.DictionaryDetail, 0, len(pos)) @@ -426,7 +427,7 @@ func (r *dictionaryRepo) DictionaryDetailTree(ctx context.Context, dictionaryID } func (r *dictionaryRepo) DictionaryDetailsByParent(ctx context.Context, dictionaryID uint, parentID *uint, includeChildren bool) ([]*biz.DictionaryDetail, error) { - db := r.data.gormDB.WithContext(ctx).Where("sys_dictionary_id = ?", dictionaryID) + db := r.data.DB().WithContext(ctx).Where("sys_dictionary_id = ?", dictionaryID) if parentID == nil { db = db.Where("parent_id IS NULL") } else { @@ -451,7 +452,7 @@ func (r *dictionaryRepo) DictionaryDetailsByParent(ctx context.Context, dictiona func (r *dictionaryRepo) loadDictionaryDetailChildren(ctx context.Context, parent *biz.DictionaryDetail) error { var pos []dictionaryDetailPO - if err := r.data.gormDB.WithContext(ctx).Where("parent_id = ?", parent.ID).Order("sort").Find(&pos).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("parent_id = ?", parent.ID).Order("sort").Find(&pos).Error; err != nil { return err } parent.Children = make([]*biz.DictionaryDetail, 0, len(pos)) diff --git a/internal/data/dictionary_department_parity_test.go b/internal/data/system/dictionary_department_parity_test.go similarity index 99% rename from internal/data/dictionary_department_parity_test.go rename to internal/data/system/dictionary_department_parity_test.go index 9288e57..10954a4 100644 --- a/internal/data/dictionary_department_parity_test.go +++ b/internal/data/system/dictionary_department_parity_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/error_record.go b/internal/data/system/error_record.go similarity index 78% rename from internal/data/error_record.go rename to internal/data/system/error_record.go index a27f380..9f9b909 100644 --- a/internal/data/error_record.go +++ b/internal/data/system/error_record.go @@ -1,10 +1,11 @@ -package data +package system import ( "context" "time" "kra/internal/biz" + "kra/pkg/pagination" "gorm.io/gorm" ) @@ -29,14 +30,14 @@ 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() { + if !r.data.DatabaseReady() { // Silently ignore error records before the database is initialized. return nil } if v.Status == "" { v.Status = "未处理" } - 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 + return r.data.DB().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, 9) @@ -67,20 +68,20 @@ func (r *auditQueryRepo) UpdateError(ctx context.Context, v *biz.ErrorRecord) er if v.Status != "" { updates["status"] = v.Status } - return r.data.gormDB.WithContext(ctx).Model(&errorRecordPO{}).Where("id = ?", v.ID).Updates(updates).Error + return r.data.DB().WithContext(ctx).Model(&errorRecordPO{}).Where("id = ?", v.ID).Updates(updates).Error } func (r *auditQueryRepo) DeleteErrors(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Delete(&errorRecordPO{}, ids).Error + return r.data.DB().WithContext(ctx).Delete(&errorRecordPO{}, ids).Error } func (r *auditQueryRepo) FindError(ctx context.Context, id uint) (*biz.ErrorRecord, error) { var po errorRecordPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return errorFromPO(po), nil } func (r *auditQueryRepo) ListErrors(ctx context.Context, page, size int, q *biz.ErrorRecord) ([]*biz.ErrorRecord, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&errorRecordPO{}) + db := r.data.DB().WithContext(ctx).Model(&errorRecordPO{}) if q != nil { if len(q.CreatedAtRange) == 2 { db = db.Where("created_at BETWEEN ? AND ?", q.CreatedAtRange[0], q.CreatedAtRange[1]) @@ -97,7 +98,7 @@ func (r *auditQueryRepo) ListErrors(ctx context.Context, page, size int, q *biz. return nil, 0, err } var pos []errorRecordPO - if err := applyPagination(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.Apply(db.Order("created_at desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.ErrorRecord, 0, len(pos)) diff --git a/internal/data/error_record_test.go b/internal/data/system/error_record_test.go similarity index 99% rename from internal/data/error_record_test.go rename to internal/data/system/error_record_test.go index 045fa1a..d3c5e81 100644 --- a/internal/data/error_record_test.go +++ b/internal/data/system/error_record_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/export.go b/internal/data/system/export.go similarity index 96% rename from internal/data/export.go rename to internal/data/system/export.go index aba522a..c7c2065 100644 --- a/internal/data/export.go +++ b/internal/data/system/export.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -57,9 +57,9 @@ type exportJoinPO struct { func (exportJoinPO) TableName() string { return "sys_export_template_join" } -type exportRepo struct{ data *Data } +type exportRepo struct{ data Provider } -func NewExportRepo(data *Data) biz.ExportRepo { return &exportRepo{data: data} } +func NewExportRepo(data Provider) biz.ExportRepo { return &exportRepo{data: data} } func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []exportJoinPO) *biz.ExportTemplate { v := &biz.ExportTemplate{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DBName: po.DBName, Name: po.Name, TableName: po.DBTableName, TemplateID: po.TemplateID, TemplateInfo: po.TemplateInfo, SQL: po.SQL, ImportSQL: po.ImportSQL, Limit: po.Limit, Order: po.Order} if conditions != nil { @@ -138,7 +138,7 @@ func (r *exportRepo) saveRelations(tx *gorm.DB, v *biz.ExportTemplate, resetIDs, return nil } func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *biz.ExportTemplate) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { po := exportToPO(v) if err := tx.Create(&po).Error; err != nil { return err @@ -148,7 +148,7 @@ func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *biz.ExportTemp }) } func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemplate) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { po := exportToPO(v) if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil { return err @@ -157,11 +157,11 @@ func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *biz.ExportTemp }) } func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Delete(&[]exportTemplatePO{}, "id IN ?", ids).Error + return r.data.DB().WithContext(ctx).Delete(&[]exportTemplatePO{}, "id IN ?", ids).Error } func (r *exportRepo) FindExportTemplate(ctx context.Context, id uint, tid string) (*biz.ExportTemplate, error) { var po exportTemplatePO - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var err error if tid != "" { err = db.Where("template_id = ?", tid).First(&po).Error @@ -182,7 +182,7 @@ func (r *exportRepo) FindExportTemplate(ctx context.Context, id uint, tid string return exportFromPO(po, conditions, joins), nil } func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q *biz.ExportTemplate) ([]*biz.ExportTemplate, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&exportTemplatePO{}) + db := r.data.DB().WithContext(ctx).Model(&exportTemplatePO{}) if q != nil { if q.StartCreatedAt != nil && q.EndCreatedAt != nil { db = db.Where("created_at BETWEEN ? AND ?", q.StartCreatedAt, q.EndCreatedAt) @@ -218,7 +218,7 @@ func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q } func (r *exportRepo) QueryExport(ctx context.Context, t *biz.ExportTemplate, params map[string]string) ([]map[string]any, string, error) { - selected, err := r.data.database(t.DBName) + selected, err := r.data.Database(t.DBName) if err != nil { return nil, "", err } @@ -355,7 +355,7 @@ func (r *exportRepo) PreviewExport(ctx context.Context, t *biz.ExportTemplate, p wheres := make([]string, 0) if params["filterDeleted"] == "true" { wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", t.TableName)) - selected, err := r.data.database(t.DBName) + selected, err := r.data.Database(t.DBName) if err != nil { return "", err } @@ -442,7 +442,7 @@ func (r *exportRepo) ImportExportRows(ctx context.Context, t *biz.ExportTemplate // is still treated as custom SQL and is allowed to return the driver's // native error instead of silently falling back to GORM insertion. sql := t.ImportSQL - selected, err := r.data.database(t.DBName) + selected, err := r.data.Database(t.DBName) if err != nil { return err } diff --git a/internal/data/log_file.go b/internal/data/system/log_file.go similarity index 99% rename from internal/data/log_file.go rename to internal/data/system/log_file.go index 10d863e..3127fc8 100644 --- a/internal/data/log_file.go +++ b/internal/data/system/log_file.go @@ -1,4 +1,4 @@ -package data +package system import ( "bytes" @@ -24,7 +24,7 @@ const ( ) func (r *logFileRepo) configuredLogRoot() (root string, exists bool, err error) { - admin := r.data.runtime.Admin() + admin := r.data.Runtime().Admin() if admin == nil || admin.Zap == nil || strings.TrimSpace(admin.Zap.Director) == "" { return "", false, biz.ErrLogRootUnavailable } diff --git a/internal/data/log_file_test.go b/internal/data/system/log_file_test.go similarity index 98% rename from internal/data/log_file_test.go rename to internal/data/system/log_file_test.go index 1241ef1..f8588b6 100644 --- a/internal/data/log_file_test.go +++ b/internal/data/system/log_file_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/login_log.go b/internal/data/system/login_log.go similarity index 73% rename from internal/data/login_log.go rename to internal/data/system/login_log.go index 0d990ae..ff5ee9f 100644 --- a/internal/data/login_log.go +++ b/internal/data/system/login_log.go @@ -1,10 +1,11 @@ -package data +package system import ( "context" "time" "kra/internal/biz" + "kra/pkg/pagination" "gorm.io/gorm" ) @@ -23,18 +24,18 @@ 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() { + if !r.data.DatabaseReady() { // The login endpoint remains reachable before database initialization; // skip the audit write until storage is ready. 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.DB().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 { return &biz.LoginLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Username: v.Username, IP: v.IP, Status: v.Status, ErrorMessage: v.ErrorMessage, Agent: v.Agent, UserID: v.UserID} } func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *biz.LoginLog) ([]*biz.LoginLog, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&loginLogPO{}) + db := r.data.DB().WithContext(ctx).Model(&loginLogPO{}) if q != nil { if q.Username != "" { db = db.Where("username LIKE ?", "%"+q.Username+"%") @@ -48,7 +49,7 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *biz. return nil, 0, err } var pos []loginLogPO - if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.ApplyRequired(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } ids := make([]uint, 0, len(pos)) @@ -57,7 +58,7 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *biz. ids = append(ids, value.UserID) } } - users := auditUsers(ctx, r.data.gormDB.WithContext(ctx), ids) + users := auditUsers(ctx, r.data.DB().WithContext(ctx), ids) out := make([]*biz.LoginLog, 0, len(pos)) for _, po := range pos { value := loginFromPO(po) @@ -68,14 +69,14 @@ func (r *auditQueryRepo) ListLogins(ctx context.Context, page, size int, q *biz. } func (r *auditQueryRepo) FindLogin(ctx context.Context, id uint) (*biz.LoginLog, error) { var po loginLogPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return loginFromPO(po), nil } func (r *auditQueryRepo) DeleteLogins(ctx context.Context, ids []int) error { if len(ids) == 1 && ids[0] == 0 { - return r.data.gormDB.WithContext(ctx).Delete(&loginLogPO{}).Error + return r.data.DB().WithContext(ctx).Delete(&loginLogPO{}).Error } - return r.data.gormDB.WithContext(ctx).Delete(&loginLogPO{}, "id IN ?", ids).Error + return r.data.DB().WithContext(ctx).Delete(&loginLogPO{}, "id IN ?", ids).Error } diff --git a/internal/data/media.go b/internal/data/system/media.go similarity index 82% rename from internal/data/media.go rename to internal/data/system/media.go index 107c4ba..67c53d4 100644 --- a/internal/data/media.go +++ b/internal/data/system/media.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -39,9 +39,9 @@ type categoryPO struct { func (categoryPO) TableName() string { return "media_attachment_category" } -type mediaRepo struct{ data *Data } +type mediaRepo struct{ data Provider } -func NewMediaRepo(data *Data) biz.MediaRepo { return &mediaRepo{data: data} } +func NewMediaRepo(data Provider) biz.MediaRepo { return &mediaRepo{data: data} } func mediaFromPO(v mediaPO) *biz.MediaFile { return &biz.MediaFile{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, CategoryID: v.CategoryID, URL: v.URL, Tag: v.Tag, Key: v.Key, Size: v.Size, Mime: v.Mime, MD5: v.MD5, UserID: v.UserID} } @@ -50,7 +50,7 @@ func mediaToPO(v *biz.MediaFile) mediaPO { } func (r *mediaRepo) CreateMedia(ctx context.Context, v *biz.MediaFile) error { po := mediaToPO(v) - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID = po.ID @@ -60,20 +60,20 @@ func (r *mediaRepo) CreateMedia(ctx context.Context, v *biz.MediaFile) error { } func (r *mediaRepo) FindMedia(ctx context.Context, id uint) (*biz.MediaFile, error) { var po mediaPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return mediaFromPO(po), nil } func (r *mediaRepo) FindMediaByHash(ctx context.Context, userID uint, hash string) (*biz.MediaFile, error) { var po mediaPO - if err := r.data.gormDB.WithContext(ctx).Where("user_id = ? AND md5 = ?", userID, hash).First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("user_id = ? AND md5 = ?", userID, hash).First(&po).Error; err != nil { return nil, err } return mediaFromPO(po), nil } func (r *mediaRepo) ListMedia(ctx context.Context, filter biz.MediaFilter) ([]*biz.MediaFile, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&mediaPO{}) + db := r.data.DB().WithContext(ctx).Model(&mediaPO{}) if filter.Keyword != "" { db = db.Where("name LIKE ?", "%"+filter.Keyword+"%") } @@ -129,17 +129,17 @@ func (r *mediaRepo) UpdateMediaName(ctx context.Context, id uint, name string) e // The compatible flow performs a First before Update, so a missing record returns a // not-found error instead of silently succeeding with RowsAffected=0. var po mediaPO - if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { return err } - return r.data.gormDB.WithContext(ctx).Model(&po).Update("name", name).Error + return r.data.DB().WithContext(ctx).Model(&po).Update("name", name).Error } func (r *mediaRepo) DeleteMedia(ctx context.Context, id uint) error { - return r.data.gormDB.WithContext(ctx).Unscoped().Delete(&mediaPO{}, id).Error + return r.data.DB().WithContext(ctx).Unscoped().Delete(&mediaPO{}, id).Error } func (r *mediaRepo) MediaKeyReferences(ctx context.Context, key string) (int64, error) { var count int64 - err := r.data.gormDB.WithContext(ctx).Model(&mediaPO{}).Where(map[string]any{"key": key}).Count(&count).Error + err := r.data.DB().WithContext(ctx).Model(&mediaPO{}).Where(map[string]any{"key": key}).Count(&count).Error return count, err } func (r *mediaRepo) CreateMediaBatch(ctx context.Context, items []*biz.MediaFile) error { @@ -147,10 +147,10 @@ func (r *mediaRepo) CreateMediaBatch(ctx context.Context, items []*biz.MediaFile for _, v := range items { pos = append(pos, mediaToPO(v)) } - return r.data.gormDB.WithContext(ctx).Create(&pos).Error + return r.data.DB().WithContext(ctx).Create(&pos).Error } func (r *mediaRepo) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) error { - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var count int64 query := db.Model(&categoryPO{}).Where("name = ? AND pid = ?", v.Name, v.ParentID) if err := query.Count(&count).Error; err != nil { @@ -171,17 +171,17 @@ func (r *mediaRepo) SaveCategory(ctx context.Context, v *biz.AttachmentCategory) } func (r *mediaRepo) DeleteCategory(ctx context.Context, id uint) error { var count int64 - if err := r.data.gormDB.WithContext(ctx).Model(&categoryPO{}).Where("pid = ?", id).Count(&count).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&categoryPO{}).Where("pid = ?", id).Count(&count).Error; err != nil { return err } if count > 0 { return errors.New("请先删除子级") } - return r.data.gormDB.WithContext(ctx).Unscoped().Delete(&categoryPO{}, id).Error + return r.data.DB().WithContext(ctx).Unscoped().Delete(&categoryPO{}, id).Error } func (r *mediaRepo) ListCategories(ctx context.Context) ([]*biz.AttachmentCategory, error) { var pos []categoryPO - if err := r.data.gormDB.WithContext(ctx).Find(&pos).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Find(&pos).Error; err != nil { return nil, err } byID := map[uint]*biz.AttachmentCategory{} diff --git a/internal/data/media_test.go b/internal/data/system/media_test.go similarity index 98% rename from internal/data/media_test.go rename to internal/data/system/media_test.go index 93ac6df..cd91a3e 100644 --- a/internal/data/media_test.go +++ b/internal/data/system/media_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/media_upload.go b/internal/data/system/media_upload.go similarity index 72% rename from internal/data/media_upload.go rename to internal/data/system/media_upload.go index 3c9d905..ca30eba 100644 --- a/internal/data/media_upload.go +++ b/internal/data/system/media_upload.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -47,7 +47,7 @@ func uploadFromPO(v uploadSessionPO) *biz.UploadSession { } func (r *mediaRepo) FindCompletedSession(ctx context.Context, userID uint, hash string) (*biz.UploadSession, error) { var po uploadSessionPO - if err := r.data.gormDB.WithContext(ctx).Where("user_id = ? AND file_hash = ? AND status = ?", userID, hash, "completed").First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("user_id = ? AND file_hash = ? AND status = ?", userID, hash, "completed").First(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, biz.ErrUploadSessionNotFound } @@ -57,7 +57,7 @@ func (r *mediaRepo) FindCompletedSession(ctx context.Context, userID uint, hash } func (r *mediaRepo) FindUploadingSession(ctx context.Context, userID uint, hash string) (*biz.UploadSession, error) { var po uploadSessionPO - if err := r.data.gormDB.WithContext(ctx).Where("user_id = ? AND file_hash = ? AND status = ?", userID, hash, "uploading").First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("user_id = ? AND file_hash = ? AND status = ?", userID, hash, "uploading").First(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, biz.ErrUploadSessionNotFound } @@ -67,7 +67,7 @@ func (r *mediaRepo) FindUploadingSession(ctx context.Context, userID uint, hash } func (r *mediaRepo) CreateUploadSession(ctx context.Context, v *biz.UploadSession) error { po := uploadSessionPO{UserID: v.UserID, FileName: v.FileName, FileHash: v.FileHash, FileSize: v.FileSize, ChunkSize: v.ChunkSize, ChunkTotal: v.ChunkTotal, Status: v.Status} - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID = po.ID @@ -75,36 +75,36 @@ func (r *mediaRepo) CreateUploadSession(ctx context.Context, v *biz.UploadSessio } func (r *mediaRepo) FindUploadSession(ctx context.Context, id uint) (*biz.UploadSession, error) { var po uploadSessionPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return uploadFromPO(po), nil } func (r *mediaRepo) ClaimUploadSession(ctx context.Context, id uint) (bool, error) { - result := r.data.gormDB.WithContext(ctx).Model(&uploadSessionPO{}).Where("id = ? AND status = ?", id, "uploading").Update("status", "merging") + result := r.data.DB().WithContext(ctx).Model(&uploadSessionPO{}).Where("id = ? AND status = ?", id, "uploading").Update("status", "merging") return result.RowsAffected == 1, result.Error } func (r *mediaRepo) FailUploadSession(ctx context.Context, id uint) error { - return r.data.gormDB.WithContext(ctx).Model(&uploadSessionPO{}).Where("id = ?", id).Update("status", "failed").Error + return r.data.DB().WithContext(ctx).Model(&uploadSessionPO{}).Where("id = ?", id).Update("status", "failed").Error } func (r *mediaRepo) CompleteUploadSession(ctx context.Context, id uint, key string, mediaID uint) error { - return r.data.gormDB.WithContext(ctx).Model(&uploadSessionPO{}).Where("id = ?", id).Updates(map[string]any{"status": "completed", "storage_key": key, "media_id": mediaID}).Error + return r.data.DB().WithContext(ctx).Model(&uploadSessionPO{}).Where("id = ?", id).Updates(map[string]any{"status": "completed", "storage_key": key, "media_id": mediaID}).Error } func (r *mediaRepo) DeleteUploadSession(ctx context.Context, id uint) error { // The compatible flow uses GORM's normal Delete here, retaining the soft-deleted session // for audit/recovery rather than physically removing it. - return r.data.gormDB.WithContext(ctx).Delete(&uploadSessionPO{}, id).Error + return r.data.DB().WithContext(ctx).Delete(&uploadSessionPO{}, id).Error } func (r *mediaRepo) UpsertChunk(ctx context.Context, uploadID uint, v *biz.UploadChunk) error { po := uploadChunkPO{UploadID: uploadID, ChunkIndex: v.Index, ChunkHash: v.Hash, Size: v.Size} - return r.data.gormDB.WithContext(ctx).Clauses(clause.OnConflict{ + return r.data.DB().WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "upload_id"}, {Name: "chunk_index"}}, DoUpdates: clause.AssignmentColumns([]string{"chunk_hash", "size", "updated_at", "deleted_at"}), }).Create(&po).Error } func (r *mediaRepo) ListChunks(ctx context.Context, uploadID uint) ([]*biz.UploadChunk, error) { var pos []uploadChunkPO - if err := r.data.gormDB.WithContext(ctx).Where("upload_id = ?", uploadID).Order("chunk_index").Find(&pos).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("upload_id = ?", uploadID).Order("chunk_index").Find(&pos).Error; err != nil { return nil, err } out := make([]*biz.UploadChunk, 0, len(pos)) @@ -114,15 +114,15 @@ func (r *mediaRepo) ListChunks(ctx context.Context, uploadID uint) ([]*biz.Uploa return out, nil } func (r *mediaRepo) DeleteChunks(ctx context.Context, uploadID uint) error { - return r.data.gormDB.WithContext(ctx).Where("upload_id = ?", uploadID).Delete(&uploadChunkPO{}).Error + return r.data.DB().WithContext(ctx).Where("upload_id = ?", uploadID).Delete(&uploadChunkPO{}).Error } func (r *mediaRepo) StaleUploadSessionIDs(ctx context.Context, before time.Time) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&uploadSessionPO{}).Where("status = ? AND updated_at < ?", "uploading", before).Pluck("id", &ids).Error + err := r.data.DB().WithContext(ctx).Model(&uploadSessionPO{}).Where("status = ? AND updated_at < ?", "uploading", before).Pluck("id", &ids).Error return ids, err } func (r *mediaRepo) DeleteUploadData(ctx context.Context, uploadID uint) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("upload_id = ?", uploadID).Delete(&uploadChunkPO{}).Error; err != nil { return err } diff --git a/internal/data/menu.go b/internal/data/system/menu.go similarity index 84% rename from internal/data/menu.go rename to internal/data/system/menu.go index 0c14cd9..681a8d0 100644 --- a/internal/data/menu.go +++ b/internal/data/system/menu.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -10,9 +10,9 @@ import ( "gorm.io/gorm" ) -type menuRepo struct{ data *Data } +type menuRepo struct{ data Provider } -func NewMenuRepo(data *Data) biz.MenuRepo { return &menuRepo{data: data} } +func NewMenuRepo(data Provider) biz.MenuRepo { return &menuRepo{data: data} } func menuFromPO(po menuPO) *biz.Menu { var deletedAt *time.Time @@ -58,7 +58,7 @@ func createMenuRelations(tx *gorm.DB, menu *biz.Menu) error { } func (r *menuRepo) Create(ctx context.Context, v *biz.Menu) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var count int64 if err := tx.Model(&menuPO{}).Where("name = ?", v.Name).Count(&count).Error; err != nil { return err @@ -99,7 +99,7 @@ func (r *menuRepo) Create(ctx context.Context, v *biz.Menu) error { } func (r *menuRepo) Update(ctx context.Context, v *biz.Menu) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var old menuPO if err := tx.First(&old, v.ID).Error; err != nil { return err @@ -122,7 +122,7 @@ func (r *menuRepo) Update(ctx context.Context, v *biz.Menu) error { } func (r *menuRepo) Delete(ctx context.Context, id uint) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var count int64 if err := tx.Model(&menuPO{}).Where("parent_id = ?", id).Count(&count).Error; err != nil { return err @@ -158,7 +158,7 @@ func (r *menuRepo) Delete(ctx context.Context, id uint) error { func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error { var parameters []menuParameterPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(¶meters).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(¶meters).Error; err != nil { return err } menu.Parameters = make([]*biz.MenuParameter, 0, len(parameters)) @@ -166,7 +166,7 @@ func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error { menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter)) } var buttons []menuButtonPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(&buttons).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_base_menu_id = ?", menu.ID).Find(&buttons).Error; err != nil { return err } menu.Buttons = make([]*biz.MenuButton, 0, len(buttons)) @@ -178,7 +178,7 @@ func (r *menuRepo) loadRelations(ctx context.Context, menu *biz.Menu) error { func (r *menuRepo) Find(ctx context.Context, id uint) (*biz.Menu, error) { var po menuPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } menu := menuFromPO(po) @@ -190,15 +190,15 @@ func (r *menuRepo) Find(ctx context.Context, id uint) (*biz.Menu, error) { func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { var pos []menuPO - db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}).Order("sort") - config := r.data.runtime.Admin() + db := r.data.DB().WithContext(ctx).Model(&menuPO{}).Order("sort") + config := r.data.Runtime().Admin() if actor, ok := biz.ActorFromContext(ctx); ok && config != nil && config.System != nil && config.System.UseStrictAuth { var authority authorityPO - if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("authority_id = ?", actor.AuthorityID).First(&authority).Error; err != nil { return nil, err } if authority.ParentID != nil && *authority.ParentID != 0 { - db = db.Where("id IN (?)", r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Select("sys_base_menu_id").Where("sys_authority_authority_id = ?", actor.AuthorityID)) + db = db.Where("id IN (?)", r.data.DB().WithContext(ctx).Model(&authorityMenuPO{}).Select("sys_base_menu_id").Where("sys_authority_authority_id = ?", actor.AuthorityID)) } } if err := db.Find(&pos).Error; err != nil { @@ -220,7 +220,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { menu.Buttons = []*biz.MenuButton{} } var parameters []menuParameterPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(¶meters).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(¶meters).Error; err != nil { return nil, err } for _, parameter := range parameters { @@ -228,7 +228,7 @@ func (r *menuRepo) List(ctx context.Context) ([]*biz.Menu, error) { menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter)) } var buttons []menuButtonPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(&buttons).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_base_menu_id IN ?", ids).Find(&buttons).Error; err != nil { return nil, err } for _, button := range buttons { @@ -260,13 +260,13 @@ func (r *menuRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) e if err := (&authorityAccessRepo{data: r.data}).checkAuthorityIDAuth(ctx, id); err != nil { return err } - if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ?", id).First(&authorityPO{}).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("authority_id = ?", id).First(&authorityPO{}).Error; err != nil { return err } if err := r.checkMenuAssignmentAuth(ctx, ids); err != nil { return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("sys_authority_authority_id = ?", id).Delete(&authorityMenuPO{}).Error; err != nil { return err } @@ -283,7 +283,7 @@ func (r *menuRepo) SetAuthorityMenus(ctx context.Context, id uint, ids []uint) e func (r *menuRepo) AuthorityMenuIDs(ctx context.Context, id uint) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_base_menu_id", &ids).Error + err := r.data.DB().WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ?", id).Pluck("sys_base_menu_id", &ids).Error return ids, err } @@ -294,8 +294,8 @@ func (r *menuRepo) AuthorityMenuIDs(ctx context.Context, id uint) ([]uint, error // menus when an administrator inspects a different (child) authority. func (r *menuRepo) ListAuthorityMenus(ctx context.Context, authorityID uint) ([]*biz.Menu, error) { var pos []menuPO - db := r.data.gormDB.WithContext(ctx).Model(&menuPO{}). - Where("id IN (?)", r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}). + db := r.data.DB().WithContext(ctx).Model(&menuPO{}). + Where("id IN (?)", r.data.DB().WithContext(ctx).Model(&authorityMenuPO{}). Select("sys_base_menu_id").Where("sys_authority_authority_id = ?", authorityID)). Order("sort") if err := db.Find(&pos).Error; err != nil { @@ -312,17 +312,17 @@ func (r *menuRepo) ListAuthorityMenus(ctx context.Context, authorityID uint) ([] func (r *menuRepo) MenuRoleIDs(ctx context.Context, id uint) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_base_menu_id = ?", id).Pluck("sys_authority_authority_id", &ids).Error + err := r.data.DB().WithContext(ctx).Model(&authorityMenuPO{}).Where("sys_base_menu_id = ?", id).Pluck("sys_authority_authority_id", &ids).Error return ids, err } func (r *menuRepo) DefaultRouterRoleIDs(ctx context.Context, id uint) ([]uint, error) { var menu menuPO - if err := r.data.gormDB.WithContext(ctx).First(&menu, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&menu, id).Error; err != nil { return nil, err } var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&authorityPO{}).Where("default_router = ?", menu.Name).Pluck("authority_id", &ids).Error + err := r.data.DB().WithContext(ctx).Model(&authorityPO{}).Where("default_router = ?", menu.Name).Pluck("authority_id", &ids).Error return ids, err } @@ -342,7 +342,7 @@ func (r *menuRepo) SetMenuRoles(ctx context.Context, id uint, ids []uint) error } } } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if strict { if len(allowedAuthorities) > 0 { if err := tx.Where("sys_base_menu_id = ? AND sys_authority_authority_id IN ?", id, authorityIDs(allowedAuthorities)).Delete(&authorityMenuPO{}).Error; err != nil { @@ -376,7 +376,7 @@ func (r *menuRepo) checkMenuAssignmentAuth(ctx context.Context, menuIDs []uint) if err != nil || !strict { return err } - return checkMenuAssignment(r.data.gormDB.WithContext(ctx), actor.AuthorityID, allowedAuthorities[actor.AuthorityID], menuIDs) + return checkMenuAssignment(r.data.DB().WithContext(ctx), actor.AuthorityID, allowedAuthorities[actor.AuthorityID], menuIDs) } func checkMenuAssignment(db *gorm.DB, actorID uint, root bool, menuIDs []uint) error { diff --git a/internal/data/menu_test.go b/internal/data/system/menu_test.go similarity index 99% rename from internal/data/menu_test.go rename to internal/data/system/menu_test.go index 4a5c808..b02e3a6 100644 --- a/internal/data/menu_test.go +++ b/internal/data/system/menu_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/system/migrations.go b/internal/data/system/migrations.go new file mode 100644 index 0000000..a41c101 --- /dev/null +++ b/internal/data/system/migrations.go @@ -0,0 +1,507 @@ +package system + +import ( + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" +) + +// legacySchemaMigration creates the complete schema and repairs legacy table +// shapes. It is intentionally called only by the one-time baseline migration. +func LegacySchemaMigration(db *gorm.DB) error { + if err := migrateLegacyIgnoreAPITable(db); err != nil { + return err + } + if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil { + return err + } + return db.AutoMigrate( + &userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{}, + &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{}, + &departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{}, + &dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &SecurityConfigPO{}, + &versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{}, + &operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{}, + &taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{}, + &announcementPO{}, + ) +} + +// CurrentDataMigration contains idempotent system data and authorization +// reconciliation that belongs to an explicit gormigrate version. +func CurrentDataMigration(db *gorm.DB) error { + if err := migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil { + return err + } + if err := normalizeErrorRecordStatuses(db); err != nil { + return err + } + if err := reconcileRootAuthorityAPIs(db); err != nil { + return err + } + return reconcileReferenceIndexes(db) +} + +// EnsureAdminSurface upgrades an existing database with menu/API metadata +// contributed by a feature module. The operation is idempotent and safe to run +// before the root authority exists; bootstrap seeding links the full menu tree +// when the first administrator is created. +func EnsureAdminSurface(db *gorm.DB, surface AdminSurface) error { + clean := db.Session(&gorm.Session{NewDB: true}) + return clean.Transaction(func(tx *gorm.DB) error { + menus := make([]menuPO, 0, len(surface.Menus)) + for _, item := range surface.Menus { + parentID := uint(0) + if item.ParentName != "" { + var parent menuPO + if err := tx.Where("name = ?", item.ParentName).First(&parent).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + parent = menuPO{Path: item.ParentName, Name: item.ParentName, Component: "view/routerHolder.vue", Title: item.ParentName, Sort: item.Sort} + if err := tx.Create(&parent).Error; err != nil { + return err + } + } + parentID = parent.ID + } + menu := menuPO{MenuLevel: 1, ParentID: parentID, Path: item.Path, Name: item.Name, Component: item.Component, Title: item.Title, Icon: item.Icon, Sort: item.Sort} + if item.ParentName == "" { + menu.MenuLevel = 0 + } + menus = append(menus, menu) + } + for _, item := range menus { + var current menuPO + err := tx.Where("name = ?", item.Name).First(¤t).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + if err := tx.Create(&item).Error; err != nil { + return err + } + case err != nil: + return err + default: + if err := tx.Model(¤t).Updates(map[string]any{ + "menu_level": item.MenuLevel, "parent_id": parent.ID, "path": item.Path, + "component": item.Component, "title": item.Title, "icon": item.Icon, "sort": item.Sort, + }).Error; err != nil { + return err + } + } + } + + apis := make([]apiPO, 0, len(surface.APIs)) + for _, item := range surface.APIs { + apis = append(apis, apiPO{Path: item.Path, Method: item.Method, APIGroup: item.APIGroup, Description: item.Description}) + } + for _, item := range apis { + var current apiPO + err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(¤t).Error + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + if err := tx.Create(&item).Error; err != nil { + return err + } + case err != nil: + return err + default: + if err := tx.Model(¤t).Updates(map[string]any{"api_group": item.APIGroup, "description": item.Description}).Error; err != nil { + return err + } + } + } + + var authorityCount int64 + if err := tx.Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil { + return err + } + if authorityCount == 0 { + return nil + } + for _, item := range menus { + var current menuPO + if err := tx.Where("name = ?", item.Name).First(¤t).Error; err != nil { + return err + } + var count int64 + if err := tx.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, current.ID).Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: current.ID}).Error; err != nil { + return err + } + } + } + for _, item := range apis { + var current apiPO + if err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(¤t).Error; err != nil { + return err + } + exists, err := policyExists(tx, 888, current.Path, current.Method) + if err != nil { + return err + } + if !exists { + if err := tx.Create(&casbinRulePO{Ptype: "p", V0: "888", V1: current.Path, V2: current.Method}).Error; err != nil { + return err + } + } + } + return nil + }) +} + +// migrateLegacyAuthorityDepartmentColumns preserves data created by early +// Kra builds, which used shortened join-column names and a composite primary +// key. The administration connection model has neither a primary key nor a +// uniqueness constraint, so rebuild the small table before AutoMigrate. +func migrateLegacyAuthorityDepartmentColumns(db *gorm.DB) error { + clean := db.Session(&gorm.Session{NewDB: true}) + const ( + table = "sys_authority_departments" + backup = "sys_authority_departments_kra_legacy" + ) + // MySQL and Oracle auto-commit DDL. If a prior process stopped between the + // rename and cleanup steps, restore the untouched backup first and retry the + // migration from a known state. + if clean.Migrator().HasTable(backup) { + if clean.Migrator().HasTable(table) { + if err := clean.Migrator().DropTable(table); err != nil { + return fmt.Errorf("remove incomplete authority-department table: %w", err) + } + } + if err := clean.Migrator().RenameTable(backup, table); err != nil { + return fmt.Errorf("restore authority-department backup: %w", err) + } + } + if !clean.Migrator().HasTable(table) { + return nil + } + + authorityColumn := "sys_authority_authority_id" + if !tableHasColumn(clean, table, authorityColumn) { + if !tableHasColumn(clean, table, "authority_id") { + return fmt.Errorf("authority-department table has no authority column") + } + authorityColumn = "authority_id" + } + departmentColumn := "sys_department_id" + if !tableHasColumn(clean, table, departmentColumn) { + if !tableHasColumn(clean, table, "department_id") { + return fmt.Errorf("authority-department table has no department column") + } + departmentColumn = "department_id" + } + hasPrimaryKey, err := tableHasPrimaryKey(clean, table) + if err != nil { + return err + } + if authorityColumn == "sys_authority_authority_id" && departmentColumn == "sys_department_id" && !hasPrimaryKey { + return nil + } + + type relation struct { + AuthorityID uint `gorm:"column:authority_id"` + DepartmentID uint `gorm:"column:department_id"` + } + var rows []relation + selectColumns := authorityColumn + " AS authority_id, " + departmentColumn + " AS department_id" + if err := clean.Table(table).Select(selectColumns).Scan(&rows).Error; err != nil { + return fmt.Errorf("read legacy authority-department rows: %w", err) + } + + rebuild := func(tx *gorm.DB) error { + if err := tx.Migrator().RenameTable(table, backup); err != nil { + return fmt.Errorf("rename legacy authority-department table: %w", err) + } + if err := tx.AutoMigrate(&authorityDepartmentPO{}); err != nil { + return fmt.Errorf("create authority-department table: %w", err) + } + if len(rows) > 0 { + items := make([]authorityDepartmentPO, 0, len(rows)) + for _, row := range rows { + items = append(items, authorityDepartmentPO{AuthorityID: row.AuthorityID, DepartmentID: row.DepartmentID}) + } + if err := tx.Create(&items).Error; err != nil { + return fmt.Errorf("copy authority-department rows: %w", err) + } + } + var count int64 + if err := tx.Model(&authorityDepartmentPO{}).Count(&count).Error; err != nil { + return fmt.Errorf("verify authority-department rows: %w", err) + } + if count != int64(len(rows)) { + return fmt.Errorf("verify authority-department rows: got %d want %d", count, len(rows)) + } + if err := tx.Migrator().DropTable(backup); err != nil { + return fmt.Errorf("drop legacy authority-department table: %w", err) + } + return nil + } + + switch clean.Dialector.Name() { + case "mysql", "oracle": + if err := rebuild(clean); err != nil { + restoreErr := restoreAuthorityDepartmentBackup(clean, table, backup) + if restoreErr != nil { + return fmt.Errorf("%v; restore authority-department backup: %w", err, restoreErr) + } + return err + } + return nil + default: + return clean.Transaction(rebuild) + } +} + +func restoreAuthorityDepartmentBackup(db *gorm.DB, table, backup string) error { + if db.Migrator().HasTable(table) { + if err := db.Migrator().DropTable(table); err != nil { + return err + } + } + if db.Migrator().HasTable(backup) { + return db.Migrator().RenameTable(backup, table) + } + return nil +} + +func tableHasPrimaryKey(db *gorm.DB, table string) (bool, error) { + columns, err := db.Migrator().ColumnTypes(table) + if err != nil { + return false, err + } + for _, column := range columns { + if primary, ok := column.PrimaryKey(); ok && primary { + return true, nil + } + } + return false, nil +} + +// Older builds used a status label outside the administration page's supported +// state set, so normalize existing rows during migration. +func normalizeErrorRecordStatuses(db *gorm.DB) error { + return db.Session(&gorm.Session{NewDB: true}).Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error +} + +// migrateLegacyAuthorityAPIsToCasbinRules upgrades the early Kra join-table +// representation to the independent Casbin policy table. Keep the legacy +// table in place for backwards compatibility, but make casbin_rule the sole +// live policy source. Existing policy rows are not duplicated. +func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error { + clean := db.Session(&gorm.Session{NewDB: true}) + if !clean.Migrator().HasTable(&authorityAPIPO{}) || !clean.Migrator().HasTable(&casbinRulePO{}) { + return nil + } + type legacyPolicy struct { + AuthorityID uint + Path string + Method string + } + baseQuery := func() *gorm.DB { + return clean.Table("sys_authority_apis sa"). + Select("sa.authority_id, a.path, a.method"). + Joins("JOIN sys_apis a ON a.id = sa.api_id") + } + query := baseQuery() + // Early Kra schemas stored sys_apis without soft-delete timestamps. The + // legacy-policy migration must run before assuming that column exists; + // otherwise an upgrade from those schemas cannot start on MySQL. + if tableHasColumn(clean, "sys_apis", "deleted_at") { + query = query.Where("a.deleted_at IS NULL") + } + var rows []legacyPolicy + if err := query.Find(&rows).Error; err != nil { + // A few MySQL-compatible drivers report stale/incomplete metadata from + // INFORMATION_SCHEMA during startup. If the optional soft-delete column + // was reported present but the join still rejects it, retry using only + // columns shared by every legacy schema. This migration must never block + // startup of an older database solely because deleted_at is absent. + if strings.Contains(strings.ToLower(err.Error()), "unknown column") && strings.Contains(strings.ToLower(err.Error()), "deleted_at") { + if retryErr := baseQuery().Find(&rows).Error; retryErr != nil { + return retryErr + } + } else { + return err + } + } + for _, row := range rows { + exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method) + if err != nil { + return err + } + if exists { + continue + } + if err := clean.Create(&casbinRulePO{Ptype: "p", V0: fmt.Sprint(row.AuthorityID), V1: row.Path, V2: row.Method}).Error; err != nil { + return err + } + } + return nil +} + +// tableHasColumn deliberately inspects the physical table rather than the +// model schema. Legacy databases may predate soft-delete columns even though +// the current PO includes gorm.DeletedAt. Metadata inspection failures are +// treated as "unknown" so callers use the portable query shape. +func tableHasColumn(db *gorm.DB, table, column string) bool { + columns, err := db.Migrator().ColumnTypes(table) + if err != nil { + return false + } + for _, item := range columns { + if strings.EqualFold(item.Name(), column) { + return true + } + } + return false +} + +// migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key +// shape (path, method) to the compatible model shape (ID/timestamps/soft +// delete). AutoMigrate can add columns but cannot replace an existing +// composite primary key portably, so rebuild the small table once while +// preserving every existing ignore rule. +func migrateLegacyIgnoreAPITable(db *gorm.DB) error { + clean := db.Session(&gorm.Session{NewDB: true}) + if !clean.Migrator().HasTable(&ignoredAPIPO{}) || clean.Migrator().HasColumn(&ignoredAPIPO{}, "id") { + return nil + } + legacyTable := fmt.Sprintf("sys_ignore_apis_legacy_%d", time.Now().UnixNano()) + type legacyIgnoredAPI struct { + Path string + Method string + } + return clean.Transaction(func(tx *gorm.DB) error { + if err := tx.Migrator().RenameTable(ignoredAPIPO{}.TableName(), legacyTable); err != nil { + return fmt.Errorf("rename legacy ignore API table: %w", err) + } + if err := tx.AutoMigrate(&ignoredAPIPO{}); err != nil { + return fmt.Errorf("create compatible ignore API table: %w", err) + } + var rows []legacyIgnoredAPI + if err := tx.Table(legacyTable).Find(&rows).Error; err != nil { + return fmt.Errorf("read legacy ignore API rows: %w", err) + } + if len(rows) > 0 { + items := make([]ignoredAPIPO, 0, len(rows)) + for _, row := range rows { + items = append(items, ignoredAPIPO{Path: row.Path, Method: row.Method}) + } + if err := tx.Create(&items).Error; err != nil { + return fmt.Errorf("copy legacy ignore API rows: %w", err) + } + } + if err := tx.Migrator().DropTable(legacyTable); err != nil { + return fmt.Errorf("drop legacy ignore API table: %w", err) + } + return nil + }) +} + +// reconcileRootAuthorityAPIs is a one-time upgrade path from the former Kra +// implementation where authority 888 bypassed policy storage entirely. The compatible behavior +// grants its root role through persisted Casbin policies, so when a legacy +// database has the root role but no stored API links, materialize the same +// policy set and let normal authorization read it thereafter. +func reconcileRootAuthorityAPIs(db *gorm.DB) error { + clean := db.Session(&gorm.Session{NewDB: true}) + var authorityCount int64 + if err := clean.Session(&gorm.Session{NewDB: true}).Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil || authorityCount == 0 { + return err + } + var policyCount int64 + if err := policyScope(clean).Where("v0 = ?", "888").Count(&policyCount).Error; err != nil || policyCount != 0 { + return err + } + var ignored []ignoredAPIPO + if err := clean.Session(&gorm.Session{NewDB: true}).Find(&ignored).Error; err != nil { + return err + } + ignoreSet := make(map[string]struct{}, len(ignored)) + for _, item := range ignored { + ignoreSet[item.Method+"\x00"+item.Path] = struct{}{} + } + var apis []apiPO + if err := clean.Session(&gorm.Session{NewDB: true}).Find(&apis).Error; err != nil { + return err + } + rules := make([]casbinRulePO, 0, len(apis)) + for _, api := range apis { + if _, ok := ignoreSet[api.Method+"\x00"+api.Path]; ok { + continue + } + rules = append(rules, newPolicyRule(888, api.Path, api.Method)) + } + if len(rules) == 0 { + return nil + } + return clean.Session(&gorm.Session{NewDB: true}).Create(&rules).Error +} + +// reconcileReferenceIndexes removes constraints created by older Kra builds +// that are not part of the administration data model. Business services own +// duplicate checks and their user-facing error messages. +func reconcileReferenceIndexes(db *gorm.DB) error { + clean := db.Session(&gorm.Session{NewDB: true}) + obsolete := []struct { + model any + name string + }{ + {&apiPO{}, "idx_api_path_method"}, + {&dictionaryPO{}, "idx_sys_dictionaries_type"}, + {¶meterPO{}, "idx_sys_params_key"}, + {&apiTokenPO{}, "idx_sys_api_tokens_token"}, + {&exportTemplatePO{}, "idx_sys_export_templates_template_id"}, + } + for _, item := range obsolete { + migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator() + if migrator.HasIndex(item.model, item.name) { + if err := migrator.DropIndex(item.model, item.name); err != nil { + return fmt.Errorf("drop obsolete index %s: %w", item.name, err) + } + } + } + for _, item := range []struct { + name string + field string + }{{"idx_sys_users_uuid", "UUID"}, {"idx_sys_users_username", "Username"}} { + unique, err := indexIsUnique(clean.Session(&gorm.Session{NewDB: true}), &userPO{}, item.name) + if err != nil { + return err + } + if !unique { + continue + } + migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator() + if err = migrator.DropIndex(&userPO{}, item.name); err != nil { + return fmt.Errorf("drop legacy unique index %s: %w", item.name, err) + } + if err = migrator.CreateIndex(&userPO{}, item.field); err != nil { + return fmt.Errorf("create reference index %s: %w", item.name, err) + } + } + return nil +} + +func indexIsUnique(db *gorm.DB, model any, name string) (bool, error) { + indexes, err := db.Migrator().GetIndexes(model) + if err != nil { + // Some third-party GORM drivers do not implement index inspection. + // Fresh schemas are already correct; skip only the legacy repair there. + return false, nil + } + for _, index := range indexes { + if index.Name() == name { + unique, known := index.Unique() + return known && unique, nil + } + } + return false, nil +} diff --git a/internal/data/migrations_test.go b/internal/data/system/migrations_test.go similarity index 59% rename from internal/data/migrations_test.go rename to internal/data/system/migrations_test.go index eaa1bd1..79edd01 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/system/migrations_test.go @@ -1,9 +1,117 @@ -package data +package system import ( "testing" + + "kra/internal/data/migration" ) +func TestMigrateAllUsesVersionTableAndIsIdempotent(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + + if err = migrateAll(db); err != nil { + t.Fatalf("first migration: %v", err) + } + if err = migrateAll(db); err != nil { + t.Fatalf("second migration: %v", err) + } + if !db.Migrator().HasTable(migration.TableName) { + t.Fatalf("missing migration table %q", migration.TableName) + } + var count int64 + if err = db.Table(migration.TableName).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("migration rows = %d, want 2", count) + } +} + +func TestEnsurePaymentAdminSurfaceIsIdempotent(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err = db.AutoMigrate(&menuPO{}, &authorityMenuPO{}, &authorityPO{}, &apiPO{}, &casbinRulePO{}); err != nil { + t.Fatal(err) + } + rootParentID := uint(0) + if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root", ParentID: &rootParentID}).Error; err != nil { + t.Fatal(err) + } + if err = db.Create(&menuPO{Name: "extensions", Path: "legacy-extensions", Title: "旧扩展"}).Error; err != nil { + t.Fatal(err) + } + if err = db.Create(&menuPO{Name: "paymentOrders", Component: "legacy.vue", Title: "旧支付订单"}).Error; err != nil { + t.Fatal(err) + } + if err = db.Create(&apiPO{Path: "/payment/configs", Method: "GET", APIGroup: "legacy"}).Error; err != nil { + t.Fatal(err) + } + + for i := 0; i < 2; i++ { + if err = ensurePaymentAdminSurface(db); err != nil { + t.Fatalf("ensure payment admin surface pass %d: %v", i+1, err) + } + } + + var parent menuPO + if err = db.Where("name = ?", "extensions").First(&parent).Error; err != nil { + t.Fatal(err) + } + for name, component := range map[string]string{ + "paymentOrders": "view/systemTools/payment/orders.vue", + "paymentConfig": "view/systemTools/payment/config.vue", + } { + var menu menuPO + if err = db.Where("name = ?", name).First(&menu).Error; err != nil { + t.Fatal(err) + } + if menu.ParentID != parent.ID || menu.Component != component { + t.Fatalf("menu %s = %#v", name, menu) + } + var linkCount int64 + if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, menu.ID).Count(&linkCount).Error; err != nil { + t.Fatal(err) + } + if linkCount != 1 { + t.Fatalf("menu %s root links = %d, want 1", name, linkCount) + } + } + for _, item := range []struct{ method, path string }{ + {"GET", "/payment/configs"}, {"POST", "/payment/config"}, + {"GET", "/payment/orders"}, {"POST", "/payment/order"}, + } { + var apiCount int64 + if err = db.Model(&apiPO{}).Where("path = ? AND method = ?", item.path, item.method).Count(&apiCount).Error; err != nil { + t.Fatal(err) + } + if apiCount != 1 { + t.Fatalf("API %s %s rows = %d, want 1", item.method, item.path, apiCount) + } + var policyCount int64 + if err = policyScope(db).Where("v0 = ? AND v1 = ? AND v2 = ?", "888", item.path, item.method).Count(&policyCount).Error; err != nil { + t.Fatal(err) + } + if policyCount != 1 { + t.Fatalf("policy %s %s rows = %d, want 1", item.method, item.path, policyCount) + } + } +} + func TestMigrateLegacyAuthorityAPIsWithoutDeletedAt(t *testing.T) { db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") if err != nil { diff --git a/internal/data/system.go b/internal/data/system/models.go similarity index 91% rename from internal/data/system.go rename to internal/data/system/models.go index 5133a6e..4ebe473 100644 --- a/internal/data/system.go +++ b/internal/data/system/models.go @@ -1,4 +1,4 @@ -package data +package system import ( "time" @@ -96,9 +96,6 @@ type menuParameterPO struct { func (menuParameterPO) TableName() string { return "sys_base_menu_parameters" } -type userRepo struct{ data *Data } -type initializationRepo struct{ data *Data } +type userRepo struct{ data Provider } -func NewUserRepo(data *Data) biz.UserRepo { return &userRepo{data: data} } - -func NewInitializationRepo(data *Data) biz.InitializationRepo { return &initializationRepo{data: data} } +func NewUserRepo(data Provider) biz.UserRepo { return &userRepo{data: data} } diff --git a/internal/data/operation_log.go b/internal/data/system/operation_log.go similarity index 78% rename from internal/data/operation_log.go rename to internal/data/system/operation_log.go index 4943455..05e5af1 100644 --- a/internal/data/operation_log.go +++ b/internal/data/system/operation_log.go @@ -1,10 +1,11 @@ -package data +package system import ( "context" "time" "kra/internal/biz" + "kra/pkg/pagination" "gorm.io/gorm" ) @@ -30,13 +31,13 @@ type operationPO struct { func (operationPO) TableName() string { return "sys_operation_records" } func (r *auditRecorderRepo) RecordOperation(ctx context.Context, v *biz.OperationRecord) error { - return r.data.gormDB.WithContext(ctx).Create(&operationPO{IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID}).Error + return r.data.DB().WithContext(ctx).Create(&operationPO{IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID}).Error } func opFromPO(v operationPO) *biz.OperationRecord { return &biz.OperationRecord{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, IP: v.IP, Method: v.Method, Path: v.Path, Status: v.Status, LatencyMS: v.LatencyMS, Agent: v.Agent, ErrorMessage: v.ErrorMessage, Body: v.Body, Response: v.Response, UserID: v.UserID, RequestID: v.RequestID, TraceID: v.TraceID, DeviceID: v.DeviceID} } func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q *biz.OperationRecord) ([]*biz.OperationRecord, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&operationPO{}) + db := r.data.DB().WithContext(ctx).Model(&operationPO{}) if q != nil { if q.Path != "" { db = db.Where("path LIKE ?", "%"+q.Path+"%") @@ -53,10 +54,10 @@ func (r *auditQueryRepo) ListOperations(ctx context.Context, page, size int, q * return nil, 0, err } var pos []operationPO - if err := applyRequiredPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.ApplyRequired(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } - users := auditUsers(ctx, r.data.gormDB.WithContext(ctx), operationUserIDs(pos)) + users := auditUsers(ctx, r.data.DB().WithContext(ctx), operationUserIDs(pos)) out := make([]*biz.OperationRecord, 0, len(pos)) for _, po := range pos { value := opFromPO(po) @@ -92,7 +93,7 @@ func auditUsers(ctx context.Context, db *gorm.DB, ids []uint) map[uint]*biz.User } func (r *auditQueryRepo) FindOperation(ctx context.Context, id uint) (*biz.OperationRecord, error) { var po operationPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return opFromPO(po), nil @@ -100,7 +101,7 @@ func (r *auditQueryRepo) FindOperation(ctx context.Context, id uint) (*biz.Opera func (r *auditQueryRepo) DeleteOperations(ctx context.Context, ids []int) error { if len(ids) == 1 && ids[0] == 0 { - return r.data.gormDB.WithContext(ctx).Delete(&operationPO{}).Error + return r.data.DB().WithContext(ctx).Delete(&operationPO{}).Error } - return r.data.gormDB.WithContext(ctx).Delete(&operationPO{}, "id IN ?", ids).Error + return r.data.DB().WithContext(ctx).Delete(&operationPO{}, "id IN ?", ids).Error } diff --git a/internal/data/organization_test.go b/internal/data/system/organization_test.go similarity index 98% rename from internal/data/organization_test.go rename to internal/data/system/organization_test.go index 9215b45..1e7ad16 100644 --- a/internal/data/organization_test.go +++ b/internal/data/system/organization_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/parameter.go b/internal/data/system/parameter.go similarity index 69% rename from internal/data/parameter.go rename to internal/data/system/parameter.go index b88cc40..88fc24b 100644 --- a/internal/data/parameter.go +++ b/internal/data/system/parameter.go @@ -1,18 +1,19 @@ -package data +package system import ( "context" "time" "kra/internal/biz" + "kra/pkg/pagination" "gorm.io/gorm" "gorm.io/gorm/clause" ) -type parameterRepo struct{ data *Data } +type parameterRepo struct{ data Provider } -func NewParameterRepo(data *Data) biz.ParameterRepo { return ¶meterRepo{data: data} } +func NewParameterRepo(data Provider) biz.ParameterRepo { return ¶meterRepo{data: data} } type parameterPO struct { ID uint `gorm:"primaryKey"` @@ -29,7 +30,7 @@ func (parameterPO) TableName() string { return "sys_params" } func (r *parameterRepo) CreateParameter(ctx context.Context, v *biz.SystemParameter) error { po := parameterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc} - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID, v.CreatedAt, v.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt @@ -38,30 +39,30 @@ func (r *parameterRepo) CreateParameter(ctx context.Context, v *biz.SystemParame func (r *parameterRepo) UpdateParameter(ctx context.Context, v *biz.SystemParameter) error { // The compatible update uses a struct, so zero-value optional fields (notably desc) // are intentionally ignored rather than clearing an existing value. - return r.data.gormDB.WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(¶meterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}).Error + return r.data.DB().WithContext(ctx).Model(¶meterPO{}).Where("id = ?", v.ID).Updates(¶meterPO{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Key: v.Key, Value: v.Value, Desc: v.Desc}).Error } func (r *parameterRepo) DeleteParameters(ctx context.Context, ids []string) error { if len(ids) == 1 { - return r.data.gormDB.WithContext(ctx).Delete(¶meterPO{}, "id = ?", ids[0]).Error + return r.data.DB().WithContext(ctx).Delete(¶meterPO{}, "id = ?", ids[0]).Error } - return r.data.gormDB.WithContext(ctx).Delete(&[]parameterPO{}, "id in ?", ids).Error + return r.data.DB().WithContext(ctx).Delete(&[]parameterPO{}, "id in ?", ids).Error } func (r *parameterRepo) FindParameterByID(ctx context.Context, id string) (*biz.SystemParameter, error) { var po parameterPO - if err := r.data.gormDB.WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("id = ?", id).First(&po).Error; err != nil { return nil, err } return parameterFromPO(po), nil } func (r *parameterRepo) FindParameterByKey(ctx context.Context, key string) (*biz.SystemParameter, error) { var po parameterPO - if err := r.data.gormDB.WithContext(ctx).Where(parameterPO{Key: key}).First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where(parameterPO{Key: key}).First(&po).Error; err != nil { return nil, err } return parameterFromPO(po), nil } func (r *parameterRepo) ListParameters(ctx context.Context, page, size int, q *biz.SystemParameter) ([]*biz.SystemParameter, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(¶meterPO{}) + db := r.data.DB().WithContext(ctx).Model(¶meterPO{}) if q != nil { if q.StartCreatedAt != nil && q.EndCreatedAt != nil { db = db.Where("created_at BETWEEN ? AND ?", q.StartCreatedAt, q.EndCreatedAt) @@ -78,7 +79,7 @@ func (r *parameterRepo) ListParameters(ctx context.Context, page, size int, q *b return nil, 0, err } var pos []parameterPO - if err := applyPagination(db, page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.Apply(db, page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.SystemParameter, 0, len(pos)) diff --git a/internal/data/parameter_test.go b/internal/data/system/parameter_test.go similarity index 99% rename from internal/data/parameter_test.go rename to internal/data/system/parameter_test.go index 0a0af27..d0430bb 100644 --- a/internal/data/parameter_test.go +++ b/internal/data/system/parameter_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/permission.go b/internal/data/system/permission.go similarity index 82% rename from internal/data/permission.go rename to internal/data/system/permission.go index bb0d1a6..b98d67a 100644 --- a/internal/data/permission.go +++ b/internal/data/system/permission.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -10,9 +10,9 @@ import ( "gorm.io/gorm" ) -type permissionRepo struct{ data *Data } +type permissionRepo struct{ data Provider } -func NewPermissionRepo(data *Data) biz.PermissionRepo { return &permissionRepo{data: data} } +func NewPermissionRepo(data Provider) biz.PermissionRepo { return &permissionRepo{data: data} } type menuButtonPO struct { ID uint `gorm:"primaryKey"` @@ -36,7 +36,7 @@ func (authorityButtonPO) TableName() string { return "sys_authority_btns" } func (r *permissionRepo) Buttons(ctx context.Context, menuID uint) ([]*biz.MenuButton, error) { var pos []menuButtonPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id = ?", menuID).Find(&pos).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_base_menu_id = ?", menuID).Find(&pos).Error; err != nil { return nil, err } out := make([]*biz.MenuButton, 0, len(pos)) @@ -52,7 +52,7 @@ func (r *permissionRepo) SetAuthorityButtons(ctx context.Context, aid uint, butt if err := r.checkButtonAssignmentAuth(ctx, buttons); err != nil { return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("authority_id = ?", aid).Delete(&authorityButtonPO{}).Error; err != nil { return err } @@ -70,7 +70,7 @@ func (r *permissionRepo) SetAuthorityButtons(ctx context.Context, aid uint, butt } func (r *permissionRepo) SelectedButtons(ctx context.Context, aid, menuID uint) ([]uint, error) { var rows []authorityButtonPO - if err := r.data.gormDB.WithContext(ctx).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Find(&rows).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Find(&rows).Error; err != nil { return nil, err } var ids []uint @@ -86,7 +86,7 @@ func (r *permissionRepo) SetSelectedButtons(ctx context.Context, aid, menuID uin if err := r.checkButtonAssignmentAuth(ctx, map[uint][]uint{menuID: ids}); err != nil { return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("authority_id = ? AND sys_menu_id = ?", aid, menuID).Delete(&authorityButtonPO{}).Error; err != nil { return err } @@ -102,7 +102,7 @@ func (r *permissionRepo) SetSelectedButtons(ctx context.Context, aid, menuID uin } func (r *permissionRepo) CanRemoveButton(ctx context.Context, id string) (bool, error) { var row authorityButtonPO - err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_btn_id = ?", id).First(&row).Error + err := r.data.DB().WithContext(ctx).Where("sys_base_menu_btn_id = ?", id).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return true, nil } @@ -113,7 +113,7 @@ func (r *permissionRepo) CanRemoveButton(ctx context.Context, id string) (bool, } func (r *permissionRepo) AuthorityButtonIDs(ctx context.Context, aid uint) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&authorityButtonPO{}).Where("authority_id = ?", aid).Pluck("sys_base_menu_btn_id", &ids).Error + err := r.data.DB().WithContext(ctx).Model(&authorityButtonPO{}).Where("authority_id = ?", aid).Pluck("sys_base_menu_btn_id", &ids).Error return ids, err } @@ -122,7 +122,7 @@ func (r *permissionRepo) checkButtonAssignmentAuth(ctx context.Context, buttons if err != nil || !strict { return err } - return checkButtonAssignment(r.data.gormDB.WithContext(ctx), actor.AuthorityID, allowedAuthorities[actor.AuthorityID], buttons) + return checkButtonAssignment(r.data.DB().WithContext(ctx), actor.AuthorityID, allowedAuthorities[actor.AuthorityID], buttons) } func checkButtonAssignment(db *gorm.DB, actorID uint, root bool, buttons map[uint][]uint) error { diff --git a/internal/data/permission_test.go b/internal/data/system/permission_test.go similarity index 99% rename from internal/data/permission_test.go rename to internal/data/system/permission_test.go index 5fc8829..e5cbc0a 100644 --- a/internal/data/permission_test.go +++ b/internal/data/system/permission_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/position.go b/internal/data/system/position.go similarity index 76% rename from internal/data/position.go rename to internal/data/system/position.go index da0d9c9..321f953 100644 --- a/internal/data/position.go +++ b/internal/data/system/position.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -10,9 +10,9 @@ import ( "gorm.io/gorm" ) -type positionRepo struct{ data *Data } +type positionRepo struct{ data Provider } -func NewPositionRepo(data *Data) biz.PositionRepo { return &positionRepo{data: data} } +func NewPositionRepo(data Provider) biz.PositionRepo { return &positionRepo{data: data} } type positionPO struct { ID uint `gorm:"primaryKey"` @@ -41,33 +41,33 @@ func posFromPO(po positionPO) *biz.Position { return &biz.Position{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, Name: po.Name, Code: po.Code, Sort: po.Sort, Status: po.Status, Remark: po.Remark} } func (r *positionRepo) CreatePosition(ctx context.Context, v *biz.Position) error { - return r.data.gormDB.WithContext(ctx).Create(&positionPO{Name: v.Name, Code: v.Code, Sort: v.Sort, Status: v.Status, Remark: v.Remark}).Error + return r.data.DB().WithContext(ctx).Create(&positionPO{Name: v.Name, Code: v.Code, Sort: v.Sort, Status: v.Status, Remark: v.Remark}).Error } func (r *positionRepo) UpdatePosition(ctx context.Context, v *biz.Position) error { - return r.data.gormDB.WithContext(ctx).Model(&positionPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "code": v.Code, "sort": v.Sort, "status": v.Status, "remark": v.Remark}).Error + return r.data.DB().WithContext(ctx).Model(&positionPO{}).Where("id = ?", v.ID).Updates(map[string]any{"name": v.Name, "code": v.Code, "sort": v.Sort, "status": v.Status, "remark": v.Remark}).Error } func (r *positionRepo) DeletePosition(ctx context.Context, id uint) error { if id == 0 { return errors.New("岗位ID不能为空") } var count int64 - if err := r.data.gormDB.WithContext(ctx).Model(&userPositionPO{}).Where("sys_position_id = ?", id).Count(&count).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Model(&userPositionPO{}).Where("sys_position_id = ?", id).Count(&count).Error; err != nil { return err } if count > 0 { return errors.New("该岗位下存在用户,不允许删除") } - return r.data.gormDB.WithContext(ctx).Delete(&positionPO{}, id).Error + return r.data.DB().WithContext(ctx).Delete(&positionPO{}, id).Error } func (r *positionRepo) FindPosition(ctx context.Context, id uint) (*biz.Position, error) { var po positionPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return posFromPO(po), nil } func (r *positionRepo) ListPositions(ctx context.Context, page, size int, q *biz.PositionListFilter) ([]*biz.Position, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&positionPO{}) + db := r.data.DB().WithContext(ctx).Model(&positionPO{}) if q != nil { if q.Name != "" { db = db.Where("name LIKE ?", "%"+q.Name+"%") @@ -98,14 +98,14 @@ func (r *positionRepo) ListPositions(ctx context.Context, page, size int, q *biz } func (r *positionRepo) PositionUserIDs(ctx context.Context, id uint) ([]uint, error) { var ids []uint - err := r.data.gormDB.WithContext(ctx).Model(&userPositionPO{}).Where("sys_position_id = ?", id).Pluck("sys_user_id", &ids).Error + err := r.data.DB().WithContext(ctx).Model(&userPositionPO{}).Where("sys_position_id = ?", id).Pluck("sys_user_id", &ids).Error return ids, err } func (r *positionRepo) SetPositionUsers(ctx context.Context, id uint, ids []uint) error { if id == 0 { return errors.New("岗位ID不能为空") } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("sys_position_id = ?", id).Delete(&userPositionPO{}).Error; err != nil { return err } @@ -120,7 +120,7 @@ func (r *positionRepo) SetPositionUsers(ctx context.Context, id uint, ids []uint }) } func (r *positionRepo) SetUserPositions(ctx context.Context, uid uint, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("sys_user_id = ?", uid).Delete(&userPositionPO{}).Error; err != nil { return err } diff --git a/internal/data/system/provider.go b/internal/data/system/provider.go new file mode 100644 index 0000000..e8f1b80 --- /dev/null +++ b/internal/data/system/provider.go @@ -0,0 +1,16 @@ +package system + +import ( + "kra/internal/conf" + + "gorm.io/gorm" +) + +// Provider exposes the shared data infrastructure needed by system +// repositories without coupling this module to the parent data package. +type Provider interface { + DB() *gorm.DB + Database(name string) (*gorm.DB, error) + DatabaseReady() bool + Runtime() *conf.Runtime +} diff --git a/internal/data/system/seed.go b/internal/data/system/seed.go new file mode 100644 index 0000000..a1d9bca --- /dev/null +++ b/internal/data/system/seed.go @@ -0,0 +1,156 @@ +package system + +import ( + "context" + "strings" + "time" + + "kra/internal/biz" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) error { + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + rootParentID := uint(0) + authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", ParentID: &rootParentID, DataScope: 1, DefaultRouter: "dashboard"} + if err := tx.FirstOrCreate(&authority, authorityPO{AuthorityID: 888}).Error; err != nil { + return err + } + if err := tx.Model(&authorityPO{}).Where("authority_id = ? AND parent_id IS NULL", 888).Update("parent_id", 0).Error; err != nil { + return err + } + menus := defaultMenus() + for i := range menus { + if err := tx.Where("name = ?", menus[i].Name).FirstOrCreate(&menus[i]).Error; err != nil { + return err + } + } + var persisted []menuPO + if err := tx.Order("sort asc, id asc").Find(&persisted).Error; err != nil { + return err + } + nameID := make(map[string]uint, len(persisted)) + for _, menu := range persisted { + nameID[menu.Name] = menu.ID + } + for i := range menus { + if menus[i].ActiveName != "" { + if err := tx.Model(&menuPO{}).Where("name = ?", menus[i].Name).Updates(map[string]any{"parent_id": nameID[menus[i].ActiveName], "active_name": ""}).Error; err != nil { + return err + } + } + } + if err := tx.Where("sys_authority_authority_id = ?", 888).Delete(&authorityMenuPO{}).Error; err != nil { + return err + } + links := make([]authorityMenuPO, 0, len(persisted)) + for _, menu := range persisted { + links = append(links, authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: menu.ID}) + } + if len(links) > 0 { + if err := tx.Create(&links).Error; err != nil { + return err + } + } + var count int64 + if err := tx.Model(&userPO{}).Where("username = ?", "admin").Count(&count).Error; err != nil { + return err + } + if count == 0 { + hash, err := bcrypt.GenerateFromPassword([]byte(input.AdminPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + now := time.Now() + user := userPO{UUID: uuid.NewString(), Username: "admin", Password: string(hash), NickName: "超级管理员", AuthorityID: 888, Enable: 1, PasswordUpdatedAt: &now} + if err := tx.Create(&user).Error; err != nil { + return err + } + if err := tx.Create(&userAuthorityPO{SysUserID: user.ID, SysAuthorityAuthorityID: 888}).Error; err != nil { + return err + } + } + enabled := true + department := departmentPO{Name: "总公司", ParentID: 0, Ancestors: "0", Sort: 0, Status: &enabled} + if err := tx.Where("name = ?", department.Name).FirstOrCreate(&department).Error; err != nil { + return err + } + for _, position := range []positionPO{{Name: "总经理", Code: "CEO", Sort: 1, Status: &enabled}, {Name: "普通员工", Code: "STAFF", Sort: 2, Status: &enabled}} { + if err := tx.Where("code = ?", position.Code).FirstOrCreate(&position).Error; err != nil { + return err + } + } + security := DefaultSecurityConfig() + if err := tx.FirstOrCreate(&security, SecurityConfigPO{ID: 1}).Error; err != nil { + return err + } + exportTemplate := exportTemplatePO{Name: "api", DBTableName: "sys_apis", TemplateID: "api", TemplateInfo: "{\n\"path\":\"路径\",\n\"method\":\"方法(大写)\",\n\"description\":\"方法介绍\",\n\"api_group\":\"方法分组\"\n}"} + if err := tx.Where("template_id = ?", exportTemplate.TemplateID).FirstOrCreate(&exportTemplate).Error; err != nil { + return err + } + for _, task := range []taskPO{{Name: "ClearDB", Description: "定时清理数据库过期日志(操作记录/JWT黑名单/定时任务执行日志)", Spec: "@daily", ExecutorType: "method", MethodName: "ClearDB", Enabled: true}, {Name: "CleanStaleUploads", Description: "定时清理过期大文件上传会话", Spec: "@hourly", ExecutorType: "method", MethodName: "CleanStaleUploads", Enabled: true}} { + if err := tx.Where("name = ?", task.Name).FirstOrCreate(&task).Error; err != nil { + return err + } + } + for _, item := range input.APIs { + if item != nil { + po := apiPO{Path: item.Path, Method: strings.ToUpper(item.Method), Description: item.Description, APIGroup: item.APIGroup} + if err := tx.Where("path = ? AND method = ?", po.Path, po.Method).FirstOrCreate(&po).Error; err != nil { + return err + } + } + } + ignoredAPIs := make([]ignoredAPIPO, 0) + for _, item := range DefaultIgnoredAPIs("uploads/file") { + ignoredAPIs = append(ignoredAPIs, ignoredAPIPO{Method: item.Method, Path: item.Path}) + } + for _, ignored := range ignoredAPIs { + if err := tx.FirstOrCreate(&ignored, ignored).Error; err != nil { + return err + } + } + ignoreSet := make(map[string]struct{}, len(ignoredAPIs)) + for _, ignored := range ignoredAPIs { + ignoreSet[ignored.Method+"\x00"+ignored.Path] = struct{}{} + } + var apiRows []apiPO + if err := tx.Find(&apiRows).Error; err != nil { + return err + } + for _, api := range apiRows { + if _, ignored := ignoreSet[api.Method+"\x00"+api.Path]; ignored { + continue + } + exists, err := policyExists(tx, 888, api.Path, api.Method) + if err != nil { + return err + } + if !exists { + rule := newPolicyRule(888, api.Path, api.Method) + if err := tx.Create(&rule).Error; err != nil { + return err + } + } + } + return nil + }) +} + +func defaultMenus() []menuPO { + root := func(path, name, title, icon string, sort int) menuPO { + return menuPO{Path: path, Name: name, Component: "view/routerHolder.vue", Title: title, Icon: icon, Sort: sort} + } + child := func(parent, path, name, component, title, icon string, sort int) menuPO { + return menuPO{MenuLevel: 1, Path: path, Name: name, Component: component, Title: title, Icon: icon, Sort: sort, ActiveName: parent} + } + cachedChild := func(parent, path, name, component, title, icon string, sort int) menuPO { + value := child(parent, path, name, component, title, icon, sort) + value.KeepAlive = true + return value + } + return []menuPO{{Path: "dashboard", Name: "dashboard", Component: "view/dashboard/index.vue", Title: "仪表盘", Icon: "odometer", Sort: 1}, root("permission", "permission", "权限管理", "perm-kra", 2), root("org", "org", "组织管理", "share", 3), root("systemConfig", "systemConfig", "系统设置", "config-kra", 4), root("monitor", "monitor", "运维监控", "monitor-kra", 5), root("media", "media", "媒体管理", "folder-opened", 6), root("extensions", "extensions", "扩展功能", "cherry", 10), {Path: "person", Name: "person", Component: "view/person/person.vue", Title: "个人信息", Icon: "postcard", Hidden: true, Sort: 13}, child("permission", "authority", "authority", "view/superAdmin/authority/authority.vue", "角色管理", "role-kra", 1), cachedChild("permission", "menu", "menu", "view/superAdmin/menu/menu.vue", "菜单管理", "tickets", 2), cachedChild("permission", "api", "api", "view/superAdmin/api/api.vue", "api管理", "api-kra", 3), child("permission", "apiToken", "apiToken", "view/systemTools/apiToken/index.vue", "API Token", "key", 4), child("org", "user", "user", "view/superAdmin/user/user.vue", "用户管理", "user", 1), child("org", "department", "department", "view/superAdmin/department/department.vue", "部门管理", "office-building", 2), child("org", "position", "position", "view/superAdmin/position/position.vue", "岗位管理", "postcard", 3), child("systemConfig", "system", "system", "view/systemTools/system/system.vue", "配置文件", "config-file-kra", 1), child("systemConfig", "dictionary", "dictionary", "view/superAdmin/dictionary/sysDictionary.vue", "字典管理", "notebook", 2), child("systemConfig", "sysParams", "sysParams", "view/superAdmin/params/sysParams.vue", "参数管理", "set-up", 3), child("systemConfig", "security", "security", "view/system/security/index.vue", "安全配置", "security-kra", 4), child("monitor", "operation", "operation", "view/superAdmin/operation/sysOperationRecord.vue", "操作历史", "document", 1), child("monitor", "loginLog", "loginLog", "view/systemTools/loginLog/index.vue", "登录日志", "clock", 2), child("monitor", "sysError", "sysError", "view/systemTools/sysError/sysError.vue", "错误日志", "error-kra", 3), child("monitor", "sysVersion", "sysVersion", "view/systemTools/version/version.vue", "版本管理", "version-kra", 4), child("monitor", "state", "state", "view/system/state.vue", "服务器状态", "server", 5), child("monitor", "dataAccessLog", "dataAccessLog", "view/superAdmin/dataAccessLog/dataAccessLog.vue", "数据权限审计", "warning", 6), child("monitor", "timedTask", "timedTask", "view/systemTools/timedTask/index.vue", "定时任务", "timer", 7), child("monitor", "logViewer", "logViewer", "view/systemTools/logViewer/index.vue", "文件日志", "document", 8), child("media", "upload", "upload", "view/media/upload.vue", "媒体库(上传下载)", "upload", 1), child("media", "chunkUpload", "chunkUpload", "view/media/chunkUpload.vue", "大文件上传", "folder-add", 2), child("extensions", "email", "email", "modules/email/view/index.vue", "邮件发送", "message", 4), child("extensions", "anInfo", "anInfo", "modules/announcement/view/info.vue", "公告管理", "bell", 5)} +} diff --git a/internal/data/system_init_ignore_test.go b/internal/data/system/system_init_ignore_test.go similarity index 85% rename from internal/data/system_init_ignore_test.go rename to internal/data/system/system_init_ignore_test.go index 57d144a..3dedc04 100644 --- a/internal/data/system_init_ignore_test.go +++ b/internal/data/system/system_init_ignore_test.go @@ -1,4 +1,4 @@ -package data +package system import "testing" @@ -8,7 +8,7 @@ func TestDefaultIgnoredAPIsIncludeSwagger(t *testing.T) { "GET /uploads/file/*filepath": false, "HEAD /uploads/file/*filepath": false, } - for _, api := range defaultIgnoredAPIs("uploads/file") { + for _, api := range DefaultIgnoredAPIs("uploads/file") { key := api.Method + " " + api.Path if _, ok := wants[key]; ok { wants[key] = true diff --git a/internal/data/task.go b/internal/data/system/task.go similarity index 71% rename from internal/data/task.go rename to internal/data/system/task.go index c389158..2290943 100644 --- a/internal/data/task.go +++ b/internal/data/system/task.go @@ -1,8 +1,10 @@ -package data +package system import ( "context" "kra/internal/biz" + "kra/pkg/gormkit" + "kra/pkg/pagination" "time" "gorm.io/gorm" @@ -17,10 +19,10 @@ type taskPO struct { Description, Spec string WithSeconds bool ExecutorType, MethodName string - Params jsonPO + Params gormkit.JSON HTTPURL string HTTPMethod string - HTTPHeader jsonPO + HTTPHeader gormkit.JSON HTTPBody string `gorm:"type:text"` HTTPAllowPrivate, Enabled bool } @@ -42,18 +44,18 @@ type taskLogPO struct { func (taskLogPO) TableName() string { return "sys_timed_task_logs" } -type taskRepo struct{ data *Data } +type taskRepo struct{ data Provider } -func NewTaskRepo(data *Data) biz.TaskRepo { return &taskRepo{data: data} } +func NewTaskRepo(data Provider) biz.TaskRepo { return &taskRepo{data: data} } func taskToPO(v *biz.TimedTask) taskPO { - return taskPO{ID: v.ID, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: jsonPO(v.Params), HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: jsonPO(v.HTTPHeader), HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled} + return taskPO{ID: v.ID, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: gormkit.JSON(v.Params), HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: gormkit.JSON(v.HTTPHeader), HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled} } func taskFromPO(v taskPO) *biz.TimedTask { return &biz.TimedTask{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Description: v.Description, Spec: v.Spec, WithSeconds: v.WithSeconds, ExecutorType: v.ExecutorType, MethodName: v.MethodName, Params: []byte(v.Params), HTTPURL: v.HTTPURL, HTTPMethod: v.HTTPMethod, HTTPHeader: []byte(v.HTTPHeader), HTTPBody: v.HTTPBody, HTTPAllowPrivate: v.HTTPAllowPrivate, Enabled: v.Enabled} } func (r *taskRepo) CreateTask(ctx context.Context, v *biz.TimedTask) error { po := taskToPO(v) - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID = po.ID @@ -61,7 +63,7 @@ func (r *taskRepo) CreateTask(ctx context.Context, v *biz.TimedTask) error { } func (r *taskRepo) TaskNameExists(ctx context.Context, name string, excludeID uint) (bool, error) { var count int64 - db := r.data.gormDB.WithContext(ctx).Model(&taskPO{}).Where("name = ?", name) + db := r.data.DB().WithContext(ctx).Model(&taskPO{}).Where("name = ?", name) if excludeID > 0 { db = db.Where("id <> ?", excludeID) } @@ -70,20 +72,20 @@ func (r *taskRepo) TaskNameExists(ctx context.Context, name string, excludeID ui } func (r *taskRepo) UpdateTask(ctx context.Context, v *biz.TimedTask) error { po := taskToPO(v) - return r.data.gormDB.WithContext(ctx).Model(&taskPO{}).Where("id = ?", v.ID).Select("name", "description", "spec", "with_seconds", "executor_type", "method_name", "params", "http_url", "http_method", "http_header", "http_body", "http_allow_private", "enabled").Updates(&po).Error + return r.data.DB().WithContext(ctx).Model(&taskPO{}).Where("id = ?", v.ID).Select("name", "description", "spec", "with_seconds", "executor_type", "method_name", "params", "http_url", "http_method", "http_header", "http_body", "http_allow_private", "enabled").Updates(&po).Error } func (r *taskRepo) DeleteTask(ctx context.Context, id uint) error { - return r.data.gormDB.WithContext(ctx).Delete(&taskPO{}, id).Error + return r.data.DB().WithContext(ctx).Delete(&taskPO{}, id).Error } func (r *taskRepo) FindTask(ctx context.Context, id uint) (*biz.TimedTask, error) { var po taskPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return taskFromPO(po), nil } func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *biz.TimedTask) ([]*biz.TimedTask, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&taskPO{}) + db := r.data.DB().WithContext(ctx).Model(&taskPO{}) if q != nil { if q.Name != "" { db = db.Where("name LIKE ?", "%"+q.Name+"%") @@ -100,7 +102,7 @@ func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *biz.TimedTa return nil, 0, err } var pos []taskPO - if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.Apply(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.TimedTask, 0, len(pos)) @@ -110,16 +112,16 @@ func (r *taskRepo) ListTasks(ctx context.Context, page, size int, q *biz.TimedTa return out, total, nil } func (r *taskRepo) ToggleTask(ctx context.Context, id uint, enabled bool) error { - return r.data.gormDB.WithContext(ctx).Model(&taskPO{}).Where("id = ?", id).Update("enabled", enabled).Error + return r.data.DB().WithContext(ctx).Model(&taskPO{}).Where("id = ?", id).Update("enabled", enabled).Error } func (r *taskRepo) RecordTaskLog(ctx context.Context, v *biz.TimedTaskLog) error { - return r.data.gormDB.WithContext(ctx).Create(&taskLogPO{TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output}).Error + return r.data.DB().WithContext(ctx).Create(&taskLogPO{TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output}).Error } func taskLogFromPO(v taskLogPO) *biz.TimedTaskLog { return &biz.TimedTaskLog{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, TaskID: v.TaskID, TaskName: v.TaskName, TriggerType: v.TriggerType, StartedAt: v.StartedAt, FinishedAt: v.FinishedAt, DurationMS: v.DurationMS, Status: v.Status, ErrorMsg: v.ErrorMsg, Output: v.Output} } func (r *taskRepo) ListTaskLogs(ctx context.Context, page, size int, taskID uint, status string) ([]*biz.TimedTaskLog, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&taskLogPO{}) + db := r.data.DB().WithContext(ctx).Model(&taskLogPO{}) if taskID != 0 { db = db.Where("task_id = ?", taskID) } @@ -131,7 +133,7 @@ func (r *taskRepo) ListTaskLogs(ctx context.Context, page, size int, taskID uint return nil, 0, err } var pos []taskLogPO - if err := applyPagination(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.Apply(db.Order("id desc"), page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.TimedTaskLog, 0, len(pos)) @@ -142,7 +144,7 @@ func (r *taskRepo) ListTaskLogs(ctx context.Context, page, size int, taskID uint } func (r *taskRepo) CleanupLogs(ctx context.Context) error { now := time.Now() - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Unscoped().Where("created_at < ?", now.Add(-2160*time.Hour)).Delete(&operationPO{}).Error; err != nil { return err } diff --git a/internal/data/system/testing_support_test.go b/internal/data/system/testing_support_test.go new file mode 100644 index 0000000..244349e --- /dev/null +++ b/internal/data/system/testing_support_test.go @@ -0,0 +1,76 @@ +package system + +import ( + "context" + "fmt" + "sync/atomic" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "kra/internal/conf" + "kra/internal/data/migration" +) + +// Data is a small in-package test harness. Production repositories depend on +// Provider; tests keep the old, convenient field-based setup without coupling +// the system module back to internal/data. +type Data struct { + gormDB *reloadableDB + redis *reloadableRedis + runtime *conf.Runtime + databaseReady atomic.Bool +} + +func (d *Data) DB() *gorm.DB { + if d == nil || d.gormDB == nil { + return nil + } + return d.gormDB.DB() +} + +func (d *Data) Database(string) (*gorm.DB, error) { return d.DB(), nil } +func (d *Data) DatabaseReady() bool { return d != nil && d.databaseReady.Load() } +func (d *Data) Runtime() *conf.Runtime { + if d == nil { + return nil + } + return d.runtime +} + +type reloadableDB struct{ db *gorm.DB } + +func newReloadableDB(db *gorm.DB, _ any) *reloadableDB { return &reloadableDB{db: db} } +func (r *reloadableDB) DB() *gorm.DB { + if r == nil { + return nil + } + return r.db +} +func (r *reloadableDB) WithContext(ctx context.Context) *gorm.DB { return r.db.WithContext(ctx) } +func (r *reloadableDB) close() { + if r == nil || r.db == nil { + return + } + sqlDB, err := r.db.DB() + if err == nil { + _ = sqlDB.Close() + } +} + +type reloadableRedis struct{} + +func newReloadableRedis(_ any) *reloadableRedis { return &reloadableRedis{} } + +func openWithDriver(driver, dsn string) (*gorm.DB, error) { + if driver != "sqlite" { + return nil, fmt.Errorf("unsupported test database driver %q", driver) + } + return gorm.Open(sqlite.Open(dsn), &gorm.Config{}) +} + +func migrateAll(db *gorm.DB) error { + return migration.Run(db, []migration.Step{ + {ID: "test_baseline", Migrate: LegacySchemaMigration}, + {ID: "test_reconcile", Migrate: CurrentDataMigration}, + }) +} diff --git a/internal/data/transactions_test.go b/internal/data/system/transactions_test.go similarity index 99% rename from internal/data/transactions_test.go rename to internal/data/system/transactions_test.go index 325a510..c3b00c4 100644 --- a/internal/data/transactions_test.go +++ b/internal/data/system/transactions_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/user.go b/internal/data/system/user.go similarity index 86% rename from internal/data/user.go rename to internal/data/system/user.go index fe556c5..dce38c7 100644 --- a/internal/data/user.go +++ b/internal/data/system/user.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -9,6 +9,7 @@ import ( "time" "kra/internal/biz" + "kra/pkg/pagination" "github.com/google/uuid" "gorm.io/gorm" @@ -17,7 +18,7 @@ import ( func (r *userRepo) FindUserByUsername(ctx context.Context, username string) (*biz.User, error) { var po userPO - if err := r.data.gormDB.WithContext(ctx).Where("username = ?", username).First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("username = ?", username).First(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, biz.ErrUserNotFound } @@ -28,7 +29,7 @@ func (r *userRepo) FindUserByUsername(ctx context.Context, username string) (*bi func (r *userRepo) FindUserByID(ctx context.Context, id uint) (*biz.User, error) { var po userPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, biz.ErrUserNotFound } @@ -39,7 +40,7 @@ func (r *userRepo) FindUserByID(ctx context.Context, id uint) (*biz.User, error) func (r *userRepo) FindUserByUUID(ctx context.Context, value string) (*biz.User, error) { var po userPO - if err := r.data.gormDB.WithContext(ctx).Where("uuid = ?", value).First(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("uuid = ?", value).First(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, biz.ErrUserNotFound } @@ -50,11 +51,11 @@ func (r *userRepo) FindUserByUUID(ctx context.Context, value string) (*biz.User, func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error) { var authority authorityPO - if err := r.data.gormDB.WithContext(ctx).First(&authority, "authority_id = ?", po.AuthorityID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + if err := r.data.DB().WithContext(ctx).First(&authority, "authority_id = ?", po.AuthorityID).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } var authorityPOs []authorityPO - if err := r.data.gormDB.WithContext(ctx).Table("sys_authorities").Joins("JOIN sys_user_authority ON sys_user_authority.sys_authority_authority_id = sys_authorities.authority_id").Where("sys_user_authority.sys_user_id = ? AND sys_authorities.deleted_at IS NULL", po.ID).Find(&authorityPOs).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Table("sys_authorities").Joins("JOIN sys_user_authority ON sys_user_authority.sys_authority_authority_id = sys_authorities.authority_id").Where("sys_user_authority.sys_user_id = ? AND sys_authorities.deleted_at IS NULL", po.ID).Find(&authorityPOs).Error; err != nil { return nil, err } authorities := make([]biz.Authority, 0, len(authorityPOs)) @@ -66,7 +67,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error) _ = json.Unmarshal([]byte(po.OriginSetting), &setting) } var departmentPOs []departmentPO - if err := r.data.gormDB.WithContext(ctx).Table("sys_departments").Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Table("sys_departments").Joins("JOIN sys_user_departments ON sys_user_departments.sys_department_id = sys_departments.id").Where("sys_user_departments.sys_user_id = ? AND sys_departments.deleted_at IS NULL", po.ID).Find(&departmentPOs).Error; err != nil { return nil, err } departments := make([]biz.Department, 0, len(departmentPOs)) @@ -77,14 +78,14 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*biz.User, error) var primary *biz.Department if po.DeptID != 0 { var primaryPO departmentPO - if err := r.data.gormDB.WithContext(ctx).First(&primaryPO, po.DeptID).Error; err == nil { + if err := r.data.DB().WithContext(ctx).First(&primaryPO, po.DeptID).Error; err == nil { primary = deptFromPO(primaryPO) } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } } var positionPOs []positionPO - if err := r.data.gormDB.WithContext(ctx).Table("sys_positions").Joins("JOIN sys_user_positions ON sys_user_positions.sys_position_id = sys_positions.id").Where("sys_user_positions.sys_user_id = ? AND sys_positions.deleted_at IS NULL", po.ID).Find(&positionPOs).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Table("sys_positions").Joins("JOIN sys_user_positions ON sys_user_positions.sys_position_id = sys_positions.id").Where("sys_user_positions.sys_user_id = ? AND sys_positions.deleted_at IS NULL", po.ID).Find(&positionPOs).Error; err != nil { return nil, err } positions := make([]biz.Position, 0, len(positionPOs)) @@ -115,7 +116,7 @@ func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*biz.User, er if len(pos) == 0 { return []*biz.User{}, nil } - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) userIDs := make([]uint, 0, len(pos)) authorityIDs := make([]uint, 0, len(pos)) departmentIDs := make([]uint, 0, len(pos)) @@ -218,7 +219,7 @@ func (r *userRepo) loadUsers(ctx context.Context, pos []userPO) ([]*biz.User, er func (r *userRepo) HasAuthorityMenu(ctx context.Context, authorityID uint, name string) (bool, error) { var count int64 - err := r.data.gormDB.WithContext(ctx).Model(&menuPO{}). + err := r.data.DB().WithContext(ctx).Model(&menuPO{}). Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id"). Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.name = ?", authorityID, name). Count(&count).Error @@ -227,7 +228,7 @@ func (r *userRepo) HasAuthorityMenu(ctx context.Context, authorityID uint, name func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*biz.Menu, error) { var pos []menuPO - err := r.data.gormDB.WithContext(ctx).Table("sys_base_menus"). + err := r.data.DB().WithContext(ctx).Table("sys_base_menus"). Joins("JOIN sys_authority_menus ON sys_authority_menus.sys_base_menu_id = sys_base_menus.id"). Where("sys_authority_menus.sys_authority_authority_id = ? AND sys_base_menus.deleted_at IS NULL", authorityID). Order("sys_base_menus.sort").Scan(&pos).Error @@ -247,7 +248,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b } if len(menuIDs) > 0 { var parameters []menuParameterPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(¶meters).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(¶meters).Error; err != nil { return nil, err } for _, parameter := range parameters { @@ -255,7 +256,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b menu.Parameters = append(menu.Parameters, menuParameterFromPO(parameter)) } var buttons []menuButtonPO - if err := r.data.gormDB.WithContext(ctx).Table("sys_base_menu_btns").Joins("JOIN sys_authority_btns ON sys_authority_btns.sys_base_menu_btn_id = sys_base_menu_btns.id").Where("sys_authority_btns.authority_id = ? AND sys_authority_btns.sys_menu_id IN ?", authorityID, menuIDs).Find(&buttons).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Table("sys_base_menu_btns").Joins("JOIN sys_authority_btns ON sys_authority_btns.sys_base_menu_btn_id = sys_base_menu_btns.id").Where("sys_authority_btns.authority_id = ? AND sys_authority_btns.sys_menu_id IN ?", authorityID, menuIDs).Find(&buttons).Error; err != nil { return nil, err } for _, button := range buttons { @@ -278,7 +279,7 @@ func (r *userRepo) MenusByAuthority(ctx context.Context, authorityID uint) ([]*b } func (r *userRepo) ListUsers(ctx context.Context, page, pageSize int, filter *biz.UserListFilter) ([]*biz.User, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&userPO{}) + db := r.data.DB().WithContext(ctx).Model(&userPO{}) order := "id desc" if filter != nil { if filter.Username != "" { @@ -306,7 +307,7 @@ func (r *userRepo) ListUsers(ctx context.Context, page, pageSize int, filter *bi return nil, 0, err } var pos []userPO - if err := applyRequiredPagination(db.Order(order), page, pageSize, 100).Find(&pos).Error; err != nil { + if err := pagination.ApplyRequired(db.Order(order), page, pageSize, 100).Find(&pos).Error; err != nil { return nil, 0, err } users, err := r.loadUsers(ctx, pos) @@ -337,7 +338,7 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *biz.User now := time.Now() po := userPO{UUID: user.UUID, Username: user.Username, Password: user.Password, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Phone: user.Phone, Email: user.Email, Enable: user.Enable, PasswordUpdatedAt: &now, MustChangePassword: user.MustChangePassword} var createdAuthorities []biz.Authority - if err := r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { // The compatible registration rejects an existing non-deleted username before creating the // account. The username column is intentionally not unique for legacy // schema compatibility, so keep the behavioral check here rather than @@ -384,7 +385,7 @@ func (r *userRepo) UpdateUser(ctx context.Context, user *biz.User) error { if err := r.checkUserUpdateAuth(ctx, user); err != nil { return err } - return r.updateUser(r.data.gormDB.WithContext(ctx), user) + return r.updateUser(r.data.DB().WithContext(ctx), user) } func (r *userRepo) checkUserUpdateAuth(ctx context.Context, user *biz.User) error { @@ -418,7 +419,7 @@ func (r *userRepo) UpdateSelfUser(ctx context.Context, user *biz.User) error { if len(updates) == 0 { return nil } - return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error + return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error } func (r *userRepo) FillDepartmentNamePaths(ctx context.Context, user *biz.User) error { @@ -441,7 +442,7 @@ func (r *userRepo) FillDepartmentNamePaths(ctx context.Context, user *biz.User) nameByID := make(map[uint]string, len(ids)) if len(ids) > 0 { var rows []departmentPO - if err := r.data.gormDB.WithContext(ctx).Select("id", "name").Where("id IN ?", ids).Find(&rows).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Select("id", "name").Where("id IN ?", ids).Find(&rows).Error; err != nil { return err } for _, row := range rows { @@ -478,7 +479,7 @@ func (r *userRepo) UpdateUserWithAuthorities(ctx context.Context, user *biz.User if err := access.checkAuthorityIDsAuth(ctx, authorityIDs); err != nil { return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := r.updateUser(tx, user); err != nil { return err } @@ -492,7 +493,7 @@ func (r *userRepo) DeleteUser(ctx context.Context, id uint) error { if err := (&authorityAccessRepo{data: r.data}).checkUserIDAuth(ctx, id, false); err != nil { return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("id = ?", id).Delete(&userPO{}).Error; err != nil { return err } @@ -517,11 +518,11 @@ func (r *userRepo) UpdatePassword(ctx context.Context, id uint, password string, if clearMustChange { updates["must_change_password"] = false } - return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Updates(updates).Error + return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Updates(updates).Error } func (r *userRepo) ListAuthorities(ctx context.Context) ([]*biz.Authority, error) { var pos []authorityPO - if err := r.data.gormDB.WithContext(ctx).Order("authority_id").Find(&pos).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Order("authority_id").Find(&pos).Error; err != nil { return nil, err } result := make([]*biz.Authority, 0, len(pos)) @@ -540,7 +541,7 @@ func (r *userRepo) SetUserAuthorities(ctx context.Context, id uint, authorityIDs if err := access.checkAuthorityIDsAuth(ctx, authorityIDs); err != nil { return err } - return r.data.gormDB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { var user userPO if err := tx.Where("id = ?", id).First(&user).Error; err != nil { return errors.New("查询用户数据失败") @@ -567,7 +568,7 @@ func setUserAuthorities(tx *gorm.DB, id uint, authorityIDs []uint) error { } func (r *userRepo) SetUserAuthority(ctx context.Context, id, authorityID uint) error { - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) var count int64 if err := db.Model(&userAuthorityPO{}).Where("sys_user_id = ? AND sys_authority_authority_id = ?", id, authorityID).Count(&count).Error; err != nil { return err @@ -597,7 +598,7 @@ func (r *userRepo) SetUserSetting(ctx context.Context, id uint, setting map[stri if err != nil { return err } - return r.data.gormDB.WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Update("origin_setting", string(value)).Error + return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", id).Update("origin_setting", string(value)).Error } // ActiveName temporarily carries the parent menu name during seeding. It is diff --git a/internal/data/user_strict_test.go b/internal/data/system/user_strict_test.go similarity index 99% rename from internal/data/user_strict_test.go rename to internal/data/system/user_strict_test.go index 0079872..9933607 100644 --- a/internal/data/user_strict_test.go +++ b/internal/data/system/user_strict_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/version.go b/internal/data/system/version.go similarity index 87% rename from internal/data/version.go rename to internal/data/system/version.go index ca723b6..72b0d4e 100644 --- a/internal/data/version.go +++ b/internal/data/system/version.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" @@ -7,6 +7,7 @@ import ( "gorm.io/gorm" "kra/internal/biz" + "kra/pkg/pagination" ) type versionPO struct { @@ -22,32 +23,32 @@ type versionPO struct { func (versionPO) TableName() string { return "sys_versions" } -type versionRepo struct{ data *Data } +type versionRepo struct{ data Provider } -func NewVersionRepo(data *Data) biz.VersionRepo { return &versionRepo{data: data} } +func NewVersionRepo(data Provider) biz.VersionRepo { return &versionRepo{data: data} } func versionFromPO(v versionPO) *biz.Version { return &biz.Version{ID: v.ID, CreatedAt: v.CreatedAt, UpdatedAt: v.UpdatedAt, Name: v.Name, Code: v.Code, Description: v.Description, Data: v.Data} } func (r *versionRepo) CreateVersion(ctx context.Context, v *biz.Version) error { po := versionPO{Name: v.Name, Code: v.Code, Description: v.Description, Data: v.Data} - if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Create(&po).Error; err != nil { return err } v.ID = po.ID return nil } func (r *versionRepo) DeleteVersions(ctx context.Context, ids []uint) error { - return r.data.gormDB.WithContext(ctx).Where("id IN ?", ids).Delete(&versionPO{}).Error + return r.data.DB().WithContext(ctx).Where("id IN ?", ids).Delete(&versionPO{}).Error } func (r *versionRepo) FindVersion(ctx context.Context, id uint) (*biz.Version, error) { var po versionPO - if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + if err := r.data.DB().WithContext(ctx).First(&po, id).Error; err != nil { return nil, err } return versionFromPO(po), nil } func (r *versionRepo) ListVersions(ctx context.Context, page, size int, name, code string, createdAtRange []*time.Time) ([]*biz.Version, int64, error) { - db := r.data.gormDB.WithContext(ctx).Model(&versionPO{}) + db := r.data.DB().WithContext(ctx).Model(&versionPO{}) if len(createdAtRange) == 2 && createdAtRange[0] != nil && createdAtRange[1] != nil { db = db.Where("created_at BETWEEN ? AND ?", createdAtRange[0], createdAtRange[1]) } @@ -62,7 +63,7 @@ func (r *versionRepo) ListVersions(ctx context.Context, page, size int, name, co return nil, 0, err } var pos []versionPO - if err := applyPagination(db, page, size, 100).Find(&pos).Error; err != nil { + if err := pagination.Apply(db, page, size, 100).Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*biz.Version, 0, len(pos)) @@ -75,7 +76,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d bundle := &biz.VersionBundle{} var menus []menuPO if len(menuIDs) > 0 { - if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", menuIDs).Order("sort,id").Find(&menus).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("id IN ?", menuIDs).Order("sort,id").Find(&menus).Error; err != nil { return nil, &biz.VersionStageError{Stage: biz.VersionStageMenus, Err: err} } } @@ -86,7 +87,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d } if len(menuIDs) > 0 { var buttons []menuButtonPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(&buttons).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(&buttons).Error; err != nil { return nil, &biz.VersionStageError{Stage: biz.VersionStageMenus, Err: err} } for _, button := range buttons { @@ -95,7 +96,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d } } var parameters []menuParameterPO - if err := r.data.gormDB.WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(¶meters).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("sys_base_menu_id IN ?", menuIDs).Find(¶meters).Error; err != nil { return nil, &biz.VersionStageError{Stage: biz.VersionStageMenus, Err: err} } for _, parameter := range parameters { @@ -142,7 +143,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d } var apis []apiPO if len(apiIDs) > 0 { - if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", apiIDs).Find(&apis).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("id IN ?", apiIDs).Find(&apis).Error; err != nil { return nil, &biz.VersionStageError{Stage: biz.VersionStageAPIs, Err: err} } } @@ -153,7 +154,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d } var dictionaries []dictionaryPO if len(dictIDs) > 0 { - if err := r.data.gormDB.WithContext(ctx).Where("id IN ?", dictIDs).Find(&dictionaries).Error; err != nil { + if err := r.data.DB().WithContext(ctx).Where("id IN ?", dictIDs).Find(&dictionaries).Error; err != nil { return nil, &biz.VersionStageError{Stage: biz.VersionStageDictionaries, Err: err} } } @@ -178,7 +179,7 @@ func (r *versionRepo) BuildVersionBundle(ctx context.Context, menuIDs, apiIDs, d return bundle, nil } func (r *versionRepo) ImportVersionBundle(ctx context.Context, b *biz.VersionBundle) error { - db := r.data.gormDB.WithContext(ctx) + db := r.data.DB().WithContext(ctx) if len(b.Menus) > 0 { if err := db.Transaction(func(tx *gorm.DB) error { return importVersionMenus(tx, b.Menus, 0) }); err != nil { return &biz.VersionStageError{Stage: biz.VersionStageMenus, Err: err} diff --git a/internal/data/version_test.go b/internal/data/system/version_test.go similarity index 98% rename from internal/data/version_test.go rename to internal/data/system/version_test.go index 4c06953..5ae4405 100644 --- a/internal/data/version_test.go +++ b/internal/data/system/version_test.go @@ -1,4 +1,4 @@ -package data +package system import ( "context" diff --git a/internal/data/cache.go b/internal/integration/cache/cache.go similarity index 64% rename from internal/data/cache.go rename to internal/integration/cache/cache.go index d1b0508..1393bab 100644 --- a/internal/data/cache.go +++ b/internal/integration/cache/cache.go @@ -1,4 +1,4 @@ -package data +package cache import ( "context" @@ -6,33 +6,34 @@ import ( "sync" "time" - "kra/internal/biz" - "github.com/redis/go-redis/v9" + "kra/internal/biz" ) -type memoryCacheEntry struct { +type RedisProvider interface { + RedisClient() redis.UniversalClient +} + +type memoryEntry struct { value string expiresAt time.Time } -type cacheStore struct { - data *Data - mu sync.Mutex - memory map[string]memoryCacheEntry +type Store struct { + provider RedisProvider + mu sync.Mutex + memory map[string]memoryEntry } -const maxMemoryCacheEntries = 10000 +const maxMemoryEntries = 10000 -func NewCache(data *Data) biz.Cache { - return &cacheStore{data: data, memory: make(map[string]memoryCacheEntry)} +func New(provider RedisProvider) biz.Cache { + return &Store{provider: provider, memory: make(map[string]memoryEntry)} } -func (s *cacheStore) client() redis.UniversalClient { - return s.data.redis.load() -} +func (s *Store) client() redis.UniversalClient { return s.provider.RedisClient() } -func (s *cacheStore) Get(ctx context.Context, key string) (string, bool, error) { +func (s *Store) Get(ctx context.Context, key string) (string, bool, error) { if client := s.client(); client != nil { value, err := client.Get(ctx, key).Result() if err == nil { @@ -56,14 +57,14 @@ func (s *cacheStore) Get(ctx context.Context, key string) (string, bool, error) return entry.value, true, nil } -func (s *cacheStore) Set(ctx context.Context, key, value string, expiration time.Duration) error { +func (s *Store) Set(ctx context.Context, key, value string, expiration time.Duration) error { if client := s.client(); client != nil { return client.Set(ctx, key, value, expiration).Err() } s.mu.Lock() defer s.mu.Unlock() - s.makeMemoryRoom(key) - entry := memoryCacheEntry{value: value} + s.makeRoom(key) + entry := memoryEntry{value: value} if expiration > 0 { entry.expiresAt = time.Now().Add(expiration) } @@ -71,7 +72,7 @@ func (s *cacheStore) Set(ctx context.Context, key, value string, expiration time return nil } -func (s *cacheStore) Delete(ctx context.Context, key string) error { +func (s *Store) Delete(ctx context.Context, key string) error { if client := s.client(); client != nil { return client.Del(ctx, key).Err() } @@ -81,7 +82,7 @@ func (s *cacheStore) Delete(ctx context.Context, key string) error { return nil } -func (s *cacheStore) Increment(ctx context.Context, key string, expiration time.Duration) (int64, error) { +func (s *Store) Increment(ctx context.Context, key string, expiration time.Duration) (int64, error) { if client := s.client(); client != nil { value, err := client.Incr(ctx, key).Result() if err != nil { @@ -96,7 +97,7 @@ func (s *cacheStore) Increment(ctx context.Context, key string, expiration time. } s.mu.Lock() defer s.mu.Unlock() - s.makeMemoryRoom(key) + s.makeRoom(key) entry, ok := s.memory[key] if ok && !entry.expiresAt.IsZero() && time.Now().After(entry.expiresAt) { ok = false @@ -114,8 +115,8 @@ func (s *cacheStore) Increment(ctx context.Context, key string, expiration time. return value, nil } -func (s *cacheStore) makeMemoryRoom(incoming string) { - if len(s.memory) < maxMemoryCacheEntries { +func (s *Store) makeRoom(incoming string) { + if len(s.memory) < maxMemoryEntries { return } now := time.Now() @@ -124,14 +125,12 @@ func (s *cacheStore) makeMemoryRoom(incoming string) { delete(s.memory, key) } } - if len(s.memory) < maxMemoryCacheEntries { + if len(s.memory) < maxMemoryEntries { return } if _, exists := s.memory[incoming]; exists { return } - // The in-memory store is only a Redis fallback. A bounded arbitrary eviction - // is preferable to unbounded growth under IP churn. for key := range s.memory { delete(s.memory, key) break diff --git a/internal/integration/storage/reloadable.go b/internal/integration/storage/reloadable.go new file mode 100644 index 0000000..ea70b3d --- /dev/null +++ b/internal/integration/storage/reloadable.go @@ -0,0 +1,68 @@ +package storage + +import ( + "context" + "io" + "sync" + + "kra/internal/biz" + "kra/internal/conf" +) + +type Reloadable struct { + mu sync.RWMutex + current biz.FileStorage +} + +func NewFileStorage(runtime *conf.Runtime) (*Reloadable, error) { + var config *conf.AdminBackend + if runtime != nil { + config = runtime.Admin() + } + return NewReloadable(config) +} + +func NewReloadable(config *conf.AdminBackend) (*Reloadable, error) { + current, err := New(config) + if err != nil { + return nil, err + } + return &Reloadable{current: current}, nil +} + +func (s *Reloadable) Replace(current biz.FileStorage) { + s.mu.Lock() + s.current = current + s.mu.Unlock() +} + +func (s *Reloadable) Put(ctx context.Context, name string, reader io.Reader) (*biz.StoredFile, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current.Put(ctx, name, reader) +} +func (s *Reloadable) Open(ctx context.Context, name string) (io.ReadCloser, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current.Open(ctx, name) +} +func (s *Reloadable) Delete(ctx context.Context, name string) error { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current.Delete(ctx, name) +} +func (s *Reloadable) Compose(ctx context.Context, names []string, destination string) (*biz.StoredFile, string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current.Compose(ctx, names, destination) +} +func (s *Reloadable) DeletePrefix(ctx context.Context, prefix string) error { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current.DeletePrefix(ctx, prefix) +} +func (s *Reloadable) List(ctx context.Context, prefix, cursor string, limit int) ([]*biz.StoredFile, string, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.current.List(ctx, prefix, cursor, limit) +}