From 63dbb4cfbc8c89523644ef3179f656ac6f06313d Mon Sep 17 00:00:00 2001 From: Yvan <8574526@qq,com> Date: Thu, 20 Aug 2026 21:46:52 +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 --- AGENTS.md | 11 +- cmd/kratos-admin/main.go | 2 +- cmd/kratos-admin/wire.go | 18 +- cmd/kratos-admin/wire_gen.go | 5 +- internal/adminsurface/surface.go | 25 + internal/data/README.md | 31 +- internal/data/config_helpers.go | 35 ++ internal/data/data.go | 9 +- internal/data/database.go | 2 +- internal/data/gorm_logger_test.go | 4 +- internal/data/gormkit/README.md | 5 + {pkg => internal/data}/gormkit/logger.go | 0 internal/data/initialization.go | 9 - ...stem_init.go => initialization_backend.go} | 116 ++-- ...test.go => initialization_backend_test.go} | 5 +- internal/data/integration_config_test.go | 3 +- internal/data/migration/runner.go | 6 +- internal/data/migrations.go | 26 +- internal/data/migrations_test.go | 41 ++ .../data}/pagination/pagination.go | 4 +- internal/data/payment/migrations.go | 26 +- internal/data/payment/payment_helpers.go | 6 +- internal/data/payment/payment_order.go | 2 +- internal/data/payment/testing_support_test.go | 13 +- internal/data/system/announcement.go | 6 +- internal/data/system/api.go | 2 +- internal/data/system/api_token.go | 2 +- internal/data/system/bootstrap.go | 25 - internal/data/system/data_access_log.go | 2 +- internal/data/system/dictionary.go | 2 +- internal/data/system/error_record.go | 2 +- internal/data/system/login_log.go | 2 +- internal/data/system/migrations.go | 519 +----------------- internal/data/system/migrations_test.go | 272 +-------- internal/data/system/operation_log.go | 2 +- internal/data/system/parameter.go | 2 +- internal/data/system/runtime.go | 2 +- internal/data/system/seed.go | 49 +- internal/data/system/seed_test.go | 56 ++ internal/data/system/task.go | 4 +- internal/data/system/testing_support_test.go | 11 +- internal/data/system/user.go | 2 +- internal/data/system/version.go | 2 +- internal/initialize/README.md | 22 + internal/initialize/backend.go | 24 + .../compatibility.go} | 2 +- .../configuration.go} | 136 +---- internal/initialize/initialize.go | 43 ++ internal/initialize/provider.go | 5 + internal/integration/README.md | 20 + internal/integration/payment/gopay_test.go | 14 +- internal/integration/payment/qq_test.go | 6 +- internal/integration/payment/result.go | 26 +- internal/integration/payment/vendor.go | 14 +- internal/integration/provider.go | 20 + .../integration/storage}/compose.go | 6 +- .../integration/storage}/compose_test.go | 6 +- internal/integration/storage/local.go | 3 +- {pkg => internal}/logging/context.go | 0 {pkg => internal}/logging/daily.go | 0 {pkg => internal}/logging/source.go | 2 +- {pkg => internal}/logging/zap.go | 2 +- {pkg => internal}/logging/zap_test.go | 0 internal/security/README.md | 6 + {pkg => internal/security}/adminauth/token.go | 0 internal/server/middleware/request.go | 2 +- internal/utils/README.md | 11 + internal/utils/configutil/json.go | 78 +++ internal/utils/configutil/json_test.go | 28 + internal/utils/paymentutil/README.md | 5 + .../utils/paymentutil}/amount.go | 2 +- .../utils/paymentutil}/amount_test.go | 2 +- .../utils/paymentutil}/json.go | 2 +- .../utils/paymentutil}/json_test.go | 2 +- .../utils/paymentutil}/signing.go | 2 +- .../utils/paymentutil}/signing_test.go | 2 +- .../utils/paymentutil}/status.go | 2 +- .../utils/paymentutil}/xml.go | 2 +- 78 files changed, 775 insertions(+), 1090 deletions(-) create mode 100644 internal/adminsurface/surface.go create mode 100644 internal/data/config_helpers.go create mode 100644 internal/data/gormkit/README.md rename {pkg => internal/data}/gormkit/logger.go (100%) delete mode 100644 internal/data/initialization.go rename internal/data/{system_init.go => initialization_backend.go} (51%) rename internal/data/{system_init_state_test.go => initialization_backend_test.go} (94%) create mode 100644 internal/data/migrations_test.go rename {pkg => internal/data}/pagination/pagination.go (89%) create mode 100644 internal/data/system/seed_test.go create mode 100644 internal/initialize/README.md create mode 100644 internal/initialize/backend.go rename internal/{data/admin_config_compat.go => initialize/compatibility.go} (99%) rename internal/{data/config_management.go => initialize/configuration.go} (74%) create mode 100644 internal/initialize/initialize.go create mode 100644 internal/initialize/provider.go create mode 100644 internal/integration/README.md create mode 100644 internal/integration/provider.go rename {pkg/osskit => internal/integration/storage}/compose.go (92%) rename {pkg/osskit => internal/integration/storage}/compose_test.go (89%) rename {pkg => internal}/logging/context.go (100%) rename {pkg => internal}/logging/daily.go (100%) rename {pkg => internal}/logging/source.go (99%) rename {pkg => internal}/logging/zap.go (99%) rename {pkg => internal}/logging/zap_test.go (100%) create mode 100644 internal/security/README.md rename {pkg => internal/security}/adminauth/token.go (100%) create mode 100644 internal/utils/README.md create mode 100644 internal/utils/configutil/json.go create mode 100644 internal/utils/configutil/json_test.go create mode 100644 internal/utils/paymentutil/README.md rename {pkg/paymentkit => internal/utils/paymentutil}/amount.go (99%) rename {pkg/paymentkit => internal/utils/paymentutil}/amount_test.go (97%) rename {pkg/paymentkit => internal/utils/paymentutil}/json.go (98%) rename {pkg/paymentkit => internal/utils/paymentutil}/json_test.go (95%) rename {pkg/paymentkit => internal/utils/paymentutil}/signing.go (98%) rename {pkg/paymentkit => internal/utils/paymentutil}/signing_test.go (94%) rename {pkg/paymentkit => internal/utils/paymentutil}/status.go (98%) rename {pkg/paymentkit => internal/utils/paymentutil}/xml.go (98%) diff --git a/AGENTS.md b/AGENTS.md index c198cb1..60d8817 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,12 @@ internal/conf/ Config proto; generated by `make config`. internal/server/ HTTP/gRPC server wiring. internal/service/ Transport adapters; one file per resource. internal/biz/ Domain models, usecases, repo interfaces, errors. -internal/data/ Repo implementations and storage clients. +internal/data/ Repo implementations, database clients, migrations. +internal/initialize/ First-install and configuration orchestration. +internal/integration/ External I/O adapters: cache, email, payment, storage. +internal/logging/ Application logging infrastructure. +internal/security/ Security mechanisms such as admin JWT handling. +internal/utils/ Stateless, internal-only helper packages. ``` ## Layering & dependency rules @@ -43,6 +48,10 @@ owns the PO; `service` is a pass-through that converts at its boundary. never `data`. The repo interface declared here is the inversion seam. - `data` imports `biz` to implement the repo interface. Never `service`, never DTOs. +- `integration` implements external I/O boundaries and may import `biz` and + provider SDKs. It is not a utility layer. +- `utils` contains stateless helpers only. It must not own clients, watchers, + repositories, runtime configuration, or provider SDK lifecycles. - `cmd` is the only place that wires all layers via Wire. A change crossing these arrows the wrong way is a layering bug; fix the diff --git a/cmd/kratos-admin/main.go b/cmd/kratos-admin/main.go index 00d0fc1..cbb2bff 100644 --- a/cmd/kratos-admin/main.go +++ b/cmd/kratos-admin/main.go @@ -10,10 +10,10 @@ import ( "strings" "kra/internal/conf" + "kra/internal/logging" "kra/internal/service" "kra/internal/service/dto" "kra/internal/worker" - "kra/pkg/logging" "github.com/go-kratos/kratos/v3" "github.com/go-kratos/kratos/v3/config" diff --git a/cmd/kratos-admin/wire.go b/cmd/kratos-admin/wire.go index f4e352a..0f31f9f 100644 --- a/cmd/kratos-admin/wire.go +++ b/cmd/kratos-admin/wire.go @@ -11,10 +11,13 @@ import ( "kra/internal/biz" "kra/internal/conf" "kra/internal/data" + "kra/internal/initialize" + "kra/internal/integration" + "kra/internal/integration/cache" + "kra/internal/logging" "kra/internal/server" "kra/internal/service" "kra/internal/worker" - "kra/pkg/logging" "github.com/go-kratos/kratos/v3" "github.com/google/wire" @@ -22,5 +25,16 @@ import ( // wireApp init kratos application. func wireApp(*conf.Server, *conf.Runtime, *slog.Logger, *logging.ReloadableLogger, string) (*kratos.App, func(), error) { - panic(wire.Build(server.ProviderSet, worker.ProviderSet, data.ProviderSet, biz.ProviderSet, service.ProviderSet, newApp)) + panic(wire.Build( + server.ProviderSet, + worker.ProviderSet, + data.ProviderSet, + integration.ProviderSet, + initialize.ProviderSet, + wire.Bind(new(initialize.Backend), new(*data.Data)), + wire.Bind(new(cache.RedisProvider), new(*data.Data)), + biz.ProviderSet, + service.ProviderSet, + newApp, + )) } diff --git a/cmd/kratos-admin/wire_gen.go b/cmd/kratos-admin/wire_gen.go index 81a4750..7c4cc06 100644 --- a/cmd/kratos-admin/wire_gen.go +++ b/cmd/kratos-admin/wire_gen.go @@ -13,14 +13,15 @@ import ( "kra/internal/data" "kra/internal/data/payment" "kra/internal/data/system" + "kra/internal/initialize" "kra/internal/integration/cache" "kra/internal/integration/email" "kra/internal/integration/storage" + "kra/internal/logging" "kra/internal/server" "kra/internal/server/handler" "kra/internal/service" "kra/internal/worker" - "kra/pkg/logging" "log/slog" ) @@ -123,7 +124,7 @@ func wireApp(confServer *conf.Server, runtime *conf.Runtime, logger *slog.Logger tokenIssuer := system.NewTokenIssuer(runtimeSettings) tokenService := service.NewTokenService(tokenUsecase, tokenIssuer) apiToken := handler.NewAPIToken(tokenService) - initializationRepo := data.NewInitializationRepo(dataData) + initializationRepo := initialize.NewRepo(dataData) systemConfigUsecase := biz.NewSystemConfigUsecase(initializationRepo, taskRuntime) systemConfigService := service.NewSystemConfigService(systemConfigUsecase, runtimeSettings) securityRepo := system.NewSecurityRepo(dataData) diff --git a/internal/adminsurface/surface.go b/internal/adminsurface/surface.go new file mode 100644 index 0000000..bc2ad92 --- /dev/null +++ b/internal/adminsurface/surface.go @@ -0,0 +1,25 @@ +// Package adminsurface defines administration UI metadata contributed by +// business modules. The system data module owns how this metadata is stored. +package adminsurface + +type Menu struct { + Name string + Path string + ParentName string + Component string + Title string + Icon string + Sort int +} + +type API struct { + Path string + Method string + APIGroup string + Description string +} + +type Surface struct { + Menus []Menu + APIs []API +} diff --git a/internal/data/README.md b/internal/data/README.md index cbd4287..3e56032 100644 --- a/internal/data/README.md +++ b/internal/data/README.md @@ -15,7 +15,11 @@ feature can be found by its domain instead of by scanning one large package. persistence. Provider SDK implementations are separate in `internal/integration/payment`. - `internal/data/migration` contains the version-table runner used by the - root migration coordinator. + root migration coordinator. It stays under data because it executes and + records database schema steps. +- `internal/adminsurface` contains the small menu/API metadata contract used + when a business module contributes pages to the administration UI. It lives + outside `data` because the contract itself is not persistence code. The root package intentionally keeps only cross-cutting infrastructure: database lifecycle/reloads, runtime configuration persistence, data-scope @@ -23,6 +27,27 @@ auditing, integration configuration storage, and migration orchestration. Repositories depend on narrow module seams (`system.Provider` and `payment.Provider`) rather than importing the root implementation details. +First-install and configuration-management orchestration lives in +`internal/initialize`. It implements `biz.InitializationRepo` through a narrow +backend interface that `*data.Data` satisfies. This keeps administrator/menu/API +seeding out of the database lifecycle package while retaining one migration +entry point. + Non-database adapters remain under `internal/integration` (storage, email, -payment SDKs, and cache), while reusable GORM and pagination helpers live in -`pkg/gormkit` and `pkg/pagination`. +payment SDKs, and cache). Their constructors are provided by +`internal/integration.ProviderSet`, separate from the data provider set. +GORM and pagination helpers live beside the data layer; stateless payment +parsing lives in `internal/utils/paymentutil`; admin JWT handling lives in +`internal/security/adminauth`. + +## Migration and bootstrap + +There is one migration execution entry point: `migrateAll` in the root data +package. The root owns the shared integration-configuration table and appends +the steps returned by `system.Migrations()` and `payment.Migrations()`. + +Migrations create schemas and module-required fixed records only. Initial +business data is separate: `initialize.Repo` invokes `system.SeedSystem` only +from the explicit first-install flow to create the administrator, roles, +menus, APIs, departments, policies, and module-provided administration +surfaces. diff --git a/internal/data/config_helpers.go b/internal/data/config_helpers.go new file mode 100644 index 0000000..33bb9f6 --- /dev/null +++ b/internal/data/config_helpers.go @@ -0,0 +1,35 @@ +package data + +import ( + "kra/internal/conf" + + "google.golang.org/protobuf/proto" +) + +func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend { + if value == nil { + return &conf.AdminBackend{} + } + return proto.Clone(value).(*conf.AdminBackend) +} + +func maskStorageSecrets(storage *conf.AdminBackend_Storage) { + if storage == nil { + return + } + if storage.Qiniu != nil && storage.Qiniu.SecretKey != "" { + storage.Qiniu.SecretKey = "******" + } + for _, item := range []*conf.AdminBackend_ObjectStore{ + storage.AliyunOss, + storage.HuaweiObs, + storage.TencentCos, + storage.AwsS3, + storage.CloudflareR2, + storage.Minio, + } { + if item != nil && item.SecretKey != "" { + item.SecretKey = "******" + } + } +} diff --git a/internal/data/data.go b/internal/data/data.go index 17088e0..9df079e 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -11,12 +11,9 @@ import ( "github.com/google/wire" "github.com/redis/go-redis/v9" "gorm.io/gorm" - "kra/internal/biz" "kra/internal/conf" datapayment "kra/internal/data/payment" datasystem "kra/internal/data/system" - "kra/internal/integration/cache" - "kra/internal/integration/email" "kra/internal/integration/storage" ) @@ -25,15 +22,13 @@ var ProviderSet = wire.NewSet( wire.Bind(new(datasystem.Provider), new(*Data)), wire.Bind(new(datasystem.DatabaseProvider), new(*Data)), wire.Bind(new(datapayment.Provider), new(*Data)), - wire.Bind(new(cache.RedisProvider), new(*Data)), - wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)), datasystem.NewRuntimeSettings, datasystem.NewTokenIssuer, - datasystem.NewUserRepo, NewInitializationRepo, datasystem.NewAuthorityAccessRepo, datasystem.NewAPIRepo, datasystem.NewPermissionRepo, + datasystem.NewUserRepo, datasystem.NewAuthorityAccessRepo, datasystem.NewAPIRepo, datasystem.NewPermissionRepo, datasystem.NewMenuRepo, datasystem.NewDepartmentRepo, datasystem.NewPositionRepo, datasystem.NewDictionaryRepo, datasystem.NewParameterRepo, datasystem.NewAPITokenRepo, datasystem.NewSecurityRepo, datasystem.NewVersionRepo, datasystem.NewExportRepo, datasystem.NewAuditRepo, datasystem.NewAuditRecorderRepo, datasystem.NewLogFileRepo, datasystem.NewTaskRepo, - datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, email.NewEmailRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo, cache.New, storage.NewFileStorage, + datasystem.NewMediaRepo, datasystem.NewAnnouncementRepo, datapayment.NewPaymentRepo, datapayment.NewPaymentOrderRepo, ) type Data struct { diff --git a/internal/data/database.go b/internal/data/database.go index 47ea5f7..73c4cd5 100644 --- a/internal/data/database.go +++ b/internal/data/database.go @@ -20,7 +20,7 @@ import ( "gorm.io/gorm/logger" "gorm.io/gorm/schema" "kra/internal/conf" - "kra/pkg/gormkit" + "kra/internal/data/gormkit" ) var databaseNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_$-]*$`) diff --git a/internal/data/gorm_logger_test.go b/internal/data/gorm_logger_test.go index 5f22f84..c6241d3 100644 --- a/internal/data/gorm_logger_test.go +++ b/internal/data/gorm_logger_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "kra/pkg/gormkit" - "kra/pkg/logging" + "kra/internal/data/gormkit" + "kra/internal/logging" "gorm.io/gorm/logger" ) diff --git a/internal/data/gormkit/README.md b/internal/data/gormkit/README.md new file mode 100644 index 0000000..d61c3b9 --- /dev/null +++ b/internal/data/gormkit/README.md @@ -0,0 +1,5 @@ +# GORM Helpers + +This package owns storage-specific GORM types and logging integration used by +the data repositories. It is intentionally internal because its JSON value and +logger are persistence concerns, not general application utilities. diff --git a/pkg/gormkit/logger.go b/internal/data/gormkit/logger.go similarity index 100% rename from pkg/gormkit/logger.go rename to internal/data/gormkit/logger.go diff --git a/internal/data/initialization.go b/internal/data/initialization.go deleted file mode 100644 index 9160f2c..0000000 --- a/internal/data/initialization.go +++ /dev/null @@ -1,9 +0,0 @@ -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/system_init.go b/internal/data/initialization_backend.go similarity index 51% rename from internal/data/system_init.go rename to internal/data/initialization_backend.go index 6dcaf30..de5e6af 100644 --- a/internal/data/system_init.go +++ b/internal/data/initialization_backend.go @@ -7,17 +7,58 @@ import ( "kra/internal/biz" "kra/internal/conf" - datasystem "kra/internal/data/system" "kra/internal/integration/storage" "github.com/google/uuid" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" + "gorm.io/gorm" ) -func (r *initializationRepo) PersistConfig(context.Context) error { return r.data.persistConfig() } -func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte) error { - currentData, currentAdmin := r.data.runtime.Values() +func (d *Data) RuntimeValues() (*conf.Data, *conf.AdminBackend) { return d.runtime.Values() } +func (d *Data) RuntimeAdmin() *conf.AdminBackend { return d.runtime.Admin() } +func (d *Data) RefreshDatabaseSources(value *conf.Data) error { return refreshDatabaseSources(value) } + +func refreshDatabaseSources(value *conf.Data) error { + if value == nil { + return nil + } + if err := refreshDatabaseSource(value.Database); err != nil { + return err + } + for _, database := range value.DatabaseList { + if database == nil || database.Disable { + continue + } + if err := refreshDatabaseSource(database); err != nil { + return err + } + } + return nil +} + +func refreshDatabaseSource(database *conf.Data_Database) error { + if database == nil { + return nil + } + hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || database.Password != "" || database.Name != "" || database.Config != "" || database.Path != "" + if !hasStructuredConfig { + return nil + } + previousSource := database.Source + database.Source = "" + source, err := databaseDSN(database, "") + if err != nil { + database.Source = previousSource + return err + } + database.Source = source + return nil +} + +func (d *Data) PersistConfig(context.Context) error { return d.persistConfig() } +func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error { + currentData, currentAdmin := d.runtime.Values() next := proto.Clone(currentAdmin).(*conf.AdminBackend) if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, next); err != nil { return err @@ -33,25 +74,25 @@ func (r *initializationRepo) PersistAdminConfig(ctx context.Context, raw []byte) if err != nil { return err } - if err := r.data.persistStorageIntegrationConfig(ctx, next.Storage); err != nil { + if err := d.persistStorageIntegrationConfig(ctx, next.Storage); err != nil { return err } - if err := r.data.persistEmailIntegrationConfig(ctx, next.Email); err != nil { + if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil { return err } - if err := r.data.persistConfigValues(currentData, next); err != nil { + if err := d.persistConfigValues(currentData, next); err != nil { return err } // Writing through the management API updates the same in-memory values // immediately; the file watcher remains the fallback for external edits. - r.data.runtime.Replace(currentData, next) - if r.data.storage != nil { - r.data.storage.Replace(candidateStorage) + d.runtime.Replace(currentData, next) + if d.storage != nil { + d.storage.Replace(candidateStorage) } return nil } -func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error { - currentData, currentAdmin := r.data.runtime.Values() +func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byte) error { + currentData, currentAdmin := d.runtime.Values() nextData := proto.Clone(currentData).(*conf.Data) nextAdmin := proto.Clone(currentAdmin).(*conf.AdminBackend) options := protojson.UnmarshalOptions{DiscardUnknown: true} @@ -72,32 +113,35 @@ func (r *initializationRepo) PersistRuntimeConfig(ctx context.Context, dataRaw, if err != nil { return err } - if err := r.data.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil { + if err := d.persistStorageIntegrationConfig(ctx, nextAdmin.Storage); err != nil { return err } - if err := r.data.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil { + if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil { return err } - if err := r.data.persistConfigValues(nextData, nextAdmin); err != nil { + if err := d.persistConfigValues(nextData, nextAdmin); err != nil { return err } - r.data.runtime.Replace(nextData, nextAdmin) - if r.data.storage != nil { - r.data.storage.Replace(candidateStorage) + d.runtime.Replace(nextData, nextAdmin) + if d.storage != nil { + d.storage.Replace(candidateStorage) } return nil } -func (r *initializationRepo) ReloadConfig(ctx context.Context) error { - return r.data.reloadConfig(ctx) +func (d *Data) ReloadConfig(ctx context.Context) error { + return d.reloadConfig(ctx) } -func (r *initializationRepo) IsInitialized(ctx context.Context) (bool, error) { - return r.data.databaseReady.Load(), nil +func (d *Data) IsInitialized(context.Context) (bool, error) { + return d.databaseReady.Load(), nil } -func (r *initializationRepo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error { +// InitializeDatabase opens and activates the configured database. The callback +// is the application-level first-install hook; data owns only lifecycle and +// schema migration, while initialize owns system seed orchestration. +func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig, seed func(context.Context, *gorm.DB) error) error { config := &conf.Data_Database{} - if current := r.data.runtime.Data(); current != nil && current.Database != nil { + if current := d.runtime.Data(); current != nil && current.Database != nil { config = proto.Clone(current.Database).(*conf.Data_Database) } config.Driver = input.Driver @@ -114,16 +158,16 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database return err } config.Source = source - r.data.initMu.Lock() - defer r.data.initMu.Unlock() - initialized, err := r.IsInitialized(ctx) + d.initMu.Lock() + defer d.initMu.Unlock() + initialized, err := d.IsInitialized(ctx) if err != nil { return err } if initialized { return errors.New("数据库已初始化,无需重复初始化") } - candidate, err := openDatabase(config, true, input.Template, r.data.logger()) + candidate, err := openDatabase(config, true, input.Template, d.logger()) if err != nil { return err } @@ -139,10 +183,12 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database if err := migrateAll(db); err != nil { return err } - if err := datasystem.SeedSystem(ctx, db, input); err != nil { - return err + if seed != nil { + if err := seed(ctx, db); err != nil { + return err + } } - currentAdmin := r.data.runtime.Admin() + currentAdmin := d.runtime.Admin() var legacyStorage *conf.AdminBackend_Storage if currentAdmin != nil { legacyStorage = currentAdmin.Storage @@ -160,11 +206,11 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database return fmt.Errorf("initialize email integration configuration: %w", err) } signingKey := uuid.NewString() - if err := r.data.persistDatabaseConfig(config, signingKey); err != nil { + if err := d.persistDatabaseConfig(config, signingKey); err != nil { return fmt.Errorf("persist database configuration: %w", err) } - r.data.activateDatabase(candidate, config) - currentData, currentAdmin := r.data.runtime.Values() + d.activateDatabase(candidate, config) + currentData, currentAdmin := d.runtime.Values() if currentAdmin == nil { currentAdmin = &conf.AdminBackend{} } @@ -174,7 +220,7 @@ func (r *initializationRepo) Initialize(ctx context.Context, input *biz.Database currentAdmin.Jwt.SigningKey = signingKey currentAdmin.Storage = storageConfig currentAdmin.Email = emailConfig - r.data.runtime.Replace(currentData, currentAdmin) + d.runtime.Replace(currentData, currentAdmin) activated = true return nil } diff --git a/internal/data/system_init_state_test.go b/internal/data/initialization_backend_test.go similarity index 94% rename from internal/data/system_init_state_test.go rename to internal/data/initialization_backend_test.go index 31c3e56..1677659 100644 --- a/internal/data/system_init_state_test.go +++ b/internal/data/initialization_backend_test.go @@ -35,9 +35,8 @@ func TestDatabaseConnectionConfigured(t *testing.T) { func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) { data := &Data{} - repo := &initializationRepo{data: data} - initialized, err := repo.IsInitialized(context.Background()) + initialized, err := data.IsInitialized(context.Background()) if err != nil { t.Fatalf("IsInitialized() error = %v", err) } @@ -46,7 +45,7 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) { } data.databaseReady.Store(true) - initialized, err = repo.IsInitialized(context.Background()) + initialized, err = data.IsInitialized(context.Background()) if err != nil { t.Fatalf("IsInitialized() error = %v", err) } diff --git a/internal/data/integration_config_test.go b/internal/data/integration_config_test.go index 629561d..8a8634d 100644 --- a/internal/data/integration_config_test.go +++ b/internal/data/integration_config_test.go @@ -267,8 +267,7 @@ func TestPersistRuntimeConfigReplacesActiveStorage(t *testing.T) { if err != nil { t.Fatal(err) } - repo := &initializationRepo{data: d} - if err = repo.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil { + if err = d.PersistRuntimeConfig(context.Background(), dataRaw, adminRaw); err != nil { t.Fatal(err) } diff --git a/internal/data/migration/runner.go b/internal/data/migration/runner.go index 9dd14cb..0e12f08 100644 --- a/internal/data/migration/runner.go +++ b/internal/data/migration/runner.go @@ -1,6 +1,6 @@ -// Package migration owns the application database migration runner. Schema -// steps remain in internal/data because they need that package's private POs; -// this package contains only the reusable versioning mechanism. +// Package migration owns the application database migration runner. Each data +// module declares its own steps; the root data package only orders and runs +// them. package migration import ( diff --git a/internal/data/migrations.go b/internal/data/migrations.go index 1f5ffc2..d69eda0 100644 --- a/internal/data/migrations.go +++ b/internal/data/migrations.go @@ -11,21 +11,13 @@ import ( // migrateAll is the single data-layer migration entry point. Module-specific // schema work stays with the module that owns its persistent objects. func migrateAll(db *gorm.DB) error { - return migration.Run(db, []migration.Step{ - {ID: "202608200001_baseline", Migrate: func(db *gorm.DB) error { - if err := datasystem.LegacySchemaMigration(db); err != nil { - return err - } - return datapayment.Migrate(db) - }}, - {ID: "202608200002_data_reconcile", Migrate: func(db *gorm.DB) error { - if err := datapayment.Reconcile(db); err != nil { - return err - } - return datasystem.CurrentDataMigration(db) - }}, - {ID: "202608200003_payment_admin_surface", Migrate: func(db *gorm.DB) error { - return datasystem.EnsureAdminSurface(db, datapayment.AdminSurface()) - }}, - }) + steps := []migration.Step{{ + ID: "202608200001_data_infrastructure", + Migrate: func(db *gorm.DB) error { + return db.AutoMigrate(&integrationConfigPO{}) + }, + }} + steps = append(steps, datasystem.Migrations()...) + steps = append(steps, datapayment.Migrations()...) + return migration.Run(db, steps) } diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go new file mode 100644 index 0000000..5ec3c97 --- /dev/null +++ b/internal/data/migrations_test.go @@ -0,0 +1,41 @@ +package data + +import ( + "testing" + + "kra/internal/data/migration" +) + +func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err = migrateAll(db); err != nil { + t.Fatal(err) + } + if err = migrateAll(db); err != nil { + t.Fatalf("second migration run: %v", err) + } + for _, table := range []string{"sys_integration_configs", "sys_users", "sys_base_menus", "pay_orders"} { + if !db.Migrator().HasTable(table) { + t.Fatalf("migration did not create %s", table) + } + } + var versions int64 + if err = db.Table(migration.TableName).Count(&versions).Error; err != nil { + t.Fatal(err) + } + if versions != 4 { + t.Fatalf("migration versions = %d, want 4", versions) + } + for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis"} { + var count int64 + if err = db.Table(table).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("migration seeded %s: rows=%d", table, count) + } + } +} diff --git a/pkg/pagination/pagination.go b/internal/data/pagination/pagination.go similarity index 89% rename from pkg/pagination/pagination.go rename to internal/data/pagination/pagination.go index 0552600..4e94289 100644 --- a/pkg/pagination/pagination.go +++ b/internal/data/pagination/pagination.go @@ -1,5 +1,5 @@ -// Package pagination contains database-agnostic page calculations shared by -// repository implementations. +// Package pagination contains GORM pagination helpers shared by repository +// implementations in the data layer. package pagination import "gorm.io/gorm" diff --git a/internal/data/payment/migrations.go b/internal/data/payment/migrations.go index 953ff52..a9f5145 100644 --- a/internal/data/payment/migrations.go +++ b/internal/data/payment/migrations.go @@ -1,31 +1,31 @@ package payment import ( - "kra/internal/data/system" + "kra/internal/adminsurface" + "kra/internal/data/migration" "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) +func Migrations() []migration.Step { + return []migration.Step{ + {ID: "202608200003_payment_schema", Migrate: func(db *gorm.DB) error { + return db.AutoMigrate(&paymentOrderPO{}) + }}, + {ID: "202608200004_payment_defaults", Migrate: ensurePaymentIntegrationConfigs}, + } } // 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{ +func AdminSurface() adminsurface.Surface { + return adminsurface.Surface{ + Menus: []adminsurface.Menu{ {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{ + APIs: []adminsurface.API{ {Path: "/payment/configs", Method: "GET", APIGroup: "支付", Description: "获取支付渠道配置"}, {Path: "/payment/config", Method: "POST", APIGroup: "支付", Description: "保存支付渠道配置"}, {Path: "/payment/orders", Method: "GET", APIGroup: "支付", Description: "分页查询支付订单"}, diff --git a/internal/data/payment/payment_helpers.go b/internal/data/payment/payment_helpers.go index c7e8b88..e490cca 100644 --- a/internal/data/payment/payment_helpers.go +++ b/internal/data/payment/payment_helpers.go @@ -1,6 +1,6 @@ package payment -import "kra/pkg/paymentkit" +import "kra/internal/utils/paymentutil" func text(values map[string]any, key string) string { value, _ := values[key].(string) @@ -17,7 +17,7 @@ func firstAny(values map[string]any, keys ...string) string { } // configuredInt64 keeps repository-side validation independent from the -// provider adapter package while sharing the canonical paymentkit parser. +// provider adapter package while sharing the canonical payment parser. func configuredInt64(values map[string]any, key string, fallback int64) int64 { - return paymentkit.ConfiguredInt64(values, key, fallback) + return paymentutil.ConfiguredInt64(values, key, fallback) } diff --git a/internal/data/payment/payment_order.go b/internal/data/payment/payment_order.go index 2308588..7813103 100644 --- a/internal/data/payment/payment_order.go +++ b/internal/data/payment/payment_order.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" ) type paymentOrderPO struct { diff --git a/internal/data/payment/testing_support_test.go b/internal/data/payment/testing_support_test.go index e8e714c..245efa6 100644 --- a/internal/data/payment/testing_support_test.go +++ b/internal/data/payment/testing_support_test.go @@ -6,7 +6,6 @@ import ( "github.com/glebarez/sqlite" "gorm.io/gorm" - "kra/internal/biz" ) type Data struct{ gormDB *reloadableDB } @@ -49,19 +48,13 @@ func openIntegrationConfigTestDB(t *testing.T) *gorm.DB { } func migrateAll(db *gorm.DB) error { - if err := db.AutoMigrate(&integrationConfigPO{}, &paymentOrderPO{}); err != nil { + if err := db.AutoMigrate(&integrationConfigPO{}); 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 { + for _, step := range Migrations() { + if err := step.Migrate(db); 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/system/announcement.go b/internal/data/system/announcement.go index c847bfd..8bcf1e1 100644 --- a/internal/data/system/announcement.go +++ b/internal/data/system/announcement.go @@ -6,8 +6,8 @@ import ( "time" "kra/internal/biz" - "kra/pkg/gormkit" - "kra/pkg/pagination" + "kra/internal/data/gormkit" + "kra/internal/data/pagination" "gorm.io/gorm" ) @@ -23,7 +23,7 @@ type announcementPO struct { Attachments gormkit.JSON } -func (announcementPO) TableName() string { return "kra_announcements_info" } +func (announcementPO) TableName() string { return "sys_announcements" } type announcementRepo struct{ data Provider } diff --git a/internal/data/system/api.go b/internal/data/system/api.go index 6a11bc8..64bb437 100644 --- a/internal/data/system/api.go +++ b/internal/data/system/api.go @@ -7,7 +7,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" "gorm.io/gorm" ) diff --git a/internal/data/system/api_token.go b/internal/data/system/api_token.go index 347f635..369662f 100644 --- a/internal/data/system/api_token.go +++ b/internal/data/system/api_token.go @@ -6,7 +6,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" "gorm.io/gorm" ) diff --git a/internal/data/system/bootstrap.go b/internal/data/system/bootstrap.go index b7a4cd5..0ddff95 100644 --- a/internal/data/system/bootstrap.go +++ b/internal/data/system/bootstrap.go @@ -10,31 +10,6 @@ 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/system/data_access_log.go b/internal/data/system/data_access_log.go index 5343458..d4681c9 100644 --- a/internal/data/system/data_access_log.go +++ b/internal/data/system/data_access_log.go @@ -5,7 +5,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" "gorm.io/gorm" ) diff --git a/internal/data/system/dictionary.go b/internal/data/system/dictionary.go index f0ffacd..e5420e9 100644 --- a/internal/data/system/dictionary.go +++ b/internal/data/system/dictionary.go @@ -8,7 +8,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" "gorm.io/gorm" ) diff --git a/internal/data/system/error_record.go b/internal/data/system/error_record.go index 9f9b909..049b4c1 100644 --- a/internal/data/system/error_record.go +++ b/internal/data/system/error_record.go @@ -5,7 +5,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" "gorm.io/gorm" ) diff --git a/internal/data/system/login_log.go b/internal/data/system/login_log.go index ff5ee9f..1b5199f 100644 --- a/internal/data/system/login_log.go +++ b/internal/data/system/login_log.go @@ -5,7 +5,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" "gorm.io/gorm" ) diff --git a/internal/data/system/migrations.go b/internal/data/system/migrations.go index a41c101..a716ee4 100644 --- a/internal/data/system/migrations.go +++ b/internal/data/system/migrations.go @@ -1,507 +1,30 @@ package system import ( - "errors" - "fmt" - "strings" - "time" + "kra/internal/data/migration" "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) +// Migrations returns schema steps owned by the built-in system module. +// Administrators, menus, APIs, and policies are created only by SeedSystem +// during the explicit first-install flow. +func Migrations() []migration.Step { + return []migration.Step{ + { + ID: "202608200002_system_schema", + Migrate: func(db *gorm.DB) error { + 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{}, + ) + }, + }, } } - -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/system/migrations_test.go b/internal/data/system/migrations_test.go index 79edd01..8ced4b9 100644 --- a/internal/data/system/migrations_test.go +++ b/internal/data/system/migrations_test.go @@ -1,265 +1,31 @@ package system -import ( - "testing" +import "testing" - "kra/internal/data/migration" -) - -func TestMigrateAllUsesVersionTableAndIsIdempotent(t *testing.T) { +func TestMigrationsCreateOnlySystemSchema(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 { - t.Fatal(err) - } - sqlDB, err := db.DB() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = sqlDB.Close() }) - - for _, statement := range []string{ - `CREATE TABLE sys_apis (id integer primary key, path text, method text)`, - `CREATE TABLE sys_authority_apis (authority_id integer, api_id integer)`, - `INSERT INTO sys_apis (id, path, method) VALUES (1, '/legacy', 'GET')`, - `INSERT INTO sys_authority_apis (authority_id, api_id) VALUES (888, 1)`, - } { - if err = db.Exec(statement).Error; err != nil { - t.Fatal(err) - } - } - if err = db.AutoMigrate(&casbinRulePO{}, &errorRecordPO{}); err != nil { - t.Fatal(err) - } - legacyError := errorRecordPO{Status: "未解决"} - if err = db.Create(&legacyError).Error; err != nil { - t.Fatal(err) - } - if err = migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil { - t.Fatalf("legacy migration failed without sys_apis.deleted_at: %v", err) - } - if err = normalizeErrorRecordStatuses(db); err != nil { - t.Fatalf("following migration inherited the legacy table alias: %v", err) - } - - var count int64 - if err = db.Model(&casbinRulePO{}). - Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", "888", "/legacy", "GET"). - Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("migrated policy count = %d, want 1", count) - } - if err = db.First(&legacyError, legacyError.ID).Error; err != nil { - t.Fatal(err) - } - if legacyError.Status != "未处理" { - t.Fatalf("normalized status = %q, want 未处理", legacyError.Status) - } -} - -func TestReconcileRootAuthorityAPIsUsesIndependentQueries(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(&authorityPO{}, &apiPO{}, &ignoredAPIPO{}, &casbinRulePO{}); err != nil { - t.Fatal(err) - } - if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root"}).Error; err != nil { - t.Fatal(err) - } - if err = db.Create(&apiPO{Path: "/allowed", Method: "GET"}).Error; err != nil { - t.Fatal(err) - } - if err = db.Create(&apiPO{Path: "/ignored", Method: "POST"}).Error; err != nil { - t.Fatal(err) - } - if err = db.Create(&ignoredAPIPO{Path: "/ignored", Method: "POST"}).Error; err != nil { - t.Fatal(err) - } - - // Deliberately pass a handle carrying an unrelated model and predicate. - // Migration queries must not inherit either state. - dirty := db.Model(&authorityPO{}).Where("authority_id = ?", 999) - if err = reconcileRootAuthorityAPIs(dirty); err != nil { - t.Fatalf("reconcile root policies with dirty DB state: %v", err) - } - - var rules []casbinRulePO - if err = db.Where("ptype = ? AND v0 = ?", "p", "888").Find(&rules).Error; err != nil { - t.Fatal(err) - } - if len(rules) != 1 || rules[0].V1 != "/allowed" || rules[0].V2 != "GET" { - t.Fatalf("root rules = %#v, want only GET /allowed", rules) - } -} - -func TestMigrateLegacyAuthorityDepartmentColumns(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() }) - - for _, statement := range []string{ - `CREATE TABLE sys_authority_departments (authority_id integer, department_id integer, PRIMARY KEY (authority_id, department_id))`, - `INSERT INTO sys_authority_departments (authority_id, department_id) VALUES (888, 7)`, - } { - if err = db.Exec(statement).Error; err != nil { - t.Fatal(err) - } - } - if err = migrateLegacyAuthorityDepartmentColumns(db); err != nil { - t.Fatalf("migrate legacy authority-department columns: %v", err) - } - if err = db.AutoMigrate(&authorityDepartmentPO{}); err != nil { - t.Fatal(err) - } - - var relation authorityDepartmentPO - if err = db.First(&relation).Error; err != nil { - t.Fatal(err) - } - if relation.AuthorityID != 888 || relation.DepartmentID != 7 { - t.Fatalf("migrated relation = %#v", relation) - } - for _, column := range []string{"sys_authority_authority_id", "sys_department_id"} { - if !tableHasColumn(db, "sys_authority_departments", column) { - t.Fatalf("missing compatible column %s", column) - } - } - hasPrimaryKey, err := tableHasPrimaryKey(db, "sys_authority_departments") - if err != nil { - t.Fatal(err) - } - if hasPrimaryKey { - t.Fatal("authority-department table retained the legacy primary key") - } - if err = db.Create(&authorityDepartmentPO{AuthorityID: 888, DepartmentID: 7}).Error; err != nil { - t.Fatalf("duplicate administration relation was rejected: %v", err) - } - var count int64 - if err = db.Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ? AND sys_department_id = ?", 888, 7).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 2 { - t.Fatalf("duplicate relation count = %d, want 2", count) + for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis", "sys_departments", "sys_announcements"} { + if !db.Migrator().HasTable(table) { + t.Fatalf("system migration did not create %s", table) + } + } + var users, menus, apis int64 + if err = db.Model(&userPO{}).Count(&users).Error; err != nil { + t.Fatal(err) + } + if err = db.Model(&menuPO{}).Count(&menus).Error; err != nil { + t.Fatal(err) + } + if err = db.Model(&apiPO{}).Count(&apis).Error; err != nil { + t.Fatal(err) + } + if users != 0 || menus != 0 || apis != 0 { + t.Fatalf("schema migration seeded business data: users=%d menus=%d apis=%d", users, menus, apis) } } diff --git a/internal/data/system/operation_log.go b/internal/data/system/operation_log.go index 05e5af1..2cb7fdf 100644 --- a/internal/data/system/operation_log.go +++ b/internal/data/system/operation_log.go @@ -5,7 +5,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" "gorm.io/gorm" ) diff --git a/internal/data/system/parameter.go b/internal/data/system/parameter.go index 88fc24b..742598a 100644 --- a/internal/data/system/parameter.go +++ b/internal/data/system/parameter.go @@ -5,7 +5,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/internal/data/system/runtime.go b/internal/data/system/runtime.go index 35705e2..f97613c 100644 --- a/internal/data/system/runtime.go +++ b/internal/data/system/runtime.go @@ -8,7 +8,7 @@ import ( "kra/internal/biz" "kra/internal/conf" - "kra/pkg/adminauth" + "kra/internal/security/adminauth" jwt "github.com/golang-jwt/jwt/v5" ) diff --git a/internal/data/system/seed.go b/internal/data/system/seed.go index a1d9bca..8e4c83b 100644 --- a/internal/data/system/seed.go +++ b/internal/data/system/seed.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "kra/internal/adminsurface" "kra/internal/biz" "github.com/google/uuid" @@ -12,7 +13,7 @@ import ( "gorm.io/gorm" ) -func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) error { +func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig, surfaces ...adminsurface.Surface) 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"} @@ -28,6 +29,11 @@ func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) err return err } } + for _, surface := range surfaces { + if err := seedAdminSurface(tx, surface); err != nil { + return err + } + } var persisted []menuPO if err := tx.Order("sort asc, id asc").Find(&persisted).Error; err != nil { return err @@ -140,6 +146,47 @@ func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) err }) } +func seedAdminSurface(tx *gorm.DB, surface adminsurface.Surface) error { + 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 { + 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 + } + if err := tx.Where("name = ?", item.Name).FirstOrCreate(&menu).Error; err != nil { + return err + } + } + for _, item := range surface.APIs { + api := apiPO{ + Path: item.Path, + Method: strings.ToUpper(item.Method), + APIGroup: item.APIGroup, + Description: item.Description, + } + if err := tx.Where("path = ? AND method = ?", api.Path, api.Method).FirstOrCreate(&api).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} diff --git a/internal/data/system/seed_test.go b/internal/data/system/seed_test.go new file mode 100644 index 0000000..1d88421 --- /dev/null +++ b/internal/data/system/seed_test.go @@ -0,0 +1,56 @@ +package system + +import ( + "context" + "testing" + + "kra/internal/adminsurface" + "kra/internal/biz" +) + +func TestSeedSystemCreatesInitialDataAndModuleSurface(t *testing.T) { + db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared") + if err != nil { + t.Fatal(err) + } + if err = migrateAll(db); err != nil { + t.Fatal(err) + } + surface := adminsurface.Surface{ + Menus: []adminsurface.Menu{{Name: "orders", Path: "orders", ParentName: "extensions", Component: "view/orders.vue", Title: "订单", Sort: 6}}, + APIs: []adminsurface.API{{Path: "/orders", Method: "GET", APIGroup: "订单", Description: "订单列表"}}, + } + input := &biz.DatabaseConfig{AdminPassword: "admin-password", APIs: []*biz.API{{Path: "/healthz", Method: "GET", APIGroup: "系统"}}} + if err = SeedSystem(context.Background(), db, input, surface); err != nil { + t.Fatal(err) + } + var admin userPO + if err = db.Where("username = ?", "admin").First(&admin).Error; err != nil { + t.Fatal(err) + } + var menu menuPO + if err = db.Where("name = ?", "orders").First(&menu).Error; err != nil { + t.Fatal(err) + } + var parent menuPO + if err = db.Where("name = ?", "extensions").First(&parent).Error; err != nil { + t.Fatal(err) + } + if menu.ParentID != parent.ID { + t.Fatalf("module menu parent = %d, want %d", menu.ParentID, parent.ID) + } + var links int64 + if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, menu.ID).Count(&links).Error; err != nil { + t.Fatal(err) + } + if links != 1 { + t.Fatalf("root menu links = %d, want 1", links) + } + var policies int64 + if err = policyScope(db).Where("v0 = ? AND v1 = ? AND v2 = ?", "888", "/orders", "GET").Count(&policies).Error; err != nil { + t.Fatal(err) + } + if policies != 1 { + t.Fatalf("root policies = %d, want 1", policies) + } +} diff --git a/internal/data/system/task.go b/internal/data/system/task.go index 2290943..62f846f 100644 --- a/internal/data/system/task.go +++ b/internal/data/system/task.go @@ -3,8 +3,8 @@ package system import ( "context" "kra/internal/biz" - "kra/pkg/gormkit" - "kra/pkg/pagination" + "kra/internal/data/gormkit" + "kra/internal/data/pagination" "time" "gorm.io/gorm" diff --git a/internal/data/system/testing_support_test.go b/internal/data/system/testing_support_test.go index 244349e..8c8a311 100644 --- a/internal/data/system/testing_support_test.go +++ b/internal/data/system/testing_support_test.go @@ -8,7 +8,6 @@ import ( "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 @@ -69,8 +68,10 @@ func openWithDriver(driver, dsn string) (*gorm.DB, error) { } func migrateAll(db *gorm.DB) error { - return migration.Run(db, []migration.Step{ - {ID: "test_baseline", Migrate: LegacySchemaMigration}, - {ID: "test_reconcile", Migrate: CurrentDataMigration}, - }) + for _, step := range Migrations() { + if err := step.Migrate(db); err != nil { + return err + } + } + return nil } diff --git a/internal/data/system/user.go b/internal/data/system/user.go index dce38c7..ca2d5b4 100644 --- a/internal/data/system/user.go +++ b/internal/data/system/user.go @@ -9,7 +9,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" "github.com/google/uuid" "gorm.io/gorm" diff --git a/internal/data/system/version.go b/internal/data/system/version.go index 72b0d4e..8cc46ad 100644 --- a/internal/data/system/version.go +++ b/internal/data/system/version.go @@ -7,7 +7,7 @@ import ( "gorm.io/gorm" "kra/internal/biz" - "kra/pkg/pagination" + "kra/internal/data/pagination" ) type versionPO struct { diff --git a/internal/initialize/README.md b/internal/initialize/README.md new file mode 100644 index 0000000..a43626c --- /dev/null +++ b/internal/initialize/README.md @@ -0,0 +1,22 @@ +# Initialization + +`internal/initialize` owns application bootstrap orchestration. It implements +`biz.InitializationRepo`, coordinates the explicit first-install workflow, and +formats the compatible runtime configuration API. + +The package depends on a narrow `Backend` interface. `*data.Data` implements +that interface and remains responsible for opening, migrating, activating, +reloading, and persisting database-backed runtime infrastructure. + +The first-install order is: + +1. `data` opens the candidate database. +2. The single root migration runner applies infrastructure, system, and + payment schema steps. +3. `initialize` invokes `system.SeedSystem` for administrator, menu, API, + department, policy, and module administration-surface records. +4. `data` persists configuration and activates the candidate clients. + +Schema migration and business seed data are deliberately separate. Normal +startup and configuration reload may run migrations, but only the explicit +first-install endpoint runs seed data. diff --git a/internal/initialize/backend.go b/internal/initialize/backend.go new file mode 100644 index 0000000..821cbf1 --- /dev/null +++ b/internal/initialize/backend.go @@ -0,0 +1,24 @@ +package initialize + +import ( + "context" + + "kra/internal/biz" + "kra/internal/conf" + + "gorm.io/gorm" +) + +// Backend is the infrastructure boundary required by application +// initialization and runtime configuration management. +type Backend interface { + IsInitialized(context.Context) (bool, error) + InitializeDatabase(context.Context, *biz.DatabaseConfig, func(context.Context, *gorm.DB) error) error + PersistConfig(context.Context) error + PersistAdminConfig(context.Context, []byte) error + PersistRuntimeConfig(context.Context, []byte, []byte) error + ReloadConfig(context.Context) error + RuntimeValues() (*conf.Data, *conf.AdminBackend) + RuntimeAdmin() *conf.AdminBackend + RefreshDatabaseSources(*conf.Data) error +} diff --git a/internal/data/admin_config_compat.go b/internal/initialize/compatibility.go similarity index 99% rename from internal/data/admin_config_compat.go rename to internal/initialize/compatibility.go index 36db040..94bee24 100644 --- a/internal/data/admin_config_compat.go +++ b/internal/initialize/compatibility.go @@ -1,4 +1,4 @@ -package data +package initialize import ( "encoding/json" diff --git a/internal/data/config_management.go b/internal/initialize/configuration.go similarity index 74% rename from internal/data/config_management.go rename to internal/initialize/configuration.go index 798c0dc..42cef3e 100644 --- a/internal/data/config_management.go +++ b/internal/initialize/configuration.go @@ -1,11 +1,11 @@ -package data +package initialize import ( "context" "encoding/json" - "strings" "kra/internal/conf" + "kra/internal/utils/configutil" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" @@ -27,8 +27,8 @@ type configurationEnvelope struct { } `json:"email"` } -func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { - dataConfig, adminConfig := r.data.runtime.Values() +func (r *Repo) ConfigurationJSON() (json.RawMessage, error) { + dataConfig, adminConfig := r.backend.RuntimeValues() admin := map[string]any{"routerPrefix": ""} email := map[string]any{} if adminConfig != nil { @@ -121,8 +121,8 @@ func (r *initializationRepo) ConfigurationJSON() (json.RawMessage, error) { return json.Marshal(map[string]any{"config": config}) } -func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error { - currentData, currentAdmin := r.data.runtime.Values() +func (r *Repo) SaveConfigurationJSON(ctx context.Context, raw json.RawMessage) error { + currentData, currentAdmin := r.backend.RuntimeValues() if currentAdmin == nil { return nil } @@ -137,12 +137,12 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json } options := protojson.UnmarshalOptions{DiscardUnknown: true} if len(value.Data) > 0 && string(value.Data) != "null" { - if err := mergeConfigurationMessage(nextData, value.Data, options); err != nil { + if err := configutil.MergeProtoJSON(nextData, value.Data, options); err != nil { return err } } if len(value.Admin) > 0 && string(value.Admin) != "null" { - if err := mergeConfigurationMessage(nextAdmin, value.Admin, options); err != nil { + if err := configutil.MergeProtoJSON(nextAdmin, value.Admin, options); err != nil { return err } } @@ -159,7 +159,7 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json nextAdmin.Email.Secret = value.Email.Secret } } - if err := refreshDatabaseSources(nextData); err != nil { + if err := r.backend.RefreshDatabaseSources(nextData); err != nil { return err } nextAdmin.ConfigPath = currentAdmin.ConfigPath @@ -174,8 +174,8 @@ func (r *initializationRepo) SaveConfigurationJSON(ctx context.Context, raw json return r.PersistRuntimeConfig(ctx, dataRaw, adminRaw) } -func (r *initializationRepo) DiskMountPoints() []string { - config := r.data.runtime.Admin() +func (r *Repo) DiskMountPoints() []string { + config := r.backend.RuntimeAdmin() if config == nil { return nil } @@ -202,120 +202,6 @@ func cloneAdminConfig(value *conf.AdminBackend) *conf.AdminBackend { return proto.Clone(value).(*conf.AdminBackend) } -func refreshDatabaseSources(value *conf.Data) error { - if value == nil { - return nil - } - if err := refreshDatabaseSource(value.Database); err != nil { - return err - } - for _, database := range value.DatabaseList { - if database == nil || database.Disable { - continue - } - if err := refreshDatabaseSource(database); err != nil { - return err - } - } - return nil -} - -func refreshDatabaseSource(database *conf.Data_Database) error { - if database == nil { - return nil - } - hasStructuredConfig := database.Host != "" || database.Port != "" || database.User != "" || - database.Password != "" || database.Name != "" || database.Config != "" || database.Path != "" - if !hasStructuredConfig { - // A source-only configuration is an intentional escape hatch for custom - // driver DSNs; do not reinterpret it as the structured form. - return nil - } - previousSource := database.Source - database.Source = "" - source, err := databaseDSN(database, "") - if err != nil { - database.Source = previousSource - return err - } - database.Source = source - return nil -} - -// mergeConfigurationMessage applies a JSON object as a partial update while -// retaining fields that were not sent by the caller. protojson.Unmarshal is -// intentionally not used directly here because its reset semantics would -// clear unrelated configuration sections when the management page submits a -// partial object. -func mergeConfigurationMessage(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error { - currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target) - if err != nil { - return err - } - var current map[string]any - if err = json.Unmarshal(currentRaw, ¤t); err != nil { - return err - } - var incoming map[string]any - if err = json.Unmarshal(patch, &incoming); err != nil { - return err - } - incoming = normalizeJSONKeys(incoming).(map[string]any) - mergeJSONObjects(current, incoming) - merged, err := json.Marshal(current) - if err != nil { - return err - } - return options.Unmarshal(merged, target) -} - -func normalizeJSONKeys(value any) any { - switch item := value.(type) { - case map[string]any: - result := make(map[string]any, len(item)) - for key, nested := range item { - result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested) - } - return result - case []any: - result := make([]any, len(item)) - for index, nested := range item { - result[index] = normalizeJSONKeys(nested) - } - return result - default: - return value - } -} - -func snakeToLowerCamel(value string) string { - if !strings.Contains(value, "_") { - return value - } - parts := strings.Split(value, "_") - result := parts[0] - for _, part := range parts[1:] { - if part == "" { - continue - } - result += strings.ToUpper(part[:1]) + part[1:] - } - return result -} - -func mergeJSONObjects(target, patch map[string]any) { - for key, value := range patch { - incoming, isObject := value.(map[string]any) - if isObject { - if existing, ok := target[key].(map[string]any); ok { - mergeJSONObjects(existing, incoming) - continue - } - } - target[key] = value - } -} - func durationString(value *durationpb.Duration) string { if value == nil { return "0s" diff --git a/internal/initialize/initialize.go b/internal/initialize/initialize.go new file mode 100644 index 0000000..b4d7ca2 --- /dev/null +++ b/internal/initialize/initialize.go @@ -0,0 +1,43 @@ +package initialize + +import ( + "context" + + "kra/internal/biz" + datapayment "kra/internal/data/payment" + datasystem "kra/internal/data/system" + + "gorm.io/gorm" +) + +type Repo struct{ backend Backend } + +func NewRepo(backend Backend) biz.InitializationRepo { + return &Repo{backend: backend} +} + +func (r *Repo) IsInitialized(ctx context.Context) (bool, error) { + return r.backend.IsInitialized(ctx) +} + +func (r *Repo) Initialize(ctx context.Context, input *biz.DatabaseConfig) error { + return r.backend.InitializeDatabase(ctx, input, func(ctx context.Context, db *gorm.DB) error { + return datasystem.SeedSystem(ctx, db, input, datapayment.AdminSurface()) + }) +} + +func (r *Repo) PersistConfig(ctx context.Context) error { + return r.backend.PersistConfig(ctx) +} + +func (r *Repo) PersistAdminConfig(ctx context.Context, value []byte) error { + return r.backend.PersistAdminConfig(ctx, value) +} + +func (r *Repo) PersistRuntimeConfig(ctx context.Context, data, admin []byte) error { + return r.backend.PersistRuntimeConfig(ctx, data, admin) +} + +func (r *Repo) ReloadConfig(ctx context.Context) error { + return r.backend.ReloadConfig(ctx) +} diff --git a/internal/initialize/provider.go b/internal/initialize/provider.go new file mode 100644 index 0000000..826308d --- /dev/null +++ b/internal/initialize/provider.go @@ -0,0 +1,5 @@ +package initialize + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRepo) diff --git a/internal/integration/README.md b/internal/integration/README.md new file mode 100644 index 0000000..5c7c546 --- /dev/null +++ b/internal/integration/README.md @@ -0,0 +1,20 @@ +# Integrations + +`internal/integration` contains adapters that talk to systems outside the +application process. They may open sockets, create SDK clients, keep reloadable +state, or translate provider-specific protocols into `biz` interfaces. + +## Packages + +- `cache`: Redis-backed cache with an in-memory fallback. +- `email`: SMTP email repository. +- `payment`: payment-channel SDKs and callback/signature handling. +- `storage`: local and object-storage implementations of `biz.FileStorage`. + +These packages are not `utils`: they perform I/O and own external dependency +lifecycles. Their constructors are exposed through `ProviderSet`; `cmd` binds +the `cache.RedisProvider` implementation to the shared `data.Data` container. + +Stateless protocol helpers that do not own clients live in +`internal/utils/paymentutil`. They are intentionally small and dependency +light, while provider adapters remain here. diff --git a/internal/integration/payment/gopay_test.go b/internal/integration/payment/gopay_test.go index 2887248..c3e92f8 100644 --- a/internal/integration/payment/gopay_test.go +++ b/internal/integration/payment/gopay_test.go @@ -21,7 +21,7 @@ import ( "testing" "kra/internal/biz" - "kra/pkg/paymentkit" + "kra/internal/utils/paymentutil" gopayAlipay "github.com/go-pay/gopay/alipay" gopayDouyin "github.com/go-pay/gopay/douyin" @@ -177,7 +177,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) { t.Errorf("read request: %v", err) return } - requestValues, err = paymentkit.XMLValues(body) + requestValues, err = paymentutil.XMLValues(body) if err != nil { t.Errorf("parse request: %v", err) return @@ -190,7 +190,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) { } response := map[string]string{"return_code": "SUCCESS", "return_msg": "OK", "result_code": "SUCCESS", "prepay_id": "prepay"} response["sign"] = wechatV2Sign(response, key, "MD5") - _, _ = w.Write(paymentkit.XMLEncode(response)) + _, _ = w.Write(paymentutil.XMLEncode(response)) })) defer server.Close() @@ -219,7 +219,7 @@ func TestWechatV2GoPayCallbackVerificationAndSandboxAmount(t *testing.T) { if callbackValues["sign"] != originalSign { t.Fatal("verification mutated source values") } - callbackBody := paymentkit.XMLEncode(callbackValues) + callbackBody := paymentutil.XMLEncode(callbackValues) callbackResult, err := (&wechatV2Adapter{}).Callback(context.Background(), &biz.PaymentCallback{Body: callbackBody}, map[string]any{ "app_id": "wx-test", "merchant_id": "mch-test", "mch_key": key, }) @@ -243,7 +243,7 @@ func TestWechatV2MicropayUsesGoPaySDKAndValidatesIdentities(t *testing.T) { t.Errorf("read request: %v", err) return } - requestValues, err = paymentkit.XMLValues(body) + requestValues, err = paymentutil.XMLValues(body) if err != nil { t.Errorf("parse request: %v", err) return @@ -263,7 +263,7 @@ func TestWechatV2MicropayUsesGoPaySDKAndValidatesIdentities(t *testing.T) { "transaction_id": "WX-TRANSACTION-1", "total_fee": "123", "cash_fee": "123", "fee_type": "CNY", } response["sign"] = wechatV2Sign(response, key, "MD5") - _, _ = w.Write(paymentkit.XMLEncode(response)) + _, _ = w.Write(paymentutil.XMLEncode(response)) })) defer server.Close() @@ -344,7 +344,7 @@ func TestWechatV2MicropayRejectsInvalidSuccessResponse(t *testing.T) { } tc.mutate(response) response["sign"] = wechatV2Sign(response, key, "MD5") - _, _ = w.Write(paymentkit.XMLEncode(response)) + _, _ = w.Write(paymentutil.XMLEncode(response)) })) defer server.Close() diff --git a/internal/integration/payment/qq_test.go b/internal/integration/payment/qq_test.go index 8a3b3df..52abcc9 100644 --- a/internal/integration/payment/qq_test.go +++ b/internal/integration/payment/qq_test.go @@ -12,7 +12,7 @@ import ( "testing" "kra/internal/biz" - "kra/pkg/paymentkit" + "kra/internal/utils/paymentutil" "github.com/go-pay/gopay" gopayQQ "github.com/go-pay/gopay/qq" @@ -55,7 +55,7 @@ func TestQQCreateKeepsClientPaymentDataOutOfDurableIdentities(t *testing.T) { http.Error(w, "invalid XML", http.StatusBadRequest) return } - values, err := paymentkit.XMLValues(body) + values, err := paymentutil.XMLValues(body) if err != nil { t.Errorf("parse request XML: %v", err) http.Error(w, "invalid XML", http.StatusBadRequest) @@ -81,7 +81,7 @@ func TestQQCreateKeepsClientPaymentDataOutOfDurableIdentities(t *testing.T) { for key, value := range response { encoded[key] = fmt.Sprint(value) } - _, _ = w.Write(paymentkit.XMLEncode(encoded)) + _, _ = w.Write(paymentutil.XMLEncode(encoded)) })) result, err := (&qqAdapter{}).Create(context.Background(), &biz.PaymentRequest{ diff --git a/internal/integration/payment/result.go b/internal/integration/payment/result.go index c349b6c..cc15560 100644 --- a/internal/integration/payment/result.go +++ b/internal/integration/payment/result.go @@ -8,7 +8,7 @@ import ( "strings" "kra/internal/biz" - "kra/pkg/paymentkit" + "kra/internal/utils/paymentutil" ) func callbackFields(callback *biz.PaymentCallback) map[string]string { @@ -70,49 +70,49 @@ func firstAny(values map[string]any, keys ...string) string { } // Compatibility shims keep channel adapters focused on protocol and -// persistence concerns while stateless parsing rules live in paymentkit. +// persistence concerns while stateless parsing rules live in paymentutil. func normalizePaymentStatus(value, fallback string) string { - return paymentkit.NormalizeStatus(value, fallback) + return paymentutil.NormalizeStatus(value, fallback) } func parseIntegerAmount(value string) (int64, error) { - return paymentkit.ParseIntegerAmount(value) + return paymentutil.ParseIntegerAmount(value) } func parseDecimalAmount(value string, scale int64) (int64, error) { - return paymentkit.ParseDecimalAmount(value, scale) + return paymentutil.ParseDecimalAmount(value, scale) } func formatDecimalAmount(amount, scale int64) (string, error) { - return paymentkit.FormatDecimalAmount(amount, scale) + return paymentutil.FormatDecimalAmount(amount, scale) } func configuredInt64(values map[string]any, key string, fallback int64) int64 { - return paymentkit.ConfiguredInt64(values, key, fallback) + return paymentutil.ConfiguredInt64(values, key, fallback) } func jsonObject(raw []byte) map[string]any { - return paymentkit.JSONObject(raw) + return paymentutil.JSONObject(raw) } func nestedString(value any, keys ...string) string { - return paymentkit.NestedString(value, keys...) + return paymentutil.NestedString(value, keys...) } func valueAtPath(value any, path string) any { - return paymentkit.ValueAtPath(value, path) + return paymentutil.ValueAtPath(value, path) } func stringAtPath(value any, path string) string { - return paymentkit.StringAtPath(value, path) + return paymentutil.StringAtPath(value, path) } func configuredValues(values map[string]any, key string) []string { - return paymentkit.ConfiguredValues(values, key) + return paymentutil.ConfiguredValues(values, key) } func containsFold(values []string, value string) bool { - return paymentkit.ContainsFold(values, value) + return paymentutil.ContainsFold(values, value) } // parseConfiguredAmount reads a provider amount field using an explicit unit diff --git a/internal/integration/payment/vendor.go b/internal/integration/payment/vendor.go index 75c0780..77d07a4 100644 --- a/internal/integration/payment/vendor.go +++ b/internal/integration/payment/vendor.go @@ -15,7 +15,7 @@ import ( "time" "kra/internal/biz" - "kra/pkg/paymentkit" + "kra/internal/utils/paymentutil" ) type vendorProfile string @@ -244,18 +244,18 @@ func (a *vendorPaymentAdapter) signRequest(request *http.Request, payload map[st digest := sha256.Sum256([]byte(appID + timestamp + nonceValue + string(raw) + secret)) request.Header.Set("Authorization", "OPEN-BODY-SIG AppId="+appID+", Timestamp="+timestamp+", Nonce="+nonceValue+", Signature="+hex.EncodeToString(digest[:])) case vendorSFT: - request.Header.Set("X-SFT-Sign", paymentkit.MD5Canonical(payload, secret)) + request.Header.Set("X-SFT-Sign", paymentutil.MD5Canonical(payload, secret)) case vendorWechatGame: - request.Header.Set("X-Wechat-Game-Sign", paymentkit.HMACSHA256Hex(raw, secret, false)) + request.Header.Set("X-Wechat-Game-Sign", paymentutil.HMACSHA256Hex(raw, secret, false)) if token := text(c, "access_token"); token != "" { query := request.URL.Query() query.Set("access_token", token) request.URL.RawQuery = query.Encode() } case vendorDouyinGame: - request.Header.Set("X-TT-Pay-Sign", paymentkit.MD5Canonical(payload, secret)) + request.Header.Set("X-TT-Pay-Sign", paymentutil.MD5Canonical(payload, secret)) default: - request.Header.Set("X-Payment-Sign", paymentkit.HMACSHA256Hex(raw, secret, false)) + request.Header.Set("X-Payment-Sign", paymentutil.HMACSHA256Hex(raw, secret, false)) } } func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, headers map[string]string, c map[string]any) error { @@ -298,9 +298,9 @@ func (a *vendorPaymentAdapter) verify(fields map[string]string, raw []byte, head values[key] = value } } - actual = paymentkit.MD5Canonical(values, secret) + actual = paymentutil.MD5Canonical(values, secret) default: - actual = paymentkit.HMACSHA256Hex(raw, secret, false) + actual = paymentutil.HMACSHA256Hex(raw, secret, false) } if !hmac.Equal([]byte(strings.ToLower(expected)), []byte(strings.ToLower(actual))) { return errors.New("支付回调签名校验失败") diff --git a/internal/integration/provider.go b/internal/integration/provider.go new file mode 100644 index 0000000..bffaa4a --- /dev/null +++ b/internal/integration/provider.go @@ -0,0 +1,20 @@ +// Package integration contains adapters for external systems and runtime +// clients. These constructors are kept out of the persistence provider set so +// the data package does not also act as the integration composition root. +package integration + +import ( + "kra/internal/biz" + "kra/internal/integration/cache" + "kra/internal/integration/email" + "kra/internal/integration/storage" + + "github.com/google/wire" +) + +var ProviderSet = wire.NewSet( + cache.New, + email.NewEmailRepo, + storage.NewFileStorage, + wire.Bind(new(biz.FileStorage), new(*storage.Reloadable)), +) diff --git a/pkg/osskit/compose.go b/internal/integration/storage/compose.go similarity index 92% rename from pkg/osskit/compose.go rename to internal/integration/storage/compose.go index 42a9ad2..ab63aca 100644 --- a/pkg/osskit/compose.go +++ b/internal/integration/storage/compose.go @@ -1,4 +1,4 @@ -package osskit +package storage import ( "context" @@ -7,10 +7,10 @@ import ( "io" ) -// ComposeStreams concatenates objects into destination while calculating the +// composeStreams concatenates objects into destination while calculating the // MD5 of the exact bytes written. Provider-specific storage clients stay out // of this package; callbacks keep this helper independent from biz and SDKs. -func ComposeStreams( +func composeStreams( ctx context.Context, names []string, open func(context.Context, string) (io.ReadCloser, error), diff --git a/pkg/osskit/compose_test.go b/internal/integration/storage/compose_test.go similarity index 89% rename from pkg/osskit/compose_test.go rename to internal/integration/storage/compose_test.go index 19a943c..8659073 100644 --- a/pkg/osskit/compose_test.go +++ b/internal/integration/storage/compose_test.go @@ -1,4 +1,4 @@ -package osskit +package storage import ( "context" @@ -13,7 +13,7 @@ import ( func TestComposeStreams(t *testing.T) { objects := map[string]string{"a": "hello ", "b": "world"} var stored string - hash, err := ComposeStreams(context.Background(), []string{"a", "b"}, func(_ context.Context, name string) (io.ReadCloser, error) { + hash, err := composeStreams(context.Background(), []string{"a", "b"}, func(_ context.Context, name string) (io.ReadCloser, error) { value, ok := objects[name] if !ok { return nil, errors.New("missing object") @@ -35,7 +35,7 @@ func TestComposeStreams(t *testing.T) { func TestComposeStreamsRemovesPartialDestination(t *testing.T) { removed := false - _, err := ComposeStreams(context.Background(), []string{"missing"}, func(context.Context, string) (io.ReadCloser, error) { + _, err := composeStreams(context.Background(), []string{"missing"}, func(context.Context, string) (io.ReadCloser, error) { return nil, errors.New("open failed") }, func(_ context.Context, _ string, reader io.Reader) error { _, _ = io.ReadAll(reader) diff --git a/internal/integration/storage/local.go b/internal/integration/storage/local.go index dc04edf..7d83314 100644 --- a/internal/integration/storage/local.go +++ b/internal/integration/storage/local.go @@ -12,7 +12,6 @@ import ( "kra/internal/biz" "kra/internal/conf" - "kra/pkg/osskit" ) type fileStorage struct { @@ -66,7 +65,7 @@ func New(config *conf.AdminBackend) (biz.FileStorage, error) { func composeFiles(ctx context.Context, storage biz.FileStorage, names []string, destination string) (*biz.StoredFile, string, error) { var stored *biz.StoredFile - hash, err := osskit.ComposeStreams(ctx, names, storage.Open, func(ctx context.Context, destination string, reader io.Reader) error { + hash, err := composeStreams(ctx, names, storage.Open, func(ctx context.Context, destination string, reader io.Reader) error { var putErr error stored, putErr = storage.Put(ctx, destination, reader) return putErr diff --git a/pkg/logging/context.go b/internal/logging/context.go similarity index 100% rename from pkg/logging/context.go rename to internal/logging/context.go diff --git a/pkg/logging/daily.go b/internal/logging/daily.go similarity index 100% rename from pkg/logging/daily.go rename to internal/logging/daily.go diff --git a/pkg/logging/source.go b/internal/logging/source.go similarity index 99% rename from pkg/logging/source.go rename to internal/logging/source.go index dc7eb6b..0b8e17c 100644 --- a/pkg/logging/source.go +++ b/internal/logging/source.go @@ -50,7 +50,7 @@ func skipStackFile(filename string) bool { "/go/pkg/mod/", "/go.uber.org/", "/gorm.io/", - "/pkg/logging/", + "/internal/logging/", "/internal/server/middleware/", "/internal/server/router/", } { diff --git a/pkg/logging/zap.go b/internal/logging/zap.go similarity index 99% rename from pkg/logging/zap.go rename to internal/logging/zap.go index 6613d55..3bedc66 100644 --- a/pkg/logging/zap.go +++ b/internal/logging/zap.go @@ -25,7 +25,7 @@ type Options struct { } // ErrorEntry is the storage-neutral representation of an Error-level log. -// Keeping it in pkg/logging lets the log core report failures without taking a +// Keeping it in internal/logging lets the log core report failures without taking a // dependency on the application service or persistence layers. type ErrorEntry struct { Form, Info, Level, RequestID, TraceID string diff --git a/pkg/logging/zap_test.go b/internal/logging/zap_test.go similarity index 100% rename from pkg/logging/zap_test.go rename to internal/logging/zap_test.go diff --git a/internal/security/README.md b/internal/security/README.md new file mode 100644 index 0000000..a092ac9 --- /dev/null +++ b/internal/security/README.md @@ -0,0 +1,6 @@ +# Security + +Security mechanisms used by the application live here. `adminauth` contains +the admin JWT claims, signing, parsing, and normalized token errors. It has no +HTTP or persistence dependency, but it is still application security code and +therefore does not belong in a generic utility package. diff --git a/pkg/adminauth/token.go b/internal/security/adminauth/token.go similarity index 100% rename from pkg/adminauth/token.go rename to internal/security/adminauth/token.go diff --git a/internal/server/middleware/request.go b/internal/server/middleware/request.go index dbc90c2..4002ec1 100644 --- a/internal/server/middleware/request.go +++ b/internal/server/middleware/request.go @@ -5,7 +5,7 @@ import ( "encoding/hex" "strings" - "kra/pkg/logging" + "kra/internal/logging" "github.com/gin-gonic/gin" "github.com/google/uuid" diff --git a/internal/utils/README.md b/internal/utils/README.md new file mode 100644 index 0000000..9285c7d --- /dev/null +++ b/internal/utils/README.md @@ -0,0 +1,11 @@ +# Utilities + +Utilities are pure or near-pure helpers with no database, network client, +runtime watcher, or business repository state. + +- `configutil`: partial JSON/Proto merge helpers used by configuration APIs. +- `paymentutil`: payment amount, status, XML/JSON, and signing helpers shared + by payment adapters. + +Do not place Redis, SMTP, object storage, payment SDK clients, or GORM repos in +this directory. Those belong to `internal/integration` or `internal/data`. diff --git a/internal/utils/configutil/json.go b/internal/utils/configutil/json.go new file mode 100644 index 0000000..38bd3f7 --- /dev/null +++ b/internal/utils/configutil/json.go @@ -0,0 +1,78 @@ +package configutil + +import ( + "encoding/json" + "strings" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// MergeProtoJSON applies a JSON object as a partial protobuf update while +// retaining fields omitted by the caller. +func MergeProtoJSON(target proto.Message, patch json.RawMessage, options protojson.UnmarshalOptions) error { + currentRaw, err := protojson.MarshalOptions{UseProtoNames: false}.Marshal(target) + if err != nil { + return err + } + var current map[string]any + if err = json.Unmarshal(currentRaw, ¤t); err != nil { + return err + } + var incoming map[string]any + if err = json.Unmarshal(patch, &incoming); err != nil { + return err + } + incoming = normalizeJSONKeys(incoming).(map[string]any) + mergeJSONObjects(current, incoming) + merged, err := json.Marshal(current) + if err != nil { + return err + } + return options.Unmarshal(merged, target) +} + +func normalizeJSONKeys(value any) any { + switch item := value.(type) { + case map[string]any: + result := make(map[string]any, len(item)) + for key, nested := range item { + result[snakeToLowerCamel(key)] = normalizeJSONKeys(nested) + } + return result + case []any: + result := make([]any, len(item)) + for index, nested := range item { + result[index] = normalizeJSONKeys(nested) + } + return result + default: + return value + } +} + +func snakeToLowerCamel(value string) string { + if !strings.Contains(value, "_") { + return value + } + parts := strings.Split(value, "_") + result := parts[0] + for _, part := range parts[1:] { + if part != "" { + result += strings.ToUpper(part[:1]) + part[1:] + } + } + return result +} + +func mergeJSONObjects(target, patch map[string]any) { + for key, value := range patch { + if incoming, ok := value.(map[string]any); ok { + if existing, exists := target[key].(map[string]any); exists { + mergeJSONObjects(existing, incoming) + continue + } + } + target[key] = value + } +} diff --git a/internal/utils/configutil/json_test.go b/internal/utils/configutil/json_test.go new file mode 100644 index 0000000..f19035d --- /dev/null +++ b/internal/utils/configutil/json_test.go @@ -0,0 +1,28 @@ +package configutil + +import ( + "encoding/json" + "testing" + + "kra/internal/conf" + + "google.golang.org/protobuf/encoding/protojson" +) + +func TestMergeProtoJSONPreservesOmittedFieldsAndNormalizesKeys(t *testing.T) { + target := &conf.AdminBackend{ + RouterPrefix: "/api", + System: &conf.AdminBackend_System{UseRedis: true, IplimitCount: 3}, + } + patch := json.RawMessage(`{"router_prefix":"/admin","system":{"iplimit_count":9}}`) + + if err := MergeProtoJSON(target, patch, protojson.UnmarshalOptions{DiscardUnknown: true}); err != nil { + t.Fatal(err) + } + if target.RouterPrefix != "/admin" || target.System.IplimitCount != 9 { + t.Fatalf("patch was not applied: %#v", target) + } + if !target.System.UseRedis { + t.Fatal("an omitted nested field was cleared") + } +} diff --git a/internal/utils/paymentutil/README.md b/internal/utils/paymentutil/README.md new file mode 100644 index 0000000..8459592 --- /dev/null +++ b/internal/utils/paymentutil/README.md @@ -0,0 +1,5 @@ +# Payment Utilities + +Stateless payment protocol helpers live here: amount conversion, status +normalization, JSON/XML extraction, and request signing. Provider adapters and +their SDK clients remain under `internal/integration/payment`. diff --git a/pkg/paymentkit/amount.go b/internal/utils/paymentutil/amount.go similarity index 99% rename from pkg/paymentkit/amount.go rename to internal/utils/paymentutil/amount.go index 0f71ac5..180fe74 100644 --- a/pkg/paymentkit/amount.go +++ b/internal/utils/paymentutil/amount.go @@ -1,4 +1,4 @@ -package paymentkit +package paymentutil import ( "errors" diff --git a/pkg/paymentkit/amount_test.go b/internal/utils/paymentutil/amount_test.go similarity index 97% rename from pkg/paymentkit/amount_test.go rename to internal/utils/paymentutil/amount_test.go index 5697861..1d82d33 100644 --- a/pkg/paymentkit/amount_test.go +++ b/internal/utils/paymentutil/amount_test.go @@ -1,4 +1,4 @@ -package paymentkit +package paymentutil import "testing" diff --git a/pkg/paymentkit/json.go b/internal/utils/paymentutil/json.go similarity index 98% rename from pkg/paymentkit/json.go rename to internal/utils/paymentutil/json.go index 98bdf0e..c31d6bc 100644 --- a/pkg/paymentkit/json.go +++ b/internal/utils/paymentutil/json.go @@ -1,4 +1,4 @@ -package paymentkit +package paymentutil import ( "encoding/json" diff --git a/pkg/paymentkit/json_test.go b/internal/utils/paymentutil/json_test.go similarity index 95% rename from pkg/paymentkit/json_test.go rename to internal/utils/paymentutil/json_test.go index fcb0bc8..34f46e2 100644 --- a/pkg/paymentkit/json_test.go +++ b/internal/utils/paymentutil/json_test.go @@ -1,4 +1,4 @@ -package paymentkit +package paymentutil import "testing" diff --git a/pkg/paymentkit/signing.go b/internal/utils/paymentutil/signing.go similarity index 98% rename from pkg/paymentkit/signing.go rename to internal/utils/paymentutil/signing.go index 0abb32e..7ba4b15 100644 --- a/pkg/paymentkit/signing.go +++ b/internal/utils/paymentutil/signing.go @@ -1,4 +1,4 @@ -package paymentkit +package paymentutil import ( "crypto/hmac" diff --git a/pkg/paymentkit/signing_test.go b/internal/utils/paymentutil/signing_test.go similarity index 94% rename from pkg/paymentkit/signing_test.go rename to internal/utils/paymentutil/signing_test.go index 18793a2..a85e011 100644 --- a/pkg/paymentkit/signing_test.go +++ b/internal/utils/paymentutil/signing_test.go @@ -1,4 +1,4 @@ -package paymentkit +package paymentutil import "testing" diff --git a/pkg/paymentkit/status.go b/internal/utils/paymentutil/status.go similarity index 98% rename from pkg/paymentkit/status.go rename to internal/utils/paymentutil/status.go index 8108b30..12b6030 100644 --- a/pkg/paymentkit/status.go +++ b/internal/utils/paymentutil/status.go @@ -1,4 +1,4 @@ -package paymentkit +package paymentutil import ( "fmt" diff --git a/pkg/paymentkit/xml.go b/internal/utils/paymentutil/xml.go similarity index 98% rename from pkg/paymentkit/xml.go rename to internal/utils/paymentutil/xml.go index 60dd4fd..6c46733 100644 --- a/pkg/paymentkit/xml.go +++ b/internal/utils/paymentutil/xml.go @@ -1,4 +1,4 @@ -package paymentkit +package paymentutil import ( "bytes"